Skip to content
quarry Artifact [33fed583e0]
Login

Artifact [33fed583e0]

Artifact 33fed583e0146842fc513999913d4c2b7b827eeb6a3bc29721258cacc1f1871e:


package main

import (
	"context"
	"errors"
	"log"
	"net/url"
	"strings"
	"time"

	"craftdesign.group/quarry/internal/children"
	"craftdesign.group/quarry/internal/lease"
	"craftdesign.group/quarry/internal/ops"
)

// A repository's mirror to a Fossil server (RFC 0022, Fossil remote):
// the node holding the store's lease runs `zit push --once` to the
// remote with the stored login, and records what happened in words.
//
// The triggers and the one-run-at-a-time rule are the Git mirror's
// (mirrorRuns): after a fold, which is every way the store changes, and
// the retry tick. Unlike a Git push, a Fossil push carries everything
// (wiki, tickets, forum, every branch), so a kick never skips a run
// because trunk's tip is unchanged; the tick is what is selective.

// fossilMirrorTimeout bounds one run, for the reason gitMirrorTimeout
// does.
const fossilMirrorTimeout = 30 * time.Minute

// fossilMirrors is the Fossil mirror's run state.
type fossilMirrors struct {
	mirrorRuns
	// host, when set, refuses a remote whose host the forge may not
	// reach, checked again at each push because a name can come to
	// resolve elsewhere after it was accepted.
	host func(ctx context.Context, host string) error
}

// fossilMirrorKick runs name's mirror now, or once more after the run
// in flight. Never blocks the caller.
func (rg *registrar) fossilMirrorKick(name string) {
	if rg.odb == nil || !rg.jobs.store(name) {
		return
	}
	rg.fossilMirror.kick(name, rg.fossilMirrorRun)
}

// fossilMirrorRetry kicks the mirrors that have never pushed, and those
// whose last push failed and whose backoff has passed. One that has
// pushed and not failed since waits for the next fold.
func (rg *registrar) fossilMirrorRetry() {
	if rg.odb == nil {
		return
	}
	repos, err := rg.odb.FossilMirrorRepos()
	if err != nil {
		log.Printf("fossil mirror: list: %v", err)
		return
	}
	at := time.Now()
	for _, name := range repos {
		m, on, err := rg.odb.FossilMirrorOf(name)
		if err != nil || !on || !fossilMirrorDue(m.PushedAt, m.ErrAt, m.Fails, at) {
			continue
		}
		rg.fossilMirrorKick(name)
	}
}

// fossilMirrorDue says whether the retry tick runs a mirror: one that
// has never succeeded, or whose last attempt failed, once the backoff
// has passed.
func fossilMirrorDue(pushedAt, errAt string, fails int, at time.Time) bool {
	if pushedAt != "" && errAt == "" {
		return false
	}
	if errAt != "" {
		if failed, err := time.Parse(time.RFC3339, errAt); err == nil && at.Before(failed.Add(gitMirrorBackoff(fails))) {
			return false
		}
	}
	return true
}

// fossilPush is zitcli.Client.FossilPush, named so a run can be handed
// one.
type fossilPush func(ctx context.Context, remote, user string, env []string) (string, error)

