eclipse/cmd/eclipse-httpd/main.go

438 lines
12 KiB
Go
Raw Normal View History

2024-01-17 21:52:40 +00:00
// Copyright (C) 2023-2024 Umorpha Systems
// SPDX-License-Identifier: AGPL-3.0-or-later
package main
import (
"context"
2024-01-18 02:47:08 +00:00
"embed"
2024-01-18 16:17:10 +00:00
"encoding/json"
2024-01-18 19:08:10 +00:00
"errors"
2024-01-17 21:52:40 +00:00
"fmt"
2024-01-18 01:08:22 +00:00
"html/template"
2024-01-18 16:17:10 +00:00
"io"
2024-01-18 19:55:53 +00:00
"io/fs"
2024-01-17 21:52:40 +00:00
"log"
2024-01-18 16:17:10 +00:00
"mime"
"mime/multipart"
2024-01-17 21:52:40 +00:00
"net/http"
2024-01-18 04:15:20 +00:00
"net/url"
2024-01-17 21:52:40 +00:00
"os"
2024-01-18 19:08:10 +00:00
"path"
2024-01-17 21:52:40 +00:00
"path/filepath"
2024-01-18 04:15:20 +00:00
"strconv"
2024-01-17 21:52:40 +00:00
"strings"
2024-01-18 19:55:53 +00:00
"time"
2024-01-17 21:52:40 +00:00
2024-01-18 19:08:10 +00:00
"git.lukeshu.com/go/containers/typedsync"
2024-01-17 21:52:40 +00:00
"github.com/datawire/ocibuild/pkg/cliutil"
"github.com/spf13/cobra"
source "git.mothstuff.lol/lukeshu/eclipse"
2024-01-18 05:49:09 +00:00
"git.mothstuff.lol/lukeshu/eclipse/lib/eclipse"
2024-01-17 21:52:40 +00:00
)
func main() {
argparser := &cobra.Command{
Use: os.Args[0] + " [flags]",
Short: "HTTP server",
2024-01-18 07:43:02 +00:00
Args: cliutil.WrapPositionalArgs(cobra.NoArgs),
2024-01-17 21:52:40 +00:00
SilenceErrors: true, // we'll handle this ourselves after .ExecuteContext()
SilenceUsage: true, // FlagErrorFunc will handle this
CompletionOptions: cobra.CompletionOptions{
DisableDefaultCmd: true,
},
}
argparser.SetFlagErrorFunc(cliutil.FlagErrorFunc)
argparser.SetHelpTemplate(cliutil.HelpTemplate)
var cfgFile string
2024-01-18 05:49:09 +00:00
argparser.Flags().StringVar(&cfgFile, "config", source.DefaultConfigFile,
2024-01-17 21:52:40 +00:00
"Config file to use")
argparser.MarkFlagFilename("config", "yml", "yaml")
var socket string
argparser.Flags().StringVar(&socket, "socket", "tcp::8080",
"the socket to listen on; a \"family:address\" pair, where the\n"+
"\"family\" is one of \"tcp\", \"tcp4\", \"tcp6\", \"unix\", \"unixpacket\"\n"+
"(the usual Go net.Listen() network names), or \"fd\" (to listen\n"+
"on an already-open file descriptor). The \"address\" part for\n"+
"the \"fd\" family is one of\n"+
" 1. a file descriptor number (or the special names \"stdin\",\n"+
" \"stdout\", or \"stderr\" to refer to 0, 1, or 2\n"+
" respectively),\n"+
" 2. \"systemd:N\" to refer to the Nth file descriptor passed in\n"+
" via systemd (or \"systemd\" as shorthand for \"systemd:0\")\n"+
" (see sd_listend_fds(3)),\n"+
" 3. \"systemd:NAME\" to refer to a named file descriptor passed\n"+
" in via systemd (see sd_listend_fds_with_names(3)).\n")
argparser.RunE = func(cmd *cobra.Command, args []string) error {
stype, saddr, ok := strings.Cut(socket, ":")
if !ok {
return cliutil.FlagErrorFunc(cmd, fmt.Errorf("invalid address: %q", socket))
}
return Serve(cfgFile, stype, saddr)
}
ctx := context.Background()
if err := argparser.ExecuteContext(ctx); err != nil {
fmt.Fprintf(os.Stderr, "%v: error: %v\n", argparser.CommandPath(), err)
os.Exit(1)
}
}
func Serve(cfgFile, stype, saddr string) (err error) {
maybeSetErr := func(_err error) {
if err == nil && _err != nil {
err = _err
}
}
2024-01-18 05:49:09 +00:00
cfg, err := eclipse.LoadConfig(cfgFile)
2024-01-17 21:52:40 +00:00
if err != nil {
return err
}
2024-01-18 05:49:09 +00:00
db, err := cfg.DB()
2024-01-17 21:52:40 +00:00
if err != nil {
return err
}
2024-01-18 05:49:09 +00:00
defer func() { maybeSetErr(db.Close()) }()
2024-01-17 21:52:40 +00:00
sock, err := netListen(stype, saddr)
if err != nil {
return err
}
2024-01-18 21:17:26 +00:00
if err := db.GCRunningJobs(); err != nil {
return err
}
2024-01-18 01:08:22 +00:00
state := &ServerState{
2024-01-18 05:49:09 +00:00
db: db,
2024-01-18 01:08:22 +00:00
}
2024-01-17 21:52:40 +00:00
router := http.NewServeMux()
2024-01-18 02:47:08 +00:00
router.HandleFunc("/", state.serveIndex)
router.HandleFunc("/style.css", state.serveStatic)
router.HandleFunc("/favicon.ico", state.serveStatic)
router.HandleFunc("/eclipse.tar", state.serveStatic)
2024-01-18 01:08:22 +00:00
router.HandleFunc("/jobs/", state.serveJobs)
2024-01-17 21:52:40 +00:00
log.Printf("Serving on %v...", sock.Addr())
return http.Serve(sock, router)
}
2024-01-18 02:47:08 +00:00
type ServerState struct {
2024-01-18 19:08:10 +00:00
db *eclipse.DB
running typedsync.Map[eclipse.JobID, chan struct{}]
2024-01-18 02:47:08 +00:00
}
2024-01-17 21:52:40 +00:00
2024-01-18 02:08:47 +00:00
//go:embed style.css
var fileStyle []byte
2024-01-17 21:52:40 +00:00
//go:embed favicon.ico
var fileFavicon []byte
2024-01-18 02:47:08 +00:00
func (*ServerState) serveStatic(w http.ResponseWriter, r *http.Request) {
2024-01-17 21:52:40 +00:00
if r.Method != http.MethodGet {
http.Error(w, "HTTP 405: only GET is supported", http.StatusMethodNotAllowed)
return
}
switch r.URL.Path {
2024-01-18 02:08:47 +00:00
case "/style.css":
w.Header().Set("Content-Type", "text/css")
_, _ = w.Write(fileStyle)
2024-01-17 21:52:40 +00:00
case "/favicon.ico":
w.Header().Set("Content-Type", "image/vnd.microsoft.icon")
_, _ = w.Write(fileFavicon)
case "/eclipse.tar":
w.Header().Set("Content-Type", "application/x-tar")
_, _ = w.Write(source.Tarball)
default:
http.NotFound(w, r)
}
}
2024-01-18 01:08:22 +00:00
2024-01-18 04:15:20 +00:00
//go:embed html
2024-01-18 02:47:08 +00:00
var tmplFS embed.FS
2024-01-18 01:08:22 +00:00
2024-01-18 04:15:20 +00:00
var (
tmplIndex = template.Must(template.ParseFS(tmplFS, "html/pages/index.html.tmpl", "html/lib/*.html.tmpl"))
tmplJobs = template.Must(template.ParseFS(tmplFS, "html/pages/jobs.html.tmpl", "html/lib/*.html.tmpl"))
tmplJob = template.Must(template.ParseFS(tmplFS, "html/pages/job.html.tmpl", "html/lib/*.html.tmpl"))
)
func httpAddSlash(w http.ResponseWriter, r *http.Request) {
u := &url.URL{Path: r.URL.Path + "/", RawQuery: r.URL.RawQuery}
http.Redirect(w, r, u.String(), http.StatusMovedPermanently)
}
2024-01-18 01:08:22 +00:00
2024-01-18 02:47:08 +00:00
func (*ServerState) serveIndex(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
if r.Method != http.MethodGet {
http.Error(w, "HTTP 405: only GET is supported", http.StatusMethodNotAllowed)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
2024-01-18 04:15:20 +00:00
if err := tmplIndex.Execute(w, nil); err != nil {
log.Printf("error: %q: %v", r.URL, err)
}
2024-01-18 02:47:08 +00:00
}
2024-01-18 01:08:22 +00:00
func (o *ServerState) serveJobs(w http.ResponseWriter, r *http.Request) {
2024-01-18 21:17:26 +00:00
log.Printf("access %q", r.URL)
defer func() {
log.Printf("done %q", r.URL)
panic("x")
}()
2024-01-18 01:08:22 +00:00
rest := strings.TrimPrefix(r.URL.Path, "/jobs/")
if rest == "" {
switch r.Method {
case http.MethodGet:
jobs, err := o.db.ListJobs()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
2024-01-18 04:15:20 +00:00
if err := tmplJobs.Execute(w, map[string]any{"Jobs": jobs}); err != nil {
log.Printf("error: %q: %v", r.URL, err)
}
2024-01-18 19:08:10 +00:00
// TODO: content-negotiate /jobs/ to have JSON
2024-01-18 01:08:22 +00:00
case http.MethodPost:
2024-01-18 19:08:10 +00:00
// TODO: allow POSTing to /jobs/ to create a job
2024-01-18 01:08:22 +00:00
default:
http.Error(w, "HTTP 405: only GET and POST are supported", http.StatusMethodNotAllowed)
}
2024-01-18 04:15:20 +00:00
return
}
jobIDStr, file, haveSlash := strings.Cut(rest, "/")
jobID, err := strconv.Atoi(jobIDStr)
if err != nil {
http.NotFound(w, r)
return
}
2024-01-18 05:49:09 +00:00
job, err := o.db.GetJob(eclipse.JobID(jobID))
2024-01-18 04:15:20 +00:00
if err != nil {
http.NotFound(w, r)
return
}
if !haveSlash {
httpAddSlash(w, r)
return
}
switch file {
case "":
2024-01-18 07:43:02 +00:00
switch r.Method {
case http.MethodGet:
2024-01-18 19:55:53 +00:00
dirfs := job.DirFS()
2024-01-18 21:17:26 +00:00
artifactNames := []string{}
if err := fs.WalkDir(dirfs, "artifacts", func(path string, d fs.DirEntry, err error) error {
2024-01-18 19:55:53 +00:00
if err != nil {
return err
}
if d.Type() == 0 {
2024-01-18 21:17:26 +00:00
artifactNames = append(artifactNames, strings.TrimPrefix(path, "artifacts/"))
2024-01-18 19:55:53 +00:00
}
return nil
2024-01-18 21:17:26 +00:00
}); err != nil && !os.IsNotExist(err) {
2024-01-18 19:55:53 +00:00
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
2024-01-18 07:43:02 +00:00
w.Header().Set("Content-Type", "text/html; charset=utf-8")
2024-01-18 19:55:53 +00:00
if err := tmplJob.Execute(w, map[string]any{
2024-01-18 21:17:26 +00:00
"Job": job,
"ArtifactNames": artifactNames,
2024-01-18 19:55:53 +00:00
}); err != nil {
2024-01-18 07:43:02 +00:00
log.Printf("error: %q: %v", r.URL, err)
}
case http.MethodPost:
2024-01-18 19:08:10 +00:00
// TODO: Validate API key
if err := o.runJob(w, r, job); err != nil {
2024-01-18 21:17:26 +00:00
var herr *httpError
if errors.As(err, &herr) {
http.Error(w, herr.Error(), herr.Code)
}
2024-01-18 16:17:10 +00:00
log.Printf("error: %q: %v", r.URL, err)
}
2024-01-18 07:43:02 +00:00
default:
http.Error(w, "HTTP 405: only GET and POST are supported", http.StatusMethodNotAllowed)
2024-01-18 04:15:20 +00:00
}
2024-01-18 19:08:10 +00:00
default:
2024-01-18 07:43:02 +00:00
if r.Method != http.MethodGet {
http.Error(w, "HTTP 405: only GET is supported", http.StatusMethodNotAllowed)
return
}
2024-01-18 19:08:10 +00:00
if file == "log.txt" && job.Status == eclipse.StatusRunning {
2024-01-18 21:17:26 +00:00
fh, _ := os.Open(filepath.Join(job.Dir, "log.txt"))
2024-01-18 19:55:53 +00:00
ch, _ := o.running.Load(job.ID)
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
2024-01-18 21:17:26 +00:00
if fh != nil {
if _, err := io.Copy(w, fh); err != nil {
log.Printf("error: %q: %v", r.URL, err)
return
}
2024-01-18 19:55:53 +00:00
}
ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop()
for keepGoing := true; keepGoing; {
select {
case <-ch:
keepGoing = false
case <-ticker.C:
}
2024-01-18 21:17:26 +00:00
if fh == nil {
fh, _ = os.Open(filepath.Join(job.Dir, "log.txt"))
}
if fh != nil {
if _, err := io.Copy(w, fh); err != nil {
log.Printf("error: %q: %v", r.URL, err)
keepGoing = false
}
2024-01-18 19:55:53 +00:00
}
}
return
2024-01-18 19:08:10 +00:00
}
http.FileServer(http.Dir(job.Dir)).ServeHTTP(w, r)
}
}
type httpError struct {
Err error
Code int
}
func (e *httpError) Error() string {
return fmt.Sprintf("HTTP %d: %v", e.Code, e.Err)
}
2024-01-18 21:17:26 +00:00
func (o *ServerState) runJob(w http.ResponseWriter, r *http.Request, job eclipse.Job) (_reterr error) {
log.Printf("running job: %q", r.URL)
ctl := http.NewResponseController(w)
if err := ctl.EnableFullDuplex(); err != nil {
return &httpError{Code: http.StatusInternalServerError, Err: err}
}
2024-01-18 19:08:10 +00:00
ch := make(chan struct{})
if _, conflict := o.running.LoadOrStore(job.ID, ch); conflict {
2024-01-18 21:17:26 +00:00
return &httpError{Code: http.StatusConflict, Err: errors.New("job has already started (ch)")}
2024-01-18 19:08:10 +00:00
}
defer func() {
close(ch)
o.running.Delete(job.ID)
}()
swapped, err := o.db.SwapJobStatus(job.ID, eclipse.StatusNew, eclipse.StatusRunning)
if err != nil {
return &httpError{Code: http.StatusInternalServerError, Err: err}
}
if !swapped {
2024-01-18 21:17:26 +00:00
return &httpError{Code: http.StatusConflict, Err: errors.New("job has already started (db)")}
2024-01-18 19:08:10 +00:00
}
2024-01-18 21:17:26 +00:00
haveWritten := false
2024-01-18 19:08:10 +00:00
defer func() {
if _, err := o.db.SwapJobStatus(job.ID, eclipse.StatusRunning, eclipse.StatusError); err != nil {
2024-01-18 21:17:26 +00:00
if !haveWritten {
_reterr = &httpError{Code: http.StatusInternalServerError, Err: err}
} else {
_reterr = err
}
2024-01-18 19:08:10 +00:00
}
}()
bs, err := json.Marshal(job)
if err != nil {
2024-01-18 21:17:26 +00:00
return &httpError{Code: http.StatusInternalServerError, Err: fmt.Errorf("encoding job description: %w", err)}
2024-01-18 19:08:10 +00:00
}
mediaType, params, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
if err != nil {
2024-01-18 21:17:26 +00:00
return &httpError{Code: http.StatusBadRequest, Err: fmt.Errorf("invalid Content-Type: %q: %w", r.Header.Get("Content-Type"), err)}
2024-01-18 19:08:10 +00:00
}
if mediaType != "multipart/mixed" {
2024-01-18 21:17:26 +00:00
return &httpError{Code: http.StatusBadRequest, Err: fmt.Errorf("invalid Content-Type: %q", r.Header.Get("Content-Type"))}
2024-01-18 19:08:10 +00:00
}
reader := multipart.NewReader(r.Body, params["boundary"])
2024-01-18 21:17:26 +00:00
// Last chance to return a status code /////////////////////////////////
haveWritten = true
2024-01-18 19:08:10 +00:00
w.Header().Set("Content-Type", "application/json")
if _, err := w.Write(bs); err != nil {
2024-01-18 21:17:26 +00:00
return fmt.Errorf("write job description: %w", err)
2024-01-18 19:08:10 +00:00
}
2024-01-18 21:17:26 +00:00
if err := ctl.Flush(); err != nil {
return fmt.Errorf("write job description: %w", err)
}
// At this point we can nolonger return a status code ///////////////////
2024-01-18 19:08:10 +00:00
var status string
for {
part, err := reader.NextPart()
if err != nil {
if err == io.EOF {
break
}
2024-01-18 21:17:26 +00:00
return fmt.Errorf("read next part: %w", err)
2024-01-18 19:08:10 +00:00
}
2024-01-18 21:17:26 +00:00
disposition, params, err := mime.ParseMediaType(part.Header.Get("Content-Disposition"))
2024-01-18 19:08:10 +00:00
if err != nil {
2024-01-18 21:17:26 +00:00
return fmt.Errorf("invalid Content-Disposition: %q: %w", part.Header.Get("Content-Disposition"), err)
2024-01-18 19:08:10 +00:00
}
filename := path.Clean(params["filename"])
switch {
case disposition == "attachment" && filename == "log.txt" || strings.HasPrefix(filename, "artifacts/"):
fsFilename := filepath.Join(job.Dir, filepath.FromSlash(filename))
if err := os.MkdirAll(filepath.Dir(fsFilename), 0755); err != nil {
2024-01-18 21:17:26 +00:00
return fmt.Errorf("saving file: %q: %w", filename, err)
2024-01-18 19:08:10 +00:00
}
fh, err := os.Create(fsFilename)
if err != nil {
2024-01-18 21:17:26 +00:00
return fmt.Errorf("saving file: %q: %w", filename, err)
2024-01-18 19:08:10 +00:00
}
if _, err := io.Copy(fh, part); err != nil {
2024-01-18 21:17:26 +00:00
return fmt.Errorf("saving file: %q: %w", filename, err)
2024-01-18 19:08:10 +00:00
}
if err := fh.Close(); err != nil {
2024-01-18 21:17:26 +00:00
return fmt.Errorf("saving file: %q: %w", filename, err)
2024-01-18 19:08:10 +00:00
}
case disposition == "form-data" && params["name"] == "status":
statusBytes, err := io.ReadAll(part)
if err != nil {
2024-01-18 21:17:26 +00:00
return fmt.Errorf("reading status: %q: %w", filename, err)
2024-01-18 19:08:10 +00:00
}
status = strings.TrimSpace(string(statusBytes))
default:
2024-01-18 21:17:26 +00:00
return fmt.Errorf("invalid Content-Disposition: %q", part.Header.Get("Content-Disposition"))
2024-01-18 19:08:10 +00:00
}
if err := part.Close(); err != nil {
2024-01-18 21:17:26 +00:00
return fmt.Errorf("closing part: %w", err)
2024-01-18 19:08:10 +00:00
}
}
switch status {
case "0":
2024-01-18 21:17:26 +00:00
if _, err := o.db.SwapJobStatus(job.ID, eclipse.StatusRunning, eclipse.StatusSucceeded); err != nil {
return err
2024-01-18 19:08:10 +00:00
}
case "":
2024-01-18 21:17:26 +00:00
return fmt.Errorf("no status: %w", io.ErrUnexpectedEOF)
2024-01-18 04:15:20 +00:00
default:
2024-01-18 19:08:10 +00:00
if _, err := o.db.SwapJobStatus(job.ID, eclipse.StatusRunning, eclipse.StatusFailed); err != nil {
2024-01-18 21:17:26 +00:00
return err
2024-01-18 07:43:02 +00:00
}
2024-01-18 01:08:22 +00:00
}
2024-01-18 19:08:10 +00:00
return nil
2024-01-18 01:08:22 +00:00
}