Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Install Operation #12

Merged
merged 7 commits into from
Oct 12, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ module github.com/openconfig/gnoigo
go 1.18

require (
github.com/golang/glog v1.1.0
github.com/google/go-cmp v0.5.9
github.com/openconfig/gnoi v0.1.0
google.golang.org/grpc v1.58.0
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ github.com/envoyproxy/go-control-plane v0.10.2-0.20220325020618-49ff273808a1/go.
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/glog v1.1.0 h1:/d3pCKDPWNnvIWe0vVUpNP32qc8U3PDVxySP/y360qE=
github.com/golang/glog v1.1.0/go.mod h1:pfYeQZ3JWZoXTV5sFc986z3HTpwQs9At6P4ImfuP3NQ=
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
Expand Down
172 changes: 172 additions & 0 deletions os/os.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
// Copyright 2023 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//     https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

// Package os provides gNOI os operations.
package os

import (
"context"
"fmt"
"io"

log "github.com/golang/glog"
ospb "github.com/openconfig/gnoi/os"
"github.com/openconfig/gnoigo/internal"
)

// InstallOperation represents the parameters of a Install operation.
type InstallOperation struct {
req *ospb.TransferRequest
reader io.Reader
}

// Version identifies the OS version.
func (i *InstallOperation) Version(version string) *InstallOperation {
i.req.Version = version
return i
}

// Standby specifies if supervisor is on standby.
func (i *InstallOperation) Standby(standby bool) *InstallOperation {
i.req.StandbySupervisor = standby
return i
}

// Reader specifies the package reader for the OS file.
func (i *InstallOperation) Reader(reader io.Reader) *InstallOperation {
i.reader = reader
return i
}

// NewInstallOperation creates an empty InstallOperation.
func NewInstallOperation() *InstallOperation {
return &InstallOperation{req: &ospb.TransferRequest{}}
}

// awaitPackageInstall receives messages from the client until either
// (a) the package is installed and validated, in which case it returns the InstallResponse message
// (b) the device does not have the package, in which case it returns a nil response
// (c) an error occurs, in which case it returns the error
// (d) context is cancelled, in which case it returns the context error
func awaitPackageInstall(ctx context.Context, ic ospb.OS_InstallClient) (*ospb.InstallResponse, error) {
for {
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}

cresp, err := ic.Recv()
if err != nil {
return nil, err
}
switch v := cresp.GetResponse().(type) {
case *ospb.InstallResponse_Validated:
return cresp, nil
case *ospb.InstallResponse_TransferReady:
return nil, nil
case *ospb.InstallResponse_InstallError:
errName := ospb.InstallError_Type_name[int32(v.InstallError.Type)]
return nil, fmt.Errorf("installation error %q: %s", errName, v.InstallError.GetDetail())
case *ospb.InstallResponse_TransferProgress:
log.Infof("installation progress: %v bytes received from client", v.TransferProgress.GetBytesReceived())
case *ospb.InstallResponse_SyncProgress:
log.Infof("installation progress: %v%% synced from supervisor", v.SyncProgress.GetPercentageTransferred())
default:
return nil, fmt.Errorf("unexpected client install response: %v (%T)", v, v)
}
}
}

func transferContent(ctx context.Context, ic ospb.OS_InstallClient, reader io.Reader) error {
// The gNOI SetPackage operation sets the maximum chunk size at 64K,
// so assuming the install operation allows for up to the same size.
buf := make([]byte, 64*1024)
for {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
n, err := reader.Read(buf)
if n > 0 {
if err := ic.Send(
&ospb.InstallRequest{
Request: &ospb.InstallRequest_TransferContent{
TransferContent: buf[0:n],
},
},
); err != nil {
return err
}
}
if err == io.EOF {
break
}
if err != nil {
return err
}
}
return ic.Send(
&ospb.InstallRequest{
Request: &ospb.InstallRequest_TransferEnd{
TransferEnd: &ospb.TransferEnd{},
},
},
)
}

// Execute performs the Install operation.
func (i *InstallOperation) Execute(ctx context.Context, c *internal.Clients) (*ospb.InstallResponse, error) {
ic, icErr := c.OS().Install(ctx)
if icErr != nil {
return nil, icErr
}

installReq := &ospb.InstallRequest{
Request: &ospb.InstallRequest_TransferRequest{
TransferRequest: i.req,
},
}

if err := ic.Send(installReq); err != nil {
return nil, err
}

installResp, err := awaitPackageInstall(ctx, ic)
if err != nil {
return nil, err
}
if installResp != nil {
return installResp, nil
}
if i.reader == nil {
return nil, fmt.Errorf("no reader specified for install operation")
}
awaitChan := make(chan error)
go func() {
installResp, err = awaitPackageInstall(ctx, ic)
awaitChan <- err
}()
if err := transferContent(ctx, ic, i.reader); err != nil {
return nil, err
}
if err := <-awaitChan; err != nil {
return nil, err
}
if gotVersion := installResp.GetValidated().GetVersion(); gotVersion != i.req.Version {
return nil, fmt.Errorf("installed version %q does not match requested version %q", gotVersion, i.req.Version)
}
return installResp, nil
}
173 changes: 173 additions & 0 deletions os/os_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
// Copyright 2023 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//     https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package os_test

