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