Merge branch 'main' of github.com:github/ghcs into jg/poll-on-async-creation

This commit is contained in:
Jose Garcia 2021-09-21 14:01:37 -04:00
commit d5b03df406
13 changed files with 144 additions and 74 deletions

View file

@ -1,10 +1,9 @@
package main
package ghcs
import (
"context"
"fmt"
"net/url"
"os"
"github.com/github/ghcs/internal/api"
"github.com/skratchdot/open-golang/open"
@ -32,12 +31,8 @@ func newCodeCmd() *cobra.Command {
return codeCmd
}
func init() {
rootCmd.AddCommand(newCodeCmd())
}
func code(codespaceName string, useInsiders bool) error {
apiClient := api.New(os.Getenv("GITHUB_TOKEN"))
apiClient := api.New(GithubToken)
ctx := context.Background()
user, err := apiClient.GetUser(ctx)

View file

@ -1,4 +1,4 @@
package main
package ghcs
// This file defines functions common to the entire ghcs command set.
@ -6,6 +6,7 @@ import (
"context"
"errors"
"fmt"
"io"
"os"
"sort"
@ -93,6 +94,12 @@ func getOrChooseCodespace(ctx context.Context, apiClient *api.API, user *api.Use
return codespace, token, nil
}
func safeClose(closer io.Closer, err *error) {
if closeErr := closer.Close(); *err == nil {
*err = closeErr
}
}
// hasTTY indicates whether the process connected to a terminal.
// It is not portable to assume stdin/stdout are fds 0 and 1.
var hasTTY = term.IsTerminal(int(os.Stdin.Fd())) && term.IsTerminal(int(os.Stdout.Fd()))
@ -120,3 +127,17 @@ func ask(qs []*survey.Question, response interface{}) error {
}
return err
}
// checkAuthorizedKeys reports an error if the user has not registered any SSH keys;
// see https://github.com/github/ghcs/issues/166#issuecomment-921769703.
// The check is not required for security but it improves the error message.
func checkAuthorizedKeys(ctx context.Context, client *api.API, user string) error {
keys, err := client.AuthorizedKeys(ctx, user)
if err != nil {
return fmt.Errorf("failed to read GitHub-authorized SSH keys for %s: %w", user, err)
}
if len(keys) == 0 {
return fmt.Errorf("user %s has no GitHub-authorized SSH keys", user)
}
return nil // success
}

View file

@ -1,4 +1,4 @@
package main
package ghcs
import (
"context"
@ -43,13 +43,9 @@ func newCreateCmd() *cobra.Command {
return createCmd
}
func init() {
rootCmd.AddCommand(newCreateCmd())
}
func create(opts *createOptions) error {
ctx := context.Background()
apiClient := api.New(os.Getenv("GITHUB_TOKEN"))
apiClient := api.New(GithubToken)
locationCh := getLocation(ctx, apiClient)
userCh := getUser(ctx, apiClient)
log := output.NewLogger(os.Stdout, os.Stderr, false)

View file

@ -1,4 +1,4 @@
package main
package ghcs
import (
"context"
@ -27,6 +27,9 @@ func newDeleteCmd() *cobra.Command {
Use: "delete",
Short: "Delete a codespace",
RunE: func(cmd *cobra.Command, args []string) error {
if len(args) > 0 {
return fmt.Errorf("delete: unexpected positional arguments")
}
switch {
case allCodespaces && repo != "":
return errors.New("both --all and --repo is not supported")
@ -48,12 +51,8 @@ func newDeleteCmd() *cobra.Command {
return deleteCmd
}
func init() {
rootCmd.AddCommand(newDeleteCmd())
}
func delete_(log *output.Logger, codespaceName string, force bool) error {
apiClient := api.New(os.Getenv("GITHUB_TOKEN"))
apiClient := api.New(GithubToken)
ctx := context.Background()
user, err := apiClient.GetUser(ctx)
@ -85,7 +84,7 @@ func delete_(log *output.Logger, codespaceName string, force bool) error {
}
func deleteAll(log *output.Logger, force bool) error {
apiClient := api.New(os.Getenv("GITHUB_TOKEN"))
apiClient := api.New(GithubToken)
ctx := context.Background()
user, err := apiClient.GetUser(ctx)
@ -124,7 +123,7 @@ func deleteAll(log *output.Logger, force bool) error {
}
func deleteByRepo(log *output.Logger, repo string, force bool) error {
apiClient := api.New(os.Getenv("GITHUB_TOKEN"))
apiClient := api.New(GithubToken)
ctx := context.Background()
user, err := apiClient.GetUser(ctx)

View file

@ -1,4 +1,4 @@
package main
package ghcs
import (
"context"
@ -31,12 +31,8 @@ func newListCmd() *cobra.Command {
return listCmd
}
func init() {
rootCmd.AddCommand(newListCmd())
}
func list(opts *listOptions) error {
apiClient := api.New(os.Getenv("GITHUB_TOKEN"))
apiClient := api.New(GithubToken)
ctx := context.Background()
user, err := apiClient.GetUser(ctx)

View file

@ -1,4 +1,4 @@
package main
package ghcs
import (
"context"
@ -36,22 +36,23 @@ func newLogsCmd() *cobra.Command {
return logsCmd
}
func init() {
rootCmd.AddCommand(newLogsCmd())
}
func logs(ctx context.Context, log *output.Logger, codespaceName string, follow bool) error {
func logs(ctx context.Context, log *output.Logger, codespaceName string, follow bool) (err error) {
// Ensure all child tasks (port forwarding, remote exec) terminate before return.
ctx, cancel := context.WithCancel(ctx)
defer cancel()
apiClient := api.New(os.Getenv("GITHUB_TOKEN"))
apiClient := api.New(GithubToken)
user, err := apiClient.GetUser(ctx)
if err != nil {
return fmt.Errorf("getting user: %w", err)
}
authkeys := make(chan error, 1)
go func() {
authkeys <- checkAuthorizedKeys(ctx, apiClient, user.Login)
}()
codespace, token, err := getOrChooseCodespace(ctx, apiClient, user, codespaceName)
if err != nil {
return fmt.Errorf("get or choose codespace: %w", err)
@ -61,6 +62,11 @@ func logs(ctx context.Context, log *output.Logger, codespaceName string, follow
if err != nil {
return fmt.Errorf("connecting to Live Share: %w", err)
}
defer safeClose(session, &err)
if err := <-authkeys; err != nil {
return err
}
// Ensure local port is listening before client (getPostCreateOutput) connects.
listen, err := net.Listen("tcp", ":0") // arbitrary port

26
cmd/ghcs/main/main.go Normal file
View file

@ -0,0 +1,26 @@
package main
import (
"errors"
"fmt"
"io"
"os"
"github.com/github/ghcs/cmd/ghcs"
)
func main() {
rootCmd := ghcs.NewRootCmd()
if err := rootCmd.Execute(); err != nil {
explainError(os.Stderr, err)
os.Exit(1)
}
}
func explainError(w io.Writer, err error) {
if errors.Is(err, ghcs.ErrTokenMissing) {
fmt.Fprintln(w, "The GITHUB_TOKEN environment variable is required. Create a Personal Access Token at https://github.com/settings/tokens/new?scopes=repo")
fmt.Fprintln(w, "Make sure to enable SSO for your organizations after creating the token.")
return
}
}

View file

@ -1,4 +1,4 @@
package main
package ghcs
import (
"bytes"
@ -47,11 +47,7 @@ func newPortsCmd() *cobra.Command {
return portsCmd
}
func init() {
rootCmd.AddCommand(newPortsCmd())
}
func ports(codespaceName string, asJSON bool) error {
func ports(codespaceName string, asJSON bool) (err error) {
apiClient := api.New(os.Getenv("GITHUB_TOKEN"))
ctx := context.Background()
log := output.NewLogger(os.Stdout, os.Stderr, asJSON)
@ -76,6 +72,7 @@ func ports(codespaceName string, asJSON bool) error {
if err != nil {
return fmt.Errorf("error connecting to Live Share: %w", err)
}
defer safeClose(session, &err)
log.Println("Loading ports...")
ports, err := session.GetSharedServers(ctx)
@ -198,9 +195,9 @@ func newPortsPrivateCmd() *cobra.Command {
}
}
func updatePortVisibility(log *output.Logger, codespaceName, sourcePort string, public bool) error {
func updatePortVisibility(log *output.Logger, codespaceName, sourcePort string, public bool) (err error) {
ctx := context.Background()
apiClient := api.New(os.Getenv("GITHUB_TOKEN"))
apiClient := api.New(GithubToken)
user, err := apiClient.GetUser(ctx)
if err != nil {
@ -219,6 +216,7 @@ func updatePortVisibility(log *output.Logger, codespaceName, sourcePort string,
if err != nil {
return fmt.Errorf("error connecting to Live Share: %w", err)
}
defer safeClose(session, &err)
port, err := strconv.Atoi(sourcePort)
if err != nil {
@ -260,9 +258,9 @@ func newPortsForwardCmd() *cobra.Command {
}
}
func forwardPorts(log *output.Logger, codespaceName string, ports []string) error {
func forwardPorts(log *output.Logger, codespaceName string, ports []string) (err error) {
ctx := context.Background()
apiClient := api.New(os.Getenv("GITHUB_TOKEN"))
apiClient := api.New(GithubToken)
portPairs, err := getPortPairs(ports)
if err != nil {
@ -286,6 +284,7 @@ func forwardPorts(log *output.Logger, codespaceName string, ports []string) erro
if err != nil {
return fmt.Errorf("error connecting to Live Share: %w", err)
}
defer safeClose(session, &err)
// Run forwarding of all ports concurrently, aborting all of
// them at the first failure, including cancellation of the context.

View file

@ -1,9 +1,8 @@
package main
package ghcs
import (
"errors"
"fmt"
"io"
"log"
"os"
"strconv"
@ -14,18 +13,12 @@ import (
"github.com/spf13/cobra"
)
func main() {
if err := rootCmd.Execute(); err != nil {
explainError(os.Stderr, err)
os.Exit(1)
}
}
var version = "DEV" // Replaced in the release build process (by GoReleaser or Homebrew) by the git tag version number.
var rootCmd = newRootCmd()
// GithubToken is a temporary stopgap to make the token configurable by apps that import this package
var GithubToken = os.Getenv("GITHUB_TOKEN")
func newRootCmd() *cobra.Command {
func NewRootCmd() *cobra.Command {
var lightstep string
root := &cobra.Command{
@ -40,7 +33,7 @@ token to access the GitHub API with.`,
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
if os.Getenv("GITHUB_TOKEN") == "" {
return errTokenMissing
return ErrTokenMissing
}
return initLightstep(lightstep)
},
@ -48,18 +41,18 @@ token to access the GitHub API with.`,
root.PersistentFlags().StringVar(&lightstep, "lightstep", "", "Lightstep tracing endpoint (service:token@host:port)")
root.AddCommand(newCodeCmd())
root.AddCommand(newCreateCmd())
root.AddCommand(newDeleteCmd())
root.AddCommand(newListCmd())
root.AddCommand(newLogsCmd())
root.AddCommand(newPortsCmd())
root.AddCommand(newSSHCmd())
return root
}
var errTokenMissing = errors.New("GITHUB_TOKEN is missing")
func explainError(w io.Writer, err error) {
if errors.Is(err, errTokenMissing) {
fmt.Fprintln(w, "The GITHUB_TOKEN environment variable is required. Create a Personal Access Token at https://github.com/settings/tokens/new?scopes=repo")
fmt.Fprintln(w, "Make sure to enable SSO for your organizations after creating the token.")
return
}
}
var ErrTokenMissing = errors.New("GITHUB_TOKEN is missing")
// initLightstep parses the --lightstep=service:token@host:port flag and
// enables tracing if non-empty.

View file

@ -1,4 +1,4 @@
package main
package ghcs
import (
"context"
@ -32,16 +32,12 @@ func newSSHCmd() *cobra.Command {
return sshCmd
}
func init() {
rootCmd.AddCommand(newSSHCmd())
}
func ssh(ctx context.Context, sshArgs []string, sshProfile, codespaceName string, localSSHServerPort int) error {
func ssh(ctx context.Context, sshArgs []string, sshProfile, codespaceName string, localSSHServerPort int) (err error) {
// Ensure all child tasks (e.g. port forwarding) terminate before return.
ctx, cancel := context.WithCancel(ctx)
defer cancel()
apiClient := api.New(os.Getenv("GITHUB_TOKEN"))
apiClient := api.New(GithubToken)
log := output.NewLogger(os.Stdout, os.Stderr, false)
user, err := apiClient.GetUser(ctx)
@ -49,6 +45,11 @@ func ssh(ctx context.Context, sshArgs []string, sshProfile, codespaceName string
return fmt.Errorf("error getting user: %w", err)
}
authkeys := make(chan error, 1)
go func() {
authkeys <- checkAuthorizedKeys(ctx, apiClient, user.Login)
}()
codespace, token, err := getOrChooseCodespace(ctx, apiClient, user, codespaceName)
if err != nil {
return fmt.Errorf("get or choose codespace: %w", err)
@ -58,6 +59,11 @@ func ssh(ctx context.Context, sshArgs []string, sshProfile, codespaceName string
if err != nil {
return fmt.Errorf("error connecting to Live Share: %w", err)
}
defer safeClose(session, &err)
if err := <-authkeys; err != nil {
return err
}
log.Println("Fetching SSH Details...")
remoteSSHServerPort, sshUser, err := session.StartSSHServer(ctx)

View file

@ -13,6 +13,7 @@ package api
// - github.GetUser(github.Client)
// - github.GetRepository(Client)
// - github.ReadFile(Client, nwo, branch, path) // was GetCodespaceRepositoryContents
// - github.AuthorizedKeys(Client, user)
// - codespaces.Create(Client, user, repo, sku, branch, location)
// - codespaces.Delete(Client, user, token, name)
// - codespaces.Get(Client, token, owner, name)
@ -512,6 +513,31 @@ func (a *API) GetCodespaceRepositoryContents(ctx context.Context, codespace *Cod
return decoded, nil
}
// AuthorizedKeys returns the public keys (in ~/.ssh/authorized_keys
// format) registered by the specified GitHub user.
func (a *API) AuthorizedKeys(ctx context.Context, user string) ([]byte, error) {
url := fmt.Sprintf("https://github.com/%s.keys", user)
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return nil, err
}
resp, err := a.do(ctx, req, "/user.keys")
if err != nil {
return nil, err
}
defer resp.Body.Close()
b, err := ioutil.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("error reading response body: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("server returned %s", resp.Status)
}
return b, nil
}
func (a *API) do(ctx context.Context, req *http.Request, spanName string) (*http.Response, error) {
// TODO(adonovan): use NewRequestWithContext(ctx) and drop ctx parameter.
span, ctx := opentracing.StartSpanFromContext(ctx, spanName)

View file

@ -23,6 +23,8 @@ func connectionReady(codespace *api.Codespace) bool {
codespace.Environment.State == api.CodespaceEnvironmentStateAvailable
}
// ConnectToLiveshare creates a Live Share client and joins the Live Share session.
// It will start the Codespace if it is not already running, it will time out after 60 seconds if fails to start.
func ConnectToLiveshare(ctx context.Context, log logger, apiClient *api.API, userLogin, token string, codespace *api.Codespace) (*liveshare.Session, error) {
var startedCodespace bool
if codespace.Environment.State != api.CodespaceEnvironmentStateAvailable {

View file

@ -36,7 +36,7 @@ type PostCreateState struct {
// PollPostCreateStates watches for state changes in a codespace,
// and calls the supplied poller for each batch of state changes.
// It runs until it encounters an error, including cancellation of the context.
func PollPostCreateStates(ctx context.Context, log logger, apiClient *api.API, user *api.User, codespace *api.Codespace, poller func([]PostCreateState)) error {
func PollPostCreateStates(ctx context.Context, log logger, apiClient *api.API, user *api.User, codespace *api.Codespace, poller func([]PostCreateState)) (err error) {
token, err := apiClient.GetCodespaceToken(ctx, user.Login, codespace.Name)
if err != nil {
return fmt.Errorf("getting codespace token: %w", err)
@ -46,6 +46,11 @@ func PollPostCreateStates(ctx context.Context, log logger, apiClient *api.API, u
if err != nil {
return fmt.Errorf("connect to Live Share: %w", err)
}
defer func() {
if closeErr := session.Close(); err == nil {
err = closeErr
}
}()
// Ensure local port is listening before client (getPostCreateOutput) connects.
listen, err := net.Listen("tcp", ":0") // arbitrary port