Monorepo for Tangled
tangled.org
1package microvm
2
3import (
4 "encoding/json"
5 "errors"
6 "fmt"
7 "os"
8 "path/filepath"
9 "strings"
10)
11
12const imageSpecFileName = "spec.json"
13
14type RunnerConfig struct {
15 CPU string `json:"cpu,omitempty"`
16 Machine string `json:"machine,omitempty"`
17 Console string `json:"console,omitempty"`
18 ExtraArgs []string `json:"extraArgs,omitempty"`
19}
20
21type ImageSpec struct {
22 Arch string `json:"arch"`
23 BootArgs string `json:"bootArgs"`
24 Initrd string `json:"initrd"`
25 Kernel string `json:"kernel"`
26 RunnerType string `json:"runnerType"`
27 RunnerConfig RunnerConfig `json:"runnerConfig"`
28 MemoryMiB int `json:"memoryMiB"`
29 NetworkInterfaces []NetworkInterface `json:"networkInterfaces"`
30 StoreDisk string `json:"storeDisk"`
31 StoreDiskType string `json:"storeDiskType"`
32 // baseConfigHash identifies the base nixos configuration baked into the
33 // image. its only for nixos images as other images won't have a system
34 // to rebuild.
35 BaseConfigHash string `json:"baseConfigHash,omitempty"`
36 // shell is the login shell used to run workflow step commands in the guest.
37 Shell string `json:"shell"`
38 VCPUs int `json:"vcpus"`
39 Volumes []Volume `json:"volumes"`
40}
41
42func (s ImageSpec) SupportsConfigActivation() bool {
43 return s.BaseConfigHash != ""
44}
45
46type NetworkInterface struct {
47 Type string `json:"type"`
48 ID string `json:"id"`
49 MAC string `json:"mac"`
50}
51
52type Volume struct {
53 FSType string `json:"fsType"`
54 Image string `json:"image"`
55 ImageType string `json:"imageType"`
56 MountPoint string `json:"mountPoint"`
57 ReadOnly bool `json:"readOnly"`
58 SizeMiB int64 `json:"sizeMiB"`
59}
60
61func LoadImageSpec(path string) (ImageSpec, error) {
62 data, err := os.ReadFile(path)
63 if err != nil {
64 return ImageSpec{}, fmt.Errorf("read microvm image spec: %w", err)
65 }
66
67 var spec ImageSpec
68 if err := json.Unmarshal(data, &spec); err != nil {
69 return ImageSpec{}, fmt.Errorf("parse microvm image spec: %w", err)
70 }
71
72 base := filepath.Dir(path)
73 spec.Kernel = resolveImageSpecPath(base, spec.Kernel)
74 spec.Initrd = resolveImageSpecPath(base, spec.Initrd)
75 spec.StoreDisk = resolveImageSpecPath(base, spec.StoreDisk)
76
77 if err := spec.Validate(); err != nil {
78 return ImageSpec{}, err
79 }
80 return spec, nil
81}
82
83func (s ImageSpec) Validate() error {
84 if s.Kernel == "" {
85 return fmt.Errorf("microvm image spec missing kernel")
86 }
87 if s.Initrd == "" {
88 return fmt.Errorf("microvm image spec missing initrd")
89 }
90 if s.StoreDisk == "" {
91 return fmt.Errorf("microvm image spec missing storeDisk")
92 }
93 if s.BootArgs == "" {
94 return fmt.Errorf("microvm image spec missing bootArgs")
95 }
96 if s.Shell == "" {
97 return fmt.Errorf("microvm image spec missing shell")
98 }
99 if s.RunnerType == "qemu" || s.RunnerType == "" {
100 if s.RunnerConfig.Machine == "" {
101 return fmt.Errorf("microvm image spec missing runnerConfig.machine for qemu runner")
102 }
103 }
104 if s.MemoryMiB <= 0 {
105 return fmt.Errorf("microvm image spec memoryMiB must be positive")
106 }
107 if s.VCPUs <= 0 {
108 return fmt.Errorf("microvm image spec vcpus must be positive")
109 }
110 for _, networkInterface := range s.NetworkInterfaces {
111 if networkInterface.Type == "" {
112 return fmt.Errorf("microvm image spec network interface missing type")
113 }
114 if networkInterface.ID == "" {
115 return fmt.Errorf("microvm image spec network interface missing id")
116 }
117 if networkInterface.MAC == "" {
118 return fmt.Errorf("microvm image spec network interface %q missing mac", networkInterface.ID)
119 }
120 }
121 for _, volume := range s.Volumes {
122 if volume.Image == "" {
123 return fmt.Errorf("microvm image spec volume missing image")
124 }
125 if volume.FSType == "" {
126 return fmt.Errorf("microvm image spec volume %q missing fsType", volume.Image)
127 }
128 if volume.SizeMiB <= 0 {
129 return fmt.Errorf("microvm image spec volume %q sizeMiB must be positive", volume.Image)
130 }
131 }
132 return nil
133}
134
135func (s ImageSpec) RunnerCmd() string {
136 switch s.RunnerType {
137 case "qemu", "":
138 return "qemu-system-" + s.Arch
139 case "firecracker":
140 return "firecracker"
141 default:
142 return ""
143 }
144}
145
146// also see Runner.Validate for where Runner specific files are validated
147func (s ImageSpec) validateImageFiles() error {
148 required := map[string]string{
149 "kernel": s.Kernel,
150 "initrd": s.Initrd,
151 "storeDisk": s.StoreDisk,
152 }
153 for name, path := range required {
154 if !filepath.IsAbs(path) {
155 continue
156 }
157 if _, err := os.Stat(path); err != nil {
158 return fmt.Errorf("required image spec file %s not found at %q: %w", name, path, err)
159 }
160 }
161
162 return nil
163}
164
165func resolveImageSpecPath(base, path string) string {
166 if path == "" || filepath.IsAbs(path) {
167 return path
168 }
169 return filepath.Join(base, path)
170}
171
172func (e *Engine) resolveImage(name string) (ImageSpec, string, string, error) {
173 name = strings.TrimSpace(name)
174 if name == "" {
175 name = strings.TrimSpace(e.cfg.MicroVMPipelines.DefaultImage)
176 }
177 if name == "" {
178 return ImageSpec{}, "", "", fmt.Errorf("no image specified in workflow and SPINDLE_MICROVM_PIPELINES_DEFAULT_IMAGE is not set")
179 }
180 if !isPlainImageName(name) {
181 return ImageSpec{}, "", "", fmt.Errorf("invalid microVM image name %q: must be a plain name, not a path", name)
182 }
183
184 candidates := imageCandidates(e.cfg.MicroVMPipelines.ImageDir, name)
185 for _, candidate := range candidates {
186 path, ok, err := imageSpecPath(candidate)
187 if err != nil {
188 return ImageSpec{}, "", "", err
189 }
190 if !ok {
191 continue
192 }
193 imageSpec, err := LoadImageSpec(path)
194 if err != nil {
195 return ImageSpec{}, "", "", err
196 }
197 return imageSpec, path, name, nil
198 }
199
200 return ImageSpec{}, "", "", fmt.Errorf("microVM image %q was not found; looked in: %s", name, strings.Join(candidates, ", "))
201}
202
203// check if image name is not a path
204func isPlainImageName(name string) bool {
205 if name == "" || name == "." || name == ".." {
206 return false
207 }
208 if filepath.IsAbs(name) || strings.ContainsRune(name, '/') || strings.ContainsRune(name, filepath.Separator) {
209 return false
210 }
211 return true
212}
213
214// returns candidates, which is either a directory or spec file itself
215func imageCandidates(imageDir, name string) []string {
216 if imageDir == "" {
217 return nil
218 }
219 return []string{
220 filepath.Join(imageDir, name),
221 filepath.Join(imageDir, name+".json"),
222 }
223}
224
225// resolve the candidate to a spec:
226// - first check if its a file, if yes, return
227// - otherwise assume its a directory and check and return `/spec.json`
228func imageSpecPath(candidate string) (string, bool, error) {
229 info, err := os.Stat(candidate)
230 if err != nil {
231 if errors.Is(err, os.ErrNotExist) {
232 return "", false, nil
233 }
234 return "", false, err
235 }
236 if !info.IsDir() {
237 return candidate, true, nil
238 }
239
240 spec := filepath.Join(candidate, imageSpecFileName)
241 info, err = os.Stat(spec)
242 if err != nil {
243 if errors.Is(err, os.ErrNotExist) {
244 return "", false, fmt.Errorf("microVM image directory %q does not contain %s", candidate, imageSpecFileName)
245 }
246 return "", false, err
247 }
248 // this only happens if there is a directory named `spec.json` which would be very silly.
249 // but better output an error for it anyway :p
250 if info.IsDir() {
251 return "", false, fmt.Errorf("microVM image spec %q is a directory", spec)
252 }
253 return spec, true, nil
254}