forked from
tangled.org/core
Monorepo for Tangled
7.5 kB
259 lines
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 imageDir := strings.TrimSpace(e.cfg.MicroVMPipelines.ImageDir)
185 if imageDir == "" {
186 return ImageSpec{}, "", "", fmt.Errorf("microVM workflows require SPINDLE_MICROVM_PIPELINES_IMAGE_DIR")
187 }
188
189 candidates := imageCandidates(imageDir, name)
190 for _, candidate := range candidates {
191 path, ok, err := imageSpecPath(candidate)
192 if err != nil {
193 return ImageSpec{}, "", "", err
194 }
195 if !ok {
196 continue
197 }
198 imageSpec, err := LoadImageSpec(path)
199 if err != nil {
200 return ImageSpec{}, "", "", err
201 }
202 return imageSpec, path, name, nil
203 }
204
205 return ImageSpec{}, "", "", fmt.Errorf("microVM image %q was not found; looked in: %s", name, strings.Join(candidates, ", "))
206}
207
208// check if image name is not a path
209func isPlainImageName(name string) bool {
210 if name == "" || name == "." || name == ".." {
211 return false
212 }
213 if filepath.IsAbs(name) || strings.ContainsRune(name, '/') || strings.ContainsRune(name, filepath.Separator) {
214 return false
215 }
216 return true
217}
218
219// returns candidates, which is either a directory or spec file itself
220func imageCandidates(imageDir, name string) []string {
221 if imageDir == "" {
222 return nil
223 }
224 return []string{
225 filepath.Join(imageDir, name),
226 filepath.Join(imageDir, name+".json"),
227 }
228}
229
230// resolve the candidate to a spec:
231// - first check if its a file, if yes, return
232// - otherwise assume its a directory and check and return `/spec.json`
233func imageSpecPath(candidate string) (string, bool, error) {
234 info, err := os.Stat(candidate)
235 if err != nil {
236 if errors.Is(err, os.ErrNotExist) {
237 return "", false, nil
238 }
239 return "", false, err
240 }
241 if !info.IsDir() {
242 return candidate, true, nil
243 }
244
245 spec := filepath.Join(candidate, imageSpecFileName)
246 info, err = os.Stat(spec)
247 if err != nil {
248 if errors.Is(err, os.ErrNotExist) {
249 return "", false, fmt.Errorf("microVM image directory %q does not contain %s", candidate, imageSpecFileName)
250 }
251 return "", false, err
252 }
253 // this only happens if there is a directory named `spec.json` which would be very silly.
254 // but better output an error for it anyway :p
255 if info.IsDir() {
256 return "", false, fmt.Errorf("microVM image spec %q is a directory", spec)
257 }
258 return spec, true, nil
259}