docs: publish Preview 19 dogfood evidence

Export the reviewed allowlisted snapshot from private source commit 05928cebd01b586cf9e9d4b8c8537a7605a6068c. This records the exact candidate, bounded capacity result, stateful migration scratch requirement, authenticated batch identity proof, and immediate live acceptance evidence.

AI-Assisted: OpenAI Codex
Signed-off-by: Cole Speelman <crspeelman@gmail.com>
This commit is contained in:
2026-08-18 21:47:08 -04:00
commit 92a66db3df
201 changed files with 38227 additions and 0 deletions
+303
View File
@@ -0,0 +1,303 @@
// SPDX-License-Identifier: AGPL-3.0-only
package agent
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"time"
"gamertan.com/observatory/internal/agentclient"
"gamertan.com/observatory/internal/agentstate"
"gamertan.com/observatory/internal/config"
"gamertan.com/observatory/internal/edgealert"
"gamertan.com/observatory/internal/hostmetrics"
"gamertan.com/observatory/internal/model"
"gamertan.com/observatory/internal/spool"
"gamertan.com/observatory/internal/storage"
"gamertan.com/observatory/internal/tailer"
)
type Sender interface {
Send(context.Context, model.Batch) (storage.Ack, error)
SendAlertTransition(context.Context, model.AlertTransition) (storage.SourceAlertTransitionAck, error)
}
type Runner struct {
configuration config.Agent
sourceID string
stateStore *agentstate.Store
state agentstate.State
spool *spool.Spool
sender Sender
agentEpoch string
}
func Open(configuration config.Agent, credential string, transport http.RoundTripper) (*Runner, error) {
sourceID, err := sourceIDFromCredential(credential)
if err != nil {
return nil, err
}
stateStore, state, err := agentstate.Open(configuration.StateFile)
if err != nil {
return nil, err
}
queue, err := spool.Open(configuration.SpoolDir, configuration.MaxSpoolBytes, configuration.MaxSpoolAge)
if err != nil {
return nil, err
}
client, err := agentclient.New(configuration.ServerURL, credential, transport)
if err != nil {
return nil, err
}
return newRunner(configuration, sourceID, stateStore, state, queue, client, agentEpoch(credential))
}
func New(configuration config.Agent, sourceID string, stateStore *agentstate.Store, state agentstate.State, queue *spool.Spool, sender Sender) (*Runner, error) {
return newRunner(configuration, sourceID, stateStore, state, queue, sender, agentEpoch(sourceID))
}
func newRunner(configuration config.Agent, sourceID string, stateStore *agentstate.Store, state agentstate.State, queue *spool.Spool, sender Sender, epoch string) (*Runner, error) {
if err := model.ValidateSourceID(sourceID); err != nil || stateStore == nil || queue == nil || sender == nil {
return nil, errors.New("agent runtime dependencies are invalid")
}
if err := state.Validate(); err != nil {
return nil, err
}
if len(epoch) != 32 {
return nil, errors.New("agent epoch is invalid")
}
return &Runner{configuration: configuration, sourceID: sourceID, stateStore: stateStore, state: state, spool: queue, sender: sender, agentEpoch: epoch}, nil
}
func (runner *Runner) RunOnce(ctx context.Context, now time.Time) error {
if now.IsZero() {
return errors.New("agent cycle time is required")
}
var cycleErrors []error
if err := runner.recoverCheckpoints(now); err != nil {
return err
}
deliveryFailed := false
if err := runner.deliver(ctx, now); err != nil {
cycleErrors = append(cycleErrors, err)
deliveryFailed = true
}
for _, source := range runner.configuration.Sources {
cursor := runner.state.Streams[source.StreamID]
if source.Kind == "linux_metrics" {
observations, collectErr := hostmetrics.Collect(*source.LinuxMetrics, now)
if len(observations) == 0 {
if collectErr != nil {
cycleErrors = append(cycleErrors, fmt.Errorf("collect stream %s: %w", source.StreamID, collectErr))
}
continue
}
for start := 0; start < len(observations); start += runner.configuration.BatchRecords {
end := min(start+runner.configuration.BatchRecords, len(observations))
cursor.Sequence++
spooled, err := runner.spoolObservations(source.StreamID, cursor, model.SignalMetrics, observations[start:end], now)
if err != nil {
if spooled {
return err
}
cycleErrors = append(cycleErrors, fmt.Errorf("spool stream %s: %w", source.StreamID, err))
break
}
}
if collectErr != nil {
cycleErrors = append(cycleErrors, fmt.Errorf("collect stream %s: %w", source.StreamID, collectErr))
}
continue
}
result, err := tailer.Read(source, cursor, runner.configuration.BatchRecords, now)
if err != nil {
cycleErrors = append(cycleErrors, fmt.Errorf("collect stream %s: %w", source.StreamID, err))
continue
}
if len(result.Observations) == 0 {
if result.Cursor != cursor {
runner.state.Streams[source.StreamID] = result.Cursor
if err = runner.stateStore.Save(runner.state); err != nil {
return err
}
}
continue
}
result.Cursor.Sequence = cursor.Sequence + 1
spooled, err := runner.spoolObservations(source.StreamID, result.Cursor, result.Signal, result.Observations, now)
if err != nil {
if spooled {
return err
}
cycleErrors = append(cycleErrors, fmt.Errorf("spool stream %s: %w", source.StreamID, err))
continue
}
}
if !deliveryFailed {
if err := runner.deliver(ctx, now); err != nil {
cycleErrors = append(cycleErrors, err)
}
}
return errors.Join(cycleErrors...)
}
func (runner *Runner) spoolObservations(streamID string, cursor agentstate.Cursor, signal model.Signal, observations []model.Observation, now time.Time) (bool, error) {
batch := model.Batch{Version: model.BatchVersion, SourceID: runner.sourceID, StreamID: streamID, Sequence: cursor.Sequence, ObservedAt: now.UTC(), Signal: signal, Records: observations}
checkpoint, err := json.Marshal(cursor)
if err != nil {
return false, err
}
if _, err = runner.spool.PutWithCheckpoint(batch, checkpoint, now); err != nil {
return false, err
}
runner.state.Streams[streamID] = cursor
return true, runner.stateStore.Save(runner.state)
}
func (runner *Runner) recoverCheckpoints(now time.Time) error {
entries, err := runner.spool.List(now)
if err != nil {
return err
}
changed := false
for _, entry := range entries {
_, checkpoint, err := runner.spool.ReadWithCheckpoint(entry)
if err != nil {
return err
}
if len(checkpoint) == 0 {
return errors.New("pending spool batch lacks a cursor checkpoint")
}
cursor, err := decodeCursor(checkpoint)
if err != nil || cursor.Sequence != entry.Sequence {
return errors.New("pending spool checkpoint is invalid")
}
current := runner.state.Streams[entry.StreamID]
if cursor.Sequence < current.Sequence {
continue
}
if cursor.Sequence == current.Sequence {
if cursor != current {
return errors.New("pending spool checkpoint conflicts with agent state")
}
continue
}
if cursor.Sequence != current.Sequence+1 {
return errors.New("pending spool checkpoint has a sequence gap")
}
runner.state.Streams[entry.StreamID] = cursor
changed = true
}
if changed {
return runner.stateStore.Save(runner.state)
}
return nil
}
func (runner *Runner) deliver(ctx context.Context, now time.Time) error {
entries, err := runner.spool.List(now)
if err != nil {
return err
}
blocked := map[string]bool{}
var deliveryErrors []error
for _, entry := range entries {
if blocked[entry.StreamID] {
continue
}
batch, _, err := runner.spool.ReadWithCheckpoint(entry)
if err != nil {
return err
}
ack, sendErr := runner.sender.Send(ctx, batch)
if sendErr != nil {
blocked[entry.StreamID] = true
deliveryErrors = append(deliveryErrors, fmt.Errorf("deliver stream %s sequence %d: %w", entry.StreamID, entry.Sequence, sendErr))
continue
}
batchDigest, digestErr := batch.Digest()
if digestErr != nil || ack.SourceID != batch.SourceID || ack.StreamID != batch.StreamID || ack.Sequence != batch.Sequence || ack.BatchDigest != batchDigest {
blocked[entry.StreamID] = true
deliveryErrors = append(deliveryErrors, fmt.Errorf("deliver stream %s sequence %d: acknowledgement does not match exact batch", entry.StreamID, entry.Sequence))
continue
}
transitionFailed := false
for _, rule := range runner.configuration.AlertRules {
if rule.StreamID != batch.StreamID {
continue
}
evaluation, evaluationErr := edgealert.Evaluate(rule, batch)
if evaluationErr != nil {
blocked[entry.StreamID] = true
deliveryErrors = append(deliveryErrors, fmt.Errorf("evaluate alert rule %s for stream %s sequence %d: %w", rule.ID, entry.StreamID, entry.Sequence, evaluationErr))
transitionFailed = true
break
}
transition := model.AlertTransition{Version: model.AlertTransitionVersion, RuleID: rule.ID, RuleRevision: rule.Revision, AgentEpoch: runner.agentEpoch, Sequence: batch.Sequence, StreamID: batch.StreamID, BatchSequence: batch.Sequence, SegmentDigest: ack.Digest, WindowStart: evaluation.WindowStart, WindowEnd: evaluation.WindowEnd, State: evaluation.State, ObservedAt: evaluation.ObservedAt}
transitionAck, transitionErr := runner.sender.SendAlertTransition(ctx, transition)
expectedDigest, digestErr := transition.Digest()
if transitionErr != nil || digestErr != nil || transitionAck.SourceID != batch.SourceID || transitionAck.RuleID != transition.RuleID || transitionAck.RuleRevision != transition.RuleRevision || transitionAck.AgentEpoch != transition.AgentEpoch || transitionAck.Sequence != transition.Sequence || transitionAck.Digest != expectedDigest {
blocked[entry.StreamID] = true
if transitionErr == nil {
transitionErr = errors.New("acknowledgement does not match exact transition")
}
deliveryErrors = append(deliveryErrors, fmt.Errorf("deliver alert rule %s for stream %s sequence %d: %w", rule.ID, entry.StreamID, entry.Sequence, transitionErr))
transitionFailed = true
break
}
}
if transitionFailed {
continue
}
if err = runner.spool.Acknowledge(entry, entry.Digest); err != nil {
return err
}
}
return errors.Join(deliveryErrors...)
}
func agentEpoch(sourceID string) string {
digest := sha256.Sum256([]byte("observatory-agent-epoch-v1\x00" + sourceID))
return hex.EncodeToString(digest[:16])
}
func decodeCursor(body []byte) (agentstate.Cursor, error) {
decoder := json.NewDecoder(bytes.NewReader(body))
decoder.DisallowUnknownFields()
var cursor agentstate.Cursor
if err := decoder.Decode(&cursor); err != nil {
return agentstate.Cursor{}, err
}
if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
return agentstate.Cursor{}, errors.New("cursor checkpoint has trailing data")
}
if cursor.Offset < 0 || (cursor.Device == 0) != (cursor.Inode == 0) || cursor.Sequence == 0 {
return agentstate.Cursor{}, errors.New("cursor checkpoint is invalid")
}
return cursor, nil
}
func sourceIDFromCredential(credential string) (string, error) {
if !strings.HasPrefix(credential, "obs1.") || strings.ContainsAny(credential, " \t\r\n") {
return "", errors.New("source credential is invalid")
}
remainder := strings.TrimPrefix(credential, "obs1.")
separator := strings.LastIndexByte(remainder, '.')
if separator < 1 || separator == len(remainder)-1 {
return "", errors.New("source credential is invalid")
}
sourceID := remainder[:separator]
if err := model.ValidateSourceID(sourceID); err != nil {
return "", errors.New("source credential is invalid")
}
return sourceID, nil
}
+306
View File
@@ -0,0 +1,306 @@
// SPDX-License-Identifier: AGPL-3.0-only
package agent
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"time"
"gamertan.com/observatory/internal/agentstate"
"gamertan.com/observatory/internal/config"
"gamertan.com/observatory/internal/hostmetrics"
"gamertan.com/observatory/internal/model"
"gamertan.com/observatory/internal/query"
"gamertan.com/observatory/internal/spool"
"gamertan.com/observatory/internal/storage"
)
type fakeSender struct {
fail bool
failAlerts bool
batchDigests map[uint64]string
sent []uint64
alerts []model.AlertTransition
}
func (sender *fakeSender) SendAlertTransition(_ context.Context, transition model.AlertTransition) (storage.SourceAlertTransitionAck, error) {
if sender.failAlerts {
return storage.SourceAlertTransitionAck{}, errors.New("alert endpoint unavailable")
}
sender.alerts = append(sender.alerts, transition)
digest, err := transition.Digest()
if err != nil {
return storage.SourceAlertTransitionAck{}, err
}
return storage.SourceAlertTransitionAck{SourceID: "source", RuleID: transition.RuleID, RuleRevision: transition.RuleRevision, AgentEpoch: transition.AgentEpoch, Sequence: transition.Sequence, Digest: digest}, nil
}
func (sender *fakeSender) Send(_ context.Context, batch model.Batch) (storage.Ack, error) {
if sender.fail {
return storage.Ack{}, errors.New("server unavailable")
}
sender.sent = append(sender.sent, batch.Sequence)
batchDigest, err := batch.Digest()
if err != nil {
return storage.Ack{}, err
}
if override := sender.batchDigests[batch.Sequence]; override != "" {
batchDigest = override
}
return storage.Ack{SourceID: batch.SourceID, StreamID: batch.StreamID, Sequence: batch.Sequence, Digest: strings.Repeat("d", 64), BatchDigest: batchDigest}, nil
}
func TestRunnerCollectsWhileOfflineAndRecoversCheckpoint(t *testing.T) {
root := t.TempDir()
logPath := filepath.Join(root, "request.jsonl")
if err := os.WriteFile(logPath, []byte(requestLine("one")+"\n"), 0o640); err != nil {
t.Fatal(err)
}
spoolRoot := filepath.Join(root, "spool")
configuration := config.Agent{BatchRecords: 1, MaxSpoolBytes: 1 << 20, MaxSpoolAge: 72 * time.Hour, SpoolDir: spoolRoot, StateFile: filepath.Join(spoolRoot, "state.json"), Sources: []config.AgentSource{{Kind: "requestlog_jsonl", Path: logPath, StreamID: "request"}}}
stateStore, state, err := agentstate.Open(configuration.StateFile)
if err != nil {
t.Fatal(err)
}
queue, err := spool.Open(spoolRoot, configuration.MaxSpoolBytes, configuration.MaxSpoolAge)
if err != nil {
t.Fatal(err)
}
sender := &fakeSender{fail: true}
runner, err := New(configuration, "source", stateStore, state, queue, sender)
if err != nil {
t.Fatal(err)
}
now := time.Date(2026, 8, 17, 1, 2, 4, 0, time.UTC)
if err = runner.RunOnce(context.Background(), now); err == nil {
t.Fatal("offline delivery unexpectedly succeeded")
}
file, err := os.OpenFile(logPath, os.O_APPEND|os.O_WRONLY, 0)
if err != nil {
t.Fatal(err)
}
if _, err = file.WriteString(requestLine("two") + "\n"); err != nil {
t.Fatal(err)
}
file.Close()
if err = runner.RunOnce(context.Background(), now.Add(time.Second)); err == nil {
t.Fatal("offline delivery unexpectedly succeeded")
}
entries, err := queue.List(now.Add(time.Second))
if err != nil || len(entries) != 2 || entries[0].Sequence != 1 || entries[1].Sequence != 2 {
t.Fatalf("entries=%+v err=%v", entries, err)
}
// Simulate a crash after both durable spool commits but before either cursor
// checkpoint reached the state file. Recovery must advance state from the
// spool envelopes before it reads the source again.
empty := agentstate.State{Version: agentstate.Version, Streams: map[string]agentstate.Cursor{}}
if err = stateStore.Save(empty); err != nil {
t.Fatal(err)
}
_, recoveredState, err := agentstate.Open(configuration.StateFile)
if err != nil {
t.Fatal(err)
}
sender.fail = false
restarted, err := New(configuration, "source", stateStore, recoveredState, queue, sender)
if err != nil {
t.Fatal(err)
}
if err = restarted.RunOnce(context.Background(), now.Add(2*time.Second)); err != nil {
t.Fatal(err)
}
if len(sender.sent) != 2 || sender.sent[0] != 1 || sender.sent[1] != 2 {
t.Fatalf("sent=%v", sender.sent)
}
if entries, err = queue.List(now.Add(2 * time.Second)); err != nil || len(entries) != 0 {
t.Fatalf("remaining=%+v err=%v", entries, err)
}
_, finalState, err := agentstate.Open(configuration.StateFile)
if err != nil || finalState.Streams["request"].Sequence != 2 {
t.Fatalf("state=%+v err=%v", finalState, err)
}
}
func TestRunnerPreservesSpoolOnMismatchedAcknowledgement(t *testing.T) {
root := t.TempDir()
logPath := filepath.Join(root, "request.jsonl")
if err := os.WriteFile(logPath, []byte(requestLine("one")+"\n"), 0o640); err != nil {
t.Fatal(err)
}
spoolRoot := filepath.Join(root, "spool")
configuration := config.Agent{BatchRecords: 1, MaxSpoolBytes: 1 << 20, MaxSpoolAge: 72 * time.Hour, SpoolDir: spoolRoot, StateFile: filepath.Join(spoolRoot, "state.json"), Sources: []config.AgentSource{{Kind: "requestlog_jsonl", Path: logPath, StreamID: "request"}}}
stateStore, state, err := agentstate.Open(configuration.StateFile)
if err != nil {
t.Fatal(err)
}
queue, err := spool.Open(spoolRoot, configuration.MaxSpoolBytes, configuration.MaxSpoolAge)
if err != nil {
t.Fatal(err)
}
sender := &fakeSender{batchDigests: map[uint64]string{1: strings.Repeat("a", 64)}}
runner, err := New(configuration, "source", stateStore, state, queue, sender)
if err != nil {
t.Fatal(err)
}
now := time.Date(2026, 8, 17, 1, 2, 4, 0, time.UTC)
if err = runner.RunOnce(context.Background(), now); err == nil || !strings.Contains(err.Error(), "acknowledgement does not match exact batch") {
t.Fatalf("expected exact acknowledgement rejection, got %v", err)
}
entries, err := queue.List(now)
if err != nil || len(entries) != 1 {
t.Fatalf("entries=%+v err=%v", entries, err)
}
}
func TestRunnerDeliversLocallyEvaluatedAlertAfterRawBatchAcknowledgement(t *testing.T) {
root := t.TempDir()
logPath := filepath.Join(root, "request.jsonl")
if err := os.WriteFile(logPath, []byte(requestLineWithStatus("failed", 503)+"\n"), 0o640); err != nil {
t.Fatal(err)
}
spoolRoot := filepath.Join(root, "spool")
ast, err := query.Parse("logs | where status >= 500 | limit 10", model.MaxRecords)
if err != nil {
t.Fatal(err)
}
configuration := config.Agent{BatchRecords: 10, MaxSpoolBytes: 1 << 20, MaxSpoolAge: time.Hour, SpoolDir: spoolRoot, StateFile: filepath.Join(spoolRoot, "state.json"), Sources: []config.AgentSource{{Kind: "requestlog_jsonl", Path: logPath, StreamID: "request"}}, AlertRules: []config.AgentAlertRule{{Version: 1, ID: "http-failures", Revision: 1, StreamID: "request", Query: "logs | where status >= 500 | limit 10", MinimumMatches: 1, AST: ast}}}
stateStore, state, err := agentstate.Open(configuration.StateFile)
if err != nil {
t.Fatal(err)
}
queue, err := spool.Open(spoolRoot, configuration.MaxSpoolBytes, configuration.MaxSpoolAge)
if err != nil {
t.Fatal(err)
}
sender := &fakeSender{}
runner, err := New(configuration, "source", stateStore, state, queue, sender)
if err != nil {
t.Fatal(err)
}
now := time.Date(2026, 8, 17, 1, 2, 4, 0, time.UTC)
if err = runner.RunOnce(context.Background(), now); err != nil {
t.Fatal(err)
}
if len(sender.sent) != 1 || len(sender.alerts) != 1 || sender.alerts[0].State != "matched" || sender.alerts[0].Sequence != 1 || sender.alerts[0].SegmentDigest != strings.Repeat("d", 64) || len(sender.alerts[0].AgentEpoch) != 32 {
t.Fatalf("sent=%v alerts=%+v", sender.sent, sender.alerts)
}
if entries, listErr := queue.List(now); listErr != nil || len(entries) != 0 {
t.Fatalf("entries=%+v err=%v", entries, listErr)
}
}
func TestRunnerRetainsRawBatchUntilAlertTransitionIsAcknowledged(t *testing.T) {
root := t.TempDir()
logPath := filepath.Join(root, "request.jsonl")
if err := os.WriteFile(logPath, []byte(requestLineWithStatus("failed", 503)+"\n"), 0o640); err != nil {
t.Fatal(err)
}
spoolRoot := filepath.Join(root, "spool")
ast, err := query.Parse("logs | where status >= 500 | limit 10", model.MaxRecords)
if err != nil {
t.Fatal(err)
}
configuration := config.Agent{BatchRecords: 10, MaxSpoolBytes: 1 << 20, MaxSpoolAge: time.Hour, SpoolDir: spoolRoot, StateFile: filepath.Join(spoolRoot, "state.json"), Sources: []config.AgentSource{{Kind: "requestlog_jsonl", Path: logPath, StreamID: "request"}}, AlertRules: []config.AgentAlertRule{{Version: 1, ID: "http-failures", Revision: 1, StreamID: "request", Query: "logs | where status >= 500 | limit 10", MinimumMatches: 1, AST: ast}}}
stateStore, state, err := agentstate.Open(configuration.StateFile)
if err != nil {
t.Fatal(err)
}
queue, err := spool.Open(spoolRoot, configuration.MaxSpoolBytes, configuration.MaxSpoolAge)
if err != nil {
t.Fatal(err)
}
sender := &fakeSender{failAlerts: true}
runner, err := New(configuration, "source", stateStore, state, queue, sender)
if err != nil {
t.Fatal(err)
}
now := time.Date(2026, 8, 17, 1, 2, 4, 0, time.UTC)
if err = runner.RunOnce(context.Background(), now); err == nil || !strings.Contains(err.Error(), "alert endpoint unavailable") {
t.Fatalf("err=%v", err)
}
if entries, listErr := queue.List(now); listErr != nil || len(entries) != 1 {
t.Fatalf("entries=%+v err=%v", entries, listErr)
}
sender.failAlerts = false
if err = runner.RunOnce(context.Background(), now.Add(time.Second)); err != nil {
t.Fatal(err)
}
if len(sender.sent) != 2 || len(sender.alerts) != 1 {
t.Fatalf("sent=%v alerts=%+v", sender.sent, sender.alerts)
}
}
func TestSourceIDParsingKeepsDotsInsideIdentifier(t *testing.T) {
id, err := sourceIDFromCredential("obs1.node.example.abcdef")
if err != nil || id != "node.example" {
t.Fatalf("id=%q err=%v", id, err)
}
for _, invalid := range []string{"token", "obs1..secret", "obs1.bad value.secret"} {
if _, err = sourceIDFromCredential(invalid); err == nil {
t.Fatalf("accepted %q", invalid)
}
}
}
func TestRunnerSpoolsLinuxMetricsWhileServerIsOffline(t *testing.T) {
root := t.TempDir()
proc := filepath.Join(root, "proc")
if err := os.MkdirAll(filepath.Join(proc, "net"), 0o700); err != nil {
t.Fatal(err)
}
files := map[string]string{
"stat": "cpu 1 2 3 4\n", "uptime": "10 5\n",
"meminfo": "MemTotal: 10 kB\nMemAvailable: 8 kB\nSwapTotal: 2 kB\nSwapFree: 1 kB\n",
"loadavg": "0.1 0.2 0.3 1/1 1\n", filepath.Join("net", "dev"): "Inter-| Receive | Transmit\n",
}
for name, body := range files {
if err := os.WriteFile(filepath.Join(proc, name), []byte(body), 0o600); err != nil {
t.Fatal(err)
}
}
spoolRoot := filepath.Join(root, "spool")
configuration := config.Agent{BatchRecords: 3, MaxSpoolBytes: 1 << 20, MaxSpoolAge: time.Hour, SpoolDir: spoolRoot, StateFile: filepath.Join(spoolRoot, "state.json"), Sources: []config.AgentSource{{Kind: "linux_metrics", StreamID: "host-metrics", LinuxMetrics: &hostmetrics.Config{ProcRoot: proc}}}}
stateStore, state, err := agentstate.Open(configuration.StateFile)
if err != nil {
t.Fatal(err)
}
queue, err := spool.Open(spoolRoot, configuration.MaxSpoolBytes, configuration.MaxSpoolAge)
if err != nil {
t.Fatal(err)
}
runner, err := New(configuration, "source", stateStore, state, queue, &fakeSender{fail: true})
if err != nil {
t.Fatal(err)
}
now := time.Date(2026, 8, 17, 6, 0, 0, 0, time.UTC)
if err = runner.RunOnce(context.Background(), now); err == nil {
t.Fatal("offline delivery unexpectedly succeeded")
}
entries, err := queue.List(now)
if err != nil || len(entries) < 2 {
t.Fatalf("entries=%+v err=%v", entries, err)
}
for index, entry := range entries {
batch, _, readErr := queue.ReadWithCheckpoint(entry)
if readErr != nil {
t.Fatal(readErr)
}
if batch.Signal != model.SignalMetrics || batch.StreamID != "host-metrics" || len(batch.Records) == 0 || len(batch.Records) > configuration.BatchRecords || batch.Sequence != uint64(index+1) {
t.Fatalf("batch=%+v", batch)
}
}
}
func requestLine(route string) string {
return `{"timestamp":"2026-08-17T01:02:03Z","method":"GET","route":"/` + route + `","status":200,"bytes":12,"duration_ns":1000,"request_id":"request-1"}`
}
func requestLineWithStatus(route string, status int) string {
return fmt.Sprintf(`{"timestamp":"2026-08-17T01:02:03Z","method":"GET","route":"/%s","status":%d,"bytes":12,"duration_ns":1000,"request_id":"request-1"}`, route, status)
}
+229
View File
@@ -0,0 +1,229 @@
// SPDX-License-Identifier: AGPL-3.0-only
package agentclient
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"gamertan.com/observatory/internal/model"
"gamertan.com/observatory/internal/nativeprotocol"
"gamertan.com/observatory/internal/storage"
)
type Client struct {
endpoint string
alertEndpoint string
credential string
sourceID string
http *http.Client
}
type EnrollmentResult struct {
SourceID string `json:"source_id"`
Credential string `json:"credential"`
}
func Enroll(ctx context.Context, serverURL, enrollmentToken string, transport http.RoundTripper) (EnrollmentResult, error) {
u, err := url.Parse(serverURL)
if err != nil || u.Scheme != "https" || u.Host == "" || u.User != nil || (u.Path != "" && u.Path != "/") || u.RawQuery != "" || u.Fragment != "" {
return EnrollmentResult{}, errors.New("server URL must be an absolute HTTPS origin")
}
if len(enrollmentToken) != len("obse1.")+64 || !strings.HasPrefix(enrollmentToken, "obse1.") || strings.ContainsAny(enrollmentToken, " \t\r\n") {
return EnrollmentResult{}, errors.New("invalid enrollment token")
}
if transport == nil {
transport = http.DefaultTransport
}
client := &http.Client{Transport: transport, Timeout: 30 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return errors.New("redirect refused") }}
request, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimSuffix(serverURL, "/")+"/api/v1/agent/enroll", http.NoBody)
if err != nil {
return EnrollmentResult{}, errors.New("create enrollment request")
}
request.Header.Set("Authorization", "Bearer "+enrollmentToken)
request.Header.Set("Accept", "application/json")
response, err := client.Do(request)
if err != nil {
return EnrollmentResult{}, errors.New("enrollment request failed")
}
defer response.Body.Close()
if response.StatusCode != http.StatusCreated {
_, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 64<<10))
return EnrollmentResult{}, fmt.Errorf("enrollment returned HTTP %d", response.StatusCode)
}
decoder := json.NewDecoder(io.LimitReader(response.Body, 64<<10))
decoder.DisallowUnknownFields()
var result EnrollmentResult
if err = decoder.Decode(&result); err != nil {
return EnrollmentResult{}, errors.New("invalid enrollment response")
}
if err = decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
return EnrollmentResult{}, errors.New("enrollment response has trailing data")
}
if err = validateCredential(result.SourceID, result.Credential); err != nil {
return EnrollmentResult{}, err
}
return result, nil
}
func RevokeSource(ctx context.Context, serverURL, credential string, transport http.RoundTripper) error {
u, err := url.Parse(serverURL)
if err != nil || u.Scheme != "https" || u.Host == "" || u.User != nil || (u.Path != "" && u.Path != "/") || u.RawQuery != "" || u.Fragment != "" {
return errors.New("server URL must be an absolute HTTPS origin")
}
if len(credential) < 48 || len(credential) > 512 || !strings.HasPrefix(credential, "obs1.") || strings.ContainsAny(credential, " \t\r\n") {
return errors.New("invalid source credential")
}
if transport == nil {
transport = http.DefaultTransport
}
client := &http.Client{Transport: transport, Timeout: 30 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return errors.New("redirect refused") }}
request, err := http.NewRequestWithContext(ctx, http.MethodDelete, strings.TrimSuffix(serverURL, "/")+"/api/v1/agent/source", http.NoBody)
if err != nil {
return errors.New("create source revocation request")
}
request.Header.Set("Authorization", "Bearer "+credential)
response, err := client.Do(request)
if err != nil {
return errors.New("source revocation request failed")
}
defer response.Body.Close()
_, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 64<<10))
if response.StatusCode != http.StatusNoContent {
return fmt.Errorf("source revocation returned HTTP %d", response.StatusCode)
}
return nil
}
func New(serverURL, credential string, transport http.RoundTripper) (*Client, error) {
u, err := url.Parse(serverURL)
if err != nil || u.Scheme != "https" || u.Host == "" || u.User != nil || (u.Path != "" && u.Path != "/") || u.RawQuery != "" || u.Fragment != "" {
return nil, errors.New("server URL must be an absolute HTTPS origin")
}
sourceID, credentialErr := credentialSourceID(credential)
if credentialErr != nil {
return nil, errors.New("invalid source credential")
}
if transport == nil {
transport = http.DefaultTransport
}
httpClient := &http.Client{Transport: transport, Timeout: 30 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return errors.New("redirect refused") }}
base := strings.TrimSuffix(serverURL, "/")
return &Client{endpoint: base + "/api/v2/ingest/native", alertEndpoint: base + "/api/v1/agent/alert-transition", credential: credential, sourceID: sourceID, http: httpClient}, nil
}
func (c *Client) SendAlertTransition(ctx context.Context, transition model.AlertTransition) (storage.SourceAlertTransitionAck, error) {
b, err := json.Marshal(transition)
if err != nil {
return storage.SourceAlertTransitionAck{}, errors.New("encode source alert transition")
}
expectedDigest, err := transition.Digest()
if err != nil {
return storage.SourceAlertTransitionAck{}, err
}
request, err := http.NewRequestWithContext(ctx, http.MethodPost, c.alertEndpoint, bytes.NewReader(b))
if err != nil {
return storage.SourceAlertTransitionAck{}, errors.New("create source alert transition request")
}
request.Header.Set("Authorization", "Bearer "+c.credential)
request.Header.Set("Content-Type", "application/json")
request.Header.Set("Accept", "application/json")
response, err := c.http.Do(request)
if err != nil {
return storage.SourceAlertTransitionAck{}, errors.New("source alert transition request failed")
}
defer response.Body.Close()
if response.StatusCode != http.StatusAccepted {
_, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 64<<10))
return storage.SourceAlertTransitionAck{}, fmt.Errorf("source alert transition returned HTTP %d", response.StatusCode)
}
decoder := json.NewDecoder(io.LimitReader(response.Body, 64<<10))
decoder.DisallowUnknownFields()
var ack storage.SourceAlertTransitionAck
if err = decoder.Decode(&ack); err != nil {
return storage.SourceAlertTransitionAck{}, errors.New("invalid source alert transition acknowledgement")
}
if err = decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
return storage.SourceAlertTransitionAck{}, errors.New("source alert transition acknowledgement has trailing data")
}
if ack.SourceID != c.sourceID || ack.RuleID != transition.RuleID || ack.RuleRevision != transition.RuleRevision || ack.AgentEpoch != transition.AgentEpoch || ack.Sequence != transition.Sequence || ack.Digest != expectedDigest {
return storage.SourceAlertTransitionAck{}, errors.New("source alert transition acknowledgement does not match transition")
}
return ack, nil
}
func credentialSourceID(credential string) (string, error) {
if len(credential) < 48 || len(credential) > 512 || !strings.HasPrefix(credential, "obs1.") || strings.ContainsAny(credential, " \t\r\n") {
return "", errors.New("invalid source credential")
}
remainder := strings.TrimPrefix(credential, "obs1.")
separator := strings.LastIndexByte(remainder, '.')
if separator < 1 || separator == len(remainder)-1 {
return "", errors.New("invalid source credential")
}
sourceID := remainder[:separator]
if model.ValidateSourceID(sourceID) != nil {
return "", errors.New("invalid source credential")
}
return sourceID, nil
}
func validateCredential(sourceID, credential string) error {
if model.ValidateSourceID(sourceID) != nil || len(credential) < 48 || len(credential) > 512 || !strings.HasPrefix(credential, "obs1."+sourceID+".") || strings.ContainsAny(credential, " \t\r\n") {
return errors.New("invalid source credential")
}
return nil
}
func (c *Client) Send(ctx context.Context, batch model.Batch) (storage.Ack, error) {
b, err := json.Marshal(batch)
if err != nil {
return storage.Ack{}, errors.New("encode native batch")
}
envelope, err := batch.Envelope(b)
if err != nil {
return storage.Ack{}, errors.New("encode native batch envelope")
}
request, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoint, bytes.NewReader(b))
if err != nil {
return storage.Ack{}, errors.New("create ingestion request")
}
request.Header.Set("Authorization", "Bearer "+c.credential)
request.Header.Set("Content-Type", "application/json")
request.Header.Set("Accept", "application/json")
nativeprotocol.SetHeaders(request.Header, envelope)
response, err := c.http.Do(request)
if err != nil {
return storage.Ack{}, errors.New("ingestion request failed")
}
defer response.Body.Close()
if response.StatusCode != http.StatusAccepted {
_, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 64<<10))
return storage.Ack{}, fmt.Errorf("ingestion returned HTTP %d", response.StatusCode)
}
decoder := json.NewDecoder(io.LimitReader(response.Body, 64<<10))
decoder.DisallowUnknownFields()
var ack storage.Ack
if err := decoder.Decode(&ack); err != nil {
return storage.Ack{}, errors.New("invalid ingestion acknowledgement")
}
if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
return storage.Ack{}, errors.New("ingestion acknowledgement has trailing data")
}
decodedDigest, digestErr := hex.DecodeString(ack.Digest)
decodedBatchDigest, batchDigestErr := hex.DecodeString(ack.BatchDigest)
if ack.SourceID != batch.SourceID || ack.StreamID != batch.StreamID || ack.Sequence != batch.Sequence || digestErr != nil || len(decodedDigest) != sha256.Size || batchDigestErr != nil || len(decodedBatchDigest) != sha256.Size || ack.BatchDigest != envelope.BatchDigest {
return storage.Ack{}, errors.New("ingestion acknowledgement does not match batch")
}
return ack, nil
}
+206
View File
@@ -0,0 +1,206 @@
// SPDX-License-Identifier: AGPL-3.0-only
package agentclient
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"gamertan.com/observatory/internal/httpserver"
"gamertan.com/observatory/internal/identity"
"gamertan.com/observatory/internal/model"
"gamertan.com/observatory/internal/nativeprotocol"
"gamertan.com/observatory/internal/query"
"gamertan.com/observatory/internal/storage"
)
type transportFunc func(*http.Request) (*http.Response, error)
func (f transportFunc) RoundTrip(request *http.Request) (*http.Response, error) { return f(request) }
func TestSendRequiresMatchingAcknowledgementAndDoesNotRedirect(t *testing.T) {
credential := "obs1.source." + strings.Repeat("a", 64)
var authorization, path string
client, err := New("https://observatory.example", credential, transportFunc(func(request *http.Request) (*http.Response, error) {
authorization = request.Header.Get("Authorization")
path = request.URL.Path
body, readErr := io.ReadAll(request.Body)
if readErr != nil {
t.Fatal(readErr)
}
digest := sha256.Sum256(body)
if request.Header.Get(nativeprotocol.WireDigestHeader) != hex.EncodeToString(digest[:]) || request.Header.Get(nativeprotocol.StreamHeader) != "access" || request.Header.Get(nativeprotocol.SequenceHeader) != "1" {
t.Fatalf("missing framed batch metadata: %v", request.Header)
}
response := fmt.Sprintf(`{"source_id":"source","stream_id":"access","sequence":1,"digest":"%s","batch_digest":"%s","duplicate":false}`, strings.Repeat("a", 64), hex.EncodeToString(digest[:]))
return &http.Response{StatusCode: http.StatusAccepted, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(response))}, nil
}))
if err != nil {
t.Fatal(err)
}
now := time.Now().UTC()
batch := model.Batch{Version: 1, SourceID: "source", StreamID: "access", Sequence: 1, ObservedAt: now, Signal: model.SignalLogs, Records: []model.Observation{{Timestamp: now, Name: "request"}}}
if _, err := client.Send(context.Background(), batch); err != nil {
t.Fatal(err)
}
if authorization != "Bearer "+credential || path != "/api/v2/ingest/native" {
t.Fatalf("authorization=%q path=%q", authorization, path)
}
}
func TestSendAcceptsRealServerBatchDigestRatherThanPrivateSegmentDigest(t *testing.T) {
root := filepath.Join(t.TempDir(), "data")
if err := os.Mkdir(root, 0o700); err != nil {
t.Fatal(err)
}
store, err := storage.Open(root)
if err != nil {
t.Fatal(err)
}
defer store.Close()
identities, err := identity.Open(root)
if err != nil {
t.Fatal(err)
}
defer identities.Close()
token, err := store.CreateSource(context.Background(), "source", model.Scope{OrganizationID: "organization", ProjectID: "project", EnvironmentID: "production", ServiceID: "service"})
if err != nil {
t.Fatal(err)
}
server, err := httpserver.New(store, identities, httpserver.Options{
PublicOrigin: "https://observatory.example", MaxBodyBytes: 1 << 20, MaxQueryRows: 100,
QueryBudget: query.Budget{MaxDuration: time.Second, MaxRows: 100, MaxScannedBytes: 1 << 20, MaxMemoryBytes: 1 << 20}, SessionLifetime: time.Hour,
})
if err != nil {
t.Fatal(err)
}
client, err := New("https://observatory.example", token, transportFunc(func(request *http.Request) (*http.Response, error) {
request.RemoteAddr = "127.0.0.1:40000"
response := httptest.NewRecorder()
server.Handler().ServeHTTP(response, request)
return response.Result(), nil
}))
if err != nil {
t.Fatal(err)
}
now := time.Now().UTC()
batch := model.Batch{Version: 1, SourceID: "source", StreamID: "access", Sequence: 1, ObservedAt: now, Signal: model.SignalLogs, Records: []model.Observation{{Timestamp: now, Name: "request"}}}
ack, err := client.Send(context.Background(), batch)
if err != nil {
t.Fatal(err)
}
expected, err := batch.Digest()
if err != nil {
t.Fatal(err)
}
if ack.BatchDigest != expected || ack.Digest == ack.BatchDigest {
t.Fatalf("ack=%+v expected batch digest=%s", ack, expected)
}
}
func TestSendRejectsMismatchedAcknowledgementWithoutEchoingBody(t *testing.T) {
credential := "obs1.source." + strings.Repeat("a", 64)
client, err := New("https://observatory.example", credential, transportFunc(func(*http.Request) (*http.Response, error) {
return &http.Response{StatusCode: http.StatusAccepted, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"source_id":"source","stream_id":"access","sequence":1,"digest":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","batch_digest":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}`))}, nil
}))
if err != nil {
t.Fatal(err)
}
now := time.Now().UTC()
batch := model.Batch{Version: 1, SourceID: "source", StreamID: "access", Sequence: 1, ObservedAt: now, Signal: model.SignalLogs, Records: []model.Observation{{Timestamp: now, Name: "request", Body: "do-not-echo"}}}
_, err = client.Send(context.Background(), batch)
if err == nil || strings.Contains(err.Error(), "do-not-echo") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestSendAlertTransitionUsesExactEndpointAndRejectsMismatchedAcknowledgement(t *testing.T) {
credential := "obs1.source." + strings.Repeat("a", 64)
now := time.Date(2026, 8, 18, 23, 50, 0, 0, time.UTC)
transition := model.AlertTransition{Version: model.AlertTransitionVersion, RuleID: "rule-a", RuleRevision: 2, AgentEpoch: strings.Repeat("b", 32), Sequence: 3, StreamID: "requests", BatchSequence: 8, SegmentDigest: strings.Repeat("c", 64), WindowStart: now.Add(-time.Minute), WindowEnd: now, State: "matched", ObservedAt: now}
digest, err := transition.Digest()
if err != nil {
t.Fatal(err)
}
var path, authorization string
client, err := New("https://observatory.example", credential, transportFunc(func(request *http.Request) (*http.Response, error) {
path = request.URL.Path
authorization = request.Header.Get("Authorization")
body, readErr := io.ReadAll(request.Body)
if readErr != nil || !strings.Contains(string(body), `"segment_digest":"`+transition.SegmentDigest+`"`) {
t.Fatalf("body=%q err=%v", string(body), readErr)
}
response := fmt.Sprintf(`{"source_id":"source","rule_id":"rule-a","rule_revision":2,"agent_epoch":"%s","sequence":3,"digest":"%s","duplicate":false}`, transition.AgentEpoch, digest)
return &http.Response{StatusCode: http.StatusAccepted, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(response))}, nil
}))
if err != nil {
t.Fatal(err)
}
ack, err := client.SendAlertTransition(context.Background(), transition)
if err != nil || ack.Digest != digest || path != "/api/v1/agent/alert-transition" || authorization != "Bearer "+credential {
t.Fatalf("ack=%+v path=%q authorization=%q err=%v", ack, path, authorization, err)
}
mismatch, err := New("https://observatory.example", credential, transportFunc(func(*http.Request) (*http.Response, error) {
return &http.Response{StatusCode: http.StatusAccepted, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(fmt.Sprintf(`{"source_id":"source","rule_id":"rule-a","rule_revision":2,"agent_epoch":"%s","sequence":4,"digest":"%s","duplicate":false}`, transition.AgentEpoch, digest)))}, nil
}))
if err != nil {
t.Fatal(err)
}
if _, err = mismatch.SendAlertTransition(context.Background(), transition); err == nil || !strings.Contains(err.Error(), "does not match") {
t.Fatalf("mismatch err=%v", err)
}
wrongSource, err := New("https://observatory.example", credential, transportFunc(func(*http.Request) (*http.Response, error) {
return &http.Response{StatusCode: http.StatusAccepted, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(fmt.Sprintf(`{"source_id":"other","rule_id":"rule-a","rule_revision":2,"agent_epoch":"%s","sequence":3,"digest":"%s","duplicate":false}`, transition.AgentEpoch, digest)))}, nil
}))
if err != nil {
t.Fatal(err)
}
if _, err = wrongSource.SendAlertTransition(context.Background(), transition); err == nil || !strings.Contains(err.Error(), "does not match") {
t.Fatalf("wrong source err=%v", err)
}
}
func TestEnrollAndRevokeUseExactHTTPSEndpoints(t *testing.T) {
enrollment := "obse1." + strings.Repeat("e", 64)
credential := "obs1.source." + strings.Repeat("a", 64)
var methods []string
transport := transportFunc(func(request *http.Request) (*http.Response, error) {
methods = append(methods, request.Method+" "+request.URL.Path)
switch request.URL.Path {
case "/api/v1/agent/enroll":
if request.Header.Get("Authorization") != "Bearer "+enrollment {
t.Fatal("enrollment authorization missing")
}
return &http.Response{StatusCode: http.StatusCreated, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"source_id":"source","credential":"` + credential + `"}`))}, nil
case "/api/v1/agent/source":
if request.Header.Get("Authorization") != "Bearer "+credential {
t.Fatal("source authorization missing")
}
return &http.Response{StatusCode: http.StatusNoContent, Header: make(http.Header), Body: http.NoBody}, nil
default:
t.Fatalf("unexpected endpoint %s", request.URL.Path)
return nil, nil
}
})
result, err := Enroll(context.Background(), "https://observatory.example", enrollment, transport)
if err != nil || result.SourceID != "source" || result.Credential != credential {
t.Fatalf("result=%+v err=%v", result, err)
}
if err = RevokeSource(context.Background(), "https://observatory.example", credential, transport); err != nil {
t.Fatal(err)
}
if len(methods) != 2 || methods[0] != "POST /api/v1/agent/enroll" || methods[1] != "DELETE /api/v1/agent/source" {
t.Fatalf("methods=%v", methods)
}
}
+149
View File
@@ -0,0 +1,149 @@
// SPDX-License-Identifier: AGPL-3.0-only
package agentstate
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
)
const Version = 1
type Cursor struct {
Device uint64 `json:"device"`
Inode uint64 `json:"inode"`
Offset int64 `json:"offset"`
Sequence uint64 `json:"sequence"`
DiscardingLine bool `json:"discarding_line"`
DroppedRecords uint64 `json:"dropped_records"`
Discontinuities uint64 `json:"discontinuities"`
}
type State struct {
Version int `json:"version"`
Streams map[string]Cursor `json:"streams"`
}
type Store struct{ path string }
func Open(path string) (*Store, State, error) {
if !filepath.IsAbs(path) || filepath.Clean(path) != path {
return nil, State{}, errors.New("agent state path must be absolute and clean")
}
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return nil, State{}, fmt.Errorf("create agent state directory: %w", err)
}
parent, err := os.Lstat(filepath.Dir(path))
if err != nil || !parent.IsDir() || parent.Mode()&os.ModeSymlink != 0 || parent.Mode().Perm()&0o077 != 0 {
return nil, State{}, errors.New("agent state directory must be private and must not be a symlink")
}
store := &Store{path: path}
state, err := store.Load()
return store, state, err
}
func (store *Store) Load() (State, error) {
info, err := os.Lstat(store.path)
if errors.Is(err, os.ErrNotExist) {
return State{Version: Version, Streams: map[string]Cursor{}}, nil
}
if err != nil {
return State{}, fmt.Errorf("inspect agent state: %w", err)
}
if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 || info.Mode().Perm() != 0o600 {
return State{}, errors.New("agent state must be a mode-0600 regular non-symlink file")
}
body, err := os.ReadFile(store.path)
if err != nil {
return State{}, fmt.Errorf("read agent state: %w", err)
}
decoder := json.NewDecoder(bytes.NewReader(body))
decoder.DisallowUnknownFields()
var state State
if err = decoder.Decode(&state); err != nil {
return State{}, errors.New("decode agent state")
}
if err = decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
return State{}, errors.New("agent state contains trailing data")
}
if err = state.Validate(); err != nil {
return State{}, err
}
return state, nil
}
func (store *Store) Save(state State) error {
if err := state.Validate(); err != nil {
return err
}
body, err := json.Marshal(state)
if err != nil {
return err
}
body = append(body, '\n')
dir := filepath.Dir(store.path)
temporary, err := os.CreateTemp(dir, ".state-*")
if err != nil {
return err
}
name := temporary.Name()
defer os.Remove(name)
if err = temporary.Chmod(0o600); err == nil {
_, err = temporary.Write(body)
}
if err == nil {
err = temporary.Sync()
}
if closeErr := temporary.Close(); err == nil {
err = closeErr
}
if err != nil {
return fmt.Errorf("write agent state: %w", err)
}
if existing, inspectErr := os.Lstat(store.path); inspectErr == nil {
if !existing.Mode().IsRegular() || existing.Mode()&os.ModeSymlink != 0 {
return errors.New("agent state destination is not a regular file")
}
} else if !errors.Is(inspectErr, os.ErrNotExist) {
return inspectErr
}
if err = os.Rename(name, store.path); err != nil {
return err
}
directory, err := os.Open(dir)
if err != nil {
return err
}
defer directory.Close()
return directory.Sync()
}
func (state State) Validate() error {
if state.Version != Version || state.Streams == nil || len(state.Streams) > 64 {
return errors.New("agent state identity is invalid")
}
for stream, cursor := range state.Streams {
if !safeID(stream) || cursor.Offset < 0 || (cursor.Device == 0) != (cursor.Inode == 0) {
return errors.New("agent state cursor is invalid")
}
}
return nil
}
func safeID(value string) bool {
if value == "" || len(value) > 128 {
return false
}
for _, character := range value {
if !(character >= 'a' && character <= 'z' || character >= 'A' && character <= 'Z' || character >= '0' && character <= '9' || strings.ContainsRune("._-", character)) {
return false
}
}
return true
}
+43
View File
@@ -0,0 +1,43 @@
// SPDX-License-Identifier: AGPL-3.0-only
package agentstate
import (
"os"
"path/filepath"
"testing"
)
func TestStateAtomicRoundTripAndSymlinkRefusal(t *testing.T) {
root := filepath.Join(t.TempDir(), "state")
path := filepath.Join(root, "state.json")
store, state, err := Open(path)
if err != nil {
t.Fatal(err)
}
state.Streams["access"] = Cursor{Device: 1, Inode: 2, Offset: 99, Sequence: 3, DroppedRecords: 4}
if err = store.Save(state); err != nil {
t.Fatal(err)
}
_, loaded, err := Open(path)
if err != nil || loaded.Streams["access"] != state.Streams["access"] {
t.Fatalf("loaded=%+v err=%v", loaded, err)
}
info, err := os.Stat(path)
if err != nil || info.Mode().Perm() != 0o600 {
t.Fatalf("state info=%v err=%v", info, err)
}
if err = os.Remove(path); err != nil {
t.Fatal(err)
}
outside := filepath.Join(t.TempDir(), "outside")
if err = os.WriteFile(outside, []byte(`{"version":1,"streams":{}}`), 0o600); err != nil {
t.Fatal(err)
}
if err = os.Symlink(outside, path); err != nil {
t.Skip(err)
}
if _, _, err = Open(path); err == nil {
t.Fatal("symlinked state accepted")
}
}
+253
View File
@@ -0,0 +1,253 @@
//go:build observatory_browser_fixture
// SPDX-License-Identifier: AGPL-3.0-only
// Command browsertest serves a disposable HTTPS Observatory instance for the
// real-browser verification campaign. It is excluded from ordinary builds.
package main
import (
"bytes"
"context"
"crypto/ecdh"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/sha256"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/base64"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"math/big"
"net"
"net/http"
"net/http/httptest"
"os"
"os/signal"
"path/filepath"
"syscall"
"time"
"gamertan.com/observatory/internal/httpserver"
"gamertan.com/observatory/internal/identity"
"gamertan.com/observatory/internal/model"
"gamertan.com/observatory/internal/query"
"gamertan.com/observatory/internal/storage"
)
const (
fixtureUsername = "browser-operator"
fixturePassword = "browser-fixture-password"
)
type pushDispatcher struct{}
func (pushDispatcher) Enqueue(string) bool { return true }
func main() {
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, "browser fixture failed:", err)
os.Exit(1)
}
}
func run() error {
root, err := os.MkdirTemp("", "observatory-browser-fixture-")
if err != nil {
return errors.New("create browser fixture root")
}
defer os.RemoveAll(root)
if err = os.Chmod(root, 0o700); err != nil {
return errors.New("protect browser fixture root")
}
certificate, spki, err := localCertificate()
if err != nil {
return err
}
listener, err := tls.Listen("tcp", "127.0.0.1:0", &tls.Config{Certificates: []tls.Certificate{certificate}, MinVersion: tls.VersionTLS13})
if err != nil {
return errors.New("listen for browser fixture")
}
defer listener.Close()
_, port, err := net.SplitHostPort(listener.Addr().String())
if err != nil {
return errors.New("read browser fixture listener")
}
origin := "https://localhost:" + port
store, err := storage.Open(filepath.Join(root, "data"))
if err != nil {
return err
}
defer store.Close()
identities, err := identity.Open(filepath.Join(root, "data"))
if err != nil {
return err
}
defer identities.Close()
bootstrap, err := identities.Bootstrap(context.Background(), identity.BootstrapInput{
Username: fixtureUsername, Email: "browser@example.test", DisplayName: "Browser Operator", Password: fixturePassword,
})
if err != nil {
return err
}
now := time.Now().UTC()
scope := model.Scope{OrganizationID: bootstrap.Organization.ID, ProjectID: "browser-project", EnvironmentID: "test", ServiceID: "browser-service"}
token, err := store.CreateSource(context.Background(), "browser-source", scope)
if err != nil {
return err
}
if _, err = store.Ingest(context.Background(), token, logBatch(1, now), now); err != nil {
return err
}
if err = store.Recover(context.Background()); err != nil {
return err
}
saved, err := store.SaveQuery(context.Background(), storage.SavedQueryInput{
OrganizationID: scope.OrganizationID, Name: "Browser fixture evidence", Description: "Exact browser-campaign evidence.",
Query: "logs | window 1h | limit 10", ActorUserID: bootstrap.User.ID, MaxRows: 100,
}, now)
if err != nil {
return err
}
if _, err = store.SaveAlertRule(context.Background(), storage.AlertRuleInput{
OrganizationID: scope.OrganizationID, Name: "Browser fixture incident", Description: "Exercises the private offline incident boundary.",
SavedQueryID: saved.ID, Severity: "critical", MinimumMatches: 1, RequiredConsecutive: 1,
EvaluationInterval: 15 * time.Second, Enabled: true, ActorUserID: bootstrap.User.ID,
}, now); err != nil {
return err
}
vapid, err := ecdh.P256().GenerateKey(rand.Reader)
if err != nil {
return errors.New("create browser fixture Web Push key")
}
server, err := httpserver.New(store, identities, httpserver.Options{
PublicOrigin: origin, MaxBodyBytes: 1 << 20, MaxQueryRows: 100,
QueryBudget: query.Budget{MaxDuration: 2 * time.Second, MaxRows: 100, MaxScannedBytes: 32 << 20, MaxMemoryBytes: 16 << 20},
SessionLifetime: time.Hour, PushPublicKey: base64.RawURLEncoding.EncodeToString(vapid.PublicKey().Bytes()), PushDispatcher: pushDispatcher{},
})
if err != nil {
return err
}
if _, err = server.EvaluateAlerts(context.Background()); err != nil {
return err
}
handler := browserFixture(server.Handler())
httpServer := &http.Server{Handler: handler, ReadHeaderTimeout: 5 * time.Second, IdleTimeout: 30 * time.Second}
serveErrors := make(chan error, 1)
go func() { serveErrors <- httpServer.Serve(listener) }()
updatesDone := make(chan struct{})
defer close(updatesDone)
go publishUpdates(handler, origin, token, updatesDone)
fmt.Println(origin)
fmt.Println(spki)
signals := make(chan os.Signal, 1)
signal.Notify(signals, os.Interrupt, syscall.SIGTERM)
defer signal.Stop(signals)
select {
case <-signals:
shutdown, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
return httpServer.Shutdown(shutdown)
case serveErr := <-serveErrors:
if errors.Is(serveErr, http.ErrServerClosed) {
return nil
}
return serveErr
}
}
// browserFixture bounds the fixture EventSource connection so the browser
// campaign can prove native reconnection. It does not alter request origin
// metadata; form submissions exercise the production token-bound policy.
func browserFixture(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/app/events" {
ctx, cancel := context.WithTimeout(r.Context(), 1250*time.Millisecond)
defer cancel()
r = r.WithContext(ctx)
}
next.ServeHTTP(w, r)
})
}
func logBatch(sequence uint64, observed time.Time) model.Batch {
return model.Batch{Version: model.BatchVersion, SourceID: "browser-source", StreamID: "browser-stream", Sequence: sequence, ObservedAt: observed, Signal: model.SignalLogs, Records: []model.Observation{{
Timestamp: observed, Name: "browser.fixture", Severity: "information", Body: "bounded browser fixture observation",
Attributes: map[string]string{"http.route": "/browser-fixture", "http.status_code": "200"},
}}}
}
func publishUpdates(handler http.Handler, origin, token string, done <-chan struct{}) {
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
sequence := uint64(2)
for {
select {
case <-done:
return
case observed := <-ticker.C:
body, err := json.Marshal(logBatch(sequence, observed.UTC()))
if err != nil {
return
}
request := httptest.NewRequest(http.MethodPost, origin+"/api/v1/ingest/native", bytes.NewReader(body))
request.Header.Set("Authorization", "Bearer "+token)
request.Header.Set("Content-Type", "application/json")
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code == http.StatusAccepted {
sequence++
}
}
}
}
func localCertificate() (tls.Certificate, string, error) {
private, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return tls.Certificate{}, "", errors.New("create local TLS key")
}
maximum := new(big.Int).Lsh(big.NewInt(1), 128)
serial, err := rand.Int(rand.Reader, maximum)
if err != nil {
return tls.Certificate{}, "", errors.New("create local TLS serial")
}
now := time.Now().UTC()
template := x509.Certificate{
SerialNumber: serial, Subject: pkix.Name{CommonName: "Observatory browser fixture"},
NotBefore: now.Add(-time.Minute), NotAfter: now.Add(time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
DNSNames: []string{"localhost"}, IPAddresses: []net.IP{net.ParseIP("127.0.0.1")},
}
der, err := x509.CreateCertificate(rand.Reader, &template, &template, &private.PublicKey, private)
if err != nil {
return tls.Certificate{}, "", errors.New("create local TLS certificate")
}
encodedKey, err := x509.MarshalPKCS8PrivateKey(private)
if err != nil {
return tls.Certificate{}, "", errors.New("encode local TLS key")
}
encodedPublic, err := x509.MarshalPKIXPublicKey(&private.PublicKey)
if err != nil {
return tls.Certificate{}, "", errors.New("encode local TLS public key")
}
digest := sha256.Sum256(encodedPublic)
certificate, err := tls.X509KeyPair(
pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}),
pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: encodedKey}),
)
if err != nil {
return tls.Certificate{}, "", err
}
return certificate, base64.StdEncoding.EncodeToString(digest[:]), nil
}
+920
View File
@@ -0,0 +1,920 @@
//go:build observatory_capacity_fixture && linux
// SPDX-License-Identifier: AGPL-3.0-only
// Command capacitytest runs the bounded Observatory capacity and recovery
// campaign. It is excluded from ordinary builds and emits only aggregate,
// synthetic evidence.
package main
import (
"context"
"database/sql"
"encoding/json"
"errors"
"flag"
"fmt"
"math"
"net/url"
"os"
"os/signal"
"path/filepath"
"runtime"
"sort"
"strconv"
"strings"
"sync"
"sync/atomic"
"syscall"
"time"
"gamertan.com/observatory/internal/model"
"gamertan.com/observatory/internal/query"
"gamertan.com/observatory/internal/spool"
"gamertan.com/observatory/internal/storage"
)
const (
reportVersion = 2
visibilityObservationTimeout = 30 * time.Second
)
type settings struct {
sustainRate int
sustainDuration time.Duration
burstRate int
burstDuration time.Duration
minimumPrimary int64
queryIterations int
organizations int
primaryWeight int
batchInterval time.Duration
requireCgroup bool
expectedCPUs int
expectedMemory int64
}
type phaseReport struct {
TargetRate int `json:"target_rate_per_second"`
DurationSeconds float64 `json:"target_duration_seconds"`
Observations int64 `json:"observations"`
ElapsedSeconds float64 `json:"elapsed_seconds"`
AchievedRate float64 `json:"achieved_rate_per_second"`
IngestP50Milliseconds float64 `json:"ingest_p50_milliseconds"`
IngestP95Milliseconds float64 `json:"ingest_p95_milliseconds"`
IngestP99Milliseconds float64 `json:"ingest_p99_milliseconds"`
VisibleP50Milliseconds float64 `json:"visible_p50_milliseconds"`
VisibleP95Milliseconds float64 `json:"visible_p95_milliseconds"`
VisibleP99Milliseconds float64 `json:"visible_p99_milliseconds"`
}
type queryReport struct {
Name string `json:"name"`
Iterations int `json:"iterations"`
P50Milliseconds float64 `json:"p50_milliseconds"`
P95Milliseconds float64 `json:"p95_milliseconds"`
P99Milliseconds float64 `json:"p99_milliseconds"`
MaximumScannedRows int64 `json:"maximum_scanned_rows"`
MaximumScannedBytes int64 `json:"maximum_scanned_bytes"`
}
type spoolReport struct {
Batches int `json:"batches"`
Observations int64 `json:"observations"`
OldestAgeHours float64 `json:"oldest_age_hours"`
Replayed int64 `json:"replayed_observations"`
RemainingAfterAck int `json:"remaining_after_ack"`
DuplicateRecognized bool `json:"duplicate_recognized"`
}
type retentionEvidence struct {
ArchivedSegments int `json:"archived_segments"`
ArchivedBytes int64 `json:"archived_bytes"`
RemovedSegments int `json:"removed_segments"`
RemovedBytes int64 `json:"removed_bytes"`
ProjectionRowsRemoved int64 `json:"projection_rows_removed"`
ColdQueryRows int `json:"cold_query_rows"`
}
type resourceReport struct {
GOMAXPROCS int `json:"gomaxprocs"`
CPUQuota float64 `json:"cpu_quota"`
MemoryLimitBytes int64 `json:"memory_limit_bytes"`
MaximumRSSBytes int64 `json:"maximum_rss_bytes"`
DatasetBytes int64 `json:"dataset_bytes"`
}
type projectionDrainReport struct {
PendingSegments int `json:"pending_segments_at_start"`
PendingBytes int64 `json:"pending_bytes_at_start"`
OldestLagSeconds float64 `json:"oldest_pending_lag_seconds_at_start"`
ElapsedSeconds float64 `json:"elapsed_seconds"`
}
type storageClass struct {
Files int `json:"files"`
Bytes int64 `json:"bytes"`
}
type storageBreakdown struct {
Raw storageClass `json:"raw_segments"`
Projection storageClass `json:"projection_sqlite"`
Control storageClass `json:"control_sqlite"`
Other storageClass `json:"other"`
Total storageClass `json:"total"`
SQLitePageClasses sqlitePageBreakdown `json:"primary_projection_sqlite_page_classes"`
SQLiteBytes map[string]int64 `json:"primary_projection_sqlite_objects"`
}
type sqlitePageBreakdown struct {
Tables int64 `json:"tables_bytes"`
Indexes int64 `json:"indexes_bytes"`
Internal int64 `json:"internal_bytes"`
Total int64 `json:"total_bytes"`
}
type campaignReport struct {
Version int `json:"version"`
StartedAt time.Time `json:"started_at"`
CompletedAt time.Time `json:"completed_at"`
Organizations int `json:"organizations"`
PrimaryPhaseWeight int `json:"primary_phase_weight"`
TotalObservations int64 `json:"total_observations"`
PrimaryObservations int64 `json:"primary_observations"`
Sustain phaseReport `json:"sustain"`
Burst phaseReport `json:"burst"`
FillSeconds float64 `json:"fill_seconds"`
ProjectionDrain projectionDrainReport `json:"projection_drain"`
PrimaryStorage storageBreakdown `json:"primary_storage"`
Queries []queryReport `json:"queries"`
Spool spoolReport `json:"spool"`
Retention retentionEvidence `json:"retention"`
Resources resourceReport `json:"resources"`
Pass bool `json:"pass"`
}
type sourceState struct {
index int
weight int64
organizationID string
sourceID string
token string
sequences map[model.Signal]uint64
count atomic.Int64
}
type measurements struct {
mu sync.Mutex
ingest []time.Duration
visibility []time.Duration
}
func main() {
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, "capacity campaign failed:", err)
os.Exit(1)
}
}
func run() error {
configuration := settings{}
flag.IntVar(&configuration.sustainRate, "sustain-rate", 2_000, "sustained mixed observations per second")
flag.DurationVar(&configuration.sustainDuration, "sustain-duration", time.Hour, "sustained campaign duration")
flag.IntVar(&configuration.burstRate, "burst-rate", 10_000, "burst observations per second")
flag.DurationVar(&configuration.burstDuration, "burst-duration", time.Minute, "burst campaign duration")
flag.Int64Var(&configuration.minimumPrimary, "minimum-primary-observations", 10_000_000, "minimum observations in the primary organization")
flag.IntVar(&configuration.queryIterations, "query-iterations", 20, "iterations per common query")
flag.IntVar(&configuration.organizations, "organizations", 4, "concurrent organizations")
flag.IntVar(&configuration.primaryWeight, "primary-phase-weight", 1, "relative phase weight of the primary organization")
flag.DurationVar(&configuration.batchInterval, "batch-interval", 500*time.Millisecond, "batch scheduling interval")
flag.BoolVar(&configuration.requireCgroup, "require-cgroup", false, "require exact CPU and memory cgroup limits")
flag.IntVar(&configuration.expectedCPUs, "expected-cpus", 4, "required CPU quota")
flag.Int64Var(&configuration.expectedMemory, "expected-memory-bytes", 8<<30, "required memory limit")
flag.Parse()
if flag.NArg() != 0 {
return errors.New("capacity campaign accepts no positional arguments")
}
if err := configuration.validate(); err != nil {
return err
}
cpuQuota, memoryLimit, err := cgroupLimits()
if err != nil && configuration.requireCgroup {
return err
}
if configuration.requireCgroup && (math.Abs(cpuQuota-float64(configuration.expectedCPUs)) > 0.001 || memoryLimit != configuration.expectedMemory || runtime.GOMAXPROCS(0) != configuration.expectedCPUs) {
return fmt.Errorf("resource boundary differs: cpu=%.3f memory=%d gomaxprocs=%d", cpuQuota, memoryLimit, runtime.GOMAXPROCS(0))
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
root, err := os.MkdirTemp("", "observatory-capacity-")
if err != nil {
return errors.New("create capacity workspace")
}
defer os.RemoveAll(root)
if err = os.Chmod(root, 0o700); err != nil {
return errors.New("protect capacity workspace")
}
store, err := storage.Open(filepath.Join(root, "dataset"))
if err != nil {
return err
}
defer store.Close()
projectorContext, stopProjector := context.WithCancel(ctx)
projectorDone := make(chan struct{})
projectorErrors := make(chan error, 1)
go func() {
defer close(projectorDone)
store.RunProjector(projectorContext, 100*time.Millisecond, func(projectErr error) {
select {
case projectorErrors <- projectErr:
default:
}
})
}()
defer func() {
stopProjector()
<-projectorDone
}()
states, err := createSources(ctx, store, configuration.organizations, configuration.primaryWeight)
if err != nil {
return err
}
report := campaignReport{Version: reportVersion, StartedAt: time.Now().UTC(), Organizations: len(states), PrimaryPhaseWeight: configuration.primaryWeight}
seed := &atomic.Uint64{}
fmt.Fprintln(os.Stderr, "stage=sustain")
report.Sustain, err = runPhase(ctx, store, states, seed, configuration.sustainRate, configuration.sustainDuration, configuration.batchInterval)
if err != nil {
return err
}
fmt.Fprintln(os.Stderr, "stage=burst")
report.Burst, err = runPhase(ctx, store, states, seed, configuration.burstRate, configuration.burstDuration, configuration.batchInterval)
if err != nil {
return err
}
fmt.Fprintln(os.Stderr, "stage=fill")
fillStarted := time.Now()
if err = fillPrimary(ctx, store, states[0], seed, configuration.minimumPrimary); err != nil {
return err
}
report.FillSeconds = time.Since(fillStarted).Seconds()
report.PrimaryObservations = states[0].count.Load()
for _, state := range states {
report.TotalObservations += state.count.Load()
}
if report.PrimaryObservations < configuration.minimumPrimary {
return errors.New("primary dataset did not reach the required observation count")
}
drainStarted := time.Now()
drainStart, statusErr := store.ProjectionStatus(ctx, drainStarted.UTC())
if statusErr != nil {
return statusErr
}
report.ProjectionDrain = projectionDrainReport{
PendingSegments: drainStart.PendingSegments,
PendingBytes: drainStart.PendingBytes,
OldestLagSeconds: drainStart.OldestPendingLag.Seconds(),
}
if err = waitForProjection(ctx, store, projectorErrors, 10*time.Minute); err != nil {
return err
}
report.ProjectionDrain.ElapsedSeconds = time.Since(drainStarted).Seconds()
fmt.Fprintln(os.Stderr, "stage=queries")
report.Queries, err = runQueries(ctx, store, states[0].organizationID, configuration.queryIterations)
if err != nil {
return err
}
report.PrimaryStorage, err = measureStorage(filepath.Join(root, "dataset"), states[0].organizationID)
if err != nil {
return err
}
fmt.Fprintln(os.Stderr, "stage=spool")
report.Spool, err = runSpoolReplay(ctx, filepath.Join(root, "outage"))
if err != nil {
return err
}
fmt.Fprintln(os.Stderr, "stage=retention")
report.Retention, err = runRetention(ctx, filepath.Join(root, "retention"))
if err != nil {
return err
}
report.Resources = resourceReport{GOMAXPROCS: runtime.GOMAXPROCS(0), CPUQuota: cpuQuota, MemoryLimitBytes: memoryLimit}
report.Resources.MaximumRSSBytes = maximumRSS()
report.Resources.DatasetBytes, err = directoryBytes(root)
if err != nil {
return err
}
var campaignErrors []error
if report.Sustain.AchievedRate < float64(configuration.sustainRate)*0.99 || report.Burst.AchievedRate < float64(configuration.burstRate)*0.99 {
campaignErrors = append(campaignErrors, errors.New("target observation rate was not sustained"))
}
if report.Sustain.VisibleP95Milliseconds >= 2_000 || report.Burst.VisibleP95Milliseconds >= 2_000 {
campaignErrors = append(campaignErrors, errors.New("p95 ingestion-to-query visibility exceeded two seconds"))
}
for _, result := range report.Queries {
if result.P95Milliseconds >= 3_000 {
campaignErrors = append(campaignErrors, fmt.Errorf("query %s exceeded the three-second p95 boundary", result.Name))
}
}
if configuration.requireCgroup && report.Resources.MaximumRSSBytes >= configuration.expectedMemory {
campaignErrors = append(campaignErrors, errors.New("maximum RSS reached the cgroup memory boundary"))
}
report.CompletedAt = time.Now().UTC()
report.Pass = len(campaignErrors) == 0
encoder := json.NewEncoder(os.Stdout)
encoder.SetIndent("", " ")
if err = encoder.Encode(report); err != nil {
campaignErrors = append(campaignErrors, fmt.Errorf("encode capacity report: %w", err))
}
return errors.Join(campaignErrors...)
}
func (configuration settings) validate() error {
if configuration.sustainRate < 1 || configuration.burstRate < configuration.sustainRate || configuration.sustainDuration < time.Second || configuration.burstDuration < time.Second || configuration.minimumPrimary < 1 || configuration.queryIterations < 1 || configuration.queryIterations > 100 || configuration.organizations != 4 || configuration.primaryWeight < 1 || configuration.primaryWeight > 32 || configuration.batchInterval < 100*time.Millisecond || configuration.batchInterval > time.Second || configuration.expectedCPUs < 1 || configuration.expectedMemory < 1<<30 {
return errors.New("capacity campaign settings are invalid")
}
primaryShare := float64(configuration.primaryWeight) / float64(configuration.primaryWeight+configuration.organizations-1)
for _, rate := range []int{configuration.sustainRate, configuration.burstRate} {
if int(math.Ceil(float64(rate)*configuration.batchInterval.Seconds()*primaryShare)) > model.MaxRecords {
return errors.New("weighted batch would exceed the model record limit")
}
}
return nil
}
func createSources(ctx context.Context, store *storage.Store, count, primaryWeight int) ([]*sourceState, error) {
states := make([]*sourceState, 0, count)
for index := 0; index < count; index++ {
state := &sourceState{index: index, weight: 1, organizationID: fmt.Sprintf("capacity-org-%d", index+1), sourceID: fmt.Sprintf("capacity-source-%d", index+1), sequences: map[model.Signal]uint64{}}
if index == 0 {
state.weight = int64(primaryWeight)
}
var err error
state.token, err = store.CreateSource(ctx, state.sourceID, model.Scope{OrganizationID: state.organizationID, ProjectID: "observatory", EnvironmentID: "capacity", ServiceID: "server"})
if err != nil {
return nil, err
}
states = append(states, state)
}
return states, nil
}
func runPhase(ctx context.Context, store *storage.Store, states []*sourceState, seed *atomic.Uint64, rate int, duration, interval time.Duration) (phaseReport, error) {
target := int64(math.Round(float64(rate) * duration.Seconds()))
batches := int(math.Ceil(float64(duration) / float64(interval)))
started := time.Now()
measure := &measurements{}
errorsChannel := make(chan error, len(states))
visibilityExpected := make(chan time.Time, batches)
visibilityErrors := make(chan error, 1)
visibilityDone := make(chan struct{})
go func() {
defer close(visibilityDone)
for expected := range visibilityExpected {
visible, err := latestVisibility(ctx, store, states[0].organizationID, expected)
if err != nil {
select {
case visibilityErrors <- err:
default:
}
return
}
measure.mu.Lock()
measure.visibility = append(measure.visibility, visible)
measure.mu.Unlock()
}
}()
var wait sync.WaitGroup
remaining := target
weightTotal := int64(0)
for _, state := range states {
weightTotal += state.weight
}
for index, state := range states {
stateTarget := target * state.weight / weightTotal
if index == 0 {
allocated := int64(0)
for _, candidate := range states {
allocated += target * candidate.weight / weightTotal
}
stateTarget += target - allocated
}
remaining -= stateTarget
wait.Add(1)
go func(current *sourceState, count int64) {
defer wait.Done()
if err := runScheduledSource(ctx, store, current, seed, count, batches, interval, started, measure, visibilityExpected); err != nil {
errorsChannel <- err
}
}(state, stateTarget)
}
if remaining != 0 {
return phaseReport{}, errors.New("phase allocation did not preserve target")
}
wait.Wait()
elapsed := time.Since(started)
close(visibilityExpected)
<-visibilityDone
close(errorsChannel)
for phaseErr := range errorsChannel {
if phaseErr != nil {
return phaseReport{}, phaseErr
}
}
select {
case visibilityErr := <-visibilityErrors:
return phaseReport{}, visibilityErr
default:
}
measure.mu.Lock()
ingest := append([]time.Duration(nil), measure.ingest...)
visibility := append([]time.Duration(nil), measure.visibility...)
measure.mu.Unlock()
if len(ingest) == 0 || len(visibility) == 0 {
return phaseReport{}, errors.New("phase produced no latency evidence")
}
return phaseReport{
TargetRate: rate, DurationSeconds: duration.Seconds(), Observations: target, ElapsedSeconds: elapsed.Seconds(), AchievedRate: float64(target) / elapsed.Seconds(),
IngestP50Milliseconds: milliseconds(percentile(ingest, 0.50)), IngestP95Milliseconds: milliseconds(percentile(ingest, 0.95)), IngestP99Milliseconds: milliseconds(percentile(ingest, 0.99)),
VisibleP50Milliseconds: milliseconds(percentile(visibility, 0.50)), VisibleP95Milliseconds: milliseconds(percentile(visibility, 0.95)), VisibleP99Milliseconds: milliseconds(percentile(visibility, 0.99)),
}, nil
}
func runScheduledSource(ctx context.Context, store *storage.Store, state *sourceState, seed *atomic.Uint64, target int64, batches int, interval time.Duration, phaseStart time.Time, measurements *measurements, visibilityExpected chan<- time.Time) error {
base, remainder := target/int64(batches), target%int64(batches)
for batchIndex := 0; batchIndex < batches; batchIndex++ {
planned := phaseStart.Add(time.Duration(batchIndex) * interval)
if delay := time.Until(planned); delay > 0 {
timer := time.NewTimer(delay)
select {
case <-ctx.Done():
timer.Stop()
return ctx.Err()
case <-timer.C:
}
}
count := base
if int64(batchIndex) < remainder {
count++
}
if count == 0 {
continue
}
signal := []model.Signal{model.SignalLogs, model.SignalMetrics, model.SignalTraces}[batchIndex%3]
observed := time.Now().UTC()
records := syntheticRecords(int(count), signal, observed, seed)
state.sequences[signal]++
batch := model.Batch{Version: model.BatchVersion, SourceID: state.sourceID, StreamID: string(signal), Sequence: state.sequences[signal], ObservedAt: observed, Signal: signal, Records: records}
ingestStarted := time.Now()
ack, err := store.Ingest(ctx, state.token, batch, observed)
latency := time.Since(ingestStarted)
if err != nil {
return err
}
expected, err := batch.Digest()
if err != nil || ack.BatchDigest != expected || ack.Duplicate {
return errors.New("ingestion acknowledgement did not bind the scheduled batch")
}
state.count.Add(count)
measurements.mu.Lock()
measurements.ingest = append(measurements.ingest, latency)
measurements.mu.Unlock()
if state.index == 0 && signal == model.SignalLogs {
visibilityExpected <- observed
}
}
return nil
}
func syntheticRecords(count int, signal model.Signal, observed time.Time, seed *atomic.Uint64) []model.Observation {
records := make([]model.Observation, count)
routes := []string{"/", "/items", "/search", "/healthz"}
for index := range records {
id := seed.Add(1)
timestamp := observed.Add(time.Duration(index) * time.Nanosecond)
switch signal {
case model.SignalLogs:
status := "200"
if id%50 == 0 {
status = "503"
}
records[index] = model.Observation{Timestamp: timestamp, Name: "http.server.request", Severity: "information", CorrelationID: fmt.Sprintf("capacity-%d", id), Attributes: map[string]string{"http.route": routes[id%uint64(len(routes))], "http.status_code": status, "duration_ns": strconv.FormatUint(100_000+id%5_000_000, 10)}}
case model.SignalMetrics:
value := float64(id%10_000) / 100
records[index] = model.Observation{Timestamp: timestamp, Name: "system.cpu.utilization", Value: &value}
case model.SignalTraces:
records[index] = model.Observation{Timestamp: timestamp, Name: "http.server", TraceID: fmt.Sprintf("%032x", id), SpanID: fmt.Sprintf("%016x", id), Attributes: map[string]string{"http.route": routes[id%uint64(len(routes))]}}
}
}
return records
}
func latestVisibility(ctx context.Context, store *storage.Store, organizationID string, expected time.Time) (time.Duration, error) {
ast, err := query.Parse("logs | sort timestamp desc | limit 1", 10)
if err != nil {
return 0, err
}
// The release boundary is the aggregate p95 below two seconds, not a
// zero-outlier maximum. Keep observing a slow sample long enough to retain
// the phase evidence; the report gate below still fails an excessive p95.
deadline := time.Now().Add(visibilityObservationTimeout)
for {
result, queryErr := store.Query(ctx, ast, query.Scope{OrganizationID: organizationID}, capacityBudget(10), time.Now().UTC())
if queryErr == nil && len(result.Rows) == 1 {
var timestamp string
for index, column := range result.Columns {
if column.Field == "timestamp" && result.Rows[0].Values[index] != nil {
timestamp = *result.Rows[0].Values[index]
}
}
visibleAt, parseErr := time.Parse(time.RFC3339Nano, timestamp)
if parseErr == nil && !visibleAt.Before(expected) {
return time.Since(expected), nil
}
}
if time.Now().After(deadline) {
return 0, fmt.Errorf("latest visibility query remained stale for %s", visibilityObservationTimeout)
}
timer := time.NewTimer(10 * time.Millisecond)
select {
case <-ctx.Done():
timer.Stop()
return 0, ctx.Err()
case <-timer.C:
}
}
}
func waitForProjection(ctx context.Context, store *storage.Store, projectorErrors <-chan error, maximum time.Duration) error {
deadline := time.Now().Add(maximum)
for {
select {
case err := <-projectorErrors:
return fmt.Errorf("background projection failed: %w", err)
default:
}
status, err := store.ProjectionStatus(ctx, time.Now().UTC())
if err != nil {
return err
}
if status.PendingSegments == 0 {
return nil
}
if time.Now().After(deadline) {
return fmt.Errorf("projection backlog remained after %s: segments=%d bytes=%d lag=%s", maximum, status.PendingSegments, status.PendingBytes, status.OldestPendingLag)
}
timer := time.NewTimer(100 * time.Millisecond)
select {
case <-ctx.Done():
timer.Stop()
return ctx.Err()
case <-timer.C:
}
}
}
func fillPrimary(ctx context.Context, store *storage.Store, state *sourceState, seed *atomic.Uint64, minimum int64) error {
batchIndex := 0
for state.count.Load() < minimum {
remaining := minimum - state.count.Load()
count := int64(model.MaxRecords)
if remaining < count {
count = remaining
}
signal := []model.Signal{model.SignalLogs, model.SignalMetrics, model.SignalTraces}[batchIndex%3]
observed := time.Now().UTC()
state.sequences[signal]++
batch := model.Batch{Version: model.BatchVersion, SourceID: state.sourceID, StreamID: string(signal), Sequence: state.sequences[signal], ObservedAt: observed, Signal: signal, Records: syntheticRecords(int(count), signal, observed, seed)}
ack, err := store.Ingest(ctx, state.token, batch, observed)
if err != nil {
return err
}
expected, _ := batch.Digest()
if ack.BatchDigest != expected || ack.Duplicate {
return errors.New("fill acknowledgement did not bind the batch")
}
state.count.Add(count)
batchIndex++
if batchIndex%100 == 0 {
fmt.Fprintf(os.Stderr, "stage=fill observations=%d\n", state.count.Load())
}
}
return nil
}
func runQueries(ctx context.Context, store *storage.Store, organizationID string, iterations int) ([]queryReport, error) {
definitions := []struct{ name, text string }{
{"recent-errors-by-route", `logs | where status >= 500 | window 24h | summarize count() by route, window(5m) | sort count desc | limit 50`},
{"recent-items", `logs | where route == "/items" | window 24h | sort timestamp desc | limit 50`},
{"metric-rollup", `metrics | window 24h | summarize count(), p95(value) by name, window(5m) | sort count desc | limit 50`},
}
reports := make([]queryReport, 0, len(definitions))
for _, definition := range definitions {
ast, err := query.Parse(definition.text, 50)
if err != nil {
return nil, err
}
var samples []time.Duration
var maximumRows, maximumBytes int64
for iteration := 0; iteration < iterations; iteration++ {
started := time.Now()
result, queryErr := store.Query(ctx, ast, query.Scope{OrganizationID: organizationID}, capacityBudget(50), time.Now().UTC())
samples = append(samples, time.Since(started))
if queryErr != nil {
return nil, fmt.Errorf("capacity query %s failed: %w", definition.name, queryErr)
}
if len(result.Rows) == 0 {
return nil, fmt.Errorf("capacity query %s returned no rows", definition.name)
}
maximumRows = max(maximumRows, int64(result.Stats.ScannedRows))
maximumBytes = max(maximumBytes, result.Stats.ScannedBytes)
}
reports = append(reports, queryReport{Name: definition.name, Iterations: iterations, P50Milliseconds: milliseconds(percentile(samples, 0.50)), P95Milliseconds: milliseconds(percentile(samples, 0.95)), P99Milliseconds: milliseconds(percentile(samples, 0.99)), MaximumScannedRows: maximumRows, MaximumScannedBytes: maximumBytes})
}
return reports, nil
}
func runSpoolReplay(ctx context.Context, root string) (spoolReport, error) {
now := time.Now().UTC()
queue, err := spool.Open(filepath.Join(root, "spool"), 1<<30, 72*time.Hour)
if err != nil {
return spoolReport{}, err
}
store, err := storage.Open(filepath.Join(root, "server"))
if err != nil {
return spoolReport{}, err
}
defer store.Close()
token, err := store.CreateSource(ctx, "outage-source", model.Scope{OrganizationID: "outage-org", ProjectID: "observatory", EnvironmentID: "capacity", ServiceID: "agent"})
if err != nil {
return spoolReport{}, err
}
const batches, recordsPerBatch = 72, 100
for index := 0; index < batches; index++ {
observed := now.Add(-time.Duration(batches-index) * time.Hour).Add(time.Minute)
batch := model.Batch{Version: model.BatchVersion, SourceID: "outage-source", StreamID: "logs", Sequence: uint64(index + 1), ObservedAt: observed, Signal: model.SignalLogs, Records: syntheticRecords(recordsPerBatch, model.SignalLogs, observed, &atomic.Uint64{})}
entry, putErr := queue.Put(batch, observed)
if putErr != nil {
return spoolReport{}, putErr
}
if err = os.Chtimes(entry.Path, observed, observed); err != nil {
return spoolReport{}, err
}
}
entries, err := queue.List(now)
if err != nil || len(entries) != batches {
return spoolReport{}, errors.New("72-hour spool did not preserve every batch")
}
report := spoolReport{Batches: len(entries), Observations: batches * recordsPerBatch, OldestAgeHours: now.Sub(entries[0].ModTime).Hours()}
for index, entry := range entries {
batch, readErr := queue.Read(entry)
if readErr != nil {
return spoolReport{}, readErr
}
ack, ingestErr := store.Ingest(ctx, token, batch, now)
if ingestErr != nil {
return spoolReport{}, ingestErr
}
expected, _ := batch.Digest()
if ack.BatchDigest != expected {
return spoolReport{}, errors.New("outage replay acknowledgement mismatch")
}
if index == 0 {
duplicate, duplicateErr := store.Ingest(ctx, token, batch, now)
if duplicateErr != nil || !duplicate.Duplicate || duplicate.BatchDigest != expected {
return spoolReport{}, errors.New("outage replay duplicate was not recognized")
}
report.DuplicateRecognized = true
}
if err = queue.Acknowledge(entry, entry.Digest); err != nil {
return spoolReport{}, err
}
report.Replayed += int64(len(batch.Records))
}
remaining, err := queue.List(now)
if err != nil {
return spoolReport{}, err
}
report.RemainingAfterAck = len(remaining)
if report.Replayed != report.Observations || report.RemainingAfterAck != 0 || report.OldestAgeHours < 71.9 {
return spoolReport{}, errors.New("outage replay evidence is incomplete")
}
return report, nil
}
func runRetention(ctx context.Context, root string) (retentionEvidence, error) {
now := time.Now().UTC()
store, err := storage.Open(root)
if err != nil {
return retentionEvidence{}, err
}
defer store.Close()
token, err := store.CreateSource(ctx, "retention-source", model.Scope{OrganizationID: "retention-org", ProjectID: "observatory", EnvironmentID: "capacity", ServiceID: "server"})
if err != nil {
return retentionEvidence{}, err
}
tests := []struct {
signal model.Signal
age time.Duration
}{
{model.SignalLogs, 31 * 24 * time.Hour},
{model.SignalTraces, 31 * 24 * time.Hour},
{model.SignalMetrics, 15 * 24 * time.Hour},
{model.SignalDeployments, 399 * 24 * time.Hour},
}
for index, item := range tests {
observed := now.Add(-item.age)
batch := model.Batch{Version: model.BatchVersion, SourceID: "retention-source", StreamID: string(item.signal), Sequence: 1, ObservedAt: now, Signal: item.signal, Records: syntheticRecords(1, item.signal, observed, &atomic.Uint64{})}
if item.signal == model.SignalDeployments {
batch.Records[0] = model.Observation{Timestamp: observed, Name: "deployment", Attributes: map[string]string{"outcome": "success"}}
}
if _, err = store.Ingest(ctx, token, batch, now); err != nil {
return retentionEvidence{}, fmt.Errorf("retention fixture %d: %w", index, err)
}
}
if err = store.Recover(ctx); err != nil {
return retentionEvidence{}, err
}
policy := storage.RetentionPolicy{RawLogsDays: 30, RawTracesDays: 30, RawMetricsDays: 14, ColdRawDays: 400, DeleteColdRaw: true, MetricRollupsDays: 400, EvidenceDays: 400}
report, err := store.ApplyRetention(ctx, policy, now.Add(2*24*time.Hour))
if err != nil {
return retentionEvidence{}, err
}
ast, err := query.Parse("logs | window 960h | limit 10", 10)
if err != nil {
return retentionEvidence{}, err
}
result, err := store.Query(ctx, ast, query.Scope{OrganizationID: "retention-org", Sensitive: true}, capacityBudget(10), now.Add(2*24*time.Hour))
if err != nil {
return retentionEvidence{}, err
}
evidence := retentionEvidence{ArchivedSegments: report.RawSegmentsArchived, ArchivedBytes: report.RawBytesArchived, RemovedSegments: report.RawSegmentsRemoved, RemovedBytes: report.RawBytesRemoved, ProjectionRowsRemoved: report.ProjectedObservationsRemoved, ColdQueryRows: len(result.Rows)}
if evidence.ArchivedSegments != 4 || evidence.RemovedSegments != 1 || evidence.ProjectionRowsRemoved != 4 || evidence.ColdQueryRows != 1 {
return retentionEvidence{}, fmt.Errorf("retention lifecycle mismatch: %+v", evidence)
}
return evidence, nil
}
func capacityBudget(rows int) query.Budget {
return query.Budget{MaxDuration: 10 * time.Second, MaxRows: rows, MaxScannedBytes: 64 << 30, MaxMemoryBytes: 1 << 30}
}
func percentile(values []time.Duration, fraction float64) time.Duration {
copyOfValues := append([]time.Duration(nil), values...)
sort.Slice(copyOfValues, func(left, right int) bool { return copyOfValues[left] < copyOfValues[right] })
index := int(math.Ceil(float64(len(copyOfValues))*fraction)) - 1
if index < 0 {
index = 0
}
return copyOfValues[index]
}
func milliseconds(value time.Duration) float64 { return float64(value) / float64(time.Millisecond) }
func cgroupLimits() (float64, int64, error) {
cpuBody, err := os.ReadFile("/sys/fs/cgroup/cpu.max")
if err != nil {
return 0, 0, errors.New("read cgroup CPU limit")
}
parts := strings.Fields(string(cpuBody))
if len(parts) != 2 || parts[0] == "max" {
return 0, 0, errors.New("cgroup CPU quota is not finite")
}
quota, quotaErr := strconv.ParseFloat(parts[0], 64)
period, periodErr := strconv.ParseFloat(parts[1], 64)
memoryBody, memoryErr := os.ReadFile("/sys/fs/cgroup/memory.max")
memory, parseMemoryErr := strconv.ParseInt(strings.TrimSpace(string(memoryBody)), 10, 64)
if quotaErr != nil || periodErr != nil || period <= 0 || memoryErr != nil || parseMemoryErr != nil {
return 0, 0, errors.New("cgroup resource limit is invalid")
}
return quota / period, memory, nil
}
func maximumRSS() int64 {
var usage syscall.Rusage
if syscall.Getrusage(syscall.RUSAGE_SELF, &usage) != nil {
return 0
}
return usage.Maxrss * 1024
}
func directoryBytes(root string) (int64, error) {
var total int64
err := filepath.WalkDir(root, func(path string, entry os.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if entry.Type()&os.ModeSymlink != 0 {
return errors.New("capacity workspace contains a symlink")
}
if entry.IsDir() {
return nil
}
info, err := entry.Info()
if err != nil || !info.Mode().IsRegular() {
return errors.New("capacity workspace contains a non-regular file")
}
if info.Size() > math.MaxInt64-total {
return errors.New("capacity workspace size overflow")
}
total += info.Size()
return nil
})
return total, err
}
func measureStorage(root, primaryOrganizationID string) (storageBreakdown, error) {
var report storageBreakdown
err := filepath.WalkDir(root, func(path string, entry os.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if entry.Type()&os.ModeSymlink != 0 {
return errors.New("capacity dataset contains a symlink")
}
if entry.IsDir() {
return nil
}
info, err := entry.Info()
if err != nil || !info.Mode().IsRegular() {
return errors.New("capacity dataset contains a non-regular file")
}
relative, err := filepath.Rel(root, path)
if err != nil || relative == "." || strings.HasPrefix(relative, ".."+string(os.PathSeparator)) {
return errors.New("capacity dataset path is invalid")
}
class := &report.Other
switch {
case relative == "control.sqlite" || strings.HasPrefix(relative, "control.sqlite-"):
class = &report.Control
case strings.HasPrefix(relative, "raw"+string(os.PathSeparator)) || strings.HasPrefix(relative, "cold"+string(os.PathSeparator)):
class = &report.Raw
case strings.HasPrefix(relative, "organizations"+string(os.PathSeparator)):
class = &report.Projection
}
if info.Size() > math.MaxInt64-class.Bytes || info.Size() > math.MaxInt64-report.Total.Bytes {
return errors.New("capacity storage class size overflow")
}
class.Files++
class.Bytes += info.Size()
report.Total.Files++
report.Total.Bytes += info.Size()
return nil
})
if err != nil {
return storageBreakdown{}, err
}
projectionPath := filepath.Join(root, "organizations", primaryOrganizationID, "projection.sqlite")
report.SQLiteBytes, report.SQLitePageClasses, err = sqliteObjectBytes(projectionPath)
if err != nil {
return storageBreakdown{}, err
}
return report, nil
}
func sqliteObjectBytes(path string) (map[string]int64, sqlitePageBreakdown, error) {
dsn := (&url.URL{Scheme: "file", Path: path, RawQuery: "mode=ro"}).String()
db, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, sqlitePageBreakdown{}, errors.New("open capacity projection diagnostics")
}
defer db.Close()
db.SetMaxOpenConns(1)
rows, err := db.Query(`SELECT d.name,COALESCE(m.type,'internal'),COALESCE(SUM(d.pgsize),0) FROM dbstat AS d LEFT JOIN sqlite_schema AS m ON m.name=d.name GROUP BY d.name,m.type ORDER BY d.name`)
if err != nil {
return nil, sqlitePageBreakdown{}, errors.New("read capacity projection page accounting")
}
defer rows.Close()
report := map[string]int64{}
var classes sqlitePageBreakdown
for rows.Next() {
var name, objectType string
var bytes int64
if err = rows.Scan(&name, &objectType, &bytes); err != nil || name == "" || bytes < 0 {
return nil, sqlitePageBreakdown{}, errors.New("read capacity projection page accounting")
}
report[name] = bytes
if bytes > math.MaxInt64-classes.Total {
return nil, sqlitePageBreakdown{}, errors.New("capacity projection page accounting overflow")
}
classes.Total += bytes
switch objectType {
case "table":
classes.Tables += bytes
case "index":
classes.Indexes += bytes
default:
classes.Internal += bytes
}
}
if err = rows.Err(); err != nil {
return nil, sqlitePageBreakdown{}, errors.New("read capacity projection page accounting")
}
if len(report) == 0 {
return nil, sqlitePageBreakdown{}, errors.New("capacity projection page accounting is empty")
}
if classes.Total != classes.Tables+classes.Indexes+classes.Internal {
return nil, sqlitePageBreakdown{}, errors.New("capacity projection page accounting total mismatch")
}
return report, classes, nil
}
+80
View File
@@ -0,0 +1,80 @@
//go:build observatory_capacity_fixture && linux
// SPDX-License-Identifier: AGPL-3.0-only
package main
import (
"context"
"os"
"path/filepath"
"sync/atomic"
"testing"
"time"
"gamertan.com/observatory/internal/model"
"gamertan.com/observatory/internal/storage"
)
func TestMeasureStorageClassifiesFilesAndSQLiteObjects(t *testing.T) {
root := filepath.Join(t.TempDir(), "dataset")
if err := os.Mkdir(root, 0o700); err != nil {
t.Fatal(err)
}
store, err := storage.Open(root)
if err != nil {
t.Fatal(err)
}
now := time.Now().UTC()
scope := model.Scope{OrganizationID: "capacity-org-1", ProjectID: "observatory", EnvironmentID: "capacity", ServiceID: "server"}
token, err := store.CreateSource(t.Context(), "capacity-source-1", scope)
if err != nil {
store.Close()
t.Fatal(err)
}
batch := model.Batch{Version: model.BatchVersion, SourceID: "capacity-source-1", StreamID: "logs", Sequence: 1, ObservedAt: now, Signal: model.SignalLogs, Records: syntheticRecords(2, model.SignalLogs, now, new(atomic.Uint64))}
if _, err = store.Ingest(context.Background(), token, batch, now); err != nil {
store.Close()
t.Fatal(err)
}
if _, err = store.ProjectPending(t.Context()); err != nil {
store.Close()
t.Fatal(err)
}
report, err := measureStorage(root, scope.OrganizationID)
if closeErr := store.Close(); err == nil && closeErr != nil {
err = closeErr
}
if err != nil {
t.Fatal(err)
}
if report.Raw.Files == 0 || report.Raw.Bytes == 0 || report.Projection.Files == 0 || report.Projection.Bytes == 0 || report.Control.Files == 0 || report.Control.Bytes == 0 {
t.Fatalf("incomplete breakdown: %+v", report)
}
if report.Total.Files != report.Raw.Files+report.Projection.Files+report.Control.Files+report.Other.Files {
t.Fatalf("file total mismatch: %+v", report)
}
if report.Total.Bytes != report.Raw.Bytes+report.Projection.Bytes+report.Control.Bytes+report.Other.Bytes {
t.Fatalf("byte total mismatch: %+v", report)
}
if report.SQLiteBytes["observations"] == 0 || report.SQLiteBytes["observations_signal_time"] == 0 {
t.Fatalf("SQLite object accounting missing: %+v", report.SQLiteBytes)
}
if report.SQLitePageClasses.Tables == 0 || report.SQLitePageClasses.Indexes == 0 || report.SQLitePageClasses.Total != report.SQLitePageClasses.Tables+report.SQLitePageClasses.Indexes+report.SQLitePageClasses.Internal {
t.Fatalf("SQLite page classes incomplete: %+v", report.SQLitePageClasses)
}
}
func TestMeasureStorageRejectsSymlink(t *testing.T) {
root := t.TempDir()
target := filepath.Join(t.TempDir(), "target")
if err := os.WriteFile(target, []byte("unsafe"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.Symlink(target, filepath.Join(root, "linked")); err != nil {
t.Skipf("symlink unavailable: %v", err)
}
if _, err := measureStorage(root, "capacity-org-1"); err == nil {
t.Fatal("symlinked capacity evidence was accepted")
}
}
+303
View File
@@ -0,0 +1,303 @@
// SPDX-License-Identifier: AGPL-3.0-only
package collector
import (
"bytes"
"encoding/json"
"errors"
"io"
"net/netip"
"net/url"
"regexp"
"strconv"
"strings"
"time"
"unicode/utf8"
"gamertan.com/observatory/internal/model"
)
const MaxLineBytes = 1 << 20
var tendSafeValue = regexp.MustCompile(`^[A-Za-z0-9._:/@+-]{1,256}$`)
var tendOperationID = regexp.MustCompile(`^[0-9a-f]{32}$`)
var tendArtifactDigest = regexp.MustCompile(`^[0-9a-f]{64}$`)
var tendCommit = regexp.MustCompile(`^[0-9a-f]{40}$`)
func Parse(kind string, line []byte, fallback time.Time, sensitiveFields ...string) (model.Signal, model.Observation, error) {
if len(line) == 0 || len(line) > MaxLineBytes || !utf8.Valid(line) || bytes.IndexByte(line, 0) >= 0 {
return "", model.Observation{}, errors.New("collector line is outside accepted bounds")
}
switch kind {
case "caddy_json":
observation, err := parseCaddy(line, fallback, sensitiveFields)
return model.SignalLogs, observation, err
case "requestlog_jsonl":
observation, err := parseRequestLog(line, fallback, sensitiveFields)
return model.SignalLogs, observation, err
case "tend_events_jsonl":
observation, err := parseTend(line, fallback)
return model.SignalDeployments, observation, err
default:
return "", model.Observation{}, errors.New("unsupported collector kind")
}
}
func parseCaddy(line []byte, fallback time.Time, sensitiveFields []string) (model.Observation, error) {
var record struct {
Timestamp float64 `json:"ts"`
Status int `json:"status"`
Size int64 `json:"size"`
Duration float64 `json:"duration"`
RequestID string `json:"request_id"`
ClientIP string `json:"client_ip"`
Referrer string `json:"referrer"`
UserAgent string `json:"user_agent"`
Request struct {
Method string `json:"method"`
URI string `json:"uri"`
Headers map[string][]string `json:"headers"`
} `json:"request"`
}
if err := json.Unmarshal(line, &record); err != nil {
return model.Observation{}, errors.New("invalid Caddy JSON record")
}
timestamp := fallback
if record.Timestamp > 0 {
seconds, fraction := mathModf(record.Timestamp)
timestamp = time.Unix(seconds, int64(fraction*1e9)).UTC()
}
path := "/"
parsed, parseErr := url.ParseRequestURI(record.Request.URI)
if parseErr == nil && parsed.Path != "" {
path = parsed.EscapedPath()
}
attributes := map[string]string{
"http.method": bounded(record.Request.Method, 32),
"http.path": bounded(path, 4096),
"http.status_code": strconv.Itoa(record.Status),
"http.response_bytes": strconv.FormatInt(record.Size, 10),
"duration_ns": strconv.FormatInt(int64(record.Duration*1e9), 10),
}
requestID := record.RequestID
if !safeIdentifier(requestID) {
requestID = firstHeader(record.Request.Headers, "X-Request-Id", "X-Request-ID")
}
if safeIdentifier(requestID) {
attributes["request.id"] = requestID
}
selected := sensitiveFieldSet(sensitiveFields)
if selected["client_ip"] {
if address, err := netip.ParseAddr(record.ClientIP); err == nil && address.IsValid() && address.Zone() == "" {
attributes["client.address"] = address.String()
}
}
if selected["query"] && parseErr == nil && parsed.RawQuery != "" {
attributes["url.query"] = bounded(parsed.RawQuery, 4096)
}
if selected["referrer"] {
if value := boundedSensitive(record.Referrer, 4096); value != "" {
attributes["http.request.referrer"] = value
}
}
if selected["user_agent"] {
if value := boundedSensitive(record.UserAgent, 1024); value != "" {
attributes["user_agent.original"] = value
}
}
return model.Observation{Timestamp: timestamp, Name: "caddy.http.request", CorrelationID: attributes["request.id"], Attributes: attributes}, nil
}
func parseRequestLog(line []byte, fallback time.Time, sensitiveFields []string) (model.Observation, error) {
var raw map[string]json.RawMessage
if err := json.Unmarshal(line, &raw); err != nil {
return model.Observation{}, errors.New("invalid requestlog JSON record")
}
timestamp := parseTimeFields(raw, fallback, "observed_at", "timestamp", "time")
attributes := map[string]string{}
copyString(raw, attributes, "method", "http.method", 32)
copyString(raw, attributes, "route", "http.route", 4096)
copyNumber(raw, attributes, "status", "http.status_code")
copyNumber(raw, attributes, "bytes", "http.response_bytes")
copyNumber(raw, attributes, "duration_ns", "duration_ns")
copyString(raw, attributes, "authorization_outcome", "auth.outcome", 128)
requestID := rawString(raw["request_id"], 128)
if safeIdentifier(requestID) {
attributes["request.id"] = requestID
}
selected := sensitiveFieldSet(sensitiveFields)
if selected["client_ip"] {
if address, err := netip.ParseAddr(rawString(raw["client_ip"], 64)); err == nil && address.IsValid() && address.Zone() == "" {
attributes["client.address"] = address.String()
}
}
if selected["query"] {
copyString(raw, attributes, "query", "url.query", 4096)
}
if selected["referrer"] {
copyString(raw, attributes, "referer", "http.request.referrer", 2048)
}
if selected["user_agent"] {
copyString(raw, attributes, "user_agent", "user_agent.original", 1024)
}
if selected["session_id"] {
copyString(raw, attributes, "session_id", "session.id", 256)
}
return model.Observation{Timestamp: timestamp, Name: "application.http.request", CorrelationID: requestID, Attributes: attributes}, nil
}
func sensitiveFieldSet(fields []string) map[string]bool {
selected := make(map[string]bool, len(fields))
for _, field := range fields {
selected[field] = true
}
return selected
}
func boundedSensitive(value string, maximum int) string {
if value == "" || len(value) > maximum || !utf8.ValidString(value) || strings.ContainsAny(value, "\x00\r\n") {
return ""
}
return value
}
func parseTend(line []byte, _ time.Time) (model.Observation, error) {
if len(line) > 4096 {
return model.Observation{}, errors.New("invalid Tend deployment event")
}
var record tendEvent
decoder := json.NewDecoder(bytes.NewReader(line))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&record); err != nil || record.validate() != nil {
return model.Observation{}, errors.New("invalid Tend deployment event")
}
if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
return model.Observation{}, errors.New("invalid Tend deployment event")
}
timestamp, _ := time.Parse(time.RFC3339Nano, record.ObservedAt)
attributes := map[string]string{
"deployment.operation_id": record.OperationID,
"service.name": record.Service,
"deployment.artifact": record.ArtifactDigest,
"deployment.commit": record.Commit,
"deployment.version": record.ReleaseVersion,
"deployment.phase": record.Phase,
"deployment.duration_ms": strconv.FormatInt(record.DurationMillis, 10),
"deployment.outcome": record.Outcome,
}
if record.Slot != "" {
attributes["deployment.slot"] = record.Slot
}
return model.Observation{Timestamp: timestamp.UTC(), Name: "tend.deployment", CorrelationID: record.OperationID, Attributes: attributes}, nil
}
type tendEvent struct {
Version int `json:"version"`
OperationID string `json:"operation_id"`
Service string `json:"service"`
ArtifactDigest string `json:"artifact_digest"`
Commit string `json:"commit"`
ReleaseVersion string `json:"release_version"`
Phase string `json:"phase"`
Slot string `json:"slot"`
DurationMillis int64 `json:"duration_ms"`
Outcome string `json:"outcome"`
ObservedAt string `json:"observed_at"`
}
func (event tendEvent) validate() error {
if event.Version != 1 || !tendOperationID.MatchString(event.OperationID) || !tendArtifactDigest.MatchString(event.ArtifactDigest) || !tendCommit.MatchString(event.Commit) {
return errors.New("identity or provenance is invalid")
}
for _, value := range []string{event.Service, event.ArtifactDigest, event.Commit, event.ReleaseVersion, event.Phase, event.Outcome} {
if !tendSafeValue.MatchString(value) || strings.ContainsRune(value, '\x00') {
return errors.New("value is invalid")
}
}
if event.Slot != "" && !tendSafeValue.MatchString(event.Slot) {
return errors.New("slot is invalid")
}
if event.DurationMillis < 0 {
return errors.New("duration is invalid")
}
if _, err := time.Parse(time.RFC3339Nano, event.ObservedAt); err != nil {
return errors.New("timestamp is invalid")
}
return nil
}
func copyString(raw map[string]json.RawMessage, attributes map[string]string, source, target string, maximum int) {
if value := rawString(raw[source], maximum); value != "" {
attributes[target] = value
}
}
func copyNumber(raw map[string]json.RawMessage, attributes map[string]string, source, target string) {
value := strings.TrimSpace(string(raw[source]))
if value == "" || len(value) > 64 {
return
}
if _, err := strconv.ParseFloat(value, 64); err == nil {
attributes[target] = value
}
}
func rawString(raw json.RawMessage, maximum int) string {
var value string
if len(raw) == 0 || json.Unmarshal(raw, &value) != nil || len(value) > maximum || !utf8.ValidString(value) || strings.ContainsAny(value, "\x00\r\n") {
return ""
}
return value
}
func parseTimeFields(raw map[string]json.RawMessage, fallback time.Time, names ...string) time.Time {
for _, name := range names {
value := rawString(raw[name], 128)
if value == "" {
continue
}
if parsed, err := time.Parse(time.RFC3339Nano, value); err == nil {
return parsed.UTC()
}
}
return fallback.UTC()
}
func firstHeader(headers map[string][]string, names ...string) string {
for _, name := range names {
for key, values := range headers {
if strings.EqualFold(key, name) && len(values) == 1 {
return values[0]
}
}
}
return ""
}
func safeIdentifier(value string) bool {
if len(value) < 1 || len(value) > 128 {
return false
}
for _, r := range value {
if !(r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || strings.ContainsRune("._:-", r)) {
return false
}
}
return true
}
func bounded(value string, maximum int) string {
if len(value) > maximum {
for maximum > 0 && !utf8.ValidString(value[:maximum]) {
maximum--
}
return value[:maximum]
}
return value
}
func mathModf(value float64) (int64, float64) {
seconds := int64(value)
return seconds, value - float64(seconds)
}
+125
View File
@@ -0,0 +1,125 @@
// SPDX-License-Identifier: AGPL-3.0-only
package collector
import (
"encoding/json"
"strings"
"testing"
"time"
)
func TestCaddyCollectorDropsSecretsAndQuery(t *testing.T) {
line := []byte(`{"ts":1720000000.25,"request":{"method":"GET","uri":"/search?q=secret","remote_ip":"192.0.2.10","headers":{"Cookie":["session=secret"],"Authorization":["Bearer secret"],"X-Request-Id":["req-123"]}},"status":200,"size":42,"duration":0.001}`)
_, observation, err := Parse("caddy_json", line, time.Unix(1, 0))
if err != nil {
t.Fatal(err)
}
b, _ := json.Marshal(observation)
for _, forbidden := range []string{"q=secret", "session=secret", "Bearer secret", "192.0.2.10", "Authorization", "Cookie"} {
if strings.Contains(string(b), forbidden) {
t.Fatalf("secret escaped into observation: %s", b)
}
}
if observation.Attributes["http.path"] != "/search" || observation.CorrelationID != "req-123" {
t.Fatalf("observation=%+v", observation)
}
}
func TestCaddyCollectorUsesFilteredTopLevelRequestID(t *testing.T) {
line := []byte(`{"ts":1720000000.25,"request_id":"response-request-123","client_ip":"192.0.2.45","referrer":"https://example.test/start?private=yes","user_agent":"example-agent","request":{"method":"GET","uri":"/items?view=private"},"status":200,"size":42,"duration":0.001}`)
_, observation, err := Parse("caddy_json", line, time.Unix(1, 0))
if err != nil {
t.Fatal(err)
}
if observation.CorrelationID != "response-request-123" || observation.Attributes["request.id"] != "response-request-123" || observation.Attributes["http.path"] != "/items" {
t.Fatalf("observation=%+v", observation)
}
for _, forbidden := range []string{"client.address", "url.query", "http.request.referrer", "user_agent.original"} {
if _, ok := observation.Attributes[forbidden]; ok {
t.Fatalf("default collection retained sensitive field %q", forbidden)
}
}
_, observation, err = Parse("caddy_json", line, time.Unix(1, 0), "client_ip", "query", "referrer", "user_agent")
if err != nil || observation.Attributes["client.address"] != "192.0.2.45" || observation.Attributes["url.query"] != "view=private" || observation.Attributes["http.request.referrer"] != "https://example.test/start?private=yes" || observation.Attributes["user_agent.original"] != "example-agent" {
t.Fatalf("sensitive observation=%+v err=%v", observation, err)
}
line = []byte(`{"request_id":"invalid request id","request":{"method":"GET","uri":"/items","headers":{"X-Request-ID":["compatible-header-id"]}},"status":200}`)
_, observation, err = Parse("caddy_json", line, time.Unix(1, 0))
if err != nil || observation.CorrelationID != "compatible-header-id" {
t.Fatalf("fallback observation=%+v err=%v", observation, err)
}
}
func TestRequestLogCollectorUsesWhitelist(t *testing.T) {
line := []byte(`{"timestamp":"2026-08-17T01:02:03Z","method":"GET","route":"/items/{id}","status":200,"request_id":"request-1","query":"view=full","client_ip":"192.0.2.2","referer":"https://example.test/start","user_agent":"example-agent","session_id":"anonymous-session","extra_secret":"nope"}`)
_, observation, err := Parse("requestlog_jsonl", line, time.Unix(1, 0))
if err != nil {
t.Fatal(err)
}
b, _ := json.Marshal(observation)
if strings.Contains(string(b), "192.0.2.2") || strings.Contains(string(b), "view=full") || strings.Contains(string(b), "anonymous-session") || observation.Attributes["http.route"] != "/items/{id}" {
t.Fatalf("observation=%s", b)
}
_, observation, err = Parse("requestlog_jsonl", line, time.Unix(1, 0), "client_ip", "query", "referrer", "user_agent", "session_id")
if err != nil || observation.Attributes["client.address"] != "192.0.2.2" || observation.Attributes["url.query"] != "view=full" || observation.Attributes["http.request.referrer"] != "https://example.test/start" || observation.Attributes["user_agent.original"] != "example-agent" || observation.Attributes["session.id"] != "anonymous-session" {
t.Fatalf("sensitive requestlog observation=%+v err=%v", observation, err)
}
}
func TestTendCollectorIsStrict(t *testing.T) {
line := []byte(`{"version":1,"operation_id":"0123456789abcdef0123456789abcdef","service":"site","artifact_digest":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","commit":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","release_version":"v0.2.0-preview.1","phase":"activation","slot":"green","duration_ms":25,"outcome":"succeeded","observed_at":"2026-08-17T01:02:03Z"}`)
signal, observation, err := Parse("tend_events_jsonl", line, time.Unix(1, 0))
if err != nil {
t.Fatal(err)
}
if signal != "deployments" || observation.Attributes["deployment.outcome"] != "succeeded" || observation.CorrelationID != "0123456789abcdef0123456789abcdef" || len(observation.Attributes) != 9 {
t.Fatalf("signal=%s observation=%+v", signal, observation)
}
if _, _, err := Parse("tend_events_jsonl", append(line[:len(line)-1], []byte(`,"secret":"x"}`)...), time.Unix(1, 0)); err == nil {
t.Fatal("expected unknown field rejection")
}
rollback := []byte(`{"version":1,"operation_id":"fedcba9876543210fedcba9876543210","service":"site","artifact_digest":"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc","commit":"dddddddddddddddddddddddddddddddddddddddd","release_version":"v0.2.0-preview.1","phase":"rollback","slot":"blue","duration_ms":12,"outcome":"succeeded","observed_at":"2026-08-17T01:03:04.123Z"}`)
_, observation, err = Parse("tend_events_jsonl", rollback, time.Unix(1, 0))
if err != nil || observation.Attributes["deployment.phase"] != "rollback" || observation.Attributes["deployment.artifact"] != strings.Repeat("c", 64) {
t.Fatalf("rollback observation=%+v err=%v", observation, err)
}
invalid := [][]byte{
[]byte(strings.Replace(string(line), `"version":1`, `"version":2`, 1)),
[]byte(strings.Replace(string(line), "0123456789abcdef0123456789abcdef", "short", 1)),
[]byte(strings.Replace(string(line), strings.Repeat("a", 64), strings.Repeat("A", 64), 1)),
[]byte(strings.Replace(string(line), strings.Repeat("b", 40), strings.Repeat("b", 39), 1)),
[]byte(strings.Replace(string(line), `"service":"site"`, `"service":"unsafe service"`, 1)),
[]byte(strings.Replace(string(line), `"duration_ms":25`, `"duration_ms":-1`, 1)),
[]byte(strings.Replace(string(line), "2026-08-17T01:02:03Z", "not-a-time", 1)),
append(append([]byte{}, line...), []byte(` {}`)...),
[]byte(strings.Repeat(" ", 4097)),
}
for index, candidate := range invalid {
if _, _, err := Parse("tend_events_jsonl", candidate, time.Unix(1, 0)); err == nil {
t.Fatalf("invalid Tend event %d was accepted", index)
}
}
}
func FuzzTendCollector(f *testing.F) {
f.Add([]byte(`{"version":1,"operation_id":"0123456789abcdef0123456789abcdef","service":"site","artifact_digest":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","commit":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","release_version":"v0.2.0-preview.1","phase":"activation","slot":"green","duration_ms":25,"outcome":"succeeded","observed_at":"2026-08-17T01:02:03Z"}`))
f.Add([]byte(`{"version":1}`))
f.Add([]byte{0, 1, 2, 3})
f.Fuzz(func(t *testing.T, line []byte) {
signal, observation, err := Parse("tend_events_jsonl", line, time.Unix(1, 0))
if err != nil {
return
}
if signal != "deployments" || observation.Name != "tend.deployment" || observation.CorrelationID == "" || len(observation.Attributes) < 8 || len(observation.Attributes) > 9 {
t.Fatalf("accepted event violated invariants: signal=%q observation=%+v", signal, observation)
}
allowed := map[string]bool{"deployment.operation_id": true, "service.name": true, "deployment.artifact": true, "deployment.commit": true, "deployment.version": true, "deployment.phase": true, "deployment.slot": true, "deployment.duration_ms": true, "deployment.outcome": true}
for name := range observation.Attributes {
if !allowed[name] {
t.Fatalf("accepted unexpected attribute %q", name)
}
}
})
}
+506
View File
@@ -0,0 +1,506 @@
// SPDX-License-Identifier: AGPL-3.0-only
package config
import (
"bytes"
"crypto/ecdh"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/url"
"os"
"path/filepath"
"runtime"
"strings"
"syscall"
"time"
"gamertan.com/observatory/internal/hostmetrics"
"gamertan.com/observatory/internal/model"
"gamertan.com/observatory/internal/query"
)
const SchemaVersion = 1
type Server struct {
Schema int `json:"schema"`
Listen string `json:"listen"`
PublicURL string `json:"public_url"`
DataDir string `json:"data_dir"`
MaxBodyBytes int64 `json:"max_body_bytes"`
MaxConcurrentIngest int `json:"max_concurrent_ingest,omitempty"`
SessionLifetimeText string `json:"session_lifetime"`
SessionLifetime time.Duration `json:"-"`
Query QueryLimits `json:"query"`
Retention Retention `json:"retention"`
WebPush *WebPush `json:"web_push,omitempty"`
}
type WebPush struct {
PrivateKeyFile string `json:"private_key_file"`
Subject string `json:"subject"`
QueueCapacity int `json:"queue_capacity"`
RequestTimeout string `json:"request_timeout"`
Timeout time.Duration `json:"-"`
PrivateKey []byte `json:"-"`
}
type QueryLimits struct {
MaxDuration time.Duration `json:"-"`
MaxDurationText string `json:"max_duration"`
MaxRows int `json:"max_rows"`
MaxScannedBytes int64 `json:"max_scanned_bytes"`
MaxMemoryBytes int64 `json:"max_memory_bytes"`
}
type Retention struct {
RawLogsDays int `json:"raw_logs_days"`
RawTracesDays int `json:"raw_traces_days"`
RawMetricsDays int `json:"raw_metrics_days"`
ColdRawDays int `json:"cold_raw_days"`
DeleteColdRaw bool `json:"delete_cold_raw"`
MetricRollupsDays int `json:"metric_rollups_days"`
EvidenceDays int `json:"evidence_days"`
}
type FilePolicy struct {
RequireRoot bool
SystemdCredentialDirectory string
}
func SystemdCredentialPolicy() (FilePolicy, error) {
directory := os.Getenv("CREDENTIALS_DIRECTORY")
if directory == "" {
return FilePolicy{}, errors.New("CREDENTIALS_DIRECTORY is not set")
}
if !filepath.IsAbs(directory) || filepath.Clean(directory) != directory {
return FilePolicy{}, errors.New("CREDENTIALS_DIRECTORY must be an absolute clean path")
}
info, err := os.Lstat(directory)
if err != nil {
return FilePolicy{}, fmt.Errorf("inspect CREDENTIALS_DIRECTORY: %w", err)
}
if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
return FilePolicy{}, errors.New("CREDENTIALS_DIRECTORY must be a non-symlink directory")
}
return FilePolicy{SystemdCredentialDirectory: directory}, nil
}
type Agent struct {
Schema int `json:"schema"`
ServerURL string `json:"server_url"`
CredentialFile string `json:"credential_file"`
SpoolDir string `json:"spool_dir"`
StateFile string `json:"state_file"`
MaxSpoolBytes int64 `json:"max_spool_bytes"`
MaxSpoolAgeText string `json:"max_spool_age"`
MaxSpoolAge time.Duration `json:"-"`
BatchRecords int `json:"batch_records"`
FlushInterval string `json:"flush_interval"`
FlushEvery time.Duration `json:"-"`
Sources []AgentSource `json:"sources"`
AlertRules []AgentAlertRule `json:"alert_rules,omitempty"`
}
type AgentSource struct {
Kind string `json:"kind"`
Path string `json:"path,omitempty"`
StreamID string `json:"stream_id"`
SensitiveFields []string `json:"sensitive_fields,omitempty"`
LinuxMetrics *hostmetrics.Config `json:"linux_metrics,omitempty"`
}
type AgentAlertRule struct {
Version int `json:"version"`
ID string `json:"id"`
Revision int `json:"revision"`
StreamID string `json:"stream_id"`
Query string `json:"query"`
MinimumMatches int `json:"minimum_matches"`
AST query.AST `json:"-"`
}
func LoadServer(path string, policy FilePolicy) (Server, error) {
var cfg Server
if err := loadStrict(path, policy, &cfg); err != nil {
return Server{}, err
}
if cfg.Schema != SchemaVersion {
return Server{}, fmt.Errorf("unsupported server configuration schema %d", cfg.Schema)
}
if cfg.Listen == "" {
return Server{}, errors.New("listen is required")
}
u, err := url.Parse(cfg.PublicURL)
if err != nil || u.Scheme != "https" || u.Host == "" || u.User != nil || (u.Path != "" && u.Path != "/") || u.RawQuery != "" || u.Fragment != "" {
return Server{}, errors.New("public_url must be an absolute HTTPS origin")
}
if !filepath.IsAbs(cfg.DataDir) || filepath.Clean(cfg.DataDir) != cfg.DataDir {
return Server{}, errors.New("data_dir must be an absolute clean path")
}
if cfg.MaxBodyBytes < 1024 || cfg.MaxBodyBytes > 64<<20 {
return Server{}, errors.New("max_body_bytes must be between 1024 and 67108864")
}
if cfg.MaxConcurrentIngest == 0 {
cfg.MaxConcurrentIngest = 8
}
if cfg.MaxConcurrentIngest < 1 || cfg.MaxConcurrentIngest > 64 {
return Server{}, errors.New("max_concurrent_ingest must be between 1 and 64")
}
sessionLifetime, err := time.ParseDuration(cfg.SessionLifetimeText)
if err != nil || sessionLifetime < 5*time.Minute || sessionLifetime > 30*24*time.Hour {
return Server{}, errors.New("session_lifetime must be between 5m and 720h")
}
cfg.SessionLifetime = sessionLifetime
if cfg.Query.MaxRows < 1 || cfg.Query.MaxRows > 100_000 {
return Server{}, errors.New("query.max_rows must be between 1 and 100000")
}
if cfg.Query.MaxScannedBytes < 1 || cfg.Query.MaxMemoryBytes < 1 {
return Server{}, errors.New("query byte limits must be positive")
}
d, err := time.ParseDuration(cfg.Query.MaxDurationText)
if err != nil || d < time.Millisecond || d > time.Minute {
return Server{}, errors.New("query.max_duration must be between 1ms and 1m")
}
cfg.Query.MaxDuration = d
if err := cfg.Retention.validate(); err != nil {
return Server{}, err
}
if cfg.WebPush != nil {
if !filepath.IsAbs(cfg.WebPush.PrivateKeyFile) || filepath.Clean(cfg.WebPush.PrivateKeyFile) != cfg.WebPush.PrivateKeyFile {
return Server{}, errors.New("web_push.private_key_file must be an absolute clean path")
}
if err = validateWebPushSubject(cfg.WebPush.Subject); err != nil {
return Server{}, err
}
if cfg.WebPush.QueueCapacity < 1 || cfg.WebPush.QueueCapacity > 1024 {
return Server{}, errors.New("web_push.queue_capacity must be between 1 and 1024")
}
cfg.WebPush.Timeout, err = time.ParseDuration(cfg.WebPush.RequestTimeout)
if err != nil || cfg.WebPush.Timeout < time.Second || cfg.WebPush.Timeout > 30*time.Second {
return Server{}, errors.New("web_push.request_timeout must be between 1s and 30s")
}
cfg.WebPush.PrivateKey, err = LoadWebPushPrivateKey(cfg.WebPush.PrivateKeyFile, policy)
if err != nil {
return Server{}, err
}
}
return cfg, nil
}
func LoadAgent(path string, policy FilePolicy) (Agent, error) {
var cfg Agent
if err := loadStrict(path, policy, &cfg); err != nil {
return Agent{}, err
}
if cfg.Schema != SchemaVersion {
return Agent{}, fmt.Errorf("unsupported agent configuration schema %d", cfg.Schema)
}
u, err := url.Parse(cfg.ServerURL)
if err != nil || u.Scheme != "https" || u.Host == "" || u.User != nil || (u.Path != "" && u.Path != "/") || u.RawQuery != "" || u.Fragment != "" {
return Agent{}, errors.New("server_url must be an absolute HTTPS origin")
}
for label, path := range map[string]string{"credential_file": cfg.CredentialFile, "spool_dir": cfg.SpoolDir, "state_file": cfg.StateFile} {
if !filepath.IsAbs(path) || filepath.Clean(path) != path {
return Agent{}, fmt.Errorf("%s must be an absolute clean path", label)
}
}
if cfg.SpoolDir == cfg.StateFile || !strings.HasPrefix(cfg.StateFile, cfg.SpoolDir+string(os.PathSeparator)) {
return Agent{}, errors.New("state_file must be below spool_dir")
}
if cfg.MaxSpoolBytes < 1<<20 || cfg.MaxSpoolBytes > 5<<30 {
return Agent{}, errors.New("max_spool_bytes must be between 1 MiB and 5 GiB")
}
age, err := time.ParseDuration(cfg.MaxSpoolAgeText)
if err != nil || age < time.Hour || age > 72*time.Hour {
return Agent{}, errors.New("max_spool_age must be between 1h and 72h")
}
cfg.MaxSpoolAge = age
flush, err := time.ParseDuration(cfg.FlushInterval)
if err != nil || flush < 100*time.Millisecond || flush > time.Minute {
return Agent{}, errors.New("flush_interval must be between 100ms and 1m")
}
cfg.FlushEvery = flush
if cfg.BatchRecords < 1 || cfg.BatchRecords > model.MaxRecords {
return Agent{}, fmt.Errorf("batch_records must be between 1 and %d", model.MaxRecords)
}
if len(cfg.Sources) < 1 || len(cfg.Sources) > 64 {
return Agent{}, errors.New("sources must contain between 1 and 64 entries")
}
streams := map[string]bool{}
for index, source := range cfg.Sources {
switch source.Kind {
case "caddy_json", "requestlog_jsonl", "tend_events_jsonl":
if source.LinuxMetrics != nil {
return Agent{}, fmt.Errorf("sources[%d].linux_metrics is only valid for linux_metrics", index)
}
if !filepath.IsAbs(source.Path) || filepath.Clean(source.Path) != source.Path {
return Agent{}, fmt.Errorf("sources[%d].path must be absolute and clean", index)
}
case "linux_metrics":
if source.Path != "" || source.LinuxMetrics == nil {
return Agent{}, fmt.Errorf("sources[%d] requires linux_metrics and no path", index)
}
if err := source.LinuxMetrics.Validate(); err != nil {
return Agent{}, fmt.Errorf("sources[%d]: %w", index, err)
}
default:
return Agent{}, fmt.Errorf("sources[%d].kind is unsupported", index)
}
allowedSensitive := map[string]bool{}
switch source.Kind {
case "caddy_json":
for _, name := range []string{"client_ip", "query", "referrer", "user_agent"} {
allowedSensitive[name] = true
}
case "requestlog_jsonl":
for _, name := range []string{"client_ip", "query", "referrer", "session_id", "user_agent"} {
allowedSensitive[name] = true
}
}
selectedSensitive := map[string]bool{}
for _, name := range source.SensitiveFields {
if !allowedSensitive[name] {
return Agent{}, fmt.Errorf("sources[%d].sensitive_fields contains unsupported field %q", index, name)
}
if selectedSensitive[name] {
return Agent{}, fmt.Errorf("sources[%d].sensitive_fields duplicates %q", index, name)
}
selectedSensitive[name] = true
}
if err := model.ValidateStreamID(source.StreamID); err != nil {
return Agent{}, fmt.Errorf("sources[%d]: %w", index, err)
}
if streams[source.StreamID] {
return Agent{}, fmt.Errorf("sources[%d].stream_id is duplicated", index)
}
streams[source.StreamID] = true
}
rules := map[string]bool{}
for index := range cfg.AlertRules {
rule := &cfg.AlertRules[index]
if rule.Version != 1 || model.ValidateSourceID(rule.ID) != nil || rule.Revision < 1 || rule.Revision > 1_000_000 || !streams[rule.StreamID] || rule.MinimumMatches < 1 || rule.MinimumMatches > model.MaxRecords || rules[rule.ID] {
return Agent{}, fmt.Errorf("alert_rules[%d] identity is invalid", index)
}
var sourceKind string
for _, source := range cfg.Sources {
if source.StreamID == rule.StreamID {
sourceKind = source.Kind
break
}
}
if sourceKind != "caddy_json" && sourceKind != "requestlog_jsonl" {
return Agent{}, fmt.Errorf("alert_rules[%d] requires a log stream", index)
}
rule.AST, err = query.Parse(rule.Query, model.MaxRecords)
if err != nil || rule.AST.Signal != model.SignalLogs || len(rule.AST.Filters) == 0 || rule.AST.Summary != nil || rule.AST.Sort != nil || rule.AST.Window != 0 || rule.AST.Limit < rule.MinimumMatches {
return Agent{}, fmt.Errorf("alert_rules[%d] query must be a bounded logs filter without sort, summary, or window", index)
}
for _, filter := range rule.AST.Filters {
switch query.CanonicalField(filter.Field) {
case "project.id", "environment.id", "service.id", "source.id", "stream.id":
return Agent{}, fmt.Errorf("alert_rules[%d] cannot filter server-derived scope", index)
}
}
rules[rule.ID] = true
}
return cfg, nil
}
func LoadCredential(path string, policy FilePolicy) (string, error) {
var holder struct {
Credential string `json:"credential"`
}
if err := loadStrict(path, policy, &holder); err != nil {
return "", err
}
if len(holder.Credential) < 48 || len(holder.Credential) > 512 || !strings.HasPrefix(holder.Credential, "obs1.") || strings.ContainsAny(holder.Credential, " \t\r\n") {
return "", errors.New("credential file contains an invalid source credential")
}
return holder.Credential, nil
}
func LoadEnrollmentToken(path string, policy FilePolicy) (string, error) {
var holder struct {
EnrollmentToken string `json:"enrollment_token"`
}
if err := loadStrict(path, policy, &holder); err != nil {
return "", err
}
if len(holder.EnrollmentToken) != len("obse1.")+64 || !strings.HasPrefix(holder.EnrollmentToken, "obse1.") || strings.ContainsAny(holder.EnrollmentToken, " \t\r\n") {
return "", errors.New("enrollment file contains an invalid token")
}
return holder.EnrollmentToken, nil
}
func WriteEnrollmentToken(path, token string) error {
if len(token) != len("obse1.")+64 || !strings.HasPrefix(token, "obse1.") || strings.ContainsAny(token, " \t\r\n") {
return errors.New("invalid enrollment token")
}
return writePrivateJSON(path, struct {
EnrollmentToken string `json:"enrollment_token"`
}{token})
}
func WriteCredential(path, credential string) error {
if len(credential) < 48 || len(credential) > 512 || !strings.HasPrefix(credential, "obs1.") || strings.ContainsAny(credential, " \t\r\n") {
return errors.New("invalid source credential")
}
return writePrivateJSON(path, struct {
Credential string `json:"credential"`
}{credential})
}
func LoadWebPushPrivateKey(path string, policy FilePolicy) ([]byte, error) {
var holder struct {
PrivateKey string `json:"private_key"`
}
if err := loadStrict(path, policy, &holder); err != nil {
return nil, err
}
key, err := base64.RawURLEncoding.DecodeString(holder.PrivateKey)
if err != nil || len(key) != 32 {
return nil, errors.New("web push private key file is invalid")
}
if _, err = ecdh.P256().NewPrivateKey(key); err != nil {
return nil, errors.New("web push private key file is invalid")
}
return key, nil
}
func WriteWebPushPrivateKey(path string, key []byte) error {
if len(key) != 32 {
return errors.New("invalid web push private key")
}
if _, err := ecdh.P256().NewPrivateKey(key); err != nil {
return errors.New("invalid web push private key")
}
return writePrivateJSON(path, struct {
PrivateKey string `json:"private_key"`
}{base64.RawURLEncoding.EncodeToString(key)})
}
func writePrivateJSON(path string, value any) error {
if !filepath.IsAbs(path) || filepath.Clean(path) != path {
return errors.New("secret output path must be absolute and clean")
}
parent := filepath.Dir(path)
info, err := os.Lstat(parent)
if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
return errors.New("secret output directory must be an existing non-symlink directory")
}
file, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY|syscall.O_NOFOLLOW, 0o600)
if err != nil {
return fmt.Errorf("create secret output: %w", err)
}
body, marshalErr := json.Marshal(value)
if marshalErr == nil {
body = append(body, '\n')
_, marshalErr = file.Write(body)
}
if marshalErr == nil {
marshalErr = file.Sync()
}
if closeErr := file.Close(); marshalErr == nil {
marshalErr = closeErr
}
if marshalErr != nil {
_ = os.Remove(path)
return fmt.Errorf("write secret output: %w", marshalErr)
}
directory, err := os.Open(parent)
if err != nil {
return err
}
defer directory.Close()
return directory.Sync()
}
func (r Retention) validate() error {
values := []int{r.RawLogsDays, r.RawTracesDays, r.RawMetricsDays, r.ColdRawDays, r.MetricRollupsDays, r.EvidenceDays}
for _, days := range values {
if days < 1 || days > 3650 {
return errors.New("retention values must be between 1 and 3650 days")
}
}
if r.MetricRollupsDays < r.RawMetricsDays {
return errors.New("metric rollup retention cannot be shorter than raw metric retention")
}
if r.ColdRawDays < r.RawLogsDays || r.ColdRawDays < r.RawTracesDays || r.ColdRawDays < r.RawMetricsDays || r.ColdRawDays < r.EvidenceDays {
return errors.New("cold raw retention cannot be shorter than a hot raw or evidence retention window")
}
return nil
}
func validateWebPushSubject(subject string) error {
if len(subject) < 8 || len(subject) > 512 || strings.ContainsAny(subject, " \t\r\n") {
return errors.New("web_push.subject is invalid")
}
parsed, err := url.Parse(subject)
if err != nil || parsed.RawQuery != "" || parsed.Fragment != "" {
return errors.New("web_push.subject is invalid")
}
if parsed.Scheme == "mailto" && parsed.Opaque != "" && strings.Contains(parsed.Opaque, "@") {
return nil
}
if parsed.Scheme == "https" && parsed.Host != "" && parsed.User == nil {
return nil
}
return errors.New("web_push.subject must be a mailto address or HTTPS URL")
}
func loadStrict(path string, policy FilePolicy, out any) error {
if !filepath.IsAbs(path) || filepath.Clean(path) != path {
return errors.New("configuration path must be absolute and clean")
}
info, err := os.Lstat(path)
if err != nil {
return fmt.Errorf("inspect configuration: %w", err)
}
if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
return errors.New("configuration must be a regular non-symlink file")
}
if policy.SystemdCredentialDirectory != "" {
if filepath.Dir(path) != policy.SystemdCredentialDirectory {
return errors.New("runtime credential must be a direct child of CREDENTIALS_DIRECTORY")
}
mode := info.Mode().Perm()
if mode != 0o400 && mode != 0o440 && mode != 0o600 {
return fmt.Errorf("runtime credential mode must be 0400, 0440, or 0600, got %04o", mode)
}
if runtime.GOOS != "linux" {
return errors.New("systemd credential validation is supported only on Linux")
}
stat, ok := info.Sys().(*syscall.Stat_t)
if !ok || (stat.Uid != 0 && stat.Uid != uint32(os.Geteuid())) {
return errors.New("runtime credential must be owned by root or the service user")
}
} else if info.Mode().Perm() != 0o600 {
return fmt.Errorf("configuration mode must be 0600, got %04o", info.Mode().Perm())
}
if policy.RequireRoot {
if runtime.GOOS != "linux" {
return errors.New("root ownership validation is supported only on Linux")
}
stat, ok := info.Sys().(*syscall.Stat_t)
if !ok || stat.Uid != 0 {
return errors.New("configuration must be owned by root")
}
}
b, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("read configuration: %w", err)
}
dec := json.NewDecoder(bytes.NewReader(b))
dec.DisallowUnknownFields()
if err := dec.Decode(out); err != nil {
return fmt.Errorf("decode configuration: %w", err)
}
if err := dec.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
return errors.New("configuration must contain exactly one JSON value")
}
return nil
}
+349
View File
@@ -0,0 +1,349 @@
// SPDX-License-Identifier: AGPL-3.0-only
package config
import (
"crypto/ecdh"
"crypto/rand"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func TestLoadServerStrictAndValidated(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "server.json")
body := `{"schema":1,"listen":"127.0.0.1:9010","public_url":"https://observatory.example","data_dir":"` + filepath.Join(dir, "data") + `","max_body_bytes":1048576,"session_lifetime":"12h","query":{"max_duration":"2s","max_rows":1000,"max_scanned_bytes":10485760,"max_memory_bytes":8388608},"retention":{"raw_logs_days":30,"raw_traces_days":30,"raw_metrics_days":14,"cold_raw_days":400,"metric_rollups_days":400,"evidence_days":400}}`
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatal(err)
}
cfg, err := LoadServer(path, FilePolicy{})
if err != nil {
t.Fatal(err)
}
if cfg.Query.MaxDuration.String() != "2s" || cfg.MaxConcurrentIngest != 8 || cfg.Retention.EvidenceDays != 400 || cfg.Retention.DeleteColdRaw {
t.Fatalf("unexpected config: %#v", cfg)
}
deleting := strings.Replace(body, `"cold_raw_days":400`, `"cold_raw_days":400,"delete_cold_raw":true`, 1)
if err = os.WriteFile(path, []byte(deleting), 0o600); err != nil {
t.Fatal(err)
}
if cfg, err = LoadServer(path, FilePolicy{}); err != nil || !cfg.Retention.DeleteColdRaw {
t.Fatalf("explicit cold deletion config=%#v err=%v", cfg.Retention, err)
}
bad := strings.Replace(body, `"schema":1`, `"schema":1,"surprise":true`, 1)
if err := os.WriteFile(path, []byte(bad), 0o600); err != nil {
t.Fatal(err)
}
if _, err := LoadServer(path, FilePolicy{}); err == nil || !strings.Contains(err.Error(), "unknown field") {
t.Fatalf("expected unknown-field rejection, got %v", err)
}
bad = strings.Replace(body, `"max_body_bytes":1048576`, `"max_body_bytes":1048576,"max_concurrent_ingest":65`, 1)
if err = os.WriteFile(path, []byte(bad), 0o600); err != nil {
t.Fatal(err)
}
if _, err = LoadServer(path, FilePolicy{}); err == nil || !strings.Contains(err.Error(), "max_concurrent_ingest") {
t.Fatalf("expected ingestion concurrency rejection, got %v", err)
}
}
func TestDogfoodServerConfigurationMatchesTheDeploymentBoundary(t *testing.T) {
body, err := os.ReadFile(filepath.Join("..", "..", "release", "server.json"))
if err != nil {
t.Fatal(err)
}
dir := t.TempDir()
keyPath := filepath.Join(dir, "web-push.json")
key, err := ecdh.P256().GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
if err = WriteWebPushPrivateKey(keyPath, key.Bytes()); err != nil {
t.Fatal(err)
}
const runtimeKey = "/run/credentials/gamertan-observatory.service/web-push.json"
rewritten := strings.Replace(string(body), runtimeKey, keyPath, 1)
if rewritten == string(body) || strings.Contains(rewritten, runtimeKey) {
t.Fatal("dogfood Web Push credential path was not replaced exactly once")
}
path := filepath.Join(dir, "server.json")
if err = os.WriteFile(path, []byte(rewritten), 0o600); err != nil {
t.Fatal(err)
}
cfg, err := LoadServer(path, FilePolicy{})
if err != nil {
t.Fatal(err)
}
if cfg.Listen != "127.0.0.1:8093" || cfg.PublicURL != "https://observatory.gamertan.com" || cfg.DataDir != "/var/lib/gamertan-observatory" || cfg.MaxBodyBytes != 32<<20 || cfg.MaxConcurrentIngest != 8 || cfg.Retention.DeleteColdRaw || cfg.WebPush == nil || cfg.WebPush.Subject != "mailto:security@sandwichhime.com" || cfg.WebPush.QueueCapacity != 64 || cfg.WebPush.Timeout != 10*time.Second || string(cfg.WebPush.PrivateKey) != string(key.Bytes()) {
t.Fatalf("unexpected dogfood config: %#v", cfg)
}
}
func TestLoadServerRejectsRollupsShorterThanRawMetrics(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "server.json")
body := `{"schema":1,"listen":"127.0.0.1:9010","public_url":"https://observatory.example","data_dir":"` + filepath.Join(dir, "data") + `","max_body_bytes":1048576,"session_lifetime":"12h","query":{"max_duration":"2s","max_rows":1000,"max_scanned_bytes":10485760,"max_memory_bytes":8388608},"retention":{"raw_logs_days":30,"raw_traces_days":30,"raw_metrics_days":14,"cold_raw_days":400,"metric_rollups_days":7,"evidence_days":400}}`
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatal(err)
}
if _, err := LoadServer(path, FilePolicy{}); err == nil || !strings.Contains(err.Error(), "cannot be shorter") {
t.Fatalf("short metric rollup retention err=%v", err)
}
}
func TestLoadServerRejectsColdWindowShorterThanHotEvidence(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "server.json")
body := `{"schema":1,"listen":"127.0.0.1:9010","public_url":"https://observatory.example","data_dir":"` + filepath.Join(dir, "data") + `","max_body_bytes":1048576,"session_lifetime":"12h","query":{"max_duration":"2s","max_rows":1000,"max_scanned_bytes":10485760,"max_memory_bytes":8388608},"retention":{"raw_logs_days":30,"raw_traces_days":30,"raw_metrics_days":14,"cold_raw_days":399,"metric_rollups_days":400,"evidence_days":400}}`
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatal(err)
}
if _, err := LoadServer(path, FilePolicy{}); err == nil || !strings.Contains(err.Error(), "cold raw retention") {
t.Fatalf("short cold retention err=%v", err)
}
}
func TestLoadServerWebPushUsesSeparatePrivateKeyFile(t *testing.T) {
dir := t.TempDir()
keyPath := filepath.Join(dir, "web-push.json")
key, err := ecdh.P256().GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
if err = WriteWebPushPrivateKey(keyPath, key.Bytes()); err != nil {
t.Fatal(err)
}
path := filepath.Join(dir, "server.json")
body := `{"schema":1,"listen":"127.0.0.1:9010","public_url":"https://observatory.example","data_dir":"` + filepath.Join(dir, "data") + `","max_body_bytes":1048576,"session_lifetime":"12h","query":{"max_duration":"2s","max_rows":1000,"max_scanned_bytes":10485760,"max_memory_bytes":8388608},"retention":{"raw_logs_days":30,"raw_traces_days":30,"raw_metrics_days":14,"cold_raw_days":400,"metric_rollups_days":400,"evidence_days":400},"web_push":{"private_key_file":"` + keyPath + `","subject":"mailto:security@sandwichhime.com","queue_capacity":16,"request_timeout":"5s"}}`
if err = os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatal(err)
}
cfg, err := LoadServer(path, FilePolicy{})
if err != nil {
t.Fatal(err)
}
if cfg.WebPush == nil || cfg.WebPush.Timeout != 5*time.Second || cfg.WebPush.QueueCapacity != 16 || string(cfg.WebPush.PrivateKey) != string(key.Bytes()) {
t.Fatalf("web push=%+v", cfg.WebPush)
}
if err = os.WriteFile(keyPath, []byte(`{"private_key":"not-a-key"}`), 0o600); err != nil {
t.Fatal(err)
}
if _, err = LoadServer(path, FilePolicy{}); err == nil {
t.Fatal("invalid Web Push private key accepted")
}
}
func TestLoadServerRejectsModeAndSymlink(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "server.json")
if err := os.WriteFile(path, []byte(`{}`), 0o644); err != nil {
t.Fatal(err)
}
if _, err := LoadServer(path, FilePolicy{}); err == nil || !strings.Contains(err.Error(), "0600") {
t.Fatalf("expected mode rejection, got %v", err)
}
link := filepath.Join(dir, "link.json")
if err := os.Symlink(path, link); err != nil {
t.Skipf("symlink unavailable: %v", err)
}
if _, err := LoadServer(link, FilePolicy{}); err == nil || !strings.Contains(err.Error(), "non-symlink") {
t.Fatalf("expected symlink rejection, got %v", err)
}
}
func TestLoadAgentKeepsSourcesLocalAndBounded(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "agent.json")
body := `{"schema":1,"server_url":"https://observatory.example","credential_file":"` + filepath.Join(dir, "credential.json") + `","spool_dir":"` + filepath.Join(dir, "spool") + `","state_file":"` + filepath.Join(dir, "spool", "state.json") + `","max_spool_bytes":5368709120,"max_spool_age":"72h","batch_records":500,"flush_interval":"1s","sources":[{"kind":"caddy_json","path":"/var/log/caddy/access.jsonl","stream_id":"caddy","sensitive_fields":["client_ip","query","referrer","user_agent"]}]}`
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatal(err)
}
cfg, err := LoadAgent(path, FilePolicy{})
if err != nil {
t.Fatal(err)
}
if cfg.MaxSpoolAge != 72*time.Hour || cfg.FlushEvery != time.Second || cfg.Sources[0].Path != "/var/log/caddy/access.jsonl" || len(cfg.Sources[0].SensitiveFields) != 4 {
t.Fatalf("agent=%+v", cfg)
}
bad := strings.Replace(body, `"kind":"caddy_json"`, `"kind":"shell"`, 1)
if err := os.WriteFile(path, []byte(bad), 0o600); err != nil {
t.Fatal(err)
}
if _, err := LoadAgent(path, FilePolicy{}); err == nil {
t.Fatal("expected collector-kind rejection")
}
for _, change := range [][2]string{
{`"sensitive_fields":["client_ip","query","referrer","user_agent"]`, `"sensitive_fields":["client_ip","client_ip"]`},
{`"sensitive_fields":["client_ip","query","referrer","user_agent"]`, `"sensitive_fields":["cookie"]`},
{`"kind":"caddy_json"`, `"kind":"tend_events_jsonl"`},
} {
candidate := strings.Replace(body, change[0], change[1], 1)
if err := os.WriteFile(path, []byte(candidate), 0o600); err != nil {
t.Fatal(err)
}
if _, err := LoadAgent(path, FilePolicy{}); err == nil {
t.Fatalf("invalid sensitive-field configuration accepted: %s", change[1])
}
}
}
func TestLoadAgentAcceptsExplicitLinuxMetricSelectors(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "agent.json")
body := `{"schema":1,"server_url":"https://observatory.example","credential_file":"` + filepath.Join(dir, "credential.json") + `","spool_dir":"` + filepath.Join(dir, "spool") + `","state_file":"` + filepath.Join(dir, "spool", "state.json") + `","max_spool_bytes":1048576,"max_spool_age":"1h","batch_records":500,"flush_interval":"1s","sources":[{"kind":"linux_metrics","stream_id":"host-metrics","linux_metrics":{"proc_root":"/proc","cgroup_root":"/sys/fs/cgroup","filesystems":[{"name":"root","path":"/"}],"processes":[{"name":"caddy","pid_file":"/run/caddy.pid"}],"cgroups":[{"name":"caddy-service","path":"system.slice/caddy.service"}]}}]}`
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatal(err)
}
cfg, err := LoadAgent(path, FilePolicy{})
if err != nil {
t.Fatal(err)
}
if cfg.Sources[0].LinuxMetrics == nil || cfg.Sources[0].LinuxMetrics.Filesystems[0].Name != "root" {
t.Fatalf("agent=%+v", cfg)
}
bad := strings.Replace(body, `"path":"system.slice/caddy.service"`, `"path":"../escape"`, 1)
if err = os.WriteFile(path, []byte(bad), 0o600); err != nil {
t.Fatal(err)
}
if _, err = LoadAgent(path, FilePolicy{}); err == nil {
t.Fatal("escaping cgroup selector accepted")
}
}
func TestLoadAgentAcceptsOnlyLocalBoundedLogAlertRules(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "agent.json")
body := `{"schema":1,"server_url":"https://observatory.example","credential_file":"` + filepath.Join(dir, "credential.json") + `","spool_dir":"` + filepath.Join(dir, "spool") + `","state_file":"` + filepath.Join(dir, "spool", "state.json") + `","max_spool_bytes":1048576,"max_spool_age":"1h","batch_records":500,"flush_interval":"1s","sources":[{"kind":"requestlog_jsonl","path":"/var/log/example/request.jsonl","stream_id":"requests"}],"alert_rules":[{"version":1,"id":"http-failures","revision":2,"stream_id":"requests","query":"logs | where status >= 500 | limit 10","minimum_matches":1}]}`
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatal(err)
}
cfg, err := LoadAgent(path, FilePolicy{})
if err != nil || len(cfg.AlertRules) != 1 || cfg.AlertRules[0].AST.Signal != "logs" || len(cfg.AlertRules[0].AST.Filters) != 1 {
t.Fatalf("agent=%+v err=%v", cfg, err)
}
for _, replacement := range []string{
`"stream_id":"missing"`,
`"query":"metrics | where value > 0 | limit 10"`,
`"query":"logs | summarize count() | limit 10"`,
`"query":"logs | where service == other | limit 10"`,
`"minimum_matches":11`,
} {
candidate := body
switch {
case strings.Contains(replacement, "missing"):
candidate = strings.Replace(candidate, `"stream_id":"requests","query"`, replacement+`,"query"`, 1)
case strings.HasPrefix(replacement, `"query"`):
candidate = strings.Replace(candidate, `"query":"logs | where status >= 500 | limit 10"`, replacement, 1)
default:
candidate = strings.Replace(candidate, `"minimum_matches":1`, replacement, 1)
}
if err = os.WriteFile(path, []byte(candidate), 0o600); err != nil {
t.Fatal(err)
}
if _, err = LoadAgent(path, FilePolicy{}); err == nil {
t.Fatalf("invalid alert rule accepted: %s", replacement)
}
}
}
func TestLoadCredentialDoesNotAcceptWhitespaceOrExtraFields(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "credential.json")
valid := `{"credential":"obs1.source.` + strings.Repeat("a", 64) + `"}`
if err := os.WriteFile(path, []byte(valid), 0o600); err != nil {
t.Fatal(err)
}
if credential, err := LoadCredential(path, FilePolicy{}); err != nil || !strings.HasPrefix(credential, "obs1.source.") {
t.Fatalf("credential=%q err=%v", credential, err)
}
if err := os.WriteFile(path, []byte(`{"credential":"obs1.source.bad value"}`), 0o600); err != nil {
t.Fatal(err)
}
if _, err := LoadCredential(path, FilePolicy{}); err == nil {
t.Fatal("expected whitespace rejection")
}
}
func TestEnrollmentAndCredentialOutputsArePrivateAndExclusive(t *testing.T) {
dir := t.TempDir()
enrollmentPath := filepath.Join(dir, "enrollment.json")
enrollment := "obse1." + strings.Repeat("e", 64)
if err := WriteEnrollmentToken(enrollmentPath, enrollment); err != nil {
t.Fatal(err)
}
if got, err := LoadEnrollmentToken(enrollmentPath, FilePolicy{}); err != nil || got != enrollment {
t.Fatalf("token=%q err=%v", got, err)
}
if err := WriteEnrollmentToken(enrollmentPath, enrollment); err == nil {
t.Fatal("enrollment output overwritten")
}
credentialPath := filepath.Join(dir, "credential.json")
credential := "obs1.source." + strings.Repeat("a", 64)
if err := WriteCredential(credentialPath, credential); err != nil {
t.Fatal(err)
}
if got, err := LoadCredential(credentialPath, FilePolicy{}); err != nil || got != credential {
t.Fatalf("credential=%q err=%v", got, err)
}
for _, path := range []string{enrollmentPath, credentialPath} {
info, err := os.Stat(path)
if err != nil || info.Mode().Perm() != 0o600 {
t.Fatalf("path=%s info=%v err=%v", path, info, err)
}
}
}
func TestSystemdCredentialPolicyIsExplicitAndConfined(t *testing.T) {
dir := t.TempDir()
t.Setenv("CREDENTIALS_DIRECTORY", dir)
policy, err := SystemdCredentialPolicy()
if err != nil {
t.Fatal(err)
}
path := filepath.Join(dir, "credential.json")
credential := `{"credential":"obs1.source.` + strings.Repeat("a", 64) + `"}`
if err := os.WriteFile(path, []byte(credential), 0o400); err != nil {
t.Fatal(err)
}
if _, err := LoadCredential(path, policy); err != nil {
t.Fatalf("load systemd credential: %v", err)
}
if err := os.Chmod(path, 0o440); err != nil {
t.Fatal(err)
}
if _, err := LoadCredential(path, policy); err != nil {
t.Fatalf("load systemd mode-0440 credential: %v", err)
}
if err := os.Chmod(path, 0o444); err != nil {
t.Fatal(err)
}
if _, err := LoadCredential(path, policy); err == nil || !strings.Contains(err.Error(), "0400, 0440, or 0600") {
t.Fatalf("expected public credential mode rejection, got %v", err)
}
if err := os.Chmod(path, 0o400); err != nil {
t.Fatal(err)
}
outside := filepath.Join(t.TempDir(), "credential.json")
if err := os.WriteFile(outside, []byte(credential), 0o400); err != nil {
t.Fatal(err)
}
if _, err := LoadCredential(outside, policy); err == nil || !strings.Contains(err.Error(), "direct child") {
t.Fatalf("expected confinement rejection, got %v", err)
}
nested := filepath.Join(dir, "nested")
if err := os.Mkdir(nested, 0o700); err != nil {
t.Fatal(err)
}
nestedPath := filepath.Join(nested, "credential.json")
if err := os.WriteFile(nestedPath, []byte(credential), 0o400); err != nil {
t.Fatal(err)
}
if _, err := LoadCredential(nestedPath, policy); err == nil || !strings.Contains(err.Error(), "direct child") {
t.Fatalf("expected nested-path rejection, got %v", err)
}
}
+59
View File
@@ -0,0 +1,59 @@
// SPDX-License-Identifier: AGPL-3.0-only
package edgealert
import (
"errors"
"time"
"gamertan.com/observatory/internal/config"
"gamertan.com/observatory/internal/model"
"gamertan.com/observatory/internal/query"
)
type Evaluation struct {
State string
Matches int
WindowStart time.Time
WindowEnd time.Time
ObservedAt time.Time
}
// Evaluate applies one locally configured, filter-only log rule to one exact
// durable batch. It does not perform I/O, mutate incident state, or retain
// telemetry beyond the caller-owned batch.
func Evaluate(rule config.AgentAlertRule, batch model.Batch) (Evaluation, error) {
if rule.AST.Signal != model.SignalLogs || batch.Signal != model.SignalLogs || rule.StreamID != batch.StreamID || len(batch.Records) == 0 {
return Evaluation{}, errors.New("edge alert rule and batch are incompatible")
}
first, last := batch.Records[0].Timestamp.UTC(), batch.Records[0].Timestamp.UTC()
matches := 0
state := "clear"
for _, observation := range batch.Records {
timestamp := observation.Timestamp.UTC()
if timestamp.Before(first) {
first = timestamp
}
if timestamp.After(last) {
last = timestamp
}
matched, err := query.MatchObservation(observation, rule.AST, nil)
if err != nil {
return Evaluation{State: "error", Matches: matches, WindowStart: first, WindowEnd: last, ObservedAt: maxTime(batch.ObservedAt.UTC(), last)}, nil
}
if matched {
matches++
if matches >= rule.MinimumMatches {
state = "matched"
}
}
}
return Evaluation{State: state, Matches: matches, WindowStart: first, WindowEnd: last, ObservedAt: maxTime(batch.ObservedAt.UTC(), last)}, nil
}
func maxTime(left, right time.Time) time.Time {
if left.After(right) {
return left
}
return right
}
+105
View File
@@ -0,0 +1,105 @@
// SPDX-License-Identifier: AGPL-3.0-only
package edgealert
import (
"context"
"os"
"path/filepath"
"testing"
"time"
"gamertan.com/observatory/internal/config"
"gamertan.com/observatory/internal/model"
"gamertan.com/observatory/internal/query"
"gamertan.com/observatory/internal/storage"
)
func TestEvaluateMatchesBoundedBatchWithoutIO(t *testing.T) {
now := time.Date(2026, 8, 19, 0, 0, 0, 0, time.UTC)
ast, err := query.Parse("logs | where status >= 500 | limit 10", model.MaxRecords)
if err != nil {
t.Fatal(err)
}
rule := config.AgentAlertRule{Version: 1, ID: "rule-a", Revision: 1, StreamID: "requests", Query: "logs | where status >= 500 | limit 10", MinimumMatches: 2, AST: ast}
batch := model.Batch{Version: model.BatchVersion, SourceID: "source-a", StreamID: "requests", Sequence: 4, ObservedAt: now, Signal: model.SignalLogs, Records: []model.Observation{
{Timestamp: now.Add(-time.Second), Name: "request", Attributes: map[string]string{"http.status_code": "503"}},
{Timestamp: now, Name: "request", Attributes: map[string]string{"http.status_code": "502"}},
}}
evaluation, err := Evaluate(rule, batch)
if err != nil || evaluation.State != "matched" || evaluation.Matches != 2 || !evaluation.WindowStart.Equal(now.Add(-time.Second)) || !evaluation.WindowEnd.Equal(now) || !evaluation.ObservedAt.Equal(now) {
t.Fatalf("evaluation=%+v err=%v", evaluation, err)
}
batch.Records[1].Attributes["http.status_code"] = "200"
evaluation, err = Evaluate(rule, batch)
if err != nil || evaluation.State != "clear" || evaluation.Matches != 1 {
t.Fatalf("evaluation=%+v err=%v", evaluation, err)
}
}
func TestEdgeEvaluationMatchesCentralOracleForExactBatch(t *testing.T) {
ctx := context.Background()
root := filepath.Join(t.TempDir(), "data")
if err := os.Mkdir(root, 0o700); err != nil {
t.Fatal(err)
}
store, err := storage.Open(root)
if err != nil {
t.Fatal(err)
}
defer store.Close()
now := time.Date(2026, 8, 19, 0, 5, 0, 0, time.UTC)
scope := model.Scope{OrganizationID: "organization-a", ProjectID: "project-a", EnvironmentID: "production", ServiceID: "service-a"}
token, err := store.CreateSource(ctx, "source-a", scope)
if err != nil {
t.Fatal(err)
}
queryText := "logs | where status >= 500 | limit 10"
ast, err := query.Parse(queryText, model.MaxRecords)
if err != nil {
t.Fatal(err)
}
saved, err := store.SaveQuery(ctx, storage.SavedQueryInput{OrganizationID: scope.OrganizationID, ActorUserID: "operator-a", MaxRows: 100, Name: "Failures", Query: queryText, Scope: storage.ResourceScope{ProjectID: scope.ProjectID, EnvironmentID: scope.EnvironmentID, ServiceID: scope.ServiceID}}, now)
if err != nil {
t.Fatal(err)
}
_, err = store.SaveAlertRule(ctx, storage.AlertRuleInput{OrganizationID: scope.OrganizationID, ActorUserID: "operator-a", SavedQueryID: saved.ID, Name: "Failures", Severity: "warning", MinimumMatches: 1, RequiredConsecutive: 1, EvaluationInterval: 15 * time.Second, Enabled: true}, now)
if err != nil {
t.Fatal(err)
}
batch := model.Batch{Version: model.BatchVersion, SourceID: "source-a", StreamID: "requests", Sequence: 1, ObservedAt: now, Signal: model.SignalLogs, Records: []model.Observation{{Timestamp: now, Name: "request", Attributes: map[string]string{"http.status_code": "503"}}, {Timestamp: now, Name: "request", Attributes: map[string]string{"http.status_code": "200"}}}}
if _, err = store.Ingest(ctx, token, batch, now); err != nil {
t.Fatal(err)
}
for {
report, projectErr := store.ProjectPending(ctx)
if projectErr != nil {
t.Fatal(projectErr)
}
if report.ProjectedSegments == 0 {
break
}
}
edge, err := Evaluate(config.AgentAlertRule{Version: 1, ID: "rule-a", Revision: 1, StreamID: "requests", Query: queryText, MinimumMatches: 1, AST: ast}, batch)
if err != nil {
t.Fatal(err)
}
central, err := store.EvaluateDueAlertRules(ctx, query.Budget{MaxDuration: time.Second, MaxRows: 100, MaxScannedBytes: 16 << 20, MaxMemoryBytes: 8 << 20}, now)
if err != nil || len(central) != 1 || (edge.State == "matched") != central[0].Matched || edge.Matches != central[0].Rows {
t.Fatalf("edge=%+v central=%+v err=%v", edge, central, err)
}
}
func TestEvaluateReturnsBoundedErrorStateForTypedMismatch(t *testing.T) {
now := time.Now().UTC()
ast, err := query.Parse("logs | where status >= nope | limit 10", model.MaxRecords)
if err != nil {
t.Fatal(err)
}
rule := config.AgentAlertRule{Version: 1, ID: "rule-a", Revision: 1, StreamID: "requests", Query: "logs | where status >= nope | limit 10", MinimumMatches: 1, AST: ast}
batch := model.Batch{Version: model.BatchVersion, SourceID: "source-a", StreamID: "requests", Sequence: 1, ObservedAt: now, Signal: model.SignalLogs, Records: []model.Observation{{Timestamp: now, Name: "request", Attributes: map[string]string{"http.status_code": "503"}}}}
evaluation, err := Evaluate(rule, batch)
if err != nil || evaluation.State != "error" || evaluation.Matches != 0 {
t.Fatalf("evaluation=%+v err=%v", evaluation, err)
}
}
+487
View File
@@ -0,0 +1,487 @@
// SPDX-License-Identifier: AGPL-3.0-only
package hostmetrics
import (
"bufio"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
"syscall"
"time"
"unicode/utf8"
"gamertan.com/observatory/internal/model"
)
const maxSourceBytes = 1 << 20
type Config struct {
ProcRoot string `json:"proc_root"`
CgroupRoot string `json:"cgroup_root,omitempty"`
Filesystems []Filesystem `json:"filesystems,omitempty"`
Processes []Process `json:"processes,omitempty"`
ControlGroups []Cgroup `json:"cgroups,omitempty"`
}
type Filesystem struct {
Name string `json:"name"`
Path string `json:"path"`
}
type Process struct {
Name string `json:"name"`
PIDFile string `json:"pid_file"`
}
type Cgroup struct {
Name string `json:"name"`
Path string `json:"path"`
}
func (configuration Config) Validate() error {
if runtime.GOOS != "linux" {
return errors.New("Linux metrics require Linux")
}
if err := absoluteClean("proc_root", configuration.ProcRoot); err != nil {
return err
}
if configuration.CgroupRoot != "" {
if err := absoluteClean("cgroup_root", configuration.CgroupRoot); err != nil {
return err
}
}
if len(configuration.Filesystems) > 32 || len(configuration.Processes) > 32 || len(configuration.ControlGroups) > 32 {
return errors.New("Linux metric selector limit exceeded")
}
names := map[string]bool{}
for _, filesystem := range configuration.Filesystems {
if err := selectorName(filesystem.Name, names); err != nil {
return fmt.Errorf("filesystem: %w", err)
}
if err := absoluteClean("filesystem path", filesystem.Path); err != nil {
return err
}
}
for _, process := range configuration.Processes {
if err := selectorName(process.Name, names); err != nil {
return fmt.Errorf("process: %w", err)
}
if err := absoluteClean("pid_file", process.PIDFile); err != nil {
return err
}
}
for _, group := range configuration.ControlGroups {
if err := selectorName(group.Name, names); err != nil {
return fmt.Errorf("cgroup: %w", err)
}
if configuration.CgroupRoot == "" {
return errors.New("cgroup_root is required when cgroups are selected")
}
if group.Path == "" || filepath.IsAbs(group.Path) || filepath.Clean(group.Path) != group.Path || group.Path == "." || strings.HasPrefix(group.Path, ".."+string(os.PathSeparator)) {
return errors.New("cgroup path must be a clean relative path below cgroup_root")
}
}
return nil
}
func Collect(configuration Config, now time.Time) ([]model.Observation, error) {
if err := configuration.Validate(); err != nil {
return nil, err
}
if now.IsZero() {
return nil, errors.New("collection time is required")
}
if err := secureDirectory(configuration.ProcRoot); err != nil {
return nil, errors.New("proc_root is unavailable")
}
var observations []model.Observation
var problems []error
appendResult := func(result []model.Observation, err error) {
observations = append(observations, result...)
if err != nil {
problems = append(problems, err)
}
}
result, err := collectStat(configuration.ProcRoot, now)
appendResult(result, err)
result, err = collectMemory(configuration.ProcRoot, now)
appendResult(result, err)
result, err = collectLoad(configuration.ProcRoot, now)
appendResult(result, err)
result, err = collectNetwork(configuration.ProcRoot, now)
appendResult(result, err)
for _, filesystem := range configuration.Filesystems {
result, err = collectFilesystem(filesystem, now)
appendResult(result, err)
}
for _, process := range configuration.Processes {
result, err = collectProcess(configuration.ProcRoot, process, now)
appendResult(result, err)
}
for _, group := range configuration.ControlGroups {
result, err = collectCgroup(configuration.CgroupRoot, group, now)
appendResult(result, err)
}
if len(observations) > model.MaxRecords {
return nil, errors.New("Linux metric record limit exceeded")
}
return observations, errors.Join(problems...)
}
func collectStat(root string, now time.Time) ([]model.Observation, error) {
body, err := readBounded(filepath.Join(root, "stat"))
if err != nil {
return nil, errors.New("read proc stat")
}
var observations []model.Observation
for _, line := range strings.Split(string(body), "\n") {
fields := strings.Fields(line)
if len(fields) < 5 || fields[0] != "cpu" {
continue
}
states := []string{"user", "nice", "system", "idle", "iowait", "irq", "softirq", "steal", "guest", "guest_nice"}
for index := 1; index < len(fields) && index <= len(states); index++ {
value, parseErr := strconv.ParseFloat(fields[index], 64)
if parseErr != nil {
return nil, errors.New("proc stat contains an invalid CPU counter")
}
observations = append(observations, metric(now, "system.cpu.time_ticks", value, "ticks", map[string]string{"state": states[index-1]}))
}
break
}
uptime, err := readBounded(filepath.Join(root, "uptime"))
if err == nil {
fields := strings.Fields(string(uptime))
if len(fields) > 0 {
if value, parseErr := strconv.ParseFloat(fields[0], 64); parseErr == nil {
observations = append(observations, metric(now, "system.uptime", value, "seconds", nil))
}
}
}
if len(observations) == 0 {
return nil, errors.New("proc stat contains no aggregate CPU record")
}
return observations, nil
}
func collectMemory(root string, now time.Time) ([]model.Observation, error) {
body, err := readBounded(filepath.Join(root, "meminfo"))
if err != nil {
return nil, errors.New("read proc meminfo")
}
wanted := map[string]string{"MemTotal": "total", "MemAvailable": "available", "SwapTotal": "swap_total", "SwapFree": "swap_free"}
var observations []model.Observation
for _, line := range strings.Split(string(body), "\n") {
fields := strings.Fields(line)
if len(fields) < 2 {
continue
}
state, ok := wanted[strings.TrimSuffix(fields[0], ":")]
if !ok {
continue
}
value, parseErr := strconv.ParseFloat(fields[1], 64)
if parseErr != nil {
return nil, errors.New("proc meminfo contains an invalid value")
}
if len(fields) > 2 && fields[2] == "kB" {
value *= 1024
}
observations = append(observations, metric(now, "system.memory", value, "bytes", map[string]string{"state": state}))
}
if len(observations) != len(wanted) {
return observations, errors.New("proc meminfo is missing required values")
}
return observations, nil
}
func collectLoad(root string, now time.Time) ([]model.Observation, error) {
body, err := readBounded(filepath.Join(root, "loadavg"))
if err != nil {
return nil, errors.New("read proc loadavg")
}
fields := strings.Fields(string(body))
if len(fields) < 3 {
return nil, errors.New("proc loadavg is incomplete")
}
periods := []string{"1m", "5m", "15m"}
observations := make([]model.Observation, 0, 3)
for index := range periods {
value, parseErr := strconv.ParseFloat(fields[index], 64)
if parseErr != nil {
return nil, errors.New("proc loadavg contains an invalid value")
}
observations = append(observations, metric(now, "system.load.average", value, "1", map[string]string{"period": periods[index]}))
}
return observations, nil
}
func collectNetwork(root string, now time.Time) ([]model.Observation, error) {
body, err := readBounded(filepath.Join(root, "net", "dev"))
if err != nil {
return nil, errors.New("read proc network counters")
}
var observations []model.Observation
for _, line := range strings.Split(string(body), "\n") {
separator := strings.IndexByte(line, ':')
if separator < 0 {
continue
}
name := strings.TrimSpace(line[:separator])
if !safeLabel(name) {
return nil, errors.New("proc network interface name is invalid")
}
fields := strings.Fields(line[separator+1:])
if len(fields) != 16 {
return nil, errors.New("proc network counter record is invalid")
}
indexes := []struct {
field int
name string
dir string
}{{0, "system.network.bytes", "receive"}, {1, "system.network.packets", "receive"}, {3, "system.network.dropped_packets", "receive"}, {8, "system.network.bytes", "transmit"}, {9, "system.network.packets", "transmit"}, {11, "system.network.dropped_packets", "transmit"}}
for _, selected := range indexes {
value, parseErr := strconv.ParseFloat(fields[selected.field], 64)
if parseErr != nil {
return nil, errors.New("proc network counter is invalid")
}
unit := "1"
if selected.name == "system.network.bytes" {
unit = "bytes"
}
observations = append(observations, metric(now, selected.name, value, unit, map[string]string{"interface": name, "direction": selected.dir}))
}
if len(observations) > 128*6 {
return nil, errors.New("proc network interface limit exceeded")
}
}
return observations, nil
}
func collectFilesystem(selected Filesystem, now time.Time) ([]model.Observation, error) {
info, err := os.Lstat(selected.Path)
if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
return nil, fmt.Errorf("filesystem %s is unavailable", selected.Name)
}
var status syscall.Statfs_t
if err = syscall.Statfs(selected.Path, &status); err != nil {
return nil, fmt.Errorf("inspect filesystem %s", selected.Name)
}
blockSize := float64(status.Bsize)
attributes := map[string]string{"filesystem": selected.Name}
return []model.Observation{
metric(now, "system.filesystem.bytes", float64(status.Blocks)*blockSize, "bytes", with(attributes, "state", "total")),
metric(now, "system.filesystem.bytes", float64(status.Bavail)*blockSize, "bytes", with(attributes, "state", "available")),
metric(now, "system.filesystem.inodes", float64(status.Files), "1", with(attributes, "state", "total")),
metric(now, "system.filesystem.inodes", float64(status.Ffree), "1", with(attributes, "state", "free")),
}, nil
}
func collectProcess(procRoot string, selected Process, now time.Time) ([]model.Observation, error) {
pidBody, err := readBounded(selected.PIDFile)
if err != nil {
return []model.Observation{metric(now, "process.up", 0, "1", map[string]string{"process": selected.Name})}, fmt.Errorf("process %s PID is unavailable", selected.Name)
}
pidText := strings.TrimSpace(string(pidBody))
pid, err := strconv.ParseUint(pidText, 10, 31)
if err != nil || pid == 0 {
return []model.Observation{metric(now, "process.up", 0, "1", map[string]string{"process": selected.Name})}, fmt.Errorf("process %s PID is invalid", selected.Name)
}
stat, err := readBounded(filepath.Join(procRoot, pidText, "stat"))
if err != nil {
return []model.Observation{metric(now, "process.up", 0, "1", map[string]string{"process": selected.Name})}, fmt.Errorf("process %s is unavailable", selected.Name)
}
closing := strings.LastIndexByte(string(stat), ')')
if closing < 2 {
return nil, fmt.Errorf("process %s stat is invalid", selected.Name)
}
fields := strings.Fields(string(stat)[closing+1:])
// fields begin with state (field 3); utime, stime, starttime, vsize, rss
// are therefore indexes 11, 12, 19, 20, and 21 in this slice.
if len(fields) < 22 {
return nil, fmt.Errorf("process %s stat is incomplete", selected.Name)
}
values := make([]float64, 5)
for index, field := range []int{11, 12, 19, 20, 21} {
values[index], err = strconv.ParseFloat(fields[field], 64)
if err != nil {
return nil, fmt.Errorf("process %s stat contains an invalid counter", selected.Name)
}
}
pageSize := float64(os.Getpagesize())
base := map[string]string{"process": selected.Name}
observations := []model.Observation{
metric(now, "process.up", 1, "1", base),
metric(now, "process.start_time_ticks", values[2], "ticks", base),
metric(now, "process.cpu.time_ticks", values[0], "ticks", with(base, "state", "user")),
metric(now, "process.cpu.time_ticks", values[1], "ticks", with(base, "state", "system")),
metric(now, "process.memory.virtual", values[3], "bytes", base),
metric(now, "process.memory.resident", values[4]*pageSize, "bytes", base),
}
if ioBody, ioErr := readBounded(filepath.Join(procRoot, pidText, "io")); ioErr == nil {
for _, line := range strings.Split(string(ioBody), "\n") {
fields := strings.Fields(line)
if len(fields) != 2 || fields[0] != "read_bytes:" && fields[0] != "write_bytes:" {
continue
}
value, parseErr := strconv.ParseFloat(fields[1], 64)
if parseErr != nil {
continue
}
direction := strings.TrimSuffix(fields[0], "_bytes:")
observations = append(observations, metric(now, "process.io.bytes", value, "bytes", with(base, "direction", direction)))
}
}
return observations, nil
}
func collectCgroup(root string, selected Cgroup, now time.Time) ([]model.Observation, error) {
directory, err := secureRelativeDirectory(root, selected.Path)
if err != nil {
return []model.Observation{metric(now, "cgroup.up", 0, "1", map[string]string{"cgroup": selected.Name})}, fmt.Errorf("cgroup %s is unavailable", selected.Name)
}
base := map[string]string{"cgroup": selected.Name}
observations := []model.Observation{metric(now, "cgroup.up", 1, "1", base)}
for _, scalar := range []struct {
file, name, unit, state string
}{{"memory.current", "cgroup.memory", "bytes", "current"}, {"memory.peak", "cgroup.memory", "bytes", "peak"}, {"memory.swap.current", "cgroup.memory", "bytes", "swap"}, {"pids.current", "cgroup.pids", "1", "current"}} {
body, readErr := readBounded(filepath.Join(directory, scalar.file))
if readErr != nil {
continue
}
value, parseErr := strconv.ParseFloat(strings.TrimSpace(string(body)), 64)
if parseErr == nil {
observations = append(observations, metric(now, scalar.name, value, scalar.unit, with(base, "state", scalar.state)))
}
}
if body, readErr := readBounded(filepath.Join(directory, "cpu.stat")); readErr == nil {
for _, line := range strings.Split(string(body), "\n") {
fields := strings.Fields(line)
if len(fields) != 2 || fields[0] != "usage_usec" && fields[0] != "user_usec" && fields[0] != "system_usec" {
continue
}
value, parseErr := strconv.ParseFloat(fields[1], 64)
if parseErr == nil {
observations = append(observations, metric(now, "cgroup.cpu.time", value, "microseconds", with(base, "state", strings.TrimSuffix(fields[0], "_usec"))))
}
}
}
if body, readErr := readBounded(filepath.Join(directory, "io.stat")); readErr == nil {
var readBytes, writeBytes float64
for _, line := range strings.Split(string(body), "\n") {
for _, field := range strings.Fields(line) {
key, text, found := strings.Cut(field, "=")
if !found || key != "rbytes" && key != "wbytes" {
continue
}
value, parseErr := strconv.ParseFloat(text, 64)
if parseErr != nil {
continue
}
if key == "rbytes" {
readBytes += value
} else {
writeBytes += value
}
}
}
observations = append(observations,
metric(now, "cgroup.io.bytes", readBytes, "bytes", with(base, "direction", "read")),
metric(now, "cgroup.io.bytes", writeBytes, "bytes", with(base, "direction", "write")),
)
}
return observations, nil
}
func metric(now time.Time, name string, value float64, unit string, attributes map[string]string) model.Observation {
copied := with(attributes, "unit", unit)
return model.Observation{Timestamp: now.UTC(), Name: name, Value: &value, Attributes: copied}
}
func with(source map[string]string, key, value string) map[string]string {
result := make(map[string]string, len(source)+1)
for existingKey, existingValue := range source {
result[existingKey] = existingValue
}
result[key] = value
return result
}
func readBounded(path string) ([]byte, error) {
before, err := os.Lstat(path)
if err != nil || !before.Mode().IsRegular() || before.Mode()&os.ModeSymlink != 0 || before.Size() > maxSourceBytes {
return nil, errors.New("metric source is not a bounded regular file")
}
file, err := os.Open(path)
if err != nil {
return nil, err
}
defer file.Close()
after, err := file.Stat()
if err != nil || !after.Mode().IsRegular() || !os.SameFile(before, after) {
return nil, errors.New("metric source changed during open")
}
body, err := io.ReadAll(io.LimitReader(bufio.NewReader(file), maxSourceBytes+1))
if err != nil || len(body) > maxSourceBytes || !utf8.Valid(body) || strings.IndexByte(string(body), 0) >= 0 {
return nil, errors.New("metric source exceeds accepted bounds")
}
return body, nil
}
func secureDirectory(path string) error {
info, err := os.Lstat(path)
if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
return errors.New("path is not a non-symlink directory")
}
return nil
}
func secureRelativeDirectory(root, relative string) (string, error) {
if err := secureDirectory(root); err != nil {
return "", err
}
current := root
for _, part := range strings.Split(filepath.ToSlash(relative), "/") {
current = filepath.Join(current, part)
if err := secureDirectory(current); err != nil {
return "", err
}
}
return current, nil
}
func absoluteClean(label, value string) error {
if !filepath.IsAbs(value) || filepath.Clean(value) != value {
return fmt.Errorf("%s must be absolute and clean", label)
}
return nil
}
func selectorName(value string, names map[string]bool) error {
if !safeLabel(value) {
return errors.New("selector name is invalid")
}
if names[value] {
return errors.New("selector name is duplicated")
}
names[value] = true
return nil
}
func safeLabel(value string) bool {
if value == "" || len(value) > 128 {
return false
}
for _, character := range value {
if !(character >= 'a' && character <= 'z' || character >= 'A' && character <= 'Z' || character >= '0' && character <= '9' || strings.ContainsRune("._-", character)) {
return false
}
}
return true
}
+158
View File
@@ -0,0 +1,158 @@
// SPDX-License-Identifier: AGPL-3.0-only
package hostmetrics
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
"gamertan.com/observatory/internal/model"
)
func TestCollectReadsOnlyConfiguredLinuxEvidence(t *testing.T) {
root := t.TempDir()
proc := filepath.Join(root, "proc")
cgroup := filepath.Join(root, "cgroup")
filesystem := filepath.Join(root, "filesystem")
pidFile := filepath.Join(root, "service.pid")
for _, directory := range []string{filepath.Join(proc, "net"), filepath.Join(proc, "123"), filepath.Join(cgroup, "system.slice", "example.service"), filesystem} {
if err := os.MkdirAll(directory, 0o700); err != nil {
t.Fatal(err)
}
}
write := func(path, body string) {
t.Helper()
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatal(err)
}
}
write(filepath.Join(proc, "stat"), "cpu 100 2 30 400 5 6 7 8 9 10\n")
write(filepath.Join(proc, "uptime"), "99.5 80.0\n")
write(filepath.Join(proc, "meminfo"), "MemTotal: 1000 kB\nMemAvailable: 750 kB\nSwapTotal: 200 kB\nSwapFree: 150 kB\n")
write(filepath.Join(proc, "loadavg"), "0.10 0.20 0.30 1/100 123\n")
write(filepath.Join(proc, "net", "dev"), "Inter-| Receive | Transmit\nlo: 100 2 0 1 0 0 0 0 200 3 0 2 0 0 0 0\n")
write(pidFile, "123\n")
write(filepath.Join(proc, "123", "stat"), "123 (example worker) S 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23\n")
write(filepath.Join(proc, "123", "io"), "read_bytes: 4096\nwrite_bytes: 8192\n")
group := filepath.Join(cgroup, "system.slice", "example.service")
write(filepath.Join(group, "memory.current"), "1024\n")
write(filepath.Join(group, "memory.peak"), "2048\n")
write(filepath.Join(group, "memory.swap.current"), "0\n")
write(filepath.Join(group, "pids.current"), "3\n")
write(filepath.Join(group, "cpu.stat"), "usage_usec 300\nuser_usec 200\nsystem_usec 100\n")
write(filepath.Join(group, "io.stat"), "8:0 rbytes=100 wbytes=200 rios=1 wios=2\n8:1 rbytes=300 wbytes=400 rios=3 wios=4\n")
configuration := Config{
ProcRoot: proc, CgroupRoot: cgroup,
Filesystems: []Filesystem{{Name: "data", Path: filesystem}},
Processes: []Process{{Name: "web", PIDFile: pidFile}},
ControlGroups: []Cgroup{{Name: "web-service", Path: filepath.Join("system.slice", "example.service")}},
}
now := time.Date(2026, 8, 17, 6, 0, 0, 0, time.UTC)
observations, err := Collect(configuration, now)
if err != nil {
t.Fatal(err)
}
wanted := map[string]bool{
"system.cpu.time_ticks": false, "system.memory": false, "system.load.average": false,
"system.network.bytes": false, "system.filesystem.bytes": false, "process.up": false,
"process.start_time_ticks": false, "process.io.bytes": false, "cgroup.up": false, "cgroup.io.bytes": false,
}
for _, observation := range observations {
if _, ok := wanted[observation.Name]; ok {
wanted[observation.Name] = true
}
for _, value := range observation.Attributes {
if strings.Contains(value, root) {
t.Fatalf("local path leaked in attributes: %+v", observation)
}
}
if _, found := observation.Attributes["start_ticks"]; found {
t.Fatalf("dynamic process identity leaked into metric attributes: %+v", observation)
}
}
for name, found := range wanted {
if !found {
t.Errorf("missing %s", name)
}
}
batch := model.Batch{Version: model.BatchVersion, SourceID: "source", StreamID: "host-metrics", Sequence: 1, ObservedAt: now, Signal: model.SignalMetrics, Records: observations}
if err = batch.Validate(now); err != nil {
t.Fatalf("collected batch: %v", err)
}
}
func TestCollectRejectsSymlinkMetricSource(t *testing.T) {
proc := createMinimalProc(t)
target := filepath.Join(t.TempDir(), "pid")
if err := os.WriteFile(target, []byte("123\n"), 0o600); err != nil {
t.Fatal(err)
}
link := filepath.Join(t.TempDir(), "selected.pid")
if err := os.Symlink(target, link); err != nil {
t.Fatal(err)
}
observations, err := Collect(Config{ProcRoot: proc, Processes: []Process{{Name: "selected", PIDFile: link}}}, time.Date(2026, 8, 17, 6, 0, 0, 0, time.UTC))
if err == nil {
t.Fatal("symlink PID source accepted without a collection warning")
}
for _, observation := range observations {
if observation.Name == "process.up" && observation.Value != nil && *observation.Value == 0 {
return
}
}
t.Fatal("rejected symlink source did not retain process.up=0 evidence")
}
func TestCollectReportsMissingSelectedProcessWithoutDroppingHostMetrics(t *testing.T) {
proc := createMinimalProc(t)
now := time.Date(2026, 8, 17, 6, 0, 0, 0, time.UTC)
observations, err := Collect(Config{ProcRoot: proc, Processes: []Process{{Name: "missing", PIDFile: filepath.Join(t.TempDir(), "missing.pid")}}}, now)
if err == nil || len(observations) == 0 {
t.Fatalf("observations=%d err=%v", len(observations), err)
}
foundDown := false
for _, observation := range observations {
if observation.Name == "process.up" && observation.Value != nil && *observation.Value == 0 {
foundDown = true
}
}
if !foundDown {
t.Fatal("missing process did not emit process.up=0")
}
}
func TestValidationRejectsEscapingAndDuplicateSelectors(t *testing.T) {
root := t.TempDir()
for _, configuration := range []Config{
{ProcRoot: "relative"},
{ProcRoot: root, CgroupRoot: root, ControlGroups: []Cgroup{{Name: "bad", Path: "../escape"}}},
{ProcRoot: root, Filesystems: []Filesystem{{Name: "same", Path: root}}, Processes: []Process{{Name: "same", PIDFile: filepath.Join(root, "pid")}}},
} {
if err := configuration.Validate(); err == nil {
t.Fatalf("invalid configuration accepted: %+v", configuration)
}
}
}
func createMinimalProc(t *testing.T) string {
t.Helper()
proc := filepath.Join(t.TempDir(), "proc")
if err := os.MkdirAll(filepath.Join(proc, "net"), 0o700); err != nil {
t.Fatal(err)
}
files := map[string]string{
"stat": "cpu 1 1 1 1\n", "uptime": "1 1\n",
"meminfo": "MemTotal: 1 kB\nMemAvailable: 1 kB\nSwapTotal: 0 kB\nSwapFree: 0 kB\n",
"loadavg": "0 0 0 1/1 1\n", filepath.Join("net", "dev"): "Inter-| Receive | Transmit\n",
}
for name, body := range files {
if err := os.WriteFile(filepath.Join(proc, name), []byte(body), 0o600); err != nil {
t.Fatal(err)
}
}
return proc
}
+182
View File
@@ -0,0 +1,182 @@
// SPDX-License-Identifier: AGPL-3.0-only
package httpserver
import (
"errors"
"fmt"
"net/http"
"net/url"
"time"
"gamertan.com/observatory/internal/identity"
"gamertan.com/observatory/internal/query"
"gamertan.com/observatory/internal/site"
"gamertan.com/web/access"
"gamertan.com/web/auth"
"gamertan.com/web/authhttp"
)
const defaultExploreQuery = "logs | window 1h | limit 50"
func (s *Server) explorePage(w http.ResponseWriter, r *http.Request) {
view, _, ok := s.exploreView(w, r, defaultExploreQuery)
if !ok {
return
}
s.renderHTML(w, r, http.StatusOK, site.Explore(view))
}
func (s *Server) exploreForm(w http.ResponseWriter, r *http.Request) {
values, err := readForm(w, r, 20<<10, "csrf_token", "query")
queryText := defaultExploreQuery
if err == nil {
queryText = values.Get("query")
}
view, token, ok := s.exploreView(w, r, queryText)
if !ok {
return
}
csrfOK := err == nil && authhttp.VerifyCSRF(token, "query:execute", values.Get("csrf_token"))
if !tokenBoundFormRequest(r, s.options.PublicOrigin, csrfOK) {
view.ErrorMessage = "This query form expired or could not be verified. Please try again."
s.renderHTML(w, r, http.StatusForbidden, site.Explore(view))
return
}
if err != nil {
view.ErrorMessage = "The query form was not accepted."
s.renderHTML(w, r, http.StatusBadRequest, site.Explore(view))
return
}
ast, err := query.Parse(queryText, s.options.MaxQueryRows)
if err != nil {
view.ErrorMessage = "The query could not be parsed. Check its stages, values, window, and limit."
s.renderHTML(w, r, http.StatusUnprocessableEntity, site.Explore(view))
return
}
principal, _ := auth.PrincipalFromContext(r.Context())
scope := access.Scope{OrganizationID: view.Organization.ID}
sensitive, err := s.identity.Access.Authorize(r.Context(), principal.User.ID, scope, identity.PermissionTelemetryReadSensitive)
if err != nil {
view.ErrorMessage = "Authorization is temporarily unavailable."
s.renderHTML(w, r, http.StatusServiceUnavailable, site.Explore(view))
return
}
result, err := s.store.Query(r.Context(), ast, query.Scope{OrganizationID: view.Organization.ID, Sensitive: sensitive.Allowed}, s.options.QueryBudget, s.now())
switch {
case errors.Is(err, query.ErrSensitivePermissionRequired):
view.ErrorMessage = "This query requires permission to read sensitive fields."
s.renderHTML(w, r, http.StatusForbidden, site.Explore(view))
return
case errors.Is(err, query.ErrBudgetExceeded):
view.ErrorMessage = "This query exceeded its execution budget. Narrow the time window, fields, or result limit."
s.renderHTML(w, r, http.StatusUnprocessableEntity, site.Explore(view))
return
case errors.Is(err, query.ErrTypeMismatch):
view.ErrorMessage = "A query value did not match the selected field type."
s.renderHTML(w, r, http.StatusUnprocessableEntity, site.Explore(view))
return
case err != nil:
view.ErrorMessage = "The bounded query is temporarily unavailable. Your query text remains here to retry."
s.renderHTML(w, r, http.StatusServiceUnavailable, site.Explore(view))
return
}
view.Executed = true
view.Table = resultTable("Authorized query results", result)
view.Table.Empty = "No observations matched this query."
view.Stats = site.QueryStatsView{
ScannedRows: result.Stats.ScannedRows, MatchedRows: result.Stats.MatchedRows,
ScannedBytes: formatQueryBytes(result.Stats.ScannedBytes),
Duration: formatQueryDuration(result.Stats.DurationNS),
Truncated: result.Stats.Truncated, Approximate: result.Stats.Approximate,
}
s.renderHTML(w, r, http.StatusOK, site.Explore(view))
}
func (s *Server) exploreView(w http.ResponseWriter, r *http.Request, queryText string) (site.ExploreView, string, bool) {
principal, ok := auth.PrincipalFromContext(r.Context())
if !ok {
http.Redirect(w, r, "/login/", http.StatusSeeOther)
return site.ExploreView{}, "", false
}
values := r.URL.Query()
organizationID := values.Get("organization")
if len(values) != 1 || len(values["organization"]) != 1 || organizationID == "" {
writeProblem(w, http.StatusBadRequest, "organization is required")
return site.ExploreView{}, "", false
}
organizations, err := s.identity.OrganizationsForUser(r.Context(), principal.User.ID)
if err != nil {
writeProblem(w, http.StatusServiceUnavailable, "organization list unavailable")
return site.ExploreView{}, "", false
}
organizationName := ""
for _, organization := range organizations {
if organization.ID == organizationID {
organizationName = organization.Name
break
}
}
if organizationName == "" {
writeProblem(w, http.StatusForbidden, "organization access denied")
return site.ExploreView{}, "", false
}
scope := access.Scope{OrganizationID: organizationID}
decision, err := s.identity.Access.Authorize(r.Context(), principal.User.ID, scope, identity.PermissionTelemetryQuery)
if err != nil {
writeProblem(w, http.StatusServiceUnavailable, "authorization unavailable")
return site.ExploreView{}, "", false
}
if !decision.Allowed {
writeProblem(w, http.StatusForbidden, "telemetry query access denied")
return site.ExploreView{}, "", false
}
if err = s.identity.ValidateResourceScope(r.Context(), scope); err != nil {
writeProblem(w, http.StatusBadRequest, "invalid organization")
return site.ExploreView{}, "", false
}
token, ok := authhttp.SessionToken(r, s.cookie)
if !ok {
writeProblem(w, http.StatusUnauthorized, "authentication required")
return site.ExploreView{}, "", false
}
csrf, err := authhttp.CSRFToken(token, "query:execute")
if err != nil {
writeProblem(w, http.StatusServiceUnavailable, "session unavailable")
return site.ExploreView{}, "", false
}
view := site.ExploreView{
Head: s.head("Explore — Gamertan Observatory", "Run an authorized, bounded query against organization evidence.", "/app/explore/"),
DisplayName: principal.User.DisplayName,
Organization: site.OrganizationOption{ID: organizationID, Name: organizationName, Selected: true},
Query: queryText, CSRFToken: csrf,
EventsURL: "/app/events?organization=" + url.QueryEscape(organizationID),
}
return view, token, true
}
func formatQueryBytes(value int64) string {
if value < 1024 {
return fmt.Sprintf("%d B", value)
}
units := []string{"KiB", "MiB", "GiB", "TiB"}
amount := float64(value)
for _, unit := range units {
amount /= 1024
if amount < 1024 || unit == units[len(units)-1] {
return fmt.Sprintf("%.1f %s", amount, unit)
}
}
return fmt.Sprintf("%d B", value)
}
func formatQueryDuration(nanoseconds int64) string {
duration := time.Duration(nanoseconds)
if duration < time.Microsecond {
return duration.String()
}
if duration < time.Millisecond {
return duration.Round(time.Microsecond).String()
}
return duration.Round(time.Millisecond).String()
}
+281
View File
@@ -0,0 +1,281 @@
// SPDX-License-Identifier: AGPL-3.0-only
package httpserver
import (
"context"
"net/http"
"net/url"
"strconv"
"time"
"gamertan.com/observatory/internal/identity"
"gamertan.com/observatory/internal/site"
"gamertan.com/observatory/internal/storage"
"gamertan.com/web/access"
"gamertan.com/web/auth"
"gamertan.com/web/authhttp"
)
// EvaluateAlerts performs one bounded due-rule pass. It publishes only a
// generic organization invalidation signal when an incident changed;
// telemetry and incident details never enter the SSE stream.
func (s *Server) EvaluateAlerts(ctx context.Context) (int, error) {
evaluations, err := s.store.EvaluateDueAlertRules(ctx, s.options.QueryBudget, s.now())
if err != nil {
return 0, err
}
organizations := map[string]struct{}{}
for _, evaluation := range evaluations {
if evaluation.IncidentChanged {
organizations[evaluation.OrganizationID] = struct{}{}
}
if evaluation.IncidentChanged && evaluation.IncidentState == "firing" && s.options.PushDispatcher != nil {
s.options.PushDispatcher.Enqueue(evaluation.OrganizationID)
}
}
for organizationID := range organizations {
s.refresh.publish(organizationID)
}
return len(evaluations), nil
}
func (s *Server) incidentInbox(w http.ResponseWriter, r *http.Request) {
principal, organizationID, organizationName, ok := s.authorizeIncidentRead(w, r)
if !ok {
return
}
scope := access.Scope{OrganizationID: organizationID}
incidents, err := s.store.Incidents(r.Context(), organizationID, true, 100)
if err != nil {
writeProblem(w, http.StatusServiceUnavailable, "incidents unavailable")
return
}
rules, err := s.store.AlertRules(r.Context(), organizationID)
if err != nil {
writeProblem(w, http.StatusServiceUnavailable, "alert rules unavailable")
return
}
saved, err := s.store.SavedQueries(r.Context(), organizationID)
if err != nil {
writeProblem(w, http.StatusServiceUnavailable, "saved queries unavailable")
return
}
view := site.IncidentInboxView{
Head: s.head("Incident inbox — Gamertan Observatory", "Authorized incident response and bounded alert rules.", "/app/incidents/"),
DisplayName: principal.User.DisplayName,
Organization: site.OrganizationOption{ID: organizationID, Name: organizationName, Selected: true},
EventsURL: "/app/events?organization=" + url.QueryEscape(organizationID),
OfflineURL: "/app/incidents/offline/?organization=" + url.QueryEscape(organizationID),
CacheKey: "/app/incidents/?organization=" + url.QueryEscape(organizationID),
}
if s.options.PushDispatcher != nil {
token, sessionOK := authhttp.SessionToken(r, s.cookie)
if !sessionOK {
writeProblem(w, http.StatusUnauthorized, "authentication required")
return
}
view.PushPublicKey = s.options.PushPublicKey
view.PushCSRF, err = authhttp.CSRFToken(token, "push:manage")
if err != nil {
writeProblem(w, http.StatusServiceUnavailable, "session unavailable")
return
}
}
for _, incident := range incidents {
item := site.IncidentSummary{ID: incident.ID, Title: incident.Title, State: incident.State, Severity: incident.Severity, StartedAt: incident.StartedAt.Format("2006-01-02 15:04:05 UTC"), UpdatedAt: incident.UpdatedAt.Format("2006-01-02 15:04:05 UTC")}
if incident.SilencedUntil != nil {
item.SilencedUntil = incident.SilencedUntil.Format("2006-01-02 15:04:05 UTC")
}
view.Incidents = append(view.Incidents, item)
if incident.State != "resolved" {
view.OpenCount++
}
}
for _, rule := range rules {
item := site.AlertRuleSummary{Name: rule.Name, Description: rule.Description, Severity: rule.Severity, Enabled: rule.Enabled, Interval: rule.EvaluationInterval.String(), LastError: rule.LastError}
if rule.LastEvaluatedAt != nil {
item.LastEvaluatedAt = rule.LastEvaluatedAt.Format("2006-01-02 15:04:05 UTC")
}
view.Rules = append(view.Rules, item)
}
for _, savedQuery := range saved {
view.SavedQueries = append(view.SavedQueries, site.SavedQuerySummary{ID: savedQuery.ID, Name: savedQuery.Name, Description: savedQuery.Description, Query: savedQuery.Query})
}
manage, err := s.identity.Access.Authorize(r.Context(), principal.User.ID, scope, identity.PermissionIncidentsManage)
if err != nil {
writeProblem(w, http.StatusServiceUnavailable, "authorization unavailable")
return
}
view.CanManage = manage.Allowed
if view.CanManage {
token, sessionOK := authhttp.SessionToken(r, s.cookie)
if !sessionOK {
writeProblem(w, http.StatusUnauthorized, "authentication required")
return
}
view.ManageCSRF, err = authhttp.CSRFToken(token, "incidents:manage")
if err != nil {
writeProblem(w, http.StatusServiceUnavailable, "session unavailable")
return
}
}
s.renderHTML(w, r, http.StatusOK, site.IncidentInbox(view))
}
func (s *Server) offlineIncidentInbox(w http.ResponseWriter, r *http.Request) {
_, organizationID, organizationName, ok := s.authorizeIncidentRead(w, r)
if !ok {
return
}
incidents, err := s.store.Incidents(r.Context(), organizationID, false, 100)
if err != nil {
writeProblem(w, http.StatusServiceUnavailable, "incidents unavailable")
return
}
view := site.OfflineIncidentView{
Head: s.head("Saved incident inbox — Gamertan Observatory", "A deliberately saved read-only incident snapshot.", "/app/incidents/"),
Organization: site.OrganizationOption{ID: organizationID, Name: organizationName, Selected: true},
CapturedAt: s.now().Format("2006-01-02 15:04:05 UTC"),
}
for _, incident := range incidents {
item := site.IncidentSummary{Title: incident.Title, State: incident.State, Severity: incident.Severity, StartedAt: incident.StartedAt.Format("2006-01-02 15:04:05 UTC"), UpdatedAt: incident.UpdatedAt.Format("2006-01-02 15:04:05 UTC")}
if incident.SilencedUntil != nil {
item.SilencedUntil = incident.SilencedUntil.Format("2006-01-02 15:04:05 UTC")
}
view.Incidents = append(view.Incidents, item)
}
s.renderHTML(w, r, http.StatusOK, site.OfflineIncidentInbox(view))
}
func (s *Server) authorizeIncidentRead(w http.ResponseWriter, r *http.Request) (auth.Principal, string, string, bool) {
principal, ok := auth.PrincipalFromContext(r.Context())
if !ok {
http.Redirect(w, r, "/login/", http.StatusSeeOther)
return auth.Principal{}, "", "", false
}
values := r.URL.Query()
organizationValues, exists := values["organization"]
if !exists || len(values) != 1 || len(organizationValues) != 1 || organizationValues[0] == "" {
writeProblem(w, http.StatusBadRequest, "incident organization is required")
return auth.Principal{}, "", "", false
}
organizationID := organizationValues[0]
scope := access.Scope{OrganizationID: organizationID}
decision, err := s.identity.Access.Authorize(r.Context(), principal.User.ID, scope, identity.PermissionIncidentsRead)
if err != nil || !decision.Allowed {
writeProblem(w, http.StatusForbidden, "incident access denied")
return auth.Principal{}, "", "", false
}
if err = s.identity.ValidateResourceScope(r.Context(), scope); err != nil {
writeProblem(w, http.StatusBadRequest, "invalid organization")
return auth.Principal{}, "", "", false
}
organizations, err := s.identity.OrganizationsForUser(r.Context(), principal.User.ID)
if err != nil {
writeProblem(w, http.StatusServiceUnavailable, "organization unavailable")
return auth.Principal{}, "", "", false
}
for _, organization := range organizations {
if organization.ID == organizationID {
return principal, organizationID, organization.Name, true
}
}
writeProblem(w, http.StatusForbidden, "incident access denied")
return auth.Principal{}, "", "", false
}
func (s *Server) createAlertRule(w http.ResponseWriter, r *http.Request) {
values, principal, ok := s.authorizeIncidentForm(w, r, []string{"organization_id", "csrf_token", "name", "description", "saved_query_id", "severity", "minimum_matches", "required_consecutive", "evaluation_interval"}, nil)
if !ok {
return
}
minimumMatches, err := strconv.Atoi(values.Get("minimum_matches"))
if err != nil {
writeProblem(w, http.StatusUnprocessableEntity, "alert rule rejected")
return
}
requiredConsecutive, err := strconv.Atoi(values.Get("required_consecutive"))
if err != nil {
writeProblem(w, http.StatusUnprocessableEntity, "alert rule rejected")
return
}
intervals := map[string]time.Duration{"15s": 15 * time.Second, "30s": 30 * time.Second, "1m": time.Minute, "5m": 5 * time.Minute, "15m": 15 * time.Minute}
interval, exists := intervals[values.Get("evaluation_interval")]
if !exists {
writeProblem(w, http.StatusUnprocessableEntity, "alert rule rejected")
return
}
_, err = s.store.SaveAlertRule(r.Context(), storage.AlertRuleInput{
OrganizationID: values.Get("organization_id"), Name: values.Get("name"), Description: values.Get("description"),
SavedQueryID: values.Get("saved_query_id"), Severity: values.Get("severity"), MinimumMatches: minimumMatches,
RequiredConsecutive: requiredConsecutive, EvaluationInterval: interval, Enabled: true, ActorUserID: principal.User.ID,
}, s.now())
if err != nil {
writeProblem(w, http.StatusUnprocessableEntity, "alert rule rejected")
return
}
http.Redirect(w, r, "/app/incidents/?organization="+url.QueryEscape(values.Get("organization_id")), http.StatusSeeOther)
}
func (s *Server) transitionIncident(w http.ResponseWriter, r *http.Request) {
values, principal, ok := s.authorizeIncidentForm(w, r, []string{"organization_id", "csrf_token", "action"}, []string{"silence_duration"})
if !ok {
return
}
var silenceUntil *time.Time
if values.Get("action") == "silence" {
durations := map[string]time.Duration{"15m": 15 * time.Minute, "1h": time.Hour, "6h": 6 * time.Hour, "24h": 24 * time.Hour, "168h": 7 * 24 * time.Hour}
duration, exists := durations[values.Get("silence_duration")]
if !exists {
writeProblem(w, http.StatusUnprocessableEntity, "incident transition rejected")
return
}
until := s.now().Add(duration)
silenceUntil = &until
} else if values.Get("silence_duration") != "" {
writeProblem(w, http.StatusUnprocessableEntity, "incident transition rejected")
return
}
_, err := s.store.TransitionIncident(r.Context(), values.Get("organization_id"), r.PathValue("id"), values.Get("action"), principal.User.ID, silenceUntil, s.now())
if err != nil {
writeProblem(w, http.StatusUnprocessableEntity, "incident transition rejected")
return
}
s.refresh.publish(values.Get("organization_id"))
http.Redirect(w, r, "/app/incidents/?organization="+url.QueryEscape(values.Get("organization_id")), http.StatusSeeOther)
}
func (s *Server) authorizeIncidentForm(w http.ResponseWriter, r *http.Request, required, optional []string) (url.Values, auth.Principal, bool) {
principal, ok := auth.PrincipalFromContext(r.Context())
values, err := readFormFields(w, r, 24<<10, required, optional)
token, sessionOK := authhttp.SessionToken(r, s.cookie)
csrfOK := err == nil && sessionOK && authhttp.VerifyCSRF(token, "incidents:manage", values.Get("csrf_token"))
if !tokenBoundFormRequest(r, s.options.PublicOrigin, csrfOK) {
writeProblem(w, http.StatusForbidden, "valid incident form required")
return nil, auth.Principal{}, false
}
if !ok {
writeProblem(w, http.StatusUnauthorized, "authentication required")
return nil, auth.Principal{}, false
}
if err != nil {
writeProblem(w, http.StatusBadRequest, "invalid incident management request")
return nil, auth.Principal{}, false
}
scope := access.Scope{OrganizationID: values.Get("organization_id")}
decision, err := s.identity.Access.Authorize(r.Context(), principal.User.ID, scope, identity.PermissionIncidentsManage)
if err != nil || !decision.Allowed {
writeProblem(w, http.StatusForbidden, "incident management denied")
return nil, auth.Principal{}, false
}
if err = s.identity.ValidateResourceScope(r.Context(), scope); err != nil {
writeProblem(w, http.StatusBadRequest, "invalid organization")
return nil, auth.Principal{}, false
}
if !csrfOK {
writeProblem(w, http.StatusForbidden, "valid incident CSRF token required")
return nil, auth.Principal{}, false
}
return values, principal, true
}
+591
View File
@@ -0,0 +1,591 @@
// SPDX-License-Identifier: AGPL-3.0-only
package httpserver
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"math"
"net/http"
"net/url"
"strconv"
"strings"
"unicode/utf8"
"gamertan.com/observatory/internal/identity"
"gamertan.com/observatory/internal/query"
"gamertan.com/observatory/internal/site"
"gamertan.com/observatory/internal/storage"
"gamertan.com/web/access"
"gamertan.com/web/auth"
"gamertan.com/web/authhttp"
)
func (s *Server) createSavedQuery(w http.ResponseWriter, r *http.Request) {
values, principal, ok := s.authorizeManagementForm(w, r, "organization_id", "csrf_token", "name", "description", "query")
if !ok {
return
}
s.saveQuery(w, r, values, principal, values.Get("query"))
}
func (s *Server) createBuiltQuery(w http.ResponseWriter, r *http.Request) {
required := []string{"organization_id", "csrf_token", "name", "description", "signal", "filter_operator", "window", "aggregate", "limit"}
optional := []string{"filter_field", "filter_value", "aggregate_field", "group_by", "bucket"}
values, principal, ok := s.authorizeManagementFormFields(w, r, required, optional)
if !ok {
return
}
text, err := buildAssistedQuery(values, s.options.MaxQueryRows)
if err != nil {
writeProblem(w, http.StatusUnprocessableEntity, "assisted query rejected")
return
}
s.saveQuery(w, r, values, principal, text)
}
func (s *Server) saveQuery(w http.ResponseWriter, r *http.Request, values url.Values, principal auth.Principal, text string) {
_, err := s.store.SaveQuery(r.Context(), storage.SavedQueryInput{
OrganizationID: values.Get("organization_id"), Name: values.Get("name"),
Description: values.Get("description"), Query: text,
ActorUserID: principal.User.ID, MaxRows: s.options.MaxQueryRows,
}, s.now())
if err != nil {
writeProblem(w, http.StatusUnprocessableEntity, "saved query rejected")
return
}
http.Redirect(w, r, "/app/?organization="+url.QueryEscape(values.Get("organization_id"))+"#saved-work", http.StatusSeeOther)
}
func buildAssistedQuery(values url.Values, maxRows int) (string, error) {
allowed := func(value string, candidates ...string) bool {
for _, candidate := range candidates {
if value == candidate {
return true
}
}
return false
}
signal := values.Get("signal")
if !allowed(signal, "logs", "metrics", "traces", "deployments") {
return "", fmt.Errorf("unsupported signal")
}
window := values.Get("window")
if !allowed(window, "15m", "1h", "6h", "24h", "168h") {
return "", fmt.Errorf("unsupported window")
}
limit, err := strconv.Atoi(values.Get("limit"))
if err != nil || limit < 1 || limit > maxRows || !allowed(values.Get("limit"), "10", "20", "50", "100", "250") {
return "", fmt.Errorf("unsupported limit")
}
stages := []string{signal}
filterField := values.Get("filter_field")
filterValue := values.Get("filter_value")
filterOperator := values.Get("filter_operator")
if !allowed(filterOperator, "==", "!=", ">=", "<=", ">", "<") {
return "", fmt.Errorf("invalid filter comparison")
}
if filterField == "" {
if filterValue != "" {
return "", fmt.Errorf("filter value requires a field")
}
} else {
if !allowed(filterField, "service", "project", "environment", "route", "status", "duration", "name", "severity", "value", "trace_id", "correlation_id") ||
filterValue == "" || len(filterValue) > 256 || !utf8.ValidString(filterValue) || strings.IndexByte(filterValue, 0) >= 0 {
return "", fmt.Errorf("invalid filter")
}
quoted := strconv.Quote(filterValue)
quoted = strings.ReplaceAll(quoted, "|", `\u007c`)
stages = append(stages, "where "+filterField+" "+filterOperator+" "+quoted)
}
stages = append(stages, "window "+window)
aggregate := values.Get("aggregate")
aggregateField := values.Get("aggregate_field")
groupBy := values.Get("group_by")
bucket := values.Get("bucket")
if aggregate == "none" {
if aggregateField != "" || groupBy != "" || bucket != "" {
return "", fmt.Errorf("summary options require an aggregate")
}
} else {
if !allowed(aggregate, "count", "min", "max", "sum", "avg", "p50", "p95", "p99") ||
!allowed(groupBy, "", "service", "project", "environment", "route", "status", "name", "severity") ||
!allowed(bucket, "", "1m", "5m", "15m", "1h") {
return "", fmt.Errorf("invalid summary")
}
expression := "count()"
if aggregate == "count" {
if aggregateField != "" {
return "", fmt.Errorf("count accepts no field")
}
} else {
if !allowed(aggregateField, "value", "duration", "status") {
return "", fmt.Errorf("numeric aggregate field required")
}
expression = aggregate + "(" + aggregateField + ")"
}
groups := make([]string, 0, 2)
if groupBy != "" {
groups = append(groups, groupBy)
}
if bucket != "" {
groups = append(groups, "window("+bucket+")")
}
stage := "summarize " + expression
if len(groups) > 0 {
stage += " by " + strings.Join(groups, ", ")
}
stages = append(stages, stage)
}
stages = append(stages, "limit "+strconv.Itoa(limit))
text := strings.Join(stages, " | ")
if _, err = query.Parse(text, maxRows); err != nil {
return "", err
}
return text, nil
}
func (s *Server) createDashboard(w http.ResponseWriter, r *http.Request) {
values, principal, ok := s.authorizeManagementForm(w, r, "organization_id", "csrf_token", "slug", "name", "description", "panel_title", "saved_query_id", "visualization")
if !ok {
return
}
organizationID := values.Get("organization_id")
queryValue, err := s.store.SavedQuery(r.Context(), organizationID, values.Get("saved_query_id"))
if err != nil {
writeProblem(w, http.StatusUnprocessableEntity, "dashboard query rejected")
return
}
visualization := values.Get("visualization")
if !validDashboardPresentation(queryValue, visualization) {
writeProblem(w, http.StatusUnprocessableEntity, "dashboard presentation does not match query")
return
}
_, err = s.store.SaveDashboard(r.Context(), storage.DashboardInput{
OrganizationID: organizationID, Slug: values.Get("slug"), Name: values.Get("name"),
Description: values.Get("description"), ActorUserID: principal.User.ID,
Panels: []storage.DashboardPanel{{Position: 0, Title: values.Get("panel_title"), Visualization: visualization, SavedQueryID: queryValue.ID}},
}, s.now())
if err != nil {
writeProblem(w, http.StatusUnprocessableEntity, "dashboard rejected")
return
}
http.Redirect(w, r, "/app/dashboards/"+url.PathEscape(values.Get("slug"))+"/?organization="+url.QueryEscape(organizationID), http.StatusSeeOther)
}
func (s *Server) updateDashboard(w http.ResponseWriter, r *http.Request) {
values, principal, ok := s.authorizeManagementForm(w, r, "organization_id", "csrf_token", "dashboard_id", "expected_revision", "slug", "name", "description")
if !ok {
return
}
current, ok := s.dashboardForRevision(w, r, values)
if !ok {
return
}
updated, err := s.store.SaveDashboard(r.Context(), storage.DashboardInput{
ID: current.ID, ExpectedRevision: current.Revision, OrganizationID: current.OrganizationID,
Slug: values.Get("slug"), Name: values.Get("name"), Description: values.Get("description"),
Panels: current.Panels, ActorUserID: principal.User.ID,
}, s.now())
if !writeDashboardRevisionResult(w, err) {
return
}
redirectDashboard(w, r, updated.Slug, current.OrganizationID)
}
func (s *Server) addDashboardPanel(w http.ResponseWriter, r *http.Request) {
values, principal, ok := s.authorizeManagementForm(w, r, "organization_id", "csrf_token", "dashboard_id", "expected_revision", "panel_title", "saved_query_id", "visualization")
if !ok {
return
}
current, ok := s.dashboardForRevision(w, r, values)
if !ok {
return
}
queryValue, err := s.store.SavedQuery(r.Context(), current.OrganizationID, values.Get("saved_query_id"))
if err != nil || !validDashboardPresentation(queryValue, values.Get("visualization")) {
writeProblem(w, http.StatusUnprocessableEntity, "dashboard panel rejected")
return
}
panels := append([]storage.DashboardPanel(nil), current.Panels...)
panels = append(panels, storage.DashboardPanel{Position: len(panels), Title: values.Get("panel_title"), Visualization: values.Get("visualization"), SavedQueryID: queryValue.ID})
updated, err := s.store.SaveDashboard(r.Context(), storage.DashboardInput{
ID: current.ID, ExpectedRevision: current.Revision, OrganizationID: current.OrganizationID,
Slug: current.Slug, Name: current.Name, Description: current.Description,
Panels: panels, ActorUserID: principal.User.ID,
}, s.now())
if !writeDashboardRevisionResult(w, err) {
return
}
redirectDashboard(w, r, updated.Slug, current.OrganizationID)
}
func (s *Server) updateDashboardPanel(w http.ResponseWriter, r *http.Request) {
values, principal, ok := s.authorizeManagementForm(w, r, "organization_id", "csrf_token", "dashboard_id", "expected_revision", "panel_title", "saved_query_id", "visualization")
if !ok {
return
}
current, ok := s.dashboardForRevision(w, r, values)
if !ok {
return
}
queryValue, err := s.store.SavedQuery(r.Context(), current.OrganizationID, values.Get("saved_query_id"))
if err != nil || !validDashboardPresentation(queryValue, values.Get("visualization")) {
writeProblem(w, http.StatusUnprocessableEntity, "dashboard panel rejected")
return
}
panelID := r.PathValue("panel")
panels := append([]storage.DashboardPanel(nil), current.Panels...)
found := false
for index := range panels {
if panels[index].ID != panelID {
continue
}
panels[index].Title = values.Get("panel_title")
panels[index].Visualization = values.Get("visualization")
panels[index].SavedQueryID = queryValue.ID
found = true
break
}
if !found {
writeProblem(w, http.StatusNotFound, "dashboard panel not found")
return
}
updated, err := s.store.SaveDashboard(r.Context(), storage.DashboardInput{
ID: current.ID, ExpectedRevision: current.Revision, OrganizationID: current.OrganizationID,
Slug: current.Slug, Name: current.Name, Description: current.Description,
Panels: panels, ActorUserID: principal.User.ID,
}, s.now())
if !writeDashboardRevisionResult(w, err) {
return
}
redirectDashboard(w, r, updated.Slug, current.OrganizationID)
}
func (s *Server) removeDashboardPanel(w http.ResponseWriter, r *http.Request) {
values, principal, ok := s.authorizeManagementForm(w, r, "organization_id", "csrf_token", "dashboard_id", "expected_revision")
if !ok {
return
}
current, ok := s.dashboardForRevision(w, r, values)
if !ok {
return
}
panelID := r.PathValue("panel")
panels := make([]storage.DashboardPanel, 0, len(current.Panels))
for _, panel := range current.Panels {
if panel.ID != panelID {
panel.Position = len(panels)
panels = append(panels, panel)
}
}
if len(panels) == len(current.Panels) {
writeProblem(w, http.StatusNotFound, "dashboard panel not found")
return
}
updated, err := s.store.SaveDashboard(r.Context(), storage.DashboardInput{
ID: current.ID, ExpectedRevision: current.Revision, OrganizationID: current.OrganizationID,
Slug: current.Slug, Name: current.Name, Description: current.Description,
Panels: panels, ActorUserID: principal.User.ID,
}, s.now())
if !writeDashboardRevisionResult(w, err) {
return
}
redirectDashboard(w, r, updated.Slug, current.OrganizationID)
}
func (s *Server) dashboardForRevision(w http.ResponseWriter, r *http.Request, values url.Values) (storage.Dashboard, bool) {
current, err := s.store.Dashboard(r.Context(), values.Get("organization_id"), r.PathValue("slug"))
if err != nil {
writeProblem(w, http.StatusNotFound, "dashboard not found")
return storage.Dashboard{}, false
}
revision, err := strconv.Atoi(values.Get("expected_revision"))
if err != nil || revision < 1 || values.Get("dashboard_id") != current.ID {
writeProblem(w, http.StatusBadRequest, "dashboard revision is invalid")
return storage.Dashboard{}, false
}
if revision != current.Revision {
writeProblem(w, http.StatusConflict, "dashboard changed; reload before editing")
return storage.Dashboard{}, false
}
return current, true
}
func validDashboardPresentation(saved storage.SavedQuery, visualization string) bool {
switch visualization {
case "table":
return true
case "stat":
return saved.AST.Summary != nil
case "timeseries":
return saved.AST.Summary != nil && saved.AST.Bucket > 0
default:
return false
}
}
func writeDashboardRevisionResult(w http.ResponseWriter, err error) bool {
if err == nil {
return true
}
if errors.Is(err, storage.ErrDashboardRevisionConflict) {
writeProblem(w, http.StatusConflict, "dashboard changed; reload before editing")
} else {
writeProblem(w, http.StatusUnprocessableEntity, "dashboard revision rejected")
}
return false
}
func redirectDashboard(w http.ResponseWriter, r *http.Request, slug, organizationID string) {
http.Redirect(w, r, "/app/dashboards/"+url.PathEscape(slug)+"/?organization="+url.QueryEscape(organizationID), http.StatusSeeOther)
}
func (s *Server) authorizeManagementForm(w http.ResponseWriter, r *http.Request, fields ...string) (url.Values, auth.Principal, bool) {
return s.authorizeManagementFormFields(w, r, fields, nil)
}
func (s *Server) authorizeManagementFormFields(w http.ResponseWriter, r *http.Request, required, optional []string) (url.Values, auth.Principal, bool) {
principal, ok := auth.PrincipalFromContext(r.Context())
values, err := readFormFields(w, r, 24<<10, required, optional)
token, sessionOK := authhttp.SessionToken(r, s.cookie)
csrfOK := err == nil && sessionOK && authhttp.VerifyCSRF(token, "dashboards:manage", values.Get("csrf_token"))
if !tokenBoundFormRequest(r, s.options.PublicOrigin, csrfOK) {
writeProblem(w, http.StatusForbidden, "valid dashboard form required")
return nil, auth.Principal{}, false
}
if !ok {
writeProblem(w, http.StatusUnauthorized, "authentication required")
return nil, auth.Principal{}, false
}
if err != nil {
writeProblem(w, http.StatusBadRequest, "invalid dashboard management request")
return nil, auth.Principal{}, false
}
organizationID := values.Get("organization_id")
scope := access.Scope{OrganizationID: organizationID}
if err = s.identity.ValidateResourceScope(r.Context(), scope); err != nil {
writeProblem(w, http.StatusBadRequest, "invalid organization")
return nil, auth.Principal{}, false
}
decision, err := s.identity.Access.Authorize(r.Context(), principal.User.ID, scope, identity.PermissionDashboardsManage)
if err != nil || !decision.Allowed {
writeProblem(w, http.StatusForbidden, "dashboard management denied")
return nil, auth.Principal{}, false
}
if !csrfOK {
writeProblem(w, http.StatusForbidden, "valid dashboard CSRF token required")
return nil, auth.Principal{}, false
}
return values, principal, true
}
func (s *Server) dashboard(w http.ResponseWriter, r *http.Request) {
organizationID, principal, ok := s.authorizeDashboardRead(w, r)
if !ok {
return
}
dashboard, err := s.store.Dashboard(r.Context(), organizationID, r.PathValue("slug"))
if err != nil {
writeProblem(w, http.StatusNotFound, "dashboard not found")
return
}
organizations, err := s.identity.OrganizationsForUser(r.Context(), principal.User.ID)
if err != nil {
writeProblem(w, http.StatusServiceUnavailable, "organization unavailable")
return
}
organizationName := "Organization"
for _, organization := range organizations {
if organization.ID == organizationID {
organizationName = organization.Name
break
}
}
view := site.DashboardView{
Head: s.head(dashboard.Name+" — Gamertan Observatory", dashboard.Description, "/app/dashboards/"+url.PathEscape(dashboard.Slug)+"/"),
DisplayName: principal.User.DisplayName, Organization: site.OrganizationOption{ID: organizationID, Name: organizationName, Selected: true},
ID: dashboard.ID, Slug: dashboard.Slug, Revision: dashboard.Revision,
Name: dashboard.Name, Description: dashboard.Description,
ExportURL: "/app/dashboards/" + url.PathEscape(dashboard.Slug) + "/export.json?organization=" + url.QueryEscape(organizationID),
}
manage, err := s.identity.Access.Authorize(r.Context(), principal.User.ID, access.Scope{OrganizationID: organizationID}, identity.PermissionDashboardsManage)
if err != nil {
writeProblem(w, http.StatusServiceUnavailable, "authorization unavailable")
return
}
view.CanManage = manage.Allowed
if view.CanManage {
token, sessionOK := authhttp.SessionToken(r, s.cookie)
if !sessionOK {
writeProblem(w, http.StatusUnauthorized, "authentication required")
return
}
view.ManageCSRF, err = authhttp.CSRFToken(token, "dashboards:manage")
if err != nil {
writeProblem(w, http.StatusServiceUnavailable, "session unavailable")
return
}
savedQueries, loadErr := s.store.SavedQueries(r.Context(), organizationID)
if loadErr != nil {
writeProblem(w, http.StatusServiceUnavailable, "saved queries unavailable")
return
}
for _, saved := range savedQueries {
view.SavedQueries = append(view.SavedQueries, site.SavedQuerySummary{ID: saved.ID, Name: saved.Name, Description: saved.Description, Query: saved.Query})
}
}
for _, panel := range dashboard.Panels {
view.Panels = append(view.Panels, s.dashboardPanel(r, principal.User.ID, organizationID, panel))
}
s.renderHTML(w, r, http.StatusOK, site.Dashboard(view))
}
func (s *Server) dashboardPanel(r *http.Request, userID, organizationID string, panel storage.DashboardPanel) site.PanelView {
view := site.PanelView{ID: panel.ID, SavedQueryID: panel.SavedQueryID, Title: panel.Title, Visualization: panel.Visualization, Table: site.TableView{Caption: panel.Title, Columns: []site.TableColumn{{Label: "Status"}}, Empty: "Panel data is unavailable."}}
saved, err := s.store.SavedQuery(r.Context(), organizationID, panel.SavedQueryID)
if err != nil {
return view
}
view.Query = saved.Query
scope := access.Scope{OrganizationID: organizationID, ProjectID: saved.Scope.ProjectID, EnvironmentID: saved.Scope.EnvironmentID, ServiceID: saved.Scope.ServiceID}
decision, err := s.identity.Access.Authorize(r.Context(), userID, scope, identity.PermissionTelemetryQuery)
if err != nil || !decision.Allowed || s.identity.ValidateResourceScope(r.Context(), scope) != nil {
return view
}
sensitive, err := s.identity.Access.Authorize(r.Context(), userID, scope, identity.PermissionTelemetryReadSensitive)
if err != nil {
return view
}
result, err := s.store.Query(r.Context(), saved.AST, query.Scope{OrganizationID: organizationID, ProjectID: saved.Scope.ProjectID, EnvironmentID: saved.Scope.EnvironmentID, ServiceID: saved.Scope.ServiceID, Sensitive: sensitive.Allowed}, s.options.QueryBudget, s.now())
if err != nil {
return view
}
view.Table = resultTable(panel.Title, result)
if panel.Visualization == "timeseries" {
view.Chart = resultChart(panel.Title, result)
}
if panel.Visualization == "stat" {
for _, row := range result.Rows {
for _, value := range row.Values {
if value != nil {
view.Stat = boundedCell(*value)
return view
}
}
}
}
return view
}
func resultChart(title string, result query.Result) site.ChartView {
if len(result.Columns) < 2 || len(result.Rows) == 0 {
return site.ChartView{}
}
valueIndex := len(result.Columns) - 1
valueType := result.Columns[valueIndex].Type
if valueType != "integer" && valueType != "float" && valueType != "duration" {
return site.ChartView{}
}
const maxPoints = 48
points := make([]struct {
label, display string
value float64
}, 0, min(len(result.Rows), maxPoints))
maximum := float64(0)
for index, row := range result.Rows {
if index >= maxPoints || valueIndex >= len(row.Values) || row.Values[valueIndex] == nil {
continue
}
value, err := strconv.ParseFloat(*row.Values[valueIndex], 64)
if err != nil || math.IsNaN(value) || math.IsInf(value, 0) || value < 0 {
return site.ChartView{}
}
labels := make([]string, 0, valueIndex)
for column := 0; column < valueIndex && column < len(row.Values); column++ {
if row.Values[column] != nil {
labels = append(labels, boundedCell(*row.Values[column]))
}
}
label := boundedCell(strings.Join(labels, " · "))
if label == "" {
label = fmt.Sprintf("Point %d", index+1)
}
display := boundedCell(*row.Values[valueIndex])
if unit := result.Columns[valueIndex].Unit; unit != "" {
display += " " + boundedCell(unit)
}
points = append(points, struct {
label, display string
value float64
}{label: label, display: display, value: value})
maximum = math.Max(maximum, value)
}
if len(points) == 0 {
return site.ChartView{}
}
if maximum == 0 {
maximum = 1
}
view := site.ChartView{Label: title + " visual summary"}
for _, point := range points {
view.Points = append(view.Points, site.ChartPoint{
Label: point.label, Value: strconv.FormatFloat(point.value, 'g', -1, 64),
Maximum: strconv.FormatFloat(maximum, 'g', -1, 64), Display: point.display,
})
}
return view
}
func (s *Server) exportDashboard(w http.ResponseWriter, r *http.Request) {
organizationID, _, ok := s.authorizeDashboardRead(w, r)
if !ok {
return
}
exported, err := s.store.ExportDashboard(r.Context(), organizationID, r.PathValue("slug"))
if err != nil {
writeProblem(w, http.StatusNotFound, "dashboard not found")
return
}
var body bytes.Buffer
encoder := json.NewEncoder(&body)
encoder.SetIndent("", " ")
if err = encoder.Encode(exported); err != nil {
writeProblem(w, http.StatusInternalServerError, "dashboard export unavailable")
return
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", "observatory-dashboard-"+r.PathValue("slug")+".json"))
w.Header().Set("Content-Length", fmt.Sprintf("%d", body.Len()))
if r.Method != http.MethodHead {
_, _ = w.Write(body.Bytes())
}
}
func (s *Server) authorizeDashboardRead(w http.ResponseWriter, r *http.Request) (string, auth.Principal, bool) {
principal, ok := auth.PrincipalFromContext(r.Context())
if !ok {
writeProblem(w, http.StatusUnauthorized, "authentication required")
return "", auth.Principal{}, false
}
values := r.URL.Query()
organizationID := values.Get("organization")
if len(values) != 1 || len(values["organization"]) != 1 || organizationID == "" {
writeProblem(w, http.StatusBadRequest, "organization is required")
return "", auth.Principal{}, false
}
scope := access.Scope{OrganizationID: organizationID}
if err := s.identity.ValidateResourceScope(r.Context(), scope); err != nil {
writeProblem(w, http.StatusBadRequest, "invalid organization")
return "", auth.Principal{}, false
}
decision, err := s.identity.Access.Authorize(r.Context(), principal.User.ID, scope, identity.PermissionDashboardsRead)
if err != nil || !decision.Allowed {
writeProblem(w, http.StatusForbidden, "dashboard access denied")
return "", auth.Principal{}, false
}
return organizationID, principal, true
}
@@ -0,0 +1,103 @@
// SPDX-License-Identifier: AGPL-3.0-only
package httpserver
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"
"gamertan.com/observatory/internal/identity"
"gamertan.com/observatory/internal/model"
"gamertan.com/observatory/internal/nativeprotocol"
"gamertan.com/observatory/internal/storage"
)
func BenchmarkNativeExactReplay(b *testing.B) {
root := filepath.Join(b.TempDir(), "data")
if err := os.Mkdir(root, 0o700); err != nil {
b.Fatal(err)
}
store, err := storage.Open(root)
if err != nil {
b.Fatal(err)
}
defer store.Close()
identities, err := identity.Open(root)
if err != nil {
b.Fatal(err)
}
defer identities.Close()
token, err := store.CreateSource(context.Background(), "source", model.Scope{OrganizationID: "org", ProjectID: "project", EnvironmentID: "production", ServiceID: "service"})
if err != nil {
b.Fatal(err)
}
now := time.Date(2026, 8, 18, 20, 0, 0, 0, time.UTC)
server, err := New(store, identities, testOptions())
if err != nil {
b.Fatal(err)
}
server.now = func() time.Time { return now }
handler := server.Handler()
legacy := model.Batch{Version: model.BatchVersion, SourceID: "source", StreamID: "legacy", Sequence: 1, ObservedAt: now, Signal: model.SignalLogs, Records: benchmarkRecords(now)}
legacyBody, _ := json.Marshal(legacy)
framed := legacy
framed.StreamID = "framed"
framedBody, _ := json.Marshal(framed)
framedEnvelope, _ := framed.Envelope(framedBody)
seed := func(path string, body []byte, envelope *model.BatchEnvelope) {
request := httptest.NewRequest(http.MethodPost, path, bytes.NewReader(body))
request.Header.Set("Authorization", "Bearer "+token)
request.Header.Set("Content-Type", "application/json")
if envelope != nil {
nativeprotocol.SetHeaders(request.Header, *envelope)
}
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code != http.StatusAccepted {
b.Fatalf("seed %s: %d %s", path, response.Code, response.Body.String())
}
}
seed("/api/v1/ingest/native", legacyBody, nil)
seed("/api/v2/ingest/native", framedBody, &framedEnvelope)
for _, benchmark := range []struct {
name string
path string
body []byte
envelope *model.BatchEnvelope
}{{"legacy-v1", "/api/v1/ingest/native", legacyBody, nil}, {"framed-v2", "/api/v2/ingest/native", framedBody, &framedEnvelope}} {
b.Run(benchmark.name, func(b *testing.B) {
b.ReportAllocs()
b.SetBytes(int64(len(benchmark.body)))
for range b.N {
request := httptest.NewRequest(http.MethodPost, benchmark.path, bytes.NewReader(benchmark.body))
request.Header.Set("Authorization", "Bearer "+token)
request.Header.Set("Content-Type", "application/json")
if benchmark.envelope != nil {
nativeprotocol.SetHeaders(request.Header, *benchmark.envelope)
}
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code != http.StatusAccepted {
b.Fatalf("status=%d body=%s", response.Code, response.Body.String())
}
}
})
}
}
func benchmarkRecords(now time.Time) []model.Observation {
records := make([]model.Observation, 500)
for index := range records {
records[index] = model.Observation{Timestamp: now, Name: "http.request", Attributes: map[string]string{"route": "/items", "status": "200", "method": "GET"}}
}
return records
}
+132
View File
@@ -0,0 +1,132 @@
// SPDX-License-Identifier: AGPL-3.0-only
package httpserver
import (
"crypto/ecdh"
"encoding/base64"
"net/http"
"gamertan.com/observatory/internal/identity"
"gamertan.com/observatory/internal/storage"
"gamertan.com/observatory/internal/webpush"
"gamertan.com/web/access"
"gamertan.com/web/auth"
"gamertan.com/web/authhttp"
"gamertan.com/web/websec"
)
type pushSubscriptionRequest struct {
OrganizationID string `json:"organization_id"`
Endpoint string `json:"endpoint"`
Keys struct {
P256DH string `json:"p256dh"`
Auth string `json:"auth"`
} `json:"keys"`
}
func (s *Server) savePushSubscription(w http.ResponseWriter, r *http.Request) {
request, principal, ok := s.authorizePushRequest(w, r)
if !ok {
return
}
p256dh, p256Err := base64.RawURLEncoding.DecodeString(request.Keys.P256DH)
authSecret, authErr := base64.RawURLEncoding.DecodeString(request.Keys.Auth)
if p256Err != nil || authErr != nil || len(p256dh) != 65 || len(authSecret) != 16 {
writeProblem(w, http.StatusUnprocessableEntity, "push subscription rejected")
return
}
if _, err := ecdh.P256().NewPublicKey(p256dh); err != nil {
writeProblem(w, http.StatusUnprocessableEntity, "push subscription rejected")
return
}
if _, err := webpush.ValidateEndpoint(request.Endpoint); err != nil {
writeProblem(w, http.StatusUnprocessableEntity, "push subscription rejected")
return
}
subscription, err := s.store.SavePushSubscription(r.Context(), storage.PushSubscriptionInput{OrganizationID: request.OrganizationID, UserID: principal.User.ID, Endpoint: request.Endpoint, P256DH: p256dh, Auth: authSecret}, s.now())
if err != nil {
writeProblem(w, http.StatusUnprocessableEntity, "push subscription rejected")
return
}
writeJSON(w, http.StatusCreated, struct {
ID string `json:"id"`
}{subscription.ID})
}
func (s *Server) deletePushSubscription(w http.ResponseWriter, r *http.Request) {
request, principal, ok := s.authorizePushRequest(w, r)
if !ok {
return
}
if _, err := webpush.ValidateEndpoint(request.Endpoint); err != nil {
writeProblem(w, http.StatusUnprocessableEntity, "push subscription rejected")
return
}
remaining, err := s.store.DeletePushSubscription(r.Context(), request.OrganizationID, principal.User.ID, request.Endpoint)
if err != nil {
writeProblem(w, http.StatusNotFound, "push subscription not found")
return
}
writeJSON(w, http.StatusOK, struct {
Remaining bool `json:"remaining"`
}{remaining})
}
func (s *Server) pushSubscriptionStatus(w http.ResponseWriter, r *http.Request) {
request, principal, ok := s.authorizePushRequest(w, r)
if !ok {
return
}
if _, err := webpush.ValidateEndpoint(request.Endpoint); err != nil {
writeProblem(w, http.StatusUnprocessableEntity, "push subscription rejected")
return
}
subscribed, err := s.store.HasPushSubscription(r.Context(), request.OrganizationID, principal.User.ID, request.Endpoint)
if err != nil {
writeProblem(w, http.StatusServiceUnavailable, "push subscription status unavailable")
return
}
writeJSON(w, http.StatusOK, struct {
Subscribed bool `json:"subscribed"`
}{subscribed})
}
func (s *Server) authorizePushRequest(w http.ResponseWriter, r *http.Request) (pushSubscriptionRequest, auth.Principal, bool) {
if s.options.PushDispatcher == nil {
writeProblem(w, http.StatusNotFound, "Web Push is not configured")
return pushSubscriptionRequest{}, auth.Principal{}, false
}
if !websec.SameOrigin(r, s.options.PublicOrigin) {
writeProblem(w, http.StatusForbidden, "same-origin request required")
return pushSubscriptionRequest{}, auth.Principal{}, false
}
principal, ok := auth.PrincipalFromContext(r.Context())
if !ok {
writeProblem(w, http.StatusUnauthorized, "authentication required")
return pushSubscriptionRequest{}, auth.Principal{}, false
}
body := http.MaxBytesReader(w, r.Body, 8<<10)
defer body.Close()
var request pushSubscriptionRequest
if err := decodeOne(body, &request); err != nil {
writeProblem(w, http.StatusBadRequest, "invalid push subscription request")
return pushSubscriptionRequest{}, auth.Principal{}, false
}
scope := access.Scope{OrganizationID: request.OrganizationID}
decision, err := s.identity.Access.Authorize(r.Context(), principal.User.ID, scope, identity.PermissionIncidentsRead)
if err != nil || !decision.Allowed {
writeProblem(w, http.StatusForbidden, "incident access denied")
return pushSubscriptionRequest{}, auth.Principal{}, false
}
if err = s.identity.ValidateResourceScope(r.Context(), scope); err != nil {
writeProblem(w, http.StatusBadRequest, "invalid organization")
return pushSubscriptionRequest{}, auth.Principal{}, false
}
token, sessionOK := authhttp.SessionToken(r, s.cookie)
if !sessionOK || !authhttp.VerifyCSRF(token, "push:manage", r.Header.Get("X-CSRF-Token")) {
writeProblem(w, http.StatusForbidden, "valid push CSRF token required")
return pushSubscriptionRequest{}, auth.Principal{}, false
}
return request, principal, true
}
+32
View File
@@ -0,0 +1,32 @@
// SPDX-License-Identifier: AGPL-3.0-only
package httpserver
import (
"fmt"
"net/http"
"gamertan.com/observatory/internal/site"
)
func (s *Server) webManifest(w http.ResponseWriter, r *http.Request) {
serveFixedBody(w, r, site.WebManifest(), "application/manifest+json")
}
func (s *Server) serviceWorker(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Service-Worker-Allowed", "/")
serveFixedBody(w, r, site.ServiceWorker(), "text/javascript; charset=utf-8")
}
func (s *Server) offlineShell(w http.ResponseWriter, r *http.Request) {
s.renderHTML(w, r, http.StatusOK, site.Offline(site.OfflineView{Head: s.head("Offline — Gamertan Observatory", "Observatory is temporarily unreachable.", "/offline/")}))
}
func serveFixedBody(w http.ResponseWriter, r *http.Request, body []byte, contentType string) {
w.Header().Set("Content-Type", contentType)
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(body)))
if r.Method != http.MethodHead {
_, _ = w.Write(body)
}
}
+71
View File
@@ -0,0 +1,71 @@
// SPDX-License-Identifier: AGPL-3.0-only
package httpserver
import (
"errors"
"sync"
)
var errRefreshCapacity = errors.New("live refresh capacity reached")
// refreshHub carries only a coalescible invalidation signal. It never carries
// telemetry, resource names, incident details, or credentials.
type refreshHub struct {
mu sync.Mutex
next uint64
total int
maxTotal int
maxPerOrganization int
subscribers map[string]map[uint64]chan struct{}
}
func newRefreshHub(maxTotal, maxPerOrganization int) *refreshHub {
return &refreshHub{maxTotal: maxTotal, maxPerOrganization: maxPerOrganization, subscribers: make(map[string]map[uint64]chan struct{})}
}
func (hub *refreshHub) subscribe(organizationID string) (<-chan struct{}, func(), error) {
hub.mu.Lock()
defer hub.mu.Unlock()
group := hub.subscribers[organizationID]
if hub.total >= hub.maxTotal || len(group) >= hub.maxPerOrganization {
return nil, nil, errRefreshCapacity
}
if group == nil {
group = make(map[uint64]chan struct{})
hub.subscribers[organizationID] = group
}
hub.next++
id := hub.next
updates := make(chan struct{}, 1)
group[id] = updates
hub.total++
var once sync.Once
remove := func() {
once.Do(func() {
hub.mu.Lock()
defer hub.mu.Unlock()
if current := hub.subscribers[organizationID]; current != nil {
if _, exists := current[id]; exists {
delete(current, id)
hub.total--
}
if len(current) == 0 {
delete(hub.subscribers, organizationID)
}
}
})
}
return updates, remove, nil
}
func (hub *refreshHub) publish(organizationID string) {
hub.mu.Lock()
defer hub.mu.Unlock()
for _, updates := range hub.subscribers[organizationID] {
select {
case updates <- struct{}{}:
default:
}
}
}
+53
View File
@@ -0,0 +1,53 @@
// SPDX-License-Identifier: AGPL-3.0-only
package httpserver
import (
"errors"
"testing"
)
func TestRefreshHubIsBoundedCoalescedAndOrganizationScoped(t *testing.T) {
hub := newRefreshHub(2, 1)
first, removeFirst, err := hub.subscribe("organization-a")
if err != nil {
t.Fatal(err)
}
defer removeFirst()
if _, _, err = hub.subscribe("organization-a"); !errors.Is(err, errRefreshCapacity) {
t.Fatalf("same-organization capacity err=%v", err)
}
second, removeSecond, err := hub.subscribe("organization-b")
if err != nil {
t.Fatal(err)
}
defer removeSecond()
if _, _, err = hub.subscribe("organization-c"); !errors.Is(err, errRefreshCapacity) {
t.Fatalf("total capacity err=%v", err)
}
hub.publish("organization-a")
hub.publish("organization-a")
select {
case <-first:
default:
t.Fatal("organization A did not receive refresh")
}
select {
case <-first:
t.Fatal("duplicate refresh was not coalesced")
default:
}
select {
case <-second:
t.Fatal("organization B received organization A refresh")
default:
}
removeFirst()
if _, removeReplacement, err := hub.subscribe("organization-a"); err != nil {
t.Fatalf("released capacity was not reusable: %v", err)
} else {
removeReplacement()
}
}
+810
View File
@@ -0,0 +1,810 @@
// SPDX-License-Identifier: AGPL-3.0-only
package httpserver
import (
"bytes"
"compress/gzip"
"crypto/ecdh"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"mime"
"net/http"
"net/url"
"strings"
"time"
"gamertan.com/observatory/internal/identity"
"gamertan.com/observatory/internal/model"
"gamertan.com/observatory/internal/nativeprotocol"
"gamertan.com/observatory/internal/otlp"
"gamertan.com/observatory/internal/query"
"gamertan.com/observatory/internal/storage"
"gamertan.com/web/access"
"gamertan.com/web/auth"
"gamertan.com/web/authhttp"
"gamertan.com/web/requestmeta"
"gamertan.com/web/websec"
)
type Options struct {
PublicOrigin string
MaxBodyBytes int64
MaxConcurrentIngest int
MaxQueryRows int
QueryBudget query.Budget
SessionLifetime time.Duration
PushPublicKey string
PushDispatcher PushDispatcher
}
type PushDispatcher interface {
Enqueue(organizationID string) bool
}
type Server struct {
store *storage.Store
identity *identity.Services
options Options
cookie authhttp.CookieConfig
now func() time.Time
refresh *refreshHub
requests *requestmeta.Resolver
ingestSlots chan struct{}
}
func New(store *storage.Store, identities *identity.Services, options Options) (*Server, error) {
if store == nil || identities == nil || identities.Auth == nil || identities.Access == nil {
return nil, errors.New("server storage and identity services are required")
}
origin, err := url.Parse(options.PublicOrigin)
if err != nil || origin.Scheme != "https" || origin.Host == "" || origin.User != nil || origin.Path != "" && origin.Path != "/" || origin.RawQuery != "" || origin.Fragment != "" {
return nil, errors.New("server public origin must be an absolute HTTPS origin")
}
options.PublicOrigin = strings.TrimSuffix(options.PublicOrigin, "/")
if options.MaxConcurrentIngest == 0 {
options.MaxConcurrentIngest = 8
}
if options.MaxBodyBytes < 1024 || options.MaxConcurrentIngest < 1 || options.MaxConcurrentIngest > 64 || options.MaxQueryRows < 1 || options.SessionLifetime < 5*time.Minute || options.SessionLifetime > 30*24*time.Hour {
return nil, errors.New("server limits are invalid")
}
if options.QueryBudget.MaxRows != options.MaxQueryRows || options.QueryBudget.MaxDuration < time.Millisecond || options.QueryBudget.MaxScannedBytes < 1 || options.QueryBudget.MaxMemoryBytes < 1 {
return nil, errors.New("server query budget is invalid")
}
if (options.PushPublicKey == "") != (options.PushDispatcher == nil) {
return nil, errors.New("server Web Push key and dispatcher must be configured together")
}
if options.PushPublicKey != "" {
publicKey, decodeErr := base64.RawURLEncoding.DecodeString(options.PushPublicKey)
if decodeErr != nil || len(publicKey) != 65 {
return nil, errors.New("server Web Push public key is invalid")
}
if _, decodeErr = ecdh.P256().NewPublicKey(publicKey); decodeErr != nil {
return nil, errors.New("server Web Push public key is invalid")
}
}
cookie := authhttp.CookieConfig{Name: "__Host-observatory_session", Lifetime: options.SessionLifetime, SameSite: http.SameSiteStrictMode}
if err = cookie.Validate(); err != nil {
return nil, err
}
requests, err := requestmeta.New(requestmeta.Config{})
if err != nil {
return nil, fmt.Errorf("server request metadata: %w", err)
}
return &Server{store: store, identity: identities, options: options, cookie: cookie, now: func() time.Time { return time.Now().UTC() }, refresh: newRefreshHub(256, 8), requests: requests, ingestSlots: make(chan struct{}, options.MaxConcurrentIngest)}, nil
}
func (s *Server) enterIngest(w http.ResponseWriter) (func(), bool) {
select {
case s.ingestSlots <- struct{}{}:
return func() { <-s.ingestSlots }, true
default:
w.Header().Set("Retry-After", "1")
writeProblem(w, http.StatusServiceUnavailable, "ingestion capacity temporarily unavailable")
return nil, false
}
}
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", textOK("ok\n"))
mux.HandleFunc("HEAD /healthz", textOK("ok\n"))
mux.HandleFunc("GET /readyz", textOK("ready\n"))
mux.HandleFunc("HEAD /readyz", textOK("ready\n"))
mux.HandleFunc("GET /{$}", s.landing)
mux.HandleFunc("HEAD /{$}", s.landing)
mux.HandleFunc("GET /manifest.webmanifest", s.webManifest)
mux.HandleFunc("HEAD /manifest.webmanifest", s.webManifest)
mux.HandleFunc("GET /service-worker.js", s.serviceWorker)
mux.HandleFunc("HEAD /service-worker.js", s.serviceWorker)
mux.HandleFunc("GET /offline/{$}", s.offlineShell)
mux.HandleFunc("HEAD /offline/{$}", s.offlineShell)
mux.HandleFunc("GET /login/{$}", s.loginPage)
mux.HandleFunc("HEAD /login/{$}", s.loginPage)
mux.HandleFunc("POST /login/{$}", s.loginForm)
mux.HandleFunc("POST /logout/{$}", s.logoutForm)
mux.HandleFunc("GET /account/password/{$}", s.passwordPage)
mux.HandleFunc("HEAD /account/password/{$}", s.passwordPage)
mux.HandleFunc("POST /account/password/{$}", s.passwordForm)
mux.HandleFunc("GET /app/{$}", s.app)
mux.HandleFunc("HEAD /app/{$}", s.app)
mux.HandleFunc("GET /app/explore/{$}", s.explorePage)
mux.HandleFunc("HEAD /app/explore/{$}", s.explorePage)
mux.HandleFunc("POST /app/explore/{$}", s.exploreForm)
mux.HandleFunc("GET /app/events", s.events)
mux.HandleFunc("POST /app/queries/{$}", s.createSavedQuery)
mux.HandleFunc("POST /app/queries/builder/{$}", s.createBuiltQuery)
mux.HandleFunc("POST /app/dashboards/{$}", s.createDashboard)
mux.HandleFunc("GET /app/dashboards/{slug}/{$}", s.dashboard)
mux.HandleFunc("HEAD /app/dashboards/{slug}/{$}", s.dashboard)
mux.HandleFunc("POST /app/dashboards/{slug}/{$}", s.updateDashboard)
mux.HandleFunc("POST /app/dashboards/{slug}/panels/{$}", s.addDashboardPanel)
mux.HandleFunc("POST /app/dashboards/{slug}/panels/{panel}/{$}", s.updateDashboardPanel)
mux.HandleFunc("POST /app/dashboards/{slug}/panels/{panel}/remove/{$}", s.removeDashboardPanel)
mux.HandleFunc("GET /app/dashboards/{slug}/export.json", s.exportDashboard)
mux.HandleFunc("HEAD /app/dashboards/{slug}/export.json", s.exportDashboard)
mux.HandleFunc("GET /app/incidents/{$}", s.incidentInbox)
mux.HandleFunc("HEAD /app/incidents/{$}", s.incidentInbox)
mux.HandleFunc("GET /app/incidents/offline/{$}", s.offlineIncidentInbox)
mux.HandleFunc("HEAD /app/incidents/offline/{$}", s.offlineIncidentInbox)
mux.HandleFunc("POST /app/alert-rules/{$}", s.createAlertRule)
mux.HandleFunc("POST /app/incidents/{id}/{$}", s.transitionIncident)
mux.HandleFunc("GET /assets/", s.serveAsset)
mux.HandleFunc("HEAD /assets/", s.serveAsset)
mux.HandleFunc("POST /api/v1/ingest/native", s.ingest)
mux.HandleFunc("POST /api/v2/ingest/native", s.ingestFramed)
mux.HandleFunc("POST /v1/logs", s.ingestOTLP(otlp.Logs))
mux.HandleFunc("POST /v1/metrics", s.ingestOTLP(otlp.Metrics))
mux.HandleFunc("POST /v1/traces", s.ingestOTLP(otlp.Traces))
mux.HandleFunc("POST /api/v1/agent/enroll", s.enrollAgent)
mux.HandleFunc("POST /api/v1/agent/alert-transition", s.recordAgentAlertTransition)
mux.HandleFunc("DELETE /api/v1/agent/source", s.revokeAgentSource)
mux.HandleFunc("POST /api/v1/session", s.login)
mux.HandleFunc("DELETE /api/v1/session", s.logout)
mux.HandleFunc("POST /api/v1/account/password", s.changePassword)
mux.HandleFunc("POST /api/v1/query/parse", s.parseQuery)
mux.HandleFunc("POST /api/v1/query/explain", s.explainQuery)
mux.HandleFunc("POST /api/v1/query", s.executeQuery)
mux.HandleFunc("POST /api/v1/push/subscription", s.savePushSubscription)
mux.HandleFunc("POST /api/v1/push/subscription/status", s.pushSubscriptionStatus)
mux.HandleFunc("DELETE /api/v1/push/subscription", s.deletePushSubscription)
return securityHeaders(s.requests.Middleware(authhttp.Optional(s.identity.Auth, s.cookie)(s.requirePasswordChange(mux))))
}
func (s *Server) recordAgentAlertTransition(w http.ResponseWriter, r *http.Request) {
token, ok := bearer(r.Header.Get("Authorization"))
if !ok || !strings.HasPrefix(token, "obs1.") {
writeProblem(w, http.StatusUnauthorized, "source authentication required")
return
}
// Authenticate before decoding. RecordSourceAlertTransition authenticates
// again while binding scope and raw evidence to the credential.
if _, err := s.store.Authenticate(r.Context(), token); err != nil {
writeProblem(w, http.StatusUnauthorized, "source authentication required")
return
}
leave, ok := s.enterIngest(w)
if !ok {
return
}
defer leave()
body := http.MaxBytesReader(w, r.Body, 64<<10)
defer body.Close()
var transition model.AlertTransition
if err := decodeOne(body, &transition); err != nil {
writeProblem(w, http.StatusBadRequest, "invalid source alert transition")
return
}
ack, err := s.store.RecordSourceAlertTransition(r.Context(), token, transition, s.now())
if err != nil {
writeProblem(w, http.StatusUnprocessableEntity, "source alert transition rejected")
return
}
writeJSON(w, http.StatusAccepted, ack)
}
func (s *Server) requirePasswordChange(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
principal, ok := auth.PrincipalFromContext(r.Context())
if !ok || !principal.User.PasswordChangeRequired || passwordChangeAllowed(r) {
next.ServeHTTP(w, r)
return
}
if r.Method == http.MethodGet || r.Method == http.MethodHead {
http.Redirect(w, r, "/account/password/", http.StatusSeeOther)
return
}
writeProblem(w, http.StatusForbidden, "password change required")
})
}
func passwordChangeAllowed(r *http.Request) bool {
switch r.URL.Path {
case "/healthz", "/readyz", "/manifest.webmanifest", "/service-worker.js", "/offline/", "/account/password/", "/logout/", "/api/v1/account/password", "/api/v1/session":
return true
}
return strings.HasPrefix(r.URL.Path, "/assets/")
}
func (s *Server) revokeAgentSource(w http.ResponseWriter, r *http.Request) {
token, ok := bearer(r.Header.Get("Authorization"))
if !ok || !strings.HasPrefix(token, "obs1.") {
writeProblem(w, http.StatusUnauthorized, "source authentication required")
return
}
source, err := s.store.Authenticate(r.Context(), token)
if err != nil {
writeProblem(w, http.StatusUnauthorized, "source authentication required")
return
}
if err = s.store.RevokeSource(r.Context(), source.ID); err != nil {
writeProblem(w, http.StatusServiceUnavailable, "source revocation unavailable")
return
}
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) enrollAgent(w http.ResponseWriter, r *http.Request) {
token, ok := bearer(r.Header.Get("Authorization"))
if !ok || !strings.HasPrefix(token, "obse1.") {
writeProblem(w, http.StatusUnauthorized, "valid enrollment required")
return
}
body := http.MaxBytesReader(w, r.Body, 1)
defer body.Close()
payload, bodyErr := io.ReadAll(body)
if bodyErr != nil || len(payload) != 0 {
writeProblem(w, http.StatusBadRequest, "enrollment request body must be empty")
return
}
enrollment, credential, err := s.store.RedeemEnrollment(r.Context(), token, s.now())
if err != nil {
writeProblem(w, http.StatusUnauthorized, "valid enrollment required")
return
}
writeJSON(w, http.StatusCreated, struct {
SourceID string `json:"source_id"`
Credential string `json:"credential"`
}{enrollment.SourceID, credential})
}
func (s *Server) ingest(w http.ResponseWriter, r *http.Request) {
token, ok := bearer(r.Header.Get("Authorization"))
if !ok {
writeProblem(w, http.StatusUnauthorized, "source authentication required")
return
}
source, err := s.store.Authenticate(r.Context(), token)
if err != nil {
writeProblem(w, http.StatusUnauthorized, "source authentication required")
return
}
leave, ok := s.enterIngest(w)
if !ok {
return
}
defer leave()
body := http.MaxBytesReader(w, r.Body, s.options.MaxBodyBytes)
defer body.Close()
var batch model.Batch
if err := decodeOne(body, &batch); err != nil {
writeProblem(w, http.StatusBadRequest, "invalid native batch")
return
}
ack, err := s.store.Ingest(r.Context(), token, batch, s.now())
if err != nil {
writeProblem(w, http.StatusUnprocessableEntity, "batch rejected")
return
}
s.refresh.publish(source.Scope.OrganizationID)
writeJSON(w, http.StatusAccepted, ack)
}
func (s *Server) ingestFramed(w http.ResponseWriter, r *http.Request) {
token, ok := bearer(r.Header.Get("Authorization"))
if !ok {
writeProblem(w, http.StatusUnauthorized, "source authentication required")
return
}
source, err := s.store.Authenticate(r.Context(), token)
if err != nil {
writeProblem(w, http.StatusUnauthorized, "source authentication required")
return
}
leave, ok := s.enterIngest(w)
if !ok {
return
}
defer leave()
mediaType, parameters, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
if err != nil || mediaType != "application/json" || len(parameters) != 0 {
writeProblem(w, http.StatusUnsupportedMediaType, "native JSON content type required")
return
}
envelope, err := nativeprotocol.ParseHeaders(r.Header, s.options.MaxBodyBytes)
if err != nil {
writeProblem(w, http.StatusBadRequest, "invalid native batch envelope")
return
}
body := http.MaxBytesReader(w, r.Body, s.options.MaxBodyBytes)
defer body.Close()
if _, exact, checkErr := s.store.CheckNativeReplay(r.Context(), token, envelope); checkErr != nil {
writeProblem(w, http.StatusUnprocessableEntity, "batch rejected")
return
} else if exact {
if err = verifyNativeReplayBody(body, envelope); err != nil {
writeProblem(w, http.StatusBadRequest, "native batch body does not match envelope")
return
}
ack, confirmErr := s.store.ConfirmNativeReplay(r.Context(), token, envelope)
if confirmErr != nil {
writeProblem(w, http.StatusUnprocessableEntity, "batch rejected")
return
}
writeJSON(w, http.StatusAccepted, ack)
return
}
encoded, err := io.ReadAll(body)
if err != nil {
writeProblem(w, http.StatusBadRequest, "invalid native batch")
return
}
var batch model.Batch
if err = decodeOne(bytes.NewReader(encoded), &batch); err != nil {
writeProblem(w, http.StatusBadRequest, "invalid native batch")
return
}
ack, err := s.store.IngestNative(r.Context(), token, batch, envelope, encoded, s.now())
if err != nil {
writeProblem(w, http.StatusUnprocessableEntity, "batch rejected")
return
}
if !ack.Duplicate {
s.refresh.publish(source.Scope.OrganizationID)
}
writeJSON(w, http.StatusAccepted, ack)
}
func verifyNativeReplayBody(body io.Reader, envelope model.BatchEnvelope) error {
digest := sha256.New()
written, err := io.Copy(digest, body)
if err != nil || written != envelope.EncodedBytes || hex.EncodeToString(digest.Sum(nil)) != envelope.WireDigest {
return errors.New("native batch body does not match envelope")
}
return nil
}
func (s *Server) ingestOTLP(signal otlp.Signal) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
token, ok := bearer(r.Header.Get("Authorization"))
if !ok || !strings.HasPrefix(token, "obs1.") {
writeProblem(w, http.StatusUnauthorized, "source authentication required")
return
}
// Authenticate before parsing attacker-controlled protobuf. IngestAuto
// authenticates again while assigning the next sequence under its source
// lock so revocation cannot race into an acknowledged write.
source, err := s.store.Authenticate(r.Context(), token)
if err != nil {
writeProblem(w, http.StatusUnauthorized, "source authentication required")
return
}
leave, ok := s.enterIngest(w)
if !ok {
return
}
defer leave()
mediaType, parameters, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
if err != nil || mediaType != "application/x-protobuf" || len(parameters) != 0 {
writeProblem(w, http.StatusUnsupportedMediaType, "OTLP protobuf content type required")
return
}
body, status, err := readOTLPBody(w, r, s.options.MaxBodyBytes)
if err != nil {
title := "invalid OTLP request body"
if status == http.StatusRequestEntityTooLarge {
title = "OTLP request body exceeds limit"
} else if status == http.StatusUnsupportedMediaType {
title = "unsupported OTLP content encoding"
}
writeProblem(w, status, title)
return
}
now := s.now()
records, err := otlp.Decode(signal, body, now)
if err != nil {
writeProblem(w, http.StatusBadRequest, "invalid OTLP protobuf payload")
return
}
if _, err = s.store.IngestAuto(r.Context(), token, signal.StreamID(), signal.ModelSignal(), records, now); err != nil {
writeProblem(w, http.StatusUnprocessableEntity, "OTLP batch rejected")
return
}
s.refresh.publish(source.Scope.OrganizationID)
response, err := otlp.SuccessResponse(signal)
if err != nil {
writeProblem(w, http.StatusInternalServerError, "OTLP response unavailable")
return
}
w.Header().Set("Content-Type", "application/x-protobuf")
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(response)))
w.WriteHeader(http.StatusOK)
if len(response) > 0 {
_, _ = w.Write(response)
}
}
}
func readOTLPBody(w http.ResponseWriter, r *http.Request, limit int64) ([]byte, int, error) {
compressed := http.MaxBytesReader(w, r.Body, limit)
defer compressed.Close()
encoding := strings.TrimSpace(strings.ToLower(r.Header.Get("Content-Encoding")))
var reader io.Reader = compressed
var zipped *gzip.Reader
switch encoding {
case "", "identity":
case "gzip":
var err error
zipped, err = gzip.NewReader(compressed)
if err != nil {
return nil, http.StatusBadRequest, err
}
defer zipped.Close()
reader = io.LimitReader(zipped, limit+1)
default:
return nil, http.StatusUnsupportedMediaType, errors.New("unsupported content encoding")
}
body, err := io.ReadAll(reader)
if err != nil {
var tooLarge *http.MaxBytesError
if errors.As(err, &tooLarge) {
return nil, http.StatusRequestEntityTooLarge, err
}
return nil, http.StatusBadRequest, err
}
if int64(len(body)) > limit {
return nil, http.StatusRequestEntityTooLarge, errors.New("decoded body exceeds limit")
}
return body, http.StatusOK, nil
}
func (s *Server) login(w http.ResponseWriter, r *http.Request) {
if !websec.SameOrigin(r, s.options.PublicOrigin) {
writeProblem(w, http.StatusForbidden, "same-origin request required")
return
}
body := http.MaxBytesReader(w, r.Body, 16<<10)
defer body.Close()
var input struct {
Identifier string `json:"identifier"`
Password string `json:"password"`
}
if err := decodeOne(body, &input); err != nil {
writeProblem(w, http.StatusBadRequest, "invalid session request")
return
}
token, principal, err := s.identity.Auth.Authenticate(r.Context(), input.Identifier, input.Password, s.options.SessionLifetime)
if err != nil {
writeProblem(w, http.StatusUnauthorized, "invalid credentials")
return
}
if err = authhttp.SetSession(w, s.cookie, token, s.now()); err != nil {
_ = s.identity.Auth.RevokeSession(r.Context(), token)
writeProblem(w, http.StatusServiceUnavailable, "session unavailable")
return
}
csrf, err := authhttp.CSRFToken(token, "session:delete")
if err != nil {
_ = s.identity.Auth.RevokeSession(r.Context(), token)
writeProblem(w, http.StatusServiceUnavailable, "session unavailable")
return
}
passwordCSRF := ""
if principal.User.PasswordChangeRequired {
passwordCSRF, err = authhttp.CSRFToken(token, "account:password:change")
if err != nil {
_ = s.identity.Auth.RevokeSession(r.Context(), token)
writeProblem(w, http.StatusServiceUnavailable, "session unavailable")
return
}
}
writeJSON(w, http.StatusOK, struct {
UserID string `json:"user_id"`
Username string `json:"username"`
DisplayName string `json:"display_name"`
CSRFToken string `json:"csrf_token"`
PasswordChangeCSRF string `json:"password_change_csrf,omitempty"`
PasswordChangeRequired bool `json:"password_change_required"`
}{principal.User.ID, principal.User.Username, principal.User.DisplayName, csrf, passwordCSRF, principal.User.PasswordChangeRequired})
}
func (s *Server) changePassword(w http.ResponseWriter, r *http.Request) {
if !websec.SameOrigin(r, s.options.PublicOrigin) {
writeProblem(w, http.StatusForbidden, "same-origin request required")
return
}
principal, ok := auth.PrincipalFromContext(r.Context())
token, tokenOK := authhttp.SessionToken(r, s.cookie)
if !ok || !tokenOK || !principal.User.PasswordChangeRequired || !authhttp.VerifyCSRF(token, "account:password:change", r.Header.Get("X-CSRF-Token")) {
writeProblem(w, http.StatusForbidden, "password change authorization required")
return
}
body := http.MaxBytesReader(w, r.Body, 8<<10)
defer body.Close()
var input struct {
CurrentPassword string `json:"current_password"`
NewPassword string `json:"new_password"`
}
if err := decodeOne(body, &input); err != nil {
writeProblem(w, http.StatusBadRequest, "invalid password change request")
return
}
if err := s.identity.Auth.ChangePassword(r.Context(), principal.User.ID, input.CurrentPassword, input.NewPassword); err != nil {
writeProblem(w, http.StatusUnprocessableEntity, "password change rejected")
return
}
if err := authhttp.ClearSession(w, s.cookie); err != nil {
writeProblem(w, http.StatusServiceUnavailable, "session unavailable")
return
}
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) logout(w http.ResponseWriter, r *http.Request) {
if !websec.SameOrigin(r, s.options.PublicOrigin) {
writeProblem(w, http.StatusForbidden, "same-origin request required")
return
}
token, ok := authhttp.SessionToken(r, s.cookie)
if !ok || !authhttp.VerifyCSRF(token, "session:delete", r.Header.Get("X-CSRF-Token")) {
writeProblem(w, http.StatusForbidden, "valid session CSRF token required")
return
}
if err := s.identity.Auth.RevokeSession(r.Context(), token); err != nil && !errors.Is(err, auth.ErrSessionNotFound) {
writeProblem(w, http.StatusServiceUnavailable, "session unavailable")
return
}
if err := authhttp.ClearSession(w, s.cookie); err != nil {
writeProblem(w, http.StatusServiceUnavailable, "session unavailable")
return
}
w.WriteHeader(http.StatusNoContent)
}
type queryRequest struct {
Query string `json:"query,omitempty"`
AST *query.AST `json:"ast,omitempty"`
OrganizationID string `json:"organization_id,omitempty"`
ProjectID string `json:"project_id,omitempty"`
EnvironmentID string `json:"environment_id,omitempty"`
ServiceID string `json:"service_id,omitempty"`
}
func (s *Server) parseQuery(w http.ResponseWriter, r *http.Request) {
body := http.MaxBytesReader(w, r.Body, 16<<10)
defer body.Close()
var input queryRequest
if err := decodeOne(body, &input); err != nil {
writeProblem(w, http.StatusBadRequest, "invalid query request")
return
}
ast, err := parseAST(input, s.options.MaxQueryRows)
if err != nil {
writeProblem(w, http.StatusUnprocessableEntity, "query rejected")
return
}
writeJSON(w, http.StatusOK, ast)
}
func (s *Server) explainQuery(w http.ResponseWriter, r *http.Request) {
if !websec.SameOrigin(r, s.options.PublicOrigin) {
writeProblem(w, http.StatusForbidden, "same-origin request required")
return
}
principal, ok := auth.PrincipalFromContext(r.Context())
if !ok {
writeProblem(w, http.StatusUnauthorized, "authentication required")
return
}
body := http.MaxBytesReader(w, r.Body, 16<<10)
defer body.Close()
var input queryRequest
if err := decodeOne(body, &input); err != nil {
writeProblem(w, http.StatusBadRequest, "invalid query request")
return
}
ast, err := parseAST(input, s.options.MaxQueryRows)
if err != nil {
writeProblem(w, http.StatusUnprocessableEntity, "query rejected")
return
}
requested := access.Scope{OrganizationID: input.OrganizationID, ProjectID: input.ProjectID, EnvironmentID: input.EnvironmentID, ServiceID: input.ServiceID}
decision, err := s.identity.Access.Authorize(r.Context(), principal.User.ID, requested, identity.PermissionTelemetryQuery)
if err != nil {
writeProblem(w, http.StatusBadRequest, "invalid resource scope")
return
}
if !decision.Allowed {
writeProblem(w, http.StatusForbidden, "resource access denied")
return
}
if err := s.identity.ValidateResourceScope(r.Context(), requested); err != nil {
writeProblem(w, http.StatusBadRequest, "invalid resource scope")
return
}
sensitive, err := s.identity.Access.Authorize(r.Context(), principal.User.ID, requested, identity.PermissionTelemetryReadSensitive)
if err != nil {
writeProblem(w, http.StatusServiceUnavailable, "authorization unavailable")
return
}
estimated, err := s.store.EstimateOrganizationBytes(input.OrganizationID)
if err != nil {
writeProblem(w, http.StatusServiceUnavailable, "query planning unavailable")
return
}
registry, _, err := s.store.ActiveDescriptors(r.Context(), input.OrganizationID)
if err != nil {
writeProblem(w, http.StatusServiceUnavailable, "query planning unavailable")
return
}
explain, err := query.Plan(ast, query.Scope{
OrganizationID: input.OrganizationID, ProjectID: input.ProjectID,
EnvironmentID: input.EnvironmentID, ServiceID: input.ServiceID,
Sensitive: sensitive.Allowed,
}, registry, estimated, s.options.QueryBudget)
if errors.Is(err, query.ErrSensitivePermissionRequired) {
writeProblem(w, http.StatusForbidden, "sensitive-field permission required")
return
}
if err != nil {
writeProblem(w, http.StatusUnprocessableEntity, "query plan rejected")
return
}
writeJSON(w, http.StatusOK, explain)
}
func (s *Server) executeQuery(w http.ResponseWriter, r *http.Request) {
if !websec.SameOrigin(r, s.options.PublicOrigin) {
writeProblem(w, http.StatusForbidden, "same-origin request required")
return
}
principal, ok := auth.PrincipalFromContext(r.Context())
if !ok {
writeProblem(w, http.StatusUnauthorized, "authentication required")
return
}
body := http.MaxBytesReader(w, r.Body, 16<<10)
defer body.Close()
var input queryRequest
if err := decodeOne(body, &input); err != nil {
writeProblem(w, http.StatusBadRequest, "invalid query request")
return
}
ast, err := parseAST(input, s.options.MaxQueryRows)
if err != nil {
writeProblem(w, http.StatusUnprocessableEntity, "query rejected")
return
}
requested := access.Scope{OrganizationID: input.OrganizationID, ProjectID: input.ProjectID, EnvironmentID: input.EnvironmentID, ServiceID: input.ServiceID}
decision, err := s.identity.Access.Authorize(r.Context(), principal.User.ID, requested, identity.PermissionTelemetryQuery)
if err != nil {
writeProblem(w, http.StatusBadRequest, "invalid resource scope")
return
}
if !decision.Allowed {
writeProblem(w, http.StatusForbidden, "resource access denied")
return
}
if err = s.identity.ValidateResourceScope(r.Context(), requested); err != nil {
writeProblem(w, http.StatusBadRequest, "invalid resource scope")
return
}
sensitive, err := s.identity.Access.Authorize(r.Context(), principal.User.ID, requested, identity.PermissionTelemetryReadSensitive)
if err != nil {
writeProblem(w, http.StatusServiceUnavailable, "authorization unavailable")
return
}
result, err := s.store.Query(r.Context(), ast, query.Scope{
OrganizationID: input.OrganizationID, ProjectID: input.ProjectID,
EnvironmentID: input.EnvironmentID, ServiceID: input.ServiceID,
Sensitive: sensitive.Allowed,
}, s.options.QueryBudget, s.now())
switch {
case errors.Is(err, query.ErrSensitivePermissionRequired):
writeProblem(w, http.StatusForbidden, "sensitive-field permission required")
return
case errors.Is(err, query.ErrBudgetExceeded):
writeProblem(w, http.StatusUnprocessableEntity, "query execution budget exceeded")
return
case errors.Is(err, query.ErrTypeMismatch):
writeProblem(w, http.StatusUnprocessableEntity, "query field type mismatch")
return
case err != nil:
writeProblem(w, http.StatusServiceUnavailable, "query execution unavailable")
return
}
writeJSON(w, http.StatusOK, result)
}
func parseAST(input queryRequest, maxRows int) (query.AST, error) {
if (input.Query == "") == (input.AST == nil) {
return query.AST{}, errors.New("provide exactly one query or AST")
}
if input.AST == nil {
return query.Parse(input.Query, maxRows)
}
ast := *input.AST
var err error
if ast.WindowText != "" {
ast.Window, err = time.ParseDuration(ast.WindowText)
}
if err == nil && ast.BucketText != "" {
ast.Bucket, err = time.ParseDuration(ast.BucketText)
}
if err == nil {
err = query.Validate(ast, maxRows)
}
return ast, err
}
func decodeOne(reader io.Reader, value any) error {
decoder := json.NewDecoder(reader)
decoder.DisallowUnknownFields()
if err := decoder.Decode(value); err != nil {
return err
}
if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
return errors.New("request must contain exactly one JSON value")
}
return nil
}
func bearer(value string) (string, bool) {
const prefix = "Bearer "
if !strings.HasPrefix(value, prefix) || strings.ContainsAny(value[len(prefix):], " \t\r\n") {
return "", false
}
return value[len(prefix):], value[len(prefix):] != ""
}
func textOK(body string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(body)))
if r.Method != http.MethodHead {
_, _ = io.WriteString(w, body)
}
}
}
func securityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Content-Security-Policy", "default-src 'none'; base-uri 'none'; connect-src 'self'; form-action 'self'; frame-ancestors 'none'; img-src 'self'; manifest-src 'self'; script-src 'self'; style-src 'self'; worker-src 'self'")
w.Header().Set("Cross-Origin-Opener-Policy", "same-origin")
w.Header().Set("Permissions-Policy", "camera=(), geolocation=(), microphone=()")
w.Header().Set("Referrer-Policy", "no-referrer")
w.Header().Set("X-Content-Type-Options", "nosniff")
next.ServeHTTP(w, r)
})
}
func writeJSON(w http.ResponseWriter, status int, value any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(value)
}
func writeProblem(w http.ResponseWriter, status int, title string) {
w.Header().Set("Content-Type", "application/problem+json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(struct {
Title string `json:"title"`
Status int `json:"status"`
}{Title: title, Status: status})
}
+496
View File
@@ -0,0 +1,496 @@
// SPDX-License-Identifier: AGPL-3.0-only
package httpserver
import (
"bytes"
"compress/gzip"
"context"
"crypto/ecdh"
"crypto/rand"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"gamertan.com/observatory/internal/identity"
"gamertan.com/observatory/internal/model"
"gamertan.com/observatory/internal/nativeprotocol"
"gamertan.com/observatory/internal/query"
"gamertan.com/observatory/internal/schema"
"gamertan.com/observatory/internal/storage"
commonpb "go.opentelemetry.io/proto/otlp/common/v1"
logspb "go.opentelemetry.io/proto/otlp/logs/v1"
"google.golang.org/protobuf/proto"
)
func TestIngestionConcurrencyIsBoundedAndReusable(t *testing.T) {
server, store, identities, _ := newUITestServer(t)
defer store.Close()
defer identities.Close()
server.ingestSlots = make(chan struct{}, 1)
first := httptest.NewRecorder()
leave, ok := server.enterIngest(first)
if !ok || leave == nil {
t.Fatal("first ingestion slot was unavailable")
}
second := httptest.NewRecorder()
if secondLeave, admitted := server.enterIngest(second); admitted || secondLeave != nil || second.Code != http.StatusServiceUnavailable || second.Header().Get("Retry-After") != "1" || !strings.Contains(second.Body.String(), "ingestion capacity temporarily unavailable") {
t.Fatalf("second admission admitted=%t status=%d retry=%q body=%s", admitted, second.Code, second.Header().Get("Retry-After"), second.Body.String())
}
leave()
third := httptest.NewRecorder()
thirdLeave, admitted := server.enterIngest(third)
if !admitted || thirdLeave == nil {
t.Fatal("released ingestion capacity was not reusable")
}
thirdLeave()
}
func TestServerWebPushConfigurationIsAllOrNothing(t *testing.T) {
root := filepath.Join(t.TempDir(), "data")
if err := os.Mkdir(root, 0o700); err != nil {
t.Fatal(err)
}
store, err := storage.Open(root)
if err != nil {
t.Fatal(err)
}
defer store.Close()
identities, err := identity.Open(root)
if err != nil {
t.Fatal(err)
}
defer identities.Close()
dispatcher := &recordingPushDispatcher{}
options := testOptions()
options.PushDispatcher = dispatcher
if _, err = New(store, identities, options); err == nil {
t.Fatal("dispatcher without public key accepted")
}
options.PushPublicKey = "invalid"
if _, err = New(store, identities, options); err == nil {
t.Fatal("invalid public key accepted")
}
private, err := ecdh.P256().GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
options.PushPublicKey = base64.RawURLEncoding.EncodeToString(private.PublicKey().Bytes())
if _, err = New(store, identities, options); err != nil {
t.Fatalf("valid Web Push configuration: %v", err)
}
}
func TestIngestDoesNotExposeValuesInErrors(t *testing.T) {
root := filepath.Join(t.TempDir(), "data")
if err := os.Mkdir(root, 0o700); err != nil {
t.Fatal(err)
}
store, err := storage.Open(root)
if err != nil {
t.Fatal(err)
}
defer store.Close()
token, err := store.CreateSource(context.Background(), "source", model.Scope{OrganizationID: "org", ProjectID: "p", EnvironmentID: "prod", ServiceID: "s"})
if err != nil {
t.Fatal(err)
}
now := time.Now().UTC()
identities, err := identity.Open(root)
if err != nil {
t.Fatal(err)
}
defer identities.Close()
server, err := New(store, identities, testOptions())
if err != nil {
t.Fatal(err)
}
server.now = func() time.Time { return now }
batch := model.Batch{Version: 1, SourceID: "source", StreamID: "logs", Sequence: 1, ObservedAt: now, Signal: model.SignalLogs, Records: []model.Observation{{Timestamp: now, Name: "request", Body: "credential=do-not-echo"}}}
body, _ := json.Marshal(batch)
req := httptest.NewRequest(http.MethodPost, "/api/v1/ingest/native", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
rec := httptest.NewRecorder()
server.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusAccepted {
t.Fatalf("expected 202, got %d: %s", rec.Code, rec.Body.String())
}
req = httptest.NewRequest(http.MethodPost, "/api/v1/ingest/native", bytes.NewBufferString(`{"secret":"do-not-echo"}`))
req.Header.Set("Authorization", "Bearer "+token)
rec = httptest.NewRecorder()
server.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest || bytes.Contains(rec.Body.Bytes(), []byte("do-not-echo")) {
t.Fatalf("unsafe error response %d: %s", rec.Code, rec.Body.String())
}
}
func TestFramedNativeIngestAcknowledgesExactReplayAndOverlappingTime(t *testing.T) {
root := filepath.Join(t.TempDir(), "data")
if err := os.Mkdir(root, 0o700); err != nil {
t.Fatal(err)
}
store, err := storage.Open(root)
if err != nil {
t.Fatal(err)
}
defer store.Close()
token, err := store.CreateSource(context.Background(), "source", model.Scope{OrganizationID: "org", ProjectID: "p", EnvironmentID: "prod", ServiceID: "s"})
if err != nil {
t.Fatal(err)
}
identities, err := identity.Open(root)
if err != nil {
t.Fatal(err)
}
defer identities.Close()
now := time.Date(2026, 8, 18, 20, 0, 0, 0, time.UTC)
server, err := New(store, identities, testOptions())
if err != nil {
t.Fatal(err)
}
server.now = func() time.Time { return now }
handler := server.Handler()
send := func(batch model.Batch, body []byte, envelope model.BatchEnvelope) *httptest.ResponseRecorder {
t.Helper()
request := httptest.NewRequest(http.MethodPost, "https://observatory.example/api/v2/ingest/native", bytes.NewReader(body))
request.Header.Set("Authorization", "Bearer "+token)
request.Header.Set("Content-Type", "application/json")
nativeprotocol.SetHeaders(request.Header, envelope)
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
return response
}
batch := model.Batch{Version: model.BatchVersion, SourceID: "source", StreamID: "logs", Sequence: 1, ObservedAt: now, Signal: model.SignalLogs, Records: []model.Observation{{Timestamp: now, Name: "first"}}}
body, _ := json.Marshal(batch)
envelope, _ := batch.Envelope(body)
first := send(batch, body, envelope)
if first.Code != http.StatusAccepted || strings.Contains(first.Body.String(), `"duplicate":true`) {
t.Fatalf("first status=%d body=%s", first.Code, first.Body.String())
}
replay := send(batch, body, envelope)
if replay.Code != http.StatusAccepted || !strings.Contains(replay.Body.String(), `"duplicate":true`) {
t.Fatalf("replay status=%d body=%s", replay.Code, replay.Body.String())
}
tampered := append(append([]byte(nil), body...), ' ')
bad := send(batch, tampered, envelope)
if bad.Code != http.StatusBadRequest {
t.Fatalf("tampered status=%d body=%s", bad.Code, bad.Body.String())
}
batch.Sequence = 2
batch.ObservedAt = now.Add(time.Second)
// The second batch intentionally overlaps the first batch's observed time.
body, _ = json.Marshal(batch)
envelope, _ = batch.Envelope(body)
overlap := send(batch, body, envelope)
if overlap.Code != http.StatusAccepted {
t.Fatalf("overlap status=%d body=%s", overlap.Code, overlap.Body.String())
}
}
func TestOTLPHTTPIngestionIsAuthenticatedBoundedAndCompressed(t *testing.T) {
root := filepath.Join(t.TempDir(), "data")
if err := os.Mkdir(root, 0o700); err != nil {
t.Fatal(err)
}
store, err := storage.Open(root)
if err != nil {
t.Fatal(err)
}
defer store.Close()
scope := model.Scope{OrganizationID: "organization-a", ProjectID: "project-a", EnvironmentID: "production", ServiceID: "service-a"}
token, err := store.CreateSource(context.Background(), "otlp-source", scope)
if err != nil {
t.Fatal(err)
}
identities, err := identity.Open(root)
if err != nil {
t.Fatal(err)
}
defer identities.Close()
now := time.Date(2026, 8, 17, 3, 0, 0, 0, time.UTC)
server, err := New(store, identities, testOptions())
if err != nil {
t.Fatal(err)
}
server.now = func() time.Time { return now }
handler := server.Handler()
payload, err := proto.Marshal(&logspb.LogsData{ResourceLogs: []*logspb.ResourceLogs{{ScopeLogs: []*logspb.ScopeLogs{{LogRecords: []*logspb.LogRecord{{
TimeUnixNano: uint64(now.UnixNano()), EventName: "http.request", Body: &commonpb.AnyValue{Value: &commonpb.AnyValue_StringValue{StringValue: "accepted"}},
}}}}}}})
if err != nil {
t.Fatal(err)
}
send := func(body []byte, encoding string) *httptest.ResponseRecorder {
t.Helper()
request := httptest.NewRequest(http.MethodPost, "https://observatory.example/v1/logs", bytes.NewReader(body))
request.Header.Set("Authorization", "Bearer "+token)
request.Header.Set("Content-Type", "application/x-protobuf")
if encoding != "" {
request.Header.Set("Content-Encoding", encoding)
}
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
return response
}
plain := send(payload, "")
if plain.Code != http.StatusOK || plain.Header().Get("Content-Type") != "application/x-protobuf" || plain.Header().Get("Content-Length") != "0" || plain.Body.Len() != 0 {
t.Fatalf("plain status=%d headers=%v body=%q", plain.Code, plain.Header(), plain.Body.String())
}
var compressed bytes.Buffer
zipper := gzip.NewWriter(&compressed)
if _, err = zipper.Write(payload); err != nil {
t.Fatal(err)
}
if err = zipper.Close(); err != nil {
t.Fatal(err)
}
if response := send(compressed.Bytes(), "gzip"); response.Code != http.StatusOK {
t.Fatalf("gzip status=%d body=%s", response.Code, response.Body.String())
}
if err = store.Recover(context.Background()); err != nil {
t.Fatal(err)
}
if bytes, err := store.EstimateOrganizationBytes(scope.OrganizationID); err != nil || bytes == 0 {
t.Fatalf("projection bytes=%d err=%v", bytes, err)
}
unauthorized := httptest.NewRequest(http.MethodPost, "https://observatory.example/v1/logs", bytes.NewReader(payload))
unauthorized.Header.Set("Content-Type", "application/x-protobuf")
unauthorizedResult := httptest.NewRecorder()
handler.ServeHTTP(unauthorizedResult, unauthorized)
if unauthorizedResult.Code != http.StatusUnauthorized {
t.Fatalf("unauthorized status=%d", unauthorizedResult.Code)
}
wrongMedia := httptest.NewRequest(http.MethodPost, "https://observatory.example/v1/logs", bytes.NewReader(payload))
wrongMedia.Header.Set("Authorization", "Bearer "+token)
wrongMedia.Header.Set("Content-Type", "application/json")
wrongMediaResult := httptest.NewRecorder()
handler.ServeHTTP(wrongMediaResult, wrongMedia)
if wrongMediaResult.Code != http.StatusUnsupportedMediaType {
t.Fatalf("media status=%d", wrongMediaResult.Code)
}
compressed.Reset()
zipper = gzip.NewWriter(&compressed)
if _, err = zipper.Write(bytes.Repeat([]byte{'x'}, int(testOptions().MaxBodyBytes)+1)); err != nil {
t.Fatal(err)
}
if err = zipper.Close(); err != nil {
t.Fatal(err)
}
tooLarge := send(compressed.Bytes(), "gzip")
if tooLarge.Code != http.StatusRequestEntityTooLarge {
t.Fatalf("oversized status=%d body=%s", tooLarge.Code, tooLarge.Body.String())
}
}
func TestSessionAndScopedExplain(t *testing.T) {
root := filepath.Join(t.TempDir(), "data")
if err := os.Mkdir(root, 0o700); err != nil {
t.Fatal(err)
}
store, err := storage.Open(root)
if err != nil {
t.Fatal(err)
}
defer store.Close()
identities, err := identity.Open(root)
if err != nil {
t.Fatal(err)
}
defer identities.Close()
bootstrap, err := identities.Bootstrap(context.Background(), identity.BootstrapInput{Username: "operator", Email: "operator@example.test", DisplayName: "Operator", Password: "correct horse battery staple"})
if err != nil {
t.Fatal(err)
}
server, err := New(store, identities, testOptions())
if err != nil {
t.Fatal(err)
}
handler := server.Handler()
login := httptest.NewRequest(http.MethodPost, "https://observatory.example/api/v1/session", strings.NewReader(`{"identifier":"operator","password":"correct horse battery staple"}`))
login.Header.Set("Origin", "https://observatory.example")
loginResult := httptest.NewRecorder()
handler.ServeHTTP(loginResult, login)
if loginResult.Code != http.StatusOK || strings.Contains(loginResult.Body.String(), "correct horse") {
t.Fatalf("login status=%d body=%s", loginResult.Code, loginResult.Body.String())
}
var session struct {
CSRFToken string `json:"csrf_token"`
}
if err = json.Unmarshal(loginResult.Body.Bytes(), &session); err != nil || session.CSRFToken == "" {
t.Fatalf("session=%+v err=%v", session, err)
}
cookies := loginResult.Result().Cookies()
if len(cookies) != 1 || cookies[0].Name != "__Host-observatory_session" || !cookies[0].Secure || !cookies[0].HttpOnly {
t.Fatalf("cookies=%+v", cookies)
}
explainBody := fmt.Sprintf(`{"organization_id":%q,"query":"logs | where status >= 500 | limit 10"}`, bootstrap.Organization.ID)
explain := httptest.NewRequest(http.MethodPost, "https://observatory.example/api/v1/query/explain", strings.NewReader(explainBody))
explain.Header.Set("Origin", "https://observatory.example")
explain.AddCookie(cookies[0])
explainResult := httptest.NewRecorder()
handler.ServeHTTP(explainResult, explain)
if explainResult.Code != http.StatusOK || !strings.Contains(explainResult.Body.String(), `"projected_sources"`) {
t.Fatalf("explain status=%d body=%s", explainResult.Code, explainResult.Body.String())
}
now := time.Now().UTC()
sourceToken, err := store.CreateSource(context.Background(), "query-source", model.Scope{OrganizationID: bootstrap.Organization.ID, ProjectID: "project-a", EnvironmentID: "production", ServiceID: "service-a"})
if err != nil {
t.Fatal(err)
}
batch := model.Batch{Version: model.BatchVersion, SourceID: "query-source", StreamID: "requests", Sequence: 1, ObservedAt: now, Signal: model.SignalLogs, Records: []model.Observation{{Timestamp: now, Name: "application.http.request", Attributes: map[string]string{"http.status_code": "503", "http.route": "/failed", "workshop.queue_depth": "12"}}}}
if _, err = store.Ingest(context.Background(), sourceToken, batch, now); err != nil {
t.Fatal(err)
}
if err = store.Recover(context.Background()); err != nil {
t.Fatal(err)
}
execute := httptest.NewRequest(http.MethodPost, "https://observatory.example/api/v1/query", strings.NewReader(explainBody))
execute.Header.Set("Origin", "https://observatory.example")
execute.AddCookie(cookies[0])
executeResult := httptest.NewRecorder()
handler.ServeHTTP(executeResult, execute)
if executeResult.Code != http.StatusOK || !strings.Contains(executeResult.Body.String(), `"http.status_code"`) || !strings.Contains(executeResult.Body.String(), `"503"`) {
t.Fatalf("execute status=%d body=%s", executeResult.Code, executeResult.Body.String())
}
reviewed := schema.Descriptor{Version: schema.DescriptorVersion, Signal: model.SignalLogs, Field: "workshop.queue_depth", Type: schema.TypeInteger, Meaning: "Reviewed queue depth for one application service.", Sensitivity: schema.SensitivityInternal, Cardinality: schema.CardinalityLow, Index: schema.IndexRange, Retention: schema.RetentionRaw, ProjectionVersion: 1}
if _, err = store.ActivateDescriptor(context.Background(), bootstrap.Organization.ID, reviewed, now.Add(time.Second)); err != nil {
t.Fatal(err)
}
customBody := fmt.Sprintf(`{"organization_id":%q,"query":"logs | where workshop.queue_depth >= 10 | limit 10"}`, bootstrap.Organization.ID)
for _, path := range []string{"/api/v1/query/explain", "/api/v1/query"} {
request := httptest.NewRequest(http.MethodPost, "https://observatory.example"+path, strings.NewReader(customBody))
request.Header.Set("Origin", "https://observatory.example")
request.AddCookie(cookies[0])
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), `"workshop.queue_depth"`) || !strings.Contains(response.Body.String(), `"indexed":true`) {
t.Fatalf("path=%s status=%d body=%s", path, response.Code, response.Body.String())
}
}
for _, path := range []string{"/api/v1/query/explain", "/api/v1/query"} {
denied := httptest.NewRequest(http.MethodPost, "https://observatory.example"+path, strings.NewReader(`{"organization_id":"unowned1","query":"logs | limit 10"}`))
denied.Header.Set("Origin", "https://observatory.example")
denied.AddCookie(cookies[0])
deniedResult := httptest.NewRecorder()
handler.ServeHTTP(deniedResult, denied)
if deniedResult.Code != http.StatusForbidden {
t.Fatalf("path=%s cross-organization status=%d body=%s", path, deniedResult.Code, deniedResult.Body.String())
}
}
logout := httptest.NewRequest(http.MethodDelete, "https://observatory.example/api/v1/session", nil)
logout.Header.Set("Origin", "https://observatory.example")
logout.Header.Set("X-CSRF-Token", session.CSRFToken)
logout.AddCookie(cookies[0])
logoutResult := httptest.NewRecorder()
handler.ServeHTTP(logoutResult, logout)
if logoutResult.Code != http.StatusNoContent {
t.Fatalf("logout status=%d body=%s", logoutResult.Code, logoutResult.Body.String())
}
}
func TestCrossOriginLoginIsRejected(t *testing.T) {
root := filepath.Join(t.TempDir(), "data")
if err := os.Mkdir(root, 0o700); err != nil {
t.Fatal(err)
}
store, err := storage.Open(root)
if err != nil {
t.Fatal(err)
}
defer store.Close()
identities, err := identity.Open(root)
if err != nil {
t.Fatal(err)
}
defer identities.Close()
server, err := New(store, identities, testOptions())
if err != nil {
t.Fatal(err)
}
request := httptest.NewRequest(http.MethodPost, "https://observatory.example/api/v1/session", strings.NewReader(`{"identifier":"operator","password":"secret"}`))
request.Header.Set("Origin", "https://attacker.example")
response := httptest.NewRecorder()
server.Handler().ServeHTTP(response, request)
if response.Code != http.StatusForbidden {
t.Fatalf("status=%d body=%s", response.Code, response.Body.String())
}
}
func TestAgentEnrollmentIsSingleUseAndCredentialCanSelfRevoke(t *testing.T) {
root := filepath.Join(t.TempDir(), "data")
if err := os.Mkdir(root, 0o700); err != nil {
t.Fatal(err)
}
store, err := storage.Open(root)
if err != nil {
t.Fatal(err)
}
defer store.Close()
identities, err := identity.Open(root)
if err != nil {
t.Fatal(err)
}
defer identities.Close()
now := time.Date(2026, 8, 17, 2, 0, 0, 0, time.UTC)
enrollmentToken, _, err := store.CreateEnrollment(context.Background(), "source-a", model.Scope{OrganizationID: "organization-a", ProjectID: "project-a", EnvironmentID: "production", ServiceID: "service-a"}, "operator-a", 15*time.Minute, now)
if err != nil {
t.Fatal(err)
}
server, err := New(store, identities, testOptions())
if err != nil {
t.Fatal(err)
}
server.now = func() time.Time { return now.Add(time.Minute) }
enroll := httptest.NewRequest(http.MethodPost, "https://observatory.example/api/v1/agent/enroll", nil)
enroll.Header.Set("Authorization", "Bearer "+enrollmentToken)
response := httptest.NewRecorder()
server.Handler().ServeHTTP(response, enroll)
if response.Code != http.StatusCreated || strings.Contains(response.Body.String(), enrollmentToken) {
t.Fatalf("enroll status=%d body=%s", response.Code, response.Body.String())
}
var enrolled struct {
SourceID string `json:"source_id"`
Credential string `json:"credential"`
}
if err = json.Unmarshal(response.Body.Bytes(), &enrolled); err != nil || enrolled.SourceID != "source-a" || enrolled.Credential == "" {
t.Fatalf("enrolled=%+v err=%v", enrolled, err)
}
replay := httptest.NewRequest(http.MethodPost, "https://observatory.example/api/v1/agent/enroll", nil)
replay.Header.Set("Authorization", "Bearer "+enrollmentToken)
replayResponse := httptest.NewRecorder()
server.Handler().ServeHTTP(replayResponse, replay)
if replayResponse.Code != http.StatusUnauthorized {
t.Fatalf("replay status=%d", replayResponse.Code)
}
revoke := httptest.NewRequest(http.MethodDelete, "https://observatory.example/api/v1/agent/source", nil)
revoke.Header.Set("Authorization", "Bearer "+enrolled.Credential)
revokeResponse := httptest.NewRecorder()
server.Handler().ServeHTTP(revokeResponse, revoke)
if revokeResponse.Code != http.StatusNoContent {
t.Fatalf("revoke status=%d body=%s", revokeResponse.Code, revokeResponse.Body.String())
}
if _, err = store.Authenticate(context.Background(), enrolled.Credential); err == nil {
t.Fatal("revoked source credential remained active")
}
}
func testOptions() Options {
return Options{PublicOrigin: "https://observatory.example", MaxBodyBytes: 1 << 20, MaxQueryRows: 1000, SessionLifetime: time.Hour, QueryBudget: query.Budget{MaxDuration: 2 * time.Second, MaxRows: 1000, MaxScannedBytes: 10 << 20, MaxMemoryBytes: 8 << 20}}
}
+92
View File
@@ -0,0 +1,92 @@
// SPDX-License-Identifier: AGPL-3.0-only
package httpserver
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"gamertan.com/observatory/internal/identity"
"gamertan.com/observatory/internal/model"
"gamertan.com/observatory/internal/storage"
)
func TestAgentAlertTransitionIsAuthenticatedBoundedAndEvidenceBacked(t *testing.T) {
root := filepath.Join(t.TempDir(), "data")
if err := os.Mkdir(root, 0o700); err != nil {
t.Fatal(err)
}
store, err := storage.Open(root)
if err != nil {
t.Fatal(err)
}
defer store.Close()
identities, err := identity.Open(root)
if err != nil {
t.Fatal(err)
}
defer identities.Close()
now := time.Date(2026, 8, 18, 23, 45, 0, 0, time.UTC)
scope := model.Scope{OrganizationID: "organization-a", ProjectID: "project-a", EnvironmentID: "production", ServiceID: "service-a"}
token, err := store.CreateSource(context.Background(), "source-a", scope)
if err != nil {
t.Fatal(err)
}
saved, err := store.SaveQuery(context.Background(), storage.SavedQueryInput{OrganizationID: scope.OrganizationID, ActorUserID: "operator-a", MaxRows: 100, Name: "Logs", Query: "logs | limit 10", Scope: storage.ResourceScope{ProjectID: scope.ProjectID, EnvironmentID: scope.EnvironmentID, ServiceID: scope.ServiceID}}, now)
if err != nil {
t.Fatal(err)
}
rule, err := store.SaveAlertRule(context.Background(), storage.AlertRuleInput{OrganizationID: scope.OrganizationID, ActorUserID: "operator-a", SavedQueryID: saved.ID, Name: "Logs", Severity: "warning", MinimumMatches: 1, RequiredConsecutive: 1, EvaluationInterval: 15 * time.Second, Enabled: true}, now)
if err != nil {
t.Fatal(err)
}
batch := model.Batch{Version: model.BatchVersion, SourceID: "source-a", StreamID: "requests", Sequence: 1, ObservedAt: now, Signal: model.SignalLogs, Records: []model.Observation{{Timestamp: now, Name: "request"}}}
ingested, err := store.Ingest(context.Background(), token, batch, now)
if err != nil {
t.Fatal(err)
}
server, err := New(store, identities, testOptions())
if err != nil {
t.Fatal(err)
}
server.now = func() time.Time { return now }
handler := server.Handler()
transition := model.AlertTransition{Version: model.AlertTransitionVersion, RuleID: rule.ID, RuleRevision: rule.Revision, AgentEpoch: strings.Repeat("a", 32), Sequence: 1, StreamID: batch.StreamID, BatchSequence: batch.Sequence, SegmentDigest: ingested.Digest, WindowStart: now, WindowEnd: now, State: "matched", ObservedAt: now}
payload, err := json.Marshal(transition)
if err != nil {
t.Fatal(err)
}
request := httptest.NewRequest(http.MethodPost, "https://observatory.example/api/v1/agent/alert-transition", bytes.NewReader(payload))
request.Header.Set("Authorization", "Bearer "+token)
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code != http.StatusAccepted {
t.Fatalf("status=%d body=%s", response.Code, response.Body.String())
}
var ack storage.SourceAlertTransitionAck
if err = json.Unmarshal(response.Body.Bytes(), &ack); err != nil || ack.SourceID != "source-a" || ack.RuleID != rule.ID || ack.Duplicate {
t.Fatalf("ack=%+v err=%v", ack, err)
}
unauthorized := httptest.NewRequest(http.MethodPost, "https://observatory.example/api/v1/agent/alert-transition", bytes.NewReader([]byte(`{"state":"do-not-echo"}`)))
unauthorizedResult := httptest.NewRecorder()
handler.ServeHTTP(unauthorizedResult, unauthorized)
if unauthorizedResult.Code != http.StatusUnauthorized || strings.Contains(unauthorizedResult.Body.String(), "do-not-echo") {
t.Fatalf("unauthorized status=%d body=%q", unauthorizedResult.Code, unauthorizedResult.Body.String())
}
oversized := httptest.NewRequest(http.MethodPost, "https://observatory.example/api/v1/agent/alert-transition", strings.NewReader(strings.Repeat("x", (64<<10)+1)))
oversized.Header.Set("Authorization", "Bearer "+token)
oversizedResult := httptest.NewRecorder()
handler.ServeHTTP(oversizedResult, oversized)
if oversizedResult.Code != http.StatusBadRequest {
t.Fatalf("oversized status=%d body=%q", oversizedResult.Code, oversizedResult.Body.String())
}
}
+532
View File
@@ -0,0 +1,532 @@
// SPDX-License-Identifier: AGPL-3.0-only
package httpserver
import (
"bytes"
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"errors"
"fmt"
"io"
"mime"
"net/http"
"net/url"
"strings"
"time"
"unicode/utf8"
"gamertan.com/observatory/internal/identity"
"gamertan.com/observatory/internal/model"
"gamertan.com/observatory/internal/query"
"gamertan.com/observatory/internal/site"
"gamertan.com/sandwich-hime/sando"
"gamertan.com/web/access"
"gamertan.com/web/auth"
"gamertan.com/web/authhttp"
"gamertan.com/web/websec"
)
const loginCSRFCookieName = "__Host-observatory_login_csrf"
const (
loginFormFailure = "This sign-in form expired or could not be verified. Please try again."
loginCredentialFailure = "The username or password was not accepted."
passwordFormFailure = "This password form expired or could not be verified. Please try again."
passwordMatchFailure = "The new passwords did not match. Please enter them again."
passwordChangeFailure = "The password could not be changed. Check the temporary password and choose a different password of at least 12 characters."
)
func (s *Server) landing(w http.ResponseWriter, r *http.Request) {
if _, ok := auth.PrincipalFromContext(r.Context()); ok {
http.Redirect(w, r, "/app/", http.StatusSeeOther)
return
}
view := site.LandingView{Head: s.head("Gamertan Observatory", "A self-hosted observability platform in development for carefully operated Linux systems.", "/")}
s.renderHTML(w, r, http.StatusOK, site.Landing(view))
}
func (s *Server) loginPage(w http.ResponseWriter, r *http.Request) {
if principal, ok := auth.PrincipalFromContext(r.Context()); ok {
if principal.User.PasswordChangeRequired {
http.Redirect(w, r, "/account/password/", http.StatusSeeOther)
return
}
http.Redirect(w, r, "/app/", http.StatusSeeOther)
return
}
s.renderLogin(w, r, http.StatusOK, "")
}
func (s *Server) loginForm(w http.ResponseWriter, r *http.Request) {
values, err := readFormFields(w, r, 16<<10, []string{"identifier", "password"}, []string{"csrf_token"})
csrfOK := err == nil && validLoginCSRF(r, values.Get("csrf_token"))
if !tokenBoundFormRequest(r, s.options.PublicOrigin, csrfOK) {
s.renderLogin(w, r, http.StatusForbidden, loginFormFailure)
return
}
if err != nil {
s.renderLogin(w, r, http.StatusBadRequest, loginFormFailure)
return
}
token, principal, err := s.identity.Auth.Authenticate(r.Context(), values.Get("identifier"), values.Get("password"), s.options.SessionLifetime)
if err != nil {
s.renderLogin(w, r, http.StatusUnauthorized, loginCredentialFailure)
return
}
if err = authhttp.SetSession(w, s.cookie, token, s.now()); err != nil {
_ = s.identity.Auth.RevokeSession(r.Context(), token)
writeProblem(w, http.StatusServiceUnavailable, "session unavailable")
return
}
if _, cookieErr := r.Cookie(loginCSRFCookieName); cookieErr == nil {
clearLoginCSRF(w)
}
location := "/app/"
if principal.User.PasswordChangeRequired {
location = "/account/password/"
}
http.Redirect(w, r, location, http.StatusSeeOther)
}
// tokenBoundFormRequest makes the purpose-bound form token the primary CSRF
// proof. Browser origin metadata is defense in depth: an explicit cross-site
// or contradictory origin still fails closed, while absent or opaque metadata
// does not break an otherwise valid ordinary HTML form submission.
func tokenBoundFormRequest(r *http.Request, publicOrigin string, validToken bool) bool {
if !validToken {
return false
}
fetchSite := strings.ToLower(strings.TrimSpace(r.Header.Get("Sec-Fetch-Site")))
if fetchSite != "" && fetchSite != "same-origin" && fetchSite != "none" {
return false
}
origin := strings.TrimSpace(r.Header.Get("Origin"))
if origin == "" || origin == "null" {
return true
}
return websec.SameOrigin(r, publicOrigin)
}
func (s *Server) renderLogin(w http.ResponseWriter, r *http.Request, status int, message string) {
csrf, err := s.issueLoginCSRF(w)
if err != nil {
writeProblem(w, http.StatusServiceUnavailable, "login unavailable")
return
}
view := site.LoginView{Head: s.head("Sign in — Gamertan Observatory", "Sign in to the local Gamertan Observatory workshop.", "/login/"), CSRFToken: csrf, ErrorMessage: message}
s.renderHTML(w, r, status, site.Login(view))
}
func (s *Server) issueLoginCSRF(w http.ResponseWriter) (string, error) {
secret := make([]byte, 32)
if _, err := io.ReadFull(rand.Reader, secret); err != nil {
return "", errors.New("generate login CSRF token")
}
token := base64.RawURLEncoding.EncodeToString(secret)
http.SetCookie(w, &http.Cookie{Name: loginCSRFCookieName, Value: token, Path: "/", Secure: true, HttpOnly: true, SameSite: http.SameSiteStrictMode, Expires: s.now().Add(10 * time.Minute), MaxAge: 600})
return token, nil
}
func validLoginCSRF(r *http.Request, candidate string) bool {
cookie, err := r.Cookie(loginCSRFCookieName)
if err != nil {
return false
}
want, wantErr := base64.RawURLEncoding.DecodeString(cookie.Value)
got, gotErr := base64.RawURLEncoding.DecodeString(candidate)
return wantErr == nil && gotErr == nil && len(want) == 32 && len(got) == len(want) && subtle.ConstantTimeCompare(want, got) == 1
}
func clearLoginCSRF(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{Name: loginCSRFCookieName, Path: "/", Secure: true, HttpOnly: true, SameSite: http.SameSiteStrictMode, MaxAge: -1})
}
func (s *Server) passwordPage(w http.ResponseWriter, r *http.Request) {
principal, ok := auth.PrincipalFromContext(r.Context())
if !ok {
http.Redirect(w, r, "/login/", http.StatusSeeOther)
return
}
if !principal.User.PasswordChangeRequired {
http.Redirect(w, r, "/app/", http.StatusSeeOther)
return
}
s.renderPassword(w, r, http.StatusOK, "")
}
func (s *Server) passwordForm(w http.ResponseWriter, r *http.Request) {
principal, ok := auth.PrincipalFromContext(r.Context())
token, tokenOK := authhttp.SessionToken(r, s.cookie)
values, err := readForm(w, r, 8<<10, "csrf_token", "current_password", "new_password", "confirm_password")
csrfOK := err == nil && tokenOK && authhttp.VerifyCSRF(token, "account:password:change", values.Get("csrf_token"))
if !tokenBoundFormRequest(r, s.options.PublicOrigin, csrfOK) {
if ok && tokenOK && principal.User.PasswordChangeRequired {
s.renderPassword(w, r, http.StatusForbidden, passwordFormFailure)
} else {
http.Redirect(w, r, "/login/", http.StatusSeeOther)
}
return
}
if !ok || !tokenOK || !principal.User.PasswordChangeRequired {
writeProblem(w, http.StatusForbidden, "password change authorization required")
return
}
if err != nil || !csrfOK {
s.renderPassword(w, r, http.StatusBadRequest, passwordFormFailure)
return
}
if values.Get("new_password") != values.Get("confirm_password") {
s.renderPassword(w, r, http.StatusUnprocessableEntity, passwordMatchFailure)
return
}
if err = s.identity.Auth.ChangePassword(r.Context(), principal.User.ID, values.Get("current_password"), values.Get("new_password")); err != nil {
s.renderPassword(w, r, http.StatusUnprocessableEntity, passwordChangeFailure)
return
}
if err = authhttp.ClearSession(w, s.cookie); err != nil {
writeProblem(w, http.StatusServiceUnavailable, "session unavailable")
return
}
http.Redirect(w, r, "/login/?password=changed", http.StatusSeeOther)
}
func (s *Server) renderPassword(w http.ResponseWriter, r *http.Request, status int, message string) {
token, ok := authhttp.SessionToken(r, s.cookie)
if !ok {
writeProblem(w, http.StatusUnauthorized, "authentication required")
return
}
csrf, err := authhttp.CSRFToken(token, "account:password:change")
if err != nil {
writeProblem(w, http.StatusServiceUnavailable, "session unavailable")
return
}
view := site.PasswordView{Head: s.head("Choose your password — Gamertan Observatory", "Replace the one-time Observatory credential before continuing.", "/account/password/"), CSRFToken: csrf, ErrorMessage: message}
s.renderHTML(w, r, status, site.Password(view))
}
func (s *Server) logoutForm(w http.ResponseWriter, r *http.Request) {
values, err := readForm(w, r, 4<<10, "csrf_token")
token, ok := authhttp.SessionToken(r, s.cookie)
csrfOK := err == nil && ok && authhttp.VerifyCSRF(token, "session:delete", values.Get("csrf_token"))
if !tokenBoundFormRequest(r, s.options.PublicOrigin, csrfOK) {
writeProblem(w, http.StatusForbidden, "valid sign-out form required")
return
}
if err != nil {
writeProblem(w, http.StatusBadRequest, "invalid sign-out request")
return
}
if !csrfOK {
writeProblem(w, http.StatusForbidden, "valid session CSRF token required")
return
}
if err = s.identity.Auth.RevokeSession(r.Context(), token); err != nil && !errors.Is(err, auth.ErrSessionNotFound) {
writeProblem(w, http.StatusServiceUnavailable, "session unavailable")
return
}
if err = authhttp.ClearSession(w, s.cookie); err != nil {
writeProblem(w, http.StatusServiceUnavailable, "session unavailable")
return
}
http.Redirect(w, r, "/login/", http.StatusSeeOther)
}
func (s *Server) app(w http.ResponseWriter, r *http.Request) {
principal, ok := auth.PrincipalFromContext(r.Context())
if !ok {
http.Redirect(w, r, "/login/", http.StatusSeeOther)
return
}
organizations, err := s.identity.OrganizationsForUser(r.Context(), principal.User.ID)
if err != nil {
writeProblem(w, http.StatusServiceUnavailable, "organization list unavailable")
return
}
if len(organizations) == 0 {
writeProblem(w, http.StatusForbidden, "organization access required")
return
}
queryValues := r.URL.Query()
requested := queryValues.Get("organization")
if len(queryValues) > 0 {
selectedValues, exists := queryValues["organization"]
if !exists || len(queryValues) != 1 || len(selectedValues) != 1 || selectedValues[0] == "" {
writeProblem(w, http.StatusBadRequest, "invalid organization selection")
return
}
}
selected := organizations[0]
if requested != "" {
found := false
for _, organization := range organizations {
if organization.ID == requested {
selected, found = organization, true
break
}
}
if !found {
writeProblem(w, http.StatusForbidden, "organization access denied")
return
}
}
scope := access.Scope{OrganizationID: selected.ID}
decision, err := s.identity.Access.Authorize(r.Context(), principal.User.ID, scope, identity.PermissionDashboardsRead)
if err != nil || !decision.Allowed {
writeProblem(w, http.StatusForbidden, "dashboard access denied")
return
}
token, ok := authhttp.SessionToken(r, s.cookie)
if !ok {
writeProblem(w, http.StatusUnauthorized, "authentication required")
return
}
csrf, err := authhttp.CSRFToken(token, "session:delete")
if err != nil {
writeProblem(w, http.StatusServiceUnavailable, "session unavailable")
return
}
view := site.AppView{
Head: s.head("Overview — Gamertan Observatory", "Recent authorized telemetry and saved Observatory work.", "/app/"),
DisplayName: principal.User.DisplayName, CSRFToken: csrf,
EventsURL: "/app/events?organization=" + url.QueryEscape(selected.ID),
RefreshedAt: s.now().Format("2006-01-02 15:04:05 UTC"),
Organization: site.OrganizationOption{ID: selected.ID, Name: selected.Name, Selected: true},
IncidentsURL: "/app/incidents/?organization=" + url.QueryEscape(selected.ID),
}
projectionStatus, projectionErr := s.store.OrganizationProjectionStatus(r.Context(), selected.ID, s.now())
if projectionErr == nil && projectionStatus.PendingSegments > 0 {
view.PendingBatches = projectionStatus.PendingSegments
view.ProjectionLag = formatProjectionLag(projectionStatus.OldestPendingLag)
}
incidentDecision, err := s.identity.Access.Authorize(r.Context(), principal.User.ID, scope, identity.PermissionIncidentsRead)
if err != nil {
writeProblem(w, http.StatusServiceUnavailable, "authorization unavailable")
return
}
if incidentDecision.Allowed {
incidents, incidentErr := s.store.Incidents(r.Context(), selected.ID, false, 100)
if incidentErr != nil {
writeProblem(w, http.StatusServiceUnavailable, "incidents unavailable")
return
}
view.OpenIncidents = len(incidents)
}
manage, err := s.identity.Access.Authorize(r.Context(), principal.User.ID, scope, identity.PermissionDashboardsManage)
if err != nil {
writeProblem(w, http.StatusServiceUnavailable, "authorization unavailable")
return
}
view.CanManage = manage.Allowed
if view.CanManage {
view.ManageCSRF, err = authhttp.CSRFToken(token, "dashboards:manage")
if err != nil {
writeProblem(w, http.StatusServiceUnavailable, "session unavailable")
return
}
}
for _, organization := range organizations {
view.Organizations = append(view.Organizations, site.OrganizationOption{ID: organization.ID, Name: organization.Name, Selected: organization.ID == selected.ID})
}
view.Signals = s.overviewSignals(r, selected.ID)
saved, err := s.store.SavedQueries(r.Context(), selected.ID)
if err != nil {
writeProblem(w, http.StatusServiceUnavailable, "saved queries unavailable")
return
}
for _, item := range saved {
view.SavedQueries = append(view.SavedQueries, site.SavedQuerySummary{ID: item.ID, Name: item.Name, Description: item.Description, Query: item.Query})
}
dashboards, err := s.store.Dashboards(r.Context(), selected.ID)
if err != nil {
writeProblem(w, http.StatusServiceUnavailable, "dashboards unavailable")
return
}
for _, item := range dashboards {
view.Dashboards = append(view.Dashboards, site.DashboardSummary{Slug: item.Slug, Name: item.Name, Description: item.Description, PanelCount: len(item.Panels)})
}
s.renderHTML(w, r, http.StatusOK, site.App(view))
}
func formatProjectionLag(lag time.Duration) string {
if lag < time.Second {
return "less than one second"
}
return lag.Round(time.Second).String()
}
func (s *Server) overviewSignals(r *http.Request, organizationID string) []site.SignalView {
definitions := []struct {
signal model.Signal
id, name string
description string
}{
{model.SignalLogs, "logs", "logs", "Recent structured events accepted for this organization."},
{model.SignalMetrics, "metrics", "metrics", "Recent numeric observations with a table alternative."},
{model.SignalTraces, "traces", "traces", "Recent spans and their correlation identities."},
{model.SignalDeployments, "deployments", "deployments", "Recent bounded deployment evidence."},
}
views := make([]site.SignalView, 0, len(definitions))
for _, definition := range definitions {
text := string(definition.signal) + " | window 1h | limit 20"
ast, err := query.Parse(text, s.options.MaxQueryRows)
var result query.Result
if err == nil {
result, err = s.store.Query(r.Context(), ast, query.Scope{OrganizationID: organizationID}, s.options.QueryBudget, s.now())
}
table := site.TableView{Caption: "Recent " + definition.name, Columns: []site.TableColumn{{Label: "Status"}}, Empty: "No observations are available in the last hour."}
if err != nil {
table.Empty = "This bounded query is temporarily unavailable."
} else {
table = resultTable("Recent "+definition.name, result)
}
views = append(views, site.SignalView{ID: definition.id, Name: definition.name, Description: definition.description, Query: text, Table: table})
}
return views
}
func resultTable(caption string, result query.Result) site.TableView {
table := site.TableView{Caption: caption, Empty: "No observations are available in the last hour."}
for _, column := range result.Columns {
table.Columns = append(table.Columns, site.TableColumn{Label: column.Field, Unit: column.Unit})
}
for _, row := range result.Rows {
view := site.TableRow{Values: make([]string, len(result.Columns))}
for index := range view.Values {
view.Values[index] = "—"
if index < len(row.Values) && row.Values[index] != nil {
view.Values[index] = boundedCell(*row.Values[index])
}
}
table.Rows = append(table.Rows, view)
}
return table
}
func boundedCell(value string) string {
const maxRunes = 256
if utf8.RuneCountInString(value) <= maxRunes {
return value
}
runes := []rune(value)
return string(runes[:maxRunes]) + "…"
}
func (s *Server) events(w http.ResponseWriter, r *http.Request) {
principal, ok := auth.PrincipalFromContext(r.Context())
if !ok {
writeProblem(w, http.StatusUnauthorized, "authentication required")
return
}
organizationID := r.URL.Query().Get("organization")
if len(r.URL.Query()) != 1 || len(r.URL.Query()["organization"]) != 1 {
writeProblem(w, http.StatusBadRequest, "organization is required")
return
}
scope := access.Scope{OrganizationID: organizationID}
if err := s.identity.ValidateResourceScope(r.Context(), scope); err != nil {
writeProblem(w, http.StatusBadRequest, "invalid organization")
return
}
decision, err := s.identity.Access.Authorize(r.Context(), principal.User.ID, scope, identity.PermissionDashboardsRead)
if err != nil || !decision.Allowed {
writeProblem(w, http.StatusForbidden, "dashboard access denied")
return
}
updates, remove, err := s.refresh.subscribe(organizationID)
if err != nil {
writeProblem(w, http.StatusServiceUnavailable, "live refresh capacity reached")
return
}
defer remove()
flusher, ok := w.(http.Flusher)
if !ok {
writeProblem(w, http.StatusNotImplemented, "streaming unavailable")
return
}
w.Header().Set("Content-Type", "text/event-stream; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("X-Accel-Buffering", "no")
_, _ = io.WriteString(w, "event: ready\ndata: {}\n\n")
flusher.Flush()
heartbeat := time.NewTicker(20 * time.Second)
defer heartbeat.Stop()
for {
select {
case <-r.Context().Done():
return
case <-updates:
_, _ = io.WriteString(w, "event: refresh\ndata: {}\n\n")
flusher.Flush()
case <-heartbeat.C:
_, _ = io.WriteString(w, ": keepalive\n\n")
flusher.Flush()
}
}
}
func (s *Server) serveAsset(w http.ResponseWriter, r *http.Request) {
body, contentType, ok := site.Asset(r.URL.Path)
if !ok {
http.NotFound(w, r)
return
}
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
w.Header().Set("Content-Type", contentType)
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(body)))
if r.Method != http.MethodHead {
_, _ = w.Write(body)
}
}
func (s *Server) head(title, description, path string) site.HeadView {
return site.HeadView{Title: title, Description: description, CanonicalURL: s.options.PublicOrigin + path, Assets: site.AssetPaths()}
}
func (s *Server) renderHTML(w http.ResponseWriter, r *http.Request, status int, component sando.Component) {
var body bytes.Buffer
if err := sando.Render(r.Context(), &body, component); err != nil {
writeProblem(w, http.StatusInternalServerError, "interface render unavailable")
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Content-Length", fmt.Sprintf("%d", body.Len()))
w.WriteHeader(status)
if r.Method != http.MethodHead {
_, _ = w.Write(body.Bytes())
}
}
func readForm(w http.ResponseWriter, r *http.Request, limit int64, fields ...string) (url.Values, error) {
return readFormFields(w, r, limit, fields, nil)
}
func readFormFields(w http.ResponseWriter, r *http.Request, limit int64, required, optional []string) (url.Values, error) {
mediaType, parameters, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
if err != nil || mediaType != "application/x-www-form-urlencoded" || len(parameters) > 1 || len(parameters) == 1 && !strings.EqualFold(parameters["charset"], "utf-8") {
return nil, errors.New("URL-encoded form required")
}
body := http.MaxBytesReader(w, r.Body, limit)
defer body.Close()
encoded, err := io.ReadAll(body)
if err != nil || !utf8.Valid(encoded) {
return nil, errors.New("invalid form body")
}
values, err := url.ParseQuery(string(encoded))
if err != nil || len(values) != len(required)+len(optional) {
return nil, errors.New("invalid form fields")
}
for _, field := range required {
if len(values[field]) != 1 || values.Get(field) == "" {
return nil, errors.New("invalid form field")
}
}
for _, field := range optional {
if len(values[field]) != 1 {
return nil, errors.New("invalid form field")
}
}
return values, nil
}
+1122
View File
@@ -0,0 +1,1122 @@
// SPDX-License-Identifier: AGPL-3.0-only
package httpserver
import (
"bytes"
"context"
"crypto/ecdh"
"crypto/rand"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"html"
"io"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"testing"
"time"
"gamertan.com/observatory/internal/identity"
"gamertan.com/observatory/internal/model"
"gamertan.com/observatory/internal/query"
"gamertan.com/observatory/internal/schema"
"gamertan.com/observatory/internal/site"
"gamertan.com/observatory/internal/storage"
"gamertan.com/web/authhttp"
)
func TestHandlerAssignsFreshBoundedRequestIDs(t *testing.T) {
server, store, identities, _ := newUITestServer(t)
defer store.Close()
defer identities.Close()
handler := server.Handler()
var previous string
for _, test := range []struct {
method string
target string
}{
{http.MethodGet, "https://observatory.example/healthz"},
{http.MethodHead, "https://observatory.example/readyz"},
{http.MethodGet, "https://observatory.example/"},
{http.MethodGet, "https://observatory.example/not-found"},
} {
header := http.Header{"X-Request-ID": []string{"attacker-selected"}}
response := perform(handler, test.method, test.target, nil, nil, header)
requestID := response.Header().Get("X-Request-ID")
decoded, err := hex.DecodeString(requestID)
if err != nil || len(decoded) != 16 || requestID == "attacker-selected" || requestID == previous {
t.Fatalf("%s %s request_id=%q decoded=%d err=%v previous=%q", test.method, test.target, requestID, len(decoded), err, previous)
}
previous = requestID
}
}
func TestHTMLInterfaceAuthenticationAssetsAndOverview(t *testing.T) {
server, store, identities, bootstrap := newUITestServer(t)
defer store.Close()
defer identities.Close()
handler := server.Handler()
landing := perform(handler, http.MethodGet, "https://observatory.example/", nil, nil)
if landing.Code != http.StatusOK || landing.Header().Get("Content-Type") != "text/html; charset=utf-8" || !strings.Contains(landing.Body.String(), "Keep the evidence close.") {
t.Fatalf("landing status=%d body=%s", landing.Code, landing.Body.String())
}
csp := landing.Header().Get("Content-Security-Policy")
if strings.Contains(csp, "unsafe-inline") || !strings.Contains(csp, "script-src 'self'") || !strings.Contains(csp, "style-src 'self'") || !strings.Contains(csp, "manifest-src 'self'") || !strings.Contains(csp, "worker-src 'self'") || strings.Contains(landing.Body.String(), "<style") {
t.Fatalf("CSP=%q body=%s", csp, landing.Body.String())
}
if !strings.Contains(landing.Body.String(), `<link rel="manifest" href="/manifest.webmanifest">`) {
t.Fatalf("landing omitted manifest discovery: %s", landing.Body.String())
}
head := perform(handler, http.MethodHead, "https://observatory.example/", nil, nil)
if head.Code != http.StatusOK || head.Body.Len() != 0 || head.Header().Get("Content-Length") != landing.Header().Get("Content-Length") {
t.Fatalf("HEAD status=%d length=%q body=%d", head.Code, head.Header().Get("Content-Length"), head.Body.Len())
}
for _, path := range []string{site.AssetPaths().StylePath, site.AssetPaths().ScriptPath, site.AssetPaths().IconPath} {
asset := perform(handler, http.MethodGet, "https://observatory.example"+path, nil, nil)
if asset.Code != http.StatusOK || asset.Body.Len() == 0 || asset.Header().Get("Cache-Control") != "public, max-age=31536000, immutable" {
t.Fatalf("asset %s status=%d cache=%q", path, asset.Code, asset.Header().Get("Cache-Control"))
}
assetHead := perform(handler, http.MethodHead, "https://observatory.example"+path, nil, nil)
if assetHead.Code != http.StatusOK || assetHead.Body.Len() != 0 || assetHead.Header().Get("Content-Length") != asset.Header().Get("Content-Length") {
t.Fatalf("asset HEAD %s status=%d", path, assetHead.Code)
}
}
manifest := perform(handler, http.MethodGet, "https://observatory.example/manifest.webmanifest", nil, nil)
if manifest.Code != http.StatusOK || manifest.Header().Get("Content-Type") != "application/manifest+json" || !strings.Contains(manifest.Body.String(), site.AssetPaths().IconPath) {
t.Fatalf("manifest status=%d headers=%v body=%s", manifest.Code, manifest.Header(), manifest.Body.String())
}
manifestHead := perform(handler, http.MethodHead, "https://observatory.example/manifest.webmanifest", nil, nil)
if manifestHead.Code != http.StatusOK || manifestHead.Body.Len() != 0 || manifestHead.Header().Get("Content-Length") != manifest.Header().Get("Content-Length") {
t.Fatalf("manifest HEAD status=%d body=%d", manifestHead.Code, manifestHead.Body.Len())
}
worker := perform(handler, http.MethodGet, "https://observatory.example/service-worker.js", nil, nil)
if worker.Code != http.StatusOK || worker.Header().Get("Content-Type") != "text/javascript; charset=utf-8" || worker.Header().Get("Cache-Control") != "no-cache" || worker.Header().Get("Service-Worker-Allowed") != "/" || !strings.Contains(worker.Body.String(), "cache-inbox") {
t.Fatalf("worker status=%d headers=%v", worker.Code, worker.Header())
}
offline := perform(handler, http.MethodGet, "https://observatory.example/offline/", nil, nil)
if offline.Code != http.StatusOK || !strings.Contains(offline.Body.String(), "The evidence is still safe.") {
t.Fatalf("offline status=%d body=%s", offline.Code, offline.Body.String())
}
disabledPush := perform(handler, http.MethodPost, "https://observatory.example/api/v1/push/subscription", strings.NewReader(`{}`), nil)
if disabledPush.Code != http.StatusNotFound {
t.Fatalf("disabled push status=%d body=%s", disabledPush.Code, disabledPush.Body.String())
}
unauthenticated := perform(handler, http.MethodGet, "https://observatory.example/app/", nil, nil)
if unauthenticated.Code != http.StatusSeeOther || unauthenticated.Header().Get("Location") != "/login/" {
t.Fatalf("unauthenticated status=%d location=%q", unauthenticated.Code, unauthenticated.Header().Get("Location"))
}
extraQuery := perform(handler, http.MethodGet, "https://observatory.example/app/?unexpected=true", nil, []*http.Cookie{loginHTML(t, handler)})
if extraQuery.Code != http.StatusBadRequest {
t.Fatalf("unexpected query status=%d", extraQuery.Code)
}
loginCookie := loginHTML(t, handler)
now := server.now()
token, err := store.CreateSource(context.Background(), "ui-source", model.Scope{OrganizationID: bootstrap.Organization.ID, ProjectID: "project-a", EnvironmentID: "production", ServiceID: "service-a"})
if err != nil {
t.Fatal(err)
}
batch := model.Batch{Version: model.BatchVersion, SourceID: "ui-source", StreamID: "requests", Sequence: 1, ObservedAt: now, Signal: model.SignalLogs, Records: []model.Observation{{Timestamp: now, Name: "application.http.request", Attributes: map[string]string{"http.status_code": "200", "http.route": "/"}}}}
body, _ := json.Marshal(batch)
updates, remove, err := server.refresh.subscribe(bootstrap.Organization.ID)
if err != nil {
t.Fatal(err)
}
defer remove()
ingestHeaders := http.Header{"Authorization": []string{"Bearer " + token}, "Content-Type": []string{"application/json"}}
ingest := perform(handler, http.MethodPost, "https://observatory.example/api/v1/ingest/native", bytes.NewReader(body), nil, ingestHeaders)
if ingest.Code != http.StatusAccepted {
t.Fatalf("ingest status=%d body=%s", ingest.Code, ingest.Body.String())
}
pending := perform(handler, http.MethodGet, "https://observatory.example/app/", nil, []*http.Cookie{loginCookie})
if pending.Code != http.StatusOK || !strings.Contains(pending.Body.String(), "Durable evidence is still being indexed.") || !strings.Contains(pending.Body.String(), "Accepted batches safely stored: 1.") || !strings.Contains(pending.Body.String(), `role="status"`) {
t.Fatalf("pending app status=%d body=%s", pending.Code, pending.Body.String())
}
if err = store.Recover(context.Background()); err != nil {
t.Fatal(err)
}
select {
case <-updates:
default:
t.Fatal("successful ingest did not publish organization refresh")
}
app := perform(handler, http.MethodGet, "https://observatory.example/app/", nil, []*http.Cookie{loginCookie})
if app.Code != http.StatusOK || !strings.Contains(app.Body.String(), bootstrap.Organization.Name) || !strings.Contains(app.Body.String(), "application.http.request") || !strings.Contains(app.Body.String(), "Recent metrics") || !strings.Contains(app.Body.String(), "Recent traces") || !strings.Contains(app.Body.String(), "Recent deployments") || !strings.Contains(app.Body.String(), `action="/app/queries/builder/"`) || !strings.Contains(app.Body.String(), "Build a query") {
t.Fatalf("app status=%d body=%s", app.Code, app.Body.String())
}
if strings.Contains(app.Body.String(), "Durable evidence is still being indexed.") {
t.Fatalf("app retained indexing status after projection completed: %s", app.Body.String())
}
for _, forbidden := range []string{"Render #", "request number", "position:sticky", "unsafe-inline"} {
if strings.Contains(app.Body.String(), forbidden) {
t.Fatalf("app exposed forbidden marker %q", forbidden)
}
}
appHead := perform(handler, http.MethodHead, "https://observatory.example/app/", nil, []*http.Cookie{loginCookie})
if appHead.Code != http.StatusOK || appHead.Body.Len() != 0 || appHead.Header().Get("Content-Length") != app.Header().Get("Content-Length") {
t.Fatalf("app HEAD status=%d body=%d", appHead.Code, appHead.Body.Len())
}
sessionToken := loginCookie.Value
csrf, err := authhttp.CSRFToken(sessionToken, "session:delete")
if err != nil {
t.Fatal(err)
}
logout := url.Values{"csrf_token": []string{csrf}}.Encode()
logoutHeaders := http.Header{"Origin": []string{"null"}, "Content-Type": []string{"application/x-www-form-urlencoded"}}
loggedOut := perform(handler, http.MethodPost, "https://observatory.example/logout/", strings.NewReader(logout), []*http.Cookie{loginCookie}, logoutHeaders)
if loggedOut.Code != http.StatusSeeOther || loggedOut.Header().Get("Location") != "/login/" {
t.Fatalf("logout status=%d location=%q", loggedOut.Code, loggedOut.Header().Get("Location"))
}
}
func TestExploreWorkbenchUsesBoundedServerRenderedQueries(t *testing.T) {
server, store, identities, bootstrap := newUITestServer(t)
defer store.Close()
defer identities.Close()
handler := server.Handler()
unauthenticated := perform(handler, http.MethodGet, "https://observatory.example/app/explore/?organization="+url.QueryEscape(bootstrap.Organization.ID), nil, nil)
if unauthenticated.Code != http.StatusSeeOther || unauthenticated.Header().Get("Location") != "/login/" {
t.Fatalf("unauthenticated status=%d location=%q", unauthenticated.Code, unauthenticated.Header().Get("Location"))
}
cookie := loginHTML(t, handler)
missingOrganization := perform(handler, http.MethodGet, "https://observatory.example/app/explore/", nil, []*http.Cookie{cookie})
if missingOrganization.Code != http.StatusBadRequest {
t.Fatalf("missing organization status=%d body=%s", missingOrganization.Code, missingOrganization.Body.String())
}
target := "https://observatory.example/app/explore/?organization=" + url.QueryEscape(bootstrap.Organization.ID)
page := perform(handler, http.MethodGet, target, nil, []*http.Cookie{cookie})
pageBody := page.Body.String()
currentLink := `<a aria-current="page" href="/app/explore/?organization=` + bootstrap.Organization.ID + `">Explore</a>`
if page.Code != http.StatusOK || !strings.Contains(pageBody, "Follow the evidence.") || !strings.Contains(pageBody, currentLink) || !strings.Contains(pageBody, defaultExploreQuery) || strings.Contains(pageBody, "Authorized query results") {
t.Fatalf("explore status=%d body=%s", page.Code, pageBody)
}
if !strings.Contains(pageBody, `method="post" action="/app/explore/?organization=`+bootstrap.Organization.ID+`"`) || strings.Contains(pageBody, "?query=") {
t.Fatalf("explore form did not keep query in POST body: %s", pageBody)
}
head := perform(handler, http.MethodHead, target, nil, []*http.Cookie{cookie})
if head.Code != http.StatusOK || head.Body.Len() != 0 || head.Header().Get("Content-Length") != page.Header().Get("Content-Length") {
t.Fatalf("explore HEAD status=%d length=%q body=%d", head.Code, head.Header().Get("Content-Length"), head.Body.Len())
}
now := server.now()
sourceToken, err := store.CreateSource(context.Background(), "explore-source", model.Scope{OrganizationID: bootstrap.Organization.ID, ProjectID: "project-a", EnvironmentID: "production", ServiceID: "service-a"})
if err != nil {
t.Fatal(err)
}
batch := model.Batch{Version: model.BatchVersion, SourceID: "explore-source", StreamID: "requests", Sequence: 1, ObservedAt: now, Signal: model.SignalLogs, Records: []model.Observation{{Timestamp: now, Name: "application.http.request", Attributes: map[string]string{"http.status_code": "200", "http.route": "/explore-proof"}}}}
if _, err = store.Ingest(context.Background(), sourceToken, batch, now); err != nil {
t.Fatal(err)
}
if err = store.Recover(context.Background()); err != nil {
t.Fatal(err)
}
csrf, err := authhttp.CSRFToken(cookie.Value, "query:execute")
if err != nil {
t.Fatal(err)
}
queryText := `logs | where route == "/explore-proof" | window 1h | limit 10`
form := url.Values{"csrf_token": []string{csrf}, "query": []string{queryText}}.Encode()
headers := http.Header{"Content-Type": []string{"application/x-www-form-urlencoded"}}
result := perform(handler, http.MethodPost, target, strings.NewReader(form), []*http.Cookie{cookie}, headers)
resultBody := result.Body.String()
for _, required := range []string{"Authorized query results", "/explore-proof", "Scanned rows", "Matched rows", "Scanned bytes", "Execution", html.EscapeString(queryText)} {
if !strings.Contains(resultBody, required) {
t.Fatalf("result omitted %q: status=%d body=%s", required, result.Code, resultBody)
}
}
if result.Code != http.StatusOK || strings.Contains(result.Header().Get("Content-Type"), "application/json") {
t.Fatalf("query status=%d type=%q body=%s", result.Code, result.Header().Get("Content-Type"), resultBody)
}
invalidForm := url.Values{"csrf_token": []string{"invalid"}, "query": []string{"logs | limit 10"}}.Encode()
invalid := perform(handler, http.MethodPost, target, strings.NewReader(invalidForm), []*http.Cookie{cookie}, headers)
if invalid.Code != http.StatusForbidden || !strings.Contains(invalid.Body.String(), "query form expired") || strings.Contains(invalid.Header().Get("Content-Type"), "application/json") {
t.Fatalf("invalid CSRF status=%d body=%s", invalid.Code, invalid.Body.String())
}
badQuery := url.Values{"csrf_token": []string{csrf}, "query": []string{"logs | become unbounded"}}.Encode()
rejected := perform(handler, http.MethodPost, target, strings.NewReader(badQuery), []*http.Cookie{cookie}, headers)
if rejected.Code != http.StatusUnprocessableEntity || !strings.Contains(rejected.Body.String(), "could not be parsed") || !strings.Contains(rejected.Body.String(), "logs | become unbounded") {
t.Fatalf("rejected query status=%d body=%s", rejected.Code, rejected.Body.String())
}
}
func TestHTMLLoginFailsClosed(t *testing.T) {
server, store, identities, _ := newUITestServer(t)
defer store.Close()
defer identities.Close()
handler := server.Handler()
wrongOrigin := http.Header{"Origin": []string{"https://attacker.example"}, "Content-Type": []string{"application/x-www-form-urlencoded"}}
result := perform(handler, http.MethodPost, "https://observatory.example/login/", strings.NewReader("identifier=operator&password=do-not-echo"), nil, wrongOrigin)
if result.Code != http.StatusForbidden || !strings.Contains(result.Body.String(), loginFormFailure) || strings.Contains(result.Body.String(), "do-not-echo") || strings.Contains(result.Header().Get("Content-Type"), "application/json") {
t.Fatalf("wrong origin status=%d body=%s", result.Code, result.Body.String())
}
csrfCookie, csrfToken := loginFormCSRF(t, handler)
extraField := http.Header{"Origin": []string{"https://observatory.example"}, "Content-Type": []string{"application/x-www-form-urlencoded"}}
extraForm := url.Values{"csrf_token": []string{csrfToken}, "identifier": []string{"operator"}, "password": []string{"wrong"}, "next": []string{"https://attacker.example"}}.Encode()
result = perform(handler, http.MethodPost, "https://observatory.example/login/", strings.NewReader(extraForm), []*http.Cookie{csrfCookie}, extraField)
if result.Code != http.StatusForbidden || !strings.Contains(result.Body.String(), loginFormFailure) || strings.Contains(result.Body.String(), "attacker.example") || result.Header().Get("Location") != "" {
t.Fatalf("extra field status=%d location=%q body=%s", result.Code, result.Header().Get("Location"), result.Body.String())
}
}
func TestHTMLLoginUsesTokenWhenBrowserOmitsOrObscuresOriginMetadata(t *testing.T) {
server, store, identities, _ := newUITestServer(t)
defer store.Close()
defer identities.Close()
handler := server.Handler()
page := perform(handler, http.MethodGet, "https://observatory.example/login/", nil, nil)
if page.Code != http.StatusOK {
t.Fatalf("login page status=%d body=%s", page.Code, page.Body.String())
}
var csrfCookie *http.Cookie
for _, cookie := range page.Result().Cookies() {
if cookie.Name == loginCSRFCookieName {
csrfCookie = cookie
}
}
if csrfCookie == nil || !csrfCookie.Secure || !csrfCookie.HttpOnly || csrfCookie.SameSite != http.SameSiteStrictMode || csrfCookie.MaxAge != 600 {
t.Fatalf("login CSRF cookie=%+v", csrfCookie)
}
const marker = `name="csrf_token" value="`
start := strings.Index(page.Body.String(), marker)
if start < 0 {
t.Fatalf("login page omitted CSRF token: %s", page.Body.String())
}
start += len(marker)
end := strings.IndexByte(page.Body.String()[start:], '"')
if end < 0 {
t.Fatal("login page CSRF token is unterminated")
}
token := page.Body.String()[start : start+end]
if token == "" || token != csrfCookie.Value {
t.Fatal("login form and cookie CSRF tokens differ")
}
form := url.Values{"csrf_token": []string{token}, "identifier": []string{"not-a-user"}, "password": []string{"not-a-password"}}.Encode()
contentType := http.Header{"Content-Type": []string{"application/x-www-form-urlencoded"}}
omittedMetadata := perform(handler, http.MethodPost, "https://observatory.example/login/", strings.NewReader(form), []*http.Cookie{csrfCookie}, contentType)
if omittedMetadata.Code != http.StatusUnauthorized || strings.Contains(omittedMetadata.Body.String(), "not-a-password") {
t.Fatalf("origin-metadata fallback status=%d body=%s", omittedMetadata.Code, omittedMetadata.Body.String())
}
opaqueSameOriginHeaders := contentType.Clone()
opaqueSameOriginHeaders.Set("Origin", "null")
opaqueSameOriginHeaders.Set("Sec-Fetch-Site", "same-origin")
opaqueSameOrigin := perform(handler, http.MethodPost, "https://observatory.example/login/", strings.NewReader(form), []*http.Cookie{csrfCookie}, opaqueSameOriginHeaders)
if opaqueSameOrigin.Code != http.StatusUnauthorized || strings.Contains(opaqueSameOrigin.Body.String(), "not-a-password") {
t.Fatalf("opaque same-origin fallback status=%d body=%s", opaqueSameOrigin.Code, opaqueSameOrigin.Body.String())
}
withoutToken := perform(handler, http.MethodPost, "https://observatory.example/login/", strings.NewReader("identifier=not-a-user&password=not-a-password"), nil, contentType)
if withoutToken.Code != http.StatusForbidden {
t.Fatalf("originless tokenless status=%d body=%s", withoutToken.Code, withoutToken.Body.String())
}
opaqueWithoutToken := perform(handler, http.MethodPost, "https://observatory.example/login/", strings.NewReader("identifier=not-a-user&password=not-a-password"), nil, opaqueSameOriginHeaders)
if opaqueWithoutToken.Code != http.StatusForbidden {
t.Fatalf("opaque tokenless status=%d body=%s", opaqueWithoutToken.Code, opaqueWithoutToken.Body.String())
}
opaqueWithoutFetchMetadata := contentType.Clone()
opaqueWithoutFetchMetadata.Set("Origin", "null")
opaqueTokenOnly := perform(handler, http.MethodPost, "https://observatory.example/login/", strings.NewReader(form), []*http.Cookie{csrfCookie}, opaqueWithoutFetchMetadata)
if opaqueTokenOnly.Code != http.StatusUnauthorized || !strings.Contains(opaqueTokenOnly.Body.String(), loginCredentialFailure) {
t.Fatalf("opaque origin without same-origin fetch metadata status=%d body=%s", opaqueTokenOnly.Code, opaqueTokenOnly.Body.String())
}
wrongOrigin := contentType.Clone()
wrongOrigin.Set("Origin", "https://attacker.example")
crossSite := perform(handler, http.MethodPost, "https://observatory.example/login/", strings.NewReader(form), []*http.Cookie{csrfCookie}, wrongOrigin)
if crossSite.Code != http.StatusForbidden {
t.Fatalf("cross-site token replay status=%d body=%s", crossSite.Code, crossSite.Body.String())
}
}
func TestTemporaryOperatorMustRotatePasswordBeforeUsingApplication(t *testing.T) {
server, store, identities, _ := newRotationTestServer(t)
defer store.Close()
defer identities.Close()
handler := server.Handler()
const temporary = "temporary correct horse battery staple"
const replacement = "permanent correct horse battery staple"
headers := http.Header{"Origin": []string{"https://observatory.example"}, "Content-Type": []string{"application/x-www-form-urlencoded"}}
login := loginHTMLResponse(t, handler, "operator", temporary)
if login.Code != http.StatusSeeOther || login.Header().Get("Location") != "/account/password/" || strings.Contains(login.Body.String(), temporary) {
t.Fatalf("login status=%d location=%q body=%s", login.Code, login.Header().Get("Location"), login.Body.String())
}
var cookie *http.Cookie
for _, candidate := range login.Result().Cookies() {
if candidate.Name == "__Host-observatory_session" {
cookie = candidate
break
}
}
if cookie == nil {
t.Fatalf("login cookies=%+v", login.Result().Cookies())
}
blocked := perform(handler, http.MethodGet, "https://observatory.example/app/", nil, []*http.Cookie{cookie})
if blocked.Code != http.StatusSeeOther || blocked.Header().Get("Location") != "/account/password/" {
t.Fatalf("blocked app status=%d location=%q", blocked.Code, blocked.Header().Get("Location"))
}
blockedWrite := perform(handler, http.MethodPost, "https://observatory.example/api/v1/query", strings.NewReader(`{}`), []*http.Cookie{cookie}, headers)
if blockedWrite.Code != http.StatusForbidden {
t.Fatalf("blocked write status=%d body=%s", blockedWrite.Code, blockedWrite.Body.String())
}
page := perform(handler, http.MethodGet, "https://observatory.example/account/password/", nil, []*http.Cookie{cookie})
if page.Code != http.StatusOK || !strings.Contains(page.Body.String(), "Choose your password") || strings.Contains(page.Body.String(), temporary) {
t.Fatalf("password page status=%d body=%s", page.Code, page.Body.String())
}
head := perform(handler, http.MethodHead, "https://observatory.example/account/password/", nil, []*http.Cookie{cookie})
if head.Code != http.StatusOK || head.Body.Len() != 0 || head.Header().Get("Content-Length") != page.Header().Get("Content-Length") {
t.Fatalf("password HEAD status=%d length=%q body=%d", head.Code, head.Header().Get("Content-Length"), head.Body.Len())
}
csrf, err := authhttp.CSRFToken(cookie.Value, "account:password:change")
if err != nil {
t.Fatal(err)
}
crossSiteHeaders := http.Header{"Origin": []string{"https://attacker.example"}, "Sec-Fetch-Site": []string{"cross-site"}, "Content-Type": []string{"application/x-www-form-urlencoded"}}
crossSiteForm := url.Values{"csrf_token": []string{csrf}, "current_password": []string{temporary}, "new_password": []string{replacement}, "confirm_password": []string{replacement}}.Encode()
crossSite := perform(handler, http.MethodPost, "https://observatory.example/account/password/", strings.NewReader(crossSiteForm), []*http.Cookie{cookie}, crossSiteHeaders)
if crossSite.Code != http.StatusForbidden || !strings.Contains(crossSite.Body.String(), passwordFormFailure) || strings.Contains(crossSite.Body.String(), temporary) || strings.Contains(crossSite.Body.String(), replacement) || strings.Contains(crossSite.Header().Get("Content-Type"), "application/json") {
t.Fatalf("cross-site password status=%d body=%s", crossSite.Code, crossSite.Body.String())
}
invalidTokenForm := url.Values{"csrf_token": []string{"invalid"}, "current_password": []string{temporary}, "new_password": []string{replacement}, "confirm_password": []string{replacement}}.Encode()
invalidToken := perform(handler, http.MethodPost, "https://observatory.example/account/password/", strings.NewReader(invalidTokenForm), []*http.Cookie{cookie}, headers)
if invalidToken.Code != http.StatusForbidden || !strings.Contains(invalidToken.Body.String(), passwordFormFailure) || strings.Contains(invalidToken.Body.String(), temporary) || strings.Contains(invalidToken.Body.String(), replacement) || strings.Contains(invalidToken.Header().Get("Content-Type"), "application/json") {
t.Fatalf("invalid-token password status=%d body=%s", invalidToken.Code, invalidToken.Body.String())
}
mismatch := url.Values{"csrf_token": []string{csrf}, "current_password": []string{temporary}, "new_password": []string{replacement}, "confirm_password": []string{"different password value"}}.Encode()
rejected := perform(handler, http.MethodPost, "https://observatory.example/account/password/", strings.NewReader(mismatch), []*http.Cookie{cookie}, headers)
if rejected.Code != http.StatusUnprocessableEntity || !strings.Contains(rejected.Body.String(), passwordMatchFailure) || strings.Contains(rejected.Body.String(), temporary) || strings.Contains(rejected.Body.String(), replacement) {
t.Fatalf("mismatch status=%d body=%s", rejected.Code, rejected.Body.String())
}
change := url.Values{"csrf_token": []string{csrf}, "current_password": []string{temporary}, "new_password": []string{replacement}, "confirm_password": []string{replacement}}.Encode()
privacyHeaders := http.Header{"Origin": []string{"null"}, "Content-Type": []string{"application/x-www-form-urlencoded"}}
changed := perform(handler, http.MethodPost, "https://observatory.example/account/password/", strings.NewReader(change), []*http.Cookie{cookie}, privacyHeaders)
if changed.Code != http.StatusSeeOther || changed.Header().Get("Location") != "/login/?password=changed" {
t.Fatalf("change status=%d location=%q body=%s", changed.Code, changed.Header().Get("Location"), changed.Body.String())
}
oldSession := perform(handler, http.MethodGet, "https://observatory.example/app/", nil, []*http.Cookie{cookie})
if oldSession.Code != http.StatusSeeOther || oldSession.Header().Get("Location") != "/login/" {
t.Fatalf("old session status=%d location=%q", oldSession.Code, oldSession.Header().Get("Location"))
}
if _, _, err = identities.Auth.Authenticate(t.Context(), "operator", temporary, time.Hour); err == nil {
t.Fatal("temporary password remained valid")
}
_, principal, err := identities.Auth.Authenticate(t.Context(), "operator", replacement, time.Hour)
if err != nil || principal.User.PasswordChangeRequired {
t.Fatalf("replacement principal=%+v err=%v", principal, err)
}
newLogin := loginHTMLResponse(t, handler, "operator", replacement)
if newLogin.Code != http.StatusSeeOther || newLogin.Header().Get("Location") != "/app/" {
t.Fatalf("new login status=%d location=%q", newLogin.Code, newLogin.Header().Get("Location"))
}
}
func TestAPITemporaryOperatorReceivesScopedRotationToken(t *testing.T) {
server, store, identities, _ := newRotationTestServer(t)
defer store.Close()
defer identities.Close()
handler := server.Handler()
loginHeaders := http.Header{"Origin": []string{"https://observatory.example"}, "Content-Type": []string{"application/json"}}
login := perform(handler, http.MethodPost, "https://observatory.example/api/v1/session", strings.NewReader(`{"identifier":"operator","password":"temporary correct horse battery staple"}`), nil, loginHeaders)
var session struct {
PasswordChangeRequired bool `json:"password_change_required"`
PasswordChangeCSRF string `json:"password_change_csrf"`
}
if login.Code != http.StatusOK || json.Unmarshal(login.Body.Bytes(), &session) != nil || !session.PasswordChangeRequired || session.PasswordChangeCSRF == "" {
t.Fatalf("login status=%d session=%+v body=%s", login.Code, session, login.Body.String())
}
cookies := login.Result().Cookies()
changeHeaders := http.Header{"Origin": []string{"https://observatory.example"}, "Content-Type": []string{"application/json"}, "X-CSRF-Token": []string{session.PasswordChangeCSRF}}
changed := perform(handler, http.MethodPost, "https://observatory.example/api/v1/account/password", strings.NewReader(`{"current_password":"temporary correct horse battery staple","new_password":"API replacement password value"}`), cookies, changeHeaders)
if changed.Code != http.StatusNoContent || changed.Body.Len() != 0 {
t.Fatalf("change status=%d body=%s", changed.Code, changed.Body.String())
}
_, principal, err := identities.Auth.Authenticate(t.Context(), "operator", "API replacement password value", time.Hour)
if err != nil || principal.User.PasswordChangeRequired {
t.Fatalf("principal=%+v err=%v", principal, err)
}
}
func TestLiveRefreshStreamIsAuthorizedAndCarriesNoTelemetry(t *testing.T) {
server, store, identities, bootstrap := newUITestServer(t)
defer store.Close()
defer identities.Close()
handler := server.Handler()
unauthorized := perform(handler, http.MethodGet, "https://observatory.example/app/events?organization="+url.QueryEscape(bootstrap.Organization.ID), nil, nil)
if unauthorized.Code != http.StatusUnauthorized {
t.Fatalf("unauthorized stream status=%d", unauthorized.Code)
}
cookie := loginHTML(t, handler)
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
request := httptest.NewRequest(http.MethodGet, "https://observatory.example/app/events?organization="+url.QueryEscape(bootstrap.Organization.ID), nil).WithContext(ctx)
request.AddCookie(cookie)
stream := newStreamRecorder()
done := make(chan struct{})
go func() {
handler.ServeHTTP(stream, request)
close(done)
}()
waitFlush := func() {
t.Helper()
select {
case <-stream.flushed:
case <-ctx.Done():
t.Fatal("stream did not flush before timeout")
}
}
waitFlush()
if stream.statusCode() != http.StatusOK || stream.Header().Get("Content-Type") != "text/event-stream; charset=utf-8" || stream.Header().Get("X-Accel-Buffering") != "no" || stream.bodyString() != "event: ready\ndata: {}\n\n" {
t.Fatalf("stream status=%d headers=%v body=%q", stream.statusCode(), stream.Header(), stream.bodyString())
}
server.refresh.publish(bootstrap.Organization.ID)
waitFlush()
streamBody := stream.bodyString()
if streamBody != "event: ready\ndata: {}\n\nevent: refresh\ndata: {}\n\n" || strings.Contains(streamBody, bootstrap.Organization.ID) || strings.Contains(streamBody, "service") {
t.Fatalf("stream body=%q", streamBody)
}
cancel()
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("stream did not stop after cancellation")
}
}
func TestDashboardManagementIsScopedCSRFProtectedAndExportable(t *testing.T) {
server, store, identities, bootstrap := newUITestServer(t)
defer store.Close()
defer identities.Close()
handler := server.Handler()
cookie := loginHTML(t, handler)
csrf, err := authhttp.CSRFToken(cookie.Value, "dashboards:manage")
if err != nil {
t.Fatal(err)
}
headers := http.Header{"Origin": []string{"https://observatory.example"}, "Content-Type": []string{"application/x-www-form-urlencoded"}}
invalid := url.Values{
"organization_id": []string{bootstrap.Organization.ID}, "csrf_token": []string{"invalid"},
"name": []string{"Recent errors"}, "description": []string{"A bounded recent error view."},
"query": []string{"logs | where status >= 500 | window 1h | limit 50"},
}.Encode()
denied := perform(handler, http.MethodPost, "https://observatory.example/app/queries/", strings.NewReader(invalid), []*http.Cookie{cookie}, headers)
if denied.Code != http.StatusForbidden {
t.Fatalf("invalid CSRF status=%d", denied.Code)
}
queryForm := url.Values{
"organization_id": []string{bootstrap.Organization.ID}, "csrf_token": []string{csrf},
"name": []string{"Recent errors"}, "description": []string{"A bounded recent error view."},
"query": []string{"logs | where status >= 500 | window 1h | limit 50"},
}.Encode()
privacyHeaders := http.Header{"Origin": []string{"null"}, "Content-Type": []string{"application/x-www-form-urlencoded"}}
createdQuery := perform(handler, http.MethodPost, "https://observatory.example/app/queries/", strings.NewReader(queryForm), []*http.Cookie{cookie}, privacyHeaders)
if createdQuery.Code != http.StatusSeeOther || !strings.HasPrefix(createdQuery.Header().Get("Location"), "/app/?organization=") {
t.Fatalf("query status=%d location=%q body=%s", createdQuery.Code, createdQuery.Header().Get("Location"), createdQuery.Body.String())
}
queries, err := store.SavedQueries(context.Background(), bootstrap.Organization.ID)
if err != nil || len(queries) != 1 {
t.Fatalf("queries=%+v err=%v", queries, err)
}
mismatchedDashboard := url.Values{
"organization_id": []string{bootstrap.Organization.ID}, "csrf_token": []string{csrf},
"slug": []string{"invalid-stat"}, "name": []string{"Invalid stat"},
"description": []string{"A non-summary query cannot become a statistic."}, "panel_title": []string{"Invalid"},
"saved_query_id": []string{queries[0].ID}, "visualization": []string{"stat"},
}.Encode()
mismatched := perform(handler, http.MethodPost, "https://observatory.example/app/dashboards/", strings.NewReader(mismatchedDashboard), []*http.Cookie{cookie}, headers)
if mismatched.Code != http.StatusUnprocessableEntity {
t.Fatalf("mismatched presentation status=%d body=%s", mismatched.Code, mismatched.Body.String())
}
dashboardForm := url.Values{
"organization_id": []string{bootstrap.Organization.ID}, "csrf_token": []string{csrf},
"slug": []string{"recent-errors"}, "name": []string{"Recent errors"},
"description": []string{"An accessible bounded error dashboard."}, "panel_title": []string{"Errors"},
"saved_query_id": []string{queries[0].ID}, "visualization": []string{"table"},
}.Encode()
createdDashboard := perform(handler, http.MethodPost, "https://observatory.example/app/dashboards/", strings.NewReader(dashboardForm), []*http.Cookie{cookie}, headers)
if createdDashboard.Code != http.StatusSeeOther || !strings.HasPrefix(createdDashboard.Header().Get("Location"), "/app/dashboards/recent-errors/") {
t.Fatalf("dashboard status=%d location=%q body=%s", createdDashboard.Code, createdDashboard.Header().Get("Location"), createdDashboard.Body.String())
}
target := "https://observatory.example/app/dashboards/recent-errors/?organization=" + url.QueryEscape(bootstrap.Organization.ID)
dashboard := perform(handler, http.MethodGet, target, nil, []*http.Cookie{cookie})
if dashboard.Code != http.StatusOK || !strings.Contains(dashboard.Body.String(), "An accessible bounded error dashboard.") || !strings.Contains(dashboard.Body.String(), "Recent errors") || !strings.Contains(dashboard.Body.String(), ">Errors</h2>") {
t.Fatalf("dashboard status=%d body=%s", dashboard.Code, dashboard.Body.String())
}
dashboardHead := perform(handler, http.MethodHead, target, nil, []*http.Cookie{cookie})
if dashboardHead.Code != http.StatusOK || dashboardHead.Body.Len() != 0 || dashboardHead.Header().Get("Content-Length") != dashboard.Header().Get("Content-Length") {
t.Fatalf("dashboard HEAD status=%d length=%q", dashboardHead.Code, dashboardHead.Header().Get("Content-Length"))
}
storedDashboard, err := store.Dashboard(context.Background(), bootstrap.Organization.ID, "recent-errors")
if err != nil || storedDashboard.Revision != 1 || !strings.Contains(dashboard.Body.String(), "Update dashboard details") || !strings.Contains(dashboard.Body.String(), `name="expected_revision" value="1"`) {
t.Fatalf("stored dashboard=%+v err=%v body=%s", storedDashboard, err, dashboard.Body.String())
}
revisionForm := url.Values{
"organization_id": []string{bootstrap.Organization.ID}, "csrf_token": []string{csrf},
"dashboard_id": []string{storedDashboard.ID}, "expected_revision": []string{strconv.Itoa(storedDashboard.Revision)},
"slug": []string{storedDashboard.Slug}, "name": []string{"Current errors"},
"description": []string{"A revision-safe bounded error dashboard."},
}.Encode()
revised := perform(handler, http.MethodPost, target, strings.NewReader(revisionForm), []*http.Cookie{cookie}, headers)
if revised.Code != http.StatusSeeOther {
t.Fatalf("revision status=%d body=%s", revised.Code, revised.Body.String())
}
stale := perform(handler, http.MethodPost, target, strings.NewReader(revisionForm), []*http.Cookie{cookie}, headers)
if stale.Code != http.StatusConflict {
t.Fatalf("stale revision status=%d body=%s", stale.Code, stale.Body.String())
}
storedDashboard, err = store.Dashboard(context.Background(), bootstrap.Organization.ID, "recent-errors")
if err != nil || storedDashboard.Revision != 2 || storedDashboard.Name != "Current errors" {
t.Fatalf("revised dashboard=%+v err=%v", storedDashboard, err)
}
addPanelForm := url.Values{
"organization_id": []string{bootstrap.Organization.ID}, "csrf_token": []string{csrf},
"dashboard_id": []string{storedDashboard.ID}, "expected_revision": []string{strconv.Itoa(storedDashboard.Revision)},
"panel_title": []string{"Recent failures"}, "saved_query_id": []string{queries[0].ID}, "visualization": []string{"table"},
}.Encode()
addTarget := "https://observatory.example/app/dashboards/recent-errors/panels/?organization=" + url.QueryEscape(bootstrap.Organization.ID)
added := perform(handler, http.MethodPost, addTarget, strings.NewReader(addPanelForm), []*http.Cookie{cookie}, headers)
if added.Code != http.StatusSeeOther {
t.Fatalf("add panel status=%d body=%s", added.Code, added.Body.String())
}
storedDashboard, err = store.Dashboard(context.Background(), bootstrap.Organization.ID, "recent-errors")
if err != nil || storedDashboard.Revision != 3 || len(storedDashboard.Panels) != 2 {
t.Fatalf("dashboard after add=%+v err=%v", storedDashboard, err)
}
addedPanel := storedDashboard.Panels[1]
updatePanelForm := url.Values{
"organization_id": []string{bootstrap.Organization.ID}, "csrf_token": []string{csrf},
"dashboard_id": []string{storedDashboard.ID}, "expected_revision": []string{strconv.Itoa(storedDashboard.Revision)},
"panel_title": []string{"Renamed failures"}, "saved_query_id": []string{queries[0].ID}, "visualization": []string{"table"},
}.Encode()
updatePanelTarget := "https://observatory.example/app/dashboards/recent-errors/panels/" + url.PathEscape(addedPanel.ID) + "/?organization=" + url.QueryEscape(bootstrap.Organization.ID)
updatedPanel := perform(handler, http.MethodPost, updatePanelTarget, strings.NewReader(updatePanelForm), []*http.Cookie{cookie}, headers)
if updatedPanel.Code != http.StatusSeeOther {
t.Fatalf("update panel status=%d body=%s", updatedPanel.Code, updatedPanel.Body.String())
}
storedDashboard, err = store.Dashboard(context.Background(), bootstrap.Organization.ID, "recent-errors")
if err != nil || storedDashboard.Revision != 4 || storedDashboard.Panels[1].Title != "Renamed failures" {
t.Fatalf("dashboard after panel update=%+v err=%v", storedDashboard, err)
}
mismatchedRevision := url.Values{
"organization_id": []string{bootstrap.Organization.ID}, "csrf_token": []string{csrf},
"dashboard_id": []string{storedDashboard.ID}, "expected_revision": []string{strconv.Itoa(storedDashboard.Revision)},
"panel_title": []string{"Invalid chart"}, "saved_query_id": []string{queries[0].ID}, "visualization": []string{"timeseries"},
}.Encode()
mismatchedPanel := perform(handler, http.MethodPost, updatePanelTarget, strings.NewReader(mismatchedRevision), []*http.Cookie{cookie}, headers)
if mismatchedPanel.Code != http.StatusUnprocessableEntity {
t.Fatalf("mismatched panel status=%d body=%s", mismatchedPanel.Code, mismatchedPanel.Body.String())
}
removePanelForm := url.Values{
"organization_id": []string{bootstrap.Organization.ID}, "csrf_token": []string{csrf},
"dashboard_id": []string{storedDashboard.ID}, "expected_revision": []string{strconv.Itoa(storedDashboard.Revision)},
}.Encode()
removeTarget := "https://observatory.example/app/dashboards/recent-errors/panels/" + url.PathEscape(addedPanel.ID) + "/remove/?organization=" + url.QueryEscape(bootstrap.Organization.ID)
removed := perform(handler, http.MethodPost, removeTarget, strings.NewReader(removePanelForm), []*http.Cookie{cookie}, headers)
if removed.Code != http.StatusSeeOther {
t.Fatalf("remove panel status=%d body=%s", removed.Code, removed.Body.String())
}
storedDashboard, err = store.Dashboard(context.Background(), bootstrap.Organization.ID, "recent-errors")
if err != nil || storedDashboard.Revision != 5 || len(storedDashboard.Panels) != 1 {
t.Fatalf("dashboard after remove=%+v err=%v", storedDashboard, err)
}
exportTarget := "https://observatory.example/app/dashboards/recent-errors/export.json?organization=" + url.QueryEscape(bootstrap.Organization.ID)
exported := perform(handler, http.MethodGet, exportTarget, nil, []*http.Cookie{cookie})
if exported.Code != http.StatusOK || exported.Header().Get("Content-Type") != "application/json" || !strings.Contains(exported.Body.String(), `"version": 1`) || strings.Contains(exported.Body.String(), bootstrap.Organization.ID) || strings.Contains(exported.Body.String(), bootstrap.User.ID) {
t.Fatalf("export status=%d headers=%v body=%s", exported.Code, exported.Header(), exported.Body.String())
}
exportedHead := perform(handler, http.MethodHead, exportTarget, nil, []*http.Cookie{cookie})
if exportedHead.Code != http.StatusOK || exportedHead.Body.Len() != 0 || exportedHead.Header().Get("Content-Length") != exported.Header().Get("Content-Length") {
t.Fatalf("export HEAD status=%d length=%q", exportedHead.Code, exportedHead.Header().Get("Content-Length"))
}
unauthorized := perform(handler, http.MethodGet, exportTarget, nil, nil)
if unauthorized.Code != http.StatusUnauthorized || unauthorized.Body.String() == exported.Body.String() {
t.Fatalf("unauthorized export status=%d", unauthorized.Code)
}
}
func TestIncidentRulesEvaluationInboxAndResponseAreScoped(t *testing.T) {
server, store, identities, bootstrap := newUITestServer(t)
defer store.Close()
defer identities.Close()
pushes := &recordingPushDispatcher{}
server.options.PushDispatcher = pushes
server.options.PushPublicKey = base64.RawURLEncoding.EncodeToString(append([]byte{4}, make([]byte, 64)...))
handler := server.Handler()
cookie := loginHTML(t, handler)
now := server.now()
saved, err := store.SaveQuery(context.Background(), storage.SavedQueryInput{
OrganizationID: bootstrap.Organization.ID, ActorUserID: bootstrap.User.ID, MaxRows: 100,
Name: "Recent failures", Description: "Recent HTTP failures.", Query: "logs | where status >= 500 | window 1h | limit 50",
}, now)
if err != nil {
t.Fatal(err)
}
token, err := store.CreateSource(context.Background(), "incident-source", model.Scope{OrganizationID: bootstrap.Organization.ID, ProjectID: "project-a", EnvironmentID: "production", ServiceID: "service-a"})
if err != nil {
t.Fatal(err)
}
batch := model.Batch{Version: model.BatchVersion, SourceID: "incident-source", StreamID: "requests", Sequence: 1, ObservedAt: now, Signal: model.SignalLogs, Records: []model.Observation{{Timestamp: now, Name: "http.request", Attributes: map[string]string{"http.status_code": "503", "http.route": "/failed"}}}}
if _, err = store.Ingest(context.Background(), token, batch, now); err != nil {
t.Fatal(err)
}
if err = store.Recover(context.Background()); err != nil {
t.Fatal(err)
}
csrf, err := authhttp.CSRFToken(cookie.Value, "incidents:manage")
if err != nil {
t.Fatal(err)
}
headers := http.Header{"Origin": []string{"https://observatory.example"}, "Content-Type": []string{"application/x-www-form-urlencoded"}}
form := url.Values{
"organization_id": []string{bootstrap.Organization.ID}, "csrf_token": []string{csrf},
"name": []string{"HTTP failures"}, "description": []string{"Open when a bounded saved query finds a failure."},
"saved_query_id": []string{saved.ID}, "severity": []string{"critical"}, "minimum_matches": []string{"1"},
"required_consecutive": []string{"1"}, "evaluation_interval": []string{"15s"},
}
invalid := cloneValues(form)
invalid.Set("csrf_token", "invalid")
denied := perform(handler, http.MethodPost, "https://observatory.example/app/alert-rules/", strings.NewReader(invalid.Encode()), []*http.Cookie{cookie}, headers)
if denied.Code != http.StatusForbidden {
t.Fatalf("invalid CSRF status=%d body=%s", denied.Code, denied.Body.String())
}
privacyHeaders := http.Header{"Origin": []string{"null"}, "Content-Type": []string{"application/x-www-form-urlencoded"}}
created := perform(handler, http.MethodPost, "https://observatory.example/app/alert-rules/", strings.NewReader(form.Encode()), []*http.Cookie{cookie}, privacyHeaders)
if created.Code != http.StatusSeeOther || !strings.HasPrefix(created.Header().Get("Location"), "/app/incidents/?organization=") {
t.Fatalf("create status=%d location=%q body=%s", created.Code, created.Header().Get("Location"), created.Body.String())
}
updates, remove, err := server.refresh.subscribe(bootstrap.Organization.ID)
if err != nil {
t.Fatal(err)
}
defer remove()
if evaluated, evaluationErr := server.EvaluateAlerts(context.Background()); evaluationErr != nil || evaluated != 1 {
t.Fatalf("evaluated=%d err=%v", evaluated, evaluationErr)
}
if len(pushes.organizations) != 1 || pushes.organizations[0] != bootstrap.Organization.ID {
t.Fatalf("push organizations=%v", pushes.organizations)
}
select {
case <-updates:
default:
t.Fatal("incident change did not publish a generic refresh")
}
incidents, err := store.Incidents(context.Background(), bootstrap.Organization.ID, false, 10)
if err != nil || len(incidents) != 1 || incidents[0].State != "firing" {
t.Fatalf("incidents=%+v err=%v", incidents, err)
}
path := "https://observatory.example/app/incidents/?organization=" + url.QueryEscape(bootstrap.Organization.ID)
inbox := perform(handler, http.MethodGet, path, nil, []*http.Cookie{cookie})
if inbox.Code != http.StatusOK || !strings.Contains(inbox.Body.String(), "What needs attention?") || !strings.Contains(inbox.Body.String(), "HTTP failures") || !strings.Contains(inbox.Body.String(), "critical · firing") || !strings.Contains(inbox.Body.String(), "data-cache-inbox") || !strings.Contains(inbox.Body.String(), "data-push-toggle") || !strings.Contains(inbox.Body.String(), `data-open-incident-count="1"`) || strings.Contains(inbox.Body.String(), "/failed") {
t.Fatalf("inbox status=%d body=%s", inbox.Code, inbox.Body.String())
}
pushCSRF, err := authhttp.CSRFToken(cookie.Value, "push:manage")
if err != nil {
t.Fatal(err)
}
clientKey, err := ecdh.P256().GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
authSecret := make([]byte, 16)
if _, err = rand.Read(authSecret); err != nil {
t.Fatal(err)
}
pushBody, _ := json.Marshal(map[string]any{"organization_id": bootstrap.Organization.ID, "endpoint": "https://push.example.test/send/browser", "keys": map[string]string{"p256dh": base64.RawURLEncoding.EncodeToString(clientKey.PublicKey().Bytes()), "auth": base64.RawURLEncoding.EncodeToString(authSecret)}})
pushHeaders := make(http.Header)
pushHeaders.Set("Origin", "https://observatory.example")
pushHeaders.Set("Content-Type", "application/json")
pushHeaders.Set("X-CSRF-Token", pushCSRF)
invalidPushHeaders := pushHeaders.Clone()
invalidPushHeaders.Set("X-CSRF-Token", "invalid")
invalidPush := perform(handler, http.MethodPost, "https://observatory.example/api/v1/push/subscription", bytes.NewReader(pushBody), []*http.Cookie{cookie}, invalidPushHeaders)
if invalidPush.Code != http.StatusForbidden {
t.Fatalf("invalid push CSRF status=%d body=%s", invalidPush.Code, invalidPush.Body.String())
}
crossOriginPushHeaders := pushHeaders.Clone()
crossOriginPushHeaders.Set("Origin", "https://attacker.example")
crossOriginPush := perform(handler, http.MethodPost, "https://observatory.example/api/v1/push/subscription", bytes.NewReader(pushBody), []*http.Cookie{cookie}, crossOriginPushHeaders)
if crossOriginPush.Code != http.StatusForbidden {
t.Fatalf("cross-origin push status=%d body=%s", crossOriginPush.Code, crossOriginPush.Body.String())
}
registered := perform(handler, http.MethodPost, "https://observatory.example/api/v1/push/subscription", bytes.NewReader(pushBody), []*http.Cookie{cookie}, pushHeaders)
if registered.Code != http.StatusCreated || !strings.Contains(registered.Body.String(), `"id":"push_`) {
t.Fatalf("push registration status=%d body=%s", registered.Code, registered.Body.String())
}
if subscriptions, listErr := store.PushSubscriptions(context.Background(), bootstrap.Organization.ID); listErr != nil || len(subscriptions) != 1 || subscriptions[0].UserID != bootstrap.User.ID {
t.Fatalf("push subscriptions=%+v err=%v", subscriptions, listErr)
}
statusBody, _ := json.Marshal(map[string]any{"organization_id": bootstrap.Organization.ID, "endpoint": "https://push.example.test/send/browser", "keys": map[string]string{"p256dh": "", "auth": ""}})
pushStatus := perform(handler, http.MethodPost, "https://observatory.example/api/v1/push/subscription/status", bytes.NewReader(statusBody), []*http.Cookie{cookie}, pushHeaders)
if pushStatus.Code != http.StatusOK || !strings.Contains(pushStatus.Body.String(), `"subscribed":true`) {
t.Fatalf("push status=%d body=%s", pushStatus.Code, pushStatus.Body.String())
}
privateEndpointBody := bytes.Replace(pushBody, []byte("https://push.example.test/send/browser"), []byte("https://127.0.0.1/send/browser"), 1)
rejected := perform(handler, http.MethodPost, "https://observatory.example/api/v1/push/subscription", bytes.NewReader(privateEndpointBody), []*http.Cookie{cookie}, pushHeaders)
if rejected.Code != http.StatusUnprocessableEntity || strings.Contains(rejected.Body.String(), "127.0.0.1") {
t.Fatalf("private endpoint status=%d body=%s", rejected.Code, rejected.Body.String())
}
invalidCurveBody, _ := json.Marshal(map[string]any{"organization_id": bootstrap.Organization.ID, "endpoint": "https://push.example.test/send/invalid-curve", "keys": map[string]string{"p256dh": base64.RawURLEncoding.EncodeToString(append([]byte{4}, make([]byte, 64)...)), "auth": base64.RawURLEncoding.EncodeToString(authSecret)}})
invalidCurve := perform(handler, http.MethodPost, "https://observatory.example/api/v1/push/subscription", bytes.NewReader(invalidCurveBody), []*http.Cookie{cookie}, pushHeaders)
if invalidCurve.Code != http.StatusUnprocessableEntity {
t.Fatalf("invalid curve status=%d body=%s", invalidCurve.Code, invalidCurve.Body.String())
}
deleteBody, _ := json.Marshal(map[string]any{"organization_id": bootstrap.Organization.ID, "endpoint": "https://push.example.test/send/browser", "keys": map[string]string{"p256dh": "", "auth": ""}})
deleted := perform(handler, http.MethodDelete, "https://observatory.example/api/v1/push/subscription", bytes.NewReader(deleteBody), []*http.Cookie{cookie}, pushHeaders)
if deleted.Code != http.StatusOK || !strings.Contains(deleted.Body.String(), `"remaining":false`) {
t.Fatalf("push deletion status=%d body=%s", deleted.Code, deleted.Body.String())
}
offlineInbox := perform(handler, http.MethodGet, "https://observatory.example/app/incidents/offline/?organization="+url.QueryEscape(bootstrap.Organization.ID), nil, []*http.Cookie{cookie})
if offlineInbox.Code != http.StatusOK || !strings.Contains(offlineInbox.Body.String(), "Saved incident inbox") || !strings.Contains(offlineInbox.Body.String(), "HTTP failures") {
t.Fatalf("offline inbox status=%d body=%s", offlineInbox.Code, offlineInbox.Body.String())
}
for _, forbidden := range []string{incidents[0].ID, saved.Query, bootstrap.User.ID, "csrf_token", "/failed", "Acknowledge", "Resolve"} {
if strings.Contains(offlineInbox.Body.String(), forbidden) {
t.Fatalf("offline inbox exposed %q: %s", forbidden, offlineInbox.Body.String())
}
}
unauthenticatedOffline := perform(handler, http.MethodGet, "https://observatory.example/app/incidents/offline/?organization="+url.QueryEscape(bootstrap.Organization.ID), nil, nil)
if unauthenticatedOffline.Code != http.StatusSeeOther || unauthenticatedOffline.Header().Get("Location") != "/login/" {
t.Fatalf("unauthenticated offline status=%d", unauthenticatedOffline.Code)
}
inboxHead := perform(handler, http.MethodHead, path, nil, []*http.Cookie{cookie})
if inboxHead.Code != http.StatusOK || inboxHead.Body.Len() != 0 || inboxHead.Header().Get("Content-Length") != inbox.Header().Get("Content-Length") {
t.Fatalf("inbox HEAD status=%d length=%q body=%d", inboxHead.Code, inboxHead.Header().Get("Content-Length"), inboxHead.Body.Len())
}
missingOrganization := perform(handler, http.MethodGet, "https://observatory.example/app/incidents/", nil, []*http.Cookie{cookie})
if missingOrganization.Code != http.StatusBadRequest {
t.Fatalf("missing organization status=%d", missingOrganization.Code)
}
action := url.Values{"organization_id": []string{bootstrap.Organization.ID}, "csrf_token": []string{csrf}, "action": []string{"acknowledge"}, "silence_duration": []string{""}}.Encode()
acknowledged := perform(handler, http.MethodPost, "https://observatory.example/app/incidents/"+incidents[0].ID+"/", strings.NewReader(action), []*http.Cookie{cookie}, headers)
if acknowledged.Code != http.StatusSeeOther {
t.Fatalf("acknowledge status=%d body=%s", acknowledged.Code, acknowledged.Body.String())
}
current, err := store.Incidents(context.Background(), bootstrap.Organization.ID, false, 10)
if err != nil || len(current) != 1 || current[0].State != "acknowledged" || current[0].AcknowledgedBy != bootstrap.User.ID {
t.Fatalf("current=%+v err=%v", current, err)
}
}
type recordingPushDispatcher struct{ organizations []string }
func (dispatcher *recordingPushDispatcher) Enqueue(organizationID string) bool {
dispatcher.organizations = append(dispatcher.organizations, organizationID)
return true
}
func cloneValues(input url.Values) url.Values {
result := make(url.Values, len(input))
for key, values := range input {
result[key] = append([]string(nil), values...)
}
return result
}
func TestAssistedQueryBuilderCreatesTypedTimeSeriesWithTableAlternative(t *testing.T) {
server, store, identities, bootstrap := newUITestServer(t)
defer store.Close()
defer identities.Close()
handler := server.Handler()
cookie := loginHTML(t, handler)
csrf, err := authhttp.CSRFToken(cookie.Value, "dashboards:manage")
if err != nil {
t.Fatal(err)
}
now := server.now()
token, err := store.CreateSource(context.Background(), "builder-source", model.Scope{OrganizationID: bootstrap.Organization.ID, ProjectID: "project-a", EnvironmentID: "production", ServiceID: "service-a"})
if err != nil {
t.Fatal(err)
}
batch := model.Batch{Version: model.BatchVersion, SourceID: "builder-source", StreamID: "requests", Sequence: 1, ObservedAt: now, Signal: model.SignalLogs, Records: []model.Observation{
{Timestamp: now.Add(-6 * time.Minute), Name: "application.http.request", Attributes: map[string]string{"http.status_code": "503", "http.route": "/failed"}},
{Timestamp: now.Add(-1 * time.Minute), Name: "application.http.request", Attributes: map[string]string{"http.status_code": "500", "http.route": "/failed"}},
}}
if _, err = store.Ingest(context.Background(), token, batch, now); err != nil {
t.Fatal(err)
}
if err = store.Recover(context.Background()); err != nil {
t.Fatal(err)
}
headers := http.Header{"Origin": []string{"https://observatory.example"}, "Content-Type": []string{"application/x-www-form-urlencoded"}}
builderForm := url.Values{
"organization_id": []string{bootstrap.Organization.ID}, "csrf_token": []string{csrf},
"name": []string{"Errors over time"}, "description": []string{"Five-minute error counts."},
"signal": []string{"logs"}, "filter_field": []string{"status"}, "filter_operator": []string{">="}, "filter_value": []string{"500"},
"window": []string{"1h"}, "aggregate": []string{"count"}, "aggregate_field": []string{""}, "group_by": []string{"route"}, "bucket": []string{"5m"}, "limit": []string{"50"},
}.Encode()
createdQuery := perform(handler, http.MethodPost, "https://observatory.example/app/queries/builder/", strings.NewReader(builderForm), []*http.Cookie{cookie}, headers)
if createdQuery.Code != http.StatusSeeOther {
t.Fatalf("builder status=%d body=%s", createdQuery.Code, createdQuery.Body.String())
}
queries, err := store.SavedQueries(context.Background(), bootstrap.Organization.ID)
if err != nil || len(queries) != 1 {
t.Fatalf("queries=%+v err=%v", queries, err)
}
expectedText := `logs | where status >= "500" | window 1h | summarize count() by route, window(5m) | limit 50`
if queries[0].Query != expectedText || queries[0].AST.Signal != model.SignalLogs || len(queries[0].AST.Filters) != 1 || queries[0].AST.Filters[0].Value != "500" || queries[0].AST.Summary == nil || queries[0].AST.Bucket != 5*time.Minute {
t.Fatalf("saved query=%+v", queries[0])
}
dashboardForm := url.Values{
"organization_id": []string{bootstrap.Organization.ID}, "csrf_token": []string{csrf},
"slug": []string{"error-rate"}, "name": []string{"Error rate"}, "description": []string{"A bounded error trend."},
"panel_title": []string{"Errors by route"}, "saved_query_id": []string{queries[0].ID}, "visualization": []string{"timeseries"},
}.Encode()
createdDashboard := perform(handler, http.MethodPost, "https://observatory.example/app/dashboards/", strings.NewReader(dashboardForm), []*http.Cookie{cookie}, headers)
if createdDashboard.Code != http.StatusSeeOther {
t.Fatalf("dashboard status=%d body=%s", createdDashboard.Code, createdDashboard.Body.String())
}
target := "https://observatory.example/app/dashboards/error-rate/?organization=" + url.QueryEscape(bootstrap.Organization.ID)
dashboard := perform(handler, http.MethodGet, target, nil, []*http.Cookie{cookie})
body := dashboard.Body.String()
if dashboard.Code != http.StatusOK || !strings.Contains(body, "Errors by route visual summary") || strings.Count(body, "<meter ") != 2 || !strings.Contains(body, "<table>") || !strings.Contains(body, "<caption>Errors by route</caption>") {
t.Fatalf("dashboard status=%d body=%s", dashboard.Code, body)
}
}
func TestAssistedQueryBuilderEscapesStageSeparatorsAndRejectsInvalidCombinations(t *testing.T) {
values := url.Values{
"signal": []string{"logs"}, "filter_field": []string{"name"}, "filter_operator": []string{"=="}, "filter_value": []string{"worker | limit 250"},
"window": []string{"1h"}, "aggregate": []string{"none"}, "aggregate_field": []string{""}, "group_by": []string{""}, "bucket": []string{""}, "limit": []string{"50"},
}
text, err := buildAssistedQuery(values, 1000)
if err != nil || strings.Contains(text, `"worker | limit 250"`) || !strings.Contains(text, `\u007c`) {
t.Fatalf("text=%q err=%v", text, err)
}
ast, err := query.Parse(text, 1000)
if err != nil || len(ast.Filters) != 1 || ast.Filters[0].Value != "worker | limit 250" || ast.Limit != 50 {
t.Fatalf("AST=%+v err=%v", ast, err)
}
values.Set("bucket", "5m")
if _, err = buildAssistedQuery(values, 1000); err == nil {
t.Fatal("time bucket without an aggregate was accepted")
}
}
func TestResultChartIsBoundedAndFailsClosedForNegativeValues(t *testing.T) {
result := query.Result{Columns: []query.Column{{Field: "window_start", Type: schema.TypeTime}, {Field: "count", Type: schema.TypeInteger}}}
for index := range 60 {
label := fmt.Sprintf("2026-08-17T07:%02d:00Z", index)
value := strconv.Itoa(index)
result.Rows = append(result.Rows, query.Row{Values: []*string{&label, &value}})
}
chart := resultChart("Requests", result)
if len(chart.Points) != 48 || chart.Points[47].Maximum != "47" || chart.Points[47].Value != "47" {
t.Fatalf("chart=%+v", chart)
}
negative := "-1"
result.Rows[0].Values[1] = &negative
if chart = resultChart("Requests", result); len(chart.Points) != 0 {
t.Fatalf("negative chart=%+v", chart)
}
}
type streamRecorder struct {
mu sync.Mutex
header http.Header
status int
body bytes.Buffer
flushed chan struct{}
}
func newStreamRecorder() *streamRecorder {
return &streamRecorder{header: make(http.Header), flushed: make(chan struct{}, 4)}
}
func (recorder *streamRecorder) Header() http.Header { return recorder.header }
func (recorder *streamRecorder) WriteHeader(status int) {
recorder.mu.Lock()
defer recorder.mu.Unlock()
if recorder.status == 0 {
recorder.status = status
}
}
func (recorder *streamRecorder) Write(body []byte) (int, error) {
recorder.mu.Lock()
defer recorder.mu.Unlock()
if recorder.status == 0 {
recorder.status = http.StatusOK
}
return recorder.body.Write(body)
}
func (recorder *streamRecorder) Flush() {
select {
case recorder.flushed <- struct{}{}:
default:
}
}
func (recorder *streamRecorder) statusCode() int {
recorder.mu.Lock()
defer recorder.mu.Unlock()
return recorder.status
}
func (recorder *streamRecorder) bodyString() string {
recorder.mu.Lock()
defer recorder.mu.Unlock()
return recorder.body.String()
}
func newUITestServer(t *testing.T) (*Server, *storage.Store, *identity.Services, identity.BootstrapResult) {
t.Helper()
root := filepath.Join(t.TempDir(), "data")
if err := os.Mkdir(root, 0o700); err != nil {
t.Fatal(err)
}
store, err := storage.Open(root)
if err != nil {
t.Fatal(err)
}
identities, err := identity.Open(root)
if err != nil {
store.Close()
t.Fatal(err)
}
bootstrap, err := identities.Bootstrap(context.Background(), identity.BootstrapInput{Username: "operator", Email: "operator@example.test", DisplayName: "Operator", Password: "correct horse battery staple"})
if err != nil {
identities.Close()
store.Close()
t.Fatal(err)
}
server, err := New(store, identities, testOptions())
if err != nil {
identities.Close()
store.Close()
t.Fatal(err)
}
server.now = func() time.Time { return time.Date(2026, 8, 17, 7, 30, 0, 0, time.UTC) }
return server, store, identities, bootstrap
}
func newRotationTestServer(t *testing.T) (*Server, *storage.Store, *identity.Services, identity.BootstrapResult) {
t.Helper()
root := filepath.Join(t.TempDir(), "data")
if err := os.Mkdir(root, 0o700); err != nil {
t.Fatal(err)
}
store, err := storage.Open(root)
if err != nil {
t.Fatal(err)
}
identities, err := identity.Open(root)
if err != nil {
store.Close()
t.Fatal(err)
}
bootstrap, err := identities.Bootstrap(t.Context(), identity.BootstrapInput{Username: "operator", Email: "operator@example.test", DisplayName: "Operator", Password: "temporary correct horse battery staple", RequirePasswordChange: true})
if err != nil {
identities.Close()
store.Close()
t.Fatal(err)
}
server, err := New(store, identities, testOptions())
if err != nil {
identities.Close()
store.Close()
t.Fatal(err)
}
server.now = func() time.Time { return time.Date(2026, 8, 18, 5, 30, 0, 0, time.UTC) }
return server, store, identities, bootstrap
}
func loginHTML(t *testing.T, handler http.Handler) *http.Cookie {
t.Helper()
result := loginHTMLResponse(t, handler, "operator", "correct horse battery staple")
if result.Code != http.StatusSeeOther || result.Header().Get("Location") != "/app/" {
t.Fatalf("login status=%d location=%q body=%s", result.Code, result.Header().Get("Location"), result.Body.String())
}
for _, cookie := range result.Result().Cookies() {
if cookie.Name == "__Host-observatory_session" && cookie.Secure && cookie.HttpOnly && cookie.SameSite == http.SameSiteStrictMode {
return cookie
}
}
t.Fatalf("login cookies=%+v", result.Result().Cookies())
return nil
}
func loginHTMLResponse(t *testing.T, handler http.Handler, identifier, password string) *httptest.ResponseRecorder {
t.Helper()
csrfCookie, csrfToken := loginFormCSRF(t, handler)
form := url.Values{"csrf_token": []string{csrfToken}, "identifier": []string{identifier}, "password": []string{password}}.Encode()
headers := http.Header{"Origin": []string{"https://observatory.example"}, "Content-Type": []string{"application/x-www-form-urlencoded; charset=utf-8"}}
return perform(handler, http.MethodPost, "https://observatory.example/login/", strings.NewReader(form), []*http.Cookie{csrfCookie}, headers)
}
func loginFormCSRF(t *testing.T, handler http.Handler) (*http.Cookie, string) {
t.Helper()
page := perform(handler, http.MethodGet, "https://observatory.example/login/", nil, nil)
if page.Code != http.StatusOK {
t.Fatalf("login page status=%d body=%s", page.Code, page.Body.String())
}
var csrfCookie *http.Cookie
for _, cookie := range page.Result().Cookies() {
if cookie.Name == loginCSRFCookieName {
csrfCookie = cookie
break
}
}
if csrfCookie == nil || !csrfCookie.Secure || !csrfCookie.HttpOnly || csrfCookie.SameSite != http.SameSiteStrictMode {
t.Fatalf("login CSRF cookie=%+v", csrfCookie)
}
const marker = `name="csrf_token" value="`
start := strings.Index(page.Body.String(), marker)
if start < 0 {
t.Fatalf("login page omitted CSRF token: %s", page.Body.String())
}
start += len(marker)
end := strings.IndexByte(page.Body.String()[start:], '"')
if end < 0 {
t.Fatal("login page CSRF token is unterminated")
}
token := page.Body.String()[start : start+end]
if token == "" || token != csrfCookie.Value {
t.Fatal("login form and cookie CSRF tokens differ")
}
return csrfCookie, token
}
func perform(handler http.Handler, method, target string, body io.Reader, cookies []*http.Cookie, headerSets ...http.Header) *httptest.ResponseRecorder {
request := httptest.NewRequest(method, target, body)
for _, cookie := range cookies {
request.AddCookie(cookie)
}
for _, headers := range headerSets {
for name, values := range headers {
for _, value := range values {
request.Header.Add(name, value)
}
}
}
result := httptest.NewRecorder()
handler.ServeHTTP(result, request)
return result
}
+431
View File
@@ -0,0 +1,431 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Package identity binds Observatory's application policy to the storage-neutral
// Gamertan Web Foundations authentication, organization, and access packages.
package identity
import (
"context"
"database/sql"
"errors"
"fmt"
"io"
"net/url"
"os"
"path/filepath"
"sort"
"strings"
"syscall"
"time"
"gamertan.com/web/access"
"gamertan.com/web/auth"
"gamertan.com/web/authsqlite"
"gamertan.com/web/organizations"
_ "modernc.org/sqlite"
)
const (
PlatformOperator = "platform.operator"
OrganizationOwner = "organization.owner"
OrganizationViewer = "organization.viewer"
IncidentResponder = "incident.responder"
PermissionPlatformOperate = "platform.operate"
PermissionTelemetryQuery = "telemetry.query"
PermissionTelemetryReadSensitive = "telemetry.sensitive"
PermissionSourcesManage = "sources.manage"
PermissionSchemaManage = "schema.manage"
PermissionDashboardsRead = "dashboards.read"
PermissionDashboardsManage = "dashboards.manage"
PermissionIncidentsRead = "incidents.read"
PermissionIncidentsManage = "incidents.manage"
PermissionOrganizationAudit = "organization.audit.read"
PermissionOrganizationManage = "organization.manage"
)
var (
ErrAlreadyBootstrapped = errors.New("identity: platform is already bootstrapped")
ErrResourceNotFound = errors.New("identity: resource scope not found")
)
type Services struct {
Store *authsqlite.Store
Auth *auth.Service
Organizations *organizations.Service
Access *access.Service
control *sql.DB
dataDir string
}
func Open(dataDir string) (*Services, error) {
if !filepath.IsAbs(dataDir) || filepath.Clean(dataDir) != dataDir {
return nil, errors.New("identity: data directory must be absolute and clean")
}
info, err := os.Lstat(dataDir)
if err != nil {
return nil, fmt.Errorf("identity: inspect data directory: %w", err)
}
if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 || info.Mode().Perm()&0o077 != 0 {
return nil, errors.New("identity: data directory must be a private non-symlink directory")
}
controlPath := filepath.Join(dataDir, "control.sqlite")
store, err := authsqlite.Open(controlPath)
if err != nil {
return nil, err
}
authService, err := auth.New(store, auth.Options{})
if err != nil {
store.Close()
return nil, err
}
organizationService, err := organizations.New(store, organizations.Options{})
if err != nil {
store.Close()
return nil, err
}
accessService, err := access.New(store, AccessPolicy(), access.Options{})
if err != nil {
store.Close()
return nil, err
}
control, err := sql.Open("sqlite", sqliteDSN(controlPath))
if err != nil {
store.Close()
return nil, err
}
control.SetMaxOpenConns(1)
services := &Services{Store: store, Auth: authService, Organizations: organizationService, Access: accessService, control: control, dataDir: dataDir}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if err = services.seed(ctx); err != nil {
services.Close()
return nil, err
}
return services, nil
}
func (services *Services) Close() error {
var errs []error
if services.control != nil {
errs = append(errs, services.control.Close())
}
if services.Store != nil {
errs = append(errs, services.Store.Close())
}
return errors.Join(errs...)
}
func PlatformPolicy() auth.PolicySeed {
return auth.PolicySeed{
Roles: map[string]string{PlatformOperator: "Operate the Observatory service without implicit access to organization telemetry."},
Permissions: map[string]string{PermissionPlatformOperate: "Operate platform-level service and migration controls."},
RolePermissions: map[string][]string{PlatformOperator: {PermissionPlatformOperate}},
}
}
func AccessPolicy() access.Policy {
permissions := map[string]string{
PermissionTelemetryQuery: "Query telemetry in an explicitly authorized resource scope.",
PermissionTelemetryReadSensitive: "Read fields classified as sensitive.",
PermissionSourcesManage: "Enroll, rotate, and revoke ingestion sources.",
PermissionSchemaManage: "Review field descriptors and projection changes.",
PermissionDashboardsRead: "Read saved queries and dashboards.",
PermissionDashboardsManage: "Create and change saved queries and dashboards.",
PermissionIncidentsRead: "Read incidents for an authorized scope.",
PermissionIncidentsManage: "Acknowledge, silence, and resolve incidents.",
PermissionOrganizationAudit: "Read organization-visible security and access audit events.",
PermissionOrganizationManage: "Manage organization membership invitations and teams.",
}
return access.Policy{
Roles: map[string]string{
OrganizationOwner: "Manage an organization and its Observatory resources.",
OrganizationViewer: "Read ordinary telemetry, dashboards, and incidents.",
IncidentResponder: "Read telemetry and respond to incidents without managing sources or access.",
},
Permissions: permissions,
Grants: map[string][]string{
OrganizationOwner: {
PermissionTelemetryQuery, PermissionTelemetryReadSensitive,
PermissionSourcesManage, PermissionSchemaManage, PermissionDashboardsRead,
PermissionDashboardsManage, PermissionIncidentsRead,
PermissionIncidentsManage, PermissionOrganizationAudit,
PermissionOrganizationManage,
},
OrganizationViewer: {PermissionTelemetryQuery, PermissionDashboardsRead, PermissionIncidentsRead},
IncidentResponder: {PermissionTelemetryQuery, PermissionDashboardsRead, PermissionIncidentsRead, PermissionIncidentsManage},
},
}
}
func (services *Services) CancelUnusedInvitation(ctx context.Context, digest [32]byte) error {
result, err := services.control.ExecContext(ctx, `DELETE FROM gwf_organization_invitations WHERE token_hash=? AND used_at IS NULL`, digest[:])
if err != nil {
return errors.New("identity: cancel invitation")
}
if changed, _ := result.RowsAffected(); changed != 1 {
return errors.New("identity: unused invitation was not found")
}
return nil
}
func (services *Services) seed(ctx context.Context) error {
if err := services.Store.SeedPolicy(ctx, PlatformPolicy()); err != nil {
return fmt.Errorf("identity: seed platform policy: %w", err)
}
if err := services.Access.Seed(ctx); err != nil {
return fmt.Errorf("identity: seed organization access policy: %w", err)
}
return nil
}
func (services *Services) ValidateResourceScope(ctx context.Context, scope access.Scope) error {
if err := scope.Validate(); err != nil {
return err
}
queryText := `SELECT COUNT(*) FROM gwf_organizations WHERE id=?`
arguments := []any{scope.OrganizationID}
switch {
case scope.ServiceID != "":
queryText = `SELECT COUNT(*) FROM gwf_application_services WHERE id=? AND environment_id=? AND project_id=? AND organization_id=?`
arguments = []any{scope.ServiceID, scope.EnvironmentID, scope.ProjectID, scope.OrganizationID}
case scope.EnvironmentID != "":
queryText = `SELECT COUNT(*) FROM gwf_environments WHERE id=? AND project_id=? AND organization_id=?`
arguments = []any{scope.EnvironmentID, scope.ProjectID, scope.OrganizationID}
case scope.ProjectID != "":
queryText = `SELECT COUNT(*) FROM gwf_projects WHERE id=? AND organization_id=?`
arguments = []any{scope.ProjectID, scope.OrganizationID}
}
var count int
if err := services.control.QueryRowContext(ctx, queryText, arguments...).Scan(&count); err != nil {
return fmt.Errorf("identity: validate resource scope: %w", err)
}
if count != 1 {
return ErrResourceNotFound
}
return nil
}
// OrganizationsForUser returns only active organizations in which the user
// has a direct membership. Access grants remain the independent authority for
// every operation performed after selection.
func (services *Services) OrganizationsForUser(ctx context.Context, userID string) ([]organizations.Organization, error) {
memberships, err := services.Organizations.Memberships(ctx, userID)
if err != nil {
return nil, fmt.Errorf("identity: list organization memberships: %w", err)
}
result := make([]organizations.Organization, 0, len(memberships))
for _, membership := range memberships {
if membership.Status != "active" {
continue
}
var organization organizations.Organization
var personal int
var createdAt int64
err = services.control.QueryRowContext(ctx, `SELECT id,slug,name,personal,created_at FROM gwf_organizations WHERE id=?`, membership.OrganizationID).Scan(&organization.ID, &organization.Slug, &organization.Name, &personal, &createdAt)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrResourceNotFound
}
if err != nil {
return nil, fmt.Errorf("identity: read organization: %w", err)
}
organization.Personal = personal == 1
organization.CreatedAt = time.Unix(createdAt, 0).UTC()
result = append(result, organization)
}
sort.Slice(result, func(i, j int) bool {
if result[i].Name == result[j].Name {
return result[i].ID < result[j].ID
}
return result[i].Name < result[j].Name
})
return result, nil
}
type BootstrapInput struct {
Username, Email, DisplayName, Password string
RequirePasswordChange bool
}
type BootstrapResult struct {
User auth.User
Organization organizations.Organization
Binding access.Binding
}
type UserProvisionResult struct {
User auth.User
Organization organizations.Organization
Binding access.Binding
}
// ProvisionUser creates an active local user and the personal organization
// that owns their private work. Shared organization access still requires a
// separately authorized, expiring invitation.
func (services *Services) ProvisionUser(ctx context.Context, input auth.CreateUser) (UserProvisionResult, error) {
user, err := services.Auth.CreateUser(ctx, input)
if err != nil {
return UserProvisionResult{}, fmt.Errorf("identity: create user: %w", err)
}
organization, err := services.Organizations.CreatePersonalOrganization(ctx, user.ID, user.DisplayName)
if err != nil {
return UserProvisionResult{}, fmt.Errorf("identity: create personal organization: %w", err)
}
binding, err := services.Access.Grant(ctx, access.Grant{
SubjectKind: access.User, SubjectID: user.ID, Role: OrganizationOwner,
Scope: access.Scope{OrganizationID: organization.ID}, GrantedBy: user.ID,
})
if err != nil {
return UserProvisionResult{}, fmt.Errorf("identity: grant personal organization ownership: %w", err)
}
return UserProvisionResult{User: user, Organization: organization, Binding: binding}, nil
}
func (services *Services) Bootstrap(ctx context.Context, input BootstrapInput) (BootstrapResult, error) {
lock, err := openBootstrapLock(filepath.Join(services.dataDir, ".bootstrap.lock"))
if err != nil {
return BootstrapResult{}, err
}
defer lock.Close()
var users int
if err = services.control.QueryRowContext(ctx, `SELECT COUNT(*) FROM gwf_users`).Scan(&users); err != nil {
return BootstrapResult{}, fmt.Errorf("identity: inspect bootstrap state: %w", err)
}
if users != 0 {
return BootstrapResult{}, ErrAlreadyBootstrapped
}
provisioned, err := services.ProvisionUser(ctx, auth.CreateUser{
Username: input.Username, Email: input.Email,
DisplayName: input.DisplayName, Password: input.Password,
RequirePasswordChange: input.RequirePasswordChange,
})
if err != nil {
return BootstrapResult{}, fmt.Errorf("identity: create first operator: %w", err)
}
now := time.Now().UTC()
if err = services.Store.GrantRole(ctx, provisioned.User.ID, PlatformOperator, now); err != nil {
return BootstrapResult{}, fmt.Errorf("identity: grant platform operator: %w", err)
}
return BootstrapResult{User: provisioned.User, Organization: provisioned.Organization, Binding: provisioned.Binding}, nil
}
func openBootstrapLock(path string) (*os.File, error) {
file, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR|syscall.O_NOFOLLOW, 0o600)
if err != nil {
return nil, fmt.Errorf("identity: open bootstrap lock: %w", err)
}
if err = syscall.Flock(int(file.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
file.Close()
return nil, errors.New("identity: another bootstrap operation is active")
}
info, err := file.Stat()
if err != nil || !info.Mode().IsRegular() || info.Mode().Perm() != 0o600 {
file.Close()
return nil, errors.New("identity: bootstrap lock must be a private regular file")
}
return file, nil
}
func sqliteDSN(path string) string {
return (&url.URL{Scheme: "file", Path: filepath.ToSlash(path), RawQuery: "_pragma=busy_timeout(5000)&_pragma=foreign_keys(1)&_pragma=journal_mode(WAL)&_pragma=synchronous(FULL)"}).String()
}
func ReadSecret(path string, requireRoot bool) (string, error) {
if !filepath.IsAbs(path) || filepath.Clean(path) != path {
return "", errors.New("identity: secret path must be absolute and clean")
}
info, err := os.Lstat(path)
if err != nil {
return "", fmt.Errorf("identity: inspect secret file: %w", err)
}
if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 || info.Mode().Perm() != 0o600 {
return "", errors.New("identity: secret must be a regular non-symlink file with mode 0600")
}
if requireRoot {
stat, ok := info.Sys().(*syscall.Stat_t)
if !ok || stat.Uid != 0 {
return "", errors.New("identity: secret must be owned by root")
}
}
value, err := os.ReadFile(path)
if err != nil {
return "", fmt.Errorf("identity: read secret file: %w", err)
}
secret := strings.TrimSuffix(string(value), "\n")
secret = strings.TrimSuffix(secret, "\r")
if secret == "" || strings.ContainsAny(secret, "\x00\r\n") {
return "", errors.New("identity: secret file must contain one non-empty line")
}
return secret, nil
}
func WriteSecret(path, secret string) error {
if !filepath.IsAbs(path) || filepath.Clean(path) != path || secret == "" || len(secret) > 1024 || strings.ContainsAny(secret, "\x00\r\n") {
return errors.New("identity: secret output is invalid")
}
parent := filepath.Dir(path)
info, err := os.Lstat(parent)
if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
return errors.New("identity: secret output directory must be an existing non-symlink directory")
}
file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL|syscall.O_NOFOLLOW, 0o600)
if err != nil {
return fmt.Errorf("identity: create secret output: %w", err)
}
remove := true
defer func() {
_ = file.Close()
if remove {
_ = os.Remove(path)
}
}()
if err = file.Chmod(0o600); err == nil {
_, err = io.WriteString(file, secret+"\n")
}
if err == nil {
err = file.Sync()
}
if closeErr := file.Close(); err == nil {
err = closeErr
}
if err != nil {
return errors.New("identity: persist secret output")
}
directory, err := os.Open(parent)
if err != nil {
return errors.New("identity: open secret output directory")
}
if err = directory.Sync(); err != nil {
directory.Close()
return errors.New("identity: persist secret output directory")
}
if err = directory.Close(); err != nil {
return errors.New("identity: close secret output directory")
}
remove = false
return nil
}
// RemoveSecret removes only an exact private regular secret file and syncs its
// parent directory. It is used to clean up a generated bootstrap credential
// when bootstrap cannot commit an operator.
func RemoveSecret(path string, requireRoot bool) error {
if _, err := ReadSecret(path, requireRoot); err != nil {
return err
}
if err := os.Remove(path); err != nil {
return fmt.Errorf("identity: remove secret file: %w", err)
}
directory, err := os.Open(filepath.Dir(path))
if err != nil {
return errors.New("identity: open secret output directory")
}
if err = directory.Sync(); err != nil {
directory.Close()
return errors.New("identity: persist secret output directory")
}
if err = directory.Close(); err != nil {
return errors.New("identity: close secret output directory")
}
return nil
}
+344
View File
@@ -0,0 +1,344 @@
// SPDX-License-Identifier: AGPL-3.0-only
package identity
import (
"context"
"errors"
"os"
"path/filepath"
"runtime"
"testing"
"time"
"gamertan.com/web/access"
"gamertan.com/web/auth"
"gamertan.com/web/organizations"
)
func TestBootstrapSeparatesPlatformAndOrganizationAccess(t *testing.T) {
root := t.TempDir()
if err := os.Chmod(root, 0o700); err != nil {
t.Fatal(err)
}
services, err := Open(root)
if err != nil {
t.Fatal(err)
}
defer services.Close()
result, err := services.Bootstrap(context.Background(), BootstrapInput{
Username: "operator", Email: "operator@example.test",
DisplayName: "First Operator", Password: "correct horse battery staple",
})
if err != nil {
t.Fatal(err)
}
if !result.Organization.Personal || result.Binding.Role != OrganizationOwner {
t.Fatalf("result=%+v", result)
}
token, principal, err := services.Auth.Authenticate(context.Background(), "operator", "correct horse battery staple", time.Hour)
if err != nil || token == "" || !principal.Has(PermissionPlatformOperate) {
t.Fatalf("platform session: token_present=%t principal=%+v err=%v", token != "", principal, err)
}
decision, err := services.Access.Authorize(context.Background(), result.User.ID, access.Scope{OrganizationID: result.Organization.ID}, PermissionTelemetryQuery)
if err != nil || !decision.Allowed || decision.Role != OrganizationOwner {
t.Fatalf("query decision=%+v err=%v", decision, err)
}
decision, err = services.Access.Authorize(context.Background(), result.User.ID, access.Scope{OrganizationID: result.Organization.ID}, PermissionTelemetryReadSensitive)
if err != nil || !decision.Allowed {
t.Fatalf("sensitive decision=%+v err=%v", decision, err)
}
decision, err = services.Access.Authorize(context.Background(), result.User.ID, access.Scope{OrganizationID: result.Organization.ID}, PermissionSchemaManage)
if err != nil || !decision.Allowed {
t.Fatalf("schema decision=%+v err=%v", decision, err)
}
if _, err = services.Access.Authorize(context.Background(), result.User.ID, access.Scope{OrganizationID: result.Organization.ID}, PermissionPlatformOperate); err == nil {
t.Fatal("platform permission entered organization access policy")
}
project, err := services.Organizations.CreateProject(context.Background(), organizations.CreateProject{OrganizationID: result.Organization.ID, Slug: "eql-helper", Name: "EQL Helper"})
if err != nil {
t.Fatal(err)
}
environment, err := services.Organizations.CreateEnvironment(context.Background(), organizations.CreateEnvironment{OrganizationID: result.Organization.ID, ProjectID: project.ID, Slug: "production", Name: "Production"})
if err != nil {
t.Fatal(err)
}
application, err := services.Organizations.CreateApplicationService(context.Background(), organizations.CreateApplicationService{OrganizationID: result.Organization.ID, ProjectID: project.ID, EnvironmentID: environment.ID, Slug: "web", Name: "Web"})
if err != nil {
t.Fatal(err)
}
scope := access.Scope{OrganizationID: result.Organization.ID, ProjectID: project.ID, EnvironmentID: environment.ID, ServiceID: application.ID}
if err = services.ValidateResourceScope(context.Background(), scope); err != nil {
t.Fatal(err)
}
scope.ServiceID = "missing1"
if err = services.ValidateResourceScope(context.Background(), scope); !errors.Is(err, ErrResourceNotFound) {
t.Fatalf("missing scope err=%v", err)
}
}
func TestBootstrapIsSingleUse(t *testing.T) {
root := t.TempDir()
if err := os.Chmod(root, 0o700); err != nil {
t.Fatal(err)
}
services, err := Open(root)
if err != nil {
t.Fatal(err)
}
defer services.Close()
input := BootstrapInput{Username: "operator", Email: "operator@example.test", DisplayName: "First Operator", Password: "correct horse battery staple"}
if _, err = services.Bootstrap(context.Background(), input); err != nil {
t.Fatal(err)
}
if _, err = services.Bootstrap(context.Background(), BootstrapInput{Username: "second", Email: "second@example.test", DisplayName: "Second Operator", Password: "correct horse battery staple"}); !errors.Is(err, ErrAlreadyBootstrapped) {
t.Fatalf("second bootstrap err=%v", err)
}
}
func TestBootstrapCanRequirePasswordChange(t *testing.T) {
root := t.TempDir()
if err := os.Chmod(root, 0o700); err != nil {
t.Fatal(err)
}
services, err := Open(root)
if err != nil {
t.Fatal(err)
}
defer services.Close()
result, err := services.Bootstrap(t.Context(), BootstrapInput{
Username: "operator", Email: "operator@example.test", DisplayName: "First Operator",
Password: "temporary correct horse battery staple", RequirePasswordChange: true,
})
if err != nil || !result.User.PasswordChangeRequired {
t.Fatalf("result=%+v err=%v", result, err)
}
_, principal, err := services.Auth.Authenticate(t.Context(), "operator", "temporary correct horse battery staple", time.Hour)
if err != nil || !principal.User.PasswordChangeRequired {
t.Fatalf("principal=%+v err=%v", principal, err)
}
}
func TestRemoveSecretValidatesAndRemovesOnlyPrivateRegularFile(t *testing.T) {
root := t.TempDir()
path := filepath.Join(root, "bootstrap-password")
if err := WriteSecret(path, "temporary secret"); err != nil {
t.Fatal(err)
}
if err := RemoveSecret(path, false); err != nil {
t.Fatal(err)
}
if _, err := os.Lstat(path); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("removed path err=%v", err)
}
unsafe := filepath.Join(root, "unsafe")
if err := os.WriteFile(unsafe, []byte("secret\n"), 0o644); err != nil {
t.Fatal(err)
}
if err := RemoveSecret(unsafe, false); err == nil {
t.Fatal("world-readable secret was removed")
}
if _, err := os.Stat(unsafe); err != nil {
t.Fatalf("unsafe file changed: %v", err)
}
}
func TestEvidenceRetentionPrunesOnlyExpiredAudit(t *testing.T) {
ctx := t.Context()
root := t.TempDir()
if err := os.Chmod(root, 0o700); err != nil {
t.Fatal(err)
}
services, err := Open(root)
if err != nil {
t.Fatal(err)
}
defer services.Close()
owner, err := services.Bootstrap(ctx, BootstrapInput{Username: "operator", Email: "operator@example.test", DisplayName: "First Operator", Password: "correct horse battery staple"})
if err != nil {
t.Fatal(err)
}
now := time.Date(2026, 8, 17, 5, 0, 0, 0, time.UTC)
for _, event := range []struct {
id string
created time.Time
}{{"audit-old", now.Add(-401 * 24 * time.Hour)}, {"audit-current", now.Add(-399 * 24 * time.Hour)}} {
if _, err = services.control.ExecContext(ctx, `INSERT INTO gwf_audit_events(id,actor_user_id,action,resource_type,resource_id,summary,created_at) VALUES(?,?,?,?,?,?,?)`, event.id, owner.User.ID, "session.test", "user", owner.User.ID, "Test event", event.created.Unix()); err != nil {
t.Fatal(err)
}
if _, err = services.control.ExecContext(ctx, `INSERT INTO gwf_access_audit_events(id,organization_id,actor_user_id,action,resource_type,resource_id,summary,created_at) VALUES(?,?,?,?,?,?,?,?)`, "access-"+event.id, owner.Organization.ID, owner.User.ID, "access.test", "organization", owner.Organization.ID, "Test event", event.created.Unix()); err != nil {
t.Fatal(err)
}
}
report, err := services.PruneEvidence(ctx, 400, now)
if err != nil {
t.Fatal(err)
}
if report.AuthenticationEvents != 1 || report.OrganizationEvents != 1 {
t.Fatalf("report=%+v", report)
}
for _, table := range []string{"gwf_audit_events", "gwf_access_audit_events"} {
var count int
if err = services.control.QueryRowContext(ctx, `SELECT COUNT(*) FROM `+table).Scan(&count); err != nil || count != 1 {
t.Fatalf("table=%s count=%d err=%v", table, count, err)
}
}
}
func TestTeamsInvitationsRevocationAndBreakGlassRemainOrganizationScoped(t *testing.T) {
ctx := context.Background()
root := t.TempDir()
if err := os.Chmod(root, 0o700); err != nil {
t.Fatal(err)
}
services, err := Open(root)
if err != nil {
t.Fatal(err)
}
defer services.Close()
owner, err := services.Bootstrap(ctx, BootstrapInput{Username: "operator", Email: "operator@example.test", DisplayName: "First Operator", Password: "correct horse battery staple"})
if err != nil {
t.Fatal(err)
}
member, err := services.Auth.CreateUser(ctx, auth.CreateUser{Username: "responder", Email: "responder@example.test", DisplayName: "Incident Responder", Password: "another correct horse battery staple"})
if err != nil {
t.Fatal(err)
}
rawInvitation, invitation, err := services.Organizations.Invite(ctx, owner.Organization.ID, member.Email, owner.User.ID, 15*time.Minute)
if err != nil || rawInvitation == "" || invitation.OrganizationID != owner.Organization.ID {
t.Fatalf("invitation=%+v token_present=%t err=%v", invitation, rawInvitation != "", err)
}
if err = services.Organizations.AcceptInvitation(ctx, rawInvitation, member.ID); err != nil {
t.Fatal(err)
}
team, err := services.Organizations.CreateTeam(ctx, organizations.CreateTeam{OrganizationID: owner.Organization.ID, Slug: "responders", Name: "Incident Responders"})
if err != nil {
t.Fatal(err)
}
if err = services.Organizations.AddTeamMember(ctx, team.ID, member.ID); err != nil {
t.Fatal(err)
}
binding, err := services.Access.Grant(ctx, access.Grant{SubjectKind: access.Team, SubjectID: team.ID, Role: IncidentResponder, Scope: access.Scope{OrganizationID: owner.Organization.ID}, GrantedBy: owner.User.ID})
if err != nil {
t.Fatal(err)
}
decision, err := services.Access.Authorize(ctx, member.ID, access.Scope{OrganizationID: owner.Organization.ID}, PermissionIncidentsManage)
if err != nil || !decision.Allowed || decision.Source != "role" || decision.Role != IncidentResponder {
t.Fatalf("team decision=%+v err=%v", decision, err)
}
decision, err = services.Access.Authorize(ctx, owner.User.ID, access.Scope{OrganizationID: owner.Organization.ID}, PermissionOrganizationManage)
if err != nil || !decision.Allowed || decision.Role != OrganizationOwner {
t.Fatalf("owner organization-management decision=%+v err=%v", decision, err)
}
decision, err = services.Access.Authorize(ctx, member.ID, access.Scope{OrganizationID: owner.Organization.ID}, PermissionOrganizationManage)
if err != nil || decision.Allowed {
t.Fatalf("member organization-management decision=%+v err=%v", decision, err)
}
cancelToken, cancelInvitation, err := services.Organizations.Invite(ctx, owner.Organization.ID, "cancelled@example.test", owner.User.ID, 15*time.Minute)
if err != nil || cancelToken == "" {
t.Fatalf("cancel invitation=%+v token_present=%t err=%v", cancelInvitation, cancelToken != "", err)
}
if err = services.CancelUnusedInvitation(ctx, cancelInvitation.Digest); err != nil {
t.Fatal(err)
}
if err = services.Organizations.AcceptInvitation(ctx, cancelToken, member.ID); err == nil {
t.Fatal("cancelled invitation remained usable")
}
other, err := services.Organizations.CreatePersonalOrganization(ctx, member.ID, member.DisplayName)
if err != nil {
t.Fatal(err)
}
decision, err = services.Access.Authorize(ctx, owner.User.ID, access.Scope{OrganizationID: other.ID}, PermissionTelemetryQuery)
if err != nil || decision.Allowed {
t.Fatalf("cross-organization decision=%+v err=%v", decision, err)
}
decision, err = services.Access.Authorize(ctx, member.ID, access.Scope{OrganizationID: owner.Organization.ID}, PermissionTelemetryReadSensitive)
if err != nil || decision.Allowed {
t.Fatalf("unexpected sensitive decision=%+v err=%v", decision, err)
}
breakGlass, err := services.Access.ActivateBreakGlass(ctx, owner.Organization.ID, member.ID, PermissionTelemetryReadSensitive, "Investigate an active incident", "request-12345678", 15*time.Minute)
if err != nil {
t.Fatal(err)
}
decision, err = services.Access.Authorize(ctx, member.ID, access.Scope{OrganizationID: owner.Organization.ID}, PermissionTelemetryReadSensitive)
if err != nil || !decision.Allowed || decision.Source != "break_glass" {
t.Fatalf("break-glass decision=%+v err=%v", decision, err)
}
audit, err := services.Access.Audit(ctx, owner.Organization.ID, 10)
if err != nil || len(audit) != 1 || audit[0].Action != "break_glass.activate" || audit[0].ResourceID != owner.Organization.ID {
t.Fatalf("audit=%+v err=%v", audit, err)
}
if _, err = services.control.ExecContext(ctx, `UPDATE gwf_break_glass SET expires_at=? WHERE id=?`, time.Now().Add(-time.Minute).Unix(), breakGlass.ID); err != nil {
t.Fatal(err)
}
decision, err = services.Access.Authorize(ctx, member.ID, access.Scope{OrganizationID: owner.Organization.ID}, PermissionTelemetryReadSensitive)
if err != nil || decision.Allowed {
t.Fatalf("expired break-glass decision=%+v err=%v", decision, err)
}
if err = services.Store.Revoke(ctx, binding.ID, owner.User.ID, time.Now().UTC()); err != nil {
t.Fatal(err)
}
decision, err = services.Access.Authorize(ctx, member.ID, access.Scope{OrganizationID: owner.Organization.ID}, PermissionIncidentsManage)
if err != nil || decision.Allowed {
t.Fatalf("revoked team decision=%+v err=%v", decision, err)
}
}
func TestReadSecretRejectsWeakFiles(t *testing.T) {
dir := t.TempDir()
valid := filepath.Join(dir, "password")
if err := os.WriteFile(valid, []byte("correct horse battery staple\n"), 0o600); err != nil {
t.Fatal(err)
}
secret, err := ReadSecret(valid, false)
if err != nil || secret != "correct horse battery staple" {
t.Fatalf("secret=%q err=%v", secret, err)
}
weak := filepath.Join(dir, "weak")
if err = os.WriteFile(weak, []byte("not private"), 0o644); err != nil {
t.Fatal(err)
}
if _, err = ReadSecret(weak, false); err == nil {
t.Fatal("world-readable secret accepted")
}
if runtime.GOOS != "windows" {
link := filepath.Join(dir, "link")
if err = os.Symlink(valid, link); err != nil {
t.Fatal(err)
}
if _, err = ReadSecret(link, false); err == nil {
t.Fatal("symlinked secret accepted")
}
}
}
func TestWriteSecretIsPrivateExclusiveAndReadable(t *testing.T) {
path := filepath.Join(t.TempDir(), "invitation")
const secret = "single-use-invitation-token"
if err := WriteSecret(path, secret); err != nil {
t.Fatal(err)
}
info, err := os.Lstat(path)
if err != nil || !info.Mode().IsRegular() || info.Mode().Perm() != 0o600 {
t.Fatalf("info=%v err=%v", info, err)
}
got, err := ReadSecret(path, false)
if err != nil || got != secret {
t.Fatalf("secret=%q err=%v", got, err)
}
if err = WriteSecret(path, "replacement"); err == nil {
t.Fatal("existing secret was overwritten")
}
if err = WriteSecret(filepath.Join(filepath.Dir(path), "multiline"), "first\nsecond"); err == nil {
t.Fatal("multiline secret was accepted")
}
if runtime.GOOS != "windows" {
linkedParent := filepath.Join(filepath.Dir(path), "linked-parent")
if err = os.Symlink(filepath.Dir(path), linkedParent); err != nil {
t.Fatal(err)
}
if err = WriteSecret(filepath.Join(linkedParent, "through-link"), "secret"); err == nil {
t.Fatal("symlinked secret output directory was accepted")
}
}
}
+93
View File
@@ -0,0 +1,93 @@
// SPDX-License-Identifier: AGPL-3.0-only
package identity
import (
"context"
"errors"
"time"
)
type EvidencePruneReport struct {
AuthenticationEvents int64 `json:"authentication_events"`
OrganizationEvents int64 `json:"organization_events"`
ExpiredSessions int64 `json:"expired_sessions"`
ExpiredInvitations int64 `json:"expired_invitations"`
ExpiredBreakGlass int64 `json:"expired_break_glass"`
}
// PruneEvidence applies the server default to platform authentication audit
// events and each organization's approved retention override to its visible
// access audit. Operational credentials are removed only after expiration.
func (services *Services) PruneEvidence(ctx context.Context, defaultDays int, now time.Time) (EvidencePruneReport, error) {
if services == nil || services.control == nil || defaultDays < 1 || defaultDays > 3650 || now.IsZero() {
return EvidencePruneReport{}, errors.New("identity: evidence retention input is invalid")
}
tx, err := services.control.BeginTx(ctx, nil)
if err != nil {
return EvidencePruneReport{}, errors.New("identity: begin evidence retention")
}
defer tx.Rollback()
report := EvidencePruneReport{}
cutoff := now.UTC().Add(-time.Duration(defaultDays) * 24 * time.Hour).Unix()
result, err := tx.ExecContext(ctx, `DELETE FROM gwf_audit_events WHERE created_at<?`, cutoff)
if err != nil {
return report, errors.New("identity: prune authentication audit")
}
report.AuthenticationEvents, _ = result.RowsAffected()
var policyTable int
if err = tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='organization_retention_policies'`).Scan(&policyTable); err != nil {
return report, errors.New("identity: inspect organization retention policy")
}
organizationQuery := `SELECT DISTINCT organization_id,? FROM gwf_access_audit_events ORDER BY organization_id`
if policyTable == 1 {
organizationQuery = `SELECT DISTINCT audit.organization_id,COALESCE(policy.evidence_days,?) FROM gwf_access_audit_events audit LEFT JOIN organization_retention_policies policy ON policy.organization_id=audit.organization_id ORDER BY audit.organization_id`
}
rows, err := tx.QueryContext(ctx, organizationQuery, defaultDays)
if err != nil {
return report, errors.New("identity: list organization audit retention")
}
type organizationCutoff struct {
id string
days int
}
var organizations []organizationCutoff
for rows.Next() {
var organization organizationCutoff
if err = rows.Scan(&organization.id, &organization.days); err != nil || organization.days < 1 || organization.days > 3650 {
_ = rows.Close()
return report, errors.New("identity: organization audit retention is invalid")
}
organizations = append(organizations, organization)
}
if err = rows.Close(); err != nil {
return report, errors.New("identity: close organization audit retention")
}
for _, organization := range organizations {
organizationCutoff := now.UTC().Add(-time.Duration(organization.days) * 24 * time.Hour).Unix()
result, err = tx.ExecContext(ctx, `DELETE FROM gwf_access_audit_events WHERE organization_id=? AND created_at<?`, organization.id, organizationCutoff)
if err != nil {
return report, errors.New("identity: prune organization access audit")
}
removed, _ := result.RowsAffected()
report.OrganizationEvents += removed
}
for _, cleanup := range []struct {
statement string
destination *int64
}{
{`DELETE FROM gwf_auth_sessions WHERE expires_at<=?`, &report.ExpiredSessions},
{`DELETE FROM gwf_organization_invitations WHERE expires_at<=?`, &report.ExpiredInvitations},
{`DELETE FROM gwf_break_glass WHERE expires_at<=?`, &report.ExpiredBreakGlass},
} {
result, err = tx.ExecContext(ctx, cleanup.statement, now.UTC().Unix())
if err != nil {
return report, errors.New("identity: prune expired security state")
}
*cleanup.destination, _ = result.RowsAffected()
}
if err = tx.Commit(); err != nil {
return report, errors.New("identity: commit evidence retention")
}
return report, nil
}
+54
View File
@@ -0,0 +1,54 @@
// SPDX-License-Identifier: AGPL-3.0-only
package model
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"math"
"time"
)
const AlertTransitionVersion = 1
// AlertTransition is a bounded source-reported rule evaluation. Resource
// scope, rule metadata, and incident authority remain server-owned. The
// transition carries no telemetry values.
type AlertTransition struct {
Version int `json:"version"`
RuleID string `json:"rule_id"`
RuleRevision int `json:"rule_revision"`
AgentEpoch string `json:"agent_epoch"`
Sequence uint64 `json:"sequence"`
StreamID string `json:"stream_id"`
BatchSequence uint64 `json:"batch_sequence"`
SegmentDigest string `json:"segment_digest"`
WindowStart time.Time `json:"window_start"`
WindowEnd time.Time `json:"window_end"`
State string `json:"state"`
ObservedAt time.Time `json:"observed_at"`
}
func (value AlertTransition) Validate(now time.Time) error {
if value.Version != AlertTransitionVersion || validateID("rule_id", value.RuleID) != nil || value.RuleRevision < 1 || value.RuleRevision > 1_000_000 || !validLowerHex(value.AgentEpoch, 32) || value.Sequence == 0 || value.Sequence > math.MaxInt64 || ValidateStreamID(value.StreamID) != nil || value.BatchSequence == 0 || value.BatchSequence > math.MaxInt64 || !validLowerHex(value.SegmentDigest, 64) {
return errors.New("alert transition identity is invalid")
}
if value.State != "matched" && value.State != "clear" && value.State != "error" {
return errors.New("alert transition state is invalid")
}
if now.IsZero() || value.WindowStart.IsZero() || value.WindowEnd.Before(value.WindowStart) || value.WindowEnd.Sub(value.WindowStart) > 24*time.Hour || value.ObservedAt.Before(value.WindowEnd) || value.ObservedAt.Before(now.Add(-7*24*time.Hour)) || value.ObservedAt.After(now.Add(10*time.Minute)) {
return errors.New("alert transition time range is invalid")
}
return nil
}
func (value AlertTransition) Digest() (string, error) {
encoded, err := json.Marshal(value)
if err != nil {
return "", errors.New("encode alert transition digest")
}
digest := sha256.Sum256(encoded)
return hex.EncodeToString(digest[:]), nil
}
+40
View File
@@ -0,0 +1,40 @@
// SPDX-License-Identifier: AGPL-3.0-only
package model
import (
"strings"
"testing"
"time"
)
func TestAlertTransitionValidationAndDigest(t *testing.T) {
now := time.Date(2026, 8, 18, 22, 30, 0, 0, time.UTC)
value := AlertTransition{Version: AlertTransitionVersion, RuleID: "rule-a", RuleRevision: 1, AgentEpoch: strings.Repeat("a", 32), Sequence: 1, StreamID: "requests", BatchSequence: 2, SegmentDigest: strings.Repeat("b", 64), WindowStart: now.Add(-time.Minute), WindowEnd: now, State: "matched", ObservedAt: now}
if err := value.Validate(now); err != nil {
t.Fatal(err)
}
first, err := value.Digest()
if err != nil || len(first) != 64 {
t.Fatalf("digest=%q err=%v", first, err)
}
second, err := value.Digest()
if err != nil || first != second {
t.Fatalf("digest changed: %q %q err=%v", first, second, err)
}
invalid := []AlertTransition{
{},
func() AlertTransition { copy := value; copy.AgentEpoch = "not-hex"; return copy }(),
func() AlertTransition { copy := value; copy.Sequence = 0; return copy }(),
func() AlertTransition { copy := value; copy.Sequence = ^uint64(0); return copy }(),
func() AlertTransition { copy := value; copy.BatchSequence = ^uint64(0); return copy }(),
func() AlertTransition { copy := value; copy.State = "firing"; return copy }(),
func() AlertTransition { copy := value; copy.WindowStart = now.Add(-25 * time.Hour); return copy }(),
func() AlertTransition { copy := value; copy.ObservedAt = now.Add(-time.Second); return copy }(),
}
for index, candidate := range invalid {
if err := candidate.Validate(now); err == nil {
t.Fatalf("invalid transition %d accepted", index)
}
}
}
+325
View File
@@ -0,0 +1,325 @@
// SPDX-License-Identifier: AGPL-3.0-only
package model
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"math"
"strings"
"time"
"unicode/utf8"
)
// Digest returns the SHA-256 of the canonical JSON representation used by the
// native agent protocol. It binds an acknowledgement to the exact logical
// batch independently of either side's private compressed storage format.
func (b Batch) Digest() (string, error) {
encoded, err := json.Marshal(b)
if err != nil {
return "", errors.New("encode batch digest")
}
digest := sha256.Sum256(encoded)
return hex.EncodeToString(digest[:]), nil
}
const (
BatchVersion = 1
BatchEnvelopeVersion = 1
MaxRecords = 5_000
MaxAttributes = 64
MaxAttributeKey = 128
MaxAttributeValue = 4_096
MaxName = 256
MaxBody = 16_384
MaxDistinctFields = 1_024
)
// BatchEnvelope is the bounded transport metadata for one native batch. The
// enrolled credential supplies source and tenant scope; timestamps are useful
// partition hints and deliberately do not participate in record-level
// deduplication.
type BatchEnvelope struct {
Version int
StreamID string
Sequence uint64
Signal Signal
WireDigest string
BatchDigest string
RecordCount int
EncodedBytes int64
FirstObservedAt time.Time
LastObservedAt time.Time
}
type Signal string
const (
SignalLogs Signal = "logs"
SignalMetrics Signal = "metrics"
SignalTraces Signal = "traces"
SignalDeployments Signal = "deployments"
)
type Batch struct {
Version int `json:"version"`
SourceID string `json:"source_id"`
StreamID string `json:"stream_id"`
Sequence uint64 `json:"sequence"`
ObservedAt time.Time `json:"observed_at"`
Signal Signal `json:"signal"`
Records []Observation `json:"records"`
}
type Observation struct {
Timestamp time.Time `json:"timestamp"`
Name string `json:"name"`
Severity string `json:"severity,omitempty"`
Body string `json:"body,omitempty"`
Value *float64 `json:"value,omitempty"`
TraceID string `json:"trace_id,omitempty"`
SpanID string `json:"span_id,omitempty"`
CorrelationID string `json:"correlation_id,omitempty"`
Attributes map[string]string `json:"attributes,omitempty"`
}
type Scope struct {
OrganizationID string `json:"organization_id"`
ProjectID string `json:"project_id"`
EnvironmentID string `json:"environment_id"`
ServiceID string `json:"service_id"`
}
// Envelope returns transport metadata bound to the exact encoded body and the
// canonical logical batch. The two digests are intentionally distinct even
// when the current JSON encoder happens to produce identical bytes.
func (b Batch) Envelope(encoded []byte) (BatchEnvelope, error) {
if len(encoded) == 0 {
return BatchEnvelope{}, errors.New("encoded batch is empty")
}
batchDigest, err := b.Digest()
if err != nil {
return BatchEnvelope{}, err
}
wireDigest := sha256.Sum256(encoded)
first, last := b.ObservationRange()
return BatchEnvelope{
Version: BatchEnvelopeVersion,
StreamID: b.StreamID,
Sequence: b.Sequence,
Signal: b.Signal,
WireDigest: hex.EncodeToString(wireDigest[:]),
BatchDigest: batchDigest,
RecordCount: len(b.Records),
EncodedBytes: int64(len(encoded)),
FirstObservedAt: first,
LastObservedAt: last,
}, nil
}
func (b Batch) ObservationRange() (time.Time, time.Time) {
if len(b.Records) == 0 {
return time.Time{}, time.Time{}
}
first, last := b.Records[0].Timestamp.UTC(), b.Records[0].Timestamp.UTC()
for _, record := range b.Records[1:] {
observed := record.Timestamp.UTC()
if observed.Before(first) {
first = observed
}
if observed.After(last) {
last = observed
}
}
return first, last
}
func (e BatchEnvelope) Validate(maxEncodedBytes int64) error {
if e.Version != BatchEnvelopeVersion {
return errors.New("unsupported batch envelope version")
}
if err := ValidateStreamID(e.StreamID); err != nil {
return err
}
if e.Sequence == 0 {
return errors.New("sequence must be positive")
}
if !e.Signal.valid() {
return errors.New("unsupported signal")
}
if !validLowerHex(e.WireDigest, sha256.Size*2) || !validLowerHex(e.BatchDigest, sha256.Size*2) {
return errors.New("batch envelope digest is invalid")
}
if e.RecordCount < 1 || e.RecordCount > MaxRecords {
return errors.New("batch envelope record count is invalid")
}
if e.EncodedBytes < 1 || e.EncodedBytes > maxEncodedBytes {
return errors.New("batch envelope byte count is invalid")
}
if e.FirstObservedAt.IsZero() || e.LastObservedAt.IsZero() || e.LastObservedAt.Before(e.FirstObservedAt) {
return errors.New("batch envelope time range is invalid")
}
return nil
}
// Match proves that the decoded batch and exact transport bytes agree with
// the agent-supplied envelope. Tenant scope remains absent by design.
func (e BatchEnvelope) Match(batch Batch, encoded []byte) error {
if err := e.Validate(int64(len(encoded))); err != nil || e.EncodedBytes != int64(len(encoded)) {
return errors.New("batch envelope does not match encoded body")
}
expected, err := batch.Envelope(encoded)
if err != nil {
return err
}
if e != expected {
return errors.New("batch envelope does not match encoded body")
}
return nil
}
func (b Batch) Validate(now time.Time) error {
if b.Version != BatchVersion {
return fmt.Errorf("unsupported batch version %d", b.Version)
}
if err := validateID("source_id", b.SourceID); err != nil {
return err
}
if err := validateID("stream_id", b.StreamID); err != nil {
return err
}
if b.Sequence == 0 {
return errors.New("sequence must be positive")
}
if !b.Signal.valid() {
return errors.New("unsupported signal")
}
if b.ObservedAt.IsZero() || b.ObservedAt.Before(now.Add(-7*24*time.Hour)) || b.ObservedAt.After(now.Add(10*time.Minute)) {
return errors.New("observed_at outside accepted clock-skew window")
}
if len(b.Records) == 0 || len(b.Records) > MaxRecords {
return fmt.Errorf("records must contain between 1 and %d items", MaxRecords)
}
distinctFields := map[string]struct{}{}
for i, record := range b.Records {
if err := record.validate(b.Signal, now); err != nil {
return fmt.Errorf("record %d: %w", i, err)
}
for field := range record.Attributes {
distinctFields[field] = struct{}{}
if len(distinctFields) > MaxDistinctFields {
return fmt.Errorf("batch contains more than %d distinct attribute fields", MaxDistinctFields)
}
}
}
return nil
}
func (s Signal) valid() bool {
switch s {
case SignalLogs, SignalMetrics, SignalTraces, SignalDeployments:
return true
default:
return false
}
}
func (o Observation) validate(signal Signal, now time.Time) error {
if o.Timestamp.IsZero() || o.Timestamp.Before(now.Add(-400*24*time.Hour)) || o.Timestamp.After(now.Add(10*time.Minute)) {
return errors.New("timestamp outside accepted window")
}
if err := validateText("name", o.Name, MaxName, false); err != nil {
return err
}
if err := validateText("body", o.Body, MaxBody, true); err != nil {
return err
}
if err := validateText("severity", o.Severity, 64, true); err != nil {
return err
}
if o.TraceID != "" && !validLowerHex(o.TraceID, 32) {
return errors.New("trace_id must be 16 bytes encoded as lowercase hexadecimal")
}
if o.SpanID != "" && !validLowerHex(o.SpanID, 16) {
return errors.New("span_id must be 8 bytes encoded as lowercase hexadecimal")
}
if err := validateText("correlation_id", o.CorrelationID, 128, true); err != nil {
return err
}
if signal == SignalMetrics && o.Value == nil {
return errors.New("metric requires value")
}
if o.Value != nil && (math.IsNaN(*o.Value) || math.IsInf(*o.Value, 0)) {
return errors.New("value must be finite")
}
if len(o.Attributes) > MaxAttributes {
return fmt.Errorf("too many attributes: maximum %d", MaxAttributes)
}
for key, value := range o.Attributes {
if err := validateText("attribute key", key, MaxAttributeKey, false); err != nil {
return err
}
if err := validateText("attribute value", value, MaxAttributeValue, true); err != nil {
return err
}
}
return nil
}
func validLowerHex(value string, length int) bool {
if len(value) != length {
return false
}
for _, character := range value {
if !(character >= '0' && character <= '9' || character >= 'a' && character <= 'f') {
return false
}
}
return true
}
func (s Scope) Validate() error {
for label, value := range map[string]string{
"organization_id": s.OrganizationID,
"project_id": s.ProjectID,
"environment_id": s.EnvironmentID,
"service_id": s.ServiceID,
} {
if err := validateID(label, value); err != nil {
return err
}
}
return nil
}
func validateID(label, value string) error {
if len(value) < 1 || len(value) > 128 {
return fmt.Errorf("%s length must be between 1 and 128", label)
}
for _, r := range value {
if !(r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || strings.ContainsRune("._-", r)) {
return fmt.Errorf("%s contains an invalid character", label)
}
}
return nil
}
func ValidateSourceID(value string) error { return validateID("source_id", value) }
func ValidateStreamID(value string) error { return validateID("stream_id", value) }
func validateText(label, value string, max int, empty bool) error {
if !utf8.ValidString(value) || strings.IndexByte(value, 0) >= 0 {
return fmt.Errorf("%s must be valid UTF-8 without NUL", label)
}
if !empty && value == "" {
return fmt.Errorf("%s is required", label)
}
if len(value) > max {
return fmt.Errorf("%s exceeds %d bytes", label, max)
}
return nil
}
+147
View File
@@ -0,0 +1,147 @@
// SPDX-License-Identifier: AGPL-3.0-only
package model
import (
"encoding/json"
"fmt"
"math"
"strings"
"testing"
"time"
)
func TestBatchEnvelopeBindsTransportAndTimePartitionHints(t *testing.T) {
now := time.Date(2026, 8, 18, 20, 0, 0, 0, time.UTC)
batch := Batch{Version: BatchVersion, SourceID: "source", StreamID: "logs", Sequence: 1, ObservedAt: now, Signal: SignalLogs, Records: []Observation{{Timestamp: now, Name: "latest"}, {Timestamp: now.Add(-time.Hour), Name: "earliest"}}}
body, err := json.Marshal(batch)
if err != nil {
t.Fatal(err)
}
envelope, err := batch.Envelope(body)
if err != nil {
t.Fatal(err)
}
if envelope.RecordCount != 2 || envelope.EncodedBytes != int64(len(body)) || envelope.FirstObservedAt != now.Add(-time.Hour) || envelope.LastObservedAt != now || envelope.WireDigest != envelope.BatchDigest {
t.Fatalf("envelope=%+v", envelope)
}
if err = envelope.Match(batch, body); err != nil {
t.Fatal(err)
}
padded := append([]byte(" \n"), body...)
paddedEnvelope, err := batch.Envelope(padded)
if err != nil || paddedEnvelope.WireDigest == envelope.WireDigest || paddedEnvelope.BatchDigest != envelope.BatchDigest || paddedEnvelope.EncodedBytes != int64(len(padded)) {
t.Fatalf("padded=%+v err=%v", paddedEnvelope, err)
}
if err = envelope.Match(batch, padded); err == nil {
t.Fatal("transport mutation was accepted")
}
// Overlapping time ranges are valid metadata, not a uniqueness key.
batch.Sequence = 2
batch.Records = []Observation{{Timestamp: now.Add(-30 * time.Minute), Name: "overlap"}}
body, _ = json.Marshal(batch)
if overlap, overlapErr := batch.Envelope(body); overlapErr != nil || overlap.FirstObservedAt != now.Add(-30*time.Minute) {
t.Fatalf("overlap=%+v err=%v", overlap, overlapErr)
}
}
func TestBatchDigestIsCanonicalAndContentBound(t *testing.T) {
now := time.Date(2026, 8, 17, 18, 0, 0, 0, time.UTC)
left := Batch{Version: BatchVersion, SourceID: "source", StreamID: "logs", Sequence: 1, ObservedAt: now, Signal: SignalLogs, Records: []Observation{{Timestamp: now, Name: "request", Attributes: map[string]string{"z": "last", "a": "first"}}}}
right := left
right.Records = []Observation{{Timestamp: now, Name: "request", Attributes: map[string]string{"a": "first", "z": "last"}}}
leftDigest, err := left.Digest()
if err != nil {
t.Fatal(err)
}
rightDigest, err := right.Digest()
if err != nil {
t.Fatal(err)
}
if leftDigest != rightDigest || len(leftDigest) != 64 || strings.Trim(leftDigest, "0123456789abcdef") != "" {
t.Fatalf("left=%q right=%q", leftDigest, rightDigest)
}
right.Records[0].Name = "changed"
changed, err := right.Digest()
if err != nil || changed == leftDigest {
t.Fatalf("changed=%q err=%v", changed, err)
}
}
func validBatch(now time.Time) Batch {
v := 1.5
return Batch{Version: 1, SourceID: "src_1", StreamID: "metrics", Sequence: 1, ObservedAt: now, Signal: SignalMetrics, Records: []Observation{{Timestamp: now, Name: "http.duration", Value: &v, Attributes: map[string]string{"route": "/"}}}}
}
func TestBatchRejectsDistinctFieldCardinalityAbuse(t *testing.T) {
now := time.Now().UTC()
batch := Batch{Version: BatchVersion, SourceID: "source", StreamID: "logs", Sequence: 1, ObservedAt: now, Signal: SignalLogs}
for recordIndex := 0; recordIndex < 17; recordIndex++ {
record := Observation{Timestamp: now, Name: "record", Attributes: map[string]string{}}
for fieldIndex := 0; fieldIndex < MaxAttributes; fieldIndex++ {
record.Attributes[fmt.Sprintf("field.%d.%d", recordIndex, fieldIndex)] = "value"
}
batch.Records = append(batch.Records, record)
}
if err := batch.Validate(now); err == nil || !strings.Contains(err.Error(), "distinct attribute fields") {
t.Fatalf("cardinality abuse err=%v", err)
}
}
func TestBatchValidation(t *testing.T) {
now := time.Now().UTC()
if err := validBatch(now).Validate(now); err != nil {
t.Fatal(err)
}
b := validBatch(now)
b.SourceID = "../../tenant"
if err := b.Validate(now); err == nil || !strings.Contains(err.Error(), "invalid character") {
t.Fatalf("expected path-like ID rejection, got %v", err)
}
b = validBatch(now)
b.Records[0].Attributes["secret"] = strings.Repeat("x", MaxAttributeValue+1)
if err := b.Validate(now); err == nil || !strings.Contains(err.Error(), "exceeds") {
t.Fatalf("expected attribute bound, got %v", err)
}
}
func TestBatchClockSkewAndRetentionWindowsFailClosed(t *testing.T) {
now := time.Date(2026, 8, 17, 12, 0, 0, 0, time.UTC)
for name, mutate := range map[string]func(*Batch){
"old batch": func(batch *Batch) { batch.ObservedAt = now.Add(-7*24*time.Hour - time.Nanosecond) },
"future batch": func(batch *Batch) { batch.ObservedAt = now.Add(10*time.Minute + time.Nanosecond) },
"old record": func(batch *Batch) { batch.Records[0].Timestamp = now.Add(-400*24*time.Hour - time.Nanosecond) },
"future record": func(batch *Batch) { batch.Records[0].Timestamp = now.Add(10*time.Minute + time.Nanosecond) },
"zero observed": func(batch *Batch) { batch.ObservedAt = time.Time{} },
"zero timestamp": func(batch *Batch) { batch.Records[0].Timestamp = time.Time{} },
} {
t.Run(name, func(t *testing.T) {
batch := validBatch(now)
mutate(&batch)
if err := batch.Validate(now); err == nil {
t.Fatal("out-of-window telemetry was accepted")
}
})
}
}
func TestObservationRejectsUnsafeIdentifiersAndNonFiniteValues(t *testing.T) {
now := time.Now().UTC()
nan := math.NaN()
base := Batch{Version: BatchVersion, SourceID: "source", StreamID: "stream", Sequence: 1, ObservedAt: now, Signal: SignalLogs, Records: []Observation{{Timestamp: now, Name: "record"}}}
for name, mutate := range map[string]func(*Observation){
"trace": func(record *Observation) { record.TraceID = "ABC" },
"span": func(record *Observation) { record.SpanID = strings.Repeat("g", 16) },
"correlation": func(record *Observation) { record.CorrelationID = strings.Repeat("x", 129) },
"value": func(record *Observation) { record.Value = &nan },
} {
t.Run(name, func(t *testing.T) {
batch := base
batch.Records = append([]Observation(nil), base.Records...)
mutate(&batch.Records[0])
if err := batch.Validate(now); err == nil {
t.Fatal("unsafe observation accepted")
}
})
}
}
+74
View File
@@ -0,0 +1,74 @@
// SPDX-License-Identifier: AGPL-3.0-only
package nativeprotocol
import (
"errors"
"net/http"
"strconv"
"time"
"gamertan.com/observatory/internal/model"
)
const (
VersionHeader = "Observatory-Batch-Version"
StreamHeader = "Observatory-Stream-ID"
SequenceHeader = "Observatory-Sequence"
SignalHeader = "Observatory-Signal"
WireDigestHeader = "Observatory-Wire-SHA256"
BatchDigestHeader = "Observatory-Batch-SHA256"
RecordCountHeader = "Observatory-Record-Count"
EncodedBytesHeader = "Observatory-Encoded-Bytes"
FirstObservedHeader = "Observatory-First-Observed-At"
LastObservedHeader = "Observatory-Last-Observed-At"
)
var envelopeHeaders = []string{
VersionHeader, StreamHeader, SequenceHeader, SignalHeader,
WireDigestHeader, BatchDigestHeader, RecordCountHeader,
EncodedBytesHeader, FirstObservedHeader, LastObservedHeader,
}
func SetHeaders(header http.Header, envelope model.BatchEnvelope) {
header.Set(VersionHeader, strconv.Itoa(envelope.Version))
header.Set(StreamHeader, envelope.StreamID)
header.Set(SequenceHeader, strconv.FormatUint(envelope.Sequence, 10))
header.Set(SignalHeader, string(envelope.Signal))
header.Set(WireDigestHeader, envelope.WireDigest)
header.Set(BatchDigestHeader, envelope.BatchDigest)
header.Set(RecordCountHeader, strconv.Itoa(envelope.RecordCount))
header.Set(EncodedBytesHeader, strconv.FormatInt(envelope.EncodedBytes, 10))
header.Set(FirstObservedHeader, envelope.FirstObservedAt.UTC().Format(time.RFC3339Nano))
header.Set(LastObservedHeader, envelope.LastObservedAt.UTC().Format(time.RFC3339Nano))
}
func ParseHeaders(header http.Header, maxEncodedBytes int64) (model.BatchEnvelope, error) {
values := make(map[string]string, len(envelopeHeaders))
for _, name := range envelopeHeaders {
items := header.Values(name)
if len(items) != 1 || items[0] == "" {
return model.BatchEnvelope{}, errors.New("native batch envelope headers are incomplete")
}
values[name] = items[0]
}
version, versionErr := strconv.Atoi(values[VersionHeader])
sequence, sequenceErr := strconv.ParseUint(values[SequenceHeader], 10, 64)
recordCount, recordErr := strconv.Atoi(values[RecordCountHeader])
encodedBytes, bytesErr := strconv.ParseInt(values[EncodedBytesHeader], 10, 64)
first, firstErr := time.Parse(time.RFC3339Nano, values[FirstObservedHeader])
last, lastErr := time.Parse(time.RFC3339Nano, values[LastObservedHeader])
if versionErr != nil || sequenceErr != nil || recordErr != nil || bytesErr != nil || firstErr != nil || lastErr != nil {
return model.BatchEnvelope{}, errors.New("native batch envelope headers are invalid")
}
envelope := model.BatchEnvelope{
Version: version, StreamID: values[StreamHeader], Sequence: sequence,
Signal: model.Signal(values[SignalHeader]), WireDigest: values[WireDigestHeader],
BatchDigest: values[BatchDigestHeader], RecordCount: recordCount,
EncodedBytes: encodedBytes, FirstObservedAt: first.UTC(), LastObservedAt: last.UTC(),
}
if err := envelope.Validate(maxEncodedBytes); err != nil {
return model.BatchEnvelope{}, errors.New("native batch envelope headers are invalid")
}
return envelope, nil
}
+46
View File
@@ -0,0 +1,46 @@
// SPDX-License-Identifier: AGPL-3.0-only
package nativeprotocol
import (
"encoding/json"
"net/http"
"testing"
"time"
"gamertan.com/observatory/internal/model"
)
func TestEnvelopeHeadersRoundTripAndRejectAmbiguity(t *testing.T) {
now := time.Date(2026, 8, 18, 20, 0, 0, 0, time.UTC)
batch := model.Batch{Version: model.BatchVersion, SourceID: "source", StreamID: "logs", Sequence: 7, ObservedAt: now, Signal: model.SignalLogs, Records: []model.Observation{{Timestamp: now.Add(-time.Second), Name: "first"}, {Timestamp: now, Name: "last"}}}
body, err := json.Marshal(batch)
if err != nil {
t.Fatal(err)
}
envelope, err := batch.Envelope(body)
if err != nil {
t.Fatal(err)
}
header := make(http.Header)
SetHeaders(header, envelope)
parsed, err := ParseHeaders(header, 1<<20)
if err != nil || parsed != envelope {
t.Fatalf("parsed=%+v err=%v", parsed, err)
}
header.Add(SequenceHeader, "8")
if _, err = ParseHeaders(header, 1<<20); err == nil {
t.Fatal("duplicate security-relevant header was accepted")
}
}
func FuzzParseEnvelopeHeaders(f *testing.F) {
f.Add("1", "logs", "1", "logs", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "1", "100", "2026-08-18T20:00:00Z", "2026-08-18T20:00:00Z")
f.Fuzz(func(t *testing.T, version, stream, sequence, signal, wire, batch, count, size, first, last string) {
header := make(http.Header)
for name, value := range map[string]string{VersionHeader: version, StreamHeader: stream, SequenceHeader: sequence, SignalHeader: signal, WireDigestHeader: wire, BatchDigestHeader: batch, RecordCountHeader: count, EncodedBytesHeader: size, FirstObservedHeader: first, LastObservedHeader: last} {
header.Set(name, value)
}
_, _ = ParseHeaders(header, 32<<20)
})
}
+666
View File
@@ -0,0 +1,666 @@
// SPDX-License-Identifier: AGPL-3.0-only
package otlp
import (
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"math"
"strconv"
"strings"
"time"
"unicode/utf8"
"gamertan.com/observatory/internal/model"
commonpb "go.opentelemetry.io/proto/otlp/common/v1"
logspb "go.opentelemetry.io/proto/otlp/logs/v1"
metricspb "go.opentelemetry.io/proto/otlp/metrics/v1"
resourcepb "go.opentelemetry.io/proto/otlp/resource/v1"
tracepb "go.opentelemetry.io/proto/otlp/trace/v1"
"google.golang.org/protobuf/proto"
)
type Signal string
const (
Logs Signal = "logs"
Metrics Signal = "metrics"
Traces Signal = "traces"
)
func (signal Signal) ModelSignal() model.Signal {
switch signal {
case Logs:
return model.SignalLogs
case Metrics:
return model.SignalMetrics
case Traces:
return model.SignalTraces
default:
return ""
}
}
func (signal Signal) StreamID() string { return "otlp-" + string(signal) }
func Decode(signal Signal, body []byte, now time.Time) ([]model.Observation, error) {
if len(body) == 0 || now.IsZero() {
return nil, errors.New("OTLP request is empty or missing collection time")
}
options := proto.UnmarshalOptions{DiscardUnknown: true, RecursionLimit: 64}
var records []model.Observation
var err error
switch signal {
case Logs:
request := new(logspb.LogsData)
if err = options.Unmarshal(body, request); err == nil {
records, err = decodeLogs(request)
}
case Metrics:
request := new(metricspb.MetricsData)
if err = options.Unmarshal(body, request); err == nil {
records, err = decodeMetrics(request)
}
case Traces:
request := new(tracepb.TracesData)
if err = options.Unmarshal(body, request); err == nil {
records, err = decodeTraces(request)
}
default:
return nil, errors.New("unsupported OTLP signal")
}
if err != nil {
return nil, errors.New("invalid OTLP protobuf payload")
}
if len(records) == 0 {
return nil, errors.New("OTLP request contains no supported records")
}
if len(records) > model.MaxRecords {
return nil, fmt.Errorf("OTLP request exceeds %d records", model.MaxRecords)
}
return records, nil
}
func SuccessResponse(signal Signal) ([]byte, error) {
switch signal {
case Logs, Metrics, Traces:
// Each successful OTLP/HTTP protobuf response is an empty message when
// there is no partial-success detail. The canonical protobuf encoding of
// an empty message is an empty byte sequence.
return []byte{}, nil
default:
return nil, errors.New("unsupported OTLP signal")
}
}
func decodeLogs(request *logspb.LogsData) ([]model.Observation, error) {
var records []model.Observation
for _, resourceLogs := range request.GetResourceLogs() {
resource, err := baseAttributes(resourceLogs.GetResource())
if err != nil {
return nil, err
}
for _, scopeLogs := range resourceLogs.GetScopeLogs() {
base, err := scopeAttributes(resource, scopeLogs.GetScope())
if err != nil {
return nil, err
}
for _, record := range scopeLogs.GetLogRecords() {
attributes, err := mergeAttributes(base, record.GetAttributes())
if err != nil {
return nil, err
}
if record.GetDroppedAttributesCount() > 0 {
if err = addAttribute(attributes, "otel.dropped_attributes", strconv.FormatUint(uint64(record.GetDroppedAttributesCount()), 10)); err != nil {
return nil, err
}
}
body, err := anyValueText(record.GetBody(), model.MaxBody)
if err != nil {
return nil, err
}
name := record.GetEventName()
if name == "" {
name = "otlp.log"
}
when := record.GetTimeUnixNano()
if when == 0 {
when = record.GetObservedTimeUnixNano()
}
timestamp, err := timestamp(when)
if err != nil {
return nil, err
}
traceID, err := identifier(record.GetTraceId(), 16)
if err != nil {
return nil, err
}
spanID, err := identifier(record.GetSpanId(), 8)
if err != nil {
return nil, err
}
severity := record.GetSeverityText()
if severity == "" && record.GetSeverityNumber() != 0 {
severity = record.GetSeverityNumber().String()
}
records = append(records, model.Observation{Timestamp: timestamp, Name: name, Severity: severity, Body: body, TraceID: traceID, SpanID: spanID, CorrelationID: traceID, Attributes: attributes})
if len(records) > model.MaxRecords {
return nil, errors.New("OTLP logs exceed record limit")
}
}
}
}
return records, nil
}
func decodeMetrics(request *metricspb.MetricsData) ([]model.Observation, error) {
var records []model.Observation
for _, resourceMetrics := range request.GetResourceMetrics() {
resource, err := baseAttributes(resourceMetrics.GetResource())
if err != nil {
return nil, err
}
for _, scopeMetrics := range resourceMetrics.GetScopeMetrics() {
base, err := scopeAttributes(resource, scopeMetrics.GetScope())
if err != nil {
return nil, err
}
for _, metric := range scopeMetrics.GetMetrics() {
metricBase := cloneAttributes(base)
if metric.GetUnit() != "" {
if err = addAttribute(metricBase, "metric.unit", metric.GetUnit()); err != nil {
return nil, err
}
}
switch data := metric.Data.(type) {
case *metricspb.Metric_Gauge:
err = appendNumberPoints(&records, metric.GetName(), "gauge", "", metricBase, data.Gauge.GetDataPoints())
case *metricspb.Metric_Sum:
err = appendNumberPoints(&records, metric.GetName(), "sum", data.Sum.GetAggregationTemporality().String(), metricBase, data.Sum.GetDataPoints())
case *metricspb.Metric_Histogram:
err = appendHistogramPoints(&records, metric.GetName(), data.Histogram.GetAggregationTemporality().String(), metricBase, data.Histogram.GetDataPoints())
case *metricspb.Metric_ExponentialHistogram:
err = appendExponentialHistogramPoints(&records, metric.GetName(), data.ExponentialHistogram.GetAggregationTemporality().String(), metricBase, data.ExponentialHistogram.GetDataPoints())
case *metricspb.Metric_Summary:
err = appendSummaryPoints(&records, metric.GetName(), metricBase, data.Summary.GetDataPoints())
default:
err = errors.New("OTLP metric has unsupported data")
}
if err != nil {
return nil, err
}
if len(records) > model.MaxRecords {
return nil, errors.New("OTLP metrics exceed record limit")
}
}
}
}
return records, nil
}
func appendNumberPoints(records *[]model.Observation, name, kind, temporality string, base map[string]string, points []*metricspb.NumberDataPoint) error {
for _, point := range points {
if point.GetFlags()&uint32(metricspb.DataPointFlags_DATA_POINT_FLAGS_NO_RECORDED_VALUE_MASK) != 0 {
continue
}
attributes, err := metricAttributes(base, point.GetAttributes(), kind, temporality, point.GetStartTimeUnixNano())
if err != nil {
return err
}
var value float64
switch number := point.Value.(type) {
case *metricspb.NumberDataPoint_AsDouble:
value = number.AsDouble
case *metricspb.NumberDataPoint_AsInt:
value = float64(number.AsInt)
if err = addAttribute(attributes, "metric.int64", strconv.FormatInt(number.AsInt, 10)); err != nil {
return err
}
default:
return errors.New("OTLP number point has no value")
}
if math.IsNaN(value) || math.IsInf(value, 0) {
return errors.New("OTLP metric value is not finite")
}
timestamp, err := timestamp(point.GetTimeUnixNano())
if err != nil {
return err
}
*records = append(*records, model.Observation{Timestamp: timestamp, Name: name, Value: &value, Attributes: attributes})
}
return nil
}
func appendHistogramPoints(records *[]model.Observation, name, temporality string, base map[string]string, points []*metricspb.HistogramDataPoint) error {
for _, point := range points {
if point.GetFlags()&uint32(metricspb.DataPointFlags_DATA_POINT_FLAGS_NO_RECORDED_VALUE_MASK) != 0 {
continue
}
attributes, err := metricAttributes(base, point.GetAttributes(), "histogram", temporality, point.GetStartTimeUnixNano())
if err != nil {
return err
}
bounds := point.GetExplicitBounds()
if len(point.GetBucketCounts()) != len(bounds)+1 || !strictlyIncreasingFinite(bounds) {
return errors.New("OTLP histogram buckets are invalid")
}
for key, value := range map[string]any{"metric.bucket_counts": point.GetBucketCounts(), "metric.explicit_bounds": point.GetExplicitBounds(), "metric.count": point.GetCount()} {
if err = addJSONAttribute(attributes, key, value); err != nil {
return err
}
}
value := float64(point.GetCount())
if point.Sum != nil {
value = point.GetSum()
if !finite(value) {
return errors.New("OTLP histogram sum is not finite")
}
if err = addAttribute(attributes, "metric.sum", strconv.FormatFloat(value, 'g', -1, 64)); err != nil {
return err
}
}
if point.Min != nil {
if !finite(point.GetMin()) {
return errors.New("OTLP histogram minimum is not finite")
}
if err = addAttribute(attributes, "metric.min", strconv.FormatFloat(point.GetMin(), 'g', -1, 64)); err != nil {
return err
}
}
if point.Max != nil {
if !finite(point.GetMax()) {
return errors.New("OTLP histogram maximum is not finite")
}
if err = addAttribute(attributes, "metric.max", strconv.FormatFloat(point.GetMax(), 'g', -1, 64)); err != nil {
return err
}
}
timestamp, err := timestamp(point.GetTimeUnixNano())
if err != nil {
return err
}
*records = append(*records, model.Observation{Timestamp: timestamp, Name: name, Value: &value, Attributes: attributes})
}
return nil
}
func appendExponentialHistogramPoints(records *[]model.Observation, name, temporality string, base map[string]string, points []*metricspb.ExponentialHistogramDataPoint) error {
for _, point := range points {
if point.GetFlags()&uint32(metricspb.DataPointFlags_DATA_POINT_FLAGS_NO_RECORDED_VALUE_MASK) != 0 {
continue
}
attributes, err := metricAttributes(base, point.GetAttributes(), "exponential_histogram", temporality, point.GetStartTimeUnixNano())
if err != nil {
return err
}
if point.GetScale() < -10 || point.GetScale() > 20 || !finite(point.GetZeroThreshold()) || point.GetZeroThreshold() < 0 {
return errors.New("OTLP exponential histogram parameters are invalid")
}
values := map[string]any{
"metric.count": point.GetCount(), "metric.scale": point.GetScale(), "metric.zero_count": point.GetZeroCount(),
"metric.zero_threshold": point.GetZeroThreshold(), "metric.positive": point.GetPositive(), "metric.negative": point.GetNegative(),
}
for key, value := range values {
if err = addJSONAttribute(attributes, key, value); err != nil {
return err
}
}
value := float64(point.GetCount())
if point.Sum != nil {
value = point.GetSum()
if !finite(value) {
return errors.New("OTLP exponential histogram sum is not finite")
}
if err = addAttribute(attributes, "metric.sum", strconv.FormatFloat(value, 'g', -1, 64)); err != nil {
return err
}
}
timestamp, err := timestamp(point.GetTimeUnixNano())
if err != nil {
return err
}
*records = append(*records, model.Observation{Timestamp: timestamp, Name: name, Value: &value, Attributes: attributes})
}
return nil
}
func appendSummaryPoints(records *[]model.Observation, name string, base map[string]string, points []*metricspb.SummaryDataPoint) error {
for _, point := range points {
if point.GetFlags()&uint32(metricspb.DataPointFlags_DATA_POINT_FLAGS_NO_RECORDED_VALUE_MASK) != 0 {
continue
}
attributes, err := metricAttributes(base, point.GetAttributes(), "summary", "", point.GetStartTimeUnixNano())
if err != nil {
return err
}
if err = addJSONAttribute(attributes, "metric.count", point.GetCount()); err != nil {
return err
}
quantiles := make([][2]float64, 0, len(point.GetQuantileValues()))
for _, quantile := range point.GetQuantileValues() {
if !finite(quantile.GetQuantile()) || quantile.GetQuantile() < 0 || quantile.GetQuantile() > 1 || !finite(quantile.GetValue()) {
return errors.New("OTLP summary quantile is invalid")
}
quantiles = append(quantiles, [2]float64{quantile.GetQuantile(), quantile.GetValue()})
}
if err = addJSONAttribute(attributes, "metric.quantiles", quantiles); err != nil {
return err
}
value := point.GetSum()
if !finite(value) {
return errors.New("OTLP summary sum is not finite")
}
if err = addAttribute(attributes, "metric.sum", strconv.FormatFloat(value, 'g', -1, 64)); err != nil {
return err
}
timestamp, err := timestamp(point.GetTimeUnixNano())
if err != nil {
return err
}
*records = append(*records, model.Observation{Timestamp: timestamp, Name: name, Value: &value, Attributes: attributes})
}
return nil
}
func decodeTraces(request *tracepb.TracesData) ([]model.Observation, error) {
var records []model.Observation
for _, resourceSpans := range request.GetResourceSpans() {
resource, err := baseAttributes(resourceSpans.GetResource())
if err != nil {
return nil, err
}
for _, scopeSpans := range resourceSpans.GetScopeSpans() {
base, err := scopeAttributes(resource, scopeSpans.GetScope())
if err != nil {
return nil, err
}
for _, span := range scopeSpans.GetSpans() {
attributes, err := mergeAttributes(base, span.GetAttributes())
if err != nil {
return nil, err
}
traceID, err := identifier(span.GetTraceId(), 16)
if err != nil || traceID == "" {
return nil, errors.New("OTLP span has invalid trace ID")
}
spanID, err := identifier(span.GetSpanId(), 8)
if err != nil || spanID == "" {
return nil, errors.New("OTLP span has invalid span ID")
}
if parent, parentErr := identifier(span.GetParentSpanId(), 8); parentErr != nil {
return nil, parentErr
} else if parent != "" {
if err = addAttribute(attributes, "span.parent_id", parent); err != nil {
return nil, err
}
}
start, err := timestamp(span.GetStartTimeUnixNano())
if err != nil {
return nil, err
}
end, err := timestamp(span.GetEndTimeUnixNano())
if err != nil || end.Before(start) {
return nil, errors.New("OTLP span has invalid time range")
}
duration := float64(end.Sub(start).Nanoseconds())
if err = addAttribute(attributes, "span.duration_ns", strconv.FormatInt(end.Sub(start).Nanoseconds(), 10)); err != nil {
return nil, err
}
if err = addAttribute(attributes, "span.kind", span.GetKind().String()); err != nil {
return nil, err
}
if status := span.GetStatus(); status != nil {
if err = addAttribute(attributes, "span.status", status.GetCode().String()); err != nil {
return nil, err
}
}
for key, count := range map[string]uint64{"span.events_count": uint64(len(span.GetEvents())), "span.links_count": uint64(len(span.GetLinks())), "otel.dropped_attributes": uint64(span.GetDroppedAttributesCount()), "otel.dropped_events": uint64(span.GetDroppedEventsCount()), "otel.dropped_links": uint64(span.GetDroppedLinksCount())} {
if count > 0 {
if err = addAttribute(attributes, key, strconv.FormatUint(count, 10)); err != nil {
return nil, err
}
}
}
records = append(records, model.Observation{Timestamp: start, Name: span.GetName(), Value: &duration, TraceID: traceID, SpanID: spanID, CorrelationID: traceID, Attributes: attributes})
if len(records) > model.MaxRecords {
return nil, errors.New("OTLP traces exceed record limit")
}
}
}
}
return records, nil
}
func baseAttributes(resource *resourcepb.Resource) (map[string]string, error) {
if resource == nil {
return map[string]string{}, nil
}
attributes, err := mergeAttributes(nil, resource.GetAttributes())
if err != nil {
return nil, err
}
if resource.GetDroppedAttributesCount() > 0 {
err = addAttribute(attributes, "otel.resource.dropped_attributes", strconv.FormatUint(uint64(resource.GetDroppedAttributesCount()), 10))
}
return attributes, err
}
func scopeAttributes(base map[string]string, scope *commonpb.InstrumentationScope) (map[string]string, error) {
attributes := cloneAttributes(base)
if scope == nil {
return attributes, nil
}
if scope.GetName() != "" {
if err := addAttribute(attributes, "otel.scope.name", scope.GetName()); err != nil {
return nil, err
}
}
if scope.GetVersion() != "" {
if err := addAttribute(attributes, "otel.scope.version", scope.GetVersion()); err != nil {
return nil, err
}
}
for _, pair := range scope.GetAttributes() {
if pair.GetKey() == "" || deniedKey(pair.GetKey()) {
continue
}
value, err := anyValueText(pair.GetValue(), model.MaxAttributeValue)
if err != nil {
return nil, err
}
if err = addAttribute(attributes, "otel.scope.attribute."+pair.GetKey(), value); err != nil {
return nil, err
}
}
return attributes, nil
}
func metricAttributes(base map[string]string, pairs []*commonpb.KeyValue, kind, temporality string, start uint64) (map[string]string, error) {
attributes, err := mergeAttributes(base, pairs)
if err != nil {
return nil, err
}
if err = addAttribute(attributes, "metric.kind", kind); err != nil {
return nil, err
}
if temporality != "" {
if err = addAttribute(attributes, "metric.temporality", temporality); err != nil {
return nil, err
}
}
if start != 0 {
if err = addAttribute(attributes, "metric.start_unix_nano", strconv.FormatUint(start, 10)); err != nil {
return nil, err
}
}
return attributes, nil
}
func mergeAttributes(base map[string]string, pairs []*commonpb.KeyValue) (map[string]string, error) {
attributes := cloneAttributes(base)
for _, pair := range pairs {
key := pair.GetKey()
if key == "" || deniedKey(key) {
continue
}
value, err := anyValueText(pair.GetValue(), model.MaxAttributeValue)
if err != nil {
return nil, err
}
if err = addAttribute(attributes, key, value); err != nil {
return nil, err
}
}
return attributes, nil
}
func cloneAttributes(source map[string]string) map[string]string {
copy := make(map[string]string, len(source))
for key, value := range source {
copy[key] = value
}
return copy
}
func addAttribute(attributes map[string]string, key, value string) error {
if key == "" || len(key) > model.MaxAttributeKey || !utf8.ValidString(key) || strings.IndexByte(key, 0) >= 0 || len(value) > model.MaxAttributeValue || !utf8.ValidString(value) || strings.IndexByte(value, 0) >= 0 {
return errors.New("OTLP attribute exceeds accepted bounds")
}
if _, exists := attributes[key]; !exists && len(attributes) >= model.MaxAttributes {
return errors.New("OTLP record exceeds attribute limit")
}
attributes[key] = value
return nil
}
func addJSONAttribute(attributes map[string]string, key string, value any) error {
body, err := json.Marshal(value)
if err != nil || len(body) > model.MaxAttributeValue {
return errors.New("OTLP structured attribute exceeds accepted bounds")
}
return addAttribute(attributes, key, string(body))
}
func anyValueText(value *commonpb.AnyValue, maximum int) (string, error) {
converted, err := anyValue(value, 0)
if err != nil {
return "", err
}
if text, ok := converted.(string); ok {
if len(text) > maximum || !utf8.ValidString(text) || strings.IndexByte(text, 0) >= 0 {
return "", errors.New("OTLP string value exceeds accepted bounds")
}
return text, nil
}
body, err := json.Marshal(converted)
if err != nil || len(body) > maximum {
return "", errors.New("OTLP value exceeds accepted bounds")
}
return string(body), nil
}
func anyValue(value *commonpb.AnyValue, depth int) (any, error) {
if value == nil {
return "", nil
}
if depth > 8 {
return nil, errors.New("OTLP value exceeds nesting limit")
}
switch content := value.Value.(type) {
case *commonpb.AnyValue_StringValue:
return content.StringValue, nil
case *commonpb.AnyValue_BoolValue:
return content.BoolValue, nil
case *commonpb.AnyValue_IntValue:
return content.IntValue, nil
case *commonpb.AnyValue_DoubleValue:
if math.IsNaN(content.DoubleValue) || math.IsInf(content.DoubleValue, 0) {
return nil, errors.New("OTLP value is not finite")
}
return content.DoubleValue, nil
case *commonpb.AnyValue_BytesValue:
if len(content.BytesValue) > model.MaxAttributeValue/2 {
return nil, errors.New("OTLP byte value exceeds accepted bounds")
}
return hex.EncodeToString(content.BytesValue), nil
case *commonpb.AnyValue_ArrayValue:
values := content.ArrayValue.GetValues()
if len(values) > 64 {
return nil, errors.New("OTLP array exceeds element limit")
}
result := make([]any, 0, len(values))
for _, item := range values {
converted, err := anyValue(item, depth+1)
if err != nil {
return nil, err
}
result = append(result, converted)
}
return result, nil
case *commonpb.AnyValue_KvlistValue:
values := content.KvlistValue.GetValues()
if len(values) > 64 {
return nil, errors.New("OTLP key-value list exceeds element limit")
}
result := make(map[string]any, len(values))
for _, pair := range values {
key := pair.GetKey()
if key == "" || deniedKey(key) {
continue
}
converted, err := anyValue(pair.GetValue(), depth+1)
if err != nil {
return nil, err
}
result[key] = converted
}
return result, nil
case *commonpb.AnyValue_StringValueStrindex:
return "", nil
default:
return "", nil
}
}
func deniedKey(key string) bool {
normalized := strings.NewReplacer("-", "_", ".", "_", "/", "_").Replace(strings.ToLower(key))
for _, denied := range []string{"authorization", "proxy_authorization", "cookie", "set_cookie", "password", "passwd", "secret", "api_key", "apikey", "access_token", "refresh_token", "client_secret"} {
if strings.Contains(normalized, denied) {
return true
}
}
return false
}
func finite(value float64) bool { return !math.IsNaN(value) && !math.IsInf(value, 0) }
func strictlyIncreasingFinite(values []float64) bool {
for index, value := range values {
if !finite(value) || index > 0 && value <= values[index-1] {
return false
}
}
return true
}
func identifier(value []byte, bytes int) (string, error) {
if len(value) == 0 {
return "", nil
}
if len(value) != bytes {
return "", errors.New("OTLP identifier has invalid length")
}
return hex.EncodeToString(value), nil
}
func timestamp(nanoseconds uint64) (time.Time, error) {
if nanoseconds == 0 {
return time.Time{}, errors.New("OTLP timestamp is required")
}
if nanoseconds > math.MaxInt64 {
return time.Time{}, errors.New("OTLP timestamp exceeds supported range")
}
return time.Unix(0, int64(nanoseconds)).UTC(), nil
}
+146
View File
@@ -0,0 +1,146 @@
// SPDX-License-Identifier: AGPL-3.0-only
package otlp
import (
"bytes"
"math"
"testing"
"time"
commonpb "go.opentelemetry.io/proto/otlp/common/v1"
logspb "go.opentelemetry.io/proto/otlp/logs/v1"
metricspb "go.opentelemetry.io/proto/otlp/metrics/v1"
resourcepb "go.opentelemetry.io/proto/otlp/resource/v1"
tracepb "go.opentelemetry.io/proto/otlp/trace/v1"
"google.golang.org/protobuf/proto"
)
func TestDecodeLogsDropsCredentialsAndPreservesTelemetry(t *testing.T) {
now := time.Date(2026, 8, 17, 3, 0, 0, 0, time.UTC)
request := &logspb.LogsData{ResourceLogs: []*logspb.ResourceLogs{{
Resource: &resourcepb.Resource{Attributes: []*commonpb.KeyValue{
stringAttribute("service.name", "eql"),
stringAttribute("http.request.header.authorization", "Bearer do-not-store"),
}},
ScopeLogs: []*logspb.ScopeLogs{{
Scope: &commonpb.InstrumentationScope{Name: "test", Version: "1"},
LogRecords: []*logspb.LogRecord{{
TimeUnixNano: uint64(now.UnixNano()), EventName: "http.request", SeverityText: "INFO",
Body: &commonpb.AnyValue{Value: &commonpb.AnyValue_StringValue{StringValue: "complete"}},
TraceId: bytes.Repeat([]byte{0x11}, 16), SpanId: bytes.Repeat([]byte{0x22}, 8),
Attributes: []*commonpb.KeyValue{stringAttribute("http.route", "/items/{id}")},
}},
}},
}}}
records, err := Decode(Logs, marshal(t, request), now)
if err != nil {
t.Fatal(err)
}
if len(records) != 1 || records[0].Name != "http.request" || records[0].Body != "complete" || records[0].TraceID != "11111111111111111111111111111111" {
t.Fatalf("records=%+v", records)
}
if records[0].Attributes["service.name"] != "eql" || records[0].Attributes["http.route"] != "/items/{id}" || records[0].Attributes["otel.scope.name"] != "test" {
t.Fatalf("attributes=%+v", records[0].Attributes)
}
if _, exists := records[0].Attributes["http.request.header.authorization"]; exists {
t.Fatal("credential-bearing attribute was retained")
}
}
func TestDecodeMetricsAndTraces(t *testing.T) {
now := time.Date(2026, 8, 17, 3, 0, 0, 0, time.UTC)
point := &metricspb.NumberDataPoint{
TimeUnixNano: uint64(now.UnixNano()),
Value: &metricspb.NumberDataPoint_AsDouble{AsDouble: 12.5},
Attributes: []*commonpb.KeyValue{stringAttribute("http.route", "/")},
}
metric := &metricspb.Metric{Name: "http.server.duration", Unit: "ms", Data: &metricspb.Metric_Gauge{Gauge: &metricspb.Gauge{DataPoints: []*metricspb.NumberDataPoint{point}}}}
metricRequest := &metricspb.MetricsData{ResourceMetrics: []*metricspb.ResourceMetrics{{ScopeMetrics: []*metricspb.ScopeMetrics{{Metrics: []*metricspb.Metric{metric}}}}}}
metrics, err := Decode(Metrics, marshal(t, metricRequest), now)
if err != nil {
t.Fatal(err)
}
if len(metrics) != 1 || metrics[0].Value == nil || *metrics[0].Value != 12.5 || metrics[0].Attributes["metric.unit"] != "ms" {
t.Fatalf("metrics=%+v", metrics)
}
span := &tracepb.Span{
TraceId: bytes.Repeat([]byte{0x33}, 16), SpanId: bytes.Repeat([]byte{0x44}, 8), Name: "GET /", Kind: tracepb.Span_SPAN_KIND_SERVER,
StartTimeUnixNano: uint64(now.UnixNano()), EndTimeUnixNano: uint64(now.Add(7 * time.Millisecond).UnixNano()), Status: &tracepb.Status{Code: tracepb.Status_STATUS_CODE_OK},
}
traceRequest := &tracepb.TracesData{ResourceSpans: []*tracepb.ResourceSpans{{ScopeSpans: []*tracepb.ScopeSpans{{Spans: []*tracepb.Span{span}}}}}}
traces, err := Decode(Traces, marshal(t, traceRequest), now)
if err != nil {
t.Fatal(err)
}
if len(traces) != 1 || traces[0].Name != "GET /" || traces[0].Value == nil || *traces[0].Value != float64((7*time.Millisecond).Nanoseconds()) || traces[0].Attributes["span.status"] != "STATUS_CODE_OK" {
t.Fatalf("traces=%+v", traces)
}
}
func TestDecodeRejectsMalformedAndNonFiniteData(t *testing.T) {
now := time.Date(2026, 8, 17, 3, 0, 0, 0, time.UTC)
badPoint := &metricspb.NumberDataPoint{TimeUnixNano: uint64(now.UnixNano()), Value: &metricspb.NumberDataPoint_AsDouble{AsDouble: math.NaN()}}
badMetricValue := &metricspb.Metric{Name: "bad", Data: &metricspb.Metric_Gauge{Gauge: &metricspb.Gauge{DataPoints: []*metricspb.NumberDataPoint{badPoint}}}}
badMetric := &metricspb.MetricsData{ResourceMetrics: []*metricspb.ResourceMetrics{{ScopeMetrics: []*metricspb.ScopeMetrics{{Metrics: []*metricspb.Metric{badMetricValue}}}}}}
if _, err := Decode(Metrics, marshal(t, badMetric), now); err == nil {
t.Fatal("non-finite metric accepted")
}
badTrace := &tracepb.TracesData{ResourceSpans: []*tracepb.ResourceSpans{{ScopeSpans: []*tracepb.ScopeSpans{{Spans: []*tracepb.Span{{TraceId: []byte{1}, SpanId: bytes.Repeat([]byte{2}, 8), Name: "bad", StartTimeUnixNano: uint64(now.UnixNano()), EndTimeUnixNano: uint64(now.UnixNano())}}}}}}}
if _, err := Decode(Traces, marshal(t, badTrace), now); err == nil {
t.Fatal("invalid trace identifier accepted")
}
if _, err := Decode(Logs, []byte{0xff, 0xff}, now); err == nil {
t.Fatal("malformed protobuf accepted")
}
}
func TestSuccessResponseIsDeterministicAndValid(t *testing.T) {
for _, signal := range []Signal{Logs, Metrics, Traces} {
first, err := SuccessResponse(signal)
if err != nil {
t.Fatal(err)
}
second, _ := SuccessResponse(signal)
if !bytes.Equal(first, second) {
t.Fatalf("%s response is not deterministic", signal)
}
if len(first) != 0 {
t.Fatalf("%s response has non-canonical empty encoding", signal)
}
}
}
func FuzzDecode(f *testing.F) {
now := time.Date(2026, 8, 17, 3, 0, 0, 0, time.UTC)
seed, err := proto.Marshal(&logspb.LogsData{ResourceLogs: []*logspb.ResourceLogs{{ScopeLogs: []*logspb.ScopeLogs{{LogRecords: []*logspb.LogRecord{{TimeUnixNano: uint64(now.UnixNano()), EventName: "seed"}}}}}}})
if err != nil {
f.Fatal(err)
}
f.Add(uint8(0), seed)
f.Add(uint8(1), []byte{0xff, 0x00})
f.Fuzz(func(t *testing.T, selected uint8, body []byte) {
if len(body) > 1<<20 {
return
}
signal := []Signal{Logs, Metrics, Traces}[selected%3]
records, _ := Decode(signal, body, now)
if len(records) > 5_000 {
t.Fatalf("decoded %d records", len(records))
}
})
}
func stringAttribute(key, value string) *commonpb.KeyValue {
return &commonpb.KeyValue{Key: key, Value: &commonpb.AnyValue{Value: &commonpb.AnyValue_StringValue{StringValue: value}}}
}
func marshal(t *testing.T, message proto.Message) []byte {
t.Helper()
body, err := proto.MarshalOptions{Deterministic: true}.Marshal(message)
if err != nil {
t.Fatal(err)
}
return body
}
+151
View File
@@ -0,0 +1,151 @@
// SPDX-License-Identifier: AGPL-3.0-only
package query
import (
"math"
"regexp"
"strconv"
"strings"
"time"
"gamertan.com/observatory/internal/model"
"gamertan.com/observatory/internal/schema"
)
type FieldLookup func(field string) (string, bool)
// MatchesFilters applies the query's typed filters to a bounded record value
// lookup. Storage projections and the local agent evaluator share this path so
// edge and central comparisons cannot drift silently.
func MatchesFilters(ast AST, registry Registry, lookup FieldLookup) (bool, error) {
for _, filter := range ast.Filters {
field := CanonicalField(filter.Field)
descriptor, _ := ResolveDescriptor(ast.Signal, field, registry)
value, present := lookup(field)
if !present {
return false, nil
}
matched, err := CompareValue(value, filter.Value, filter.Op, descriptor.Type)
if err != nil {
return false, err
}
if !matched {
return false, nil
}
}
return true, nil
}
func MatchObservation(observation model.Observation, ast AST, registry Registry) (bool, error) {
return MatchesFilters(ast, registry, func(field string) (string, bool) {
switch CanonicalField(field) {
case "timestamp":
return observation.Timestamp.UTC().Format(time.RFC3339Nano), !observation.Timestamp.IsZero()
case "name":
return observation.Name, observation.Name != ""
case "severity":
return observation.Severity, observation.Severity != ""
case "body":
return observation.Body, observation.Body != ""
case "value":
if observation.Value == nil {
return "", false
}
return strconv.FormatFloat(*observation.Value, 'g', -1, 64), true
case "trace_id":
return observation.TraceID, observation.TraceID != ""
case "span_id":
return observation.SpanID, observation.SpanID != ""
case "correlation_id":
return observation.CorrelationID, observation.CorrelationID != ""
default:
value, ok := observation.Attributes[CanonicalField(field)]
return value, ok
}
})
}
func CompareValue(left, right, operator string, valueType schema.Type) (bool, error) {
if operator == "=~" {
if valueType != schema.TypeString {
return false, ErrTypeMismatch
}
expression, err := regexp.Compile(right)
if err != nil {
return false, ErrTypeMismatch
}
return expression.MatchString(left), nil
}
var comparison int
switch valueType {
case schema.TypeInteger, schema.TypeFloat, schema.TypeDuration:
rightNumber, rightErr := strconv.ParseFloat(right, 64)
if rightErr != nil || math.IsNaN(rightNumber) || math.IsInf(rightNumber, 0) {
return false, ErrTypeMismatch
}
leftNumber, leftErr := strconv.ParseFloat(left, 64)
if leftErr != nil || math.IsNaN(leftNumber) || math.IsInf(leftNumber, 0) {
return false, nil
}
comparison = compareFloat(leftNumber, rightNumber)
case schema.TypeTime:
rightTime, rightErr := time.Parse(time.RFC3339Nano, right)
if rightErr != nil {
return false, ErrTypeMismatch
}
leftTime, leftErr := time.Parse(time.RFC3339Nano, left)
if leftErr != nil {
return false, nil
}
comparison = leftTime.Compare(rightTime)
case schema.TypeBoolean:
rightBool, rightErr := strconv.ParseBool(right)
if rightErr != nil {
return false, ErrTypeMismatch
}
leftBool, leftErr := strconv.ParseBool(left)
if leftErr != nil {
return false, nil
}
comparison = compareBool(leftBool, rightBool)
default:
comparison = strings.Compare(left, right)
}
switch operator {
case "==":
return comparison == 0, nil
case "!=":
return comparison != 0, nil
case ">":
return comparison > 0, nil
case ">=":
return comparison >= 0, nil
case "<":
return comparison < 0, nil
case "<=":
return comparison <= 0, nil
default:
return false, ErrTypeMismatch
}
}
func compareFloat(left, right float64) int {
if left < right {
return -1
}
if left > right {
return 1
}
return 0
}
func compareBool(left, right bool) int {
if left == right {
return 0
}
if !left {
return -1
}
return 1
}
+44
View File
@@ -0,0 +1,44 @@
// SPDX-License-Identifier: AGPL-3.0-only
package query
import (
"testing"
"time"
"gamertan.com/observatory/internal/model"
)
func TestMatchObservationUsesCanonicalTypedFilters(t *testing.T) {
now := time.Date(2026, 8, 18, 23, 55, 0, 0, time.UTC)
observation := model.Observation{Timestamp: now, Name: "http.request", Severity: "error", Attributes: map[string]string{"http.status_code": "503", "http.route": "/failed"}}
ast, err := Parse(`logs | where status >= 500 | where route == "/failed" | limit 10`, 100)
if err != nil {
t.Fatal(err)
}
matched, err := MatchObservation(observation, ast, nil)
if err != nil || !matched {
t.Fatalf("matched=%t err=%v", matched, err)
}
observation.Attributes["http.status_code"] = "200"
matched, err = MatchObservation(observation, ast, nil)
if err != nil || matched {
t.Fatalf("matched=%t err=%v", matched, err)
}
delete(observation.Attributes, "http.status_code")
matched, err = MatchObservation(observation, ast, nil)
if err != nil || matched {
t.Fatalf("missing field matched=%t err=%v", matched, err)
}
}
func TestMatchObservationRejectsInvalidTypedFilterValue(t *testing.T) {
ast, err := Parse(`logs | where status >= nope | limit 10`, 100)
if err != nil {
t.Fatal(err)
}
observation := model.Observation{Timestamp: time.Now().UTC(), Name: "http.request", Attributes: map[string]string{"http.status_code": "503"}}
if _, err = MatchObservation(observation, ast, nil); err != ErrTypeMismatch {
t.Fatalf("err=%v", err)
}
}
+248
View File
@@ -0,0 +1,248 @@
// SPDX-License-Identifier: AGPL-3.0-only
package query
import (
"errors"
"fmt"
"sort"
"strings"
"time"
"gamertan.com/observatory/internal/model"
"gamertan.com/observatory/internal/schema"
)
var ErrSensitivePermissionRequired = errors.New("query requires sensitive-field permission")
type Scope struct {
OrganizationID string `json:"organization_id"`
ProjectID string `json:"project_id,omitempty"`
EnvironmentID string `json:"environment_id,omitempty"`
ServiceID string `json:"service_id,omitempty"`
Sensitive bool `json:"sensitive"`
}
type Budget struct {
MaxDuration time.Duration `json:"-"`
MaxRows int `json:"max_rows"`
MaxScannedBytes int64 `json:"max_scanned_bytes"`
MaxMemoryBytes int64 `json:"max_memory_bytes"`
}
type FieldPlan struct {
Field string `json:"field"`
Descriptor schema.Descriptor `json:"descriptor"`
Indexed bool `json:"indexed"`
Unknown bool `json:"unknown"`
}
type Explain struct {
AST AST `json:"ast"`
ProjectedSources []string `json:"projected_sources"`
Fields []FieldPlan `json:"fields"`
EstimatedScanBytes int64 `json:"estimated_scan_bytes"`
CacheEligible bool `json:"cache_eligible"`
RequiredPermissions []string `json:"required_permissions"`
Budget Budget `json:"budget"`
}
type Registry interface {
Lookup(model.Signal, string) (schema.Descriptor, bool)
}
type MapRegistry map[string]schema.Descriptor
func (registry MapRegistry) Lookup(signal model.Signal, field string) (schema.Descriptor, bool) {
descriptor, ok := registry[string(signal)+":"+CanonicalField(field)]
return descriptor, ok
}
func Plan(ast AST, scope Scope, registry Registry, estimatedScanBytes int64, budget Budget) (Explain, error) {
if err := Validate(ast, budget.MaxRows); err != nil {
return Explain{}, err
}
if !safeScope(scope) {
return Explain{}, errors.New("query scope is invalid")
}
if budget.MaxDuration < time.Millisecond || budget.MaxDuration > time.Minute || budget.MaxRows < 1 || budget.MaxScannedBytes < 1 || budget.MaxMemoryBytes < 1 {
return Explain{}, errors.New("query budget is invalid")
}
if estimatedScanBytes < 0 || estimatedScanBytes > budget.MaxScannedBytes {
return Explain{}, errors.New("estimated query scan exceeds budget")
}
fields := ReferencedFields(ast)
plans := make([]FieldPlan, 0, len(fields))
requiresSensitive := false
cacheEligible := true
for _, field := range fields {
canonical := CanonicalField(field)
descriptor, unknown := ResolveDescriptor(ast.Signal, canonical, registry)
if err := descriptor.Validate(); err != nil {
return Explain{}, fmt.Errorf("field %s descriptor: %w", field, err)
}
if descriptor.Sensitivity == schema.SensitivitySensitive {
requiresSensitive = true
cacheEligible = false
}
plans = append(plans, FieldPlan{Field: field, Descriptor: descriptor, Indexed: descriptor.Index != schema.IndexNone, Unknown: unknown})
}
if requiresSensitive && !scope.Sensitive {
return Explain{}, ErrSensitivePermissionRequired
}
for _, filter := range ast.Filters {
if filter.Op == "=~" {
cacheEligible = false
}
}
permissions := []string{"telemetry:query"}
if requiresSensitive {
permissions = append(permissions, "telemetry:sensitive")
}
source := "organization:" + scope.OrganizationID + "/signal:" + string(ast.Signal)
if scope.ProjectID != "" {
source += "/project:" + scope.ProjectID
}
if scope.EnvironmentID != "" {
source += "/environment:" + scope.EnvironmentID
}
if scope.ServiceID != "" {
source += "/service:" + scope.ServiceID
}
return Explain{AST: ast, ProjectedSources: []string{source}, Fields: plans, EstimatedScanBytes: estimatedScanBytes, CacheEligible: cacheEligible, RequiredPermissions: permissions, Budget: budget}, nil
}
func ReferencedFields(ast AST) []string {
seen := map[string]bool{}
var fields []string
add := func(field string) {
if field != "" && !seen[field] {
seen[field] = true
fields = append(fields, field)
}
}
for _, filter := range ast.Filters {
add(filter.Field)
}
if ast.Sort != nil {
isAggregateAlias := false
if ast.Summary != nil {
for _, aggregate := range ast.Summary.Aggregates {
if aggregate.Alias == ast.Sort.Field {
isAggregateAlias = true
break
}
}
}
if !isAggregateAlias {
add(ast.Sort.Field)
}
}
if ast.Summary != nil {
for _, aggregate := range ast.Summary.Aggregates {
add(aggregate.Field)
}
for _, field := range ast.Summary.GroupBy {
add(field)
}
}
sort.Strings(fields)
return fields
}
func CanonicalField(field string) string {
switch field {
case "service":
return "service.id"
case "project":
return "project.id"
case "environment":
return "environment.id"
case "route":
return "http.route"
case "status":
return "http.status_code"
case "duration":
return "duration_ns"
default:
return field
}
}
func ResolveDescriptor(signal model.Signal, field string, registry Registry) (schema.Descriptor, bool) {
canonical := CanonicalField(field)
descriptor, ok := BuiltinDescriptor(signal, canonical)
if !ok && registry != nil {
descriptor, ok = registry.Lookup(signal, canonical)
}
if !ok {
return schema.Unknown(signal, canonical), true
}
return descriptor, false
}
func BuiltinDescriptor(signal model.Signal, field string) (schema.Descriptor, bool) {
descriptors := map[string]schema.Descriptor{
"service.id": descriptor(signal, "service.id", schema.TypeString, "Application service identifier.", schema.SensitivityInternal, schema.CardinalityLow, schema.IndexExact, ""),
"project.id": descriptor(signal, "project.id", schema.TypeString, "Application project identifier.", schema.SensitivityInternal, schema.CardinalityLow, schema.IndexExact, ""),
"environment.id": descriptor(signal, "environment.id", schema.TypeString, "Deployment environment identifier.", schema.SensitivityInternal, schema.CardinalityLow, schema.IndexExact, ""),
"timestamp": descriptor(signal, "timestamp", schema.TypeTime, "Observation timestamp.", schema.SensitivityInternal, schema.CardinalityHigh, schema.IndexRange, "s"),
"name": descriptor(signal, "name", schema.TypeString, "Observation name.", schema.SensitivityInternal, schema.CardinalityMedium, schema.IndexExact, ""),
"severity": descriptor(signal, "severity", schema.TypeString, "Log severity.", schema.SensitivityInternal, schema.CardinalityLow, schema.IndexExact, ""),
"value": descriptor(signal, "value", schema.TypeFloat, "Numeric observation value.", schema.SensitivityInternal, schema.CardinalityHigh, schema.IndexRange, ""),
"http.route": descriptor(signal, "http.route", schema.TypeString, "Application-normalized HTTP route.", schema.SensitivityInternal, schema.CardinalityMedium, schema.IndexExact, ""),
"http.status_code": descriptor(signal, "http.status_code", schema.TypeInteger, "HTTP response status code.", schema.SensitivityPublic, schema.CardinalityLow, schema.IndexExact, ""),
"duration_ns": descriptor(signal, "duration_ns", schema.TypeDuration, "Observed duration in nanoseconds.", schema.SensitivityInternal, schema.CardinalityHigh, schema.IndexRange, "ns"),
"state": descriptor(signal, "state", schema.TypeString, "Bounded metric state dimension.", schema.SensitivityInternal, schema.CardinalityLow, schema.IndexExact, ""),
"period": descriptor(signal, "period", schema.TypeString, "Bounded measurement period dimension.", schema.SensitivityInternal, schema.CardinalityLow, schema.IndexExact, ""),
"interface": descriptor(signal, "interface", schema.TypeString, "Configured network interface dimension.", schema.SensitivityInternal, schema.CardinalityMedium, schema.IndexExact, ""),
"direction": descriptor(signal, "direction", schema.TypeString, "Bounded input or output direction dimension.", schema.SensitivityInternal, schema.CardinalityLow, schema.IndexExact, ""),
"filesystem": descriptor(signal, "filesystem", schema.TypeString, "Configured filesystem dimension.", schema.SensitivityInternal, schema.CardinalityMedium, schema.IndexExact, ""),
"process": descriptor(signal, "process", schema.TypeString, "Configured process dimension.", schema.SensitivityInternal, schema.CardinalityMedium, schema.IndexExact, ""),
"cgroup": descriptor(signal, "cgroup", schema.TypeString, "Configured cgroup dimension.", schema.SensitivityInternal, schema.CardinalityMedium, schema.IndexExact, ""),
"unit": descriptor(signal, "unit", schema.TypeString, "Metric unit supplied by a bounded collector.", schema.SensitivityInternal, schema.CardinalityLow, schema.IndexExact, ""),
"trace_id": descriptor(signal, "trace_id", schema.TypeString, "Trace correlation identifier.", schema.SensitivityInternal, schema.CardinalityHigh, schema.IndexRange, ""),
"span_id": descriptor(signal, "span_id", schema.TypeString, "Span correlation identifier.", schema.SensitivityInternal, schema.CardinalityHigh, schema.IndexRange, ""),
"correlation_id": descriptor(signal, "correlation_id", schema.TypeString, "Cross-signal correlation identifier.", schema.SensitivityInternal, schema.CardinalityHigh, schema.IndexRange, ""),
"body": descriptor(signal, "body", schema.TypeString, "Optional retained log text.", schema.SensitivitySensitive, schema.CardinalityHigh, schema.IndexNone, ""),
}
descriptor, ok := descriptors[field]
if ok && signal != model.SignalMetrics {
switch field {
case "state", "period", "interface", "direction", "filesystem", "process", "cgroup", "unit":
return schema.Descriptor{}, false
}
}
if ok && signal == model.SignalMetrics {
switch field {
case "http.route", "http.status_code", "state", "period", "interface", "direction", "filesystem", "process", "cgroup", "unit":
descriptor.Retention = schema.RetentionMetric
}
}
return descriptor, ok
}
func descriptor(signal model.Signal, field string, valueType schema.Type, meaning string, sensitivity schema.Sensitivity, cardinality schema.Cardinality, index schema.IndexPolicy, unit string) schema.Descriptor {
return schema.Descriptor{Version: schema.DescriptorVersion, Signal: signal, Field: field, Type: valueType, Unit: unit, Meaning: meaning, Sensitivity: sensitivity, Cardinality: cardinality, Index: index, Retention: schema.RetentionRaw, ProjectionVersion: 1}
}
func safeScope(scope Scope) bool {
values := []string{scope.OrganizationID, scope.ProjectID, scope.EnvironmentID, scope.ServiceID}
if values[0] == "" {
return false
}
for _, value := range values {
if value == "" {
continue
}
if len(value) > 128 {
return false
}
for _, r := range value {
if !(r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || strings.ContainsRune("._-", r)) {
return false
}
}
}
return true
}
+80
View File
@@ -0,0 +1,80 @@
// SPDX-License-Identifier: AGPL-3.0-only
package query
import (
"testing"
"time"
"gamertan.com/observatory/internal/model"
"gamertan.com/observatory/internal/schema"
)
func TestPlanInjectsScopeAndReportsIndexes(t *testing.T) {
ast, err := Parse(`logs | where service == "eql" | where status >= 500 | window 24h | sort timestamp desc | limit 50`, 1000)
if err != nil {
t.Fatal(err)
}
explain, err := Plan(ast, Scope{OrganizationID: "personal-cole", ProjectID: "eql", EnvironmentID: "production"}, nil, 10<<20, Budget{MaxDuration: 5 * time.Second, MaxRows: 1000, MaxScannedBytes: 100 << 20, MaxMemoryBytes: 64 << 20})
if err != nil {
t.Fatal(err)
}
if len(explain.ProjectedSources) != 1 || explain.ProjectedSources[0] != "organization:personal-cole/signal:logs/project:eql/environment:production" || len(explain.Fields) != 3 {
t.Fatalf("explain=%+v", explain)
}
for _, field := range explain.Fields {
if !field.Indexed || field.Unknown {
t.Fatalf("field=%+v", field)
}
}
}
func TestPlanRequiresSensitivePermissionForUnknownAndBody(t *testing.T) {
for _, text := range []string{`logs | where vendor.unknown == "x" | limit 10`, `logs | where body =~ "error" | limit 10`} {
ast, err := Parse(text, 100)
if err != nil {
t.Fatal(err)
}
budget := Budget{MaxDuration: time.Second, MaxRows: 100, MaxScannedBytes: 1 << 20, MaxMemoryBytes: 1 << 20}
if _, err := Plan(ast, Scope{OrganizationID: "org"}, nil, 100, budget); err == nil {
t.Fatalf("expected sensitive rejection for %q", text)
}
explain, err := Plan(ast, Scope{OrganizationID: "org", Sensitive: true}, nil, 100, budget)
if err != nil {
t.Fatal(err)
}
if len(explain.RequiredPermissions) != 2 || explain.CacheEligible {
t.Fatalf("explain=%+v", explain)
}
}
}
func TestPlanRejectsCrossTenantScopeAndScanBudget(t *testing.T) {
ast, _ := Parse(`metrics | limit 10`, 100)
budget := Budget{MaxDuration: time.Second, MaxRows: 100, MaxScannedBytes: 1000, MaxMemoryBytes: 1000}
if _, err := Plan(ast, Scope{OrganizationID: "../other"}, nil, 10, budget); err == nil {
t.Fatal("expected scope rejection")
}
if _, err := Plan(ast, Scope{OrganizationID: "org"}, nil, 1001, budget); err == nil {
t.Fatal("expected scan-budget rejection")
}
}
func TestBuiltinMetricDimensionsAreSignalScopedAndRetentionAware(t *testing.T) {
state, ok := BuiltinDescriptor(model.SignalMetrics, "state")
if !ok || state.Retention != schema.RetentionMetric {
t.Fatalf("metric state descriptor=%+v ok=%t", state, ok)
}
if _, ok = BuiltinDescriptor(model.SignalLogs, "state"); ok {
t.Fatal("metric-only state dimension was exposed to logs")
}
metricRoute, ok := BuiltinDescriptor(model.SignalMetrics, "http.route")
if !ok || metricRoute.Retention != schema.RetentionMetric {
t.Fatalf("metric route descriptor=%+v ok=%t", metricRoute, ok)
}
logRoute, ok := BuiltinDescriptor(model.SignalLogs, "http.route")
if !ok || logRoute.Retention != schema.RetentionRaw {
t.Fatalf("log route descriptor=%+v ok=%t", logRoute, ok)
}
}
+300
View File
@@ -0,0 +1,300 @@
// SPDX-License-Identifier: AGPL-3.0-only
package query
import (
"errors"
"fmt"
"regexp"
"strconv"
"strings"
"time"
"gamertan.com/observatory/internal/model"
)
const (
ASTVersion = 1
maxQueryWindow = 3650 * 24 * time.Hour
)
type AST struct {
Version int `json:"version"`
Signal model.Signal `json:"signal"`
Filters []Filter `json:"filters,omitempty"`
Sort *Sort `json:"sort,omitempty"`
Summary *Summary `json:"summary,omitempty"`
Limit int `json:"limit"`
Window time.Duration `json:"-"`
WindowText string `json:"window,omitempty"`
Bucket time.Duration `json:"-"`
BucketText string `json:"bucket,omitempty"`
}
type Filter struct {
Field string `json:"field"`
Op string `json:"op"`
Value string `json:"value"`
}
type Sort struct {
Field string `json:"field"`
Descending bool `json:"descending"`
}
type Summary struct {
Aggregates []Aggregate `json:"aggregates"`
GroupBy []string `json:"group_by,omitempty"`
}
type Aggregate struct {
Function string `json:"function"`
Field string `json:"field,omitempty"`
Alias string `json:"alias"`
}
func Parse(text string, maxLimit int) (AST, error) {
if len(text) == 0 || len(text) > 16_384 {
return AST{}, errors.New("query length outside accepted bounds")
}
parts := strings.Split(text, "|")
if len(parts) > 16 {
return AST{}, errors.New("too many query stages")
}
ast := AST{Version: ASTVersion, Signal: model.Signal(strings.TrimSpace(parts[0])), Limit: min(100, maxLimit)}
if ast.Signal != model.SignalLogs && ast.Signal != model.SignalMetrics && ast.Signal != model.SignalTraces && ast.Signal != model.SignalDeployments {
return AST{}, errors.New("query must begin with logs, metrics, traces, or deployments")
}
for _, raw := range parts[1:] {
stage := strings.TrimSpace(raw)
switch {
case strings.HasPrefix(stage, "where "):
filter, err := parseFilter(strings.TrimSpace(strings.TrimPrefix(stage, "where ")))
if err != nil {
return AST{}, err
}
ast.Filters = append(ast.Filters, filter)
case strings.HasPrefix(stage, "sort "):
fields := strings.Fields(strings.TrimSpace(strings.TrimPrefix(stage, "sort ")))
if len(fields) < 1 || len(fields) > 2 || !validField(fields[0]) {
return AST{}, errors.New("invalid sort stage")
}
desc := len(fields) == 2 && fields[1] == "desc"
if len(fields) == 2 && fields[1] != "asc" && fields[1] != "desc" {
return AST{}, errors.New("sort direction must be asc or desc")
}
ast.Sort = &Sort{Field: fields[0], Descending: desc}
case strings.HasPrefix(stage, "summarize "):
if ast.Summary != nil {
return AST{}, errors.New("query may contain only one summarize stage")
}
summary, bucket, bucketText, err := parseSummary(strings.TrimSpace(strings.TrimPrefix(stage, "summarize ")))
if err != nil {
return AST{}, err
}
ast.Summary = &summary
if bucket > 0 {
ast.Bucket, ast.BucketText = bucket, bucketText
}
case strings.HasPrefix(stage, "limit "):
n, err := strconv.Atoi(strings.TrimSpace(strings.TrimPrefix(stage, "limit ")))
if err != nil || n < 1 || n > maxLimit {
return AST{}, fmt.Errorf("limit must be between 1 and %d", maxLimit)
}
ast.Limit = n
case strings.HasPrefix(stage, "window "):
windowText := strings.TrimSpace(strings.TrimPrefix(stage, "window "))
d, err := time.ParseDuration(windowText)
if err != nil || d < time.Second || d > maxQueryWindow {
return AST{}, errors.New("window must be between 1s and 87600h")
}
ast.Window, ast.WindowText = d, windowText
default:
return AST{}, fmt.Errorf("unsupported query stage %q", stage)
}
}
if err := Validate(ast, maxLimit); err != nil {
return AST{}, err
}
return ast, nil
}
func Validate(ast AST, maxLimit int) error {
if ast.Version != ASTVersion || (ast.Signal != model.SignalLogs && ast.Signal != model.SignalMetrics && ast.Signal != model.SignalTraces && ast.Signal != model.SignalDeployments) {
return errors.New("query AST identity is invalid")
}
if ast.Limit < 1 || ast.Limit > maxLimit {
return fmt.Errorf("limit must be between 1 and %d", maxLimit)
}
if ast.Window < 0 || ast.Window > maxQueryWindow || ast.Window > 0 && ast.Window < time.Second {
return errors.New("query window is invalid")
}
if ast.Bucket < 0 || ast.Bucket > maxQueryWindow || ast.Bucket > 0 && (ast.Bucket < time.Second || ast.Summary == nil) {
return errors.New("query summary bucket is invalid")
}
if len(ast.Filters) > 16 {
return errors.New("too many query filters")
}
for _, filter := range ast.Filters {
if !validField(filter.Field) || len(filter.Value) > 4096 {
return errors.New("query filter is invalid")
}
switch filter.Op {
case "!=", ">=", "<=", "==", ">", "<":
case "=~":
if len(filter.Value) > 512 {
return errors.New("regular expression exceeds 512 bytes")
}
if _, err := regexp.Compile(filter.Value); err != nil {
return errors.New("invalid regular expression")
}
default:
return errors.New("query filter operator is invalid")
}
}
if ast.Sort != nil && !validField(ast.Sort.Field) {
return errors.New("query sort is invalid")
}
if ast.Summary != nil {
if len(ast.Summary.Aggregates) < 1 || len(ast.Summary.Aggregates) > 16 || len(ast.Summary.GroupBy) > 16 {
return errors.New("query summary is invalid")
}
aliases := map[string]bool{}
for _, aggregate := range ast.Summary.Aggregates {
switch aggregate.Function {
case "count":
if aggregate.Field != "" {
return errors.New("count accepts no field")
}
case "min", "max", "sum", "avg", "p50", "p95", "p99":
if !validField(aggregate.Field) {
return errors.New("aggregate field is invalid")
}
default:
return errors.New("aggregate function is unsupported")
}
if !validField(aggregate.Alias) || aliases[aggregate.Alias] {
return errors.New("aggregate alias is invalid or duplicated")
}
aliases[aggregate.Alias] = true
}
for _, group := range ast.Summary.GroupBy {
if !validField(group) {
return errors.New("grouping field is invalid")
}
if aliases[group] {
return errors.New("grouping field conflicts with aggregate alias")
}
}
}
return nil
}
func parseFilter(expr string) (Filter, error) {
for _, op := range []string{"!=", ">=", "<=", "==", "=~", ">", "<"} {
if i := strings.Index(expr, op); i > 0 {
field := strings.TrimSpace(expr[:i])
value := strings.TrimSpace(expr[i+len(op):])
if !validField(field) || value == "" || len(value) > 4096 {
return Filter{}, errors.New("invalid where stage")
}
if len(value) >= 2 && value[0] == '"' && value[len(value)-1] == '"' {
unquoted, err := strconv.Unquote(value)
if err != nil {
return Filter{}, errors.New("invalid quoted filter value")
}
value = unquoted
}
if op == "=~" {
if len(value) > 512 {
return Filter{}, errors.New("regular expression exceeds 512 bytes")
}
if _, err := regexp.Compile(value); err != nil {
return Filter{}, errors.New("invalid regular expression")
}
}
return Filter{Field: field, Op: op, Value: value}, nil
}
}
return Filter{}, errors.New("where stage requires a comparison")
}
func parseSummary(stage string) (Summary, time.Duration, string, error) {
aggregateText, groupText, found := strings.Cut(stage, " by ")
aggregateParts := strings.Split(aggregateText, ",")
if len(aggregateParts) < 1 || len(aggregateParts) > 16 {
return Summary{}, 0, "", errors.New("summarize requires between 1 and 16 aggregates")
}
summary := Summary{}
aliases := map[string]bool{}
for _, raw := range aggregateParts {
expression := strings.TrimSpace(raw)
open := strings.IndexByte(expression, '(')
if open < 1 || !strings.HasSuffix(expression, ")") {
return Summary{}, 0, "", errors.New("invalid aggregate")
}
function := expression[:open]
field := strings.TrimSpace(expression[open+1 : len(expression)-1])
switch function {
case "count":
if field != "" {
return Summary{}, 0, "", errors.New("count accepts no field")
}
case "min", "max", "sum", "avg", "p50", "p95", "p99":
if !validField(field) {
return Summary{}, 0, "", errors.New("aggregate field is invalid")
}
default:
return Summary{}, 0, "", errors.New("aggregate function is unsupported")
}
alias := function
if field != "" {
alias += "_" + strings.ReplaceAll(field, ".", "_")
}
if aliases[alias] {
return Summary{}, 0, "", errors.New("aggregate alias is duplicated")
}
aliases[alias] = true
summary.Aggregates = append(summary.Aggregates, Aggregate{Function: function, Field: field, Alias: alias})
}
var window time.Duration
var windowText string
if found {
groups := strings.Split(groupText, ",")
if len(groups) > 16 {
return Summary{}, 0, "", errors.New("too many grouping fields")
}
for _, raw := range groups {
group := strings.TrimSpace(raw)
if strings.HasPrefix(group, "window(") && strings.HasSuffix(group, ")") {
if window > 0 {
return Summary{}, 0, "", errors.New("summary window is duplicated")
}
windowText = strings.TrimSpace(group[len("window(") : len(group)-1])
parsed, err := time.ParseDuration(windowText)
if err != nil || parsed < time.Second || parsed > maxQueryWindow {
return Summary{}, 0, "", errors.New("summary window must be between 1s and 87600h")
}
window = parsed
continue
}
if !validField(group) {
return Summary{}, 0, "", errors.New("grouping field is invalid")
}
summary.GroupBy = append(summary.GroupBy, group)
}
}
return summary, window, windowText, nil
}
func validField(field string) bool {
if field == "" || len(field) > 128 {
return false
}
for _, r := range field {
if !(r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '_' || r == '.') {
return false
}
}
return true
}
+104
View File
@@ -0,0 +1,104 @@
// SPDX-License-Identifier: AGPL-3.0-only
package query
import (
"encoding/json"
"reflect"
"testing"
)
func TestParseBoundedQuery(t *testing.T) {
ast, err := Parse(`logs | where service == "eql" | where status >= 500 | window 24h | sort timestamp desc | limit 50`, 1000)
if err != nil {
t.Fatal(err)
}
if ast.Limit != 50 || len(ast.Filters) != 2 || ast.WindowText != "24h" || ast.Sort == nil || !ast.Sort.Descending {
t.Fatalf("unexpected AST: %#v", ast)
}
}
func TestTextAndVisualBuilderShareValidatedAST(t *testing.T) {
fromText, err := Parse(`metrics | where service == "eql" | window 1h | limit 25`, 1000)
if err != nil {
t.Fatal(err)
}
b, err := json.Marshal(fromText)
if err != nil {
t.Fatal(err)
}
var fromBuilder AST
if err := json.Unmarshal(b, &fromBuilder); err != nil {
t.Fatal(err)
}
fromBuilder.Window = fromText.Window
fromBuilder.Bucket = fromText.Bucket
if err := Validate(fromBuilder, 1000); err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(fromText, fromBuilder) {
t.Fatalf("text=%+v builder=%+v", fromText, fromBuilder)
}
fromBuilder.Filters[0].Op = "SQL"
if err := Validate(fromBuilder, 1000); err == nil {
t.Fatal("expected hostile builder AST rejection")
}
}
func TestParseRejectsSQLAndUnboundedLimit(t *testing.T) {
for _, input := range []string{"select * from logs", "logs | limit 1001", "logs | where route;drop == x"} {
if _, err := Parse(input, 1000); err == nil {
t.Fatalf("expected rejection for %q", input)
}
}
}
func TestParseUnifiedSummaryAndSafeRegex(t *testing.T) {
ast, err := Parse(`logs | where route =~ "^/items/[0-9]+$" | summarize count(), p95(duration) by route, window(5m) | sort count desc | limit 50`, 1000)
if err != nil {
t.Fatal(err)
}
if ast.Summary == nil || len(ast.Summary.Aggregates) != 2 || len(ast.Summary.GroupBy) != 1 || ast.WindowText != "" || ast.BucketText != "5m" || ast.Summary.Aggregates[1].Alias != "p95_duration" {
t.Fatalf("ast=%+v", ast)
}
if _, err := Parse(`logs | where route =~ "["`, 1000); err == nil {
t.Fatal("expected invalid regular expression rejection")
}
}
func TestParseSupportsApprovedTenYearColdLookback(t *testing.T) {
ast, err := Parse(`logs | window 87600h | limit 10`, 100)
if err != nil || ast.Window != maxQueryWindow {
t.Fatalf("ast=%+v err=%v", ast, err)
}
if _, err = Parse(`logs | window 87601h | limit 10`, 100); err == nil {
t.Fatal("lookback beyond retention ceiling was accepted")
}
}
func FuzzParse(f *testing.F) {
for _, seed := range []string{
`logs | where service == "eql" | limit 50`,
`metrics | summarize count(), p95(duration) by route, window(5m) | limit 50`,
`traces | where trace_id =~ "^[0-9a-f]{32}$" | window 1h | limit 10`,
`select * from logs`,
string([]byte{0, 1, 2, 3}),
} {
f.Add(seed)
}
f.Fuzz(func(t *testing.T, text string) {
if len(text) > 20_000 {
return
}
ast, err := Parse(text, 1_000)
if err != nil {
return
}
if err = Validate(ast, 1_000); err != nil {
t.Fatalf("parser returned an invalid AST: %v", err)
}
if ast.Limit < 1 || ast.Limit > 1_000 || len(ast.Filters) > 16 {
t.Fatalf("accepted AST violates hard bounds: %+v", ast)
}
})
}
+45
View File
@@ -0,0 +1,45 @@
// SPDX-License-Identifier: AGPL-3.0-only
package query
import (
"errors"
"gamertan.com/observatory/internal/schema"
)
const ResultVersion = 1
var (
ErrBudgetExceeded = errors.New("query execution budget exceeded")
ErrTypeMismatch = errors.New("query value does not match its field type")
)
type Column struct {
Field string `json:"field"`
Type schema.Type `json:"type"`
Unit string `json:"unit,omitempty"`
}
// Row values align positionally with Result.Columns. Nil is a missing value;
// non-nil values use the canonical string form described by the column type.
type Row struct {
Values []*string `json:"values"`
}
type Statistics struct {
ScannedRows int `json:"scanned_rows"`
MatchedRows int `json:"matched_rows"`
ScannedBytes int64 `json:"scanned_bytes"`
DurationNS int64 `json:"duration_ns"`
Truncated bool `json:"truncated"`
Approximate bool `json:"approximate,omitempty"`
}
type Result struct {
Version int `json:"version"`
Explain Explain `json:"explain"`
Columns []Column `json:"columns"`
Rows []Row `json:"rows"`
Stats Statistics `json:"statistics"`
}
+138
View File
@@ -0,0 +1,138 @@
// SPDX-License-Identifier: AGPL-3.0-only
package schema
import (
"errors"
"fmt"
"regexp"
"strings"
"gamertan.com/observatory/internal/model"
)
const DescriptorVersion = 1
type Type string
type Sensitivity string
type Cardinality string
type IndexPolicy string
type RetentionClass string
const (
TypeString Type = "string"
TypeInteger Type = "integer"
TypeFloat Type = "float"
TypeBoolean Type = "boolean"
TypeDuration Type = "duration"
TypeTime Type = "time"
SensitivityPublic Sensitivity = "public"
SensitivityInternal Sensitivity = "internal"
SensitivitySensitive Sensitivity = "sensitive"
CardinalityLow Cardinality = "low"
CardinalityMedium Cardinality = "medium"
CardinalityHigh Cardinality = "high"
IndexNone IndexPolicy = "none"
IndexExact IndexPolicy = "exact"
IndexRange IndexPolicy = "range"
RetentionRaw RetentionClass = "raw"
RetentionMetric RetentionClass = "metric"
RetentionEvidence RetentionClass = "evidence"
)
var fieldPattern = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_.]{0,127}$`)
var unitPattern = regexp.MustCompile(`^[A-Za-z0-9%/._-]{0,64}$`)
type Descriptor struct {
Version int `json:"version"`
Signal model.Signal `json:"signal"`
Field string `json:"field"`
Type Type `json:"type"`
Unit string `json:"unit,omitempty"`
Meaning string `json:"meaning"`
Sensitivity Sensitivity `json:"sensitivity"`
Cardinality Cardinality `json:"cardinality"`
Index IndexPolicy `json:"index"`
Retention RetentionClass `json:"retention"`
ProjectionVersion int `json:"projection_version"`
}
type Proposal struct {
Descriptor Descriptor `json:"descriptor"`
ObservedValues int64 `json:"observed_values"`
EstimatedBytes int64 `json:"estimated_bytes"`
ExampleQueries []string `json:"example_queries"`
}
func (d Descriptor) Validate() error {
if d.Version != DescriptorVersion || !validSignal(d.Signal) || !fieldPattern.MatchString(d.Field) || !unitPattern.MatchString(d.Unit) {
return errors.New("descriptor identity is invalid")
}
switch d.Type {
case TypeString, TypeInteger, TypeFloat, TypeBoolean, TypeDuration, TypeTime:
default:
return errors.New("descriptor type is invalid")
}
if len(d.Meaning) < 1 || len(d.Meaning) > 512 || strings.IndexByte(d.Meaning, 0) >= 0 {
return errors.New("descriptor meaning is invalid")
}
switch d.Sensitivity {
case SensitivityPublic, SensitivityInternal, SensitivitySensitive:
default:
return errors.New("descriptor sensitivity is invalid")
}
switch d.Cardinality {
case CardinalityLow, CardinalityMedium, CardinalityHigh:
default:
return errors.New("descriptor cardinality is invalid")
}
switch d.Index {
case IndexNone, IndexExact, IndexRange:
default:
return errors.New("descriptor index policy is invalid")
}
if d.Cardinality == CardinalityHigh && d.Index == IndexExact {
return errors.New("high-cardinality exact indexes require a reviewed exception")
}
switch d.Retention {
case RetentionRaw, RetentionMetric, RetentionEvidence:
default:
return errors.New("descriptor retention class is invalid")
}
if d.ProjectionVersion < 1 {
return errors.New("projection version must be positive")
}
return nil
}
func (p Proposal) Validate() error {
if err := p.Descriptor.Validate(); err != nil {
return err
}
if p.ObservedValues < 1 || p.EstimatedBytes < 0 || len(p.ExampleQueries) > 16 {
return errors.New("proposal evidence is invalid")
}
for _, example := range p.ExampleQueries {
if len(example) < 1 || len(example) > 4096 || strings.IndexByte(example, 0) >= 0 {
return fmt.Errorf("proposal example query is invalid")
}
}
return nil
}
func Unknown(signal model.Signal, field string) Descriptor {
return Descriptor{Version: DescriptorVersion, Signal: signal, Field: field, Type: TypeString, Meaning: "Unreviewed field retained in raw evidence.", Sensitivity: SensitivitySensitive, Cardinality: CardinalityHigh, Index: IndexNone, Retention: RetentionRaw, ProjectionVersion: 1}
}
func validSignal(signal model.Signal) bool {
switch signal {
case model.SignalLogs, model.SignalMetrics, model.SignalTraces, model.SignalDeployments:
return true
default:
return false
}
}
+26
View File
@@ -0,0 +1,26 @@
// SPDX-License-Identifier: AGPL-3.0-only
package schema
import (
"testing"
"gamertan.com/observatory/internal/model"
)
func TestUnknownFieldsAreSensitiveAndUnindexed(t *testing.T) {
descriptor := Unknown(model.SignalLogs, "vendor.unreviewed")
if err := descriptor.Validate(); err != nil {
t.Fatal(err)
}
if descriptor.Sensitivity != SensitivitySensitive || descriptor.Index != IndexNone || descriptor.Cardinality != CardinalityHigh {
t.Fatalf("descriptor=%+v", descriptor)
}
}
func TestDescriptorRejectsHighCardinalityExactIndex(t *testing.T) {
descriptor := Descriptor{Version: 1, Signal: model.SignalLogs, Field: "request.id", Type: TypeString, Meaning: "A request correlation identifier.", Sensitivity: SensitivityInternal, Cardinality: CardinalityHigh, Index: IndexExact, Retention: RetentionRaw, ProjectionVersion: 1}
if err := descriptor.Validate(); err == nil {
t.Fatal("expected unsafe index rejection")
}
}
+554
View File
@@ -0,0 +1,554 @@
// SPDX-License-Identifier: AGPL-3.0-only
package segment
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"gamertan.com/observatory/internal/model"
"github.com/klauspost/compress/zstd"
)
const (
MaxDecodedSegment = 64 << 20
MaxEncodedSegment = MaxDecodedSegment + 1<<20
metadataReadBatch = 128
)
type Store struct {
root string
}
type Committed struct {
Path string
Digest string
Compressed int64
Uncompressed int64
}
type Entry struct {
OrganizationID string
Committed Committed
Batch model.Batch
}
// Metadata identifies one immutable raw object without reading, checksumming,
// decompressing, or decoding its contents. It is safe to retain while walking
// a large store because it contains no telemetry records.
type Metadata struct {
OrganizationID string
SourceID string
StreamID string
Sequence uint64
Path string
Digest string
Compressed int64
}
func New(root string) (*Store, error) {
if !filepath.IsAbs(root) || filepath.Clean(root) != root {
return nil, errors.New("segment root must be an absolute clean path")
}
if err := os.MkdirAll(root, 0o700); err != nil {
return nil, fmt.Errorf("create segment root: %w", err)
}
info, err := os.Lstat(root)
if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 || info.Mode().Perm()&0o077 != 0 {
return nil, errors.New("segment root must be a private non-symlink directory")
}
return &Store{root: root}, nil
}
func (s *Store) Commit(scope model.Scope, batch model.Batch) (Committed, error) {
if err := scope.Validate(); err != nil {
return Committed{}, err
}
raw, err := json.Marshal(batch)
if err != nil {
return Committed{}, fmt.Errorf("encode batch: %w", err)
}
if len(raw) > MaxDecodedSegment {
return Committed{}, errors.New("decoded segment exceeds limit")
}
enc, err := zstd.NewWriter(nil, zstd.WithEncoderConcurrency(1), zstd.WithEncoderLevel(zstd.SpeedBetterCompression))
if err != nil {
return Committed{}, fmt.Errorf("create compressor: %w", err)
}
compressed := enc.EncodeAll(raw, nil)
enc.Close()
if len(compressed) > MaxEncodedSegment {
return Committed{}, errors.New("encoded segment exceeds limit")
}
sum := sha256.Sum256(compressed)
digest := hex.EncodeToString(sum[:])
dir := filepath.Join(s.root, "raw", scope.OrganizationID, batch.SourceID, batch.StreamID)
if err := ensurePrivateDirectoryChain(s.root, dir); err != nil {
return Committed{}, err
}
name := fmt.Sprintf("%020d-%s.zst", batch.Sequence, digest)
final := filepath.Join(dir, name)
if existing, err := readRegular(final, MaxEncodedSegment); err == nil {
if bytes.Equal(existing, compressed) {
return Committed{Path: final, Digest: digest, Compressed: int64(len(compressed)), Uncompressed: int64(len(raw))}, nil
}
return Committed{}, errors.New("existing segment digest collision")
} else if !errors.Is(err, os.ErrNotExist) {
return Committed{}, fmt.Errorf("inspect existing segment: %w", err)
}
tmp, err := os.CreateTemp(dir, ".segment-*")
if err != nil {
return Committed{}, fmt.Errorf("create segment temporary file: %w", err)
}
tmpName := tmp.Name()
cleanup := func() { _ = os.Remove(tmpName) }
defer cleanup()
if err := tmp.Chmod(0o600); err != nil {
_ = tmp.Close()
return Committed{}, fmt.Errorf("set segment mode: %w", err)
}
if _, err := tmp.Write(compressed); err != nil {
_ = tmp.Close()
return Committed{}, fmt.Errorf("write segment: %w", err)
}
if err := tmp.Sync(); err != nil {
_ = tmp.Close()
return Committed{}, fmt.Errorf("sync segment: %w", err)
}
if err := tmp.Close(); err != nil {
return Committed{}, fmt.Errorf("close segment: %w", err)
}
if err := os.Rename(tmpName, final); err != nil {
return Committed{}, fmt.Errorf("commit segment: %w", err)
}
d, err := os.Open(dir)
if err != nil {
return Committed{}, fmt.Errorf("open segment directory: %w", err)
}
if err := d.Sync(); err != nil {
_ = d.Close()
return Committed{}, fmt.Errorf("sync segment directory: %w", err)
}
if err := d.Close(); err != nil {
return Committed{}, fmt.Errorf("close segment directory: %w", err)
}
return Committed{Path: final, Digest: digest, Compressed: int64(len(compressed)), Uncompressed: int64(len(raw))}, nil
}
func (s *Store) Read(path, expectedDigest string) (model.Batch, error) {
var batch model.Batch
cleanRoot := filepath.Clean(s.root) + string(os.PathSeparator)
cleanPath := filepath.Clean(path)
if !strings.HasPrefix(cleanPath, cleanRoot) {
return batch, errors.New("segment path escapes store")
}
if err := validatePrivateDirectoryChain(s.root, filepath.Dir(cleanPath)); err != nil {
return batch, err
}
b, err := readRegular(cleanPath, MaxEncodedSegment)
if err != nil {
return batch, fmt.Errorf("read segment: %w", err)
}
sum := sha256.Sum256(b)
if hex.EncodeToString(sum[:]) != expectedDigest {
return batch, errors.New("segment checksum mismatch")
}
dec, err := zstd.NewReader(bytes.NewReader(b), zstd.WithDecoderConcurrency(1), zstd.WithDecoderMaxMemory(MaxDecodedSegment))
if err != nil {
return batch, fmt.Errorf("create decompressor: %w", err)
}
decompressed, err := io.ReadAll(io.LimitReader(dec, MaxDecodedSegment+1))
dec.Close()
if err != nil {
return batch, fmt.Errorf("decompress segment: %w", err)
}
if len(decompressed) > MaxDecodedSegment {
return batch, errors.New("decoded segment exceeds limit")
}
decoder := json.NewDecoder(bytes.NewReader(decompressed))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&batch); err != nil {
return batch, fmt.Errorf("decode segment: %w", err)
}
if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
return batch, errors.New("segment contains trailing JSON")
}
return batch, nil
}
// ReadEntry validates and decodes exactly one object previously returned by
// WalkMetadata. Callers can therefore keep startup discovery bounded and pay
// the decompression cost only for an object that actually needs recovery.
func (s *Store) ReadEntry(metadata Metadata) (Entry, error) {
actual, err := s.metadata(metadata.Path)
if err != nil {
return Entry{}, err
}
if actual != metadata {
return Entry{}, errors.New("segment metadata changed before decode")
}
batch, err := s.Read(metadata.Path, metadata.Digest)
if err != nil {
return Entry{}, err
}
if batch.SourceID != metadata.SourceID || batch.StreamID != metadata.StreamID || batch.Sequence != metadata.Sequence {
return Entry{}, errors.New("segment path does not match batch identity")
}
raw, err := json.Marshal(batch)
if err != nil {
return Entry{}, fmt.Errorf("measure decoded segment: %w", err)
}
return Entry{
OrganizationID: metadata.OrganizationID,
Committed: Committed{
Path: metadata.Path,
Digest: metadata.Digest,
Compressed: metadata.Compressed,
Uncompressed: int64(len(raw)),
},
Batch: batch,
}, nil
}
// Delete removes one already-retired segment after verifying that the path,
// filename, file type, and content digest still identify the exact committed
// object. A missing object is an idempotent success for crash recovery.
func (s *Store) Delete(path, expectedDigest string) error {
if len(expectedDigest) != 64 {
return errors.New("segment deletion digest is invalid")
}
rawRoot := filepath.Join(filepath.Clean(s.root), "raw") + string(os.PathSeparator)
coldRoot := filepath.Join(filepath.Clean(s.root), "cold") + string(os.PathSeparator)
cleanPath := filepath.Clean(path)
if (!strings.HasPrefix(cleanPath, rawRoot) && !strings.HasPrefix(cleanPath, coldRoot)) || !strings.HasSuffix(filepath.Base(cleanPath), "-"+expectedDigest+".zst") {
return errors.New("segment deletion path is invalid")
}
if err := validatePrivateDirectoryChain(s.root, filepath.Dir(cleanPath)); err != nil {
return err
}
if _, err := os.Lstat(cleanPath); errors.Is(err, os.ErrNotExist) {
return nil
} else if err != nil {
return fmt.Errorf("inspect retired segment: %w", err)
}
if _, err := s.Read(cleanPath, expectedDigest); err != nil {
return fmt.Errorf("verify retired segment: %w", err)
}
if err := os.Remove(cleanPath); err != nil && !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("remove retired segment: %w", err)
}
directory, err := os.Open(filepath.Dir(cleanPath))
if err != nil {
return fmt.Errorf("open retired segment directory: %w", err)
}
if err = directory.Sync(); err != nil {
_ = directory.Close()
return fmt.Errorf("sync retired segment directory: %w", err)
}
if err = directory.Close(); err != nil {
return fmt.Errorf("close retired segment directory: %w", err)
}
return nil
}
// MoveToCold atomically relocates one verified hot object beneath the cold
// archive. It is idempotent across a crash after rename: when the source is
// absent, the exact destination must already exist and match its digest.
func (s *Store) MoveToCold(path, target, expectedDigest string) error {
if len(expectedDigest) != 64 {
return errors.New("segment archive digest is invalid")
}
cleanPath, cleanTarget := filepath.Clean(path), filepath.Clean(target)
rawRoot := filepath.Join(filepath.Clean(s.root), "raw") + string(os.PathSeparator)
coldRoot := filepath.Join(filepath.Clean(s.root), "cold") + string(os.PathSeparator)
if !strings.HasPrefix(cleanPath, rawRoot) || !strings.HasPrefix(cleanTarget, coldRoot) || filepath.Base(cleanPath) != filepath.Base(cleanTarget) || !strings.HasSuffix(filepath.Base(cleanPath), "-"+expectedDigest+".zst") {
return errors.New("segment archive path is invalid")
}
if err := validatePrivateDirectoryChain(s.root, filepath.Dir(cleanPath)); err != nil {
return err
}
if err := ensurePrivateDirectoryChain(s.root, filepath.Dir(cleanTarget)); err != nil {
return err
}
if _, err := os.Lstat(cleanPath); errors.Is(err, os.ErrNotExist) {
if _, readErr := s.Read(cleanTarget, expectedDigest); readErr != nil {
return fmt.Errorf("recover archived segment: %w", readErr)
}
for _, directory := range []string{filepath.Dir(cleanTarget), filepath.Dir(cleanPath)} {
if syncErr := syncDirectory(directory); syncErr != nil {
return syncErr
}
}
return nil
} else if err != nil {
return fmt.Errorf("inspect hot segment: %w", err)
}
if _, err := os.Lstat(cleanTarget); err == nil {
return errors.New("cold segment destination already exists")
} else if !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("inspect cold segment destination: %w", err)
}
if _, err := s.Read(cleanPath, expectedDigest); err != nil {
return fmt.Errorf("verify hot segment: %w", err)
}
if err := os.Rename(cleanPath, cleanTarget); err != nil {
return fmt.Errorf("archive segment: %w", err)
}
for _, directory := range []string{filepath.Dir(cleanTarget), filepath.Dir(cleanPath)} {
if err := syncDirectory(directory); err != nil {
return err
}
}
return nil
}
func syncDirectory(path string) error {
directory, err := os.Open(path)
if err != nil {
return fmt.Errorf("open segment directory: %w", err)
}
if err = directory.Sync(); err != nil {
_ = directory.Close()
return fmt.Errorf("sync segment directory: %w", err)
}
if err = directory.Close(); err != nil {
return fmt.Errorf("close segment directory: %w", err)
}
return nil
}
func validatePrivateDirectoryChain(root, directory string) error {
root = filepath.Clean(root)
directory = filepath.Clean(directory)
relative, err := filepath.Rel(root, directory)
if err != nil || relative == ".." || strings.HasPrefix(relative, ".."+string(os.PathSeparator)) {
return errors.New("segment directory escapes store")
}
candidates := []string{root}
current := root
if relative != "." {
for _, component := range strings.Split(relative, string(os.PathSeparator)) {
if component == "" || component == "." || component == ".." {
return errors.New("segment directory path is invalid")
}
current = filepath.Join(current, component)
candidates = append(candidates, current)
}
}
for _, candidate := range candidates {
info, inspectErr := os.Lstat(candidate)
if inspectErr != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 || info.Mode().Perm()&0o077 != 0 {
return errors.New("segment directory chain must be private and non-symlinked")
}
}
return nil
}
func ensurePrivateDirectoryChain(root, directory string) error {
root = filepath.Clean(root)
directory = filepath.Clean(directory)
relative, err := filepath.Rel(root, directory)
if err != nil || relative == "." || relative == ".." || strings.HasPrefix(relative, ".."+string(os.PathSeparator)) {
return errors.New("segment directory path is invalid")
}
current := root
for _, component := range strings.Split(relative, string(os.PathSeparator)) {
if component == "" || component == "." || component == ".." {
return errors.New("segment directory path is invalid")
}
current = filepath.Join(current, component)
info, inspectErr := os.Lstat(current)
if errors.Is(inspectErr, os.ErrNotExist) {
if inspectErr = os.Mkdir(current, 0o700); inspectErr != nil && !errors.Is(inspectErr, os.ErrExist) {
return fmt.Errorf("create segment directory: %w", inspectErr)
}
info, inspectErr = os.Lstat(current)
}
if inspectErr != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 || info.Mode().Perm()&0o077 != 0 {
return errors.New("segment directory chain must be private and non-symlinked")
}
}
return nil
}
func readRegular(path string, maximum int) ([]byte, error) {
before, err := os.Lstat(path)
if err != nil {
return nil, err
}
if !before.Mode().IsRegular() || before.Mode()&os.ModeSymlink != 0 || before.Size() > int64(maximum) {
return nil, errors.New("segment is not a bounded regular file")
}
file, err := os.Open(path)
if err != nil {
return nil, err
}
defer file.Close()
after, err := file.Stat()
if err != nil || !after.Mode().IsRegular() || !os.SameFile(before, after) {
return nil, errors.New("segment changed during open")
}
body, err := io.ReadAll(io.LimitReader(file, int64(maximum)+1))
if err != nil {
return nil, err
}
if len(body) > maximum {
return nil, errors.New("encoded segment exceeds limit")
}
return body, nil
}
// WalkMetadata derives bounded committed-object identities from the filesystem
// so a crash after rename but before control-database bookkeeping cannot hide
// a segment. It deliberately does not read or decode segment contents.
func (s *Store) WalkMetadata(visit func(Metadata) error) error {
if visit == nil {
return errors.New("segment metadata visitor is required")
}
base := filepath.Join(s.root, "raw")
if _, err := os.Lstat(base); errors.Is(err, os.ErrNotExist) {
return nil
} else if err != nil {
return fmt.Errorf("inspect raw segment root: %w", err)
}
if err := validatePrivateDirectoryChain(s.root, base); err != nil {
return err
}
err := s.walkMetadataDirectory(base, 0, visit)
if err != nil {
return fmt.Errorf("walk raw segments: %w", err)
}
return nil
}
func (s *Store) walkMetadataDirectory(directoryPath string, depth int, visit func(Metadata) error) error {
directory, err := os.Open(directoryPath)
if err != nil {
return fmt.Errorf("open raw segment directory: %w", err)
}
defer directory.Close()
info, err := directory.Stat()
if err != nil || !info.IsDir() || info.Mode().Perm()&0o077 != 0 {
return errors.New("raw segment directory must be private")
}
for {
entries, readErr := directory.ReadDir(metadataReadBatch)
for _, entry := range entries {
path := filepath.Join(directoryPath, entry.Name())
if entry.Type()&os.ModeSymlink != 0 {
return errors.New("raw segment tree contains a symlink")
}
if entry.IsDir() {
if depth >= 3 {
return errors.New("raw segment tree has invalid depth")
}
if err = s.walkMetadataDirectory(path, depth+1, visit); err != nil {
return err
}
continue
}
if !strings.HasSuffix(entry.Name(), ".zst") {
continue
}
metadata, metadataErr := s.metadata(path)
if metadataErr != nil {
return metadataErr
}
if visitErr := visit(metadata); visitErr != nil {
return visitErr
}
}
if errors.Is(readErr, io.EOF) {
break
}
if readErr != nil {
return fmt.Errorf("read raw segment directory: %w", readErr)
}
}
if err = directory.Close(); err != nil {
return fmt.Errorf("close raw segment directory: %w", err)
}
return nil
}
func (s *Store) metadata(path string) (Metadata, error) {
base := filepath.Join(s.root, "raw")
cleanPath := filepath.Clean(path)
relative, err := filepath.Rel(base, cleanPath)
if err != nil || relative == "." || relative == ".." || strings.HasPrefix(relative, ".."+string(os.PathSeparator)) {
return Metadata{}, errors.New("segment path escapes raw store")
}
pathParts := strings.Split(filepath.ToSlash(relative), "/")
if len(pathParts) != 4 {
return Metadata{}, errors.New("raw segment path has invalid depth")
}
if err = model.ValidateSourceID(pathParts[0]); err != nil {
return Metadata{}, errors.New("raw segment organization is invalid")
}
if err = model.ValidateSourceID(pathParts[1]); err != nil {
return Metadata{}, errors.New("raw segment source is invalid")
}
if err = model.ValidateStreamID(pathParts[2]); err != nil {
return Metadata{}, errors.New("raw segment stream is invalid")
}
name := strings.TrimSuffix(pathParts[3], ".zst")
parts := strings.Split(name, "-")
if len(parts) != 2 || len(parts[0]) != 20 || len(parts[1]) != 64 || strings.ToLower(parts[1]) != parts[1] {
return Metadata{}, fmt.Errorf("invalid segment filename %q", pathParts[3])
}
sequence, err := strconv.ParseUint(parts[0], 10, 64)
if err != nil || sequence == 0 {
return Metadata{}, fmt.Errorf("invalid segment sequence %q", parts[0])
}
if _, err = hex.DecodeString(parts[1]); err != nil {
return Metadata{}, errors.New("segment filename digest is invalid")
}
if err = validatePrivateDirectoryChain(s.root, filepath.Dir(cleanPath)); err != nil {
return Metadata{}, err
}
info, err := os.Lstat(cleanPath)
if err != nil {
return Metadata{}, fmt.Errorf("inspect raw segment: %w", err)
}
if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 || info.Mode().Perm()&0o077 != 0 || info.Size() > MaxEncodedSegment {
return Metadata{}, errors.New("raw segment must be a private bounded regular file")
}
return Metadata{
OrganizationID: pathParts[0],
SourceID: pathParts[1],
StreamID: pathParts[2],
Sequence: sequence,
Path: cleanPath,
Digest: parts[1],
Compressed: info.Size(),
}, nil
}
// List retains the complete decoding API for explicit forensic callers and
// tests. Startup recovery uses WalkMetadata and decodes only missing work.
func (s *Store) List() ([]Entry, error) {
var entries []Entry
err := s.WalkMetadata(func(metadata Metadata) error {
entry, readErr := s.ReadEntry(metadata)
if readErr != nil {
return readErr
}
entries = append(entries, entry)
return nil
})
if err != nil {
return nil, err
}
sort.Slice(entries, func(i, j int) bool { return entries[i].Committed.Path < entries[j].Committed.Path })
return entries, nil
}
+302
View File
@@ -0,0 +1,302 @@
// SPDX-License-Identifier: AGPL-3.0-only
package segment
import (
"errors"
"os"
"path/filepath"
"sync"
"testing"
"time"
"gamertan.com/observatory/internal/model"
)
func TestCommitReadAndCorruption(t *testing.T) {
root := filepath.Join(t.TempDir(), "data")
store, err := New(root)
if err != nil {
t.Fatal(err)
}
now := time.Now().UTC()
batch := model.Batch{Version: 1, SourceID: "source", StreamID: "access", Sequence: 1, ObservedAt: now, Signal: model.SignalLogs, Records: []model.Observation{{Timestamp: now, Name: "http.request", Body: "safe"}}}
scope := model.Scope{OrganizationID: "org", ProjectID: "project", EnvironmentID: "prod", ServiceID: "site"}
committed, err := store.Commit(scope, batch)
if err != nil {
t.Fatal(err)
}
got, err := store.Read(committed.Path, committed.Digest)
if err != nil {
t.Fatal(err)
}
if got.Sequence != 1 || got.SourceID != "source" {
t.Fatalf("unexpected batch: %#v", got)
}
before, err := os.Stat(committed.Path)
if err != nil {
t.Fatal(err)
}
again, err := store.Commit(scope, batch)
if err != nil {
t.Fatal(err)
}
after, _ := os.Stat(again.Path)
if !before.ModTime().Equal(after.ModTime()) {
t.Fatal("idempotent commit changed segment mtime")
}
if err := os.WriteFile(committed.Path, []byte("corrupt"), 0o600); err != nil {
t.Fatal(err)
}
if _, err := store.Read(committed.Path, committed.Digest); err == nil {
t.Fatal("expected checksum failure")
}
}
func TestConcurrentCommitsCreateOnePrivateDirectoryChain(t *testing.T) {
root := filepath.Join(t.TempDir(), "data")
store, err := New(root)
if err != nil {
t.Fatal(err)
}
const writers = 64
now := time.Now().UTC()
scope := model.Scope{OrganizationID: "org", ProjectID: "project", EnvironmentID: "prod", ServiceID: "site"}
start := make(chan struct{})
errorsChannel := make(chan error, writers)
var wait sync.WaitGroup
for index := 0; index < writers; index++ {
wait.Add(1)
go func(sequence uint64) {
defer wait.Done()
<-start
batch := model.Batch{Version: 1, SourceID: "source", StreamID: "access", Sequence: sequence, ObservedAt: now, Signal: model.SignalLogs, Records: []model.Observation{{Timestamp: now, Name: "http.request"}}}
if _, commitErr := store.Commit(scope, batch); commitErr != nil {
errorsChannel <- commitErr
}
}(uint64(index + 1))
}
close(start)
wait.Wait()
close(errorsChannel)
for commitErr := range errorsChannel {
t.Errorf("concurrent commit: %v", commitErr)
}
entries, err := store.List()
if err != nil {
t.Fatal(err)
}
if len(entries) != writers {
t.Fatalf("committed entries=%d want=%d", len(entries), writers)
}
}
func TestListFindsCommittedSegments(t *testing.T) {
root := filepath.Join(t.TempDir(), "data")
store, err := New(root)
if err != nil {
t.Fatal(err)
}
now := time.Now().UTC()
batch := model.Batch{Version: 1, SourceID: "source", StreamID: "access", Sequence: 1, ObservedAt: now, Signal: model.SignalLogs, Records: []model.Observation{{Timestamp: now, Name: "http.request"}}}
_, err = store.Commit(model.Scope{OrganizationID: "org", ProjectID: "project", EnvironmentID: "prod", ServiceID: "site"}, batch)
if err != nil {
t.Fatal(err)
}
entries, err := store.List()
if err != nil {
t.Fatal(err)
}
if len(entries) != 1 || entries[0].Batch.Sequence != 1 || entries[0].Committed.Digest == "" {
t.Fatalf("unexpected entries: %#v", entries)
}
}
func TestWalkMetadataDoesNotDecodeSegmentContents(t *testing.T) {
root := filepath.Join(t.TempDir(), "data")
store, err := New(root)
if err != nil {
t.Fatal(err)
}
now := time.Now().UTC()
batch := model.Batch{Version: 1, SourceID: "source", StreamID: "access", Sequence: 1, ObservedAt: now, Signal: model.SignalLogs, Records: []model.Observation{{Timestamp: now, Name: "http.request"}}}
committed, err := store.Commit(model.Scope{OrganizationID: "org", ProjectID: "project", EnvironmentID: "prod", ServiceID: "site"}, batch)
if err != nil {
t.Fatal(err)
}
body, err := os.ReadFile(committed.Path)
if err != nil {
t.Fatal(err)
}
body[0] ^= 0xff
if err = os.WriteFile(committed.Path, body, 0o600); err != nil {
t.Fatal(err)
}
var metadata Metadata
if err = store.WalkMetadata(func(candidate Metadata) error {
metadata = candidate
return nil
}); err != nil {
t.Fatalf("metadata walk decoded content: %v", err)
}
if metadata.Path != committed.Path || metadata.Digest != committed.Digest || metadata.Compressed != committed.Compressed {
t.Fatalf("metadata=%+v committed=%+v", metadata, committed)
}
if _, err = store.ReadEntry(metadata); err == nil {
t.Fatal("corrupt segment decoded successfully")
}
}
func TestInterruptedTemporarySegmentIsIgnoredUntilAtomicCommit(t *testing.T) {
root := filepath.Join(t.TempDir(), "data")
store, err := New(root)
if err != nil {
t.Fatal(err)
}
dir := filepath.Join(root, "raw", "organization", "source", "stream")
if err = os.MkdirAll(dir, 0o700); err != nil {
t.Fatal(err)
}
partial := filepath.Join(dir, ".segment-interrupted")
if err = os.WriteFile(partial, []byte("partial compressed bytes"), 0o600); err != nil {
t.Fatal(err)
}
entries, err := store.List()
if err != nil || len(entries) != 0 {
t.Fatalf("entries=%+v err=%v", entries, err)
}
if body, readErr := os.ReadFile(partial); readErr != nil || string(body) != "partial compressed bytes" {
t.Fatalf("partial=%q err=%v", body, readErr)
}
}
func TestReadRejectsSymlinksAndOversizedEncodedSegments(t *testing.T) {
root := filepath.Join(t.TempDir(), "data")
store, err := New(root)
if err != nil {
t.Fatal(err)
}
if err = os.MkdirAll(filepath.Join(root, "raw"), 0o700); err != nil {
t.Fatal(err)
}
target := filepath.Join(root, "raw", "target")
if err = os.WriteFile(target, []byte("safe"), 0o600); err != nil {
t.Fatal(err)
}
link := filepath.Join(root, "raw", "link")
if err = os.Symlink(target, link); err != nil {
t.Fatal(err)
}
if _, err = store.Read(link, "unused"); err == nil {
t.Fatal("symlink segment accepted")
}
oversized := filepath.Join(root, "raw", "oversized")
file, err := os.Create(oversized)
if err != nil {
t.Fatal(err)
}
if err = file.Truncate(MaxEncodedSegment + 1); err != nil {
_ = file.Close()
t.Fatal(err)
}
if err = file.Close(); err != nil {
t.Fatal(err)
}
if _, err = store.Read(oversized, "unused"); err == nil {
t.Fatal("oversized encoded segment accepted")
}
}
func TestCommitRejectsSymlinkedDirectoryChain(t *testing.T) {
base := t.TempDir()
root := filepath.Join(base, "data")
if err := os.Mkdir(root, 0o700); err != nil {
t.Fatal(err)
}
store, err := New(root)
if err != nil {
t.Fatal(err)
}
if err = os.Mkdir(filepath.Join(root, "raw"), 0o700); err != nil {
t.Fatal(err)
}
target := filepath.Join(base, "outside")
if err = os.Mkdir(target, 0o700); err != nil {
t.Fatal(err)
}
if err = os.Symlink(target, filepath.Join(root, "raw", "org")); err != nil {
t.Skipf("symlink unavailable: %v", err)
}
now := time.Now().UTC()
batch := model.Batch{Version: 1, SourceID: "source", StreamID: "access", Sequence: 1, ObservedAt: now, Signal: model.SignalLogs, Records: []model.Observation{{Timestamp: now, Name: "http.request"}}}
if _, err = store.Commit(model.Scope{OrganizationID: "org", ProjectID: "project", EnvironmentID: "prod", ServiceID: "site"}, batch); err == nil {
t.Fatal("symlinked segment directory was accepted")
}
entries, err := os.ReadDir(target)
if err != nil || len(entries) != 0 {
t.Fatalf("symlink target was changed: entries=%v err=%v", entries, err)
}
}
func TestMoveToColdIsVerifiedAndCrashIdempotent(t *testing.T) {
root := filepath.Join(t.TempDir(), "data")
store, err := New(root)
if err != nil {
t.Fatal(err)
}
now := time.Now().UTC()
batch := model.Batch{Version: 1, SourceID: "source", StreamID: "access", Sequence: 1, ObservedAt: now, Signal: model.SignalLogs, Records: []model.Observation{{Timestamp: now, Name: "http.request"}}}
committed, err := store.Commit(model.Scope{OrganizationID: "org", ProjectID: "project", EnvironmentID: "prod", ServiceID: "site"}, batch)
if err != nil {
t.Fatal(err)
}
target := filepath.Join(root, "cold", "org", "logs", "source", "access", filepath.Base(committed.Path))
if err = store.MoveToCold(committed.Path, target, committed.Digest); err != nil {
t.Fatal(err)
}
if _, err = os.Lstat(committed.Path); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("hot segment still exists: %v", err)
}
if got, readErr := store.Read(target, committed.Digest); readErr != nil || got.Sequence != 1 {
t.Fatalf("cold batch=%+v err=%v", got, readErr)
}
if err = store.MoveToCold(committed.Path, target, committed.Digest); err != nil {
t.Fatalf("idempotent archive: %v", err)
}
if err = store.MoveToCold(target, committed.Path, committed.Digest); err == nil {
t.Fatal("reverse cold-to-hot move was accepted")
}
}
func TestMoveToColdRejectsSymlinkedDestination(t *testing.T) {
base := t.TempDir()
root := filepath.Join(base, "data")
store, err := New(root)
if err != nil {
t.Fatal(err)
}
now := time.Now().UTC()
batch := model.Batch{Version: 1, SourceID: "source", StreamID: "access", Sequence: 1, ObservedAt: now, Signal: model.SignalLogs, Records: []model.Observation{{Timestamp: now, Name: "http.request"}}}
committed, err := store.Commit(model.Scope{OrganizationID: "org", ProjectID: "project", EnvironmentID: "prod", ServiceID: "site"}, batch)
if err != nil {
t.Fatal(err)
}
outside := filepath.Join(base, "outside")
if err = os.Mkdir(outside, 0o700); err != nil {
t.Fatal(err)
}
if err = os.Symlink(outside, filepath.Join(root, "cold")); err != nil {
t.Skipf("symlink unavailable: %v", err)
}
target := filepath.Join(root, "cold", "org", "logs", "source", "access", filepath.Base(committed.Path))
if err = store.MoveToCold(committed.Path, target, committed.Digest); err == nil {
t.Fatal("symlinked cold destination was accepted")
}
if _, err = os.Stat(committed.Path); err != nil {
t.Fatalf("hot segment changed: %v", err)
}
entries, err := os.ReadDir(outside)
if err != nil || len(entries) != 0 {
t.Fatalf("outside entries=%v err=%v", entries, err)
}
}
+126
View File
@@ -0,0 +1,126 @@
<?sando go
package site
func App(view AppView)
?>
<?# SPDX-License-Identifier: AGPL-3.0-only ?>
<!doctype html>
<html lang="en">
<head><?~ Head(view.Head) ?></head>
<body>
<a class="skip-link" href="#main">Skip to content</a>
<?~ SiteHeader(true, "overview", view.Organization.ID) ?>
<main id="main">
<section class="app-intro" aria-labelledby="overview-title">
<div><p class="eyebrow">Organization overview</p><h1 id="overview-title"><?= view.Organization.Name ?></h1><p>Welcome, <?= view.DisplayName ?>. Last refreshed <?= view.RefreshedAt ?>.</p></div>
<form class="logout" method="post" action="/logout/">
<input type="hidden" name="csrf_token" value="<?= view.CSRFToken ?>">
<button class="secondary" type="submit">Sign out</button>
</form>
</section>
<? if len(view.Organizations) > 1 { ?>
<nav class="organization-nav" aria-label="Organizations">
<span>Organization:</span>
<? for _, organization := range view.Organizations { ?>
<? if organization.Selected { ?>
<a href="/app/?organization=<?= organization.ID ?>" aria-current="page"><?= organization.Name ?></a>
<? } else { ?>
<a href="/app/?organization=<?= organization.ID ?>"><?= organization.Name ?></a>
<? } ?>
<? } ?>
</nav>
<? } ?>
<? if view.PendingBatches > 0 { ?>
<aside class="live-status" role="status">
<p><strong>Durable evidence is still being indexed.</strong> Accepted batches safely stored: <?= view.PendingBatches ?>. Recent queries may lag by <?= view.ProjectionLag ?>.</p>
</aside>
<? } ?>
<aside class="live-status" data-events-url="<?= view.EventsURL ?>" hidden>
<p><strong>New observations are available.</strong> <a href="/app/?organization=<?= view.Organization.ID ?>">Refresh this overview</a>.</p>
</aside>
<section class="signal-grid" aria-label="Recent telemetry">
<? for _, signal := range view.Signals { ?>
<article class="signal-card" id="<?= signal.ID ?>">
<p class="eyebrow"><?= signal.Name ?></p>
<h2>Recent <?= signal.Name ?></h2>
<p><?= signal.Description ?></p>
<details><summary>Query</summary><pre><code><?= signal.Query ?></code></pre></details>
<?~ ResultTable(signal.Table) ?>
</article>
<? } ?>
</section>
<section class="section-grid" aria-labelledby="saved-work">
<div><p class="eyebrow">Saved work</p><h2 id="saved-work">Queries and dashboards</h2></div>
<div class="saved-grid">
<section aria-labelledby="saved-queries"><h3 id="saved-queries">Saved queries</h3>
<? if len(view.SavedQueries) == 0 { ?><p>No saved queries yet.</p><? } ?>
<? for _, item := range view.SavedQueries { ?><article><h4><?= item.Name ?></h4><p><?= item.Description ?></p><pre><code><?= item.Query ?></code></pre></article><? } ?>
</section>
<section aria-labelledby="dashboards"><h3 id="dashboards">Dashboards</h3>
<? if len(view.Dashboards) == 0 { ?><p>No dashboards yet.</p><? } ?>
<? for _, item := range view.Dashboards { ?><article><h4><a href="/app/dashboards/<?= item.Slug ?>/?organization=<?= view.Organization.ID ?>"><?= item.Name ?></a></h4><p><?= item.Description ?></p><p><?= item.PanelCount ?> panels</p></article><? } ?>
</section>
</div>
</section>
<section class="section-grid" aria-labelledby="incident-work">
<div><p class="eyebrow">Incident response</p><h2 id="incident-work">Attention without noise</h2></div>
<div><p><?= view.OpenIncidents ?> open incidents are visible in the authorized inbox. Alert rules execute the same bounded saved-query AST used everywhere else.</p><p><a class="button secondary" href="<?= view.IncidentsURL ?>">Open incident inbox</a></p></div>
</section>
<? if view.CanManage { ?>
<section class="section-grid" aria-labelledby="manage-work">
<div><p class="eyebrow">Organization workshop</p><h2 id="manage-work">Save a reusable view</h2><p>Assisted controls and typed text enter the same parser and versioned AST. Every form is an ordinary server request; nothing is generated or executed on page load.</p></div>
<div class="editor-grid">
<form class="editor-card builder-card" method="post" action="/app/queries/builder/">
<h3>Build a query</h3>
<p>Choose one bounded evidence source, an optional filter, and an optional summary. The saved result remains ordinary reviewable query text.</p>
<input type="hidden" name="organization_id" value="<?= view.Organization.ID ?>">
<input type="hidden" name="csrf_token" value="<?= view.ManageCSRF ?>">
<div class="field-grid">
<div><label for="builder-name">Name</label><input id="builder-name" name="name" required maxlength="128"></div>
<div><label for="builder-description">Description</label><textarea id="builder-description" name="description" required maxlength="1024"></textarea></div>
<div><label for="builder-signal">Evidence</label><select id="builder-signal" name="signal" required><option value="logs">Logs</option><option value="metrics">Metrics</option><option value="traces">Traces</option><option value="deployments">Deployments</option></select></div>
<div><label for="builder-window">Lookback</label><select id="builder-window" name="window" required><option value="15m">15 minutes</option><option value="1h" selected>1 hour</option><option value="6h">6 hours</option><option value="24h">24 hours</option><option value="168h">7 days</option></select></div>
<div><label for="builder-filter-field">Filter field <span class="optional">(optional)</span></label><select id="builder-filter-field" name="filter_field"><option value="">No filter</option><option value="service">Service</option><option value="project">Project</option><option value="environment">Environment</option><option value="route">HTTP route</option><option value="status">HTTP status</option><option value="duration">Duration</option><option value="name">Name</option><option value="severity">Severity</option><option value="value">Metric value</option><option value="trace_id">Trace ID</option><option value="correlation_id">Correlation ID</option></select></div>
<div><label for="builder-filter-operator">Filter comparison</label><select id="builder-filter-operator" name="filter_operator" required><option value="==">equals</option><option value="!=">does not equal</option><option value=">=">at least</option><option value="&lt;=">at most</option><option value=">">greater than</option><option value="&lt;">less than</option></select></div>
<div><label for="builder-filter-value">Filter value <span class="optional">(optional)</span></label><input id="builder-filter-value" name="filter_value" maxlength="256"></div>
<div><label for="builder-aggregate">Summary</label><select id="builder-aggregate" name="aggregate" required><option value="none">No summary</option><option value="count">Count</option><option value="min">Minimum</option><option value="max">Maximum</option><option value="sum">Sum</option><option value="avg">Average</option><option value="p50">50th percentile</option><option value="p95">95th percentile</option><option value="p99">99th percentile</option></select></div>
<div><label for="builder-aggregate-field">Numeric summary field <span class="optional">(not used by count)</span></label><select id="builder-aggregate-field" name="aggregate_field"><option value="">No field</option><option value="value">Metric value</option><option value="duration">Duration</option><option value="status">HTTP status</option></select></div>
<div><label for="builder-group">Group by <span class="optional">(optional)</span></label><select id="builder-group" name="group_by"><option value="">No grouping</option><option value="service">Service</option><option value="project">Project</option><option value="environment">Environment</option><option value="route">HTTP route</option><option value="status">HTTP status</option><option value="name">Name</option><option value="severity">Severity</option></select></div>
<div><label for="builder-bucket">Time bucket <span class="optional">(optional)</span></label><select id="builder-bucket" name="bucket"><option value="">No time bucket</option><option value="1m">1 minute</option><option value="5m">5 minutes</option><option value="15m">15 minutes</option><option value="1h">1 hour</option></select></div>
<div><label for="builder-limit">Maximum rows</label><select id="builder-limit" name="limit" required><option value="10">10</option><option value="20">20</option><option value="50" selected>50</option><option value="100">100</option><option value="250">250</option></select></div>
</div>
<button type="submit">Build and save query</button>
</form>
<form class="editor-card" method="post" action="/app/queries/">
<h3>Write a query</h3>
<input type="hidden" name="organization_id" value="<?= view.Organization.ID ?>">
<input type="hidden" name="csrf_token" value="<?= view.ManageCSRF ?>">
<label for="query-name">Name</label><input id="query-name" name="name" required maxlength="128">
<label for="query-description">Description</label><textarea id="query-description" name="description" required maxlength="1024"></textarea>
<label for="query-text">Typed query</label><textarea id="query-text" name="query" required maxlength="16384">logs | window 1h | limit 50</textarea>
<button type="submit">Save query</button>
</form>
<form class="editor-card" method="post" action="/app/dashboards/">
<h3>Create a one-panel dashboard</h3>
<input type="hidden" name="organization_id" value="<?= view.Organization.ID ?>">
<input type="hidden" name="csrf_token" value="<?= view.ManageCSRF ?>">
<label for="dashboard-slug">URL slug</label><input id="dashboard-slug" name="slug" required maxlength="63" pattern="[a-z][a-z0-9-]+">
<label for="dashboard-name">Name</label><input id="dashboard-name" name="name" required maxlength="128">
<label for="dashboard-description">Description</label><textarea id="dashboard-description" name="description" required maxlength="1024"></textarea>
<label for="panel-title">Panel title</label><input id="panel-title" name="panel_title" required maxlength="128">
<label for="saved-query">Saved query</label>
<select id="saved-query" name="saved_query_id" required>
<option value="">Choose a saved query</option>
<? for _, item := range view.SavedQueries { ?><option value="<?= item.ID ?>"><?= item.Name ?></option><? } ?>
</select>
<label for="visualization">Presentation</label>
<select id="visualization" name="visualization" required><option value="table">Table</option><option value="stat">Single statistic with table</option><option value="timeseries">Time series table</option></select>
<? if len(view.SavedQueries) == 0 { ?><button type="submit" disabled>Create dashboard</button><? } else { ?><button type="submit">Create dashboard</button><? } ?>
</form>
</div>
</section>
<? } ?>
</main>
<?~ SiteFooter() ?>
</body>
</html>
+497
View File
@@ -0,0 +1,497 @@
// Code generated by himesan; DO NOT EDIT.
// himesan:compiler v1.0.0-beta.2
// himesan:runtime-abi sando.v1
// himesan:source-sha256 ab6403781ccb2a659b749730243555dcb8a3f319720ad6a972252eb30847c5f4
package site
import (
__himesan_context "context"
__himesan_sando "gamertan.com/sandwich-hime/sando"
__himesan_io "io"
)
var _ = __himesan_sando.ABISandoV1
func App(view AppView) __himesan_sando.Component {
return __himesan_sando.ComponentFunc(func(__himesan_render_context __himesan_context.Context, __himesan_writer __himesan_io.Writer) error {
_ = __himesan_render_context
//line internal/site/app.sando:5:3
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\n"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/app.sando:6:46
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\n<!doctype html>\n<html lang=\"en\">\n<head>"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/app.sando:9:11
if __himesan_error := __himesan_sando.Render(__himesan_render_context, __himesan_writer, (Head(view.Head))); __himesan_error != nil {
return __himesan_error
}
//line internal/site/app.sando:9:29
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "</head>\n<body>\n <a class=\"skip-link\" href=\"#main\">Skip to content</a>\n "); __himesan_error != nil {
return __himesan_error
}
//line internal/site/app.sando:12:7
if __himesan_error := __himesan_sando.Render(__himesan_render_context, __himesan_writer, (SiteHeader(true, "overview", view.Organization.ID))); __himesan_error != nil {
return __himesan_error
}
//line internal/site/app.sando:12:60
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\n <main id=\"main\">\n <section class=\"app-intro\" aria-labelledby=\"overview-title\">\n <div><p class=\"eyebrow\">Organization overview</p><h1 id=\"overview-title\">"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/app.sando:15:84
if __himesan_error := __himesan_sando.WriteText(__himesan_writer, (view.Organization.Name)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/app.sando:15:109
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "</h1><p>Welcome, "); __himesan_error != nil {
return __himesan_error
}
//line internal/site/app.sando:15:130
if __himesan_error := __himesan_sando.WriteText(__himesan_writer, (view.DisplayName)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/app.sando:15:149
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, ". Last refreshed "); __himesan_error != nil {
return __himesan_error
}
//line internal/site/app.sando:15:170
if __himesan_error := __himesan_sando.WriteText(__himesan_writer, (view.RefreshedAt)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/app.sando:15:189
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, ".</p></div>\n <form class=\"logout\" method=\"post\" action=\"/logout/\">\n <input type=\"hidden\" name=\"csrf_token\" value=\""); __himesan_error != nil {
return __himesan_error
}
//line internal/site/app.sando:17:59
if __himesan_error := __himesan_sando.WriteAttr(__himesan_writer, (view.CSRFToken)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/app.sando:17:76
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\">\n <button class=\"secondary\" type=\"submit\">Sign out</button>\n </form>\n </section>\n "); __himesan_error != nil {
return __himesan_error
}
//line internal/site/app.sando:21:8
if len(view.Organizations) > 1 {
//line internal/site/app.sando:21:43
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\n <nav class=\"organization-nav\" aria-label=\"Organizations\">\n <span>Organization:</span>\n "); __himesan_error != nil {
return __himesan_error
}
//line internal/site/app.sando:24:10
for _, organization := range view.Organizations {
//line internal/site/app.sando:24:62
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\n "); __himesan_error != nil {
return __himesan_error
}
//line internal/site/app.sando:25:12
if organization.Selected {
//line internal/site/app.sando:25:41
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\n <a href=\"/app/?organization="); __himesan_error != nil {
return __himesan_error
}
//line internal/site/app.sando:26:43
if __himesan_error := __himesan_sando.WriteURL(__himesan_writer, (organization.ID)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/app.sando:26:61
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\" aria-current=\"page\">"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/app.sando:26:87
if __himesan_error := __himesan_sando.WriteText(__himesan_writer, (organization.Name)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/app.sando:26:107
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "</a>\n "); __himesan_error != nil {
return __himesan_error
}
//line internal/site/app.sando:27:12
} else {
//line internal/site/app.sando:27:23
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\n <a href=\"/app/?organization="); __himesan_error != nil {
return __himesan_error
}
//line internal/site/app.sando:28:43
if __himesan_error := __himesan_sando.WriteURL(__himesan_writer, (organization.ID)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/app.sando:28:61
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\">"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/app.sando:28:67
if __himesan_error := __himesan_sando.WriteText(__himesan_writer, (organization.Name)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/app.sando:28:87
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "</a>\n "); __himesan_error != nil {
return __himesan_error
}
//line internal/site/app.sando:29:12
}
//line internal/site/app.sando:29:16
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\n "); __himesan_error != nil {
return __himesan_error
}
//line internal/site/app.sando:30:10
}
//line internal/site/app.sando:30:14
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\n </nav>\n "); __himesan_error != nil {
return __himesan_error
}
//line internal/site/app.sando:32:8
}
//line internal/site/app.sando:32:12
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\n "); __himesan_error != nil {
return __himesan_error
}
//line internal/site/app.sando:33:8
if view.PendingBatches > 0 {
//line internal/site/app.sando:33:39
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\n <aside class=\"live-status\" role=\"status\">\n <p><strong>Durable evidence is still being indexed.</strong> Accepted batches safely stored: "); __himesan_error != nil {
return __himesan_error
}
//line internal/site/app.sando:35:104
if __himesan_error := __himesan_sando.WriteText(__himesan_writer, (view.PendingBatches)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/app.sando:35:126
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, ". Recent queries may lag by "); __himesan_error != nil {
return __himesan_error
}
//line internal/site/app.sando:35:158
if __himesan_error := __himesan_sando.WriteText(__himesan_writer, (view.ProjectionLag)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/app.sando:35:179
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, ".</p>\n </aside>\n "); __himesan_error != nil {
return __himesan_error
}
//line internal/site/app.sando:37:8
}
//line internal/site/app.sando:37:12
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\n <aside class=\"live-status\" data-events-url=\""); __himesan_error != nil {
return __himesan_error
}
//line internal/site/app.sando:38:53
if __himesan_error := __himesan_sando.WriteAttr(__himesan_writer, (view.EventsURL)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/app.sando:38:70
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\" hidden>\n <p><strong>New observations are available.</strong> <a href=\"/app/?organization="); __himesan_error != nil {
return __himesan_error
}
//line internal/site/app.sando:39:91
if __himesan_error := __himesan_sando.WriteURL(__himesan_writer, (view.Organization.ID)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/app.sando:39:114
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\">Refresh this overview</a>.</p>\n </aside>\n <section class=\"signal-grid\" aria-label=\"Recent telemetry\">\n "); __himesan_error != nil {
return __himesan_error
}
//line internal/site/app.sando:42:8
for _, signal := range view.Signals {
//line internal/site/app.sando:42:48
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\n <article class=\"signal-card\" id=\""); __himesan_error != nil {
return __himesan_error
}
//line internal/site/app.sando:43:44
if __himesan_error := __himesan_sando.WriteAttr(__himesan_writer, (signal.ID)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/app.sando:43:56
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\">\n <p class=\"eyebrow\">"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/app.sando:44:32
if __himesan_error := __himesan_sando.WriteText(__himesan_writer, (signal.Name)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/app.sando:44:46
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "</p>\n <h2>Recent "); __himesan_error != nil {
return __himesan_error
}
//line internal/site/app.sando:45:24
if __himesan_error := __himesan_sando.WriteText(__himesan_writer, (signal.Name)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/app.sando:45:38
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "</h2>\n <p>"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/app.sando:46:16
if __himesan_error := __himesan_sando.WriteText(__himesan_writer, (signal.Description)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/app.sando:46:37
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "</p>\n <details><summary>Query</summary><pre><code>"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/app.sando:47:57
if __himesan_error := __himesan_sando.WriteText(__himesan_writer, (signal.Query)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/app.sando:47:72
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "</code></pre></details>\n "); __himesan_error != nil {
return __himesan_error
}
//line internal/site/app.sando:48:13
if __himesan_error := __himesan_sando.Render(__himesan_render_context, __himesan_writer, (ResultTable(signal.Table))); __himesan_error != nil {
return __himesan_error
}
//line internal/site/app.sando:48:41
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\n </article>\n "); __himesan_error != nil {
return __himesan_error
}
//line internal/site/app.sando:50:8
}
//line internal/site/app.sando:50:12
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\n </section>\n <section class=\"section-grid\" aria-labelledby=\"saved-work\">\n <div><p class=\"eyebrow\">Saved work</p><h2 id=\"saved-work\">Queries and dashboards</h2></div>\n <div class=\"saved-grid\">\n <section aria-labelledby=\"saved-queries\"><h3 id=\"saved-queries\">Saved queries</h3>\n "); __himesan_error != nil {
return __himesan_error
}
//line internal/site/app.sando:56:12
if len(view.SavedQueries) == 0 {
//line internal/site/app.sando:56:47
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "<p>No saved queries yet.</p>"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/app.sando:56:78
}
//line internal/site/app.sando:56:82
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\n "); __himesan_error != nil {
return __himesan_error
}
//line internal/site/app.sando:57:12
for _, item := range view.SavedQueries {
//line internal/site/app.sando:57:55
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "<article><h4>"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/app.sando:57:72
if __himesan_error := __himesan_sando.WriteText(__himesan_writer, (item.Name)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/app.sando:57:84
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "</h4><p>"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/app.sando:57:96
if __himesan_error := __himesan_sando.WriteText(__himesan_writer, (item.Description)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/app.sando:57:115
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "</p><pre><code>"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/app.sando:57:134
if __himesan_error := __himesan_sando.WriteText(__himesan_writer, (item.Query)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/app.sando:57:147
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "</code></pre></article>"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/app.sando:57:173
}
//line internal/site/app.sando:57:177
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\n </section>\n <section aria-labelledby=\"dashboards\"><h3 id=\"dashboards\">Dashboards</h3>\n "); __himesan_error != nil {
return __himesan_error
}
//line internal/site/app.sando:60:12
if len(view.Dashboards) == 0 {
//line internal/site/app.sando:60:45
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "<p>No dashboards yet.</p>"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/app.sando:60:73
}
//line internal/site/app.sando:60:77
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\n "); __himesan_error != nil {
return __himesan_error
}
//line internal/site/app.sando:61:12
for _, item := range view.Dashboards {
//line internal/site/app.sando:61:53
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "<article><h4><a href=\"/app/dashboards/"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/app.sando:61:95
if __himesan_error := __himesan_sando.WriteURL(__himesan_writer, (item.Slug)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/app.sando:61:107
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "/?organization="); __himesan_error != nil {
return __himesan_error
}
//line internal/site/app.sando:61:126
if __himesan_error := __himesan_sando.WriteURL(__himesan_writer, (view.Organization.ID)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/app.sando:61:149
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\">"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/app.sando:61:155
if __himesan_error := __himesan_sando.WriteText(__himesan_writer, (item.Name)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/app.sando:61:167
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "</a></h4><p>"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/app.sando:61:183
if __himesan_error := __himesan_sando.WriteText(__himesan_writer, (item.Description)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/app.sando:61:202
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "</p><p>"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/app.sando:61:213
if __himesan_error := __himesan_sando.WriteText(__himesan_writer, (item.PanelCount)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/app.sando:61:231
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, " panels</p></article>"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/app.sando:61:255
}
//line internal/site/app.sando:61:259
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\n </section>\n </div>\n </section>\n <section class=\"section-grid\" aria-labelledby=\"incident-work\">\n <div><p class=\"eyebrow\">Incident response</p><h2 id=\"incident-work\">Attention without noise</h2></div>\n <div><p>"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/app.sando:67:19
if __himesan_error := __himesan_sando.WriteText(__himesan_writer, (view.OpenIncidents)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/app.sando:67:40
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, " open incidents are visible in the authorized inbox. Alert rules execute the same bounded saved-query AST used everywhere else.</p><p><a class=\"button secondary\" href=\""); __himesan_error != nil {
return __himesan_error
}
//line internal/site/app.sando:67:212
if __himesan_error := __himesan_sando.WriteURL(__himesan_writer, (view.IncidentsURL)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/app.sando:67:232
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\">Open incident inbox</a></p></div>\n </section>\n "); __himesan_error != nil {
return __himesan_error
}
//line internal/site/app.sando:69:8
if view.CanManage {
//line internal/site/app.sando:69:30
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\n <section class=\"section-grid\" aria-labelledby=\"manage-work\">\n <div><p class=\"eyebrow\">Organization workshop</p><h2 id=\"manage-work\">Save a reusable view</h2><p>Assisted controls and typed text enter the same parser and versioned AST. Every form is an ordinary server request; nothing is generated or executed on page load.</p></div>\n <div class=\"editor-grid\">\n <form class=\"editor-card builder-card\" method=\"post\" action=\"/app/queries/builder/\">\n <h3>Build a query</h3>\n <p>Choose one bounded evidence source, an optional filter, and an optional summary. The saved result remains ordinary reviewable query text.</p>\n <input type=\"hidden\" name=\"organization_id\" value=\""); __himesan_error != nil {
return __himesan_error
}
//line internal/site/app.sando:76:66
if __himesan_error := __himesan_sando.WriteAttr(__himesan_writer, (view.Organization.ID)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/app.sando:76:89
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\">\n <input type=\"hidden\" name=\"csrf_token\" value=\""); __himesan_error != nil {
return __himesan_error
}
//line internal/site/app.sando:77:61
if __himesan_error := __himesan_sando.WriteAttr(__himesan_writer, (view.ManageCSRF)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/app.sando:77:79
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\">\n <div class=\"field-grid\">\n <div><label for=\"builder-name\">Name</label><input id=\"builder-name\" name=\"name\" required maxlength=\"128\"></div>\n <div><label for=\"builder-description\">Description</label><textarea id=\"builder-description\" name=\"description\" required maxlength=\"1024\"></textarea></div>\n <div><label for=\"builder-signal\">Evidence</label><select id=\"builder-signal\" name=\"signal\" required><option value=\"logs\">Logs</option><option value=\"metrics\">Metrics</option><option value=\"traces\">Traces</option><option value=\"deployments\">Deployments</option></select></div>\n <div><label for=\"builder-window\">Lookback</label><select id=\"builder-window\" name=\"window\" required><option value=\"15m\">15 minutes</option><option value=\"1h\" selected>1 hour</option><option value=\"6h\">6 hours</option><option value=\"24h\">24 hours</option><option value=\"168h\">7 days</option></select></div>\n <div><label for=\"builder-filter-field\">Filter field <span class=\"optional\">(optional)</span></label><select id=\"builder-filter-field\" name=\"filter_field\"><option value=\"\">No filter</option><option value=\"service\">Service</option><option value=\"project\">Project</option><option value=\"environment\">Environment</option><option value=\"route\">HTTP route</option><option value=\"status\">HTTP status</option><option value=\"duration\">Duration</option><option value=\"name\">Name</option><option value=\"severity\">Severity</option><option value=\"value\">Metric value</option><option value=\"trace_id\">Trace ID</option><option value=\"correlation_id\">Correlation ID</option></select></div>\n <div><label for=\"builder-filter-operator\">Filter comparison</label><select id=\"builder-filter-operator\" name=\"filter_operator\" required><option value=\"==\">equals</option><option value=\"!=\">does not equal</option><option value=\">=\">at least</option><option value=\"&lt;=\">at most</option><option value=\">\">greater than</option><option value=\"&lt;\">less than</option></select></div>\n <div><label for=\"builder-filter-value\">Filter value <span class=\"optional\">(optional)</span></label><input id=\"builder-filter-value\" name=\"filter_value\" maxlength=\"256\"></div>\n <div><label for=\"builder-aggregate\">Summary</label><select id=\"builder-aggregate\" name=\"aggregate\" required><option value=\"none\">No summary</option><option value=\"count\">Count</option><option value=\"min\">Minimum</option><option value=\"max\">Maximum</option><option value=\"sum\">Sum</option><option value=\"avg\">Average</option><option value=\"p50\">50th percentile</option><option value=\"p95\">95th percentile</option><option value=\"p99\">99th percentile</option></select></div>\n <div><label for=\"builder-aggregate-field\">Numeric summary field <span class=\"optional\">(not used by count)</span></label><select id=\"builder-aggregate-field\" name=\"aggregate_field\"><option value=\"\">No field</option><option value=\"value\">Metric value</option><option value=\"duration\">Duration</option><option value=\"status\">HTTP status</option></select></div>\n <div><label for=\"builder-group\">Group by <span class=\"optional\">(optional)</span></label><select id=\"builder-group\" name=\"group_by\"><option value=\"\">No grouping</option><option value=\"service\">Service</option><option value=\"project\">Project</option><option value=\"environment\">Environment</option><option value=\"route\">HTTP route</option><option value=\"status\">HTTP status</option><option value=\"name\">Name</option><option value=\"severity\">Severity</option></select></div>\n <div><label for=\"builder-bucket\">Time bucket <span class=\"optional\">(optional)</span></label><select id=\"builder-bucket\" name=\"bucket\"><option value=\"\">No time bucket</option><option value=\"1m\">1 minute</option><option value=\"5m\">5 minutes</option><option value=\"15m\">15 minutes</option><option value=\"1h\">1 hour</option></select></div>\n <div><label for=\"builder-limit\">Maximum rows</label><select id=\"builder-limit\" name=\"limit\" required><option value=\"10\">10</option><option value=\"20\">20</option><option value=\"50\" selected>50</option><option value=\"100\">100</option><option value=\"250\">250</option></select></div>\n </div>\n <button type=\"submit\">Build and save query</button>\n </form>\n <form class=\"editor-card\" method=\"post\" action=\"/app/queries/\">\n <h3>Write a query</h3>\n <input type=\"hidden\" name=\"organization_id\" value=\""); __himesan_error != nil {
return __himesan_error
}
//line internal/site/app.sando:96:66
if __himesan_error := __himesan_sando.WriteAttr(__himesan_writer, (view.Organization.ID)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/app.sando:96:89
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\">\n <input type=\"hidden\" name=\"csrf_token\" value=\""); __himesan_error != nil {
return __himesan_error
}
//line internal/site/app.sando:97:61
if __himesan_error := __himesan_sando.WriteAttr(__himesan_writer, (view.ManageCSRF)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/app.sando:97:79
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\">\n <label for=\"query-name\">Name</label><input id=\"query-name\" name=\"name\" required maxlength=\"128\">\n <label for=\"query-description\">Description</label><textarea id=\"query-description\" name=\"description\" required maxlength=\"1024\"></textarea>\n <label for=\"query-text\">Typed query</label><textarea id=\"query-text\" name=\"query\" required maxlength=\"16384\">logs | window 1h | limit 50</textarea>\n <button type=\"submit\">Save query</button>\n </form>\n <form class=\"editor-card\" method=\"post\" action=\"/app/dashboards/\">\n <h3>Create a one-panel dashboard</h3>\n <input type=\"hidden\" name=\"organization_id\" value=\""); __himesan_error != nil {
return __himesan_error
}
//line internal/site/app.sando:105:66
if __himesan_error := __himesan_sando.WriteAttr(__himesan_writer, (view.Organization.ID)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/app.sando:105:89
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\">\n <input type=\"hidden\" name=\"csrf_token\" value=\""); __himesan_error != nil {
return __himesan_error
}
//line internal/site/app.sando:106:61
if __himesan_error := __himesan_sando.WriteAttr(__himesan_writer, (view.ManageCSRF)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/app.sando:106:79
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\">\n <label for=\"dashboard-slug\">URL slug</label><input id=\"dashboard-slug\" name=\"slug\" required maxlength=\"63\" pattern=\"[a-z][a-z0-9-]+\">\n <label for=\"dashboard-name\">Name</label><input id=\"dashboard-name\" name=\"name\" required maxlength=\"128\">\n <label for=\"dashboard-description\">Description</label><textarea id=\"dashboard-description\" name=\"description\" required maxlength=\"1024\"></textarea>\n <label for=\"panel-title\">Panel title</label><input id=\"panel-title\" name=\"panel_title\" required maxlength=\"128\">\n <label for=\"saved-query\">Saved query</label>\n <select id=\"saved-query\" name=\"saved_query_id\" required>\n <option value=\"\">Choose a saved query</option>\n "); __himesan_error != nil {
return __himesan_error
}
//line internal/site/app.sando:114:16
for _, item := range view.SavedQueries {
//line internal/site/app.sando:114:59
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "<option value=\""); __himesan_error != nil {
return __himesan_error
}
//line internal/site/app.sando:114:78
if __himesan_error := __himesan_sando.WriteAttr(__himesan_writer, (item.ID)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/app.sando:114:88
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\">"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/app.sando:114:94
if __himesan_error := __himesan_sando.WriteText(__himesan_writer, (item.Name)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/app.sando:114:106
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "</option>"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/app.sando:114:118
}
//line internal/site/app.sando:114:122
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\n </select>\n <label for=\"visualization\">Presentation</label>\n <select id=\"visualization\" name=\"visualization\" required><option value=\"table\">Table</option><option value=\"stat\">Single statistic with table</option><option value=\"timeseries\">Time series table</option></select>\n "); __himesan_error != nil {
return __himesan_error
}
//line internal/site/app.sando:118:14
if len(view.SavedQueries) == 0 {
//line internal/site/app.sando:118:49
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "<button type=\"submit\" disabled>Create dashboard</button>"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/app.sando:118:108
} else {
//line internal/site/app.sando:118:119
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "<button type=\"submit\">Create dashboard</button>"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/app.sando:118:169
}
//line internal/site/app.sando:118:173
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\n </form>\n </div>\n </section>\n "); __himesan_error != nil {
return __himesan_error
}
//line internal/site/app.sando:122:8
}
//line internal/site/app.sando:122:12
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\n </main>\n "); __himesan_error != nil {
return __himesan_error
}
//line internal/site/app.sando:124:7
if __himesan_error := __himesan_sando.Render(__himesan_render_context, __himesan_writer, (SiteFooter())); __himesan_error != nil {
return __himesan_error
}
//line internal/site/app.sando:124:22
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\n</body>\n</html>\n"); __himesan_error != nil {
return __himesan_error
}
return nil
})
}
+49
View File
@@ -0,0 +1,49 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Package site contains Observatory's typed Sandwich Hime interface and its
// immutable browser assets. HTTP policy remains owned by internal/httpserver.
package site
import (
"crypto/sha256"
_ "embed"
"encoding/hex"
)
//go:embed assets/site.css
var style []byte
//go:embed assets/site.js
var script []byte
//go:embed assets/observatory.svg
var icon []byte
type Assets struct {
StylePath string
ScriptPath string
IconPath string
}
func AssetPaths() Assets {
return Assets{StylePath: fingerprintedPath("site", "css", style), ScriptPath: fingerprintedPath("site", "js", script), IconPath: fingerprintedPath("observatory", "svg", icon)}
}
func Asset(path string) (body []byte, contentType string, ok bool) {
assets := AssetPaths()
switch path {
case assets.StylePath:
return style, "text/css; charset=utf-8", true
case assets.ScriptPath:
return script, "text/javascript; charset=utf-8", true
case assets.IconPath:
return icon, "image/svg+xml", true
default:
return nil, "", false
}
}
func fingerprintedPath(name, extension string, body []byte) string {
digest := sha256.Sum256(body)
return "/assets/" + name + "-" + hex.EncodeToString(digest[:8]) + "." + extension
}
+12
View File
@@ -0,0 +1,12 @@
<!-- SPDX-License-Identifier: AGPL-3.0-only -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-labelledby="title description">
<title id="title">Gamertan Observatory</title>
<desc id="description">Three calm observation rings around one bright point.</desc>
<rect width="512" height="512" rx="96" fill="#111715"/>
<g fill="none" stroke="#b5e3c5" stroke-width="24">
<circle cx="256" cy="256" r="168"/>
<ellipse cx="256" cy="256" rx="168" ry="72"/>
<ellipse cx="256" cy="256" rx="72" ry="168"/>
</g>
<circle cx="256" cy="256" r="28" fill="#f7c873"/>
</svg>

After

Width:  |  Height:  |  Size: 610 B

+37
View File
@@ -0,0 +1,37 @@
/* SPDX-License-Identifier: AGPL-3.0-only */
:root{color-scheme:dark;--ink:#f6f0e2;--muted:#bfb8aa;--paper:#111715;--panel:#18221f;--line:#4f665e;--accent:#b5e3c5;--focus:#f7c873;font:100%/1.6 system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
*{box-sizing:border-box}
html{background:var(--paper);color:var(--ink)}
body{margin:0;min-width:18rem}
a{color:var(--accent);text-underline-offset:.2em}
a:hover{text-decoration-thickness:.16em}
button,input,select,textarea{font:inherit}
button,.button{display:inline-block;border:.12rem solid var(--ink);border-radius:.2rem;background:var(--ink);color:var(--paper);padding:.65rem 1rem;font-weight:700;text-decoration:none;cursor:pointer}
button.secondary,.secondary{background:transparent;color:var(--ink)}
:focus-visible{outline:.2rem solid var(--focus);outline-offset:.2rem}
.skip-link{position:absolute;left:1rem;top:-8rem;background:var(--ink);color:var(--paper);padding:.6rem;z-index:10}.skip-link:focus{top:1rem}
.site-header,.site-footer,main{width:min(92rem,100%);margin-inline:auto;padding-inline:clamp(1rem,4vw,3rem)}
.site-header{display:flex;align-items:center;justify-content:space-between;gap:1rem;padding-block:1.25rem;border-bottom:.08rem solid var(--line)}
.site-header nav{display:flex;gap:1rem}.wordmark{color:var(--ink);font-weight:800;text-decoration:none;letter-spacing:.02em}
.site-footer{margin-top:4rem;padding-block:2rem;border-top:.08rem solid var(--line);color:var(--muted)}
.hero{padding-block:clamp(4rem,12vw,9rem);max-width:72rem}.hero h1{font-size:clamp(3rem,10vw,8rem);line-height:.9;letter-spacing:-.055em;max-width:11ch;margin:.15em 0}.lede{font-size:clamp(1.15rem,2vw,1.5rem);max-width:48rem;color:var(--muted)}
.eyebrow{text-transform:uppercase;letter-spacing:.14em;font-size:.78rem;font-weight:800;color:var(--accent)}
.section-grid{display:grid;grid-template-columns:minmax(14rem,1fr) minmax(0,2fr);gap:clamp(2rem,6vw,7rem);padding-block:clamp(3rem,7vw,6rem);border-top:.08rem solid var(--line)}
h1,h2,h3,h4{line-height:1.1;text-wrap:balance}h2{font-size:clamp(2rem,5vw,4.5rem);margin:.15em 0}h3{font-size:1.4rem}.prose{max-width:52rem}.cards{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:1rem}.cards article,.auth-card,.signal-card,.saved-grid>section,.live-status{background:var(--panel);border:.08rem solid var(--line);padding:clamp(1rem,3vw,2rem)}
.auth-shell{min-height:70vh;display:grid;place-items:center}.auth-card{width:min(34rem,100%);margin-block:4rem}.auth-card h1{font-size:clamp(2.3rem,8vw,4rem)}
form{display:grid;gap:.55rem}label{font-weight:700;margin-top:.6rem}input,select,textarea{width:100%;border:.1rem solid var(--line);background:var(--paper);color:var(--ink);padding:.7rem;border-radius:.2rem}textarea{min-height:7rem;resize:vertical}.form-error{border-left:.3rem solid #ff9b8f;padding:.7rem;background:#301b1b}
.app-intro{display:flex;align-items:end;justify-content:space-between;gap:2rem;padding-block:3rem}.app-intro h1{font-size:clamp(2.6rem,7vw,6rem);margin:.1em 0}.logout{display:block}.organization-nav{display:flex;flex-wrap:wrap;gap:.7rem 1rem;align-items:center;border-block:.08rem solid var(--line);padding-block:1rem}.organization-nav a[aria-current="page"]{color:var(--ink);font-weight:800}
.live-status{margin-block:1rem}.live-status p{margin:0}.signal-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:1rem;padding-block:2rem}.signal-card{min-width:0}.signal-card h2{font-size:clamp(1.8rem,4vw,3rem)}
details{margin-block:1rem}pre{max-width:100%;overflow:auto;background:#0a0f0d;border:.08rem solid var(--line);padding:.8rem;white-space:pre-wrap;overflow-wrap:anywhere}
.table-scroll{max-width:100%;overflow:auto;border:.08rem solid var(--line)}table{width:100%;border-collapse:collapse;min-width:40rem}caption{text-align:left;padding:.7rem;font-weight:800}th,td{text-align:left;vertical-align:top;border-top:.06rem solid var(--line);padding:.55rem .7rem;overflow-wrap:anywhere}th{color:var(--accent)}.unit{font-weight:400;color:var(--muted)}
.saved-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:1rem}.saved-grid article+article{border-top:.08rem solid var(--line);margin-top:1rem;padding-top:1rem}
.editor-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:1rem}.editor-card{background:var(--panel);border:.08rem solid var(--line);padding:clamp(1rem,3vw,2rem)}.builder-card{grid-column:1/-1}.field-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:.6rem 1rem}.optional{color:var(--muted);font-size:.9em;font-weight:400}button:disabled{cursor:not-allowed;opacity:.55}.breadcrumbs{display:flex;flex-wrap:wrap;gap:.5rem;padding-top:1.5rem}.dashboard-panels{display:grid;gap:1rem}.stat{font-size:clamp(2rem,8vw,5rem);font-weight:800;line-height:1;margin:.5em 0}
.meter-figure{margin:1.5rem 0}.meter-figure figcaption{font-weight:800;margin-bottom:.75rem}.meter-chart{display:grid;gap:.7rem;list-style:none;margin:0;padding:0}.meter-chart li{display:grid;grid-template-columns:minmax(8rem,1fr) minmax(10rem,3fr) minmax(5rem,auto);gap:.8rem;align-items:center}.meter-label,.meter-value{overflow-wrap:anywhere}.meter-value{font-variant-numeric:tabular-nums;text-align:right}meter{width:100%;height:1.2rem;accent-color:var(--accent)}
.incident-list{display:grid;gap:1rem}.incident-card{background:var(--panel);border:.08rem solid var(--line);border-left:.35rem solid var(--accent);padding:clamp(1rem,3vw,2rem)}.incident-card[data-state="firing"]{border-left-color:#ff9b8f}.incident-card[data-state="resolved"]{opacity:.78}.incident-card dl,.saved-grid dl{display:grid;gap:.5rem;margin-block:1rem}.incident-card dl div,.saved-grid dl div{display:grid;grid-template-columns:minmax(8rem,1fr) minmax(0,2fr);gap:1rem}.incident-card dt,.saved-grid dt{font-weight:800}.incident-card dd,.saved-grid dd{margin:0}.incident-actions{display:flex;align-items:end;flex-wrap:wrap;gap:.75rem}.incident-actions form{display:flex;align-items:end;gap:.45rem}.incident-actions label{margin:0}.incident-actions select{width:auto;min-width:10rem}
.offline-control{display:grid;grid-template-columns:minmax(14rem,1fr) minmax(0,2fr);gap:clamp(2rem,6vw,7rem);margin-block:1rem;padding:clamp(1rem,3vw,2rem);border:.08rem solid var(--line);background:var(--panel)}.offline-control h2{font-size:clamp(1.5rem,3vw,2.5rem)}.offline-control [role="status"]{color:var(--muted)}
.explore-layout{display:grid;grid-template-columns:minmax(16rem,1fr) minmax(0,2fr);gap:clamp(2rem,6vw,7rem);padding-block:2rem;border-top:.08rem solid var(--line)}.explore-layout h2,.query-results h2{font-size:clamp(2rem,5vw,4rem)}.query-workbench textarea{min-height:14rem;font-family:ui-monospace,SFMono-Regular,Consolas,monospace}.field-help{color:var(--muted)}.quick-queries{display:flex;flex-wrap:wrap;gap:.6rem;margin-block:1.5rem}.quick-queries form{display:block}.query-results{padding-block:3rem;border-top:.08rem solid var(--line)}.query-results-heading{display:grid;grid-template-columns:minmax(14rem,1fr) minmax(0,2fr);gap:clamp(2rem,6vw,7rem);align-items:end}.query-stats{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:.75rem;margin:0}.query-stats div{border-left:.18rem solid var(--line);padding-left:.75rem}.query-stats dt{color:var(--muted);font-size:.82rem;font-weight:800;text-transform:uppercase;letter-spacing:.08em}.query-stats dd{margin:.15rem 0 0;font-size:1.15rem;font-variant-numeric:tabular-nums;font-weight:800}
@media(max-width:55rem){.section-grid,.cards,.signal-grid,.saved-grid,.editor-grid,.field-grid,.offline-control,.explore-layout,.query-results-heading{grid-template-columns:1fr}.query-stats{grid-template-columns:repeat(2,minmax(0,1fr))}.app-intro{align-items:start;flex-direction:column}.hero h1{letter-spacing:-.035em}.site-header{align-items:flex-start;flex-direction:column}.site-header nav{flex-wrap:wrap}.meter-chart li{grid-template-columns:1fr}.meter-value{text-align:left}.incident-card dl div{grid-template-columns:1fr;gap:.1rem}.incident-actions,.incident-actions form{align-items:stretch;flex-direction:column}.incident-actions select{width:100%}}
@media(max-width:24rem){:root{font-size:95%}.hero h1{font-size:2.75rem;letter-spacing:-.025em}.site-header,.site-footer,main{padding-inline:.8rem}}
@media(prefers-reduced-motion:reduce){*,*::before,*::after{scroll-behavior:auto!important;animation:none!important;transition:none!important}}
@media(forced-colors:active){button,.button,.auth-card,.signal-card,.saved-grid>section,.editor-card,.live-status,.incident-card,.offline-control,.query-stats div{border:1px solid CanvasText}.eyebrow,a{color:LinkText}}
@media print{*{color:#000!important;background:#fff!important}.site-header nav,.logout,.live-status,.button,script{display:none!important}.site-header,.site-footer,.section-grid{border-color:#777}.signal-grid,.section-grid{display:block}.signal-card{break-inside:avoid;margin-block:1rem}.table-scroll{overflow:visible}table{min-width:0;font-size:9pt}}
+146
View File
@@ -0,0 +1,146 @@
// SPDX-License-Identifier: AGPL-3.0-only
(() => {
"use strict";
const serviceWorker = "serviceWorker" in navigator
? navigator.serviceWorker.register("/service-worker.js", {scope: "/"})
: Promise.reject(new Error("service workers unavailable"));
const messageWorker = async message => {
const registration = await serviceWorker;
const worker = registration.active || registration.waiting || registration.installing;
if (!worker) throw new Error("service worker unavailable");
return await new Promise((resolve, reject) => {
const channel = new MessageChannel();
const timeout = window.setTimeout(() => reject(new Error("service worker timeout")), 5000);
channel.port1.onmessage = event => {
window.clearTimeout(timeout);
event.data && event.data.ok ? resolve(event.data) : reject(new Error("service worker request failed"));
};
worker.postMessage(message, [channel.port2]);
});
};
const decodeURLBase64 = value => {
const normalized = value.replace(/-/g, "+").replace(/_/g, "/");
const padded = normalized + "=".repeat((4 - normalized.length % 4) % 4);
const decoded = window.atob(padded);
return Uint8Array.from(decoded, character => character.charCodeAt(0));
};
const pushRequest = async (button, action, subscription) => {
const serialized = subscription.toJSON();
const status = action === "status";
const response = await fetch(status ? "/api/v1/push/subscription/status" : "/api/v1/push/subscription", {
method: action === "delete" ? "DELETE" : "POST",
credentials: "same-origin",
cache: "no-store",
headers: {"Content-Type": "application/json", "X-CSRF-Token": button.dataset.pushCsrf},
body: JSON.stringify({
organization_id: button.dataset.pushOrganization,
endpoint: subscription.endpoint,
keys: action === "save" ? serialized.keys : {p256dh: "", auth: ""}
})
});
if (!response.ok) throw new Error("push subscription request failed");
return await response.json();
};
for (const button of document.querySelectorAll("[data-cache-inbox]")) {
const status = document.querySelector("[data-cache-status]");
serviceWorker.then(() => {
button.disabled = false;
if (status) status.textContent = "No private incident copy has been saved by this control yet.";
}).catch(() => {
if (status) status.textContent = "Offline saving is unavailable in this browser.";
});
button.addEventListener("click", async () => {
button.disabled = true;
if (status) status.textContent = "Saving a private, read-only incident snapshot…";
try {
await messageWorker({type: "cache-inbox", source: button.dataset.offlineSource, target: button.dataset.offlineTarget});
if (status) status.textContent = "This incident inbox is available offline on this browser.";
} catch (_) {
if (status) status.textContent = "The offline incident snapshot could not be saved.";
} finally {
button.disabled = false;
}
});
}
for (const form of document.querySelectorAll('form[action="/logout/"]')) {
form.addEventListener("submit", async event => {
if (form.dataset.privateCacheCleared === "true") return;
event.preventDefault();
try { await messageWorker({type: "clear-private"}); } catch (_) {}
form.dataset.privateCacheCleared = "true";
form.requestSubmit();
});
}
for (const button of document.querySelectorAll("[data-push-toggle]")) {
const status = document.querySelector("[data-push-status]");
const ready = serviceWorker.then(async registration => {
if (!("PushManager" in window) || !("Notification" in window)) throw new Error("push unavailable");
const existing = await registration.pushManager.getSubscription();
const registered = existing ? (await pushRequest(button, "status", existing)).subscribed === true : false;
button.dataset.pushRegistered = registered ? "true" : "false";
button.textContent = registered ? "Disable private incident nudges" : "Enable private incident nudges";
button.disabled = false;
if (status) status.textContent = registered ? "This browser is subscribed for this organization." : "This browser is not subscribed for this organization.";
return registration;
});
ready.catch(() => { if (status) status.textContent = "Web Push is unavailable in this browser."; });
button.addEventListener("click", async () => {
button.disabled = true;
try {
const registration = await ready;
let subscription = await registration.pushManager.getSubscription();
if (subscription && button.dataset.pushRegistered === "true") {
const result = await pushRequest(button, "delete", subscription);
if (!result.remaining) await subscription.unsubscribe();
button.dataset.pushRegistered = "false";
button.textContent = "Enable private incident nudges";
if (status) status.textContent = "This browser is no longer subscribed for this organization.";
} else {
let createdNow = false;
if (!subscription) {
const permission = await Notification.requestPermission();
if (permission !== "granted") throw new Error("notification permission not granted");
subscription = await registration.pushManager.subscribe({userVisibleOnly: true, applicationServerKey: decodeURLBase64(button.dataset.pushPublicKey)});
createdNow = true;
}
try {
await pushRequest(button, "save", subscription);
} catch (error) {
if (createdNow) await subscription.unsubscribe();
throw error;
}
button.dataset.pushRegistered = "true";
button.textContent = "Disable private incident nudges";
if (status) status.textContent = "This browser will receive generic incident nudges.";
}
} catch (_) {
if (status) status.textContent = "The browser push setting could not be changed.";
} finally {
button.disabled = false;
}
});
}
const incidentCount = document.querySelector("[data-open-incident-count]");
if (incidentCount && "setAppBadge" in navigator) {
const count = Number.parseInt(incidentCount.dataset.openIncidentCount, 10);
if (Number.isSafeInteger(count) && count >= 0) {
const update = count === 0 && "clearAppBadge" in navigator ? navigator.clearAppBadge() : navigator.setAppBadge(count);
Promise.resolve(update).catch(() => {});
}
}
for (const status of document.querySelectorAll(".live-status[data-events-url]")) {
const source = new EventSource(status.dataset.eventsUrl);
source.addEventListener("refresh", () => {
status.hidden = false;
});
window.addEventListener("pagehide", () => source.close(), {once: true});
}
})();
+44
View File
@@ -0,0 +1,44 @@
// SPDX-License-Identifier: AGPL-3.0-only
package site
import (
"crypto/sha256"
"encoding/hex"
"strings"
"testing"
)
func TestAssetsAreContentAddressedAndBounded(t *testing.T) {
paths := AssetPaths()
for _, test := range []struct {
path, suffix, contentType string
body []byte
}{{paths.StylePath, ".css", "text/css; charset=utf-8", style}, {paths.ScriptPath, ".js", "text/javascript; charset=utf-8", script}, {paths.IconPath, ".svg", "image/svg+xml", icon}} {
t.Run(test.suffix, func(t *testing.T) {
if len(test.body) == 0 || len(test.body) > 64<<10 || !strings.HasSuffix(test.path, test.suffix) {
t.Fatalf("path=%q bytes=%d", test.path, len(test.body))
}
digest := sha256.Sum256(test.body)
if !strings.Contains(test.path, hex.EncodeToString(digest[:8])) {
t.Fatalf("asset path %q does not contain content digest", test.path)
}
body, contentType, ok := Asset(test.path)
if !ok || contentType != test.contentType || string(body) != string(test.body) {
t.Fatalf("asset lookup ok=%v type=%q", ok, contentType)
}
})
}
if _, _, ok := Asset("/assets/site.css"); ok {
t.Fatal("unversioned asset path was accepted")
}
css := string(style)
for _, required := range []string{"@media(max-width:24rem)", "@media(prefers-reduced-motion:reduce)", "@media(forced-colors:active)", "@media print", ".table-scroll", ".explore-layout", ".query-stats"} {
if !strings.Contains(css, required) {
t.Fatalf("responsive CSS missing %q", required)
}
}
if strings.Contains(strings.ToLower(css), "position:sticky") {
t.Fatal("mobile-hostile sticky table behavior returned")
}
}
+109
View File
@@ -0,0 +1,109 @@
<?sando go
package site
func Dashboard(view DashboardView)
?>
<?# SPDX-License-Identifier: AGPL-3.0-only ?>
<!doctype html>
<html lang="en">
<head><?~ Head(view.Head) ?></head>
<body>
<a class="skip-link" href="#main">Skip to content</a>
<?~ SiteHeader(true, "dashboards", view.Organization.ID) ?>
<main id="main">
<nav class="breadcrumbs" aria-label="Breadcrumb"><a href="/app/?organization=<?= view.Organization.ID ?>">Overview</a> <span aria-hidden="true">/</span> <span aria-current="page"><?= view.Name ?></span></nav>
<header class="app-intro"><div><p class="eyebrow"><?= view.Organization.Name ?> · dashboard</p><h1><?= view.Name ?></h1><p><?= view.Description ?></p></div><p><a class="button secondary" href="<?= view.ExportURL ?>">Export JSON</a></p></header>
<section class="dashboard-panels" aria-label="Dashboard panels">
<? if len(view.Panels) == 0 { ?><p>This dashboard has no panels yet.</p><? } ?>
<? for _, panel := range view.Panels { ?>
<article class="signal-card">
<p class="eyebrow"><?= panel.Visualization ?></p><h2><?= panel.Title ?></h2>
<? if panel.Stat != "" { ?><p class="stat" aria-label="Current statistic"><?= panel.Stat ?></p><? } ?>
<? if len(panel.Chart.Points) > 0 { ?>
<figure class="meter-figure">
<figcaption><?= panel.Chart.Label ?></figcaption>
<ol class="meter-chart">
<? for _, point := range panel.Chart.Points { ?>
<li><span class="meter-label"><?= point.Label ?></span><meter min="0" max="<?= point.Maximum ?>" value="<?= point.Value ?>"><?= point.Display ?></meter><span class="meter-value"><?= point.Display ?></span></li>
<? } ?>
</ol>
</figure>
<? } ?>
<details><summary>Query</summary><pre><code><?= panel.Query ?></code></pre></details>
<?~ ResultTable(panel.Table) ?>
</article>
<? } ?>
</section>
<? if view.CanManage { ?>
<section class="section-grid" aria-labelledby="edit-dashboard">
<div><p class="eyebrow">Dashboard workshop</p><h2 id="edit-dashboard">Revise this view</h2><p>Every change includes revision <?= view.Revision ?>. If someone else saves first, Observatory asks you to reload instead of overwriting their work.</p></div>
<div class="editor-grid">
<form class="editor-card" method="post" action="/app/dashboards/<?= view.Slug ?>/">
<h3>Dashboard details</h3>
<input type="hidden" name="organization_id" value="<?= view.Organization.ID ?>">
<input type="hidden" name="csrf_token" value="<?= view.ManageCSRF ?>">
<input type="hidden" name="dashboard_id" value="<?= view.ID ?>">
<input type="hidden" name="expected_revision" value="<?= view.Revision ?>">
<label for="dashboard-edit-slug">URL slug</label><input id="dashboard-edit-slug" name="slug" value="<?= view.Slug ?>" required maxlength="63" pattern="[a-z][a-z0-9-]+">
<label for="dashboard-edit-name">Name</label><input id="dashboard-edit-name" name="name" value="<?= view.Name ?>" required maxlength="128">
<label for="dashboard-edit-description">Description</label><textarea id="dashboard-edit-description" name="description" required maxlength="1024"><?= view.Description ?></textarea>
<button type="submit">Update dashboard details</button>
</form>
<? for index, panel := range view.Panels { ?>
<section class="editor-card" aria-labelledby="panel-editor-<?= index ?>">
<h3 id="panel-editor-<?= index ?>">Panel <?= index + 1 ?></h3>
<form method="post" action="/app/dashboards/<?= view.Slug ?>/panels/<?= panel.ID ?>/">
<input type="hidden" name="organization_id" value="<?= view.Organization.ID ?>">
<input type="hidden" name="csrf_token" value="<?= view.ManageCSRF ?>">
<input type="hidden" name="dashboard_id" value="<?= view.ID ?>">
<input type="hidden" name="expected_revision" value="<?= view.Revision ?>">
<label for="panel-title-<?= index ?>">Title</label><input id="panel-title-<?= index ?>" name="panel_title" value="<?= panel.Title ?>" required maxlength="128">
<label for="panel-query-<?= index ?>">Saved query</label>
<select id="panel-query-<?= index ?>" name="saved_query_id" required>
<? for _, item := range view.SavedQueries { ?>
<? if item.ID == panel.SavedQueryID { ?><option value="<?= item.ID ?>" selected><?= item.Name ?></option><? } else { ?><option value="<?= item.ID ?>"><?= item.Name ?></option><? } ?>
<? } ?>
</select>
<label for="panel-visualization-<?= index ?>">Presentation</label>
<select id="panel-visualization-<?= index ?>" name="visualization" required>
<? if panel.Visualization == "table" { ?><option value="table" selected>Table</option><? } else { ?><option value="table">Table</option><? } ?>
<? if panel.Visualization == "stat" { ?><option value="stat" selected>Single statistic with table</option><? } else { ?><option value="stat">Single statistic with table</option><? } ?>
<? if panel.Visualization == "timeseries" { ?><option value="timeseries" selected>Time series with table</option><? } else { ?><option value="timeseries">Time series with table</option><? } ?>
</select>
<button type="submit">Update panel</button>
</form>
<details>
<summary>Remove this panel</summary>
<p>This removes the panel from the dashboard definition. The saved query remains available.</p>
<form method="post" action="/app/dashboards/<?= view.Slug ?>/panels/<?= panel.ID ?>/remove/">
<input type="hidden" name="organization_id" value="<?= view.Organization.ID ?>">
<input type="hidden" name="csrf_token" value="<?= view.ManageCSRF ?>">
<input type="hidden" name="dashboard_id" value="<?= view.ID ?>">
<input type="hidden" name="expected_revision" value="<?= view.Revision ?>">
<button class="secondary" type="submit">Confirm panel removal</button>
</form>
</details>
</section>
<? } ?>
<? if len(view.Panels) < 16 { ?>
<form class="editor-card" method="post" action="/app/dashboards/<?= view.Slug ?>/panels/">
<h3>Add a panel</h3>
<input type="hidden" name="organization_id" value="<?= view.Organization.ID ?>">
<input type="hidden" name="csrf_token" value="<?= view.ManageCSRF ?>">
<input type="hidden" name="dashboard_id" value="<?= view.ID ?>">
<input type="hidden" name="expected_revision" value="<?= view.Revision ?>">
<label for="panel-add-title">Title</label><input id="panel-add-title" name="panel_title" required maxlength="128">
<label for="panel-add-query">Saved query</label>
<select id="panel-add-query" name="saved_query_id" required><option value="">Choose a saved query</option><? for _, item := range view.SavedQueries { ?><option value="<?= item.ID ?>"><?= item.Name ?></option><? } ?></select>
<label for="panel-add-visualization">Presentation</label>
<select id="panel-add-visualization" name="visualization" required><option value="table">Table</option><option value="stat">Single statistic with table</option><option value="timeseries">Time series with table</option></select>
<? if len(view.SavedQueries) == 0 { ?><button type="submit" disabled>Add panel</button><? } else { ?><button type="submit">Add panel</button><? } ?>
</form>
<? } ?>
</div>
</section>
<? } ?>
</main>
<?~ SiteFooter() ?>
</body>
</html>
+735
View File
@@ -0,0 +1,735 @@
// Code generated by himesan; DO NOT EDIT.
// himesan:compiler v1.0.0-beta.2
// himesan:runtime-abi sando.v1
// himesan:source-sha256 5a4729bb7867878ea92e49dd8dc66f388fe8fd255ab32292283ca573d17153d9
package site
import (
__himesan_context "context"
__himesan_sando "gamertan.com/sandwich-hime/sando"
__himesan_io "io"
)
var _ = __himesan_sando.ABISandoV1
func Dashboard(view DashboardView) __himesan_sando.Component {
return __himesan_sando.ComponentFunc(func(__himesan_render_context __himesan_context.Context, __himesan_writer __himesan_io.Writer) error {
_ = __himesan_render_context
//line internal/site/dashboard.sando:5:3
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\n"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:6:46
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\n<!doctype html>\n<html lang=\"en\">\n<head>"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:9:11
if __himesan_error := __himesan_sando.Render(__himesan_render_context, __himesan_writer, (Head(view.Head))); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:9:29
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "</head>\n<body>\n <a class=\"skip-link\" href=\"#main\">Skip to content</a>\n "); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:12:7
if __himesan_error := __himesan_sando.Render(__himesan_render_context, __himesan_writer, (SiteHeader(true, "dashboards", view.Organization.ID))); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:12:62
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\n <main id=\"main\">\n <nav class=\"breadcrumbs\" aria-label=\"Breadcrumb\"><a href=\"/app/?organization="); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:14:86
if __himesan_error := __himesan_sando.WriteURL(__himesan_writer, (view.Organization.ID)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:14:109
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\">Overview</a> <span aria-hidden=\"true\">/</span> <span aria-current=\"page\">"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:14:188
if __himesan_error := __himesan_sando.WriteText(__himesan_writer, (view.Name)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:14:200
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "</span></nav>\n <header class=\"app-intro\"><div><p class=\"eyebrow\">"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:15:59
if __himesan_error := __himesan_sando.WriteText(__himesan_writer, (view.Organization.Name)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:15:84
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, " · dashboard</p><h1>"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:15:109
if __himesan_error := __himesan_sando.WriteText(__himesan_writer, (view.Name)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:15:121
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "</h1><p>"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:15:133
if __himesan_error := __himesan_sando.WriteText(__himesan_writer, (view.Description)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:15:152
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "</p></div><p><a class=\"button secondary\" href=\""); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:15:203
if __himesan_error := __himesan_sando.WriteURL(__himesan_writer, (view.ExportURL)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:15:220
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\">Export JSON</a></p></header>\n <section class=\"dashboard-panels\" aria-label=\"Dashboard panels\">\n "); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:17:8
if len(view.Panels) == 0 {
//line internal/site/dashboard.sando:17:37
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "<p>This dashboard has no panels yet.</p>"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:17:80
}
//line internal/site/dashboard.sando:17:84
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\n "); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:18:8
for _, panel := range view.Panels {
//line internal/site/dashboard.sando:18:46
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\n <article class=\"signal-card\">\n <p class=\"eyebrow\">"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:20:32
if __himesan_error := __himesan_sando.WriteText(__himesan_writer, (panel.Visualization)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:20:54
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "</p><h2>"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:20:66
if __himesan_error := __himesan_sando.WriteText(__himesan_writer, (panel.Title)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:20:80
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "</h2>\n "); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:21:12
if panel.Stat != "" {
//line internal/site/dashboard.sando:21:36
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "<p class=\"stat\" aria-label=\"Current statistic\">"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:21:87
if __himesan_error := __himesan_sando.WriteText(__himesan_writer, (panel.Stat)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:21:100
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "</p>"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:21:107
}
//line internal/site/dashboard.sando:21:111
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\n "); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:22:12
if len(panel.Chart.Points) > 0 {
//line internal/site/dashboard.sando:22:47
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\n <figure class=\"meter-figure\">\n <figcaption>"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:24:27
if __himesan_error := __himesan_sando.WriteText(__himesan_writer, (panel.Chart.Label)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:24:47
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "</figcaption>\n <ol class=\"meter-chart\">\n "); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:26:14
for _, point := range panel.Chart.Points {
//line internal/site/dashboard.sando:26:59
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\n <li><span class=\"meter-label\">"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:27:47
if __himesan_error := __himesan_sando.WriteText(__himesan_writer, (point.Label)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:27:61
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "</span><meter min=\"0\" max=\""); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:27:92
if __himesan_error := __himesan_sando.WriteAttr(__himesan_writer, (point.Maximum)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:27:108
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\" value=\""); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:27:121
if __himesan_error := __himesan_sando.WriteAttr(__himesan_writer, (point.Value)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:27:135
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\">"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:27:141
if __himesan_error := __himesan_sando.WriteText(__himesan_writer, (point.Display)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:27:157
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "</meter><span class=\"meter-value\">"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:27:195
if __himesan_error := __himesan_sando.WriteText(__himesan_writer, (point.Display)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:27:211
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "</span></li>\n "); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:28:14
}
//line internal/site/dashboard.sando:28:18
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\n </ol>\n </figure>\n "); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:31:12
}
//line internal/site/dashboard.sando:31:16
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\n <details><summary>Query</summary><pre><code>"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:32:57
if __himesan_error := __himesan_sando.WriteText(__himesan_writer, (panel.Query)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:32:71
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "</code></pre></details>\n "); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:33:13
if __himesan_error := __himesan_sando.Render(__himesan_render_context, __himesan_writer, (ResultTable(panel.Table))); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:33:40
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\n </article>\n "); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:35:8
}
//line internal/site/dashboard.sando:35:12
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\n </section>\n "); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:37:8
if view.CanManage {
//line internal/site/dashboard.sando:37:30
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\n <section class=\"section-grid\" aria-labelledby=\"edit-dashboard\">\n <div><p class=\"eyebrow\">Dashboard workshop</p><h2 id=\"edit-dashboard\">Revise this view</h2><p>Every change includes revision "); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:39:136
if __himesan_error := __himesan_sando.WriteText(__himesan_writer, (view.Revision)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:39:152
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, ". If someone else saves first, Observatory asks you to reload instead of overwriting their work.</p></div>\n <div class=\"editor-grid\">\n <form class=\"editor-card\" method=\"post\" action=\"/app/dashboards/"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:41:77
if __himesan_error := __himesan_sando.WriteURL(__himesan_writer, (view.Slug)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:41:89
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "/\">\n <h3>Dashboard details</h3>\n <input type=\"hidden\" name=\"organization_id\" value=\""); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:43:66
if __himesan_error := __himesan_sando.WriteAttr(__himesan_writer, (view.Organization.ID)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:43:89
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\">\n <input type=\"hidden\" name=\"csrf_token\" value=\""); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:44:61
if __himesan_error := __himesan_sando.WriteAttr(__himesan_writer, (view.ManageCSRF)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:44:79
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\">\n <input type=\"hidden\" name=\"dashboard_id\" value=\""); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:45:63
if __himesan_error := __himesan_sando.WriteAttr(__himesan_writer, (view.ID)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:45:73
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\">\n <input type=\"hidden\" name=\"expected_revision\" value=\""); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:46:68
if __himesan_error := __himesan_sando.WriteAttr(__himesan_writer, (view.Revision)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:46:84
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\">\n <label for=\"dashboard-edit-slug\">URL slug</label><input id=\"dashboard-edit-slug\" name=\"slug\" value=\""); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:47:115
if __himesan_error := __himesan_sando.WriteAttr(__himesan_writer, (view.Slug)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:47:127
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\" required maxlength=\"63\" pattern=\"[a-z][a-z0-9-]+\">\n <label for=\"dashboard-edit-name\">Name</label><input id=\"dashboard-edit-name\" name=\"name\" value=\""); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:48:111
if __himesan_error := __himesan_sando.WriteAttr(__himesan_writer, (view.Name)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:48:123
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\" required maxlength=\"128\">\n <label for=\"dashboard-edit-description\">Description</label><textarea id=\"dashboard-edit-description\" name=\"description\" required maxlength=\"1024\">"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:49:161
if __himesan_error := __himesan_sando.WriteRCDATA(__himesan_writer, (view.Description)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:49:180
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "</textarea>\n <button type=\"submit\">Update dashboard details</button>\n </form>\n "); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:52:12
for index, panel := range view.Panels {
//line internal/site/dashboard.sando:52:54
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\n <section class=\"editor-card\" aria-labelledby=\"panel-editor-"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:53:72
if __himesan_error := __himesan_sando.WriteAttr(__himesan_writer, (index)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:53:80
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\">\n <h3 id=\"panel-editor-"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:54:36
if __himesan_error := __himesan_sando.WriteAttr(__himesan_writer, (index)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:54:44
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\">Panel "); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:54:56
if __himesan_error := __himesan_sando.WriteText(__himesan_writer, (index + 1)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:54:68
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "</h3>\n <form method=\"post\" action=\"/app/dashboards/"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:55:59
if __himesan_error := __himesan_sando.WriteURL(__himesan_writer, (view.Slug)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:55:71
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "/panels/"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:55:83
if __himesan_error := __himesan_sando.WriteURL(__himesan_writer, (panel.ID)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:55:94
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "/\">\n <input type=\"hidden\" name=\"organization_id\" value=\""); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:56:68
if __himesan_error := __himesan_sando.WriteAttr(__himesan_writer, (view.Organization.ID)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:56:91
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\">\n <input type=\"hidden\" name=\"csrf_token\" value=\""); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:57:63
if __himesan_error := __himesan_sando.WriteAttr(__himesan_writer, (view.ManageCSRF)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:57:81
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\">\n <input type=\"hidden\" name=\"dashboard_id\" value=\""); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:58:65
if __himesan_error := __himesan_sando.WriteAttr(__himesan_writer, (view.ID)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:58:75
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\">\n <input type=\"hidden\" name=\"expected_revision\" value=\""); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:59:70
if __himesan_error := __himesan_sando.WriteAttr(__himesan_writer, (view.Revision)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:59:86
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\">\n <label for=\"panel-title-"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:60:41
if __himesan_error := __himesan_sando.WriteAttr(__himesan_writer, (index)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:60:49
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\">Title</label><input id=\"panel-title-"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:60:91
if __himesan_error := __himesan_sando.WriteAttr(__himesan_writer, (index)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:60:99
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\" name=\"panel_title\" value=\""); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:60:131
if __himesan_error := __himesan_sando.WriteAttr(__himesan_writer, (panel.Title)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:60:145
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\" required maxlength=\"128\">\n <label for=\"panel-query-"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:61:41
if __himesan_error := __himesan_sando.WriteAttr(__himesan_writer, (index)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:61:49
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\">Saved query</label>\n <select id=\"panel-query-"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:62:41
if __himesan_error := __himesan_sando.WriteAttr(__himesan_writer, (index)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:62:49
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\" name=\"saved_query_id\" required>\n "); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:63:16
for _, item := range view.SavedQueries {
//line internal/site/dashboard.sando:63:59
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\n "); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:64:18
if item.ID == panel.SavedQueryID {
//line internal/site/dashboard.sando:64:55
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "<option value=\""); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:64:74
if __himesan_error := __himesan_sando.WriteAttr(__himesan_writer, (item.ID)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:64:84
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\" selected>"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:64:99
if __himesan_error := __himesan_sando.WriteText(__himesan_writer, (item.Name)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:64:111
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "</option>"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:64:123
} else {
//line internal/site/dashboard.sando:64:134
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "<option value=\""); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:64:153
if __himesan_error := __himesan_sando.WriteAttr(__himesan_writer, (item.ID)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:64:163
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\">"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:64:169
if __himesan_error := __himesan_sando.WriteText(__himesan_writer, (item.Name)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:64:181
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "</option>"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:64:193
}
//line internal/site/dashboard.sando:64:197
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\n "); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:65:16
}
//line internal/site/dashboard.sando:65:20
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\n </select>\n <label for=\"panel-visualization-"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:67:49
if __himesan_error := __himesan_sando.WriteAttr(__himesan_writer, (index)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:67:57
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\">Presentation</label>\n <select id=\"panel-visualization-"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:68:49
if __himesan_error := __himesan_sando.WriteAttr(__himesan_writer, (index)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:68:57
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\" name=\"visualization\" required>\n "); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:69:18
if panel.Visualization == "table" {
//line internal/site/dashboard.sando:69:56
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "<option value=\"table\" selected>Table</option>"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:69:104
} else {
//line internal/site/dashboard.sando:69:115
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "<option value=\"table\">Table</option>"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:69:154
}
//line internal/site/dashboard.sando:69:158
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\n "); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:70:18
if panel.Visualization == "stat" {
//line internal/site/dashboard.sando:70:55
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "<option value=\"stat\" selected>Single statistic with table</option>"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:70:124
} else {
//line internal/site/dashboard.sando:70:135
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "<option value=\"stat\">Single statistic with table</option>"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:70:195
}
//line internal/site/dashboard.sando:70:199
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\n "); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:71:18
if panel.Visualization == "timeseries" {
//line internal/site/dashboard.sando:71:61
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "<option value=\"timeseries\" selected>Time series with table</option>"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:71:131
} else {
//line internal/site/dashboard.sando:71:142
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "<option value=\"timeseries\">Time series with table</option>"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:71:203
}
//line internal/site/dashboard.sando:71:207
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\n </select>\n <button type=\"submit\">Update panel</button>\n </form>\n <details>\n <summary>Remove this panel</summary>\n <p>This removes the panel from the dashboard definition. The saved query remains available.</p>\n <form method=\"post\" action=\"/app/dashboards/"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:78:61
if __himesan_error := __himesan_sando.WriteURL(__himesan_writer, (view.Slug)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:78:73
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "/panels/"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:78:85
if __himesan_error := __himesan_sando.WriteURL(__himesan_writer, (panel.ID)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:78:96
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "/remove/\">\n <input type=\"hidden\" name=\"organization_id\" value=\""); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:79:70
if __himesan_error := __himesan_sando.WriteAttr(__himesan_writer, (view.Organization.ID)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:79:93
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\">\n <input type=\"hidden\" name=\"csrf_token\" value=\""); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:80:65
if __himesan_error := __himesan_sando.WriteAttr(__himesan_writer, (view.ManageCSRF)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:80:83
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\">\n <input type=\"hidden\" name=\"dashboard_id\" value=\""); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:81:67
if __himesan_error := __himesan_sando.WriteAttr(__himesan_writer, (view.ID)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:81:77
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\">\n <input type=\"hidden\" name=\"expected_revision\" value=\""); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:82:72
if __himesan_error := __himesan_sando.WriteAttr(__himesan_writer, (view.Revision)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:82:88
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\">\n <button class=\"secondary\" type=\"submit\">Confirm panel removal</button>\n </form>\n </details>\n </section>\n "); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:87:12
}
//line internal/site/dashboard.sando:87:16
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\n "); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:88:12
if len(view.Panels) < 16 {
//line internal/site/dashboard.sando:88:41
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\n <form class=\"editor-card\" method=\"post\" action=\"/app/dashboards/"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:89:77
if __himesan_error := __himesan_sando.WriteURL(__himesan_writer, (view.Slug)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:89:89
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "/panels/\">\n <h3>Add a panel</h3>\n <input type=\"hidden\" name=\"organization_id\" value=\""); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:91:66
if __himesan_error := __himesan_sando.WriteAttr(__himesan_writer, (view.Organization.ID)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:91:89
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\">\n <input type=\"hidden\" name=\"csrf_token\" value=\""); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:92:61
if __himesan_error := __himesan_sando.WriteAttr(__himesan_writer, (view.ManageCSRF)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:92:79
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\">\n <input type=\"hidden\" name=\"dashboard_id\" value=\""); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:93:63
if __himesan_error := __himesan_sando.WriteAttr(__himesan_writer, (view.ID)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:93:73
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\">\n <input type=\"hidden\" name=\"expected_revision\" value=\""); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:94:68
if __himesan_error := __himesan_sando.WriteAttr(__himesan_writer, (view.Revision)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:94:84
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\">\n <label for=\"panel-add-title\">Title</label><input id=\"panel-add-title\" name=\"panel_title\" required maxlength=\"128\">\n <label for=\"panel-add-query\">Saved query</label>\n <select id=\"panel-add-query\" name=\"saved_query_id\" required><option value=\"\">Choose a saved query</option>"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:97:120
for _, item := range view.SavedQueries {
//line internal/site/dashboard.sando:97:163
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "<option value=\""); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:97:182
if __himesan_error := __himesan_sando.WriteAttr(__himesan_writer, (item.ID)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:97:192
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\">"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:97:198
if __himesan_error := __himesan_sando.WriteText(__himesan_writer, (item.Name)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:97:210
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "</option>"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:97:222
}
//line internal/site/dashboard.sando:97:226
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "</select>\n <label for=\"panel-add-visualization\">Presentation</label>\n <select id=\"panel-add-visualization\" name=\"visualization\" required><option value=\"table\">Table</option><option value=\"stat\">Single statistic with table</option><option value=\"timeseries\">Time series with table</option></select>\n "); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:100:14
if len(view.SavedQueries) == 0 {
//line internal/site/dashboard.sando:100:49
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "<button type=\"submit\" disabled>Add panel</button>"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:100:101
} else {
//line internal/site/dashboard.sando:100:112
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "<button type=\"submit\">Add panel</button>"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:100:155
}
//line internal/site/dashboard.sando:100:159
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\n </form>\n "); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:102:12
}
//line internal/site/dashboard.sando:102:16
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\n </div>\n </section>\n "); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:105:8
}
//line internal/site/dashboard.sando:105:12
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\n </main>\n "); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:107:7
if __himesan_error := __himesan_sando.Render(__himesan_render_context, __himesan_writer, (SiteFooter())); __himesan_error != nil {
return __himesan_error
}
//line internal/site/dashboard.sando:107:22
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\n</body>\n</html>\n"); __himesan_error != nil {
return __himesan_error
}
return nil
})
}
+60
View File
@@ -0,0 +1,60 @@
<?sando go
package site
func Explore(view ExploreView)
?>
<?# SPDX-License-Identifier: AGPL-3.0-only ?>
<!doctype html>
<html lang="en">
<head><?~ Head(view.Head) ?></head>
<body>
<a class="skip-link" href="#main">Skip to content</a>
<?~ SiteHeader(true, "explore", view.Organization.ID) ?>
<main id="main">
<nav class="breadcrumbs" aria-label="Breadcrumb"><a href="/app/?organization=<?= view.Organization.ID ?>">Overview</a><span aria-hidden="true">/</span><span aria-current="page">Explore</span></nav>
<section class="app-intro" aria-labelledby="explore-title">
<div><p class="eyebrow">Query workshop · <?= view.Organization.Name ?></p><h1 id="explore-title">Follow the evidence.</h1><p>Welcome, <?= view.DisplayName ?>. Write one bounded query, run it on the server, and inspect both the result and its cost.</p></div>
</section>
<aside class="live-status" data-events-url="<?= view.EventsURL ?>" hidden>
<p><strong>New observations are available.</strong> Run the query again when you are ready; Observatory will not replace the result beneath you.</p>
</aside>
<section class="explore-layout" aria-labelledby="query-title">
<div>
<p class="eyebrow">Typed query</p>
<h2 id="query-title">Ask a bounded question</h2>
<p>Queries stay out of the URL and access log. The same parser, authorization scope, sensitivity rules, and execution limits used by the API are applied here.</p>
<div class="quick-queries" aria-label="Example queries">
<form method="post" action="/app/explore/?organization=<?= view.Organization.ID ?>"><input type="hidden" name="csrf_token" value="<?= view.CSRFToken ?>"><input type="hidden" name="query" value="logs | window 1h | limit 50"><button class="secondary" type="submit">Recent logs</button></form>
<form method="post" action="/app/explore/?organization=<?= view.Organization.ID ?>"><input type="hidden" name="csrf_token" value="<?= view.CSRFToken ?>"><input type="hidden" name="query" value="metrics | window 1h | limit 50"><button class="secondary" type="submit">Recent metrics</button></form>
<form method="post" action="/app/explore/?organization=<?= view.Organization.ID ?>"><input type="hidden" name="csrf_token" value="<?= view.CSRFToken ?>"><input type="hidden" name="query" value="traces | window 1h | limit 50"><button class="secondary" type="submit">Recent traces</button></form>
<form method="post" action="/app/explore/?organization=<?= view.Organization.ID ?>"><input type="hidden" name="csrf_token" value="<?= view.CSRFToken ?>"><input type="hidden" name="query" value="deployments | window 24h | limit 50"><button class="secondary" type="submit">Recent deployments</button></form>
</div>
</div>
<form class="editor-card query-workbench" method="post" action="/app/explore/?organization=<?= view.Organization.ID ?>">
<input type="hidden" name="csrf_token" value="<?= view.CSRFToken ?>">
<label for="explore-query">Query</label>
<textarea id="explore-query" name="query" required maxlength="16384" spellcheck="false"><?= view.Query ?></textarea>
<p class="field-help">Begin with <code>logs</code>, <code>metrics</code>, <code>traces</code>, or <code>deployments</code>. Every query has bounded time, rows, bytes, and memory.</p>
<button type="submit">Run query</button>
</form>
</section>
<? if view.ErrorMessage != "" { ?><p class="form-error" role="alert"><?= view.ErrorMessage ?></p><? } ?>
<? if view.Executed { ?>
<section class="query-results" aria-labelledby="results-title">
<div class="query-results-heading"><div><p class="eyebrow">Authorized result</p><h2 id="results-title">Query results</h2></div>
<dl class="query-stats">
<div><dt>Scanned rows</dt><dd><?= view.Stats.ScannedRows ?></dd></div>
<div><dt>Matched rows</dt><dd><?= view.Stats.MatchedRows ?></dd></div>
<div><dt>Scanned bytes</dt><dd><?= view.Stats.ScannedBytes ?></dd></div>
<div><dt>Execution</dt><dd><?= view.Stats.Duration ?></dd></div>
</dl>
</div>
<? if view.Stats.Truncated { ?><p class="live-status"><strong>Result limit reached.</strong> Refine the query or deliberately choose a different bounded limit.</p><? } ?>
<? if view.Stats.Approximate { ?><p class="live-status"><strong>Approximate result.</strong> This answer uses an authorized aggregate projection.</p><? } ?>
<?~ ResultTable(view.Table) ?>
</section>
<? } ?>
</main>
<?~ SiteFooter() ?>
</body>
</html>
+269
View File
@@ -0,0 +1,269 @@
// Code generated by himesan; DO NOT EDIT.
// himesan:compiler v1.0.0-beta.2
// himesan:runtime-abi sando.v1
// himesan:source-sha256 970611f5a27597a2257f7ce7f38bc1184b99fa0801b9b0e3d09ea8bd53986827
package site
import (
__himesan_context "context"
__himesan_sando "gamertan.com/sandwich-hime/sando"
__himesan_io "io"
)
var _ = __himesan_sando.ABISandoV1
func Explore(view ExploreView) __himesan_sando.Component {
return __himesan_sando.ComponentFunc(func(__himesan_render_context __himesan_context.Context, __himesan_writer __himesan_io.Writer) error {
_ = __himesan_render_context
//line internal/site/explore.sando:5:3
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\n"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/explore.sando:6:46
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\n<!doctype html>\n<html lang=\"en\">\n<head>"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/explore.sando:9:11
if __himesan_error := __himesan_sando.Render(__himesan_render_context, __himesan_writer, (Head(view.Head))); __himesan_error != nil {
return __himesan_error
}
//line internal/site/explore.sando:9:29
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "</head>\n<body>\n <a class=\"skip-link\" href=\"#main\">Skip to content</a>\n "); __himesan_error != nil {
return __himesan_error
}
//line internal/site/explore.sando:12:7
if __himesan_error := __himesan_sando.Render(__himesan_render_context, __himesan_writer, (SiteHeader(true, "explore", view.Organization.ID))); __himesan_error != nil {
return __himesan_error
}
//line internal/site/explore.sando:12:59
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\n <main id=\"main\">\n <nav class=\"breadcrumbs\" aria-label=\"Breadcrumb\"><a href=\"/app/?organization="); __himesan_error != nil {
return __himesan_error
}
//line internal/site/explore.sando:14:86
if __himesan_error := __himesan_sando.WriteURL(__himesan_writer, (view.Organization.ID)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/explore.sando:14:109
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\">Overview</a><span aria-hidden=\"true\">/</span><span aria-current=\"page\">Explore</span></nav>\n <section class=\"app-intro\" aria-labelledby=\"explore-title\">\n <div><p class=\"eyebrow\">Query workshop · "); __himesan_error != nil {
return __himesan_error
}
//line internal/site/explore.sando:16:53
if __himesan_error := __himesan_sando.WriteText(__himesan_writer, (view.Organization.Name)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/explore.sando:16:78
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "</p><h1 id=\"explore-title\">Follow the evidence.</h1><p>Welcome, "); __himesan_error != nil {
return __himesan_error
}
//line internal/site/explore.sando:16:146
if __himesan_error := __himesan_sando.WriteText(__himesan_writer, (view.DisplayName)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/explore.sando:16:165
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, ". Write one bounded query, run it on the server, and inspect both the result and its cost.</p></div>\n </section>\n <aside class=\"live-status\" data-events-url=\""); __himesan_error != nil {
return __himesan_error
}
//line internal/site/explore.sando:18:53
if __himesan_error := __himesan_sando.WriteAttr(__himesan_writer, (view.EventsURL)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/explore.sando:18:70
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\" hidden>\n <p><strong>New observations are available.</strong> Run the query again when you are ready; Observatory will not replace the result beneath you.</p>\n </aside>\n <section class=\"explore-layout\" aria-labelledby=\"query-title\">\n <div>\n <p class=\"eyebrow\">Typed query</p>\n <h2 id=\"query-title\">Ask a bounded question</h2>\n <p>Queries stay out of the URL and access log. The same parser, authorization scope, sensitivity rules, and execution limits used by the API are applied here.</p>\n <div class=\"quick-queries\" aria-label=\"Example queries\">\n <form method=\"post\" action=\"/app/explore/?organization="); __himesan_error != nil {
return __himesan_error
}
//line internal/site/explore.sando:27:70
if __himesan_error := __himesan_sando.WriteURL(__himesan_writer, (view.Organization.ID)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/explore.sando:27:93
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\"><input type=\"hidden\" name=\"csrf_token\" value=\""); __himesan_error != nil {
return __himesan_error
}
//line internal/site/explore.sando:27:145
if __himesan_error := __himesan_sando.WriteAttr(__himesan_writer, (view.CSRFToken)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/explore.sando:27:162
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\"><input type=\"hidden\" name=\"query\" value=\"logs | window 1h | limit 50\"><button class=\"secondary\" type=\"submit\">Recent logs</button></form>\n <form method=\"post\" action=\"/app/explore/?organization="); __himesan_error != nil {
return __himesan_error
}
//line internal/site/explore.sando:28:70
if __himesan_error := __himesan_sando.WriteURL(__himesan_writer, (view.Organization.ID)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/explore.sando:28:93
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\"><input type=\"hidden\" name=\"csrf_token\" value=\""); __himesan_error != nil {
return __himesan_error
}
//line internal/site/explore.sando:28:145
if __himesan_error := __himesan_sando.WriteAttr(__himesan_writer, (view.CSRFToken)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/explore.sando:28:162
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\"><input type=\"hidden\" name=\"query\" value=\"metrics | window 1h | limit 50\"><button class=\"secondary\" type=\"submit\">Recent metrics</button></form>\n <form method=\"post\" action=\"/app/explore/?organization="); __himesan_error != nil {
return __himesan_error
}
//line internal/site/explore.sando:29:70
if __himesan_error := __himesan_sando.WriteURL(__himesan_writer, (view.Organization.ID)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/explore.sando:29:93
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\"><input type=\"hidden\" name=\"csrf_token\" value=\""); __himesan_error != nil {
return __himesan_error
}
//line internal/site/explore.sando:29:145
if __himesan_error := __himesan_sando.WriteAttr(__himesan_writer, (view.CSRFToken)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/explore.sando:29:162
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\"><input type=\"hidden\" name=\"query\" value=\"traces | window 1h | limit 50\"><button class=\"secondary\" type=\"submit\">Recent traces</button></form>\n <form method=\"post\" action=\"/app/explore/?organization="); __himesan_error != nil {
return __himesan_error
}
//line internal/site/explore.sando:30:70
if __himesan_error := __himesan_sando.WriteURL(__himesan_writer, (view.Organization.ID)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/explore.sando:30:93
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\"><input type=\"hidden\" name=\"csrf_token\" value=\""); __himesan_error != nil {
return __himesan_error
}
//line internal/site/explore.sando:30:145
if __himesan_error := __himesan_sando.WriteAttr(__himesan_writer, (view.CSRFToken)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/explore.sando:30:162
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\"><input type=\"hidden\" name=\"query\" value=\"deployments | window 24h | limit 50\"><button class=\"secondary\" type=\"submit\">Recent deployments</button></form>\n </div>\n </div>\n <form class=\"editor-card query-workbench\" method=\"post\" action=\"/app/explore/?organization="); __himesan_error != nil {
return __himesan_error
}
//line internal/site/explore.sando:33:102
if __himesan_error := __himesan_sando.WriteURL(__himesan_writer, (view.Organization.ID)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/explore.sando:33:125
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\">\n <input type=\"hidden\" name=\"csrf_token\" value=\""); __himesan_error != nil {
return __himesan_error
}
//line internal/site/explore.sando:34:59
if __himesan_error := __himesan_sando.WriteAttr(__himesan_writer, (view.CSRFToken)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/explore.sando:34:76
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\">\n <label for=\"explore-query\">Query</label>\n <textarea id=\"explore-query\" name=\"query\" required maxlength=\"16384\" spellcheck=\"false\">"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/explore.sando:36:101
if __himesan_error := __himesan_sando.WriteRCDATA(__himesan_writer, (view.Query)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/explore.sando:36:114
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "</textarea>\n <p class=\"field-help\">Begin with <code>logs</code>, <code>metrics</code>, <code>traces</code>, or <code>deployments</code>. Every query has bounded time, rows, bytes, and memory.</p>\n <button type=\"submit\">Run query</button>\n </form>\n </section>\n "); __himesan_error != nil {
return __himesan_error
}
//line internal/site/explore.sando:41:8
if view.ErrorMessage != "" {
//line internal/site/explore.sando:41:39
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "<p class=\"form-error\" role=\"alert\">"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/explore.sando:41:78
if __himesan_error := __himesan_sando.WriteText(__himesan_writer, (view.ErrorMessage)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/explore.sando:41:98
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "</p>"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/explore.sando:41:105
}
//line internal/site/explore.sando:41:109
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\n "); __himesan_error != nil {
return __himesan_error
}
//line internal/site/explore.sando:42:8
if view.Executed {
//line internal/site/explore.sando:42:29
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\n <section class=\"query-results\" aria-labelledby=\"results-title\">\n <div class=\"query-results-heading\"><div><p class=\"eyebrow\">Authorized result</p><h2 id=\"results-title\">Query results</h2></div>\n <dl class=\"query-stats\">\n <div><dt>Scanned rows</dt><dd>"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/explore.sando:46:45
if __himesan_error := __himesan_sando.WriteText(__himesan_writer, (view.Stats.ScannedRows)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/explore.sando:46:70
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "</dd></div>\n <div><dt>Matched rows</dt><dd>"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/explore.sando:47:45
if __himesan_error := __himesan_sando.WriteText(__himesan_writer, (view.Stats.MatchedRows)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/explore.sando:47:70
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "</dd></div>\n <div><dt>Scanned bytes</dt><dd>"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/explore.sando:48:46
if __himesan_error := __himesan_sando.WriteText(__himesan_writer, (view.Stats.ScannedBytes)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/explore.sando:48:72
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "</dd></div>\n <div><dt>Execution</dt><dd>"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/explore.sando:49:42
if __himesan_error := __himesan_sando.WriteText(__himesan_writer, (view.Stats.Duration)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/explore.sando:49:64
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "</dd></div>\n </dl>\n </div>\n "); __himesan_error != nil {
return __himesan_error
}
//line internal/site/explore.sando:52:10
if view.Stats.Truncated {
//line internal/site/explore.sando:52:38
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "<p class=\"live-status\"><strong>Result limit reached.</strong> Refine the query or deliberately choose a different bounded limit.</p>"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/explore.sando:52:173
}
//line internal/site/explore.sando:52:177
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\n "); __himesan_error != nil {
return __himesan_error
}
//line internal/site/explore.sando:53:10
if view.Stats.Approximate {
//line internal/site/explore.sando:53:40
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "<p class=\"live-status\"><strong>Approximate result.</strong> This answer uses an authorized aggregate projection.</p>"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/explore.sando:53:159
}
//line internal/site/explore.sando:53:163
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\n "); __himesan_error != nil {
return __himesan_error
}
//line internal/site/explore.sando:54:11
if __himesan_error := __himesan_sando.Render(__himesan_render_context, __himesan_writer, (ResultTable(view.Table))); __himesan_error != nil {
return __himesan_error
}
//line internal/site/explore.sando:54:37
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\n </section>\n "); __himesan_error != nil {
return __himesan_error
}
//line internal/site/explore.sando:56:8
}
//line internal/site/explore.sando:56:12
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\n </main>\n "); __himesan_error != nil {
return __himesan_error
}
//line internal/site/explore.sando:58:7
if __himesan_error := __himesan_sando.Render(__himesan_render_context, __himesan_writer, (SiteFooter())); __himesan_error != nil {
return __himesan_error
}
//line internal/site/explore.sando:58:22
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\n</body>\n</html>\n"); __himesan_error != nil {
return __himesan_error
}
return nil
})
}
+14
View File
@@ -0,0 +1,14 @@
<?sando go
package site
func Head(view HeadView)
?>
<?# SPDX-License-Identifier: AGPL-3.0-only ?>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="<?= view.Description ?>">
<link rel="canonical" href="<?= view.CanonicalURL ?>">
<link rel="manifest" href="/manifest.webmanifest">
<link rel="stylesheet" href="<?= view.Assets.StylePath ?>">
<script src="<?= view.Assets.ScriptPath ?>" defer></script>
<title><?= view.Title ?></title>
+69
View File
@@ -0,0 +1,69 @@
// Code generated by himesan; DO NOT EDIT.
// himesan:compiler v1.0.0-beta.2
// himesan:runtime-abi sando.v1
// himesan:source-sha256 131f6248343bf4c87cd3a2a525a2050cee13ad29a2e178d5ef4e996c65590dc9
package site
import (
__himesan_context "context"
__himesan_sando "gamertan.com/sandwich-hime/sando"
__himesan_io "io"
)
var _ = __himesan_sando.ABISandoV1
func Head(view HeadView) __himesan_sando.Component {
return __himesan_sando.ComponentFunc(func(__himesan_render_context __himesan_context.Context, __himesan_writer __himesan_io.Writer) error {
_ = __himesan_render_context
//line internal/site/head.sando:5:3
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\n"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/head.sando:6:46
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n<meta name=\"description\" content=\""); __himesan_error != nil {
return __himesan_error
}
//line internal/site/head.sando:9:39
if __himesan_error := __himesan_sando.WriteAttr(__himesan_writer, (view.Description)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/head.sando:9:58
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\">\n<link rel=\"canonical\" href=\""); __himesan_error != nil {
return __himesan_error
}
//line internal/site/head.sando:10:33
if __himesan_error := __himesan_sando.WriteURL(__himesan_writer, (view.CanonicalURL)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/head.sando:10:53
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\">\n<link rel=\"manifest\" href=\"/manifest.webmanifest\">\n<link rel=\"stylesheet\" href=\""); __himesan_error != nil {
return __himesan_error
}
//line internal/site/head.sando:12:34
if __himesan_error := __himesan_sando.WriteURL(__himesan_writer, (view.Assets.StylePath)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/head.sando:12:58
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\">\n<script src=\""); __himesan_error != nil {
return __himesan_error
}
//line internal/site/head.sando:13:18
if __himesan_error := __himesan_sando.WriteURL(__himesan_writer, (view.Assets.ScriptPath)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/head.sando:13:43
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\" defer></script>\n<title>"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/head.sando:14:12
if __himesan_error := __himesan_sando.WriteRCDATA(__himesan_writer, (view.Title)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/head.sando:14:25
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "</title>\n"); __himesan_error != nil {
return __himesan_error
}
return nil
})
}
+63
View File
@@ -0,0 +1,63 @@
<?sando go
package site
func IncidentInbox(view IncidentInboxView)
?>
<?# SPDX-License-Identifier: AGPL-3.0-only ?>
<!doctype html>
<html lang="en">
<head><?~ Head(view.Head) ?></head>
<body>
<a class="skip-link" href="#main">Skip to content</a>
<?~ SiteHeader(true, "incidents", view.Organization.ID) ?>
<main id="main" data-open-incident-count="<?= view.OpenCount ?>">
<nav class="breadcrumbs" aria-label="Breadcrumb"><a href="/app/?organization=<?= view.Organization.ID ?>">Overview</a><span aria-hidden="true">/</span><span aria-current="page">Incidents</span></nav>
<section class="app-intro" aria-labelledby="incident-title">
<div><p class="eyebrow">Incident inbox · <?= view.Organization.Name ?></p><h1 id="incident-title">What needs attention?</h1><p>Welcome, <?= view.DisplayName ?>. Rules use bounded saved queries; this inbox keeps the human response durable and reviewable.</p></div>
</section>
<aside class="live-status" data-events-url="<?= view.EventsURL ?>" hidden><p><strong>Incident evidence changed.</strong> <a href="/app/incidents/?organization=<?= view.Organization.ID ?>">Refresh this inbox</a>.</p></aside>
<section class="offline-control" aria-labelledby="offline-copy-title"><div><h2 id="offline-copy-title">Keep a careful offline copy</h2><p id="offline-copy-note">This is opt-in. The saved copy excludes response controls, CSRF material, query text, telemetry values, actors, and project, environment, or service identifiers.</p></div><div><button class="secondary" type="button" data-cache-inbox data-offline-source="<?= view.OfflineURL ?>" data-offline-target="<?= view.CacheKey ?>" aria-describedby="offline-copy-note" disabled>Save this inbox for offline use</button><p data-cache-status role="status" aria-live="polite">Offline saving requires service-worker support.</p></div></section>
<? if view.PushPublicKey != "" { ?><section class="offline-control" aria-labelledby="push-title"><div><h2 id="push-title">Ask this browser to nudge you</h2><p id="push-note">Optional Web Push sends one fixed message through the browser vendor: “Gamertan Observatory needs your attention.” It never includes an organization, host, service, severity, rule, incident identifier, count, or telemetry text.</p></div><div><button class="secondary" type="button" data-push-toggle data-push-public-key="<?= view.PushPublicKey ?>" data-push-organization="<?= view.Organization.ID ?>" data-push-csrf="<?= view.PushCSRF ?>" aria-describedby="push-note" disabled>Enable private incident nudges</button><p data-push-status role="status" aria-live="polite">Push requires service-worker and notification support.</p></div></section><? } ?>
<section class="section-grid" aria-labelledby="active-incidents">
<div><p class="eyebrow">Durable state</p><h2 id="active-incidents">Incidents</h2></div>
<div class="incident-list">
<? if len(view.Incidents) == 0 { ?><p>No incidents have been opened.</p><? } ?>
<? for _, incident := range view.Incidents { ?>
<article class="incident-card" data-state="<?= incident.State ?>">
<div><p class="eyebrow"><?= incident.Severity ?> · <?= incident.State ?></p><h3><?= incident.Title ?></h3></div>
<dl><div><dt>Started</dt><dd><?= incident.StartedAt ?></dd></div><div><dt>Updated</dt><dd><?= incident.UpdatedAt ?></dd></div><? if incident.SilencedUntil != "" { ?><div><dt>Silenced until</dt><dd><?= incident.SilencedUntil ?></dd></div><? } ?></dl>
<? if view.CanManage && incident.State != "resolved" { ?>
<div class="incident-actions" aria-label="Actions for <?= incident.Title ?>">
<form method="post" action="/app/incidents/<?= incident.ID ?>/"><input type="hidden" name="organization_id" value="<?= view.Organization.ID ?>"><input type="hidden" name="csrf_token" value="<?= view.ManageCSRF ?>"><input type="hidden" name="action" value="acknowledge"><input type="hidden" name="silence_duration" value=""><button class="secondary" type="submit">Acknowledge</button></form>
<form method="post" action="/app/incidents/<?= incident.ID ?>/"><input type="hidden" name="organization_id" value="<?= view.Organization.ID ?>"><input type="hidden" name="csrf_token" value="<?= view.ManageCSRF ?>"><input type="hidden" name="action" value="silence"><label for="silence-<?= incident.ID ?>">Silence for</label><select id="silence-<?= incident.ID ?>" name="silence_duration"><option value="15m">15 minutes</option><option value="1h">1 hour</option><option value="6h">6 hours</option><option value="24h">24 hours</option><option value="168h">7 days</option></select><button class="secondary" type="submit">Silence</button></form>
<form method="post" action="/app/incidents/<?= incident.ID ?>/"><input type="hidden" name="organization_id" value="<?= view.Organization.ID ?>"><input type="hidden" name="csrf_token" value="<?= view.ManageCSRF ?>"><input type="hidden" name="action" value="resolve"><input type="hidden" name="silence_duration" value=""><button class="secondary" type="submit">Resolve</button></form>
</div>
<? } ?>
</article>
<? } ?>
</div>
</section>
<section class="section-grid" aria-labelledby="alert-rules">
<div><p class="eyebrow">Bounded evaluation</p><h2 id="alert-rules">Alert rules</h2></div>
<div class="saved-grid">
<? if len(view.Rules) == 0 { ?><p>No alert rules yet.</p><? } ?>
<? for _, rule := range view.Rules { ?><article><p class="eyebrow"><?= rule.Severity ?></p><h3><?= rule.Name ?></h3><p><?= rule.Description ?></p><dl><div><dt>Interval</dt><dd><?= rule.Interval ?></dd></div><div><dt>Status</dt><dd><? if rule.Enabled { ?>Enabled<? } else { ?>Disabled<? } ?></dd></div><? if rule.LastEvaluatedAt != "" { ?><div><dt>Last evaluated</dt><dd><?= rule.LastEvaluatedAt ?></dd></div><? } ?><? if rule.LastError != "" { ?><div><dt>Last result</dt><dd>Query unavailable</dd></div><? } ?></dl></article><? } ?>
</div>
</section>
<? if view.CanManage { ?>
<section class="section-grid" aria-labelledby="create-alert">
<div><p class="eyebrow">Organization workshop</p><h2 id="create-alert">Create an alert rule</h2><p>A rule counts rows returned by one saved query. Consecutive matches allow brief noise to remain pending before an incident fires.</p></div>
<form class="editor-card" method="post" action="/app/alert-rules/">
<input type="hidden" name="organization_id" value="<?= view.Organization.ID ?>"><input type="hidden" name="csrf_token" value="<?= view.ManageCSRF ?>">
<label for="alert-name">Name</label><input id="alert-name" name="name" required maxlength="128">
<label for="alert-description">Description</label><textarea id="alert-description" name="description" required maxlength="1024"></textarea>
<label for="alert-query">Saved query</label><select id="alert-query" name="saved_query_id" required><option value="">Choose a saved query</option><? for _, item := range view.SavedQueries { ?><option value="<?= item.ID ?>"><?= item.Name ?></option><? } ?></select>
<div class="field-grid"><div><label for="alert-severity">Severity</label><select id="alert-severity" name="severity" required><option value="information">Information</option><option value="warning" selected>Warning</option><option value="critical">Critical</option></select></div><div><label for="alert-interval">Evaluate every</label><select id="alert-interval" name="evaluation_interval" required><option value="15s">15 seconds</option><option value="30s">30 seconds</option><option value="1m" selected>1 minute</option><option value="5m">5 minutes</option><option value="15m">15 minutes</option></select></div><div><label for="alert-matches">Minimum matching rows</label><input id="alert-matches" type="number" name="minimum_matches" min="1" max="100000" value="1" required></div><div><label for="alert-confirmations">Consecutive evaluations</label><input id="alert-confirmations" type="number" name="required_consecutive" min="1" max="10" value="2" required></div></div>
<? if len(view.SavedQueries) == 0 { ?><button type="submit" disabled>Create alert rule</button><? } else { ?><button type="submit">Create alert rule</button><? } ?>
</form>
</section>
<? } ?>
</main>
<?~ SiteFooter() ?>
</body>
</html>
+529
View File
@@ -0,0 +1,529 @@
// Code generated by himesan; DO NOT EDIT.
// himesan:compiler v1.0.0-beta.2
// himesan:runtime-abi sando.v1
// himesan:source-sha256 df123546e9ef420a7ce574a528a984505176d15c8e6233582468bde1e5bc9ea6
package site
import (
__himesan_context "context"
__himesan_sando "gamertan.com/sandwich-hime/sando"
__himesan_io "io"
)
var _ = __himesan_sando.ABISandoV1
func IncidentInbox(view IncidentInboxView) __himesan_sando.Component {
return __himesan_sando.ComponentFunc(func(__himesan_render_context __himesan_context.Context, __himesan_writer __himesan_io.Writer) error {
_ = __himesan_render_context
//line internal/site/incidents.sando:5:3
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\n"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/incidents.sando:6:46
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\n<!doctype html>\n<html lang=\"en\">\n<head>"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/incidents.sando:9:11
if __himesan_error := __himesan_sando.Render(__himesan_render_context, __himesan_writer, (Head(view.Head))); __himesan_error != nil {
return __himesan_error
}
//line internal/site/incidents.sando:9:29
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "</head>\n<body>\n <a class=\"skip-link\" href=\"#main\">Skip to content</a>\n "); __himesan_error != nil {
return __himesan_error
}
//line internal/site/incidents.sando:12:7
if __himesan_error := __himesan_sando.Render(__himesan_render_context, __himesan_writer, (SiteHeader(true, "incidents", view.Organization.ID))); __himesan_error != nil {
return __himesan_error
}
//line internal/site/incidents.sando:12:61
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\n <main id=\"main\" data-open-incident-count=\""); __himesan_error != nil {
return __himesan_error
}
//line internal/site/incidents.sando:13:49
if __himesan_error := __himesan_sando.WriteAttr(__himesan_writer, (view.OpenCount)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/incidents.sando:13:66
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\">\n <nav class=\"breadcrumbs\" aria-label=\"Breadcrumb\"><a href=\"/app/?organization="); __himesan_error != nil {
return __himesan_error
}
//line internal/site/incidents.sando:14:86
if __himesan_error := __himesan_sando.WriteURL(__himesan_writer, (view.Organization.ID)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/incidents.sando:14:109
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\">Overview</a><span aria-hidden=\"true\">/</span><span aria-current=\"page\">Incidents</span></nav>\n <section class=\"app-intro\" aria-labelledby=\"incident-title\">\n <div><p class=\"eyebrow\">Incident inbox · "); __himesan_error != nil {
return __himesan_error
}
//line internal/site/incidents.sando:16:53
if __himesan_error := __himesan_sando.WriteText(__himesan_writer, (view.Organization.Name)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/incidents.sando:16:78
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "</p><h1 id=\"incident-title\">What needs attention?</h1><p>Welcome, "); __himesan_error != nil {
return __himesan_error
}
//line internal/site/incidents.sando:16:148
if __himesan_error := __himesan_sando.WriteText(__himesan_writer, (view.DisplayName)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/incidents.sando:16:167
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, ". Rules use bounded saved queries; this inbox keeps the human response durable and reviewable.</p></div>\n </section>\n <aside class=\"live-status\" data-events-url=\""); __himesan_error != nil {
return __himesan_error
}
//line internal/site/incidents.sando:18:53
if __himesan_error := __himesan_sando.WriteAttr(__himesan_writer, (view.EventsURL)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/incidents.sando:18:70
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\" hidden><p><strong>Incident evidence changed.</strong> <a href=\"/app/incidents/?organization="); __himesan_error != nil {
return __himesan_error
}
//line internal/site/incidents.sando:18:168
if __himesan_error := __himesan_sando.WriteURL(__himesan_writer, (view.Organization.ID)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/incidents.sando:18:191
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\">Refresh this inbox</a>.</p></aside>\n <section class=\"offline-control\" aria-labelledby=\"offline-copy-title\"><div><h2 id=\"offline-copy-title\">Keep a careful offline copy</h2><p id=\"offline-copy-note\">This is opt-in. The saved copy excludes response controls, CSRF material, query text, telemetry values, actors, and project, environment, or service identifiers.</p></div><div><button class=\"secondary\" type=\"button\" data-cache-inbox data-offline-source=\""); __himesan_error != nil {
return __himesan_error
}
//line internal/site/incidents.sando:19:424
if __himesan_error := __himesan_sando.WriteAttr(__himesan_writer, (view.OfflineURL)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/incidents.sando:19:442
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\" data-offline-target=\""); __himesan_error != nil {
return __himesan_error
}
//line internal/site/incidents.sando:19:469
if __himesan_error := __himesan_sando.WriteAttr(__himesan_writer, (view.CacheKey)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/incidents.sando:19:485
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\" aria-describedby=\"offline-copy-note\" disabled>Save this inbox for offline use</button><p data-cache-status role=\"status\" aria-live=\"polite\">Offline saving requires service-worker support.</p></div></section>\n "); __himesan_error != nil {
return __himesan_error
}
//line internal/site/incidents.sando:20:8
if view.PushPublicKey != "" {
//line internal/site/incidents.sando:20:40
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "<section class=\"offline-control\" aria-labelledby=\"push-title\"><div><h2 id=\"push-title\">Ask this browser to nudge you</h2><p id=\"push-note\">Optional Web Push sends one fixed message through the browser vendor: “Gamertan Observatory needs your attention.” It never includes an organization, host, service, severity, rule, incident identifier, count, or telemetry text.</p></div><div><button class=\"secondary\" type=\"button\" data-push-toggle data-push-public-key=\""); __himesan_error != nil {
return __himesan_error
}
//line internal/site/incidents.sando:20:508
if __himesan_error := __himesan_sando.WriteAttr(__himesan_writer, (view.PushPublicKey)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/incidents.sando:20:529
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\" data-push-organization=\""); __himesan_error != nil {
return __himesan_error
}
//line internal/site/incidents.sando:20:559
if __himesan_error := __himesan_sando.WriteAttr(__himesan_writer, (view.Organization.ID)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/incidents.sando:20:582
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\" data-push-csrf=\""); __himesan_error != nil {
return __himesan_error
}
//line internal/site/incidents.sando:20:604
if __himesan_error := __himesan_sando.WriteAttr(__himesan_writer, (view.PushCSRF)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/incidents.sando:20:620
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\" aria-describedby=\"push-note\" disabled>Enable private incident nudges</button><p data-push-status role=\"status\" aria-live=\"polite\">Push requires service-worker and notification support.</p></div></section>"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/incidents.sando:20:829
}
//line internal/site/incidents.sando:20:833
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\n <section class=\"section-grid\" aria-labelledby=\"active-incidents\">\n <div><p class=\"eyebrow\">Durable state</p><h2 id=\"active-incidents\">Incidents</h2></div>\n <div class=\"incident-list\">\n "); __himesan_error != nil {
return __himesan_error
}
//line internal/site/incidents.sando:24:10
if len(view.Incidents) == 0 {
//line internal/site/incidents.sando:24:42
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "<p>No incidents have been opened.</p>"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/incidents.sando:24:82
}
//line internal/site/incidents.sando:24:86
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\n "); __himesan_error != nil {
return __himesan_error
}
//line internal/site/incidents.sando:25:10
for _, incident := range view.Incidents {
//line internal/site/incidents.sando:25:54
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\n <article class=\"incident-card\" data-state=\""); __himesan_error != nil {
return __himesan_error
}
//line internal/site/incidents.sando:26:56
if __himesan_error := __himesan_sando.WriteAttr(__himesan_writer, (incident.State)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/incidents.sando:26:73
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\">\n <div><p class=\"eyebrow\">"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/incidents.sando:27:39
if __himesan_error := __himesan_sando.WriteText(__himesan_writer, (incident.Severity)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/incidents.sando:27:59
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, " · "); __himesan_error != nil {
return __himesan_error
}
//line internal/site/incidents.sando:27:67
if __himesan_error := __himesan_sando.WriteText(__himesan_writer, (incident.State)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/incidents.sando:27:84
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "</p><h3>"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/incidents.sando:27:96
if __himesan_error := __himesan_sando.WriteText(__himesan_writer, (incident.Title)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/incidents.sando:27:113
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "</h3></div>\n <dl><div><dt>Started</dt><dd>"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/incidents.sando:28:44
if __himesan_error := __himesan_sando.WriteText(__himesan_writer, (incident.StartedAt)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/incidents.sando:28:65
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "</dd></div><div><dt>Updated</dt><dd>"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/incidents.sando:28:105
if __himesan_error := __himesan_sando.WriteText(__himesan_writer, (incident.UpdatedAt)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/incidents.sando:28:126
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "</dd></div>"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/incidents.sando:28:140
if incident.SilencedUntil != "" {
//line internal/site/incidents.sando:28:176
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "<div><dt>Silenced until</dt><dd>"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/incidents.sando:28:212
if __himesan_error := __himesan_sando.WriteText(__himesan_writer, (incident.SilencedUntil)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/incidents.sando:28:237
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "</dd></div>"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/incidents.sando:28:251
}
//line internal/site/incidents.sando:28:255
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "</dl>\n "); __himesan_error != nil {
return __himesan_error
}
//line internal/site/incidents.sando:29:14
if view.CanManage && incident.State != "resolved" {
//line internal/site/incidents.sando:29:68
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\n <div class=\"incident-actions\" aria-label=\"Actions for "); __himesan_error != nil {
return __himesan_error
}
//line internal/site/incidents.sando:30:69
if __himesan_error := __himesan_sando.WriteAttr(__himesan_writer, (incident.Title)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/incidents.sando:30:86
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\">\n <form method=\"post\" action=\"/app/incidents/"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/incidents.sando:31:60
if __himesan_error := __himesan_sando.WriteURL(__himesan_writer, (incident.ID)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/incidents.sando:31:74
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "/\"><input type=\"hidden\" name=\"organization_id\" value=\""); __himesan_error != nil {
return __himesan_error
}
//line internal/site/incidents.sando:31:132
if __himesan_error := __himesan_sando.WriteAttr(__himesan_writer, (view.Organization.ID)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/incidents.sando:31:155
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\"><input type=\"hidden\" name=\"csrf_token\" value=\""); __himesan_error != nil {
return __himesan_error
}
//line internal/site/incidents.sando:31:207
if __himesan_error := __himesan_sando.WriteAttr(__himesan_writer, (view.ManageCSRF)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/incidents.sando:31:225
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\"><input type=\"hidden\" name=\"action\" value=\"acknowledge\"><input type=\"hidden\" name=\"silence_duration\" value=\"\"><button class=\"secondary\" type=\"submit\">Acknowledge</button></form>\n <form method=\"post\" action=\"/app/incidents/"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/incidents.sando:32:60
if __himesan_error := __himesan_sando.WriteURL(__himesan_writer, (incident.ID)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/incidents.sando:32:74
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "/\"><input type=\"hidden\" name=\"organization_id\" value=\""); __himesan_error != nil {
return __himesan_error
}
//line internal/site/incidents.sando:32:132
if __himesan_error := __himesan_sando.WriteAttr(__himesan_writer, (view.Organization.ID)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/incidents.sando:32:155
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\"><input type=\"hidden\" name=\"csrf_token\" value=\""); __himesan_error != nil {
return __himesan_error
}
//line internal/site/incidents.sando:32:207
if __himesan_error := __himesan_sando.WriteAttr(__himesan_writer, (view.ManageCSRF)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/incidents.sando:32:225
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\"><input type=\"hidden\" name=\"action\" value=\"silence\"><label for=\"silence-"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/incidents.sando:32:302
if __himesan_error := __himesan_sando.WriteAttr(__himesan_writer, (incident.ID)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/incidents.sando:32:316
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\">Silence for</label><select id=\"silence-"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/incidents.sando:32:361
if __himesan_error := __himesan_sando.WriteAttr(__himesan_writer, (incident.ID)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/incidents.sando:32:375
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\" name=\"silence_duration\"><option value=\"15m\">15 minutes</option><option value=\"1h\">1 hour</option><option value=\"6h\">6 hours</option><option value=\"24h\">24 hours</option><option value=\"168h\">7 days</option></select><button class=\"secondary\" type=\"submit\">Silence</button></form>\n <form method=\"post\" action=\"/app/incidents/"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/incidents.sando:33:60
if __himesan_error := __himesan_sando.WriteURL(__himesan_writer, (incident.ID)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/incidents.sando:33:74
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "/\"><input type=\"hidden\" name=\"organization_id\" value=\""); __himesan_error != nil {
return __himesan_error
}
//line internal/site/incidents.sando:33:132
if __himesan_error := __himesan_sando.WriteAttr(__himesan_writer, (view.Organization.ID)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/incidents.sando:33:155
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\"><input type=\"hidden\" name=\"csrf_token\" value=\""); __himesan_error != nil {
return __himesan_error
}
//line internal/site/incidents.sando:33:207
if __himesan_error := __himesan_sando.WriteAttr(__himesan_writer, (view.ManageCSRF)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/incidents.sando:33:225
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\"><input type=\"hidden\" name=\"action\" value=\"resolve\"><input type=\"hidden\" name=\"silence_duration\" value=\"\"><button class=\"secondary\" type=\"submit\">Resolve</button></form>\n </div>\n "); __himesan_error != nil {
return __himesan_error
}
//line internal/site/incidents.sando:35:14
}
//line internal/site/incidents.sando:35:18
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\n </article>\n "); __himesan_error != nil {
return __himesan_error
}
//line internal/site/incidents.sando:37:10
}
//line internal/site/incidents.sando:37:14
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\n </div>\n </section>\n <section class=\"section-grid\" aria-labelledby=\"alert-rules\">\n <div><p class=\"eyebrow\">Bounded evaluation</p><h2 id=\"alert-rules\">Alert rules</h2></div>\n <div class=\"saved-grid\">\n "); __himesan_error != nil {
return __himesan_error
}
//line internal/site/incidents.sando:43:10
if len(view.Rules) == 0 {
//line internal/site/incidents.sando:43:38
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "<p>No alert rules yet.</p>"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/incidents.sando:43:67
}
//line internal/site/incidents.sando:43:71
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\n "); __himesan_error != nil {
return __himesan_error
}
//line internal/site/incidents.sando:44:10
for _, rule := range view.Rules {
//line internal/site/incidents.sando:44:46
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "<article><p class=\"eyebrow\">"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/incidents.sando:44:78
if __himesan_error := __himesan_sando.WriteText(__himesan_writer, (rule.Severity)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/incidents.sando:44:94
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "</p><h3>"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/incidents.sando:44:106
if __himesan_error := __himesan_sando.WriteText(__himesan_writer, (rule.Name)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/incidents.sando:44:118
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "</h3><p>"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/incidents.sando:44:130
if __himesan_error := __himesan_sando.WriteText(__himesan_writer, (rule.Description)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/incidents.sando:44:149
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "</p><dl><div><dt>Interval</dt><dd>"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/incidents.sando:44:187
if __himesan_error := __himesan_sando.WriteText(__himesan_writer, (rule.Interval)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/incidents.sando:44:203
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "</dd></div><div><dt>Status</dt><dd>"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/incidents.sando:44:241
if rule.Enabled {
//line internal/site/incidents.sando:44:261
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "Enabled"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/incidents.sando:44:271
} else {
//line internal/site/incidents.sando:44:282
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "Disabled"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/incidents.sando:44:293
}
//line internal/site/incidents.sando:44:297
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "</dd></div>"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/incidents.sando:44:311
if rule.LastEvaluatedAt != "" {
//line internal/site/incidents.sando:44:345
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "<div><dt>Last evaluated</dt><dd>"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/incidents.sando:44:381
if __himesan_error := __himesan_sando.WriteText(__himesan_writer, (rule.LastEvaluatedAt)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/incidents.sando:44:404
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "</dd></div>"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/incidents.sando:44:418
}
//line internal/site/incidents.sando:44:425
if rule.LastError != "" {
//line internal/site/incidents.sando:44:453
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "<div><dt>Last result</dt><dd>Query unavailable</dd></div>"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/incidents.sando:44:513
}
//line internal/site/incidents.sando:44:517
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "</dl></article>"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/incidents.sando:44:535
}
//line internal/site/incidents.sando:44:539
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\n </div>\n </section>\n "); __himesan_error != nil {
return __himesan_error
}
//line internal/site/incidents.sando:47:8
if view.CanManage {
//line internal/site/incidents.sando:47:30
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\n <section class=\"section-grid\" aria-labelledby=\"create-alert\">\n <div><p class=\"eyebrow\">Organization workshop</p><h2 id=\"create-alert\">Create an alert rule</h2><p>A rule counts rows returned by one saved query. Consecutive matches allow brief noise to remain pending before an incident fires.</p></div>\n <form class=\"editor-card\" method=\"post\" action=\"/app/alert-rules/\">\n <input type=\"hidden\" name=\"organization_id\" value=\""); __himesan_error != nil {
return __himesan_error
}
//line internal/site/incidents.sando:51:64
if __himesan_error := __himesan_sando.WriteAttr(__himesan_writer, (view.Organization.ID)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/incidents.sando:51:87
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\"><input type=\"hidden\" name=\"csrf_token\" value=\""); __himesan_error != nil {
return __himesan_error
}
//line internal/site/incidents.sando:51:139
if __himesan_error := __himesan_sando.WriteAttr(__himesan_writer, (view.ManageCSRF)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/incidents.sando:51:157
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\">\n <label for=\"alert-name\">Name</label><input id=\"alert-name\" name=\"name\" required maxlength=\"128\">\n <label for=\"alert-description\">Description</label><textarea id=\"alert-description\" name=\"description\" required maxlength=\"1024\"></textarea>\n <label for=\"alert-query\">Saved query</label><select id=\"alert-query\" name=\"saved_query_id\" required><option value=\"\">Choose a saved query</option>"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/incidents.sando:54:158
for _, item := range view.SavedQueries {
//line internal/site/incidents.sando:54:201
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "<option value=\""); __himesan_error != nil {
return __himesan_error
}
//line internal/site/incidents.sando:54:220
if __himesan_error := __himesan_sando.WriteAttr(__himesan_writer, (item.ID)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/incidents.sando:54:230
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\">"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/incidents.sando:54:236
if __himesan_error := __himesan_sando.WriteText(__himesan_writer, (item.Name)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/incidents.sando:54:248
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "</option>"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/incidents.sando:54:260
}
//line internal/site/incidents.sando:54:264
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "</select>\n <div class=\"field-grid\"><div><label for=\"alert-severity\">Severity</label><select id=\"alert-severity\" name=\"severity\" required><option value=\"information\">Information</option><option value=\"warning\" selected>Warning</option><option value=\"critical\">Critical</option></select></div><div><label for=\"alert-interval\">Evaluate every</label><select id=\"alert-interval\" name=\"evaluation_interval\" required><option value=\"15s\">15 seconds</option><option value=\"30s\">30 seconds</option><option value=\"1m\" selected>1 minute</option><option value=\"5m\">5 minutes</option><option value=\"15m\">15 minutes</option></select></div><div><label for=\"alert-matches\">Minimum matching rows</label><input id=\"alert-matches\" type=\"number\" name=\"minimum_matches\" min=\"1\" max=\"100000\" value=\"1\" required></div><div><label for=\"alert-confirmations\">Consecutive evaluations</label><input id=\"alert-confirmations\" type=\"number\" name=\"required_consecutive\" min=\"1\" max=\"10\" value=\"2\" required></div></div>\n "); __himesan_error != nil {
return __himesan_error
}
//line internal/site/incidents.sando:56:12
if len(view.SavedQueries) == 0 {
//line internal/site/incidents.sando:56:47
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "<button type=\"submit\" disabled>Create alert rule</button>"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/incidents.sando:56:107
} else {
//line internal/site/incidents.sando:56:118
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "<button type=\"submit\">Create alert rule</button>"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/incidents.sando:56:169
}
//line internal/site/incidents.sando:56:173
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\n </form>\n </section>\n "); __himesan_error != nil {
return __himesan_error
}
//line internal/site/incidents.sando:59:8
}
//line internal/site/incidents.sando:59:12
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\n </main>\n "); __himesan_error != nil {
return __himesan_error
}
//line internal/site/incidents.sando:61:7
if __himesan_error := __himesan_sando.Render(__himesan_render_context, __himesan_writer, (SiteFooter())); __himesan_error != nil {
return __himesan_error
}
//line internal/site/incidents.sando:61:22
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\n</body>\n</html>\n"); __himesan_error != nil {
return __himesan_error
}
return nil
})
}
+35
View File
@@ -0,0 +1,35 @@
<?sando go
package site
func Landing(view LandingView)
?>
<?# SPDX-License-Identifier: AGPL-3.0-only ?>
<!doctype html>
<html lang="en">
<head><?~ Head(view.Head) ?></head>
<body>
<a class="skip-link" href="#main">Skip to content</a>
<?~ SiteHeader(false, "", "") ?>
<main id="main">
<section class="hero" aria-labelledby="hero-title">
<p class="eyebrow">Logs · metrics · traces · deployments</p>
<h1 id="hero-title">Keep the evidence close.</h1>
<p class="lede">Gamertan Observatory is a self-hosted observability platform being built for small, carefully operated Linux systems. It keeps raw evidence replayable, tenant boundaries explicit, and queries understandable.</p>
<p><a class="button" href="/login/">Open Observatory</a></p>
</section>
<section class="section-grid" aria-labelledby="working-model">
<div><p class="eyebrow">Working model</p><h2 id="working-model">One view without one giant trust boundary.</h2></div>
<div class="cards">
<article><h3>Evidence first</h3><p>Checksummed raw segments are committed before projections acknowledge an ingest. Indexes and dashboards remain rebuildable views.</p></article>
<article><h3>Scoped by the server</h3><p>Organization and resource identity come from enrolled sources and authenticated grants—not from telemetry payloads or query text.</p></article>
<article><h3>Bounded by default</h3><p>Ingestion, storage, queries, subscribers, and fields have explicit limits. Sensitive data requires a separate permission.</p></article>
</div>
</section>
<section class="section-grid" aria-labelledby="preview-status">
<div><p class="eyebrow">Current status</p><h2 id="preview-status">A private development preview.</h2></div>
<div class="prose"><p>The foundations are under active development and are not a public release yet. Logs, metrics, traces, deployment records, organizations, typed queries, schema activation, and persisted dashboard definitions are being integrated before the first preview.</p><p>The interface remains useful without JavaScript. Progressive live updates add a small convenience; they do not own navigation, querying, or incident history.</p></div>
</section>
</main>
<?~ SiteFooter() ?>
</body>
</html>
+53
View File
@@ -0,0 +1,53 @@
// Code generated by himesan; DO NOT EDIT.
// himesan:compiler v1.0.0-beta.2
// himesan:runtime-abi sando.v1
// himesan:source-sha256 7a68dcec190a95c15b15d7bba50a9ba2a067a270c7d1f00b0349f4fd3784ba3b
package site
import (
__himesan_context "context"
__himesan_sando "gamertan.com/sandwich-hime/sando"
__himesan_io "io"
)
var _ = __himesan_sando.ABISandoV1
func Landing(view LandingView) __himesan_sando.Component {
return __himesan_sando.ComponentFunc(func(__himesan_render_context __himesan_context.Context, __himesan_writer __himesan_io.Writer) error {
_ = __himesan_render_context
//line internal/site/landing.sando:5:3
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\n"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/landing.sando:6:46
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\n<!doctype html>\n<html lang=\"en\">\n<head>"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/landing.sando:9:11
if __himesan_error := __himesan_sando.Render(__himesan_render_context, __himesan_writer, (Head(view.Head))); __himesan_error != nil {
return __himesan_error
}
//line internal/site/landing.sando:9:29
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "</head>\n<body>\n <a class=\"skip-link\" href=\"#main\">Skip to content</a>\n "); __himesan_error != nil {
return __himesan_error
}
//line internal/site/landing.sando:12:7
if __himesan_error := __himesan_sando.Render(__himesan_render_context, __himesan_writer, (SiteHeader(false, "", ""))); __himesan_error != nil {
return __himesan_error
}
//line internal/site/landing.sando:12:35
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\n <main id=\"main\">\n <section class=\"hero\" aria-labelledby=\"hero-title\">\n <p class=\"eyebrow\">Logs · metrics · traces · deployments</p>\n <h1 id=\"hero-title\">Keep the evidence close.</h1>\n <p class=\"lede\">Gamertan Observatory is a self-hosted observability platform being built for small, carefully operated Linux systems. It keeps raw evidence replayable, tenant boundaries explicit, and queries understandable.</p>\n <p><a class=\"button\" href=\"/login/\">Open Observatory</a></p>\n </section>\n <section class=\"section-grid\" aria-labelledby=\"working-model\">\n <div><p class=\"eyebrow\">Working model</p><h2 id=\"working-model\">One view without one giant trust boundary.</h2></div>\n <div class=\"cards\">\n <article><h3>Evidence first</h3><p>Checksummed raw segments are committed before projections acknowledge an ingest. Indexes and dashboards remain rebuildable views.</p></article>\n <article><h3>Scoped by the server</h3><p>Organization and resource identity come from enrolled sources and authenticated grants—not from telemetry payloads or query text.</p></article>\n <article><h3>Bounded by default</h3><p>Ingestion, storage, queries, subscribers, and fields have explicit limits. Sensitive data requires a separate permission.</p></article>\n </div>\n </section>\n <section class=\"section-grid\" aria-labelledby=\"preview-status\">\n <div><p class=\"eyebrow\">Current status</p><h2 id=\"preview-status\">A private development preview.</h2></div>\n <div class=\"prose\"><p>The foundations are under active development and are not a public release yet. Logs, metrics, traces, deployment records, organizations, typed queries, schema activation, and persisted dashboard definitions are being integrated before the first preview.</p><p>The interface remains useful without JavaScript. Progressive live updates add a small convenience; they do not own navigation, querying, or incident history.</p></div>\n </section>\n </main>\n "); __himesan_error != nil {
return __himesan_error
}
//line internal/site/landing.sando:33:7
if __himesan_error := __himesan_sando.Render(__himesan_render_context, __himesan_writer, (SiteFooter())); __himesan_error != nil {
return __himesan_error
}
//line internal/site/landing.sando:33:22
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\n</body>\n</html>\n"); __himesan_error != nil {
return __himesan_error
}
return nil
})
}
+31
View File
@@ -0,0 +1,31 @@
<?sando go
package site
func Login(view LoginView)
?>
<?# SPDX-License-Identifier: AGPL-3.0-only ?>
<!doctype html>
<html lang="en">
<head><?~ Head(view.Head) ?></head>
<body>
<a class="skip-link" href="#main">Skip to content</a>
<?~ SiteHeader(false, "", "") ?>
<main id="main" class="auth-shell">
<section class="auth-card" aria-labelledby="login-title">
<p class="eyebrow">Private workshop</p>
<h1 id="login-title">Sign in to Observatory</h1>
<p>Use the local account created by <code>observatory admin bootstrap</code>.</p>
<? if view.ErrorMessage != "" { ?><p class="form-error" role="alert"><?= view.ErrorMessage ?></p><? } ?>
<form method="post" action="/login/">
<input type="hidden" name="csrf_token" value="<?= view.CSRFToken ?>">
<label for="identifier">Username or email</label>
<input id="identifier" name="identifier" autocomplete="username" required maxlength="320">
<label for="password">Password</label>
<input id="password" name="password" type="password" autocomplete="current-password" required maxlength="1024">
<button type="submit">Sign in</button>
</form>
</section>
</main>
<?~ SiteFooter() ?>
</body>
</html>
+81
View File
@@ -0,0 +1,81 @@
// Code generated by himesan; DO NOT EDIT.
// himesan:compiler v1.0.0-beta.2
// himesan:runtime-abi sando.v1
// himesan:source-sha256 dd75f02614b8688552456737ff2bfd5e9625897254989ab8ae2098b5fd5fccc9
package site
import (
__himesan_context "context"
__himesan_sando "gamertan.com/sandwich-hime/sando"
__himesan_io "io"
)
var _ = __himesan_sando.ABISandoV1
func Login(view LoginView) __himesan_sando.Component {
return __himesan_sando.ComponentFunc(func(__himesan_render_context __himesan_context.Context, __himesan_writer __himesan_io.Writer) error {
_ = __himesan_render_context
//line internal/site/login.sando:5:3
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\n"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/login.sando:6:46
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\n<!doctype html>\n<html lang=\"en\">\n<head>"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/login.sando:9:11
if __himesan_error := __himesan_sando.Render(__himesan_render_context, __himesan_writer, (Head(view.Head))); __himesan_error != nil {
return __himesan_error
}
//line internal/site/login.sando:9:29
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "</head>\n<body>\n <a class=\"skip-link\" href=\"#main\">Skip to content</a>\n "); __himesan_error != nil {
return __himesan_error
}
//line internal/site/login.sando:12:7
if __himesan_error := __himesan_sando.Render(__himesan_render_context, __himesan_writer, (SiteHeader(false, "", ""))); __himesan_error != nil {
return __himesan_error
}
//line internal/site/login.sando:12:35
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\n <main id=\"main\" class=\"auth-shell\">\n <section class=\"auth-card\" aria-labelledby=\"login-title\">\n <p class=\"eyebrow\">Private workshop</p>\n <h1 id=\"login-title\">Sign in to Observatory</h1>\n <p>Use the local account created by <code>observatory admin bootstrap</code>.</p>\n "); __himesan_error != nil {
return __himesan_error
}
//line internal/site/login.sando:18:10
if view.ErrorMessage != "" {
//line internal/site/login.sando:18:41
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "<p class=\"form-error\" role=\"alert\">"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/login.sando:18:80
if __himesan_error := __himesan_sando.WriteText(__himesan_writer, (view.ErrorMessage)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/login.sando:18:100
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "</p>"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/login.sando:18:107
}
//line internal/site/login.sando:18:111
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\n <form method=\"post\" action=\"/login/\">\n <input type=\"hidden\" name=\"csrf_token\" value=\""); __himesan_error != nil {
return __himesan_error
}
//line internal/site/login.sando:20:59
if __himesan_error := __himesan_sando.WriteAttr(__himesan_writer, (view.CSRFToken)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/login.sando:20:76
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\">\n <label for=\"identifier\">Username or email</label>\n <input id=\"identifier\" name=\"identifier\" autocomplete=\"username\" required maxlength=\"320\">\n <label for=\"password\">Password</label>\n <input id=\"password\" name=\"password\" type=\"password\" autocomplete=\"current-password\" required maxlength=\"1024\">\n <button type=\"submit\">Sign in</button>\n </form>\n </section>\n </main>\n "); __himesan_error != nil {
return __himesan_error
}
//line internal/site/login.sando:29:7
if __himesan_error := __himesan_sando.Render(__himesan_render_context, __himesan_writer, (SiteFooter())); __himesan_error != nil {
return __himesan_error
}
//line internal/site/login.sando:29:22
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\n</body>\n</html>\n"); __himesan_error != nil {
return __himesan_error
}
return nil
})
}
+7
View File
@@ -0,0 +1,7 @@
<?sando go
package site
func Offline(view OfflineView)
?>
<?# SPDX-License-Identifier: AGPL-3.0-only ?>
<!doctype html><html lang="en"><head><?~ Head(view.Head) ?></head><body><a class="skip-link" href="#main">Skip to content</a><?~ SiteHeader(false, "", "") ?><main id="main"><section class="hero"><p class="eyebrow">Offline</p><h1>The evidence is still safe.</h1><p class="lede">Observatory cannot reach the server right now. A previously saved incident inbox remains available from its usual address; otherwise, reconnect before viewing private organization data.</p><p><a class="button secondary" href="/app/">Try Observatory again</a></p></section></main><?~ SiteFooter() ?></body></html>
+53
View File
@@ -0,0 +1,53 @@
// Code generated by himesan; DO NOT EDIT.
// himesan:compiler v1.0.0-beta.2
// himesan:runtime-abi sando.v1
// himesan:source-sha256 00fe79c601abcfc98b766fbb7332552b31cc92968251972bd24715f990812f09
package site
import (
__himesan_context "context"
__himesan_sando "gamertan.com/sandwich-hime/sando"
__himesan_io "io"
)
var _ = __himesan_sando.ABISandoV1
func Offline(view OfflineView) __himesan_sando.Component {
return __himesan_sando.ComponentFunc(func(__himesan_render_context __himesan_context.Context, __himesan_writer __himesan_io.Writer) error {
_ = __himesan_render_context
//line internal/site/offline.sando:5:3
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\n"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/offline.sando:6:46
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\n<!doctype html><html lang=\"en\"><head>"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/offline.sando:7:42
if __himesan_error := __himesan_sando.Render(__himesan_render_context, __himesan_writer, (Head(view.Head))); __himesan_error != nil {
return __himesan_error
}
//line internal/site/offline.sando:7:60
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "</head><body><a class=\"skip-link\" href=\"#main\">Skip to content</a>"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/offline.sando:7:130
if __himesan_error := __himesan_sando.Render(__himesan_render_context, __himesan_writer, (SiteHeader(false, "", ""))); __himesan_error != nil {
return __himesan_error
}
//line internal/site/offline.sando:7:158
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "<main id=\"main\"><section class=\"hero\"><p class=\"eyebrow\">Offline</p><h1>The evidence is still safe.</h1><p class=\"lede\">Observatory cannot reach the server right now. A previously saved incident inbox remains available from its usual address; otherwise, reconnect before viewing private organization data.</p><p><a class=\"button secondary\" href=\"/app/\">Try Observatory again</a></p></section></main>"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/offline.sando:7:561
if __himesan_error := __himesan_sando.Render(__himesan_render_context, __himesan_writer, (SiteFooter())); __himesan_error != nil {
return __himesan_error
}
//line internal/site/offline.sando:7:576
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "</body></html>\n"); __himesan_error != nil {
return __himesan_error
}
return nil
})
}
+24
View File
@@ -0,0 +1,24 @@
<?sando go
package site
func OfflineIncidentInbox(view OfflineIncidentView)
?>
<?# SPDX-License-Identifier: AGPL-3.0-only ?>
<!doctype html>
<html lang="en">
<head><?~ Head(view.Head) ?></head>
<body>
<a class="skip-link" href="#main">Skip to content</a>
<?~ SiteHeader(true, "incidents", view.Organization.ID) ?>
<main id="main" data-open-incident-count="<?= len(view.Incidents) ?>">
<nav class="breadcrumbs" aria-label="Breadcrumb"><a href="/app/">Overview</a><span aria-hidden="true">/</span><span aria-current="page">Saved incident inbox</span></nav>
<section class="app-intro" aria-labelledby="offline-incident-title"><div><p class="eyebrow">Offline snapshot · <?= view.Organization.Name ?></p><h1 id="offline-incident-title">Saved incident inbox</h1><p>Captured <?= view.CapturedAt ?>. Response actions are intentionally unavailable offline.</p></div></section>
<section class="section-grid" aria-labelledby="saved-incidents"><div><p class="eyebrow">Read-only</p><h2 id="saved-incidents">Incidents</h2></div><div class="incident-list">
<? if len(view.Incidents) == 0 { ?><p>No open incidents were present in this snapshot.</p><? } ?>
<? for _, incident := range view.Incidents { ?><article class="incident-card" data-state="<?= incident.State ?>"><p class="eyebrow"><?= incident.Severity ?> · <?= incident.State ?></p><h3><?= incident.Title ?></h3><dl><div><dt>Started</dt><dd><?= incident.StartedAt ?></dd></div><div><dt>Updated</dt><dd><?= incident.UpdatedAt ?></dd></div><? if incident.SilencedUntil != "" { ?><div><dt>Silenced until</dt><dd><?= incident.SilencedUntil ?></dd></div><? } ?></dl></article><? } ?>
</div></section>
<section class="section-grid" aria-labelledby="offline-boundary"><div><p class="eyebrow">Privacy boundary</p><h2 id="offline-boundary">This copy stays on this browser.</h2></div><div><p>It contains the organization name, incident titles, and states, but no response controls, CSRF tokens, query text, telemetry values, actor identifiers, or project, environment, or service identifiers. Signing out while online clears saved private snapshots.</p><p><a class="button secondary" href="/app/">Reconnect and refresh</a></p></div></section>
</main>
<?~ SiteFooter() ?>
</body>
</html>
+169
View File
@@ -0,0 +1,169 @@
// Code generated by himesan; DO NOT EDIT.
// himesan:compiler v1.0.0-beta.2
// himesan:runtime-abi sando.v1
// himesan:source-sha256 47e648ceed0893244cf41692e7a3d7ecc66a0cba09554e45b0d3dda3567bdfd7
package site
import (
__himesan_context "context"
__himesan_sando "gamertan.com/sandwich-hime/sando"
__himesan_io "io"
)
var _ = __himesan_sando.ABISandoV1
func OfflineIncidentInbox(view OfflineIncidentView) __himesan_sando.Component {
return __himesan_sando.ComponentFunc(func(__himesan_render_context __himesan_context.Context, __himesan_writer __himesan_io.Writer) error {
_ = __himesan_render_context
//line internal/site/offline_incidents.sando:5:3
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\n"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/offline_incidents.sando:6:46
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\n<!doctype html>\n<html lang=\"en\">\n<head>"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/offline_incidents.sando:9:11
if __himesan_error := __himesan_sando.Render(__himesan_render_context, __himesan_writer, (Head(view.Head))); __himesan_error != nil {
return __himesan_error
}
//line internal/site/offline_incidents.sando:9:29
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "</head>\n<body>\n <a class=\"skip-link\" href=\"#main\">Skip to content</a>\n "); __himesan_error != nil {
return __himesan_error
}
//line internal/site/offline_incidents.sando:12:7
if __himesan_error := __himesan_sando.Render(__himesan_render_context, __himesan_writer, (SiteHeader(true, "incidents", view.Organization.ID))); __himesan_error != nil {
return __himesan_error
}
//line internal/site/offline_incidents.sando:12:61
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\n <main id=\"main\" data-open-incident-count=\""); __himesan_error != nil {
return __himesan_error
}
//line internal/site/offline_incidents.sando:13:49
if __himesan_error := __himesan_sando.WriteAttr(__himesan_writer, (len(view.Incidents))); __himesan_error != nil {
return __himesan_error
}
//line internal/site/offline_incidents.sando:13:71
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\">\n <nav class=\"breadcrumbs\" aria-label=\"Breadcrumb\"><a href=\"/app/\">Overview</a><span aria-hidden=\"true\">/</span><span aria-current=\"page\">Saved incident inbox</span></nav>\n <section class=\"app-intro\" aria-labelledby=\"offline-incident-title\"><div><p class=\"eyebrow\">Offline snapshot · "); __himesan_error != nil {
return __himesan_error
}
//line internal/site/offline_incidents.sando:15:121
if __himesan_error := __himesan_sando.WriteText(__himesan_writer, (view.Organization.Name)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/offline_incidents.sando:15:146
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "</p><h1 id=\"offline-incident-title\">Saved incident inbox</h1><p>Captured "); __himesan_error != nil {
return __himesan_error
}
//line internal/site/offline_incidents.sando:15:223
if __himesan_error := __himesan_sando.WriteText(__himesan_writer, (view.CapturedAt)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/offline_incidents.sando:15:241
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, ". Response actions are intentionally unavailable offline.</p></div></section>\n <section class=\"section-grid\" aria-labelledby=\"saved-incidents\"><div><p class=\"eyebrow\">Read-only</p><h2 id=\"saved-incidents\">Incidents</h2></div><div class=\"incident-list\">\n "); __himesan_error != nil {
return __himesan_error
}
//line internal/site/offline_incidents.sando:17:10
if len(view.Incidents) == 0 {
//line internal/site/offline_incidents.sando:17:42
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "<p>No open incidents were present in this snapshot.</p>"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/offline_incidents.sando:17:100
}
//line internal/site/offline_incidents.sando:17:104
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\n "); __himesan_error != nil {
return __himesan_error
}
//line internal/site/offline_incidents.sando:18:10
for _, incident := range view.Incidents {
//line internal/site/offline_incidents.sando:18:54
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "<article class=\"incident-card\" data-state=\""); __himesan_error != nil {
return __himesan_error
}
//line internal/site/offline_incidents.sando:18:101
if __himesan_error := __himesan_sando.WriteAttr(__himesan_writer, (incident.State)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/offline_incidents.sando:18:118
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\"><p class=\"eyebrow\">"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/offline_incidents.sando:18:143
if __himesan_error := __himesan_sando.WriteText(__himesan_writer, (incident.Severity)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/offline_incidents.sando:18:163
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, " · "); __himesan_error != nil {
return __himesan_error
}
//line internal/site/offline_incidents.sando:18:171
if __himesan_error := __himesan_sando.WriteText(__himesan_writer, (incident.State)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/offline_incidents.sando:18:188
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "</p><h3>"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/offline_incidents.sando:18:200
if __himesan_error := __himesan_sando.WriteText(__himesan_writer, (incident.Title)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/offline_incidents.sando:18:217
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "</h3><dl><div><dt>Started</dt><dd>"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/offline_incidents.sando:18:255
if __himesan_error := __himesan_sando.WriteText(__himesan_writer, (incident.StartedAt)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/offline_incidents.sando:18:276
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "</dd></div><div><dt>Updated</dt><dd>"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/offline_incidents.sando:18:316
if __himesan_error := __himesan_sando.WriteText(__himesan_writer, (incident.UpdatedAt)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/offline_incidents.sando:18:337
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "</dd></div>"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/offline_incidents.sando:18:351
if incident.SilencedUntil != "" {
//line internal/site/offline_incidents.sando:18:387
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "<div><dt>Silenced until</dt><dd>"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/offline_incidents.sando:18:423
if __himesan_error := __himesan_sando.WriteText(__himesan_writer, (incident.SilencedUntil)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/offline_incidents.sando:18:448
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "</dd></div>"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/offline_incidents.sando:18:462
}
//line internal/site/offline_incidents.sando:18:466
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "</dl></article>"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/offline_incidents.sando:18:484
}
//line internal/site/offline_incidents.sando:18:488
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\n </div></section>\n <section class=\"section-grid\" aria-labelledby=\"offline-boundary\"><div><p class=\"eyebrow\">Privacy boundary</p><h2 id=\"offline-boundary\">This copy stays on this browser.</h2></div><div><p>It contains the organization name, incident titles, and states, but no response controls, CSRF tokens, query text, telemetry values, actor identifiers, or project, environment, or service identifiers. Signing out while online clears saved private snapshots.</p><p><a class=\"button secondary\" href=\"/app/\">Reconnect and refresh</a></p></div></section>\n </main>\n "); __himesan_error != nil {
return __himesan_error
}
//line internal/site/offline_incidents.sando:22:7
if __himesan_error := __himesan_sando.Render(__himesan_render_context, __himesan_writer, (SiteFooter())); __himesan_error != nil {
return __himesan_error
}
//line internal/site/offline_incidents.sando:22:22
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\n</body>\n</html>\n"); __himesan_error != nil {
return __himesan_error
}
return nil
})
}
+33
View File
@@ -0,0 +1,33 @@
<?sando go
package site
func Password(view PasswordView)
?>
<?# SPDX-License-Identifier: AGPL-3.0-only ?>
<!doctype html>
<html lang="en">
<head><?~ Head(view.Head) ?></head>
<body>
<a class="skip-link" href="#main">Skip to content</a>
<?~ SiteHeader(false, "", "") ?>
<main id="main" class="auth-shell">
<section class="auth-card" aria-labelledby="password-title">
<p class="eyebrow">One careful first step</p>
<h1 id="password-title">Choose your password</h1>
<p>The bootstrap credential is single-purpose. Replace it now; Observatory will end every session and ask you to sign in again.</p>
<? if view.ErrorMessage != "" { ?><p class="form-error" role="alert"><?= view.ErrorMessage ?></p><? } ?>
<form method="post" action="/account/password/">
<input type="hidden" name="csrf_token" value="<?= view.CSRFToken ?>">
<label for="current-password">Temporary password</label>
<input id="current-password" name="current_password" type="password" autocomplete="current-password" required maxlength="1024">
<label for="new-password">New password</label>
<input id="new-password" name="new_password" type="password" autocomplete="new-password" required minlength="12" maxlength="1024">
<label for="confirm-password">Confirm new password</label>
<input id="confirm-password" name="confirm_password" type="password" autocomplete="new-password" required minlength="12" maxlength="1024">
<button type="submit">Change password and sign out</button>
</form>
</section>
</main>
<?~ SiteFooter() ?>
</body>
</html>
+81
View File
@@ -0,0 +1,81 @@
// Code generated by himesan; DO NOT EDIT.
// himesan:compiler v1.0.0-beta.2
// himesan:runtime-abi sando.v1
// himesan:source-sha256 75a8b350f1635bc7a9ba4feb50aa4bc3b151783a3ebee72fc0e1947f7787c320
package site
import (
__himesan_context "context"
__himesan_sando "gamertan.com/sandwich-hime/sando"
__himesan_io "io"
)
var _ = __himesan_sando.ABISandoV1
func Password(view PasswordView) __himesan_sando.Component {
return __himesan_sando.ComponentFunc(func(__himesan_render_context __himesan_context.Context, __himesan_writer __himesan_io.Writer) error {
_ = __himesan_render_context
//line internal/site/password.sando:5:3
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\n"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/password.sando:6:46
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\n<!doctype html>\n<html lang=\"en\">\n<head>"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/password.sando:9:11
if __himesan_error := __himesan_sando.Render(__himesan_render_context, __himesan_writer, (Head(view.Head))); __himesan_error != nil {
return __himesan_error
}
//line internal/site/password.sando:9:29
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "</head>\n<body>\n <a class=\"skip-link\" href=\"#main\">Skip to content</a>\n "); __himesan_error != nil {
return __himesan_error
}
//line internal/site/password.sando:12:7
if __himesan_error := __himesan_sando.Render(__himesan_render_context, __himesan_writer, (SiteHeader(false, "", ""))); __himesan_error != nil {
return __himesan_error
}
//line internal/site/password.sando:12:35
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\n <main id=\"main\" class=\"auth-shell\">\n <section class=\"auth-card\" aria-labelledby=\"password-title\">\n <p class=\"eyebrow\">One careful first step</p>\n <h1 id=\"password-title\">Choose your password</h1>\n <p>The bootstrap credential is single-purpose. Replace it now; Observatory will end every session and ask you to sign in again.</p>\n "); __himesan_error != nil {
return __himesan_error
}
//line internal/site/password.sando:18:10
if view.ErrorMessage != "" {
//line internal/site/password.sando:18:41
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "<p class=\"form-error\" role=\"alert\">"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/password.sando:18:80
if __himesan_error := __himesan_sando.WriteText(__himesan_writer, (view.ErrorMessage)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/password.sando:18:100
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "</p>"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/password.sando:18:107
}
//line internal/site/password.sando:18:111
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\n <form method=\"post\" action=\"/account/password/\">\n <input type=\"hidden\" name=\"csrf_token\" value=\""); __himesan_error != nil {
return __himesan_error
}
//line internal/site/password.sando:20:59
if __himesan_error := __himesan_sando.WriteAttr(__himesan_writer, (view.CSRFToken)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/password.sando:20:76
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\">\n <label for=\"current-password\">Temporary password</label>\n <input id=\"current-password\" name=\"current_password\" type=\"password\" autocomplete=\"current-password\" required maxlength=\"1024\">\n <label for=\"new-password\">New password</label>\n <input id=\"new-password\" name=\"new_password\" type=\"password\" autocomplete=\"new-password\" required minlength=\"12\" maxlength=\"1024\">\n <label for=\"confirm-password\">Confirm new password</label>\n <input id=\"confirm-password\" name=\"confirm_password\" type=\"password\" autocomplete=\"new-password\" required minlength=\"12\" maxlength=\"1024\">\n <button type=\"submit\">Change password and sign out</button>\n </form>\n </section>\n </main>\n "); __himesan_error != nil {
return __himesan_error
}
//line internal/site/password.sando:31:7
if __himesan_error := __himesan_sando.Render(__himesan_render_context, __himesan_writer, (SiteFooter())); __himesan_error != nil {
return __himesan_error
}
//line internal/site/password.sando:31:22
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\n</body>\n</html>\n"); __himesan_error != nil {
return __himesan_error
}
return nil
})
}
+102
View File
@@ -0,0 +1,102 @@
// SPDX-License-Identifier: AGPL-3.0-only
package site
import (
"crypto/sha256"
"encoding/hex"
"fmt"
)
func WebManifest() []byte {
assets := AssetPaths()
return []byte(fmt.Sprintf(`{"id":"/app/","name":"Gamertan Observatory","short_name":"Observatory","description":"A self-hosted, organization-aware observability workshop.","start_url":"/app/","scope":"/","display":"standalone","background_color":"#111715","theme_color":"#111715","icons":[{"src":%q,"sizes":"any","type":"image/svg+xml","purpose":"any maskable"}]}`+"\n", assets.IconPath))
}
func ServiceWorker() []byte {
assets := AssetPaths()
revisionSource := append(append(append([]byte(nil), style...), script...), icon...)
revision := sha256.Sum256(revisionSource)
return []byte(fmt.Sprintf(`/* SPDX-License-Identifier: AGPL-3.0-only */
"use strict";
const SHELL_CACHE = %q;
const PRIVATE_CACHE = "observatory-private-v1";
const PUSH_MESSAGE = %q;
const PUSH_ICON = %q;
const SHELL = [%q,%q,%q,%q];
self.addEventListener("install", event => {
event.waitUntil(caches.open(SHELL_CACHE).then(cache => cache.addAll(SHELL)));
self.skipWaiting();
});
self.addEventListener("activate", event => {
event.waitUntil((async () => {
for (const name of await caches.keys()) {
if (name.startsWith("observatory-shell-") && name !== SHELL_CACHE) await caches.delete(name);
}
await self.clients.claim();
})());
});
self.addEventListener("fetch", event => {
const request = event.request;
if (request.method !== "GET") return;
const url = new URL(request.url);
if (url.origin !== self.location.origin) return;
if (request.mode === "navigate") {
event.respondWith((async () => {
try {
const response = await fetch(request);
if (response.status < 500) return response;
const saved = await caches.open(PRIVATE_CACHE).then(cache => cache.match(request));
return saved || response;
} catch (_) {
const saved = await caches.open(PRIVATE_CACHE).then(cache => cache.match(request));
return saved || await caches.match("/offline/");
}
})());
return;
}
if (SHELL.includes(url.pathname)) {
event.respondWith(caches.match(request).then(saved => saved || fetch(request)));
}
});
self.addEventListener("message", event => {
const reply = value => { if (event.ports[0]) event.ports[0].postMessage(value); };
if (!event.data || typeof event.data.type !== "string") return;
if (event.data.type === "clear-private") {
event.waitUntil(caches.delete(PRIVATE_CACHE).then(() => reply({ok:true})));
return;
}
if (event.data.type !== "cache-inbox") return;
event.waitUntil((async () => {
try {
const source = new URL(event.data.source, self.location.origin);
const target = new URL(event.data.target, self.location.origin);
const valid = source.origin === self.location.origin && target.origin === self.location.origin &&
source.pathname === "/app/incidents/offline/" && target.pathname === "/app/incidents/" &&
source.search === target.search && source.searchParams.size === 1 && source.searchParams.has("organization");
if (!valid) throw new Error("invalid inbox cache request");
const response = await fetch(source.href, {credentials:"include",cache:"no-store"});
if (!response.ok || !(response.headers.get("content-type") || "").startsWith("text/html")) throw new Error("offline inbox unavailable");
const cache = await caches.open(PRIVATE_CACHE);
await cache.put(new Request(target.href, {method:"GET"}), response);
reply({ok:true});
} catch (_) { reply({ok:false}); }
})());
});
self.addEventListener("push", event => {
event.waitUntil(self.registration.showNotification(PUSH_MESSAGE, {icon:PUSH_ICON,badge:PUSH_ICON,tag:"observatory-attention",renotify:true,data:{url:"/app/"}}));
});
self.addEventListener("notificationclick", event => {
event.notification.close();
event.waitUntil((async () => {
for (const client of await self.clients.matchAll({type:"window",includeUncontrolled:true})) {
if (new URL(client.url).origin === self.location.origin) {
await client.navigate("/app/");
return client.focus();
}
}
return self.clients.openWindow("/app/");
})());
});
`, "observatory-shell-"+hex.EncodeToString(revision[:8]), "Gamertan Observatory needs your attention.", assets.IconPath, "/offline/", assets.StylePath, assets.ScriptPath, assets.IconPath))
}
+50
View File
@@ -0,0 +1,50 @@
// SPDX-License-Identifier: AGPL-3.0-only
package site
import (
"encoding/json"
"strings"
"testing"
)
func TestManifestAndServiceWorkerUseExactContentAddressedShell(t *testing.T) {
var manifest struct {
ID string `json:"id"`
Name string `json:"name"`
StartURL string `json:"start_url"`
Scope string `json:"scope"`
Display string `json:"display"`
Icons []struct {
Source string `json:"src"`
Sizes string `json:"sizes"`
Type string `json:"type"`
Purpose string `json:"purpose"`
} `json:"icons"`
}
if err := json.Unmarshal(WebManifest(), &manifest); err != nil {
t.Fatal(err)
}
assets := AssetPaths()
if manifest.ID != "/app/" || manifest.Name != "Gamertan Observatory" || manifest.StartURL != "/app/" || manifest.Scope != "/" || manifest.Display != "standalone" || len(manifest.Icons) != 1 || manifest.Icons[0].Source != assets.IconPath || manifest.Icons[0].Sizes != "any" || manifest.Icons[0].Type != "image/svg+xml" || manifest.Icons[0].Purpose != "any maskable" {
t.Fatalf("manifest=%+v", manifest)
}
worker := string(ServiceWorker())
for _, required := range []string{"/offline/", assets.StylePath, assets.ScriptPath, assets.IconPath, "cache-inbox", "clear-private", "credentials:\"include\"", "source.pathname === \"/app/incidents/offline/\"", "target.pathname === \"/app/incidents/\"", "Gamertan Observatory needs your attention.", `self.addEventListener("push"`, `self.addEventListener("notificationclick"`, `data:{url:"/app/"}`} {
if !strings.Contains(worker, required) {
t.Fatalf("service worker missing %q", required)
}
}
for _, forbidden := range []string{"query_text", "csrf_token", "telemetry", "incident.title", "organization_id", "service_id", "severity", "rule_id", "console.log", "eval("} {
if strings.Contains(worker, forbidden) {
t.Fatalf("service worker contains forbidden %q", forbidden)
}
}
if strings.Count(worker, "Gamertan Observatory needs your attention.") != 1 {
t.Fatal("service worker did not contain exactly one fixed notification message")
}
if first, second := string(ServiceWorker()), string(ServiceWorker()); first != second {
t.Fatal("service worker output is not deterministic")
}
}
+24
View File
@@ -0,0 +1,24 @@
<?sando go
package site
func ResultTable(view TableView)
?>
<?# SPDX-License-Identifier: AGPL-3.0-only ?>
<div class="table-scroll" tabindex="0" role="region" aria-label="<?= view.Caption ?>">
<table>
<caption><?= view.Caption ?></caption>
<thead><tr>
<? for _, column := range view.Columns { ?>
<th scope="col"><?= column.Label ?><? if column.Unit != "" { ?> <span class="unit">(<?= column.Unit ?>)</span><? } ?></th>
<? } ?>
</tr></thead>
<tbody>
<? for _, row := range view.Rows { ?>
<tr><? for _, value := range row.Values { ?><td><?= value ?></td><? } ?></tr>
<? } ?>
<? if len(view.Rows) == 0 { ?>
<tr><td colspan="<?= len(view.Columns) ?>"><?= view.Empty ?></td></tr>
<? } ?>
</tbody>
</table>
</div>
+141
View File
@@ -0,0 +1,141 @@
// Code generated by himesan; DO NOT EDIT.
// himesan:compiler v1.0.0-beta.2
// himesan:runtime-abi sando.v1
// himesan:source-sha256 bf3381b912a1264323672beac6aeed2a2ddb770904404f9597645ac09d2981ae
package site
import (
__himesan_context "context"
__himesan_sando "gamertan.com/sandwich-hime/sando"
__himesan_io "io"
)
var _ = __himesan_sando.ABISandoV1
func ResultTable(view TableView) __himesan_sando.Component {
return __himesan_sando.ComponentFunc(func(__himesan_render_context __himesan_context.Context, __himesan_writer __himesan_io.Writer) error {
_ = __himesan_render_context
//line internal/site/result_table.sando:5:3
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\n"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/result_table.sando:6:46
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\n<div class=\"table-scroll\" tabindex=\"0\" role=\"region\" aria-label=\""); __himesan_error != nil {
return __himesan_error
}
//line internal/site/result_table.sando:7:70
if __himesan_error := __himesan_sando.WriteAttr(__himesan_writer, (view.Caption)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/result_table.sando:7:85
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\">\n <table>\n <caption>"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/result_table.sando:9:18
if __himesan_error := __himesan_sando.WriteText(__himesan_writer, (view.Caption)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/result_table.sando:9:33
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "</caption>\n <thead><tr>\n "); __himesan_error != nil {
return __himesan_error
}
//line internal/site/result_table.sando:11:8
for _, column := range view.Columns {
//line internal/site/result_table.sando:11:48
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\n <th scope=\"col\">"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/result_table.sando:12:27
if __himesan_error := __himesan_sando.WriteText(__himesan_writer, (column.Label)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/result_table.sando:12:45
if column.Unit != "" {
//line internal/site/result_table.sando:12:70
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, " <span class=\"unit\">("); __himesan_error != nil {
return __himesan_error
}
//line internal/site/result_table.sando:12:95
if __himesan_error := __himesan_sando.WriteText(__himesan_writer, (column.Unit)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/result_table.sando:12:109
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, ")</span>"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/result_table.sando:12:120
}
//line internal/site/result_table.sando:12:124
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "</th>\n "); __himesan_error != nil {
return __himesan_error
}
//line internal/site/result_table.sando:13:8
}
//line internal/site/result_table.sando:13:12
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\n </tr></thead>\n <tbody>\n "); __himesan_error != nil {
return __himesan_error
}
//line internal/site/result_table.sando:16:8
for _, row := range view.Rows {
//line internal/site/result_table.sando:16:42
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\n <tr>"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/result_table.sando:17:14
for _, value := range row.Values {
//line internal/site/result_table.sando:17:51
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "<td>"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/result_table.sando:17:59
if __himesan_error := __himesan_sando.WriteText(__himesan_writer, (value)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/result_table.sando:17:67
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "</td>"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/result_table.sando:17:75
}
//line internal/site/result_table.sando:17:79
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "</tr>\n "); __himesan_error != nil {
return __himesan_error
}
//line internal/site/result_table.sando:18:8
}
//line internal/site/result_table.sando:18:12
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\n "); __himesan_error != nil {
return __himesan_error
}
//line internal/site/result_table.sando:19:8
if len(view.Rows) == 0 {
//line internal/site/result_table.sando:19:35
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\n <tr><td colspan=\""); __himesan_error != nil {
return __himesan_error
}
//line internal/site/result_table.sando:20:28
if __himesan_error := __himesan_sando.WriteAttr(__himesan_writer, (len(view.Columns))); __himesan_error != nil {
return __himesan_error
}
//line internal/site/result_table.sando:20:48
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\">"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/result_table.sando:20:54
if __himesan_error := __himesan_sando.WriteText(__himesan_writer, (view.Empty)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/result_table.sando:20:67
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "</td></tr>\n "); __himesan_error != nil {
return __himesan_error
}
//line internal/site/result_table.sando:21:8
}
//line internal/site/result_table.sando:21:12
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\n </tbody>\n </table>\n</div>\n"); __himesan_error != nil {
return __himesan_error
}
return nil
})
}
+9
View File
@@ -0,0 +1,9 @@
<?sando go
package site
func SiteFooter()
?>
<?# SPDX-License-Identifier: AGPL-3.0-only ?>
<footer class="site-footer">
<p>Self-hosted, evidence-minded observability. Built and tended by Gamertan.</p>
</footer>
+29
View File
@@ -0,0 +1,29 @@
// Code generated by himesan; DO NOT EDIT.
// himesan:compiler v1.0.0-beta.2
// himesan:runtime-abi sando.v1
// himesan:source-sha256 df9cf0c1ef86d0317606ba4eccb618cfe2d0925ef83dd572df78beaa6d2c254f
package site
import (
__himesan_context "context"
__himesan_sando "gamertan.com/sandwich-hime/sando"
__himesan_io "io"
)
var _ = __himesan_sando.ABISandoV1
func SiteFooter() __himesan_sando.Component {
return __himesan_sando.ComponentFunc(func(__himesan_render_context __himesan_context.Context, __himesan_writer __himesan_io.Writer) error {
_ = __himesan_render_context
//line internal/site/site_footer.sando:5:3
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\n"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/site_footer.sando:6:46
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\n<footer class=\"site-footer\">\n <p>Self-hosted, evidence-minded observability. Built and tended by Gamertan.</p>\n</footer>\n"); __himesan_error != nil {
return __himesan_error
}
return nil
})
}
+20
View File
@@ -0,0 +1,20 @@
<?sando go
package site
func SiteHeader(authenticated bool, current string, organizationID string)
?>
<?# SPDX-License-Identifier: AGPL-3.0-only ?>
<header class="site-header">
<? if authenticated { ?>
<a class="wordmark" href="/app/?organization=<?= organizationID ?>">Gamertan Observatory</a>
<nav aria-label="Primary">
<? if current == "overview" { ?><a aria-current="page" href="/app/?organization=<?= organizationID ?>">Overview</a><? } else { ?><a href="/app/?organization=<?= organizationID ?>">Overview</a><? } ?>
<? if current == "explore" { ?><a aria-current="page" href="/app/explore/?organization=<?= organizationID ?>">Explore</a><? } else { ?><a href="/app/explore/?organization=<?= organizationID ?>">Explore</a><? } ?>
<? if current == "dashboards" { ?><a aria-current="page" href="/app/?organization=<?= organizationID ?>#dashboards">Dashboards</a><? } else { ?><a href="/app/?organization=<?= organizationID ?>#dashboards">Dashboards</a><? } ?>
<? if current == "incidents" { ?><a aria-current="page" href="/app/incidents/?organization=<?= organizationID ?>">Incidents</a><? } else { ?><a href="/app/incidents/?organization=<?= organizationID ?>">Incidents</a><? } ?>
</nav>
<? } else { ?>
<a class="wordmark" href="/">Gamertan Observatory</a>
<nav aria-label="Primary"><a href="/login/">Sign in</a></nav>
<? } ?>
</header>
+191
View File
@@ -0,0 +1,191 @@
// Code generated by himesan; DO NOT EDIT.
// himesan:compiler v1.0.0-beta.2
// himesan:runtime-abi sando.v1
// himesan:source-sha256 5e30e1ad98c586ffdc566d3688d3288d918faf5645804cd18ef6233a1e250e6b
package site
import (
__himesan_context "context"
__himesan_sando "gamertan.com/sandwich-hime/sando"
__himesan_io "io"
)
var _ = __himesan_sando.ABISandoV1
func SiteHeader(authenticated bool, current string, organizationID string) __himesan_sando.Component {
return __himesan_sando.ComponentFunc(func(__himesan_render_context __himesan_context.Context, __himesan_writer __himesan_io.Writer) error {
_ = __himesan_render_context
//line internal/site/site_header.sando:5:3
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\n"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/site_header.sando:6:46
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\n<header class=\"site-header\">\n "); __himesan_error != nil {
return __himesan_error
}
//line internal/site/site_header.sando:8:6
if authenticated {
//line internal/site/site_header.sando:8:27
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\n <a class=\"wordmark\" href=\"/app/?organization="); __himesan_error != nil {
return __himesan_error
}
//line internal/site/site_header.sando:9:54
if __himesan_error := __himesan_sando.WriteURL(__himesan_writer, (organizationID)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/site_header.sando:9:71
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\">Gamertan Observatory</a>\n <nav aria-label=\"Primary\">\n "); __himesan_error != nil {
return __himesan_error
}
//line internal/site/site_header.sando:11:10
if current == "overview" {
//line internal/site/site_header.sando:11:39
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "<a aria-current=\"page\" href=\"/app/?organization="); __himesan_error != nil {
return __himesan_error
}
//line internal/site/site_header.sando:11:91
if __himesan_error := __himesan_sando.WriteURL(__himesan_writer, (organizationID)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/site_header.sando:11:108
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\">Overview</a>"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/site_header.sando:11:125
} else {
//line internal/site/site_header.sando:11:136
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "<a href=\"/app/?organization="); __himesan_error != nil {
return __himesan_error
}
//line internal/site/site_header.sando:11:168
if __himesan_error := __himesan_sando.WriteURL(__himesan_writer, (organizationID)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/site_header.sando:11:185
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\">Overview</a>"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/site_header.sando:11:202
}
//line internal/site/site_header.sando:11:206
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\n "); __himesan_error != nil {
return __himesan_error
}
//line internal/site/site_header.sando:12:10
if current == "explore" {
//line internal/site/site_header.sando:12:38
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "<a aria-current=\"page\" href=\"/app/explore/?organization="); __himesan_error != nil {
return __himesan_error
}
//line internal/site/site_header.sando:12:98
if __himesan_error := __himesan_sando.WriteURL(__himesan_writer, (organizationID)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/site_header.sando:12:115
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\">Explore</a>"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/site_header.sando:12:131
} else {
//line internal/site/site_header.sando:12:142
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "<a href=\"/app/explore/?organization="); __himesan_error != nil {
return __himesan_error
}
//line internal/site/site_header.sando:12:182
if __himesan_error := __himesan_sando.WriteURL(__himesan_writer, (organizationID)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/site_header.sando:12:199
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\">Explore</a>"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/site_header.sando:12:215
}
//line internal/site/site_header.sando:12:219
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\n "); __himesan_error != nil {
return __himesan_error
}
//line internal/site/site_header.sando:13:10
if current == "dashboards" {
//line internal/site/site_header.sando:13:41
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "<a aria-current=\"page\" href=\"/app/?organization="); __himesan_error != nil {
return __himesan_error
}
//line internal/site/site_header.sando:13:93
if __himesan_error := __himesan_sando.WriteURL(__himesan_writer, (organizationID)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/site_header.sando:13:110
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "#dashboards\">Dashboards</a>"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/site_header.sando:13:140
} else {
//line internal/site/site_header.sando:13:151
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "<a href=\"/app/?organization="); __himesan_error != nil {
return __himesan_error
}
//line internal/site/site_header.sando:13:183
if __himesan_error := __himesan_sando.WriteURL(__himesan_writer, (organizationID)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/site_header.sando:13:200
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "#dashboards\">Dashboards</a>"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/site_header.sando:13:230
}
//line internal/site/site_header.sando:13:234
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\n "); __himesan_error != nil {
return __himesan_error
}
//line internal/site/site_header.sando:14:10
if current == "incidents" {
//line internal/site/site_header.sando:14:40
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "<a aria-current=\"page\" href=\"/app/incidents/?organization="); __himesan_error != nil {
return __himesan_error
}
//line internal/site/site_header.sando:14:102
if __himesan_error := __himesan_sando.WriteURL(__himesan_writer, (organizationID)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/site_header.sando:14:119
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\">Incidents</a>"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/site_header.sando:14:137
} else {
//line internal/site/site_header.sando:14:148
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "<a href=\"/app/incidents/?organization="); __himesan_error != nil {
return __himesan_error
}
//line internal/site/site_header.sando:14:190
if __himesan_error := __himesan_sando.WriteURL(__himesan_writer, (organizationID)); __himesan_error != nil {
return __himesan_error
}
//line internal/site/site_header.sando:14:207
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\">Incidents</a>"); __himesan_error != nil {
return __himesan_error
}
//line internal/site/site_header.sando:14:225
}
//line internal/site/site_header.sando:14:229
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\n </nav>\n "); __himesan_error != nil {
return __himesan_error
}
//line internal/site/site_header.sando:16:6
} else {
//line internal/site/site_header.sando:16:17
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\n <a class=\"wordmark\" href=\"/\">Gamertan Observatory</a>\n <nav aria-label=\"Primary\"><a href=\"/login/\">Sign in</a></nav>\n "); __himesan_error != nil {
return __himesan_error
}
//line internal/site/site_header.sando:19:6
}
//line internal/site/site_header.sando:19:10
if __himesan_error := __himesan_sando.WriteString(__himesan_writer, "\n</header>\n"); __himesan_error != nil {
return __himesan_error
}
return nil
})
}
+192
View File
@@ -0,0 +1,192 @@
// SPDX-License-Identifier: AGPL-3.0-only
package site
type HeadView struct {
Title string
Description string
CanonicalURL string
Assets Assets
}
type LandingView struct{ Head HeadView }
type LoginView struct {
Head HeadView
CSRFToken string
ErrorMessage string
}
type PasswordView struct {
Head HeadView
CSRFToken string
ErrorMessage string
}
type OrganizationOption struct {
ID string
Name string
Selected bool
}
type TableColumn struct {
Label string
Unit string
}
type TableRow struct{ Values []string }
type TableView struct {
Caption string
Columns []TableColumn
Rows []TableRow
Empty string
}
type SignalView struct {
ID string
Name string
Description string
Query string
Table TableView
}
type SavedQuerySummary struct {
ID string
Name string
Description string
Query string
}
type DashboardSummary struct {
Slug string
Name string
Description string
PanelCount int
}
type AppView struct {
Head HeadView
DisplayName string
Organizations []OrganizationOption
Organization OrganizationOption
Signals []SignalView
SavedQueries []SavedQuerySummary
Dashboards []DashboardSummary
CSRFToken string
ManageCSRF string
CanManage bool
EventsURL string
RefreshedAt string
ProjectionLag string
PendingBatches int
IncidentsURL string
OpenIncidents int
}
type QueryStatsView struct {
ScannedRows int
MatchedRows int
ScannedBytes string
Duration string
Truncated bool
Approximate bool
}
type ExploreView struct {
Head HeadView
DisplayName string
Organization OrganizationOption
Query string
CSRFToken string
EventsURL string
Executed bool
ErrorMessage string
Table TableView
Stats QueryStatsView
}
type IncidentSummary struct {
ID string
Title string
State string
Severity string
StartedAt string
UpdatedAt string
SilencedUntil string
}
type AlertRuleSummary struct {
Name string
Description string
Severity string
Enabled bool
Interval string
LastEvaluatedAt string
LastError string
}
type IncidentInboxView struct {
Head HeadView
DisplayName string
Organization OrganizationOption
Incidents []IncidentSummary
Rules []AlertRuleSummary
SavedQueries []SavedQuerySummary
CanManage bool
ManageCSRF string
EventsURL string
OfflineURL string
CacheKey string
OpenCount int
PushPublicKey string
PushCSRF string
}
type OfflineIncidentView struct {
Head HeadView
Organization OrganizationOption
Incidents []IncidentSummary
CapturedAt string
}
type OfflineView struct{ Head HeadView }
type PanelView struct {
ID string
SavedQueryID string
Title string
Visualization string
Query string
Stat string
Chart ChartView
Table TableView
}
type ChartPoint struct {
Label string
Value string
Maximum string
Display string
}
type ChartView struct {
Label string
Points []ChartPoint
}
type DashboardView struct {
Head HeadView
DisplayName string
Organization OrganizationOption
ID string
Slug string
Revision int
Name string
Description string
ExportURL string
Panels []PanelView
SavedQueries []SavedQuerySummary
CanManage bool
ManageCSRF string
}
+273
View File
@@ -0,0 +1,273 @@
// SPDX-License-Identifier: AGPL-3.0-only
package spool
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
"gamertan.com/observatory/internal/model"
"github.com/klauspost/compress/zstd"
)
type Spool struct {
root string
maxBytes int64
maxAge time.Duration
}
type Entry struct {
Path string
Digest string
StreamID string
Sequence uint64
Size int64
ModTime time.Time
}
type envelope struct {
Version int `json:"version"`
Batch model.Batch `json:"batch"`
Checkpoint json.RawMessage `json:"checkpoint,omitempty"`
}
const maxEncodedBatchBytes = 64 << 20
func Open(root string, maxBytes int64, maxAge time.Duration) (*Spool, error) {
if !filepath.IsAbs(root) || filepath.Clean(root) != root || maxBytes < 1<<20 || maxBytes > 5<<30 || maxAge < time.Hour || maxAge > 72*time.Hour {
return nil, errors.New("invalid spool configuration")
}
if err := os.MkdirAll(filepath.Join(root, "pending"), 0o700); err != nil {
return nil, fmt.Errorf("create spool: %w", err)
}
info, err := os.Lstat(root)
if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 || info.Mode().Perm()&0o077 != 0 {
return nil, errors.New("spool root must be a private non-symlink directory")
}
return &Spool{root: root, maxBytes: maxBytes, maxAge: maxAge}, nil
}
func (s *Spool) Put(batch model.Batch, now time.Time) (Entry, error) {
return s.PutWithCheckpoint(batch, nil, now)
}
func (s *Spool) PutWithCheckpoint(batch model.Batch, checkpoint []byte, now time.Time) (Entry, error) {
if err := batch.Validate(now); err != nil {
return Entry{}, err
}
if len(checkpoint) > 4096 || len(checkpoint) != 0 && !json.Valid(checkpoint) {
return Entry{}, errors.New("spool checkpoint is invalid")
}
raw, err := json.Marshal(envelope{Version: 1, Batch: batch, Checkpoint: checkpoint})
if err != nil {
return Entry{}, err
}
if len(raw) > maxEncodedBatchBytes {
return Entry{}, errors.New("spool batch exceeds encoded size limit")
}
encoder, err := zstd.NewWriter(nil, zstd.WithEncoderConcurrency(1))
if err != nil {
return Entry{}, err
}
compressed := encoder.EncodeAll(raw, nil)
encoder.Close()
entries, err := s.List(now)
if err != nil {
return Entry{}, err
}
var used int64
for _, entry := range entries {
used += entry.Size
}
if used+int64(len(compressed)) > s.maxBytes {
return Entry{}, errors.New("agent spool quota exhausted")
}
sum := sha256.Sum256(compressed)
digest := hex.EncodeToString(sum[:])
dir := filepath.Join(s.root, "pending", batch.StreamID)
if err := os.MkdirAll(dir, 0o700); err != nil {
return Entry{}, err
}
final := filepath.Join(dir, fmt.Sprintf("%020d-%s.zst", batch.Sequence, digest))
if info, err := os.Lstat(final); err == nil {
if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 {
return Entry{}, errors.New("existing spool batch is not a regular file")
}
existing, err := os.ReadFile(final)
if err != nil || !bytes.Equal(existing, compressed) {
return Entry{}, errors.New("existing spool batch does not match content")
}
return Entry{Path: final, Digest: digest, StreamID: batch.StreamID, Sequence: batch.Sequence, Size: info.Size(), ModTime: info.ModTime()}, nil
} else if !errors.Is(err, os.ErrNotExist) {
return Entry{}, err
}
tmp, err := os.CreateTemp(dir, ".batch-*")
if err != nil {
return Entry{}, err
}
name := tmp.Name()
defer os.Remove(name)
if err := tmp.Chmod(0o600); err != nil {
_ = tmp.Close()
return Entry{}, err
}
if _, err := tmp.Write(compressed); err != nil {
_ = tmp.Close()
return Entry{}, err
}
if err := tmp.Sync(); err != nil {
_ = tmp.Close()
return Entry{}, err
}
if err := tmp.Close(); err != nil {
return Entry{}, err
}
if err := os.Rename(name, final); err != nil {
return Entry{}, err
}
if err := syncDir(dir); err != nil {
return Entry{}, err
}
return Entry{Path: final, Digest: digest, StreamID: batch.StreamID, Sequence: batch.Sequence, Size: int64(len(compressed)), ModTime: now}, nil
}
func (s *Spool) List(now time.Time) ([]Entry, error) {
base := filepath.Join(s.root, "pending")
var entries []Entry
err := filepath.WalkDir(base, func(path string, item os.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if item.Type()&os.ModeSymlink != 0 {
return errors.New("spool contains a symlink")
}
if item.IsDir() || !strings.HasSuffix(item.Name(), ".zst") {
return nil
}
if !item.Type().IsRegular() {
return errors.New("spool batch is not a regular file")
}
parts := strings.Split(strings.TrimSuffix(item.Name(), ".zst"), "-")
if len(parts) != 2 || len(parts[0]) != 20 || len(parts[1]) != 64 {
return errors.New("spool contains an invalid batch filename")
}
sequence, err := strconv.ParseUint(parts[0], 10, 64)
if err != nil {
return err
}
info, err := item.Info()
if err != nil {
return err
}
if info.Size() < 1 || info.Size() > maxEncodedBatchBytes {
return errors.New("spool batch exceeds compressed size limit")
}
if now.Sub(info.ModTime()) > s.maxAge {
return errors.New("agent spool contains data older than its outage budget")
}
entries = append(entries, Entry{Path: path, Digest: parts[1], StreamID: filepath.Base(filepath.Dir(path)), Sequence: sequence, Size: info.Size(), ModTime: info.ModTime()})
return nil
})
if err != nil {
return nil, err
}
sort.Slice(entries, func(i, j int) bool {
if entries[i].StreamID == entries[j].StreamID {
return entries[i].Sequence < entries[j].Sequence
}
return entries[i].StreamID < entries[j].StreamID
})
return entries, nil
}
func (s *Spool) Read(entry Entry) (model.Batch, error) {
batch, _, err := s.ReadWithCheckpoint(entry)
return batch, err
}
func (s *Spool) ReadWithCheckpoint(entry Entry) (model.Batch, []byte, error) {
if !strings.HasPrefix(filepath.Clean(entry.Path), filepath.Join(s.root, "pending")+string(os.PathSeparator)) {
return model.Batch{}, nil, errors.New("spool entry escapes root")
}
info, err := os.Lstat(entry.Path)
if err != nil {
return model.Batch{}, nil, err
}
if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 || info.Size() < 1 || info.Size() > maxEncodedBatchBytes {
return model.Batch{}, nil, errors.New("spool batch is outside compressed size limit")
}
b, err := os.ReadFile(entry.Path)
if err != nil {
return model.Batch{}, nil, err
}
sum := sha256.Sum256(b)
if hex.EncodeToString(sum[:]) != entry.Digest {
return model.Batch{}, nil, errors.New("spool checksum mismatch")
}
decoder, err := zstd.NewReader(nil, zstd.WithDecoderConcurrency(1), zstd.WithDecoderMaxMemory(maxEncodedBatchBytes), zstd.WithDecodeAllCapLimit(true))
if err != nil {
return model.Batch{}, nil, err
}
raw, err := decoder.DecodeAll(b, make([]byte, 0, maxEncodedBatchBytes))
decoder.Close()
if err != nil || len(raw) > maxEncodedBatchBytes {
return model.Batch{}, nil, errors.New("invalid compressed spool batch")
}
var payload envelope
jsonDecoder := json.NewDecoder(bytes.NewReader(raw))
jsonDecoder.DisallowUnknownFields()
if err := jsonDecoder.Decode(&payload); err != nil {
return model.Batch{}, nil, err
}
if err := jsonDecoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
return model.Batch{}, nil, errors.New("spool batch has trailing JSON")
}
if payload.Version != 1 || len(payload.Checkpoint) > 4096 {
return model.Batch{}, nil, errors.New("spool envelope is invalid")
}
batch := payload.Batch
if batch.StreamID != entry.StreamID || batch.Sequence != entry.Sequence {
return model.Batch{}, nil, errors.New("spool path does not match batch identity")
}
return batch, append([]byte(nil), payload.Checkpoint...), nil
}
func (s *Spool) Acknowledge(entry Entry, digest string) error {
if digest != entry.Digest {
return errors.New("acknowledgement digest does not match spool entry")
}
if !strings.HasPrefix(filepath.Clean(entry.Path), filepath.Join(s.root, "pending")+string(os.PathSeparator)) {
return errors.New("spool acknowledgement path escapes root")
}
info, err := os.Lstat(entry.Path)
if err != nil {
return err
}
if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 {
return errors.New("spool acknowledgement target is not a regular file")
}
if err := os.Remove(entry.Path); err != nil {
return err
}
return syncDir(filepath.Dir(entry.Path))
}
func syncDir(path string) error {
dir, err := os.Open(path)
if err != nil {
return err
}
defer dir.Close()
return dir.Sync()
}
+103
View File
@@ -0,0 +1,103 @@
// SPDX-License-Identifier: AGPL-3.0-only
package spool
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
"gamertan.com/observatory/internal/model"
)
func TestSpoolRoundTripAndExactAcknowledgement(t *testing.T) {
root := filepath.Join(t.TempDir(), "spool")
if err := os.Mkdir(root, 0o700); err != nil {
t.Fatal(err)
}
spool, err := Open(root, 1<<20, 72*time.Hour)
if err != nil {
t.Fatal(err)
}
now := time.Now().UTC()
batch := model.Batch{Version: 1, SourceID: "source", StreamID: "access", Sequence: 1, ObservedAt: now, Signal: model.SignalLogs, Records: []model.Observation{{Timestamp: now, Name: "request"}}}
checkpoint := []byte(`{"offset":42,"sequence":1}`)
entry, err := spool.PutWithCheckpoint(batch, checkpoint, now)
if err != nil {
t.Fatal(err)
}
got, err := spool.Read(entry)
if err != nil || got.Sequence != 1 {
t.Fatalf("batch=%+v err=%v", got, err)
}
_, gotCheckpoint, err := spool.ReadWithCheckpoint(entry)
if err != nil || string(gotCheckpoint) != string(checkpoint) {
t.Fatalf("checkpoint=%s err=%v", gotCheckpoint, err)
}
if err := spool.Acknowledge(entry, "wrong"); err == nil {
t.Fatal("expected digest mismatch")
}
outside := filepath.Join(t.TempDir(), "outside")
if err := os.WriteFile(outside, []byte("keep"), 0o600); err != nil {
t.Fatal(err)
}
if err := spool.Acknowledge(Entry{Path: outside, Digest: entry.Digest}, entry.Digest); err == nil {
t.Fatal("expected acknowledgement path rejection")
}
if err := spool.Acknowledge(entry, entry.Digest); err != nil {
t.Fatal(err)
}
if entries, err := spool.List(now); err != nil || len(entries) != 0 {
t.Fatalf("entries=%+v err=%v", entries, err)
}
}
func TestSpoolRejectsQuotaAndSymlink(t *testing.T) {
root := filepath.Join(t.TempDir(), "spool")
if err := os.Mkdir(root, 0o700); err != nil {
t.Fatal(err)
}
spool, err := Open(root, 1<<20, 72*time.Hour)
if err != nil {
t.Fatal(err)
}
link := filepath.Join(root, "pending", "bad")
if err := os.Symlink(t.TempDir(), link); err != nil {
t.Skip(err)
}
if _, err := spool.List(time.Now().UTC()); err == nil {
t.Fatal("expected symlink rejection")
}
}
func TestSpoolRejectsOversizedFileBeforeRead(t *testing.T) {
root := filepath.Join(t.TempDir(), "spool")
if err := os.Mkdir(root, 0o700); err != nil {
t.Fatal(err)
}
queue, err := Open(root, 1<<20, 72*time.Hour)
if err != nil {
t.Fatal(err)
}
dir := filepath.Join(root, "pending", "access")
if err = os.MkdirAll(dir, 0o700); err != nil {
t.Fatal(err)
}
path := filepath.Join(dir, "00000000000000000001-"+strings.Repeat("a", 64)+".zst")
file, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
if err != nil {
t.Fatal(err)
}
if err = file.Truncate(maxEncodedBatchBytes + 1); err != nil {
file.Close()
t.Fatal(err)
}
if err = file.Close(); err != nil {
t.Fatal(err)
}
if _, err = queue.List(time.Now().UTC()); err == nil || !strings.Contains(err.Error(), "compressed size limit") {
t.Fatalf("expected compressed-size rejection, got %v", err)
}
}
+445
View File
@@ -0,0 +1,445 @@
// SPDX-License-Identifier: AGPL-3.0-only
package storage
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"math"
"net/url"
"os"
"path/filepath"
"sort"
"strconv"
"sync"
"time"
"gamertan.com/observatory/internal/model"
"gamertan.com/observatory/internal/query"
"gamertan.com/observatory/internal/schema"
)
const maxProjectionVersion = 999999
const indexedTimeFormat = "2006-01-02T15:04:05.000000000Z"
type DescriptorActivation struct {
OrganizationID string `json:"organization_id"`
Signal model.Signal `json:"signal"`
Field string `json:"field"`
Previous int `json:"previous_version"`
Active int `json:"active_version"`
IndexedRows int64 `json:"indexed_rows"`
Descriptor schema.Descriptor `json:"descriptor"`
}
func (s *Store) namedLock(name string) *sync.Mutex {
value, _ := s.locks.LoadOrStore(name, &sync.Mutex{})
return value.(*sync.Mutex)
}
func ensureProjectionMetadata(ctx context.Context, db sqlExecutor) error {
statements := []string{
`CREATE TABLE IF NOT EXISTS projection_versions (
version INTEGER PRIMARY KEY CHECK(version >= 1 AND version <= 999999),
created_at TEXT NOT NULL,
activated_at TEXT NOT NULL
)`,
`CREATE TABLE IF NOT EXISTS projection_state (
id INTEGER PRIMARY KEY CHECK(id=1),
active_version INTEGER NOT NULL REFERENCES projection_versions(version)
)`,
`CREATE TABLE IF NOT EXISTS projection_descriptors (
version INTEGER NOT NULL REFERENCES projection_versions(version),
signal TEXT NOT NULL,
field TEXT NOT NULL,
descriptor_json TEXT NOT NULL,
PRIMARY KEY(version,signal,field)
)`,
`INSERT OR IGNORE INTO projection_versions(version,created_at,activated_at) VALUES(1,'1970-01-01T00:00:00Z','1970-01-01T00:00:00Z')`,
`INSERT OR IGNORE INTO projection_state(id,active_version) VALUES(1,1)`,
}
for _, statement := range statements {
if _, err := db.ExecContext(ctx, statement); err != nil {
return fmt.Errorf("migrate projection metadata: %w", err)
}
}
return nil
}
type sqlExecutor interface {
ExecContext(context.Context, string, ...any) (sql.Result, error)
}
func activeProjection(ctx context.Context, db interface {
QueryRowContext(context.Context, string, ...any) *sql.Row
QueryContext(context.Context, string, ...any) (*sql.Rows, error)
}) (int, query.MapRegistry, []schema.Descriptor, error) {
var version int
if err := db.QueryRowContext(ctx, `SELECT active_version FROM projection_state WHERE id=1`).Scan(&version); err != nil {
return 0, nil, nil, errors.New("read active projection version")
}
if version < 1 || version > maxProjectionVersion {
return 0, nil, nil, errors.New("active projection version is invalid")
}
rows, err := db.QueryContext(ctx, `SELECT descriptor_json FROM projection_descriptors WHERE version=? ORDER BY signal,field`, version)
if err != nil {
return 0, nil, nil, errors.New("read active projection descriptors")
}
defer rows.Close()
registry := query.MapRegistry{}
var descriptors []schema.Descriptor
for rows.Next() {
if len(descriptors) >= model.MaxDistinctFields {
return 0, nil, nil, errors.New("active projection descriptor limit exceeded")
}
var encoded string
if err = rows.Scan(&encoded); err != nil {
return 0, nil, nil, errors.New("read active projection descriptor")
}
var descriptor schema.Descriptor
if err = json.Unmarshal([]byte(encoded), &descriptor); err != nil || descriptor.Validate() != nil || descriptor.ProjectionVersion != version {
return 0, nil, nil, errors.New("active projection descriptor is invalid")
}
key := string(descriptor.Signal) + ":" + query.CanonicalField(descriptor.Field)
if _, exists := registry[key]; exists {
return 0, nil, nil, errors.New("active projection descriptor is duplicated")
}
registry[key] = descriptor
descriptors = append(descriptors, descriptor)
}
if err = rows.Err(); err != nil {
return 0, nil, nil, errors.New("read active projection descriptors")
}
return version, registry, descriptors, nil
}
func projectionIndexTable(version int) (string, error) {
if version < 2 || version > maxProjectionVersion {
return "", errors.New("indexed projection version is invalid")
}
return fmt.Sprintf("indexed_fields_v%06d", version), nil
}
func (s *Store) ActiveDescriptors(ctx context.Context, organizationID string) (query.MapRegistry, int, error) {
if err := model.ValidateSourceID(organizationID); err != nil {
return nil, 0, errors.New("invalid organization identifier")
}
path := filepath.Join(s.root, "organizations", organizationID, "projection.sqlite")
info, err := os.Lstat(path)
if errors.Is(err, os.ErrNotExist) {
return query.MapRegistry{}, 1, nil
}
if err != nil || !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 {
return nil, 0, errors.New("organization projection is unavailable")
}
dsn := (&url.URL{Scheme: "file", Path: path, RawQuery: "mode=ro"}).String()
db, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, 0, errors.New("open organization projection")
}
defer db.Close()
db.SetMaxOpenConns(1)
var metadataTables int
if err = db.QueryRowContext(ctx, `SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='projection_state'`).Scan(&metadataTables); err != nil {
return nil, 0, errors.New("inspect organization projection metadata")
}
if metadataTables == 0 {
return query.MapRegistry{}, 1, nil
}
version, registry, _, err := activeProjection(ctx, db)
return registry, version, err
}
func (s *Store) ActivateDescriptor(ctx context.Context, organizationID string, reviewed schema.Descriptor, now time.Time) (DescriptorActivation, error) {
if err := model.ValidateSourceID(organizationID); err != nil || reviewed.Validate() != nil || reviewed.ProjectionVersion != 1 || now.IsZero() {
return DescriptorActivation{}, errors.New("descriptor activation input is invalid")
}
proposal, err := s.descriptorProposal(ctx, organizationID, reviewed.Signal, reviewed.Field)
if err != nil {
return DescriptorActivation{}, err
}
if proposal.Status == "rejected" {
return DescriptorActivation{}, errors.New("rejected descriptor proposal cannot be activated")
}
if proposal.Proposal.Descriptor.Signal != reviewed.Signal || proposal.Proposal.Descriptor.Field != reviewed.Field {
return DescriptorActivation{}, errors.New("reviewed descriptor does not match proposal")
}
lock := s.namedLock("organization:" + organizationID)
lock.Lock()
defer lock.Unlock()
path := filepath.Join(s.root, "organizations", organizationID, "projection.sqlite")
info, err := os.Lstat(path)
if err != nil || !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 {
return DescriptorActivation{}, errors.New("organization projection is unavailable")
}
db, err := sql.Open("sqlite", path)
if err != nil {
return DescriptorActivation{}, errors.New("open organization projection")
}
defer db.Close()
db.SetMaxOpenConns(1)
for _, statement := range []string{`PRAGMA journal_mode=WAL`, `PRAGMA synchronous=FULL`, `PRAGMA busy_timeout=5000`, `PRAGMA foreign_keys=ON`} {
if _, err = db.ExecContext(ctx, statement); err != nil {
return DescriptorActivation{}, errors.New("configure organization projection")
}
}
if err = ensureProjectionMetadata(ctx, db); err != nil {
return DescriptorActivation{}, err
}
currentVersion, _, current, err := activeProjection(ctx, db)
if err != nil {
return DescriptorActivation{}, err
}
for _, descriptor := range current {
if descriptor.Signal == reviewed.Signal && descriptor.Field == reviewed.Field {
if sameDescriptorIgnoringProjection(descriptor, reviewed) {
if err = s.markProposalActivated(ctx, organizationID, descriptor); err != nil {
return DescriptorActivation{}, err
}
return DescriptorActivation{OrganizationID: organizationID, Signal: descriptor.Signal, Field: descriptor.Field, Previous: currentVersion, Active: currentVersion, Descriptor: descriptor}, nil
}
}
}
if proposal.Status == "activated" {
return DescriptorActivation{}, errors.New("activated descriptor revision requires a new review proposal")
}
controlTx, err := s.control.BeginTx(ctx, nil)
if err != nil {
return DescriptorActivation{}, errors.New("begin descriptor proposal claim")
}
defer controlTx.Rollback()
claim, err := controlTx.ExecContext(ctx, `UPDATE descriptor_proposals SET status=status WHERE organization_id=? AND signal=? AND field=? AND status='pending'`, organizationID, reviewed.Signal, reviewed.Field)
if err != nil {
return DescriptorActivation{}, errors.New("claim descriptor proposal")
}
if changed, _ := claim.RowsAffected(); changed != 1 {
return DescriptorActivation{}, errors.New("descriptor proposal is no longer pending")
}
if currentVersion >= maxProjectionVersion {
return DescriptorActivation{}, errors.New("projection version space exhausted")
}
nextVersion := currentVersion + 1
reviewed.ProjectionVersion = nextVersion
if err = reviewed.Validate(); err != nil {
return DescriptorActivation{}, errors.New("reviewed descriptor is invalid")
}
byKey := map[string]schema.Descriptor{}
for _, descriptor := range current {
descriptor.ProjectionVersion = nextVersion
byKey[string(descriptor.Signal)+":"+descriptor.Field] = descriptor
}
byKey[string(reviewed.Signal)+":"+reviewed.Field] = reviewed
if len(byKey) > model.MaxDistinctFields {
return DescriptorActivation{}, errors.New("active projection descriptor limit exceeded")
}
next := make([]schema.Descriptor, 0, len(byKey))
for _, descriptor := range byKey {
next = append(next, descriptor)
}
sort.Slice(next, func(i, j int) bool {
if next[i].Signal == next[j].Signal {
return next[i].Field < next[j].Field
}
return next[i].Signal < next[j].Signal
})
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return DescriptorActivation{}, errors.New("begin descriptor activation")
}
defer tx.Rollback()
timestamp := now.UTC().Format(time.RFC3339Nano)
if _, err = tx.ExecContext(ctx, `INSERT INTO projection_versions(version,created_at,activated_at) VALUES(?,?,?)`, nextVersion, timestamp, timestamp); err != nil {
return DescriptorActivation{}, errors.New("create projection version")
}
for _, descriptor := range next {
encoded, marshalErr := json.Marshal(descriptor)
if marshalErr != nil {
return DescriptorActivation{}, errors.New("encode active descriptor")
}
if _, err = tx.ExecContext(ctx, `INSERT INTO projection_descriptors(version,signal,field,descriptor_json) VALUES(?,?,?,?)`, nextVersion, descriptor.Signal, descriptor.Field, string(encoded)); err != nil {
return DescriptorActivation{}, errors.New("store active descriptor")
}
}
indexedRows, err := buildProjectionIndex(ctx, tx, nextVersion, next)
if err != nil {
return DescriptorActivation{}, err
}
result, err := tx.ExecContext(ctx, `UPDATE projection_state SET active_version=? WHERE id=1 AND active_version=?`, nextVersion, currentVersion)
if err != nil {
return DescriptorActivation{}, errors.New("activate projection version")
}
if changed, _ := result.RowsAffected(); changed != 1 {
return DescriptorActivation{}, errors.New("active projection changed during activation")
}
if err = tx.Commit(); err != nil {
return DescriptorActivation{}, errors.New("commit descriptor activation")
}
encoded, err := json.Marshal(reviewed)
if err != nil {
return DescriptorActivation{}, errors.New("encode activated descriptor")
}
result, err = controlTx.ExecContext(ctx, `UPDATE descriptor_proposals SET descriptor_json=?,status='activated' WHERE organization_id=? AND signal=? AND field=? AND status='pending'`, string(encoded), organizationID, reviewed.Signal, reviewed.Field)
if err != nil {
return DescriptorActivation{}, errors.New("acknowledge activated descriptor")
}
if changed, _ := result.RowsAffected(); changed != 1 {
return DescriptorActivation{}, errors.New("descriptor proposal changed during activation")
}
if err = controlTx.Commit(); err != nil {
return DescriptorActivation{}, errors.New("commit activated descriptor acknowledgement")
}
return DescriptorActivation{OrganizationID: organizationID, Signal: reviewed.Signal, Field: reviewed.Field, Previous: currentVersion, Active: nextVersion, IndexedRows: indexedRows, Descriptor: reviewed}, nil
}
func buildProjectionIndex(ctx context.Context, tx *sql.Tx, version int, descriptors []schema.Descriptor) (int64, error) {
table, err := projectionIndexTable(version)
if err != nil {
return 0, err
}
if _, err = tx.ExecContext(ctx, `CREATE TABLE `+table+` (
signal TEXT NOT NULL, field TEXT NOT NULL,
source_id TEXT NOT NULL, stream_id TEXT NOT NULL,
sequence INTEGER NOT NULL, record_index INTEGER NOT NULL,
timestamp TEXT NOT NULL, value_text TEXT NOT NULL, value_number REAL,
PRIMARY KEY(signal,field,source_id,stream_id,sequence,record_index)
) WITHOUT ROWID`); err != nil {
return 0, errors.New("create projection index")
}
indexed := map[string]schema.Descriptor{}
for _, descriptor := range descriptors {
if descriptor.Index != schema.IndexNone {
indexed[string(descriptor.Signal)+":"+descriptor.Field] = descriptor
}
}
rows, err := tx.QueryContext(ctx, `SELECT source_id,stream_id,sequence,record_index,signal,timestamp,attributes_json FROM observations ORDER BY source_id,stream_id,sequence,record_index`)
if err != nil {
return 0, errors.New("scan projection for index build")
}
statement, err := tx.PrepareContext(ctx, `INSERT INTO `+table+`(signal,field,source_id,stream_id,sequence,record_index,timestamp,value_text,value_number) VALUES(?,?,?,?,?,?,?,?,?)`)
if err != nil {
_ = rows.Close()
return 0, errors.New("prepare projection index build")
}
defer statement.Close()
var count int64
for rows.Next() {
var sourceID, streamID, signalText, timestamp, attributesJSON string
var sequence uint64
var recordIndex int
if err = rows.Scan(&sourceID, &streamID, &sequence, &recordIndex, &signalText, &timestamp, &attributesJSON); err != nil {
_ = rows.Close()
return 0, errors.New("read projection index source")
}
var attributes map[string]string
if err = json.Unmarshal([]byte(attributesJSON), &attributes); err != nil {
_ = rows.Close()
return 0, errors.New("decode projection index source")
}
for field, raw := range attributes {
descriptor, ok := indexed[signalText+":"+query.CanonicalField(field)]
if !ok {
continue
}
text, number, ok := indexValue(raw, descriptor.Type)
if !ok {
continue
}
if _, err = statement.ExecContext(ctx, signalText, descriptor.Field, sourceID, streamID, sequence, recordIndex, timestamp, text, number); err != nil {
_ = rows.Close()
return 0, errors.New("build projection index")
}
if count == math.MaxInt64 {
_ = rows.Close()
return 0, errors.New("projection index row count overflow")
}
count++
}
}
if err = rows.Close(); err != nil {
return 0, errors.New("close projection index source")
}
if err = rows.Err(); err != nil {
return 0, errors.New("scan projection for index build")
}
for _, suffix := range []struct{ name, columns string }{
{"exact", "signal,field,value_text,timestamp"},
{"number", "signal,field,value_number,timestamp"},
{"record", "source_id,stream_id,sequence,record_index,signal,field"},
} {
if _, err = tx.ExecContext(ctx, `CREATE INDEX `+table+`_`+suffix.name+` ON `+table+`(`+suffix.columns+`)`); err != nil {
return 0, errors.New("index activated projection")
}
}
return count, nil
}
func indexValue(raw string, valueType schema.Type) (string, any, bool) {
switch valueType {
case schema.TypeInteger:
value, err := strconv.ParseInt(raw, 10, 64)
return raw, value, err == nil
case schema.TypeFloat, schema.TypeDuration:
value, err := strconv.ParseFloat(raw, 64)
return raw, value, err == nil && !math.IsNaN(value) && !math.IsInf(value, 0)
case schema.TypeBoolean:
value, err := strconv.ParseBool(raw)
if err != nil {
return "", nil, false
}
return strconv.FormatBool(value), nil, true
case schema.TypeTime:
value, err := time.Parse(time.RFC3339Nano, raw)
if err != nil {
return "", nil, false
}
return value.UTC().Format(indexedTimeFormat), nil, true
case schema.TypeString:
return raw, nil, true
default:
return "", nil, false
}
}
func indexProjectedObservation(ctx context.Context, tx *sql.Tx, version int, descriptors []schema.Descriptor, batch model.Batch, recordIndex int, observation model.Observation) error {
if version == 1 {
return nil
}
table, err := projectionIndexTable(version)
if err != nil {
return err
}
indexed := map[string]schema.Descriptor{}
for _, descriptor := range descriptors {
if descriptor.Signal == batch.Signal && descriptor.Index != schema.IndexNone {
indexed[descriptor.Field] = descriptor
}
}
for field, raw := range observation.Attributes {
descriptor, ok := indexed[query.CanonicalField(field)]
if !ok {
continue
}
text, number, ok := indexValue(raw, descriptor.Type)
if !ok {
continue
}
_, err = tx.ExecContext(ctx, `INSERT OR IGNORE INTO `+table+`(signal,field,source_id,stream_id,sequence,record_index,timestamp,value_text,value_number) VALUES(?,?,?,?,?,?,?,?,?)`, batch.Signal, descriptor.Field, batch.SourceID, batch.StreamID, batch.Sequence, recordIndex, observation.Timestamp.UTC().Format(time.RFC3339Nano), text, number)
if err != nil {
return errors.New("index projected observation")
}
}
return nil
}
func sameDescriptorIgnoringProjection(left, right schema.Descriptor) bool {
left.ProjectionVersion = 1
right.ProjectionVersion = 1
leftJSON, _ := json.Marshal(left)
rightJSON, _ := json.Marshal(right)
return string(leftJSON) == string(rightJSON)
}
+308
View File
@@ -0,0 +1,308 @@
// SPDX-License-Identifier: AGPL-3.0-only
package storage
import (
"context"
"database/sql"
"encoding/json"
"errors"
"path/filepath"
"strings"
"testing"
"time"
"gamertan.com/observatory/internal/model"
"gamertan.com/observatory/internal/query"
"gamertan.com/observatory/internal/schema"
)
func TestIndexedTimeValuesUseFixedChronologicalUTCText(t *testing.T) {
earlier, _, ok := indexValue("2026-08-17T09:00:00Z", schema.TypeTime)
if !ok {
t.Fatal("earlier time rejected")
}
later, _, ok := indexValue("2026-08-17T09:00:00.1Z", schema.TypeTime)
if !ok {
t.Fatal("later time rejected")
}
if len(earlier) != len(later) || strings.Compare(earlier, later) >= 0 || earlier != "2026-08-17T09:00:00.000000000Z" {
t.Fatalf("earlier=%q later=%q", earlier, later)
}
}
func TestDescriptorActivationAndIngestionSerializeAcrossStoreInstances(t *testing.T) {
ctx := context.Background()
store := testStore(t)
defer store.Close()
second, err := Open(store.root)
if err != nil {
t.Fatal(err)
}
defer second.Close()
now := time.Date(2026, 8, 17, 9, 0, 0, 0, time.UTC)
token, err := store.CreateSource(ctx, "source-a", model.Scope{OrganizationID: "organization-a", ProjectID: "project-a", EnvironmentID: "production", ServiceID: "service-a"})
if err != nil {
t.Fatal(err)
}
first := model.Batch{Version: model.BatchVersion, SourceID: "source-a", StreamID: "metrics", Sequence: 1, ObservedAt: now, Signal: model.SignalMetrics, Records: []model.Observation{{Timestamp: now, Name: "queue.depth", Value: floatPointer(1), Attributes: map[string]string{"workshop.queue_depth": "1"}}}}
if _, err = store.Ingest(ctx, token, first, now); err != nil {
t.Fatal(err)
}
projectAll(t, store)
reviewed := schema.Descriptor{Version: schema.DescriptorVersion, Signal: model.SignalMetrics, Field: "workshop.queue_depth", Type: schema.TypeInteger, Meaning: "Number of work items waiting in the selected service queue.", Sensitivity: schema.SensitivityInternal, Cardinality: schema.CardinalityLow, Index: schema.IndexRange, Retention: schema.RetentionRaw, ProjectionVersion: 1}
secondBatch := first
secondBatch.Sequence = 2
secondBatch.ObservedAt = now.Add(time.Second)
secondBatch.Records = []model.Observation{{Timestamp: secondBatch.ObservedAt, Name: "queue.depth", Value: floatPointer(2), Attributes: map[string]string{"workshop.queue_depth": "2"}}}
start := make(chan struct{})
errorsFound := make(chan error, 2)
go func() {
<-start
_, activateErr := store.ActivateDescriptor(ctx, "organization-a", reviewed, now.Add(2*time.Second))
errorsFound <- activateErr
}()
go func() {
<-start
_, ingestErr := second.Ingest(ctx, token, secondBatch, now.Add(2*time.Second))
errorsFound <- ingestErr
}()
close(start)
for range 2 {
if runErr := <-errorsFound; runErr != nil {
t.Fatal(runErr)
}
}
projectAll(t, store)
path := filepath.Join(store.root, "organizations", "organization-a", "projection.sqlite")
db := openTestProjection(t, path)
defer db.Close()
var indexed int
if err = db.QueryRow(`SELECT COUNT(*) FROM indexed_fields_v000002`).Scan(&indexed); err != nil {
t.Fatal(err)
}
if indexed != 2 {
t.Fatalf("indexed=%d", indexed)
}
}
func TestDescriptorActivationBuildsBesideCurrentAndFeedsQueriesAndIngestion(t *testing.T) {
ctx := context.Background()
store := testStore(t)
defer store.Close()
now := time.Date(2026, 8, 17, 9, 0, 0, 0, time.UTC)
token, err := store.CreateSource(ctx, "source-a", model.Scope{OrganizationID: "organization-a", ProjectID: "project-a", EnvironmentID: "production", ServiceID: "service-a"})
if err != nil {
t.Fatal(err)
}
batch := model.Batch{Version: model.BatchVersion, SourceID: "source-a", StreamID: "metrics", Sequence: 1, ObservedAt: now, Signal: model.SignalMetrics, Records: []model.Observation{
{Timestamp: now, Name: "queue.depth", Value: floatPointer(10), Attributes: map[string]string{"workshop.queue_depth": "10", "workshop.mode": "fast"}},
{Timestamp: now.Add(time.Second), Name: "queue.depth", Value: floatPointer(11), Attributes: map[string]string{"workshop.queue_depth": "not-an-integer", "workshop.mode": "slow"}},
}}
if _, err = store.Ingest(ctx, token, batch, now.Add(time.Second)); err != nil {
t.Fatal(err)
}
projectAll(t, store)
ast, err := query.Parse(`metrics | where workshop.queue_depth >= 9 | limit 50`, 100)
if err != nil {
t.Fatal(err)
}
if _, err = store.Query(ctx, ast, query.Scope{OrganizationID: "organization-a"}, testQueryBudget(), now.Add(time.Minute)); !errors.Is(err, query.ErrSensitivePermissionRequired) {
t.Fatalf("unreviewed query err=%v", err)
}
reviewed := schema.Descriptor{
Version: schema.DescriptorVersion, Signal: model.SignalMetrics, Field: "workshop.queue_depth",
Type: schema.TypeInteger, Meaning: "Number of work items waiting in the selected service queue.",
Sensitivity: schema.SensitivityInternal, Cardinality: schema.CardinalityLow,
Index: schema.IndexRange, Retention: schema.RetentionRaw, ProjectionVersion: 1,
}
activation, err := store.ActivateDescriptor(ctx, "organization-a", reviewed, now.Add(2*time.Minute))
if err != nil {
t.Fatal(err)
}
if activation.Previous != 1 || activation.Active != 2 || activation.IndexedRows != 1 || activation.Descriptor.ProjectionVersion != 2 {
t.Fatalf("activation=%+v", activation)
}
registry, version, err := store.ActiveDescriptors(ctx, "organization-a")
if err != nil || version != 2 {
t.Fatalf("version=%d registry=%+v err=%v", version, registry, err)
}
descriptor, ok := registry.Lookup(model.SignalMetrics, "workshop.queue_depth")
if !ok || descriptor.ProjectionVersion != 2 || descriptor.Sensitivity != schema.SensitivityInternal || descriptor.Index != schema.IndexRange {
t.Fatalf("descriptor=%+v ok=%t", descriptor, ok)
}
result, err := store.Query(ctx, ast, query.Scope{OrganizationID: "organization-a"}, testQueryBudget(), now.Add(3*time.Minute))
if err != nil {
t.Fatal(err)
}
if len(result.Rows) != 1 || len(result.Explain.Fields) != 1 || !result.Explain.Fields[0].Indexed || result.Explain.Fields[0].Unknown {
t.Fatalf("result=%+v", result)
}
batch.Sequence = 2
batch.ObservedAt = now.Add(4 * time.Minute)
batch.Records = []model.Observation{{Timestamp: batch.ObservedAt, Name: "queue.depth", Value: floatPointer(12), Attributes: map[string]string{"workshop.queue_depth": "12", "workshop.mode": "fast"}}}
if _, err = store.Ingest(ctx, token, batch, batch.ObservedAt); err != nil {
t.Fatal(err)
}
projectAll(t, store)
result, err = store.Query(ctx, ast, query.Scope{OrganizationID: "organization-a"}, testQueryBudget(), now.Add(5*time.Minute))
if err != nil || len(result.Rows) != 2 {
t.Fatalf("rows=%d err=%v", len(result.Rows), err)
}
path := filepath.Join(store.root, "organizations", "organization-a", "projection.sqlite")
db := openTestProjection(t, path)
defer db.Close()
var active, indexed int
if err = db.QueryRow(`SELECT active_version FROM projection_state WHERE id=1`).Scan(&active); err != nil {
t.Fatal(err)
}
if err = db.QueryRow(`SELECT COUNT(*) FROM indexed_fields_v000002`).Scan(&indexed); err != nil {
t.Fatal(err)
}
if active != 2 || indexed != 2 {
t.Fatalf("active=%d indexed=%d", active, indexed)
}
statement, arguments, err := projectionSelection(ast, query.Scope{OrganizationID: "organization-a"}, registry, version, now.Add(5*time.Minute))
if err != nil {
t.Fatal(err)
}
planRows, err := db.Query(`EXPLAIN QUERY PLAN `+statement, arguments...)
if err != nil {
t.Fatal(err)
}
var plan strings.Builder
for planRows.Next() {
var id, parent, unused int
var detail string
if err = planRows.Scan(&id, &parent, &unused, &detail); err != nil {
t.Fatal(err)
}
plan.WriteString(detail)
plan.WriteByte('\n')
}
if err = planRows.Close(); err != nil {
t.Fatal(err)
}
if !strings.Contains(plan.String(), "indexed_fields_v000002_number") {
t.Fatalf("custom range index absent from query plan:\n%s", plan.String())
}
preActivationDescriptor, err := json.Marshal(reviewed)
if err != nil {
t.Fatal(err)
}
if _, err = store.control.Exec(`UPDATE descriptor_proposals SET descriptor_json=?,status='pending' WHERE organization_id='organization-a' AND signal='metrics' AND field='workshop.queue_depth'`, string(preActivationDescriptor)); err != nil {
t.Fatal(err)
}
retry, err := store.ActivateDescriptor(ctx, "organization-a", reviewed, now.Add(6*time.Minute))
if err != nil || retry.Active != 2 || retry.Previous != 2 || retry.IndexedRows != 0 {
t.Fatalf("retry=%+v err=%v", retry, err)
}
mode := schema.Descriptor{
Version: schema.DescriptorVersion, Signal: model.SignalMetrics, Field: "workshop.mode",
Type: schema.TypeString, Meaning: "Reviewed operating mode label for the workshop queue.",
Sensitivity: schema.SensitivityInternal, Cardinality: schema.CardinalityLow,
Index: schema.IndexExact, Retention: schema.RetentionRaw, ProjectionVersion: 1,
}
second, err := store.ActivateDescriptor(ctx, "organization-a", mode, now.Add(7*time.Minute))
if err != nil || second.Previous != 2 || second.Active != 3 || second.IndexedRows != 5 {
t.Fatalf("second=%+v err=%v", second, err)
}
var retained, current int
if err = db.QueryRow(`SELECT COUNT(*) FROM indexed_fields_v000002`).Scan(&retained); err != nil {
t.Fatal(err)
}
if err = db.QueryRow(`SELECT COUNT(*) FROM indexed_fields_v000003`).Scan(&current); err != nil {
t.Fatal(err)
}
if retained != 2 || current != 5 {
t.Fatalf("retained=%d current=%d", retained, current)
}
result, err = store.Query(ctx, ast, query.Scope{OrganizationID: "organization-a"}, testQueryBudget(), now.Add(8*time.Minute))
if err != nil || len(result.Rows) != 2 {
t.Fatalf("version-three rows=%d err=%v", len(result.Rows), err)
}
proposals, err := store.DescriptorProposals(ctx, "organization-a")
if err != nil || len(proposals) != 2 || proposals[0].Status != "activated" || proposals[1].Status != "activated" {
t.Fatalf("proposals=%+v err=%v", proposals, err)
}
}
func TestDescriptorActivationFailureKeepsPriorVersion(t *testing.T) {
ctx := context.Background()
store := testStore(t)
defer store.Close()
now := time.Date(2026, 8, 17, 9, 0, 0, 0, time.UTC)
token, err := store.CreateSource(ctx, "source-a", model.Scope{OrganizationID: "organization-a", ProjectID: "project-a", EnvironmentID: "production", ServiceID: "service-a"})
if err != nil {
t.Fatal(err)
}
batch := model.Batch{Version: model.BatchVersion, SourceID: "source-a", StreamID: "logs", Sequence: 1, ObservedAt: now, Signal: model.SignalLogs, Records: []model.Observation{{Timestamp: now, Name: "request", Attributes: map[string]string{"workshop.label": "ready"}}}}
if _, err = store.Ingest(ctx, token, batch, now); err != nil {
t.Fatal(err)
}
projectAll(t, store)
path := filepath.Join(store.root, "organizations", "organization-a", "projection.sqlite")
db := openTestProjection(t, path)
if _, err = db.Exec(`DROP TABLE observations`); err != nil {
t.Fatal(err)
}
db.Close()
reviewed := schema.Descriptor{Version: schema.DescriptorVersion, Signal: model.SignalLogs, Field: "workshop.label", Type: schema.TypeString, Meaning: "Reviewed workshop state label.", Sensitivity: schema.SensitivityInternal, Cardinality: schema.CardinalityLow, Index: schema.IndexExact, Retention: schema.RetentionRaw, ProjectionVersion: 1}
if _, err = store.ActivateDescriptor(ctx, "organization-a", reviewed, now.Add(time.Minute)); err == nil {
t.Fatal("activation unexpectedly succeeded without the source projection")
}
db = openTestProjection(t, path)
defer db.Close()
var active, versions int
if err = db.QueryRow(`SELECT active_version FROM projection_state WHERE id=1`).Scan(&active); err != nil {
t.Fatal(err)
}
if err = db.QueryRow(`SELECT COUNT(*) FROM projection_versions`).Scan(&versions); err != nil {
t.Fatal(err)
}
if active != 1 || versions != 1 {
t.Fatalf("active=%d versions=%d", active, versions)
}
proposal, err := store.descriptorProposal(ctx, "organization-a", model.SignalLogs, "workshop.label")
if err != nil || proposal.Status != "pending" {
t.Fatalf("proposal=%+v err=%v", proposal, err)
}
}
func TestDescriptorProposalRejectionIsIdempotentAndBlocksActivation(t *testing.T) {
ctx := context.Background()
store := testStore(t)
defer store.Close()
now := time.Date(2026, 8, 17, 9, 0, 0, 0, time.UTC)
token, err := store.CreateSource(ctx, "source-a", model.Scope{OrganizationID: "organization-a", ProjectID: "project-a", EnvironmentID: "production", ServiceID: "service-a"})
if err != nil {
t.Fatal(err)
}
batch := model.Batch{Version: model.BatchVersion, SourceID: "source-a", StreamID: "logs", Sequence: 1, ObservedAt: now, Signal: model.SignalLogs, Records: []model.Observation{{Timestamp: now, Name: "request", Attributes: map[string]string{"workshop.label": "ready"}}}}
if _, err = store.Ingest(ctx, token, batch, now); err != nil {
t.Fatal(err)
}
projectAll(t, store)
if err = store.RejectDescriptorProposal(ctx, "organization-a", model.SignalLogs, "workshop.label"); err != nil {
t.Fatal(err)
}
if err = store.RejectDescriptorProposal(ctx, "organization-a", model.SignalLogs, "workshop.label"); err != nil {
t.Fatal(err)
}
reviewed := schema.Descriptor{Version: schema.DescriptorVersion, Signal: model.SignalLogs, Field: "workshop.label", Type: schema.TypeString, Meaning: "Reviewed workshop state label.", Sensitivity: schema.SensitivityInternal, Cardinality: schema.CardinalityLow, Index: schema.IndexExact, Retention: schema.RetentionRaw, ProjectionVersion: 1}
if _, err = store.ActivateDescriptor(ctx, "organization-a", reviewed, now.Add(time.Minute)); err == nil {
t.Fatal("rejected proposal was activated")
}
}
func openTestProjection(t *testing.T, path string) *sql.DB {
t.Helper()
db, err := sql.Open("sqlite", path)
if err != nil {
t.Fatal(err)
}
return db
}
+68
View File
@@ -0,0 +1,68 @@
// SPDX-License-Identifier: AGPL-3.0-only
package storage
import (
"context"
"database/sql"
"errors"
"fmt"
)
const baseIndexVersion = 1
var presenceIndexStatements = []string{
`CREATE INDEX IF NOT EXISTS observations_value ON observations(signal,value,timestamp) WHERE value IS NOT NULL`,
`CREATE INDEX IF NOT EXISTS observations_http_route ON observations(signal,json_extract(attributes_json,'$."http.route"'),timestamp) WHERE json_extract(attributes_json,'$."http.route"') IS NOT NULL`,
`CREATE INDEX IF NOT EXISTS observations_http_status ON observations(signal,CAST(json_extract(attributes_json,'$."http.status_code"') AS INTEGER),timestamp) WHERE CAST(json_extract(attributes_json,'$."http.status_code"') AS INTEGER) IS NOT NULL`,
`CREATE INDEX IF NOT EXISTS observations_duration ON observations(signal,CAST(json_extract(attributes_json,'$."duration_ns"') AS REAL),timestamp) WHERE CAST(json_extract(attributes_json,'$."duration_ns"') AS REAL) IS NOT NULL`,
}
// ensureBaseIndexes migrates fields that are absent from most signal types to
// presence-only indexes. The DDL and migration marker share one SQLite
// transaction: an interrupted migration retains the complete previous index
// set and retries before the projection is served.
func ensureBaseIndexes(ctx context.Context, db *sql.DB) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("begin base index migration: %w", err)
}
defer tx.Rollback()
columns, err := sqliteColumns(tx, "storage_projection_state")
if err != nil {
return err
}
if !columns["base_index_version"] {
if _, err = tx.ExecContext(ctx, `ALTER TABLE storage_projection_state ADD COLUMN base_index_version INTEGER NOT NULL DEFAULT 0 CHECK(base_index_version BETWEEN 0 AND 1)`); err != nil {
return errors.New("add base index migration state")
}
}
var version int
if err = tx.QueryRowContext(ctx, `SELECT base_index_version FROM storage_projection_state WHERE id=1`).Scan(&version); err != nil {
return errors.New("read base index migration state")
}
if version < 0 || version > baseIndexVersion {
return errors.New("unsupported base index version")
}
if version == 0 {
for _, name := range []string{"observations_value", "observations_http_route", "observations_http_status", "observations_duration"} {
if _, err = tx.ExecContext(ctx, `DROP INDEX IF EXISTS `+name); err != nil {
return errors.New("remove superseded base index")
}
}
}
for _, statement := range presenceIndexStatements {
if _, err = tx.ExecContext(ctx, statement); err != nil {
return errors.New("create presence-only base index")
}
}
if version == 0 {
if _, err = tx.ExecContext(ctx, `UPDATE storage_projection_state SET base_index_version=? WHERE id=1 AND base_index_version=0`, baseIndexVersion); err != nil {
return errors.New("activate base index migration")
}
}
if err = tx.Commit(); err != nil {
return errors.New("commit base index migration")
}
return nil
}
+109
View File
@@ -0,0 +1,109 @@
// SPDX-License-Identifier: AGPL-3.0-only
package storage
import (
"database/sql"
"os"
"path/filepath"
"strings"
"testing"
"time"
"gamertan.com/observatory/internal/model"
"gamertan.com/observatory/internal/query"
)
func TestBaseIndexMigrationReplacesLegacyFullIndexes(t *testing.T) {
root := filepath.Join(t.TempDir(), "data")
dir := filepath.Join(root, "organizations", "legacy")
if err := os.MkdirAll(dir, 0o700); err != nil {
t.Fatal(err)
}
path := filepath.Join(dir, "projection.sqlite")
legacy, err := sql.Open("sqlite", path)
if err != nil {
t.Fatal(err)
}
statements := []string{
`CREATE TABLE observations (organization_id TEXT NOT NULL,project_id TEXT NOT NULL,environment_id TEXT NOT NULL,service_id TEXT NOT NULL,source_id TEXT NOT NULL,stream_id TEXT NOT NULL,sequence INTEGER NOT NULL,record_index INTEGER NOT NULL,signal TEXT NOT NULL,timestamp TEXT NOT NULL,name TEXT NOT NULL,severity TEXT,body TEXT,value REAL,trace_id TEXT,span_id TEXT,correlation_id TEXT,attributes_json TEXT NOT NULL,segment_digest TEXT NOT NULL,PRIMARY KEY(source_id,stream_id,sequence,record_index))`,
`CREATE TABLE storage_projection_state (id INTEGER PRIMARY KEY CHECK(id=1),metric_rollup_version INTEGER NOT NULL CHECK(metric_rollup_version BETWEEN 0 AND 1))`,
`INSERT INTO storage_projection_state(id,metric_rollup_version) VALUES(1,1)`,
`CREATE INDEX observations_value ON observations(signal,value,timestamp)`,
`CREATE INDEX observations_http_route ON observations(signal,json_extract(attributes_json,'$."http.route"'),timestamp)`,
`CREATE INDEX observations_http_status ON observations(signal,CAST(json_extract(attributes_json,'$."http.status_code"') AS INTEGER),timestamp)`,
`CREATE INDEX observations_duration ON observations(signal,CAST(json_extract(attributes_json,'$."duration_ns"') AS REAL),timestamp)`,
}
for _, statement := range statements {
if _, err = legacy.Exec(statement); err != nil {
legacy.Close()
t.Fatal(err)
}
}
if err = legacy.Close(); err != nil {
t.Fatal(err)
}
db, err := openProjection(t.Context(), path)
if err != nil {
t.Fatal(err)
}
defer db.Close()
var version int
if err = db.QueryRow(`SELECT base_index_version FROM storage_projection_state WHERE id=1`).Scan(&version); err != nil || version != baseIndexVersion {
t.Fatalf("base index version=%d err=%v", version, err)
}
for _, name := range []string{"observations_value", "observations_http_route", "observations_http_status", "observations_duration"} {
var definition string
if err = db.QueryRow(`SELECT sql FROM sqlite_schema WHERE type='index' AND name=?`, name).Scan(&definition); err != nil {
t.Fatal(err)
}
if !strings.Contains(strings.ToUpper(definition), " WHERE ") || !strings.Contains(strings.ToUpper(definition), " IS NOT NULL") {
t.Fatalf("index %s was not migrated: %s", name, definition)
}
}
}
func TestPresenceIndexesSupportSelectiveQueries(t *testing.T) {
ctx := t.Context()
store := testStore(t)
defer store.Close()
now := time.Now().UTC()
scope := model.Scope{OrganizationID: "organization-a", ProjectID: "project-a", EnvironmentID: "production", ServiceID: "service-a"}
logToken, err := store.CreateSource(ctx, "source-logs", scope)
if err != nil {
t.Fatal(err)
}
logs := model.Batch{Version: model.BatchVersion, SourceID: "source-logs", StreamID: "logs", Sequence: 1, ObservedAt: now, Signal: model.SignalLogs, Records: []model.Observation{
{Timestamp: now, Name: "http.server.request", Severity: "error", Attributes: map[string]string{"http.route": "/items", "http.status_code": "503", "duration_ns": "500"}},
{Timestamp: now.Add(-time.Second), Name: "application.event", Severity: "information", Attributes: map[string]string{}},
}}
if _, err = store.Ingest(ctx, logToken, logs, now); err != nil {
t.Fatal(err)
}
metricToken, err := store.CreateSource(ctx, "source-metrics", scope)
if err != nil {
t.Fatal(err)
}
value := 42.0
metrics := model.Batch{Version: model.BatchVersion, SourceID: "source-metrics", StreamID: "metrics", Sequence: 1, ObservedAt: now, Signal: model.SignalMetrics, Records: []model.Observation{{Timestamp: now, Name: "system.cpu.utilization", Value: &value}}}
if _, err = store.Ingest(ctx, metricToken, metrics, now); err != nil {
t.Fatal(err)
}
projectAll(t, store)
queries := []string{
`logs | where route == "/items" | summarize count() by status | limit 10`,
`logs | where status >= 500 | summarize p95(duration) by route | limit 10`,
`logs | where duration >= 100 | summarize count() by route | limit 10`,
`metrics | where value >= 1 | summarize count() by name | limit 10`,
}
for _, text := range queries {
ast, parseErr := query.Parse(text, 10)
if parseErr != nil {
t.Fatalf("parse %q: %v", text, parseErr)
}
result, queryErr := store.Query(ctx, ast, query.Scope{OrganizationID: scope.OrganizationID}, testQueryBudget(), now)
if queryErr != nil || len(result.Rows) == 0 {
t.Fatalf("query %q rows=%d err=%v", text, len(result.Rows), queryErr)
}
}
}
+168
View File
@@ -0,0 +1,168 @@
// SPDX-License-Identifier: AGPL-3.0-only
package storage
import (
"context"
"database/sql"
"errors"
"fmt"
"math"
"time"
"gamertan.com/observatory/internal/model"
)
func migrateControlBatchEnvelopes(db *sql.DB) error {
tx, err := db.Begin()
if err != nil {
return errors.New("begin batch envelope migration")
}
defer tx.Rollback()
for _, statement := range []string{
`ALTER TABLE streams ADD COLUMN last_batch_digest TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE streams ADD COLUMN last_wire_digest TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE streams ADD COLUMN last_signal TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE streams ADD COLUMN last_record_count INTEGER NOT NULL DEFAULT 0 CHECK(last_record_count BETWEEN 0 AND 5000)`,
`ALTER TABLE streams ADD COLUMN last_encoded_bytes INTEGER NOT NULL DEFAULT 0 CHECK(last_encoded_bytes >= 0)`,
`ALTER TABLE streams ADD COLUMN last_first_observed_at TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE streams ADD COLUMN last_last_observed_at TEXT NOT NULL DEFAULT ''`,
`UPDATE schema_version SET version=11 WHERE version=10`,
} {
if _, err = tx.Exec(statement); err != nil {
return fmt.Errorf("migrate batch envelope metadata: %w", err)
}
}
if err = tx.Commit(); err != nil {
return errors.New("commit batch envelope migration")
}
return nil
}
type streamWatermark struct {
sequence uint64
segmentDigest string
envelope model.BatchEnvelope
found bool
framed bool
}
func (s *Store) streamWatermark(ctx context.Context, sourceID, streamID string) (streamWatermark, error) {
var watermark streamWatermark
var signal, first, last string
err := s.control.QueryRowContext(ctx, `SELECT last_sequence,last_digest,last_batch_digest,last_wire_digest,last_signal,last_record_count,last_encoded_bytes,last_first_observed_at,last_last_observed_at FROM streams WHERE source_id=? AND stream_id=?`, sourceID, streamID).Scan(
&watermark.sequence, &watermark.segmentDigest, &watermark.envelope.BatchDigest,
&watermark.envelope.WireDigest, &signal, &watermark.envelope.RecordCount,
&watermark.envelope.EncodedBytes, &first, &last,
)
if errors.Is(err, sql.ErrNoRows) {
return streamWatermark{}, nil
}
if err != nil {
return streamWatermark{}, fmt.Errorf("read framed stream watermark: %w", err)
}
watermark.found = true
if watermark.envelope.BatchDigest == "" && watermark.envelope.WireDigest == "" {
return watermark, nil
}
firstObserved, firstErr := time.Parse(time.RFC3339Nano, first)
lastObserved, lastErr := time.Parse(time.RFC3339Nano, last)
if firstErr != nil || lastErr != nil {
return streamWatermark{}, errors.New("stored batch envelope time range is invalid")
}
watermark.envelope.Version = model.BatchEnvelopeVersion
watermark.envelope.StreamID = streamID
watermark.envelope.Sequence = watermark.sequence
watermark.envelope.Signal = model.Signal(signal)
watermark.envelope.FirstObservedAt = firstObserved.UTC()
watermark.envelope.LastObservedAt = lastObserved.UTC()
if err = watermark.envelope.Validate(math.MaxInt64); err != nil {
return streamWatermark{}, errors.New("stored batch envelope is invalid")
}
watermark.framed = true
return watermark, nil
}
func (s *Store) checkEnvelope(ctx context.Context, source Source, envelope model.BatchEnvelope) (Ack, bool, error) {
watermark, err := s.streamWatermark(ctx, source.ID, envelope.StreamID)
if err != nil {
return Ack{}, false, err
}
if !watermark.found {
if envelope.Sequence != 1 {
return Ack{}, false, errors.New("sequence gap")
}
return Ack{}, false, nil
}
if envelope.Sequence < watermark.sequence {
return Ack{}, false, errors.New("sequence replay is older than acknowledged watermark")
}
if watermark.sequence != ^uint64(0) && envelope.Sequence > watermark.sequence+1 {
return Ack{}, false, errors.New("sequence gap")
}
if envelope.Sequence == watermark.sequence {
if !watermark.framed {
return Ack{}, false, nil
}
if envelope != watermark.envelope {
return Ack{}, false, errors.New("acknowledged sequence reused with different envelope")
}
return Ack{SourceID: source.ID, StreamID: envelope.StreamID, Sequence: envelope.Sequence, Digest: watermark.segmentDigest, BatchDigest: envelope.BatchDigest, Duplicate: true}, true, nil
}
return Ack{}, false, nil
}
// CheckNativeReplay performs a cheap, read-only envelope lookup before the
// request body is decoded. Callers must still hash the complete bounded body
// and ConfirmNativeReplay before acknowledging it.
func (s *Store) CheckNativeReplay(ctx context.Context, token string, envelope model.BatchEnvelope) (Ack, bool, error) {
if err := envelope.Validate(math.MaxInt64); err != nil {
return Ack{}, false, err
}
source, err := s.Authenticate(ctx, token)
if err != nil {
return Ack{}, false, err
}
return s.checkEnvelope(ctx, source, envelope)
}
func (s *Store) ConfirmNativeReplay(ctx context.Context, token string, envelope model.BatchEnvelope) (Ack, error) {
if err := envelope.Validate(math.MaxInt64); err != nil {
return Ack{}, err
}
source, err := s.Authenticate(ctx, token)
if err != nil {
return Ack{}, err
}
lock := s.sourceLock(source.ID)
lock.Lock()
defer lock.Unlock()
ack, exact, err := s.checkEnvelope(ctx, source, envelope)
if err != nil {
return Ack{}, err
}
if !exact {
return Ack{}, errors.New("batch is not an acknowledged exact replay")
}
return ack, nil
}
func envelopeSQL(envelope *model.BatchEnvelope) (batchDigest, wireDigest, signal string, recordCount int, encodedBytes int64, first, last string) {
if envelope == nil {
return "", "", "", 0, 0, "", ""
}
return envelope.BatchDigest, envelope.WireDigest, string(envelope.Signal), envelope.RecordCount, envelope.EncodedBytes, envelope.FirstObservedAt.UTC().Format(time.RFC3339Nano), envelope.LastObservedAt.UTC().Format(time.RFC3339Nano)
}
func (s *Store) backfillAcknowledgedEnvelope(ctx context.Context, batch model.Batch, segmentDigest string, envelope model.BatchEnvelope) error {
batchDigest, wireDigest, signal, recordCount, encodedBytes, first, last := envelopeSQL(&envelope)
result, err := s.control.ExecContext(ctx, `UPDATE streams SET last_batch_digest=?,last_wire_digest=?,last_signal=?,last_record_count=?,last_encoded_bytes=?,last_first_observed_at=?,last_last_observed_at=? WHERE source_id=? AND stream_id=? AND last_sequence=? AND last_digest=? AND last_batch_digest='' AND last_wire_digest=''`, batchDigest, wireDigest, signal, recordCount, encodedBytes, first, last, batch.SourceID, batch.StreamID, batch.Sequence, segmentDigest)
if err != nil {
return fmt.Errorf("backfill acknowledged batch envelope: %w", err)
}
n, _ := result.RowsAffected()
if n != 1 {
return errors.New("acknowledged batch envelope state changed")
}
return nil
}
+29
View File
@@ -0,0 +1,29 @@
// SPDX-License-Identifier: AGPL-3.0-only
package storage
import (
"database/sql"
"errors"
"fmt"
)
func migrateControlBatchMetadata(db *sql.DB) error {
tx, err := db.Begin()
if err != nil {
return errors.New("begin batch metadata migration")
}
defer tx.Rollback()
for _, statement := range []string{
`ALTER TABLE segments ADD COLUMN record_count INTEGER NOT NULL DEFAULT 0 CHECK(record_count BETWEEN 0 AND 5000)`,
`UPDATE schema_version SET version=10 WHERE version=9`,
} {
if _, err = tx.Exec(statement); err != nil {
return fmt.Errorf("migrate batch metadata: %w", err)
}
}
if err = tx.Commit(); err != nil {
return errors.New("commit batch metadata migration")
}
return nil
}
+35
View File
@@ -0,0 +1,35 @@
// SPDX-License-Identifier: AGPL-3.0-only
package storage
import (
"context"
"testing"
"time"
"gamertan.com/observatory/internal/model"
)
func TestCommittedSegmentRecordsBoundedBatchMetadata(t *testing.T) {
store := testStore(t)
defer store.Close()
ctx := context.Background()
now := time.Date(2026, 8, 19, 0, 10, 0, 0, time.UTC)
token, err := store.CreateSource(ctx, "source-a", model.Scope{OrganizationID: "organization-a", ProjectID: "project-a", EnvironmentID: "production", ServiceID: "service-a"})
if err != nil {
t.Fatal(err)
}
batch := model.Batch{Version: model.BatchVersion, SourceID: "source-a", StreamID: "requests", Sequence: 1, ObservedAt: now, Signal: model.SignalLogs, Records: []model.Observation{{Timestamp: now.Add(-time.Second), Name: "first"}, {Timestamp: now, Name: "second"}}}
ack, err := store.Ingest(ctx, token, batch, now)
if err != nil {
t.Fatal(err)
}
var count int
var first, last string
if err = store.control.QueryRowContext(ctx, `SELECT record_count,first_observed_at,last_observed_at FROM segments WHERE digest=?`, ack.Digest).Scan(&count, &first, &last); err != nil {
t.Fatal(err)
}
if count != 2 || first != now.Add(-time.Second).Format(time.RFC3339Nano) || last != now.Format(time.RFC3339Nano) {
t.Fatalf("count=%d first=%q last=%q", count, first, last)
}
}
+129
View File
@@ -0,0 +1,129 @@
// SPDX-License-Identifier: AGPL-3.0-only
package storage
import (
"context"
"database/sql"
"errors"
"fmt"
"math"
"path/filepath"
"time"
"gamertan.com/observatory/internal/model"
"gamertan.com/observatory/internal/query"
)
const maxRawQuerySegments = 1_000_000
type rawQuerySegment struct {
digest, path, sourceID, streamID string
projectID, environmentID, serviceID string
tier string
sequence uint64
uncompressedBytes int64
firstObservedAt, lastObservedAt time.Time
}
func (s *Store) coldSegmentsForQuery(ctx context.Context, ast query.AST, scope query.Scope, now time.Time) ([]rawQuerySegment, int64, error) {
return s.rawSegmentsForQuery(ctx, ast, scope, now, false)
}
func (s *Store) allRawSegmentsForQuery(ctx context.Context, ast query.AST, scope query.Scope, now time.Time) ([]rawQuerySegment, int64, error) {
return s.rawSegmentsForQuery(ctx, ast, scope, now, true)
}
func (s *Store) rawSegmentsForQuery(ctx context.Context, ast query.AST, scope query.Scope, now time.Time, includeHot bool) ([]rawQuerySegment, int64, error) {
statement := `SELECT segment.digest,segment.path,segment.source_id,segment.stream_id,segment.sequence,segment.uncompressed_bytes,segment.first_observed_at,segment.last_observed_at,segment.tier,segment.archiving_at,segment.retiring_at,source.project_id,source.environment_id,source.service_id FROM segments segment JOIN sources source ON source.id=segment.source_id WHERE segment.organization_id=? AND source.organization_id=segment.organization_id AND segment.signal=?`
if includeHot {
statement += ` AND segment.tier IN ('hot','cold')`
} else {
statement += ` AND segment.tier='cold' AND segment.retiring_at IS NULL`
}
arguments := []any{scope.OrganizationID, ast.Signal}
for _, selected := range []struct{ column, value string }{{"source.project_id", scope.ProjectID}, {"source.environment_id", scope.EnvironmentID}, {"source.service_id", scope.ServiceID}} {
if selected.value != "" {
statement += " AND " + selected.column + "=?"
arguments = append(arguments, selected.value)
}
}
if ast.Window > 0 {
statement += " AND segment.last_observed_at>=?"
arguments = append(arguments, now.UTC().Add(-ast.Window).Format(time.RFC3339Nano))
}
statement += ` ORDER BY segment.last_observed_at DESC,segment.source_id,segment.stream_id,segment.sequence DESC`
rows, err := s.control.QueryContext(ctx, statement, arguments...)
if err != nil {
return nil, 0, errors.New("list raw query segments")
}
defer rows.Close()
segments := make([]rawQuerySegment, 0)
var estimated int64
for rows.Next() {
if len(segments) >= maxRawQuerySegments {
return nil, 0, errors.New("raw query segment limit exceeded")
}
var segment rawQuerySegment
var firstText, lastText string
var archivingAt, retiringAt sql.NullString
if err = rows.Scan(&segment.digest, &segment.path, &segment.sourceID, &segment.streamID, &segment.sequence, &segment.uncompressedBytes, &firstText, &lastText, &segment.tier, &archivingAt, &retiringAt, &segment.projectID, &segment.environmentID, &segment.serviceID); err != nil {
return nil, 0, errors.New("read raw query segment")
}
if includeHot && (archivingAt.Valid || retiringAt.Valid) {
return nil, 0, errors.New("raw query segment transition is incomplete")
}
segment.firstObservedAt, err = time.Parse(time.RFC3339Nano, firstText)
if err != nil {
return nil, 0, errors.New("raw query segment range is invalid")
}
segment.lastObservedAt, err = time.Parse(time.RFC3339Nano, lastText)
if err != nil || segment.lastObservedAt.Before(segment.firstObservedAt) || segment.uncompressedBytes < 1 {
return nil, 0, errors.New("raw query segment range is invalid")
}
var expected string
var pathErr error
if segment.tier == "cold" {
expected, pathErr = s.coldArchivePath(scope.OrganizationID, archivingSegment{digest: segment.digest, path: segment.path, sourceID: segment.sourceID, streamID: segment.streamID, signal: ast.Signal})
} else if segment.tier == "hot" && model.ValidateSourceID(scope.OrganizationID) == nil && model.ValidateSourceID(segment.sourceID) == nil && model.ValidateStreamID(segment.streamID) == nil {
expected = filepath.Join(s.root, "raw", scope.OrganizationID, segment.sourceID, segment.streamID, fmt.Sprintf("%020d-%s.zst", segment.sequence, segment.digest))
} else {
pathErr = errors.New("unsupported raw segment tier")
}
if pathErr != nil || expected != segment.path {
return nil, 0, errors.New("raw query segment path is invalid")
}
if segment.uncompressedBytes > math.MaxInt64-estimated {
return nil, 0, errors.New("raw query estimate overflow")
}
estimated += segment.uncompressedBytes
segments = append(segments, segment)
}
if err = rows.Err(); err != nil {
return nil, 0, errors.New("list raw query segments")
}
return segments, estimated, nil
}
func rawRecord(segment rawQuerySegment, batch model.Batch, index int) projectedRecord {
observation := batch.Records[index]
return projectedRecord{
projectID: segment.projectID, environmentID: segment.environmentID, serviceID: segment.serviceID,
sourceID: segment.sourceID, streamID: segment.streamID, sequence: batch.Sequence, recordIndex: index,
signal: batch.Signal, timestamp: observation.Timestamp.UTC(), name: observation.Name, severity: observation.Severity,
body: observation.Body, value: observation.Value, traceID: observation.TraceID, spanID: observation.SpanID,
correlationID: observation.CorrelationID, attributes: observation.Attributes,
}
}
func rawRecordMemory(record projectedRecord) int64 {
total := int64(256 + len(record.projectID) + len(record.environmentID) + len(record.serviceID) + len(record.sourceID) + len(record.streamID) + len(record.name) + len(record.severity) + len(record.body) + len(record.traceID) + len(record.spanID) + len(record.correlationID))
for key, value := range record.attributes {
addition := int64(len(key) + len(value) + 32)
if addition > math.MaxInt64-total {
return math.MaxInt64
}
total += addition
}
return total
}
+598
View File
@@ -0,0 +1,598 @@
// SPDX-License-Identifier: AGPL-3.0-only
package storage
import (
"context"
"crypto/rand"
"database/sql"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"sort"
"strings"
"time"
"unicode/utf8"
"gamertan.com/observatory/internal/model"
"gamertan.com/observatory/internal/query"
)
const (
SavedQueryVersion = 1
DashboardVersion = 1
MaxDashboardPanels = 16
)
var ErrDashboardRevisionConflict = errors.New("dashboard revision conflict")
type ResourceScope struct {
ProjectID string `json:"project_id,omitempty"`
EnvironmentID string `json:"environment_id,omitempty"`
ServiceID string `json:"service_id,omitempty"`
}
type SavedQuery struct {
Version int `json:"version"`
Revision int `json:"revision"`
OrganizationID string `json:"organization_id"`
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Query string `json:"query"`
AST query.AST `json:"ast"`
Scope ResourceScope `json:"scope"`
CreatedBy string `json:"created_by"`
UpdatedBy string `json:"updated_by"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type SavedQueryInput struct {
ID string
ExpectedRevision int
OrganizationID string
Name string
Description string
Query string
Scope ResourceScope
ActorUserID string
MaxRows int
}
type DashboardPanel struct {
ID string `json:"id"`
Position int `json:"position"`
Title string `json:"title"`
Visualization string `json:"visualization"`
SavedQueryID string `json:"saved_query_id"`
}
type Dashboard struct {
Version int `json:"version"`
Revision int `json:"revision"`
OrganizationID string `json:"organization_id"`
ID string `json:"id"`
Slug string `json:"slug"`
Name string `json:"name"`
Description string `json:"description"`
Panels []DashboardPanel `json:"panels"`
CreatedBy string `json:"created_by"`
UpdatedBy string `json:"updated_by"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type DashboardInput struct {
ID string
ExpectedRevision int
OrganizationID string
Slug string
Name string
Description string
Panels []DashboardPanel
ActorUserID string
}
type DashboardExport struct {
Version int `json:"version"`
Dashboard DashboardDefinition `json:"dashboard"`
SavedQueries []SavedQueryDefinition `json:"saved_queries"`
}
type DashboardDefinition struct {
Slug string `json:"slug"`
Name string `json:"name"`
Description string `json:"description"`
Panels []DashboardPanel `json:"panels"`
}
type SavedQueryDefinition struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Query string `json:"query"`
Scope ResourceScope `json:"scope"`
}
type DashboardImportInput struct {
OrganizationID string
ActorUserID string
MaxRows int
Bundle DashboardExport
}
func (s *Store) SaveQuery(ctx context.Context, input SavedQueryInput, now time.Time) (SavedQuery, error) {
ast, err := validateSavedQueryInput(input, now)
if err != nil {
return SavedQuery{}, err
}
astJSON, err := json.Marshal(ast)
if err != nil {
return SavedQuery{}, errors.New("encode saved query AST")
}
timestamp := now.UTC().Format(time.RFC3339Nano)
if input.ID == "" {
input.ID, err = storageID("query")
if err != nil {
return SavedQuery{}, err
}
_, err = s.control.ExecContext(ctx, `INSERT INTO saved_queries(organization_id,id,version,revision,name,description,query_text,ast_json,project_id,environment_id,service_id,created_by,updated_by,created_at,updated_at) VALUES(?,?,1,1,?,?,?,?,?,?,?,?,?,?,?)`, input.OrganizationID, input.ID, input.Name, input.Description, input.Query, string(astJSON), input.Scope.ProjectID, input.Scope.EnvironmentID, input.Scope.ServiceID, input.ActorUserID, input.ActorUserID, timestamp, timestamp)
if err != nil {
return SavedQuery{}, errors.New("create saved query")
}
} else {
if input.ExpectedRevision < 1 || model.ValidateSourceID(input.ID) != nil {
return SavedQuery{}, errors.New("saved query revision input is invalid")
}
result, updateErr := s.control.ExecContext(ctx, `UPDATE saved_queries SET revision=revision+1,name=?,description=?,query_text=?,ast_json=?,project_id=?,environment_id=?,service_id=?,updated_by=?,updated_at=? WHERE organization_id=? AND id=? AND revision=?`, input.Name, input.Description, input.Query, string(astJSON), input.Scope.ProjectID, input.Scope.EnvironmentID, input.Scope.ServiceID, input.ActorUserID, timestamp, input.OrganizationID, input.ID, input.ExpectedRevision)
if updateErr != nil {
return SavedQuery{}, errors.New("update saved query")
}
if changed, _ := result.RowsAffected(); changed != 1 {
return SavedQuery{}, errors.New("saved query revision conflict")
}
}
return s.SavedQuery(ctx, input.OrganizationID, input.ID)
}
func (s *Store) SavedQuery(ctx context.Context, organizationID, id string) (SavedQuery, error) {
if model.ValidateSourceID(organizationID) != nil || model.ValidateSourceID(id) != nil {
return SavedQuery{}, errors.New("saved query identity is invalid")
}
row := s.control.QueryRowContext(ctx, `SELECT version,revision,name,description,query_text,ast_json,project_id,environment_id,service_id,created_by,updated_by,created_at,updated_at FROM saved_queries WHERE organization_id=? AND id=?`, organizationID, id)
return scanSavedQuery(row, organizationID, id)
}
func (s *Store) SavedQueries(ctx context.Context, organizationID string) ([]SavedQuery, error) {
if model.ValidateSourceID(organizationID) != nil {
return nil, errors.New("invalid organization identifier")
}
rows, err := s.control.QueryContext(ctx, `SELECT id,version,revision,name,description,query_text,ast_json,project_id,environment_id,service_id,created_by,updated_by,created_at,updated_at FROM saved_queries WHERE organization_id=? ORDER BY name,id`, organizationID)
if err != nil {
return nil, errors.New("list saved queries")
}
defer rows.Close()
var result []SavedQuery
for rows.Next() {
var id string
var value SavedQuery
var astJSON, createdAt, updatedAt string
value.OrganizationID = organizationID
if err = rows.Scan(&id, &value.Version, &value.Revision, &value.Name, &value.Description, &value.Query, &astJSON, &value.Scope.ProjectID, &value.Scope.EnvironmentID, &value.Scope.ServiceID, &value.CreatedBy, &value.UpdatedBy, &createdAt, &updatedAt); err != nil {
return nil, errors.New("read saved query")
}
value.ID = id
if err = decodeSavedQuery(&value, astJSON, createdAt, updatedAt); err != nil {
return nil, err
}
result = append(result, value)
}
if err = rows.Err(); err != nil {
return nil, errors.New("list saved queries")
}
return result, nil
}
type rowScanner interface{ Scan(...any) error }
func scanSavedQuery(row rowScanner, organizationID, id string) (SavedQuery, error) {
value := SavedQuery{OrganizationID: organizationID, ID: id}
var astJSON, createdAt, updatedAt string
if err := row.Scan(&value.Version, &value.Revision, &value.Name, &value.Description, &value.Query, &astJSON, &value.Scope.ProjectID, &value.Scope.EnvironmentID, &value.Scope.ServiceID, &value.CreatedBy, &value.UpdatedBy, &createdAt, &updatedAt); errors.Is(err, sql.ErrNoRows) {
return SavedQuery{}, errors.New("saved query not found")
} else if err != nil {
return SavedQuery{}, errors.New("read saved query")
}
if err := decodeSavedQuery(&value, astJSON, createdAt, updatedAt); err != nil {
return SavedQuery{}, err
}
return value, nil
}
func decodeSavedQuery(value *SavedQuery, astJSON, createdAt, updatedAt string) error {
if value.Version != SavedQueryVersion || value.Revision < 1 || model.ValidateSourceID(value.OrganizationID) != nil || model.ValidateSourceID(value.ID) != nil || model.ValidateSourceID(value.CreatedBy) != nil || model.ValidateSourceID(value.UpdatedBy) != nil || !validResourceScope(value.Scope) || !boundedText(value.Name, 128, false) || !boundedText(value.Description, 1024, true) {
return errors.New("stored saved query is invalid")
}
parsed, err := query.Parse(value.Query, 100_000)
if err != nil || json.Unmarshal([]byte(astJSON), &value.AST) != nil || hydrateSavedAST(&value.AST) != nil || query.Validate(value.AST, 100_000) != nil {
return errors.New("stored saved query AST is invalid")
}
parsedJSON, _ := json.Marshal(parsed)
storedJSON, _ := json.Marshal(value.AST)
if string(parsedJSON) != string(storedJSON) {
return errors.New("stored saved query AST does not match query text")
}
value.CreatedAt, err = time.Parse(time.RFC3339Nano, createdAt)
if err != nil {
return errors.New("stored saved query created time is invalid")
}
value.UpdatedAt, err = time.Parse(time.RFC3339Nano, updatedAt)
if err != nil || value.UpdatedAt.Before(value.CreatedAt) {
return errors.New("stored saved query updated time is invalid")
}
return nil
}
func hydrateSavedAST(ast *query.AST) error {
var err error
if ast.WindowText != "" {
ast.Window, err = time.ParseDuration(ast.WindowText)
}
if err == nil && ast.BucketText != "" {
ast.Bucket, err = time.ParseDuration(ast.BucketText)
}
if err != nil {
return errors.New("stored saved query duration is invalid")
}
return nil
}
func (s *Store) SaveDashboard(ctx context.Context, input DashboardInput, now time.Time) (Dashboard, error) {
if err := validateDashboardInput(input, now); err != nil {
return Dashboard{}, err
}
if input.ExpectedRevision == 0 && input.ID != "" {
return Dashboard{}, errors.New("new dashboard identity is server-generated")
}
if input.ExpectedRevision > 0 && model.ValidateSourceID(input.ID) != nil {
return Dashboard{}, errors.New("dashboard revision input is invalid")
}
var err error
if input.ID == "" {
input.ID, err = storageID("dashboard")
if err != nil {
return Dashboard{}, err
}
}
panels := append([]DashboardPanel(nil), input.Panels...)
for index := range panels {
if panels[index].ID == "" {
panels[index].ID, err = storageID("panel")
if err != nil {
return Dashboard{}, err
}
}
}
tx, err := s.control.BeginTx(ctx, nil)
if err != nil {
return Dashboard{}, errors.New("begin dashboard update")
}
defer tx.Rollback()
timestamp := now.UTC().Format(time.RFC3339Nano)
if input.ExpectedRevision == 0 {
_, err = tx.ExecContext(ctx, `INSERT INTO dashboards(organization_id,id,version,revision,slug,name,description,created_by,updated_by,created_at,updated_at) VALUES(?,?,1,1,?,?,?,?,?,?,?)`, input.OrganizationID, input.ID, input.Slug, input.Name, input.Description, input.ActorUserID, input.ActorUserID, timestamp, timestamp)
if err != nil {
return Dashboard{}, errors.New("create dashboard")
}
} else {
if model.ValidateSourceID(input.ID) != nil || input.ExpectedRevision < 1 {
return Dashboard{}, errors.New("dashboard revision input is invalid")
}
result, updateErr := tx.ExecContext(ctx, `UPDATE dashboards SET revision=revision+1,slug=?,name=?,description=?,updated_by=?,updated_at=? WHERE organization_id=? AND id=? AND revision=?`, input.Slug, input.Name, input.Description, input.ActorUserID, timestamp, input.OrganizationID, input.ID, input.ExpectedRevision)
if updateErr != nil {
return Dashboard{}, errors.New("update dashboard")
}
if changed, _ := result.RowsAffected(); changed != 1 {
return Dashboard{}, ErrDashboardRevisionConflict
}
if _, err = tx.ExecContext(ctx, `DELETE FROM dashboard_panels WHERE organization_id=? AND dashboard_id=?`, input.OrganizationID, input.ID); err != nil {
return Dashboard{}, errors.New("replace dashboard panels")
}
}
for _, panel := range panels {
if _, err = tx.ExecContext(ctx, `INSERT INTO dashboard_panels(organization_id,dashboard_id,id,position,title,visualization,saved_query_id) VALUES(?,?,?,?,?,?,?)`, input.OrganizationID, input.ID, panel.ID, panel.Position, panel.Title, panel.Visualization, panel.SavedQueryID); err != nil {
return Dashboard{}, errors.New("store dashboard panel")
}
}
if err = tx.Commit(); err != nil {
return Dashboard{}, errors.New("commit dashboard update")
}
return s.Dashboard(ctx, input.OrganizationID, input.Slug)
}
func (s *Store) Dashboard(ctx context.Context, organizationID, slug string) (Dashboard, error) {
if model.ValidateSourceID(organizationID) != nil || !validSlug(slug) {
return Dashboard{}, errors.New("dashboard identity is invalid")
}
value := Dashboard{OrganizationID: organizationID}
var createdAt, updatedAt string
err := s.control.QueryRowContext(ctx, `SELECT id,version,revision,name,description,created_by,updated_by,created_at,updated_at FROM dashboards WHERE organization_id=? AND slug=?`, organizationID, slug).Scan(&value.ID, &value.Version, &value.Revision, &value.Name, &value.Description, &value.CreatedBy, &value.UpdatedBy, &createdAt, &updatedAt)
if errors.Is(err, sql.ErrNoRows) {
return Dashboard{}, errors.New("dashboard not found")
}
if err != nil {
return Dashboard{}, errors.New("read dashboard")
}
value.Slug = slug
value.CreatedAt, err = time.Parse(time.RFC3339Nano, createdAt)
if err != nil {
return Dashboard{}, errors.New("stored dashboard created time is invalid")
}
value.UpdatedAt, err = time.Parse(time.RFC3339Nano, updatedAt)
if err != nil || value.UpdatedAt.Before(value.CreatedAt) {
return Dashboard{}, errors.New("stored dashboard updated time is invalid")
}
rows, err := s.control.QueryContext(ctx, `SELECT id,position,title,visualization,saved_query_id FROM dashboard_panels WHERE organization_id=? AND dashboard_id=? ORDER BY position,id`, organizationID, value.ID)
if err != nil {
return Dashboard{}, errors.New("read dashboard panels")
}
defer rows.Close()
for rows.Next() {
var panel DashboardPanel
if err = rows.Scan(&panel.ID, &panel.Position, &panel.Title, &panel.Visualization, &panel.SavedQueryID); err != nil || validatePanel(panel) != nil {
return Dashboard{}, errors.New("stored dashboard panel is invalid")
}
value.Panels = append(value.Panels, panel)
}
if err = rows.Err(); err != nil || validateDashboard(value) != nil {
return Dashboard{}, errors.New("stored dashboard is invalid")
}
return value, nil
}
func (s *Store) Dashboards(ctx context.Context, organizationID string) ([]Dashboard, error) {
if model.ValidateSourceID(organizationID) != nil {
return nil, errors.New("invalid organization identifier")
}
rows, err := s.control.QueryContext(ctx, `SELECT slug FROM dashboards WHERE organization_id=? ORDER BY name,slug`, organizationID)
if err != nil {
return nil, errors.New("list dashboards")
}
var slugs []string
for rows.Next() {
var slug string
if err = rows.Scan(&slug); err != nil {
_ = rows.Close()
return nil, errors.New("list dashboards")
}
slugs = append(slugs, slug)
}
if err = rows.Close(); err != nil || rows.Err() != nil {
return nil, errors.New("list dashboards")
}
result := make([]Dashboard, 0, len(slugs))
for _, slug := range slugs {
value, loadErr := s.Dashboard(ctx, organizationID, slug)
if loadErr != nil {
return nil, loadErr
}
result = append(result, value)
}
return result, nil
}
func (s *Store) ExportDashboard(ctx context.Context, organizationID, slug string) (DashboardExport, error) {
dashboard, err := s.Dashboard(ctx, organizationID, slug)
if err != nil {
return DashboardExport{}, err
}
queries := make([]SavedQueryDefinition, 0, len(dashboard.Panels))
seen := map[string]bool{}
for _, panel := range dashboard.Panels {
if seen[panel.SavedQueryID] {
continue
}
value, loadErr := s.SavedQuery(ctx, organizationID, panel.SavedQueryID)
if loadErr != nil {
return DashboardExport{}, loadErr
}
seen[value.ID] = true
queries = append(queries, SavedQueryDefinition{ID: value.ID, Name: value.Name, Description: value.Description, Query: value.Query, Scope: value.Scope})
}
sort.Slice(queries, func(i, j int) bool { return queries[i].ID < queries[j].ID })
definition := DashboardDefinition{Slug: dashboard.Slug, Name: dashboard.Name, Description: dashboard.Description, Panels: dashboard.Panels}
return DashboardExport{Version: DashboardVersion, Dashboard: definition, SavedQueries: queries}, nil
}
// ImportDashboard validates one source-control-safe export and creates its
// queries, dashboard, and panels atomically with new server-owned identities.
// Tenant and actor metadata are supplied independently of the bundle.
func (s *Store) ImportDashboard(ctx context.Context, input DashboardImportInput, now time.Time) (Dashboard, error) {
if input.Bundle.Version != DashboardVersion || model.ValidateSourceID(input.OrganizationID) != nil || model.ValidateSourceID(input.ActorUserID) != nil || input.MaxRows < 1 || input.MaxRows > 100_000 || now.IsZero() || len(input.Bundle.SavedQueries) > MaxDashboardPanels {
return Dashboard{}, errors.New("dashboard import is invalid")
}
queryIDs := make(map[string]string, len(input.Bundle.SavedQueries))
type importedQuery struct {
definition SavedQueryDefinition
id string
astJSON string
ast query.AST
}
queries := make([]importedQuery, 0, len(input.Bundle.SavedQueries))
for _, definition := range input.Bundle.SavedQueries {
if model.ValidateSourceID(definition.ID) != nil || queryIDs[definition.ID] != "" {
return Dashboard{}, errors.New("dashboard import query identity is invalid or duplicated")
}
ast, err := validateSavedQueryInput(SavedQueryInput{OrganizationID: input.OrganizationID, ActorUserID: input.ActorUserID, MaxRows: input.MaxRows, Name: definition.Name, Description: definition.Description, Query: definition.Query, Scope: definition.Scope}, now)
if err != nil {
return Dashboard{}, err
}
encoded, err := json.Marshal(ast)
if err != nil {
return Dashboard{}, errors.New("encode imported saved query AST")
}
id, err := storageID("query")
if err != nil {
return Dashboard{}, err
}
queryIDs[definition.ID] = id
queries = append(queries, importedQuery{definition: definition, id: id, astJSON: string(encoded), ast: ast})
}
queryASTs := make(map[string]query.AST, len(queries))
for _, imported := range queries {
queryASTs[imported.definition.ID] = imported.ast
}
panels := make([]DashboardPanel, len(input.Bundle.Dashboard.Panels))
referenced := make(map[string]bool, len(queries))
for index, panel := range input.Bundle.Dashboard.Panels {
mapped := queryIDs[panel.SavedQueryID]
if mapped == "" {
return Dashboard{}, errors.New("dashboard import panel references an unknown saved query")
}
ast := queryASTs[panel.SavedQueryID]
if panel.Visualization == "stat" && ast.Summary == nil || panel.Visualization == "timeseries" && (ast.Summary == nil || ast.Bucket <= 0) {
return Dashboard{}, errors.New("dashboard import presentation does not match its saved query")
}
panelID, err := storageID("panel")
if err != nil {
return Dashboard{}, err
}
panel.ID, panel.SavedQueryID = panelID, mapped
panels[index] = panel
referenced[panel.SavedQueryID] = true
}
if len(referenced) != len(queries) {
return Dashboard{}, errors.New("dashboard import contains an unreferenced saved query")
}
dashboardID, err := storageID("dashboard")
if err != nil {
return Dashboard{}, err
}
dashboardInput := DashboardInput{ID: dashboardID, ExpectedRevision: 1, OrganizationID: input.OrganizationID, ActorUserID: input.ActorUserID, Slug: input.Bundle.Dashboard.Slug, Name: input.Bundle.Dashboard.Name, Description: input.Bundle.Dashboard.Description, Panels: panels}
if err = validateDashboardInput(dashboardInput, now); err != nil {
return Dashboard{}, err
}
tx, err := s.control.BeginTx(ctx, nil)
if err != nil {
return Dashboard{}, errors.New("begin dashboard import")
}
defer tx.Rollback()
timestamp := now.UTC().Format(time.RFC3339Nano)
for _, imported := range queries {
definition := imported.definition
_, err = tx.ExecContext(ctx, `INSERT INTO saved_queries(organization_id,id,version,revision,name,description,query_text,ast_json,project_id,environment_id,service_id,created_by,updated_by,created_at,updated_at) VALUES(?,?,1,1,?,?,?,?,?,?,?,?,?,?,?)`, input.OrganizationID, imported.id, definition.Name, definition.Description, strings.TrimSpace(definition.Query), imported.astJSON, definition.Scope.ProjectID, definition.Scope.EnvironmentID, definition.Scope.ServiceID, input.ActorUserID, input.ActorUserID, timestamp, timestamp)
if err != nil {
return Dashboard{}, errors.New("import saved query")
}
}
_, err = tx.ExecContext(ctx, `INSERT INTO dashboards(organization_id,id,version,revision,slug,name,description,created_by,updated_by,created_at,updated_at) VALUES(?,?,1,1,?,?,?,?,?,?,?)`, input.OrganizationID, dashboardID, input.Bundle.Dashboard.Slug, input.Bundle.Dashboard.Name, input.Bundle.Dashboard.Description, input.ActorUserID, input.ActorUserID, timestamp, timestamp)
if err != nil {
return Dashboard{}, errors.New("import dashboard")
}
for _, panel := range panels {
if _, err = tx.ExecContext(ctx, `INSERT INTO dashboard_panels(organization_id,dashboard_id,id,position,title,visualization,saved_query_id) VALUES(?,?,?,?,?,?,?)`, input.OrganizationID, dashboardID, panel.ID, panel.Position, panel.Title, panel.Visualization, panel.SavedQueryID); err != nil {
return Dashboard{}, errors.New("import dashboard panel")
}
}
if err = tx.Commit(); err != nil {
return Dashboard{}, errors.New("commit dashboard import")
}
return s.Dashboard(ctx, input.OrganizationID, input.Bundle.Dashboard.Slug)
}
func validateSavedQueryInput(input SavedQueryInput, now time.Time) (query.AST, error) {
if model.ValidateSourceID(input.OrganizationID) != nil || model.ValidateSourceID(input.ActorUserID) != nil || !boundedText(input.Name, 128, false) || !boundedText(input.Description, 1024, true) || !validResourceScope(input.Scope) || input.MaxRows < 1 || input.MaxRows > 100_000 || now.IsZero() {
return query.AST{}, errors.New("saved query input is invalid")
}
ast, err := query.Parse(strings.TrimSpace(input.Query), input.MaxRows)
if err != nil {
return query.AST{}, fmt.Errorf("saved query: %w", err)
}
return ast, nil
}
func validateDashboardInput(input DashboardInput, now time.Time) error {
if model.ValidateSourceID(input.OrganizationID) != nil || model.ValidateSourceID(input.ActorUserID) != nil || !validSlug(input.Slug) || !boundedText(input.Name, 128, false) || !boundedText(input.Description, 1024, true) || len(input.Panels) > MaxDashboardPanels || now.IsZero() {
return errors.New("dashboard input is invalid")
}
positions := map[int]bool{}
ids := map[string]bool{}
for _, panel := range input.Panels {
if err := validatePanelInput(panel); err != nil || positions[panel.Position] || panel.ID != "" && ids[panel.ID] {
return errors.New("dashboard panel input is invalid")
}
positions[panel.Position] = true
if panel.ID != "" {
ids[panel.ID] = true
}
}
return nil
}
func validateDashboard(value Dashboard) error {
if value.Version != DashboardVersion || value.Revision < 1 || model.ValidateSourceID(value.OrganizationID) != nil || model.ValidateSourceID(value.ID) != nil || model.ValidateSourceID(value.CreatedBy) != nil || model.ValidateSourceID(value.UpdatedBy) != nil || !validSlug(value.Slug) || !boundedText(value.Name, 128, false) || !boundedText(value.Description, 1024, true) || len(value.Panels) > MaxDashboardPanels {
return errors.New("dashboard is invalid")
}
return nil
}
func validatePanel(panel DashboardPanel) error {
if model.ValidateSourceID(panel.ID) != nil {
return errors.New("dashboard panel is invalid")
}
return validatePanelInput(panel)
}
func validatePanelInput(panel DashboardPanel) error {
if panel.ID != "" && model.ValidateSourceID(panel.ID) != nil || panel.Position < 0 || panel.Position >= 64 || !boundedText(panel.Title, 128, false) || model.ValidateSourceID(panel.SavedQueryID) != nil {
return errors.New("dashboard panel is invalid")
}
switch panel.Visualization {
case "table", "stat", "timeseries":
return nil
default:
return errors.New("dashboard panel visualization is invalid")
}
}
func validResourceScope(scope ResourceScope) bool {
values := []string{scope.ProjectID, scope.EnvironmentID, scope.ServiceID}
for _, value := range values {
if value != "" && model.ValidateSourceID(value) != nil {
return false
}
}
if scope.EnvironmentID != "" && scope.ProjectID == "" {
return false
}
if scope.ServiceID != "" && (scope.ProjectID == "" || scope.EnvironmentID == "") {
return false
}
return true
}
func boundedText(value string, maximum int, empty bool) bool {
return utf8.ValidString(value) && !strings.ContainsAny(value, "\x00\r\n") && len(value) <= maximum && (empty || value != "")
}
func validSlug(value string) bool {
if len(value) < 2 || len(value) > 63 || value[0] < 'a' || value[0] > 'z' {
return false
}
for _, character := range value {
if character != '-' && (character < 'a' || character > 'z') && (character < '0' || character > '9') {
return false
}
}
return true
}
func storageID(prefix string) (string, error) {
random := make([]byte, 18)
if _, err := rand.Read(random); err != nil {
return "", errors.New("cryptographic randomness unavailable")
}
return prefix + "_" + base64.RawURLEncoding.EncodeToString(random), nil
}
+184
View File
@@ -0,0 +1,184 @@
// SPDX-License-Identifier: AGPL-3.0-only
package storage
import (
"context"
"encoding/json"
"errors"
"strings"
"testing"
"time"
)
func TestSavedQueriesAreTypedVersionedAndOptimisticallyUpdated(t *testing.T) {
ctx := context.Background()
store := testStore(t)
defer store.Close()
now := time.Date(2026, 8, 17, 10, 0, 0, 0, time.UTC)
input := SavedQueryInput{
OrganizationID: "organization-a", Name: "Recent failures",
Description: "Recent failed application requests grouped by route.",
Query: `logs | where status >= 500 | window 1h | summarize count() by route | sort count desc | limit 50`,
Scope: ResourceScope{ProjectID: "project-a", EnvironmentID: "production", ServiceID: "service-a"},
ActorUserID: "operator-a", MaxRows: 1000,
}
created, err := store.SaveQuery(ctx, input, now)
if err != nil {
t.Fatal(err)
}
if created.Version != SavedQueryVersion || created.Revision != 1 || created.ID == "" || created.AST.Signal != "logs" || created.AST.Window != time.Hour || created.CreatedAt != now || created.UpdatedAt != now {
t.Fatalf("created=%+v", created)
}
input.ID, input.ExpectedRevision = created.ID, created.Revision
input.Description = "Reviewed application failures grouped by normalized route."
updated, err := store.SaveQuery(ctx, input, now.Add(time.Minute))
if err != nil {
t.Fatal(err)
}
if updated.Revision != 2 || updated.Description != input.Description || updated.UpdatedBy != "operator-a" || !updated.UpdatedAt.Equal(now.Add(time.Minute)) {
t.Fatalf("updated=%+v", updated)
}
if _, err = store.SaveQuery(ctx, input, now.Add(2*time.Minute)); err == nil {
t.Fatal("stale saved-query revision was accepted")
}
queries, err := store.SavedQueries(ctx, "organization-a")
if err != nil || len(queries) != 1 || queries[0].Revision != 2 {
t.Fatalf("queries=%+v err=%v", queries, err)
}
if _, err = store.control.Exec(`UPDATE saved_queries SET ast_json='{}' WHERE organization_id='organization-a' AND id=?`, created.ID); err != nil {
t.Fatal(err)
}
if _, err = store.SavedQuery(ctx, "organization-a", created.ID); err == nil {
t.Fatal("query text and stored AST disagreement was accepted")
}
}
func TestDashboardPanelsRemainOrganizationScopedAndExportSafeDefinitions(t *testing.T) {
ctx := context.Background()
store := testStore(t)
defer store.Close()
now := time.Date(2026, 8, 17, 10, 0, 0, 0, time.UTC)
queryA, err := store.SaveQuery(ctx, SavedQueryInput{OrganizationID: "organization-a", Name: "Request rate", Description: "Five-minute request counts.", Query: `logs | window 1h | summarize count() by window(5m) | limit 50`, ActorUserID: "operator-a", MaxRows: 1000}, now)
if err != nil {
t.Fatal(err)
}
queryB, err := store.SaveQuery(ctx, SavedQueryInput{OrganizationID: "organization-b", Name: "Other organization", Description: "Must not cross the tenant boundary.", Query: `logs | limit 10`, ActorUserID: "operator-b", MaxRows: 1000}, now)
if err != nil {
t.Fatal(err)
}
input := DashboardInput{
OrganizationID: "organization-a", Slug: "operations", Name: "Operations",
Description: "Recent service activity and evidence.", ActorUserID: "operator-a",
Panels: []DashboardPanel{{Position: 0, Title: "Request rate", Visualization: "timeseries", SavedQueryID: queryA.ID}},
}
created, err := store.SaveDashboard(ctx, input, now)
if err != nil {
t.Fatal(err)
}
if created.Version != DashboardVersion || created.Revision != 1 || len(created.Panels) != 1 || created.Panels[0].ID == "" {
t.Fatalf("created=%+v", created)
}
unsafe := input
unsafe.Slug = "cross-tenant"
unsafe.Panels = []DashboardPanel{{Position: 0, Title: "Other", Visualization: "table", SavedQueryID: queryB.ID}}
if _, err = store.SaveDashboard(ctx, unsafe, now); err == nil {
t.Fatal("cross-organization saved query entered dashboard")
}
input.ID, input.ExpectedRevision = created.ID, created.Revision
input.Name = "Service operations"
input.Panels[0].ID = created.Panels[0].ID
updated, err := store.SaveDashboard(ctx, input, now.Add(time.Minute))
if err != nil {
t.Fatal(err)
}
if updated.Revision != 2 || updated.Name != input.Name {
t.Fatalf("updated=%+v", updated)
}
if _, err = store.SaveDashboard(ctx, input, now.Add(2*time.Minute)); !errors.Is(err, ErrDashboardRevisionConflict) {
t.Fatalf("stale dashboard revision err=%v", err)
}
exported, err := store.ExportDashboard(ctx, "organization-a", "operations")
if err != nil {
t.Fatal(err)
}
body, err := json.Marshal(exported)
if err != nil {
t.Fatal(err)
}
for _, forbidden := range []string{"organization-a", "operator-a", "created_at", "updated_at", "ast"} {
if strings.Contains(string(body), forbidden) {
t.Fatalf("private runtime metadata %q entered export: %s", forbidden, body)
}
}
if !strings.Contains(string(body), `"version":1`) || !strings.Contains(string(body), `"query":"logs | window 1h`) {
t.Fatalf("export=%s", body)
}
}
func TestDashboardValidationBoundsScopePanelsAndServerGeneratedIdentity(t *testing.T) {
ctx := context.Background()
store := testStore(t)
defer store.Close()
now := time.Date(2026, 8, 17, 10, 0, 0, 0, time.UTC)
if _, err := store.SaveQuery(ctx, SavedQueryInput{OrganizationID: "organization-a", Name: "Invalid scope", Query: `logs | limit 10`, Scope: ResourceScope{ServiceID: "service-a"}, ActorUserID: "operator-a", MaxRows: 1000}, now); err == nil {
t.Fatal("service-only query scope was accepted")
}
queryValue, err := store.SaveQuery(ctx, SavedQueryInput{OrganizationID: "organization-a", Name: "Valid", Query: `logs | limit 10`, ActorUserID: "operator-a", MaxRows: 1000}, now)
if err != nil {
t.Fatal(err)
}
if _, err = store.SaveDashboard(ctx, DashboardInput{ID: "caller-selected", OrganizationID: "organization-a", Slug: "invalid-id", Name: "Invalid", ActorUserID: "operator-a"}, now); err == nil {
t.Fatal("caller-selected new dashboard identity was accepted")
}
duplicate := DashboardInput{OrganizationID: "organization-a", Slug: "duplicate-panels", Name: "Duplicate panels", ActorUserID: "operator-a", Panels: []DashboardPanel{
{Position: 0, Title: "First", Visualization: "table", SavedQueryID: queryValue.ID},
{Position: 0, Title: "Second", Visualization: "stat", SavedQueryID: queryValue.ID},
}}
if _, err = store.SaveDashboard(ctx, duplicate, now); err == nil {
t.Fatal("duplicate dashboard panel positions were accepted")
}
}
func TestDashboardImportIsAtomicTenantIndependentAndRevalidated(t *testing.T) {
ctx := context.Background()
store := testStore(t)
defer store.Close()
now := time.Date(2026, 8, 17, 11, 0, 0, 0, time.UTC)
bundle := DashboardExport{
Version: DashboardVersion,
Dashboard: DashboardDefinition{Slug: "operations", Name: "Operations", Description: "A portable service view.", Panels: []DashboardPanel{
{ID: "old-panel-1", Position: 0, Title: "Recent failures", Visualization: "table", SavedQueryID: "portable-query-1"},
}},
SavedQueries: []SavedQueryDefinition{{ID: "portable-query-1", Name: "Recent failures", Description: "Bounded failures.", Query: `logs | where status >= 500 | window 1h | limit 50`}},
}
imported, err := store.ImportDashboard(ctx, DashboardImportInput{OrganizationID: "organization-b", ActorUserID: "operator-b", MaxRows: 1_000, Bundle: bundle}, now)
if err != nil {
t.Fatal(err)
}
if imported.OrganizationID != "organization-b" || imported.CreatedBy != "operator-b" || imported.ID == "" || len(imported.Panels) != 1 || imported.Panels[0].ID == "old-panel-1" || imported.Panels[0].SavedQueryID == "portable-query-1" {
t.Fatalf("imported=%+v", imported)
}
queries, err := store.SavedQueries(ctx, "organization-b")
if err != nil || len(queries) != 1 || queries[0].OrganizationID != "organization-b" || queries[0].CreatedBy != "operator-b" || queries[0].ID != imported.Panels[0].SavedQueryID {
t.Fatalf("queries=%+v err=%v", queries, err)
}
invalid := bundle
invalid.Dashboard.Slug = "partial-import"
invalid.SavedQueries = append(invalid.SavedQueries, SavedQueryDefinition{ID: "unused-query-2", Name: "Unused", Query: `logs | limit 10`})
if _, err = store.ImportDashboard(ctx, DashboardImportInput{OrganizationID: "organization-b", ActorUserID: "operator-b", MaxRows: 1_000, Bundle: invalid}, now); err == nil {
t.Fatal("unreferenced imported query was accepted")
}
if dashboards, listErr := store.Dashboards(ctx, "organization-b"); listErr != nil || len(dashboards) != 1 {
t.Fatalf("failed import left partial dashboard: dashboards=%+v err=%v", dashboards, listErr)
}
if queries, listErr := store.SavedQueries(ctx, "organization-b"); listErr != nil || len(queries) != 1 {
t.Fatalf("failed import left partial queries: queries=%+v err=%v", queries, listErr)
}
incompatible := bundle
incompatible.Dashboard.Slug = "invalid-presentation"
incompatible.Dashboard.Panels[0].Visualization = "timeseries"
if _, err = store.ImportDashboard(ctx, DashboardImportInput{OrganizationID: "organization-b", ActorUserID: "operator-b", MaxRows: 1_000, Bundle: incompatible}, now); err == nil {
t.Fatal("timeseries dashboard without a bucketed summary was accepted")
}
}
+312
View File
@@ -0,0 +1,312 @@
// SPDX-License-Identifier: AGPL-3.0-only
package storage
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"math"
"sort"
"strconv"
"strings"
"time"
"gamertan.com/observatory/internal/model"
"gamertan.com/observatory/internal/query"
"gamertan.com/observatory/internal/schema"
)
type StoredProposal struct {
Proposal schema.Proposal `json:"proposal"`
Status string `json:"status"`
FirstSeenAt time.Time `json:"first_seen_at"`
LastSeenAt time.Time `json:"last_seen_at"`
}
type fieldEvidence struct {
descriptor schema.Descriptor
count int64
bytes int64
}
func (s *Store) descriptorProposal(ctx context.Context, organizationID string, signal model.Signal, field string) (StoredProposal, error) {
if err := model.ValidateSourceID(organizationID); err != nil {
return StoredProposal{}, errors.New("invalid organization identifier")
}
var descriptorJSON, examplesJSON, firstSeen, lastSeen string
var proposal StoredProposal
err := s.control.QueryRowContext(ctx, `SELECT descriptor_json,observed_values,estimated_bytes,example_queries_json,status,first_seen_at,last_seen_at FROM descriptor_proposals WHERE organization_id=? AND signal=? AND field=?`, organizationID, signal, query.CanonicalField(field)).Scan(&descriptorJSON, &proposal.Proposal.ObservedValues, &proposal.Proposal.EstimatedBytes, &examplesJSON, &proposal.Status, &firstSeen, &lastSeen)
if errors.Is(err, sql.ErrNoRows) {
return StoredProposal{}, errors.New("descriptor proposal not found")
}
if err != nil {
return StoredProposal{}, errors.New("load descriptor proposal")
}
if err = json.Unmarshal([]byte(descriptorJSON), &proposal.Proposal.Descriptor); err != nil {
return StoredProposal{}, errors.New("decode descriptor proposal")
}
if err = json.Unmarshal([]byte(examplesJSON), &proposal.Proposal.ExampleQueries); err != nil {
return StoredProposal{}, errors.New("decode descriptor examples")
}
proposal.FirstSeenAt, err = time.Parse(time.RFC3339Nano, firstSeen)
if err != nil {
return StoredProposal{}, errors.New("descriptor proposal first-seen time is invalid")
}
proposal.LastSeenAt, err = time.Parse(time.RFC3339Nano, lastSeen)
if err != nil || proposal.Proposal.Validate() != nil || proposal.Status != "pending" && proposal.Status != "activated" && proposal.Status != "rejected" {
return StoredProposal{}, errors.New("stored descriptor proposal is invalid")
}
return proposal, nil
}
func (s *Store) markProposalActivated(ctx context.Context, organizationID string, descriptor schema.Descriptor) error {
encoded, err := json.Marshal(descriptor)
if err != nil {
return errors.New("encode activated descriptor")
}
result, err := s.control.ExecContext(ctx, `UPDATE descriptor_proposals SET descriptor_json=?,status='activated' WHERE organization_id=? AND signal=? AND field=? AND status='pending'`, string(encoded), organizationID, descriptor.Signal, descriptor.Field)
if err != nil {
return errors.New("acknowledge activated descriptor")
}
if changed, _ := result.RowsAffected(); changed == 1 {
return nil
}
proposal, err := s.descriptorProposal(ctx, organizationID, descriptor.Signal, descriptor.Field)
if err != nil || proposal.Status != "activated" || !sameDescriptorIgnoringProjection(proposal.Proposal.Descriptor, descriptor) || proposal.Proposal.Descriptor.ProjectionVersion != descriptor.ProjectionVersion {
return errors.New("activated descriptor acknowledgement is inconsistent")
}
return nil
}
func (s *Store) RejectDescriptorProposal(ctx context.Context, organizationID string, signal model.Signal, field string) error {
if err := model.ValidateSourceID(organizationID); err != nil {
return errors.New("invalid organization identifier")
}
lock := s.namedLock("organization:" + organizationID)
lock.Lock()
defer lock.Unlock()
registry, _, err := s.ActiveDescriptors(ctx, organizationID)
if err != nil {
return err
}
if _, active := registry.Lookup(signal, field); active {
return errors.New("active descriptor proposal cannot be rejected")
}
result, err := s.control.ExecContext(ctx, `UPDATE descriptor_proposals SET status='rejected' WHERE organization_id=? AND signal=? AND field=? AND status='pending'`, organizationID, signal, query.CanonicalField(field))
if err != nil {
return errors.New("reject descriptor proposal")
}
if changed, _ := result.RowsAffected(); changed == 1 {
return nil
}
proposal, err := s.descriptorProposal(ctx, organizationID, signal, field)
if err == nil && proposal.Status == "rejected" {
return nil
}
return errors.New("pending descriptor proposal not found")
}
func (s *Store) recordDescriptorProposals(ctx context.Context, organizationID string, batch model.Batch, segmentDigest string, now time.Time) error {
tx, err := s.control.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
if err = recordDescriptorProposalsTx(ctx, tx, organizationID, batch, segmentDigest, now); err != nil {
return err
}
if err = tx.Commit(); err != nil {
return errors.New("commit descriptor proposals")
}
return nil
}
func recordDescriptorProposalsTx(ctx context.Context, tx *sql.Tx, organizationID string, batch model.Batch, segmentDigest string, now time.Time) error {
if err := model.ValidateSourceID(organizationID); err != nil || !validDigest(segmentDigest) || now.IsZero() {
return errors.New("descriptor proposal identity is invalid")
}
evidence := map[string]fieldEvidence{}
for _, observation := range batch.Records {
for field, value := range observation.Attributes {
canonical := query.CanonicalField(field)
if _, known := query.BuiltinDescriptor(batch.Signal, canonical); known {
continue
}
current, exists := evidence[canonical]
if !exists && len(evidence) >= model.MaxDistinctFields {
return errors.New("descriptor proposal field limit exceeded")
}
descriptor := proposedDescriptor(batch.Signal, canonical, inferType(value))
if err := descriptor.Validate(); err != nil {
continue
}
if current.count > 0 {
descriptor.Type = mergeType(current.descriptor.Type, descriptor.Type)
}
current.descriptor = descriptor
current.count++
current.bytes += int64(len(canonical) + len(value))
evidence[canonical] = current
}
}
if len(evidence) == 0 {
return nil
}
fields := make([]string, 0, len(evidence))
for field := range evidence {
fields = append(fields, field)
}
sort.Strings(fields)
for _, field := range fields {
observed := evidence[field]
result, err := tx.ExecContext(ctx, `INSERT OR IGNORE INTO descriptor_proposal_segments(segment_digest,organization_id,signal,field,observed_values,estimated_bytes) VALUES(?,?,?,?,?,?)`, segmentDigest, organizationID, batch.Signal, field, observed.count, observed.bytes)
if err != nil {
return errors.New("record descriptor proposal segment")
}
inserted, err := result.RowsAffected()
if err != nil {
return errors.New("inspect descriptor proposal segment")
}
if inserted == 0 {
continue
}
if err = upsertProposal(ctx, tx, organizationID, batch.Signal, observed, now); err != nil {
return err
}
}
return nil
}
func upsertProposal(ctx context.Context, tx *sql.Tx, organizationID string, signal model.Signal, observed fieldEvidence, now time.Time) error {
field := observed.descriptor.Field
var descriptorJSON, examplesJSON, status string
var count, estimated int64
err := tx.QueryRowContext(ctx, `SELECT descriptor_json,observed_values,estimated_bytes,example_queries_json,status FROM descriptor_proposals WHERE organization_id=? AND signal=? AND field=?`, organizationID, signal, field).Scan(&descriptorJSON, &count, &estimated, &examplesJSON, &status)
if errors.Is(err, sql.ErrNoRows) {
descriptorBody, marshalErr := json.Marshal(observed.descriptor)
if marshalErr != nil {
return errors.New("encode descriptor proposal")
}
examples := []string{fmt.Sprintf(`%s | where %s == "value" | limit 50`, signal, field)}
exampleBody, marshalErr := json.Marshal(examples)
if marshalErr != nil {
return errors.New("encode descriptor examples")
}
_, err = tx.ExecContext(ctx, `INSERT INTO descriptor_proposals(organization_id,signal,field,descriptor_json,observed_values,estimated_bytes,example_queries_json,status,first_seen_at,last_seen_at) VALUES(?,?,?,?,?,?,?,'pending',?,?)`, organizationID, signal, field, string(descriptorBody), observed.count, observed.bytes, string(exampleBody), now.UTC().Format(time.RFC3339Nano), now.UTC().Format(time.RFC3339Nano))
if err != nil {
return errors.New("create descriptor proposal")
}
return nil
}
if err != nil {
return errors.New("load descriptor proposal")
}
if status != "pending" {
return nil
}
var descriptor schema.Descriptor
if err = json.Unmarshal([]byte(descriptorJSON), &descriptor); err != nil || descriptor.Validate() != nil {
return errors.New("stored descriptor proposal is invalid")
}
descriptor.Type = mergeType(descriptor.Type, observed.descriptor.Type)
if count > math.MaxInt64-observed.count || estimated > math.MaxInt64-observed.bytes {
return errors.New("descriptor proposal evidence overflow")
}
descriptorBody, err := json.Marshal(descriptor)
if err != nil {
return errors.New("encode descriptor proposal")
}
_, err = tx.ExecContext(ctx, `UPDATE descriptor_proposals SET descriptor_json=?,observed_values=?,estimated_bytes=?,last_seen_at=? WHERE organization_id=? AND signal=? AND field=? AND status='pending'`, string(descriptorBody), count+observed.count, estimated+observed.bytes, now.UTC().Format(time.RFC3339Nano), organizationID, signal, field)
if err != nil {
return errors.New("update descriptor proposal")
}
return nil
}
func (s *Store) DescriptorProposals(ctx context.Context, organizationID string) ([]StoredProposal, error) {
if err := model.ValidateSourceID(organizationID); err != nil {
return nil, errors.New("invalid organization identifier")
}
rows, err := s.control.QueryContext(ctx, `SELECT descriptor_json,observed_values,estimated_bytes,example_queries_json,status,first_seen_at,last_seen_at FROM descriptor_proposals WHERE organization_id=? ORDER BY signal,field`, organizationID)
if err != nil {
return nil, errors.New("list descriptor proposals")
}
defer rows.Close()
var proposals []StoredProposal
for rows.Next() {
var descriptorJSON, examplesJSON, firstSeen, lastSeen string
var proposal StoredProposal
if err = rows.Scan(&descriptorJSON, &proposal.Proposal.ObservedValues, &proposal.Proposal.EstimatedBytes, &examplesJSON, &proposal.Status, &firstSeen, &lastSeen); err != nil {
return nil, errors.New("read descriptor proposal")
}
if err = json.Unmarshal([]byte(descriptorJSON), &proposal.Proposal.Descriptor); err != nil {
return nil, errors.New("decode descriptor proposal")
}
if err = json.Unmarshal([]byte(examplesJSON), &proposal.Proposal.ExampleQueries); err != nil {
return nil, errors.New("decode descriptor examples")
}
proposal.FirstSeenAt, err = time.Parse(time.RFC3339Nano, firstSeen)
if err != nil {
return nil, errors.New("descriptor proposal first-seen time is invalid")
}
proposal.LastSeenAt, err = time.Parse(time.RFC3339Nano, lastSeen)
if err != nil || proposal.Proposal.Validate() != nil || proposal.Status != "pending" && proposal.Status != "activated" && proposal.Status != "rejected" {
return nil, errors.New("stored descriptor proposal is invalid")
}
proposals = append(proposals, proposal)
}
if err = rows.Err(); err != nil {
return nil, errors.New("list descriptor proposals")
}
return proposals, nil
}
func proposedDescriptor(signal model.Signal, field string, valueType schema.Type) schema.Descriptor {
return schema.Descriptor{
Version: schema.DescriptorVersion, Signal: signal, Field: field,
Type: valueType, Meaning: "Observed unreviewed field awaiting administrator classification.",
Sensitivity: schema.SensitivitySensitive, Cardinality: schema.CardinalityHigh,
Index: schema.IndexNone, Retention: schema.RetentionRaw, ProjectionVersion: 1,
}
}
func inferType(value string) schema.Type {
if _, err := strconv.ParseInt(value, 10, 64); err == nil {
return schema.TypeInteger
}
if number, err := strconv.ParseFloat(value, 64); err == nil && !math.IsNaN(number) && !math.IsInf(number, 0) {
return schema.TypeFloat
}
if value == "true" || value == "false" {
return schema.TypeBoolean
}
if _, err := time.Parse(time.RFC3339Nano, value); err == nil {
return schema.TypeTime
}
return schema.TypeString
}
func mergeType(left, right schema.Type) schema.Type {
if left == right {
return left
}
if left == schema.TypeInteger && right == schema.TypeFloat || left == schema.TypeFloat && right == schema.TypeInteger {
return schema.TypeFloat
}
return schema.TypeString
}
func validDigest(value string) bool {
if len(value) != 64 {
return false
}
for _, character := range value {
if !strings.ContainsRune("0123456789abcdef", character) {
return false
}
}
return true
}
+102
View File
@@ -0,0 +1,102 @@
// SPDX-License-Identifier: AGPL-3.0-only
package storage
import (
"context"
"encoding/json"
"strings"
"testing"
"time"
"gamertan.com/observatory/internal/model"
"gamertan.com/observatory/internal/schema"
)
func TestDescriptorProposalsAreSegmentIdempotentAndDefaultSensitive(t *testing.T) {
ctx := context.Background()
store := testStore(t)
defer store.Close()
now := time.Date(2026, 8, 17, 7, 30, 0, 0, time.UTC)
token, err := store.CreateSource(ctx, "source-a", model.Scope{OrganizationID: "organization-a", ProjectID: "project-a", EnvironmentID: "production", ServiceID: "service-a"})
if err != nil {
t.Fatal(err)
}
batch := model.Batch{Version: model.BatchVersion, SourceID: "source-a", StreamID: "metrics", Sequence: 1, ObservedAt: now, Signal: model.SignalMetrics, Records: []model.Observation{
{Timestamp: now, Name: "custom.metric", Value: floatPointer(1), Attributes: map[string]string{"workshop.queue_depth": "9238471928374", "http.route": "/known", "invalid-field": "ignored"}},
{Timestamp: now, Name: "custom.metric", Value: floatPointer(2), Attributes: map[string]string{"workshop.queue_depth": "9238471928375"}},
}}
ack, err := store.Ingest(ctx, token, batch, now)
if err != nil {
t.Fatal(err)
}
projectAll(t, store)
proposals, err := store.DescriptorProposals(ctx, "organization-a")
if err != nil {
t.Fatal(err)
}
if len(proposals) != 1 {
t.Fatalf("proposals=%+v", proposals)
}
proposal := proposals[0]
if proposal.Proposal.Descriptor.Field != "workshop.queue_depth" || proposal.Proposal.Descriptor.Type != schema.TypeInteger || proposal.Proposal.Descriptor.Sensitivity != schema.SensitivitySensitive || proposal.Proposal.Descriptor.Index != schema.IndexNone || proposal.Proposal.ObservedValues != 2 || proposal.Status != "pending" {
t.Fatalf("proposal=%+v", proposal)
}
if len(proposal.Proposal.ExampleQueries) != 1 || proposal.Proposal.ExampleQueries[0] != `metrics | where workshop.queue_depth == "value" | limit 50` {
t.Fatalf("examples=%v", proposal.Proposal.ExampleQueries)
}
encoded, err := json.Marshal(proposals)
if err != nil || strings.Contains(string(encoded), "9238471928374") || strings.Contains(string(encoded), "9238471928375") {
t.Fatalf("observed values entered proposal metadata: %s err=%v", encoded, err)
}
if err = store.recordDescriptorProposals(ctx, "organization-a", batch, ack.Digest, now.Add(time.Minute)); err != nil {
t.Fatal(err)
}
proposals, err = store.DescriptorProposals(ctx, "organization-a")
if err != nil || proposals[0].Proposal.ObservedValues != 2 || !proposals[0].LastSeenAt.Equal(now) {
t.Fatalf("idempotent proposals=%+v err=%v", proposals, err)
}
}
func TestDescriptorProposalTypesWidenWithoutMixingOrganizations(t *testing.T) {
ctx := context.Background()
store := testStore(t)
defer store.Close()
now := time.Date(2026, 8, 17, 7, 30, 0, 0, time.UTC)
for index, organization := range []string{"organization-a", "organization-b"} {
source := "source-" + string(rune('a'+index))
token, err := store.CreateSource(ctx, source, model.Scope{OrganizationID: organization, ProjectID: "project", EnvironmentID: "production", ServiceID: "service"})
if err != nil {
t.Fatal(err)
}
value := "10"
if organization == "organization-b" {
value = "label"
}
batch := model.Batch{Version: model.BatchVersion, SourceID: source, StreamID: "metrics", Sequence: 1, ObservedAt: now, Signal: model.SignalMetrics, Records: []model.Observation{{Timestamp: now, Name: "custom.metric", Value: floatPointer(1), Attributes: map[string]string{"workshop.value": value}}}}
if _, err = store.Ingest(ctx, token, batch, now); err != nil {
t.Fatal(err)
}
if organization == "organization-a" {
batch.Sequence = 2
batch.Records[0].Attributes["workshop.value"] = "10.5"
if _, err = store.Ingest(ctx, token, batch, now.Add(time.Second)); err != nil {
t.Fatal(err)
}
}
}
projectAll(t, store)
proposalA, err := store.DescriptorProposals(ctx, "organization-a")
if err != nil {
t.Fatal(err)
}
proposalB, err := store.DescriptorProposals(ctx, "organization-b")
if err != nil {
t.Fatal(err)
}
if proposalA[0].Proposal.Descriptor.Type != schema.TypeFloat || proposalB[0].Proposal.Descriptor.Type != schema.TypeString {
t.Fatalf("organization-a=%+v organization-b=%+v", proposalA, proposalB)
}
}
func floatPointer(value float64) *float64 { return &value }

Some files were not shown because too many files have changed in this diff Show More