@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,546 @@
1
+ import { type ModuleConstraints } from "../interfaces/module.interface"
2
+ import type { Katmer } from "../interfaces/task.interface"
3
+ import type { SSHProvider } from "../providers/ssh/ssh.provider"
4
+ import type { OsInfo } from "../interfaces/provider.interface"
5
+ import { KatmerModule } from "../module"
6
+
7
+ declare module "../interfaces/task.interface" {
8
+ export namespace Katmer {
9
+ export interface TaskActions {
10
+ apt?: AptModuleOptions
11
+ }
12
+ }
13
+ }
14
+
15
+ /**
16
+ * APT package manager: install, remove, and upgrade packages on Debian/Ubuntu.
17
+ *
18
+ * @remarks
19
+ * - Uses `apt-get` with noninteractive, idempotent flags where possible.
20
+ * - Can optionally refresh the cache (`apt-get update`) with retries/backoff.
21
+ * - Supports installing from package names or local `.deb` files.
22
+ * - When `upgrade` is set, a system-wide upgrade action runs before package
23
+ * state changes (e.g. `full-upgrade`), then requested package operations.
24
+ *
25
+ * @examples
26
+ * ```yaml
27
+ * # Refresh cache (if older than 10 minutes), install nginx, then autoremove
28
+ * - name: Install nginx and clean up
29
+ * apt:
30
+ * name: nginx
31
+ * state: present
32
+ * update_cache: true
33
+ * cache_valid_time: 600
34
+ * autoremove: true
35
+ *
36
+ * # Ensure latest versions for a list of packages
37
+ * - name: Bump packages to latest available versions
38
+ * apt:
39
+ * name: [curl, unzip, git]
40
+ * state: latest
41
+ *
42
+ * # Remove a package (purge configuration files too)
43
+ * - name: Remove nginx completely
44
+ * apt:
45
+ * name: nginx
46
+ * state: absent
47
+ * purge: true
48
+ *
49
+ * # Install from local .deb(s)
50
+ * - name: Install from local deb files
51
+ * apt:
52
+ * deb:
53
+ * - /tmp/custom_1.0.0_amd64.deb
54
+ * - /tmp/agent_2.3.1_amd64.deb
55
+ *
56
+ * # Run a safe upgrade first, then install a package
57
+ * - name: Safe upgrade then install htop
58
+ * apt:
59
+ * upgrade: safe
60
+ * name: htop
61
+ * state: present
62
+ * ```
63
+ */
64
+ export class AptModule extends KatmerModule<
65
+ AptModuleOptions,
66
+ AptModuleResult,
67
+ SSHProvider
68
+ > {
69
+ constraints = {
70
+ platform: {
71
+ linux: {
72
+ packages: ["apt"]
73
+ }
74
+ }
75
+ } satisfies ModuleConstraints
76
+
77
+ static name = "apt" as const
78
+
79
+ private aptCmd = "apt-get"
80
+
81
+ async check(ctx: Katmer.TaskContext<SSHProvider>): Promise<void> {
82
+ // tools
83
+ const tools = ["apt-get", "dpkg", "apt-cache"]
84
+ for (const t of tools) {
85
+ const r = await ctx.exec(`command -v ${t} >/dev/null 2>&1; echo $?`)
86
+ if (String(r.stdout).trim() !== "0") {
87
+ throw new Error(`${t} is not available on the target system`)
88
+ }
89
+ }
90
+
91
+ // resolve apt command preference
92
+ this.aptCmd = this.params.force_apt_get ? "apt-get" : "apt-get"
93
+
94
+ // basic validation
95
+ if (
96
+ this.params.state === "absent" &&
97
+ !this.params.name &&
98
+ !this.params.deb
99
+ ) {
100
+ throw new Error("state=absent requires 'name' or 'deb'")
101
+ }
102
+ }
103
+
104
+ async initialize(_ctx: Katmer.TaskContext<SSHProvider>): Promise<void> {}
105
+ async cleanup(_ctx: Katmer.TaskContext<SSHProvider>): Promise<void> {}
106
+
107
+ private dpkgOptions(): string {
108
+ const opts = new Set<string>(this.params.dpkg_options || [])
109
+ if (this.params.dpkg_force_confnew) opts.add("--force-confnew")
110
+ if (this.params.dpkg_force_confdef) opts.add("--force-confdef")
111
+ if (opts.size === 0) return ""
112
+ return Array.from(opts)
113
+ .map((o) => `-o Dpkg::Options::=${quote(o)}`)
114
+ .join(" ")
115
+ }
116
+
117
+ private installRecommends(): string {
118
+ const v = this.params.install_recommends
119
+ if (typeof v === "boolean") {
120
+ return `-o APT::Install-Recommends=${v ? "true" : "false"}`
121
+ }
122
+ return "" // default apt behavior (true)
123
+ }
124
+
125
+ private lockTimeout(): string {
126
+ const t = this.params.lock_timeout
127
+ if (!t || t <= 0) return ""
128
+ // best-effort; different apt versions handle this differently
129
+ return `-o DPkg::Lock::Timeout=${t}`
130
+ }
131
+
132
+ private baseEnv(): string {
133
+ const env: string[] = []
134
+ if (typeof this.params.policy_rc_d === "number") {
135
+ env.push(`POLICY_RC_D=${this.params.policy_rc_d}`)
136
+ }
137
+ if (this.params.allow_unauthenticated) {
138
+ env.push("APT_LISTCHANGES_FRONTEND=none")
139
+ }
140
+ return env.join(" ")
141
+ }
142
+
143
+ private async aptUpdateIfNeeded(
144
+ ctx: Katmer.TaskContext<SSHProvider>
145
+ ): Promise<boolean> {
146
+ const update_cache = !!this.params.update_cache
147
+ if (!update_cache) return false
148
+
149
+ // cache_valid_time best-effort: check mtime of lists dir
150
+ const cv = this.params.cache_valid_time
151
+ if (cv && cv > 0) {
152
+ const stampCheck = await ctx.exec(
153
+ "test -d /var/lib/apt/lists && stat -c %Y /var/lib/apt/lists 2>/dev/null || echo 0"
154
+ )
155
+ const stamp = Number(String(stampCheck.stdout).trim() || "0")
156
+ if (stamp && nowSeconds() - stamp < cv) {
157
+ return false
158
+ }
159
+ }
160
+
161
+ const retries = this.params.update_cache_retries ?? 5
162
+ const maxDelay = this.params.update_cache_retry_max_delay ?? 12
163
+ const randomize = Math.random()
164
+ const fatal = this.params.update_cache_error_fatal !== false
165
+
166
+ for (let retry = 0; retry < retries; retry++) {
167
+ const r = await ctx.exec(
168
+ `${this.baseEnv()} sudo ${this.aptCmd} update -y`
169
+ )
170
+ if (r.code === 0) return true
171
+ const lastErr = r.stderr || r.stdout || "unknown reason"
172
+ ctx.warn(
173
+ `apt-get update failed: ${lastErr}. Attempt ${retry + 1}/${retries}, retrying...`
174
+ )
175
+ let delay = 2 ** retry + randomize
176
+ if (delay > maxDelay) delay = maxDelay + randomize
177
+ await new Promise((res) => setTimeout(res, Math.round(delay * 1000)))
178
+ if (retry === retries - 1 && fatal) {
179
+ throw {
180
+ changed: false,
181
+ updated_cache: false,
182
+ msg: `Failed to update apt cache after ${retries} retries: ${lastErr}`
183
+ } as AptModuleResult
184
+ }
185
+ }
186
+ return false
187
+ }
188
+
189
+ private aptCommonFlags(): string {
190
+ const parts = [
191
+ "-y",
192
+ "-qq",
193
+ this.lockTimeout(),
194
+ this.dpkgOptions(),
195
+ this.installRecommends(),
196
+ this.params.only_upgrade ? "--only-upgrade" : "",
197
+ this.params.allow_unauthenticated ? "--allow-unauthenticated" : ""
198
+ ].filter(Boolean)
199
+ return parts.join(" ")
200
+ }
201
+
202
+ private pkgListArg(): string {
203
+ const pkgs = joinPkgs(this.params.name).filter(Boolean) as string[]
204
+ return pkgs.map(quote).join(" ")
205
+ }
206
+
207
+ private debListArg(): string {
208
+ const debs = joinPkgs(this.params.deb).filter(Boolean) as string[]
209
+ return debs.map(quote).join(" ")
210
+ }
211
+
212
+ async execute(
213
+ ctx: Katmer.TaskContext<SSHProvider>
214
+ ): Promise<AptModuleResult> {
215
+ const state: PackageState = this.params.state ?? "present"
216
+
217
+ // update cache
218
+ const updated_cache = await this.aptUpdateIfNeeded(ctx)
219
+
220
+ // upgrades
221
+ if (this.params.upgrade && this.params.upgrade !== "no") {
222
+ const modeMap: Record<
223
+ NonNullable<AptModuleOptions["upgrade"]>,
224
+ string
225
+ > = {
226
+ no: "",
227
+ yes: "upgrade",
228
+ safe: "upgrade",
229
+ full: "full-upgrade",
230
+ dist: "dist-upgrade"
231
+ }
232
+ const sub = modeMap[this.params.upgrade]
233
+ if (sub) {
234
+ const cmd = `${this.baseEnv()} sudo ${this.aptCmd} ${sub} ${this.aptCommonFlags()}`
235
+ const r = await ctx.exec(cmd)
236
+ if (r.code !== 0) {
237
+ throw {
238
+ changed: false,
239
+ updated_cache,
240
+ upgraded: false,
241
+ msg: r.stderr || r.stdout || `${this.aptCmd} ${sub} failed`
242
+ } as AptModuleResult
243
+ }
244
+ }
245
+ }
246
+
247
+ let changed = false
248
+ let stdout = ""
249
+ let stderr = ""
250
+
251
+ if (state === "present" || state === "latest" || state === "build-dep") {
252
+ const verb = state === "build-dep" ? "build-dep" : "install"
253
+ const pkgArgs = this.pkgListArg()
254
+ const debArgs = this.debListArg()
255
+
256
+ if (!pkgArgs && !debArgs) {
257
+ // nothing to do
258
+ } else {
259
+ const extra = state === "latest" ? "--only-upgrade" : ""
260
+ const targetArgs = debArgs || pkgArgs
261
+ const cmd =
262
+ `${this.baseEnv()} sudo ${this.aptCmd} ${verb} ${this.aptCommonFlags()} ${extra} ${targetArgs}`.trim()
263
+ const r = await ctx.exec(cmd)
264
+ stdout = r.stdout
265
+ stderr = r.stderr
266
+ if (r.code !== 0) {
267
+ throw {
268
+ changed: false,
269
+ updated_cache,
270
+ msg: r.stderr || r.stdout || `${this.aptCmd} ${verb} failed`
271
+ } as AptModuleResult
272
+ }
273
+ changed = true
274
+ }
275
+ } else if (state === "absent") {
276
+ const purge = this.params.purge ? "--purge" : ""
277
+ const pkgArgs = this.pkgListArg()
278
+ const debArgs = this.debListArg()
279
+ const targetArgs = debArgs || pkgArgs
280
+ if (targetArgs) {
281
+ const cmd = `${this.baseEnv()} sudo ${this.aptCmd} remove ${purge} ${this.aptCommonFlags()} ${targetArgs}`
282
+ const r = await ctx.exec(cmd)
283
+ stdout = r.stdout
284
+ stderr = r.stderr
285
+ if (r.code !== 0) {
286
+ throw {
287
+ changed: false,
288
+ updated_cache,
289
+ msg: r.stderr || r.stdout || `${this.aptCmd} remove failed`
290
+ } as AptModuleResult
291
+ }
292
+ changed = true
293
+ }
294
+ }
295
+
296
+ // autoremove
297
+ let didAutoremove = false
298
+ if (this.params.autoremove) {
299
+ const r = await ctx.exec(
300
+ `${this.baseEnv()} sudo ${this.aptCmd} autoremove -y -qq ${this.lockTimeout()}`
301
+ )
302
+ if (r.code !== 0) {
303
+ throw {
304
+ changed,
305
+ updated_cache,
306
+ autoremove: false,
307
+ msg: r.stderr || r.stdout || `${this.aptCmd} autoremove failed`
308
+ } as AptModuleResult
309
+ }
310
+ didAutoremove = true
311
+ changed = true
312
+ }
313
+
314
+ // clean
315
+ let didClean = false
316
+ if (this.params.clean) {
317
+ const r = await ctx.exec(`${this.baseEnv()} sudo ${this.aptCmd} clean`)
318
+ if (r.code !== 0) {
319
+ throw {
320
+ changed,
321
+ updated_cache,
322
+ cleaned: false,
323
+ msg: r.stderr || r.stdout || `${this.aptCmd} clean failed`
324
+ } as AptModuleResult
325
+ }
326
+ didClean = true
327
+ }
328
+
329
+ return {
330
+ changed,
331
+ stdout,
332
+ stderr,
333
+ updated_cache,
334
+ upgraded: this.params.upgrade ? this.params.upgrade !== "no" : false,
335
+ cleaned: didClean,
336
+ autoremove: didAutoremove
337
+ }
338
+ }
339
+ }
340
+
341
+ type PackageState = "present" | "absent" | "latest" | "build-dep"
342
+ /**
343
+ * Options for the **apt** module.
344
+ */
345
+ export type AptModuleOptions = {
346
+ /**
347
+ * Name(s) of packages to manage.
348
+ * Accepts a single name or a list.
349
+ *
350
+ * Ignored if only {@link AptModuleOptions.deb | deb} is provided.
351
+ */
352
+ name?: string | string[]
353
+
354
+ /**
355
+ * Desired package state.
356
+ * - `present`: ensure installed (default).
357
+ * - `absent`: ensure removed (optionally `purge` config files).
358
+ * - `latest`: ensure installed at newest available version.
359
+ * - `build-dep`: install build-dependencies for the named source package(s).
360
+ * @defaultValue "present"
361
+ */
362
+ state?: "present" | "absent" | "latest" | "build-dep"
363
+
364
+ /**
365
+ * Run `apt-get update` before making changes.
366
+ *
367
+ * Honors {@link AptModuleOptions.cache_valid_time | cache_valid_time} to skip
368
+ * the update if the package lists are fresh.
369
+ */
370
+ update_cache?: boolean
371
+
372
+ /**
373
+ * Perform a system-wide upgrade action before package operations.
374
+ * - `no`: skip upgrade
375
+ * - `yes`/`safe`: `apt-get upgrade`
376
+ * - `full`: `apt-get full-upgrade`
377
+ * - `dist`: `apt-get dist-upgrade`
378
+ */
379
+ upgrade?: "no" | "yes" | "safe" | "full" | "dist"
380
+
381
+ /**
382
+ * When removing (state=`absent`), purge configuration files as well.
383
+ */
384
+ purge?: boolean
385
+
386
+ /**
387
+ * After changes, remove automatically installed packages that are no longer needed.
388
+ */
389
+ autoremove?: boolean
390
+
391
+ /**
392
+ * Allow unauthenticated packages (passes `--allow-unauthenticated`).
393
+ */
394
+ allow_unauthenticated?: boolean
395
+
396
+ /**
397
+ * Convenience toggle for dpkg conflict handling (minimal set).
398
+ * When `true`, acts like enabling `--force-confnew` for dpkg.
399
+ * Prefer {@link AptModuleOptions.dpkg_options | dpkg_options} for full control.
400
+ */
401
+ force?: boolean
402
+
403
+ /**
404
+ * Raw dpkg options; each entry is passed as `-o Dpkg::Options::[value]`.
405
+ *
406
+ * Example: `["--force-confnew","--force-confdef"]`.
407
+ *
408
+ * See also the convenience flags:
409
+ * {@link AptModuleOptions.dpkg_force_confnew | dpkg_force_confnew} and
410
+ * {@link AptModuleOptions.dpkg_force_confdef | dpkg_force_confdef}.
411
+ */
412
+ dpkg_options?: string[]
413
+
414
+ /**
415
+ * Whether to install recommended packages.
416
+ * Omitting keeps apt's default behavior (usually `true`).
417
+ */
418
+ install_recommends?: boolean
419
+
420
+ /**
421
+ * Max number of retry attempts for `apt-get update` when `update_cache` is enabled.
422
+ * @defaultValue 5
423
+ */
424
+ update_cache_retries?: number
425
+
426
+ /**
427
+ * Maximum backoff delay (seconds) between update retries.
428
+ * @defaultValue 12
429
+ */
430
+ update_cache_retry_max_delay?: number
431
+
432
+ /**
433
+ * Lock timeout for dpkg/apt operations (seconds).
434
+ * Best-effort via `-o DPkg::Lock::Timeout=[seconds]`.
435
+ */
436
+ lock_timeout?: number
437
+
438
+ /**
439
+ * Set `POLICY_RC_D` in the environment (e.g. `101`) to prevent service starts.
440
+ */
441
+ policy_rc_d?: number
442
+
443
+ /**
444
+ * Upgrade only existing packages; do not install new ones.
445
+ * Maps to `--only-upgrade` where applicable.
446
+ */
447
+ only_upgrade?: boolean
448
+
449
+ /**
450
+ * If set (seconds), skip `apt-get update` when the apt lists directory mtime
451
+ * is newer than `now - cache_valid_time`. Best-effort heuristic.
452
+ */
453
+ cache_valid_time?: number
454
+
455
+ /**
456
+ * Local `.deb` path(s) to install directly. Accepts a single path or list.
457
+ * If provided, {@link AptModuleOptions.name | name} is optional.
458
+ */
459
+ deb?: string | string[]
460
+
461
+ /**
462
+ * Prefer `apt-get` explicitly. (Kept for parity; module already uses `apt-get`.)
463
+ */
464
+ force_apt_get?: boolean
465
+
466
+ /**
467
+ * Run `apt-get clean` at the end.
468
+ */
469
+ clean?: boolean
470
+
471
+ /**
472
+ * Shorthand to add `--force-confnew` to dpkg options.
473
+ * (Equivalent to including it in {@link AptModuleOptions.dpkg_options | dpkg_options}.)
474
+ */
475
+ dpkg_force_confnew?: boolean
476
+
477
+ /**
478
+ * Shorthand to add `--force-confdef` to dpkg options.
479
+ * (Equivalent to including it in {@link AptModuleOptions.dpkg_options | dpkg_options}.)
480
+ */
481
+ dpkg_force_confdef?: boolean
482
+
483
+ /**
484
+ * If `true`, fail the task when `apt-get update` ultimately fails after retries.
485
+ * @defaultValue true
486
+ */
487
+ update_cache_error_fatal?: boolean
488
+ }
489
+
490
+ /**
491
+ * Result returned by the **apt** module.
492
+ */
493
+ export type AptModuleResult = {
494
+ /**
495
+ * Whether any changes were made (install/remove/upgrade/autoremove/clean).
496
+ */
497
+ changed: boolean
498
+
499
+ /**
500
+ * Standard output from the last apt/dpkg command executed (best-effort).
501
+ */
502
+ stdout?: string
503
+
504
+ /**
505
+ * Standard error from the last apt/dpkg command executed (best-effort).
506
+ */
507
+ stderr?: string
508
+
509
+ /**
510
+ * Whether the package cache was updated during this run.
511
+ */
512
+ updated_cache?: boolean
513
+
514
+ /**
515
+ * Whether an upgrade action (per {@link AptModuleOptions.upgrade}) ran.
516
+ */
517
+ upgraded?: boolean
518
+
519
+ /**
520
+ * Whether `apt-get clean` ran.
521
+ */
522
+ cleaned?: boolean
523
+
524
+ /**
525
+ * Whether `apt-get autoremove` ran.
526
+ */
527
+ autoremove?: boolean
528
+
529
+ /**
530
+ * Human-readable message when the module surfaces an error condition.
531
+ */
532
+ msg?: string
533
+ }
534
+
535
+ function quote(v: string) {
536
+ return JSON.stringify(v)
537
+ }
538
+
539
+ function joinPkgs(pkgs?: string | string[]): string[] {
540
+ if (!pkgs) return []
541
+ return Array.isArray(pkgs) ? pkgs : [pkgs]
542
+ }
543
+
544
+ function nowSeconds(): number {
545
+ return Math.floor(Date.now() / 1000)
546
+ }