Initial commit
Test / test (push) Successful in 7m5s
Release / gates (push) Successful in 7m28s
Release / build (amd64, freebsd) (push) Successful in 2m52s
Release / build (amd64, linux) (push) Successful in 2m46s
Release / build (arm64, freebsd) (push) Successful in 2m22s
Release / build (arm64, linux) (push) Successful in 2m38s
Release / build (loong64, linux) (push) Successful in 2m7s
Release / build (riscv64, linux) (push) Successful in 2m17s
Release / release (push) Successful in 1m0s
Test / test (push) Successful in 7m5s
Release / gates (push) Successful in 7m28s
Release / build (amd64, freebsd) (push) Successful in 2m52s
Release / build (amd64, linux) (push) Successful in 2m46s
Release / build (arm64, freebsd) (push) Successful in 2m22s
Release / build (arm64, linux) (push) Successful in 2m38s
Release / build (loong64, linux) (push) Successful in 2m7s
Release / build (riscv64, linux) (push) Successful in 2m17s
Release / release (push) Successful in 1m0s
Assisted-by: GLM 5.3
This commit is contained in:
@@ -0,0 +1,119 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"sourcedock.dev/petrbalvin/volumen/internal/app"
|
||||
"sourcedock.dev/petrbalvin/volumen/internal/backup"
|
||||
"sourcedock.dev/petrbalvin/volumen/internal/config"
|
||||
)
|
||||
|
||||
func runExport(args []string, stderr io.Writer) int {
|
||||
flags := flag.NewFlagSet("export", flag.ContinueOnError)
|
||||
flags.SetOutput(stderr)
|
||||
configPath := flags.String("config", config.ResolveConfigPath(), "config path")
|
||||
outFlag := flags.String("out", "volumen-backup.tar.gz", "output archive path")
|
||||
if err := flags.Parse(args); err != nil {
|
||||
return flagExitCode(err)
|
||||
}
|
||||
if extra := extraArg(flags); extra != "" {
|
||||
fmt.Fprintf(stderr, "volumen export: unexpected argument %q\n", extra)
|
||||
return 2
|
||||
}
|
||||
cfg, err := config.Load(*configPath, config.Overrides{Port: config.PortUnset})
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "volumen export: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
if err := writeBackup(*outFlag, cfg); err != nil {
|
||||
fmt.Fprintf(stderr, "volumen export: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
fmt.Fprintf(stdout, "exported backup to %s\n", *outFlag)
|
||||
return 0
|
||||
}
|
||||
|
||||
// writeBackup writes an archive of the content directory and the
|
||||
// users, templates and tokens files. It holds no secrets: the config
|
||||
// file stays out, because the import ignores it and its session_key
|
||||
// would be a credential in a file that is easy to copy around.
|
||||
func writeBackup(outPath string, cfg *config.Config) error {
|
||||
dir := filepath.Dir(outPath)
|
||||
tmp, err := os.CreateTemp(dir, ".volumen-backup-*.tmp")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmpPath := tmp.Name()
|
||||
if err := tmp.Chmod(0o600); err != nil {
|
||||
tmp.Close()
|
||||
os.Remove(tmpPath)
|
||||
return err
|
||||
}
|
||||
if err := backup.Write(tmp, app.BackupOptions(cfg)); err != nil {
|
||||
tmp.Close()
|
||||
os.Remove(tmpPath)
|
||||
return err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
os.Remove(tmpPath)
|
||||
return err
|
||||
}
|
||||
// The rename happens last, so a failed export leaves the previous
|
||||
// archive in place rather than a truncated one.
|
||||
if err := os.Rename(tmpPath, outPath); err != nil {
|
||||
os.Remove(tmpPath)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func runImport(args []string, stderr io.Writer) int {
|
||||
flags := flag.NewFlagSet("import", flag.ContinueOnError)
|
||||
flags.SetOutput(stderr)
|
||||
configPath := flags.String("config", config.ResolveConfigPath(), "config path")
|
||||
if err := flags.Parse(args); err != nil {
|
||||
return flagExitCode(err)
|
||||
}
|
||||
if flags.NArg() != 1 {
|
||||
fmt.Fprintln(stderr, "volumen import: archive path required")
|
||||
return 2
|
||||
}
|
||||
cfg, err := config.Load(*configPath, config.Overrides{Port: config.PortUnset})
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "volumen import: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
if err := restoreBackup(flags.Arg(0), cfg); err != nil {
|
||||
fmt.Fprintf(stderr, "volumen import: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
fmt.Fprintln(stdout, "backup imported.")
|
||||
return 0
|
||||
}
|
||||
|
||||
// restoreBackup extracts an exported archive into the configured
|
||||
// locations. Entries outside the layout are skipped, and the extraction
|
||||
// is confined to the content directory by construction.
|
||||
func restoreBackup(archivePath string, cfg *config.Config) error {
|
||||
file, err := os.Open(archivePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
written, err := backup.Restore(file, app.BackupOptions(cfg))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if written == 0 {
|
||||
return errors.New("the archive holds no files this deployment recognises")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,453 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json/jsontext"
|
||||
json "encoding/json/v2"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"sourcedock.dev/petrbalvin/volumen/internal/config"
|
||||
"sourcedock.dev/petrbalvin/volumen/internal/password"
|
||||
"sourcedock.dev/petrbalvin/volumen/internal/payloads"
|
||||
"sourcedock.dev/petrbalvin/volumen/internal/post"
|
||||
"sourcedock.dev/petrbalvin/volumen/internal/scheduler"
|
||||
"sourcedock.dev/petrbalvin/volumen/internal/store"
|
||||
"sourcedock.dev/petrbalvin/volumen/internal/updater"
|
||||
"sourcedock.dev/petrbalvin/volumen/internal/users"
|
||||
"sourcedock.dev/petrbalvin/volumen/internal/version"
|
||||
"sourcedock.dev/petrbalvin/volumen/internal/webhooks"
|
||||
)
|
||||
|
||||
// extraArg names the first stray positional argument of a subcommand
|
||||
// that takes none, so `volumen export config.toml` is a usage error
|
||||
// rather than an export that quietly reads the default config.
|
||||
func extraArg(flags *flag.FlagSet) string {
|
||||
if flags.NArg() > 0 {
|
||||
return flags.Arg(0)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// writeCommandJSON writes one indented JSON document and the newline a
|
||||
// line-oriented consumer expects.
|
||||
func writeCommandJSON(w io.Writer, value any) {
|
||||
if err := json.MarshalWrite(w, value, jsontext.WithIndent(" "), json.Deterministic(true)); err != nil {
|
||||
slog.Warn("volumen: cannot encode the JSON output", "error", err)
|
||||
return
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
|
||||
// runStatus reports the configuration, the post count and the user
|
||||
// count, without changing anything on disk.
|
||||
func runStatus(args []string, stderr io.Writer) int {
|
||||
flags := flag.NewFlagSet("status", flag.ContinueOnError)
|
||||
flags.SetOutput(stderr)
|
||||
configPath := flags.String("config", config.ResolveConfigPath(), "config path to inspect")
|
||||
dataDir := flags.String("data", "", "data directory to inspect")
|
||||
usersFile := flags.String("users-file", "", "users.toml path to inspect")
|
||||
jsonOut := flags.Bool("json", false, "machine-readable output")
|
||||
if err := flags.Parse(args); err != nil {
|
||||
return flagExitCode(err)
|
||||
}
|
||||
if extra := extraArg(flags); extra != "" {
|
||||
fmt.Fprintf(stderr, "volumen status: unexpected argument %q\n", extra)
|
||||
return 2
|
||||
}
|
||||
|
||||
checks := map[string]string{}
|
||||
ok := true
|
||||
if _, err := os.Stat(*configPath); err == nil {
|
||||
checks["config"] = *configPath
|
||||
} else {
|
||||
checks["config"] = "missing (" + *configPath + ")"
|
||||
ok = false
|
||||
}
|
||||
cfg, err := config.Load(*configPath, config.Overrides{Port: config.PortUnset})
|
||||
if err != nil {
|
||||
checks["config_error"] = err.Error()
|
||||
ok = false
|
||||
} else {
|
||||
if verr := cfg.Validate(); verr != nil {
|
||||
checks["config_validate"] = verr.Error()
|
||||
ok = false
|
||||
}
|
||||
content := cfg.ContentDir
|
||||
if *dataDir != "" {
|
||||
content = *dataDir
|
||||
}
|
||||
st := store.New(store.Options{ContentDir: content, DefaultLang: cfg.Site.Language, RevisionLimit: cfg.RevisionLimit})
|
||||
posts := st.All()
|
||||
drafts := 0
|
||||
for _, p := range posts {
|
||||
if p.Status() == post.StatusDraft {
|
||||
drafts++
|
||||
}
|
||||
}
|
||||
checks["posts"] = fmt.Sprintf("%d (%d drafts)", len(posts), drafts)
|
||||
checks["content_dir"] = content
|
||||
if skipped := st.Unreadable(); len(skipped) > 0 {
|
||||
checks["posts_unreadable"] = fmt.Sprintf("%d file(s)", len(skipped))
|
||||
ok = false
|
||||
}
|
||||
|
||||
usersPath := cfg.UsersFile
|
||||
if *usersFile != "" {
|
||||
usersPath = *usersFile
|
||||
}
|
||||
usersObj := users.New(usersPath)
|
||||
if err := usersObj.Health(); err != nil {
|
||||
checks["users"] = "unreadable"
|
||||
ok = false
|
||||
} else if count := len(usersObj.All()); count == 0 {
|
||||
checks["users"] = "0 (open /admin to run the setup wizard)"
|
||||
} else {
|
||||
checks["users"] = fmt.Sprintf("%d", count)
|
||||
}
|
||||
}
|
||||
|
||||
if *jsonOut {
|
||||
writeCommandJSON(stdout, map[string]any{"ok": ok, "checks": checks})
|
||||
} else {
|
||||
for _, key := range []string{
|
||||
"config", "config_error", "config_validate", "content_dir",
|
||||
"posts", "posts_unreadable", "users",
|
||||
} {
|
||||
if value, present := checks[key]; present {
|
||||
fmt.Fprintf(stdout, "%-14s %s\n", key, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !ok {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func runDoctor(args []string, stderr io.Writer) int {
|
||||
flags := flag.NewFlagSet("doctor", flag.ContinueOnError)
|
||||
flags.SetOutput(stderr)
|
||||
configPath := flags.String("config", config.ResolveConfigPath(), "config path to check")
|
||||
jsonOut := flags.Bool("json", false, "machine-readable output")
|
||||
if err := flags.Parse(args); err != nil {
|
||||
return flagExitCode(err)
|
||||
}
|
||||
if extra := extraArg(flags); extra != "" {
|
||||
fmt.Fprintf(stderr, "volumen doctor: unexpected argument %q\n", extra)
|
||||
return 2
|
||||
}
|
||||
|
||||
type check struct {
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
}
|
||||
var checks []check
|
||||
add := func(name, status, detail string) {
|
||||
checks = append(checks, check{Name: name, Status: status, Detail: detail})
|
||||
}
|
||||
|
||||
if _, err := os.Stat(*configPath); err != nil {
|
||||
add("config", "fail", "missing "+*configPath)
|
||||
} else {
|
||||
add("config", "ok", *configPath)
|
||||
}
|
||||
cfg, err := config.Load(*configPath, config.Overrides{Port: config.PortUnset})
|
||||
if err != nil {
|
||||
add("config-parse", "fail", err.Error())
|
||||
} else if err := cfg.Validate(); err != nil {
|
||||
add("config-validate", "fail", err.Error())
|
||||
} else {
|
||||
add("config-validate", "ok", "")
|
||||
}
|
||||
|
||||
if cfg != nil {
|
||||
st := openStore(cfg)
|
||||
skipped := st.Unreadable()
|
||||
if len(skipped) > 0 {
|
||||
// A file that cannot be parsed is invisible to every read, so
|
||||
// it fails a check rather than warning.
|
||||
add("posts-readable", "fail",
|
||||
fmt.Sprintf("%d file(s) cannot be parsed: %s", len(skipped), skipped[0].Path))
|
||||
} else {
|
||||
add("posts-readable", "ok", "")
|
||||
}
|
||||
broken := 0
|
||||
for _, p := range st.All() {
|
||||
if _, err := p.HTML(); err != nil {
|
||||
broken++
|
||||
}
|
||||
}
|
||||
if broken > 0 {
|
||||
add("posts-render", "warn", fmt.Sprintf("%d post(s) fail to render", broken))
|
||||
} else {
|
||||
add("posts-render", "ok", "")
|
||||
}
|
||||
|
||||
usersObj := users.New(cfg.UsersFile)
|
||||
if err := usersObj.Health(); err != nil {
|
||||
add("users-file", "fail", err.Error())
|
||||
} else {
|
||||
add("users-file", "ok", "")
|
||||
if !usersObj.Any() {
|
||||
add("setup", "warn", "no accounts yet: run the first-run wizard at /admin")
|
||||
} else {
|
||||
add("setup", "ok", "")
|
||||
}
|
||||
weak := 0
|
||||
for _, user := range usersObj.All() {
|
||||
if password.NeedsRehash(user.PasswordHash) {
|
||||
weak++
|
||||
}
|
||||
}
|
||||
if weak > 0 {
|
||||
add("password-hashes", "warn", fmt.Sprintf("%d account(s) use weak scrypt parameters", weak))
|
||||
} else {
|
||||
add("password-hashes", "ok", "")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
failed := false
|
||||
for _, c := range checks {
|
||||
if c.Status == "fail" {
|
||||
failed = true
|
||||
}
|
||||
}
|
||||
if *jsonOut {
|
||||
writeCommandJSON(stdout, map[string]any{"ok": !failed, "checks": checks})
|
||||
} else {
|
||||
for _, c := range checks {
|
||||
line := fmt.Sprintf("%-16s %s", c.Name, c.Status)
|
||||
if c.Detail != "" {
|
||||
line += " " + c.Detail
|
||||
}
|
||||
fmt.Fprintln(stdout, line)
|
||||
}
|
||||
}
|
||||
if failed {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func runCheckUpdate(args []string, stderr io.Writer) int {
|
||||
flags := flag.NewFlagSet("check-update", flag.ContinueOnError)
|
||||
flags.SetOutput(stderr)
|
||||
jsonOut := flags.Bool("json", false, "machine-readable output")
|
||||
if err := flags.Parse(args); err != nil {
|
||||
return flagExitCode(err)
|
||||
}
|
||||
if extra := extraArg(flags); extra != "" {
|
||||
fmt.Fprintf(stderr, "volumen check-update: unexpected argument %q\n", extra)
|
||||
return 2
|
||||
}
|
||||
latest, err := updater.CheckLatest()
|
||||
if err != nil {
|
||||
// A network failure is an operational failure, not a usage one:
|
||||
// 2 stays reserved for wrong arguments.
|
||||
fmt.Fprintf(stderr, "volumen check-update: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
available := latest != "" && updater.CompareVersions(latest, version.Version()) > 0
|
||||
if *jsonOut {
|
||||
_ = json.MarshalWrite(stdout, map[string]any{
|
||||
"current": version.Version(), "latest": latest, "available": available,
|
||||
})
|
||||
} else if available {
|
||||
fmt.Fprintf(stdout, "volumen %s is available (running %s).\n", latest, version.Version())
|
||||
} else {
|
||||
fmt.Fprintf(stdout, "volumen %s is up to date.\n", version.Version())
|
||||
}
|
||||
if available {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func runPublishDue(args []string, stderr io.Writer) int {
|
||||
flags := flag.NewFlagSet("publish-due", flag.ContinueOnError)
|
||||
flags.SetOutput(stderr)
|
||||
configPath := flags.String("config", config.ResolveConfigPath(), "config path")
|
||||
dryRun := flags.Bool("dry-run", false, "only list due posts, do not modify files")
|
||||
jsonOut := flags.Bool("json", false, "machine-readable output")
|
||||
if err := flags.Parse(args); err != nil {
|
||||
return flagExitCode(err)
|
||||
}
|
||||
if extra := extraArg(flags); extra != "" {
|
||||
fmt.Fprintf(stderr, "volumen publish-due: unexpected argument %q\n", extra)
|
||||
return 2
|
||||
}
|
||||
cfg, err := config.Load(*configPath, config.Overrides{Port: config.PortUnset})
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "volumen publish-due: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
st := openStore(cfg)
|
||||
if *dryRun {
|
||||
// A dry run must not touch the tree, so the tombstone cleanup the
|
||||
// serving path performs is deliberately absent here.
|
||||
due := scheduler.DuePosts(st)
|
||||
slugs := make([]string, 0, len(due))
|
||||
for _, p := range due {
|
||||
slugs = append(slugs, p.Slug())
|
||||
}
|
||||
if *jsonOut {
|
||||
_ = json.MarshalWrite(stdout, map[string]any{"due": slugs, "dry_run": true})
|
||||
} else {
|
||||
for _, slug := range slugs {
|
||||
fmt.Fprintln(stdout, slug)
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
// Webhooks configured in the file are delivered here too, so a
|
||||
// front-end that rebuilds from them hears about a CLI publish.
|
||||
hooks := webhookManager(cfg)
|
||||
published, failures := scheduler.PublishDueWith(st, func(p *post.Post) {
|
||||
hooks.Fire("post.published", map[string]any{"post": payloads.BuildSummary(p)}, false)
|
||||
})
|
||||
hooks.Wait()
|
||||
if published == nil {
|
||||
published = []string{}
|
||||
}
|
||||
if *jsonOut {
|
||||
_ = json.MarshalWrite(stdout, map[string]any{
|
||||
"published": published, "failed": failures,
|
||||
})
|
||||
} else {
|
||||
for _, slug := range published {
|
||||
fmt.Fprintf(stdout, "published %s\n", slug)
|
||||
}
|
||||
}
|
||||
if failures > 0 {
|
||||
fmt.Fprintf(stderr, "volumen publish-due: %d post(s) could not be published\n", failures)
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// webhookManager builds a delivery manager from the file's [[webhooks]]
|
||||
// plus the admin-managed store beside the users file, the same merge the
|
||||
// server runs, so a short-lived command reaches the same endpoints.
|
||||
func webhookManager(cfg *config.Config) *webhooks.Manager {
|
||||
hooks := make([]webhooks.Webhook, 0, len(cfg.Webhooks))
|
||||
for _, hook := range cfg.Webhooks {
|
||||
hooks = append(hooks, webhooks.Webhook{
|
||||
URL: hook.URL, Secret: hook.Secret,
|
||||
Events: hook.Events, Enabled: hook.Delivers(),
|
||||
})
|
||||
}
|
||||
fileHooks, err := webhooks.LoadFile(filepath.Join(filepath.Dir(cfg.UsersFile), "webhooks.toml"))
|
||||
if err != nil {
|
||||
slog.Warn("volumen: ignoring the webhook store", "error", err)
|
||||
} else {
|
||||
hooks = append(hooks, fileHooks...)
|
||||
}
|
||||
return webhooks.NewManager(hooks, version.Version())
|
||||
}
|
||||
|
||||
func runValidate(args []string, stderr io.Writer) int {
|
||||
flags := flag.NewFlagSet("validate", flag.ContinueOnError)
|
||||
flags.SetOutput(stderr)
|
||||
configPath := flags.String("config", config.ResolveConfigPath(), "config path")
|
||||
jsonOut := flags.Bool("json", false, "machine-readable output")
|
||||
if err := flags.Parse(args); err != nil {
|
||||
return flagExitCode(err)
|
||||
}
|
||||
if extra := extraArg(flags); extra != "" {
|
||||
fmt.Fprintf(stderr, "volumen validate: unexpected argument %q\n", extra)
|
||||
return 2
|
||||
}
|
||||
cfg, err := config.Load(*configPath, config.Overrides{Port: config.PortUnset})
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "volumen validate: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
st := openStore(cfg)
|
||||
type problem struct {
|
||||
Slug string `json:"slug"`
|
||||
Path string `json:"path,omitempty"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
var problems []problem
|
||||
seen := map[string]string{}
|
||||
aliases := map[string]string{}
|
||||
|
||||
// A file the store cannot parse is invisible to every other command,
|
||||
// so it is reported first: a post that silently disappeared is the
|
||||
// failure an operator most needs named.
|
||||
for _, broken := range st.Unreadable() {
|
||||
problems = append(problems, problem{Path: broken.Path, Error: broken.Error})
|
||||
}
|
||||
|
||||
for _, p := range st.All() {
|
||||
if _, err := p.HTML(); err != nil {
|
||||
problems = append(problems, problem{Slug: p.Slug(), Error: err.Error()})
|
||||
}
|
||||
if other, dup := seen[p.Slug()]; dup {
|
||||
problems = append(problems, problem{
|
||||
Slug: p.Slug(),
|
||||
Error: fmt.Sprintf("duplicate slug (also %s)", other),
|
||||
})
|
||||
}
|
||||
seen[p.Slug()] = p.Path
|
||||
if !payloads.SlugRegex.MatchString(p.Slug()) {
|
||||
problems = append(problems, problem{Slug: p.Slug(), Error: "invalid slug format"})
|
||||
}
|
||||
if p.Title() == "" {
|
||||
problems = append(problems, problem{Slug: p.Slug(), Error: "missing title"})
|
||||
}
|
||||
if value, present := p.Metadata.Get("publish_at"); present && value != nil {
|
||||
if _, ok := p.DueAt(); !ok {
|
||||
problems = append(problems, problem{
|
||||
Slug: p.Slug(),
|
||||
Error: "publish_at is not a date, so the post stays withheld",
|
||||
})
|
||||
}
|
||||
}
|
||||
for _, alias := range p.Aliases() {
|
||||
if existing, dup := aliases[alias]; dup {
|
||||
problems = append(problems, problem{
|
||||
Slug: p.Slug(),
|
||||
Error: fmt.Sprintf("alias %q already used by %s", alias, existing),
|
||||
})
|
||||
continue
|
||||
}
|
||||
aliases[alias] = p.Slug()
|
||||
}
|
||||
}
|
||||
// An alias that shadows a live slug would shadow a real post.
|
||||
for alias, owner := range aliases {
|
||||
if _, clash := seen[alias]; clash {
|
||||
problems = append(problems, problem{
|
||||
Slug: owner,
|
||||
Error: fmt.Sprintf("alias %q collides with a live slug", alias),
|
||||
})
|
||||
}
|
||||
}
|
||||
if *jsonOut {
|
||||
_ = json.MarshalWrite(stdout, map[string]any{"problems": problems})
|
||||
} else if len(problems) == 0 {
|
||||
fmt.Fprintln(stdout, "content OK")
|
||||
} else {
|
||||
for _, p := range problems {
|
||||
where := p.Slug
|
||||
if where == "" {
|
||||
where = p.Path
|
||||
}
|
||||
fmt.Fprintf(stdout, "%s: %s\n", where, p.Error)
|
||||
}
|
||||
}
|
||||
if len(problems) > 0 {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,101 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||
|
||||
// Command volumen is a lightweight publishing platform for scientists: a
|
||||
// headless JSON API plus a server-rendered admin, on posts that are files.
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
|
||||
"sourcedock.dev/petrbalvin/volumen/internal/updater"
|
||||
"sourcedock.dev/petrbalvin/volumen/internal/version"
|
||||
)
|
||||
|
||||
func main() {
|
||||
os.Exit(run(os.Args[1:], os.Stderr))
|
||||
}
|
||||
|
||||
// stdout is indirected for tests.
|
||||
var stdout io.Writer = os.Stdout
|
||||
|
||||
func usage(stderr io.Writer) {
|
||||
fmt.Fprintf(stderr, `volumen %s - lightweight publishing platform for scientists
|
||||
|
||||
Usage: volumen <command> [options]
|
||||
|
||||
Commands:
|
||||
serve Start the publishing server
|
||||
status Show installation status
|
||||
doctor Check installation health
|
||||
check-update Compare with the latest Gitea release
|
||||
export Export posts, media, and users to a tar.gz
|
||||
import Import a backup archive
|
||||
publish-due Publish scheduled posts whose date arrived
|
||||
validate Validate the content directory
|
||||
version Show version
|
||||
`, version.Version())
|
||||
}
|
||||
|
||||
// run dispatches subcommands and returns the process exit code.
|
||||
func run(args []string, stderr io.Writer) int {
|
||||
if len(args) == 0 {
|
||||
usage(stderr)
|
||||
return 2
|
||||
}
|
||||
switch args[0] {
|
||||
case "help", "-h", "--help":
|
||||
usage(stdout)
|
||||
return 0
|
||||
case "-v", "--version":
|
||||
fmt.Fprintf(stdout, "volumen %s\n", version.Version())
|
||||
return 0
|
||||
case "serve":
|
||||
return runServe(args[1:], stderr)
|
||||
case "status":
|
||||
return runStatus(args[1:], stderr)
|
||||
case "doctor":
|
||||
return runDoctor(args[1:], stderr)
|
||||
case "check-update":
|
||||
return runCheckUpdate(args[1:], stderr)
|
||||
case "export":
|
||||
return runExport(args[1:], stderr)
|
||||
case "import":
|
||||
return runImport(args[1:], stderr)
|
||||
case "publish-due":
|
||||
return runPublishDue(args[1:], stderr)
|
||||
case "validate":
|
||||
return runValidate(args[1:], stderr)
|
||||
case "version":
|
||||
fmt.Fprintf(stdout, "volumen %s\n", version.Version())
|
||||
return 0
|
||||
default:
|
||||
fmt.Fprintf(stderr, "volumen: unknown command %q\n", args[0])
|
||||
usage(stderr)
|
||||
return 2
|
||||
}
|
||||
}
|
||||
|
||||
func (c *versionCache) refresh() {
|
||||
value := updater.UpdateAvailable(version.Version())
|
||||
c.mu.Lock()
|
||||
c.value = value
|
||||
c.ready = true
|
||||
c.inFlight = false
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
func (c *versionCache) check() (string, error) {
|
||||
// Never block a request: return the cached value, and start at most
|
||||
// one refresh at a time, because the caller is a page render that may
|
||||
// be anonymous.
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if !c.ready && !c.inFlight {
|
||||
c.inFlight = true
|
||||
go c.refresh()
|
||||
}
|
||||
return c.value, nil
|
||||
}
|
||||
@@ -0,0 +1,461 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"sourcedock.dev/petrbalvin/volumen/internal/updater"
|
||||
)
|
||||
|
||||
func TestRunWithoutArgs(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
if code := run(nil, &buf); code != 2 {
|
||||
t.Fatalf("exit code = %d, want 2", code)
|
||||
}
|
||||
if !strings.Contains(buf.String(), "Usage: volumen") {
|
||||
t.Fatalf("stderr = %q, want usage", buf.String())
|
||||
}
|
||||
if !strings.Contains(buf.String(), "lightweight publishing platform for scientists") {
|
||||
t.Fatalf("stderr = %q, want the platform definition", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunUnknownSubcommand(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
if code := run([]string{"frobnicate"}, &buf); code != 2 {
|
||||
t.Fatalf("exit code = %d, want 2", code)
|
||||
}
|
||||
if !strings.Contains(buf.String(), "unknown command") {
|
||||
t.Fatalf("stderr = %q", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunVersion(t *testing.T) {
|
||||
var out bytes.Buffer
|
||||
old := stdout
|
||||
stdout = &out
|
||||
defer func() { stdout = old }()
|
||||
if code := run([]string{"version"}, &bytes.Buffer{}); code != 0 {
|
||||
t.Fatalf("exit code = %d", code)
|
||||
}
|
||||
got := strings.TrimSpace(out.String())
|
||||
// <name> <version>: the recorded version carries the v prefix at a tag,
|
||||
// a pseudo-version in a plain checkout, or (devel) outside version control.
|
||||
if !strings.HasPrefix(got, "volumen ") || strings.TrimSpace(strings.TrimPrefix(got, "volumen ")) == "" {
|
||||
t.Fatalf("stdout = %q, want \"volumen <version>\"", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunServeBadFlags(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
if code := run([]string{"serve", "--nonsense"}, &buf); code != 2 {
|
||||
t.Fatalf("exit code = %d, want 2", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunServeBadConfig(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "config.toml")
|
||||
if err := os.WriteFile(path, []byte("not = valid = toml"), 0o600); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if code := run([]string{"serve", "--config", path}, &buf); code != 1 {
|
||||
t.Fatalf("exit code = %d, want 1; stderr = %q", code, buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func writeCLIConfig(t *testing.T) (string, string) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
content := filepath.Join(dir, "posts")
|
||||
if err := os.MkdirAll(content, 0o755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
path := filepath.Join(dir, "config.toml")
|
||||
body := "content_dir = \"" + content +
|
||||
"\"\nusers_file = \"" + filepath.Join(dir, "users.toml") + "\"\n"
|
||||
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
return path, content
|
||||
}
|
||||
|
||||
func captureStdout(t *testing.T, fn func() int) (int, string) {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
old := stdout
|
||||
stdout = &buf
|
||||
defer func() { stdout = old }()
|
||||
code := fn()
|
||||
return code, buf.String()
|
||||
}
|
||||
|
||||
func TestStatusAndDoctorCommands(t *testing.T) {
|
||||
configPath, content := writeCLIConfig(t)
|
||||
if err := os.WriteFile(filepath.Join(content, "a.md"),
|
||||
[]byte("+++\nslug = \"a\"\ntitle = \"A\"\ndate = 2026-01-01\n+++\nbody\n"), 0o644); err != nil {
|
||||
t.Fatalf("write post: %v", err)
|
||||
}
|
||||
|
||||
code, out := captureStdout(t, func() int {
|
||||
return run([]string{"status", "--config", configPath, "--json"}, &bytes.Buffer{})
|
||||
})
|
||||
if code != 0 || !strings.Contains(out, `"posts": "1 (0 drafts)"`) {
|
||||
t.Fatalf("status code=%d out=%s", code, out)
|
||||
}
|
||||
// With no accounts yet the report points at the wizard rather than
|
||||
// leaving the zero unexplained.
|
||||
if !strings.Contains(out, `"users": "0 (open /admin to run the setup wizard)"`) {
|
||||
t.Fatalf("status users hint missing: %s", out)
|
||||
}
|
||||
|
||||
code, out = captureStdout(t, func() int {
|
||||
return run([]string{"doctor", "--config", configPath, "--json"}, &bytes.Buffer{})
|
||||
})
|
||||
if code != 0 || !strings.Contains(out, `"ok": true`) {
|
||||
t.Fatalf("doctor code=%d out=%s", code, out)
|
||||
}
|
||||
if !strings.Contains(out, `"name": "setup"`) || !strings.Contains(out, "first-run wizard") {
|
||||
t.Fatalf("doctor setup check missing: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateCommand(t *testing.T) {
|
||||
configPath, content := writeCLIConfig(t)
|
||||
if err := os.WriteFile(filepath.Join(content, "a.md"),
|
||||
[]byte("+++\nslug = \"a\"\ntitle = \"A\"\n+++\nbody\n"), 0o644); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
code, out := captureStdout(t, func() int {
|
||||
return run([]string{"validate", "--config", configPath}, &bytes.Buffer{})
|
||||
})
|
||||
if code != 0 || !strings.Contains(out, "content OK") {
|
||||
t.Fatalf("code=%d out=%q", code, out)
|
||||
}
|
||||
|
||||
// A duplicate slug is reported.
|
||||
if err := os.WriteFile(filepath.Join(content, "b.md"),
|
||||
[]byte("+++\nslug = \"a\"\ntitle = \"B\"\n+++\nbody\n"), 0o644); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
code, out = captureStdout(t, func() int {
|
||||
return run([]string{"validate", "--config", configPath}, &bytes.Buffer{})
|
||||
})
|
||||
if code != 1 || !strings.Contains(out, "duplicate slug") {
|
||||
t.Fatalf("code=%d out=%q", code, out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishDueDryRun(t *testing.T) {
|
||||
configPath, content := writeCLIConfig(t)
|
||||
due := time.Now().UTC().Format("2006-01-02")
|
||||
body := "+++\nslug = \"due\"\npublish_at = " + due + "\n+++\nx\n"
|
||||
if err := os.WriteFile(filepath.Join(content, "due.md"), []byte(body), 0o644); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
code, out := captureStdout(t, func() int {
|
||||
return run([]string{"publish-due", "--config", configPath, "--dry-run"}, &bytes.Buffer{})
|
||||
})
|
||||
if code != 0 || !strings.Contains(out, "due") {
|
||||
t.Fatalf("code=%d out=%q", code, out)
|
||||
}
|
||||
// Real run publishes it.
|
||||
code, out = captureStdout(t, func() int {
|
||||
return run([]string{"publish-due", "--config", configPath}, &bytes.Buffer{})
|
||||
})
|
||||
if code != 0 || !strings.Contains(out, "published due") {
|
||||
t.Fatalf("code=%d out=%q", code, out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportImportRoundTrip(t *testing.T) {
|
||||
configPath, content := writeCLIConfig(t)
|
||||
if err := os.WriteFile(filepath.Join(content, "a.md"),
|
||||
[]byte("+++\nslug = \"a\"\ntitle = \"A\"\n+++\nbody\n"), 0o644); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
archive := filepath.Join(t.TempDir(), "backup.tar.gz")
|
||||
code, _ := captureStdout(t, func() int {
|
||||
return run([]string{"export", "--config", configPath, "--out", archive}, &bytes.Buffer{})
|
||||
})
|
||||
if code != 0 {
|
||||
t.Fatalf("export code = %d", code)
|
||||
}
|
||||
if err := os.Remove(filepath.Join(content, "a.md")); err != nil {
|
||||
t.Fatalf("remove: %v", err)
|
||||
}
|
||||
code, _ = captureStdout(t, func() int {
|
||||
return run([]string{"import", "--config", configPath, archive}, &bytes.Buffer{})
|
||||
})
|
||||
if code != 0 {
|
||||
t.Fatalf("import code = %d", code)
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join(content, "a.md"))
|
||||
if err != nil || !strings.Contains(string(raw), "slug = \"a\"") {
|
||||
t.Fatalf("restored post missing: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeBadConfig(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "config.toml")
|
||||
if err := os.WriteFile(path, []byte("not = valid = toml"), 0o600); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if code := run([]string{"serve", "--config", path}, &buf); code != 1 {
|
||||
t.Fatalf("exit code = %d, want 1; stderr = %q", code, buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckUpdateFlagParsing(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
if code := run([]string{"check-update", "--nonsense"}, &buf); code != 2 {
|
||||
t.Fatalf("exit code = %d, want 2", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckUpdateCommand(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"tag_name": "v99.0.0"})
|
||||
}))
|
||||
defer srv.Close()
|
||||
old := updater.ReleaseBase
|
||||
updater.ReleaseBase = srv.URL
|
||||
defer func() { updater.ReleaseBase = old }()
|
||||
|
||||
code, out := captureStdout(t, func() int {
|
||||
return run([]string{"check-update", "--json"}, &bytes.Buffer{})
|
||||
})
|
||||
if code != 1 || !strings.Contains(out, `"available":true`) {
|
||||
t.Fatalf("code=%d out=%s", code, out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateReportsContentProblems(t *testing.T) {
|
||||
configPath, content := writeCLIConfig(t)
|
||||
posts := map[string]string{
|
||||
"a.md": "+++\nslug = \"a\"\n+++\nbody\n", // missing title
|
||||
"b.md": "+++\nslug = \"Bad Slug\"\ntitle = \"B\"\n+++\nbody\n", // invalid slug
|
||||
"c.md": "+++\nslug = \"c\"\ntitle = \"C\"\naliases = [\"a\"]\n+++\nx\n", // alias clash
|
||||
}
|
||||
for name, body := range posts {
|
||||
if err := os.WriteFile(filepath.Join(content, name), []byte(body), 0o644); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
}
|
||||
code, out := captureStdout(t, func() int {
|
||||
return run([]string{"validate", "--config", configPath, "--json"}, &bytes.Buffer{})
|
||||
})
|
||||
if code != 1 {
|
||||
t.Fatalf("code = %d, want 1", code)
|
||||
}
|
||||
for _, want := range []string{"missing title", "invalid slug format", "collides with a live slug"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("output missing %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigOverrides(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cfg, err := loadConfig(filepath.Join(dir, "absent.toml"), filepath.Join(dir, "posts"),
|
||||
"127.0.0.1", 9191)
|
||||
if err != nil {
|
||||
t.Fatalf("loadConfig: %v", err)
|
||||
}
|
||||
if cfg.Server.Host != "127.0.0.1" || cfg.Server.Port != 9191 {
|
||||
t.Fatalf("host/port = %q/%d", cfg.Server.Host, cfg.Server.Port)
|
||||
}
|
||||
if cfg.ContentDir != filepath.Join(dir, "posts") {
|
||||
t.Fatalf("content dir = %q", cfg.ContentDir)
|
||||
}
|
||||
if _, err := loadConfig(filepath.Join(dir, "absent.toml"), "", "", 70000); err == nil {
|
||||
t.Fatal("an out-of-range port was accepted")
|
||||
}
|
||||
if _, err := loadConfig(filepath.Join(dir, "absent.toml"), "", "", 0); err != nil {
|
||||
t.Fatalf("zero means no override: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVersionCacheServesWithoutBlocking(t *testing.T) {
|
||||
// The first check spawns a refresh that would otherwise leave for the
|
||||
// real release host; the stub holds it until the assertions are done,
|
||||
// so the test touches no network and the goroutine cannot overwrite
|
||||
// the value the test is about to read. The defers only restore the
|
||||
// global after the refresh has read it, which the request arrival
|
||||
// orders.
|
||||
release := make(chan struct{})
|
||||
started := make(chan struct{})
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
close(started)
|
||||
<-release
|
||||
}))
|
||||
defer func() {
|
||||
close(release)
|
||||
srv.Close()
|
||||
}()
|
||||
old := updater.ReleaseBase
|
||||
updater.ReleaseBase = srv.URL
|
||||
defer func() {
|
||||
<-started
|
||||
updater.ReleaseBase = old
|
||||
}()
|
||||
|
||||
c := newVersionCache()
|
||||
if got, err := c.check(); got != "" || err != nil {
|
||||
t.Fatalf("check before the first refresh = %q, %v", got, err)
|
||||
}
|
||||
c.mu.Lock()
|
||||
c.value = "1.2.3"
|
||||
c.ready = true
|
||||
c.mu.Unlock()
|
||||
if got, _ := c.check(); got != "1.2.3" {
|
||||
t.Fatalf("check = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlagHelpExitsZero(t *testing.T) {
|
||||
var stderr bytes.Buffer
|
||||
if code := run([]string{"serve", "-h"}, &stderr); code != 0 {
|
||||
t.Fatalf("serve -h exit code = %d, want 0", code)
|
||||
}
|
||||
stderr.Reset()
|
||||
if code := run([]string{"export", "-nope"}, &stderr); code != 2 {
|
||||
t.Fatalf("an unknown flag exit code = %d, want 2", code)
|
||||
}
|
||||
stderr.Reset()
|
||||
if code := run([]string{"serve", "extra"}, &stderr); code != 2 {
|
||||
t.Fatalf("a stray argument exit code = %d, want 2", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusAndDoctorReportABrokenConfig(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
configPath := filepath.Join(dir, "config.toml")
|
||||
if err := os.WriteFile(configPath, []byte("[server]\nport = 70000\n"), 0o600); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
var out, errOut bytes.Buffer
|
||||
swapStdout(t, &out)
|
||||
if code := run([]string{"status", "--config", configPath, "--json"}, &errOut); code != 1 {
|
||||
t.Fatalf("status exit code = %d, want 1 for an invalid config", code)
|
||||
}
|
||||
if !strings.Contains(out.String(), "config_validate") {
|
||||
t.Fatalf("status did not report the validation failure: %s", out.String())
|
||||
}
|
||||
out.Reset()
|
||||
if code := run([]string{"doctor", "--config", configPath}, &errOut); code != 1 {
|
||||
t.Fatalf("doctor exit code = %d, want 1", code)
|
||||
}
|
||||
if !strings.Contains(out.String(), "config-validate") {
|
||||
t.Fatalf("doctor output = %s", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateReportsAnUnreadablePost(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
content := filepath.Join(dir, "posts")
|
||||
if err := os.MkdirAll(content, 0o755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
body := "+++\nslug = \"broken\"\ninvalid = = = \n+++\nx\n"
|
||||
if err := os.WriteFile(filepath.Join(content, "broken.md"), []byte(body), 0o644); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
configPath := filepath.Join(dir, "config.toml")
|
||||
settings := fmt.Sprintf("content_dir = %q\nusers_file = %q\n[site]\nbase_url = \"https://example.com\"\n",
|
||||
content, filepath.Join(dir, "users.toml"))
|
||||
if err := os.WriteFile(configPath, []byte(settings), 0o600); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
var out, errOut bytes.Buffer
|
||||
swapStdout(t, &out)
|
||||
if code := run([]string{"validate", "--config", configPath, "--json"}, &errOut); code != 1 {
|
||||
t.Fatalf("exit code = %d, want 1", code)
|
||||
}
|
||||
if !strings.Contains(out.String(), "broken.md") {
|
||||
t.Fatalf("validate did not name the unreadable file: %s", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
func swapStdout(t *testing.T, w io.Writer) {
|
||||
t.Helper()
|
||||
previous := stdout
|
||||
stdout = w
|
||||
t.Cleanup(func() { stdout = previous })
|
||||
}
|
||||
|
||||
// A listen failure (the port is taken) exits through the same cleanup
|
||||
// path as a signal: background work is waited for and the audit log
|
||||
// closed before the process gives up, and the exit code is 1.
|
||||
func TestServeFailsCleanlyOnABusyPort(t *testing.T) {
|
||||
release := make(chan struct{})
|
||||
started := make(chan struct{})
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
close(started)
|
||||
<-release
|
||||
}))
|
||||
defer func() {
|
||||
close(release)
|
||||
srv.Close()
|
||||
}()
|
||||
old := updater.ReleaseBase
|
||||
updater.ReleaseBase = srv.URL
|
||||
// The serve path starts a background refresh; the global is restored
|
||||
// only after that goroutine has read it.
|
||||
defer func() {
|
||||
<-started
|
||||
updater.ReleaseBase = old
|
||||
}()
|
||||
|
||||
occupied, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
defer occupied.Close()
|
||||
port := occupied.Addr().(*net.TCPAddr).Port
|
||||
|
||||
configPath, _ := writeCLIConfig(t)
|
||||
var stderr bytes.Buffer
|
||||
code := run([]string{"serve", "--config", configPath, "--port", strconv.Itoa(port)}, &stderr)
|
||||
if code != 1 {
|
||||
t.Fatalf("exit code = %d, want 1; stderr = %s", code, stderr.String())
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "address already in use") &&
|
||||
!strings.Contains(stderr.String(), "bind") {
|
||||
t.Fatalf("stderr does not name the listen failure: %s", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
// Every subcommand that takes no positional argument says so with exit
|
||||
// code 2 rather than ignoring the extra word.
|
||||
func TestSubcommandsRejectStrayArguments(t *testing.T) {
|
||||
for _, name := range []string{
|
||||
"serve", "status", "doctor", "check-update", "publish-due",
|
||||
"validate", "export",
|
||||
} {
|
||||
var stderr bytes.Buffer
|
||||
code := run([]string{name, "stray-argument"}, &stderr)
|
||||
if code != 2 {
|
||||
t.Errorf("%s: exit code = %d, want 2 (stderr = %s)", name, code, stderr.String())
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "unexpected argument") {
|
||||
t.Errorf("%s: stderr = %s", name, stderr.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"sourcedock.dev/petrbalvin/volumen/internal/app"
|
||||
"sourcedock.dev/petrbalvin/volumen/internal/config"
|
||||
"sourcedock.dev/petrbalvin/volumen/internal/scheduler"
|
||||
"sourcedock.dev/petrbalvin/volumen/internal/store"
|
||||
"sourcedock.dev/petrbalvin/volumen/internal/updater"
|
||||
"sourcedock.dev/petrbalvin/volumen/internal/version"
|
||||
)
|
||||
|
||||
// loadConfig resolves overrides from the common flags.
|
||||
func loadConfig(configPath, contentDir, host string, port int) (*config.Config, error) {
|
||||
overrides := config.Overrides{Port: config.PortUnset}
|
||||
if host != "" {
|
||||
overrides.Host = host
|
||||
}
|
||||
if port != 0 {
|
||||
if port < 1 || port > 65535 {
|
||||
return nil, fmt.Errorf("--port must be in 1..65535 (got %d)", port)
|
||||
}
|
||||
overrides.Port = port
|
||||
}
|
||||
if contentDir != "" {
|
||||
overrides.ContentDir = contentDir
|
||||
}
|
||||
return config.Load(configPath, overrides)
|
||||
}
|
||||
|
||||
func openStore(cfg *config.Config) *store.Store {
|
||||
return store.New(store.Options{ContentDir: cfg.ContentDir, DefaultLang: cfg.Site.Language, RevisionLimit: cfg.RevisionLimit})
|
||||
}
|
||||
|
||||
// The server bounds how long a connection may occupy a goroutine: the API
|
||||
// serves small responses, so the values are generous but finite, and
|
||||
// without them a client that dribbles a request holds a socket
|
||||
// indefinitely.
|
||||
const (
|
||||
readHeaderTimeout = 10 * time.Second
|
||||
readTimeout = 60 * time.Second
|
||||
writeTimeout = 120 * time.Second
|
||||
idleTimeout = 120 * time.Second
|
||||
shutdownGrace = 15 * time.Second
|
||||
|
||||
// maxHeaderBytes bounds a request line and its headers, which is what
|
||||
// a client can make the server buffer before any handler runs.
|
||||
maxHeaderBytes = 1 << 20
|
||||
)
|
||||
|
||||
func runServe(args []string, stderr io.Writer) int {
|
||||
flags := flag.NewFlagSet("serve", flag.ContinueOnError)
|
||||
flags.SetOutput(stderr)
|
||||
configPath := flags.String("config", config.ResolveConfigPath(), "path to config.toml")
|
||||
contentDir := flags.String("content", "", "override the posts directory")
|
||||
host := flags.String("host", "", "override the listen host")
|
||||
port := flags.Int("port", 0, "override the listen port")
|
||||
if err := flags.Parse(args); err != nil {
|
||||
return flagExitCode(err)
|
||||
}
|
||||
if extra := extraArg(flags); extra != "" {
|
||||
fmt.Fprintf(stderr, "volumen serve: unexpected argument %q\n", extra)
|
||||
return 2
|
||||
}
|
||||
cfg, err := loadConfig(*configPath, *contentDir, *host, *port)
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "volumen serve: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
if cfg.Server.LogFormat == config.LogFormatJSON {
|
||||
slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stderr, nil)))
|
||||
}
|
||||
st := openStore(cfg)
|
||||
server, err := app.New(cfg, st)
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "volumen serve: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
// Serving is the moment a stale tombstone becomes a hazard, so the
|
||||
// cleanup runs here rather than in the store constructor, where the
|
||||
// read-only CLI commands would trigger it too.
|
||||
st.CleanupStaleTombstones()
|
||||
|
||||
// Background update check feeding the admin banner.
|
||||
latest := newVersionCache()
|
||||
go latest.refresh()
|
||||
server.Admin.SetUpdateHooks(latest.check, func() (string, error) {
|
||||
return updater.SelfUpdate(version.Version())
|
||||
})
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
var background sync.WaitGroup
|
||||
|
||||
// The scheduler runs until the shutdown path closes its stop channel,
|
||||
// and the wait below depends on that happening.
|
||||
var schedulerStop chan struct{}
|
||||
if cfg.Scheduler.Enabled {
|
||||
schedulerStop = make(chan struct{})
|
||||
background.Go(func() {
|
||||
scheduler.Run(st, time.Duration(cfg.Scheduler.Interval)*time.Second,
|
||||
schedulerStop, server.PublishEvent)
|
||||
})
|
||||
}
|
||||
|
||||
listenAddr, err := cfg.ListenAddr()
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "volumen serve: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
addr := listenAddr.String()
|
||||
httpServer := &http.Server{
|
||||
Addr: addr,
|
||||
Handler: server.Handler(),
|
||||
ReadHeaderTimeout: readHeaderTimeout,
|
||||
ReadTimeout: readTimeout,
|
||||
WriteTimeout: writeTimeout,
|
||||
IdleTimeout: idleTimeout,
|
||||
MaxHeaderBytes: maxHeaderBytes,
|
||||
// net/http writes its own errors to stderr by default, outside
|
||||
// the log configuration; route them through slog instead.
|
||||
ErrorLog: slog.NewLogLogger(slog.Default().Handler(), slog.LevelError),
|
||||
}
|
||||
errCh := make(chan error, 1)
|
||||
background.Go(func() {
|
||||
slog.Info("volumen: listening", "addr", addr, "version", version.Version())
|
||||
errCh <- httpServer.ListenAndServe()
|
||||
})
|
||||
|
||||
exitCode := 0
|
||||
select {
|
||||
case err := <-errCh:
|
||||
if err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
fmt.Fprintf(stderr, "volumen serve: %v\n", err)
|
||||
// The listen failure path still flushes what the background
|
||||
// work started, the same as the signal path.
|
||||
exitCode = 1
|
||||
}
|
||||
if schedulerStop != nil {
|
||||
close(schedulerStop)
|
||||
}
|
||||
case <-ctx.Done():
|
||||
slog.Info("volumen: shutting down")
|
||||
if schedulerStop != nil {
|
||||
close(schedulerStop)
|
||||
}
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdownGrace)
|
||||
defer cancel()
|
||||
if err := httpServer.Shutdown(shutdownCtx); err != nil {
|
||||
slog.Warn("volumen: shutdown did not finish cleanly", "error", err)
|
||||
}
|
||||
}
|
||||
background.Wait()
|
||||
// Deliveries fired during the last requests run on the webhook
|
||||
// manager's own tracker, not on background: without this wait a
|
||||
// SIGTERM would kill a delivery mid-retry and the receiver would
|
||||
// never hear about the change.
|
||||
server.Webhooks.Wait()
|
||||
if err := server.Audit.Close(); err != nil {
|
||||
slog.Warn("volumen: cannot close the audit log", "error", err)
|
||||
}
|
||||
return exitCode
|
||||
}
|
||||
|
||||
// flagExitCode maps a flag parse failure to the process exit code: a
|
||||
// help request is not an error, anything else is a usage error.
|
||||
func flagExitCode(err error) int {
|
||||
if errors.Is(err, flag.ErrHelp) {
|
||||
return 0
|
||||
}
|
||||
return 2
|
||||
}
|
||||
|
||||
// versionCache memoises the release check so admin page renders never
|
||||
// block on the network.
|
||||
type versionCache struct {
|
||||
mu sync.Mutex
|
||||
value string
|
||||
ready bool
|
||||
inFlight bool
|
||||
}
|
||||
|
||||
func newVersionCache() *versionCache { return &versionCache{} }
|
||||
Reference in New Issue
Block a user