feat(zddc-server): CLI flags, --version, CWD-default ZDDC_ROOT
Adds command-line flags to zddc-server alongside the existing env vars.
Each setting can be set via --<flag-name> or ZDDC_<NAME>; the flag wins
on conflict, the env var wins over the hard-coded default.
--root / ZDDC_ROOT (now defaults to CWD if both unset)
--addr / ZDDC_ADDR (:8443)
--tls-cert / ZDDC_TLS_CERT ("none" / empty / path)
--tls-key / ZDDC_TLS_KEY
--log-level / ZDDC_LOG_LEVEL (info)
--index-path / ZDDC_INDEX_PATH (.archive)
--email-header / ZDDC_EMAIL_HEADER (X-Auth-Request-Email)
--cors-origin / ZDDC_CORS_ORIGIN (https://zddc.varasys.io; "" disables)
--insecure-direct / ZDDC_INSECURE_DIRECT (false)
--help (prints flag list to stderr, exits 0)
--version (prints binary + embedded tool versions, exits 0)
So an operator can `cd /srv/zddc && zddc-server` with zero config — the
served root defaults to the current directory, and TLS defaults to a
self-signed cert. config.Load now takes []string (test-friendly: nil
skips flag parsing entirely; tests pass an empty slice for env-only
loads).
Adds a `version` package-level var in main.go injected at link time via
`-ldflags="-X main.version=..."`. The build.sh runs git describe against
zddc-server-v* tags; for in-flight commits between releases it produces
e.g. zddc-server-v0.0.7-19-gadb6904-dirty.
Adds an embedded versions manifest:
- Each tool's compute_build_label (in shared/build-lib.sh) writes a
sidecar <tool>.label to $BUILD_LABELS_DIR if that env var is set.
- Top-level build.sh sets BUILD_LABELS_DIR before running each tool's
build, then assembles zddc/internal/apps/embedded/versions.txt as
one `<app>=<build label>` line per app.
- apps.EmbeddedVersions() loads the manifest at runtime.
- main.go logs a compact summary on every startup; --version dumps
the full per-app label.
Removes the old cfg.BuildVersion field — the X-ZDDC-Source: embedded
header now uses the package-level main.version directly.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
adb6904397
commit
4ede42010a
13 changed files with 462 additions and 88 deletions
45
build.sh
45
build.sh
|
|
@ -8,6 +8,13 @@ SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
|
||||||
|
|
||||||
echo "=== Building ZDDC tools ==="
|
echo "=== Building ZDDC tools ==="
|
||||||
|
|
||||||
|
# Each tool's compute_build_label writes a sidecar `<tool>.label` here so
|
||||||
|
# we can assemble zddc/internal/apps/embedded/versions.txt below.
|
||||||
|
BUILD_LABELS_DIR="$SCRIPT_DIR/zddc/internal/apps/embedded/.labels"
|
||||||
|
rm -rf "$BUILD_LABELS_DIR"
|
||||||
|
mkdir -p "$BUILD_LABELS_DIR"
|
||||||
|
export BUILD_LABELS_DIR
|
||||||
|
|
||||||
sh "$SCRIPT_DIR/transmittal/build.sh" "${1:-}" "${2:-}"
|
sh "$SCRIPT_DIR/transmittal/build.sh" "${1:-}" "${2:-}"
|
||||||
sh "$SCRIPT_DIR/archive/build.sh" "${1:-}" "${2:-}"
|
sh "$SCRIPT_DIR/archive/build.sh" "${1:-}" "${2:-}"
|
||||||
sh "$SCRIPT_DIR/classifier/build.sh" "${1:-}" "${2:-}"
|
sh "$SCRIPT_DIR/classifier/build.sh" "${1:-}" "${2:-}"
|
||||||
|
|
@ -43,6 +50,26 @@ cp "$SCRIPT_DIR/classifier/dist/classifier.html" "$EMBED_DIR/classifier.html
|
||||||
cp "$SCRIPT_DIR/mdedit/dist/mdedit.html" "$EMBED_DIR/mdedit.html"
|
cp "$SCRIPT_DIR/mdedit/dist/mdedit.html" "$EMBED_DIR/mdedit.html"
|
||||||
echo "Populated $EMBED_DIR/ for //go:embed"
|
echo "Populated $EMBED_DIR/ for //go:embed"
|
||||||
|
|
||||||
|
# Assemble the embedded versions manifest from the per-tool .label sidecars
|
||||||
|
# written by shared/build-lib.sh's compute_build_label. The Go side reads
|
||||||
|
# this via //go:embed in internal/apps/versions.go and surfaces it in
|
||||||
|
# `zddc-server --version` output and the startup log line.
|
||||||
|
VERSIONS_FILE="$EMBED_DIR/versions.txt"
|
||||||
|
{
|
||||||
|
echo "# Generated by build.sh — do not edit. One <app>=<build label> per line."
|
||||||
|
for _tool in archive transmittal classifier mdedit landing; do
|
||||||
|
_label_file="$BUILD_LABELS_DIR/${_tool}.label"
|
||||||
|
if [ -f "$_label_file" ]; then
|
||||||
|
_label=$(cat "$_label_file")
|
||||||
|
else
|
||||||
|
_label=""
|
||||||
|
fi
|
||||||
|
printf '%s=%s\n' "$_tool" "$_label"
|
||||||
|
done
|
||||||
|
} > "$VERSIONS_FILE"
|
||||||
|
echo "Wrote $VERSIONS_FILE"
|
||||||
|
rm -rf "$BUILD_LABELS_DIR"
|
||||||
|
|
||||||
# Cross-compiled zddc-server binaries for Linux/macOS/Windows. Always built
|
# Cross-compiled zddc-server binaries for Linux/macOS/Windows. Always built
|
||||||
# inside docker.io/golang:1.24-alpine via podman (or docker), matching the
|
# inside docker.io/golang:1.24-alpine via podman (or docker), matching the
|
||||||
# helm/zddc-server-prod chart's `buildImage` so dev binaries are byte-for-byte
|
# helm/zddc-server-prod chart's `buildImage` so dev binaries are byte-for-byte
|
||||||
|
|
@ -70,6 +97,19 @@ GO_BUILD_IMAGE="${ZDDC_GO_BUILD_IMAGE:-docker.io/golang:1.24-alpine}"
|
||||||
GO_MOD_VOL="${ZDDC_GO_MOD_VOL:-zddc-go-mod}"
|
GO_MOD_VOL="${ZDDC_GO_MOD_VOL:-zddc-go-mod}"
|
||||||
GO_BUILD_VOL="${ZDDC_GO_BUILD_VOL:-zddc-go-cache}"
|
GO_BUILD_VOL="${ZDDC_GO_BUILD_VOL:-zddc-go-cache}"
|
||||||
|
|
||||||
|
# Compute the binary's own version: `git describe` if available (clean tag,
|
||||||
|
# or tag-N-gSHA[-dirty] for in-flight commits), else falls back to "dev".
|
||||||
|
# Surfaces via `zddc-server --version` and in the startup log line.
|
||||||
|
ZDDC_BINARY_VERSION=$(git -C "$SCRIPT_DIR" describe --tags --dirty --match 'zddc-server-v*' 2>/dev/null || true)
|
||||||
|
if [ -z "$ZDDC_BINARY_VERSION" ]; then
|
||||||
|
_sha=$(git -C "$SCRIPT_DIR" rev-parse --short=7 HEAD 2>/dev/null || echo unknown)
|
||||||
|
if ! git -C "$SCRIPT_DIR" diff --quiet HEAD 2>/dev/null; then
|
||||||
|
_sha="${_sha}-dirty"
|
||||||
|
fi
|
||||||
|
ZDDC_BINARY_VERSION="dev-${_sha}"
|
||||||
|
fi
|
||||||
|
echo " binary version: $ZDDC_BINARY_VERSION"
|
||||||
|
|
||||||
# Single container invocation, multiple cross-compile targets inside a
|
# Single container invocation, multiple cross-compile targets inside a
|
||||||
# `for` loop — avoids paying image-startup overhead 4×.
|
# `for` loop — avoids paying image-startup overhead 4×.
|
||||||
"$GO_RUNNER" run --rm \
|
"$GO_RUNNER" run --rm \
|
||||||
|
|
@ -79,6 +119,7 @@ GO_BUILD_VOL="${ZDDC_GO_BUILD_VOL:-zddc-go-cache}"
|
||||||
-w /src/zddc \
|
-w /src/zddc \
|
||||||
-e GOFLAGS=-mod=mod \
|
-e GOFLAGS=-mod=mod \
|
||||||
-e CGO_ENABLED=0 \
|
-e CGO_ENABLED=0 \
|
||||||
|
-e ZDDC_BINARY_VERSION="$ZDDC_BINARY_VERSION" \
|
||||||
"$GO_BUILD_IMAGE" \
|
"$GO_BUILD_IMAGE" \
|
||||||
sh -c '
|
sh -c '
|
||||||
set -e
|
set -e
|
||||||
|
|
@ -88,7 +129,9 @@ GO_BUILD_VOL="${ZDDC_GO_BUILD_VOL:-zddc-go-cache}"
|
||||||
case "$os" in windows) out="${out}.exe" ;; esac
|
case "$os" in windows) out="${out}.exe" ;; esac
|
||||||
echo " building $out"
|
echo " building $out"
|
||||||
GOOS="$os" GOARCH="$arch" \
|
GOOS="$os" GOARCH="$arch" \
|
||||||
go build -trimpath -ldflags="-s -w" -o "dist/$out" ./cmd/zddc-server
|
go build -trimpath \
|
||||||
|
-ldflags="-s -w -X main.version=${ZDDC_BINARY_VERSION}" \
|
||||||
|
-o "dist/$out" ./cmd/zddc-server
|
||||||
done
|
done
|
||||||
'
|
'
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -169,6 +169,7 @@ compute_build_label() {
|
||||||
fi
|
fi
|
||||||
channel="alpha"
|
channel="alpha"
|
||||||
build_label="v${_next_stable}-alpha · ${build_timestamp} · ${_sha}"
|
build_label="v${_next_stable}-alpha · ${build_timestamp} · ${_sha}"
|
||||||
|
_emit_build_label_sidecar "$_tool"
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
@ -180,6 +181,7 @@ compute_build_label() {
|
||||||
_date=$(date -u +"%Y-%m-%d")
|
_date=$(date -u +"%Y-%m-%d")
|
||||||
_sha=$(git -C "$root_dir" rev-parse --short=7 HEAD 2>/dev/null || echo "unknown")
|
_sha=$(git -C "$root_dir" rev-parse --short=7 HEAD 2>/dev/null || echo "unknown")
|
||||||
build_label="v${_next_stable}-${channel} · ${_date} · ${_sha}"
|
build_label="v${_next_stable}-${channel} · ${_date} · ${_sha}"
|
||||||
|
_emit_build_label_sidecar "$_tool"
|
||||||
return 0
|
return 0
|
||||||
;;
|
;;
|
||||||
'')
|
'')
|
||||||
|
|
@ -195,6 +197,18 @@ compute_build_label() {
|
||||||
channel="stable"
|
channel="stable"
|
||||||
is_red=0
|
is_red=0
|
||||||
build_label="v${build_version}"
|
build_label="v${build_version}"
|
||||||
|
_emit_build_label_sidecar "$_tool"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Write the resolved build label to a sidecar file the top-level build.sh
|
||||||
|
# reads to assemble zddc/internal/apps/embedded/versions.txt. No-op when
|
||||||
|
# BUILD_LABELS_DIR is not set in the env (tools built standalone).
|
||||||
|
_emit_build_label_sidecar() {
|
||||||
|
if [ -z "${BUILD_LABELS_DIR:-}" ]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
mkdir -p "$BUILD_LABELS_DIR"
|
||||||
|
printf '%s\n' "$build_label" > "$BUILD_LABELS_DIR/$1.label"
|
||||||
}
|
}
|
||||||
|
|
||||||
# Compute the next-stable target version for a tool — i.e., the patch-bump
|
# Compute the next-stable target version for a tool — i.e., the patch-bump
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,14 @@ package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
@ -20,16 +22,34 @@ import (
|
||||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
|
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// version is the binary's own version, injected at build time via
|
||||||
|
// `-ldflags="-X main.version=..."`. Defaults to "dev" for unreleased
|
||||||
|
// builds; release pipelines pass the result of `git describe --tags`.
|
||||||
|
var version = "dev"
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
cfg, err := config.Load()
|
cfg, err := config.Load(os.Args[1:])
|
||||||
|
if errors.Is(err, config.ErrHelpRequested) {
|
||||||
|
config.Usage(os.Stderr)
|
||||||
|
os.Exit(0)
|
||||||
|
}
|
||||||
|
if errors.Is(err, config.ErrVersionRequested) {
|
||||||
|
printVersions(os.Stdout)
|
||||||
|
os.Exit(0)
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "configuration error: %v\n", err)
|
fmt.Fprintf(os.Stderr, "configuration error: %v\n\nRun with --help for usage.\n", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
logRing := setupLogger(cfg.LogLevel)
|
logRing := setupLogger(cfg.LogLevel)
|
||||||
|
|
||||||
slog.Info("zddc-server starting", "root", cfg.Root, "addr", cfg.Addr)
|
embedded := apps.EmbeddedVersions()
|
||||||
|
slog.Info("zddc-server starting",
|
||||||
|
"version", version,
|
||||||
|
"root", cfg.Root,
|
||||||
|
"addr", cfg.Addr,
|
||||||
|
"embedded_apps", embeddedVersionsForLog(embedded))
|
||||||
|
|
||||||
// Build archive index
|
// Build archive index
|
||||||
slog.Info("building archive index...")
|
slog.Info("building archive index...")
|
||||||
|
|
@ -135,7 +155,53 @@ func setupApps(cfg config.Config) (*apps.Server, error) {
|
||||||
return nil, fmt.Errorf("create cache: %w", err)
|
return nil, fmt.Errorf("create cache: %w", err)
|
||||||
}
|
}
|
||||||
fetcher := apps.NewFetcher(cache, slog.Default())
|
fetcher := apps.NewFetcher(cache, slog.Default())
|
||||||
return apps.NewServer(cfg.Root, cache, fetcher, cfg.BuildVersion), nil
|
return apps.NewServer(cfg.Root, cache, fetcher, version), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// printVersions writes the binary version + the build label of every app
|
||||||
|
// embedded into the binary. Called by --version and reused for the
|
||||||
|
// startup log line.
|
||||||
|
func printVersions(w *os.File) {
|
||||||
|
fmt.Fprintf(w, "zddc-server %s\n\n", version)
|
||||||
|
embedded := apps.EmbeddedVersions()
|
||||||
|
if len(embedded) == 0 {
|
||||||
|
fmt.Fprintln(w, "Embedded tools: (none — run `sh build.sh` to populate)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Fprintln(w, "Embedded tools:")
|
||||||
|
keys := make([]string, 0, len(embedded))
|
||||||
|
for k := range embedded {
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
for _, k := range keys {
|
||||||
|
fmt.Fprintf(w, " %-12s %s\n", k, embedded[k])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// embeddedVersionsForLog formats the embedded-versions map as a single
|
||||||
|
// short string suitable for the startup `log/slog` line. Sorted by app
|
||||||
|
// name for stable output.
|
||||||
|
func embeddedVersionsForLog(embedded map[string]string) string {
|
||||||
|
if len(embedded) == 0 {
|
||||||
|
return "(none)"
|
||||||
|
}
|
||||||
|
keys := make([]string, 0, len(embedded))
|
||||||
|
for k := range embedded {
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
parts := make([]string, 0, len(keys))
|
||||||
|
for _, k := range keys {
|
||||||
|
// Strip any " · timestamp · sha" suffix so the log line stays compact;
|
||||||
|
// operators who want full detail run `zddc-server --version`.
|
||||||
|
v := embedded[k]
|
||||||
|
if i := strings.Index(v, " "); i > 0 {
|
||||||
|
v = v[:i]
|
||||||
|
}
|
||||||
|
parts = append(parts, k+"="+v)
|
||||||
|
}
|
||||||
|
return strings.Join(parts, " ")
|
||||||
}
|
}
|
||||||
|
|
||||||
// dispatch routes a request to the appropriate handler.
|
// dispatch routes a request to the appropriate handler.
|
||||||
|
|
|
||||||
|
|
@ -138,7 +138,6 @@ func TestDispatchAppsResolution(t *testing.T) {
|
||||||
Root: root,
|
Root: root,
|
||||||
IndexPath: ".archive",
|
IndexPath: ".archive",
|
||||||
EmailHeader: "X-Auth-Request-Email",
|
EmailHeader: "X-Auth-Request-Email",
|
||||||
BuildVersion: "test-build",
|
|
||||||
}
|
}
|
||||||
ring := handler.NewLogRing(10)
|
ring := handler.NewLogRing(10)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2113,7 +2113,7 @@ td[data-field="trackingNumber"] {
|
||||||
</svg>
|
</svg>
|
||||||
<div class="header-title-group">
|
<div class="header-title-group">
|
||||||
<span class="app-header__title">ZDDC Archive</span>
|
<span class="app-header__title">ZDDC Archive</span>
|
||||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.3-alpha · 2026-05-01 20:14:53 · fedc365-dirty</span></span>
|
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.3-alpha · 2026-05-01 20:41:59 · adb6904-dirty</span></span>
|
||||||
</div>
|
</div>
|
||||||
<button id="addDirectoryBtn" class="btn btn-primary">Add Local Directory</button>
|
<button id="addDirectoryBtn" class="btn btn-primary">Add Local Directory</button>
|
||||||
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh Data" style="font-size:1.1rem;">⟳</button>
|
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh Data" style="font-size:1.1rem;">⟳</button>
|
||||||
|
|
|
||||||
|
|
@ -1376,7 +1376,7 @@ body.help-open .app-header {
|
||||||
</svg>
|
</svg>
|
||||||
<div class="header-title-group">
|
<div class="header-title-group">
|
||||||
<span class="app-header__title">ZDDC Classifier</span>
|
<span class="app-header__title">ZDDC Classifier</span>
|
||||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.3-alpha · 2026-05-01 20:14:53 · fedc365-dirty</span></span>
|
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.3-alpha · 2026-05-01 20:41:59 · adb6904-dirty</span></span>
|
||||||
</div>
|
</div>
|
||||||
<button id="selectDirectoryBtn" class="btn btn-primary">Select Directory</button>
|
<button id="selectDirectoryBtn" class="btn btn-primary">Select Directory</button>
|
||||||
<button id="refreshBtn" class="btn btn-secondary hidden" title="Refresh and rescan directory">Refresh</button>
|
<button id="refreshBtn" class="btn btn-secondary hidden" title="Refresh and rescan directory">Refresh</button>
|
||||||
|
|
|
||||||
|
|
@ -866,7 +866,7 @@ body {
|
||||||
</g>
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
<span class="app-header__title">ZDDC Archive</span>
|
<span class="app-header__title">ZDDC Archive</span>
|
||||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.3-alpha · 2026-05-01 20:14:53 · fedc365-dirty</span></span>
|
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.3-alpha · 2026-05-01 20:41:59 · adb6904-dirty</span></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
<button id="theme-btn" class="btn btn-secondary" title="Theme: auto (follows OS)" aria-label="Theme: auto (follows OS)">◐</button>
|
<button id="theme-btn" class="btn btn-secondary" title="Theme: auto (follows OS)" aria-label="Theme: auto (follows OS)">◐</button>
|
||||||
|
|
|
||||||
|
|
@ -1668,7 +1668,7 @@ body.help-open .app-header {
|
||||||
</svg>
|
</svg>
|
||||||
<div class="header-title-group">
|
<div class="header-title-group">
|
||||||
<span class="app-header__title">ZDDC Markdown</span>
|
<span class="app-header__title">ZDDC Markdown</span>
|
||||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.3-alpha · 2026-05-01 20:14:53 · fedc365-dirty</span></span>
|
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.3-alpha · 2026-05-01 20:41:59 · adb6904-dirty</span></span>
|
||||||
</div>
|
</div>
|
||||||
<button id="select-directory" class="btn btn-primary" title="Select a Directory">Select Directory</button>
|
<button id="select-directory" class="btn btn-primary" title="Select a Directory">Select Directory</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -2210,7 +2210,7 @@ dialog.modal--narrow {
|
||||||
</svg>
|
</svg>
|
||||||
<div class="header-title-group">
|
<div class="header-title-group">
|
||||||
<span class="app-header__title">ZDDC Transmittal</span>
|
<span class="app-header__title">ZDDC Transmittal</span>
|
||||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.3-alpha · 2026-05-01 20:14:53 · fedc365-dirty</span></span>
|
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.3-alpha · 2026-05-01 20:41:59 · adb6904-dirty</span></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="app-header__spacer"></div>
|
<div class="app-header__spacer"></div>
|
||||||
<div class="app-header__icons">
|
<div class="app-header__icons">
|
||||||
|
|
|
||||||
6
zddc/internal/apps/embedded/versions.txt
Normal file
6
zddc/internal/apps/embedded/versions.txt
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
# Generated by build.sh — do not edit. One <app>=<build label> per line.
|
||||||
|
archive=v0.0.3-alpha · 2026-05-01 20:41:59 · adb6904-dirty
|
||||||
|
transmittal=v0.0.3-alpha · 2026-05-01 20:41:59 · adb6904-dirty
|
||||||
|
classifier=v0.0.3-alpha · 2026-05-01 20:41:59 · adb6904-dirty
|
||||||
|
mdedit=v0.0.3-alpha · 2026-05-01 20:41:59 · adb6904-dirty
|
||||||
|
landing=v0.0.3-alpha · 2026-05-01 20:41:59 · adb6904-dirty
|
||||||
39
zddc/internal/apps/versions.go
Normal file
39
zddc/internal/apps/versions.go
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
package apps
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
_ "embed"
|
||||||
|
)
|
||||||
|
|
||||||
|
// embeddedVersionsRaw is the manifest written by the top-level build.sh
|
||||||
|
// at compile time. Format is one `<app>=<build label>` line per app —
|
||||||
|
// e.g. `archive=v0.0.5-alpha · 2026-05-01 14:00:00 · abc1234`. An empty
|
||||||
|
// or missing value indicates the embedded slot was not populated (a fresh
|
||||||
|
// clone where build.sh hasn't run yet).
|
||||||
|
//
|
||||||
|
//go:embed embedded/versions.txt
|
||||||
|
var embeddedVersionsRaw []byte
|
||||||
|
|
||||||
|
// EmbeddedVersions returns the build label of each tool baked into the
|
||||||
|
// binary, keyed by canonical app name. Apps with empty values are
|
||||||
|
// omitted. Caller copies the map if mutation is needed.
|
||||||
|
func EmbeddedVersions() map[string]string {
|
||||||
|
out := make(map[string]string, 5)
|
||||||
|
for _, line := range strings.Split(string(embeddedVersionsRaw), "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" || strings.HasPrefix(line, "#") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
eq := strings.IndexByte(line, '=')
|
||||||
|
if eq <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key := strings.TrimSpace(line[:eq])
|
||||||
|
val := strings.TrimSpace(line[eq+1:])
|
||||||
|
if val != "" {
|
||||||
|
out[key] = val
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
@ -2,85 +2,221 @@ package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"net"
|
"net"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Config holds all runtime configuration loaded from environment variables.
|
// Config holds all runtime configuration. Each field can be set via a
|
||||||
|
// command-line flag (--<name>) or environment variable (ZDDC_<NAME>);
|
||||||
|
// the flag takes precedence when both are present.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Root string // ZDDC_ROOT — absolute path to the served file tree
|
Root string // --root / ZDDC_ROOT — served file tree (default: CWD)
|
||||||
Addr string // ZDDC_ADDR — bind address (default :8443)
|
Addr string // --addr / ZDDC_ADDR — bind address (default :8443)
|
||||||
TLSCert string // ZDDC_TLS_CERT — path to PEM cert; empty = self-signed
|
TLSCert string // --tls-cert / ZDDC_TLS_CERT — PEM cert path; "none" = plain HTTP; empty = self-signed
|
||||||
TLSKey string // ZDDC_TLS_KEY — path to PEM key; empty = self-signed
|
TLSKey string // --tls-key / ZDDC_TLS_KEY — PEM key path
|
||||||
TLSMode string // computed from TLSCert/TLSKey: none/selfsigned/provided
|
TLSMode string // computed: none/selfsigned/provided
|
||||||
LogLevel string // ZDDC_LOG_LEVEL — debug/info/warn/error (default info)
|
LogLevel string // --log-level / ZDDC_LOG_LEVEL — debug/info/warn/error (default info)
|
||||||
IndexPath string // ZDDC_INDEX_PATH — virtual segment name (default .archive)
|
IndexPath string // --index-path / ZDDC_INDEX_PATH — virtual archive prefix (default .archive)
|
||||||
EmailHeader string // ZDDC_EMAIL_HEADER — header name for user email (default X-Auth-Request-Email)
|
EmailHeader string // --email-header / ZDDC_EMAIL_HEADER — auth header name (default X-Auth-Request-Email)
|
||||||
CORSOrigins []string // ZDDC_CORS_ORIGIN — comma-separated CORS allowlist; default https://zddc.varasys.io; empty disables
|
CORSOrigins []string // --cors-origin / ZDDC_CORS_ORIGIN — comma-separated allowlist; default https://zddc.varasys.io; empty disables
|
||||||
|
|
||||||
// BuildVersion is baked into the X-ZDDC-Source header on embedded
|
|
||||||
// fallback responses so operators see exactly which binary's
|
|
||||||
// embedded HTML they're getting. Set at build time via -ldflags.
|
|
||||||
BuildVersion string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load reads configuration from environment variables and validates required fields.
|
// ErrHelpRequested is returned by Load when --help is passed; the caller
|
||||||
func Load() (Config, error) {
|
// should print Usage() to stderr and exit 0.
|
||||||
cfg := Config{
|
var ErrHelpRequested = errors.New("help requested")
|
||||||
Addr: getEnv("ZDDC_ADDR", ":8443"),
|
|
||||||
Root: os.Getenv("ZDDC_ROOT"),
|
// ErrVersionRequested is returned by Load when --version is passed; the
|
||||||
TLSCert: os.Getenv("ZDDC_TLS_CERT"),
|
// caller should print version info and exit 0.
|
||||||
TLSKey: os.Getenv("ZDDC_TLS_KEY"),
|
var ErrVersionRequested = errors.New("version requested")
|
||||||
LogLevel: getEnv("ZDDC_LOG_LEVEL", "info"),
|
|
||||||
IndexPath: getEnv("ZDDC_INDEX_PATH", ".archive"),
|
// Load reads configuration from CLI flags + environment variables.
|
||||||
EmailHeader: getEnv("ZDDC_EMAIL_HEADER", "X-Auth-Request-Email"),
|
//
|
||||||
CORSOrigins: parseCORSOrigins(),
|
// Precedence (highest → lowest): command-line flag, environment variable,
|
||||||
BuildVersion: getEnv("ZDDC_BUILD_VERSION", "dev"),
|
// hard-coded default. Special-cases:
|
||||||
|
// - --root / ZDDC_ROOT default to the current working directory if both
|
||||||
|
// are unset, so an operator can `cd /srv/zddc && zddc-server` with
|
||||||
|
// zero config.
|
||||||
|
// - --version and --help return distinguished sentinel errors; the caller
|
||||||
|
// handles printing and exit. Pass nil for args to skip flag parsing
|
||||||
|
// entirely (used by tests that set state via env vars only).
|
||||||
|
//
|
||||||
|
// Standard usage from main.go:
|
||||||
|
//
|
||||||
|
// cfg, err := config.Load(os.Args[1:])
|
||||||
|
// if errors.Is(err, config.ErrHelpRequested) { os.Exit(0) }
|
||||||
|
// if errors.Is(err, config.ErrVersionRequested) { /* print versions */ ; os.Exit(0) }
|
||||||
|
// if err != nil { ... }
|
||||||
|
func Load(args []string) (Config, error) {
|
||||||
|
fs := flag.NewFlagSet("zddc-server", flag.ContinueOnError)
|
||||||
|
// Discard flag's own error output; we wrap and return our own.
|
||||||
|
fs.SetOutput(io.Discard)
|
||||||
|
|
||||||
|
rootFlag := fs.String("root", os.Getenv("ZDDC_ROOT"),
|
||||||
|
"Path to the served file tree. Default: ZDDC_ROOT or the current directory.")
|
||||||
|
addrFlag := fs.String("addr", getEnv("ZDDC_ADDR", ":8443"),
|
||||||
|
"Listen address (host:port). Default: ZDDC_ADDR or :8443.")
|
||||||
|
tlsCertFlag := fs.String("tls-cert", os.Getenv("ZDDC_TLS_CERT"),
|
||||||
|
"Path to a PEM TLS certificate. \"none\" disables TLS (plain HTTP). Empty means self-signed.")
|
||||||
|
tlsKeyFlag := fs.String("tls-key", os.Getenv("ZDDC_TLS_KEY"),
|
||||||
|
"Path to the matching PEM TLS private key.")
|
||||||
|
logLevelFlag := fs.String("log-level", getEnv("ZDDC_LOG_LEVEL", "info"),
|
||||||
|
"Log level: debug, info, warn, error.")
|
||||||
|
indexPathFlag := fs.String("index-path", getEnv("ZDDC_INDEX_PATH", ".archive"),
|
||||||
|
"URL segment that triggers the virtual archive index (default \".archive\").")
|
||||||
|
emailHeaderFlag := fs.String("email-header", getEnv("ZDDC_EMAIL_HEADER", "X-Auth-Request-Email"),
|
||||||
|
"HTTP header carrying the authenticated user's email.")
|
||||||
|
corsOriginFlag := fs.String("cors-origin", "",
|
||||||
|
"Comma-separated CORS allowlist. Empty = CORS disabled. Default: ZDDC_CORS_ORIGIN or https://zddc.varasys.io.")
|
||||||
|
insecureDirectFlag := fs.Bool("insecure-direct", os.Getenv("ZDDC_INSECURE_DIRECT") == "1",
|
||||||
|
"Allow plain HTTP on non-loopback addresses (only safe behind an authenticating proxy).")
|
||||||
|
helpFlag := fs.Bool("help", false, "Print this help and exit.")
|
||||||
|
versionFlag := fs.Bool("version", false, "Print version info and exit.")
|
||||||
|
|
||||||
|
if args != nil {
|
||||||
|
if err := fs.Parse(args); err != nil {
|
||||||
|
if errors.Is(err, flag.ErrHelp) {
|
||||||
|
return Config{}, ErrHelpRequested
|
||||||
|
}
|
||||||
|
return Config{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if *helpFlag {
|
||||||
|
return Config{}, ErrHelpRequested
|
||||||
|
}
|
||||||
|
if *versionFlag {
|
||||||
|
return Config{}, ErrVersionRequested
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CORS has special semantics: "unset" → default origin list; "set to
|
||||||
|
// empty" → CORS disabled. The flag default is "" so we can't tell unset
|
||||||
|
// from explicit-empty via the flag alone — fs.Visit catches explicit
|
||||||
|
// flag use, and os.LookupEnv catches explicit env-var use.
|
||||||
|
corsFlagSet := false
|
||||||
|
if args != nil {
|
||||||
|
fs.Visit(func(f *flag.Flag) {
|
||||||
|
if f.Name == "cors-origin" {
|
||||||
|
corsFlagSet = true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := Config{
|
||||||
|
Root: *rootFlag,
|
||||||
|
Addr: *addrFlag,
|
||||||
|
TLSCert: *tlsCertFlag,
|
||||||
|
TLSKey: *tlsKeyFlag,
|
||||||
|
LogLevel: *logLevelFlag,
|
||||||
|
IndexPath: *indexPathFlag,
|
||||||
|
EmailHeader: *emailHeaderFlag,
|
||||||
|
CORSOrigins: resolveCORS(corsFlagSet, *corsOriginFlag),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default Root to the current working directory.
|
||||||
if cfg.Root == "" {
|
if cfg.Root == "" {
|
||||||
return Config{}, errors.New("ZDDC_ROOT environment variable is required")
|
cwd, err := os.Getwd()
|
||||||
|
if err != nil {
|
||||||
|
return Config{}, fmt.Errorf("--root not set and could not determine current directory: %w", err)
|
||||||
|
}
|
||||||
|
cfg.Root = cwd
|
||||||
}
|
}
|
||||||
|
|
||||||
info, err := os.Stat(cfg.Root)
|
info, err := os.Stat(cfg.Root)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Config{}, fmt.Errorf("ZDDC_ROOT %q is not accessible: %w", cfg.Root, err)
|
return Config{}, fmt.Errorf("--root %q is not accessible: %w", cfg.Root, err)
|
||||||
}
|
}
|
||||||
if !info.IsDir() {
|
if !info.IsDir() {
|
||||||
return Config{}, fmt.Errorf("ZDDC_ROOT %q is not a directory", cfg.Root)
|
return Config{}, fmt.Errorf("--root %q is not a directory", cfg.Root)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine TLS mode
|
// Determine TLS mode.
|
||||||
if cfg.TLSCert == "none" {
|
switch {
|
||||||
|
case cfg.TLSCert == "none":
|
||||||
cfg.TLSMode = "none"
|
cfg.TLSMode = "none"
|
||||||
} else if cfg.TLSCert == "" && cfg.TLSKey == "" {
|
case cfg.TLSCert == "" && cfg.TLSKey == "":
|
||||||
cfg.TLSMode = "selfsigned"
|
cfg.TLSMode = "selfsigned"
|
||||||
} else {
|
default:
|
||||||
cfg.TLSMode = "provided"
|
cfg.TLSMode = "provided"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cert and key must both be set or both be empty only when TLSMode == "provided"
|
|
||||||
if cfg.TLSMode == "provided" && (cfg.TLSCert == "") != (cfg.TLSKey == "") {
|
if cfg.TLSMode == "provided" && (cfg.TLSCert == "") != (cfg.TLSKey == "") {
|
||||||
return Config{}, errors.New("ZDDC_TLS_CERT and ZDDC_TLS_KEY must both be set or both be empty")
|
return Config{}, errors.New("--tls-cert and --tls-key must both be set or both be empty")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Plain HTTP mode trusts the email header from any client. That is only
|
// Plain HTTP mode trusts the email header from any client. Only safe
|
||||||
// safe behind an authenticating reverse proxy, so refuse to start when
|
// behind an authenticating reverse proxy. Refuse to start when binding
|
||||||
// binding plain HTTP to a non-loopback interface unless the operator has
|
// plain HTTP to a non-loopback interface unless the operator has
|
||||||
// explicitly acknowledged the deployment shape via ZDDC_INSECURE_DIRECT=1.
|
// explicitly acknowledged the deployment shape.
|
||||||
if cfg.TLSMode == "none" && !isLoopbackAddr(cfg.Addr) && os.Getenv("ZDDC_INSECURE_DIRECT") != "1" {
|
if cfg.TLSMode == "none" && !isLoopbackAddr(cfg.Addr) && !*insecureDirectFlag {
|
||||||
return Config{}, fmt.Errorf(
|
return Config{}, fmt.Errorf(
|
||||||
"ZDDC_TLS_CERT=none binds plain HTTP to %q which trusts %s headers from any client; "+
|
"--tls-cert=none binds plain HTTP to %q which trusts %s headers from any client; "+
|
||||||
"either use TLS (unset ZDDC_TLS_CERT or supply a cert), bind to loopback (127.0.0.1: or [::1]:), "+
|
"either use TLS (omit --tls-cert or supply a cert), bind to loopback (127.0.0.1: or [::1]:), "+
|
||||||
"or set ZDDC_INSECURE_DIRECT=1 to confirm an authenticating reverse proxy is in front",
|
"or pass --insecure-direct to confirm an authenticating reverse proxy is in front",
|
||||||
cfg.Addr, cfg.EmailHeader)
|
cfg.Addr, cfg.EmailHeader)
|
||||||
}
|
}
|
||||||
|
|
||||||
return cfg, nil
|
return cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Usage prints the flag list to w (stderr is the conventional caller).
|
||||||
|
// Returned format mirrors `flag.PrintDefaults` plus a one-line summary.
|
||||||
|
func Usage(w io.Writer) {
|
||||||
|
fmt.Fprintln(w, "Usage: zddc-server [flags]")
|
||||||
|
fmt.Fprintln(w, "")
|
||||||
|
fmt.Fprintln(w, "Each flag has an equivalent ZDDC_* environment variable; the flag wins on conflict.")
|
||||||
|
fmt.Fprintln(w, "ZDDC_ROOT defaults to the current working directory.")
|
||||||
|
fmt.Fprintln(w, "")
|
||||||
|
fmt.Fprintln(w, "Flags:")
|
||||||
|
fs := flag.NewFlagSet("zddc-server", flag.ContinueOnError)
|
||||||
|
fs.SetOutput(w)
|
||||||
|
// Re-register flags to populate Usage output (we discard the values).
|
||||||
|
fs.String("root", "", "Path to the served file tree. Default: ZDDC_ROOT or the current directory.")
|
||||||
|
fs.String("addr", ":8443", "Listen address (host:port). Default: ZDDC_ADDR or :8443.")
|
||||||
|
fs.String("tls-cert", "", "Path to a PEM TLS certificate. \"none\" disables TLS. Empty = self-signed.")
|
||||||
|
fs.String("tls-key", "", "Path to the matching PEM TLS private key.")
|
||||||
|
fs.String("log-level", "info", "Log level: debug, info, warn, error.")
|
||||||
|
fs.String("index-path", ".archive", "URL segment for the virtual archive index.")
|
||||||
|
fs.String("email-header", "X-Auth-Request-Email", "HTTP header carrying the authenticated user's email.")
|
||||||
|
fs.String("cors-origin", "", "Comma-separated CORS allowlist. Empty = CORS disabled.")
|
||||||
|
fs.Bool("insecure-direct", false, "Allow plain HTTP on non-loopback addresses.")
|
||||||
|
fs.Bool("help", false, "Print this help and exit.")
|
||||||
|
fs.Bool("version", false, "Print version info and exit.")
|
||||||
|
fs.PrintDefaults()
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveCORS implements the precedence rules for the CORS allowlist:
|
||||||
|
// - flag explicitly set → use flag value (empty = disabled)
|
||||||
|
// - else env var explicitly set → use env value (empty = disabled)
|
||||||
|
// - else → default to the canonical upstream
|
||||||
|
func resolveCORS(flagSet bool, flagValue string) []string {
|
||||||
|
if flagSet {
|
||||||
|
return parseCSV(flagValue)
|
||||||
|
}
|
||||||
|
if v, ok := os.LookupEnv("ZDDC_CORS_ORIGIN"); ok {
|
||||||
|
return parseCSV(v)
|
||||||
|
}
|
||||||
|
return []string{"https://zddc.varasys.io"}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseCSV splits a comma-separated list and trims whitespace. Empty
|
||||||
|
// returns nil (which the middleware treats as "CORS disabled").
|
||||||
|
func parseCSV(s string) []string {
|
||||||
|
if s == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
parts := strings.Split(s, ",")
|
||||||
|
out := make([]string, 0, len(parts))
|
||||||
|
for _, p := range parts {
|
||||||
|
if t := strings.TrimSpace(p); t != "" {
|
||||||
|
out = append(out, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// isLoopbackAddr reports whether addr binds only to a loopback interface.
|
// isLoopbackAddr reports whether addr binds only to a loopback interface.
|
||||||
// addr is in net.Listen form: "host:port", ":port", or "[ipv6]:port".
|
// addr is in net.Listen form: "host:port", ":port", or "[ipv6]:port".
|
||||||
// ":port" means all interfaces, so it is NOT loopback.
|
// ":port" means all interfaces, so it is NOT loopback.
|
||||||
|
|
@ -108,25 +244,3 @@ func getEnv(key, fallback string) string {
|
||||||
}
|
}
|
||||||
return fallback
|
return fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseCORSOrigins reads ZDDC_CORS_ORIGIN as a comma-separated allowlist.
|
|
||||||
// Unset → default to https://zddc.varasys.io. Empty string → CORS disabled.
|
|
||||||
// Origins are not validated as URLs here; the middleware does an exact-match
|
|
||||||
// comparison against the request's Origin header.
|
|
||||||
func parseCORSOrigins() []string {
|
|
||||||
v, ok := os.LookupEnv("ZDDC_CORS_ORIGIN")
|
|
||||||
if !ok {
|
|
||||||
return []string{"https://zddc.varasys.io"}
|
|
||||||
}
|
|
||||||
if v == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
parts := strings.Split(v, ",")
|
|
||||||
out := make([]string, 0, len(parts))
|
|
||||||
for _, p := range parts {
|
|
||||||
if s := strings.TrimSpace(p); s != "" {
|
|
||||||
out = append(out, s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -58,11 +58,20 @@ func TestLoad(t *testing.T) {
|
||||||
check func(*testing.T, Config)
|
check func(*testing.T, Config)
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "missing root",
|
name: "missing root defaults to CWD",
|
||||||
env: envSet{},
|
env: envSet{},
|
||||||
// ZDDC_ROOT not set
|
// ZDDC_ROOT not set → Load falls back to os.Getwd().
|
||||||
wantErr: true,
|
check: func(t *testing.T, cfg Config) {
|
||||||
errContains: "ZDDC_ROOT",
|
cwd, err := os.Getwd()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Getwd: %v", err)
|
||||||
|
}
|
||||||
|
// os.Stat resolves symlinks; so does Load via filepath behavior, so
|
||||||
|
// just compare the resolved values.
|
||||||
|
if cfg.Root != cwd {
|
||||||
|
t.Errorf("Root = %q, want CWD %q", cfg.Root, cwd)
|
||||||
|
}
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "root not a directory",
|
name: "root not a directory",
|
||||||
|
|
@ -151,7 +160,7 @@ func TestLoad(t *testing.T) {
|
||||||
"ZDDC_ADDR": ":8080",
|
"ZDDC_ADDR": ":8080",
|
||||||
},
|
},
|
||||||
wantErr: true,
|
wantErr: true,
|
||||||
errContains: "ZDDC_INSECURE_DIRECT",
|
errContains: "--insecure-direct",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "plain HTTP on 0.0.0.0 without insecure flag is rejected",
|
name: "plain HTTP on 0.0.0.0 without insecure flag is rejected",
|
||||||
|
|
@ -161,7 +170,7 @@ func TestLoad(t *testing.T) {
|
||||||
"ZDDC_ADDR": "0.0.0.0:8080",
|
"ZDDC_ADDR": "0.0.0.0:8080",
|
||||||
},
|
},
|
||||||
wantErr: true,
|
wantErr: true,
|
||||||
errContains: "ZDDC_INSECURE_DIRECT",
|
errContains: "--insecure-direct",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "plain HTTP on loopback is allowed",
|
name: "plain HTTP on loopback is allowed",
|
||||||
|
|
@ -199,7 +208,7 @@ func TestLoad(t *testing.T) {
|
||||||
"ZDDC_INSECURE_DIRECT": "true", // must be exactly "1"
|
"ZDDC_INSECURE_DIRECT": "true", // must be exactly "1"
|
||||||
},
|
},
|
||||||
wantErr: true,
|
wantErr: true,
|
||||||
errContains: "ZDDC_INSECURE_DIRECT",
|
errContains: "--insecure-direct",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -208,7 +217,7 @@ func TestLoad(t *testing.T) {
|
||||||
apply(tc.env)
|
apply(tc.env)
|
||||||
defer clearAll()
|
defer clearAll()
|
||||||
|
|
||||||
cfg, err := Load()
|
cfg, err := Load([]string{})
|
||||||
if tc.wantErr {
|
if tc.wantErr {
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatalf("Load() = nil error, want error containing %q", tc.errContains)
|
t.Fatalf("Load() = nil error, want error containing %q", tc.errContains)
|
||||||
|
|
@ -227,3 +236,87 @@ func TestLoad(t *testing.T) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestLoadFlags_OverrideEnv: --root flag wins over ZDDC_ROOT env var.
|
||||||
|
func TestLoadFlags_OverrideEnv(t *testing.T) {
|
||||||
|
envRoot := t.TempDir()
|
||||||
|
flagRoot := t.TempDir()
|
||||||
|
os.Setenv("ZDDC_ROOT", envRoot)
|
||||||
|
defer os.Unsetenv("ZDDC_ROOT")
|
||||||
|
|
||||||
|
cfg, err := Load([]string{"--root", flagRoot})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load: %v", err)
|
||||||
|
}
|
||||||
|
if cfg.Root != flagRoot {
|
||||||
|
t.Errorf("Root = %q, want flag value %q", cfg.Root, flagRoot)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLoadFlags_AddrLogLevelFromFlags: arbitrary flags override env defaults.
|
||||||
|
func TestLoadFlags_AddrLogLevelFromFlags(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
cfg, err := Load([]string{
|
||||||
|
"--root", root,
|
||||||
|
"--addr", "127.0.0.1:9999",
|
||||||
|
"--log-level", "debug",
|
||||||
|
"--index-path", ".myindex",
|
||||||
|
"--email-header", "X-User-Email",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load: %v", err)
|
||||||
|
}
|
||||||
|
if cfg.Addr != "127.0.0.1:9999" {
|
||||||
|
t.Errorf("Addr=%q", cfg.Addr)
|
||||||
|
}
|
||||||
|
if cfg.LogLevel != "debug" {
|
||||||
|
t.Errorf("LogLevel=%q", cfg.LogLevel)
|
||||||
|
}
|
||||||
|
if cfg.IndexPath != ".myindex" {
|
||||||
|
t.Errorf("IndexPath=%q", cfg.IndexPath)
|
||||||
|
}
|
||||||
|
if cfg.EmailHeader != "X-User-Email" {
|
||||||
|
t.Errorf("EmailHeader=%q", cfg.EmailHeader)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLoadFlags_CORSExplicitEmptyDisables: --cors-origin="" explicitly disables CORS.
|
||||||
|
func TestLoadFlags_CORSExplicitEmptyDisables(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
cfg, err := Load([]string{"--root", root, "--cors-origin", ""})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load: %v", err)
|
||||||
|
}
|
||||||
|
if len(cfg.CORSOrigins) != 0 {
|
||||||
|
t.Errorf("CORSOrigins = %v, want empty (CORS disabled by explicit empty flag)", cfg.CORSOrigins)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLoadFlags_HelpRequested: --help returns the sentinel error.
|
||||||
|
func TestLoadFlags_HelpRequested(t *testing.T) {
|
||||||
|
_, err := Load([]string{"--help"})
|
||||||
|
if !strings.Contains(err.Error(), "help requested") && err != ErrHelpRequested {
|
||||||
|
t.Errorf("got err=%v, want ErrHelpRequested", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLoadFlags_VersionRequested: --version returns the sentinel error.
|
||||||
|
func TestLoadFlags_VersionRequested(t *testing.T) {
|
||||||
|
_, err := Load([]string{"--version"})
|
||||||
|
if !strings.Contains(err.Error(), "version requested") && err != ErrVersionRequested {
|
||||||
|
t.Errorf("got err=%v, want ErrVersionRequested", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLoadFlags_RootFlagDefaultsToCWD: with no --root and no ZDDC_ROOT, falls back to CWD.
|
||||||
|
func TestLoadFlags_RootFlagDefaultsToCWD(t *testing.T) {
|
||||||
|
os.Unsetenv("ZDDC_ROOT")
|
||||||
|
cfg, err := Load([]string{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load: %v", err)
|
||||||
|
}
|
||||||
|
cwd, _ := os.Getwd()
|
||||||
|
if cfg.Root != cwd {
|
||||||
|
t.Errorf("Root=%q, want CWD=%q", cfg.Root, cwd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue