...

Source file src/edge-infra.dev/pkg/f8n/devinfra/ghappman/client.go

Documentation: edge-infra.dev/pkg/f8n/devinfra/ghappman

     1  package ghappman
     2  
     3  import (
     4  	"bytes"
     5  	"context"
     6  	"crypto/rand"
     7  	"encoding/base64"
     8  	"encoding/json"
     9  	"fmt"
    10  	"io"
    11  	"net/http"
    12  	"os"
    13  	"time"
    14  
    15  	credentials "cloud.google.com/go/iam/credentials/apiv1"
    16  	"cloud.google.com/go/iam/credentials/apiv1/credentialspb"
    17  	"golang.org/x/oauth2"
    18  )
    19  
    20  // Client embeds an http.Client for accessing the ghappman service. It provides auth
    21  // via an OIDC token
    22  type Client struct {
    23  	*http.Client
    24  }
    25  
    26  // CreateGitHubApp prints a prompt to start the app creation flow. It blocks until the user creates the app
    27  // or a timeout occurs. A '*' passed as installSuffix will cause a random suffix to be generated.
    28  func CreateGitHubApp(ctx context.Context, installSuffix string) (*AppConfig, error) {
    29  	b, err := generatePassword()
    30  	if err != nil {
    31  		return nil, err
    32  	}
    33  	userKey := base64.StdEncoding.EncodeToString(b)
    34  	// TODO: installSuffix will be generated before repo creation
    35  	// so repo and app have the same suffix. address when intergrating
    36  	// with full e2e bootstrap flow
    37  
    38  	client, err := NewIAPClient(ctx)
    39  	if err != nil {
    40  		return nil, err
    41  	}
    42  
    43  	if installSuffix == "*" {
    44  		installSuffix = RandAN(8)
    45  	}
    46  	req, err := json.Marshal(&CreateAppReq{UserKey: userKey, InstallSuffix: installSuffix})
    47  	if err != nil {
    48  		return nil, err
    49  	}
    50  	res, err := client.Post(
    51  		fmt.Sprintf("%s/%s", ghamURL, "new-app"),
    52  		"application/json",
    53  		bytes.NewReader(req),
    54  	)
    55  	if err != nil {
    56  		return nil, err
    57  	}
    58  	resByte, _ := io.ReadAll(res.Body)
    59  	createAppRes := &CreateAppRes{}
    60  	err = json.Unmarshal(resByte, createAppRes)
    61  	if err != nil {
    62  		return nil, err
    63  	}
    64  
    65  	fmt.Println("=== beginning github app creation")
    66  	fmt.Printf("visit %v to initiate app creation\n", createAppRes.CreateURL)
    67  	waitTimeout, cancel := context.WithTimeout(ctx, createAppRes.ExpiresIn)
    68  	app, err := client.waitForAppInstallation(waitTimeout, createAppRes.State, userKey)
    69  	cancel()
    70  	if err != nil {
    71  		return nil, err
    72  	}
    73  	fmt.Printf("app '%s' has been created, but you may still need to finish the installation dialog\n", app.Name)
    74  	// TODO: if this is integrated with env bootstrapping, repo creation, pause until the app
    75  	// installation for the new (or existing) repo is complete
    76  	return app, nil
    77  }
    78  
    79  // NewIAPClient creates an http.Client configured to include Bearer token Auth headers. It uses
    80  // local, default gcloud credentials to obtain an ID token for accessing the given IAP Web App
    81  // (audience) via a service account (name). See:
    82  // https://cloud.google.com/iap/docs/authentication-howto#obtaining_an_oidc_token_in_all_other_cases
    83  func NewIAPClient(ctx context.Context) (*Client, error) {
    84  	c, err := credentials.NewIamCredentialsClient(ctx)
    85  	if err != nil {
    86  		return nil, fmt.Errorf("credentials.NewIamCredentialsClient: %v", err)
    87  	}
    88  	// Name is the service account to impersonate
    89  	// Audience is the OAuth2 client id for the IAP enabled web service
    90  	req := &credentialspb.GenerateIdTokenRequest{
    91  		Name:         "projects/-/serviceAccounts/114355029064852695579",
    92  		Audience:     "886862789596-v6bsskt8f7o4v0ql32trt354h7plcta7.apps.googleusercontent.com",
    93  		IncludeEmail: true,
    94  	}
    95  
    96  	resp, err := c.GenerateIdToken(ctx, req)
    97  	if err != nil {
    98  		return nil, fmt.Errorf("GenerateIdToken: %v", err)
    99  	}
   100  
   101  	// TODO: implement a ReuseTokenSource that obtains the idtoken once and caches it
   102  	// oauth2.ReuseTokenSource(t *oauth2.Token, src oauth2.TokenSource). Maybe not necessary
   103  	// for low frequency calls. Id tokens expire quickly anyways
   104  	ts := oauth2.StaticTokenSource(&oauth2.Token{
   105  		TokenType:   "Bearer",
   106  		AccessToken: resp.Token,
   107  	})
   108  	client := oauth2.NewClient(ctx, ts)
   109  
   110  	return &Client{client}, nil
   111  }
   112  
   113  func generatePassword() ([]byte, error) {
   114  	n := 32
   115  	b := make([]byte, n)
   116  	_, err := rand.Read(b)
   117  	if err != nil {
   118  		return nil, err
   119  	}
   120  	return b, nil
   121  }
   122  
   123  func (c *Client) waitForAppInstallation(ctx context.Context, state string, key string) (*AppConfig, error) {
   124  	interval := time.Second * 5
   125  	for {
   126  		select {
   127  		case <-ctx.Done():
   128  			if ctx.Err() == context.DeadlineExceeded {
   129  				return nil, fmt.Errorf("waiting for app creation timed out")
   130  			}
   131  		case <-time.After(interval):
   132  			retryable, app := c.makeAppInstallationRequest(state, key)
   133  			if !retryable && app != nil {
   134  				// TODO: the app response here contains the private key. to actually run the chariot app
   135  				// in a dev environment, we still need to get that secret into the env's project's secret manager
   136  				return app, nil
   137  			} else if !retryable && app == nil {
   138  				fmt.Println("something went wrong.")
   139  				os.Exit(1)
   140  			}
   141  		}
   142  	}
   143  }
   144  
   145  func (c *Client) makeAppInstallationRequest(state string, key string) (bool, *AppConfig) {
   146  	url := fmt.Sprintf("%s/new-app/%s/app", ghamURL, state)
   147  	appReq, err := http.NewRequest("GET", url, nil)
   148  	if err != nil {
   149  		fmt.Printf("failed to create request. err: %v\n", err.Error())
   150  		os.Exit(1)
   151  	}
   152  	appReq.Header.Add("X-Appkey", key)
   153  
   154  	appRes, err := c.Do(appReq)
   155  	if err != nil {
   156  		fmt.Printf("failed to make request. err: %v\n", err.Error())
   157  		os.Exit(1)
   158  	}
   159  
   160  	switch appRes.StatusCode {
   161  	case http.StatusBadRequest:
   162  		return true, nil
   163  	case http.StatusNotFound:
   164  		return false, nil
   165  	}
   166  
   167  	appResBody, _ := io.ReadAll(appRes.Body)
   168  	_ = appRes.Body.Close()
   169  	app := &AppConfig{}
   170  	err = json.Unmarshal(appResBody, app)
   171  	if err != nil {
   172  		fmt.Printf("failed to parse response. err: %v\nresponse: %s\n", err.Error(), appResBody)
   173  		os.Exit(1)
   174  	}
   175  	return false, app
   176  }
   177  

View as plain text