1package serve
2
3import (
4 "context"
5 "crypto/tls"
6 "errors"
7 "fmt"
8 "net/http"
9
10 "charm.land/log/v2"
11
12 "github.com/charmbracelet/soft-serve/pkg/backend"
13 "github.com/charmbracelet/soft-serve/pkg/config"
14 "github.com/charmbracelet/soft-serve/pkg/cron"
15 "github.com/charmbracelet/soft-serve/pkg/daemon"
16 "github.com/charmbracelet/soft-serve/pkg/db"
17 "github.com/charmbracelet/soft-serve/pkg/jobs"
18 sshsrv "github.com/charmbracelet/soft-serve/pkg/ssh"
19 "github.com/charmbracelet/soft-serve/pkg/stats"
20 "github.com/charmbracelet/soft-serve/pkg/web"
21 "github.com/charmbracelet/ssh"
22 "golang.org/x/sync/errgroup"
23)
24
25// Server is the Soft Serve server.
26type Server struct {
27 SSHServer *sshsrv.SSHServer
28 GitDaemon *daemon.GitDaemon
29 HTTPServer *web.HTTPServer
30 StatsServer *stats.StatsServer
31 CertLoader *CertReloader
32 Cron *cron.Scheduler
33 Config *config.Config
34 Backend *backend.Backend
35 DB *db.DB
36
37 logger *log.Logger
38 ctx context.Context
39}
40
41// NewServer returns a new *Server configured to serve Soft Serve. The SSH
42// server key-pair will be created if none exists.
43// It expects a context with *backend.Backend, *db.DB, *log.Logger, and
44// *config.Config attached.
45func NewServer(ctx context.Context) (*Server, error) {
46 var err error
47 cfg := config.FromContext(ctx)
48 be := backend.FromContext(ctx)
49 db := db.FromContext(ctx)
50 logger := log.FromContext(ctx).WithPrefix("server")
51 srv := &Server{
52 Config: cfg,
53 Backend: be,
54 DB: db,
55 logger: log.FromContext(ctx).WithPrefix("server"),
56 ctx: ctx,
57 }
58
59 // Add cron jobs.
60 sched := cron.NewScheduler(ctx)
61 for n, j := range jobs.List() {
62 id, err := sched.AddFunc(j.Runner.Spec(ctx), j.Runner.Func(ctx))
63 if err != nil {
64 logger.Warn("error adding cron job", "job", n, "err", err)
65 }
66
67 j.ID = id
68 }
69
70 srv.Cron = sched
71
72 srv.SSHServer, err = sshsrv.NewSSHServer(ctx)
73 if err != nil {
74 return nil, fmt.Errorf("create ssh server: %w", err)
75 }
76
77 srv.GitDaemon, err = daemon.NewGitDaemon(ctx)
78 if err != nil {
79 return nil, fmt.Errorf("create git daemon: %w", err)
80 }
81
82 srv.HTTPServer, err = web.NewHTTPServer(ctx)
83 if err != nil {
84 return nil, fmt.Errorf("create http server: %w", err)
85 }
86
87 srv.StatsServer, err = stats.NewStatsServer(ctx)
88 if err != nil {
89 return nil, fmt.Errorf("create stats server: %w", err)
90 }
91
92 if cfg.HTTP.TLSKeyPath != "" && cfg.HTTP.TLSCertPath != "" {
93 srv.CertLoader, err = NewCertReloader(cfg.HTTP.TLSCertPath, cfg.HTTP.TLSKeyPath, logger)
94 if err != nil {
95 return nil, fmt.Errorf("create cert reloader: %w", err)
96 }
97
98 srv.HTTPServer.SetTLSConfig(&tls.Config{
99 GetCertificate: srv.CertLoader.GetCertificateFunc(),
100 })
101 }
102
103 return srv, nil
104}
105
106// ReloadCertificates reloads the TLS certificates for the HTTP server.
107func (s *Server) ReloadCertificates() error {
108 if s.CertLoader == nil {
109 return nil
110 }
111 return s.CertLoader.Reload()
112}
113
114// Start starts the SSH server.
115func (s *Server) Start() error {
116 errg, _ := errgroup.WithContext(s.ctx)
117
118 // optionally start the SSH server
119 if s.Config.SSH.Enabled {
120 errg.Go(func() error {
121 s.logger.Print("Starting SSH server", "addr", s.Config.SSH.ListenAddr)
122 if err := s.SSHServer.ListenAndServe(); !errors.Is(err, ssh.ErrServerClosed) {
123 return err
124 }
125 return nil
126 })
127 }
128
129 // optionally start the git daemon
130 if s.Config.Git.Enabled {
131 errg.Go(func() error {
132 s.logger.Print("Starting Git daemon", "addr", s.Config.Git.ListenAddr)
133 if err := s.GitDaemon.ListenAndServe(); !errors.Is(err, daemon.ErrServerClosed) {
134 return err
135 }
136 return nil
137 })
138 }
139
140 // optionally start the HTTP server
141 if s.Config.HTTP.Enabled {
142 errg.Go(func() error {
143 s.logger.Print("Starting HTTP server", "addr", s.Config.HTTP.ListenAddr)
144 if err := s.HTTPServer.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
145 return err
146 }
147 return nil
148 })
149 }
150
151 // optionally start the Stats server
152 if s.Config.Stats.Enabled {
153 errg.Go(func() error {
154 s.logger.Print("Starting Stats server", "addr", s.Config.Stats.ListenAddr)
155 if err := s.StatsServer.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
156 return err
157 }
158 return nil
159 })
160 }
161
162 errg.Go(func() error {
163 s.Cron.Start()
164 return nil
165 })
166 return errg.Wait()
167}
168
169// Shutdown lets the server gracefully shutdown.
170func (s *Server) Shutdown(ctx context.Context) error {
171 errg, ctx := errgroup.WithContext(ctx)
172 errg.Go(func() error {
173 return s.GitDaemon.Shutdown(ctx)
174 })
175 errg.Go(func() error {
176 return s.HTTPServer.Shutdown(ctx)
177 })
178 errg.Go(func() error {
179 return s.SSHServer.Shutdown(ctx)
180 })
181 errg.Go(func() error {
182 return s.StatsServer.Shutdown(ctx)
183 })
184 errg.Go(func() error {
185 for _, j := range jobs.List() {
186 s.Cron.Remove(j.ID)
187 }
188 s.Cron.Stop()
189 return nil
190 })
191 // defer s.DB.Close() // nolint: errcheck
192 return errg.Wait()
193}
194
195// Close closes the SSH server.
196func (s *Server) Close() error {
197 var errg errgroup.Group
198 errg.Go(s.GitDaemon.Close)
199 errg.Go(s.HTTPServer.Close)
200 errg.Go(s.SSHServer.Close)
201 errg.Go(s.StatsServer.Close)
202 errg.Go(func() error {
203 s.Cron.Stop()
204 return nil
205 })
206 // defer s.DB.Close() // nolint: errcheck
207 return errg.Wait()
208}