Files
Sisyphus/apps/sisyphusd/daemon.go
T
rvnminers-A-and-N 6c3b2ab2a0 feat: a coordinator can keep its stored data in an S3 bucket (#114)
A third place for a pool's stored data, beside the coordinator's disk
and Kubo: a bucket in any object store that speaks S3. Each block is an
object; pins stay on the node, and the credentials never leave it.
2026-10-09 03:21:47 -04:00

1339 lines
51 KiB
Go

package main
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"log/slog"
"net"
"net/http"
"net/netip"
"os"
"path/filepath"
"regexp"
goruntime "runtime"
"slices"
"sort"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/ipfs/go-cid"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
"github.com/sisyphus-network/Sisyphus/apps/sisyphusd/access"
"github.com/sisyphus-network/Sisyphus/apps/sisyphusd/api"
"github.com/sisyphus-network/Sisyphus/apps/sisyphusd/coordinator"
"github.com/sisyphus-network/Sisyphus/apps/sisyphusd/inference"
"github.com/sisyphus-network/Sisyphus/apps/sisyphusd/p2p"
"github.com/sisyphus-network/Sisyphus/apps/sisyphusd/planner"
"github.com/sisyphus-network/Sisyphus/apps/sisyphusd/tunnel"
"github.com/sisyphus-network/Sisyphus/apps/sisyphusd/worker"
"github.com/sisyphus-network/Sisyphus/packages/geo"
"github.com/sisyphus-network/Sisyphus/packages/hardware"
"github.com/sisyphus-network/Sisyphus/packages/identity"
"github.com/sisyphus-network/Sisyphus/packages/kubo"
"github.com/sisyphus-network/Sisyphus/packages/nodedb"
nodepb "github.com/sisyphus-network/Sisyphus/packages/protocol/sisyphus/node/v1"
pb "github.com/sisyphus-network/Sisyphus/packages/protocol/sisyphus/v1"
"github.com/sisyphus-network/Sisyphus/packages/runtime"
"github.com/sisyphus-network/Sisyphus/packages/s3"
"github.com/sisyphus-network/Sisyphus/packages/sealed"
"github.com/sisyphus-network/Sisyphus/packages/storage"
)
func runDaemon(ctx context.Context, args []string) error {
fs := flag.NewFlagSet("sisyphusd run", flag.ContinueOnError)
roles := fs.String("role", "coordinator,worker", "comma-separated roles this node runs: coordinator, worker")
listen := fs.String("listen", defaultAddr, "coordinator role: address to serve workers and clients on")
join := fs.String("coordinator", "", "worker-only node: host:port of its coordinator")
invitation := fs.String("join", "", "worker-only node: an invitation from the coordinator, needed the first time this node connects to it and ignored after that")
serve := fs.String("serve", "", "worker-only node: address to serve its cached data on to the other workers of its pool, so that they need not all fetch it from the coordinator")
advertise := fs.String("advertise", "", "worker-only node: the address other workers should use to reach --serve, if not the same")
modelsFrom := fs.String("models-from", "", "worker role: offer the language models of the Ollama at this address, such as http://127.0.0.1:11434, to the pool's jobs. Whoever may submit jobs can then use them, and this machine sees what they ask")
inferenceListen := fs.String("inference-listen", "", "coordinator: loopback address to offer the pool's language models on, as a service speaking OpenAI's dialect whose key is the node's API token; off if empty")
webListen := fs.String("web-listen", "", "loopback address to serve the local API on to web pages, as gRPC-Web and Connect, with every call needing the node's API token; off if empty")
webOrigins := fs.String("web-origin", "", "the origins of the web pages that may use --web-listen, separated by commas, such as http://localhost:5173; pages from anywhere else are refused")
apiListen := fs.String("api-listen", "", "loopback address to serve the local API on, for the desktop client on this machine (it expects 127.0.0.1:50051); off if empty")
name := fs.String("name", defaultName(), "a label for people to recognise this node by")
slots := fs.Int("slots", goruntime.NumCPU(), "worker role: how many tasks to run at once")
dataDir := fs.String("data-dir", defaultDataDir(), "directory for this node's stored data; nodes sharing a machine each need their own")
containers := fs.Bool("containers", false, "worker role: run container images for the pool's jobs, with Docker. This lets whoever may submit jobs to the pool run what they like on this machine")
maxMemory := fs.Uint64("offer-memory-mb", 0, "worker role: tell the pool this node has no more than this much memory, in mebibytes; 0 offers all it has")
maxGPUs := fs.Int("offer-gpus", -1, "worker role: tell the pool this node has no more than this many graphics cards; -1 offers all it has")
reciprocate := fs.Bool("work-for-trusted", true, "a node with both roles: also work for the nodes it trusts for compute, whenever they trust it back")
relayAt := fs.String("relay", "", "worker-only node: listen on this address, as host:port, for the other members' libp2p hosts and relay between them; the port must be open to them. A coordinator relays already")
discovery := fs.String("discovery", "on", "whether to look for other nodes, on this network and through the nodes already known, and let them find this one: on or off")
bootstrap := fs.String("bootstrap", "", "addresses of nodes to connect to at startup, besides those in the address book: comma-separated, each ending in /p2p/<node ID>")
locate := fs.Bool("locate-country", false, "if the node cannot tell which country it is in from its own addresses, ask ipapi.co, which thereby learns its address. Without this nothing is asked of anyone")
keepJobs := fs.Duration("keep-jobs", 30*24*time.Hour, "coordinator role: how long a finished job can still be asked after; 0 keeps them for good")
retain := fs.Duration("retain", 7*24*time.Hour, "coordinator role: how long a job's inputs and results are kept after it finishes")
gcInterval := fs.Duration("gc-interval", time.Hour, "coordinator role: how often to delete stored data nothing is keeping; 0 never does")
maxStore := fs.Uint64("max-store-bytes", 0, "coordinator role: refuse uploads once stored data uses this much disk; 0 means no limit")
maxCache := fs.Uint64("max-cache-bytes", 0, "worker-only node: evict the least recently used cached blobs once the cache uses this much disk; 0 means no limit")
s3Endpoint := fs.String("s3-endpoint", "", "coordinator: keep stored data in a bucket of the S3-style object store at this address, such as https://s3.wasabisys.com or http://127.0.0.1:9000, instead of on this machine's disk")
s3Bucket := fs.String("s3-bucket", "", "the bucket, which must exist")
s3Prefix := fs.String("s3-prefix", "", "put this before the name of every object, to share the bucket with other things")
s3Region := fs.String("s3-region", "", "the bucket's region, for stores that have them")
s3Credentials := fs.String("s3-credentials", "", "a file with the access key on its first line and the secret key on its second; they stay on this node")
useKubo := fs.Bool("kubo", false, "keep stored data in a Kubo (IPFS) daemon that this node starts and runs alongside itself, on a private network with the rest of its pool; needs the ipfs program installed, and for a worker, a coordinator that uses it too")
swarmPort := fs.Int("swarm-port", 0, "coordinator role with --kubo: TCP port to open so that members' Kubo daemons can connect to this node's directly, which is faster; 0 opens none, and they reach it through --listen")
syncCache := fs.Bool("sync-cache", false, "worker-only node: wait for the disk when caching a blob; slower, but the cache then survives a power cut without downloading again")
verbose := fs.Bool("v", false, "log per-task detail")
if err := fs.Parse(args); err != nil {
return err
}
if fs.NArg() > 0 {
return fmt.Errorf("unexpected argument %q", fs.Arg(0))
}
var isCoordinator, isWorker bool
for _, role := range strings.Split(*roles, ",") {
switch strings.TrimSpace(role) {
case "coordinator":
isCoordinator = true
case "worker":
isWorker = true
default:
return fmt.Errorf("unknown role %q", role)
}
}
if isCoordinator && *join != "" {
return errors.New("--coordinator is for worker-only nodes; a coordinator's own worker joins it automatically")
}
if !isCoordinator && *join == "" {
return errors.New("a worker-only node needs --coordinator host:port")
}
if *slots < 1 {
return errors.New("--slots must be at least 1")
}
if isCoordinator && *relayAt != "" {
return errors.New("--relay is for worker-only nodes; a coordinator relays already, on --listen")
}
if *discovery != "on" && *discovery != "off" {
return fmt.Errorf("--discovery is on or off, not %q", *discovery)
}
if host, _, err := net.SplitHostPort(*apiListen); *apiListen != "" && (err != nil || !net.ParseIP(host).IsLoopback()) {
return errors.New("--api-listen must be a loopback address such as 127.0.0.1:50051: the local API is for programs on this machine only")
}
if host, _, err := net.SplitHostPort(*inferenceListen); *inferenceListen != "" && (err != nil || !net.ParseIP(host).IsLoopback()) {
return errors.New("--inference-listen must be a loopback address such as 127.0.0.1:11435: the service is for programs on this machine only")
}
if *inferenceListen != "" && !isCoordinator {
return errors.New("--inference-listen is for a node that coordinates a pool: it is that pool's models it offers")
}
if host, _, err := net.SplitHostPort(*webListen); *webListen != "" && (err != nil || !net.ParseIP(host).IsLoopback()) {
return errors.New("--web-listen must be a loopback address such as 127.0.0.1:50052: the local API is for this machine only, its web pages included")
}
if *s3Endpoint != "" {
switch {
case !isCoordinator:
return errors.New("--s3-endpoint is for a node that coordinates a pool: it is the pool's stored data that is kept there")
case *useKubo:
return errors.New("--s3-endpoint and --kubo each say where stored data is kept: choose one")
case *s3Bucket == "":
return errors.New("--s3-endpoint needs --s3-bucket, the bucket to keep stored data in")
}
}
if isCoordinator && *invitation != "" {
return errors.New("--join is for worker-only nodes")
}
if isCoordinator && *serve != "" {
return errors.New("--serve is for worker-only nodes; a coordinator serves its data already")
}
if *advertise == "" {
*advertise = *serve
}
if host, _, err := net.SplitHostPort(*advertise); *serve != "" && (err != nil || net.ParseIP(host).IsUnspecified()) {
return errors.New("--serve needs an address other workers can connect to; if it listens on every interface, give that address with --advertise")
}
level := slog.LevelInfo
if *verbose {
level = slog.LevelDebug
}
log := slog.New(slog.NewTextHandler(stderr, &slog.HandlerOptions{Level: level}))
// A node can take in, split and combine any workload, since that needs
// nothing but itself. What it will run as a worker is another matter:
// container images only if its owner has said so.
workloads, runs := runtime.WithContainers(), runtime.Builtin()
if *containers {
if err := checkContainers(ctx); err != nil {
return fmt.Errorf("--containers: %w", err)
}
runs = workloads
}
if *modelsFrom != "" {
runs = runs.With(runtime.WithModels(*modelsFrom)...)
}
// A graph is a job of jobs, which a coordinator carries out itself: no
// worker runs one, so only what takes jobs in knows of it.
workloads = workloads.With(runtime.Graph{})
// The node's key lives beside its data and is created on first run.
ident, err := loadIdentity(*dataDir)
if err != nil {
return err
}
log.Info("node identity", "id", ident.ID())
ctx, cancel := context.WithCancel(ctx)
defer cancel()
// Each role reports once when it stops; the first to stop ends the node.
stopped := make(chan error, 2)
// local is what the desktop client on this machine is told and may do.
local := api.LocalConfig{NodeID: ident.ID(), Version: version, Workloads: workloads.Names()}
// coordinatorID is the ID of the node this one's worker answers to: the
// node itself, or for a worker-only node the coordinator it joined.
coordinatorID := ident.ID()
if !isCoordinator {
// A worker only ever talks to the coordinator it joined. The first
// time, an invitation says which node that is and gets this one
// admitted; after that the coordinator's ID is remembered.
known, err := loadKnown(*dataDir)
if err != nil {
return err
}
// An invitation works once, so it is only presented by a node that
// has not joined yet. That lets a worker be restarted with the very
// command it was first started with.
if known[*join] == "" && *invitation != "" {
if _, known, err = joinPool(ctx, *dataDir, ident, *join, *invitation); err != nil {
return err
}
}
if coordinatorID = known[*join]; coordinatorID == "" {
return fmt.Errorf("this node has not joined a coordinator at %s: start it once with --join and an invitation from that coordinator", *join)
}
}
creds := credentials.NewTLS(ident.ClientTLS(coordinatorID))
// With --kubo, Kubo runs as the same peer as this node, on a repository
// beside the node's other data, and is stopped when the node stops. A
// coordinator's starts the pool's private network; a worker's joins it.
var sidecar *kubo.Daemon
var swarm *poolSwarm
if *useKubo && isCoordinator {
// With no port of its own opened, a coordinator's Kubo listens on
// this machine only and members are brought to it through --listen.
swarm = &poolSwarm{keyFile: filepath.Join(*dataDir, "swarm.key"), port: *swarmPort}
key, err := swarmKey(swarm.keyFile)
if err != nil {
return err
}
network := &kubo.Swarm{Key: key, Port: *swarmPort, Loopback: *swarmPort == 0}
if sidecar, err = kubo.Start(ctx, kubo.Config{Repo: filepath.Join(*dataDir, "ipfs"), Identity: ident, Swarm: network}); err != nil {
return err
}
defer sidecar.Stop()
swarm.key, swarm.daemon = key, sidecar
}
if *useKubo && !isCoordinator {
var leave func()
if swarm, leave, err = joinSwarm(ctx, ident, filepath.Join(*dataDir, "ipfs"), *join, coordinatorID, creds, log); err != nil {
return err
}
defer leave()
sidecar = swarm.daemon
}
if *useKubo {
log.Info("kubo started", "repo", filepath.Join(*dataDir, "ipfs"))
}
// Every node keeps a blob store. A coordinator's is durable, because it
// is where a job's inputs and results live. A worker-only node's is a
// cache of what the pool holds, kept apart from any durable store. With
// Kubo, either kind keeps its blocks there. Deferred calls run last-in
// first-out, so the store closes only after everything using it has
// stopped.
var store *storage.Store
switch {
case *s3Endpoint != "":
bucket, err := openBucket(ctx, *s3Endpoint, *s3Bucket, *s3Prefix, *s3Region, *s3Credentials)
if err != nil {
return err
}
log.Info("stored data is kept in a bucket", "endpoint", *s3Endpoint, "bucket", *s3Bucket)
if store, err = storage.OpenBucket(bucket, filepath.Join(*dataDir, "s3-pins")); err != nil {
return fmt.Errorf("%w (nodes sharing a machine each need their own --data-dir)", err)
}
case isCoordinator && *useKubo:
store, err = storage.OpenKubo(sidecar, filepath.Join(*dataDir, "kubo-pins"))
case isCoordinator:
store, err = storage.OpenLocal(filepath.Join(*dataDir, "blobs"))
case *useKubo:
store = storage.OpenKuboCache(sidecar)
default:
store, err = storage.OpenCache(filepath.Join(*dataDir, "cache"), *syncCache)
}
if err != nil {
return fmt.Errorf("%w (nodes sharing a machine each need their own --data-dir)", err)
}
defer store.Close()
// A node that is its own coordinator reads and writes the one store
// directly; a worker-only node fetches into its cache.
var blobs runtime.Blobs = store
// What a node must not forget is kept in its database: the addresses
// it starts from and, on a coordinator, who has been admitted, the
// invitations still out, and its jobs, so that a restart picks up the
// unfinished ones where they were.
db, err := nodedb.Open(filepath.Join(*dataDir, "node.db"))
if err != nil {
return err
}
defer db.Close()
// host is the node's libp2p host, started below according to its role.
var host *p2p.Host
// trustChanged hears of each node the coordinator admits or removes, and
// worksFor says whether this node is working for another.
trustChanged := new(notices)
// planningPool is the pool the node's planner computes on, if it
// coordinates one.
var planningPool planner.Pool
// thinkingPool is the same pool as its models are asked of.
var thinkingPool inference.Pool
// offered says whether any worker now connected runs a workload. The
// planner is told only of those, so that it asks for nothing the pool
// would leave waiting. It is set where there is a pool to plan for.
var offered func(workload string) bool
worksFor := func(string) bool { return false }
// takes is the other side of trust: the nodes this one will take tasks
// from, whatever else it does.
takes, err := openWilling(db)
if err != nil {
return err
}
if isCoordinator {
admitted, err := access.Open(db, ident.ID())
if err == nil {
// Earlier versions kept the list in a file of its own.
err = admitted.Import(filepath.Join(*dataDir, "access.json"))
}
if err != nil {
return err
}
admitted.OnChange(trustChanged.tell)
// And a node that ran the Rust daemon before this one keeps what
// that one knew.
if err := importRustState(*dataDir, db, admitted, takes, log); err != nil {
return err
}
coord := coordinator.New(coordinator.Config{
ID: ident.ID(), Workloads: workloads, Store: store, Journal: db, Retain: *retain, KeepJobs: *keepJobs, Log: log,
})
defer coord.Close()
unfinished, err := coord.Recover()
if err == nil {
_, err = coord.Prune(time.Now())
}
if err == nil {
// A pin a job held open is released when the job ends. One whose
// job is not among the unfinished has nobody left to release it:
// let it lapse instead.
err = store.ExpireOpenPins(func(owner string) bool {
return coordinator.OwnsPin(owner) && !coord.Holds(owner)
}, time.Now().Add(*retain))
}
if err != nil {
return err
}
if unfinished > 0 {
log.Info("took up the jobs left unfinished", "jobs", unfinished)
}
// The node listens on one port for two kinds of caller: its gRPC
// clients and workers, and the libp2p hosts of its pool's members,
// for which it relays so that two of them with no port open can
// still reach each other. The nodes whose work this one takes are
// let in too: it is in their pools, if not they in its own.
host, err = p2p.New(p2p.Config{Identity: ident, Listen: *listen, Relay: true, Discover: *discovery == "on", Log: log, Allow: func(id string) bool {
_, admitted := admitted.Role(id)
return admitted || slices.Contains(takes.List(), id)
}})
if err != nil {
return err
}
defer host.Close()
lis, _ := host.TLSListener() // fails only if asked for twice
config := api.Config{Identity: ident, Access: admitted, Coordinator: coord, Store: store, MaxStoreBytes: *maxStore, WorkFor: takes}
if swarm != nil {
config.Swarm = swarm
coord.AnnounceSwarm(swarm.Fingerprint())
}
srv := api.NewServer(config)
local.Pool = api.NewPoolAdmin(config)
local.Jobs = coord
local.Store, local.Files, local.MaxStoreBytes = store, db, *maxStore
local.SealingKey = func() (sealed.Key, error) { return sealingKey(filepath.Join(*dataDir, "private.key")) }
planningPool, thinkingPool = coord, coord
offered = func(workload string) bool {
return slices.ContainsFunc(coord.Nodes(), func(node *pb.NodeInfo) bool {
return slices.Contains(node.GetCapabilities().GetWorkloads(), workload)
})
}
local.Peers = func() []*nodepb.Peer { return poolPeers(ident.ID(), coord.Nodes(), admitted.Members()) }
// Workers hold streams open indefinitely, so a graceful stop would
// never finish. The coordinator is closed first, so that it knows
// the workers it is about to lose are no fault of their tasks.
defer func() {
coord.Close()
srv.Stop()
}()
log.Info("coordinator listening", "addr", lis.Addr().String(), "name", *name)
go func() { stopped <- srv.Serve(lis) }()
// The node's own worker connects to it as any other would, and
// expects to find the node itself at the other end.
*join = loopback(lis.Addr())
if *gcInterval > 0 {
collected := make(chan struct{})
// The store must outlive the collector.
defer func() {
cancel()
<-collected
}()
go func() {
defer close(collected)
collectPeriodically(ctx, store, coord.Prune, *gcInterval, log)
}()
}
} else {
remote, err := worker.DialBlobs(*join, creds, store, *maxCache)
if err != nil {
return err
}
defer remote.Close()
if swarm != nil {
remote.OnFallback = warnOfFallback(log)
}
// Blobs are fetched from fellow workers that have them, where the
// coordinator knows of any, and served to fellow workers if asked.
remote.PeerCredentials = func(nodeID string) credentials.TransportCredentials {
return credentials.NewTLS(ident.ClientTLS(nodeID))
}
blobs = remote
members := worker.NewMembers(remote.Conn())
peers := api.NewPeerServer(ident, store, members.IsMember)
defer peers.Stop()
if *serve != "" {
lis, err := net.Listen("tcp", *serve)
if err != nil {
return err
}
log.Info("serving cached data to fellow workers", "addr", lis.Addr().String(), "advertised", *advertise)
go peers.Serve(lis)
}
// Whether or not it has a port open, the node can be reached by its
// fellow workers through the coordinator, which relays for the pool,
// and reaches them the same way. Two that can connect directly then
// do. Only the coordinator and the pool's members are let in.
// One that has a port open for the purpose relays as the coordinator
// does, and every worker keeps a place on each member that relays.
host, err = p2p.New(p2p.Config{Identity: ident, Listen: *relayAt, Relay: *relayAt != "", Via: relayAddrs(*join, coordinatorID), MoreRelays: members.Relays, Discover: *discovery == "on", Log: log, Allow: func(id string) bool {
if id == coordinatorID || slices.Contains(takes.List(), id) {
return true
}
asking, cancel := context.WithTimeout(ctx, memberCheck)
defer cancel()
member, _ := members.IsMember(asking, id)
return member
}})
if err != nil {
return err
}
defer host.Close()
go peers.Serve(host.Listen(blobProtocol))
remote.PeerDialer = func(ctx context.Context, nodeID string) (net.Conn, error) {
return host.Dial(ctx, nodeID, blobProtocol)
}
remote.OnPeerFetch = func(c cid.Cid, holder *pb.BlobHolder) {
log.Debug("fetched a blob from a fellow worker", "cid", c.String(), "node", holder.GetNodeId(), "at", holder.GetAddress(), "route", routeTo(host.Peers(), holder.GetNodeId()))
}
if *advertise == "" {
// With no address of its own to give, the node is asked for by
// name.
*advertise = worker.ViaP2P
}
}
if isWorker {
w := &worker.Worker{
Name: *name, Coordinator: *join, Credentials: creds,
Slots: *slots, Workloads: runs, Blobs: blobs, Log: log,
ServeAddress: *advertise,
// What the machine has, as far as its owner offers it.
Hardware: hardware.Detect(ctx).Offer(*maxMemory<<20, *maxGPUs),
}
if *reciprocate {
// The node works for each node it has said it will take tasks
// from, whenever that node will have it. All of that shares the
// node's slots with the work it was started to do.
local.WorkFor = takes
w.Limit, w.Pool = worker.NewSlots(*slots), coordinatorID
guests := &guestWork{ident: ident, dataDir: *dataDir, template: w, kubo: *useKubo, syncCache: *syncCache, maxCache: *maxCache}
mutual := newReciprocity(&reciprocity{
// The node it already works for, being its coordinator, is
// not one to work for a second time.
trusted: func() []string {
return slices.DeleteFunc(takes.List(), func(id string) bool { return id == coordinatorID })
},
addresses: func(id string) []string { return whereToAsk(host.Peers(), id) },
ask: func(ctx context.Context, id, addr string) bool { return trustsThisNode(ctx, ident, id, addr) },
work: guests.work,
every: reciprocityCheck, most: maxWorkedFor, log: log,
})
worksFor = mutual.workingFor
local.Join = joinFromDesktop(*dataDir, ident, host.Connect, db, takes, mutual.nudge)
// A change of trust at either end is acted on at once: this node
// looks again when its own changes, and tells the node concerned,
// which looks again when it hears.
var words sync.WaitGroup
takes.onChange(mutual.nudge)
trustChanged.listen(func(id string) {
mutual.nudge()
words.Add(1)
go func() {
defer words.Done()
sendWord(ctx, host, id)
}()
})
hearing := host.Listen(trustProtocol)
go hearWord(hearing, mutual.nudge)
working := make(chan struct{})
defer func() {
cancel()
hearing.Close()
takes.onChange(nil)
trustChanged.listen(nil)
words.Wait()
<-working
}()
go func() {
defer close(working)
mutual.run(ctx)
}()
}
if *relayAt != "" {
w.RelayAddresses = host.Addrs()
w.Relayed = host.Carried
log.Info("relaying between the pool's members", "addrs", w.RelayAddresses)
}
if swarm != nil && !isCoordinator {
// Changing key restarts Kubo, which takes seconds, so it is
// done beside the worker's own work and finished, or given up,
// before Kubo is stopped.
var following sync.WaitGroup
defer following.Wait()
w.OnSwarm = func(stated string) {
following.Add(1)
go func() {
defer following.Done()
swarm.adopt(ctx, *join, creds, stated, log)
}()
}
}
if !isCoordinator {
// A worker's pool, as far as it knows, is its coordinator.
coordinatorAddr := *join
local.Peers = func() []*nodepb.Peer {
state := nodepb.PeerConnectionState_PEER_CONNECTION_STATE_DISCONNECTED
if w.Connected() {
state = nodepb.PeerConnectionState_PEER_CONNECTION_STATE_CONNECTED
}
return []*nodepb.Peer{{PeerId: coordinatorID, ConnectionState: state, KnownAddresses: multiaddrs(coordinatorAddr), TakesWork: true, ThisNodeWorksFor: w.Connected()}}
}
}
done := make(chan struct{})
// Tasks must finish before the store they use closes.
defer func() {
cancel()
<-done
}()
go func() {
defer close(done)
stopped <- w.Run(ctx)
}()
}
// The node's planner is there whatever its role, so that its model can
// be set and its conversations read; it has something to compute on
// only where the node coordinates a pool.
local.Assistant = &assistant{store: db, pool: planningPool, workloads: workloads, offered: offered}
// Whatever its role, the node is connected to the nodes in its address
// book and those named on the command line, and through them finds
// others. That goes on beside everything else and ends with the node.
book, err := db.BootstrapPeers()
if err != nil {
return err
}
looking, stopLooking := context.WithCancel(ctx)
var searches sync.WaitGroup
defer func() {
stopLooking()
searches.Wait()
}()
local.Connect, local.Listen, local.Book = host.Connect, host.Addrs, db
local.Bootstrap = func(ctx context.Context, addresses []string) {
if answered := host.Bootstrap(ctx, addresses); len(addresses) > 0 {
log.Info("connected to the nodes this one starts from", "answered", answered, "of", len(addresses))
}
}
searches.Add(1)
go func() {
defer searches.Done()
local.Bootstrap(looking, startingPoints(book, *bootstrap))
}()
// The desktop client is shown the node's pool and, beside it, the nodes
// its host has found.
pool := local.Peers
local.Peers = func() []*nodepb.Peer {
return withTrust(withFound(pool(), host.Peers()), takes.List(), worksFor)
}
// The node places itself by the addresses it is reached at, from a
// table it carries. Only if told to does it ask anyone.
var asked atomic.Value
if *locate {
searches.Add(1)
go func() {
defer searches.Done()
asked.Store(lookUpCountry(looking))
}()
}
local.Country = func() string {
if code := placed(host.Addrs()); code != "" {
return code
}
code, _ := asked.Load().(string)
return code
}
if *inferenceListen != "" {
lis, err := net.Listen("tcp", *inferenceListen)
if err != nil {
return err
}
token, err := apiToken(filepath.Join(*dataDir, "api.token"))
if err != nil {
lis.Close()
return err
}
models := &http.Server{Handler: inference.Handler(thinkingPool, token)}
defer models.Close()
log.Info("the pool's models are offered", "addr", lis.Addr().String())
go models.Serve(lis)
}
if *apiListen != "" {
lis, err := net.Listen("tcp", *apiListen)
if err != nil {
return err
}
if local.Token, err = apiToken(filepath.Join(*dataDir, "api.token")); err != nil {
lis.Close()
return err
}
desktop := api.NewLocalServer(local)
defer desktop.Stop()
log.Info("local API listening", "addr", lis.Addr().String())
go desktop.Serve(lis)
}
if *webListen != "" {
lis, err := net.Listen("tcp", *webListen)
if err != nil {
return err
}
if local.Token, err = apiToken(filepath.Join(*dataDir, "api.token")); err != nil {
lis.Close()
return err
}
var origins []string
for _, origin := range strings.Split(*webOrigins, ",") {
if origin = strings.TrimSpace(origin); origin != "" {
origins = append(origins, strings.TrimRight(origin, "/"))
}
}
pages := &http.Server{Handler: api.NewWebHandler(local, origins)}
defer pages.Close()
log.Info("local API listening for web pages", "addr", lis.Addr().String(), "origins", origins)
go pages.Serve(lis)
}
select {
case err := <-stopped:
return err
case <-ctx.Done():
log.Info("shutting down")
return nil
}
}
// reciprocityCheck is how often a node looks over the nodes it trusts and
// asks those it does not yet work for whether they trust it, in case word
// of a change did not reach it, and
// maxWorkedFor how many it will work for at once.
var reciprocityCheck = time.Minute
const maxWorkedFor = 16
// routeTo says how this node's libp2p host is connected to another: directly,
// through a relay, or not at all. A direct connection beside a relayed one
// is what counts, since it is the one used.
func routeTo(found []p2p.Peer, id string) string {
route := "none"
for _, peer := range found {
for _, conn := range peer.Conns {
switch {
case peer.ID != id:
case !strings.Contains(conn, "/p2p-circuit"):
route = "direct"
case route == "none":
route = "relayed"
}
}
}
return route
}
// checkContainers is how a node finds out whether it can run containers.
var checkContainers = runtime.CheckContainers
// startingPoints lists the addresses a node connects to at startup: its
// address book, then those given on the command line.
func startingPoints(book []nodedb.BootstrapPeer, flag string) []string {
var addresses []string
for _, entry := range book {
addresses = append(addresses, entry.Address)
}
for _, address := range strings.Split(flag, ",") {
if address = strings.TrimSpace(address); address != "" {
addresses = append(addresses, address)
}
}
return addresses
}
// withFound adds to a node's pool, as the local API lists it, the nodes its
// libp2p host knows of. A node in both is listed once, with the addresses
// the host knows for it, and as connected if either says so. One the host
// alone knows is no part of the pool and so is not trusted for compute.
func withFound(pool []*nodepb.Peer, found []p2p.Peer) []*nodepb.Peer {
listed := make(map[string]*nodepb.Peer, len(pool))
for _, member := range pool {
listed[member.GetPeerId()] = member
}
for _, f := range found {
entry := listed[f.ID]
if entry == nil {
entry = &nodepb.Peer{PeerId: f.ID, ConnectionState: nodepb.PeerConnectionState_PEER_CONNECTION_STATE_DISCONNECTED}
listed[f.ID] = entry
pool = append(pool, entry)
}
for _, address := range f.Addrs {
if !slices.Contains(entry.KnownAddresses, address) {
entry.KnownAddresses = append(entry.KnownAddresses, address)
}
}
if f.Connected() {
entry.ConnectionState = nodepb.PeerConnectionState_PEER_CONNECTION_STATE_CONNECTED
}
// Where it is connected from says most about where it is.
entry.CountryCode = placed(append(slices.Clone(f.Conns), f.Addrs...))
}
sort.Slice(pool, func(a, b int) bool { return pool[a].GetPeerId() < pool[b].GetPeerId() })
return pool
}
// withTrust fills in, for each node the local API lists, the side of trust
// that is this node's willingness to take its tasks, and whether it is
// doing so now. A node this one will work for is listed even if nothing
// else is known of it. A node is trusted for compute if either side holds.
func withTrust(peers []*nodepb.Peer, takes []string, worksFor func(id string) bool) []*nodepb.Peer {
listed := make(map[string]*nodepb.Peer, len(peers))
for _, peer := range peers {
listed[peer.GetPeerId()] = peer
}
for _, id := range takes {
if listed[id] == nil {
listed[id] = &nodepb.Peer{PeerId: id, ConnectionState: nodepb.PeerConnectionState_PEER_CONNECTION_STATE_DISCONNECTED}
peers = append(peers, listed[id])
}
listed[id].TakesWork = true
}
for _, peer := range peers {
peer.ThisNodeWorksFor = peer.ThisNodeWorksFor || worksFor(peer.GetPeerId())
peer.TrustedForCompute = peer.GetGivesWork() || peer.GetTakesWork()
}
sort.Slice(peers, func(a, b int) bool { return peers[a].GetPeerId() < peers[b].GetPeerId() })
return peers
}
// openBucket opens the bucket a node keeps its stored data in, with the
// credentials in a file: the access key on the first line and the secret
// key on the second.
func openBucket(ctx context.Context, endpoint, bucket, prefix, region, credentialsFile string) (*s3.Client, error) {
cfg := s3.Config{Endpoint: endpoint, Bucket: bucket, Prefix: prefix, Region: region}
if credentialsFile != "" {
keys, err := os.ReadFile(credentialsFile)
if err != nil {
return nil, fmt.Errorf("read the bucket's credentials: %w", err)
}
lines := strings.Fields(string(keys))
if len(lines) != 2 {
return nil, fmt.Errorf("%s should hold the access key on its first line and the secret key on its second", credentialsFile)
}
cfg.AccessKey, cfg.SecretKey = lines[0], lines[1]
}
return s3.Open(ctx, cfg)
}
// countryOf says which country an address is registered in. It is the
// table the daemon carries; tests put another here.
var countryOf = geo.Country
// placed returns the country of the first of some multiaddresses that has
// a place in the world, or nothing if none has.
func placed(multiaddrs []string) string {
for _, multiaddr := range multiaddrs {
parts := strings.Split(multiaddr, "/")
if len(parts) < 3 || (parts[1] != "ip4" && parts[1] != "ip6") {
continue
}
addr, err := netip.ParseAddr(parts[2])
if err != nil {
continue
}
if code := countryOf(addr); code != "" {
return code
}
}
return ""
}
// countryURL is the service asked which country this node's address is in.
var countryURL = "https://ipapi.co/json/"
var countryCode = regexp.MustCompile(`^[A-Za-z]{2}$`)
// lookUpCountry asks an outside service which country the address this node
// reaches it from is in, and returns its two-letter code, or nothing if the
// service does not give one. The service learns the address by being asked.
func lookUpCountry(ctx context.Context) string {
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, countryURL, nil) // the address is ours and well formed
res, err := http.DefaultClient.Do(req)
if err != nil {
return ""
}
defer res.Body.Close()
var answer struct {
CountryCode string `json:"country_code"`
}
json.NewDecoder(io.LimitReader(res.Body, 64<<10)).Decode(&answer)
if !countryCode.MatchString(answer.CountryCode) {
return ""
}
return strings.ToUpper(answer.CountryCode)
}
// blobProtocol names the streams on which one worker's libp2p host asks
// another's for blobs. What runs over them is the same gRPC, with the same
// TLS, that a worker with a port open serves there.
const blobProtocol = "/sisyphus/blob/1"
// memberCheck is how long a worker waits for its coordinator to say whether
// a node that is connecting is a member of the pool.
const memberCheck = 5 * time.Second
// relayAddrs gives a coordinator's address as that of a libp2p relay.
func relayAddrs(hostport, id string) []string {
var addrs []string
for _, addr := range multiaddrs(hostport) {
addrs = append(addrs, addr+"/p2p/"+id)
}
return addrs
}
// poolPeers describes the nodes admitted to a coordinator's pool, in order
// of ID, for the local API: whether each is connected, and whether it is
// trusted to run tasks.
func poolPeers(self string, connected []*pb.NodeInfo, members []access.Member) []*nodepb.Peer {
online := make(map[string]bool, len(connected))
for _, node := range connected {
online[node.GetNodeId()] = true
}
peers := make([]*nodepb.Peer, 0, len(members))
for _, member := range members {
state := nodepb.PeerConnectionState_PEER_CONNECTION_STATE_DISCONNECTED
if online[member.ID] {
state = nodepb.PeerConnectionState_PEER_CONNECTION_STATE_CONNECTED
}
peers = append(peers, &nodepb.Peer{
PeerId: member.ID, ConnectionState: state, GivesWork: member.Role == access.Worker,
// A member whose worker is connected is working for this node.
WorksForThisNode: online[member.ID],
})
}
return peers
}
// multiaddrs gives a host:port address in the multiaddress form the local
// API speaks, or nothing if it is not an address.
func multiaddrs(hostport string) []string {
host, port, err := net.SplitHostPort(hostport)
if err != nil {
return nil
}
kind := "dns"
if ip := net.ParseIP(host); ip != nil {
kind = "ip6"
if ip.To4() != nil {
kind = "ip4"
}
}
return []string{"/" + kind + "/" + host + "/tcp/" + port}
}
// apiToken returns the secret a local program must present to change this
// node through the local API, kept in file and made the first time.
func apiToken(file string) (string, error) {
data, err := os.ReadFile(file)
if err == nil {
return strings.TrimSpace(string(data)), nil
}
if !errors.Is(err, os.ErrNotExist) {
return "", fmt.Errorf("read API token: %w", err)
}
var secret [32]byte
rand.Read(secret[:]) // never fails; see crypto/rand
token := hex.EncodeToString(secret[:])
if err := os.WriteFile(file, []byte(token+"\n"), 0o600); err != nil {
return "", fmt.Errorf("save API token: %w", err)
}
return token, nil
}
// poolSwarm is the private IPFS network of the pool this node belongs to,
// and this node's Kubo daemon on it.
type poolSwarm struct {
daemon *kubo.Daemon
// keyFile is where a coordinator keeps the key, and port the port it has
// opened for the other members' Kubo daemons to connect to its own, or
// zero if it has opened none.
keyFile string
port int
// route is how a worker's Kubo reaches its coordinator's.
route *route
// mu serialises changes of key.
mu sync.Mutex
key string
}
func (s *poolSwarm) Key() string {
s.mu.Lock()
defer s.mu.Unlock()
return s.key
}
// Fingerprint identifies the current key without revealing it.
func (s *poolSwarm) Fingerprint() string {
return fingerprint(s.Key())
}
func fingerprint(key string) string {
sum := sha256.Sum256([]byte(key))
return hex.EncodeToString(sum[:8])
}
// Addresses returns where this node's Kubo can be reached, followed by where
// the members connected to it can be, so that a node joining the network
// connects to all of them and can fetch from any.
//
// A node that has opened no port for its Kubo leaves its own addresses out:
// they lead nowhere from another machine, and it is reached by tunnel.
func (s *poolSwarm) Addresses(ctx context.Context) ([]string, error) {
own, err := s.daemon.Addresses(ctx)
if err != nil {
return nil, err
}
if s.port == 0 {
own = nil
}
others, err := s.daemon.PeerAddresses(ctx)
return append(own, others...), err
}
// Local returns where on this machine the node's Kubo accepts members.
func (s *poolSwarm) Local(ctx context.Context) (string, error) {
own, err := s.daemon.Addresses(ctx)
if err != nil {
return "", err
}
return loopbackOf(own)
}
// loopbackOf picks, from a Kubo daemon's addresses, the one that reaches it
// from its own machine, as a host and port.
func loopbackOf(addresses []string) (string, error) {
for _, address := range addresses {
if hostport, ok := tcpAddress(address); ok && strings.HasPrefix(hostport, "127.0.0.1:") {
return hostport, nil
}
}
return "", fmt.Errorf("Kubo is not listening on this machine's loopback address (it listens on %v)", addresses)
}
// tcpAddress gives the host and port of a multiaddress that names a TCP
// port at an IP address, such as /ip4/10.0.0.5/tcp/4101/p2p/12D3Koo.
func tcpAddress(multiaddr string) (hostport string, ok bool) {
parts := strings.Split(multiaddr, "/")
if len(parts) < 5 || (parts[1] != "ip4" && parts[1] != "ip6") || parts[3] != "tcp" {
return "", false
}
return net.JoinHostPort(parts[2], parts[4]), true
}
// route is how a worker's Kubo gets to its coordinator's: straight to it
// where that works, and otherwise by a tunnel through the coordinator's own
// port.
type route struct {
// coordinator is the coordinator's node ID, which is its Kubo's peer ID
// too, and coordinatorAddr the address this node reaches it at.
coordinator string
coordinatorAddr string
// tunnel is the address on this machine that leads to the coordinator's
// Kubo.
tunnel string
log *slog.Logger
}
// probeTimeout is how long a worker gives the coordinator's Kubo to accept a
// direct connection before it settles for the tunnel.
var probeTimeout = 2 * time.Second
// choose takes the addresses a coordinator gave for the pool's Kubo daemons
// and returns the ones this worker's Kubo should use. The other members' are
// kept as they are. The coordinator's are kept if one of them accepts a
// connection, and are otherwise replaced with the tunnel.
func (r *route) choose(addresses []string) []string {
var others, candidates []string
host, _, _ := net.SplitHostPort(r.coordinatorAddr)
sameMachine := net.ParseIP(host).IsLoopback() || host == "localhost"
for _, address := range addresses {
if !strings.HasSuffix(address, "/p2p/"+r.coordinator) {
others = append(others, address)
continue
}
// The coordinator's loopback address means this machine, here,
// unless the two are the same machine.
hostport, ok := tcpAddress(address)
if ip, _, _ := net.SplitHostPort(hostport); ok && (sameMachine || !net.ParseIP(ip).IsLoopback()) {
candidates = append(candidates, address)
}
}
answers := make([]bool, len(candidates))
var probes sync.WaitGroup
for i, address := range candidates {
probes.Add(1)
go func() {
defer probes.Done()
hostport, _ := tcpAddress(address)
conn, err := net.DialTimeout("tcp", hostport, probeTimeout)
if err == nil {
conn.Close()
answers[i] = true
}
}()
}
probes.Wait()
var direct []string
for i, address := range candidates {
if answers[i] {
direct = append(direct, address)
}
}
if len(direct) > 0 {
r.log.Info("this node's Kubo connects to the coordinator's directly", "addresses", direct)
return append(direct, others...)
}
r.log.Info("this node's Kubo reaches the coordinator's through the coordinator's own port")
host, port, _ := net.SplitHostPort(r.tunnel)
return append([]string{"/ip4/" + host + "/tcp/" + port + "/p2p/" + r.coordinator}, others...)
}
// fetch asks the coordinator how to join the pool's network and works out
// how this worker's Kubo will reach it.
func (s *poolSwarm) fetch(ctx context.Context, coordinator string, creds credentials.TransportCredentials) (key string, peers []string, err error) {
key, addresses, err := fetchSwarm(ctx, coordinator, creds)
if err != nil {
return "", nil, err
}
return key, s.route.choose(addresses), nil
}
// listenLoopback opens a port on this machine only, for the system to pick.
var listenLoopback = func() (*net.TCPListener, error) {
return net.ListenTCP("tcp", &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1)})
}
// Rekey gives the pool's network a new key, on a coordinator: it saves the
// key and restarts this node's Kubo on it. Every other holder of the old key
// is then outside the network until it is given the new one.
func (s *poolSwarm) Rekey(ctx context.Context) error {
s.mu.Lock()
defer s.mu.Unlock()
key := kubo.NewSwarmKey()
if err := os.WriteFile(s.keyFile, []byte(key), 0o600); err != nil {
return fmt.Errorf("save swarm key: %w", err)
}
if err := s.daemon.Rekey(ctx, &kubo.Swarm{Key: key, Port: s.port, Loopback: s.port == 0}); err != nil {
return err
}
s.key = key
return nil
}
// joinSwarm starts a Kubo daemon, on the repository in repo, as a member of
// the private IPFS network of the pool coordinated at addr by the node with
// the given ID. The daemon runs until leave is called.
//
// A worker's Kubo makes its connections outwards. Where it cannot reach the
// coordinator's directly, it connects to a port on this machine that leads
// there through the coordinator's own.
func joinSwarm(ctx context.Context, ident *identity.Identity, repo, addr, coordinatorID string, creds credentials.TransportCredentials, log *slog.Logger) (swarm *poolSwarm, leave func(), err error) {
conn, err := grpc.NewClient(addr, grpc.WithTransportCredentials(creds))
if err != nil {
return nil, nil, err
}
lis, err := listenLoopback()
if err != nil {
conn.Close()
return nil, nil, fmt.Errorf("listen for this node's Kubo: %w", err)
}
forwarding, stop := context.WithCancel(ctx)
go tunnel.Forward(forwarding, lis, pb.NewTunnelServiceClient(conn), pb.TunnelTarget_TUNNEL_TARGET_SWARM, log)
swarm = &poolSwarm{route: &route{coordinator: coordinatorID, coordinatorAddr: addr, tunnel: lis.Addr().String(), log: log}}
key, peers, err := swarm.fetch(ctx, addr, creds)
if err == nil {
swarm.daemon, err = kubo.Start(ctx, kubo.Config{Repo: repo, Identity: ident, Swarm: &kubo.Swarm{Key: key, Peers: peers}})
}
if err != nil {
stop()
conn.Close()
return nil, nil, err
}
swarm.key = key
return swarm, func() {
swarm.daemon.Stop()
stop()
conn.Close()
}, nil
}
// adoptRetry is how long a worker waits before trying again to follow the
// pool to a new key.
var adoptRetry = 5 * time.Second
// adopt moves a worker's Kubo onto the pool's current key if the fingerprint
// its coordinator states shows that it is on another: it asks the
// coordinator for the key and the members' addresses and restarts on them.
// A failed attempt can leave Kubo stopped, and the worker with no store, so
// it keeps trying until it succeeds or ctx ends.
func (s *poolSwarm) adopt(ctx context.Context, coordinator string, creds credentials.TransportCredentials, stated string, log *slog.Logger) {
s.mu.Lock()
defer s.mu.Unlock()
if stated == fingerprint(s.key) {
return
}
for {
key, peers, err := s.fetch(ctx, coordinator, creds)
if err == nil {
err = s.daemon.Rekey(ctx, &kubo.Swarm{Key: key, Peers: peers})
}
if err == nil {
s.key = key
log.Info("moved to the new key of the pool's private IPFS network")
return
}
log.Warn("could not follow the pool's private IPFS network to its new key; trying again", "in", adoptRetry.String(), "error", err)
select {
case <-time.After(adoptRetry):
case <-ctx.Done():
return
}
}
}
// warnOfFallback returns what a worker on the pool's private network does
// when the network fails to supply a blob and the coordinator has to: say
// so, since otherwise the only sign of a broken network is slowness.
func warnOfFallback(log *slog.Logger) func(cid.Cid) {
return func(c cid.Cid) {
log.Warn("the pool's private IPFS network did not supply a blob, which was downloaded from the coordinator instead; check that this node's Kubo can reach the others", "cid", c.String())
}
}
// swarmKey returns the secret of the private network a coordinator runs,
// kept in file, generating it the first time.
func swarmKey(file string) (string, error) {
data, err := os.ReadFile(file)
if err == nil {
return string(data), nil
}
if !errors.Is(err, os.ErrNotExist) {
return "", fmt.Errorf("read swarm key: %w", err)
}
key := kubo.NewSwarmKey()
if err := os.WriteFile(file, []byte(key), 0o600); err != nil {
return "", fmt.Errorf("save swarm key: %w", err)
}
return key, nil
}
// fetchSwarm asks the coordinator at addr how to join its pool's private
// network: the key, and where its own Kubo daemon is.
func fetchSwarm(ctx context.Context, addr string, creds credentials.TransportCredentials) (key string, peers []string, err error) {
conn, err := grpc.NewClient(addr, grpc.WithTransportCredentials(creds))
if err != nil {
return "", nil, err
}
defer conn.Close()
swarm, err := pb.NewPoolServiceClient(conn).Swarm(ctx, &pb.SwarmRequest{})
if err != nil {
return "", nil, fmt.Errorf("ask the coordinator about its private IPFS network: %w", err)
}
return swarm.GetSwarmKey(), swarm.GetAddresses(), nil
}
// loadIdentity returns the key of the node whose data is in dataDir, making
// the directory and the key if this is the node's first run.
func loadIdentity(dataDir string) (*identity.Identity, error) {
if err := os.MkdirAll(dataDir, 0o700); err != nil {
return nil, err
}
if err := adoptRustKey(dataDir); err != nil {
return nil, err
}
ident, _, err := identity.LoadOrCreate(filepath.Join(dataDir, "node.key"))
return ident, err
}
// showIdentity prints a node's ID.
func showIdentity(args []string) error {
fs := flag.NewFlagSet("sisyphusd id", flag.ContinueOnError)
dataDir := fs.String("data-dir", defaultDataDir(), "directory holding the node's key and data")
if err := fs.Parse(args); err != nil {
return err
}
if fs.NArg() > 0 {
return fmt.Errorf("unexpected argument %q", fs.Arg(0))
}
ident, err := loadIdentity(*dataDir)
if err != nil {
return err
}
fmt.Fprintln(stdout, ident.ID())
return nil
}
// collectPeriodically clears out, every interval until ctx ends, what has
// outlived its keeping: stored data nothing pins any longer, and the record
// of jobs that finished long ago.
func collectPeriodically(ctx context.Context, store *storage.Store, prune func(time.Time) (int, error), interval time.Duration, log *slog.Logger) {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
done, err := store.GC(ctx, time.Now())
if err != nil {
log.Warn("garbage collection failed", "error", err)
} else if done != (storage.Collected{}) {
log.Info("collected garbage", "expired_pins", done.ExpiredPins, "blocks", done.Blocks, "bytes", done.Bytes)
}
forgotten, err := prune(time.Now())
if err != nil {
log.Warn("could not clear out old jobs", "error", err)
} else if forgotten > 0 {
log.Info("forgot jobs that finished long ago", "jobs", forgotten)
}
case <-ctx.Done():
return
}
}
}
// loopback returns an address the local worker can dial to reach a listener
// on this machine, including one bound to every interface.
func loopback(addr net.Addr) string {
tcp, ok := addr.(*net.TCPAddr)
if !ok || !tcp.IP.IsUnspecified() {
return addr.String()
}
return net.JoinHostPort("127.0.0.1", strconv.Itoa(tcp.Port))
}
// hostname is os.Hostname; tests replace it.
var hostname = os.Hostname
// defaultName is the machine's host name.
func defaultName() string {
name, err := hostname()
if err != nil {
return "node"
}
return name
}
// defaultDataDir is ~/.sisyphus, falling back to the working directory when
// the home directory is unknown.
func defaultDataDir() string {
home, err := os.UserHomeDir()
if err != nil {
return ".sisyphus"
}
return dataDirFor(home, goruntime.GOOS, os.Getenv, func(dir string) bool {
_, err := os.Stat(dir)
return err == nil
})
}
// dataDirFor returns where a node keeps its data when not told where: the
// place its operating system sets aside for a program's own data, as the
// Rust daemon chose. A node that already has a ~/.sisyphus, which is where
// earlier versions kept everything, carries on using it.
func dataDirFor(home, goos string, env func(string) string, exists func(string) bool) string {
if old := filepath.Join(home, ".sisyphus"); exists(old) {
return old
}
switch goos {
case "windows":
if local := env("LOCALAPPDATA"); local != "" {
return filepath.Join(local, "sisyphus")
}
return filepath.Join(home, "AppData", "Local", "sisyphus")
case "darwin":
return filepath.Join(home, "Library", "Application Support", "sisyphus")
}
if data := env("XDG_DATA_HOME"); data != "" {
return filepath.Join(data, "sisyphus")
}
return filepath.Join(home, ".local", "share", "sisyphus")
}