From 7a840340848c31f59c81fc2b31ab9086980bb95d Mon Sep 17 00:00:00 2001 From: Brandon Palm Date: Mon, 24 Oct 2022 15:31:33 -0500 Subject: [PATCH] Address comments; move lib to /internal --- .../internal/lib}/check_container_test.go | 2 +- {lib => certification/internal/lib}/fakes.go | 4 +- {lib => certification/internal/lib}/lib.go | 4 +- .../internal/lib}/lib_test.go | 0 .../internal/lib}/preflight_check_test.go | 0 {lib => certification/internal/lib}/types.go | 10 +- certification/runtime/result_writer.go | 2 +- cmd/check_container.go | 2 +- cmd/check_container_test.go | 4 +- cmd/check_operator.go | 2 +- cmd/check_operator_test.go | 4 +- cmd/check_test.go | 2 +- internal/lib/check_container_test.go | 431 ++++++++++++++++++ internal/lib/fakes.go | 243 ++++++++++ internal/lib/lib.go | 229 ++++++++++ internal/lib/lib_test.go | 1 + internal/lib/preflight_check_test.go | 154 +++++++ internal/lib/types.go | 236 ++++++++++ 18 files changed, 1312 insertions(+), 18 deletions(-) rename {lib => certification/internal/lib}/check_container_test.go (99%) rename {lib => certification/internal/lib}/fakes.go (98%) rename {lib => certification/internal/lib}/lib.go (98%) rename {lib => certification/internal/lib}/lib_test.go (100%) rename {lib => certification/internal/lib}/preflight_check_test.go (100%) rename {lib => certification/internal/lib}/types.go (96%) create mode 100644 internal/lib/check_container_test.go create mode 100644 internal/lib/fakes.go create mode 100644 internal/lib/lib.go create mode 100644 internal/lib/lib_test.go create mode 100644 internal/lib/preflight_check_test.go create mode 100644 internal/lib/types.go diff --git a/lib/check_container_test.go b/certification/internal/lib/check_container_test.go similarity index 99% rename from lib/check_container_test.go rename to certification/internal/lib/check_container_test.go index 0fbfa2cd7..22dc03f72 100644 --- a/lib/check_container_test.go +++ b/certification/internal/lib/check_container_test.go @@ -421,7 +421,7 @@ var _ = Describe("Check Container Command", func() { }) }) - It("should contain a ResultWriterFile resultWriter", func() { + It("should contain a ResultWriterFile ResultWriter", func() { runner, err := NewCheckContainerRunner(context.TODO(), cfg, false) Expect(err).ToNot(HaveOccurred()) _, rwIsExpectedType := runner.Rw.(*runtime.ResultWriterFile) diff --git a/lib/fakes.go b/certification/internal/lib/fakes.go similarity index 98% rename from lib/fakes.go rename to certification/internal/lib/fakes.go index e1ffb71d2..0b9b24df5 100644 --- a/lib/fakes.go +++ b/certification/internal/lib/fakes.go @@ -198,7 +198,7 @@ func (e fakeCheckEngine) Results(ctx context.Context) runtime.Results { } } -// badResultWriter implements resultWriter and will automatically fail with the +// badResultWriter implements ResultWriter and will automatically fail with the // provided errmsg. type badResultWriter struct { errmsg string @@ -233,7 +233,7 @@ func (f *badFormatter) Format(ctx context.Context, r runtime.Results) ([]byte, e return nil, errors.New(f.errormsg) } -// badResultSubmitter implements resultSubmitter and fails to submit with the included errmsg. +// badResultSubmitter implements ResultSubmitter and fails to submit with the included errmsg. type badResultSubmitter struct { errmsg string } diff --git a/lib/lib.go b/certification/internal/lib/lib.go similarity index 98% rename from lib/lib.go rename to certification/internal/lib/lib.go index 1ef90d475..19da366ac 100644 --- a/lib/lib.go +++ b/certification/internal/lib/lib.go @@ -96,7 +96,7 @@ func NewCheckOperatorRunner(ctx context.Context, cfg *runtime.Config) (*CheckOpe }, nil } -// resolveSubmitter will build out a resultSubmitter if the provided pyxisClient, pc, is not nil. +// resolveSubmitter will build out a ResultSubmitter if the provided pyxisClient, pc, is not nil. // The pyxisClient is a required component of the submitter. If pc is nil, then a noop submitter // is returned instead, which does nothing. func ResolveSubmitter(pc PyxisClient, cfg certification.Config) ResultSubmitter { @@ -138,7 +138,7 @@ func GetContainerPolicyExceptions(ctx context.Context, pc PyxisClient) (policy.P func PreflightCheck( ctx context.Context, cfg *runtime.Config, - pc PyxisClient, //nolint:unparam // pyxisClient is currently unused. + pc PyxisClient, //nolint:unparam // PyxisClient is currently unused. eng engine.CheckEngine, formatter formatters.ResponseFormatter, rw ResultWriter, diff --git a/lib/lib_test.go b/certification/internal/lib/lib_test.go similarity index 100% rename from lib/lib_test.go rename to certification/internal/lib/lib_test.go diff --git a/lib/preflight_check_test.go b/certification/internal/lib/preflight_check_test.go similarity index 100% rename from lib/preflight_check_test.go rename to certification/internal/lib/preflight_check_test.go diff --git a/lib/types.go b/certification/internal/lib/types.go similarity index 96% rename from lib/types.go rename to certification/internal/lib/types.go index db771611e..e2d62ed71 100644 --- a/lib/types.go +++ b/certification/internal/lib/types.go @@ -18,18 +18,18 @@ import ( log "github.com/sirupsen/logrus" ) -// resultWriter defines methods associated with writing check results. +// ResultWriter defines methods associated with writing check results. type ResultWriter interface { OpenFile(name string) (io.WriteCloser, error) io.WriteCloser } -// resultSubmitter defines methods associated with submitting results to Red HAt. +// ResultSubmitter defines methods associated with submitting results to Red HAt. type ResultSubmitter interface { Submit(context.Context) error } -// pyxisClient defines pyxis API interactions that are relevant to check executions in cmd. +// PyxisClient defines pyxis API interactions that are relevant to check executions in cmd. type PyxisClient interface { FindImagesByDigest(ctx context.Context, digests []string) ([]pyxis.CertImage, error) GetProject(context.Context) (*pyxis.CertProject, error) @@ -55,7 +55,7 @@ func NewPyxisClient(ctx context.Context, cfg certification.Config) PyxisClient { } // ContainerCertificationSubmitter submits container results to Pyxis, and implements -// a resultSubmitter. +// a ResultSubmitter. type ContainerCertificationSubmitter struct { CertificationProjectID string Pyxis PyxisClient @@ -177,7 +177,7 @@ func (s *ContainerCertificationSubmitter) Submit(ctx context.Context) error { return nil } -// noopSubmitter is a no-op resultSubmitter that optionally logs a message +// noopSubmitter is a no-op ResultSubmitter that optionally logs a message // and a reason as to why results were not submitted. type NoopSubmitter struct { emitLog bool diff --git a/certification/runtime/result_writer.go b/certification/runtime/result_writer.go index 960333484..876d82055 100644 --- a/certification/runtime/result_writer.go +++ b/certification/runtime/result_writer.go @@ -5,7 +5,7 @@ import ( "os" ) -// ResultWriterFile implements a resultWriter for use at preflight runtime. +// ResultWriterFile implements a ResultWriter for use at preflight runtime. type ResultWriterFile struct { file *os.File } diff --git a/cmd/check_container.go b/cmd/check_container.go index e9bd995f0..9911a74ba 100644 --- a/cmd/check_container.go +++ b/cmd/check_container.go @@ -7,7 +7,7 @@ import ( "github.com/redhat-openshift-ecosystem/openshift-preflight/certification" "github.com/redhat-openshift-ecosystem/openshift-preflight/certification/formatters" "github.com/redhat-openshift-ecosystem/openshift-preflight/certification/runtime" - "github.com/redhat-openshift-ecosystem/openshift-preflight/lib" + "github.com/redhat-openshift-ecosystem/openshift-preflight/internal/lib" "github.com/redhat-openshift-ecosystem/openshift-preflight/version" log "github.com/sirupsen/logrus" diff --git a/cmd/check_container_test.go b/cmd/check_container_test.go index 45d54961d..a0a49f865 100644 --- a/cmd/check_container_test.go +++ b/cmd/check_container_test.go @@ -9,7 +9,7 @@ import ( . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" "github.com/redhat-openshift-ecosystem/openshift-preflight/certification/runtime" - "github.com/redhat-openshift-ecosystem/openshift-preflight/lib" + "github.com/redhat-openshift-ecosystem/openshift-preflight/internal/lib" "github.com/spf13/viper" ) @@ -151,7 +151,7 @@ certification_project_id: mycertid` AfterEach(func() { submit = origSubmitValue }) - It("should return a noopSubmitter resultSubmitter", func() { + It("should return a noopSubmitter ResultSubmitter", func() { runner, err := lib.NewCheckContainerRunner(context.TODO(), cfg, false) Expect(err).ToNot(HaveOccurred()) _, rsIsCorrectType := runner.Rs.(*lib.NoopSubmitter) diff --git a/cmd/check_operator.go b/cmd/check_operator.go index 9a6bf3883..daef67649 100644 --- a/cmd/check_operator.go +++ b/cmd/check_operator.go @@ -6,7 +6,7 @@ import ( "github.com/redhat-openshift-ecosystem/openshift-preflight/certification/formatters" "github.com/redhat-openshift-ecosystem/openshift-preflight/certification/runtime" - "github.com/redhat-openshift-ecosystem/openshift-preflight/lib" + "github.com/redhat-openshift-ecosystem/openshift-preflight/internal/lib" "github.com/redhat-openshift-ecosystem/openshift-preflight/version" log "github.com/sirupsen/logrus" diff --git a/cmd/check_operator_test.go b/cmd/check_operator_test.go index 94573572b..477b9698a 100644 --- a/cmd/check_operator_test.go +++ b/cmd/check_operator_test.go @@ -8,7 +8,7 @@ import ( "github.com/redhat-openshift-ecosystem/openshift-preflight/certification/formatters" "github.com/redhat-openshift-ecosystem/openshift-preflight/certification/policy" "github.com/redhat-openshift-ecosystem/openshift-preflight/certification/runtime" - "github.com/redhat-openshift-ecosystem/openshift-preflight/lib" + "github.com/redhat-openshift-ecosystem/openshift-preflight/internal/lib" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -167,7 +167,7 @@ var _ = Describe("Check Operator", func() { }) }) - It("should contain a ResultWriterFile resultWriter", func() { + It("should contain a ResultWriterFile ResultWriter", func() { runner, err := lib.NewCheckOperatorRunner(context.TODO(), cfg) Expect(err).ToNot(HaveOccurred()) _, rwIsExpectedType := runner.Rw.(*runtime.ResultWriterFile) diff --git a/cmd/check_test.go b/cmd/check_test.go index f6153024c..9eace7662 100644 --- a/cmd/check_test.go +++ b/cmd/check_test.go @@ -7,7 +7,7 @@ import ( "github.com/redhat-openshift-ecosystem/openshift-preflight/certification/artifacts" "github.com/redhat-openshift-ecosystem/openshift-preflight/certification/runtime" - "github.com/redhat-openshift-ecosystem/openshift-preflight/lib" + "github.com/redhat-openshift-ecosystem/openshift-preflight/internal/lib" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" diff --git a/internal/lib/check_container_test.go b/internal/lib/check_container_test.go new file mode 100644 index 000000000..22dc03f72 --- /dev/null +++ b/internal/lib/check_container_test.go @@ -0,0 +1,431 @@ +package lib + +import ( + "bytes" + "context" + "encoding/json" + "os" + "path" + "strings" + + "github.com/redhat-openshift-ecosystem/openshift-preflight/certification" + "github.com/redhat-openshift-ecosystem/openshift-preflight/certification/artifacts" + "github.com/redhat-openshift-ecosystem/openshift-preflight/certification/engine" + "github.com/redhat-openshift-ecosystem/openshift-preflight/certification/formatters" + "github.com/redhat-openshift-ecosystem/openshift-preflight/certification/policy" + "github.com/redhat-openshift-ecosystem/openshift-preflight/certification/pyxis" + "github.com/redhat-openshift-ecosystem/openshift-preflight/certification/runtime" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/sirupsen/logrus" + "github.com/spf13/afero" +) + +var _ = Describe("Check Container Command", func() { + // BeforeEach(createAndCleanupDirForArtifactsAndLogs) + + Context("When determining container policy exceptions", func() { + var fakePC *FakePyxisClient + BeforeEach(func() { + // reset the fake pyxis client before each execution + // as a precaution. + fakePC = &FakePyxisClient{ + findImagesByDigestFunc: fidbFuncNoop, + getProjectsFunc: gpFuncNoop, + submitResultsFunc: srFuncNoop, + } + }) + + It("should throw an error if unable to get the project from the API", func() { + fakePC.getProjectsFunc = gpFuncReturnError + _, err := GetContainerPolicyExceptions(context.TODO(), fakePC) + Expect(err).To(HaveOccurred()) + }) + + It("should return a scratch policy exception if the project has the flag in the API", func() { + fakePC.getProjectsFunc = gpFuncReturnScratchException + p, err := GetContainerPolicyExceptions(context.TODO(), fakePC) + Expect(p).To(Equal(policy.PolicyScratch)) + Expect(err).ToNot(HaveOccurred()) + }) + + It("should return a root policy exception if the project has the flag in the API", func() { + fakePC.getProjectsFunc = gpFuncReturnRootException + p, err := GetContainerPolicyExceptions(context.TODO(), fakePC) + Expect(p).To(Equal(policy.PolicyRoot)) + Expect(err).ToNot(HaveOccurred()) + }) + + It("should return a container policy exception if the project no exceptions in the API", func() { + fakePC.getProjectsFunc = gpFuncReturnNoException + p, err := GetContainerPolicyExceptions(context.TODO(), fakePC) + Expect(p).To(Equal(policy.PolicyContainer)) + Expect(err).ToNot(HaveOccurred()) + }) + }) + + Context("When using the containerCertificationSubmitter", func() { + var sbmt *ContainerCertificationSubmitter + var fakePC *FakePyxisClient + var dockerConfigPath string + var preflightLogPath string + + preflightLogFilename := "preflight.log" + dockerconfigFilename := "dockerconfig.json" + BeforeEach(func() { + dockerConfigPath = path.Join(artifacts.Path(), dockerconfigFilename) + preflightLogPath = path.Join(artifacts.Path(), preflightLogFilename) + // Normalize a FakePyxisClient with noop functions. + fakePC = NewFakePyxisClientNoop() + + // Most tests will need a passing getProjects func so set that to + // avoid having to perform multiple BeforeEaches + fakePC.setGPFuncReturnBaseProject("") + + // configure the submitter + sbmt = &ContainerCertificationSubmitter{ + CertificationProjectID: fakePC.baseProject("").ID, + Pyxis: fakePC, + DockerConfig: dockerConfigPath, + PreflightLogFile: preflightLogPath, + } + + certImageJSONBytes, err := json.Marshal(pyxis.CertImage{ + ID: "111111111111", + }) + Expect(err).ToNot(HaveOccurred()) + + preflightTestResultsJSONBytes, err := json.Marshal(runtime.Results{ + TestedImage: "foo", + PassedOverall: true, + }) + Expect(err).ToNot(HaveOccurred()) + + rpmManifestJSONBytes, err := json.Marshal(pyxis.RPMManifest{ + ID: "foo", + ImageID: "foo", + }) + Expect(err).ToNot(HaveOccurred()) + + // Create expected files. Use of Gomega's Expect here (without a subsequent test) is intentional. + // Expect automatically checks that additional return values are nil, and thus will fail if they + // are not. + Expect(artifacts.WriteFile(dockerconfigFilename, strings.NewReader("dockerconfig"))) + Expect(artifacts.WriteFile(preflightLogFilename, strings.NewReader("preflight log"))) + Expect(artifacts.WriteFile(certification.DefaultCertImageFilename, bytes.NewReader(certImageJSONBytes))) + Expect(artifacts.WriteFile(certification.DefaultTestResultsFilename, bytes.NewReader(preflightTestResultsJSONBytes))) + Expect(artifacts.WriteFile(certification.DefaultRPMManifestFilename, bytes.NewReader(rpmManifestJSONBytes))) + }) + + Context("and project cannot be obtained from the API", func() { + BeforeEach(func() { + fakePC.getProjectsFunc = gpFuncReturnError + }) + It("should throw an error", func() { + err := sbmt.Submit(context.TODO()) + Expect(err).To(HaveOccurred()) + }) + }) + + Context("and the provided docker config cannot be read from disk", func() { + It("should throw an error", func() { + err := os.Remove(dockerConfigPath) + Expect(err).ToNot(HaveOccurred()) + + err = sbmt.Submit(context.TODO()) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring(dockerconfigFilename)) + }) + }) + + Context("and no docker config command argument was provided", func() { + BeforeEach(func() { + fakePC.setSRFuncSubmitSuccessfully("", "") + }) + It("should not throw an error", func() { + sbmt.DockerConfig = "" + err := os.Remove(dockerConfigPath) + Expect(err).ToNot(HaveOccurred()) + + err = sbmt.Submit(context.TODO()) + Expect(err).ToNot(HaveOccurred()) + }) + }) + + Context("and the cert image cannot be read from disk", func() { + It("should throw an error", func() { + err := os.Remove(path.Join(artifacts.Path(), certification.DefaultCertImageFilename)) + Expect(err).ToNot(HaveOccurred()) + + err = sbmt.Submit(context.TODO()) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring(certification.DefaultCertImageFilename)) + }) + }) + + Context("and the preflight results cannot be read from disk", func() { + It("should throw an error", func() { + err := os.Remove(path.Join(artifacts.Path(), certification.DefaultTestResultsFilename)) + Expect(err).ToNot(HaveOccurred()) + + err = sbmt.Submit(context.TODO()) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring(certification.DefaultTestResultsFilename)) + }) + }) + + Context("and the rpmManifest cannot be read from disk", func() { + It("should throw an error", func() { + err := os.Remove(path.Join(artifacts.Path(), certification.DefaultRPMManifestFilename)) + Expect(err).ToNot(HaveOccurred()) + + err = sbmt.Submit(context.TODO()) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring(certification.DefaultRPMManifestFilename)) + }) + }) + + Context("and the preflight logfile cannot be read from disk", func() { + It("should throw an error", func() { + err := os.Remove(preflightLogPath) + Expect(err).ToNot(HaveOccurred()) + + err = sbmt.Submit(context.TODO()) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring(preflightLogFilename)) + }) + }) + + Context("and the submission fails", func() { + BeforeEach(func() { + fakePC.submitResultsFunc = srFuncReturnError + }) + + It("should throw an error", func() { + err := sbmt.Submit(context.TODO()) + Expect(err).To(HaveOccurred()) + }) + }) + + Context("and the certproject returned from pyxis is nil, but no error was returned", func() { + BeforeEach(func() { + fakePC.getProjectsFunc = gpFuncNoop + }) + + It("should throw an error", func() { + err := sbmt.Submit(context.TODO()) + Expect(err).To(HaveOccurred()) + }) + }) + + Context("and one of the submission artifacts is malformed", func() { + JustBeforeEach(func() { + afs := afero.NewBasePathFs(afero.NewOsFs(), artifacts.Path()) + Expect(afs.Remove(certification.DefaultRPMManifestFilename)).To(Succeed()) + Expect(artifacts.WriteFile(certification.DefaultRPMManifestFilename, strings.NewReader("malformed"))).To(ContainSubstring(certification.DefaultRPMManifestFilename)) + }) + + It("should throw an error finalizing the submission", func() { + err := sbmt.Submit(context.TODO()) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("unable to finalize data")) + }) + }) + + Context("and the submission succeeds", func() { + BeforeEach(func() { + fakePC.setSRFuncSubmitSuccessfully("", "") + }) + It("should not throw an error", func() { + err := sbmt.Submit(context.TODO()) + Expect(err).ToNot(HaveOccurred()) + }) + }) + }) + + Context("When using the noop submitter", func() { + var bf *bytes.Buffer + var noop *NoopSubmitter + + BeforeEach(func() { + bufferLogger := logrus.New() + bf = bytes.NewBuffer([]byte{}) + bufferLogger.SetOutput(bf) + + noop = NewNoopSubmitter(false, "", bufferLogger) + }) + + Context("and enabling log emitting", func() { + BeforeEach(func() { + noop.SetEmitLog(true) + }) + + It("should include the reason in the emitted log if specified", func() { + testReason := "test reason" + noop.SetReason(testReason) + err := noop.Submit(context.TODO()) + Expect(err).ToNot(HaveOccurred()) + Expect(bf.String()).To(ContainSubstring(testReason)) + }) + + It("should emit logs when calling submit", func() { + err := noop.Submit(context.TODO()) + Expect(err).ToNot(HaveOccurred()) + Expect(bf.String()).To(ContainSubstring("Results are not being sent for submission.")) + }) + }) + + Context("and disabling log emitting", func() { + It("should not emit logs when calling submit", func() { + noop.SetEmitLog(false) + err := noop.Submit(context.TODO()) + Expect(err).ToNot(HaveOccurred()) + Expect(bf.String()).To(BeEmpty()) + }) + }) + }) + + Context("When resolving the submitter", func() { + Context("with a valid pyxis client", func() { + cfg := runtime.Config{ + CertificationProjectID: "projectid", + PyxisHost: "host", + PyxisAPIToken: "apitoken", + DockerConfig: "dockercfg", + LogFile: "logfile", + } + + pc := NewPyxisClient(context.TODO(), cfg.ReadOnly()) + Expect(pc).ToNot(BeNil()) + + It("should return a containerCertificationSubmitter", func() { + submitter := ResolveSubmitter(pc, cfg.ReadOnly()) + typed, ok := submitter.(*ContainerCertificationSubmitter) + Expect(typed).ToNot(BeNil()) + Expect(ok).To(BeTrue()) + }) + }) + + Context("With no pyxis client", func() { + cfg := runtime.Config{} + It("should return a no-op submitter", func() { + submitter := ResolveSubmitter(nil, cfg.ReadOnly()) + typed, ok := submitter.(*NoopSubmitter) + Expect(typed).ToNot(BeNil()) + Expect(ok).To(BeTrue()) + }) + }) + }) + + Context("When establishing a pyxis client.", func() { + Context("with none of the required values", func() { + cfgNoCertProjectID := runtime.Config{} + + It("Should return a nil pyxis client", func() { + pc := NewPyxisClient(context.TODO(), cfgNoCertProjectID.ReadOnly()) + Expect(pc).To(BeNil()) + }) + }) + + Context("Missing any of the required values", func() { + cfgMissingCertProjectID := runtime.Config{ + PyxisHost: "foo", + PyxisAPIToken: "bar", + } + + cfgMissingPyxisHost := runtime.Config{ + CertificationProjectID: "foo", + PyxisAPIToken: "bar", + } + + cfgMissingPyxisAPIToken := runtime.Config{ + CertificationProjectID: "foo", + PyxisHost: "bar", + } + + It("Should return a nil pyxis client", func() { + pc := NewPyxisClient(context.TODO(), cfgMissingCertProjectID.ReadOnly()) + Expect(pc).To(BeNil()) + + pc = NewPyxisClient(context.TODO(), cfgMissingPyxisHost.ReadOnly()) + Expect(pc).To(BeNil()) + + pc = NewPyxisClient(context.TODO(), cfgMissingPyxisAPIToken.ReadOnly()) + Expect(pc).To(BeNil()) + }) + }) + + Context("With all the required values", func() { + cfgValid := runtime.Config{ + CertificationProjectID: "foo", + PyxisHost: "bar", + PyxisAPIToken: "baz", + } + + It("should return a pyxis client", func() { + pc := NewPyxisClient(context.TODO(), cfgValid.ReadOnly()) + Expect(pc).ToNot(BeNil()) + }) + }) + }) + + Context("When instantiating a checkContainerRunner", func() { + var cfg *runtime.Config + + BeforeEach(func() { + cfg = &runtime.Config{ + Image: "quay.io/example/foo:latest", + ResponseFormat: formatters.DefaultFormat, + } + }) + + Context("and the user passed the submit flag, but no credentials", func() { + It("should return a noop submitter as credentials are required for submission", func() { + runner, err := NewCheckContainerRunner(context.TODO(), cfg, false) + Expect(err).ToNot(HaveOccurred()) + _, rsIsCorrectType := runner.Rs.(*NoopSubmitter) + Expect(rsIsCorrectType).To(BeTrue()) + }) + }) + + Context("with a valid policy formatter", func() { + It("should return with no error, and the appropriate formatter", func() { + cfg.ResponseFormat = "xml" + runner, err := NewCheckContainerRunner(context.TODO(), cfg, false) + Expect(err).ToNot(HaveOccurred()) + expectedFormatter, err := formatters.NewByName(cfg.ResponseFormat) + Expect(err).ToNot(HaveOccurred()) + Expect(runner.Formatter.PrettyName()).To(Equal(expectedFormatter.PrettyName())) + }) + }) + + Context("with an invalid policy definition", func() { + It("should return the container policy engine anyway", func() { + runner, err := NewCheckContainerRunner(context.TODO(), cfg, false) + Expect(err).ToNot(HaveOccurred()) + + expectedEngine, err := engine.NewForConfig(context.TODO(), cfg.ReadOnly()) + Expect(runner.Eng).To(BeEquivalentTo(expectedEngine)) + Expect(err).ToNot(HaveOccurred()) + }) + }) + // NOTE(): There's no way to test policy exceptions here because + // without valid credentials to pyxis. + + Context("with an invalid formatter definition", func() { + It("should return an error", func() { + cfg.ResponseFormat = "foo" + _, err := NewCheckContainerRunner(context.TODO(), cfg, false) + Expect(err).To(HaveOccurred()) + }) + }) + + It("should contain a ResultWriterFile ResultWriter", func() { + runner, err := NewCheckContainerRunner(context.TODO(), cfg, false) + Expect(err).ToNot(HaveOccurred()) + _, rwIsExpectedType := runner.Rw.(*runtime.ResultWriterFile) + Expect(rwIsExpectedType).To(BeTrue()) + }) + }) +}) diff --git a/internal/lib/fakes.go b/internal/lib/fakes.go new file mode 100644 index 000000000..0b9b24df5 --- /dev/null +++ b/internal/lib/fakes.go @@ -0,0 +1,243 @@ +package lib + +import ( + "context" + "errors" + "fmt" + "io" + "math/rand" + "time" + + "github.com/redhat-openshift-ecosystem/openshift-preflight/certification" + "github.com/redhat-openshift-ecosystem/openshift-preflight/certification/pyxis" + "github.com/redhat-openshift-ecosystem/openshift-preflight/certification/runtime" +) + +type ( + fibdFunc func(ctx context.Context, digests []string) ([]pyxis.CertImage, error) + gpFunc func(context.Context) (*pyxis.CertProject, error) + srFunc func(context.Context, *pyxis.CertificationInput) (*pyxis.CertificationResults, error) +) + +func NewFakePyxisClientNoop() *FakePyxisClient { + return &FakePyxisClient{ + findImagesByDigestFunc: fidbFuncNoop, + getProjectsFunc: gpFuncNoop, + submitResultsFunc: srFuncNoop, + } +} + +// FakePyxisClient is a configurable pyxisClient for use in testing. It accepts function definitions to +// use to implement a cmd.pyxisClient. +type FakePyxisClient struct { + findImagesByDigestFunc fibdFunc + getProjectsFunc gpFunc + submitResultsFunc srFunc +} + +// baseProject returns a pyxis.CertProject with an id of projectID, or a base value +// if none is provided. +func (pc *FakePyxisClient) baseProject(projectID string) pyxis.CertProject { + pid := "000000000000" + if len(projectID) > 0 { + pid = projectID + } + + return pyxis.CertProject{ + ID: pid, + CertificationStatus: "false", + Name: "some-project", + } +} + +// successfulCertResults returns a pyxis.CertificationResults for use in tests emulating successful +// submission. +func (pc *FakePyxisClient) successfulCertResults(projectID, certImageID string) pyxis.CertificationResults { + pid := "000000000000" + if len(projectID) > 0 { + pid = projectID + } + + ciid := "111111111111" + if len(certImageID) > 0 { + ciid = certImageID + } + return pyxis.CertificationResults{ + CertProject: &pyxis.CertProject{ + ID: pid, + }, + CertImage: &pyxis.CertImage{ + ID: ciid, + }, + } +} + +// setGPFuncReturnBaseProject sets pc.getProjectFunc to a function that returns baseProject. +// This is a FakePyxisClient method because it enables standardizing on a single value of +// a CertificationProject for GetProject calls, tied to the instance of FakePyxisClient +func (pc *FakePyxisClient) setGPFuncReturnBaseProject(projectID string) { + baseproj := pc.baseProject(projectID) + pc.getProjectsFunc = func(context.Context) (*pyxis.CertProject, error) { return &baseproj, nil } +} + +func (pc *FakePyxisClient) setSRFuncSubmitSuccessfully(projectID, certImageID string) { + baseproj := pc.baseProject(projectID) + certresults := pc.successfulCertResults(baseproj.ID, certImageID) + pc.submitResultsFunc = func(context.Context, *pyxis.CertificationInput) (*pyxis.CertificationResults, error) { + return &certresults, nil + } +} + +func (pc *FakePyxisClient) FindImagesByDigest(ctx context.Context, digests []string) ([]pyxis.CertImage, error) { + return pc.findImagesByDigestFunc(ctx, digests) +} + +func (pc *FakePyxisClient) GetProject(ctx context.Context) (*pyxis.CertProject, error) { + return pc.getProjectsFunc(ctx) +} + +func (pc *FakePyxisClient) SubmitResults(ctx context.Context, ci *pyxis.CertificationInput) (*pyxis.CertificationResults, error) { + return pc.submitResultsFunc(ctx, ci) +} + +// gpFuncReturnError implements gpFunc but returns an error. +func gpFuncReturnError(ctx context.Context) (*pyxis.CertProject, error) { + return nil, errors.New("some error returned from the api") +} + +// gpFuncReturnScratchException implements gpFunc and returns a scratch exception. +func gpFuncReturnScratchException(ctx context.Context) (*pyxis.CertProject, error) { + return &pyxis.CertProject{ + Container: pyxis.Container{ + Type: "scratch", + }, + }, nil +} + +// gpFuncReturnRootException implements gpFunc and returns a root exception. +func gpFuncReturnRootException(ctx context.Context) (*pyxis.CertProject, error) { + return &pyxis.CertProject{ + Container: pyxis.Container{ + DockerConfigJSON: "", + Privileged: true, + }, + }, nil +} + +// gpFuncReturnNoException implements gpFunc and returns no exception indicators. +func gpFuncReturnNoException(ctx context.Context) (*pyxis.CertProject, error) { + return &pyxis.CertProject{ + Container: pyxis.Container{ + Type: "", + Privileged: false, + }, + }, nil +} + +// srFuncReturnError implements srFunc and returns a submission error. +func srFuncReturnError(ctx context.Context, ci *pyxis.CertificationInput) (*pyxis.CertificationResults, error) { + return nil, errors.New("some submission error") +} + +// fidbFuncNoop implements a fidbFunc, best to use while instantiating FakePyxisClient. +func fidbFuncNoop(ctx context.Context, digests []string) ([]pyxis.CertImage, error) { + return nil, nil +} + +// gpFuncNoop implements a gpFunc, best to use while instantiating FakePyxisClient. +func gpFuncNoop(ctx context.Context) (*pyxis.CertProject, error) { + return nil, nil +} + +// srFuncNoop implements a srFuncNoop, best to use while instantiating FakePyxisClient. +func srFuncNoop(ctx context.Context, ci *pyxis.CertificationInput) (*pyxis.CertificationResults, error) { + return nil, nil +} + +// fakeCheckEngine implements a certification.CheckEngine with configurables for use in tests. +type fakeCheckEngine struct { + image string + passed bool + errorRunningChecks bool + errorMsg string +} + +// generateCheck generates a check with a randomized name +func (e fakeCheckEngine) generateCheck() certification.Check { + generatedName := fmt.Sprintf("test-rand-%d", rand.Int()) + + doNothing := func(c context.Context, i certification.ImageReference) (bool, error) { + return true, nil + } + + return certification.NewGenericCheck(generatedName, + doNothing, + certification.Metadata{}, + certification.HelpText{}, + ) +} + +func (e fakeCheckEngine) ExecuteChecks(ctx context.Context) error { + if e.errorRunningChecks { + return errors.New(e.errorMsg) + } + return nil +} + +func (e fakeCheckEngine) Results(ctx context.Context) runtime.Results { + return runtime.Results{ + TestedImage: "", + PassedOverall: false, + TestedOn: runtime.OpenshiftClusterVersion{}, + CertificationHash: "", + Passed: []runtime.Result{ + {Check: e.generateCheck(), ElapsedTime: 20 * time.Millisecond}, + }, + Failed: []runtime.Result{}, + Errors: []runtime.Result{}, + } +} + +// badResultWriter implements ResultWriter and will automatically fail with the +// provided errmsg. +type badResultWriter struct { + errmsg string +} + +func (brw *badResultWriter) OpenFile(n string) (io.WriteCloser, error) { + return nil, errors.New(brw.errmsg) +} + +func (brw *badResultWriter) Close() error { + return nil +} + +func (brw *badResultWriter) Write(p []byte) (int, error) { + return 0, nil +} + +// badFormatter implements Formatter and fails to Format with the provided errmsg. +type badFormatter struct { + errormsg string +} + +func (f *badFormatter) FileExtension() string { + return "fake" +} + +func (f *badFormatter) PrettyName() string { + return "Fake" +} + +func (f *badFormatter) Format(ctx context.Context, r runtime.Results) ([]byte, error) { + return nil, errors.New(f.errormsg) +} + +// badResultSubmitter implements ResultSubmitter and fails to submit with the included errmsg. +type badResultSubmitter struct { + errmsg string +} + +func (brs *badResultSubmitter) Submit(ctx context.Context) error { + return errors.New(brs.errmsg) +} diff --git a/internal/lib/lib.go b/internal/lib/lib.go new file mode 100644 index 000000000..19da366ac --- /dev/null +++ b/internal/lib/lib.go @@ -0,0 +1,229 @@ +package lib + +import ( + "bytes" + "context" + "fmt" + "io" + "os" + "strings" + + "github.com/redhat-openshift-ecosystem/openshift-preflight/certification" + "github.com/redhat-openshift-ecosystem/openshift-preflight/certification/artifacts" + "github.com/redhat-openshift-ecosystem/openshift-preflight/certification/engine" + "github.com/redhat-openshift-ecosystem/openshift-preflight/certification/formatters" + "github.com/redhat-openshift-ecosystem/openshift-preflight/certification/policy" + "github.com/redhat-openshift-ecosystem/openshift-preflight/certification/runtime" + log "github.com/sirupsen/logrus" +) + +// CheckContainerRunner contains all of the components necessary to run checkContainer. +type CheckContainerRunner struct { + Cfg *runtime.Config + Pc PyxisClient + Eng engine.CheckEngine + Formatter formatters.ResponseFormatter + Rw ResultWriter + Rs ResultSubmitter +} + +func NewCheckContainerRunner(ctx context.Context, cfg *runtime.Config, submit bool) (*CheckContainerRunner, error) { + cfg.Policy = policy.PolicyContainer + cfg.Submit = submit + + pyxisClient := NewPyxisClient(ctx, cfg.ReadOnly()) + // If we have a pyxisClient, we can query for container policy exceptions. + if pyxisClient != nil { + policy, err := GetContainerPolicyExceptions(ctx, pyxisClient) + if err != nil { + return nil, err + } + + cfg.Policy = policy + } + + engine, err := engine.NewForConfig(ctx, cfg.ReadOnly()) + if err != nil { + return nil, err + } + + fmttr, err := formatters.NewForConfig(cfg.ReadOnly()) + if err != nil { + return nil, err + } + + rs := ResolveSubmitter(pyxisClient, cfg.ReadOnly()) + + return &CheckContainerRunner{ + Cfg: cfg, + Pc: pyxisClient, + Eng: engine, + Formatter: fmttr, + Rw: &runtime.ResultWriterFile{}, + Rs: rs, + }, nil +} + +// checkOperatorRunner contains all of the components necessary to run checkOperator. +type CheckOperatorRunner struct { + Cfg *runtime.Config + Eng engine.CheckEngine + Formatter formatters.ResponseFormatter + Rw ResultWriter +} + +// newCheckOperatorRunner returns a checkOperatorRunner containing all of the tooling necessary +// to run checkOperator. +func NewCheckOperatorRunner(ctx context.Context, cfg *runtime.Config) (*CheckOperatorRunner, error) { + cfg.Policy = policy.PolicyOperator + cfg.Submit = false // there's no such thing as submitting for operators today. + + engine, err := engine.NewForConfig(ctx, cfg.ReadOnly()) + if err != nil { + return nil, err + } + + fmttr, err := formatters.NewForConfig(cfg.ReadOnly()) + if err != nil { + return nil, err + } + + return &CheckOperatorRunner{ + Cfg: cfg, + Eng: engine, + Formatter: fmttr, + Rw: &runtime.ResultWriterFile{}, + }, nil +} + +// resolveSubmitter will build out a ResultSubmitter if the provided pyxisClient, pc, is not nil. +// The pyxisClient is a required component of the submitter. If pc is nil, then a noop submitter +// is returned instead, which does nothing. +func ResolveSubmitter(pc PyxisClient, cfg certification.Config) ResultSubmitter { + if pc != nil { + return &ContainerCertificationSubmitter{ + CertificationProjectID: cfg.CertificationProjectID(), + Pyxis: pc, + DockerConfig: cfg.DockerConfig(), + PreflightLogFile: cfg.LogFile(), + } + } + return NewNoopSubmitter(true, "", nil) +} + +// GetContainerPolicyExceptions will query Pyxis to determine if +// a given project has a certification excemptions, such as root or scratch. +// This will then return the corresponding policy. +// +// If no policy exception flags are found on the project, the standard +// container policy is returned. +func GetContainerPolicyExceptions(ctx context.Context, pc PyxisClient) (policy.Policy, error) { + certProject, err := pc.GetProject(ctx) + if err != nil { + return "", fmt.Errorf("could not retrieve project: %w", err) + } + // log.Debugf("Certification project name is: %s", certProject.Name) + if certProject.Container.Type == "scratch" { + return policy.PolicyScratch, nil + } + + // if a partner sets `Host Level Access` in connect to `Privileged`, enable RootExceptionContainerPolicy checks + if certProject.Container.Privileged { + return policy.PolicyRoot, nil + } + return policy.PolicyContainer, nil +} + +// PreflightCheck executes checks, interacts with pyxis, format output, writes, and submits results. +func PreflightCheck( + ctx context.Context, + cfg *runtime.Config, + pc PyxisClient, //nolint:unparam // PyxisClient is currently unused. + eng engine.CheckEngine, + formatter formatters.ResponseFormatter, + rw ResultWriter, + rs ResultSubmitter, +) error { + // configure the artifacts directory if the user requested a different directory. + if cfg.Artifacts != "" { + artifacts.SetDir(cfg.Artifacts) + } + + // create the results file early to catch cases where we are not + // able to write to the filesystem before we attempt to execute checks. + resultsFilePath, err := artifacts.WriteFile(resultsFilenameWithExtension(formatter.FileExtension()), strings.NewReader("")) + if err != nil { + return err + } + resultsFile, err := rw.OpenFile(resultsFilePath) + if err != nil { + return err + } + defer resultsFile.Close() + + resultsOutputTarget := io.MultiWriter(os.Stdout, resultsFile) + + // execute the checks + if err := eng.ExecuteChecks(ctx); err != nil { + return err + } + results := eng.Results(ctx) + + // return results to the user and then close output files + formattedResults, err := formatter.Format(ctx, results) + if err != nil { + return err + } + + fmt.Fprintln(resultsOutputTarget, string(formattedResults)) + + if cfg.WriteJUnit { + if err := writeJUnit(ctx, results); err != nil { + return err + } + } + + if cfg.Submit { + if err := rs.Submit(ctx); err != nil { + return err + } + } + + log.Infof("Preflight result: %s", convertPassedOverall(results.PassedOverall)) + + return nil +} + +func writeJUnit(ctx context.Context, results runtime.Results) error { + var cfg runtime.Config + cfg.ResponseFormat = "junitxml" + + junitformatter, err := formatters.NewForConfig(cfg.ReadOnly()) + if err != nil { + return err + } + junitResults, err := junitformatter.Format(ctx, results) + if err != nil { + return err + } + + junitFilename, err := artifacts.WriteFile("results-junit.xml", bytes.NewReader((junitResults))) + if err != nil { + return err + } + log.Tracef("JUnitXML written to %s", junitFilename) + + return nil +} + +func resultsFilenameWithExtension(ext string) string { + return strings.Join([]string{"results", ext}, ".") +} + +func convertPassedOverall(passedOverall bool) string { + if passedOverall { + return "PASSED" + } + + return "FAILED" +} diff --git a/internal/lib/lib_test.go b/internal/lib/lib_test.go new file mode 100644 index 000000000..55c21f80a --- /dev/null +++ b/internal/lib/lib_test.go @@ -0,0 +1 @@ +package lib diff --git a/internal/lib/preflight_check_test.go b/internal/lib/preflight_check_test.go new file mode 100644 index 000000000..da7321c72 --- /dev/null +++ b/internal/lib/preflight_check_test.go @@ -0,0 +1,154 @@ +package lib + +import ( + "context" + "os" + "path" + + "github.com/redhat-openshift-ecosystem/openshift-preflight/certification/artifacts" + "github.com/redhat-openshift-ecosystem/openshift-preflight/certification/engine" + "github.com/redhat-openshift-ecosystem/openshift-preflight/certification/formatters" + "github.com/redhat-openshift-ecosystem/openshift-preflight/certification/runtime" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Preflight Check Func", func() { + Context("When running the preflight check logic", func() { + // This test customizes the artifactsDir in testing functions, + // so we set a custom temp dir outside of the top-level just for + // this test. + var localTempDir string + var localArtifactsDir string + + var cfg *runtime.Config + var pc PyxisClient + var eng engine.CheckEngine + var fmttr formatters.ResponseFormatter + var rw ResultWriter + var rs ResultSubmitter + + BeforeEach(func() { + // instantiate err to make sure we can equal-assign in the following line. + var err error + localTempDir, err = os.MkdirTemp(os.TempDir(), "preflight-check-local-tempdir-*") + Expect(err).ToNot(HaveOccurred()) + Expect(len(localTempDir)).ToNot(BeZero()) + localArtifactsDir = path.Join(localTempDir, "artifacts") + // Don't set the artifacts dir here! This is handled by the function under test. + + img := "quay.io/example/foo:latest" + // create a base config + cfg = &runtime.Config{ + Image: img, + Artifacts: localArtifactsDir, + } + + pc = &FakePyxisClient{ + findImagesByDigestFunc: fidbFuncNoop, + getProjectsFunc: gpFuncNoop, + submitResultsFunc: srFuncNoop, + } + + eng = fakeCheckEngine{ + image: img, + passed: true, + } + + fmttr, _ = formatters.NewByName(formatters.DefaultFormat) + rw = &runtime.ResultWriterFile{} + rs = NewNoopSubmitter(false, "", nil) + + DeferCleanup(os.RemoveAll, localTempDir) + DeferCleanup(os.RemoveAll, localArtifactsDir) + DeferCleanup(artifacts.Reset) + }) + + Context("with a customized artifacts directory", func() { + It("should set the artifacts directory accordingly", func() { + // it's possible this will throw an error, but we dont' care for this test. + _ = PreflightCheck(context.TODO(), cfg, pc, eng, fmttr, rw, rs) + Expect(artifacts.Path()).To(Equal(localArtifactsDir)) + }) + }) + + Context("and the results file fails to open", func() { + BeforeEach(func() { + rw = &badResultWriter{errmsg: "some result writer error"} + }) + + It("should throw an error", func() { + err := PreflightCheck(context.TODO(), cfg, pc, eng, fmttr, rw, rs) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("some result writer error")) + }) + }) + + Context("with an engine that encounters an error while executing checks", func() { + var msg string + BeforeEach(func() { + msg = "some internal engine error" + eng = fakeCheckEngine{errorRunningChecks: true, errorMsg: msg} + }) + It("should thrown an error", func() { + err := PreflightCheck(context.TODO(), cfg, pc, eng, fmttr, rw, rs) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring(msg)) + }) + }) + + Context("with a formatter that cannot properly format the results", func() { + var msg string + BeforeEach(func() { + msg = "some error formatting results" + fmttr = &badFormatter{errormsg: msg} + }) + + It("should throw an error", func() { + err := PreflightCheck(context.TODO(), cfg, pc, eng, fmttr, rw, rs) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring(msg)) + }) + }) + + Context("and the user has requested JUnit output", func() { + BeforeEach(func() { + cfg.WriteJUnit = true + }) + It("should write a junit file in the artifacts directory", func() { + err := PreflightCheck(context.TODO(), cfg, pc, eng, fmttr, rw, rs) + Expect(err).ToNot(HaveOccurred()) + Expect(path.Join(artifacts.Path(), "results-junit.xml")).To(BeAnExistingFile()) + }) + }) + + Context("and submission encounters an error", func() { + var msg string + BeforeEach(func() { + msg = "some error submitting" + rs = &badResultSubmitter{errmsg: msg} + // TODO(): This is the package level variable, and isn't fantastic to have to evaluate in tests. + // It would make sense to rely solely on the cfg.Submit value instead of the global variable. + cfg.Submit = true + }) + + It("should throw an error", func() { + err := PreflightCheck(context.TODO(), cfg, pc, eng, fmttr, rw, rs) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring(msg)) + }) + }) + + Context("and there are no errors encountered in execution", func() { + BeforeEach(func() { + cfg.Submit = true + }) + + It("should complete with no errors", func() { + err := PreflightCheck(context.TODO(), cfg, pc, eng, fmttr, rw, rs) + Expect(err).ToNot(HaveOccurred()) + }) + }) + }) +}) diff --git a/internal/lib/types.go b/internal/lib/types.go new file mode 100644 index 000000000..e2d62ed71 --- /dev/null +++ b/internal/lib/types.go @@ -0,0 +1,236 @@ +package lib + +import ( + "context" + "fmt" + "io" + "net/http" + "os" + "path" + "path/filepath" + "time" + + "github.com/redhat-openshift-ecosystem/openshift-preflight/certification" + "github.com/redhat-openshift-ecosystem/openshift-preflight/certification/artifacts" + "github.com/redhat-openshift-ecosystem/openshift-preflight/certification/pyxis" + "github.com/spf13/viper" + + log "github.com/sirupsen/logrus" +) + +// ResultWriter defines methods associated with writing check results. +type ResultWriter interface { + OpenFile(name string) (io.WriteCloser, error) + io.WriteCloser +} + +// ResultSubmitter defines methods associated with submitting results to Red HAt. +type ResultSubmitter interface { + Submit(context.Context) error +} + +// PyxisClient defines pyxis API interactions that are relevant to check executions in cmd. +type PyxisClient interface { + FindImagesByDigest(ctx context.Context, digests []string) ([]pyxis.CertImage, error) + GetProject(context.Context) (*pyxis.CertProject, error) + SubmitResults(context.Context, *pyxis.CertificationInput) (*pyxis.CertificationResults, error) +} + +// newPyxisClient initializes a pyxisClient with relevant information from cfg. +// If the the CertificationProjectID, PyxisAPIToken, or PyxisHost are empty, then nil is returned. +// Callers should treat a nil pyxis client as an indicator that pyxis calls should not be made. +// +//nolint:unparam // ctx is unused. Keep for future use. +func NewPyxisClient(ctx context.Context, cfg certification.Config) PyxisClient { + if cfg.CertificationProjectID() == "" || cfg.PyxisAPIToken() == "" || cfg.PyxisHost() == "" { + return nil + } + + return pyxis.NewPyxisClient( + cfg.PyxisHost(), + cfg.PyxisAPIToken(), + cfg.CertificationProjectID(), + &http.Client{Timeout: 60 * time.Second}, + ) +} + +// ContainerCertificationSubmitter submits container results to Pyxis, and implements +// a ResultSubmitter. +type ContainerCertificationSubmitter struct { + CertificationProjectID string + Pyxis PyxisClient + DockerConfig string + PreflightLogFile string +} + +func (s *ContainerCertificationSubmitter) Submit(ctx context.Context) error { + log.Info("preparing results that will be submitted to Red Hat") + + // get the project info from pyxis + certProject, err := s.Pyxis.GetProject(ctx) + if err != nil { + return fmt.Errorf("could not retrieve project: %w", err) + } + + // Ensure that a certProject was returned. In theory we would expect pyxis + // to throw an error if no project is returned, but in the event that it doesn't + // we need to confirm before we proceed in order to prevent a runtime panic + // setting the DockerConfigJSON below. + if certProject == nil { + return fmt.Errorf("no certification project was returned from pyxis") + } + + log.Tracef("CertProject: %+v", certProject) + + // only read the dockerfile if the user provides a location for the file + // at this point in the flow, if `cfg.DockerConfig` is empty we know the repo is public and can continue the submission flow + if s.DockerConfig != "" { + dockerConfigJSONBytes, err := os.ReadFile(s.DockerConfig) + if err != nil { + return fmt.Errorf("could not open file for submission: %s: %w", + s.DockerConfig, + err, + ) + } + + certProject.Container.DockerConfigJSON = string(dockerConfigJSONBytes) + } + + // the below code is for the edge case where a partner has a DockerConfig in pyxis, but does not send one to preflight. + // when we call pyxis's GetProject API, we get back the DockerConfig as a PGP encrypted string and not JSON, + // if we were to send what pyixs just sent us in a update call, pyxis would throw a validation error saying it's not valid json + // the below code aims to set the DockerConfigJSON to an empty string, and since this field is `omitempty` when we marshall it + // we will not get a validation error + if s.DockerConfig == "" { + certProject.Container.DockerConfigJSON = "" + } + + // prepare submission. We ignore the error because nil checks for the certProject + // are done earlier to prevent panics, and that's the only error case for this function. + submission, _ := pyxis.NewCertificationInput(certProject) + + certImage, err := os.Open(path.Join(artifacts.Path(), certification.DefaultCertImageFilename)) + if err != nil { + return fmt.Errorf("could not open file for submission: %s: %w", + certification.DefaultCertImageFilename, + err, + ) + } + defer certImage.Close() + + preflightResults, err := os.Open(path.Join(artifacts.Path(), certification.DefaultTestResultsFilename)) + if err != nil { + return fmt.Errorf( + "could not open file for submission: %s: %w", + certification.DefaultTestResultsFilename, + err, + ) + } + defer preflightResults.Close() + + rpmManifest, err := os.Open(path.Join(artifacts.Path(), certification.DefaultRPMManifestFilename)) + if err != nil { + return fmt.Errorf( + "could not open file for submission: %s: %w", + certification.DefaultRPMManifestFilename, + err, + ) + } + defer rpmManifest.Close() + + logfile, err := os.Open(s.PreflightLogFile) + if err != nil { + return fmt.Errorf( + "could not open file for submission: %s: %w", + s.PreflightLogFile, + err, + ) + } + defer logfile.Close() + + submission. + // The engine writes the certified image config to disk in a Pyxis-specific format. + WithCertImage(certImage). + // Include Preflight's test results in our submission. pyxis.TestResults embeds them. + WithPreflightResults(preflightResults). + // The certification engine writes the rpmManifest for images not based on scratch. + WithRPMManifest(rpmManifest). + // Include the preflight execution log file. + WithArtifact(logfile, filepath.Base(s.PreflightLogFile)) + + input, err := submission.Finalize() + if err != nil { + return fmt.Errorf("unable to finalize data that would be sent to pyxis: %w", err) + } + + certResults, err := s.Pyxis.SubmitResults(ctx, input) + if err != nil { + return fmt.Errorf("could not submit to pyxis: %w", err) + } + + log.Info("Test results have been submitted to Red Hat.") + log.Info("These results will be reviewed by Red Hat for final certification.") + log.Infof("The container's image id is: %s.", certResults.CertImage.ID) + log.Infof("Please check %s to view scan results.", BuildScanResultsURL(s.CertificationProjectID, certResults.CertImage.ID)) + log.Infof("Please check %s to monitor the progress.", BuildOverviewURL(s.CertificationProjectID)) + + return nil +} + +// noopSubmitter is a no-op ResultSubmitter that optionally logs a message +// and a reason as to why results were not submitted. +type NoopSubmitter struct { + emitLog bool + reason string + log *log.Logger +} + +func NewNoopSubmitter(emitLog bool, reason string, log *log.Logger) *NoopSubmitter { + return &NoopSubmitter{ + emitLog: emitLog, + reason: reason, + log: log, + } +} + +var _ ResultSubmitter = &NoopSubmitter{} + +func (s *NoopSubmitter) Submit(ctx context.Context) error { + if s.emitLog { + msg := "Results are not being sent for submission." + if s.reason != "" { + msg = fmt.Sprintf("%s Reason: %s.", msg, s.reason) + } + + s.log.Info(msg) + } + + return nil +} + +func (s *NoopSubmitter) SetEmitLog(emitLog bool) { + s.emitLog = emitLog +} + +func (s *NoopSubmitter) SetReason(reason string) { + s.reason = reason +} + +func BuildConnectURL(projectID string) string { + connectURL := fmt.Sprintf("https://connect.redhat.com/projects/%s", projectID) + + pyxisEnv := viper.GetString("pyxis_env") + if len(pyxisEnv) > 0 && pyxisEnv != "prod" { + connectURL = fmt.Sprintf("https://connect.%s.redhat.com/projects/%s", viper.GetString("pyxis_env"), projectID) + } + + return connectURL +} + +func BuildOverviewURL(projectID string) string { + return fmt.Sprintf("%s/overview", BuildConnectURL(projectID)) +} + +func BuildScanResultsURL(projectID string, imageID string) string { + return fmt.Sprintf("%s/images/%s/scan-results", BuildConnectURL(projectID), imageID) +}