@rip-lang/stamp 0.1.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/README.md +166 -0
- package/bin/stamp +21 -0
- package/directives/brew.rip +48 -0
- package/directives/container.rip +106 -0
- package/directives/dataset.rip +40 -0
- package/directives/firewall.rip +78 -0
- package/directives/group.rip +34 -0
- package/directives/incus.rip +111 -0
- package/directives/multipass.rip +47 -0
- package/directives/packages.rip +40 -0
- package/directives/pool.rip +53 -0
- package/directives/profile.rip +29 -0
- package/directives/service.rip +52 -0
- package/directives/ssh.rip +64 -0
- package/directives/user.rip +61 -0
- package/package.json +45 -0
- package/src/cli.rip +125 -0
- package/src/engine.rip +146 -0
- package/src/helpers.rip +83 -0
- package/src/parser.rip +170 -0
package/README.md
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
<img src="https://raw.githubusercontent.com/shreeve/rip-lang/main/docs/assets/rip.png" style="width:50px" /> <br>
|
|
2
|
+
|
|
3
|
+
# Stamp - @rip-lang/stamp
|
|
4
|
+
|
|
5
|
+
**Declarative host provisioning — no state file, no agent, no YAML.**
|
|
6
|
+
|
|
7
|
+
Stamp reads a Stampfile, resolves each directive to a handler, and reconciles
|
|
8
|
+
the declared state against reality using three operations: **check**, **apply**,
|
|
9
|
+
and **verify**.
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
stamp apply Hostfile
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## Quick Start
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
# Preview changes (read-only)
|
|
21
|
+
stamp plan Hostfile
|
|
22
|
+
|
|
23
|
+
# Apply the declared state
|
|
24
|
+
stamp apply Hostfile
|
|
25
|
+
|
|
26
|
+
# Audit current state
|
|
27
|
+
stamp verify Hostfile
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Stampfile Syntax
|
|
31
|
+
|
|
32
|
+
A Stampfile declares the desired state of a system. Each top-level entry
|
|
33
|
+
names a resource using a **directive**. Indented lines below it describe
|
|
34
|
+
properties.
|
|
35
|
+
|
|
36
|
+
```
|
|
37
|
+
packages curl git jq zfsutils-linux
|
|
38
|
+
|
|
39
|
+
pool tank /dev/sdb
|
|
40
|
+
compression zstd
|
|
41
|
+
atime off
|
|
42
|
+
|
|
43
|
+
container web ubuntu/24.04
|
|
44
|
+
profile trusted
|
|
45
|
+
disk data /tank/web -> /data
|
|
46
|
+
start
|
|
47
|
+
|
|
48
|
+
firewall
|
|
49
|
+
default deny incoming
|
|
50
|
+
default allow outgoing
|
|
51
|
+
allow ssh
|
|
52
|
+
allow 443/tcp
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
### Three levels of expression
|
|
56
|
+
|
|
57
|
+
- **Inline** — single line: `packages curl git jq`
|
|
58
|
+
- **Block** — directive + indented properties
|
|
59
|
+
- **Expanded** — property with its own nested sub-block
|
|
60
|
+
|
|
61
|
+
### Variables
|
|
62
|
+
|
|
63
|
+
```
|
|
64
|
+
set POOL tank
|
|
65
|
+
set DEVICE /dev/sdb
|
|
66
|
+
|
|
67
|
+
pool $POOL $DEVICE
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### The `->` operator
|
|
71
|
+
|
|
72
|
+
Source-to-destination mapping:
|
|
73
|
+
|
|
74
|
+
```
|
|
75
|
+
disk data /tank/web -> /data
|
|
76
|
+
disk logs /tank/logs -> /var/log readonly
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### Grouped directives
|
|
80
|
+
|
|
81
|
+
Plural forms expand to individual directives:
|
|
82
|
+
|
|
83
|
+
```
|
|
84
|
+
datasets
|
|
85
|
+
tank/home
|
|
86
|
+
tank/shared
|
|
87
|
+
owner 1001:1001
|
|
88
|
+
mode 2775
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## Built-in Directives
|
|
92
|
+
|
|
93
|
+
| Directive | Purpose |
|
|
94
|
+
|-----------|---------|
|
|
95
|
+
| `brew` | Homebrew packages (macOS) |
|
|
96
|
+
| `packages` | System packages (apt-get) |
|
|
97
|
+
| `pool` | ZFS pool creation |
|
|
98
|
+
| `dataset` | ZFS dataset with ownership/permissions |
|
|
99
|
+
| `profile` | Incus profile configuration |
|
|
100
|
+
| `container` | Incus container management |
|
|
101
|
+
| `incus` | Incus daemon initialization |
|
|
102
|
+
| `multipass` | Multipass virtual machines |
|
|
103
|
+
| `user` | System user management |
|
|
104
|
+
| `group` | System group management |
|
|
105
|
+
| `firewall` | ufw firewall rules |
|
|
106
|
+
| `ssh` | SSH daemon configuration |
|
|
107
|
+
| `service` | systemd service management |
|
|
108
|
+
|
|
109
|
+
## Handler Contract
|
|
110
|
+
|
|
111
|
+
Every directive handler exports three async functions:
|
|
112
|
+
|
|
113
|
+
- **check(name, props)** — returns `"ok"`, `"drift"`, or `"missing"`
|
|
114
|
+
- **apply(name, props)** — make reality match the declaration
|
|
115
|
+
- **verify(name, props)** — return `[{ status, message }]` results
|
|
116
|
+
|
|
117
|
+
## Plugin System
|
|
118
|
+
|
|
119
|
+
Handlers resolve in this order:
|
|
120
|
+
|
|
121
|
+
1. Built-in (`directives/`)
|
|
122
|
+
2. Local (`./directives/` beside Stampfile)
|
|
123
|
+
3. Installed (`~/.stamp/directives/`)
|
|
124
|
+
4. npm (`@stamp/<name>` or `stamp-<name>`)
|
|
125
|
+
5. Remote (via `use` directive)
|
|
126
|
+
|
|
127
|
+
### Writing a directive
|
|
128
|
+
|
|
129
|
+
```coffee
|
|
130
|
+
import { sh, ok, run } from "@rip-lang/stamp/helpers"
|
|
131
|
+
|
|
132
|
+
export name = "mydirective"
|
|
133
|
+
export description = "What it does"
|
|
134
|
+
|
|
135
|
+
export check = (name, props) ->
|
|
136
|
+
return 'missing' unless ok "some-check"
|
|
137
|
+
'ok'
|
|
138
|
+
|
|
139
|
+
export apply = (name, props) ->
|
|
140
|
+
sh "some-command"
|
|
141
|
+
|
|
142
|
+
export verify = (name, props) ->
|
|
143
|
+
[{ status: 'pass', message: 'looks good' }]
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
## CLI
|
|
147
|
+
|
|
148
|
+
```
|
|
149
|
+
stamp apply [file] Reconcile system to match Stampfile
|
|
150
|
+
stamp verify [file] Check current state, report PASS/WARN/FAIL
|
|
151
|
+
stamp plan [file] Dry-run: show what apply would do
|
|
152
|
+
stamp list Show all available directives
|
|
153
|
+
stamp info <directive> Show a directive's syntax and properties
|
|
154
|
+
stamp version Print version
|
|
155
|
+
stamp help Show help
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
Default file search: `Stampfile`, `Hostfile`, `Containerfile`.
|
|
159
|
+
|
|
160
|
+
## Runtime
|
|
161
|
+
|
|
162
|
+
Bun + Rip. Zero dependencies beyond `rip-lang`.
|
|
163
|
+
|
|
164
|
+
## License
|
|
165
|
+
|
|
166
|
+
MIT
|
package/bin/stamp
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
import { fileURLToPath } from 'url';
|
|
4
|
+
import { dirname, join } from 'path';
|
|
5
|
+
|
|
6
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
7
|
+
const loaderPath = join(__dirname, '..', '..', '..', 'rip-loader.js');
|
|
8
|
+
|
|
9
|
+
// Preload the Rip loader so .rip imports work
|
|
10
|
+
const { spawnSync } = await import('child_process');
|
|
11
|
+
|
|
12
|
+
const result = spawnSync('bun', [
|
|
13
|
+
'--preload', loaderPath,
|
|
14
|
+
join(__dirname, '..', 'src', 'cli.rip'),
|
|
15
|
+
...process.argv.slice(2)
|
|
16
|
+
], {
|
|
17
|
+
stdio: 'inherit',
|
|
18
|
+
env: { ...process.env, NODE_PATH: join(__dirname, '..', '..', '..') }
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
process.exit(result.status || 0);
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
export name = "brew"
|
|
2
|
+
export description = "Ensures Homebrew packages are installed (macOS)"
|
|
3
|
+
export positional = []
|
|
4
|
+
|
|
5
|
+
gather = (name, props) ->
|
|
6
|
+
list = [...(props.args or [])]
|
|
7
|
+
for own k, entries of props when k != 'args'
|
|
8
|
+
list.push k
|
|
9
|
+
for entry in entries
|
|
10
|
+
list.push ...entry.args if entry.args?.length
|
|
11
|
+
list
|
|
12
|
+
|
|
13
|
+
installed = (pkg) ->
|
|
14
|
+
ok $"brew list #{pkg}"
|
|
15
|
+
|
|
16
|
+
export check = (name, props) ->
|
|
17
|
+
return 'missing' unless ok $"which brew"
|
|
18
|
+
list = gather name, props
|
|
19
|
+
return 'ok' if list.length == 0
|
|
20
|
+
for pkg in list
|
|
21
|
+
return 'drift' unless installed pkg
|
|
22
|
+
'ok'
|
|
23
|
+
|
|
24
|
+
export apply = (name, props) ->
|
|
25
|
+
unless ok $"which brew"
|
|
26
|
+
sh "/bin/bash -c \"$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)\""
|
|
27
|
+
|
|
28
|
+
list = gather name, props
|
|
29
|
+
return if list.length == 0
|
|
30
|
+
missing = (pkg for pkg in list when not installed pkg)
|
|
31
|
+
return if missing.length == 0
|
|
32
|
+
for pkg in missing
|
|
33
|
+
sh $"brew install #{pkg}"
|
|
34
|
+
|
|
35
|
+
export verify = (name, props) ->
|
|
36
|
+
results = []
|
|
37
|
+
unless ok $"which brew"
|
|
38
|
+
results.push { status: 'fail', message: "homebrew not installed" }
|
|
39
|
+
return results
|
|
40
|
+
results.push { status: 'pass', message: "homebrew installed" }
|
|
41
|
+
|
|
42
|
+
list = gather name, props
|
|
43
|
+
for pkg in list
|
|
44
|
+
if installed pkg
|
|
45
|
+
results.push { status: 'pass', message: "#{pkg} installed" }
|
|
46
|
+
else
|
|
47
|
+
results.push { status: 'fail', message: "#{pkg} not installed" }
|
|
48
|
+
results
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
export name = "container"
|
|
2
|
+
export description = "Manages Incus containers"
|
|
3
|
+
export positional = ["name", "image"]
|
|
4
|
+
export properties =
|
|
5
|
+
profile: { description: "Attach an Incus profile", multi: true }
|
|
6
|
+
disk: { description: "Mount a host path into the container", arrow: true, multi: true }
|
|
7
|
+
user: { description: "Create a user inside the container", multi: true }
|
|
8
|
+
start: { description: "Ensure container is running" }
|
|
9
|
+
|
|
10
|
+
isRunning = (name) ->
|
|
11
|
+
out = run $"incus info #{name}"
|
|
12
|
+
out.ok and out.stdout.toLowerCase().includes 'status: running'
|
|
13
|
+
|
|
14
|
+
export check = (name, props) ->
|
|
15
|
+
return 'missing' unless ok $"incus info #{name}"
|
|
16
|
+
|
|
17
|
+
if props.profile
|
|
18
|
+
current = sh $"incus config show #{name}"
|
|
19
|
+
for entry in props.profile
|
|
20
|
+
return 'drift' unless current.includes entry.args[0]
|
|
21
|
+
|
|
22
|
+
if props.disk
|
|
23
|
+
for entry in props.disk
|
|
24
|
+
return 'drift' unless ok $"incus config device get #{name} #{entry.args[0]} source"
|
|
25
|
+
|
|
26
|
+
return 'drift' if props.start and not isRunning name
|
|
27
|
+
|
|
28
|
+
'ok'
|
|
29
|
+
|
|
30
|
+
export apply = (name, props) ->
|
|
31
|
+
image = props.args?[0]
|
|
32
|
+
|
|
33
|
+
unless ok $"incus info #{name}"
|
|
34
|
+
raise "container '#{name}' requires an image argument" unless image
|
|
35
|
+
sh $"incus init #{image} #{name}"
|
|
36
|
+
|
|
37
|
+
if props.profile
|
|
38
|
+
current = sh $"incus config show #{name}"
|
|
39
|
+
for entry in props.profile
|
|
40
|
+
sh $"incus profile add #{name} #{entry.args[0]}" unless current.includes entry.args[0]
|
|
41
|
+
|
|
42
|
+
if props.disk
|
|
43
|
+
for entry in props.disk
|
|
44
|
+
devname = entry.args[0]
|
|
45
|
+
unless ok $"incus config device get #{name} #{devname} source"
|
|
46
|
+
ro = 'readonly=true' if 'readonly' in (entry.flags or [])
|
|
47
|
+
sh $"incus config device add #{name} #{devname} disk source=#{entry.source} path=#{entry.dest} #{ro}"
|
|
48
|
+
|
|
49
|
+
if props.start and not isRunning name
|
|
50
|
+
sh $"incus start #{name}"
|
|
51
|
+
|
|
52
|
+
attempts = 30
|
|
53
|
+
while attempts > 0
|
|
54
|
+
break if ok $"incus exec #{name} -- true"
|
|
55
|
+
Bun.sleepSync 1000
|
|
56
|
+
attempts--
|
|
57
|
+
raise "Container #{name} not ready after 30 seconds" if attempts == 0
|
|
58
|
+
|
|
59
|
+
if props.user and isRunning name
|
|
60
|
+
for entry in props.user
|
|
61
|
+
username = entry.args[0]
|
|
62
|
+
uidgid = entry.args[1]
|
|
63
|
+
unless ok $"incus exec #{name} -- id #{username}"
|
|
64
|
+
[uid, gid] = uidgid.split ':'
|
|
65
|
+
ok $"incus exec #{name} -- groupadd -g #{gid} #{username}" unless ok $"incus exec #{name} -- getent group #{gid}"
|
|
66
|
+
sh $"incus exec #{name} -- useradd -m -u #{uid} -g #{gid} #{username}"
|
|
67
|
+
|
|
68
|
+
export verify = (name, props) ->
|
|
69
|
+
results = []
|
|
70
|
+
|
|
71
|
+
unless ok $"incus info #{name}"
|
|
72
|
+
results.push { status: 'fail', message: "container #{name} missing" }
|
|
73
|
+
return results
|
|
74
|
+
results.push { status: 'pass', message: "container #{name} exists" }
|
|
75
|
+
|
|
76
|
+
if isRunning name
|
|
77
|
+
results.push { status: 'pass', message: "#{name} running" }
|
|
78
|
+
else
|
|
79
|
+
results.push { status: 'fail', message: "#{name} not running" }
|
|
80
|
+
|
|
81
|
+
if props.profile
|
|
82
|
+
current = sh $"incus config show #{name}"
|
|
83
|
+
for entry in props.profile
|
|
84
|
+
want = entry.args[0]
|
|
85
|
+
if current.includes want
|
|
86
|
+
results.push { status: 'pass', message: "#{name} profile #{want} attached" }
|
|
87
|
+
else
|
|
88
|
+
results.push { status: 'fail', message: "#{name} profile #{want} not attached" }
|
|
89
|
+
|
|
90
|
+
if props.disk
|
|
91
|
+
for entry in props.disk
|
|
92
|
+
devname = entry.args[0]
|
|
93
|
+
if ok $"incus config device get #{name} #{devname} source"
|
|
94
|
+
results.push { status: 'pass', message: "#{name} disk #{devname} present" }
|
|
95
|
+
else
|
|
96
|
+
results.push { status: 'fail', message: "#{name} disk #{devname} missing" }
|
|
97
|
+
|
|
98
|
+
if props.user and isRunning name
|
|
99
|
+
for entry in props.user
|
|
100
|
+
username = entry.args[0]
|
|
101
|
+
if ok $"incus exec #{name} -- id #{username}"
|
|
102
|
+
results.push { status: 'pass', message: "#{name} user #{username} exists" }
|
|
103
|
+
else
|
|
104
|
+
results.push { status: 'fail', message: "#{name} user #{username} missing" }
|
|
105
|
+
|
|
106
|
+
results
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
export name = "dataset"
|
|
2
|
+
export description = "Ensures a ZFS dataset exists with correct ownership and permissions"
|
|
3
|
+
export positional = ["path"]
|
|
4
|
+
export properties =
|
|
5
|
+
owner: { description: "Ownership as uid:gid" }
|
|
6
|
+
mode: { description: "Permission mode (octal)" }
|
|
7
|
+
|
|
8
|
+
export check = (path, props) ->
|
|
9
|
+
return 'missing' unless ok $"zfs list #{path}"
|
|
10
|
+
mountpoint = sh $"zfs get -H -o value mountpoint #{path}"
|
|
11
|
+
if want = props.owner?[0]?.args?[0]
|
|
12
|
+
return 'drift' if want isnt sh $"stat -c %u:%g #{mountpoint}"
|
|
13
|
+
if want = props.mode?[0]?.args?[0]
|
|
14
|
+
return 'drift' if want isnt sh $"stat -c %a #{mountpoint}"
|
|
15
|
+
'ok'
|
|
16
|
+
|
|
17
|
+
export apply = (path, props) ->
|
|
18
|
+
sh $"zfs create #{path}" unless ok $"zfs list #{path}"
|
|
19
|
+
mountpoint = sh $"zfs get -H -o value mountpoint #{path}"
|
|
20
|
+
sh $"chown #{props.owner[0].args[0]} #{mountpoint}" if props.owner
|
|
21
|
+
sh $"chmod #{props.mode[0].args[0]} #{mountpoint}" if props.mode
|
|
22
|
+
|
|
23
|
+
export verify = (path, props) ->
|
|
24
|
+
results = []
|
|
25
|
+
unless ok $"zfs list #{path}"
|
|
26
|
+
results.push { status: 'fail', message: "dataset missing: #{path}" }
|
|
27
|
+
return results
|
|
28
|
+
results.push { status: 'pass', message: "dataset exists: #{path}" }
|
|
29
|
+
mountpoint = sh $"zfs get -H -o value mountpoint #{path}"
|
|
30
|
+
if want = props.owner?[0]?.args?[0]
|
|
31
|
+
if want is (have = sh $"stat -c %u:%g #{mountpoint}")
|
|
32
|
+
results.push { status: 'pass', message: "#{path} owner is #{want}" }
|
|
33
|
+
else
|
|
34
|
+
results.push { status: 'fail', message: "#{path} owner is #{have}, expected #{want}" }
|
|
35
|
+
if want = props.mode?[0]?.args?[0]
|
|
36
|
+
if want is (have = sh $"stat -c %a #{mountpoint}")
|
|
37
|
+
results.push { status: 'pass', message: "#{path} mode is #{want}" }
|
|
38
|
+
else
|
|
39
|
+
results.push { status: 'fail', message: "#{path} mode is #{have}, expected #{want}" }
|
|
40
|
+
results
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
export name = "firewall"
|
|
2
|
+
export description = "Ensures ufw firewall rules are configured"
|
|
3
|
+
export positional = []
|
|
4
|
+
export properties =
|
|
5
|
+
default: { description: "Default policy: deny/allow incoming/outgoing", multi: true }
|
|
6
|
+
allow: { description: "Allow rule", multi: true }
|
|
7
|
+
deny: { description: "Deny rule", multi: true }
|
|
8
|
+
|
|
9
|
+
ufwActive = ->
|
|
10
|
+
out = run $"ufw status"
|
|
11
|
+
out.ok and out.stdout.includes 'Status: active'
|
|
12
|
+
|
|
13
|
+
export check = (name, props) ->
|
|
14
|
+
return 'missing' unless ok $"which ufw"
|
|
15
|
+
return 'drift' unless ufwActive()
|
|
16
|
+
|
|
17
|
+
if props.default
|
|
18
|
+
status = sh $"ufw status verbose"
|
|
19
|
+
for entry in props.default
|
|
20
|
+
[action, direction] = entry.args
|
|
21
|
+
return 'drift' unless status.includes action
|
|
22
|
+
|
|
23
|
+
if props.allow
|
|
24
|
+
status = sh $"ufw status"
|
|
25
|
+
for entry in props.allow
|
|
26
|
+
rule = entry.args.join ' '
|
|
27
|
+
return 'drift' unless status.toUpperCase().includes rule.toUpperCase()
|
|
28
|
+
|
|
29
|
+
'ok'
|
|
30
|
+
|
|
31
|
+
export apply = (name, props) ->
|
|
32
|
+
unless ok $"which ufw"
|
|
33
|
+
sh $"apt-get update -qq"
|
|
34
|
+
sh $"apt-get install -y -qq ufw"
|
|
35
|
+
|
|
36
|
+
if props.default
|
|
37
|
+
for entry in props.default
|
|
38
|
+
[action, direction] = entry.args
|
|
39
|
+
sh $"ufw default #{action} #{direction}"
|
|
40
|
+
|
|
41
|
+
if props.allow
|
|
42
|
+
for entry in props.allow
|
|
43
|
+
sh $"ufw allow #{entry.args.join ' '}"
|
|
44
|
+
|
|
45
|
+
if props.deny
|
|
46
|
+
for entry in props.deny
|
|
47
|
+
sh $"ufw deny #{entry.args.join ' '}"
|
|
48
|
+
|
|
49
|
+
unless ufwActive()
|
|
50
|
+
sh $"ufw --force enable"
|
|
51
|
+
|
|
52
|
+
export verify = (name, props) ->
|
|
53
|
+
results = []
|
|
54
|
+
|
|
55
|
+
unless ok $"which ufw"
|
|
56
|
+
results.push { status: 'fail', message: "ufw not installed" }
|
|
57
|
+
return results
|
|
58
|
+
|
|
59
|
+
if ufwActive()
|
|
60
|
+
results.push { status: 'pass', message: "ufw active" }
|
|
61
|
+
else
|
|
62
|
+
results.push { status: 'fail', message: "ufw not active" }
|
|
63
|
+
|
|
64
|
+
if props.default
|
|
65
|
+
status = sh $"ufw status verbose"
|
|
66
|
+
for entry in props.default
|
|
67
|
+
[action, direction] = entry.args
|
|
68
|
+
if status.includes action
|
|
69
|
+
results.push { status: 'pass', message: "default #{action} #{direction}" }
|
|
70
|
+
else
|
|
71
|
+
results.push { status: 'fail', message: "default #{action} #{direction} not set" }
|
|
72
|
+
|
|
73
|
+
if props.allow
|
|
74
|
+
for entry in props.allow
|
|
75
|
+
rule = entry.args.join ' '
|
|
76
|
+
results.push { status: 'pass', message: "allow #{rule}" }
|
|
77
|
+
|
|
78
|
+
results
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export name = "group"
|
|
2
|
+
export description = "Ensures a system group exists on the host"
|
|
3
|
+
export positional = ["name", "gid"]
|
|
4
|
+
|
|
5
|
+
export check = (name, props) ->
|
|
6
|
+
return 'missing' unless ok $"getent group #{name}"
|
|
7
|
+
|
|
8
|
+
if want = props.args?[0]
|
|
9
|
+
return 'drift' if want isnt (sh $"getent group #{name}").split(':')[2]
|
|
10
|
+
|
|
11
|
+
'ok'
|
|
12
|
+
|
|
13
|
+
export apply = (name, props) ->
|
|
14
|
+
gid = props.args?[0]
|
|
15
|
+
unless ok $"getent group #{name}"
|
|
16
|
+
if gid
|
|
17
|
+
sh $"groupadd -g #{gid} #{name}"
|
|
18
|
+
else
|
|
19
|
+
sh $"groupadd #{name}"
|
|
20
|
+
|
|
21
|
+
export verify = (name, props) ->
|
|
22
|
+
results = []
|
|
23
|
+
unless ok $"getent group #{name}"
|
|
24
|
+
results.push { status: 'fail', message: "group #{name} missing" }
|
|
25
|
+
return results
|
|
26
|
+
results.push { status: 'pass', message: "group #{name} exists" }
|
|
27
|
+
|
|
28
|
+
if want = props.args?[0]
|
|
29
|
+
if want is (have = (sh $"getent group #{name}").split(':')[2])
|
|
30
|
+
results.push { status: 'pass', message: "#{name} gid is #{want}" }
|
|
31
|
+
else
|
|
32
|
+
results.push { status: 'fail', message: "#{name} gid is #{have}, expected #{want}" }
|
|
33
|
+
|
|
34
|
+
results
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
export name = "incus"
|
|
2
|
+
export description = "Ensures the Incus daemon is initialized with network and storage"
|
|
3
|
+
export positional = []
|
|
4
|
+
export properties =
|
|
5
|
+
storage: { description: "Storage pool: <name> <driver> <source>" }
|
|
6
|
+
network: { description: "Network bridge name" }
|
|
7
|
+
|
|
8
|
+
export check = (name, props) ->
|
|
9
|
+
return 'missing' unless ok $"incus info"
|
|
10
|
+
|
|
11
|
+
if props.storage
|
|
12
|
+
poolName = props.storage[0].args[0]
|
|
13
|
+
return 'drift' unless ok $"incus storage show #{poolName}"
|
|
14
|
+
|
|
15
|
+
if props.network
|
|
16
|
+
bridge = props.network[0].args[0]
|
|
17
|
+
return 'drift' unless ok $"incus network show #{bridge}"
|
|
18
|
+
|
|
19
|
+
'ok'
|
|
20
|
+
|
|
21
|
+
export apply = (name, props) ->
|
|
22
|
+
unless ok $"systemctl is-active incus"
|
|
23
|
+
sh $"systemctl enable --now incus"
|
|
24
|
+
|
|
25
|
+
unless ok $"incus info"
|
|
26
|
+
storageEntry = props.storage?[0]
|
|
27
|
+
networkEntry = props.network?[0]
|
|
28
|
+
|
|
29
|
+
networkYaml = if networkEntry
|
|
30
|
+
bridge = networkEntry.args[0]
|
|
31
|
+
"""
|
|
32
|
+
networks:
|
|
33
|
+
- config:
|
|
34
|
+
ipv4.address: auto
|
|
35
|
+
ipv4.nat: "true"
|
|
36
|
+
ipv6.address: none
|
|
37
|
+
description: ""
|
|
38
|
+
name: #{bridge}
|
|
39
|
+
type: bridge
|
|
40
|
+
"""
|
|
41
|
+
else
|
|
42
|
+
"networks: []"
|
|
43
|
+
|
|
44
|
+
storageYaml = if storageEntry
|
|
45
|
+
[poolName, driver, source] = storageEntry.args
|
|
46
|
+
"""
|
|
47
|
+
storage_pools:
|
|
48
|
+
- config:
|
|
49
|
+
source: #{source}
|
|
50
|
+
description: ""
|
|
51
|
+
driver: #{driver}
|
|
52
|
+
name: #{poolName}
|
|
53
|
+
"""
|
|
54
|
+
else
|
|
55
|
+
"storage_pools: []"
|
|
56
|
+
|
|
57
|
+
defaultPool = storageEntry?.args?[0] or 'default'
|
|
58
|
+
|
|
59
|
+
preseed = """
|
|
60
|
+
config: {}
|
|
61
|
+
#{networkYaml}
|
|
62
|
+
#{storageYaml}
|
|
63
|
+
profiles:
|
|
64
|
+
- config: {}
|
|
65
|
+
description: Default Incus profile
|
|
66
|
+
devices:
|
|
67
|
+
root:
|
|
68
|
+
path: /
|
|
69
|
+
pool: #{defaultPool}
|
|
70
|
+
type: disk
|
|
71
|
+
name: default
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
out = Bun.spawnSync ['incus', 'admin', 'init', '--preseed'], { stdin: Buffer.from(preseed), stdout: 'pipe', stderr: 'pipe' }
|
|
75
|
+
unless out.exitCode == 0
|
|
76
|
+
raise "incus admin init failed: #{out.stderr.toString().trim()}"
|
|
77
|
+
else
|
|
78
|
+
if props.storage
|
|
79
|
+
poolName = props.storage[0].args[0]
|
|
80
|
+
unless ok $"incus storage show #{poolName}"
|
|
81
|
+
raise "Incus already initialized but storage pool '#{poolName}' does not exist"
|
|
82
|
+
|
|
83
|
+
if props.network
|
|
84
|
+
bridge = props.network[0].args[0]
|
|
85
|
+
unless ok $"incus network show #{bridge}"
|
|
86
|
+
raise "Incus already initialized but network '#{bridge}' does not exist"
|
|
87
|
+
|
|
88
|
+
export verify = (name, props) ->
|
|
89
|
+
results = []
|
|
90
|
+
|
|
91
|
+
if ok $"incus info"
|
|
92
|
+
results.push { status: 'pass', message: "incus daemon running" }
|
|
93
|
+
else
|
|
94
|
+
results.push { status: 'fail', message: "incus daemon not running" }
|
|
95
|
+
return results
|
|
96
|
+
|
|
97
|
+
if props.storage
|
|
98
|
+
poolName = props.storage[0].args[0]
|
|
99
|
+
if ok $"incus storage show #{poolName}"
|
|
100
|
+
results.push { status: 'pass', message: "storage pool #{poolName} exists" }
|
|
101
|
+
else
|
|
102
|
+
results.push { status: 'fail', message: "storage pool #{poolName} missing" }
|
|
103
|
+
|
|
104
|
+
if props.network
|
|
105
|
+
bridge = props.network[0].args[0]
|
|
106
|
+
if ok $"incus network show #{bridge}"
|
|
107
|
+
results.push { status: 'pass', message: "network #{bridge} exists" }
|
|
108
|
+
else
|
|
109
|
+
results.push { status: 'fail', message: "network #{bridge} missing" }
|
|
110
|
+
|
|
111
|
+
results
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
export name = "multipass"
|
|
2
|
+
export description = "Manages Multipass virtual machines"
|
|
3
|
+
export positional = ["name", "image"]
|
|
4
|
+
export properties =
|
|
5
|
+
cpus: { description: "Number of CPUs (default: 1)" }
|
|
6
|
+
memory: { description: "Memory allocation (default: 1G)" }
|
|
7
|
+
disk: { description: "Disk size (default: 5G)" }
|
|
8
|
+
start: { description: "Ensure VM is running" }
|
|
9
|
+
|
|
10
|
+
isRunning = (name) ->
|
|
11
|
+
out = run $"multipass info #{name}"
|
|
12
|
+
out.ok and out.stdout.includes 'State:' and out.stdout.includes 'Running'
|
|
13
|
+
|
|
14
|
+
export check = (name, props) ->
|
|
15
|
+
return 'missing' unless ok $"multipass info #{name}"
|
|
16
|
+
return 'drift' if props.start and not isRunning name
|
|
17
|
+
'ok'
|
|
18
|
+
|
|
19
|
+
export apply = (name, props) ->
|
|
20
|
+
image = props.args?[0] or '24.04'
|
|
21
|
+
|
|
22
|
+
unless ok $"multipass info #{name}"
|
|
23
|
+
cpus = props.cpus?[0]?.args?[0]
|
|
24
|
+
memory = props.memory?[0]?.args?[0]
|
|
25
|
+
disk = props.disk?[0]?.args?[0]
|
|
26
|
+
sh ['multipass', 'launch', image, '--name', name,
|
|
27
|
+
'--cpus', cpus or '1',
|
|
28
|
+
'--memory', memory or '1G',
|
|
29
|
+
'--disk', disk or '5G']
|
|
30
|
+
|
|
31
|
+
if props.start and not isRunning name
|
|
32
|
+
sh $"multipass start #{name}"
|
|
33
|
+
|
|
34
|
+
export verify = (name, props) ->
|
|
35
|
+
results = []
|
|
36
|
+
|
|
37
|
+
unless ok $"multipass info #{name}"
|
|
38
|
+
results.push { status: 'fail', message: "vm #{name} missing" }
|
|
39
|
+
return results
|
|
40
|
+
results.push { status: 'pass', message: "vm #{name} exists" }
|
|
41
|
+
|
|
42
|
+
if isRunning name
|
|
43
|
+
results.push { status: 'pass', message: "#{name} running" }
|
|
44
|
+
else
|
|
45
|
+
results.push { status: 'fail', message: "#{name} not running" }
|
|
46
|
+
|
|
47
|
+
results
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
export name = "packages"
|
|
2
|
+
export description = "Ensures system packages are installed"
|
|
3
|
+
export positional = []
|
|
4
|
+
|
|
5
|
+
gather = (name, props) ->
|
|
6
|
+
list = [...(props.args or [])]
|
|
7
|
+
for own k, entries of props when k != 'args'
|
|
8
|
+
list.push k
|
|
9
|
+
for entry in entries
|
|
10
|
+
list.push ...entry.args if entry.args?.length
|
|
11
|
+
list
|
|
12
|
+
|
|
13
|
+
installed = (pkg) ->
|
|
14
|
+
out = run $"dpkg -s #{pkg}"
|
|
15
|
+
out.ok and out.stdout.includes 'Status: install ok installed'
|
|
16
|
+
|
|
17
|
+
export check = (name, props) ->
|
|
18
|
+
list = gather name, props
|
|
19
|
+
return 'ok' if list.length == 0
|
|
20
|
+
for pkg in list
|
|
21
|
+
return 'drift' unless installed pkg
|
|
22
|
+
'ok'
|
|
23
|
+
|
|
24
|
+
export apply = (name, props) ->
|
|
25
|
+
list = gather name, props
|
|
26
|
+
return if list.length == 0
|
|
27
|
+
missing = (pkg for pkg in list when not installed pkg)
|
|
28
|
+
return if missing.length == 0
|
|
29
|
+
sh $"apt-get update -qq"
|
|
30
|
+
sh ['apt-get', 'install', '-y', '-qq', ...missing]
|
|
31
|
+
|
|
32
|
+
export verify = (name, props) ->
|
|
33
|
+
list = gather name, props
|
|
34
|
+
results = []
|
|
35
|
+
for pkg in list
|
|
36
|
+
if installed pkg
|
|
37
|
+
results.push { status: 'pass', message: "#{pkg} installed" }
|
|
38
|
+
else
|
|
39
|
+
results.push { status: 'fail', message: "#{pkg} not installed" }
|
|
40
|
+
results
|