Skip to content

Commit 289767d

Browse files
committed
internal/gomote, buildlet: add execute command implementation
This change adds the implementation for the execute command endpoint. This enables the caller to execute a command on the gomote instance. The output from the command will stream back to the caller if requested. This change also adds helper functions that retrieve the session from the session pool and the buildlet client. For golang/go#47521 Updates golang/go#48742 Change-Id: Iec1853667992b5674b07be5e972ac145a6349fca Reviewed-on: https://go-review.googlesource.com/c/build/+/382494 Trust: Carlos Amedee <[email protected]> Run-TryBot: Carlos Amedee <[email protected]> TryBot-Result: Gopher Robot <[email protected]> Reviewed-by: Alex Rakoczy <[email protected]>
1 parent 3ab5e7e commit 289767d

File tree

5 files changed

+466
-160
lines changed

5 files changed

+466
-160
lines changed

buildlet/fakebuildletclient.go

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ package buildlet
77
import (
88
"context"
99
"errors"
10+
"fmt"
1011
"io"
1112
"net"
1213
"net/http"
@@ -88,7 +89,19 @@ func (fc *FakeClient) DestroyVM(ts oauth2.TokenSource, proj, zone, instance stri
8889

8990
// Exec fakes the execution.
9091
func (fc *FakeClient) Exec(ctx context.Context, cmd string, opts ExecOpts) (remoteErr, execErr error) {
91-
return nil, errUnimplemented
92+
if cmd == "" {
93+
return nil, errors.New("invalid command")
94+
}
95+
if opts.Output == nil {
96+
return nil, nil
97+
}
98+
out := []byte("<this is a song that never ends>")
99+
for it := 0; it < 3; it++ {
100+
if n, err := opts.Output.Write(out); n != len(out) || err != nil {
101+
return nil, fmt.Errorf("Output.Write(...) = %d, %q; want %d, no error", n, err, len(out))
102+
}
103+
}
104+
return nil, nil
92105
}
93106

94107
// GCEInstanceName gives the fake instance name.

internal/gomote/gomote.go

Lines changed: 88 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ package gomote
1010
import (
1111
"context"
1212
"errors"
13+
"fmt"
1314
"log"
1415
"regexp"
1516
"strings"
@@ -149,12 +150,10 @@ func (s *Server) InstanceAlive(ctx context.Context, req *protos.InstanceAliveReq
149150
if req.GetGomoteId() == "" {
150151
return nil, status.Errorf(codes.InvalidArgument, "invalid gomote ID")
151152
}
152-
session, err := s.buildlets.Session(req.GetGomoteId())
153+
_, err = s.session(req.GetGomoteId(), creds.ID)
153154
if err != nil {
154-
return nil, status.Errorf(codes.NotFound, "specified gomote instance does not exist")
155-
}
156-
if session.OwnerID != creds.ID {
157-
return nil, status.Errorf(codes.PermissionDenied, "not allowed to modify this gomote session")
155+
// the helper function returns meaningful GRPC error.
156+
return nil, err
158157
}
159158
if err := s.buildlets.RenewTimeout(req.GetGomoteId()); err != nil {
160159
return nil, status.Errorf(codes.Internal, "unable to renew timeout")
@@ -170,16 +169,10 @@ func (s *Server) ListDirectory(ctx context.Context, req *protos.ListDirectoryReq
170169
if req.GetGomoteId() == "" || req.GetDirectory() == "" {
171170
return nil, status.Errorf(codes.InvalidArgument, "invalid arguments")
172171
}
173-
session, err := s.buildlets.Session(req.GetGomoteId())
172+
_, bc, err := s.sessionAndClient(req.GetGomoteId(), creds.ID)
174173
if err != nil {
175-
return nil, status.Errorf(codes.NotFound, "specified gomote instance does not exist")
176-
}
177-
if session.OwnerID != creds.ID {
178-
return nil, status.Errorf(codes.PermissionDenied, "not allowed to modify this gomote session")
179-
}
180-
bc, err := s.buildlets.BuildletClient(req.GetGomoteId())
181-
if err != nil {
182-
return nil, status.Errorf(codes.Internal, "unable to retrieve buildlet client")
174+
// the helper function returns meaningful GRPC error.
175+
return nil, err
183176
}
184177
opt := buildlet.ListDirOpts{
185178
Recursive: req.GetRecursive(),
@@ -230,12 +223,10 @@ func (s *Server) DestroyInstance(ctx context.Context, req *protos.DestroyInstanc
230223
if req.GetGomoteId() == "" {
231224
return nil, status.Errorf(codes.InvalidArgument, "invalid gomote ID")
232225
}
233-
session, err := s.buildlets.Session(req.GetGomoteId())
226+
_, err = s.session(req.GetGomoteId(), creds.ID)
234227
if err != nil {
235-
return nil, status.Errorf(codes.NotFound, "specified gomote instance does not exist")
236-
}
237-
if session.OwnerID != creds.ID {
238-
return nil, status.Errorf(codes.PermissionDenied, "not allowed to modify this gomote session")
228+
// the helper function returns meaningful GRPC error.
229+
return nil, err
239230
}
240231
if err := s.buildlets.DestroySession(req.GetGomoteId()); err != nil {
241232
log.Printf("DestroyInstance remote.DestroySession(%s) = %s", req.GetGomoteId(), err)
@@ -244,6 +235,54 @@ func (s *Server) DestroyInstance(ctx context.Context, req *protos.DestroyInstanc
244235
return &protos.DestroyInstanceResponse{}, nil
245236
}
246237

238+
// ExecuteCommand will execute a command on a gomote instance. The output from the command will be streamed back to the caller if the output is set.
239+
func (s *Server) ExecuteCommand(req *protos.ExecuteCommandRequest, stream protos.GomoteService_ExecuteCommandServer) error {
240+
creds, err := access.IAPFromContext(stream.Context())
241+
if err != nil {
242+
return status.Errorf(codes.Unauthenticated, "request does not contain the required authentication")
243+
}
244+
_, bc, err := s.sessionAndClient(req.GetGomoteId(), creds.ID)
245+
if err != nil {
246+
// the helper function returns meaningful GRPC error.
247+
return err
248+
}
249+
remoteErr, execErr := bc.Exec(stream.Context(), req.GetCommand(), buildlet.ExecOpts{
250+
Dir: req.GetCommand(),
251+
SystemLevel: req.GetSystemLevel(),
252+
Output: &execStreamWriter{stream: stream},
253+
Args: req.GetArgs(),
254+
ExtraEnv: req.GetAppendEnvironment(),
255+
Debug: req.GetDebug(),
256+
Path: req.GetPath(),
257+
})
258+
if execErr != nil {
259+
// there were system errors preventing the command from being started or seen to completition.
260+
return status.Errorf(codes.Aborted, "unable to execute command: %s", execErr)
261+
}
262+
if remoteErr != nil {
263+
// the command succeeded remotely
264+
return status.Errorf(codes.Unknown, "command execution failed: %s", remoteErr)
265+
}
266+
return nil
267+
}
268+
269+
// execStreamWriter implements the io.Writer interface. Any data writen to it will be streamed
270+
// as an execute command response.
271+
type execStreamWriter struct {
272+
stream protos.GomoteService_ExecuteCommandServer
273+
}
274+
275+
// Write sends data writen to it as an execute command response.
276+
func (sw *execStreamWriter) Write(p []byte) (int, error) {
277+
err := sw.stream.Send(&protos.ExecuteCommandResponse{
278+
Output: string(p),
279+
})
280+
if err != nil {
281+
return 0, fmt.Errorf("unable to send data=%w", err)
282+
}
283+
return len(p), nil
284+
}
285+
247286
// RemoveFiles removes files or directories from the gomote instance.
248287
func (s *Server) RemoveFiles(ctx context.Context, req *protos.RemoveFilesRequest) (*protos.RemoveFilesResponse, error) {
249288
creds, err := access.IAPFromContext(ctx)
@@ -255,16 +294,10 @@ func (s *Server) RemoveFiles(ctx context.Context, req *protos.RemoveFilesRequest
255294
if req.GetGomoteId() == "" || len(req.GetPaths()) == 0 {
256295
return nil, status.Errorf(codes.InvalidArgument, "invalid arguments")
257296
}
258-
session, err := s.buildlets.Session(req.GetGomoteId())
297+
_, bc, err := s.sessionAndClient(req.GetGomoteId(), creds.ID)
259298
if err != nil {
260-
return nil, status.Errorf(codes.NotFound, "specified gomote instance does not exist")
261-
}
262-
if session.OwnerID != creds.ID {
263-
return nil, status.Errorf(codes.PermissionDenied, "not allowed to modify this gomote session")
264-
}
265-
bc, err := s.buildlets.BuildletClient(req.GetGomoteId())
266-
if err != nil {
267-
return nil, status.Errorf(codes.Internal, "unable to retrieve buildlet client")
299+
// the helper function returns meaningful GRPC error.
300+
return nil, err
268301
}
269302
if err := bc.RemoveAll(ctx, req.GetPaths()...); err != nil {
270303
log.Printf("RemoveFiles buildletClient.RemoveAll(ctx, %q) = %s", req.GetPaths(), err)
@@ -287,21 +320,41 @@ func (s *Server) WriteTGZFromURL(ctx context.Context, req *protos.WriteTGZFromUR
287320
if req.GetUrl() == "" {
288321
return nil, status.Errorf(codes.InvalidArgument, "missing URL")
289322
}
290-
session, err := s.buildlets.Session(req.GetGomoteId())
323+
_, bc, err := s.sessionAndClient(req.GetGomoteId(), creds.ID)
324+
if err != nil {
325+
// the helper function returns meaningful GRPC error.
326+
return nil, err
327+
}
328+
if err = bc.PutTarFromURL(ctx, req.GetUrl(), req.GetDirectory()); err != nil {
329+
return nil, status.Errorf(codes.FailedPrecondition, "unable to write tar.gz: %s", err)
330+
}
331+
return &protos.WriteTGZFromURLResponse{}, nil
332+
}
333+
334+
// session is a helper function that retreives a session associated with the gomoteID and ownerID.
335+
func (s *Server) session(gomoteID, ownerID string) (*remote.Session, error) {
336+
session, err := s.buildlets.Session(gomoteID)
291337
if err != nil {
292338
return nil, status.Errorf(codes.NotFound, "specified gomote instance does not exist")
293339
}
294-
if session.OwnerID != creds.ID {
340+
if session.OwnerID != ownerID {
295341
return nil, status.Errorf(codes.PermissionDenied, "not allowed to modify this gomote session")
296342
}
297-
bc, err := s.buildlets.BuildletClient(req.GetGomoteId())
343+
return session, nil
344+
}
345+
346+
// sessionAndClient is a helper function that retrieves a session and buildlet client for the
347+
// associated gomoteID and ownerID.
348+
func (s *Server) sessionAndClient(gomoteID, ownerID string) (*remote.Session, buildlet.Client, error) {
349+
session, err := s.session(gomoteID, ownerID)
298350
if err != nil {
299-
return nil, status.Errorf(codes.NotFound, "specified gomote instance does not exist")
351+
return nil, nil, err
300352
}
301-
if err = bc.PutTarFromURL(ctx, req.GetUrl(), req.GetDirectory()); err != nil {
302-
return nil, status.Errorf(codes.FailedPrecondition, "unable to write tar.gz: %s", err)
353+
bc, err := s.buildlets.BuildletClient(gomoteID)
354+
if err != nil {
355+
return nil, nil, status.Errorf(codes.NotFound, "specified gomote instance does not exist")
303356
}
304-
return &protos.WriteTGZFromURLResponse{}, nil
357+
return session, bc, nil
305358
}
306359

307360
// isPrivilagedUser returns true if the user is using a Google account.

internal/gomote/gomote_test.go

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -401,6 +401,115 @@ func TestDestroyInstanceError(t *testing.T) {
401401
}
402402
}
403403

404+
func TestExecuteCommand(t *testing.T) {
405+
ctx := access.FakeContextWithOutgoingIAPAuth(context.Background(), fakeIAP())
406+
client := setupGomoteTest(t, context.Background())
407+
gomoteID := mustCreateInstance(t, client, fakeIAP())
408+
stream, err := client.ExecuteCommand(ctx, &protos.ExecuteCommandRequest{
409+
GomoteId: gomoteID,
410+
Command: "ls",
411+
SystemLevel: false,
412+
Debug: false,
413+
AppendEnvironment: nil,
414+
Path: nil,
415+
Directory: "/workdir",
416+
Args: []string{"-alh"},
417+
})
418+
if err != nil {
419+
t.Fatalf("client.ExecuteCommand(ctx, req) = response, %s; want no error", err)
420+
}
421+
out := []string{}
422+
for {
423+
res, err := stream.Recv()
424+
if err != nil && err == io.EOF {
425+
break
426+
}
427+
if err != nil {
428+
t.Fatalf("stream.Recv() = _, %s; want no error", err)
429+
}
430+
out = append(out, res.GetOutput())
431+
}
432+
if len(out) == 0 {
433+
t.Fatalf("output: %q, expected non-empty", out)
434+
}
435+
}
436+
437+
func TestExecuteCommandError(t *testing.T) {
438+
// This test will create a gomote instance and attempt to call TestExecuteCommand.
439+
// If overrideID is set to true, the test will use a diffrent gomoteID than the
440+
// the one created for the test.
441+
testCases := []struct {
442+
desc string
443+
ctx context.Context
444+
overrideID bool
445+
gomoteID string // Used iff overrideID is true.
446+
cmd string
447+
wantCode codes.Code
448+
}{
449+
{
450+
desc: "unauthenticated request",
451+
ctx: context.Background(),
452+
wantCode: codes.Unauthenticated,
453+
},
454+
{
455+
desc: "missing gomote id",
456+
ctx: access.FakeContextWithOutgoingIAPAuth(context.Background(), fakeIAP()),
457+
overrideID: true,
458+
gomoteID: "",
459+
wantCode: codes.NotFound,
460+
},
461+
{
462+
desc: "missing command",
463+
ctx: access.FakeContextWithOutgoingIAPAuth(context.Background(), fakeIAP()),
464+
wantCode: codes.Aborted,
465+
},
466+
{
467+
desc: "gomote does not exist",
468+
ctx: access.FakeContextWithOutgoingIAPAuth(context.Background(), fakeIAPWithUser("foo", "bar")),
469+
overrideID: true,
470+
gomoteID: "chucky",
471+
cmd: "ls",
472+
wantCode: codes.NotFound,
473+
},
474+
{
475+
desc: "wrong gomote id",
476+
ctx: access.FakeContextWithOutgoingIAPAuth(context.Background(), fakeIAPWithUser("foo", "bar")),
477+
overrideID: false,
478+
cmd: "ls",
479+
wantCode: codes.PermissionDenied,
480+
},
481+
}
482+
for _, tc := range testCases {
483+
t.Run(tc.desc, func(t *testing.T) {
484+
client := setupGomoteTest(t, context.Background())
485+
gomoteID := mustCreateInstance(t, client, fakeIAP())
486+
if tc.overrideID {
487+
gomoteID = tc.gomoteID
488+
}
489+
stream, err := client.ExecuteCommand(tc.ctx, &protos.ExecuteCommandRequest{
490+
GomoteId: gomoteID,
491+
Command: tc.cmd,
492+
SystemLevel: false,
493+
Debug: false,
494+
AppendEnvironment: nil,
495+
Path: nil,
496+
Directory: "/workdir",
497+
Args: []string{"-alh"},
498+
})
499+
if err != nil {
500+
t.Fatalf("unexpected error: %s", err)
501+
}
502+
res, err := stream.Recv()
503+
if err != nil && status.Code(err) != tc.wantCode {
504+
t.Fatalf("unexpected error: %s", err)
505+
}
506+
if err == nil {
507+
t.Fatalf("client.ExecuteCommand(ctx, req) = %v, nil; want error", res)
508+
}
509+
})
510+
}
511+
}
512+
404513
func TestRemoveFiles(t *testing.T) {
405514
ctx := access.FakeContextWithOutgoingIAPAuth(context.Background(), fakeIAP())
406515
client := setupGomoteTest(t, context.Background())

0 commit comments

Comments
 (0)