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(), `Local (local)`) + assert.Contains(t, rec.Body.String(), ``) + assert.Contains(t, rec.Body.String(), `commerce`) + 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.Nil(t, fake.getSchemaMigrationsRequest) +} + +func TestMigrationsPageRejectsEmptyClusterID(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=&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.getSchemaMigrationsRequest) +} + +func TestMigrationsPageRequiresClusterID(t *testing.T) { + fake := &operationsFakeServer{} + s, err := NewServer(fake, Options{}) + require.NoError(t, err) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/migrations?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.getSchemaMigrationsRequest) +} + +func TestMigrationsPageValidatesSubmissionBeforeLoadingFormOptions(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, "/migrations?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.getSchemaMigrationsRequest) +} + +func TestMigrationsPageRequiresKeyspace(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", nil) + s.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusBadRequest, rec.Code) + assert.Contains(t, rec.Body.String(), "keyspace") + assert.Nil(t, fake.getSchemaMigrationsRequest) +} + +func TestMigrationsPageRendersBackendError(t *testing.T) { + fake := &operationsFakeServer{getSchemaMigrationsError: errors.New("migrations backend failed")} + s, err := NewServer(fake, Options{}) + require.NoError(t, err) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/migrations?cluster_id=local&keyspace=commerce", nil) + s.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusInternalServerError, rec.Code) + assert.Contains(t, rec.Body.String(), "migrations backend failed") + require.NotNil(t, fake.getSchemaMigrationsRequest) +} + +func TestTransactionsPagePassesFiltersAndRendersRows(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=30", nil) + s.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + require.NotNil(t, fake.getUnresolvedTransactionsRequest) + assert.Equal(t, "local", fake.getUnresolvedTransactionsRequest.GetClusterId()) + assert.Equal(t, "commerce", fake.getUnresolvedTransactionsRequest.GetKeyspace()) + assert.EqualValues(t, 30, fake.getUnresolvedTransactionsRequest.GetAbandonAge()) + assert.Contains(t, rec.Body.String(), "Transactions") + assert.Contains(t, rec.Body.String(), "dtid-1") + assert.Contains(t, rec.Body.String(), "/transaction/local/dtid-1/info") +} + +func TestTransactionsPageDefaultsClusterAndKeyspaceSelects(t *testing.T) { + fake := &operationsFakeServer{} + s, err := NewServer(fake, Options{}) + require.NoError(t, err) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/transactions", nil) + s.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + assert.Contains(t, rec.Body.String(), ``) + assert.Contains(t, rec.Body.String(), `Local (local)`) + assert.Contains(t, rec.Body.String(), ``) + assert.Contains(t, rec.Body.String(), `commerce`) + assert.Nil(t, fake.getUnresolvedTransactionsRequest) +} + +func TestTransactionsPageRefreshOnlyDoesNotRequireFilters(t *testing.T) { + fake := &operationsFakeServer{} + s, err := NewServer(fake, Options{}) + require.NoError(t, err) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/transactions?refresh=10", nil) + s.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + 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, "") +} + +func TestRefreshMinIntervalEnforced(t *testing.T) { + fake := &refreshFakeServer{} + s, err := NewServer(fake, Options{}) + require.NoError(t, err) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/workflows?refresh=1", nil) + s.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + assert.Contains(t, rec.Body.String(), `content="10"`) +} + +func TestRefreshRestrictedToVolatilePages(t *testing.T) { + fake := &refreshFakeServer{} + s, err := NewServer(fake, Options{}) + require.NoError(t, err) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/clusters?refresh=30", nil) + s.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + assert.NotContains(t, rec.Body.String(), "http-equiv=\"refresh\"") +} + +func indexOf(s, substr string) int { + for i := 0; i+len(substr) <= len(s); i++ { + if s[i:i+len(substr)] == substr { + return i + } + } + return -1 +} diff --git a/go/vt/vtadmin/vtadmin2/render.go b/go/vt/vtadmin/vtadmin2/render.go new file mode 100644 index 00000000000..a7aac51cf75 --- /dev/null +++ b/go/vt/vtadmin/vtadmin2/render.go @@ -0,0 +1,235 @@ +/* +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 ( + "bytes" + jsonv2 "encoding/json/v2" + "html/template" + "io/fs" + "net/http" + "path/filepath" + "reflect" + "strconv" + "strings" + + "google.golang.org/protobuf/proto" +) + +type ( + templateSet struct { + templates map[string]*template.Template + } + + PageData struct { + Title string + Active string + ReadOnly bool + CSRFToken string + NeedsCSRF bool + Flash *Flash + Data any + DocumentTitle string + Theme string + RefreshSeconds int + } + + Flash struct { + Kind string + Message string + } +) + +func parseTemplates() (*templateSet, error) { + pages, err := fs.Glob(assets, "templates/*.html") + if err != nil { + return nil, err + } + + templates := make(map[string]*template.Template, len(pages)) + for _, page := range pages { + name := filepath.Base(page) + if name == "layout.html" { + continue + } + + tmpl, err := template.New("").Funcs(template.FuncMap{ + "clusterID": clusterID, + "externalURL": externalURL, + "keyspaceName": keyspaceName, + "pathEscape": pathEscape, + "protoJSON": protoJSON, + "schemaTableCount": schemaTableCount, + "shardActionPath": shardActionPath, + "keyspaceActionPath": keyspaceActionPath, + "sortedShardNames": sortedShardNames, + "tabletAlias": tabletAlias, + "urlQueryEscape": urlQueryEscape, + }).ParseFS(assets, "templates/layout.html", page) + if err != nil { + return nil, err + } + templates[name] = tmpl + } + + return &templateSet{templates: templates}, nil +} + +func staticFS() fs.FS { + static, err := fs.Sub(assets, "static") + if err != nil { + panic(err) + } + return static +} + +// refreshablePagePrefixes are the volatile pages for which auto-refresh via +// ?refresh=N is honored. Refreshing expensive pages (e.g. topology) or every +// page by default would hammer the API. +var refreshablePagePrefixes = []string{ + "/workflow", + "/migrations", + "/transactions", +} + +// refreshSecondsFromQuery parses the ?refresh=N query parameter on pages +// where auto-refresh makes sense, bounded to [10, 60] seconds so aggressive +// polling cannot hammer the API. +func refreshSecondsFromQuery(r *http.Request) int { + refresh := queryValue(r, "refresh") + if refresh == "" { + return 0 + } + n, err := strconv.Atoi(refresh) + if err != nil || n <= 0 { + return 0 + } + if n > 60 { + n = 60 + } + if n < 10 { + n = 10 + } + for _, prefix := range refreshablePagePrefixes { + if strings.HasPrefix(r.URL.Path, prefix) { + return n + } + } + return 0 +} + +func (s *Server) render(w http.ResponseWriter, r *http.Request, status int, name string, data PageData) { + if data.DocumentTitle == "" { + data.DocumentTitle = s.opts.DocumentTitle + } + data.ReadOnly = s.opts.ReadOnly + data.RefreshSeconds = refreshSecondsFromQuery(r) + // Respect the user's saved theme, defaulting to system preference. + data.Theme = "system" + if cookie, err := r.Cookie(themeCookieName); err == nil && validThemes[cookie.Value] { + data.Theme = cookie.Value + } + if data.NeedsCSRF && data.CSRFToken == "" { + data.CSRFToken = s.csrfToken(w, r) + } + if data.Flash == nil { + if flash := s.flashFromRequest(w, r); flash != nil { + data.Flash = flash + } + } else { + s.clearFlash(w, r) + } + + tmpl := s.templates.templates[strings.TrimPrefix(name, "templates/")] + if tmpl == nil { + http.Error(w, "template not found: "+name, http.StatusInternalServerError) + return + } + + // Render into a buffer first so template failures produce a clean 500 + // rather than a partial page followed by an error. + var buf bytes.Buffer + if err := tmpl.ExecuteTemplate(&buf, name, data); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + if s.opts.EnableDebugJSON && queryValue(r, "format") == "json" { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(status) + _, _ = w.Write([]byte(protoJSONAny(data.Data))) + return + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(status) + _, _ = w.Write(buf.Bytes()) +} + +// protoJSONAny serializes a proto message, or a slice of proto messages, to +// JSON using protoJSON per element. Non-proto values fall back to +// encoding/json/v2. +func protoJSONAny(v any) string { + if msg, ok := v.(proto.Message); ok { + return protoJSON(msg) + } + + rv := reflect.ValueOf(v) + if rv.Kind() != reflect.Slice && rv.Kind() != reflect.Array { + b, err := jsonv2.Marshal(v) + if err != nil { + return "null" + } + return string(b) + } + + parts := make([]string, 0, rv.Len()) + for i := range rv.Len() { + elem := rv.Index(i) + if msg, ok := elem.Interface().(proto.Message); ok { + parts = append(parts, protoJSON(msg)) + continue + } + b, err := jsonv2.Marshal(elem.Interface()) + if err != nil { + parts = append(parts, "null") + continue + } + parts = append(parts, string(b)) + } + return "[" + strings.Join(parts, ",") + "]" +} + +func (s *Server) renderError(w http.ResponseWriter, r *http.Request, status int, title string, err error) { + s.render(w, r, status, "index.html", PageData{ + Title: title, + Flash: &Flash{ + Kind: "error", + Message: err.Error(), + }, + }) +} + +func (s *Server) renderReadOnly(w http.ResponseWriter, r *http.Request) { + s.render(w, r, http.StatusForbidden, "index.html", PageData{ + Title: "Read-only", + Flash: &Flash{ + Kind: "error", + Message: "vtadmin2 is running in read-only mode", + }, + }) +} diff --git a/go/vt/vtadmin/vtadmin2/schema.go b/go/vt/vtadmin/vtadmin2/schema.go new file mode 100644 index 00000000000..a68e3896943 --- /dev/null +++ b/go/vt/vtadmin/vtadmin2/schema.go @@ -0,0 +1,153 @@ +/* +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 ( + "log/slog" + "net/http" + "slices" + + tabletmanagerdatapb "vitess.io/vitess/go/vt/proto/tabletmanagerdata" + vschemapb "vitess.io/vitess/go/vt/proto/vschema" + vtadminpb "vitess.io/vitess/go/vt/proto/vtadmin" + vtrpcpb "vitess.io/vitess/go/vt/proto/vtrpc" + "vitess.io/vitess/go/vt/vterrors" +) + +type ( + schemaDetailData struct { + ClusterID string + Keyspace string + Table string + Definition *tabletmanagerdatapb.TableDefinition + Vindexes []schemaTableVindex + } + + schemaTableVindex struct { + Name string + Columns []string + Type string + ParamKeys []string + Params map[string]string + Primary bool + } +) + +func schemaDetailPath(clusterID, keyspace, table string) string { + return "/schema/" + pathEscape(clusterID) + "/" + pathEscape(keyspace) + "/" + pathEscape(table) +} + +func (s *Server) schemaDetail(w http.ResponseWriter, r *http.Request) { + clusterID := r.PathValue("cluster_id") + keyspace := r.PathValue("keyspace") + table := r.PathValue("table") + + schema, err := s.api.GetSchema(r.Context(), &vtadminpb.GetSchemaRequest{ + ClusterId: clusterID, + Keyspace: keyspace, + Table: table, + }) + if err != nil { + s.renderError(w, r, tabletErrorStatus(err), "Schema", err) + return + } + + definition := findTableDefinition(schema.GetTableDefinitions(), table) + if definition == nil { + s.renderError(w, r, http.StatusNotFound, "Schema", vterrors.Errorf( + vtrpcpb.Code_NOT_FOUND, "no table %s found in keyspace %s (cluster %s)", table, keyspace, clusterID, + )) + return + } + + s.render(w, r, http.StatusOK, "schema.html", PageData{ + Title: table, + Active: "schemas", + Data: schemaDetailData{ + ClusterID: clusterID, + Keyspace: keyspace, + Table: table, + Definition: definition, + Vindexes: s.resolveTableVindexes(r, clusterID, keyspace, table), + }, + }) +} + +// resolveTableVindexes resolves the column vindexes for a table against the +// keyspace VSchema. The VSchema is supplementary to the page (the table +// definition is the primary content), so failures are logged and the page +// renders without the vindexes section rather than erroring out entirely. +func (s *Server) resolveTableVindexes(r *http.Request, clusterID, keyspace, table string) []schemaTableVindex { + resp, err := s.api.GetVSchema(r.Context(), &vtadminpb.GetVSchemaRequest{ + ClusterId: clusterID, + Keyspace: keyspace, + }) + if err != nil { + slog.WarnContext(r.Context(), "failed to fetch VSchema for table vindexes", + slog.String("cluster_id", clusterID), + slog.String("keyspace", keyspace), + slog.String("table", table), + slog.Any("error", err)) + return nil + } + + return resolveTableVindexes(resp.GetVSchema(), table) +} + +func resolveTableVindexes(vschema *vschemapb.Keyspace, table string) []schemaTableVindex { + tableInfo := vschema.GetTables()[table] + if len(tableInfo.GetColumnVindexes()) == 0 { + return nil + } + + keyspaceVindexes := vschema.GetVindexes() + resolved := make([]schemaTableVindex, 0, len(tableInfo.GetColumnVindexes())) + for i, cv := range tableInfo.GetColumnVindexes() { + vindex := keyspaceVindexes[cv.GetName()] + + columns := cv.GetColumns() + if len(columns) == 0 && cv.GetColumn() != "" { + columns = []string{cv.GetColumn()} + } + + params := vindex.GetParams() + paramKeys := make([]string, 0, len(params)) + for key := range params { + paramKeys = append(paramKeys, key) + } + slices.Sort(paramKeys) + + resolved = append(resolved, schemaTableVindex{ + Name: cv.GetName(), + Columns: columns, + Type: vindex.GetType(), + ParamKeys: paramKeys, + Params: params, + Primary: i == 0, + }) + } + return resolved +} + +func findTableDefinition(definitions []*tabletmanagerdatapb.TableDefinition, table string) *tabletmanagerdatapb.TableDefinition { + for _, def := range definitions { + if def.GetName() == table { + return def + } + } + return nil +} diff --git a/go/vt/vtadmin/vtadmin2/schema_test.go b/go/vt/vtadmin/vtadmin2/schema_test.go new file mode 100644 index 00000000000..5b1f020a94a --- /dev/null +++ b/go/vt/vtadmin/vtadmin2/schema_test.go @@ -0,0 +1,222 @@ +/* +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" + vschemapb "vitess.io/vitess/go/vt/proto/vschema" + vtadminpb "vitess.io/vitess/go/vt/proto/vtadmin" + vtrpcpb "vitess.io/vitess/go/vt/proto/vtrpc" + "vitess.io/vitess/go/vt/vterrors" +) + +type schemaFakeServer struct { + fakeVTAdminServer + getSchemaReq *vtadminpb.GetSchemaRequest + getSchemaRes *vtadminpb.Schema + getSchemaErr error + getVSchemaReq *vtadminpb.GetVSchemaRequest + getVSchemaRes *vtadminpb.VSchema + getVSchemaErr error + getSchemasReq *vtadminpb.GetSchemasRequest +} + +func (f *schemaFakeServer) GetSchema(ctx context.Context, req *vtadminpb.GetSchemaRequest) (*vtadminpb.Schema, error) { + f.getSchemaReq = req + if f.getSchemaErr != nil { + return nil, f.getSchemaErr + } + return f.getSchemaRes, nil +} + +func (f *schemaFakeServer) GetSchemas(ctx context.Context, req *vtadminpb.GetSchemasRequest) (*vtadminpb.GetSchemasResponse, error) { + f.getSchemasReq = req + return &vtadminpb.GetSchemasResponse{Schemas: []*vtadminpb.Schema{f.getSchemaRes}}, nil +} + +func (f *schemaFakeServer) GetVSchema(ctx context.Context, req *vtadminpb.GetVSchemaRequest) (*vtadminpb.VSchema, error) { + f.getVSchemaReq = req + if f.getVSchemaErr != nil { + return nil, f.getVSchemaErr + } + return f.getVSchemaRes, nil +} + +func newSchemaTestServer(t *testing.T, fake *schemaFakeServer) *Server { + t.Helper() + s, err := NewServer(fake, Options{}) + require.NoError(t, err) + return s +} + +func getSchemaDetail(t *testing.T, s *Server, clusterID, keyspace, table string) *httptest.ResponseRecorder { + t.Helper() + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/schema/"+clusterID+"/"+keyspace+"/"+table, nil) + s.ServeHTTP(rec, req) + return rec +} + +func newSchemaFake() *schemaFakeServer { + return &schemaFakeServer{ + getSchemaRes: &vtadminpb.Schema{ + Cluster: &vtadminpb.Cluster{Id: testClusterID}, + Keyspace: testKeyspace, + TableDefinitions: []*tabletmanagerdatapb.TableDefinition{ + { + Name: "users", + Schema: "CREATE TABLE `users` (\n `user_id` bigint NOT NULL,\n PRIMARY KEY (`user_id`)\n)", + Columns: []string{"user_id", "email"}, + PrimaryKeyColumns: []string{"user_id"}, + }, + {Name: "orders", Schema: "CREATE TABLE `orders` ()"}, + }, + }, + getVSchemaRes: &vtadminpb.VSchema{ + Cluster: &vtadminpb.Cluster{Id: testClusterID}, + Name: testKeyspace, + VSchema: &vschemapb.Keyspace{ + Sharded: true, + Vindexes: map[string]*vschemapb.Vindex{ + "hash": { + Type: "hash", + Params: map[string]string{"hash_order": "1"}, + }, + "lookup": { + Type: "consistent_lookup_unique", + }, + }, + Tables: map[string]*vschemapb.Table{ + "users": { + ColumnVindexes: []*vschemapb.ColumnVindex{ + {Column: "user_id", Name: "hash"}, + {Columns: []string{"email"}, Name: "lookup"}, + }, + }, + }, + }, + }, + } +} + +func TestSchemaDetailRendersDefinitionAndVindexes(t *testing.T) { + fake := newSchemaFake() + s := newSchemaTestServer(t, fake) + + rec := getSchemaDetail(t, s, testClusterID, testKeyspace, "users") + + require.Equal(t, http.StatusOK, rec.Code) + body := rec.Body.String() + + assert.Contains(t, body, "CREATE TABLE `users`") + assert.Contains(t, body, "user_id") + assert.Contains(t, body, "hash") + assert.Contains(t, body, "hash_order") + assert.Contains(t, body, "lookup") + assert.Contains(t, body, "consistent_lookup_unique") + assert.Contains(t, body, "Primary") + assert.Contains(t, body, "/keyspace/"+testClusterID+"/"+testKeyspace) + + // Other tables' definitions must not appear. + assert.NotContains(t, body, "CREATE TABLE `orders`") +} + +func TestSchemaDetailRequestsSchemaAndVSchema(t *testing.T) { + fake := newSchemaFake() + s := newSchemaTestServer(t, fake) + + getSchemaDetail(t, s, testClusterID, testKeyspace, "users") + + require.NotNil(t, fake.getSchemaReq) + assert.Equal(t, testClusterID, fake.getSchemaReq.ClusterId) + assert.Equal(t, testKeyspace, fake.getSchemaReq.Keyspace) + assert.Equal(t, "users", fake.getSchemaReq.Table) + + require.NotNil(t, fake.getVSchemaReq) + assert.Equal(t, testClusterID, fake.getVSchemaReq.ClusterId) + assert.Equal(t, testKeyspace, fake.getVSchemaReq.Keyspace) +} + +func TestSchemaDetailWithoutVindexes(t *testing.T) { + fake := newSchemaFake() + fake.getVSchemaRes.VSchema = &vschemapb.Keyspace{Sharded: false} + s := newSchemaTestServer(t, fake) + + rec := getSchemaDetail(t, s, testClusterID, testKeyspace, "users") + + require.Equal(t, http.StatusOK, rec.Code) + assert.NotContains(t, rec.Body.String(), "Vindexes") + assert.Contains(t, rec.Body.String(), "CREATE TABLE `users`") +} + +func TestSchemaDetailVSchemaErrorStillRenders(t *testing.T) { + fake := newSchemaFake() + fake.getVSchemaErr = vterrors.New(vtrpcpb.Code_INTERNAL, "vtctld unavailable") + s := newSchemaTestServer(t, fake) + + rec := getSchemaDetail(t, s, testClusterID, testKeyspace, "users") + + // The table definition is the primary content; the page must still render + // without the vindexes section when the VSchema cannot be fetched. + require.Equal(t, http.StatusOK, rec.Code) + assert.Contains(t, rec.Body.String(), "CREATE TABLE `users`") + assert.NotContains(t, rec.Body.String(), "Vindexes") +} + +func TestSchemaDetailTableNotFound(t *testing.T) { + fake := newSchemaFake() + s := newSchemaTestServer(t, fake) + + rec := getSchemaDetail(t, s, testClusterID, testKeyspace, "missing") + + assert.Equal(t, http.StatusNotFound, rec.Code) + assert.Contains(t, rec.Body.String(), "missing") +} + +func TestSchemaDetailAPIError(t *testing.T) { + fake := newSchemaFake() + fake.getSchemaErr = vterrors.New(vtrpcpb.Code_NOT_FOUND, "no such keyspace") + s := newSchemaTestServer(t, fake) + + rec := getSchemaDetail(t, s, testClusterID, "missing", "users") + + assert.Equal(t, http.StatusNotFound, rec.Code) +} + +func TestSchemasListLinksToTableDetail(t *testing.T) { + fake := newSchemaFake() + s := newSchemaTestServer(t, fake) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/schemas", nil) + s.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + body := rec.Body.String() + assert.Contains(t, body, "/schema/"+testClusterID+"/"+testKeyspace+"/users") + assert.Contains(t, body, "/schema/"+testClusterID+"/"+testKeyspace+"/orders") + require.NotNil(t, fake.getSchemasReq) + assert.Empty(t, fake.getSchemasReq.ClusterIds) +} diff --git a/go/vt/vtadmin/vtadmin2/security.go b/go/vt/vtadmin/vtadmin2/security.go new file mode 100644 index 00000000000..bdd19d19570 --- /dev/null +++ b/go/vt/vtadmin/vtadmin2/security.go @@ -0,0 +1,140 @@ +/* +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/rand" + "crypto/subtle" + "encoding/base64" + "net/http" + "strings" +) + +const ( + csrfCookieName = "vtadmin2_csrf" + flashCookieName = "vtadmin2_flash" +) + +// secureCookie reports whether cookies for this request should carry the +// Secure attribute. Direct TLS is detected from the request; when the UI is +// deployed behind an HTTPS-terminating proxy, TrustProxyProto additionally +// honors an X-Forwarded-Proto: https header from the configured proxy. +func (s *Server) secureCookie(r *http.Request) bool { + if r.TLS != nil { + return true + } + return s.opts.TrustProxyProto && r.Header.Get("X-Forwarded-Proto") == "https" +} + +func (s *Server) csrfToken(w http.ResponseWriter, r *http.Request) string { + if cookie, err := r.Cookie(csrfCookieName); err == nil && cookie.Value != "" { + return cookie.Value + } + + secure := s.secureCookie(r) + token := randomToken() + http.SetCookie(w, &http.Cookie{ + Name: csrfCookieName, + Value: token, + Path: "/", + Secure: secure, + HttpOnly: true, + SameSite: http.SameSiteStrictMode, + }) + return token +} + +func validCSRFToken(r *http.Request) bool { + cookie, err := r.Cookie(csrfCookieName) + if err != nil || cookie.Value == "" { + return false + } + formToken := r.Form.Get("csrf_token") + if formToken == "" { + return false + } + return subtle.ConstantTimeCompare([]byte(cookie.Value), []byte(formToken)) == 1 +} + +func (s *Server) setFlash(w http.ResponseWriter, r *http.Request, flash Flash) { + http.SetCookie(w, &http.Cookie{ + Name: flashCookieName, + Value: encodeFlash(flash), + Path: "/", + Secure: s.secureCookie(r), + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + }) +} + +func (s *Server) flashFromRequest(w http.ResponseWriter, r *http.Request) *Flash { + cookie, err := r.Cookie(flashCookieName) + if err != nil || cookie.Value == "" { + return nil + } + + s.clearFlash(w, r) + flash := decodeFlash(cookie.Value) + if flash == nil || !validFlashKind(flash.Kind) || flash.Message == "" { + return nil + } + return flash +} + +func (s *Server) clearFlash(w http.ResponseWriter, r *http.Request) { + http.SetCookie(w, &http.Cookie{ + Name: flashCookieName, + Value: "", + Path: "/", + MaxAge: -1, + Secure: s.secureCookie(r), + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + }) +} + +func encodeFlash(flash Flash) string { + return base64.RawURLEncoding.EncodeToString([]byte(flash.Kind)) + "." + base64.RawURLEncoding.EncodeToString([]byte(flash.Message)) +} + +func decodeFlash(value string) *Flash { + kindValue, messageValue, ok := strings.Cut(value, ".") + if !ok { + return nil + } + kind, err := base64.RawURLEncoding.DecodeString(kindValue) + if err != nil { + return nil + } + message, err := base64.RawURLEncoding.DecodeString(messageValue) + if err != nil { + return nil + } + return &Flash{Kind: string(kind), Message: string(message)} +} + +func validFlashKind(kind string) bool { + return kind == "success" || kind == "error" +} + +func randomToken() string { + var buf [32]byte + if _, err := rand.Read(buf[:]); err != nil { + panic(err) + } + return base64.RawURLEncoding.EncodeToString(buf[:]) +} diff --git a/go/vt/vtadmin/vtadmin2/server.go b/go/vt/vtadmin/vtadmin2/server.go new file mode 100644 index 00000000000..93e742c64c0 --- /dev/null +++ b/go/vt/vtadmin/vtadmin2/server.go @@ -0,0 +1,190 @@ +/* +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" + + vtrpcpb "vitess.io/vitess/go/vt/proto/vtrpc" + vthandlers "vitess.io/vitess/go/vt/vtadmin/http/handlers" + "vitess.io/vitess/go/vt/vtadmin/rbac" + "vitess.io/vitess/go/vt/vterrors" +) + +type ( + Options struct { + Addr string + ReadOnly bool + DocumentTitle string + EnableDebugJSON bool + Authenticator rbac.Authenticator + // TrustProxyProto marks cookies Secure when the request arrives without + // direct TLS but carries X-Forwarded-Proto: https from a trusted + // HTTPS-terminating proxy. Only enable this when the UI is behind such + // a proxy; the header is spoofable otherwise. + TrustProxyProto bool + } + + Server struct { + api vtAdminAPI + opts Options + templates *templateSet + router *http.ServeMux + handler http.Handler + } +) + +func NewServer(api vtAdminAPI, opts Options) (*Server, error) { + if api == nil { + return nil, vterrors.Errorf(vtrpcpb.Code_INVALID_ARGUMENT, "vtadmin2 requires a VTAdmin server") + } + + if opts.DocumentTitle == "" { + opts.DocumentTitle = "VTAdmin2" + } + + tmpl, err := parseTemplates() + if err != nil { + return nil, err + } + + s := &Server{ + api: api, + opts: opts, + templates: tmpl, + router: http.NewServeMux(), + } + s.routes() + s.handler = s.secureHeaders(s.router) + return s, nil +} + +// secureHeaders applies response hardening to every UI response. The UI +// renders CSRF tokens, so framing it from another origin would let an +// attacker clickjack authenticated operators. +func (s *Server) secureHeaders(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-Frame-Options", "DENY") + w.Header().Set("Content-Security-Policy", "frame-ancestors 'none'") + w.Header().Set("X-Content-Type-Options", "nosniff") + next.ServeHTTP(w, r) + }) +} + +func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { + s.handler.ServeHTTP(w, r) +} + +func (s *Server) routes() { + s.handleFunc("GET /{$}", s.redirectRoot) + s.handleFunc("GET /clusters", s.clusters) + s.handleFunc("GET /vtgates", s.gates) + s.handleFunc("GET /vtctlds", s.vtctlds) + s.handleFunc("GET /keyspaces", s.keyspaces) + s.handleFunc("GET /keyspaces/create", s.createKeyspaceForm) + s.handleFunc("POST /keyspaces/create", s.createKeyspace) + s.handleFunc("GET /keyspace/{cluster_id}/{name}", s.keyspace) + s.handleFunc("POST /keyspace/{cluster_id}/{name}/validate", s.keyspaceValidate) + s.handleFunc("POST /keyspace/{cluster_id}/{name}/validate_schema", s.keyspaceValidateSchema) + s.handleFunc("POST /keyspace/{cluster_id}/{name}/validate_version", s.keyspaceValidateVersion) + s.handleFunc("POST /keyspace/{cluster_id}/{name}/rebuild_graph", s.keyspaceRebuildGraph) + s.handleFunc("POST /keyspace/{cluster_id}/{name}/remove_cell", s.keyspaceRemoveCell) + s.handleFunc("POST /keyspace/{cluster_id}/{name}/create_shard", s.keyspaceCreateShard) + s.handleFunc("POST /keyspace/{cluster_id}/{name}/reload_schema", s.keyspaceReloadSchema) + s.handleFunc("GET /keyspace/{cluster_id}/{name}/shard/{shard}", s.shardDetail) + s.handleFunc("POST /keyspace/{cluster_id}/{name}/shard/{shard}/delete", s.shardDelete) + s.handleFunc("POST /keyspace/{cluster_id}/{name}/shard/{shard}/reload-schema", s.shardReloadSchema) + s.handleFunc("POST /keyspace/{cluster_id}/{name}/shard/{shard}/externally-promote", s.shardExternallyPromote) + s.handleFunc("POST /keyspace/{cluster_id}/{name}/shard/{shard}/planned-failover", s.shardPlannedFailover) + s.handleFunc("POST /keyspace/{cluster_id}/{name}/shard/{shard}/emergency-failover", s.shardEmergencyFailover) + s.handleFunc("POST /keyspace/{cluster_id}/{name}/shard/{shard}/validate", s.shardValidate) + s.handleFunc("POST /keyspace/{cluster_id}/{name}/shard/{shard}/validate-version", s.shardValidateVersion) + s.handleFunc("GET /topology", s.topologyPath) + s.handleFunc("GET /topology/{cluster_id}", s.topologyTree) + s.handleFunc("GET /topologytree/{cluster_id}", s.topologyTree) + s.handleFunc("GET /shards", s.shards) + s.handleFunc("GET /tablets", s.tablets) + s.handleFunc("GET /tablet/{cluster_id}/{alias}", s.tabletDetail) + s.handleFunc("GET /tablet/{cluster_id}/{alias}/full_status", s.tabletFullStatus) + s.handleFunc("POST /tablet/{cluster_id}/{alias}/start_replication", s.tabletStartReplication) + s.handleFunc("POST /tablet/{cluster_id}/{alias}/stop_replication", s.tabletStopReplication) + s.handleFunc("POST /tablet/{cluster_id}/{alias}/refresh_replication_source", s.tabletRefreshReplicationSource) + s.handleFunc("POST /tablet/{cluster_id}/{alias}/set_read_only", s.tabletSetReadOnly) + s.handleFunc("POST /tablet/{cluster_id}/{alias}/set_read_write", s.tabletSetReadWrite) + s.handleFunc("POST /tablet/{cluster_id}/{alias}/delete", s.tabletDelete) + s.handleFunc("POST /tablet/{cluster_id}/{alias}/ping", s.tabletPing) + s.handleFunc("POST /tablet/{cluster_id}/{alias}/refresh_state", s.tabletRefreshState) + s.handleFunc("POST /tablet/{cluster_id}/{alias}/health_check", s.tabletRunHealthCheck) + s.handleFunc("GET /schemas", s.schemas) + s.handleFunc("GET /schema/{cluster_id}/{keyspace}/{table}", s.schemaDetail) + s.handleFunc("GET /vschemas", s.vschemas) + s.handleFunc("GET /vschema/{cluster_id}/{keyspace}", s.vschema) + s.handleFunc("GET /srvkeyspaces", s.srvKeyspaces) + s.handleFunc("GET /srvvschemas", s.srvVSchemas) + s.handleFunc("GET /cells", s.cells) + s.handleFunc("GET /cells_aliases", s.cellsAliases) + s.handleFunc("GET /backups", s.backups) + s.handleFunc("GET /workflows", s.workflows) + s.handleFunc("GET /workflows/movetables/create", s.createMoveTablesForm) + s.handleFunc("POST /workflows/movetables/create", s.createMoveTables) + s.handleFunc("GET /workflows/reshard/create", s.createReshardForm) + s.handleFunc("POST /workflows/reshard/create", s.createReshard) + s.handleFunc("GET /workflows/materialize/create", s.createMaterializeForm) + s.handleFunc("POST /workflows/materialize/create", s.createMaterialize) + s.handleFunc("GET /migrations/create", s.createMigrationForm) + s.handleFunc("POST /migrations/create", s.createMigration) + s.handleFunc("GET /migrations", s.schemaMigrations) + s.handleFunc("GET /transactions", s.transactions) + s.handleFunc("GET /vtexplain", s.vtExplain) + s.handleFunc("GET /vexplain", s.vExplain) + s.handleFunc("GET /settings", s.settingsForm) + s.handleFunc("POST /settings", s.settingsSave) + s.handleFunc("GET /transaction/{cluster_id}/{dtid}/info", s.transactionInfo) + // SPA compat: the old UI served the detail page at /transaction/{c}/{dtid}. + s.handleFunc("GET /transaction/{cluster_id}/{dtid}", func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, "/transaction/"+pathEscape(r.PathValue("cluster_id"))+"/"+pathEscape(r.PathValue("dtid"))+"/info", http.StatusMovedPermanently) + }) + s.handleFunc("POST /transaction/{cluster_id}/{dtid}/conclude", s.transactionConclude) + s.handleFunc("GET /workflow/{cluster_id}/{keyspace}/{name}", s.workflow) + s.handleFunc("GET /workflow/{cluster_id}/{keyspace}/{name}/status", s.workflowStatus) + s.handleFunc("POST /workflow/{cluster_id}/{keyspace}/{name}/start", s.workflowStart) + s.handleFunc("POST /workflow/{cluster_id}/{keyspace}/{name}/stop", s.workflowStop) + s.handleFunc("POST /workflow/{cluster_id}/{keyspace}/{name}/cancel", s.workflowCancel) + s.handleFunc("POST /workflow/{cluster_id}/{keyspace}/{name}/complete", s.workflowComplete) + s.handleFunc("POST /workflow/{cluster_id}/{keyspace}/{name}/switch_traffic", s.workflowSwitchTrafficForward) + s.handleFunc("POST /workflow/{cluster_id}/{keyspace}/{name}/reverse_traffic", s.workflowSwitchTrafficReverse) + s.handleFunc("POST /workflow/{cluster_id}/{keyspace}/{name}/vdiff", s.workflowVDiffCreate) + s.handleFunc("GET /workflow/{cluster_id}/{keyspace}/{name}/stream/{tablet_cell}/{tablet_uid}/{stream_id}", s.streamDetail) + s.handleFunc("GET /vdiff/{cluster_id}/show", s.vdiffShow) + s.router.HandleFunc("/static", http.NotFound) + s.router.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticFS())))) +} + +func (s *Server) handleFunc(pattern string, handler http.HandlerFunc) { + s.handle(pattern, handler) +} + +func (s *Server) handle(pattern string, handler http.Handler) { + if s.opts.Authenticator != nil { + handler = vthandlers.NewAuthenticationHandler(s.opts.Authenticator)(handler) + } + s.router.Handle(pattern, handler) +} + +func (s *Server) redirectRoot(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, "/clusters", http.StatusSeeOther) +} diff --git a/go/vt/vtadmin/vtadmin2/server_test.go b/go/vt/vtadmin/vtadmin2/server_test.go new file mode 100644 index 00000000000..74d05debe68 --- /dev/null +++ b/go/vt/vtadmin/vtadmin2/server_test.go @@ -0,0 +1,250 @@ +/* +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" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + vtadminpb "vitess.io/vitess/go/vt/proto/vtadmin" + "vitess.io/vitess/go/vt/vtadmin/rbac" +) + +type fakeVTAdminServer struct { + vtadminpb.UnimplementedVTAdminServer +} + +type fakeAuthenticator struct{} + +type rejectingAuthenticator struct { + called bool +} + +func (fakeAuthenticator) Authenticate(ctx context.Context) (*rbac.Actor, error) { + return &rbac.Actor{Name: "cli", Roles: []string{"admin"}}, nil +} + +func (fakeAuthenticator) AuthenticateHTTP(r *http.Request) (*rbac.Actor, error) { + return &rbac.Actor{Name: "browser", Roles: []string{"admin"}}, nil +} + +func (a *rejectingAuthenticator) Authenticate(ctx context.Context) (*rbac.Actor, error) { + a.called = true + return nil, errors.New("rejected") +} + +func (a *rejectingAuthenticator) AuthenticateHTTP(r *http.Request) (*rbac.Actor, error) { + a.called = true + return nil, errors.New("rejected") +} + +func TestNewServerRequiresAPI(t *testing.T) { + s, err := NewServer(nil, Options{}) + + require.ErrorContains(t, err, "requires a VTAdmin server") + assert.Nil(t, s) +} + +func TestServerUsesStandardLibraryRouter(t *testing.T) { + s, err := NewServer(&fakeVTAdminServer{}, Options{}) + require.NoError(t, err) + + assert.IsType(t, http.NewServeMux(), s.router) +} + +func TestNewHTTPServerConfiguresBoundedTimeouts(t *testing.T) { + h := http.NewServeMux() + s := NewHTTPServer("127.0.0.1:0", h) + + assert.Equal(t, "127.0.0.1:0", s.Addr) + assert.Same(t, h, s.Handler) + assert.Equal(t, 10*time.Second, s.ReadHeaderTimeout) + assert.Equal(t, 30*time.Second, s.ReadTimeout) + assert.Equal(t, 5*time.Minute, s.WriteTimeout) + assert.Equal(t, 120*time.Second, s.IdleTimeout) +} + +func TestNewServerRegistersStaticAssets(t *testing.T) { + s, err := NewServer(&fakeVTAdminServer{}, Options{}) + require.NoError(t, err) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/static/vtadmin2.css", nil) + s.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + assert.Contains(t, rec.Body.String(), "--vt-color-primary") +} + +func TestRootRedirectsToClusters(t *testing.T) { + s, err := NewServer(&fakeVTAdminServer{}, Options{}) + require.NoError(t, err) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/", nil) + s.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusSeeOther, rec.Code) + assert.Equal(t, "/clusters", rec.Header().Get("Location")) +} + +func TestHeadPageRoutesUseGetHandler(t *testing.T) { + fake := &pageFakeServer{} + s, err := NewServer(fake, Options{}) + require.NoError(t, err) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodHead, "/clusters", nil) + s.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + assert.True(t, fake.getClustersCalled) +} + +func TestUnknownPathReturnsNotFound(t *testing.T) { + s, err := NewServer(&fakeVTAdminServer{}, Options{}) + require.NoError(t, err) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/does-not-exist", nil) + s.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusNotFound, rec.Code) +} + +func TestUnknownPathSkipsAuthentication(t *testing.T) { + authenticator := &rejectingAuthenticator{} + s, err := NewServer(&fakeVTAdminServer{}, Options{Authenticator: authenticator}) + require.NoError(t, err) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/does-not-exist", nil) + s.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusNotFound, rec.Code) + assert.False(t, authenticator.called) +} + +func TestStaticDirectoryWithoutSlashReturnsNotFound(t *testing.T) { + s, err := NewServer(&fakeVTAdminServer{}, Options{}) + require.NoError(t, err) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/static", nil) + s.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusNotFound, rec.Code) +} + +func TestStaticAssetsAuthenticateBeforeMethodHandling(t *testing.T) { + authenticator := &rejectingAuthenticator{} + s, err := NewServer(&fakeVTAdminServer{}, Options{Authenticator: authenticator}) + require.NoError(t, err) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/static/vtadmin2.css", nil) + s.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusMethodNotAllowed, rec.Code) + assert.False(t, authenticator.called) +} + +func TestStaticAssetsSkipAuthentication(t *testing.T) { + authenticator := &rejectingAuthenticator{} + s, err := NewServer(&fakeVTAdminServer{}, Options{Authenticator: authenticator}) + require.NoError(t, err) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/static/vtadmin2.css", nil) + s.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + assert.False(t, authenticator.called) +} + +func TestNavigationIncludesReadOnlyParitySections(t *testing.T) { + s, err := NewServer(&fakeVTAdminServer{}, Options{}) + require.NoError(t, err) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/clusters", nil) + s.ServeHTTP(rec, req) + + body := rec.Body.String() + assert.Contains(t, body, "href=\"/vtgates\"") + assert.Contains(t, body, "href=\"/vtctlds\"") + assert.Contains(t, body, "href=\"/cells\"") + assert.Contains(t, body, "href=\"/backups\"") + assert.Contains(t, body, "href=\"/topology\"") + assert.Contains(t, body, "href=\"/shards\"") + assert.Contains(t, body, "href=\"/vschemas\"") + assert.Contains(t, body, "href=\"/srvkeyspaces\"") + assert.Contains(t, body, "href=\"/srvvschemas\"") + assert.Contains(t, body, "href=\"/workflows\"") + assert.Contains(t, body, "href=\"/migrations\"") + assert.Contains(t, body, "href=\"/transactions\"") + assert.Contains(t, body, "href=\"/vtexplain\"") + assert.Contains(t, body, "href=\"/vexplain\"") +} + +func TestServerAuthenticatesRequests(t *testing.T) { + api := &authnFakeServer{} + s, err := NewServer(api, Options{Authenticator: fakeAuthenticator{}}) + require.NoError(t, err) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/clusters", nil) + s.ServeHTTP(rec, req) + + require.NotNil(t, api.actor) + assert.Equal(t, "browser", api.actor.Name) + assert.Equal(t, http.StatusOK, rec.Code) +} + +type authnFakeServer struct { + fakeVTAdminServer + actor *rbac.Actor +} + +func (f *authnFakeServer) GetClusters(ctx context.Context, req *vtadminpb.GetClustersRequest) (*vtadminpb.GetClustersResponse, error) { + f.actor, _ = rbac.FromContext(ctx) + return &vtadminpb.GetClustersResponse{}, nil +} + +func TestSecureHeadersOnEveryResponse(t *testing.T) { + s, err := NewServer(&fakeVTAdminServer{}, Options{}) + require.NoError(t, err) + + for _, path := range []string{"/clusters", "/does-not-exist"} { + t.Run(path, func(t *testing.T) { + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, path, nil) + s.ServeHTTP(rec, req) + + assert.Equal(t, "DENY", rec.Header().Get("X-Frame-Options")) + assert.Equal(t, "frame-ancestors 'none'", rec.Header().Get("Content-Security-Policy")) + assert.Equal(t, "nosniff", rec.Header().Get("X-Content-Type-Options")) + }) + } +} diff --git a/go/vt/vtadmin/vtadmin2/settings.go b/go/vt/vtadmin/vtadmin2/settings.go new file mode 100644 index 00000000000..fcf1d86bdc9 --- /dev/null +++ b/go/vt/vtadmin/vtadmin2/settings.go @@ -0,0 +1,133 @@ +/* +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" + vtrpcpb "vitess.io/vitess/go/vt/proto/vtrpc" + "vitess.io/vitess/go/vt/vterrors" +) + +const ( + themeCookieName = "vtadmin2_theme" + defaultClusterCookieName = "vtadmin2_default_cluster" +) + +// validThemes are the supported theme values for the theme cookie. +var validThemes = map[string]bool{ + "light": true, + "dark": true, + "system": true, +} + +type ( + settingsData struct { + Clusters []*vtadminpb.Cluster + Theme string + DefaultCluster string + } +) + +// settingsForm renders the user preferences page: theme and default cluster, +// persisted in cookies. +func (s *Server) settingsForm(w http.ResponseWriter, r *http.Request) { + clustersResp, err := s.api.GetClusters(r.Context(), &vtadminpb.GetClustersRequest{}) + if err != nil { + s.renderError(w, r, http.StatusInternalServerError, "Settings", err) + return + } + + theme := "system" + if cookie, err := r.Cookie(themeCookieName); err == nil && validThemes[cookie.Value] { + theme = cookie.Value + } + + s.render(w, r, http.StatusOK, "settings.html", PageData{ + Title: "Settings", + Active: "settings", + NeedsCSRF: true, + Data: settingsData{ + Clusters: clustersResp.GetClusters(), + Theme: theme, + DefaultCluster: cookieValue(r, defaultClusterCookieName), + }, + }) +} + +func (s *Server) settingsSave(w http.ResponseWriter, r *http.Request) { + const title = "Settings" + // Preferences are local browser cookies, not Vitess mutations. They remain + // changeable in read-only mode, while still requiring CSRF protection. + if err := r.ParseForm(); err != nil { + s.renderFormError(w, r, title, err.Error()) + return + } + if !validCSRFToken(r) { + s.renderError(w, r, http.StatusForbidden, title, vterrors.New(vtrpcpb.Code_INVALID_ARGUMENT, "invalid CSRF token")) + return + } + + theme := r.Form.Get("theme") + if !validThemes[theme] { + s.renderFormError(w, r, title, "invalid theme: "+theme) + return + } + + secure := s.secureCookie(r) + http.SetCookie(w, &http.Cookie{ + Name: themeCookieName, + Value: theme, + Path: "/", + Secure: secure, + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + }) + if cluster := r.Form.Get("default_cluster"); cluster != "" { + http.SetCookie(w, &http.Cookie{ + Name: defaultClusterCookieName, + Value: cluster, + Path: "/", + Secure: secure, + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + }) + } else { + http.SetCookie(w, &http.Cookie{ + Name: defaultClusterCookieName, + Value: "", + Path: "/", + MaxAge: -1, + Secure: secure, + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + }) + } + + s.redirectWithFlash(w, r, "/settings", Flash{ + Kind: "success", + Message: "settings saved", + }) +} + +func cookieValue(r *http.Request, name string) string { + if cookie, err := r.Cookie(name); err == nil { + return cookie.Value + } + return "" +} diff --git a/go/vt/vtadmin/vtadmin2/settings_test.go b/go/vt/vtadmin/vtadmin2/settings_test.go new file mode 100644 index 00000000000..a8bda062183 --- /dev/null +++ b/go/vt/vtadmin/vtadmin2/settings_test.go @@ -0,0 +1,148 @@ +/* +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" + + vtadminpb "vitess.io/vitess/go/vt/proto/vtadmin" +) + +func TestSettingsFormRenders(t *testing.T) { + fake := &settingsFakeServer{} + s, err := NewServer(fake, Options{}) + require.NoError(t, err) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/settings", nil) + s.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + body := rec.Body.String() + assert.Contains(t, body, `name="theme"`) + assert.Contains(t, body, `value="light"`) + assert.Contains(t, body, `value="dark"`) + assert.Contains(t, body, `value="system"`) + assert.Contains(t, body, `name="default_cluster"`) + assert.Contains(t, body, `value="local"`) + assert.Contains(t, body, `name="csrf_token"`) +} + +func TestSettingsPostSavesCookies(t *testing.T) { + fake := &settingsFakeServer{} + s, err := NewServer(fake, Options{}) + require.NoError(t, err) + + form := url.Values{ + "theme": {"dark"}, + "default_cluster": {"local"}, + } + rec := postShardForm(t, s, "/settings", form) + + assert.Equal(t, http.StatusSeeOther, rec.Code) + + themeCookie := findCookie(rec, themeCookieName) + require.NotNil(t, themeCookie) + assert.Equal(t, "dark", themeCookie.Value) + + clusterCookie := findCookie(rec, defaultClusterCookieName) + require.NotNil(t, clusterCookie) + assert.Equal(t, "local", clusterCookie.Value) +} + +func TestSettingsPostAllowedInReadOnlyMode(t *testing.T) { + fake := &settingsFakeServer{} + s, err := NewServer(fake, Options{ReadOnly: true}) + require.NoError(t, err) + + rec := postShardForm(t, s, "/settings", url.Values{"theme": {"dark"}}) + + assert.Equal(t, http.StatusSeeOther, rec.Code) + themeCookie := findCookie(rec, themeCookieName) + require.NotNil(t, themeCookie) + assert.Equal(t, "dark", themeCookie.Value) +} + +func TestSettingsPostRejectsInvalidTheme(t *testing.T) { + fake := &settingsFakeServer{} + s, err := NewServer(fake, Options{}) + require.NoError(t, err) + + form := url.Values{"theme": {"hot-dog-stand"}} + rec := postShardForm(t, s, "/settings", form) + + assert.Equal(t, http.StatusBadRequest, rec.Code) + assert.Nil(t, findCookie(rec, themeCookieName)) +} + +func TestPageRenderAppliesThemeCookie(t *testing.T) { + fake := &settingsFakeServer{} + s, err := NewServer(fake, Options{}) + require.NoError(t, err) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/clusters", nil) + req.AddCookie(&http.Cookie{Name: themeCookieName, Value: "dark"}) + s.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + assert.Contains(t, rec.Body.String(), `data-theme="dark"`) +} + +func TestPageRenderDefaultsToSystemTheme(t *testing.T) { + fake := &settingsFakeServer{} + s, err := NewServer(fake, Options{}) + require.NoError(t, err) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/clusters", nil) + s.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + assert.Contains(t, rec.Body.String(), `data-theme="system"`) +} + +func TestNavLinksToSettings(t *testing.T) { + fake := &settingsFakeServer{} + s, err := NewServer(fake, Options{}) + require.NoError(t, err) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/clusters", nil) + s.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + assert.Contains(t, rec.Body.String(), `href="/settings"`) +} + +type settingsFakeServer struct { + fakeVTAdminServer +} + +func (f *settingsFakeServer) GetClusters(ctx context.Context, req *vtadminpb.GetClustersRequest) (*vtadminpb.GetClustersResponse, error) { + return &vtadminpb.GetClustersResponse{Clusters: []*vtadminpb.Cluster{ + {Id: "local", Name: "Local"}, + {Id: "prod", Name: "Production"}, + }}, nil +} diff --git a/go/vt/vtadmin/vtadmin2/shard.go b/go/vt/vtadmin/vtadmin2/shard.go new file mode 100644 index 00000000000..fb89ffa6507 --- /dev/null +++ b/go/vt/vtadmin/vtadmin2/shard.go @@ -0,0 +1,452 @@ +/* +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/url" + "slices" + "strconv" + "strings" + + "vitess.io/vitess/go/vt/topo/topoproto" + "vitess.io/vitess/go/vt/vterrors" + + logutilpb "vitess.io/vitess/go/vt/proto/logutil" + topodatapb "vitess.io/vitess/go/vt/proto/topodata" + vtadminpb "vitess.io/vitess/go/vt/proto/vtadmin" + vtctldatapb "vitess.io/vitess/go/vt/proto/vtctldata" + vtrpcpb "vitess.io/vitess/go/vt/proto/vtrpc" + vttimepb "vitess.io/vitess/go/vt/proto/vttime" +) + +type ( + shardDetailData struct { + ClusterID string + Keyspace string + Shard string + Tablets []*vtadminpb.Tablet + Positions *vtadminpb.ClusterShardReplicationPosition + } + + shardFailoverOptions struct { + NewPrimary *topodatapb.TabletAlias + WaitReplicasTimeout *vttimepb.Duration + Planned *vtctldatapb.PlannedReparentShardRequest + } +) + +func shardDetailPath(clusterID, keyspace, shard string) string { + return "/keyspace/" + url.PathEscape(clusterID) + "/" + url.PathEscape(keyspace) + "/shard/" + url.PathEscape(shard) +} + +func shardActionPath(clusterID, keyspace, shard, action string) string { + return shardDetailPath(clusterID, keyspace, shard) + "/" + url.PathEscape(action) +} + +func keyspaceDetailPath(clusterID, keyspace string) string { + return "/keyspace/" + url.PathEscape(clusterID) + "/" + url.PathEscape(keyspace) +} + +func keyspaceActionPath(clusterID, keyspace, action string) string { + return keyspaceDetailPath(clusterID, keyspace) + "/" + url.PathEscape(action) +} + +func (s *Server) shardDetail(w http.ResponseWriter, r *http.Request) { + clusterID := r.PathValue("cluster_id") + keyspace := r.PathValue("name") + shard := r.PathValue("shard") + + _, err := s.api.GetKeyspace(r.Context(), &vtadminpb.GetKeyspaceRequest{ + ClusterId: clusterID, + Keyspace: keyspace, + }) + if err != nil { + s.renderError(w, r, tabletErrorStatus(err), "Shard", err) + return + } + + tabletsResp, err := s.api.GetTablets(r.Context(), &vtadminpb.GetTabletsRequest{ + ClusterIds: []string{clusterID}, + }) + if err != nil { + s.renderError(w, r, http.StatusInternalServerError, "Shard", err) + return + } + + tablets := filterShardTablets(tabletsResp.GetTablets(), keyspace, shard) + slices.SortFunc(tablets, func(a, b *vtadminpb.Tablet) int { + return strings.Compare(topoproto.TabletAliasString(a.GetTablet().GetAlias()), topoproto.TabletAliasString(b.GetTablet().GetAlias())) + }) + + positionsResp, err := s.api.GetShardReplicationPositions(r.Context(), &vtadminpb.GetShardReplicationPositionsRequest{ + ClusterIds: []string{clusterID}, + KeyspaceShards: []string{keyspace + "/" + shard}, + }) + if err != nil { + s.renderError(w, r, http.StatusInternalServerError, "Shard", err) + return + } + + s.render(w, r, http.StatusOK, "shard.html", PageData{ + Title: keyspace + "/" + shard, + Active: "shards", + NeedsCSRF: !s.opts.ReadOnly, + Data: shardDetailData{ + ClusterID: clusterID, + Keyspace: keyspace, + Shard: shard, + Tablets: tablets, + Positions: findShardPosition(positionsResp.GetReplicationPositions(), clusterID, keyspace, shard), + }, + }) +} + +func filterShardTablets(tablets []*vtadminpb.Tablet, keyspace, shard string) []*vtadminpb.Tablet { + filtered := make([]*vtadminpb.Tablet, 0, len(tablets)) + for _, tablet := range tablets { + if tablet.GetTablet().GetKeyspace() == keyspace && tablet.GetTablet().GetShard() == shard { + filtered = append(filtered, tablet) + } + } + return filtered +} + +func findShardPosition(positions []*vtadminpb.ClusterShardReplicationPosition, clusterID, keyspace, shard string) *vtadminpb.ClusterShardReplicationPosition { + for _, pos := range positions { + if pos.GetCluster().GetId() == clusterID && pos.GetKeyspace() == keyspace && pos.GetShard() == shard { + return pos + } + } + return nil +} + +// beginShardAction performs the shared preflight for shard mutation handlers: +// read-only rejection, form parsing, and CSRF validation. It returns the +// parsed route values; ok is false when the preflight has already rendered an +// error response. +func (s *Server) beginShardAction(w http.ResponseWriter, r *http.Request, title string) (clusterID, keyspace, shard string, ok bool) { + if !s.beginFormAction(w, r, title) { + return "", "", "", false + } + return r.PathValue("cluster_id"), r.PathValue("name"), r.PathValue("shard"), true +} + +func (s *Server) shardDelete(w http.ResponseWriter, r *http.Request) { + const title = "Delete shard" + clusterID, keyspace, shard, ok := s.beginShardAction(w, r, title) + if !ok { + return + } + + _, err := s.api.DeleteShards(r.Context(), &vtadminpb.DeleteShardsRequest{ + ClusterId: clusterID, + Options: &vtctldatapb.DeleteShardsRequest{ + Shards: []*vtctldatapb.Shard{{Keyspace: keyspace, Name: shard}}, + }, + }) + if err != nil { + s.renderFormError(w, r, title, err.Error()) + return + } + + s.redirectWithFlash(w, r, "/keyspaces", Flash{ + Kind: "success", + Message: "deleted shard " + keyspace + "/" + shard, + }) +} + +func (s *Server) shardReloadSchema(w http.ResponseWriter, r *http.Request) { + const title = "Reload schema" + clusterID, keyspace, shard, ok := s.beginShardAction(w, r, title) + if !ok { + return + } + + resp, err := s.api.ReloadSchemaShard(r.Context(), &vtadminpb.ReloadSchemaShardRequest{ + ClusterId: clusterID, + Keyspace: keyspace, + Shard: shard, + IncludePrimary: r.Form.Get("include_primary") == "on", + }) + if err != nil { + s.renderFormError(w, r, title, err.Error()) + return + } + if resp == nil { + s.renderFormError(w, r, title, "not authorized to reload schema") + return + } + if err := reloadSchemaEventsError(resp.GetEvents()); err != nil { + s.renderFormError(w, r, title, err.Error()) + return + } + + s.redirectWithFlash(w, r, shardDetailPath(clusterID, keyspace, shard), Flash{ + Kind: "success", + Message: "reloaded schema on shard " + keyspace + "/" + shard, + }) +} + +func (s *Server) shardExternallyPromote(w http.ResponseWriter, r *http.Request) { + const title = "Tablet externally promoted" + clusterID, keyspace, shard, ok := s.beginShardAction(w, r, title) + if !ok { + return + } + + alias, err := parseShardFormAlias(r, "alias") + if err != nil { + s.renderFormError(w, r, title, err.Error()) + return + } + if alias == nil { + s.renderFormError(w, r, title, "promoted tablet alias is required") + return + } + + // The page is scoped to one shard, but the API operates on whatever alias + // it is given. Verify the submitted tablet actually belongs to this shard + // before mutating it, otherwise an operator could affect another shard + // while believing they are acting on this one. + tablet, err := s.api.GetTablet(r.Context(), &vtadminpb.GetTabletRequest{ + Alias: alias, + ClusterIds: []string{clusterID}, + }) + if err != nil { + s.renderError(w, r, tabletErrorStatus(err), title, err) + return + } + if tablet == nil || tablet.GetTablet() == nil { + s.renderFormError(w, r, title, "tablet not found") + return + } + if tablet.GetTablet().GetKeyspace() != keyspace || tablet.GetTablet().GetShard() != shard { + s.renderFormErrorErr(w, r, title, vterrors.Errorf(vtrpcpb.Code_INVALID_ARGUMENT, + "tablet %s belongs to %s/%s, not %s/%s", + topoproto.TabletAliasString(alias), + tablet.GetTablet().GetKeyspace(), tablet.GetTablet().GetShard(), keyspace, shard)) + return + } + + _, err = s.api.TabletExternallyPromoted(r.Context(), &vtadminpb.TabletExternallyPromotedRequest{ + Alias: alias, + ClusterIds: []string{clusterID}, + }) + if err != nil { + s.renderFormError(w, r, title, err.Error()) + return + } + + s.redirectWithFlash(w, r, shardDetailPath(clusterID, keyspace, shard), Flash{ + Kind: "success", + Message: "acknowledged external promotion of " + topoproto.TabletAliasString(alias), + }) +} + +func (s *Server) shardPlannedFailover(w http.ResponseWriter, r *http.Request) { + const title = "Planned failover" + clusterID, keyspace, shard, ok := s.beginShardAction(w, r, title) + if !ok { + return + } + + options, err := parseShardFailoverOptions(r, keyspace, shard) + if err != nil { + s.renderFormError(w, r, title, err.Error()) + return + } + + resp, err := s.api.PlannedFailoverShard(r.Context(), &vtadminpb.PlannedFailoverShardRequest{ + ClusterId: clusterID, + Options: options.Planned, + }) + if err != nil { + s.renderFormError(w, r, title, err.Error()) + return + } + if resp == nil { + s.renderFormError(w, r, title, "not authorized to run planned failover") + return + } + + s.redirectWithFlash(w, r, shardDetailPath(clusterID, keyspace, shard), Flash{ + Kind: "success", + Message: "planned failover completed for shard " + keyspace + "/" + shard, + }) +} + +func (s *Server) shardEmergencyFailover(w http.ResponseWriter, r *http.Request) { + const title = "Emergency failover" + clusterID, keyspace, shard, ok := s.beginShardAction(w, r, title) + if !ok { + return + } + + options, err := parseShardFailoverOptions(r, keyspace, shard) + if err != nil { + s.renderFormError(w, r, title, err.Error()) + return + } + + resp, err := s.api.EmergencyFailoverShard(r.Context(), &vtadminpb.EmergencyFailoverShardRequest{ + ClusterId: clusterID, + Options: &vtctldatapb.EmergencyReparentShardRequest{ + Keyspace: keyspace, + Shard: shard, + NewPrimary: options.NewPrimary, + WaitReplicasTimeout: options.WaitReplicasTimeout, + PreventCrossCellPromotion: r.Form.Get("prevent_cross_cell_promotion") == "on", + }, + }) + if err != nil { + s.renderFormError(w, r, title, err.Error()) + return + } + if resp == nil { + s.renderFormError(w, r, title, "not authorized to run emergency failover") + return + } + + s.redirectWithFlash(w, r, shardDetailPath(clusterID, keyspace, shard), Flash{ + Kind: "success", + Message: "emergency failover completed for shard " + keyspace + "/" + shard, + }) +} + +// parseShardFailoverOptions reads the new_primary tablet alias and +// wait_replicas_timeout form values shared by planned and emergency failover. +// An empty new_primary means vtctld picks the most up-to-date candidate. +func parseShardFailoverOptions(r *http.Request, keyspace, shard string) (*shardFailoverOptions, error) { + opts := &shardFailoverOptions{} + + newPrimary, err := parseShardFormAlias(r, "new_primary") + if err != nil { + return nil, err + } + + timeout, err := parseShardFormDuration(r, "wait_replicas_timeout") + if err != nil { + return nil, err + } + + opts.NewPrimary = newPrimary + opts.WaitReplicasTimeout = timeout + opts.Planned = &vtctldatapb.PlannedReparentShardRequest{ + Keyspace: keyspace, + Shard: shard, + NewPrimary: newPrimary, + WaitReplicasTimeout: timeout, + } + return opts, nil +} + +func parseShardFormAlias(r *http.Request, field string) (*topodatapb.TabletAlias, error) { + value := strings.TrimSpace(r.Form.Get(field)) + if value == "" { + return nil, nil + } + alias, err := topoproto.ParseTabletAlias(value) + if err != nil { + return nil, vterrors.Wrapf(err, "invalid tablet alias for %s", field) + } + return alias, nil +} + +func parseShardFormDuration(r *http.Request, field string) (*vttimepb.Duration, error) { + value := strings.TrimSpace(r.Form.Get(field)) + if value == "" { + return nil, nil + } + seconds, err := strconv.ParseInt(value, 10, 64) + if err != nil || seconds < 0 { + return nil, vterrors.Errorf(vtrpcpb.Code_INVALID_ARGUMENT, "invalid value for %s: %s (expected non-negative integer seconds)", field, value) + } + return &vttimepb.Duration{Seconds: seconds}, nil +} + +func (s *Server) shardValidate(w http.ResponseWriter, r *http.Request) { + const title = "Validate shard" + clusterID, keyspace, shard, ok := s.beginShardAction(w, r, title) + if !ok { + return + } + + resp, err := s.api.ValidateShard(r.Context(), &vtadminpb.ValidateShardRequest{ + ClusterId: clusterID, + Keyspace: keyspace, + Shard: shard, + PingTablets: r.Form.Get("ping_tablets") == "on", + }) + if err != nil { + s.renderFormError(w, r, title, err.Error()) + return + } + if resp == nil { + s.renderFormError(w, r, title, "not authorized to validate shard") + return + } + + s.redirectWithFlash(w, r, shardDetailPath(clusterID, keyspace, shard), Flash{ + Kind: "success", + Message: "validated shard " + keyspace + "/" + shard + ": " + strings.Join(resp.GetResults(), "; "), + }) +} + +func (s *Server) shardValidateVersion(w http.ResponseWriter, r *http.Request) { + const title = "Validate version" + clusterID, keyspace, shard, ok := s.beginShardAction(w, r, title) + if !ok { + return + } + + resp, err := s.api.ValidateVersionShard(r.Context(), &vtadminpb.ValidateVersionShardRequest{ + ClusterId: clusterID, + Keyspace: keyspace, + Shard: shard, + }) + if err != nil { + s.renderFormError(w, r, title, err.Error()) + return + } + if resp == nil { + s.renderFormError(w, r, title, "not authorized to validate version") + return + } + + s.redirectWithFlash(w, r, shardDetailPath(clusterID, keyspace, shard), Flash{ + Kind: "success", + Message: "validated versions on shard " + keyspace + "/" + shard, + }) +} + +func reloadSchemaEventsError(events []*logutilpb.Event) error { + for _, ev := range events { + if ev == nil { + continue + } + switch ev.Level { + case logutilpb.Level_ERROR: + return vterrors.Errorf(vtrpcpb.Code_INTERNAL, "reload schema failed: %s", ev.GetValue()) + case logutilpb.Level_WARNING: + if strings.Contains(strings.ToLower(ev.GetValue()), "failed") { + return vterrors.Errorf(vtrpcpb.Code_INTERNAL, "reload schema failed: %s", ev.GetValue()) + } + } + } + return nil +} diff --git a/go/vt/vtadmin/vtadmin2/shard_test.go b/go/vt/vtadmin/vtadmin2/shard_test.go new file mode 100644 index 00000000000..9f23f3c5ee4 --- /dev/null +++ b/go/vt/vtadmin/vtadmin2/shard_test.go @@ -0,0 +1,663 @@ +/* +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" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + logutilpb "vitess.io/vitess/go/vt/proto/logutil" + replicationdatapb "vitess.io/vitess/go/vt/proto/replicationdata" + topodatapb "vitess.io/vitess/go/vt/proto/topodata" + 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" +) + +const ( + testClusterID = "local" + testKeyspace = "commerce" + testShard = "0" + + testCSRF = "test-csrf-token" +) + +type shardFakeServer struct { + fakeVTAdminServer + + getTabletsRequest *vtadminpb.GetTabletsRequest + getTabletsError error + tablets []*vtadminpb.Tablet + keyspaceError error + getPositionsReql *vtadminpb.GetShardReplicationPositionsRequest + getPositionsError error + positions []*vtadminpb.ClusterShardReplicationPosition + + deleteShardsReq *vtadminpb.DeleteShardsRequest + reloadSchemaShardReq *vtadminpb.ReloadSchemaShardRequest + reloadSchemaShardResp *vtadminpb.ReloadSchemaShardResponse + externallyPromotedReq *vtadminpb.TabletExternallyPromotedRequest + plannedFailoverReq *vtadminpb.PlannedFailoverShardRequest + plannedFailoverNil bool + emergencyFailoverReq *vtadminpb.EmergencyFailoverShardRequest + emergencyFailoverNil bool + validateShardReq *vtadminpb.ValidateShardRequest + validateShardNil bool + validateVersionShardRe *vtadminpb.ValidateVersionShardRequest + validateVersionNil bool + getTabletReq *vtadminpb.GetTabletRequest + getTabletError error +} + +func (f *shardFakeServer) GetKeyspace(ctx context.Context, req *vtadminpb.GetKeyspaceRequest) (*vtadminpb.Keyspace, error) { + if f.keyspaceError != nil { + return nil, f.keyspaceError + } + return &vtadminpb.Keyspace{ + Cluster: &vtadminpb.Cluster{Id: req.ClusterId}, + Keyspace: &vtctldatapb.Keyspace{Name: req.Keyspace}, + Shards: map[string]*vtctldatapb.Shard{ + testShard: {}, + }, + }, nil +} + +func (f *shardFakeServer) GetTablets(ctx context.Context, req *vtadminpb.GetTabletsRequest) (*vtadminpb.GetTabletsResponse, error) { + f.getTabletsRequest = req + if f.getTabletsError != nil { + return nil, f.getTabletsError + } + return &vtadminpb.GetTabletsResponse{Tablets: f.tablets}, nil +} + +func (f *shardFakeServer) GetShardReplicationPositions(ctx context.Context, req *vtadminpb.GetShardReplicationPositionsRequest) (*vtadminpb.GetShardReplicationPositionsResponse, error) { + f.getPositionsReql = req + if f.getPositionsError != nil { + return nil, f.getPositionsError + } + return &vtadminpb.GetShardReplicationPositionsResponse{ReplicationPositions: f.positions}, nil +} + +func (f *shardFakeServer) GetTablet(ctx context.Context, req *vtadminpb.GetTabletRequest) (*vtadminpb.Tablet, error) { + f.getTabletReq = req + if f.getTabletError != nil { + return nil, f.getTabletError + } + return &vtadminpb.Tablet{ + Cluster: &vtadminpb.Cluster{Id: req.GetClusterIds()[0]}, + Tablet: &topodatapb.Tablet{ + Alias: req.Alias, + Keyspace: testKeyspace, + Shard: testShard, + }, + }, nil +} + +func (f *shardFakeServer) DeleteShards(ctx context.Context, req *vtadminpb.DeleteShardsRequest) (*vtctldatapb.DeleteShardsResponse, error) { + f.deleteShardsReq = req + return &vtctldatapb.DeleteShardsResponse{}, nil +} + +func (f *shardFakeServer) ReloadSchemaShard(ctx context.Context, req *vtadminpb.ReloadSchemaShardRequest) (*vtadminpb.ReloadSchemaShardResponse, error) { + f.reloadSchemaShardReq = req + if f.reloadSchemaShardResp != nil { + return f.reloadSchemaShardResp, nil + } + return &vtadminpb.ReloadSchemaShardResponse{}, nil +} + +func (f *shardFakeServer) TabletExternallyPromoted(ctx context.Context, req *vtadminpb.TabletExternallyPromotedRequest) (*vtadminpb.TabletExternallyPromotedResponse, error) { + f.externallyPromotedReq = req + return &vtadminpb.TabletExternallyPromotedResponse{}, nil +} + +func (f *shardFakeServer) PlannedFailoverShard(ctx context.Context, req *vtadminpb.PlannedFailoverShardRequest) (*vtadminpb.PlannedFailoverShardResponse, error) { + f.plannedFailoverReq = req + if f.plannedFailoverNil { + return nil, nil + } + return &vtadminpb.PlannedFailoverShardResponse{}, nil +} + +func (f *shardFakeServer) EmergencyFailoverShard(ctx context.Context, req *vtadminpb.EmergencyFailoverShardRequest) (*vtadminpb.EmergencyFailoverShardResponse, error) { + f.emergencyFailoverReq = req + if f.emergencyFailoverNil { + return nil, nil + } + return &vtadminpb.EmergencyFailoverShardResponse{}, nil +} + +func (f *shardFakeServer) ValidateShard(ctx context.Context, req *vtadminpb.ValidateShardRequest) (*vtctldatapb.ValidateShardResponse, error) { + f.validateShardReq = req + if f.validateShardNil { + return nil, nil + } + return &vtctldatapb.ValidateShardResponse{}, nil +} + +func (f *shardFakeServer) ValidateVersionShard(ctx context.Context, req *vtadminpb.ValidateVersionShardRequest) (*vtctldatapb.ValidateVersionShardResponse, error) { + f.validateVersionShardRe = req + if f.validateVersionNil { + return nil, nil + } + return &vtctldatapb.ValidateVersionShardResponse{}, nil +} + +func newShardTestServer(t *testing.T, fake *shardFakeServer, readOnly bool) *Server { + t.Helper() + s, err := NewServer(fake, Options{ReadOnly: readOnly}) + require.NoError(t, err) + return s +} + +func getShardDetail(t *testing.T, s *Server, clusterID, keyspace, shard string) *httptest.ResponseRecorder { + t.Helper() + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/keyspace/"+clusterID+"/"+keyspace+"/shard/"+shard, nil) + s.ServeHTTP(rec, req) + return rec +} + +func postShardForm(t *testing.T, s *Server, path string, form url.Values) *httptest.ResponseRecorder { + t.Helper() + form.Set("csrf_token", testCSRF) + 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: testCSRF}) + rec := httptest.NewRecorder() + s.ServeHTTP(rec, req) + return rec +} + +func testTablet(aliasCell string, uid uint32, ks, shard string, typ topodatapb.TabletType) *vtadminpb.Tablet { + return &vtadminpb.Tablet{ + Cluster: &vtadminpb.Cluster{Id: testClusterID}, + Tablet: &topodatapb.Tablet{ + Alias: &topodatapb.TabletAlias{Cell: aliasCell, Uid: uid}, + Hostname: "host-" + aliasCell, + Keyspace: ks, + Shard: shard, + Type: typ, + }, + State: vtadminpb.Tablet_SERVING, + } +} + +func newShardFake() *shardFakeServer { + return &shardFakeServer{ + tablets: []*vtadminpb.Tablet{ + testTablet("zone1", 100, testKeyspace, testShard, topodatapb.TabletType_PRIMARY), + testTablet("zone1", 101, testKeyspace, testShard, topodatapb.TabletType_REPLICA), + }, + positions: []*vtadminpb.ClusterShardReplicationPosition{{ + Cluster: &vtadminpb.Cluster{Id: testClusterID}, + Keyspace: testKeyspace, + Shard: testShard, + PositionInfo: &vtctldatapb.ShardReplicationPositionsResponse{ + ReplicationStatuses: map[string]*replicationdatapb.Status{ + "zone1-0000000101": { + ReplicationLagSeconds: 7, + Position: "MySQL56/00000000-0000-0000-0000-000000000001:1-5", + }, + }, + }, + }}, + } +} + +func TestShardDetailRendersTabletsAndReplicationPositions(t *testing.T) { + fake := newShardFake() + fake.tablets = append(fake.tablets, testTablet("zone2", 200, "other_ks", "0", topodatapb.TabletType_RDONLY)) + s := newShardTestServer(t, fake, false) + + rec := getShardDetail(t, s, testClusterID, testKeyspace, testShard) + + require.Equal(t, http.StatusOK, rec.Code) + body := rec.Body.String() + + // Tablet details for tablets in the shard. + assert.Contains(t, body, "zone1-0000000100") + assert.Contains(t, body, "zone1-0000000101") + assert.Contains(t, body, "/tablet/"+testClusterID+"/zone1-0000000100") + assert.Contains(t, body, "PRIMARY") + assert.Contains(t, body, "SERVING") + + // Replication position data. + assert.Contains(t, body, "7") + assert.Contains(t, body, "MySQL56/00000000-0000-0000-0000-000000000001:1-5") + + // Tablets from other keyspaces or shards must not appear. + assert.NotContains(t, body, "zone2-0000000200") +} + +func TestShardDetailRequestsReplicationPositionsForShard(t *testing.T) { + fake := newShardFake() + s := newShardTestServer(t, fake, false) + + getShardDetail(t, s, testClusterID, testKeyspace, testShard) + + require.NotNil(t, fake.getPositionsReql) + assert.Equal(t, []string{testClusterID}, fake.getPositionsReql.ClusterIds) + assert.Equal(t, []string{testKeyspace + "/" + testShard}, fake.getPositionsReql.KeyspaceShards) + + require.NotNil(t, fake.getTabletsRequest) + assert.Equal(t, []string{testClusterID}, fake.getTabletsRequest.ClusterIds) +} + +func TestShardDetailEmptyShard(t *testing.T) { + fake := newShardFake() + fake.tablets = nil + fake.positions = nil + s := newShardTestServer(t, fake, false) + + rec := getShardDetail(t, s, testClusterID, testKeyspace, testShard) + + require.Equal(t, http.StatusOK, rec.Code) + assert.Contains(t, rec.Body.String(), "No tablets") +} + +func TestShardDetailAPIError(t *testing.T) { + fake := newShardFake() + fake.keyspaceError = vterrors.New(vtrpcpb.Code_INTERNAL, "topo exploded") + s := newShardTestServer(t, fake, false) + + rec := getShardDetail(t, s, testClusterID, testKeyspace, testShard) + + assert.Equal(t, http.StatusInternalServerError, rec.Code) +} + +func TestShardDetailKeyspaceNotFound(t *testing.T) { + fake := newShardFake() + fake.keyspaceError = vterrors.New(vtrpcpb.Code_NOT_FOUND, "no such keyspace") + s := newShardTestServer(t, fake, false) + + rec := getShardDetail(t, s, testClusterID, "missing", testShard) + + assert.Equal(t, http.StatusNotFound, rec.Code) +} + +func TestShardActionsRequireCSRFToken(t *testing.T) { + paths := shardActionPaths() + + for _, path := range paths { + t.Run(path, func(t *testing.T) { + fake := newShardFake() + s := newShardTestServer(t, fake, false) + + req := httptest.NewRequest(http.MethodPost, path, strings.NewReader("csrf_token=wrong")) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.AddCookie(&http.Cookie{Name: csrfCookieName, Value: testCSRF}) + rec := httptest.NewRecorder() + s.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusForbidden, rec.Code) + assertNilShardActionRequests(t, fake) + }) + } +} + +func TestShardActionsRejectReadOnly(t *testing.T) { + paths := shardActionPaths() + + for _, path := range paths { + t.Run(path, func(t *testing.T) { + fake := newShardFake() + s := newShardTestServer(t, fake, true) + + rec := postShardForm(t, s, path, url.Values{}) + + assert.Equal(t, http.StatusForbidden, rec.Code) + assertNilShardActionRequests(t, fake) + }) + } +} + +func shardActionPaths() []string { + base := "/keyspace/" + testClusterID + "/" + testKeyspace + "/shard/" + testShard + return []string{ + base + "/delete", + base + "/reload-schema", + base + "/externally-promote", + base + "/planned-failover", + base + "/emergency-failover", + base + "/validate", + base + "/validate-version", + } +} + +func assertNilShardActionRequests(t *testing.T, fake *shardFakeServer) { + t.Helper() + assert.Nil(t, fake.deleteShardsReq) + assert.Nil(t, fake.reloadSchemaShardReq) + assert.Nil(t, fake.externallyPromotedReq) + assert.Nil(t, fake.plannedFailoverReq) + assert.Nil(t, fake.emergencyFailoverReq) + assert.Nil(t, fake.validateShardReq) + assert.Nil(t, fake.validateVersionShardRe) +} + +func TestShardDeleteRedirectsWithFlash(t *testing.T) { + fake := newShardFake() + s := newShardTestServer(t, fake, false) + + rec := postShardForm(t, s, shardActionPaths()[0], url.Values{}) + + assert.Equal(t, http.StatusSeeOther, rec.Code) + assert.Equal(t, "/keyspaces", rec.Header().Get("Location")) + require.NotNil(t, fake.deleteShardsReq) + assert.Equal(t, testClusterID, fake.deleteShardsReq.ClusterId) + require.NotNil(t, fake.deleteShardsReq.Options) + require.Len(t, fake.deleteShardsReq.Options.Shards, 1) + assert.Equal(t, testKeyspace, fake.deleteShardsReq.Options.Shards[0].Keyspace) + assert.Equal(t, testShard, fake.deleteShardsReq.Options.Shards[0].Name) +} + +func TestShardReloadSchema(t *testing.T) { + fake := newShardFake() + s := newShardTestServer(t, fake, false) + + form := url.Values{"include_primary": {"on"}} + rec := postShardForm(t, s, shardActionPaths()[1], form) + + assert.Equal(t, http.StatusSeeOther, rec.Code) + require.NotNil(t, fake.reloadSchemaShardReq) + assert.Equal(t, testClusterID, fake.reloadSchemaShardReq.ClusterId) + assert.Equal(t, testKeyspace, fake.reloadSchemaShardReq.Keyspace) + assert.Equal(t, testShard, fake.reloadSchemaShardReq.Shard) + assert.True(t, fake.reloadSchemaShardReq.IncludePrimary) +} + +func TestShardReloadSchemaDefaultsExcludePrimary(t *testing.T) { + fake := newShardFake() + s := newShardTestServer(t, fake, false) + + rec := postShardForm(t, s, shardActionPaths()[1], url.Values{}) + + assert.Equal(t, http.StatusSeeOther, rec.Code) + require.NotNil(t, fake.reloadSchemaShardReq) + assert.False(t, fake.reloadSchemaShardReq.IncludePrimary) +} + +func TestShardReloadSchemaFailureEventsDoNotFlashSuccess(t *testing.T) { + fake := newShardFake() + fake.reloadSchemaShardResp = &vtadminpb.ReloadSchemaShardResponse{ + Events: []*logutilpb.Event{{ + Level: logutilpb.Level_WARNING, + Value: "ReloadSchemaShard(commerce/0) failed to load tablet list", + }}, + } + s := newShardTestServer(t, fake, false) + + rec := postShardForm(t, s, shardActionPaths()[1], url.Values{}) + + assert.Equal(t, http.StatusBadRequest, rec.Code) + assert.NotEqual(t, shardDetailPath(testClusterID, testKeyspace, testShard), rec.Header().Get("Location")) +} + +func TestShardExternallyPromote(t *testing.T) { + fake := newShardFake() + s := newShardTestServer(t, fake, false) + + form := url.Values{"alias": {"zone1-0000000100"}} + rec := postShardForm(t, s, shardActionPaths()[2], form) + + assert.Equal(t, http.StatusSeeOther, rec.Code) + require.NotNil(t, fake.externallyPromotedReq) + require.NotNil(t, fake.externallyPromotedReq.Alias) + assert.Equal(t, "zone1", fake.externallyPromotedReq.Alias.Cell) + assert.Equal(t, uint32(100), fake.externallyPromotedReq.Alias.Uid) + assert.Equal(t, []string{testClusterID}, fake.externallyPromotedReq.ClusterIds) +} + +func TestShardExternallyPromoteRejectsTabletFromOtherShard(t *testing.T) { + fake := newShardFake() + // The fake's GetTablet reports the tablet lives in commerce/0, so a + // request scoped to a different shard must be rejected. + s := newShardTestServer(t, fake, false) + + form := url.Values{"alias": {"zone1-0000000100"}} + rec := postShardForm(t, s, shardDetailPath(testClusterID, testKeyspace, "other-shard")+"/externally-promote", form) + + assert.Equal(t, http.StatusBadRequest, rec.Code) + assert.Nil(t, fake.externallyPromotedReq) + require.NotNil(t, fake.getTabletReq) +} + +func TestShardExternallyPromoteTabletLookupFails(t *testing.T) { + fake := newShardFake() + fake.getTabletError = vterrors.New(vtrpcpb.Code_NOT_FOUND, "no such tablet") + s := newShardTestServer(t, fake, false) + + form := url.Values{"alias": {"zone1-0000000100"}} + rec := postShardForm(t, s, shardActionPaths()[2], form) + + assert.Equal(t, http.StatusNotFound, rec.Code) + assert.Nil(t, fake.externallyPromotedReq) +} + +func TestShardExternallyPromoteInvalidAlias(t *testing.T) { + fake := newShardFake() + s := newShardTestServer(t, fake, false) + + form := url.Values{"alias": {"not-an-alias"}} + rec := postShardForm(t, s, shardActionPaths()[2], form) + + assert.Equal(t, http.StatusBadRequest, rec.Code) + assert.Nil(t, fake.externallyPromotedReq) +} + +func TestShardExternallyPromoteMissingAlias(t *testing.T) { + fake := newShardFake() + s := newShardTestServer(t, fake, false) + + rec := postShardForm(t, s, shardActionPaths()[2], url.Values{}) + + assert.Equal(t, http.StatusBadRequest, rec.Code) + assert.Nil(t, fake.externallyPromotedReq) +} + +func TestShardPlannedFailover(t *testing.T) { + fake := newShardFake() + s := newShardTestServer(t, fake, false) + + form := url.Values{ + "new_primary": {"zone1-0000000101"}, + "wait_replicas_timeout": {"15"}, + } + rec := postShardForm(t, s, shardActionPaths()[3], form) + + assert.Equal(t, http.StatusSeeOther, rec.Code) + require.NotNil(t, fake.plannedFailoverReq) + assert.Equal(t, testClusterID, fake.plannedFailoverReq.ClusterId) + require.NotNil(t, fake.plannedFailoverReq.Options) + assert.Equal(t, testKeyspace, fake.plannedFailoverReq.Options.Keyspace) + assert.Equal(t, testShard, fake.plannedFailoverReq.Options.Shard) + require.NotNil(t, fake.plannedFailoverReq.Options.NewPrimary) + assert.Equal(t, "zone1", fake.plannedFailoverReq.Options.NewPrimary.Cell) + assert.Equal(t, uint32(101), fake.plannedFailoverReq.Options.NewPrimary.Uid) + assert.Equal(t, int64(15), fake.plannedFailoverReq.Options.WaitReplicasTimeout.Seconds) +} + +func TestShardPlannedFailoverDefaults(t *testing.T) { + fake := newShardFake() + s := newShardTestServer(t, fake, false) + + // No new_primary and no timeout: vtctld picks the most up-to-date candidate. + rec := postShardForm(t, s, shardActionPaths()[3], url.Values{}) + + assert.Equal(t, http.StatusSeeOther, rec.Code) + require.NotNil(t, fake.plannedFailoverReq) + assert.Nil(t, fake.plannedFailoverReq.Options.NewPrimary) + assert.Nil(t, fake.plannedFailoverReq.Options.WaitReplicasTimeout) +} + +func TestShardPlannedFailoverInvalidAlias(t *testing.T) { + fake := newShardFake() + s := newShardTestServer(t, fake, false) + + form := url.Values{"new_primary": {"bogus"}} + rec := postShardForm(t, s, shardActionPaths()[3], form) + + assert.Equal(t, http.StatusBadRequest, rec.Code) + assert.Nil(t, fake.plannedFailoverReq) +} + +func TestShardPlannedFailoverInvalidTimeout(t *testing.T) { + fake := newShardFake() + s := newShardTestServer(t, fake, false) + + form := url.Values{"wait_replicas_timeout": {"not-a-number"}} + rec := postShardForm(t, s, shardActionPaths()[3], form) + + assert.Equal(t, http.StatusBadRequest, rec.Code) + assert.Nil(t, fake.plannedFailoverReq) +} + +func TestShardPlannedFailoverUnauthorizedNilResponse(t *testing.T) { + fake := newShardFake() + fake.plannedFailoverNil = true + s := newShardTestServer(t, fake, false) + + rec := postShardForm(t, s, shardActionPaths()[3], url.Values{}) + + assert.Equal(t, http.StatusBadRequest, rec.Code) + require.NotNil(t, fake.plannedFailoverReq) + assert.Contains(t, rec.Body.String(), "not authorized") +} + +func TestShardEmergencyFailover(t *testing.T) { + fake := newShardFake() + s := newShardTestServer(t, fake, false) + + form := url.Values{ + "new_primary": {"zone1-0000000101"}, + "prevent_cross_cell_promotion": {"on"}, + "wait_replicas_timeout": {"30"}, + } + rec := postShardForm(t, s, shardActionPaths()[4], form) + + assert.Equal(t, http.StatusSeeOther, rec.Code) + require.NotNil(t, fake.emergencyFailoverReq) + assert.Equal(t, testClusterID, fake.emergencyFailoverReq.ClusterId) + require.NotNil(t, fake.emergencyFailoverReq.Options) + assert.Equal(t, testKeyspace, fake.emergencyFailoverReq.Options.Keyspace) + assert.Equal(t, testShard, fake.emergencyFailoverReq.Options.Shard) + require.NotNil(t, fake.emergencyFailoverReq.Options.NewPrimary) + assert.Equal(t, uint32(101), fake.emergencyFailoverReq.Options.NewPrimary.Uid) + assert.True(t, fake.emergencyFailoverReq.Options.PreventCrossCellPromotion) + assert.Equal(t, int64(30), fake.emergencyFailoverReq.Options.WaitReplicasTimeout.Seconds) +} + +func TestShardEmergencyFailoverUnauthorizedNilResponse(t *testing.T) { + fake := newShardFake() + fake.emergencyFailoverNil = true + s := newShardTestServer(t, fake, false) + + rec := postShardForm(t, s, shardActionPaths()[4], url.Values{}) + + assert.Equal(t, http.StatusBadRequest, rec.Code) + require.NotNil(t, fake.emergencyFailoverReq) + assert.Contains(t, rec.Body.String(), "not authorized") +} + +func TestShardValidate(t *testing.T) { + fake := newShardFake() + s := newShardTestServer(t, fake, false) + + form := url.Values{"ping_tablets": {"on"}} + rec := postShardForm(t, s, shardActionPaths()[5], form) + + assert.Equal(t, http.StatusSeeOther, rec.Code) + require.NotNil(t, fake.validateShardReq) + assert.Equal(t, testClusterID, fake.validateShardReq.ClusterId) + assert.Equal(t, testKeyspace, fake.validateShardReq.Keyspace) + assert.Equal(t, testShard, fake.validateShardReq.Shard) + assert.True(t, fake.validateShardReq.PingTablets) +} + +func TestShardValidateVersion(t *testing.T) { + fake := newShardFake() + s := newShardTestServer(t, fake, false) + + rec := postShardForm(t, s, shardActionPaths()[6], url.Values{}) + + assert.Equal(t, http.StatusSeeOther, rec.Code) + require.NotNil(t, fake.validateVersionShardRe) + assert.Equal(t, testClusterID, fake.validateVersionShardRe.ClusterId) + assert.Equal(t, testKeyspace, fake.validateVersionShardRe.Keyspace) + assert.Equal(t, testShard, fake.validateVersionShardRe.Shard) +} + +func TestShardValidateUnauthorizedNilResponse(t *testing.T) { + fake := newShardFake() + fake.validateShardNil = true + s := newShardTestServer(t, fake, false) + + rec := postShardForm(t, s, shardActionPaths()[5], url.Values{}) + + assert.Equal(t, http.StatusBadRequest, rec.Code) + require.NotNil(t, fake.validateShardReq) + assert.Contains(t, rec.Body.String(), "not authorized") +} + +func TestShardValidateVersionUnauthorizedNilResponse(t *testing.T) { + fake := newShardFake() + fake.validateVersionNil = true + s := newShardTestServer(t, fake, false) + + rec := postShardForm(t, s, shardActionPaths()[6], url.Values{}) + + assert.Equal(t, http.StatusBadRequest, rec.Code) + require.NotNil(t, fake.validateVersionShardRe) + assert.Contains(t, rec.Body.String(), "not authorized") +} + +func TestKeyspaceDetailLinksToShardDetail(t *testing.T) { + fake := newShardFake() + s := newShardTestServer(t, fake, false) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/keyspace/"+testClusterID+"/"+testKeyspace, nil) + s.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + assert.Contains(t, rec.Body.String(), "/keyspace/"+testClusterID+"/"+testKeyspace+"/shard/"+testShard) +} + +func TestShardDetailCSRFTokenRoundTrip(t *testing.T) { + fake := newShardFake() + s := newShardTestServer(t, fake, false) + + token, _ := renderWithCSRF(t, s, shardDetailPath(testClusterID, testKeyspace, testShard)) + + // A POST using the exact rendered token/cookie pairing must get past + // CSRF validation. + rec := postFormWithCSRF(s, shardActionPaths()[5], token, url.Values{ + "csrf_token": {token}, + "ping_tablets": {"on"}, + }) + assert.Equal(t, http.StatusSeeOther, rec.Code) + require.NotNil(t, fake.validateShardReq) +} diff --git a/go/vt/vtadmin/vtadmin2/static/vitess-stacked.png b/go/vt/vtadmin/vtadmin2/static/vitess-stacked.png new file mode 100644 index 00000000000..b207bdf4f08 Binary files /dev/null and b/go/vt/vtadmin/vtadmin2/static/vitess-stacked.png differ diff --git a/go/vt/vtadmin/vtadmin2/static/vtadmin2.css b/go/vt/vtadmin/vtadmin2/static/vtadmin2.css new file mode 100644 index 00000000000..3617becc2aa --- /dev/null +++ b/go/vt/vtadmin/vtadmin2/static/vtadmin2.css @@ -0,0 +1,340 @@ +:root { + --vt-color-primary: #f16626; + --vt-color-primary-dark: #d64d0e; + --vt-color-primary-light: #fef1ec; + --vt-color-accent: #00a3a6; + --vt-color-bg: #f7f8fc; + --vt-color-surface: #ffffff; + --vt-color-text: #172033; + --vt-color-muted: #64748b; + --vt-color-border: #d9deea; + --vt-color-success: #15803d; + --vt-color-warning: #b45309; + --vt-color-danger: #b91c1c; +} + +* { box-sizing: border-box; } + +body { + margin: 0; + background: var(--vt-color-bg); + color: var(--vt-color-text); + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; +} + +a { color: var(--vt-color-primary); } + +.topbar { + align-items: center; + background: var(--vt-color-primary-dark); + color: white; + display: flex; + gap: 1rem; + min-height: 3.5rem; + padding: 0 1.5rem; +} + +.brand { + align-items: center; + color: white; + display: flex; + font-size: 1.1rem; + font-weight: 700; + gap: 0.65rem; + text-decoration: none; +} + +.brand-logo { + display: block; + height: 2.4rem; + width: auto; +} + +.badge { + background: var(--vt-color-warning); + border-radius: 999px; + color: white; + font-size: 0.75rem; + padding: 0.2rem 0.55rem; +} + +.badge-primary { + background: var(--vt-color-accent); + font-weight: 600; +} + +.shell { + display: grid; + grid-template-columns: 14rem 1fr; + min-height: calc(100vh - 3.5rem); +} + +.nav { + background: var(--vt-color-surface); + border-right: 1px solid var(--vt-color-border); + padding: 1rem; +} + +.nav-section { + color: var(--vt-color-muted); + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.06em; + margin: 1rem 0 0.35rem; + text-transform: uppercase; +} + +.nav a { + border-radius: 0.4rem; + color: var(--vt-color-text); + display: block; + padding: 0.65rem 0.75rem; + text-decoration: none; +} + +.nav a.active, +.nav a:hover { + background: var(--vt-color-primary-light); + color: var(--vt-color-primary-dark); +} + +.content { padding: 1.5rem; } + +.card { + background: var(--vt-color-surface); + border: 1px solid var(--vt-color-border); + border-radius: 0.65rem; + margin-bottom: 1rem; + padding: 1rem; +} + +dl { + display: grid; + gap: 0.45rem 1.5rem; + grid-template-columns: max-content 1fr; + margin: 0; +} + +dt { + color: var(--vt-color-muted); + font-size: 0.78rem; + font-weight: 600; + letter-spacing: 0.04em; + padding-top: 0.18rem; + text-transform: uppercase; +} + +dd { + margin: 0; + overflow-wrap: anywhere; +} + +.toolbar { + align-items: center; + display: flex; + gap: 0.75rem; + justify-content: space-between; + margin-bottom: 1rem; +} + +.table-filter { + border: 1px solid var(--vt-color-border); + border-radius: 0.4rem; + padding: 0.5rem; + width: min(24rem, 100%); +} + +table { + border-collapse: collapse; + width: 100%; +} + +th, td { + border-bottom: 1px solid var(--vt-color-border); + padding: 0.7rem; + text-align: left; + vertical-align: top; +} + +th { + color: var(--vt-color-muted); + font-size: 0.8rem; + text-transform: uppercase; +} + +.button { + background: var(--vt-color-primary); + border: 0; + border-radius: 0.4rem; + color: white; + cursor: pointer; + display: inline-block; + font: inherit; + padding: 0.55rem 0.8rem; + text-decoration: none; +} + +.button.secondary { + background: var(--vt-color-accent); +} + +.button.danger { + background: var(--vt-color-danger); +} + +.flash { + border-radius: 0.5rem; + margin-bottom: 1rem; + padding: 0.75rem 1rem; +} + +.flash.success { + background: #dcfce7; + color: var(--vt-color-success); +} + +.flash.error { + background: #fee2e2; + color: var(--vt-color-danger); +} + +.form-grid { + display: grid; + gap: 0.9rem; + max-width: 42rem; +} + +.form-fieldset { + border: 1px solid var(--vt-color-border); + border-radius: 0.4rem; + display: grid; + gap: 0.5rem; + padding: 0.75rem; +} + +.form-fieldset legend { + color: var(--vt-color-muted); + font-size: 0.8rem; + font-weight: 600; + padding: 0 0.35rem; + text-transform: uppercase; +} + +label { + display: grid; + font-weight: 600; + gap: 0.35rem; +} + +input[type="text"], input[type="number"], select { + border: 1px solid var(--vt-color-border); + border-radius: 0.4rem; + font: inherit; + padding: 0.55rem; +} + +.danger-zone { + border-color: #f2c4c4; +} + +.danger-zone h2 { + color: var(--vt-color-danger); +} + +.actions-grid { + display: grid; + gap: 0.75rem; +} + +.actions-grid details { + border: 1px solid var(--vt-color-border); + border-radius: 0.4rem; +} + +.actions-grid summary { + cursor: pointer; + font-weight: 600; + padding: 0.65rem 0.8rem; +} + +.actions-grid form { + padding: 0 0.8rem 0.8rem; +} + +.actions-grid .button:active, +.button:active { + transform: translateY(1px); +} + +.checkbox-label { + align-items: center; + display: flex; + font-weight: 400; + gap: 0.5rem; + grid-template-columns: none; +} + +.muted { + color: var(--vt-color-muted); + font-size: 0.85rem; + margin: 0.25rem 0 0; +} + +details > summary { + list-style: revert; +} + +details pre { + max-height: 16rem; + overflow: auto; +} + +.toolbar-links { + display: flex; + gap: 1rem; +} + +.inline-form { + display: inline-block; + margin: 0; +} + +@media (max-width: 800px) { + .shell { grid-template-columns: 1fr; } + .nav { border-right: 0; border-bottom: 1px solid var(--vt-color-border); } +} +.settings-link { + color: white; + font-size: 0.85rem; + margin-left: auto; + text-decoration: none; +} + +/* Dark theme: token overrides applied via . */ +@media (prefers-color-scheme: dark) { + body[data-theme="system"] { + --vt-color-bg: #10151f; + --vt-color-surface: #1a2230; + --vt-color-text: #e6eaf2; + --vt-color-muted: #94a0b8; + --vt-color-border: #2c3648; + --vt-color-primary-light: #3a2417; + } +} + +body[data-theme="dark"] { + --vt-color-bg: #10151f; + --vt-color-surface: #1a2230; + --vt-color-text: #e6eaf2; + --vt-color-muted: #94a0b8; + --vt-color-border: #2c3648; + --vt-color-primary-light: #3a2417; +} + +body[data-theme="dark"] .flash.success { + background: #12351f; +} + +body[data-theme="dark"] .flash.error { + background: #3b1414; +} diff --git a/go/vt/vtadmin/vtadmin2/static/vtadmin2.js b/go/vt/vtadmin/vtadmin2/static/vtadmin2.js new file mode 100644 index 00000000000..a2b1c4a65a2 --- /dev/null +++ b/go/vt/vtadmin/vtadmin2/static/vtadmin2.js @@ -0,0 +1,60 @@ +document.addEventListener('input', (event) => { + if (!event.target.matches('[data-table-filter]')) { + return; + } + const table = document.querySelector(event.target.dataset.tableFilter); + if (!table) { + return; + } + const needle = event.target.value.toLowerCase(); + table.querySelectorAll('tbody tr').forEach((row) => { + row.hidden = !row.textContent.toLowerCase().includes(needle); + }); +}); + +document.addEventListener('submit', (event) => { + // Confirmation may be declared on the form or on the specific submit + // button that triggered the submission. + const submitter = event.submitter; + const confirmSource = [event.target, submitter].find( + (el) => el instanceof Element && el.hasAttribute('data-confirm') + ); + if (confirmSource && !window.confirm(confirmSource.getAttribute('data-confirm'))) { + event.preventDefault(); + return; + } + if (submitter) { + submitter.disabled = true; + } +}); + +// Table sorting: click a table header to sort rows by that column. +document.addEventListener('click', (event) => { + const th = event.target.closest('th'); + if (!th || !th.closest('table')) { + return; + } + const table = th.closest('table'); + const tbody = table.querySelector('tbody'); + if (!tbody) { + return; + } + const columnIndex = Array.from(th.parentNode.children).indexOf(th); + const rows = Array.from(tbody.querySelectorAll('tr')); + const alreadySorted = th.getAttribute('data-sort-dir') === 'asc'; + + rows.sort((a, b) => { + const aText = (a.children[columnIndex]?.textContent || '').trim(); + const bText = (b.children[columnIndex]?.textContent || '').trim(); + const aNum = parseFloat(aText); + const bNum = parseFloat(bText); + if (!isNaN(aNum) && !isNaN(bNum)) { + return alreadySorted ? bNum - aNum : aNum - bNum; + } + return alreadySorted ? bText.localeCompare(aText) : aText.localeCompare(bText); + }); + + table.querySelectorAll('th').forEach((h) => h.removeAttribute('data-sort-dir')); + th.setAttribute('data-sort-dir', alreadySorted ? 'desc' : 'asc'); + rows.forEach((row) => tbody.appendChild(row)); +}); diff --git a/go/vt/vtadmin/vtadmin2/stream.go b/go/vt/vtadmin/vtadmin2/stream.go new file mode 100644 index 00000000000..9ae5feb83a0 --- /dev/null +++ b/go/vt/vtadmin/vtadmin2/stream.go @@ -0,0 +1,109 @@ +/* +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" +) + +type ( + streamData struct { + ClusterID string + Keyspace string + Workflow string + StreamKey string + Stream *vtctldatapb.Workflow_Stream + } +) + +func (s *Server) streamDetail(w http.ResponseWriter, r *http.Request) { + clusterID := r.PathValue("cluster_id") + keyspace := r.PathValue("keyspace") + workflow := r.PathValue("name") + + streamID, err := strconv.ParseInt(r.PathValue("stream_id"), 10, 64) + if err != nil { + s.renderError(w, r, http.StatusBadRequest, "Stream", + vterrors.Errorf(vtrpcpb.Code_INVALID_ARGUMENT, "invalid stream ID %q", r.PathValue("stream_id"))) + return + } + + tabletUID, err := strconv.ParseUint(r.PathValue("tablet_uid"), 10, 32) + if err != nil { + s.renderError(w, r, http.StatusBadRequest, "Stream", + vterrors.Errorf(vtrpcpb.Code_INVALID_ARGUMENT, "invalid tablet UID %q", r.PathValue("tablet_uid"))) + return + } + tabletCell := r.PathValue("tablet_cell") + + wf, err := s.api.GetWorkflow(r.Context(), &vtadminpb.GetWorkflowRequest{ + ClusterId: clusterID, + Keyspace: keyspace, + Name: workflow, + }) + if err != nil { + s.renderError(w, r, tabletErrorStatus(err), "Stream", err) + return + } + + streamKey := formatStreamKey(tabletCell, uint32(tabletUID), streamID) + stream := findStream(wf.GetWorkflow(), tabletCell, uint32(tabletUID), streamID) + if stream == nil { + s.renderError(w, r, http.StatusNotFound, "Stream", + vterrors.Errorf(vtrpcpb.Code_NOT_FOUND, "no stream %s found in workflow %s", streamKey, workflow)) + return + } + + s.render(w, r, http.StatusOK, "stream.html", PageData{ + Title: streamKey, + Active: "workflows", + Data: streamData{ + ClusterID: clusterID, + Keyspace: keyspace, + Workflow: workflow, + StreamKey: streamKey, + Stream: stream, + }, + }) +} + +// findStream locates the stream matching the tablet cell/UID and stream ID +// within a workflow's shard streams. +func findStream(wf *vtctldatapb.Workflow, tabletCell string, tabletUID uint32, streamID int64) *vtctldatapb.Workflow_Stream { + if wf == nil { + return nil + } + for _, shardStream := range wf.GetShardStreams() { + for _, stream := range shardStream.GetStreams() { + t := stream.GetTablet() + if t.GetCell() == tabletCell && t.GetUid() == tabletUID && stream.GetId() == streamID { + return stream + } + } + } + return nil +} + +func formatStreamKey(tabletCell string, tabletUID uint32, streamID int64) string { + return tabletCell + "-" + strconv.FormatUint(uint64(tabletUID), 10) + ":" + strconv.FormatInt(streamID, 10) +} diff --git a/go/vt/vtadmin/vtadmin2/stream_test.go b/go/vt/vtadmin/vtadmin2/stream_test.go new file mode 100644 index 00000000000..345f519b938 --- /dev/null +++ b/go/vt/vtadmin/vtadmin2/stream_test.go @@ -0,0 +1,114 @@ +/* +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" + vtctldatapb "vitess.io/vitess/go/vt/proto/vtctldata" +) + +const streamDetailPath = "/workflow/local/sales/users_to_sales/stream/zone1/100/1" + +type streamFakeServer struct { + fakeVTAdminServer +} + +func (f *streamFakeServer) GetWorkflow(ctx context.Context, req *vtadminpb.GetWorkflowRequest) (*vtadminpb.Workflow, error) { + return &vtadminpb.Workflow{ + Cluster: &vtadminpb.Cluster{Id: req.ClusterId}, + Keyspace: req.Keyspace, + Workflow: &vtctldatapb.Workflow{ + Name: req.Name, + WorkflowType: "MoveTables", + ShardStreams: map[string]*vtctldatapb.Workflow_ShardStream{ + "0": { + Streams: []*vtctldatapb.Workflow_Stream{ + { + Id: 1, + Shard: "0", + Tablet: &topodatapb.TabletAlias{Cell: "zone1", Uid: 100}, + State: "Running", + }, + }, + }, + }, + }, + }, nil +} + +func TestStreamDetailRendersStream(t *testing.T) { + fake := &streamFakeServer{} + s, err := NewServer(fake, Options{}) + require.NoError(t, err) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, streamDetailPath, nil) + s.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + body := rec.Body.String() + + assert.Contains(t, body, "zone1-100:1") + assert.Contains(t, body, "Running") + assert.Contains(t, body, "users_to_sales") +} + +func TestStreamDetailNotFound(t *testing.T) { + fake := &streamFakeServer{} + s, err := NewServer(fake, Options{}) + require.NoError(t, err) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/workflow/local/sales/users_to_sales/stream/zone1/999/42", nil) + s.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusNotFound, rec.Code) +} + +func TestStreamDetailInvalidStreamID(t *testing.T) { + fake := &streamFakeServer{} + s, err := NewServer(fake, Options{}) + require.NoError(t, err) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/workflow/local/sales/users_to_sales/stream/zone1/100/not-a-number", nil) + s.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusBadRequest, rec.Code) +} + +func TestWorkflowDetailLinksToStreams(t *testing.T) { + fake := &streamFakeServer{} + s, err := NewServer(fake, Options{}) + require.NoError(t, err) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/workflow/local/sales/users_to_sales", nil) + s.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + assert.Contains(t, rec.Body.String(), "/workflow/local/sales/users_to_sales/stream/zone1/100/1") +} diff --git a/go/vt/vtadmin/vtadmin2/tablet.go b/go/vt/vtadmin/vtadmin2/tablet.go new file mode 100644 index 00000000000..d2a2f8def6f --- /dev/null +++ b/go/vt/vtadmin/vtadmin2/tablet.go @@ -0,0 +1,125 @@ +/* +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" + + "vitess.io/vitess/go/vt/topo/topoproto" + + topodatapb "vitess.io/vitess/go/vt/proto/topodata" + 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 ( + tabletDetailData struct { + ClusterID string + Alias string + Tablet *vtadminpb.Tablet + } + + tabletFullStatusData struct { + ClusterID string + Alias string + Response *vtctldatapb.GetFullStatusResponse + } +) + +func parseRouteTabletAlias(alias string) (*topodatapb.TabletAlias, error) { + parsed, err := topoproto.ParseTabletAlias(alias) + if err != nil { + return nil, vterrors.Wrapf(err, "failed to parse tablet alias %s", alias) + } + return parsed, nil +} + +func tabletErrorStatus(err error) int { + switch vterrors.Code(err) { + case vtrpcpb.Code_NOT_FOUND: + return http.StatusNotFound + case vtrpcpb.Code_INVALID_ARGUMENT: + return http.StatusBadRequest + default: + return http.StatusInternalServerError + } +} + +func (s *Server) tabletDetail(w http.ResponseWriter, r *http.Request) { + clusterID := r.PathValue("cluster_id") + alias := r.PathValue("alias") + parsedAlias, err := parseRouteTabletAlias(alias) + if err != nil { + s.renderError(w, r, http.StatusBadRequest, "Tablet", err) + return + } + + tablet, err := s.api.GetTablet(r.Context(), &vtadminpb.GetTabletRequest{ + Alias: parsedAlias, + ClusterIds: []string{clusterID}, + }) + if err != nil { + s.renderError(w, r, tabletErrorStatus(err), "Tablet", err) + return + } + + s.render(w, r, http.StatusOK, "tablet.html", PageData{ + Title: alias, + Active: "tablets", + NeedsCSRF: !s.opts.ReadOnly, + Data: tabletDetailData{ + ClusterID: clusterID, + Alias: alias, + Tablet: tablet, + }, + }) +} + +func (s *Server) tabletFullStatus(w http.ResponseWriter, r *http.Request) { + clusterID := r.PathValue("cluster_id") + alias := r.PathValue("alias") + parsedAlias, err := parseRouteTabletAlias(alias) + if err != nil { + s.renderError(w, r, http.StatusBadRequest, "Tablet", err) + return + } + + resp, err := s.api.GetFullStatus(r.Context(), &vtadminpb.GetFullStatusRequest{ + ClusterId: clusterID, + Alias: parsedAlias, + }) + if err != nil { + s.renderError(w, r, tabletErrorStatus(err), "Full Status", err) + return + } + if resp == nil { + http.NotFound(w, r) + return + } + + s.render(w, r, http.StatusOK, "tablet_full_status.html", PageData{ + Title: "Full Status", + Active: "tablets", + Data: tabletFullStatusData{ + ClusterID: clusterID, + Alias: alias, + Response: resp, + }, + }) +} diff --git a/go/vt/vtadmin/vtadmin2/tablet_actions.go b/go/vt/vtadmin/vtadmin2/tablet_actions.go new file mode 100644 index 00000000000..cbc66793558 --- /dev/null +++ b/go/vt/vtadmin/vtadmin2/tablet_actions.go @@ -0,0 +1,260 @@ +/* +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" + + topodatapb "vitess.io/vitess/go/vt/proto/topodata" + vtadminpb "vitess.io/vitess/go/vt/proto/vtadmin" +) + +// beginTabletAction is the shared preflight for tablet mutation handlers. It +// validates the CSRF token, parses the route alias, and returns the parsed +// values; ok is false when the preflight has already rendered an error +// response. +func (s *Server) beginTabletAction(w http.ResponseWriter, r *http.Request, title string) (alias *topodatapb.TabletAlias, clusterID string, ok bool) { + if !s.beginFormAction(w, r, title) { + return nil, "", false + } + + parsed, err := parseRouteTabletAlias(r.PathValue("alias")) + if err != nil { + s.renderFormErrorErr(w, r, title, err) + return nil, "", false + } + return parsed, r.PathValue("cluster_id"), true +} + +func tabletDetailRedirect(clusterID, alias string) string { + return "/tablet/" + pathEscape(clusterID) + "/" + pathEscape(alias) +} + +func (s *Server) tabletStartReplication(w http.ResponseWriter, r *http.Request) { + const title = "Start replication" + alias, clusterID, ok := s.beginTabletAction(w, r, title) + if !ok { + return + } + + _, err := s.api.StartReplication(r.Context(), &vtadminpb.StartReplicationRequest{ + Alias: alias, + ClusterIds: []string{clusterID}, + }) + if err != nil { + s.renderFormErrorErr(w, r, title, err) + return + } + + s.redirectWithFlash(w, r, tabletDetailRedirect(clusterID, r.PathValue("alias")), Flash{ + Kind: "success", + Message: "replication started on tablet " + r.PathValue("alias"), + }) +} + +func (s *Server) tabletStopReplication(w http.ResponseWriter, r *http.Request) { + const title = "Stop replication" + alias, clusterID, ok := s.beginTabletAction(w, r, title) + if !ok { + return + } + + _, err := s.api.StopReplication(r.Context(), &vtadminpb.StopReplicationRequest{ + Alias: alias, + ClusterIds: []string{clusterID}, + }) + if err != nil { + s.renderFormErrorErr(w, r, title, err) + return + } + + s.redirectWithFlash(w, r, tabletDetailRedirect(clusterID, r.PathValue("alias")), Flash{ + Kind: "success", + Message: "replication stopped on tablet " + r.PathValue("alias"), + }) +} + +func (s *Server) tabletRefreshReplicationSource(w http.ResponseWriter, r *http.Request) { + const title = "Refresh replication source" + alias, clusterID, ok := s.beginTabletAction(w, r, title) + if !ok { + return + } + + resp, err := s.api.RefreshTabletReplicationSource(r.Context(), &vtadminpb.RefreshTabletReplicationSourceRequest{ + Alias: alias, + ClusterIds: []string{clusterID}, + }) + if err != nil { + s.renderFormErrorErr(w, r, title, err) + return + } + + message := "replication source refreshed on tablet " + r.PathValue("alias") + if keyspace := resp.GetKeyspace(); keyspace != "" { + message += " (now replicating from " + keyspace + "/" + resp.GetShard() + ")" + } + + s.redirectWithFlash(w, r, tabletDetailRedirect(clusterID, r.PathValue("alias")), Flash{ + Kind: "success", + Message: message, + }) +} + +func (s *Server) tabletSetReadOnly(w http.ResponseWriter, r *http.Request) { + const title = "Set read-only" + alias, clusterID, ok := s.beginTabletAction(w, r, title) + if !ok { + return + } + + _, err := s.api.SetReadOnly(r.Context(), &vtadminpb.SetReadOnlyRequest{ + Alias: alias, + ClusterIds: []string{clusterID}, + }) + if err != nil { + s.renderFormErrorErr(w, r, title, err) + return + } + + s.redirectWithFlash(w, r, tabletDetailRedirect(clusterID, r.PathValue("alias")), Flash{ + Kind: "success", + Message: "tablet " + r.PathValue("alias") + " set to read-only", + }) +} + +func (s *Server) tabletSetReadWrite(w http.ResponseWriter, r *http.Request) { + const title = "Set read-write" + alias, clusterID, ok := s.beginTabletAction(w, r, title) + if !ok { + return + } + + _, err := s.api.SetReadWrite(r.Context(), &vtadminpb.SetReadWriteRequest{ + Alias: alias, + ClusterIds: []string{clusterID}, + }) + if err != nil { + s.renderFormErrorErr(w, r, title, err) + return + } + + s.redirectWithFlash(w, r, tabletDetailRedirect(clusterID, r.PathValue("alias")), Flash{ + Kind: "success", + Message: "tablet " + r.PathValue("alias") + " set to read-write", + }) +} + +func (s *Server) tabletDelete(w http.ResponseWriter, r *http.Request) { + const title = "Delete tablet" + alias, clusterID, ok := s.beginTabletAction(w, r, title) + if !ok { + return + } + + _, err := s.api.DeleteTablet(r.Context(), &vtadminpb.DeleteTabletRequest{ + Alias: alias, + ClusterIds: []string{clusterID}, + AllowPrimary: r.Form.Get("allow_primary") == "on", + }) + if err != nil { + s.renderFormErrorErr(w, r, title, err) + return + } + + // The tablet no longer exists after deletion, so redirect to the list. + s.redirectWithFlash(w, r, "/tablets", Flash{ + Kind: "success", + Message: "deleted tablet " + r.PathValue("alias"), + }) +} + +func (s *Server) tabletPing(w http.ResponseWriter, r *http.Request) { + const title = "Ping tablet" + alias, clusterID, ok := s.beginTabletAction(w, r, title) + if !ok { + return + } + + _, err := s.api.PingTablet(r.Context(), &vtadminpb.PingTabletRequest{ + Alias: alias, + ClusterIds: []string{clusterID}, + }) + if err != nil { + s.renderFormErrorErr(w, r, title, err) + return + } + + s.redirectWithFlash(w, r, tabletDetailRedirect(clusterID, r.PathValue("alias")), Flash{ + Kind: "success", + Message: "tablet " + r.PathValue("alias") + " responded to ping", + }) +} + +func (s *Server) tabletRefreshState(w http.ResponseWriter, r *http.Request) { + const title = "Refresh tablet state" + alias, clusterID, ok := s.beginTabletAction(w, r, title) + if !ok { + return + } + + resp, err := s.api.RefreshState(r.Context(), &vtadminpb.RefreshStateRequest{ + Alias: alias, + ClusterIds: []string{clusterID}, + }) + if err != nil { + s.renderFormErrorErr(w, r, title, err) + return + } + + message := "tablet state refreshed" + if status := resp.GetStatus(); status != "" { + message += ": " + status + } + + s.redirectWithFlash(w, r, tabletDetailRedirect(clusterID, r.PathValue("alias")), Flash{ + Kind: "success", + Message: message, + }) +} + +func (s *Server) tabletRunHealthCheck(w http.ResponseWriter, r *http.Request) { + const title = "Run health check" + alias, clusterID, ok := s.beginTabletAction(w, r, title) + if !ok { + return + } + + resp, err := s.api.RunHealthCheck(r.Context(), &vtadminpb.RunHealthCheckRequest{ + Alias: alias, + ClusterIds: []string{clusterID}, + }) + if err != nil { + s.renderFormErrorErr(w, r, title, err) + return + } + + message := "health check complete" + if status := resp.GetStatus(); status != "" { + message += ": " + status + } + + s.redirectWithFlash(w, r, tabletDetailRedirect(clusterID, r.PathValue("alias")), Flash{ + Kind: "success", + Message: message, + }) +} diff --git a/go/vt/vtadmin/vtadmin2/tablet_actions_test.go b/go/vt/vtadmin/vtadmin2/tablet_actions_test.go new file mode 100644 index 00000000000..d8f3f657af6 --- /dev/null +++ b/go/vt/vtadmin/vtadmin2/tablet_actions_test.go @@ -0,0 +1,298 @@ +/* +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" + + topodatapb "vitess.io/vitess/go/vt/proto/topodata" + vtadminpb "vitess.io/vitess/go/vt/proto/vtadmin" +) + +type tabletActionsFakeServer struct { + fakeVTAdminServer + + startReplicationReq *vtadminpb.StartReplicationRequest + stopReplicationReq *vtadminpb.StopReplicationRequest + refreshReplicationSourceReq *vtadminpb.RefreshTabletReplicationSourceRequest + setReadOnlyReq *vtadminpb.SetReadOnlyRequest + setReadWriteReq *vtadminpb.SetReadWriteRequest + deleteTabletReq *vtadminpb.DeleteTabletRequest + pingTabletReq *vtadminpb.PingTabletRequest + refreshStateReq *vtadminpb.RefreshStateRequest + runHealthCheckReq *vtadminpb.RunHealthCheckRequest +} + +func (f *tabletActionsFakeServer) GetTablet(ctx context.Context, req *vtadminpb.GetTabletRequest) (*vtadminpb.Tablet, error) { + return &vtadminpb.Tablet{ + Cluster: &vtadminpb.Cluster{Id: testClusterID}, + Tablet: &topodatapb.Tablet{ + Alias: &topodatapb.TabletAlias{Cell: "zone1", Uid: 100}, + Hostname: "tablet-100", + Keyspace: "commerce", + Shard: "0", + Type: topodatapb.TabletType_REPLICA, + }, + State: vtadminpb.Tablet_SERVING, + }, nil +} + +func (f *tabletActionsFakeServer) StartReplication(ctx context.Context, req *vtadminpb.StartReplicationRequest) (*vtadminpb.StartReplicationResponse, error) { + f.startReplicationReq = req + return &vtadminpb.StartReplicationResponse{}, nil +} + +func (f *tabletActionsFakeServer) StopReplication(ctx context.Context, req *vtadminpb.StopReplicationRequest) (*vtadminpb.StopReplicationResponse, error) { + f.stopReplicationReq = req + return &vtadminpb.StopReplicationResponse{}, nil +} + +func (f *tabletActionsFakeServer) RefreshTabletReplicationSource(ctx context.Context, req *vtadminpb.RefreshTabletReplicationSourceRequest) (*vtadminpb.RefreshTabletReplicationSourceResponse, error) { + f.refreshReplicationSourceReq = req + return &vtadminpb.RefreshTabletReplicationSourceResponse{}, nil +} + +func (f *tabletActionsFakeServer) SetReadOnly(ctx context.Context, req *vtadminpb.SetReadOnlyRequest) (*vtadminpb.SetReadOnlyResponse, error) { + f.setReadOnlyReq = req + return &vtadminpb.SetReadOnlyResponse{}, nil +} + +func (f *tabletActionsFakeServer) SetReadWrite(ctx context.Context, req *vtadminpb.SetReadWriteRequest) (*vtadminpb.SetReadWriteResponse, error) { + f.setReadWriteReq = req + return &vtadminpb.SetReadWriteResponse{}, nil +} + +func (f *tabletActionsFakeServer) DeleteTablet(ctx context.Context, req *vtadminpb.DeleteTabletRequest) (*vtadminpb.DeleteTabletResponse, error) { + f.deleteTabletReq = req + return &vtadminpb.DeleteTabletResponse{}, nil +} + +func (f *tabletActionsFakeServer) PingTablet(ctx context.Context, req *vtadminpb.PingTabletRequest) (*vtadminpb.PingTabletResponse, error) { + f.pingTabletReq = req + return &vtadminpb.PingTabletResponse{}, nil +} + +func (f *tabletActionsFakeServer) RefreshState(ctx context.Context, req *vtadminpb.RefreshStateRequest) (*vtadminpb.RefreshStateResponse, error) { + f.refreshStateReq = req + return &vtadminpb.RefreshStateResponse{Status: "serving"}, nil +} + +func (f *tabletActionsFakeServer) RunHealthCheck(ctx context.Context, req *vtadminpb.RunHealthCheckRequest) (*vtadminpb.RunHealthCheckResponse, error) { + f.runHealthCheckReq = req + return &vtadminpb.RunHealthCheckResponse{Status: "healthy"}, nil +} + +func newTabletActionsTestServer(t *testing.T, fake *tabletActionsFakeServer, readOnly bool) *Server { + t.Helper() + s, err := NewServer(fake, Options{ReadOnly: readOnly}) + require.NoError(t, err) + return s +} + +const tabletActionBase = "/tablet/local/zone1-0000000100" + +func postTabletAction(t *testing.T, s *Server, path string, form url.Values) *httptest.ResponseRecorder { + t.Helper() + return postShardForm(t, s, path, form) +} + +func TestTabletActionsCallAPIWithAlias(t *testing.T) { + tests := []struct { + action string + verify func(t *testing.T, fake *tabletActionsFakeServer) + formFunc func(f url.Values) + }{ + { + action: "/start_replication", + verify: func(t *testing.T, fake *tabletActionsFakeServer) { + require.NotNil(t, fake.startReplicationReq) + assert.Equal(t, "zone1", fake.startReplicationReq.Alias.Cell) + assert.Equal(t, uint32(100), fake.startReplicationReq.Alias.Uid) + assert.Equal(t, []string{testClusterID}, fake.startReplicationReq.ClusterIds) + }, + }, + { + action: "/stop_replication", + verify: func(t *testing.T, fake *tabletActionsFakeServer) { + require.NotNil(t, fake.stopReplicationReq) + assert.Equal(t, uint32(100), fake.stopReplicationReq.Alias.Uid) + }, + }, + { + action: "/refresh_replication_source", + verify: func(t *testing.T, fake *tabletActionsFakeServer) { + require.NotNil(t, fake.refreshReplicationSourceReq) + assert.Equal(t, uint32(100), fake.refreshReplicationSourceReq.Alias.Uid) + }, + }, + { + action: "/set_read_only", + verify: func(t *testing.T, fake *tabletActionsFakeServer) { + require.NotNil(t, fake.setReadOnlyReq) + assert.Equal(t, uint32(100), fake.setReadOnlyReq.Alias.Uid) + }, + }, + { + action: "/set_read_write", + verify: func(t *testing.T, fake *tabletActionsFakeServer) { + require.NotNil(t, fake.setReadWriteReq) + assert.Equal(t, uint32(100), fake.setReadWriteReq.Alias.Uid) + }, + }, + { + action: "/ping", + verify: func(t *testing.T, fake *tabletActionsFakeServer) { + require.NotNil(t, fake.pingTabletReq) + assert.Equal(t, uint32(100), fake.pingTabletReq.Alias.Uid) + }, + }, + { + action: "/refresh_state", + verify: func(t *testing.T, fake *tabletActionsFakeServer) { + require.NotNil(t, fake.refreshStateReq) + assert.Equal(t, uint32(100), fake.refreshStateReq.Alias.Uid) + }, + }, + { + action: "/health_check", + verify: func(t *testing.T, fake *tabletActionsFakeServer) { + require.NotNil(t, fake.runHealthCheckReq) + assert.Equal(t, uint32(100), fake.runHealthCheckReq.Alias.Uid) + }, + }, + { + action: "/delete", + formFunc: func(f url.Values) { + f.Set("allow_primary", "on") + }, + verify: func(t *testing.T, fake *tabletActionsFakeServer) { + require.NotNil(t, fake.deleteTabletReq) + assert.Equal(t, uint32(100), fake.deleteTabletReq.Alias.Uid) + assert.True(t, fake.deleteTabletReq.AllowPrimary) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.action, func(t *testing.T) { + fake := &tabletActionsFakeServer{} + s := newTabletActionsTestServer(t, fake, false) + + form := url.Values{} + if tt.formFunc != nil { + tt.formFunc(form) + } + rec := postTabletAction(t, s, tabletActionBase+tt.action, form) + + assert.Equal(t, http.StatusSeeOther, rec.Code) + tt.verify(t, fake) + }) + } +} + +func TestTabletDeleteRedirectsToTabletsList(t *testing.T) { + fake := &tabletActionsFakeServer{} + s := newTabletActionsTestServer(t, fake, false) + + rec := postTabletAction(t, s, tabletActionBase+"/delete", url.Values{}) + + assert.Equal(t, http.StatusSeeOther, rec.Code) + assert.Equal(t, "/tablets", rec.Header().Get("Location")) +} + +func TestTabletActionFlashCarriesResult(t *testing.T) { + fake := &tabletActionsFakeServer{} + s := newTabletActionsTestServer(t, fake, false) + + rec := postTabletAction(t, s, tabletActionBase+"/refresh_state", url.Values{}) + assert.Equal(t, http.StatusSeeOther, rec.Code) + + cookie := findCookie(rec, flashCookieName) + require.NotNil(t, cookie) + flash := decodeFlash(cookie.Value) + require.NotNil(t, flash) + assert.Equal(t, "tablet state refreshed: serving", flash.Message) +} + +func TestTabletActionsReadOnly(t *testing.T) { + actions := []string{ + "/start_replication", "/stop_replication", "/refresh_replication_source", + "/set_read_only", "/set_read_write", "/delete", "/ping", + "/refresh_state", "/health_check", + } + + for _, action := range actions { + t.Run(action, func(t *testing.T) { + fake := &tabletActionsFakeServer{} + s := newTabletActionsTestServer(t, fake, true) + + rec := postTabletAction(t, s, tabletActionBase+action, url.Values{}) + + assert.Equal(t, http.StatusForbidden, rec.Code) + assert.Nil(t, fake.deleteTabletReq) + assert.Nil(t, fake.startReplicationReq) + }) + } +} + +func TestTabletDetailRendersActionsCard(t *testing.T) { + fake := &tabletActionsFakeServer{} + s := newTabletActionsTestServer(t, fake, false) + + token, rec := renderWithCSRF(t, s, tabletActionBase) + body := rec.Body.String() + assert.Contains(t, body, tabletActionBase+"/start_replication") + assert.Contains(t, body, tabletActionBase+"/delete") + assert.Contains(t, body, `name="allow_primary"`) + + // A POST using the exact rendered token/cookie pairing must get past + // CSRF validation. + rec = postFormWithCSRF(s, tabletActionBase+"/ping", token, url.Values{ + "csrf_token": {token}, + }) + assert.Equal(t, http.StatusSeeOther, rec.Code) + require.NotNil(t, fake.pingTabletReq) +} + +func TestTabletDetailHidesActionsWhenReadOnly(t *testing.T) { + fake := &tabletActionsFakeServer{} + s := newTabletActionsTestServer(t, fake, true) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, tabletActionBase, nil) + s.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + assert.NotContains(t, rec.Body.String(), tabletActionBase+"/start_replication") +} + +func TestTabletActionInvalidAlias(t *testing.T) { + fake := &tabletActionsFakeServer{} + s := newTabletActionsTestServer(t, fake, false) + + rec := postTabletAction(t, s, "/tablet/local/not-an-alias/start_replication", url.Values{}) + + assert.Equal(t, http.StatusBadRequest, rec.Code) + assert.Nil(t, fake.startReplicationReq) +} diff --git a/go/vt/vtadmin/vtadmin2/tablet_test.go b/go/vt/vtadmin/vtadmin2/tablet_test.go new file mode 100644 index 00000000000..362886ac4b7 --- /dev/null +++ b/go/vt/vtadmin/vtadmin2/tablet_test.go @@ -0,0 +1,200 @@ +/* +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" + + replicationdatapb "vitess.io/vitess/go/vt/proto/replicationdata" + topodatapb "vitess.io/vitess/go/vt/proto/topodata" + 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 tabletFakeServer struct { + fakeVTAdminServer + getTabletRequest *vtadminpb.GetTabletRequest + getFullStatusRequest *vtadminpb.GetFullStatusRequest + getTabletError error + getFullStatusError error + getFullStatusNil bool +} + +func (f *tabletFakeServer) GetTablet(ctx context.Context, req *vtadminpb.GetTabletRequest) (*vtadminpb.Tablet, error) { + f.getTabletRequest = req + if f.getTabletError != nil { + return nil, f.getTabletError + } + return &vtadminpb.Tablet{ + Cluster: &vtadminpb.Cluster{Id: "local", Name: "Local"}, + Tablet: &topodatapb.Tablet{ + Alias: &topodatapb.TabletAlias{Cell: "zone1", Uid: 100}, + Hostname: "tablet-100", + Keyspace: "commerce", + Shard: "0", + Type: topodatapb.TabletType_PRIMARY, + }, + State: vtadminpb.Tablet_SERVING, + }, nil +} + +func (f *tabletFakeServer) GetFullStatus(ctx context.Context, req *vtadminpb.GetFullStatusRequest) (*vtctldatapb.GetFullStatusResponse, error) { + f.getFullStatusRequest = req + if f.getFullStatusError != nil { + return nil, f.getFullStatusError + } + if f.getFullStatusNil { + return nil, nil + } + return &vtctldatapb.GetFullStatusResponse{ + Status: &replicationdatapb.FullStatus{ServerUuid: "server-uuid-1"}, + }, nil +} + +func TestTabletDetailCallsServerAndRendersTablet(t *testing.T) { + fake := &tabletFakeServer{} + s, err := NewServer(fake, Options{}) + require.NoError(t, err) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/tablet/local/zone1-0000000100", nil) + s.ServeHTTP(rec, req) + + require.NotNil(t, fake.getTabletRequest) + assert.Equal(t, []string{"local"}, fake.getTabletRequest.GetClusterIds()) + require.NotNil(t, fake.getTabletRequest.GetAlias()) + assert.Equal(t, "zone1", fake.getTabletRequest.GetAlias().GetCell()) + assert.EqualValues(t, 100, fake.getTabletRequest.GetAlias().GetUid()) + assert.Equal(t, http.StatusOK, rec.Code) + assert.Contains(t, rec.Body.String(), "zone1-0000000100") + assert.Contains(t, rec.Body.String(), "tablet-100") + assert.Contains(t, rec.Body.String(), "commerce") +} + +func TestTabletFullStatusCallsServerAndRendersStatus(t *testing.T) { + fake := &tabletFakeServer{} + s, err := NewServer(fake, Options{}) + require.NoError(t, err) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/tablet/local/zone1-0000000100/full_status", nil) + s.ServeHTTP(rec, req) + + require.NotNil(t, fake.getFullStatusRequest) + assert.Equal(t, "local", fake.getFullStatusRequest.GetClusterId()) + require.NotNil(t, fake.getFullStatusRequest.GetAlias()) + assert.Equal(t, "zone1", fake.getFullStatusRequest.GetAlias().GetCell()) + assert.EqualValues(t, 100, fake.getFullStatusRequest.GetAlias().GetUid()) + assert.Equal(t, http.StatusOK, rec.Code) + assert.Contains(t, rec.Body.String(), "Full Status") + assert.Contains(t, rec.Body.String(), "zone1-0000000100") + assert.Contains(t, rec.Body.String(), "server-uuid-1") +} + +func TestTabletDetailInvalidAliasReturnsBadRequest(t *testing.T) { + fake := &tabletFakeServer{} + s, err := NewServer(fake, Options{}) + require.NoError(t, err) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/tablet/local/not-a-valid-alias", nil) + s.ServeHTTP(rec, req) + + assert.Nil(t, fake.getTabletRequest) + assert.Equal(t, http.StatusBadRequest, rec.Code) + assert.Contains(t, rec.Body.String(), "not-a-valid-alias") +} + +func TestTabletDetailNotFoundReturnsNotFound(t *testing.T) { + fake := &tabletFakeServer{ + getTabletError: vterrors.Errorf(vtrpcpb.Code_NOT_FOUND, "tablet missing"), + } + s, err := NewServer(fake, Options{}) + require.NoError(t, err) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/tablet/local/zone1-0000000100", nil) + s.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusNotFound, rec.Code) + assert.Contains(t, rec.Body.String(), "tablet missing") +} + +func TestTabletDetailInvalidBackendArgumentReturnsBadRequest(t *testing.T) { + fake := &tabletFakeServer{ + getTabletError: vterrors.Errorf(vtrpcpb.Code_INVALID_ARGUMENT, "bad tablet request"), + } + s, err := NewServer(fake, Options{}) + require.NoError(t, err) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/tablet/local/zone1-0000000100", nil) + s.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusBadRequest, rec.Code) + assert.Contains(t, rec.Body.String(), "bad tablet request") +} + +func TestTabletFullStatusNotFoundReturnsNotFound(t *testing.T) { + fake := &tabletFakeServer{ + getFullStatusError: vterrors.Errorf(vtrpcpb.Code_NOT_FOUND, "full status missing"), + } + s, err := NewServer(fake, Options{}) + require.NoError(t, err) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/tablet/local/zone1-0000000100/full_status", nil) + s.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusNotFound, rec.Code) + assert.Contains(t, rec.Body.String(), "full status missing") +} + +func TestTabletFullStatusNilResponseReturnsNotFound(t *testing.T) { + fake := &tabletFakeServer{getFullStatusNil: true} + s, err := NewServer(fake, Options{}) + require.NoError(t, err) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/tablet/local/zone1-0000000100/full_status", nil) + s.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusNotFound, rec.Code) +} + +func TestTabletFullStatusInvalidBackendArgumentReturnsBadRequest(t *testing.T) { + fake := &tabletFakeServer{ + getFullStatusError: vterrors.Errorf(vtrpcpb.Code_INVALID_ARGUMENT, "bad full status request"), + } + s, err := NewServer(fake, Options{}) + require.NoError(t, err) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/tablet/local/zone1-0000000100/full_status", nil) + s.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusBadRequest, rec.Code) + assert.Contains(t, rec.Body.String(), "bad full status request") +} diff --git a/go/vt/vtadmin/vtadmin2/templates/backups.html b/go/vt/vtadmin/vtadmin2/templates/backups.html new file mode 100644 index 00000000000..7faeb4711f5 --- /dev/null +++ b/go/vt/vtadmin/vtadmin2/templates/backups.html @@ -0,0 +1,32 @@ +{{ define "backups.html" }} +{{ template "layout" . }} +{{ end }} + +{{ define "content" }} + + Backups + + + + + + ClusterNameKeyspaceShardDirectoryEngine + + + {{ range .Data }} + {{ $backup := .GetBackup }} + + {{ clusterID .GetCluster }} + {{ $backup.GetName }} + {{ $backup.GetKeyspace }} + {{ $backup.GetShard }} + {{ $backup.GetDirectory }} + {{ $backup.GetEngine }} + + {{ else }} + No backups found. + {{ end }} + + + +{{ end }} diff --git a/go/vt/vtadmin/vtadmin2/templates/cells.html b/go/vt/vtadmin/vtadmin2/templates/cells.html new file mode 100644 index 00000000000..050ebf9cbde --- /dev/null +++ b/go/vt/vtadmin/vtadmin2/templates/cells.html @@ -0,0 +1,28 @@ +{{ define "cells.html" }} +{{ template "layout" . }} +{{ end }} + +{{ define "content" }} + + Cells + + + + + + ClusterNameDetails + + + {{ range .Data }} + + {{ clusterID .GetCluster }} + {{ .GetName }} + {{ protoJSON .GetCellInfo }} + + {{ else }} + No cells found. + {{ end }} + + + +{{ end }} diff --git a/go/vt/vtadmin/vtadmin2/templates/cells_aliases.html b/go/vt/vtadmin/vtadmin2/templates/cells_aliases.html new file mode 100644 index 00000000000..0cef5f09b6e --- /dev/null +++ b/go/vt/vtadmin/vtadmin2/templates/cells_aliases.html @@ -0,0 +1,34 @@ +{{ define "cells_aliases.html" }} +{{ template "layout" . }} +{{ end }} + +{{ define "content" }} + + Cell Aliases + + + + + + ClusterAliasCellsDetails + + + {{ range .Data }} + {{ $cluster := clusterID .GetCluster }} + {{ range $name, $alias := .GetAliases }} + + {{ $cluster }} + {{ $name }} + {{ range $alias.GetCells }}{{ . }} {{ end }} + {{ protoJSON $alias }} + + {{ else }} + {{ $cluster }}No aliases found for this cluster. + {{ end }} + {{ else }} + No cell aliases found. + {{ end }} + + + +{{ end }} diff --git a/go/vt/vtadmin/vtadmin2/templates/clusters.html b/go/vt/vtadmin/vtadmin2/templates/clusters.html new file mode 100644 index 00000000000..31f98441a54 --- /dev/null +++ b/go/vt/vtadmin/vtadmin2/templates/clusters.html @@ -0,0 +1,27 @@ +{{ define "clusters.html" }} +{{ template "layout" . }} +{{ end }} + +{{ define "content" }} + + Clusters + + + + + + IDName + + + {{ range .Data }} + + {{ .GetId }} + {{ .GetName }} + + {{ else }} + No clusters found. + {{ end }} + + + +{{ end }} diff --git a/go/vt/vtadmin/vtadmin2/templates/gates.html b/go/vt/vtadmin/vtadmin2/templates/gates.html new file mode 100644 index 00000000000..182cd982945 --- /dev/null +++ b/go/vt/vtadmin/vtadmin2/templates/gates.html @@ -0,0 +1,30 @@ +{{ define "gates.html" }} +{{ template "layout" . }} +{{ end }} + +{{ define "content" }} + + VTGates + + + + + + ClusterHostnameCellPoolFQDN + + + {{ range .Data }} + + {{ clusterID .GetCluster }} + {{ if .GetHostname }}{{ .GetHostname }}{{ end }} + {{ .GetCell }} + {{ .GetPool }} + {{ if .GetFQDN }}{{ .GetFQDN }}{{ end }} + + {{ else }} + No VTGates found. + {{ end }} + + + +{{ end }} diff --git a/go/vt/vtadmin/vtadmin2/templates/index.html b/go/vt/vtadmin/vtadmin2/templates/index.html new file mode 100644 index 00000000000..42b4f00961c --- /dev/null +++ b/go/vt/vtadmin/vtadmin2/templates/index.html @@ -0,0 +1,8 @@ +{{ define "index.html" }} +{{ template "layout" . }} +{{ end }} + +{{ define "content" }} +{{ .Title }} +Use the navigation to inspect your Vitess clusters. +{{ end }} diff --git a/go/vt/vtadmin/vtadmin2/templates/keyspace.html b/go/vt/vtadmin/vtadmin2/templates/keyspace.html new file mode 100644 index 00000000000..ef9d3c53d40 --- /dev/null +++ b/go/vt/vtadmin/vtadmin2/templates/keyspace.html @@ -0,0 +1,116 @@ +{{ define "keyspace.html" }} +{{ template "layout" . }} +{{ end }} + +{{ define "content" }} +{{ $ks := .Data }} + + {{ keyspaceName $ks }} + Back to keyspaces + + + + Cluster{{ clusterID $ks.GetCluster }} + Keyspace{{ keyspaceName $ks }} + + + + Shards + + Name + + {{ range sortedShardNames $ks }} + {{ . }} + {{ else }} + No shards found. + {{ end }} + + + +{{ if not .ReadOnly }} + + Keyspace actions + + + Validate + + + + + Ping tablets + + Validates that all nodes reachable from this keyspace are consistent. + Validate keyspace + + + + Validate schema + + + Validates that the schema is consistent across all tablets in this keyspace. + Validate schema + + + + Validate version + + + Validates that the version on the primary matches all replicas. + Validate version + + + + Rebuild keyspace graph + + + + Cells + + + Rebuilds the serving graph for this keyspace in the given cells. Leave cells empty for all. + Rebuild graph + + + + Reload schema + + + Reloads the schema on all tablets in this keyspace, including the primary. Best-effort. + Reload schema + + + + Create shard + + + + Shard name + + + + + Force (treat existing shard as success) + + Create shard + + + + Remove cell + + + + Cell + + + + + Force + + Removes the cell from the keyspace's SrvKeyspace. All shards in the keyspace must have no tablets in the cell, unless forced. + Remove cell + + + + +{{ end }} +{{ end }} diff --git a/go/vt/vtadmin/vtadmin2/templates/keyspace_create.html b/go/vt/vtadmin/vtadmin2/templates/keyspace_create.html new file mode 100644 index 00000000000..c1e244e1370 --- /dev/null +++ b/go/vt/vtadmin/vtadmin2/templates/keyspace_create.html @@ -0,0 +1,38 @@ +{{ define "keyspace_create.html" }} +{{ template "layout" . }} +{{ end }} + +{{ define "content" }} + + Create keyspace + Back to keyspaces + + + + + + Cluster + + {{ range .Data }}{{ .GetName }} ({{ .GetId }}){{ end }} + + + + Keyspace name + + + + Durability policy + + + + Sidecar DB name + + + Force if keyspace already exists + Allow empty VSchema + + Create keyspace + + + +{{ end }} diff --git a/go/vt/vtadmin/vtadmin2/templates/keyspaces.html b/go/vt/vtadmin/vtadmin2/templates/keyspaces.html new file mode 100644 index 00000000000..774d9408074 --- /dev/null +++ b/go/vt/vtadmin/vtadmin2/templates/keyspaces.html @@ -0,0 +1,31 @@ +{{ define "keyspaces.html" }} +{{ template "layout" . }} +{{ end }} + +{{ define "content" }} + + Keyspaces + + + {{ if not .ReadOnly }}Create keyspace{{ end }} + + + + + + ClusterKeyspaceShards + + + {{ range .Data }} + + {{ clusterID .GetCluster }} + {{ keyspaceName . }} + {{ len .GetShards }} + + {{ else }} + No keyspaces found. + {{ end }} + + + +{{ end }} diff --git a/go/vt/vtadmin/vtadmin2/templates/layout.html b/go/vt/vtadmin/vtadmin2/templates/layout.html new file mode 100644 index 00000000000..892fc3957d2 --- /dev/null +++ b/go/vt/vtadmin/vtadmin2/templates/layout.html @@ -0,0 +1,55 @@ +{{ define "layout" }} + + + + + + {{ if .RefreshSeconds }}{{ end }} + {{ .Title }} | {{ .DocumentTitle }} + + + + + + + + VTAdmin2 + + Settings + {{ if .ReadOnly }}Read-only{{ end }} + + + + Overview + Clusters + VTGates + VTCtlds + Topology + Keyspaces + Shards + Topology + Cells + Serving + Tablets + Schemas + VSchema + SrvKeyspaces + SrvVSchemas + Operations + Workflows + Migrations + Transactions + Backups + Cell Aliases + Tools + VTExplain + VExplain + + + {{ if .Flash }}{{ .Flash.Message }}{{ end }} + {{ template "content" . }} + + + + +{{ end }} diff --git a/go/vt/vtadmin/vtadmin2/templates/migration_create.html b/go/vt/vtadmin/vtadmin2/templates/migration_create.html new file mode 100644 index 00000000000..fd13ab2eadc --- /dev/null +++ b/go/vt/vtadmin/vtadmin2/templates/migration_create.html @@ -0,0 +1,95 @@ +{{ define "migration_create.html" }} +{{ template "layout" . }} +{{ end }} + +{{ define "content" }} +{{ $d := .Data }} + + Create schema migration + Back to migrations + + +Applies DDL to a keyspace through Vitess's online schema migration (or directly, depending on the DDL strategy). Schema changes user guide. + +{{ if $d.PickCluster }} + + Choose a cluster + Keyspaces are per cluster. Pick the cluster you want to migrate: + + {{ range $d.Form.Clusters }} + {{ .GetName }} ({{ .GetId }}) + {{ end }} + + +{{ else }} + + + + + + + Cluster + {{ range $d.Form.Clusters }} + {{ if eq .GetId $d.SelectedCluster }} + {{ .GetName }} ({{ .GetId }}) + {{ end }} + {{ end }} + + {{ if gt (len $d.Form.Clusters) 1 }} + Choose a different cluster + {{ end }} + + Keyspace + + {{ range $d.Form.Keyspaces }} + {{ if eq (clusterID .GetCluster) $d.SelectedCluster }} + {{ keyspaceName . }} + {{ end }} + {{ end }} + + + + SQL + + One or more semicolon-terminated DDL statements, one per line. + + + Advanced + + DDL strategy + + vitess + direct + gh-ost + pt-osc + + Online DDL strategy, compatible with the @@ddl_strategy session variable. + + + Batch size + + How many queries to apply together. Leave empty for the default. + + + Caller ID + + Effective caller ID for the operation; must map to an ACL name granting the needed permissions when strict table ACLs are used. + + + Migration context + + Optional unique string used as context for the migration(s). Auto-generated by Vitess when empty. + + + UUID list + + Optional explicit UUIDs for the migrations; must match the number of DDL changes. + + + + Create migration + + + +{{ end }} +{{ end }} diff --git a/go/vt/vtadmin/vtadmin2/templates/migrations.html b/go/vt/vtadmin/vtadmin2/templates/migrations.html new file mode 100644 index 00000000000..9e233a2214e --- /dev/null +++ b/go/vt/vtadmin/vtadmin2/templates/migrations.html @@ -0,0 +1,59 @@ +{{ define "migrations.html" }} +{{ template "layout" . }} +{{ end }} + +{{ define "content" }} + + Migrations + + {{ if not .ReadOnly }}Create migration{{ end }} + + + + + + + Cluster + + {{ range .Data.Form.Clusters }} + {{ .GetName }} ({{ .GetId }}) + {{ end }} + + + + Keyspace + + {{ range .Data.Form.Keyspaces }} + {{ if eq (clusterID .GetCluster) $.Data.Form.SelectedCluster }} + {{ keyspaceName . }} + {{ end }} + {{ end }} + + + UUID + Filter Migrations + + + + + + ClusterUUIDKeyspaceShardTableStatus + + + {{ range .Data.Migrations }} + + {{ clusterID .GetCluster }} + {{ .GetSchemaMigration.GetUuid }} + {{ .GetSchemaMigration.GetKeyspace }} + {{ .GetSchemaMigration.GetShard }} + {{ .GetSchemaMigration.GetTable }} + {{ .GetSchemaMigration.GetStatus }} + + {{ protoJSON .GetSchemaMigration }} + {{ else }} + No migrations found. + {{ end }} + + + +{{ end }} diff --git a/go/vt/vtadmin/vtadmin2/templates/schema.html b/go/vt/vtadmin/vtadmin2/templates/schema.html new file mode 100644 index 00000000000..dba1d39314b --- /dev/null +++ b/go/vt/vtadmin/vtadmin2/templates/schema.html @@ -0,0 +1,57 @@ +{{ define "schema.html" }} +{{ template "layout" . }} +{{ end }} + +{{ define "content" }} +{{ $d := .Data }} + + {{ $d.Table }} + + All schemas + Keyspace {{ $d.Keyspace }} + VSchema + + + + + Table definition + + Cluster{{ $d.ClusterID }} + Keyspace{{ $d.Keyspace }} + Columns{{ range $i, $c := $d.Definition.GetColumns }}{{ if $i }}, {{ end }}{{ $c }}{{ end }} + {{ if $d.Definition.GetPrimaryKeyColumns }} + Primary key{{ range $i, $c := $d.Definition.GetPrimaryKeyColumns }}{{ if $i }}, {{ end }}{{ $c }}{{ end }} + {{ end }} + + {{ $d.Definition.GetSchema }} + + +{{ if $d.Vindexes }} + + Vindexes + A Vindex maps a column value to a keyspace ID, which identifies the shard that contains the row. + Learn more about Vindexes. + + + VindexColumnsTypeParams + + + {{ range $d.Vindexes }}{{ $v := . }} + + {{ $v.Name }}{{ if $v.Primary }} Primary{{ end }} + {{ range $i, $c := $v.Columns }}{{ if $i }}, {{ end }}{{ $c }}{{ end }} + {{ if $v.Type }}{{ $v.Type }}{{ else }}N/A{{ end }} + + {{ if $v.ParamKeys }} + {{ range $k := $v.ParamKeys }}{{ $k }}: {{ index $v.Params $k }}{{ end }} + {{ else }} + N/A + {{ end }} + + + {{ end }} + + + +{{ end }} +{{ end }} diff --git a/go/vt/vtadmin/vtadmin2/templates/schemas.html b/go/vt/vtadmin/vtadmin2/templates/schemas.html new file mode 100644 index 00000000000..29e63e1a446 --- /dev/null +++ b/go/vt/vtadmin/vtadmin2/templates/schemas.html @@ -0,0 +1,31 @@ +{{ define "schemas.html" }} +{{ template "layout" . }} +{{ end }} + +{{ define "content" }} + + Schemas + + + + + + ClusterKeyspaceTables + + + {{ range .Data }}{{ $s := . }} + + {{ clusterID $s.GetCluster }} + {{ $s.GetKeyspace }} + + {{ schemaTableCount $s }} + {{ range $s.GetTableDefinitions }}{{ .GetName }}{{ end }} + + + {{ else }} + No schemas found. + {{ end }} + + + +{{ end }} diff --git a/go/vt/vtadmin/vtadmin2/templates/settings.html b/go/vt/vtadmin/vtadmin2/templates/settings.html new file mode 100644 index 00000000000..e763650fdb5 --- /dev/null +++ b/go/vt/vtadmin/vtadmin2/templates/settings.html @@ -0,0 +1,38 @@ +{{ define "settings.html" }} +{{ template "layout" . }} +{{ end }} + +{{ define "content" }} +{{ $d := .Data }} + + Settings + + + + + + + Theme + + System + Light + Dark + + System follows your operating system preference. + + + Default cluster + + None + {{ range $d.Clusters }} + {{ .GetName }} ({{ .GetId }}) + {{ end }} + + Pre-selects this cluster on pages that filter by cluster. + + + Save settings + + + +{{ end }} diff --git a/go/vt/vtadmin/vtadmin2/templates/shard.html b/go/vt/vtadmin/vtadmin2/templates/shard.html new file mode 100644 index 00000000000..1e06af0f8dc --- /dev/null +++ b/go/vt/vtadmin/vtadmin2/templates/shard.html @@ -0,0 +1,155 @@ +{{ define "shard.html" }} +{{ template "layout" . }} +{{ end }} + +{{ define "content" }} +{{ $d := .Data }} +{{ $statuses := $d.Positions.GetPositionInfo.GetReplicationStatuses }} + + {{ $d.Keyspace }}/{{ $d.Shard }} + + Back to keyspace + All keyspaces + + + + + + Cluster{{ $d.ClusterID }} + Keyspace{{ $d.Keyspace }} + Shard{{ $d.Shard }} + Tablets{{ len $d.Tablets }} + + + + + Tablets + + + AliasTypeStateHostnameReplication lagReplication status + + + {{ range $d.Tablets }} + {{ $alias := tabletAlias .GetTablet.GetAlias }} + + {{ $alias }} + {{ .GetTablet.GetType }} + {{ .GetState }} + {{ if .GetTablet.GetHostname }}{{ .GetTablet.GetHostname }}{{ end }} + {{ with index $statuses $alias }} + {{ .GetReplicationLagSeconds }}s + Details{{ protoJSON . }} + {{ else }} + — + — + {{ end }} + + {{ else }} + No tablets found in this shard. + {{ end }} + + + + +{{ if not .ReadOnly }} + + Shard actions + + + Reload schema + + + + + Include primary + + Reloads the schema on all tablets in this shard. Best-effort; failures on individual tablets are logged by vtctld. + Reload schema + + + + + Validate shard + + + + + Ping tablets + + Checks that all nodes reachable from this shard are consistent. + Validate + + + + + Validate version + + + Validates that the version on the primary matches all replicas. + Validate version + + + + + Planned failover + + + + New primary (optional) + + + + Wait replicas timeout (seconds) + + + Gracefully reparents this shard to the given tablet, or to the most up-to-date candidate when left empty. Brief write downtime is expected. + Planned failover + + + + + Emergency failover + + + + New primary (optional) + + + + Wait replicas timeout (seconds) + + + + + Prevent cross-cell promotion + + Assumes the current primary is dead and not responding. Use only when planned failover is not possible. + Emergency failover + + + + + Acknowledge external promotion + + + + Promoted tablet alias + + + Updates the topo after an external tool (e.g. orchestrator) promoted a tablet. This does not itself reparent anything. + Acknowledge + + + + + Delete shard + + + Deletes this shard from the topo. Fails if the shard still has tablets; deleting a serving shard requires force elsewhere. This cannot be undone. + Delete shard + + + + +{{ end }} +{{ end }} diff --git a/go/vt/vtadmin/vtadmin2/templates/shards.html b/go/vt/vtadmin/vtadmin2/templates/shards.html new file mode 100644 index 00000000000..cf1bc0c6a93 --- /dev/null +++ b/go/vt/vtadmin/vtadmin2/templates/shards.html @@ -0,0 +1,29 @@ +{{ define "shards.html" }} +{{ template "layout" . }} +{{ end }} + +{{ define "content" }} + + Shards + + + + + + ClusterKeyspaceShardDetails + + + {{ range .Data }} + + {{ clusterID .GetCluster }} + {{ .GetKeyspace }} + {{ .GetShard }} + {{ protoJSON .GetPositionInfo }} + + {{ else }} + No shards found. + {{ end }} + + + +{{ end }} diff --git a/go/vt/vtadmin/vtadmin2/templates/srvkeyspaces.html b/go/vt/vtadmin/vtadmin2/templates/srvkeyspaces.html new file mode 100644 index 00000000000..74d1a746213 --- /dev/null +++ b/go/vt/vtadmin/vtadmin2/templates/srvkeyspaces.html @@ -0,0 +1,42 @@ +{{ define "srvkeyspaces.html" }} +{{ template "layout" . }} +{{ end }} + +{{ define "content" }} + + SrvKeyspaces + + + + + + Cluster + + {{ range .Data.Clusters }} + {{ .GetName }} ({{ .GetId }}) + {{ end }} + + + Load + + +{{ if .Data.SelectedCluster }} + + + + KeyspaceDetails + + + {{ range $keyspace, $response := .Data.SrvKeyspaces }} + + {{ $keyspace }} + {{ protoJSON $response }} + + {{ else }} + No SrvKeyspaces found. + {{ end }} + + + +{{ end }} +{{ end }} diff --git a/go/vt/vtadmin/vtadmin2/templates/srvvschemas.html b/go/vt/vtadmin/vtadmin2/templates/srvvschemas.html new file mode 100644 index 00000000000..9ee59f10d8f --- /dev/null +++ b/go/vt/vtadmin/vtadmin2/templates/srvvschemas.html @@ -0,0 +1,28 @@ +{{ define "srvvschemas.html" }} +{{ template "layout" . }} +{{ end }} + +{{ define "content" }} + + SrvVSchemas + + + + + + ClusterCellDetails + + + {{ range .Data }} + + {{ clusterID .GetCluster }} + {{ .GetCell }} + {{ protoJSON .GetSrvVSchema }} + + {{ else }} + No SrvVSchemas found. + {{ end }} + + + +{{ end }} diff --git a/go/vt/vtadmin/vtadmin2/templates/stream.html b/go/vt/vtadmin/vtadmin2/templates/stream.html new file mode 100644 index 00000000000..187ad6b2882 --- /dev/null +++ b/go/vt/vtadmin/vtadmin2/templates/stream.html @@ -0,0 +1,33 @@ +{{ define "stream.html" }} +{{ template "layout" . }} +{{ end }} + +{{ define "content" }} +{{ $d := .Data }} +{{ $s := $d.Stream }} + + {{ $d.StreamKey }} + + Back to workflow + + + + + + Cluster{{ $d.ClusterID }} + Workflow{{ $d.Workflow }} + Stream key{{ $d.StreamKey }} + Shard{{ $s.GetShard }} + State{{ $s.GetState }} + Position{{ $s.GetPosition }} + Stop position{{ $s.GetStopPosition }} + DB name{{ $s.GetDbName }} + Message{{ $s.GetMessage }} + + + + + Stream data + {{ protoJSON $s }} + +{{ end }} diff --git a/go/vt/vtadmin/vtadmin2/templates/tablet.html b/go/vt/vtadmin/vtadmin2/templates/tablet.html new file mode 100644 index 00000000000..2d78092c462 --- /dev/null +++ b/go/vt/vtadmin/vtadmin2/templates/tablet.html @@ -0,0 +1,95 @@ +{{ define "tablet.html" }} +{{ template "layout" . }} +{{ end }} + +{{ define "content" }} +{{ $tablet := .Data.Tablet.GetTablet }} + + {{ .Data.Alias }} + Back to tablets + + + + Cluster{{ .Data.ClusterID }} + Alias{{ .Data.Alias }} + Hostname{{ if $tablet.GetHostname }}{{ $tablet.GetHostname }}{{ end }} + Keyspace{{ $tablet.GetKeyspace }} + Shard{{ $tablet.GetShard }} + Type{{ $tablet.GetType }} + State{{ .Data.Tablet.GetState }} + + Full Status + +{{ if not .ReadOnly }} + + Tablet actions + + + Replication + + + + Start replication + + + + Stop replication + + + + Refresh replication source + Runs CHANGE REPLICATION SOURCE TO so this tablet replicates from the current shard primary. + + + + + Read-only mode + + + + Set read-only + + + + Set read-write + + + + + Diagnostics + + + + Ping tablet + + + + Refresh state + Reloads the tablet record from the topology server. + + + + Run health check + + + + + Delete tablet + + + + + Allow deleting a primary tablet + + Removes the tablet record from the topology server. The mysqld process itself is not stopped. This cannot be undone. + Delete tablet + + + + +{{ end }} + + Tablet Data + {{ protoJSON .Data.Tablet }} + +{{ end }} diff --git a/go/vt/vtadmin/vtadmin2/templates/tablet_full_status.html b/go/vt/vtadmin/vtadmin2/templates/tablet_full_status.html new file mode 100644 index 00000000000..a9166a8e8f1 --- /dev/null +++ b/go/vt/vtadmin/vtadmin2/templates/tablet_full_status.html @@ -0,0 +1,19 @@ +{{ define "tablet_full_status.html" }} +{{ template "layout" . }} +{{ end }} + +{{ define "content" }} + + Full Status + Back to tablet + + + + Cluster{{ .Data.ClusterID }} + Alias{{ .Data.Alias }} + + + + {{ protoJSON .Data.Response }} + +{{ end }} diff --git a/go/vt/vtadmin/vtadmin2/templates/tablets.html b/go/vt/vtadmin/vtadmin2/templates/tablets.html new file mode 100644 index 00000000000..998ad48db44 --- /dev/null +++ b/go/vt/vtadmin/vtadmin2/templates/tablets.html @@ -0,0 +1,35 @@ +{{ define "tablets.html" }} +{{ template "layout" . }} +{{ end }} + +{{ define "content" }} + + Tablets + + + + + + ClusterAliasFQDNKeyspaceShardTypeState + + + {{ range .Data }} + {{ $tablet := .GetTablet }} + {{ $clusterID := clusterID .GetCluster }} + {{ $alias := tabletAlias $tablet.GetAlias }} + + {{ $clusterID }} + {{ $alias }} + {{ if .GetFQDN }}{{ .GetFQDN }}{{ end }} + {{ $tablet.GetKeyspace }} + {{ $tablet.GetShard }} + {{ $tablet.GetType }} + {{ .GetState }} + + {{ else }} + No tablets found. + {{ end }} + + + +{{ end }} diff --git a/go/vt/vtadmin/vtadmin2/templates/topology.html b/go/vt/vtadmin/vtadmin2/templates/topology.html new file mode 100644 index 00000000000..812067afc7b --- /dev/null +++ b/go/vt/vtadmin/vtadmin2/templates/topology.html @@ -0,0 +1,35 @@ +{{ define "topology.html" }} +{{ template "layout" . }} +{{ end }} + +{{ define "content" }} + + Topology + + + + + Cluster + + {{ range .Data.Clusters }} + {{ .GetName }} ({{ .GetId }}) + {{ end }} + + + + Path + + + Load + + + + + Cluster + {{ .Data.ClusterID }} + Path + {{ .Data.Path }} + + {{ protoJSON .Data.Response }} + +{{ end }} diff --git a/go/vt/vtadmin/vtadmin2/templates/topology_tree.html b/go/vt/vtadmin/vtadmin2/templates/topology_tree.html new file mode 100644 index 00000000000..c621fe609d3 --- /dev/null +++ b/go/vt/vtadmin/vtadmin2/templates/topology_tree.html @@ -0,0 +1,59 @@ +{{ define "topology_tree.html" }} +{{ template "layout" . }} +{{ end }} + +{{ define "content" }} +{{ $d := .Data }} + + Topology: {{ $d.ClusterID }} + Path lookup + + + + Cells + + CellTablets + + {{ range $d.Cells }} + {{ .Name }}{{ .TabletCount }} + {{ else }} + No cells found. + {{ end }} + + + + + + Keyspaces + {{ range $d.Keyspaces }}{{ $tk := . }} + + {{ $tk.Name }} + + ShardPrimaryTablets + + {{ range $tk.Shards }} + + {{ .Name }} + + {{ if .PrimaryAlias }} + {{ .PrimaryAlias }} + {{ else }} + none + {{ end }} + + + {{ range .Tablets }}{{ . }} {{ end }} + {{ if not .Tablets }}No tablets{{ end }} + + + {{ else }} + No shards found. + {{ end }} + + + + {{ else }} + No keyspaces found. + {{ end }} + +{{ end }} diff --git a/go/vt/vtadmin/vtadmin2/templates/transaction.html b/go/vt/vtadmin/vtadmin2/templates/transaction.html new file mode 100644 index 00000000000..26528af08ce --- /dev/null +++ b/go/vt/vtadmin/vtadmin2/templates/transaction.html @@ -0,0 +1,34 @@ +{{ define "transaction.html" }} +{{ template "layout" . }} +{{ end }} + +{{ define "content" }} + + Transaction + + + + Cluster{{ .Data.ClusterID }} + DTID{{ .Data.Dtid }} + + + + + + ShardStateMessage + + + {{ range .Data.Response.GetShardStates }} + + {{ .GetShard }} + {{ .GetState }} + {{ .GetMessage }} + + {{ else }} + No shard states found. + {{ end }} + + + {{ protoJSON .Data.Response }} + +{{ end }} diff --git a/go/vt/vtadmin/vtadmin2/templates/transactions.html b/go/vt/vtadmin/vtadmin2/templates/transactions.html new file mode 100644 index 00000000000..9d8088e171c --- /dev/null +++ b/go/vt/vtadmin/vtadmin2/templates/transactions.html @@ -0,0 +1,59 @@ +{{ define "transactions.html" }} +{{ template "layout" . }} +{{ end }} + +{{ define "content" }} + + Transactions + + + + + + Cluster + + {{ range .Data.Form.Clusters }} + {{ .GetName }} ({{ .GetId }}) + {{ end }} + + + + Keyspace + + {{ range .Data.Form.Keyspaces }} + {{ if eq (clusterID .GetCluster) $.Data.Form.SelectedCluster }} + {{ keyspaceName . }} + {{ end }} + {{ end }} + + + Abandon Age + Filter Transactions + + + + + + DTIDInfo + + + {{ range .Data.Transactions }} + + {{ .GetDtid }} + + Info + {{ if not $.ReadOnly }} + + + Conclude + + {{ end }} + + + {{ else }} + No unresolved transactions found. + {{ end }} + + + +{{ end }} diff --git a/go/vt/vtadmin/vtadmin2/templates/vdiff_show.html b/go/vt/vtadmin/vtadmin2/templates/vdiff_show.html new file mode 100644 index 00000000000..089d8a5da6f --- /dev/null +++ b/go/vt/vtadmin/vtadmin2/templates/vdiff_show.html @@ -0,0 +1,47 @@ +{{ define "vdiff_show.html" }} +{{ template "layout" . }} +{{ end }} + +{{ define "content" }} + + VDiff + + + + Cluster + + Keyspace + + {{ range .Data.Form.Keyspaces }} + {{ if eq (clusterID .GetCluster) $.Data.ClusterID }} + {{ keyspaceName . }} + {{ end }} + {{ end }} + + + Workflow + Arg + Show VDiff + + + + + + ShardStateRows ComparedMismatch + + + {{ range $shard, $report := .Data.Response.GetShardReport }} + + {{ $shard }} + {{ $report.GetState }} + {{ $report.GetRowsCompared }} + {{ $report.GetHasMismatch }} + + {{ else }} + No VDiff results found. + {{ end }} + + + {{ protoJSON .Data.Response }} + +{{ end }} diff --git a/go/vt/vtadmin/vtadmin2/templates/vexplain.html b/go/vt/vtadmin/vtadmin2/templates/vexplain.html new file mode 100644 index 00000000000..90d0e0d8fff --- /dev/null +++ b/go/vt/vtadmin/vtadmin2/templates/vexplain.html @@ -0,0 +1,40 @@ +{{ define "vexplain.html" }} +{{ template "layout" . }} +{{ end }} + +{{ define "content" }} + + VExplain + + + + + Cluster + + {{ range .Data.Form.Clusters }} + {{ .GetName }} ({{ .GetId }}) + {{ end }} + + + + Keyspace + + {{ range .Data.Form.Keyspaces }} + {{ if eq (clusterID .GetCluster) $.Data.Form.SelectedCluster }} + {{ keyspaceName . }} + {{ end }} + {{ end }} + + + SQL {{ .Data.SQL }} + Run VExplain + + +{{ if .Data.Response }} + + Result + {{ .Data.Response.GetResponse }} + {{ protoJSON .Data.Response }} + +{{ end }} +{{ end }} diff --git a/go/vt/vtadmin/vtadmin2/templates/vschema.html b/go/vt/vtadmin/vtadmin2/templates/vschema.html new file mode 100644 index 00000000000..52f09d4572a --- /dev/null +++ b/go/vt/vtadmin/vtadmin2/templates/vschema.html @@ -0,0 +1,18 @@ +{{ define "vschema.html" }} +{{ template "layout" . }} +{{ end }} + +{{ define "content" }} + + {{ .Data.GetName }} + + + + + Cluster{{ clusterID .Data.GetCluster }} + Keyspace{{ .Data.GetName }} + Details{{ protoJSON .Data.GetVSchema }} + + + +{{ end }} diff --git a/go/vt/vtadmin/vtadmin2/templates/vschemas.html b/go/vt/vtadmin/vtadmin2/templates/vschemas.html new file mode 100644 index 00000000000..1483ca48cf0 --- /dev/null +++ b/go/vt/vtadmin/vtadmin2/templates/vschemas.html @@ -0,0 +1,28 @@ +{{ define "vschemas.html" }} +{{ template "layout" . }} +{{ end }} + +{{ define "content" }} + + VSchema + + + + + + ClusterKeyspaceDetails + + + {{ range .Data }} + + {{ clusterID .GetCluster }} + {{ .GetName }} + {{ protoJSON .GetVSchema }} + + {{ else }} + No VSchema found. + {{ end }} + + + +{{ end }} diff --git a/go/vt/vtadmin/vtadmin2/templates/vtctlds.html b/go/vt/vtadmin/vtadmin2/templates/vtctlds.html new file mode 100644 index 00000000000..8c1d57164f9 --- /dev/null +++ b/go/vt/vtadmin/vtadmin2/templates/vtctlds.html @@ -0,0 +1,28 @@ +{{ define "vtctlds.html" }} +{{ template "layout" . }} +{{ end }} + +{{ define "content" }} + + VTCtlds + + + + + + ClusterHostnameFQDN + + + {{ range .Data }} + + {{ clusterID .GetCluster }} + {{ if .GetHostname }}{{ .GetHostname }}{{ end }} + {{ if .GetFQDN }}{{ .GetFQDN }}{{ end }} + + {{ else }} + No VTCtlds found. + {{ end }} + + + +{{ end }} diff --git a/go/vt/vtadmin/vtadmin2/templates/vtexplain.html b/go/vt/vtadmin/vtadmin2/templates/vtexplain.html new file mode 100644 index 00000000000..572efa4e59f --- /dev/null +++ b/go/vt/vtadmin/vtadmin2/templates/vtexplain.html @@ -0,0 +1,40 @@ +{{ define "vtexplain.html" }} +{{ template "layout" . }} +{{ end }} + +{{ define "content" }} + + VTExplain + + + + + Cluster + + {{ range .Data.Form.Clusters }} + {{ .GetName }} ({{ .GetId }}) + {{ end }} + + + + Keyspace + + {{ range .Data.Form.Keyspaces }} + {{ if eq (clusterID .GetCluster) $.Data.Form.SelectedCluster }} + {{ keyspaceName . }} + {{ end }} + {{ end }} + + + SQL {{ .Data.SQL }} + Run VTExplain + + +{{ if .Data.Response }} + + Result + {{ .Data.Response.GetResponse }} + {{ protoJSON .Data.Response }} + +{{ end }} +{{ end }} diff --git a/go/vt/vtadmin/vtadmin2/templates/workflow.html b/go/vt/vtadmin/vtadmin2/templates/workflow.html new file mode 100644 index 00000000000..d4f2fdf43e3 --- /dev/null +++ b/go/vt/vtadmin/vtadmin2/templates/workflow.html @@ -0,0 +1,182 @@ +{{ define "workflow.html" }} +{{ template "layout" . }} +{{ end }} + +{{ define "content" }} + + {{ .Data.GetWorkflow.GetName }} + + + + + Cluster{{ clusterID .Data.GetCluster }} + Keyspace{{ .Data.GetKeyspace }} + Workflow{{ .Data.GetWorkflow.GetName }} + Type{{ .Data.GetWorkflow.GetWorkflowType }} + Subtype{{ .Data.GetWorkflow.GetWorkflowSubType }} + StatusWorkflow Status + Details{{ protoJSON .Data.GetWorkflow }} + + + + + Streams + + Stream keyShardState + + {{ range $shardKey, $shardStream := .Data.GetWorkflow.GetShardStreams }} + {{ range $shardStream.GetStreams }} + + {{ .GetTablet.GetCell }}-{{ .GetTablet.GetUid }}:{{ .GetId }} + {{ .GetShard }} + {{ .GetState }} + + {{ end }} + {{ else }} + No streams found. + {{ end }} + + + +{{ if not .ReadOnly }} + + Workflow actions + + + Lifecycle + + + + Start workflow + + + + Stop workflow + + + + {{ $wfType := .Data.GetWorkflow.GetWorkflowType }} + {{ if or (eq $wfType "MoveTables") (eq $wfType "Reshard") }} + + Switch traffic + + + + Tablet types + PRIMARY + REPLICA + RDONLY + If none are selected, PRIMARY, REPLICA, and RDONLY are switched. + + + Cells + + + + Timeout (seconds) + + + + Max replication lag allowed (seconds) + + + + + Enable reverse replication + + + + Initialize target sequences + + + + Force + + + + Dry run + + Switch traffic + + + + Reverse traffic + + + + Tablet types + PRIMARY + REPLICA + RDONLY + If none are selected, PRIMARY, REPLICA, and RDONLY are switched. + + + + Force + + Reverse traffic + + + {{ end }} + + VDiff + + + + VDiff UUID (optional) + + + + Source cells + + + + Tables + + Comma-separated. Leave empty to compare all tables in the workflow. + + + + Auto retry on error + + The VDiff is created asynchronously; open the VDiff report to follow its progress. + Create VDiff + + + {{ if .Data.CanComplete }} + + Complete workflow + + + + + + Keep data (do not drop copied tables on the source) + + + + Keep routing rules + + + + Rename tables instead of dropping them + + Complete workflow + + + {{ end }} + + Cancel workflow (delete) + + + + + Keep data + + Cancel workflow + + + + +{{ end }} +{{ end }} diff --git a/go/vt/vtadmin/vtadmin2/templates/workflow_materialize_create.html b/go/vt/vtadmin/vtadmin2/templates/workflow_materialize_create.html new file mode 100644 index 00000000000..2c8911497bc --- /dev/null +++ b/go/vt/vtadmin/vtadmin2/templates/workflow_materialize_create.html @@ -0,0 +1,124 @@ +{{ define "workflow_materialize_create.html" }} +{{ template "layout" . }} +{{ end }} + +{{ define "content" }} +{{ $d := .Data }} + + Create Materialize workflow + Back to workflows + + +Materialize continuously copies data from one keyspace into tables in another, driven by per-table select expressions. Materialize user guide. + + + 1. Choose source + + + Cluster + + {{ range $d.Form.Clusters }} + {{ .GetName }} ({{ .GetId }}) + {{ end }} + + + + Source keyspace + + {{ range $d.Form.Keyspaces }} + {{ if eq (clusterID .GetCluster) $d.SelectedCluster }} + {{ keyspaceName . }} + {{ end }} + {{ end }} + + + + Load reference tables + Reference tables live in the source keyspace and can be copied as-is for use by materialized queries. + + + + +{{ if and $d.SelectedCluster $d.SourceKeyspace }} + + 2. Create the workflow + + + + + + + Workflow name + + + + Target keyspace + + {{ range $d.Form.Keyspaces }} + {{ if eq (clusterID .GetCluster) $d.SelectedCluster }} + {{ keyspaceName . }} + {{ end }} + {{ end }} + + Keyspace where the materialized tables will live. + + + Table settings (JSON) + + JSON defining which tables to materialize and the select expressions to build them from. Provide either table settings or reference tables — not both. + + + {{ if $d.ReferenceTables }} + + Reference tables (in {{ $d.SourceKeyspace }}) + {{ range $d.ReferenceTables }} + + + {{ . }} + + {{ end }} + Selecting reference tables creates a reference-table materialization; leave all unchecked when using table settings. + + {{ end }} + + Advanced + + Cell + + + + Source tablet types + + + REPLICA + + + + PRIMARY + + + + RDONLY + + + + + Prefer tablets in order (REPLICA, then RDONLY) + + + + Stop after copy + + Stops VReplication once the backfill copy finishes, without catching up. + + + Create workflow + + + +{{ else }} + + Pick a cluster and source keyspace above to continue. + +{{ end }} +{{ end }} diff --git a/go/vt/vtadmin/vtadmin2/templates/workflow_movetables_create.html b/go/vt/vtadmin/vtadmin2/templates/workflow_movetables_create.html new file mode 100644 index 00000000000..04bd9b2e168 --- /dev/null +++ b/go/vt/vtadmin/vtadmin2/templates/workflow_movetables_create.html @@ -0,0 +1,151 @@ +{{ define "workflow_movetables_create.html" }} +{{ template "layout" . }} +{{ end }} + +{{ define "content" }} +{{ $d := .Data }} + + Create MoveTables workflow + Back to workflows + + +MoveTables copies tables from a source keyspace into a target keyspace and keeps them in sync via VReplication, enabling zero-downtime migration or resharding. MoveTables user guide. + + + 1. Choose source + + + Cluster + + {{ range $d.Form.Clusters }} + {{ .GetName }} ({{ .GetId }}) + {{ end }} + + + + Source keyspace + + {{ range $d.Form.Keyspaces }} + {{ if eq (clusterID .GetCluster) $d.SelectedCluster }} + {{ keyspaceName . }} + {{ end }} + {{ end }} + + + + Load tables + Reloads this page with the source keyspace's tables so you can pick what to copy. + + + + +{{ if and $d.SelectedCluster $d.SourceKeyspace }} + + 2. Create the workflow + + + + + + + Workflow name + + + + Target keyspace + + {{ range $d.Form.Keyspaces }} + {{ if eq (clusterID .GetCluster) $d.SelectedCluster }} + {{ keyspaceName . }} + {{ end }} + {{ end }} + + + + + Tables to copy from {{ $d.SourceKeyspace }} + {{ if $d.SourceTables }} + {{ range $d.SourceTables }} + + + {{ . }} + + {{ end }} + {{ else }} + Could not list tables for this keyspace; you can still copy all tables. + {{ end }} + + + Copy all tables + + When enabled, every table in the source keyspace is copied and individual selection is ignored. + + + Advanced + + Cells + + Cells and/or cell aliases to copy table data from. Leave empty for all. + + + OnDDL strategy + + IGNORE + STOP + EXEC + EXEC_IGNORE + + What to do when DDL is encountered in the VReplication stream. + + + Source tablet types + + + REPLICA + + + + PRIMARY + + + + RDONLY + + + + Source time zone + + DATETIME fields on the source are converted from this time zone into UTC. + + + External source cluster + + Optional mounted cluster containing the source keyspace. + + + + Stop after copy + + Stops VReplication after the copy phase finishes, without catching up. + + + Defer secondary keys + + Creates secondary indexes in one shot after the table copy finishes instead of during it. + + + Auto start + + Starts the workflow automatically after creating it. + + + Create workflow + + + +{{ else }} + + Pick a cluster and source keyspace above to continue. + +{{ end }} +{{ end }} diff --git a/go/vt/vtadmin/vtadmin2/templates/workflow_reshard_create.html b/go/vt/vtadmin/vtadmin2/templates/workflow_reshard_create.html new file mode 100644 index 00000000000..30984592e21 --- /dev/null +++ b/go/vt/vtadmin/vtadmin2/templates/workflow_reshard_create.html @@ -0,0 +1,130 @@ +{{ define "workflow_reshard_create.html" }} +{{ template "layout" . }} +{{ end }} + +{{ define "content" }} +{{ $d := .Data }} + + Create Reshard workflow + Back to workflows + + +Reshard splits or merges shards within a keyspace: tablets are launched for the new shard ranges and data is copied from the source shards while staying in sync. Reshard user guide. + +{{ if $d.PickCluster }} + + Choose a cluster + Shards are per cluster. Pick the cluster you want to reshard: + + {{ range $d.Form.Clusters }} + {{ .GetName }} ({{ .GetId }}) + {{ end }} + + +{{ else }} + + Reshard details + + + + + + Cluster + {{ range $d.Form.Clusters }} + {{ if eq .GetId $d.SelectedCluster }} + {{ .GetName }} ({{ .GetId }}) + {{ end }} + {{ end }} + + {{ if gt (len $d.Form.Clusters) 1 }} + Choose a different cluster + {{ end }} + + Workflow name + + + + Keyspace + + {{ range $d.Form.Keyspaces }} + {{ if eq (clusterID .GetCluster) $d.SelectedCluster }} + {{ keyspaceName . }} + {{ end }} + {{ end }} + + + + Source shards + + Comma-separated shard names to split or merge from, e.g. "0" or "-80, 80-". + + + Target shards + + Comma-separated shard names to create, e.g. "-80, 80-". + + + Advanced + + Cells + + Cells and/or cell aliases to source the copy from. Leave empty for all. + + + Source tablet types + + + REPLICA + + + + PRIMARY + + + + RDONLY + + + + + Prefer tablets in order (REPLICA, then RDONLY) + + When unchecked, tablets of any type may be picked for the copy. + + OnDDL strategy + + IGNORE + STOP + EXEC + EXEC_IGNORE + + What to do when DDL is encountered in the VReplication stream. + + + + Skip schema copy + + Use when the target shards already have the schema created. + + + Stop after copy + + Stops VReplication once the backfill copy finishes, without catching up. + + + Defer secondary keys + + Creates secondary indexes in one shot after the table copy finishes instead of during it. + + + Auto start + + Starts the workflow automatically after creating it. + + + Create workflow + + + +{{ end }} +{{ end }} diff --git a/go/vt/vtadmin/vtadmin2/templates/workflow_status.html b/go/vt/vtadmin/vtadmin2/templates/workflow_status.html new file mode 100644 index 00000000000..30f927782e7 --- /dev/null +++ b/go/vt/vtadmin/vtadmin2/templates/workflow_status.html @@ -0,0 +1,20 @@ +{{ define "workflow_status.html" }} +{{ template "layout" . }} +{{ end }} + +{{ define "content" }} + + Workflow Status + + + + + Cluster{{ .Data.ClusterID }} + Keyspace{{ .Data.Keyspace }} + Workflow{{ .Data.Name }} + Traffic State{{ .Data.Response.GetTrafficState }} + Details{{ protoJSON .Data.Response }} + + + +{{ end }} diff --git a/go/vt/vtadmin/vtadmin2/templates/workflows.html b/go/vt/vtadmin/vtadmin2/templates/workflows.html new file mode 100644 index 00000000000..ab55351c196 --- /dev/null +++ b/go/vt/vtadmin/vtadmin2/templates/workflows.html @@ -0,0 +1,42 @@ +{{ define "workflows.html" }} +{{ template "layout" . }} +{{ end }} + +{{ define "content" }} + + Workflows + + {{ if not .ReadOnly }} + Create MoveTables workflow + Create Reshard workflow + Create Materialize workflow + {{ end }} + + + + + + + ClusterKeyspaceWorkflowTypeStatus + + + {{ range $clusterID, $clusterWorkflows := .Data }} + {{ range $clusterWorkflows.GetWorkflows }} + + {{ clusterID .GetCluster }} + {{ .GetKeyspace }} + {{ .GetWorkflow.GetName }} + {{ .GetWorkflow.GetWorkflowType }} + Status + + {{ end }} + {{ range $clusterWorkflows.GetWarnings }} + {{ $clusterID }}{{ . }} + {{ end }} + {{ else }} + No workflows found. + {{ end }} + + + +{{ end }} diff --git a/go/vt/vtadmin/vtadmin2/tools.go b/go/vt/vtadmin/vtadmin2/tools.go new file mode 100644 index 00000000000..2a1fd249b41 --- /dev/null +++ b/go/vt/vtadmin/vtadmin2/tools.go @@ -0,0 +1,137 @@ +/* +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" + vtrpcpb "vitess.io/vitess/go/vt/proto/vtrpc" + "vitess.io/vitess/go/vt/vterrors" +) + +type ( + vtExplainData struct { + Cluster string + Keyspace string + SQL string + Response *vtadminpb.VTExplainResponse + Form formOptions + } + + vExplainData struct { + ClusterID string + Keyspace string + SQL string + Response *vtadminpb.VExplainResponse + Form formOptions + } +) + +func (s *Server) vtExplain(w http.ResponseWriter, r *http.Request) { + clusterID := queryValue(r, "cluster_id") + keyspace := queryValue(r, "keyspace") + sql := queryValue(r, "sql") + if len(r.URL.Query()) > 0 { + if clusterID == "" { + s.renderError(w, r, http.StatusBadRequest, "VTExplain", vterrors.Errorf(vtrpcpb.Code_INVALID_ARGUMENT, "cluster_id query parameter is required")) + return + } + if keyspace == "" { + s.renderError(w, r, http.StatusBadRequest, "VTExplain", vterrors.Errorf(vtrpcpb.Code_INVALID_ARGUMENT, "keyspace query parameter is required")) + return + } + if sql == "" { + s.renderError(w, r, http.StatusBadRequest, "VTExplain", vterrors.Errorf(vtrpcpb.Code_INVALID_ARGUMENT, "sql query parameter is required")) + return + } + } + form, err := s.loadFormOptions(r, clusterID, keyspace) + if err != nil { + s.renderError(w, r, http.StatusInternalServerError, "VTExplain", err) + return + } + data := vtExplainData{Cluster: form.SelectedCluster, Keyspace: form.SelectedKeyspace, SQL: sql, Form: form} + if len(r.URL.Query()) == 0 { + s.render(w, r, http.StatusOK, "vtexplain.html", PageData{Title: "VTExplain", Active: "vtexplain", Data: data}) + return + } + + resp, err := s.api.VTExplain(r.Context(), &vtadminpb.VTExplainRequest{ + Cluster: clusterID, + Keyspace: keyspace, + Sql: sql, + }) + if err != nil { + s.renderError(w, r, http.StatusInternalServerError, "VTExplain", err) + return + } + if resp == nil { + http.NotFound(w, r) + return + } + + data.Response = resp + s.render(w, r, http.StatusOK, "vtexplain.html", PageData{Title: "VTExplain", Active: "vtexplain", Data: data}) +} + +func (s *Server) vExplain(w http.ResponseWriter, r *http.Request) { + clusterID := queryValue(r, "cluster_id") + keyspace := queryValue(r, "keyspace") + sql := queryValue(r, "sql") + if len(r.URL.Query()) > 0 { + if clusterID == "" { + s.renderError(w, r, http.StatusBadRequest, "VExplain", vterrors.Errorf(vtrpcpb.Code_INVALID_ARGUMENT, "cluster_id query parameter is required")) + return + } + if keyspace == "" { + s.renderError(w, r, http.StatusBadRequest, "VExplain", vterrors.Errorf(vtrpcpb.Code_INVALID_ARGUMENT, "keyspace query parameter is required")) + return + } + if sql == "" { + s.renderError(w, r, http.StatusBadRequest, "VExplain", vterrors.Errorf(vtrpcpb.Code_INVALID_ARGUMENT, "sql query parameter is required")) + return + } + } + form, err := s.loadFormOptions(r, clusterID, keyspace) + if err != nil { + s.renderError(w, r, http.StatusInternalServerError, "VExplain", err) + return + } + data := vExplainData{ClusterID: form.SelectedCluster, Keyspace: form.SelectedKeyspace, SQL: sql, Form: form} + if len(r.URL.Query()) == 0 { + s.render(w, r, http.StatusOK, "vexplain.html", PageData{Title: "VExplain", Active: "vexplain", Data: data}) + return + } + + resp, err := s.api.VExplain(r.Context(), &vtadminpb.VExplainRequest{ + ClusterId: clusterID, + Keyspace: keyspace, + Sql: sql, + }) + if err != nil { + s.renderError(w, r, http.StatusInternalServerError, "VExplain", err) + return + } + if resp == nil { + http.NotFound(w, r) + return + } + + data.Response = resp + s.render(w, r, http.StatusOK, "vexplain.html", PageData{Title: "VExplain", Active: "vexplain", Data: data}) +} diff --git a/go/vt/vtadmin/vtadmin2/tools_test.go b/go/vt/vtadmin/vtadmin2/tools_test.go new file mode 100644 index 00000000000..701f52c652b --- /dev/null +++ b/go/vt/vtadmin/vtadmin2/tools_test.go @@ -0,0 +1,408 @@ +/* +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" + + vtadminpb "vitess.io/vitess/go/vt/proto/vtadmin" + vtctldatapb "vitess.io/vitess/go/vt/proto/vtctldata" +) + +type toolsFakeServer struct { + fakeVTAdminServer + + vtExplainRequest *vtadminpb.VTExplainRequest + vtExplainError error + vtExplainNil bool + vExplainRequest *vtadminpb.VExplainRequest + vExplainError error + vExplainNil bool + getClustersError error +} + +func (f *toolsFakeServer) VTExplain(ctx context.Context, req *vtadminpb.VTExplainRequest) (*vtadminpb.VTExplainResponse, error) { + f.vtExplainRequest = req + if f.vtExplainError != nil { + return nil, f.vtExplainError + } + if f.vtExplainNil { + return nil, nil + } + return &vtadminpb.VTExplainResponse{Response: "vtgate plan"}, nil +} + +func (f *toolsFakeServer) VExplain(ctx context.Context, req *vtadminpb.VExplainRequest) (*vtadminpb.VExplainResponse, error) { + f.vExplainRequest = req + if f.vExplainError != nil { + return nil, f.vExplainError + } + if f.vExplainNil { + return nil, nil + } + return &vtadminpb.VExplainResponse{Response: "vttablet plan"}, nil +} + +func (f *toolsFakeServer) 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 *toolsFakeServer) 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 TestVTExplainFormOnlyRendersWithoutCallingBackend(t *testing.T) { + fake := &toolsFakeServer{} + s, err := NewServer(fake, Options{}) + require.NoError(t, err) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/vtexplain", nil) + s.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + assert.Contains(t, rec.Body.String(), "VTExplain") + assert.Contains(t, rec.Body.String(), `action="/vtexplain"`) + assert.Contains(t, rec.Body.String(), `name="cluster_id"`) + assert.Contains(t, rec.Body.String(), `name="keyspace"`) + assert.Contains(t, rec.Body.String(), `name="sql"`) + assert.Nil(t, fake.vtExplainRequest) +} + +func TestVTExplainDefaultsClusterAndKeyspaceSelects(t *testing.T) { + fake := &toolsFakeServer{} + s, err := NewServer(fake, Options{}) + require.NoError(t, err) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/vtexplain", nil) + s.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + assert.Contains(t, rec.Body.String(), ``) + assert.Contains(t, rec.Body.String(), `Local (local)`) + assert.Contains(t, rec.Body.String(), ``) + assert.Contains(t, rec.Body.String(), `commerce`) + assert.Nil(t, fake.vtExplainRequest) +} + +func TestVTExplainPassesQueryFieldsAndRendersResponse(t *testing.T) { + fake := &toolsFakeServer{} + s, err := NewServer(fake, Options{}) + require.NoError(t, err) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/vtexplain?cluster_id=local&keyspace=commerce&sql=select+*+from+customer", nil) + s.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + require.NotNil(t, fake.vtExplainRequest) + assert.Equal(t, "local", fake.vtExplainRequest.GetCluster()) + assert.Equal(t, "commerce", fake.vtExplainRequest.GetKeyspace()) + assert.Equal(t, "select * from customer", fake.vtExplainRequest.GetSql()) + assert.Contains(t, rec.Body.String(), "vtgate plan") + assert.Contains(t, rec.Body.String(), ""response"") +} + +func TestVTExplainRequiresClusterForSubmittedSQL(t *testing.T) { + fake := &toolsFakeServer{} + s, err := NewServer(fake, Options{}) + require.NoError(t, err) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/vtexplain?keyspace=commerce&sql=select+1", nil) + s.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusBadRequest, rec.Code) + assert.Contains(t, rec.Body.String(), "cluster") + assert.Nil(t, fake.vtExplainRequest) +} + +func TestVTExplainValidatesSubmissionBeforeLoadingFormOptions(t *testing.T) { + fake := &toolsFakeServer{getClustersError: errors.New("cluster options failed")} + s, err := NewServer(fake, Options{}) + require.NoError(t, err) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/vtexplain?keyspace=commerce&sql=select+1", 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.vtExplainRequest) +} + +func TestVTExplainRejectsBlankSubmission(t *testing.T) { + fake := &toolsFakeServer{} + s, err := NewServer(fake, Options{}) + require.NoError(t, err) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/vtexplain?cluster_id=&keyspace=&sql=", nil) + s.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusBadRequest, rec.Code) + assert.Contains(t, rec.Body.String(), "cluster") + assert.Nil(t, fake.vtExplainRequest) +} + +func TestVTExplainRequiresKeyspaceForSubmittedSQL(t *testing.T) { + fake := &toolsFakeServer{} + s, err := NewServer(fake, Options{}) + require.NoError(t, err) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/vtexplain?cluster_id=local&sql=select+1", nil) + s.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusBadRequest, rec.Code) + assert.Contains(t, rec.Body.String(), "keyspace") + assert.Nil(t, fake.vtExplainRequest) +} + +func TestVTExplainRequiresSQLWhenOtherFieldsAreSupplied(t *testing.T) { + fake := &toolsFakeServer{} + s, err := NewServer(fake, Options{}) + require.NoError(t, err) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/vtexplain?cluster_id=local&keyspace=commerce", nil) + s.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusBadRequest, rec.Code) + assert.Contains(t, rec.Body.String(), "sql") + assert.Nil(t, fake.vtExplainRequest) +} + +func TestVTExplainRendersBackendError(t *testing.T) { + fake := &toolsFakeServer{vtExplainError: errors.New("vtexplain backend failed")} + s, err := NewServer(fake, Options{}) + require.NoError(t, err) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/vtexplain?cluster_id=local&keyspace=commerce&sql=select+1", nil) + s.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusInternalServerError, rec.Code) + assert.Contains(t, rec.Body.String(), "vtexplain backend failed") + require.NotNil(t, fake.vtExplainRequest) +} + +func TestVTExplainReturnsNotFoundForNilResponse(t *testing.T) { + fake := &toolsFakeServer{vtExplainNil: true} + s, err := NewServer(fake, Options{}) + require.NoError(t, err) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/vtexplain?cluster_id=local&keyspace=commerce&sql=select+1", nil) + s.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusNotFound, rec.Code) + require.NotNil(t, fake.vtExplainRequest) +} + +func TestVTExplainHeadUsesGetHandler(t *testing.T) { + fake := &toolsFakeServer{} + s, err := NewServer(fake, Options{}) + require.NoError(t, err) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodHead, "/vtexplain", nil) + s.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + assert.Nil(t, fake.vtExplainRequest) +} + +func TestVExplainFormOnlyRendersWithoutCallingBackend(t *testing.T) { + fake := &toolsFakeServer{} + s, err := NewServer(fake, Options{}) + require.NoError(t, err) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/vexplain", nil) + s.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + assert.Contains(t, rec.Body.String(), "VExplain") + assert.Contains(t, rec.Body.String(), `action="/vexplain"`) + assert.Contains(t, rec.Body.String(), `name="cluster_id"`) + assert.Contains(t, rec.Body.String(), `name="keyspace"`) + assert.Contains(t, rec.Body.String(), `name="sql"`) + assert.Nil(t, fake.vExplainRequest) +} + +func TestVExplainDefaultsClusterAndKeyspaceSelects(t *testing.T) { + fake := &toolsFakeServer{} + s, err := NewServer(fake, Options{}) + require.NoError(t, err) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/vexplain", nil) + s.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + assert.Contains(t, rec.Body.String(), ``) + assert.Contains(t, rec.Body.String(), `Local (local)`) + assert.Contains(t, rec.Body.String(), ``) + assert.Contains(t, rec.Body.String(), `commerce`) + assert.Nil(t, fake.vExplainRequest) +} + +func TestVExplainPassesQueryFieldsAndRendersResponse(t *testing.T) { + fake := &toolsFakeServer{} + s, err := NewServer(fake, Options{}) + require.NoError(t, err) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/vexplain?cluster_id=local&keyspace=commerce&sql=select+*+from+customer", nil) + s.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + require.NotNil(t, fake.vExplainRequest) + assert.Equal(t, "local", fake.vExplainRequest.GetClusterId()) + assert.Equal(t, "commerce", fake.vExplainRequest.GetKeyspace()) + assert.Equal(t, "select * from customer", fake.vExplainRequest.GetSql()) + assert.Contains(t, rec.Body.String(), "vttablet plan") + assert.Contains(t, rec.Body.String(), ""response"") +} + +func TestVExplainRequiresClusterIDForSubmittedSQL(t *testing.T) { + fake := &toolsFakeServer{} + s, err := NewServer(fake, Options{}) + require.NoError(t, err) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/vexplain?keyspace=commerce&sql=select+1", nil) + s.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusBadRequest, rec.Code) + assert.Contains(t, rec.Body.String(), "cluster_id") + assert.Nil(t, fake.vExplainRequest) +} + +func TestVExplainValidatesSubmissionBeforeLoadingFormOptions(t *testing.T) { + fake := &toolsFakeServer{getClustersError: errors.New("cluster options failed")} + s, err := NewServer(fake, Options{}) + require.NoError(t, err) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/vexplain?keyspace=commerce&sql=select+1", 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.vExplainRequest) +} + +func TestVExplainRejectsBlankSubmission(t *testing.T) { + fake := &toolsFakeServer{} + s, err := NewServer(fake, Options{}) + require.NoError(t, err) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/vexplain?cluster_id=&keyspace=&sql=", nil) + s.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusBadRequest, rec.Code) + assert.Contains(t, rec.Body.String(), "cluster_id") + assert.Nil(t, fake.vExplainRequest) +} + +func TestVExplainRequiresKeyspaceForSubmittedSQL(t *testing.T) { + fake := &toolsFakeServer{} + s, err := NewServer(fake, Options{}) + require.NoError(t, err) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/vexplain?cluster_id=local&sql=select+1", nil) + s.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusBadRequest, rec.Code) + assert.Contains(t, rec.Body.String(), "keyspace") + assert.Nil(t, fake.vExplainRequest) +} + +func TestVExplainRequiresSQLWhenOtherFieldsAreSupplied(t *testing.T) { + fake := &toolsFakeServer{} + s, err := NewServer(fake, Options{}) + require.NoError(t, err) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/vexplain?cluster_id=local&keyspace=commerce", nil) + s.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusBadRequest, rec.Code) + assert.Contains(t, rec.Body.String(), "sql") + assert.Nil(t, fake.vExplainRequest) +} + +func TestVExplainRendersBackendError(t *testing.T) { + fake := &toolsFakeServer{vExplainError: errors.New("vexplain backend failed")} + s, err := NewServer(fake, Options{}) + require.NoError(t, err) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/vexplain?cluster_id=local&keyspace=commerce&sql=select+1", nil) + s.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusInternalServerError, rec.Code) + assert.Contains(t, rec.Body.String(), "vexplain backend failed") + require.NotNil(t, fake.vExplainRequest) +} + +func TestVExplainReturnsNotFoundForNilResponse(t *testing.T) { + fake := &toolsFakeServer{vExplainNil: true} + s, err := NewServer(fake, Options{}) + require.NoError(t, err) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/vexplain?cluster_id=local&keyspace=commerce&sql=select+1", nil) + s.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusNotFound, rec.Code) + require.NotNil(t, fake.vExplainRequest) +} + +func TestVExplainHeadUsesGetHandler(t *testing.T) { + fake := &toolsFakeServer{} + s, err := NewServer(fake, Options{}) + require.NoError(t, err) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodHead, "/vexplain", nil) + s.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + assert.Nil(t, fake.vExplainRequest) +} diff --git a/go/vt/vtadmin/vtadmin2/topology.go b/go/vt/vtadmin/vtadmin2/topology.go new file mode 100644 index 00000000000..8e6a49b77d4 --- /dev/null +++ b/go/vt/vtadmin/vtadmin2/topology.go @@ -0,0 +1,96 @@ +/* +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" + vtctldatapb "vitess.io/vitess/go/vt/proto/vtctldata" + vtrpcpb "vitess.io/vitess/go/vt/proto/vtrpc" + "vitess.io/vitess/go/vt/vterrors" +) + +type ( + topologyPathData struct { + Clusters []*vtadminpb.Cluster + ClusterID string + Path string + Response *vtctldatapb.GetTopologyPathResponse + } +) + +func (s *Server) topologyPath(w http.ResponseWriter, r *http.Request) { + clustersResp, err := s.api.GetClusters(r.Context(), &vtadminpb.GetClustersRequest{}) + if err != nil { + s.renderError(w, r, http.StatusInternalServerError, "Topology", err) + return + } + clusters := clustersResp.GetClusters() + clusterID := selectedClusterID(clusters, queryValue(r, "cluster_id"), cookieValue(r, defaultClusterCookieName)) + if clusterID == "" { + s.renderError(w, r, http.StatusBadRequest, "Topology", vterrors.Errorf(vtrpcpb.Code_INVALID_ARGUMENT, "cluster_id is required")) + return + } + + path := queryValue(r, "path") + if path == "" { + path = "/" + } + + resp, err := s.api.GetTopologyPath(r.Context(), &vtadminpb.GetTopologyPathRequest{ + ClusterId: clusterID, + Path: path, + }) + if err != nil { + s.renderError(w, r, http.StatusInternalServerError, "Topology", err) + return + } + if resp == nil { + http.NotFound(w, r) + return + } + + s.render(w, r, http.StatusOK, "topology.html", PageData{ + Title: "Topology", + Active: "topology", + Data: topologyPathData{ + Clusters: clusters, + ClusterID: clusterID, + Path: path, + Response: resp, + }, + }) +} + +func (s *Server) shards(w http.ResponseWriter, r *http.Request) { + resp, err := s.api.GetShardReplicationPositions(r.Context(), &vtadminpb.GetShardReplicationPositionsRequest{ + ClusterIds: queryValues(r, "cluster_id"), + Keyspaces: queryValues(r, "keyspace"), + KeyspaceShards: queryValues(r, "keyspace_shard"), + }) + if err != nil { + s.renderError(w, r, http.StatusInternalServerError, "Shards", err) + return + } + + s.render(w, r, http.StatusOK, "shards.html", PageData{ + Title: "Shards", + Active: "shards", + Data: resp.GetReplicationPositions(), + }) +} diff --git a/go/vt/vtadmin/vtadmin2/topology_test.go b/go/vt/vtadmin/vtadmin2/topology_test.go new file mode 100644 index 00000000000..4fc7b7bee4f --- /dev/null +++ b/go/vt/vtadmin/vtadmin2/topology_test.go @@ -0,0 +1,136 @@ +/* +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" + vtctldatapb "vitess.io/vitess/go/vt/proto/vtctldata" +) + +type topologyFakeServer struct { + fakeVTAdminServer + topologyPathRequest *vtadminpb.GetTopologyPathRequest + shardReplicationPositionsRequest *vtadminpb.GetShardReplicationPositionsRequest + getTopologyPathNil bool +} + +func (f *topologyFakeServer) GetClusters(ctx context.Context, req *vtadminpb.GetClustersRequest) (*vtadminpb.GetClustersResponse, error) { + return &vtadminpb.GetClustersResponse{Clusters: []*vtadminpb.Cluster{{Id: "local", Name: "Local"}, {Id: "prod", Name: "Prod"}}}, nil +} + +func (f *topologyFakeServer) GetTopologyPath(ctx context.Context, req *vtadminpb.GetTopologyPathRequest) (*vtctldatapb.GetTopologyPathResponse, error) { + f.topologyPathRequest = req + if f.getTopologyPathNil { + return nil, nil + } + return &vtctldatapb.GetTopologyPathResponse{ + Cell: &vtctldatapb.TopologyCell{ + Name: "zone1", + Path: "/vitess", + Children: []string{"keyspaces"}, + }, + }, nil +} + +func (f *topologyFakeServer) GetShardReplicationPositions(ctx context.Context, req *vtadminpb.GetShardReplicationPositionsRequest) (*vtadminpb.GetShardReplicationPositionsResponse, error) { + f.shardReplicationPositionsRequest = req + return &vtadminpb.GetShardReplicationPositionsResponse{ + ReplicationPositions: []*vtadminpb.ClusterShardReplicationPosition{ + { + Cluster: &vtadminpb.Cluster{Id: "local", Name: "Local"}, + Keyspace: "commerce", + Shard: "0", + PositionInfo: &vtctldatapb.ShardReplicationPositionsResponse{}, + }, + }, + }, nil +} + +func TestTopologyPathPageCallsServerAndRendersResponse(t *testing.T) { + fake := &topologyFakeServer{} + s, err := NewServer(fake, Options{}) + require.NoError(t, err) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/topology?cluster_id=local&path=/vitess", nil) + s.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + require.NotNil(t, fake.topologyPathRequest) + assert.Equal(t, "local", fake.topologyPathRequest.GetClusterId()) + assert.Equal(t, "/vitess", fake.topologyPathRequest.GetPath()) + assert.Contains(t, rec.Body.String(), "Topology") + assert.Contains(t, rec.Body.String(), "/vitess") + assert.Contains(t, rec.Body.String(), "keyspaces") +} + +func TestTopologyPathPageDefaultsClusterSelect(t *testing.T) { + fake := &topologyFakeServer{} + s, err := NewServer(fake, Options{}) + require.NoError(t, err) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/topology", nil) + s.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + require.NotNil(t, fake.topologyPathRequest) + assert.Equal(t, "local", fake.topologyPathRequest.GetClusterId()) + assert.Equal(t, "/", fake.topologyPathRequest.GetPath()) + assert.Contains(t, rec.Body.String(), ``) + assert.Contains(t, rec.Body.String(), `Local (local)`) + assert.Contains(t, rec.Body.String(), `Prod (prod)`) +} + +func TestTopologyPathNilResponseReturnsNotFound(t *testing.T) { + fake := &topologyFakeServer{getTopologyPathNil: true} + s, err := NewServer(fake, Options{}) + require.NoError(t, err) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/topology?cluster_id=local&path=/vitess", nil) + s.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusNotFound, rec.Code) +} + +func TestShardsPageCallsServerAndRendersRows(t *testing.T) { + fake := &topologyFakeServer{} + s, err := NewServer(fake, Options{}) + require.NoError(t, err) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/shards?cluster_id=local&keyspace=commerce&keyspace_shard=commerce/0", nil) + s.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + require.NotNil(t, fake.shardReplicationPositionsRequest) + assert.Equal(t, []string{"local"}, fake.shardReplicationPositionsRequest.GetClusterIds()) + assert.Equal(t, []string{"commerce"}, fake.shardReplicationPositionsRequest.GetKeyspaces()) + assert.Equal(t, []string{"commerce/0"}, fake.shardReplicationPositionsRequest.GetKeyspaceShards()) + assert.Contains(t, rec.Body.String(), "local") + assert.Contains(t, rec.Body.String(), "commerce") + assert.Contains(t, rec.Body.String(), "0") +} diff --git a/go/vt/vtadmin/vtadmin2/topology_tree.go b/go/vt/vtadmin/vtadmin2/topology_tree.go new file mode 100644 index 00000000000..a97fb1ee9a9 --- /dev/null +++ b/go/vt/vtadmin/vtadmin2/topology_tree.go @@ -0,0 +1,120 @@ +/* +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" + "strings" + + topodatapb "vitess.io/vitess/go/vt/proto/topodata" + vtadminpb "vitess.io/vitess/go/vt/proto/vtadmin" +) + +type ( + topologyTreeData struct { + ClusterID string + Cells []*topologyCell + Keyspaces []*topologyKeyspace + } + + topologyCell struct { + Name string + TabletCount int + } + + topologyKeyspace struct { + Name string + Shards []*topologyShard + } + + topologyShard struct { + Name string + PrimaryAlias string + Tablets []string + } +) + +// topologyTree renders a read-only hierarchy of a cluster's topology: the +// cells with tablet counts, and each keyspace's shards with per-shard tablet +// links. It replaces the SPA's interactive d3 graph with a printable, +// linkable page. +func (s *Server) topologyTree(w http.ResponseWriter, r *http.Request) { + clusterID := r.PathValue("cluster_id") + + keyspacesResp, err := s.api.GetKeyspaces(r.Context(), &vtadminpb.GetKeyspacesRequest{ + ClusterIds: []string{clusterID}, + }) + if err != nil { + s.renderError(w, r, http.StatusInternalServerError, "Topology", err) + return + } + + tabletsResp, err := s.api.GetTablets(r.Context(), &vtadminpb.GetTabletsRequest{ + ClusterIds: []string{clusterID}, + }) + if err != nil { + s.renderError(w, r, http.StatusInternalServerError, "Topology", err) + return + } + + data := buildTopologyTree(clusterID, keyspacesResp.GetKeyspaces(), tabletsResp.GetTablets()) + + s.render(w, r, http.StatusOK, "topology_tree.html", PageData{ + Title: "Topology: " + clusterID, + Active: "topology", + Data: data, + }) +} + +func buildTopologyTree(clusterID string, keyspaces []*vtadminpb.Keyspace, tablets []*vtadminpb.Tablet) topologyTreeData { + data := topologyTreeData{ClusterID: clusterID} + + // Cells, with tablet counts per cell. + cellTablets := map[string]int{} + for _, t := range tablets { + cellTablets[t.GetTablet().GetAlias().GetCell()]++ + } + for cell := range cellTablets { + data.Cells = append(data.Cells, &topologyCell{Name: cell, TabletCount: cellTablets[cell]}) + } + slices.SortFunc(data.Cells, func(a, b *topologyCell) int { return strings.Compare(a.Name, b.Name) }) + + // Keyspaces with per-shard tablet alias lists. + for _, ks := range keyspaces { + tk := &topologyKeyspace{Name: ks.GetKeyspace().GetName()} + for _, shardName := range sortedShardNames(ks) { + shard := &topologyShard{Name: shardName} + for _, t := range tablets { + if t.GetTablet().GetKeyspace() != ks.GetKeyspace().GetName() || t.GetTablet().GetShard() != shardName { + continue + } + alias := tabletAlias(t.GetTablet().GetAlias()) + shard.Tablets = append(shard.Tablets, alias) + if t.GetTablet().GetType() == topodatapb.TabletType_PRIMARY { + shard.PrimaryAlias = alias + } + } + slices.Sort(shard.Tablets) + tk.Shards = append(tk.Shards, shard) + } + data.Keyspaces = append(data.Keyspaces, tk) + } + slices.SortFunc(data.Keyspaces, func(a, b *topologyKeyspace) int { return strings.Compare(a.Name, b.Name) }) + + return data +} diff --git a/go/vt/vtadmin/vtadmin2/topology_tree_test.go b/go/vt/vtadmin/vtadmin2/topology_tree_test.go new file mode 100644 index 00000000000..d7a95785e8a --- /dev/null +++ b/go/vt/vtadmin/vtadmin2/topology_tree_test.go @@ -0,0 +1,121 @@ +/* +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" + "slices" + "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" + vtctldatapb "vitess.io/vitess/go/vt/proto/vtctldata" +) + +type topologyTreeFakeServer struct { + fakeVTAdminServer +} + +func (f *topologyTreeFakeServer) GetKeyspaces(ctx context.Context, req *vtadminpb.GetKeyspacesRequest) (*vtadminpb.GetKeyspacesResponse, error) { + if !slices.Contains(req.ClusterIds, "local") && len(req.ClusterIds) > 0 { + return &vtadminpb.GetKeyspacesResponse{}, nil + } + return &vtadminpb.GetKeyspacesResponse{Keyspaces: []*vtadminpb.Keyspace{ + { + Cluster: &vtadminpb.Cluster{Id: "local"}, + Keyspace: &vtctldatapb.Keyspace{Name: "commerce"}, + Shards: map[string]*vtctldatapb.Shard{ + "0": {Name: "0"}, + "-80": {Name: "-80"}, + }, + }, + { + Cluster: &vtadminpb.Cluster{Id: "local"}, + Keyspace: &vtctldatapb.Keyspace{Name: "customer"}, + Shards: map[string]*vtctldatapb.Shard{ + "-": {Name: "-"}, + }, + }, + }}, nil +} + +func (f *topologyTreeFakeServer) GetTablets(ctx context.Context, req *vtadminpb.GetTabletsRequest) (*vtadminpb.GetTabletsResponse, error) { + if !slices.Contains(req.ClusterIds, "local") && len(req.ClusterIds) > 0 { + return &vtadminpb.GetTabletsResponse{}, nil + } + tablet := func(cell string, uid uint32, ks, shard string, typ topodatapb.TabletType) *vtadminpb.Tablet { + return &vtadminpb.Tablet{ + Cluster: &vtadminpb.Cluster{Id: "local"}, + Tablet: &topodatapb.Tablet{ + Alias: &topodatapb.TabletAlias{Cell: cell, Uid: uid}, + Keyspace: ks, + Shard: shard, + Type: typ, + }, + } + } + return &vtadminpb.GetTabletsResponse{Tablets: []*vtadminpb.Tablet{ + tablet("zone1", 100, "commerce", "0", topodatapb.TabletType_PRIMARY), + tablet("zone1", 101, "commerce", "0", topodatapb.TabletType_REPLICA), + tablet("zone2", 200, "commerce", "-80", topodatapb.TabletType_REPLICA), + }}, nil +} + +func TestTopologyTreeRendersHierarchy(t *testing.T) { + fake := &topologyTreeFakeServer{} + s, err := NewServer(fake, Options{}) + require.NoError(t, err) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/topology/local", nil) + s.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + body := rec.Body.String() + + // Cells. + assert.Contains(t, body, "zone1") + assert.Contains(t, body, "zone2") + // Keyspaces. + assert.Contains(t, body, "commerce") + assert.Contains(t, body, "customer") + // Shards. + assert.Contains(t, body, "-80") + // Tablet links. + assert.Contains(t, body, "/tablet/local/zone1-0000000100") + // Tablet counts. + assert.Contains(t, body, "2") +} + +func TestTopologyTreeUnknownCluster(t *testing.T) { + fake := &topologyTreeFakeServer{} + s, err := NewServer(fake, Options{}) + require.NoError(t, err) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/topology/bogus", nil) + s.ServeHTTP(rec, req) + + // An unknown cluster simply renders an empty tree with a note. + require.Equal(t, http.StatusOK, rec.Code) + assert.Contains(t, rec.Body.String(), "No keyspaces found.") +} diff --git a/go/vt/vtadmin/vtadmin2/transaction_actions.go b/go/vt/vtadmin/vtadmin2/transaction_actions.go new file mode 100644 index 00000000000..d0de0ac0bc0 --- /dev/null +++ b/go/vt/vtadmin/vtadmin2/transaction_actions.go @@ -0,0 +1,50 @@ +/* +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" +) + +func (s *Server) transactionConclude(w http.ResponseWriter, r *http.Request) { + const title = "Conclude transaction" + if !s.beginFormAction(w, r, title) { + return + } + + clusterID := r.PathValue("cluster_id") + dtid := r.PathValue("dtid") + + _, err := s.api.ConcludeTransaction(r.Context(), &vtadminpb.ConcludeTransactionRequest{ + ClusterId: clusterID, + Dtid: dtid, + }) + if err != nil { + s.renderFormErrorErr(w, r, title, err) + return + } + + // Redirect to the unfiltered transactions page: the transactions handler + // requires both cluster_id and keyspace when any query parameters are + // present, and this action is not keyed to a single keyspace. + s.redirectWithFlash(w, r, "/transactions", Flash{ + Kind: "success", + Message: "concluded transaction " + dtid, + }) +} diff --git a/go/vt/vtadmin/vtadmin2/transaction_actions_test.go b/go/vt/vtadmin/vtadmin2/transaction_actions_test.go new file mode 100644 index 00000000000..ee404ed83bb --- /dev/null +++ b/go/vt/vtadmin/vtadmin2/transaction_actions_test.go @@ -0,0 +1,109 @@ +/* +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" + + 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 transactionActionsFakeServer struct { + fakeVTAdminServer + concludeTransactionReq *vtadminpb.ConcludeTransactionRequest +} + +func (f *transactionActionsFakeServer) GetUnresolvedTransactions(ctx context.Context, req *vtadminpb.GetUnresolvedTransactionsRequest) (*vtctldatapb.GetUnresolvedTransactionsResponse, error) { + return &vtctldatapb.GetUnresolvedTransactionsResponse{ + Transactions: []*querypb.TransactionMetadata{ + {Dtid: "transaction-id-1"}, + }, + }, nil +} + +func (f *transactionActionsFakeServer) GetClusters(ctx context.Context, req *vtadminpb.GetClustersRequest) (*vtadminpb.GetClustersResponse, error) { + return &vtadminpb.GetClustersResponse{Clusters: []*vtadminpb.Cluster{ + {Id: testClusterID, Name: "Local"}, + }}, nil +} + +func (f *transactionActionsFakeServer) GetKeyspaces(ctx context.Context, req *vtadminpb.GetKeyspacesRequest) (*vtadminpb.GetKeyspacesResponse, error) { + return &vtadminpb.GetKeyspacesResponse{Keyspaces: []*vtadminpb.Keyspace{ + { + Cluster: &vtadminpb.Cluster{Id: testClusterID}, + Keyspace: &vtctldatapb.Keyspace{Name: "commerce"}, + }, + }}, nil +} + +func (f *transactionActionsFakeServer) ConcludeTransaction(ctx context.Context, req *vtadminpb.ConcludeTransactionRequest) (*vtctldatapb.ConcludeTransactionResponse, error) { + f.concludeTransactionReq = req + return &vtctldatapb.ConcludeTransactionResponse{}, nil +} + +func TestTransactionConclude(t *testing.T) { + fake := &transactionActionsFakeServer{} + s, err := NewServer(fake, Options{}) + require.NoError(t, err) + + form := url.Values{} + rec := postShardForm(t, s, "/transaction/local/transaction-id-1/conclude", form) + + assert.Equal(t, http.StatusSeeOther, rec.Code) + // Redirect to the unfiltered list: the filtered view requires both + // cluster_id and keyspace, which this action does not key to. + assert.Equal(t, "/transactions", rec.Header().Get("Location")) + + req := fake.concludeTransactionReq + require.NotNil(t, req) + assert.Equal(t, testClusterID, req.ClusterId) + assert.Equal(t, "transaction-id-1", req.Dtid) +} + +func TestTransactionConcludeReadOnly(t *testing.T) { + fake := &transactionActionsFakeServer{} + s, err := NewServer(fake, Options{ReadOnly: true}) + require.NoError(t, err) + + rec := postShardForm(t, s, "/transaction/local/transaction-id-1/conclude", url.Values{}) + + assert.Equal(t, http.StatusForbidden, rec.Code) + assert.Nil(t, fake.concludeTransactionReq) +} + +func TestTransactionsListRendersConcludeAction(t *testing.T) { + fake := &transactionActionsFakeServer{} + 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=600", nil) + s.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + assert.Contains(t, rec.Body.String(), "transaction-id-1") + assert.Contains(t, rec.Body.String(), "/transaction/local/transaction-id-1/conclude") +} diff --git a/go/vt/vtadmin/vtadmin2/view.go b/go/vt/vtadmin/vtadmin2/view.go new file mode 100644 index 00000000000..0dc16b60c85 --- /dev/null +++ b/go/vt/vtadmin/vtadmin2/view.go @@ -0,0 +1,148 @@ +/* +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 ( + "html/template" + "maps" + "net/http" + "net/url" + "slices" + "strings" + + "google.golang.org/protobuf/encoding/protojson" + "google.golang.org/protobuf/proto" + + "vitess.io/vitess/go/vt/topo/topoproto" + + topodatapb "vitess.io/vitess/go/vt/proto/topodata" + vtadminpb "vitess.io/vitess/go/vt/proto/vtadmin" +) + +type ( + formOptions struct { + Clusters []*vtadminpb.Cluster + Keyspaces []*vtadminpb.Keyspace + SelectedCluster string + SelectedKeyspace string + } +) + +func queryValues(r *http.Request, name string) []string { return r.URL.Query()[name] } + +func queryValue(r *http.Request, name string) string { return r.URL.Query().Get(name) } + +func hasNonRefreshQuery(r *http.Request) bool { + for key := range r.URL.Query() { + if key != "refresh" { + return true + } + } + return false +} + +func pathEscape(value string) string { return url.PathEscape(value) } + +func urlQueryEscape(value string) template.URL { + return template.URL(url.QueryEscape(value)) +} + +func externalURL(value string) string { + if value == "" { + return "" + } + if strings.HasPrefix(value, "http://") || strings.HasPrefix(value, "https://") { + return value + } + return "http://" + value +} + +func clusterIDExists(clusters []*vtadminpb.Cluster, id string) bool { + return slices.ContainsFunc(clusters, func(c *vtadminpb.Cluster) bool { + return c.GetId() == id + }) +} + +func selectedClusterID(clusters []*vtadminpb.Cluster, requested, defaultCluster string) string { + if requested != "" && clusterIDExists(clusters, requested) { + return requested + } + if defaultCluster != "" && clusterIDExists(clusters, defaultCluster) { + return defaultCluster + } + if len(clusters) == 0 { + return "" + } + return clusters[0].GetId() +} + +func selectedKeyspaceName(keyspaces []*vtadminpb.Keyspace, selectedCluster string, requested string) string { + if requested != "" { + return requested + } + for _, ks := range keyspaces { + if selectedCluster == "" || clusterID(ks.GetCluster()) == selectedCluster { + return keyspaceName(ks) + } + } + return "" +} + +func protoJSON(v any) string { + msg, ok := v.(proto.Message) + if !ok || msg == nil { + return "" + } + + b, err := protojson.MarshalOptions{Multiline: true, Indent: " "}.Marshal(msg) + if err != nil { + return err.Error() + } + return string(b) +} + +func keyspaceName(ks *vtadminpb.Keyspace) string { + if ks == nil || ks.GetKeyspace() == nil { + return "" + } + return ks.GetKeyspace().GetName() +} + +func clusterID(c *vtadminpb.Cluster) string { + if c == nil { + return "" + } + return c.GetId() +} + +func sortedShardNames(ks *vtadminpb.Keyspace) []string { + if ks == nil { + return nil + } + return slices.Sorted(maps.Keys(ks.GetShards())) +} + +func tabletAlias(alias *topodatapb.TabletAlias) string { + return topoproto.TabletAliasString(alias) +} + +func schemaTableCount(schema *vtadminpb.Schema) int { + if schema == nil { + return 0 + } + return len(schema.GetTableDefinitions()) +} diff --git a/go/vt/vtadmin/vtadmin2/view_test.go b/go/vt/vtadmin/vtadmin2/view_test.go new file mode 100644 index 00000000000..19efa7424c4 --- /dev/null +++ b/go/vt/vtadmin/vtadmin2/view_test.go @@ -0,0 +1,60 @@ +/* +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 ( + "testing" + + "github.com/stretchr/testify/assert" + + vtadminpb "vitess.io/vitess/go/vt/proto/vtadmin" + vtctldatapb "vitess.io/vitess/go/vt/proto/vtctldata" +) + +func TestExternalURLPrefixesHTTPForHosts(t *testing.T) { + assert.Equal(t, "http://vtgate.example.com:15001", externalURL("vtgate.example.com:15001")) + assert.Equal(t, "https://vtctld.example.com", externalURL("https://vtctld.example.com")) + assert.Equal(t, "", externalURL("")) +} + +func TestSelectedClusterDefaultsToFirstCluster(t *testing.T) { + clusters := []*vtadminpb.Cluster{{Id: "local", Name: "Local"}, {Id: "prod", Name: "Prod"}} + + assert.Equal(t, "local", selectedClusterID(clusters, "", "")) + assert.Equal(t, "prod", selectedClusterID(clusters, "prod", "")) + // An explicit request wins over the saved default. + assert.Equal(t, "prod", selectedClusterID(clusters, "prod", "local")) + // The saved default cluster wins over the first cluster. + assert.Equal(t, "prod", selectedClusterID(clusters, "", "prod")) + // An unknown default falls back to the first cluster. + assert.Equal(t, "local", selectedClusterID(clusters, "", "bogus")) + // An unknown requested ID is ignored rather than locking an empty form. + assert.Equal(t, "local", selectedClusterID(clusters, "bogus", "")) + assert.Equal(t, "prod", selectedClusterID(clusters, "bogus", "prod")) +} + +func TestSelectedKeyspaceDefaultsToFirstForCluster(t *testing.T) { + keyspaces := []*vtadminpb.Keyspace{ + {Cluster: &vtadminpb.Cluster{Id: "local"}, Keyspace: &vtctldatapb.Keyspace{Name: "commerce"}}, + {Cluster: &vtadminpb.Cluster{Id: "local"}, Keyspace: &vtctldatapb.Keyspace{Name: "customer"}}, + {Cluster: &vtadminpb.Cluster{Id: "prod"}, Keyspace: &vtctldatapb.Keyspace{Name: "commerce_prod"}}, + } + + assert.Equal(t, "commerce", selectedKeyspaceName(keyspaces, "local", "")) + assert.Equal(t, "customer", selectedKeyspaceName(keyspaces, "local", "customer")) + assert.Equal(t, "commerce_prod", selectedKeyspaceName(keyspaces, "prod", "")) +} diff --git a/go/vt/vtadmin/vtadmin2/vschema.go b/go/vt/vtadmin/vtadmin2/vschema.go new file mode 100644 index 00000000000..b0448492301 --- /dev/null +++ b/go/vt/vtadmin/vtadmin2/vschema.go @@ -0,0 +1,127 @@ +/* +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" + vtctldatapb "vitess.io/vitess/go/vt/proto/vtctldata" +) + +func (s *Server) vschemas(w http.ResponseWriter, r *http.Request) { + resp, err := s.api.GetVSchemas(r.Context(), &vtadminpb.GetVSchemasRequest{ + ClusterIds: queryValues(r, "cluster_id"), + }) + if err != nil { + s.renderError(w, r, http.StatusInternalServerError, "VSchema", err) + return + } + + s.render(w, r, http.StatusOK, "vschemas.html", PageData{ + Title: "VSchema", + Active: "vschemas", + Data: resp.GetVSchemas(), + }) +} + +func (s *Server) vschema(w http.ResponseWriter, r *http.Request) { + keyspace := r.PathValue("keyspace") + resp, err := s.api.GetVSchema(r.Context(), &vtadminpb.GetVSchemaRequest{ + ClusterId: r.PathValue("cluster_id"), + Keyspace: keyspace, + }) + if err != nil { + s.renderError(w, r, http.StatusInternalServerError, keyspace, err) + return + } + if resp == nil { + http.NotFound(w, r) + return + } + + s.render(w, r, http.StatusOK, "vschema.html", PageData{ + Title: keyspace, + Active: "vschemas", + Data: resp, + }) +} + +type ( + srvKeyspacesData struct { + Clusters []*vtadminpb.Cluster + SelectedCluster string + SrvKeyspaces map[string]*vtctldatapb.GetSrvKeyspacesResponse + } +) + +// srvKeyspaces requires an explicit cluster: the backing API merges results +// into a map keyed only by keyspace name, so same-named keyspaces from +// different clusters would overwrite one another without cluster attribution. +func (s *Server) srvKeyspaces(w http.ResponseWriter, r *http.Request) { + clustersResp, err := s.api.GetClusters(r.Context(), &vtadminpb.GetClustersRequest{}) + if err != nil { + s.renderError(w, r, http.StatusInternalServerError, "SrvKeyspaces", err) + return + } + + data := srvKeyspacesData{ + Clusters: clustersResp.GetClusters(), + SelectedCluster: queryValue(r, "cluster_id"), + } + if data.SelectedCluster == "" { + s.render(w, r, http.StatusOK, "srvkeyspaces.html", PageData{ + Title: "SrvKeyspaces", + Active: "srvkeyspaces", + Data: data, + }) + return + } + + resp, err := s.api.GetSrvKeyspaces(r.Context(), &vtadminpb.GetSrvKeyspacesRequest{ + ClusterIds: []string{data.SelectedCluster}, + Cells: queryValues(r, "cell"), + }) + if err != nil { + s.renderError(w, r, http.StatusInternalServerError, "SrvKeyspaces", err) + return + } + data.SrvKeyspaces = resp.GetSrvKeyspaces() + + s.render(w, r, http.StatusOK, "srvkeyspaces.html", PageData{ + Title: "SrvKeyspaces", + Active: "srvkeyspaces", + Data: data, + }) +} + +func (s *Server) srvVSchemas(w http.ResponseWriter, r *http.Request) { + resp, err := s.api.GetSrvVSchemas(r.Context(), &vtadminpb.GetSrvVSchemasRequest{ + ClusterIds: queryValues(r, "cluster_id"), + Cells: queryValues(r, "cell"), + }) + if err != nil { + s.renderError(w, r, http.StatusInternalServerError, "SrvVSchemas", err) + return + } + + s.render(w, r, http.StatusOK, "srvvschemas.html", PageData{ + Title: "SrvVSchemas", + Active: "srvvschemas", + Data: resp.GetSrvVSchemas(), + }) +} diff --git a/go/vt/vtadmin/vtadmin2/vschema_test.go b/go/vt/vtadmin/vtadmin2/vschema_test.go new file mode 100644 index 00000000000..db2b878ede0 --- /dev/null +++ b/go/vt/vtadmin/vtadmin2/vschema_test.go @@ -0,0 +1,238 @@ +/* +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" + + topodatapb "vitess.io/vitess/go/vt/proto/topodata" + vschemapb "vitess.io/vitess/go/vt/proto/vschema" + vtadminpb "vitess.io/vitess/go/vt/proto/vtadmin" + vtctldatapb "vitess.io/vitess/go/vt/proto/vtctldata" +) + +type vschemaFakeServer struct { + fakeVTAdminServer + getVSchemasRequest *vtadminpb.GetVSchemasRequest + getVSchemaRequest *vtadminpb.GetVSchemaRequest + getSrvKeyspacesRequest *vtadminpb.GetSrvKeyspacesRequest + getSrvVSchemasRequest *vtadminpb.GetSrvVSchemasRequest + getVSchemasError error + getVSchemaError error + getSrvKeyspacesError error + getSrvVSchemasError error + getVSchemaNilResponse bool +} + +func (f *vschemaFakeServer) GetClusters(ctx context.Context, req *vtadminpb.GetClustersRequest) (*vtadminpb.GetClustersResponse, error) { + return &vtadminpb.GetClustersResponse{Clusters: []*vtadminpb.Cluster{ + {Id: "local", Name: "Local"}, + {Id: "prod", Name: "Prod"}, + }}, nil +} + +func (f *vschemaFakeServer) GetVSchemas(ctx context.Context, req *vtadminpb.GetVSchemasRequest) (*vtadminpb.GetVSchemasResponse, error) { + f.getVSchemasRequest = req + if f.getVSchemasError != nil { + return nil, f.getVSchemasError + } + return &vtadminpb.GetVSchemasResponse{VSchemas: []*vtadminpb.VSchema{{Cluster: &vtadminpb.Cluster{Id: "local", Name: "Local"}, Name: "commerce", VSchema: &vschemapb.Keyspace{Tables: map[string]*vschemapb.Table{"customer": {}}}}}}, nil +} + +func (f *vschemaFakeServer) GetVSchema(ctx context.Context, req *vtadminpb.GetVSchemaRequest) (*vtadminpb.VSchema, error) { + f.getVSchemaRequest = req + if f.getVSchemaError != nil { + return nil, f.getVSchemaError + } + if f.getVSchemaNilResponse { + return nil, nil + } + return &vtadminpb.VSchema{Cluster: &vtadminpb.Cluster{Id: "local", Name: "Local"}, Name: "commerce", VSchema: &vschemapb.Keyspace{Tables: map[string]*vschemapb.Table{"customer": {}}}}, nil +} + +func (f *vschemaFakeServer) GetSrvKeyspaces(ctx context.Context, req *vtadminpb.GetSrvKeyspacesRequest) (*vtadminpb.GetSrvKeyspacesResponse, error) { + f.getSrvKeyspacesRequest = req + if f.getSrvKeyspacesError != nil { + return nil, f.getSrvKeyspacesError + } + return &vtadminpb.GetSrvKeyspacesResponse{SrvKeyspaces: map[string]*vtctldatapb.GetSrvKeyspacesResponse{"commerce": {SrvKeyspaces: map[string]*topodatapb.SrvKeyspace{"zone1": {}}}}}, nil +} + +func (f *vschemaFakeServer) GetSrvVSchemas(ctx context.Context, req *vtadminpb.GetSrvVSchemasRequest) (*vtadminpb.GetSrvVSchemasResponse, error) { + f.getSrvVSchemasRequest = req + if f.getSrvVSchemasError != nil { + return nil, f.getSrvVSchemasError + } + return &vtadminpb.GetSrvVSchemasResponse{SrvVSchemas: []*vtadminpb.SrvVSchema{{Cell: "zone1", Cluster: &vtadminpb.Cluster{Id: "local", Name: "Local"}, SrvVSchema: &vschemapb.SrvVSchema{Keyspaces: map[string]*vschemapb.Keyspace{"commerce": {}}}}}}, nil +} + +func TestVSchemasPageCallsServerAndRendersRows(t *testing.T) { + fake := &vschemaFakeServer{} + s, err := NewServer(fake, Options{}) + require.NoError(t, err) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/vschemas?cluster_id=local", nil) + s.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + require.NotNil(t, fake.getVSchemasRequest) + assert.Equal(t, []string{"local"}, fake.getVSchemasRequest.GetClusterIds()) + assert.Contains(t, rec.Body.String(), "VSchema") + assert.Contains(t, rec.Body.String(), "commerce") + assert.Contains(t, rec.Body.String(), "href=\"/vschema/local/commerce\"") +} + +func TestVSchemasPageBackendErrorReturnsInternalServerError(t *testing.T) { + fake := &vschemaFakeServer{getVSchemasError: errors.New("vschemas backend failed")} + s, err := NewServer(fake, Options{}) + require.NoError(t, err) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/vschemas?cluster_id=local", nil) + s.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusInternalServerError, rec.Code) + assert.Contains(t, rec.Body.String(), "vschemas backend failed") +} + +func TestVSchemaPageCallsServerAndRendersDetails(t *testing.T) { + fake := &vschemaFakeServer{} + s, err := NewServer(fake, Options{}) + require.NoError(t, err) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/vschema/local/commerce", nil) + s.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + require.NotNil(t, fake.getVSchemaRequest) + assert.Equal(t, "local", fake.getVSchemaRequest.GetClusterId()) + assert.Equal(t, "commerce", fake.getVSchemaRequest.GetKeyspace()) + assert.Contains(t, rec.Body.String(), "commerce") + assert.Contains(t, rec.Body.String(), "customer") +} + +func TestVSchemaPageBackendErrorReturnsInternalServerError(t *testing.T) { + fake := &vschemaFakeServer{getVSchemaError: errors.New("vschema backend failed")} + s, err := NewServer(fake, Options{}) + require.NoError(t, err) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/vschema/local/commerce", nil) + s.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusInternalServerError, rec.Code) + assert.Contains(t, rec.Body.String(), "vschema backend failed") +} + +func TestVSchemaDetailNilResponseReturnsNotFound(t *testing.T) { + fake := &vschemaFakeServer{getVSchemaNilResponse: true} + s, err := NewServer(fake, Options{}) + require.NoError(t, err) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/vschema/local/commerce", nil) + s.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusNotFound, rec.Code) +} + +func TestSrvKeyspacesPageCallsServerAndRendersRows(t *testing.T) { + fake := &vschemaFakeServer{} + s, err := NewServer(fake, Options{}) + require.NoError(t, err) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/srvkeyspaces?cluster_id=local&cell=zone1", nil) + s.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + require.NotNil(t, fake.getSrvKeyspacesRequest) + assert.Equal(t, []string{"local"}, fake.getSrvKeyspacesRequest.GetClusterIds()) + assert.Equal(t, []string{"zone1"}, fake.getSrvKeyspacesRequest.GetCells()) + assert.Contains(t, rec.Body.String(), "SrvKeyspaces") + assert.Contains(t, rec.Body.String(), "commerce") + assert.Contains(t, rec.Body.String(), "zone1") +} + +func TestSrvKeyspacesPageRequiresClusterSelection(t *testing.T) { + fake := &vschemaFakeServer{} + s, err := NewServer(fake, Options{}) + require.NoError(t, err) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/srvkeyspaces", nil) + s.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + assert.Nil(t, fake.getSrvKeyspacesRequest) + assert.Contains(t, rec.Body.String(), `name="cluster_id"`) + assert.Contains(t, rec.Body.String(), "local") + assert.Contains(t, rec.Body.String(), "prod") +} + +func TestSrvKeyspacesPageBackendErrorReturnsInternalServerError(t *testing.T) { + fake := &vschemaFakeServer{getSrvKeyspacesError: errors.New("srvkeyspaces backend failed")} + s, err := NewServer(fake, Options{}) + require.NoError(t, err) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/srvkeyspaces?cluster_id=local&cell=zone1", nil) + s.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusInternalServerError, rec.Code) + assert.Contains(t, rec.Body.String(), "srvkeyspaces backend failed") +} + +func TestSrvVSchemasPageCallsServerAndRendersRows(t *testing.T) { + fake := &vschemaFakeServer{} + s, err := NewServer(fake, Options{}) + require.NoError(t, err) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/srvvschemas?cluster_id=local&cell=zone1", nil) + s.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + require.NotNil(t, fake.getSrvVSchemasRequest) + assert.Equal(t, []string{"local"}, fake.getSrvVSchemasRequest.GetClusterIds()) + assert.Equal(t, []string{"zone1"}, fake.getSrvVSchemasRequest.GetCells()) + assert.Contains(t, rec.Body.String(), "SrvVSchemas") + assert.Contains(t, rec.Body.String(), "zone1") + assert.Contains(t, rec.Body.String(), "commerce") +} + +func TestSrvVSchemasPageBackendErrorReturnsInternalServerError(t *testing.T) { + fake := &vschemaFakeServer{getSrvVSchemasError: errors.New("srvvschemas backend failed")} + s, err := NewServer(fake, Options{}) + require.NoError(t, err) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/srvvschemas?cluster_id=local&cell=zone1", nil) + s.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusInternalServerError, rec.Code) + assert.Contains(t, rec.Body.String(), "srvvschemas backend failed") +} diff --git a/go/vt/vtadmin/vtadmin2/workflows.go b/go/vt/vtadmin/vtadmin2/workflows.go new file mode 100644 index 00000000000..b747b7bfac7 --- /dev/null +++ b/go/vt/vtadmin/vtadmin2/workflows.go @@ -0,0 +1,207 @@ +/* +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" + "strings" + + vtadminpb "vitess.io/vitess/go/vt/proto/vtadmin" + vtctldatapb "vitess.io/vitess/go/vt/proto/vtctldata" + vtrpcpb "vitess.io/vitess/go/vt/proto/vtrpc" + vtadminerrors "vitess.io/vitess/go/vt/vtadmin/errors" + "vitess.io/vitess/go/vt/vterrors" +) + +type ( + workflowDetailData struct { + *vtadminpb.Workflow + CanComplete bool + KeepDataChecked bool + } + + workflowStatusData struct { + ClusterID string + Keyspace string + Name string + Response *vtctldatapb.WorkflowStatusResponse + } + + vdiffShowData struct { + ClusterID string + Keyspace string + Workflow string + Arg string + Response *vtadminpb.VDiffShowResponse + Form formOptions + } +) + +func (s *Server) workflows(w http.ResponseWriter, r *http.Request) { + activeOnly, err := parseQueryBool(r, "active_only", false) + if err != nil { + s.renderError(w, r, http.StatusBadRequest, "Workflows", err) + return + } + + resp, err := s.api.GetWorkflows(r.Context(), &vtadminpb.GetWorkflowsRequest{ + ClusterIds: queryValues(r, "cluster_id"), + Keyspaces: queryValues(r, "keyspace"), + IgnoreKeyspaces: queryValues(r, "ignore_keyspace"), + ActiveOnly: activeOnly, + }) + if err != nil { + s.renderError(w, r, http.StatusInternalServerError, "Workflows", err) + return + } + + s.render(w, r, http.StatusOK, "workflows.html", PageData{ + Title: "Workflows", + Active: "workflows", + Data: resp.GetWorkflowsByCluster(), + }) +} + +func (s *Server) workflow(w http.ResponseWriter, r *http.Request) { + activeOnly, err := parseQueryBool(r, "active_only", false) + if err != nil { + s.renderError(w, r, http.StatusBadRequest, r.PathValue("name"), err) + return + } + + resp, err := s.api.GetWorkflow(r.Context(), &vtadminpb.GetWorkflowRequest{ + ClusterId: r.PathValue("cluster_id"), + Keyspace: r.PathValue("keyspace"), + Name: r.PathValue("name"), + ActiveOnly: activeOnly, + }) + if err != nil { + if errors.Is(err, vtadminerrors.ErrNoWorkflow) { + s.renderError(w, r, http.StatusNotFound, r.PathValue("name"), err) + return + } + s.renderError(w, r, http.StatusInternalServerError, r.PathValue("name"), err) + return + } + if resp == nil { + http.NotFound(w, r) + return + } + + canComplete := false + if workflowSupportsTrafficSwitch(resp.GetWorkflow().GetWorkflowType()) { + status, statusErr := s.api.GetWorkflowStatus(r.Context(), &vtadminpb.GetWorkflowStatusRequest{ + ClusterId: r.PathValue("cluster_id"), + Keyspace: r.PathValue("keyspace"), + Name: r.PathValue("name"), + }) + if statusErr == nil { + canComplete = workflowTrafficFullySwitched(status.GetTrafficState()) + } + } + + s.render(w, r, http.StatusOK, "workflow.html", PageData{ + Title: r.PathValue("name"), + Active: "workflows", + NeedsCSRF: !s.opts.ReadOnly, + Data: workflowDetailData{ + Workflow: resp, + CanComplete: canComplete, + KeepDataChecked: strings.HasSuffix(resp.GetWorkflow().GetName(), "_reverse"), + }, + }) +} + +func (s *Server) workflowStatus(w http.ResponseWriter, r *http.Request) { + resp, err := s.api.GetWorkflowStatus(r.Context(), &vtadminpb.GetWorkflowStatusRequest{ + ClusterId: r.PathValue("cluster_id"), + Keyspace: r.PathValue("keyspace"), + Name: r.PathValue("name"), + }) + if err != nil { + s.renderError(w, r, http.StatusInternalServerError, "Workflow Status", err) + return + } + if resp == nil { + http.NotFound(w, r) + return + } + + s.render(w, r, http.StatusOK, "workflow_status.html", PageData{ + Title: "Workflow Status", + Active: "workflows", + Data: workflowStatusData{ + ClusterID: r.PathValue("cluster_id"), + Keyspace: r.PathValue("keyspace"), + Name: r.PathValue("name"), + Response: resp, + }, + }) +} + +func (s *Server) vdiffShow(w http.ResponseWriter, r *http.Request) { + keyspace := queryValue(r, "keyspace") + if keyspace == "" { + s.renderError(w, r, http.StatusBadRequest, "VDiff", vterrors.Errorf(vtrpcpb.Code_INVALID_ARGUMENT, "keyspace query parameter is required")) + return + } + workflow := queryValue(r, "workflow") + if workflow == "" { + s.renderError(w, r, http.StatusBadRequest, "VDiff", vterrors.Errorf(vtrpcpb.Code_INVALID_ARGUMENT, "workflow query parameter is required")) + return + } + form, err := s.loadFormOptions(r, r.PathValue("cluster_id"), keyspace) + if err != nil { + s.renderError(w, r, http.StatusInternalServerError, "VDiff", err) + return + } + arg := queryValue(r, "arg") + if arg == "" { + arg = "last" + } + + resp, err := s.api.VDiffShow(r.Context(), &vtadminpb.VDiffShowRequest{ + ClusterId: r.PathValue("cluster_id"), + Request: &vtctldatapb.VDiffShowRequest{ + TargetKeyspace: keyspace, + Workflow: workflow, + Arg: arg, + }, + }) + if err != nil { + s.renderError(w, r, http.StatusInternalServerError, "VDiff", err) + return + } + if resp == nil { + http.NotFound(w, r) + return + } + + s.render(w, r, http.StatusOK, "vdiff_show.html", PageData{ + Title: "VDiff", + Active: "workflows", + Data: vdiffShowData{ + ClusterID: r.PathValue("cluster_id"), + Keyspace: keyspace, + Workflow: workflow, + Arg: arg, + Response: resp, + Form: form, + }, + }) +} diff --git a/go/vt/vtadmin/vtadmin2/workflows_actions.go b/go/vt/vtadmin/vtadmin2/workflows_actions.go new file mode 100644 index 00000000000..f18d808b582 --- /dev/null +++ b/go/vt/vtadmin/vtadmin2/workflows_actions.go @@ -0,0 +1,362 @@ +/* +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 ( + "cmp" + "net/http" + "net/url" + "slices" + "strings" + + vtadminpb "vitess.io/vitess/go/vt/proto/vtadmin" + vtctldatapb "vitess.io/vitess/go/vt/proto/vtctldata" +) + +// Switch traffic direction values used by WorkflowSwitchTrafficRequest. +// These correspond to TrafficSwitchDirection in go/vt/vtctl/workflow. +const ( + switchDirectionForward = int32(0) + switchDirectionBackward = int32(1) +) + +// beginWorkflowAction is the shared preflight for workflow mutation handlers. +// It returns the parsed route values; ok is false when the preflight has +// already rendered an error response. +func (s *Server) beginWorkflowAction(w http.ResponseWriter, r *http.Request, title string) (clusterID, keyspace, workflow string, ok bool) { + if !s.beginFormAction(w, r, title) { + return "", "", "", false + } + return r.PathValue("cluster_id"), r.PathValue("keyspace"), r.PathValue("name"), true +} + +func workflowDetailPath(clusterID, keyspace, workflow string) string { + return "/workflow/" + pathEscape(clusterID) + "/" + pathEscape(keyspace) + "/" + pathEscape(workflow) +} + +func (s *Server) workflowStart(w http.ResponseWriter, r *http.Request) { + const title = "Start workflow" + clusterID, keyspace, workflow, ok := s.beginWorkflowAction(w, r, title) + if !ok { + return + } + + resp, err := s.api.StartWorkflow(r.Context(), &vtadminpb.StartWorkflowRequest{ + ClusterId: clusterID, + Keyspace: keyspace, + Workflow: workflow, + }) + if err != nil { + s.renderFormErrorErr(w, r, title, err) + return + } + if resp == nil { + s.renderFormError(w, r, title, "not authorized to start workflow") + return + } + + s.redirectWithFlash(w, r, workflowDetailPath(clusterID, keyspace, workflow), Flash{ + Kind: "success", + Message: "started workflow " + workflow, + }) +} + +func (s *Server) workflowStop(w http.ResponseWriter, r *http.Request) { + const title = "Stop workflow" + clusterID, keyspace, workflow, ok := s.beginWorkflowAction(w, r, title) + if !ok { + return + } + + resp, err := s.api.StopWorkflow(r.Context(), &vtadminpb.StopWorkflowRequest{ + ClusterId: clusterID, + Keyspace: keyspace, + Workflow: workflow, + }) + if err != nil { + s.renderFormErrorErr(w, r, title, err) + return + } + if resp == nil { + s.renderFormError(w, r, title, "not authorized to stop workflow") + return + } + + s.redirectWithFlash(w, r, workflowDetailPath(clusterID, keyspace, workflow), Flash{ + Kind: "success", + Message: "stopped workflow " + workflow, + }) +} + +func (s *Server) workflowCancel(w http.ResponseWriter, r *http.Request) { + const title = "Cancel workflow" + clusterID, keyspace, workflow, ok := s.beginWorkflowAction(w, r, title) + if !ok { + return + } + + var keepData *bool + if r.Form.Get("keep_data") == "on" { + keepData = new(true) + } + + _, err := s.api.WorkflowDelete(r.Context(), &vtadminpb.WorkflowDeleteRequest{ + ClusterId: clusterID, + Request: &vtctldatapb.WorkflowDeleteRequest{ + Keyspace: keyspace, + Workflow: workflow, + KeepData: keepData, + Shards: r.Form["shard"], + }, + }) + if err != nil { + s.renderFormErrorErr(w, r, title, err) + return + } + + s.redirectWithFlash(w, r, "/workflows", Flash{ + Kind: "success", + Message: "cancelled workflow " + workflow, + }) +} + +func (s *Server) workflowComplete(w http.ResponseWriter, r *http.Request) { + const title = "Complete workflow" + clusterID, keyspace, workflow, ok := s.beginWorkflowAction(w, r, title) + if !ok { + return + } + + wf, err := s.api.GetWorkflow(r.Context(), &vtadminpb.GetWorkflowRequest{ + ClusterId: clusterID, + Keyspace: keyspace, + Name: workflow, + }) + if err != nil { + s.renderFormErrorErr(w, r, title, err) + return + } + if wf == nil || wf.GetWorkflow() == nil { + s.renderFormError(w, r, title, "not authorized to complete workflow") + return + } + if !workflowSupportsTrafficSwitch(wf.GetWorkflow().GetWorkflowType()) { + s.renderFormError(w, r, title, "complete is only supported for MoveTables and Reshard workflows") + return + } + + status, err := s.api.GetWorkflowStatus(r.Context(), &vtadminpb.GetWorkflowStatusRequest{ + ClusterId: clusterID, + Keyspace: keyspace, + Name: workflow, + }) + if err != nil { + s.renderFormErrorErr(w, r, title, err) + return + } + if !workflowTrafficFullySwitched(status.GetTrafficState()) { + s.renderFormError(w, r, title, "cannot complete workflow until traffic is fully switched") + return + } + + _, err = s.api.MoveTablesComplete(r.Context(), &vtadminpb.MoveTablesCompleteRequest{ + ClusterId: clusterID, + Request: &vtctldatapb.MoveTablesCompleteRequest{ + Workflow: workflow, + TargetKeyspace: keyspace, + KeepData: formOptionalKeepData(r.Form), + KeepRoutingRules: r.Form.Get("keep_routing_rules") == "on", + RenameTables: r.Form.Get("rename_tables") == "on", + }, + }) + if err != nil { + s.renderFormErrorErr(w, r, title, err) + return + } + + s.redirectWithFlash(w, r, "/workflows", Flash{ + Kind: "success", + Message: "completed workflow " + workflow, + }) +} + +func workflowSupportsTrafficSwitch(workflowType string) bool { + return workflowType == "MoveTables" || workflowType == "Reshard" +} + +func workflowTrafficFullySwitched(trafficState string) bool { + if strings.Contains(trafficState, "Not Switched") || strings.Contains(trafficState, "partially") { + return false + } + return strings.Contains(trafficState, "Writes Switched") +} + +func formOptionalKeepData(form url.Values) *bool { + values := form["keep_data"] + if len(values) == 0 { + return nil + } + if slices.Contains(values, "on") { + return new(true) + } + return new(false) +} + +func (s *Server) workflowSwitchTraffic(w http.ResponseWriter, r *http.Request, direction int32) { + title := "Switch traffic" + if direction == switchDirectionBackward { + title = "Reverse traffic" + } + + clusterID, keyspace, workflow, ok := s.beginWorkflowAction(w, r, title) + if !ok { + return + } + + wf, err := s.api.GetWorkflow(r.Context(), &vtadminpb.GetWorkflowRequest{ + ClusterId: clusterID, + Keyspace: keyspace, + Name: workflow, + }) + if err != nil { + s.renderFormErrorErr(w, r, title, err) + return + } + if wf == nil || wf.GetWorkflow() == nil { + s.renderFormError(w, r, title, "not authorized to "+strings.ToLower(title)) + return + } + if !workflowSupportsTrafficSwitch(wf.GetWorkflow().GetWorkflowType()) { + s.renderFormError(w, r, title, "traffic switching is only supported for MoveTables and Reshard workflows") + return + } + + // Match the vtctldclient CLI: an omitted tablet-types selection defaults + // to PRIMARY, REPLICA, and RDONLY. An empty list can return success while + // switching no traffic at all. + submittedTypes := r.Form["tablet_type"] + if len(submittedTypes) == 0 { + submittedTypes = []string{"PRIMARY", "REPLICA", "RDONLY"} + } + tabletTypes, err := parseTabletTypes(submittedTypes) + if err != nil { + s.renderFormErrorErr(w, r, title, err) + return + } + + timeout, err := parseShardFormDuration(r, "timeout") + if err != nil { + s.renderFormErrorErr(w, r, title, err) + return + } + + maxLag, err := parseShardFormDuration(r, "max_replication_lag_allowed") + if err != nil { + s.renderFormErrorErr(w, r, title, err) + return + } + + // Reverse traffic cannot enable reverse replication (it IS the reverse). + enableReverseReplication := direction == switchDirectionForward && r.Form.Get("enable_reverse_replication") == "on" + + _, err = s.api.WorkflowSwitchTraffic(r.Context(), &vtadminpb.WorkflowSwitchTrafficRequest{ + ClusterId: clusterID, + Request: &vtctldatapb.WorkflowSwitchTrafficRequest{ + Keyspace: keyspace, + Workflow: workflow, + TabletTypes: tabletTypes, + Cells: splitFormList(r.Form.Get("cells")), + EnableReverseReplication: enableReverseReplication, + Direction: direction, + Timeout: timeout, + MaxReplicationLagAllowed: maxLag, + InitializeTargetSequences: r.Form.Get("initialize_target_sequences") == "on", + Force: r.Form.Get("force") == "on", + DryRun: r.Form.Get("dry_run") == "on", + }, + }) + if err != nil { + s.renderFormErrorErr(w, r, title, err) + return + } + + message := "switched traffic for workflow " + workflow + if direction == switchDirectionBackward { + message = "reversed traffic for workflow " + workflow + } + if r.Form.Get("dry_run") == "on" { + message += " (dry run)" + } + + s.redirectWithFlash(w, r, workflowDetailPath(clusterID, keyspace, workflow), Flash{ + Kind: "success", + Message: message, + }) +} + +func (s *Server) workflowSwitchTrafficForward(w http.ResponseWriter, r *http.Request) { + s.workflowSwitchTraffic(w, r, switchDirectionForward) +} + +func (s *Server) workflowSwitchTrafficReverse(w http.ResponseWriter, r *http.Request) { + s.workflowSwitchTraffic(w, r, switchDirectionBackward) +} + +func (s *Server) workflowVDiffCreate(w http.ResponseWriter, r *http.Request) { + const title = "Create VDiff" + clusterID, keyspace, workflow, ok := s.beginWorkflowAction(w, r, title) + if !ok { + return + } + + resp, err := s.api.VDiffCreate(r.Context(), &vtadminpb.VDiffCreateRequest{ + ClusterId: clusterID, + Request: &vtctldatapb.VDiffCreateRequest{ + Workflow: workflow, + TargetKeyspace: keyspace, + Uuid: strings.TrimSpace(r.Form.Get("uuid")), + SourceCells: splitFormList(r.Form.Get("source_cells")), + TargetCells: splitFormList(r.Form.Get("target_cells")), + Tables: splitFormList(r.Form.Get("tables")), + AutoRetry: r.Form.Get("auto_retry") == "on", + // Wait is intentionally not offered: a synchronous VDiff can outlive + // the HTTP request. Create the VDiff and poll the show page instead. + }, + }) + if err != nil { + s.renderFormErrorErr(w, r, title, err) + return + } + if resp == nil { + s.renderFormError(w, r, title, "not authorized to create VDiff") + return + } + + redirect := "/vdiff/" + pathEscape(clusterID) + "/show?" + + "cluster_id=" + url.QueryEscape(clusterID) + + "&keyspace=" + url.QueryEscape(keyspace) + + "&workflow=" + url.QueryEscape(workflow) + + if u := cmp.Or(resp.GetUUID(), strings.TrimSpace(r.Form.Get("uuid"))); u != "" { + redirect += "&arg=" + url.QueryEscape(u) + } + + s.redirectWithFlash(w, r, redirect, Flash{ + Kind: "success", + Message: "created VDiff for workflow " + workflow, + }) +} diff --git a/go/vt/vtadmin/vtadmin2/workflows_actions_test.go b/go/vt/vtadmin/vtadmin2/workflows_actions_test.go new file mode 100644 index 00000000000..ee909dea74b --- /dev/null +++ b/go/vt/vtadmin/vtadmin2/workflows_actions_test.go @@ -0,0 +1,594 @@ +/* +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/url" + "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" + vtctldatapb "vitess.io/vitess/go/vt/proto/vtctldata" +) + +const ( + workflowActionBase = "/workflow/local/sales/users_to_sales" + generatedVDiffUUID = "550e8400-e29b-41d4-a716-446655440000" +) + +type workflowActionsFakeServer struct { + fakeVTAdminServer + + workflowType string + trafficState string + startWorkflowNil bool + stopWorkflowNil bool + startWorkflowReq *vtadminpb.StartWorkflowRequest + stopWorkflowReq *vtadminpb.StopWorkflowRequest + workflowDeleteReq *vtadminpb.WorkflowDeleteRequest + workflowSwitchTrafficReq *vtadminpb.WorkflowSwitchTrafficRequest + moveTablesCompleteReq *vtadminpb.MoveTablesCompleteRequest + vdiffCreateReq *vtadminpb.VDiffCreateRequest +} + +func (f *workflowActionsFakeServer) GetWorkflow(ctx context.Context, req *vtadminpb.GetWorkflowRequest) (*vtadminpb.Workflow, error) { + workflowType := f.workflowType + if workflowType == "" { + workflowType = "MoveTables" + } + return &vtadminpb.Workflow{ + Cluster: &vtadminpb.Cluster{Id: req.ClusterId}, + Keyspace: req.Keyspace, + Workflow: &vtctldatapb.Workflow{Name: req.Name, WorkflowType: workflowType}, + }, nil +} + +func (f *workflowActionsFakeServer) GetWorkflowStatus(ctx context.Context, req *vtadminpb.GetWorkflowStatusRequest) (*vtctldatapb.WorkflowStatusResponse, error) { + state := f.trafficState + if state == "" { + state = "All Reads Switched. Writes Switched" + } + return &vtctldatapb.WorkflowStatusResponse{TrafficState: state}, nil +} + +func (f *workflowActionsFakeServer) StartWorkflow(ctx context.Context, req *vtadminpb.StartWorkflowRequest) (*vtctldatapb.WorkflowUpdateResponse, error) { + f.startWorkflowReq = req + if f.startWorkflowNil { + return nil, nil + } + return &vtctldatapb.WorkflowUpdateResponse{}, nil +} + +func (f *workflowActionsFakeServer) StopWorkflow(ctx context.Context, req *vtadminpb.StopWorkflowRequest) (*vtctldatapb.WorkflowUpdateResponse, error) { + f.stopWorkflowReq = req + if f.stopWorkflowNil { + return nil, nil + } + return &vtctldatapb.WorkflowUpdateResponse{}, nil +} + +func (f *workflowActionsFakeServer) WorkflowDelete(ctx context.Context, req *vtadminpb.WorkflowDeleteRequest) (*vtctldatapb.WorkflowDeleteResponse, error) { + f.workflowDeleteReq = req + return &vtctldatapb.WorkflowDeleteResponse{}, nil +} + +func (f *workflowActionsFakeServer) WorkflowSwitchTraffic(ctx context.Context, req *vtadminpb.WorkflowSwitchTrafficRequest) (*vtctldatapb.WorkflowSwitchTrafficResponse, error) { + f.workflowSwitchTrafficReq = req + return &vtctldatapb.WorkflowSwitchTrafficResponse{}, nil +} + +func (f *workflowActionsFakeServer) MoveTablesComplete(ctx context.Context, req *vtadminpb.MoveTablesCompleteRequest) (*vtctldatapb.MoveTablesCompleteResponse, error) { + f.moveTablesCompleteReq = req + return &vtctldatapb.MoveTablesCompleteResponse{}, nil +} + +func (f *workflowActionsFakeServer) VDiffCreate(ctx context.Context, req *vtadminpb.VDiffCreateRequest) (*vtctldatapb.VDiffCreateResponse, error) { + f.vdiffCreateReq = req + uuid := req.GetRequest().GetUuid() + if uuid == "" { + uuid = generatedVDiffUUID + } + return &vtctldatapb.VDiffCreateResponse{UUID: uuid}, nil +} + +func newWorkflowActionsTestServer(t *testing.T, fake *workflowActionsFakeServer, readOnly bool) *Server { + t.Helper() + s, err := NewServer(fake, Options{ReadOnly: readOnly}) + require.NoError(t, err) + return s +} + +func TestWorkflowStartStopCallAPI(t *testing.T) { + tests := []struct { + action string + verify func(t *testing.T, fake *workflowActionsFakeServer) + }{ + { + action: "/start", + verify: func(t *testing.T, fake *workflowActionsFakeServer) { + require.NotNil(t, fake.startWorkflowReq) + assert.Equal(t, testClusterID, fake.startWorkflowReq.ClusterId) + assert.Equal(t, "sales", fake.startWorkflowReq.Keyspace) + assert.Equal(t, "users_to_sales", fake.startWorkflowReq.Workflow) + }, + }, + { + action: "/stop", + verify: func(t *testing.T, fake *workflowActionsFakeServer) { + require.NotNil(t, fake.stopWorkflowReq) + assert.Equal(t, "users_to_sales", fake.stopWorkflowReq.Workflow) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.action, func(t *testing.T) { + fake := &workflowActionsFakeServer{} + s := newWorkflowActionsTestServer(t, fake, false) + + rec := postShardForm(t, s, workflowActionBase+tt.action, url.Values{}) + + assert.Equal(t, http.StatusSeeOther, rec.Code) + assert.Equal(t, workflowActionBase, rec.Header().Get("Location")) + tt.verify(t, fake) + }) + } +} + +func TestWorkflowStartStopUnauthorizedNilResponse(t *testing.T) { + tests := []struct { + action string + setup func(*workflowActionsFakeServer) + called func(*workflowActionsFakeServer) any + }{ + { + action: "/start", + setup: func(f *workflowActionsFakeServer) { f.startWorkflowNil = true }, + called: func(f *workflowActionsFakeServer) any { return f.startWorkflowReq }, + }, + { + action: "/stop", + setup: func(f *workflowActionsFakeServer) { f.stopWorkflowNil = true }, + called: func(f *workflowActionsFakeServer) any { return f.stopWorkflowReq }, + }, + } + + for _, tt := range tests { + t.Run(tt.action, func(t *testing.T) { + fake := &workflowActionsFakeServer{} + tt.setup(fake) + s := newWorkflowActionsTestServer(t, fake, false) + + rec := postShardForm(t, s, workflowActionBase+tt.action, url.Values{}) + + assert.Equal(t, http.StatusBadRequest, rec.Code) + assert.NotNil(t, tt.called(fake)) + assert.Contains(t, rec.Body.String(), "not authorized") + }) + } +} + +func TestWorkflowCancelCallsDelete(t *testing.T) { + fake := &workflowActionsFakeServer{} + s := newWorkflowActionsTestServer(t, fake, false) + + form := url.Values{"keep_data": {"on"}} + rec := postShardForm(t, s, workflowActionBase+"/cancel", form) + + assert.Equal(t, http.StatusSeeOther, rec.Code) + assert.Equal(t, "/workflows", rec.Header().Get("Location")) + + req := fake.workflowDeleteReq + require.NotNil(t, req) + assert.Equal(t, testClusterID, req.ClusterId) + assert.Equal(t, "sales", req.GetRequest().Keyspace) + assert.Equal(t, "users_to_sales", req.GetRequest().Workflow) + assert.True(t, req.GetRequest().GetKeepData()) +} + +func TestWorkflowCancelKeepDataDefaultsNil(t *testing.T) { + fake := &workflowActionsFakeServer{} + s := newWorkflowActionsTestServer(t, fake, false) + + rec := postShardForm(t, s, workflowActionBase+"/cancel", url.Values{}) + + assert.Equal(t, http.StatusSeeOther, rec.Code) + require.NotNil(t, fake.workflowDeleteReq) + assert.Nil(t, fake.workflowDeleteReq.GetRequest().KeepData) +} + +func TestWorkflowCompleteCallsMoveTablesComplete(t *testing.T) { + fake := &workflowActionsFakeServer{} + s := newWorkflowActionsTestServer(t, fake, false) + + form := url.Values{"keep_data": {"on"}} + rec := postShardForm(t, s, workflowActionBase+"/complete", form) + + assert.Equal(t, http.StatusSeeOther, rec.Code) + assert.Equal(t, "/workflows", rec.Header().Get("Location")) + + req := fake.moveTablesCompleteReq + require.NotNil(t, req) + assert.Equal(t, testClusterID, req.ClusterId) + + inner := req.GetRequest() + assert.Equal(t, "users_to_sales", inner.Workflow) + assert.Equal(t, "sales", inner.TargetKeyspace) + assert.True(t, inner.GetKeepData()) +} + +func TestWorkflowCompleteKeepDataDefaultsNil(t *testing.T) { + fake := &workflowActionsFakeServer{} + s := newWorkflowActionsTestServer(t, fake, false) + + rec := postShardForm(t, s, workflowActionBase+"/complete", url.Values{}) + + assert.Equal(t, http.StatusSeeOther, rec.Code) + require.NotNil(t, fake.moveTablesCompleteReq) + assert.Nil(t, fake.moveTablesCompleteReq.GetRequest().KeepData) +} + +func TestWorkflowCompleteDefaultFormDropsData(t *testing.T) { + fake := &workflowActionsFakeServer{} + s := newWorkflowActionsTestServer(t, fake, false) + + rec := postShardForm(t, s, workflowActionBase+"/complete", url.Values{"keep_data": {"off"}}) + + assert.Equal(t, http.StatusSeeOther, rec.Code) + require.NotNil(t, fake.moveTablesCompleteReq) + keepData := fake.moveTablesCompleteReq.GetRequest().KeepData + require.NotNil(t, keepData) + assert.False(t, *keepData) +} + +func TestWorkflowCompleteReverseDefaultFormKeepsData(t *testing.T) { + fake := &workflowActionsFakeServer{} + s := newWorkflowActionsTestServer(t, fake, false) + + rec := postShardForm(t, s, "/workflow/local/sales/users_to_sales_reverse/complete", url.Values{ + "keep_data": {"off", "on"}, + }) + + assert.Equal(t, http.StatusSeeOther, rec.Code) + require.NotNil(t, fake.moveTablesCompleteReq) + assert.Equal(t, "users_to_sales_reverse", fake.moveTablesCompleteReq.GetRequest().GetWorkflow()) + keepData := fake.moveTablesCompleteReq.GetRequest().KeepData + require.NotNil(t, keepData) + assert.True(t, *keepData) +} + +func TestWorkflowCompleteReverseUncheckedDropsData(t *testing.T) { + fake := &workflowActionsFakeServer{} + s := newWorkflowActionsTestServer(t, fake, false) + + rec := postShardForm(t, s, "/workflow/local/sales/users_to_sales_reverse/complete", url.Values{ + "keep_data": {"off"}, + }) + + assert.Equal(t, http.StatusSeeOther, rec.Code) + require.NotNil(t, fake.moveTablesCompleteReq) + keepData := fake.moveTablesCompleteReq.GetRequest().KeepData + require.NotNil(t, keepData) + assert.False(t, *keepData) +} + +func TestWorkflowCompleteRejectsUnsupportedTypes(t *testing.T) { + fake := &workflowActionsFakeServer{workflowType: "Materialize"} + s := newWorkflowActionsTestServer(t, fake, false) + + rec := postShardForm(t, s, workflowActionBase+"/complete", url.Values{}) + + assert.Equal(t, http.StatusBadRequest, rec.Code) + assert.Nil(t, fake.moveTablesCompleteReq) + assert.Contains(t, rec.Body.String(), "MoveTables") +} + +func TestWorkflowCompleteRejectsUnswitchedTraffic(t *testing.T) { + fake := &workflowActionsFakeServer{trafficState: "Reads Not Switched. Writes Not Switched"} + s := newWorkflowActionsTestServer(t, fake, false) + + rec := postShardForm(t, s, workflowActionBase+"/complete", url.Values{}) + + assert.Equal(t, http.StatusBadRequest, rec.Code) + assert.Nil(t, fake.moveTablesCompleteReq) + assert.Contains(t, rec.Body.String(), "fully switched") +} + +func TestWorkflowTrafficFullySwitched(t *testing.T) { + tests := []struct { + state string + want bool + }{ + {state: "All Reads Switched. Writes Switched", want: true}, + {state: "All Reads Switched. All Writes Switched", want: true}, + {state: "Reads Not Switched. Writes Not Switched", want: false}, + {state: "All Reads Switched. Writes Not Switched", want: false}, + {state: "Reads Not Switched. Writes Switched", want: false}, + {state: "Reads partially switched. Replica not switched. All Rdonly Reads Switched. Writes Switched", want: false}, + {state: "", want: false}, + } + for _, tt := range tests { + t.Run(tt.state, func(t *testing.T) { + assert.Equal(t, tt.want, workflowTrafficFullySwitched(tt.state)) + }) + } +} + +func TestWorkflowSwitchTraffic(t *testing.T) { + fake := &workflowActionsFakeServer{} + s := newWorkflowActionsTestServer(t, fake, false) + + form := url.Values{ + "tablet_type": {"PRIMARY", "REPLICA"}, + "enable_reverse_replication": {"on"}, + "initialize_target_sequences": {"on"}, + "timeout": {"30"}, + "max_replication_lag_allowed": {"5"}, + "force": {"on"}, + } + rec := postShardForm(t, s, workflowActionBase+"/switch_traffic", form) + + assert.Equal(t, http.StatusSeeOther, rec.Code) + assert.Equal(t, workflowActionBase, rec.Header().Get("Location")) + + req := fake.workflowSwitchTrafficReq + require.NotNil(t, req) + assert.Equal(t, testClusterID, req.ClusterId) + + inner := req.GetRequest() + assert.Equal(t, "users_to_sales", inner.Workflow) + assert.Equal(t, "sales", inner.Keyspace) + assert.ElementsMatch(t, []topodatapb.TabletType{topodatapb.TabletType_PRIMARY, topodatapb.TabletType_REPLICA}, inner.TabletTypes) + assert.True(t, inner.EnableReverseReplication) + assert.True(t, inner.InitializeTargetSequences) + assert.True(t, inner.Force) + assert.Equal(t, int64(30), inner.Timeout.Seconds) + assert.Equal(t, int64(5), inner.MaxReplicationLagAllowed.Seconds) + // Forward direction. + assert.Equal(t, int32(0), inner.Direction) +} + +func TestWorkflowReverseTraffic(t *testing.T) { + fake := &workflowActionsFakeServer{} + s := newWorkflowActionsTestServer(t, fake, false) + + form := url.Values{"tablet_type": {"PRIMARY"}} + rec := postShardForm(t, s, workflowActionBase+"/reverse_traffic", form) + + assert.Equal(t, http.StatusSeeOther, rec.Code) + + inner := fake.workflowSwitchTrafficReq.GetRequest() + require.NotNil(t, inner) + // Reverse direction. + assert.Equal(t, int32(1), inner.Direction) + // Reverse traffic always disables reverse replication. + assert.False(t, inner.EnableReverseReplication) +} + +func TestWorkflowVDiffCreateRedirectsToShow(t *testing.T) { + fake := &workflowActionsFakeServer{} + s := newWorkflowActionsTestServer(t, fake, false) + + form := url.Values{ + "uuid": {"my-vdiff-uuid"}, + "source_cells": {"zone1, zone2"}, + "tables": {"users, orders"}, + } + rec := postShardForm(t, s, workflowActionBase+"/vdiff", form) + + assert.Equal(t, http.StatusSeeOther, rec.Code) + assert.Contains(t, rec.Header().Get("Location"), "/vdiff/local/show?") + assert.Contains(t, rec.Header().Get("Location"), "workflow=users_to_sales") + assert.Contains(t, rec.Header().Get("Location"), "keyspace=sales") + assert.Contains(t, rec.Header().Get("Location"), "arg=my-vdiff-uuid") + + req := fake.vdiffCreateReq + require.NotNil(t, req) + assert.Equal(t, testClusterID, req.ClusterId) + + inner := req.GetRequest() + assert.Equal(t, "users_to_sales", inner.Workflow) + assert.Equal(t, "sales", inner.TargetKeyspace) + assert.Equal(t, "my-vdiff-uuid", inner.Uuid) + assert.Equal(t, []string{"zone1", "zone2"}, inner.SourceCells) + assert.Equal(t, []string{"users", "orders"}, inner.Tables) +} + +func TestWorkflowVDiffCreateRedirectsUsingReturnedUUID(t *testing.T) { + fake := &workflowActionsFakeServer{} + s := newWorkflowActionsTestServer(t, fake, false) + + rec := postShardForm(t, s, workflowActionBase+"/vdiff", url.Values{}) + + assert.Equal(t, http.StatusSeeOther, rec.Code) + assert.Contains(t, rec.Header().Get("Location"), "arg="+generatedVDiffUUID) +} + +func TestWorkflowSwitchTrafficDefaultsTabletTypes(t *testing.T) { + fake := &workflowActionsFakeServer{} + s := newWorkflowActionsTestServer(t, fake, false) + + rec := postShardForm(t, s, workflowActionBase+"/switch_traffic", url.Values{}) + + assert.Equal(t, http.StatusSeeOther, rec.Code) + inner := fake.workflowSwitchTrafficReq.GetRequest() + require.NotNil(t, inner) + assert.Equal(t, []topodatapb.TabletType{ + topodatapb.TabletType_PRIMARY, + topodatapb.TabletType_REPLICA, + topodatapb.TabletType_RDONLY, + }, inner.TabletTypes) + assert.False(t, inner.InitializeTargetSequences) +} + +func TestWorkflowReverseTrafficDefaultsTabletTypes(t *testing.T) { + fake := &workflowActionsFakeServer{} + s := newWorkflowActionsTestServer(t, fake, false) + + rec := postShardForm(t, s, workflowActionBase+"/reverse_traffic", url.Values{}) + + assert.Equal(t, http.StatusSeeOther, rec.Code) + inner := fake.workflowSwitchTrafficReq.GetRequest() + require.NotNil(t, inner) + assert.Equal(t, []topodatapb.TabletType{ + topodatapb.TabletType_PRIMARY, + topodatapb.TabletType_REPLICA, + topodatapb.TabletType_RDONLY, + }, inner.TabletTypes) +} + +func TestWorkflowActionsReadOnly(t *testing.T) { + actions := []string{"/start", "/stop", "/cancel", "/complete", "/switch_traffic", "/reverse_traffic", "/vdiff"} + + for _, action := range actions { + t.Run(action, func(t *testing.T) { + fake := &workflowActionsFakeServer{} + s := newWorkflowActionsTestServer(t, fake, true) + + rec := postShardForm(t, s, workflowActionBase+action, url.Values{}) + + assert.Equal(t, http.StatusForbidden, rec.Code) + assert.Nil(t, fake.startWorkflowReq) + assert.Nil(t, fake.workflowDeleteReq) + }) + } +} + +func TestWorkflowDetailRendersActions(t *testing.T) { + fake := &workflowActionsFakeServer{} + s := newWorkflowActionsTestServer(t, fake, false) + + token, rec := renderWithCSRF(t, s, workflowActionBase) + body := rec.Body.String() + assert.Contains(t, body, workflowActionBase+"/start") + assert.Contains(t, body, workflowActionBase+"/switch_traffic") + assert.Contains(t, body, workflowActionBase+"/complete") + assert.Contains(t, body, workflowActionBase+"/vdiff") + assert.Contains(t, body, `name="keep_data"`) + assert.Contains(t, body, "Keep data (do not drop copied tables on the source)") + assert.NotContains(t, body, `name="keep_data" value="on" checked`) + + // A POST using the exact rendered token/cookie pairing must get past + // CSRF validation. + rec = postFormWithCSRF(s, workflowActionBase+"/start", token, url.Values{ + "csrf_token": {token}, + }) + assert.Equal(t, http.StatusSeeOther, rec.Code) + require.NotNil(t, fake.startWorkflowReq) +} + +func TestWorkflowDetailReverseCompleteKeepDataChecked(t *testing.T) { + fake := &workflowActionsFakeServer{} + s := newWorkflowActionsTestServer(t, fake, false) + + _, rec := renderWithCSRF(t, s, "/workflow/local/sales/users_to_sales_reverse") + assert.Equal(t, http.StatusOK, rec.Code) + assert.Contains(t, rec.Body.String(), `name="keep_data" value="on" checked`) +} + +func TestWorkflowDetailTrafficSwitchVisibility(t *testing.T) { + tests := []struct { + workflowType string + wantSwitch bool + }{ + {workflowType: "MoveTables", wantSwitch: true}, + {workflowType: "Reshard", wantSwitch: true}, + {workflowType: "Materialize", wantSwitch: false}, + } + + for _, tt := range tests { + t.Run(tt.workflowType, func(t *testing.T) { + fake := &workflowActionsFakeServer{workflowType: tt.workflowType} + s := newWorkflowActionsTestServer(t, fake, false) + + _, rec := renderWithCSRF(t, s, workflowActionBase) + body := rec.Body.String() + assert.Contains(t, body, workflowActionBase+"/start") + if tt.wantSwitch { + assert.Contains(t, body, workflowActionBase+"/switch_traffic") + assert.Contains(t, body, workflowActionBase+"/reverse_traffic") + } else { + assert.NotContains(t, body, workflowActionBase+"/switch_traffic") + assert.NotContains(t, body, workflowActionBase+"/reverse_traffic") + } + }) + } +} + +func TestWorkflowDetailCompleteVisibility(t *testing.T) { + tests := []struct { + name string + workflowType string + trafficState string + wantComplete bool + }{ + {name: "movetables switched", workflowType: "MoveTables", trafficState: "All Reads Switched. Writes Switched", wantComplete: true}, + {name: "reshard switched", workflowType: "Reshard", trafficState: "All Reads Switched. Writes Switched", wantComplete: true}, + {name: "movetables not switched", workflowType: "MoveTables", trafficState: "Reads Not Switched. Writes Not Switched", wantComplete: false}, + {name: "materialize switched", workflowType: "Materialize", trafficState: "All Reads Switched. Writes Switched", wantComplete: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fake := &workflowActionsFakeServer{workflowType: tt.workflowType, trafficState: tt.trafficState} + s := newWorkflowActionsTestServer(t, fake, false) + + _, rec := renderWithCSRF(t, s, workflowActionBase) + body := rec.Body.String() + if tt.wantComplete { + assert.Contains(t, body, workflowActionBase+"/complete") + return + } + assert.NotContains(t, body, workflowActionBase+"/complete") + }) + } +} + +func TestWorkflowSwitchTrafficRejectsUnsupportedTypes(t *testing.T) { + tests := []struct { + workflowType string + wantOK bool + }{ + {workflowType: "MoveTables", wantOK: true}, + {workflowType: "Reshard", wantOK: true}, + {workflowType: "Materialize", wantOK: false}, + } + + for _, tt := range tests { + t.Run(tt.workflowType, func(t *testing.T) { + fake := &workflowActionsFakeServer{workflowType: tt.workflowType} + s := newWorkflowActionsTestServer(t, fake, false) + + rec := postShardForm(t, s, workflowActionBase+"/switch_traffic", url.Values{}) + + if tt.wantOK { + assert.Equal(t, http.StatusSeeOther, rec.Code) + require.NotNil(t, fake.workflowSwitchTrafficReq) + return + } + + assert.Equal(t, http.StatusBadRequest, rec.Code) + assert.Nil(t, fake.workflowSwitchTrafficReq) + assert.Contains(t, rec.Body.String(), "MoveTables") + }) + } +} diff --git a/go/vt/vtadmin/vtadmin2/workflows_create.go b/go/vt/vtadmin/vtadmin2/workflows_create.go new file mode 100644 index 00000000000..34c3929328c --- /dev/null +++ b/go/vt/vtadmin/vtadmin2/workflows_create.go @@ -0,0 +1,588 @@ +/* +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/url" + "strconv" + "strings" + + 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" + vtrpcpb "vitess.io/vitess/go/vt/proto/vtrpc" + "vitess.io/vitess/go/vt/vterrors" +) + +type ( + createMoveTablesData struct { + Form formOptions + SourceKeyspace string + SourceTables []string + SelectedCluster string + } + + createReshardData struct { + Form formOptions + PickCluster bool + SelectedCluster string + } + + createMaterializeData struct { + Form formOptions + SourceKeyspace string + ReferenceTables []string + SelectedCluster string + } + + createMigrationData struct { + Form formOptions + PickCluster bool + SelectedCluster string + } +) + +// beginFormAction performs the shared preflight for mutating form handlers: +// read-only rejection, form parsing, and CSRF validation. ok is false when +// the preflight has already rendered an error response. +func (s *Server) beginFormAction(w http.ResponseWriter, r *http.Request, title string) bool { + if s.opts.ReadOnly { + s.renderReadOnly(w, r) + return false + } + + if err := r.ParseForm(); err != nil { + s.renderFormError(w, r, title, err.Error()) + return false + } + if !validCSRFToken(r) { + s.renderError(w, r, http.StatusForbidden, title, vterrors.New(vtrpcpb.Code_INVALID_ARGUMENT, "invalid CSRF token")) + return false + } + return true +} + +func (s *Server) createMoveTablesForm(w http.ResponseWriter, r *http.Request) { + if s.opts.ReadOnly { + s.renderReadOnly(w, r) + return + } + + selectedCluster := queryValue(r, "cluster_id") + sourceKeyspace := queryValue(r, "source_keyspace") + + form, err := s.loadFormOptions(r, selectedCluster, "") + if err != nil { + s.renderError(w, r, http.StatusInternalServerError, "Create MoveTables workflow", err) + return + } + + data := createMoveTablesData{ + Form: form, + SourceKeyspace: sourceKeyspace, + SelectedCluster: form.SelectedCluster, + } + + if form.SelectedCluster != "" && sourceKeyspace != "" { + tables, err := s.fetchSourceTables(r, form.SelectedCluster, sourceKeyspace) + if err != nil { + s.renderError(w, r, http.StatusInternalServerError, "Create MoveTables workflow", err) + return + } + data.SourceTables = tables + } + + s.render(w, r, http.StatusOK, "workflow_movetables_create.html", PageData{ + Title: "Create MoveTables workflow", + Active: "workflows", + NeedsCSRF: true, + Data: data, + }) +} + +func (s *Server) fetchSourceTables(r *http.Request, clusterID, keyspace string) ([]string, error) { + resp, err := s.api.GetSchemas(r.Context(), &vtadminpb.GetSchemasRequest{ + ClusterIds: []string{clusterID}, + }) + if err != nil { + return nil, err + } + for _, schema := range resp.GetSchemas() { + if schema.GetKeyspace() == keyspace { + tables := make([]string, 0, len(schema.GetTableDefinitions())) + for _, def := range schema.GetTableDefinitions() { + tables = append(tables, def.GetName()) + } + return tables, nil + } + } + return nil, vterrors.Errorf(vtrpcpb.Code_NOT_FOUND, "no schema found for keyspace %s (cluster %s)", keyspace, clusterID) +} + +func (s *Server) createReshardForm(w http.ResponseWriter, r *http.Request) { + if s.opts.ReadOnly { + s.renderReadOnly(w, r) + return + } + + requestedCluster := queryValue(r, "cluster_id") + form, err := s.loadFormOptions(r, requestedCluster, "") + if err != nil { + s.renderError(w, r, http.StatusInternalServerError, "Create Reshard workflow", err) + return + } + + // With more than one cluster, a shard list only makes sense per cluster; + // ask the user to pick one explicitly rather than guessing. + pickCluster := len(form.Clusters) > 1 && requestedCluster != form.SelectedCluster + + s.render(w, r, http.StatusOK, "workflow_reshard_create.html", PageData{ + Title: "Create Reshard workflow", + Active: "workflows", + NeedsCSRF: true, + Data: createReshardData{ + Form: form, + PickCluster: pickCluster, + SelectedCluster: form.SelectedCluster, + }, + }) +} + +func (s *Server) createReshard(w http.ResponseWriter, r *http.Request) { + const title = "Create Reshard workflow" + if !s.beginFormAction(w, r, title) { + return + } + + clusterID := strings.TrimSpace(r.Form.Get("cluster_id")) + workflow := strings.TrimSpace(r.Form.Get("workflow")) + keyspace := strings.TrimSpace(r.Form.Get("keyspace")) + sourceShards := splitFormList(r.Form.Get("source_shards")) + targetShards := splitFormList(r.Form.Get("target_shards")) + + if clusterID == "" { + s.renderFormError(w, r, title, "cluster is required") + return + } + if workflow == "" { + s.renderFormError(w, r, title, "workflow name is required") + return + } + if keyspace == "" { + s.renderFormError(w, r, title, "keyspace is required") + return + } + if len(sourceShards) == 0 { + s.renderFormError(w, r, title, "at least one source shard is required") + return + } + if len(targetShards) == 0 { + s.renderFormError(w, r, title, "at least one target shard is required") + return + } + + tabletTypes, err := parseCreateSourceTabletTypes(r.Form["tablet_type"]) + if err != nil { + s.renderFormError(w, r, title, err.Error()) + return + } + + onDdl := strings.TrimSpace(r.Form.Get("on_ddl")) + if onDdl == "" { + onDdl = "IGNORE" + } + + selectionPreference := tabletmanagerdatapb.TabletSelectionPreference_ANY + if r.Form.Get("tablet_selection_preference") == "on" { + selectionPreference = tabletmanagerdatapb.TabletSelectionPreference_INORDER + } + + _, err = s.api.ReshardCreate(r.Context(), &vtadminpb.ReshardCreateRequest{ + ClusterId: clusterID, + Request: &vtctldatapb.ReshardCreateRequest{ + Workflow: workflow, + Keyspace: keyspace, + SourceShards: sourceShards, + TargetShards: targetShards, + Cells: splitFormList(r.Form.Get("cells")), + TabletTypes: tabletTypes, + TabletSelectionPreference: selectionPreference, + SkipSchemaCopy: r.Form.Get("skip_schema_copy") == "on", + OnDdl: onDdl, + StopAfterCopy: r.Form.Get("stop_after_copy") == "on", + DeferSecondaryKeys: r.Form.Get("defer_secondary_keys") == "on", + AutoStart: r.Form.Get("auto_start") == "on", + }, + }) + if err != nil { + s.renderFormError(w, r, title, err.Error()) + return + } + + s.redirectWithFlash(w, r, "/workflow/"+pathEscape(clusterID)+"/"+pathEscape(keyspace)+"/"+pathEscape(workflow), Flash{ + Kind: "success", + Message: "created Reshard workflow " + workflow + " on keyspace " + keyspace, + }) +} + +func (s *Server) createMaterializeForm(w http.ResponseWriter, r *http.Request) { + if s.opts.ReadOnly { + s.renderReadOnly(w, r) + return + } + + selectedCluster := queryValue(r, "cluster_id") + sourceKeyspace := queryValue(r, "source_keyspace") + + form, err := s.loadFormOptions(r, selectedCluster, "") + if err != nil { + s.renderError(w, r, http.StatusInternalServerError, "Create Materialize workflow", err) + return + } + + var referenceTables []string + if form.SelectedCluster != "" && sourceKeyspace != "" { + referenceTables, err = s.fetchSourceTables(r, form.SelectedCluster, sourceKeyspace) + if err != nil && vterrors.Code(err) != vtrpcpb.Code_NOT_FOUND { + s.renderError(w, r, http.StatusInternalServerError, "Create Materialize workflow", err) + return + } + } + + s.render(w, r, http.StatusOK, "workflow_materialize_create.html", PageData{ + Title: "Create Materialize workflow", + Active: "workflows", + NeedsCSRF: true, + Data: createMaterializeData{ + Form: form, + SourceKeyspace: sourceKeyspace, + ReferenceTables: referenceTables, + SelectedCluster: form.SelectedCluster, + }, + }) +} + +func (s *Server) createMaterialize(w http.ResponseWriter, r *http.Request) { + const title = "Create Materialize workflow" + if !s.beginFormAction(w, r, title) { + return + } + + clusterID := strings.TrimSpace(r.Form.Get("cluster_id")) + workflow := strings.TrimSpace(r.Form.Get("workflow")) + sourceKeyspace := strings.TrimSpace(r.Form.Get("source_keyspace")) + targetKeyspace := strings.TrimSpace(r.Form.Get("target_keyspace")) + + if clusterID == "" { + s.renderFormError(w, r, title, "cluster is required") + return + } + if workflow == "" { + s.renderFormError(w, r, title, "workflow name is required") + return + } + if sourceKeyspace == "" { + s.renderFormError(w, r, title, "source keyspace is required") + return + } + if targetKeyspace == "" { + s.renderFormError(w, r, title, "target keyspace is required") + return + } + if sourceKeyspace == targetKeyspace { + s.renderFormError(w, r, title, "source and target keyspace must differ") + return + } + + // The vtctld Materialize contract requires exactly one of TableSettings + // (JSON) or ReferenceTables. Enforce the exclusive choice here so invalid + // combinations are rejected before reaching the backend. + tableSettings := strings.TrimSpace(r.Form.Get("table_settings")) + referenceTables := r.Form["reference_table"] + hasTableSettings := tableSettings != "" + hasReferenceTables := len(referenceTables) > 0 + if hasTableSettings && hasReferenceTables { + s.renderFormError(w, r, title, "provide either table settings or reference tables, not both") + return + } + if !hasTableSettings && !hasReferenceTables { + s.renderFormError(w, r, title, "provide table settings or select reference tables") + return + } + + tabletTypes, err := parseCreateSourceTabletTypes(r.Form["tablet_type"]) + if err != nil { + s.renderFormError(w, r, title, err.Error()) + return + } + + // MaterializeSettings carries cells and tablet types as pre-joined + // strings rather than repeated fields. + tabletTypeNames := make([]string, 0, len(tabletTypes)) + for _, tt := range tabletTypes { + tabletTypeNames = append(tabletTypeNames, tt.String()) + } + + _, err = s.api.MaterializeCreate(r.Context(), &vtadminpb.MaterializeCreateRequest{ + ClusterId: clusterID, + TableSettings: tableSettings, + Request: &vtctldatapb.MaterializeCreateRequest{ + Settings: &vtctldatapb.MaterializeSettings{ + Workflow: workflow, + SourceKeyspace: sourceKeyspace, + TargetKeyspace: targetKeyspace, + ReferenceTables: referenceTables, + Cell: strings.Join(splitFormList(r.Form.Get("cell")), ","), + TabletTypes: strings.Join(tabletTypeNames, ","), + StopAfterCopy: r.Form.Get("stop_after_copy") == "on", + TabletSelectionPreference: parseSelectionPreference(r.Form.Get("tablet_selection_preference") == "on"), + MaterializationIntent: vtctldatapb.MaterializationIntent_CUSTOM, + }, + }, + }) + if err != nil { + s.renderFormError(w, r, title, err.Error()) + return + } + + s.redirectWithFlash(w, r, "/workflow/"+pathEscape(clusterID)+"/"+pathEscape(targetKeyspace)+"/"+pathEscape(workflow), Flash{ + Kind: "success", + Message: "created Materialize workflow " + workflow + " (" + sourceKeyspace + " -> " + targetKeyspace + ")", + }) +} + +func (s *Server) createMigrationForm(w http.ResponseWriter, r *http.Request) { + if s.opts.ReadOnly { + s.renderReadOnly(w, r) + return + } + + requestedCluster := queryValue(r, "cluster_id") + form, err := s.loadFormOptions(r, requestedCluster, "") + if err != nil { + s.renderError(w, r, http.StatusInternalServerError, "Create schema migration", err) + return + } + + pickCluster := len(form.Clusters) > 1 && requestedCluster != form.SelectedCluster + + s.render(w, r, http.StatusOK, "migration_create.html", PageData{ + Title: "Create schema migration", + Active: "migrations", + NeedsCSRF: true, + Data: createMigrationData{ + Form: form, + PickCluster: pickCluster, + SelectedCluster: form.SelectedCluster, + }, + }) +} + +func (s *Server) createMigration(w http.ResponseWriter, r *http.Request) { + const title = "Create schema migration" + if !s.beginFormAction(w, r, title) { + return + } + + clusterID := strings.TrimSpace(r.Form.Get("cluster_id")) + keyspace := strings.TrimSpace(r.Form.Get("keyspace")) + sql := strings.TrimSpace(r.Form.Get("sql")) + + if clusterID == "" { + s.renderFormError(w, r, title, "cluster is required") + return + } + if keyspace == "" { + s.renderFormError(w, r, title, "keyspace is required") + return + } + if sql == "" { + s.renderFormError(w, r, title, "SQL is required") + return + } + + batchSize := int64(0) + if raw := strings.TrimSpace(r.Form.Get("batch_size")); raw != "" { + var err error + batchSize, err = strconv.ParseInt(raw, 10, 64) + if err != nil || batchSize < 0 { + s.renderFormError(w, r, title, vterrors.Errorf(vtrpcpb.Code_INVALID_ARGUMENT, "invalid batch size: %s (expected non-negative integer)", raw).Error()) + return + } + } + + dDLStrategy := strings.TrimSpace(r.Form.Get("ddl_strategy")) + if dDLStrategy == "" { + dDLStrategy = "vitess" + } + + // The vtadmin API layer splits multi-statement SQL into pieces via the + // SQL parser and overrides Request.Sql, so pass the raw SQL as-is. + _, err := s.api.ApplySchema(r.Context(), &vtadminpb.ApplySchemaRequest{ + ClusterId: clusterID, + Sql: sql, + CallerId: strings.TrimSpace(r.Form.Get("caller_id")), + Request: &vtctldatapb.ApplySchemaRequest{ + Keyspace: keyspace, + DdlStrategy: dDLStrategy, + BatchSize: batchSize, + MigrationContext: strings.TrimSpace(r.Form.Get("migration_context")), + UuidList: splitFormList(r.Form.Get("uuid_list")), + }, + }) + if err != nil { + s.renderFormError(w, r, title, err.Error()) + return + } + + s.redirectWithFlash(w, r, "/migrations?keyspace="+url.QueryEscape(keyspace)+"&cluster_id="+url.QueryEscape(clusterID), Flash{ + Kind: "success", + Message: "schema migration request created for keyspace " + keyspace, + }) +} + +func parseSelectionPreference(inOrder bool) tabletmanagerdatapb.TabletSelectionPreference { + if inOrder { + return tabletmanagerdatapb.TabletSelectionPreference_INORDER + } + return tabletmanagerdatapb.TabletSelectionPreference_ANY +} + +func (s *Server) createMoveTables(w http.ResponseWriter, r *http.Request) { + const title = "Create MoveTables workflow" + if !s.beginFormAction(w, r, title) { + return + } + + clusterID := strings.TrimSpace(r.Form.Get("cluster_id")) + workflow := strings.TrimSpace(r.Form.Get("workflow")) + sourceKeyspace := strings.TrimSpace(r.Form.Get("source_keyspace")) + targetKeyspace := strings.TrimSpace(r.Form.Get("target_keyspace")) + + if clusterID == "" { + s.renderFormError(w, r, title, "cluster is required") + return + } + if workflow == "" { + s.renderFormError(w, r, title, "workflow name is required") + return + } + if sourceKeyspace == "" { + s.renderFormError(w, r, title, "source keyspace is required") + return + } + if targetKeyspace == "" { + s.renderFormError(w, r, title, "target keyspace is required") + return + } + if sourceKeyspace == targetKeyspace { + s.renderFormError(w, r, title, "source and target keyspace must differ") + return + } + + allTables := r.Form.Get("all_tables") == "on" + includeTables := r.Form["table"] + if allTables { + // MoveTables rejects a request with both AllTables and IncludeTables, + // so drop individual selections when copying all tables. + includeTables = nil + } + if !allTables && len(includeTables) == 0 { + s.renderFormError(w, r, title, "select at least one table or enable copy of all tables") + return + } + + tabletTypes, err := parseCreateSourceTabletTypes(r.Form["tablet_type"]) + if err != nil { + s.renderFormError(w, r, title, err.Error()) + return + } + + onDdl := strings.TrimSpace(r.Form.Get("on_ddl")) + if onDdl == "" { + onDdl = "IGNORE" + } + + _, err = s.api.MoveTablesCreate(r.Context(), &vtadminpb.MoveTablesCreateRequest{ + ClusterId: clusterID, + Request: &vtctldatapb.MoveTablesCreateRequest{ + Workflow: workflow, + SourceKeyspace: sourceKeyspace, + TargetKeyspace: targetKeyspace, + AllTables: allTables, + IncludeTables: includeTables, + Cells: splitFormList(r.Form.Get("cells")), + TabletTypes: tabletTypes, + TabletSelectionPreference: tabletmanagerdatapb.TabletSelectionPreference_INORDER, + OnDdl: onDdl, + SourceTimeZone: strings.TrimSpace(r.Form.Get("source_time_zone")), + ExternalClusterName: strings.TrimSpace(r.Form.Get("external_cluster_name")), + AutoStart: r.Form.Get("auto_start") == "on", + StopAfterCopy: r.Form.Get("stop_after_copy") == "on", + DeferSecondaryKeys: r.Form.Get("defer_secondary_keys") == "on", + }, + }) + if err != nil { + s.renderFormError(w, r, title, err.Error()) + return + } + + s.redirectWithFlash(w, r, "/workflow/"+pathEscape(clusterID)+"/"+pathEscape(targetKeyspace)+"/"+pathEscape(workflow), Flash{ + Kind: "success", + Message: "created MoveTables workflow " + workflow + " (" + sourceKeyspace + " -> " + targetKeyspace + ")", + }) +} + +// splitFormList splits a comma-separated form value into trimmed, non-empty parts. +func splitFormList(value string) []string { + out := make([]string, 0, strings.Count(value, ",")+1) + for part := range strings.SplitSeq(value, ",") { + if trimmed := strings.TrimSpace(part); trimmed != "" { + out = append(out, trimmed) + } + } + return out +} + +func parseTabletTypes(names []string) ([]topodatapb.TabletType, error) { + types := make([]topodatapb.TabletType, 0, len(names)) + for _, name := range names { + value, ok := topodatapb.TabletType_value[name] + if !ok { + return nil, vterrors.Errorf(vtrpcpb.Code_INVALID_ARGUMENT, "unknown tablet type %s", name) + } + types = append(types, topodatapb.TabletType(value)) + } + return types, nil +} + +func parseCreateSourceTabletTypes(names []string) ([]topodatapb.TabletType, error) { + if len(names) == 0 { + return []topodatapb.TabletType{ + topodatapb.TabletType_REPLICA, + topodatapb.TabletType_PRIMARY, + }, nil + } + return parseTabletTypes(names) +} + +// renderFormErrorErr is renderFormError for error values. +func (s *Server) renderFormErrorErr(w http.ResponseWriter, r *http.Request, title string, err error) { + s.renderFormError(w, r, title, err.Error()) +} diff --git a/go/vt/vtadmin/vtadmin2/workflows_create_test.go b/go/vt/vtadmin/vtadmin2/workflows_create_test.go new file mode 100644 index 00000000000..13cded02875 --- /dev/null +++ b/go/vt/vtadmin/vtadmin2/workflows_create_test.go @@ -0,0 +1,1066 @@ +/* +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" + "maps" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "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" +) + +const createMoveTablesPath = "/workflows/movetables/create" + +type workflowCreateFakeServer struct { + fakeVTAdminServer + moveTablesCreateReq *vtadminpb.MoveTablesCreateRequest + moveTablesErr error + reshardCreateReq *vtadminpb.ReshardCreateRequest + reshardErr error + materializeCreateReq *vtadminpb.MaterializeCreateRequest + materializeErr error + applySchemaReq *vtadminpb.ApplySchemaRequest + applySchemaErr error +} + +func (f *workflowCreateFakeServer) GetClusters(ctx context.Context, req *vtadminpb.GetClustersRequest) (*vtadminpb.GetClustersResponse, error) { + return &vtadminpb.GetClustersResponse{Clusters: []*vtadminpb.Cluster{ + {Id: testClusterID, Name: "Local"}, + }}, nil +} + +func (f *workflowCreateFakeServer) GetKeyspaces(ctx context.Context, req *vtadminpb.GetKeyspacesRequest) (*vtadminpb.GetKeyspacesResponse, error) { + ks := func(name string) *vtadminpb.Keyspace { + return &vtadminpb.Keyspace{ + Cluster: &vtadminpb.Cluster{Id: testClusterID}, + Keyspace: &vtctldatapb.Keyspace{Name: name}, + } + } + return &vtadminpb.GetKeyspacesResponse{Keyspaces: []*vtadminpb.Keyspace{ + ks("commerce"), ks("sales"), + }}, nil +} + +func (f *workflowCreateFakeServer) GetSchemas(ctx context.Context, req *vtadminpb.GetSchemasRequest) (*vtadminpb.GetSchemasResponse, error) { + return &vtadminpb.GetSchemasResponse{Schemas: []*vtadminpb.Schema{ + { + Cluster: &vtadminpb.Cluster{Id: testClusterID}, + Keyspace: "commerce", + TableDefinitions: []*tabletmanagerdatapb.TableDefinition{ + {Name: "users"}, + {Name: "orders"}, + }, + }, + { + Cluster: &vtadminpb.Cluster{Id: testClusterID}, + Keyspace: "sales", + TableDefinitions: []*tabletmanagerdatapb.TableDefinition{ + {Name: "users"}, + {Name: "orders"}, + }, + }, + }}, nil +} + +func (f *workflowCreateFakeServer) GetWorkflows(ctx context.Context, req *vtadminpb.GetWorkflowsRequest) (*vtadminpb.GetWorkflowsResponse, error) { + return &vtadminpb.GetWorkflowsResponse{WorkflowsByCluster: map[string]*vtadminpb.ClusterWorkflows{}}, nil +} + +func (f *workflowCreateFakeServer) MoveTablesCreate(ctx context.Context, req *vtadminpb.MoveTablesCreateRequest) (*vtctldatapb.WorkflowStatusResponse, error) { + f.moveTablesCreateReq = req + if f.moveTablesErr != nil { + return nil, f.moveTablesErr + } + return &vtctldatapb.WorkflowStatusResponse{}, nil +} + +func newWorkflowCreateTestServer(t *testing.T, fake *workflowCreateFakeServer, readOnly bool) *Server { + t.Helper() + s, err := NewServer(fake, Options{ReadOnly: readOnly}) + require.NoError(t, err) + return s +} + +func TestCreateMoveTablesFormRendersOptions(t *testing.T) { + fake := &workflowCreateFakeServer{} + s := newWorkflowCreateTestServer(t, fake, false) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, createMoveTablesPath+"?source_keyspace=commerce", nil) + s.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + body := rec.Body.String() + + assert.Contains(t, body, `name="workflow"`) + assert.Contains(t, body, `value="local"`) // cluster option + assert.Contains(t, body, `value="commerce"`) // source keyspace option + assert.Contains(t, body, `value="sales"`) // target keyspace option + assert.Contains(t, body, `name="table" value="users"`) // source tables to pick + assert.Contains(t, body, `name="table" value="orders"`) + assert.Contains(t, body, `name="all_tables"`) + assert.Contains(t, body, `name="on_ddl"`) + assert.Contains(t, body, `name="tablet_type" value="REPLICA" checked`) + assert.Contains(t, body, `name="tablet_type" value="PRIMARY" checked`) + assert.Contains(t, body, `name="defer_secondary_keys" checked`) + assert.Contains(t, body, `name="auto_start"`) + assert.Contains(t, body, `name="csrf_token"`) + assert.Contains(t, body, "IGNORE") // on_ddl default option + + // CSRF cookie must be minted so the form can post. + assert.NotNil(t, findCookie(rec, csrfCookieName)) +} + +func TestCreateMoveTablesFormReadOnly(t *testing.T) { + fake := &workflowCreateFakeServer{} + s := newWorkflowCreateTestServer(t, fake, true) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, createMoveTablesPath, nil) + s.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusForbidden, rec.Code) + assert.Nil(t, fake.moveTablesCreateReq) +} + +func TestCreateMoveTablesPostRedirectsToWorkflowDetail(t *testing.T) { + fake := &workflowCreateFakeServer{} + s := newWorkflowCreateTestServer(t, fake, false) + + form := url.Values{ + "cluster_id": {testClusterID}, + "workflow": {"users_to_sales"}, + "source_keyspace": {testKeyspace}, + "target_keyspace": {"sales"}, + "table": {"users", "orders"}, + "cells": {"zone1, zone2"}, + "on_ddl": {"EXEC"}, + "tablet_type": {"REPLICA", "PRIMARY"}, + "source_time_zone": {"UTC"}, + "auto_start": {"on"}, + "defer_secondary_keys": {"on"}, + } + rec := postShardForm(t, s, createMoveTablesPath, form) + + assert.Equal(t, http.StatusSeeOther, rec.Code) + assert.Equal(t, "/workflow/local/sales/users_to_sales", rec.Header().Get("Location")) + + req := fake.moveTablesCreateReq + require.NotNil(t, req) + assert.Equal(t, testClusterID, req.ClusterId) + + inner := req.GetRequest() + assert.Equal(t, "users_to_sales", inner.Workflow) + assert.Equal(t, testKeyspace, inner.SourceKeyspace) + assert.Equal(t, "sales", inner.TargetKeyspace) + assert.ElementsMatch(t, []string{"users", "orders"}, inner.IncludeTables) + assert.False(t, inner.AllTables) + assert.Equal(t, []string{"zone1", "zone2"}, inner.Cells) + assert.Equal(t, []topodatapb.TabletType{topodatapb.TabletType_REPLICA, topodatapb.TabletType_PRIMARY}, inner.TabletTypes) + assert.Equal(t, "EXEC", inner.OnDdl) + assert.Equal(t, "UTC", inner.SourceTimeZone) + assert.True(t, inner.AutoStart) + assert.True(t, inner.DeferSecondaryKeys) + assert.Equal(t, tabletmanagerdatapb.TabletSelectionPreference_INORDER, inner.TabletSelectionPreference) +} + +func TestCreateMoveTablesFormDefaultsClusterWithoutQuery(t *testing.T) { + fake := &workflowCreateFakeServer{} + s := newWorkflowCreateTestServer(t, fake, false) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, createMoveTablesPath, nil) + s.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + body := rec.Body.String() + assert.Contains(t, body, `name="source_keyspace"`) + assert.Contains(t, body, `value="commerce"`) + assert.Contains(t, body, `value="sales"`) + assert.NotContains(t, body, `name="workflow"`) +} + +func TestCreateMoveTablesPostAllTables(t *testing.T) { + fake := &workflowCreateFakeServer{} + s := newWorkflowCreateTestServer(t, fake, false) + + form := url.Values{ + "cluster_id": {testClusterID}, + "workflow": {"full_copy"}, + "source_keyspace": {testKeyspace}, + "target_keyspace": {"sales"}, + "all_tables": {"on"}, + } + rec := postShardForm(t, s, createMoveTablesPath, form) + + assert.Equal(t, http.StatusSeeOther, rec.Code) + inner := fake.moveTablesCreateReq.GetRequest() + require.NotNil(t, inner) + assert.True(t, inner.AllTables) + assert.Empty(t, inner.IncludeTables) +} + +func TestCreateMoveTablesPostAllTablesClearsSelections(t *testing.T) { + fake := &workflowCreateFakeServer{} + s := newWorkflowCreateTestServer(t, fake, false) + + form := url.Values{ + "cluster_id": {testClusterID}, + "workflow": {"full_copy"}, + "source_keyspace": {testKeyspace}, + "target_keyspace": {"sales"}, + "all_tables": {"on"}, + "table": {"users", "orders"}, + } + rec := postShardForm(t, s, createMoveTablesPath, form) + + assert.Equal(t, http.StatusSeeOther, rec.Code) + inner := fake.moveTablesCreateReq.GetRequest() + require.NotNil(t, inner) + assert.True(t, inner.AllTables) + assert.Empty(t, inner.IncludeTables) +} + +func TestCreateMoveTablesPostDefaults(t *testing.T) { + fake := &workflowCreateFakeServer{} + s := newWorkflowCreateTestServer(t, fake, false) + + form := url.Values{ + "cluster_id": {testClusterID}, + "workflow": {"minimal"}, + "source_keyspace": {testKeyspace}, + "target_keyspace": {"sales"}, + "table": {"users"}, + } + rec := postShardForm(t, s, createMoveTablesPath, form) + + assert.Equal(t, http.StatusSeeOther, rec.Code) + inner := fake.moveTablesCreateReq.GetRequest() + require.NotNil(t, inner) + // SPA default when nothing else is selected. + assert.Equal(t, "IGNORE", inner.OnDdl) + assert.Empty(t, inner.Cells) + assert.Equal(t, []topodatapb.TabletType{topodatapb.TabletType_REPLICA, topodatapb.TabletType_PRIMARY}, inner.TabletTypes) + assert.False(t, inner.AutoStart) // not submitted: zero value, not assumed +} + +func TestCreateMoveTablesPostValidation(t *testing.T) { + valid := url.Values{ + "cluster_id": {testClusterID}, + "workflow": {"w"}, + "source_keyspace": {testKeyspace}, + "target_keyspace": {"sales"}, + "table": {"users"}, + } + + cases := map[string]func(f url.Values){ + "missing workflow": func(f url.Values) { f.Del("workflow") }, + "missing source": func(f url.Values) { f.Del("source_keyspace") }, + "missing target": func(f url.Values) { f.Del("target_keyspace") }, + "source equals target": func(f url.Values) { f.Set("target_keyspace", testKeyspace) }, + "no tables and no all": func(f url.Values) { f.Del("table") }, + "invalid tablet type": func(f url.Values) { f.Set("tablet_type", "BOGUS") }, + } + + for name, mutate := range cases { + t.Run(name, func(t *testing.T) { + fake := &workflowCreateFakeServer{} + s := newWorkflowCreateTestServer(t, fake, false) + + form := url.Values{} + maps.Copy(form, valid) + mutate(form) + + rec := postShardForm(t, s, createMoveTablesPath, form) + + assert.Equal(t, http.StatusBadRequest, rec.Code) + assert.Nil(t, fake.moveTablesCreateReq) + }) + } +} + +func TestCreateMoveTablesPostReadOnly(t *testing.T) { + fake := &workflowCreateFakeServer{} + s := newWorkflowCreateTestServer(t, fake, true) + + form := url.Values{ + "cluster_id": {testClusterID}, + "workflow": {"w"}, + "source_keyspace": {testKeyspace}, + "target_keyspace": {"sales"}, + "table": {"users"}, + } + rec := postShardForm(t, s, createMoveTablesPath, form) + + assert.Equal(t, http.StatusForbidden, rec.Code) + assert.Nil(t, fake.moveTablesCreateReq) +} + +func TestCreateMoveTablesPostInvalidCSRF(t *testing.T) { + fake := &workflowCreateFakeServer{} + s := newWorkflowCreateTestServer(t, fake, false) + + form := url.Values{ + "cluster_id": {testClusterID}, + "workflow": {"w"}, + "source_keyspace": {testKeyspace}, + "target_keyspace": {"sales"}, + "table": {"users"}, + "csrf_token": {"wrong"}, + } + req := httptest.NewRequest(http.MethodPost, createMoveTablesPath, strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.AddCookie(&http.Cookie{Name: csrfCookieName, Value: testCSRF}) + rec := httptest.NewRecorder() + s.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusForbidden, rec.Code) + assert.Nil(t, fake.moveTablesCreateReq) +} + +func (f *workflowCreateFakeServer) ReshardCreate(ctx context.Context, req *vtadminpb.ReshardCreateRequest) (*vtctldatapb.WorkflowStatusResponse, error) { + f.reshardCreateReq = req + if f.reshardErr != nil { + return nil, f.reshardErr + } + return &vtctldatapb.WorkflowStatusResponse{}, nil +} + +func TestWorkflowsListLinksToCreateMoveTables(t *testing.T) { + fake := &workflowCreateFakeServer{} + s := newWorkflowCreateTestServer(t, fake, false) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/workflows", nil) + s.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + assert.Contains(t, rec.Body.String(), `href="`+createMoveTablesPath+`"`) +} + +func (f *workflowCreateFakeServer) MaterializeCreate(ctx context.Context, req *vtadminpb.MaterializeCreateRequest) (*vtctldatapb.MaterializeCreateResponse, error) { + f.materializeCreateReq = req + if f.materializeErr != nil { + return nil, f.materializeErr + } + return &vtctldatapb.MaterializeCreateResponse{}, nil +} + +const createMaterializePath = "/workflows/materialize/create" + +func TestCreateMaterializeFormRendersOptions(t *testing.T) { + fake := &workflowCreateFakeServer{} + s := newWorkflowCreateTestServer(t, fake, false) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, createMaterializePath+"?source_keyspace=commerce", nil) + s.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + body := rec.Body.String() + + assert.Contains(t, body, `name="workflow"`) + assert.Contains(t, body, `name="source_keyspace"`) + assert.Contains(t, body, `name="target_keyspace"`) + assert.Contains(t, body, `name="table_settings"`) + assert.Contains(t, body, `placeholder='[{"target_table":"target_table","source_expression":"select * from source_table"}]'`) + assert.Contains(t, body, `name="cell"`) + assert.Contains(t, body, `name="tablet_type" value="REPLICA" checked`) + assert.Contains(t, body, `name="tablet_type" value="PRIMARY" checked`) + assert.Contains(t, body, `name="reference_table" value="users"`) + assert.Contains(t, body, `name="reference_table" value="orders"`) + assert.Contains(t, body, "Reference tables (in commerce)") + assert.Contains(t, body, `name="csrf_token"`) + assert.NotNil(t, findCookie(rec, csrfCookieName)) +} + +func TestCreateMaterializeFormDefaultsClusterWithoutQuery(t *testing.T) { + fake := &workflowCreateFakeServer{} + s := newWorkflowCreateTestServer(t, fake, false) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, createMaterializePath, nil) + s.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + body := rec.Body.String() + assert.Contains(t, body, `name="source_keyspace"`) + assert.Contains(t, body, `value="commerce"`) + assert.Contains(t, body, `value="sales"`) + assert.NotContains(t, body, `name="workflow"`) +} + +func TestCreateMaterializeFormReadOnly(t *testing.T) { + fake := &workflowCreateFakeServer{} + s := newWorkflowCreateTestServer(t, fake, true) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, createMaterializePath, nil) + s.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusForbidden, rec.Code) + assert.Nil(t, fake.materializeCreateReq) +} + +func TestCreateMaterializePostRedirectsToWorkflowDetail(t *testing.T) { + fake := &workflowCreateFakeServer{} + s := newWorkflowCreateTestServer(t, fake, false) + + form := url.Values{ + "cluster_id": {testClusterID}, + "workflow": {"sales_summary"}, + "source_keyspace": {testKeyspace}, + "target_keyspace": {"sales"}, + "table_settings": {`{"target_table":"sales_summary"}`}, + "cell": {"zone1, zone2"}, + "tablet_type": {"REPLICA", "PRIMARY"}, + "tablet_selection_preference": {"on"}, + "stop_after_copy": {"on"}, + } + rec := postShardForm(t, s, createMaterializePath, form) + + assert.Equal(t, http.StatusSeeOther, rec.Code) + assert.Equal(t, "/workflow/local/sales/sales_summary", rec.Header().Get("Location")) + + req := fake.materializeCreateReq + require.NotNil(t, req) + assert.Equal(t, testClusterID, req.ClusterId) + assert.Equal(t, `{"target_table":"sales_summary"}`, req.TableSettings) + + settings := req.GetRequest().GetSettings() + assert.Equal(t, "sales_summary", settings.Workflow) + assert.Equal(t, testKeyspace, settings.SourceKeyspace) + assert.Equal(t, "sales", settings.TargetKeyspace) + assert.Empty(t, settings.ReferenceTables) + assert.Equal(t, "zone1,zone2", settings.Cell) + assert.Equal(t, "REPLICA,PRIMARY", settings.TabletTypes) + assert.True(t, settings.StopAfterCopy) + assert.Equal(t, tabletmanagerdatapb.TabletSelectionPreference_INORDER, settings.TabletSelectionPreference) + assert.Equal(t, vtctldatapb.MaterializationIntent_CUSTOM, settings.MaterializationIntent) +} + +func TestCreateMaterializePostReferenceTablesOnly(t *testing.T) { + fake := &workflowCreateFakeServer{} + s := newWorkflowCreateTestServer(t, fake, false) + + form := url.Values{ + "cluster_id": {testClusterID}, + "workflow": {"ref_copy"}, + "source_keyspace": {testKeyspace}, + "target_keyspace": {"sales"}, + "reference_table": {"users", "orders"}, + } + rec := postShardForm(t, s, createMaterializePath, form) + + assert.Equal(t, http.StatusSeeOther, rec.Code) + assert.Equal(t, "/workflow/local/sales/ref_copy", rec.Header().Get("Location")) + + req := fake.materializeCreateReq + require.NotNil(t, req) + assert.Empty(t, req.TableSettings) + assert.Equal(t, []string{"users", "orders"}, req.GetRequest().GetSettings().ReferenceTables) +} + +func TestCreateMaterializePostRejectsBothModes(t *testing.T) { + fake := &workflowCreateFakeServer{} + s := newWorkflowCreateTestServer(t, fake, false) + + form := url.Values{ + "cluster_id": {testClusterID}, + "workflow": {"both"}, + "source_keyspace": {testKeyspace}, + "target_keyspace": {"sales"}, + "table_settings": {`{"target_table":"x"}`}, + "reference_table": {"users"}, + } + rec := postShardForm(t, s, createMaterializePath, form) + + assert.Equal(t, http.StatusBadRequest, rec.Code) + assert.Nil(t, fake.materializeCreateReq) +} + +func TestCreateMaterializePostRejectsNeitherMode(t *testing.T) { + fake := &workflowCreateFakeServer{} + s := newWorkflowCreateTestServer(t, fake, false) + + form := url.Values{ + "cluster_id": {testClusterID}, + "workflow": {"neither"}, + "source_keyspace": {testKeyspace}, + "target_keyspace": {"sales"}, + } + rec := postShardForm(t, s, createMaterializePath, form) + + assert.Equal(t, http.StatusBadRequest, rec.Code) + assert.Nil(t, fake.materializeCreateReq) +} + +func TestCreateMaterializePostDefaults(t *testing.T) { + fake := &workflowCreateFakeServer{} + s := newWorkflowCreateTestServer(t, fake, false) + + form := url.Values{ + "cluster_id": {testClusterID}, + "workflow": {"minimal"}, + "source_keyspace": {testKeyspace}, + "target_keyspace": {"sales"}, + "table_settings": {"{}"}, + } + rec := postShardForm(t, s, createMaterializePath, form) + + assert.Equal(t, http.StatusSeeOther, rec.Code) + settings := fake.materializeCreateReq.GetRequest().GetSettings() + assert.Empty(t, settings.Cell) + assert.Equal(t, "REPLICA,PRIMARY", settings.TabletTypes) + assert.Equal(t, tabletmanagerdatapb.TabletSelectionPreference_ANY, settings.TabletSelectionPreference) + assert.False(t, settings.StopAfterCopy) +} + +func TestCreateMaterializePostValidation(t *testing.T) { + valid := url.Values{ + "cluster_id": {testClusterID}, + "workflow": {"w"}, + "source_keyspace": {testKeyspace}, + "target_keyspace": {"sales"}, + "table_settings": {"{}"}, + } + + cases := map[string]func(f url.Values){ + "missing workflow": func(f url.Values) { f.Del("workflow") }, + "missing source": func(f url.Values) { f.Del("source_keyspace") }, + "missing target": func(f url.Values) { f.Del("target_keyspace") }, + "missing table settings": func(f url.Values) { f.Del("table_settings") }, + "invalid tablet type": func(f url.Values) { f.Set("tablet_type", "BOGUS") }, + } + + for name, mutate := range cases { + t.Run(name, func(t *testing.T) { + fake := &workflowCreateFakeServer{} + s := newWorkflowCreateTestServer(t, fake, false) + + form := url.Values{} + maps.Copy(form, valid) + mutate(form) + + rec := postShardForm(t, s, createMaterializePath, form) + + assert.Equal(t, http.StatusBadRequest, rec.Code) + assert.Nil(t, fake.materializeCreateReq) + }) + } +} + +func (f *workflowCreateFakeServer) ApplySchema(ctx context.Context, req *vtadminpb.ApplySchemaRequest) (*vtctldatapb.ApplySchemaResponse, error) { + f.applySchemaReq = req + if f.applySchemaErr != nil { + return nil, f.applySchemaErr + } + return &vtctldatapb.ApplySchemaResponse{UuidList: []string{"0b1c2d3e"}}, nil +} + +const createMigrationPath = "/migrations/create" + +func TestCreateMigrationFormRendersOptions(t *testing.T) { + fake := &workflowCreateFakeServer{} + s := newWorkflowCreateTestServer(t, fake, false) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, createMigrationPath+"?cluster_id=local&keyspace=commerce", nil) + s.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + body := rec.Body.String() + + assert.Contains(t, body, `type="hidden" name="cluster_id" value="local"`) + assert.NotContains(t, body, ``) +} + +func TestCreateMigrationFormUnknownClusterFallsBackWhenSingleCluster(t *testing.T) { + fake := &workflowCreateFakeServer{} + s := newWorkflowCreateTestServer(t, fake, false) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, createMigrationPath+"?cluster_id=missing", nil) + s.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + body := rec.Body.String() + assert.Contains(t, body, `name="sql"`) + assert.Contains(t, body, `type="hidden" name="cluster_id" value="local"`) + assert.NotContains(t, body, "Choose a cluster") +} + +func TestCreateMigrationFormUnknownClusterRepicksWhenMultiCluster(t *testing.T) { + fake := &multiClusterFakeServer{} + s, err := NewServer(fake, Options{}) + require.NoError(t, err) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, createMigrationPath+"?cluster_id=missing", nil) + s.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + body := rec.Body.String() + assert.NotContains(t, body, `name="sql"`) + assert.Contains(t, body, "/migrations/create?cluster_id=local") + assert.Contains(t, body, "/migrations/create?cluster_id=prod") +} + +func TestCreateMigrationFormQueryEscapesClusterID(t *testing.T) { + fake := &reservedCharClusterFakeServer{} + s, err := NewServer(fake, Options{}) + require.NoError(t, err) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, createMigrationPath, nil) + s.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + body := rec.Body.String() + assert.Contains(t, body, "/migrations/create?cluster_id="+url.QueryEscape(reservedCharClusterID)) + assert.NotContains(t, body, "/migrations/create?cluster_id="+reservedCharClusterID+"\"") +} + +func TestCreateMigrationPostRedirectsToMigrations(t *testing.T) { + fake := &workflowCreateFakeServer{} + s := newWorkflowCreateTestServer(t, fake, false) + + form := url.Values{ + "cluster_id": {testClusterID}, + "keyspace": {testKeyspace}, + "sql": {"ALTER TABLE users ADD COLUMN name varchar(128);\nALTER TABLE orders ADD COLUMN note varchar(255);"}, + "ddl_strategy": {"vitess"}, + "batch_size": {"10"}, + "caller_id": {"admin"}, + "migration_context": {"add-name-columns"}, + "uuid_list": {"uuid-1, uuid-2"}, + } + rec := postShardForm(t, s, createMigrationPath, form) + + assert.Equal(t, http.StatusSeeOther, rec.Code) + assert.Equal(t, "/migrations?keyspace=commerce&cluster_id=local", rec.Header().Get("Location")) + + req := fake.applySchemaReq + require.NotNil(t, req) + assert.Equal(t, testClusterID, req.ClusterId) + assert.Equal(t, "admin", req.CallerId) + // Raw multi-statement SQL is passed through; the vtadmin API layer splits it. + assert.Equal(t, "ALTER TABLE users ADD COLUMN name varchar(128);\nALTER TABLE orders ADD COLUMN note varchar(255);", req.Sql) + assert.Empty(t, req.GetRequest().Sql) + + inner := req.GetRequest() + assert.Equal(t, testKeyspace, inner.Keyspace) + assert.Equal(t, "vitess", inner.DdlStrategy) + assert.Equal(t, int64(10), inner.BatchSize) + assert.Equal(t, "add-name-columns", inner.MigrationContext) + assert.Equal(t, []string{"uuid-1", "uuid-2"}, inner.UuidList) +} + +func TestCreateMigrationPostValidation(t *testing.T) { + valid := url.Values{ + "cluster_id": {testClusterID}, + "keyspace": {testKeyspace}, + "sql": {"ALTER TABLE users ADD COLUMN name varchar(128);"}, + "ddl_strategy": {"vitess"}, + } + + cases := map[string]func(f url.Values){ + "missing keyspace": func(f url.Values) { f.Del("keyspace") }, + "missing sql": func(f url.Values) { f.Del("sql") }, + "invalid batch": func(f url.Values) { f.Set("batch_size", "ten") }, + } + + for name, mutate := range cases { + t.Run(name, func(t *testing.T) { + fake := &workflowCreateFakeServer{} + s := newWorkflowCreateTestServer(t, fake, false) + + form := url.Values{} + maps.Copy(form, valid) + mutate(form) + + rec := postShardForm(t, s, createMigrationPath, form) + + assert.Equal(t, http.StatusBadRequest, rec.Code) + assert.Nil(t, fake.applySchemaReq) + }) + } +} + +func TestCreateMigrationPostReadOnly(t *testing.T) { + fake := &workflowCreateFakeServer{} + s := newWorkflowCreateTestServer(t, fake, true) + + form := url.Values{ + "cluster_id": {testClusterID}, + "keyspace": {testKeyspace}, + "sql": {"ALTER TABLE users ADD COLUMN name varchar(128);"}, + "ddl_strategy": {"vitess"}, + } + rec := postShardForm(t, s, createMigrationPath, form) + + assert.Equal(t, http.StatusForbidden, rec.Code) + assert.Nil(t, fake.applySchemaReq) +} + +func TestWorkflowsListLinksToCreateReshard(t *testing.T) { + fake := &workflowCreateFakeServer{} + s := newWorkflowCreateTestServer(t, fake, false) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/workflows", nil) + s.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + assert.Contains(t, rec.Body.String(), `href="`+createReshardPath+`"`) +} + +func TestCreateReshardFormMultiClusterPicksClusterFirst(t *testing.T) { + fake := &multiClusterFakeServer{} + s, err := NewServer(fake, Options{}) + require.NoError(t, err) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, createReshardPath, nil) + s.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + // No form until a cluster is picked explicitly. + assert.NotContains(t, rec.Body.String(), `name="workflow"`) + assert.Contains(t, rec.Body.String(), "/workflows/reshard/create?cluster_id=local") + assert.Contains(t, rec.Body.String(), "/workflows/reshard/create?cluster_id=prod") +} + +func TestCreateReshardFormLocksSelectedCluster(t *testing.T) { + fake := &multiClusterFakeServer{} + s, err := NewServer(fake, Options{}) + require.NoError(t, err) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, createReshardPath+"?cluster_id=prod", nil) + s.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + body := rec.Body.String() + assert.Contains(t, body, `name="workflow"`) + assert.Contains(t, body, `type="hidden" name="cluster_id" value="prod"`) + assert.NotContains(t, body, ``) +} + +func TestCreateReshardFormUnknownClusterFallsBackWhenSingleCluster(t *testing.T) { + fake := &workflowCreateFakeServer{} + s := newWorkflowCreateTestServer(t, fake, false) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, createReshardPath+"?cluster_id=missing", nil) + s.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + body := rec.Body.String() + assert.Contains(t, body, `name="workflow"`) + assert.Contains(t, body, `type="hidden" name="cluster_id" value="local"`) + assert.NotContains(t, body, "Choose a cluster") +} + +func TestCreateReshardFormUnknownClusterRepicksWhenMultiCluster(t *testing.T) { + fake := &multiClusterFakeServer{} + s, err := NewServer(fake, Options{}) + require.NoError(t, err) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, createReshardPath+"?cluster_id=missing", nil) + s.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + body := rec.Body.String() + assert.NotContains(t, body, `name="workflow"`) + assert.Contains(t, body, "/workflows/reshard/create?cluster_id=local") + assert.Contains(t, body, "/workflows/reshard/create?cluster_id=prod") +} + +func TestCreateReshardFormQueryEscapesClusterID(t *testing.T) { + fake := &reservedCharClusterFakeServer{} + s, err := NewServer(fake, Options{}) + require.NoError(t, err) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, createReshardPath, nil) + s.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + body := rec.Body.String() + assert.Contains(t, body, "/workflows/reshard/create?cluster_id="+url.QueryEscape(reservedCharClusterID)) + assert.NotContains(t, body, "/workflows/reshard/create?cluster_id="+reservedCharClusterID+"\"") +} + +type multiClusterFakeServer struct { + workflowCreateFakeServer +} + +func (f *multiClusterFakeServer) GetClusters(ctx context.Context, req *vtadminpb.GetClustersRequest) (*vtadminpb.GetClustersResponse, error) { + return &vtadminpb.GetClustersResponse{Clusters: []*vtadminpb.Cluster{ + {Id: "local", Name: "Local"}, + {Id: "prod", Name: "Production"}, + }}, nil +} + +func (f *multiClusterFakeServer) GetKeyspaces(ctx context.Context, req *vtadminpb.GetKeyspacesRequest) (*vtadminpb.GetKeyspacesResponse, error) { + return &vtadminpb.GetKeyspacesResponse{Keyspaces: []*vtadminpb.Keyspace{}}, nil +} + +const reservedCharClusterID = "team&env=prod" + +type reservedCharClusterFakeServer struct { + workflowCreateFakeServer +} + +func (f *reservedCharClusterFakeServer) GetClusters(ctx context.Context, req *vtadminpb.GetClustersRequest) (*vtadminpb.GetClustersResponse, error) { + return &vtadminpb.GetClustersResponse{Clusters: []*vtadminpb.Cluster{ + {Id: "local", Name: "Local"}, + {Id: reservedCharClusterID, Name: "Team"}, + }}, nil +} + +const createReshardPath = "/workflows/reshard/create" + +func TestCreateReshardFormRendersOptions(t *testing.T) { + fake := &workflowCreateFakeServer{} + s := newWorkflowCreateTestServer(t, fake, false) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, createReshardPath, nil) + s.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + body := rec.Body.String() + + // In a single-cluster setup the default cluster must be resolved so the + // keyspace select is populated without ?cluster_id in the URL. + assert.Contains(t, body, ``) + assert.Contains(t, rec.Body.String(), `commerce`) + assert.Contains(t, rec.Body.String(), "completed") +} + +func TestVDiffShowPageRequiresKeyspace(t *testing.T) { + fake := &workflowFakeServer{} + s, err := NewServer(fake, Options{}) + require.NoError(t, err) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/vdiff/local/show?workflow=wf1", nil) + s.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusBadRequest, rec.Code) + assert.Contains(t, rec.Body.String(), "keyspace") + assert.Nil(t, fake.vdiffShowRequest) +} + +func TestVDiffShowPageValidatesSubmissionBeforeLoadingFormOptions(t *testing.T) { + fake := &workflowFakeServer{getClustersError: errors.New("cluster options failed")} + s, err := NewServer(fake, Options{}) + require.NoError(t, err) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/vdiff/local/show?workflow=wf1", nil) + s.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusBadRequest, rec.Code) + assert.Contains(t, rec.Body.String(), "keyspace") + assert.NotContains(t, rec.Body.String(), "cluster options failed") + assert.Nil(t, fake.vdiffShowRequest) +} + +func TestVDiffShowPageRequiresWorkflow(t *testing.T) { + fake := &workflowFakeServer{} + s, err := NewServer(fake, Options{}) + require.NoError(t, err) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/vdiff/local/show?keyspace=commerce", nil) + s.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusBadRequest, rec.Code) + assert.Contains(t, rec.Body.String(), "workflow") + assert.Nil(t, fake.vdiffShowRequest) +} + +func TestWorkflowsPageRejectsInvalidActiveOnly(t *testing.T) { + fake := &workflowFakeServer{} + s, err := NewServer(fake, Options{}) + require.NoError(t, err) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/workflows?active_only=not-bool", nil) + s.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusBadRequest, rec.Code) + assert.Contains(t, rec.Body.String(), "active_only") + assert.Nil(t, fake.getWorkflowsRequest) +} diff --git a/go/vt/vtadmin/vtctldclient/fakevtctldclient/vtctldclient.go b/go/vt/vtadmin/vtctldclient/fakevtctldclient/vtctldclient.go index 428d38fc8e0..00a27ef578c 100644 --- a/go/vt/vtadmin/vtctldclient/fakevtctldclient/vtctldclient.go +++ b/go/vt/vtadmin/vtctldclient/fakevtctldclient/vtctldclient.go @@ -54,9 +54,10 @@ type VtctldClient struct { Response *vtctldatapb.CompleteSchemaMigrationResponse Error error } - CreateKeyspaceShouldErr bool - CreateShardShouldErr bool - DeleteKeyspaceShouldErr bool + CreateKeyspaceShouldErr bool + ConcludeTransactionShouldErr bool + CreateShardShouldErr bool + DeleteKeyspaceShouldErr bool // Keyed by _sorted_ list string joined by commas. DeleteShardsResults map[string]error // Keyed by _sorted_ TabletAlias list string joined by commas. @@ -167,7 +168,9 @@ type VtctldClient struct { Response *vtctldatapb.ValidateVersionKeyspaceResponse Error error } - WorkflowUpdateResults map[string]struct { + LastVDiffCreateRequest *vtctldatapb.VDiffCreateRequest + LastMaterializeCreateRequest *vtctldatapb.MaterializeCreateRequest + WorkflowUpdateResults map[string]struct { Response *vtctldatapb.WorkflowUpdateResponse Error error } @@ -240,6 +243,15 @@ func (fake *VtctldClient) CompleteSchemaMigration(ctx context.Context, req *vtct return nil, fmt.Errorf("%w: no result set for %s", assert.AnError, key) } +// ConcludeTransaction is part of the vtctldclient.VtctldClient interface. +func (fake *VtctldClient) ConcludeTransaction(ctx context.Context, req *vtctldatapb.ConcludeTransactionRequest, opts ...grpc.CallOption) (*vtctldatapb.ConcludeTransactionResponse, error) { + if fake.ConcludeTransactionShouldErr { + return nil, fmt.Errorf("%w: ConcludeTransaction error", assert.AnError) + } + + return &vtctldatapb.ConcludeTransactionResponse{}, nil +} + // CreateKeyspace is part of the vtctldclient.VtctldClient interface. func (fake *VtctldClient) CreateKeyspace(ctx context.Context, req *vtctldatapb.CreateKeyspaceRequest, opts ...grpc.CallOption) (*vtctldatapb.CreateKeyspaceResponse, error) { if fake.CreateKeyspaceShouldErr { @@ -807,6 +819,21 @@ func (fake *VtctldClient) ValidateVersionKeyspace(ctx context.Context, req *vtct return nil, fmt.Errorf("%w: no result set for %s", assert.AnError, key) } +// MaterializeCreate is part of the vtctldclient.VtctldClient interface. +func (fake *VtctldClient) MaterializeCreate(ctx context.Context, req *vtctldatapb.MaterializeCreateRequest, opts ...grpc.CallOption) (*vtctldatapb.MaterializeCreateResponse, error) { + fake.LastMaterializeCreateRequest = req + return &vtctldatapb.MaterializeCreateResponse{}, nil +} + +// VDiffCreate is part of the vtctldclient.VtctldClient interface. +func (fake *VtctldClient) VDiffCreate(ctx context.Context, req *vtctldatapb.VDiffCreateRequest, opts ...grpc.CallOption) (*vtctldatapb.VDiffCreateResponse, error) { + fake.LastVDiffCreateRequest = req + if req == nil { + return &vtctldatapb.VDiffCreateResponse{}, nil + } + return &vtctldatapb.VDiffCreateResponse{UUID: req.Uuid}, nil +} + // WorkflowUpdate is part of the vtctldclient.VtctldClient interface. func (fake *VtctldClient) WorkflowUpdate(ctx context.Context, req *vtctldatapb.WorkflowUpdateRequest, opts ...grpc.CallOption) (*vtctldatapb.WorkflowUpdateResponse, error) { if fake.WorkflowUpdateResults == nil { diff --git a/tools/make-release-packages.sh b/tools/make-release-packages.sh index e1a189d6507..90cdd7e7832 100755 --- a/tools/make-release-packages.sh +++ b/tools/make-release-packages.sh @@ -35,7 +35,7 @@ mkdir -p releases # Copy a subset of binaries from issue #5421 mkdir -p "${RELEASE_DIR}/bin" -for binary in vttestserver mysqlctl mysqlctld topo2topo vtaclcheck vtadmin vtbackup vtbench vtclient vtcombo vtctl vtctldclient vtctlclient vtctld vtexplain vtgate vttablet vtorc zk zkctl zkctld; do +for binary in vttestserver mysqlctl mysqlctld topo2topo vtaclcheck vtadmin vtadmin2 vtbackup vtbench vtclient vtcombo vtctl vtctldclient vtctlclient vtctld vtexplain vtgate vttablet vtorc zk zkctl zkctld; do cp "bin/$binary" "${RELEASE_DIR}/bin/" done;
{{ protoJSON .GetCellInfo }}
{{ protoJSON $alias }}
Use the navigation to inspect your Vitess clusters.
Validates that all nodes reachable from this keyspace are consistent.
Validates that the schema is consistent across all tablets in this keyspace.
Validates that the version on the primary matches all replicas.
Rebuilds the serving graph for this keyspace in the given cells. Leave cells empty for all.
Reloads the schema on all tablets in this keyspace, including the primary. Best-effort.
Removes the cell from the keyspace's SrvKeyspace. All shards in the keyspace must have no tablets in the cell, unless forced.
Applies DDL to a keyspace through Vitess's online schema migration (or directly, depending on the DDL strategy). Schema changes user guide.
Keyspaces are per cluster. Pick the cluster you want to migrate:
Choose a different cluster
{{ protoJSON .GetSchemaMigration }}
{{ $d.Definition.GetSchema }}
A Vindex maps a column value to a keyspace ID, which identifies the shard that contains the row. + Learn more about Vindexes.
{{ protoJSON . }}
Reloads the schema on all tablets in this shard. Best-effort; failures on individual tablets are logged by vtctld.
Checks that all nodes reachable from this shard are consistent.
Gracefully reparents this shard to the given tablet, or to the most up-to-date candidate when left empty. Brief write downtime is expected.
Assumes the current primary is dead and not responding. Use only when planned failover is not possible.
Updates the topo after an external tool (e.g. orchestrator) promoted a tablet. This does not itself reparent anything.
Deletes this shard from the topo. Fails if the shard still has tablets; deleting a serving shard requires force elsewhere. This cannot be undone.
{{ protoJSON .GetPositionInfo }}
{{ protoJSON $response }}
{{ protoJSON .GetSrvVSchema }}
{{ protoJSON $s }}
Full Status
Runs CHANGE REPLICATION SOURCE TO so this tablet replicates from the current shard primary.
Reloads the tablet record from the topology server.
Removes the tablet record from the topology server. The mysqld process itself is not stopped. This cannot be undone.
{{ protoJSON .Data.Tablet }}
{{ protoJSON .Data.Response }}
No keyspaces found.
{{ .Data.Response.GetResponse }}
{{ protoJSON .Data.GetVSchema }}
{{ protoJSON .GetVSchema }}
{{ protoJSON .Data.GetWorkflow }}
The VDiff is created asynchronously; open the VDiff report to follow its progress.
Materialize continuously copies data from one keyspace into tables in another, driven by per-table select expressions. Materialize user guide.
Stops VReplication once the backfill copy finishes, without catching up.
Pick a cluster and source keyspace above to continue.
MoveTables copies tables from a source keyspace into a target keyspace and keeps them in sync via VReplication, enabling zero-downtime migration or resharding. MoveTables user guide.
Could not list tables for this keyspace; you can still copy all tables.
When enabled, every table in the source keyspace is copied and individual selection is ignored.
Stops VReplication after the copy phase finishes, without catching up.
Creates secondary indexes in one shot after the table copy finishes instead of during it.
Starts the workflow automatically after creating it.
Reshard splits or merges shards within a keyspace: tablets are launched for the new shard ranges and data is copied from the source shards while staying in sync. Reshard user guide.
Shards are per cluster. Pick the cluster you want to reshard:
When unchecked, tablets of any type may be picked for the copy.
Use when the target shards already have the schema created.