valkey + gitea + long build
This commit is contained in:
@@ -0,0 +1,56 @@
|
|||||||
|
APP_NAME = Gitea
|
||||||
|
RUN_USER = git
|
||||||
|
RUN_MODE = prod
|
||||||
|
WORK_PATH = /mnt/volume/gitea
|
||||||
|
|
||||||
|
[server]
|
||||||
|
PROTOCOL = http
|
||||||
|
DOMAIN = %%GITEA_DOMAIN%%
|
||||||
|
ROOT_URL = %%GITEA_ROOT_URL%%
|
||||||
|
HTTP_ADDR = 0.0.0.0
|
||||||
|
HTTP_PORT = 3000
|
||||||
|
SSH_DOMAIN = %%GITEA_DOMAIN%%
|
||||||
|
START_SSH_SERVER = true
|
||||||
|
SSH_PORT = 2222
|
||||||
|
SSH_LISTEN_PORT = 2222
|
||||||
|
LFS_START_SERVER = true
|
||||||
|
APP_DATA_PATH = /mnt/volume/gitea/data
|
||||||
|
DISABLE_SSH = false
|
||||||
|
LFS_JWT_SECRET = %%LFS_JWT_SECRET%%
|
||||||
|
|
||||||
|
[database]
|
||||||
|
DB_TYPE = postgres
|
||||||
|
HOST = %%DB_HOST%%:%%DB_PORT%%
|
||||||
|
NAME = %%DB_NAME%%
|
||||||
|
USER = %%DB_USER%%
|
||||||
|
PASSWD = %%DB_PASSWORD%%
|
||||||
|
SSL_MODE = disable
|
||||||
|
SCHEMA = public
|
||||||
|
|
||||||
|
[repository]
|
||||||
|
ROOT = /mnt/volume/gitea/data/gitea-repositories
|
||||||
|
|
||||||
|
[lfs]
|
||||||
|
PATH = /mnt/volume/gitea/data/lfs
|
||||||
|
|
||||||
|
[log]
|
||||||
|
ROOT_PATH = /mnt/volume/gitea/log
|
||||||
|
MODE = console
|
||||||
|
LEVEL = info
|
||||||
|
|
||||||
|
[service]
|
||||||
|
DISABLE_REGISTRATION = true
|
||||||
|
REQUIRE_SIGNIN_VIEW = false
|
||||||
|
DEFAULT_KEEP_EMAIL_PRIVATE = true
|
||||||
|
DEFAULT_ALLOW_CREATE_ORGANIZATION = true
|
||||||
|
|
||||||
|
[security]
|
||||||
|
INSTALL_LOCK = true
|
||||||
|
SECRET_KEY = %%SECRET_KEY%%
|
||||||
|
INTERNAL_TOKEN = %%INTERNAL_TOKEN%%
|
||||||
|
|
||||||
|
[oauth2]
|
||||||
|
JWT_SECRET = %%JWT_SECRET%%
|
||||||
|
|
||||||
|
[session]
|
||||||
|
PROVIDER = file
|
||||||
Executable
+16
@@ -0,0 +1,16 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Generates JWT_SECRET, SECRET_KEY and INTERNAL_TOKEN in the exact format Gitea expects.
|
||||||
|
# Run once, then paste the three lines into Zerops as secret env vars for the gitea service.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
GITEA_VERSION="${GITEA_VERSION:-1.26.1}"
|
||||||
|
BIN=$(mktemp)
|
||||||
|
trap 'rm -f "$BIN"' EXIT
|
||||||
|
|
||||||
|
wget -qO "$BIN" "https://dl.gitea.com/gitea/${GITEA_VERSION}/gitea-${GITEA_VERSION}-linux-amd64"
|
||||||
|
chmod +x "$BIN"
|
||||||
|
|
||||||
|
echo "JWT_SECRET=$("$BIN" generate secret JWT_SECRET)"
|
||||||
|
echo "SECRET_KEY=$("$BIN" generate secret SECRET_KEY)"
|
||||||
|
echo "INTERNAL_TOKEN=$("$BIN" generate secret INTERNAL_TOKEN)"
|
||||||
|
echo "LFS_JWT_SECRET=$("$BIN" generate secret LFS_JWT_SECRET)"
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
zerops:
|
||||||
|
- setup: gitea
|
||||||
|
build:
|
||||||
|
base: ubuntu@24.04
|
||||||
|
deployFiles: app.ini
|
||||||
|
run:
|
||||||
|
base: ubuntu@24.04
|
||||||
|
ports:
|
||||||
|
- port: 3000
|
||||||
|
httpSupport: true
|
||||||
|
- port: 2222
|
||||||
|
envVariables:
|
||||||
|
GITEA_VERSION: 1.26.1
|
||||||
|
GITEA_WORK_DIR: /mnt/volume/gitea
|
||||||
|
GITEA_DOMAIN: git.matejpavlicek.cz
|
||||||
|
GITEA_ROOT_URL: https://git.matejpavlicek.cz
|
||||||
|
DB_HOST: db
|
||||||
|
DB_PORT: 5432
|
||||||
|
DB_NAME: giteadb
|
||||||
|
DB_USER: gitea
|
||||||
|
envReplace:
|
||||||
|
delimiter: "%%"
|
||||||
|
target:
|
||||||
|
- app.ini
|
||||||
|
prepareCommands:
|
||||||
|
- sudo apt-get update
|
||||||
|
- sudo apt-get install -y --no-install-recommends git ca-certificates gettext-base wget gpg
|
||||||
|
- wget -q -O /tmp/gitea https://dl.gitea.com/gitea/${GITEA_VERSION}/gitea-${GITEA_VERSION}-linux-amd64
|
||||||
|
- wget -q -O /tmp/gitea.asc https://dl.gitea.com/gitea/${GITEA_VERSION}/gitea-${GITEA_VERSION}-linux-amd64.asc
|
||||||
|
- gpg --keyserver hkps://keys.openpgp.org --recv 7C9E68152594688862D62AF62D9AE806EC1592E2
|
||||||
|
- gpg --verify /tmp/gitea.asc /tmp/gitea
|
||||||
|
- sudo install -m 755 /tmp/gitea /usr/local/bin/gitea
|
||||||
|
- sudo groupadd --system git
|
||||||
|
- sudo useradd --system --gid git --shell /bin/bash --home-dir /home/git --create-home git
|
||||||
|
- sudo mkdir -p /etc/gitea
|
||||||
|
- sudo chown root:git /etc/gitea
|
||||||
|
- sudo chmod 770 /etc/gitea
|
||||||
|
initCommands:
|
||||||
|
- until mountpoint -q /mnt/volume; do sleep 1; done
|
||||||
|
- sudo mkdir -p /mnt/volume/gitea/{custom,data,indexers,public,log}
|
||||||
|
- sudo chown -R git:git /mnt/volume/gitea
|
||||||
|
- sudo chmod -R 750 /mnt/volume/gitea
|
||||||
|
- sudo install -m 660 -o root -g git /var/www/app.ini /etc/gitea/app.ini
|
||||||
|
start: sudo -u git -E /usr/local/bin/gitea web --config /etc/gitea/app.ini
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
services:
|
||||||
|
- hostname: app
|
||||||
|
type: ubuntu@24.04
|
||||||
|
maxContainers: 1
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
zerops:
|
||||||
|
- setup: app
|
||||||
|
build:
|
||||||
|
os: ubuntu
|
||||||
|
base: ubuntu@latest
|
||||||
|
buildCommands:
|
||||||
|
- echo "build start at $(date -Iseconds)"
|
||||||
|
# 1h10m sleep exercises the build-side prolong chain: zbuilder heartbeat -> znode -> zbusiness.
|
||||||
|
# Old 1h cap would kill it at the 60min mark; with prolong the process should comfortably reach the echo below.
|
||||||
|
- sleep 4200
|
||||||
|
- echo "build done at $(date -Iseconds)"
|
||||||
|
deployFiles:
|
||||||
|
- ./
|
||||||
|
run:
|
||||||
|
os: ubuntu
|
||||||
|
base: ubuntu@latest
|
||||||
|
prepareCommands:
|
||||||
|
- echo "prepare start at $(date -Iseconds)"
|
||||||
|
# ~100 GiB of incompressible random data, written to the runtime image filesystem.
|
||||||
|
# status=progress prints throughput every second so we can watch from the dashboard.
|
||||||
|
- dd if=/dev/urandom of=/var/www/bigfile bs=1M count=20480 status=progress
|
||||||
|
- echo "image bloated at $(date -Iseconds)"
|
||||||
|
start: tail -f /dev/null
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
valkey-test
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
-----BEGIN CERTIFICATE-----
|
||||||
|
MIIB8TCCAZagAwIBAgIQbwx3ZUCaU9IQr+ynltMGsTAKBggqhkjOPQQDAjA2MRUw
|
||||||
|
EwYDVQQKEwxaZXJvcHMgSW5mcmExHTAbBgNVBAMTFFplcm9wcyBJbmZyYSBSb290
|
||||||
|
IENBMB4XDTI1MDkwMTA3NDMzOVoXDTI3MDkwMTA3NDMxM1owVjELMAkGA1UEBhMC
|
||||||
|
Q1oxDzANBgNVBAoTBlplcm9wczEQMA4GA1UECxMHU2VydmljZTEkMCIGA1UEAxMb
|
||||||
|
WmVyb3BzIFNlcnZpY2UgSW50ZXJtZWRpYXRlMFkwEwYHKoZIzj0CAQYIKoZIzj0D
|
||||||
|
AQcDQgAEeJwBKQfdfdRR7QJWVLegIXvn1k8SqUo8dp8bY2Fj9PUSNsTZ4yDsgpGo
|
||||||
|
Zummp6P92mOookbowR1nc0d8TyRYs6NmMGQwDgYDVR0PAQH/BAQDAgEGMBIGA1Ud
|
||||||
|
EwEB/wQIMAYBAf8CAQAwHQYDVR0OBBYEFLhO0kbsvwnGvuBp+gBy82mzsMbzMB8G
|
||||||
|
A1UdIwQYMBaAFJIVumuZisDXAdV9359xveeP3HkhMAoGCCqGSM49BAMCA0kAMEYC
|
||||||
|
IQCanJOFmOCQjnC/0oGQTHKMmmfPcCXp82XOYDnfY6YEGAIhAPYpIizvP5RpLymG
|
||||||
|
fn7wyg9VTA7W2wxF0ToJUXOKJO5O
|
||||||
|
-----END CERTIFICATE-----
|
||||||
+1520
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,897 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"sync"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Distributed collector — HTTP dashboard that ingests events from workers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
//
|
||||||
|
// Endpoints:
|
||||||
|
// POST /events ingest a wireBatch from a worker container
|
||||||
|
// GET /api/state JSON snapshot for the live dashboard
|
||||||
|
// GET /api/events?cursor= JSON event log slice past the cursor
|
||||||
|
// GET / the dashboard HTML page
|
||||||
|
//
|
||||||
|
// Data model:
|
||||||
|
// workers keyed by "<containerID>/<workerName>" — same uiStats shape the
|
||||||
|
// TUI uses. A 1-second tick goroutine computes throughput deltas and p95
|
||||||
|
// over a sliding window of recent latencies.
|
||||||
|
|
||||||
|
type collectorWorker struct {
|
||||||
|
uiStats
|
||||||
|
containerID string
|
||||||
|
workerName string
|
||||||
|
}
|
||||||
|
|
||||||
|
type collectorState struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
|
||||||
|
startAt time.Time
|
||||||
|
|
||||||
|
// keyed by "<container>/<worker>"; iteration order preserved via slice
|
||||||
|
keys []string
|
||||||
|
workers map[string]*collectorWorker
|
||||||
|
|
||||||
|
events []eventLine
|
||||||
|
nextCursor int64
|
||||||
|
|
||||||
|
inconsistencies []eventLine
|
||||||
|
nextInconsCursor int64
|
||||||
|
|
||||||
|
// global series for the top-of-dashboard charts
|
||||||
|
p95Series []float64
|
||||||
|
okPerSecSeries []float64
|
||||||
|
failPerSecSeries []float64
|
||||||
|
missingPerSecSeries []float64
|
||||||
|
globalLat []time.Duration
|
||||||
|
prevTotalOps int64
|
||||||
|
prevTotalFail int64
|
||||||
|
prevTotalMissing int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func newCollectorState() *collectorState {
|
||||||
|
return &collectorState{
|
||||||
|
startAt: time.Now(),
|
||||||
|
workers: make(map[string]*collectorWorker),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- ingest -------------------------------------------------------------
|
||||||
|
|
||||||
|
func (c *collectorState) ingest(b wireBatch) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
|
for _, ev := range b.Events {
|
||||||
|
switch ev.Type {
|
||||||
|
case "op":
|
||||||
|
c.applyOp(b.ContainerID, ev)
|
||||||
|
case "sub":
|
||||||
|
c.applySub(b.ContainerID, ev)
|
||||||
|
case "ledger":
|
||||||
|
c.applyLedger(b.ContainerID, ev)
|
||||||
|
case "info":
|
||||||
|
c.appendEvent(ev.Level, ev.At, ev.Text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *collectorState) workerByKey(containerID, workerName string) *collectorWorker {
|
||||||
|
key := containerID + "/" + workerName
|
||||||
|
w, ok := c.workers[key]
|
||||||
|
if !ok {
|
||||||
|
w = &collectorWorker{containerID: containerID, workerName: workerName}
|
||||||
|
c.workers[key] = w
|
||||||
|
c.keys = append(c.keys, key)
|
||||||
|
sort.Strings(c.keys)
|
||||||
|
}
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *collectorState) applyOp(containerID string, ev wireEvent) {
|
||||||
|
w := c.workerByKey(containerID, ev.Worker)
|
||||||
|
if w.role == "" {
|
||||||
|
w.role = roleFromOp(ev.Op)
|
||||||
|
}
|
||||||
|
w.ops++
|
||||||
|
if !ev.OK {
|
||||||
|
// Surface every individual failure in the dashboard event log so
|
||||||
|
// the operator can see what's actually breaking, not just the
|
||||||
|
// summarized OUTAGE START/END boundaries.
|
||||||
|
c.appendEvent("err", ev.At,
|
||||||
|
fmt.Sprintf("[%s/%s] %s FAIL: %s",
|
||||||
|
containerID, ev.Worker, ev.Op, trim(ev.Err, 200)))
|
||||||
|
w.fail++
|
||||||
|
w.consecFail++
|
||||||
|
if !w.inOutage {
|
||||||
|
w.inOutage = true
|
||||||
|
w.outageStart = ev.At
|
||||||
|
w.outageOpsLost = 1
|
||||||
|
c.appendEvent("err", ev.At,
|
||||||
|
fmt.Sprintf("[%s/%s] OUTAGE START", containerID, ev.Worker))
|
||||||
|
} else {
|
||||||
|
w.outageOpsLost++
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.ok++
|
||||||
|
if w.inOutage {
|
||||||
|
w.outages = append(w.outages, outageRec{
|
||||||
|
start: w.outageStart, end: ev.At, opsLost: w.outageOpsLost,
|
||||||
|
})
|
||||||
|
w.inOutage = false
|
||||||
|
w.consecFail = 0
|
||||||
|
w.outageOpsLost = 0
|
||||||
|
}
|
||||||
|
if ev.DurUS > 0 {
|
||||||
|
dur := time.Duration(ev.DurUS) * time.Microsecond
|
||||||
|
w.recentLat = appendBoundedDur(w.recentLat, dur, maxRecentLat)
|
||||||
|
c.globalLat = appendBoundedDur(c.globalLat, dur, maxGlobalLat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *collectorState) applySub(containerID string, ev wireEvent) {
|
||||||
|
w := c.workerByKey(containerID, ev.Worker)
|
||||||
|
if w.role == "" {
|
||||||
|
w.role = "subscriber"
|
||||||
|
}
|
||||||
|
w.msgs++
|
||||||
|
if w.lastSeq != 0 && ev.Seq > w.lastSeq+1 {
|
||||||
|
gap := ev.Seq - w.lastSeq - 1
|
||||||
|
w.gaps += gap
|
||||||
|
}
|
||||||
|
if !w.lastSeqAt.IsZero() {
|
||||||
|
if d := ev.At.Sub(w.lastSeqAt); d > w.longestGap {
|
||||||
|
w.longestGap = d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
w.lastSeq = ev.Seq
|
||||||
|
w.lastSeqAt = ev.At
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *collectorState) applyLedger(containerID string, ev wireEvent) {
|
||||||
|
w := c.workerByKey(containerID, ev.Worker)
|
||||||
|
if w.role == "" {
|
||||||
|
w.role = "reader"
|
||||||
|
}
|
||||||
|
switch ev.Kind {
|
||||||
|
case "ok":
|
||||||
|
w.verified++
|
||||||
|
case "missing":
|
||||||
|
w.missing++
|
||||||
|
c.appendInconsistency("err", ev.At,
|
||||||
|
fmt.Sprintf("[%s/%s] LEDGER MISSING seq=%d %s",
|
||||||
|
containerID, ev.Worker, ev.Seq, ev.Detail))
|
||||||
|
case "mismatch":
|
||||||
|
w.mismatch++
|
||||||
|
c.appendInconsistency("err", ev.At,
|
||||||
|
fmt.Sprintf("[%s/%s] LEDGER MISMATCH seq=%d %s",
|
||||||
|
containerID, ev.Worker, ev.Seq, ev.Detail))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// roleFromOp infers a worker's role from the first op name we see, since
|
||||||
|
// workers don't currently transmit role with each event. Best-effort only.
|
||||||
|
func roleFromOp(op string) string {
|
||||||
|
switch op {
|
||||||
|
case "WRITE":
|
||||||
|
return "writer"
|
||||||
|
case "RW":
|
||||||
|
return "readwriter"
|
||||||
|
case "GET", "PING":
|
||||||
|
return "reader"
|
||||||
|
case "PUB":
|
||||||
|
return "publisher"
|
||||||
|
case "SUBSCRIBE":
|
||||||
|
return "subscriber"
|
||||||
|
case "BLOAT":
|
||||||
|
return "bloater"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *collectorState) appendEvent(level string, at time.Time, text string) {
|
||||||
|
if at.IsZero() {
|
||||||
|
at = time.Now()
|
||||||
|
}
|
||||||
|
if level == "" {
|
||||||
|
level = "info"
|
||||||
|
}
|
||||||
|
if len(c.events) >= maxEvents {
|
||||||
|
copy(c.events, c.events[1:])
|
||||||
|
c.events = c.events[:len(c.events)-1]
|
||||||
|
}
|
||||||
|
c.events = append(c.events, eventLine{at: at, level: level, text: text})
|
||||||
|
c.nextCursor++
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *collectorState) appendInconsistency(level string, at time.Time, text string) {
|
||||||
|
if at.IsZero() {
|
||||||
|
at = time.Now()
|
||||||
|
}
|
||||||
|
if level == "" {
|
||||||
|
level = "info"
|
||||||
|
}
|
||||||
|
if len(c.inconsistencies) >= maxEvents {
|
||||||
|
copy(c.inconsistencies, c.inconsistencies[1:])
|
||||||
|
c.inconsistencies = c.inconsistencies[:len(c.inconsistencies)-1]
|
||||||
|
}
|
||||||
|
c.inconsistencies = append(c.inconsistencies, eventLine{at: at, level: level, text: text})
|
||||||
|
c.nextInconsCursor++
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- tick (1Hz) ---------------------------------------------------------
|
||||||
|
|
||||||
|
func (c *collectorState) tick() {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
|
if len(c.globalLat) > 0 {
|
||||||
|
cp := make([]time.Duration, len(c.globalLat))
|
||||||
|
copy(cp, c.globalLat)
|
||||||
|
sort.Slice(cp, func(i, j int) bool { return cp[i] < cp[j] })
|
||||||
|
idx := 95 * len(cp) / 100
|
||||||
|
if idx >= len(cp) {
|
||||||
|
idx = len(cp) - 1
|
||||||
|
}
|
||||||
|
p95ms := float64(cp[idx]) / float64(time.Millisecond)
|
||||||
|
c.p95Series = appendBoundedFloat(c.p95Series, p95ms, maxSeriesPoints)
|
||||||
|
} else {
|
||||||
|
c.p95Series = appendBoundedFloat(c.p95Series, 0, maxSeriesPoints)
|
||||||
|
}
|
||||||
|
|
||||||
|
var totalOps, totalFail, totalMissing int64
|
||||||
|
for _, w := range c.workers {
|
||||||
|
totalOps += w.ops + w.msgs
|
||||||
|
totalFail += w.fail
|
||||||
|
totalMissing += w.missing
|
||||||
|
}
|
||||||
|
okDelta := (totalOps - totalFail) - (c.prevTotalOps - c.prevTotalFail)
|
||||||
|
if okDelta < 0 {
|
||||||
|
okDelta = 0
|
||||||
|
}
|
||||||
|
failDelta := totalFail - c.prevTotalFail
|
||||||
|
if failDelta < 0 {
|
||||||
|
failDelta = 0
|
||||||
|
}
|
||||||
|
missingDelta := totalMissing - c.prevTotalMissing
|
||||||
|
if missingDelta < 0 {
|
||||||
|
missingDelta = 0
|
||||||
|
}
|
||||||
|
c.prevTotalOps = totalOps
|
||||||
|
c.prevTotalFail = totalFail
|
||||||
|
c.prevTotalMissing = totalMissing
|
||||||
|
c.okPerSecSeries = appendBoundedFloat(c.okPerSecSeries, float64(okDelta), maxSeriesPoints)
|
||||||
|
c.failPerSecSeries = appendBoundedFloat(c.failPerSecSeries, float64(failDelta), maxSeriesPoints)
|
||||||
|
c.missingPerSecSeries = appendBoundedFloat(c.missingPerSecSeries, float64(missingDelta), maxSeriesPoints)
|
||||||
|
|
||||||
|
for _, w := range c.workers {
|
||||||
|
current := w.ops + w.msgs
|
||||||
|
delta := current - w.prevOps
|
||||||
|
if delta < 0 {
|
||||||
|
delta = 0
|
||||||
|
}
|
||||||
|
w.prevOps = current
|
||||||
|
w.opsHist = appendBoundedFloat(w.opsHist, float64(delta), maxSparkSamples)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- snapshot view ------------------------------------------------------
|
||||||
|
|
||||||
|
type stateView struct {
|
||||||
|
StartAt time.Time `json:"start_at"`
|
||||||
|
Elapsed string `json:"elapsed"`
|
||||||
|
Containers int `json:"containers"`
|
||||||
|
InOutage int `json:"in_outage"`
|
||||||
|
Workers []workerView `json:"workers"`
|
||||||
|
Charts chartsView `json:"charts"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type workerView struct {
|
||||||
|
Container string `json:"container"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
Ops int64 `json:"ops"`
|
||||||
|
OK int64 `json:"ok"`
|
||||||
|
Fail int64 `json:"fail"`
|
||||||
|
UptimePct float64 `json:"uptime_pct"`
|
||||||
|
P50MS float64 `json:"p50_ms"`
|
||||||
|
P95MS float64 `json:"p95_ms"`
|
||||||
|
OpsHist []float64 `json:"ops_hist"`
|
||||||
|
InOutage bool `json:"in_outage"`
|
||||||
|
Outages int `json:"outages"`
|
||||||
|
LongestMS float64 `json:"longest_outage_ms"`
|
||||||
|
// reader-only
|
||||||
|
Verified int64 `json:"verified,omitempty"`
|
||||||
|
Missing int64 `json:"missing,omitempty"`
|
||||||
|
Mismatch int64 `json:"mismatch,omitempty"`
|
||||||
|
// subscriber-only
|
||||||
|
Msgs int64 `json:"msgs,omitempty"`
|
||||||
|
Gaps int64 `json:"gaps,omitempty"`
|
||||||
|
LongestGapMS float64 `json:"longest_gap_ms,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type chartsView struct {
|
||||||
|
P95 []float64 `json:"p95"`
|
||||||
|
OkPerSec []float64 `json:"ok_per_sec"`
|
||||||
|
FailPerSec []float64 `json:"fail_per_sec"`
|
||||||
|
MissingPerSec []float64 `json:"missing_per_sec"`
|
||||||
|
P95Peak float64 `json:"p95_peak"`
|
||||||
|
OkPeak float64 `json:"ok_peak"`
|
||||||
|
FailPeak float64 `json:"fail_peak"`
|
||||||
|
MissingPeak float64 `json:"missing_peak"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *collectorState) snapshot() stateView {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
|
containers := map[string]struct{}{}
|
||||||
|
for _, k := range c.keys {
|
||||||
|
w := c.workers[k]
|
||||||
|
containers[w.containerID] = struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
view := stateView{
|
||||||
|
StartAt: c.startAt,
|
||||||
|
Elapsed: time.Since(c.startAt).Round(time.Second).String(),
|
||||||
|
Containers: len(containers),
|
||||||
|
Charts: chartsView{
|
||||||
|
P95: append([]float64(nil), c.p95Series...),
|
||||||
|
OkPerSec: append([]float64(nil), c.okPerSecSeries...),
|
||||||
|
FailPerSec: append([]float64(nil), c.failPerSecSeries...),
|
||||||
|
MissingPerSec: append([]float64(nil), c.missingPerSecSeries...),
|
||||||
|
P95Peak: maxFloat(c.p95Series),
|
||||||
|
OkPeak: maxFloat(c.okPerSecSeries),
|
||||||
|
FailPeak: maxFloat(c.failPerSecSeries),
|
||||||
|
MissingPeak: maxFloat(c.missingPerSecSeries),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, k := range c.keys {
|
||||||
|
w := c.workers[k]
|
||||||
|
uptime := 100.0
|
||||||
|
if w.ops > 0 {
|
||||||
|
uptime = float64(w.ok) / float64(w.ops) * 100
|
||||||
|
}
|
||||||
|
var longest time.Duration
|
||||||
|
for _, o := range w.outages {
|
||||||
|
if d := o.end.Sub(o.start); d > longest {
|
||||||
|
longest = d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if w.inOutage {
|
||||||
|
view.InOutage++
|
||||||
|
}
|
||||||
|
wv := workerView{
|
||||||
|
Container: w.containerID,
|
||||||
|
Name: w.workerName,
|
||||||
|
Role: w.role,
|
||||||
|
Ops: w.ops,
|
||||||
|
OK: w.ok,
|
||||||
|
Fail: w.fail,
|
||||||
|
UptimePct: uptime,
|
||||||
|
P50MS: float64(percentileLat(w.recentLat, 50)) / float64(time.Millisecond),
|
||||||
|
P95MS: float64(percentileLat(w.recentLat, 95)) / float64(time.Millisecond),
|
||||||
|
OpsHist: append([]float64(nil), w.opsHist...),
|
||||||
|
InOutage: w.inOutage,
|
||||||
|
Outages: len(w.outages),
|
||||||
|
LongestMS: float64(longest) / float64(time.Millisecond),
|
||||||
|
}
|
||||||
|
switch w.role {
|
||||||
|
case "reader":
|
||||||
|
wv.Verified = w.verified
|
||||||
|
wv.Missing = w.missing
|
||||||
|
wv.Mismatch = w.mismatch
|
||||||
|
case "subscriber":
|
||||||
|
wv.Msgs = w.msgs
|
||||||
|
wv.Gaps = w.gaps
|
||||||
|
wv.LongestGapMS = float64(w.longestGap) / float64(time.Millisecond)
|
||||||
|
}
|
||||||
|
view.Workers = append(view.Workers, wv)
|
||||||
|
}
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
|
type eventOut struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
At string `json:"at"`
|
||||||
|
Level string `json:"level"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *collectorState) eventsSince(cursor int64) ([]eventOut, int64) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
|
// nextCursor counts events ever inserted; events slice may have dropped older.
|
||||||
|
// Compute base id of the first slot in the current slice:
|
||||||
|
baseID := c.nextCursor - int64(len(c.events))
|
||||||
|
if cursor < baseID {
|
||||||
|
cursor = baseID
|
||||||
|
}
|
||||||
|
startIdx := int(cursor - baseID)
|
||||||
|
if startIdx < 0 {
|
||||||
|
startIdx = 0
|
||||||
|
}
|
||||||
|
if startIdx > len(c.events) {
|
||||||
|
startIdx = len(c.events)
|
||||||
|
}
|
||||||
|
out := make([]eventOut, 0, len(c.events)-startIdx)
|
||||||
|
for i := startIdx; i < len(c.events); i++ {
|
||||||
|
e := c.events[i]
|
||||||
|
out = append(out, eventOut{
|
||||||
|
ID: baseID + int64(i),
|
||||||
|
At: e.at.Format("15:04:05.000"),
|
||||||
|
Level: e.level,
|
||||||
|
Text: e.text,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out, c.nextCursor
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *collectorState) inconsistenciesSince(cursor int64) ([]eventOut, int64) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
|
baseID := c.nextInconsCursor - int64(len(c.inconsistencies))
|
||||||
|
if cursor < baseID {
|
||||||
|
cursor = baseID
|
||||||
|
}
|
||||||
|
startIdx := int(cursor - baseID)
|
||||||
|
if startIdx < 0 {
|
||||||
|
startIdx = 0
|
||||||
|
}
|
||||||
|
if startIdx > len(c.inconsistencies) {
|
||||||
|
startIdx = len(c.inconsistencies)
|
||||||
|
}
|
||||||
|
out := make([]eventOut, 0, len(c.inconsistencies)-startIdx)
|
||||||
|
for i := startIdx; i < len(c.inconsistencies); i++ {
|
||||||
|
e := c.inconsistencies[i]
|
||||||
|
out = append(out, eventOut{
|
||||||
|
ID: baseID + int64(i),
|
||||||
|
At: e.at.Format("15:04:05.000"),
|
||||||
|
Level: e.level,
|
||||||
|
Text: e.text,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out, c.nextInconsCursor
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- HTTP server --------------------------------------------------------
|
||||||
|
|
||||||
|
func runCollector(port int) int {
|
||||||
|
state := newCollectorState()
|
||||||
|
|
||||||
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||||
|
defer stop()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
t := time.NewTicker(tickInterval)
|
||||||
|
defer t.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-t.C:
|
||||||
|
state.tick()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/events", state.handleIngest)
|
||||||
|
mux.HandleFunc("/api/state", state.handleState)
|
||||||
|
mux.HandleFunc("/api/events", state.handleEvents)
|
||||||
|
mux.HandleFunc("/api/inconsistencies", state.handleInconsistencies)
|
||||||
|
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
})
|
||||||
|
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/" {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
_, _ = w.Write([]byte(dashboardHTML))
|
||||||
|
})
|
||||||
|
|
||||||
|
srv := &http.Server{
|
||||||
|
Addr: fmt.Sprintf(":%d", port),
|
||||||
|
Handler: mux,
|
||||||
|
ReadHeaderTimeout: 5 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
log.Printf("collector listening on :%d", port)
|
||||||
|
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||||
|
log.Fatalf("collector: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
<-ctx.Done()
|
||||||
|
log.Printf("collector shutting down…")
|
||||||
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
_ = srv.Shutdown(shutdownCtx)
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *collectorState) handleIngest(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "POST only", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var b wireBatch
|
||||||
|
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&b); err != nil {
|
||||||
|
http.Error(w, "bad request: "+err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.ingest(b)
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *collectorState) handleState(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
view := c.snapshot()
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(view)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *collectorState) handleEvents(w http.ResponseWriter, r *http.Request) {
|
||||||
|
cursor := int64(0)
|
||||||
|
if v := r.URL.Query().Get("cursor"); v != "" {
|
||||||
|
if n, err := strconv.ParseInt(v, 10, 64); err == nil {
|
||||||
|
cursor = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
events, next := c.eventsSince(cursor)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"events": events,
|
||||||
|
"next_cursor": next,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *collectorState) handleInconsistencies(w http.ResponseWriter, r *http.Request) {
|
||||||
|
cursor := int64(0)
|
||||||
|
if v := r.URL.Query().Get("cursor"); v != "" {
|
||||||
|
if n, err := strconv.ParseInt(v, 10, 64); err == nil {
|
||||||
|
cursor = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
events, next := c.inconsistenciesSince(cursor)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"events": events,
|
||||||
|
"next_cursor": next,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- embedded dashboard HTML -------------------------------------------
|
||||||
|
|
||||||
|
const dashboardHTML = `<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>valkey-ha chaos</title>
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #0c0c10;
|
||||||
|
--panel: #15151c;
|
||||||
|
--border: #2a2a35;
|
||||||
|
--text: #e6e6ea;
|
||||||
|
--dim: #6c6c7a;
|
||||||
|
--accent: #d75faf;
|
||||||
|
--ok: #5fd75f;
|
||||||
|
--warn: #ffaf5f;
|
||||||
|
--err: #ff5f5f;
|
||||||
|
--chart: #af87ff;
|
||||||
|
--chart-fail: #ff5f5f;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body { font-family: ui-monospace, 'JetBrains Mono', Menlo, Consolas, monospace;
|
||||||
|
background: var(--bg); color: var(--text); margin: 0; padding: 16px;
|
||||||
|
font-size: 13px; line-height: 1.45; }
|
||||||
|
h1 { color: var(--accent); margin: 0 0 4px; font-size: 18px; }
|
||||||
|
.header-meta { color: var(--dim); margin-bottom: 14px; }
|
||||||
|
.badge { padding: 1px 6px; border-radius: 3px; font-weight: 600; }
|
||||||
|
.badge.ok { background: #1c3a1c; color: var(--ok); }
|
||||||
|
.badge.err { background: #401818; color: var(--err); }
|
||||||
|
.badge.warn { background: #402a18; color: var(--warn); }
|
||||||
|
.grid { display: grid; grid-template-columns: 1fr 1fr 1fr 1fr; gap: 14px; margin-bottom: 16px; }
|
||||||
|
@media (max-width: 1400px) { .grid { grid-template-columns: 1fr 1fr; } }
|
||||||
|
@media (max-width: 700px) { .grid { grid-template-columns: 1fr; } }
|
||||||
|
.panel { background: var(--panel); border: 1px solid var(--border);
|
||||||
|
border-radius: 4px; padding: 10px 12px; }
|
||||||
|
.panel h3 { margin: 0 0 6px; color: #5fafff; font-size: 12px;
|
||||||
|
text-transform: uppercase; letter-spacing: 0.06em; }
|
||||||
|
.panel .meta { color: var(--dim); font-size: 11px; margin-bottom: 4px; }
|
||||||
|
table { border-collapse: collapse; width: 100%; font-size: 12px; }
|
||||||
|
th, td { padding: 4px 8px; text-align: left; vertical-align: middle;
|
||||||
|
border-bottom: 1px solid var(--border); }
|
||||||
|
th { color: #5fafff; font-weight: 600; text-transform: uppercase;
|
||||||
|
font-size: 10px; letter-spacing: 0.05em; }
|
||||||
|
td.num { text-align: right; font-variant-numeric: tabular-nums; }
|
||||||
|
tr.outage td:first-child { border-left: 3px solid var(--err); }
|
||||||
|
.container-tag { color: var(--accent); }
|
||||||
|
.events { background: var(--panel); border: 1px solid var(--border);
|
||||||
|
border-radius: 4px; padding: 8px 10px; max-height: 50vh;
|
||||||
|
overflow-y: auto; font-size: 12px; }
|
||||||
|
.events .ev { padding: 1px 0; white-space: pre-wrap; word-break: break-word; }
|
||||||
|
.events .ev .ts { color: var(--dim); margin-right: 6px; }
|
||||||
|
.events .ev.err { color: var(--err); }
|
||||||
|
.events .ev.warn { color: var(--warn); }
|
||||||
|
.events .ev.info { color: var(--text); }
|
||||||
|
svg { display: block; width: 100%; height: 80px; }
|
||||||
|
polyline { fill: none; stroke: var(--chart); stroke-width: 1.4; }
|
||||||
|
.area { fill: var(--chart); fill-opacity: 0.18; stroke: none; }
|
||||||
|
.spark { font-family: ui-monospace, monospace; color: var(--chart); }
|
||||||
|
.dim { color: var(--dim); }
|
||||||
|
.filter { margin-bottom: 8px; }
|
||||||
|
.filter input { background: var(--panel); color: var(--text);
|
||||||
|
border: 1px solid var(--border); padding: 4px 8px;
|
||||||
|
border-radius: 3px; font: inherit; }
|
||||||
|
.footer { color: var(--dim); margin-top: 12px; font-size: 11px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>valkey-ha chaos</h1>
|
||||||
|
<div class="header-meta" id="header">connecting…</div>
|
||||||
|
|
||||||
|
<div class="grid">
|
||||||
|
<div class="panel">
|
||||||
|
<h3>p95 latency (rolling 2 min)</h3>
|
||||||
|
<div class="meta" id="lat-meta">—</div>
|
||||||
|
<svg id="chart-lat" viewBox="0 0 100 30" preserveAspectRatio="none"></svg>
|
||||||
|
</div>
|
||||||
|
<div class="panel">
|
||||||
|
<h3>ok ops/sec (rolling 2 min)</h3>
|
||||||
|
<div class="meta" id="tp-meta">—</div>
|
||||||
|
<svg id="chart-tp" viewBox="0 0 100 30" preserveAspectRatio="none"></svg>
|
||||||
|
</div>
|
||||||
|
<div class="panel">
|
||||||
|
<h3>fail ops/sec (rolling 2 min)</h3>
|
||||||
|
<div class="meta" id="fail-meta">—</div>
|
||||||
|
<svg id="chart-fail" viewBox="0 0 100 30" preserveAspectRatio="none"></svg>
|
||||||
|
</div>
|
||||||
|
<div class="panel">
|
||||||
|
<h3>missing ledger/sec (rolling 2 min)</h3>
|
||||||
|
<div class="meta" id="miss-meta">—</div>
|
||||||
|
<svg id="chart-miss" viewBox="0 0 100 30" preserveAspectRatio="none"></svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel" style="margin-bottom:16px">
|
||||||
|
<h3>workers</h3>
|
||||||
|
<div class="filter">
|
||||||
|
<input id="filter" placeholder="filter by container or worker name" />
|
||||||
|
</div>
|
||||||
|
<table id="workers">
|
||||||
|
<thead><tr>
|
||||||
|
<th>CONTAINER</th><th>NAME</th><th>ROLE</th>
|
||||||
|
<th class="num">OPS</th><th class="num">OK</th><th class="num">FAIL</th>
|
||||||
|
<th class="num">UPTIME</th><th class="num">P50</th><th class="num">P95</th>
|
||||||
|
<th>SPARK</th><th class="num">OUTAGES</th><th>STATUS</th><th>NOTES</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 style="color:#5fafff;font-size:12px;text-transform:uppercase;
|
||||||
|
letter-spacing:0.06em;margin:0 0 6px">client errors</h3>
|
||||||
|
<div class="events" id="events"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-top:16px">
|
||||||
|
<h3 style="color:#5fafff;font-size:12px;text-transform:uppercase;
|
||||||
|
letter-spacing:0.06em;margin:0 0 6px">ledger inconsistencies</h3>
|
||||||
|
<div class="events" id="inconsistencies"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer" id="footer">collector dashboard · poll 1s</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const sparkChars = [' ','▁','▂','▃','▄','▅','▆','▇','█'];
|
||||||
|
function spark(values, width) {
|
||||||
|
if (!values || !values.length) return '';
|
||||||
|
const max = Math.max(...values);
|
||||||
|
if (max <= 0) return ' '.repeat(width);
|
||||||
|
let out = '';
|
||||||
|
for (let col = 0; col < width; col++) {
|
||||||
|
const idx = Math.min(values.length - 1, Math.floor(col * values.length / width));
|
||||||
|
const v = values[idx];
|
||||||
|
const ratio = v / max;
|
||||||
|
const i = Math.max(0, Math.min(8, Math.floor(ratio * 8)));
|
||||||
|
out += sparkChars[i];
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawChart(svgID, values, color) {
|
||||||
|
const stroke = color || 'var(--chart)';
|
||||||
|
const svg = document.getElementById(svgID);
|
||||||
|
svg.innerHTML = '';
|
||||||
|
if (!values || !values.length) return;
|
||||||
|
const max = Math.max(1, ...values);
|
||||||
|
const w = 100, h = 30;
|
||||||
|
const step = w / Math.max(1, values.length - 1);
|
||||||
|
let path = '';
|
||||||
|
for (let i = 0; i < values.length; i++) {
|
||||||
|
const x = i * step;
|
||||||
|
const y = h - (values[i] / max) * h;
|
||||||
|
path += (i === 0 ? 'M' : 'L') + x.toFixed(2) + ',' + y.toFixed(2) + ' ';
|
||||||
|
}
|
||||||
|
const ns = 'http://www.w3.org/2000/svg';
|
||||||
|
const area = document.createElementNS(ns, 'path');
|
||||||
|
area.setAttribute('fill', stroke);
|
||||||
|
area.setAttribute('fill-opacity', '0.18');
|
||||||
|
area.setAttribute('stroke', 'none');
|
||||||
|
area.setAttribute('d', path + 'L' + w + ',' + h + ' L0,' + h + ' Z');
|
||||||
|
svg.appendChild(area);
|
||||||
|
const line = document.createElementNS(ns, 'path');
|
||||||
|
line.setAttribute('d', path);
|
||||||
|
line.setAttribute('fill', 'none');
|
||||||
|
line.setAttribute('stroke', stroke);
|
||||||
|
line.setAttribute('stroke-width', '1.2');
|
||||||
|
svg.appendChild(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmt(n) { return n == null ? '-' : n.toLocaleString(); }
|
||||||
|
function fmtMS(v) {
|
||||||
|
if (v == null || v === 0) return '-';
|
||||||
|
if (v < 1) return (v * 1000).toFixed(0) + 'µs';
|
||||||
|
if (v < 1000) return v.toFixed(1) + 'ms';
|
||||||
|
return (v / 1000).toFixed(2) + 's';
|
||||||
|
}
|
||||||
|
|
||||||
|
let cursor = 0;
|
||||||
|
let inconsCursor = 0;
|
||||||
|
let lastWorkers = [];
|
||||||
|
let filterStr = '';
|
||||||
|
|
||||||
|
document.getElementById('filter').addEventListener('input', e => {
|
||||||
|
filterStr = e.target.value.toLowerCase();
|
||||||
|
renderTable(lastWorkers);
|
||||||
|
});
|
||||||
|
|
||||||
|
function renderTable(workers) {
|
||||||
|
const tbody = document.querySelector('#workers tbody');
|
||||||
|
tbody.innerHTML = '';
|
||||||
|
for (const w of workers) {
|
||||||
|
if (filterStr) {
|
||||||
|
const hay = (w.container + ' ' + w.name + ' ' + w.role).toLowerCase();
|
||||||
|
if (!hay.includes(filterStr)) continue;
|
||||||
|
}
|
||||||
|
const tr = document.createElement('tr');
|
||||||
|
if (w.in_outage) tr.classList.add('outage');
|
||||||
|
const status = w.in_outage
|
||||||
|
? '<span class="badge err">OUTAGE</span>'
|
||||||
|
: (w.role === 'subscriber' && w.msgs === 0
|
||||||
|
? '<span class="badge warn">NO MSGS</span>'
|
||||||
|
: '<span class="badge ok">OK</span>');
|
||||||
|
let notes = '';
|
||||||
|
if (w.role === 'reader') {
|
||||||
|
notes = '<span class="dim">v=' + fmt(w.verified) +
|
||||||
|
' miss=' + fmt(w.missing) +
|
||||||
|
' mis=' + fmt(w.mismatch) + '</span>';
|
||||||
|
} else if (w.role === 'subscriber') {
|
||||||
|
notes = '<span class="dim">msgs=' + fmt(w.msgs) +
|
||||||
|
' gaps=' + fmt(w.gaps) +
|
||||||
|
' longest_gap=' + fmtMS(w.longest_gap_ms) + '</span>';
|
||||||
|
}
|
||||||
|
tr.innerHTML =
|
||||||
|
'<td><span class="container-tag">' + w.container + '</span></td>' +
|
||||||
|
'<td>' + w.name + '</td>' +
|
||||||
|
'<td>' + (w.role || '?') + '</td>' +
|
||||||
|
'<td class="num">' + fmt(w.ops) + '</td>' +
|
||||||
|
'<td class="num">' + fmt(w.ok) + '</td>' +
|
||||||
|
'<td class="num">' + fmt(w.fail) + '</td>' +
|
||||||
|
'<td class="num">' + (w.uptime_pct != null ? w.uptime_pct.toFixed(2) + '%' : '-') + '</td>' +
|
||||||
|
'<td class="num">' + fmtMS(w.p50_ms) + '</td>' +
|
||||||
|
'<td class="num">' + fmtMS(w.p95_ms) + '</td>' +
|
||||||
|
'<td><span class="spark">' + spark(w.ops_hist, 14) + '</span></td>' +
|
||||||
|
'<td class="num">' + fmt(w.outages) + '</td>' +
|
||||||
|
'<td>' + status + '</td>' +
|
||||||
|
'<td>' + notes + '</td>';
|
||||||
|
tbody.appendChild(tr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tickState() {
|
||||||
|
try {
|
||||||
|
const s = await (await fetch('/api/state')).json();
|
||||||
|
const tag = s.in_outage > 0
|
||||||
|
? '<span class="badge err">' + s.in_outage + ' worker(s) in outage</span>'
|
||||||
|
: '<span class="badge ok">healthy</span>';
|
||||||
|
document.getElementById('header').innerHTML =
|
||||||
|
s.containers + ' container(s) · ' + s.workers.length + ' worker(s) · elapsed ' +
|
||||||
|
s.elapsed + ' · ' + tag;
|
||||||
|
document.getElementById('lat-meta').textContent =
|
||||||
|
'peak ' + s.charts.p95_peak.toFixed(1) + 'ms · last ' +
|
||||||
|
(s.charts.p95.length ? s.charts.p95[s.charts.p95.length-1].toFixed(1) + 'ms' : '-');
|
||||||
|
document.getElementById('tp-meta').textContent =
|
||||||
|
'peak ' + Math.round(s.charts.ok_peak) + ' · last ' +
|
||||||
|
(s.charts.ok_per_sec.length ? Math.round(s.charts.ok_per_sec[s.charts.ok_per_sec.length-1]) : '-');
|
||||||
|
document.getElementById('fail-meta').textContent =
|
||||||
|
'peak ' + Math.round(s.charts.fail_peak) + ' · last ' +
|
||||||
|
(s.charts.fail_per_sec.length ? Math.round(s.charts.fail_per_sec[s.charts.fail_per_sec.length-1]) : '-');
|
||||||
|
document.getElementById('miss-meta').textContent =
|
||||||
|
'peak ' + Math.round(s.charts.missing_peak) + ' · last ' +
|
||||||
|
(s.charts.missing_per_sec.length ? Math.round(s.charts.missing_per_sec[s.charts.missing_per_sec.length-1]) : '-');
|
||||||
|
drawChart('chart-lat', s.charts.p95);
|
||||||
|
drawChart('chart-tp', s.charts.ok_per_sec);
|
||||||
|
drawChart('chart-fail', s.charts.fail_per_sec, 'var(--chart-fail)');
|
||||||
|
drawChart('chart-miss', s.charts.missing_per_sec, 'var(--chart-fail)');
|
||||||
|
lastWorkers = s.workers;
|
||||||
|
renderTable(s.workers);
|
||||||
|
} catch (e) {
|
||||||
|
document.getElementById('header').textContent = 'fetch error: ' + e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tickEvents() {
|
||||||
|
try {
|
||||||
|
const r = await (await fetch('/api/events?cursor=' + cursor)).json();
|
||||||
|
cursor = r.next_cursor;
|
||||||
|
const div = document.getElementById('events');
|
||||||
|
const wasAtBottom = div.scrollHeight - div.scrollTop - div.clientHeight < 30;
|
||||||
|
for (const e of r.events) {
|
||||||
|
const d = document.createElement('div');
|
||||||
|
d.className = 'ev ' + (e.level || 'info');
|
||||||
|
const ts = document.createElement('span');
|
||||||
|
ts.className = 'ts';
|
||||||
|
ts.textContent = e.at;
|
||||||
|
d.appendChild(ts);
|
||||||
|
d.appendChild(document.createTextNode(e.text));
|
||||||
|
div.appendChild(d);
|
||||||
|
}
|
||||||
|
while (div.children.length > 1000) div.removeChild(div.firstChild);
|
||||||
|
if (wasAtBottom) div.scrollTop = div.scrollHeight;
|
||||||
|
} catch (e) { /* noop */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tickInconsistencies() {
|
||||||
|
try {
|
||||||
|
const r = await (await fetch('/api/inconsistencies?cursor=' + inconsCursor)).json();
|
||||||
|
inconsCursor = r.next_cursor;
|
||||||
|
const div = document.getElementById('inconsistencies');
|
||||||
|
const wasAtBottom = div.scrollHeight - div.scrollTop - div.clientHeight < 30;
|
||||||
|
for (const e of r.events) {
|
||||||
|
const d = document.createElement('div');
|
||||||
|
d.className = 'ev ' + (e.level || 'info');
|
||||||
|
const ts = document.createElement('span');
|
||||||
|
ts.className = 'ts';
|
||||||
|
ts.textContent = e.at;
|
||||||
|
d.appendChild(ts);
|
||||||
|
d.appendChild(document.createTextNode(e.text));
|
||||||
|
div.appendChild(d);
|
||||||
|
}
|
||||||
|
while (div.children.length > 1000) div.removeChild(div.firstChild);
|
||||||
|
if (wasAtBottom) div.scrollTop = div.scrollHeight;
|
||||||
|
} catch (e) { /* noop */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
setInterval(tickState, 1000);
|
||||||
|
setInterval(tickEvents, 1000);
|
||||||
|
setInterval(tickInconsistencies, 1000);
|
||||||
|
tickState();
|
||||||
|
tickEvents();
|
||||||
|
tickInconsistencies();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
# Intermediate chaos workload: 1 readwriter + 1 TLS reader per valkey.
|
||||||
|
#
|
||||||
|
# Per target:
|
||||||
|
# - readwriter on plaintext :6379 at 10ms (~100 ops/sec)
|
||||||
|
# - reader on TLS :6380 at 5ms (~200 ops/sec)
|
||||||
|
#
|
||||||
|
# ⇒ ~300 ops/sec per target, ~1.8k ops/sec total per container, and both
|
||||||
|
# the plaintext and TLS ingress paths get exercised on every valkey.
|
||||||
|
#
|
||||||
|
# Per-worker passwords reference env vars that the deployment yaml
|
||||||
|
# (zerops-basic-import.yaml) wires in from each valkey service.
|
||||||
|
#
|
||||||
|
# NB: :6380 is the master TLS port; both valkey:single and valkey:ha
|
||||||
|
# masters serve GETs there, so a reader against :6380 works on both
|
||||||
|
# topologies. If a single-mode valkey ever stops exposing :6380, the
|
||||||
|
# corresponding ro-* worker will surface as a connect error — that's
|
||||||
|
# signal, not a bug here.
|
||||||
|
|
||||||
|
workers:
|
||||||
|
- name: rw-single1
|
||||||
|
role: readwriter
|
||||||
|
host: valkeysingle1.zerops
|
||||||
|
port: 6379
|
||||||
|
password: ${VALKEY_VALKEYSINGLE1_PASSWORD}
|
||||||
|
interval: 10ms
|
||||||
|
- name: ro-single1
|
||||||
|
role: reader
|
||||||
|
host: valkeysingle1.zerops
|
||||||
|
port: 6380
|
||||||
|
tls: true
|
||||||
|
password: ${VALKEY_VALKEYSINGLE1_PASSWORD}
|
||||||
|
interval: 5ms
|
||||||
|
|
||||||
|
- name: rw-single2
|
||||||
|
role: readwriter
|
||||||
|
host: valkeysingle2.zerops
|
||||||
|
port: 6379
|
||||||
|
password: ${VALKEY_VALKEYSINGLE2_PASSWORD}
|
||||||
|
interval: 10ms
|
||||||
|
- name: ro-single2
|
||||||
|
role: reader
|
||||||
|
host: valkeysingle2.zerops
|
||||||
|
port: 6380
|
||||||
|
tls: true
|
||||||
|
password: ${VALKEY_VALKEYSINGLE2_PASSWORD}
|
||||||
|
interval: 5ms
|
||||||
|
|
||||||
|
- name: rw-single3
|
||||||
|
role: readwriter
|
||||||
|
host: valkeysingle3.zerops
|
||||||
|
port: 6379
|
||||||
|
password: ${VALKEY_VALKEYSINGLE3_PASSWORD}
|
||||||
|
interval: 10ms
|
||||||
|
- name: ro-single3
|
||||||
|
role: reader
|
||||||
|
host: valkeysingle3.zerops
|
||||||
|
port: 6380
|
||||||
|
tls: true
|
||||||
|
password: ${VALKEY_VALKEYSINGLE3_PASSWORD}
|
||||||
|
interval: 5ms
|
||||||
|
|
||||||
|
- name: rw-ha1
|
||||||
|
role: readwriter
|
||||||
|
host: valkeyha1.zerops
|
||||||
|
port: 6379
|
||||||
|
password: ${VALKEY_VALKEYHA1_PASSWORD}
|
||||||
|
interval: 10ms
|
||||||
|
- name: ro-ha1
|
||||||
|
role: reader
|
||||||
|
host: valkeyha1.zerops
|
||||||
|
port: 6380
|
||||||
|
tls: true
|
||||||
|
password: ${VALKEY_VALKEYHA1_PASSWORD}
|
||||||
|
interval: 5ms
|
||||||
|
|
||||||
|
- name: rw-ha2
|
||||||
|
role: readwriter
|
||||||
|
host: valkeyha2.zerops
|
||||||
|
port: 6379
|
||||||
|
password: ${VALKEY_VALKEYHA2_PASSWORD}
|
||||||
|
interval: 10ms
|
||||||
|
- name: ro-ha2
|
||||||
|
role: reader
|
||||||
|
host: valkeyha2.zerops
|
||||||
|
port: 6380
|
||||||
|
tls: true
|
||||||
|
password: ${VALKEY_VALKEYHA2_PASSWORD}
|
||||||
|
interval: 5ms
|
||||||
|
|
||||||
|
- name: rw-ha3
|
||||||
|
role: readwriter
|
||||||
|
host: valkeyha3.zerops
|
||||||
|
port: 6379
|
||||||
|
password: ${VALKEY_VALKEYHA3_PASSWORD}
|
||||||
|
interval: 10ms
|
||||||
|
- name: ro-ha3
|
||||||
|
role: reader
|
||||||
|
host: valkeyha3.zerops
|
||||||
|
port: 6380
|
||||||
|
tls: true
|
||||||
|
password: ${VALKEY_VALKEYHA3_PASSWORD}
|
||||||
|
interval: 5ms
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
# Valkey HA chaos/soak config — spicy edition.
|
||||||
|
#
|
||||||
|
# 13 workers per container. With Zerops scaling to 10 replicas, that's
|
||||||
|
# ~130 concurrent connections × short intervals = serious load on
|
||||||
|
# HAProxy, Sentinel, and the Valkey master.
|
||||||
|
#
|
||||||
|
# Each $ZEROPS_Number replica gets its own DB and its own channel suffix
|
||||||
|
# (handled in main.go::applyZeropsOverrides), so the 10 containers don't
|
||||||
|
# step on each other's keyspace or pub/sub traffic.
|
||||||
|
#
|
||||||
|
# Topology:
|
||||||
|
# 6379 = read/write plaintext (master)
|
||||||
|
# 6380 = read/write TLS (master)
|
||||||
|
# 7000 = read-only plaintext (replicas via HAProxy)
|
||||||
|
# 7001 = read-only TLS (replicas via HAProxy)
|
||||||
|
#
|
||||||
|
# Roughly half the workers run over TLS so each ingress path gets
|
||||||
|
# exercised under load.
|
||||||
|
#
|
||||||
|
# Workload mix:
|
||||||
|
# - 1× readwriter (write + read-after-write on master)
|
||||||
|
# - 2× pure writer (separate TCP sessions — stress the master)
|
||||||
|
# - 1× master-side reader (does master serve GETs under load?)
|
||||||
|
# - 4× replica-side reader (stress replicas + verify replication catches up)
|
||||||
|
# - 2× publisher on two distinct channels (one slow, one tight)
|
||||||
|
# - 2× master-side subscriber (one per channel)
|
||||||
|
# - 1× replica-side subscriber (topology probe — should receive 0 msgs)
|
||||||
|
|
||||||
|
workers:
|
||||||
|
# ---- write path (3 connections) ----------------------------------------
|
||||||
|
- name: rw-main
|
||||||
|
role: readwriter
|
||||||
|
host: valkeyha.zerops
|
||||||
|
port: 6379
|
||||||
|
db: 0
|
||||||
|
interval: 20ms
|
||||||
|
|
||||||
|
- name: rw-burst-1
|
||||||
|
role: writer
|
||||||
|
host: valkeyha.zerops
|
||||||
|
port: 6380
|
||||||
|
tls: true
|
||||||
|
interval: 3ms
|
||||||
|
|
||||||
|
- name: rw-burst-2
|
||||||
|
role: writer
|
||||||
|
host: valkeyha.zerops
|
||||||
|
port: 6379
|
||||||
|
interval: 3ms
|
||||||
|
|
||||||
|
# ---- master-side reader (sanity that master also serves reads) ---------
|
||||||
|
- name: ro-master
|
||||||
|
role: reader
|
||||||
|
host: valkeyha.zerops
|
||||||
|
port: 6380
|
||||||
|
tls: true
|
||||||
|
interval: 5ms
|
||||||
|
|
||||||
|
# ---- replica-side reader fan-out (4 connections) -----------------------
|
||||||
|
- name: ro-1
|
||||||
|
role: reader
|
||||||
|
host: valkeyha.zerops
|
||||||
|
port: 7000
|
||||||
|
interval: 2ms
|
||||||
|
- name: ro-2
|
||||||
|
role: reader
|
||||||
|
host: valkeyha.zerops
|
||||||
|
port: 7001
|
||||||
|
tls: true
|
||||||
|
interval: 2ms
|
||||||
|
- name: ro-3
|
||||||
|
role: reader
|
||||||
|
host: valkeyha.zerops
|
||||||
|
port: 7000
|
||||||
|
interval: 3ms
|
||||||
|
- name: ro-4
|
||||||
|
role: reader
|
||||||
|
host: valkeyha.zerops
|
||||||
|
port: 7001
|
||||||
|
tls: true
|
||||||
|
interval: 3ms
|
||||||
|
|
||||||
|
# ---- pub/sub: two channels at different cadences -----------------------
|
||||||
|
- name: pub-bus
|
||||||
|
role: publisher
|
||||||
|
host: valkeyha.zerops
|
||||||
|
port: 6379
|
||||||
|
channel: "vk:chaos:bus"
|
||||||
|
interval: 5ms
|
||||||
|
|
||||||
|
- name: pub-fast
|
||||||
|
role: publisher
|
||||||
|
host: valkeyha.zerops
|
||||||
|
port: 6380
|
||||||
|
tls: true
|
||||||
|
channel: "vk:chaos:fast"
|
||||||
|
interval: 2ms
|
||||||
|
|
||||||
|
- name: sub-bus
|
||||||
|
role: subscriber
|
||||||
|
host: valkeyha.zerops
|
||||||
|
port: 6380
|
||||||
|
tls: true
|
||||||
|
channel: "vk:chaos:bus"
|
||||||
|
|
||||||
|
- name: sub-fast
|
||||||
|
role: subscriber
|
||||||
|
host: valkeyha.zerops
|
||||||
|
port: 6379
|
||||||
|
channel: "vk:chaos:fast"
|
||||||
|
|
||||||
|
# ---- memory bloater ----------------------------------------------------
|
||||||
|
# Writes 8 KB of random bytes to a fresh key every 10ms with NO TTL.
|
||||||
|
# ~800 KB/sec per container × 10 containers = ~8 MB/sec growth on the
|
||||||
|
# master. Watch used_memory climb. Keys land under vk:chaos:bloat:<seq>
|
||||||
|
# so they don't pollute the durability ledger. To reset, FLUSHDB the
|
||||||
|
# affected DB (or each per-replica DB if running in Zerops).
|
||||||
|
# - name: bloat-1
|
||||||
|
# role: bloater
|
||||||
|
# host: valkeyha.zerops
|
||||||
|
# port: 6379
|
||||||
|
# interval: 10ms
|
||||||
|
# valueBytes: 8192
|
||||||
|
|
||||||
|
# ---- topology probe ----------------------------------------------------
|
||||||
|
# Subscriber on the read-only VIP. Valkey 7.2 + Sentinel does not
|
||||||
|
# replicate pub/sub from master to replicas, so this should stay at
|
||||||
|
# msgs=0 the entire run. If it ever ticks up, replication semantics
|
||||||
|
# changed.
|
||||||
|
- name: sub-replica
|
||||||
|
role: subscriber
|
||||||
|
host: valkeyha.zerops
|
||||||
|
port: 7001
|
||||||
|
tls: true
|
||||||
|
channel: "vk:chaos:bus"
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
module valkey-test
|
||||||
|
|
||||||
|
go 1.24.2
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/charmbracelet/bubbles v1.0.0
|
||||||
|
github.com/charmbracelet/bubbletea v1.3.10
|
||||||
|
github.com/charmbracelet/lipgloss v1.1.0
|
||||||
|
github.com/redis/go-redis/v9 v9.7.0
|
||||||
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||||
|
github.com/cespare/xxhash/v2 v2.2.0 // indirect
|
||||||
|
github.com/charmbracelet/colorprofile v0.4.1 // indirect
|
||||||
|
github.com/charmbracelet/x/ansi v0.11.6 // indirect
|
||||||
|
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
|
||||||
|
github.com/charmbracelet/x/term v0.2.2 // indirect
|
||||||
|
github.com/clipperhouse/displaywidth v0.9.0 // indirect
|
||||||
|
github.com/clipperhouse/stringish v0.1.1 // indirect
|
||||||
|
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
|
||||||
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||||
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||||
|
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||||
|
github.com/mattn/go-runewidth v0.0.19 // indirect
|
||||||
|
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||||
|
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||||
|
github.com/muesli/termenv v0.16.0 // indirect
|
||||||
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||||
|
golang.org/x/sys v0.38.0 // indirect
|
||||||
|
golang.org/x/text v0.3.8 // indirect
|
||||||
|
)
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||||
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||||
|
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||||
|
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||||
|
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||||
|
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
||||||
|
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
|
||||||
|
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
|
github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
|
||||||
|
github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
|
||||||
|
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
||||||
|
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
|
||||||
|
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
|
||||||
|
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
|
||||||
|
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
|
||||||
|
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
|
||||||
|
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
|
||||||
|
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
|
||||||
|
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
|
||||||
|
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
|
||||||
|
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
|
||||||
|
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
|
||||||
|
github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
|
||||||
|
github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA=
|
||||||
|
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
|
||||||
|
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
|
||||||
|
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
|
||||||
|
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
|
||||||
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||||
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||||
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||||
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||||
|
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
|
||||||
|
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
||||||
|
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
||||||
|
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
|
||||||
|
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||||
|
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
||||||
|
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
|
||||||
|
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||||
|
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||||
|
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||||
|
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||||
|
github.com/redis/go-redis/v9 v9.7.0 h1:HhLSs+B6O021gwzl+locl0zEDnyNkxMtf/Z3NNBMa9E=
|
||||||
|
github.com/redis/go-redis/v9 v9.7.0/go.mod h1:f6zhXITC7JUJIlPEiBOTXxJgPLdZcA93GewI7inzyWw=
|
||||||
|
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||||
|
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||||
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||||
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||||
|
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
|
||||||
|
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
|
||||||
|
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||||
|
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
|
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
|
||||||
|
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
+639
@@ -0,0 +1,639 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"crypto/x509"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"strconv"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/redis/go-redis/v9"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// tlsRootCAs, when non-nil, is used as the RootCAs pool for every TLS
|
||||||
|
// connection. Set by --tls-ca for local testing against a Valkey whose
|
||||||
|
// cert is signed by a non-system CA.
|
||||||
|
var tlsRootCAs *x509.CertPool
|
||||||
|
|
||||||
|
type WorkerConfig struct {
|
||||||
|
Name string `yaml:"name"`
|
||||||
|
Role string `yaml:"role"`
|
||||||
|
Host string `yaml:"host"`
|
||||||
|
Port int `yaml:"port"`
|
||||||
|
Password string `yaml:"password"`
|
||||||
|
DB int `yaml:"db"`
|
||||||
|
TLS bool `yaml:"tls"`
|
||||||
|
Interval time.Duration `yaml:"interval"`
|
||||||
|
Channel string `yaml:"channel"`
|
||||||
|
ValueBytes int `yaml:"valueBytes"` // bloater role only
|
||||||
|
}
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
Host string `yaml:"host,omitempty"`
|
||||||
|
Port int `yaml:"port,omitempty"`
|
||||||
|
Password string `yaml:"password,omitempty"`
|
||||||
|
DB int `yaml:"db,omitempty"`
|
||||||
|
TLS bool `yaml:"tls,omitempty"`
|
||||||
|
|
||||||
|
Workers []WorkerConfig `yaml:"workers,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadConfig(path string) (*Config, error) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read config: %w", err)
|
||||||
|
}
|
||||||
|
data = []byte(os.ExpandEnv(string(data)))
|
||||||
|
var cfg Config
|
||||||
|
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
||||||
|
return nil, fmt.Errorf("parse config: %w", err)
|
||||||
|
}
|
||||||
|
applyZeropsOverrides(&cfg)
|
||||||
|
return &cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyZeropsOverrides applies env-var overrides on top of the parsed
|
||||||
|
// config so the same config.yaml works locally and in Zerops:
|
||||||
|
//
|
||||||
|
// $VALKEY_PASSWORD → overrides every worker's password (so the secret
|
||||||
|
// lives in the Zerops env, not in committed YAML).
|
||||||
|
//
|
||||||
|
// $ZEROPS_Number → ordinal injected per Zerops replica. Distributes
|
||||||
|
// 10 containers across distinct DBs and pub/sub
|
||||||
|
// channels so they don't collide on shared cluster:
|
||||||
|
// cfg.Workers[*].DB = ordinal mod 16
|
||||||
|
// cfg.Workers[*].Channel = "<channel>:<ord mod 16>"
|
||||||
|
// The mod-16 wrap is needed because Valkey defaults
|
||||||
|
// to DB 0–15 and Zerops's ordinal grows across
|
||||||
|
// redeploys (a fresh deploy can start well above 16).
|
||||||
|
//
|
||||||
|
// Each override is independent: an unset env var is a no-op, leaving the
|
||||||
|
// YAML value in place.
|
||||||
|
func applyZeropsOverrides(cfg *Config) {
|
||||||
|
if pw := os.Getenv("VALKEY_PASSWORD"); pw != "" {
|
||||||
|
for i := range cfg.Workers {
|
||||||
|
cfg.Workers[i].Password = pw
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if n := os.Getenv("ZEROPS_Number"); n != "" {
|
||||||
|
if raw, err := strconv.Atoi(n); err == nil {
|
||||||
|
dbN := raw % 16
|
||||||
|
suffix := strconv.Itoa(dbN)
|
||||||
|
for i := range cfg.Workers {
|
||||||
|
cfg.Workers[i].DB = dbN
|
||||||
|
if cfg.Workers[i].Channel != "" {
|
||||||
|
cfg.Workers[i].Channel = cfg.Workers[i].Channel + ":" + suffix
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyFlagOverrides applies --host / --password / --tls-ca on top of the
|
||||||
|
// loaded config so the local binary can target a remote Valkey without
|
||||||
|
// editing config.yaml. --tls-ca loads the PEM into the package-level
|
||||||
|
// tlsRootCAs pool and forces TLS on every worker.
|
||||||
|
func applyFlagOverrides(cfg *Config, host string, port int, password string, tlsEnable bool, tlsCAPath string) error {
|
||||||
|
if host != "" {
|
||||||
|
cfg.Host = host
|
||||||
|
for i := range cfg.Workers {
|
||||||
|
cfg.Workers[i].Host = host
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if port != 0 {
|
||||||
|
cfg.Port = port
|
||||||
|
for i := range cfg.Workers {
|
||||||
|
cfg.Workers[i].Port = port
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if password != "" {
|
||||||
|
cfg.Password = password
|
||||||
|
for i := range cfg.Workers {
|
||||||
|
cfg.Workers[i].Password = password
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if tlsCAPath != "" {
|
||||||
|
pem, err := os.ReadFile(tlsCAPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read tls-ca: %w", err)
|
||||||
|
}
|
||||||
|
pool := x509.NewCertPool()
|
||||||
|
if !pool.AppendCertsFromPEM(pem) {
|
||||||
|
return fmt.Errorf("tls-ca: no certificates parsed from %s", tlsCAPath)
|
||||||
|
}
|
||||||
|
tlsRootCAs = pool
|
||||||
|
}
|
||||||
|
if tlsEnable || tlsCAPath != "" {
|
||||||
|
cfg.TLS = true
|
||||||
|
for i := range cfg.Workers {
|
||||||
|
cfg.Workers[i].TLS = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Config) primary() (*redis.Options, error) {
|
||||||
|
if c.Host != "" && c.Port != 0 {
|
||||||
|
return optionsFromConn(c.Host, c.Port, c.Password, c.DB, c.TLS), nil
|
||||||
|
}
|
||||||
|
if len(c.Workers) > 0 {
|
||||||
|
w := c.Workers[0]
|
||||||
|
return optionsFromConn(w.Host, w.Port, w.Password, w.DB, w.TLS), nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("config has neither top-level host nor workers")
|
||||||
|
}
|
||||||
|
|
||||||
|
func optionsFromConn(host string, port int, password string, db int, useTLS bool) *redis.Options {
|
||||||
|
opts := &redis.Options{
|
||||||
|
Addr: fmt.Sprintf("%s:%d", host, port),
|
||||||
|
Password: password,
|
||||||
|
DB: db,
|
||||||
|
}
|
||||||
|
if useTLS {
|
||||||
|
opts.TLSConfig = &tls.Config{MinVersion: tls.VersionTLS12, RootCAs: tlsRootCAs}
|
||||||
|
}
|
||||||
|
return opts
|
||||||
|
}
|
||||||
|
|
||||||
|
func redactPassword(pw string) string {
|
||||||
|
if pw == "" {
|
||||||
|
return "(none)"
|
||||||
|
}
|
||||||
|
return "(set)"
|
||||||
|
}
|
||||||
|
|
||||||
|
func envInt(name string, def int) int {
|
||||||
|
if v := os.Getenv(name); v != "" {
|
||||||
|
if n, err := strconv.Atoi(v); err == nil {
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
configPath := flag.String("config", "config.yaml", "path to YAML config file")
|
||||||
|
continuous := flag.Bool("continuous", false, "single-worker rolling smoke loop")
|
||||||
|
chaosLocal := flag.Bool("chaos", false, "multi-worker chaos TUI (local, in-process)")
|
||||||
|
workerMode := flag.Bool("worker", false, "distributed worker — runs the chaos workload and ships events to --collector-url")
|
||||||
|
collectorMode := flag.Bool("collector", false, "distributed collector — HTTP dashboard that ingests events from workers")
|
||||||
|
collectorURL := flag.String("collector-url", os.Getenv("COLLECTOR_URL"), "collector base URL (worker mode); env: COLLECTOR_URL")
|
||||||
|
port := flag.Int("collector-port", envInt("PORT", 8080), "HTTP port (collector mode); env: PORT")
|
||||||
|
interval := flag.Duration("interval", time.Second, "tick interval for --continuous")
|
||||||
|
hostOverride := flag.String("host", "", "override host for all workers (local testing)")
|
||||||
|
portOverride := flag.Int("port", 0, "override port for all workers (local testing)")
|
||||||
|
passwordOverride := flag.String("password", "", "override password for all workers (local testing)")
|
||||||
|
tlsEnable := flag.Bool("tls", false, "enable TLS for all workers using system root CAs (local testing)")
|
||||||
|
tlsCAPath := flag.String("tls-ca", "", "path to TLS CA cert PEM; enables TLS for all workers (local testing)")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case *collectorMode:
|
||||||
|
os.Exit(runCollector(*port))
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := loadConfig(*configPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("config error: %v", err)
|
||||||
|
}
|
||||||
|
if err := applyFlagOverrides(cfg, *hostOverride, *portOverride, *passwordOverride, *tlsEnable, *tlsCAPath); err != nil {
|
||||||
|
log.Fatalf("flag override error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case *chaosLocal:
|
||||||
|
if len(cfg.Workers) == 0 {
|
||||||
|
log.Fatalf("--chaos requires workers: list in %s", *configPath)
|
||||||
|
}
|
||||||
|
os.Exit(runChaos(cfg.Workers))
|
||||||
|
case *workerMode:
|
||||||
|
if len(cfg.Workers) == 0 {
|
||||||
|
log.Fatalf("--worker requires workers: list in %s", *configPath)
|
||||||
|
}
|
||||||
|
if *collectorURL == "" {
|
||||||
|
log.Fatalf("--worker requires --collector-url or $COLLECTOR_URL")
|
||||||
|
}
|
||||||
|
os.Exit(runWorker(cfg.Workers, *collectorURL))
|
||||||
|
}
|
||||||
|
|
||||||
|
primary, err := cfg.primary()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("config error: %v", err)
|
||||||
|
}
|
||||||
|
fmt.Printf("redis config: addr=%s db=%d tls=%t password=%s\n",
|
||||||
|
primary.Addr, primary.DB, primary.TLSConfig != nil, redactPassword(primary.Password))
|
||||||
|
rdb := redis.NewClient(primary)
|
||||||
|
defer rdb.Close()
|
||||||
|
|
||||||
|
if *continuous {
|
||||||
|
runContinuous(rdb, *interval)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
fn func(context.Context, *redis.Client) error
|
||||||
|
}{
|
||||||
|
{"PING", testPing},
|
||||||
|
{"INFO version", testInfo},
|
||||||
|
{"STRING SET/GET", testString},
|
||||||
|
{"INCR/DECR", testCounter},
|
||||||
|
{"EXPIRE/TTL", testExpire},
|
||||||
|
{"LIST ops", testList},
|
||||||
|
{"HASH ops", testHash},
|
||||||
|
{"SET ops", testSet},
|
||||||
|
{"SORTED SET ops", testZSet},
|
||||||
|
{"TRANSACTION", testTxn},
|
||||||
|
{"PIPELINE", testPipeline},
|
||||||
|
{"PUB/SUB", testPubSub},
|
||||||
|
{"FLUSHDB cleanup", testCleanup},
|
||||||
|
}
|
||||||
|
|
||||||
|
failed := 0
|
||||||
|
for _, t := range tests {
|
||||||
|
fmt.Printf("[ .. ] %s\n", t.name)
|
||||||
|
if err := t.fn(ctx, rdb); err != nil {
|
||||||
|
fmt.Printf("[FAIL] %s: %v\n", t.name, err)
|
||||||
|
failed++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fmt.Printf("[ OK ] %s\n", t.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("\n%d/%d tests passed\n", len(tests)-failed, len(tests))
|
||||||
|
if failed > 0 {
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPing(ctx context.Context, r *redis.Client) error {
|
||||||
|
pong, err := r.Ping(ctx).Result()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if pong != "PONG" {
|
||||||
|
return fmt.Errorf("expected PONG, got %q", pong)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func testInfo(ctx context.Context, r *redis.Client) error {
|
||||||
|
info, err := r.Info(ctx, "server").Result()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(info) == 0 {
|
||||||
|
return fmt.Errorf("empty INFO response")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func testString(ctx context.Context, r *redis.Client) error {
|
||||||
|
if err := r.Set(ctx, "vk:string", "hello-valkey", 0).Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
got, err := r.Get(ctx, "vk:string").Result()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if got != "hello-valkey" {
|
||||||
|
return fmt.Errorf("expected hello-valkey, got %q", got)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCounter(ctx context.Context, r *redis.Client) error {
|
||||||
|
r.Del(ctx, "vk:counter")
|
||||||
|
if _, err := r.Incr(ctx, "vk:counter").Result(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := r.IncrBy(ctx, "vk:counter", 9).Result(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
v, err := r.Decr(ctx, "vk:counter").Result()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if v != 9 {
|
||||||
|
return fmt.Errorf("expected 9, got %d", v)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func testExpire(ctx context.Context, r *redis.Client) error {
|
||||||
|
if err := r.Set(ctx, "vk:ttl", "x", 5*time.Second).Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
ttl, err := r.TTL(ctx, "vk:ttl").Result()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if ttl <= 0 || ttl > 5*time.Second {
|
||||||
|
return fmt.Errorf("unexpected TTL: %v", ttl)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func testList(ctx context.Context, r *redis.Client) error {
|
||||||
|
r.Del(ctx, "vk:list")
|
||||||
|
if _, err := r.RPush(ctx, "vk:list", "a", "b", "c").Result(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
vals, err := r.LRange(ctx, "vk:list", 0, -1).Result()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(vals) != 3 || vals[0] != "a" || vals[2] != "c" {
|
||||||
|
return fmt.Errorf("unexpected list: %v", vals)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func testHash(ctx context.Context, r *redis.Client) error {
|
||||||
|
r.Del(ctx, "vk:hash")
|
||||||
|
if err := r.HSet(ctx, "vk:hash", "name", "valkey", "version", "7.2").Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
got, err := r.HGetAll(ctx, "vk:hash").Result()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if got["name"] != "valkey" || got["version"] != "7.2" {
|
||||||
|
return fmt.Errorf("unexpected hash: %v", got)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSet(ctx context.Context, r *redis.Client) error {
|
||||||
|
r.Del(ctx, "vk:set")
|
||||||
|
if _, err := r.SAdd(ctx, "vk:set", "x", "y", "z", "x").Result(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
n, err := r.SCard(ctx, "vk:set").Result()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if n != 3 {
|
||||||
|
return fmt.Errorf("expected 3 members, got %d", n)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func testZSet(ctx context.Context, r *redis.Client) error {
|
||||||
|
r.Del(ctx, "vk:zset")
|
||||||
|
_, err := r.ZAdd(ctx, "vk:zset",
|
||||||
|
redis.Z{Score: 1, Member: "one"},
|
||||||
|
redis.Z{Score: 2, Member: "two"},
|
||||||
|
redis.Z{Score: 3, Member: "three"},
|
||||||
|
).Result()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
vals, err := r.ZRangeByScore(ctx, "vk:zset", &redis.ZRangeBy{Min: "1", Max: "2"}).Result()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(vals) != 2 || vals[0] != "one" {
|
||||||
|
return fmt.Errorf("unexpected zrange: %v", vals)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func testTxn(ctx context.Context, r *redis.Client) error {
|
||||||
|
r.Del(ctx, "vk:txn")
|
||||||
|
pipe := r.TxPipeline()
|
||||||
|
pipe.Set(ctx, "vk:txn", "1", 0)
|
||||||
|
pipe.Incr(ctx, "vk:txn")
|
||||||
|
pipe.Incr(ctx, "vk:txn")
|
||||||
|
if _, err := pipe.Exec(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
v, err := r.Get(ctx, "vk:txn").Result()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if v != "3" {
|
||||||
|
return fmt.Errorf("expected 3, got %s", v)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPipeline(ctx context.Context, r *redis.Client) error {
|
||||||
|
pipe := r.Pipeline()
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
pipe.Set(ctx, fmt.Sprintf("vk:pipe:%d", i), i, 0)
|
||||||
|
}
|
||||||
|
if _, err := pipe.Exec(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
v, err := r.Get(ctx, "vk:pipe:3").Result()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if v != "3" {
|
||||||
|
return fmt.Errorf("expected 3, got %s", v)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPubSub(ctx context.Context, r *redis.Client) error {
|
||||||
|
sub := r.Subscribe(ctx, "vk:channel")
|
||||||
|
defer sub.Close()
|
||||||
|
|
||||||
|
if _, err := sub.Receive(ctx); err != nil {
|
||||||
|
return fmt.Errorf("subscribe: %w", err)
|
||||||
|
}
|
||||||
|
ch := sub.Channel()
|
||||||
|
|
||||||
|
if err := r.Publish(ctx, "vk:channel", "ping").Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case msg := <-ch:
|
||||||
|
if msg.Payload != "ping" {
|
||||||
|
return fmt.Errorf("expected ping, got %q", msg.Payload)
|
||||||
|
}
|
||||||
|
case <-time.After(3 * time.Second):
|
||||||
|
return fmt.Errorf("timeout waiting for message")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCleanup(ctx context.Context, r *redis.Client) error {
|
||||||
|
keys, err := r.Keys(ctx, "vk:*").Result()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(keys) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return r.Del(ctx, keys...).Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func runContinuous(r *redis.Client, interval time.Duration) {
|
||||||
|
stopCtx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||||
|
defer stop()
|
||||||
|
|
||||||
|
subCtx, cancelSub := context.WithCancel(stopCtx)
|
||||||
|
defer cancelSub()
|
||||||
|
sub := r.Subscribe(subCtx, "vk:cont:channel")
|
||||||
|
defer sub.Close()
|
||||||
|
if _, err := sub.Receive(subCtx); err != nil {
|
||||||
|
log.Fatalf("subscribe: %v", err)
|
||||||
|
}
|
||||||
|
subCh := sub.Channel()
|
||||||
|
|
||||||
|
ops := []struct {
|
||||||
|
name string
|
||||||
|
fn func(context.Context, *redis.Client, int64) error
|
||||||
|
}{
|
||||||
|
{"PING", opPing},
|
||||||
|
{"SET/GET", opSetGet},
|
||||||
|
{"INCR", opIncr},
|
||||||
|
{"LIST", opList},
|
||||||
|
{"INFO", opInfo},
|
||||||
|
{"PUB/SUB", func(ctx context.Context, r *redis.Client, i int64) error {
|
||||||
|
return opPubSub(ctx, r, i, subCh)
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
|
||||||
|
var cycle, okCount, failCount int64
|
||||||
|
ticker := time.NewTicker(interval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
fmt.Printf("continuous mode: addr=%s interval=%s (Ctrl+C to stop)\n", r.Options().Addr, interval)
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-stopCtx.Done():
|
||||||
|
fmt.Printf("\nstopped: cycles=%d ok=%d failed=%d\n", cycle, okCount, failCount)
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
}
|
||||||
|
cycle++
|
||||||
|
|
||||||
|
results := make([]string, len(ops))
|
||||||
|
var failures []string
|
||||||
|
cycleStart := time.Now()
|
||||||
|
for i, op := range ops {
|
||||||
|
ctx, cancel := context.WithTimeout(stopCtx, interval)
|
||||||
|
start := time.Now()
|
||||||
|
err := op.fn(ctx, r, cycle)
|
||||||
|
cancel()
|
||||||
|
dur := time.Since(start)
|
||||||
|
if err != nil {
|
||||||
|
results[i] = fmt.Sprintf("%s=FAIL", op.name)
|
||||||
|
failures = append(failures, fmt.Sprintf(" %s (%s): %v", op.name, dur, err))
|
||||||
|
} else {
|
||||||
|
results[i] = fmt.Sprintf("%s=ok(%s)", op.name, dur.Round(time.Microsecond))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(failures) == 0 {
|
||||||
|
okCount++
|
||||||
|
} else {
|
||||||
|
failCount++
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("[%s] #%d %s | total=%s\n",
|
||||||
|
time.Now().Format("15:04:05"), cycle, joinResults(results), time.Since(cycleStart).Round(time.Microsecond))
|
||||||
|
for _, f := range failures {
|
||||||
|
fmt.Println(f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func joinResults(parts []string) string {
|
||||||
|
out := ""
|
||||||
|
for i, p := range parts {
|
||||||
|
if i > 0 {
|
||||||
|
out += " "
|
||||||
|
}
|
||||||
|
out += p
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func opPing(ctx context.Context, r *redis.Client, _ int64) error {
|
||||||
|
pong, err := r.Ping(ctx).Result()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if pong != "PONG" {
|
||||||
|
return fmt.Errorf("expected PONG, got %q", pong)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func opSetGet(ctx context.Context, r *redis.Client, i int64) error {
|
||||||
|
val := fmt.Sprintf("v-%d", i)
|
||||||
|
if err := r.Set(ctx, "vk:cont:str", val, 10*time.Second).Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
got, err := r.Get(ctx, "vk:cont:str").Result()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if got != val {
|
||||||
|
return fmt.Errorf("set %q, got %q", val, got)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func opIncr(ctx context.Context, r *redis.Client, _ int64) error {
|
||||||
|
_, err := r.Incr(ctx, "vk:cont:counter").Result()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func opList(ctx context.Context, r *redis.Client, i int64) error {
|
||||||
|
if err := r.RPush(ctx, "vk:cont:list", fmt.Sprintf("item-%d", i)).Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := r.LTrim(ctx, "vk:cont:list", -10, -1).Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err := r.LRange(ctx, "vk:cont:list", 0, -1).Result()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func opInfo(ctx context.Context, r *redis.Client, _ int64) error {
|
||||||
|
info, err := r.Info(ctx, "server").Result()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(info) == 0 {
|
||||||
|
return fmt.Errorf("empty INFO")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func opPubSub(ctx context.Context, r *redis.Client, i int64, ch <-chan *redis.Message) error {
|
||||||
|
payload := fmt.Sprintf("ping-%d", i)
|
||||||
|
if err := r.Publish(ctx, "vk:cont:channel", payload).Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case msg := <-ch:
|
||||||
|
if msg == nil {
|
||||||
|
return fmt.Errorf("channel closed")
|
||||||
|
}
|
||||||
|
if msg.Payload == payload {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
case <-ctx.Done():
|
||||||
|
return fmt.Errorf("timeout waiting for %q", payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+33
@@ -0,0 +1,33 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
PORT="${PORT:-6379}"
|
||||||
|
|
||||||
|
services=$(zcli service list 2>/dev/null \
|
||||||
|
| awk -F'│' '{gsub(/ /, "", $3); print $3}' \
|
||||||
|
| grep -E '^valkey' || true)
|
||||||
|
|
||||||
|
if [[ -z "$services" ]]; then
|
||||||
|
echo "no valkey* services found" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
fail=0
|
||||||
|
while IFS= read -r svc; do
|
||||||
|
echo "=== testing $svc ==="
|
||||||
|
password=$(zcli project env --service "$svc" 2>/dev/null \
|
||||||
|
| grep -E '^password=' \
|
||||||
|
| head -n1 \
|
||||||
|
| sed -E 's/^password="?([^"]*)"?$/\1/')
|
||||||
|
if [[ -z "$password" ]]; then
|
||||||
|
echo "!!! $svc: could not fetch password" >&2
|
||||||
|
fail=1
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
if ! go run ./... -password "$password" -host "${svc}.zerops" -port "$PORT"; then
|
||||||
|
echo "!!! $svc FAILED" >&2
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
done <<< "$services"
|
||||||
|
|
||||||
|
exit "$fail"
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# Basic stress import: one chaosbasic container that hits all 6 valkeys
|
||||||
|
# in the project with a single readwriter each, plaintext :6379, AND
|
||||||
|
# serves the collector dashboard on :8080 (subdomain access on).
|
||||||
|
#
|
||||||
|
# Build/run config (incl. the startCommands that boot both the collector
|
||||||
|
# and the worker in the same container) lives in zerops.yaml under
|
||||||
|
# setup: chaosbasic.
|
||||||
|
#
|
||||||
|
# Use with: zcli project service-import zerops-basic-import.yaml
|
||||||
|
# Then: zcli push --setup chaosbasic
|
||||||
|
#
|
||||||
|
# Prereqs in the same project (e.g. via zerops-mega-import.yaml):
|
||||||
|
# valkeysingle1 / valkeysingle2 / valkeysingle3 (valkey:single@7.2)
|
||||||
|
# valkeyha1 / valkeyha2 / valkeyha3 (valkey:ha@7.2)
|
||||||
|
#
|
||||||
|
# NB — config-basic.yaml uses literal ${VALKEY_..._PASSWORD} strings in
|
||||||
|
# each worker's password field. applyZeropsOverrides today only honors
|
||||||
|
# a single $VALKEY_PASSWORD env var; extend it to expand ${VAR}
|
||||||
|
# references in each worker.Password against os.Getenv at load time
|
||||||
|
# before this will authenticate against the per-host passwords below.
|
||||||
|
|
||||||
|
services:
|
||||||
|
- hostname: chaosbasic
|
||||||
|
type: go@1
|
||||||
|
minContainers: 1
|
||||||
|
maxContainers: 1
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# Full project import for the chaos test infra:
|
||||||
|
# - Valkey HA cluster (the system under test)
|
||||||
|
# - chaosworker (10x runner replicas; user-defined Go service)
|
||||||
|
# - chaoscollector (1x dashboard; user-defined Go service, subdomain on)
|
||||||
|
#
|
||||||
|
# Use with: zcli project project-import zerops-chaos-import.yaml
|
||||||
|
#
|
||||||
|
# After import, push the runtime config from valkey/zerops.yaml to deploy
|
||||||
|
# the app code to chaosworker and chaoscollector.
|
||||||
|
#
|
||||||
|
# Hostnames are alphanumeric to match Zerops conventions.
|
||||||
|
services:
|
||||||
|
- hostname: chaoscollector
|
||||||
|
type: go@1
|
||||||
|
enableSubdomainAccess: true
|
||||||
|
minContainers: 1
|
||||||
|
maxContainers: 1
|
||||||
|
|
||||||
|
- hostname: chaosworker
|
||||||
|
type: go@1
|
||||||
|
minContainers: 10
|
||||||
|
maxContainers: 10
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
services:
|
||||||
|
- hostname: valkeyha
|
||||||
|
type: valkey@7.2
|
||||||
|
mode: HA
|
||||||
|
verticalAutoscaling:
|
||||||
|
minFreeRamPercent: 40
|
||||||
|
minRam: 4
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
services:
|
||||||
|
- hostname: valkeysingle1
|
||||||
|
type: valkey:single@7.2
|
||||||
|
- hostname: valkeysingle2
|
||||||
|
type: valkey:single@7.2
|
||||||
|
- hostname: valkeysingle3
|
||||||
|
type: valkey:single@7.2
|
||||||
|
- hostname: valkeyha1
|
||||||
|
type: valkey:ha@7.2
|
||||||
|
- hostname: valkeyha2
|
||||||
|
type: valkey:ha@7.2
|
||||||
|
- hostname: valkeyha3
|
||||||
|
type: valkey:ha@7.2
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
services:
|
||||||
|
- hostname: valkeysingle
|
||||||
|
type: valkey@7.2
|
||||||
|
mode: NON_HA
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
# zerops.yaml — runtime build/run config for the chaos test services.
|
||||||
|
#
|
||||||
|
# Two setups, both built from the same Go source under valkey/.
|
||||||
|
#
|
||||||
|
# chaosworker 10 replicas, ships events to chaoscollector (no exposed port).
|
||||||
|
# Reads $ZEROPS_Number → maps to Redis DB N and pub/sub
|
||||||
|
# channel suffix ":N" so the 10 containers don't collide
|
||||||
|
# in the keyspace or on the same channel.
|
||||||
|
#
|
||||||
|
# chaoscollector 1 replica, exposes :8080 with subdomain access enabled,
|
||||||
|
# ingests events from the workers and serves the dashboard.
|
||||||
|
#
|
||||||
|
# NB: validate the exact schema (field names, autoscaling block, prepare
|
||||||
|
# commands) against the current Zerops docs before pushing. Field names
|
||||||
|
# below follow the typical zerops.yaml conventions but vary by runtime
|
||||||
|
# version.
|
||||||
|
|
||||||
|
zerops:
|
||||||
|
- setup: chaosworker
|
||||||
|
build:
|
||||||
|
base: ubuntu/go@1
|
||||||
|
buildCommands:
|
||||||
|
- go build -o app .
|
||||||
|
deployFiles:
|
||||||
|
- app
|
||||||
|
- config.yaml
|
||||||
|
cache: true
|
||||||
|
run:
|
||||||
|
base: ubuntu@latest
|
||||||
|
start: ./app --worker --config config.yaml
|
||||||
|
envVariables:
|
||||||
|
COLLECTOR_URL: http://chaoscollector.zerops:8080
|
||||||
|
VALKEY_PASSWORD: ${valkeyha_password}
|
||||||
|
|
||||||
|
- setup: chaoscollector
|
||||||
|
build:
|
||||||
|
base: ubuntu/go@1
|
||||||
|
buildCommands:
|
||||||
|
- go build -o app .
|
||||||
|
deployFiles:
|
||||||
|
- app
|
||||||
|
cache: true
|
||||||
|
run:
|
||||||
|
base: ubuntu@latest
|
||||||
|
ports:
|
||||||
|
- port: 8080
|
||||||
|
httpSupport: true
|
||||||
|
start: ./app --collector --collector-port 8080
|
||||||
|
|
||||||
|
- setup: chaosbasic
|
||||||
|
build:
|
||||||
|
base: ubuntu/go@1
|
||||||
|
buildCommands:
|
||||||
|
- go build -o app .
|
||||||
|
deployFiles:
|
||||||
|
- app
|
||||||
|
- config-basic.yaml
|
||||||
|
cache: true
|
||||||
|
run:
|
||||||
|
base: ubuntu@latest
|
||||||
|
ports:
|
||||||
|
- port: 8080
|
||||||
|
httpSupport: true
|
||||||
|
startCommands:
|
||||||
|
- name: collector
|
||||||
|
command: ./app --collector --collector-port 8080
|
||||||
|
- name: worker
|
||||||
|
command: ./app --worker --config config-basic.yaml
|
||||||
|
envVariables:
|
||||||
|
COLLECTOR_URL: http://localhost:8080
|
||||||
|
VALKEY_VALKEYSINGLE1_PASSWORD: ${valkeysingle1_password}
|
||||||
|
VALKEY_VALKEYSINGLE2_PASSWORD: ${valkeysingle2_password}
|
||||||
|
VALKEY_VALKEYSINGLE3_PASSWORD: ${valkeysingle3_password}
|
||||||
|
VALKEY_VALKEYHA1_PASSWORD: ${valkeyha1_password}
|
||||||
|
VALKEY_VALKEYHA2_PASSWORD: ${valkeyha2_password}
|
||||||
|
VALKEY_VALKEYHA3_PASSWORD: ${valkeyha3_password}
|
||||||
Reference in New Issue
Block a user