main.go
⎇
Raw
1// Command hearthforge runs the forge. Subcommands:
2//
3// hearthforge start HTTP (and SSH) servers
4// hearthforge init create the database and the admin account
5package main
6
7import (
8 "context"
9 "crypto/rand"
10 "errors"
11 "flag"
12 "fmt"
13 "log"
14 "net/http"
15 "os"
16 "os/exec"
17 "os/signal"
18 "strings"
19 "syscall"
20 "time"
21
22 "hearthforge/internal/ci"
23 "hearthforge/internal/config"
24 "hearthforge/internal/db"
25 "hearthforge/internal/gitcmd"
26 "hearthforge/internal/highlight"
27 "hearthforge/internal/markdown"
28 "hearthforge/internal/sshd"
29 "hearthforge/internal/web"
30)
31
32const usage = `Usage: hearthforge [command]
33
34Commands:
35 (none) start the HTTP and SSH servers
36 init create the admin account (password from ADMIN_PASSWORD, else random and printed once)
37
38Configuration comes from environment variables. See README.md.
39`
40
41func main() {
42 flag.Usage = func() { fmt.Fprint(os.Stderr, usage) }
43 flag.Parse()
44 command := ""
45 switch args := flag.Args(); {
46 case len(args) == 0:
47 case len(args) == 1 && args[0] == "init":
48 command = "init"
49 default:
50 fmt.Fprintf(os.Stderr, "unknown arguments: %s\n\n%s", strings.Join(args, " "), usage)
51 os.Exit(2)
52 }
53
54 cfg, err := config.Load()
55 if err != nil {
56 log.Fatal(err)
57 }
58 for _, dir := range []string{cfg.ReposDir(), cfg.AvatarsDir(), cfg.ReleasesDir(), cfg.CIArtifactsDir()} {
59 if err := os.MkdirAll(dir, 0o755); err != nil {
60 log.Fatal(err)
61 }
62 }
63 database, err := db.Open(cfg.DBPath())
64 if err != nil {
65 log.Fatal(err)
66 }
67 defer func() { _ = database.Close() }()
68 // A signal cancels ctx, which stops the background workers and starts the
69 // HTTP shutdown below.
70 ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
71 defer stop()
72
73 if command == "init" {
74 pw := os.Getenv("ADMIN_PASSWORD")
75 generated := pw == ""
76 if generated {
77 pw = rand.Text()
78 }
79 created, err := database.InitAdmin(ctx, pw)
80 if err != nil {
81 log.Fatal(err)
82 }
83 switch {
84 case created && generated:
85 fmt.Printf("admin account created, password: %s\n", pw)
86 case created:
87 fmt.Println("admin account created")
88 default:
89 fmt.Println("admin account already exists")
90 }
91 return
92 }
93
94 for _, bin := range []string{"git", "ssh-keygen"} {
95 if _, err := exec.LookPath(bin); err != nil {
96 log.Fatalf("missing required command: %s", bin)
97 }
98 }
99
100 git := gitcmd.New(cfg)
101 runner := ci.New(cfg, database)
102 srv := &web.Server{
103 Cfg: cfg,
104 DB: database,
105 MD: markdown.New(),
106 HL: highlight.New(cfg.InlineMaxBytes),
107 CI: runner,
108 Git: git,
109 Patches: gitcmd.NewPatchCache(),
110 }
111 if err := srv.SyncRepos(ctx); err != nil {
112 log.Fatalf("repo sync: %v", err)
113 }
114 if err := srv.SyncReleases(ctx); err != nil {
115 log.Printf("release sync: %v", err)
116 }
117 if err := runner.CancelStaleRuns(ctx); err != nil {
118 log.Printf("cancel stale ci runs: %v", err)
119 }
120 srv.StartSessionCleanup(ctx)
121
122 // A server that stops on its own is fatal; the channel carries that up so
123 // the deferred cleanup still runs.
124 serverErr := make(chan error, 2)
125
126 var ssh *sshd.Server
127 if !cfg.SSHDisabled {
128 ssh = &sshd.Server{Cfg: cfg, DB: database, CI: runner, OnPush: git.InvalidateRefCache}
129 go func() { serverErr <- fmt.Errorf("ssh: %w", ssh.ListenAndServe()) }()
130 }
131
132 log.Printf("Hearthforge running at http://localhost:%d", cfg.Port)
133 httpSrv := &http.Server{
134 Addr: fmt.Sprintf(":%d", cfg.Port),
135 Handler: srv.Router(),
136 ReadHeaderTimeout: 30 * time.Second,
137 // No read or write timeout: a git clone or push body is legitimately
138 // slow. An idle keep-alive connection is still dropped.
139 IdleTimeout: 120 * time.Second,
140 }
141 go func() { serverErr <- httpSrv.ListenAndServe() }()
142
143 failed := false
144 select {
145 case err := <-serverErr:
146 if !errors.Is(err, http.ErrServerClosed) {
147 log.Printf("server stopped: %v", err)
148 failed = true
149 }
150 case <-ctx.Done():
151 log.Println("shutting down")
152 }
153
154 shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
155 defer cancel()
156 if err := httpSrv.Shutdown(shutdownCtx); err != nil {
157 log.Printf("http shutdown: %v", err)
158 }
159 if ssh != nil {
160 if err := ssh.Close(); err != nil {
161 log.Printf("ssh shutdown: %v", err)
162 }
163 }
164 if failed {
165 // os.Exit skips the deferred close, so do it here.
166 _ = database.Close()
167 os.Exit(1)
168 }
169}
170