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 }