build/buildlet/buildletclient.go

807 строки
22 KiB
Go
Исходник Обычный вид История

// Copyright 2015 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// Package buildlet contains client tools for working with a buildlet
// server.
package buildlet // import "golang.org/x/build/buildlet"
import (
"bufio"
"encoding/json"
"errors"
"fmt"
"io"
"io/ioutil"
"log"
"net"
"net/http"
"net/url"
"os"
"strings"
"sync"
"time"
"context"
"golang.org/x/oauth2"
)
// NewClient returns a *Client that will manipulate ipPort,
// authenticated using the provided keypair.
//
// This constructor returns immediately without testing the host or auth.
func NewClient(ipPort string, kp KeyPair) *Client {
tr := &http.Transport{
Dial: defaultDialer(),
DialTLS: kp.tlsDialer(),
IdleConnTimeout: time.Minute,
}
c := &Client{
ipPort: ipPort,
tls: kp,
password: kp.Password(),
httpClient: &http.Client{Transport: tr},
closeFuncs: []func(){tr.CloseIdleConnections},
}
c.setCommon()
return c
}
func (c *Client) setCommon() {
c.peerDead = make(chan struct{})
c.ctx, c.ctxCancel = context.WithCancel(context.Background())
}
// SetOnHeartbeatFailure sets a function to be called when heartbeats
// against this builder fail, or when the client is destroyed with
// Close. The function fn is never called more than once.
// SetOnHeartbeatFailure must be set before any use of the buildlet.
func (c *Client) SetOnHeartbeatFailure(fn func()) {
c.heartbeatFailure = fn
}
var ErrClosed = errors.New("buildlet: Client closed")
// Closes destroys and closes down the buildlet, destroying all state
// immediately.
func (c *Client) Close() error {
// TODO(bradfitz): have a Client-wide Done channel we set on
// all outbound HTTP Requests and close it in the once here?
// Then if something was in-flight and somebody else Closes,
// the local http.Transport notices, rather than noticing via
// the remote machine dying or timing out.
c.closeOnce.Do(func() {
// Send a best-effort notification to the server to destroy itself.
// Don't want too long (since it's likely in a broken state anyway).
// Ignore the return value, since we're about to forcefully destroy
// it anyway.
req, err := http.NewRequest("POST", c.URL()+"/halt", nil)
if err != nil {
// ignore.
} else {
_, err = c.doHeaderTimeout(req, 2*time.Second)
}
if err == nil {
err = ErrClosed
}
for _, fn := range c.closeFuncs {
fn()
}
c.setPeerDead(err) // which will also cause c.heartbeatFailure to run
})
return nil
}
func (c *Client) setPeerDead(err error) {
c.setPeerDeadOnce.Do(func() {
c.MarkBroken()
if err == nil {
err = errors.New("peer dead (no specific error)")
}
c.deadErr = err
close(c.peerDead)
})
}
// SetDescription sets a short description of where the buildlet
// connection came from. This is used by the build coordinator status
// page, mostly for debugging.
func (c *Client) SetDescription(v string) {
c.desc = v
}
// SetHTTPClient replaces the underlying HTTP client.
// It should only be called before the Client is used.
func (c *Client) SetHTTPClient(httpClient *http.Client) {
c.httpClient = httpClient
}
cmd/coordinator, cmd/buildlet, cmd/gomote: add SSH support This adds an SSH server to farmer.golang.org on port 2222 that proxies SSH connections to users' gomote-created buildlet instances. For example: $ gomote create openbsd-amd64-60 user-bradfitz-openbsd-amd64-60-1 $ gomote ssh user-bradfitz-openbsd-amd64-60-1 Warning: Permanently added '[localhost]:33351' (ECDSA) to the list of known hosts. OpenBSD 6.0 (GENERIC.MP) golang/go#2319: Tue Jul 26 13:00:43 MDT 2016 Welcome to OpenBSD: The proactively secure Unix-like operating system. Please use the sendbug(1) utility to report bugs in the system. Before reporting a bug, please try to reproduce it with the latest version of the code. With bug reports, please try to ensure that enough information to reproduce the problem is enclosed, and if a known fix for it exists, include that as well. $ As before, if the coordinator process is restarted (or crashes, is evicted, etc), all gomote instances die. Not yet supported: * scp (help wanted) * not all host types are configured. most are. some will need slight config tweaks to the Docker image (e.g. adding openssh-server) Supports currently: * linux-amd64 (host type shared by 386, nacl) * linux-arm * linux-arm64 * darwin * freebsd * openbsd * plan9-386 * windows Implementation details: * the ssh server process listens on port 2222 in the coordinator (farmer.golang.org), which is behind a GKE TCP load balancer. * the ssh server library is github.com/gliderlabs/ssh * authentication is done via Github users' public keys. It's assumed that gomote user == github user. But there's a mapping in the code for known exceptions. * we can't give out access to this too widely. too many things are accessible from within the host environment if you look in the right places. Details omitted. But the Go team and other trusted gomote users can use this. * the buildlet binary has a new /connect-ssh handler that acts like a CONNECT request but instead of taking an explicit host:port, just says "give me your machine's SSH connection". The buildlet can also start sshd if needed for the environment. The /connect-ssh handler also installs the coordinator's public key. * a new buildlet client library method "ConnectSSH" hits the /connect-ssh handler and returns a net.Conn. * the coordinator's ssh.Handler is just running the OpenSSH ssh client. * because the OpenSSH ssh child process can't connect to a net.Conn, an emphemeral localhost port is created on the coordinator to proxy between the ssh client and the net.Conn returned by ConnectSSH. * The /connect-ssh handler requires http.Hijacker, which requires fully compliant net.Conn implementations as of Go 1.8. So I needed to flesh out revdial too, testing it with the golang.org/x/net/nettest package. * plan9 doesn't have an ssh server, so we use 0intro's new conterm program (drawterm without GUI support) to connect to plan9 from the coordinator ssh proxy instead of using the OpenSSH ssh client binary. * windows doesn't have an ssh server, so we enable the telnet service and the coordinator ssh proxy uses telnet instead on the backend on the private network. (There is a Windows ssh server but only in new versions.) Happy debugging over ssh! Fixes golang/go#19956 Change-Id: I80a62064c5f85af1f195f980c862ba29af4015f0 Reviewed-on: https://go-review.googlesource.com/50750 Reviewed-by: Herbie Ong <herbie@google.com> Reviewed-by: Jessie Frazelle <me@jessfraz.com> Reviewed-by: Brad Fitzpatrick <bradfitz@golang.org>
2017-07-22 22:15:56 +03:00
// SetDialer sets the function that creates a new connection to the buildlet.
// By default, net.Dial is used.
func (c *Client) SetDialer(dialer func() (net.Conn, error)) {
c.dialer = dialer
}
// defaultDialer returns the net/http package's default Dial function.
// Notably, this sets TCP keep-alive values, so when we kill VMs
// (whose TCP stacks stop replying, forever), we don't leak file
// descriptors for otherwise forever-stalled TCP connections.
func defaultDialer() func(network, addr string) (net.Conn, error) {
if fn := http.DefaultTransport.(*http.Transport).Dial; fn != nil {
return fn
}
return net.Dial
}
// A Client interacts with a single buildlet.
type Client struct {
ipPort string // required, unless remoteBuildlet+baseURL is set
tls KeyPair
httpClient *http.Client
cmd/coordinator, cmd/buildlet, cmd/gomote: add SSH support This adds an SSH server to farmer.golang.org on port 2222 that proxies SSH connections to users' gomote-created buildlet instances. For example: $ gomote create openbsd-amd64-60 user-bradfitz-openbsd-amd64-60-1 $ gomote ssh user-bradfitz-openbsd-amd64-60-1 Warning: Permanently added '[localhost]:33351' (ECDSA) to the list of known hosts. OpenBSD 6.0 (GENERIC.MP) golang/go#2319: Tue Jul 26 13:00:43 MDT 2016 Welcome to OpenBSD: The proactively secure Unix-like operating system. Please use the sendbug(1) utility to report bugs in the system. Before reporting a bug, please try to reproduce it with the latest version of the code. With bug reports, please try to ensure that enough information to reproduce the problem is enclosed, and if a known fix for it exists, include that as well. $ As before, if the coordinator process is restarted (or crashes, is evicted, etc), all gomote instances die. Not yet supported: * scp (help wanted) * not all host types are configured. most are. some will need slight config tweaks to the Docker image (e.g. adding openssh-server) Supports currently: * linux-amd64 (host type shared by 386, nacl) * linux-arm * linux-arm64 * darwin * freebsd * openbsd * plan9-386 * windows Implementation details: * the ssh server process listens on port 2222 in the coordinator (farmer.golang.org), which is behind a GKE TCP load balancer. * the ssh server library is github.com/gliderlabs/ssh * authentication is done via Github users' public keys. It's assumed that gomote user == github user. But there's a mapping in the code for known exceptions. * we can't give out access to this too widely. too many things are accessible from within the host environment if you look in the right places. Details omitted. But the Go team and other trusted gomote users can use this. * the buildlet binary has a new /connect-ssh handler that acts like a CONNECT request but instead of taking an explicit host:port, just says "give me your machine's SSH connection". The buildlet can also start sshd if needed for the environment. The /connect-ssh handler also installs the coordinator's public key. * a new buildlet client library method "ConnectSSH" hits the /connect-ssh handler and returns a net.Conn. * the coordinator's ssh.Handler is just running the OpenSSH ssh client. * because the OpenSSH ssh child process can't connect to a net.Conn, an emphemeral localhost port is created on the coordinator to proxy between the ssh client and the net.Conn returned by ConnectSSH. * The /connect-ssh handler requires http.Hijacker, which requires fully compliant net.Conn implementations as of Go 1.8. So I needed to flesh out revdial too, testing it with the golang.org/x/net/nettest package. * plan9 doesn't have an ssh server, so we use 0intro's new conterm program (drawterm without GUI support) to connect to plan9 from the coordinator ssh proxy instead of using the OpenSSH ssh client binary. * windows doesn't have an ssh server, so we enable the telnet service and the coordinator ssh proxy uses telnet instead on the backend on the private network. (There is a Windows ssh server but only in new versions.) Happy debugging over ssh! Fixes golang/go#19956 Change-Id: I80a62064c5f85af1f195f980c862ba29af4015f0 Reviewed-on: https://go-review.googlesource.com/50750 Reviewed-by: Herbie Ong <herbie@google.com> Reviewed-by: Jessie Frazelle <me@jessfraz.com> Reviewed-by: Brad Fitzpatrick <bradfitz@golang.org>
2017-07-22 22:15:56 +03:00
dialer func() (net.Conn, error) // nil means to use net.Dial
baseURL string // optional baseURL (used by remote buildlets)
authUser string // defaults to "gomote", if password is non-empty
password string // basic auth password or empty for none
remoteBuildlet string // non-empty if for remote buildlets
closeFuncs []func() // optional extra code to run on close
releaseMode bool
ctx context.Context
ctxCancel context.CancelFunc
heartbeatFailure func() // optional
desc string
closeOnce sync.Once
initHeartbeatOnce sync.Once
setPeerDeadOnce sync.Once
peerDead chan struct{} // closed on peer death
deadErr error // guarded by peerDead's close
mu sync.Mutex
broken bool // client is broken in some way
}
// SetReleaseMode sets whether this client is being used in "release
// mode", to prepare the final binaries to be shipped to users. All
// this mode does for now is disable pargzip multi-stream gzip
// files. See golang.org/issue/19052.
//
// SetReleaseMode must be set before using the client.
func (c *Client) SetReleaseMode(v bool) {
c.releaseMode = v
}
func (c *Client) String() string {
if c == nil {
return "(nil *buildlet.Client)"
}
return strings.TrimSpace(c.URL() + " " + c.desc)
}
// RemoteName returns the name of this client's buildlet on the
// coordinator. If this buildlet isn't a remote buildlet created via
// a buildlet, this returns the empty string.
func (c *Client) RemoteName() string {
return c.remoteBuildlet
}
// URL returns the buildlet's URL prefix, without a trailing slash.
func (c *Client) URL() string {
if c.baseURL != "" {
return strings.TrimRight(c.baseURL, "/")
}
if !c.tls.IsZero() {
return "https://" + strings.TrimSuffix(c.ipPort, ":443")
}
return "http://" + strings.TrimSuffix(c.ipPort, ":80")
}
func (c *Client) IPPort() string { return c.ipPort }
func (c *Client) Name() string {
if c.ipPort != "" {
return c.ipPort
}
return "(unnamed-buildlet)"
}
// MarkBroken marks this client as broken in some way.
func (c *Client) MarkBroken() {
c.mu.Lock()
defer c.mu.Unlock()
c.broken = true
c.ctxCancel()
}
// IsBroken reports whether this client is broken in some way.
func (c *Client) IsBroken() bool {
c.mu.Lock()
defer c.mu.Unlock()
return c.broken
}
func (c *Client) authUsername() string {
if c.authUser != "" {
return c.authUser
}
return "gomote"
}
func (c *Client) do(req *http.Request) (*http.Response, error) {
if req.Cancel == nil {
req.Cancel = c.ctx.Done()
}
c.initHeartbeatOnce.Do(c.initHeartbeats)
if c.password != "" {
req.SetBasicAuth(c.authUsername(), c.password)
}
if c.remoteBuildlet != "" {
req.Header.Set("X-Buildlet-Proxy", c.remoteBuildlet)
}
return c.httpClient.Do(req)
}
// ProxyRoundTripper returns a RoundTripper that sends HTTP requests directly
// through to the underlying buildlet, adding auth and X-Buildlet-Proxy headers
// as necessary. This is really only intended for use by the coordinator.
func (c *Client) ProxyRoundTripper() http.RoundTripper {
return proxyRoundTripper{c}
}
type proxyRoundTripper struct {
c *Client
}
func (p proxyRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
return p.c.do(req)
}
func (c *Client) initHeartbeats() {
go c.heartbeatLoop()
}
func (c *Client) heartbeatLoop() {
failInARow := 0
for {
select {
case <-c.peerDead:
// Dead for whatever reason (heartbeat, remote
// side closed, caller Closed
// normally). Regardless, we call the
// heartbeatFailure func if set.
if c.heartbeatFailure != nil {
c.heartbeatFailure()
}
return
case <-time.After(10 * time.Second):
t0 := time.Now()
if _, err := c.Status(); err != nil {
failInARow++
if failInARow == 3 {
log.Printf("Buildlet %v failed three heartbeats; final error: %v", c, err)
c.setPeerDead(fmt.Errorf("Buildlet %v failed heartbeat after %v; marking dead; err=%v", c, time.Since(t0), err))
}
} else {
failInARow = 0
}
}
}
}
var errHeaderTimeout = errors.New("timeout waiting for headers")
// doHeaderTimeout calls c.do(req) and returns its results, or
// errHeaderTimeout if max elapses first.
func (c *Client) doHeaderTimeout(req *http.Request, max time.Duration) (res *http.Response, err error) {
type resErr struct {
res *http.Response
err error
}
if req.Cancel != nil {
panic("use of Request.Cancel inside the buildlet package is reserved for doHeaderTimeout")
}
cancelc := make(chan struct{}) // closed to abort
req.Cancel = cancelc
resErrc := make(chan resErr, 1)
go func() {
res, err := c.do(req)
resErrc <- resErr{res, err}
}()
timer := time.NewTimer(max)
defer timer.Stop()
cleanup := func() {
close(cancelc)
if re := <-resErrc; re.res != nil {
re.res.Body.Close()
}
}
select {
case re := <-resErrc:
return re.res, re.err
case <-c.peerDead:
go cleanup()
return nil, c.deadErr
case <-timer.C:
go cleanup()
return nil, errHeaderTimeout
}
}
// doOK sends the request and expects a 200 OK response.
func (c *Client) doOK(req *http.Request) error {
res, err := c.do(req)
if err != nil {
return err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
slurp, _ := ioutil.ReadAll(io.LimitReader(res.Body, 4<<10))
return fmt.Errorf("%v; body: %s", res.Status, slurp)
}
return nil
}
// PutTar writes files to the remote buildlet, rooted at the relative
// directory dir.
// If dir is empty, they're placed at the root of the buildlet's work directory.
// The dir is created if necessary.
// The Reader must be of a tar.gz file.
func (c *Client) PutTar(r io.Reader, dir string) error {
req, err := http.NewRequest("PUT", c.URL()+"/writetgz?dir="+url.QueryEscape(dir), r)
if err != nil {
return err
}
return c.doOK(req)
}
// PutTarFromURL tells the buildlet to download the tar.gz file from tarURL
// and write it to dir, a relative directory from the workdir.
// If dir is empty, they're placed at the root of the buildlet's work directory.
// The dir is created if necessary.
// The url must be of a tar.gz file.
func (c *Client) PutTarFromURL(tarURL, dir string) error {
form := url.Values{
"url": {tarURL},
}
req, err := http.NewRequest("POST", c.URL()+"/writetgz?dir="+url.QueryEscape(dir), strings.NewReader(form.Encode()))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
return c.doOK(req)
}
// Put writes the provided file to path (relative to workdir) and sets mode.
func (c *Client) Put(r io.Reader, path string, mode os.FileMode) error {
param := url.Values{
"path": {path},
"mode": {fmt.Sprint(int64(mode))},
}
req, err := http.NewRequest("PUT", c.URL()+"/write?"+param.Encode(), r)
if err != nil {
return err
}
return c.doOK(req)
}
// GetTar returns a .tar.gz stream of the given directory, relative to the buildlet's work dir.
// The provided dir may be empty to get everything.
func (c *Client) GetTar(ctx context.Context, dir string) (io.ReadCloser, error) {
var args string
if c.releaseMode {
args = "&pargzip=0"
}
req, err := http.NewRequest("GET", c.URL()+"/tgz?dir="+url.QueryEscape(dir)+args, nil)
if err != nil {
return nil, err
}
res, err := c.do(req.WithContext(ctx))
if err != nil {
return nil, err
}
if res.StatusCode != http.StatusOK {
slurp, _ := ioutil.ReadAll(io.LimitReader(res.Body, 4<<10))
res.Body.Close()
return nil, fmt.Errorf("%v; body: %s", res.Status, slurp)
}
return res.Body, nil
}
// ExecOpts are options for a remote command invocation.
type ExecOpts struct {
// Output is the output of stdout and stderr.
// If nil, the output is discarded.
Output io.Writer
// Dir is the directory from which to execute the command.
// It is optional. If not specified, it defaults to the directory of
// the command, or the work directory if SystemLevel is set.
Dir string
// Args are the arguments to pass to the cmd given to Client.Exec.
Args []string
// ExtraEnv are KEY=VALUE pairs to append to the buildlet
// process's environment.
ExtraEnv []string
// Path, if non-nil, specifies the PATH variable of the executed
// process's environment. A non-nil empty list clears the path.
// The following expansions apply:
// - the string "$PATH" expands to any existing PATH element(s)
// - the substring "$WORKDIR" expands to buildlet's temp workdir
// After expansions, the list is joined with an OS-specific list
// separator and supplied to the executed process as its PATH
// environment variable.
Path []string
// SystemLevel controls whether the command is run outside of
// the buildlet's environment.
SystemLevel bool
// Debug, if true, instructs to the buildlet to print extra debug
// info to the output before the command begins executing.
Debug bool
// OnStartExec is an optional hook that runs after the 200 OK
// response from the buildlet, but before the output begins
// writing to Output.
OnStartExec func()
// Timeout is an optional duration before ErrTimeout is returned.
Timeout time.Duration
}
var ErrTimeout = errors.New("buildlet: timeout waiting for command to complete")
// Exec runs cmd on the buildlet.
//
// Two errors are returned: one is whether the command succeeded
// remotely (remoteErr), and the second (execErr) is whether there
// were system errors preventing the command from being started or
// seen to completition. If execErr is non-nil, the remoteErr is
// meaningless.
func (c *Client) Exec(cmd string, opts ExecOpts) (remoteErr, execErr error) {
var mode string
if opts.SystemLevel {
mode = "sys"
}
path := opts.Path
if len(path) == 0 && path != nil {
// url.Values doesn't distinguish between a nil slice and
// a non-nil zero-length slice, so use this sentinel value.
path = []string{"$EMPTY"}
}
form := url.Values{
"cmd": {cmd},
"mode": {mode},
"dir": {opts.Dir},
"cmdArg": opts.Args,
"env": opts.ExtraEnv,
"path": path,
"debug": {fmt.Sprint(opts.Debug)},
}
req, err := http.NewRequest("POST", c.URL()+"/exec", strings.NewReader(form.Encode()))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
// The first thing the buildlet's exec handler does is flush the headers, so
// 10 seconds should be plenty of time, regardless of where on the planet
// (Atlanta, Paris, etc) the reverse buildlet is:
res, err := c.doHeaderTimeout(req, 10*time.Second)
if err == errHeaderTimeout {
c.MarkBroken()
return nil, errors.New("buildlet: timeout waiting for exec header response")
}
if err != nil {
return nil, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
slurp, _ := ioutil.ReadAll(io.LimitReader(res.Body, 4<<10))
return nil, fmt.Errorf("buildlet: HTTP status %v: %s", res.Status, slurp)
}
condRun(opts.OnStartExec)
type errs struct {
remoteErr, execErr error
}
resc := make(chan errs, 1)
go func() {
// Stream the output:
out := opts.Output
if out == nil {
out = ioutil.Discard
}
if _, err := io.Copy(out, res.Body); err != nil {
resc <- errs{execErr: fmt.Errorf("error copying response: %v", err)}
return
}
// Don't record to the dashboard unless we heard the trailer from
// the buildlet, otherwise it was probably some unrelated error
// (like the VM being killed, or the buildlet crashing due to
// e.g. https://golang.org/issue/9309, since we require a tip
// build of the buildlet to get Trailers support)
state := res.Trailer.Get("Process-State")
if state == "" {
resc <- errs{execErr: errors.New("missing Process-State trailer from HTTP response; buildlet built with old (<= 1.4) Go?")}
return
}
if state != "ok" {
resc <- errs{remoteErr: errors.New(state)}
} else {
resc <- errs{} // success
}
}()
var timer <-chan time.Time
if opts.Timeout > 0 {
t := time.NewTimer(opts.Timeout)
defer t.Stop()
timer = t.C
}
select {
case <-timer:
c.MarkBroken()
return nil, ErrTimeout
case res := <-resc:
return res.remoteErr, res.execErr
case <-c.peerDead:
return nil, c.deadErr
}
}
// RemoveAll deletes the provided paths, relative to the work directory.
func (c *Client) RemoveAll(paths ...string) error {
if len(paths) == 0 {
return nil
}
form := url.Values{"path": paths}
req, err := http.NewRequest("POST", c.URL()+"/removeall", strings.NewReader(form.Encode()))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
return c.doOK(req)
}
// DestroyVM shuts down the buildlet and destroys the VM instance.
func (c *Client) DestroyVM(ts oauth2.TokenSource, proj, zone, instance string) error {
// TODO(bradfitz): move GCE stuff out of this package?
gceErrc := make(chan error, 1)
buildletErrc := make(chan error, 1)
go func() {
gceErrc <- DestroyVM(ts, proj, zone, instance)
}()
go func() {
buildletErrc <- c.Close()
}()
timeout := time.NewTimer(5 * time.Second)
defer timeout.Stop()
var retErr error
var gceDone, buildletDone bool
for !gceDone || !buildletDone {
select {
case err := <-gceErrc:
if err != nil {
retErr = err
}
gceDone = true
case err := <-buildletErrc:
if err != nil {
retErr = err
}
buildletDone = true
case <-timeout.C:
e := ""
if !buildletDone {
e = "timeout asking buildlet to shut down"
}
if !gceDone {
if e != "" {
e += " and "
}
e += "timeout asking GCE to delete builder VM"
}
return errors.New(e)
}
}
return retErr
}
// Status provides status information about the buildlet.
//
// A coordinator can use the provided information to decide what, if anything,
// to do with a buildlet.
type Status struct {
Version int // buildlet version, coordinator rejects any value less than 1.
}
// Status returns an Status value describing this buildlet.
func (c *Client) Status() (Status, error) {
select {
case <-c.peerDead:
return Status{}, c.deadErr
default:
// Continue below.
}
req, err := http.NewRequest("GET", c.URL()+"/status", nil)
if err != nil {
return Status{}, err
}
resp, err := c.doHeaderTimeout(req, 10*time.Second) // plenty of time
if err != nil {
return Status{}, err
}
if resp.StatusCode != http.StatusOK {
return Status{}, errors.New(resp.Status)
}
b, err := ioutil.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
return Status{}, err
}
var status Status
if err := json.Unmarshal(b, &status); err != nil {
return Status{}, err
}
return status, nil
}
// WorkDir returns the absolute path to the buildlet work directory.
func (c *Client) WorkDir() (string, error) {
req, err := http.NewRequest("GET", c.URL()+"/workdir", nil)
if err != nil {
return "", err
}
resp, err := c.doHeaderTimeout(req, 10*time.Second) // plenty of time
if err != nil {
return "", err
}
if resp.StatusCode != http.StatusOK {
return "", errors.New(resp.Status)
}
b, err := ioutil.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
return "", err
}
return string(b), nil
}
// DirEntry is the information about a file on a buildlet.
type DirEntry struct {
// line is of the form "drw-rw-rw\t<name>" and then if a regular file,
// also "\t<size>\t<modtime>". in either case, without trailing newline.
// TODO: break into parsed fields?
line string
}
func (de DirEntry) String() string {
return de.line
}
func (de DirEntry) Name() string {
f := strings.Split(de.line, "\t")
if len(f) < 2 {
return ""
}
return f[1]
}
func (de DirEntry) Digest() string {
f := strings.Split(de.line, "\t")
if len(f) < 5 {
return ""
}
return f[4]
}
// ListDirOpts are options for Client.ListDir.
type ListDirOpts struct {
// Recursive controls whether the directory is listed
// recursively.
Recursive bool
// Skip are the directories to skip, relative to the directory
// passed to ListDir. Each item should contain only forward
// slashes and not start or end in slashes.
Skip []string
// Digest controls whether the SHA-1 digests of regular files
// are returned.
Digest bool
}
// ListDir lists the contents of a directory.
// The fn callback is run for each entry.
func (c *Client) ListDir(dir string, opts ListDirOpts, fn func(DirEntry)) error {
param := url.Values{
"dir": {dir},
"recursive": {fmt.Sprint(opts.Recursive)},
"skip": opts.Skip,
"digest": {fmt.Sprint(opts.Digest)},
}
req, err := http.NewRequest("GET", c.URL()+"/ls?"+param.Encode(), nil)
if err != nil {
return err
}
resp, err := c.do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
slurp, _ := ioutil.ReadAll(io.LimitReader(resp.Body, 1<<10))
return fmt.Errorf("%s: %s", resp.Status, slurp)
}
sc := bufio.NewScanner(resp.Body)
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
fn(DirEntry{line: line})
}
return sc.Err()
}
cmd/coordinator, cmd/buildlet, cmd/gomote: add SSH support This adds an SSH server to farmer.golang.org on port 2222 that proxies SSH connections to users' gomote-created buildlet instances. For example: $ gomote create openbsd-amd64-60 user-bradfitz-openbsd-amd64-60-1 $ gomote ssh user-bradfitz-openbsd-amd64-60-1 Warning: Permanently added '[localhost]:33351' (ECDSA) to the list of known hosts. OpenBSD 6.0 (GENERIC.MP) golang/go#2319: Tue Jul 26 13:00:43 MDT 2016 Welcome to OpenBSD: The proactively secure Unix-like operating system. Please use the sendbug(1) utility to report bugs in the system. Before reporting a bug, please try to reproduce it with the latest version of the code. With bug reports, please try to ensure that enough information to reproduce the problem is enclosed, and if a known fix for it exists, include that as well. $ As before, if the coordinator process is restarted (or crashes, is evicted, etc), all gomote instances die. Not yet supported: * scp (help wanted) * not all host types are configured. most are. some will need slight config tweaks to the Docker image (e.g. adding openssh-server) Supports currently: * linux-amd64 (host type shared by 386, nacl) * linux-arm * linux-arm64 * darwin * freebsd * openbsd * plan9-386 * windows Implementation details: * the ssh server process listens on port 2222 in the coordinator (farmer.golang.org), which is behind a GKE TCP load balancer. * the ssh server library is github.com/gliderlabs/ssh * authentication is done via Github users' public keys. It's assumed that gomote user == github user. But there's a mapping in the code for known exceptions. * we can't give out access to this too widely. too many things are accessible from within the host environment if you look in the right places. Details omitted. But the Go team and other trusted gomote users can use this. * the buildlet binary has a new /connect-ssh handler that acts like a CONNECT request but instead of taking an explicit host:port, just says "give me your machine's SSH connection". The buildlet can also start sshd if needed for the environment. The /connect-ssh handler also installs the coordinator's public key. * a new buildlet client library method "ConnectSSH" hits the /connect-ssh handler and returns a net.Conn. * the coordinator's ssh.Handler is just running the OpenSSH ssh client. * because the OpenSSH ssh child process can't connect to a net.Conn, an emphemeral localhost port is created on the coordinator to proxy between the ssh client and the net.Conn returned by ConnectSSH. * The /connect-ssh handler requires http.Hijacker, which requires fully compliant net.Conn implementations as of Go 1.8. So I needed to flesh out revdial too, testing it with the golang.org/x/net/nettest package. * plan9 doesn't have an ssh server, so we use 0intro's new conterm program (drawterm without GUI support) to connect to plan9 from the coordinator ssh proxy instead of using the OpenSSH ssh client binary. * windows doesn't have an ssh server, so we enable the telnet service and the coordinator ssh proxy uses telnet instead on the backend on the private network. (There is a Windows ssh server but only in new versions.) Happy debugging over ssh! Fixes golang/go#19956 Change-Id: I80a62064c5f85af1f195f980c862ba29af4015f0 Reviewed-on: https://go-review.googlesource.com/50750 Reviewed-by: Herbie Ong <herbie@google.com> Reviewed-by: Jessie Frazelle <me@jessfraz.com> Reviewed-by: Brad Fitzpatrick <bradfitz@golang.org>
2017-07-22 22:15:56 +03:00
func (c *Client) getDialer() func() (net.Conn, error) {
if c.dialer != nil {
return c.dialer
}
return c.dialWithNetDial
}
func (c *Client) dialWithNetDial() (net.Conn, error) {
// TODO: contexts? the tedious part will be adding it to
// revdial.Dial. For now just do a 5 second timeout. Probably
// fine. This is currently only used for ssh connections.
d := net.Dialer{Timeout: 5 * time.Second}
return d.Dial("tcp", c.ipPort)
}
// ConnectSSH opens an SSH connection to the buildlet for the given username.
// The authorizedPubKey must be a line from an ~/.ssh/authorized_keys file
// and correspond to the private key to be used to communicate over the net.Conn.
func (c *Client) ConnectSSH(user, authorizedPubKey string) (net.Conn, error) {
conn, err := c.getDialer()()
if err != nil {
return nil, fmt.Errorf("error dialing HTTP connection before SSH upgrade: %v", err)
}
req, err := http.NewRequest("POST", "/connect-ssh", nil)
if err != nil {
conn.Close()
return nil, err
}
req.Header.Add("X-Go-Ssh-User", user)
req.Header.Add("X-Go-Authorized-Key", authorizedPubKey)
if err := req.Write(conn); err != nil {
conn.Close()
return nil, fmt.Errorf("writing /connect-ssh HTTP request failed: %v", err)
}
bufr := bufio.NewReader(conn)
res, err := http.ReadResponse(bufr, req)
if err != nil {
conn.Close()
return nil, fmt.Errorf("reading /connect-ssh response: %v", err)
}
if res.StatusCode != http.StatusSwitchingProtocols {
slurp, _ := ioutil.ReadAll(res.Body)
return nil, fmt.Errorf("unexpected /connect-ssh response: %v, %s", res.Status, slurp)
}
return conn, nil
}
func condRun(fn func()) {
if fn != nil {
fn()
}
}