Files
verdande/cmd/verdande/main.go
T
Kristian WindandClaude Opus 5 0dbea0ea4b
Release / Build and publish image (push) Failing after 9m56s
feat: make it possible to see which version is actually running
"I have updated and restarted, but I cannot tell whether it worked" is
not a question the panel could answer. The version was on the startup
line and in the settings page, and nowhere you can reach from outside.

/healthz carries it now. That is the one endpoint answering without a
session, which makes it the only way to ask the question from a terminal
or from the panel itself. The "listening" line repeats it too, because
that is the line the Rune's done_regex matches and therefore the one
sitting at the bottom of the console after a deploy.

The update notice could also never have worked. It reads GitHub's
releases/latest, and this repository had only tags — a tag is not a
release, and that endpoint answers 404 for a repository without any. The
release workflow creates one now, after the image is pushed so a release
never announces a version that failed to build.

VERDANDE_UPDATE_CHECK is a Rune variable, so it can be switched on
without editing the container. Still off by default: it is an outbound
request, and that is a choice to make rather than a default to inherit.

Rune version 1 -> 2, so the panel offers the update.

Note that the check still cannot see anything while the repository is
private — releases/latest is 404 anonymously, exactly as the GHCR package
was. Making the repo public, or teaching the check to carry a token, is a
separate decision.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 14:35:36 +02:00

140 lines
4.4 KiB
Go

// Command verdande is the whole application: API, WebSocket, background jobs and
// the web interface, in one binary with SQLite underneath. There is no second
// process to run and no database to provision.
package main
import (
"context"
"errors"
"fmt"
"io/fs"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/kristianwind/verdande/internal/config"
"github.com/kristianwind/verdande/internal/httpapi"
"github.com/kristianwind/verdande/internal/jobs"
"github.com/kristianwind/verdande/internal/store"
)
// version is stamped at build time: -ldflags "-X main.version=v1.2.3"
var version = "dev"
func main() {
if err := run(); err != nil {
fmt.Fprintf(os.Stderr, "verdande: %v\n", err)
os.Exit(1)
}
}
func run() error {
cfg, err := config.Load()
if err != nil {
return err
}
log := httpapi.NewLogger(cfg.Dev)
log.Info("starting verdande", "version", version, "data_dir", cfg.DataDir, "base_url", cfg.BaseURL)
// The data volume is the only state that matters; create the whole layout up
// front so a failure here is a startup error rather than a surprise at the
// first upload or the first backup at three in the morning.
for _, dir := range []string{cfg.DataDir, cfg.FilesDir(), cfg.BackupsDir()} {
if err := os.MkdirAll(dir, 0o750); err != nil {
return fmt.Errorf("create %s: %w", dir, err)
}
}
db, err := store.Open(cfg.DBPath())
if err != nil {
return err
}
defer db.Close()
log.Info("database ready", "path", db.Path())
httpapi.Version = version
api := httpapi.New(cfg, db, log, webAssets(log))
// Background work: reminders, the nightly backup, and the trash emptying
// itself. Started before the listener so a reminder that came due while the
// process was down goes out immediately rather than on the next tick.
jobCtx, stopJobs := context.WithCancel(context.Background())
defer stopJobs()
runner := jobs.New(cfg, db, log, api.Mail(), api.Hub())
runner.SyncGmail = api.SyncGmail
runner.Start(jobCtx)
srv := &http.Server{
Addr: cfg.Addr,
Handler: api,
ReadHeaderTimeout: 10 * time.Second,
// No WriteTimeout: it would also cap the WebSocket and any long file
// download. Per-handler timeouts do that job with the right granularity.
IdleTimeout: 120 * time.Second,
}
errc := make(chan error, 1)
go func() {
// The version is repeated here rather than only on the "starting" line
// above: this is the line the Rune's done_regex matches, so it is the one
// sitting at the bottom of the panel console after a deploy — which is
// exactly when somebody wants to know what actually started.
log.Info("listening", "addr", cfg.Addr, "version", version)
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
errc <- err
}
}()
// SIGTERM is what Docker — and so the Yggdrasil panel — sends on stop. Draining
// in-flight requests before exiting is what keeps a restart from showing up as
// a handful of failed saves.
stop := make(chan os.Signal, 1)
signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
select {
case err := <-errc:
return fmt.Errorf("listen: %w", err)
case sig := <-stop:
log.Info("shutting down", "signal", sig.String())
}
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
return fmt.Errorf("shutdown: %w", err)
}
// Let the background jobs finish the pass they are in. A shutdown during a
// backup would otherwise leave a partial file that looks like a backup.
stopJobs()
runner.Wait()
// WAL mode leaves recent writes in the -wal file. Checkpointing on the way out
// means a backup taken of a stopped container is complete.
if _, err := db.Exec(`PRAGMA wal_checkpoint(TRUNCATE)`); err != nil {
log.Warn("wal checkpoint on shutdown", "err", err)
}
log.Info("stopped")
return nil
}
// webAssets returns the built frontend if it was compiled into this binary.
//
// The web build is embedded through a build tag so that `go build ./...` works on a
// clean checkout with no Node toolchain: without the tag the app serves the API
// alone and says so, rather than failing to compile because web/build is missing.
func webAssets(log logger) fs.FS {
assets, err := embeddedWeb()
if err != nil {
log.Warn("no web assets in this build; serving the API only", "err", err)
return nil
}
return assets
}
type logger interface {
Warn(msg string, args ...any)
}