@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,435 @@
1
+ import type { Katmer } from "../../interfaces/task.interface"
2
+ import { SourcesList } from "./apt-sources-list"
3
+ import { cloneInstance } from "../../utils/object.utils"
4
+ import type { SSHProvider } from "../../providers/ssh/ssh.provider"
5
+ import { KatmerModule } from "../../module"
6
+
7
+ declare module "../../interfaces/task.interface" {
8
+ export namespace Katmer {
9
+ export interface TaskActions {
10
+ /**
11
+ * Manage apt repositories (.list files).
12
+ * See {@link AptRepositoryModuleOptions | AptRepositoryModuleOptions} for all parameters.
13
+ */
14
+ apt_repository?: AptRepositoryModuleOptions
15
+ }
16
+ }
17
+ }
18
+ export interface AptRepositoryModuleOptions {
19
+ /**
20
+ * Desired end-state for the repository line(s).
21
+ *
22
+ * - `"present"`: ensure the given `repo` line(s) exist (create/update files as needed).
23
+ * - `"absent"`: remove matching lines (by exact `repo` or by `regexp`).
24
+ *
25
+ * @defaultValue "present"
26
+ */
27
+ state?: "present" | "absent"
28
+
29
+ /**
30
+ * Repository line(s) to add or remove.
31
+ *
32
+ * - **Required** when `state === "present"`.
33
+ * - **Optional** when `state === "absent"`; you may use this to remove specific line(s), or omit it and use `regexp` to remove by pattern.
34
+ */
35
+ repo?: string | string[]
36
+
37
+ /**
38
+ * Regular expression to match lines for removal (used only with `state === "absent"`).
39
+ *
40
+ * @remarks
41
+ * - Mutually exclusive with `repo` for `state === "present"`.
42
+ * - Useful to sweep multiple entries across files (e.g., remove a discontinued mirror).
43
+ *
44
+ * @example
45
+ * "^deb .*example\\.com"
46
+ */
47
+ regexp?: string
48
+
49
+ /**
50
+ * Override the destination filename (no path).
51
+ *
52
+ * @remarks
53
+ * - When adding lines, places them under `/etc/apt/sources.list.d/[filename].list`.
54
+ * - If omitted, the module chooses a suitable filename (e.g. derived from the repo).
55
+ *
56
+ * @example
57
+ * "example.list"
58
+ */
59
+ filename?: string
60
+
61
+ /**
62
+ * Run `apt-get update` after changes.
63
+ *
64
+ * @remarks
65
+ * - Only runs if any file content actually changed.
66
+ * - Retries are controlled by {@link AptRepositoryModuleOptions.update_cache_retries} and {@link AptRepositoryModuleOptions.update_cache_retry_max_delay}.
67
+ *
68
+ * @defaultValue false
69
+ */
70
+ update_cache?: boolean
71
+
72
+ /**
73
+ * Maximum retry attempts when updating the APT cache.
74
+ *
75
+ * @defaultValue 5
76
+ */
77
+ update_cache_retries?: number
78
+
79
+ /**
80
+ * Maximum exponential backoff (seconds) between retries when updating the cache.
81
+ *
82
+ * @defaultValue 12
83
+ */
84
+ update_cache_retry_max_delay?: number
85
+
86
+ /**
87
+ * Validate TLS certificates when fetching remote keys/sources.
88
+ *
89
+ * @defaultValue true
90
+ */
91
+ validate_certs?: boolean
92
+
93
+ /**
94
+ * Simulate changes without writing files or running `apt-get update`.
95
+ *
96
+ * @remarks
97
+ * - Returns what would change; useful in CI and dry runs.
98
+ *
99
+ * @defaultValue false
100
+ */
101
+ check_mode?: boolean
102
+
103
+ /**
104
+ * File mode for created/updated source files.
105
+ *
106
+ * @remarks
107
+ * - Accepts octal number (e.g., `0o644`) or string (e.g., `"0644"`).
108
+ * - Only applied to files the module creates/updates.
109
+ *
110
+ * @example
111
+ * "0644"
112
+ * @example
113
+ * 0o644
114
+ */
115
+ mode?: number | string
116
+ }
117
+
118
+ /**
119
+ * Result returned by the `apt-repository` module.
120
+ * @public
121
+ */
122
+ export interface AptRepositoryModuleResult {
123
+ /** Original repo parameter (if provided). */
124
+ repo?: string | string[]
125
+ /** Filenames created. */
126
+ sources_added: string[]
127
+ /** Filenames removed. */
128
+ sources_removed: string[]
129
+ }
130
+ /**
131
+ * Manage entries in APT sources lists (e.g. `/etc/apt/sources.list.d/*.list`).
132
+ *
133
+ * @remarks
134
+ * - When `state: "present"`, at least one `repo` line is required.
135
+ * - When `state: "absent"`, you may remove by explicit `repo` line(s) or by `regexp`.
136
+ * - For `state: "present"`, do **not** set `regexp` (mutually exclusive with `repo`).
137
+ * - If `update_cache` is true and changes occurred, `apt-get update` will run with retries/backoff.
138
+ *
139
+ * @examples
140
+ * ```yaml
141
+ * - name: Add a repo line and refresh cache
142
+ * apt-repository:
143
+ * state: present
144
+ * repo: "deb http://deb.debian.org/debian bookworm main"
145
+ * update_cache: true
146
+ *
147
+ * - name: Remove specific repo lines
148
+ * apt-repository:
149
+ * state: absent
150
+ * repo:
151
+ * - "deb http://old.example.com/debian bookworm main"
152
+ * - "deb-src http://old.example.com/debian bookworm main"
153
+ *
154
+ * - name: Remove all lines that match a pattern
155
+ * apt-repository:
156
+ * state: absent
157
+ * regexp: "^deb .*example\\.com"
158
+ *
159
+ * - name: Add deb + deb-src into a fixed filename
160
+ * apt-repository:
161
+ * state: present
162
+ * filename: "example.list"
163
+ * repo:
164
+ * - "deb http://deb.debian.org/debian bookworm main"
165
+ * - "deb-src http://deb.debian.org/debian bookworm main"
166
+ * mode: "0644"
167
+ * ```
168
+ */
169
+
170
+ export class AptRepositoryModule extends KatmerModule<
171
+ AptRepositoryModuleOptions,
172
+ AptRepositoryModuleResult,
173
+ SSHProvider
174
+ > {
175
+ static name = "apt-repository" as const
176
+
177
+ constraints = {
178
+ platform: {
179
+ linux: {
180
+ packages: ["apt"]
181
+ }
182
+ }
183
+ }
184
+ apt_config!: Record<string, any>
185
+ sources_list!: SourcesList
186
+
187
+ async check(ctx: Katmer.TaskContext<SSHProvider>): Promise<void> {
188
+ if (this.params.state === "present" && (this.params as any).regexp) {
189
+ throw "'regexp' is not supported with state: 'present'"
190
+ }
191
+ if ((this.params as any).regexp && (this.params as any).repo) {
192
+ throw "'regexp' and 'repo' cannot be used together"
193
+ }
194
+
195
+ const checkModules = ["apt-get", "apt-config"]
196
+ for (const module of checkModules) {
197
+ const checkResult = await ctx.execSafe(
198
+ `command -v ${module} >/dev/null 2>&1; echo $?`
199
+ )
200
+ if (String(checkResult.stdout).trim() !== "0") {
201
+ throw new Error(`${module} is not available on the target system.`)
202
+ }
203
+ }
204
+
205
+ const { stdout } = await ctx.exec("apt-config dump")
206
+ this.apt_config = parseAPTConfig(stdout)
207
+
208
+ // validate repo lines only for "present"
209
+ const state = this.params.state ?? "present"
210
+ if (state === "present") {
211
+ const repo = this.params.repo
212
+ const repos = Array.isArray(repo) ? repo : [repo]
213
+ const first = repos.find((r) => typeof r === "string" && r.trim())
214
+ if (!first) {
215
+ throw new Error(
216
+ "Invalid configuration: 'repo' must be a non-empty string or string[]"
217
+ )
218
+ }
219
+ const firstToken = first
220
+ .replace(/\[[^\]]*\]/g, "")
221
+ .trim()
222
+ .split(/\s+/)[0]
223
+ if (!/^deb(-src)?$/.test(firstToken)) {
224
+ throw new Error("Repository line must start with 'deb' or 'deb-src'")
225
+ }
226
+ }
227
+ }
228
+
229
+ async initialize(ctx: Katmer.TaskContext<SSHProvider>): Promise<void> {
230
+ this.sources_list = new SourcesList(this.apt_config, ctx)
231
+ await this.sources_list.init()
232
+ }
233
+
234
+ cleanup(_ctx: Katmer.TaskContext<SSHProvider>): Promise<void> {
235
+ return Promise.resolve(undefined)
236
+ }
237
+
238
+ protected async _revert_sources_list(
239
+ ctx: Katmer.TaskContext<SSHProvider>,
240
+ sources_before: Record<string, string>,
241
+ sources_after: Record<string, string>,
242
+ initial_sources_list: SourcesList
243
+ ) {
244
+ try {
245
+ const beforeKeys = new Set(Object.keys(sources_before))
246
+ const afterKeys = new Set(Object.keys(sources_after))
247
+
248
+ // remove files that didn't exist before
249
+ for (const added of Object.keys(sources_after)) {
250
+ if (!beforeKeys.has(added)) {
251
+ await ctx.exec(`rm -f -- ${JSON.stringify(added)}`).catch(() => {})
252
+ }
253
+ }
254
+
255
+ // restore only files that existed before AND whose contents actually changed
256
+ for (const filename of Object.keys(sources_before)) {
257
+ if (!afterKeys.has(filename)) continue
258
+ const beforeContent = sources_before[filename] ?? ""
259
+ const afterContent = sources_after[filename] ?? ""
260
+ if (beforeContent !== afterContent) {
261
+ const payload = beforeContent.replace(/'/g, `'\"'\"'`)
262
+ const tmp = `${filename}.katmer-revert.$RANDOM$$`
263
+ const writeCmd = `'umask 022; tmp=${JSON.stringify(tmp)}; target=${JSON.stringify(
264
+ filename
265
+ )}; printf %s '${payload}' > "$tmp" && mv -f "$tmp" "$target"`
266
+ await ctx.exec(writeCmd).catch(() => {})
267
+ }
268
+ }
269
+ } catch {
270
+ // ignore revert failures
271
+ } finally {
272
+ await initial_sources_list.save()
273
+ }
274
+ }
275
+
276
+ async execute(ctx: Katmer.TaskContext<SSHProvider>) {
277
+ const state = this.params.state ?? "present"
278
+ const update_cache = !!this.params.update_cache
279
+
280
+ const sources_list = this.sources_list
281
+ const initial_sources_list = cloneInstance(sources_list)
282
+ const sources_before = sources_list.dump()
283
+
284
+ try {
285
+ if (state === "present") {
286
+ const { repo, filename } = this.params
287
+ const fname = filename?.trim() || undefined
288
+ const repos = Array.isArray(repo) ? repo : [repo]
289
+ for (const r of repos) {
290
+ if (!r?.trim()) continue
291
+ this.sources_list.add_source(r, "", fname)
292
+ }
293
+ } else {
294
+ const { repo, regexp } = this.params
295
+ const repos = Array.isArray(repo) ? repo : [repo]
296
+ if (repos.length > 0) {
297
+ for (const r of repos) {
298
+ if (!r) continue
299
+ sources_list.remove_source(r, undefined)
300
+ }
301
+ } else {
302
+ // fallback to regexp removal when repo not provided
303
+ sources_list.remove_source(undefined, regexp)
304
+ }
305
+ }
306
+ } catch (ex: any) {
307
+ throw new Error(`Invalid repository string: ${String(ex?.message ?? ex)}`)
308
+ }
309
+
310
+ const sources_after = sources_list.dump()
311
+ const changed =
312
+ JSON.stringify(sources_before) !== JSON.stringify(sources_after)
313
+
314
+ let diff: Array<{
315
+ before: string
316
+ after: string
317
+ before_header: string
318
+ after_header: string
319
+ }> = []
320
+ let sources_added: string[] = []
321
+ let sources_removed: string[] = []
322
+
323
+ if (changed) {
324
+ const beforeKeys = new Set(Object.keys(sources_before))
325
+ const afterKeys = new Set(Object.keys(sources_after))
326
+
327
+ sources_added = [...afterKeys].filter((k) => !beforeKeys.has(k))
328
+ sources_removed = [...beforeKeys].filter((k) => !afterKeys.has(k))
329
+
330
+ const union = new Set<string>([
331
+ ...sources_added,
332
+ ...sources_removed,
333
+ ...Object.keys(sources_before).filter((k) => afterKeys.has(k))
334
+ ])
335
+ diff = [...union]
336
+ .filter(
337
+ (filename) =>
338
+ (sources_before[filename] ?? "") !== (sources_after[filename] ?? "")
339
+ )
340
+ .map((filename) => ({
341
+ before: sources_before[filename] ?? "",
342
+ after: sources_after[filename] ?? "",
343
+ before_header: sources_before[filename] ? filename : "/dev/null",
344
+ after_header: sources_after[filename] ? filename : "/dev/null"
345
+ }))
346
+ }
347
+
348
+ if (changed && !this.params.check_mode) {
349
+ try {
350
+ // save() will write only changed files and delete only those that became empty
351
+ await sources_list.save()
352
+
353
+ if (update_cache) {
354
+ const retries = this.params.update_cache_retries ?? 5
355
+ const maxDelay = this.params.update_cache_retry_max_delay ?? 12
356
+ const randomize = Math.random()
357
+
358
+ let success = false
359
+ let lastErr = ""
360
+
361
+ for (let retry = 0; retry < retries; retry++) {
362
+ const r = await ctx.execSafe("sudo apt-get update -y")
363
+ if (r.code === 0) {
364
+ success = true
365
+ break
366
+ }
367
+ lastErr = r.stderr || r.stdout || "unknown reason"
368
+ ctx.warn(
369
+ `Failed to update cache after ${retry + 1} due to ${lastErr} retry, retrying`
370
+ )
371
+
372
+ let delay = 2 ** retry + randomize
373
+ if (delay > maxDelay) delay = maxDelay + randomize
374
+ ctx.warn(
375
+ `Sleeping for ${Math.round(delay)} seconds, before attempting to update the cache again`
376
+ )
377
+ await new Promise((res) =>
378
+ setTimeout(res, Math.round(delay * 1000))
379
+ )
380
+ }
381
+
382
+ if (!success) {
383
+ ctx.fail(
384
+ `Failed to update apt cache after ${retries} retries: ${lastErr}`
385
+ )
386
+ }
387
+ }
388
+ } catch (e) {
389
+ await this._revert_sources_list(
390
+ ctx,
391
+ sources_before,
392
+ sources_after,
393
+ initial_sources_list
394
+ )
395
+ throw e
396
+ }
397
+ }
398
+
399
+ return {
400
+ changed,
401
+ repo: (this.params as any).repo,
402
+ state,
403
+ sources_added,
404
+ sources_removed,
405
+ diff
406
+ }
407
+ }
408
+ }
409
+
410
+ function parseAPTConfig(raw: string) {
411
+ const result: Record<string, string | string[]> = {}
412
+
413
+ const lines = raw
414
+ .split(/\r?\n/)
415
+ .map((l) => l.trim())
416
+ .filter(Boolean)
417
+
418
+ for (const line of lines) {
419
+ const m = line.match(/^(.+?)\s+"?(.*?)"?;$/)
420
+ if (!m) continue
421
+
422
+ const rawKey = m[1].trim() // e.g., Dir::Etc::sourcelist
423
+ const value = m[2]
424
+
425
+ const current = result[rawKey]
426
+ if (current === undefined) {
427
+ result[rawKey] = value
428
+ } else if (Array.isArray(current)) {
429
+ current.push(value)
430
+ } else {
431
+ result[rawKey] = [current, value]
432
+ }
433
+ }
434
+ return result
435
+ }