package ci import ( "archive/tar" "bytes" "context" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" "io" "io/fs" "net/http" "net/url" "strings" "sync" "hearthforge" ) // vmContext is the embedded vm/ directory as a tar, the build context of // the default build VM image. var vmContext = sync.OnceValues(func() ([]byte, error) { sub, err := fs.Sub(hearthforge.VMFS, "vm") if err != nil { return nil, err } var buf bytes.Buffer tw := tar.NewWriter(&buf) if err := tw.AddFS(sub); err != nil { return nil, err } if err := tw.Close(); err != nil { return nil, err } return buf.Bytes(), nil }) // DefaultVMImage names the default build VM image after its sources, so an // upgrade that changes vm/ builds a new image instead of reusing a stale one. func DefaultVMImage() (string, error) { data, err := vmContext() if err != nil { return "", err } sum := sha256.Sum256(data) return "localhost/hearthforge-buildvm:" + hex.EncodeToString(sum[:])[:12], nil } // prepareVMImage returns the build VM image and makes sure the engine has // it. A missing CI_VM_IMAGE is pulled. The default image is built from the // embedded vm/ once per Hearthforge version. say receives progress for the // step log. func (r *Runner) prepareVMImage(ctx context.Context, say func(string)) (string, error) { if image := r.cfg.CIVMImage; image != "" { found, err := r.hasImage(ctx, image) if err != nil || found { return image, err } say("Pulling the build VM image " + image + "\n") return image, r.pullImage(ctx, image) } image, err := DefaultVMImage() if err != nil { return "", err } // Concurrent runs would each start the same slow build. A channel, not a // mutex, so a cancelled run stops waiting. select { case r.vmImageSlot <- struct{}{}: default: say("Waiting for another run to build the build VM image\n") select { case r.vmImageSlot <- struct{}{}: case <-ctx.Done(): return "", ctx.Err() } } defer func() { <-r.vmImageSlot }() found, err := r.hasImage(ctx, image) if err != nil || found { return image, err } say("Building the build VM image " + image + ". This happens once per Hearthforge version.\n") if err := r.buildVMImage(ctx, image, say); err != nil { return "", fmt.Errorf("cannot build the build VM image: %w", err) } say("Built the build VM image " + image + "\n") return image, nil } func (r *Runner) hasImage(ctx context.Context, image string) (bool, error) { resp, _, err := r.doJSON(ctx, http.MethodGet, "/images/"+image+"/json", nil) if err != nil { return false, err } return resp.StatusCode == http.StatusOK, nil } // buildVMImage builds the embedded vm/ with the engine's classic build API. // Docker and Podman both serve it without a BuildKit session. func (r *Runner) buildVMImage(ctx context.Context, image string, say func(string)) error { body, err := vmContext() if err != nil { return err } resp, err := r.do(ctx, http.MethodPost, "/build?dockerfile=Containerfile&rm=1&forcerm=1&t="+url.QueryEscape(image), bytes.NewReader(body), "application/x-tar") if err != nil { return err } defer discard(resp) if resp.StatusCode >= 300 { detail, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) return fmt.Errorf("HTTP %d %s", resp.StatusCode, strings.TrimSpace(string(detail))) } // A failed build still answers 200. The error arrives in the stream. dec := json.NewDecoder(resp.Body) for { var msg struct { Stream string `json:"stream"` Error string `json:"error"` } err := dec.Decode(&msg) if errors.Is(err, io.EOF) { return nil } if err != nil { return err } if msg.Error != "" { return errors.New(strings.TrimSpace(msg.Error)) } say(msg.Stream) } }