From f94905fe4f3d5b95a7340b9ace886e2d05f08bd4 Mon Sep 17 00:00:00 2001 From: Matheus Moraes Date: Thu, 10 Aug 2023 10:32:08 -0300 Subject: [PATCH] refactor worker --- cmd/worker/main.go | 24 +- go.mod | 1 + go.sum | 2 + pkg/plugins/cronjob.go | 14 +- pkg/worker/config.go | 49 +++ pkg/worker/config/config.go | 182 ----------- pkg/worker/config/config_test.go | 367 --------------------- pkg/worker/config_test.go | 112 +++++++ pkg/worker/parse.go | 89 +++++ pkg/worker/parse_test.go | 362 +++++++++++++++++++++ pkg/worker/report/marvin/parse.go | 13 +- pkg/worker/report/marvin/parse_test.go | 10 +- pkg/worker/report/parse.go | 90 ------ pkg/worker/report/parse_test.go | 395 ----------------------- pkg/worker/report/popeye/parse.go | 33 +- pkg/worker/report/popeye/parse_test.go | 18 +- pkg/worker/report/popeye/popeye_types.go | 8 +- pkg/worker/run.go | 93 ------ pkg/worker/run_test.go | 104 ------ pkg/worker/worker.go | 136 ++++++++ pkg/worker/worker_test.go | 174 ++++++++++ 21 files changed, 994 insertions(+), 1282 deletions(-) create mode 100644 pkg/worker/config.go delete mode 100644 pkg/worker/config/config.go delete mode 100644 pkg/worker/config/config_test.go create mode 100644 pkg/worker/config_test.go create mode 100644 pkg/worker/parse.go create mode 100644 pkg/worker/parse_test.go delete mode 100644 pkg/worker/report/parse.go delete mode 100644 pkg/worker/report/parse_test.go delete mode 100644 pkg/worker/run.go delete mode 100644 pkg/worker/run_test.go create mode 100644 pkg/worker/worker.go create mode 100644 pkg/worker/worker_test.go diff --git a/cmd/worker/main.go b/cmd/worker/main.go index a9d16174..83a615ef 100644 --- a/cmd/worker/main.go +++ b/cmd/worker/main.go @@ -15,30 +15,32 @@ package main import ( + "context" + "flag" + "os" "time" + "github.com/go-logr/logr" "go.uber.org/zap/zapcore" - ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/log/zap" "github.com/undistro/zora/pkg/worker" ) -var log = ctrl.Log.WithName("worker") - func main() { opts := zap.Options{ Development: true, TimeEncoder: zapcore.TimeEncoderOfLayout(time.RFC3339), } - ctrl.SetLogger(zap.New(zap.UseFlagOptions(&opts))) - - log.Info("Starting worker") - if err := worker.Run(log); err != nil { - log.Info("Worker crashed") - panic(err) + opts.BindFlags(flag.CommandLine) + log := zap.New(zap.UseFlagOptions(&opts)).WithName("worker") + ctx := logr.NewContext(context.Background(), log) + + log.Info("starting worker") + if err := worker.Run(ctx); err != nil { + log.Error(err, "failed to run worker") + os.Exit(1) } - log.Info("Worker finished successfully") - log.Info("Stopping worker") + log.Info("worker finished successfully") } diff --git a/go.mod b/go.mod index 446309b9..ccd75180 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/undistro/zora go 1.20 require ( + github.com/caarlos0/env/v9 v9.0.0 github.com/go-logr/logr v1.2.4 github.com/google/go-cmp v0.5.9 github.com/onsi/ginkgo/v2 v2.9.5 diff --git a/go.sum b/go.sum index 548f3288..7ad55c40 100644 --- a/go.sum +++ b/go.sum @@ -6,6 +6,8 @@ github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLj github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/caarlos0/env/v9 v9.0.0 h1:SI6JNsOA+y5gj9njpgybykATIylrRMklbs5ch6wO6pc= +github.com/caarlos0/env/v9 v9.0.0/go.mod h1:ye5mlCVMYh6tZ+vCgrs/B95sj88cg5Tlnc0XIzgZ020= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= diff --git a/pkg/plugins/cronjob.go b/pkg/plugins/cronjob.go index 3ea5947a..cac76fbb 100644 --- a/pkg/plugins/cronjob.go +++ b/pkg/plugins/cronjob.go @@ -48,6 +48,14 @@ var ( Name: "DONE_DIR", Value: resultsDir, }, + { + Name: "DONE_FILE", + Value: filepath.Join(resultsDir, "done"), + }, + { + Name: "ERROR_FILE", + Value: filepath.Join(resultsDir, "error"), + }, } // commonVolumeMounts represents the volume mounts to be used in worker and plugin containers commonVolumeMounts = []corev1.VolumeMount{ @@ -259,8 +267,10 @@ func (r *CronJobMutator) workerEnv() []corev1.EnvVar { Value: r.ClusterScan.Spec.ClusterRef.Name, }, corev1.EnvVar{ - Name: "CLUSTER_ISSUES_NAMESPACE", - Value: r.ClusterScan.Namespace, + Name: "NAMESPACE", + ValueFrom: &corev1.EnvVarSource{ + FieldRef: &corev1.ObjectFieldSelector{FieldPath: "metadata.namespace", APIVersion: "v1"}, + }, }, corev1.EnvVar{ Name: "PLUGIN_NAME", diff --git a/pkg/worker/config.go b/pkg/worker/config.go new file mode 100644 index 00000000..0c5e8cda --- /dev/null +++ b/pkg/worker/config.go @@ -0,0 +1,49 @@ +// Copyright 2023 Undistro 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 worker + +import ( + "strings" + "time" + + "github.com/caarlos0/env/v9" +) + +// config represents worker configuration +type config struct { + DoneFile string `env:"DONE_FILE" envDefault:"/tmp/zora/results/done"` + ErrorFile string `env:"ERROR_FILE" envDefault:"/tmp/zora/results/error"` + PluginName string `env:"PLUGIN_NAME,required"` + ClusterName string `env:"CLUSTER_NAME,required"` + Namespace string `env:"NAMESPACE,required"` + JobName string `env:"JOB_NAME,required"` + JobUID string `env:"JOB_UID,required"` + PodName string `env:"POD_NAME,required"` + WaitInterval time.Duration `env:"WAIT_INTERVAL" envDefault:"1s"` + + suffix string +} + +// configFromEnv returns a config from environment variables +func configFromEnv() (*config, error) { + cfg := &config{} + if err := env.Parse(cfg); err != nil { + return nil, err + } + if i := strings.LastIndex(cfg.PodName, "-"); i > 0 && len(cfg.PodName) > i+1 { + cfg.suffix = cfg.PodName[i+1:] + } + return cfg, nil +} diff --git a/pkg/worker/config/config.go b/pkg/worker/config/config.go deleted file mode 100644 index 94bbfb18..00000000 --- a/pkg/worker/config/config.go +++ /dev/null @@ -1,182 +0,0 @@ -// Copyright 2022 Undistro 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 config - -import ( - "errors" - "fmt" - "io" - "os" - "path" - "strings" - - "github.com/go-logr/logr" - - zorav1a1 "github.com/undistro/zora/api/zora/v1alpha1" - "github.com/undistro/zora/pkg/worker/report/marvin" - "github.com/undistro/zora/pkg/worker/report/popeye" -) - -const ( - DefaultDoneDir = "/tmp/zora/results" - DoneDirEnvVar = "DONE_DIR" - PluginEnvVar = "PLUGIN_NAME" - ClusterEnvVar = "CLUSTER_NAME" - ClusterIssuesNsEnvVar = "CLUSTER_ISSUES_NAMESPACE" - JobEnvVar = "JOB_NAME" - JobUIDEnvVar = "JOB_UID" -) - -// PluginParsers correlates plugins with their respective parsing functions. -var PluginParsers = map[string]func(logr.Logger, []byte) ([]*zorav1a1.ClusterIssueSpec, error){ - "popeye": popeye.Parse, - "marvin": marvin.Parse, -} - -// Config stores information used by the worker to create a list of -// instances, and to specify the "done" file path. -type Config struct { - DonePath string - ErrorPath string - Plugin string - Cluster string - ClusterIssuesNs string - Job string - JobUID string -} - -// New instantiates a new struct, with the default path for the -// "done" file. -func New() *Config { - return &Config{ - DonePath: fmt.Sprintf("%s/done", DefaultDoneDir), - ErrorPath: fmt.Sprintf("%s/error", DefaultDoneDir), - } -} - -// FromEnv instantiates a new struct, with values taken from the -// environment. It'll return an error in case cluster related variables aren't -// found. -func FromEnv() (*Config, error) { - c := New() - if e := os.Getenv(PluginEnvVar); len(e) != 0 { - c.Plugin = e - } else { - return nil, fmt.Errorf("Empty environment variable <%s>", PluginEnvVar) - } - if e := os.Getenv(ClusterEnvVar); len(e) != 0 { - c.Cluster = e - } else { - return nil, fmt.Errorf("Empty environment variable <%s>", ClusterEnvVar) - } - if e := os.Getenv(ClusterIssuesNsEnvVar); len(e) != 0 { - c.ClusterIssuesNs = e - } else { - return nil, fmt.Errorf("Empty environment variable <%s>", ClusterIssuesNsEnvVar) - } - - if e := os.Getenv(JobEnvVar); len(e) != 0 { - c.Job = e - } else { - return nil, fmt.Errorf("Empty environment variable <%s>", JobEnvVar) - } - if e := os.Getenv(JobUIDEnvVar); len(e) != 0 { - c.JobUID = e - } else { - return nil, fmt.Errorf("Empty environment variable <%s>", JobUIDEnvVar) - } - - if e := os.Getenv(DoneDirEnvVar); len(e) != 0 { - c.DonePath = fmt.Sprintf("%s/done", e) - c.ErrorPath = fmt.Sprintf("%s/error", e) - } - return c, nil -} - -// Validate ensures a instance has all its fields populated, and the -// plugin specified is supported by the worker. -func (r *Config) Validate() error { - if len(r.DonePath) == 0 { - return errors.New("Config's field is empty") - } - if len(r.ErrorPath) == 0 { - return errors.New("Config's field is empty") - } - if len(r.Cluster) == 0 { - return errors.New("Config's field is empty") - } - if len(r.ClusterIssuesNs) == 0 { - return errors.New("Config's field is empty") - } - if len(r.Plugin) == 0 { - return errors.New("Config's field is empty") - } - - if len(r.Job) == 0 { - return errors.New("Config's field is empty") - } - if len(r.JobUID) == 0 { - return errors.New("Config's field is empty") - } else if i := strings.LastIndex(r.JobUID, "-"); i == -1 || i == len(r.JobUID)-1 { - return errors.New("Config's field is invalid") - } - - if _, ok := PluginParsers[r.Plugin]; !ok { - return fmt.Errorf("Invalid plugin: <%s>", r.Plugin) - } - return nil -} - -// HandleDonePath ensures the directory wherefrom the "done" file will be -// written exists. -func (r *Config) HandleDonePath() error { - if len(r.DonePath) == 0 { - return errors.New("Empty ") - } - - dir := path.Dir(r.DonePath) - if _, err := os.Stat(dir); err != nil && !os.IsNotExist(err) { - return fmt.Errorf("Unable to check existance of dir <%s>: %w", dir, err) - } - if err := os.MkdirAll(dir, 0755); err != nil { - return fmt.Errorf("Unable to create results dir <%s>: %w", dir, err) - } - return nil -} - -// HandleResultsPath returns an pointing to the path inside the -// "done" file. -func (r *Config) HandleResultsPath() (io.Reader, error) { - fiby, err := os.ReadFile(r.DonePath) - if err != nil { - return nil, errors.New("Unable to read 'done' file") - } - if len(fiby) == 0 { - return nil, errors.New("Empty 'done' file") - } - - fid, err := os.Open(strings.TrimSpace(string(fiby))) - if err != nil { - return nil, fmt.Errorf("Unable to open 'done' file: %w", err) - } - finf, err := fid.Stat() - if err != nil { - return nil, fmt.Errorf("Invalid path in 'done' file: %w", err) - } - if finf.IsDir() { - return nil, errors.New("Path in the 'done' file points to a directory") - } - return fid, nil -} diff --git a/pkg/worker/config/config_test.go b/pkg/worker/config/config_test.go deleted file mode 100644 index e4366ea5..00000000 --- a/pkg/worker/config/config_test.go +++ /dev/null @@ -1,367 +0,0 @@ -// Copyright 2022 Undistro 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 config - -import ( - "fmt" - "os" - "path" - "strings" - "testing" - - "github.com/google/go-cmp/cmp" -) - -func TestFromEnv(t *testing.T) { - cases := []struct { - description string - env map[string]string - config *Config - toerr bool - }{ - { - description: "Full config with default 'done' path", - env: map[string]string{ - PluginEnvVar: "fake_plugin", - ClusterEnvVar: "fake_cluster", - ClusterIssuesNsEnvVar: "fake_ns", - JobEnvVar: "fake_job", - JobUIDEnvVar: "fake_job_uid-666-666", - }, - config: &Config{ - DonePath: fmt.Sprintf("%s/done", DefaultDoneDir), - ErrorPath: fmt.Sprintf("%s/error", DefaultDoneDir), - Plugin: "fake_plugin", - Cluster: "fake_cluster", - ClusterIssuesNs: "fake_ns", - Job: "fake_job", - JobUID: "fake_job_uid-666-666", - }, - toerr: false, - }, - { - description: "Config missing pod and job fields", - env: map[string]string{ - PluginEnvVar: "fake_plugin", - ClusterEnvVar: "fake_cluster", - ClusterIssuesNsEnvVar: "fake_ns", - }, - config: nil, - toerr: true, - }, - { - description: "Config missing cluster fields", - env: map[string]string{ - DoneDirEnvVar: fmt.Sprintf("%s/done", DefaultDoneDir), - PluginEnvVar: "fake_plugin", - JobEnvVar: "fake_job", - JobUIDEnvVar: "fake_job_uid-666-666", - }, - config: nil, - toerr: true, - }, - { - description: "Complete config with custom 'done' path", - env: map[string]string{ - DoneDirEnvVar: "/tmp/fake", - PluginEnvVar: "fake_plugin", - ClusterEnvVar: "fake_cluster", - ClusterIssuesNsEnvVar: "fake_ns", - JobEnvVar: "fake_job", - JobUIDEnvVar: "fake_job_uid-666-666", - }, - config: &Config{ - DonePath: "/tmp/fake/done", - ErrorPath: "/tmp/fake/error", - Plugin: "fake_plugin", - Cluster: "fake_cluster", - ClusterIssuesNs: "fake_ns", - Job: "fake_job", - JobUID: "fake_job_uid-666-666", - }, - toerr: false, - }, - } - - for _, c := range cases { - os.Clearenv() - for e, v := range c.env { - if err := os.Setenv(e, v); err != nil { - t.Errorf("Setup failed on case: %s\n", c.description) - t.Fatal(err) - } - } - if config, err := FromEnv(); c.config != config && c.toerr { - if err != nil { - t.Error(err) - } - t.Errorf("Setup failed on case: %s\n", c.description) - t.Errorf("Mismatch between expected and obtained values: \n%s\n", cmp.Diff(c.config, config)) - } - } -} - -func TestValidate(t *testing.T) { - cases := []struct { - description string - config *Config - toerr bool - }{ - { - description: "Full config with default 'done' path", - config: &Config{ - DonePath: fmt.Sprintf("%s/done", DefaultDoneDir), - ErrorPath: fmt.Sprintf("%s/error", DefaultDoneDir), - Plugin: "popeye", - Cluster: "fake_cluster", - ClusterIssuesNs: "fake_ns", - Job: "fake_job", - JobUID: "fake_job_uid-666-666", - }, - toerr: false, - }, - { - description: "Config missing pod field", - config: &Config{ - DonePath: fmt.Sprintf("%s/done", DefaultDoneDir), - ErrorPath: fmt.Sprintf("%s/error", DefaultDoneDir), - Plugin: "popeye", - Cluster: "fake_cluster", - ClusterIssuesNs: "fake_ns", - Job: "fake_job-666-666", - JobUID: "fake_job_uid", - }, - toerr: true, - }, - { - description: "Config missing cluster fields", - config: &Config{ - DonePath: fmt.Sprintf("%s/done", DefaultDoneDir), - ErrorPath: fmt.Sprintf("%s/error", DefaultDoneDir), - Plugin: "popeye", - Job: "fake_job-666-666", - JobUID: "fake_job_uid", - }, - toerr: true, - }, - { - description: "Config missing field", - config: &Config{ - DonePath: "/tmp/fake/done", - Plugin: "unsupported_plugin", - Cluster: "fake_cluster", - ClusterIssuesNs: "fake_ns", - Job: "fake_job-666-666", - JobUID: "fake_job_uid", - }, - toerr: true, - }, - { - description: "Config missing field", - config: &Config{ - ErrorPath: "/tmp/fake/error", - Plugin: "unsupported_plugin", - Cluster: "fake_cluster", - ClusterIssuesNs: "fake_ns", - Job: "fake_job-666-666", - JobUID: "fake_job_uid", - }, - toerr: true, - }, - { - description: "Unsupported plugin in config", - config: &Config{ - DonePath: "/tmp/fake/done", - ErrorPath: "/tmp/fake/error", - Plugin: "unsupported_plugin", - Cluster: "fake_cluster", - ClusterIssuesNs: "fake_ns", - Job: "fake_job-666-666", - JobUID: "fake_job_uid", - }, - toerr: true, - }, - { - description: "Invalid Job format", - config: &Config{ - DonePath: fmt.Sprintf("%s/done", DefaultDoneDir), - ErrorPath: fmt.Sprintf("%s/error", DefaultDoneDir), - Plugin: "popeye", - Cluster: "fake_cluster", - ClusterIssuesNs: "fake_ns", - Job: "totally_fake_job", - JobUID: "fake_job_uid", - }, - toerr: true, - }, - } - - for _, c := range cases { - if err := c.config.Validate(); err != nil && !c.toerr { - t.Errorf("Setup failed on case: %s\n", c.description) - t.Error(err) - } - } -} - -func TestHandleDonePath(t *testing.T) { - cases := []struct { - description string - config *Config - donedirmode os.FileMode - toerr bool - }{ - { - description: "Empty 'done' path", - config: &Config{}, - toerr: true, - }, - { - description: "Path for 'done' file is created", - config: New(), - toerr: false, - }, - { - description: "Path for 'done' file already exists", - config: New(), - donedirmode: os.FileMode(0755), - toerr: false, - }, - { - description: "Unable to check for existence of 'done' path", - config: New(), - donedirmode: os.FileMode(0111), - toerr: true, - }, - } - - for _, c := range cases { - if c.donedirmode != 0 { - if err := os.MkdirAll(path.Dir(c.config.DonePath), c.donedirmode); err != nil { - t.Errorf("Setup failed on case: %s\n", c.description) - t.Fatal(err) - } - } - if err := c.config.HandleDonePath(); err != nil && !c.toerr { - t.Errorf("Setup failed on case: %s\n", c.description) - t.Error(err) - } - if c.donedirmode != 0 { - if err := os.RemoveAll(path.Dir(c.config.DonePath)); err != nil { - t.Fatal(err) - } - } - } -} - -func TestHandleResultsPath(t *testing.T) { - type fakeresults struct { - path string - isdir bool - mode os.FileMode - } - cases := []struct { - description string - config *Config - results *fakeresults - toerr bool - }{ - { - description: "Inexistent 'done' file", - config: &Config{ - DonePath: "/no/way/this/path/exists/666/done", - }, - toerr: true, - }, - { - description: "Empty 'done' file", - config: New(), - results: &fakeresults{ - mode: os.FileMode(0644), - }, - toerr: true, - }, - { - description: "White space in 'done' file", - config: New(), - results: &fakeresults{ - path: "\t /tmp/fake_results.txt \t \t", - mode: os.FileMode(0644), - }, - toerr: false, - }, - { - description: "No read permission for results file", - config: New(), - results: &fakeresults{ - path: "/tmp/fake_results.txt", - mode: os.FileMode(0000), - }, - toerr: true, - }, - { - description: "Results path is a directory", - config: New(), - results: &fakeresults{ - path: "/tmp/fake_results", - mode: os.FileMode(0550), - isdir: true, - }, - toerr: true, - }, - } - - for _, c := range cases { - if c.results != nil { - if err := os.MkdirAll(path.Dir(c.config.DonePath), 0755); err != nil { - t.Errorf("Setup failed on case: %s\n", c.description) - t.Fatal(err) - } - if err := os.WriteFile(c.config.DonePath, []byte(c.results.path), 0644); err != nil { - t.Errorf("Setup failed on case: %s\n", c.description) - t.Fatal(err) - } - if len(c.results.path) != 0 { - if c.results.isdir { - if err := os.MkdirAll(c.results.path, 0755); err != nil { - t.Errorf("Setup failed on case: %s\n", c.description) - t.Fatal(err) - } - } else { - if err := os.WriteFile(strings.TrimSpace(c.results.path), []byte{}, c.results.mode); err != nil { - t.Errorf("Setup failed on case: %s\n", c.description) - t.Fatal(err) - } - } - } - } - - if _, err := c.config.HandleResultsPath(); (err != nil) != c.toerr { - t.Errorf("Case: %s\n", c.description) - t.Error(err) - } - - if c.results != nil { - if err := os.RemoveAll(path.Dir(c.config.DonePath)); err != nil { - t.Errorf("Setup failed on case: %s\n", c.description) - t.Fatal(err) - } - if err := os.RemoveAll(strings.TrimSpace(c.results.path)); err != nil { - t.Errorf("Setup failed on case: %s\n", c.description) - t.Fatal(err) - } - } - } -} diff --git a/pkg/worker/config_test.go b/pkg/worker/config_test.go new file mode 100644 index 00000000..5f9d2e20 --- /dev/null +++ b/pkg/worker/config_test.go @@ -0,0 +1,112 @@ +// Copyright 2023 Undistro 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 worker + +import ( + "reflect" + "testing" + "time" +) + +func TestConfigFromEnv(t *testing.T) { + tests := []struct { + name string + env map[string]string + want *config + wantErr bool + }{ + { + name: "empty", + env: nil, + wantErr: true, + }, + { + name: "required only", + env: map[string]string{ + "PLUGIN_NAME": "plugin", + "CLUSTER_NAME": "cluster", + "NAMESPACE": "ns", + "JOB_NAME": "cluster-plugin-28140229", + "JOB_UID": "50c8957e-c9e1-493a-9fa4-d0786deea017", + "POD_NAME": "cluster-plugin-28140229-h9kcn", + }, + want: &config{ + DoneFile: "/tmp/zora/results/done", + ErrorFile: "/tmp/zora/results/error", + PluginName: "plugin", + ClusterName: "cluster", + Namespace: "ns", + JobName: "cluster-plugin-28140229", + JobUID: "50c8957e-c9e1-493a-9fa4-d0786deea017", + PodName: "cluster-plugin-28140229-h9kcn", + WaitInterval: time.Second, + suffix: "h9kcn", + }, + }, + { + name: "one required env missing", + env: map[string]string{ + //"PLUGIN_NAME": "plugin", + "CLUSTER_NAME": "cluster", + "NAMESPACE": "ns", + "JOB_NAME": "cluster-plugin-28140229", + "JOB_UID": "50c8957e-c9e1-493a-9fa4-d0786deea017", + "POD_NAME": "cluster-plugin-28140229-h9kcn", + }, + wantErr: true, + }, + { + name: "all", + env: map[string]string{ + "PLUGIN_NAME": "plugin", + "CLUSTER_NAME": "cluster", + "NAMESPACE": "ns", + "JOB_NAME": "cluster-plugin-28140229", + "JOB_UID": "50c8957e-c9e1-493a-9fa4-d0786deea017", + "POD_NAME": "cluster-plugin-28140229-h9kcn", + "DONE_FILE": "/done", + "ERROR_FILE": "/error", + "WAIT_INTERVAL": "5s", + }, + want: &config{ + DoneFile: "/done", + ErrorFile: "/error", + PluginName: "plugin", + ClusterName: "cluster", + Namespace: "ns", + JobName: "cluster-plugin-28140229", + JobUID: "50c8957e-c9e1-493a-9fa4-d0786deea017", + PodName: "cluster-plugin-28140229-h9kcn", + WaitInterval: 5 * time.Second, + suffix: "h9kcn", + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + for k, v := range tt.env { + t.Setenv(k, v) + } + got, err := configFromEnv() + if (err != nil) != tt.wantErr { + t.Errorf("configFromEnv() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("configFromEnv() got = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/pkg/worker/parse.go b/pkg/worker/parse.go new file mode 100644 index 00000000..f5864703 --- /dev/null +++ b/pkg/worker/parse.go @@ -0,0 +1,89 @@ +// Copyright 2023 Undistro 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 worker + +import ( + "context" + "errors" + "fmt" + "io" + "strconv" + "strings" + + batchv1 "k8s.io/api/batch/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + + "github.com/undistro/zora/api/zora/v1alpha1" + "github.com/undistro/zora/pkg/worker/report/marvin" + "github.com/undistro/zora/pkg/worker/report/popeye" +) + +// pluginParsers maps parse function by plugin name +var pluginParsers = map[string]func(ctx context.Context, reader io.Reader) ([]v1alpha1.ClusterIssueSpec, error){ + "popeye": popeye.Parse, + "marvin": marvin.Parse, +} + +var clusterIssueTypeMeta = metav1.TypeMeta{ + Kind: "ClusterIssue", + APIVersion: v1alpha1.SchemeGroupVersion.String(), +} + +// parseResults converts the given results into ClusterIssues +func parseResults(ctx context.Context, cfg *config, results io.Reader) ([]v1alpha1.ClusterIssue, error) { + parseFunc, ok := pluginParsers[cfg.PluginName] + if !ok { + return nil, errors.New(fmt.Sprintf("invalid plugin %q", cfg.PluginName)) + } + specs, err := parseFunc(ctx, results) + if err != nil { + return nil, fmt.Errorf("failed to parse %q results: %v", cfg.PluginName, err) + } + owner := metav1.OwnerReference{ + APIVersion: batchv1.SchemeGroupVersion.String(), + Kind: "Job", + Name: cfg.JobName, + UID: types.UID(cfg.JobUID), + } + issues := make([]v1alpha1.ClusterIssue, len(specs)) + for i := 0; i < len(specs); i++ { + issues[i] = newClusterIssue(cfg, specs[i], owner) + } + return issues, nil +} + +// newClusterIssue returns a new ClusterIssue from the given config, spec, and owner +func newClusterIssue(cfg *config, spec v1alpha1.ClusterIssueSpec, owner metav1.OwnerReference) v1alpha1.ClusterIssue { + spec.Cluster = cfg.ClusterName + return v1alpha1.ClusterIssue{ + TypeMeta: clusterIssueTypeMeta, + ObjectMeta: metav1.ObjectMeta{ + Name: fmt.Sprintf("%s-%s-%s", cfg.ClusterName, strings.ToLower(spec.ID), cfg.suffix), + Namespace: cfg.Namespace, + OwnerReferences: []metav1.OwnerReference{owner}, + Labels: map[string]string{ + v1alpha1.LabelScanID: cfg.JobUID, + v1alpha1.LabelCluster: cfg.ClusterName, + v1alpha1.LabelSeverity: string(spec.Severity), + v1alpha1.LabelIssueID: spec.ID, + v1alpha1.LabelCategory: strings.ReplaceAll(spec.Category, " ", ""), + v1alpha1.LabelPlugin: cfg.PluginName, + v1alpha1.LabelCustom: strconv.FormatBool(spec.Custom), + }, + }, + Spec: spec, + } +} diff --git a/pkg/worker/parse_test.go b/pkg/worker/parse_test.go new file mode 100644 index 00000000..c22e376c --- /dev/null +++ b/pkg/worker/parse_test.go @@ -0,0 +1,362 @@ +// Copyright 2023 Undistro 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 worker + +import ( + "context" + "io" + "os" + "reflect" + "sort" + "testing" + + "github.com/google/go-cmp/cmp" + batchv1 "k8s.io/api/batch/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/undistro/zora/api/zora/v1alpha1" +) + +func TestParseResults(t *testing.T) { + type args struct { + cfg *config + filename string + } + tests := []struct { + name string + args args + want []v1alpha1.ClusterIssue + wantErr bool + }{ + { + name: "invalid plugin", + args: args{cfg: &config{PluginName: "foo"}}, + want: nil, + wantErr: true, + }, + { + name: "reader of a directory", + args: args{ + cfg: &config{PluginName: "marvin"}, + filename: t.TempDir(), + }, + want: nil, + wantErr: true, + }, + { + name: "marvin", + args: args{ + cfg: &config{ + PluginName: "marvin", + ClusterName: "cluster", + Namespace: "ns", + JobName: "cluster-marvin-28140229", + JobUID: "50c8957e-c9e1-493a-9fa4-d0786deea017", + PodName: "cluster-marvin-28140229-h9kcn", + suffix: "h9kcn", + }, + filename: "report/marvin/testdata/httpbin.json", + }, + want: []v1alpha1.ClusterIssue{ + { + TypeMeta: clusterIssueTypeMeta, + ObjectMeta: metav1.ObjectMeta{ + Name: "cluster-m-400-h9kcn", + Namespace: "ns", + OwnerReferences: []metav1.OwnerReference{ + { + APIVersion: batchv1.SchemeGroupVersion.String(), + Kind: "Job", + Name: "cluster-marvin-28140229", + UID: "50c8957e-c9e1-493a-9fa4-d0786deea017", + }, + }, + Labels: map[string]string{ + v1alpha1.LabelScanID: "50c8957e-c9e1-493a-9fa4-d0786deea017", + v1alpha1.LabelCluster: "cluster", + v1alpha1.LabelSeverity: string(v1alpha1.SeverityMedium), + v1alpha1.LabelIssueID: "M-400", + v1alpha1.LabelCategory: "BestPractices", + v1alpha1.LabelPlugin: "marvin", + v1alpha1.LabelCustom: "false", + }, + }, + Spec: v1alpha1.ClusterIssueSpec{ + Cluster: "cluster", + ID: "M-400", + Message: "Image tagged latest", + Severity: v1alpha1.SeverityMedium, + Category: "Best Practices", + Resources: map[string][]string{ + "apps/v1/deployments": {"httpbin/httpbin"}, + "apps/v1/replicasets": {"httpbin/httpbin-5978c9d878"}, + }, + Url: "https://kubernetes.io/docs/concepts/containers/images/#image-names", + }, + }, + { + TypeMeta: clusterIssueTypeMeta, + ObjectMeta: metav1.ObjectMeta{ + Name: "cluster-m-407-h9kcn", + Namespace: "ns", + OwnerReferences: []metav1.OwnerReference{ + { + APIVersion: batchv1.SchemeGroupVersion.String(), + Kind: "Job", + Name: "cluster-marvin-28140229", + UID: "50c8957e-c9e1-493a-9fa4-d0786deea017", + }, + }, + Labels: map[string]string{ + v1alpha1.LabelScanID: "50c8957e-c9e1-493a-9fa4-d0786deea017", + v1alpha1.LabelCluster: "cluster", + v1alpha1.LabelSeverity: string(v1alpha1.SeverityMedium), + v1alpha1.LabelIssueID: "M-407", + v1alpha1.LabelCategory: "Reliability", + v1alpha1.LabelPlugin: "marvin", + v1alpha1.LabelCustom: "false", + }, + }, + Spec: v1alpha1.ClusterIssueSpec{ + Cluster: "cluster", + ID: "M-407", + Message: "CPU not limited", + Severity: v1alpha1.SeverityMedium, + Category: "Reliability", + Resources: map[string][]string{ + "apps/v1/deployments": {"httpbin/httpbin"}, + "apps/v1/replicasets": {"httpbin/httpbin-5978c9d878"}, + }, + Url: "https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/", + }, + }, + { + TypeMeta: clusterIssueTypeMeta, + ObjectMeta: metav1.ObjectMeta{ + Name: "cluster-m-116-h9kcn", + Namespace: "ns", + OwnerReferences: []metav1.OwnerReference{ + { + APIVersion: batchv1.SchemeGroupVersion.String(), + Kind: "Job", + Name: "cluster-marvin-28140229", + UID: "50c8957e-c9e1-493a-9fa4-d0786deea017", + }, + }, + Labels: map[string]string{ + v1alpha1.LabelScanID: "50c8957e-c9e1-493a-9fa4-d0786deea017", + v1alpha1.LabelCluster: "cluster", + v1alpha1.LabelSeverity: string(v1alpha1.SeverityLow), + v1alpha1.LabelIssueID: "M-116", + v1alpha1.LabelCategory: "Security", + v1alpha1.LabelPlugin: "marvin", + v1alpha1.LabelCustom: "false", + }, + }, + Spec: v1alpha1.ClusterIssueSpec{ + Cluster: "cluster", + ID: "M-116", + Message: "Not allowed added/dropped capabilities", + Severity: v1alpha1.SeverityLow, + Category: "Security", + Resources: map[string][]string{ + "apps/v1/deployments": {"httpbin/httpbin"}, + "apps/v1/replicasets": {"httpbin/httpbin-5978c9d878"}, + }, + Url: "https://kubernetes.io/docs/concepts/security/pod-security-standards/#restricted", + }, + }, + { + TypeMeta: clusterIssueTypeMeta, + ObjectMeta: metav1.ObjectMeta{ + Name: "cluster-m-113-h9kcn", + Namespace: "ns", + OwnerReferences: []metav1.OwnerReference{ + { + APIVersion: batchv1.SchemeGroupVersion.String(), + Kind: "Job", + Name: "cluster-marvin-28140229", + UID: "50c8957e-c9e1-493a-9fa4-d0786deea017", + }, + }, + Labels: map[string]string{ + v1alpha1.LabelScanID: "50c8957e-c9e1-493a-9fa4-d0786deea017", + v1alpha1.LabelCluster: "cluster", + v1alpha1.LabelSeverity: string(v1alpha1.SeverityMedium), + v1alpha1.LabelIssueID: "M-113", + v1alpha1.LabelCategory: "Security", + v1alpha1.LabelPlugin: "marvin", + v1alpha1.LabelCustom: "false", + }, + }, + Spec: v1alpha1.ClusterIssueSpec{ + Cluster: "cluster", + ID: "M-113", + Message: "Container could be running as root user", + Severity: v1alpha1.SeverityMedium, + Category: "Security", + Resources: map[string][]string{ + "apps/v1/deployments": {"httpbin/httpbin"}, + "apps/v1/replicasets": {"httpbin/httpbin-5978c9d878", "httpbin/httpbin-6089d0e989"}, + }, + Url: "https://kubernetes.io/docs/concepts/security/pod-security-standards/#restricted", + }, + }, + { + TypeMeta: clusterIssueTypeMeta, + ObjectMeta: metav1.ObjectMeta{ + Name: "cluster-m-115-h9kcn", + Namespace: "ns", + OwnerReferences: []metav1.OwnerReference{ + { + APIVersion: batchv1.SchemeGroupVersion.String(), + Kind: "Job", + Name: "cluster-marvin-28140229", + UID: "50c8957e-c9e1-493a-9fa4-d0786deea017", + }, + }, + Labels: map[string]string{ + v1alpha1.LabelScanID: "50c8957e-c9e1-493a-9fa4-d0786deea017", + v1alpha1.LabelCluster: "cluster", + v1alpha1.LabelSeverity: string(v1alpha1.SeverityLow), + v1alpha1.LabelIssueID: "M-115", + v1alpha1.LabelCategory: "Security", + v1alpha1.LabelPlugin: "marvin", + v1alpha1.LabelCustom: "false", + }, + }, + Spec: v1alpha1.ClusterIssueSpec{ + Cluster: "cluster", + ID: "M-115", + Message: "Not allowed seccomp profile", + Severity: v1alpha1.SeverityLow, + Category: "Security", + Resources: map[string][]string{ + "apps/v1/deployments": {"httpbin/httpbin"}, + "apps/v1/replicasets": {"httpbin/httpbin-5978c9d878"}, + }, + Url: "https://kubernetes.io/docs/concepts/security/pod-security-standards/#restricted", + }, + }, + { + TypeMeta: clusterIssueTypeMeta, + ObjectMeta: metav1.ObjectMeta{ + Name: "cluster-m-202-h9kcn", + Namespace: "ns", + OwnerReferences: []metav1.OwnerReference{ + { + APIVersion: batchv1.SchemeGroupVersion.String(), + Kind: "Job", + Name: "cluster-marvin-28140229", + UID: "50c8957e-c9e1-493a-9fa4-d0786deea017", + }, + }, + Labels: map[string]string{ + v1alpha1.LabelScanID: "50c8957e-c9e1-493a-9fa4-d0786deea017", + v1alpha1.LabelCluster: "cluster", + v1alpha1.LabelSeverity: string(v1alpha1.SeverityLow), + v1alpha1.LabelIssueID: "M-202", + v1alpha1.LabelCategory: "Security", + v1alpha1.LabelPlugin: "marvin", + v1alpha1.LabelCustom: "false", + }, + }, + Spec: v1alpha1.ClusterIssueSpec{ + Cluster: "cluster", + ID: "M-202", + Message: "Automounted service account token", + Severity: v1alpha1.SeverityLow, + Category: "Security", + Resources: map[string][]string{ + "apps/v1/deployments": {"httpbin/httpbin"}, + "apps/v1/replicasets": {"httpbin/httpbin-5978c9d878"}, + }, + Url: "https://microsoft.github.io/Threat-Matrix-for-Kubernetes/mitigations/MS-M9025%20Disable%20Service%20Account%20Auto%20Mount/", + }, + }, + { + TypeMeta: clusterIssueTypeMeta, + ObjectMeta: metav1.ObjectMeta{ + Name: "cluster-m-300-h9kcn", + Namespace: "ns", + OwnerReferences: []metav1.OwnerReference{ + { + APIVersion: batchv1.SchemeGroupVersion.String(), + Kind: "Job", + Name: "cluster-marvin-28140229", + UID: "50c8957e-c9e1-493a-9fa4-d0786deea017", + }, + }, + Labels: map[string]string{ + v1alpha1.LabelScanID: "50c8957e-c9e1-493a-9fa4-d0786deea017", + v1alpha1.LabelCluster: "cluster", + v1alpha1.LabelSeverity: string(v1alpha1.SeverityLow), + v1alpha1.LabelIssueID: "M-300", + v1alpha1.LabelCategory: "Security", + v1alpha1.LabelPlugin: "marvin", + v1alpha1.LabelCustom: "false", + }, + }, + Spec: v1alpha1.ClusterIssueSpec{ + Cluster: "cluster", + ID: "M-300", + Message: "Root filesystem write allowed", + Severity: v1alpha1.SeverityLow, + Category: "Security", + Resources: map[string][]string{ + "apps/v1/deployments": {"httpbin/httpbin"}, + "apps/v1/replicasets": {"httpbin/httpbin-5978c9d878"}, + }, + Url: "https://media.defense.gov/2022/Aug/29/2003066362/-1/-1/0/CTR_KUBERNETES_HARDENING_GUIDANCE_1.2_20220829.PDF#page=50", + }, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var r io.Reader + if tt.args.filename != "" { + f, err := os.Open(tt.args.filename) + if err != nil { + t.Errorf("parseResults() setup error = %v", err) + return + } + r = f + } + got, err := parseResults(context.TODO(), tt.args.cfg, r) + if (err != nil) != tt.wantErr { + t.Errorf("parseResults() error = %v, wantErr %v", err, tt.wantErr) + return + } + sortClusterIssues(got) + sortClusterIssues(tt.want) + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("parseResults() got unexpect result, diff = %v", cmp.Diff(got, tt.want)) + } + }) + } +} + +func sortClusterIssues(issues []v1alpha1.ClusterIssue) { + sort.Slice(issues, func(i, j int) bool { + return issues[i].Spec.ID > issues[j].Spec.ID + }) + for i := 0; i < len(issues); i++ { + for r := range issues[i].Spec.Resources { + sort.Strings(issues[i].Spec.Resources[r]) + } + } +} diff --git a/pkg/worker/report/marvin/parse.go b/pkg/worker/report/marvin/parse.go index 7f514912..a0f94bfd 100644 --- a/pkg/worker/report/marvin/parse.go +++ b/pkg/worker/report/marvin/parse.go @@ -15,7 +15,9 @@ package marvin import ( + "context" "encoding/json" + "io" "github.com/go-logr/logr" marvin "github.com/undistro/marvin/pkg/types" @@ -30,12 +32,13 @@ var marvinToZoraSeverity = map[marvin.Severity]v1alpha1.ClusterIssueSeverity{ marvin.SeverityCritical: v1alpha1.SeverityHigh, } -func Parse(log logr.Logger, bs []byte) ([]*v1alpha1.ClusterIssueSpec, error) { +func Parse(ctx context.Context, results io.Reader) ([]v1alpha1.ClusterIssueSpec, error) { + log := logr.FromContextOrDiscard(ctx) report := &marvin.Report{} - if err := json.Unmarshal(bs, report); err != nil { + if err := json.NewDecoder(results).Decode(report); err != nil { return nil, err } - var css []*v1alpha1.ClusterIssueSpec + var css []v1alpha1.ClusterIssueSpec for _, check := range report.Checks { if check.Status != marvin.StatusFailed { continue @@ -49,7 +52,7 @@ func Parse(log logr.Logger, bs []byte) ([]*v1alpha1.ClusterIssueSpec, error) { return css, nil } -func clusterIssueSpec(report *marvin.Report, check *marvin.CheckResult) *v1alpha1.ClusterIssueSpec { +func clusterIssueSpec(report *marvin.Report, check *marvin.CheckResult) v1alpha1.ClusterIssueSpec { resources := map[string][]string{} for gvk, objs := range check.Failed { for _, obj := range objs { @@ -57,7 +60,7 @@ func clusterIssueSpec(report *marvin.Report, check *marvin.CheckResult) *v1alpha resources[gvr] = append(resources[gvr], obj) } } - return &v1alpha1.ClusterIssueSpec{ + return v1alpha1.ClusterIssueSpec{ ID: check.ID, Message: check.Message, Severity: marvinToZoraSeverity[check.Severity], diff --git a/pkg/worker/report/marvin/parse_test.go b/pkg/worker/report/marvin/parse_test.go index 20622c81..5281fbd3 100644 --- a/pkg/worker/report/marvin/parse_test.go +++ b/pkg/worker/report/marvin/parse_test.go @@ -15,11 +15,11 @@ package marvin import ( + "context" "os" "reflect" "testing" - "github.com/go-logr/logr" "github.com/google/go-cmp/cmp" "github.com/undistro/zora/api/zora/v1alpha1" @@ -29,13 +29,13 @@ func TestParse(t *testing.T) { tests := []struct { name string filename string - want []*v1alpha1.ClusterIssueSpec + want []v1alpha1.ClusterIssueSpec wantErr bool }{ { name: "OK", filename: "httpbin.json", - want: []*v1alpha1.ClusterIssueSpec{ + want: []v1alpha1.ClusterIssueSpec{ { ID: "M-400", Message: "Image tagged latest", @@ -119,11 +119,11 @@ func TestParse(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - bs, err := os.ReadFile("testdata/" + tt.filename) + bs, err := os.Open("testdata/" + tt.filename) if err != nil { t.Errorf("Read testdata file error = %v", err) } - got, err := Parse(logr.Discard(), bs) + got, err := Parse(context.TODO(), bs) if (err != nil) != tt.wantErr { t.Errorf("Parse() error = %v, wantErr %v", err, tt.wantErr) return diff --git a/pkg/worker/report/parse.go b/pkg/worker/report/parse.go deleted file mode 100644 index b329af1b..00000000 --- a/pkg/worker/report/parse.go +++ /dev/null @@ -1,90 +0,0 @@ -// Copyright 2022 Undistro 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 report - -import ( - "fmt" - "io" - "strconv" - "strings" - - "github.com/go-logr/logr" - - zorav1a1 "github.com/undistro/zora/api/zora/v1alpha1" - "github.com/undistro/zora/pkg/worker/config" - - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/types" -) - -// NewClusterIssue creates and returns a pointer to a instance -// carrying issue metadata on its labels. The instance is set as a child of the -// Job whereby the plugin executed. -func NewClusterIssue(c *config.Config, cispec *zorav1a1.ClusterIssueSpec, orefs []metav1.OwnerReference, juid *string) *zorav1a1.ClusterIssue { - cispec.Cluster = c.Cluster - return &zorav1a1.ClusterIssue{ - TypeMeta: metav1.TypeMeta{ - Kind: "ClusterIssue", - APIVersion: zorav1a1.SchemeGroupVersion.String(), - }, - ObjectMeta: metav1.ObjectMeta{ - Name: fmt.Sprintf("%s-%s-%s", c.Cluster, strings.ToLower(cispec.ID), *juid), - Namespace: c.ClusterIssuesNs, - OwnerReferences: orefs, - Labels: map[string]string{ - zorav1a1.LabelScanID: c.JobUID, - zorav1a1.LabelCluster: c.Cluster, - zorav1a1.LabelSeverity: string(cispec.Severity), - zorav1a1.LabelIssueID: cispec.ID, - zorav1a1.LabelCategory: strings.ReplaceAll(cispec.Category, " ", ""), - zorav1a1.LabelPlugin: c.Plugin, - zorav1a1.LabelCustom: strconv.FormatBool(cispec.Custom), - }, - }, - Spec: *cispec, - } -} - -// Parse receives a reader pointing to a plugin's report file, transforming -// such report into an array of pointers according to the -// cluster name and issues namespace specified on the struct. The -// parsing for each plugin is left to dedicated functions which are called -// according to the plugin type. -func Parse(log logr.Logger, r io.Reader, c *config.Config) ([]*zorav1a1.ClusterIssue, error) { - if err := c.Validate(); err != nil { - return nil, fmt.Errorf("Invalid configuration: %w", err) - } - repby, err := io.ReadAll(r) - if err != nil { - return nil, fmt.Errorf("Unable to read results of plugin <%s> from cluster <%s>: %w", c.Plugin, c.Cluster, err) - } - cispecs, err := config.PluginParsers[c.Plugin](log, repby) - if err != nil { - return nil, err - } - - juid := c.JobUID[strings.LastIndex(c.JobUID, "-")+1:] - orefs := []metav1.OwnerReference{{ - APIVersion: "batch/v1", - Kind: "Job", - Name: c.Job, - UID: types.UID(c.JobUID), - }} - ciarr := make([]*zorav1a1.ClusterIssue, len(cispecs)) - for i := 0; i < len(cispecs); i++ { - ciarr[i] = NewClusterIssue(c, cispecs[i], orefs, &juid) - } - return ciarr, nil -} diff --git a/pkg/worker/report/parse_test.go b/pkg/worker/report/parse_test.go deleted file mode 100644 index 43d493cc..00000000 --- a/pkg/worker/report/parse_test.go +++ /dev/null @@ -1,395 +0,0 @@ -// Copyright 2022 Undistro 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 report - -import ( - "os" - "reflect" - "sort" - "testing" - - "github.com/go-logr/logr" - "github.com/google/go-cmp/cmp" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/types" - - zorav1a1 "github.com/undistro/zora/api/zora/v1alpha1" - "github.com/undistro/zora/pkg/worker/config" -) - -func TestParse(t *testing.T) { - cases := []struct { - description string - testrepname string - config *config.Config - clusterissues []*zorav1a1.ClusterIssue - toerr bool - }{ - - // Popeye specific. - { - description: "Single Popeye instance with many resources", - testrepname: "popeye/testdata/test_report_1.json", - config: &config.Config{ - DonePath: "_", - ErrorPath: "_", - Plugin: "popeye", - Cluster: "fake_cluster", - ClusterIssuesNs: "fake_ns", - Job: "fake_job_id", - JobUID: "fake_job_uid-666-666", - }, - clusterissues: []*zorav1a1.ClusterIssue{ - { - TypeMeta: metav1.TypeMeta{ - Kind: "ClusterIssue", - APIVersion: zorav1a1.SchemeGroupVersion.String(), - }, - ObjectMeta: metav1.ObjectMeta{ - Name: "fake_cluster-pop-400-666", - Namespace: "fake_ns", - OwnerReferences: []metav1.OwnerReference{{ - APIVersion: "batch/v1", - Kind: "Job", - Name: "fake_job_id", - UID: types.UID("fake_job_uid-666-666"), - }}, - Labels: map[string]string{ - zorav1a1.LabelScanID: "fake_job_uid-666-666", - zorav1a1.LabelCluster: "fake_cluster", - zorav1a1.LabelSeverity: "Low", - zorav1a1.LabelIssueID: "POP-400", - zorav1a1.LabelCategory: "General", - zorav1a1.LabelPlugin: "popeye", - zorav1a1.LabelCustom: "false", - }, - }, - Spec: zorav1a1.ClusterIssueSpec{ - ID: "POP-400", - Message: "Used? Unable to locate resource reference", - Severity: zorav1a1.ClusterIssueSeverity("Low"), - Category: "General", - Resources: map[string][]string{ - "rbac.authorization.k8s.io/v1/clusterroles": { - "capi-kubeadm-control-plane-manager-role", - "cert-manager-edit", - "system:certificates.k8s.io:kube-apiserver-client-kubelet-approver", - "system:persistent-volume-provisioner", - "metrics-reader", - "cert-manager-view", - "system:heapster", - "system:kube-aggregator", - "admin", - "system:metrics-server-aggregated-reader", - "system:node-bootstrapper", - "system:node-problem-detector", - "view", - "capi-manager-role", - "system:certificates.k8s.io:kubelet-serving-approver", - "system:certificates.k8s.io:legacy-unknown-approver", - }, - }, - TotalResources: 16, - Cluster: "fake_cluster", - Url: "", - }, - }, - }, - toerr: false, - }, - - { - description: "Five Popeye instances with many resources", - testrepname: "popeye/testdata/test_report_2.json", - config: &config.Config{ - DonePath: "_", - ErrorPath: "_", - Plugin: "popeye", - Cluster: "super_fake_cluster", - ClusterIssuesNs: "super_fake_ns", - Job: "super_fake_job_id", - JobUID: "super_fake_job_uid-666-666", - }, - clusterissues: []*zorav1a1.ClusterIssue{ - { - TypeMeta: metav1.TypeMeta{ - Kind: "ClusterIssue", - APIVersion: zorav1a1.SchemeGroupVersion.String(), - }, - ObjectMeta: metav1.ObjectMeta{ - Name: "super_fake_cluster-pop-400-666", - Namespace: "super_fake_ns", - OwnerReferences: []metav1.OwnerReference{{ - APIVersion: "batch/v1", - Kind: "Job", - Name: "super_fake_job_id", - UID: types.UID("super_fake_job_uid-666-666"), - }}, - Labels: map[string]string{ - zorav1a1.LabelScanID: "super_fake_job_uid-666-666", - zorav1a1.LabelCluster: "super_fake_cluster", - zorav1a1.LabelSeverity: "Low", - zorav1a1.LabelIssueID: "POP-400", - zorav1a1.LabelCategory: "General", - zorav1a1.LabelPlugin: "popeye", - zorav1a1.LabelCustom: "false", - }, - }, - Spec: zorav1a1.ClusterIssueSpec{ - ID: "POP-400", - Message: "Used? Unable to locate resource reference", - Severity: zorav1a1.ClusterIssueSeverity("Low"), - Category: "General", - Resources: map[string][]string{ - "rbac.authorization.k8s.io/v1/clusterroles": {"system:node-bootstrapper", "metrics-reader"}, - }, - TotalResources: 2, - Cluster: "super_fake_cluster", - Url: "", - }, - }, - - { - TypeMeta: metav1.TypeMeta{ - Kind: "ClusterIssue", - APIVersion: zorav1a1.SchemeGroupVersion.String(), - }, - ObjectMeta: metav1.ObjectMeta{ - Name: "super_fake_cluster-pop-106-666", - Namespace: "super_fake_ns", - OwnerReferences: []metav1.OwnerReference{{ - APIVersion: "batch/v1", - Kind: "Job", - Name: "super_fake_job_id", - UID: types.UID("super_fake_job_uid-666-666"), - }}, - Labels: map[string]string{ - zorav1a1.LabelScanID: "super_fake_job_uid-666-666", - zorav1a1.LabelCluster: "super_fake_cluster", - zorav1a1.LabelSeverity: "Medium", - zorav1a1.LabelIssueID: "POP-106", - zorav1a1.LabelCategory: "Container", - zorav1a1.LabelPlugin: "popeye", - zorav1a1.LabelCustom: "false", - }, - }, - Spec: zorav1a1.ClusterIssueSpec{ - ID: "POP-106", - Message: "No resources requests/limits defined", - Severity: zorav1a1.ClusterIssueSeverity("Medium"), - Category: "Container", - Resources: map[string][]string{ - "apps/v1/daemonsets": {"kube-system/aws-node"}, - "apps/v1/deployments": {"cert-manager/cert-manager"}, - }, - TotalResources: 2, - Cluster: "super_fake_cluster", - Url: "https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/", - }, - }, - - { - TypeMeta: metav1.TypeMeta{ - Kind: "ClusterIssue", - APIVersion: zorav1a1.SchemeGroupVersion.String(), - }, - ObjectMeta: metav1.ObjectMeta{ - Name: "super_fake_cluster-pop-107-666", - Namespace: "super_fake_ns", - OwnerReferences: []metav1.OwnerReference{{ - APIVersion: "batch/v1", - Kind: "Job", - Name: "super_fake_job_id", - UID: types.UID("super_fake_job_uid-666-666"), - }}, - Labels: map[string]string{ - zorav1a1.LabelScanID: "super_fake_job_uid-666-666", - zorav1a1.LabelCluster: "super_fake_cluster", - zorav1a1.LabelSeverity: "Medium", - zorav1a1.LabelIssueID: "POP-107", - zorav1a1.LabelCategory: "Container", - zorav1a1.LabelPlugin: "popeye", - zorav1a1.LabelCustom: "false", - }, - }, - Spec: zorav1a1.ClusterIssueSpec{ - ID: "POP-107", - Message: "No resource limits defined", - Severity: zorav1a1.ClusterIssueSeverity("Medium"), - Category: "Container", - Resources: map[string][]string{ - "apps/v1/daemonsets": {"kube-system/aws-node", "kube-system/kube-proxy"}, - }, - TotalResources: 2, - Cluster: "super_fake_cluster", - Url: "https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/", - }, - }, - - { - TypeMeta: metav1.TypeMeta{ - Kind: "ClusterIssue", - APIVersion: zorav1a1.SchemeGroupVersion.String(), - }, - ObjectMeta: metav1.ObjectMeta{ - Name: "super_fake_cluster-pop-108-666", - Namespace: "super_fake_ns", - OwnerReferences: []metav1.OwnerReference{{ - APIVersion: "batch/v1", - Kind: "Job", - Name: "super_fake_job_id", - UID: types.UID("super_fake_job_uid-666-666"), - }}, - Labels: map[string]string{ - zorav1a1.LabelScanID: "super_fake_job_uid-666-666", - zorav1a1.LabelCluster: "super_fake_cluster", - zorav1a1.LabelSeverity: "Low", - zorav1a1.LabelIssueID: "POP-108", - zorav1a1.LabelCategory: "Container", - zorav1a1.LabelPlugin: "popeye", - zorav1a1.LabelCustom: "false", - }, - }, - Spec: zorav1a1.ClusterIssueSpec{ - ID: "POP-108", - Message: "Unnamed port", - Severity: zorav1a1.ClusterIssueSeverity("Low"), - Category: "Container", - Resources: map[string][]string{ - "apps/v1/deployments": {"cert-manager/cert-manager"}, - }, - TotalResources: 1, - Cluster: "super_fake_cluster", - Url: "", - }, - }, - { - TypeMeta: metav1.TypeMeta{ - Kind: "ClusterIssue", - APIVersion: zorav1a1.SchemeGroupVersion.String(), - }, - ObjectMeta: metav1.ObjectMeta{ - Name: "super_fake_cluster-pop-306-666", - Namespace: "super_fake_ns", - OwnerReferences: []metav1.OwnerReference{{ - APIVersion: "batch/v1", - Kind: "Job", - Name: "super_fake_job_id", - UID: types.UID("super_fake_job_uid-666-666"), - }}, - Labels: map[string]string{ - zorav1a1.LabelScanID: "super_fake_job_uid-666-666", - zorav1a1.LabelCluster: "super_fake_cluster", - zorav1a1.LabelSeverity: "Medium", - zorav1a1.LabelIssueID: "POP-306", - zorav1a1.LabelCategory: "Security", - zorav1a1.LabelPlugin: "popeye", - zorav1a1.LabelCustom: "false", - }, - }, - Spec: zorav1a1.ClusterIssueSpec{ - ID: "POP-306", - Message: "Container could be running as root user. Check SecurityContext/Image", - Severity: zorav1a1.ClusterIssueSeverity("Medium"), - Category: "Security", - Resources: map[string][]string{ - "v1/pods": {"kube-system/cilium-jxncv"}, - }, - TotalResources: 1, - Cluster: "super_fake_cluster", - Url: "https://kubernetes.io/docs/concepts/security/pod-security-standards/#restricted", - }, - }, - }, - toerr: false, - }, - - { - description: "Invalid Popeye report", - testrepname: "popeye/testdata/test_report_3.json", - config: &config.Config{ - DonePath: "_", - ErrorPath: "_", - Plugin: "popeye", - Cluster: "_", - ClusterIssuesNs: "_", - Job: "_", - JobUID: "fake_job_uid-666-666", - }, - clusterissues: nil, - toerr: true, - }, - { - description: "Empty Popeye report", - testrepname: "popeye/testdata/test_report_4.json", - config: &config.Config{ - DonePath: "_", - ErrorPath: "_", - Plugin: "popeye", - Cluster: "_", - ClusterIssuesNs: "_", - Job: "_", - JobUID: "fake_job_uid-666-666", - }, - clusterissues: nil, - toerr: true, - }, - - // Generic. - { - description: "Invalid plugin", - testrepname: "popeye/testdata/test_report_4.json", - config: &config.Config{ - DonePath: "_", - ErrorPath: "_", - Plugin: "fake_plugin", - Cluster: "_", - ClusterIssuesNs: "_", - Job: "_", - JobUID: "fake_job_uid-666-666", - }, - clusterissues: nil, - toerr: true, - }, - } - - sfun := func(ciarr []*zorav1a1.ClusterIssue) { - sort.Slice(ciarr, func(i, j int) bool { - return ciarr[i].Spec.ID > ciarr[j].Spec.ID - }) - for c := 0; c < len(ciarr); c++ { - for r := range ciarr[c].Spec.Resources { - sort.Strings(ciarr[c].Spec.Resources[r]) - } - } - } - for _, c := range cases { - fid, err := os.Open(c.testrepname) - if err != nil { - t.Errorf("Setup failed on case: %s\n", c.description) - t.Fatal(err) - } - ciarr, err := Parse(logr.Discard(), fid, c.config) - sfun(c.clusterissues) - sfun(ciarr) - if (err != nil) != c.toerr || !reflect.DeepEqual(c.clusterissues, ciarr) { - if err != nil { - t.Error(err) - } - t.Errorf("Case: %s\n", c.description) - t.Errorf("Mismatch between expected and obtained values: \n%s\n", cmp.Diff(c.clusterissues, ciarr)) - } - } -} diff --git a/pkg/worker/report/popeye/parse.go b/pkg/worker/report/popeye/parse.go index 9caaee95..54ee22b8 100644 --- a/pkg/worker/report/popeye/parse.go +++ b/pkg/worker/report/popeye/parse.go @@ -15,15 +15,17 @@ package popeye import ( + "context" "encoding/json" "errors" "fmt" + "io" "regexp" "strings" "github.com/go-logr/logr" - zorav1a1 "github.com/undistro/zora/api/zora/v1alpha1" + "github.com/undistro/zora/api/zora/v1alpha1" ) var ( @@ -50,14 +52,15 @@ func prepareIdAndMsg(msg string) (string, string, error) { // Parse transforms a Popeye report into a slice of . This // function is called by the package when a Popeye plugin is used. -func Parse(log logr.Logger, popr []byte) ([]*zorav1a1.ClusterIssueSpec, error) { - r := &Report{} - if err := json.Unmarshal(popr, r); err != nil { +func Parse(ctx context.Context, results io.Reader) ([]v1alpha1.ClusterIssueSpec, error) { + log := logr.FromContextOrDiscard(ctx) + report := &Report{} + if err := json.NewDecoder(results).Decode(report); err != nil { return nil, err } - issuesmap := map[string]*zorav1a1.ClusterIssueSpec{} - for _, san := range r.Popeye.Sanitizers { - for typ, issues := range san.Issues { + issuesByID := map[string]*v1alpha1.ClusterIssueSpec{} + for _, sanitizer := range report.Popeye.Sanitizers { + for typ, issues := range sanitizer.Issues { if typ == "" { if len(issues) > 0 { if msg := issues[0].Message; strings.Contains(msg, "forbidden") { @@ -77,10 +80,10 @@ func Parse(log logr.Logger, popr []byte) ([]*zorav1a1.ClusterIssueSpec, error) { log.Info("Skipping OK level issue", "id", id, "msg", msg) continue } - if ci, ok := issuesmap[id]; ok { - ci.AddResource(san.GVR, typ) + if ci, ok := issuesByID[id]; ok { + ci.AddResource(sanitizer.GVR, typ) } else { - spec := &zorav1a1.ClusterIssueSpec{ + spec := &v1alpha1.ClusterIssueSpec{ ID: id, Message: msg, Severity: LevelToIssueSeverity[iss.Level], @@ -92,17 +95,17 @@ func Parse(log logr.Logger, popr []byte) ([]*zorav1a1.ClusterIssueSpec, error) { } if !clusterScoped { spec.TotalResources = 1 - spec.Resources = map[string][]string{san.GVR: {typ}} + spec.Resources = map[string][]string{sanitizer.GVR: {typ}} } - issuesmap[id] = spec + issuesByID[id] = spec } } } } - res := []*zorav1a1.ClusterIssueSpec{} - for _, ci := range issuesmap { - res = append(res, ci) + res := make([]v1alpha1.ClusterIssueSpec, 0, len(issuesByID)) + for _, ci := range issuesByID { + res = append(res, *ci) } return res, nil } diff --git a/pkg/worker/report/popeye/parse_test.go b/pkg/worker/report/popeye/parse_test.go index b81a7462..c99cf661 100644 --- a/pkg/worker/report/popeye/parse_test.go +++ b/pkg/worker/report/popeye/parse_test.go @@ -15,12 +15,12 @@ package popeye import ( + "context" "os" "reflect" "sort" "testing" - "github.com/go-logr/logr" "github.com/google/go-cmp/cmp" zorav1a1 "github.com/undistro/zora/api/zora/v1alpha1" @@ -99,13 +99,13 @@ func TestParse(t *testing.T) { cases := []struct { description string testrepname string - cispecs []*zorav1a1.ClusterIssueSpec + cispecs []zorav1a1.ClusterIssueSpec toerr bool }{ { description: "Single instance with many resources", testrepname: "testdata/test_report_1.json", - cispecs: []*zorav1a1.ClusterIssueSpec{ + cispecs: []zorav1a1.ClusterIssueSpec{ { ID: "POP-400", Message: "Used? Unable to locate resource reference", @@ -141,7 +141,7 @@ func TestParse(t *testing.T) { { description: "Five instance with many resources", testrepname: "testdata/test_report_2.json", - cispecs: []*zorav1a1.ClusterIssueSpec{ + cispecs: []zorav1a1.ClusterIssueSpec{ { ID: "POP-400", Message: "Used? Unable to locate resource reference", @@ -217,7 +217,7 @@ func TestParse(t *testing.T) { { description: "Popeye report with one resource not found error", testrepname: "testdata/test_report_5.json", - cispecs: []*zorav1a1.ClusterIssueSpec{ + cispecs: []zorav1a1.ClusterIssueSpec{ { ID: "POP-712", Message: "Found only one master node", @@ -240,7 +240,7 @@ func TestParse(t *testing.T) { { description: "metrics-server issue", testrepname: "testdata/test_report_7.json", - cispecs: []*zorav1a1.ClusterIssueSpec{ + cispecs: []zorav1a1.ClusterIssueSpec{ { ID: "POP-402", Message: "No metrics-server detected", @@ -263,7 +263,7 @@ func TestParse(t *testing.T) { }, } - sfun := func(cis []*zorav1a1.ClusterIssueSpec) { + sfun := func(cis []zorav1a1.ClusterIssueSpec) { sort.Slice(cis, func(i, j int) bool { return cis[i].ID > cis[j].ID }) @@ -274,12 +274,12 @@ func TestParse(t *testing.T) { } } for _, c := range cases { - rep, err := os.ReadFile(c.testrepname) + rep, err := os.Open(c.testrepname) if err != nil { t.Errorf("Setup failed on case: %s\n", c.description) t.Fatal(err) } - cispecs, err := Parse(logr.Discard(), rep) + cispecs, err := Parse(context.TODO(), rep) sfun(c.cispecs) sfun(cispecs) if (err != nil) != c.toerr || !reflect.DeepEqual(c.cispecs, cispecs) { diff --git a/pkg/worker/report/popeye/popeye_types.go b/pkg/worker/report/popeye/popeye_types.go index 2b3d0336..ee8b67da 100644 --- a/pkg/worker/report/popeye/popeye_types.go +++ b/pkg/worker/report/popeye/popeye_types.go @@ -38,14 +38,14 @@ type Issue struct { // Sanitizer represents a Popeye sanitizer. type Sanitizer struct { - Sanitizer string `json:"sanitizer"` - GVR string `json:"gvr"` - Issues map[string][]*Issue `json:"issues"` + Sanitizer string `json:"sanitizer"` + GVR string `json:"gvr"` + Issues map[string][]Issue `json:"issues"` } // Popeye represents a Popeye report. type Popeye struct { - Sanitizers []*Sanitizer `json:"sanitizers"` + Sanitizers []Sanitizer `json:"sanitizers"` } // Report wraps a Popeye report. diff --git a/pkg/worker/run.go b/pkg/worker/run.go deleted file mode 100644 index 8120d555..00000000 --- a/pkg/worker/run.go +++ /dev/null @@ -1,93 +0,0 @@ -// Copyright 2022 Undistro 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 worker - -import ( - "context" - "errors" - "fmt" - "os" - "time" - - "github.com/go-logr/logr" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - ctrl "sigs.k8s.io/controller-runtime" - - zorav1a1 "github.com/undistro/zora/api/zora/v1alpha1" - "github.com/undistro/zora/pkg/clientset/versioned" - "github.com/undistro/zora/pkg/worker/config" - "github.com/undistro/zora/pkg/worker/report" -) - -// CreateClusterIssues creates instances of on the Kubernetes -// cluster which the client set points to. -func CreateClusterIssues(c *config.Config, ciarr []*zorav1a1.ClusterIssue) error { - rconfig := ctrl.GetConfigOrDie() - cset, err := versioned.NewForConfig(rconfig) - if err != nil { - return fmt.Errorf("Unable to instantiate REST config: %w", err) - } - for _, ci := range ciarr { - _, err = cset.ZoraV1alpha1().ClusterIssues(c.ClusterIssuesNs).Create(context.Background(), ci, metav1.CreateOptions{}) - if err != nil { - return fmt.Errorf("Failed to create instance on cluster <%s>: %w", c.Cluster, err) - } - } - return nil -} - -// Done is used to check whether the "done" or "error" file have been created. -func Done(dpath string) bool { - if finf, err := os.Stat(dpath); errors.Is(err, os.ErrNotExist) || finf.IsDir() { - return false - } - return true -} - -// Run performs a worker run, being the main point of entry for the component. -func Run(log logr.Logger) error { - c, err := config.FromEnv() - if err != nil { - return fmt.Errorf("Unable to create config from environment: %w", err) - } - if err := c.HandleDonePath(); err != nil { - return fmt.Errorf("Unable to ensure done path exists: %w", err) - } - - for { - if Done(c.ErrorPath) { - return errors.New("Plugin crashed") - } - if Done(c.DonePath) { - break - } - time.Sleep(500 * time.Millisecond) - } - - fid, err := c.HandleResultsPath() - if err != nil { - return fmt.Errorf("Failed checking results path: %w", err) - } - - ciarr, err := report.Parse(log, fid, c) - if err != nil { - return fmt.Errorf("Failed to parse results: %w", err) - } - if err = CreateClusterIssues(c, ciarr); err != nil { - return fmt.Errorf("Failed to create issues: %w", err) - } - - return nil -} diff --git a/pkg/worker/run_test.go b/pkg/worker/run_test.go deleted file mode 100644 index cfe85785..00000000 --- a/pkg/worker/run_test.go +++ /dev/null @@ -1,104 +0,0 @@ -// Copyright 2022 Undistro 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 worker - -import ( - "fmt" - "os" - "path" - "testing" - - "github.com/undistro/zora/pkg/worker/config" -) - -func TestDone(t *testing.T) { - type donepath struct { - create bool - dir bool - path string - mode os.FileMode - } - - cases := []struct { - description string - donepath donepath - done bool - }{ - { - description: "Inexistent 'done' file", - done: false, - }, - { - description: "File 'done' created", - donepath: donepath{ - create: true, - path: fmt.Sprintf("%s/done", config.DefaultDoneDir), - mode: os.FileMode(0644), - }, - done: true, - }, - { - description: "Dir 'done' created", - donepath: donepath{ - create: true, - dir: true, - path: fmt.Sprintf("%s/done", config.DefaultDoneDir), - mode: os.FileMode(0644), - }, - done: false, - }, - { - description: "File 'done' without read permission", - donepath: donepath{ - create: true, - path: fmt.Sprintf("%s/done", config.DefaultDoneDir), - mode: os.FileMode(0000), - }, - done: true, - }, - } - - for _, c := range cases { - if c.donepath.create { - if !c.donepath.dir { - if err := os.MkdirAll(path.Dir(c.donepath.path), 0755); err != nil { - t.Errorf("Setup failed on case: %s\n", c.description) - t.Fatal(err) - } - if err := os.WriteFile(c.donepath.path, []byte{}, c.donepath.mode); err != nil { - t.Errorf("Setup failed on case: %s\n", c.description) - t.Fatal(err) - } - } else { - if err := os.MkdirAll(c.donepath.path, 0755); err != nil { - t.Errorf("Setup failed on case: %s\n", c.description) - t.Fatal(err) - } - - } - } - - if done := Done(c.donepath.path); done != c.done { - t.Errorf("Case: %s\n", c.description) - t.Errorf("Expected path <%s> to return <%t>, but got <%t>\n", c.donepath.path, c.done, done) - } - if c.donepath.create { - if err := os.RemoveAll(path.Dir(c.donepath.path)); err != nil { - t.Errorf("Setup failed on case: %s\n", c.description) - t.Fatal(err) - } - } - } -} diff --git a/pkg/worker/worker.go b/pkg/worker/worker.go new file mode 100644 index 00000000..21bbe2e8 --- /dev/null +++ b/pkg/worker/worker.go @@ -0,0 +1,136 @@ +// Copyright 2023 Undistro 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 worker + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "os" + "time" + + "github.com/go-logr/logr" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + ctrl "sigs.k8s.io/controller-runtime" + + zora "github.com/undistro/zora/pkg/clientset/versioned" +) + +func Run(ctx context.Context) error { + log := logr.FromContextOrDiscard(ctx) + cfg, err := configFromEnv() + if err != nil { + return fmt.Errorf("failed to get config from env: %v", err) + } + client, err := getZoraClient() + if err != nil { + return err + } + results, err := gatherResults(ctx, cfg.WaitInterval, cfg.DoneFile, cfg.ErrorFile) + if err != nil { + return fmt.Errorf("failed to gather results: %v", err) + } + issues, err := parseResults(ctx, cfg, results) + if err != nil { + return err + } + for _, issue := range issues { + issue, err := client.ZoraV1alpha1().ClusterIssues(cfg.Namespace).Create(ctx, &issue, metav1.CreateOptions{}) + if err != nil { + return fmt.Errorf("failed to create ClusterIssue %q: %v", issue.Name, err) + } + log.Info(fmt.Sprintf("cluster issue %q successfully created", issue.Name), "resource version", issue.ResourceVersion) + } + return nil +} + +// getZoraClient returns Zora clientset +func getZoraClient() (*zora.Clientset, error) { + cfg, err := ctrl.GetConfig() + if err != nil { + return nil, fmt.Errorf("failed to get kubernetes config: %v", err) + } + client, err := zora.NewForConfig(cfg) + if err != nil { + return nil, fmt.Errorf("failed to build zora client: %v", err) + } + return client, nil +} + +// gatherResults waits for the "done file" to be present, then returns the indicated "results file" reader +func gatherResults(ctx context.Context, interval time.Duration, doneFile, errorFile string) (io.Reader, error) { + log := logr.FromContextOrDiscard(ctx).WithValues("doneFile", doneFile, "errorFile", errorFile) + for { + if ok, err := fileExists(errorFile); ok || err != nil { + if err == nil { + err = errors.New("error file detected") + } + return nil, err + } + done, err := fileExists(doneFile) + if err != nil { + return nil, fmt.Errorf("failed to check if done file exists: %v", err) + } + if done { + log.Info("done file detected") + break + } + log.Info(fmt.Sprintf("done file not found, waiting %s...", interval.String())) + time.Sleep(interval) + } + return readResultsFile(doneFile) +} + +// readResultsFile returns the "results file" reader indicated into "done file" +func readResultsFile(doneFile string) (io.Reader, error) { + b, err := os.ReadFile(doneFile) + if err != nil { + return nil, fmt.Errorf("failed to read done file %q: %v", doneFile, err) + } + resultsFile := string(bytes.TrimSpace(b)) + file, err := os.Open(resultsFile) + if err != nil { + return nil, fmt.Errorf("failed to read results file %q: %v", resultsFile, err) + } + info, err := file.Stat() + if err != nil { + return nil, fmt.Errorf("failed to stat results file %q: %v", resultsFile, err) + } + if info.IsDir() { + return nil, errors.New("results file is a directory") + } + return file, nil +} + +// fileExists returns true if the file at the given path exists and is not a directory +func fileExists(filename string) (bool, error) { + fi, err := os.Stat(filename) + if err != nil { + return false, ignoreNotExist(err) + } + if fi.IsDir() { + return false, errors.New("is directory") + } + return true, nil +} + +func ignoreNotExist(err error) error { + if os.IsNotExist(err) { + return nil + } + return err +} diff --git a/pkg/worker/worker_test.go b/pkg/worker/worker_test.go new file mode 100644 index 00000000..8b384049 --- /dev/null +++ b/pkg/worker/worker_test.go @@ -0,0 +1,174 @@ +// Copyright 2023 Undistro 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 worker + +import ( + "io" + "os" + "path/filepath" + "reflect" + "testing" +) + +func TestFileExists(t *testing.T) { + tmpDir := t.TempDir() + + okPath := filepath.Join(tmpDir, "ok") + if err := os.WriteFile(okPath, []byte("ok"), 0644); err != nil { + t.Fatal(err) + } + + noPermPath := filepath.Join(tmpDir, "noperm") + if err := os.WriteFile(noPermPath, []byte("noperm"), 0000); err != nil { + t.Fatal(err) + } + + tests := []struct { + name string + filename string + wantFileExists bool + wantErr bool + }{ + { + name: "dir", + filename: tmpDir, + wantFileExists: false, + wantErr: true, + }, + { + name: "ok", + filename: okPath, + wantFileExists: true, + wantErr: false, + }, + { + name: "exists without permission", + filename: noPermPath, + wantFileExists: true, + wantErr: false, + }, + { + name: "not exists", + filename: filepath.Join(tmpDir, "results"), + wantFileExists: false, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := fileExists(tt.filename) + if (err != nil) != tt.wantErr { + t.Errorf("fileExists() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.wantFileExists { + t.Errorf("fileExists() got = %v, want %v", got, tt.wantFileExists) + } + }) + } +} + +func TestReadResultsFile(t *testing.T) { + tmpDir := t.TempDir() + + noPermFile := filepath.Join(tmpDir, "noperm") + if err := os.WriteFile(noPermFile, []byte("noperm"), 0000); err != nil { + t.Fatal(err) + } + + resultsFile := filepath.Join(tmpDir, "results") + if err := os.WriteFile(resultsFile, []byte("report"), 0644); err != nil { + t.Fatal(err) + } + + doneFile := filepath.Join(tmpDir, "done") + if err := os.WriteFile(doneFile, []byte(resultsFile), 0644); err != nil { + t.Fatal(err) + } + + // done file pointing to a file without permission + doneFile2NoPerm := filepath.Join(tmpDir, "done2noperm") + if err := os.WriteFile(doneFile2NoPerm, []byte(noPermFile), 0644); err != nil { + t.Fatal(err) + } + + // done file pointing to a directory + doneFile2Dir := filepath.Join(tmpDir, "done2dir") + if err := os.WriteFile(doneFile2Dir, []byte(tmpDir), 0644); err != nil { + t.Fatal(err) + } + + tests := []struct { + name string + doneFile string + want string + wantErr bool + }{ + { + name: "ok", + doneFile: doneFile, + want: "report", + wantErr: false, + }, + { + name: "done file is a directory", + doneFile: tmpDir, + want: "", + wantErr: true, + }, + { + name: "done file without permission", + doneFile: noPermFile, + want: "", + wantErr: true, + }, + { + name: "results file without permissions", + doneFile: doneFile2NoPerm, + want: "", + wantErr: true, + }, + { + name: "results file is a directory", + doneFile: doneFile2Dir, + want: "", + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reader, err := readResultsFile(tt.doneFile) + if (err != nil) != tt.wantErr { + t.Errorf("readResultsFile() error = %v, wantErr %v", err, tt.wantErr) + return + } + got := readerToString(reader) + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("readResultsFile() got = %v, want %v", got, tt.want) + } + }) + } +} + +func readerToString(r io.Reader) string { + if r == nil { + return "" + } + b, err := io.ReadAll(r) + if err != nil { + return err.Error() + } + return string(b) +}