@eighty4/c2 0.0.1
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/CHANGELOG.md +6 -0
- package/LICENSE +10 -0
- package/README.md +79 -0
- package/c2.ts +42 -0
- package/lib/attachments.ts +49 -0
- package/lib/build.ts +59 -0
- package/lib/cli.ts +48 -0
- package/lib/expression.ts +65 -0
- package/lib/fs.bun.ts +5 -0
- package/lib/fs.node.ts +17 -0
- package/package.json +51 -0
package/CHANGELOG.md
ADDED
package/LICENSE
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
Copyright 2025 Adam McKee Bennett <adam.be.g84d@gmail.com>
|
|
2
|
+
|
|
3
|
+
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
|
|
4
|
+
|
|
5
|
+
1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
|
|
6
|
+
|
|
7
|
+
2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
|
|
8
|
+
|
|
9
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS “AS IS” AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|
10
|
+
|
package/README.md
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# Cloud Config
|
|
2
|
+
|
|
3
|
+
Blow up your `cloud-init` developer workflows!
|
|
4
|
+
|
|
5
|
+
## Getting started
|
|
6
|
+
|
|
7
|
+
`c2` publishes only TypeScript requiring Node >=23 to run.
|
|
8
|
+
|
|
9
|
+
```shell
|
|
10
|
+
npm i -g @eighty4/c2
|
|
11
|
+
c2 -h
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
(tests use `bun:test` so [install Bun](https://bun.sh/docs/installation) for contributing!)
|
|
15
|
+
|
|
16
|
+
## Using the CLI program
|
|
17
|
+
|
|
18
|
+
```
|
|
19
|
+
c2 ./cloud_init_dir
|
|
20
|
+
|
|
21
|
+
# base64 encode your user data
|
|
22
|
+
c2 --base64 ./cloud_init_dir
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Using the JS API
|
|
26
|
+
|
|
27
|
+
```
|
|
28
|
+
import { buildUserData } from '@eighty4/c2'
|
|
29
|
+
|
|
30
|
+
const userData: string = await buildUserData('./cloud_init_dir')
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Cloud Config data dir
|
|
34
|
+
|
|
35
|
+
Multiple user data are ordered by filenames and `01_` numbered prefixes help declaring execution order.
|
|
36
|
+
|
|
37
|
+
```
|
|
38
|
+
ls ./cloud_init_dir
|
|
39
|
+
01_upgrades.yml
|
|
40
|
+
02_security.yml
|
|
41
|
+
03_services.sh
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Shell scripts are supported as `x-shellscript` and YAML is included as `cloud-config` MIME types.
|
|
45
|
+
|
|
46
|
+
## Evaluating expressions in user data
|
|
47
|
+
|
|
48
|
+
Scripts and YAML support two template functions that can be used in expressions.
|
|
49
|
+
|
|
50
|
+
### env()
|
|
51
|
+
|
|
52
|
+
Replaces expression with content of a local environment variable.
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
#!/bin/sh
|
|
56
|
+
|
|
57
|
+
ENV_VAR="${{ env('LOCAL_ENV_VAR') }}"
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### file()
|
|
61
|
+
|
|
62
|
+
Looks up a file from your local filesystem and replaces the expression with its content.
|
|
63
|
+
|
|
64
|
+
```yaml
|
|
65
|
+
#cloud-config
|
|
66
|
+
|
|
67
|
+
users:
|
|
68
|
+
- name: devops-baller
|
|
69
|
+
ssh_authorized_keys:
|
|
70
|
+
- ${{ file('~/.ssh/my_ssh_key.pub') }}
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Relative, absolute and `~/` paths are supported.
|
|
74
|
+
|
|
75
|
+
## Contributing
|
|
76
|
+
|
|
77
|
+
I use `c2` for initializing Debian cloud instances and locally test with QEMU and Ubuntu.
|
|
78
|
+
|
|
79
|
+
Feedback on your use cases and worfklows is greatly appreciated!
|
package/c2.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { buildUserData } from '#c2/build.ts'
|
|
4
|
+
import { parseArgs, type ParsedArgs } from '#c2/cli.ts'
|
|
5
|
+
import { doesDirExist } from '#c2/fs.ts'
|
|
6
|
+
|
|
7
|
+
let args: ParsedArgs | undefined
|
|
8
|
+
try {
|
|
9
|
+
args = parseArgs()
|
|
10
|
+
} catch (e: any) {
|
|
11
|
+
if (e.message) {
|
|
12
|
+
console.error(e.message)
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
if (!args || args.help) {
|
|
17
|
+
const optional = (s: string) => `\u001b[90m${s}\u001b[0m`
|
|
18
|
+
const required = (s: string) => `\u001b[1m${s}\u001b[0m`
|
|
19
|
+
errorExit(
|
|
20
|
+
`c2 ${optional('[[--base64] | [--http PORT]]')} ${required('USER_DATA_DIR')}`,
|
|
21
|
+
)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (args.httpPort) {
|
|
25
|
+
errorExit('--http PORT is not yet implemented')
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (!(await doesDirExist(args.userDataDir))) {
|
|
29
|
+
errorExit(`${args.userDataDir} directory does not exist`)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
const userData = await buildUserData(args.userDataDir)
|
|
34
|
+
console.log(args.base64 ? btoa(userData) : userData)
|
|
35
|
+
} catch (e: any) {
|
|
36
|
+
errorExit(e.message)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function errorExit(msg: string): never {
|
|
40
|
+
console.error(msg)
|
|
41
|
+
process.exit(1)
|
|
42
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { readDirListing, readToString } from '#c2/fs.ts'
|
|
2
|
+
|
|
3
|
+
export type AttachmentType = 'cloud-config' | 'x-shellscript'
|
|
4
|
+
|
|
5
|
+
export interface Attachment {
|
|
6
|
+
path: string
|
|
7
|
+
content: string
|
|
8
|
+
filename: string
|
|
9
|
+
type: AttachmentType
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export async function collectAttachments(
|
|
13
|
+
dir: string,
|
|
14
|
+
): Promise<Array<Attachment>> {
|
|
15
|
+
const result: Array<Attachment> = []
|
|
16
|
+
for (const filename of await readDirListing(dir)) {
|
|
17
|
+
const path = `${dir}/${filename}`
|
|
18
|
+
const content = await readToString(path)
|
|
19
|
+
const type = resolveAttachmentType(filename, content)
|
|
20
|
+
result.push({ content, filename, path, type })
|
|
21
|
+
}
|
|
22
|
+
return result.sort(compareAttachmentFilenames)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function compareAttachmentFilenames(
|
|
26
|
+
a1: Attachment,
|
|
27
|
+
a2: Attachment,
|
|
28
|
+
): 1 | 0 | -1 {
|
|
29
|
+
if (a1.filename === a2.filename) return 0
|
|
30
|
+
if (a1.filename > a2.filename) return 1
|
|
31
|
+
return -1
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function resolveAttachmentType(
|
|
35
|
+
filename: string,
|
|
36
|
+
content: string,
|
|
37
|
+
): AttachmentType {
|
|
38
|
+
if (
|
|
39
|
+
filename.endsWith('.yml') ||
|
|
40
|
+
(filename.endsWith('.yaml') &&
|
|
41
|
+
content.trim().startsWith('#cloud-config'))
|
|
42
|
+
) {
|
|
43
|
+
return 'cloud-config'
|
|
44
|
+
} else if (filename.endsWith('.sh')) {
|
|
45
|
+
return 'x-shellscript'
|
|
46
|
+
} else {
|
|
47
|
+
throw new Error(`unsupported file type ${filename}`)
|
|
48
|
+
}
|
|
49
|
+
}
|
package/lib/build.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { type Attachment, collectAttachments } from '#c2/attachments.ts'
|
|
2
|
+
import { evalTemplateExpressions } from '#c2/expression.ts'
|
|
3
|
+
import { readToString } from '#c2/fs.ts'
|
|
4
|
+
|
|
5
|
+
export type BuildUserDataOpts = {
|
|
6
|
+
attachmentBoundary?: string
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export async function buildUserData(
|
|
10
|
+
userDataDir: string,
|
|
11
|
+
opts?: BuildUserDataOpts,
|
|
12
|
+
): Promise<string> {
|
|
13
|
+
const attachments = await collectAttachments(userDataDir)
|
|
14
|
+
switch (attachments.length) {
|
|
15
|
+
case 0:
|
|
16
|
+
throw new Error(`nothing found in dir ${userDataDir}`)
|
|
17
|
+
case 1:
|
|
18
|
+
return evalTemplateExpressions(
|
|
19
|
+
await readToString(attachments[0].path),
|
|
20
|
+
)
|
|
21
|
+
default:
|
|
22
|
+
return buildMultipartUserData(attachments, opts?.attachmentBoundary)
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function createBoundary(): string {
|
|
27
|
+
return new Date().toISOString()
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function buildMultipartUserData(
|
|
31
|
+
attachments: Array<Attachment>,
|
|
32
|
+
boundary: string = createBoundary(),
|
|
33
|
+
): Promise<string> {
|
|
34
|
+
let result = `Content-Type: multipart/mixed; boundary=${boundary}
|
|
35
|
+
MIME-Version: 1.0
|
|
36
|
+
Number-Attachments: ${attachments.length}
|
|
37
|
+
--${boundary}
|
|
38
|
+
`
|
|
39
|
+
|
|
40
|
+
for (const attachment of attachments) {
|
|
41
|
+
let content: string
|
|
42
|
+
try {
|
|
43
|
+
content = await evalTemplateExpressions(attachment.content)
|
|
44
|
+
} catch (e: any) {
|
|
45
|
+
throw new Error(
|
|
46
|
+
`error templating ${attachment.filename}: ${e.message}`,
|
|
47
|
+
)
|
|
48
|
+
}
|
|
49
|
+
result += `Content-Type: text/${attachment.type}; charset="us-ascii"
|
|
50
|
+
MIME-Version: 1.0
|
|
51
|
+
Content-Transfer-Encoding: 7bit
|
|
52
|
+
Content-Disposition: attachment; filename="${attachment.filename}"
|
|
53
|
+
|
|
54
|
+
${content}
|
|
55
|
+
--${boundary}
|
|
56
|
+
`
|
|
57
|
+
}
|
|
58
|
+
return result
|
|
59
|
+
}
|
package/lib/cli.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
export type ParsedArgs =
|
|
2
|
+
| { help: true }
|
|
3
|
+
| {
|
|
4
|
+
help?: false
|
|
5
|
+
base64?: boolean
|
|
6
|
+
httpPort?: number
|
|
7
|
+
userDataDir: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function parseArgs(args?: Array<string>): ParsedArgs {
|
|
11
|
+
if (!args) {
|
|
12
|
+
args = process.argv
|
|
13
|
+
}
|
|
14
|
+
args = [...args]
|
|
15
|
+
while (!args.shift()!.endsWith('/c2.ts')) {}
|
|
16
|
+
let base64 = false
|
|
17
|
+
let httpPort: number | undefined
|
|
18
|
+
let userData: Array<string> = []
|
|
19
|
+
let expectHttpPort = false
|
|
20
|
+
for (const arg of args) {
|
|
21
|
+
if (expectHttpPort) {
|
|
22
|
+
expectHttpPort = false
|
|
23
|
+
httpPort = parseInt(arg, 10)
|
|
24
|
+
if (isNaN(httpPort)) {
|
|
25
|
+
throw new Error(`--http ${arg} is not a valid http port`)
|
|
26
|
+
}
|
|
27
|
+
} else if (arg === '-h' || arg === '--help') {
|
|
28
|
+
return { help: true }
|
|
29
|
+
} else if (arg === '--base64') {
|
|
30
|
+
base64 = true
|
|
31
|
+
} else if (arg === '--http') {
|
|
32
|
+
expectHttpPort = true
|
|
33
|
+
} else {
|
|
34
|
+
userData.push(arg)
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
switch (userData.length) {
|
|
38
|
+
case 1:
|
|
39
|
+
const userDataDir = userData[0]
|
|
40
|
+
if (typeof httpPort !== 'undefined') {
|
|
41
|
+
return { httpPort, userDataDir }
|
|
42
|
+
} else {
|
|
43
|
+
return { base64, userDataDir }
|
|
44
|
+
}
|
|
45
|
+
default:
|
|
46
|
+
throw new Error()
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import MagicString from 'magic-string'
|
|
2
|
+
import { readToString } from '#c2/fs.ts'
|
|
3
|
+
|
|
4
|
+
type TemplateExpression = {
|
|
5
|
+
index: number
|
|
6
|
+
innie: string
|
|
7
|
+
outie: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export async function evalTemplateExpressions(
|
|
11
|
+
content: string,
|
|
12
|
+
): Promise<string> {
|
|
13
|
+
const regex = new RegExp(/\${{\s*(.*)\s*}}/g)
|
|
14
|
+
let match: RegExpExecArray | null
|
|
15
|
+
const expressions: Array<TemplateExpression> = []
|
|
16
|
+
while ((match = regex.exec(content)) != null) {
|
|
17
|
+
expressions.push({
|
|
18
|
+
index: match.index,
|
|
19
|
+
innie: match[1],
|
|
20
|
+
outie: match[0],
|
|
21
|
+
})
|
|
22
|
+
}
|
|
23
|
+
if (!expressions.length) {
|
|
24
|
+
return content
|
|
25
|
+
}
|
|
26
|
+
const ms = new MagicString(content)
|
|
27
|
+
for (const expression of expressions) {
|
|
28
|
+
ms.update(
|
|
29
|
+
expression.index,
|
|
30
|
+
expression.index + expression.outie.length,
|
|
31
|
+
await evaluate(expression.innie),
|
|
32
|
+
)
|
|
33
|
+
}
|
|
34
|
+
return ms.toString()
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function evaluate(expression: string): Promise<string> {
|
|
38
|
+
let match: RegExpMatchArray | null
|
|
39
|
+
if ((match = expression.match(/env\(\s*'(.*)'\s*\)/)) != null) {
|
|
40
|
+
const envVarKey = match[1]
|
|
41
|
+
if (!/[A-Z_]+/.test(envVarKey)) {
|
|
42
|
+
throw new Error(
|
|
43
|
+
`env var expression \`${envVarKey}\` is not valid syntax`,
|
|
44
|
+
)
|
|
45
|
+
}
|
|
46
|
+
const envVarValue = process.env[envVarKey]
|
|
47
|
+
if (!envVarValue) {
|
|
48
|
+
throw new Error(`env var \`${envVarKey}\` does not exist`)
|
|
49
|
+
}
|
|
50
|
+
return envVarValue
|
|
51
|
+
} else if ((match = expression.match(/file\(\s*'(.*)'\s*\)/)) != null) {
|
|
52
|
+
let path = match[1]
|
|
53
|
+
if (path.startsWith('~/')) {
|
|
54
|
+
if (!process.env.HOME) {
|
|
55
|
+
throw new Error(
|
|
56
|
+
`file \`${path}\` cannot be resolved without env var HOME`,
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
path = `${process.env.HOME}${path.substring(1)}`
|
|
60
|
+
}
|
|
61
|
+
return readToString(path)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
throw new Error(`unsupported expression: ${expression}`)
|
|
65
|
+
}
|
package/lib/fs.bun.ts
ADDED
package/lib/fs.node.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { readdir, readFile, stat } from 'node:fs/promises'
|
|
2
|
+
|
|
3
|
+
export async function doesDirExist(p: string): Promise<boolean> {
|
|
4
|
+
try {
|
|
5
|
+
return (await stat(p)).isDirectory()
|
|
6
|
+
} catch (ignore) {
|
|
7
|
+
return false
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export async function readDirListing(p: string): Promise<Array<string>> {
|
|
12
|
+
return readdir(p)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function readToString(p: string): Promise<string> {
|
|
16
|
+
return readFile(p, 'utf8')
|
|
17
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@eighty4/c2",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"author": "Adam McKee <adam.be.g84d@gmail.com>",
|
|
5
|
+
"repository": "https://github.com/eighty4/c2",
|
|
6
|
+
"homepage": "https://github.com/eighty4/c2",
|
|
7
|
+
"keywords": [
|
|
8
|
+
"devops",
|
|
9
|
+
"cloud init",
|
|
10
|
+
"cloud config",
|
|
11
|
+
"user data"
|
|
12
|
+
],
|
|
13
|
+
"description": "Cross platform cloud config tooling for cloud-init",
|
|
14
|
+
"license": "BSD-2-Clause",
|
|
15
|
+
"type": "module",
|
|
16
|
+
"engines": {
|
|
17
|
+
"bun": ">=1.2",
|
|
18
|
+
"node": ">=23"
|
|
19
|
+
},
|
|
20
|
+
"main": "lib/build.ts",
|
|
21
|
+
"bin": {
|
|
22
|
+
"c2": "./c2.ts"
|
|
23
|
+
},
|
|
24
|
+
"scripts": {
|
|
25
|
+
"fmtcheck": "prettier --check .",
|
|
26
|
+
"typecheck": "tsc"
|
|
27
|
+
},
|
|
28
|
+
"imports": {
|
|
29
|
+
"#c2/fs.ts": {
|
|
30
|
+
"bun": "./lib/fs.bun.ts",
|
|
31
|
+
"node": "./lib/fs.node.ts"
|
|
32
|
+
},
|
|
33
|
+
"#c2/*": "./lib/*"
|
|
34
|
+
},
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"magic-string": "0.30.17"
|
|
37
|
+
},
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"@types/bun": "1.2.5",
|
|
40
|
+
"@types/node": "^22.14.1",
|
|
41
|
+
"prettier": "^3.5.3",
|
|
42
|
+
"typescript": "5.8.2"
|
|
43
|
+
},
|
|
44
|
+
"files": [
|
|
45
|
+
"lib/**/*.ts",
|
|
46
|
+
"!lib/**/*.spec.ts",
|
|
47
|
+
"!lib/fs.testing.ts",
|
|
48
|
+
"CHANGELOG.md",
|
|
49
|
+
"c2.ts"
|
|
50
|
+
]
|
|
51
|
+
}
|