From 1435e6204862b666f86eb7c4c483eb0a764ba28b Mon Sep 17 00:00:00 2001
From: DIZAMU <117295462+DIZAMU@users.noreply.github.com>
Date: Tue, 27 Oct 2026 17:23:55 +0100
Subject: [PATCH 1/4] feat(runtime): add the Kubernetes runtime and the web
terminal API
---
cmd/rootcause/main.go | 33 +-
dbt/models/marts/mart_build_info.sql | 9 +
dbt/models/marts/schema.yml | 5 +
go.mod | 52 +-
go.sum | 123 ++++-
internal/cli/app.go | 49 +-
internal/cli/errors.go | 13 +
internal/cli/next.go | 12 +-
internal/cli/runtime.go | 72 +++
internal/cli/serve.go | 49 +-
internal/cli/serve_test.go | 77 +++
internal/dbt/dbt.go | 29 +-
internal/dbt/dbt_test.go | 4 +-
internal/runtime/kubernetes/build.go | 46 ++
internal/runtime/kubernetes/kubernetes.go | 474 ++++++++++++++++++
.../runtime/kubernetes/kubernetes_test.go | 249 +++++++++
internal/runtime/runtime.go | 25 +-
internal/runtime/scripted/scripted.go | 16 +
internal/server/api.go | 12 +-
internal/server/cluster_test.go | 210 ++++++++
internal/server/server.go | 24 +-
internal/server/server_test.go | 18 +
internal/server/terminal.go | 162 ++++++
internal/server/terminal_test.go | 151 ++++++
internal/server/views.go | 2 +
internal/session/engine.go | 63 +++
internal/store/analytics.go | 15 +
locales/en.json | 15 +-
locales/no.json | 15 +-
29 files changed, 1956 insertions(+), 68 deletions(-)
create mode 100644 dbt/models/marts/mart_build_info.sql
create mode 100644 internal/cli/runtime.go
create mode 100644 internal/cli/serve_test.go
create mode 100644 internal/runtime/kubernetes/build.go
create mode 100644 internal/runtime/kubernetes/kubernetes.go
create mode 100644 internal/runtime/kubernetes/kubernetes_test.go
create mode 100644 internal/server/cluster_test.go
create mode 100644 internal/server/terminal.go
create mode 100644 internal/server/terminal_test.go
diff --git a/cmd/rootcause/main.go b/cmd/rootcause/main.go
index 1757958..7a68215 100644
--- a/cmd/rootcause/main.go
+++ b/cmd/rootcause/main.go
@@ -2,7 +2,7 @@
//
// The play commands are init, next, start, shell, ticket, hint, check, reset,
// giveup, status, list, stats, serve (the dashboard) and lang. The developer
-// commands validate, vars and selftest need no database.
+// commands validate, vars, selftest and dbt-project need no database.
package main
import (
@@ -12,8 +12,8 @@ import (
"strings"
"github.com/DIZAMU/RootCause/internal/cli"
+ "github.com/DIZAMU/RootCause/internal/dbt"
"github.com/DIZAMU/RootCause/internal/exercise"
- "github.com/DIZAMU/RootCause/internal/runtime/docker"
"github.com/DIZAMU/RootCause/internal/session"
)
@@ -33,6 +33,8 @@ func main() {
os.Exit(cmdVars(args))
case "selftest":
os.Exit(cmdSelfTest(ctx, args))
+ case "dbt-project":
+ os.Exit(cmdDBTProject(args))
case "init":
os.Exit(cmdInit(ctx))
default:
@@ -115,20 +117,35 @@ func cmdValidate(args []string) int {
}
// cmdSelfTest runs an exercise's unsolved and solved scenarios in real
-// sandboxes, with the same flags a session uses. It needs Docker, not the
-// database. scripts/validate-exercise.sh calls it.
+// sandboxes, with the same flags a session uses, on the runtime RC_RUNTIME
+// selects. It needs that runtime, not the database.
+// scripts/validate-exercise.sh calls it.
func cmdSelfTest(ctx context.Context, args []string) int {
seed, dir, ok := seedAndDir("selftest", args)
if !ok {
return 2
}
- rt, err := docker.New()
+ be, err := cli.NewBackend(ctx)
if err != nil {
- fmt.Fprintln(os.Stderr, err)
+ fmt.Fprintln(os.Stderr, cli.English(err))
+ return 1
+ }
+ if err := session.SelfTest(ctx, be.Runtime, be.Builder, dir, seed, os.Stdout); err != nil {
+ fmt.Fprintln(os.Stderr, "FAIL:", cli.English(err))
return 1
}
- if err := session.SelfTest(ctx, rt, rt, dir, seed, os.Stdout); err != nil {
- fmt.Fprintln(os.Stderr, "FAIL:", err)
+ return 0
+}
+
+// cmdDBTProject writes the embedded dbt project into an existing directory.
+// The cluster's dbt CronJob runs it as an init container (spec section 8.2).
+func cmdDBTProject(args []string) int {
+ if len(args) != 1 {
+ fmt.Fprintln(os.Stderr, "dbt-project: expected exactly one directory")
+ return 2
+ }
+ if err := dbt.WriteProject(args[0]); err != nil {
+ fmt.Fprintln(os.Stderr, "dbt-project:", err)
return 1
}
return 0
diff --git a/dbt/models/marts/mart_build_info.sql b/dbt/models/marts/mart_build_info.sql
new file mode 100644
index 0000000..11ecb88
--- /dev/null
+++ b/dbt/models/marts/mart_build_info.sql
@@ -0,0 +1,9 @@
+-- One row: when the latest dbt build made the marts. The dashboard shows it,
+-- so statistics that lag behind (the v2 CronJob builds every 10 minutes) are
+-- visibly older than the latest session.
+-- depends_on: {{ ref('mart_topic_levels') }}
+-- depends_on: {{ ref('mart_review_queue') }}
+-- depends_on: {{ ref('mart_exercise_stats') }}
+-- depends_on: {{ ref('mart_progress') }}
+
+select now() as built_at
diff --git a/dbt/models/marts/schema.yml b/dbt/models/marts/schema.yml
index 501f172..3861327 100644
--- a/dbt/models/marts/schema.yml
+++ b/dbt/models/marts/schema.yml
@@ -49,3 +49,8 @@ models:
- accepted_values:
values: [1, 2, 3]
quote: false
+
+ - name: mart_build_info
+ columns:
+ - name: built_at
+ tests: [not_null]
diff --git a/go.mod b/go.mod
index 00c164d..1f82da4 100644
--- a/go.mod
+++ b/go.mod
@@ -3,14 +3,62 @@ module github.com/DIZAMU/RootCause
go 1.27.2
require (
+ github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674
github.com/jackc/pgx/v5 v5.11.0
go.yaml.in/yaml/v3 v3.0.5
+ k8s.io/api v0.37.1
+ k8s.io/apimachinery v0.37.1
+ k8s.io/client-go v0.37.1
)
require (
+ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
+ github.com/emicklei/go-restful/v3 v3.13.0 // indirect
+ github.com/fxamacker/cbor/v2 v2.9.1 // indirect
+ github.com/go-logr/logr v1.4.3 // indirect
+ github.com/go-openapi/jsonpointer v1.0.0 // indirect
+ github.com/go-openapi/jsonreference v1.0.0 // indirect
+ github.com/go-openapi/swag v0.27.1 // indirect
+ github.com/go-openapi/swag/cmdutils v0.27.1 // indirect
+ github.com/go-openapi/swag/conv v0.27.1 // indirect
+ github.com/go-openapi/swag/fileutils v0.27.1 // indirect
+ github.com/go-openapi/swag/jsonutils v0.27.1 // indirect
+ github.com/go-openapi/swag/loading v0.27.1 // indirect
+ github.com/go-openapi/swag/mangling v0.27.1 // indirect
+ github.com/go-openapi/swag/netutils v0.27.1 // indirect
+ github.com/go-openapi/swag/pools v0.27.1 // indirect
+ github.com/go-openapi/swag/stringutils v0.27.1 // indirect
+ github.com/go-openapi/swag/typeutils v0.27.1 // indirect
+ github.com/go-openapi/swag/yamlutils v0.27.1 // indirect
+ github.com/google/gnostic-models v0.7.0 // indirect
+ github.com/google/uuid v1.6.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
- golang.org/x/sync v0.17.0 // indirect
- golang.org/x/text v0.29.0 // indirect
+ github.com/json-iterator/go v1.1.12 // indirect
+ github.com/moby/spdystream v0.5.1 // indirect
+ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
+ github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
+ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
+ github.com/spf13/pflag v1.0.10 // indirect
+ github.com/x448/float16 v0.8.4 // indirect
+ go.yaml.in/yaml/v2 v2.4.4 // indirect
+ golang.org/x/net v0.57.0 // indirect
+ golang.org/x/oauth2 v0.36.0 // indirect
+ golang.org/x/sync v0.22.0 // indirect
+ golang.org/x/sys v0.47.0 // indirect
+ golang.org/x/term v0.45.0 // indirect
+ golang.org/x/text v0.40.0 // indirect
+ golang.org/x/time v0.15.0 // indirect
+ google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect
+ gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect
+ gopkg.in/inf.v0 v0.9.1 // indirect
+ k8s.io/klog/v2 v2.140.0 // indirect
+ k8s.io/kube-openapi v0.0.0-20260721132016-d427ff9ee9ad // indirect
+ k8s.io/streaming v0.37.1 // indirect
+ k8s.io/utils v0.0.0-20260626114624-be93311217bd // indirect
+ sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect
+ sigs.k8s.io/randfill v1.0.0 // indirect
+ sigs.k8s.io/structured-merge-diff/v6 v6.4.2 // indirect
+ sigs.k8s.io/yaml v1.6.0 // indirect
)
diff --git a/go.sum b/go.sum
index b2de37b..627ba30 100644
--- a/go.sum
+++ b/go.sum
@@ -1,6 +1,58 @@
+github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
+github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
-github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
+github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes=
+github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
+github.com/fxamacker/cbor/v2 v2.9.1 h1:2rWm8B193Ll4VdjsJY28jxs70IdDsHRWgQYAI80+rMQ=
+github.com/fxamacker/cbor/v2 v2.9.1/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
+github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
+github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
+github.com/go-openapi/jsonpointer v1.0.0 h1:kR9tHqY0CtZaOPVFm622dPVNhrvYpwr4uCxgL3h1H8s=
+github.com/go-openapi/jsonpointer v1.0.0/go.mod h1:Z3rw7dWu1p9IgitXCFamSlA5lmDiklEB6vkaxcNZW5Y=
+github.com/go-openapi/jsonreference v1.0.0 h1:jlmTr6torcd1YgDQvSfNmRtKzYDO4FGBkrAdlAVWnpY=
+github.com/go-openapi/jsonreference v1.0.0/go.mod h1:jtwdyGbJk0Xhe5Y+rwtglQP6Sb1WZST4rT32LWB+sv0=
+github.com/go-openapi/swag v0.27.1 h1:VotvOLWW8q/EAxB0YdsBBGC8XYyeL1YwBj2ungAGPNg=
+github.com/go-openapi/swag v0.27.1/go.mod h1:GTkJPwHfhJp6MWr4/rCh64HVI3Ofu+tcsbfjfHmTxpE=
+github.com/go-openapi/swag/cmdutils v0.27.1 h1:I7sYqaWVl5mq0NEmNQkAmFDyNin9ufvMX/p2zwtQaOE=
+github.com/go-openapi/swag/cmdutils v0.27.1/go.mod h1:Sm1MVFMkF6guJJ+pQqHnQA3N0j9qALV3NxzDSv6bETM=
+github.com/go-openapi/swag/conv v0.27.1 h1:8wi9ZG+olmY1wXphl93EWniPtbSPkXM/feH7FgjsvrU=
+github.com/go-openapi/swag/conv v0.27.1/go.mod h1:QbqMivkpKhC3g1B1GGGOJ6ANewI3S62dbzYu3Duowqs=
+github.com/go-openapi/swag/fileutils v0.27.1 h1:QQqBSoi5mW4XpU85nS0mLcA+zAE6vLzrb0QkmLKf9oM=
+github.com/go-openapi/swag/fileutils v0.27.1/go.mod h1:VvJFZLTZS0AI854gEQz5tk7dBESdLjiNUMSZ/th2ry8=
+github.com/go-openapi/swag/jsonutils v0.27.1 h1:SVgK3i4USzCU5mibOOS/l4ea2h9UQXy7J7RNLTjuXjU=
+github.com/go-openapi/swag/jsonutils v0.27.1/go.mod h1:tdlEpZqdcQ17uj6J4YdK9vd8It5qWMwjWXOs0tjpRlk=
+github.com/go-openapi/swag/jsonutils/fixtures_test v0.27.1 h1:mJu3COL9WEaZVp/Kf2PRMi7tPszPEJfSr/OO75ynCs8=
+github.com/go-openapi/swag/jsonutils/fixtures_test v0.27.1/go.mod h1:mofwUWx70wvskwESqRJ//k/9kURmCgyJl5m5Ppoh5kY=
+github.com/go-openapi/swag/loading v0.27.1 h1:/DxUgDXKbBX4bcn7r9uEXfJyzN5XpiJmZplzQTjrRCY=
+github.com/go-openapi/swag/loading v0.27.1/go.mod h1:jvGh3iA2+zyUUycB5fgJWzeHnhrpvGnJJM0RVE9ZShE=
+github.com/go-openapi/swag/mangling v0.27.1 h1:yC9D0HyUE8gbP+BfmGx9+AA89ikwZTMjESK3OnnoaqA=
+github.com/go-openapi/swag/mangling v0.27.1/go.mod h1:jtBE2+V+3pILxOR7Vgce+Cwp6A2PgZbvVqfNntbVs0w=
+github.com/go-openapi/swag/netutils v0.27.1 h1:mICMFoS82F5TZ4Zy3cqmcQk+BFeCp3Uyq3Np7GI0/qU=
+github.com/go-openapi/swag/netutils v0.27.1/go.mod h1:J+WYyFMLtvtCGqa6jLv+YNUmIKI3ZRQRrvfNDMoQoEQ=
+github.com/go-openapi/swag/pools v0.27.1 h1:9LeadcMyb2GJCbXX5hVQDbZ2Lq9TL4dCs/nx1j5DO0E=
+github.com/go-openapi/swag/pools v0.27.1/go.mod h1:kVQefhSK5RWuRe7BXsL8htgBPAMpN7HDGpGEknqugeE=
+github.com/go-openapi/swag/stringutils v0.27.1 h1:ZXePZ0r2p1qSjo8tD3Un4vFj8+FqlCkczxDrJIhYUp8=
+github.com/go-openapi/swag/stringutils v0.27.1/go.mod h1:lzRN95CxXmA03XcDWHLOb6nOMcxCqR5rGY0lOgsfRoM=
+github.com/go-openapi/swag/typeutils v0.27.1 h1:KSTdFlfnse4r6dP9IrEnwMldjE+zs71UeEB3//PtVXc=
+github.com/go-openapi/swag/typeutils v0.27.1/go.mod h1:Srm0xFNRZ1Y+vCxJclo5qzx8aj+1pAKda/YfFPrG0dQ=
+github.com/go-openapi/swag/yamlutils v0.27.1 h1:ftxv6xvXb1E3zohUc+okZ9nSqNb9StQX/FXnKZ98sQA=
+github.com/go-openapi/swag/yamlutils v0.27.1/go.mod h1:bnxFIB1qewGRiZHypXGZ3fNgf13/0HfRgnS/iZBDrOo=
+github.com/go-openapi/testify/enable/yaml/v2 v2.6.0 h1:gGHwAJ0R/5jU8BEGDbfRNR3hL68dAVi84WuOApp29B0=
+github.com/go-openapi/testify/enable/yaml/v2 v2.6.0/go.mod h1:tY+St1SGq4NFl0QIqdTY4aEdbChAHxhyB77XQi9iJCo=
+github.com/go-openapi/testify/v2 v2.6.0 h1:5PKH2HE7YJ/LuRPQGvSxBRlFXNQhSetBLlGAgUEu3ug=
+github.com/go-openapi/testify/v2 v2.6.0/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw=
+github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo=
+github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ=
+github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
+github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
+github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo=
+github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
@@ -9,20 +61,79 @@ github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg=
github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
-github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
+github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
+github.com/moby/spdystream v0.5.1 h1:9sNYeYZUcci9R6/w7KDaFWEWeV4LStVG78Mpyq/Zm/Y=
+github.com/moby/spdystream v0.5.1/go.mod h1:xBAYlnt/ay+11ShkdFKNAG7LsyK/tmNBVvVOwrfMgdI=
+github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
+github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
+github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
+github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
+github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8=
+github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
+github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
+github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
+github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
+github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
+github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
+github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
+github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
+go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
+go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
-golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
-golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
-golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
-golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
+golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
+golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
+golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
+golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
+golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
+golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
+golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
+golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
+golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
+golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
+golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
+golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
+golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
+google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI=
+google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo=
+gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M=
+gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=
+gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+k8s.io/api v0.37.1 h1:l6N77U7tjwB5L056bgrBTJIEdevac/naBZ3iSvDNfpM=
+k8s.io/api v0.37.1/go.mod h1:zSlbB1YpJ1YQlFVQy20UYll81UJSJJUMLhkhvg6Z78M=
+k8s.io/apimachinery v0.37.1 h1:hGCYyvKHCwtwMitj2vU4vYx0Z16N9GyZk9BBnz0wDAE=
+k8s.io/apimachinery v0.37.1/go.mod h1:jF84AyUi/IRIXRot5f+lm6MpxoWI+F1XgjaMmwCdTFw=
+k8s.io/client-go v0.37.1 h1:QTv/5ha4jAHtW9qxxVBkQVFBRDb4jHfFopQqqMdc+wM=
+k8s.io/client-go v0.37.1/go.mod h1:dnAPtTnCNY38Ho04D2KdY1F4IKausa9UbqaAZKl60SY=
+k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc=
+k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0=
+k8s.io/kube-openapi v0.0.0-20260721132016-d427ff9ee9ad h1:oXImqH8mQNk7PmvzKhmN3ddJoY6OnyM225MXwGHPm0A=
+k8s.io/kube-openapi v0.0.0-20260721132016-d427ff9ee9ad/go.mod h1:0/mqHCVhlumdJ3BhCfnjSZQE037nAhNodh1/hK0T8/I=
+k8s.io/streaming v0.37.1 h1:TpzVfQeFuVndn2g9mFqxy1UcUYPwDzqjUmwR/IzJCWc=
+k8s.io/streaming v0.37.1/go.mod h1:APlJR26ZWRcVy5bIEj0QRrKUXROtBHPcxl2NT7EAzPU=
+k8s.io/utils v0.0.0-20260626114624-be93311217bd h1:Ea7fgQ5we8Y9T0OX5o0dAHzQOBRI07D/dEYRaB9ZZEs=
+k8s.io/utils v0.0.0-20260626114624-be93311217bd/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk=
+sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg=
+sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg=
+sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU=
+sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY=
+sigs.k8s.io/structured-merge-diff/v6 v6.4.2 h1:qdOxHwrl2Kaag1aQEarlYcOA9vSyGCp3CIki3aW8c4Q=
+sigs.k8s.io/structured-merge-diff/v6 v6.4.2/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE=
+sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs=
+sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4=
diff --git a/internal/cli/app.go b/internal/cli/app.go
index 4d97d57..bdf5b7a 100644
--- a/internal/cli/app.go
+++ b/internal/cli/app.go
@@ -11,12 +11,12 @@ import (
"os"
"os/exec"
"path/filepath"
+ "strconv"
"strings"
"time"
"github.com/DIZAMU/RootCause/internal/i18n"
"github.com/DIZAMU/RootCause/internal/play"
- "github.com/DIZAMU/RootCause/internal/runtime/docker"
"github.com/DIZAMU/RootCause/internal/session"
"github.com/DIZAMU/RootCause/internal/store"
"github.com/DIZAMU/RootCause/locales"
@@ -28,7 +28,7 @@ type App struct {
dsn string // the database the store uses; dbt builds against it
st *store.Store
eng *session.Engine
- rt *docker.Runtime
+ runtime string // the runtime RC_RUNTIME selects
bundle *i18n.Bundle
learner store.Learner
out *os.File
@@ -57,7 +57,7 @@ func NewApp(ctx context.Context) (*App, error) {
if err != nil {
return failed(err)
}
- st, err := store.Open(ctx, dsn)
+ st, err := openStore(ctx, dsn)
if err != nil {
return failed(fail("error.needs_init", nil))
}
@@ -75,28 +75,47 @@ func NewApp(ctx context.Context) (*App, error) {
return failed(err)
}
lang = learner.Lang
- rt, err := docker.New()
+ be, err := NewBackend(ctx)
if err != nil {
st.Close()
- return failed(fail("error.no_docker", err))
- }
- // Refuse to run sessions when root in a sandbox would be root on the host
- // (spec section 13.1, OQ-3).
- if err := rt.CheckIsolation(ctx); err != nil {
- st.Close()
- if errors.Is(err, docker.ErrNotIsolated) {
- return failed(fail("error.not_isolated", nil))
- }
return failed(err)
}
- eng := session.New(rt, rt, rt, st, filepath.Join(root, "exercises"))
+ rtName, _ := RuntimeName()
+ eng := session.New(be.Runtime, be.Builder, be.Sheller, st, filepath.Join(root, "exercises"))
// Expire any sessions past their TTL before running the command. v1 has no
// long-running daemon, so this lazy pass keeps sandboxes from lingering.
// Best-effort: a reap error must not block the command.
_, _ = session.NewReaper(eng, st, 0).ReapOnce(ctx)
- return &App{root: root, dsn: dsn, st: st, eng: eng, rt: rt, bundle: bundle, learner: learner, out: os.Stdout}, nil
+ return &App{root: root, dsn: dsn, st: st, eng: eng, runtime: rtName, bundle: bundle, learner: learner, out: os.Stdout}, nil
+}
+
+// EnvDBWait is how many seconds NewApp keeps trying to reach the database
+// before it gives up (default 0: one try). The cluster deployment sets it,
+// because a new pod's network policy is in place only a moment after the pod
+// starts.
+const EnvDBWait = "RC_DB_WAIT_SECONDS"
+
+// openStore opens the store, polling for up to RC_DB_WAIT_SECONDS until the
+// database answers.
+func openStore(ctx context.Context, dsn string) (*store.Store, error) {
+ wait, err := strconv.Atoi(os.Getenv(EnvDBWait))
+ if err != nil || wait < 0 {
+ wait = 0
+ }
+ deadline := time.Now().Add(time.Duration(wait) * time.Second)
+ for {
+ st, err := store.Open(ctx, dsn)
+ if err == nil || !time.Now().Before(deadline) {
+ return st, err
+ }
+ select {
+ case <-ctx.Done():
+ return nil, err
+ case <-time.After(500 * time.Millisecond):
+ }
+ }
}
// Close releases resources.
diff --git a/internal/cli/errors.go b/internal/cli/errors.go
index 1b5aec7..f8308d6 100644
--- a/internal/cli/errors.go
+++ b/internal/cli/errors.go
@@ -6,6 +6,7 @@ import (
"github.com/DIZAMU/RootCause/internal/i18n"
"github.com/DIZAMU/RootCause/internal/runtime"
"github.com/DIZAMU/RootCause/internal/session"
+ "github.com/DIZAMU/RootCause/locales"
)
// userError is a failure the learner can act on. key names a locale string
@@ -53,7 +54,19 @@ func Message(b *i18n.Bundle, lang string, err error) string {
return b.T(lang, "error.no_shell", nil)
case errors.Is(err, runtime.ErrNeedsCgroupV2):
return b.T(lang, "error.cgroup_v2_required", nil)
+ case errors.Is(err, runtime.ErrSystemdUnsupported):
+ return b.T(lang, "error.systemd_unsupported", nil)
default:
return b.T(lang, "error.generic", map[string]string{"detail": err.Error()})
}
}
+
+// English renders an error in English, for the developer commands, which run
+// without a learner profile.
+func English(err error) string {
+ b, lerr := i18n.Load(locales.FS)
+ if lerr != nil {
+ return err.Error()
+ }
+ return Message(b, "en", err)
+}
diff --git a/internal/cli/next.go b/internal/cli/next.go
index 0d59645..082640b 100644
--- a/internal/cli/next.go
+++ b/internal/cli/next.go
@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"math/rand"
+ "os"
"time"
"github.com/DIZAMU/RootCause/internal/play"
@@ -116,8 +117,17 @@ func (a *App) refreshAfterSession(ctx context.Context) {
}
}
-// buildStats runs dbt against the app's database, under the stats lock.
+// EnvDBTMode set to "cronjob" means a Kubernetes CronJob builds the marts
+// (the cluster deployment, spec section 8.2), so the app never runs dbt
+// itself.
+const EnvDBTMode = "RC_DBT_MODE"
+
+// buildStats runs dbt against the app's database, under the stats lock. With
+// RC_DBT_MODE=cronjob it does nothing.
func (a *App) buildStats(ctx context.Context) error {
+ if os.Getenv(EnvDBTMode) == "cronjob" {
+ return nil
+ }
return play.BuildStats(ctx, a.st, a.dsn)
}
diff --git a/internal/cli/runtime.go b/internal/cli/runtime.go
new file mode 100644
index 0000000..58574e3
--- /dev/null
+++ b/internal/cli/runtime.go
@@ -0,0 +1,72 @@
+package cli
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "os"
+
+ "github.com/DIZAMU/RootCause/internal/runtime"
+ "github.com/DIZAMU/RootCause/internal/runtime/docker"
+ "github.com/DIZAMU/RootCause/internal/runtime/kubernetes"
+ "github.com/DIZAMU/RootCause/internal/session"
+)
+
+// EnvRuntime selects the sandbox runtime: "docker" (the default, v1) or
+// "kubernetes" (v2, spec section 8.2).
+const EnvRuntime = "RC_RUNTIME"
+
+// Backend is the runtime the engine drives, with the builder for its images
+// and, where the runtime has one, its interactive shell.
+type Backend struct {
+ Runtime runtime.Runtime
+ Builder session.Builder
+ Sheller runtime.Sheller // nil when the runtime has no terminal shell
+}
+
+// RuntimeName returns the runtime RC_RUNTIME selects, or an error for an
+// unknown value.
+func RuntimeName() (string, error) {
+ switch v := os.Getenv(EnvRuntime); v {
+ case "", "docker":
+ return "docker", nil
+ case "kubernetes":
+ return "kubernetes", nil
+ default:
+ return "", fmt.Errorf("%s=%q: want docker or kubernetes", EnvRuntime, v)
+ }
+}
+
+// NewBackend builds the backend RC_RUNTIME selects. The docker runtime must
+// keep sandbox root apart from host root (spec section 13.1, OQ-3); the
+// kubernetes runtime gives every sandbox pod its own user namespace itself.
+// Errors are already the translatable user errors.
+func NewBackend(ctx context.Context) (Backend, error) {
+ name, err := RuntimeName()
+ if err != nil {
+ return Backend{}, err
+ }
+ if name == "kubernetes" {
+ rt, err := kubernetes.New()
+ if err != nil {
+ return Backend{}, fail("error.no_kubernetes", err)
+ }
+ var b session.Builder = kubernetes.Importer{Cluster: kubernetes.Cluster}
+ if rt.InCluster() {
+ b = kubernetes.Preloaded{}
+ }
+ return Backend{Runtime: rt, Builder: b}, nil
+ }
+
+ rt, err := docker.New()
+ if err != nil {
+ return Backend{}, fail("error.no_docker", err)
+ }
+ if err := rt.CheckIsolation(ctx); err != nil {
+ if errors.Is(err, docker.ErrNotIsolated) {
+ return Backend{}, fail("error.not_isolated", nil)
+ }
+ return Backend{}, err
+ }
+ return Backend{Runtime: rt, Builder: rt, Sheller: rt}, nil
+}
diff --git a/internal/cli/serve.go b/internal/cli/serve.go
index 6b4aef5..8a8858b 100644
--- a/internal/cli/serve.go
+++ b/internal/cli/serve.go
@@ -24,11 +24,14 @@ const reapInterval = time.Minute
// Serve runs the local dashboard on 127.0.0.1 (spec sections 10 and 11) until
// Ctrl-C. It reaps expired sessions on a ticker while it runs.
func (a *App) Serve(ctx context.Context, args []string) error {
- port, ok := parsePort(args)
+ port, listenAll, ok := parseServe(args)
if !ok {
a.say("serve.usage", nil)
return nil
}
+ if err := checkListenAll(listenAll, a.runtime); err != nil {
+ return err
+ }
ctx, stop := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
defer stop()
@@ -46,7 +49,11 @@ func (a *App) Serve(ctx context.Context, args []string) error {
if err != nil {
return err
}
- ln, err := server.Listen(port)
+ listen := server.Listen
+ if listenAll {
+ listen = server.ListenAll
+ }
+ ln, err := listen(port)
if err != nil {
return failWith("serve.listen_failed", map[string]string{"port": strconv.Itoa(port)}, err)
}
@@ -71,17 +78,33 @@ func (a *App) Serve(ctx context.Context, args []string) error {
return nil
}
-// parsePort reads "[--port N]".
-func parsePort(args []string) (int, bool) {
- switch {
- case len(args) == 0:
- return server.DefaultPort, true
- case len(args) == 2 && args[0] == "--port":
- p, err := strconv.Atoi(args[1])
- if err != nil || p < 1 || p > 65535 {
- return 0, false
+// parseServe reads "[--port N] [--listen-all]".
+func parseServe(args []string) (port int, listenAll bool, ok bool) {
+ port = server.DefaultPort
+ for i := 0; i < len(args); i++ {
+ switch {
+ case args[i] == "--listen-all":
+ listenAll = true
+ case args[i] == "--port" && i+1 < len(args):
+ p, err := strconv.Atoi(args[i+1])
+ if err != nil || p < 1 || p > 65535 {
+ return 0, false, false
+ }
+ port = p
+ i++
+ default:
+ return 0, false, false
}
- return p, true
}
- return 0, false
+ return port, listenAll, true
+}
+
+// checkListenAll refuses --listen-all unless the runtime is kubernetes: only
+// the cluster deployment listens beyond 127.0.0.1 (spec section 13.1), behind
+// k3d's port mapping on the host's 127.0.0.1.
+func checkListenAll(listenAll bool, runtimeName string) error {
+ if listenAll && runtimeName != "kubernetes" {
+ return fail("serve.listen_all_refused", nil)
+ }
+ return nil
}
diff --git a/internal/cli/serve_test.go b/internal/cli/serve_test.go
new file mode 100644
index 0000000..a49cfdc
--- /dev/null
+++ b/internal/cli/serve_test.go
@@ -0,0 +1,77 @@
+package cli
+
+import (
+ "context"
+ "errors"
+ "testing"
+ "time"
+)
+
+func TestParseServe(t *testing.T) {
+ cases := []struct {
+ args []string
+ port int
+ listenAll bool
+ ok bool
+ }{
+ {nil, 7300, false, true},
+ {[]string{"--port", "8080"}, 8080, false, true},
+ {[]string{"--listen-all"}, 7300, true, true},
+ {[]string{"--listen-all", "--port", "8080"}, 8080, true, true},
+ {[]string{"--port", "0"}, 0, false, false},
+ {[]string{"--port"}, 0, false, false},
+ {[]string{"--bind", "0.0.0.0"}, 0, false, false},
+ }
+ for _, c := range cases {
+ port, all, ok := parseServe(c.args)
+ if ok != c.ok || (ok && (port != c.port || all != c.listenAll)) {
+ t.Errorf("parseServe(%q) = %d, %v, %v; want %d, %v, %v", c.args, port, all, ok, c.port, c.listenAll, c.ok)
+ }
+ }
+}
+
+func TestListenAllNeedsKubernetes(t *testing.T) {
+ var ue *userError
+ if err := checkListenAll(true, "docker"); !errors.As(err, &ue) || ue.key != "serve.listen_all_refused" {
+ t.Errorf("--listen-all with docker: %v, want serve.listen_all_refused", err)
+ }
+ if err := checkListenAll(true, "kubernetes"); err != nil {
+ t.Errorf("--listen-all with kubernetes: %v", err)
+ }
+ if err := checkListenAll(false, "docker"); err != nil {
+ t.Errorf("no --listen-all: %v", err)
+ }
+}
+
+func TestRuntimeName(t *testing.T) {
+ for in, want := range map[string]string{"": "docker", "docker": "docker", "kubernetes": "kubernetes"} {
+ t.Setenv(EnvRuntime, in)
+ if got, err := RuntimeName(); err != nil || got != want {
+ t.Errorf("RC_RUNTIME=%q: %q, %v; want %q", in, got, err, want)
+ }
+ }
+ t.Setenv(EnvRuntime, "podman")
+ if _, err := RuntimeName(); err == nil {
+ t.Error("RC_RUNTIME=podman accepted")
+ }
+}
+
+// openStore keeps trying an unreachable database for RC_DB_WAIT_SECONDS,
+// then fails; without the variable it fails on the first try.
+func TestOpenStoreWaitIsBounded(t *testing.T) {
+ const dsn = "postgres://nobody:nothing@127.0.0.1:1/none?sslmode=disable&connect_timeout=1"
+ ctx := context.Background()
+ for _, c := range []struct {
+ wait string
+ min, max time.Duration
+ }{{"", 0, time.Second}, {"2", 2 * time.Second, 5 * time.Second}} {
+ t.Setenv(EnvDBWait, c.wait)
+ start := time.Now()
+ if _, err := openStore(ctx, dsn); err == nil {
+ t.Fatalf("RC_DB_WAIT_SECONDS=%q: opened an unreachable database", c.wait)
+ }
+ if d := time.Since(start); d < c.min || d > c.max {
+ t.Errorf("RC_DB_WAIT_SECONDS=%q: gave up after %s, want %s to %s", c.wait, d, c.min, c.max)
+ }
+ }
+}
diff --git a/internal/dbt/dbt.go b/internal/dbt/dbt.go
index d14cf1c..a62b931 100644
--- a/internal/dbt/dbt.go
+++ b/internal/dbt/dbt.go
@@ -289,7 +289,26 @@ func extract(fsys fs.FS) (string, error) {
if err != nil {
return "", err
}
- err = fs.WalkDir(fsys, ".", func(p string, d fs.DirEntry, err error) error {
+ err = writeTree(fsys, dir)
+ if err == nil {
+ err = os.Chmod(dir, 0o755) // MkdirTemp creates it 0700
+ }
+ if err != nil {
+ os.RemoveAll(dir)
+ return "", err
+ }
+ return dir, nil
+}
+
+// WriteProject writes the embedded dbt project into dir, which must exist.
+// The cluster's dbt CronJob runs it from there (spec section 8.2).
+func WriteProject(dir string) error {
+ return writeTree(dbtproject.FS, dir)
+}
+
+// writeTree copies fsys into dir: directories 0755, files 0644.
+func writeTree(fsys fs.FS, dir string) error {
+ return fs.WalkDir(fsys, ".", func(p string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
@@ -303,14 +322,6 @@ func extract(fsys fs.FS) (string, error) {
}
return os.WriteFile(dst, data, 0o644)
})
- if err == nil {
- err = os.Chmod(dir, 0o755) // MkdirTemp creates it 0700
- }
- if err != nil {
- os.RemoveAll(dir)
- return "", err
- }
- return dir, nil
}
// LogDir is where the dbt.log of failed builds is saved.
diff --git a/internal/dbt/dbt_test.go b/internal/dbt/dbt_test.go
index 6e7518d..3d2ea17 100644
--- a/internal/dbt/dbt_test.go
+++ b/internal/dbt/dbt_test.go
@@ -52,8 +52,8 @@ func TestExpectedNodesOfProject(t *testing.T) {
if err != nil {
t.Fatal(err)
}
- if n != 55 {
- t.Errorf("ExpectedNodes = %d, want 55 (8 models, 3 singular tests, 44 generic tests)", n)
+ if n != 57 {
+ t.Errorf("ExpectedNodes = %d, want 57 (9 models, 3 singular tests, 45 generic tests)", n)
}
}
diff --git a/internal/runtime/kubernetes/build.go b/internal/runtime/kubernetes/build.go
new file mode 100644
index 0000000..495cd3b
--- /dev/null
+++ b/internal/runtime/kubernetes/build.go
@@ -0,0 +1,46 @@
+package kubernetes
+
+import (
+ "bytes"
+ "context"
+ "fmt"
+ "os/exec"
+ "strings"
+)
+
+// Cluster is the name of the k3d cluster in deploy/k8s/k3d.yaml.
+const Cluster = "rootcause"
+
+// Importer builds an exercise image with docker on the host and imports it
+// into every node of the k3d cluster, where sandbox pods use it without a
+// pull. It is the builder for the CLI outside the cluster.
+type Importer struct {
+ Cluster string
+}
+
+// BuildImage builds the image, then runs k3d image import.
+func (i Importer) BuildImage(ctx context.Context, tag, dir string) error {
+ if err := run(ctx, "docker", "build", "-t", tag, dir); err != nil {
+ return err
+ }
+ return run(ctx, "k3d", "image", "import", "--cluster", i.Cluster, tag)
+}
+
+// Preloaded is the builder inside the cluster, which cannot build images:
+// scripts/k8s-up.sh imports every exercise image before the API starts. A
+// missing image shows up as the pod's ErrImageNeverPull in Diagnose.
+type Preloaded struct{}
+
+// BuildImage does nothing.
+func (Preloaded) BuildImage(context.Context, string, string) error { return nil }
+
+// run runs a host command and returns its stderr in the error.
+func run(ctx context.Context, name string, args ...string) error {
+ c := exec.CommandContext(ctx, name, args...)
+ var errBuf bytes.Buffer
+ c.Stderr = &errBuf
+ if err := c.Run(); err != nil {
+ return fmt.Errorf("%s %s: %w: %s", name, strings.Join(args, " "), err, strings.TrimSpace(errBuf.String()))
+ }
+ return nil
+}
diff --git a/internal/runtime/kubernetes/kubernetes.go b/internal/runtime/kubernetes/kubernetes.go
new file mode 100644
index 0000000..604f78b
--- /dev/null
+++ b/internal/runtime/kubernetes/kubernetes.go
@@ -0,0 +1,474 @@
+// Package kubernetes implements runtime.Runtime with client-go (spec sections
+// 8.2 and 8.3). Every sandbox is a pod in the sandboxes namespace, created
+// locked down (spec sections 13.1 and 13.2): its own user namespace
+// (hostUsers false), seccomp RuntimeDefault, all capabilities dropped and the
+// exercise's allowlist added back, no privilege escalation, no service account
+// token, no host paths, memory and CPU limits, and no cluster DNS. The pause
+// container is PID 1 and reaps stopped processes, like docker's --init.
+//
+// The default-deny NetworkPolicy, the ResourceQuota and the LimitRange of the
+// namespace live in deploy/k8s, not here.
+package kubernetes
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "sort"
+ "strings"
+ "time"
+
+ corev1 "k8s.io/api/core/v1"
+ apierrors "k8s.io/apimachinery/pkg/api/errors"
+ "k8s.io/apimachinery/pkg/api/resource"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/client-go/kubernetes"
+ "k8s.io/client-go/kubernetes/scheme"
+ "k8s.io/client-go/rest"
+ "k8s.io/client-go/tools/clientcmd"
+ "k8s.io/client-go/tools/remotecommand"
+ utilexec "k8s.io/client-go/util/exec"
+
+ "github.com/DIZAMU/RootCause/internal/runtime"
+)
+
+// Namespace holds every sandbox pod.
+const Namespace = "sandboxes"
+
+const (
+ // container is the name of the one container in a sandbox pod.
+ container = "sandbox"
+ // labelSandbox marks every pod this package creates, so List and the
+ // reaper find RootCause sandboxes and nothing else.
+ labelSandbox = "rootcause.sandbox"
+ // labelNetwork is the spec's network mode ("none" or "internal"). The
+ // NetworkPolicies in deploy/k8s select on it.
+ labelNetwork = "rootcause.network"
+)
+
+// deleteTimeout bounds how long Destroy waits for a pod to be gone, so a
+// sandbox can be created again under the same name right after.
+const deleteTimeout = 60 * time.Second
+
+// ErrRestartUnsupported means the runtime cannot restart a sandbox and keep
+// its files: a restarted container starts again from its image.
+var ErrRestartUnsupported = errors.New("the kubernetes runtime cannot restart a sandbox and keep its files")
+
+// Runtime controls sandbox pods.
+type Runtime struct {
+ cs kubernetes.Interface
+ config *rest.Config
+ ns string
+ inCluster bool
+}
+
+var _ runtime.Runtime = (*Runtime)(nil)
+var _ runtime.Diagnoser = (*Runtime)(nil)
+var _ runtime.SystemdChecker = (*Runtime)(nil)
+var _ runtime.Terminal = (*Runtime)(nil)
+
+// New returns a runtime for the cluster it runs in, or, outside a cluster, for
+// the current kubeconfig context.
+func New() (*Runtime, error) {
+ config, err := rest.InClusterConfig()
+ inCluster := err == nil
+ if errors.Is(err, rest.ErrNotInCluster) {
+ rules := clientcmd.NewDefaultClientConfigLoadingRules()
+ config, err = clientcmd.NewNonInteractiveDeferredLoadingClientConfig(rules, &clientcmd.ConfigOverrides{}).ClientConfig()
+ }
+ if err != nil {
+ return nil, fmt.Errorf("kubernetes config: %w", err)
+ }
+ cs, err := kubernetes.NewForConfig(config)
+ if err != nil {
+ return nil, fmt.Errorf("kubernetes client: %w", err)
+ }
+ return &Runtime{cs: cs, config: config, ns: Namespace, inCluster: inCluster}, nil
+}
+
+// InCluster reports whether the runtime uses the pod's own service account.
+func (r *Runtime) InCluster() bool { return r.inCluster }
+
+// Create starts a locked-down sandbox pod and returns it once its container
+// runs, as docker run -d does. The engine then waits until the sandbox
+// accepts commands. A pod that cannot start (a missing image, say) is deleted
+// and the error describes it.
+//
+// Pods on the internal network get a hosts entry for every other internal pod
+// that has an address, so a check runner reaches its sandbox by name, as on
+// docker's internal network. Which of them may actually connect is up to the
+// NetworkPolicies.
+func (r *Runtime) Create(ctx context.Context, spec runtime.SandboxSpec) (runtime.Sandbox, error) {
+ if spec.Name == "" || spec.Image == "" {
+ return runtime.Sandbox{}, fmt.Errorf("sandbox spec needs a name and an image")
+ }
+ var aliases []corev1.HostAlias
+ if spec.Network == "internal" {
+ pods, err := r.cs.CoreV1().Pods(r.ns).List(ctx, metav1.ListOptions{
+ LabelSelector: labelSandbox + "=true," + labelNetwork + "=internal",
+ })
+ if err != nil {
+ return runtime.Sandbox{}, fmt.Errorf("list internal pods: %w", err)
+ }
+ aliases = hostAliases(pods.Items)
+ }
+ pod, err := podFor(spec, r.ns, aliases)
+ if err != nil {
+ return runtime.Sandbox{}, err
+ }
+ created, err := r.cs.CoreV1().Pods(r.ns).Create(ctx, pod, metav1.CreateOptions{})
+ if err != nil {
+ return runtime.Sandbox{}, fmt.Errorf("create pod %s: %w", spec.Name, err)
+ }
+ if err := r.waitRunning(ctx, created.Name); err != nil {
+ _ = r.Destroy(context.Background(), created.Name)
+ return runtime.Sandbox{}, err
+ }
+ return runtime.Sandbox{
+ ID: created.Name,
+ Name: created.Name,
+ Image: spec.Image,
+ Created: created.CreationTimestamp.Time,
+ Labels: spec.Labels,
+ }, nil
+}
+
+// startTimeout bounds how long a new pod may take to run its container.
+const startTimeout = 60 * time.Second
+
+// waitRunning polls the pod until its container runs. It fails at once on a
+// pod that has ended or a container that cannot start, and after
+// startTimeout otherwise, with Diagnose's description of the pod.
+func (r *Runtime) waitRunning(ctx context.Context, name string) error {
+ ctx, cancel := context.WithTimeout(ctx, startTimeout)
+ defer cancel()
+ for {
+ pod, err := r.cs.CoreV1().Pods(r.ns).Get(ctx, name, metav1.GetOptions{})
+ if err == nil {
+ switch started(pod) {
+ case podRunning:
+ return nil
+ case podFailed:
+ return fmt.Errorf("pod %s did not start: %s", name, r.diagnose(context.Background(), name))
+ }
+ }
+ select {
+ case <-ctx.Done():
+ return fmt.Errorf("pod %s not running within %s: %s", name, startTimeout, r.diagnose(context.Background(), name))
+ case <-time.After(200 * time.Millisecond):
+ }
+ }
+}
+
+type podStart int
+
+const (
+ podStarting podStart = iota
+ podRunning
+ podFailed
+)
+
+// cannotStart are the waiting reasons of a container that will not start
+// without a change to the pod or the node.
+var cannotStart = map[string]bool{
+ "ErrImageNeverPull": true,
+ "InvalidImageName": true,
+ "CreateContainerConfigError": true,
+ "CreateContainerError": true,
+ "RunContainerError": true,
+}
+
+// started reads a pod's progress towards a running container.
+func started(pod *corev1.Pod) podStart {
+ if pod.Status.Phase == corev1.PodSucceeded || pod.Status.Phase == corev1.PodFailed {
+ return podFailed
+ }
+ for _, cs := range pod.Status.ContainerStatuses {
+ if cs.Name != container {
+ continue
+ }
+ switch {
+ case cs.State.Running != nil:
+ return podRunning
+ case cs.State.Terminated != nil:
+ return podFailed
+ case cs.State.Waiting != nil && cannotStart[cs.State.Waiting.Reason]:
+ return podFailed
+ }
+ }
+ return podStarting
+}
+
+// hostAliases maps the name of every running pod with an address to that
+// address, sorted by name.
+func hostAliases(pods []corev1.Pod) []corev1.HostAlias {
+ var aliases []corev1.HostAlias
+ for _, p := range pods {
+ if p.Status.PodIP == "" || p.DeletionTimestamp != nil {
+ continue
+ }
+ aliases = append(aliases, corev1.HostAlias{IP: p.Status.PodIP, Hostnames: []string{p.Name}})
+ }
+ sort.Slice(aliases, func(i, j int) bool { return aliases[i].Hostnames[0] < aliases[j].Hostnames[0] })
+ return aliases
+}
+
+// podFor builds the pod for a sandbox spec.
+func podFor(spec runtime.SandboxSpec, ns string, aliases []corev1.HostAlias) (*corev1.Pod, error) {
+ no, yes := false, true
+ labels := map[string]string{labelSandbox: "true", labelNetwork: networkLabel(spec.Network)}
+ for k, v := range spec.Labels {
+ labels[k] = v
+ }
+
+ limits := corev1.ResourceList{}
+ if spec.Memory != "" {
+ q, err := resource.ParseQuantity(spec.Memory)
+ if err != nil {
+ return nil, fmt.Errorf("memory %q: %w", spec.Memory, err)
+ }
+ limits[corev1.ResourceMemory] = q
+ }
+ if spec.CPUs > 0 {
+ limits[corev1.ResourceCPU] = *resource.NewMilliQuantity(int64(spec.CPUs*1000), resource.DecimalSI)
+ }
+
+ var volumes []corev1.Volume
+ var mounts []corev1.VolumeMount
+ for i, t := range spec.Tmpfs {
+ size, err := resource.ParseQuantity(t.Size)
+ if err != nil {
+ return nil, fmt.Errorf("tmpfs %s size %q: %w", t.Path, t.Size, err)
+ }
+ name := fmt.Sprintf("tmpfs-%d", i)
+ volumes = append(volumes, corev1.Volume{Name: name, VolumeSource: corev1.VolumeSource{
+ EmptyDir: &corev1.EmptyDirVolumeSource{Medium: corev1.StorageMediumMemory, SizeLimit: &size},
+ }})
+ mounts = append(mounts, corev1.VolumeMount{Name: name, MountPath: t.Path})
+ }
+
+ caps := make([]corev1.Capability, len(spec.Caps))
+ for i, c := range spec.Caps {
+ caps[i] = corev1.Capability(c)
+ }
+ grace := int64(0)
+
+ return &corev1.Pod{
+ ObjectMeta: metav1.ObjectMeta{Name: spec.Name, Namespace: ns, Labels: labels},
+ Spec: corev1.PodSpec{
+ HostUsers: &no,
+ AutomountServiceAccountToken: &no,
+ EnableServiceLinks: &no,
+ ShareProcessNamespace: &yes,
+ RestartPolicy: corev1.RestartPolicyNever,
+ TerminationGracePeriodSeconds: &grace,
+ // No cluster DNS: the sandbox resolves nothing it is not given,
+ // and learns nothing about the cluster from resolv.conf.
+ DNSPolicy: corev1.DNSNone,
+ DNSConfig: &corev1.PodDNSConfig{Nameservers: []string{"127.0.0.1"}},
+ HostAliases: aliases,
+ SecurityContext: &corev1.PodSecurityContext{
+ SeccompProfile: &corev1.SeccompProfile{Type: corev1.SeccompProfileTypeRuntimeDefault},
+ },
+ Containers: []corev1.Container{{
+ Name: container,
+ Image: spec.Image,
+ ImagePullPolicy: corev1.PullNever,
+ SecurityContext: &corev1.SecurityContext{
+ AllowPrivilegeEscalation: &no,
+ Capabilities: &corev1.Capabilities{Drop: []corev1.Capability{"ALL"}, Add: caps},
+ },
+ Resources: corev1.ResourceRequirements{Limits: limits, Requests: limits},
+ VolumeMounts: mounts,
+ }},
+ Volumes: volumes,
+ },
+ }, nil
+}
+
+// networkLabel maps a spec network to the pod's network label. Anything but
+// "internal" is "none", as in the docker runtime.
+func networkLabel(network string) string {
+ if network == "internal" {
+ return "internal"
+ }
+ return "none"
+}
+
+// CheckSystemd always returns runtime.ErrSystemdUnsupported: in a pod with
+// its own user namespace the cgroup filesystem stays owned by host root, so
+// systemd cannot create its own cgroups and does not boot.
+func (r *Runtime) CheckSystemd(context.Context) error {
+ return runtime.ErrSystemdUnsupported
+}
+
+// Exec runs a command in the sandbox and captures its output and exit code.
+// A non-zero exit from the command is returned in ExecResult.ExitCode, not as
+// a Go error; a Go error means the exec itself could not run.
+func (r *Runtime) Exec(ctx context.Context, id string, cmd []string, stdin io.Reader) (runtime.ExecResult, error) {
+ var stdout, stderr bytes.Buffer
+ err := r.stream(ctx, id, cmd, remotecommand.StreamOptions{Stdin: stdin, Stdout: &stdout, Stderr: &stderr})
+ res := runtime.ExecResult{Stdout: stdout.Bytes(), Stderr: stderr.Bytes()}
+ if err == nil {
+ return res, nil
+ }
+ var ee utilexec.ExitError
+ if errors.As(err, &ee) && ee.Exited() {
+ res.ExitCode = ee.ExitStatus()
+ return res, nil
+ }
+ return res, fmt.Errorf("exec in pod %s: %w", id, err)
+}
+
+// Terminal runs a command in the sandbox with a terminal, for the
+// dashboard's web terminal.
+func (r *Runtime) Terminal(ctx context.Context, id string, cmd []string, stdin io.Reader, stdout io.Writer, sizes <-chan runtime.TermSize) error {
+ return r.stream(ctx, id, cmd, remotecommand.StreamOptions{
+ Stdin: stdin, Stdout: stdout, Tty: true, TerminalSizeQueue: sizeQueue(sizes),
+ })
+}
+
+// sizeQueue hands terminal sizes to remotecommand until the channel closes.
+type sizeQueue <-chan runtime.TermSize
+
+func (q sizeQueue) Next() *remotecommand.TerminalSize {
+ s, ok := <-q
+ if !ok {
+ return nil
+ }
+ return &remotecommand.TerminalSize{Width: s.Cols, Height: s.Rows}
+}
+
+// stream runs one exec through the API server's pods/exec subresource, over
+// WebSocket.
+func (r *Runtime) stream(ctx context.Context, id string, cmd []string, opts remotecommand.StreamOptions) error {
+ req := r.cs.CoreV1().RESTClient().Post().
+ Resource("pods").Namespace(r.ns).Name(id).SubResource("exec").
+ VersionedParams(&corev1.PodExecOptions{
+ Container: container,
+ Command: cmd,
+ Stdin: opts.Stdin != nil,
+ Stdout: opts.Stdout != nil,
+ Stderr: opts.Stderr != nil,
+ TTY: opts.Tty,
+ }, scheme.ParameterCodec)
+ exec, err := remotecommand.NewWebSocketExecutor(r.config, "GET", req.URL().String())
+ if err != nil {
+ return err
+ }
+ return exec.StreamWithContext(ctx, opts)
+}
+
+// CopyIn writes data to a path inside the sandbox. Kubernetes has no copy
+// API, so the data goes to `cat` on stdin; the file is made world-readable,
+// as docker cp leaves it.
+func (r *Runtime) CopyIn(ctx context.Context, id string, path string, data []byte) error {
+ res, err := r.Exec(ctx, id, []string{"/bin/sh", "-c", `cat > "$1" && chmod 0644 "$1"`, "sh", path}, bytes.NewReader(data))
+ if err != nil {
+ return err
+ }
+ if res.ExitCode != 0 {
+ return fmt.Errorf("copy to %s in pod %s: exit %d: %s", path, id, res.ExitCode, strings.TrimSpace(string(res.Stderr)))
+ }
+ return nil
+}
+
+// Restart returns ErrRestartUnsupported. The only exercises that ask for a
+// restart before the check boot systemd, which CheckSystemd refuses first.
+func (r *Runtime) Restart(context.Context, string) error {
+ return ErrRestartUnsupported
+}
+
+// Destroy deletes the sandbox pod at once and waits until it is gone. It is
+// not an error if it is already gone.
+func (r *Runtime) Destroy(ctx context.Context, id string) error {
+ grace := int64(0)
+ err := r.cs.CoreV1().Pods(r.ns).Delete(ctx, id, metav1.DeleteOptions{GracePeriodSeconds: &grace})
+ if apierrors.IsNotFound(err) {
+ return nil
+ }
+ if err != nil {
+ return fmt.Errorf("delete pod %s: %w", id, err)
+ }
+ ctx, cancel := context.WithTimeout(ctx, deleteTimeout)
+ defer cancel()
+ for {
+ _, err := r.cs.CoreV1().Pods(r.ns).Get(ctx, id, metav1.GetOptions{})
+ if apierrors.IsNotFound(err) {
+ return nil
+ }
+ select {
+ case <-ctx.Done():
+ return fmt.Errorf("pod %s still exists after %s", id, deleteTimeout)
+ case <-time.After(200 * time.Millisecond):
+ }
+ }
+}
+
+// List returns the RootCause sandbox pods that are not being deleted, with
+// their labels.
+func (r *Runtime) List(ctx context.Context) ([]runtime.Sandbox, error) {
+ pods, err := r.cs.CoreV1().Pods(r.ns).List(ctx, metav1.ListOptions{LabelSelector: labelSandbox + "=true"})
+ if err != nil {
+ return nil, fmt.Errorf("list pods: %w", err)
+ }
+ var boxes []runtime.Sandbox
+ for _, p := range pods.Items {
+ if p.DeletionTimestamp != nil {
+ continue
+ }
+ image := ""
+ if len(p.Spec.Containers) > 0 {
+ image = p.Spec.Containers[0].Image
+ }
+ boxes = append(boxes, runtime.Sandbox{
+ ID: p.Name, Name: p.Name, Image: image, Created: p.CreationTimestamp.Time, Labels: p.Labels,
+ })
+ }
+ return boxes, nil
+}
+
+// diagnoseLogLines is how many of a sandbox's last log lines Diagnose shows.
+const diagnoseLogLines = 20
+
+// Diagnose describes a sandbox pod for an error message: its phase, why its
+// container is waiting or how it ended, and its last log lines. It never
+// fails; what it cannot read, it says it cannot read.
+func (r *Runtime) Diagnose(ctx context.Context, id string) string {
+ return r.diagnose(ctx, id)
+}
+
+// diagnose is Diagnose with its own short deadline, for callers whose context
+// may have ended.
+func (r *Runtime) diagnose(ctx context.Context, id string) string {
+ ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
+ defer cancel()
+ pod, err := r.cs.CoreV1().Pods(r.ns).Get(ctx, id, metav1.GetOptions{})
+ if err != nil {
+ return "state unknown: " + err.Error()
+ }
+ var b strings.Builder
+ fmt.Fprintf(&b, "pod phase %s", pod.Status.Phase)
+ for _, cs := range pod.Status.ContainerStatuses {
+ switch {
+ case cs.State.Waiting != nil:
+ fmt.Fprintf(&b, ", waiting: %s %s", cs.State.Waiting.Reason, cs.State.Waiting.Message)
+ case cs.State.Terminated != nil:
+ fmt.Fprintf(&b, ", exit code %d (%s)", cs.State.Terminated.ExitCode, cs.State.Terminated.Reason)
+ }
+ }
+ lines := int64(diagnoseLogLines)
+ logs, err := r.cs.CoreV1().Pods(r.ns).GetLogs(id, &corev1.PodLogOptions{Container: container, TailLines: &lines}).DoRaw(ctx)
+ tail := strings.TrimSpace(string(logs))
+ switch {
+ case err != nil:
+ b.WriteString("; logs unavailable")
+ case tail == "":
+ b.WriteString("; no log output")
+ default:
+ fmt.Fprintf(&b, "; last log lines:\n%s", tail)
+ }
+ return b.String()
+}
diff --git a/internal/runtime/kubernetes/kubernetes_test.go b/internal/runtime/kubernetes/kubernetes_test.go
new file mode 100644
index 0000000..9aff482
--- /dev/null
+++ b/internal/runtime/kubernetes/kubernetes_test.go
@@ -0,0 +1,249 @@
+package kubernetes
+
+import (
+ "context"
+ "errors"
+ "reflect"
+ "strconv"
+ "testing"
+
+ corev1 "k8s.io/api/core/v1"
+ "k8s.io/apimachinery/pkg/api/resource"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ k8sruntime "k8s.io/apimachinery/pkg/runtime"
+ "k8s.io/client-go/kubernetes/fake"
+ k8stesting "k8s.io/client-go/testing"
+
+ "github.com/DIZAMU/RootCause/internal/runtime"
+)
+
+func TestPodForLocksDown(t *testing.T) {
+ spec := runtime.SandboxSpec{
+ Name: "rootcause-s-1", Image: "rootcause/perm-01-shared-folder:v1",
+ Memory: "256Mi", CPUs: 0.5, Network: "none",
+ Caps: []string{"CHOWN", "SETUID"},
+ Labels: map[string]string{"rootcause.session": "s-1"},
+ Tmpfs: []runtime.Tmpfs{{Path: "/var/spool", Size: "32Mi"}},
+ }
+ pod, err := podFor(spec, Namespace, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ ps := pod.Spec
+ if ps.HostUsers == nil || *ps.HostUsers {
+ t.Error("hostUsers must be false: pod root must not be host root")
+ }
+ if ps.AutomountServiceAccountToken == nil || *ps.AutomountServiceAccountToken {
+ t.Error("the service account token must not be mounted")
+ }
+ if ps.EnableServiceLinks == nil || *ps.EnableServiceLinks {
+ t.Error("service links must be off: no cluster addresses in the environment")
+ }
+ if ps.ShareProcessNamespace == nil || !*ps.ShareProcessNamespace {
+ t.Error("shareProcessNamespace must be on, so pause reaps stopped processes")
+ }
+ if ps.SecurityContext == nil || ps.SecurityContext.SeccompProfile == nil ||
+ ps.SecurityContext.SeccompProfile.Type != corev1.SeccompProfileTypeRuntimeDefault {
+ t.Error("seccomp must be RuntimeDefault")
+ }
+ if ps.HostNetwork || ps.HostPID || ps.HostIPC {
+ t.Error("no host namespaces")
+ }
+ if ps.DNSPolicy != corev1.DNSNone || ps.DNSConfig == nil || !reflect.DeepEqual(ps.DNSConfig.Nameservers, []string{"127.0.0.1"}) {
+ t.Errorf("DNS: policy %q config %+v, want None with 127.0.0.1 only", ps.DNSPolicy, ps.DNSConfig)
+ }
+ if ps.RestartPolicy != corev1.RestartPolicyNever {
+ t.Errorf("restartPolicy %q, want Never", ps.RestartPolicy)
+ }
+ for _, v := range ps.Volumes {
+ if v.HostPath != nil {
+ t.Errorf("volume %s is a hostPath", v.Name)
+ }
+ }
+ if len(ps.Containers) != 1 {
+ t.Fatalf("%d containers, want 1", len(ps.Containers))
+ }
+ c := ps.Containers[0]
+ sc := c.SecurityContext
+ if sc == nil || sc.AllowPrivilegeEscalation == nil || *sc.AllowPrivilegeEscalation {
+ t.Error("allowPrivilegeEscalation must be false")
+ }
+ if sc.Privileged != nil && *sc.Privileged {
+ t.Error("privileged")
+ }
+ if !reflect.DeepEqual(sc.Capabilities.Drop, []corev1.Capability{"ALL"}) ||
+ !reflect.DeepEqual(sc.Capabilities.Add, []corev1.Capability{"CHOWN", "SETUID"}) {
+ t.Errorf("capabilities %+v, want drop ALL, add the exercise's", sc.Capabilities)
+ }
+ if c.ImagePullPolicy != corev1.PullNever {
+ t.Errorf("imagePullPolicy %q, want Never", c.ImagePullPolicy)
+ }
+ if got := c.Resources.Limits[corev1.ResourceMemory]; got.Cmp(resource.MustParse("256Mi")) != 0 {
+ t.Errorf("memory limit %s", got.String())
+ }
+ if got := c.Resources.Limits[corev1.ResourceCPU]; got.MilliValue() != 500 {
+ t.Errorf("cpu limit %s", got.String())
+ }
+ if len(ps.Volumes) != 1 || ps.Volumes[0].EmptyDir == nil || ps.Volumes[0].EmptyDir.Medium != corev1.StorageMediumMemory ||
+ ps.Volumes[0].EmptyDir.SizeLimit.Cmp(resource.MustParse("32Mi")) != 0 || c.VolumeMounts[0].MountPath != "/var/spool" {
+ t.Errorf("tmpfs: volumes %+v mounts %+v", ps.Volumes, c.VolumeMounts)
+ }
+ want := map[string]string{labelSandbox: "true", labelNetwork: "none", "rootcause.session": "s-1"}
+ if !reflect.DeepEqual(pod.Labels, want) {
+ t.Errorf("labels %v, want %v", pod.Labels, want)
+ }
+}
+
+func TestPodForRejectsBadSizes(t *testing.T) {
+ if _, err := podFor(runtime.SandboxSpec{Name: "a", Image: "i", Memory: "lots"}, Namespace, nil); err == nil {
+ t.Error("memory \"lots\" accepted")
+ }
+ if _, err := podFor(runtime.SandboxSpec{Name: "a", Image: "i", Tmpfs: []runtime.Tmpfs{{Path: "/x", Size: "big"}}}, Namespace, nil); err == nil {
+ t.Error("tmpfs size \"big\" accepted")
+ }
+}
+
+func TestNetworkLabel(t *testing.T) {
+ for in, want := range map[string]string{"internal": "internal", "none": "none", "": "none", "host": "none"} {
+ if got := networkLabel(in); got != want {
+ t.Errorf("networkLabel(%q) = %q, want %q", in, got, want)
+ }
+ }
+}
+
+func pod(name, ip string, deleting bool, labels map[string]string) *corev1.Pod {
+ p := &corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: Namespace, Labels: labels}}
+ p.Status.PodIP = ip
+ if deleting {
+ now := metav1.Now()
+ p.DeletionTimestamp = &now
+ }
+ return p
+}
+
+func TestHostAliases(t *testing.T) {
+ got := hostAliases([]corev1.Pod{
+ *pod("b", "10.0.0.2", false, nil),
+ *pod("a", "10.0.0.1", false, nil),
+ *pod("pending", "", false, nil),
+ *pod("going", "10.0.0.3", true, nil),
+ })
+ want := []corev1.HostAlias{{IP: "10.0.0.1", Hostnames: []string{"a"}}, {IP: "10.0.0.2", Hostnames: []string{"b"}}}
+ if !reflect.DeepEqual(got, want) {
+ t.Errorf("hostAliases = %+v, want %+v", got, want)
+ }
+}
+
+func TestStarted(t *testing.T) {
+ state := func(s corev1.ContainerState) *corev1.Pod {
+ p := pod("p", "", false, nil)
+ p.Status.Phase = corev1.PodPending
+ p.Status.ContainerStatuses = []corev1.ContainerStatus{{Name: container, State: s}}
+ return p
+ }
+ cases := []struct {
+ name string
+ pod *corev1.Pod
+ want podStart
+ }{
+ {"no status yet", pod("p", "", false, nil), podStarting},
+ {"creating", state(corev1.ContainerState{Waiting: &corev1.ContainerStateWaiting{Reason: "ContainerCreating"}}), podStarting},
+ {"running", state(corev1.ContainerState{Running: &corev1.ContainerStateRunning{}}), podRunning},
+ {"image missing", state(corev1.ContainerState{Waiting: &corev1.ContainerStateWaiting{Reason: "ErrImageNeverPull"}}), podFailed},
+ {"exited", state(corev1.ContainerState{Terminated: &corev1.ContainerStateTerminated{ExitCode: 1}}), podFailed},
+ }
+ for _, c := range cases {
+ if got := started(c.pod); got != c.want {
+ t.Errorf("%s: %v, want %v", c.name, got, c.want)
+ }
+ }
+ failed := pod("p", "", false, nil)
+ failed.Status.Phase = corev1.PodFailed
+ if started(failed) != podFailed {
+ t.Error("failed pod not reported as failed")
+ }
+}
+
+// fakeRuntime is a Runtime over client-go's fake clientset, where every
+// created pod runs at once and gets the next address.
+func fakeRuntime(t *testing.T, objects ...k8sruntime.Object) (*Runtime, *fake.Clientset) {
+ t.Helper()
+ cs := fake.NewClientset(objects...)
+ ip := 10
+ cs.PrependReactor("create", "pods", func(a k8stesting.Action) (bool, k8sruntime.Object, error) {
+ p := a.(k8stesting.CreateAction).GetObject().(*corev1.Pod)
+ ip++
+ p.Status.PodIP = "10.42.1." + strconv.Itoa(ip)
+ p.Status.ContainerStatuses = []corev1.ContainerStatus{{Name: container, State: corev1.ContainerState{Running: &corev1.ContainerStateRunning{}}}}
+ return false, nil, nil
+ })
+ return &Runtime{cs: cs, ns: Namespace}, cs
+}
+
+func TestCreateGivesInternalPodsTheirPeers(t *testing.T) {
+ ctx := context.Background()
+ internal := map[string]string{labelSandbox: "true", labelNetwork: "internal"}
+ isolated := map[string]string{labelSandbox: "true", labelNetwork: "none"}
+ r, cs := fakeRuntime(t,
+ pod("rootcause-s-1", "10.42.0.5", false, internal),
+ pod("rootcause-s-2", "10.42.0.6", false, isolated),
+ )
+
+ sb, err := r.Create(ctx, runtime.SandboxSpec{Name: "rootcause-s-1-runner", Image: "img", Network: "internal"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ got, err := cs.CoreV1().Pods(Namespace).Get(ctx, sb.ID, metav1.GetOptions{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ want := []corev1.HostAlias{{IP: "10.42.0.5", Hostnames: []string{"rootcause-s-1"}}}
+ if !reflect.DeepEqual(got.Spec.HostAliases, want) {
+ t.Errorf("runner hostAliases %+v, want only the internal sandbox %+v", got.Spec.HostAliases, want)
+ }
+
+ sb, err = r.Create(ctx, runtime.SandboxSpec{Name: "rootcause-s-3", Image: "img", Network: "none"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ got, _ = cs.CoreV1().Pods(Namespace).Get(ctx, sb.ID, metav1.GetOptions{})
+ if len(got.Spec.HostAliases) != 0 {
+ t.Errorf("isolated pod got hostAliases %+v", got.Spec.HostAliases)
+ }
+}
+
+func TestDestroyAndList(t *testing.T) {
+ ctx := context.Background()
+ labels := map[string]string{labelSandbox: "true", labelNetwork: "none", "rootcause.session": "s-1"}
+ r, _ := fakeRuntime(t,
+ pod("rootcause-s-1", "10.42.0.5", false, labels),
+ pod("rootcause-s-2", "10.42.0.6", true, map[string]string{labelSandbox: "true"}),
+ pod("not-ours", "10.42.0.7", false, nil),
+ )
+ boxes, err := r.List(ctx)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(boxes) != 1 || boxes[0].ID != "rootcause-s-1" || boxes[0].Labels["rootcause.session"] != "s-1" {
+ t.Errorf("List = %+v, want only rootcause-s-1 (not the deleting pod, not foreign pods)", boxes)
+ }
+ if err := r.Destroy(ctx, "rootcause-s-1"); err != nil {
+ t.Fatal(err)
+ }
+ if err := r.Destroy(ctx, "rootcause-s-1"); err != nil {
+ t.Errorf("Destroy of a missing pod: %v, want nil", err)
+ }
+ if boxes, _ := r.List(ctx); len(boxes) != 0 {
+ t.Errorf("after Destroy, List = %+v", boxes)
+ }
+}
+
+func TestUnsupported(t *testing.T) {
+ r := &Runtime{}
+ if err := r.CheckSystemd(context.Background()); !errors.Is(err, runtime.ErrSystemdUnsupported) {
+ t.Errorf("CheckSystemd = %v, want ErrSystemdUnsupported", err)
+ }
+ if err := r.Restart(context.Background(), "x"); !errors.Is(err, ErrRestartUnsupported) {
+ t.Errorf("Restart = %v, want ErrRestartUnsupported", err)
+ }
+}
diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go
index 7b63bd7..7107860 100644
--- a/internal/runtime/runtime.go
+++ b/internal/runtime/runtime.go
@@ -11,7 +11,7 @@ import (
)
// Runtime creates and controls sandboxes. The v1 implementation is
-// internal/runtime/docker; v2 will add internal/runtime/kubernetes.
+// internal/runtime/docker; v2 adds internal/runtime/kubernetes.
type Runtime interface {
Create(ctx context.Context, spec SandboxSpec) (Sandbox, error)
Exec(ctx context.Context, id string, cmd []string, stdin io.Reader) (ExecResult, error)
@@ -65,11 +65,25 @@ type ExecResult struct {
// Sheller is an optional, runtime-specific capability: open an interactive
// shell wired to the caller's own terminal. It is not part of Runtime because
-// v1 (docker exec -it) and v2 (ttyd in the browser) do it in unrelated ways.
+// v1 (docker exec -it) and v2 (a terminal in the browser) do it in unrelated
+// ways.
type Sheller interface {
Shell(ctx context.Context, id string, cmd []string) error
}
+// Terminal is an optional capability: run a command in a sandbox with a
+// terminal, wired to stdin and stdout, until it exits or ctx ends. sizes
+// carries the terminal's size whenever it changes. The dashboard's web
+// terminal (v2) uses it.
+type Terminal interface {
+ Terminal(ctx context.Context, id string, cmd []string, stdin io.Reader, stdout io.Writer, sizes <-chan TermSize) error
+}
+
+// TermSize is a terminal's size in character cells.
+type TermSize struct {
+ Cols, Rows uint16
+}
+
// Diagnoser is an optional capability: describe a sandbox that did not become
// ready, for example its exit code and its last log lines, so the error says
// why instead of only that it timed out.
@@ -78,7 +92,8 @@ type Diagnoser interface {
}
// SystemdChecker is an optional capability: report whether the host can boot
-// systemd as PID 1 in a sandbox. It returns ErrNeedsCgroupV2 if it cannot.
+// systemd as PID 1 in a sandbox. It returns ErrNeedsCgroupV2 or
+// ErrSystemdUnsupported if it cannot.
type SystemdChecker interface {
CheckSystemd(ctx context.Context) error
}
@@ -86,3 +101,7 @@ type SystemdChecker interface {
// ErrNeedsCgroupV2 means the host runs cgroup v1, where the systemd in the
// exercise images refuses to boot.
var ErrNeedsCgroupV2 = errors.New("this exercise needs a host with cgroup v2")
+
+// ErrSystemdUnsupported means the runtime cannot boot systemd as PID 1 in a
+// sandbox at all, whatever the host runs.
+var ErrSystemdUnsupported = errors.New("this runtime cannot boot systemd in a sandbox")
diff --git a/internal/runtime/scripted/scripted.go b/internal/runtime/scripted/scripted.go
index 4e5621f..40fffcb 100644
--- a/internal/runtime/scripted/scripted.go
+++ b/internal/runtime/scripted/scripted.go
@@ -51,3 +51,19 @@ func (r *Runtime) Restart(context.Context, string) error { return
func (r *Runtime) Destroy(context.Context, string) error { return nil }
func (r *Runtime) List(context.Context) ([]runtime.Sandbox, error) { return nil, nil }
func (r *Runtime) BuildImage(context.Context, string, string) error { return nil }
+
+// Terminal echoes stdin to stdout, like a shell printing what is typed, until
+// stdin ends or ctx does.
+func (r *Runtime) Terminal(ctx context.Context, _ string, _ []string, stdin io.Reader, stdout io.Writer, _ <-chan runtime.TermSize) error {
+ done := make(chan error, 1)
+ go func() {
+ _, err := io.Copy(stdout, stdin)
+ done <- err
+ }()
+ select {
+ case <-ctx.Done():
+ return ctx.Err()
+ case err := <-done:
+ return err
+ }
+}
diff --git a/internal/server/api.go b/internal/server/api.go
index 2592129..4f4fc6a 100644
--- a/internal/server/api.go
+++ b/internal/server/api.go
@@ -43,6 +43,7 @@ func (s *Server) routes(mux *http.ServeMux) {
mux.HandleFunc("POST /api/giveup", s.handleGiveUp)
mux.HandleFunc("PUT /api/lang", s.handleSetLang)
mux.HandleFunc("GET /api/i18n/{lang}", s.handleStrings)
+ mux.HandleFunc("GET "+TerminalPath, s.handleTerminal)
}
// --- responses ---------------------------------------------------------------
@@ -115,9 +116,10 @@ type reviewJSON struct {
}
type statsJSON struct {
- Fresh bool `json:"fresh"`
- Updating bool `json:"updating"`
- Error string `json:"error"` // the last rebuild's failure, translated; "" if none
+ Fresh bool `json:"fresh"`
+ Updating bool `json:"updating"`
+ Error string `json:"error"` // the last rebuild's failure, translated; "" if none
+ BuiltAt *time.Time `json:"built_at"` // when dbt last built the marts; null if never
}
// handleOverview is the home view: the active session, a preview of the next
@@ -188,6 +190,10 @@ func (s *Server) handleOverview(w http.ResponseWriter, r *http.Request) {
}
out.Stats = statsJSON{Fresh: fresh, Updating: s.stats.busy()}
+ if out.Stats.BuiltAt, err = s.cfg.Store.StatsBuiltAt(ctx); err != nil {
+ s.failErr(w, lang, err)
+ return
+ }
if err := s.stats.err(); err != nil {
out.Stats.Error = plainText(s.cfg.Describe(lang, err))
}
diff --git a/internal/server/cluster_test.go b/internal/server/cluster_test.go
new file mode 100644
index 0000000..9510640
--- /dev/null
+++ b/internal/server/cluster_test.go
@@ -0,0 +1,210 @@
+package server_test
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "regexp"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/gorilla/websocket"
+
+ "github.com/DIZAMU/RootCause/internal/server"
+)
+
+// TestClusterSession plays one session through the dashboard deployed in the
+// cluster (spec section 18, Phase 7), the way the browser does: it reads the
+// token from the served page, starts perm-01 over the API, works in the web
+// terminal, and checks. From the terminal it also tries every address in
+// RC_CLUSTER_PROBES ("name=ip:port,..."), all of which must be unreachable
+// from a sandbox. scripts/k8s-prove.sh runs it with RC_CLUSTER_URL set; it is
+// skipped otherwise.
+//
+// It plays on the cluster's own database, so the session counts there.
+func TestClusterSession(t *testing.T) {
+ base := os.Getenv("RC_CLUSTER_URL")
+ if base == "" {
+ t.Skip("RC_CLUSTER_URL is not set")
+ }
+ c := &clusterClient{t: t, base: strings.TrimSuffix(base, "/")}
+ c.readToken()
+
+ // A session left by an earlier, interrupted run ends first: give up is
+ // open after the third hint.
+ var active struct{ ID string }
+ if c.call("GET", "/api/session", nil, &active) == http.StatusOK {
+ for i := 0; i < 3; i++ {
+ c.call("POST", "/api/hint", map[string]string{"session": active.ID}, nil)
+ }
+ if code := c.call("POST", "/api/giveup", map[string]string{"session": active.ID}, nil); code != http.StatusOK {
+ t.Fatalf("give up the leftover session %s: status %d", active.ID, code)
+ }
+ }
+
+ var sess struct {
+ ID string
+ Status string
+ Terminal bool
+ }
+ if code := c.call("POST", "/api/start", map[string]string{"exercise": "perm-01-shared-folder"}, &sess); code != http.StatusOK {
+ t.Fatalf("start: status %d", code)
+ }
+ if sess.Status != "active" || !sess.Terminal {
+ t.Fatalf("started session %+v: want active with a terminal", sess)
+ }
+ t.Logf("session %s started", sess.ID)
+
+ term := c.openTerminal()
+ defer term.Close()
+ term.send(`{"cols":120,"rows":40}`)
+
+ // Root in the sandbox pod, in a pod named after the session.
+ out := term.result(t, "who", `$(id -u)@$(hostname)`)
+ if out != "0@rootcause-"+sess.ID {
+ t.Errorf("terminal is not root in the session's pod: %q", out)
+ }
+
+ // Nothing outside the sandbox answers.
+ for _, probe := range strings.Split(os.Getenv("RC_CLUSTER_PROBES"), ",") {
+ name, addr, ok := strings.Cut(probe, "=")
+ host, port, ok2 := strings.Cut(addr, ":")
+ if !ok || !ok2 {
+ continue
+ }
+ got := term.result(t, "probe-"+name, fmt.Sprintf(`$(timeout 3 bash -c 'exec 3<>/dev/tcp/%s/%s' 2>/dev/null && echo open || echo closed)`, host, port))
+ if got != "closed" {
+ t.Errorf("sandbox reached %s (%s): %q", name, addr, got)
+ } else {
+ t.Logf("sandbox cannot reach %s (%s)", name, addr)
+ }
+ }
+
+ // Before the fix the check fails, the way the learner sees it.
+ var res struct {
+ Status string
+ Check struct{ Result string }
+ }
+ if code := c.call("POST", "/api/check", map[string]string{"session": sess.ID}, &res); code != http.StatusOK || res.Check.Result != "fail" {
+ t.Fatalf("check before the fix: status %d, %+v", code, res)
+ }
+
+ // Fix it in the terminal, as the learner would.
+ if got := term.result(t, "fix", `$(d=$(ls /srv/shares); chgrp -R "$d" "/srv/shares/$d" && chmod -R g+w "/srv/shares/$d" && chmod g+s "/srv/shares/$d" && echo done)`); got != "done" {
+ t.Fatalf("fix in the terminal: %q", got)
+ }
+ if code := c.call("POST", "/api/check", map[string]string{"session": sess.ID}, &res); code != http.StatusOK || res.Check.Result != "pass" || res.Status != "solved" {
+ t.Fatalf("check after the fix: status %d, %+v", code, res)
+ }
+
+ // The solved session takes its terminal with it.
+ if reason, _ := readClose(t, term.conn, 10*time.Second); reason != "session_ended" {
+ t.Errorf("terminal close reason %q, want session_ended", reason)
+ }
+}
+
+type clusterClient struct {
+ t *testing.T
+ base string
+ token string
+}
+
+var clusterToken = regexp.MustCompile(`=", with value
+// expanded by the shell, and returns value. The typed line itself never
+// contains "RESULT-": printf joins "R" and "ESULT-" only in the output.
+func (tm *clusterTerm) result(t *testing.T, name, value string) string {
+ t.Helper()
+ cmd := fmt.Sprintf(`printf 'R%%s\n' "ESULT-%s=%s"`, name, value)
+ b, _ := json.Marshal(map[string]string{"input": cmd + "\r"})
+ tm.send(string(b))
+ want := regexp.MustCompile(`RESULT-` + regexp.QuoteMeta(name) + `=([^\r\n]*)\r?\n`)
+ var out strings.Builder
+ deadline := time.Now().Add(30 * time.Second)
+ for {
+ if m := want.FindStringSubmatch(out.String()); m != nil {
+ return m[1]
+ }
+ _ = tm.conn.SetReadDeadline(deadline)
+ _, data, err := tm.conn.ReadMessage()
+ if err != nil {
+ t.Fatalf("terminal: %v waiting for RESULT-%s; output %q", err, name, out.String())
+ }
+ out.Write(data)
+ }
+}
diff --git a/internal/server/server.go b/internal/server/server.go
index 0e9830b..c6d49a0 100644
--- a/internal/server/server.go
+++ b/internal/server/server.go
@@ -37,6 +37,10 @@ const BindAddress = "127.0.0.1"
// DefaultPort is the dashboard's port (spec section 8.1).
const DefaultPort = 7300
+// ContentSecurityPolicy is sent with every response.
+const ContentSecurityPolicy = "default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; " +
+ "frame-ancestors 'none'; base-uri 'none'; form-action 'none'"
+
// TokenHeader carries the per-process token on every /api request.
const TokenHeader = "X-RootCause-Token"
@@ -98,6 +102,14 @@ func Listen(port int) (net.Listener, error) {
return net.Listen("tcp", net.JoinHostPort(BindAddress, strconv.Itoa(port)))
}
+// ListenAll opens the dashboard's listener on every address of port. Only the
+// cluster deployment uses it: the pod must accept connections from its
+// Service, and k3d publishes the port on the host's 127.0.0.1 only. Host and
+// token checks apply as on 127.0.0.1.
+func ListenAll(port int) (net.Listener, error) {
+ return net.Listen("tcp", net.JoinHostPort("", strconv.Itoa(port)))
+}
+
// Serve serves on ln until ctx ends, then shuts down: it cancels requests
// still running (a dbt build among them removes its container), waits for
// them, and waits for background statistics rebuilds to stop.
@@ -152,8 +164,9 @@ func (s *Server) guard(port int, next http.Handler) http.Handler {
h.Set("X-Content-Type-Options", "nosniff")
h.Set("X-Frame-Options", "DENY")
h.Set("Referrer-Policy", "no-referrer")
- h.Set("Content-Security-Policy",
- "default-src 'self'; img-src 'self' data:; frame-ancestors 'none'; base-uri 'none'; form-action 'none'")
+ // Inline styles only: the web terminal (xterm.js) writes its layout
+ // and colors into