mesh/merge-gate head 661b83249c2a merged into trunk bb291b5ad8af — pass: builds gitea, ssh-client → ace, g14, novox, shanks; no bus step; every machine com…
mesh/repo-check head 661b83249c2a merged into trunk bb291b5ad8af — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
457 lines
15 KiB
Go
457 lines
15 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"git.novox.be/novox/mesh-sdk/go/provider"
|
|
|
|
"gitea/internal/forgeaccount"
|
|
"gitea/internal/forgehttp"
|
|
)
|
|
|
|
// ProvisionName is what gitea provides here, as its manifest says (novox/hq ADR 0400).
|
|
const ProvisionName = "forge-account"
|
|
|
|
// Module is gitea's name in the mesh.
|
|
const Module = "gitea"
|
|
|
|
// Runner runs a program; a test fakes it.
|
|
type Runner interface {
|
|
Run(ctx context.Context, name string, args ...string) ([]byte, error)
|
|
}
|
|
|
|
// KeySource is the machines' public keys as the bus keeps them (ssh-client's state `keys`): the whole current set,
|
|
// then each change. handle is told the key, the operation ("put" or "delete"), the value and whether it was there
|
|
// when the watch began.
|
|
type KeySource interface {
|
|
Watch(handle func(key, op string, value []byte, current bool)) error
|
|
}
|
|
|
|
// Config is what the service needs from its environment.
|
|
type Config struct {
|
|
// Node is the forge's machine.
|
|
Node string
|
|
// HostFile holds the forge machine's own mesh name (a fact of the machine), OriginsFile the forge's public
|
|
// origins, one per line.
|
|
HostFile, OriginsFile string
|
|
// Port is where the forge's sshd is published on the mesh.
|
|
Port int
|
|
Forge forgehttp.Client
|
|
Runner Runner
|
|
Log func(format string, a ...any)
|
|
}
|
|
|
|
// Service answers the consumers of the provision `forge-account` (novox/hq ADR 0399, ADR 0400; issue 955): for each
|
|
// machine whose ssh-client reported a public key it makes the machine's agent a forge account, registers the key
|
|
// for it, and serves where and as whom to reach the forge over ssh. A key is no secret: the offer has no credential.
|
|
type Service struct {
|
|
cfg Config
|
|
accounts *forgeaccount.Accounts
|
|
|
|
// OnKeys is told the node whose key changed, so its consumer is answered without waiting for the next pass.
|
|
OnKeys func(node string)
|
|
// Pause waits between asks of a watch the runtime does not answer yet.
|
|
Pause func(time.Duration)
|
|
|
|
mu sync.Mutex
|
|
keys map[string]machineKey
|
|
loaded bool // the machines' keys have been read: until then a node with none has not reported none
|
|
known string // the forge's host key line, as the machines write it into known_hosts
|
|
|
|
bound provider.Bus // the bus the entries are written to (set by Provider)
|
|
|
|
making sync.Mutex // one Make at a time: they register keys, and two at once would race each other
|
|
}
|
|
|
|
type machineKey struct {
|
|
Node, Account, Kind, Public string
|
|
}
|
|
|
|
// NewService is the service over its environment.
|
|
func NewService(cfg Config) *Service {
|
|
if cfg.Log == nil {
|
|
cfg.Log = func(f string, a ...any) { fmt.Fprintf(os.Stderr, "[gitea forge-account] "+f+"\n", a...) }
|
|
}
|
|
return &Service{cfg: cfg, keys: map[string]machineKey{}, Pause: time.Sleep,
|
|
accounts: &forgeaccount.Accounts{Forge: cfg.Forge, Log: cfg.Log}}
|
|
}
|
|
|
|
// Provider is the provider helper answering the consumers over bus. Every consumer is made again every ten minutes,
|
|
// which puts back an account, its team or a key changed by hand.
|
|
func (s *Service) Provider(bus provider.Bus) *provider.Provider {
|
|
vb := &valuesBus{Bus: bus, s: s, written: map[string][]byte{}}
|
|
s.bound = vb
|
|
return provider.New(provider.Options{
|
|
Node: s.cfg.Node, Module: Module,
|
|
Offers: map[string]provider.Offer{ProvisionName: {Credential: provider.NoCredential}},
|
|
Bus: vb,
|
|
Make: s.Make, Gone: s.Gone,
|
|
Every: 10 * time.Minute,
|
|
Logf: s.cfg.Log,
|
|
})
|
|
}
|
|
|
|
// errNoKey is a consumer whose machine has reported no key yet: nothing is made, and the client says it waits.
|
|
type errNoKey struct{ node string }
|
|
|
|
func (e errNoKey) Error() string {
|
|
return "no key from " + e.node + " yet: its ssh-client has not reported a machine key, so there is no account " +
|
|
"and no entry to make"
|
|
}
|
|
|
|
// Make brings one consumer's account, its team membership and its keys into being, as the machine reported them. It
|
|
// leaves what is as asked alone, so it is asked again freely.
|
|
func (s *Service) Make(ctx context.Context, c provider.Consumer) error {
|
|
s.making.Lock()
|
|
defer s.making.Unlock()
|
|
node := c.Entry.Consumer
|
|
user := forgeaccount.ForgeUser(node, "machine")
|
|
if user == "" {
|
|
return fmt.Errorf("%q is not a machine's name an account can be named for", node)
|
|
}
|
|
s.mu.Lock()
|
|
loaded := s.loaded
|
|
s.mu.Unlock()
|
|
if !loaded {
|
|
// After a restart the first pass can come before the keys arrive: nothing is removed or deleted on that.
|
|
return errors.New("the machines' keys are not read yet, so nothing is made, removed or deleted")
|
|
}
|
|
have := s.keysOf(node)
|
|
if len(have) == 0 {
|
|
// A key the machine no longer reports is no longer wanted (ADR 0400 §2): the mesh's keys go, the operator's
|
|
// stay, and the entry that served the machine goes with them.
|
|
if err := s.accounts.RemoveKeysTitled(ctx, user, forgeaccount.TitlePrefix+node+"."); err != nil {
|
|
return err
|
|
}
|
|
if s.bound != nil {
|
|
if err := s.bound.Delete(ctx, provider.BucketOf(Module), c.Entry.Key()); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return errNoKey{node}
|
|
}
|
|
if _, err := s.hostName(); err != nil {
|
|
return err
|
|
}
|
|
known, err := s.scan(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := s.accounts.EnsureAccount(ctx, user); err != nil {
|
|
return err
|
|
}
|
|
if err := s.accounts.EnsureInTeam(ctx, user); err != nil {
|
|
return err
|
|
}
|
|
var wanted []forgeaccount.WantedKey
|
|
for _, k := range have {
|
|
wanted = append(wanted, forgeaccount.WantedKey{Title: forgeaccount.KeyTitle(k.Node, k.Account), Key: k.Public})
|
|
}
|
|
if err := s.accounts.EnsureKeys(ctx, user, wanted); err != nil {
|
|
return err
|
|
}
|
|
s.mu.Lock()
|
|
s.known = known
|
|
s.mu.Unlock()
|
|
return nil
|
|
}
|
|
|
|
// Gone removes the keys the mesh registered for a machine that no longer binds the forge. The account stays: it may
|
|
// hold what the machine's agent wrote, and the keys of the operator's own that were added by hand are not touched
|
|
// (ADR 0399 §2).
|
|
func (s *Service) Gone(ctx context.Context, e provider.Entry) error {
|
|
s.making.Lock()
|
|
defer s.making.Unlock()
|
|
user := forgeaccount.ForgeUser(e.Consumer, "machine")
|
|
if user == "" {
|
|
return nil
|
|
}
|
|
return s.accounts.RemoveKeysTitled(ctx, user, forgeaccount.TitlePrefix+e.Consumer+".")
|
|
}
|
|
|
|
// KeyChanged takes in one machine key as the bus keeps it, and tells OnKeys its node.
|
|
func (s *Service) KeyChanged(key string, value []byte) {
|
|
if node := s.store(key, value); node != "" && s.OnKeys != nil {
|
|
s.OnKeys(node)
|
|
}
|
|
}
|
|
|
|
// KeyGone forgets one machine key, and tells OnKeys its node.
|
|
func (s *Service) KeyGone(key string) {
|
|
node, _, _ := strings.Cut(key, ".")
|
|
s.mu.Lock()
|
|
delete(s.keys, key)
|
|
s.mu.Unlock()
|
|
if s.OnKeys != nil {
|
|
s.OnKeys(node)
|
|
}
|
|
}
|
|
|
|
// store keeps a machine key that reads as one: its node and account the key's own, and a public half that is a
|
|
// public key line and nothing private. It answers the node, or "" for a value it did not keep (and drops what was
|
|
// kept under that key, since the key now says something else).
|
|
func (s *Service) store(key string, value []byte) string {
|
|
node, account, _ := strings.Cut(key, ".")
|
|
var k machineKey
|
|
var raw struct {
|
|
Node, Account, Kind, Public string
|
|
}
|
|
err := json.Unmarshal(value, &raw)
|
|
k = machineKey(raw)
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if err != nil || k.Node != node || k.Account != account || !publicKeyLine(k.Public) {
|
|
if _, had := s.keys[key]; had {
|
|
delete(s.keys, key)
|
|
}
|
|
s.cfg.Log("the key %s is not a machine's public key; it is not registered", key)
|
|
return ""
|
|
}
|
|
s.keys[key] = k
|
|
return node
|
|
}
|
|
|
|
func publicKeyLine(line string) bool {
|
|
f := strings.Fields(line)
|
|
return len(f) >= 2 && f[0] == "ssh-ed25519" && !strings.Contains(line, "PRIVATE") && !strings.ContainsAny(line, "\r\n")
|
|
}
|
|
|
|
// keysOf are the machine keys one node reported, by key.
|
|
func (s *Service) keysOf(node string) []machineKey {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
var names []string
|
|
for key, k := range s.keys {
|
|
if k.Node == node && k.Kind == "machine" && strings.HasPrefix(key, node+".") {
|
|
names = append(names, key)
|
|
}
|
|
}
|
|
sort.Strings(names)
|
|
out := make([]machineKey, 0, len(names))
|
|
for _, n := range names {
|
|
out = append(out, s.keys[n])
|
|
}
|
|
return out
|
|
}
|
|
|
|
// WatchKeys keeps the machines' keys as the bus holds them, asking again until the runtime answers (the channel to it
|
|
// opens as the bundle starts, and the state of ssh-client may arrive after this one). A change after the first
|
|
// values is told to OnKeys; the first values are not, since one pass answers them all.
|
|
func (s *Service) WatchKeys(ctx context.Context, src KeySource) error {
|
|
waits := []time.Duration{2 * time.Second, 5 * time.Second, 10 * time.Second, 30 * time.Second}
|
|
for attempt := 0; ; attempt++ {
|
|
err := src.Watch(func(key, op string, value []byte, current bool) {
|
|
switch {
|
|
case op != "put" && current:
|
|
case op != "put":
|
|
s.KeyGone(key)
|
|
case current:
|
|
s.store(key, value)
|
|
default:
|
|
s.KeyChanged(key, value)
|
|
}
|
|
})
|
|
if err == nil {
|
|
// The watch answers once the current values are all handled (stdio KeptState.Watch).
|
|
s.mu.Lock()
|
|
s.loaded = true
|
|
s.mu.Unlock()
|
|
return nil
|
|
}
|
|
pause := time.Minute
|
|
if attempt < len(waits) {
|
|
pause = waits[attempt]
|
|
}
|
|
s.cfg.Log("the machines' keys cannot be watched yet (%v); asking again in %s", err, pause)
|
|
if ctx.Err() != nil {
|
|
return ctx.Err()
|
|
}
|
|
s.Pause(pause)
|
|
}
|
|
}
|
|
|
|
// hostName is the forge machine's own mesh name, from the fact the mesh writes.
|
|
func (s *Service) hostName() (string, error) {
|
|
raw, err := os.ReadFile(s.cfg.HostFile)
|
|
if err != nil {
|
|
return "", fmt.Errorf("the forge machine's mesh name cannot be read (%s): %w", s.cfg.HostFile, err)
|
|
}
|
|
host := strings.TrimSpace(string(raw))
|
|
if host == "" || strings.ContainsAny(host, " \t\n[]:/") {
|
|
return "", fmt.Errorf("%s holds %q, which is not a machine's mesh name", s.cfg.HostFile, host)
|
|
}
|
|
return host, nil
|
|
}
|
|
|
|
// httpsBase is the forge's public https root with a trailing slash.
|
|
func (s *Service) httpsBase() (string, error) {
|
|
raw, err := os.ReadFile(s.cfg.OriginsFile)
|
|
if err != nil {
|
|
return "", fmt.Errorf("the forge's origins cannot be read (%s): %w", s.cfg.OriginsFile, err)
|
|
}
|
|
for _, line := range strings.Split(string(raw), "\n") {
|
|
line = strings.TrimRight(strings.TrimSpace(line), "/")
|
|
if strings.HasPrefix(line, "https://") {
|
|
return line + "/", nil
|
|
}
|
|
}
|
|
return "", fmt.Errorf("%s names no https origin", s.cfg.OriginsFile)
|
|
}
|
|
|
|
// scan reads the forge's ed25519 host key from its own sshd and writes it as the machines' known_hosts line.
|
|
func (s *Service) scan(ctx context.Context) (string, error) {
|
|
host, err := s.hostName()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
out, err := s.cfg.Runner.Run(ctx, "ssh-keyscan", "-t", "ed25519", "-p", fmt.Sprint(s.cfg.Port), "127.0.0.1")
|
|
if err != nil {
|
|
return "", fmt.Errorf("the forge's ssh host key cannot be read: %w", err)
|
|
}
|
|
return knownHostLine(string(out), host, s.cfg.Port)
|
|
}
|
|
|
|
// sshAlias is the forge's own name in ssh's configuration (issue 955, ADR 0409): the machines' ssh-client writes a
|
|
// Host of this name, so no other Host (the operator's own entry for the forge machine) is merged with it.
|
|
const sshAlias = "mesh-forge"
|
|
|
|
// knownHostLine is the ed25519 line of ssh-keyscan's output with its host written as the mesh name and port. It is
|
|
// the old value of `known_host`, kept for machines whose ssh-client has not moved to ssh_alias and host_key yet.
|
|
func knownHostLine(scanned, host string, port int) (string, error) {
|
|
for _, line := range strings.Split(scanned, "\n") {
|
|
f := strings.Fields(line)
|
|
if len(f) >= 3 && !strings.HasPrefix(f[0], "#") && f[1] == "ssh-ed25519" {
|
|
return fmt.Sprintf("[%s]:%d %s %s", host, port, f[1], f[2]), nil
|
|
}
|
|
}
|
|
return "", errors.New("the forge's sshd gave no ed25519 host key")
|
|
}
|
|
|
|
// valuesFor are the values served to one machine's consumer (ADR 0400 §3, §4).
|
|
func (s *Service) valuesFor(node string) (map[string]any, error) {
|
|
user := forgeaccount.ForgeUser(node, "machine")
|
|
host, err := s.hostName()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
https, err := s.httpsBase()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
s.mu.Lock()
|
|
known := s.known
|
|
s.mu.Unlock()
|
|
if user == "" || known == "" {
|
|
return nil, fmt.Errorf("nothing is known to serve %s yet", node)
|
|
}
|
|
hostKey := strings.Join(strings.Fields(known)[1:], " ")
|
|
return map[string]any{"user": user, "ssh_host": host, "ssh_port": s.cfg.Port, "known_host": known,
|
|
"ssh_alias": sshAlias, "host_key": hostKey,
|
|
// known_host and ssh_base keep their old values: a machine whose ssh-client has not moved yet renders its old
|
|
// templates from them. They are dropped in a later change once every machine uses ssh_alias and host_key (ADR 0409).
|
|
"ssh_base": fmt.Sprintf("ssh://git@%s:%d/", host, s.cfg.Port), "https_base": https}, nil
|
|
}
|
|
|
|
// valuesBus writes each consumer's entry with the values that differ per consumer or come from the machine's own
|
|
// facts. The provider helper's offer states what is the same for all; the helper has no place for the rest.
|
|
type valuesBus struct {
|
|
provider.Bus
|
|
s *Service
|
|
|
|
mu sync.Mutex
|
|
written map[string][]byte
|
|
}
|
|
|
|
func (b *valuesBus) Put(ctx context.Context, bucket, key string, value []byte) error {
|
|
if bucket != provider.BucketOf(Module) {
|
|
return b.Bus.Put(ctx, bucket, key, value)
|
|
}
|
|
var e provider.Entry
|
|
if err := json.Unmarshal(value, &e); err != nil {
|
|
return err
|
|
}
|
|
if e.Refused == "" {
|
|
extra, err := b.s.valuesFor(e.Consumer)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if e.Values == nil {
|
|
e.Values = map[string]any{}
|
|
}
|
|
for k, v := range extra {
|
|
e.Values[k] = v
|
|
}
|
|
}
|
|
out, err := json.Marshal(e)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
b.mu.Lock()
|
|
same := bytes.Equal(b.written[key], out)
|
|
b.mu.Unlock()
|
|
if same {
|
|
return nil // the helper writes what it composed on every pass; this one stands
|
|
}
|
|
if err := b.Bus.Put(ctx, bucket, key, out); err != nil {
|
|
return err
|
|
}
|
|
b.mu.Lock()
|
|
b.written[key] = out
|
|
b.mu.Unlock()
|
|
return nil
|
|
}
|
|
|
|
func (b *valuesBus) Delete(ctx context.Context, bucket, key string) error {
|
|
b.mu.Lock()
|
|
delete(b.written, key)
|
|
b.mu.Unlock()
|
|
return b.Bus.Delete(ctx, bucket, key)
|
|
}
|
|
|
|
// Backoff of KeepRunning: the first pause and the longest.
|
|
const (
|
|
restartFirst = 5 * time.Second
|
|
restartLast = 2 * time.Minute
|
|
)
|
|
|
|
// KeepRunning runs the SDK provider and starts it again whenever it ends, however it ended (a watch or a read the
|
|
// runtime refuses, because a grant has not reached it yet, ends Run): a provider stopped for good answers no consumer
|
|
// until something restarts the process, and nothing does when its bundle is unchanged (issue 955). It says so once
|
|
// per spell of the same error, pauses 5 s doubling to 2 min between starts, and returns only when ctx ends.
|
|
func (s *Service) KeepRunning(ctx context.Context, run func(context.Context) error) {
|
|
pause := restartFirst
|
|
lastSaid := ""
|
|
for ctx.Err() == nil {
|
|
started := time.Now()
|
|
err := run(ctx)
|
|
if ctx.Err() != nil {
|
|
return
|
|
}
|
|
if time.Since(started) >= restartLast {
|
|
// It served a while: a new spell.
|
|
pause, lastSaid = restartFirst, ""
|
|
}
|
|
why := "it returned without an error"
|
|
if err != nil {
|
|
why = err.Error()
|
|
}
|
|
if why != lastSaid {
|
|
s.cfg.Log("the provider stopped: %s; starting it again every %s up to %s until it stays up", why, pause, restartLast)
|
|
lastSaid = why
|
|
}
|
|
s.Pause(pause)
|
|
if pause *= 2; pause > restartLast {
|
|
pause = restartLast
|
|
}
|
|
}
|
|
}
|