Compare commits

...

40 Commits

Author SHA1 Message Date
William Perron 2b3ff1aa04
improve instrumentation of internal notifier
6 months ago
William Perron 18165a827c
add some emojis
7 months ago
William Perron 3c4de5a8c3
enable propagation of the weekly notification spans
9 months ago
William Perron 451a113987
add root span for weekly reminder
9 months ago
William Perron 66a0fd92b7
add fly detector to otel setup
9 months ago
William Perron 96df84736b
mark some responses as ephemeral and enable cpu profiling
9 months ago
William Perron 13a65780d8
remove unused import
9 months ago
William Perron 619fa082ac
fix failing test
9 months ago
William Perron d52efdce94
fix lints and update logger
9 months ago
William Perron 8c7099796c
update sqlitexporter dep
9 months ago
William Perron a46de17b7c
refactor discord command span in its own middleware
10 months ago
William Perron 6b99919dd7
fix context TODO usage in main.go
10 months ago
William Perron d96cd1a0ee
bump system deps
10 months ago
William Perron e95f741820
bump sqliteexporter dependency and improve error handling for init tracing
10 months ago
William Perron 6404b508e8
update deps and go mod tidy
10 months ago
William Perron 3e8d49c0c0
add service name attribute and fix context for flush
10 months ago
William Perron 6b280e7b33
finish instrumenting with opentelemetry
10 months ago
William Perron cc45c55922
instrumented absences with opentelemetry
10 months ago
William Perron 27567d16a2
convert to opentelemetry part 1
10 months ago
William Perron 1646762081
wip: refactor command logging and tracing
10 months ago
William Perron 4c09153756
wip: implement embedded distributed tracing
10 months ago
William Perron 407d63d4e6
remove unused test function
11 months ago
William Perron 1097ce5fce
use table instead of view for better perf
11 months ago
William Perron 31f2813192
fix scheduled message emoji error
11 months ago
William Perron 171ef4ee98
Add `claimables` view to database
11 months ago
William Perron 73c327d51b
update provinces table with latest version
11 months ago
William Perron 60c5430594
add doc comment
11 months ago
William Perron 008d025fa0
apply correlation IDs to modal submits and message actions
11 months ago
William Perron 7f78dcdc67
fix tests
11 months ago
William Perron 0a9cc8d7e0
add correlation IDs and propagate ctx
11 months ago
William Perron b287a95c31
fix bug in logging middleware
11 months ago
William Perron 07252229e0
add interactive button to cancel last minute
11 months ago
William Perron b292db1050
refactor command logging middleware
11 months ago
William Perron a38c27ded2
add debug info for each command with total latency
11 months ago
William Perron 04befc00f1
go fmt
11 months ago
William Perron 3d670c420d
optimize discord command registration for faster startup and shutdown
11 months ago
William Perron c1d5e472f2
add last flush to /info command
11 months ago
William Perron 5cbaff0820
improve logging, use console writer
11 months ago
William Perron f0c401f604
add tests
11 months ago
William Perron b040694c2a
add basic audit log
12 months ago

@ -6,7 +6,7 @@ jobs:
steps:
- uses: actions/setup-go@v3
with:
go-version: 1.19
go-version: 1.21
- uses: actions/checkout@v3
- name: "fmt"
run: test -z $(go fmt ./...)

@ -3,13 +3,13 @@ WORKDIR /app
COPY . .
RUN mkdir ./bin; go build -buildvcs=false -o ./bin ./cmd/...
FROM ubuntu:22.04 as litestream
FROM ubuntu:23.10 as litestream
WORKDIR /download
RUN apt update -y && apt install -y wget tar
RUN wget https://github.com/benbjohnson/litestream/releases/download/v0.3.9/litestream-v0.3.9-linux-amd64.tar.gz; \
tar -zxf litestream-v0.3.9-linux-amd64.tar.gz;
FROM ubuntu:22.04
FROM ubuntu:23.10
WORKDIR /themis
COPY --from=builder /app/bin/themis-server /usr/local/bin/themis-server
COPY --from=litestream /download/litestream /usr/local/bin/litestream

@ -7,7 +7,7 @@ Discord App to allow EU4 players to take claims on regions and provinces.
### Requirements
To develop:
- [Go](https://go.dev/) version 1.19 or higher installed locally
- [Go](https://go.dev/) version 1.21 or higher installed locally
- `sqlite3` installed locally (already ships by default on most OSes)
To deploy:

@ -4,26 +4,54 @@ import (
"context"
"fmt"
"time"
"github.com/rs/zerolog/log"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
semconv "go.opentelemetry.io/otel/semconv/v1.24.0"
"go.opentelemetry.io/otel/trace"
)
func (s *Store) AddAbsence(ctx context.Context, session time.Time, userId string) error {
ctx, span := tracer.Start(ctx, "add_absence", trace.WithAttributes(
semconv.DBSystemSqlite,
semconv.DBSQLTable("absences"),
semconv.DBOperation("insert"),
attribute.String("user_id", userId),
attribute.String("session_date", session.Format(time.DateOnly)),
))
defer span.End()
if session.Weekday() != time.Monday {
log.Debug().Ctx(ctx).Msg(fmt.Sprintf("%s is not a monday", session))
span.RecordError(fmt.Errorf("%s is not a monday", session))
return fmt.Errorf("not a monday")
}
defer s.Audit(ctx, &AuditableEvent{
userId: userId,
eventType: EventAbsence,
})
tx, err := s.db.Begin()
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, "failed to begin transaction")
return fmt.Errorf("failed to begin transaction: %w", err)
}
defer tx.Commit() //nolint:errcheck
stmt, err := s.db.PrepareContext(ctx, "INSERT INTO absences (session_date, userid) VALUES (?, ?)")
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, "failed to prepare absence query")
return fmt.Errorf("failed to prepare absence query: %w", err)
}
_, err = stmt.ExecContext(ctx, session.Format(time.DateOnly), userId)
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, "failed to insert absence")
return fmt.Errorf("failed to insert absence: %w", err)
}
@ -31,19 +59,34 @@ func (s *Store) AddAbsence(ctx context.Context, session time.Time, userId string
}
func (s *Store) GetAbsentees(ctx context.Context, session time.Time) ([]string, error) {
ctx, span := tracer.Start(ctx, "get_absentees", trace.WithAttributes(
semconv.DBSystemSqlite,
semconv.DBSQLTable("absences"),
semconv.DBOperation("select"),
attribute.String("session_date", session.Format(time.DateOnly)),
))
defer span.End()
log.Debug().Ctx(ctx).Time("session", session).Msg("getting list of absentees")
tx, err := s.db.Begin()
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, "failed to begin transaction")
return nil, fmt.Errorf("failed to begin transaction: %w", err)
}
defer tx.Commit() //nolint:errcheck
stmt, err := s.db.PrepareContext(ctx, `SELECT userid FROM absences WHERE session_date = ?`)
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, "failed to prepare query")
return nil, fmt.Errorf("failed to prepare query: %w", err)
}
rows, err := stmt.QueryContext(ctx, session.Format(time.DateOnly))
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, "failed to execute query")
return nil, fmt.Errorf("failed to execute query: %w", err)
}
@ -65,22 +108,38 @@ func (s *Store) GetAbsentees(ctx context.Context, session time.Time) ([]string,
type Schedule map[string][]string
func (s *Store) GetSchedule(ctx context.Context, from, to time.Time) (Schedule, error) {
ctx, span := tracer.Start(ctx, "get_schedule", trace.WithAttributes(
semconv.DBSystemSqlite,
semconv.DBSQLTable("absences"),
semconv.DBOperation("select"),
attribute.String("from", from.Format(time.DateOnly)),
attribute.String("to", to.Format(time.DateOnly)),
))
defer span.End()
log.Debug().Ctx(ctx).Time("from", from).Time("to", to).Msg("getting next sessions schedule")
schedule := make(Schedule)
initSchedule(schedule, from, to)
tx, err := s.db.Begin()
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, "failed to begin transaction")
return nil, fmt.Errorf("failed to begin transaction: %w", err)
}
defer tx.Commit() //nolint:errcheck
stmt, err := s.db.PrepareContext(ctx, `SELECT session_date, userid FROM absences WHERE session_date BETWEEN ? AND ? ORDER BY session_date ASC`)
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, "failed to prepare query")
return nil, fmt.Errorf("failed to prepare query: %w", err)
}
rows, err := stmt.QueryContext(ctx, from.Format(time.DateOnly), to.Format(time.DateOnly))
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, "failed to execute query")
return nil, fmt.Errorf("failed to execute query: %w", err)
}
@ -89,14 +148,12 @@ func (s *Store) GetSchedule(ctx context.Context, from, to time.Time) (Schedule,
var user string
err = rows.Scan(&date, &user)
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, "failed to scan row")
return nil, fmt.Errorf("failed to scan row: %w", err)
}
if _, ok := schedule[date]; ok {
schedule[date] = append(schedule[date], user)
} else {
schedule[date] = []string{user}
}
}
return schedule, nil

@ -2,16 +2,20 @@ package themis
import (
"context"
"database/sql"
"fmt"
"testing"
"time"
"github.com/rs/zerolog"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestAddAbsence(t *testing.T) {
store, err := NewStore(fmt.Sprintf(TEST_CONN_STRING_PATTERN, "TestAddAbsence"))
db, err := sql.Open("sqlite3", fmt.Sprintf(TEST_CONN_STRING_PATTERN, "TestAddAbsence"))
require.NoError(t, err)
store, err := NewStore(db, zerolog.Nop())
require.NoError(t, err)
now := NextMonday()
@ -25,10 +29,15 @@ func TestAddAbsence(t *testing.T) {
absentees, err = store.GetAbsentees(context.TODO(), now)
assert.NoError(t, err)
assert.Equal(t, 1, len(absentees))
_, err = store.LastOf(context.TODO(), EventAbsence)
require.NoError(t, err)
}
func TestGetSchedule(t *testing.T) {
store, err := NewStore(fmt.Sprintf(TEST_CONN_STRING_PATTERN, "TestGetSchedule"))
db, err := sql.Open("sqlite3", fmt.Sprintf(TEST_CONN_STRING_PATTERN, "TestGetSchedule"))
require.NoError(t, err)
store, err := NewStore(db, zerolog.Nop())
require.NoError(t, err)
now := NextMonday()

@ -0,0 +1,148 @@
package themis
import (
"context"
"database/sql"
"fmt"
"time"
"github.com/rs/zerolog/log"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
semconv "go.opentelemetry.io/otel/semconv/v1.24.0"
"go.opentelemetry.io/otel/trace"
)
type EventType int
const (
EventFlush EventType = iota
EventClaim
EventUnclaim
EventAbsence
)
func (et EventType) String() string {
switch et {
case EventFlush:
return "FLUSH"
case EventClaim:
return "CLAIM"
case EventUnclaim:
return "UNCLAIM"
case EventAbsence:
return "ABSENT"
default:
return ""
}
}
func EventTypeFromString(ev string) (EventType, error) {
switch ev {
case "FLUSH":
return EventFlush, nil
case "CLAIM":
return EventClaim, nil
case "UNCLAIM":
return EventUnclaim, nil
case "ABSENT":
return EventAbsence, nil
default:
return EventType(9999), fmt.Errorf("no such event type: %s", ev)
}
}
type AuditableEvent struct {
userId string
eventType EventType
err error
}
// Audit writes to the audit table, returns nothing because it is meant to be
// used in a defered statement on functions that write to the database.
func (s *Store) Audit(ctx context.Context, ev *AuditableEvent) {
ctx, span := tracer.Start(ctx, "audit", trace.WithAttributes(
semconv.DBSystemSqlite,
semconv.DBSQLTable("audit_log"),
semconv.DBOperation("insert"),
attribute.String("user_id", ev.userId),
attribute.Stringer("event_type", ev.eventType),
))
defer span.End()
if ev.err == nil {
log.Debug().Ctx(ctx).Str("event_type", ev.eventType.String()).Str("userid", ev.userId).Msg("recording audit log")
ctx := context.Background()
tx, err := s.db.Begin()
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, "failed to start transaction")
log.Error().Ctx(ctx).Err(err).Msg("failed to start transaction")
}
defer tx.Commit() //nolint:errcheck
stmt, err := s.db.PrepareContext(ctx, "INSERT INTO audit_log (userid, event_type, ts) VALUES (?, ?, ?)")
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, "failed to prepare audit log insert")
log.Error().Ctx(ctx).Err(err).Msg("failed to prepare audit log insert")
}
if _, err := stmt.ExecContext(ctx, ev.userId, ev.eventType.String(), time.Now()); err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, "failed to insert audit log")
log.Error().Ctx(ctx).Err(err).Msg("failed to insert audit log")
}
}
}
type AuditEvent struct {
Id int
UserId string
EventType EventType
Timestamp time.Time
}
func (s *Store) LastOf(ctx context.Context, t EventType) (AuditEvent, error) {
ctx, span := tracer.Start(ctx, "find_last_audit_log", trace.WithAttributes(
semconv.DBSystemSqlite,
semconv.DBSQLTable("audit_log"),
semconv.DBOperation("select"),
attribute.Stringer("event_type", t),
))
defer span.End()
log.Debug().Ctx(ctx).Str("event_type", t.String()).Msg("finding last audit log")
stmt, err := s.db.PrepareContext(ctx, `SELECT id, userid, event_type, ts FROM audit_log WHERE event_type = ? ORDER BY ts DESC LIMIT 1`)
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, "failed to get last event")
return AuditEvent{}, fmt.Errorf("failed to get last event of type %s: %w", t.String(), err)
}
row := stmt.QueryRowContext(ctx, t.String())
ev := AuditEvent{}
var rawEventType string
err = row.Scan(&ev.Id, &ev.UserId, &rawEventType, &ev.Timestamp)
if err == sql.ErrNoRows {
span.RecordError(ErrNever)
return AuditEvent{}, ErrNever
}
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, "failed to scan row")
return AuditEvent{}, fmt.Errorf("failed to scan row: %w", err)
}
ev.EventType, err = EventTypeFromString(rawEventType)
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, "failed to parse event type")
return AuditEvent{}, fmt.Errorf("failed to parse event type: %w", err)
}
return ev, nil
}

