package main import ( "context" "errors" "fmt" "net" "net/http" "os" "time" "golang.org/x/sync/errgroup" "git.pablomurad.com/pablo/patchwork/internal/config" "git.pablomurad.com/pablo/patchwork/internal/engine" "git.pablomurad.com/pablo/patchwork/internal/export" "git.pablomurad.com/pablo/patchwork/internal/render" "git.pablomurad.com/pablo/patchwork/internal/server" "git.pablomurad.com/pablo/patchwork/internal/source/folder" ) // cmdServe watches the content folder and serves the API and feeds, // rebuilding them after every scan that changed something. func cmdServe(ctx context.Context, args []string) error { fs := newFlags("serve", "[-addr host:port] [-poll-only]") cfgPath := configFlag(fs) addr := fs.String("addr", "", "listen address (default: http.addr from the config)") pollOnly := fs.Bool("poll-only", false, "don't use file events") if err := fs.Parse(args); err != nil { return err } a, err := openApp(*cfgPath) if err != nil { return err } defer a.Close() if *addr == "" { *addr = a.cfg.HTTP.Addr } srv := server.New(a.log) rebuild := func() { start := time.Now() t, err := render.Build(ctx, a.store, a.cfg) if err != nil { a.log.Error("build failed; still serving the previous build", "err", err) return } srv.Swap(t) a.log.Info("published", "files", len(t.Files), "revisions", len(t.Model.Revisions), "took", time.Since(start).Round(time.Millisecond)) } r := a.runner() r.PollOnly = *pollOnly g, ctx := errgroup.WithContext(ctx) g.Go(func() error { first := true err := r.Watch(ctx, a.cfg.Watch.Debounce.Duration, a.cfg.Watch.Poll.Duration, func(rep folder.Report, err error) { if err != nil { a.log.Error("scan failed", "err", err) return } printReport(rep, false) if first || changed(rep) { rebuild() } first = false }) if errors.Is(err, context.Canceled) { return nil } return err }) g.Go(func() error { a.log.Info("serving", "addr", *addr, "source", a.cfg.Source.Path, "db", a.cfg.DB) return srv.ListenAndServe(ctx, *addr) }) return g.Wait() } func changed(rep folder.Report) bool { for _, r := range rep.Results { if r.Action != engine.Unchanged || r.Restored { return true } } return false } func cmdExport(ctx context.Context, args []string) error { fs := newFlags("export", "-out DIR [-scan]") cfgPath := configFlag(fs) out := fs.String("out", "", "directory to write (replaced as a whole)") scan := fs.Bool("scan", false, "scan the content folder first") if err := fs.Parse(args); err != nil { return err } if *out == "" { fs.Usage() return errProblems } a, err := openApp(*cfgPath) if err != nil { return err } defer a.Close() if *scan { r := a.runner() r.Settle = 0 rep, err := r.Scan(ctx, false) if err != nil { return err } printReport(rep, false) } t, err := render.Build(ctx, a.store, a.cfg) if err != nil { return err } if err := export.Write(t, *out); err != nil { return err } fmt.Fprintf(stdout, "wrote %d files to %s\n", len(t.Files), *out) return nil } // cmdHealth exits 0 when a running `patchwork serve` answers /healthz. It // exists for container health checks, where images have no curl. func cmdHealth(args []string) error { fs := newFlags("health", "[-url http://127.0.0.1:8080/healthz]") cfgPath := configFlag(fs) url := fs.String("url", "", "health URL (default: built from http.addr)") if err := fs.Parse(args); err != nil { return err } if *url == "" { // Load applies PATCHWORK_HTTP_ADDR, so the env wins over the file. addr := config.Default().HTTP.Addr if cfg, err := config.Load(*cfgPath); err == nil { addr = cfg.HTTP.Addr } else if env := os.Getenv("PATCHWORK_HTTP_ADDR"); env != "" { addr = env } host, port, err := net.SplitHostPort(addr) if err != nil { return err } if host == "" || host == "0.0.0.0" || host == "::" { host = "127.0.0.1" } *url = "http://" + net.JoinHostPort(host, port) + "/healthz" } c := &http.Client{Timeout: 3 * time.Second} resp, err := c.Get(*url) if err != nil { return err } resp.Body.Close() if resp.StatusCode != http.StatusOK { return fmt.Errorf("%s: %s", *url, resp.Status) } return nil }