vmimage.go
⎇
Raw
1package ci
2
3import (
4 "archive/tar"
5 "bytes"
6 "context"
7 "crypto/sha256"
8 "encoding/hex"
9 "encoding/json"
10 "errors"
11 "fmt"
12 "io"
13 "io/fs"
14 "net/http"
15 "net/url"
16 "strings"
17 "sync"
18
19 "hearthforge"
20)
21
22// vmContext is the embedded vm/ directory as a tar, the build context of
23// the default build VM image.
24var vmContext = sync.OnceValues(func() ([]byte, error) {
25 sub, err := fs.Sub(hearthforge.VMFS, "vm")
26 if err != nil {
27 return nil, err
28 }
29 var buf bytes.Buffer
30 tw := tar.NewWriter(&buf)
31 if err := tw.AddFS(sub); err != nil {
32 return nil, err
33 }
34 if err := tw.Close(); err != nil {
35 return nil, err
36 }
37 return buf.Bytes(), nil
38})
39
40// DefaultVMImage names the default build VM image after its sources, so an
41// upgrade that changes vm/ builds a new image instead of reusing a stale one.
42func DefaultVMImage() (string, error) {
43 data, err := vmContext()
44 if err != nil {
45 return "", err
46 }
47 sum := sha256.Sum256(data)
48 return "localhost/hearthforge-buildvm:" + hex.EncodeToString(sum[:])[:12], nil
49}
50
51// prepareVMImage returns the build VM image and makes sure the engine has
52// it. A missing CI_VM_IMAGE is pulled. The default image is built from the
53// embedded vm/ once per Hearthforge version. say receives progress for the
54// step log.
55func (r *Runner) prepareVMImage(ctx context.Context, say func(string)) (string, error) {
56 if image := r.cfg.CIVMImage; image != "" {
57 found, err := r.hasImage(ctx, image)
58 if err != nil || found {
59 return image, err
60 }
61 say("Pulling the build VM image " + image + "\n")
62 return image, r.pullImage(ctx, image)
63 }
64
65 image, err := DefaultVMImage()
66 if err != nil {
67 return "", err
68 }
69 // Concurrent runs would each start the same slow build. A channel, not a
70 // mutex, so a cancelled run stops waiting.
71 select {
72 case r.vmImageSlot <- struct{}{}:
73 default:
74 say("Waiting for another run to build the build VM image\n")
75 select {
76 case r.vmImageSlot <- struct{}{}:
77 case <-ctx.Done():
78 return "", ctx.Err()
79 }
80 }
81 defer func() { <-r.vmImageSlot }()
82 found, err := r.hasImage(ctx, image)
83 if err != nil || found {
84 return image, err
85 }
86 say("Building the build VM image " + image + ". This happens once per Hearthforge version.\n")
87 if err := r.buildVMImage(ctx, image, say); err != nil {
88 return "", fmt.Errorf("cannot build the build VM image: %w", err)
89 }
90 say("Built the build VM image " + image + "\n")
91 return image, nil
92}
93
94func (r *Runner) hasImage(ctx context.Context, image string) (bool, error) {
95 resp, _, err := r.doJSON(ctx, http.MethodGet, "/images/"+image+"/json", nil)
96 if err != nil {
97 return false, err
98 }
99 return resp.StatusCode == http.StatusOK, nil
100}
101
102// buildVMImage builds the embedded vm/ with the engine's classic build API.
103// Docker and Podman both serve it without a BuildKit session.
104func (r *Runner) buildVMImage(ctx context.Context, image string, say func(string)) error {
105 body, err := vmContext()
106 if err != nil {
107 return err
108 }
109 resp, err := r.do(ctx, http.MethodPost,
110 "/build?dockerfile=Containerfile&rm=1&forcerm=1&t="+url.QueryEscape(image),
111 bytes.NewReader(body), "application/x-tar")
112 if err != nil {
113 return err
114 }
115 defer discard(resp)
116 if resp.StatusCode >= 300 {
117 detail, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
118 return fmt.Errorf("HTTP %d %s", resp.StatusCode, strings.TrimSpace(string(detail)))
119 }
120 // A failed build still answers 200. The error arrives in the stream.
121 dec := json.NewDecoder(resp.Body)
122 for {
123 var msg struct {
124 Stream string `json:"stream"`
125 Error string `json:"error"`
126 }
127 err := dec.Decode(&msg)
128 if errors.Is(err, io.EOF) {
129 return nil
130 }
131 if err != nil {
132 return err
133 }
134 if msg.Error != "" {
135 return errors.New(strings.TrimSpace(msg.Error))
136 }
137 say(msg.Stream)
138 }
139}
140