diff --git a/Makefile b/Makefile index d23f7d8a218..130d9aa7797 100644 --- a/Makefile +++ b/Makefile @@ -143,20 +143,20 @@ endif install: build # binaries mkdir -p "$${PREFIX}/bin" - cp "$${VTROOTBIN}/"{mysqlctl,mysqlctld,vtorc,vtadmin,vtctl,vtctld,vtctlclient,vtctldclient,vtgate,vttablet,vtbackup,vtexplain} "$${PREFIX}/bin/" + cp "$${VTROOTBIN}/"{mysqlctl,mysqlctld,vtorc,vtadmin,vtadmin2,vtctl,vtctld,vtctlclient,vtctldclient,vtgate,vttablet,vtbackup,vtexplain} "$${PREFIX}/bin/" # Will only work inside the docker bootstrap for now cross-install: cross-build # binaries mkdir -p "$${PREFIX}/bin" - cp "${VTROOTBIN}/${GOOS}_${GOARCH}/"{mysqlctl,mysqlctld,vtorc,vtadmin,vtctld,vtctlclient,vtctldclient,vtgate,vttablet,vtbackup} "$${PREFIX}/bin/" + cp "${VTROOTBIN}/${GOOS}_${GOARCH}/"{mysqlctl,mysqlctld,vtorc,vtadmin,vtadmin2,vtctld,vtctlclient,vtctldclient,vtgate,vttablet,vtbackup} "$${PREFIX}/bin/" # Install local install the binaries needed to run vitess locally # Usage: make install-local PREFIX=/path/to/install/root install-local: build # binaries mkdir -p "$${PREFIX}/bin" - cp "$${VTROOT}/bin/"{mysqlctl,mysqlctld,vtorc,vtadmin,vtctl,vtctld,vtctlclient,vtctldclient,vtgate,vttablet,vtbackup} "$${PREFIX}/bin/" + cp "$${VTROOT}/bin/"{mysqlctl,mysqlctld,vtorc,vtadmin,vtadmin2,vtctl,vtctld,vtctlclient,vtctldclient,vtgate,vttablet,vtbackup} "$${PREFIX}/bin/" # install copies the files needed to run test Vitess using vtcombo into the given directory tree. diff --git a/changelog/25.0/25.0.0/summary.md b/changelog/25.0/25.0.0/summary.md index 7af1ffd77aa..611190bd996 100644 --- a/changelog/25.0/25.0.0/summary.md +++ b/changelog/25.0/25.0.0/summary.md @@ -44,6 +44,8 @@ - [ApplySchema session variables](#vttablet-applyschema-session-variables) - **[VTCtld](#minor-changes-vtctld)** - [MySQL version-aware reparent candidate election](#vtctld-version-aware-reparent) + - **[VTAdmin](#minor-changes-vtadmin)** + - [Experimental server-rendered VTAdmin UI (vtadmin2)](#vtadmin2-experimental-ui) - **[Backup/Restore](#minor-changes-backup)** - [Chunked backup/restore for the builtinbackupengine](#backup-chunked-builtin) - [Slow clean mysqld shutdowns no longer fail backups](#backup-mysqld-shutdown-timeout) @@ -464,6 +466,21 @@ The same caveat applies to a `REPLICA` that is *excluded from election* — beca See [#20211](https://github.com/vitessio/vitess/pull/20211) for details. +### VTAdmin + +#### Experimental server-rendered VTAdmin UI (vtadmin2) + +VTAdmin now ships an experimental server-rendered UI alongside the existing React SPA. The React UI remains the default. + +There are two opt-in ways to run it: + +- Integrated: `vtadmin --ui=vtadmin2` serves the HTML UI (default listen address `:14202`, overridable with `--ui-addr`). `--ui-read-only` hides mutating actions. `--ui-debug-json` enables `?format=json` page dumps. `--ui-trust-proxy-https` marks cookies `Secure` when `X-Forwarded-Proto: https` is present; only enable this behind a trusted HTTPS-terminating proxy, because that header is otherwise spoofable. +- Standalone: the `vtadmin2` binary is now included in install and release packages. Example local clusters start it by default unless `SKIP_VTADMIN2` is set. + +The UI is experimental: it covers the operator pages and actions needed to manage a cluster without the React frontend toolchain, but it is not a complete replacement yet. RBAC still applies through the backing VTAdmin API. Creating a VDiff now requires a cluster `put` action (it previously accepted cluster `get`). Concluding an unresolved transaction also requires cluster `put` rather than a read. Reloading schema on a shard requires the schema `reload` action. + +See [#20107](https://github.com/vitessio/vitess/issues/20107) and [#20946](https://github.com/vitessio/vitess/pull/20946). + ### Backup/Restore #### Chunked backup/restore for the `builtinbackupengine` diff --git a/examples/common/scripts/vtadmin-down.sh b/examples/common/scripts/vtadmin-down.sh index c592f0991ee..d1ebbf88b3b 100755 --- a/examples/common/scripts/vtadmin-down.sh +++ b/examples/common/scripts/vtadmin-down.sh @@ -16,5 +16,6 @@ source "$(dirname "${BASH_SOURCE[0]:-$0}")/../env.sh" +stop_process "vtadmin2" "$VTDATAROOT/tmp/vtadmin2.pid" stop_process "vtadmin-web" "$VTDATAROOT/tmp/vtadmin-web.pid" stop_process "vtadmin-api" "$VTDATAROOT/tmp/vtadmin-api.pid" diff --git a/examples/common/scripts/vtadmin2-up.sh b/examples/common/scripts/vtadmin2-up.sh new file mode 100755 index 00000000000..e8b03b79640 --- /dev/null +++ b/examples/common/scripts/vtadmin2-up.sh @@ -0,0 +1,58 @@ +#!/bin/bash + +# Copyright 2026 The Vitess Authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +function output() { + echo -e "$@" +} + +script_dir="$(dirname "${BASH_SOURCE[0]:-$0}")" +source "${script_dir}/../env.sh" + +cluster_name="local" +log_dir="${VTDATAROOT}/tmp" +vtadmin2_port=14202 + +case_insensitive_hostname=$(echo "$hostname" | tr '[:upper:]' '[:lower:]') + +output "\n\033[1;32mStarting vtadmin2 on http://${case_insensitive_hostname}:${vtadmin2_port}\033[0m" + +vtadmin2 \ + --addr "${case_insensitive_hostname}:${vtadmin2_port}" \ + --logtostderr \ + --alsologtostderr \ + --rbac \ + --rbac-config="${script_dir}/../vtadmin/rbac.yaml" \ + --cluster "id=${cluster_name},name=${cluster_name},discovery=staticfile,discovery-staticfile-path=${script_dir}/../vtadmin/discovery.json,tablet-fqdn-tmpl=http://{{ .Tablet.Hostname }}:15{{ .Tablet.Alias.Uid }},schema-cache-default-expiration=1m" \ + > "${log_dir}/vtadmin2.out" 2>&1 & + +vtadmin2_pid=$! +echo ${vtadmin2_pid} > "${log_dir}/vtadmin2.pid" + +for _ in {0..100}; do + if curl -s "http://${case_insensitive_hostname}:${vtadmin2_port}/clusters" | grep -q "${cluster_name}"; then + break + fi + sleep 0.1 +done + +curl -s "http://${case_insensitive_hostname}:${vtadmin2_port}/clusters" | grep -q "${cluster_name}" || fail "vtadmin2 failed to discover the running example Vitess cluster." + +echo "\ +vtadmin2 is running! + - Browser: http://${case_insensitive_hostname}:${vtadmin2_port} + - Logs: ${log_dir}/vtadmin2.out + - PID: ${vtadmin2_pid} +" diff --git a/examples/local/101_initial_cluster.sh b/examples/local/101_initial_cluster.sh index 71bb1714e23..ed92a711899 100755 --- a/examples/local/101_initial_cluster.sh +++ b/examples/local/101_initial_cluster.sh @@ -91,3 +91,9 @@ else ../common/scripts/vtadmin-up.sh fi +# start vtadmin2 +if [[ -n ${SKIP_VTADMIN2} ]]; then + echo -e "\nSkipping VTAdmin2! If this is not what you want then please unset the SKIP_VTADMIN2 env variable in your shell." +else + ../common/scripts/vtadmin2-up.sh +fi diff --git a/go/cmd/vtadmin/main.go b/go/cmd/vtadmin/main.go index cd6516ab8f8..d7ae9132003 100644 --- a/go/cmd/vtadmin/main.go +++ b/go/cmd/vtadmin/main.go @@ -17,10 +17,15 @@ limitations under the License. package main import ( + "context" + "errors" "flag" "fmt" "io" + "log/slog" + "net/http" "os" + "syscall" "time" "github.com/spf13/cobra" @@ -38,6 +43,7 @@ import ( vtadminhttp "vitess.io/vitess/go/vt/vtadmin/http" "vitess.io/vitess/go/vt/vtadmin/http/debug" "vitess.io/vitess/go/vt/vtadmin/rbac" + "vitess.io/vitess/go/vt/vtadmin/vtadmin2" "vitess.io/vitess/go/vt/vtctl/grpcclientcommon" "vitess.io/vitess/go/vt/vtenv" ) @@ -56,11 +62,22 @@ var ( cacheRefreshKey string + // Server-rendered UI (vtadmin2) options. + ui string + uiAddr string + uiReadOnly bool + uiDebugJSON bool + uiTrustProxy bool + traceCloser io.Closer = &noopCloser{} rootCmd = &cobra.Command{ Use: "vtadmin", PreRunE: func(cmd *cobra.Command, args []string) error { + if err := validateUIOptions(ui, enableDynamicClusters); err != nil { + return err + } + _flag.TrickGlog() if err := log.Init(cmd.Flags()); err != nil { @@ -107,6 +124,16 @@ func startTracing(cmd *cobra.Command) { traceCloser = trace.StartTracing("vtadmin") } +func validateUIOptions(ui string, enableDynamicClusters bool) error { + if ui != "react" && ui != "vtadmin2" { + return fmt.Errorf("invalid --ui value %q: want react or vtadmin2", ui) + } + if ui == "vtadmin2" && enableDynamicClusters { + return errors.New("--enable-dynamic-clusters is not supported with --ui=vtadmin2") + } + return nil +} + func run(cmd *cobra.Command, args []string) { bootSpan, ctx := trace.NewSpan(cmd.Context(), "vtadmin.boot") defer bootSpan.Finish() @@ -164,6 +191,54 @@ func run(cmd *cobra.Command, args []string) { RBAC: rbacConfig, EnableDynamicClusters: enableDynamicClusters, }) + + // The vtadmin2 server reuses the same API implementation (and its RBAC + // enforcement) and runs alongside the JSON API on its own address, so both + // UIs can be served during the migration. + if ui == "vtadmin2" { + uiServer, err := vtadmin2.NewServer(s, vtadmin2.Options{ + Addr: uiAddr, + ReadOnly: uiReadOnly, + DocumentTitle: "VTAdmin", + EnableDebugJSON: uiDebugJSON, + TrustProxyProto: uiTrustProxy, + Authenticator: rbacConfig.GetAuthenticator(), + }) + if err != nil { + fatal(err) + } + + httpServer := vtadmin2.NewHTTPServer(uiAddr, uiServer) + + uiErr := make(chan error, 1) + shutdownBase := context.WithoutCancel(cmd.Context()) + shutdownUI := func() { + shutdownCtx, cancel := context.WithTimeout(shutdownBase, 5*time.Second) + defer cancel() + if err := httpServer.Shutdown(shutdownCtx); err != nil && !errors.Is(err, http.ErrServerClosed) { + slog.Error("vtadmin2 UI shutdown failed", slog.Any("error", err)) + } + } + servenv.OnTermSync(shutdownUI) + + go func() { + slog.Info("vtadmin2 UI listening", slog.String("addr", uiAddr)) + if err := httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + uiErr <- err + } + }() + + defer shutdownUI() + + go func() { + if err := <-uiErr; err != nil { + slog.Error("vtadmin2 UI server failed", slog.Any("error", err)) + if signalErr := syscall.Kill(syscall.Getpid(), syscall.SIGTERM); signalErr != nil { + slog.Error("failed to signal vtadmin shutdown", slog.Any("error", signalErr)) + } + } + }() + } bootSpan.Finish() if err := s.ListenAndServe(); err != nil { @@ -183,6 +258,13 @@ func registerFlags() { rootCmd.Flags().Var(&defaultClusterConfig, "cluster-defaults", "default options for all clusters") rootCmd.Flags().BoolVar(&enableDynamicClusters, "enable-dynamic-clusters", false, "whether to enable dynamic clusters that are set by request header cookies or gRPC metadata") + // Server-rendered UI flags + rootCmd.Flags().StringVar(&ui, "ui", "react", "admin UI to serve: react (default SPA) or vtadmin2 (server-rendered UI)") + rootCmd.Flags().StringVar(&uiAddr, "ui-addr", ":14202", "address for the vtadmin2 UI to listen on (used with --ui=vtadmin2)") + rootCmd.Flags().BoolVar(&uiReadOnly, "ui-read-only", false, "run the vtadmin2 UI in read-only mode (used with --ui=vtadmin2)") + rootCmd.Flags().BoolVar(&uiDebugJSON, "ui-debug-json", false, "enable ?format=json page data output in the vtadmin2 UI (used with --ui=vtadmin2)") + rootCmd.Flags().BoolVar(&uiTrustProxy, "ui-trust-proxy-https", false, "mark UI cookies Secure when X-Forwarded-Proto: https is present from a trusted HTTPS-terminating proxy (used with --ui=vtadmin2)") + // Tracing flags trace.RegisterFlags(rootCmd.Flags()) // defined in go/vt/trace utils.SetFlagBoolVar(rootCmd.Flags(), &opts.EnableTracing, "grpc-tracing", false, "whether to enable tracing on the gRPC server") @@ -201,7 +283,8 @@ func registerFlags() { "HTTP endpoint to expose prometheus metrics on. Omit to disable scraping metrics. "+ "Using a path used by VTAdmin's http API is unsupported and causes undefined behavior.") rootCmd.Flags().StringSliceVar(&httpOpts.CORSOrigins, "http-origin", []string{}, "repeated, comma-separated flag of allowed CORS origins. omit to disable CORS") - rootCmd.Flags().StringVar(&httpOpts.ExperimentalOptions.TabletURLTmpl, + rootCmd.Flags().StringVar( + &httpOpts.ExperimentalOptions.TabletURLTmpl, "http-tablet-url-tmpl", "https://{{ .Tablet.Hostname }}:80", "[EXPERIMENTAL] Go template string to generate a reachable http(s) "+ @@ -211,8 +294,8 @@ func registerFlags() { // RBAC flags rootCmd.Flags().StringVar(&rbacConfigPath, "rbac-config", "", "path to an RBAC config file. must be set if passing --rbac") - rootCmd.Flags().BoolVar(&enableRBAC, "rbac", false, "whether to enable RBAC. must be set if not passing --rbac") - rootCmd.Flags().BoolVar(&disableRBAC, "no-rbac", false, "whether to disable RBAC. must be set if not passing --no-rbac") + rootCmd.Flags().BoolVar(&enableRBAC, "rbac", false, "whether to enable RBAC. must be set if not passing --no-rbac") + rootCmd.Flags().BoolVar(&disableRBAC, "no-rbac", false, "whether to disable RBAC. must be set if not passing --rbac") // Global cache flags (N.B. there are also cluster-specific cache flags) cacheRefreshHelp := "instructs a request to ignore any cached data (if applicable) and refresh the cache;" + diff --git a/go/cmd/vtadmin/main_test.go b/go/cmd/vtadmin/main_test.go index d1cc697b585..c90f4f9b7e1 100644 --- a/go/cmd/vtadmin/main_test.go +++ b/go/cmd/vtadmin/main_test.go @@ -23,6 +23,13 @@ import ( "github.com/stretchr/testify/require" ) +func TestValidateUIOptions(t *testing.T) { + assert.NoError(t, validateUIOptions("react", false)) + assert.NoError(t, validateUIOptions("vtadmin2", false)) + assert.ErrorContains(t, validateUIOptions("unknown", false), "invalid --ui value") + assert.ErrorContains(t, validateUIOptions("vtadmin2", true), "not supported") +} + func TestMainFlagRegistration(t *testing.T) { registerFlags() diff --git a/go/cmd/vtadmin2/main.go b/go/cmd/vtadmin2/main.go new file mode 100644 index 00000000000..84048330884 --- /dev/null +++ b/go/cmd/vtadmin2/main.go @@ -0,0 +1,289 @@ +/* +Copyright 2026 The Vitess Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package main + +import ( + "context" + "errors" + "flag" + "fmt" + "log/slog" + "net" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/spf13/cobra" + + _flag "vitess.io/vitess/go/internal/flag" + "vitess.io/vitess/go/vt/log" + "vitess.io/vitess/go/vt/logutil" + "vitess.io/vitess/go/vt/servenv" + "vitess.io/vitess/go/vt/utils" + "vitess.io/vitess/go/vt/vtadmin" + "vitess.io/vitess/go/vt/vtadmin/cache" + "vitess.io/vitess/go/vt/vtadmin/cluster" + "vitess.io/vitess/go/vt/vtadmin/rbac" + "vitess.io/vitess/go/vt/vtadmin/vtadmin2" + "vitess.io/vitess/go/vt/vtctl/grpcclientcommon" + "vitess.io/vitess/go/vt/vtenv" +) + +type ( + runtimeConfig struct { + rbac *rbac.Config + ui vtadmin2.Options + } +) + +var ( + clusterConfigs cluster.ClustersFlag + clusterFileConfig cluster.FileConfig + defaultClusterConfig cluster.Config + uiOpts vtadmin2.Options + + enableDynamicClusters bool + rbacConfigPath string + enableRBAC bool + disableRBAC bool + cacheRefreshKey string + + rootCmd = &cobra.Command{ + Use: "vtadmin2", + Short: "Go and HTML VTAdmin web UI", + PreRunE: preRun, + RunE: run, + Version: servenv.AppVersion.String(), + } +) + +func preRun(cmd *cobra.Command, args []string) error { + _flag.TrickGlog() + if err := log.Init(cmd.Flags()); err != nil { + return err + } + logutil.PurgeLogs() + return validateFlags() +} + +func run(cmd *cobra.Command, args []string) (err error) { + cfg, err := buildRuntimeConfig() + if err != nil { + return err + } + + api, err := buildAPI(cmd.Context(), cfg.rbac) + if err != nil { + return err + } + defer func() { + if closeErr := api.Close(); closeErr != nil { + slog.Error("failed to close vtadmin API", slog.Any("error", closeErr)) + if err == nil { + err = closeErr + } + } + }() + + server, err := vtadmin2.NewServer(api, cfg.ui) + if err != nil { + return err + } + + addr := uiOpts.Addr + if addr == "" { + addr = ":14202" + } + log.Info("starting vtadmin2", slog.String("addr", addr)) + ctx, stop := signal.NotifyContext(cmd.Context(), os.Interrupt, syscall.SIGTERM) + defer stop() + + return serveHTTPServer(ctx, buildHTTPServer(addr, server)) +} + +func buildHTTPServer(addr string, handler http.Handler) *http.Server { + return vtadmin2.NewHTTPServer(addr, handler) +} + +func serveHTTPServer(ctx context.Context, server *http.Server) error { + listener, err := net.Listen("tcp", server.Addr) + if err != nil { + return err + } + return serveHTTPServerWithListener(ctx, server, listener) +} + +func serveHTTPServerWithListener(ctx context.Context, server *http.Server, listener net.Listener) error { + errCh := make(chan error, 1) + go func() { + errCh <- normalizeListenAndServeError(server.Serve(listener)) + }() + + select { + case err := <-errCh: + return err + case <-ctx.Done(): + shutdownCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second) + defer cancel() + if err := server.Shutdown(shutdownCtx); err != nil { + return err + } + return <-errCh + } +} + +func normalizeListenAndServeError(err error) error { + if errors.Is(err, http.ErrServerClosed) { + return nil + } + return err +} + +func buildAPI(ctx context.Context, rbacConfig *rbac.Config) (*vtadmin.API, error) { + configs := clusterFileConfig.Combine(defaultClusterConfig, clusterConfigs) + if len(configs) == 0 { + return nil, errors.New("must specify at least one cluster") + } + + clusters := make([]*cluster.Cluster, 0, len(configs)) + for _, cfg := range configs { + cl, err := cfg.Cluster(ctx) + if err != nil { + closeClusters(clusters) + return nil, err + } + clusters = append(clusters, cl) + } + + if cacheRefreshKey == "" { + log.Warn("no cache-refresh-key set; forcing cache refreshes will not be possible") + } + cache.SetCacheRefreshKey(cacheRefreshKey) + + env, err := vtenv.New(vtenv.Options{ + MySQLServerVersion: servenv.MySQLServerVersion(), + TruncateUILen: servenv.TruncateUILen, + TruncateErrLen: servenv.TruncateErrLen, + }) + if err != nil { + closeClusters(clusters) + return nil, err + } + + return vtadmin.NewAPI(env, clusters, vtadmin.Options{ + RBAC: rbacConfig, + }), nil +} + +func closeClusters(clusters []*cluster.Cluster) { + for _, cl := range clusters { + if cl == nil { + continue + } + if err := cl.Close(); err != nil { + slog.Error("failed to close cluster after setup error", slog.String("cluster_id", cl.ID), slog.Any("error", err)) + } + } +} + +func validateFlags() error { + if err := validateRBACFlags(); err != nil { + return err + } + if enableDynamicClusters { + return errors.New("vtadmin2 does not support dynamic clusters") + } + return nil +} + +func validateRBACFlags() error { + if enableRBAC == disableRBAC { + return errors.New("must explicitly enable or disable RBAC by passing --rbac or --no-rbac") + } + if enableRBAC && rbacConfigPath == "" { + return errors.New("must pass --rbac-config path when enabling rbac") + } + return nil +} + +func buildRBACConfig() (*rbac.Config, error) { + if disableRBAC { + return rbac.DefaultConfig(), nil + } + return rbac.LoadConfig(rbacConfigPath) +} + +func buildRuntimeConfig() (*runtimeConfig, error) { + rbacConfig, err := buildRBACConfig() + if err != nil { + return nil, err + } + + return &runtimeConfig{ + rbac: rbacConfig, + ui: buildVTAdmin2Options(rbacConfig), + }, nil +} + +func buildVTAdmin2Options(rbacConfig *rbac.Config) vtadmin2.Options { + opts := uiOpts + if opts.DocumentTitle == "" { + opts.DocumentTitle = "VTAdmin2" + } + if rbacConfig != nil { + opts.Authenticator = rbacConfig.GetAuthenticator() + } + return opts +} + +func registerFlags() { + rootCmd.Flags().StringVar(&uiOpts.Addr, "addr", ":14202", "address to serve the vtadmin2 web UI on") + rootCmd.Flags().BoolVar(&uiOpts.ReadOnly, "read-only", false, "hide vtadmin2 mutating actions in the UI") + rootCmd.Flags().StringVar(&uiOpts.DocumentTitle, "document-title", "VTAdmin2", "document title for vtadmin2 pages") + rootCmd.Flags().BoolVar(&uiOpts.EnableDebugJSON, "debug-json", false, "enable debug JSON views in vtadmin2 pages") + rootCmd.Flags().BoolVar(&uiOpts.TrustProxyProto, "cookie-secure-behind-proxy", false, "mark vtadmin2 cookies Secure when X-Forwarded-Proto: https is present from a trusted HTTPS-terminating proxy") + + rootCmd.Flags().Var(&clusterConfigs, "cluster", "per-cluster configuration. any values here take precedence over those in -cluster-defaults or -cluster-config") + rootCmd.Flags().Var(&clusterFileConfig, "cluster-config", "path to a yaml cluster configuration. see clusters.example.yaml") + rootCmd.Flags().Var(&defaultClusterConfig, "cluster-defaults", "default options for all clusters") + rootCmd.Flags().BoolVar(&enableDynamicClusters, "enable-dynamic-clusters", false, "whether to enable dynamic clusters that are set by request header cookies or gRPC metadata") + + rootCmd.Flags().StringVar(&rbacConfigPath, "rbac-config", "", "path to an RBAC config file. must be set if passing --rbac") + rootCmd.Flags().BoolVar(&enableRBAC, "rbac", false, "whether to enable RBAC. must be set if not passing --no-rbac") + rootCmd.Flags().BoolVar(&disableRBAC, "no-rbac", false, "whether to disable RBAC. must be set if not passing --rbac") + rootCmd.Flags().StringVar(&cacheRefreshKey, "cache-refresh-key", "vt-cache-refresh", "cache refresh key used by the backing VTAdmin API") + + log.RegisterFlags(rootCmd.Flags()) + rootCmd.Flags().AddGoFlag(flag.Lookup("v")) + rootCmd.Flags().AddGoFlag(flag.Lookup("logtostderr")) + rootCmd.Flags().AddGoFlag(flag.Lookup("alsologtostderr")) + rootCmd.Flags().AddGoFlag(flag.Lookup("stderrthreshold")) + rootCmd.Flags().AddGoFlag(flag.Lookup("log_dir")) + servenv.RegisterMySQLServerFlags(rootCmd.Flags()) + grpcclientcommon.RegisterFlags(rootCmd.Flags()) +} + +func main() { + registerFlags() + rootCmd.SetGlobalNormalizationFunc(utils.NormalizeUnderscoresToDashes) + if err := rootCmd.Execute(); err != nil { + log.Error(fmt.Sprint(err)) + os.Exit(1) + } +} diff --git a/go/cmd/vtadmin2/main_test.go b/go/cmd/vtadmin2/main_test.go new file mode 100644 index 00000000000..64cddf0ace6 --- /dev/null +++ b/go/cmd/vtadmin2/main_test.go @@ -0,0 +1,167 @@ +/* +Copyright 2026 The Vitess Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package main + +import ( + "context" + "errors" + "net" + "net/http" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "vitess.io/vitess/go/vt/vtadmin/vtadmin2" +) + +func TestBuildVTAdmin2OptionsDefaultsTitle(t *testing.T) { + uiOpts = vtadmin2.Options{} + + opts := buildVTAdmin2Options(nil) + + assert.Equal(t, "VTAdmin2", opts.DocumentTitle) +} + +func TestValidateRBACFlagsRequiresExplicitChoice(t *testing.T) { + enableRBAC = false + disableRBAC = false + + err := validateRBACFlags() + + require.ErrorContains(t, err, "must explicitly enable or disable RBAC") +} + +func TestValidateFlagsRejectsDynamicClusters(t *testing.T) { + enableRBAC = false + disableRBAC = true + enableDynamicClusters = true + t.Cleanup(func() { enableDynamicClusters = false }) + + err := validateFlags() + + require.ErrorContains(t, err, "vtadmin2 does not support dynamic clusters") +} + +func TestBuildRuntimeConfigLoadsRBACOnceForAPIAndUI(t *testing.T) { + rbacPath := filepath.Join(t.TempDir(), "rbac.yaml") + require.NoError(t, os.WriteFile(rbacPath, []byte(`rules: + - resource: "*" + actions: ["*"] + subjects: ["*"] + clusters: ["*"] +`), 0o600)) + + enableRBAC = true + disableRBAC = false + rbacConfigPath = rbacPath + uiOpts = vtadmin2.Options{} + t.Cleanup(func() { + enableRBAC = false + disableRBAC = false + rbacConfigPath = "" + }) + + cfg, err := buildRuntimeConfig() + + require.NoError(t, err) + require.NotNil(t, cfg.rbac) + assert.Equal(t, cfg.rbac.GetAuthenticator(), cfg.ui.Authenticator) +} + +func TestBuildRuntimeConfigFailsWhenRBACCannotLoad(t *testing.T) { + enableRBAC = true + disableRBAC = false + rbacConfigPath = filepath.Join(t.TempDir(), "missing.yaml") + t.Cleanup(func() { + enableRBAC = false + disableRBAC = false + rbacConfigPath = "" + }) + + cfg, err := buildRuntimeConfig() + + require.Error(t, err) + assert.Nil(t, cfg) +} + +func TestBuildHTTPServerConfiguresTimeouts(t *testing.T) { + server := buildHTTPServer("127.0.0.1:0", http.NewServeMux()) + + assert.Equal(t, "127.0.0.1:0", server.Addr) + assert.NotNil(t, server.Handler) + assert.GreaterOrEqual(t, server.ReadHeaderTimeout, 5*time.Second) + assert.GreaterOrEqual(t, server.ReadTimeout, 30*time.Second) + assert.GreaterOrEqual(t, server.WriteTimeout, 5*time.Minute) + assert.GreaterOrEqual(t, server.IdleTimeout, 30*time.Second) +} + +func TestServeHTTPServerShutsDownWhenContextIsCanceled(t *testing.T) { + shutdownCalled := make(chan struct{}) + server := &http.Server{Handler: http.NewServeMux()} + server.RegisterOnShutdown(func() { close(shutdownCalled) }) + listener, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + + ctx, cancel := context.WithCancel(t.Context()) + errCh := make(chan error, 1) + go func() { + errCh <- serveHTTPServerWithListener(ctx, server, listener) + }() + + waitUntilServing(t, "http://"+listener.Addr().String()) + cancel() + + assert.Eventually(t, func() bool { + select { + case <-shutdownCalled: + return true + default: + return false + } + }, 30*time.Second, 10*time.Millisecond) + require.NoError(t, <-errCh) +} + +func TestNormalizeListenAndServeErrorIgnoresClosedServer(t *testing.T) { + err := normalizeListenAndServeError(http.ErrServerClosed) + + assert.NoError(t, err) +} + +func TestNormalizeListenAndServeErrorReturnsOtherErrors(t *testing.T) { + listenErr := errors.New("listen failed") + err := normalizeListenAndServeError(listenErr) + + assert.ErrorIs(t, err, listenErr) +} + +func waitUntilServing(t *testing.T, url string) { + t.Helper() + + assert.Eventually(t, func() bool { + resp, err := http.Get(url) + if err != nil { + return false + } + defer resp.Body.Close() + return true + }, 30*time.Second, 10*time.Millisecond) +} diff --git a/go/flags/endtoend/flags_test.go b/go/flags/endtoend/flags_test.go index 6ce5b0274a1..2171b8bf45b 100644 --- a/go/flags/endtoend/flags_test.go +++ b/go/flags/endtoend/flags_test.go @@ -80,6 +80,12 @@ var ( //go:embed zkctld.txt zkctldTxt string + //go:embed vtadmin.txt + vtadminTxt string + + //go:embed vtadmin2.txt + vtadmin2Txt string + //go:embed vtbackup.txt vtbackupTxt string @@ -94,6 +100,8 @@ var ( "mysqlctld": mysqlctldTxt, "topo2topo": topo2topoTxt, "vtaclcheck": vtaclcheckTxt, + "vtadmin": vtadminTxt, + "vtadmin2": vtadmin2Txt, "vtbackup": vtbackupTxt, "vtcombo": vtcomboTxt, "vtctlclient": vtctlclientTxt, diff --git a/go/flags/endtoend/vtadmin.txt b/go/flags/endtoend/vtadmin.txt new file mode 100644 index 00000000000..ea4345c9f8d --- /dev/null +++ b/go/flags/endtoend/vtadmin.txt @@ -0,0 +1,49 @@ +Usage: + vtadmin [flags] + +Flags: + --addr string address to serve on (default ":15000") + --cache-refresh-key string instructs a request to ignore any cached data (if applicable) and refresh the cache;usable as an HTTP header named 'X-' and as a gRPC metadata key '' + Note: any whitespace characters are replaced with hyphens. (default "vt-cache-refresh") + --cluster cluster.ClustersFlag per-cluster configuration. any values here take precedence over those in -cluster-defaults or -cluster-config (default []) + --cluster-config cluster.FileConfig path to a yaml cluster configuration. see clusters.example.yaml (default {defaults: *cluster.Config:{ID: Name: DiscoveryImpl: DiscoveryFlagsByImpl:map[] TabletFQDNTmplStr: VtSQLFlags:map[] VtctldFlags:map[] BackupReadPoolConfig: SchemaReadPoolConfig: TopoRWPoolConfig: TopoReadPoolConfig: WorkflowReadPoolConfig: EmergencyFailoverPoolConfig: FailoverPoolConfig: SchemaCacheConfig: vtctldConfigOpts:[] vtsqlConfigOpts:[]}, clusters: []}) + --cluster-defaults cluster.Config default options for all clusters (default *cluster.Config:{ID: Name: DiscoveryImpl: DiscoveryFlagsByImpl:map[] TabletFQDNTmplStr: VtSQLFlags:map[] VtctldFlags:map[] BackupReadPoolConfig: SchemaReadPoolConfig: TopoRWPoolConfig: TopoReadPoolConfig: WorkflowReadPoolConfig: EmergencyFailoverPoolConfig: FailoverPoolConfig: SchemaCacheConfig: vtctldConfigOpts:[] vtsqlConfigOpts:[]}) + --enable-dynamic-clusters whether to enable dynamic clusters that are set by request header cookies or gRPC metadata + --grpc-allow-reflection whether to register the gRPC server for reflection; this is required to use tools like grpc_cli + --grpc-enable-channelz whether to enable the channelz service on the gRPC server + --grpc-tracing whether to enable tracing on the gRPC server + -h, --help help for vtadmin + --http-debug-omit-env StringSetFlag name of an environment variable to omit from /debug/env, if http debug endpoints are enabled. specify multiple times to omit multiple env vars + --http-debug-sanitize-env StringSetFlag name of an environment variable to sanitize in /debug/env, if http debug endpoints are enabled. specify multiple times to sanitize multiple env vars + --http-metrics-endpoint string HTTP endpoint to expose prometheus metrics on. Omit to disable scraping metrics. Using a path used by VTAdmin's http API is unsupported and causes undefined behavior. (default "/metrics") + --http-no-compress whether to disable compression of HTTP API responses + --http-no-debug whether to disable /debug/pprof/* and /debug/env HTTP endpoints + --http-origin strings repeated, comma-separated flag of allowed CORS origins. omit to disable CORS + --http-tablet-url-tmpl string [EXPERIMENTAL] Go template string to generate a reachable http(s) address for a tablet. Currently used to make passthrough requests to /debug/vars endpoints. (default "https://{{ "{{ .Tablet.Hostname }}" }}:80") + --http-tracing whether to enable tracing on the HTTP server + --lame-duck-duration duration length of lame duck period at shutdown (default 5s) + --lmux-read-timeout duration how long to spend connection muxing (default 1s) + --log-format string log output format: json for machine-readable JSON, text for human-readable colored output (default "json") + --log-level string minimum log level when structured logging is enabled (debug, info, warn, error) (default "info") + --log-rotate-max-size uint size in bytes at which logs are rotated (glog.MaxSize) (default 1887436800) + --log-structured enable structured JSON logging (default true) + --mysql-server-version string MySQL server version to advertise. (default "8.4.6-Vitess") + --no-rbac whether to disable RBAC. must be set if not passing --rbac + --otel-endpoint string OpenTelemetry collector endpoint (host:port for gRPC); if empty, the OTEL_EXPORTER_OTLP_ENDPOINT env var is used + --otel-insecure use insecure connection to OpenTelemetry collector + --rbac whether to enable RBAC. must be set if not passing --no-rbac + --rbac-config string path to an RBAC config file. must be set if passing --rbac + --tracer string tracing service to use (default "noop") + --tracing-enable-logging whether to enable logging in the tracing service + --tracing-sampling-rate float sampling rate for traces as a probability between 0.0 and 1.0 (default 0.1) + --ui string admin UI to serve: react (default SPA) or vtadmin2 (server-rendered UI) (default "react") + --ui-addr string address for the vtadmin2 UI to listen on (used with --ui=vtadmin2) (default ":14202") + --ui-debug-json enable ?format=json page data output in the vtadmin2 UI (used with --ui=vtadmin2) + --ui-read-only run the vtadmin2 UI in read-only mode (used with --ui=vtadmin2) + --ui-trust-proxy-https mark UI cookies Secure when X-Forwarded-Proto: https is present from a trusted HTTPS-terminating proxy (used with --ui=vtadmin2) + --version version for vtadmin + --vtctld-grpc-ca string the server ca to use to validate servers when connecting + --vtctld-grpc-cert string the cert to use to connect + --vtctld-grpc-crl string the server crl to use to validate server certificates when connecting + --vtctld-grpc-key string the key to use to connect + --vtctld-grpc-server-name string the server name to use to validate server certificate diff --git a/go/flags/endtoend/vtadmin2.txt b/go/flags/endtoend/vtadmin2.txt new file mode 100644 index 00000000000..06ddd1dd615 --- /dev/null +++ b/go/flags/endtoend/vtadmin2.txt @@ -0,0 +1,36 @@ +Go and HTML VTAdmin web UI + +Usage: + vtadmin2 [flags] + +Flags: + --addr string address to serve the vtadmin2 web UI on (default ":14202") + --alsologtostderr log to standard error as well as files + --cache-refresh-key string cache refresh key used by the backing VTAdmin API (default "vt-cache-refresh") + --cluster cluster.ClustersFlag per-cluster configuration. any values here take precedence over those in -cluster-defaults or -cluster-config (default []) + --cluster-config cluster.FileConfig path to a yaml cluster configuration. see clusters.example.yaml (default {defaults: *cluster.Config:{ID: Name: DiscoveryImpl: DiscoveryFlagsByImpl:map[] TabletFQDNTmplStr: VtSQLFlags:map[] VtctldFlags:map[] BackupReadPoolConfig: SchemaReadPoolConfig: TopoRWPoolConfig: TopoReadPoolConfig: WorkflowReadPoolConfig: EmergencyFailoverPoolConfig: FailoverPoolConfig: SchemaCacheConfig: vtctldConfigOpts:[] vtsqlConfigOpts:[]}, clusters: []}) + --cluster-defaults cluster.Config default options for all clusters (default *cluster.Config:{ID: Name: DiscoveryImpl: DiscoveryFlagsByImpl:map[] TabletFQDNTmplStr: VtSQLFlags:map[] VtctldFlags:map[] BackupReadPoolConfig: SchemaReadPoolConfig: TopoRWPoolConfig: TopoReadPoolConfig: WorkflowReadPoolConfig: EmergencyFailoverPoolConfig: FailoverPoolConfig: SchemaCacheConfig: vtctldConfigOpts:[] vtsqlConfigOpts:[]}) + --cookie-secure-behind-proxy mark vtadmin2 cookies Secure when X-Forwarded-Proto: https is present from a trusted HTTPS-terminating proxy + --debug-json enable debug JSON views in vtadmin2 pages + --document-title string document title for vtadmin2 pages (default "VTAdmin2") + --enable-dynamic-clusters whether to enable dynamic clusters that are set by request header cookies or gRPC metadata + -h, --help help for vtadmin2 + --log-format string log output format: json for machine-readable JSON, text for human-readable colored output (default "json") + --log-level string minimum log level when structured logging is enabled (debug, info, warn, error) (default "info") + --log-rotate-max-size uint size in bytes at which logs are rotated (glog.MaxSize) (default 1887436800) + --log-structured enable structured JSON logging (default true) + --log_dir string If non-empty, write log files in this directory + --logtostderr log to standard error instead of files + --mysql-server-version string MySQL server version to advertise. (default "8.4.6-Vitess") + --no-rbac whether to disable RBAC. must be set if not passing --rbac + --rbac whether to enable RBAC. must be set if not passing --no-rbac + --rbac-config string path to an RBAC config file. must be set if passing --rbac + --read-only hide vtadmin2 mutating actions in the UI + --stderrthreshold severityFlag logs at or above this threshold go to stderr (default 1) + -v, --v Level log level for V logs + --version version for vtadmin2 + --vtctld-grpc-ca string the server ca to use to validate servers when connecting + --vtctld-grpc-cert string the cert to use to connect + --vtctld-grpc-crl string the server crl to use to validate server certificates when connecting + --vtctld-grpc-key string the key to use to connect + --vtctld-grpc-server-name string the server name to use to validate server certificate diff --git a/go/vt/vtadmin/api.go b/go/vt/vtadmin/api.go index 89b1271c7b9..07be1865f9c 100644 --- a/go/vt/vtadmin/api.go +++ b/go/vt/vtadmin/api.go @@ -367,7 +367,8 @@ func (api *API) Handler() http.Handler { router := mux.NewRouter().PathPrefix("/api").Subrouter() router.Use(handlers.CORS( - handlers.AllowCredentials(), handlers.AllowedOrigins(api.options.HTTPOpts.CORSOrigins), handlers.AllowedMethods([]string{"GET", "HEAD", "POST", "PUT", "DELETE", "OPTIONS"}))) + handlers.AllowCredentials(), handlers.AllowedOrigins(api.options.HTTPOpts.CORSOrigins), handlers.AllowedMethods([]string{"GET", "HEAD", "POST", "PUT", "DELETE", "OPTIONS"}), + )) httpAPI := vtadminhttp.NewAPI(api, api.options.HTTPOpts) @@ -581,8 +582,10 @@ func (api *API) ConcludeTransaction(ctx context.Context, req *vtadminpb.Conclude span, ctx := trace.NewSpan(ctx, "API.ConcludeTransaction") defer span.Finish() - if !api.authz.IsAuthorized(ctx, req.ClusterId, rbac.ClusterResource, rbac.GetAction) { - return nil, nil + // Concluding a transaction is a destructive, write-like operation, so it + // requires PutAction rather than a read-only action. + if !api.authz.IsAuthorized(ctx, req.ClusterId, rbac.ClusterResource, rbac.PutAction) { + return nil, fmt.Errorf("%w: cannot conclude transaction in %s", errors.ErrUnauthorized, req.ClusterId) } c, err := api.getClusterForRequest(req.ClusterId) @@ -1708,8 +1711,10 @@ func (api *API) VDiffCreate(ctx context.Context, req *vtadminpb.VDiffCreateReque span, ctx := trace.NewSpan(ctx, "API.VDiffCreate") defer span.Finish() - if !api.authz.IsAuthorized(ctx, req.ClusterId, rbac.ClusterResource, rbac.GetAction) { - return nil, nil + // Creating a VDiff launches a job and changes cluster state, so it + // requires a write action rather than read permission. + if !api.authz.IsAuthorized(ctx, req.ClusterId, rbac.ClusterResource, rbac.PutAction) { + return nil, fmt.Errorf("%w: cannot create VDiff in %s", errors.ErrUnauthorized, req.ClusterId) } c, err := api.getClusterForRequest(req.ClusterId) @@ -1719,20 +1724,40 @@ func (api *API) VDiffCreate(ctx context.Context, req *vtadminpb.VDiffCreateReque cluster.AnnotateSpan(c, span) - // Set the default options - req.Request.Uuid = uuid.New().String() - req.Request.TabletTypes = vdiffcmd.TabletTypesDefault - req.Request.TabletSelectionPreference = tabletmanagerdatapb.TabletSelectionPreference_INORDER - req.Request.FilteredReplicationWaitTime = protoutil.DurationToProto(workflow.DefaultTimeout) - req.Request.Limit = math.MaxInt64 - req.Request.MaxReportSampleRows = 10 - req.Request.MaxExtraRowsToCompare = 1000 - req.Request.WaitUpdateInterval = protoutil.DurationToProto(time.Duration(1 * time.Minute)) + // Set defaults. Explicitly submitted values are preserved; defaults are + // applied only for omitted options. + if req.Request.Uuid == "" { + req.Request.Uuid = uuid.New().String() + } + if len(req.Request.TabletTypes) == 0 { + req.Request.TabletTypes = vdiffcmd.TabletTypesDefault + } + if req.Request.TabletSelectionPreference == tabletmanagerdatapb.TabletSelectionPreference_ANY { + req.Request.TabletSelectionPreference = tabletmanagerdatapb.TabletSelectionPreference_INORDER + } + if d, ok, err := protoutil.DurationFromProto(req.Request.FilteredReplicationWaitTime); err != nil || !ok || d <= 0 { + req.Request.FilteredReplicationWaitTime = protoutil.DurationToProto(workflow.DefaultTimeout) + } + if req.Request.Limit == 0 { + req.Request.Limit = math.MaxInt64 + } + if req.Request.MaxReportSampleRows == 0 { + req.Request.MaxReportSampleRows = 10 + } + if req.Request.MaxExtraRowsToCompare == 0 { + req.Request.MaxExtraRowsToCompare = 1000 + } + if req.Request.WaitUpdateInterval == nil { + req.Request.WaitUpdateInterval = protoutil.DurationToProto(time.Duration(1 * time.Minute)) + } + if req.Request.RowDiffColumnTruncateAt == 0 { + req.Request.RowDiffColumnTruncateAt = 128 + } + if req.Request.AutoStart == nil { + defaultAutoStart := true + req.Request.AutoStart = &defaultAutoStart + } req.Request.AutoRetry = true - req.Request.RowDiffColumnTruncateAt = 128 - - defaultAutoStart := true - req.Request.AutoStart = &defaultAutoStart return c.Vtctld.VDiffCreate(ctx, req.Request) } @@ -2038,17 +2063,19 @@ func (api *API) MaterializeCreate(ctx context.Context, req *vtadminpb.Materializ return nil, err } - // Parser with default options. New() itself initializes with default MySQL version. - parser, err := sqlparser.New(sqlparser.Options{ - TruncateUILen: 512, - TruncateErrLen: 0, - }) - if err != nil { - return nil, err - } - req.Request.Settings.TableSettings, err = vreplcommon.ParseTableMaterializeSettings(req.TableSettings, parser) - if err != nil { - return nil, err + tableSettings := strings.TrimSpace(req.TableSettings) + if tableSettings != "" || len(req.Request.GetSettings().GetReferenceTables()) == 0 { + parser, err := sqlparser.New(sqlparser.Options{ + TruncateUILen: 512, + TruncateErrLen: 0, + }) + if err != nil { + return nil, err + } + req.Request.Settings.TableSettings, err = vreplcommon.ParseTableMaterializeSettings(tableSettings, parser) + if err != nil { + return nil, err + } } return c.Vtctld.MaterializeCreate(ctx, req.Request) @@ -2294,12 +2321,20 @@ func (api *API) ReloadSchemaShard(ctx context.Context, req *vtadminpb.ReloadSche span, ctx := trace.NewSpan(ctx, "API.ReloadSchemas") defer span.Finish() + if !api.authz.IsAuthorized(ctx, req.ClusterId, rbac.SchemaResource, rbac.ReloadAction) { + return nil, fmt.Errorf("%w: cannot reload schema in %s", errors.ErrUnauthorized, req.ClusterId) + } + c, err := api.getClusterForRequest(req.ClusterId) if err != nil { return nil, err } + // Keyspace and shard identity are required by vtctld; forward them + // explicitly so the reload targets the requested shard. res, err := c.Vtctld.ReloadSchemaShard(ctx, &vtctldatapb.ReloadSchemaShardRequest{ + Keyspace: req.Keyspace, + Shard: req.Shard, WaitPosition: req.WaitPosition, IncludePrimary: req.IncludePrimary, Concurrency: req.Concurrency, diff --git a/go/vt/vtadmin/api_authz_transaction_test.go b/go/vt/vtadmin/api_authz_transaction_test.go new file mode 100644 index 00000000000..702fc3ff18f --- /dev/null +++ b/go/vt/vtadmin/api_authz_transaction_test.go @@ -0,0 +1,98 @@ +/* +Copyright 2026 The Vitess Authors. + +Licensed under the Apache License, Version 2.0 the "License"; +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package vtadmin_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "vitess.io/vitess/go/vt/vtadmin" + "vitess.io/vitess/go/vt/vtadmin/rbac" + "vitess.io/vitess/go/vt/vtenv" + + vtadminpb "vitess.io/vitess/go/vt/proto/vtadmin" +) + +// TestConcludeTransactionAuthorization verifies that concluding an unresolved +// transaction requires a write-level action (put) on the cluster, and is not +// permitted to read-only actors. ConcludeTransaction is destructive: it +// finalizes and removes an unresolved transaction record. +func TestConcludeTransactionAuthorization(t *testing.T) { + t.Parallel() + + opts := vtadmin.Options{ + RBAC: &rbac.Config{ + Rules: []*struct { + Resource string + Actions []string + Subjects []string + Clusters []string + }{ + { + Resource: "Cluster", + Actions: []string{"get"}, + Subjects: []string{"user:readonly"}, + Clusters: []string{"*"}, + }, + { + Resource: "Cluster", + Actions: []string{"put"}, + Subjects: []string{"user:allowed"}, + Clusters: []string{"*"}, + }, + }, + }, + } + err := opts.RBAC.Reify() + require.NoError(t, err, "failed to reify authorization rules: %+v", opts.RBAC.Rules) + + api := vtadmin.NewAPI(vtenv.NewTestEnv(), testClusters(t), opts) + t.Cleanup(func() { + if err := api.Close(); err != nil { + t.Logf("api did not close cleanly: %s", err.Error()) + } + }) + + t.Run("read-only actor is not permitted", func(t *testing.T) { + t.Parallel() + + actor := &rbac.Actor{Name: "readonly"} + ctx := rbac.NewContext(t.Context(), actor) + + resp, err := api.ConcludeTransaction(ctx, &vtadminpb.ConcludeTransactionRequest{ + ClusterId: "test", + Dtid: "dtid-1", + }) + assert.ErrorContains(t, err, "unauthorized", "actor %+v should not be permitted to ConcludeTransaction", actor) + assert.Nil(t, resp, "actor %+v should not be permitted to ConcludeTransaction", actor) + }) + + t.Run("actor with put action is permitted", func(t *testing.T) { + t.Parallel() + + actor := &rbac.Actor{Name: "allowed"} + ctx := rbac.NewContext(t.Context(), actor) + + _, err := api.ConcludeTransaction(ctx, &vtadminpb.ConcludeTransactionRequest{ + ClusterId: "test", + Dtid: "dtid-1", + }) + require.NoError(t, err, "actor %+v should be authorized to ConcludeTransaction", actor) + }) +} diff --git a/go/vt/vtadmin/api_materialize_test.go b/go/vt/vtadmin/api_materialize_test.go new file mode 100644 index 00000000000..04bccfeeb66 --- /dev/null +++ b/go/vt/vtadmin/api_materialize_test.go @@ -0,0 +1,62 @@ +/* +Copyright 2026 The Vitess Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package vtadmin + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + vtadminpb "vitess.io/vitess/go/vt/proto/vtadmin" + vtctldatapb "vitess.io/vitess/go/vt/proto/vtctldata" + vtadmintestutil "vitess.io/vitess/go/vt/vtadmin/testutil" + "vitess.io/vitess/go/vt/vtadmin/vtctldclient/fakevtctldclient" + "vitess.io/vitess/go/vt/vtenv" +) + +func TestMaterializeCreateReferenceTablesSkipsTableSettingsParse(t *testing.T) { + t.Parallel() + + fake := &fakevtctldclient.VtctldClient{} + api := NewAPI(vtenv.NewTestEnv(), vtadmintestutil.BuildClusters(t, vtadmintestutil.TestClusterConfig{ + Cluster: &vtadminpb.Cluster{Id: "c1", Name: "cluster1"}, + VtctldClient: fake, + }), Options{}) + t.Cleanup(func() { + assert.NoError(t, api.Close()) + }) + + resp, err := api.MaterializeCreate(t.Context(), &vtadminpb.MaterializeCreateRequest{ + ClusterId: "c1", + Request: &vtctldatapb.MaterializeCreateRequest{ + Settings: &vtctldatapb.MaterializeSettings{ + Workflow: "wf", + SourceKeyspace: "commerce", + TargetKeyspace: "sales", + ReferenceTables: []string{"users"}, + }, + }, + }) + require.NoError(t, err) + require.NotNil(t, resp) + + got := fake.LastMaterializeCreateRequest + require.NotNil(t, got) + assert.Equal(t, []string{"users"}, got.GetSettings().GetReferenceTables()) + assert.Empty(t, got.GetSettings().GetTableSettings()) +} diff --git a/go/vt/vtadmin/api_vdiff_reload_test.go b/go/vt/vtadmin/api_vdiff_reload_test.go new file mode 100644 index 00000000000..b48ae4577fd --- /dev/null +++ b/go/vt/vtadmin/api_vdiff_reload_test.go @@ -0,0 +1,284 @@ +/* +Copyright 2026 The Vitess Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package vtadmin + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "vitess.io/vitess/go/protoutil" + vtadminpb "vitess.io/vitess/go/vt/proto/vtadmin" + vtctldatapb "vitess.io/vitess/go/vt/proto/vtctldata" + "vitess.io/vitess/go/vt/vtadmin/rbac" + vtadmintestutil "vitess.io/vitess/go/vt/vtadmin/testutil" + "vitess.io/vitess/go/vt/vtadmin/vtctldclient/fakevtctldclient" + "vitess.io/vitess/go/vt/vtctl/workflow" + "vitess.io/vitess/go/vt/vtenv" +) + +func TestVDiffCreateRequiresPutAction(t *testing.T) { + t.Parallel() + + opts := Options{ + RBAC: &rbac.Config{ + Rules: []*struct { + Resource string + Actions []string + Subjects []string + Clusters []string + }{ + { + Resource: "Cluster", + Actions: []string{"get"}, + Subjects: []string{"user:readonly"}, + Clusters: []string{"*"}, + }, + { + Resource: "Cluster", + Actions: []string{"put"}, + Subjects: []string{"user:allowed"}, + Clusters: []string{"*"}, + }, + }, + }, + } + require.NoError(t, opts.RBAC.Reify()) + + fake := &fakevtctldclient.VtctldClient{} + api := NewAPI(vtenv.NewTestEnv(), vtadmintestutil.BuildClusters(t, vtadmintestutil.TestClusterConfig{ + Cluster: &vtadminpb.Cluster{Id: "c1", Name: "cluster1"}, + VtctldClient: fake, + }), opts) + t.Cleanup(func() { + assert.NoError(t, api.Close()) + }) + + t.Run("read-only actor is not permitted", func(t *testing.T) { + ctx := rbac.NewContext(t.Context(), &rbac.Actor{Name: "readonly"}) + resp, err := api.VDiffCreate(ctx, &vtadminpb.VDiffCreateRequest{ + ClusterId: "c1", + Request: &vtctldatapb.VDiffCreateRequest{Workflow: "wf", TargetKeyspace: "ks"}, + }) + require.ErrorContains(t, err, "unauthorized") + assert.Nil(t, resp) + assert.Nil(t, fake.LastVDiffCreateRequest) + }) + + t.Run("actor with put action is permitted", func(t *testing.T) { + ctx := rbac.NewContext(t.Context(), &rbac.Actor{Name: "allowed"}) + resp, err := api.VDiffCreate(ctx, &vtadminpb.VDiffCreateRequest{ + ClusterId: "c1", + Request: &vtctldatapb.VDiffCreateRequest{Workflow: "wf", TargetKeyspace: "ks", Uuid: "explicit-uuid"}, + }) + require.NoError(t, err) + require.NotNil(t, resp) + assert.Equal(t, "explicit-uuid", resp.GetUUID()) + }) +} + +func TestVDiffCreatePreservesExplicitOptions(t *testing.T) { + t.Parallel() + + fake := &fakevtctldclient.VtctldClient{} + api := NewAPI(vtenv.NewTestEnv(), vtadmintestutil.BuildClusters(t, vtadmintestutil.TestClusterConfig{ + Cluster: &vtadminpb.Cluster{Id: "c1", Name: "cluster1"}, + VtctldClient: fake, + }), Options{}) + t.Cleanup(func() { + assert.NoError(t, api.Close()) + }) + + resp, err := api.VDiffCreate(t.Context(), &vtadminpb.VDiffCreateRequest{ + ClusterId: "c1", + Request: &vtctldatapb.VDiffCreateRequest{ + Workflow: "wf", + TargetKeyspace: "ks", + Uuid: "kept-uuid", + AutoRetry: false, + }, + }) + require.NoError(t, err) + require.NotNil(t, resp) + assert.Equal(t, "kept-uuid", resp.GetUUID()) + + got := fake.LastVDiffCreateRequest + require.NotNil(t, got) + assert.Equal(t, "kept-uuid", got.Uuid) + assert.True(t, got.AutoRetry) +} + +func TestVDiffCreateAppliesDefaultWaitTimeWhenZero(t *testing.T) { + t.Parallel() + + fake := &fakevtctldclient.VtctldClient{} + api := NewAPI(vtenv.NewTestEnv(), vtadmintestutil.BuildClusters(t, vtadmintestutil.TestClusterConfig{ + Cluster: &vtadminpb.Cluster{Id: "c1", Name: "cluster1"}, + VtctldClient: fake, + }), Options{}) + t.Cleanup(func() { + assert.NoError(t, api.Close()) + }) + + _, err := api.VDiffCreate(t.Context(), &vtadminpb.VDiffCreateRequest{ + ClusterId: "c1", + Request: &vtctldatapb.VDiffCreateRequest{ + Workflow: "wf", + TargetKeyspace: "ks", + Uuid: "wait-uuid", + FilteredReplicationWaitTime: protoutil.DurationToProto(0), + }, + }) + require.NoError(t, err) + + got := fake.LastVDiffCreateRequest + require.NotNil(t, got) + require.NotNil(t, got.FilteredReplicationWaitTime) + assert.Equal(t, int64(workflow.DefaultTimeout.Seconds()), got.FilteredReplicationWaitTime.Seconds) +} + +func TestVDiffCreatePreservesPositiveWaitTime(t *testing.T) { + t.Parallel() + + fake := &fakevtctldclient.VtctldClient{} + api := NewAPI(vtenv.NewTestEnv(), vtadmintestutil.BuildClusters(t, vtadmintestutil.TestClusterConfig{ + Cluster: &vtadminpb.Cluster{Id: "c1", Name: "cluster1"}, + VtctldClient: fake, + }), Options{}) + t.Cleanup(func() { + assert.NoError(t, api.Close()) + }) + + _, err := api.VDiffCreate(t.Context(), &vtadminpb.VDiffCreateRequest{ + ClusterId: "c1", + Request: &vtctldatapb.VDiffCreateRequest{ + Workflow: "wf", + TargetKeyspace: "ks", + Uuid: "wait-kept", + FilteredReplicationWaitTime: protoutil.DurationToProto(45 * time.Second), + }, + }) + require.NoError(t, err) + + got := fake.LastVDiffCreateRequest + require.NotNil(t, got) + require.NotNil(t, got.FilteredReplicationWaitTime) + assert.Equal(t, int64(45), got.FilteredReplicationWaitTime.Seconds) +} + +func TestReloadSchemaShardForwardsKeyspaceAndShard(t *testing.T) { + t.Parallel() + + api := NewAPI(vtenv.NewTestEnv(), vtadmintestutil.BuildClusters(t, vtadmintestutil.TestClusterConfig{ + Cluster: &vtadminpb.Cluster{Id: "c1", Name: "cluster1"}, + VtctldClient: &fakevtctldclient.VtctldClient{ + ReloadSchemaShardResults: map[string]struct { + Response *vtctldatapb.ReloadSchemaShardResponse + Error error + }{ + "commerce/0": { + Response: &vtctldatapb.ReloadSchemaShardResponse{}, + }, + }, + }, + }), Options{}) + t.Cleanup(func() { + assert.NoError(t, api.Close()) + }) + + resp, err := api.ReloadSchemaShard(t.Context(), &vtadminpb.ReloadSchemaShardRequest{ + ClusterId: "c1", + Keyspace: "commerce", + Shard: "0", + }) + require.NoError(t, err) + require.NotNil(t, resp) +} + +func TestReloadSchemaShardRequiresReloadAction(t *testing.T) { + t.Parallel() + + opts := Options{ + RBAC: &rbac.Config{ + Rules: []*struct { + Resource string + Actions []string + Subjects []string + Clusters []string + }{ + { + Resource: "Schema", + Actions: []string{"get"}, + Subjects: []string{"user:readonly"}, + Clusters: []string{"*"}, + }, + { + Resource: "Schema", + Actions: []string{"reload"}, + Subjects: []string{"user:allowed"}, + Clusters: []string{"*"}, + }, + }, + }, + } + require.NoError(t, opts.RBAC.Reify()) + + api := NewAPI(vtenv.NewTestEnv(), vtadmintestutil.BuildClusters(t, vtadmintestutil.TestClusterConfig{ + Cluster: &vtadminpb.Cluster{Id: "c1", Name: "cluster1"}, + VtctldClient: &fakevtctldclient.VtctldClient{ + ReloadSchemaShardResults: map[string]struct { + Response *vtctldatapb.ReloadSchemaShardResponse + Error error + }{ + "commerce/0": { + Response: &vtctldatapb.ReloadSchemaShardResponse{}, + }, + }, + }, + }), opts) + t.Cleanup(func() { + assert.NoError(t, api.Close()) + }) + + t.Run("read-only actor is not permitted", func(t *testing.T) { + t.Parallel() + + ctx := rbac.NewContext(t.Context(), &rbac.Actor{Name: "readonly"}) + resp, err := api.ReloadSchemaShard(ctx, &vtadminpb.ReloadSchemaShardRequest{ + ClusterId: "c1", + Keyspace: "commerce", + Shard: "0", + }) + require.ErrorContains(t, err, "unauthorized") + assert.Nil(t, resp) + }) + + t.Run("actor with reload action is permitted", func(t *testing.T) { + t.Parallel() + + ctx := rbac.NewContext(t.Context(), &rbac.Actor{Name: "allowed"}) + resp, err := api.ReloadSchemaShard(ctx, &vtadminpb.ReloadSchemaShardRequest{ + ClusterId: "c1", + Keyspace: "commerce", + Shard: "0", + }) + require.NoError(t, err) + require.NotNil(t, resp) + }) +} diff --git a/go/vt/vtadmin/vtadmin2/actions.go b/go/vt/vtadmin/vtadmin2/actions.go new file mode 100644 index 00000000000..216c844d244 --- /dev/null +++ b/go/vt/vtadmin/vtadmin2/actions.go @@ -0,0 +1,89 @@ +/* +Copyright 2026 The Vitess Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package vtadmin2 + +import ( + "errors" + "net/http" + "net/url" + "strings" + + vtadminpb "vitess.io/vitess/go/vt/proto/vtadmin" + vtctldatapb "vitess.io/vitess/go/vt/proto/vtctldata" +) + +func (s *Server) createKeyspace(w http.ResponseWriter, r *http.Request) { + if s.opts.ReadOnly { + s.renderReadOnly(w, r) + return + } + + if err := r.ParseForm(); err != nil { + s.renderFormError(w, r, "Create keyspace", err.Error()) + return + } + if !validCSRFToken(r) { + s.renderError(w, r, http.StatusForbidden, "Create keyspace", errors.New("invalid CSRF token")) + return + } + + clusterID := strings.TrimSpace(r.Form.Get("cluster_id")) + name := strings.TrimSpace(r.Form.Get("name")) + if clusterID == "" { + s.renderFormError(w, r, "Create keyspace", "cluster is required") + return + } + if name == "" { + s.renderFormError(w, r, "Create keyspace", "keyspace name is required") + return + } + + _, err := s.api.CreateKeyspace(r.Context(), &vtadminpb.CreateKeyspaceRequest{ + ClusterId: clusterID, + Options: &vtctldatapb.CreateKeyspaceRequest{ + Name: name, + Force: r.Form.Get("force") == "on", + AllowEmptyVSchema: r.Form.Get("allow_empty_v_schema") == "on", + DurabilityPolicy: strings.TrimSpace(r.Form.Get("durability_policy")), + SidecarDbName: strings.TrimSpace(r.Form.Get("sidecar_db_name")), + }, + }) + if err != nil { + s.renderFormError(w, r, "Create keyspace", err.Error()) + return + } + + s.redirectWithFlash(w, r, "/keyspace/"+url.PathEscape(clusterID)+"/"+url.PathEscape(name), Flash{ + Kind: "success", + Message: "created keyspace " + name, + }) +} + +func (s *Server) renderFormError(w http.ResponseWriter, r *http.Request, title string, message string) { + s.render(w, r, http.StatusBadRequest, "index.html", PageData{ + Title: title, + Flash: &Flash{ + Kind: "error", + Message: message, + }, + }) +} + +func (s *Server) redirectWithFlash(w http.ResponseWriter, r *http.Request, target string, flash Flash) { + s.setFlash(w, r, flash) + http.Redirect(w, r, target, http.StatusSeeOther) +} diff --git a/go/vt/vtadmin/vtadmin2/actions_test.go b/go/vt/vtadmin/vtadmin2/actions_test.go new file mode 100644 index 00000000000..9b5ef96799b --- /dev/null +++ b/go/vt/vtadmin/vtadmin2/actions_test.go @@ -0,0 +1,221 @@ +/* +Copyright 2026 The Vitess Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package vtadmin2 + +import ( + "context" + "crypto/tls" + "net/http" + "net/http/httptest" + "net/url" + "regexp" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + vtadminpb "vitess.io/vitess/go/vt/proto/vtadmin" + vtctldatapb "vitess.io/vitess/go/vt/proto/vtctldata" +) + +type actionFakeServer struct { + fakeVTAdminServer + createKeyspaceRequest *vtadminpb.CreateKeyspaceRequest +} + +func (f *actionFakeServer) GetClusters(ctx context.Context, req *vtadminpb.GetClustersRequest) (*vtadminpb.GetClustersResponse, error) { + return &vtadminpb.GetClustersResponse{Clusters: []*vtadminpb.Cluster{{Id: "local", Name: "Local"}}}, nil +} + +func (f *actionFakeServer) CreateKeyspace(ctx context.Context, req *vtadminpb.CreateKeyspaceRequest) (*vtadminpb.CreateKeyspaceResponse, error) { + f.createKeyspaceRequest = req + return &vtadminpb.CreateKeyspaceResponse{Keyspace: &vtadminpb.Keyspace{ + Cluster: &vtadminpb.Cluster{Id: req.GetClusterId(), Name: "Local"}, + Keyspace: &vtctldatapb.Keyspace{Name: req.GetOptions().GetName()}, + }}, nil +} + +func TestCreateKeyspaceFormRendersClusters(t *testing.T) { + s, err := NewServer(&actionFakeServer{}, Options{}) + require.NoError(t, err) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/keyspaces/create", nil) + s.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + assert.Contains(t, rec.Body.String(), "Create keyspace") + assert.Contains(t, rec.Body.String(), "local") + assert.Contains(t, rec.Body.String(), "name=\"name\"") + assert.Contains(t, rec.Body.String(), "name=\"csrf_token\"") +} + +func TestCreateKeyspacePostCallsServerAndRedirects(t *testing.T) { + fake := &actionFakeServer{} + s, err := NewServer(fake, Options{}) + require.NoError(t, err) + + csrfToken := createKeyspaceCSRFToken(t, s) + form := url.Values{} + form.Set("cluster_id", "local") + form.Set("name", "commerce") + form.Set("force", "on") + form.Set("csrf_token", csrfToken) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/keyspaces/create", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.AddCookie(&http.Cookie{Name: csrfCookieName, Value: csrfToken}) + s.ServeHTTP(rec, req) + + require.NotNil(t, fake.createKeyspaceRequest) + assert.Equal(t, "local", fake.createKeyspaceRequest.GetClusterId()) + assert.Equal(t, "commerce", fake.createKeyspaceRequest.GetOptions().GetName()) + assert.True(t, fake.createKeyspaceRequest.GetOptions().GetForce()) + assert.Equal(t, http.StatusSeeOther, rec.Code) + assert.Contains(t, rec.Header().Get("Location"), "/keyspace/local/commerce") + assert.NotContains(t, rec.Header().Get("Location"), "flash=success") + assert.NotEmpty(t, findCookie(rec, flashCookieName).Value) +} + +func TestCreateKeyspacePostMarksFlashCookieSecureOnTLS(t *testing.T) { + fake := &actionFakeServer{} + s, err := NewServer(fake, Options{}) + require.NoError(t, err) + + csrfToken := createKeyspaceCSRFToken(t, s) + form := url.Values{} + form.Set("cluster_id", "local") + form.Set("name", "commerce") + form.Set("csrf_token", csrfToken) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/keyspaces/create", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.AddCookie(&http.Cookie{Name: csrfCookieName, Value: csrfToken}) + req.TLS = &tls.ConnectionState{} + s.ServeHTTP(rec, req) + + flashCookie := findCookie(rec, flashCookieName) + require.NotNil(t, flashCookie) + assert.True(t, flashCookie.Secure) +} + +func TestCreateKeyspacePostRejectsMissingCSRFToken(t *testing.T) { + fake := &actionFakeServer{} + s, err := NewServer(fake, Options{}) + require.NoError(t, err) + + form := url.Values{} + form.Set("cluster_id", "local") + form.Set("name", "commerce") + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/keyspaces/create", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + s.ServeHTTP(rec, req) + + assert.Nil(t, fake.createKeyspaceRequest) + assert.Equal(t, http.StatusForbidden, rec.Code) + assert.Contains(t, rec.Body.String(), "invalid CSRF token") +} + +func TestCreateKeyspacePostValidatesRequiredFields(t *testing.T) { + fake := &actionFakeServer{} + s, err := NewServer(fake, Options{}) + require.NoError(t, err) + + csrfToken := createKeyspaceCSRFToken(t, s) + form := url.Values{} + form.Set("cluster_id", "local") + form.Set("csrf_token", csrfToken) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/keyspaces/create", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.AddCookie(&http.Cookie{Name: csrfCookieName, Value: csrfToken}) + s.ServeHTTP(rec, req) + + assert.Nil(t, fake.createKeyspaceRequest) + assert.Equal(t, http.StatusBadRequest, rec.Code) + assert.Equal(t, "text/html; charset=utf-8", rec.Header().Get("Content-Type")) + assert.Contains(t, rec.Body.String(), "keyspace name is required") +} + +func TestCreateKeyspaceHiddenInReadOnlyMode(t *testing.T) { + s, err := NewServer(&actionFakeServer{}, Options{ReadOnly: true}) + require.NoError(t, err) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/keyspaces/create", nil) + req.AddCookie(&http.Cookie{Name: flashCookieName, Value: encodeFlash(Flash{Kind: "success", Message: "created keyspace stale"})}) + s.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusForbidden, rec.Code) + assert.Equal(t, "text/html; charset=utf-8", rec.Header().Get("Content-Type")) + assert.Contains(t, rec.Body.String(), "read-only") + assert.NotContains(t, rec.Body.String(), "created keyspace stale") +} + +func TestCreateKeyspaceFormSetsCSRFCookieOnlyOnForm(t *testing.T) { + s, err := NewServer(&actionFakeServer{}, Options{}) + require.NoError(t, err) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/keyspaces/create", nil) + s.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + assert.NotNil(t, findCookie(rec, csrfCookieName)) +} + +func TestCreateKeyspaceFormMarksCSRFCookieSecureOnTLS(t *testing.T) { + s, err := NewServer(&actionFakeServer{}, Options{}) + require.NoError(t, err) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/keyspaces/create", nil) + req.TLS = &tls.ConnectionState{} + s.ServeHTTP(rec, req) + + csrfCookie := findCookie(rec, csrfCookieName) + require.NotNil(t, csrfCookie) + assert.True(t, csrfCookie.Secure) +} + +func createKeyspaceCSRFToken(t *testing.T, s *Server) string { + t.Helper() + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/keyspaces/create", nil) + s.ServeHTTP(rec, req) + require.Equal(t, http.StatusOK, rec.Code) + + csrfCookie := findCookie(rec, csrfCookieName) + require.NotNil(t, csrfCookie) + + matches := regexp.MustCompile(`name="csrf_token" value="([^"]+)"`).FindStringSubmatch(rec.Body.String()) + require.Len(t, matches, 2) + assert.Equal(t, csrfCookie.Value, matches[1]) + return matches[1] +} + +func findCookie(rec *httptest.ResponseRecorder, name string) *http.Cookie { + for _, cookie := range rec.Result().Cookies() { + if cookie.Name == name { + return cookie + } + } + return nil +} diff --git a/go/vt/vtadmin/vtadmin2/api.go b/go/vt/vtadmin/vtadmin2/api.go new file mode 100644 index 00000000000..1563587cf98 --- /dev/null +++ b/go/vt/vtadmin/vtadmin2/api.go @@ -0,0 +1,95 @@ +/* +Copyright 2026 The Vitess Authors. + +Licensed under the Apache License, Version 2.0 the "License"; +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package vtadmin2 + +import ( + "context" + + vtadminpb "vitess.io/vitess/go/vt/proto/vtadmin" + vtctldatapb "vitess.io/vitess/go/vt/proto/vtctldata" +) + +// vtAdminAPI defines exactly the subset of the vtadmin API that the +// server-rendered UI consumes. Declaring it here keeps vtadmin2 decoupled +// from the full generated VTAdminServer interface; vtadmin.API satisfies it. +type ( + vtAdminAPI interface { + ApplySchema(ctx context.Context, req *vtadminpb.ApplySchemaRequest) (*vtctldatapb.ApplySchemaResponse, error) + ConcludeTransaction(ctx context.Context, req *vtadminpb.ConcludeTransactionRequest) (*vtctldatapb.ConcludeTransactionResponse, error) + CreateKeyspace(ctx context.Context, req *vtadminpb.CreateKeyspaceRequest) (*vtadminpb.CreateKeyspaceResponse, error) + CreateShard(ctx context.Context, req *vtadminpb.CreateShardRequest) (*vtctldatapb.CreateShardResponse, error) + DeleteShards(ctx context.Context, req *vtadminpb.DeleteShardsRequest) (*vtctldatapb.DeleteShardsResponse, error) + DeleteTablet(ctx context.Context, req *vtadminpb.DeleteTabletRequest) (*vtadminpb.DeleteTabletResponse, error) + EmergencyFailoverShard(ctx context.Context, req *vtadminpb.EmergencyFailoverShardRequest) (*vtadminpb.EmergencyFailoverShardResponse, error) + GetBackups(ctx context.Context, req *vtadminpb.GetBackupsRequest) (*vtadminpb.GetBackupsResponse, error) + GetCellInfos(ctx context.Context, req *vtadminpb.GetCellInfosRequest) (*vtadminpb.GetCellInfosResponse, error) + GetCellsAliases(ctx context.Context, req *vtadminpb.GetCellsAliasesRequest) (*vtadminpb.GetCellsAliasesResponse, error) + GetClusters(ctx context.Context, req *vtadminpb.GetClustersRequest) (*vtadminpb.GetClustersResponse, error) + GetFullStatus(ctx context.Context, req *vtadminpb.GetFullStatusRequest) (*vtctldatapb.GetFullStatusResponse, error) + GetGates(ctx context.Context, req *vtadminpb.GetGatesRequest) (*vtadminpb.GetGatesResponse, error) + GetKeyspace(ctx context.Context, req *vtadminpb.GetKeyspaceRequest) (*vtadminpb.Keyspace, error) + GetKeyspaces(ctx context.Context, req *vtadminpb.GetKeyspacesRequest) (*vtadminpb.GetKeyspacesResponse, error) + GetSchema(ctx context.Context, req *vtadminpb.GetSchemaRequest) (*vtadminpb.Schema, error) + GetSchemaMigrations(ctx context.Context, req *vtadminpb.GetSchemaMigrationsRequest) (*vtadminpb.GetSchemaMigrationsResponse, error) + GetSchemas(ctx context.Context, req *vtadminpb.GetSchemasRequest) (*vtadminpb.GetSchemasResponse, error) + GetShardReplicationPositions(ctx context.Context, req *vtadminpb.GetShardReplicationPositionsRequest) (*vtadminpb.GetShardReplicationPositionsResponse, error) + GetSrvKeyspaces(ctx context.Context, req *vtadminpb.GetSrvKeyspacesRequest) (*vtadminpb.GetSrvKeyspacesResponse, error) + GetSrvVSchemas(ctx context.Context, req *vtadminpb.GetSrvVSchemasRequest) (*vtadminpb.GetSrvVSchemasResponse, error) + GetTablet(ctx context.Context, req *vtadminpb.GetTabletRequest) (*vtadminpb.Tablet, error) + GetTablets(ctx context.Context, req *vtadminpb.GetTabletsRequest) (*vtadminpb.GetTabletsResponse, error) + GetTopologyPath(ctx context.Context, req *vtadminpb.GetTopologyPathRequest) (*vtctldatapb.GetTopologyPathResponse, error) + GetTransactionInfo(ctx context.Context, req *vtadminpb.GetTransactionInfoRequest) (*vtctldatapb.GetTransactionInfoResponse, error) + GetUnresolvedTransactions(ctx context.Context, req *vtadminpb.GetUnresolvedTransactionsRequest) (*vtctldatapb.GetUnresolvedTransactionsResponse, error) + GetVSchema(ctx context.Context, req *vtadminpb.GetVSchemaRequest) (*vtadminpb.VSchema, error) + GetVSchemas(ctx context.Context, req *vtadminpb.GetVSchemasRequest) (*vtadminpb.GetVSchemasResponse, error) + GetVtctlds(ctx context.Context, req *vtadminpb.GetVtctldsRequest) (*vtadminpb.GetVtctldsResponse, error) + GetWorkflow(ctx context.Context, req *vtadminpb.GetWorkflowRequest) (*vtadminpb.Workflow, error) + GetWorkflows(ctx context.Context, req *vtadminpb.GetWorkflowsRequest) (*vtadminpb.GetWorkflowsResponse, error) + GetWorkflowStatus(ctx context.Context, req *vtadminpb.GetWorkflowStatusRequest) (*vtctldatapb.WorkflowStatusResponse, error) + MaterializeCreate(ctx context.Context, req *vtadminpb.MaterializeCreateRequest) (*vtctldatapb.MaterializeCreateResponse, error) + MoveTablesComplete(ctx context.Context, req *vtadminpb.MoveTablesCompleteRequest) (*vtctldatapb.MoveTablesCompleteResponse, error) + MoveTablesCreate(ctx context.Context, req *vtadminpb.MoveTablesCreateRequest) (*vtctldatapb.WorkflowStatusResponse, error) + PingTablet(ctx context.Context, req *vtadminpb.PingTabletRequest) (*vtadminpb.PingTabletResponse, error) + PlannedFailoverShard(ctx context.Context, req *vtadminpb.PlannedFailoverShardRequest) (*vtadminpb.PlannedFailoverShardResponse, error) + RebuildKeyspaceGraph(ctx context.Context, req *vtadminpb.RebuildKeyspaceGraphRequest) (*vtadminpb.RebuildKeyspaceGraphResponse, error) + RefreshState(ctx context.Context, req *vtadminpb.RefreshStateRequest) (*vtadminpb.RefreshStateResponse, error) + RefreshTabletReplicationSource(ctx context.Context, req *vtadminpb.RefreshTabletReplicationSourceRequest) (*vtadminpb.RefreshTabletReplicationSourceResponse, error) + ReloadSchemas(ctx context.Context, req *vtadminpb.ReloadSchemasRequest) (*vtadminpb.ReloadSchemasResponse, error) + ReloadSchemaShard(ctx context.Context, req *vtadminpb.ReloadSchemaShardRequest) (*vtadminpb.ReloadSchemaShardResponse, error) + RemoveKeyspaceCell(ctx context.Context, req *vtadminpb.RemoveKeyspaceCellRequest) (*vtadminpb.RemoveKeyspaceCellResponse, error) + ReshardCreate(ctx context.Context, req *vtadminpb.ReshardCreateRequest) (*vtctldatapb.WorkflowStatusResponse, error) + RunHealthCheck(ctx context.Context, req *vtadminpb.RunHealthCheckRequest) (*vtadminpb.RunHealthCheckResponse, error) + SetReadOnly(ctx context.Context, req *vtadminpb.SetReadOnlyRequest) (*vtadminpb.SetReadOnlyResponse, error) + SetReadWrite(ctx context.Context, req *vtadminpb.SetReadWriteRequest) (*vtadminpb.SetReadWriteResponse, error) + StartReplication(ctx context.Context, req *vtadminpb.StartReplicationRequest) (*vtadminpb.StartReplicationResponse, error) + StartWorkflow(ctx context.Context, req *vtadminpb.StartWorkflowRequest) (*vtctldatapb.WorkflowUpdateResponse, error) + StopReplication(ctx context.Context, req *vtadminpb.StopReplicationRequest) (*vtadminpb.StopReplicationResponse, error) + StopWorkflow(ctx context.Context, req *vtadminpb.StopWorkflowRequest) (*vtctldatapb.WorkflowUpdateResponse, error) + TabletExternallyPromoted(ctx context.Context, req *vtadminpb.TabletExternallyPromotedRequest) (*vtadminpb.TabletExternallyPromotedResponse, error) + ValidateKeyspace(ctx context.Context, req *vtadminpb.ValidateKeyspaceRequest) (*vtctldatapb.ValidateKeyspaceResponse, error) + ValidateSchemaKeyspace(ctx context.Context, req *vtadminpb.ValidateSchemaKeyspaceRequest) (*vtctldatapb.ValidateSchemaKeyspaceResponse, error) + ValidateShard(ctx context.Context, req *vtadminpb.ValidateShardRequest) (*vtctldatapb.ValidateShardResponse, error) + ValidateVersionKeyspace(ctx context.Context, req *vtadminpb.ValidateVersionKeyspaceRequest) (*vtctldatapb.ValidateVersionKeyspaceResponse, error) + ValidateVersionShard(ctx context.Context, req *vtadminpb.ValidateVersionShardRequest) (*vtctldatapb.ValidateVersionShardResponse, error) + VDiffCreate(ctx context.Context, req *vtadminpb.VDiffCreateRequest) (*vtctldatapb.VDiffCreateResponse, error) + VDiffShow(ctx context.Context, req *vtadminpb.VDiffShowRequest) (*vtadminpb.VDiffShowResponse, error) + VExplain(ctx context.Context, req *vtadminpb.VExplainRequest) (*vtadminpb.VExplainResponse, error) + VTExplain(ctx context.Context, req *vtadminpb.VTExplainRequest) (*vtadminpb.VTExplainResponse, error) + WorkflowDelete(ctx context.Context, req *vtadminpb.WorkflowDeleteRequest) (*vtctldatapb.WorkflowDeleteResponse, error) + WorkflowSwitchTraffic(ctx context.Context, req *vtadminpb.WorkflowSwitchTrafficRequest) (*vtctldatapb.WorkflowSwitchTrafficResponse, error) + } +) diff --git a/go/vt/vtadmin/vtadmin2/assets.go b/go/vt/vtadmin/vtadmin2/assets.go new file mode 100644 index 00000000000..d961445e3e6 --- /dev/null +++ b/go/vt/vtadmin/vtadmin2/assets.go @@ -0,0 +1,22 @@ +/* +Copyright 2026 The Vitess Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package vtadmin2 + +import "embed" + +//go:embed templates/*.html static/* +var assets embed.FS diff --git a/go/vt/vtadmin/vtadmin2/cookie_security_test.go b/go/vt/vtadmin/vtadmin2/cookie_security_test.go new file mode 100644 index 00000000000..de6a3fce961 --- /dev/null +++ b/go/vt/vtadmin/vtadmin2/cookie_security_test.go @@ -0,0 +1,108 @@ +/* +Copyright 2026 The Vitess Authors. + +Licensed under the Apache License, Version 2.0 the "License"; +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package vtadmin2 + +import ( + "crypto/tls" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// getSettingsWithCSRF renders the settings page (which mints a CSRF cookie) +// and returns the CSRF cookie for cookie-security assertions. +func getSettingsWithCSRF(t *testing.T, s *Server, header http.Header) *http.Cookie { + t.Helper() + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/settings", nil) + if header != nil { + req.Header = header + } + s.ServeHTTP(rec, req) + require.Equal(t, http.StatusOK, rec.Code) + + cookie := findCookie(rec, csrfCookieName) + require.NotNil(t, cookie, "no CSRF cookie issued") + return cookie +} + +func TestCSRFCookieNotSecureOverPlainHTTP(t *testing.T) { + fake := &settingsFakeServer{} + s, err := NewServer(fake, Options{}) + require.NoError(t, err) + + cookie := getSettingsWithCSRF(t, s, nil) + + assert.False(t, cookie.Secure, "cookies must not be Secure over plain HTTP") +} + +func TestCSRFCookieSecureOverDirectTLS(t *testing.T) { + fake := &settingsFakeServer{} + s, err := NewServer(fake, Options{}) + require.NoError(t, err) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/settings", nil) + req.TLS = &tls.ConnectionState{} // non-nil signals direct TLS + s.ServeHTTP(rec, req) + require.Equal(t, http.StatusOK, rec.Code) + + cookie := findCookie(rec, csrfCookieName) + require.NotNil(t, cookie) + assert.True(t, cookie.Secure, "cookies must be Secure over direct TLS") +} + +func TestCSRFCookieBehindTrustedProxy(t *testing.T) { + fake := &settingsFakeServer{} + + t.Run("enabled trusts X-Forwarded-Proto https", func(t *testing.T) { + s, err := NewServer(fake, Options{TrustProxyProto: true}) + require.NoError(t, err) + + cookie := getSettingsWithCSRF(t, s, http.Header{ + "X-Forwarded-Proto": []string{"https"}, + }) + + assert.True(t, cookie.Secure, "cookies must be Secure behind a trusted HTTPS proxy") + }) + + t.Run("enabled ignores plaintext forwarded proto", func(t *testing.T) { + s, err := NewServer(fake, Options{TrustProxyProto: true}) + require.NoError(t, err) + + cookie := getSettingsWithCSRF(t, s, http.Header{ + "X-Forwarded-Proto": []string{"http"}, + }) + + assert.False(t, cookie.Secure, "X-Forwarded-Proto: http must not make cookies Secure") + }) + + t.Run("disabled ignores forwarded header", func(t *testing.T) { + s, err := NewServer(fake, Options{}) + require.NoError(t, err) + + cookie := getSettingsWithCSRF(t, s, http.Header{ + "X-Forwarded-Proto": []string{"https"}, + }) + + assert.False(t, cookie.Secure, "X-Forwarded-Proto must be ignored unless TrustProxyProto is enabled") + }) +} diff --git a/go/vt/vtadmin/vtadmin2/csrf_test.go b/go/vt/vtadmin/vtadmin2/csrf_test.go new file mode 100644 index 00000000000..bde6158c5f8 --- /dev/null +++ b/go/vt/vtadmin/vtadmin2/csrf_test.go @@ -0,0 +1,66 @@ +/* +Copyright 2026 The Vitess Authors. + +Licensed under the Apache License, Version 2.0 the "License"; +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package vtadmin2 + +import ( + "net/http" + "net/http/httptest" + "net/url" + "regexp" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +var csrfInputRe = regexp.MustCompile(`name="csrf_token" value="([^"]+)"`) + +// renderWithCSRF performs a GET of a page and returns the response along with +// the CSRF token rendered into it. It fails the test if the CSRF cookie or +// the rendered token is missing, or if the rendered token does not match the +// issued cookie — the exact pairing a browser would rely on. +func renderWithCSRF(t *testing.T, s *Server, path string) (token string, rec *httptest.ResponseRecorder) { + t.Helper() + + rec = httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, path, nil) + s.ServeHTTP(rec, req) + require.Equal(t, http.StatusOK, rec.Code, "GET %s did not render", path) + + cookie := findCookie(rec, csrfCookieName) + require.NotNil(t, cookie, "no CSRF cookie issued for %s", path) + require.NotEmpty(t, cookie.Value, "CSRF cookie is empty for %s", path) + + match := csrfInputRe.FindStringSubmatch(rec.Body.String()) + require.NotNil(t, match, "no csrf_token input rendered for %s", path) + require.NotEmpty(t, match[1], "rendered csrf_token value is empty for %s", path) + require.Equal(t, cookie.Value, match[1], "rendered csrf_token does not match the issued cookie for %s", path) + + return match[1], rec +} + +// postFormWithCSRF submits a form with the given CSRF pairing, without +// rewriting the form's token (unlike postShardForm, which uses the fixed +// test pairing). The csrf_token must already be present in the form. +func postFormWithCSRF(s *Server, path, csrfCookie string, form url.Values) *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.AddCookie(&http.Cookie{Name: csrfCookieName, Value: csrfCookie}) + rec := httptest.NewRecorder() + s.ServeHTTP(rec, req) + return rec +} diff --git a/go/vt/vtadmin/vtadmin2/debug_json_test.go b/go/vt/vtadmin/vtadmin2/debug_json_test.go new file mode 100644 index 00000000000..aee1e1abb99 --- /dev/null +++ b/go/vt/vtadmin/vtadmin2/debug_json_test.go @@ -0,0 +1,73 @@ +/* +Copyright 2026 The Vitess Authors. + +Licensed under the Apache License, Version 2.0 the "License"; +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package vtadmin2 + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + topodatapb "vitess.io/vitess/go/vt/proto/topodata" + vtadminpb "vitess.io/vitess/go/vt/proto/vtadmin" +) + +type debugJSONFakeServer struct { + fakeVTAdminServer +} + +func (f *debugJSONFakeServer) GetTablets(ctx context.Context, req *vtadminpb.GetTabletsRequest) (*vtadminpb.GetTabletsResponse, error) { + tablet := &vtadminpb.Tablet{ + Cluster: &vtadminpb.Cluster{Id: "local"}, + Tablet: &topodatapb.Tablet{ + Alias: &topodatapb.TabletAlias{Cell: "zone1", Uid: 100}, + Keyspace: "commerce", + Shard: "0", + }, + } + return &vtadminpb.GetTabletsResponse{Tablets: []*vtadminpb.Tablet{tablet}}, nil +} + +func TestDebugJSONRendersPageData(t *testing.T) { + fake := &debugJSONFakeServer{} + s, err := NewServer(fake, Options{EnableDebugJSON: true}) + require.NoError(t, err) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/tablets?format=json", nil) + s.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + assert.Contains(t, rec.Header().Get("Content-Type"), "application/json") + assert.Contains(t, rec.Body.String(), "zone1") +} + +func TestDebugJSONDisabledByDefault(t *testing.T) { + fake := &debugJSONFakeServer{} + s, err := NewServer(fake, Options{}) + require.NoError(t, err) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/tablets?format=json", nil) + s.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + assert.NotContains(t, rec.Header().Get("Content-Type"), "application/json") +} diff --git a/go/vt/vtadmin/vtadmin2/http_server.go b/go/vt/vtadmin/vtadmin2/http_server.go new file mode 100644 index 00000000000..d8e7b8d347f --- /dev/null +++ b/go/vt/vtadmin/vtadmin2/http_server.go @@ -0,0 +1,37 @@ +/* +Copyright 2026 The Vitess Authors. + +Licensed under the Apache License, Version 2.0 the "License"; +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package vtadmin2 + +import ( + "net/http" + "time" +) + +// NewHTTPServer creates the bounded HTTP server configuration shared by the +// standalone and integrated vtadmin2 entrypoints. The write timeout covers +// failovers, traffic switches, and schema changes. VDiff creation is +// asynchronous; operators poll the show page instead of waiting in-request. +func NewHTTPServer(addr string, handler http.Handler) *http.Server { + return &http.Server{ + Addr: addr, + Handler: handler, + ReadHeaderTimeout: 10 * time.Second, + ReadTimeout: 30 * time.Second, + WriteTimeout: 5 * time.Minute, + IdleTimeout: 120 * time.Second, + } +} diff --git a/go/vt/vtadmin/vtadmin2/inventory.go b/go/vt/vtadmin/vtadmin2/inventory.go new file mode 100644 index 00000000000..1a7b17dc84b --- /dev/null +++ b/go/vt/vtadmin/vtadmin2/inventory.go @@ -0,0 +1,162 @@ +/* +Copyright 2026 The Vitess Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package vtadmin2 + +import ( + "net/http" + "strconv" + + vtadminpb "vitess.io/vitess/go/vt/proto/vtadmin" + vtctldatapb "vitess.io/vitess/go/vt/proto/vtctldata" + vtrpcpb "vitess.io/vitess/go/vt/proto/vtrpc" + "vitess.io/vitess/go/vt/vterrors" +) + +func (s *Server) gates(w http.ResponseWriter, r *http.Request) { + resp, err := s.api.GetGates(r.Context(), &vtadminpb.GetGatesRequest{ + ClusterIds: queryValues(r, "cluster_id"), + }) + if err != nil { + s.renderError(w, r, http.StatusInternalServerError, "VTGates", err) + return + } + + s.render(w, r, http.StatusOK, "gates.html", PageData{ + Title: "VTGates", + Active: "gates", + Data: resp.GetGates(), + }) +} + +func (s *Server) vtctlds(w http.ResponseWriter, r *http.Request) { + resp, err := s.api.GetVtctlds(r.Context(), &vtadminpb.GetVtctldsRequest{ + ClusterIds: queryValues(r, "cluster_id"), + }) + if err != nil { + s.renderError(w, r, http.StatusInternalServerError, "VTCtlds", err) + return + } + + s.render(w, r, http.StatusOK, "vtctlds.html", PageData{ + Title: "VTCtlds", + Active: "vtctlds", + Data: resp.GetVtctlds(), + }) +} + +func (s *Server) cells(w http.ResponseWriter, r *http.Request) { + namesOnly, err := parseQueryBool(r, "names_only", false) + if err != nil { + s.renderError(w, r, http.StatusBadRequest, "Cells", err) + return + } + + resp, err := s.api.GetCellInfos(r.Context(), &vtadminpb.GetCellInfosRequest{ + ClusterIds: queryValues(r, "cluster_id"), + Cells: queryValues(r, "cell"), + NamesOnly: namesOnly, + }) + if err != nil { + s.renderError(w, r, http.StatusInternalServerError, "Cells", err) + return + } + + s.render(w, r, http.StatusOK, "cells.html", PageData{ + Title: "Cells", + Active: "cells", + Data: resp.GetCellInfos(), + }) +} + +func (s *Server) cellsAliases(w http.ResponseWriter, r *http.Request) { + resp, err := s.api.GetCellsAliases(r.Context(), &vtadminpb.GetCellsAliasesRequest{ + ClusterIds: queryValues(r, "cluster_id"), + }) + if err != nil { + s.renderError(w, r, http.StatusInternalServerError, "Cell Aliases", err) + return + } + + s.render(w, r, http.StatusOK, "cells_aliases.html", PageData{ + Title: "Cell Aliases", + Active: "cells_aliases", + Data: resp.GetAliases(), + }) +} + +func (s *Server) backups(w http.ResponseWriter, r *http.Request) { + limit, err := parseQueryUint32(r, "limit", 0) + if err != nil { + s.renderError(w, r, http.StatusBadRequest, "Backups", err) + return + } + detailed, err := parseQueryBool(r, "detailed", true) + if err != nil { + s.renderError(w, r, http.StatusBadRequest, "Backups", err) + return + } + detailedLimit, err := parseQueryUint32(r, "detailed_limit", 3) + if err != nil { + s.renderError(w, r, http.StatusBadRequest, "Backups", err) + return + } + + resp, err := s.api.GetBackups(r.Context(), &vtadminpb.GetBackupsRequest{ + ClusterIds: queryValues(r, "cluster_id"), + Keyspaces: queryValues(r, "keyspace"), + KeyspaceShards: queryValues(r, "keyspace_shard"), + RequestOptions: &vtctldatapb.GetBackupsRequest{ + Limit: limit, + Detailed: detailed, + DetailedLimit: detailedLimit, + }, + }) + if err != nil { + s.renderError(w, r, http.StatusInternalServerError, "Backups", err) + return + } + + s.render(w, r, http.StatusOK, "backups.html", PageData{ + Title: "Backups", + Active: "backups", + Data: resp.GetBackups(), + }) +} + +func parseQueryBool(r *http.Request, name string, defaultVal bool) (bool, error) { + param := queryValue(r, name) + if param == "" { + return defaultVal, nil + } + val, err := strconv.ParseBool(param) + if err != nil { + return defaultVal, vterrors.Errorf(vtrpcpb.Code_INVALID_ARGUMENT, "could not parse query parameter %s (= %s) into bool value", name, param) + } + return val, nil +} + +func parseQueryUint32(r *http.Request, name string, defaultVal uint32) (uint32, error) { + param := queryValue(r, name) + if param == "" { + return defaultVal, nil + } + val, err := strconv.ParseUint(param, 10, 32) + if err != nil { + return defaultVal, vterrors.Errorf(vtrpcpb.Code_INVALID_ARGUMENT, "could not parse query parameter %s (= %s) into uint32 value", name, param) + } + return uint32(val), nil +} diff --git a/go/vt/vtadmin/vtadmin2/inventory_test.go b/go/vt/vtadmin/vtadmin2/inventory_test.go new file mode 100644 index 00000000000..1e2478ddc36 --- /dev/null +++ b/go/vt/vtadmin/vtadmin2/inventory_test.go @@ -0,0 +1,227 @@ +/* +Copyright 2026 The Vitess Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package vtadmin2 + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + mysqlctlpb "vitess.io/vitess/go/vt/proto/mysqlctl" + topodatapb "vitess.io/vitess/go/vt/proto/topodata" + vtadminpb "vitess.io/vitess/go/vt/proto/vtadmin" +) + +type inventoryFakeServer struct { + fakeVTAdminServer + cellInfosRequest *vtadminpb.GetCellInfosRequest + cellAliasesRequest *vtadminpb.GetCellsAliasesRequest + backupsRequest *vtadminpb.GetBackupsRequest +} + +func (f *inventoryFakeServer) GetGates(ctx context.Context, req *vtadminpb.GetGatesRequest) (*vtadminpb.GetGatesResponse, error) { + return &vtadminpb.GetGatesResponse{Gates: []*vtadminpb.VTGate{{Cluster: &vtadminpb.Cluster{Id: "local", Name: "Local"}, Hostname: "vtgate-1"}}}, nil +} + +func (f *inventoryFakeServer) GetVtctlds(ctx context.Context, req *vtadminpb.GetVtctldsRequest) (*vtadminpb.GetVtctldsResponse, error) { + return &vtadminpb.GetVtctldsResponse{Vtctlds: []*vtadminpb.Vtctld{{Cluster: &vtadminpb.Cluster{Id: "local", Name: "Local"}, Hostname: "vtctld-1", FQDN: "vtctld-1.example.com:15000"}}}, nil +} + +func (f *inventoryFakeServer) GetCellInfos(ctx context.Context, req *vtadminpb.GetCellInfosRequest) (*vtadminpb.GetCellInfosResponse, error) { + f.cellInfosRequest = req + return &vtadminpb.GetCellInfosResponse{CellInfos: []*vtadminpb.ClusterCellInfo{{Cluster: &vtadminpb.Cluster{Id: "local", Name: "Local"}, Name: "zone1"}}}, nil +} + +func (f *inventoryFakeServer) GetCellsAliases(ctx context.Context, req *vtadminpb.GetCellsAliasesRequest) (*vtadminpb.GetCellsAliasesResponse, error) { + f.cellAliasesRequest = req + return &vtadminpb.GetCellsAliasesResponse{Aliases: []*vtadminpb.ClusterCellsAliases{{Cluster: &vtadminpb.Cluster{Id: "local", Name: "Local"}, Aliases: map[string]*topodatapb.CellsAlias{"regional": {Cells: []string{"zone1", "zone2"}}}}}}, nil +} + +func (f *inventoryFakeServer) GetBackups(ctx context.Context, req *vtadminpb.GetBackupsRequest) (*vtadminpb.GetBackupsResponse, error) { + f.backupsRequest = req + return &vtadminpb.GetBackupsResponse{Backups: []*vtadminpb.ClusterBackup{{Cluster: &vtadminpb.Cluster{Id: "local", Name: "Local"}, Backup: &mysqlctlpb.BackupInfo{Name: "backup-1", Keyspace: "commerce", Shard: "0"}}}}, nil +} + +func TestGatesPageRendersRows(t *testing.T) { + s, err := NewServer(&inventoryFakeServer{}, Options{}) + require.NoError(t, err) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/vtgates", nil) + s.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + assert.Contains(t, rec.Body.String(), "VTGates") + assert.NotContains(t, rec.Body.String(), ">Gates<") + assert.Contains(t, rec.Body.String(), `href="http://vtgate-1" target="_blank" rel="noopener noreferrer"`) + assert.Contains(t, rec.Body.String(), "vtgate-1") +} + +func TestVtctldsPageRendersRows(t *testing.T) { + s, err := NewServer(&inventoryFakeServer{}, Options{}) + require.NoError(t, err) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/vtctlds", nil) + s.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + assert.Contains(t, rec.Body.String(), "VTCtlds") + assert.NotContains(t, rec.Body.String(), "Vtctlds") + assert.Contains(t, rec.Body.String(), `href="http://vtctld-1.example.com:15000/debug/status" target="_blank" rel="noopener noreferrer"`) + assert.Contains(t, rec.Body.String(), "vtctld-1") +} + +func TestCellsPageRendersRows(t *testing.T) { + s, err := NewServer(&inventoryFakeServer{}, Options{}) + require.NoError(t, err) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/cells", nil) + s.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + assert.Contains(t, rec.Body.String(), "Cells") + assert.Contains(t, rec.Body.String(), "zone1") +} + +func TestCellsPagePassesFilters(t *testing.T) { + fake := &inventoryFakeServer{} + s, err := NewServer(fake, Options{}) + require.NoError(t, err) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/cells?cluster_id=local&cell=zone1&names_only=true", nil) + s.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + require.NotNil(t, fake.cellInfosRequest) + assert.Equal(t, []string{"local"}, fake.cellInfosRequest.GetClusterIds()) + assert.Equal(t, []string{"zone1"}, fake.cellInfosRequest.GetCells()) + assert.True(t, fake.cellInfosRequest.GetNamesOnly()) +} + +func TestCellsPageRejectsInvalidNamesOnly(t *testing.T) { + fake := &inventoryFakeServer{} + s, err := NewServer(fake, Options{}) + require.NoError(t, err) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/cells?names_only=not-bool", nil) + s.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusBadRequest, rec.Code) + assert.Contains(t, rec.Body.String(), "could not parse query parameter names_only") + assert.Nil(t, fake.cellInfosRequest) +} + +func TestCellsAliasesPageRendersRows(t *testing.T) { + s, err := NewServer(&inventoryFakeServer{}, Options{}) + require.NoError(t, err) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/cells_aliases", nil) + s.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + assert.Contains(t, rec.Body.String(), "Cell Aliases") + assert.Contains(t, rec.Body.String(), "regional") + assert.Contains(t, rec.Body.String(), "zone1") + assert.Contains(t, rec.Body.String(), "zone2") +} + +func TestCellsAliasesPagePassesFilters(t *testing.T) { + fake := &inventoryFakeServer{} + s, err := NewServer(fake, Options{}) + require.NoError(t, err) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/cells_aliases?cluster_id=local", nil) + s.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + require.NotNil(t, fake.cellAliasesRequest) + assert.Equal(t, []string{"local"}, fake.cellAliasesRequest.GetClusterIds()) +} + +func TestBackupsPageRendersRows(t *testing.T) { + s, err := NewServer(&inventoryFakeServer{}, Options{}) + require.NoError(t, err) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/backups", nil) + s.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + assert.Contains(t, rec.Body.String(), "Backups") + assert.Contains(t, rec.Body.String(), "backup-1") + assert.Contains(t, rec.Body.String(), "commerce") +} + +func TestBackupsPagePassesFilters(t *testing.T) { + fake := &inventoryFakeServer{} + s, err := NewServer(fake, Options{}) + require.NoError(t, err) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/backups?cluster_id=local&keyspace=commerce&keyspace_shard=commerce/0&limit=10&detailed=false&detailed_limit=2", nil) + s.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + require.NotNil(t, fake.backupsRequest) + assert.Equal(t, []string{"local"}, fake.backupsRequest.GetClusterIds()) + assert.Equal(t, []string{"commerce"}, fake.backupsRequest.GetKeyspaces()) + assert.Equal(t, []string{"commerce/0"}, fake.backupsRequest.GetKeyspaceShards()) + require.NotNil(t, fake.backupsRequest.GetRequestOptions()) + assert.EqualValues(t, 10, fake.backupsRequest.GetRequestOptions().GetLimit()) + assert.False(t, fake.backupsRequest.GetRequestOptions().GetDetailed()) + assert.EqualValues(t, 2, fake.backupsRequest.GetRequestOptions().GetDetailedLimit()) +} + +func TestBackupsPageUsesDetailedDefaults(t *testing.T) { + fake := &inventoryFakeServer{} + s, err := NewServer(fake, Options{}) + require.NoError(t, err) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/backups", nil) + s.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + require.NotNil(t, fake.backupsRequest) + require.NotNil(t, fake.backupsRequest.GetRequestOptions()) + assert.True(t, fake.backupsRequest.GetRequestOptions().GetDetailed()) + assert.EqualValues(t, 3, fake.backupsRequest.GetRequestOptions().GetDetailedLimit()) +} + +func TestBackupsPageRejectsInvalidQueryValues(t *testing.T) { + fake := &inventoryFakeServer{} + s, err := NewServer(fake, Options{}) + require.NoError(t, err) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/backups?limit=bad", nil) + s.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusBadRequest, rec.Code) + assert.Contains(t, rec.Body.String(), "could not parse query parameter limit") + assert.Nil(t, fake.backupsRequest) +} diff --git a/go/vt/vtadmin/vtadmin2/keyspace_actions.go b/go/vt/vtadmin/vtadmin2/keyspace_actions.go new file mode 100644 index 00000000000..c68fea9aafc --- /dev/null +++ b/go/vt/vtadmin/vtadmin2/keyspace_actions.go @@ -0,0 +1,255 @@ +/* +Copyright 2026 The Vitess Authors. + +Licensed under the Apache License, Version 2.0 the "License"; +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package vtadmin2 + +import ( + "net/http" + "strings" + + vtadminpb "vitess.io/vitess/go/vt/proto/vtadmin" + vtctldatapb "vitess.io/vitess/go/vt/proto/vtctldata" +) + +// beginKeyspaceAction is the shared preflight for keyspace mutation handlers. +// It returns the parsed route values; ok is false when the preflight has +// already rendered an error response. +func (s *Server) beginKeyspaceAction(w http.ResponseWriter, r *http.Request, title string) (clusterID, keyspace string, ok bool) { + if !s.beginFormAction(w, r, title) { + return "", "", false + } + return r.PathValue("cluster_id"), r.PathValue("name"), true +} + +func (s *Server) keyspaceValidate(w http.ResponseWriter, r *http.Request) { + const title = "Validate keyspace" + clusterID, keyspace, ok := s.beginKeyspaceAction(w, r, title) + if !ok { + return + } + + resp, err := s.api.ValidateKeyspace(r.Context(), &vtadminpb.ValidateKeyspaceRequest{ + ClusterId: clusterID, + Keyspace: keyspace, + PingTablets: r.Form.Get("ping_tablets") == "on", + }) + if err != nil { + s.renderFormErrorErr(w, r, title, err) + return + } + if resp == nil { + s.renderFormError(w, r, title, "not authorized to validate keyspace") + return + } + + s.redirectWithFlash(w, r, keyspaceDetailPath(clusterID, keyspace), Flash{ + Kind: "success", + Message: "validated keyspace " + keyspace + ": " + strings.Join(resp.GetResults(), "; "), + }) +} + +func (s *Server) keyspaceValidateSchema(w http.ResponseWriter, r *http.Request) { + const title = "Validate schema" + clusterID, keyspace, ok := s.beginKeyspaceAction(w, r, title) + if !ok { + return + } + + resp, err := s.api.ValidateSchemaKeyspace(r.Context(), &vtadminpb.ValidateSchemaKeyspaceRequest{ + ClusterId: clusterID, + Keyspace: keyspace, + }) + if err != nil { + s.renderFormErrorErr(w, r, title, err) + return + } + if resp == nil { + s.renderFormError(w, r, title, "not authorized to validate schema") + return + } + + s.redirectWithFlash(w, r, keyspaceDetailPath(clusterID, keyspace), Flash{ + Kind: "success", + Message: "validated schema on keyspace " + keyspace, + }) +} + +func (s *Server) keyspaceValidateVersion(w http.ResponseWriter, r *http.Request) { + const title = "Validate version" + clusterID, keyspace, ok := s.beginKeyspaceAction(w, r, title) + if !ok { + return + } + + resp, err := s.api.ValidateVersionKeyspace(r.Context(), &vtadminpb.ValidateVersionKeyspaceRequest{ + ClusterId: clusterID, + Keyspace: keyspace, + }) + if err != nil { + s.renderFormErrorErr(w, r, title, err) + return + } + if resp == nil { + s.renderFormError(w, r, title, "not authorized to validate version") + return + } + + s.redirectWithFlash(w, r, keyspaceDetailPath(clusterID, keyspace), Flash{ + Kind: "success", + Message: "validated versions on keyspace " + keyspace, + }) +} + +func (s *Server) keyspaceRebuildGraph(w http.ResponseWriter, r *http.Request) { + const title = "Rebuild keyspace graph" + clusterID, keyspace, ok := s.beginKeyspaceAction(w, r, title) + if !ok { + return + } + + resp, err := s.api.RebuildKeyspaceGraph(r.Context(), &vtadminpb.RebuildKeyspaceGraphRequest{ + ClusterId: clusterID, + Keyspace: keyspace, + Cells: splitFormList(r.Form.Get("cells")), + }) + if err != nil { + s.renderFormErrorErr(w, r, title, err) + return + } + if resp == nil { + s.renderFormError(w, r, title, "not authorized to rebuild keyspace graph") + return + } + + s.redirectWithFlash(w, r, keyspaceDetailPath(clusterID, keyspace), Flash{ + Kind: "success", + Message: "rebuilt keyspace graph for " + keyspace, + }) +} + +func (s *Server) keyspaceRemoveCell(w http.ResponseWriter, r *http.Request) { + const title = "Remove keyspace cell" + clusterID, keyspace, ok := s.beginKeyspaceAction(w, r, title) + if !ok { + return + } + + cell := strings.TrimSpace(r.Form.Get("cell")) + if cell == "" { + s.renderFormError(w, r, title, "cell is required") + return + } + + resp, err := s.api.RemoveKeyspaceCell(r.Context(), &vtadminpb.RemoveKeyspaceCellRequest{ + ClusterId: clusterID, + Keyspace: keyspace, + Cell: cell, + Force: r.Form.Get("force") == "on", + }) + if err != nil { + s.renderFormErrorErr(w, r, title, err) + return + } + if resp == nil { + s.renderFormError(w, r, title, "not authorized to remove keyspace cell") + return + } + + s.redirectWithFlash(w, r, keyspaceDetailPath(clusterID, keyspace), Flash{ + Kind: "success", + Message: "removed cell " + cell + " from keyspace " + keyspace, + }) +} + +func (s *Server) keyspaceCreateShard(w http.ResponseWriter, r *http.Request) { + const title = "Create shard" + clusterID, keyspace, ok := s.beginKeyspaceAction(w, r, title) + if !ok { + return + } + + shard := strings.TrimSpace(r.Form.Get("shard")) + if shard == "" { + s.renderFormError(w, r, title, "shard name is required") + return + } + + _, err := s.api.CreateShard(r.Context(), &vtadminpb.CreateShardRequest{ + ClusterId: clusterID, + Options: &vtctldatapb.CreateShardRequest{ + Keyspace: keyspace, + ShardName: shard, + Force: r.Form.Get("force") == "on", + }, + }) + if err != nil { + s.renderFormErrorErr(w, r, title, err) + return + } + + s.redirectWithFlash(w, r, shardDetailPath(clusterID, keyspace, shard), Flash{ + Kind: "success", + Message: "created shard " + keyspace + "/" + shard, + }) +} + +func (s *Server) keyspaceReloadSchema(w http.ResponseWriter, r *http.Request) { + const title = "Reload schema" + clusterID, keyspace, ok := s.beginKeyspaceAction(w, r, title) + if !ok { + return + } + + resp, err := s.api.ReloadSchemas(r.Context(), &vtadminpb.ReloadSchemasRequest{ + ClusterIds: []string{clusterID}, + Keyspaces: []string{keyspace}, + IncludePrimary: true, + }) + if err != nil { + s.renderFormErrorErr(w, r, title, err) + return + } + if resp == nil || !reloadSchemasCoveredKeyspace(resp, keyspace) { + s.renderFormError(w, r, title, "not authorized to reload schema") + return + } + for _, result := range resp.GetKeyspaceResults() { + if err := reloadSchemaEventsError(result.GetEvents()); err != nil { + s.renderFormErrorErr(w, r, title, err) + return + } + } + for _, result := range resp.GetShardResults() { + if err := reloadSchemaEventsError(result.GetEvents()); err != nil { + s.renderFormErrorErr(w, r, title, err) + return + } + } + + s.redirectWithFlash(w, r, keyspaceDetailPath(clusterID, keyspace), Flash{ + Kind: "success", + Message: "reloaded schema on keyspace " + keyspace, + }) +} + +func reloadSchemasCoveredKeyspace(resp *vtadminpb.ReloadSchemasResponse, keyspace string) bool { + for _, result := range resp.GetKeyspaceResults() { + if result.GetKeyspace().GetKeyspace().GetName() == keyspace { + return true + } + } + return false +} diff --git a/go/vt/vtadmin/vtadmin2/keyspace_actions_test.go b/go/vt/vtadmin/vtadmin2/keyspace_actions_test.go new file mode 100644 index 00000000000..1f28b0079f5 --- /dev/null +++ b/go/vt/vtadmin/vtadmin2/keyspace_actions_test.go @@ -0,0 +1,388 @@ +/* +Copyright 2026 The Vitess Authors. + +Licensed under the Apache License, Version 2.0 the "License"; +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package vtadmin2 + +import ( + "context" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + logutilpb "vitess.io/vitess/go/vt/proto/logutil" + vtadminpb "vitess.io/vitess/go/vt/proto/vtadmin" + vtctldatapb "vitess.io/vitess/go/vt/proto/vtctldata" +) + +type keyspaceActionsFakeServer struct { + fakeVTAdminServer + + validateKeyspaceReq *vtadminpb.ValidateKeyspaceRequest + validateSchemaKeyspaceReq *vtadminpb.ValidateSchemaKeyspaceRequest + validateVersionKeyspaceReq *vtadminpb.ValidateVersionKeyspaceRequest + rebuildKeyspaceGraphReq *vtadminpb.RebuildKeyspaceGraphRequest + removeKeyspaceCellReq *vtadminpb.RemoveKeyspaceCellRequest + createShardReq *vtadminpb.CreateShardRequest + reloadSchemasReq *vtadminpb.ReloadSchemasRequest + reloadSchemasResp *vtadminpb.ReloadSchemasResponse + reloadSchemasNil bool + validateKeyspaceNil bool + validateSchemaNil bool + validateVersionNil bool + rebuildGraphNil bool + removeCellNil bool +} + +func (f *keyspaceActionsFakeServer) GetKeyspace(ctx context.Context, req *vtadminpb.GetKeyspaceRequest) (*vtadminpb.Keyspace, error) { + return &vtadminpb.Keyspace{ + Cluster: &vtadminpb.Cluster{Id: req.ClusterId}, + Keyspace: &vtctldatapb.Keyspace{Name: req.Keyspace}, + Shards: map[string]*vtctldatapb.Shard{"0": {}}, + }, nil +} + +func (f *keyspaceActionsFakeServer) ValidateKeyspace(ctx context.Context, req *vtadminpb.ValidateKeyspaceRequest) (*vtctldatapb.ValidateKeyspaceResponse, error) { + f.validateKeyspaceReq = req + if f.validateKeyspaceNil { + return nil, nil + } + return &vtctldatapb.ValidateKeyspaceResponse{}, nil +} + +func (f *keyspaceActionsFakeServer) ValidateSchemaKeyspace(ctx context.Context, req *vtadminpb.ValidateSchemaKeyspaceRequest) (*vtctldatapb.ValidateSchemaKeyspaceResponse, error) { + f.validateSchemaKeyspaceReq = req + if f.validateSchemaNil { + return nil, nil + } + return &vtctldatapb.ValidateSchemaKeyspaceResponse{}, nil +} + +func (f *keyspaceActionsFakeServer) ValidateVersionKeyspace(ctx context.Context, req *vtadminpb.ValidateVersionKeyspaceRequest) (*vtctldatapb.ValidateVersionKeyspaceResponse, error) { + f.validateVersionKeyspaceReq = req + if f.validateVersionNil { + return nil, nil + } + return &vtctldatapb.ValidateVersionKeyspaceResponse{}, nil +} + +func (f *keyspaceActionsFakeServer) RebuildKeyspaceGraph(ctx context.Context, req *vtadminpb.RebuildKeyspaceGraphRequest) (*vtadminpb.RebuildKeyspaceGraphResponse, error) { + f.rebuildKeyspaceGraphReq = req + if f.rebuildGraphNil { + return nil, nil + } + return &vtadminpb.RebuildKeyspaceGraphResponse{}, nil +} + +func (f *keyspaceActionsFakeServer) RemoveKeyspaceCell(ctx context.Context, req *vtadminpb.RemoveKeyspaceCellRequest) (*vtadminpb.RemoveKeyspaceCellResponse, error) { + f.removeKeyspaceCellReq = req + if f.removeCellNil { + return nil, nil + } + return &vtadminpb.RemoveKeyspaceCellResponse{}, nil +} + +func (f *keyspaceActionsFakeServer) CreateShard(ctx context.Context, req *vtadminpb.CreateShardRequest) (*vtctldatapb.CreateShardResponse, error) { + f.createShardReq = req + return &vtctldatapb.CreateShardResponse{}, nil +} + +func (f *keyspaceActionsFakeServer) ReloadSchemas(ctx context.Context, req *vtadminpb.ReloadSchemasRequest) (*vtadminpb.ReloadSchemasResponse, error) { + f.reloadSchemasReq = req + if f.reloadSchemasNil { + return nil, nil + } + if f.reloadSchemasResp != nil { + return f.reloadSchemasResp, nil + } + return &vtadminpb.ReloadSchemasResponse{ + KeyspaceResults: []*vtadminpb.ReloadSchemasResponse_KeyspaceResult{{ + Keyspace: &vtadminpb.Keyspace{ + Cluster: &vtadminpb.Cluster{Id: testClusterID}, + Keyspace: &vtctldatapb.Keyspace{Name: testKeyspace}, + }, + }}, + }, nil +} + +func newKeyspaceActionsTestServer(t *testing.T, fake *keyspaceActionsFakeServer, readOnly bool) *Server { + t.Helper() + s, err := NewServer(fake, Options{ReadOnly: readOnly}) + require.NoError(t, err) + return s +} + +const keyspaceActionBase = "/keyspace/local/commerce" + +func TestKeyspaceActionsCallAPI(t *testing.T) { + tests := []struct { + action string + verify func(t *testing.T, fake *keyspaceActionsFakeServer) + form func(f url.Values) + }{ + { + action: "/validate", + form: func(f url.Values) { f.Set("ping_tablets", "on") }, + verify: func(t *testing.T, fake *keyspaceActionsFakeServer) { + require.NotNil(t, fake.validateKeyspaceReq) + assert.Equal(t, testClusterID, fake.validateKeyspaceReq.ClusterId) + assert.Equal(t, testKeyspace, fake.validateKeyspaceReq.Keyspace) + assert.True(t, fake.validateKeyspaceReq.PingTablets) + }, + }, + { + action: "/validate_schema", + verify: func(t *testing.T, fake *keyspaceActionsFakeServer) { + require.NotNil(t, fake.validateSchemaKeyspaceReq) + assert.Equal(t, testKeyspace, fake.validateSchemaKeyspaceReq.Keyspace) + }, + }, + { + action: "/validate_version", + verify: func(t *testing.T, fake *keyspaceActionsFakeServer) { + require.NotNil(t, fake.validateVersionKeyspaceReq) + assert.Equal(t, testKeyspace, fake.validateVersionKeyspaceReq.Keyspace) + }, + }, + { + action: "/rebuild_graph", + form: func(f url.Values) { f.Set("cells", "zone1, zone2") }, + verify: func(t *testing.T, fake *keyspaceActionsFakeServer) { + require.NotNil(t, fake.rebuildKeyspaceGraphReq) + assert.Equal(t, testKeyspace, fake.rebuildKeyspaceGraphReq.Keyspace) + assert.Equal(t, []string{"zone1", "zone2"}, fake.rebuildKeyspaceGraphReq.Cells) + }, + }, + { + action: "/remove_cell", + form: func(f url.Values) { f.Set("cell", "zone1") }, + verify: func(t *testing.T, fake *keyspaceActionsFakeServer) { + require.NotNil(t, fake.removeKeyspaceCellReq) + assert.Equal(t, "zone1", fake.removeKeyspaceCellReq.Cell) + }, + }, + { + action: "/create_shard", + form: func(f url.Values) { f.Set("shard", "-80") }, + verify: func(t *testing.T, fake *keyspaceActionsFakeServer) { + require.NotNil(t, fake.createShardReq) + assert.Equal(t, testClusterID, fake.createShardReq.ClusterId) + assert.Equal(t, testKeyspace, fake.createShardReq.Options.Keyspace) + assert.Equal(t, "-80", fake.createShardReq.Options.ShardName) + }, + }, + { + action: "/reload_schema", + verify: func(t *testing.T, fake *keyspaceActionsFakeServer) { + require.NotNil(t, fake.reloadSchemasReq) + assert.Equal(t, []string{testClusterID}, fake.reloadSchemasReq.ClusterIds) + assert.Equal(t, []string{testKeyspace}, fake.reloadSchemasReq.Keyspaces) + assert.True(t, fake.reloadSchemasReq.IncludePrimary) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.action, func(t *testing.T) { + fake := &keyspaceActionsFakeServer{} + s := newKeyspaceActionsTestServer(t, fake, false) + + form := url.Values{} + if tt.form != nil { + tt.form(form) + } + rec := postShardForm(t, s, keyspaceActionBase+tt.action, form) + + assert.Equal(t, http.StatusSeeOther, rec.Code) + // Creating a shard lands you on the new shard's detail page. + expectedRedirect := keyspaceActionBase + if tt.action == "/create_shard" { + expectedRedirect = keyspaceActionBase + "/shard/-80" + } + assert.Equal(t, expectedRedirect, rec.Header().Get("Location")) + tt.verify(t, fake) + }) + } +} + +func TestKeyspaceActionsUnauthorizedNilResponse(t *testing.T) { + tests := []struct { + action string + form url.Values + setup func(*keyspaceActionsFakeServer) + called func(*keyspaceActionsFakeServer) any + }{ + { + action: "/validate", + setup: func(f *keyspaceActionsFakeServer) { f.validateKeyspaceNil = true }, + called: func(f *keyspaceActionsFakeServer) any { return f.validateKeyspaceReq }, + }, + { + action: "/validate_schema", + setup: func(f *keyspaceActionsFakeServer) { f.validateSchemaNil = true }, + called: func(f *keyspaceActionsFakeServer) any { return f.validateSchemaKeyspaceReq }, + }, + { + action: "/validate_version", + setup: func(f *keyspaceActionsFakeServer) { f.validateVersionNil = true }, + called: func(f *keyspaceActionsFakeServer) any { return f.validateVersionKeyspaceReq }, + }, + { + action: "/rebuild_graph", + setup: func(f *keyspaceActionsFakeServer) { f.rebuildGraphNil = true }, + called: func(f *keyspaceActionsFakeServer) any { return f.rebuildKeyspaceGraphReq }, + }, + { + action: "/remove_cell", + form: url.Values{"cell": {"zone1"}}, + setup: func(f *keyspaceActionsFakeServer) { f.removeCellNil = true }, + called: func(f *keyspaceActionsFakeServer) any { return f.removeKeyspaceCellReq }, + }, + { + action: "/reload_schema", + setup: func(f *keyspaceActionsFakeServer) { f.reloadSchemasNil = true }, + called: func(f *keyspaceActionsFakeServer) any { return f.reloadSchemasReq }, + }, + } + + for _, tt := range tests { + t.Run(tt.action, func(t *testing.T) { + fake := &keyspaceActionsFakeServer{} + tt.setup(fake) + s := newKeyspaceActionsTestServer(t, fake, false) + + form := url.Values{} + if tt.form != nil { + form = tt.form + } + rec := postShardForm(t, s, keyspaceActionBase+tt.action, form) + + assert.Equal(t, http.StatusBadRequest, rec.Code) + assert.NotNil(t, tt.called(fake)) + assert.Contains(t, rec.Body.String(), "not authorized") + }) + } +} + +func TestKeyspaceReloadSchemaEmptyResponseUnauthorized(t *testing.T) { + fake := &keyspaceActionsFakeServer{ + reloadSchemasResp: &vtadminpb.ReloadSchemasResponse{}, + } + s := newKeyspaceActionsTestServer(t, fake, false) + + rec := postShardForm(t, s, keyspaceActionBase+"/reload_schema", url.Values{}) + + assert.Equal(t, http.StatusBadRequest, rec.Code) + assert.NotEqual(t, keyspaceActionBase, rec.Header().Get("Location")) + assert.Contains(t, rec.Body.String(), "not authorized") +} + +func TestKeyspaceReloadSchemaFailureEventsDoNotFlashSuccess(t *testing.T) { + fake := &keyspaceActionsFakeServer{ + reloadSchemasResp: &vtadminpb.ReloadSchemasResponse{ + KeyspaceResults: []*vtadminpb.ReloadSchemasResponse_KeyspaceResult{{ + Keyspace: &vtadminpb.Keyspace{ + Keyspace: &vtctldatapb.Keyspace{Name: testKeyspace}, + }, + Events: []*logutilpb.Event{{ + Level: logutilpb.Level_ERROR, + Value: "ReloadSchema(commerce) failed", + }}, + }}, + }, + } + s := newKeyspaceActionsTestServer(t, fake, false) + + rec := postShardForm(t, s, keyspaceActionBase+"/reload_schema", url.Values{}) + + assert.Equal(t, http.StatusBadRequest, rec.Code) + assert.NotEqual(t, keyspaceActionBase, rec.Header().Get("Location")) + assert.Contains(t, rec.Body.String(), "reload schema failed") +} + +func TestKeyspaceRemoveCellValidation(t *testing.T) { + fake := &keyspaceActionsFakeServer{} + s := newKeyspaceActionsTestServer(t, fake, false) + + rec := postShardForm(t, s, keyspaceActionBase+"/remove_cell", url.Values{}) + + assert.Equal(t, http.StatusBadRequest, rec.Code) + assert.Nil(t, fake.removeKeyspaceCellReq) +} + +func TestKeyspaceCreateShardValidation(t *testing.T) { + fake := &keyspaceActionsFakeServer{} + s := newKeyspaceActionsTestServer(t, fake, false) + + rec := postShardForm(t, s, keyspaceActionBase+"/create_shard", url.Values{}) + + assert.Equal(t, http.StatusBadRequest, rec.Code) + assert.Nil(t, fake.createShardReq) +} + +func TestKeyspaceActionsReadOnly(t *testing.T) { + actions := []string{ + "/validate", "/validate_schema", "/validate_version", + "/rebuild_graph", "/remove_cell", "/create_shard", "/reload_schema", + } + + for _, action := range actions { + t.Run(action, func(t *testing.T) { + fake := &keyspaceActionsFakeServer{} + s := newKeyspaceActionsTestServer(t, fake, true) + + rec := postShardForm(t, s, keyspaceActionBase+action, url.Values{}) + + assert.Equal(t, http.StatusForbidden, rec.Code) + assert.Nil(t, fake.validateKeyspaceReq) + assert.Nil(t, fake.createShardReq) + }) + } +} + +func TestKeyspaceDetailRendersActionsCard(t *testing.T) { + fake := &keyspaceActionsFakeServer{} + s := newKeyspaceActionsTestServer(t, fake, false) + + token, rec := renderWithCSRF(t, s, keyspaceActionBase) + body := rec.Body.String() + assert.Contains(t, body, keyspaceActionBase+"/validate") + assert.Contains(t, body, keyspaceActionBase+"/create_shard") + assert.Contains(t, body, keyspaceActionBase+"/reload_schema") + + // A POST using the exact rendered token/cookie pairing must get past + // CSRF validation. + rec = postFormWithCSRF(s, keyspaceActionBase+"/reload_schema", token, url.Values{ + "csrf_token": {token}, + }) + assert.Equal(t, http.StatusSeeOther, rec.Code) + require.NotNil(t, fake.reloadSchemasReq) +} + +func TestKeyspaceDetailHidesActionsWhenReadOnly(t *testing.T) { + fake := &keyspaceActionsFakeServer{} + s := newKeyspaceActionsTestServer(t, fake, true) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, keyspaceActionBase, nil) + s.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + assert.NotContains(t, rec.Body.String(), keyspaceActionBase+"/validate") +} diff --git a/go/vt/vtadmin/vtadmin2/operations.go b/go/vt/vtadmin/vtadmin2/operations.go new file mode 100644 index 00000000000..847d8c92587 --- /dev/null +++ b/go/vt/vtadmin/vtadmin2/operations.go @@ -0,0 +1,216 @@ +/* +Copyright 2026 The Vitess Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package vtadmin2 + +import ( + "net/http" + "slices" + "strconv" + + querypb "vitess.io/vitess/go/vt/proto/query" + vtadminpb "vitess.io/vitess/go/vt/proto/vtadmin" + vtctldatapb "vitess.io/vitess/go/vt/proto/vtctldata" + vtrpcpb "vitess.io/vitess/go/vt/proto/vtrpc" + "vitess.io/vitess/go/vt/vterrors" +) + +type ( + migrationsData struct { + Form formOptions + UUID string + Migrations []*vtadminpb.SchemaMigration + } + + transactionsData struct { + ClusterID string + Keyspace string + AbandonAge string + Transactions []*querypb.TransactionMetadata + Form formOptions + } + + transactionInfoData struct { + ClusterID string + Dtid string + Response *vtctldatapb.GetTransactionInfoResponse + } +) + +func (s *Server) schemaMigrations(w http.ResponseWriter, r *http.Request) { + clusterIDs := queryValues(r, "cluster_id") + requestedCluster := "" + if len(clusterIDs) > 0 { + requestedCluster = clusterIDs[0] + } + keyspace := queryValue(r, "keyspace") + filtering := hasNonRefreshQuery(r) + if filtering { + if len(clusterIDs) == 0 { + s.renderError(w, r, http.StatusBadRequest, "Migrations", vterrors.Errorf(vtrpcpb.Code_INVALID_ARGUMENT, "cluster_id query parameter is required")) + return + } + if slices.Contains(clusterIDs, "") { + s.renderError(w, r, http.StatusBadRequest, "Migrations", vterrors.Errorf(vtrpcpb.Code_INVALID_ARGUMENT, "cluster_id query parameter is required")) + return + } + if keyspace == "" { + s.renderError(w, r, http.StatusBadRequest, "Migrations", vterrors.Errorf(vtrpcpb.Code_INVALID_ARGUMENT, "keyspace query parameter is required")) + return + } + } + form, err := s.loadFormOptions(r, requestedCluster, keyspace) + if err != nil { + s.renderError(w, r, http.StatusInternalServerError, "Migrations", err) + return + } + data := migrationsData{Form: form, UUID: queryValue(r, "uuid")} + if !filtering { + s.render(w, r, http.StatusOK, "migrations.html", PageData{ + Title: "Migrations", + Active: "migrations", + Data: data, + }) + return + } + clusterRequests := make([]*vtadminpb.GetSchemaMigrationsRequest_ClusterRequest, 0, len(clusterIDs)) + for _, clusterID := range clusterIDs { + clusterRequests = append(clusterRequests, &vtadminpb.GetSchemaMigrationsRequest_ClusterRequest{ + ClusterId: clusterID, + Request: &vtctldatapb.GetSchemaMigrationsRequest{ + Keyspace: keyspace, + Uuid: queryValue(r, "uuid"), + }, + }) + } + + resp, err := s.api.GetSchemaMigrations(r.Context(), &vtadminpb.GetSchemaMigrationsRequest{ + ClusterRequests: clusterRequests, + }) + if err != nil { + s.renderError(w, r, http.StatusInternalServerError, "Migrations", err) + return + } + data.Migrations = resp.GetSchemaMigrations() + + s.render(w, r, http.StatusOK, "migrations.html", PageData{ + Title: "Migrations", + Active: "migrations", + Data: data, + }) +} + +func (s *Server) transactions(w http.ResponseWriter, r *http.Request) { + clusterID := queryValue(r, "cluster_id") + keyspace := queryValue(r, "keyspace") + filtering := hasNonRefreshQuery(r) + if filtering { + if clusterID == "" { + s.renderError(w, r, http.StatusBadRequest, "Transactions", vterrors.Errorf(vtrpcpb.Code_INVALID_ARGUMENT, "cluster_id query parameter is required")) + return + } + if keyspace == "" { + s.renderError(w, r, http.StatusBadRequest, "Transactions", vterrors.Errorf(vtrpcpb.Code_INVALID_ARGUMENT, "keyspace query parameter is required")) + return + } + } + form, err := s.loadFormOptions(r, clusterID, keyspace) + if err != nil { + s.renderError(w, r, http.StatusInternalServerError, "Transactions", err) + return + } + abandonAgeParam := queryValue(r, "abandon_age") + data := transactionsData{ClusterID: form.SelectedCluster, Keyspace: form.SelectedKeyspace, AbandonAge: abandonAgeParam, Form: form} + if !filtering { + s.render(w, r, http.StatusOK, "transactions.html", PageData{ + Title: "Transactions", + Active: "transactions", + Data: data, + }) + return + } + abandonAge, err := parseQueryInt64(r, "abandon_age", 0) + if err != nil { + s.renderError(w, r, http.StatusBadRequest, "Transactions", err) + return + } + + resp, err := s.api.GetUnresolvedTransactions(r.Context(), &vtadminpb.GetUnresolvedTransactionsRequest{ + ClusterId: clusterID, + Keyspace: keyspace, + AbandonAge: abandonAge, + }) + if err != nil { + s.renderError(w, r, http.StatusInternalServerError, "Transactions", err) + return + } + if resp == nil { + http.NotFound(w, r) + return + } + + s.render(w, r, http.StatusOK, "transactions.html", PageData{ + Title: "Transactions", + Active: "transactions", + NeedsCSRF: !s.opts.ReadOnly, + Data: transactionsData{ + ClusterID: clusterID, + Keyspace: keyspace, + AbandonAge: abandonAgeParam, + Transactions: resp.GetTransactions(), + Form: form, + }, + }) +} + +func (s *Server) transactionInfo(w http.ResponseWriter, r *http.Request) { + resp, err := s.api.GetTransactionInfo(r.Context(), &vtadminpb.GetTransactionInfoRequest{ + ClusterId: r.PathValue("cluster_id"), + Request: &vtctldatapb.GetTransactionInfoRequest{ + Dtid: r.PathValue("dtid"), + }, + }) + if err != nil { + s.renderError(w, r, http.StatusInternalServerError, "Transaction", err) + return + } + if resp == nil { + http.NotFound(w, r) + return + } + + s.render(w, r, http.StatusOK, "transaction.html", PageData{ + Title: "Transaction", + Active: "transactions", + Data: transactionInfoData{ + ClusterID: r.PathValue("cluster_id"), + Dtid: r.PathValue("dtid"), + Response: resp, + }, + }) +} + +func parseQueryInt64(r *http.Request, name string, defaultVal int64) (int64, error) { + param := queryValue(r, name) + if param == "" { + return defaultVal, nil + } + val, err := strconv.ParseInt(param, 10, 64) + if err != nil { + return defaultVal, vterrors.Errorf(vtrpcpb.Code_INVALID_ARGUMENT, "could not parse query parameter %s (= %s) into int64 value", name, param) + } + return val, nil +} diff --git a/go/vt/vtadmin/vtadmin2/operations_test.go b/go/vt/vtadmin/vtadmin2/operations_test.go new file mode 100644 index 00000000000..ddc1abc593b --- /dev/null +++ b/go/vt/vtadmin/vtadmin2/operations_test.go @@ -0,0 +1,418 @@ +/* +Copyright 2026 The Vitess Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package vtadmin2 + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + querypb "vitess.io/vitess/go/vt/proto/query" + vtadminpb "vitess.io/vitess/go/vt/proto/vtadmin" + vtctldatapb "vitess.io/vitess/go/vt/proto/vtctldata" +) + +type operationsFakeServer struct { + fakeVTAdminServer + + getSchemaMigrationsRequest *vtadminpb.GetSchemaMigrationsRequest + getSchemaMigrationsError error + getUnresolvedTransactionsRequest *vtadminpb.GetUnresolvedTransactionsRequest + getUnresolvedTransactionsError error + getUnresolvedTransactionsNil bool + getTransactionInfoRequest *vtadminpb.GetTransactionInfoRequest + getTransactionInfoError error + getTransactionInfoNil bool + getClustersError error +} + +func (f *operationsFakeServer) GetSchemaMigrations(ctx context.Context, req *vtadminpb.GetSchemaMigrationsRequest) (*vtadminpb.GetSchemaMigrationsResponse, error) { + f.getSchemaMigrationsRequest = req + if f.getSchemaMigrationsError != nil { + return nil, f.getSchemaMigrationsError + } + return &vtadminpb.GetSchemaMigrationsResponse{ + SchemaMigrations: []*vtadminpb.SchemaMigration{ + { + Cluster: &vtadminpb.Cluster{Id: "local", Name: "Local"}, + SchemaMigration: &vtctldatapb.SchemaMigration{ + Uuid: "mig-1", + Keyspace: "commerce", + Shard: "0", + Table: "customer", + Status: vtctldatapb.SchemaMigration_COMPLETE, + }, + }, + }, + }, nil +} + +func (f *operationsFakeServer) GetClusters(ctx context.Context, req *vtadminpb.GetClustersRequest) (*vtadminpb.GetClustersResponse, error) { + if f.getClustersError != nil { + return nil, f.getClustersError + } + return &vtadminpb.GetClustersResponse{Clusters: []*vtadminpb.Cluster{{Id: "local", Name: "Local"}, {Id: "prod", Name: "Prod"}}}, nil +} + +func (f *operationsFakeServer) GetKeyspaces(ctx context.Context, req *vtadminpb.GetKeyspacesRequest) (*vtadminpb.GetKeyspacesResponse, error) { + return &vtadminpb.GetKeyspacesResponse{Keyspaces: []*vtadminpb.Keyspace{ + {Cluster: &vtadminpb.Cluster{Id: "local", Name: "Local"}, Keyspace: &vtctldatapb.Keyspace{Name: "commerce"}}, + {Cluster: &vtadminpb.Cluster{Id: "local", Name: "Local"}, Keyspace: &vtctldatapb.Keyspace{Name: "customer"}}, + {Cluster: &vtadminpb.Cluster{Id: "prod", Name: "Prod"}, Keyspace: &vtctldatapb.Keyspace{Name: "commerce_prod"}}, + }}, nil +} + +func (f *operationsFakeServer) GetUnresolvedTransactions(ctx context.Context, req *vtadminpb.GetUnresolvedTransactionsRequest) (*vtctldatapb.GetUnresolvedTransactionsResponse, error) { + f.getUnresolvedTransactionsRequest = req + if f.getUnresolvedTransactionsError != nil { + return nil, f.getUnresolvedTransactionsError + } + if f.getUnresolvedTransactionsNil { + return nil, nil + } + return &vtctldatapb.GetUnresolvedTransactionsResponse{ + Transactions: []*querypb.TransactionMetadata{{Dtid: "dtid-1"}}, + }, nil +} + +func (f *operationsFakeServer) GetTransactionInfo(ctx context.Context, req *vtadminpb.GetTransactionInfoRequest) (*vtctldatapb.GetTransactionInfoResponse, error) { + f.getTransactionInfoRequest = req + if f.getTransactionInfoError != nil { + return nil, f.getTransactionInfoError + } + if f.getTransactionInfoNil { + return nil, nil + } + return &vtctldatapb.GetTransactionInfoResponse{ + Metadata: &querypb.TransactionMetadata{Dtid: "dtid-1"}, + ShardStates: []*vtctldatapb.ShardTransactionState{{Shard: "commerce/0", State: "PREPARE"}}, + }, nil +} + +func TestMigrationsPagePassesFiltersAndRendersRows(t *testing.T) { + fake := &operationsFakeServer{} + s, err := NewServer(fake, Options{}) + require.NoError(t, err) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/migrations?cluster_id=local&cluster_id=prod&keyspace=commerce&uuid=mig-1", nil) + s.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + require.NotNil(t, fake.getSchemaMigrationsRequest) + require.Len(t, fake.getSchemaMigrationsRequest.GetClusterRequests(), 2) + clusterReq := fake.getSchemaMigrationsRequest.GetClusterRequests()[0] + assert.Equal(t, "local", clusterReq.GetClusterId()) + require.NotNil(t, clusterReq.GetRequest()) + assert.Equal(t, "commerce", clusterReq.GetRequest().GetKeyspace()) + assert.Equal(t, "mig-1", clusterReq.GetRequest().GetUuid()) + clusterReq = fake.getSchemaMigrationsRequest.GetClusterRequests()[1] + assert.Equal(t, "prod", clusterReq.GetClusterId()) + require.NotNil(t, clusterReq.GetRequest()) + assert.Equal(t, "commerce", clusterReq.GetRequest().GetKeyspace()) + assert.Equal(t, "mig-1", clusterReq.GetRequest().GetUuid()) + assert.Contains(t, rec.Body.String(), "Migrations") + assert.Contains(t, rec.Body.String(), "mig-1") + assert.Contains(t, rec.Body.String(), "commerce") +} + +func TestMigrationsPageDefaultsClusterAndKeyspaceSelects(t *testing.T) { + fake := &operationsFakeServer{} + s, err := NewServer(fake, Options{}) + require.NoError(t, err) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/migrations", nil) + s.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + assert.Contains(t, rec.Body.String(), ``) + assert.Contains(t, rec.Body.String(), ``) + assert.Nil(t, fake.getSchemaMigrationsRequest) +} + +func TestMigrationsPageRefreshOnlyDoesNotRequireFilters(t *testing.T) { + fake := &operationsFakeServer{} + s, err := NewServer(fake, Options{}) + require.NoError(t, err) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/migrations?refresh=10", nil) + s.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + assert.Contains(t, rec.Body.String(), ``) + assert.Contains(t, rec.Body.String(), ``) + assert.Contains(t, rec.Body.String(), ``) + assert.Nil(t, fake.getUnresolvedTransactionsRequest) +} + +func TestTransactionsPageRequiresKeyspace(t *testing.T) { + fake := &operationsFakeServer{} + s, err := NewServer(fake, Options{}) + require.NoError(t, err) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/transactions?cluster_id=local", nil) + s.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusBadRequest, rec.Code) + assert.Contains(t, rec.Body.String(), "keyspace") + assert.Nil(t, fake.getUnresolvedTransactionsRequest) +} + +func TestTransactionsPageRequiresClusterID(t *testing.T) { + fake := &operationsFakeServer{} + s, err := NewServer(fake, Options{}) + require.NoError(t, err) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/transactions?keyspace=commerce", nil) + s.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusBadRequest, rec.Code) + assert.Contains(t, rec.Body.String(), "cluster_id") + assert.Nil(t, fake.getUnresolvedTransactionsRequest) +} + +func TestTransactionsPageValidatesSubmissionBeforeLoadingFormOptions(t *testing.T) { + fake := &operationsFakeServer{getClustersError: errors.New("cluster options failed")} + s, err := NewServer(fake, Options{}) + require.NoError(t, err) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/transactions?keyspace=commerce", nil) + s.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusBadRequest, rec.Code) + assert.Contains(t, rec.Body.String(), "cluster_id") + assert.NotContains(t, rec.Body.String(), "cluster options failed") + assert.Nil(t, fake.getUnresolvedTransactionsRequest) +} + +func TestTransactionsPageRendersBackendError(t *testing.T) { + fake := &operationsFakeServer{getUnresolvedTransactionsError: errors.New("transactions backend failed")} + s, err := NewServer(fake, Options{}) + require.NoError(t, err) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/transactions?cluster_id=local&keyspace=commerce", nil) + s.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusInternalServerError, rec.Code) + assert.Contains(t, rec.Body.String(), "transactions backend failed") + require.NotNil(t, fake.getUnresolvedTransactionsRequest) +} + +func TestTransactionsPageReturnsNotFoundForNilResponse(t *testing.T) { + fake := &operationsFakeServer{getUnresolvedTransactionsNil: true} + s, err := NewServer(fake, Options{}) + require.NoError(t, err) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/transactions?cluster_id=local&keyspace=commerce", nil) + s.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusNotFound, rec.Code) + require.NotNil(t, fake.getUnresolvedTransactionsRequest) +} + +func TestTransactionsPageRejectsInvalidAbandonAge(t *testing.T) { + fake := &operationsFakeServer{} + s, err := NewServer(fake, Options{}) + require.NoError(t, err) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/transactions?cluster_id=local&keyspace=commerce&abandon_age=bad", nil) + s.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusBadRequest, rec.Code) + assert.Contains(t, rec.Body.String(), "abandon_age") + assert.Nil(t, fake.getUnresolvedTransactionsRequest) +} + +func TestTransactionInfoPageUsesRouteValuesAndRendersDetail(t *testing.T) { + fake := &operationsFakeServer{} + s, err := NewServer(fake, Options{}) + require.NoError(t, err) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/transaction/local/dtid-1/info", nil) + s.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + require.NotNil(t, fake.getTransactionInfoRequest) + assert.Equal(t, "local", fake.getTransactionInfoRequest.GetClusterId()) + require.NotNil(t, fake.getTransactionInfoRequest.GetRequest()) + assert.Equal(t, "dtid-1", fake.getTransactionInfoRequest.GetRequest().GetDtid()) + assert.Contains(t, rec.Body.String(), "Transaction") + assert.Contains(t, rec.Body.String(), "dtid-1") + assert.Contains(t, rec.Body.String(), "commerce/0") +} + +func TestTransactionInfoPageRendersBackendError(t *testing.T) { + fake := &operationsFakeServer{getTransactionInfoError: errors.New("transaction info backend failed")} + s, err := NewServer(fake, Options{}) + require.NoError(t, err) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/transaction/local/dtid-1/info", nil) + s.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusInternalServerError, rec.Code) + assert.Contains(t, rec.Body.String(), "transaction info backend failed") + require.NotNil(t, fake.getTransactionInfoRequest) +} + +func TestTransactionInfoPageReturnsNotFoundForNilResponse(t *testing.T) { + fake := &operationsFakeServer{getTransactionInfoNil: true} + s, err := NewServer(fake, Options{}) + require.NoError(t, err) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/transaction/local/dtid-1/info", nil) + s.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusNotFound, rec.Code) + require.NotNil(t, fake.getTransactionInfoRequest) +} diff --git a/go/vt/vtadmin/vtadmin2/pages.go b/go/vt/vtadmin/vtadmin2/pages.go new file mode 100644 index 00000000000..d5b8a9c744d --- /dev/null +++ b/go/vt/vtadmin/vtadmin2/pages.go @@ -0,0 +1,157 @@ +/* +Copyright 2026 The Vitess Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package vtadmin2 + +import ( + "net/http" + + vtadminpb "vitess.io/vitess/go/vt/proto/vtadmin" +) + +// loadFormOptions loads clusters and keyspaces for form rendering. The +// selected cluster honors, in order: an explicit request, the saved default +// cluster cookie, then the first cluster. +func (s *Server) loadFormOptions(r *http.Request, requestedCluster, requestedKeyspace string) (formOptions, error) { + ctx := r.Context() + clustersResp, err := s.api.GetClusters(ctx, &vtadminpb.GetClustersRequest{}) + if err != nil { + return formOptions{}, err + } + clusters := clustersResp.GetClusters() + selectedCluster := selectedClusterID(clusters, requestedCluster, cookieValue(r, defaultClusterCookieName)) + + keyspaceReq := &vtadminpb.GetKeyspacesRequest{} + if selectedCluster != "" { + keyspaceReq.ClusterIds = []string{selectedCluster} + } + keyspacesResp, err := s.api.GetKeyspaces(ctx, keyspaceReq) + if err != nil { + return formOptions{}, err + } + keyspaces := keyspacesResp.GetKeyspaces() + + return formOptions{ + Clusters: clusters, + Keyspaces: keyspaces, + SelectedCluster: selectedCluster, + SelectedKeyspace: selectedKeyspaceName(keyspaces, selectedCluster, requestedKeyspace), + }, nil +} + +func (s *Server) clusters(w http.ResponseWriter, r *http.Request) { + resp, err := s.api.GetClusters(r.Context(), &vtadminpb.GetClustersRequest{}) + if err != nil { + s.renderError(w, r, http.StatusInternalServerError, "Clusters", err) + return + } + + s.render(w, r, http.StatusOK, "clusters.html", PageData{ + Title: "Clusters", + Active: "clusters", + Data: resp.GetClusters(), + }) +} + +func (s *Server) keyspaces(w http.ResponseWriter, r *http.Request) { + resp, err := s.api.GetKeyspaces(r.Context(), &vtadminpb.GetKeyspacesRequest{ + ClusterIds: r.URL.Query()["cluster_id"], + }) + if err != nil { + s.renderError(w, r, http.StatusInternalServerError, "Keyspaces", err) + return + } + + s.render(w, r, http.StatusOK, "keyspaces.html", PageData{ + Title: "Keyspaces", + Active: "keyspaces", + Data: resp.GetKeyspaces(), + }) +} + +func (s *Server) keyspace(w http.ResponseWriter, r *http.Request) { + ks, err := s.api.GetKeyspace(r.Context(), &vtadminpb.GetKeyspaceRequest{ + ClusterId: r.PathValue("cluster_id"), + Keyspace: r.PathValue("name"), + }) + if err != nil { + s.renderError(w, r, http.StatusInternalServerError, "Keyspace", err) + return + } + if ks == nil { + http.NotFound(w, r) + return + } + + s.render(w, r, http.StatusOK, "keyspace.html", PageData{ + Title: keyspaceName(ks), + Active: "keyspaces", + NeedsCSRF: !s.opts.ReadOnly, + Data: ks, + }) +} + +func (s *Server) tablets(w http.ResponseWriter, r *http.Request) { + resp, err := s.api.GetTablets(r.Context(), &vtadminpb.GetTabletsRequest{ + ClusterIds: r.URL.Query()["cluster_id"], + }) + if err != nil { + s.renderError(w, r, http.StatusInternalServerError, "Tablets", err) + return + } + + s.render(w, r, http.StatusOK, "tablets.html", PageData{ + Title: "Tablets", + Active: "tablets", + Data: resp.GetTablets(), + }) +} + +func (s *Server) schemas(w http.ResponseWriter, r *http.Request) { + resp, err := s.api.GetSchemas(r.Context(), &vtadminpb.GetSchemasRequest{ + ClusterIds: r.URL.Query()["cluster_id"], + }) + if err != nil { + s.renderError(w, r, http.StatusInternalServerError, "Schemas", err) + return + } + + s.render(w, r, http.StatusOK, "schemas.html", PageData{ + Title: "Schemas", + Active: "schemas", + Data: resp.GetSchemas(), + }) +} + +func (s *Server) createKeyspaceForm(w http.ResponseWriter, r *http.Request) { + if s.opts.ReadOnly { + s.renderReadOnly(w, r) + return + } + + resp, err := s.api.GetClusters(r.Context(), &vtadminpb.GetClustersRequest{}) + if err != nil { + s.renderError(w, r, http.StatusInternalServerError, "Create keyspace", err) + return + } + + s.render(w, r, http.StatusOK, "keyspace_create.html", PageData{ + Title: "Create keyspace", + Active: "keyspaces", + NeedsCSRF: true, + Data: resp.GetClusters(), + }) +} diff --git a/go/vt/vtadmin/vtadmin2/pages_test.go b/go/vt/vtadmin/vtadmin2/pages_test.go new file mode 100644 index 00000000000..c19ded292b0 --- /dev/null +++ b/go/vt/vtadmin/vtadmin2/pages_test.go @@ -0,0 +1,199 @@ +/* +Copyright 2026 The Vitess Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package vtadmin2 + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + tabletmanagerdatapb "vitess.io/vitess/go/vt/proto/tabletmanagerdata" + topodatapb "vitess.io/vitess/go/vt/proto/topodata" + vtadminpb "vitess.io/vitess/go/vt/proto/vtadmin" + vtctldatapb "vitess.io/vitess/go/vt/proto/vtctldata" +) + +type pageFakeServer struct { + fakeVTAdminServer + getClustersCalled bool +} + +func (f *pageFakeServer) GetClusters(ctx context.Context, req *vtadminpb.GetClustersRequest) (*vtadminpb.GetClustersResponse, error) { + f.getClustersCalled = true + return &vtadminpb.GetClustersResponse{Clusters: []*vtadminpb.Cluster{ + {Id: "local", Name: "Local"}, + {Id: "prod", Name: "Production"}, + }}, nil +} + +func TestClustersPageCallsServerAndRendersRows(t *testing.T) { + fake := &pageFakeServer{} + s, err := NewServer(fake, Options{}) + require.NoError(t, err) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/clusters", nil) + s.ServeHTTP(rec, req) + + assert.True(t, fake.getClustersCalled) + assert.Equal(t, http.StatusOK, rec.Code) + assert.Contains(t, rec.Body.String(), "Clusters") + assert.Contains(t, rec.Body.String(), "local") + assert.Contains(t, rec.Body.String(), "Production") + assert.Contains(t, rec.Body.String(), "data-table-filter") +} + +func (f *pageFakeServer) GetKeyspaces(ctx context.Context, req *vtadminpb.GetKeyspacesRequest) (*vtadminpb.GetKeyspacesResponse, error) { + return &vtadminpb.GetKeyspacesResponse{Keyspaces: []*vtadminpb.Keyspace{ + { + Cluster: &vtadminpb.Cluster{Id: "local cluster", Name: "Local"}, + Keyspace: &vtctldatapb.Keyspace{Name: "commerce/customer"}, + Shards: map[string]*vtctldatapb.Shard{ + "0": {Name: "0"}, + }, + }, + }}, nil +} + +func (f *pageFakeServer) GetKeyspace(ctx context.Context, req *vtadminpb.GetKeyspaceRequest) (*vtadminpb.Keyspace, error) { + return &vtadminpb.Keyspace{ + Cluster: &vtadminpb.Cluster{Id: req.GetClusterId(), Name: "Local"}, + Keyspace: &vtctldatapb.Keyspace{Name: req.GetKeyspace()}, + Shards: map[string]*vtctldatapb.Shard{ + "-80": {Name: "-80"}, + "80-": {Name: "80-"}, + }, + }, nil +} + +func TestKeyspacesPageRendersRowsAndCreateLink(t *testing.T) { + s, err := NewServer(&pageFakeServer{}, Options{}) + require.NoError(t, err) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/keyspaces", nil) + s.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + assert.Contains(t, rec.Body.String(), "Keyspaces") + assert.Contains(t, rec.Body.String(), "commerce/customer") + assert.Contains(t, rec.Body.String(), "/keyspace/local%20cluster/commerce%2Fcustomer") + assert.Contains(t, rec.Body.String(), "/keyspaces/create") +} + +func TestPageRenderDoesNotMintCSRFCookieOnReadOnlyPages(t *testing.T) { + s, err := NewServer(&pageFakeServer{}, Options{}) + require.NoError(t, err) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/keyspaces", nil) + s.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + assert.Nil(t, findCookie(rec, csrfCookieName)) +} + +func TestKeyspacesPageIgnoresQueryStringFlash(t *testing.T) { + s, err := NewServer(&pageFakeServer{}, Options{}) + require.NoError(t, err) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/keyspaces?flash=success&message=Keyspace+deleted", nil) + s.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + assert.NotContains(t, rec.Body.String(), "Keyspace deleted") +} + +func TestKeyspaceDetailRendersShardNames(t *testing.T) { + s, err := NewServer(&pageFakeServer{}, Options{}) + require.NoError(t, err) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/keyspace/local/commerce", nil) + s.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + assert.Contains(t, rec.Body.String(), "commerce") + assert.Contains(t, rec.Body.String(), "-80") + assert.Contains(t, rec.Body.String(), "80-") +} + +func (f *pageFakeServer) GetTablets(ctx context.Context, req *vtadminpb.GetTabletsRequest) (*vtadminpb.GetTabletsResponse, error) { + return &vtadminpb.GetTabletsResponse{Tablets: []*vtadminpb.Tablet{ + { + Cluster: &vtadminpb.Cluster{Id: "local", Name: "Local"}, + Tablet: &topodatapb.Tablet{ + Alias: &topodatapb.TabletAlias{Cell: "zone1", Uid: 100}, + Hostname: "tablet-100", + Keyspace: "commerce", + PortMap: map[string]int32{"vt": 15100}, + Shard: "0", + Type: topodatapb.TabletType_PRIMARY, + }, + FQDN: "localhost:15101", + State: vtadminpb.Tablet_SERVING, + }, + }}, nil +} + +func (f *pageFakeServer) GetSchemas(ctx context.Context, req *vtadminpb.GetSchemasRequest) (*vtadminpb.GetSchemasResponse, error) { + return &vtadminpb.GetSchemasResponse{Schemas: []*vtadminpb.Schema{ + { + Cluster: &vtadminpb.Cluster{Id: "local", Name: "Local"}, + Keyspace: "commerce", + TableDefinitions: []*tabletmanagerdatapb.TableDefinition{ + {Name: "customer", Schema: "create table customer(id bigint primary key)"}, + }, + }, + }}, nil +} + +func TestTabletsPageRendersRows(t *testing.T) { + s, err := NewServer(&pageFakeServer{}, Options{}) + require.NoError(t, err) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/tablets", nil) + s.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + assert.Contains(t, rec.Body.String(), "Tablets") + assert.Contains(t, rec.Body.String(), "zone1-0000000100") + assert.Contains(t, rec.Body.String(), "/tablet/local/zone1-0000000100") + assert.Contains(t, rec.Body.String(), "FQDN") + assert.Contains(t, rec.Body.String(), `localhost:15101`) + assert.Contains(t, rec.Body.String(), "PRIMARY") +} + +func TestSchemasPageRendersTables(t *testing.T) { + s, err := NewServer(&pageFakeServer{}, Options{}) + require.NoError(t, err) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/schemas", nil) + s.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + assert.Contains(t, rec.Body.String(), "Schemas") + assert.Contains(t, rec.Body.String(), "commerce") + assert.Contains(t, rec.Body.String(), "customer") +} diff --git a/go/vt/vtadmin/vtadmin2/refresh_test.go b/go/vt/vtadmin/vtadmin2/refresh_test.go new file mode 100644 index 00000000000..6e630b5074d --- /dev/null +++ b/go/vt/vtadmin/vtadmin2/refresh_test.go @@ -0,0 +1,94 @@ +/* +Copyright 2026 The Vitess Authors. + +Licensed under the Apache License, Version 2.0 the "License"; +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package vtadmin2 + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + vtadminpb "vitess.io/vitess/go/vt/proto/vtadmin" +) + +type refreshFakeServer struct { + fakeVTAdminServer +} + +func (f *refreshFakeServer) GetClusters(ctx context.Context, req *vtadminpb.GetClustersRequest) (*vtadminpb.GetClustersResponse, error) { + return &vtadminpb.GetClustersResponse{}, nil +} + +func (f *refreshFakeServer) GetWorkflows(ctx context.Context, req *vtadminpb.GetWorkflowsRequest) (*vtadminpb.GetWorkflowsResponse, error) { + return &vtadminpb.GetWorkflowsResponse{}, nil +} + +func TestRefreshMetaRenderedInHead(t *testing.T) { + fake := &refreshFakeServer{} + s, err := NewServer(fake, Options{}) + require.NoError(t, err) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/workflows?refresh=30", nil) + s.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + body := rec.Body.String() + headEnd := indexOf(body, "") + metaIdx := indexOf(body, ``) + require.NotEqual(t, -1, metaIdx, "refresh meta not rendered") + assert.Less(t, metaIdx, headEnd, "refresh meta must be in ") + assert.NotContains(t, body, "