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.
162 lines
6.3 KiB
Go
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...)
|
|
}
|
|
}
|