diff --git a/hack/check-license.sh b/hack/check-license.sh index 210e4c76f0..34b7d33757 100755 --- a/hack/check-license.sh +++ b/hack/check-license.sh @@ -10,7 +10,7 @@ echo "Checking for license header..." allfiles=$(listFiles) licRes="" for file in $allfiles; do - if ! head -n3 "${file}" | grep -Eq "(Copyright|generated|GENERATED)" ; then + if ! head -n3 "${file}" | grep -Eq "(Copyright|generated|GENERATED|Licensed)" ; then licRes="${licRes}\n"$(echo -e " ${file}") fi done diff --git a/internal/kubebuilder/cmdutil/cmdutil.go b/internal/kubebuilder/cmdutil/cmdutil.go new file mode 100644 index 0000000000..602bc70742 --- /dev/null +++ b/internal/kubebuilder/cmdutil/cmdutil.go @@ -0,0 +1,56 @@ +/* +Copyright 2020 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 cmdutil + +import "sigs.k8s.io/kubebuilder/pkg/plugin/scaffold" + +// RunOptions represent the types used to implement the different commands +type RunOptions interface { + // - Step 1: verify that the command can be run (e.g., go version, project version, arguments, ...) + Validate() error + // - Step 2: create the Scaffolder instance + GetScaffolder() (scaffold.Scaffolder, error) + // - Step 3: call the Scaffold method of the Scaffolder instance. Doesn't need any method + // - Step 4: finish the command execution + PostScaffold() error +} + +// Run executes a command +func Run(options RunOptions) error { + // Step 1: validate + if err := options.Validate(); err != nil { + return err + } + + // Step 2: get scaffolder + scaffolder, err := options.GetScaffolder() + if err != nil { + return err + } + // Step 3: scaffold + if scaffolder != nil { + if err := scaffolder.Scaffold(); err != nil { + return err + } + } + // Step 4: finish + if err := options.PostScaffold(); err != nil { + return err + } + + return nil +} diff --git a/internal/kubebuilder/filesystem/errors.go b/internal/kubebuilder/filesystem/errors.go new file mode 100644 index 0000000000..7f605d3241 --- /dev/null +++ b/internal/kubebuilder/filesystem/errors.go @@ -0,0 +1,173 @@ +/* +Copyright 2020 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 filesystem + +import ( + "errors" + "fmt" +) + +// This file contains the errors returned by the file system wrapper +// They are not exported as they should not be created outside of this package +// Exported functions are provided to check which kind of error was returned + +// fileExistsError is returned if it could not be checked if the file exists +type fileExistsError struct { + path string + err error +} + +// Error implements error interface +func (e fileExistsError) Error() string { + return fmt.Sprintf("failed to check if %s exists: %v", e.path, e.err) +} + +// Unwrap implements Wrapper interface +func (e fileExistsError) Unwrap() error { + return e.err +} + +// IsFileExistsError checks if the returned error is because the file could not be checked for existence +func IsFileExistsError(err error) bool { + return errors.As(err, &fileExistsError{}) +} + +// openFileError is returned if the file could not be opened +type openFileError struct { + path string + err error +} + +// Error implements error interface +func (e openFileError) Error() string { + return fmt.Sprintf("failed to open %s: %v", e.path, e.err) +} + +// Unwrap implements Wrapper interface +func (e openFileError) Unwrap() error { + return e.err +} + +// IsOpenFileError checks if the returned error is because the file could not be opened +func IsOpenFileError(err error) bool { + return errors.As(err, &openFileError{}) +} + +// createDirectoryError is returned if the directory could not be created +type createDirectoryError struct { + path string + err error +} + +// Error implements error interface +func (e createDirectoryError) Error() string { + return fmt.Sprintf("failed to create directory for %s: %v", e.path, e.err) +} + +// Unwrap implements Wrapper interface +func (e createDirectoryError) Unwrap() error { + return e.err +} + +// IsCreateDirectoryError checks if the returned error is because the directory could not be created +func IsCreateDirectoryError(err error) bool { + return errors.As(err, &createDirectoryError{}) +} + +// createFileError is returned if the file could not be created +type createFileError struct { + path string + err error +} + +// Error implements error interface +func (e createFileError) Error() string { + return fmt.Sprintf("failed to create %s: %v", e.path, e.err) +} + +// Unwrap implements Wrapper interface +func (e createFileError) Unwrap() error { + return e.err +} + +// IsCreateFileError checks if the returned error is because the file could not be created +func IsCreateFileError(err error) bool { + return errors.As(err, &createFileError{}) +} + +// readFileError is returned if the file could not be read +type readFileError struct { + path string + err error +} + +// Error implements error interface +func (e readFileError) Error() string { + return fmt.Sprintf("failed to read from %s: %v", e.path, e.err) +} + +// Unwrap implements Wrapper interface +func (e readFileError) Unwrap() error { + return e.err +} + +// IsReadFileError checks if the returned error is because the file could not be read +func IsReadFileError(err error) bool { + return errors.As(err, &readFileError{}) +} + +// writeFileError is returned if the file could not be written +type writeFileError struct { + path string + err error +} + +// Error implements error interface +func (e writeFileError) Error() string { + return fmt.Sprintf("failed to write to %s: %v", e.path, e.err) +} + +// Unwrap implements Wrapper interface +func (e writeFileError) Unwrap() error { + return e.err +} + +// IsWriteFileError checks if the returned error is because the file could not be written to +func IsWriteFileError(err error) bool { + return errors.As(err, &writeFileError{}) +} + +// closeFileError is returned if the file could not be created +type closeFileError struct { + path string + err error +} + +// Error implements error interface +func (e closeFileError) Error() string { + return fmt.Sprintf("failed to close %s: %v", e.path, e.err) +} + +// Unwrap implements Wrapper interface +func (e closeFileError) Unwrap() error { + return e.err +} + +// IsCloseFileError checks if the returned error is because the file could not be closed +func IsCloseFileError(err error) bool { + return errors.As(err, &closeFileError{}) +} diff --git a/internal/kubebuilder/filesystem/errors_test.go b/internal/kubebuilder/filesystem/errors_test.go new file mode 100644 index 0000000000..cc3651a101 --- /dev/null +++ b/internal/kubebuilder/filesystem/errors_test.go @@ -0,0 +1,83 @@ +/* +Copyright 2020 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 filesystem + +import ( + "errors" + "path/filepath" + "testing" + + . "github.com/onsi/ginkgo" + . "github.com/onsi/ginkgo/extensions/table" + . "github.com/onsi/gomega" +) + +func TestErrors(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Error suite") +} + +var _ = Describe("Errors", func() { + var ( + path = filepath.Join("path", "to", "file") + err = errors.New("test error") + fileExistsErr = fileExistsError{path, err} + openFileErr = openFileError{path, err} + createDirectoryErr = createDirectoryError{path, err} + createFileErr = createFileError{path, err} + readFileErr = readFileError{path, err} + writeFileErr = writeFileError{path, err} + closeFileErr = closeFileError{path, err} + ) + + DescribeTable("IsXxxxError should return true for themselves and false for the rest", + func(f func(error) bool, itself error, rest ...error) { + Expect(f(itself)).To(BeTrue()) + for _, err := range rest { + Expect(f(err)).To(BeFalse()) + } + }, + Entry("file exists", IsFileExistsError, fileExistsErr, + openFileErr, createDirectoryErr, createFileErr, readFileErr, writeFileErr, closeFileErr), + Entry("open file", IsOpenFileError, openFileErr, + fileExistsErr, createDirectoryErr, createFileErr, readFileErr, writeFileErr, closeFileErr), + Entry("create directory", IsCreateDirectoryError, createDirectoryErr, + fileExistsErr, openFileErr, createFileErr, readFileErr, writeFileErr, closeFileErr), + Entry("create file", IsCreateFileError, createFileErr, + fileExistsErr, openFileErr, createDirectoryErr, readFileErr, writeFileErr, closeFileErr), + Entry("read file", IsReadFileError, readFileErr, + fileExistsErr, openFileErr, createDirectoryErr, createFileErr, writeFileErr, closeFileErr), + Entry("write file", IsWriteFileError, writeFileErr, + fileExistsErr, openFileErr, createDirectoryErr, createFileErr, readFileErr, closeFileErr), + Entry("close file", IsCloseFileError, closeFileErr, + fileExistsErr, openFileErr, createDirectoryErr, createFileErr, readFileErr, writeFileErr), + ) + + DescribeTable("should contain the wrapped error and error message", + func(err error) { + Expect(err).To(MatchError(err)) + Expect(err.Error()).To(ContainSubstring(err.Error())) + }, + Entry("file exists", fileExistsErr), + Entry("open file", openFileErr), + Entry("create directory", createDirectoryErr), + Entry("create file", createFileErr), + Entry("read file", readFileErr), + Entry("write file", writeFileErr), + Entry("close file", closeFileErr), + ) +}) diff --git a/internal/kubebuilder/filesystem/filesystem.go b/internal/kubebuilder/filesystem/filesystem.go new file mode 100644 index 0000000000..1c124a1ce1 --- /dev/null +++ b/internal/kubebuilder/filesystem/filesystem.go @@ -0,0 +1,181 @@ +/* +Copyright 2020 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 filesystem + +import ( + "io" + "os" + "path/filepath" + + "github.com/spf13/afero" +) + +const ( + createOrUpdate = os.O_WRONLY | os.O_CREATE | os.O_TRUNC + + defaultDirectoryPermission os.FileMode = 0755 + defaultFilePermission os.FileMode = 0644 +) + +// FileSystem is an IO wrapper to create files +type FileSystem interface { + // Exists checks if the file exists + Exists(path string) (bool, error) + + // Open opens the file and returns a self-closing io.Reader. + Open(path string) (io.ReadCloser, error) + + // Create creates the directory and file and returns a self-closing + // io.Writer pointing to that file. If the file exists, it truncates it. + Create(path string) (io.Writer, error) +} + +// fileSystem implements FileSystem +type fileSystem struct { + fs afero.Fs + dirPerm os.FileMode + filePerm os.FileMode + fileMode int +} + +// New returns a new FileSystem +func New(options ...Options) FileSystem { + // Default values + fs := fileSystem{ + fs: afero.NewOsFs(), + dirPerm: defaultDirectoryPermission, + filePerm: defaultFilePermission, + fileMode: createOrUpdate, + } + + // Apply options + for _, option := range options { + option(&fs) + } + + return fs +} + +// Options configure FileSystem +type Options func(system *fileSystem) + +// DirectoryPermissions makes FileSystem.Create use the provided directory +// permissions +func DirectoryPermissions(dirPerm os.FileMode) Options { + return func(fs *fileSystem) { + fs.dirPerm = dirPerm + } +} + +// FilePermissions makes FileSystem.Create use the provided file permissions +func FilePermissions(filePerm os.FileMode) Options { + return func(fs *fileSystem) { + fs.filePerm = filePerm + } +} + +// Exists implements FileSystem.Exists +func (fs fileSystem) Exists(path string) (bool, error) { + exists, err := afero.Exists(fs.fs, path) + if err != nil { + return exists, fileExistsError{path, err} + } + + return exists, nil +} + +// Open implements FileSystem.Open +func (fs fileSystem) Open(path string) (io.ReadCloser, error) { + rc, err := fs.fs.Open(path) + if err != nil { + return nil, openFileError{path, err} + } + + return &readFile{path, rc}, nil +} + +// Create implements FileSystem.Create +func (fs fileSystem) Create(path string) (io.Writer, error) { + // Create the directory if needed + if err := fs.fs.MkdirAll(filepath.Dir(path), fs.dirPerm); err != nil { + return nil, createDirectoryError{path, err} + } + + // Create or truncate the file + wc, err := fs.fs.OpenFile(path, fs.fileMode, fs.filePerm) + if err != nil { + return nil, createFileError{path, err} + } + + return &writeFile{path, wc}, nil +} + +var _ io.ReadCloser = &readFile{} + +// readFile implements io.Reader +type readFile struct { + path string + io.ReadCloser +} + +// Read implements io.Reader.ReadCloser +func (f *readFile) Read(content []byte) (n int, err error) { + // Read the content + n, err = f.ReadCloser.Read(content) + // EOF is a special case error that we can't wrap + if err == io.EOF { + return + } + if err != nil { + return n, readFileError{f.path, err} + } + + return n, nil +} + +// Close implements io.Reader.ReadCloser +func (f *readFile) Close() error { + if err := f.ReadCloser.Close(); err != nil { + return closeFileError{f.path, err} + } + + return nil +} + +// writeFile implements io.Writer +type writeFile struct { + path string + io.WriteCloser +} + +// Write implements io.Writer.Write +func (f *writeFile) Write(content []byte) (n int, err error) { + // Close the file when we end writing + defer func() { + if closeErr := f.Close(); err == nil && closeErr != nil { + err = closeFileError{f.path, err} + } + }() + + // Write the content + n, err = f.WriteCloser.Write(content) + if err != nil { + return n, writeFileError{f.path, err} + } + + return n, nil +} diff --git a/internal/kubebuilder/filesystem/filesystem_test.go b/internal/kubebuilder/filesystem/filesystem_test.go new file mode 100644 index 0000000000..9f98ce400a --- /dev/null +++ b/internal/kubebuilder/filesystem/filesystem_test.go @@ -0,0 +1,156 @@ +/* +Copyright 2020 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 filesystem + +import ( + "os" + "testing" + + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" +) + +func TestFileSystem(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "FileSystem suite") +} + +var _ = Describe("FileSystem", func() { + Describe("New", func() { + const ( + dirPerm os.FileMode = 0777 + filePerm os.FileMode = 0666 + ) + + var ( + fsi FileSystem + fs fileSystem + ok bool + ) + + Context("when using no options", func() { + BeforeEach(func() { + fsi = New() + fs, ok = fsi.(fileSystem) + }) + + It("should be a fileSystem instance", func() { + Expect(ok).To(BeTrue()) + }) + + It("should not have a nil fs", func() { + Expect(fs.fs).NotTo(BeNil()) + }) + + It("should use default directory permission", func() { + Expect(fs.dirPerm).To(Equal(defaultDirectoryPermission)) + }) + + It("should use default file permission", func() { + Expect(fs.filePerm).To(Equal(defaultFilePermission)) + }) + + It("should use default file mode", func() { + Expect(fs.fileMode).To(Equal(createOrUpdate)) + }) + }) + + Context("when using directory permission option", func() { + BeforeEach(func() { + fsi = New(DirectoryPermissions(dirPerm)) + fs, ok = fsi.(fileSystem) + }) + + It("should be a fileSystem instance", func() { + Expect(ok).To(BeTrue()) + }) + + It("should not have a nil fs", func() { + Expect(fs.fs).NotTo(BeNil()) + }) + + It("should use provided directory permission", func() { + Expect(fs.dirPerm).To(Equal(dirPerm)) + }) + + It("should use default file permission", func() { + Expect(fs.filePerm).To(Equal(defaultFilePermission)) + }) + + It("should use default file mode", func() { + Expect(fs.fileMode).To(Equal(createOrUpdate)) + }) + }) + + Context("when using file permission option", func() { + BeforeEach(func() { + fsi = New(FilePermissions(filePerm)) + fs, ok = fsi.(fileSystem) + }) + + It("should be a fileSystem instance", func() { + Expect(ok).To(BeTrue()) + }) + + It("should not have a nil fs", func() { + Expect(fs.fs).NotTo(BeNil()) + }) + + It("should use default directory permission", func() { + Expect(fs.dirPerm).To(Equal(defaultDirectoryPermission)) + }) + + It("should use provided file permission", func() { + Expect(fs.filePerm).To(Equal(filePerm)) + }) + + It("should use default file mode", func() { + Expect(fs.fileMode).To(Equal(createOrUpdate)) + }) + }) + + Context("when using both directory and file permission options", func() { + BeforeEach(func() { + fsi = New(DirectoryPermissions(dirPerm), FilePermissions(filePerm)) + fs, ok = fsi.(fileSystem) + }) + + It("should be a fileSystem instance", func() { + Expect(ok).To(BeTrue()) + }) + + It("should not have a nil fs", func() { + Expect(fs.fs).NotTo(BeNil()) + }) + + It("should use provided directory permission", func() { + Expect(fs.dirPerm).To(Equal(dirPerm)) + }) + + It("should use provided file permission", func() { + Expect(fs.filePerm).To(Equal(filePerm)) + }) + + It("should use default file mode", func() { + Expect(fs.fileMode).To(Equal(createOrUpdate)) + }) + }) + }) + + // NOTE: FileSystem.Exists, FileSystem.Open, FileSystem.Open().Read, FileSystem.Create and FileSystem.Create().Write + // are hard to test in unitary tests as they deal with actual files +}) diff --git a/internal/kubebuilder/filesystem/mock.go b/internal/kubebuilder/filesystem/mock.go new file mode 100644 index 0000000000..b7d213c1fc --- /dev/null +++ b/internal/kubebuilder/filesystem/mock.go @@ -0,0 +1,217 @@ +/* +Copyright 2020 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 filesystem + +import ( + "bytes" + "io" +) + +// mockFileSystem implements FileSystem +type mockFileSystem struct { + path string + exists func(path string) bool + existsError error + openFileError error + createDirError error + createFileError error + input *bytes.Buffer + readFileError error + output *bytes.Buffer + writeFileError error + closeFileError error +} + +// NewMock returns a new FileSystem +func NewMock(options ...MockOptions) FileSystem { + // Default values + fs := mockFileSystem{ + exists: func(_ string) bool { return false }, + output: new(bytes.Buffer), + } + + // Apply options + for _, option := range options { + option(&fs) + } + + return fs +} + +// MockOptions configure FileSystem +type MockOptions func(system *mockFileSystem) + +// MockPath ensures that the file created with this scaffold is at path +func MockPath(path string) MockOptions { + return func(fs *mockFileSystem) { + fs.path = path + } +} + +// MockExists makes FileSystem.Exists use the provided function to check if the file exists +func MockExists(exists func(path string) bool) MockOptions { + return func(fs *mockFileSystem) { + fs.exists = exists + } +} + +// MockExistsError makes FileSystem.Exists return err +func MockExistsError(err error) MockOptions { + return func(fs *mockFileSystem) { + fs.existsError = err + } +} + +// MockOpenFileError makes FileSystem.Open return err +func MockOpenFileError(err error) MockOptions { + return func(fs *mockFileSystem) { + fs.openFileError = err + } +} + +// MockCreateDirError makes FileSystem.Create return err +func MockCreateDirError(err error) MockOptions { + return func(fs *mockFileSystem) { + fs.createDirError = err + } +} + +// MockCreateFileError makes FileSystem.Create return err +func MockCreateFileError(err error) MockOptions { + return func(fs *mockFileSystem) { + fs.createFileError = err + } +} + +// MockInput provides a buffer where the content will be read from +func MockInput(input *bytes.Buffer) MockOptions { + return func(fs *mockFileSystem) { + fs.input = input + } +} + +// MockReadFileError makes the Read method (of the io.Reader returned by FileSystem.Open) return err +func MockReadFileError(err error) MockOptions { + return func(fs *mockFileSystem) { + fs.readFileError = err + } +} + +// MockOutput provides a buffer where the content will be written +func MockOutput(output *bytes.Buffer) MockOptions { + return func(fs *mockFileSystem) { + fs.output = output + } +} + +// MockWriteFileError makes the Write method (of the io.Writer returned by FileSystem.Create) return err +func MockWriteFileError(err error) MockOptions { + return func(fs *mockFileSystem) { + fs.writeFileError = err + } +} + +// MockCloseFileError makes the Write method (of the io.Writer returned by FileSystem.Create) return err +func MockCloseFileError(err error) MockOptions { + return func(fs *mockFileSystem) { + fs.closeFileError = err + } +} + +// Exists implements FileSystem.Exists +func (fs mockFileSystem) Exists(path string) (bool, error) { + if fs.existsError != nil { + return false, fileExistsError{path, fs.existsError} + } + + return fs.exists(path), nil +} + +// Open implements FileSystem.Open +func (fs mockFileSystem) Open(path string) (io.ReadCloser, error) { + if fs.openFileError != nil { + return nil, openFileError{path, fs.openFileError} + } + + if fs.input == nil { + fs.input = bytes.NewBufferString("Hello world!") + } + + return &mockReadFile{path, fs.input, fs.readFileError, fs.closeFileError}, nil +} + +// Create implements FileSystem.Create +func (fs mockFileSystem) Create(path string) (io.Writer, error) { + if fs.createDirError != nil { + return nil, createDirectoryError{path, fs.createDirError} + } + + if fs.createFileError != nil { + return nil, createFileError{path, fs.createFileError} + } + + return &mockWriteFile{path, fs.output, fs.writeFileError, fs.closeFileError}, nil +} + +// mockReadFile implements io.Reader mocking a readFile for tests +type mockReadFile struct { + path string + input *bytes.Buffer + readFileError error + closeFileError error +} + +// Read implements io.Reader.ReadCloser +func (f *mockReadFile) Read(content []byte) (n int, err error) { + if f.readFileError != nil { + return 0, readFileError{path: f.path, err: f.readFileError} + } + + return f.input.Read(content) +} + +// Read implements io.Reader.ReadCloser +func (f *mockReadFile) Close() error { + if f.closeFileError != nil { + return closeFileError{path: f.path, err: f.closeFileError} + } + + return nil +} + +// mockWriteFile implements io.Writer mocking a writeFile for tests +type mockWriteFile struct { + path string + content *bytes.Buffer + writeFileError error + closeFileError error +} + +// Write implements io.Writer.Write +func (f *mockWriteFile) Write(content []byte) (n int, err error) { + defer func() { + if err == nil && f.closeFileError != nil { + err = closeFileError{f.path, f.closeFileError} + } + }() + + if f.writeFileError != nil { + return 0, writeFileError{f.path, f.writeFileError} + } + + return f.content.Write(content) +} diff --git a/internal/kubebuilder/filesystem/mock_test.go b/internal/kubebuilder/filesystem/mock_test.go new file mode 100644 index 0000000000..37f9e827cc --- /dev/null +++ b/internal/kubebuilder/filesystem/mock_test.go @@ -0,0 +1,454 @@ +/* +Copyright 2020 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 filesystem + +import ( + "bytes" + "errors" + "path/filepath" + "testing" + + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" +) + +func TestMockFileSystem(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "MockFileSystem suite") +} + +//nolint:dupl +var _ = Describe("MockFileSystem", func() { + var ( + fsi FileSystem + fs mockFileSystem + ok bool + options []MockOptions + testErr = errors.New("test error") + ) + + JustBeforeEach(func() { + fsi = NewMock(options...) + fs, ok = fsi.(mockFileSystem) + }) + + Context("when using no options", func() { + BeforeEach(func() { + options = make([]MockOptions, 0) + }) + + It("should be a mockFileSystem instance", func() { + Expect(ok).To(BeTrue()) + }) + + It("should claim that files don't exist", func() { + exists, err := fsi.Exists("") + Expect(err).NotTo(HaveOccurred()) + Expect(exists).To(BeFalse()) + }) + + It("should open readable files", func() { + f, err := fsi.Open("") + Expect(err).NotTo(HaveOccurred()) + + _, err = f.Read([]byte("")) + Expect(err).NotTo(HaveOccurred()) + }) + + It("should create writable files", func() { + f, err := fsi.Create("") + Expect(err).NotTo(HaveOccurred()) + + _, err = f.Write([]byte("")) + Expect(err).NotTo(HaveOccurred()) + }) + }) + + Context("when using MockPath", func() { + var filePath = filepath.Join("path", "to", "file") + + BeforeEach(func() { + options = []MockOptions{MockPath(filePath)} + }) + + It("should be a mockFileSystem instance", func() { + Expect(ok).To(BeTrue()) + }) + + It("should claim that files don't exist", func() { + exists, err := fsi.Exists("") + Expect(err).NotTo(HaveOccurred()) + Expect(exists).To(BeFalse()) + }) + + It("should open readable files", func() { + f, err := fsi.Open("") + Expect(err).NotTo(HaveOccurred()) + + _, err = f.Read([]byte("")) + Expect(err).NotTo(HaveOccurred()) + }) + + It("should create writable files", func() { + f, err := fsi.Create("") + Expect(err).NotTo(HaveOccurred()) + + _, err = f.Write([]byte("")) + Expect(err).NotTo(HaveOccurred()) + }) + + It("should save the provided path", func() { + Expect(fs.path).To(Equal(filePath)) + }) + }) + + Context("when using MockExists", func() { + BeforeEach(func() { + options = []MockOptions{MockExists(func(_ string) bool { return true })} + }) + + It("should be a mockFileSystem instance", func() { + Expect(ok).To(BeTrue()) + }) + + It("should claim that files exist", func() { + exists, err := fsi.Exists("") + Expect(err).NotTo(HaveOccurred()) + Expect(exists).To(BeTrue()) + }) + + It("should open readable files", func() { + f, err := fsi.Open("") + Expect(err).NotTo(HaveOccurred()) + + _, err = f.Read([]byte("")) + Expect(err).NotTo(HaveOccurred()) + }) + + It("should create writable files", func() { + f, err := fsi.Create("") + Expect(err).NotTo(HaveOccurred()) + + _, err = f.Write([]byte("")) + Expect(err).NotTo(HaveOccurred()) + }) + }) + + Context("when using MockExistsError", func() { + BeforeEach(func() { + options = []MockOptions{MockExistsError(testErr)} + }) + + It("should be a mockFileSystem instance", func() { + Expect(ok).To(BeTrue()) + }) + + It("should error when calling Exists", func() { + _, err := fsi.Exists("") + Expect(err).To(MatchError(testErr)) + Expect(IsFileExistsError(err)).To(BeTrue()) + }) + + It("should open readable files", func() { + f, err := fsi.Open("") + Expect(err).NotTo(HaveOccurred()) + + _, err = f.Read([]byte("")) + Expect(err).NotTo(HaveOccurred()) + }) + + It("should create writable files", func() { + f, err := fsi.Create("") + Expect(err).NotTo(HaveOccurred()) + + _, err = f.Write([]byte("")) + Expect(err).NotTo(HaveOccurred()) + }) + }) + + Context("when using MockOpenFileError", func() { + BeforeEach(func() { + options = []MockOptions{MockOpenFileError(testErr)} + }) + + It("should be a mockFileSystem instance", func() { + Expect(ok).To(BeTrue()) + }) + + It("should claim that files don't exist", func() { + exists, err := fsi.Exists("") + Expect(err).NotTo(HaveOccurred()) + Expect(exists).To(BeFalse()) + }) + + It("should error when calling Open", func() { + _, err := fsi.Open("") + Expect(err).To(MatchError(testErr)) + Expect(IsOpenFileError(err)).To(BeTrue()) + }) + + It("should create writable files", func() { + f, err := fsi.Create("") + Expect(err).NotTo(HaveOccurred()) + + _, err = f.Write([]byte("")) + Expect(err).NotTo(HaveOccurred()) + }) + }) + + Context("when using MockCreateDirError", func() { + BeforeEach(func() { + options = []MockOptions{MockCreateDirError(testErr)} + }) + + It("should be a mockFileSystem instance", func() { + Expect(ok).To(BeTrue()) + }) + + It("should claim that files don't exist", func() { + exists, err := fsi.Exists("") + Expect(err).NotTo(HaveOccurred()) + Expect(exists).To(BeFalse()) + }) + + It("should open readable files", func() { + f, err := fsi.Open("") + Expect(err).NotTo(HaveOccurred()) + + _, err = f.Read([]byte("")) + Expect(err).NotTo(HaveOccurred()) + }) + + It("should error when calling Create", func() { + _, err := fsi.Create("") + Expect(err).To(MatchError(testErr)) + Expect(IsCreateDirectoryError(err)).To(BeTrue()) + }) + }) + + Context("when using MockCreateFileError", func() { + BeforeEach(func() { + options = []MockOptions{MockCreateFileError(testErr)} + }) + + It("should be a mockFileSystem instance", func() { + Expect(ok).To(BeTrue()) + }) + + It("should claim that files don't exist", func() { + exists, err := fsi.Exists("") + Expect(err).NotTo(HaveOccurred()) + Expect(exists).To(BeFalse()) + }) + + It("should open readable files", func() { + f, err := fsi.Open("") + Expect(err).NotTo(HaveOccurred()) + + _, err = f.Read([]byte("")) + Expect(err).NotTo(HaveOccurred()) + }) + + It("should error when calling Create", func() { + _, err := fsi.Create("") + Expect(err).To(MatchError(testErr)) + Expect(IsCreateFileError(err)).To(BeTrue()) + }) + }) + + Context("when using MockInput", func() { + var ( + input *bytes.Buffer + fileContent = []byte("Hello world!") + ) + + BeforeEach(func() { + input = bytes.NewBufferString("Hello world!") + options = []MockOptions{MockInput(input)} + }) + + It("should be a mockFileSystem instance", func() { + Expect(ok).To(BeTrue()) + }) + + It("should claim that files don't exist", func() { + exists, err := fsi.Exists("") + Expect(err).NotTo(HaveOccurred()) + Expect(exists).To(BeFalse()) + }) + + It("should open readable files and the content to be accessible", func() { + f, err := fsi.Open("") + Expect(err).NotTo(HaveOccurred()) + + output := make([]byte, len(fileContent)) + n, err := f.Read(output) + Expect(err).NotTo(HaveOccurred()) + Expect(n).To(Equal(len(fileContent))) + Expect(output).To(Equal(fileContent)) + }) + + It("should create writable files", func() { + f, err := fsi.Create("") + Expect(err).NotTo(HaveOccurred()) + + _, err = f.Write([]byte("")) + Expect(err).NotTo(HaveOccurred()) + }) + }) + + Context("when using MockReadFileError", func() { + BeforeEach(func() { + options = []MockOptions{MockReadFileError(testErr)} + }) + + It("should be a mockFileSystem instance", func() { + Expect(ok).To(BeTrue()) + }) + + It("should claim that files don't exist", func() { + exists, err := fsi.Exists("") + Expect(err).NotTo(HaveOccurred()) + Expect(exists).To(BeFalse()) + }) + + It("should error when calling Open().Read", func() { + f, err := fsi.Open("") + Expect(err).NotTo(HaveOccurred()) + + output := make([]byte, 0) + _, err = f.Read(output) + Expect(err).To(MatchError(testErr)) + Expect(IsReadFileError(err)).To(BeTrue()) + }) + + It("should create writable files", func() { + f, err := fsi.Create("") + Expect(err).NotTo(HaveOccurred()) + + _, err = f.Write([]byte("")) + Expect(err).NotTo(HaveOccurred()) + }) + }) + + Context("when using MockOutput", func() { + var ( + output bytes.Buffer + fileContent = []byte("Hello world!") + ) + + BeforeEach(func() { + options = []MockOptions{MockOutput(&output)} + output.Reset() + }) + + It("should be a mockFileSystem instance", func() { + Expect(ok).To(BeTrue()) + }) + + It("should claim that files don't exist", func() { + exists, err := fsi.Exists("") + Expect(err).NotTo(HaveOccurred()) + Expect(exists).To(BeFalse()) + }) + + It("should open readable files", func() { + f, err := fsi.Open("") + Expect(err).NotTo(HaveOccurred()) + + _, err = f.Read([]byte("")) + Expect(err).NotTo(HaveOccurred()) + }) + + It("should create writable files and the content should be accesible", func() { + f, err := fsi.Create("") + Expect(err).NotTo(HaveOccurred()) + + n, err := f.Write(fileContent) + Expect(err).NotTo(HaveOccurred()) + Expect(n).To(Equal(len(fileContent))) + Expect(output.Bytes()).To(Equal(fileContent)) + }) + }) + + Context("when using MockWriteFileError", func() { + BeforeEach(func() { + options = []MockOptions{MockWriteFileError(testErr)} + }) + + It("should be a mockFileSystem instance", func() { + Expect(ok).To(BeTrue()) + }) + + It("should claim that files don't exist", func() { + exists, err := fsi.Exists("") + Expect(err).NotTo(HaveOccurred()) + Expect(exists).To(BeFalse()) + }) + + It("should open readable files", func() { + f, err := fsi.Open("") + Expect(err).NotTo(HaveOccurred()) + + _, err = f.Read([]byte("")) + Expect(err).NotTo(HaveOccurred()) + }) + + It("should error when calling Create().Write", func() { + f, err := fsi.Create("") + Expect(err).NotTo(HaveOccurred()) + + _, err = f.Write([]byte("")) + Expect(err).To(MatchError(testErr)) + Expect(IsWriteFileError(err)).To(BeTrue()) + }) + }) + + Context("when using MockCloseFileError", func() { + BeforeEach(func() { + options = []MockOptions{MockCloseFileError(testErr)} + }) + + It("should be a mockFileSystem instance", func() { + Expect(ok).To(BeTrue()) + }) + + It("should claim that files don't exist", func() { + exists, err := fsi.Exists("") + Expect(err).NotTo(HaveOccurred()) + Expect(exists).To(BeFalse()) + }) + + It("should error when calling Open().Close", func() { + f, err := fsi.Open("") + Expect(err).NotTo(HaveOccurred()) + + err = f.Close() + Expect(err).To(MatchError(testErr)) + Expect(IsCloseFileError(err)).To(BeTrue()) + }) + + It("should error when calling Create().Write", func() { + f, err := fsi.Create("") + Expect(err).NotTo(HaveOccurred()) + + _, err = f.Write([]byte("")) + Expect(err).To(MatchError(testErr)) + Expect(IsCloseFileError(err)).To(BeTrue()) + }) + }) +}) diff --git a/internal/kubebuilder/machinery/errors.go b/internal/kubebuilder/machinery/errors.go new file mode 100644 index 0000000000..aef82aeb56 --- /dev/null +++ b/internal/kubebuilder/machinery/errors.go @@ -0,0 +1,74 @@ +/* +Copyright 2020 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 machinery + +import ( + "errors" + "fmt" + + "sigs.k8s.io/kubebuilder/pkg/model/file" +) + +// This file contains the errors returned by the scaffolding machinery +// They are not exported as they should not be created outside of this package +// Exported functions are provided to check which kind of error was returned + +// fileAlreadyExistsError is returned if the file is expected not to exist but it does +type fileAlreadyExistsError struct { + path string +} + +// Error implements error interface +func (e fileAlreadyExistsError) Error() string { + return fmt.Sprintf("failed to create %s: file already exists", e.path) +} + +// IsFileAlreadyExistsError checks if the returned error is because the file already existed when expected not to +func IsFileAlreadyExistsError(err error) bool { + return errors.As(err, &fileAlreadyExistsError{}) +} + +// modelAlreadyExistsError is returned if the file is expected not to exist but a previous model does +type modelAlreadyExistsError struct { + path string +} + +// Error implements error interface +func (e modelAlreadyExistsError) Error() string { + return fmt.Sprintf("failed to create %s: model already exists", e.path) +} + +// IsModelAlreadyExistsError checks if the returned error is because the model already existed when expected not to +func IsModelAlreadyExistsError(err error) bool { + return errors.As(err, &modelAlreadyExistsError{}) +} + +// unknownIfExistsActionError is returned if the if-exists-action is unknown +type unknownIfExistsActionError struct { + path string + ifExistsAction file.IfExistsAction +} + +// Error implements error interface +func (e unknownIfExistsActionError) Error() string { + return fmt.Sprintf("unknown behavior if file exists (%d) for %s", e.ifExistsAction, e.path) +} + +// IsUnknownIfExistsActionError checks if the returned error is because the if-exists-action is unknown +func IsUnknownIfExistsActionError(err error) bool { + return errors.As(err, &unknownIfExistsActionError{}) +} diff --git a/internal/kubebuilder/machinery/errors_test.go b/internal/kubebuilder/machinery/errors_test.go new file mode 100644 index 0000000000..adcf32b4f7 --- /dev/null +++ b/internal/kubebuilder/machinery/errors_test.go @@ -0,0 +1,71 @@ +/* +Copyright 2020 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 machinery + +import ( + "errors" + "path/filepath" + "testing" + + . "github.com/onsi/ginkgo" + . "github.com/onsi/ginkgo/extensions/table" + . "github.com/onsi/gomega" +) + +func TestErrors(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Error suite") +} + +var _ = Describe("Errors", func() { + var ( + path = filepath.Join("path", "to", "file") + err = errors.New("test error") + fileAlreadyExistsErr = fileAlreadyExistsError{path} + modelAlreadyExistsErr = modelAlreadyExistsError{path} + unknownIfExistsActionErr = unknownIfExistsActionError{path, -1} + ) + + DescribeTable("IsXxxxError should return true for themselves and false for the rest", + func(f func(error) bool, itself error, rest ...error) { + Expect(f(itself)).To(BeTrue()) + for _, err := range rest { + Expect(f(err)).To(BeFalse()) + } + }, + Entry("file exists", IsFileAlreadyExistsError, fileAlreadyExistsErr, + err, modelAlreadyExistsErr, unknownIfExistsActionErr), + Entry("model exists", IsModelAlreadyExistsError, modelAlreadyExistsErr, + err, fileAlreadyExistsErr, unknownIfExistsActionErr), + Entry("unknown if exists action", IsUnknownIfExistsActionError, unknownIfExistsActionErr, + err, fileAlreadyExistsErr, modelAlreadyExistsErr), + ) + + DescribeTable("should contain the wrapped error and error message", + func(err error) { + Expect(err).To(MatchError(err)) + Expect(err.Error()).To(ContainSubstring(err.Error())) + }, + ) + + // NOTE: the following test increases coverage + It("should print a descriptive error message", func() { + Expect(fileAlreadyExistsErr.Error()).To(ContainSubstring("file already exists")) + Expect(modelAlreadyExistsErr.Error()).To(ContainSubstring("model already exists")) + Expect(unknownIfExistsActionErr.Error()).To(ContainSubstring("unknown behavior if file exists")) + }) +}) diff --git a/internal/kubebuilder/machinery/scaffold.go b/internal/kubebuilder/machinery/scaffold.go new file mode 100644 index 0000000000..632740b073 --- /dev/null +++ b/internal/kubebuilder/machinery/scaffold.go @@ -0,0 +1,389 @@ +/* +Copyright 2018 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 machinery + +import ( + "bufio" + "bytes" + "fmt" + "io/ioutil" + "path/filepath" + "strings" + "text/template" + + "golang.org/x/tools/imports" + "sigs.k8s.io/kubebuilder/pkg/model" + "sigs.k8s.io/kubebuilder/pkg/model/file" + + "github.com/operator-framework/operator-sdk/internal/kubebuilder/filesystem" +) + +var options = imports.Options{ + Comments: true, + TabIndent: true, + TabWidth: 8, + FormatOnly: true, +} + +// Scaffold uses templates to scaffold new files +type Scaffold interface { + // Execute writes to disk the provided files + Execute(*model.Universe, ...file.Builder) error +} + +// scaffold implements Scaffold interface +type scaffold struct { + // plugins is the list of plugins we should allow to transform our generated scaffolding + plugins []model.Plugin + + // fs allows to mock the file system for tests + fs filesystem.FileSystem +} + +// NewScaffold returns a new Scaffold with the provided plugins +func NewScaffold(plugins ...model.Plugin) Scaffold { + return &scaffold{ + plugins: plugins, + fs: filesystem.New(), + } +} + +// Execute implements Scaffold.Execute +func (s *scaffold) Execute(universe *model.Universe, files ...file.Builder) error { + // Initialize the universe files + universe.Files = make(map[string]*file.File, len(files)) + + // Set the repo as the local prefix so that it knows how to group imports + if universe.Config != nil { + imports.LocalPrefix = universe.Config.Repo + } + + for _, f := range files { + // Inject common fields + universe.InjectInto(f) + + // Validate file builders + if reqValFile, requiresValidation := f.(file.RequiresValidation); requiresValidation { + if err := reqValFile.Validate(); err != nil { + return file.NewValidateError(err) + } + } + + // Build models for Template builders + if t, isTemplate := f.(file.Template); isTemplate { + if err := s.buildFileModel(t, universe.Files); err != nil { + return err + } + } + + // Build models for Inserter builders + if i, isInserter := f.(file.Inserter); isInserter { + if err := s.updateFileModel(i, universe.Files); err != nil { + return err + } + } + } + + // Execute plugins + for _, plugin := range s.plugins { + if err := plugin.Pipe(universe); err != nil { + return model.NewPluginError(err) + } + } + + // Persist the files to disk + for _, f := range universe.Files { + if err := s.writeFile(f); err != nil { + return err + } + } + + return nil +} + +// buildFileModel scaffolds a single file +func (scaffold) buildFileModel(t file.Template, models map[string]*file.File) error { + // Set the template default values + err := t.SetTemplateDefaults() + if err != nil { + return file.NewSetTemplateDefaultsError(err) + } + + // Handle already existing models + if _, found := models[t.GetPath()]; found { + switch t.GetIfExistsAction() { + case file.Skip: + return nil + case file.Error: + return modelAlreadyExistsError{t.GetPath()} + case file.Overwrite: + default: + return unknownIfExistsActionError{t.GetPath(), t.GetIfExistsAction()} + } + } + + m := &file.File{ + Path: t.GetPath(), + IfExistsAction: t.GetIfExistsAction(), + } + + b, err := doTemplate(t) + if err != nil { + return err + } + m.Contents = string(b) + + models[m.Path] = m + return nil +} + +// doTemplate executes the template for a file using the input +func doTemplate(t file.Template) ([]byte, error) { + temp, err := newTemplate(t).Parse(t.GetBody()) + if err != nil { + return nil, err + } + + out := &bytes.Buffer{} + err = temp.Execute(out, t) + if err != nil { + return nil, err + } + b := out.Bytes() + + // TODO(adirio): move go-formatting to write step + // gofmt the imports + if filepath.Ext(t.GetPath()) == ".go" { + b, err = imports.Process(t.GetPath(), b, &options) + if err != nil { + return nil, err + } + } + + return b, nil +} + +// newTemplate a new template with common functions +func newTemplate(t file.Template) *template.Template { + fm := file.DefaultFuncMap() + useFM, ok := t.(file.UseCustomFuncMap) + if ok { + fm = useFM.GetFuncMap() + } + return template.New(fmt.Sprintf("%T", t)).Funcs(fm) +} + +// updateFileModel updates a single file +func (s scaffold) updateFileModel(i file.Inserter, models map[string]*file.File) error { + m, err := s.loadPreviousModel(i, models) + if err != nil { + return err + } + + // Get valid code fragments + codeFragments := getValidCodeFragments(i) + + // Remove code fragments that already were applied + err = filterExistingValues(m.Contents, codeFragments) + if err != nil { + return err + } + + // If no code fragment to insert, we are done + if len(codeFragments) == 0 { + return nil + } + + content, err := insertStrings(m.Contents, codeFragments) + if err != nil { + return err + } + + // TODO(adirio): move go-formatting to write step + formattedContent := content + if ext := filepath.Ext(i.GetPath()); ext == ".go" { + formattedContent, err = imports.Process(i.GetPath(), content, nil) + if err != nil { + return err + } + } + + m.Contents = string(formattedContent) + m.IfExistsAction = file.Overwrite + models[m.Path] = m + return nil +} + +// loadPreviousModel gets the previous model from the models map or the actual file +func (s scaffold) loadPreviousModel(i file.Inserter, models map[string]*file.File) (*file.File, error) { + // Lets see if we already have a model for this file + if m, found := models[i.GetPath()]; found { + // Check if there is already an scaffolded file + exists, err := s.fs.Exists(i.GetPath()) + if err != nil { + return nil, err + } + + // If there is a model but no scaffolded file we return the model + if !exists { + return m, nil + } + + // If both a model and a file are found, check which has preference + switch m.IfExistsAction { + case file.Skip: + // File has preference + fromFile, err := s.loadModelFromFile(i.GetPath()) + if err != nil { + return m, nil + } + return fromFile, nil + case file.Error: + // Writing will result in an error, so we can return error now + return nil, fileAlreadyExistsError{i.GetPath()} + case file.Overwrite: + // Model has preference + return m, nil + default: + return nil, unknownIfExistsActionError{i.GetPath(), m.IfExistsAction} + } + } + + // There was no model + return s.loadModelFromFile(i.GetPath()) +} + +// loadModelFromFile gets the previous model from the actual file +func (s scaffold) loadModelFromFile(path string) (f *file.File, err error) { + reader, err := s.fs.Open(path) + if err != nil { + return + } + defer func() { + closeErr := reader.Close() + if err == nil { + err = closeErr + } + }() + + content, err := ioutil.ReadAll(reader) + if err != nil { + return + } + + f = &file.File{Path: path, Contents: string(content)} + return +} + +// getValidCodeFragments obtains the code fragments from a file.Inserter +func getValidCodeFragments(i file.Inserter) file.CodeFragmentsMap { + // Get the code fragments + codeFragments := i.GetCodeFragments() + + // Validate the code fragments + validMarkers := i.GetMarkers() + for marker := range codeFragments { + valid := false + for _, validMarker := range validMarkers { + if marker == validMarker { + valid = true + break + } + } + if !valid { + delete(codeFragments, marker) + } + } + + return codeFragments +} + +// filterExistingValues removes the single-line values that already exists +// TODO: Add support for multi-line duplicate values +func filterExistingValues(content string, codeFragmentsMap file.CodeFragmentsMap) error { + scanner := bufio.NewScanner(strings.NewReader(content)) + for scanner.Scan() { + line := scanner.Text() + for marker, codeFragments := range codeFragmentsMap { + for i, codeFragment := range codeFragments { + if strings.TrimSpace(line) == strings.TrimSpace(codeFragment) { + codeFragmentsMap[marker] = append(codeFragments[:i], codeFragments[i+1:]...) + } + } + if len(codeFragmentsMap[marker]) == 0 { + delete(codeFragmentsMap, marker) + } + } + } + if err := scanner.Err(); err != nil { + return err + } + return nil +} + +func insertStrings(content string, codeFragmentsMap file.CodeFragmentsMap) ([]byte, error) { + out := new(bytes.Buffer) + + scanner := bufio.NewScanner(strings.NewReader(content)) + for scanner.Scan() { + line := scanner.Text() + + for marker, codeFragments := range codeFragmentsMap { + if strings.TrimSpace(line) == strings.TrimSpace(marker.String()) { + for _, codeFragment := range codeFragments { + _, _ = out.WriteString(codeFragment) // bytes.Buffer.WriteString always returns nil errors + } + } + } + + _, _ = out.WriteString(line + "\n") // bytes.Buffer.WriteString always returns nil errors + } + if err := scanner.Err(); err != nil { + return nil, err + } + + return out.Bytes(), nil +} + +func (s scaffold) writeFile(f *file.File) error { + // Check if the file to write already exists + exists, err := s.fs.Exists(f.Path) + if err != nil { + return err + } + if exists { + switch f.IfExistsAction { + case file.Overwrite: + // By not returning, the file is written as if it didn't exist + case file.Skip: + // By returning nil, the file is not written but the process will carry on + return nil + case file.Error: + // By returning an error, the file is not written and the process will fail + return fileAlreadyExistsError{f.Path} + } + } + + writer, err := s.fs.Create(f.Path) + if err != nil { + return err + } + + _, err = writer.Write([]byte(f.Contents)) + + return err +} diff --git a/internal/kubebuilder/machinery/scaffold_test.go b/internal/kubebuilder/machinery/scaffold_test.go new file mode 100644 index 0000000000..e711291897 --- /dev/null +++ b/internal/kubebuilder/machinery/scaffold_test.go @@ -0,0 +1,560 @@ +/* +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 machinery + +import ( + "bytes" + "errors" + "testing" + + . "github.com/onsi/ginkgo" + . "github.com/onsi/ginkgo/extensions/table" + . "github.com/onsi/gomega" + "sigs.k8s.io/kubebuilder/pkg/model" + "sigs.k8s.io/kubebuilder/pkg/model/file" + + "github.com/operator-framework/operator-sdk/internal/kubebuilder/filesystem" +) + +func TestScaffold(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Scaffold suite") +} + +var _ = Describe("Scaffold", func() { + Describe("NewScaffold", func() { + var ( + si Scaffold + s *scaffold + ok bool + ) + + Context("when using no plugins", func() { + BeforeEach(func() { + si = NewScaffold() + s, ok = si.(*scaffold) + }) + + It("should be a scaffold instance", func() { + Expect(ok).To(BeTrue()) + }) + + It("should not have a nil fs", func() { + Expect(s.fs).NotTo(BeNil()) + }) + + It("should not have any plugin", func() { + Expect(len(s.plugins)).To(Equal(0)) + }) + }) + + Context("when using one plugin", func() { + BeforeEach(func() { + si = NewScaffold(fakePlugin{}) + s, ok = si.(*scaffold) + }) + + It("should be a scaffold instance", func() { + Expect(ok).To(BeTrue()) + }) + + It("should not have a nil fs", func() { + Expect(s.fs).NotTo(BeNil()) + }) + + It("should have one plugin", func() { + Expect(len(s.plugins)).To(Equal(1)) + }) + }) + + Context("when using several plugins", func() { + BeforeEach(func() { + si = NewScaffold(fakePlugin{}, fakePlugin{}, fakePlugin{}) + s, ok = si.(*scaffold) + }) + + It("should be a scaffold instance", func() { + Expect(ok).To(BeTrue()) + }) + + It("should not have a nil fs", func() { + Expect(s.fs).NotTo(BeNil()) + }) + + It("should have several plugins", func() { + Expect(len(s.plugins)).To(Equal(3)) + }) + }) + }) + + Describe("Scaffold.Execute", func() { + const fileContent = "Hello world!" + + var ( + output bytes.Buffer + testErr = errors.New("error text") + ) + + BeforeEach(func() { + output.Reset() + }) + + DescribeTable("successes", + func(expected string, files ...file.Builder) { + s := &scaffold{ + fs: filesystem.NewMock( + filesystem.MockOutput(&output), + ), + } + + Expect(s.Execute(model.NewUniverse(), files...)).To(Succeed()) + Expect(output.String()).To(Equal(expected)) + }, + Entry("should write the file", + fileContent, + fakeTemplate{body: fileContent}, + ), + Entry("should skip optional models if already have one", + fileContent, + fakeTemplate{body: fileContent}, + fakeTemplate{}, + ), + Entry("should overwrite required models if already have one", + fileContent, + fakeTemplate{}, + fakeTemplate{fakeBuilder: fakeBuilder{ifExistsAction: file.Overwrite}, body: fileContent}, + ), + Entry("should format a go file", + "package file\n", + fakeTemplate{fakeBuilder: fakeBuilder{path: "file.go"}, body: "package file"}, + ), + ) + + DescribeTable("file builders related errors", + func(f func(error) bool, files ...file.Builder) { + s := &scaffold{fs: filesystem.NewMock()} + + Expect(f(s.Execute(model.NewUniverse(), files...))).To(BeTrue()) + }, + Entry("should fail if unable to validate a file builder", + file.IsValidateError, + fakeRequiresValidation{validateErr: testErr}, + ), + Entry("should fail if unable to set default values for a template", + file.IsSetTemplateDefaultsError, + fakeTemplate{err: testErr}, + ), + Entry("should fail if an unexpected previous model is found", + IsModelAlreadyExistsError, + fakeTemplate{fakeBuilder: fakeBuilder{path: "filename"}}, + fakeTemplate{fakeBuilder: fakeBuilder{path: "filename", ifExistsAction: file.Error}}, + ), + Entry("should fail if behavior if file exists is not defined", + IsUnknownIfExistsActionError, + fakeTemplate{fakeBuilder: fakeBuilder{path: "filename"}}, + fakeTemplate{fakeBuilder: fakeBuilder{path: "filename", ifExistsAction: -1}}, + ), + ) + + // Following errors are unwrapped, so we need to check for substrings + DescribeTable("template related errors", + func(errMsg string, files ...file.Builder) { + s := &scaffold{fs: filesystem.NewMock()} + + err := s.Execute(model.NewUniverse(), files...) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring(errMsg)) + }, + Entry("should fail if a template is broken", + "template: ", + fakeTemplate{body: "{{ .Field }"}, + ), + Entry("should fail if a template params aren't provided", + "template: ", + fakeTemplate{body: "{{ .Field }}"}, + ), + Entry("should fail if unable to format a go file", + "expected 'package', found ", + fakeTemplate{fakeBuilder: fakeBuilder{path: "file.go"}, body: fileContent}, + ), + ) + + DescribeTable("insert strings", + func(input, expected string, files ...file.Builder) { + s := &scaffold{ + fs: filesystem.NewMock( + filesystem.MockInput(bytes.NewBufferString(input)), + filesystem.MockOutput(&output), + filesystem.MockExists(func(_ string) bool { return len(input) != 0 }), + ), + } + + Expect(s.Execute(model.NewUniverse(), files...)).To(Succeed()) + Expect(output.String()).To(Equal(expected)) + }, + Entry("should insert lines for go files", + ` +// +kubebuilder:scaffold:- +`, + ` +1 +2 +// +kubebuilder:scaffold:- +`, + fakeInserter{codeFragments: file.CodeFragmentsMap{ + file.NewMarkerFor("file.go", "-"): {"1\n", "2\n"}}, + }, + ), + Entry("should insert lines for yaml files", + ` +# +kubebuilder:scaffold:- +`, + ` +1 +2 +# +kubebuilder:scaffold:- +`, + fakeInserter{codeFragments: file.CodeFragmentsMap{ + file.NewMarkerFor("file.yaml", "-"): {"1\n", "2\n"}}, + }, + ), + Entry("should use models if there is no file", + "", + ` +1 +2 +// +kubebuilder:scaffold:- +`, + fakeTemplate{fakeBuilder: fakeBuilder{ifExistsAction: file.Overwrite}, body: ` +// +kubebuilder:scaffold:- +`}, + fakeInserter{codeFragments: file.CodeFragmentsMap{ + file.NewMarkerFor("file.go", "-"): {"1\n", "2\n"}}, + }, + ), + Entry("should use required models over files", + fileContent, + ` +1 +2 +// +kubebuilder:scaffold:- +`, + fakeTemplate{fakeBuilder: fakeBuilder{ifExistsAction: file.Overwrite}, body: ` +// +kubebuilder:scaffold:- +`}, + fakeInserter{codeFragments: file.CodeFragmentsMap{ + file.NewMarkerFor("file.go", "-"): {"1\n", "2\n"}}, + }, + ), + Entry("should use files over optional models", + ` +// +kubebuilder:scaffold:- +`, + ` +1 +2 +// +kubebuilder:scaffold:- +`, + fakeTemplate{body: fileContent}, + fakeInserter{ + codeFragments: file.CodeFragmentsMap{ + file.NewMarkerFor("file.go", "-"): {"1\n", "2\n"}, + }, + }, + ), + Entry("should filter invalid markers", + ` +// +kubebuilder:scaffold:- +// +kubebuilder:scaffold:* +`, + ` +1 +2 +// +kubebuilder:scaffold:- +// +kubebuilder:scaffold:* +`, + fakeInserter{ + markers: []file.Marker{file.NewMarkerFor("file.go", "-")}, + codeFragments: file.CodeFragmentsMap{ + file.NewMarkerFor("file.go", "-"): {"1\n", "2\n"}, + file.NewMarkerFor("file.go", "*"): {"3\n", "4\n"}, + }, + }, + ), + Entry("should filter already existing one-line code fragments", + ` +1 +// +kubebuilder:scaffold:- +3 +4 +// +kubebuilder:scaffold:* +`, + ` +1 +2 +// +kubebuilder:scaffold:- +3 +4 +// +kubebuilder:scaffold:* +`, + fakeInserter{ + codeFragments: file.CodeFragmentsMap{ + file.NewMarkerFor("file.go", "-"): {"1\n", "2\n"}, + file.NewMarkerFor("file.go", "*"): {"3\n", "4\n"}, + }, + }, + ), + Entry("should not insert anything if no code fragment", + "", // input is provided through a template as mock fs doesn't copy it to the output buffer if no-op + ` +// +kubebuilder:scaffold:- +`, + fakeTemplate{body: ` +// +kubebuilder:scaffold:- +`}, + fakeInserter{ + codeFragments: file.CodeFragmentsMap{ + file.NewMarkerFor("file.go", "-"): {}, + }, + }, + ), + ) + + DescribeTable("insert strings related errors", + func(f func(error) bool, files ...file.Builder) { + s := &scaffold{ + fs: filesystem.NewMock( + filesystem.MockExists(func(_ string) bool { return true }), + ), + } + + err := s.Execute(model.NewUniverse(), files...) + Expect(err).To(HaveOccurred()) + Expect(f(err)).To(BeTrue()) + }, + Entry("should fail if inserting into a model that fails when a file exists and it does exist", + IsFileAlreadyExistsError, + fakeTemplate{fakeBuilder: fakeBuilder{path: "filename", ifExistsAction: file.Error}}, + fakeInserter{fakeBuilder: fakeBuilder{path: "filename"}}, + ), + Entry("should fail if inserting into a model with unknown behavior if the file exists and it does exist", + IsUnknownIfExistsActionError, + fakeTemplate{fakeBuilder: fakeBuilder{path: "filename", ifExistsAction: -1}}, + fakeInserter{fakeBuilder: fakeBuilder{path: "filename"}}, + ), + ) + + It("should fail if a plugin fails", func() { + s := &scaffold{ + fs: filesystem.NewMock(), + plugins: []model.Plugin{fakePlugin{err: testErr}}, + } + + err := s.Execute( + model.NewUniverse(), + fakeTemplate{}, + ) + Expect(err).To(MatchError(testErr)) + Expect(model.IsPluginError(err)).To(BeTrue()) + }) + + Context("write when the file already exists", func() { + var s Scaffold + + BeforeEach(func() { + s = &scaffold{ + fs: filesystem.NewMock( + filesystem.MockExists(func(_ string) bool { return true }), + filesystem.MockOutput(&output), + ), + } + }) + + It("should skip the file by default", func() { + Expect(s.Execute( + model.NewUniverse(), + fakeTemplate{body: fileContent}, + )).To(Succeed()) + Expect(output.String()).To(BeEmpty()) + }) + + It("should write the file if configured to do so", func() { + Expect(s.Execute( + model.NewUniverse(), + fakeTemplate{fakeBuilder: fakeBuilder{ifExistsAction: file.Overwrite}, body: fileContent}, + )).To(Succeed()) + Expect(output.String()).To(Equal(fileContent)) + }) + + It("should error if configured to do so", func() { + err := s.Execute( + model.NewUniverse(), + fakeTemplate{fakeBuilder: fakeBuilder{path: "filename", ifExistsAction: file.Error}, body: fileContent}, + ) + Expect(err).To(HaveOccurred()) + Expect(IsFileAlreadyExistsError(err)).To(BeTrue()) + Expect(output.String()).To(BeEmpty()) + }) + }) + + DescribeTable("filesystem errors", + func( + mockErrorF func(error) filesystem.MockOptions, + checkErrorF func(error) bool, + files ...file.Builder, + ) { + s := &scaffold{ + fs: filesystem.NewMock( + mockErrorF(testErr), + ), + } + + err := s.Execute(model.NewUniverse(), files...) + Expect(err).To(HaveOccurred()) + Expect(checkErrorF(err)).To(BeTrue()) + }, + Entry("should fail if fs.Exists failed (at file writing)", + filesystem.MockExistsError, filesystem.IsFileExistsError, + fakeTemplate{}, + ), + Entry("should fail if fs.Exists failed (at model updating)", + filesystem.MockExistsError, filesystem.IsFileExistsError, + fakeTemplate{}, + fakeInserter{}, + ), + Entry("should fail if fs.Open was unable to open the file", + filesystem.MockOpenFileError, filesystem.IsOpenFileError, + fakeInserter{}, + ), + Entry("should fail if fs.Open().Read was unable to read the file", + filesystem.MockReadFileError, filesystem.IsReadFileError, + fakeInserter{}, + ), + Entry("should fail if fs.Open().Close was unable to close the file", + filesystem.MockCloseFileError, filesystem.IsCloseFileError, + fakeInserter{}, + ), + Entry("should fail if fs.Create was unable to create the directory", + filesystem.MockCreateDirError, filesystem.IsCreateDirectoryError, + fakeTemplate{}, + ), + Entry("should fail if fs.Create was unable to create the file", + filesystem.MockCreateFileError, filesystem.IsCreateFileError, + fakeTemplate{}, + ), + Entry("should fail if fs.Create().Write was unable to write the file", + filesystem.MockWriteFileError, filesystem.IsWriteFileError, + fakeTemplate{}, + ), + Entry("should fail if fs.Create().Write was unable to close the file", + filesystem.MockCloseFileError, filesystem.IsCloseFileError, + fakeTemplate{}, + ), + ) + }) +}) + +var _ model.Plugin = fakePlugin{} + +// fakePlugin is used to mock a model.Plugin in order to test Scaffold +type fakePlugin struct { + err error +} + +// Pipe implements model.Plugin +func (f fakePlugin) Pipe(_ *model.Universe) error { + return f.err +} + +var _ file.Builder = fakeBuilder{} + +// fakeBuilder is used to mock a file.Builder +type fakeBuilder struct { + path string + ifExistsAction file.IfExistsAction +} + +// GetPath implements file.Builder +func (f fakeBuilder) GetPath() string { + return f.path +} + +// GetIfExistsAction implements file.Builder +func (f fakeBuilder) GetIfExistsAction() file.IfExistsAction { + return f.ifExistsAction +} + +var _ file.RequiresValidation = fakeRequiresValidation{} + +// fakeRequiresValidation is used to mock a file.RequiresValidation in order to test Scaffold +type fakeRequiresValidation struct { + fakeBuilder + + validateErr error +} + +// Validate implements file.RequiresValidation +func (f fakeRequiresValidation) Validate() error { + return f.validateErr +} + +var _ file.Template = fakeTemplate{} + +// fakeTemplate is used to mock a file.File in order to test Scaffold +type fakeTemplate struct { + fakeBuilder + + body string + err error +} + +// GetBody implements file.Template +func (f fakeTemplate) GetBody() string { + return f.body +} + +// SetTemplateDefaults implements file.Template +func (f fakeTemplate) SetTemplateDefaults() error { + if f.err != nil { + return f.err + } + + return nil +} + +type fakeInserter struct { + fakeBuilder + + markers []file.Marker + codeFragments file.CodeFragmentsMap +} + +// GetMarkers implements file.UpdatableTemplate +func (f fakeInserter) GetMarkers() []file.Marker { + if f.markers != nil { + return f.markers + } + + markers := make([]file.Marker, 0, len(f.codeFragments)) + for marker := range f.codeFragments { + markers = append(markers, marker) + } + return markers +} + +// GetCodeFragments implements file.UpdatableTemplate +func (f fakeInserter) GetCodeFragments() file.CodeFragmentsMap { + return f.codeFragments +}