@ -34,12 +34,6 @@ const (
CLAIM_TYPE_TRADE = "trade"
)
var claimTypeToColumn = map[ClaimType]string{
CLAIM_TYPE_AREA: "area",
CLAIM_TYPE_REGION: "region",
CLAIM_TYPE_TRADE: "trade_node",
}
type Claim struct {
ID int
Player string

@ -7,8 +7,10 @@ import (
"flag"
"fmt"
"net/http"
"net/url"
"os"
"os/signal"
"runtime/pprof"
"sort"
"strconv"
"strings"
@ -17,9 +19,21 @@ import (
"github.com/bwmarrin/discordgo"
_ "github.com/mattn/go-sqlite3"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/propagation"
"go.opentelemetry.io/otel/sdk/resource"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
semconv "go.opentelemetry.io/otel/semconv/v1.24.0"
"go.opentelemetry.io/otel/trace"
"go.wperron.io/flydetector"
"go.wperron.io/sqliteexporter"
"go.wperron.io/themis"
"go.wperron.io/themis/correlation"
)
const (
@ -27,19 +41,52 @@ const (
)
var (
dbFile = flag.String("db", "", "SQlite database file path")
dbFile = flag.String("db", "", "SQlite database file path.")
debug = flag.Bool("debug", false, "Set log level to DEBUG.")
cpuProfile = flag.String("cpuprofile", "", "Output file for pprof profiling.")
store *themis.Store
tracer trace.Tracer
propagator = propagation.TraceContext{}
)
type Handler func(s *discordgo.Session, i *discordgo.InteractionCreate)
type Handler func(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate) error
func main() {
log.Info().Msg("startup.")
start := time.Now()
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGKILL, syscall.SIGINT)
defer cancel()
flag.Parse()
zerolog.SetGlobalLevel(zerolog.InfoLevel)
if *debug {
zerolog.SetGlobalLevel(zerolog.DebugLevel)
}
log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stdout})
log.Logger = log.Logger.Hook(correlation.TraceContextHook{})
zerolog.DurationFieldUnit = time.Millisecond
if *cpuProfile != "" && os.Getenv("ENV") != "production" {
log.Info().Str("file", *cpuProfile).Msg("starting profiler")
f, err := os.Create(*cpuProfile)
if err != nil {
log.Fatal().Err(err).Msg("failed to create cpu profile output file")
}
_ = pprof.StartCPUProfile(f)
defer pprof.StopCPUProfile()
}
go func() {
if err := serve(":8080"); err != nil {
log.Error().Err(err).Msg("failed to serve requests")
}
cancel()
}()
err := touchDbFile(*dbFile)
if err != nil {
log.Fatal().Err(err).Msg("failed to touch database file")
@ -47,13 +94,23 @@ func main() {
connString := fmt.Sprintf(CONN_STRING_PATTERN, *dbFile)
store, err = themis.NewStore(connString)
log.Debug().Str("connection_string", connString).Msg("opening sqlite3 database")
db, err := sql.Open("sqlite3", connString)
if err != nil {
log.Fatal().Err(err).Msg("failed to open database")
}
store, err = themis.NewStore(db, log.Logger)
if err != nil {
log.Fatal().Err(err).Msg("failed to initialize database")
}
defer store.Close()
notifChan := make(chan struct{})
if err := initTracing(ctx, db); err != nil {
log.Fatal().Err(err).Msg("failed to initialize tracing")
}
notifChan := make(chan context.Context)
notifier := themis.NewNotifier(notifChan)
go notifier.Start(ctx)
@ -169,6 +226,12 @@ func main() {
Description: "Get the schedule for the following weeks.",
Type: discordgo.ChatApplicationCommand,
},
{
Name: "send-schedule",
Description: "Trigger the scheduled message. Admins only",
Type: discordgo.ChatApplicationCommand,
DefaultMemberPermissions: new(int64), // default 0 == admins only
},
{
Name: "absent",
Description: "Mark yourself as absent for a session",
@ -184,10 +247,9 @@ func main() {
},
}
handlers := map[string]Handler{
"info": func(s *discordgo.Session, i *discordgo.InteractionCreate) {
uptime, err := themis.Uptime()
"info": func(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate) error {
uptime, err := themis.Uptime(ctx)
if err != nil {
log.Error().Err(err).Msg("failed to get server uptime")
err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
@ -195,13 +257,13 @@ func main() {
},
})
if err != nil {
log.Error().Err(err).Msg("failed to respond to interaction")
return fmt.Errorf("failed to respond to interaction: %w", err)
}
return fmt.Errorf("failed to get server uptime: %w", err)
}
claimCount, uniquePlayers, err := store.CountClaims(ctx)
if err != nil {
log.Error().Err(err).Msg("failed to count claims")
err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
@ -209,24 +271,45 @@ func main() {
},
})
if err != nil {
log.Error().Err(err).Msg("failed to respond to interaction")
return fmt.Errorf("failed to respond to interaction: %w", err)
}
return fmt.Errorf("failed to count claims: %w", err)
}
ev, err := store.LastOf(ctx, themis.EventFlush)
var lastFlush string
if err != nil {
if err != themis.ErrNever {
err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "Oops, something went wrong! :(",
},
})
if err != nil {
return fmt.Errorf("failed to respond to interaction: %w", err)
}
return fmt.Errorf("failed get last flush event: %w", err)
}
lastFlush = "never"
} else {
lastFlush = ev.Timestamp.Format(time.DateTime)
}
err = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: fmt.Sprintf("Server has been up for %s, has %d claims from %d unique players", uptime, claimCount, uniquePlayers),
Content: fmt.Sprintf("Server has been up for %s, has %d claims from %d unique players.\nThe last time claims were flushed was: %s.", uptime, claimCount, uniquePlayers, lastFlush),
},
})
if err != nil {
log.Error().Err(err).Msg("failed to respond to interaction")
return fmt.Errorf("failed to respond to interaction: %w", err)
}
return nil
},
"list-claims": func(s *discordgo.Session, i *discordgo.InteractionCreate) {
"list-claims": func(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate) error {
claims, err := store.ListClaims(ctx)
if err != nil {
log.Error().Err(err).Msg("failed to list claims")
err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
@ -234,8 +317,9 @@ func main() {
},
})
if err != nil {
log.Error().Err(err).Msg("failed to respond to interaction")
return fmt.Errorf("failed to respond to interaction: %w", err)
}
return fmt.Errorf("failed to list claims: %w", err)
}
sb := strings.Builder{}
@ -251,13 +335,16 @@ func main() {
},
})
if err != nil {
log.Error().Err(err).Msg("failed to respond to interaction")
return fmt.Errorf("failed to respond to interaction: %w", err)
}
return nil
},
"claim": func(s *discordgo.Session, i *discordgo.InteractionCreate) {
"claim": func(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate) error {
if i.Type == discordgo.InteractionApplicationCommandAutocomplete {
log.Debug().Ctx(ctx).Msg("command type interaction autocomplete")
// TODO(wperron) fix this
handleClaimAutocomplete(ctx, store, s, i)
return
return nil
}
opts := i.ApplicationCommandData().Options
@ -265,28 +352,29 @@ func main() {
err = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Flags: discordgo.MessageFlagsEphemeral,
Content: "`claim-type` and `name` are mandatory parameters",
},
})
if err != nil {
log.Error().Err(err).Msg("failed to respond to interaction")
return fmt.Errorf("failed to respond to interaction: %w", err)
}
return
return nil
}
claimType, err := themis.ClaimTypeFromString(opts[0].StringValue())
if err != nil {
log.Error().Err(err).Str("claim_type", opts[0].StringValue()).Msg("failed to parse claim")
err = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Flags: discordgo.MessageFlagsEphemeral,
Content: "You can only take claims of types `area`, `region` or `trade`",
},
})
if err != nil {
log.Error().Err(err).Msg("failed to respond to interaction")
return fmt.Errorf("failed to respond to interaction: %w", err)
}
return
return fmt.Errorf("failed to parse claim: %w", err)
}
name := opts[1].StringValue()
@ -299,6 +387,7 @@ func main() {
_, err = store.Claim(ctx, userId, player, name, claimType)
if err != nil {
// TODO(wperron) fix this error cast
conflict, ok := err.(themis.ErrConflict)
if ok {
sb := strings.Builder{}
@ -311,26 +400,27 @@ func main() {
err = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Flags: discordgo.MessageFlagsEphemeral,
Content: sb.String(),
},
})
if err != nil {
log.Error().Err(err).Msg("failed to respond to interaction")
return fmt.Errorf("failed to respond to interaction")
}
return
return nil
}
log.Error().Err(err).Msg("failed to acquire claim")
err = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Flags: discordgo.MessageFlagsEphemeral,
Content: "failed to acquire claim :(",
},
})
if err != nil {
log.Error().Err(err).Msg("failed to respond to interaction")
return fmt.Errorf("failed to respond to interaction: %w", err)
}
return
return fmt.Errorf("failed to acquire claim: %w", err)
}
err = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
@ -340,14 +430,14 @@ func main() {
},
})
if err != nil {
log.Error().Err(err).Msg("failed to respond to interaction")
return fmt.Errorf("failed to respond to interaction: %w", err)
}
return nil
},
"describe-claim": func(s *discordgo.Session, i *discordgo.InteractionCreate) {
"describe-claim": func(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate) error {
id := i.ApplicationCommandData().Options[0]
detail, err := store.DescribeClaim(ctx, int(id.IntValue()))
if err != nil {
log.Error().Err(err).Msg("failed to describe claim")
err = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
@ -355,14 +445,15 @@ func main() {
},
})
if err != nil {
log.Error().Err(err).Msg("failed to respond to interaction")
return fmt.Errorf("failed to respond to interaction: %w", err)
}
return fmt.Errorf("failed to describe claim: %w", err)
}
sb := strings.Builder{}
sb.WriteString(fmt.Sprintf("#%d %s %s (%s)\n", detail.ID, detail.Name, detail.Type, detail.Player))
for _, p := range detail.Provinces {
sb.WriteString(fmt.Sprintf(" - %s\n", p))
sb.WriteString(fmt.Sprintf("- %s\n", p))
}
err = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
@ -372,19 +463,19 @@ func main() {
},
})
if err != nil {
log.Error().Err(err).Msg("failed to respond to interaction")
return fmt.Errorf("failed to respond to interaction: %w", err)
}
return nil
},
"delete-claim": func(s *discordgo.Session, i *discordgo.InteractionCreate) {
"delete-claim": func(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate) error {
id := i.ApplicationCommandData().Options[0]
userId := i.Member.User.ID
err := store.DeleteClaim(ctx, int(id.IntValue()), userId)
if err != nil {
msg := "Oops, something went wrong :( blame @wperron"
msg := "Oops, something went wrong :( blame <@wperron>"
if errors.Is(err, themis.ErrNoSuchClaim) {
msg = fmt.Sprintf("Claim #%d not found for %s", id.IntValue(), i.Member.Nick)
}
log.Error().Err(err).Msg("failed to delete claim")
err = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
@ -392,29 +483,48 @@ func main() {
},
})
if err != nil {
log.Error().Err(err).Msg("failed to respond to interaction")
return fmt.Errorf("failed to respond to interaction: %w", err)
}
return fmt.Errorf("failed to delete claim: %w", err)
}
err = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Flags: discordgo.MessageFlagsEphemeral,
Content: "Got it chief.",
},
})
if err != nil {
log.Error().Err(err).Msg("failed to respond to interaction")
return fmt.Errorf("failed to respond to interaction: %w", err)
}
return nil
},
"flush": func(s *discordgo.Session, i *discordgo.InteractionCreate) {
"flush": func(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate) error {
baggage := make(url.Values)
propagator.Inject(ctx, correlation.UrlValuesCarrier(baggage))
sb := strings.Builder{}
sb.WriteString("modal_flush")
if len(baggage) != 0 {
sb.WriteRune(':')
sb.WriteString(baggage.Encode())
}
if err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseModal,
Data: &discordgo.InteractionResponseData{
CustomID: "modals_flush_" + i.Interaction.Member.User.ID,
CustomID: sb.String(),
Title: "Are you sure?",
Components: []discordgo.MessageComponent{
discordgo.ActionsRow{
Components: []discordgo.MessageComponent{
// Note: Currently, Discord *requires* modal have at least one field present, it's not
// possible to simply have a cancel/confirm box. That is why this modal uses an old
// school [y/N] confirmation.
//
// source: https://stackoverflow.com/questions/71874810/discord-modals-without-textinputcomponent-but-a-text-and-button-only#answer-74317739
discordgo.TextInput{
CustomID: "confirmation",
Label: "Delete all claims permanently? [y/N]",
@ -430,13 +540,14 @@ func main() {
},
},
}); err != nil {
log.Error().Err(err).Msg("failed to respond to interaction")
return fmt.Errorf("failed to respond to interaction: %w", err)
}
return nil
},
"query": func(s *discordgo.Session, i *discordgo.InteractionCreate) {
"query": func(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate) error {
roDB, err := sql.Open("sqlite3", fmt.Sprintf("file:%s?cache=private&mode=ro", *dbFile))
if err != nil {
log.Error().Err(err).Msg("failed to open read-only copy of databse")
return fmt.Errorf("failed to open read-only copy of database: %w", err)
}
q := i.ApplicationCommandData().Options[0].StringValue()
@ -444,13 +555,12 @@ func main() {
defer cancelDeadline()
rows, err := roDB.QueryContext(deadlined, q)
if err != nil {
log.Error().Err(err).Msg("failed to exec user-provided query")
return
return fmt.Errorf("faied to exec user-provided query: %w", err)
}
fmtd, err := themis.FormatRows(rows)
fmtd, err := themis.FormatRows(ctx, rows)
if err != nil {
log.Error().Err(err).Msg("failed to format rows")
return fmt.Errorf("failed to format rows: %w", err)
}
// 2000 is a magic number here, it's the character limit for a discord
@ -464,22 +574,24 @@ func main() {
Content: table,
},
}); err != nil {
log.Error().Err(err).Msg("failed to respond to interaction")
log.Error().Ctx(ctx).Err(err).Msg("failed to respond to interaction")
return fmt.Errorf("failed to respond to interaction: %w", err)
}
return nil
},
"schedule": func(s *discordgo.Session, i *discordgo.InteractionCreate) {
"schedule": func(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate) error {
// get schedule from now to 4 mondays into the future
sched, err := store.GetSchedule(ctx, themis.NextMonday(), themis.NextMonday().Add(4*7*24*time.Hour))
if err != nil {
log.Error().Err(err).Msg("failed to get schedule")
if err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "failed to get schedule, check logs for more info.",
},
}); err != nil {
log.Error().Err(err).Msg("failed to respond to interaction")
return fmt.Errorf("failed to respond to interaction: %w", err)
}
return fmt.Errorf("failed to get schedule: %w", err)
}
sb := strings.Builder{}
@ -508,10 +620,27 @@ func main() {
Content: sb.String(),
},
}); err != nil {
log.Error().Err(err).Msg("failed to respond to interaction")
log.Error().Ctx(ctx).Err(err).Msg("failed to respond to interaction")
return fmt.Errorf("failed to respond to interaction: %w", err)
}
return nil
},
"send-schedule": func(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate) error {
notifier.Send(ctx)
if err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Flags: discordgo.MessageFlagsEphemeral,
Content: "Done.",
},
"absent": func(s *discordgo.Session, i *discordgo.InteractionCreate) {
}); err != nil {
log.Error().Ctx(ctx).Err(err).Msg("failed to respond to interaction")
return fmt.Errorf("failed to respond to interaction: %w", err)
}
return nil
},
"absent": func(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate) error {
var rawDate string
if len(i.ApplicationCommandData().Options) == 0 {
rawDate = themis.NextMonday().Format(time.DateOnly)
@ -524,34 +653,40 @@ func main() {
if err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Flags: discordgo.MessageFlagsEphemeral,
Content: "failed to parse provided date, make sure to use the YYYY-MM-DD format.",
},
}); err != nil {
log.Error().Err(err).Msg("failed to respond to interaction")
return fmt.Errorf("failed to respond to interaction: %w", err)
}
return nil
}
if date.Before(time.Now()) {
if err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Flags: discordgo.MessageFlagsEphemeral,
Content: "The date must be some time in the future.",
},
}); err != nil {
log.Error().Err(err).Msg("failed to respond to interaction")
return fmt.Errorf("failed to respond to interaction: %w", err)
}
return nil
}
if date.Weekday() != time.Monday {
if err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Flags: discordgo.MessageFlagsEphemeral,
Content: "The date you provided is not a Monday.",
},
}); err != nil {
log.Error().Err(err).Msg("failed to respond to interaction")
return fmt.Errorf("failed to respond to interaction: %w", err)
}
// TODO(wperron) suggest Mondays before and after?
return nil
}
userId := i.Member.User.ID
@ -562,19 +697,22 @@ func main() {
Content: "something went wrong recording your absence, check logs for more info.",
},
}); err != nil {
log.Error().Err(err).Msg("failed to respond to interaction")
return fmt.Errorf("failed to respond to interaction: %w", err)
}
return fmt.Errorf("failed to record absence: %w", err)
}
err = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Flags: discordgo.MessageFlagsEphemeral,
Content: "Okey dokey.",
},
})
if err != nil {
log.Error().Err(err).Msg("failed to respond to interaction")
return fmt.Errorf("failed to respond to interaction: %w", err)
}
return nil
},
}
@ -586,25 +724,21 @@ func main() {
}
defer discord.Close()
registeredCommands := make([]*discordgo.ApplicationCommand, len(commands))
for i, c := range commands {
command, err := discord.ApplicationCommandCreate(appId, guildId, c)
log.Debug().Int("count", len(commands)).Msg("registering commands via bulk overwrite")
created, err := discord.ApplicationCommandBulkOverwrite(appId, guildId, commands)
if err != nil {
log.Fatal().Err(err).Msg("failed to register command")
}
registeredCommands[i] = command
log.Fatal().Err(err).Msg("failed to register commands with discord")
}
log.Info().Int("count", len(created)).Dur("startup_latency_ms", time.Since(start)).Msg("registered commands, ready to operate")
log.Info().Int("count", len(registeredCommands)).Msg("registered commands")
go notifier.NotifyFunc(ctx, func(ctx context.Context) {
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
go func() {
if err := serve(":8080"); err != nil {
log.Error().Err(err).Msg("failed to serve requests")
}
cancel()
}()
ctx, span := tracer.Start(ctx, "weekly_notification")
defer span.End()
go notifier.NotifyFunc(ctx, func() {
log.Info().Msg("sending weekly reminder")
absentees, err := store.GetAbsentees(ctx, themis.NextMonday())
if err != nil {
log.Error().Err(err).Msg("failed to get absentees for next session")
@ -617,13 +751,32 @@ func main() {
}
var msg string
var components []discordgo.MessageComponent
if len(absentees) == 0 {
msg = "Everybody can make it next Monday, see you then!"
msg = "Everybody can make it next Monday, see you then! 🎉"
components = []discordgo.MessageComponent{
discordgo.ActionsRow{
Components: []discordgo.MessageComponent{
discordgo.Button{
CustomID: "schedule-response",
Label: "I Can't Make It",
Style: discordgo.DangerButton,
Disabled: false,
Emoji: discordgo.ComponentEmoji{
Name: "🙁",
},
},
},
},
}
} else {
msg = fmt.Sprintf("%s can't make it next Monday. :sad:", themis.FormatStringSlice(absentees))
msg = fmt.Sprintf("%s can't make it next Monday. 🙁", themis.FormatStringSlice(absentees))
}
_, err = discord.ChannelMessageSend(channelId, msg)
_, err = discord.ChannelMessageSendComplex(channelId, &discordgo.MessageSend{
Content: msg,
Components: components,
})
if err != nil {
log.Error().Err(err).Msg("failed to send scheduled notification")
}
@ -631,18 +784,11 @@ func main() {
<-ctx.Done()
log.Info().Msg("context cancelled, exiting")
for _, c := range registeredCommands {
err = discord.ApplicationCommandDelete(appId, guildId, c.ID)
if err != nil {
log.Error().Err(err).Msg("failed to deregister commands")
}
}
log.Info().Msg("deregistered commands, exiting")
os.Exit(0)
store.Close()
}
func touchDbFile(path string) error {
log.Debug().Str("path", path).Msg("touching database file")
f, err := os.Open(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
@ -667,18 +813,34 @@ func registerHandlers(sess *discordgo.Session, handlers map[string]Handler) {
sess.AddHandler(func(s *discordgo.Session, i *discordgo.InteractionCreate) {
switch i.Type {
case discordgo.InteractionApplicationCommand:
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if h, ok := handlers[i.ApplicationCommandData().Name]; ok {
h(s, i)
_ = inSpan(i.ApplicationCommandData().Name, withLogging(i.ApplicationCommandData().Name, h))(ctx, s, i)
}
case discordgo.InteractionModalSubmit:
if strings.HasPrefix(i.ModalSubmitData().CustomID, "modals_flush_") {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
state, err := parseCustomIDState(i.ModalSubmitData().CustomID)
if err != nil {
log.Error().Ctx(ctx).Err(err).Msg("unexpected error occurred while parsing state from custom id, returning early.")
return
}
ctx = propagator.Extract(ctx, correlation.UrlValuesCarrier(state))
if strings.HasPrefix(i.ModalSubmitData().CustomID, "modal_flush") {
sub := i.ModalSubmitData().Components[0].(*discordgo.ActionsRow).Components[0].(*discordgo.TextInput).Value
sub = strings.ToLower(sub)
log.Debug().Ctx(ctx).Str("value", sub).Msg("flush modal submitted")
if sub == "y" || sub == "ye" || sub == "yes" {
err := store.Flush(context.Background())
err := store.Flush(ctx, i.Member.User.ID)
msg := "Flushed all claims!"
if err != nil {
log.Error().Err(err).Msg("failed to flush claims")
log.Error().Ctx(ctx).Err(err).Msg("failed to flush claims")
msg = "failed to flush claims from database"
}
@ -689,7 +851,7 @@ func registerHandlers(sess *discordgo.Session, handlers map[string]Handler) {
},
})
if err != nil {
log.Error().Err(err).Msg("failed to respond to interaction")
log.Error().Ctx(ctx).Err(err).Msg("failed to respond to interaction")
}
return
}
@ -701,10 +863,49 @@ func registerHandlers(sess *discordgo.Session, handlers map[string]Handler) {
},
})
if err != nil {
log.Error().Ctx(ctx).Err(err).Msg("failed to respond to interaction")
}
return
}
case discordgo.InteractionMessageComponent:
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
state, err := parseCustomIDState(i.MessageComponentData().CustomID)
if err != nil {
log.Error().Ctx(ctx).Err(err).Msg("unexpected error occured while parsing state from custom id, returning early.")
return
}
ctx = propagator.Extract(ctx, correlation.UrlValuesCarrier(state))
switch i.MessageComponentData().CustomID {
case "schedule-response":
userId := i.Member.User.ID
log.Info().Ctx(ctx).Str("message_component", "schedule-response").Str("userid", userId).Msg("handling message component interaction")
if err := store.AddAbsence(ctx, themis.NextMonday(), userId); err != nil {
if err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "something went wrong recording your absence, check logs for more info.",
},
}); err != nil {
log.Error().Err(err).Msg("failed to respond to interaction")
}
return
}
err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: fmt.Sprintf("Looks like <@%s> can't make it after all.", userId),
},
})
if err != nil {
log.Error().Err(err).Msg("failed to respond to interaction")
}
}
}
})
}
@ -741,6 +942,7 @@ func formatClaimsTable(claims []themis.Claim) string {
}
func handleClaimAutocomplete(ctx context.Context, store *themis.Store, s *discordgo.Session, i *discordgo.InteractionCreate) {
log.Debug().Msg("getting autocomplete data for claim")
opts := i.ApplicationCommandData().Options
claimType, err := themis.ClaimTypeFromString(opts[0].StringValue())
if err != nil {
@ -762,6 +964,8 @@ func handleClaimAutocomplete(ctx context.Context, store *themis.Store, s *discor
})
}
log.Debug().Int("len", len(choices)).Msg("found autocomplete suggestions")
if err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionApplicationCommandAutocompleteResult,
Data: &discordgo.InteractionResponseData{
@ -780,9 +984,108 @@ func serve(address string) error {
return http.ListenAndServe(address, nil)
}
func inSpan(name string, h Handler) Handler {
return func(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate) error {
ctx, span := tracer.Start(ctx, fmt.Sprintf("discord_command %s", name))
defer span.End()
return h(ctx, s, i)
}
}
func withLogging(name string, h Handler) Handler {
return func(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate) error {
start := time.Now()
logCommandInvocation(ctx, name, s, i)
err := h(ctx, s, i)
debugCommandCompletion(ctx, name, time.Since(start), err, s, i)
return nil
}
}
func logCommandInvocation(ctx context.Context, name string, s *discordgo.Session, i *discordgo.InteractionCreate) {
span := trace.SpanFromContext(ctx)
span.SetAttributes(
attribute.String("user_id", i.Member.User.ID),
attribute.String("username", i.Member.User.Username),
attribute.String("command_name", name),
)
for _, o := range i.ApplicationCommandData().Options {
switch o.Type {
case discordgo.ApplicationCommandOptionSubCommand, discordgo.ApplicationCommandOptionSubCommandGroup:
panic("unreachable")
case discordgo.ApplicationCommandOptionString:
span.SetAttributes(attribute.String(fmt.Sprintf("command_option.%s", o.Name), o.StringValue()))
case discordgo.ApplicationCommandOptionInteger:
span.SetAttributes(attribute.Int64(fmt.Sprintf("command_option.%s", o.Name), o.IntValue()))
case discordgo.ApplicationCommandOptionBoolean:
span.SetAttributes(attribute.Bool(fmt.Sprintf("command_option.%s", o.Name), o.BoolValue()))
default:
span.SetAttributes(attribute.String(fmt.Sprintf("command_option.%s", o.Name), "unsupported_type"))
}
}
log.Info().Ctx(ctx).Msg("command invoked")
}
func debugCommandCompletion(ctx context.Context, name string, dur time.Duration, err error, s *discordgo.Session, i *discordgo.InteractionCreate) {
span := trace.SpanFromContext(ctx)
if err != nil {
span.SetStatus(codes.Error, err.Error())
}
log.Info().Ctx(ctx).Dur("latency_ms", dur).Msg("command completed")
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
func parseCustomIDState(qs string) (url.Values, error) {
parts := strings.Split(qs, ":")
if len(parts) == 1 {
return make(url.Values), nil
}
v, err := url.ParseQuery(parts[1])
if err != nil {
return nil, err
}
return v, nil
}
func initTracing(ctx context.Context, db *sql.DB) error {
fd, _ := flydetector.NewDetector().Detect(ctx) // this can't error
rd, err := resource.New(ctx,
resource.WithHost(),
resource.WithOS(),
resource.WithProcess(),
resource.WithTelemetrySDK(),
resource.WithAttributes(semconv.ServiceName("themis")),
)
if err != nil {
return fmt.Errorf("failed to create resource: %w", err)
}
res, err := resource.Merge(fd, rd)
if err != nil {
return fmt.Errorf("failed to merge resources: %w", err)
}
ex, err := sqliteexporter.NewSqliteSDKTraceExporterWithDB(db)
if err != nil {
return fmt.Errorf("failed to create span exporter: %w", err)
}
tp := sdktrace.NewTracerProvider(
sdktrace.WithBatcher(ex, sdktrace.WithExportTimeout(time.Second)),
sdktrace.WithResource(res),
)
otel.SetTracerProvider(tp)
tracer = tp.Tracer("themis")
return nil
}

@ -3,6 +3,12 @@ package themis
import (
"context"
"fmt"
"github.com/rs/zerolog/log"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
semconv "go.opentelemetry.io/otel/semconv/v1.24.0"
"go.opentelemetry.io/otel/trace"
)
type Conflict struct {
@ -17,36 +23,47 @@ func (c Conflict) String() string {
return fmt.Sprintf("%s owned by #%d %s %s (%s)", c.Province, c.ClaimID, c.ClaimType, c.Claim, c.Player)
}
const conflictQuery string = `SELECT name, player, claim_type, val, id FROM (
SELECT provinces.name, claims.player, claims.claim_type, claims.val, claims.id
FROM claims
LEFT JOIN provinces ON claims.val = provinces.trade_node
WHERE claims.claim_type = 'trade' AND claims.userid IS NOT ?
AND provinces.%[1]s = ?
UNION
SELECT provinces.name, claims.player, claims.claim_type, claims.val, claims.id
FROM claims
LEFT JOIN provinces ON claims.val = provinces.region
WHERE claims.claim_type = 'region' AND claims.userid IS NOT ?
AND provinces.%[1]s = ?
UNION
SELECT provinces.name, claims.player, claims.claim_type, claims.val, claims.id
FROM claims
LEFT JOIN provinces ON claims.val = provinces.area
WHERE claims.claim_type = 'area' AND claims.userid IS NOT ?
AND provinces.%[1]s = ?
);`
const conflictQuery string = `WITH claiming AS (
SELECT province FROM claimables
WHERE claimables.typ = ?
AND claimables.name = ?
)
SELECT claimables.province, claims.player, claims.claim_type, claims.val, claims.id
FROM claims
INNER JOIN claimables
ON claims.claim_type = claimables.typ
AND claims.val = claimables.name
INNER JOIN claiming
ON claiming.province = claimables.province
WHERE claims.userid IS NOT ?;`
func (s *Store) FindConflicts(ctx context.Context, userId, name string, claimType ClaimType) ([]Conflict, error) {
stmt, err := s.db.PrepareContext(ctx, fmt.Sprintf(conflictQuery, claimTypeToColumn[claimType]))
ctx, span := tracer.Start(ctx, "find_conflicts", trace.WithAttributes(
semconv.DBSystemSqlite,
semconv.DBSQLTable("claims"),
semconv.DBOperation("select"),
attribute.String("user_id", userId),
attribute.String("claim_name", name),
attribute.Stringer("claim_type", claimType),
))
defer span.End()
log.Debug().Ctx(ctx).Stringer("claim_type", claimType).Str("userid", userId).Msg("searching for potential conflicts")
stmt, err := s.db.PrepareContext(ctx, conflictQuery)
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, "failed to prepare query")
return nil, fmt.Errorf("failed to prepare conflicts query: %w", err)
}
rows, err := stmt.QueryContext(ctx, userId, name, userId, name, userId, name)
rows, err := stmt.QueryContext(ctx, claimType, name, userId)
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, "failed to execute query")
return nil, fmt.Errorf("failed to get conflicting provinces: %w", err)
}
defer stmt.Close()
conflicts := make([]Conflict, 0)
for rows.Next() {
@ -59,6 +76,8 @@ func (s *Store) FindConflicts(ctx context.Context, userId, name string, claimTyp
)
err = rows.Scan(&province, &player, &sClaimType, &claimName, &claimId)
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, "failed to scan row")
return nil, fmt.Errorf("failed to scan row: %w", err)
}

