@katmer/core 0.0.3

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 (71) hide show
  1. package/README.md +1 -0
  2. package/cli/katmer.js +28 -0
  3. package/cli/run.ts +16 -0
  4. package/index.ts +5 -0
  5. package/lib/config.ts +82 -0
  6. package/lib/interfaces/config.interface.ts +113 -0
  7. package/lib/interfaces/executor.interface.ts +13 -0
  8. package/lib/interfaces/module.interface.ts +170 -0
  9. package/lib/interfaces/provider.interface.ts +214 -0
  10. package/lib/interfaces/task.interface.ts +100 -0
  11. package/lib/katmer.ts +126 -0
  12. package/lib/lookup/env.lookup.ts +13 -0
  13. package/lib/lookup/file.lookup.ts +23 -0
  14. package/lib/lookup/index.ts +46 -0
  15. package/lib/lookup/url.lookup.ts +21 -0
  16. package/lib/lookup/var.lookup.ts +13 -0
  17. package/lib/module.ts +560 -0
  18. package/lib/module_registry.ts +64 -0
  19. package/lib/modules/apt-repository/apt-repository.module.ts +435 -0
  20. package/lib/modules/apt-repository/apt-sources-list.ts +363 -0
  21. package/lib/modules/apt.module.ts +546 -0
  22. package/lib/modules/archive.module.ts +280 -0
  23. package/lib/modules/become.module.ts +119 -0
  24. package/lib/modules/copy.module.ts +807 -0
  25. package/lib/modules/cron.module.ts +541 -0
  26. package/lib/modules/debug.module.ts +231 -0
  27. package/lib/modules/gather_facts.module.ts +605 -0
  28. package/lib/modules/git.module.ts +243 -0
  29. package/lib/modules/hostname.module.ts +213 -0
  30. package/lib/modules/http/http.curl.module.ts +342 -0
  31. package/lib/modules/http/http.local.module.ts +253 -0
  32. package/lib/modules/http/http.module.ts +298 -0
  33. package/lib/modules/index.ts +14 -0
  34. package/lib/modules/package.module.ts +283 -0
  35. package/lib/modules/script.module.ts +121 -0
  36. package/lib/modules/set_fact.module.ts +171 -0
  37. package/lib/modules/systemd_service.module.ts +373 -0
  38. package/lib/modules/template.module.ts +478 -0
  39. package/lib/providers/local.provider.ts +336 -0
  40. package/lib/providers/provider_response.ts +20 -0
  41. package/lib/providers/ssh/ssh.provider.ts +420 -0
  42. package/lib/providers/ssh/ssh.utils.ts +31 -0
  43. package/lib/schemas/katmer_config.schema.json +358 -0
  44. package/lib/target_resolver.ts +298 -0
  45. package/lib/task/controls/environment.control.ts +42 -0
  46. package/lib/task/controls/index.ts +13 -0
  47. package/lib/task/controls/loop.control.ts +89 -0
  48. package/lib/task/controls/register.control.ts +23 -0
  49. package/lib/task/controls/until.control.ts +64 -0
  50. package/lib/task/controls/when.control.ts +25 -0
  51. package/lib/task/task.ts +225 -0
  52. package/lib/utils/ajv.utils.ts +24 -0
  53. package/lib/utils/cls.ts +4 -0
  54. package/lib/utils/datetime.utils.ts +15 -0
  55. package/lib/utils/errors.ts +25 -0
  56. package/lib/utils/execute-shell.ts +116 -0
  57. package/lib/utils/file.utils.ts +68 -0
  58. package/lib/utils/http.utils.ts +10 -0
  59. package/lib/utils/json.utils.ts +15 -0
  60. package/lib/utils/number.utils.ts +9 -0
  61. package/lib/utils/object.utils.ts +11 -0
  62. package/lib/utils/os.utils.ts +31 -0
  63. package/lib/utils/path.utils.ts +9 -0
  64. package/lib/utils/renderer/render_functions.ts +3 -0
  65. package/lib/utils/renderer/renderer.ts +89 -0
  66. package/lib/utils/renderer/twig.ts +191 -0
  67. package/lib/utils/string.utils.ts +33 -0
  68. package/lib/utils/typed-event-emitter.ts +26 -0
  69. package/lib/utils/unix.utils.ts +91 -0
  70. package/lib/utils/windows.utils.ts +92 -0
  71. package/package.json +67 -0
