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