1package api
2
3// For descriptions of service interfaces, see:
4// - https://online.visualstudio.com/api/swagger (for visualstudio.com)
5// - https://docs.github.com/en/rest/reference/repos (for api.github.com)
6// - https://github.com/github/github/blob/master/app/api/codespaces.rb (for vscs_internal)
7// TODO(adonovan): replace the last link with a public doc URL when available.
8
9// TODO(adonovan): a possible reorganization would be to split this
10// file into three internal packages, one per backend service, and to
11// rename api.API to github.Client:
12//
13// - github.GetUser(github.Client)
14// - github.GetRepository(Client)
15// - github.ReadFile(Client, nwo, branch, path) // was GetCodespaceRepositoryContents
16// - github.AuthorizedKeys(Client, user)
17// - codespaces.Create(Client, user, repo, sku, branch, location)
18// - codespaces.Delete(Client, user, token, name)
19// - codespaces.Get(Client, token, owner, name)
20// - codespaces.GetMachineTypes(Client, user, repo, branch, location)
21// - codespaces.GetToken(Client, login, name)
22// - codespaces.List(Client, user)
23// - codespaces.Start(Client, token, codespace)
24// - visualstudio.GetRegionLocation(http.Client) // no dependency on github
25//
26// This would make the meaning of each operation clearer.
27
28import (
29	"bytes"
30	"context"
31	"encoding/base64"
32	"encoding/json"
33	"errors"
34	"fmt"
35	"io/ioutil"
36	"net/http"
37	"net/url"
38	"reflect"
39	"regexp"
40	"strconv"
41	"strings"
42	"time"
43
44	"github.com/cli/cli/v2/api"
45	"github.com/opentracing/opentracing-go"
46)
47
48const (
49	githubServer = "https://github.com"
50	githubAPI    = "https://api.github.com"
51	vscsAPI      = "https://online.visualstudio.com"
52)
53
54// API is the interface to the codespace service.
55type API struct {
56	client       httpClient
57	vscsAPI      string
58	githubAPI    string
59	githubServer string
60}
61
62type httpClient interface {
63	Do(req *http.Request) (*http.Response, error)
64}
65
66// New creates a new API client connecting to the configured endpoints with the HTTP client.
67func New(serverURL, apiURL, vscsURL string, httpClient httpClient) *API {
68	if serverURL == "" {
69		serverURL = githubServer
70	}
71	if apiURL == "" {
72		apiURL = githubAPI
73	}
74	if vscsURL == "" {
75		vscsURL = vscsAPI
76	}
77	return &API{
78		client:       httpClient,
79		vscsAPI:      strings.TrimSuffix(vscsURL, "/"),
80		githubAPI:    strings.TrimSuffix(apiURL, "/"),
81		githubServer: strings.TrimSuffix(serverURL, "/"),
82	}
83}
84
85// User represents a GitHub user.
86type User struct {
87	Login string `json:"login"`
88}
89
90// GetUser returns the user associated with the given token.
91func (a *API) GetUser(ctx context.Context) (*User, error) {
92	req, err := http.NewRequest(http.MethodGet, a.githubAPI+"/user", nil)
93	if err != nil {
94		return nil, fmt.Errorf("error creating request: %w", err)
95	}
96
97	a.setHeaders(req)
98	resp, err := a.do(ctx, req, "/user")
99	if err != nil {
100		return nil, fmt.Errorf("error making request: %w", err)
101	}
102	defer resp.Body.Close()
103
104	if resp.StatusCode != http.StatusOK {
105		return nil, api.HandleHTTPError(resp)
106	}
107
108	b, err := ioutil.ReadAll(resp.Body)
109	if err != nil {
110		return nil, fmt.Errorf("error reading response body: %w", err)
111	}
112
113	var response User
114	if err := json.Unmarshal(b, &response); err != nil {
115		return nil, fmt.Errorf("error unmarshaling response: %w", err)
116	}
117
118	return &response, nil
119}
120
121// Repository represents a GitHub repository.
122type Repository struct {
123	ID            int    `json:"id"`
124	FullName      string `json:"full_name"`
125	DefaultBranch string `json:"default_branch"`
126}
127
128// GetRepository returns the repository associated with the given owner and name.
129func (a *API) GetRepository(ctx context.Context, nwo string) (*Repository, error) {
130	req, err := http.NewRequest(http.MethodGet, a.githubAPI+"/repos/"+strings.ToLower(nwo), nil)
131	if err != nil {
132		return nil, fmt.Errorf("error creating request: %w", err)
133	}
134
135	a.setHeaders(req)
136	resp, err := a.do(ctx, req, "/repos/*")
137	if err != nil {
138		return nil, fmt.Errorf("error making request: %w", err)
139	}
140	defer resp.Body.Close()
141
142	if resp.StatusCode != http.StatusOK {
143		return nil, api.HandleHTTPError(resp)
144	}
145
146	b, err := ioutil.ReadAll(resp.Body)
147	if err != nil {
148		return nil, fmt.Errorf("error reading response body: %w", err)
149	}
150
151	var response Repository
152	if err := json.Unmarshal(b, &response); err != nil {
153		return nil, fmt.Errorf("error unmarshaling response: %w", err)
154	}
155
156	return &response, nil
157}
158
159// Codespace represents a codespace.
160type Codespace struct {
161	Name       string              `json:"name"`
162	CreatedAt  string              `json:"created_at"`
163	LastUsedAt string              `json:"last_used_at"`
164	Owner      User                `json:"owner"`
165	Repository Repository          `json:"repository"`
166	State      string              `json:"state"`
167	GitStatus  CodespaceGitStatus  `json:"git_status"`
168	Connection CodespaceConnection `json:"connection"`
169}
170
171type CodespaceGitStatus struct {
172	Ahead                int    `json:"ahead"`
173	Behind               int    `json:"behind"`
174	Ref                  string `json:"ref"`
175	HasUnpushedChanges   bool   `json:"has_unpushed_changes"`
176	HasUncommitedChanges bool   `json:"has_uncommited_changes"`
177}
178
179const (
180	// CodespaceStateAvailable is the state for a running codespace environment.
181	CodespaceStateAvailable = "Available"
182	// CodespaceStateShutdown is the state for a shutdown codespace environment.
183	CodespaceStateShutdown = "Shutdown"
184	// CodespaceStateStarting is the state for a starting codespace environment.
185	CodespaceStateStarting = "Starting"
186)
187
188type CodespaceConnection struct {
189	SessionID      string   `json:"sessionId"`
190	SessionToken   string   `json:"sessionToken"`
191	RelayEndpoint  string   `json:"relayEndpoint"`
192	RelaySAS       string   `json:"relaySas"`
193	HostPublicKeys []string `json:"hostPublicKeys"`
194}
195
196// CodespaceFields is the list of exportable fields for a codespace.
197var CodespaceFields = []string{
198	"name",
199	"owner",
200	"repository",
201	"state",
202	"gitStatus",
203	"createdAt",
204	"lastUsedAt",
205}
206
207func (c *Codespace) ExportData(fields []string) map[string]interface{} {
208	v := reflect.ValueOf(c).Elem()
209	data := map[string]interface{}{}
210
211	for _, f := range fields {
212		switch f {
213		case "owner":
214			data[f] = c.Owner.Login
215		case "repository":
216			data[f] = c.Repository.FullName
217		case "gitStatus":
218			data[f] = map[string]interface{}{
219				"ref":                  c.GitStatus.Ref,
220				"hasUnpushedChanges":   c.GitStatus.HasUnpushedChanges,
221				"hasUncommitedChanges": c.GitStatus.HasUncommitedChanges,
222			}
223		default:
224			sf := v.FieldByNameFunc(func(s string) bool {
225				return strings.EqualFold(f, s)
226			})
227			data[f] = sf.Interface()
228		}
229	}
230
231	return data
232}
233
234// ListCodespaces returns a list of codespaces for the user. Pass a negative limit to request all pages from
235// the API until all codespaces have been fetched.
236func (a *API) ListCodespaces(ctx context.Context, limit int) (codespaces []*Codespace, err error) {
237	perPage := 100
238	if limit > 0 && limit < 100 {
239		perPage = limit
240	}
241
242	listURL := fmt.Sprintf("%s/user/codespaces?per_page=%d", a.githubAPI, perPage)
243	for {
244		req, err := http.NewRequest(http.MethodGet, listURL, nil)
245		if err != nil {
246			return nil, fmt.Errorf("error creating request: %w", err)
247		}
248		a.setHeaders(req)
249
250		resp, err := a.do(ctx, req, "/user/codespaces")
251		if err != nil {
252			return nil, fmt.Errorf("error making request: %w", err)
253		}
254		defer resp.Body.Close()
255
256		if resp.StatusCode != http.StatusOK {
257			return nil, api.HandleHTTPError(resp)
258		}
259
260		var response struct {
261			Codespaces []*Codespace `json:"codespaces"`
262		}
263		dec := json.NewDecoder(resp.Body)
264		if err := dec.Decode(&response); err != nil {
265			return nil, fmt.Errorf("error unmarshaling response: %w", err)
266		}
267
268		nextURL := findNextPage(resp.Header.Get("Link"))
269		codespaces = append(codespaces, response.Codespaces...)
270
271		if nextURL == "" || (limit > 0 && len(codespaces) >= limit) {
272			break
273		}
274
275		if newPerPage := limit - len(codespaces); limit > 0 && newPerPage < 100 {
276			u, _ := url.Parse(nextURL)
277			q := u.Query()
278			q.Set("per_page", strconv.Itoa(newPerPage))
279			u.RawQuery = q.Encode()
280			listURL = u.String()
281		} else {
282			listURL = nextURL
283		}
284	}
285
286	return codespaces, nil
287}
288
289var linkRE = regexp.MustCompile(`<([^>]+)>;\s*rel="([^"]+)"`)
290
291func findNextPage(linkValue string) string {
292	for _, m := range linkRE.FindAllStringSubmatch(linkValue, -1) {
293		if len(m) > 2 && m[2] == "next" {
294			return m[1]
295		}
296	}
297	return ""
298}
299
300// GetCodespace returns the user codespace based on the provided name.
301// If the codespace is not found, an error is returned.
302// If includeConnection is true, it will return the connection information for the codespace.
303func (a *API) GetCodespace(ctx context.Context, codespaceName string, includeConnection bool) (*Codespace, error) {
304	req, err := http.NewRequest(
305		http.MethodGet,
306		a.githubAPI+"/user/codespaces/"+codespaceName,
307		nil,
308	)
309	if err != nil {
310		return nil, fmt.Errorf("error creating request: %w", err)
311	}
312
313	if includeConnection {
314		q := req.URL.Query()
315		q.Add("internal", "true")
316		q.Add("refresh", "true")
317		req.URL.RawQuery = q.Encode()
318	}
319
320	a.setHeaders(req)
321	resp, err := a.do(ctx, req, "/user/codespaces/*")
322	if err != nil {
323		return nil, fmt.Errorf("error making request: %w", err)
324	}
325	defer resp.Body.Close()
326
327	if resp.StatusCode != http.StatusOK {
328		return nil, api.HandleHTTPError(resp)
329	}
330
331	b, err := ioutil.ReadAll(resp.Body)
332	if err != nil {
333		return nil, fmt.Errorf("error reading response body: %w", err)
334	}
335
336	var response Codespace
337	if err := json.Unmarshal(b, &response); err != nil {
338		return nil, fmt.Errorf("error unmarshaling response: %w", err)
339	}
340
341	return &response, nil
342}
343
344// StartCodespace starts a codespace for the user.
345// If the codespace is already running, the returned error from the API is ignored.
346func (a *API) StartCodespace(ctx context.Context, codespaceName string) error {
347	req, err := http.NewRequest(
348		http.MethodPost,
349		a.githubAPI+"/user/codespaces/"+codespaceName+"/start",
350		nil,
351	)
352	if err != nil {
353		return fmt.Errorf("error creating request: %w", err)
354	}
355
356	a.setHeaders(req)
357	resp, err := a.do(ctx, req, "/user/codespaces/*/start")
358	if err != nil {
359		return fmt.Errorf("error making request: %w", err)
360	}
361	defer resp.Body.Close()
362
363	if resp.StatusCode != http.StatusOK {
364		if resp.StatusCode == http.StatusConflict {
365			// 409 means the codespace is already running which we can safely ignore
366			return nil
367		}
368		return api.HandleHTTPError(resp)
369	}
370
371	return nil
372}
373
374func (a *API) StopCodespace(ctx context.Context, codespaceName string) error {
375	req, err := http.NewRequest(
376		http.MethodPost,
377		a.githubAPI+"/user/codespaces/"+codespaceName+"/stop",
378		nil,
379	)
380	if err != nil {
381		return fmt.Errorf("error creating request: %w", err)
382	}
383
384	a.setHeaders(req)
385	resp, err := a.do(ctx, req, "/user/codespaces/*/stop")
386	if err != nil {
387		return fmt.Errorf("error making request: %w", err)
388	}
389	defer resp.Body.Close()
390
391	if resp.StatusCode != http.StatusOK {
392		return api.HandleHTTPError(resp)
393	}
394
395	return nil
396}
397
398type getCodespaceRegionLocationResponse struct {
399	Current string `json:"current"`
400}
401
402// GetCodespaceRegionLocation returns the closest codespace location for the user.
403func (a *API) GetCodespaceRegionLocation(ctx context.Context) (string, error) {
404	req, err := http.NewRequest(http.MethodGet, a.vscsAPI+"/api/v1/locations", nil)
405	if err != nil {
406		return "", fmt.Errorf("error creating request: %w", err)
407	}
408
409	resp, err := a.do(ctx, req, req.URL.String())
410	if err != nil {
411		return "", fmt.Errorf("error making request: %w", err)
412	}
413	defer resp.Body.Close()
414
415	if resp.StatusCode != http.StatusOK {
416		return "", api.HandleHTTPError(resp)
417	}
418
419	b, err := ioutil.ReadAll(resp.Body)
420	if err != nil {
421		return "", fmt.Errorf("error reading response body: %w", err)
422	}
423
424	var response getCodespaceRegionLocationResponse
425	if err := json.Unmarshal(b, &response); err != nil {
426		return "", fmt.Errorf("error unmarshaling response: %w", err)
427	}
428
429	return response.Current, nil
430}
431
432type Machine struct {
433	Name                 string `json:"name"`
434	DisplayName          string `json:"display_name"`
435	PrebuildAvailability string `json:"prebuild_availability"`
436}
437
438// GetCodespacesMachines returns the codespaces machines for the given repo, branch and location.
439func (a *API) GetCodespacesMachines(ctx context.Context, repoID int, branch, location string) ([]*Machine, error) {
440	reqURL := fmt.Sprintf("%s/repositories/%d/codespaces/machines", a.githubAPI, repoID)
441	req, err := http.NewRequest(http.MethodGet, reqURL, nil)
442	if err != nil {
443		return nil, fmt.Errorf("error creating request: %w", err)
444	}
445
446	q := req.URL.Query()
447	q.Add("location", location)
448	q.Add("ref", branch)
449	req.URL.RawQuery = q.Encode()
450
451	a.setHeaders(req)
452	resp, err := a.do(ctx, req, "/repositories/*/codespaces/machines")
453	if err != nil {
454		return nil, fmt.Errorf("error making request: %w", err)
455	}
456	defer resp.Body.Close()
457
458	if resp.StatusCode != http.StatusOK {
459		return nil, api.HandleHTTPError(resp)
460	}
461
462	b, err := ioutil.ReadAll(resp.Body)
463	if err != nil {
464		return nil, fmt.Errorf("error reading response body: %w", err)
465	}
466
467	var response struct {
468		Machines []*Machine `json:"machines"`
469	}
470	if err := json.Unmarshal(b, &response); err != nil {
471		return nil, fmt.Errorf("error unmarshaling response: %w", err)
472	}
473
474	return response.Machines, nil
475}
476
477// CreateCodespaceParams are the required parameters for provisioning a Codespace.
478type CreateCodespaceParams struct {
479	RepositoryID       int
480	IdleTimeoutMinutes int
481	Branch             string
482	Machine            string
483	Location           string
484}
485
486// CreateCodespace creates a codespace with the given parameters and returns a non-nil error if it
487// fails to create.
488func (a *API) CreateCodespace(ctx context.Context, params *CreateCodespaceParams) (*Codespace, error) {
489	codespace, err := a.startCreate(ctx, params)
490	if err != errProvisioningInProgress {
491		return codespace, err
492	}
493
494	// errProvisioningInProgress indicates that codespace creation did not complete
495	// within the GitHub API RPC time limit (10s), so it continues asynchronously.
496	// We must poll the server to discover the outcome.
497	ctx, cancel := context.WithTimeout(ctx, 2*time.Minute)
498	defer cancel()
499
500	ticker := time.NewTicker(1 * time.Second)
501	defer ticker.Stop()
502
503	for {
504		select {
505		case <-ctx.Done():
506			return nil, ctx.Err()
507		case <-ticker.C:
508			codespace, err = a.GetCodespace(ctx, codespace.Name, false)
509			if err != nil {
510				return nil, fmt.Errorf("failed to get codespace: %w", err)
511			}
512
513			// we continue to poll until the codespace shows as provisioned
514			if codespace.State != CodespaceStateAvailable {
515				continue
516			}
517
518			return codespace, nil
519		}
520	}
521}
522
523type startCreateRequest struct {
524	RepositoryID       int    `json:"repository_id"`
525	IdleTimeoutMinutes int    `json:"idle_timeout_minutes,omitempty"`
526	Ref                string `json:"ref"`
527	Location           string `json:"location"`
528	Machine            string `json:"machine"`
529}
530
531var errProvisioningInProgress = errors.New("provisioning in progress")
532
533// startCreate starts the creation of a codespace.
534// It may return success or an error, or errProvisioningInProgress indicating that the operation
535// did not complete before the GitHub API's time limit for RPCs (10s), in which case the caller
536// must poll the server to learn the outcome.
537func (a *API) startCreate(ctx context.Context, params *CreateCodespaceParams) (*Codespace, error) {
538	if params == nil {
539		return nil, errors.New("startCreate missing parameters")
540	}
541
542	requestBody, err := json.Marshal(startCreateRequest{
543		RepositoryID:       params.RepositoryID,
544		IdleTimeoutMinutes: params.IdleTimeoutMinutes,
545		Ref:                params.Branch,
546		Location:           params.Location,
547		Machine:            params.Machine,
548	})
549	if err != nil {
550		return nil, fmt.Errorf("error marshaling request: %w", err)
551	}
552
553	req, err := http.NewRequest(http.MethodPost, a.githubAPI+"/user/codespaces", bytes.NewBuffer(requestBody))
554	if err != nil {
555		return nil, fmt.Errorf("error creating request: %w", err)
556	}
557
558	a.setHeaders(req)
559	resp, err := a.do(ctx, req, "/user/codespaces")
560	if err != nil {
561		return nil, fmt.Errorf("error making request: %w", err)
562	}
563	defer resp.Body.Close()
564
565	if resp.StatusCode == http.StatusAccepted {
566		return nil, errProvisioningInProgress // RPC finished before result of creation known
567	} else if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
568		return nil, api.HandleHTTPError(resp)
569	}
570
571	b, err := ioutil.ReadAll(resp.Body)
572	if err != nil {
573		return nil, fmt.Errorf("error reading response body: %w", err)
574	}
575
576	var response Codespace
577	if err := json.Unmarshal(b, &response); err != nil {
578		return nil, fmt.Errorf("error unmarshaling response: %w", err)
579	}
580
581	return &response, nil
582}
583
584// DeleteCodespace deletes the given codespace.
585func (a *API) DeleteCodespace(ctx context.Context, codespaceName string) error {
586	req, err := http.NewRequest(http.MethodDelete, a.githubAPI+"/user/codespaces/"+codespaceName, nil)
587	if err != nil {
588		return fmt.Errorf("error creating request: %w", err)
589	}
590
591	a.setHeaders(req)
592	resp, err := a.do(ctx, req, "/user/codespaces/*")
593	if err != nil {
594		return fmt.Errorf("error making request: %w", err)
595	}
596	defer resp.Body.Close()
597
598	if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusAccepted {
599		return api.HandleHTTPError(resp)
600	}
601
602	return nil
603}
604
605type getCodespaceRepositoryContentsResponse struct {
606	Content string `json:"content"`
607}
608
609func (a *API) GetCodespaceRepositoryContents(ctx context.Context, codespace *Codespace, path string) ([]byte, error) {
610	req, err := http.NewRequest(http.MethodGet, a.githubAPI+"/repos/"+codespace.Repository.FullName+"/contents/"+path, nil)
611	if err != nil {
612		return nil, fmt.Errorf("error creating request: %w", err)
613	}
614
615	q := req.URL.Query()
616	q.Add("ref", codespace.GitStatus.Ref)
617	req.URL.RawQuery = q.Encode()
618
619	a.setHeaders(req)
620	resp, err := a.do(ctx, req, "/repos/*/contents/*")
621	if err != nil {
622		return nil, fmt.Errorf("error making request: %w", err)
623	}
624	defer resp.Body.Close()
625
626	if resp.StatusCode == http.StatusNotFound {
627		return nil, nil
628	} else if resp.StatusCode != http.StatusOK {
629		return nil, api.HandleHTTPError(resp)
630	}
631
632	b, err := ioutil.ReadAll(resp.Body)
633	if err != nil {
634		return nil, fmt.Errorf("error reading response body: %w", err)
635	}
636
637	var response getCodespaceRepositoryContentsResponse
638	if err := json.Unmarshal(b, &response); err != nil {
639		return nil, fmt.Errorf("error unmarshaling response: %w", err)
640	}
641
642	decoded, err := base64.StdEncoding.DecodeString(response.Content)
643	if err != nil {
644		return nil, fmt.Errorf("error decoding content: %w", err)
645	}
646
647	return decoded, nil
648}
649
650// AuthorizedKeys returns the public keys (in ~/.ssh/authorized_keys
651// format) registered by the specified GitHub user.
652func (a *API) AuthorizedKeys(ctx context.Context, user string) ([]byte, error) {
653	url := fmt.Sprintf("%s/%s.keys", a.githubServer, user)
654	req, err := http.NewRequest(http.MethodGet, url, nil)
655	if err != nil {
656		return nil, err
657	}
658	resp, err := a.do(ctx, req, "/user.keys")
659	if err != nil {
660		return nil, err
661	}
662	defer resp.Body.Close()
663
664	if resp.StatusCode != http.StatusOK {
665		return nil, fmt.Errorf("server returned %s", resp.Status)
666	}
667
668	b, err := ioutil.ReadAll(resp.Body)
669	if err != nil {
670		return nil, fmt.Errorf("error reading response body: %w", err)
671	}
672	return b, nil
673}
674
675// do executes the given request and returns the response. It creates an
676// opentracing span to track the length of the request.
677func (a *API) do(ctx context.Context, req *http.Request, spanName string) (*http.Response, error) {
678	// TODO(adonovan): use NewRequestWithContext(ctx) and drop ctx parameter.
679	span, ctx := opentracing.StartSpanFromContext(ctx, spanName)
680	defer span.Finish()
681	req = req.WithContext(ctx)
682	return a.client.Do(req)
683}
684
685// setHeaders sets the required headers for the API.
686func (a *API) setHeaders(req *http.Request) {
687	req.Header.Set("Accept", "application/vnd.github.v3+json")
688}
689