DevPlace CI / test (push) Failing after 44m45s
Continuous Delivery could never be turned off once a crop was planted. Add a one-shot per-plot "stop after harvest" flag (POST /game/plot/stop, Devii tool game_plot_stop, game_plots.stop_after_harvest), cleared by the next plant, autoreplant claim or Refactor. Autoreplant now also respects crop locks through the shared _crop_lock predicate. Plan and measurements in happyplanting.md. Also includes the pending community sync, node network and related work in the tree; the full suite passed (4999) with all of it. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
213 lines
6.3 KiB
Go
213 lines
6.3 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"os/signal"
|
|
"path/filepath"
|
|
"syscall"
|
|
"time"
|
|
_ "time/tzdata"
|
|
)
|
|
|
|
var Version = "dev"
|
|
|
|
const (
|
|
exitOK = 0
|
|
exitError = 1
|
|
exitUsage = 2
|
|
exitFatal = 3
|
|
usageText = `usage: devplace-node <command> [flags]
|
|
|
|
commands:
|
|
login store the server and device secret (--server URL --secret dpn_...)
|
|
status show the stored configuration, today's usage and whether the server accepts this device
|
|
run connect and serve jobs in the foreground
|
|
version print the version
|
|
|
|
flags for every command: --config PATH (default: <user config dir>/devplace-node/config.json)
|
|
`
|
|
)
|
|
|
|
func main() {
|
|
os.Exit(execute(os.Args[1:], os.Stdout, os.Stderr))
|
|
}
|
|
|
|
func execute(args []string, stdout, stderr io.Writer) int {
|
|
if len(args) == 0 {
|
|
fmt.Fprint(stderr, usageText)
|
|
return exitUsage
|
|
}
|
|
command, rest := args[0], args[1:]
|
|
switch command {
|
|
case "version":
|
|
fmt.Fprintln(stdout, "devplace-node", Version)
|
|
return exitOK
|
|
case "login":
|
|
return runLogin(rest, stdout, stderr)
|
|
case "status":
|
|
return runStatus(rest, stdout, stderr)
|
|
case "run":
|
|
return runRun(rest, stderr)
|
|
case "help", "-h", "--help":
|
|
fmt.Fprint(stdout, usageText)
|
|
return exitOK
|
|
}
|
|
fmt.Fprintf(stderr, "unknown command %q\n%s", command, usageText)
|
|
return exitUsage
|
|
}
|
|
|
|
func resolveConfigPath(flagValue string) (string, error) {
|
|
if flagValue != "" {
|
|
return flagValue, nil
|
|
}
|
|
return DefaultConfigPath()
|
|
}
|
|
|
|
func newFlags(name string, stderr io.Writer) (*flag.FlagSet, *string) {
|
|
set := flag.NewFlagSet(name, flag.ContinueOnError)
|
|
set.SetOutput(stderr)
|
|
path := set.String("config", "", "config file path")
|
|
return set, path
|
|
}
|
|
|
|
func runLogin(args []string, stdout, stderr io.Writer) int {
|
|
set, pathFlag := newFlags("login", stderr)
|
|
server := set.String("server", "", "server url, for example https://devplace.net")
|
|
secret := set.String("secret", "", "device secret (or set DEVPLACE_NODE_SECRET)")
|
|
bandwidth := set.Int("bandwidth-mb", DefaultBandwidthMB, "daily bandwidth cap in MB")
|
|
concurrent := set.Int("concurrency", DefaultMaxConcurrent, "maximum parallel jobs")
|
|
tunnels := set.Int("tunnels", DefaultMaxTunnels, "maximum parallel tunnels")
|
|
from := set.String("from", "", "active hours start HH:MM")
|
|
to := set.String("to", "", "active hours end HH:MM")
|
|
zone := set.String("timezone", "", "IANA timezone for the active hours (default: system)")
|
|
skip := set.Bool("no-verify", false, "store without asking the server to accept the secret")
|
|
if err := set.Parse(args); err != nil {
|
|
return exitUsage
|
|
}
|
|
if *secret == "" {
|
|
*secret = os.Getenv("DEVPLACE_NODE_SECRET")
|
|
}
|
|
path, err := resolveConfigPath(*pathFlag)
|
|
if err != nil {
|
|
fmt.Fprintln(stderr, err)
|
|
return exitError
|
|
}
|
|
config := Config{
|
|
Server: *server, Secret: *secret, BandwidthMBDay: *bandwidth, MaxConcurrent: *concurrent,
|
|
MaxTunnels: *tunnels, ActiveFrom: *from, ActiveTo: *to, Timezone: *zone,
|
|
}
|
|
if err := config.Validate(); err != nil {
|
|
fmt.Fprintln(stderr, "login refused:", err)
|
|
return exitUsage
|
|
}
|
|
if !*skip {
|
|
if code := verifyDevice(config, stderr); code != exitOK {
|
|
return code
|
|
}
|
|
}
|
|
if err := SaveConfig(path, config); err != nil {
|
|
fmt.Fprintln(stderr, "config could not be written:", err)
|
|
return exitError
|
|
}
|
|
fmt.Fprintf(stdout, "stored %s (mode 0600)\n", path)
|
|
return exitOK
|
|
}
|
|
|
|
func verifyDevice(config Config, stderr io.Writer) int {
|
|
session := NewSession(config, NewPolicy(), nil, nil, nil, NewLogger(stderr, false))
|
|
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(TicketTimeoutSeconds+5)*time.Second)
|
|
defer cancel()
|
|
_, result := session.fetchTicket(ctx)
|
|
if result.fatal != "" {
|
|
fmt.Fprintln(stderr, "server refused this device:", result.fatal)
|
|
return exitFatal
|
|
}
|
|
if result.reason != "" {
|
|
fmt.Fprintln(stderr, "server not verified:", result.reason, "(use --no-verify to store anyway)")
|
|
return exitError
|
|
}
|
|
return exitOK
|
|
}
|
|
|
|
func runStatus(args []string, stdout, stderr io.Writer) int {
|
|
set, pathFlag := newFlags("status", stderr)
|
|
if err := set.Parse(args); err != nil {
|
|
return exitUsage
|
|
}
|
|
path, err := resolveConfigPath(*pathFlag)
|
|
if err != nil {
|
|
fmt.Fprintln(stderr, err)
|
|
return exitError
|
|
}
|
|
config, err := LoadConfig(path)
|
|
if err != nil {
|
|
fmt.Fprintln(stderr, err)
|
|
return exitError
|
|
}
|
|
location, _ := config.Location()
|
|
used := ReadUsage(filepath.Join(filepath.Dir(path), UsageFileName), location, time.Now())
|
|
fmt.Fprintf(stdout, "version: %s\nconfig: %s\nserver: %s\nsecret: stored\n", Version, path, config.Server)
|
|
fmt.Fprintf(stdout, "bandwidth: %d MB/day (used today: %.1f MB)\n", config.BandwidthMBDay, float64(used)/mebibyte)
|
|
fmt.Fprintf(stdout, "concurrency: %d jobs, %d tunnels\n", config.MaxConcurrent, config.MaxTunnels)
|
|
hours := "always"
|
|
if config.ActiveFrom != "" {
|
|
hours = config.ActiveFrom + "-" + config.ActiveTo
|
|
}
|
|
fmt.Fprintf(stdout, "active hours: %s (%s)\n", hours, location)
|
|
if code := verifyDevice(config, stderr); code != exitOK {
|
|
fmt.Fprintln(stdout, "server: not accepting this device")
|
|
return code
|
|
}
|
|
fmt.Fprintln(stdout, "server: accepts this device")
|
|
return exitOK
|
|
}
|
|
|
|
func runRun(args []string, stderr io.Writer) int {
|
|
set, pathFlag := newFlags("run", stderr)
|
|
verbose := set.Bool("v", false, "verbose logging")
|
|
if err := set.Parse(args); err != nil {
|
|
return exitUsage
|
|
}
|
|
path, err := resolveConfigPath(*pathFlag)
|
|
if err != nil {
|
|
fmt.Fprintln(stderr, err)
|
|
return exitError
|
|
}
|
|
config, err := LoadConfig(path)
|
|
if err != nil {
|
|
fmt.Fprintln(stderr, err)
|
|
return exitError
|
|
}
|
|
logger := NewLogger(stderr, *verbose)
|
|
limiter, err := NewLimiter(config, filepath.Join(filepath.Dir(path), UsageFileName), time.Now)
|
|
if err != nil {
|
|
fmt.Fprintln(stderr, err)
|
|
return exitError
|
|
}
|
|
limiter.SetBatteryProbe(systemBattery, logger.Debugf)
|
|
policy := NewPolicy()
|
|
stats := NewStats(time.Now())
|
|
session := NewSession(config, policy, limiter, NewFetcher(policy), stats, logger)
|
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
|
defer stop()
|
|
logger.Printf("started version %s, server %s", Version, config.Server)
|
|
err = session.Run(ctx)
|
|
limiter.Flush()
|
|
logger.Printf("%s", stats.Line())
|
|
var fatal *FatalError
|
|
if errors.As(err, &fatal) {
|
|
logger.Printf("%s", fatal.Error())
|
|
return exitFatal
|
|
}
|
|
if err != nil {
|
|
logger.Printf("stopped: %v", err)
|
|
return exitError
|
|
}
|
|
return exitOK
|
|
}
|