Files
mesh-catalog/modules/mesh-build-image/cmd/mesh-build-image/push.go
T
jschoubben 98c66fb1e4 Build modules: a failed command's reason says its error, not its last progress line (issue 1703)
buildah writes STEP lines on stdout and its error on stderr, so the last line of both hid every image build's cause.
mesh-build-image's reason is now stderr's last lines (600 bytes) with the exit status, and the journal gets both tails;
mesh-build-node keeps stderr's end and stdout's start apart; mesh-build-go's cause skips go build's '# package' line.
2026-10-26 15:21:01 +01:00

162 lines
6.3 KiB
Go

package main
// The trunk push (novox/hq ADR 0487 §5; issue 1703d): after the build has ended, from the module's own process and not
// from a Dockerfile step, the image is pushed to the registry under a tag of its own and the digest is read back from
// the registry, which is the only one that can say what it serves for the reference (issue 1742). The push and the
// read wait out a registry held still by the store's nightly collection (issue 457).
import (
"context"
"errors"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"syscall"
"time"
"git.novox.be/novox/mesh-sdk/go/forgekey"
)
// RegistryWait is how long a call waits for a registry that refuses connections, longer than the nightly collection
// holds it still (about a minute and a half); RegistryFirstPause is the first pause, doubling to RegistryMostPause.
const (
RegistryWait = 5 * time.Minute
RegistryFirstPause = time.Second
RegistryMostPause = 10 * time.Second
)
// registry is the registry's address, host:port, as the manifest bound it into RegistryFile.
func (m *Module) registry() (string, error) {
raw, err := os.ReadFile(m.RegistryFile)
if err != nil {
return "", fmt.Errorf("the registry's address cannot be read: %w", err)
}
addr := strings.TrimSpace(string(raw))
if addr == "" || strings.Contains(addr, "${") || strings.HasPrefix(addr, ":") || strings.HasSuffix(addr, ":") {
return "", fmt.Errorf("the registry's address in %s is %q, not host:port", m.RegistryFile, addr)
}
return addr, nil
}
func (m *Module) push(ctx context.Context, workspace, module, local string, res BuildResult, tag string) (any, error) {
addr, err := m.registry()
if err != nil {
return nil, err
}
repository := module + "/" + res.Artifact
dest := addr + "/" + repository + ":" + tag
digestFile := filepath.Join(m.Work, workspace, "pushed."+tag)
argv := append(m.storeFlags(), "push", "--tls-verify=false", "--digestfile", digestFile, local, "docker://"+dest)
var last forgekey.Ran
if err := m.waitForRegistry(ctx, addr, "buildah push "+dest, func() error {
last = m.Run(ctx, m.env(), "buildah", argv...)
if last.Status != 0 || last.Err != "" {
return errors.New(cause(last))
}
return nil
}); err != nil {
m.sayFailure("buildah push "+dest, last)
return nil, fmt.Errorf("pushing %s: %w", dest, err)
}
var served string
if err := m.waitForRegistry(ctx, addr, "reading "+dest, func() error {
var err error
served, err = m.registryDigest(ctx, addr, repository, tag)
return err
}); err != nil {
return nil, fmt.Errorf("pushed %s and cannot read back what the registry serves for it: %w", dest, err)
}
// A push that succeeded wrote the digest it made: a file missing, unreadable or empty is a fault, and the registry's
// digest is never answered unchecked.
raw, err := os.ReadFile(digestFile)
if err != nil {
return nil, fmt.Errorf("pushed %s, but the digest buildah pushed cannot be read from %s, so the registry's %s cannot be checked: %w", dest, digestFile, served, err)
}
pushed := strings.TrimSpace(string(raw))
if !digestOK.MatchString(pushed) {
return nil, fmt.Errorf("pushed %s, but the digest file %s holds %q, not a digest, so the registry's %s cannot be checked", dest, digestFile, pushed, served)
}
if pushed != served {
return nil, fmt.Errorf("pushed %s as %s, but the registry serves %s for it", dest, pushed, served)
}
res.Digest, res.Pushed = served, true
res.Reference = addr + "/" + repository + "@" + served
return res, nil
}
// registryDigest is the digest the registry answers for repository:tag (Docker-Content-Digest of a HEAD).
func (m *Module) registryDigest(ctx context.Context, addr, repository, tag string) (string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodHead, "http://"+addr+"/v2/"+repository+"/manifests/"+tag, nil)
if err != nil {
return "", err
}
req.Header.Set("Accept", strings.Join([]string{
"application/vnd.oci.image.manifest.v1+json", "application/vnd.oci.image.index.v1+json",
"application/vnd.docker.distribution.manifest.v2+json", "application/vnd.docker.distribution.manifest.list.v2+json"}, ", "))
client := m.HTTP
if client == nil {
client = &http.Client{Timeout: time.Minute}
}
resp, err := client.Do(req)
if err != nil {
return "", err
}
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("the registry answered %s for %s:%s", resp.Status, repository, tag)
}
d := resp.Header.Get("Docker-Content-Digest")
if !digestOK.MatchString(d) {
return "", fmt.Errorf("the registry named no digest for %s:%s (Docker-Content-Digest %q)", repository, tag, d)
}
return d, nil
}
// refused is whether an error is a connection refused: from a dial here, or as buildah said it in its output.
func refused(err error) bool {
return err != nil && (errors.Is(err, syscall.ECONNREFUSED) || strings.Contains(err.Error(), "connection refused"))
}
// waitForRegistry runs try, and while it fails because the registry at addr refuses connections, tries again with a
// growing pause until RegistryWait has passed (novox/hq issue 457). A refusal is the one error waited for: nothing
// was sent, so trying again does nothing twice. what names the call, for what the module says.
func (m *Module) waitForRegistry(ctx context.Context, addr, what string, try func() error) error {
err := try()
if !refused(err) {
return err
}
limit, pause := m.RegistryWait, m.RegistryPause
if limit == 0 {
limit = RegistryWait
}
if pause == 0 {
pause = RegistryFirstPause
}
started := time.Now()
m.say("%s: refused; waiting for the registry at %s for up to %s — the store's nightly collection holds it still for about a minute and a half (issue 457)", what, addr, limit)
for {
left := limit - time.Since(started)
if left <= 0 {
return fmt.Errorf("the registry at %s refused every connection for %s, longer than its nightly collection holds it still, so it is down, not paused: %w", addr, time.Since(started).Round(time.Millisecond), err)
}
select {
case <-ctx.Done():
return fmt.Errorf("stopped while waiting for the registry at %s: %w (last: %v)", addr, ctx.Err(), err)
case <-time.After(min(pause, left)):
}
pause = min(pause*2, RegistryMostPause)
if err = try(); !refused(err) {
m.say("%s: the registry at %s answers again after %s", what, addr, time.Since(started).Round(time.Millisecond))
return err
}
}
}
func (m *Module) say(format string, a ...any) {
if m.Say != nil {
m.Say(format, a...)
}
}