@platformatic/watt-extra 0.1.0
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 +87 -0
- package/app.js +124 -0
- package/cli.js +141 -0
- package/clients/compliance/compliance-types.d.ts +887 -0
- package/clients/compliance/compliance.mjs +1049 -0
- package/clients/compliance/compliance.openapi.json +6127 -0
- package/clients/control-plane/control-plane-types.d.ts +2696 -0
- package/clients/control-plane/control-plane.mjs +3051 -0
- package/clients/control-plane/control-plane.openapi.json +13693 -0
- package/clients/cron/cron-types.d.ts +1479 -0
- package/clients/cron/cron.mjs +872 -0
- package/clients/cron/cron.openapi.json +9330 -0
- package/compliance/index.js +21 -0
- package/compliance/rules/dependencies.js +76 -0
- package/compliance/rules/utils.js +12 -0
- package/eslint.config.js +11 -0
- package/help/start.txt +12 -0
- package/help/watt-extra.txt +12 -0
- package/index.js +45 -0
- package/lib/banner.js +22 -0
- package/lib/errors.js +34 -0
- package/lib/utils.js +34 -0
- package/lib/wattpro.js +580 -0
- package/package.json +50 -0
- package/plugins/alerts.js +115 -0
- package/plugins/auth.js +89 -0
- package/plugins/compliancy.js +70 -0
- package/plugins/env.js +58 -0
- package/plugins/flamegraphs.js +100 -0
- package/plugins/init.js +70 -0
- package/plugins/metadata.js +84 -0
- package/plugins/scheduler.js +48 -0
- package/plugins/update.js +128 -0
- package/renovate.json +6 -0
- package/test/alerts.test.js +607 -0
- package/test/auth.test.js +128 -0
- package/test/auto-cache.test.js +401 -0
- package/test/cli.test.js +75 -0
- package/test/compliancy.test.js +87 -0
- package/test/fixtures/runtime-domains/alpha/package.json +5 -0
- package/test/fixtures/runtime-domains/alpha/platformatic.json +6 -0
- package/test/fixtures/runtime-domains/alpha/plugin.js +16 -0
- package/test/fixtures/runtime-domains/beta/package.json +5 -0
- package/test/fixtures/runtime-domains/beta/platformatic.json +6 -0
- package/test/fixtures/runtime-domains/beta/plugin.js +7 -0
- package/test/fixtures/runtime-domains/composer/package.json +5 -0
- package/test/fixtures/runtime-domains/composer/platformatic.json +19 -0
- package/test/fixtures/runtime-domains/package.json +1 -0
- package/test/fixtures/runtime-domains/platformatic.json +27 -0
- package/test/fixtures/runtime-health/package.json +20 -0
- package/test/fixtures/runtime-health/platformatic.json +16 -0
- package/test/fixtures/runtime-health/services/service-1/package.json +17 -0
- package/test/fixtures/runtime-health/services/service-1/platformatic.json +16 -0
- package/test/fixtures/runtime-health/services/service-1/plugins/example.js +6 -0
- package/test/fixtures/runtime-health/services/service-1/routes/root.cjs +8 -0
- package/test/fixtures/runtime-health/services/service-2/package.json +17 -0
- package/test/fixtures/runtime-health/services/service-2/platformatic.json +16 -0
- package/test/fixtures/runtime-health/services/service-2/plugins/example.js +6 -0
- package/test/fixtures/runtime-health/services/service-2/routes/root.cjs +8 -0
- package/test/fixtures/runtime-next/package.json +5 -0
- package/test/fixtures/runtime-next/platformatic.json +9 -0
- package/test/fixtures/runtime-next/web/next/next.config.js +2 -0
- package/test/fixtures/runtime-next/web/next/package.json +7 -0
- package/test/fixtures/runtime-next/web/next/platformatic.json +9 -0
- package/test/fixtures/runtime-next/web/next/src/app/direct/route.js +3 -0
- package/test/fixtures/runtime-next/web/next/src/app/layout.jsx +7 -0
- package/test/fixtures/runtime-next/web/next/src/app/page.jsx +3 -0
- package/test/fixtures/runtime-scheduler/main/package.json +5 -0
- package/test/fixtures/runtime-scheduler/main/platformatic.json +9 -0
- package/test/fixtures/runtime-scheduler/main/routes/root.cjs +11 -0
- package/test/fixtures/runtime-scheduler/package.json +1 -0
- package/test/fixtures/runtime-scheduler/platformatic.json +27 -0
- package/test/fixtures/runtime-service/main/package.json +5 -0
- package/test/fixtures/runtime-service/main/platformatic.json +12 -0
- package/test/fixtures/runtime-service/main/routes/root.cjs +11 -0
- package/test/fixtures/runtime-service/package.json +1 -0
- package/test/fixtures/runtime-service/platformatic.json +19 -0
- package/test/fixtures/service-1/package.json +7 -0
- package/test/fixtures/service-1/platformatic.json +18 -0
- package/test/fixtures/service-1/routes/root.cjs +48 -0
- package/test/fixtures/service-2/platformatic.json +21 -0
- package/test/fixtures/service-2/routes/root.cjs +5 -0
- package/test/fixtures/service-3/package.json +5 -0
- package/test/fixtures/service-3/platformatic.json +21 -0
- package/test/fixtures/service-3/routes/root.cjs +8 -0
- package/test/health.test.js +44 -0
- package/test/helper.js +274 -0
- package/test/init.test.js +243 -0
- package/test/patch-config.test.js +434 -0
- package/test/scheduler.test.js +71 -0
- package/test/send-to-icc-retry.test.js +138 -0
- package/test/shared-context.test.js +82 -0
- package/test/spawn.test.js +110 -0
- package/test/trigger-flamegraphs.test.js +226 -0
- package/test/update.test.js +519 -0
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { request } from 'undici'
|
|
2
|
+
|
|
3
|
+
async function alerts (app, _opts) {
|
|
4
|
+
const healthCache = [] // It's OK to have this in memory, this is per-pod.
|
|
5
|
+
const podHealthWindow =
|
|
6
|
+
app.instanceConfig?.config?.scaler?.podHealthWindow || 60 * 1000
|
|
7
|
+
const alertRetentionWindow =
|
|
8
|
+
app.instanceConfig?.config?.scaler?.alertRetentionWindow || 30 * 1000
|
|
9
|
+
|
|
10
|
+
let lastAlertTime = 0
|
|
11
|
+
async function setupAlerts () {
|
|
12
|
+
// Skip alerts setup if ICC is not configured
|
|
13
|
+
if (!app.env.PLT_ICC_URL) {
|
|
14
|
+
app.log.info('PLT_ICC_URL not set, skipping alerts setup')
|
|
15
|
+
return
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const scalerUrl = app.instanceConfig?.iccServices?.scaler?.url
|
|
19
|
+
const runtime = app.wattpro.runtime
|
|
20
|
+
|
|
21
|
+
if (!scalerUrl) {
|
|
22
|
+
app.log.warn(
|
|
23
|
+
'No scaler URL found in ICC services, health alerts disabled'
|
|
24
|
+
)
|
|
25
|
+
return
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
runtime.on('health', async (healthInfo) => {
|
|
29
|
+
if (!healthInfo) {
|
|
30
|
+
app.log.error('No health info received')
|
|
31
|
+
return
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const timestamp = Date.now()
|
|
35
|
+
const healthWithTimestamp = { ...healthInfo, timestamp }
|
|
36
|
+
delete healthWithTimestamp.healthConfig // we don't need to store this
|
|
37
|
+
|
|
38
|
+
healthCache.push(healthWithTimestamp)
|
|
39
|
+
|
|
40
|
+
const cutoffTime = timestamp - podHealthWindow
|
|
41
|
+
const validIndex = healthCache.findIndex(
|
|
42
|
+
(entry) => entry.timestamp >= cutoffTime
|
|
43
|
+
)
|
|
44
|
+
if (validIndex > 0) {
|
|
45
|
+
healthCache.splice(0, validIndex)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// healthInfo is an object with the following structure:
|
|
49
|
+
// id: "service-1"
|
|
50
|
+
// service: "service-1"
|
|
51
|
+
// currentHealth: {
|
|
52
|
+
// "elu": 0.003816403352054066,
|
|
53
|
+
// "heapUsed": 76798040,
|
|
54
|
+
// "heapTotal": 99721216
|
|
55
|
+
// }
|
|
56
|
+
// unhealthy: false
|
|
57
|
+
// healthConfig: {
|
|
58
|
+
// "enabled": true,
|
|
59
|
+
// "interval": 1000,
|
|
60
|
+
// "gracePeriod": 1000,
|
|
61
|
+
// "maxUnhealthyChecks": 10,
|
|
62
|
+
// "maxELU": 0.99,
|
|
63
|
+
// "maxHeapUsed": 0.99,
|
|
64
|
+
// "maxHeapTotal": 4294967296
|
|
65
|
+
// }
|
|
66
|
+
|
|
67
|
+
if (healthInfo.unhealthy) {
|
|
68
|
+
const currentTime = Date.now()
|
|
69
|
+
if (currentTime - lastAlertTime < alertRetentionWindow) {
|
|
70
|
+
app.log.debug('Skipping alert, within retention window')
|
|
71
|
+
return
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
lastAlertTime = currentTime
|
|
75
|
+
delete healthInfo.healthConfig
|
|
76
|
+
|
|
77
|
+
const authHeaders = await app.getAuthorizationHeader()
|
|
78
|
+
|
|
79
|
+
const { statusCode, body } = await request(`${scalerUrl}/alerts`, {
|
|
80
|
+
method: 'POST',
|
|
81
|
+
headers: {
|
|
82
|
+
'Content-Type': 'application/json',
|
|
83
|
+
...authHeaders,
|
|
84
|
+
},
|
|
85
|
+
body: JSON.stringify({
|
|
86
|
+
applicationId: app.instanceConfig?.applicationId,
|
|
87
|
+
alert: healthInfo,
|
|
88
|
+
healthHistory: healthCache,
|
|
89
|
+
}),
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
if (statusCode !== 200) {
|
|
93
|
+
const error = await body.text()
|
|
94
|
+
app.log.error({ error }, 'Failed to send alert to scaler')
|
|
95
|
+
return
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const alert = await body.json()
|
|
99
|
+
const serviceId = healthInfo.id
|
|
100
|
+
|
|
101
|
+
try {
|
|
102
|
+
await app.sendFlamegraphs({
|
|
103
|
+
serviceIds: [serviceId],
|
|
104
|
+
alertId: alert.id
|
|
105
|
+
})
|
|
106
|
+
} catch (err) {
|
|
107
|
+
app.log.error({ err }, 'Failed to send a flamegraph')
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
})
|
|
111
|
+
}
|
|
112
|
+
app.setupAlerts = setupAlerts
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export default alerts
|
package/plugins/auth.js
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { readFile, stat } from 'node:fs/promises'
|
|
2
|
+
import { Agent } from 'undici'
|
|
3
|
+
|
|
4
|
+
const K8S_TOKEN_PATH = '/var/run/secrets/kubernetes.io/serviceaccount/token'
|
|
5
|
+
|
|
6
|
+
function decodeJwtPayload (token) {
|
|
7
|
+
try {
|
|
8
|
+
if (!token) return null
|
|
9
|
+
const base64Payload = token.split('.')[1]
|
|
10
|
+
if (!base64Payload) return null
|
|
11
|
+
const payload = Buffer.from(base64Payload, 'base64').toString('utf8')
|
|
12
|
+
return JSON.parse(payload)
|
|
13
|
+
} catch (err) {
|
|
14
|
+
return null
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function isTokenExpired (token, offset = 0) {
|
|
19
|
+
const payload = decodeJwtPayload(token)
|
|
20
|
+
if (!payload || !payload.exp) return true
|
|
21
|
+
|
|
22
|
+
// Check if token is expired
|
|
23
|
+
const currentTime = Math.floor(Date.now() / 1000)
|
|
24
|
+
return payload.exp <= (currentTime + offset)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function authPlugin (app) {
|
|
28
|
+
// Add a 1 min offset to update the token before it expires
|
|
29
|
+
// via runtime shared context
|
|
30
|
+
const offset = parseInt(process.env.PLT_JWT_EXPIRATION_OFFSET_SEC ?? 0)
|
|
31
|
+
|
|
32
|
+
async function loadToken () {
|
|
33
|
+
let token
|
|
34
|
+
try {
|
|
35
|
+
await stat(K8S_TOKEN_PATH)
|
|
36
|
+
app.log.info('Loading JWT token from K8s service account')
|
|
37
|
+
token = await readFile(K8S_TOKEN_PATH, 'utf8')
|
|
38
|
+
} catch (err) {
|
|
39
|
+
app.log.warn('Failed to load JWT token from K8s service account')
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (!token) {
|
|
43
|
+
app.log.warn('K8s token not found, falling back to environment variable')
|
|
44
|
+
token = process.env.PLT_TEST_TOKEN
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return token
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const getAuthorizationHeader = async (headers = {}) => {
|
|
51
|
+
if (isTokenExpired(app.token, offset)) {
|
|
52
|
+
app.log.info('JWT token expired, reloading')
|
|
53
|
+
app.token = await loadToken()
|
|
54
|
+
|
|
55
|
+
app.wattpro?.updateSharedContext({
|
|
56
|
+
iccAuthHeaders: { authorization: `Bearer ${app.token}` }
|
|
57
|
+
}).catch((err) => {
|
|
58
|
+
app.log.error({ err }, 'Failed to update jwt token in shared context')
|
|
59
|
+
})
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
...headers,
|
|
64
|
+
authorization: `Bearer ${app.token}`
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const authorizationTokenInterceptor = dispatch => {
|
|
69
|
+
return async function InterceptedDispatch (opts, handler) {
|
|
70
|
+
opts.headers = await getAuthorizationHeader(opts.headers)
|
|
71
|
+
return dispatch(opts, handler)
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
app.token = await loadToken()
|
|
76
|
+
|
|
77
|
+
await setInterval(async () => {
|
|
78
|
+
// Check if token is expired to propagate it to the runtime
|
|
79
|
+
// via the shared context
|
|
80
|
+
await getAuthorizationHeader()
|
|
81
|
+
}, offset * 1000 / 2).unref()
|
|
82
|
+
|
|
83
|
+
// We cannot change the global dispatcher because it's shared with the runtime main thread.
|
|
84
|
+
const wattDispatcher = new Agent()
|
|
85
|
+
app.dispatcher = wattDispatcher.compose(authorizationTokenInterceptor)
|
|
86
|
+
app.getAuthorizationHeader = getAuthorizationHeader
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export default authPlugin
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { getCompliancyMetadata } from '../compliance/index.js'
|
|
2
|
+
import {
|
|
3
|
+
CompliancyMetadataError,
|
|
4
|
+
CompliancyStatusError
|
|
5
|
+
} from '../lib/errors.js'
|
|
6
|
+
|
|
7
|
+
async function compliancy (app, _opts) {
|
|
8
|
+
async function checkCompliancy () {
|
|
9
|
+
if (app.env.PLT_DISABLE_COMPLIANCE_CHECK === true) return
|
|
10
|
+
|
|
11
|
+
// Skip compliance check if ICC is not configured
|
|
12
|
+
if (!app.env.PLT_ICC_URL) {
|
|
13
|
+
app.log.info('PLT_ICC_URL not set, skipping compliance check')
|
|
14
|
+
return
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const runtime = app.wattpro.runtime
|
|
18
|
+
const applicationId = app.instanceConfig?.applicationId
|
|
19
|
+
const appDir = app.env.PLT_APP_DIR
|
|
20
|
+
|
|
21
|
+
const { default: build, setDefaultHeaders } = await import('../clients/compliance/compliance.mjs')
|
|
22
|
+
const complianceUrl = app.instanceConfig?.iccServices?.compliance?.url
|
|
23
|
+
|
|
24
|
+
if (!complianceUrl) {
|
|
25
|
+
app.log.warn('No compliance URL found in ICC services')
|
|
26
|
+
return
|
|
27
|
+
}
|
|
28
|
+
const compliancyClient = build(complianceUrl)
|
|
29
|
+
|
|
30
|
+
// There is a better way? We need to set the default headers for the client
|
|
31
|
+
// every time, because the token might be expired
|
|
32
|
+
// And we cannot set the global dispatcher because it's shared with the runtime main thread.
|
|
33
|
+
setDefaultHeaders(await app.getAuthorizationHeader())
|
|
34
|
+
const compliancyMetadata = await getCompliancyMetadata({
|
|
35
|
+
projectDir: appDir,
|
|
36
|
+
runtime
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
{
|
|
40
|
+
const res = await compliancyClient.postMetadata({
|
|
41
|
+
applicationId,
|
|
42
|
+
data: compliancyMetadata
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
if (res.statusCode !== 200 && res.statusCode !== 201) {
|
|
46
|
+
app.log.error(res, 'Failed to send compliancy metadata')
|
|
47
|
+
throw new CompliancyMetadataError()
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
app.log.info('Compliancy metadata sent')
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
{
|
|
54
|
+
const res = await compliancyClient.postCompliance({ applicationId })
|
|
55
|
+
if (res.statusCode !== 200) {
|
|
56
|
+
app.log.error(res, 'Failed to get compliance status')
|
|
57
|
+
throw new CompliancyStatusError()
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const { compliant, report } = JSON.parse(res.body)
|
|
61
|
+
|
|
62
|
+
if (!compliant) {
|
|
63
|
+
app.log.error(report, 'Compliancy check failed')
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
app.checkCompliancy = checkCompliancy
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export default compliancy
|
package/plugins/env.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import envSchema from 'env-schema'
|
|
2
|
+
|
|
3
|
+
const schema = {
|
|
4
|
+
type: 'object',
|
|
5
|
+
required: [],
|
|
6
|
+
properties: {
|
|
7
|
+
PLT_APP_NAME: { type: 'string' },
|
|
8
|
+
PLT_ICC_URL: { type: 'string' },
|
|
9
|
+
PLT_APP_DIR: { type: 'string' },
|
|
10
|
+
PLT_TEST_TOKEN: { type: 'string' }, // JWT token for authentication when not in K8s
|
|
11
|
+
PLT_APP_HOSTNAME: { type: 'string', default: '' },
|
|
12
|
+
PLT_APP_PORT: { type: 'number' },
|
|
13
|
+
PLT_METRICS_PORT: { type: 'number', default: 9090 },
|
|
14
|
+
PLT_LOG_LEVEL: { type: 'string', default: 'info' },
|
|
15
|
+
PLT_ICC_RETRY_TIME: { type: 'number', default: 5000 },
|
|
16
|
+
PLT_DISABLE_COMPLIANCE_CHECK: { type: 'boolean', default: false },
|
|
17
|
+
PLT_APP_INTERNAL_SUB_DOMAIN: { type: 'string', default: 'plt.local' },
|
|
18
|
+
PLT_DEFAULT_CACHE_TAGS_HEADER: { type: 'string', default: 'x-plt-cache-tags' },
|
|
19
|
+
PLT_CACHE_CONFIG: { type: 'string' },
|
|
20
|
+
PLT_DISABLE_FLAMEGRAPHS: { type: 'boolean', default: false },
|
|
21
|
+
PLT_FLAMEGRAPHS_INTERVAL_SEC: { type: 'number', default: 60 },
|
|
22
|
+
PLT_JWT_EXPIRATION_OFFSET_SEC: { type: 'number', default: 60 },
|
|
23
|
+
PLT_UPDATES_RECONNECT_INTERVAL_SEC: { type: 'number', default: 1 }
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Loads the config calling ICC and populates the env variables,
|
|
28
|
+
// using default to allow the app to start without ICC
|
|
29
|
+
const getDefaultEnv = (iccUrl) => {
|
|
30
|
+
const defaults = {
|
|
31
|
+
PLT_APP_DIR: process.cwd()
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (iccUrl) {
|
|
35
|
+
defaults.PLT_CONTROL_PLANE_URL = `${iccUrl}/control-plane`
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return defaults
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function envPlugin (app) {
|
|
42
|
+
const env = envSchema({
|
|
43
|
+
schema,
|
|
44
|
+
dotenv: true
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
const iccURL = env.PLT_ICC_URL
|
|
48
|
+
const defaultEnv = getDefaultEnv(iccURL)
|
|
49
|
+
|
|
50
|
+
app.env = {
|
|
51
|
+
...defaultEnv,
|
|
52
|
+
...env
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
app.log.info('Environment variables set up')
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export default envPlugin
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
import { request } from 'undici'
|
|
4
|
+
|
|
5
|
+
async function flamegraphs (app, _opts) {
|
|
6
|
+
const isFlamegraphsDisabled = app.env.PLT_DISABLE_FLAMEGRAPHS
|
|
7
|
+
const flamegraphsIntervalSec = app.env.PLT_FLAMEGRAPHS_INTERVAL_SEC
|
|
8
|
+
|
|
9
|
+
const durationMillis = parseInt(flamegraphsIntervalSec) * 1000
|
|
10
|
+
|
|
11
|
+
app.setupFlamegraphs = async () => {
|
|
12
|
+
if (isFlamegraphsDisabled) {
|
|
13
|
+
app.log.info('PLT_DISABLE_FLAMEGRAPHS is set, skipping profiling')
|
|
14
|
+
return
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
app.log.info('Start profiling services')
|
|
18
|
+
|
|
19
|
+
const runtime = app.wattpro.runtime
|
|
20
|
+
const { applications } = await runtime.getApplications()
|
|
21
|
+
|
|
22
|
+
const promises = []
|
|
23
|
+
for (const application of applications) {
|
|
24
|
+
const promise = runtime.sendCommandToApplication(
|
|
25
|
+
application.id,
|
|
26
|
+
'startProfiling',
|
|
27
|
+
{ durationMillis }
|
|
28
|
+
)
|
|
29
|
+
promises.push(promise)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const results = await Promise.allSettled(promises)
|
|
33
|
+
for (const result of results) {
|
|
34
|
+
if (result.status === 'rejected') {
|
|
35
|
+
app.log.error({ result }, 'Failed to start profiling')
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
app.sendFlamegraphs = async (options = {}) => {
|
|
41
|
+
if (isFlamegraphsDisabled) {
|
|
42
|
+
app.log.info('PLT_DISABLE_FLAMEGRAPHS is set, flamegraphs are disabled')
|
|
43
|
+
return
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
let { serviceIds, alertId } = options
|
|
47
|
+
|
|
48
|
+
const scalerUrl = app.instanceConfig?.iccServices?.scaler?.url
|
|
49
|
+
if (!scalerUrl) {
|
|
50
|
+
app.log.error('No scaler URL found in ICC services, cannot send flamegraph')
|
|
51
|
+
throw new Error('No scaler URL found in ICC services, cannot send flamegraph')
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const podId = app.instanceId
|
|
55
|
+
const runtime = app.wattpro.runtime
|
|
56
|
+
|
|
57
|
+
if (!serviceIds) {
|
|
58
|
+
const { applications } = await runtime.getApplications()
|
|
59
|
+
serviceIds = applications.map(app => app.id)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const authHeaders = await app.getAuthorizationHeader()
|
|
63
|
+
|
|
64
|
+
const uploadPromises = serviceIds.map(async (serviceId) => {
|
|
65
|
+
try {
|
|
66
|
+
const profile = await runtime.sendCommandToApplication(serviceId, 'getLastProfile')
|
|
67
|
+
if (!profile || !(profile instanceof Uint8Array)) {
|
|
68
|
+
app.log.error({ serviceId }, 'Failed to get profile from service')
|
|
69
|
+
return
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const url = `${scalerUrl}/pods/${podId}/services/${serviceId}/flamegraph`
|
|
73
|
+
|
|
74
|
+
app.log.info({ serviceId, podId }, 'Sending flamegraph')
|
|
75
|
+
|
|
76
|
+
const { statusCode, body } = await request(url, {
|
|
77
|
+
method: 'POST',
|
|
78
|
+
headers: {
|
|
79
|
+
'Content-Type': 'application/octet-stream',
|
|
80
|
+
...authHeaders
|
|
81
|
+
},
|
|
82
|
+
query: alertId ? { alertId } : {},
|
|
83
|
+
body: profile
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
if (statusCode !== 200) {
|
|
87
|
+
const error = await body.text()
|
|
88
|
+
app.log.error({ error }, 'Failed to send flamegraph')
|
|
89
|
+
throw new Error(`Failed to send flamegraph: ${error}`)
|
|
90
|
+
}
|
|
91
|
+
} catch (err) {
|
|
92
|
+
app.log.warn({ err, serviceId, podId }, 'Failed to send flamegraph from service')
|
|
93
|
+
}
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
await Promise.all(uploadPromises)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export default flamegraphs
|
package/plugins/init.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import WattPro from '../lib/wattpro.js'
|
|
2
|
+
import os from 'node:os'
|
|
3
|
+
|
|
4
|
+
async function initPlugin (app) {
|
|
5
|
+
async function initApplicationInstance (podId, applicationName = null) {
|
|
6
|
+
const { default: build, setDefaultHeaders } = await import('../clients/control-plane/control-plane.mjs')
|
|
7
|
+
const controlPlaneClient = build(app.env.PLT_CONTROL_PLANE_URL)
|
|
8
|
+
// There is a better way? We need to set the default headers for the client
|
|
9
|
+
// every time, because the token might be expired
|
|
10
|
+
// And we cannot set the global dispatcher because it's shared with the runtime main thread.
|
|
11
|
+
setDefaultHeaders(await app.getAuthorizationHeader())
|
|
12
|
+
const request = { podId }
|
|
13
|
+
if (applicationName) {
|
|
14
|
+
request.applicationName = applicationName
|
|
15
|
+
}
|
|
16
|
+
return controlPlaneClient.initApplicationInstance(request)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function initApplication () {
|
|
20
|
+
app.log.info('Starting WattPro runtime manager')
|
|
21
|
+
|
|
22
|
+
let applicationName = app.env.PLT_APP_NAME
|
|
23
|
+
const applicationDir = app.env.PLT_APP_DIR
|
|
24
|
+
const instanceId = os.hostname()
|
|
25
|
+
|
|
26
|
+
app.log.info({ applicationName, applicationDir }, 'Loading wattpro application')
|
|
27
|
+
|
|
28
|
+
// Skip ICC initialization if PLT_ICC_URL is not set
|
|
29
|
+
if (!app.env.PLT_ICC_URL) {
|
|
30
|
+
app.log.info('PLT_ICC_URL not set, skipping ICC initialization')
|
|
31
|
+
app.applicationName = applicationName
|
|
32
|
+
app.instanceConfig = null
|
|
33
|
+
app.instanceId = instanceId
|
|
34
|
+
return
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const instanceConfig = await initApplicationInstance(instanceId, applicationName)
|
|
38
|
+
app.log.info({ applicationId: instanceConfig.applicationId }, 'Got application info')
|
|
39
|
+
|
|
40
|
+
// Use the application name from the ICC response if not provided
|
|
41
|
+
applicationName = applicationName || instanceConfig.applicationName
|
|
42
|
+
app.log.info({ applicationName }, 'Application name resolved')
|
|
43
|
+
|
|
44
|
+
app.applicationName = applicationName
|
|
45
|
+
app.instanceConfig = instanceConfig
|
|
46
|
+
app.instanceId = instanceId
|
|
47
|
+
}
|
|
48
|
+
try {
|
|
49
|
+
await initApplication()
|
|
50
|
+
} catch (err) {
|
|
51
|
+
// We don't re-throw here because we can continue without application info
|
|
52
|
+
// and nothing here should block the app from start
|
|
53
|
+
app.log.error(err, 'Failed to get application information')
|
|
54
|
+
|
|
55
|
+
// Set fallback values when ICC connection fails
|
|
56
|
+
if (!app.applicationName) {
|
|
57
|
+
app.applicationName = app.env.PLT_APP_NAME
|
|
58
|
+
app.instanceConfig = null
|
|
59
|
+
app.instanceId = os.hostname()
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
const wattpro = new WattPro(app)
|
|
63
|
+
app.wattpro = wattpro
|
|
64
|
+
app.initApplication = initApplication
|
|
65
|
+
|
|
66
|
+
const headers = await app.getAuthorizationHeader()
|
|
67
|
+
await app.wattpro.updateSharedContext({ iccAuthHeaders: headers })
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export default initPlugin
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import {
|
|
2
|
+
MetadataRuntimeError,
|
|
3
|
+
MetadataError,
|
|
4
|
+
MetadataStateError,
|
|
5
|
+
MetadataAppIdError,
|
|
6
|
+
MetadataRuntimeNotStartedError
|
|
7
|
+
} from '../lib/errors.js'
|
|
8
|
+
|
|
9
|
+
async function metadata (app, _opts) {
|
|
10
|
+
async function sendMetadata () {
|
|
11
|
+
// Skip metadata processing if ICC is not configured
|
|
12
|
+
if (!app.env.PLT_ICC_URL) {
|
|
13
|
+
app.log.info('PLT_ICC_URL not set, skipping metadata processing')
|
|
14
|
+
return
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const applicationId = app.instanceConfig?.applicationId
|
|
18
|
+
const runtime = app.wattpro.runtime
|
|
19
|
+
if (!applicationId) {
|
|
20
|
+
app.log.warn('Cannot process metadata: no applicationId available')
|
|
21
|
+
throw new MetadataAppIdError()
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (!runtime) {
|
|
25
|
+
app.log.warn('Cannot process metadata: runtime not started')
|
|
26
|
+
throw new MetadataRuntimeNotStartedError()
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
const { default: build, setDefaultHeaders } = await import('../clients/control-plane/control-plane.mjs')
|
|
31
|
+
const controlPlaneClient = build(app.env.PLT_CONTROL_PLANE_URL)
|
|
32
|
+
|
|
33
|
+
try {
|
|
34
|
+
const [runtimeConfig, runtimeMetadata] = await Promise.all([
|
|
35
|
+
runtime.getRuntimeConfig(),
|
|
36
|
+
runtime.getRuntimeMetadata()
|
|
37
|
+
])
|
|
38
|
+
|
|
39
|
+
const applications = await Promise.all(
|
|
40
|
+
runtimeConfig.applications.map((application) =>
|
|
41
|
+
runtime.getApplicationDetails(application.id)
|
|
42
|
+
)
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
// There is a better way? We need to set the default headers for the client
|
|
47
|
+
// every time, because the token might be expired
|
|
48
|
+
// And we cannot set the global dispatcher because it's shared with the runtime main thread.
|
|
49
|
+
setDefaultHeaders(await app.getAuthorizationHeader())
|
|
50
|
+
await controlPlaneClient.saveApplicationInstanceState({
|
|
51
|
+
id: app.instanceId,
|
|
52
|
+
applications,
|
|
53
|
+
metadata: runtimeMetadata
|
|
54
|
+
}, {
|
|
55
|
+
headers: await app.getAuthorizationHeader()
|
|
56
|
+
})
|
|
57
|
+
} catch (error) {
|
|
58
|
+
app.log.error('Failed to save application state to Control Plane', error)
|
|
59
|
+
throw new MetadataStateError()
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
app.log.info('Runtime metadata processed')
|
|
63
|
+
} catch (error) {
|
|
64
|
+
if (error.code === 'PLT_METADATA_STATE_ERROR') {
|
|
65
|
+
throw error
|
|
66
|
+
}
|
|
67
|
+
app.log.error(error, 'Failed in getting and processing runtime metadata')
|
|
68
|
+
throw new MetadataRuntimeError()
|
|
69
|
+
}
|
|
70
|
+
} catch (error) {
|
|
71
|
+
if (error.code === 'PLT_METADATA_APP_ID_ERROR' ||
|
|
72
|
+
error.code === 'PLT_METADATA_RUNTIME_NOT_STARTED_ERROR' ||
|
|
73
|
+
error.code === 'PLT_METADATA_RUNTIME_ERROR' ||
|
|
74
|
+
error.code === 'PLT_METADATA_STATE_ERROR') {
|
|
75
|
+
throw error
|
|
76
|
+
}
|
|
77
|
+
app.log.error(error, 'Failure in metadata processing')
|
|
78
|
+
throw new MetadataError()
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
app.sendMetadata = sendMetadata
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export default metadata
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
async function scheduler (app, _opts) {
|
|
2
|
+
async function sendSchedulerInfo () {
|
|
3
|
+
// Skip scheduler configuration if ICC is not configured
|
|
4
|
+
if (!app.env.PLT_ICC_URL) {
|
|
5
|
+
app.log.info('PLT_ICC_URL not set, skipping scheduler configuration')
|
|
6
|
+
return
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
try {
|
|
10
|
+
const applicationId = app.instanceConfig?.applicationId
|
|
11
|
+
const runtime = app.wattpro.runtime
|
|
12
|
+
const config = await runtime.getRuntimeConfig()
|
|
13
|
+
const { default: build, setDefaultHeaders } = await import('../clients/cron/cron.mjs')
|
|
14
|
+
|
|
15
|
+
const cronUrl = app.instanceConfig?.iccServices?.cron?.url
|
|
16
|
+
if (!cronUrl) {
|
|
17
|
+
app.log.warn('No cron URL found in ICC services')
|
|
18
|
+
return
|
|
19
|
+
}
|
|
20
|
+
const cronClient = build(cronUrl)
|
|
21
|
+
setDefaultHeaders(await app.getAuthorizationHeader())
|
|
22
|
+
|
|
23
|
+
const jobs = config.scheduler || []
|
|
24
|
+
|
|
25
|
+
const saveJobs = []
|
|
26
|
+
for (const job of jobs) {
|
|
27
|
+
const iccJob = { ...job, applicationId }
|
|
28
|
+
iccJob.schedule = iccJob.cron // unfortunately, the ICC API uses `schedule` instead of `cron`
|
|
29
|
+
delete iccJob.cron
|
|
30
|
+
delete iccJob.enabled
|
|
31
|
+
saveJobs.push(cronClient.putWattJobs(iccJob))
|
|
32
|
+
}
|
|
33
|
+
const result = await Promise.allSettled(saveJobs)
|
|
34
|
+
const errors = result.filter((job) => job.status === 'rejected')
|
|
35
|
+
if (errors.length > 0) {
|
|
36
|
+
app.log.error(errors, 'Failed to save jobs in ICC')
|
|
37
|
+
throw new AggregateError('Failed to save jobs in ICC', { cause: errors.map(job => job.reason) })
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
app.log.info('Scheduler configured')
|
|
41
|
+
} catch (error) {
|
|
42
|
+
app.log.error(error, 'Failed in configuring watt jobs in ICC')
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
app.sendSchedulerInfo = sendSchedulerInfo
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export default scheduler
|