// fossilMirrorRun is one pass for one repository: nothing when there is
// no mirror or it is paused (private, taken down); otherwise one push.
func (rg *registrar) fossilMirrorRun(name string, _ time.Time) {
	m, on, err := rg.odb.FossilMirrorOf(name)
	if err != nil || !on {
		return
	}
	// Paused, not failed, and read from the database closed on error,
	// for the reasons gitMirrorRun gives.
	if priv, err := rg.odb.PrivateRepos(); err != nil || priv[name] {
		return
	}
	if _, held, err := rg.odb.TakedownOf(name); err != nil || held {
		return
	}
	repo, ok := rg.deps.Repos.Get(name)
	if !ok || repo.Zit == nil || repo.Index == nil || repo.Guard == nil {
		return
	}
	var pushed string
	if tip, ok, err := repo.Index.TipOf("trunk"); err == nil && ok {
		pushed = tip.Tip
	}

	var why string
	var decided bool
	if err := repo.Guard(func(tok lease.Token) error {
		why, decided = rg.fossilMirrorPush(name, m.Remote, m.User, func(ctx context.Context, remote, user string, env []string) (string, error) {
			return repo.Zit.FossilPush(tok, ctx, remote, user, env)
		})
		// A push cut short by the lease going elsewhere says nothing
		// about the mirror: the next holder pushes without a backoff.
		if tok.Err() != nil {
			decided = false
		}
		return nil
	}); err != nil || !decided {
		return
	}
	if why == "" {
		if err := rg.odb.RecordFossilMirrorPush(name, m.Remote, m.User, m.Created, pushed); err != nil {
			log.Printf("fossil mirror: %s: record: %v", name, err)
		}
		return
	}
	if err := rg.odb.RecordFossilMirrorFailure(name, m.Remote, m.User, m.Created, why); err != nil {
		log.Printf("fossil mirror: %s: record: %v", name, err)
	}
}

// fossilMirrorPush reads the password for the length of one push and
// runs it. why is "" on success, else the failure in words; decided is
// false when the push was stopped from outside (the forge stopping),
// which says nothing about the mirror and records nothing.
//
// The password goes to zit in its environment only, never its
// arguments, and is not logged: what is logged is zit's own output,
// which never carries it.
func (rg *registrar) fossilMirrorPush(name, remote, user string, push fossilPush) (why string, decided bool) {
	ctx, cancel := context.WithTimeout(context.Background(), fossilMirrorTimeout)
	defer cancel()
	if check := rg.fossilMirror.host; check != nil {
		if u, err := url.Parse(remote); err == nil {
			if err := check(ctx, u.Hostname()); err != nil {
				log.Printf("fossil mirror: %s: %v", name, err)
				return "the forge may not push to that host: it is a private address", true
			}
		}
	}
	password, err := rg.odb.FossilMirrorPassword(name)
	if err != nil {
		log.Printf("fossil mirror: %s: password: %v", name, err)
		return "the saved password could not be read; stop mirroring and mirror again to give it again", true
	}
	out, err := push(ctx, remote, user, []string{"ZIT_PASSWORD=" + password})
	switch {
	case err == nil:
		return "", true
	case errors.Is(err, ops.ErrStopping), children.Default.IsStopping():
		return "", false
	case ctx.Err() != nil:
		return "the push did not finish within " + fossilMirrorTimeout.String(), true
	}
	log.Printf("fossil mirror: %s: %v: %s", name, err, lastLines(out, 6))
	return fossilMirrorWords(out), true
}

// fossilMirrorWords puts a refused push in words a repository admin can
// act on, from what zit said.
func fossilMirrorWords(out string) string {
	has := func(subs ...string) bool {
		for _, s := range subs {
			if strings.Contains(out, s) {
				return true
			}
		}
		return false
	}
	switch {
	case has("project code", "project-code"):
		return "the remote is not a copy of this repository: its project code differs"
	case has("login failed"):
		// A wrong user or password, and a server whose project code
		// differs (the login is signed with it), look the same to zit.
		return "the server refused the login: check the user and password, and that the remote is a copy of this repository (the same project code)"
	case has("not authorized", "not authorised"):
		return "the login is not allowed to push on that server: it needs the capability to check in"
	case has("cannot connect", "no such host", "connection refused", "timed out", "network is unreachable", "dial tcp"):
		return "the server could not be reached"
	case has("certificate", "x509"):
		return "the server's certificate was not accepted"
	}
	if l := strings.TrimRight(lastLines(out, 1), ". "); l != "" {
		return "the push failed: " + l
	}
	return "the push failed"
}