Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ef410b140 |
+3
-19
@@ -47,9 +47,7 @@ FROM golang:1.26.4-alpine@sha256:3ad57304ad93bbec8548a0437ad9e06a455660655d9af01
|
||||
COPY --from=lint /src/go.sum /dev/null
|
||||
COPY --from=test /src/go.sum /dev/null
|
||||
|
||||
RUN apk add --no-cache git
|
||||
# A tar-stream context keeps the sender's file owners, which git refuses.
|
||||
RUN git config --system --add safe.directory /src
|
||||
ARG VERSION=dev
|
||||
|
||||
WORKDIR /src
|
||||
|
||||
@@ -60,22 +58,8 @@ RUN go mod download
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Build (pure Go, no CGO required since we use modernc.org/sqlite).
|
||||
# The VERSION build arg when one is given, otherwise
|
||||
# `git describe --tags --always` on the .git in the build context. With
|
||||
# .git present, a version that is still empty, dev or unknown fails the
|
||||
# build: git is missing or could not read the checkout.
|
||||
ARG VERSION
|
||||
RUN VERSION="${VERSION:-$(git describe --tags --always)}"; \
|
||||
if [ -e .git ]; then \
|
||||
case "$VERSION" in ""|dev|unknown) \
|
||||
echo "version is '$VERSION' although .git is present" >&2; \
|
||||
exit 1 ;; \
|
||||
esac; \
|
||||
fi; \
|
||||
CGO_ENABLED=0 go build -trimpath \
|
||||
-ldflags="-s -w -X main.Version=${VERSION}" \
|
||||
-o /bsdaily ./cmd/bsdaily
|
||||
# Build (pure Go, no CGO required since we use modernc.org/sqlite)
|
||||
RUN CGO_ENABLED=0 go build -o /bsdaily ./cmd/bsdaily
|
||||
|
||||
# Runtime stage
|
||||
# alpine:3.21, 2026-06-28
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
.PHONY: all bootstrap setup check test lint fmt fmt-check build clean deps test-coverage test-integration install release release-snapshot docker hooks
|
||||
|
||||
# Stamped into the binary: the same `git describe` a plain `docker build .`
|
||||
# runs, or dev when it prints nothing (outside a git checkout, or where git is
|
||||
# missing). ?= so that a VERSION already in the environment takes precedence.
|
||||
VERSION ?= $(or $(shell git describe --tags --always 2>/dev/null),dev)
|
||||
# Version number
|
||||
VERSION := 0.1.0-dev
|
||||
|
||||
# Default target
|
||||
all: bsdaily
|
||||
@@ -38,7 +36,7 @@ lint:
|
||||
|
||||
# Build binary (pure Go; no CGO required since we use modernc.org/sqlite).
|
||||
bsdaily: internal/*/*.go cmd/bsdaily/*.go
|
||||
CGO_ENABLED=0 go build -ldflags "-X main.Version=$(VERSION)" -o $@ ./cmd/bsdaily
|
||||
CGO_ENABLED=0 go build -o $@ ./cmd/bsdaily
|
||||
|
||||
# Clean build artifacts.
|
||||
clean:
|
||||
|
||||
@@ -156,11 +156,6 @@ included to do exactly this and to rebuild the aggregate statistics
|
||||
|
||||
# How It Works
|
||||
|
||||
`cmd/bsdaily/main.go` only passes the version to `internal/cli` and exits with
|
||||
the status it returns. `internal/cli` holds the command line: the flags, the
|
||||
rules for combining them and the parsing of the dates they name.
|
||||
`internal/bsdaily` does the extraction.
|
||||
|
||||
A single run proceeds as follows:
|
||||
|
||||
1. **Find the snapshot.** Scan `SnapshotBase` for directories matching
|
||||
@@ -194,7 +189,6 @@ A single run proceeds as follows:
|
||||
bsdaily # extract the snapshot date minus one day
|
||||
bsdaily --date 2026-06-27 # extract a single specific day
|
||||
bsdaily --from 2026-06-01 --to 2026-06-27 # extract an inclusive range
|
||||
bsdaily --version # print the version and exit
|
||||
```
|
||||
|
||||
Flags:
|
||||
@@ -203,24 +197,9 @@ Flags:
|
||||
`--from`/`--to`.
|
||||
- `--from YYYY-MM-DD` — start of an inclusive range (requires `--to`).
|
||||
- `--to YYYY-MM-DD` — end of an inclusive range (requires `--from`).
|
||||
- `-v`, `--version` — print the version and exit.
|
||||
|
||||
With no flags, the tool extracts the day before the latest snapshot. All
|
||||
progress is logged as structured `slog` text to stderr; the first line of every
|
||||
run carries the version.
|
||||
|
||||
The version is set at link time and depends on how the binary was built:
|
||||
|
||||
- `docker build .` takes it from the `VERSION` build argument when one is given,
|
||||
otherwise from `git describe --tags --always` on the `.git` in the build
|
||||
context. The build fails if `.git` is there and the version still comes out
|
||||
empty, `dev` or `unknown`. With neither `.git` nor `VERSION`, the binary
|
||||
reports `dev`.
|
||||
- `script/docker`, `script/cibuild` and `make docker` pass the host's
|
||||
`git describe --tags --always --dirty` as `VERSION`, so on a modified tree the
|
||||
version ends in `-dirty`. When that prints nothing, they pass `unknown`.
|
||||
- `make` stamps the host's `git describe --tags --always`, without `-dirty`, or
|
||||
`dev` when that prints nothing.
|
||||
progress is logged as structured `slog` text to stderr.
|
||||
|
||||
## Merging dumps back into a database
|
||||
|
||||
|
||||
@@ -14,19 +14,11 @@ pre-1.0
|
||||
|
||||
# Next Step
|
||||
|
||||
Expand the `internal/bsdaily` tests beyond the compilation smoke test: unit
|
||||
tests for the extraction, verification, and atomic-publish paths.
|
||||
Expand tests beyond the compilation smoke test: unit tests for the extraction,
|
||||
verification, and atomic-publish paths.
|
||||
|
||||
# Completed Steps
|
||||
|
||||
- 2026-10-06: Moved the command line (flags, the rules for combining them, date
|
||||
parsing) from `cmd/bsdaily` into `internal/cli`, with unit tests for the flag
|
||||
rules and the dates; `cmd/bsdaily/main.go` is now a single call into it
|
||||
(https://git.eeqj.de/sneak/bsdaily/issues/8).
|
||||
- 2026-10-06: A plain `docker build .` and a host `make` build stamp the git tag
|
||||
or short commit into the binary, which `bsdaily` logs on the first line of
|
||||
every run and prints with `--version`
|
||||
(https://git.eeqj.de/sneak/bsdaily/issues/4).
|
||||
- 2026-10-06: Added the canonical `.golangci.yml`, moved the lint phase to
|
||||
golangci-lint v2.14.0, and fixed the code to pass it
|
||||
(https://git.eeqj.de/sneak/bsdaily/issues/6).
|
||||
|
||||
+112
-8
@@ -3,18 +3,122 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"git.eeqj.de/sneak/bsdaily/internal/cli"
|
||||
"git.eeqj.de/sneak/bsdaily/internal/bsdaily"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// Version is the git tag or short commit, set at link time with
|
||||
// -X main.Version=... by the Dockerfile and the Makefile. A build that sets
|
||||
// nothing, or sets it empty, reports dev.
|
||||
//
|
||||
//nolint:gochecknoglobals // -X can only set a package-level variable
|
||||
var Version string
|
||||
var (
|
||||
errDateExclusive = errors.New("--date and --from/--to are mutually exclusive")
|
||||
errFromRequiresTo = errors.New("--from requires --to")
|
||||
errToRequiresFrom = errors.New("--to requires --from")
|
||||
errFromAfterTo = errors.New("is after --to")
|
||||
)
|
||||
|
||||
func main() {
|
||||
os.Exit(cli.Main(Version))
|
||||
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
|
||||
Level: slog.LevelInfo,
|
||||
}))
|
||||
slog.SetDefault(logger)
|
||||
|
||||
var dateFlag, fromFlag, toFlag string
|
||||
|
||||
rootCmd := &cobra.Command{
|
||||
Use: "bsdaily",
|
||||
Short: "Extract a single day's data from the latest daily snapshot",
|
||||
SilenceUsage: true,
|
||||
RunE: func(_ *cobra.Command, _ []string) error {
|
||||
targetDates, err := parseTargetDates(dateFlag, fromFlag, toFlag)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = bsdaily.Run(targetDates)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
slog.Info("completed successfully")
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
rootCmd.Flags().StringVarP(&dateFlag, "date", "d", "",
|
||||
"target date to extract (YYYY-MM-DD); "+
|
||||
"defaults to snapshot date minus one day")
|
||||
rootCmd.Flags().StringVar(&fromFlag, "from", "",
|
||||
"start of date range to extract (YYYY-MM-DD, inclusive); use with --to")
|
||||
rootCmd.Flags().StringVar(&toFlag, "to", "",
|
||||
"end of date range to extract (YYYY-MM-DD, inclusive); use with --from")
|
||||
|
||||
err := rootCmd.Execute()
|
||||
if err != nil {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// parseTargetDates turns the --date, --from and --to flags into the days
|
||||
// to extract. It returns nil when none of them is set, which Run takes
|
||||
// to mean the snapshot date minus one day.
|
||||
func parseTargetDates(dateFlag, fromFlag, toFlag string) ([]time.Time, error) {
|
||||
hasDate := dateFlag != ""
|
||||
hasFrom := fromFlag != ""
|
||||
hasTo := toFlag != ""
|
||||
|
||||
// Validate mutual exclusivity
|
||||
if hasDate && (hasFrom || hasTo) {
|
||||
return nil, errDateExclusive
|
||||
}
|
||||
|
||||
if hasFrom != hasTo {
|
||||
if hasFrom {
|
||||
return nil, errFromRequiresTo
|
||||
}
|
||||
|
||||
return nil, errToRequiresFrom
|
||||
}
|
||||
|
||||
if hasDate {
|
||||
t, err := time.Parse("2006-01-02", dateFlag)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"invalid --date %q (expected YYYY-MM-DD): %w", dateFlag, err)
|
||||
}
|
||||
|
||||
return []time.Time{t}, nil
|
||||
}
|
||||
|
||||
if !hasFrom {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
from, err := time.Parse("2006-01-02", fromFlag)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"invalid --from %q (expected YYYY-MM-DD): %w", fromFlag, err)
|
||||
}
|
||||
|
||||
to, err := time.Parse("2006-01-02", toFlag)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"invalid --to %q (expected YYYY-MM-DD): %w", toFlag, err)
|
||||
}
|
||||
|
||||
if from.After(to) {
|
||||
return nil, fmt.Errorf("--from %s %w %s", fromFlag, errFromAfterTo, toFlag)
|
||||
}
|
||||
|
||||
var targetDates []time.Time
|
||||
|
||||
for d := from; !d.After(to); d = d.AddDate(0, 0, 1) {
|
||||
targetDates = append(targetDates, d)
|
||||
}
|
||||
|
||||
return targetDates, nil
|
||||
}
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
// Package cli is the bsdaily command line: the command, its flags, the
|
||||
// rules for combining them and the dates they name. The extraction itself
|
||||
// is in package bsdaily.
|
||||
package cli
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"os"
|
||||
|
||||
"git.eeqj.de/sneak/bsdaily/internal/bsdaily"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// Main runs the bsdaily command on the program's command-line arguments
|
||||
// and returns the status for the process to exit with. version is the
|
||||
// build's version; an empty one is reported as dev.
|
||||
func Main(version string) int {
|
||||
if version == "" {
|
||||
version = "dev"
|
||||
}
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
|
||||
Level: slog.LevelInfo,
|
||||
}))
|
||||
slog.SetDefault(logger)
|
||||
|
||||
var dateFlag, fromFlag, toFlag string
|
||||
|
||||
rootCmd := &cobra.Command{
|
||||
Use: "bsdaily",
|
||||
Short: "Extract a single day's data from the latest daily snapshot",
|
||||
Version: version,
|
||||
SilenceUsage: true,
|
||||
RunE: func(_ *cobra.Command, _ []string) error {
|
||||
slog.Info("starting", "version", version)
|
||||
|
||||
targetDates, err := ParseTargetDates(dateFlag, fromFlag, toFlag)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = bsdaily.Run(targetDates)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
slog.Info("completed successfully")
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
rootCmd.Flags().StringVarP(&dateFlag, "date", "d", "",
|
||||
"target date to extract (YYYY-MM-DD); "+
|
||||
"defaults to snapshot date minus one day")
|
||||
rootCmd.Flags().StringVar(&fromFlag, "from", "",
|
||||
"start of date range to extract (YYYY-MM-DD, inclusive); use with --to")
|
||||
rootCmd.Flags().StringVar(&toFlag, "to", "",
|
||||
"end of date range to extract (YYYY-MM-DD, inclusive); use with --from")
|
||||
|
||||
err := rootCmd.Execute()
|
||||
if err != nil {
|
||||
return 1
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
errDateExclusive = errors.New("--date and --from/--to are mutually exclusive")
|
||||
errFromRequiresTo = errors.New("--from requires --to")
|
||||
errToRequiresFrom = errors.New("--to requires --from")
|
||||
errFromAfterTo = errors.New("is after --to")
|
||||
)
|
||||
|
||||
// ParseTargetDates turns the --date, --from and --to flags into the days
|
||||
// to extract. It returns nil when none of them is set, which bsdaily.Run
|
||||
// takes to mean the snapshot date minus one day.
|
||||
func ParseTargetDates(dateFlag, fromFlag, toFlag string) ([]time.Time, error) {
|
||||
hasDate := dateFlag != ""
|
||||
hasFrom := fromFlag != ""
|
||||
hasTo := toFlag != ""
|
||||
|
||||
// Validate mutual exclusivity
|
||||
if hasDate && (hasFrom || hasTo) {
|
||||
return nil, errDateExclusive
|
||||
}
|
||||
|
||||
if hasFrom != hasTo {
|
||||
if hasFrom {
|
||||
return nil, errFromRequiresTo
|
||||
}
|
||||
|
||||
return nil, errToRequiresFrom
|
||||
}
|
||||
|
||||
if hasDate {
|
||||
t, err := time.Parse("2006-01-02", dateFlag)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"invalid --date %q (expected YYYY-MM-DD): %w", dateFlag, err)
|
||||
}
|
||||
|
||||
return []time.Time{t}, nil
|
||||
}
|
||||
|
||||
if !hasFrom {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
from, err := time.Parse("2006-01-02", fromFlag)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"invalid --from %q (expected YYYY-MM-DD): %w", fromFlag, err)
|
||||
}
|
||||
|
||||
to, err := time.Parse("2006-01-02", toFlag)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"invalid --to %q (expected YYYY-MM-DD): %w", toFlag, err)
|
||||
}
|
||||
|
||||
if from.After(to) {
|
||||
return nil, fmt.Errorf("--from %s %w %s", fromFlag, errFromAfterTo, toFlag)
|
||||
}
|
||||
|
||||
var targetDates []time.Time
|
||||
|
||||
for d := from; !d.After(to); d = d.AddDate(0, 0, 1) {
|
||||
targetDates = append(targetDates, d)
|
||||
}
|
||||
|
||||
return targetDates, nil
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
package cli_test
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.eeqj.de/sneak/bsdaily/internal/cli"
|
||||
)
|
||||
|
||||
func TestParseTargetDates(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const oneDay = "2026-05-14"
|
||||
|
||||
// want lists the expected days as YYYY-MM-DD.
|
||||
tests := []struct {
|
||||
name string
|
||||
date string
|
||||
from string
|
||||
to string
|
||||
want []string
|
||||
}{
|
||||
{name: "no flags"},
|
||||
{name: "single date", date: "2026-06-27", want: []string{"2026-06-27"}},
|
||||
{
|
||||
name: "range across a month end", from: "2026-06-29", to: "2026-07-02",
|
||||
want: []string{"2026-06-29", "2026-06-30", "2026-07-01", "2026-07-02"},
|
||||
},
|
||||
{name: "range of one day", from: oneDay, to: oneDay, want: []string{oneDay}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got, err := cli.ParseTargetDates(tt.date, tt.from, tt.to)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
days := make([]string, 0, len(got))
|
||||
for _, day := range got {
|
||||
days = append(days, day.Format("2006-01-02"))
|
||||
}
|
||||
|
||||
if !slices.Equal(days, tt.want) {
|
||||
t.Errorf("days = %v, want %v", days, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTargetDatesErrors(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const dateExclusive = "--date and --from/--to are mutually exclusive"
|
||||
|
||||
// wantErr is the whole error message. With startsWith set it is only how
|
||||
// the message starts: for a malformed date, the date parser's own
|
||||
// explanation follows it.
|
||||
tests := []struct {
|
||||
name string
|
||||
date string
|
||||
from string
|
||||
to string
|
||||
wantErr string
|
||||
startsWith bool
|
||||
}{
|
||||
{
|
||||
name: "from after to", from: "2026-03-02", to: "2026-03-01",
|
||||
wantErr: "--from 2026-03-02 is after --to 2026-03-01",
|
||||
},
|
||||
{name: "from without to", from: "2026-04-01", wantErr: "--from requires --to"},
|
||||
{name: "to without from", to: "2026-04-02", wantErr: "--to requires --from"},
|
||||
{
|
||||
name: "date with from", date: "2026-01-05", from: "2026-01-06",
|
||||
wantErr: dateExclusive,
|
||||
},
|
||||
{
|
||||
name: "date with to", date: "2026-01-13", to: "2026-01-14",
|
||||
wantErr: dateExclusive,
|
||||
},
|
||||
{
|
||||
name: "date with from and to", date: "2026-01-07",
|
||||
from: "2026-01-08", to: "2026-01-09",
|
||||
wantErr: dateExclusive,
|
||||
},
|
||||
{
|
||||
name: "malformed date", date: "27.06.2026",
|
||||
wantErr: `invalid --date "27.06.2026" (expected YYYY-MM-DD): `,
|
||||
startsWith: true,
|
||||
},
|
||||
{
|
||||
name: "date that does not exist", date: "2026-02-30",
|
||||
wantErr: `invalid --date "2026-02-30" (expected YYYY-MM-DD): `,
|
||||
startsWith: true,
|
||||
},
|
||||
{
|
||||
name: "malformed from", from: "2026-1-10", to: "2026-01-11",
|
||||
wantErr: `invalid --from "2026-1-10" (expected YYYY-MM-DD): `,
|
||||
startsWith: true,
|
||||
},
|
||||
{
|
||||
name: "malformed to", from: "2026-01-12", to: "tomorrow",
|
||||
wantErr: `invalid --to "tomorrow" (expected YYYY-MM-DD): `,
|
||||
startsWith: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := cli.ParseTargetDates(tt.date, tt.from, tt.to)
|
||||
|
||||
switch {
|
||||
case err == nil:
|
||||
t.Errorf("no error, want %q", tt.wantErr)
|
||||
case tt.startsWith && !strings.HasPrefix(err.Error(), tt.wantErr):
|
||||
t.Errorf("error = %q, want one starting %q", err, tt.wantErr)
|
||||
case !tt.startsWith && err.Error() != tt.wantErr:
|
||||
t.Errorf("error = %q, want %q", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user