Repository navigation
Expand file tree
/
Copy pathcreate.go
More file actions
656 lines (607 loc) · 21.6 KB
/
Copy pathcreate.go
File metadata and controls
656 lines (607 loc) · 21.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
package main
import (
"cmp"
"encoding/base64"
"errors"
"flag"
"fmt"
"io/fs"
"os"
"os/exec"
"path/filepath"
"regexp"
"slices"
"strconv"
"strings"
)
const createUsage = `Create an isolated Lima dev VM with SSH access to GitHub.
Usage: devvm create [name] [-create-ssh-key=false] [-dotfiles REPO|-no-dotfiles]
[-cpus N] [-memory GiB] [-disk GiB] [-nested]
- Generates a fresh ed25519 key pair at ~/.config/dev-vm/keys/<name> (no
passphrase) on every create; -create-ssh-key=false reuses the existing pair.
- Registers the public key on GitHub with title dev-vm/<host>/<name> via gh,
replacing any key recorded in the state file or sharing the same title. The
host qualifier keeps two machines using the same VM name from deleting each
other's key.
- Starts the VM from the embedded lima/dev-vm.yaml; the template uploads the
private key and ssh config into the guest, and provisioning fetches
known_hosts.
- Reads ~/.config/dev-vm/settings.json for the "dotfiles", "cpus", "memory",
"disk", "clone", "mkcert", "ghostty", "nested" and "mounts" keys: the "default" block
applies to every VM, and "vms".<name> overrides it key by key for this one.
- With dotfiles enabled, provisioning clones the bare repo to ~/.dotfiles in
the guest and checks it out over $HOME. The repo comes from -dotfiles or
from the "dotfiles" setting, which turns dotfiles on by default.
- Sizes the VM at 2 vCPUs, 2 GiB RAM and a 50 GiB disk by default. -cpus,
-memory and -disk override both that and the settings; -memory and -disk are
plain integers in GiB. Size is fixed at create time — resizing means delete
and create again.
- Clones the repositories listed under "clone" as the last user provisioning
step; a repository already present in the guest is skipped.
- With "mkcert": true in the settings, copies the host mkcert root CA
(rootCA.pem and rootCA-key.pem from mkcert -CAROOT) into the guest CAROOT
and trusts rootCA.pem in the guest system store through the template's
caCerts.files.
- With "ghostty": true in the settings, captures the xterm-ghostty terminfo
entry with Homebrew's infocmp (brew install ncurses; the macOS built-in is
too old for extended capabilities) and compiles it in the guest.
- -nested (or "nested": true in the settings) turns on Lima's
nestedVirtualization, so the guest gets /dev/kvm and can run VMs of its own.
Needs an Apple M3 or later; -nested=false overrides the setting. Like the
size, it is fixed at create time.
- Shares the host directories listed under "mounts" into the guest, each as
{"source": HOST, "destination": GUEST, "mode": "ro"|"rw"}. destination
defaults to source and mode to "ro"; a leading ~ means the host home in
source and the guest home in destination. Fixed at create time.
- Records VM metadata (GitHub key id, key paths) in ~/.config/dev-vm/state.json.
`
// resources is the VM size; memory and disk are in GiB.
type resources struct {
cpus int
memory int
disk int
}
var defaultResources = resources{cpus: 2, memory: 2, disk: 50}
// nestedMinChip is the first Apple silicon generation whose
// Virtualization.framework can nest, and appleChipRE pulls that generation out
// of the host CPU brand string.
const nestedMinChip = 3
var appleChipRE = regexp.MustCompile(`^Apple M(\d+)`)
// caFiles are the mkcert root CA files copied from the host CAROOT into the
// guest one. The key comes along so the guest can issue its own certificates.
var caFiles = []string{"rootCA.pem", "rootCA-key.pem"}
const (
// ghosttyTerm is the terminfo entry Ghostty sets TERM to.
ghosttyTerm = "xterm-ghostty"
// ghosttyTerminfoDir is where the macOS app bundle keeps its terminfo
// database, for the case where devvm runs outside Ghostty and TERMINFO is
// therefore unset.
ghosttyTerminfoDir = "/Applications/Ghostty.app/Contents/Resources/terminfo"
)
// cloneRepo is one repository the guest clones, with the directory it goes
// under. repo is "<org>/<name>".
type cloneRepo struct {
basedir string
repo string
}
// mountMode maps the "mode" of a mount setting to Lima's writable flag.
var mountMode = map[string]bool{"ro": false, "rw": true}
// mount is a validated "mounts" entry, ready for Lima's mounts list.
// mountPoint is a guest path or Lima template, never ~-prefixed; empty leaves
// it to Lima, which mounts at the expanded host location.
type mount struct {
location string
mountPoint string
writable bool
}
// guestFiles is what the host contributes to the template tree at create time,
// as opposed to the embedded assets: the private key path, and the payloads the
// settings turn on. An empty caroot or terminfo means the setting is off.
type guestFiles struct {
key string
clones []cloneRepo
caroot string
terminfo string
}
func cmdCreate(argv []string) {
fs := flag.NewFlagSet("create", flag.ExitOnError)
fs.Usage = func() {
fmt.Fprint(os.Stderr, createUsage)
fs.PrintDefaults()
}
var createSSHKey bool
var dotfilesRepo string
var noDotfiles bool
fs.BoolVar(&createSSHKey, "create-ssh-key", true,
"create and register a new key; -create-ssh-key=false reuses the existing key")
fs.StringVar(&dotfilesRepo, "dotfiles", "",
"clone REPO as a bare repo and check it out over the guest $HOME; "+
"unset, use the \"dotfiles\" entry in settings.json")
fs.BoolVar(&noDotfiles, "no-dotfiles", false,
"skip dotfiles even when settings.json configures them")
// Zero defaults: the real ones come from settings.json, which can only be
// read once the VM name is known. resolveResources fills in what no flag set.
var res resources
fs.IntVar(&res.cpus, "cpus", 0, "vCPUs for the VM (default 2, or settings.json)")
fs.IntVar(&res.memory, "memory", 0, "RAM in GiB (default 2, or settings.json)")
fs.IntVar(&res.disk, "disk", 0, "disk size in GiB (default 50, or settings.json)")
var nested bool
fs.BoolVar(&nested, "nested", false,
"run the VM with nested virtualization (Apple M3 or later); "+
"unset, use the \"nested\" entry in settings.json")
name := parseArgs(fs, argv)
checkName(name)
config := loadSettings(name)
set := flagsSet(fs)
res = resolveResources(res, set, config)
checkResources(res)
nested = resolveNested(nested, set, config)
if nested {
checkNested()
}
dotfiles := resolveDotfiles(dotfilesRepo, noDotfiles, config)
clones := settingsClones(config)
caroot := resolveCAROOT(config)
terminfo := resolveGhosttyTerminfo(config)
mounts := settingsMounts(config)
if vmExists(name) {
die("VM %q already exists; run: devvm delete %s", name, name)
}
key, pub := keyPaths(name)
if !createSSHKey {
if !fileExists(key) || !fileExists(pub) {
die("no key at %s; rerun without -create-ssh-key=false", key)
}
fmt.Printf("reusing key %s\n", key)
} else {
checkScopes()
title := keyTitle(name)
createKey(title, key, pub)
keyID := registerKey(name, title, pub)
putVM(name, map[string]any{
"github_key_id": keyID,
"github_key_title": title,
"private_key": key,
"public_key": pub,
})
}
if dotfiles != "" {
fmt.Printf("installing dotfiles from %s\n", dotfiles)
}
if len(clones) > 0 {
fmt.Printf("cloning %d repositories\n", len(clones))
}
if caroot != "" {
fmt.Printf("copying the mkcert root CA from %s\n", caroot)
}
if terminfo != "" {
fmt.Printf("installing the %s terminfo entry\n", ghosttyTerm)
}
if nested {
fmt.Println("nested virtualization on; the guest gets /dev/kvm")
}
for _, m := range mounts {
fmt.Printf("mounting %s at %s (%s)\n", m.location, cmp.Or(m.mountPoint, m.location), mountModeName(m.writable))
}
fmt.Printf("VM size %d vCPU, %dGiB RAM, %dGiB disk\n", res.cpus, res.memory, res.disk)
startVM(name, dotfiles, res, nested, mounts, guestFiles{
key: key,
clones: clones,
caroot: caroot,
terminfo: terminfo,
})
putVM(name, map[string]any{
"template": "embedded:lima/dev-vm.yaml",
"started_at": now(),
"private_key": key,
"public_key": pub,
"dotfiles": dotfiles,
})
if inst, ok := limaInstances()[name]; ok {
if ip := guestIP(inst); ip != "" {
fmt.Printf("IP %s (no port forwards; reach guest services here)\n", ip)
}
}
fmt.Printf("state %s\n", stateFile)
}
// parseArgs parses flags, then an optional positional name, then any flags
// placed after it.
func parseArgs(fs *flag.FlagSet, argv []string) string {
fs.Parse(argv)
args := fs.Args()
if len(args) == 0 {
return "default"
}
name := args[0]
fs.Parse(args[1:])
if fs.NArg() > 0 {
die("unexpected argument %q", fs.Arg(0))
}
return name
}
// resolveDotfiles: -dotfiles wins over settings.json; unset falls back to
// settings.
func resolveDotfiles(repo string, noDotfiles bool, config vmConfig) string {
if noDotfiles {
return ""
}
if repo == "" && config.Dotfiles != nil {
repo = *config.Dotfiles
}
if repo != "" {
checkRepo(repo)
}
return repo
}
// resolveNested: -nested (either way) wins over settings.json; unset falls back
// to the "nested" setting.
func resolveNested(nested bool, set map[string]bool, config vmConfig) bool {
if set["nested"] {
return nested
}
return config.Nested != nil && *config.Nested
}
// checkNested rejects a host whose Virtualization.framework cannot nest, before
// create gets as far as registering a GitHub key. vz exposes no probe short of
// starting a VM, so the chip generation is the check: Apple documents nested
// virtualization as M3 and later, and vz fails the start on anything older.
func checkNested() {
out, err := exec.Command("sysctl", "-n", "machdep.cpu.brand_string").Output()
if err != nil {
die("cannot read machdep.cpu.brand_string: %v", err)
}
brand := strings.TrimSpace(string(out))
if !nestedSupported(brand) {
die("nested virtualization needs an Apple M3 or later, and this host is %q", brand)
}
}
// nestedSupported reads the chip generation out of a macOS CPU brand string
// ("Apple M3 Pro"). Anything that is not Apple silicon, and every generation
// below M3, is unsupported.
func nestedSupported(brand string) bool {
m := appleChipRE.FindStringSubmatch(brand)
if m == nil {
return false
}
generation, err := strconv.Atoi(m[1])
return err == nil && generation >= nestedMinChip
}
// resolveCAROOT returns the host mkcert CA directory when the "mkcert" setting
// is on, and "" otherwise.
func resolveCAROOT(config vmConfig) string {
if !mkcertEnabled(config) {
return ""
}
return hostCAROOT()
}
func mkcertEnabled(config vmConfig) bool {
return config.Mkcert != nil && *config.Mkcert
}
// hostCAROOT asks mkcert where its CA lives and checks both files are there;
// the settings asked for the CA, so a missing one is an error rather than a
// silent skip.
func hostCAROOT() string {
out, err := exec.Command("mkcert", "-CAROOT").Output()
if err != nil {
if errors.Is(err, exec.ErrNotFound) {
die("mkcert not found; install it or unset \"mkcert\" in %s", settingsFile)
}
die("mkcert -CAROOT failed: %v", err)
}
caroot := strings.TrimSpace(string(out))
if caroot == "" {
die("mkcert -CAROOT printed nothing")
}
for _, f := range caFiles {
if !fileExists(filepath.Join(caroot, f)) {
die("no %s in %s; run: mkcert -install", f, caroot)
}
}
return caroot
}
// resolveGhosttyTerminfo returns the xterm-ghostty terminfo source when the
// "ghostty" setting is on, and "" otherwise.
func resolveGhosttyTerminfo(config vmConfig) string {
if !ghosttyEnabled(config) {
return ""
}
return hostTerminfo()
}
func ghosttyEnabled(config vmConfig) bool {
return config.Ghostty != nil && *config.Ghostty
}
// hostTerminfo dumps the xterm-ghostty entry with Homebrew's infocmp. The
// macOS built-in is ncurses 6.0 from 2015 and mangles the extended (-x)
// capabilities the entry is largely made of, so the Homebrew build is
// required rather than preferred.
func hostTerminfo() string {
infocmp := filepath.Join(brewPrefix("ncurses"), "bin", "infocmp")
if !fileExists(infocmp) {
die("no %s; run: brew install ncurses", infocmp)
}
// TERMINFO points at the app bundle when devvm runs inside Ghostty; when
// it does not, name the bundle so the entry is still found.
out, err := exec.Command(infocmp, "-x", ghosttyTerm).Output()
if err != nil {
cmd := exec.Command(infocmp, "-x", ghosttyTerm)
cmd.Env = append(os.Environ(), "TERMINFO="+ghosttyTerminfoDir)
out, err = cmd.Output()
}
if err != nil {
die("infocmp -x %s failed: %v; is Ghostty installed?", ghosttyTerm, err)
}
if len(out) == 0 {
die("infocmp -x %s printed nothing", ghosttyTerm)
}
return string(out)
}
// brewPrefix resolves a Homebrew formula prefix, e.g. /opt/homebrew/opt/ncurses.
func brewPrefix(formula string) string {
out, err := exec.Command("brew", "--prefix", formula).Output()
if err != nil {
if errors.Is(err, exec.ErrNotFound) {
die("brew not found; install Homebrew, then: brew install %s", formula)
}
die("brew --prefix %s failed: %v", formula, err)
}
return strings.TrimSpace(string(out))
}
// settingsClones flattens the "clone" setting to one entry per repository.
func settingsClones(config vmConfig) []cloneRepo {
if config.Clone == nil {
return nil
}
var clones []cloneRepo
for _, g := range *config.Clone {
if !ghNameRE.MatchString(g.Org) {
die("settings %s: invalid clone org %q", settingsFile, g.Org)
}
if !basedirRE.MatchString(g.Basedir) {
die("settings %s: invalid clone basedir %q", settingsFile, g.Basedir)
}
for _, name := range g.Repositories {
if !ghNameRE.MatchString(name) {
die("settings %s: invalid clone repository %q", settingsFile, name)
}
clones = append(clones, cloneRepo{basedir: g.Basedir, repo: g.Org + "/" + name})
}
}
return clones
}
// cloneList renders the guest-side list clone-user.sh reads: one
// "<basedir>\t<org>/<repo>" line per repository. The header keeps the file
// non-empty when nothing is configured.
func cloneList(clones []cloneRepo) string {
list := "# <basedir>\t<org>/<repo>, from the \"clone\" entry of settings.json\n"
for _, c := range clones {
list += fmt.Sprintf("%s\t%s\n", c.basedir, c.repo)
}
return list
}
// settingsMounts validates the "mounts" setting; mode falls back to "ro". Lima
// expands ~ in a host location but rejects it in a mount point, so a ~
// destination becomes the guest home template here, and an absent one is left
// for Lima to derive from the expanded source.
func settingsMounts(config vmConfig) []mount {
if config.Mounts == nil {
return nil
}
var mounts []mount
for _, m := range *config.Mounts {
if !mountPathRE.MatchString(m.Source) || !isMountPath(m.Source) {
die("settings %s: mount source %q must be an absolute or ~ path", settingsFile, m.Source)
}
dest := m.Destination
if dest != "" && (!mountPathRE.MatchString(dest) || !isMountPath(dest)) {
die("settings %s: mount destination %q must be an absolute or ~ path", settingsFile, dest)
}
writable, ok := mountMode[cmp.Or(m.Mode, "ro")]
if !ok {
die("settings %s: mount mode %q must be \"ro\" or \"rw\"", settingsFile, m.Mode)
}
if rest, ok := strings.CutPrefix(dest, "~"); ok {
dest = "{{.Home}}" + rest
}
mounts = append(mounts, mount{location: m.Source, mountPoint: dest, writable: writable})
}
return mounts
}
// isMountPath accepts an absolute path, ~ alone, or ~/...; ~user is not a
// path the guest side could expand.
func isMountPath(p string) bool {
return filepath.IsAbs(p) || p == "~" || strings.HasPrefix(p, "~/")
}
func mountModeName(writable bool) string {
if writable {
return "rw"
}
return "ro"
}
// resolveResources: a flag given on the command line wins, then settings.json,
// then the built-in defaults.
func resolveResources(flags resources, set map[string]bool, config vmConfig) resources {
res := defaultResources
for _, r := range []struct {
key string
setting *int
flag int
out *int
}{
{"cpus", config.CPUs, flags.cpus, &res.cpus},
{"memory", config.Memory, flags.memory, &res.memory},
{"disk", config.Disk, flags.disk, &res.disk},
} {
if r.setting != nil {
if *r.setting <= 0 {
die("settings %s: %q must be a positive integer", settingsFile, r.key)
}
*r.out = *r.setting
}
if set[r.key] {
*r.out = r.flag
}
}
return res
}
// flagsSet lists the flags that appeared on the command line, so an unset flag
// falls back to settings.json instead of its zero value.
func flagsSet(fs *flag.FlagSet) map[string]bool {
set := map[string]bool{}
fs.Visit(func(f *flag.Flag) { set[f.Name] = true })
return set
}
func checkResources(res resources) {
for _, r := range []struct {
flag string
n int
}{
{"cpus", res.cpus},
{"memory", res.memory},
{"disk", res.disk},
} {
if r.n <= 0 {
die("-%s must be a positive integer, got %d", r.flag, r.n)
}
}
}
func fileExists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
func createKey(comment, key, pub string) {
if err := os.MkdirAll(keyDir, 0o700); err != nil {
die("cannot create %s: %v", keyDir, err)
}
if err := os.Chmod(keyDir, 0o700); err != nil {
die("cannot chmod %s: %v", keyDir, err)
}
for _, p := range []string{key, pub} {
os.Remove(p)
}
run("ssh-keygen", "-t", "ed25519", "-N", "", "-C", comment, "-f", key)
if err := os.Chmod(key, 0o600); err != nil {
die("cannot chmod %s: %v", key, err)
}
fmt.Printf("created key %s\n", key)
}
// registerKey replaces the key recorded in the state file plus any key holding
// the qualified title, then registers the new public key under that title.
func registerKey(name, title, pub string) int64 {
previous, _ := keyID(getVM(name)["github_key_id"])
pubData, err := os.ReadFile(pub)
if err != nil {
die("cannot read %s: %v", pub, err)
}
for _, k := range listKeys() {
if k.Title == title || k.ID == previous {
deleteKey(k.ID)
fmt.Printf("deleted old GitHub key %d (%s)\n", k.ID, k.Title)
}
}
created := addKey(title, strings.TrimSpace(string(pubData)))
fmt.Printf("registered GitHub key %d (%s)\n", created.ID, title)
return created.ID
}
// startVM materializes the embedded template tree into a temp directory —
// limactl resolves provision file.url paths relative to the template — drops
// the private key at tmp/default where the template expects it, and boots.
func startVM(name, dotfiles string, res resources, nested bool, mounts []mount, files guestFiles) {
dir, err := os.MkdirTemp("", "dev-vm-")
if err != nil {
die("cannot create temp dir: %v", err)
}
defer os.RemoveAll(dir)
err = fs.WalkDir(assets, "lima", func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
dst := filepath.Join(dir, strings.TrimPrefix(path, "lima/"))
if d.IsDir() {
return os.MkdirAll(dst, 0o755)
}
data, err := assets.ReadFile(path)
if err != nil {
return err
}
return os.WriteFile(dst, data, 0o644)
})
if err != nil {
die("cannot materialize template: %v", err)
}
keyData, err := os.ReadFile(files.key)
if err != nil {
die("cannot read %s: %v", files.key, err)
}
if err := os.MkdirAll(filepath.Join(dir, "tmp"), 0o700); err != nil {
die("cannot materialize template: %v", err)
}
if err := os.WriteFile(filepath.Join(dir, "tmp", "default"), keyData, 0o600); err != nil {
die("cannot materialize template: %v", err)
}
if err := os.WriteFile(filepath.Join(dir, "tmp", "clone-list"), []byte(cloneList(files.clones)), 0o600); err != nil {
die("cannot materialize template: %v", err)
}
// Same for the terminfo source, empty when the "ghostty" setting is off:
// ghostty-terminfo-system.sh skips an empty file.
if err := os.WriteFile(filepath.Join(dir, "tmp", ghosttyTerm+".terminfo.b64"),
[]byte(terminfoB64(files.terminfo)), 0o600); err != nil {
die("cannot materialize template: %v", err)
}
// The mkcert CA files are staged the same way, empty when the setting is
// off: their `mode: data` entries are unconditional, and mkcert-user.sh
// skips an empty file.
for _, f := range caFiles {
var data []byte
if files.caroot != "" {
data, err = os.ReadFile(filepath.Join(files.caroot, f))
if err != nil {
die("cannot read %s: %v", filepath.Join(files.caroot, f), err)
}
}
if err := os.WriteFile(filepath.Join(dir, "tmp", f), data, 0o600); err != nil {
die("cannot materialize template: %v", err)
}
}
limactlRun("start", "--tty=false", "--name", name,
"--set", startSet(res, dotfiles, files.caroot, nested, mounts),
filepath.Join(dir, "dev-vm.yaml"))
}
// terminfoB64 encodes the terminfo source for its `mode: data` entry, in
// 76-column lines. The encoding is not cosmetic: Lima runs data content
// through a Go template on the host, and the entry's acsc capability contains
// a literal `{{`, which the template parser rejects. base64 keeps the payload
// out of its way; ghostty-terminfo-system.sh decodes it.
func terminfoB64(src string) string {
if src == "" {
return ""
}
var out strings.Builder
for line := range slices.Chunk([]byte(base64.StdEncoding.EncodeToString([]byte(src))), 76) {
out.Write(line)
out.WriteByte('\n')
}
return out.String()
}
// startSet is the yq expression patching the template at creation time. The
// caCerts entry points at the host CAROOT, not at the temp tree: the hostagent
// re-reads it every time the instance starts, long after that tree is gone.
// Mounts are written unconditionally, replacing the template's empty list.
func startSet(res resources, dotfiles, caroot string, nested bool, mounts []mount) string {
set := fmt.Sprintf(
`.cpus = %d | .memory = "%dGiB" | .disk = "%dGiB" | .param.DOTFILES_REPO = %q`+
` | .nestedVirtualization = %t`,
res.cpus, res.memory, res.disk, dotfiles, nested)
if caroot != "" {
set += fmt.Sprintf(` | .caCerts.files = [%q]`, filepath.Join(caroot, "rootCA.pem"))
}
entries := make([]string, len(mounts))
for i, m := range mounts {
point := ""
if m.mountPoint != "" {
point = fmt.Sprintf(`, "mountPoint": %q`, m.mountPoint)
}
entries[i] = fmt.Sprintf(`{"location": %q%s, "writable": %t}`, m.location, point, m.writable)
}
set += " | .mounts = [" + strings.Join(entries, ", ") + "]"
return set
}