@@ -0,0 +1,171 @@
1
+ import {
2
+ type ModuleCommonReturn,
3
+ type ModuleConstraints
4
+ } from "../interfaces/module.interface"
5
+ import type { Katmer } from "../interfaces/task.interface"
6
+ import type { KatmerProvider } from "../interfaces/provider.interface"
7
+ import { evalExpr, evalIterative } from "../utils/renderer/renderer"
8
+ import { toMerged } from "es-toolkit"
9
+ import { KatmerModule } from "../module"
10
+
11
+ /**
12
+ * Allow task syntax:
13
+ *
14
+ * - name: compute values
15
+ * set_fact:
16
+ * vars:
17
+ * release_dir: "{{ app_dir }}/releases/{{ release }}"
18
+ * stamp: "{{ 1 + 2 }}"
19
+ * render: true
20
+ */
21
+ declare module "../interfaces/task.interface" {
22
+ export namespace Katmer {
23
+ export interface TaskActions {
24
+ /** Compute and set variables (facts) on the task context. */
25
+ set_fact?: SetFactModuleOptions
26
+ }
27
+ }
28
+ }
29
+
30
+ /**
31
+ * Options for the set_fact module.
32
+ *
33
+ * You can pass either:
34
+ * - a plain object of key/value pairs, or
35
+ * - `{ vars, render, deep }` for more control
36
+ *
37
+ * When `render` is true, string values that contain templates like `{{ ... }}` are
38
+ * evaluated using the current `ctx.variables` scope. When `deep` is true, objects
39
+ * and arrays are traversed and any string leaves are evaluated similarly.
40
+ *
41
+ * @public
42
+ */
43
+ export type SetFactModuleOptions =
44
+ | Record<string, unknown>
45
+ | {
46
+ /** Key/value pairs to set on `ctx.variables`. */
47
+ vars: Record<string, unknown>
48
+ /**
49
+ * Evaluate string templates with `evalExpr`.
50
+ * Only strings that look like templates are evaluated.
51
+ * @defaultValue true
52
+ */
53
+ render?: boolean
54
+ /**
55
+ * Recursively render nested objects/arrays.
56
+ * Only impacts rendering when `render=true`.
57
+ * @defaultValue false
58
+ */
59
+ deep?: boolean
60
+ }
61
+
62
+ /**
63
+ * Result of the set_fact module.
64
+ * @public
65
+ */
66
+ export interface SetFactModuleResult extends ModuleCommonReturn {
67
+ /** The facts that were set (post-render). */
68
+ facts: Record<string, unknown>
69
+ }
70
+
71
+ /**
72
+ * Compute and set variables (facts) on the task context.
73
+ *
74
+ * @remarks
75
+ * - Values are merged into `ctx.variables`.
76
+ * - `changed` is true when a value is added or changed.
77
+ * - When `render=true`, string values that contain `{{ ... }}` are evaluated via `evalExpr`
78
+ * with `ctx.variables` as scope. Set `deep=true` to render nested strings as well.
79
+ *
80
+ * @examples
81
+ * ```yaml
82
+ * - name: compute derived paths and flags
83
+ * set_fact:
84
+ * vars:
85
+ * app_dir: /opt/myapp
86
+ * release: "2025-01-01"
87
+ * release_dir: "{{ app_dir }}/releases/{{ release }}"
88
+ * is_prod: "{{ env == 'prod' }}"
89
+ * nested:
90
+ * a: "value"
91
+ * b: "{{ app_dir }}/current"
92
+ * render: true
93
+ * deep: true
94
+ *
95
+ * - name: shorthand object (equivalent to vars: {...})
96
+ * set_fact:
97
+ * BUILD_ID: "42"
98
+ * url: "https://example.com/{{ BUILD_ID }}"
99
+ * # with render=true (default), url becomes "https://example.com/42"
100
+ * ```
101
+ */
102
+ export class SetFactModule extends KatmerModule<
103
+ SetFactModuleOptions,
104
+ SetFactModuleResult,
105
+ KatmerProvider
106
+ > {
107
+ static name = "set_fact" as const
108
+
109
+ constraints = {
110
+ platform: {
111
+ any: true
112
+ }
113
+ } satisfies ModuleConstraints
114
+
115
+ async check(_ctx: Katmer.TaskContext<KatmerProvider>): Promise<void> {}
116
+ async initialize(_ctx: Katmer.TaskContext<KatmerProvider>): Promise<void> {}
117
+ async cleanup(_ctx: Katmer.TaskContext<KatmerProvider>): Promise<void> {}
118
+
119
+ async execute(
120
+ ctx: Katmer.TaskContext<KatmerProvider>
121
+ ): Promise<SetFactModuleResult> {
122
+ const { vars, render, deep } = this.#normalize(this.params)
123
+
124
+ const before = ctx.variables ?? {}
125
+ const produced: Record<string, unknown> = {}
126
+
127
+ // Render each value according to flags, then collect into `produced`.
128
+ for (const [k, v] of Object.entries(vars)) {
129
+ produced[k] =
130
+ render ?
131
+ await evalIterative(v, {
132
+ scope: { ...before, ...produced },
133
+ deep: !!deep
134
+ })
135
+ : v
136
+ }
137
+
138
+ // Determine changed by comparing JSON representations of affected keys.
139
+ let changed = false
140
+ for (const [k, nextVal] of Object.entries(produced)) {
141
+ const prevVal = (before as any)[k]
142
+ if (JSON.stringify(prevVal) !== JSON.stringify(nextVal)) {
143
+ changed = true
144
+ }
145
+ }
146
+
147
+ // Merge into ctx.variables
148
+ ctx.variables = toMerged(before, produced) as any
149
+
150
+ // Optional logging via ctx.logger
151
+ ctx.logger?.debug?.({ msg: "set_fact applied", facts: produced })
152
+
153
+ return { changed, facts: produced }
154
+ }
155
+
156
+ #normalize(p: SetFactModuleOptions | undefined): {
157
+ vars: Record<string, unknown>
158
+ render: boolean
159
+ deep: boolean
160
+ } {
161
+ if (!p) return { vars: {}, render: true, deep: false }
162
+ if (typeof p === "object" && "vars" in p) {
163
+ return {
164
+ vars: (p.vars || {}) as Record<string, unknown>,
165
+ render: p.render !== false,
166
+ deep: !!p.deep
167
+ }
168
+ }
169
+ return { vars: p as Record<string, unknown>, render: true, deep: false }
170
+ }
171
+ }
@@ -0,0 +1,373 @@
1
+ import {
2
+ type ModuleCommonReturn,
3
+ type ModuleConstraints
4
+ } from "../interfaces/module.interface"
5
+ import type { Katmer } from "../interfaces/task.interface"
6
+ import type { SSHProvider } from "../providers/ssh/ssh.provider"
7
+ import { KatmerModule } from "../module"
8
+
9
+ declare module "../interfaces/task.interface" {
10
+ export namespace Katmer {
11
+ export interface TaskActions {
12
+ systemd_service?: SystemdServiceModuleOptions
13
+ }
14
+ }
15
+ }
16
+ /**
17
+ * Manage systemd units (start/stop/restart/reload/enable/disable/mask/unmask/daemon-reload).
18
+ *
19
+ * @remarks
20
+ * - Requires systemd on the target (systemctl must be available).
21
+ * - Operations are idempotent where feasible by checking current unit state.
22
+ *
23
+ * @examples
24
+ * ```yaml
25
+ * - name: Start and enable a service
26
+ * systemd_service:
27
+ * name: nginx
28
+ * state: started
29
+ * enabled: true
30
+ *
31
+ * - name: Restart with daemon-reload
32
+ * systemd_service:
33
+ * name: myapp.service
34
+ * daemon_reload: true
35
+ * state: restarted
36
+ *
37
+ * - name: Stop and disable a timer
38
+ * systemd_service:
39
+ * name: myjob.timer
40
+ * state: stopped
41
+ * enabled: false
42
+ * ```
43
+ */
44
+ export class SystemdServiceModule extends KatmerModule<
45
+ SystemdServiceModuleOptions,
46
+ SystemdServiceModuleResult,
47
+ SSHProvider
48
+ > {
49
+ static name = "systemd_service" as const
50
+
51
+ constraints = {
52
+ platform: {
53
+ linux: {
54
+ requireRoot: true, // system scope typically needs root
55
+ binaries: [
56
+ // Parse "systemd 245 (...)" from `systemctl --version`
57
+ {
58
+ cmd: "systemctl",
59
+ args: ["--version"],
60
+ versionRegex: /systemd\s+(\d+)/,
61
+ range: ">=219"
62
+ }
63
+ ],
64
+ // Shorthand strings are normalized to { name, range }
65
+ packages: ["systemd@>=219"],
66
+ // Turn off known non-systemd distro
67
+ distro: {
68
+ alpine: false
69
+ }
70
+ }
71
+ }
72
+ } satisfies ModuleConstraints
73
+
74
+ async check(ctx: Katmer.TaskContext<SSHProvider>): Promise<void> {
75
+ const { stdout } = await ctx.exec(
76
+ `command -v systemctl >/dev/null 2>&1; echo $?`
77
+ )
78
+ if (stdout.trim() !== "0") {
79
+ throw new Error(
80
+ "systemctl not found; target does not appear to be using systemd"
81
+ )
82
+ }
83
+ if (!this.params?.name || !String(this.params.name).trim()) {
84
+ throw new Error("'name' is required (unit name, e.g., nginx.service)")
85
+ }
86
+ }
87
+
88
+ async initialize(_ctx: Katmer.TaskContext<SSHProvider>): Promise<void> {}
89
+
90
+ async cleanup(_ctx: Katmer.TaskContext<SSHProvider>): Promise<void> {}
91
+
92
+ async execute(
93
+ ctx: Katmer.TaskContext<SSHProvider>
94
+ ): Promise<SystemdServiceModuleResult> {
95
+ const {
96
+ name,
97
+ state,
98
+ enabled,
99
+ masked,
100
+ daemon_reload,
101
+ scope = "system", // or "user"
102
+ no_block = false
103
+ } = this.params
104
+
105
+ const unit = String(name).trim()
106
+ const scopeFlag = scope === "user" ? "--user" : ""
107
+
108
+ // helper
109
+ const ok = async (cmd: string) => {
110
+ const r = await ctx.exec(cmd)
111
+ return { code: r.code, out: r.stdout.trim(), err: r.stderr.trim() }
112
+ }
113
+
114
+ // daemon-reload first (commonly desired before actions)
115
+ let changed = false
116
+ if (daemon_reload) {
117
+ const r = await ok(`systemctl ${scopeFlag} daemon-reload`)
118
+ if (r.code !== 0) {
119
+ throw {
120
+ changed,
121
+ msg: r.err || r.out || "daemon-reload failed"
122
+ } as SystemdServiceModuleResult
123
+ }
124
+ changed = true
125
+ }
126
+
127
+ // Query current status/idempotency anchors
128
+ const isActive = await this.getIsActive(ctx, unit, scopeFlag)
129
+ const isEnabled = await this.getIsEnabled(ctx, unit, scopeFlag)
130
+ const isMasked = await this.getIsMasked(ctx, unit, scopeFlag)
131
+
132
+ // mask/unmask if requested explicitly
133
+ if (typeof masked === "boolean") {
134
+ if (masked && !isMasked) {
135
+ const r = await ok(
136
+ `systemctl ${scopeFlag} mask ${this.blockFlag(no_block)} ${q(unit)}`
137
+ )
138
+ if (r.code !== 0) {
139
+ throw {
140
+ changed,
141
+ msg: r.err || r.out || "mask failed"
142
+ } as SystemdServiceModuleResult
143
+ }
144
+ changed = true
145
+ } else if (!masked && isMasked) {
146
+ const r = await ok(
147
+ `systemctl ${scopeFlag} unmask ${this.blockFlag(no_block)} ${q(unit)}`
148
+ )
149
+ if (r.code !== 0) {
150
+ throw {
151
+ changed,
152
+ msg: r.err || r.out || "unmask failed"
153
+ } as SystemdServiceModuleResult
154
+ }
155
+ changed = true
156
+ }
157
+ }
158
+
159
+ // enable/disable if requested
160
+ if (typeof enabled === "boolean") {
161
+ if (enabled && !isEnabled) {
162
+ const r = await ok(
163
+ `systemctl ${scopeFlag} enable ${this.blockFlag(no_block)} ${q(unit)}`
164
+ )
165
+ if (r.code !== 0) {
166
+ throw {
167
+ changed,
168
+ msg: r.err || r.out || "enable failed"
169
+ } as SystemdServiceModuleResult
170
+ }
171
+ changed = true
172
+ } else if (!enabled && isEnabled) {
173
+ const r = await ok(
174
+ `systemctl ${scopeFlag} disable ${this.blockFlag(no_block)} ${q(unit)}`
175
+ )
176
+ if (r.code !== 0) {
177
+ throw {
178
+ changed,
179
+ msg: r.err || r.out || "disable failed"
180
+ } as SystemdServiceModuleResult
181
+ }
182
+ changed = true
183
+ }
184
+ }
185
+
186
+ // state transitions
187
+ if (state) {
188
+ if (state === "started" && !isActive) {
189
+ const r = await ok(
190
+ `systemctl ${scopeFlag} start ${this.blockFlag(no_block)} ${q(unit)}`
191
+ )
192
+ if (r.code !== 0) {
193
+ throw {
194
+ changed,
195
+ msg: r.err || r.out || "start failed"
196
+ } as SystemdServiceModuleResult
197
+ }
198
+ changed = true
199
+ } else if (state === "stopped" && isActive) {
200
+ const r = await ok(
201
+ `systemctl ${scopeFlag} stop ${this.blockFlag(no_block)} ${q(unit)}`
202
+ )
203
+ if (r.code !== 0) {
204
+ throw {
205
+ changed,
206
+ msg: r.err || r.out || "stop failed"
207
+ } as SystemdServiceModuleResult
208
+ }
209
+ changed = true
210
+ } else if (state === "restarted") {
211
+ const r = await ok(
212
+ `systemctl ${scopeFlag} restart ${this.blockFlag(no_block)} ${q(unit)}`
213
+ )
214
+ if (r.code !== 0) {
215
+ throw {
216
+ changed,
217
+ msg: r.err || r.out || "restart failed"
218
+ } as SystemdServiceModuleResult
219
+ }
220
+ changed = true
221
+ } else if (state === "reloaded") {
222
+ const r = await ok(
223
+ `systemctl ${scopeFlag} reload ${this.blockFlag(no_block)} ${q(unit)}`
224
+ )
225
+ if (r.code !== 0) {
226
+ throw {
227
+ changed,
228
+ msg: r.err || r.out || "reload failed"
229
+ } as SystemdServiceModuleResult
230
+ }
231
+ changed = true
232
+ } else if (state === "paused" || state === "unpaused") {
233
+ // No direct systemctl verb; map paused -> stop, unpaused -> start (best-effort)
234
+ if (state === "paused" && isActive) {
235
+ const r = await ok(
236
+ `systemctl ${scopeFlag} stop ${this.blockFlag(no_block)} ${q(unit)}`
237
+ )
238
+ if (r.code !== 0) {
239
+ throw {
240
+ changed,
241
+ msg: r.err || r.out || "pause(stop) failed"
242
+ } as SystemdServiceModuleResult
243
+ }
244
+ changed = true
245
+ }
246
+ if (state === "unpaused" && !isActive) {
247
+ const r = await ok(
248
+ `systemctl ${scopeFlag} start ${this.blockFlag(no_block)} ${q(unit)}`
249
+ )
250
+ if (r.code !== 0) {
251
+ throw {
252
+ changed,
253
+ msg: r.err || r.out || "unpause(start) failed"
254
+ } as SystemdServiceModuleResult
255
+ }
256
+ changed = true
257
+ }
258
+ }
259
+ }
260
+
261
+ // Re-query for result
262
+ const finalActive = await this.getIsActive(ctx, unit, scopeFlag)
263
+ const finalEnabled = await this.getIsEnabled(ctx, unit, scopeFlag)
264
+ const finalMasked = await this.getIsMasked(ctx, unit, scopeFlag)
265
+
266
+ return {
267
+ changed,
268
+ status: {
269
+ name: unit,
270
+ active: finalActive,
271
+ enabled: finalEnabled,
272
+ masked: finalMasked,
273
+ scope
274
+ }
275
+ }
276
+ }
277
+
278
+ private blockFlag(no_block?: boolean) {
279
+ // systemctl is synchronous by default; when no_block is true, add --no-block
280
+ return no_block ? "--no-block" : ""
281
+ }
282
+
283
+ private async getIsActive(
284
+ ctx: Katmer.TaskContext<SSHProvider>,
285
+ unit: string,
286
+ scopeFlag: string
287
+ ) {
288
+ const r = await ctx.exec(
289
+ `systemctl ${scopeFlag} is-active ${q(unit)} || true`
290
+ )
291
+ return r.stdout.trim() === "active"
292
+ }
293
+ private async getIsEnabled(
294
+ ctx: Katmer.TaskContext<SSHProvider>,
295
+ unit: string,
296
+ scopeFlag: string
297
+ ) {
298
+ const r = await ctx.exec(
299
+ `systemctl ${scopeFlag} is-enabled ${q(unit)} || true`
300
+ )
301
+ const s = r.stdout.trim()
302
+ return s === "enabled" || s === "static" || s === "indirect"
303
+ }
304
+ private async getIsMasked(
305
+ ctx: Katmer.TaskContext<SSHProvider>,
306
+ unit: string,
307
+ scopeFlag: string
308
+ ) {
309
+ const r = await ctx.exec(
310
+ `systemctl ${scopeFlag} is-enabled ${q(unit)} || true`
311
+ )
312
+ return r.stdout.trim() === "masked"
313
+ }
314
+ }
315
+
316
+ /**
317
+ * Options for systemd_service module.
318
+ * @public
319
+ */
320
+ export interface SystemdServiceModuleOptions {
321
+ /**
322
+ * Unit name, e.g., "nginx.service" (".service" suffix optional).
323
+ */
324
+ name: string
325
+ /**
326
+ * Desired unit state.
327
+ */
328
+ state?:
329
+ | "started"
330
+ | "stopped"
331
+ | "restarted"
332
+ | "reloaded"
333
+ | "paused"
334
+ | "unpaused"
335
+ /**
336
+ * Enable or disable unit at boot.
337
+ */
338
+ enabled?: boolean
339
+ /**
340
+ * Mask or unmask the unit.
341
+ */
342
+ masked?: boolean
343
+ /**
344
+ * Run `systemctl daemon-reload` before actions.
345
+ */
346
+ daemon_reload?: boolean
347
+ /**
348
+ * Target scope (system/user). Default: system.
349
+ */
350
+ scope?: "system" | "user"
351
+ /**
352
+ * Use --no-block for start/stop/restart/reload/enable/disable/mask/unmask.
353
+ */
354
+ no_block?: boolean
355
+ }
356
+
357
+ /**
358
+ * Result for systemd_service module.
359
+ * @public
360
+ */
361
+ export interface SystemdServiceModuleResult extends ModuleCommonReturn {
362
+ status: {
363
+ name: string
364
+ active: boolean
365
+ enabled: boolean
366
+ masked: boolean
367
+ scope: "system" | "user" | string
368
+ }
369
+ }
370
+
371
+ function q(s: string) {
372
+ return JSON.stringify(s)
373
+ }