From e5d198ae7e1ed78bd5fe32511352b6c2922dfc06 Mon Sep 17 00:00:00 2001 From: Ludovic Cleroux Date: Thu, 14 Sep 2023 16:41:34 +0200 Subject: [PATCH] ROX-19013 Add gitops to fleetmanager (#1233) --- internal/dinosaur/pkg/gitops/README.md | 14 ++ internal/dinosaur/pkg/gitops/config.go | 6 +- internal/dinosaur/pkg/gitops/config_test.go | 10 +- .../dinosaur/pkg/gitops/default_central.go | 6 + .../dinosaur/pkg/gitops/default_central.yaml | 52 ++++++ .../dinosaur/pkg/gitops/gitops-workflow.png | Bin 0 -> 33441 bytes .../dinosaur/pkg/gitops/gitops-workflow.puml | 28 +++ internal/dinosaur/pkg/gitops/provider.go | 76 ++++++++ internal/dinosaur/pkg/gitops/provider_test.go | 133 ++++++++++++++ internal/dinosaur/pkg/gitops/reader.go | 68 +++++++ internal/dinosaur/pkg/gitops/reader_test.go | 50 ++++++ internal/dinosaur/pkg/gitops/service.go | 160 +++++++++++++++++ internal/dinosaur/pkg/gitops/service_test.go | 166 ++++++++++++++++++ .../pkg/handlers/data_plane_dinosaur.go | 29 +-- .../dinosaur/pkg/presenters/managedcentral.go | 59 ++++++- .../pkg/services/data_plane_cluster.go | 2 +- .../pkg/services/data_plane_dinosaur.go | 119 +++++++------ internal/dinosaur/pkg/services/dinosaur.go | 29 +-- .../pkg/services/dinosaurservice_moq.go | 44 ----- internal/dinosaur/providers.go | 10 +- 20 files changed, 908 insertions(+), 153 deletions(-) create mode 100644 internal/dinosaur/pkg/gitops/README.md create mode 100644 internal/dinosaur/pkg/gitops/default_central.go create mode 100644 internal/dinosaur/pkg/gitops/default_central.yaml create mode 100644 internal/dinosaur/pkg/gitops/gitops-workflow.png create mode 100644 internal/dinosaur/pkg/gitops/gitops-workflow.puml create mode 100644 internal/dinosaur/pkg/gitops/provider.go create mode 100644 internal/dinosaur/pkg/gitops/provider_test.go create mode 100644 internal/dinosaur/pkg/gitops/reader.go create mode 100644 internal/dinosaur/pkg/gitops/reader_test.go create mode 100644 internal/dinosaur/pkg/gitops/service.go create mode 100644 internal/dinosaur/pkg/gitops/service_test.go diff --git a/internal/dinosaur/pkg/gitops/README.md b/internal/dinosaur/pkg/gitops/README.md new file mode 100644 index 0000000000..6334ffc08a --- /dev/null +++ b/internal/dinosaur/pkg/gitops/README.md @@ -0,0 +1,14 @@ +# GitOps Workflow + +![GitOps workflow](gitops-workflow.png) + +1. `fleetshard` polls `fleetmanager` for a list of `Centrals` by sending an api request +2. `fleetmanager` lists the central instances from the database +3. `fleetmanager` applies the default configuration to the central instances +4. `fleetmanager` retrieves the gitops configuration +5. `fleetmanager` applies the gitops configuration to the central instances +6. `fleetmanager` returns the list of central instances to `fleetshard` +7. `fleetshard` applies the cluster-specific configuration/overrides to the central instances +8. `fleetshard` performs reconciliation of the central instances + +The `gitops` configuration repository is located at https://gitlab.cee.redhat.com/stackrox/acs-cloud-service/config diff --git a/internal/dinosaur/pkg/gitops/config.go b/internal/dinosaur/pkg/gitops/config.go index 299ad62538..d3a464833c 100644 --- a/internal/dinosaur/pkg/gitops/config.go +++ b/internal/dinosaur/pkg/gitops/config.go @@ -3,8 +3,8 @@ package gitops import ( "github.com/stackrox/rox/operator/apis/platform/v1alpha1" - "gopkg.in/yaml.v2" - field "k8s.io/apimachinery/pkg/util/validation/field" + "k8s.io/apimachinery/pkg/util/validation/field" + "sigs.k8s.io/yaml" ) // Config represents the gitops configuration @@ -14,8 +14,6 @@ type Config struct { // CentralsConfig represents the declarative configuration for Central instances defaults and overrides. type CentralsConfig struct { - // Default configuration for Central instances. - Default v1alpha1.Central `json:"default"` // Overrides are the overrides for Central instances. Overrides []CentralOverride `json:"overrides"` } diff --git a/internal/dinosaur/pkg/gitops/config_test.go b/internal/dinosaur/pkg/gitops/config_test.go index fdbaa275b2..a164d63ab8 100644 --- a/internal/dinosaur/pkg/gitops/config_test.go +++ b/internal/dinosaur/pkg/gitops/config_test.go @@ -24,20 +24,19 @@ func TestValidateGitOpsConfig(t *testing.T) { }, yaml: ` centrals: - default: {} overrides: - - instanceId: id1 + - instanceIds: + - id1 patch: | {}`, }, { name: "invalid yaml in patch", assert: func(t *testing.T, c *Config, err field.ErrorList) { require.Len(t, err, 1) - assert.Equal(t, field.Invalid(field.NewPath("centrals", "overrides").Index(0).Child("patch"), "foo", "invalid patch: yaml: unmarshal errors:\n line 1: cannot unmarshal !!str `foo` into v1alpha1.Central"), err[0]) + assert.Equal(t, field.Invalid(field.NewPath("centrals", "overrides").Index(0).Child("patch"), "foo", "invalid patch: error unmarshaling JSON: while decoding JSON: json: cannot unmarshal string into Go value of type v1alpha1.Central"), err[0]) }, yaml: ` centrals: - default: {} overrides: - instanceIds: - id1 @@ -47,11 +46,10 @@ centrals: name: "patch contains un-mergeable fields", assert: func(t *testing.T, c *Config, err field.ErrorList) { require.Len(t, err, 1) - assert.Equal(t, field.Invalid(field.NewPath("centrals", "overrides").Index(0).Child("patch"), "spec: 123\n", "invalid patch: yaml: unmarshal errors:\n line 1: cannot unmarshal !!int `123` into v1alpha1.CentralSpec"), err[0]) + assert.Equal(t, field.Invalid(field.NewPath("centrals", "overrides").Index(0).Child("patch"), "spec: 123\n", "invalid patch: error unmarshaling JSON: while decoding JSON: json: cannot unmarshal number into Go struct field Central.spec of type v1alpha1.CentralSpec"), err[0]) }, yaml: ` centrals: - default: {} overrides: - instanceIds: - id1 diff --git a/internal/dinosaur/pkg/gitops/default_central.go b/internal/dinosaur/pkg/gitops/default_central.go new file mode 100644 index 0000000000..d4fa535b88 --- /dev/null +++ b/internal/dinosaur/pkg/gitops/default_central.go @@ -0,0 +1,6 @@ +package gitops + +import _ "embed" + +//go:embed default_central.yaml +var defaultCentralTemplate []byte diff --git a/internal/dinosaur/pkg/gitops/default_central.yaml b/internal/dinosaur/pkg/gitops/default_central.yaml new file mode 100644 index 0000000000..f5273d8fa4 --- /dev/null +++ b/internal/dinosaur/pkg/gitops/default_central.yaml @@ -0,0 +1,52 @@ +metadata: + name: "{{ .Name }}" + namespace: "{{ .Namespace }}" + labels: + rhacs.redhat.com/instance-type: "{{ .InstanceType }}" + rhacs.redhat.com/org-id: "{{ .OrganizationID }}" + rhacs.redhat.com/tenant: "{{ .ID }}" + annotations: + platform.stackrox.io/managed-services: "true" + rhacs.redhat.com/org-name: {{ .OrganizationName }} +spec: + central: + adminPasswordGenerationDisabled: true #pragma: allowlist secret + # db: {} -- managed by fleetshard-sync + # exposure: {} -- managed by fleetshard-sync + monitoring: + exposeEndpoint: Enabled + openshift: + enabled: false + resources: + limits: + cpu: "4" + memory: 8Gi + requests: + cpu: "2" + memory: 4Gi + # telemetry: {} -- managed by fleetshard-sync + scanner: + analyzer: + resources: + limits: + cpu: "3" + memory: 8Gi + requests: + cpu: "1.5" + memory: 4Gi + scaling: + autoScaling: Enabled + maxReplicas: 3 + minReplicas: 1 + replicas: 1 + scannerComponent: Enabled + db: + resources: + limits: + cpu: "2.5" + memory: 4Gi + requests: + cpu: "1.25" + memory: 2Gi + monitoring: + exposeEndpoint: Enabled diff --git a/internal/dinosaur/pkg/gitops/gitops-workflow.png b/internal/dinosaur/pkg/gitops/gitops-workflow.png new file mode 100644 index 0000000000000000000000000000000000000000..5f80ee7c0e1edcbf36ff280eb052397e3a67cf5d GIT binary patch literal 33441 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 K;xl FM: Poll Centrals +FM -> DB: List Instances +FM -> DC: Get Default Central +FM -> FM: Apply Defaults to List +FM -> GitOps: Get GitOps Config +FM -> FM: Apply GitOps Config to List +FM -> FS: Central List +FS -> FS: Apply Cluster-Specific Defaults +FS -> FS: Reconcile + + +@enduml diff --git a/internal/dinosaur/pkg/gitops/provider.go b/internal/dinosaur/pkg/gitops/provider.go new file mode 100644 index 0000000000..3a9ccc39da --- /dev/null +++ b/internal/dinosaur/pkg/gitops/provider.go @@ -0,0 +1,76 @@ +package gitops + +import ( + "sync/atomic" + + "github.com/golang/glog" + "github.com/pkg/errors" + "github.com/prometheus/client_golang/prometheus" +) + +var ( + errorCounter = prometheus.NewCounterVec(prometheus.CounterOpts{ + Name: "dinosaur_gitops_config_provider_error_total", + Help: "Number of errors encountered by the GitOps configuration provider.", + }, []string{}) +) + +func init() { + prometheus.MustRegister(errorCounter) +} + +// ConfigProvider is the interface for GitOps configuration providers. +type ConfigProvider interface { + // Get returns the GitOps configuration. + Get() (Config, error) +} + +type validationFn func(config Config) error + +type provider struct { + reader Reader + lastWorkingConfig atomic.Pointer[Config] + validationFn validationFn +} + +// NewProvider returns a new ConfigProvider. +func NewProvider(reader Reader) ConfigProvider { + return &provider{ + reader: reader, + lastWorkingConfig: atomic.Pointer[Config]{}, + validationFn: func(config Config) error { + return ValidateConfig(config).ToAggregate() + }, + } +} + +// Get implements ConfigProvider.Get +func (p *provider) Get() (Config, error) { + // Load the config from the reader + cfg, err := p.reader.Read() + if err != nil { + p.increaseErrorCount() + return p.tryGetLastWorkingConfig(errors.Wrap(err, "failed to read GitOps configuration")) + } + // Validate the config + if err := p.validationFn(cfg); err != nil { + p.increaseErrorCount() + return p.tryGetLastWorkingConfig(errors.Wrap(err, "failed to validate GitOps configuration")) + } + // Store the config as the last working config + p.lastWorkingConfig.Store(&cfg) + return cfg, nil +} + +func (p *provider) increaseErrorCount() { + errorCounter.WithLabelValues().Inc() +} + +func (p *provider) tryGetLastWorkingConfig(err error) (Config, error) { + lastWorkingConfig := p.lastWorkingConfig.Load() + if lastWorkingConfig == nil { + return Config{}, errors.Wrap(err, "no last working gitops config available") + } + glog.Warningf("Failed to get GitOps configuration. Using last working config: %s", err) + return *lastWorkingConfig, nil +} diff --git a/internal/dinosaur/pkg/gitops/provider_test.go b/internal/dinosaur/pkg/gitops/provider_test.go new file mode 100644 index 0000000000..d15a853c11 --- /dev/null +++ b/internal/dinosaur/pkg/gitops/provider_test.go @@ -0,0 +1,133 @@ +package gitops + +import ( + "sync/atomic" + "testing" + + "github.com/prometheus/client_golang/prometheus/testutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestProvider_Get(t *testing.T) { + + var failingValidation validationFn = func(config Config) error { + return assert.AnError + } + var successfulValidation validationFn = func(config Config) error { + return nil + } + var failingReader Reader = &mockReader{err: assert.AnError} + var successfulReader Reader = &mockReader{config: Config{}} + + type tc struct { + name string + hasLastWorkingConfig bool + reader Reader + validator validationFn + expectedErrorMetricCount int + expectError bool + } + tcs := []tc{ + { + name: "Successful without last working config", + hasLastWorkingConfig: false, + reader: successfulReader, + validator: successfulValidation, + expectedErrorMetricCount: 0, + expectError: false, + }, { + name: "Successful with last working config", + hasLastWorkingConfig: true, + reader: successfulReader, + validator: successfulValidation, + expectedErrorMetricCount: 0, + expectError: false, + }, { + name: "Reader fails without last working config", + hasLastWorkingConfig: false, + reader: failingReader, + validator: successfulValidation, + expectedErrorMetricCount: 1, + expectError: true, + }, { + name: "Reader fails with last working config", + hasLastWorkingConfig: true, + reader: failingReader, + validator: successfulValidation, + expectedErrorMetricCount: 1, + expectError: false, + }, { + name: "Validation fails without last working config", + hasLastWorkingConfig: false, + reader: failingReader, + validator: failingValidation, + expectedErrorMetricCount: 1, + expectError: true, + }, { + name: "Validation fails with last working config", + hasLastWorkingConfig: true, + reader: failingReader, + validator: failingValidation, + expectedErrorMetricCount: 1, + expectError: false, + }, + } + for _, tc := range tcs { + t.Run(tc.name, func(t *testing.T) { + p := &provider{} + p.lastWorkingConfig = atomic.Pointer[Config]{} + + if tc.hasLastWorkingConfig { + // Get the config once to set the last working config + p.reader = successfulReader + p.validationFn = successfulValidation + _, err := p.Get() + require.NoError(t, err) + } + + p.reader = tc.reader + p.validationFn = tc.validator + + errorCounter.Reset() + _, err := p.Get() + if tc.expectError { + require.Error(t, err) + } else { + require.NoError(t, err) + } + + count := testutil.CollectAndCount(errorCounter) + assert.Equal(t, tc.expectedErrorMetricCount, count) + + }) + } +} + +type mockReader struct { + config Config + err error +} + +func NewMockReader(config Config) *mockReader { + return &mockReader{ + config: Config{}, + err: nil, + } +} + +func (r *mockReader) Read() (Config, error) { + return r.config, r.err +} + +func (r *mockReader) WillFail() *mockReader { + r.err = assert.AnError + return r +} + +func (r *mockReader) WillSucceed() *mockReader { + r.err = nil + return r +} + +var _ Reader = &mockReader{} diff --git a/internal/dinosaur/pkg/gitops/reader.go b/internal/dinosaur/pkg/gitops/reader.go new file mode 100644 index 0000000000..3d0570819e --- /dev/null +++ b/internal/dinosaur/pkg/gitops/reader.go @@ -0,0 +1,68 @@ +package gitops + +import ( + // embed needed for embedding the default central template + _ "embed" + "os" + + "github.com/pkg/errors" + "sigs.k8s.io/yaml" +) + +// Reader reads a Config from a source. +type Reader interface { + Read() (Config, error) +} + +// fileReader is a Reader that reads a Config from a file. +type fileReader struct { + path string +} + +// NewFileReader returns a new fileReader. +func NewFileReader(path string) Reader { + return &fileReader{path: path} +} + +// Read implements Reader.Read +func (r *fileReader) Read() (Config, error) { + fileBytes, err := os.ReadFile(r.path) + if err != nil { + return Config{}, errors.Wrap(err, "failed to read GitOps configuration file") + } + var config Config + if err := yaml.Unmarshal(fileBytes, &config); err != nil { + return Config{}, errors.Wrap(err, "failed to unmarshal GitOps configuration") + } + return config, nil +} + +// staticReader is a Reader that returns a static Config. +type staticReader struct { + config Config +} + +// NewStaticReader returns a new staticReader. +// Useful for testing. +func NewStaticReader(config Config) Reader { + return &staticReader{config: config} +} + +// Read implements Reader.Read +func (r *staticReader) Read() (Config, error) { + return r.config, nil +} + +// emptyReader is a Reader that returns an empty Config. +type emptyReader struct{} + +// NewEmptyReader returns a new emptyReader. +// Useful for testing. +func NewEmptyReader() Reader { + return &emptyReader{} +} + +// Read implements Reader.Read +func (r *emptyReader) Read() (Config, error) { + return Config{}, nil +} diff --git a/internal/dinosaur/pkg/gitops/reader_test.go b/internal/dinosaur/pkg/gitops/reader_test.go new file mode 100644 index 0000000000..4768e5da49 --- /dev/null +++ b/internal/dinosaur/pkg/gitops/reader_test.go @@ -0,0 +1,50 @@ +package gitops + +import ( + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestFileReader_Get_FailsIfFileDoesNotExist(t *testing.T) { + tmpDir := t.TempDir() + tmpFilePath := tmpDir + "/config.yaml" + provider := NewFileReader(tmpFilePath) + _, err := provider.Read() + assert.Error(t, err) +} + +func TestFileReader_Get_FailsIfFileIsInvalidYAML(t *testing.T) { + tmpDir := t.TempDir() + tmpFilePath := tmpDir + "/config.yaml" + file, err := os.Create(tmpFilePath) + require.NoError(t, err) + _, err = file.WriteString("invalid yaml") + require.NoError(t, err) + provider := NewFileReader(tmpFilePath) + _, err = provider.Read() + assert.Error(t, err) +} + +func TestFileReader_Get(t *testing.T) { + tmpDir := t.TempDir() + tmpFilePath := tmpDir + "/config.yaml" + file, err := os.Create(tmpFilePath) + require.NoError(t, err) + _, err = file.WriteString(` +centrals: + overrides: [] +`) + require.NoError(t, err) + provider := NewFileReader(tmpFilePath) + _, err = provider.Read() + require.NoError(t, err) +} + +func TestStaticReader_Read(t *testing.T) { + provider := NewStaticReader(Config{}) + _, err := provider.Read() + require.NoError(t, err) +} diff --git a/internal/dinosaur/pkg/gitops/service.go b/internal/dinosaur/pkg/gitops/service.go new file mode 100644 index 0000000000..19f6c232d0 --- /dev/null +++ b/internal/dinosaur/pkg/gitops/service.go @@ -0,0 +1,160 @@ +package gitops + +import ( + "encoding/json" + "strings" + "text/template" + + "github.com/pkg/errors" + "github.com/stackrox/rox/operator/apis/platform/v1alpha1" + "k8s.io/apimachinery/pkg/util/strategicpatch" + "sigs.k8s.io/yaml" +) + +// Service applies GitOps configuration to Central instances. +type Service interface { + GetCentral(ctx CentralParams) (v1alpha1.Central, error) +} + +type service struct { + configProvider ConfigProvider +} + +// NewService returns a new Service. +func NewService(configProvider ConfigProvider) Service { + return &service{configProvider: configProvider} +} + +// GetCentral returns a Central instance with the given parameters. +func (s *service) GetCentral(params CentralParams) (v1alpha1.Central, error) { + wr := new(strings.Builder) + if err := defaultTemplate.Execute(wr, params); err != nil { + return v1alpha1.Central{}, errors.Wrap(err, "failed to render default template") + } + var central v1alpha1.Central + if err := yaml.Unmarshal([]byte(wr.String()), ¢ral); err != nil { + return v1alpha1.Central{}, errors.Wrap(err, "failed to unmarshal default central") + } + cfg, err := s.configProvider.Get() + if err != nil { + return v1alpha1.Central{}, errors.Wrap(err, "failed to get GitOps configuration") + } + return applyConfigToCentral(cfg, central, params) +} + +// CentralParams represents the parameters for a Central instance. +type CentralParams struct { + // ID is the ID of the Central instance. + ID string + // Name is the name of the Central instance. + Name string + // Namespace is the namespace of the Central instance. + Namespace string + // Region is the region of the Central instance. + Region string + // ClusterID is the ID of the cluster of the Central instance. + ClusterID string + // CloudProvider is the cloud provider of the Central instance. + CloudProvider string + // CloudAccountID is the cloud account ID of the Central instance. + CloudAccountID string + // SubscriptionID is the subscription ID of the Central instance. + SubscriptionID string + // Owner is the owner of the Central instance. + Owner string + // OwnerAccountID is the owner account ID of the Central instance. + OwnerAccountID string + // OwnerUserID is the owner user ID of the Central instance. + OwnerUserID string + // Host is the host of the Central instance. + Host string + // OrganizationID is the organization ID of the Central instance. + OrganizationID string + // OrganizationName is the organization name of the Central instance. + OrganizationName string + // InstanceType is the instance type of the Central instance. + InstanceType string + // IsInternal is true if the Central instance is internal. + IsInternal bool +} + +// applyConfigToCentral will apply the given GitOps configuration to the given Central instance. +func applyConfigToCentral(config Config, central v1alpha1.Central, ctx CentralParams) (v1alpha1.Central, error) { + var overrides []CentralOverride + for _, override := range config.Centrals.Overrides { + if !shouldApplyCentralOverride(override, ctx) { + continue + } + overrides = append(overrides, override) + } + if len(overrides) == 0 { + return central, nil + } + // render override path templates + for i, override := range overrides { + var err error + overrides[i].Patch, err = renderPatchTemplate(override.Patch, ctx) + if err != nil { + return v1alpha1.Central{}, err + } + } + centralBytes, err := json.Marshal(central) + if err != nil { + return v1alpha1.Central{}, errors.Wrap(err, "failed to marshal Central instance") + } + for _, override := range overrides { + patchBytes := []byte(override.Patch) + centralBytes, err = applyPatchToCentral(centralBytes, patchBytes) + if err != nil { + return v1alpha1.Central{}, err + } + } + var result v1alpha1.Central + if err := json.Unmarshal(centralBytes, &result); err != nil { + return v1alpha1.Central{}, errors.Wrap(err, "failed to unmarshal Central instance") + } + return result, nil +} + +// shouldApplyCentralOverride returns true if the given Central override should be applied to the given Central instance. +func shouldApplyCentralOverride(override CentralOverride, ctx CentralParams) bool { + for _, d := range override.InstanceIDs { + if d == "*" { + return true + } + if d == ctx.ID { + return true + } + } + return false +} + +// applyPatchToCentral will apply the given patch to the given Central instance. +func applyPatchToCentral(centralBytes, patch []byte) ([]byte, error) { + // convert patch from yaml to json + jsonPath, err := yaml.YAMLToJSON(patch) + if err != nil { + return []byte{}, errors.Wrap(err, "failed to convert override patch from yaml to json") + } + // apply patch + patchedBytes, err := strategicpatch.StrategicMergePatch(centralBytes, jsonPath, v1alpha1.Central{}) + if err != nil { + return []byte{}, errors.Wrap(err, "failed to apply override to Central instance") + } + return patchedBytes, nil +} + +func renderPatchTemplate(patchTemplate string, ctx CentralParams) (string, error) { + tpl, err := template.New("patch").Parse(patchTemplate) + if err != nil { + return "", errors.Wrap(err, "failed to parse patch template") + } + var writer = new(strings.Builder) + if err := tpl.Execute(writer, ctx); err != nil { + return "", errors.Wrap(err, "failed to render patch template") + } + return writer.String(), nil +} + +// defaultTemplate is the default template for Central instances. +var defaultTemplate = template.Must(template.New("default").Parse(string(defaultCentralTemplate))) diff --git a/internal/dinosaur/pkg/gitops/service_test.go b/internal/dinosaur/pkg/gitops/service_test.go new file mode 100644 index 0000000000..53b98dd4be --- /dev/null +++ b/internal/dinosaur/pkg/gitops/service_test.go @@ -0,0 +1,166 @@ +package gitops + +import ( + "strings" + "testing" + + "github.com/stackrox/rox/operator/apis/platform/v1alpha1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "sigs.k8s.io/yaml" +) + +func TestService_GetCentral(t *testing.T) { + tests := []struct { + name string + config Config + params CentralParams + assert func(t *testing.T, got v1alpha1.Central, err error) + }{ + { + name: "no overrides", + params: CentralParams{ + ID: "central-1", + }, + config: Config{}, + assert: func(t *testing.T, got v1alpha1.Central, err error) { + require.NoError(t, err) + }, + }, + { + name: "multiple overrides", + params: CentralParams{ + ID: "central-1", + }, + config: Config{ + Centrals: CentralsConfig{ + Overrides: []CentralOverride{ + { + InstanceIDs: []string{"central-1"}, + Patch: `metadata: {"labels": {"foo": "bar"}}`, + }, { + InstanceIDs: []string{"central-1"}, + Patch: `metadata: {"annotations": {"foo": "bar"}}`, + }, + }, + }, + }, + assert: func(t *testing.T, got v1alpha1.Central, err error) { + require.NoError(t, err) + assert.Equal(t, "bar", got.Labels["foo"]) + assert.Equal(t, "bar", got.Annotations["foo"]) + }, + }, + { + name: "multiple overrides, one not matching", + params: CentralParams{ + ID: "central-1", + }, + config: Config{ + Centrals: CentralsConfig{ + Overrides: []CentralOverride{ + { + InstanceIDs: []string{"central-1"}, + Patch: `metadata: {"labels": {"foo": "bar"}}`, + }, { + InstanceIDs: []string{"central-2"}, + Patch: `metadata: {"labels": {"foo": "baz"}}`, + }, + }, + }, + }, + assert: func(t *testing.T, got v1alpha1.Central, err error) { + require.NoError(t, err) + assert.Equal(t, "bar", got.Labels["foo"]) + }, + }, + { + name: "with templated patch", + params: CentralParams{ + ID: "central-1", + }, + config: Config{ + Centrals: CentralsConfig{ + Overrides: []CentralOverride{ + { + InstanceIDs: []string{"central-1"}, + Patch: `metadata: {"labels": {"foo": "{{ .ID }}"}}`, + }, + }, + }, + }, + assert: func(t *testing.T, got v1alpha1.Central, err error) { + require.NoError(t, err) + assert.Equal(t, "central-1", got.Labels["foo"]) + }, + }, + { + name: "wildcard override", + params: CentralParams{ + ID: "central-1", + }, + config: Config{ + Centrals: CentralsConfig{ + Overrides: []CentralOverride{ + { + InstanceIDs: []string{"*"}, + Patch: `metadata: {"labels": {"foo": "bar"}}`, + }, + }, + }, + }, + assert: func(t *testing.T, got v1alpha1.Central, err error) { + require.NoError(t, err) + assert.Equal(t, "bar", got.Labels["foo"]) + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + svc := NewService(newMockProvider(tt.config)) + got, err := svc.GetCentral(tt.params) + tt.assert(t, got, err) + }) + } +} + +// TestDefaultTemplateIsValid tests that the default template is valid and +// can be unmarshaled to a functional v1alpha1.Central object. +func Test_defaultTemplate_isValid(t *testing.T) { + + var wr strings.Builder + err := defaultTemplate.Execute(&wr, CentralParams{ + ID: "id", + Name: "name", + Namespace: "namespace", + Region: "region", + ClusterID: "cluster-id", + CloudProvider: "cloud-provider", + CloudAccountID: "cloud-account-id", + SubscriptionID: "subscription-id", + Owner: "owner", + OwnerAccountID: "owner-account-id", + OwnerUserID: "owner-user-id", + Host: "host", + OrganizationID: "organization-id", + OrganizationName: "organization-name", + InstanceType: "instance-type", + IsInternal: false, + }) + require.NoError(t, err) + + var central v1alpha1.Central + require.NoError(t, yaml.Unmarshal([]byte(wr.String()), ¢ral)) +} + +type mockProvider struct { + config Config +} + +func (m *mockProvider) Get() (Config, error) { + return m.config, nil +} + +func newMockProvider(config Config) *mockProvider { + return &mockProvider{config: config} +} diff --git a/internal/dinosaur/pkg/handlers/data_plane_dinosaur.go b/internal/dinosaur/pkg/handlers/data_plane_dinosaur.go index 898425ab15..2fb85abe11 100644 --- a/internal/dinosaur/pkg/handlers/data_plane_dinosaur.go +++ b/internal/dinosaur/pkg/handlers/data_plane_dinosaur.go @@ -5,13 +5,12 @@ import ( "github.com/stackrox/acs-fleet-manager/pkg/features" "net/http" + "github.com/gorilla/mux" "github.com/stackrox/acs-fleet-manager/internal/dinosaur/pkg/api/private" "github.com/stackrox/acs-fleet-manager/internal/dinosaur/pkg/presenters" "github.com/stackrox/acs-fleet-manager/internal/dinosaur/pkg/services" - "github.com/stackrox/acs-fleet-manager/pkg/handlers" - - "github.com/gorilla/mux" "github.com/stackrox/acs-fleet-manager/pkg/errors" + "github.com/stackrox/acs-fleet-manager/pkg/handlers" ) type dataPlaneDinosaurHandler struct { @@ -21,7 +20,11 @@ type dataPlaneDinosaurHandler struct { } // NewDataPlaneDinosaurHandler ... -func NewDataPlaneDinosaurHandler(service services.DataPlaneCentralService, dinosaurService services.DinosaurService, presenter *presenters.ManagedCentralPresenter) *dataPlaneDinosaurHandler { +func NewDataPlaneDinosaurHandler( + service services.DataPlaneCentralService, + dinosaurService services.DinosaurService, + presenter *presenters.ManagedCentralPresenter, +) *dataPlaneDinosaurHandler { return &dataPlaneDinosaurHandler{ service: service, dinosaurService: dinosaurService, @@ -56,7 +59,7 @@ func (h *dataPlaneDinosaurHandler) GetAll(w http.ResponseWriter, r *http.Request handlers.ValidateLength(&clusterID, "id", &handlers.MinRequiredFieldLength, nil), }, Action: func() (interface{}, *errors.ServiceError) { - centralRequests, err := h.dinosaurService.ListByClusterID(clusterID) + centralRequests, err := h.service.ListByClusterID(clusterID) if err != nil { return nil, err } @@ -72,7 +75,10 @@ func (h *dataPlaneDinosaurHandler) GetAll(w http.ResponseWriter, r *http.Request } for i := range centralRequests { - converted := h.presenter.PresentManagedCentral(centralRequests[i]) + converted, err := h.presenter.PresentManagedCentral(centralRequests[i]) + if err != nil { + return nil, errors.GeneralError("failed to convert central request to managed central: %v", err) + } managedDinosaurList.Items = append(managedDinosaurList.Items, converted) } return managedDinosaurList, nil @@ -87,12 +93,15 @@ func (h *dataPlaneDinosaurHandler) GetByID(w http.ResponseWriter, r *http.Reques centralID := mux.Vars(r)["id"] cfg := &handlers.HandlerConfig{ Action: func() (interface{}, *errors.ServiceError) { - centralRequest, err := h.dinosaurService.GetByID(centralID) - if err != nil { - return nil, err + centralRequest, svcErr := h.dinosaurService.GetByID(centralID) + if svcErr != nil { + return nil, svcErr } - converted := h.presenter.PresentManagedCentralWithSecrets(centralRequest) + converted, err := h.presenter.PresentManagedCentralWithSecrets(centralRequest) + if err != nil { + return nil, errors.GeneralError("failed to convert central request to managed central: %v", err) + } return converted, nil }, diff --git a/internal/dinosaur/pkg/presenters/managedcentral.go b/internal/dinosaur/pkg/presenters/managedcentral.go index 98d3bdd8f9..f5e5b6e21b 100644 --- a/internal/dinosaur/pkg/presenters/managedcentral.go +++ b/internal/dinosaur/pkg/presenters/managedcentral.go @@ -7,26 +7,33 @@ import ( "time" "github.com/golang/glog" + "github.com/pkg/errors" "github.com/stackrox/acs-fleet-manager/internal/dinosaur/pkg/api/dbapi" "github.com/stackrox/acs-fleet-manager/internal/dinosaur/pkg/api/private" "github.com/stackrox/acs-fleet-manager/internal/dinosaur/pkg/config" "github.com/stackrox/acs-fleet-manager/internal/dinosaur/pkg/defaults" + "github.com/stackrox/acs-fleet-manager/internal/dinosaur/pkg/gitops" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/resource" + "sigs.k8s.io/yaml" ) // ManagedCentralPresenter helper service which converts Central DB representation to the private API representation type ManagedCentralPresenter struct { centralConfig *config.CentralConfig + gitopsService gitops.Service } // NewManagedCentralPresenter creates a new instance of ManagedCentralPresenter -func NewManagedCentralPresenter(config *config.CentralConfig) *ManagedCentralPresenter { - return &ManagedCentralPresenter{centralConfig: config} +func NewManagedCentralPresenter(config *config.CentralConfig, gitopsService gitops.Service) *ManagedCentralPresenter { + return &ManagedCentralPresenter{ + centralConfig: config, + gitopsService: gitopsService, + } } // PresentManagedCentral converts DB representation of Central to the private API representation -func (c *ManagedCentralPresenter) PresentManagedCentral(from *dbapi.CentralRequest) private.ManagedCentral { +func (c *ManagedCentralPresenter) PresentManagedCentral(from *dbapi.CentralRequest) (private.ManagedCentral, error) { var central dbapi.CentralSpec var scanner dbapi.ScannerSpec @@ -51,6 +58,16 @@ func (c *ManagedCentralPresenter) PresentManagedCentral(from *dbapi.CentralReque } } + centralCR, err := c.gitopsService.GetCentral(centralParamsFromRequest(from)) + if err != nil { + return private.ManagedCentral{}, errors.Wrap(err, "failed to apply GitOps overrides to Central") + } + + centralYaml, err := yaml.Marshal(centralCR) + if err != nil { + return private.ManagedCentral{}, errors.Wrap(err, "failed to marshal Central CR") + } + res := private.ManagedCentral{ Id: from.ID, Kind: "ManagedCentral", @@ -134,6 +151,7 @@ func (c *ManagedCentralPresenter) PresentManagedCentral(from *dbapi.CentralReque }, }, }, + CentralCRYAML: string(centralYaml), }, RequestStatus: from.Status, ForceReconcile: from.ForceReconcile, @@ -143,18 +161,20 @@ func (c *ManagedCentralPresenter) PresentManagedCentral(from *dbapi.CentralReque res.Metadata.DeletionTimestamp = from.DeletionTimestamp.Format(time.RFC3339) } - return res + return res, nil } // PresentManagedCentralWithSecrets return a private.ManagedCentral including secret data -func (c *ManagedCentralPresenter) PresentManagedCentralWithSecrets(from *dbapi.CentralRequest) private.ManagedCentral { - managedCentral := c.PresentManagedCentral(from) +func (c *ManagedCentralPresenter) PresentManagedCentralWithSecrets(from *dbapi.CentralRequest) (private.ManagedCentral, error) { + managedCentral, err := c.PresentManagedCentral(from) + if err != nil { + return private.ManagedCentral{}, err + } secretInterfaceMap, err := from.Secrets.Object() secretStringMap := make(map[string]string, len(secretInterfaceMap)) if err != nil { - glog.Errorf("Failed to get Secrets for central request as map %q/%s: %v", from.Name, from.ID, err) - return managedCentral + return managedCentral, errors.Wrapf(err, "failed to get Secrets for central request as map %q/%s", from.Name, from.ID) } for k, v := range secretInterfaceMap { @@ -162,7 +182,7 @@ func (c *ManagedCentralPresenter) PresentManagedCentralWithSecrets(from *dbapi.C } managedCentral.Metadata.Secrets = secretStringMap // pragma: allowlist secret - return managedCentral + return managedCentral, nil } func orDefaultQty(qty resource.Quantity, def resource.Quantity) *resource.Quantity { @@ -204,3 +224,24 @@ func getSecretNames(from *dbapi.CentralRequest) []string { return secretNames } + +func centralParamsFromRequest(centralRequest *dbapi.CentralRequest) gitops.CentralParams { + return gitops.CentralParams{ + ID: centralRequest.ID, + Name: centralRequest.Name, + Namespace: centralRequest.Namespace, + Region: centralRequest.Region, + ClusterID: centralRequest.ClusterID, + CloudProvider: centralRequest.CloudProvider, + CloudAccountID: centralRequest.CloudAccountID, + SubscriptionID: centralRequest.SubscriptionID, + Owner: centralRequest.Owner, + OwnerAccountID: centralRequest.OwnerAccountID, + OwnerUserID: centralRequest.OwnerUserID, + Host: centralRequest.Host, + OrganizationID: centralRequest.OrganisationID, + OrganizationName: centralRequest.OrganisationName, + InstanceType: centralRequest.InstanceType, + IsInternal: centralRequest.Internal, + } +} diff --git a/internal/dinosaur/pkg/services/data_plane_cluster.go b/internal/dinosaur/pkg/services/data_plane_cluster.go index 66ae41f658..18b5457c37 100644 --- a/internal/dinosaur/pkg/services/data_plane_cluster.go +++ b/internal/dinosaur/pkg/services/data_plane_cluster.go @@ -35,7 +35,7 @@ type dataPlaneClusterService struct { } // NewDataPlaneClusterService ... -func NewDataPlaneClusterService(config dataPlaneClusterService) *dataPlaneClusterService { +func NewDataPlaneClusterService(config dataPlaneClusterService) DataPlaneClusterService { return &config } diff --git a/internal/dinosaur/pkg/services/data_plane_dinosaur.go b/internal/dinosaur/pkg/services/data_plane_dinosaur.go index faae4a64e2..a7c0357a88 100644 --- a/internal/dinosaur/pkg/services/data_plane_dinosaur.go +++ b/internal/dinosaur/pkg/services/data_plane_dinosaur.go @@ -6,13 +6,12 @@ import ( "strings" "time" - constants2 "github.com/stackrox/acs-fleet-manager/internal/dinosaur/constants" - "github.com/golang/glog" "github.com/pkg/errors" + "github.com/stackrox/acs-fleet-manager/internal/dinosaur/constants" "github.com/stackrox/acs-fleet-manager/internal/dinosaur/pkg/api/dbapi" - "github.com/stackrox/acs-fleet-manager/internal/dinosaur/pkg/config" "github.com/stackrox/acs-fleet-manager/pkg/api" + "github.com/stackrox/acs-fleet-manager/pkg/db" serviceError "github.com/stackrox/acs-fleet-manager/pkg/errors" "github.com/stackrox/acs-fleet-manager/pkg/logger" "github.com/stackrox/acs-fleet-manager/pkg/metrics" @@ -32,26 +31,31 @@ const ( // DataPlaneCentralService ... type DataPlaneCentralService interface { UpdateDataPlaneCentralService(ctx context.Context, clusterID string, status []*dbapi.DataPlaneCentralStatus) *serviceError.ServiceError + ListByClusterID(clusterID string) (dbapi.CentralList, *serviceError.ServiceError) } type dataPlaneCentralService struct { - dinosaurService DinosaurService - clusterService ClusterService - dinosaurConfig *config.CentralConfig + dinosaurService DinosaurService + clusterService ClusterService + connectionFactory *db.ConnectionFactory } // NewDataPlaneCentralService ... -func NewDataPlaneCentralService(dinosaurSrv DinosaurService, clusterSrv ClusterService, dinosaurConfig *config.CentralConfig) *dataPlaneCentralService { +func NewDataPlaneCentralService( + dinosaurSrv DinosaurService, + clusterSrv ClusterService, + connectionFactory *db.ConnectionFactory, +) DataPlaneCentralService { return &dataPlaneCentralService{ - dinosaurService: dinosaurSrv, - clusterService: clusterSrv, - dinosaurConfig: dinosaurConfig, + dinosaurService: dinosaurSrv, + clusterService: clusterSrv, + connectionFactory: connectionFactory, } } // UpdateDataPlaneCentralService ... -func (d *dataPlaneCentralService) UpdateDataPlaneCentralService(ctx context.Context, clusterID string, status []*dbapi.DataPlaneCentralStatus) *serviceError.ServiceError { - cluster, err := d.clusterService.FindClusterByID(clusterID) +func (s *dataPlaneCentralService) UpdateDataPlaneCentralService(ctx context.Context, clusterID string, status []*dbapi.DataPlaneCentralStatus) *serviceError.ServiceError { + cluster, err := s.clusterService.FindClusterByID(clusterID) log := logger.NewUHCLogger(ctx) if err != nil { return err @@ -61,7 +65,7 @@ func (d *dataPlaneCentralService) UpdateDataPlaneCentralService(ctx context.Cont return serviceError.BadRequest("Cluster id %s not found", clusterID) } for _, ks := range status { - dinosaur, getErr := d.dinosaurService.GetByID(ks.CentralClusterID) + dinosaur, getErr := s.dinosaurService.GetByID(ks.CentralClusterID) if getErr != nil { glog.Error(errors.Wrapf(getErr, "failed to get central cluster by id %s", ks.CentralClusterID)) continue @@ -71,22 +75,22 @@ func (d *dataPlaneCentralService) UpdateDataPlaneCentralService(ctx context.Cont continue } var e *serviceError.ServiceError - switch s := getStatus(ks); s { + switch getStatus(ks) { case statusReady: // Persist values only known once central is in statusReady e.g. routes, secrets - e = d.persistCentralValues(dinosaur, ks, cluster) + e = s.persistCentralValues(dinosaur, ks, cluster) if e == nil { - e = d.setCentralClusterReady(dinosaur) + e = s.setCentralClusterReady(dinosaur) } case statusError: // when getStatus returns statusError we know that the ready // condition will be there so there's no need to check for it readyCondition, _ := ks.GetReadyCondition() - e = d.setCentralClusterFailed(dinosaur, readyCondition.Message) + e = s.setCentralClusterFailed(dinosaur, readyCondition.Message) case statusDeleted: - e = d.setCentralClusterDeleting(dinosaur) + e = s.setCentralClusterDeleting(dinosaur) case statusRejected: - e = d.reassignCentralCluster(dinosaur) + e = s.reassignCentralCluster(dinosaur) case statusUnknown: log.Infof("central cluster %s status is unknown", ks.CentralClusterID) default: @@ -100,7 +104,22 @@ func (d *dataPlaneCentralService) UpdateDataPlaneCentralService(ctx context.Cont return nil } -func (d *dataPlaneCentralService) setCentralClusterReady(centralRequest *dbapi.CentralRequest) *serviceError.ServiceError { +// ListByClusterID returns a list of CentralRequests with specified clusterID +func (s *dataPlaneCentralService) ListByClusterID(clusterID string) (dbapi.CentralList, *serviceError.ServiceError) { + dbConn := s.connectionFactory.New(). + Where("cluster_id = ?", clusterID). + Where("status IN (?)", dinosaurManagedCRStatuses). + Where("host != ''") + + var centralRequests dbapi.CentralList + if err := dbConn.Find(¢ralRequests).Error; err != nil { + return nil, serviceError.NewWithCause(serviceError.ErrorGeneral, err, "unable to list central requests") + } + + return centralRequests, nil +} + +func (s *dataPlaneCentralService) setCentralClusterReady(centralRequest *dbapi.CentralRequest) *serviceError.ServiceError { if !centralRequest.RoutesCreated { logger.Logger.V(10).Infof("routes for central %s are not created", centralRequest.ID) return nil @@ -108,72 +127,72 @@ func (d *dataPlaneCentralService) setCentralClusterReady(centralRequest *dbapi.C logger.Logger.Infof("routes for central %s are created", centralRequest.ID) // only send metrics data if the current dinosaur request is in "provisioning" status as this is the only case we want to report - shouldSendMetric, err := d.checkCentralRequestCurrentStatus(centralRequest, constants2.CentralRequestStatusProvisioning) + shouldSendMetric, err := s.checkCentralRequestCurrentStatus(centralRequest, constants.CentralRequestStatusProvisioning) if err != nil { return err } - err = d.dinosaurService.Updates(centralRequest, map[string]interface{}{"failed_reason": "", "status": constants2.CentralRequestStatusReady.String()}) + err = s.dinosaurService.Updates(centralRequest, map[string]interface{}{"failed_reason": "", "status": constants.CentralRequestStatusReady.String()}) if err != nil { - return serviceError.NewWithCause(err.Code, err, "failed to update status %s for central cluster %s", constants2.CentralRequestStatusReady, centralRequest.ID) + return serviceError.NewWithCause(err.Code, err, "failed to update status %s for central cluster %s", constants.CentralRequestStatusReady, centralRequest.ID) } if shouldSendMetric { - metrics.UpdateCentralRequestsStatusSinceCreatedMetric(constants2.CentralRequestStatusReady, centralRequest.ID, centralRequest.ClusterID, time.Since(centralRequest.CreatedAt)) + metrics.UpdateCentralRequestsStatusSinceCreatedMetric(constants.CentralRequestStatusReady, centralRequest.ID, centralRequest.ClusterID, time.Since(centralRequest.CreatedAt)) metrics.UpdateCentralCreationDurationMetric(metrics.JobTypeCentralCreate, time.Since(centralRequest.CreatedAt)) - metrics.IncreaseCentralSuccessOperationsCountMetric(constants2.CentralOperationCreate) - metrics.IncreaseCentralTotalOperationsCountMetric(constants2.CentralOperationCreate) + metrics.IncreaseCentralSuccessOperationsCountMetric(constants.CentralOperationCreate) + metrics.IncreaseCentralTotalOperationsCountMetric(constants.CentralOperationCreate) } return nil } -func (d *dataPlaneCentralService) setCentralClusterFailed(centralRequest *dbapi.CentralRequest, errMessage string) *serviceError.ServiceError { +func (s *dataPlaneCentralService) setCentralClusterFailed(centralRequest *dbapi.CentralRequest, errMessage string) *serviceError.ServiceError { // if dinosaur was already reported as failed we don't do anything - if centralRequest.Status == string(constants2.CentralRequestStatusFailed) { + if centralRequest.Status == string(constants.CentralRequestStatusFailed) { return nil } // only send metrics data if the current dinosaur request is in "provisioning" status as this is the only case we want to report - shouldSendMetric, err := d.checkCentralRequestCurrentStatus(centralRequest, constants2.CentralRequestStatusProvisioning) + shouldSendMetric, err := s.checkCentralRequestCurrentStatus(centralRequest, constants.CentralRequestStatusProvisioning) if err != nil { return err } - centralRequest.Status = string(constants2.CentralRequestStatusFailed) + centralRequest.Status = string(constants.CentralRequestStatusFailed) centralRequest.FailedReason = fmt.Sprintf("Central reported as failed: '%s'", errMessage) - err = d.dinosaurService.Update(centralRequest) + err = s.dinosaurService.Update(centralRequest) if err != nil { - return serviceError.NewWithCause(err.Code, err, "failed to update central cluster to %s status for central cluster %s", constants2.CentralRequestStatusFailed, centralRequest.ID) + return serviceError.NewWithCause(err.Code, err, "failed to update central cluster to %s status for central cluster %s", constants.CentralRequestStatusFailed, centralRequest.ID) } if shouldSendMetric { - metrics.UpdateCentralRequestsStatusSinceCreatedMetric(constants2.CentralRequestStatusFailed, centralRequest.ID, centralRequest.ClusterID, time.Since(centralRequest.CreatedAt)) - metrics.IncreaseCentralTotalOperationsCountMetric(constants2.CentralOperationCreate) + metrics.UpdateCentralRequestsStatusSinceCreatedMetric(constants.CentralRequestStatusFailed, centralRequest.ID, centralRequest.ClusterID, time.Since(centralRequest.CreatedAt)) + metrics.IncreaseCentralTotalOperationsCountMetric(constants.CentralOperationCreate) } logger.Logger.Errorf("Central status for Central ID '%s' in ClusterID '%s' reported as failed by Fleet Shard Operator: '%s'", centralRequest.ID, centralRequest.ClusterID, errMessage) return nil } -func (d *dataPlaneCentralService) setCentralClusterDeleting(centralRequest *dbapi.CentralRequest) *serviceError.ServiceError { +func (s *dataPlaneCentralService) setCentralClusterDeleting(centralRequest *dbapi.CentralRequest) *serviceError.ServiceError { // If the Dinosaur cluster is deleted from the data plane cluster, we will make it as "deleting" in db and the reconcilier will ensure it is cleaned up properly - if ok, updateErr := d.dinosaurService.UpdateStatus(centralRequest.ID, constants2.CentralRequestStatusDeleting); ok { + if ok, updateErr := s.dinosaurService.UpdateStatus(centralRequest.ID, constants.CentralRequestStatusDeleting); ok { if updateErr != nil { - return serviceError.NewWithCause(updateErr.Code, updateErr, "failed to update status %s for central cluster %s", constants2.CentralRequestStatusDeleting, centralRequest.ID) + return serviceError.NewWithCause(updateErr.Code, updateErr, "failed to update status %s for central cluster %s", constants.CentralRequestStatusDeleting, centralRequest.ID) } - metrics.UpdateCentralRequestsStatusSinceCreatedMetric(constants2.CentralRequestStatusDeleting, centralRequest.ID, centralRequest.ClusterID, time.Since(centralRequest.CreatedAt)) + metrics.UpdateCentralRequestsStatusSinceCreatedMetric(constants.CentralRequestStatusDeleting, centralRequest.ID, centralRequest.ClusterID, time.Since(centralRequest.CreatedAt)) } return nil } -func (d *dataPlaneCentralService) reassignCentralCluster(centralRequest *dbapi.CentralRequest) *serviceError.ServiceError { - if centralRequest.Status == constants2.CentralRequestStatusProvisioning.String() { +func (s *dataPlaneCentralService) reassignCentralCluster(centralRequest *dbapi.CentralRequest) *serviceError.ServiceError { + if centralRequest.Status == constants.CentralRequestStatusProvisioning.String() { // If a Dinosaur cluster is rejected by the fleetshard-operator, it should be assigned to another OSD cluster (via some scheduler service in the future). // But now we only have one OSD cluster, so we need to change the placementId field so that the fleetshard-operator will try it again // In the future, we may consider adding a new table to track the placement history for dinosaur clusters if there are multiple OSD clusters and the value here can be the key of that table centralRequest.PlacementID = api.NewID() - if err := d.dinosaurService.Update(centralRequest); err != nil { + if err := s.dinosaurService.Update(centralRequest); err != nil { return err } - metrics.UpdateCentralRequestsStatusSinceCreatedMetric(constants2.CentralRequestStatusProvisioning, centralRequest.ID, centralRequest.ClusterID, time.Since(centralRequest.CreatedAt)) + metrics.UpdateCentralRequestsStatusSinceCreatedMetric(constants.CentralRequestStatusProvisioning, centralRequest.ID, centralRequest.ClusterID, time.Since(centralRequest.CreatedAt)) } else { logger.Logger.Infof("central cluster %s is rejected and current status is %s", centralRequest.ID, centralRequest.Status) } @@ -206,9 +225,9 @@ func getStatus(status *dbapi.DataPlaneCentralStatus) centralStatus { } return statusInstalling } -func (d *dataPlaneCentralService) checkCentralRequestCurrentStatus(centralRequest *dbapi.CentralRequest, status constants2.CentralStatus) (bool, *serviceError.ServiceError) { +func (s *dataPlaneCentralService) checkCentralRequestCurrentStatus(centralRequest *dbapi.CentralRequest, status constants.CentralStatus) (bool, *serviceError.ServiceError) { matchStatus := false - if currentInstance, err := d.dinosaurService.GetByID(centralRequest.ID); err != nil { + if currentInstance, err := s.dinosaurService.GetByID(centralRequest.ID); err != nil { return matchStatus, err } else if currentInstance.Status == status.String() { matchStatus = true @@ -216,29 +235,29 @@ func (d *dataPlaneCentralService) checkCentralRequestCurrentStatus(centralReques return matchStatus, nil } -func (d *dataPlaneCentralService) persistCentralValues(centralRequest *dbapi.CentralRequest, centralStatus *dbapi.DataPlaneCentralStatus, cluster *api.Cluster) *serviceError.ServiceError { - if err := d.addRoutesToRequest(centralRequest, centralStatus, cluster); err != nil { +func (s *dataPlaneCentralService) persistCentralValues(centralRequest *dbapi.CentralRequest, centralStatus *dbapi.DataPlaneCentralStatus, cluster *api.Cluster) *serviceError.ServiceError { + if err := s.addRoutesToRequest(centralRequest, centralStatus, cluster); err != nil { return err } - if err := d.addSecretsToRequest(centralRequest, centralStatus, cluster); err != nil { + if err := s.addSecretsToRequest(centralRequest, centralStatus, cluster); err != nil { return err } - if err := d.dinosaurService.Update(centralRequest); err != nil { + if err := s.dinosaurService.Update(centralRequest); err != nil { return serviceError.NewWithCause(err.Code, err, "failed to update routes for central cluster %s", centralRequest.ID) } return nil } -func (d *dataPlaneCentralService) addRoutesToRequest(centralRequest *dbapi.CentralRequest, centralStatus *dbapi.DataPlaneCentralStatus, cluster *api.Cluster) *serviceError.ServiceError { +func (s *dataPlaneCentralService) addRoutesToRequest(centralRequest *dbapi.CentralRequest, centralStatus *dbapi.DataPlaneCentralStatus, cluster *api.Cluster) *serviceError.ServiceError { if centralRequest.Routes != nil { logger.Logger.V(10).Infof("skip persisting routes for Central %s as they are already stored", centralRequest.ID) return nil } logger.Logger.Infof("store routes information for central %s", centralRequest.ID) - clusterDNS, err := d.clusterService.GetClusterDNS(cluster.ClusterID) + clusterDNS, err := s.clusterService.GetClusterDNS(cluster.ClusterID) if err != nil { return serviceError.NewWithCause(err.Code, err, "failed to get DNS entry for cluster %s", cluster.ClusterID) } diff --git a/internal/dinosaur/pkg/services/dinosaur.go b/internal/dinosaur/pkg/services/dinosaur.go index 70a72aea20..9a397d1be2 100644 --- a/internal/dinosaur/pkg/services/dinosaur.go +++ b/internal/dinosaur/pkg/services/dinosaur.go @@ -15,9 +15,6 @@ import ( "github.com/stackrox/acs-fleet-manager/internal/dinosaur/pkg/config" "github.com/stackrox/acs-fleet-manager/internal/dinosaur/pkg/dinosaurs/types" "github.com/stackrox/acs-fleet-manager/pkg/services" - "github.com/stackrox/acs-fleet-manager/pkg/services/sso" - - "github.com/stackrox/acs-fleet-manager/pkg/services/authorization" coreServices "github.com/stackrox/acs-fleet-manager/pkg/services/queryparser" "github.com/golang/glog" @@ -83,7 +80,6 @@ type DinosaurService interface { // The Dinosaur Request in the database will be updated with a deleted_at timestamp. Delete(centralRequest *dbapi.CentralRequest, force bool) *errors.ServiceError List(ctx context.Context, listArgs *services.ListArguments) (dbapi.CentralList, *api.PagingMeta, *errors.ServiceError) - ListByClusterID(clusterID string) ([]*dbapi.CentralRequest, *errors.ServiceError) RegisterDinosaurJob(dinosaurRequest *dbapi.CentralRequest) *errors.ServiceError ListByStatus(status ...dinosaurConstants.CentralStatus) ([]*dbapi.CentralRequest, *errors.ServiceError) // UpdateStatus change the status of the Dinosaur cluster @@ -118,13 +114,11 @@ var _ DinosaurService = &dinosaurService{} type dinosaurService struct { connectionFactory *db.ConnectionFactory clusterService ClusterService - iamService sso.IAMService dinosaurConfig *config.CentralConfig awsConfig *config.AWSConfig quotaServiceFactory QuotaServiceFactory mu sync.Mutex awsClientFactory aws.ClientFactory - authService authorization.Authorization dataplaneClusterConfig *config.DataplaneClusterConfig clusterPlacementStrategy ClusterPlacementStrategy amsClient ocm.AMSClient @@ -134,20 +128,18 @@ type dinosaurService struct { } // NewDinosaurService ... -func NewDinosaurService(connectionFactory *db.ConnectionFactory, clusterService ClusterService, iamService sso.IAMService, +func NewDinosaurService(connectionFactory *db.ConnectionFactory, clusterService ClusterService, iamConfig *iam.IAMConfig, dinosaurConfig *config.CentralConfig, dataplaneClusterConfig *config.DataplaneClusterConfig, awsConfig *config.AWSConfig, - quotaServiceFactory QuotaServiceFactory, awsClientFactory aws.ClientFactory, authorizationService authorization.Authorization, - clusterPlacementStrategy ClusterPlacementStrategy, amsClient ocm.AMSClient, centralDefaultVersionService CentralDefaultVersionService) *dinosaurService { + quotaServiceFactory QuotaServiceFactory, awsClientFactory aws.ClientFactory, + clusterPlacementStrategy ClusterPlacementStrategy, amsClient ocm.AMSClient, centralDefaultVersionService CentralDefaultVersionService) DinosaurService { return &dinosaurService{ connectionFactory: connectionFactory, clusterService: clusterService, - iamService: iamService, iamConfig: iamConfig, dinosaurConfig: dinosaurConfig, awsConfig: awsConfig, quotaServiceFactory: quotaServiceFactory, awsClientFactory: awsClientFactory, - authService: authorizationService, dataplaneClusterConfig: dataplaneClusterConfig, clusterPlacementStrategy: clusterPlacementStrategy, amsClient: amsClient, @@ -676,21 +668,6 @@ func (k *dinosaurService) List(ctx context.Context, listArgs *services.ListArgum return dinosaurRequestList, pagingMeta, nil } -// ListByClusterID returns a list of CentralRequests with specified clusterID -func (k *dinosaurService) ListByClusterID(clusterID string) ([]*dbapi.CentralRequest, *errors.ServiceError) { - dbConn := k.connectionFactory.New(). - Where("cluster_id = ?", clusterID). - Where("status IN (?)", dinosaurManagedCRStatuses). - Where("host != ''") - - var dinosaurRequestList dbapi.CentralList - if err := dbConn.Find(&dinosaurRequestList).Error; err != nil { - return nil, errors.NewWithCause(errors.ErrorGeneral, err, "unable to list central requests") - } - - return dinosaurRequestList, nil -} - // Update ... func (k *dinosaurService) Update(dinosaurRequest *dbapi.CentralRequest) *errors.ServiceError { dbConn := k.connectionFactory.New(). diff --git a/internal/dinosaur/pkg/services/dinosaurservice_moq.go b/internal/dinosaur/pkg/services/dinosaurservice_moq.go index a9231f48bf..7f4e2a6593 100644 --- a/internal/dinosaur/pkg/services/dinosaurservice_moq.go +++ b/internal/dinosaur/pkg/services/dinosaurservice_moq.go @@ -64,9 +64,6 @@ var _ DinosaurService = &DinosaurServiceMock{} // ListFunc: func(ctx context.Context, listArgs *services.ListArguments) (dbapi.CentralList, *api.PagingMeta, *serviceError.ServiceError) { // panic("mock out the List method") // }, -// ListByClusterIDFunc: func(clusterID string) ([]*dbapi.CentralRequest, *serviceError.ServiceError) { -// panic("mock out the ListByClusterID method") -// }, // ListByStatusFunc: func(status ...dinosaurConstants.CentralStatus) ([]*dbapi.CentralRequest, *serviceError.ServiceError) { // panic("mock out the ListByStatus method") // }, @@ -149,9 +146,6 @@ type DinosaurServiceMock struct { // ListFunc mocks the List method. ListFunc func(ctx context.Context, listArgs *services.ListArguments) (dbapi.CentralList, *api.PagingMeta, *serviceError.ServiceError) - // ListByClusterIDFunc mocks the ListByClusterID method. - ListByClusterIDFunc func(clusterID string) ([]*dbapi.CentralRequest, *serviceError.ServiceError) - // ListByStatusFunc mocks the ListByStatus method. ListByStatusFunc func(status ...dinosaurConstants.CentralStatus) ([]*dbapi.CentralRequest, *serviceError.ServiceError) @@ -261,11 +255,6 @@ type DinosaurServiceMock struct { // ListArgs is the listArgs argument value. ListArgs *services.ListArguments } - // ListByClusterID holds details about calls to the ListByClusterID method. - ListByClusterID []struct { - // ClusterID is the clusterID argument value. - ClusterID string - } // ListByStatus holds details about calls to the ListByStatus method. ListByStatus []struct { // Status is the status argument value. @@ -348,7 +337,6 @@ type DinosaurServiceMock struct { lockGetCNAMERecordStatus sync.RWMutex lockHasAvailableCapacityInRegion sync.RWMutex lockList sync.RWMutex - lockListByClusterID sync.RWMutex lockListByStatus sync.RWMutex lockListCentralsWithoutAuthConfig sync.RWMutex lockListDinosaursWithRoutesNotCreated sync.RWMutex @@ -790,38 +778,6 @@ func (mock *DinosaurServiceMock) ListCalls() []struct { return calls } -// ListByClusterID calls ListByClusterIDFunc. -func (mock *DinosaurServiceMock) ListByClusterID(clusterID string) ([]*dbapi.CentralRequest, *serviceError.ServiceError) { - if mock.ListByClusterIDFunc == nil { - panic("DinosaurServiceMock.ListByClusterIDFunc: method is nil but DinosaurService.ListByClusterID was just called") - } - callInfo := struct { - ClusterID string - }{ - ClusterID: clusterID, - } - mock.lockListByClusterID.Lock() - mock.calls.ListByClusterID = append(mock.calls.ListByClusterID, callInfo) - mock.lockListByClusterID.Unlock() - return mock.ListByClusterIDFunc(clusterID) -} - -// ListByClusterIDCalls gets all the calls that were made to ListByClusterID. -// Check the length with: -// -// len(mockedDinosaurService.ListByClusterIDCalls()) -func (mock *DinosaurServiceMock) ListByClusterIDCalls() []struct { - ClusterID string -} { - var calls []struct { - ClusterID string - } - mock.lockListByClusterID.RLock() - calls = mock.calls.ListByClusterID - mock.lockListByClusterID.RUnlock() - return calls -} - // ListByStatus calls ListByStatusFunc. func (mock *DinosaurServiceMock) ListByStatus(status ...dinosaurConstants.CentralStatus) ([]*dbapi.CentralRequest, *serviceError.ServiceError) { if mock.ListByStatusFunc == nil { diff --git a/internal/dinosaur/providers.go b/internal/dinosaur/providers.go index 212d52ce7a..0809a46d4b 100644 --- a/internal/dinosaur/providers.go +++ b/internal/dinosaur/providers.go @@ -6,6 +6,7 @@ import ( "github.com/stackrox/acs-fleet-manager/internal/dinosaur/pkg/clusters" "github.com/stackrox/acs-fleet-manager/internal/dinosaur/pkg/config" "github.com/stackrox/acs-fleet-manager/internal/dinosaur/pkg/environments" + "github.com/stackrox/acs-fleet-manager/internal/dinosaur/pkg/gitops" "github.com/stackrox/acs-fleet-manager/internal/dinosaur/pkg/handlers" "github.com/stackrox/acs-fleet-manager/internal/dinosaur/pkg/migrations" "github.com/stackrox/acs-fleet-manager/internal/dinosaur/pkg/presenters" @@ -55,13 +56,13 @@ func ConfigProviders() di.Option { func ServiceProviders() di.Option { return di.Options( di.Provide(services.NewClusterService), - di.Provide(services.NewDinosaurService, di.As(new(services.DinosaurService))), + di.Provide(services.NewDinosaurService), di.Provide(services.NewCloudProvidersService), di.Provide(services.NewObservatoriumService), di.Provide(services.NewFleetshardOperatorAddon), di.Provide(services.NewClusterPlacementStrategy), - di.Provide(services.NewDataPlaneClusterService, di.As(new(services.DataPlaneClusterService))), - di.Provide(services.NewDataPlaneCentralService, di.As(new(services.DataPlaneCentralService))), + di.Provide(services.NewDataPlaneClusterService), + di.Provide(services.NewDataPlaneCentralService), di.Provide(handlers.NewAuthenticationBuilder), di.Provide(clusters.NewDefaultProviderFactory, di.As(new(clusters.ProviderFactory))), di.Provide(routes.NewRouteLoader), @@ -75,6 +76,9 @@ func ServiceProviders() di.Option { di.Provide(dinosaurmgrs.NewReadyDinosaurManager, di.As(new(workers.Worker))), di.Provide(dinosaurmgrs.NewDinosaurCNAMEManager, di.As(new(workers.Worker))), di.Provide(dinosaurmgrs.NewCentralAuthConfigManager, di.As(new(workers.Worker))), + di.Provide(gitops.NewEmptyReader), + di.Provide(gitops.NewProvider), + di.Provide(gitops.NewService), di.Provide(presenters.NewManagedCentralPresenter), ) }