vitess.io/vitess@v0.16.2/go/vt/vtctld/api.go (about)

     1  /*
     2  Copyright 2019 The Vitess Authors.
     3  
     4  Licensed under the Apache License, Version 2.0 (the "License");
     5  you may not use this file except in compliance with the License.
     6  You may obtain a copy of the License at
     7  
     8      http://www.apache.org/licenses/LICENSE-2.0
     9  
    10  Unless required by applicable law or agreed to in writing, software
    11  distributed under the License is distributed on an "AS IS" BASIS,
    12  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    13  See the License for the specific language governing permissions and
    14  limitations under the License.
    15  */
    16  
    17  package vtctld
    18  
    19  import (
    20  	"context"
    21  	"encoding/json"
    22  	"errors"
    23  	"fmt"
    24  	"io"
    25  	"net/http"
    26  	"strings"
    27  	"time"
    28  
    29  	"github.com/spf13/pflag"
    30  
    31  	"vitess.io/vitess/go/acl"
    32  	"vitess.io/vitess/go/netutil"
    33  	"vitess.io/vitess/go/vt/discovery"
    34  	"vitess.io/vitess/go/vt/log"
    35  	"vitess.io/vitess/go/vt/logutil"
    36  	"vitess.io/vitess/go/vt/mysqlctl"
    37  	"vitess.io/vitess/go/vt/proto/vttime"
    38  	"vitess.io/vitess/go/vt/schema"
    39  	"vitess.io/vitess/go/vt/schemamanager"
    40  	"vitess.io/vitess/go/vt/servenv"
    41  	"vitess.io/vitess/go/vt/topo"
    42  	"vitess.io/vitess/go/vt/topo/topoproto"
    43  	"vitess.io/vitess/go/vt/vtctl"
    44  	"vitess.io/vitess/go/vt/vttablet/tmclient"
    45  	"vitess.io/vitess/go/vt/wrangler"
    46  
    47  	logutilpb "vitess.io/vitess/go/vt/proto/logutil"
    48  	querypb "vitess.io/vitess/go/vt/proto/query"
    49  	topodatapb "vitess.io/vitess/go/vt/proto/topodata"
    50  )
    51  
    52  var (
    53  	localCell        string
    54  	proxyTablets     bool
    55  	showTopologyCRUD = true
    56  )
    57  
    58  // This file implements a REST-style API for the vtctld web interface.
    59  
    60  const (
    61  	apiPrefix       = "/api/"
    62  	jsonContentType = "application/json; charset=utf-8"
    63  )
    64  
    65  // TabletStats represents realtime stats from a discovery.TabletHealth struct.
    66  type TabletStats struct {
    67  	LastError string                 `json:"last_error,omitempty"`
    68  	Realtime  *querypb.RealtimeStats `json:"realtime,omitempty"`
    69  	Serving   bool                   `json:"serving"`
    70  	Up        bool                   `json:"up"`
    71  }
    72  
    73  // TabletWithStatsAndURL wraps topo.Tablet, adding a URL property and optional realtime stats.
    74  type TabletWithStatsAndURL struct {
    75  	Alias                *topodatapb.TabletAlias `json:"alias,omitempty"`
    76  	Hostname             string                  `json:"hostname,omitempty"`
    77  	PortMap              map[string]int32        `json:"port_map,omitempty"`
    78  	Keyspace             string                  `json:"keyspace,omitempty"`
    79  	Shard                string                  `json:"shard,omitempty"`
    80  	KeyRange             *topodatapb.KeyRange    `json:"key_range,omitempty"`
    81  	Type                 topodatapb.TabletType   `json:"type,omitempty"`
    82  	DbNameOverride       string                  `json:"db_name_override,omitempty"`
    83  	Tags                 map[string]string       `json:"tags,omitempty"`
    84  	MysqlHostname        string                  `json:"mysql_hostname,omitempty"`
    85  	MysqlPort            int32                   `json:"mysql_port,omitempty"`
    86  	PrimaryTermStartTime *vttime.Time            `json:"primary_term_start_time,omitempty"`
    87  	Stats                *TabletStats            `json:"stats,omitempty"`
    88  	URL                  string                  `json:"url,omitempty"`
    89  }
    90  
    91  func init() {
    92  	for _, cmd := range []string{"vtcombo", "vtctld"} {
    93  		servenv.OnParseFor(cmd, registerVtctldAPIFlags)
    94  	}
    95  }
    96  
    97  func registerVtctldAPIFlags(fs *pflag.FlagSet) {
    98  	fs.StringVar(&localCell, "cell", localCell, "cell to use")
    99  	fs.BoolVar(&proxyTablets, "proxy_tablets", proxyTablets, "Setting this true will make vtctld proxy the tablet status instead of redirecting to them")
   100  	fs.BoolVar(&showTopologyCRUD, "vtctld_show_topology_crud", showTopologyCRUD, "Controls the display of the CRUD topology actions in the vtctld UI.")
   101  	fs.MarkDeprecated("vtctld_show_topology_crud", "It is no longer applicable because vtctld no longer provides a UI.")
   102  }
   103  
   104  func newTabletWithStatsAndURL(t *topodatapb.Tablet, healthcheck discovery.HealthCheck) *TabletWithStatsAndURL {
   105  	tablet := &TabletWithStatsAndURL{
   106  		Alias:                t.Alias,
   107  		Hostname:             t.Hostname,
   108  		PortMap:              t.PortMap,
   109  		Keyspace:             t.Keyspace,
   110  		Shard:                t.Shard,
   111  		KeyRange:             t.KeyRange,
   112  		Type:                 t.Type,
   113  		DbNameOverride:       t.DbNameOverride,
   114  		Tags:                 t.Tags,
   115  		MysqlHostname:        t.MysqlHostname,
   116  		MysqlPort:            t.MysqlPort,
   117  		PrimaryTermStartTime: t.PrimaryTermStartTime,
   118  	}
   119  
   120  	if proxyTablets {
   121  		tablet.URL = fmt.Sprintf("/vttablet/%s-%d/debug/status", t.Alias.Cell, t.Alias.Uid)
   122  	} else {
   123  		tablet.URL = "http://" + netutil.JoinHostPort(t.Hostname, t.PortMap["vt"])
   124  	}
   125  
   126  	if healthcheck != nil {
   127  		if health, err := healthcheck.GetTabletHealth(discovery.KeyFromTablet(t), tablet.Alias); err == nil {
   128  			tablet.Stats = &TabletStats{
   129  				Realtime: health.Stats,
   130  				Serving:  health.Serving,
   131  				Up:       true,
   132  			}
   133  			if health.LastError != nil {
   134  				tablet.Stats.LastError = health.LastError.Error()
   135  			}
   136  		}
   137  	}
   138  
   139  	return tablet
   140  }
   141  
   142  func httpErrorf(w http.ResponseWriter, r *http.Request, format string, args ...any) {
   143  	errMsg := fmt.Sprintf(format, args...)
   144  	log.Errorf("HTTP error on %v: %v, request: %#v", r.URL.Path, errMsg, r)
   145  	http.Error(w, errMsg, http.StatusInternalServerError)
   146  }
   147  
   148  func handleAPI(apiPath string, handlerFunc func(w http.ResponseWriter, r *http.Request) error) {
   149  	http.HandleFunc(apiPrefix+apiPath, func(w http.ResponseWriter, r *http.Request) {
   150  		defer func() {
   151  			if x := recover(); x != nil {
   152  				httpErrorf(w, r, "uncaught panic: %v", x)
   153  			}
   154  		}()
   155  		if err := handlerFunc(w, r); err != nil {
   156  			httpErrorf(w, r, "%v", err)
   157  		}
   158  	})
   159  }
   160  
   161  func handleCollection(collection string, getFunc func(*http.Request) (any, error)) {
   162  	handleAPI(collection+"/", func(w http.ResponseWriter, r *http.Request) error {
   163  		// Get the requested object.
   164  		obj, err := getFunc(r)
   165  		if err != nil {
   166  			if topo.IsErrType(err, topo.NoNode) {
   167  				http.NotFound(w, r)
   168  				return nil
   169  			}
   170  			return fmt.Errorf("can't get %v: %v", collection, err)
   171  		}
   172  
   173  		// JSON encode response.
   174  		data, err := vtctl.MarshalJSON(obj)
   175  		log.Flush()
   176  		if err != nil {
   177  			return fmt.Errorf("cannot marshal data: %v", err)
   178  		}
   179  		w.Header().Set("Content-Type", jsonContentType)
   180  		w.Write(data)
   181  		return nil
   182  	})
   183  }
   184  
   185  func getItemPath(url string) string {
   186  	// Strip API prefix.
   187  	if !strings.HasPrefix(url, apiPrefix) {
   188  		return ""
   189  	}
   190  	url = url[len(apiPrefix):]
   191  
   192  	// Strip collection name.
   193  	parts := strings.SplitN(url, "/", 2)
   194  	if len(parts) != 2 {
   195  		return ""
   196  	}
   197  	return parts[1]
   198  }
   199  
   200  func unmarshalRequest(r *http.Request, v any) error {
   201  	data, err := io.ReadAll(r.Body)
   202  	if err != nil {
   203  		return err
   204  	}
   205  	return json.Unmarshal(data, v)
   206  }
   207  
   208  func initAPI(ctx context.Context, ts *topo.Server, actions *ActionRepository, healthcheck discovery.HealthCheck) {
   209  	tabletHealthCache := newTabletHealthCache(ts)
   210  	tmClient := tmclient.NewTabletManagerClient()
   211  
   212  	// Cells
   213  	handleCollection("cells", func(r *http.Request) (any, error) {
   214  		if getItemPath(r.URL.Path) != "" {
   215  			return nil, errors.New("cells can only be listed, not retrieved")
   216  		}
   217  		return ts.GetKnownCells(ctx)
   218  	})
   219  
   220  	// Keyspaces
   221  	handleCollection("keyspaces", func(r *http.Request) (any, error) {
   222  		keyspace := getItemPath(r.URL.Path)
   223  		switch r.Method {
   224  		case "GET":
   225  			// List all keyspaces.
   226  			if keyspace == "" {
   227  				return ts.GetKeyspaces(ctx)
   228  			}
   229  			// Get the keyspace record.
   230  			k, err := ts.GetKeyspace(ctx, keyspace)
   231  			if err != nil {
   232  				return nil, err
   233  			}
   234  			// Pass the embedded proto directly or jsonpb will panic.
   235  			return k.Keyspace, err
   236  			// Perform an action on a keyspace.
   237  		case "POST":
   238  			if keyspace == "" {
   239  				return nil, errors.New("a POST request needs a keyspace in the URL")
   240  			}
   241  			if err := r.ParseForm(); err != nil {
   242  				return nil, err
   243  			}
   244  
   245  			action := r.FormValue("action")
   246  			if action == "" {
   247  				return nil, errors.New("a POST request must specify action")
   248  			}
   249  			return actions.ApplyKeyspaceAction(ctx, action, keyspace), nil
   250  		default:
   251  			return nil, fmt.Errorf("unsupported HTTP method: %v", r.Method)
   252  		}
   253  	})
   254  
   255  	handleCollection("keyspace", func(r *http.Request) (any, error) {
   256  		// Valid requests: api/keyspace/my_ks/tablets (all shards)
   257  		// Valid requests: api/keyspace/my_ks/tablets/-80 (specific shard)
   258  		itemPath := getItemPath(r.URL.Path)
   259  		parts := strings.SplitN(itemPath, "/", 3)
   260  
   261  		malformedRequestError := fmt.Errorf("invalid keyspace path: %q  expected path: /keyspace/<keyspace>/tablets or /keyspace/<keyspace>/tablets/<shard>", itemPath)
   262  		if len(parts) < 2 {
   263  			return nil, malformedRequestError
   264  		}
   265  		if parts[1] != "tablets" {
   266  			return nil, malformedRequestError
   267  		}
   268  
   269  		keyspace := parts[0]
   270  		if keyspace == "" {
   271  			return nil, errors.New("keyspace is required")
   272  		}
   273  		var shardNames []string
   274  		if len(parts) > 2 && parts[2] != "" {
   275  			shardNames = []string{parts[2]}
   276  		} else {
   277  			var err error
   278  			shardNames, err = ts.GetShardNames(ctx, keyspace)
   279  			if err != nil {
   280  				return nil, err
   281  			}
   282  		}
   283  
   284  		if err := r.ParseForm(); err != nil {
   285  			return nil, err
   286  		}
   287  		cell := r.FormValue("cell")
   288  		cells := r.FormValue("cells")
   289  		filterCells := []string{} // empty == all cells
   290  		if cell != "" {
   291  			filterCells = []string{cell} // single cell
   292  		} else if cells != "" {
   293  			filterCells = strings.Split(cells, ",") // list of cells
   294  		}
   295  
   296  		tablets := [](*TabletWithStatsAndURL){}
   297  		for _, shard := range shardNames {
   298  			// Get tablets for this shard.
   299  			tabletAliases, err := ts.FindAllTabletAliasesInShardByCell(ctx, keyspace, shard, filterCells)
   300  			if err != nil && !topo.IsErrType(err, topo.PartialResult) {
   301  				return nil, err
   302  			}
   303  			for _, tabletAlias := range tabletAliases {
   304  				t, err := ts.GetTablet(ctx, tabletAlias)
   305  				if err != nil {
   306  					return nil, err
   307  				}
   308  				tablet := newTabletWithStatsAndURL(t.Tablet, healthcheck)
   309  				tablets = append(tablets, tablet)
   310  			}
   311  		}
   312  		return tablets, nil
   313  	})
   314  
   315  	// Shards
   316  	handleCollection("shards", func(r *http.Request) (any, error) {
   317  		shardPath := getItemPath(r.URL.Path)
   318  		if !strings.Contains(shardPath, "/") {
   319  			return nil, fmt.Errorf("invalid shard path: %q", shardPath)
   320  		}
   321  		parts := strings.SplitN(shardPath, "/", 2)
   322  		keyspace := parts[0]
   323  		shard := parts[1]
   324  
   325  		// List the shards in a keyspace.
   326  		if shard == "" {
   327  			return ts.GetShardNames(ctx, keyspace)
   328  		}
   329  
   330  		// Perform an action on a shard.
   331  		if r.Method == "POST" {
   332  			if err := r.ParseForm(); err != nil {
   333  				return nil, err
   334  			}
   335  			action := r.FormValue("action")
   336  			if action == "" {
   337  				return nil, errors.New("must specify action")
   338  			}
   339  			return actions.ApplyShardAction(ctx, action, keyspace, shard), nil
   340  		}
   341  
   342  		// Get the shard record.
   343  		si, err := ts.GetShard(ctx, keyspace, shard)
   344  		if err != nil {
   345  			return nil, err
   346  		}
   347  		// Pass the embedded proto directly or jsonpb will panic.
   348  		return si.Shard, err
   349  	})
   350  
   351  	// SrvKeyspace
   352  	handleCollection("srv_keyspace", func(r *http.Request) (any, error) {
   353  		keyspacePath := getItemPath(r.URL.Path)
   354  		parts := strings.SplitN(keyspacePath, "/", 2)
   355  
   356  		// Request was incorrectly formatted.
   357  		if len(parts) != 2 {
   358  			return nil, fmt.Errorf("invalid srvkeyspace path: %q  expected path: /srv_keyspace/<cell>/<keyspace>", keyspacePath)
   359  		}
   360  
   361  		cell := parts[0]
   362  		keyspace := parts[1]
   363  
   364  		if cell == "local" {
   365  			if localCell == "" {
   366  				cells, err := ts.GetCellInfoNames(ctx)
   367  				if err != nil {
   368  					return nil, fmt.Errorf("could not fetch cell info: %v", err)
   369  				}
   370  				if len(cells) == 0 {
   371  					return nil, fmt.Errorf("no local cells have been created yet")
   372  				}
   373  				cell = cells[0]
   374  			} else {
   375  				cell = localCell
   376  			}
   377  		}
   378  
   379  		// If a keyspace is provided then return the specified srvkeyspace.
   380  		if keyspace != "" {
   381  			srvKeyspace, err := ts.GetSrvKeyspace(ctx, cell, keyspace)
   382  			if err != nil {
   383  				return nil, fmt.Errorf("can't get server keyspace: %v", err)
   384  			}
   385  			return srvKeyspace, nil
   386  		}
   387  
   388  		// Else return the srvKeyspace from all keyspaces.
   389  		srvKeyspaces := make(map[string]any)
   390  		keyspaceNamesList, err := ts.GetSrvKeyspaceNames(ctx, cell)
   391  		if err != nil {
   392  			return nil, fmt.Errorf("can't get list of SrvKeyspaceNames for cell %q: GetSrvKeyspaceNames returned: %v", cell, err)
   393  		}
   394  		for _, keyspaceName := range keyspaceNamesList {
   395  			srvKeyspace, err := ts.GetSrvKeyspace(ctx, cell, keyspaceName)
   396  			if err != nil {
   397  				// If a keyspace is in the process of being set up, it exists
   398  				// in the list of keyspaces but GetSrvKeyspace fails.
   399  				//
   400  				// Instead of returning this error, simply skip it in the
   401  				// loop so we still return the other valid keyspaces.
   402  				continue
   403  			}
   404  			srvKeyspaces[keyspaceName] = srvKeyspace
   405  		}
   406  		return srvKeyspaces, nil
   407  
   408  	})
   409  
   410  	// Tablets
   411  	handleCollection("tablets", func(r *http.Request) (any, error) {
   412  		tabletPath := getItemPath(r.URL.Path)
   413  
   414  		// List tablets based on query params.
   415  		if tabletPath == "" {
   416  			if err := r.ParseForm(); err != nil {
   417  				return nil, err
   418  			}
   419  			shardRef := r.FormValue("shard")
   420  			cell := r.FormValue("cell")
   421  
   422  			if shardRef != "" {
   423  				// Look up by keyspace/shard, and optionally cell.
   424  				keyspace, shard, err := topoproto.ParseKeyspaceShard(shardRef)
   425  				if err != nil {
   426  					return nil, err
   427  				}
   428  				if cell != "" {
   429  					result, err := ts.FindAllTabletAliasesInShardByCell(ctx, keyspace, shard, []string{cell})
   430  					if err != nil && !topo.IsErrType(err, topo.PartialResult) {
   431  						return result, err
   432  					}
   433  					return result, nil
   434  				}
   435  				result, err := ts.FindAllTabletAliasesInShard(ctx, keyspace, shard)
   436  				if err != nil && !topo.IsErrType(err, topo.PartialResult) {
   437  					return result, err
   438  				}
   439  				return result, nil
   440  			}
   441  
   442  			// Get all tablets in a cell.
   443  			if cell == "" {
   444  				return nil, errors.New("cell param required")
   445  			}
   446  			return ts.GetTabletAliasesByCell(ctx, cell)
   447  		}
   448  
   449  		// Get tablet health.
   450  		if parts := strings.Split(tabletPath, "/"); len(parts) == 2 && parts[1] == "health" {
   451  			tabletAlias, err := topoproto.ParseTabletAlias(parts[0])
   452  			if err != nil {
   453  				return nil, err
   454  			}
   455  			return tabletHealthCache.Get(ctx, tabletAlias)
   456  		}
   457  
   458  		tabletAlias, err := topoproto.ParseTabletAlias(tabletPath)
   459  		if err != nil {
   460  			return nil, err
   461  		}
   462  
   463  		// Perform an action on a tablet.
   464  		if r.Method == "POST" {
   465  			if err := r.ParseForm(); err != nil {
   466  				return nil, err
   467  			}
   468  			action := r.FormValue("action")
   469  			if action == "" {
   470  				return nil, errors.New("must specify action")
   471  			}
   472  			return actions.ApplyTabletAction(ctx, action, tabletAlias, r), nil
   473  		}
   474  
   475  		// Get the tablet record.
   476  		t, err := ts.GetTablet(ctx, tabletAlias)
   477  		if err != nil {
   478  			return nil, err
   479  		}
   480  
   481  		return newTabletWithStatsAndURL(t.Tablet, nil), nil
   482  	})
   483  
   484  	// Healthcheck real time status per (cell, keyspace, tablet type, metric).
   485  	handleCollection("tablet_statuses", func(r *http.Request) (any, error) {
   486  		targetPath := getItemPath(r.URL.Path)
   487  
   488  		// Get the heatmap data based on query parameters.
   489  		if targetPath == "" {
   490  			if err := r.ParseForm(); err != nil {
   491  				return nil, err
   492  			}
   493  			keyspace := r.FormValue("keyspace")
   494  			cell := r.FormValue("cell")
   495  			tabletType := r.FormValue("type")
   496  			_, err := topoproto.ParseTabletType(tabletType)
   497  			// Excluding the case where parse fails because all tabletTypes was chosen.
   498  			if err != nil && tabletType != "all" {
   499  				return nil, fmt.Errorf("invalid tablet type: %v ", err)
   500  			}
   501  			metric := r.FormValue("metric")
   502  
   503  			// Setting default values if none was specified in the query params.
   504  			if keyspace == "" {
   505  				keyspace = "all"
   506  			}
   507  			if cell == "" {
   508  				cell = "all"
   509  			}
   510  			if tabletType == "" {
   511  				tabletType = "all"
   512  			}
   513  			if metric == "" {
   514  				metric = "health"
   515  			}
   516  
   517  			if healthcheck == nil {
   518  				return nil, fmt.Errorf("healthcheck not initialized")
   519  			}
   520  
   521  			heatmap, err := heatmapData(healthcheck, keyspace, cell, tabletType, metric)
   522  			if err != nil {
   523  				return nil, fmt.Errorf("couldn't get heatmap data: %v", err)
   524  			}
   525  			return heatmap, nil
   526  		}
   527  
   528  		return nil, fmt.Errorf("invalid target path: %q  expected path: ?keyspace=<keyspace>&cell=<cell>&type=<type>&metric=<metric>", targetPath)
   529  	})
   530  
   531  	handleCollection("tablet_health", func(r *http.Request) (any, error) {
   532  		tabletPath := getItemPath(r.URL.Path)
   533  		parts := strings.SplitN(tabletPath, "/", 2)
   534  
   535  		// Request was incorrectly formatted.
   536  		if len(parts) != 2 {
   537  			return nil, fmt.Errorf("invalid tablet_health path: %q  expected path: /tablet_health/<cell>/<uid>", tabletPath)
   538  		}
   539  
   540  		if healthcheck == nil {
   541  			return nil, fmt.Errorf("healthcheck not initialized")
   542  		}
   543  
   544  		cell := parts[0]
   545  		uidStr := parts[1]
   546  		uid, err := topoproto.ParseUID(uidStr)
   547  		if err != nil {
   548  			return nil, fmt.Errorf("incorrect uid: %v", err)
   549  		}
   550  
   551  		tabletAlias := topodatapb.TabletAlias{
   552  			Cell: cell,
   553  			Uid:  uid,
   554  		}
   555  		tabletStat, err := healthcheck.GetTabletHealthByAlias(&tabletAlias)
   556  		if err != nil {
   557  			return nil, fmt.Errorf("could not get tabletStats: %v", err)
   558  		}
   559  		return tabletStat, nil
   560  	})
   561  
   562  	handleCollection("topology_info", func(r *http.Request) (any, error) {
   563  		targetPath := getItemPath(r.URL.Path)
   564  
   565  		// Retrieving topology information (keyspaces, cells, and types) based on query params.
   566  		if targetPath == "" {
   567  			if err := r.ParseForm(); err != nil {
   568  				return nil, err
   569  			}
   570  			keyspace := r.FormValue("keyspace")
   571  			cell := r.FormValue("cell")
   572  
   573  			// Setting default values if none was specified in the query params.
   574  			if keyspace == "" {
   575  				keyspace = "all"
   576  			}
   577  			if cell == "" {
   578  				cell = "all"
   579  			}
   580  
   581  			if healthcheck == nil {
   582  				return nil, fmt.Errorf("realtimeStats not initialized")
   583  			}
   584  
   585  			return getTopologyInfo(healthcheck, keyspace, cell), nil
   586  		}
   587  		return nil, fmt.Errorf("invalid target path: %q  expected path: ?keyspace=<keyspace>&cell=<cell>", targetPath)
   588  	})
   589  
   590  	// Vtctl Command
   591  	handleAPI("vtctl/", func(w http.ResponseWriter, r *http.Request) error {
   592  		if err := acl.CheckAccessHTTP(r, acl.ADMIN); err != nil {
   593  			http.Error(w, "403 Forbidden", http.StatusForbidden)
   594  			return nil
   595  		}
   596  		var args []string
   597  		resp := struct {
   598  			Error  string
   599  			Output string
   600  		}{}
   601  		if err := unmarshalRequest(r, &args); err != nil {
   602  			return fmt.Errorf("can't unmarshal request: %v", err)
   603  		}
   604  
   605  		logstream := logutil.NewMemoryLogger()
   606  
   607  		wr := wrangler.New(logstream, ts, tmClient)
   608  		err := vtctl.RunCommand(r.Context(), wr, args)
   609  		if err != nil {
   610  			resp.Error = err.Error()
   611  		}
   612  		resp.Output = logstream.String()
   613  		data, err := json.MarshalIndent(resp, "", "  ")
   614  		if err != nil {
   615  			return fmt.Errorf("json error: %v", err)
   616  		}
   617  		w.Header().Set("Content-Type", jsonContentType)
   618  		w.Write(data)
   619  		return nil
   620  	})
   621  
   622  	// Schema Change
   623  	handleAPI("schema/apply", func(w http.ResponseWriter, r *http.Request) error {
   624  		if err := acl.CheckAccessHTTP(r, acl.ADMIN); err != nil {
   625  			http.Error(w, "403 Forbidden", http.StatusForbidden)
   626  			return nil
   627  		}
   628  		req := struct {
   629  			Keyspace, SQL         string
   630  			ReplicaTimeoutSeconds int
   631  			DDLStrategy           string `json:"ddl_strategy,omitempty"`
   632  		}{}
   633  		if err := unmarshalRequest(r, &req); err != nil {
   634  			return fmt.Errorf("can't unmarshal request: %v", err)
   635  		}
   636  		if req.ReplicaTimeoutSeconds <= 0 {
   637  			req.ReplicaTimeoutSeconds = 10
   638  		}
   639  
   640  		logger := logutil.NewCallbackLogger(func(ev *logutilpb.Event) {
   641  			w.Write([]byte(logutil.EventString(ev)))
   642  		})
   643  		wr := wrangler.New(logger, ts, tmClient)
   644  
   645  		apiCallUUID, err := schema.CreateUUID()
   646  		if err != nil {
   647  			return err
   648  		}
   649  
   650  		requestContext := fmt.Sprintf("vtctld/api:%s", apiCallUUID)
   651  		executor := schemamanager.NewTabletExecutor(requestContext, wr.TopoServer(), wr.TabletManagerClient(), wr.Logger(), time.Duration(req.ReplicaTimeoutSeconds)*time.Second)
   652  		if err := executor.SetDDLStrategy(req.DDLStrategy); err != nil {
   653  			return fmt.Errorf("error setting DDL strategy: %v", err)
   654  		}
   655  
   656  		_, err = schemamanager.Run(ctx,
   657  			schemamanager.NewUIController(req.SQL, req.Keyspace, w), executor)
   658  		return err
   659  	})
   660  
   661  	// Features
   662  	handleAPI("features", func(w http.ResponseWriter, r *http.Request) error {
   663  		if err := acl.CheckAccessHTTP(r, acl.ADMIN); err != nil {
   664  			http.Error(w, "403 Forbidden", http.StatusForbidden)
   665  			return nil
   666  		}
   667  
   668  		resp := make(map[string]any)
   669  		resp["activeReparents"] = !mysqlctl.DisableActiveReparents
   670  		resp["showStatus"] = enableRealtimeStats
   671  		data, err := json.MarshalIndent(resp, "", "  ")
   672  		if err != nil {
   673  			return fmt.Errorf("json error: %v", err)
   674  		}
   675  		w.Header().Set("Content-Type", jsonContentType)
   676  		w.Write(data)
   677  		return nil
   678  	})
   679  }