go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/tools/internal/apigen/main.go (about)

     1  // Copyright 2015 The LUCI Authors.
     2  //
     3  // Licensed under the Apache License, Version 2.0 (the "License");
     4  // you may not use this file except in compliance with the License.
     5  // You may obtain a copy of the License at
     6  //
     7  //      http://www.apache.org/licenses/LICENSE-2.0
     8  //
     9  // Unless required by applicable law or agreed to in writing, software
    10  // distributed under the License is distributed on an "AS IS" BASIS,
    11  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    12  // See the License for the specific language governing permissions and
    13  // limitations under the License.
    14  
    15  package apigen
    16  
    17  import (
    18  	"bufio"
    19  	"bytes"
    20  	"context"
    21  	"encoding/json"
    22  	"errors"
    23  	"flag"
    24  	"fmt"
    25  	"io"
    26  	"io/ioutil"
    27  	"net/http"
    28  	"net/url"
    29  	"os"
    30  	"os/exec"
    31  	"os/signal"
    32  	"regexp"
    33  	"strings"
    34  	"text/template"
    35  	"time"
    36  
    37  	"go.chromium.org/luci/common/clock"
    38  	log "go.chromium.org/luci/common/logging"
    39  	"go.chromium.org/luci/common/retry"
    40  	"go.chromium.org/luci/common/sync/parallel"
    41  )
    42  
    43  const (
    44  	defaultPackageBase = "go.chromium.org/luci/common/api"
    45  
    46  	// chromiumLicence is the standard Chromium license header.
    47  	chromiumLicense = `` +
    48  		"// Copyright {{.Year}} The LUCI Authors.\n" +
    49  		"//\n" +
    50  		"// Licensed under the Apache License, Version 2.0 (the \"License\");\n" +
    51  		"// you may not use this file except in compliance with the License.\n" +
    52  		"// You may obtain a copy of the License at\n" +
    53  		"//\n" +
    54  		"//      http://www.apache.org/licenses/LICENSE-2.0\n" +
    55  		"//\n" +
    56  		"// Unless required by applicable law or agreed to in writing, software\n" +
    57  		"// distributed under the License is distributed on an \"AS IS\" BASIS,\n" +
    58  		"// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n" +
    59  		"// See the License for the specific language governing permissions and\n" +
    60  		"// limitations under the License.\n" +
    61  		"\n"
    62  )
    63  
    64  var (
    65  	// chromiumLicenseTemplate is the compiled Chromium license template text.
    66  	chromiumLicenseTemplate = template.Must(template.New("chromium license").Parse(chromiumLicense))
    67  
    68  	// apiGoGenLicenseHdr is a start of a comment block with license header put by
    69  	// google-api-go-generator, which we remove and replace with chromium license.
    70  	apiGoGenLicenseHdr = regexp.MustCompile("// Copyright [0-9]+ Google.*")
    71  )
    72  
    73  func compileChromiumLicense(c context.Context) (string, error) {
    74  	buf := bytes.Buffer{}
    75  	err := chromiumLicenseTemplate.Execute(&buf, map[string]any{
    76  		"Year": clock.Now(c).Year(),
    77  	})
    78  	if err != nil {
    79  		return "", err
    80  	}
    81  	return buf.String(), nil
    82  }
    83  
    84  // Application is the main apigen application instance.
    85  type Application struct {
    86  	servicePath    string
    87  	serviceAPIRoot string
    88  	genPath        string
    89  	apiPackage     string
    90  	apiSubproject  string
    91  	apiAllowlist   apiAllowlist
    92  	baseURL        string
    93  
    94  	license string
    95  }
    96  
    97  // AddToFlagSet adds application-level flags to the supplied FlagSet.
    98  func (a *Application) AddToFlagSet(fs *flag.FlagSet) {
    99  	flag.StringVar(&a.servicePath, "service", ".",
   100  		"Path to the AppEngine service to generate from.")
   101  	flag.StringVar(&a.serviceAPIRoot, "service-api-root", "/_ah/api/",
   102  		"The service's API root path.")
   103  	flag.StringVar(&a.genPath, "generator", "google-api-go-generator",
   104  		"Path to the `google-api-go-generator` binary to use.")
   105  	flag.StringVar(&a.apiPackage, "api-package", defaultPackageBase,
   106  		"Name of the root API package on GOPATH.")
   107  	flag.StringVar(&a.apiSubproject, "api-subproject", "",
   108  		"If supplied, place APIs in an additional subdirectory under -api-package.")
   109  	flag.Var(&a.apiAllowlist, "api",
   110  		"If supplied, limit the emitted APIs to those named. Can be specified "+
   111  			"multiple times.")
   112  	flag.StringVar(&a.baseURL, "base-url", "http://localhost:8080",
   113  		"Use this as the default base service client URL.")
   114  }
   115  
   116  func resolveExecutable(path *string) error {
   117  	if path == nil || *path == "" {
   118  		return errors.New("empty path")
   119  	}
   120  	lpath, err := exec.LookPath(*path)
   121  	if err != nil {
   122  		return fmt.Errorf("could not find [%s]: %s", *path, err)
   123  	}
   124  
   125  	st, err := os.Stat(lpath)
   126  	if err != nil {
   127  		return err
   128  	}
   129  	if st.Mode().Perm()&0111 == 0 {
   130  		return errors.New("file is not executable")
   131  	}
   132  	*path = lpath
   133  	return nil
   134  }
   135  
   136  // retryHTTP executes an HTTP call to the specified URL, retrying if it fails.
   137  //
   138  // It will return an error if no successful HTTP results were returned.
   139  // Otherwise, it will return the body of the successful HTTP response.
   140  func retryHTTP(c context.Context, u url.URL, method, body string) ([]byte, error) {
   141  	client := http.Client{}
   142  
   143  	gen := func() retry.Iterator {
   144  		return &retry.Limited{
   145  			Delay:   2 * time.Second,
   146  			Retries: 20,
   147  		}
   148  	}
   149  
   150  	output := []byte(nil)
   151  	err := retry.Retry(c, gen, func() error {
   152  		req := http.Request{
   153  			Method: method,
   154  			URL:    &u,
   155  			Header: http.Header{},
   156  		}
   157  		if len(body) > 0 {
   158  			req.Body = io.NopCloser(bytes.NewBuffer([]byte(body)))
   159  			req.ContentLength = int64(len(body))
   160  			req.Header.Add("Content-Type", "application/json")
   161  		}
   162  
   163  		resp, err := client.Do(&req)
   164  		if err != nil {
   165  			return err
   166  		}
   167  		if resp.Body != nil {
   168  			defer resp.Body.Close()
   169  			output, err = io.ReadAll(resp.Body)
   170  			if err != nil {
   171  				return err
   172  			}
   173  		}
   174  
   175  		switch resp.StatusCode {
   176  		case http.StatusOK, http.StatusNoContent:
   177  			return nil
   178  
   179  		default:
   180  			return fmt.Errorf("unsuccessful status code (%d): %s", resp.StatusCode, resp.Status)
   181  		}
   182  	}, func(err error, d time.Duration) {
   183  		log.Fields{
   184  			log.ErrorKey: err,
   185  			"url":        u.String(),
   186  			"delay":      d,
   187  		}.Infof(c, "Service is not up yet; retrying.")
   188  	})
   189  	if err != nil {
   190  		return nil, err
   191  	}
   192  
   193  	log.Fields{
   194  		"url": u.String(),
   195  	}.Infof(c, "Service is alive!")
   196  	return output, nil
   197  }
   198  
   199  // Run executes the application using the supplied context.
   200  //
   201  // Note that this intentionally consumes the Application by value, as we may
   202  // modify its configuration as parameters become resolved.
   203  func (a Application) Run(c context.Context) error {
   204  	if err := resolveExecutable(&a.genPath); err != nil {
   205  		return fmt.Errorf("invalid API generator path (-google-api-go-generator): %s", err)
   206  	}
   207  
   208  	apiDst, err := getPackagePath(a.apiPackage)
   209  	if err != nil {
   210  		return fmt.Errorf("failed to find package path for [%s]: %s", a.apiPackage, err)
   211  	}
   212  	if a.apiSubproject != "" {
   213  		apiDst = augPath(apiDst, a.apiSubproject)
   214  		a.apiPackage = strings.Join([]string{a.apiPackage, a.apiSubproject}, "/")
   215  	}
   216  	log.Fields{
   217  		"package": a.apiPackage,
   218  		"path":    apiDst,
   219  	}.Debugf(c, "Identified API destination package path.")
   220  
   221  	// Compile our Chromium license.
   222  	a.license, err = compileChromiumLicense(c)
   223  	if err != nil {
   224  		return fmt.Errorf("failed to compile Chromium license: %s", err)
   225  	}
   226  
   227  	c, cancelFunc := context.WithCancel(c)
   228  	sigC := make(chan os.Signal, 1)
   229  	signal.Notify(sigC, os.Interrupt)
   230  	go func() {
   231  		for range sigC {
   232  			cancelFunc()
   233  		}
   234  	}()
   235  	defer signal.Stop(sigC)
   236  
   237  	// (1) Execute our service. Capture its discovery API.
   238  	svc, err := loadService(c, a.servicePath)
   239  	if err != nil {
   240  		return fmt.Errorf("failed to load service [%s]: %s", a.servicePath, err)
   241  	}
   242  
   243  	err = svc.run(c, func(c context.Context, discoveryURL url.URL) error {
   244  		discoveryURL.Path = safeURLPathJoin(discoveryURL.Path, a.serviceAPIRoot, "discovery", "v1", "apis")
   245  
   246  		data, err := retryHTTP(c, discoveryURL, "GET", "")
   247  		if err != nil {
   248  			return fmt.Errorf("discovery server did not come online: %s", err)
   249  		}
   250  
   251  		dir := directoryList{}
   252  		if err := json.Unmarshal(data, &dir); err != nil {
   253  			return fmt.Errorf("failed to load directory list: %s", err)
   254  		}
   255  
   256  		// Ensure that our target API base directory exists.
   257  		if err := ensureDirectory(apiDst); err != nil {
   258  			return fmt.Errorf("failed to create destination directory: %s", err)
   259  		}
   260  
   261  		// Run "google-api-go-generator" against the hosted service.
   262  		err = parallel.FanOutIn(func(taskC chan<- func() error) {
   263  			for i, item := range dir.Items {
   264  				item := item
   265  				c := log.SetFields(c, log.Fields{
   266  					"index": i,
   267  					"api":   item.ID,
   268  				})
   269  
   270  				if !a.isAllowed(item.ID) {
   271  					log.Infof(c, "API is not requested; skipping.")
   272  					continue
   273  				}
   274  
   275  				taskC <- func() error {
   276  					return a.generateAPI(c, item, &discoveryURL, apiDst)
   277  				}
   278  			}
   279  		})
   280  		if err != nil {
   281  			return err
   282  		}
   283  		return nil
   284  	})
   285  	if err != nil {
   286  		log.Fields{
   287  			log.ErrorKey: err,
   288  		}.Errorf(c, "Failed to extract APIs.")
   289  	}
   290  
   291  	return nil
   292  }
   293  
   294  // generateAPI generates and installs a single directory item's API.
   295  func (a *Application) generateAPI(c context.Context, item *directoryItem, discoveryURL *url.URL, dst string) error {
   296  	tmpdir, err := ioutil.TempDir(os.TempDir(), "apigen")
   297  	if err != nil {
   298  		return err
   299  	}
   300  	defer func() {
   301  		os.RemoveAll(tmpdir)
   302  	}()
   303  
   304  	gendir := augPath(tmpdir, "gen")
   305  	headerPath := augPath(tmpdir, "header.txt")
   306  	if err := os.WriteFile(headerPath, []byte(a.license), 0644); err != nil {
   307  		return err
   308  	}
   309  
   310  	args := []string{
   311  		"-cache=false", // Apparently the form {"-cache", "false"} is ignored.
   312  		"-discoveryurl", discoveryURL.String(),
   313  		"-api", item.ID,
   314  		"-gendir", gendir,
   315  		"-api_pkg_base", a.apiPackage,
   316  		"-base_url", a.baseURL,
   317  		"-header_path", headerPath,
   318  	}
   319  	log.Fields{
   320  		"command": a.genPath,
   321  		"args":    args,
   322  	}.Debugf(c, "Executing google-api-go-generator.")
   323  	out, err := exec.Command(a.genPath, args...).CombinedOutput()
   324  	log.Infof(c, "Output:\n%s", out)
   325  	if err != nil {
   326  		return fmt.Errorf("error executing google-api-go-generator: %s", err)
   327  	}
   328  
   329  	err = installSource(gendir, dst, func(relpath string, data []byte) ([]byte, error) {
   330  		// Skip the root "api-list.json" file. This is generated only for the subset
   331  		// of APIs that this installation is handling, and is not representative of
   332  		// the full discovery (much less installation) API set.
   333  		if relpath == "api-list.json" {
   334  			return nil, nil
   335  		}
   336  
   337  		if !strings.HasSuffix(relpath, "-gen.go") {
   338  			return data, nil
   339  		}
   340  
   341  		log.Fields{
   342  			"relpath": relpath,
   343  		}.Infof(c, "Fixing up generated Go file.")
   344  
   345  		// Remove copyright header added by google-api-go-generator. We have our own
   346  		// already.
   347  		filtered := strings.Builder{}
   348  		alreadySkippedHeader := false
   349  		scanner := bufio.NewScanner(bytes.NewReader(data))
   350  		for scanner.Scan() {
   351  			if line := scanner.Text(); alreadySkippedHeader || !apiGoGenLicenseHdr.MatchString(line) {
   352  				// Use a vendored copy of "google.golang.org/api/internal/gensupport", since
   353  				// we can't refer to the internal one. See crbug.com/1003496.
   354  				line = strings.ReplaceAll(line,
   355  					`"google.golang.org/api/internal/gensupport"`,
   356  					`"go.chromium.org/luci/common/api/internal/gensupport"`)
   357  				// This is forbidden. We'll replace symbols imported from it.
   358  				if line == "\tinternal \"google.golang.org/api/internal\"" {
   359  					continue
   360  				}
   361  				// This is the only symbol imported from `internal`.
   362  				line = strings.ReplaceAll(line, "internal.Version", "\"luci-go\"")
   363  				// Finish writing the line to the output.
   364  				filtered.WriteString(line)
   365  				filtered.WriteRune('\n')
   366  				continue
   367  			}
   368  
   369  			// Found the start of the comment block with the header. Skip it all.
   370  			for scanner.Scan() {
   371  				if line := scanner.Text(); !strings.HasPrefix(line, "//") {
   372  					// The comment block is usually followed by an empty line which we
   373  					// also skip. But be careful in case it's not.
   374  					if line != "" {
   375  						filtered.WriteString(line)
   376  						filtered.WriteRune('\n')
   377  					}
   378  					break
   379  				}
   380  			}
   381  
   382  			// Carry on copying the rest of lines unchanged.
   383  			alreadySkippedHeader = true
   384  		}
   385  		if err := scanner.Err(); err != nil {
   386  			return nil, err
   387  		}
   388  		return []byte(filtered.String()), nil
   389  	})
   390  	if err != nil {
   391  		return fmt.Errorf("failed to install [%s]: %s", item.ID, err)
   392  	}
   393  	return nil
   394  }
   395  
   396  func (a *Application) isAllowed(id string) bool {
   397  	if len(a.apiAllowlist) == 0 {
   398  		return true
   399  	}
   400  	for _, w := range a.apiAllowlist {
   401  		if w == id {
   402  			return true
   403  		}
   404  	}
   405  	return false
   406  }
   407  
   408  func safeURLPathJoin(p ...string) string {
   409  	for i, v := range p {
   410  		p[i] = strings.Trim(v, "/")
   411  	}
   412  	return strings.Join(p, "/")
   413  }