@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.
Files changed (95) hide show
  1. package/README.md +87 -0
  2. package/app.js +124 -0
  3. package/cli.js +141 -0
  4. package/clients/compliance/compliance-types.d.ts +887 -0
  5. package/clients/compliance/compliance.mjs +1049 -0
  6. package/clients/compliance/compliance.openapi.json +6127 -0
  7. package/clients/control-plane/control-plane-types.d.ts +2696 -0
  8. package/clients/control-plane/control-plane.mjs +3051 -0
  9. package/clients/control-plane/control-plane.openapi.json +13693 -0
  10. package/clients/cron/cron-types.d.ts +1479 -0
  11. package/clients/cron/cron.mjs +872 -0
  12. package/clients/cron/cron.openapi.json +9330 -0
  13. package/compliance/index.js +21 -0
  14. package/compliance/rules/dependencies.js +76 -0
  15. package/compliance/rules/utils.js +12 -0
  16. package/eslint.config.js +11 -0
  17. package/help/start.txt +12 -0
  18. package/help/watt-extra.txt +12 -0
  19. package/index.js +45 -0
  20. package/lib/banner.js +22 -0
  21. package/lib/errors.js +34 -0
  22. package/lib/utils.js +34 -0
  23. package/lib/wattpro.js +580 -0
  24. package/package.json +50 -0
  25. package/plugins/alerts.js +115 -0
  26. package/plugins/auth.js +89 -0
  27. package/plugins/compliancy.js +70 -0
  28. package/plugins/env.js +58 -0
  29. package/plugins/flamegraphs.js +100 -0
  30. package/plugins/init.js +70 -0
  31. package/plugins/metadata.js +84 -0
  32. package/plugins/scheduler.js +48 -0
  33. package/plugins/update.js +128 -0
  34. package/renovate.json +6 -0
  35. package/test/alerts.test.js +607 -0
  36. package/test/auth.test.js +128 -0
  37. package/test/auto-cache.test.js +401 -0
  38. package/test/cli.test.js +75 -0
  39. package/test/compliancy.test.js +87 -0
  40. package/test/fixtures/runtime-domains/alpha/package.json +5 -0
  41. package/test/fixtures/runtime-domains/alpha/platformatic.json +6 -0
  42. package/test/fixtures/runtime-domains/alpha/plugin.js +16 -0
  43. package/test/fixtures/runtime-domains/beta/package.json +5 -0
  44. package/test/fixtures/runtime-domains/beta/platformatic.json +6 -0
  45. package/test/fixtures/runtime-domains/beta/plugin.js +7 -0
  46. package/test/fixtures/runtime-domains/composer/package.json +5 -0
  47. package/test/fixtures/runtime-domains/composer/platformatic.json +19 -0
  48. package/test/fixtures/runtime-domains/package.json +1 -0
  49. package/test/fixtures/runtime-domains/platformatic.json +27 -0
  50. package/test/fixtures/runtime-health/package.json +20 -0
  51. package/test/fixtures/runtime-health/platformatic.json +16 -0
  52. package/test/fixtures/runtime-health/services/service-1/package.json +17 -0
  53. package/test/fixtures/runtime-health/services/service-1/platformatic.json +16 -0
  54. package/test/fixtures/runtime-health/services/service-1/plugins/example.js +6 -0
  55. package/test/fixtures/runtime-health/services/service-1/routes/root.cjs +8 -0
  56. package/test/fixtures/runtime-health/services/service-2/package.json +17 -0
  57. package/test/fixtures/runtime-health/services/service-2/platformatic.json +16 -0
  58. package/test/fixtures/runtime-health/services/service-2/plugins/example.js +6 -0
  59. package/test/fixtures/runtime-health/services/service-2/routes/root.cjs +8 -0
  60. package/test/fixtures/runtime-next/package.json +5 -0
  61. package/test/fixtures/runtime-next/platformatic.json +9 -0
  62. package/test/fixtures/runtime-next/web/next/next.config.js +2 -0
  63. package/test/fixtures/runtime-next/web/next/package.json +7 -0
  64. package/test/fixtures/runtime-next/web/next/platformatic.json +9 -0
  65. package/test/fixtures/runtime-next/web/next/src/app/direct/route.js +3 -0
  66. package/test/fixtures/runtime-next/web/next/src/app/layout.jsx +7 -0
  67. package/test/fixtures/runtime-next/web/next/src/app/page.jsx +3 -0
  68. package/test/fixtures/runtime-scheduler/main/package.json +5 -0
  69. package/test/fixtures/runtime-scheduler/main/platformatic.json +9 -0
  70. package/test/fixtures/runtime-scheduler/main/routes/root.cjs +11 -0
  71. package/test/fixtures/runtime-scheduler/package.json +1 -0
  72. package/test/fixtures/runtime-scheduler/platformatic.json +27 -0
  73. package/test/fixtures/runtime-service/main/package.json +5 -0
  74. package/test/fixtures/runtime-service/main/platformatic.json +12 -0
  75. package/test/fixtures/runtime-service/main/routes/root.cjs +11 -0
  76. package/test/fixtures/runtime-service/package.json +1 -0
  77. package/test/fixtures/runtime-service/platformatic.json +19 -0
  78. package/test/fixtures/service-1/package.json +7 -0
  79. package/test/fixtures/service-1/platformatic.json +18 -0
  80. package/test/fixtures/service-1/routes/root.cjs +48 -0
  81. package/test/fixtures/service-2/platformatic.json +21 -0
  82. package/test/fixtures/service-2/routes/root.cjs +5 -0
  83. package/test/fixtures/service-3/package.json +5 -0
  84. package/test/fixtures/service-3/platformatic.json +21 -0
  85. package/test/fixtures/service-3/routes/root.cjs +8 -0
  86. package/test/health.test.js +44 -0
  87. package/test/helper.js +274 -0
  88. package/test/init.test.js +243 -0
  89. package/test/patch-config.test.js +434 -0
  90. package/test/scheduler.test.js +71 -0
  91. package/test/send-to-icc-retry.test.js +138 -0
  92. package/test/shared-context.test.js +82 -0
  93. package/test/spawn.test.js +110 -0
  94. package/test/trigger-flamegraphs.test.js +226 -0
  95. 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
@@ -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
@@ -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