diff --git a/.gitattributes b/.gitattributes
index 25e3302a42b..e032f59a361 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -9,3 +9,5 @@ docs/theme/js/theme.js linguist-vendored=false
*_pb.ts linguist-generated
*_pb.grpc-*.ts linguist-generated
*_pb.client.ts linguist-generated
+
+*.golden -text
diff --git a/go.mod b/go.mod
index 3336b6707ce..c25b17dd018 100644
--- a/go.mod
+++ b/go.mod
@@ -206,6 +206,8 @@ require (
github.com/spiffe/aws-spiffe-workload-helper v0.0.4
github.com/spiffe/go-spiffe/v2 v2.5.0
github.com/stretchr/testify v1.10.0
+ github.com/testcontainers/testcontainers-go v0.37.0
+ github.com/testcontainers/testcontainers-go/modules/mysql v0.37.0
github.com/tidwall/pretty v1.2.1
github.com/tidwall/sjson v1.2.5
github.com/ucarion/urlpath v0.0.0-20200424170820-7ccc79b76bbb
@@ -337,6 +339,7 @@ require (
github.com/containerd/log v0.1.0 // indirect
github.com/containerd/platforms v0.2.1 // indirect
github.com/containerd/stargz-snapshotter/estargz v0.16.3 // indirect
+ github.com/cpuguy83/dockercfg v0.3.2 // indirect
github.com/crewjam/httperr v0.2.0 // indirect
github.com/cyberphone/json-canonicalization v0.0.0-20241213102144-19d51d7fe467 // indirect
github.com/cyphar/filepath-securejoin v0.4.1 // indirect
@@ -454,6 +457,7 @@ require (
github.com/lithammer/dedent v1.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
+ github.com/magiconair/properties v1.8.10 // indirect
github.com/mailgun/errors v0.4.0 // indirect
github.com/mailru/easyjson v0.9.0 // indirect
github.com/mattermost/xml-roundtrip-validator v0.1.0 // indirect
@@ -469,11 +473,17 @@ require (
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
github.com/mitchellh/reflectwalk v1.0.2 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
+ github.com/moby/go-archive v0.1.0 // indirect
+ github.com/moby/patternmatcher v0.6.0 // indirect
github.com/moby/spdystream v0.5.0 // indirect
+ github.com/moby/sys/sequential v0.6.0 // indirect
+ github.com/moby/sys/user v0.4.0 // indirect
+ github.com/moby/sys/userns v0.1.0 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 // indirect
github.com/montanaflynn/stats v0.7.0 // indirect
+ github.com/morikuni/aec v1.0.0 // indirect
github.com/mtibben/percent v0.2.1 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
diff --git a/go.sum b/go.sum
index 574ad594849..473d462d1f1 100644
--- a/go.sum
+++ b/go.sum
@@ -653,8 +653,8 @@ github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4 h1:/vQbFIOMb
github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4/go.mod h1:hN7oaIRCjzsZ2dE+yG5k+rsdt3qcwykqK6HVGcKwsw4=
github.com/99designs/keyring v1.2.2 h1:pZd3neh/EmUzWONb35LxQfvuY7kiSXAq3HQd97+XBn0=
github.com/99designs/keyring v1.2.2/go.mod h1:wes/FrByc8j7lFOAGLGSNEg8f/PaI3cgTBqhFkHUrPk=
-github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 h1:bvDV9vkmnHYOMsOr4WLk+Vo07yKIzd94sVoIqshQ4bU=
-github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8=
+github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk=
+github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8=
github.com/AdamKorcz/go-fuzz-headers-1 v0.0.0-20230919221257-8b5d3ce2d11d h1:zjqpY4C7H15HjRPEenkS4SAn3Jy2eRRjkjZbGR30TOg=
github.com/AdamKorcz/go-fuzz-headers-1 v0.0.0-20230919221257-8b5d3ce2d11d/go.mod h1:XNqJ7hv2kY++g8XEHREpi+JqZo3+0l+CH2egBVN4yqM=
github.com/AliyunContainerService/ack-ram-tool/pkg/credentials/provider v0.14.0 h1:kcnfY4vljxXliXDBrA9K9lwF8IoEZ4Up6Eg9kWTIm28=
@@ -1111,6 +1111,8 @@ github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f/go.mod h1:F5haX7
github.com/coreos/go-systemd/v22 v22.3.3-0.20220203105225-a9a7ef127534/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
+github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
+github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
@@ -1806,6 +1808,8 @@ github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2
github.com/lyft/protoc-gen-star v0.6.0/go.mod h1:TGAoBVkt8w7MPG72TrKIu85MIdXwDuzJYeZuUPFPNwA=
github.com/lyft/protoc-gen-star v0.6.1/go.mod h1:TGAoBVkt8w7MPG72TrKIu85MIdXwDuzJYeZuUPFPNwA=
github.com/lyft/protoc-gen-star/v2 v2.0.1/go.mod h1:RcCdONR2ScXaYnQC5tUzxzlpA3WVYF7/opLeUgcQs/o=
+github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
+github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mailgun/errors v0.4.0 h1:6LFBvod6VIW83CMIOT9sYNp28TCX0NejFPP4dSX++i8=
github.com/mailgun/errors v0.4.0/go.mod h1:xGBaaKdEdQT0/FhwvoXv4oBaqqmVZz9P1XEnvD/onc0=
github.com/mailgun/mailgun-go/v4 v4.23.0 h1:jPEMJzzin2s7lvehcfv/0UkyBu18GvcURPr2+xtZRbk=
@@ -1873,12 +1877,20 @@ github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zx
github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
+github.com/moby/go-archive v0.1.0 h1:Kk/5rdW/g+H8NHdJW2gsXyZ7UnzvJNOy6VKJqueWdcQ=
+github.com/moby/go-archive v0.1.0/go.mod h1:G9B+YoujNohJmrIYFBpSd54GTUB4lt9S+xVQvsJyFuo=
+github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk=
+github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
github.com/moby/spdystream v0.5.0 h1:7r0J1Si3QO/kjRitvSLVVFUjxMEb/YLj6S9FF62JBCU=
github.com/moby/spdystream v0.5.0/go.mod h1:xBAYlnt/ay+11ShkdFKNAG7LsyK/tmNBVvVOwrfMgdI=
github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw=
github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs=
github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=
github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
+github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs=
+github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs=
+github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g=
+github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -2191,6 +2203,10 @@ github.com/syndtr/goleveldb v1.0.1-0.20220721030215-126854af5e6d h1:vfofYNRScrDd
github.com/syndtr/goleveldb v1.0.1-0.20220721030215-126854af5e6d/go.mod h1:RRCYJbIwD5jmqPI9XoAFR0OcDxqUctll6zUj/+B4S48=
github.com/tchap/go-patricia/v2 v2.3.2 h1:xTHFutuitO2zqKAQ5rCROYgUb7Or/+IC3fts9/Yc7nM=
github.com/tchap/go-patricia/v2 v2.3.2/go.mod h1:VZRHKAb53DLaG+nA9EaYYiaEx6YztwDlLElMsnSHD4k=
+github.com/testcontainers/testcontainers-go v0.37.0 h1:L2Qc0vkTw2EHWQ08djon0D2uw7Z/PtHS/QzZZ5Ra/hg=
+github.com/testcontainers/testcontainers-go v0.37.0/go.mod h1:QPzbxZhQ6Bclip9igjLFj6z0hs01bU8lrl2dHQmgFGM=
+github.com/testcontainers/testcontainers-go/modules/mysql v0.37.0 h1:LqUos1oR5iuuzorFnSvxsHNdYdCHB/DfI82CuT58wbI=
+github.com/testcontainers/testcontainers-go/modules/mysql v0.37.0/go.mod h1:vHEEHx5Kf+uq5hveaVAMrTzPY8eeRZcKcl23MRw5Tkc=
github.com/thales-e-security/pool v0.0.2 h1:RAPs4q2EbWsTit6tpzuvTFlgFRJ3S8Evf5gtvVDbmPg=
github.com/thales-e-security/pool v0.0.2/go.mod h1:qtpMm2+thHtqhLzTwgDBj/OuNnMpupY8mv0Phz0gjhU=
github.com/theupdateframework/go-tuf v0.7.0 h1:CqbQFrWo1ae3/I0UCblSbczevCCbS31Qvs5LdxRWqRI=
@@ -3189,8 +3205,8 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo=
gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw=
-gotest.tools/v3 v3.4.0 h1:ZazjZUfuVeZGLAmlKKuyv3IKP5orXcwtOwDQH6YVr6o=
-gotest.tools/v3 v3.4.0/go.mod h1:CtbdzLSsqVhDgMtKsx03ird5YTGB3ar27v0u/yKBW5g=
+gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
+gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
gvisor.dev/gvisor v0.0.0-20230927004350-cbd86285d259 h1:TbRPT0HtzFP3Cno1zZo7yPzEEnfu8EjLfl6IU9VfqkQ=
gvisor.dev/gvisor v0.0.0-20230927004350-cbd86285d259/go.mod h1:AVgIgHMwK63XvmAzWG9vLQ41YnVHN0du0tEC46fI7yY=
helm.sh/helm/v3 v3.18.4 h1:pNhnHM3nAmDrxz6/UC+hfjDY4yeDATQCka2/87hkZXQ=
diff --git a/integrations/terraform-mwi/go.sum b/integrations/terraform-mwi/go.sum
index 921efaf514c..835523645b3 100644
--- a/integrations/terraform-mwi/go.sum
+++ b/integrations/terraform-mwi/go.sum
@@ -1006,6 +1006,8 @@ github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f/go.mod h1:F5haX7
github.com/coreos/go-systemd/v22 v22.3.3-0.20220203105225-a9a7ef127534/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
+github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
+github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
@@ -1059,6 +1061,8 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/dvsekhvalnov/jose2go v1.6.0 h1:Y9gnSnP4qEI0+/uQkHvFXeD2PLPJeXEL+ySMEA2EjTY=
github.com/dvsekhvalnov/jose2go v1.6.0/go.mod h1:QsHjhyTlD/lAVqn/NSbVZmSCGeDehTB/mPZadG+mhXU=
+github.com/ebitengine/purego v0.8.4 h1:CF7LEKg5FFOsASUj0+QwaXf8Ht6TlFxg09+S9wz0omw=
+github.com/ebitengine/purego v0.8.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/elimity-com/scim v0.0.0-20240320110924-172bf2aee9c8 h1:0+BTyxIYgiVAry/P5s8R4dYuLkhB9Nhso8ogFWNr4IQ=
github.com/elimity-com/scim v0.0.0-20240320110924-172bf2aee9c8/go.mod h1:JkjcmqbLW+khwt2fmBPJFBhx2zGZ8XobRZ+O0VhlwWo=
github.com/emicklei/go-restful/v3 v3.11.3 h1:yagOQz/38xJmcNeZJtrUcKjkHRltIaIFXKWeG1SkWGE=
@@ -1166,6 +1170,8 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ=
github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg=
+github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
+github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/go-openapi/analysis v0.23.0 h1:aGday7OWupfMs+LbmLZG4k0MYXIANxcuBTYUC03zFCU=
github.com/go-openapi/analysis v0.23.0/go.mod h1:9mz9ZWaSlV8TvjQHLl2mUW2PbZtemkE8yA5v22ohupo=
github.com/go-openapi/errors v0.22.1 h1:kslMRRnK7NCb/CvR1q1VWuEQCEIsBGn5GgKD9e+HYhU=
@@ -1710,9 +1716,13 @@ github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de h1:9TO3cAIGXtEhnIaL+V+BEER86oLrvS+kWobKpbJuye0=
github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de/go.mod h1:zAbeS9B/r2mtpb6U+EI2rYA5OAXxsYw6wTamcNW+zcE=
+github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
+github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
github.com/lyft/protoc-gen-star v0.6.0/go.mod h1:TGAoBVkt8w7MPG72TrKIu85MIdXwDuzJYeZuUPFPNwA=
github.com/lyft/protoc-gen-star v0.6.1/go.mod h1:TGAoBVkt8w7MPG72TrKIu85MIdXwDuzJYeZuUPFPNwA=
github.com/lyft/protoc-gen-star/v2 v2.0.1/go.mod h1:RcCdONR2ScXaYnQC5tUzxzlpA3WVYF7/opLeUgcQs/o=
+github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
+github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4=
github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
github.com/mark3labs/mcp-go v0.36.0 h1:rIZaijrRYPeSbJG8/qNDe0hWlGrCJ7FWHNMz2SQpTis=
@@ -1767,8 +1777,18 @@ github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zx
github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
+github.com/moby/go-archive v0.1.0 h1:Kk/5rdW/g+H8NHdJW2gsXyZ7UnzvJNOy6VKJqueWdcQ=
+github.com/moby/go-archive v0.1.0/go.mod h1:G9B+YoujNohJmrIYFBpSd54GTUB4lt9S+xVQvsJyFuo=
+github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk=
+github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
github.com/moby/spdystream v0.5.0 h1:7r0J1Si3QO/kjRitvSLVVFUjxMEb/YLj6S9FF62JBCU=
github.com/moby/spdystream v0.5.0/go.mod h1:xBAYlnt/ay+11ShkdFKNAG7LsyK/tmNBVvVOwrfMgdI=
+github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=
+github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
+github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs=
+github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs=
+github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g=
+github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -1781,6 +1801,8 @@ github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00/go.mod
github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc=
github.com/montanaflynn/stats v0.7.0 h1:r3y12KyNxj/Sb/iOE46ws+3mS1+MZca1wlHQFPsY/JU=
github.com/montanaflynn/stats v0.7.0/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=
+github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
+github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/mtibben/percent v0.2.1 h1:5gssi8Nqo8QU/r2pynCm+hBQHpkB/uNK7BJCFogWdzs=
github.com/mtibben/percent v0.2.1/go.mod h1:KG9uO+SZkUp+VkRHsCdYQV3XSZrrSpR3O9ibNBTZrns=
github.com/muhlemmer/gu v0.3.1 h1:7EAqmFrW7n3hETvuAdmFmn4hS8W+z3LgKtrnow+YzNM=
@@ -1878,6 +1900,8 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/posener/complete v1.2.3 h1:NP0eAhjcjImqslEwo/1hq7gpajME0fTLTezBKDqfXqo=
github.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSgv7Sy7s/s=
+github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw=
+github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/poy/onpar v1.1.2 h1:QaNrNiZx0+Nar5dLgTVp5mXkyoVFIbepjyEoGSnhbAY=
github.com/poy/onpar v1.1.2/go.mod h1:6X8FLNoxyr9kkmnlqpK6LSoiOtrO6MICtWwEuWkLjzg=
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
@@ -1953,6 +1977,9 @@ github.com/shabbyrobe/gocovmerge v0.0.0-20230507112040-c3350d9342df h1:S77Pf5fIG
github.com/shabbyrobe/gocovmerge v0.0.0-20230507112040-c3350d9342df/go.mod h1:dcuzJZ83w/SqN9k4eQqwKYMgmKWzg/KzJAURBhRL1tc=
github.com/shibumi/go-pathspec v1.3.0 h1:QUyMZhFo0Md5B8zV8x2tesohbb5kfbpTi9rBnKh5dkI=
github.com/shibumi/go-pathspec v1.3.0/go.mod h1:Xutfslp817l2I1cZvgcfeMQJG5QnU2lh5tVaaMCl3jE=
+github.com/shirou/gopsutil v3.21.11+incompatible h1:+1+c1VGhc88SSonWP6foOcLhvnKlUeu/erjjvaPEYiI=
+github.com/shirou/gopsutil/v4 v4.25.7 h1:bNb2JuqKuAu3tRlPv5piSmBZyMfecwQ+t/ILq+1JqVM=
+github.com/shirou/gopsutil/v4 v4.25.7/go.mod h1:XV/egmwJtd3ZQjBpJVY5kndsiOO4IRqy9TQnmm6VP7U=
github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4=
github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
@@ -2035,6 +2062,10 @@ github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOf
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
+github.com/testcontainers/testcontainers-go v0.37.0 h1:L2Qc0vkTw2EHWQ08djon0D2uw7Z/PtHS/QzZZ5Ra/hg=
+github.com/testcontainers/testcontainers-go v0.37.0/go.mod h1:QPzbxZhQ6Bclip9igjLFj6z0hs01bU8lrl2dHQmgFGM=
+github.com/testcontainers/testcontainers-go/modules/mysql v0.37.0 h1:LqUos1oR5iuuzorFnSvxsHNdYdCHB/DfI82CuT58wbI=
+github.com/testcontainers/testcontainers-go/modules/mysql v0.37.0/go.mod h1:vHEEHx5Kf+uq5hveaVAMrTzPY8eeRZcKcl23MRw5Tkc=
github.com/thales-e-security/pool v0.0.2 h1:RAPs4q2EbWsTit6tpzuvTFlgFRJ3S8Evf5gtvVDbmPg=
github.com/thales-e-security/pool v0.0.2/go.mod h1:qtpMm2+thHtqhLzTwgDBj/OuNnMpupY8mv0Phz0gjhU=
github.com/theupdateframework/go-tuf v0.7.0 h1:CqbQFrWo1ae3/I0UCblSbczevCCbS31Qvs5LdxRWqRI=
@@ -2062,6 +2093,10 @@ github.com/tink-crypto/tink-go/v2 v2.4.0 h1:8VPZeZI4EeZ8P/vB6SIkhlStrJfivTJn+cQ4
github.com/tink-crypto/tink-go/v2 v2.4.0/go.mod h1:l//evrF2Y3MjdbpNDNGnKgCpo5zSmvUvnQ4MU+yE2sw=
github.com/titanous/rocacheck v0.0.0-20171023193734-afe73141d399 h1:e/5i7d4oYZ+C1wj2THlRK+oAhjeS/TRQwMfkIuet3w0=
github.com/titanous/rocacheck v0.0.0-20171023193734-afe73141d399/go.mod h1:LdwHTNJT99C5fTAzDz0ud328OgXz+gierycbcIx2fRs=
+github.com/tklauser/go-sysconf v0.3.15 h1:VE89k0criAymJ/Os65CSn1IXaol+1wrsFHEB8Ol49K4=
+github.com/tklauser/go-sysconf v0.3.15/go.mod h1:Dmjwr6tYFIseJw7a3dRLJfsHAMXZ3nEnL/aZY+0IuI4=
+github.com/tklauser/numcpus v0.10.0 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfjso=
+github.com/tklauser/numcpus v0.10.0/go.mod h1:BiTKazU708GQTYF4mB+cmlpT2Is1gLk7XVuEeem8LsQ=
github.com/transparency-dev/formats v0.0.0-20250421220931-bb8ad4d07c26 h1:YTbkeFbzcer+42bIgo6Za2194nKwhZPgaZKsP76QffE=
github.com/transparency-dev/formats v0.0.0-20250421220931-bb8ad4d07c26/go.mod h1:ODywn0gGarHMMdSkWT56ULoK8Hk71luOyRseKek9COw=
github.com/transparency-dev/merkle v0.0.2 h1:Q9nBoQcZcgPamMkGn7ghV8XiTZ/kRxn1yCG81+twTK4=
@@ -2120,6 +2155,8 @@ github.com/yuin/goldmark-meta v1.1.0 h1:pWw+JLHGZe8Rk0EGsMVssiNb/AaPMHfSRszZeUei
github.com/yuin/goldmark-meta v1.1.0/go.mod h1:U4spWENafuA7Zyg+Lj5RqK/MF+ovMYtBvXi1lBb2VP0=
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
+github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
+github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
github.com/zalando/go-keyring v0.2.3 h1:v9CUu9phlABObO4LPWycf+zwMG7nlbb3t/B5wa97yms=
github.com/zalando/go-keyring v0.2.3/go.mod h1:HL4k+OXQfJUWaMnqyuSOc0drfGPX2b51Du6K+MRgZMk=
github.com/zclconf/go-cty v1.16.3 h1:osr++gw2T61A8KVYHoQiFbFd1Lh3JOCXc/jFLJXKTxk=
diff --git a/integrations/terraform/go.sum b/integrations/terraform/go.sum
index 8cb75eeb86f..5e4680202ff 100644
--- a/integrations/terraform/go.sum
+++ b/integrations/terraform/go.sum
@@ -1029,6 +1029,8 @@ github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f/go.mod h1:F5haX7
github.com/coreos/go-systemd/v22 v22.3.3-0.20220203105225-a9a7ef127534/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
+github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
+github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
@@ -1082,6 +1084,8 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/dvsekhvalnov/jose2go v1.6.0 h1:Y9gnSnP4qEI0+/uQkHvFXeD2PLPJeXEL+ySMEA2EjTY=
github.com/dvsekhvalnov/jose2go v1.6.0/go.mod h1:QsHjhyTlD/lAVqn/NSbVZmSCGeDehTB/mPZadG+mhXU=
+github.com/ebitengine/purego v0.8.4 h1:CF7LEKg5FFOsASUj0+QwaXf8Ht6TlFxg09+S9wz0omw=
+github.com/ebitengine/purego v0.8.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/elimity-com/scim v0.0.0-20240320110924-172bf2aee9c8 h1:0+BTyxIYgiVAry/P5s8R4dYuLkhB9Nhso8ogFWNr4IQ=
github.com/elimity-com/scim v0.0.0-20240320110924-172bf2aee9c8/go.mod h1:JkjcmqbLW+khwt2fmBPJFBhx2zGZ8XobRZ+O0VhlwWo=
github.com/emicklei/go-restful/v3 v3.11.3 h1:yagOQz/38xJmcNeZJtrUcKjkHRltIaIFXKWeG1SkWGE=
@@ -1198,6 +1202,8 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ=
github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg=
+github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
+github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/go-openapi/analysis v0.23.0 h1:aGday7OWupfMs+LbmLZG4k0MYXIANxcuBTYUC03zFCU=
github.com/go-openapi/analysis v0.23.0/go.mod h1:9mz9ZWaSlV8TvjQHLl2mUW2PbZtemkE8yA5v22ohupo=
github.com/go-openapi/errors v0.22.1 h1:kslMRRnK7NCb/CvR1q1VWuEQCEIsBGn5GgKD9e+HYhU=
@@ -1766,9 +1772,13 @@ github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de h1:9TO3cAIGXtEhnIaL+V+BEER86oLrvS+kWobKpbJuye0=
github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de/go.mod h1:zAbeS9B/r2mtpb6U+EI2rYA5OAXxsYw6wTamcNW+zcE=
+github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
+github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
github.com/lyft/protoc-gen-star v0.6.0/go.mod h1:TGAoBVkt8w7MPG72TrKIu85MIdXwDuzJYeZuUPFPNwA=
github.com/lyft/protoc-gen-star v0.6.1/go.mod h1:TGAoBVkt8w7MPG72TrKIu85MIdXwDuzJYeZuUPFPNwA=
github.com/lyft/protoc-gen-star/v2 v2.0.1/go.mod h1:RcCdONR2ScXaYnQC5tUzxzlpA3WVYF7/opLeUgcQs/o=
+github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
+github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4=
github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
github.com/mark3labs/mcp-go v0.36.0 h1:rIZaijrRYPeSbJG8/qNDe0hWlGrCJ7FWHNMz2SQpTis=
@@ -1840,8 +1850,18 @@ github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zx
github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
+github.com/moby/go-archive v0.1.0 h1:Kk/5rdW/g+H8NHdJW2gsXyZ7UnzvJNOy6VKJqueWdcQ=
+github.com/moby/go-archive v0.1.0/go.mod h1:G9B+YoujNohJmrIYFBpSd54GTUB4lt9S+xVQvsJyFuo=
+github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk=
+github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
github.com/moby/spdystream v0.5.0 h1:7r0J1Si3QO/kjRitvSLVVFUjxMEb/YLj6S9FF62JBCU=
github.com/moby/spdystream v0.5.0/go.mod h1:xBAYlnt/ay+11ShkdFKNAG7LsyK/tmNBVvVOwrfMgdI=
+github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=
+github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
+github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs=
+github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs=
+github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g=
+github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -1854,6 +1874,8 @@ github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00/go.mod
github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc=
github.com/montanaflynn/stats v0.7.0 h1:r3y12KyNxj/Sb/iOE46ws+3mS1+MZca1wlHQFPsY/JU=
github.com/montanaflynn/stats v0.7.0/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=
+github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
+github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/mtibben/percent v0.2.1 h1:5gssi8Nqo8QU/r2pynCm+hBQHpkB/uNK7BJCFogWdzs=
github.com/mtibben/percent v0.2.1/go.mod h1:KG9uO+SZkUp+VkRHsCdYQV3XSZrrSpR3O9ibNBTZrns=
github.com/muhlemmer/gu v0.3.1 h1:7EAqmFrW7n3hETvuAdmFmn4hS8W+z3LgKtrnow+YzNM=
@@ -1954,6 +1976,8 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH
github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=
github.com/posener/complete v1.2.3 h1:NP0eAhjcjImqslEwo/1hq7gpajME0fTLTezBKDqfXqo=
github.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSgv7Sy7s/s=
+github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw=
+github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/poy/onpar v1.1.2 h1:QaNrNiZx0+Nar5dLgTVp5mXkyoVFIbepjyEoGSnhbAY=
github.com/poy/onpar v1.1.2/go.mod h1:6X8FLNoxyr9kkmnlqpK6LSoiOtrO6MICtWwEuWkLjzg=
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
@@ -2033,6 +2057,9 @@ github.com/shabbyrobe/gocovmerge v0.0.0-20230507112040-c3350d9342df h1:S77Pf5fIG
github.com/shabbyrobe/gocovmerge v0.0.0-20230507112040-c3350d9342df/go.mod h1:dcuzJZ83w/SqN9k4eQqwKYMgmKWzg/KzJAURBhRL1tc=
github.com/shibumi/go-pathspec v1.3.0 h1:QUyMZhFo0Md5B8zV8x2tesohbb5kfbpTi9rBnKh5dkI=
github.com/shibumi/go-pathspec v1.3.0/go.mod h1:Xutfslp817l2I1cZvgcfeMQJG5QnU2lh5tVaaMCl3jE=
+github.com/shirou/gopsutil v3.21.11+incompatible h1:+1+c1VGhc88SSonWP6foOcLhvnKlUeu/erjjvaPEYiI=
+github.com/shirou/gopsutil/v4 v4.25.7 h1:bNb2JuqKuAu3tRlPv5piSmBZyMfecwQ+t/ILq+1JqVM=
+github.com/shirou/gopsutil/v4 v4.25.7/go.mod h1:XV/egmwJtd3ZQjBpJVY5kndsiOO4IRqy9TQnmm6VP7U=
github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4=
github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
@@ -2116,6 +2143,10 @@ github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOf
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
+github.com/testcontainers/testcontainers-go v0.37.0 h1:L2Qc0vkTw2EHWQ08djon0D2uw7Z/PtHS/QzZZ5Ra/hg=
+github.com/testcontainers/testcontainers-go v0.37.0/go.mod h1:QPzbxZhQ6Bclip9igjLFj6z0hs01bU8lrl2dHQmgFGM=
+github.com/testcontainers/testcontainers-go/modules/mysql v0.37.0 h1:LqUos1oR5iuuzorFnSvxsHNdYdCHB/DfI82CuT58wbI=
+github.com/testcontainers/testcontainers-go/modules/mysql v0.37.0/go.mod h1:vHEEHx5Kf+uq5hveaVAMrTzPY8eeRZcKcl23MRw5Tkc=
github.com/thales-e-security/pool v0.0.2 h1:RAPs4q2EbWsTit6tpzuvTFlgFRJ3S8Evf5gtvVDbmPg=
github.com/thales-e-security/pool v0.0.2/go.mod h1:qtpMm2+thHtqhLzTwgDBj/OuNnMpupY8mv0Phz0gjhU=
github.com/theupdateframework/go-tuf v0.7.0 h1:CqbQFrWo1ae3/I0UCblSbczevCCbS31Qvs5LdxRWqRI=
@@ -2143,6 +2174,10 @@ github.com/tink-crypto/tink-go/v2 v2.4.0 h1:8VPZeZI4EeZ8P/vB6SIkhlStrJfivTJn+cQ4
github.com/tink-crypto/tink-go/v2 v2.4.0/go.mod h1:l//evrF2Y3MjdbpNDNGnKgCpo5zSmvUvnQ4MU+yE2sw=
github.com/titanous/rocacheck v0.0.0-20171023193734-afe73141d399 h1:e/5i7d4oYZ+C1wj2THlRK+oAhjeS/TRQwMfkIuet3w0=
github.com/titanous/rocacheck v0.0.0-20171023193734-afe73141d399/go.mod h1:LdwHTNJT99C5fTAzDz0ud328OgXz+gierycbcIx2fRs=
+github.com/tklauser/go-sysconf v0.3.15 h1:VE89k0criAymJ/Os65CSn1IXaol+1wrsFHEB8Ol49K4=
+github.com/tklauser/go-sysconf v0.3.15/go.mod h1:Dmjwr6tYFIseJw7a3dRLJfsHAMXZ3nEnL/aZY+0IuI4=
+github.com/tklauser/numcpus v0.10.0 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfjso=
+github.com/tklauser/numcpus v0.10.0/go.mod h1:BiTKazU708GQTYF4mB+cmlpT2Is1gLk7XVuEeem8LsQ=
github.com/transparency-dev/formats v0.0.0-20250421220931-bb8ad4d07c26 h1:YTbkeFbzcer+42bIgo6Za2194nKwhZPgaZKsP76QffE=
github.com/transparency-dev/formats v0.0.0-20250421220931-bb8ad4d07c26/go.mod h1:ODywn0gGarHMMdSkWT56ULoK8Hk71luOyRseKek9COw=
github.com/transparency-dev/merkle v0.0.2 h1:Q9nBoQcZcgPamMkGn7ghV8XiTZ/kRxn1yCG81+twTK4=
@@ -2205,6 +2240,8 @@ github.com/yuin/goldmark-meta v1.1.0 h1:pWw+JLHGZe8Rk0EGsMVssiNb/AaPMHfSRszZeUei
github.com/yuin/goldmark-meta v1.1.0/go.mod h1:U4spWENafuA7Zyg+Lj5RqK/MF+ovMYtBvXi1lBb2VP0=
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
+github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
+github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
github.com/zalando/go-keyring v0.2.3 h1:v9CUu9phlABObO4LPWycf+zwMG7nlbb3t/B5wa97yms=
github.com/zalando/go-keyring v0.2.3/go.mod h1:HL4k+OXQfJUWaMnqyuSOc0drfGPX2b51Du6K+MRgZMk=
github.com/zclconf/go-cty v1.1.0/go.mod h1:xnAOWiHeOqg2nWS62VtQ7pbOu17FtxJNW8RLEih+O3s=
diff --git a/lib/client/db/mysql/repl/commands.go b/lib/client/db/mysql/repl/commands.go
new file mode 100644
index 00000000000..7e362d36ca7
--- /dev/null
+++ b/lib/client/db/mysql/repl/commands.go
@@ -0,0 +1,280 @@
+// Teleport
+// Copyright (C) 2025 Gravitational, Inc.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package repl
+
+import (
+ "cmp"
+ "fmt"
+ "maps"
+ "slices"
+ "strings"
+ "unicode/utf8"
+
+ "github.com/go-mysql-org/go-mysql/mysql"
+ "github.com/gravitational/trace"
+
+ "github.com/gravitational/teleport"
+ "github.com/gravitational/teleport/lib/asciitable"
+)
+
+type commandManager struct {
+ byName map[string]*command
+ byShortcut map[rune]*command
+ // shortcutPrefix is the prefix for command shortcuts, e.g. \h for help.
+ shortcutPrefix string
+}
+
+// command represents a command that can be executed in the REPL.
+type command struct {
+ // name is the command's full name.
+ name string
+ // shortcut is the command's escaped single char name: \x.
+ shortcut rune
+ // description provides a user-friendly explanation of what the command
+ // does.
+ description string
+ // execFunc is the function to execute the command. The commands can either
+ // return a reply (that will be sent back to the client) as a string. It can
+ // terminate the REPL by returning bool on the second argument.
+ execFunc func(r *REPL, args string) (reply string, exit bool)
+}
+
+func (c *command) checkAndSetDefaults() error {
+ if len(c.name) == 0 {
+ return trace.BadParameter("missing command name")
+ }
+ if c.shortcut == 0 {
+ return trace.BadParameter("missing command short name")
+ }
+ if len(c.description) == 0 {
+ return trace.BadParameter("missing command description")
+ }
+ if c.execFunc == nil {
+ return trace.BadParameter("missing command exec func")
+ }
+ c.name = normalizeCommandName(c.name)
+ return nil
+}
+
+func normalizeCommandName(name string) string {
+ return strings.ToLower(name)
+}
+
+func newCommands() (*commandManager, error) {
+ c := &commandManager{
+ byName: make(map[string]*command),
+ byShortcut: make(map[rune]*command),
+ shortcutPrefix: `\`,
+ }
+ for _, cmd := range []*command{
+ {
+ name: "delimiter",
+ shortcut: 'd',
+ description: "Set statement delimiter. With no argument the delimiter resets to the default.",
+ execFunc: func(r *REPL, args string) (reply string, exit bool) {
+ delim := getArg(args)
+ if err := r.parser.lex.setDelimiter(delim); err != nil {
+ return err.Error(), false
+ }
+ return "", false
+ },
+ },
+ {
+ name: "help",
+ shortcut: 'h',
+ description: "Show the list of supported commands.",
+ execFunc: func(_ *REPL, _ string) (string, bool) {
+ return c.help(), false
+ },
+ },
+ {
+ name: "quit",
+ shortcut: 'q',
+ description: "Terminates the session.",
+ execFunc: func(_ *REPL, _ string) (string, bool) { return "", true },
+ },
+ {
+ name: "status",
+ shortcut: 's',
+ description: "Get status information.",
+ execFunc: func(r *REPL, _ string) (string, bool) {
+ table := asciitable.MakeHeadlessTable(2)
+ table.AddRow([]string{"Teleport database:", r.route.ServiceName})
+ table.AddRow([]string{"Current database:", formatDatabaseName(r.route.Database)})
+ table.AddRow([]string{"Current user:", r.route.Username})
+ table.AddRow([]string{"Server version:", r.myConn.GetServerVersion()})
+ table.AddRow([]string{"Using delimiter:", r.parser.lex.delimiter()})
+ return table.String(), false
+ },
+ },
+ {
+ name: "teleport",
+ shortcut: 't',
+ description: "Show Teleport interactive shell information, such as execution limitations.",
+ execFunc: func(_ *REPL, _ string) (string, bool) {
+ var sb strings.Builder
+ for _, l := range descriptiveLimitations {
+ sb.WriteString("- " + strings.ReplaceAll(l, "\n", "\n ") + lineBreak)
+ }
+ return fmt.Sprintf(
+ "Teleport MySQL interactive shell (v%s)\n\nLimitations: \n%s",
+ teleport.Version,
+ sb.String(),
+ ), false
+ },
+ },
+ {
+ name: "use",
+ shortcut: 'u',
+ description: "Use another database.",
+ execFunc: func(r *REPL, args string) (string, bool) {
+ args = strings.TrimSpace(args)
+ if len(args) == 0 {
+ return "USE must be followed by a database name", false
+ }
+ dbName, err := getDatabaseName(r.myConn, args)
+ if err != nil {
+ return err.Error(), false
+ }
+ if err := r.myConn.UseDB(dbName); err != nil {
+ return err.Error(), false
+ }
+ r.route.Database = dbName
+ return fmt.Sprintf(`Default database changed to %q`, dbName), false
+ },
+ },
+ } {
+ if err := cmd.checkAndSetDefaults(); err != nil {
+ return nil, trace.Wrap(err)
+ }
+ if err := c.add(cmd); err != nil {
+ return nil, trace.Wrap(err)
+ }
+ }
+ return c, nil
+}
+
+func (c *commandManager) add(cmd *command) error {
+ if _, ok := c.byName[cmd.name]; ok {
+ return trace.BadParameter("overlapping command name: %s", cmd.name)
+ }
+ c.byName[cmd.name] = cmd
+ if _, ok := c.byShortcut[cmd.shortcut]; ok {
+ return trace.BadParameter("overlapping command shortcut: %c", cmd.shortcut)
+ }
+ c.byShortcut[cmd.shortcut] = cmd
+ return nil
+}
+
+// findCommand tries to find a command at the start of a line. If a command name
+// or short name matches, then it returns the command and the rest of the line.
+// If no command is found and the line does not start with the short name prefix
+// then the command and error returned will both be nil.
+func (c *commandManager) findCommand(line string) (*command, string, error) {
+ line = strings.TrimSpace(line)
+ if before, after, found := strings.Cut(line, c.shortcutPrefix); found && len(before) == 0 {
+ shortcut, width := utf8.DecodeRuneInString(after)
+ if width > 0 && shortcut != utf8.RuneError {
+ if cmd, ok := c.byShortcut[shortcut]; ok {
+ return cmd, after[1:], nil
+ }
+ }
+ return nil, "", trace.Errorf("Unknown command.\n%v", c.help())
+ }
+
+ name, after, _ := strings.Cut(line, " ")
+ if cmd, ok := c.byName[normalizeCommandName(name)]; ok {
+ return cmd, after, nil
+ }
+ return nil, "", nil
+}
+
+func (c *commandManager) help() string {
+ var res strings.Builder
+ cmds := slices.Collect(maps.Values(c.byName))
+ slices.SortStableFunc(cmds, func(a, b *command) int {
+ return strings.Compare(a.name, b.name)
+ })
+ table := asciitable.MakeHeadlessTable(3)
+ for _, cmd := range cmds {
+ short := fmt.Sprintf("%s%c", c.shortcutPrefix, cmd.shortcut)
+ row := []string{cmd.name, short, cmd.description}
+ table.AddRow(row)
+ }
+ table.WriteTo(&res)
+ res.WriteString(lineBreak)
+ return res.String()
+}
+
+// getArg trims spaces from the line and returns the first space separated arg.
+func getArg(line string) string {
+ line = strings.TrimSpace(line)
+ before, _, _ := strings.Cut(line, " ")
+ return before
+}
+
+// getDatabaseName extracts a database name from args.
+// Since USE is both a command (COM_INIT_DB) and a statement, as a client
+// we have to parse the database argument for COM_INIT_DB.
+// To handle quotation, we send the args to the server as a statement and then
+// ask the server what the current database is.
+func getDatabaseName(conn mysql.Executer, args string) (string, error) {
+ result, err := conn.Execute("USE " + args)
+ if err != nil {
+ return "", trace.Wrap(err)
+ }
+ result.Close()
+ return getCurrentDatabase(conn)
+}
+
+func getCurrentDatabase(conn mysql.Executer) (string, error) {
+ result, err := conn.Execute("SELECT DATABASE() LIMIT 1")
+ if err != nil {
+ return "", trace.Wrap(err)
+ }
+ defer result.Close()
+ if result.Resultset == nil || len(result.Resultset.Values) == 0 || len(result.Resultset.Values[0]) == 0 {
+ return "", trace.NotFound("missing result")
+ }
+ val := result.Resultset.Values[0][0]
+ if val.Type == mysql.FieldValueTypeString {
+ return string(val.AsString()), nil
+ }
+ return "", nil
+}
+
+func formatDatabaseName(name string) string {
+ return cmp.Or(name, "(none)")
+}
+
+// descriptiveLimitations defines a user-friendly text containing the REPL
+// limitations.
+var descriptiveLimitations = []string{
+ `Query cancellation is not supported. Once a query is sent, its execution
+can only be canceled by killing it from another session.
+You can identify the query process ID with "SHOW PROCESSLIST" and then kill the
+query with "KILL QUERY ".
+`,
+ // This limitation is due to our terminal emulator not fully supporting this
+ // shortcut's custom handler. Instead, it will close the terminal, leading
+ // to terminating the session. To avoid having users accidentally
+ // terminating their sessions, we're turning this off until we have a better
+ // solution and propose the behavior for it.
+ //
+ // This shortcut filtered out by the WebUI key handler.
+ "Pressing CTRL-C will have no effect in this shell.",
+}
diff --git a/lib/client/db/mysql/repl/commands_test.go b/lib/client/db/mysql/repl/commands_test.go
new file mode 100644
index 00000000000..97252d0aa91
--- /dev/null
+++ b/lib/client/db/mysql/repl/commands_test.go
@@ -0,0 +1,181 @@
+// Teleport
+// Copyright (C) 2025 Gravitational, Inc.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package repl
+
+import (
+ "maps"
+ "slices"
+ "testing"
+
+ "github.com/go-mysql-org/go-mysql/mysql"
+ "github.com/stretchr/testify/require"
+
+ "github.com/gravitational/teleport"
+ clientproto "github.com/gravitational/teleport/api/client/proto"
+)
+
+func TestCommands(t *testing.T) {
+ t.Parallel()
+ parser, err := newParser()
+ require.NoError(t, err)
+ commands := parser.commands
+ require.NotEmpty(t, commands.byName)
+ require.ElementsMatch(t,
+ slices.Collect(maps.Values(commands.byName)),
+ slices.Collect(maps.Values(commands.byShortcut)),
+ )
+
+ for name, cmd := range commands.byName {
+ require.NotNil(t, cmd)
+ require.NotEmpty(t, cmd.name)
+ require.NotZero(t, cmd.shortcut)
+ require.NotEmpty(t, cmd.description)
+ require.NotNil(t, cmd.execFunc)
+
+ require.Equal(t, cmd.name, name, "expected command to be registered by name")
+ byShortcut, ok := commands.byShortcut[cmd.shortcut]
+ require.True(t, ok)
+ require.Equal(t, cmd, byShortcut, "expected commands from name and shortcut indices to match")
+ }
+
+ repl := &REPL{
+ parser: parser,
+ myConn: &fakeMySQLConn{
+ exec: func(command string, args ...any) (*mysql.Result, error) {
+ resultSet, err := mysql.BuildSimpleTextResultset([]string{"current_database"}, [][]any{{"example"}})
+ if err != nil {
+ return nil, err
+ }
+ resultSet.Values = make([][]mysql.FieldValue, 1)
+ resultSet.Values[0], err = resultSet.RowDatas[0].Parse(resultSet.Fields, false, nil)
+ return &mysql.Result{
+ Resultset: resultSet,
+ }, err
+ },
+ },
+ route: clientproto.RouteToDatabase{
+ ServiceName: "test-service",
+ Username: "test-user",
+ Database: "test-database",
+ },
+ }
+ tests := []struct {
+ desc string
+ cmdName string
+ args string
+ expectExit bool
+ assertCommandReply require.ValueAssertionFunc
+ }{
+ {
+ desc: "delimiter command has an empty reply",
+ cmdName: "delimiter",
+ assertCommandReply: func(t require.TestingT, val any, _ ...any) {
+ require.Empty(t, val)
+ },
+ },
+ {
+ desc: "delimiter fails regex",
+ cmdName: "delimiter",
+ args: "abc",
+ assertCommandReply: func(t require.TestingT, val any, _ ...any) {
+ require.Equal(t, `DELIMITER "abc" does not match regex used for validation "^(;|[/]{2}|[$]{2})$"`, val)
+ },
+ },
+ {
+ desc: "help",
+ cmdName: "help",
+ assertCommandReply: func(t require.TestingT, val any, _ ...any) {
+ for name := range commands.byName {
+ require.Contains(t, val, name, `expected help command to include information about the %q command`, name)
+ }
+ },
+ },
+ {
+ desc: "quit",
+ cmdName: "quit",
+ expectExit: true,
+ },
+ {
+ desc: "status",
+ cmdName: "status",
+ assertCommandReply: func(t require.TestingT, val any, _ ...any) {
+ require.Contains(t, val, "test-service", "expected session command to contain service name")
+ require.Contains(t, val, "test-user", "expected session command to contain username")
+ require.Contains(t, val, "test-database", "expected session command to contain database name")
+ },
+ },
+ {
+ desc: "teleport",
+ cmdName: "teleport",
+ assertCommandReply: func(t require.TestingT, val any, _ ...any) {
+ require.Contains(t, val, teleport.Version, "expected teleport command to include current Teleport version")
+ },
+ },
+ {
+ desc: "use requires an arg",
+ cmdName: "use",
+ assertCommandReply: func(t require.TestingT, val any, _ ...any) {
+ require.Equal(t, "USE must be followed by a database name", val)
+ },
+ },
+ {
+ desc: "use with an unquoted argument",
+ cmdName: "use",
+ args: "example",
+ assertCommandReply: func(t require.TestingT, val any, _ ...any) {
+ require.Equal(t, `Default database changed to "example"`, val)
+ },
+ },
+ {
+ desc: "use with a quoted argument",
+ cmdName: "use",
+ args: `"example"`,
+ assertCommandReply: func(t require.TestingT, val any, _ ...any) {
+ require.Equal(t, `Default database changed to "example"`, val)
+ },
+ },
+ }
+
+ for _, test := range tests {
+ t.Run(test.desc, func(t *testing.T) {
+ cmd, ok := commands.byName[test.cmdName]
+ require.True(t, ok, "expected command to be registered")
+ reply, exit := cmd.execFunc(repl, test.args)
+ if test.expectExit {
+ require.True(t, exit, "expected command to exit the REPL")
+ return
+ }
+ test.assertCommandReply(t, reply)
+ })
+ }
+}
+
+type fakeMySQLConn struct {
+ exec func(command string, args ...any) (*mysql.Result, error)
+}
+
+func (c *fakeMySQLConn) Execute(command string, args ...any) (*mysql.Result, error) {
+ return c.exec(command, args...)
+}
+
+func (c *fakeMySQLConn) UseDB(dbName string) error {
+ return nil
+}
+
+func (c *fakeMySQLConn) GetServerVersion() string {
+ return "1.2.3 Fake MySQL Server"
+}
diff --git a/lib/client/db/mysql/repl/fixtures/all-data-types.sql b/lib/client/db/mysql/repl/fixtures/all-data-types.sql
new file mode 100644
index 00000000000..f95614aa9c0
--- /dev/null
+++ b/lib/client/db/mysql/repl/fixtures/all-data-types.sql
@@ -0,0 +1,113 @@
+-- temporarily disable note warnings for test consistency
+set session sql_notes = 0;
+CREATE DATABASE IF NOT EXISTS all_data_types;
+set session sql_notes = 1;
+USE all_data_types;
+
+-- without ANSI_QUOTES double quotes are literals
+SET SESSION sql_mode='';
+SELECT "thing";
+-- with ANSI_QUOTES double quotes are identifiers, which is what we want when
+-- selecting from the table below
+SET SESSION sql_mode='ANSI_QUOTES';
+SELECT "thing";
+
+CREATE TABLE all_types_demo (
+ "c tinyint" TINYINT,
+ c_smallint SMALLINT,
+ c_mediumint MEDIUMINT,
+ c_int INT,
+ c_bigint BIGINT,
+ c_decimal DECIMAL(10,2),
+ c_float FLOAT,
+ c_double DOUBLE,
+ c_bit BIT(8),
+ c_bool BOOL,
+ c_date DATE,
+ c_time TIME,
+ c_datetime DATETIME,
+ c_timestamp TIMESTAMP NULL DEFAULT NULL,
+ c_year YEAR,
+ c_char CHAR(5),
+ c_varchar VARCHAR(10),
+ c_binary BINARY(3),
+ c_varbinary VARBINARY(4),
+ c_tinyblob TINYBLOB,
+ c_blob BLOB,
+ c_mediumblob MEDIUMBLOB,
+ c_longblob LONGBLOB,
+ c_tinytext TINYTEXT,
+ c_text TEXT,
+ c_mediumtext MEDIUMTEXT,
+ c_longtext LONGTEXT,
+ c_enum ENUM('foo','bar','baz'),
+ c_set SET('a','b','c'),
+ c_geometry GEOMETRY,
+ c_point POINT,
+ c_linestring LINESTRING,
+ c_polygon POLYGON,
+ c_multipoint MULTIPOINT,
+ c_multilinestring MULTILINESTRING,
+ c_multipolygon MULTIPOLYGON,
+ c_geometrycoll GEOMETRYCOLLECTION,
+ c_json JSON
+);
+
+INSERT INTO all_types_demo VALUES (
+ 127,
+ 32767,
+ 8388607,
+ 2147483647,
+ 9223372036854775807,
+ 12345.67,
+ 3.14,
+ 2.7182818284,
+ b'10101010',
+ 1,
+ '2024-07-09',
+ '12:34:56',
+ '2024-07-09 12:34:56',
+ '2024-07-09 12:34:56',
+ 2025,
+ 'abcde',
+ 'abcdefghij',
+ 'abc',
+ 'defg',
+ 'a',
+ 'blob',
+ 'medblob',
+ 'longblob',
+ 'tiny',
+ 'text',
+ 'mediumtext',
+ 'longtext',
+ 'foo',
+ 'a,b',
+ ST_GeomFromText('POINT(1 1)'),
+ ST_GeomFromText('POINT(2 2)'),
+ ST_GeomFromText('LINESTRING(0 0,1 1,2 2)'),
+ ST_GeomFromText('POLYGON((0 0,1 0,1 1,0 1,0 0))'),
+ ST_GeomFromText('MULTIPOINT((0 0),(1 1))'),
+ ST_GeomFromText('MULTILINESTRING((0 0,1 1),(2 2,3 3))'),
+ ST_GeomFromText('MULTIPOLYGON(((0 0,1 0,1 1,0 1,0 0)))'),
+ ST_GeomFromText('GEOMETRYCOLLECTION(POINT(1 2),LINESTRING(0 0,1 1))'),
+ JSON_OBJECT('x', 1, 'y', 2)
+);
+
+SELECT "c tinyint", `c_smallint`, "c_mediumint" FROM `all_types_demo`;
+SELECT c_int, c_bigint, c_decimal FROM all_types_demo;
+SELECT c_float, c_double, c_bit FROM all_types_demo;
+SELECT c_bool, c_date, c_time FROM all_types_demo;
+SELECT c_datetime, c_timestamp, c_year FROM all_types_demo;
+SELECT c_char, c_varchar, c_binary FROM all_types_demo;
+SELECT c_varbinary, c_tinyblob, c_blob FROM all_types_demo;
+SELECT "c_mediumblob",
+ `c_longblob`,
+ c_tinytext
+FROM all_types_demo;
+SELECT c_text, c_mediumtext, c_longtext FROM all_types_demo;
+SELECT c_enum, c_set, ST_AsText(c_geometry) AS c_geometry FROM all_types_demo;
+SELECT ST_AsText(c_point) AS c_point, ST_AsText(c_linestring) AS c_linestring, ST_AsText(c_polygon) AS c_polygon FROM all_types_demo;
+SELECT ST_AsText(c_multipoint) AS c_multipoint, ST_AsText(c_multilinestring) AS c_multilinestring, ST_AsText(c_multipolygon) AS c_multipolygon FROM all_types_demo;
+SELECT ST_AsText(c_geometrycoll) AS c_geometrycoll, c_json FROM all_types_demo;
+DROP DATABASE all_data_types;
diff --git a/lib/client/db/mysql/repl/fixtures/commands.sql b/lib/client/db/mysql/repl/fixtures/commands.sql
new file mode 100644
index 00000000000..1bb2e6ff8c3
--- /dev/null
+++ b/lib/client/db/mysql/repl/fixtures/commands.sql
@@ -0,0 +1,85 @@
+-- temporarily disable note warnings for test consistency
+set session sql_notes = 0;
+create database if not exists test_commands;
+create database if not exists foo;
+create database if not exists bar;
+set session sql_notes = 1;
+use test_commands;
+
+-- leading spaces/comments before commands
+/* just a comment */
+use foo;
+use foo
+ /*
+llama
+ */
+use foo;
+ /*
+llama
+ */
+use foo
+-- llama
+use foo;
+-- llama
+use foo
+ -- llama
+ use foo;
+ -- llama
+ use foo
+/* llama */use foo;
+/* llama */use foo
+/* llama */ use foo;
+/* llama */ use foo
+/* llama */
+ use foo;
+/* llama */
+ use foo
+/* llama
+*/use foo;
+/* llama
+*/use foo
+/* llama
+*/ use foo;
+/* llama
+*/ use foo
+/* llama
+-- llama
+*/use foo;
+/* llama
+-- llama
+*/use foo
+/* llama
+-- llama
+*/ use foo;
+/* llama
+-- llama
+*/ use foo
+/* llama
+-- */use foo;
+/* llama
+-- */use foo
+/* llama
+-- */ use foo;
+/* llama
+-- */ use foo
+/* llama */
+
+use foo;use bar; use foo
+-- this isn't a command, but that should just print an error message and help info.
+\xyz select 1
+\xyz;select 1;
+delimiter $$
+help$$
+\d //
+\h
+help
+status
+\s
+teleport
+\t
+use foo
+\u mysql//
+-- reset the delimiter to default ;
+\d
+teleport;\s;\h
+use `bar`;drop database foo;drop database bar;drop database test_commands;quit
diff --git a/lib/client/db/mysql/repl/fixtures/ddl.sql b/lib/client/db/mysql/repl/fixtures/ddl.sql
new file mode 100644
index 00000000000..5000fcf058d
--- /dev/null
+++ b/lib/client/db/mysql/repl/fixtures/ddl.sql
@@ -0,0 +1,92 @@
+-- temporarily disable note warnings for test consistency
+set session sql_notes = 0;
+DROP DATABASE IF EXISTS test_ddl_db;
+CREATE DATABASE test_ddl_db;
+set session sql_notes = 1;
+-- this should produce a note level warning because it does exist
+CREATE DATABASE if not exists test_ddl_db;
+-- we clear the warning by selecting the warning count
+select @@warning_count;
+USE test_ddl_db;
+
+CREATE TABLE test_table (
+ id INT PRIMARY KEY AUTO_INCREMENT,
+ value VARCHAR(50) NOT NULL,
+ note VARCHAR(100) DEFAULT NULL
+) ENGINE=InnoDB;
+
+CREATE VIEW test_view AS (
+ SELECT id, value
+ FROM test_table
+);
+
+DELIMITER $$
+CREATE FUNCTION test_func(x INT) RETURNS INT DETERMINISTIC
+BEGIN
+ RETURN x * 2;
+END $$
+DELIMITER ;
+
+DELIMITER $$
+CREATE PROCEDURE test_proc()
+BEGIN
+ INSERT INTO test_table(value) VALUES ('from proc');
+END $$
+DELIMITER ;
+
+CALL test_proc();
+
+DELIMITER $$
+CREATE TRIGGER test_trigger_before_insert
+BEFORE INSERT ON test_table
+FOR EACH ROW
+BEGIN
+ SET NEW.value = UPPER(NEW.value);
+END $$
+DELIMITER ;
+
+CALL test_proc();
+
+CREATE EVENT test_event
+ ON SCHEDULE AT CURRENT_TIMESTAMP + INTERVAL 1 HOUR
+ DO INSERT INTO test_table(value) VALUES ('from event');
+
+set session sql_notes = 0;
+-- roles are not database scoped, let's disable note warnings in case some other
+-- test has already created the role or user.
+CREATE ROLE IF NOT EXISTS test_role;
+DROP ROLE test_role;
+
+CREATE USER IF NOT EXISTS test_user@'localhost'
+ IDENTIFIED BY 'some password'
+ ACCOUNT LOCK;
+ALTER USER test_user@'localhost' PASSWORD EXPIRE;
+DROP USER test_user@'localhost';
+set session sql_notes = 1;
+
+ALTER DATABASE test_ddl_db CHARACTER SET utf8mb4;
+ALTER TABLE test_table ADD COLUMN note2 VARCHAR(100) DEFAULT NULL;
+ALTER EVENT test_event DISABLE;
+ALTER FUNCTION test_func COMMENT 'Test function';
+ALTER PROCEDURE test_proc COMMENT 'Test procedure';
+
+RENAME TABLE test_table
+ TO test_table_renamed;
+SELECT * from test_table_renamed;
+
+RENAME
+ TABLE test_table_renamed
+ TO test_table;
+
+SELECT * from test_table;
+
+-- there is no "test_server", this should make an error.
+ALTER SERVER test_server OPTIONS (HOST '127.0.0.1');
+
+DROP TRIGGER test_trigger_before_insert;
+
+TRUNCATE TABLE test_table ;
+DROP TABLE test_table;
+
+DROP DATABASE test_ddl_db
+;
diff --git a/lib/client/db/mysql/repl/fixtures/literals.sql b/lib/client/db/mysql/repl/fixtures/literals.sql
new file mode 100644
index 00000000000..9633e6c2fcb
--- /dev/null
+++ b/lib/client/db/mysql/repl/fixtures/literals.sql
@@ -0,0 +1,50 @@
+-- with ANSI_QUOTES double quotes are identifiers
+SET SESSION sql_mode='ANSI_QUOTES';
+SELECT "thing";
+-- without ANSI_QUOTES double quotes are literals
+SET SESSION sql_mode='';
+SELECT "thing";
+select 1, -1,1.24, -1.24, 'foo', null, 'null';
+select
+ 1 as 'unsigned int',
+ -1 as 'negative int',
+ 1.24 as "float",
+ -1.24 as 'negative float',
+ 'foo' as 'string',
+ null as 'actual NULL',
+ "null" as 'string null';
+
+select 1 as 'multiple rows of literals'
+union
+ select -1
+union
+ select 1.24
+union
+ select -1.24
+union
+ select 'foo'
+union
+ select null
+union
+ select 'null';
+
+select 'a
+b
+c' as 'multiline-string';
+
+select 'a
+ ;
+ b
+ ;
+c' as 'multiline with delimiters';
+
+/* foo bar */
+select 1;
+/* foo bar */select /*
+baz */ 42;
+
+delimiter $$
+select '$$1$$'$$
+select '$$;$$';$$
+delimiter ;
+select 1;
diff --git a/lib/client/db/mysql/repl/lexer.go b/lib/client/db/mysql/repl/lexer.go
new file mode 100644
index 00000000000..609ec62e48b
--- /dev/null
+++ b/lib/client/db/mysql/repl/lexer.go
@@ -0,0 +1,314 @@
+// Teleport
+// Copyright (C) 2025 Gravitational, Inc.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package repl
+
+import (
+ "cmp"
+ "io"
+ "regexp"
+ "strings"
+ "unicode"
+
+ "github.com/gravitational/trace"
+)
+
+// lexer scans REPL input lines distinguishing between comments, strings,
+// whitespace, delimiter, and normal query text.
+type lexer struct {
+ delim string
+ inComment bool
+ inStringToken token
+ queryBuf strings.Builder
+
+ lineReader strings.Reader
+ line string
+}
+
+// clientDefaultStatementDelimiter is the default client-side delimiter between
+// statements. This is the only delimiter that is recognized server-side, but it
+// is sometimes necessary for the client to switch delimiters while typing at a
+// REPL.
+const clientDefaultStatementDelimiter = ";"
+
+// delimiter returns the current client-side delimiter.
+func (l *lexer) delimiter() string {
+ return cmp.Or(l.delim, clientDefaultStatementDelimiter)
+}
+
+// validDelimiterRegex only allows somewhat sane choices for delimiters.
+var validDelimiterRegex = regexp.MustCompile(`^(;|[/]{2}|[$]{2})$`)
+
+// setDelimiter sets the client-side statement delimiter.
+func (l *lexer) setDelimiter(delimiter string) error {
+ delimiter = strings.TrimSpace(delimiter)
+ if delimiter == "" {
+ l.delim = ""
+ return nil
+ }
+ if validDelimiterRegex.MatchString(delimiter) {
+ l.delim = delimiter
+ return nil
+ }
+ return trace.BadParameter("DELIMITER %q does not match regex used for validation %q",
+ delimiter, validDelimiterRegex.String(),
+ )
+}
+
+// writeString writes a string to the query buffer.
+func (l *lexer) writeString(s string) {
+ l.queryBuf.WriteString(s)
+}
+
+// writeRune writes a single rune to the query buffer.
+func (l *lexer) writeRune(r rune) {
+ l.queryBuf.WriteRune(r)
+}
+
+// getQuery builds the buffered query string and empties the query buffer.
+func (l *lexer) getQuery() string {
+ out := l.queryBuf.String()
+ l.queryBuf.Reset()
+ return out
+}
+
+// isInQuery returns true if the lexer is reading inside of a MySQL query.
+func (l *lexer) isInQuery() bool {
+ return l.queryBuf.Len() > 0
+}
+
+// isInComment returns true if the lexer is reading inside of a MySQL comment.
+func (l *lexer) isInComment() bool {
+ return l.inComment
+}
+
+// isInString returns true if the lexer is reading inside of a MySQL string.
+func (l *lexer) isInString() bool {
+ return len(l.inStringToken.text) > 0
+}
+
+// inStringKind returns the kind of MySQL string the lexer is currently reading
+// inside of, i.e., the opening/closing rune: "'`.
+func (l *lexer) inStringKind() string {
+ return l.inStringToken.text
+}
+
+// isMultilineQuery returns true if the lexer is reading a multline query.
+func (l *lexer) isMultilineQuery() bool {
+ return l.isInQuery() && strings.Contains(l.queryBuf.String(), lineBreak)
+}
+
+// setCloseComment marks the start of a comment.
+func (l *lexer) setOpenComment() {
+ l.inComment = true
+}
+
+// setCloseComment marks the end of a comment.
+func (l *lexer) setCloseComment() {
+ l.inComment = false
+}
+
+// setCloseString marks the start of a MySQL string.
+func (l *lexer) setOpenString(tok token) {
+ l.inStringToken = tok
+}
+
+// setCloseString closes the currently open string state.
+func (l *lexer) setCloseString() {
+ l.inStringToken = token{}
+}
+
+// setLine sets the input line to read from.
+func (l *lexer) setLine(line string) {
+ l.lineReader.Reset(line)
+ l.line = line
+}
+
+// isEmpty returns whether the line has been completely read.
+func (l *lexer) isEmpty() bool {
+ return l.lineReader.Len() == 0
+}
+
+// readRune reads a single rune and returns true if there was no error.
+func (l *lexer) readRune() (rune, bool) {
+ r, _, err := l.lineReader.ReadRune()
+ ok := err == nil
+ return r, ok
+}
+
+// unreadRune unreads the last rune that was read.
+func (l *lexer) unreadRune() {
+ l.lineReader.UnreadRune()
+}
+
+// peekString returns the remainder of the line without advancing the reader.
+func (l *lexer) peekString() string {
+ offset := len(l.line) - l.lineReader.Len()
+ return l.line[offset:]
+}
+
+// advanceByRune advances the line reader by the given rune if and only if it
+// matches.
+func (l *lexer) advanceByRune(want rune) bool {
+ if r, ok := l.readRune(); ok {
+ if r == want {
+ return true
+ }
+ l.unreadRune()
+ }
+ return false
+}
+
+// advanceByString advances the line reader by the given string if and only if
+// it is a prefix of the remaining string.
+func (l *lexer) advanceByDelimiter() bool {
+ return l.advanceByString(l.delimiter())
+}
+
+// advanceByString advances the line reader by the given string if and only if
+// it is a prefix of the remaining string.
+func (l *lexer) advanceByString(s string) bool {
+ if strings.HasPrefix(l.peekString(), s) {
+ l.lineReader.Seek(int64(len(s)), io.SeekCurrent)
+ return true
+ }
+ return false
+}
+
+// scan scans a token from the line reader.
+func (l *lexer) scan() token {
+ r, ok := l.readRune()
+ if !ok {
+ return token{kind: tokenEOF}
+ }
+
+ if unicode.IsSpace(r) {
+ l.unreadRune()
+ return token{kind: tokenSpace, text: l.readMatching(unicode.IsSpace)}
+ }
+
+ switch r {
+ case '\\':
+ return token{kind: tokenBackslash, text: `\`}
+ case '\'':
+ return token{kind: tokenSingleQuote, text: "'"}
+ case '"':
+ return token{kind: tokenDoubleQuote, text: `"`}
+ case '`':
+ return token{kind: tokenBacktick, text: "`"}
+ case '-':
+ if l.advanceByRune('-') {
+ return token{kind: tokenSingleComment, text: "--"}
+ }
+ case '#':
+ return token{kind: tokenSingleComment, text: "#"}
+ case '/':
+ if l.advanceByRune('*') {
+ return token{kind: tokenOpenComment, text: "/*"}
+ }
+ case '*':
+ if l.advanceByRune('/') {
+ return token{kind: tokenCloseComment, text: "*/"}
+ }
+ }
+ return token{kind: tokenText, text: string(r)}
+}
+
+// readMatching reads runes matching matchFn into the query buffer.
+func (l *lexer) readMatching(matchFn func(rune) bool) string {
+ var runes []rune
+ for {
+ r, ok := l.readRune()
+ if !ok {
+ break
+ }
+ if !matchFn(r) {
+ l.unreadRune()
+ break
+ }
+ runes = append(runes, r)
+ }
+ return string(runes)
+}
+
+// acceptEscapedRune reads the next character following an escape backslash and
+// saves it to the query buffer.
+func (l *lexer) acceptEscapedRune() {
+ if r, ok := l.readRune(); ok {
+ l.writeRune(r)
+ }
+}
+
+// acceptString reads a MySQL string bounded by single-quotes, double-quotes, or
+// backticks and saves it to the query buffer.
+func (l *lexer) acceptString() {
+ for !l.isEmpty() {
+ tok := l.scan()
+ l.writeString(tok.text)
+ switch tok.kind {
+ case tokenBackslash:
+ l.acceptEscapedRune()
+ case l.inStringToken.kind:
+ // this must be a closing quote
+ l.setCloseString()
+ return
+ }
+ }
+}
+
+// discardRemaining discards the remainder of the line.
+func (l *lexer) discardRemaining() {
+ l.lineReader.Reset("")
+}
+
+// discardMatching discards runes matching matchFn.
+func (l *lexer) discardMatching(matchFn func(rune) bool) {
+ for {
+ r, ok := l.readRune()
+ if !ok {
+ break
+ }
+ if !matchFn(r) {
+ l.unreadRune()
+ break
+ }
+ }
+}
+
+// discardWhitespace discards leading whitespace.
+func (l *lexer) discardWhitespace() {
+ l.discardMatching(unicode.IsSpace)
+}
+
+// discardSingleLineComment discards the rest of the line following a comment.
+func (l *lexer) discardSingleLineComment() {
+ l.discardRemaining()
+}
+
+// discardMultiLineComment discards everything inside of a multiline comment.
+func (l *lexer) discardMultiLineComment() {
+ for !l.isEmpty() {
+ l.discardMatching(func(r rune) bool {
+ // shortcut: scan until we get a potential end of comment token
+ // note that escapes don't work in comments, so we don't check for them
+ return r != '*'
+ })
+ if tok := l.scan(); tok.kind == tokenCloseComment {
+ l.setCloseComment()
+ return
+ }
+ }
+}
diff --git a/lib/client/db/mysql/repl/lexer_test.go b/lib/client/db/mysql/repl/lexer_test.go
new file mode 100644
index 00000000000..9e556e98b18
--- /dev/null
+++ b/lib/client/db/mysql/repl/lexer_test.go
@@ -0,0 +1,298 @@
+// Teleport
+// Copyright (C) 2025 Gravitational, Inc.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package repl
+
+import (
+ "testing"
+ "unicode"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestDelimiter(t *testing.T) {
+ t.Parallel()
+ var lex lexer
+ require.Equal(t, clientDefaultStatementDelimiter, lex.delimiter())
+
+ tests := []struct {
+ desc string
+ args string
+ wantDelimiter string
+ wantErrContains string
+ }{
+ {
+ desc: "passing no arg is ok",
+ wantDelimiter: ";",
+ },
+ {
+ desc: "passing the default delimiter is ok",
+ args: ";",
+ wantDelimiter: ";",
+ },
+ {
+ desc: "passing two dollars is ok",
+ args: "$$",
+ wantDelimiter: "$$",
+ },
+ {
+ desc: "passing two slashes is ok",
+ args: "//",
+ wantDelimiter: "//",
+ },
+ {
+ desc: "passing too many delimiter runes is invalid",
+ args: ";;;;;;",
+ wantErrContains: `DELIMITER ";;;;;;" does not match regex used for validation "^(;|[/]{2}|[$]{2})$"`,
+ },
+ {
+ desc: "passing rejected runes is invalid",
+ args: "x",
+ wantErrContains: `DELIMITER "x" does not match regex used for validation "^(;|[/]{2}|[$]{2})$"`,
+ },
+ {
+ desc: "passing rejected runes is invalid",
+ args: "\\",
+ wantErrContains: `DELIMITER "\\" does not match regex used for validation "^(;|[/]{2}|[$]{2})$"`,
+ },
+ }
+ for _, test := range tests {
+ t.Run(test.desc, func(t *testing.T) {
+ var lex lexer
+ err := lex.setDelimiter(test.args)
+ if test.wantErrContains != "" {
+ require.ErrorContains(t, err, test.wantErrContains)
+ return
+ }
+ require.NoError(t, err)
+ require.Equal(t, test.wantDelimiter, lex.delimiter())
+ })
+ }
+}
+
+func TestLexer_setLine(t *testing.T) {
+ t.Parallel()
+ var l lexer
+ l.setLine("SELECT * FROM t;")
+ require.Equal(t, "SELECT * FROM t;", l.peekString())
+ require.False(t, l.isEmpty())
+}
+
+func TestLexer_advanceByRune(t *testing.T) {
+ t.Parallel()
+ var l lexer
+ l.setLine("abc")
+ require.True(t, l.advanceByRune('a'), "should advance on matching rune")
+ require.Equal(t, "bc", l.peekString())
+ require.False(t, l.advanceByRune('z'), "should not advance on non-matching rune")
+ require.Equal(t, "bc", l.peekString())
+}
+
+func TestLexer_advanceByString(t *testing.T) {
+ t.Parallel()
+ var l lexer
+ l.setLine("foobar")
+ require.True(t, l.advanceByString("foo"), "should advance by matching prefix")
+ require.Equal(t, "bar", l.peekString())
+ require.False(t, l.advanceByString("baz"), "should not advance if prefix doesn't match")
+ require.Equal(t, "bar", l.peekString())
+}
+
+func TestLexer_advanceByDelimiter(t *testing.T) {
+ t.Parallel()
+ var l lexer
+ l.setDelimiter(";")
+ l.setLine("; SELECT")
+ require.True(t, l.advanceByDelimiter())
+ require.Equal(t, " SELECT", l.peekString())
+ require.False(t, l.advanceByDelimiter())
+ require.Equal(t, " SELECT", l.peekString())
+ l.setLine("//")
+ l.setDelimiter("//")
+ require.True(t, l.advanceByDelimiter())
+ require.Empty(t, l.peekString())
+ require.False(t, l.advanceByDelimiter())
+}
+
+func TestLexer_getQuery(t *testing.T) {
+ t.Parallel()
+ var l lexer
+ l.writeString("hello")
+ l.writeRune(' ')
+ l.writeString("world!")
+ require.Equal(t, "hello world!", l.getQuery())
+ require.Empty(t, l.getQuery(), "getQuery should reset buffer")
+}
+
+func TestLexer_unread(t *testing.T) {
+ t.Parallel()
+ var l lexer
+ l.setLine("xy")
+ r, ok := l.readRune()
+ require.True(t, ok)
+ require.Equal(t, 'x', r)
+ l.unreadRune()
+
+ r, ok = l.readRune()
+ require.True(t, ok)
+ require.Equal(t, 'x', r)
+
+ r, ok = l.readRune()
+ require.True(t, ok)
+ require.Equal(t, 'y', r)
+
+ _, ok = l.readRune()
+ require.False(t, ok, "should be false at end of input")
+ l.unreadRune()
+ _, ok = l.readRune()
+ require.False(t, ok, "cant unread eof")
+}
+
+func TestLexer_readMatching(t *testing.T) {
+ t.Parallel()
+ var l lexer
+ l.setLine("123abcXYZ")
+ require.Equal(t, "123", l.readMatching(unicode.IsDigit))
+ require.Equal(t, "abc", l.readMatching(unicode.IsLower))
+ require.Equal(t, "XYZ", l.readMatching(unicode.IsUpper))
+ require.Empty(t, l.readMatching(unicode.IsDigit))
+}
+
+func TestLexer_acceptEscapedRune(t *testing.T) {
+ t.Parallel()
+ var l lexer
+ l.setLine(`\xyz`)
+ tok := l.scan()
+ require.Equal(t, tokenBackslash, tok.kind)
+ l.writeString(tok.text)
+ l.acceptEscapedRune()
+ require.Equal(t, "yz", l.peekString())
+ require.Equal(t, `\x`, l.getQuery())
+}
+
+func TestLexer_acceptString(t *testing.T) {
+ t.Parallel()
+ var l lexer
+ l.setLine(`"inside string "; SELECT`)
+ tok := l.scan()
+ require.Equal(t, tokenDoubleQuote, tok.kind)
+ l.writeString(tok.text)
+ l.setOpenString(tok)
+ l.acceptString()
+ require.Equal(t, "; SELECT", l.peekString())
+ require.Equal(t, `"inside string "`, l.getQuery())
+}
+
+func TestLexer_discardRemaining(t *testing.T) {
+ t.Parallel()
+ var l lexer
+ l.setLine("DROP TABLE test;")
+ l.discardRemaining()
+ require.True(t, l.isEmpty())
+ require.Empty(t, l.peekString())
+}
+
+func TestLexer_discardMatching(t *testing.T) {
+ t.Parallel()
+ tests := []struct {
+ name string
+ input string
+ matchFn func(rune) bool
+ expected string
+ }{
+ {
+ name: "discard digits",
+ input: "123abc",
+ matchFn: unicode.IsDigit,
+ expected: "abc",
+ },
+ {
+ name: "discard lowercase",
+ input: "abcDEF",
+ matchFn: unicode.IsLower,
+ expected: "DEF",
+ },
+ {
+ name: "nothing to discard",
+ input: "ABC123",
+ matchFn: unicode.IsLower,
+ expected: "ABC123",
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ var l lexer
+ l.setLine(tc.input)
+ l.discardMatching(tc.matchFn)
+ require.Equal(t, tc.expected, l.peekString())
+ })
+ }
+}
+
+func TestLexer_discardWhitespace(t *testing.T) {
+ t.Parallel()
+ tests := []struct {
+ name string
+ input string
+ expected string
+ }{
+ {
+ name: "leading whitespace only",
+ input: " SELECT",
+ expected: "SELECT",
+ },
+ {
+ name: "no whitespace",
+ input: "SELECT",
+ expected: "SELECT",
+ },
+ {
+ name: "whitespace and tabs",
+ input: "\t \n abc",
+ expected: "abc",
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ var l lexer
+ l.setLine(tc.input)
+ l.discardWhitespace()
+ require.Equal(t, tc.expected, l.peekString())
+ })
+ }
+}
+
+func TestLexer_discardSingleLineComment(t *testing.T) {
+ t.Parallel()
+ var l lexer
+ l.setLine("-- This is a comment")
+ l.discardSingleLineComment()
+ require.True(t, l.isEmpty())
+ require.Empty(t, l.peekString())
+}
+
+func TestLexer_discardMultiLineComment(t *testing.T) {
+ t.Parallel()
+ var l lexer
+ l.setLine("inside comment */ SELECT 1;")
+ l.setOpenComment()
+ l.discardMultiLineComment()
+ require.False(t, l.isInComment(), "should have exited comment mode")
+ remaining := l.peekString()
+ require.Equal(t, " SELECT 1;", remaining)
+}
diff --git a/lib/client/db/mysql/repl/parser.go b/lib/client/db/mysql/repl/parser.go
new file mode 100644
index 00000000000..ac64cc6f240
--- /dev/null
+++ b/lib/client/db/mysql/repl/parser.go
@@ -0,0 +1,195 @@
+// Teleport
+// Copyright (C) 2025 Gravitational, Inc.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package repl
+
+import (
+ "iter"
+ "strings"
+ "time"
+
+ "github.com/gravitational/trace"
+)
+
+func newParser() (*parser, error) {
+ commands, err := newCommands()
+ if err != nil {
+ return nil, trace.Wrap(err)
+ }
+ return &parser{commands: commands}, nil
+}
+
+// parser parses commands and queries given one or more lines of input.
+type parser struct {
+ commands *commandManager
+ lex lexer
+}
+
+// parse returns an iterator over the input line yielding evaluators for each
+// command or query found.
+func (p *parser) parse(line string) iter.Seq[evaluator] {
+ p.lex.setLine(line)
+ return func(yield func(evaluator) bool) {
+ defer func() {
+ if p.lex.isEmpty() && p.lex.isInQuery() {
+ // must be a multiline query, so insert a linebreak in the query buf
+ p.lex.writeString(lineBreak)
+ }
+ }()
+
+ for !p.lex.isEmpty() {
+ if p.lex.isInComment() {
+ // dont preserve comments
+ p.lex.discardMultiLineComment()
+ continue
+ }
+
+ if !p.lex.isInQuery() {
+ // not in a comment or query: is the rest of the line a command?
+ if cmd := p.tryParseCommand(); cmd != nil {
+ if !yield(cmd) {
+ return
+ }
+ continue
+ }
+ }
+
+ if p.lex.isInString() {
+ p.lex.acceptString()
+ continue
+ }
+
+ // not in comment nor string: do we have a command/query delimiter?
+ if cmdOrQuery := p.tryParseQuery(); cmdOrQuery != nil {
+ if !yield(cmdOrQuery) {
+ return
+ }
+ continue
+ }
+
+ // not in comment nor string and no delimiter found: scan text
+ tok := p.lex.scan()
+ switch tok.kind {
+ case tokenSingleComment:
+ // dont preserve comments
+ p.lex.discardSingleLineComment()
+ case tokenOpenComment:
+ p.lex.setOpenComment()
+ p.lex.discardMultiLineComment()
+ case tokenBackslash:
+ p.lex.writeString(tok.text)
+ p.lex.acceptEscapedRune()
+ case tokenSingleQuote, tokenDoubleQuote, tokenBacktick:
+ p.lex.writeString(tok.text)
+ p.lex.setOpenString(tok)
+ p.lex.acceptString()
+ default:
+ p.lex.writeString(tok.text)
+ }
+ }
+ }
+}
+
+// tryParseCommand tries to parse a command and its args on the current line.
+// If the line contains the current delimiter and the command is not the special
+// DELIMITER command, then give up and let the query parser handle it.
+func (p *parser) tryParseCommand() evaluator {
+ p.lex.discardWhitespace()
+ line := p.lex.peekString()
+ cmd, args, err := p.commands.findCommand(line)
+ switch {
+ case err != nil:
+ if strings.Contains(line, p.lex.delimiter()) {
+ return nil
+ }
+ p.lex.discardRemaining()
+ return errorEvaluator{err: err}
+ case cmd != nil:
+ if cmd.name != "delimiter" && strings.Contains(line, p.lex.delimiter()) {
+ return nil
+ }
+ p.lex.discardRemaining()
+ return commandEvaluator{cmd: cmd, args: args}
+ default:
+ return nil
+ }
+}
+
+// tryParseQuery looks for a delimiter in the input. If it finds one and the
+// query buffer is all from one line, then it first looks for a command in the
+// query buffer. If there is no command or the query buffer is multiline, then
+// it returns a query evaluator.
+func (p *parser) tryParseQuery() evaluator {
+ if p.lex.advanceByDelimiter() {
+ query := p.lex.getQuery()
+ if !p.lex.isMultilineQuery() || strings.HasPrefix(query, p.commands.shortcutPrefix) {
+ cmd, args, err := p.commands.findCommand(query)
+ switch {
+ case err != nil:
+ // don't discard the rest of the line, since we found the
+ // command in the query buffer, unlike the command parser which
+ // looks ahead in the current line for a command
+ return errorEvaluator{err: err}
+ case cmd != nil:
+ return commandEvaluator{cmd: cmd, args: args}
+ }
+ }
+ return queryEvaluator(query)
+ }
+ return nil
+}
+
+type evaluator interface {
+ eval(r *REPL) (reply string, exit bool)
+}
+
+// queryEvaluator executes a single query and formats the response as a reply
+// to the REPL client.
+type queryEvaluator string
+
+func (query queryEvaluator) eval(r *REPL) (string, bool) {
+ start := time.Now()
+ result, err := r.myConn.Execute(string(query))
+ if err != nil {
+ return errorReplyPrefix + err.Error(), false
+ }
+ defer result.Close()
+ if r.disableQueryTimings {
+ return formatResult(result, nil), false
+ }
+
+ elapsed := time.Since(start)
+ return formatResult(result, &elapsed), false
+}
+
+// commandEvaluator executes a command.
+type commandEvaluator struct {
+ cmd *command
+ args string
+}
+
+func (e commandEvaluator) eval(r *REPL) (string, bool) {
+ return e.cmd.execFunc(r, e.args)
+}
+
+type errorEvaluator struct {
+ err error
+}
+
+// errorEvaluator formats an error as a reply to the REPL client.
+func (e errorEvaluator) eval(_ *REPL) (string, bool) {
+ return e.err.Error(), false
+}
diff --git a/lib/client/db/mysql/repl/repl.go b/lib/client/db/mysql/repl/repl.go
new file mode 100644
index 00000000000..018c12bad07
--- /dev/null
+++ b/lib/client/db/mysql/repl/repl.go
@@ -0,0 +1,281 @@
+// Teleport
+// Copyright (C) 2025 Gravitational, Inc.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package repl
+
+import (
+ "cmp"
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "iter"
+ "net"
+ "strings"
+ "time"
+
+ "github.com/go-mysql-org/go-mysql/client"
+ "github.com/go-mysql-org/go-mysql/mysql"
+ "github.com/gravitational/trace"
+ "golang.org/x/term"
+
+ "github.com/gravitational/teleport"
+ clientproto "github.com/gravitational/teleport/api/client/proto"
+ "github.com/gravitational/teleport/lib/asciitable"
+ dbrepl "github.com/gravitational/teleport/lib/client/db/repl"
+)
+
+// REPL implements [dbrepl.REPLInstance] for MySQL.
+type REPL struct {
+ client io.ReadWriteCloser
+ serverConn net.Conn
+ route clientproto.RouteToDatabase
+ term *term.Terminal
+ parser *parser
+ myConn mysqlConn
+
+ // teleportVersion is used in golden tests to fake the current Teleport
+ // version and prevent test failures when the real version is incremented.
+ teleportVersion string
+
+ // testPassword is normally blank, only used in tests where the REPL connects
+ // directly to a MySQL instance without Teleport proxying.
+ testPassword string
+ // disableQueryTimings is used in golden tests to disable query timings for
+ // test consistency.
+ disableQueryTimings bool
+}
+
+type mysqlConn interface {
+ Execute(command string, args ...any) (*mysql.Result, error)
+ UseDB(dbName string) error
+ GetServerVersion() string
+}
+
+// New implements [dbrepl.REPLNewFunc].
+func New(_ context.Context, cfg *dbrepl.NewREPLConfig) (dbrepl.REPLInstance, error) {
+ parser, err := newParser()
+ if err != nil {
+ return nil, trace.Wrap(err)
+ }
+ return &REPL{
+ client: cfg.Client,
+ serverConn: cfg.ServerConn,
+ route: cfg.Route,
+ term: term.NewTerminal(cfg.Client, ""),
+ parser: parser,
+ }, nil
+}
+
+// Run starts and run the PostgreSQL REPL session. The provided context is used
+// to interrupt the execution and clean up resources.
+func (r *REPL) Run(ctx context.Context) error {
+ dialer := func(context.Context, string, string) (net.Conn, error) {
+ return r.serverConn, nil
+ }
+ const hostnamePlaceholder = "repl"
+ myConn, err := client.ConnectWithDialer(ctx, "tcp", hostnamePlaceholder,
+ r.route.GetUsername(),
+ r.testPassword,
+ r.route.GetDatabase(),
+ dialer,
+ withClientCapabilities(
+ mysql.CLIENT_MULTI_RESULTS,
+ mysql.CLIENT_MULTI_STATEMENTS,
+ ),
+ func(c *client.Conn) error {
+ c.SetAttributes(map[string]string{clientNameParamName: clientNameParamValue})
+ return nil
+ },
+ )
+ if err != nil {
+ return trace.ConnectionProblem(err, "Unable to connect to database: %v", err)
+ }
+ r.myConn = myConn
+ defer func() {
+ _ = myConn.Close()
+ }()
+
+ // term.Terminal blocks reads/writes without respecting the context. The
+ // only thing that unblocks it is closing the underlaying connection (in
+ // our case r.client). On this goroutine we only watch for context
+ // cancelation and close the connection. This will unblocks all terminal
+ // reads/writes.
+ stop := context.AfterFunc(ctx, func() {
+ _ = r.client.Close()
+ })
+ defer stop()
+
+ if err := r.presentBanner(); err != nil {
+ return trace.Wrap(err)
+ }
+
+ for {
+ line, err := r.readLine()
+ if err != nil {
+ return trace.Wrap(rewriteTermError(ctx, err))
+ }
+ for reply, exit := range r.eval(line) {
+ if exit {
+ return nil
+ }
+ if err := r.print(reply); err != nil {
+ return trace.Wrap(err)
+ }
+ }
+ }
+}
+
+func withClientCapabilities(caps ...uint32) client.Option {
+ return func(conn *client.Conn) error {
+ for _, cap := range caps {
+ conn.SetCapability(cap)
+ }
+ return nil
+ }
+}
+
+func (r *REPL) presentBanner() error {
+ _, err := fmt.Fprintf(
+ r.term,
+ `Teleport MySQL interactive shell (v%s)
+Connected to instance %q as user %q.
+Type 'help' or '\h' for help.
+
+`,
+ cmp.Or(r.teleportVersion, teleport.Version),
+ r.route.GetServiceName(),
+ r.route.GetUsername())
+ return trace.Wrap(err)
+}
+
+func (r *REPL) readLine() (string, error) {
+ r.term.SetPrompt(r.getPrompt())
+ return r.term.ReadLine()
+}
+
+func (r *REPL) getPrompt() string {
+ dbName := formatDatabaseName(r.route.Database)
+ var suffix string
+ switch {
+ case r.parser.lex.isInComment():
+ suffix = "/*"
+ case r.parser.lex.isInString():
+ suffix = r.parser.lex.inStringKind()
+ case r.parser.lex.isInQuery():
+ suffix = "-"
+ default:
+ suffix = dbName
+ }
+ padding := len(dbName)
+ // pad the suffix with leading spaces until it is len(dbName), for example:
+ // default: "mysql>"
+ // comment: " */>"
+ // string: " '>"
+ // query: " ->"
+ return fmt.Sprintf("%*s> ", padding, suffix)
+}
+
+func (r *REPL) eval(line string) iter.Seq2[string, bool] {
+ return func(yield func(string, bool) bool) {
+ for evaluator := range r.parser.parse(line) {
+ if !yield(evaluator.eval(r)) {
+ return
+ }
+ }
+ }
+}
+
+// print writes a reply to the REPL client.
+func (r *REPL) print(reply string) error {
+ reply = strings.TrimSpace(reply)
+ if reply == "" {
+ return nil
+ }
+ _, err := r.term.Write([]byte(lineBreak + reply + lineBreak + lineBreak))
+ return trace.Wrap(err)
+}
+
+// rewriteTermError changes the term.Terminal error to match caller expectations.
+func rewriteTermError(ctx context.Context, err error) error {
+ // When context is canceled it will immediately lead read/write errors due
+ // to the closed connection. For this cases we return the context error.
+ if ctx.Err() != nil && (errors.Is(err, io.ErrClosedPipe) || errors.Is(err, net.ErrClosed)) {
+ return ctx.Err()
+ }
+ if errors.Is(err, io.EOF) {
+ return nil
+ }
+
+ return err
+}
+
+// formatResult formats a query result.
+func formatResult(result *mysql.Result, elapsed *time.Duration) string {
+ var out string
+ if result.Resultset != nil {
+ out = formatResultset(result.Resultset)
+ } else {
+ out = fmt.Sprintf("%v warnings, %v rows affected", result.Warnings, result.AffectedRows)
+ }
+ if elapsed != nil {
+ return fmt.Sprintf("%s (%.2f sec)", out, elapsed.Seconds())
+ }
+ return out
+}
+
+// formatResultset formats a query result that contains rows.
+func formatResultset(set *mysql.Resultset) string {
+ if set == nil || len(set.Values) == 0 {
+ return "Empty set"
+ }
+ var sb strings.Builder
+ columns := make([]string, 0, len(set.Fields))
+ for _, f := range set.Fields {
+ columns = append(columns, string(f.Name))
+ }
+ table := asciitable.MakeTable(columns)
+ for _, rowValues := range set.Values {
+ row := make([]string, 0, len(rowValues))
+ for _, val := range rowValues {
+ row = append(row, val.String())
+ }
+ table.AddRow(row)
+ }
+ table.WriteTo(&sb)
+ sb.WriteString(lineBreak)
+
+ if numRows := len(set.Values); numRows == 1 {
+ sb.WriteString("1 row in set")
+ } else {
+ // pluralize 0 or multiple rows
+ fmt.Fprintf(&sb, "%d rows in set", numRows)
+ }
+ return sb.String()
+}
+
+const (
+ // lineBreak represents a line break on the REPL.
+ lineBreak = "\n"
+ // errorReplyPrefix is the prefix presented when there is a execution error.
+ errorReplyPrefix = "ERR "
+ // clientNameParamName defines the application name parameter name.
+ //
+ // https://dev.mysql.com/doc/refman/8.0/en/performance-schema-connection-attribute-tables.html
+ clientNameParamName = "_client_name"
+ // clientNameParamValue defines the application name parameter value.
+ clientNameParamValue = "teleport-repl"
+)
diff --git a/lib/client/db/mysql/repl/repl_test.go b/lib/client/db/mysql/repl/repl_test.go
new file mode 100644
index 00000000000..3716fe0e70f
--- /dev/null
+++ b/lib/client/db/mysql/repl/repl_test.go
@@ -0,0 +1,455 @@
+// Teleport
+// Copyright (C) 2025 Gravitational, Inc.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package repl
+
+import (
+ "bytes"
+ "cmp"
+ "context"
+ "crypto/rand"
+ _ "embed"
+ "io"
+ "net"
+ "os"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/go-mysql-org/go-mysql/mysql"
+ "github.com/stretchr/testify/require"
+ "github.com/testcontainers/testcontainers-go"
+ mysqlcontainer "github.com/testcontainers/testcontainers-go/modules/mysql"
+ "golang.org/x/term"
+
+ clientproto "github.com/gravitational/teleport/api/client/proto"
+ dbrepl "github.com/gravitational/teleport/lib/client/db/repl"
+ "github.com/gravitational/teleport/lib/defaults"
+ "github.com/gravitational/teleport/lib/utils/testutils/golden"
+)
+
+var testSrv *testServer
+var testSrvMu sync.Mutex
+
+func TestMain(m *testing.M) {
+ code := m.Run()
+
+ // The "Ryuk" container will cleanup everything anyhow, but only after 10s
+ // without any client connections.
+ // We can speed up container cleanup after a -count=N run, e.g., a flaky
+ // test detector run, by terminating them immediately after tests have run.
+ // https://github.com/testcontainers/moby-ryuk
+ testSrvMu.Lock()
+ defer testSrvMu.Unlock()
+ // if we don't clean up after 10 seconds, RYUK will get them anyway.
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+
+ // ignore errors, Ryuk or worst case VM termination in CI will deal with it
+ _ = testSrv.container.Terminate(ctx)
+ os.Exit(code)
+}
+
+func TestREPL(t *testing.T) {
+ testSrv := newTestServer(t)
+ route := clientproto.RouteToDatabase{
+ ServiceName: "mysql-test-container",
+ Protocol: defaults.ProtocolMySQL,
+ Username: testSrv.username,
+ // Pass a blank database name, as if a user who didn't specify one.
+ // The REPL should handle this case gracefully.
+ Database: "",
+ }
+ for _, test := range []struct {
+ name string
+ input string
+ }{
+ {
+ name: "ddl",
+ input: testDDL,
+ },
+ {
+ name: "all data types",
+ input: testDataTypes,
+ },
+ {
+ name: "literals",
+ input: testLiterals,
+ },
+ {
+ name: "commands",
+ input: testCommands,
+ },
+ } {
+ t.Run(test.name, func(t *testing.T) {
+ // the term only recognizes line breaks with a carriage-return
+ const crlf = "\r\n"
+ input := strings.ReplaceAll(test.input, "\n", crlf) + crlf
+ recorder := newRecordingClient(t, io.NopCloser(strings.NewReader(input)))
+ testREPL, err := New(t.Context(), &dbrepl.NewREPLConfig{
+ Client: recorder,
+ ServerConn: testSrv.connectTCP(t, 10*time.Second),
+ Route: route,
+ })
+ require.NoError(t, err)
+ testREPL.(*REPL).testPassword = testSrv.password
+ testREPL.(*REPL).disableQueryTimings = true
+ err = testREPL.Run(t.Context())
+ require.NoError(t, err)
+ goldenName := strings.ReplaceAll(test.name, " ", "-")
+ if golden.ShouldSet() {
+ golden.SetNamed(t, goldenName, recorder.buf.Bytes())
+ }
+ require.Equal(t, string(golden.GetNamed(t, goldenName)), recorder.buf.String())
+ })
+ }
+}
+
+var (
+ //go:embed fixtures/ddl.sql
+ testDDL string
+ //go:embed fixtures/all-data-types.sql
+ testDataTypes string
+ //go:embed fixtures/literals.sql
+ testLiterals string
+ //go:embed fixtures/commands.sql
+ testCommands string
+)
+
+func TestGetPrompt(t *testing.T) {
+ t.Parallel()
+ tests := []struct {
+ desc string
+ inQuery bool
+ inComment bool
+ inStringKind string
+ database string
+ want string
+ }{
+ {
+ desc: "no default database",
+ want: "(none)> ",
+ },
+ {
+ desc: "default",
+ database: "llama",
+ want: "llama> ",
+ },
+ {
+ desc: "in a query",
+ inQuery: true,
+ database: "llama",
+ want: " -> ",
+ },
+ {
+ desc: "in a comment",
+ inComment: true,
+ database: "llama",
+ want: " /*> ",
+ },
+ {
+ desc: "in a quote string",
+ inStringKind: "'",
+ database: "llama",
+ want: " '> ",
+ },
+ {
+ desc: "in a double quote string",
+ inStringKind: `"`,
+ database: "llama",
+ want: ` "> `,
+ },
+ {
+ desc: "in a backtick string",
+ inStringKind: "`",
+ database: "llama",
+ want: " `> ",
+ },
+ }
+ for _, test := range tests {
+ t.Run(test.desc, func(t *testing.T) {
+ r := &REPL{
+ route: clientproto.RouteToDatabase{
+ Database: test.database,
+ },
+ parser: &parser{
+ lex: lexer{
+ inComment: test.inComment,
+ inStringToken: token{text: test.inStringKind},
+ },
+ },
+ teleportVersion: "v19.0.0-dev",
+ }
+ if test.inQuery {
+ r.parser.lex.queryBuf.WriteString("select")
+ }
+ require.Equal(t, test.want, r.getPrompt())
+ })
+ }
+}
+
+func FuzzEval(f *testing.F) {
+ parser, err := newParser()
+ require.NoError(f, err)
+
+ for _, cmd := range parser.commands.byName {
+ f.Add(cmd.name)
+ f.Add(parser.commands.shortcutPrefix + string(cmd.shortcut))
+ }
+
+ f.Add("") // eof
+ f.Add(" ") // space
+
+ // MySQL string literal quotes
+ f.Add("'")
+ f.Add(`"`)
+ f.Add("`")
+
+ // single line comments
+ f.Add("--")
+ f.Add("#")
+
+ // multiline comments
+ f.Add("/*")
+ f.Add("*/")
+ f.Add("*")
+
+ // valid delimiters
+ f.Add(";")
+ f.Add("$")
+ f.Add("/")
+
+ // some statement syntax
+ f.Add("select")
+ f.Add("create")
+ f.Add("drop")
+ f.Add("replace")
+ f.Add("alter")
+ f.Add("(")
+ f.Add(")")
+
+ // multiline
+ f.Add("\n")
+
+ f.Fuzz(func(t *testing.T, line string) {
+ repl := &REPL{
+ myConn: &fakeMySQLConn{
+ exec: func(command string, args ...any) (*mysql.Result, error) {
+ return nil, io.EOF
+ },
+ },
+ parser: parser,
+ }
+ require.NotPanics(t, func() {
+ for line := range strings.SplitSeq(line, "\n") {
+ for _, exit := range repl.eval(line) {
+ if exit {
+ return
+ }
+ }
+ }
+ })
+ })
+}
+
+func TestInteractively(t *testing.T) {
+ // You can get an interactive REPL to experiment with like this:
+ // $ export MYSQL_TEST_REPL_INTERACTIVELY=1
+ // $ go test -c -o ./test ./lib/client/db/mysql/repl
+ // $ ./test -test.v -test.count=1 -test.run TestInteractively
+ if os.Getenv("MYSQL_TEST_REPL_INTERACTIVELY") == "" {
+ t.Skip()
+ }
+ testSrv := newTestServer(t)
+ route := clientproto.RouteToDatabase{
+ ServiceName: "mysql-test-container",
+ Protocol: defaults.ProtocolMySQL,
+ Username: testSrv.username,
+ Database: testSrv.database,
+ Roles: []string{},
+ }
+ oldState, err := term.MakeRaw(int(os.Stdin.Fd()))
+ require.NoError(t, err)
+ defer term.Restore(int(os.Stdin.Fd()), oldState)
+ recorder := newRecordingClient(t, os.Stdin, os.Stdout)
+ repl, err := New(t.Context(), &dbrepl.NewREPLConfig{
+ Client: recorder,
+ ServerConn: testSrv.connectTCP(t, 10*time.Second),
+ Route: route,
+ })
+ require.NoError(t, err)
+ repl.(*REPL).testPassword = testSrv.password
+ require.NoError(t, repl.Run(t.Context()))
+}
+
+func Test_formatResult(t *testing.T) {
+ resultRows, err := mysql.BuildSimpleTextResultset(
+ []string{"one", "two", "three"},
+ [][]any{{1, 2.2, "3"}, {4, 5.5, "6"}, {7, 8.8, "8"}},
+ )
+ require.NoError(t, err)
+
+ noRows, err := mysql.BuildSimpleTextResultset(
+ []string{"one", "two", "three"},
+ nil,
+ )
+ require.NoError(t, err)
+
+ tests := []struct {
+ desc string
+ input *mysql.Result
+ elapsed time.Duration
+ }{
+ {
+ desc: "result rows",
+ input: &mysql.Result{Resultset: resultRows},
+ elapsed: time.Millisecond * 1234,
+ },
+ {
+ desc: "long elapsed time",
+ input: &mysql.Result{Resultset: resultRows},
+ elapsed: time.Millisecond * 123456,
+ },
+ {
+ desc: "empty set",
+ input: &mysql.Result{Resultset: noRows},
+ elapsed: time.Millisecond * 42,
+ },
+ {
+ desc: "nil set",
+ input: &mysql.Result{Warnings: 2, AffectedRows: 42},
+ elapsed: 0,
+ },
+ }
+ for _, test := range tests {
+ t.Run(test.desc, func(t *testing.T) {
+ mustParseResult(t, test.input)
+ got := formatResult(test.input, &test.elapsed)
+ goldenName := strings.ReplaceAll(test.desc, " ", "-")
+ if golden.ShouldSet() {
+ golden.SetNamed(t, goldenName, []byte(got))
+ }
+ require.Equal(t, string(golden.GetNamed(t, goldenName)), got)
+ })
+ }
+}
+
+func mustParseResult(t *testing.T, result *mysql.Result) {
+ t.Helper()
+ if result.Resultset == nil {
+ return
+ }
+ result.Values = make([][]mysql.FieldValue, len(result.RowDatas))
+ for i := range result.RowDatas {
+ const isBinary = false
+ v, err := result.RowDatas[i].Parse(result.Fields, isBinary, result.Values[i])
+ result.Values[i] = v
+ require.NoError(t, err)
+ }
+}
+
+func newTestServer(t *testing.T) *testServer {
+ t.Helper()
+ ctx := t.Context()
+
+ reuseName := cmp.Or(os.Getenv("MYSQL_TEST_SERVER_REUSE_CONTAINER_BY_NAME"), "default-mysql-test-server")
+ testSrvMu.Lock()
+ // hold the lock for the entire func to avoid parallel container requests
+ defer testSrvMu.Unlock()
+ if testSrv != nil {
+ return testSrv
+ }
+
+ user := cmp.Or(os.Getenv("MYSQL_TEST_SERVER_USER"), "root")
+ db := cmp.Or(os.Getenv("MYSQL_TEST_SERVER_DB"), "mysql")
+ pass := cmp.Or(os.Getenv("MYSQL_TEST_SERVER_PASS"), rand.Text())
+ opts := []testcontainers.ContainerCustomizer{
+ mysqlcontainer.WithDatabase(db),
+ mysqlcontainer.WithUsername(user),
+ mysqlcontainer.WithPassword(pass),
+ testcontainers.WithReuseByName(reuseName),
+ }
+
+ // MySQL 8.4.6 index digest
+ // https://docs.docker.com/dhi/core-concepts/digests/#multi-platform-images-and-manifests
+ // https://hub.docker.com/layers/library/mysql/8.4.6/images/sha256-9b9413211d004062e6d5be1118e1051773fe66d832ec9ebcae1c2fcce5af5f5b
+ const img = "mysql@sha256:d2c60b1b225c6d7845f0abdb596fc35c2d4122bcad6ec219588035a118f75d93"
+ container, err := mysqlcontainer.Run(ctx, img, opts...)
+ require.NoError(t, err)
+
+ host, err := container.Host(ctx)
+ require.NoError(t, err)
+
+ mappedPort, err := container.MappedPort(ctx, "3306/tcp")
+ require.NoError(t, err)
+
+ srv := &testServer{
+ container: container,
+ host: host,
+ port: mappedPort.Port(),
+ username: user,
+ password: pass,
+ database: db,
+ }
+ testSrv = srv
+ return srv
+}
+
+// testServer is a MySQL server for tests.
+type testServer struct {
+ // container is the container hosting the server.
+ container testcontainers.Container
+ // host is the MySQL connection endpoint host.
+ host string
+ // port is the MySQL connection endpoint port.
+ port string
+ // username is the database superuser.
+ username string
+ // password is the database superuser password.
+ password string
+ // database is a database schema provisioned in the database.
+ database string
+}
+
+// hostPort returns the server host:port.
+func (s *testServer) hostPort() string {
+ return net.JoinHostPort(s.host, s.port)
+}
+
+func (s *testServer) connectTCP(t *testing.T, timeout time.Duration) net.Conn {
+ t.Helper()
+ conn, err := net.DialTimeout("tcp", s.hostPort(), timeout)
+ require.NoError(t, err)
+ return conn
+}
+
+func newRecordingClient(t *testing.T, reader io.ReadCloser, writers ...io.Writer) *recordingClient {
+ t.Helper()
+ var buf bytes.Buffer
+ writers = append(writers, &buf)
+ return &recordingClient{
+ ReadCloser: reader,
+ Writer: io.MultiWriter(writers...),
+ buf: &buf,
+ }
+}
+
+type recordingClient struct {
+ io.ReadCloser
+ io.Writer
+ buf *bytes.Buffer
+}
diff --git a/lib/client/db/mysql/repl/testdata/TestREPL/all_data_types/all-data-types.golden b/lib/client/db/mysql/repl/testdata/TestREPL/all_data_types/all-data-types.golden
new file mode 100644
index 00000000000..7c1d023c19a
--- /dev/null
+++ b/lib/client/db/mysql/repl/testdata/TestREPL/all_data_types/all-data-types.golden
@@ -0,0 +1,255 @@
+Teleport MySQL interactive shell (v19.0.0-dev)
+Connected to instance "mysql-test-container" as user "root".
+Type 'help' or '\h' for help.
+
+(none)> -- temporarily disable note warnings for test consistency
+(none)> set session sql_notes = 0;
+
+0 warnings, 0 rows affected
+
+(none)> CREATE DATABASE IF NOT EXISTS all_data_types;
+
+0 warnings, 1 rows affected
+
+(none)> set session sql_notes = 1;
+
+0 warnings, 0 rows affected
+
+(none)> USE all_data_types;
+
+Default database changed to "all_data_types"
+
+all_data_types>
+all_data_types> -- without ANSI_QUOTES double quotes are literals
+all_data_types> SET SESSION sql_mode='';
+
+0 warnings, 0 rows affected
+
+all_data_types> SELECT "thing";
+
+thing
+-------
+'thing'
+
+1 row in set
+
+all_data_types> -- with ANSI_QUOTES double quotes are identifiers, which is what
+ we want when
+all_data_types> -- selecting from the table below
+all_data_types> SET SESSION sql_mode='ANSI_QUOTES';
+
+0 warnings, 0 rows affected
+
+all_data_types> SELECT "thing";
+
+ERR ERROR 1054 (42S22): Unknown column 'thing' in 'field list'
+
+all_data_types>
+all_data_types> CREATE TABLE all_types_demo (
+ -> "c tinyint" TINYINT,
+ -> c_smallint SMALLINT,
+ -> c_mediumint MEDIUMINT,
+ -> c_int INT,
+ -> c_bigint BIGINT,
+ -> c_decimal DECIMAL(10,2),
+ -> c_float FLOAT,
+ -> c_double DOUBLE,
+ -> c_bit BIT(8),
+ -> c_bool BOOL,
+ -> c_date DATE,
+ -> c_time TIME,
+ -> c_datetime DATETIME,
+ -> c_timestamp TIMESTAMP NULL DEFAULT NULL,
+ -> c_year YEAR,
+ -> c_char CHAR(5),
+ -> c_varchar VARCHAR(10),
+ -> c_binary BINARY(3),
+ -> c_varbinary VARBINARY(4),
+ -> c_tinyblob TINYBLOB,
+ -> c_blob BLOB,
+ -> c_mediumblob MEDIUMBLOB,
+ -> c_longblob LONGBLOB,
+ -> c_tinytext TINYTEXT,
+ -> c_text TEXT,
+ -> c_mediumtext MEDIUMTEXT,
+ -> c_longtext LONGTEXT,
+ -> c_enum ENUM('foo','bar','baz'),
+ -> c_set SET('a','b','c'),
+ -> c_geometry GEOMETRY,
+ -> c_point POINT,
+ -> c_linestring LINESTRING,
+ -> c_polygon POLYGON,
+ -> c_multipoint MULTIPOINT,
+ -> c_multilinestring MULTILINESTRING,
+ -> c_multipolygon MULTIPOLYGON,
+ -> c_geometrycoll GEOMETRYCOLLECTION,
+ -> c_json JSON
+ -> );
+
+0 warnings, 0 rows affected
+
+all_data_types>
+all_data_types> INSERT INTO all_types_demo VALUES (
+ -> 127,
+ -> 32767,
+ -> 8388607,
+ -> 2147483647,
+ -> 9223372036854775807,
+ -> 12345.67,
+ -> 3.14,
+ -> 2.7182818284,
+ -> b'10101010',
+ -> 1,
+ -> '2024-07-09',
+ -> '12:34:56',
+ -> '2024-07-09 12:34:56',
+ -> '2024-07-09 12:34:56',
+ -> 2025,
+ -> 'abcde',
+ -> 'abcdefghij',
+ -> 'abc',
+ -> 'defg',
+ -> 'a',
+ -> 'blob',
+ -> 'medblob',
+ -> 'longblob',
+ -> 'tiny',
+ -> 'text',
+ -> 'mediumtext',
+ -> 'longtext',
+ -> 'foo',
+ -> 'a,b',
+ -> ST_GeomFromText('POINT(1 1)'),
+ -> ST_GeomFromText('POINT(2 2)'),
+ -> ST_GeomFromText('LINESTRING(0 0,1 1,2 2)'),
+ -> ST_GeomFromText('POLYGON((0 0,1 0,1 1,0 1,0 0))'),
+ -> ST_GeomFromText('MULTIPOINT((0 0),(1 1))'),
+ -> ST_GeomFromText('MULTILINESTRING((0 0,1 1),(2 2,3 3))'),
+ -> ST_GeomFromText('MULTIPOLYGON(((0 0,1 0,1 1,0 1,0 0)))'),
+ -> ST_GeomFromText('GEOMETRYCOLLECTION(POINT(1 2),LINESTRING(0
+0,1 1))'),
+ -> JSON_OBJECT('x', 1, 'y', 2)
+ -> );
+
+0 warnings, 1 rows affected
+
+all_data_types>
+all_data_types> SELECT "c tinyint", `c_smallint`, "c_mediumint" FROM `all_types_
+demo`;
+
+c tinyint c_smallint c_mediumint
+--------- ---------- -----------
+127 32767 8388607
+
+1 row in set
+
+all_data_types> SELECT c_int, c_bigint, c_decimal FROM all_types_demo;
+
+c_int c_bigint c_decimal
+---------- ------------------- ----------
+2147483647 9223372036854775807 '12345.67'
+
+1 row in set
+
+all_data_types> SELECT c_float, c_double, c_bit FROM all_types_demo;
+
+c_float c_double c_bit
+------- ------------ -----
+3.14 2.7182818284 'ª'
+
+1 row in set
+
+all_data_types> SELECT c_bool, c_date, c_time FROM all_types_demo;
+
+c_bool c_date c_time
+------ ------------ ----------
+1 '2024-07-09' '12:34:56'
+
+1 row in set
+
+all_data_types> SELECT c_datetime, c_timestamp, c_year FROM all_types_demo;
+
+c_datetime c_timestamp c_year
+--------------------- --------------------- ------
+'2024-07-09 12:34:56' '2024-07-09 12:34:56' 2025
+
+1 row in set
+
+all_data_types> SELECT c_char, c_varchar, c_binary FROM all_types_demo;
+
+c_char c_varchar c_binary
+------- ------------ --------
+'abcde' 'abcdefghij' 'abc'
+
+1 row in set
+
+all_data_types> SELECT c_varbinary, c_tinyblob, c_blob FROM all_types_demo;
+
+c_varbinary c_tinyblob c_blob
+----------- ---------- ------
+'defg' 'a' 'blob'
+
+1 row in set
+
+all_data_types> SELECT "c_mediumblob",
+ -> `c_longblob`,
+ -> c_tinytext
+ -> FROM all_types_demo;
+
+c_mediumblob c_longblob c_tinytext
+------------ ---------- ----------
+'medblob' 'longblob' 'tiny'
+
+1 row in set
+
+all_data_types> SELECT c_text, c_mediumtext, c_longtext FROM all_types_demo;
+
+c_text c_mediumtext c_longtext
+------ ------------ ----------
+'text' 'mediumtext' 'longtext'
+
+1 row in set
+
+all_data_types> SELECT c_enum, c_set, ST_AsText(c_geometry) AS c_geometry FROM a
+ll_types_demo;
+
+c_enum c_set c_geometry
+------ ----- ------------
+'foo' 'a,b' 'POINT(1 1)'
+
+1 row in set
+
+all_data_types> SELECT ST_AsText(c_point) AS c_point, ST_AsText(c_linestring) AS
+ c_linestring, ST_AsText(c_polygon) AS c_polygon FROM all_types_demo;
+
+c_point c_linestring c_polygon
+------------ ------------------------- --------------------------------
+'POINT(2 2)' 'LINESTRING(0 0,1 1,2 2)' 'POLYGON((0 0,1 0,1 1,0 1,0 0))'
+
+1 row in set
+
+all_data_types> SELECT ST_AsText(c_multipoint) AS c_multipoint, ST_AsText(c_mult
+ilinestring) AS c_multilinestring, ST_AsText(c_multipolygon) AS c_multipolygon F
+ROM all_types_demo;
+
+c_multipoint c_multilinestring c_multipolygon
+------------------------- -------------------------------------- ---------------------------------------
+'MULTIPOINT((0 0),(1 1))' 'MULTILINESTRING((0 0,1 1),(2 2,3 3))' 'MULTIPOLYGON(((0 0,1 0,1 1,0 1,0 0)))'
+
+1 row in set
+
+all_data_types> SELECT ST_AsText(c_geometrycoll) AS c_geometrycoll, c_json FROM
+all_types_demo;
+
+c_geometrycoll c_json
+---------------------------------------------------- ------------------
+'GEOMETRYCOLLECTION(POINT(1 2),LINESTRING(0 0,1 1))' '{"x": 1, "y": 2}'
+
+1 row in set
+
+all_data_types> DROP DATABASE all_data_types;
+
+0 warnings, 1 rows affected
+
+all_data_types>
+all_data_types>
\ No newline at end of file
diff --git a/lib/client/db/mysql/repl/testdata/TestREPL/commands/commands.golden b/lib/client/db/mysql/repl/testdata/TestREPL/commands/commands.golden
new file mode 100644
index 00000000000..130a589162c
--- /dev/null
+++ b/lib/client/db/mysql/repl/testdata/TestREPL/commands/commands.golden
@@ -0,0 +1,325 @@
+Teleport MySQL interactive shell (v19.0.0-dev)
+Connected to instance "mysql-test-container" as user "root".
+Type 'help' or '\h' for help.
+
+(none)> -- temporarily disable note warnings for test consistency
+(none)> set session sql_notes = 0;
+
+0 warnings, 0 rows affected
+
+(none)> create database if not exists test_commands;
+
+0 warnings, 1 rows affected
+
+(none)> create database if not exists foo;
+
+0 warnings, 1 rows affected
+
+(none)> create database if not exists bar;
+
+0 warnings, 1 rows affected
+
+(none)> set session sql_notes = 1;
+
+0 warnings, 0 rows affected
+
+(none)> use test_commands;
+
+Default database changed to "test_commands"
+
+test_commands>
+test_commands> -- leading spaces/comments before commands
+test_commands> /* just a comment */
+test_commands> use foo;
+
+Default database changed to "foo"
+
+foo> use foo
+
+Default database changed to "foo"
+
+foo> /*
+ /*> llama
+ /*> */
+foo> use foo;
+
+Default database changed to "foo"
+
+foo> /*
+ /*> llama
+ /*> */
+foo> use foo
+
+Default database changed to "foo"
+
+foo> -- llama
+foo> use foo;
+
+Default database changed to "foo"
+
+foo> -- llama
+foo> use foo
+
+Default database changed to "foo"
+
+foo> -- llama
+foo> use foo;
+
+Default database changed to "foo"
+
+foo> -- llama
+foo> use foo
+
+Default database changed to "foo"
+
+foo> /* llama */use foo;
+
+Default database changed to "foo"
+
+foo> /* llama */use foo
+
+Default database changed to "foo"
+
+foo> /* llama */ use foo;
+
+Default database changed to "foo"
+
+foo> /* llama */ use foo
+
+Default database changed to "foo"
+
+foo> /* llama */
+foo> use foo;
+
+Default database changed to "foo"
+
+foo> /* llama */
+foo> use foo
+
+Default database changed to "foo"
+
+foo> /* llama
+ /*> */use foo;
+
+Default database changed to "foo"
+
+foo> /* llama
+ /*> */use foo
+
+Default database changed to "foo"
+
+foo> /* llama
+ /*> */ use foo;
+
+Default database changed to "foo"
+
+foo> /* llama
+ /*> */ use foo
+
+Default database changed to "foo"
+
+foo> /* llama
+ /*> -- llama
+ /*> */use foo;
+
+Default database changed to "foo"
+
+foo> /* llama
+ /*> -- llama
+ /*> */use foo
+
+Default database changed to "foo"
+
+foo> /* llama
+ /*> -- llama
+ /*> */ use foo;
+
+Default database changed to "foo"
+
+foo> /* llama
+ /*> -- llama
+ /*> */ use foo
+
+Default database changed to "foo"
+
+foo> /* llama
+ /*> -- */use foo;
+
+Default database changed to "foo"
+
+foo> /* llama
+ /*> -- */use foo
+
+Default database changed to "foo"
+
+foo> /* llama
+ /*> -- */ use foo;
+
+Default database changed to "foo"
+
+foo> /* llama
+ /*> -- */ use foo
+
+Default database changed to "foo"
+
+foo> /* llama */
+foo>
+foo> use foo;use bar; use foo
+
+Default database changed to "foo"
+
+
+Default database changed to "bar"
+
+
+Default database changed to "foo"
+
+foo> -- this isn't a command, but that should just print an error message and he
+lp info.
+foo> \xyz select 1
+
+Unknown command.
+delimiter \d Set statement delimiter. With no argument the delimiter resets to the default.
+help \h Show the list of supported commands.
+quit \q Terminates the session.
+status \s Get status information.
+teleport \t Show Teleport interactive shell information, such as execution limitations.
+use \u Use another database.
+
+foo> \xyz;select 1;
+
+Unknown command.
+delimiter \d Set statement delimiter. With no argument the delimiter resets to the default.
+help \h Show the list of supported commands.
+quit \q Terminates the session.
+status \s Get status information.
+teleport \t Show Teleport interactive shell information, such as execution limitations.
+use \u Use another database.
+
+
+1
+-
+1
+
+1 row in set
+
+foo> delimiter $$
+foo> help$$
+
+delimiter \d Set statement delimiter. With no argument the delimiter resets to the default.
+help \h Show the list of supported commands.
+quit \q Terminates the session.
+status \s Get status information.
+teleport \t Show Teleport interactive shell information, such as execution limitations.
+use \u Use another database.
+
+foo> \d //
+foo> \h
+
+delimiter \d Set statement delimiter. With no argument the delimiter resets to the default.
+help \h Show the list of supported commands.
+quit \q Terminates the session.
+status \s Get status information.
+teleport \t Show Teleport interactive shell information, such as execution limitations.
+use \u Use another database.
+
+foo> help
+
+delimiter \d Set statement delimiter. With no argument the delimiter resets to the default.
+help \h Show the list of supported commands.
+quit \q Terminates the session.
+status \s Get status information.
+teleport \t Show Teleport interactive shell information, such as execution limitations.
+use \u Use another database.
+
+foo> status
+
+Teleport database: mysql-test-container
+Current database: foo
+Current user: root
+Server version: 8.4.6
+Using delimiter: //
+
+foo> \s
+
+Teleport database: mysql-test-container
+Current database: foo
+Current user: root
+Server version: 8.4.6
+Using delimiter: //
+
+foo> teleport
+
+Teleport MySQL interactive shell (v19.0.0-dev)
+
+Limitations:
+- Query cancellation is not supported. Once a query is sent, its execution
+ can only be canceled by killing it from another session.
+ You can identify the query process ID with "SHOW PROCESSLIST" and then kill the
+ query with "KILL QUERY ".
+
+- Pressing CTRL-C will have no effect in this shell.
+
+foo> \t
+
+Teleport MySQL interactive shell (v19.0.0-dev)
+
+Limitations:
+- Query cancellation is not supported. Once a query is sent, its execution
+ can only be canceled by killing it from another session.
+ You can identify the query process ID with "SHOW PROCESSLIST" and then kill the
+ query with "KILL QUERY ".
+
+- Pressing CTRL-C will have no effect in this shell.
+
+foo> use foo
+
+Default database changed to "foo"
+
+foo> \u mysql//
+
+Default database changed to "mysql"
+
+mysql> -- reset the delimiter to default ;
+mysql> \d
+mysql> teleport;\s;\h
+
+Teleport MySQL interactive shell (v19.0.0-dev)
+
+Limitations:
+- Query cancellation is not supported. Once a query is sent, its execution
+ can only be canceled by killing it from another session.
+ You can identify the query process ID with "SHOW PROCESSLIST" and then kill the
+ query with "KILL QUERY ".
+
+- Pressing CTRL-C will have no effect in this shell.
+
+
+Teleport database: mysql-test-container
+Current database: mysql
+Current user: root
+Server version: 8.4.6
+Using delimiter: ;
+
+
+delimiter \d Set statement delimiter. With no argument the delimiter resets to the default.
+help \h Show the list of supported commands.
+quit \q Terminates the session.
+status \s Get status information.
+teleport \t Show Teleport interactive shell information, such as execution limitations.
+use \u Use another database.
+
+mysql> use `bar`;drop database foo;drop database bar;drop database test_commands
+;quit
+
+Default database changed to "bar"
+
+
+0 warnings, 0 rows affected
+
+
+0 warnings, 0 rows affected
+
+
+0 warnings, 0 rows affected
+
diff --git a/lib/client/db/mysql/repl/testdata/TestREPL/ddl/ddl.golden b/lib/client/db/mysql/repl/testdata/TestREPL/ddl/ddl.golden
new file mode 100644
index 00000000000..f15877eec43
--- /dev/null
+++ b/lib/client/db/mysql/repl/testdata/TestREPL/ddl/ddl.golden
@@ -0,0 +1,221 @@
+Teleport MySQL interactive shell (v19.0.0-dev)
+Connected to instance "mysql-test-container" as user "root".
+Type 'help' or '\h' for help.
+
+(none)> -- temporarily disable note warnings for test consistency
+(none)> set session sql_notes = 0;
+
+0 warnings, 0 rows affected
+
+(none)> DROP DATABASE IF EXISTS test_ddl_db;
+
+0 warnings, 0 rows affected
+
+(none)> CREATE DATABASE test_ddl_db;
+
+0 warnings, 1 rows affected
+
+(none)> set session sql_notes = 1;
+
+0 warnings, 0 rows affected
+
+(none)> -- this should produce a note level warning because it does exist
+(none)> CREATE DATABASE if not exists test_ddl_db;
+
+1 warnings, 1 rows affected
+
+(none)> -- we clear the warning by selecting the warning count
+(none)> select @@warning_count;
+
+@@warning_count
+---------------
+1
+
+1 row in set
+
+(none)> USE test_ddl_db;
+
+Default database changed to "test_ddl_db"
+
+test_ddl_db>
+test_ddl_db> CREATE TABLE test_table (
+ -> id INT PRIMARY KEY AUTO_INCREMENT,
+ -> value VARCHAR(50) NOT NULL,
+ -> note VARCHAR(100) DEFAULT NULL
+ -> ) ENGINE=InnoDB;
+
+0 warnings, 0 rows affected
+
+test_ddl_db>
+test_ddl_db> CREATE VIEW test_view AS (
+ -> SELECT id, value
+ -> FROM test_table
+ -> );
+
+0 warnings, 0 rows affected
+
+test_ddl_db>
+test_ddl_db> DELIMITER $$
+test_ddl_db> CREATE FUNCTION test_func(x INT) RETURNS INT DETERMINISTIC
+ -> BEGIN
+ -> RETURN x * 2;
+ -> END $$
+
+0 warnings, 0 rows affected
+
+test_ddl_db> DELIMITER ;
+test_ddl_db>
+test_ddl_db> DELIMITER $$
+test_ddl_db> CREATE PROCEDURE test_proc()
+ -> BEGIN
+ -> INSERT INTO test_table(value) VALUES ('from proc');
+ -> END $$
+
+0 warnings, 0 rows affected
+
+test_ddl_db> DELIMITER ;
+test_ddl_db>
+test_ddl_db> CALL test_proc();
+
+0 warnings, 1 rows affected
+
+test_ddl_db>
+test_ddl_db> DELIMITER $$
+test_ddl_db> CREATE TRIGGER test_trigger_before_insert
+ -> BEFORE INSERT ON test_table
+ -> FOR EACH ROW
+ -> BEGIN
+ -> SET NEW.value = UPPER(NEW.value);
+ -> END $$
+
+0 warnings, 0 rows affected
+
+test_ddl_db> DELIMITER ;
+test_ddl_db>
+test_ddl_db> CALL test_proc();
+
+0 warnings, 1 rows affected
+
+test_ddl_db>
+test_ddl_db> CREATE EVENT test_event
+ -> ON SCHEDULE AT CURRENT_TIMESTAMP + INTERVAL 1 HOUR
+ -> DO INSERT INTO test_table(value) VALUES ('from event');
+
+0 warnings, 0 rows affected
+
+test_ddl_db>
+test_ddl_db> set session sql_notes = 0;
+
+0 warnings, 0 rows affected
+
+test_ddl_db> -- roles are not database scoped, let's disable note warnings in ca
+se some other
+test_ddl_db> -- test has already created the role or user.
+test_ddl_db> CREATE ROLE IF NOT EXISTS test_role;
+
+0 warnings, 0 rows affected
+
+test_ddl_db> DROP ROLE test_role;
+
+0 warnings, 0 rows affected
+
+test_ddl_db>
+test_ddl_db> CREATE USER IF NOT EXISTS test_user@'localhost'
+ -> IDENTIFIED BY 'some password'
+ -> ACCOUNT LOCK;
+
+0 warnings, 0 rows affected
+
+test_ddl_db> ALTER USER test_user@'localhost' PASSWORD EXPIRE;
+
+0 warnings, 0 rows affected
+
+test_ddl_db> DROP USER test_user@'localhost';
+
+0 warnings, 0 rows affected
+
+test_ddl_db> set session sql_notes = 1;
+
+0 warnings, 0 rows affected
+
+test_ddl_db>
+test_ddl_db> ALTER DATABASE test_ddl_db CHARACTER SET utf8mb4;
+
+0 warnings, 1 rows affected
+
+test_ddl_db> ALTER TABLE test_table ADD COLUMN note2 VARCHAR(100) DEFAULT NULL;
+
+0 warnings, 0 rows affected
+
+test_ddl_db> ALTER EVENT test_event DISABLE;
+
+0 warnings, 0 rows affected
+
+test_ddl_db> ALTER FUNCTION test_func COMMENT 'Test function';
+
+0 warnings, 0 rows affected
+
+test_ddl_db> ALTER PROCEDURE test_proc COMMENT 'Test procedure';
+
+0 warnings, 0 rows affected
+
+test_ddl_db>
+test_ddl_db> RENAME TABLE test_table
+ -> TO test_table_renamed;
+
+0 warnings, 0 rows affected
+
+test_ddl_db> SELECT * from test_table_renamed;
+
+id value note note2
+-- ----------- ---- -----
+1 'from proc' NULL NULL
+2 'FROM PROC' NULL NULL
+
+2 rows in set
+
+test_ddl_db>
+test_ddl_db> RENAME
+ -> TABLE test_table_renamed
+ -> TO test_table;
+
+0 warnings, 0 rows affected
+
+test_ddl_db>
+test_ddl_db> SELECT * from test_table;
+
+id value note note2
+-- ----------- ---- -----
+1 'from proc' NULL NULL
+2 'FROM PROC' NULL NULL
+
+2 rows in set
+
+test_ddl_db>
+test_ddl_db> -- there is no "test_server", this should make an error.
+test_ddl_db> ALTER SERVER test_server OPTIONS (HOST '127.0.0.1');
+
+ERR ERROR 1477 (HY000): The foreign server name you are trying to reference does not exist. Data source error: test_server
+
+test_ddl_db>
+test_ddl_db> DROP TRIGGER test_trigger_before_insert;
+
+0 warnings, 0 rows affected
+
+test_ddl_db>
+test_ddl_db> TRUNCATE TABLE test_table ;
+
+0 warnings, 0 rows affected
+
+test_ddl_db> DROP TABLE test_table;
+
+0 warnings, 0 rows affected
+
+test_ddl_db>
+test_ddl_db> DROP DATABASE test_ddl_db
+ -> ;
+
+0 warnings, 1 rows affected
+
+test_ddl_db>
+test_ddl_db>
\ No newline at end of file
diff --git a/lib/client/db/mysql/repl/testdata/TestREPL/literals/literals.golden b/lib/client/db/mysql/repl/testdata/TestREPL/literals/literals.golden
new file mode 100644
index 00000000000..0acea33bf3b
--- /dev/null
+++ b/lib/client/db/mysql/repl/testdata/TestREPL/literals/literals.golden
@@ -0,0 +1,154 @@
+Teleport MySQL interactive shell (v19.0.0-dev)
+Connected to instance "mysql-test-container" as user "root".
+Type 'help' or '\h' for help.
+
+(none)> -- with ANSI_QUOTES double quotes are identifiers
+(none)> SET SESSION sql_mode='ANSI_QUOTES';
+
+0 warnings, 0 rows affected
+
+(none)> SELECT "thing";
+
+ERR ERROR 1054 (42S22): Unknown column 'thing' in 'field list'
+
+(none)> -- without ANSI_QUOTES double quotes are literals
+(none)> SET SESSION sql_mode='';
+
+0 warnings, 0 rows affected
+
+(none)> SELECT "thing";
+
+thing
+-------
+'thing'
+
+1 row in set
+
+(none)> select 1, -1,1.24, -1.24, 'foo', null, 'null';
+
+1 -1 1.24 -1.24 foo NULL null
+- -- ------ ------- ----- ---- ------
+1 -1 '1.24' '-1.24' 'foo' NULL 'null'
+
+1 row in set
+
+(none)> select
+ -> 1 as 'unsigned int',
+ -> -1 as 'negative int',
+ -> 1.24 as "float",
+ -> -1.24 as 'negative float',
+ -> 'foo' as 'string',
+ -> null as 'actual NULL',
+ -> "null" as 'string null';
+
+unsigned int negative int float negative float string actual NULL string null
+------------ ------------ ------ -------------- ------ ----------- -----------
+1 -1 '1.24' '-1.24' 'foo' NULL 'null'
+
+1 row in set
+
+(none)>
+(none)> select 1 as 'multiple rows of literals'
+ -> union
+ -> select -1
+ -> union
+ -> select 1.24
+ -> union
+ -> select -1.24
+ -> union
+ -> select 'foo'
+ -> union
+ -> select null
+ -> union
+ -> select 'null';
+
+multiple rows of literals
+-------------------------
+'1'
+'-1'
+'1.24'
+'-1.24'
+'foo'
+NULL
+'null'
+
+7 rows in set
+
+(none)>
+(none)> select 'a
+ '> b
+ '> c' as 'multiline-string';
+
+multiline-string
+----------------
+'a
+b
+c'
+
+1 row in set
+
+(none)>
+(none)> select 'a
+ '> ;
+ '> b
+ '> ;
+ '> c' as 'multiline with delimiters';
+
+multiline with delimiters
+-------------------------
+'a
+ ;
+ b
+ ;
+c'
+
+1 row in set
+
+(none)>
+(none)> /* foo bar */
+(none)> select 1;
+
+1
+-
+1
+
+1 row in set
+
+(none)> /* foo bar */select /*
+ /*> baz */ 42;
+
+42
+--
+42
+
+1 row in set
+
+(none)>
+(none)> delimiter $$
+(none)> select '$$1$$'$$
+
+$$1$$
+-------
+'$$1$$'
+
+1 row in set
+
+(none)> select '$$;$$';$$
+
+$$;$$
+-------
+'$$;$$'
+
+1 row in set
+
+(none)> delimiter ;
+(none)> select 1;
+
+1
+-
+1
+
+1 row in set
+
+(none)>
+(none)>
\ No newline at end of file
diff --git a/lib/client/db/mysql/repl/testdata/Test_formatResult/empty_set/empty-set.golden b/lib/client/db/mysql/repl/testdata/Test_formatResult/empty_set/empty-set.golden
new file mode 100644
index 00000000000..4883fe4c84b
--- /dev/null
+++ b/lib/client/db/mysql/repl/testdata/Test_formatResult/empty_set/empty-set.golden
@@ -0,0 +1 @@
+Empty set (0.04 sec)
\ No newline at end of file
diff --git a/lib/client/db/mysql/repl/testdata/Test_formatResult/long_elapsed_time/long-elapsed-time.golden b/lib/client/db/mysql/repl/testdata/Test_formatResult/long_elapsed_time/long-elapsed-time.golden
new file mode 100644
index 00000000000..6f8d49064fe
--- /dev/null
+++ b/lib/client/db/mysql/repl/testdata/Test_formatResult/long_elapsed_time/long-elapsed-time.golden
@@ -0,0 +1,7 @@
+one two three
+--- --- -----
+1 2.2 '3'
+4 5.5 '6'
+7 8.8 '8'
+
+3 rows in set (123.46 sec)
\ No newline at end of file
diff --git a/lib/client/db/mysql/repl/testdata/Test_formatResult/nil_set/nil-set.golden b/lib/client/db/mysql/repl/testdata/Test_formatResult/nil_set/nil-set.golden
new file mode 100644
index 00000000000..3861e046317
--- /dev/null
+++ b/lib/client/db/mysql/repl/testdata/Test_formatResult/nil_set/nil-set.golden
@@ -0,0 +1 @@
+2 warnings, 42 rows affected (0.00 sec)
\ No newline at end of file
diff --git a/lib/client/db/mysql/repl/testdata/Test_formatResult/result_rows/result-rows.golden b/lib/client/db/mysql/repl/testdata/Test_formatResult/result_rows/result-rows.golden
new file mode 100644
index 00000000000..efb91d451b8
--- /dev/null
+++ b/lib/client/db/mysql/repl/testdata/Test_formatResult/result_rows/result-rows.golden
@@ -0,0 +1,7 @@
+one two three
+--- --- -----
+1 2.2 '3'
+4 5.5 '6'
+7 8.8 '8'
+
+3 rows in set (1.23 sec)
\ No newline at end of file
diff --git a/lib/client/db/mysql/repl/token.go b/lib/client/db/mysql/repl/token.go
new file mode 100644
index 00000000000..46722ec9fd3
--- /dev/null
+++ b/lib/client/db/mysql/repl/token.go
@@ -0,0 +1,53 @@
+// Teleport
+// Copyright (C) 2025 Gravitational, Inc.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package repl
+
+// token is a lexical token.
+type token struct {
+ // kind is a token kind enum.
+ kind tokenKind
+ // text is the text that makes up the token.
+ text string
+}
+
+type tokenKind int
+
+const (
+ // EOF indicates end of token stream
+ tokenEOF tokenKind = iota
+
+ // whitespace
+ tokenSpace
+
+ // escape
+ tokenBackslash // \
+
+ // quotation syntax
+ tokenSingleQuote // '
+ tokenDoubleQuote // "
+ tokenBacktick // `
+
+ // single-line comments
+ tokenSingleComment // -- or #
+
+ // multi-line comment open/close
+ tokenOpenComment // /*
+ tokenCloseComment // */
+
+ // misc text that isn't special to us
+ tokenText
+)
diff --git a/lib/service/service.go b/lib/service/service.go
index 14e0759476c..871dc35a536 100644
--- a/lib/service/service.go
+++ b/lib/service/service.go
@@ -111,6 +111,7 @@ import (
_ "github.com/gravitational/teleport/lib/backend/pgbk"
"github.com/gravitational/teleport/lib/bpf"
"github.com/gravitational/teleport/lib/cache"
+ myrepl "github.com/gravitational/teleport/lib/client/db/mysql/repl"
pgrepl "github.com/gravitational/teleport/lib/client/db/postgres/repl"
dbrepl "github.com/gravitational/teleport/lib/client/db/repl"
"github.com/gravitational/teleport/lib/cloud"
@@ -1163,6 +1164,7 @@ func NewTeleport(cfg *servicecfg.Config) (_ *TeleportProcess, err error) {
cfg.DatabaseREPLRegistry = dbrepl.NewREPLGetter(map[string]dbrepl.REPLNewFunc{
defaults.ProtocolPostgres: pgrepl.New,
defaults.ProtocolCockroachDB: pgrepl.New,
+ defaults.ProtocolMySQL: myrepl.New,
})
}