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