blob: 937069850c12d82986c45f3d1dad884f5c22dbca [file] [log] [blame] [edit]
// Copyright 2020 Google Inc. All rights reserved.
//
// 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"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"io/ioutil"
"log"
"net/http"
"net/url"
"os"
"path"
"time"
"golang.org/x/oauth2"
"golang.org/x/oauth2/google"
)
var (
target = flag.String("target", "", "the target to fetch from")
buildID = flag.String("build_id", "", "the build id to fetch from, can use '-branch' to get the latest passed build ID")
branch = flag.String("branch", "", "the branch to fetch from, used when '-build_id' is not provided,\nit would fetch the latest successful build")
artifact = flag.String("artifact", "", "the artifact to download")
output = flag.String("output", "", "the file name to save as")
clientID = flag.String("client_id", "", "[Optional] OAuth 2.0 Client ID. Must be used with '-secret'")
secret = flag.String("secret", "", "[Optional] OAuth 2.0 Client Secret. Must be used with '-client_id'")
port = flag.Int("port", 10502, "[Optional] the port number where the oauth callback server would listen on")
projectID = flag.String("project_id", "", "[Optional] the project id being used to access the fetch APIs.")
)
var writeToStdout = false
type BuildResponse struct {
Builds []Build `json:"builds"`
}
type Build struct {
BuildId string `json:"buildId"`
}
type Auth struct {
clientID string
secret string
}
func newAuth(clientID, secret string) *Auth {
return &Auth{clientID: clientID, secret: secret}
}
func newClient(ctx context.Context, auth *Auth) *http.Client {
if auth == nil {
return &http.Client{}
}
config := &oauth2.Config{
ClientID: auth.clientID,
ClientSecret: auth.secret,
Endpoint: google.Endpoint,
Scopes: []string{"https://www.googleapis.com/auth/androidbuild.internal"},
}
return newOAuthClient(ctx, config)
}
type FetchConfig struct {
client *http.Client
target string
buildID string
branch string
output string
artifact string
projectID string
args []string
}
func newFetchConfig(client *http.Client, target string, buildID string, branch string, output string, artifact string, projectID string, args []string) (*FetchConfig, error) {
config := &FetchConfig{
client: client,
target: target,
buildID: buildID,
branch: branch,
output: output,
artifact: artifact,
projectID: projectID,
args: args,
}
err := config.validate()
if err != nil {
return nil, err
}
return config, nil
}
func (c *FetchConfig) validate() error {
// We only support passing 1 argument `-` so if we have more than
// one argument this is an error state,
if len(c.args) > 1 {
return errors.New("too many arguments passed to fetch_artifact")
}
if len(c.args) > 0 {
writeToStdout = c.args[len(c.args)-1] == "-"
if !writeToStdout {
return fmt.Errorf(
"only supported final argument to fetch_artifact is `-` but got `%s`", c.args[len(c.args)-1])
}
if len(c.output) > 0 && writeToStdout {
return errors.New("both '-output' and '-' flags can not be used together")
}
}
// check user provided flags
if len(c.target) == 0 {
return errors.New("missing target")
}
if len(c.artifact) == 0 {
return errors.New("missing artifact")
}
if len(c.buildID) == 0 && len(c.branch) == 0 {
return errors.New("missing build_id or branch")
}
if len(c.buildID) != 0 && len(c.branch) != 0 {
return errors.New("too many arguments, you should only give build_id or branch")
}
if len(c.buildID) == 0 {
bid, err := getLatestGoodBuild(c)
if err != nil {
return err
}
c.buildID = bid
}
return nil
}
func errPrint(msg string) {
fmt.Fprintln(os.Stderr, msg)
os.Exit(1)
}
func main() {
flag.Parse()
args := flag.Args()
var auth *Auth
if len(*clientID) != 0 && len(*secret) != 0 {
auth = newAuth(*clientID, *secret)
} else if len(*clientID) != 0 || len(*secret) != 0 {
errPrint(fmt.Sprintf("missing client_id or client_secret."))
}
ctx := context.Background()
client := newClient(ctx, auth)
config, err := newFetchConfig(client, *target, *buildID, *branch, *output, *artifact, *projectID, args)
if err != nil {
errPrint(fmt.Sprintf("Config validation error: %s", err))
}
fetchArtifact(config)
}
func newOAuthClient(ctx context.Context, config *oauth2.Config) *http.Client {
token := tokenFromWeb(ctx, config)
return config.Client(ctx, token)
}
func tokenFromWeb(ctx context.Context, config *oauth2.Config) *oauth2.Token {
ch := make(chan string)
randState := fmt.Sprintf("st%d", time.Now().UnixNano())
ts := createServer(ch, randState)
go func() {
err := ts.ListenAndServe()
if err != http.ErrServerClosed {
errPrint(fmt.Sprintf("Listen and serve error: %v", err))
}
}()
defer ts.Close()
config.RedirectURL = "http://localhost" + ts.Addr
authURL := config.AuthCodeURL(randState)
log.Printf("Authorize this app at: %s", authURL)
code := <-ch
token, err := config.Exchange(ctx, code)
if err != nil {
errPrint(fmt.Sprintf("Token exchange error: %v", err))
}
return token
}
func createServer(ch chan string, state string) *http.Server {
return &http.Server{
Addr: fmt.Sprintf(":%d", *port),
Handler: http.HandlerFunc(handler(ch, state)),
}
}
func handler(ch chan string, randState string) func(http.ResponseWriter, *http.Request) {
return func(rw http.ResponseWriter, req *http.Request) {
if req.URL.Path == "/favicon.ico" {
http.Error(rw, "error: visiting /favicon.ico", 404)
return
}
if req.FormValue("state") != randState {
log.Printf("state: %s doesn't match. (expected: %s)", req.FormValue("state"), randState)
http.Error(rw, "invalid state", 500)
return
}
if code := req.FormValue("code"); code != "" {
fmt.Fprintf(rw, "<h1>Success</h1>Authorized.")
rw.(http.Flusher).Flush()
ch <- code
return
}
ch <- ""
http.Error(rw, "invalid code", 500)
}
}
func sendRequest(client *http.Client, url string) (*http.Response, error) {
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/json")
res, err := client.Do(req)
if err != nil {
return nil, err
}
return res, nil
}
func fetchArtifact(c *FetchConfig) error {
apiURL := fmt.Sprintf("https://androidbuildinternal.googleapis.com/android/internal/build/v3/builds/%s/%s/attempts/latest/artifacts/%s/url", url.QueryEscape(c.buildID), url.QueryEscape(c.target), url.QueryEscape(c.artifact))
if len(c.projectID) != 0 {
apiURL += fmt.Sprintf("?$userProject=%s", url.QueryEscape(c.projectID))
}
res, err := sendRequest(c.client, apiURL)
if err != nil {
return fmt.Errorf("error fetching artifact %w", err)
}
defer res.Body.Close()
if res.Status != "200 OK" {
body, _ := ioutil.ReadAll(res.Body)
errPrint(fmt.Sprintf("Unable to download artifact: %s\n %s.", res.Status, body))
}
if writeToStdout {
io.Copy(os.Stdout, res.Body)
return nil
}
fileName := c.artifact
if len(c.output) > 0 {
fileName = c.output
}
f, err := os.Create(path.Base(fileName))
if err != nil {
return fmt.Errorf("unable to create file %w", err)
}
defer f.Close()
io.Copy(f, res.Body)
fmt.Printf("File %s created.\n", f.Name())
return nil
}
func getLatestGoodBuild(c *FetchConfig) (string, error) {
apiURL := fmt.Sprintf("https://androidbuildinternal.googleapis.com/android/internal/build/v3/builds?branches=%s&buildAttemptStatus=complete&buildType=submitted&maxResults=1&successful=true&target=%s", url.QueryEscape(c.branch), url.QueryEscape(c.target))
if len(c.projectID) != 0 {
apiURL += fmt.Sprintf("&$userProject=%s", url.QueryEscape(c.projectID))
}
res, err := sendRequest(c.client, apiURL)
if err != nil {
return "", fmt.Errorf("send request error: %w", err)
}
defer res.Body.Close()
if res.Status != "200 OK" {
body, _ := ioutil.ReadAll(res.Body)
return "", fmt.Errorf("unable to get Build ID: %s\n %s", res.Status, body)
}
body, _ := io.ReadAll(res.Body)
var buildData BuildResponse
err = json.Unmarshal(body, &buildData)
if err != nil {
return "", fmt.Errorf("error parsing JSON: %w", err)
}
if len(buildData.Builds) == 0 {
return "", errors.New("error no build ID is found")
}
return buildData.Builds[0].BuildId, nil
}