import (
"bytes"
"context"
"errors"
"fmt"
"os"
"strings"
"testing"

"github.com/google/go-cmp/cmp"
ospb "github.com/openconfig/gnoi/os"
"github.com/openconfig/gnoigo/internal"
gos "github.com/openconfig/gnoigo/os"
"google.golang.org/grpc"
"google.golang.org/protobuf/testing/protocmp"
)

type fakeOSClient struct {
ospb.OSClient
InstallFn func(context.Context, ...grpc.CallOption) (ospb.OS_InstallClient, error)
}

func (fg *fakeOSClient) OS() ospb.OSClient {
return fg
}

func (fg *fakeOSClient) Install(ctx context.Context, opts ...grpc.CallOption) (ospb.OS_InstallClient, error) {
return fg.InstallFn(ctx, opts...)
}

type fakeInstallClient struct {
ospb.OS_InstallClient
gotSent []*ospb.InstallRequest
stubRecv []*ospb.InstallResponse
}

func (ic *fakeInstallClient) Send(req *ospb.InstallRequest) error {
ic.gotSent = append(ic.gotSent, req)
return nil
}

func (ic *fakeInstallClient) Recv() (*ospb.InstallResponse, error) {
if len(ic.stubRecv) == 0 {
return nil, errors.New("no more stub responses")
}
resp := ic.stubRecv[0]
ic.stubRecv[0] = nil
ic.stubRecv = ic.stubRecv[1:]
return resp, nil
}

func (*fakeInstallClient) CloseSend() error {
return nil
}

func TestInstall(t *testing.T) {
const version = "1.2.3"

// Make a temp file to test specifying a file by file path.
file, err := os.CreateTemp("", "package")
if err != nil {
t.Fatalf("error creating temp file: %v", err)
}
defer os.Remove(file.Name())
defer file.Close()
if err := os.WriteFile(file.Name(), []byte{0}, os.ModePerm); err != nil {
t.Fatalf("error writing temp file: %v", err)
}

tests := []struct {
greg-dennis marked this conversation as resolved.
Show resolved Hide resolved
desc string
op *gos.InstallOperation
resps []*ospb.InstallResponse
want *ospb.InstallResponse
installErr string
wantErr string
cancelContext bool
}{
{
desc: "install with version",
op: gos.NewInstallOperation().Version(version),
resps: []*ospb.InstallResponse{
{Response: &ospb.InstallResponse_Validated{Validated: &ospb.Validated{Version: version}}},
},
want: &ospb.InstallResponse{Response: &ospb.InstallResponse_Validated{Validated: &ospb.Validated{Version: version}}},
},
{
desc: "install returns error",
op: gos.NewInstallOperation().Version(version),
resps: []*ospb.InstallResponse{},
installErr: "install error",
wantErr: "install error",
},
{
desc: "install with context cancel",
op: gos.NewInstallOperation().Version(version),
resps: []*ospb.InstallResponse{
{Response: &ospb.InstallResponse_Validated{Validated: &ospb.Validated{Version: version}}},
},
wantErr: "context",
cancelContext: true,
},
{
desc: "install without ioreader returns error",
op: gos.NewInstallOperation().Version(version),
resps: []*ospb.InstallResponse{
{Response: &ospb.InstallResponse_TransferReady{TransferReady: &ospb.TransferReady{}}},
{Response: &ospb.InstallResponse_Validated{Validated: &ospb.Validated{Version: version}}},
},
wantErr: "reader",
},
{
desc: "install with ioreader",
op: gos.NewInstallOperation().Version(version).Reader(bytes.NewReader([]byte{0})),
resps: []*ospb.InstallResponse{
{Response: &ospb.InstallResponse_TransferReady{TransferReady: &ospb.TransferReady{}}},
{Response: &ospb.InstallResponse_TransferProgress{TransferProgress: &ospb.TransferProgress{}}},
{Response: &ospb.InstallResponse_Validated{Validated: &ospb.Validated{Version: version}}},
},
want: &ospb.InstallResponse{Response: &ospb.InstallResponse_Validated{Validated: &ospb.Validated{Version: version}}},
},
{
desc: "install with mismatch version error",
op: gos.NewInstallOperation().Version(version).Reader(bytes.NewReader([]byte{0})),
resps: []*ospb.InstallResponse{
{Response: &ospb.InstallResponse_TransferReady{TransferReady: &ospb.TransferReady{}}},
{Response: &ospb.InstallResponse_Validated{Validated: &ospb.Validated{Version: version + "new"}}},
},
wantErr: "version",
},
}
for _, tt := range tests {
t.Run(tt.desc, func(t *testing.T) {
var fakeClient internal.Clients
fakeClient.OSClient = &fakeOSClient{InstallFn: func(context.Context, ...grpc.CallOption) (ospb.OS_InstallClient, error) {
if tt.installErr != "" {
return nil, fmt.Errorf(tt.installErr)
}
return &fakeInstallClient{stubRecv: tt.resps}, nil
}}

ctx, cancel := context.WithCancel(context.Background())
defer cancel()
if tt.cancelContext {
cancel()
}

got, gotErr := tt.op.Execute(ctx, &fakeClient)
if (gotErr == nil) != (tt.wantErr == "") || (gotErr != nil && !strings.Contains(gotErr.Error(), tt.wantErr)) {
t.Errorf("Execute() got unexpected error %v want %s", gotErr, tt.wantErr)
}
if diff := cmp.Diff(tt.want, got, protocmp.Transform()); diff != "" {
t.Errorf("Execute() got unexpected response diff (-want +got): %s", diff)
}
})
}
}