diff --git a/protokube/pkg/protokube/openstack_volume.go b/protokube/pkg/protokube/openstack_volume.go index c72afd9076..db482556a5 100644 --- a/protokube/pkg/protokube/openstack_volume.go +++ b/protokube/pkg/protokube/openstack_volume.go @@ -20,17 +20,42 @@ import ( "encoding/json" "fmt" "io" + "io/ioutil" "net" "net/http" + "os" + "path" "strings" "k8s.io/klog/v2" "k8s.io/kops/protokube/pkg/gossip" gossipos "k8s.io/kops/protokube/pkg/gossip/openstack" "k8s.io/kops/upup/pkg/fi/cloudup/openstack" + "k8s.io/mount-utils" + utilexec "k8s.io/utils/exec" ) -const MetadataLatest string = "http://169.254.169.254/openstack/latest/meta_data.json" +const ( + // MetadataLatestPath is the path to the metadata on the config drive + MetadataLatestPath string = "openstack/latest/meta_data.json" + + // MetadataID is the identifier for the metadata service + MetadataID string = "metadataService" + + // MetadataLastestServiceURL points to the latest metadata of the metadata service + MetadataLatestServiceURL string = "http://169.254.169.254/" + MetadataLatestPath + + // ConfigDriveID is the identifier for the config drive containing metadata + ConfigDriveID string = "configDrive" + + // ConfigDriveLabel identifies the config drive by label on the OS + ConfigDriveLabel string = "config-2" + + // DefaultMetadataSearchOrder defines the default order in which the metadata services are queried + DefaultMetadataSearchOrder string = ConfigDriveID + ", " + MetadataID + + DiskByLabelPath string = "/dev/disk/by-label/" +) type Metadata struct { // Matches openstack.TagClusterName @@ -59,31 +84,146 @@ type OpenStackCloudProvider struct { storageZone string } +type MetadataService struct { + serviceURL string + configDrivePath string + mounter *mount.SafeFormatAndMount + mountTarget string + searchOrder string +} + var _ CloudProvider = &OpenStackCloudProvider{} -func getLocalMetadata() (*InstanceMetadata, error) { - var meta InstanceMetadata +// getFromConfigDrive tries to get metadata by mounting a config drive and returns it as InstanceMetadata +// It will return an error if there is no disk labelled as ConfigDriveLabel or other errors while mounting the disk, or reading the file occur. +func (mds MetadataService) getFromConfigDrive() (*InstanceMetadata, error) { + dev := path.Join(DiskByLabelPath, ConfigDriveLabel) + if _, err := os.Stat(dev); os.IsNotExist(err) { + out, err := mds.mounter.Exec.Command( + "blkid", "-l", + "-t", fmt.Sprintf("LABEL=%s", ConfigDriveLabel), + "-o", "device", + ).CombinedOutput() + if err != nil { + return nil, fmt.Errorf("unable to run blkid: %v", err) + } + dev = strings.TrimSpace(string(out)) + } + + err := mds.mounter.Mount(dev, mds.mountTarget, "iso9660", []string{"ro"}) + if err != nil { + err = mds.mounter.Mount(dev, mds.mountTarget, "vfat", []string{"ro"}) + } + if err != nil { + return nil, fmt.Errorf("error mounting configdrive '%s': %v", dev, err) + } + defer mds.mounter.Unmount(mds.mountTarget) + + f, err := os.Open( + path.Join(mds.mountTarget, mds.configDrivePath)) + if err != nil { + return nil, fmt.Errorf("error reading '%s' on config drive: %v", mds.configDrivePath, err) + } + defer f.Close() + + return mds.parseMetadata(f) +} + +// getFromMetadataService tries to get metadata from a metadata service endpoint and returns it as InstanceMetadata. +// If the service endpoint cannot be contacted or reports a different status than StatusOK it will return an error. +func (mds MetadataService) getFromMetadataService() (*InstanceMetadata, error) { var client http.Client - resp, err := client.Get(MetadataLatest) + + resp, err := client.Get(mds.serviceURL) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode == http.StatusOK { - bodyBytes, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err - } - err = json.Unmarshal(bodyBytes, &meta) - if err != nil { - return nil, err - } - return &meta, nil + return mds.parseMetadata(resp.Body) } + + err = fmt.Errorf("fetching metadata from '%s' returned status code '%d'", mds.serviceURL, resp.StatusCode) return nil, err } +// parseMetadata reads JSON data from a Reader and returns it as InstanceMetadata. +func (mds MetadataService) parseMetadata(r io.Reader) (*InstanceMetadata, error) { + var meta InstanceMetadata + + data, err := ioutil.ReadAll(r) + if err != nil { + return nil, err + } + err = json.Unmarshal(data, &meta) + if err != nil { + return nil, err + } + + return &meta, nil +} + +// getMetadata tries to get metadata for the instance by mounting the config drive and/or querying the metadata service endpoint. +// Depending on the searchOrder it will return data from the first source which successfully returns. +// If all the sources in searchOrder are erroneous it will propagate the last error to its caller. +func (mds MetadataService) getMetadata() (*InstanceMetadata, error) { + // Note(ederst): I used and modified code for getting the config drive metadata to work from here: + // * https://github.com/kubernetes/cloud-provider-openstack/blob/27b6fc483451b6df2112a6a4a40a34ffc9093635/pkg/util/metadata/metadata.go + + var meta *InstanceMetadata + var err error + + ids := strings.Split(mds.searchOrder, ",") + for _, id := range ids { + id = strings.TrimSpace(id) + switch id { + case ConfigDriveID: + meta, err = mds.getFromConfigDrive() + case MetadataID: + meta, err = mds.getFromMetadataService() + default: + err = fmt.Errorf("%s is not a valid metadata search order option. Supported options are %s and %s", id, ConfigDriveID, MetadataID) + } + + if err == nil { + break + } + } + + return meta, err +} + +func newMetadataService(serviceURL string, configDrivePath string, mounter *mount.SafeFormatAndMount, mountTarget string, searchOrder string) *MetadataService { + return &MetadataService{ + serviceURL: serviceURL, + configDrivePath: configDrivePath, + mounter: mounter, + mountTarget: mountTarget, + searchOrder: searchOrder, + } +} + +// getDefaultMounter returns a mount and executor interface to use for getting metadata from a config drive +func getDefaultMounter() *mount.SafeFormatAndMount { + mounter := mount.New("") + exec := utilexec.New() + return &mount.SafeFormatAndMount{ + Interface: mounter, + Exec: exec, + } +} + +func getLocalMetadata() (*InstanceMetadata, error) { + mountTarget, err := ioutil.TempDir("", "configdrive") + if err != nil { + return nil, err + } + defer os.Remove(mountTarget) + + return newMetadataService(MetadataLatestServiceURL, MetadataLatestPath, getDefaultMounter(), mountTarget, DefaultMetadataSearchOrder).getMetadata() +} + // NewOpenStackCloudProvider builds a OpenStackCloudProvider func NewOpenStackCloudProvider() (*OpenStackCloudProvider, error) { metadata, err := getLocalMetadata() diff --git a/protokube/pkg/protokube/openstack_volume_test.go b/protokube/pkg/protokube/openstack_volume_test.go new file mode 100644 index 0000000000..d57907cb39 --- /dev/null +++ b/protokube/pkg/protokube/openstack_volume_test.go @@ -0,0 +1,247 @@ +/* +Copyright 2022 The Kubernetes Authors. + +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 + + http://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 protokube + +import ( + "fmt" + "net/http" + "net/http/httptest" + "os" + "reflect" + "strings" + "testing" + + "k8s.io/mount-utils" + ue "k8s.io/utils/exec" + uet "k8s.io/utils/exec/testing" +) + +const ( + metadataIDFirst = MetadataID + ", " + ConfigDriveID + configDriveIDFirst = ConfigDriveID + ", " + MetadataID +) + +// expectedDriveMetadata is the metadata expected from the service endpoint +var expectedServiceMetadata = &InstanceMetadata{ + ServerID: "01234567-cafe-babe-beef-0123456789ab", + Hostname: "test-server.serv.ice", + Name: "test-server", + AvailabilityZone: "nova", + ProjectID: "0123456789abcdeffedcba9876543210", +} + +// expectedDriveMetadata is the metadata expected from the config drive +var expectedDriveMetadata = &InstanceMetadata{ + ServerID: "01234567-cafe-babe-beef-0123456789ab", + Hostname: "test-server.config.drv", + Name: "test-server", + AvailabilityZone: "nova", + ProjectID: "0123456789abcdeffedcba9876543210", +} + +func assertTestResults(t *testing.T, err error, expected interface{}, actual interface{}) { + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + + if !reflect.DeepEqual(expected, actual) { + t.Fatalf("expected %+v, but got %+v", expected, actual) + } +} + +// mockMetadataEndpoint mocks the actual metadata endpoint by returning the passed data +func mockMetadataEndpoint(w http.ResponseWriter, r *http.Request, data string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, data) +} + +// createMockServer creates and returns a mock HTTP server to test getting the metadata from a service endpoint +func createMockServer() (*httptest.Server, error) { + data, err := os.ReadFile("testdata/metadata_service.json") + if err != nil { + return nil, err + } + + // Note(ederst): source of inspiration https://clavinjune.dev/en/blogs/mocking-http-call-in-golang-a-better-way/ + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "/openstack/latest/meta_data.json") { + mockMetadataEndpoint(w, r, string(data)) + } else { + http.NotFoundHandler().ServeHTTP(w, r) + } + })) + + return mockServer, nil +} + +// fakeBlkidCmd builds a fake blkid command which returns the device path of the config drive +func fakeBlkidCmd(device string) uet.FakeCommandAction { + fakeCmd := &uet.FakeCmd{ + CombinedOutputScript: []uet.FakeAction{ + func() ([]byte, []byte, error) { + if device == "" { + return nil, nil, uet.FakeExitError{ + Status: 2, + } + } + return []byte(device), nil, nil + }, + }, + } + + return func(cmd string, args ...string) ue.Cmd { + return uet.InitFakeCmd(fakeCmd, "blkid", "-l", "-t", "LABEL=config-2", "-o", "device") + } +} + +// getFakeMounter creates and returns a fake mounter to test getting the metadata from a config drive +func getFakeMounter(device string) *mount.SafeFormatAndMount { + fakeExec := &uet.FakeExec{ + ExactOrder: true, + } + + fakeExec.CommandScript = append(fakeExec.CommandScript, fakeBlkidCmd(device)) + + return &mount.SafeFormatAndMount{ + Interface: &mount.FakeMounter{}, + Exec: fakeExec, + } +} + +func TestGetMetadataFromMetadataServiceReturnsNotFoundError(t *testing.T) { + mockServer, err := createMockServer() + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + defer mockServer.Close() + metadataUrl := fmt.Sprintf("%s/%s", mockServer.URL, "no/meta_data.json") + + expectedErr := fmt.Errorf("fetching metadata from '%s' returned status code '404'", metadataUrl) + + _, actualErr := newMetadataService(metadataUrl, MetadataLatestPath, nil, "", MetadataID).getMetadata() + + assertTestResults(t, nil, expectedErr, actualErr) +} + +func TestGetMetadataFromMetadataService(t *testing.T) { + mockServer, err := createMockServer() + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + defer mockServer.Close() + metadataUrl := fmt.Sprintf("%s/%s", mockServer.URL, MetadataLatestPath) + + actual, err := newMetadataService(metadataUrl, MetadataLatestPath, nil, "", MetadataID).getMetadata() + + assertTestResults(t, err, expectedServiceMetadata, actual) +} + +func TestGetMetadataFromConfigDriveReturnsErrorWhenNoDeviceIsFound(t *testing.T) { + fakeMounter := getFakeMounter("") + + expectedErr := fmt.Errorf("unable to run blkid: exit 2") + + _, actualErr := newMetadataService("", "testdata/metadata_drive.json", fakeMounter, ".", ConfigDriveID).getMetadata() + + assertTestResults(t, nil, expectedErr, actualErr) +} + +func TestGetMetadataFromConfigDrive(t *testing.T) { + fakeMounter := getFakeMounter("/dev/sr0") + + actual, err := newMetadataService("", "testdata/metadata_drive.json", fakeMounter, ".", ConfigDriveID).getMetadata() + + assertTestResults(t, err, expectedDriveMetadata, actual) +} + +func TestGetMetadataReturnsLastErrorWhenNoMetadataWasFound(t *testing.T) { + fakeMounter := getFakeMounter("") + + mockServer, err := createMockServer() + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + defer mockServer.Close() + metadataUrl := fmt.Sprintf("%s/%s", mockServer.URL, "no/meta_data.json") + + expectedErr := fmt.Errorf("fetching metadata from '%s' returned status code '404'", metadataUrl) + + _, actualErr := newMetadataService(metadataUrl, "testdata/metadata_drive.json", fakeMounter, ".", configDriveIDFirst).getMetadata() + + assertTestResults(t, nil, expectedErr, actualErr) +} + +func TestGetMetadataFromConfigDriveWhenItIsFirstInSearchOrder(t *testing.T) { + fakeMounter := getFakeMounter("/dev/sr0") + + mockServer, err := createMockServer() + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + defer mockServer.Close() + metadataUrl := fmt.Sprintf("%s/%s", mockServer.URL, MetadataLatestPath) + + actual, err := newMetadataService(metadataUrl, "testdata/metadata_drive.json", fakeMounter, ".", configDriveIDFirst).getMetadata() + + assertTestResults(t, err, expectedDriveMetadata, actual) +} + +func TestGetMetadataFromServiceEndpointWhenConfigDriveFails(t *testing.T) { + fakeMounter := getFakeMounter("") + + mockServer, err := createMockServer() + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + defer mockServer.Close() + metadataUrl := fmt.Sprintf("%s/%s", mockServer.URL, MetadataLatestPath) + + actual, err := newMetadataService(metadataUrl, "testdata/metadata_drive.json", fakeMounter, ".", configDriveIDFirst).getMetadata() + + assertTestResults(t, err, expectedServiceMetadata, actual) +} + +func TestGetMetadataFromServiceEndpointWhenItIsFirstInSearchOrder(t *testing.T) { + fakeMounter := getFakeMounter("/dev/sr0") + + mockServer, err := createMockServer() + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + defer mockServer.Close() + metadataUrl := fmt.Sprintf("%s/%s", mockServer.URL, MetadataLatestPath) + + actual, err := newMetadataService(metadataUrl, "testdata/metadata_drive.json", fakeMounter, ".", metadataIDFirst).getMetadata() + + assertTestResults(t, err, expectedServiceMetadata, actual) +} + +func TestGetMetadataFromConfigDriveWhenServiceEndpointFails(t *testing.T) { + fakeMounter := getFakeMounter("/dev/sr0") + + mockServer, err := createMockServer() + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + defer mockServer.Close() + metadataUrl := fmt.Sprintf("%s/%s", mockServer.URL, "no/meta_data.json") + + actual, err := newMetadataService(metadataUrl, "testdata/metadata_drive.json", fakeMounter, ".", metadataIDFirst).getMetadata() + + assertTestResults(t, err, expectedDriveMetadata, actual) +} diff --git a/protokube/pkg/protokube/testdata/metadata_drive.json b/protokube/pkg/protokube/testdata/metadata_drive.json new file mode 100644 index 0000000000..e8cfb3dc05 --- /dev/null +++ b/protokube/pkg/protokube/testdata/metadata_drive.json @@ -0,0 +1,7 @@ +{ + "uuid": "01234567-cafe-babe-beef-0123456789ab", + "hostname": "test-server.config.drv", + "name": "test-server", + "availability_zone": "nova", + "project_id": "0123456789abcdeffedcba9876543210" +} diff --git a/protokube/pkg/protokube/testdata/metadata_service.json b/protokube/pkg/protokube/testdata/metadata_service.json new file mode 100644 index 0000000000..43b7fe30d6 --- /dev/null +++ b/protokube/pkg/protokube/testdata/metadata_service.json @@ -0,0 +1,7 @@ +{ + "uuid": "01234567-cafe-babe-beef-0123456789ab", + "hostname": "test-server.serv.ice", + "name": "test-server", + "availability_zone": "nova", + "project_id": "0123456789abcdeffedcba9876543210" +} diff --git a/vendor/k8s.io/utils/exec/testing/fake_exec.go b/vendor/k8s.io/utils/exec/testing/fake_exec.go new file mode 100644 index 0000000000..7380689256 --- /dev/null +++ b/vendor/k8s.io/utils/exec/testing/fake_exec.go @@ -0,0 +1,266 @@ +/* +Copyright 2017 The Kubernetes Authors. + +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 + + http://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 testingexec + +import ( + "context" + "fmt" + "io" + + "k8s.io/utils/exec" +) + +// FakeExec is a simple scripted Interface type. +type FakeExec struct { + CommandScript []FakeCommandAction + CommandCalls int + LookPathFunc func(string) (string, error) + // ExactOrder enforces that commands are called in the order they are scripted, + // and with the exact same arguments + ExactOrder bool + // DisableScripts removes the requirement that a slice of FakeCommandAction be + // populated before calling Command(). This makes the fakeexec (and subsequent + // calls to Run() or CombinedOutput() always return success and there is no + // ability to set their output. + DisableScripts bool +} + +var _ exec.Interface = &FakeExec{} + +// FakeCommandAction is the function to be executed +type FakeCommandAction func(cmd string, args ...string) exec.Cmd + +// Command is to track the commands that are executed +func (fake *FakeExec) Command(cmd string, args ...string) exec.Cmd { + if fake.DisableScripts { + fakeCmd := &FakeCmd{DisableScripts: true} + return InitFakeCmd(fakeCmd, cmd, args...) + } + if fake.CommandCalls > len(fake.CommandScript)-1 { + panic(fmt.Sprintf("ran out of Command() actions. Could not handle command [%d]: %s args: %v", fake.CommandCalls, cmd, args)) + } + i := fake.CommandCalls + fake.CommandCalls++ + fakeCmd := fake.CommandScript[i](cmd, args...) + if fake.ExactOrder { + argv := append([]string{cmd}, args...) + fc := fakeCmd.(*FakeCmd) + if cmd != fc.Argv[0] { + panic(fmt.Sprintf("received command: %s, expected: %s", cmd, fc.Argv[0])) + } + if len(argv) != len(fc.Argv) { + panic(fmt.Sprintf("command (%s) received with extra/missing arguments. Expected %v, Received %v", cmd, fc.Argv, argv)) + } + for i, a := range argv[1:] { + if a != fc.Argv[i+1] { + panic(fmt.Sprintf("command (%s) called with unexpected argument. Expected %s, Received %s", cmd, fc.Argv[i+1], a)) + } + } + } + return fakeCmd +} + +// CommandContext wraps arguments into exec.Cmd +func (fake *FakeExec) CommandContext(ctx context.Context, cmd string, args ...string) exec.Cmd { + return fake.Command(cmd, args...) +} + +// LookPath is for finding the path of a file +func (fake *FakeExec) LookPath(file string) (string, error) { + return fake.LookPathFunc(file) +} + +// FakeCmd is a simple scripted Cmd type. +type FakeCmd struct { + Argv []string + CombinedOutputScript []FakeAction + CombinedOutputCalls int + CombinedOutputLog [][]string + OutputScript []FakeAction + OutputCalls int + OutputLog [][]string + RunScript []FakeAction + RunCalls int + RunLog [][]string + Dirs []string + Stdin io.Reader + Stdout io.Writer + Stderr io.Writer + Env []string + StdoutPipeResponse FakeStdIOPipeResponse + StderrPipeResponse FakeStdIOPipeResponse + WaitResponse error + StartResponse error + DisableScripts bool +} + +var _ exec.Cmd = &FakeCmd{} + +// InitFakeCmd is for creating a fake exec.Cmd +func InitFakeCmd(fake *FakeCmd, cmd string, args ...string) exec.Cmd { + fake.Argv = append([]string{cmd}, args...) + return fake +} + +// FakeStdIOPipeResponse holds responses to use as fakes for the StdoutPipe and +// StderrPipe method calls +type FakeStdIOPipeResponse struct { + ReadCloser io.ReadCloser + Error error +} + +// FakeAction is a function type +type FakeAction func() ([]byte, []byte, error) + +// SetDir sets the directory +func (fake *FakeCmd) SetDir(dir string) { + fake.Dirs = append(fake.Dirs, dir) +} + +// SetStdin sets the stdin +func (fake *FakeCmd) SetStdin(in io.Reader) { + fake.Stdin = in +} + +// SetStdout sets the stdout +func (fake *FakeCmd) SetStdout(out io.Writer) { + fake.Stdout = out +} + +// SetStderr sets the stderr +func (fake *FakeCmd) SetStderr(out io.Writer) { + fake.Stderr = out +} + +// SetEnv sets the environment variables +func (fake *FakeCmd) SetEnv(env []string) { + fake.Env = env +} + +// StdoutPipe returns an injected ReadCloser & error (via StdoutPipeResponse) +// to be able to inject an output stream on Stdout +func (fake *FakeCmd) StdoutPipe() (io.ReadCloser, error) { + return fake.StdoutPipeResponse.ReadCloser, fake.StdoutPipeResponse.Error +} + +// StderrPipe returns an injected ReadCloser & error (via StderrPipeResponse) +// to be able to inject an output stream on Stderr +func (fake *FakeCmd) StderrPipe() (io.ReadCloser, error) { + return fake.StderrPipeResponse.ReadCloser, fake.StderrPipeResponse.Error +} + +// Start mimicks starting the process (in the background) and returns the +// injected StartResponse +func (fake *FakeCmd) Start() error { + return fake.StartResponse +} + +// Wait mimicks waiting for the process to exit returns the +// injected WaitResponse +func (fake *FakeCmd) Wait() error { + return fake.WaitResponse +} + +// Run runs the command +func (fake *FakeCmd) Run() error { + if fake.DisableScripts { + return nil + } + if fake.RunCalls > len(fake.RunScript)-1 { + panic("ran out of Run() actions") + } + if fake.RunLog == nil { + fake.RunLog = [][]string{} + } + i := fake.RunCalls + fake.RunLog = append(fake.RunLog, append([]string{}, fake.Argv...)) + fake.RunCalls++ + stdout, stderr, err := fake.RunScript[i]() + if stdout != nil { + fake.Stdout.Write(stdout) + } + if stderr != nil { + fake.Stderr.Write(stderr) + } + return err +} + +// CombinedOutput returns the output from the command +func (fake *FakeCmd) CombinedOutput() ([]byte, error) { + if fake.DisableScripts { + return []byte{}, nil + } + if fake.CombinedOutputCalls > len(fake.CombinedOutputScript)-1 { + panic("ran out of CombinedOutput() actions") + } + if fake.CombinedOutputLog == nil { + fake.CombinedOutputLog = [][]string{} + } + i := fake.CombinedOutputCalls + fake.CombinedOutputLog = append(fake.CombinedOutputLog, append([]string{}, fake.Argv...)) + fake.CombinedOutputCalls++ + stdout, _, err := fake.CombinedOutputScript[i]() + return stdout, err +} + +// Output is the response from the command +func (fake *FakeCmd) Output() ([]byte, error) { + if fake.DisableScripts { + return []byte{}, nil + } + if fake.OutputCalls > len(fake.OutputScript)-1 { + panic("ran out of Output() actions") + } + if fake.OutputLog == nil { + fake.OutputLog = [][]string{} + } + i := fake.OutputCalls + fake.OutputLog = append(fake.OutputLog, append([]string{}, fake.Argv...)) + fake.OutputCalls++ + stdout, _, err := fake.OutputScript[i]() + return stdout, err +} + +// Stop is to stop the process +func (fake *FakeCmd) Stop() { + // no-op +} + +// FakeExitError is a simple fake ExitError type. +type FakeExitError struct { + Status int +} + +var _ exec.ExitError = FakeExitError{} + +func (fake FakeExitError) String() string { + return fmt.Sprintf("exit %d", fake.Status) +} + +func (fake FakeExitError) Error() string { + return fake.String() +} + +// Exited always returns true +func (fake FakeExitError) Exited() bool { + return true +} + +// ExitStatus returns the fake status +func (fake FakeExitError) ExitStatus() int { + return fake.Status +} diff --git a/vendor/modules.txt b/vendor/modules.txt index 4f11ed9562..bd2b3adb02 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -1657,6 +1657,7 @@ k8s.io/utils/buffer k8s.io/utils/clock k8s.io/utils/clock/testing k8s.io/utils/exec +k8s.io/utils/exec/testing k8s.io/utils/integer k8s.io/utils/internal/third_party/forked/golang/net k8s.io/utils/io