@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.
- package/README.md +1 -0
- package/cli/katmer.js +28 -0
- package/cli/run.ts +16 -0
- package/index.ts +5 -0
- package/lib/config.ts +82 -0
- package/lib/interfaces/config.interface.ts +113 -0
- package/lib/interfaces/executor.interface.ts +13 -0
- package/lib/interfaces/module.interface.ts +170 -0
- package/lib/interfaces/provider.interface.ts +214 -0
- package/lib/interfaces/task.interface.ts +100 -0
- package/lib/katmer.ts +126 -0
- package/lib/lookup/env.lookup.ts +13 -0
- package/lib/lookup/file.lookup.ts +23 -0
- package/lib/lookup/index.ts +46 -0
- package/lib/lookup/url.lookup.ts +21 -0
- package/lib/lookup/var.lookup.ts +13 -0
- package/lib/module.ts +560 -0
- package/lib/module_registry.ts +64 -0
- package/lib/modules/apt-repository/apt-repository.module.ts +435 -0
- package/lib/modules/apt-repository/apt-sources-list.ts +363 -0
- package/lib/modules/apt.module.ts +546 -0
- package/lib/modules/archive.module.ts +280 -0
- package/lib/modules/become.module.ts +119 -0
- package/lib/modules/copy.module.ts +807 -0
- package/lib/modules/cron.module.ts +541 -0
- package/lib/modules/debug.module.ts +231 -0
- package/lib/modules/gather_facts.module.ts +605 -0
- package/lib/modules/git.module.ts +243 -0
- package/lib/modules/hostname.module.ts +213 -0
- package/lib/modules/http/http.curl.module.ts +342 -0
- package/lib/modules/http/http.local.module.ts +253 -0
- package/lib/modules/http/http.module.ts +298 -0
- package/lib/modules/index.ts +14 -0
- package/lib/modules/package.module.ts +283 -0
- package/lib/modules/script.module.ts +121 -0
- package/lib/modules/set_fact.module.ts +171 -0
- package/lib/modules/systemd_service.module.ts +373 -0
- package/lib/modules/template.module.ts +478 -0
- package/lib/providers/local.provider.ts +336 -0
- package/lib/providers/provider_response.ts +20 -0
- package/lib/providers/ssh/ssh.provider.ts +420 -0
- package/lib/providers/ssh/ssh.utils.ts +31 -0
- package/lib/schemas/katmer_config.schema.json +358 -0
- package/lib/target_resolver.ts +298 -0
- package/lib/task/controls/environment.control.ts +42 -0
- package/lib/task/controls/index.ts +13 -0
- package/lib/task/controls/loop.control.ts +89 -0
- package/lib/task/controls/register.control.ts +23 -0
- package/lib/task/controls/until.control.ts +64 -0
- package/lib/task/controls/when.control.ts +25 -0
- package/lib/task/task.ts +225 -0
- package/lib/utils/ajv.utils.ts +24 -0
- package/lib/utils/cls.ts +4 -0
- package/lib/utils/datetime.utils.ts +15 -0
- package/lib/utils/errors.ts +25 -0
- package/lib/utils/execute-shell.ts +116 -0
- package/lib/utils/file.utils.ts +68 -0
- package/lib/utils/http.utils.ts +10 -0
- package/lib/utils/json.utils.ts +15 -0
- package/lib/utils/number.utils.ts +9 -0
- package/lib/utils/object.utils.ts +11 -0
- package/lib/utils/os.utils.ts +31 -0
- package/lib/utils/path.utils.ts +9 -0
- package/lib/utils/renderer/render_functions.ts +3 -0
- package/lib/utils/renderer/renderer.ts +89 -0
- package/lib/utils/renderer/twig.ts +191 -0
- package/lib/utils/string.utils.ts +33 -0
- package/lib/utils/typed-event-emitter.ts +26 -0
- package/lib/utils/unix.utils.ts +91 -0
- package/lib/utils/windows.utils.ts +92 -0
- package/package.json +67 -0
|
@@ -0,0 +1,807 @@
|
|
|
1
|
+
import path from "node:path"
|
|
2
|
+
import os from "node:os"
|
|
3
|
+
import crypto from "node:crypto"
|
|
4
|
+
import fs from "fs-extra"
|
|
5
|
+
import {
|
|
6
|
+
type ModuleCommonReturn,
|
|
7
|
+
type ModuleConstraints
|
|
8
|
+
} from "../interfaces/module.interface"
|
|
9
|
+
import type { Katmer } from "../katmer"
|
|
10
|
+
import type { KatmerProvider } from "../interfaces/provider.interface"
|
|
11
|
+
import { SSHProvider } from "../providers/ssh/ssh.provider"
|
|
12
|
+
import { LocalProvider } from "../providers/local.provider"
|
|
13
|
+
import { evalTemplate } from "../utils/renderer/renderer"
|
|
14
|
+
import { exec as execCb } from "node:child_process"
|
|
15
|
+
import { promisify } from "node:util"
|
|
16
|
+
import { toOctal } from "../utils/number.utils"
|
|
17
|
+
import { UnixComms } from "../utils/unix.utils"
|
|
18
|
+
import { WindowsComms } from "../utils/windows.utils"
|
|
19
|
+
import { KatmerModule } from "../module"
|
|
20
|
+
|
|
21
|
+
const exec = promisify(execCb)
|
|
22
|
+
|
|
23
|
+
const REMOTE_STAGE_DIR = "/tmp"
|
|
24
|
+
|
|
25
|
+
declare module "../interfaces/task.interface" {
|
|
26
|
+
export namespace Katmer {
|
|
27
|
+
export interface TaskActions {
|
|
28
|
+
copy?: CopyModuleOptions
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Options for the {@link CopyModule | `copy`} module.
|
|
34
|
+
*
|
|
35
|
+
* Copies **content** or a **file** to a destination path.
|
|
36
|
+
*
|
|
37
|
+
* - If {@link CopyModuleOptions.content | `content`} is set, it is rendered **locally** using Twig
|
|
38
|
+
* against `ctx.variables` and written to {@link CopyModuleOptions.dest | `dest`}.
|
|
39
|
+
* - If {@link CopyModuleOptions.src | `src`} is set:
|
|
40
|
+
* - With **SSH** provider, it is treated as a **controller (local)** path and uploaded to the remote host,
|
|
41
|
+
* unless {@link CopyModuleOptions.remote_src | `remote_src`} is `true`.
|
|
42
|
+
* - With **Local** provider, it is a path on the **same machine**.
|
|
43
|
+
* - If {@link CopyModuleOptions.remote_src | `remote_src`} is `true` (SSH only), the copy happens **remote→remote** using shell.
|
|
44
|
+
* - If both `src` and `content` are set, the task fails.
|
|
45
|
+
*
|
|
46
|
+
* @public
|
|
47
|
+
*/
|
|
48
|
+
export interface CopyModuleOptions {
|
|
49
|
+
/**
|
|
50
|
+
* Source path.
|
|
51
|
+
* - **SSH** (default): controller → remote upload (uses NodeSSH).
|
|
52
|
+
* - **SSH + `remote_src: true`**: remote → remote via shell (`cp`).
|
|
53
|
+
* - **Local**: local → local.
|
|
54
|
+
*/
|
|
55
|
+
src?: string
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Inline content to write (templated).
|
|
59
|
+
* If a `string`, it is rendered using Twig; if a `Uint8Array`, it is written raw.
|
|
60
|
+
* Mutually exclusive with {@link CopyModuleOptions.src | `src`}.
|
|
61
|
+
*/
|
|
62
|
+
content?: string | Uint8Array
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Destination path (absolute recommended).
|
|
66
|
+
* - **SSH**: path on the target host.
|
|
67
|
+
* - **Local**: local filesystem path.
|
|
68
|
+
*/
|
|
69
|
+
dest: string
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Ensure parent directories exist before writing.
|
|
73
|
+
* - **SSH**: `mkdir -p`
|
|
74
|
+
* - **Local**: `fs.ensureDir`
|
|
75
|
+
* @defaultValue true
|
|
76
|
+
*/
|
|
77
|
+
parents?: boolean
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Treat {@link CopyModuleOptions.src | `src`} as a **remote** path and perform remote→remote copy.
|
|
81
|
+
* Only valid for **SSH** provider.
|
|
82
|
+
* @defaultValue false
|
|
83
|
+
*/
|
|
84
|
+
remote_src?: boolean
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* If `true`, overwrite even when content is identical (still creates backup if requested).
|
|
88
|
+
* When `false`, the module is idempotent and avoids rewriting unchanged files.
|
|
89
|
+
* @defaultValue false
|
|
90
|
+
*/
|
|
91
|
+
force?: boolean
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* When `true`, saves a timestamped backup of the existing `dest` before overwrite.
|
|
95
|
+
* The resulting path is returned in {@link CopyModuleResult.backup_file | `backup_file`}.
|
|
96
|
+
* @defaultValue false
|
|
97
|
+
*/
|
|
98
|
+
backup?: boolean
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Validate the **staged temporary file** before placing it.
|
|
102
|
+
* The command is executed against the staged file:
|
|
103
|
+
* - If the template contains `%s`, it is replaced with the temp filename.
|
|
104
|
+
* - Otherwise the temp filename is appended as the last argument.
|
|
105
|
+
*
|
|
106
|
+
* Only after a **zero exit code** will the temp file be moved to `dest`.
|
|
107
|
+
*
|
|
108
|
+
* @example
|
|
109
|
+
* Validate an nginx config before replacing:
|
|
110
|
+
* ```yaml
|
|
111
|
+
* copy:
|
|
112
|
+
* src: ./nginx.conf
|
|
113
|
+
* dest: /etc/nginx/nginx.conf
|
|
114
|
+
* validate: "nginx -t -c %s"
|
|
115
|
+
* ```
|
|
116
|
+
*/
|
|
117
|
+
validate?: string
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* File mode for `dest`. Accepts octal `string` (e.g. `"0644"`) or number (e.g. `0o644`).
|
|
121
|
+
* - **SSH**: applied via `chmod`.
|
|
122
|
+
* - **Local**: applied via `fs.chmod`.
|
|
123
|
+
*/
|
|
124
|
+
mode?: string | number
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* File owner for `dest` (`chown owner[:group]`).
|
|
128
|
+
* - **SSH**: may be a name (e.g. `"root"`).
|
|
129
|
+
* - **Local**: POSIX only; names are resolved via `/etc/passwd`, otherwise numeric `uid` is expected.
|
|
130
|
+
*/
|
|
131
|
+
owner?: string
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* File group for `dest` (`chown :group`).
|
|
135
|
+
* - **SSH**: may be a name (e.g. `"www-data"`).
|
|
136
|
+
* - **Local**: POSIX only; names are resolved via `/etc/group`, otherwise numeric `gid` is expected.
|
|
137
|
+
*/
|
|
138
|
+
group?: string
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Result returned by the {@link CopyModule | `copy`} module.
|
|
143
|
+
*
|
|
144
|
+
* @public
|
|
145
|
+
*/
|
|
146
|
+
export interface CopyModuleResult extends ModuleCommonReturn {
|
|
147
|
+
/** Destination path written/examined. */
|
|
148
|
+
dest: string
|
|
149
|
+
/** SHA-256 hex digest of the final file, when determinable. */
|
|
150
|
+
checksum?: string
|
|
151
|
+
/** Backup file path when `backup: true` and an overwrite occurred. */
|
|
152
|
+
backup_file?: string
|
|
153
|
+
/** Whether the `validate` command was executed and passed. */
|
|
154
|
+
validated?: boolean
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Copy files or inline content to a destination, with idempotency, optional validation,
|
|
159
|
+
* and metadata management (mode/owner/group).
|
|
160
|
+
*
|
|
161
|
+
* @remarks
|
|
162
|
+
* **Behavior by provider**
|
|
163
|
+
* - **SSH**:
|
|
164
|
+
* - Ensures parent directory with `mkdir -p` when `parents: true`.
|
|
165
|
+
* - Stages to a remote temp file (upload or `cp` for remote_src), validates, then moves into place atomically.
|
|
166
|
+
* - Applies `chmod`/`chown` via shell when requested.
|
|
167
|
+
* - Computes SHA-256 using `sha256sum`/`shasum -a 256` if available.
|
|
168
|
+
* - **Local**:
|
|
169
|
+
* - Uses pure Node.js (`fs.ensureDir`, `fs.move`, `fs.chmod`, `fs.chown`).
|
|
170
|
+
* - Owner/group resolution reads `/etc/passwd` and `/etc/group` (POSIX).
|
|
171
|
+
* - Computes SHA-256 by streaming the file via Node crypto.
|
|
172
|
+
*
|
|
173
|
+
* **Idempotency**
|
|
174
|
+
* - When `force: false` (default), compares the incoming staged file hash with the current `dest` hash.
|
|
175
|
+
* If equal, the write is skipped and `changed=false`.
|
|
176
|
+
*
|
|
177
|
+
* **Validation**
|
|
178
|
+
* - If {@link CopyModuleOptions.validate | `validate`} is defined, the command runs against the staged file.
|
|
179
|
+
* Only a zero exit code allows the replacement to proceed.
|
|
180
|
+
*
|
|
181
|
+
* @examples
|
|
182
|
+
* ```yaml
|
|
183
|
+
* - name: Copy a local file to remote, idempotent, set permissions
|
|
184
|
+
* copy:
|
|
185
|
+
* src: ./conf/app.conf
|
|
186
|
+
* dest: /etc/myapp/app.conf
|
|
187
|
+
* mode: "0644"
|
|
188
|
+
* owner: root
|
|
189
|
+
* group: root
|
|
190
|
+
*
|
|
191
|
+
* - name: Render robots.txt
|
|
192
|
+
* copy:
|
|
193
|
+
* content: |
|
|
194
|
+
* User-agent: *
|
|
195
|
+
* Disallow: {{ disallow ? '/' : '' }}
|
|
196
|
+
* dest: /var/www/robots.txt
|
|
197
|
+
*
|
|
198
|
+
* - name: Copy generated file on remote host
|
|
199
|
+
* copy:
|
|
200
|
+
* src: /tmp/generated.cfg
|
|
201
|
+
* dest: /etc/myapp/generated.cfg
|
|
202
|
+
* remote_src: true
|
|
203
|
+
* ```
|
|
204
|
+
*
|
|
205
|
+
* @public
|
|
206
|
+
*/
|
|
207
|
+
export class CopyModule extends KatmerModule<
|
|
208
|
+
CopyModuleOptions,
|
|
209
|
+
CopyModuleResult,
|
|
210
|
+
KatmerProvider
|
|
211
|
+
> {
|
|
212
|
+
static name = "copy" as const
|
|
213
|
+
|
|
214
|
+
constraints = {
|
|
215
|
+
platform: {
|
|
216
|
+
any: true
|
|
217
|
+
}
|
|
218
|
+
} satisfies ModuleConstraints
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Validate parameters before execution.
|
|
222
|
+
*
|
|
223
|
+
* @throws Error if:
|
|
224
|
+
* - Both {@link CopyModuleOptions.content | `content`} and {@link CopyModuleOptions.src | `src`} are set.
|
|
225
|
+
* - {@link CopyModuleOptions.dest | `dest`} is missing or empty.
|
|
226
|
+
*/
|
|
227
|
+
async check(_ctx: Katmer.TaskContext) {
|
|
228
|
+
if (this.params.content != null && this.params.src != null) {
|
|
229
|
+
throw new Error("copy: 'content' and 'src' are mutually exclusive.")
|
|
230
|
+
}
|
|
231
|
+
if (!this.params.dest) {
|
|
232
|
+
throw new Error("copy: 'dest' is required.")
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Initialize resources (no-op). */
|
|
237
|
+
async initialize(_ctx: Katmer.TaskContext) {}
|
|
238
|
+
/** Cleanup resources (no-op). */
|
|
239
|
+
async cleanup(_ctx: Katmer.TaskContext) {}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Execute the copy:
|
|
243
|
+
* - Renders string fields (including `content`) against `ctx.variables`.
|
|
244
|
+
* - Branches to SSH or Local implementation.
|
|
245
|
+
* - Returns {@link CopyModuleResult} with `changed`, `checksum`, optional `backup_file`, etc.
|
|
246
|
+
*/
|
|
247
|
+
async execute(ctx: Katmer.TaskContext): Promise<CopyModuleResult> {
|
|
248
|
+
const p = await this.renderFields(this.params, ctx)
|
|
249
|
+
|
|
250
|
+
try {
|
|
251
|
+
if (ctx.provider instanceof SSHProvider) {
|
|
252
|
+
return await this.runSsh(ctx as Katmer.TaskContext<SSHProvider>, p)
|
|
253
|
+
} else if (ctx.provider instanceof LocalProvider) {
|
|
254
|
+
return await this.runLocal(ctx, p)
|
|
255
|
+
}
|
|
256
|
+
throw new Error(
|
|
257
|
+
`copy: unsupported provider ${ctx.provider?.constructor?.name}`
|
|
258
|
+
)
|
|
259
|
+
} catch (err: any) {
|
|
260
|
+
return {
|
|
261
|
+
changed: false,
|
|
262
|
+
failed: true,
|
|
263
|
+
msg: err?.message || String(err),
|
|
264
|
+
dest: p.dest
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// ────────────────────────────────────────────────────────────────────────────────
|
|
270
|
+
// Provider paths
|
|
271
|
+
// ────────────────────────────────────────────────────────────────────────────────
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Local provider path:
|
|
275
|
+
* - Ensures parent dir
|
|
276
|
+
* - Stages to a temp file (from `content` or `src`)
|
|
277
|
+
* - Optional validation
|
|
278
|
+
* - Optional backup
|
|
279
|
+
* - Atomic move to `dest`
|
|
280
|
+
* - Apply `mode` and `owner`/`group` via Node APIs
|
|
281
|
+
*
|
|
282
|
+
* @internal
|
|
283
|
+
*/
|
|
284
|
+
private async runLocal(
|
|
285
|
+
ctx: Katmer.TaskContext<LocalProvider>,
|
|
286
|
+
p: RequiredSome<CopyModuleOptions, "dest">
|
|
287
|
+
): Promise<CopyModuleResult> {
|
|
288
|
+
const parents = p.parents ?? true
|
|
289
|
+
const force = !!p.force
|
|
290
|
+
const doBackup = !!p.backup
|
|
291
|
+
const dest = p.dest
|
|
292
|
+
|
|
293
|
+
if (parents) await fs.ensureDir(path.dirname(dest))
|
|
294
|
+
|
|
295
|
+
const existsBefore = await fs.pathExists(dest)
|
|
296
|
+
const prevHash = existsBefore ? await sha256FileLocal(dest) : null
|
|
297
|
+
|
|
298
|
+
let tmpPath: string | null = null
|
|
299
|
+
if (p.content != null) {
|
|
300
|
+
tmpPath = await writeTempLocal(p.content)
|
|
301
|
+
} else if (p.src) {
|
|
302
|
+
tmpPath = await writeTempLocal(await fs.readFile(p.src))
|
|
303
|
+
} else if (p.remote_src) {
|
|
304
|
+
throw new Error("copy: 'remote_src' is only valid with SSH provider.")
|
|
305
|
+
} else {
|
|
306
|
+
throw new Error("copy: one of 'content' or 'src' must be provided.")
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Idempotency: compare hashes if dest exists and not forced
|
|
310
|
+
let changed = true
|
|
311
|
+
if (existsBefore && !force) {
|
|
312
|
+
const incomingHash = await sha256FileLocal(tmpPath!)
|
|
313
|
+
if (prevHash && incomingHash && prevHash === incomingHash) {
|
|
314
|
+
changed = false
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Validate against temp file
|
|
319
|
+
if (p.validate && tmpPath) {
|
|
320
|
+
await validateLocalViaExec(p.validate, tmpPath)
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Backup before replace
|
|
324
|
+
let backup_file: string | undefined
|
|
325
|
+
if (doBackup && existsBefore && changed) {
|
|
326
|
+
backup_file = `${dest}.${timestamp()}.bak`
|
|
327
|
+
await fs.copy(dest, backup_file, {
|
|
328
|
+
preserveTimestamps: true,
|
|
329
|
+
errorOnExist: false
|
|
330
|
+
})
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Move staged into place atomically
|
|
334
|
+
if (changed && tmpPath) {
|
|
335
|
+
await fs.move(tmpPath, dest, { overwrite: true })
|
|
336
|
+
tmpPath = null
|
|
337
|
+
} else if (tmpPath) {
|
|
338
|
+
await fs.remove(tmpPath)
|
|
339
|
+
tmpPath = null
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// Apply mode (Node API)
|
|
343
|
+
if (process.platform === "win32") {
|
|
344
|
+
// skip chmod on Windows
|
|
345
|
+
} else if (p.mode != null) {
|
|
346
|
+
try {
|
|
347
|
+
await fs.chmod(dest, parseMode(p.mode))
|
|
348
|
+
} catch (e) {
|
|
349
|
+
ctx.logger?.warn?.({
|
|
350
|
+
msg: `copy(local): chmod failed: ${String(e)}`
|
|
351
|
+
})
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// Apply owner/group (POSIX)
|
|
356
|
+
if (process.platform === "win32") {
|
|
357
|
+
// skip on Windows
|
|
358
|
+
} else if (p.owner != null || p.group != null) {
|
|
359
|
+
try {
|
|
360
|
+
const ids = await toUidGid(p.owner, p.group, ctx)
|
|
361
|
+
if (ids) {
|
|
362
|
+
await fs.chown(
|
|
363
|
+
dest,
|
|
364
|
+
ids.uid ?? (await currentUid()),
|
|
365
|
+
ids.gid ?? (await currentGid())
|
|
366
|
+
)
|
|
367
|
+
} else {
|
|
368
|
+
ctx.logger?.warn?.({
|
|
369
|
+
msg: "copy(local): could not resolve owner/group; skipping chown"
|
|
370
|
+
})
|
|
371
|
+
}
|
|
372
|
+
} catch (e) {
|
|
373
|
+
// On Windows or unsupported FS, chown may throw — log and continue
|
|
374
|
+
ctx.logger?.warn?.({
|
|
375
|
+
msg: `copy(local): chown failed: ${String(e)}`
|
|
376
|
+
})
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const finalHash =
|
|
381
|
+
(await fs.pathExists(dest)) ? await sha256FileLocal(dest) : null
|
|
382
|
+
|
|
383
|
+
return {
|
|
384
|
+
changed,
|
|
385
|
+
failed: false,
|
|
386
|
+
dest,
|
|
387
|
+
checksum: finalHash ?? undefined,
|
|
388
|
+
backup_file,
|
|
389
|
+
validated: !!p.validate
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* SSH provider path:
|
|
395
|
+
* - Ensures parent dir
|
|
396
|
+
* - Stages to a remote temp file (upload or remote `cp`)
|
|
397
|
+
* - Idempotency by remote hash
|
|
398
|
+
* - Optional validation on staged temp file
|
|
399
|
+
* - Optional backup
|
|
400
|
+
* - Atomic `mv` into place
|
|
401
|
+
* - Apply `mode` / `owner` / `group` via shell
|
|
402
|
+
*
|
|
403
|
+
* @internal
|
|
404
|
+
*/
|
|
405
|
+
private async runSsh(
|
|
406
|
+
ctx: Katmer.TaskContext<SSHProvider>,
|
|
407
|
+
p: RequiredSome<CopyModuleOptions, "dest">
|
|
408
|
+
): Promise<CopyModuleResult> {
|
|
409
|
+
const osfam = ctx.provider.os.family as string
|
|
410
|
+
|
|
411
|
+
const parents = p.parents ?? true
|
|
412
|
+
const force = !!p.force
|
|
413
|
+
const doBackup = !!p.backup
|
|
414
|
+
const dest = p.dest
|
|
415
|
+
const client = ctx.provider.client!
|
|
416
|
+
|
|
417
|
+
// Ensure parent of dest exists (runs via shell; your become wrapper applies here)
|
|
418
|
+
if (parents) {
|
|
419
|
+
if (osfam === "windows") {
|
|
420
|
+
await WindowsComms.ensureDir(
|
|
421
|
+
ctx,
|
|
422
|
+
dest.replace(/\\/g, "/").replace(/\/[^/]*$/, "")
|
|
423
|
+
)
|
|
424
|
+
} else {
|
|
425
|
+
await ctx.exec(`mkdir -p -- $(dirname -- ${JSON.stringify(dest)})`)
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// Existing dest hash (if any)
|
|
430
|
+
const existed = await UnixComms.fileExists(ctx, dest)
|
|
431
|
+
const prevHash = existed ? await sha256FileRemote(ctx, dest) : null
|
|
432
|
+
|
|
433
|
+
// Always stage in a user-writable directory to avoid SFTP permission problems
|
|
434
|
+
const remoteTmp = path.posix.join(
|
|
435
|
+
REMOTE_STAGE_DIR,
|
|
436
|
+
`katmer-copy-${randomId()}`
|
|
437
|
+
)
|
|
438
|
+
let staged = false
|
|
439
|
+
|
|
440
|
+
// ---- Stage content/file into /tmp (SFTP for local->remote; cp for remote_src) ----
|
|
441
|
+
if (p.content != null) {
|
|
442
|
+
const tmp = await writeTempLocal(p.content)
|
|
443
|
+
try {
|
|
444
|
+
await client.putFile(tmp, remoteTmp) // SFTP never needs sudo; /tmp is writable
|
|
445
|
+
staged = true
|
|
446
|
+
} finally {
|
|
447
|
+
await fs.remove(tmp)
|
|
448
|
+
}
|
|
449
|
+
} else if (p.src && !p.remote_src) {
|
|
450
|
+
if (osfam === "windows") {
|
|
451
|
+
// PutFile works too, but ensure Windows path separators are handled by node-ssh
|
|
452
|
+
await client.putFile(p.src, remoteTmp)
|
|
453
|
+
} else {
|
|
454
|
+
await client.putFile(p.src, remoteTmp)
|
|
455
|
+
} // controller -> remote:/tmp
|
|
456
|
+
staged = true
|
|
457
|
+
} else if (p.src && p.remote_src) {
|
|
458
|
+
// Remote -> /tmp (shell; become applies as needed for reading src)
|
|
459
|
+
await ctx.exec(
|
|
460
|
+
`cp -f -- ${JSON.stringify(p.src)} ${JSON.stringify(remoteTmp)}`
|
|
461
|
+
)
|
|
462
|
+
staged = true
|
|
463
|
+
} else {
|
|
464
|
+
throw new Error("copy: one of 'content' or 'src' must be provided.")
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// ---- Idempotency (if not forced) compare remote hashes ----
|
|
468
|
+
let changed = true
|
|
469
|
+
if (existed && staged && !force) {
|
|
470
|
+
const incomingHash = await sha256FileRemote(ctx, remoteTmp)
|
|
471
|
+
if (prevHash && incomingHash && prevHash === incomingHash) {
|
|
472
|
+
await ctx.exec(`rm -f -- ${JSON.stringify(remoteTmp)}`)
|
|
473
|
+
staged = false
|
|
474
|
+
changed = false
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// ---- Validate staged file (runs via shell; become applies) ----
|
|
479
|
+
if (staged && p.validate) {
|
|
480
|
+
await validateRemoteViaShell(ctx, p.validate, remoteTmp)
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// ---- Optional backup of current dest ----
|
|
484
|
+
let backup_file: string | undefined
|
|
485
|
+
if (doBackup && existed && changed) {
|
|
486
|
+
backup_file = `${dest}.${timestamp()}.bak`
|
|
487
|
+
await ctx.exec(
|
|
488
|
+
`cp -p -- ${JSON.stringify(dest)} ${JSON.stringify(backup_file)} `
|
|
489
|
+
)
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// ---- Install into place (atomic as possible) ----
|
|
493
|
+
if (staged && changed) {
|
|
494
|
+
const modeStr = p.mode != null ? toOctal(p.mode) : undefined
|
|
495
|
+
if (modeStr) {
|
|
496
|
+
// Try install first (creates parents, sets mode atomically); fallback to mv+chmod
|
|
497
|
+
await ctx.exec(
|
|
498
|
+
`install -D -m ${modeStr} -- ${JSON.stringify(remoteTmp)} ${JSON.stringify(dest)} || (mv -f -- ${JSON.stringify(remoteTmp)} ${JSON.stringify(dest)} && chmod ${modeStr} -- ${JSON.stringify(dest)})`
|
|
499
|
+
)
|
|
500
|
+
} else {
|
|
501
|
+
await ctx.exec(
|
|
502
|
+
`mv -f -- ${JSON.stringify(remoteTmp)} ${JSON.stringify(dest)}`
|
|
503
|
+
)
|
|
504
|
+
}
|
|
505
|
+
} else if (staged) {
|
|
506
|
+
await ctx.exec(`rm -f -- ${JSON.stringify(remoteTmp)}`)
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// ---- Owner / group (after install), still via shell (become applies) ----
|
|
510
|
+
if (osfam === "windows") {
|
|
511
|
+
// Skip POSIX owner/group/mode. (Future: use icacls to set ACLs if provided.)
|
|
512
|
+
} else {
|
|
513
|
+
if (p.owner || p.group) {
|
|
514
|
+
const who = [p.owner ?? "", p.group ? `:${p.group}` : ""].join("")
|
|
515
|
+
await ctx.exec(`chown ${who} -- ${JSON.stringify(dest)}`)
|
|
516
|
+
}
|
|
517
|
+
if (p.mode != null) {
|
|
518
|
+
await ctx.exec(`chmod ${toOctal(p.mode)} -- ${JSON.stringify(dest)}`)
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// Final hash (best-effort)
|
|
523
|
+
const finalHash =
|
|
524
|
+
osfam === "windows" ? null
|
|
525
|
+
: (await UnixComms.fileExists(ctx, dest)) ?
|
|
526
|
+
await sha256FileRemote(ctx, dest)
|
|
527
|
+
: null
|
|
528
|
+
|
|
529
|
+
return {
|
|
530
|
+
changed,
|
|
531
|
+
failed: false,
|
|
532
|
+
dest,
|
|
533
|
+
checksum: finalHash ?? undefined,
|
|
534
|
+
backup_file,
|
|
535
|
+
validated: !!p.validate
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// ────────────────────────────────────────────────────────────────────────────────
|
|
540
|
+
// Helpers
|
|
541
|
+
// ────────────────────────────────────────────────────────────────────────────────
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* Render only the string-like fields against `ctx.variables`.
|
|
545
|
+
* - `content` is rendered only if it is a `string`; `Uint8Array` is passed through untouched.
|
|
546
|
+
*
|
|
547
|
+
* @internal
|
|
548
|
+
*/
|
|
549
|
+
private async renderFields(
|
|
550
|
+
input: CopyModuleOptions,
|
|
551
|
+
ctx: Katmer.TaskContext<KatmerProvider>
|
|
552
|
+
): Promise<RequiredSome<CopyModuleOptions, "dest">> {
|
|
553
|
+
const out: CopyModuleOptions = { ...input }
|
|
554
|
+
const renderIfString = async <T extends string | number | undefined>(
|
|
555
|
+
v?: T
|
|
556
|
+
): Promise<T> =>
|
|
557
|
+
typeof v === "string" ?
|
|
558
|
+
((await evalTemplate(v, ctx.variables)) as T)
|
|
559
|
+
: (v as T)
|
|
560
|
+
|
|
561
|
+
const absFromCwd = (s?: string) => {
|
|
562
|
+
if (typeof s !== "string" || !s.trim()) return s
|
|
563
|
+
// If already absolute, keep it. Else resolve from config.cwd (fallback to process.cwd()).
|
|
564
|
+
return path.isAbsolute(s) ? s : (
|
|
565
|
+
path.resolve(ctx.config?.cwd ?? process.cwd(), s)
|
|
566
|
+
)
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
out.src = absFromCwd(await renderIfString(input.src))
|
|
570
|
+
out.dest = await renderIfString(input.dest)
|
|
571
|
+
out.owner = await renderIfString(input.owner)
|
|
572
|
+
out.group = await renderIfString(input.group)
|
|
573
|
+
out.mode = await renderIfString(input.mode)
|
|
574
|
+
|
|
575
|
+
if (typeof input.content === "string") {
|
|
576
|
+
out.content = await renderIfString(input.content)
|
|
577
|
+
} else {
|
|
578
|
+
out.content = input.content
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
// Helpful early failure for local→remote branch
|
|
582
|
+
if (!out.content && out.src && !(await fs.pathExists(out.src))) {
|
|
583
|
+
throw new Error(
|
|
584
|
+
`copy: local source not found: ${out.src} (resolved from ${ctx.config?.cwd ?? process.cwd()})`
|
|
585
|
+
)
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
if (!out.dest) throw new Error("copy: rendered 'dest' was empty.")
|
|
589
|
+
return out as RequiredSome<CopyModuleOptions, "dest">
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
// ────────────────────────────────────────────────────────────────────────────────
|
|
594
|
+
// Utility functions (shared)
|
|
595
|
+
// ────────────────────────────────────────────────────────────────────────────────
|
|
596
|
+
|
|
597
|
+
/** @internal */
|
|
598
|
+
type RequiredSome<T, K extends keyof T> = T & Required<Pick<T, K>>
|
|
599
|
+
|
|
600
|
+
/** @internal */
|
|
601
|
+
function randomId() {
|
|
602
|
+
return crypto.randomBytes(5).toString("hex")
|
|
603
|
+
}
|
|
604
|
+
/** @internal */
|
|
605
|
+
function timestamp() {
|
|
606
|
+
const d = new Date()
|
|
607
|
+
const pad = (n: number) => String(n).padStart(2, "0")
|
|
608
|
+
return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/** @internal */
|
|
612
|
+
async function writeTempLocal(content: string | Uint8Array): Promise<string> {
|
|
613
|
+
const tmp = path.join(os.tmpdir(), `katmer-copy-${randomId()}`)
|
|
614
|
+
await fs.writeFile(tmp, content)
|
|
615
|
+
return tmp
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/** @internal */
|
|
619
|
+
async function sha256FileLocal(p: string): Promise<string | null> {
|
|
620
|
+
try {
|
|
621
|
+
const hash = crypto.createHash("sha256")
|
|
622
|
+
const s = fs.createReadStream(p)
|
|
623
|
+
await new Promise<void>((resolve, reject) => {
|
|
624
|
+
s.on("data", (c) => hash.update(c))
|
|
625
|
+
s.on("error", reject)
|
|
626
|
+
s.on("end", () => resolve())
|
|
627
|
+
})
|
|
628
|
+
return hash.digest("hex")
|
|
629
|
+
} catch {
|
|
630
|
+
return null
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
/** @internal */
|
|
635
|
+
function parseMode(mode: string | number): number {
|
|
636
|
+
if (typeof mode === "number") return mode
|
|
637
|
+
const m = String(mode).trim()
|
|
638
|
+
// accept "0644" or "644"
|
|
639
|
+
const oct = m.startsWith("0") ? m : "0" + m
|
|
640
|
+
return Number.parseInt(oct, 8)
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/** @internal */
|
|
644
|
+
async function sha256FileRemote(
|
|
645
|
+
ctx: Katmer.TaskContext<SSHProvider>,
|
|
646
|
+
p: string
|
|
647
|
+
): Promise<string | null> {
|
|
648
|
+
let r = await ctx.execSafe(
|
|
649
|
+
`sha256sum -- ${JSON.stringify(p)} 2>/dev/null || true`
|
|
650
|
+
)
|
|
651
|
+
if (r.code === 0 && r.stdout?.trim()) {
|
|
652
|
+
const m = r.stdout.trim().match(/^([a-f0-9]{64})\s+/i)
|
|
653
|
+
if (m) return m[1].toLowerCase()
|
|
654
|
+
}
|
|
655
|
+
r = await ctx.execSafe(
|
|
656
|
+
`shasum -a 256 -- ${JSON.stringify(p)} 2>/dev/null || true`
|
|
657
|
+
)
|
|
658
|
+
if (r.code === 0 && r.stdout?.trim()) {
|
|
659
|
+
const m = r.stdout.trim().match(/^([a-f0-9]{64})\s+/i)
|
|
660
|
+
if (m) return m[1].toLowerCase()
|
|
661
|
+
}
|
|
662
|
+
return null
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
/** @internal */
|
|
666
|
+
async function validateRemoteViaShell(
|
|
667
|
+
ctx: Katmer.TaskContext<SSHProvider>,
|
|
668
|
+
template: string,
|
|
669
|
+
tmpPath: string
|
|
670
|
+
) {
|
|
671
|
+
const quoted = JSON.stringify(tmpPath)
|
|
672
|
+
const cmd =
|
|
673
|
+
template.includes("%s") ?
|
|
674
|
+
template.replaceAll("%s", quoted)
|
|
675
|
+
: `${template} ${quoted}`
|
|
676
|
+
const r = await ctx.execSafe(cmd)
|
|
677
|
+
if (r.code) {
|
|
678
|
+
const details = (r.stderr || r.stdout || "").trim()
|
|
679
|
+
throw new Error(
|
|
680
|
+
details ?
|
|
681
|
+
`validate failed: ${details}`
|
|
682
|
+
: `validate failed with code ${r.code}`
|
|
683
|
+
)
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
/** @internal */
|
|
688
|
+
async function validateLocalViaExec(template: string, tmpPath: string) {
|
|
689
|
+
const cmd =
|
|
690
|
+
template.includes("%s") ?
|
|
691
|
+
template.replaceAll("%s", tmpPath)
|
|
692
|
+
: `${template} ${tmpPath}`
|
|
693
|
+
// Use a shell because template may contain flags/pipe/redir;
|
|
694
|
+
const { stderr } = await exec(cmd).catch((e: any) => {
|
|
695
|
+
const msg = e?.stderr || e?.stdout || e?.message || String(e)
|
|
696
|
+
const err = new Error(`validate failed: ${msg.trim()}`)
|
|
697
|
+
;(err as any).code = e?.code
|
|
698
|
+
throw err
|
|
699
|
+
})
|
|
700
|
+
if (stderr && /error/i.test(stderr)) {
|
|
701
|
+
// not fatal by itself; command exit code already handled
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
// POSIX uid/gid resolution via /etc files
|
|
706
|
+
|
|
707
|
+
/** @internal */
|
|
708
|
+
const passwdCache = new Map<string, number>()
|
|
709
|
+
/** @internal */
|
|
710
|
+
const groupCache = new Map<string, number>()
|
|
711
|
+
/** @internal */
|
|
712
|
+
let passwdLoaded = false
|
|
713
|
+
/** @internal */
|
|
714
|
+
let groupLoaded = false
|
|
715
|
+
|
|
716
|
+
/** @internal */
|
|
717
|
+
async function loadEtcPasswd() {
|
|
718
|
+
if (passwdLoaded || process.platform === "win32") return
|
|
719
|
+
try {
|
|
720
|
+
const data = await fs.readFile("/etc/passwd", "utf8")
|
|
721
|
+
for (const line of data.split(/\r?\n/)) {
|
|
722
|
+
if (!line || line.startsWith("#")) continue
|
|
723
|
+
const [name, , uidStr] = line.split(":")
|
|
724
|
+
const uid = Number.parseInt(uidStr, 10)
|
|
725
|
+
if (!Number.isNaN(uid)) passwdCache.set(name, uid)
|
|
726
|
+
}
|
|
727
|
+
} catch {}
|
|
728
|
+
passwdLoaded = true
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
/** @internal */
|
|
732
|
+
async function loadEtcGroup() {
|
|
733
|
+
if (groupLoaded || process.platform === "win32") return
|
|
734
|
+
try {
|
|
735
|
+
const data = await fs.readFile("/etc/group", "utf8")
|
|
736
|
+
for (const line of data.split(/\r?\n/)) {
|
|
737
|
+
if (!line || line.startsWith("#")) continue
|
|
738
|
+
const [name, , gidStr] = line.split(":")
|
|
739
|
+
const gid = Number.parseInt(gidStr, 10)
|
|
740
|
+
if (!Number.isNaN(gid)) groupCache.set(name, gid)
|
|
741
|
+
}
|
|
742
|
+
} catch {}
|
|
743
|
+
groupLoaded = true
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
/** @internal */
|
|
747
|
+
async function currentUid(): Promise<number> {
|
|
748
|
+
try {
|
|
749
|
+
return process.getuid?.() ?? 0
|
|
750
|
+
} catch {
|
|
751
|
+
return 0
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
/** @internal */
|
|
755
|
+
async function currentGid(): Promise<number> {
|
|
756
|
+
try {
|
|
757
|
+
return process.getgid?.() ?? 0
|
|
758
|
+
} catch {
|
|
759
|
+
return 0
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
/**
|
|
764
|
+
* Resolve `owner`/`group` to POSIX `uid`/`gid` on local systems.
|
|
765
|
+
* - Accepts either numbers or names; names are resolved using `/etc/passwd` and `/etc/group`.
|
|
766
|
+
* - Returns `null` on Windows or when neither could be resolved.
|
|
767
|
+
*
|
|
768
|
+
* @internal
|
|
769
|
+
*/
|
|
770
|
+
async function toUidGid(
|
|
771
|
+
owner?: string,
|
|
772
|
+
group?: string,
|
|
773
|
+
ctx?: { logger?: any }
|
|
774
|
+
): Promise<{ uid?: number; gid?: number } | null> {
|
|
775
|
+
if (process.platform === "win32") {
|
|
776
|
+
// Windows does not support POSIX chown meaningfully.
|
|
777
|
+
return null
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
await loadEtcPasswd()
|
|
781
|
+
await loadEtcGroup()
|
|
782
|
+
|
|
783
|
+
const out: { uid?: number; gid?: number } = {}
|
|
784
|
+
|
|
785
|
+
if (owner != null) {
|
|
786
|
+
const maybeNum = Number(owner)
|
|
787
|
+
if (Number.isInteger(maybeNum)) out.uid = maybeNum
|
|
788
|
+
else if (passwdCache.has(owner)) out.uid = passwdCache.get(owner)!
|
|
789
|
+
else
|
|
790
|
+
ctx?.logger?.warn?.({
|
|
791
|
+
msg: `copy(local): unknown owner '${owner}', skipping uid change`
|
|
792
|
+
})
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
if (group != null) {
|
|
796
|
+
const maybeNum = Number(group)
|
|
797
|
+
if (Number.isInteger(maybeNum)) out.gid = maybeNum
|
|
798
|
+
else if (groupCache.has(group)) out.gid = groupCache.get(group)!
|
|
799
|
+
else
|
|
800
|
+
ctx?.logger?.warn?.({
|
|
801
|
+
msg: `copy(local): unknown group '${group}', skipping gid change`
|
|
802
|
+
})
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
if (out.uid == null && out.gid == null) return null
|
|
806
|
+
return out
|
|
807
|
+
}
|