diff --git a/test/upgrade/shell/assertions_test.go b/test/upgrade/shell/assertions_test.go new file mode 100644 index 0000000000..4800d7ac0e --- /dev/null +++ b/test/upgrade/shell/assertions_test.go @@ -0,0 +1,45 @@ +/* +Copyright 2020 The Knative 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 shell_test + +import ( + "strings" + "testing" +) + +type assertions struct { + t *testing.T +} + +func (a assertions) NoError(err error) { + if err != nil { + a.t.Error(err) + } +} + +func (a assertions) Contains(haystack, needle string) { + if !strings.Contains(haystack, needle) { + a.t.Errorf("wanted to \ncontain: %#v\n in: %#v", + needle, haystack) + } +} + +func (a assertions) Equal(want, got string) { + if got != want { + a.t.Errorf("want: %#v\n got:%#v", want, got) + } +} diff --git a/test/upgrade/shell/executor.go b/test/upgrade/shell/executor.go new file mode 100644 index 0000000000..d41ccc765a --- /dev/null +++ b/test/upgrade/shell/executor.go @@ -0,0 +1,191 @@ +/* +Copyright 2020 The Knative 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 shell + +import ( + "bytes" + "fmt" + "os" + "os/exec" + "strings" + "time" +) + +const ( + defaultLabelOut = "[OUT]" + defaultLabelErr = "[ERR]" + executeMode = 0700 +) + +// NewExecutor creates a new executor. +func NewExecutor(t TestingT, loc ProjectLocation, opts ...Option) Executor { + config := &ExecutorConfig{ + ProjectLocation: loc, + Streams: testingTStreams(t), + } + for _, opt := range opts { + opt(config) + } + configureDefaultValues(config) + return &streamingExecutor{ + ExecutorConfig: *config, + } +} + +// testingTStreams returns Streams which writes to test log. +func testingTStreams(t TestingT) Streams { + tWriter := testingWriter{t: t} + return Streams{ + Out: tWriter, + Err: tWriter, + } +} + +// RunScript executes a shell script with args. +func (s *streamingExecutor) RunScript(script Script, args ...string) error { + cnt := script.scriptContent(s.ProjectLocation, args) + return withTempScript(cnt, func(bin string) error { + return stream(bin, s.ExecutorConfig, script.Label) + }) +} + +// RunFunction executes a shell function with args. +func (s *streamingExecutor) RunFunction(fn Function, args ...string) error { + cnt := fn.scriptContent(s.ProjectLocation, args) + return withTempScript(cnt, func(bin string) error { + return stream(bin, s.ExecutorConfig, fn.Label) + }) +} + +type streamingExecutor struct { + ExecutorConfig +} + +func configureDefaultValues(config *ExecutorConfig) { + if config.LabelOut == "" { + config.LabelOut = defaultLabelOut + } + if config.LabelErr == "" { + config.LabelErr = defaultLabelErr + } + if config.Environ == nil { + config.Environ = os.Environ() + } + if !config.SkipDate && config.DateFormat == "" { + config.DateFormat = time.StampMilli + } + if config.PrefixFunc == nil { + config.PrefixFunc = defaultPrefixFunc + } +} + +func stream(bin string, cfg ExecutorConfig, label string) error { + c := exec.Command(bin) + c.Env = cfg.Environ + c.Stdout = NewPrefixer(cfg.Out, prefixFunc(StreamTypeOut, label, cfg)) + c.Stderr = NewPrefixer(cfg.Err, prefixFunc(StreamTypeErr, label, cfg)) + return c.Run() +} + +func prefixFunc(st StreamType, label string, cfg ExecutorConfig) func() string { + return func() string { + return cfg.PrefixFunc(st, label, cfg) + } +} + +func defaultPrefixFunc(st StreamType, label string, cfg ExecutorConfig) string { + sep := " " + var buf []string + if !cfg.SkipDate { + dt := time.Now().Format(cfg.DateFormat) + buf = append(buf, dt) + } + buf = append(buf, label) + switch st { + case StreamTypeOut: + buf = append(buf, cfg.LabelOut) + case StreamTypeErr: + buf = append(buf, cfg.LabelErr) + } + return strings.Join(buf, sep) + sep +} + +func withTempScript(contents string, fn func(bin string) error) error { + tmpfile, err := os.CreateTemp("", "shellout-*.sh") + if err != nil { + return err + } + _, err = tmpfile.WriteString(contents) + if err != nil { + return err + } + err = tmpfile.Chmod(executeMode) + if err != nil { + return err + } + err = tmpfile.Close() + if err != nil { + return err + } + defer func() { + // clean up + _ = os.Remove(tmpfile.Name()) + }() + + return fn(tmpfile.Name()) +} + +func (fn *Function) scriptContent(location ProjectLocation, args []string) string { + return fmt.Sprintf(`#!/usr/bin/env bash + +set -Eeuo pipefail + +cd "%s" +source %s + +%s %s +`, location.RootPath(), fn.ScriptPath, fn.FunctionName, quoteArgs(args)) +} + +func (sc *Script) scriptContent(location ProjectLocation, args []string) string { + return fmt.Sprintf(`#!/usr/bin/env bash + +set -Eeuo pipefail + +cd "%s" +%s %s +`, location.RootPath(), sc.ScriptPath, quoteArgs(args)) +} + +func quoteArgs(args []string) string { + quoted := make([]string, len(args)) + for i, arg := range args { + quoted[i] = "\"" + strings.ReplaceAll(arg, "\"", "\\\"") + "\"" + } + return strings.Join(quoted, " ") +} + +func (w testingWriter) Write(p []byte) (n int, err error) { + n = len(p) + + // Strip trailing newline because t.Log always adds one. + p = bytes.TrimRight(p, "\n") + + w.t.Logf("%s", p) + + return n, nil +} diff --git a/test/upgrade/shell/executor_test.go b/test/upgrade/shell/executor_test.go new file mode 100644 index 0000000000..b0332278b1 --- /dev/null +++ b/test/upgrade/shell/executor_test.go @@ -0,0 +1,145 @@ +/* +Copyright 2020 The Knative 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 shell_test + +import ( + "bytes" + "testing" + + "knative.dev/pkg/test/upgrade/shell" +) + +func TestNewExecutor(t *testing.T) { + assert := assertions{t: t} + tests := []testcase{ + helloWorldTestCase(t), + abortTestCase(t), + failExampleCase(t), + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var outB, errB bytes.Buffer + executor := shell.NewExecutor(t, tt.config.ProjectLocation, func(cfg *shell.ExecutorConfig) { + cfg.Streams.Out = &outB + cfg.Streams.Err = &errB + }) + err := tt.op(executor) + if err != nil && !tt.wants.failed { + t.Errorf("%s: \n got: %#v\nfailed: %#v", tt.name, err, tt.failed) + } + + for _, wantOut := range tt.wants.outs { + assert.Contains(outB.String(), wantOut) + } + for _, wantErr := range tt.wants.errs { + assert.Contains(errB.String(), wantErr) + } + }) + } +} + +func TestExecutorDefaults(t *testing.T) { + assert := assertions{t: t} + loc, err := shell.NewProjectLocation("../../..") + assert.NoError(err) + exec := shell.NewExecutor(t, loc) + err = exec.RunFunction(fn("true")) + assert.NoError(err) +} + +func helloWorldTestCase(t *testing.T) testcase { + return testcase{ + "echo Hello, World!", + config(t, func(cfg *shell.ExecutorConfig) { + cfg.SkipDate = true + }), + func(exec shell.Executor) error { + return exec.RunFunction(fn("echo"), "Hello, World!") + }, + wants{ + outs: []string{ + "echo [OUT] Hello, World!", + }, + }, + } +} + +func abortTestCase(t *testing.T) testcase { + return testcase{ + "abort function", + config(t, func(cfg *shell.ExecutorConfig) {}), + func(exec shell.Executor) error { + return exec.RunFunction(fn("abort"), "reason") + }, + wants{ + failed: true, + }, + } +} + +func failExampleCase(t *testing.T) testcase { + return testcase{ + "fail-example.sh", + config(t, func(cfg *shell.ExecutorConfig) {}), + func(exec shell.Executor) error { + return exec.RunScript(shell.Script{ + Label: "fail-example.sh", + ScriptPath: "test/upgrade/shell/fail-example.sh", + }, "expected err") + }, + wants{ + failed: true, + errs: []string{ + "expected err", + }, + }, + } +} + +type wants struct { + failed bool + outs []string + errs []string +} + +type testcase struct { + name string + config shell.ExecutorConfig + op func(exec shell.Executor) error + wants +} + +func config(t *testing.T, customize func(cfg *shell.ExecutorConfig)) shell.ExecutorConfig { + assert := assertions{t: t} + loc, err := shell.NewProjectLocation("../../..") + assert.NoError(err) + cfg := shell.ExecutorConfig{ + ProjectLocation: loc, + } + customize(&cfg) + return cfg +} + +func fn(name string) shell.Function { + return shell.Function{ + Script: shell.Script{ + Label: name, + ScriptPath: "vendor/knative.dev/hack/library.sh", + }, + FunctionName: name, + } +} diff --git a/test/upgrade/shell/fail-example.sh b/test/upgrade/shell/fail-example.sh new file mode 100755 index 0000000000..4815aef9de --- /dev/null +++ b/test/upgrade/shell/fail-example.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash + +# Copyright 2020 The Knative 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. + +echo "$*" >&2 diff --git a/test/upgrade/shell/prefixer.go b/test/upgrade/shell/prefixer.go new file mode 100644 index 0000000000..f267c1e3b4 --- /dev/null +++ b/test/upgrade/shell/prefixer.go @@ -0,0 +1,68 @@ +/* +Copyright 2020 The Knative 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 shell + +import ( + "bytes" + "io" +) + +// NewPrefixer creates a new prefixer that forwards all calls to Write() to +// writer.Write() with all lines prefixed with the value of prefix. Having a +// function instead of a static prefix allows to print timestamps or other +// changing information. +func NewPrefixer(writer io.Writer, prefix func() string) io.Writer { + return &prefixer{prefix: prefix, writer: writer, trailingNewline: true} +} + +type prefixer struct { + prefix func() string + writer io.Writer + trailingNewline bool + buf bytes.Buffer // reuse buffer to save allocations +} + +func (pf *prefixer) Write(payload []byte) (int, error) { + pf.buf.Reset() // clear the buffer + + for _, b := range payload { + if pf.trailingNewline { + pf.buf.WriteString(pf.prefix()) + pf.trailingNewline = false + } + + pf.buf.WriteByte(b) + + if b == '\n' { + // do not print the prefix right after the newline character as this might + // be the very last character of the stream and we want to avoid a trailing prefix. + pf.trailingNewline = true + } + } + + n, err := pf.writer.Write(pf.buf.Bytes()) + if err != nil { + // never return more than original length to satisfy io.Writer interface + if n > len(payload) { + n = len(payload) + } + return n, err + } + + // return original length to satisfy io.Writer interface + return len(payload), nil +} diff --git a/test/upgrade/shell/prefixer_test.go b/test/upgrade/shell/prefixer_test.go new file mode 100644 index 0000000000..184b340d02 --- /dev/null +++ b/test/upgrade/shell/prefixer_test.go @@ -0,0 +1,70 @@ +/* +Copyright 2020 The Knative 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 shell_test + +import ( + "bytes" + "strconv" + "testing" + + "knative.dev/pkg/test/upgrade/shell" +) + +func TestNewPrefixer(t *testing.T) { + assert := assertions{t: t} + var lineno int64 + tests := []struct { + name string + prefix func() string + want string + }{{ + "static", + func() string { + return "[prefix] " + }, + `[prefix] test string 1 +[prefix] test string 2 +`, + }, { + "empty", + func() string { + return "" + }, + `test string 1 +test string 2 +`, + }, { + "dynamic", + func() string { + lineno++ + return strconv.FormatInt(lineno, 10) + ") " + }, + `1) test string 1 +2) test string 2 +`, + }} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + writer := &bytes.Buffer{} + wr := shell.NewPrefixer(writer, tt.prefix) + _, err := wr.Write([]byte("test string 1\ntest string 2\n")) + assert.NoError(err) + got := writer.String() + assert.Equal(tt.want, got) + }) + } +} diff --git a/test/upgrade/shell/project.go b/test/upgrade/shell/project.go new file mode 100644 index 0000000000..c58bc713c3 --- /dev/null +++ b/test/upgrade/shell/project.go @@ -0,0 +1,81 @@ +/* +Copyright 2020 The Knative 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 shell + +import ( + "errors" + "fmt" + "path" + "regexp" + "runtime" +) + +var ( + // ErrCantGetCaller is raised when we can't calculate a caller of NewProjectLocation. + ErrCantGetCaller = errors.New("can't get caller") + + // ErrCallerNotAllowed is raised when user tries to use this shell-out package + // outside of allowed places. This package is deprecated from start and was + // introduced to allow rewriting of shell code to Golang in small chunks. + ErrCallerNotAllowed = errors.New("don't try use knative.dev/pkg/test/upgrade/shell package outside of allowed places") +) + +// NewProjectLocation creates a ProjectLocation that is used to calculate +// relative paths within the project. +func NewProjectLocation(pathToRoot string) (ProjectLocation, error) { + pc, filename, _, ok := runtime.Caller(1) + if !ok { + return nil, ErrCantGetCaller + } + funcName := runtime.FuncForPC(pc).Name() + err := isCallsiteAllowed(funcName) + if err != nil { + return nil, err + } + return &callerLocation{ + caller: filename, + pathToRoot: pathToRoot, + }, nil +} + +// RootPath return a path to root of the project. +func (c *callerLocation) RootPath() string { + return path.Join(path.Dir(c.caller), c.pathToRoot) +} + +// callerLocation holds a caller Go file, and a relative location to a project +// root directory. This information can be used to calculate relative paths and +// properly source shell scripts. +type callerLocation struct { + caller string + pathToRoot string +} + +func isCallsiteAllowed(funcName string) error { + validPaths := []string{ + "knative.+/test/upgrade", + "knative(:?\\.dev/|-)pkg/test/upgrade/shell", + } + for _, validPath := range validPaths { + r := regexp.MustCompile(validPath) + if loc := r.FindStringIndex(funcName); loc != nil { + return nil + } + } + return fmt.Errorf("%w, tried using from: %s", + ErrCallerNotAllowed, funcName) +} diff --git a/test/upgrade/shell/project_test.go b/test/upgrade/shell/project_test.go new file mode 100644 index 0000000000..93df1cf088 --- /dev/null +++ b/test/upgrade/shell/project_test.go @@ -0,0 +1,35 @@ +/* +Copyright 2020 The Knative 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 shell_test + +import ( + "os" + "path" + "testing" + + "knative.dev/pkg/test/upgrade/shell" +) + +func TestNewProjectLocation(t *testing.T) { + assert := assertions{t: t} + loc, err := shell.NewProjectLocation("../../..") + assert.NoError(err) + goModPath := path.Join(loc.RootPath(), "go.mod") + bytes, err := os.ReadFile(goModPath) + assert.NoError(err) + assert.Contains(string(bytes), "module knative.dev/pkg") +} diff --git a/test/upgrade/shell/types.go b/test/upgrade/shell/types.go new file mode 100644 index 0000000000..14bbbac05a --- /dev/null +++ b/test/upgrade/shell/types.go @@ -0,0 +1,97 @@ +/* +Copyright 2020 The Knative 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 shell + +import ( + "io" +) + +// Option overrides configuration options in ExecutorConfig. +type Option func(*ExecutorConfig) + +// ProjectLocation represents a project location on a file system. +type ProjectLocation interface { + RootPath() string +} + +// Script represents a script to be executed. +type Script struct { + Label string + ScriptPath string +} + +// Function represents a function, whom will be sourced from Script file, +// and executed. +type Function struct { + Script + FunctionName string +} + +// ExecutorConfig holds executor configuration options. +type ExecutorConfig struct { + ProjectLocation + Streams + Labels + Environ []string +} + +// TestingT is used by testingWriter and allows passing testing.T. +type TestingT interface { + Logf(format string, args ...any) +} + +// testingWriter implements io.Writer and writes to given testing.T log. +type testingWriter struct { + t TestingT +} + +// StreamType represets either output or error stream. +type StreamType int + +const ( + // StreamTypeOut represents process output stream. + StreamTypeOut StreamType = iota + // StreamTypeErr represents process error stream. + StreamTypeErr +) + +// PrefixFunc is used to build a prefix that will be added to each line of the +// script/function output or error stream. +type PrefixFunc func(st StreamType, label string, config ExecutorConfig) string + +// Labels holds a labels to be used to prefix Out and Err streams of executed +// shells scripts/functions. +type Labels struct { + LabelOut string + LabelErr string + SkipDate bool + DateFormat string + PrefixFunc +} + +// Streams holds a streams of a shell scripts/functions. +type Streams struct { + Out io.Writer + Err io.Writer +} + +// Executor represents a executor that can execute shell scripts and call +// functions directly. +type Executor interface { + RunScript(script Script, args ...string) error + RunFunction(fn Function, args ...string) error +}