@ -2,19 +2,24 @@ package themis
import (
"context"
"database/sql"
"fmt"
"reflect"
"testing"
"github.com/rs/zerolog"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestStore_FindConflicts(t *testing.T) {
store, err := NewStore(fmt.Sprintf(TEST_CONN_STRING_PATTERN, "TestStore_FindConflicts"))
db, err := sql.Open("sqlite3", fmt.Sprintf(TEST_CONN_STRING_PATTERN, "TestStore_FindConflicts"))
require.NoError(t, err)
store, err := NewStore(db, zerolog.Nop())
assert.NoError(t, err)
id, err := store.Claim(context.TODO(), "000000000000000001", "foo", "Bordeaux", CLAIM_TYPE_TRADE)
assert.NoError(t, err)
id, err := store.Claim(context.Background(), "000000000000000001", "foo", "Bordeaux", CLAIM_TYPE_TRADE)
require.NoError(t, err)
type args struct {
ctx context.Context
@ -42,7 +47,7 @@ func TestStore_FindConflicts(t *testing.T) {
{
name: "overlapping",
args: args{
context.TODO(),
context.Background(),
"000000000000000002",
"Iberia",
CLAIM_TYPE_REGION,

@ -0,0 +1,44 @@
package correlation
import (
"net/url"
"github.com/rs/zerolog"
"go.opentelemetry.io/otel/propagation"
"go.opentelemetry.io/otel/trace"
)
type TraceContextHook struct{}
func (h TraceContextHook) Run(e *zerolog.Event, level zerolog.Level, msg string) {
ctx := e.GetCtx()
spanContext := trace.SpanContextFromContext(ctx)
trace_id := spanContext.TraceID()
if trace_id.IsValid() {
e.Stringer("trace_id", trace_id)
}
}
var _ propagation.TextMapCarrier = UrlValuesCarrier{}
type UrlValuesCarrier url.Values
// Get implements propagation.TextMapCarrier.
func (u UrlValuesCarrier) Get(key string) string {
return url.Values(u).Get(key)
}
// Keys implements propagation.TextMapCarrier.
func (u UrlValuesCarrier) Keys() []string {
raw := map[string][]string(u)
ks := make([]string, 0, len(raw))
for k := range raw {
ks = append(ks, k)
}
return ks
}
// Set implements propagation.TextMapCarrier.
func (u UrlValuesCarrier) Set(key string, value string) {
url.Values(u).Set(key, value)
}

File diff suppressed because it is too large Load Diff

@ -6,6 +6,7 @@ import (
)
var ErrNoSuchClaim = errors.New("no such claim")
var ErrNever = errors.New("event never occured")
type ErrConflict struct {
Conflicts []Conflict

@ -7,6 +7,7 @@ processes = []
[env]
TZ = "America/New_York"
ENV = "production"
DISCORD_APP_ID = "1014881815921705030"
DISCORD_GUILD_ID = "375417755777892353"
DISCORD_BOT_CHANNEL_ID = "1018997240968265768"
@ -26,15 +27,6 @@ DISCORD_BOT_CHANNEL_ID = "1018997240968265768"
soft_limit = 20
type = "connections"
[[services.ports]]
force_https = true
handlers = ["http"]
port = 80
[[services.ports]]
handlers = ["tls", "http"]
port = 443
[[services.tcp_checks]]
grace_period = "1s"
interval = "15s"

@ -1,19 +1,34 @@
package themis
import (
"context"
"database/sql"
"fmt"
"strings"
"github.com/rs/zerolog/log"
"go.opentelemetry.io/otel/codes"
semconv "go.opentelemetry.io/otel/semconv/v1.24.0"
"go.opentelemetry.io/otel/trace"
)
func FormatRows(rows *sql.Rows) (string, error) {
func FormatRows(ctx context.Context, rows *sql.Rows) (string, error) {
ctx, span := tracer.Start(ctx, "format_rows", trace.WithAttributes(
semconv.DBSystemSqlite,
))
defer span.End()
sb := strings.Builder{}
cols, err := rows.Columns()
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, "failed to get rows columns")
return "", fmt.Errorf("failed to get rows columns: %w", err)
}
log.Debug().Ctx(ctx).Int("columns", len(cols)).Msg("formatting sql rows to markdown table")
c := make([]string, len(cols))
for i := range c {
c[i] = " %-*s "
@ -32,6 +47,8 @@ func FormatRows(rows *sql.Rows) (string, error) {
row[i] = new(sql.NullString)
}
if err := rows.Scan(row...); err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, "failed to scan row")
return "", fmt.Errorf("failed to scan next row: %w", err)
}
@ -44,6 +61,8 @@ func FormatRows(rows *sql.Rows) (string, error) {
}
}
log.Debug().Ctx(ctx).Int("rows", len(scanned)).Ints("lengths", lengths).Msg("scanned rows and extracted max column lengths")
// Write column names
curr := make([]any, 0, 2*len(cols))
for i := range lengths {

@ -1,20 +1,26 @@
package themis
import (
"context"
"database/sql"
"fmt"
"testing"
"github.com/rs/zerolog"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestFormatRows(t *testing.T) {
store, err := NewStore(fmt.Sprintf(TEST_CONN_STRING_PATTERN, "format-rows"))
db, err := sql.Open("sqlite3", fmt.Sprintf(TEST_CONN_STRING_PATTERN, "format-rows"))
require.NoError(t, err)
store, err := NewStore(db, zerolog.Nop())
assert.NoError(t, err)
rows, err := store.db.Query("SELECT provinces.name, provinces.region, provinces.area, provinces.trade_node FROM provinces WHERE area = 'Gascony'")
assert.NoError(t, err)
fmtd, err := FormatRows(rows)
fmtd, err := FormatRows(context.Background(), rows)
assert.NoError(t, err)
assert.Equal(t, `| name | region | area | trade_node |
| -------- | ------ | ------- | ---------- |
@ -26,13 +32,15 @@ func TestFormatRows(t *testing.T) {
}
func TestFormatRowsAggregated(t *testing.T) {
store, err := NewStore(fmt.Sprintf(TEST_CONN_STRING_PATTERN, "format-rows"))
db, err := sql.Open("sqlite3", fmt.Sprintf(TEST_CONN_STRING_PATTERN, "format-rows"))
require.NoError(t, err)
store, err := NewStore(db, zerolog.Nop())
assert.NoError(t, err)
rows, err := store.db.Query("SELECT count(1) as total, trade_node from provinces where region = 'France' group by trade_node")
assert.NoError(t, err)
fmtd, err := FormatRows(rows)
fmtd, err := FormatRows(context.Background(), rows)
assert.NoError(t, err)
assert.Equal(t, `| total | trade_node |
| ----- | --------------- |
@ -45,7 +53,9 @@ func TestFormatRowsAggregated(t *testing.T) {
}
func TestFormatRowsInvalidQuery(t *testing.T) {
store, err := NewStore(fmt.Sprintf(TEST_CONN_STRING_PATTERN, "format-rows"))
db, err := sql.Open("sqlite3", fmt.Sprintf(TEST_CONN_STRING_PATTERN, "format-rows"))
require.NoError(t, err)
store, err := NewStore(db, zerolog.Nop())
assert.NoError(t, err)
_, err = store.db.Query("SELECT count(name), distinct(trade_node) from provinces where region = 'France'")

@ -1,13 +1,54 @@
module go.wperron.io/themis
go 1.21
go 1.21.1
require (
github.com/bwmarrin/discordgo v0.26.1
github.com/golang-migrate/migrate/v4 v4.16.2
github.com/mattn/go-sqlite3 v1.14.16
github.com/rs/zerolog v1.28.0
github.com/stretchr/testify v1.8.1
github.com/golang-migrate/migrate/v4 v4.17.0
github.com/mattn/go-sqlite3 v1.14.19
github.com/rs/zerolog v1.31.0
github.com/stretchr/testify v1.8.4
go.opentelemetry.io/otel/trace v1.24.0
go.wperron.io/sqliteexporter v0.1.0
)
require (
github.com/cenkalti/backoff/v4 v4.2.1 // indirect
github.com/go-logr/logr v1.4.1 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-viper/mapstructure/v2 v2.0.0-alpha.1 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang/protobuf v1.5.3 // indirect
github.com/hashicorp/go-version v1.6.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/knadh/koanf/maps v0.1.1 // indirect
github.com/knadh/koanf/providers/confmap v0.1.0 // indirect
github.com/knadh/koanf/v2 v2.0.2 // indirect
github.com/mitchellh/copystructure v1.2.0 // indirect
github.com/mitchellh/mapstructure v1.5.1-0.20231216201459-8508981c8b6c // indirect
github.com/mitchellh/reflectwalk v1.0.2 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
go.opencensus.io v0.24.0 // indirect
go.opentelemetry.io/collector v0.92.0 // indirect
go.opentelemetry.io/collector/component v0.94.1 // indirect
go.opentelemetry.io/collector/config/configretry v0.92.0 // indirect
go.opentelemetry.io/collector/config/configtelemetry v0.94.1 // indirect
go.opentelemetry.io/collector/confmap v0.94.1 // indirect
go.opentelemetry.io/collector/consumer v0.92.0 // indirect
go.opentelemetry.io/collector/exporter v0.92.0 // indirect
go.opentelemetry.io/collector/extension v0.92.0 // indirect
go.opentelemetry.io/collector/featuregate v1.0.1 // indirect
go.opentelemetry.io/collector/pdata v1.1.0 // indirect
go.opentelemetry.io/otel/metric v1.24.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.26.0 // indirect
go.wperron.io/flydetector v0.0.0-20240302202855-7f606a93cdb6 // indirect
golang.org/x/net v0.20.0 // indirect
golang.org/x/text v0.14.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20231106174013-bbf56f31fb17 // indirect
google.golang.org/grpc v1.61.0 // indirect
google.golang.org/protobuf v1.32.0 // indirect
)
require (
@ -15,11 +56,13 @@ require (
github.com/gorilla/websocket v1.4.2 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/mattn/go-colorable v0.1.12 // indirect
github.com/mattn/go-isatty v0.0.16 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
go.opentelemetry.io/otel v1.24.0
go.opentelemetry.io/otel/sdk v1.24.0
go.uber.org/atomic v1.7.0 // indirect
golang.org/x/crypto v0.7.0 // indirect
golang.org/x/sys v0.8.0 // indirect
golang.org/x/crypto v0.18.0 // indirect
golang.org/x/sys v0.17.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

279
go.sum

@ -1,12 +1,70 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
contrib.go.opencensus.io/exporter/prometheus v0.4.2 h1:sqfsYl5GIY/L570iT+l93ehxaWJs2/OwXtiWwew3oAg=
contrib.go.opencensus.io/exporter/prometheus v0.4.2/go.mod h1:dvEHbiKmgvbr5pjaF9fpw1KeYcjrnC1J8B+JKjsZyRQ=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bwmarrin/discordgo v0.26.1 h1:AIrM+g3cl+iYBr4yBxCBp9tD9jR3K7upEjl0d89FRkE=
github.com/bwmarrin/discordgo v0.26.1/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY=
github.com/coreos/go-systemd/v22 v22.3.3-0.20220203105225-a9a7ef127534/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM=
github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/go-kit/log v0.2.1 h1:MRVx0/zhvdseW+Gza6N9rVzU/IVzaeE1SFI4raAhmBU=
github.com/go-kit/log v0.2.1/go.mod h1:NwTd00d/i8cPZ3xOwwiv2PO5MOcx78fFErGNcVmBjv0=
github.com/go-logfmt/logfmt v0.5.1 h1:otpy5pqBCBZ1ng9RQ0dPu4PN7ba75Y/aA+UpowDyNVA=
github.com/go-logfmt/logfmt v0.5.1/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ=
github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-viper/mapstructure/v2 v2.0.0-alpha.1 h1:TQcrn6Wq+sKGkpyPvppOz99zsMBaUOKXq6HSv655U1c=
github.com/go-viper/mapstructure/v2 v2.0.0-alpha.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/golang-migrate/migrate/v4 v4.16.2 h1:8coYbMKUyInrFk1lfGfRovTLAW7PhWp8qQDT2iKfuoA=
github.com/golang-migrate/migrate/v4 v4.16.2/go.mod h1:pfcJX4nPHaVdc5nmdCikFBWtm+UBpiZjRNNsyBbp0/o=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-migrate/migrate/v4 v4.17.0 h1:rd40H3QXU0AA4IoLllFcEAEo9dYKRHYND2gB4p7xcaU=
github.com/golang-migrate/migrate/v4 v4.17.0/go.mod h1:+Cp2mtLP4/aXDTKb9wmXYitdrNx2HGs45rbWAo6OsKM=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc=
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
@ -14,46 +72,223 @@ github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
github.com/lib/pq v1.10.2 h1:AqzbZs4ZoCBp+GtejcpCpcxM3zlSMx29dXbUSeVtJb8=
github.com/lib/pq v1.10.2/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40=
github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ=
github.com/hashicorp/go-version v1.6.0 h1:feTTfFNnjP967rlCxM/I9g701jU+RN74YKx2mOkIeek=
github.com/hashicorp/go-version v1.6.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/knadh/koanf/maps v0.1.1 h1:G5TjmUh2D7G2YWf5SQQqSiHRJEjaicvU0KpypqB3NIs=
github.com/knadh/koanf/maps v0.1.1/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI=
github.com/knadh/koanf/providers/confmap v0.1.0 h1:gOkxhHkemwG4LezxxN8DMOFopOPghxRVp7JbIvdvqzU=
github.com/knadh/koanf/providers/confmap v0.1.0/go.mod h1:2uLhxQzJnyHKfxG927awZC7+fyHFdQkd697K4MdLnIU=
github.com/knadh/koanf/v2 v2.0.2 h1:sEZzPW2rVWSahcYILNq/syJdEyRafZIG0l9aWwL86HA=
github.com/knadh/koanf/v2 v2.0.2/go.mod h1:HN9uZ+qFAejH1e4G41gnoffIanINWQuONLXiV7kir6k=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y=
github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.19 h1:fhGleo2h1p8tVChob4I9HpmVFIAkKGpiukdrgQbWfGI=
github.com/mattn/go-sqlite3 v1.14.19/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw=
github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
github.com/mitchellh/mapstructure v1.5.1-0.20231216201459-8508981c8b6c h1:cqn374mizHuIWj+OSJCajGr/phAmuMug9qIX3l9CflE=
github.com/mitchellh/mapstructure v1.5.1-0.20231216201459-8508981c8b6c/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=
github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rs/xid v1.4.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
github.com/rs/zerolog v1.28.0 h1:MirSo27VyNi7RJYP3078AA1+Cyzd2GB66qy3aUHvsWY=
github.com/rs/zerolog v1.28.0/go.mod h1:NILgTygv/Uej1ra5XxGf82ZFSLk58MFGAUS2o6usyD0=
github.com/prometheus/client_golang v1.18.0 h1:HzFfmkOzH5Q8L8G+kSJKUx5dtG87sewO+FoDDqP5Tbk=
github.com/prometheus/client_golang v1.18.0/go.mod h1:T+GXkCk5wSJyOqMIzVgvvjFDlkOQntgjkJWKrN5txjA=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw=
github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI=
github.com/prometheus/common v0.46.0 h1:doXzt5ybi1HBKpsZOL0sSkaNHJJqkyfEWZGGqqScV0Y=
github.com/prometheus/common v0.46.0/go.mod h1:Tp0qkxpb9Jsg54QMe+EAmqXkSV7Evdy1BTn+g2pa/hQ=
github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo=
github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo=
github.com/prometheus/statsd_exporter v0.22.7 h1:7Pji/i2GuhK6Lu7DHrtTkFmNBCudCPT1pX2CziuyQR0=
github.com/prometheus/statsd_exporter v0.22.7/go.mod h1:N/TevpjkIh9ccs6nuzY3jQn9dFqnUakOjnEuMPJJJnI=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
github.com/rs/zerolog v1.31.0 h1:FcTR3NnLWW+NnTwwhFWiJSZr4ECLpqCm6QsEnyvbV4A=
github.com/rs/zerolog v1.31.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
go.opentelemetry.io/collector v0.92.0 h1:XiC0ptaT1EmOkK2RI0gt3n2tkzLAkNQGf0E7hrGdyeA=
go.opentelemetry.io/collector v0.92.0/go.mod h1:wbksjM63DTKA1BbdUVS7gAFzAngCZTWb46RBpKdtsPw=
go.opentelemetry.io/collector/component v0.94.1 h1:j4peKsWb+QVBKPs2RJeIj5EoQW7yp2ZVGrd8Bu9HU9M=
go.opentelemetry.io/collector/component v0.94.1/go.mod h1:vg+kAH81C3YS0SPzUXkSFWLPC1WH7zx70dAtUWWIHcE=
go.opentelemetry.io/collector/config/configretry v0.92.0 h1:3WUabmCRIBHSkOLGCHGieUGchlHkBw3Fa4Cj9Do5Xdw=
go.opentelemetry.io/collector/config/configretry v0.92.0/go.mod h1:gt1HRYyMxcMca9lbDLPbivQzsUCjVjkPAn/3S6fiD14=
go.opentelemetry.io/collector/config/configtelemetry v0.94.1 h1:ztYpBEBlvhcoxMiDKNmQ2SS+A41JZ4M19GfcxjCo8Zs=
go.opentelemetry.io/collector/config/configtelemetry v0.94.1/go.mod h1:2XLhyR/GVpWeZ2K044vCmrvH/d4Ewt0aD/y46avZyMU=
go.opentelemetry.io/collector/confmap v0.94.1 h1:O69bkeyR1YPAFz+jMd45aDZc1DtYnwb3Skgr2yALPqQ=
go.opentelemetry.io/collector/confmap v0.94.1/go.mod h1:pCT5UtcHaHVJ5BIILv1Z2VQyjZzmT9uTdBmC9+Z0AgA=
go.opentelemetry.io/collector/consumer v0.92.0 h1:twa8T0iR9KVglvRbwZ5OPKLXPCC2DO6gVhrgDZ47MPE=
go.opentelemetry.io/collector/consumer v0.92.0/go.mod h1:fBZqP7bou3I7pDhWjleBuzdaLfQgJBc92wPJVOcKaGU=
go.opentelemetry.io/collector/exporter v0.92.0 h1:z6u+/hswJUuZbuPYIF2gXMZsqjIDd/tJO60XjLM850U=
go.opentelemetry.io/collector/exporter v0.92.0/go.mod h1:54ODYn1weY/Wr0bdxESj4P1fgyX+zaUsnJJnafORqIY=
go.opentelemetry.io/collector/extension v0.92.0 h1:zaehgW+LXCMNEb1d6Af/VHWphh5ZwX9aZl+NuQLGhpQ=
go.opentelemetry.io/collector/extension v0.92.0/go.mod h1:5EYwiaGU6deSY8YWqT5gvlnD850yJXP3NqFRKVVbYLs=
go.opentelemetry.io/collector/featuregate v1.0.1 h1:ok//hLSXttBbyu4sSV1pTx1nKdr5udSmrWy5sFMIIbM=
go.opentelemetry.io/collector/featuregate v1.0.1/go.mod h1:QQXjP4etmJQhkQ20j4P/rapWuItYxoFozg/iIwuKnYg=
go.opentelemetry.io/collector/pdata v1.1.0 h1:cE6Al1rQieUjMHro6p6cKwcu3sjHXGG59BZ3kRVUvsM=
go.opentelemetry.io/collector/pdata v1.1.0/go.mod h1:IDkDj+B4Fp4wWOclBELN97zcb98HugJ8Q2gA4ZFsN8Q=
go.opentelemetry.io/collector/receiver v0.92.0 h1:TRz4ufr5bFEszpAWgYVEx/b7VPZzEcECsyMzztf5PsQ=
go.opentelemetry.io/collector/receiver v0.92.0/go.mod h1:bYAAYbMuUVj3wx7ave2iyyJ+aGUpACliYOQ5xI92I7k=
go.opentelemetry.io/otel v1.23.0 h1:Df0pqjqExIywbMCMTxkAwzjLZtRf+bBKLbUcpxO2C9E=
go.opentelemetry.io/otel v1.23.0/go.mod h1:YCycw9ZeKhcJFrb34iVSkyT0iczq/zYDtZYFufObyB0=
go.opentelemetry.io/otel v1.24.0 h1:0LAOdjNmQeSTzGBzduGe/rU4tZhMwL5rWgtp9Ku5Jfo=
go.opentelemetry.io/otel v1.24.0/go.mod h1:W7b9Ozg4nkF5tWI5zsXkaKKDjdVjpD4oAt9Qi/MArHo=
go.opentelemetry.io/otel/exporters/prometheus v0.45.1 h1:R/bW3afad6q6VGU+MFYpnEdo0stEARMCdhWu6+JI6aI=
go.opentelemetry.io/otel/exporters/prometheus v0.45.1/go.mod h1:wnHAfKRav5Dfp4iZhyWZ7SzQfT+rDZpEpYG7To+qJ1k=
go.opentelemetry.io/otel/metric v1.23.0 h1:pazkx7ss4LFVVYSxYew7L5I6qvLXHA0Ap2pwV+9Cnpo=
go.opentelemetry.io/otel/metric v1.23.0/go.mod h1:MqUW2X2a6Q8RN96E2/nqNoT+z9BSms20Jb7Bbp+HiTo=
go.opentelemetry.io/otel/metric v1.24.0 h1:6EhoGWWK28x1fbpA4tYTOWBkPefTDQnb8WSGXlc88kI=
go.opentelemetry.io/otel/metric v1.24.0/go.mod h1:VYhLe1rFfxuTXLgj4CBiyz+9WYBA8pNGJgDcSFRKBco=
go.opentelemetry.io/otel/sdk v1.23.0 h1:0KM9Zl2esnl+WSukEmlaAEjVY5HDZANOHferLq36BPc=
go.opentelemetry.io/otel/sdk v1.23.0/go.mod h1:wUscup7byToqyKJSilEtMf34FgdCAsFpFOjXnAwFfO0=
go.opentelemetry.io/otel/sdk v1.24.0 h1:YMPPDNymmQN3ZgczicBY3B6sf9n62Dlj9pWD3ucgoDw=
go.opentelemetry.io/otel/sdk v1.24.0/go.mod h1:KVrIYw6tEubO9E96HQpcmpTKDVn9gdv35HoYiQWGDFg=
go.opentelemetry.io/otel/sdk/metric v1.23.0 h1:u81lMvmK6GMgN4Fty7K7S6cSKOZhMKJMK2TB+KaTs0I=
go.opentelemetry.io/otel/sdk/metric v1.23.0/go.mod h1:2LUOToN/FdX6wtfpHybOnCZjoZ6ViYajJYMiJ1LKDtQ=
go.opentelemetry.io/otel/trace v1.23.0 h1:37Ik5Ib7xfYVb4V1UtnT97T1jI+AoIYkJyPkuL4iJgI=
go.opentelemetry.io/otel/trace v1.23.0/go.mod h1:GSGTbIClEsuZrGIzoEHqsVfxgn5UkggkflQwDScNUsk=
go.opentelemetry.io/otel/trace v1.24.0 h1:CsKnnL4dUAr/0llH9FKuc698G04IrpWV0MQA/Y1YELI=
go.opentelemetry.io/otel/trace v1.24.0/go.mod h1:HPc3Xr/cOApsBI154IU0OI0HJexz+aw5uPdbs3UCjNU=
go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw=
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo=
go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so=
go.wperron.io/flydetector v0.0.0-20240302202855-7f606a93cdb6 h1:SaV3yjkbcoC6sFgQbQhmVIYN9VSEMmvuUadAzBFe/YA=
go.wperron.io/flydetector v0.0.0-20240302202855-7f606a93cdb6/go.mod h1:XUxNxHOGyI6655abXX5+04qdore1sDRErBVmWFrmZV4=
go.wperron.io/sqliteexporter v0.1.0-rc5.0.20240209234500-ad89647cf9b6 h1:GbIjafkOpD/bWS2qR8PAOQAZPTQTQLk5XC4+d7DI7Fw=
go.wperron.io/sqliteexporter v0.1.0-rc5.0.20240209234500-ad89647cf9b6/go.mod h1:iQD28FG3zrdOEpKTGxvWCcLdir5eavk5bKjjN3RQ6Xc=
go.wperron.io/sqliteexporter v0.1.0 h1:0oK7DxZOMAwaGqRo5FVr9Nba3/IB5uO8Arfvi9rQADo=
go.wperron.io/sqliteexporter v0.1.0/go.mod h1:iQD28FG3zrdOEpKTGxvWCcLdir5eavk5bKjjN3RQ6Xc=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
golang.org/x/crypto v0.7.0 h1:AvwMYaRytfdeVt3u6mLaxYtErKYjxA2OXjJ1HHq6t3A=
golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU=
golang.org/x/crypto v0.18.0 h1:PGVlW0xEltQnzFZ55hkuX5+KLyrMYhHld1YHO4AKcdc=
golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.20.0 h1:aCL9BSgETF1k+blQaYUBx9hJ9LOGP3gAVemcZlf1Kpo=
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU=
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.17.0 h1:25cE3gD+tdBA7lp7QfhuV+rJiE9YXTcS3VG1SqssI/Y=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
google.golang.org/genproto/googleapis/rpc v0.0.0-20231106174013-bbf56f31fb17 h1:Jyp0Hsi0bmHXG6k9eATXoYtjd6e2UzZ1SCn/wIupY14=
google.golang.org/genproto/googleapis/rpc v0.0.0-20231106174013-bbf56f31fb17/go.mod h1:oQ5rr10WTTMvP4A36n8JpR1OrO1BEiV4f78CneXZxkA=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
google.golang.org/grpc v1.61.0 h1:TOvOcuXn30kRao+gfcvsebNEa5iZIiLkisYEkf7R7o0=
google.golang.org/grpc v1.61.0/go.mod h1:VUbo7IFqmF1QtCAstipjG0GIoq49KvMe9+h1jFLBNJs=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.32.0 h1:pPC6BG5ex8PDFnkbrGU3EixyhKcQ2aDuBS36lqK/C7I=
google.golang.org/protobuf v1.32.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=

@ -0,0 +1,6 @@
CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
event_type TEXT,
userid TEXT,
ts TIMESTAMP
);

@ -0,0 +1,3 @@
DROP TABLE provinces;
CREATE TABLE provinces AS SELECT * FROM provinces_bak;
DROP TABLE provinces_bak;

File diff suppressed because it is too large Load Diff

@ -0,0 +1,14 @@
create view if not exists claimables as
with
trades as (select distinct trade_node from provinces where trade_node != ''),
areas as (select distinct area from provinces where area != ''),
regions as (select distinct region from provinces where region != '')
select 'trade' as typ, provinces.trade_node as name, name as province, id
from provinces inner join trades on trades.trade_node = provinces.trade_node
union
select 'area' as typ, provinces.area as name, name as province, id
from provinces inner join areas on areas.area = provinces.area
union
select 'region' as typ, provinces.region as name, name as province, id
from provinces inner join regions on regions.region = provinces.region
;

@ -0,0 +1,15 @@
drop table claimables;
create view if not exists claimables as
with
trades as (select distinct trade_node from provinces where trade_node != ''),
areas as (select distinct area from provinces where area != ''),
regions as (select distinct region from provinces where region != '')
select 'trade' as typ, provinces.trade_node as name, name as province, id
from provinces inner join trades on trades.trade_node = provinces.trade_node
union
select 'area' as typ, provinces.area as name, name as province, id
from provinces inner join areas on areas.area = provinces.area
union
select 'region' as typ, provinces.region as name, name as province, id
from provinces inner join regions on regions.region = provinces.region
;

@ -0,0 +1,15 @@
drop view claimables;
create table if not exists claimables as
with
trades as (select distinct trade_node from provinces where trade_node != ''),
areas as (select distinct area from provinces where area != ''),
regions as (select distinct region from provinces where region != '')
select 'trade' as typ, provinces.trade_node as name, name as province, id
from provinces inner join trades on trades.trade_node = provinces.trade_node
union
select 'area' as typ, provinces.area as name, name as province, id
from provinces inner join areas on areas.area = provinces.area
union
select 'region' as typ, provinces.region as name, name as province, id
from provinces inner join regions on regions.region = provinces.region
;

@ -4,6 +4,9 @@ import (
"context"
"fmt"
"time"
"github.com/rs/zerolog/log"
"go.opentelemetry.io/otel/trace"
)
var loc *time.Location
@ -13,10 +16,10 @@ func init() {
}
type Notifier struct {
c chan struct{}
c chan context.Context
}
func NewNotifier(c chan struct{}) *Notifier {
func NewNotifier(c chan context.Context) *Notifier {
return &Notifier{
c: c,
}
@ -34,34 +37,57 @@ func (n *Notifier) Start(ctx context.Context) {
panic("failed to parse next monday notif time. this is likely a bug.")
}
log.Debug().Time("next", t).Msg("starting notifier instance")
first := time.NewTimer(time.Until(t))
<-first.C
select {
case <-ctx.Done():
log.Debug().Msg("context deadline exceeded, exiting notifier")
return
default:
n.c <- struct{}{}
log.Debug().Str("parent", "ticker").Msg("notifier tick")
ctx, span := tracer.Start(ctx, "notifier_tick", trace.WithNewRoot())
n.c <- ctx
span.End()
}
ticker := time.NewTicker(time.Hour * 24 * 7)
for {
select {
case <-ctx.Done():
log.Debug().Msg("context deadline exceeded, exiting notifier")
return
case <-ticker.C:
n.c <- struct{}{}
log.Debug().Str("parent", "ticker").Msg("notifier tick")
ctx, span := tracer.Start(ctx, "notifier_tick", trace.WithNewRoot())
n.c <- ctx
span.End()
}
time.Sleep(time.Second)
}
}
func (n *Notifier) NotifyFunc(ctx context.Context, f func()) {
// Trigger the notifier manually. Should be used for testing purposes only.
func (n *Notifier) Send(ctx context.Context) {
log.Debug().Str("parent", "trigger").Ctx(ctx).Msg("notifier tick")
n.c <- ctx
}
func (n *Notifier) NotifyFunc(ctx context.Context, f func(context.Context)) {
for {
select {
case <-ctx.Done():
log.Debug().Msg("context deadline exceeded, exiting notify func")
return
case <-n.c:
f()
case innerCtx := <-n.c:
innerCtx, span := tracer.Start(innerCtx, "notify_func")
log.Debug().Ctx(innerCtx).Msg("tick received, notifying function")
f(innerCtx)
span.End()
}
time.Sleep(time.Second)
}

@ -4,7 +4,6 @@ import (
"context"
"database/sql"
"embed"
_ "embed"
"errors"
"fmt"
"strings"
@ -13,21 +12,30 @@ import (
"github.com/golang-migrate/migrate/v4/database/sqlite3"
"github.com/golang-migrate/migrate/v4/source/iofs"
_ "github.com/mattn/go-sqlite3"
"github.com/rs/zerolog"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
semconv "go.opentelemetry.io/otel/semconv/v1.24.0"
"go.opentelemetry.io/otel/trace"
)
//go:embed migrations/*.sql
var migrations embed.FS
var tracer trace.Tracer
func init() {
tp := otel.GetTracerProvider()
tracer = tp.Tracer("themis")
}
type Store struct {
db *sql.DB
logger zerolog.Logger
}
func NewStore(conn string) (*Store, error) {
db, err := sql.Open("sqlite3", conn)
if err != nil {
return nil, fmt.Errorf("failed to open database: %w", err)
}
func NewStore(db *sql.DB, logger zerolog.Logger) (*Store, error) {
d, err := iofs.New(migrations, "migrations")
if err != nil {
return nil, fmt.Errorf("failed to open iofs migration source: %w", err)
@ -48,60 +56,120 @@ func NewStore(conn string) (*Store, error) {
return nil, fmt.Errorf("failed to roll up migrations: %w", err)
}
ver, dirty, err := m.Version()
if err != nil && err != migrate.ErrNilVersion {
return nil, fmt.Errorf("failed to get database migration version: %w", err)
}
logger.Debug().Uint("current_version", ver).Bool("dirty", dirty).Msg("running database migrations")
return &Store{
logger: logger,
db: db,
}, nil
}
func (s *Store) Close() error {
s.logger.Debug().Msg("closing database")
return s.db.Close()
}
func (s *Store) Claim(ctx context.Context, userId, player, province string, claimType ClaimType) (int, error) {
ctx, span := tracer.Start(ctx, "claim", trace.WithAttributes(
semconv.DBSystemSqlite,
semconv.DBSQLTable("claims"),
semconv.DBOperation("insert"),
attribute.String("user_id", userId),
attribute.String("claim_name", province),
attribute.Stringer("claim_type", claimType),
))
defer span.End()
s.logger.Debug().
Ctx(ctx).
Str("userid", userId).
Str("player", player).
Str("province", province).
Stringer("claim_type", claimType).
Msg("inserting claim")
audit := &AuditableEvent{
userId: userId,
eventType: EventClaim,
}
defer s.Audit(ctx, audit)
tx, err := s.db.Begin()
if err != nil {
audit.err = err
span.RecordError(err)
span.SetStatus(codes.Error, "failed to begin transaction")
return 0, fmt.Errorf("failed to begin transaction: %w", err)
}
defer tx.Commit() //nolint:errcheck
conflicts, err := s.FindConflicts(ctx, userId, province, claimType)
if err != nil {
audit.err = err
span.RecordError(err)
span.SetStatus(codes.Error, "conflict check failed")
return 0, fmt.Errorf("failed to run conflicts check: %w", err)
}
if len(conflicts) > 0 {
s.logger.Debug().Ctx(ctx).Int("len", len(conflicts)).Msg("found conflicts")
audit.err = errors.New("found conflicts")
return 0, ErrConflict{Conflicts: conflicts}
}
// check that provided name matches the claim type
stmt, err := s.db.PrepareContext(ctx, fmt.Sprintf(`SELECT COUNT(1) FROM provinces WHERE LOWER(provinces.%s) = ?`, claimTypeToColumn[claimType]))
stmt, err := s.db.PrepareContext(ctx, `SELECT COUNT(1) FROM claimables WHERE lower(name) = ? and typ = ?`)
if err != nil {
audit.err = err
span.RecordError(err)
span.SetStatus(codes.Error, "failed to prepare query")
return 0, fmt.Errorf("failed to prepare count query: %w", err)
}
defer stmt.Close()
row := stmt.QueryRowContext(ctx, strings.ToLower(province))
row := stmt.QueryRowContext(ctx, strings.ToLower(province), claimType)
var count int
err = row.Scan(&count)
if err != nil {
audit.err = err
span.RecordError(err)
span.SetStatus(codes.Error, "failed to scan row")
return 0, fmt.Errorf("failed to scan: %w", err)
}
if count == 0 {
audit.err = err
span.RecordError(err)
span.SetStatus(codes.Error, "no matching provinces found")
return 0, fmt.Errorf("found no provinces for %s named %s", claimType, province)
}
stmt, err = s.db.PrepareContext(ctx, "INSERT INTO claims (player, claim_type, val, userid) VALUES (?, ?, ?, ?)")
if err != nil {
audit.err = err
span.RecordError(err)
span.SetStatus(codes.Error, "failed to prepare query")
return 0, fmt.Errorf("failed to prepare claim query: %w", err)
}
defer stmt.Close()
res, err := stmt.ExecContext(ctx, player, claimType, province, userId)
if err != nil {
audit.err = err
span.RecordError(err)
span.SetStatus(codes.Error, "failed to execute query")
return 0, fmt.Errorf("failed to insert claim: %w", err)
}
id, err := res.LastInsertId()
if err != nil {
audit.err = err
span.RecordError(err)
span.SetStatus(codes.Error, "failed to last insert id")
return 0, fmt.Errorf("failed to get last ID: %w", err)
}
@ -109,24 +177,42 @@ func (s *Store) Claim(ctx context.Context, userId, player, province string, clai
}
func (s *Store) ListAvailability(ctx context.Context, claimType ClaimType, search ...string) ([]string, error) {
ctx, span := tracer.Start(ctx, "list_availability", trace.WithAttributes(
semconv.DBSystemSqlite,
semconv.DBSQLTable("claimables"),
semconv.DBOperation("select"),
attribute.StringSlice("search_terms", search),
attribute.Stringer("claim_type", claimType),
))
defer span.End()
s.logger.Debug().Ctx(ctx).Stringer("claim_type", claimType).Strs("search_terms", search).Msg("listing available entries")
queryParams := []any{string(claimType)}
queryPattern := `SELECT DISTINCT(provinces.%[1]s)
FROM provinces LEFT JOIN claims ON provinces.%[1]s = claims.val AND claims.claim_type = ?
queryPattern := `SELECT distinct name
FROM claimables
LEFT JOIN claims ON claimables.name = claims.val AND claimables.typ = claims.claim_type
WHERE claims.val IS NULL
AND provinces.typ = 'Land'`
AND claimables.typ = ?`
if len(search) > 0 && search[0] != "" {
// only take one search param, ignore the rest
queryPattern += `AND provinces.%[1]s LIKE ?`
queryPattern += `AND claimables.name LIKE ?`
queryParams = append(queryParams, fmt.Sprintf("%%%s%%", search[0]))
}
stmt, err := s.db.PrepareContext(ctx, fmt.Sprintf(queryPattern, claimTypeToColumn[claimType]))
stmt, err := s.db.PrepareContext(ctx, queryPattern)
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, "failed to prepare query")
return nil, fmt.Errorf("failed to prepare query: %w", err)
}
defer stmt.Close()
rows, err := stmt.QueryContext(ctx, queryParams...)
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, "failed to execute query")
return nil, fmt.Errorf("failed to execute query: %w", err)
}
@ -134,6 +220,8 @@ func (s *Store) ListAvailability(ctx context.Context, claimType ClaimType, searc
for rows.Next() {
var s string
if err := rows.Scan(&s); err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, "failed to scan row")
return nil, fmt.Errorf("failed to scan rows: %w", err)
}
avail = append(avail, s)
@ -143,13 +231,26 @@ func (s *Store) ListAvailability(ctx context.Context, claimType ClaimType, searc
}
func (s *Store) ListClaims(ctx context.Context) ([]Claim, error) {
ctx, span := tracer.Start(ctx, "list_claims", trace.WithAttributes(
semconv.DBSystemSqlite,
semconv.DBSQLTable("claims"),
semconv.DBOperation("select"),
))
defer span.End()
s.logger.Debug().Ctx(ctx).Msg("listing all claims currently in database")
stmt, err := s.db.PrepareContext(ctx, `SELECT id, player, claim_type, val FROM claims`)
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, "failed to prepare query")
return nil, fmt.Errorf("failed to prepare query: %w", err)
}
defer stmt.Close()
rows, err := stmt.QueryContext(ctx)
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, "failed to execute query")
return nil, fmt.Errorf("failed to execute query: %w", err)
}
@ -159,10 +260,14 @@ func (s *Store) ListClaims(ctx context.Context) ([]Claim, error) {
var rawType string
err = rows.Scan(&c.ID, &c.Player, &rawType, &c.Name)
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, "failed to scan row")
return nil, fmt.Errorf("failed to scan row: %w", err)
}
cl, err := ClaimTypeFromString(rawType)
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, "failed to parse claim type")
return nil, fmt.Errorf("unexpected error converting raw claim type: %w", err)
}
c.Type = cl
@ -182,16 +287,28 @@ func (cd ClaimDetail) String() string {
sb := strings.Builder{}
sb.WriteString(fmt.Sprintf("%s\n", cd.Claim))
for _, p := range cd.Provinces {
sb.WriteString(fmt.Sprintf(" - %s\n", p))
sb.WriteString(fmt.Sprintf("- %s\n", p))
}
return sb.String()
}
func (s *Store) DescribeClaim(ctx context.Context, ID int) (ClaimDetail, error) {
ctx, span := tracer.Start(ctx, "describe_claim", trace.WithAttributes(
semconv.DBSystemSqlite,
semconv.DBSQLTable("claims"),
semconv.DBOperation("select"),
attribute.Int("claim_id", ID),
))
defer span.End()
s.logger.Debug().Ctx(ctx).Int("id", ID).Msg("describing claim")
stmt, err := s.db.PrepareContext(ctx, `SELECT id, player, claim_type, val FROM claims WHERE id = ?`)
if err != nil {
return ClaimDetail{}, fmt.Errorf("failed to get claim: %w", err)
span.RecordError(err)
span.SetStatus(codes.Error, "failed to prepare query")
return ClaimDetail{}, fmt.Errorf("failed to prepare select claim query: %w", err)
}
defer stmt.Close()
row := stmt.QueryRowContext(ctx, ID)
@ -199,24 +316,34 @@ func (s *Store) DescribeClaim(ctx context.Context, ID int) (ClaimDetail, error)
var rawType string
err = row.Scan(&c.ID, &c.Player, &rawType, &c.Name)
if err == sql.ErrNoRows {
span.RecordError(ErrNoSuchClaim)
return ClaimDetail{}, ErrNoSuchClaim
}
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, "failed to scan row")
return ClaimDetail{}, fmt.Errorf("failed to scan row: %w", err)
}
cl, err := ClaimTypeFromString(rawType)
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, "failed to parse claim type")
return ClaimDetail{}, fmt.Errorf("unexpected error converting raw claim type: %w", err)
}
c.Type = cl
stmt, err = s.db.PrepareContext(ctx, fmt.Sprintf(`SELECT name FROM provinces where provinces.%s = ?`, claimTypeToColumn[cl]))
stmt, err = s.db.PrepareContext(ctx, `SELECT province FROM claimables WHERE name = ? AND typ = ?`)
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, "failed to prepare query")
return ClaimDetail{}, fmt.Errorf("failed to prepare query: %w", err)
}
defer stmt.Close()
rows, err := stmt.QueryContext(ctx, c.Name)
rows, err := stmt.QueryContext(ctx, c.Name, cl)
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, "failed to execute query")
return ClaimDetail{}, fmt.Errorf("failed to execute query: %w", err)
}
@ -225,6 +352,8 @@ func (s *Store) DescribeClaim(ctx context.Context, ID int) (ClaimDetail, error)
var p string
err = rows.Scan(&p)
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, "failed to scan row")
return ClaimDetail{}, fmt.Errorf("failed to scan result set: %w", err)
}
provinces = append(provinces, p)
@ -237,44 +366,102 @@ func (s *Store) DescribeClaim(ctx context.Context, ID int) (ClaimDetail, error)
}
func (s *Store) DeleteClaim(ctx context.Context, ID int, userId string) error {
ctx, span := tracer.Start(ctx, "delete_claim", trace.WithAttributes(
semconv.DBSystemSqlite,
semconv.DBSQLTable("claims"),
semconv.DBOperation("delete"),
attribute.Int("claim_id", ID),
attribute.String("user_id", userId),
))
defer span.End()
s.logger.Debug().Ctx(ctx).Str("userid", userId).Int("id", ID).Msg("deleting claim")
audit := &AuditableEvent{
userId: userId,
eventType: EventUnclaim,
}
defer s.Audit(ctx, audit)
stmt, err := s.db.PrepareContext(ctx, "DELETE FROM claims WHERE id = ? AND userid = ?")
if err != nil {
audit.err = err
span.RecordError(err)
span.SetStatus(codes.Error, "failed to prepare query")
return fmt.Errorf("failed to prepare query: %w", err)
}
defer stmt.Close()
res, err := stmt.ExecContext(ctx, ID, userId)
if err != nil {
audit.err = err
span.RecordError(err)
span.SetStatus(codes.Error, "failed to execute query")
return fmt.Errorf("failed to delete claim ID %d: %w", ID, err)
}
rows, err := res.RowsAffected()
if err != nil {
audit.err = err
span.RecordError(err)
span.SetStatus(codes.Error, "failed to get number of affected rows")
return fmt.Errorf("failed to get affected rows: %w", err)
}
if rows == 0 {
audit.err = ErrNoSuchClaim
span.RecordError(ErrNoSuchClaim)
return ErrNoSuchClaim
}
return nil
}
func (s *Store) CountClaims(ctx context.Context) (total, uniquePlayers int, err error) {
ctx, span := tracer.Start(ctx, "count_claims", trace.WithAttributes(
semconv.DBSystemSqlite,
semconv.DBSQLTable("claims"),
semconv.DBOperation("select"),
))
defer span.End()
s.logger.Debug().Ctx(ctx).Msg("counting all claims and unique users")
stmt, err := s.db.PrepareContext(ctx, "SELECT COUNT(1), COUNT(DISTINCT(userid)) FROM claims")
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, "failed to prepare query")
return 0, 0, fmt.Errorf("failed to prepare query: %w", err)
}
defer stmt.Close()
res := stmt.QueryRowContext(ctx)
if err := res.Scan(&total, &uniquePlayers); err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, "failed to scan row")
return 0, 0, fmt.Errorf("failed to scan result: %w", err)
}
return total, uniquePlayers, nil
}
func (s *Store) Flush(ctx context.Context) error {
func (s *Store) Flush(ctx context.Context, userId string) error {
ctx, span := tracer.Start(ctx, "flush", trace.WithAttributes(
semconv.DBSystemSqlite,
semconv.DBSQLTable("claims"),
semconv.DBOperation("delete"),
))
defer span.End()
s.logger.Debug().Ctx(ctx).Str("initiated_by", userId).Msg("flushing all currently help claims")
audit := &AuditableEvent{
userId: userId,
eventType: EventFlush,
}
defer s.Audit(ctx, audit)
_, err := s.db.ExecContext(ctx, "DELETE FROM claims;")
if err != nil {
audit.err = err
span.RecordError(err)
span.SetStatus(codes.Error, "failed to execute query")
return fmt.Errorf("failed to execute delete query: %w", err)
}
return nil

@ -3,11 +3,13 @@ package themis
import (
"context"
"database/sql"
_ "embed"
"fmt"
"testing"
_ "github.com/mattn/go-sqlite3"
"github.com/rs/zerolog"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@ -15,7 +17,9 @@ import (
const TEST_CONN_STRING_PATTERN = "file:%s?mode=memory&cache=shared"
func TestStore_Claim(t *testing.T) {
store, err := NewStore(fmt.Sprintf(TEST_CONN_STRING_PATTERN, "TestStore_Claim"))
db, err := sql.Open("sqlite3", fmt.Sprintf(TEST_CONN_STRING_PATTERN, "TestStore_Claim"))
require.NoError(t, err)
store, err := NewStore(db, zerolog.Nop())
require.NoError(t, err)
type args struct {
@ -90,17 +94,27 @@ func TestStore_Claim(t *testing.T) {
wantErr: false,
},
}
lastAudit := 0
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if _, err := store.Claim(context.TODO(), tt.args.userId, tt.args.player, tt.args.province, tt.args.claimType); (err != nil) != tt.wantErr {
t.Errorf("Store.Claim() error = %v, wantErr %v", err, tt.wantErr)
}
if !tt.wantErr {
ae, err := store.LastOf(context.TODO(), EventClaim)
require.NoError(t, err)
assert.Greater(t, ae.Id, lastAudit)
lastAudit = ae.Id
}
})
}
}
func TestAvailability(t *testing.T) {
store, err := NewStore(fmt.Sprintf(TEST_CONN_STRING_PATTERN, "TestAvailability"))
db, err := sql.Open("sqlite3", fmt.Sprintf(TEST_CONN_STRING_PATTERN, "TestStore_Availability"))
require.NoError(t, err)
store, err := NewStore(db, zerolog.Nop())
assert.NoError(t, err)
store.Claim(context.TODO(), "000000000000000001", "foo", "Genoa", CLAIM_TYPE_TRADE)
@ -116,22 +130,22 @@ func TestAvailability(t *testing.T) {
store.Claim(context.TODO(), "000000000000000001", "foo", "France", CLAIM_TYPE_REGION)
store.Claim(context.TODO(), "000000000000000001", "foo", "Italy", CLAIM_TYPE_REGION)
// There's a total of 73 distinct regions, there should be 71 available
// There's a total of 92 distinct regions, there should be 90 available
// after the two claims above
availability, err = store.ListAvailability(context.TODO(), CLAIM_TYPE_REGION)
assert.NoError(t, err)
assert.Equal(t, 71, len(availability))
assert.Equal(t, 90, len(availability))
store.Claim(context.TODO(), "000000000000000001", "foo", "Normandy", CLAIM_TYPE_AREA)
store.Claim(context.TODO(), "000000000000000001", "foo", "Champagne", CLAIM_TYPE_AREA)
store.Claim(context.TODO(), "000000000000000001", "foo", "Lorraine", CLAIM_TYPE_AREA)
store.Claim(context.TODO(), "000000000000000001", "foo", "Provence", CLAIM_TYPE_AREA)
// There's a total of 823 distinct regions, there should be 819 available
// There's a total of 882 distinct regions, there should be 878 available
// after the four claims above
availability, err = store.ListAvailability(context.TODO(), CLAIM_TYPE_AREA)
assert.NoError(t, err)
assert.Equal(t, 819, len(availability))
assert.Equal(t, 878, len(availability))
// There is both a Trade Node and an Area called 'Valencia', while the trade
// node is claimed, the area should show up in the availability list (even
@ -139,15 +153,17 @@ func TestAvailability(t *testing.T) {
store.Claim(context.TODO(), "000000000000000001", "foo", "Valencia", CLAIM_TYPE_TRADE)
availability, err = store.ListAvailability(context.TODO(), CLAIM_TYPE_AREA)
assert.NoError(t, err)
assert.Equal(t, 819, len(availability)) // availability for areas should be the same as before
assert.Equal(t, 878, len(availability)) // availability for areas should be the same as before
availability, err = store.ListAvailability(context.TODO(), CLAIM_TYPE_AREA, "bay")
assert.NoError(t, err)
assert.Equal(t, 3, len(availability)) // availability for areas should be the same as before
assert.Equal(t, 6, len(availability)) // availability for areas should be the same as before
}
func TestDeleteClaim(t *testing.T) {
store, err := NewStore(fmt.Sprintf(TEST_CONN_STRING_PATTERN, "TestDeleteClaim"))
db, err := sql.Open("sqlite3", fmt.Sprintf(TEST_CONN_STRING_PATTERN, "TestStore_DeleteClaim"))
require.NoError(t, err)
store, err := NewStore(db, zerolog.Nop())
assert.NoError(t, err)
// make sure all claims are gone, this is due to how the in-memory database
// with a shared cache interacts with other tests running in parallel
@ -165,13 +181,23 @@ func TestDeleteClaim(t *testing.T) {
err = store.DeleteClaim(context.TODO(), fooId, "000000000000000001")
assert.NoError(t, err)
ae, err := store.LastOf(context.TODO(), EventUnclaim)
require.NoError(t, err)
last := ae.Id
err = store.DeleteClaim(context.TODO(), barId, "000000000000000001")
assert.Error(t, err)
assert.ErrorIs(t, err, ErrNoSuchClaim)
ae, err = store.LastOf(context.TODO(), EventUnclaim)
require.NoError(t, err)
assert.Equal(t, last, ae.Id) // no new audit log was added
}
func TestDescribeClaim(t *testing.T) {
store, err := NewStore(fmt.Sprintf(TEST_CONN_STRING_PATTERN, "TestDescribeClaim"))
db, err := sql.Open("sqlite3", fmt.Sprintf(TEST_CONN_STRING_PATTERN, "TestStore_DescribeClaim"))
require.NoError(t, err)
store, err := NewStore(db, zerolog.Nop())
assert.NoError(t, err)
id, err := store.Claim(context.TODO(), "000000000000000001", "foo", "Genoa", CLAIM_TYPE_TRADE)
@ -187,7 +213,9 @@ func TestDescribeClaim(t *testing.T) {
}
func TestCountClaims(t *testing.T) {
store, err := NewStore(fmt.Sprintf(TEST_CONN_STRING_PATTERN, "TestFlush"))
db, err := sql.Open("sqlite3", fmt.Sprintf(TEST_CONN_STRING_PATTERN, "TestStore_CountClaim"))
require.NoError(t, err)
store, err := NewStore(db, zerolog.Nop())
assert.NoError(t, err)
store.Claim(context.TODO(), "000000000000000001", "foo", "Genoa", CLAIM_TYPE_TRADE)
@ -203,7 +231,9 @@ func TestCountClaims(t *testing.T) {
}
func TestFlush(t *testing.T) {
store, err := NewStore(fmt.Sprintf(TEST_CONN_STRING_PATTERN, "TestFlush"))
db, err := sql.Open("sqlite3", fmt.Sprintf(TEST_CONN_STRING_PATTERN, "TestStore_Flush"))
require.NoError(t, err)
store, err := NewStore(db, zerolog.Nop())
assert.NoError(t, err)
store.Claim(context.TODO(), "000000000000000001", "foo", "Genoa", CLAIM_TYPE_TRADE)
@ -212,8 +242,11 @@ func TestFlush(t *testing.T) {
store.Claim(context.TODO(), "000000000000000001", "foo", "Iberia", CLAIM_TYPE_REGION)
store.Claim(context.TODO(), "000000000000000001", "foo", "Ragusa", CLAIM_TYPE_TRADE)
assert.NoError(t, store.Flush(context.TODO()))
assert.NoError(t, store.Flush(context.TODO(), "bob"))
claims, err := store.ListClaims(context.TODO())
assert.NoError(t, err)
assert.Equal(t, 0, len(claims))
_, err = store.LastOf(context.TODO(), EventFlush)
require.NoError(t, err)
}

@ -3,10 +3,11 @@
package themis
import (
"context"
"time"
)
// Uptime returns the time elapsed since the start of the current process ID.
func Uptime() (time.Duration, error) {
func Uptime(ctx context.Context) (time.Duration, error) {
return 0, nil
}

@ -4,18 +4,22 @@ package themis
import (
"bytes"
"context"
"fmt"
"os"
"strconv"
"time"
"github.com/rs/zerolog/log"
)
// Uptime returns the time elapsed since the start of the current process ID.
func Uptime() (time.Duration, error) {
func Uptime(ctx context.Context) (time.Duration, error) {
raw, err := os.ReadFile("/proc/uptime")
if err != nil {
return 0, fmt.Errorf("failed to read uptime from OS: %w", err)
}
log.Debug().Ctx(ctx).Str("raw", string(raw)).Msg("reading /proc/uptime to get server up time")
i := bytes.IndexRune(raw, ' ')

@ -1,9 +1,10 @@
package themis
import (
"context"
"runtime"
"testing"
"time"
"runtime"
"github.com/stretchr/testify/assert"
)
@ -12,7 +13,7 @@ func TestUptime(t *testing.T) {
if runtime.GOOS == "darwin" {
t.Skip()
}
uptime, err := Uptime()
uptime, err := Uptime(context.Background())
assert.NoError(t, err)
assert.Greater(t, uptime, 100*time.Millisecond)
}

Loading…
Cancel
Save