diff --git a/.github/labeler.yaml b/.github/labeler.yaml index ca2cfef470..70be584717 100644 --- a/.github/labeler.yaml +++ b/.github/labeler.yaml @@ -28,3 +28,5 @@ area/tool: area/pipedv1: - pkg/app/pipedv1/**/* +- pkg/configv1/* +- pkg/configv1/**/* diff --git a/pkg/configv1/analysis.go b/pkg/configv1/analysis.go new file mode 100644 index 0000000000..093a1908ef --- /dev/null +++ b/pkg/configv1/analysis.go @@ -0,0 +1,178 @@ +// Copyright 2024 The PipeCD 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" + "strconv" + "strings" +) + +const ( + AnalysisStrategyThreshold = "THRESHOLD" + AnalysisStrategyPrevious = "PREVIOUS" + AnalysisStrategyCanaryBaseline = "CANARY_BASELINE" + AnalysisStrategyCanaryPrimary = "CANARY_PRIMARY" + + AnalysisDeviationEither = "EITHER" + AnalysisDeviationHigh = "HIGH" + AnalysisDeviationLow = "LOW" +) + +// AnalysisMetrics contains common configurable values for deployment analysis with metrics. +type AnalysisMetrics struct { + // The strategy name. One of THRESHOLD or PREVIOUS or CANARY_BASELINE or CANARY_PRIMARY is available. + // Defaults to THRESHOLD. + Strategy string `json:"strategy" default:"THRESHOLD"` + // The unique name of provider defined in the Piped Configuration. + // Required field. + Provider string `json:"provider"` + // A query performed against the Analysis Provider. + // Required field. + Query string `json:"query"` + // The expected query result. + // Required field for the THRESHOLD strategy. + Expected AnalysisExpected `json:"expected"` + // Run a query at this intervals. + // Required field. + Interval Duration `json:"interval"` + // Acceptable number of failures. For instance, If 1 is set, + // the analysis will be considered a failure after 2 failures. + // Default is 0. + FailureLimit int `json:"failureLimit"` + // If true, it considers as a success when no data returned from the analysis provider. + // Default is false. + SkipOnNoData bool `json:"skipOnNoData"` + // How long after which the query times out. + // Default is 30s. + Timeout Duration `json:"timeout" default:"30s"` + + // The stage fails on deviation in the specified direction. One of LOW or HIGH or EITHER is available. + // This can be used only for PREVIOUS, CANARY_BASELINE or CANARY_PRIMARY. Defaults to EITHER. + Deviation string `json:"deviation" default:"EITHER"` + // The custom arguments to be populated for the Canary query. + // They can be referred as {{ .VariantArgs.xxx }}. + CanaryArgs map[string]string `json:"canaryArgs"` + // The custom arguments to be populated for the Baseline query. + // They can be referred as {{ .VariantArgs.xxx }}. + BaselineArgs map[string]string `json:"baselineArgs"` + // The custom arguments to be populated for the Primary query. + // They can be referred as {{ .VariantArgs.xxx }}. + PrimaryArgs map[string]string `json:"primaryArgs"` +} + +func (m *AnalysisMetrics) Validate() error { + if m.Provider == "" { + return fmt.Errorf("missing \"provider\" field") + } + if m.Query == "" { + return fmt.Errorf("missing \"query\" field") + } + if m.Interval == 0 { + return fmt.Errorf("missing \"interval\" field") + } + if m.Deviation != AnalysisDeviationEither && m.Deviation != AnalysisDeviationHigh && m.Deviation != AnalysisDeviationLow { + return fmt.Errorf("\"deviation\" have to be one of %s, %s or %s", AnalysisDeviationEither, AnalysisDeviationHigh, AnalysisDeviationLow) + } + return nil +} + +// AnalysisExpected defines the range used for metrics analysis. +type AnalysisExpected struct { + Min *float64 `json:"min"` + Max *float64 `json:"max"` +} + +func (e *AnalysisExpected) Validate() error { + if e.Min == nil && e.Max == nil { + return fmt.Errorf("expected range is undefined") + } + return nil +} + +// InRange returns true if the given value is within the range. +func (e *AnalysisExpected) InRange(value float64) bool { + if e.Min != nil && *e.Min > value { + return false + } + if e.Max != nil && *e.Max < value { + return false + } + return true +} + +func (e *AnalysisExpected) String() string { + if e.Min == nil && e.Max == nil { + return "" + } + + var b strings.Builder + if e.Min != nil { + min := strconv.FormatFloat(*e.Min, 'f', -1, 64) + b.WriteString(min + " ") + } + + b.WriteString("<=") + + if e.Max != nil { + max := strconv.FormatFloat(*e.Max, 'f', -1, 64) + b.WriteString(" " + max) + } + return b.String() +} + +// AnalysisLog contains common configurable values for deployment analysis with log. +type AnalysisLog struct { + Query string `json:"query"` + Interval Duration `json:"interval"` + // Maximum number of failed checks before the query result is considered as failure. + FailureLimit int `json:"failureLimit"` + // If true, it considers as success when no data returned from the analysis provider. + // Default is false. + SkipOnNoData bool `json:"skipOnNoData"` + // How long after which the query times out. + Timeout Duration `json:"timeout"` + Provider string `json:"provider"` +} + +func (a *AnalysisLog) Validate() error { + return nil +} + +// AnalysisHTTP contains common configurable values for deployment analysis with http. +type AnalysisHTTP struct { + URL string `json:"url"` + Method string `json:"method"` + // Custom headers to set in the request. HTTP allows repeated headers. + Headers []AnalysisHTTPHeader `json:"headers"` + ExpectedCode int `json:"expectedCode"` + ExpectedResponse string `json:"expectedResponse"` + Interval Duration `json:"interval"` + // Maximum number of failed checks before the response is considered as failure. + FailureLimit int `json:"failureLimit"` + // If true, it considers as success when no data returned from the analysis provider. + // Default is false. + SkipOnNoData bool `json:"skipOnNoData"` + Timeout Duration `json:"timeout"` +} + +func (a *AnalysisHTTP) Validate() error { + return nil +} + +type AnalysisHTTPHeader struct { + Key string `json:"key"` + Value string `json:"value"` +} diff --git a/pkg/configv1/analysis_template.go b/pkg/configv1/analysis_template.go new file mode 100644 index 0000000000..d00be027e4 --- /dev/null +++ b/pkg/configv1/analysis_template.go @@ -0,0 +1,63 @@ +// Copyright 2024 The PipeCD 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/filepath" +) + +type AnalysisTemplateSpec struct { + Metrics map[string]AnalysisMetrics `json:"metrics"` + Logs map[string]AnalysisLog `json:"logs"` + HTTPS map[string]AnalysisHTTP `json:"https"` +} + +// LoadAnalysisTemplate finds the config file for the analysis template in the .pipe +// directory first up. And returns parsed config, ErrNotFound is returned if not found. +func LoadAnalysisTemplate(repoRoot string) (*AnalysisTemplateSpec, error) { + dir := filepath.Join(repoRoot, SharedConfigurationDirName) + files, err := os.ReadDir(dir) + if os.IsNotExist(err) { + return nil, ErrNotFound + } + if err != nil { + return nil, fmt.Errorf("failed to read %s: %w", dir, err) + } + + for _, f := range files { + if f.IsDir() { + continue + } + ext := filepath.Ext(f.Name()) + if ext != ".yaml" && ext != ".yml" && ext != ".json" { + continue + } + path := filepath.Join(dir, f.Name()) + cfg, err := LoadFromYAML(path) + if err != nil { + return nil, fmt.Errorf("failed to load config file %s: %w", path, err) + } + if cfg.Kind == KindAnalysisTemplate { + return cfg.AnalysisTemplateSpec, nil + } + } + return nil, ErrNotFound +} + +func (s *AnalysisTemplateSpec) Validate() error { + return nil +} diff --git a/pkg/configv1/analysis_template_test.go b/pkg/configv1/analysis_template_test.go new file mode 100644 index 0000000000..1a98dd76d4 --- /dev/null +++ b/pkg/configv1/analysis_template_test.go @@ -0,0 +1,110 @@ +// Copyright 2024 The PipeCD 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 ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestLoadAnalysisTemplate(t *testing.T) { + testcases := []struct { + name string + repoDir string + expectedSpec interface{} + expectedError error + }{ + { + name: "Load analysis template successfully", + repoDir: "testdata", + expectedSpec: &AnalysisTemplateSpec{ + Metrics: map[string]AnalysisMetrics{ + "app_http_error_percentage": { + Strategy: AnalysisStrategyThreshold, + Query: "http_error_percentage{env={{ .App.Env }}, app={{ .App.Name }}}", + Expected: AnalysisExpected{Max: floatPointer(0.1)}, + Interval: Duration(time.Minute), + Timeout: Duration(30 * time.Second), + Provider: "datadog-dev", + Deviation: AnalysisDeviationEither, + }, + "container_cpu_usage_seconds_total": { + Strategy: AnalysisStrategyThreshold, + Query: `sum( + max(kube_pod_labels{label_app=~"{{ .App.Name }}", label_pipecd_dev_variant=~"canary"}) by (label_app, label_pipecd_dev_variant, pod) + * + on(pod) + group_right(label_app, label_pipecd_dev_variant) + label_replace( + sum by(pod_name) ( + rate(container_cpu_usage_seconds_total{namespace="default"}[5m]) + ), "pod", "$1", "pod_name", "(.+)" + ) +) by (label_app, label_pipecd_dev_variant) +`, + Expected: AnalysisExpected{Max: floatPointer(0.0001)}, + FailureLimit: 2, + Interval: Duration(10 * time.Second), + Timeout: Duration(30 * time.Second), + Provider: "prometheus-dev", + Deviation: AnalysisDeviationEither, + }, + "grpc_error_rate-percentage": { + Strategy: AnalysisStrategyThreshold, + Query: `100 - sum( + rate( + grpc_server_handled_total{ + grpc_code!="OK", + kubernetes_namespace="{{ .Args.namespace }}", + kubernetes_pod_name=~"{{ .App.Name }}-[0-9a-zA-Z]+(-[0-9a-zA-Z]+)" + }[{{ .Args.interval }}] + ) +) +/ +sum( + rate( + grpc_server_started_total{ + kubernetes_namespace="{{ .Args.namespace }}", + kubernetes_pod_name=~"{{ .App.Name }}-[0-9a-zA-Z]+(-[0-9a-zA-Z]+)" + }[{{ .Args.interval }}] + ) +) * 100 +`, + Expected: AnalysisExpected{Max: floatPointer(10)}, + FailureLimit: 1, + Interval: Duration(time.Minute), + Timeout: Duration(30 * time.Second), + Provider: "prometheus-dev", + Deviation: AnalysisDeviationEither, + }, + }, + }, + expectedError: nil, + }, + } + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + spec, err := LoadAnalysisTemplate(tc.repoDir) + require.Equal(t, tc.expectedError, err) + if err == nil { + assert.Equal(t, tc.expectedSpec, spec) + } + }) + } +} diff --git a/pkg/configv1/analysis_test.go b/pkg/configv1/analysis_test.go new file mode 100644 index 0000000000..ae0b525708 --- /dev/null +++ b/pkg/configv1/analysis_test.go @@ -0,0 +1,65 @@ +// Copyright 2024 The PipeCD 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 ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func floatPointer(v float64) *float64 { + return &v +} + +func TestAnalysisExpectedString(t *testing.T) { + testcases := []struct { + name string + Min *float64 + Max *float64 + want string + }{ + { + name: "only min given", + Min: floatPointer(1.5), + want: "1.5 <=", + }, + { + name: "only max given", + Max: floatPointer(1.5), + want: "<= 1.5", + }, + { + name: "both min and max given", + Min: floatPointer(1.5), + Max: floatPointer(2.5), + want: "1.5 <= 2.5", + }, + { + name: "invalid range", + want: "", + }, + } + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + e := &AnalysisExpected{ + Min: tc.Min, + Max: tc.Max, + } + got := e.String() + assert.Equal(t, tc.want, got) + }) + } +} diff --git a/pkg/configv1/application.go b/pkg/configv1/application.go new file mode 100644 index 0000000000..3492219e5a --- /dev/null +++ b/pkg/configv1/application.go @@ -0,0 +1,798 @@ +// Copyright 2024 The PipeCD 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 ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/pipe-cd/pipecd/pkg/model" +) + +const allEventsSymbol = "*" + +type GenericApplicationSpec struct { + // The application name. + // This is required if you set the application through the application configuration file. + Name string `json:"name"` + // Additional attributes to identify applications. + Labels map[string]string `json:"labels"` + // Notes on the Application. + Description string `json:"description"` + + // Configuration used while planning deployment. + Planner DeploymentPlanner `json:"planner"` + // Forcibly use QuickSync or Pipeline when commit message matched the specified pattern. + CommitMatcher DeploymentCommitMatcher `json:"commitMatcher"` + // Pipeline for deploying progressively. + Pipeline *DeploymentPipeline `json:"pipeline"` + // The trigger configuration use to determine trigger logic. + Trigger Trigger `json:"trigger"` + // Configuration to be used once the deployment is triggered successfully. + PostSync *PostSync `json:"postSync"` + // The maximum length of time to execute deployment before giving up. + // Default is 6h. + Timeout Duration `json:"timeout,omitempty" default:"6h"` + // List of encrypted secrets and targets that should be decoded before using. + Encryption *SecretEncryption `json:"encryption"` + // List of files that should be attached to application manifests before using. + Attachment *Attachment `json:"attachment"` + // Additional configuration used while sending notification to external services. + DeploymentNotification *DeploymentNotification `json:"notification"` + // List of the configuration for event watcher. + EventWatcher []EventWatcherConfig `json:"eventWatcher"` + // Configuration for drift detection + DriftDetection *DriftDetection `json:"driftDetection"` +} + +type DeploymentPlanner struct { + // Disable auto-detecting to use QUICK_SYNC or PROGRESSIVE_SYNC. + // Always use the speficied pipeline for all deployments. + AlwaysUsePipeline bool `json:"alwaysUsePipeline"` + // Automatically reverts all deployment changes on failure. + // Default is true. + AutoRollback *bool `json:"autoRollback,omitempty" default:"true"` +} + +type Trigger struct { + // Configurable fields used while deciding the application + // should be triggered or not based on commit changes. + OnCommit OnCommit `json:"onCommit"` + // Configurable fields used while deciding the application + // should be triggered or not based on received SYNC command. + OnCommand OnCommand `json:"onCommand"` + // Configurable fields used while deciding the application + // should be triggered or not based on OUT_OF_SYNC state. + OnOutOfSync OnOutOfSync `json:"onOutOfSync"` + // Configurable fields used while deciding the application + // should be triggered based on received CHAIN_SYNC command. + OnChain OnChain `json:"onChain"` +} + +type OnCommit struct { + // Whether to exclude application from triggering target + // when a new commit touched the application. + // Default is false. + Disabled bool `json:"disabled,omitempty"` + // List of directories or files where their changes will trigger the deployment. + // Regular expression can be used. + Paths []string `json:"paths,omitempty"` + // List of directories or files where their changes will be ignored. + // Regular expression can be used. + Ignores []string `json:"ignores,omitempty"` +} + +type OnCommand struct { + // Whether to exclude application from triggering target + // when received a new SYNC command. + // Default is false. + Disabled bool `json:"disabled,omitempty"` +} + +type OnOutOfSync struct { + // Whether to exclude application from triggering target + // when application is at OUT_OF_SYNC state. + // Default is true. + Disabled *bool `json:"disabled,omitempty" default:"true"` + // Minimum amount of time must be elapsed since the last deployment. + // This can be used to avoid triggering unnecessary continuous deployments based on OUT_OF_SYNC status. + MinWindow Duration `json:"minWindow,omitempty" default:"5m"` +} + +type OnChain struct { + // Whether to exclude application from triggering target + // when received a new CHAIN_SYNC command. + // Default is true. + Disabled *bool `json:"disabled,omitempty" default:"true"` +} + +func (s *GenericApplicationSpec) Validate() error { + if s.Pipeline != nil { + for _, stage := range s.Pipeline.Stages { + if stage.AnalysisStageOptions != nil { + if err := stage.AnalysisStageOptions.Validate(); err != nil { + return err + } + } + if stage.WaitApprovalStageOptions != nil { + if err := stage.WaitApprovalStageOptions.Validate(); err != nil { + return err + } + } + if stage.CustomSyncOptions != nil { + if err := stage.CustomSyncOptions.Validate(); err != nil { + return err + } + } + } + } + + if ps := s.PostSync; ps != nil { + if err := ps.Validate(); err != nil { + return err + } + } + + if e := s.Encryption; e != nil { + if err := e.Validate(); err != nil { + return err + } + } + + if am := s.Attachment; am != nil { + if err := am.Validate(); err != nil { + return err + } + } + + if s.DeploymentNotification != nil { + for _, m := range s.DeploymentNotification.Mentions { + if err := m.Validate(); err != nil { + return err + } + } + } + + if dd := s.DriftDetection; dd != nil { + if err := dd.Validate(); err != nil { + return err + } + } + + return nil +} + +func (s GenericApplicationSpec) GetStage(index int32) (PipelineStage, bool) { + if s.Pipeline == nil { + return PipelineStage{}, false + } + if int(index) >= len(s.Pipeline.Stages) { + return PipelineStage{}, false + } + return s.Pipeline.Stages[index], true +} + +// HasStage checks if the given stage is included in the pipeline. +func (s GenericApplicationSpec) HasStage(stage model.Stage) bool { + if s.Pipeline == nil { + return false + } + for _, s := range s.Pipeline.Stages { + if s.Name == stage { + return true + } + } + return false +} + +// DeploymentCommitMatcher provides a way to decide how to deploy. +type DeploymentCommitMatcher struct { + // It makes sure to perform syncing if the commit message matches this regular expression. + QuickSync string `json:"quickSync"` + // It makes sure to perform pipeline if the commit message matches this regular expression. + Pipeline string `json:"pipeline"` +} + +// DeploymentPipeline represents the way to deploy the application. +// The pipeline is triggered by changes in any of the following objects: +// - Target PodSpec (Target can be Deployment, DaemonSet, StatefulSet) +// - ConfigMaps, Secrets that are mounted as volumes or envs in the deployment. +type DeploymentPipeline struct { + Stages []PipelineStage `json:"stages"` +} + +// PipelineStage represents a single stage of a pipeline. +// This is used as a generic struct for all stage type. +type PipelineStage struct { + ID string + Name model.Stage + Desc string + Timeout Duration + With json.RawMessage + + CustomSyncOptions *CustomSyncOptions + WaitStageOptions *WaitStageOptions + WaitApprovalStageOptions *WaitApprovalStageOptions + AnalysisStageOptions *AnalysisStageOptions + ScriptRunStageOptions *ScriptRunStageOptions + + K8sPrimaryRolloutStageOptions *K8sPrimaryRolloutStageOptions + K8sCanaryRolloutStageOptions *K8sCanaryRolloutStageOptions + K8sCanaryCleanStageOptions *K8sCanaryCleanStageOptions + K8sBaselineRolloutStageOptions *K8sBaselineRolloutStageOptions + K8sBaselineCleanStageOptions *K8sBaselineCleanStageOptions + K8sTrafficRoutingStageOptions *K8sTrafficRoutingStageOptions + + TerraformSyncStageOptions *TerraformSyncStageOptions + TerraformPlanStageOptions *TerraformPlanStageOptions + TerraformApplyStageOptions *TerraformApplyStageOptions + + CloudRunSyncStageOptions *CloudRunSyncStageOptions + CloudRunPromoteStageOptions *CloudRunPromoteStageOptions + + LambdaSyncStageOptions *LambdaSyncStageOptions + LambdaCanaryRolloutStageOptions *LambdaCanaryRolloutStageOptions + LambdaPromoteStageOptions *LambdaPromoteStageOptions + + ECSSyncStageOptions *ECSSyncStageOptions + ECSCanaryRolloutStageOptions *ECSCanaryRolloutStageOptions + ECSPrimaryRolloutStageOptions *ECSPrimaryRolloutStageOptions + ECSCanaryCleanStageOptions *ECSCanaryCleanStageOptions + ECSTrafficRoutingStageOptions *ECSTrafficRoutingStageOptions +} + +type genericPipelineStage struct { + ID string `json:"id"` + Name model.Stage `json:"name"` + Desc string `json:"desc,omitempty"` + Timeout Duration `json:"timeout"` + With json.RawMessage `json:"with"` +} + +func (s *PipelineStage) UnmarshalJSON(data []byte) error { + var err error + gs := genericPipelineStage{} + if err = json.Unmarshal(data, &gs); err != nil { + return err + } + s.ID = gs.ID + s.Name = gs.Name + s.Desc = gs.Desc + s.Timeout = gs.Timeout + s.With = gs.With + + switch s.Name { + case model.StageCustomSync: + s.CustomSyncOptions = &CustomSyncOptions{} + if len(gs.With) > 0 { + err = json.Unmarshal(gs.With, s.CustomSyncOptions) + } + case model.StageWait: + s.WaitStageOptions = &WaitStageOptions{} + if len(gs.With) > 0 { + err = json.Unmarshal(gs.With, s.WaitStageOptions) + } + case model.StageWaitApproval: + s.WaitApprovalStageOptions = &WaitApprovalStageOptions{} + if len(gs.With) > 0 { + err = json.Unmarshal(gs.With, s.WaitApprovalStageOptions) + } + case model.StageAnalysis: + s.AnalysisStageOptions = &AnalysisStageOptions{} + if len(gs.With) > 0 { + err = json.Unmarshal(gs.With, s.AnalysisStageOptions) + } + case model.StageScriptRun: + s.ScriptRunStageOptions = &ScriptRunStageOptions{} + if len(gs.With) > 0 { + err = json.Unmarshal(gs.With, s.ScriptRunStageOptions) + } + + case model.StageK8sPrimaryRollout: + s.K8sPrimaryRolloutStageOptions = &K8sPrimaryRolloutStageOptions{} + if len(gs.With) > 0 { + err = json.Unmarshal(gs.With, s.K8sPrimaryRolloutStageOptions) + } + case model.StageK8sCanaryRollout: + s.K8sCanaryRolloutStageOptions = &K8sCanaryRolloutStageOptions{} + if len(gs.With) > 0 { + err = json.Unmarshal(gs.With, s.K8sCanaryRolloutStageOptions) + } + case model.StageK8sCanaryClean: + s.K8sCanaryCleanStageOptions = &K8sCanaryCleanStageOptions{} + if len(gs.With) > 0 { + err = json.Unmarshal(gs.With, s.K8sCanaryCleanStageOptions) + } + case model.StageK8sBaselineRollout: + s.K8sBaselineRolloutStageOptions = &K8sBaselineRolloutStageOptions{} + if len(gs.With) > 0 { + err = json.Unmarshal(gs.With, s.K8sBaselineRolloutStageOptions) + } + case model.StageK8sBaselineClean: + s.K8sBaselineCleanStageOptions = &K8sBaselineCleanStageOptions{} + if len(gs.With) > 0 { + err = json.Unmarshal(gs.With, s.K8sBaselineCleanStageOptions) + } + case model.StageK8sTrafficRouting: + s.K8sTrafficRoutingStageOptions = &K8sTrafficRoutingStageOptions{} + if len(gs.With) > 0 { + err = json.Unmarshal(gs.With, s.K8sTrafficRoutingStageOptions) + } + + case model.StageTerraformSync: + s.TerraformSyncStageOptions = &TerraformSyncStageOptions{} + if len(gs.With) > 0 { + err = json.Unmarshal(gs.With, s.TerraformSyncStageOptions) + } + case model.StageTerraformPlan: + s.TerraformPlanStageOptions = &TerraformPlanStageOptions{} + if len(gs.With) > 0 { + err = json.Unmarshal(gs.With, s.TerraformPlanStageOptions) + } + case model.StageTerraformApply: + s.TerraformApplyStageOptions = &TerraformApplyStageOptions{} + if len(gs.With) > 0 { + err = json.Unmarshal(gs.With, s.TerraformApplyStageOptions) + } + + case model.StageCloudRunSync: + s.CloudRunSyncStageOptions = &CloudRunSyncStageOptions{} + if len(gs.With) > 0 { + err = json.Unmarshal(gs.With, s.CloudRunSyncStageOptions) + } + case model.StageCloudRunPromote: + s.CloudRunPromoteStageOptions = &CloudRunPromoteStageOptions{} + if len(gs.With) > 0 { + err = json.Unmarshal(gs.With, s.CloudRunPromoteStageOptions) + } + + case model.StageLambdaSync: + s.LambdaSyncStageOptions = &LambdaSyncStageOptions{} + if len(gs.With) > 0 { + err = json.Unmarshal(gs.With, s.LambdaSyncStageOptions) + } + case model.StageLambdaPromote: + s.LambdaPromoteStageOptions = &LambdaPromoteStageOptions{} + if len(gs.With) > 0 { + err = json.Unmarshal(gs.With, s.LambdaPromoteStageOptions) + } + case model.StageLambdaCanaryRollout: + s.LambdaCanaryRolloutStageOptions = &LambdaCanaryRolloutStageOptions{} + if len(gs.With) > 0 { + err = json.Unmarshal(gs.With, s.LambdaCanaryRolloutStageOptions) + } + + case model.StageECSSync: + s.ECSSyncStageOptions = &ECSSyncStageOptions{} + if len(gs.With) > 0 { + err = json.Unmarshal(gs.With, s.ECSSyncStageOptions) + } + case model.StageECSCanaryRollout: + s.ECSCanaryRolloutStageOptions = &ECSCanaryRolloutStageOptions{} + if len(gs.With) > 0 { + err = json.Unmarshal(gs.With, s.ECSCanaryRolloutStageOptions) + } + case model.StageECSPrimaryRollout: + s.ECSPrimaryRolloutStageOptions = &ECSPrimaryRolloutStageOptions{} + if len(gs.With) > 0 { + err = json.Unmarshal(gs.With, s.ECSPrimaryRolloutStageOptions) + } + case model.StageECSCanaryClean: + s.ECSCanaryCleanStageOptions = &ECSCanaryCleanStageOptions{} + if len(gs.With) > 0 { + err = json.Unmarshal(gs.With, s.ECSCanaryCleanStageOptions) + } + case model.StageECSTrafficRouting: + s.ECSTrafficRoutingStageOptions = &ECSTrafficRoutingStageOptions{} + if len(gs.With) > 0 { + err = json.Unmarshal(gs.With, s.ECSTrafficRoutingStageOptions) + } + + default: + err = fmt.Errorf("unsupported stage name: %s", s.Name) + } + return err +} + +// SkipOptions contains all configurable values for skipping a stage. +type SkipOptions struct { + CommitMessagePrefixes []string `json:"commitMessagePrefixes,omitempty"` + Paths []string `json:"paths,omitempty"` +} + +// WaitStageOptions contains all configurable values for a WAIT stage. +type WaitStageOptions struct { + Duration Duration `json:"duration"` + SkipOn SkipOptions `json:"skipOn,omitempty"` +} + +// WaitStageOptions contains all configurable values for a WAIT_APPROVAL stage. +type WaitApprovalStageOptions struct { + // The maximum length of time to wait before giving up. + // Defaults to 6h. + Timeout Duration `json:"timeout" default:"6h"` + Approvers []string `json:"approvers"` + MinApproverNum int `json:"minApproverNum" default:"1"` + SkipOn SkipOptions `json:"skipOn,omitempty"` +} + +func (w *WaitApprovalStageOptions) Validate() error { + if w.MinApproverNum < 1 { + return fmt.Errorf("minApproverNum %d should be greater than 0", w.MinApproverNum) + } + return nil +} + +type CustomSyncOptions struct { + Timeout Duration `json:"timeout" default:"6h"` + Envs map[string]string `json:"envs"` + Run string `json:"run"` +} + +func (c *CustomSyncOptions) Validate() error { + if c.Run == "" { + return fmt.Errorf("the CUSTOM_SYNC stage requires run field") + } + return nil +} + +// AnalysisStageOptions contains all configurable values for a K8S_ANALYSIS stage. +type AnalysisStageOptions struct { + // How long the analysis process should be executed. + Duration Duration `json:"duration,omitempty"` + // TODO: Consider about how to handle a pod restart + // possible count of pod restarting + RestartThreshold int `json:"restartThreshold,omitempty"` + Metrics []TemplatableAnalysisMetrics `json:"metrics,omitempty"` + Logs []TemplatableAnalysisLog `json:"logs,omitempty"` + HTTPS []TemplatableAnalysisHTTP `json:"https,omitempty"` + SkipOn SkipOptions `json:"skipOn,omitempty"` +} + +func (a *AnalysisStageOptions) Validate() error { + if a.Duration == 0 { + return fmt.Errorf("the ANALYSIS stage requires duration field") + } + + for _, m := range a.Metrics { + if m.Template.Name != "" { + if err := m.Template.Validate(); err != nil { + return fmt.Errorf("one of metrics configurations of ANALYSIS stage is invalid: %w", err) + } + continue + } + if err := m.AnalysisMetrics.Validate(); err != nil { + return fmt.Errorf("one of metrics configurations of ANALYSIS stage is invalid: %w", err) + } + } + + for _, l := range a.Logs { + if l.Template.Name != "" { + if err := l.Template.Validate(); err != nil { + return fmt.Errorf("one of log configurations of ANALYSIS stage is invalid: %w", err) + } + continue + } + if err := l.AnalysisLog.Validate(); err != nil { + return fmt.Errorf("one of log configurations of ANALYSIS stage is invalid: %w", err) + } + } + for _, h := range a.HTTPS { + if h.Template.Name != "" { + if err := h.Template.Validate(); err != nil { + return fmt.Errorf("one of http configurations of ANALYSIS stage is invalid: %w", err) + } + continue + } + if err := h.AnalysisHTTP.Validate(); err != nil { + return fmt.Errorf("one of http configurations of ANALYSIS stage is invalid: %w", err) + } + } + return nil +} + +// ScriptRunStageOptions contains all configurable values for a SCRIPT_RUN stage. +type ScriptRunStageOptions struct { + Env map[string]string `json:"env"` + Run string `json:"run"` + Timeout Duration `json:"timeout" default:"6h"` + OnRollback string `json:"onRollback"` + SkipOn SkipOptions `json:"skipOn,omitempty"` +} + +// Validate checks the required fields of ScriptRunStageOptions. +func (s *ScriptRunStageOptions) Validate() error { + if s.Run == "" { + return fmt.Errorf("SCRIPT_RUN stage requires run field") + } + return nil +} + +type AnalysisTemplateRef struct { + Name string `json:"name"` + AppArgs map[string]string `json:"appArgs"` +} + +func (a *AnalysisTemplateRef) Validate() error { + if a.Name == "" { + return fmt.Errorf("the reference of analysis template name is empty") + } + return nil +} + +// TemplatableAnalysisMetrics wraps AnalysisMetrics to allow specify template to use. +type TemplatableAnalysisMetrics struct { + AnalysisMetrics + Template AnalysisTemplateRef `json:"template"` +} + +// TemplatableAnalysisLog wraps AnalysisLog to allow specify template to use. +type TemplatableAnalysisLog struct { + AnalysisLog + Template AnalysisTemplateRef `json:"template"` +} + +// TemplatableAnalysisHTTP wraps AnalysisHTTP to allow specify template to use. +type TemplatableAnalysisHTTP struct { + AnalysisHTTP + Template AnalysisTemplateRef `json:"template"` +} + +type SecretEncryption struct { + // List of encrypted secrets. + EncryptedSecrets map[string]string `json:"encryptedSecrets"` + // List of files to be decrypted before using. + DecryptionTargets []string `json:"decryptionTargets"` +} + +func (e *SecretEncryption) Validate() error { + if len(e.DecryptionTargets) == 0 { + return fmt.Errorf("derecryptionTargets must not be empty") + } + for k, v := range e.EncryptedSecrets { + if k == "" { + return fmt.Errorf("key field in encryptedSecrets must not be empty") + } + if v == "" { + return fmt.Errorf("value field of %s in encryptedSecrets must not be empty", k) + } + } + return nil +} + +type Attachment struct { + // Map of name to refer with the file path which contain embedding source data. + Sources map[string]string `json:"sources"` + // List of files to be embedded before using. + Targets []string `json:"targets"` +} + +func (a *Attachment) Validate() error { + if len(a.Targets) == 0 { + return fmt.Errorf("attachment targets must not be empty") + } + for k, v := range a.Sources { + if k == "" { + return fmt.Errorf("key field in sources must not be empty") + } + if v == "" { + return fmt.Errorf("value field in sources must not be empty") + } + } + return nil +} + +// DeploymentNotification represents the way to send to users or groups. +type DeploymentNotification struct { + // List of users to be notified for each event. + Mentions []NotificationMention `json:"mentions"` +} + +// FindSlackGroups returns a list of slack group IDs to be mentioned for the given event. +func (n *DeploymentNotification) FindSlackGroups(event model.NotificationEventType) []string { + as := make(map[string]struct{}) + for _, m := range n.Mentions { + if m.Event != allEventsSymbol && "EVENT_"+m.Event != event.String() { + continue + } + if len(m.SlackGroups) > 0 { + for _, sg := range m.SlackGroups { + as[sg] = struct{}{} + } + } + } + + approvers := make([]string, 0, len(as)) + for a := range as { + approvers = append(approvers, a) + } + return approvers +} + +// FindSlackUsers returns a list of slack user IDs to be mentioned for the given event. +func (n *DeploymentNotification) FindSlackUsers(event model.NotificationEventType) []string { + as := make(map[string]struct{}) + for _, m := range n.Mentions { + if m.Event != allEventsSymbol && "EVENT_"+m.Event != event.String() { + continue + } + if len(m.Slack) > 0 { + for _, s := range m.Slack { + as[s] = struct{}{} + } + } + if len(m.SlackUsers) > 0 { + for _, su := range m.SlackUsers { + as[su] = struct{}{} + } + } + } + + approvers := make([]string, 0, len(as)) + for a := range as { + approvers = append(approvers, a) + } + return approvers +} + +type NotificationMention struct { + // The event to be notified to users. + Event string `json:"event"` + // Deprecated: Please use SlackUsers instead + // List of user IDs for mentioning in Slack. + // See https://api.slack.com/reference/surfaces/formatting#mentioning-users + // for more information on how to check them. + Slack []string `json:"slack"` + // List of user IDs for mentioning in Slack. + // See https://api.slack.com/reference/surfaces/formatting#mentioning-users + // for more information on how to check them. + SlackUsers []string `json:"slackusers,omitempty"` + // List of group IDs for mentioning in Slack. + // See https://api.slack.com/reference/surfaces/formatting#mentioning-groups + // for more information on how to check them. + SlackGroups []string `json:"slackgroups,omitempty"` + // TODO: Support for email notification + // The email for notification. + Email []string `json:"email"` +} + +func (n *NotificationMention) Validate() error { + if n.Event == allEventsSymbol { + return nil + } + + e := "EVENT_" + n.Event + for k := range model.NotificationEventType_value { + if e == k { + return nil + } + } + return fmt.Errorf("event %q is incorrect as NotificationEventType", n.Event) +} + +// PostSync provides all configurations to be used once the current deployment +// is triggered successfully. +type PostSync struct { + DeploymentChain *DeploymentChain `json:"chain"` +} + +func (p *PostSync) Validate() error { + if dc := p.DeploymentChain; dc != nil { + return dc.Validate() + } + return nil +} + +// DeploymentChain provides all configurations used to trigger a chain of deployments. +type DeploymentChain struct { + // ApplicationMatchers provides list of ChainApplicationMatcher which contain filters to be used + // to find applications to deploy as chain node. It's required to not empty. + ApplicationMatchers []ChainApplicationMatcher `json:"applications"` + // Conditions provides configuration used to determine should the piped in charge in + // the first applications in the chain trigger a whole new deployment chain or not. + // If this field is not set, always trigger a whole new deployment chain when the current + // application is triggered. + // TODO: Add conditions to deployment chain configuration. + // Conditions *DeploymentChainTriggerCondition `json:"conditions,omitempty"` +} + +func (dc *DeploymentChain) Validate() error { + if len(dc.ApplicationMatchers) == 0 { + return fmt.Errorf("missing specified applications that will be triggered on this chain of deployment") + } + + for _, m := range dc.ApplicationMatchers { + if err := m.Validate(); err != nil { + return err + } + } + + // if cc := dc.Conditions; cc != nil { + // if err := cc.Validate(); err != nil { + // return err + // } + // } + + return nil +} + +// ChainApplicationMatcher provides filters used to find the right applications to trigger +// as a part of the deployment chain. +type ChainApplicationMatcher struct { + Name string `json:"name"` + Kind string `json:"kind"` + Labels map[string]string `json:"labels"` +} + +func (m *ChainApplicationMatcher) Validate() error { + hasFilterCond := m.Name != "" || m.Kind != "" || len(m.Labels) != 0 + + if !hasFilterCond { + return fmt.Errorf("at least one of \"name\", \"kind\" or \"labels\" must be set to find applications to deploy") + } + return nil +} + +type DeploymentChainTriggerCondition struct { + CommitPrefix string `json:"commitPrefix"` +} + +func (c *DeploymentChainTriggerCondition) Validate() error { + hasCond := c.CommitPrefix != "" + if !hasCond { + return fmt.Errorf("missing commitPrefix configration as deployment chain trigger condition") + } + return nil +} + +type DriftDetection struct { + // IgnoreFields are a list of 'apiVersion:kind:namespace:name#fieldPath' + IgnoreFields []string `json:"ignoreFields"` +} + +func (dd *DriftDetection) Validate() error { + for _, ignoreField := range dd.IgnoreFields { + splited := strings.Split(ignoreField, "#") + if len(splited) != 2 { + return fmt.Errorf("ignoreFields must be in the form of 'apiVersion:kind:namespace:name#fieldPath'") + } + } + return nil +} + +func LoadApplication(repoPath, configRelPath string, appKind model.ApplicationKind) (*GenericApplicationSpec, error) { + absPath := filepath.Join(repoPath, configRelPath) + + cfg, err := LoadFromYAML(absPath) + if err != nil { + if os.IsNotExist(err) { + return nil, fmt.Errorf("application config file %s was not found in Git", configRelPath) + } + return nil, err + } + if kind, ok := cfg.Kind.ToApplicationKind(); !ok || kind != appKind { + return nil, fmt.Errorf("invalid application kind in the application config file, got: %s, expected: %s", kind, appKind) + } + + spec, ok := cfg.GetGenericApplication() + if !ok { + return nil, fmt.Errorf("unsupported application kind: %s", appKind) + } + + return &spec, nil +} diff --git a/pkg/configv1/application_cloudrun.go b/pkg/configv1/application_cloudrun.go new file mode 100644 index 0000000000..2f061f5ac6 --- /dev/null +++ b/pkg/configv1/application_cloudrun.go @@ -0,0 +1,53 @@ +// Copyright 2024 The PipeCD 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 + +// CloudRunApplicationSpec represents an application configuration for CloudRun application. +type CloudRunApplicationSpec struct { + GenericApplicationSpec + // Input for CloudRun deployment such as docker image... + Input CloudRunDeploymentInput `json:"input"` + // Configuration for quick sync. + QuickSync CloudRunSyncStageOptions `json:"quickSync"` +} + +// Validate returns an error if any wrong configuration value was found. +func (s *CloudRunApplicationSpec) Validate() error { + if err := s.GenericApplicationSpec.Validate(); err != nil { + return err + } + return nil +} + +type CloudRunDeploymentInput struct { + // The name of service manifest file placing in application directory. + // Default is service.yaml + ServiceManifestFile string `json:"serviceManifestFile"` + // Automatically reverts to the previous state when the deployment is failed. + // Default is true. + // + // Deprecated: Use Planner.AutoRollback instead. + AutoRollback *bool `json:"autoRollback,omitempty" default:"true"` +} + +// CloudRunSyncStageOptions contains all configurable values for a CLOUDRUN_SYNC stage. +type CloudRunSyncStageOptions struct { +} + +// CloudRunPromoteStageOptions contains all configurable values for a CLOUDRUN_PROMOTE stage. +type CloudRunPromoteStageOptions struct { + // Percentage of traffic should be routed to the new version. + Percent Percentage `json:"percent"` +} diff --git a/pkg/configv1/application_cloudrun_test.go b/pkg/configv1/application_cloudrun_test.go new file mode 100644 index 0000000000..2d419a78f7 --- /dev/null +++ b/pkg/configv1/application_cloudrun_test.go @@ -0,0 +1,77 @@ +// Copyright 2024 The PipeCD 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 ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCloudRunApplicationConfig(t *testing.T) { + testcases := []struct { + fileName string + expectedKind Kind + expectedAPIVersion string + expectedSpec interface{} + expectedError error + }{ + { + fileName: "testdata/application/cloudrun-app.yaml", + expectedKind: KindCloudRunApp, + expectedAPIVersion: "pipecd.dev/v1beta1", + expectedSpec: &CloudRunApplicationSpec{ + GenericApplicationSpec: GenericApplicationSpec{ + Timeout: Duration(6 * time.Hour), + Trigger: Trigger{ + OnCommit: OnCommit{ + Disabled: false, + }, + OnCommand: OnCommand{ + Disabled: false, + }, + OnOutOfSync: OnOutOfSync{ + Disabled: newBoolPointer(true), + MinWindow: Duration(5 * time.Minute), + }, + OnChain: OnChain{ + Disabled: newBoolPointer(true), + }, + }, + Planner: DeploymentPlanner{ + AutoRollback: newBoolPointer(true), + }, + }, + Input: CloudRunDeploymentInput{ + AutoRollback: newBoolPointer(true), + }, + }, + expectedError: nil, + }, + } + for _, tc := range testcases { + t.Run(tc.fileName, func(t *testing.T) { + cfg, err := LoadFromYAML(tc.fileName) + require.Equal(t, tc.expectedError, err) + if err == nil { + assert.Equal(t, tc.expectedKind, cfg.Kind) + assert.Equal(t, tc.expectedAPIVersion, cfg.APIVersion) + assert.Equal(t, tc.expectedSpec, cfg.spec) + } + }) + } +} diff --git a/pkg/configv1/application_ecs.go b/pkg/configv1/application_ecs.go new file mode 100644 index 0000000000..469183913b --- /dev/null +++ b/pkg/configv1/application_ecs.go @@ -0,0 +1,162 @@ +// Copyright 2024 The PipeCD 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" +) + +const ( + AccessTypeELB string = "ELB" + AccessTypeServiceDiscovery string = "SERVICE_DISCOVERY" +) + +// ECSApplicationSpec represents an application configuration for ECS application. +type ECSApplicationSpec struct { + GenericApplicationSpec + // Input for ECS deployment such as where to fetch source code... + Input ECSDeploymentInput `json:"input"` + // Configuration for quick sync. + QuickSync ECSSyncStageOptions `json:"quickSync"` +} + +// Validate returns an error if any wrong configuration value was found. +func (s *ECSApplicationSpec) Validate() error { + if err := s.GenericApplicationSpec.Validate(); err != nil { + return err + } + + if err := s.Input.validate(); err != nil { + return err + } + + return nil +} + +type ECSDeploymentInput struct { + // The Amazon Resource Name (ARN) that identifies the cluster. + ClusterArn string `json:"clusterArn,omitempty"` + // The launch type on which to run your task. + // https://docs.aws.amazon.com/AmazonECS/latest/developerguide/launch_types.html + // Default is FARGATE + LaunchType string `json:"launchType,omitempty" default:"FARGATE"` + // VpcConfiguration ECSVpcConfiguration `json:"awsvpcConfiguration"` + AwsVpcConfiguration ECSVpcConfiguration `json:"awsvpcConfiguration,omitempty" default:""` + // The name of service definition file placing in application directory. + ServiceDefinitionFile string `json:"serviceDefinitionFile"` + // The name of task definition file placing in application directory. + // Default is taskdef.json + TaskDefinitionFile string `json:"taskDefinitionFile" default:"taskdef.json"` + // ECSTargetGroups + TargetGroups ECSTargetGroups `json:"targetGroups,omitempty"` + // Automatically reverts all changes from all stages when one of them failed. + // Default is true. + // + // Deprecated: Use Planner.AutoRollback instead. + AutoRollback *bool `json:"autoRollback,omitempty" default:"true"` + // Run standalone task during deployment. + // Default is true. + RunStandaloneTask *bool `json:"runStandaloneTask,omitempty" default:"true"` + // How the ECS service is accessed. + // Possible values are: + // - ELB - The service is accessed via ELB and target groups. + // - SERVICE_DISCOVERY - The service is accessed via ECS Service Discovery. + // Default is ELB. + AccessType string `json:"accessType,omitempty" default:"ELB"` +} + +func (in *ECSDeploymentInput) IsStandaloneTask() bool { + return in.ServiceDefinitionFile == "" +} + +func (in *ECSDeploymentInput) IsAccessedViaELB() bool { + return in.AccessType == AccessTypeELB +} + +type ECSVpcConfiguration struct { + Subnets []string `json:"subnets,omitempty"` + AssignPublicIP string `json:"assignPublicIp,omitempty"` + SecurityGroups []string `json:"securityGroups,omitempty"` +} + +type ECSTargetGroups struct { + Primary *ECSTargetGroup `json:"primary,omitempty"` + Canary *ECSTargetGroup `json:"canary,omitempty"` +} + +type ECSTargetGroup struct { + TargetGroupArn string `json:"targetGroupArn,omitempty"` + ContainerName string `json:"containerName,omitempty"` + ContainerPort int `json:"containerPort,omitempty"` + LoadBalancerName string `json:"loadBalancerName,omitempty"` +} + +// ECSSyncStageOptions contains all configurable values for a ECS_SYNC stage. +type ECSSyncStageOptions struct { + // Whether to delete old tasksets before creating new ones or not. + // If this is set, the application may be unavailable for a short of time during the deployment. + // Default is false. + Recreate bool `json:"recreate"` +} + +// ECSCanaryRolloutStageOptions contains all configurable values for a ECS_CANARY_ROLLOUT stage. +type ECSCanaryRolloutStageOptions struct { + // Scale represents the amount of desired task that should be rolled out as CANARY variant workload. + Scale Percentage `json:"scale"` +} + +// ECSPrimaryRolloutStageOptions contains all configurable values for a ECS_PRIMARY_ROLLOUT stage. +type ECSPrimaryRolloutStageOptions struct { +} + +// ECSCanaryCleanStageOptions contains all configurable values for a ECS_CANARY_CLEAN stage. +type ECSCanaryCleanStageOptions struct { +} + +// ECSTrafficRoutingStageOptions contains all configurable values for ECS_TRAFFIC_ROUTING stage. +type ECSTrafficRoutingStageOptions struct { + // Canary represents the amount of traffic that the rolled out CANARY variant will serve. + Canary Percentage `json:"canary,omitempty"` + // Primary represents the amount of traffic that the rolled out CANARY variant will serve. + Primary Percentage `json:"primary,omitempty"` +} + +func (opts ECSTrafficRoutingStageOptions) Percentage() (primary, canary int) { + primary = opts.Primary.Int() + if primary > 0 && primary <= 100 { + canary = 100 - primary + return + } + + canary = opts.Canary.Int() + if canary > 0 && canary <= 100 { + primary = 100 - canary + return + } + // As default, Primary variant will receive 100% of traffic. + primary = 100 + canary = 0 + return +} + +func (in *ECSDeploymentInput) validate() error { + switch in.AccessType { + case AccessTypeELB, AccessTypeServiceDiscovery: + break + default: + return fmt.Errorf("invalid accessType: %s", in.AccessType) + } + return nil +} diff --git a/pkg/configv1/application_ecs_test.go b/pkg/configv1/application_ecs_test.go new file mode 100644 index 0000000000..ed6a5054ca --- /dev/null +++ b/pkg/configv1/application_ecs_test.go @@ -0,0 +1,165 @@ +// Copyright 2024 The PipeCD 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" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestECSApplicationConfig(t *testing.T) { + testcases := []struct { + fileName string + expectedKind Kind + expectedAPIVersion string + expectedLaunchType string + expectedSpec interface{} + expectedError error + }{ + { + fileName: "testdata/application/ecs-app.yaml", + expectedKind: KindECSApp, + expectedAPIVersion: "pipecd.dev/v1beta1", + expectedSpec: &ECSApplicationSpec{ + GenericApplicationSpec: GenericApplicationSpec{ + Timeout: Duration(6 * time.Hour), + Trigger: Trigger{ + OnCommit: OnCommit{ + Disabled: false, + }, + OnCommand: OnCommand{ + Disabled: false, + }, + OnOutOfSync: OnOutOfSync{ + Disabled: newBoolPointer(true), + MinWindow: Duration(5 * time.Minute), + }, + OnChain: OnChain{ + Disabled: newBoolPointer(true), + }, + }, + Planner: DeploymentPlanner{ + AutoRollback: newBoolPointer(true), + }, + }, + Input: ECSDeploymentInput{ + ServiceDefinitionFile: "/path/to/servicedef.yaml", + TaskDefinitionFile: "/path/to/taskdef.yaml", + TargetGroups: ECSTargetGroups{ + Primary: &ECSTargetGroup{ + TargetGroupArn: "arn:aws:elasticloadbalancing:xyz", + ContainerName: "web", + ContainerPort: 80, + }, + }, + LaunchType: "FARGATE", + AutoRollback: newBoolPointer(true), + RunStandaloneTask: newBoolPointer(true), + AccessType: "ELB", + }, + }, + expectedError: nil, + }, + { + fileName: "testdata/application/ecs-app-service-discovery.yaml", + expectedKind: KindECSApp, + expectedAPIVersion: "pipecd.dev/v1beta1", + expectedSpec: &ECSApplicationSpec{ + GenericApplicationSpec: GenericApplicationSpec{ + Timeout: Duration(6 * time.Hour), + Trigger: Trigger{ + OnCommit: OnCommit{ + Disabled: false, + }, + OnCommand: OnCommand{ + Disabled: false, + }, + OnOutOfSync: OnOutOfSync{ + Disabled: newBoolPointer(true), + MinWindow: Duration(5 * time.Minute), + }, + OnChain: OnChain{ + Disabled: newBoolPointer(true), + }, + }, + Planner: DeploymentPlanner{ + AutoRollback: newBoolPointer(true), + }, + }, + Input: ECSDeploymentInput{ + ServiceDefinitionFile: "/path/to/servicedef.yaml", + TaskDefinitionFile: "/path/to/taskdef.yaml", + LaunchType: "FARGATE", + AutoRollback: newBoolPointer(true), + RunStandaloneTask: newBoolPointer(true), + AccessType: "SERVICE_DISCOVERY", + }, + }, + expectedError: nil, + }, + { + fileName: "testdata/application/ecs-app-invalid-access-type.yaml", + expectedKind: KindECSApp, + expectedAPIVersion: "pipecd.dev/v1beta1", + expectedSpec: &ECSApplicationSpec{ + GenericApplicationSpec: GenericApplicationSpec{ + Timeout: Duration(6 * time.Hour), + Trigger: Trigger{ + OnCommit: OnCommit{ + Disabled: false, + }, + OnCommand: OnCommand{ + Disabled: false, + }, + OnOutOfSync: OnOutOfSync{ + Disabled: newBoolPointer(true), + MinWindow: Duration(5 * time.Minute), + }, + OnChain: OnChain{ + Disabled: newBoolPointer(true), + }, + }, + Planner: DeploymentPlanner{ + AutoRollback: newBoolPointer(true), + }, + }, + Input: ECSDeploymentInput{ + ServiceDefinitionFile: "/path/to/servicedef.yaml", + TaskDefinitionFile: "/path/to/taskdef.yaml", + LaunchType: "FARGATE", + AutoRollback: newBoolPointer(true), + RunStandaloneTask: newBoolPointer(true), + AccessType: "XXX", + }, + }, + expectedError: fmt.Errorf("invalid accessType: XXX"), + }, + } + for _, tc := range testcases { + t.Run(tc.fileName, func(t *testing.T) { + cfg, err := LoadFromYAML(tc.fileName) + require.Equal(t, tc.expectedError, err) + if err == nil { + assert.Equal(t, tc.expectedKind, cfg.Kind) + assert.Equal(t, tc.expectedAPIVersion, cfg.APIVersion) + assert.Equal(t, tc.expectedSpec, cfg.spec) + } + }) + } +} diff --git a/pkg/configv1/application_kubernetes.go b/pkg/configv1/application_kubernetes.go new file mode 100644 index 0000000000..b8ad067a9d --- /dev/null +++ b/pkg/configv1/application_kubernetes.go @@ -0,0 +1,313 @@ +// Copyright 2024 The PipeCD 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 + +// KubernetesApplicationSpec represents an application configuration for Kubernetes application. +type KubernetesApplicationSpec struct { + GenericApplicationSpec + // Input for Kubernetes deployment such as kubectl version, helm version, manifests filter... + Input KubernetesDeploymentInput `json:"input"` + // Configuration for quick sync. + QuickSync K8sSyncStageOptions `json:"quickSync"` + // Which resource should be considered as the Service of application. + // Empty means the first Service resource will be used. + Service K8sResourceReference `json:"service"` + // Which resources should be considered as the Workload of application. + // Empty means all Deployments. + // e.g. + // - kind: Deployment + // name: deployment-name + // - kind: ReplicationController + // name: replication-controller-name + Workloads []K8sResourceReference `json:"workloads"` + // Which method should be used for traffic routing. + TrafficRouting *KubernetesTrafficRouting `json:"trafficRouting"` + // The label will be configured to variant manifests used to distinguish them. + VariantLabel KubernetesVariantLabel `json:"variantLabel"` + // List of route configurations to resolve the platform provider for application resources. + // Each resource will be checked over the match conditions of each route. + // If matches, it will be applied to the route's provider, + // otherwise, it will be fallen through the next route to check. + // Any resource which does not match any specified route will be applied + // to the default platform provider which had been specified while registering the application. + ResourceRoutes []KubernetesResourceRoute `json:"resourceRoutes"` +} + +// Validate returns an error if any wrong configuration value was found. +func (s *KubernetesApplicationSpec) Validate() error { + if err := s.GenericApplicationSpec.Validate(); err != nil { + return err + } + return nil +} + +type KubernetesVariantLabel struct { + // The key of the label. + // Default is pipecd.dev/variant. + Key string `json:"key" default:"pipecd.dev/variant"` + // The label value for PRIMARY variant. + // Default is primary. + PrimaryValue string `json:"primaryValue" default:"primary"` + // The label value for CANARY variant. + // Default is canary. + CanaryValue string `json:"canaryValue" default:"canary"` + // The label value for BASELINE variant. + // Default is baseline. + BaselineValue string `json:"baselineValue" default:"baseline"` +} + +// KubernetesDeploymentInput represents needed input for triggering a Kubernetes deployment. +type KubernetesDeploymentInput struct { + // List of manifest files in the application directory used to deploy. + // Empty means all manifest files in the directory will be used. + Manifests []string `json:"manifests,omitempty"` + // Version of kubectl will be used. + KubectlVersion string `json:"kubectlVersion,omitempty"` + + // Version of kustomize will be used. + KustomizeVersion string `json:"kustomizeVersion,omitempty"` + // List of options that should be used by Kustomize commands. + KustomizeOptions map[string]string `json:"kustomizeOptions,omitempty"` + + // Version of helm will be used. + HelmVersion string `json:"helmVersion,omitempty"` + // Where to fetch helm chart. + HelmChart *InputHelmChart `json:"helmChart,omitempty"` + // Configurable parameters for helm commands. + HelmOptions *InputHelmOptions `json:"helmOptions,omitempty"` + + // The namespace where manifests will be applied. + Namespace string `json:"namespace,omitempty"` + + // Automatically reverts all deployment changes on failure. + // Default is true. + // + // Deprecated: Use Planner.AutoRollback instead. + AutoRollback *bool `json:"autoRollback,omitempty" default:"true"` + + // Automatically create a new namespace if it does not exist. + // Default is false. + AutoCreateNamespace bool `json:"autoCreateNamespace,omitempty"` +} + +type InputHelmChart struct { + // Git remote address where the chart is placing. + // Empty means the same repository. + GitRemote string `json:"gitRemote,omitempty"` + // The commit SHA or tag for remote git. + Ref string `json:"ref,omitempty"` + // Relative path from the repository root directory to the chart directory. + Path string `json:"path,omitempty"` + + // The name of an added Helm Chart Repository. + Repository string `json:"repository,omitempty"` + Name string `json:"name,omitempty"` + Version string `json:"version,omitempty"` + // Whether to skip TLS certificate checks for the repository or not. + // This option will automatically set the value of HelmChartRepository.Insecure. + Insecure bool `json:"-"` +} + +type InputHelmOptions struct { + // The release name of helm deployment. + // By default the release name is equal to the application name. + ReleaseName string `json:"releaseName,omitempty"` + // List of values. + SetValues map[string]string `json:"setValues,omitempty"` + // List of value files should be loaded. + ValueFiles []string `json:"valueFiles,omitempty"` + // List of file path for values. + SetFiles map[string]string `json:"setFiles,omitempty"` + // Set of supported Kubernetes API versions. + APIVersions []string `json:"apiVersions,omitempty"` + // Kubernetes version used for Capabilities.KubeVersion + KubeVersion string `json:"kubeVersion,omitempty"` +} + +type KubernetesTrafficRoutingMethod string + +const ( + KubernetesTrafficRoutingMethodPodSelector KubernetesTrafficRoutingMethod = "podselector" + KubernetesTrafficRoutingMethodIstio KubernetesTrafficRoutingMethod = "istio" + KubernetesTrafficRoutingMethodSMI KubernetesTrafficRoutingMethod = "smi" +) + +type KubernetesTrafficRouting struct { + Method KubernetesTrafficRoutingMethod `json:"method"` + Istio *IstioTrafficRouting `json:"istio"` +} + +// DetermineKubernetesTrafficRoutingMethod determines the routing method should be used based on the TrafficRouting config. +// The default is PodSelector: the way by updating the selector in Service to switching all of traffic. +func DetermineKubernetesTrafficRoutingMethod(cfg *KubernetesTrafficRouting) KubernetesTrafficRoutingMethod { + if cfg == nil { + return KubernetesTrafficRoutingMethodPodSelector + } + if cfg.Method == "" { + return KubernetesTrafficRoutingMethodPodSelector + } + return cfg.Method +} + +type IstioTrafficRouting struct { + // List of routes in the VirtualService that can be changed to update traffic routing. + // Empty means all routes should be updated. + EditableRoutes []string `json:"editableRoutes"` + // TODO: Add a validate to ensure this was configured or using the default value by service name. + // The service host. + Host string `json:"host"` + // The reference to VirtualService manifest. + // Empty means the first VirtualService resource will be used. + VirtualService K8sResourceReference `json:"virtualService"` +} + +type K8sResourceReference struct { + Kind string `json:"kind"` + Name string `json:"name"` +} + +// K8sSyncStageOptions contains all configurable values for a K8S_SYNC stage. +type K8sSyncStageOptions struct { + // Whether the PRIMARY variant label should be added to manifests if they were missing. + AddVariantLabelToSelector bool `json:"addVariantLabelToSelector"` + // Whether the resources that are no longer defined in Git should be removed or not. + Prune bool `json:"prune"` +} + +// K8sPrimaryRolloutStageOptions contains all configurable values for a K8S_PRIMARY_ROLLOUT stage. +type K8sPrimaryRolloutStageOptions struct { + // Suffix that should be used when naming the PRIMARY variant's resources. + // Default is "primary". + Suffix string `json:"suffix"` + // Whether the PRIMARY service should be created. + CreateService bool `json:"createService"` + // Whether the PRIMARY variant label should be added to manifests if they were missing. + AddVariantLabelToSelector bool `json:"addVariantLabelToSelector"` + // Whether the resources that are no longer defined in Git should be removed or not. + Prune bool `json:"prune"` +} + +// K8sCanaryRolloutStageOptions contains all configurable values for a K8S_CANARY_ROLLOUT stage. +type K8sCanaryRolloutStageOptions struct { + // How many pods for CANARY workloads. + // An integer value can be specified to indicate an absolute value of pod number. + // Or a string suffixed by "%" to indicate an percentage value compared to the pod number of PRIMARY. + // Default is 1 pod. + Replicas Replicas `json:"replicas"` + // Suffix that should be used when naming the CANARY variant's resources. + // Default is "canary". + Suffix string `json:"suffix"` + // Whether the CANARY service should be created. + CreateService bool `json:"createService"` + // List of patches used to customize manifests for CANARY variant. + Patches []K8sResourcePatch +} + +type K8sResourcePatch struct { + Target K8sResourcePatchTarget `json:"target"` + Ops []K8sResourcePatchOp `json:"ops"` +} + +type K8sResourcePatchTarget struct { + K8sResourceReference + // In case you want to manipulate the YAML or JSON data specified in a field + // of the manifest, specify that field's path. The string value of that field + // will be used as input for the patch operations. + // Otherwise, the whole manifest will be the target of patch operations. + DocumentRoot string `json:"documentRoot"` +} + +type K8sResourcePatchOpName string + +const ( + K8sResourcePatchOpYAMLReplace = "yaml-replace" +) + +type K8sResourcePatchOp struct { + // The operation type. + // This must be one of "yaml-replace", "yaml-add", "yaml-remove", "json-replace" or "text-regex". + // Default is "yaml-replace". + Op K8sResourcePatchOpName `json:"op" default:"yaml-replace"` + // The path string pointing to the manipulated field. + // E.g. "$.spec.foos[0].bar" + Path string `json:"path"` + // The value string whose content will be used as new value for the field. + Value string `json:"value"` +} + +// K8sCanaryCleanStageOptions contains all configurable values for a K8S_CANARY_CLEAN stage. +type K8sCanaryCleanStageOptions struct { +} + +// K8sBaselineRolloutStageOptions contains all configurable values for a K8S_BASELINE_ROLLOUT stage. +type K8sBaselineRolloutStageOptions struct { + // How many pods for BASELINE workloads. + // An integer value can be specified to indicate an absolute value of pod number. + // Or a string suffixed by "%" to indicate an percentage value compared to the pod number of PRIMARY. + // Default is 1 pod. + Replicas Replicas `json:"replicas"` + // Suffix that should be used when naming the BASELINE variant's resources. + // Default is "baseline". + Suffix string `json:"suffix"` + // Whether the BASELINE service should be created. + CreateService bool `json:"createService"` +} + +// K8sBaselineCleanStageOptions contains all configurable values for a K8S_BASELINE_CLEAN stage. +type K8sBaselineCleanStageOptions struct { +} + +// K8sTrafficRoutingStageOptions contains all configurable values for a K8S_TRAFFIC_ROUTING stage. +type K8sTrafficRoutingStageOptions struct { + // Which variant should receive all traffic. + // "primary" or "canary" or "baseline" can be populated. + All string `json:"all"` + // The percentage of traffic should be routed to PRIMARY variant. + Primary Percentage `json:"primary"` + // The percentage of traffic should be routed to CANARY variant. + Canary Percentage `json:"canary"` + // The percentage of traffic should be routed to BASELINE variant. + Baseline Percentage `json:"baseline"` +} + +func (opts K8sTrafficRoutingStageOptions) Percentages() (primary, canary, baseline int) { + switch opts.All { + case "primary": + primary = 100 + return + case "canary": + canary = 100 + return + case "baseline": + baseline = 100 + return + } + return opts.Primary.Int(), opts.Canary.Int(), opts.Baseline.Int() +} + +type KubernetesResourceRoute struct { + Provider KubernetesProviderMatcher `json:"provider"` + Match *KubernetesResourceRouteMatcher `json:"match"` +} + +type KubernetesResourceRouteMatcher struct { + Kind string `json:"kind"` + Name string `json:"name"` +} + +type KubernetesProviderMatcher struct { + Name string `json:"name"` + Labels map[string]string `json:"labels"` +} diff --git a/pkg/configv1/application_kubernetes_test.go b/pkg/configv1/application_kubernetes_test.go new file mode 100644 index 0000000000..54b9f39d65 --- /dev/null +++ b/pkg/configv1/application_kubernetes_test.go @@ -0,0 +1,195 @@ +// Copyright 2024 The PipeCD 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 ( + "encoding/json" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/pipe-cd/pipecd/pkg/model" +) + +func TestKubernetesApplicationConfig(t *testing.T) { + testcases := []struct { + fileName string + expectedKind Kind + expectedAPIVersion string + expectedSpec interface{} + expectedError error + }{ + { + fileName: "testdata/application/k8s-app-bluegreen.yaml", + expectedKind: KindKubernetesApp, + expectedAPIVersion: "pipecd.dev/v1beta1", + expectedSpec: &KubernetesApplicationSpec{ + GenericApplicationSpec: GenericApplicationSpec{ + Description: "application description first string\napplication description second string\n", + Planner: DeploymentPlanner{ + AlwaysUsePipeline: true, + AutoRollback: newBoolPointer(true), + }, + Pipeline: &DeploymentPipeline{ + Stages: []PipelineStage{ + { + Name: model.StageK8sCanaryRollout, + K8sCanaryRolloutStageOptions: &K8sCanaryRolloutStageOptions{ + Replicas: Replicas{ + Number: 100, + IsPercentage: true, + }, + }, + With: json.RawMessage(`{"replicas":"100%"}`), + }, + { + Name: model.StageK8sTrafficRouting, + K8sTrafficRoutingStageOptions: &K8sTrafficRoutingStageOptions{ + Canary: Percentage{ + Number: 100, + }, + }, + With: json.RawMessage(`{"canary":100}`), + }, + { + Name: model.StageK8sPrimaryRollout, + K8sPrimaryRolloutStageOptions: &K8sPrimaryRolloutStageOptions{}, + }, + { + Name: model.StageK8sTrafficRouting, + K8sTrafficRoutingStageOptions: &K8sTrafficRoutingStageOptions{ + Primary: Percentage{ + Number: 100, + }, + }, + With: json.RawMessage(`{"primary":100}`), + }, + { + Name: model.StageK8sCanaryClean, + K8sCanaryCleanStageOptions: &K8sCanaryCleanStageOptions{}, + }, + }, + }, + Timeout: Duration(6 * time.Hour), + Trigger: Trigger{ + OnCommit: OnCommit{ + Disabled: false, + }, + OnCommand: OnCommand{ + Disabled: false, + }, + OnOutOfSync: OnOutOfSync{ + Disabled: newBoolPointer(true), + MinWindow: Duration(5 * time.Minute), + }, + OnChain: OnChain{ + Disabled: newBoolPointer(true), + }, + }, + }, + Input: KubernetesDeploymentInput{ + AutoRollback: newBoolPointer(true), + }, + TrafficRouting: &KubernetesTrafficRouting{ + Method: KubernetesTrafficRoutingMethodPodSelector, + }, + VariantLabel: KubernetesVariantLabel{ + Key: "pipecd.dev/variant", + PrimaryValue: "primary", + BaselineValue: "baseline", + CanaryValue: "canary", + }, + }, + expectedError: nil, + }, + { + fileName: "testdata/application/k8s-app-resource-route.yaml", + expectedKind: KindKubernetesApp, + expectedAPIVersion: "pipecd.dev/v1beta1", + expectedSpec: &KubernetesApplicationSpec{ + GenericApplicationSpec: GenericApplicationSpec{ + Timeout: Duration(6 * time.Hour), + Trigger: Trigger{ + OnCommit: OnCommit{ + Disabled: false, + }, + OnCommand: OnCommand{ + Disabled: false, + }, + OnOutOfSync: OnOutOfSync{ + Disabled: newBoolPointer(true), + MinWindow: Duration(5 * time.Minute), + }, + OnChain: OnChain{ + Disabled: newBoolPointer(true), + }, + }, + Planner: DeploymentPlanner{ + AutoRollback: newBoolPointer(true), + }, + }, + Input: KubernetesDeploymentInput{ + AutoRollback: newBoolPointer(true), + }, + VariantLabel: KubernetesVariantLabel{ + Key: "pipecd.dev/variant", + PrimaryValue: "primary", + BaselineValue: "baseline", + CanaryValue: "canary", + }, + ResourceRoutes: []KubernetesResourceRoute{ + { + Provider: KubernetesProviderMatcher{ + Name: "ConfigCluster", + }, + Match: &KubernetesResourceRouteMatcher{ + Kind: "Ingress", + }, + }, + { + Provider: KubernetesProviderMatcher{ + Name: "ConfigCluster", + }, + Match: &KubernetesResourceRouteMatcher{ + Kind: "Service", + Name: "Foo", + }, + }, + { + Provider: KubernetesProviderMatcher{ + Labels: map[string]string{ + "group": "workload", + }, + }, + }, + }, + }, + expectedError: nil, + }, + } + for _, tc := range testcases { + t.Run(tc.fileName, func(t *testing.T) { + cfg, err := LoadFromYAML(tc.fileName) + require.Equal(t, tc.expectedError, err) + if err == nil { + assert.Equal(t, tc.expectedKind, cfg.Kind) + assert.Equal(t, tc.expectedAPIVersion, cfg.APIVersion) + assert.Equal(t, tc.expectedSpec, cfg.spec) + } + }) + } +} diff --git a/pkg/configv1/application_lambda.go b/pkg/configv1/application_lambda.go new file mode 100644 index 0000000000..5106e9df51 --- /dev/null +++ b/pkg/configv1/application_lambda.go @@ -0,0 +1,57 @@ +// Copyright 2024 The PipeCD 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 + +// LambdaApplicationSpec represents an application configuration for Lambda application. +type LambdaApplicationSpec struct { + GenericApplicationSpec + // Input for Lambda deployment such as where to fetch source code... + Input LambdaDeploymentInput `json:"input"` + // Configuration for quick sync. + QuickSync LambdaSyncStageOptions `json:"quickSync"` +} + +// Validate returns an error if any wrong configuration value was found. +func (s *LambdaApplicationSpec) Validate() error { + if err := s.GenericApplicationSpec.Validate(); err != nil { + return err + } + return nil +} + +type LambdaDeploymentInput struct { + // The name of service manifest file placing in application directory. + // Default is function.yaml + FunctionManifestFile string `json:"functionManifestFile" default:"function.yaml"` + // Automatically reverts all changes from all stages when one of them failed. + // Default is true. + // + // Deprecated: Use Planner.AutoRollback instead. + AutoRollback *bool `json:"autoRollback,omitempty" default:"true"` +} + +// LambdaSyncStageOptions contains all configurable values for a LAMBDA_SYNC stage. +type LambdaSyncStageOptions struct { +} + +// LambdaCanaryRolloutStageOptions contains all configurable values for a LAMBDA_CANARY_ROLLOUT stage. +type LambdaCanaryRolloutStageOptions struct { +} + +// LambdaPromoteStageOptions contains all configurable values for a LAMBDA_PROMOTE stage. +type LambdaPromoteStageOptions struct { + // Percentage of traffic should be routed to the new version. + Percent Percentage `json:"percent"` +} diff --git a/pkg/configv1/application_lambda_test.go b/pkg/configv1/application_lambda_test.go new file mode 100644 index 0000000000..eb3299865b --- /dev/null +++ b/pkg/configv1/application_lambda_test.go @@ -0,0 +1,181 @@ +// Copyright 2024 The PipeCD 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 ( + "encoding/json" + "testing" + "time" + + "github.com/pipe-cd/pipecd/pkg/model" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestLambdaApplicationConfig(t *testing.T) { + testcases := []struct { + fileName string + expectedKind Kind + expectedAPIVersion string + expectedSpec interface{} + expectedError error + }{ + { + fileName: "testdata/application/lambda-app.yaml", + expectedKind: KindLambdaApp, + expectedAPIVersion: "pipecd.dev/v1beta1", + expectedSpec: &LambdaApplicationSpec{ + GenericApplicationSpec: GenericApplicationSpec{ + Timeout: Duration(6 * time.Hour), + Trigger: Trigger{ + OnCommit: OnCommit{ + Disabled: false, + }, + OnCommand: OnCommand{ + Disabled: false, + }, + OnOutOfSync: OnOutOfSync{ + Disabled: newBoolPointer(true), + MinWindow: Duration(5 * time.Minute), + }, + OnChain: OnChain{ + Disabled: newBoolPointer(true), + }, + }, + Planner: DeploymentPlanner{ + AutoRollback: newBoolPointer(true), + }, + }, + Input: LambdaDeploymentInput{ + FunctionManifestFile: "function.yaml", + AutoRollback: newBoolPointer(true), + }, + }, + expectedError: nil, + }, + { + fileName: "testdata/application/lambda-app-canary.yaml", + expectedKind: KindLambdaApp, + expectedAPIVersion: "pipecd.dev/v1beta1", + expectedSpec: &LambdaApplicationSpec{ + GenericApplicationSpec: GenericApplicationSpec{ + Timeout: Duration(6 * time.Hour), + Pipeline: &DeploymentPipeline{ + Stages: []PipelineStage{ + { + Name: model.StageLambdaCanaryRollout, + LambdaCanaryRolloutStageOptions: &LambdaCanaryRolloutStageOptions{}, + }, + { + Name: model.StageLambdaPromote, + LambdaPromoteStageOptions: &LambdaPromoteStageOptions{ + Percent: Percentage{ + Number: 10, + HasSuffix: false, + }, + }, + With: json.RawMessage(`{"percent":10}`), + }, + { + Name: model.StageLambdaPromote, + LambdaPromoteStageOptions: &LambdaPromoteStageOptions{ + Percent: Percentage{ + Number: 100, + HasSuffix: false, + }, + }, + With: json.RawMessage(`{"percent":100}`), + }, + }, + }, + Trigger: Trigger{ + OnOutOfSync: OnOutOfSync{ + Disabled: newBoolPointer(true), + MinWindow: Duration(5 * time.Minute), + }, + OnChain: OnChain{ + Disabled: newBoolPointer(true), + }, + }, + Planner: DeploymentPlanner{ + AutoRollback: newBoolPointer(true), + }, + }, + Input: LambdaDeploymentInput{ + FunctionManifestFile: "function.yaml", + AutoRollback: newBoolPointer(true), + }, + }, + expectedError: nil, + }, + { + fileName: "testdata/application/lambda-app-bluegreen.yaml", + expectedKind: KindLambdaApp, + expectedAPIVersion: "pipecd.dev/v1beta1", + expectedSpec: &LambdaApplicationSpec{ + GenericApplicationSpec: GenericApplicationSpec{ + Timeout: Duration(6 * time.Hour), + Pipeline: &DeploymentPipeline{ + Stages: []PipelineStage{ + { + Name: model.StageLambdaCanaryRollout, + LambdaCanaryRolloutStageOptions: &LambdaCanaryRolloutStageOptions{}, + }, + { + Name: model.StageLambdaPromote, + LambdaPromoteStageOptions: &LambdaPromoteStageOptions{ + Percent: Percentage{ + Number: 100, + HasSuffix: false, + }, + }, + With: json.RawMessage(`{"percent":100}`), + }, + }, + }, + Trigger: Trigger{ + OnOutOfSync: OnOutOfSync{ + Disabled: newBoolPointer(true), + MinWindow: Duration(5 * time.Minute), + }, + OnChain: OnChain{ + Disabled: newBoolPointer(true), + }, + }, + Planner: DeploymentPlanner{ + AutoRollback: newBoolPointer(true), + }, + }, + Input: LambdaDeploymentInput{ + FunctionManifestFile: "function.yaml", + AutoRollback: newBoolPointer(true), + }, + }, + expectedError: nil, + }, + } + for _, tc := range testcases { + t.Run(tc.fileName, func(t *testing.T) { + cfg, err := LoadFromYAML(tc.fileName) + require.Equal(t, tc.expectedError, err) + if err == nil { + assert.Equal(t, tc.expectedKind, cfg.Kind) + assert.Equal(t, tc.expectedAPIVersion, cfg.APIVersion) + assert.Equal(t, tc.expectedSpec, cfg.spec) + } + }) + } +} diff --git a/pkg/configv1/application_terraform.go b/pkg/configv1/application_terraform.go new file mode 100644 index 0000000000..f94d3f092b --- /dev/null +++ b/pkg/configv1/application_terraform.go @@ -0,0 +1,92 @@ +// Copyright 2024 The PipeCD 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 + +// TerraformApplicationSpec represents an application configuration for Terraform application. +type TerraformApplicationSpec struct { + GenericApplicationSpec + // Input for Terraform deployment such as terraform version, workspace... + Input TerraformDeploymentInput `json:"input"` + // Configuration for quick sync. + QuickSync TerraformApplyStageOptions `json:"quickSync"` +} + +// Validate returns an error if any wrong configuration value was found. +func (s *TerraformApplicationSpec) Validate() error { + if err := s.GenericApplicationSpec.Validate(); err != nil { + return err + } + return nil +} + +type TerraformDeploymentInput struct { + // The terraform workspace name. + // Empty means "default" workpsace. + Workspace string `json:"workspace,omitempty"` + // The version of terraform should be used. + // Empty means the pre-installed version will be used. + TerraformVersion string `json:"terraformVersion,omitempty"` + // List of variables that will be set directly on terraform commands with "-var" flag. + // The variable must be formatted by "key=value" as below: + // "image_id=ami-abc123" + // 'image_id_list=["ami-abc123","ami-def456"]' + // 'image_id_map={"us-east-1":"ami-abc123","us-east-2":"ami-def456"}' + Vars []string `json:"vars,omitempty"` + // List of variable files that will be set on terraform commands with "-var-file" flag. + VarFiles []string `json:"varFiles,omitempty"` + // Automatically reverts all changes from all stages when one of them failed. + // Default is false. + // + // Deprecated: Use Planner.AutoRollback instead. + AutoRollback bool `json:"autoRollback"` + // List of additional flags will be used while executing terraform commands. + CommandFlags TerraformCommandFlags `json:"commandFlags"` + // List of additional environment variables will be used while executing terraform commands. + CommandEnvs TerraformCommandEnvs `json:"commandEnvs"` +} + +// TerraformSyncStageOptions contains all configurable values for a TERRAFORM_SYNC stage. +type TerraformSyncStageOptions struct { + // How many times to retry applying terraform changes. + Retries int `json:"retries"` +} + +// TerraformPlanStageOptions contains all configurable values for a TERRAFORM_PLAN stage. +type TerraformPlanStageOptions struct { + // Exit the pipeline if the result is "No Changes" with success status. + ExitOnNoChanges bool `json:"exitOnNoChanges"` +} + +// TerraformApplyStageOptions contains all configurable values for a TERRAFORM_APPLY stage. +type TerraformApplyStageOptions struct { + // How many times to retry applying terraform changes. + Retries int `json:"retries"` +} + +// TerraformCommandFlags contains all additional flags will be used while executing terraform commands. +type TerraformCommandFlags struct { + Shared []string `json:"shared"` + Init []string `json:"init"` + Plan []string `json:"plan"` + Apply []string `json:"apply"` +} + +// TerraformCommandEnvs contains all additional environment variables will be used while executing terraform commands. +type TerraformCommandEnvs struct { + Shared []string `json:"shared"` + Init []string `json:"init"` + Plan []string `json:"plan"` + Apply []string `json:"apply"` +} diff --git a/pkg/configv1/application_terraform_test.go b/pkg/configv1/application_terraform_test.go new file mode 100644 index 0000000000..6018a4c70b --- /dev/null +++ b/pkg/configv1/application_terraform_test.go @@ -0,0 +1,263 @@ +// Copyright 2024 The PipeCD 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 ( + "encoding/json" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/pipe-cd/pipecd/pkg/model" +) + +func TestTerraformApplicationtConfig(t *testing.T) { + testcases := []struct { + fileName string + expectedKind Kind + expectedAPIVersion string + expectedSpec interface{} + expectedError error + }{ + { + fileName: "testdata/application/terraform-app-empty.yaml", + expectedKind: KindTerraformApp, + expectedAPIVersion: "pipecd.dev/v1beta1", + expectedSpec: &TerraformApplicationSpec{ + GenericApplicationSpec: GenericApplicationSpec{ + Timeout: Duration(6 * time.Hour), + Trigger: Trigger{ + OnCommit: OnCommit{ + Disabled: false, + }, + OnCommand: OnCommand{ + Disabled: false, + }, + OnOutOfSync: OnOutOfSync{ + Disabled: newBoolPointer(true), + MinWindow: Duration(5 * time.Minute), + }, + OnChain: OnChain{ + Disabled: newBoolPointer(true), + }, + }, + Planner: DeploymentPlanner{ + AutoRollback: newBoolPointer(true), + }, + }, + Input: TerraformDeploymentInput{}, + }, + expectedError: nil, + }, + { + fileName: "testdata/application/terraform-app.yaml", + expectedKind: KindTerraformApp, + expectedAPIVersion: "pipecd.dev/v1beta1", + expectedSpec: &TerraformApplicationSpec{ + GenericApplicationSpec: GenericApplicationSpec{ + Timeout: Duration(6 * time.Hour), + Trigger: Trigger{ + OnCommit: OnCommit{ + Disabled: false, + }, + OnCommand: OnCommand{ + Disabled: false, + }, + OnOutOfSync: OnOutOfSync{ + Disabled: newBoolPointer(true), + MinWindow: Duration(5 * time.Minute), + }, + OnChain: OnChain{ + Disabled: newBoolPointer(true), + }, + }, + Planner: DeploymentPlanner{ + AutoRollback: newBoolPointer(true), + }, + }, + Input: TerraformDeploymentInput{ + Workspace: "dev", + TerraformVersion: "0.12.23", + }, + }, + expectedError: nil, + }, + { + fileName: "testdata/application/terraform-app-secret-management.yaml", + expectedKind: KindTerraformApp, + expectedAPIVersion: "pipecd.dev/v1beta1", + expectedSpec: &TerraformApplicationSpec{ + GenericApplicationSpec: GenericApplicationSpec{ + Timeout: Duration(6 * time.Hour), + Trigger: Trigger{ + OnCommit: OnCommit{ + Disabled: false, + }, + OnCommand: OnCommand{ + Disabled: false, + }, + OnOutOfSync: OnOutOfSync{ + Disabled: newBoolPointer(false), + MinWindow: Duration(5 * time.Minute), + }, + OnChain: OnChain{ + Disabled: newBoolPointer(true), + }, + }, + Planner: DeploymentPlanner{ + AutoRollback: newBoolPointer(true), + }, + Encryption: &SecretEncryption{ + EncryptedSecrets: map[string]string{ + "serviceAccount": "ENCRYPTED_DATA_GENERATED_FROM_WEB", + }, + DecryptionTargets: []string{ + "service-account.yaml", + }, + }, + }, + Input: TerraformDeploymentInput{ + Workspace: "dev", + TerraformVersion: "0.12.23", + }, + }, + expectedError: nil, + }, + { + fileName: "testdata/application/terraform-app-with-approval.yaml", + expectedKind: KindTerraformApp, + expectedAPIVersion: "pipecd.dev/v1beta1", + expectedSpec: &TerraformApplicationSpec{ + GenericApplicationSpec: GenericApplicationSpec{ + Pipeline: &DeploymentPipeline{ + Stages: []PipelineStage{ + { + Name: model.StageTerraformPlan, + TerraformPlanStageOptions: &TerraformPlanStageOptions{}, + }, + { + Name: model.StageWaitApproval, + WaitApprovalStageOptions: &WaitApprovalStageOptions{ + Approvers: []string{"foo", "bar"}, + Timeout: Duration(6 * time.Hour), + MinApproverNum: 1, + }, + With: json.RawMessage(`{"approvers":["foo","bar"]}`), + }, + { + Name: model.StageTerraformApply, + TerraformApplyStageOptions: &TerraformApplyStageOptions{}, + }, + }, + }, + Timeout: Duration(6 * time.Hour), + Trigger: Trigger{ + OnCommit: OnCommit{ + Disabled: false, + }, + OnCommand: OnCommand{ + Disabled: false, + }, + OnOutOfSync: OnOutOfSync{ + Disabled: newBoolPointer(true), + MinWindow: Duration(5 * time.Minute), + }, + OnChain: OnChain{ + Disabled: newBoolPointer(true), + }, + }, + Planner: DeploymentPlanner{ + AutoRollback: newBoolPointer(true), + }, + }, + Input: TerraformDeploymentInput{ + Workspace: "dev", + TerraformVersion: "0.12.23", + }, + }, + expectedError: nil, + }, + { + fileName: "testdata/application/terraform-app-with-exit.yaml", + expectedKind: KindTerraformApp, + expectedAPIVersion: "pipecd.dev/v1beta1", + expectedSpec: &TerraformApplicationSpec{ + GenericApplicationSpec: GenericApplicationSpec{ + Pipeline: &DeploymentPipeline{ + Stages: []PipelineStage{ + { + Name: model.StageTerraformPlan, + TerraformPlanStageOptions: &TerraformPlanStageOptions{ + ExitOnNoChanges: true, + }, + With: json.RawMessage(`{"exitOnNoChanges":true}`), + }, + { + Name: model.StageWaitApproval, + WaitApprovalStageOptions: &WaitApprovalStageOptions{ + Approvers: []string{"foo", "bar"}, + Timeout: Duration(6 * time.Hour), + MinApproverNum: 1, + }, + With: json.RawMessage(`{"approvers":["foo","bar"]}`), + }, + { + Name: model.StageTerraformApply, + TerraformApplyStageOptions: &TerraformApplyStageOptions{}, + }, + }, + }, + Timeout: Duration(6 * time.Hour), + Trigger: Trigger{ + OnCommit: OnCommit{ + Disabled: false, + }, + OnCommand: OnCommand{ + Disabled: false, + }, + OnOutOfSync: OnOutOfSync{ + Disabled: newBoolPointer(true), + MinWindow: Duration(5 * time.Minute), + }, + OnChain: OnChain{ + Disabled: newBoolPointer(true), + }, + }, + Planner: DeploymentPlanner{ + AutoRollback: newBoolPointer(true), + }, + }, + Input: TerraformDeploymentInput{ + Workspace: "dev", + TerraformVersion: "0.12.23", + }, + }, + expectedError: nil, + }, + } + for _, tc := range testcases { + t.Run(tc.fileName, func(t *testing.T) { + cfg, err := LoadFromYAML(tc.fileName) + require.Equal(t, tc.expectedError, err) + if err == nil { + assert.Equal(t, tc.expectedKind, cfg.Kind) + assert.Equal(t, tc.expectedAPIVersion, cfg.APIVersion) + assert.Equal(t, tc.expectedSpec, cfg.spec) + } + }) + } +} diff --git a/pkg/configv1/application_test.go b/pkg/configv1/application_test.go new file mode 100644 index 0000000000..b97bfaba39 --- /dev/null +++ b/pkg/configv1/application_test.go @@ -0,0 +1,872 @@ +// Copyright 2024 The PipeCD 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 ( + "encoding/json" + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/pipe-cd/pipecd/pkg/model" +) + +func TestHasStage(t *testing.T) { + testcases := []struct { + name string + s GenericApplicationSpec + stage model.Stage + want bool + }{ + { + name: "no pipeline configured", + s: GenericApplicationSpec{}, + stage: model.StageK8sSync, + want: false, + }, + { + name: "given one doesn't exist", + s: GenericApplicationSpec{ + Pipeline: &DeploymentPipeline{ + Stages: []PipelineStage{ + { + Name: model.StageK8sSync, + }, + }, + }, + }, + stage: model.StageK8sPrimaryRollout, + want: false, + }, + { + name: "given one exists", + s: GenericApplicationSpec{ + Pipeline: &DeploymentPipeline{ + Stages: []PipelineStage{ + { + Name: model.StageK8sSync, + }, + }, + }, + }, + stage: model.StageK8sSync, + want: true, + }, + } + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + got := tc.s.HasStage(tc.stage) + assert.Equal(t, tc.want, got) + }) + } +} + +func TestValidateWaitApprovalStageOptions(t *testing.T) { + testcases := []struct { + name string + minApproverNum int + wantErr bool + }{ + { + name: "valid", + minApproverNum: 1, + wantErr: false, + }, + { + name: "invalid", + minApproverNum: -1, + wantErr: true, + }, + } + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + w := &WaitApprovalStageOptions{ + MinApproverNum: tc.minApproverNum, + } + err := w.Validate() + assert.Equal(t, tc.wantErr, err != nil) + }) + } +} + +func TestFindSlackUsersAndGroups(t *testing.T) { + testcases := []struct { + name string + mentions []NotificationMention + event model.NotificationEventType + wantUsers []string + wantGroups []string + }{ + { + name: "match an event name", + mentions: []NotificationMention{ + { + Event: "DEPLOYMENT_TRIGGERED", + Slack: []string{"user-1", "user-2"}, + }, + { + Event: "DEPLOYMENT_PLANNED", + Slack: []string{"user-3", "user-4"}, + }, + }, + event: model.NotificationEventType_EVENT_DEPLOYMENT_TRIGGERED, + wantUsers: []string{"user-1", "user-2"}, + }, + { + name: "match with both event name and all-events mark", + mentions: []NotificationMention{ + { + Event: "DEPLOYMENT_TRIGGERED", + Slack: []string{"user-1", "user-2"}, + }, + { + Event: "*", + Slack: []string{"user-1", "user-3"}, + SlackGroups: []string{"group-1", "group-2"}, + }, + }, + event: model.NotificationEventType_EVENT_DEPLOYMENT_TRIGGERED, + wantUsers: []string{"user-1", "user-2", "user-3"}, + wantGroups: []string{"group-1", "group-2"}, + }, + { + name: "match by all-events mark", + mentions: []NotificationMention{ + { + Event: "DEPLOYMENT_TRIGGERED", + Slack: []string{"user-1", "user-2"}, + }, + { + Event: "*", + Slack: []string{"user-1", "user-3"}, + }, + }, + event: model.NotificationEventType_EVENT_DEPLOYMENT_PLANNED, + wantUsers: []string{"user-1", "user-3"}, + }, + { + name: "match by all-events mark with slack groups", + mentions: []NotificationMention{ + { + Event: "DEPLOYMENT_TRIGGERED", + Slack: []string{"user-1", "user-2"}, + }, + { + Event: "*", + SlackGroups: []string{"group-1", "group-2"}, + }, + }, + event: model.NotificationEventType_EVENT_DEPLOYMENT_PLANNED, + wantGroups: []string{"group-1", "group-2"}, + }, + { + name: "does not match anything", + mentions: []NotificationMention{ + { + Event: "DEPLOYMENT_TRIGGERED", + Slack: []string{"user-1", "user-2"}, + }, + }, + event: model.NotificationEventType_EVENT_DEPLOYMENT_PLANNED, + wantUsers: []string{}, + }, + { + name: "match an event name with Slack Groups", + mentions: []NotificationMention{ + { + Event: "DEPLOYMENT_PLANNED", + SlackGroups: []string{"group-1", "group-2"}, + }, + }, + event: model.NotificationEventType_EVENT_DEPLOYMENT_PLANNED, + wantGroups: []string{"group-1", "group-2"}, + }, + { + name: "match an event name with Slack Users and Groups", + mentions: []NotificationMention{ + { + Event: "DEPLOYMENT_PLANNED", + Slack: []string{"user-1", "user-2"}, + SlackGroups: []string{"group-1", "group-2"}, + }, + }, + event: model.NotificationEventType_EVENT_DEPLOYMENT_PLANNED, + wantUsers: []string{"user-1", "user-2"}, + wantGroups: []string{"group-1", "group-2"}, + }, + { + name: "match an event name with Slack Users with new field SlackUsers", + mentions: []NotificationMention{ + { + Event: "DEPLOYMENT_PLANNED", + SlackUsers: []string{"user-1", "user-2"}, + Slack: []string{"user-3", "user-4"}, + SlackGroups: []string{"group-1", "group-2"}, + }, + }, + event: model.NotificationEventType_EVENT_DEPLOYMENT_PLANNED, + wantUsers: []string{"user-1", "user-2", "user-3", "user-4"}, + wantGroups: []string{"group-1", "group-2"}, + }, + } + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + n := &DeploymentNotification{ + tc.mentions, + } + as := n.FindSlackUsers(tc.event) + ag := n.FindSlackGroups(tc.event) + assert.ElementsMatch(t, tc.wantUsers, as) + assert.ElementsMatch(t, tc.wantGroups, ag) + }) + } +} + +func TestValidateAnalysisTemplateRef(t *testing.T) { + testcases := []struct { + name string + tplName string + wantErr bool + }{ + { + name: "valid", + tplName: "name", + wantErr: false, + }, + { + name: "invalid due to empty template name", + tplName: "", + wantErr: true, + }, + } + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + a := &AnalysisTemplateRef{ + Name: tc.tplName, + } + err := a.Validate() + assert.Equal(t, tc.wantErr, err != nil) + }) + } +} + +func TestValidateEncryption(t *testing.T) { + testcases := []struct { + name string + encryptedSecrets map[string]string + targets []string + wantErr bool + }{ + { + name: "valid", + encryptedSecrets: map[string]string{"password": "pw"}, + targets: []string{"secret.yaml"}, + wantErr: false, + }, + { + name: "invalid because key is empty", + encryptedSecrets: map[string]string{"": "pw"}, + targets: []string{"secret.yaml"}, + wantErr: true, + }, + { + name: "invalid because value is empty", + encryptedSecrets: map[string]string{"password": ""}, + targets: []string{"secret.yaml"}, + wantErr: true, + }, + { + name: "no target files sepcified", + encryptedSecrets: map[string]string{"password": "pw"}, + wantErr: true, + }, + } + for _, tc := range testcases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + s := &SecretEncryption{ + EncryptedSecrets: tc.encryptedSecrets, + DecryptionTargets: tc.targets, + } + err := s.Validate() + assert.Equal(t, tc.wantErr, err != nil) + }) + } +} + +func TestValidateAttachment(t *testing.T) { + testcases := []struct { + name string + sources map[string]string + targets []string + wantErr bool + }{ + { + name: "valid", + sources: map[string]string{"config": "config.yaml"}, + targets: []string{"target.yaml"}, + wantErr: false, + }, + { + name: "invalid because key is empty", + sources: map[string]string{"": "config-data"}, + targets: []string{"target.yaml"}, + wantErr: true, + }, + { + name: "invalid because value is empty", + sources: map[string]string{"config": ""}, + targets: []string{"target.yaml"}, + wantErr: true, + }, + { + name: "no target files sepcified", + sources: map[string]string{"config": "config.yaml"}, + wantErr: true, + }, + } + for _, tc := range testcases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + a := &Attachment{ + Sources: tc.sources, + Targets: tc.targets, + } + err := a.Validate() + assert.Equal(t, tc.wantErr, err != nil) + }) + } +} + +func TestValidateMentions(t *testing.T) { + testcases := []struct { + name string + event string + slack []string + wantErr bool + }{ + { + name: "valid", + event: "DEPLOYMENT_TRIGGERED", + slack: []string{"user-1", "user-2"}, + wantErr: false, + }, + { + name: "valid", + event: "*", + slack: []string{"user-1", "user-2"}, + wantErr: false, + }, + { + name: "invalid because of non-existent event", + event: "event-1", + slack: []string{"user-1", "user-2"}, + wantErr: true, + }, + { + name: "invalid because of missing event", + event: "", + slack: []string{"user-1", "user-2"}, + wantErr: true, + }, + } + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + m := &NotificationMention{ + Event: tc.event, + Slack: tc.slack, + } + err := m.Validate() + assert.Equal(t, tc.wantErr, err != nil) + }) + } +} + +func TestGenericTriggerConfiguration(t *testing.T) { + testcases := []struct { + fileName string + expectedKind Kind + expectedAPIVersion string + expectedSpec interface{} + expectedError error + }{ + { + fileName: "testdata/application/generic-trigger.yaml", + expectedKind: KindKubernetesApp, + expectedAPIVersion: "pipecd.dev/v1beta1", + expectedSpec: &KubernetesApplicationSpec{ + GenericApplicationSpec: GenericApplicationSpec{ + Timeout: Duration(6 * time.Hour), + Trigger: Trigger{ + OnCommit: OnCommit{ + Disabled: false, + Paths: []string{ + "deployment.yaml", + }, + }, + OnOutOfSync: OnOutOfSync{ + Disabled: newBoolPointer(true), + MinWindow: Duration(5 * time.Minute), + }, + OnChain: OnChain{ + Disabled: newBoolPointer(true), + }, + }, + Planner: DeploymentPlanner{ + AutoRollback: newBoolPointer(true), + }, + }, + Input: KubernetesDeploymentInput{ + AutoRollback: newBoolPointer(true), + }, + VariantLabel: KubernetesVariantLabel{ + Key: "pipecd.dev/variant", + PrimaryValue: "primary", + BaselineValue: "baseline", + CanaryValue: "canary", + }, + }, + expectedError: nil, + }, + } + for _, tc := range testcases { + t.Run(tc.fileName, func(t *testing.T) { + cfg, err := LoadFromYAML(tc.fileName) + require.Equal(t, tc.expectedError, err) + if err == nil { + assert.Equal(t, tc.expectedKind, cfg.Kind) + assert.Equal(t, tc.expectedAPIVersion, cfg.APIVersion) + assert.Equal(t, tc.expectedSpec, cfg.spec) + } + }) + } +} + +func TestTrueByDefaultBoolConfiguration(t *testing.T) { + testcases := []struct { + fileName string + expectedKind Kind + expectedAPIVersion string + expectedSpec interface{} + expectedError error + }{ + { + fileName: "testdata/application/truebydefaultbool-not-specified.yaml", + expectedKind: KindKubernetesApp, + expectedAPIVersion: "pipecd.dev/v1beta1", + expectedSpec: &KubernetesApplicationSpec{ + GenericApplicationSpec: GenericApplicationSpec{ + Timeout: Duration(6 * time.Hour), + Trigger: Trigger{ + OnOutOfSync: OnOutOfSync{ + Disabled: newBoolPointer(true), + MinWindow: Duration(5 * time.Minute), + }, + OnChain: OnChain{ + Disabled: newBoolPointer(true), + }, + }, + Planner: DeploymentPlanner{ + AutoRollback: newBoolPointer(true), + }, + }, + Input: KubernetesDeploymentInput{ + AutoRollback: newBoolPointer(true), + }, + VariantLabel: KubernetesVariantLabel{ + Key: "pipecd.dev/variant", + PrimaryValue: "primary", + BaselineValue: "baseline", + CanaryValue: "canary", + }, + }, + expectedError: nil, + }, + { + fileName: "testdata/application/truebydefaultbool-false-explicitly.yaml", + expectedKind: KindKubernetesApp, + expectedAPIVersion: "pipecd.dev/v1beta1", + expectedSpec: &KubernetesApplicationSpec{ + GenericApplicationSpec: GenericApplicationSpec{ + Timeout: Duration(6 * time.Hour), + Trigger: Trigger{ + OnOutOfSync: OnOutOfSync{ + Disabled: newBoolPointer(false), + MinWindow: Duration(5 * time.Minute), + }, + OnChain: OnChain{ + Disabled: newBoolPointer(true), + }, + }, + Planner: DeploymentPlanner{ + AutoRollback: newBoolPointer(true), + }, + }, + Input: KubernetesDeploymentInput{ + AutoRollback: newBoolPointer(false), + }, + VariantLabel: KubernetesVariantLabel{ + Key: "pipecd.dev/variant", + PrimaryValue: "primary", + BaselineValue: "baseline", + CanaryValue: "canary", + }, + }, + expectedError: nil, + }, + { + fileName: "testdata/application/truebydefaultbool-true-explicitly.yaml", + expectedKind: KindKubernetesApp, + expectedAPIVersion: "pipecd.dev/v1beta1", + expectedSpec: &KubernetesApplicationSpec{ + GenericApplicationSpec: GenericApplicationSpec{ + Timeout: Duration(6 * time.Hour), + Trigger: Trigger{ + OnOutOfSync: OnOutOfSync{ + Disabled: newBoolPointer(true), + MinWindow: Duration(5 * time.Minute), + }, + OnChain: OnChain{ + Disabled: newBoolPointer(true), + }, + }, + Planner: DeploymentPlanner{ + AutoRollback: newBoolPointer(true), + }, + }, + Input: KubernetesDeploymentInput{ + AutoRollback: newBoolPointer(true), + }, + VariantLabel: KubernetesVariantLabel{ + Key: "pipecd.dev/variant", + PrimaryValue: "primary", + BaselineValue: "baseline", + CanaryValue: "canary", + }, + }, + expectedError: nil, + }, + } + for _, tc := range testcases { + t.Run(tc.fileName, func(t *testing.T) { + cfg, err := LoadFromYAML(tc.fileName) + require.Equal(t, tc.expectedError, err) + if err == nil { + assert.Equal(t, tc.expectedKind, cfg.Kind) + assert.Equal(t, tc.expectedAPIVersion, cfg.APIVersion) + assert.Equal(t, tc.expectedSpec, cfg.spec) + } + }) + } +} + +func TestGenericPostSyncConfiguration(t *testing.T) { + testcases := []struct { + fileName string + expectedKind Kind + expectedAPIVersion string + expectedSpec interface{} + expectedError error + }{ + { + fileName: "testdata/application/generic-postsync.yaml", + expectedKind: KindKubernetesApp, + expectedAPIVersion: "pipecd.dev/v1beta1", + expectedSpec: &KubernetesApplicationSpec{ + GenericApplicationSpec: GenericApplicationSpec{ + Timeout: Duration(6 * time.Hour), + Trigger: Trigger{ + OnOutOfSync: OnOutOfSync{ + Disabled: newBoolPointer(true), + MinWindow: Duration(5 * time.Minute), + }, + OnChain: OnChain{ + Disabled: newBoolPointer(true), + }, + }, + Planner: DeploymentPlanner{ + AutoRollback: newBoolPointer(true), + }, + PostSync: &PostSync{ + DeploymentChain: &DeploymentChain{ + ApplicationMatchers: []ChainApplicationMatcher{ + { + Name: "app-1", + }, + { + Labels: map[string]string{ + "env": "staging", + "foo": "bar", + }, + }, + { + Kind: "ECSApp", + }, + }, + }, + }, + }, + Input: KubernetesDeploymentInput{ + AutoRollback: newBoolPointer(true), + }, + VariantLabel: KubernetesVariantLabel{ + Key: "pipecd.dev/variant", + PrimaryValue: "primary", + BaselineValue: "baseline", + CanaryValue: "canary", + }, + }, + expectedError: nil, + }, + } + for _, tc := range testcases { + t.Run(tc.fileName, func(t *testing.T) { + cfg, err := LoadFromYAML(tc.fileName) + require.Equal(t, tc.expectedError, err) + if err == nil { + assert.Equal(t, tc.expectedKind, cfg.Kind) + assert.Equal(t, tc.expectedAPIVersion, cfg.APIVersion) + assert.Equal(t, tc.expectedSpec, cfg.spec) + } + }) + } +} + +func TestGenericAnalysisConfiguration(t *testing.T) { + testcases := []struct { + fileName string + expectedKind Kind + expectedAPIVersion string + expectedSpec interface{} + expectedError error + }{ + { + fileName: "testdata/application/generic-analysis.yaml", + expectedKind: KindKubernetesApp, + expectedAPIVersion: "pipecd.dev/v1beta1", + expectedSpec: &KubernetesApplicationSpec{ + GenericApplicationSpec: GenericApplicationSpec{ + Timeout: Duration(6 * time.Hour), + Trigger: Trigger{ + OnOutOfSync: OnOutOfSync{ + Disabled: newBoolPointer(true), + MinWindow: Duration(5 * time.Minute), + }, + OnChain: OnChain{ + Disabled: newBoolPointer(true), + }, + }, + Planner: DeploymentPlanner{ + AutoRollback: newBoolPointer(true), + }, + Pipeline: &DeploymentPipeline{ + Stages: []PipelineStage{ + { + Name: model.StageAnalysis, + AnalysisStageOptions: &AnalysisStageOptions{ + Duration: Duration(10 * time.Minute), + Metrics: []TemplatableAnalysisMetrics{ + { + AnalysisMetrics: AnalysisMetrics{ + Strategy: AnalysisStrategyThreshold, + Provider: "prometheus-dev", + Query: "grpc_error_percentage", + Expected: AnalysisExpected{Max: floatPointer(0.1)}, + Interval: Duration(1 * time.Minute), + Timeout: Duration(30 * time.Second), + FailureLimit: 1, + Deviation: AnalysisDeviationEither, + }, + }, + { + AnalysisMetrics: AnalysisMetrics{ + Strategy: AnalysisStrategyThreshold, + Provider: "prometheus-dev", + Query: "grpc_succeed_percentage", + Expected: AnalysisExpected{Min: floatPointer(0.9)}, + Interval: Duration(1 * time.Minute), + Timeout: Duration(30 * time.Second), + FailureLimit: 1, + Deviation: AnalysisDeviationEither, + }, + }, + }, + }, + With: json.RawMessage(`{"duration":"10m","metrics":[{"expected":{"max":0.1},"failureLimit":1,"interval":"1m","provider":"prometheus-dev","query":"grpc_error_percentage"},{"expected":{"min":0.9},"failureLimit":1,"interval":"1m","provider":"prometheus-dev","query":"grpc_succeed_percentage"}]}`), + }, + { + Name: model.StageAnalysis, + AnalysisStageOptions: &AnalysisStageOptions{ + Duration: Duration(10 * time.Minute), + Logs: []TemplatableAnalysisLog{ + { + AnalysisLog: AnalysisLog{ + Provider: "stackdriver-dev", + Query: "resource.labels.pod_id=\"pod1\"\n", + Interval: Duration(1 * time.Minute), + FailureLimit: 3, + }, + }, + }, + }, + With: json.RawMessage(`{"duration":"10m","logs":[{"failureLimit":3,"interval":"1m","provider":"stackdriver-dev","query":"resource.labels.pod_id=\"pod1\"\n"}]}`), + }, + { + Name: model.StageAnalysis, + AnalysisStageOptions: &AnalysisStageOptions{ + Duration: Duration(10 * time.Minute), + HTTPS: []TemplatableAnalysisHTTP{ + { + AnalysisHTTP: AnalysisHTTP{ + URL: "https://canary-endpoint.dev", + Method: "GET", + ExpectedCode: 200, + FailureLimit: 1, + Interval: Duration(1 * time.Minute), + }, + }, + }, + }, + With: json.RawMessage(`{"duration":"10m","https":[{"expectedCode":200,"failureLimit":1,"interval":"1m","method":"GET","url":"https://canary-endpoint.dev"}]}`), + }, + }, + }, + }, + Input: KubernetesDeploymentInput{ + AutoRollback: newBoolPointer(true), + }, + VariantLabel: KubernetesVariantLabel{ + Key: "pipecd.dev/variant", + PrimaryValue: "primary", + BaselineValue: "baseline", + CanaryValue: "canary", + }, + }, + expectedError: nil, + }, + } + for _, tc := range testcases { + t.Run(tc.fileName, func(t *testing.T) { + cfg, err := LoadFromYAML(tc.fileName) + require.Equal(t, tc.expectedError, err) + if err == nil { + assert.Equal(t, tc.expectedKind, cfg.Kind) + assert.Equal(t, tc.expectedAPIVersion, cfg.APIVersion) + assert.Equal(t, tc.expectedSpec, cfg.spec) + } + }) + } +} + +func TestCustomSyncConfig(t *testing.T) { + testcases := []struct { + fileName string + expectedKind Kind + expectedAPIVersion string + expectedSpec interface{} + expectedError error + }{ + { + fileName: "testdata/application/custom-sync.yaml", + expectedKind: KindLambdaApp, + expectedAPIVersion: "pipecd.dev/v1beta1", + expectedSpec: &LambdaApplicationSpec{ + GenericApplicationSpec: GenericApplicationSpec{ + Timeout: Duration(6 * time.Hour), + Pipeline: &DeploymentPipeline{ + Stages: []PipelineStage{ + { + Name: model.StageCustomSync, + Desc: "deploy by sam", + CustomSyncOptions: &CustomSyncOptions{ + Timeout: Duration(6 * time.Hour), + Envs: map[string]string{ + "AWS_PROFILE": "default", + }, + Run: "sam build\nsam deploy -g --profile $AWS_PROFILE\n", + }, + With: json.RawMessage(`{"envs":{"AWS_PROFILE":"default"},"run":"sam build\nsam deploy -g --profile $AWS_PROFILE\n","timeout":"6h"}`), + }, + }, + }, + Trigger: Trigger{ + OnOutOfSync: OnOutOfSync{ + Disabled: newBoolPointer(true), + MinWindow: Duration(5 * time.Minute), + }, + OnChain: OnChain{ + Disabled: newBoolPointer(true), + }, + }, + Planner: DeploymentPlanner{ + AutoRollback: newBoolPointer(true), + }, + }, + Input: LambdaDeploymentInput{ + FunctionManifestFile: "function.yaml", + AutoRollback: newBoolPointer(true), + }, + }, + expectedError: nil, + }, + { + fileName: "testdata/application/custom-sync-without-run.yaml", + expectedError: fmt.Errorf("the CUSTOM_SYNC stage requires run field"), + }, + } + for _, tc := range testcases { + t.Run(tc.fileName, func(t *testing.T) { + cfg, err := LoadFromYAML(tc.fileName) + require.Equal(t, tc.expectedError, err) + if err == nil { + assert.Equal(t, tc.expectedKind, cfg.Kind) + assert.Equal(t, tc.expectedAPIVersion, cfg.APIVersion) + assert.Equal(t, tc.expectedSpec, cfg.spec) + } + }) + } +} + +func TestScriptSycConfiguration(t *testing.T) { + testcases := []struct { + name string + opts ScriptRunStageOptions + wantErr bool + }{ + { + name: "valid", + opts: ScriptRunStageOptions{ + Run: "echo 'hello world'", + }, + wantErr: false, + }, + { + name: "invalid", + opts: ScriptRunStageOptions{ + Run: "", + }, + wantErr: true, + }, + } + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + err := tc.opts.Validate() + assert.Equal(t, tc.wantErr, err != nil) + }) + } +} diff --git a/pkg/configv1/config.go b/pkg/configv1/config.go new file mode 100644 index 0000000000..495330b7a7 --- /dev/null +++ b/pkg/configv1/config.go @@ -0,0 +1,255 @@ +// Copyright 2024 The PipeCD 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 ( + "bytes" + "encoding/json" + "errors" + "fmt" + "os" + + "github.com/creasty/defaults" + "sigs.k8s.io/yaml" + + "github.com/pipe-cd/pipecd/pkg/model" +) + +const ( + SharedConfigurationDirName = ".pipe" + VersionV1Beta1 = "pipecd.dev/v1beta1" +) + +// Kind represents the kind of configuration the data contains. +type Kind string + +const ( + // KindKubernetesApp represents application configuration for a Kubernetes application. + // This application can be a group of plain-YAML Kubernetes manifests, + // or kustomization manifests or helm manifests. + KindKubernetesApp Kind = "KubernetesApp" + // KindTerraformApp represents application configuration for a Terraform application. + // This application contains a single workspace of a terraform root module. + KindTerraformApp Kind = "TerraformApp" + // KindLambdaApp represents application configuration for an AWS Lambda application. + KindLambdaApp Kind = "LambdaApp" + // KindCloudRunApp represents application configuration for a CloudRun application. + KindCloudRunApp Kind = "CloudRunApp" + // KindECSApp represents application configuration for an AWS ECS. + KindECSApp Kind = "ECSApp" +) + +const ( + // KindPiped represents configuration for piped. + // This configuration will be loaded while the piped is starting up. + KindPiped Kind = "Piped" + // KindControlPlane represents configuration for control plane's services. + KindControlPlane Kind = "ControlPlane" + // KindAnalysisTemplate represents shared analysis template for a repository. + // This configuration file should be placed in .pipe directory + // at the root of the repository. + KindAnalysisTemplate Kind = "AnalysisTemplate" + // KindEventWatcher represents configuration for Event Watcher. + KindEventWatcher Kind = "EventWatcher" +) + +var ( + ErrNotFound = errors.New("not found") +) + +// Config represents configuration data load from file. +// The spec is depend on the kind of configuration. +type Config struct { + Kind Kind + APIVersion string + spec interface{} + + KubernetesApplicationSpec *KubernetesApplicationSpec + TerraformApplicationSpec *TerraformApplicationSpec + CloudRunApplicationSpec *CloudRunApplicationSpec + LambdaApplicationSpec *LambdaApplicationSpec + ECSApplicationSpec *ECSApplicationSpec + + PipedSpec *PipedSpec + ControlPlaneSpec *ControlPlaneSpec + AnalysisTemplateSpec *AnalysisTemplateSpec + EventWatcherSpec *EventWatcherSpec +} + +type genericConfig struct { + Kind Kind `json:"kind"` + APIVersion string `json:"apiVersion,omitempty"` + Spec json.RawMessage `json:"spec"` +} + +func (c *Config) init(kind Kind, apiVersion string) error { + c.Kind = kind + c.APIVersion = apiVersion + + switch kind { + case KindKubernetesApp: + c.KubernetesApplicationSpec = &KubernetesApplicationSpec{} + c.spec = c.KubernetesApplicationSpec + + case KindTerraformApp: + c.TerraformApplicationSpec = &TerraformApplicationSpec{} + c.spec = c.TerraformApplicationSpec + + case KindCloudRunApp: + c.CloudRunApplicationSpec = &CloudRunApplicationSpec{} + c.spec = c.CloudRunApplicationSpec + + case KindLambdaApp: + c.LambdaApplicationSpec = &LambdaApplicationSpec{} + c.spec = c.LambdaApplicationSpec + + case KindECSApp: + c.ECSApplicationSpec = &ECSApplicationSpec{} + c.spec = c.ECSApplicationSpec + + case KindPiped: + c.PipedSpec = &PipedSpec{} + c.spec = c.PipedSpec + + case KindControlPlane: + c.ControlPlaneSpec = &ControlPlaneSpec{} + c.spec = c.ControlPlaneSpec + + case KindAnalysisTemplate: + c.AnalysisTemplateSpec = &AnalysisTemplateSpec{} + c.spec = c.AnalysisTemplateSpec + + case KindEventWatcher: + c.EventWatcherSpec = &EventWatcherSpec{} + c.spec = c.EventWatcherSpec + + default: + return fmt.Errorf("unsupported kind: %s", c.Kind) + } + return nil +} + +// UnmarshalJSON customizes the way to unmarshal json data into Config struct. +// Firstly, this unmarshal to a generic config and then unmarshal the spec +// which depend on the kind of configuration. +func (c *Config) UnmarshalJSON(data []byte) error { + var ( + err error + gc = genericConfig{} + ) + dec := json.NewDecoder(bytes.NewReader(data)) + dec.DisallowUnknownFields() + if err := dec.Decode(&gc); err != nil { + return err + } + if err = c.init(gc.Kind, gc.APIVersion); err != nil { + return err + } + + if len(gc.Spec) > 0 { + dec := json.NewDecoder(bytes.NewReader(gc.Spec)) + dec.DisallowUnknownFields() + err = dec.Decode(c.spec) + } + return err +} + +type validator interface { + Validate() error +} + +// Validate validates the value of all fields. +func (c *Config) Validate() error { + if c.APIVersion != VersionV1Beta1 { + return fmt.Errorf("unsupported version: %s", c.APIVersion) + } + if c.Kind == "" { + return fmt.Errorf("kind is required") + } + if c.spec == nil { + return fmt.Errorf("spec is required") + } + + spec, ok := c.spec.(validator) + if !ok { + return fmt.Errorf("spec must have Validate function") + } + if err := spec.Validate(); err != nil { + return err + } + return nil +} + +// LoadFromYAML reads and decodes a yaml file to construct the Config. +func LoadFromYAML(file string) (*Config, error) { + data, err := os.ReadFile(file) + if err != nil { + return nil, err + } + return DecodeYAML(data) +} + +// DecodeYAML unmarshals config YAML data to config struct. +// It also validates the configuration after decoding. +func DecodeYAML(data []byte) (*Config, error) { + js, err := yaml.YAMLToJSON(data) + if err != nil { + return nil, err + } + c := &Config{} + if err := json.Unmarshal(js, c); err != nil { + return nil, err + } + if err := defaults.Set(c); err != nil { + return nil, err + } + if err := c.Validate(); err != nil { + return nil, err + } + return c, nil +} + +// ToApplicationKind converts configuration kind to application kind. +func (k Kind) ToApplicationKind() (model.ApplicationKind, bool) { + switch k { + case KindKubernetesApp: + return model.ApplicationKind_KUBERNETES, true + case KindTerraformApp: + return model.ApplicationKind_TERRAFORM, true + case KindLambdaApp: + return model.ApplicationKind_LAMBDA, true + case KindCloudRunApp: + return model.ApplicationKind_CLOUDRUN, true + case KindECSApp: + return model.ApplicationKind_ECS, true + } + return model.ApplicationKind_KUBERNETES, false +} + +func (c *Config) GetGenericApplication() (GenericApplicationSpec, bool) { + switch c.Kind { + case KindKubernetesApp: + return c.KubernetesApplicationSpec.GenericApplicationSpec, true + case KindTerraformApp: + return c.TerraformApplicationSpec.GenericApplicationSpec, true + case KindCloudRunApp: + return c.CloudRunApplicationSpec.GenericApplicationSpec, true + case KindLambdaApp: + return c.LambdaApplicationSpec.GenericApplicationSpec, true + case KindECSApp: + return c.ECSApplicationSpec.GenericApplicationSpec, true + } + return GenericApplicationSpec{}, false +} diff --git a/pkg/configv1/config_test.go b/pkg/configv1/config_test.go new file mode 100644 index 0000000000..0cfc80b5ae --- /dev/null +++ b/pkg/configv1/config_test.go @@ -0,0 +1,112 @@ +// Copyright 2024 The PipeCD 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 ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/pipe-cd/pipecd/pkg/model" +) + +func TestUnmarshalConfig(t *testing.T) { + testcases := []struct { + name string + data string + wantSpec interface{} + wantErr bool + }{ + { + name: "correct config for KubernetesApp", + data: `{ + "apiVersion": "pipecd.dev/v1beta1", + "kind": "KubernetesApp", + "spec": { + "input": { + "namespace": "default" + } + } +}`, + wantSpec: &KubernetesApplicationSpec{ + Input: KubernetesDeploymentInput{ + Namespace: "default", + }, + }, + wantErr: false, + }, + { + name: "config for KubernetesApp with unknown field", + data: `{ + "apiVersion": "pipecd.dev/v1beta1", + "kind": "KubernetesApp", + "spec": { + "input": { + "namespace": "default" + }, + "unknown": {} + } +}`, + wantSpec: &KubernetesApplicationSpec{ + Input: KubernetesDeploymentInput{ + Namespace: "default", + }, + }, + wantErr: true, + }, + } + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + var got Config + err := json.Unmarshal([]byte(tc.data), &got) + assert.Equal(t, tc.wantErr, err != nil) + assert.Equal(t, tc.wantSpec, got.spec) + }) + } +} + +func newBoolPointer(v bool) *bool { + return &v +} + +func TestKind_ToApplicationKind(t *testing.T) { + testcases := []struct { + name string + k Kind + want model.ApplicationKind + wantOk bool + }{ + { + name: "App config", + k: KindKubernetesApp, + want: model.ApplicationKind_KUBERNETES, + wantOk: true, + }, + { + name: "Not an app config", + k: KindPiped, + want: model.ApplicationKind_KUBERNETES, + wantOk: false, + }, + } + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + got, gotOk := tc.k.ToApplicationKind() + assert.Equal(t, tc.want, got) + assert.Equal(t, tc.wantOk, gotOk) + }) + } +} diff --git a/pkg/configv1/control_plane.go b/pkg/configv1/control_plane.go new file mode 100644 index 0000000000..47153c1e57 --- /dev/null +++ b/pkg/configv1/control_plane.go @@ -0,0 +1,323 @@ +// Copyright 2024 The PipeCD 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 ( + "encoding/json" + "fmt" + "time" + + "github.com/golang/protobuf/jsonpb" + + "github.com/pipe-cd/pipecd/pkg/model" +) + +// ControlPlaneSpec defines all configuration for all control-plane components. +type ControlPlaneSpec struct { + // The address to the control plane. + // This is required if SSO is enabled. + Address string `json:"address"` + // A randomly generated string used to sign oauth state. + StateKey string `json:"stateKey"` + // The configuration of datastore for control plane. + Datastore ControlPlaneDataStore `json:"datastore"` + // The configuration of filestore for control plane. + Filestore ControlPlaneFileStore `json:"filestore"` + // The configuration of cache for control plane. + Cache ControlPlaneCache `json:"cache"` + // The configuration of insight collector. + InsightCollector ControlPlaneInsightCollector `json:"insightCollector"` + // List of debugging/quickstart projects defined in Control Plane configuration. + // Please note that do not use this to configure the projects running in the production. + Projects []ControlPlaneProject `json:"projects"` + // List of shared SSO configurations that can be used by any projects. + SharedSSOConfigs []SharedSSOConfig `json:"sharedSSOConfigs"` +} + +func (s *ControlPlaneSpec) Validate() error { + return nil +} + +type ControlPlaneProject struct { + // The unique identifier of the project. + ID string `json:"id"` + // The description about the project. + Desc string `json:"desc"` + // Static admin account of the project. + StaticAdmin ProjectStaticUser `json:"staticAdmin"` +} + +type ProjectStaticUser struct { + // The username string. + Username string `json:"username"` + // The bcrypt hashsed value of the password string. + PasswordHash string `json:"passwordHash"` +} + +type SharedSSOConfig struct { + model.ProjectSSOConfig `json:",inline"` + Name string `json:"name"` +} + +func (s *SharedSSOConfig) UnmarshalJSON(data []byte) error { + m := make(map[string]interface{}) + if err := json.Unmarshal(data, &m); err != nil { + return err + } + + provider := m["provider"].(string) + v, ok := model.ProjectSSOConfig_Provider_value[provider] + if !ok { + return fmt.Errorf("unsupported provider %s", provider) + } + m["provider"] = v + + name, ok := m["name"] + if !ok { + return fmt.Errorf("name field in SharedSSOConfig is required") + } + s.Name = name.(string) + delete(m, "name") + + data, err := json.Marshal(m) + if err != nil { + return err + } + + // Using jsonpb instead of the standard json to unmarshal because + // json is unmarshaling with the underscored tags. + // https://github.com/golang/protobuf/issues/183 + if err := jsonpb.UnmarshalString(string(data), &s.ProjectSSOConfig); err != nil { + return err + } + return nil +} + +// FindProject finds and returns a specific project in the configured list. +func (s *ControlPlaneSpec) FindProject(id string) (ControlPlaneProject, bool) { + for i := range s.Projects { + if s.Projects[i].ID != id { + continue + } + return s.Projects[i], true + } + return ControlPlaneProject{}, false +} + +func (s *ControlPlaneSpec) ProjectMap() map[string]ControlPlaneProject { + m := make(map[string]ControlPlaneProject, len(s.Projects)) + for i := range s.Projects { + m[s.Projects[i].ID] = s.Projects[i] + } + return m +} + +func (s *ControlPlaneSpec) SharedSSOConfigMap() map[string]*model.ProjectSSOConfig { + m := make(map[string]*model.ProjectSSOConfig, len(s.SharedSSOConfigs)) + for i := range s.SharedSSOConfigs { + m[s.SharedSSOConfigs[i].Name] = &s.SharedSSOConfigs[i].ProjectSSOConfig + } + return m +} + +type ControlPlaneDataStore struct { + // The datastore type. + Type model.DataStoreType + + // The configuration in the case of Cloud Firestore. + FirestoreConfig *DataStoreFireStoreConfig + // The configuration in the case of general MySQL. + MySQLConfig *DataStoreMySQLConfig +} + +type genericControlPlaneDataStore struct { + Type model.DataStoreType `json:"type"` + Config json.RawMessage `json:"config"` +} + +func (d *ControlPlaneDataStore) UnmarshalJSON(data []byte) error { + var err error + gc := genericControlPlaneDataStore{} + if err = json.Unmarshal(data, &gc); err != nil { + return err + } + d.Type = gc.Type + + switch d.Type { + case model.DataStoreFirestore: + d.FirestoreConfig = &DataStoreFireStoreConfig{} + if len(gc.Config) > 0 { + err = json.Unmarshal(gc.Config, d.FirestoreConfig) + } + case model.DataStoreMySQL: + d.MySQLConfig = &DataStoreMySQLConfig{} + if len(gc.Config) > 0 { + err = json.Unmarshal(gc.Config, d.MySQLConfig) + } + case model.DataStoreFileDB: + // The FILEDB datastore using the same configuration with filestore + // so there will be no `datastore.config` required for now. + err = nil + default: + // Left comment out for mock response. + // err = fmt.Errorf("unsupported datastore type: %s", d.Type) + err = nil + } + return err +} + +type ControlPlaneCache struct { + TTL Duration `json:"ttl"` +} + +type ControlPlaneInsightCollector struct { + Application InsightCollectorApplication `json:"application"` + Deployment InsightCollectorDeployment `json:"deployment"` +} + +type InsightCollectorApplication struct { + Enabled *bool `json:"enabled" default:"true"` + // Default is running every hour. + Schedule string `json:"schedule" default:"0 * * * *"` +} + +type InsightCollectorDeployment struct { + Enabled *bool `json:"enabled" default:"true"` + // Default is running every hour. + Schedule string `json:"schedule" default:"30 * * * *"` + ChunkMaxCount int `json:"chunkMaxCount" default:"1000"` +} + +func (c ControlPlaneCache) TTLDuration() time.Duration { + const defaultTTL = 5 * time.Minute + + if c.TTL == 0 { + return defaultTTL + } + return c.TTL.Duration() +} + +type DataStoreFireStoreConfig struct { + // The root path element considered as a logical namespace, e.g. `pipecd`. + Namespace string `json:"namespace"` + // The second path element considered as a logical environment, e.g. `dev`. + // All pipecd collections will have path formatted according to `{namespace}/{environment}/{collection-name}`. + Environment string `json:"environment"` + // The prefix for collection name. + // This can be used to avoid conflicts with existing collections in your Firestore database. + CollectionNamePrefix string `json:"collectionNamePrefix"` + // The name of GCP project hosting the Firestore. + Project string `json:"project"` + // The path to the service account file for accessing Firestores. + CredentialsFile string `json:"credentialsFile"` +} + +type DataStoreMySQLConfig struct { + // The url of MySQL. All of credentials can be specified via this field. + URL string `json:"url"` + // The name of the database. + // For those who don't want to include the database in the URL. + Database string `json:"database"` + // The path to the username file. + // For those who don't want to include the username in the URL. + UsernameFile string `json:"usernameFile"` + // The path to the password file. + // For those who don't want to include the password in the URL. + PasswordFile string `json:"passwordFile"` +} + +type ControlPlaneFileStore struct { + // The filestore type. + Type model.FileStoreType + + // The configuration in the case of Google Cloud Storage. + GCSConfig *FileStoreGCSConfig `json:"gcs"` + // The configuration in the case of Amazon S3. + S3Config *FileStoreS3Config `json:"s3"` + // The configuration in the case of Minio. + MinioConfig *FileStoreMinioConfig `json:"minio"` +} + +type genericControlPlaneFileStore struct { + Type model.FileStoreType `json:"type"` + Config json.RawMessage `json:"config"` +} + +func (f *ControlPlaneFileStore) UnmarshalJSON(data []byte) error { + var err error + gf := genericControlPlaneFileStore{} + if err = json.Unmarshal(data, &gf); err != nil { + return err + } + f.Type = gf.Type + + switch f.Type { + case model.FileStoreGCS: + f.GCSConfig = &FileStoreGCSConfig{} + if len(gf.Config) > 0 { + err = json.Unmarshal(gf.Config, f.GCSConfig) + } + case model.FileStoreS3: + f.S3Config = &FileStoreS3Config{} + if len(gf.Config) > 0 { + err = json.Unmarshal(gf.Config, f.S3Config) + } + case model.FileStoreMINIO: + f.MinioConfig = &FileStoreMinioConfig{} + if len(gf.Config) > 0 { + err = json.Unmarshal(gf.Config, f.MinioConfig) + } + default: + // Left comment out for mock response. + // err = fmt.Errorf("unsupported filestore type: %s", f.Type) + err = nil + } + return err +} + +type FileStoreGCSConfig struct { + // The bucket name to store artifacts and logs in the piped. + Bucket string `json:"bucket"` + // The path to the credentials file for accessing GCS. + CredentialsFile string `json:"credentialsFile"` +} + +type FileStoreS3Config struct { + // The bucket name to store artifacts and logs in the piped. + Bucket string `json:"bucket"` + // The aws region of S3 bucket. + Region string `json:"region"` + // The aws profile name. + Profile string `json:"profile"` + // The path to the credentials file for accessing AWS. + CredentialsFile string `json:"credentialsFile"` + // The IAM role arn to use when assuming an role. + RoleARN string `json:"roleARN"` + // Path to the WebIdentity token the SDK should use to assume a role with. + TokenFile string `json:"tokenFile"` +} + +type FileStoreMinioConfig struct { + // The address of Minio. + Endpoint string `json:"endpoint"` + // The bucket name to store. + Bucket string `json:"bucket"` + // The path to the access key file. + AccessKeyFile string `json:"accessKeyFile"` + // The path to the secret key file. + SecretKeyFile string `json:"secretKeyFile"` + // Whether the given bucket should be made automatically if not exists. + AutoCreateBucket bool `json:"autoCreateBucket"` +} diff --git a/pkg/configv1/control_plane_test.go b/pkg/configv1/control_plane_test.go new file mode 100644 index 0000000000..333c2ec88e --- /dev/null +++ b/pkg/configv1/control_plane_test.go @@ -0,0 +1,116 @@ +// Copyright 2024 The PipeCD 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 ( + "testing" + "time" + + "github.com/golang/protobuf/proto" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/pipe-cd/pipecd/pkg/model" +) + +func TestControlPlaneConfig(t *testing.T) { + testcases := []struct { + fileName string + expectedKind Kind + expectedAPIVersion string + expectedSpec *ControlPlaneSpec + expectedError error + }{ + { + fileName: "testdata/control-plane/control-plane-config.yaml", + expectedKind: KindControlPlane, + expectedAPIVersion: "pipecd.dev/v1beta1", + expectedSpec: &ControlPlaneSpec{ + Projects: []ControlPlaneProject{ + { + ID: "abc", + StaticAdmin: ProjectStaticUser{ + Username: "test-user", + PasswordHash: "test-password", + }, + }, + }, + SharedSSOConfigs: []SharedSSOConfig{ + { + Name: "github", + ProjectSSOConfig: model.ProjectSSOConfig{ + Provider: model.ProjectSSOConfig_GITHUB, + Github: &model.ProjectSSOConfig_GitHub{ + ClientId: "client-id", + ClientSecret: "client-secret", + BaseUrl: "base-url", + UploadUrl: "upload-url", + }, + }, + }, + }, + Datastore: ControlPlaneDataStore{ + Type: model.DataStoreFirestore, + FirestoreConfig: &DataStoreFireStoreConfig{ + Namespace: "pipecd-test", + Environment: "unit-test", + Project: "project", + CredentialsFile: "datastore-credentials-file.json", + }, + }, + Filestore: ControlPlaneFileStore{ + Type: model.FileStoreGCS, + GCSConfig: &FileStoreGCSConfig{ + Bucket: "bucket", + CredentialsFile: "filestore-credentials-file.json", + }, + }, + Cache: ControlPlaneCache{ + TTL: Duration(5 * time.Minute), + }, + InsightCollector: ControlPlaneInsightCollector{ + Application: InsightCollectorApplication{ + Enabled: newBoolPointer(true), + Schedule: "0 * * * *", + }, + Deployment: InsightCollectorDeployment{ + Enabled: newBoolPointer(true), + Schedule: "0 10 * * *", + ChunkMaxCount: 1000, + }, + }, + }, + }, + } + for _, tc := range testcases { + t.Run(tc.fileName, func(t *testing.T) { + cfg, err := LoadFromYAML(tc.fileName) + require.Equal(t, tc.expectedError, err) + if err == nil { + assert.Equal(t, tc.expectedKind, cfg.Kind) + assert.Equal(t, tc.expectedAPIVersion, cfg.APIVersion) + require.Equal(t, 1, len(tc.expectedSpec.SharedSSOConfigs)) + require.Equal(t, 1, len(cfg.ControlPlaneSpec.SharedSSOConfigs)) + // Why don't we use assert.Equal to compare? + // https://github.com/stretchr/testify/issues/758 + assert.True(t, proto.Equal(&tc.expectedSpec.SharedSSOConfigs[0].ProjectSSOConfig, &cfg.ControlPlaneSpec.SharedSSOConfigs[0].ProjectSSOConfig)) + + tc.expectedSpec.SharedSSOConfigs = nil + cfg.ControlPlaneSpec.SharedSSOConfigs = nil + assert.Equal(t, tc.expectedSpec, cfg.ControlPlaneSpec) + } + }) + } +} diff --git a/pkg/configv1/duration.go b/pkg/configv1/duration.go new file mode 100644 index 0000000000..391a2c2403 --- /dev/null +++ b/pkg/configv1/duration.go @@ -0,0 +1,52 @@ +// Copyright 2024 The PipeCD 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 ( + "encoding/json" + "fmt" + "time" +) + +type Duration time.Duration + +func (d Duration) Duration() time.Duration { + return time.Duration(d) +} + +func (d Duration) MarshalJSON() ([]byte, error) { + return json.Marshal(time.Duration(d).String()) +} + +func (d *Duration) UnmarshalJSON(b []byte) error { + var v interface{} + if err := json.Unmarshal(b, &v); err != nil { + return err + } + switch raw := v.(type) { + case float64: + *d = Duration(time.Duration(raw)) + return nil + case string: + value, err := time.ParseDuration(raw) + if err != nil { + return err + } + *d = Duration(value) + return nil + default: + return fmt.Errorf("invalid duration: %v", string(b)) + } +} diff --git a/pkg/configv1/event_watcher.go b/pkg/configv1/event_watcher.go new file mode 100644 index 0000000000..282df6d3ed --- /dev/null +++ b/pkg/configv1/event_watcher.go @@ -0,0 +1,230 @@ +// Copyright 2024 The PipeCD 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/filepath" + "strings" + + "github.com/pipe-cd/pipecd/pkg/filematcher" +) + +type EventWatcherSpec struct { + Events []EventWatcherEvent `json:"events"` +} + +// EventWatcherEvent defines which file will be replaced when the given event happened. +type EventWatcherEvent struct { + // The event name. + Name string `json:"name"` + // Additional attributes of event. This can make an event definition + // unique even if the one with the same name exists. + Labels map[string]string `json:"labels"` + // List of places where will be replaced when the new event matches. + Replacements []EventWatcherReplacement `json:"replacements"` +} + +type EventWatcherConfig struct { + // Matcher represents which event will be handled. + Matcher EventWatcherMatcher `json:"matcher"` + // Handler represents how the matched event will be handled. + Handler EventWatcherHandler `json:"handler"` +} + +type EventWatcherMatcher struct { + // The handled event name. + Name string `json:"name"` + // Additional attributes of event. This can make an event definition + // unique even if the one with the same name exists. + Labels map[string]string `json:"labels"` +} + +type EventWatcherHandler struct { + // The handler type of event watcher. + Type EventWatcherHandlerType `json:"type,omitempty"` + // The config for event watcher handler. + Config EventWatcherHandlerConfig `json:"config"` +} + +type EventWatcherHandlerConfig struct { + // The commit message used to push after replacing values. + // Default message is used if not given. + CommitMessage string `json:"commitMessage,omitempty"` + // Whether to create a new branch or not when event watcher commits changes. + MakePullRequest bool `json:"makePullRequest,omitempty"` + // List of places where will be replaced when the new event matches. + Replacements []EventWatcherReplacement `json:"replacements"` +} + +type EventWatcherReplacement struct { + // The path to the file to be updated. + File string `json:"file"` + // The field to be updated. Only one of these can be used. + // + // The YAML path to the field to be updated. It requires to start + // with `$` which represents the root element. e.g. `$.foo.bar[0].baz`. + YAMLField string `json:"yamlField"` + // The JSON path to the field to be updated. + JSONField string `json:"jsonField"` + // The HCL path to the field to be updated. + HCLField string `json:"HCLField"` + // The regex string specifying what should be replaced. + // Only the first capturing group enclosed by `()` will be replaced with the new value. + // e.g. "host.xz/foo/bar:(v[0-9].[0-9].[0-9])" + Regex string `json:"regex"` +} + +// EventWatcherHandlerType represents the type of an event watcher handler. +type EventWatcherHandlerType string + +const ( + // EventWatcherHandlerTypeGitUpdate represents the handler type for git updating. + EventWatcherHandlerTypeGitUpdate = "GIT_UPDATE" +) + +// LoadEventWatcher gives back parsed EventWatcher config after merging config files placed under +// the .pipe directory. With "includes" and "excludes", you can filter the files included the result. +// "excludes" are prioritized if both "excludes" and "includes" are given. ErrNotFound is returned if not found. +func LoadEventWatcher(repoRoot string, includePatterns, excludePatterns []string) (*EventWatcherSpec, error) { + dir := filepath.Join(repoRoot, SharedConfigurationDirName) + + // Collect file paths recursively. + files := make([]string, 0) + err := filepath.Walk(dir, + func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if !info.IsDir() { + files = append(files, strings.TrimPrefix(path, dir+"/")) + } + return nil + }, + ) + if os.IsNotExist(err) { + return nil, ErrNotFound + } + if err != nil { + return nil, fmt.Errorf("failed to read %s: %w", dir, err) + } + + // Start merging events defined across multiple files. + spec := &EventWatcherSpec{ + Events: make([]EventWatcherEvent, 0), + } + filtered, err := filterEventWatcherFiles(files, includePatterns, excludePatterns) + if err != nil { + return nil, fmt.Errorf("failed to filter event watcher files at %s: %w", dir, err) + } + for _, f := range filtered { + path := filepath.Join(dir, f) + cfg, err := LoadFromYAML(path) + if err != nil { + return nil, fmt.Errorf("failed to load config file %s: %w", path, err) + } + if cfg.Kind == KindEventWatcher { + spec.Events = append(spec.Events, cfg.EventWatcherSpec.Events...) + } + } + + if err := spec.Validate(); err != nil { + return nil, err + } + + return spec, nil +} + +// filterEventWatcherFiles filters the given files based on the given Includes and Excludes. +// Excludes are prioritized if both Excludes and Includes are given. +func filterEventWatcherFiles(files, includePatterns, excludePatterns []string) ([]string, error) { + if len(includePatterns) == 0 && len(excludePatterns) == 0 { + return files, nil + } + + filtered := make([]string, 0, len(files)) + + // Use include patterns + if len(includePatterns) != 0 && len(excludePatterns) == 0 { + matcher, err := filematcher.NewPatternMatcher(includePatterns) + if err != nil { + return nil, fmt.Errorf("failed to create a matcher object: %w", err) + } + for _, f := range files { + if matcher.Matches(f) { + filtered = append(filtered, f) + } + } + return filtered, nil + } + + // Use exclude patterns + matcher, err := filematcher.NewPatternMatcher(excludePatterns) + if err != nil { + return nil, fmt.Errorf("failed to create a matcher object: %w", err) + } + for _, f := range files { + if matcher.Matches(f) { + continue + } + filtered = append(filtered, f) + } + return filtered, nil +} + +func (s *EventWatcherSpec) Validate() error { + for _, e := range s.Events { + if err := e.Validate(); err != nil { + return err + } + } + return nil +} + +func (e *EventWatcherEvent) Validate() error { + if e.Name == "" { + return fmt.Errorf("event name must not be empty") + } + if len(e.Replacements) == 0 { + return fmt.Errorf("there must be at least one replacement to an event") + } + for _, r := range e.Replacements { + if r.File == "" { + return fmt.Errorf("event %q has a replacement with no file name", e.Name) + } + + var count int + if r.YAMLField != "" { + count++ + } + if r.JSONField != "" { + count++ + } + if r.HCLField != "" { + count++ + } + if r.Regex != "" { + count++ + } + if count == 0 { + return fmt.Errorf("event %q has a replacement with no field", e.Name) + } + if count > 2 { + return fmt.Errorf("event %q has multiple fields", e.Name) + } + } + return nil +} diff --git a/pkg/configv1/event_watcher_test.go b/pkg/configv1/event_watcher_test.go new file mode 100644 index 0000000000..20629cfc00 --- /dev/null +++ b/pkg/configv1/event_watcher_test.go @@ -0,0 +1,254 @@ +// Copyright 2024 The PipeCD 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 ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestLoadEventWatcher(t *testing.T) { + want := &EventWatcherSpec{Events: []EventWatcherEvent{ + { + Name: "app1-image-update", + Replacements: []EventWatcherReplacement{ + { + File: "app1/deployment.yaml", + YAMLField: "$.spec.template.spec.containers[0].image", + }, + }, + }, + { + Name: "app2-helm-release", + Labels: map[string]string{ + "repoId": "repo-1", + }, + Replacements: []EventWatcherReplacement{ + { + File: "app2/.pipe.yaml", + YAMLField: "$.spec.input.helmChart.version", + }, + }, + }, + }} + + t.Run("valid config files given", func(t *testing.T) { + got, err := LoadEventWatcher("testdata", []string{"event-watcher.yaml"}, nil) + assert.NoError(t, err) + assert.Equal(t, want, got) + }) +} + +func TestEventWatcherValidate(t *testing.T) { + testcases := []struct { + name string + eventWatcherSpec EventWatcherSpec + wantErr bool + }{ + { + name: "no name given", + eventWatcherSpec: EventWatcherSpec{ + Events: []EventWatcherEvent{ + { + Replacements: []EventWatcherReplacement{ + { + File: "file", + YAMLField: "$.foo", + }, + }, + }, + }, + }, + wantErr: true, + }, + { + name: "no replacements given", + eventWatcherSpec: EventWatcherSpec{ + Events: []EventWatcherEvent{ + { + Name: "event-a", + }, + }, + }, + wantErr: true, + }, + { + name: "no replacement file given", + eventWatcherSpec: EventWatcherSpec{ + Events: []EventWatcherEvent{ + { + Replacements: []EventWatcherReplacement{ + { + YAMLField: "$.foo", + }, + }, + }, + }, + }, + wantErr: true, + }, + { + name: "no replacement field given", + eventWatcherSpec: EventWatcherSpec{ + Events: []EventWatcherEvent{ + { + Replacements: []EventWatcherReplacement{ + { + File: "file", + }, + }, + }, + }, + }, + wantErr: true, + }, + { + name: "both yaml and json given", + eventWatcherSpec: EventWatcherSpec{ + Events: []EventWatcherEvent{ + { + Replacements: []EventWatcherReplacement{ + { + File: "file", + YAMLField: "$.foo", + JSONField: "$.foo", + }, + }, + }, + }, + }, + wantErr: true, + }, + { + name: "both yaml and hcl given", + eventWatcherSpec: EventWatcherSpec{ + Events: []EventWatcherEvent{ + { + Replacements: []EventWatcherReplacement{ + { + File: "file", + YAMLField: "$.foo", + HCLField: "$.foo", + }, + }, + }, + }, + }, + wantErr: true, + }, + { + name: "both json and hcl given", + eventWatcherSpec: EventWatcherSpec{ + Events: []EventWatcherEvent{ + { + Replacements: []EventWatcherReplacement{ + { + File: "file", + JSONField: "$.foo", + HCLField: "$.foo", + }, + }, + }, + }, + }, + wantErr: true, + }, + { + name: "valid config given", + eventWatcherSpec: EventWatcherSpec{ + Events: []EventWatcherEvent{ + { + Name: "event-a", + Replacements: []EventWatcherReplacement{ + { + File: "file", + YAMLField: "$.foo", + }, + }, + }, + }, + }, + wantErr: false, + }, + } + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + err := tc.eventWatcherSpec.Validate() + assert.Equal(t, tc.wantErr, err != nil) + }) + } +} + +func TestFilterEventWatcherFiles(t *testing.T) { + testcases := []struct { + name string + files []string + includes []string + excludes []string + want []string + wantErr bool + }{ + { + name: "both includes and excludes aren't given", + files: []string{"file-1"}, + want: []string{"file-1"}, + wantErr: false, + }, + { + name: "both includes and excludes are given", + files: []string{"file-1"}, + want: []string{}, + includes: []string{"file-1"}, + excludes: []string{"file-1"}, + wantErr: false, + }, + { + name: "includes given", + files: []string{"file-1", "file-2", "file-3"}, + includes: []string{"file-1", "file-3"}, + want: []string{"file-1", "file-3"}, + wantErr: false, + }, + { + name: "excludes given", + files: []string{"file-1", "file-2", "file-3"}, + excludes: []string{"file-1", "file-3"}, + want: []string{"file-2"}, + wantErr: false, + }, + { + name: "includes with pattern given", + files: []string{"dir/file-1.yaml", "dir/file-2.yaml", "dir/file-3.yaml"}, + includes: []string{"dir/*.yaml"}, + want: []string{"dir/file-1.yaml", "dir/file-2.yaml", "dir/file-3.yaml"}, + wantErr: false, + }, + { + name: "excludes with pattern given", + files: []string{"dir/file-1.yaml", "dir/file-2.yaml", "dir/file-3.yaml", "dir-2/file-1.yaml"}, + excludes: []string{"dir/*.yaml"}, + want: []string{"dir-2/file-1.yaml"}, + wantErr: false, + }, + } + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + got, err := filterEventWatcherFiles(tc.files, tc.includes, tc.excludes) + assert.Equal(t, tc.wantErr, err != nil) + assert.Equal(t, tc.want, got) + }) + } +} diff --git a/pkg/configv1/feature_flag.go b/pkg/configv1/feature_flag.go new file mode 100644 index 0000000000..6a08d73370 --- /dev/null +++ b/pkg/configv1/feature_flag.go @@ -0,0 +1,37 @@ +// Copyright 2024 The PipeCD 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 "os" + +type FeatureFlag string + +const ( + FeatureFlagInsights FeatureFlag = "PIPECD_FEATURE_FLAG_INSIGHTS" +) + +func FeatureFlagEnabled(flag FeatureFlag) bool { + v := os.Getenv(string(flag)) + switch v { + case "true": + return true + case "enabled": + return true + case "on": + return true + default: + return false + } +} diff --git a/pkg/configv1/launcher.go b/pkg/configv1/launcher.go new file mode 100644 index 0000000000..57cc5e1f26 --- /dev/null +++ b/pkg/configv1/launcher.go @@ -0,0 +1,73 @@ +// Copyright 2024 The PipeCD 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 ( + "encoding/base64" + "errors" + "fmt" + "os" +) + +type LauncherConfig struct { + Kind Kind `json:"kind"` + APIVersion string `json:"apiVersion,omitempty"` + Spec LauncherSpec `json:"spec"` +} + +func (c *LauncherConfig) Validate() error { + if c.Kind != KindPiped { + return fmt.Errorf("wrong configuration kind for piped: %v", c.Kind) + } + if c.Spec.ProjectID == "" { + return errors.New("projectID must be set") + } + if c.Spec.PipedID == "" { + return errors.New("pipedID must be set") + } + if c.Spec.PipedKeyData == "" && c.Spec.PipedKeyFile == "" { + return errors.New("either pipedKeyFile or pipedKeyData must be set") + } + if c.Spec.PipedKeyData != "" && c.Spec.PipedKeyFile != "" { + return errors.New("only pipedKeyFile or pipedKeyData can be set") + } + if c.Spec.APIAddress == "" { + return errors.New("apiAddress must be set") + } + return nil +} + +type LauncherSpec struct { + // The identifier of the PipeCD project where this piped belongs to. + ProjectID string + // The unique identifier generated for this piped. + PipedID string + // The path to the file containing the generated Key string for this piped. + PipedKeyFile string + // Base64 encoded string of Piped key. + PipedKeyData string + // The address used to connect to the control-plane's API. + APIAddress string `json:"apiAddress"` +} + +func (s *LauncherSpec) LoadPipedKey() ([]byte, error) { + if s.PipedKeyData != "" { + return base64.StdEncoding.DecodeString(s.PipedKeyData) + } + if s.PipedKeyFile != "" { + return os.ReadFile(s.PipedKeyFile) + } + return nil, errors.New("either pipedKeyFile or pipedKeyData must be set") +} diff --git a/pkg/configv1/percentage.go b/pkg/configv1/percentage.go new file mode 100644 index 0000000000..30fec9c18e --- /dev/null +++ b/pkg/configv1/percentage.go @@ -0,0 +1,65 @@ +// Copyright 2024 The PipeCD 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 ( + "encoding/json" + "fmt" + "strconv" + "strings" +) + +type Percentage struct { + Number int `json:",omitempty"` + HasSuffix bool `json:",omitempty"` +} + +func (p Percentage) String() string { + s := strconv.FormatInt(int64(p.Number), 10) + if p.HasSuffix { + return s + "%" + } + return s +} + +func (p Percentage) Int() int { + return p.Number +} + +func (p Percentage) MarshalJSON() ([]byte, error) { + return json.Marshal(p.String()) +} + +func (p Percentage) MarshalYAML() (interface{}, error) { + return p.Number, nil +} + +func (p *Percentage) UnmarshalJSON(b []byte) error { + raw := strings.Trim(string(b), `"`) + percentage := Percentage{ + HasSuffix: false, + } + if strings.HasSuffix(raw, "%") { + percentage.HasSuffix = true + raw = strings.TrimSuffix(raw, "%") + } + value, err := strconv.ParseInt(raw, 10, 64) + if err != nil { + return fmt.Errorf("invalid percentage: %w", err) + } + percentage.Number = int(value) + *p = percentage + return nil +} diff --git a/pkg/configv1/percentage_test.go b/pkg/configv1/percentage_test.go new file mode 100644 index 0000000000..911ce3078b --- /dev/null +++ b/pkg/configv1/percentage_test.go @@ -0,0 +1,121 @@ +// Copyright 2024 The PipeCD 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 ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPercentageMarshal(t *testing.T) { + type wrapper struct { + Percentage Percentage + } + + testcases := []struct { + name string + input wrapper + expected string + }{ + { + name: "normal number", + input: wrapper{ + Percentage{ + Number: 10, + HasSuffix: false, + }, + }, + expected: `{"Percentage":"10"}`, + }, + { + name: "percentage number", + input: wrapper{ + Percentage{ + Number: 15, + HasSuffix: true, + }, + }, + expected: `{"Percentage":"15%"}`, + }, + } + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + got, err := json.Marshal(tc.input) + require.NoError(t, err) + assert.Equal(t, tc.expected, string(got)) + }) + } +} + +func TestPercentageUnmarshal(t *testing.T) { + type wrapper struct { + Percentage Percentage + } + + testcases := []struct { + name string + input string + expected *wrapper + expectedErr bool + }{ + { + name: "normal number", + input: `{"Percentage": 10}`, + expected: &wrapper{ + Percentage{ + Number: 10, + }, + }, + }, + { + name: "normal number by string", + input: `{"Percentage": "10"}`, + expected: &wrapper{ + Percentage{ + Number: 10, + }, + }, + }, + { + name: "percentage number", + input: `{"Percentage": "10%"}`, + expected: &wrapper{ + Percentage{ + Number: 10, + HasSuffix: true, + }, + }, + }, + { + name: "wrong string format", + input: `{"Percentage": "1a%"}`, + expected: nil, + expectedErr: true, + }, + } + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + got := &wrapper{} + err := json.Unmarshal([]byte(tc.input), got) + assert.Equal(t, tc.expectedErr, err != nil) + if tc.expected != nil { + assert.Equal(t, tc.expected, got) + } + }) + } +} diff --git a/pkg/configv1/piped.go b/pkg/configv1/piped.go new file mode 100644 index 0000000000..7a48d732ad --- /dev/null +++ b/pkg/configv1/piped.go @@ -0,0 +1,1290 @@ +// Copyright 2024 The PipeCD 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 ( + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "os" + "strings" + + "github.com/pipe-cd/pipecd/pkg/model" +) + +const ( + maskString = "******" +) + +var defaultKubernetesPlatformProvider = PipedPlatformProvider{ + Name: "kubernetes-default", + Type: model.PlatformProviderKubernetes, + KubernetesConfig: &PlatformProviderKubernetesConfig{}, +} + +// PipedSpec contains configurable data used to while running Piped. +type PipedSpec struct { + // The identifier of the PipeCD project where this piped belongs to. + ProjectID string `json:"projectID"` + // The unique identifier generated for this piped. + PipedID string `json:"pipedID"` + // The path to the file containing the generated Key string for this piped. + PipedKeyFile string `json:"pipedKeyFile,omitempty"` + // Base64 encoded string of Piped key. + PipedKeyData string `json:"pipedKeyData,omitempty"` + // The name of this piped. + Name string `json:"name,omitempty"` + // The address used to connect to the control-plane's API. + APIAddress string `json:"apiAddress"` + // The address to the control-plane's Web. + WebAddress string `json:"webAddress,omitempty"` + // How often to check whether an application should be synced. + // Default is 1m. + SyncInterval Duration `json:"syncInterval,omitempty" default:"1m"` + // How often to check whether an application configuration file should be synced. + // Default is 1m. + AppConfigSyncInterval Duration `json:"appConfigSyncInterval,omitempty" default:"1m"` + // Git configuration needed for git commands. + Git PipedGit `json:"git,omitempty"` + // List of git repositories this piped will handle. + Repositories []PipedRepository `json:"repositories,omitempty"` + // List of helm chart repositories that should be added while starting up. + ChartRepositories []HelmChartRepository `json:"chartRepositories,omitempty"` + // List of helm chart registries that should be logged in while starting up. + ChartRegistries []HelmChartRegistry `json:"chartRegistries,omitempty"` + // List of cloud providers can be used by this piped. + // Deprecated: use PlatformProvider instead. + CloudProviders []PipedPlatformProvider `json:"cloudProviders,omitempty"` + // List of platform providers can be used by this piped. + PlatformProviders []PipedPlatformProvider `json:"platformProviders,omitempty"` + // List of analysis providers can be used by this piped. + AnalysisProviders []PipedAnalysisProvider `json:"analysisProviders,omitempty"` + // Sending notification to Slack, Webhook… + Notifications Notifications `json:"notifications"` + // What secret management method should be used. + SecretManagement *SecretManagement `json:"secretManagement,omitempty"` + // Optional settings for event watcher. + EventWatcher PipedEventWatcher `json:"eventWatcher"` + // List of labels to filter all applications this piped will handle. + AppSelector map[string]string `json:"appSelector,omitempty"` +} + +func (s *PipedSpec) UnmarshalJSON(data []byte) error { + type Alias PipedSpec + ps := &struct { + *Alias + }{ + Alias: (*Alias)(s), + } + if err := json.Unmarshal(data, &ps); err != nil { + return err + } + + // Add all CloudProviders configuration as PlatformProviders configuration. + s.PlatformProviders = append(s.PlatformProviders, ps.CloudProviders...) + s.CloudProviders = nil + return nil +} + +// Validate validates configured data of all fields. +func (s *PipedSpec) Validate() error { + if s.ProjectID == "" { + return errors.New("projectID must be set") + } + if s.PipedID == "" { + return errors.New("pipedID must be set") + } + if s.PipedKeyData == "" && s.PipedKeyFile == "" { + return errors.New("either pipedKeyFile or pipedKeyData must be set") + } + if s.PipedKeyData != "" && s.PipedKeyFile != "" { + return errors.New("only pipedKeyFile or pipedKeyData can be set") + } + if s.APIAddress == "" { + return errors.New("apiAddress must be set") + } + if s.SyncInterval < 0 { + return errors.New("syncInterval must be greater than or equal to 0") + } + if err := s.Git.Validate(); err != nil { + return err + } + for _, r := range s.ChartRepositories { + if err := r.Validate(); err != nil { + return err + } + } + for _, r := range s.ChartRegistries { + if err := r.Validate(); err != nil { + return err + } + } + if s.SecretManagement != nil { + if err := s.SecretManagement.Validate(); err != nil { + return err + } + } + if err := s.EventWatcher.Validate(); err != nil { + return err + } + for _, n := range s.Notifications.Receivers { + if n.Slack != nil { + if err := n.Slack.Validate(); err != nil { + return err + } + } + } + for _, p := range s.AnalysisProviders { + if err := p.Validate(); err != nil { + return err + } + } + return nil +} + +// Clone generates a cloned PipedSpec object. +func (s *PipedSpec) Clone() (*PipedSpec, error) { + js, err := json.Marshal(s) + if err != nil { + return nil, err + } + + var clone PipedSpec + if err = json.Unmarshal(js, &clone); err != nil { + return nil, err + } + + return &clone, nil +} + +// Mask masks confidential fields. +func (s *PipedSpec) Mask() { + if len(s.PipedKeyFile) != 0 { + s.PipedKeyFile = maskString + } + if len(s.PipedKeyData) != 0 { + s.PipedKeyData = maskString + } + s.Git.Mask() + for i := 0; i < len(s.ChartRepositories); i++ { + s.ChartRepositories[i].Mask() + } + for i := 0; i < len(s.ChartRegistries); i++ { + s.ChartRegistries[i].Mask() + } + for _, p := range s.PlatformProviders { + p.Mask() + } + for _, p := range s.AnalysisProviders { + p.Mask() + } + s.Notifications.Mask() + if s.SecretManagement != nil { + s.SecretManagement.Mask() + } +} + +// EnableDefaultKubernetesPlatformProvider adds the default kubernetes cloud provider if it was not specified. +func (s *PipedSpec) EnableDefaultKubernetesPlatformProvider() { + for _, cp := range s.PlatformProviders { + if cp.Name == defaultKubernetesPlatformProvider.Name { + return + } + } + s.PlatformProviders = append(s.PlatformProviders, defaultKubernetesPlatformProvider) +} + +// HasPlatformProvider checks whether the given provider is configured or not. +func (s *PipedSpec) HasPlatformProvider(name string, t model.ApplicationKind) bool { + _, contains := s.FindPlatformProvider(name, t) + return contains +} + +// FindPlatformProvider finds and returns a Platform Provider by name and type. +func (s *PipedSpec) FindPlatformProvider(name string, t model.ApplicationKind) (PipedPlatformProvider, bool) { + requiredProviderType := t.CompatiblePlatformProviderType() + for _, p := range s.PlatformProviders { + if p.Name != name { + continue + } + if p.Type != requiredProviderType { + continue + } + return p, true + } + return PipedPlatformProvider{}, false +} + +// FindPlatformProvidersByLabels finds all PlatformProviders which match the provided labels. +func (s *PipedSpec) FindPlatformProvidersByLabels(labels map[string]string, t model.ApplicationKind) []PipedPlatformProvider { + requiredProviderType := t.CompatiblePlatformProviderType() + out := make([]PipedPlatformProvider, 0) + + labelMatch := func(providerLabels map[string]string) bool { + if len(providerLabels) < len(labels) { + return false + } + + for k, v := range labels { + if v != providerLabels[k] { + return false + } + } + return true + } + + for _, p := range s.PlatformProviders { + if p.Type != requiredProviderType { + continue + } + if !labelMatch(p.Labels) { + continue + } + out = append(out, p) + } + return out +} + +// GetRepositoryMap returns a map of repositories where key is repo id. +func (s *PipedSpec) GetRepositoryMap() map[string]PipedRepository { + m := make(map[string]PipedRepository, len(s.Repositories)) + for _, repo := range s.Repositories { + m[repo.RepoID] = repo + } + return m +} + +// GetRepository finds a repository with the given ID from the configured list. +func (s *PipedSpec) GetRepository(id string) (PipedRepository, bool) { + for _, repo := range s.Repositories { + if repo.RepoID == id { + return repo, true + } + } + return PipedRepository{}, false +} + +// GetAnalysisProvider finds and returns an Analysis Provider config whose name is the given string. +func (s *PipedSpec) GetAnalysisProvider(name string) (PipedAnalysisProvider, bool) { + for _, p := range s.AnalysisProviders { + if p.Name == name { + return p, true + } + } + return PipedAnalysisProvider{}, false +} + +func (s *PipedSpec) IsInsecureChartRepository(name string) bool { + for _, cr := range s.ChartRepositories { + if cr.Name == name { + return cr.Insecure + } + } + return false +} + +func (s *PipedSpec) LoadPipedKey() ([]byte, error) { + if s.PipedKeyData != "" { + return base64.StdEncoding.DecodeString(s.PipedKeyData) + } + if s.PipedKeyFile != "" { + return os.ReadFile(s.PipedKeyFile) + } + return nil, errors.New("either pipedKeyFile or pipedKeyData must be set") +} + +type PipedGit struct { + // The username that will be configured for `git` user. + // Default is "piped". + Username string `json:"username,omitempty"` + // The email that will be configured for `git` user. + // Default is "pipecd.dev@gmail.com". + Email string `json:"email,omitempty"` + // Where to write ssh config file. + // Default is "$HOME/.ssh/config". + SSHConfigFilePath string `json:"sshConfigFilePath,omitempty"` + // The host name. + // e.g. github.com, gitlab.com + // Default is "github.com". + Host string `json:"host,omitempty"` + // The hostname or IP address of the remote git server. + // e.g. github.com, gitlab.com + // Default is the same value with Host. + HostName string `json:"hostName,omitempty"` + // The path to the private ssh key file. + // This will be used to clone the source code of the specified git repositories. + SSHKeyFile string `json:"sshKeyFile,omitempty"` + // Base64 encoded string of ssh-key. + SSHKeyData string `json:"sshKeyData,omitempty"` + // Base64 encoded string of password. + // This will be used to clone the source repo with https basic auth. + Password string `json:"password,omitempty"` +} + +func (g PipedGit) ShouldConfigureSSHConfig() bool { + return g.SSHKeyData != "" || g.SSHKeyFile != "" +} + +func (g PipedGit) LoadSSHKey() ([]byte, error) { + if g.SSHKeyData != "" && g.SSHKeyFile != "" { + return nil, errors.New("only either sshKeyFile or sshKeyData can be set") + } + if g.SSHKeyData != "" { + return base64.StdEncoding.DecodeString(g.SSHKeyData) + } + if g.SSHKeyFile != "" { + return os.ReadFile(g.SSHKeyFile) + } + return nil, errors.New("either sshKeyFile or sshKeyData must be set") +} + +func (g *PipedGit) Validate() error { + isPassword := g.Password != "" + isSSH := g.ShouldConfigureSSHConfig() + if isSSH && isPassword { + return errors.New("cannot configure both sshKeyData or sshKeyFile and password authentication") + } + if isSSH && (g.SSHKeyData != "" && g.SSHKeyFile != "") { + return errors.New("only either sshKeyFile or sshKeyData can be set") + } + if isPassword && (g.Username == "" || g.Password == "") { + return errors.New("both username and password must be set") + } + return nil +} + +func (g *PipedGit) Mask() { + if len(g.SSHConfigFilePath) != 0 { + g.SSHConfigFilePath = maskString + } + if len(g.SSHKeyFile) != 0 { + g.SSHKeyFile = maskString + } + if len(g.SSHKeyData) != 0 { + g.SSHKeyData = maskString + } + if len(g.Password) != 0 { + g.Password = maskString + } +} + +func (g *PipedGit) DecodedPassword() (string, error) { + if len(g.Password) == 0 { + return "", nil + } + decoded, err := base64.StdEncoding.DecodeString(g.Password) + if err != nil { + return "", err + } + return string(decoded), nil +} + +type PipedRepository struct { + // Unique identifier for this repository. + // This must be unique in the piped scope. + RepoID string `json:"repoId"` + // Remote address of the repository used to clone the source code. + // e.g. git@github.com:org/repo.git + Remote string `json:"remote"` + // The branch will be handled. + Branch string `json:"branch"` +} + +type HelmChartRepositoryType string + +const ( + HTTPHelmChartRepository HelmChartRepositoryType = "HTTP" + GITHelmChartRepository HelmChartRepositoryType = "GIT" +) + +type HelmChartRepository struct { + // The repository type. Currently, HTTP and GIT are supported. + // Default is HTTP. + Type HelmChartRepositoryType `json:"type" default:"HTTP"` + + // Configuration for HTTP type. + // The name of the Helm chart repository. + Name string `json:"name,omitempty"` + // The address to the Helm chart repository. + Address string `json:"address,omitempty"` + // Username used for the repository backed by HTTP basic authentication. + Username string `json:"username,omitempty"` + // Password used for the repository backed by HTTP basic authentication. + Password string `json:"password,omitempty"` + // Whether to skip TLS certificate checks for the repository or not. + Insecure bool `json:"insecure"` + + // Configuration for GIT type. + // Remote address of the Git repository used to clone Helm charts. + // e.g. git@github.com:org/repo.git + GitRemote string `json:"gitRemote,omitempty"` + // The path to the private ssh key file used while cloning Helm charts from above Git repository. + SSHKeyFile string `json:"sshKeyFile,omitempty"` +} + +func (r *HelmChartRepository) IsHTTPRepository() bool { + return r.Type == HTTPHelmChartRepository +} + +func (r *HelmChartRepository) IsGitRepository() bool { + return r.Type == GITHelmChartRepository +} + +func (r *HelmChartRepository) Validate() error { + if r.IsHTTPRepository() { + if r.Name == "" { + return errors.New("name must be set") + } + if r.Address == "" { + return errors.New("address must be set") + } + return nil + } + + if r.IsGitRepository() { + if r.GitRemote == "" { + return errors.New("gitRemote must be set") + } + return nil + } + + return fmt.Errorf("either %s repository or %s repository must be configured", HTTPHelmChartRepository, GITHelmChartRepository) +} + +func (r *HelmChartRepository) Mask() { + if len(r.Password) != 0 { + r.Password = maskString + } + if len(r.SSHKeyFile) != 0 { + r.SSHKeyFile = maskString + } +} + +func (s *PipedSpec) HTTPHelmChartRepositories() []HelmChartRepository { + repos := make([]HelmChartRepository, 0, len(s.ChartRepositories)) + for _, r := range s.ChartRepositories { + if r.IsHTTPRepository() { + repos = append(repos, r) + } + } + return repos +} + +func (s *PipedSpec) GitHelmChartRepositories() []HelmChartRepository { + repos := make([]HelmChartRepository, 0, len(s.ChartRepositories)) + for _, r := range s.ChartRepositories { + if r.IsGitRepository() { + repos = append(repos, r) + } + } + return repos +} + +type HelmChartRegistryType string + +// The registry types that hosts Helm charts. +const ( + OCIHelmChartRegistry HelmChartRegistryType = "OCI" +) + +type HelmChartRegistry struct { + // The registry type. Currently, only OCI is supported. + Type HelmChartRegistryType `json:"type" default:"OCI"` + + // The address to the Helm chart registry. + Address string `json:"address"` + // Username used for the registry authentication. + Username string `json:"username,omitempty"` + // Password used for the registry authentication. + Password string `json:"password,omitempty"` +} + +func (r *HelmChartRegistry) IsOCI() bool { + return r.Type == OCIHelmChartRegistry +} + +func (r *HelmChartRegistry) Validate() error { + if r.IsOCI() { + if r.Address == "" { + return errors.New("address must be set") + } + return nil + } + + return fmt.Errorf("%s registry must be configured", OCIHelmChartRegistry) +} + +func (r *HelmChartRegistry) Mask() { + if len(r.Password) != 0 { + r.Password = maskString + } +} + +type PipedPlatformProvider struct { + Name string `json:"name"` + Type model.PlatformProviderType `json:"type"` + Labels map[string]string `json:"labels,omitempty"` + + KubernetesConfig *PlatformProviderKubernetesConfig + TerraformConfig *PlatformProviderTerraformConfig + CloudRunConfig *PlatformProviderCloudRunConfig + LambdaConfig *PlatformProviderLambdaConfig + ECSConfig *PlatformProviderECSConfig +} + +type genericPipedPlatformProvider struct { + Name string `json:"name"` + Type model.PlatformProviderType `json:"type"` + Labels map[string]string `json:"labels,omitempty"` + Config json.RawMessage `json:"config"` +} + +func (p *PipedPlatformProvider) MarshalJSON() ([]byte, error) { + var ( + err error + config json.RawMessage + ) + + switch p.Type { + case model.PlatformProviderKubernetes: + config, err = json.Marshal(p.KubernetesConfig) + case model.PlatformProviderTerraform: + config, err = json.Marshal(p.TerraformConfig) + case model.PlatformProviderCloudRun: + config, err = json.Marshal(p.CloudRunConfig) + case model.PlatformProviderLambda: + config, err = json.Marshal(p.LambdaConfig) + case model.PlatformProviderECS: + config, err = json.Marshal(p.ECSConfig) + default: + err = fmt.Errorf("unsupported platform provider type: %s", p.Name) + } + + if err != nil { + return nil, err + } + + return json.Marshal(&genericPipedPlatformProvider{ + Name: p.Name, + Type: p.Type, + Labels: p.Labels, + Config: config, + }) +} + +func (p *PipedPlatformProvider) UnmarshalJSON(data []byte) error { + var err error + gp := genericPipedPlatformProvider{} + if err = json.Unmarshal(data, &gp); err != nil { + return err + } + p.Name = gp.Name + p.Type = gp.Type + p.Labels = gp.Labels + + switch p.Type { + case model.PlatformProviderKubernetes: + p.KubernetesConfig = &PlatformProviderKubernetesConfig{} + if len(gp.Config) > 0 { + err = json.Unmarshal(gp.Config, p.KubernetesConfig) + } + case model.PlatformProviderTerraform: + p.TerraformConfig = &PlatformProviderTerraformConfig{} + if len(gp.Config) > 0 { + err = json.Unmarshal(gp.Config, p.TerraformConfig) + } + case model.PlatformProviderCloudRun: + p.CloudRunConfig = &PlatformProviderCloudRunConfig{} + if len(gp.Config) > 0 { + err = json.Unmarshal(gp.Config, p.CloudRunConfig) + } + case model.PlatformProviderLambda: + p.LambdaConfig = &PlatformProviderLambdaConfig{} + if len(gp.Config) > 0 { + err = json.Unmarshal(gp.Config, p.LambdaConfig) + } + case model.PlatformProviderECS: + p.ECSConfig = &PlatformProviderECSConfig{} + if len(gp.Config) > 0 { + err = json.Unmarshal(gp.Config, p.ECSConfig) + } + default: + err = fmt.Errorf("unsupported platform provider type: %s", p.Name) + } + return err +} + +func (p *PipedPlatformProvider) Mask() { + if p.CloudRunConfig != nil { + p.CloudRunConfig.Mask() + } + if p.LambdaConfig != nil { + p.LambdaConfig.Mask() + } + if p.ECSConfig != nil { + p.ECSConfig.Mask() + } +} + +type PlatformProviderKubernetesConfig struct { + // The master URL of the kubernetes cluster. + // Empty means in-cluster. + MasterURL string `json:"masterURL,omitempty"` + // The path to the kubeconfig file. + // Empty means in-cluster. + KubeConfigPath string `json:"kubeConfigPath,omitempty"` + // Configuration for application resource informer. + AppStateInformer KubernetesAppStateInformer `json:"appStateInformer"` + // Version of kubectl will be used. + KubectlVersion string `json:"kubectlVersion"` +} + +type KubernetesAppStateInformer struct { + // Only watches the specified namespace. + // Empty means watching all namespaces. + Namespace string `json:"namespace,omitempty"` + // List of resources that should be added to the watching targets. + IncludeResources []KubernetesResourceMatcher `json:"includeResources,omitempty"` + // List of resources that should be ignored from the watching targets. + ExcludeResources []KubernetesResourceMatcher `json:"excludeResources,omitempty"` +} + +type KubernetesResourceMatcher struct { + // The APIVersion of the kubernetes resource. + APIVersion string `json:"apiVersion,omitempty"` + // The kind name of the kubernetes resource. + // Empty means all kinds are matching. + Kind string `json:"kind,omitempty"` +} + +type PlatformProviderTerraformConfig struct { + // List of variables that will be set directly on terraform commands with "-var" flag. + // The variable must be formatted by "key=value" as below: + // "image_id=ami-abc123" + // 'image_id_list=["ami-abc123","ami-def456"]' + // 'image_id_map={"us-east-1":"ami-abc123","us-east-2":"ami-def456"}' + Vars []string `json:"vars,omitempty"` + // Enable drift detection. + // TODO: This is a temporary option because Terraform drift detection is buggy and has performance issues. This will be possibly removed in the future release. + DriftDetectionEnabled *bool `json:"driftDetectionEnabled" default:"true"` +} + +type PlatformProviderCloudRunConfig struct { + // The GCP project hosting the CloudRun service. + Project string `json:"project"` + // The region of running CloudRun service. + Region string `json:"region"` + // The path to the service account file for accessing CloudRun service. + CredentialsFile string `json:"credentialsFile,omitempty"` +} + +func (c *PlatformProviderCloudRunConfig) Mask() { + if len(c.CredentialsFile) != 0 { + c.CredentialsFile = maskString + } +} + +type PlatformProviderLambdaConfig struct { + // The region to send requests to. This parameter is required. + // e.g. "us-west-2" + // A full list of regions is: https://docs.aws.amazon.com/general/latest/gr/rande.html + Region string `json:"region"` + // Path to the shared credentials file. + CredentialsFile string `json:"credentialsFile,omitempty"` + // The IAM role arn to use when assuming an role. + RoleARN string `json:"roleARN,omitempty"` + // Path to the WebIdentity token the SDK should use to assume a role with. + TokenFile string `json:"tokenFile,omitempty"` + // AWS Profile to extract credentials from the shared credentials file. + // If empty, the environment variable "AWS_PROFILE" is used. + // "default" is populated if the environment variable is also not set. + Profile string `json:"profile,omitempty"` +} + +func (c *PlatformProviderLambdaConfig) Mask() { + if len(c.CredentialsFile) != 0 { + c.CredentialsFile = maskString + } + if len(c.RoleARN) != 0 { + c.RoleARN = maskString + } + if len(c.TokenFile) != 0 { + c.TokenFile = maskString + } +} + +type PlatformProviderECSConfig struct { + // The region to send requests to. This parameter is required. + // e.g. "us-west-2" + // A full list of regions is: https://docs.aws.amazon.com/general/latest/gr/rande.html + Region string `json:"region"` + // Path to the shared credentials file. + CredentialsFile string `json:"credentialsFile,omitempty"` + // The IAM role arn to use when assuming an role. + RoleARN string `json:"roleARN,omitempty"` + // Path to the WebIdentity token the SDK should use to assume a role with. + TokenFile string `json:"tokenFile,omitempty"` + // AWS Profile to extract credentials from the shared credentials file. + // If empty, the environment variable "AWS_PROFILE" is used. + // "default" is populated if the environment variable is also not set. + Profile string `json:"profile,omitempty"` +} + +func (c *PlatformProviderECSConfig) Mask() { + if len(c.CredentialsFile) != 0 { + c.CredentialsFile = maskString + } + if len(c.RoleARN) != 0 { + c.RoleARN = maskString + } + if len(c.TokenFile) != 0 { + c.TokenFile = maskString + } +} + +type PipedAnalysisProvider struct { + Name string `json:"name"` + Type model.AnalysisProviderType `json:"type"` + + PrometheusConfig *AnalysisProviderPrometheusConfig + DatadogConfig *AnalysisProviderDatadogConfig + StackdriverConfig *AnalysisProviderStackdriverConfig +} + +func (p *PipedAnalysisProvider) Mask() { + if p.PrometheusConfig != nil { + p.PrometheusConfig.Mask() + } + if p.DatadogConfig != nil { + p.DatadogConfig.Mask() + } + if p.StackdriverConfig != nil { + p.StackdriverConfig.Mask() + } +} + +type genericPipedAnalysisProvider struct { + Name string `json:"name"` + Type model.AnalysisProviderType `json:"type"` + Config json.RawMessage `json:"config"` +} + +func (p *PipedAnalysisProvider) MarshalJSON() ([]byte, error) { + var ( + err error + config json.RawMessage + ) + + switch p.Type { + case model.AnalysisProviderDatadog: + config, err = json.Marshal(p.DatadogConfig) + case model.AnalysisProviderPrometheus: + config, err = json.Marshal(p.PrometheusConfig) + case model.AnalysisProviderStackdriver: + config, err = json.Marshal(p.StackdriverConfig) + default: + err = fmt.Errorf("unsupported analysis provider type: %s", p.Name) + } + + if err != nil { + return nil, err + } + + return json.Marshal(&genericPipedAnalysisProvider{ + Name: p.Name, + Type: p.Type, + Config: config, + }) +} + +func (p *PipedAnalysisProvider) UnmarshalJSON(data []byte) error { + var err error + gp := genericPipedAnalysisProvider{} + if err = json.Unmarshal(data, &gp); err != nil { + return err + } + p.Name = gp.Name + p.Type = gp.Type + + switch p.Type { + case model.AnalysisProviderPrometheus: + p.PrometheusConfig = &AnalysisProviderPrometheusConfig{} + if len(gp.Config) > 0 { + err = json.Unmarshal(gp.Config, p.PrometheusConfig) + } + case model.AnalysisProviderDatadog: + p.DatadogConfig = &AnalysisProviderDatadogConfig{} + if len(gp.Config) > 0 { + err = json.Unmarshal(gp.Config, p.DatadogConfig) + } + case model.AnalysisProviderStackdriver: + p.StackdriverConfig = &AnalysisProviderStackdriverConfig{} + if len(gp.Config) > 0 { + err = json.Unmarshal(gp.Config, p.StackdriverConfig) + } + default: + err = fmt.Errorf("unsupported analysis provider type: %s", p.Name) + } + return err +} + +func (p *PipedAnalysisProvider) Validate() error { + switch p.Type { + case model.AnalysisProviderPrometheus: + return p.PrometheusConfig.Validate() + case model.AnalysisProviderDatadog: + return p.DatadogConfig.Validate() + case model.AnalysisProviderStackdriver: + return p.StackdriverConfig.Validate() + default: + return fmt.Errorf("unknow provider type: %s", p.Type) + } +} + +type AnalysisProviderPrometheusConfig struct { + Address string `json:"address"` + // The path to the username file. + UsernameFile string `json:"usernameFile,omitempty"` + // The path to the password file. + PasswordFile string `json:"passwordFile,omitempty"` +} + +func (a *AnalysisProviderPrometheusConfig) Validate() error { + if a.Address == "" { + return fmt.Errorf("prometheus analysis provider requires the address") + } + return nil +} + +func (a *AnalysisProviderPrometheusConfig) Mask() { + if len(a.PasswordFile) != 0 { + a.PasswordFile = maskString + } +} + +type AnalysisProviderDatadogConfig struct { + // The address of Datadog API server. + // Only "datadoghq.com", "us3.datadoghq.com", "datadoghq.eu", "ddog-gov.com" are available. + // Defaults to "datadoghq.com" + Address string `json:"address,omitempty"` + // Required: The path to the api key file. + APIKeyFile string `json:"apiKeyFile"` + // Required: The path to the application key file. + ApplicationKeyFile string `json:"applicationKeyFile"` + // Base64 API Key for Datadog API server. + APIKeyData string `json:"apiKeyData,omitempty"` + // Base64 Application Key for Datadog API server. + ApplicationKeyData string `json:"applicationKeyData,omitempty"` +} + +func (a *AnalysisProviderDatadogConfig) Validate() error { + if a.APIKeyFile == "" && a.APIKeyData == "" { + return fmt.Errorf("either datadog APIKeyFile or APIKeyData must be set") + } + if a.ApplicationKeyFile == "" && a.ApplicationKeyData == "" { + return fmt.Errorf("either datadog ApplicationKeyFile or ApplicationKeyData must be set") + } + if a.APIKeyData != "" && a.APIKeyFile != "" { + return fmt.Errorf("only datadog APIKeyFile or APIKeyData can be set") + } + if a.ApplicationKeyData != "" && a.ApplicationKeyFile != "" { + return fmt.Errorf("only datadog ApplicationKeyFile or ApplicationKeyData can be set") + } + return nil +} + +func (a *AnalysisProviderDatadogConfig) Mask() { + if len(a.APIKeyFile) != 0 { + a.APIKeyFile = maskString + } + if len(a.ApplicationKeyFile) != 0 { + a.ApplicationKeyFile = maskString + } + if len(a.APIKeyData) != 0 { + a.APIKeyData = maskString + } + if len(a.ApplicationKeyData) != 0 { + a.ApplicationKeyData = maskString + } +} + +// func(a *AnalysisProviderDatadogConfig) + +type AnalysisProviderStackdriverConfig struct { + // The path to the service account file. + ServiceAccountFile string `json:"serviceAccountFile"` +} + +func (a *AnalysisProviderStackdriverConfig) Mask() { + if len(a.ServiceAccountFile) != 0 { + a.ServiceAccountFile = maskString + } +} + +func (a *AnalysisProviderStackdriverConfig) Validate() error { + return nil +} + +type Notifications struct { + // List of notification routes. + Routes []NotificationRoute `json:"routes,omitempty"` + // List of notification receivers. + Receivers []NotificationReceiver `json:"receivers,omitempty"` +} + +func (n *Notifications) Mask() { + for _, r := range n.Receivers { + r.Mask() + } +} + +type NotificationRoute struct { + Name string `json:"name"` + Receiver string `json:"receiver"` + Events []string `json:"events,omitempty"` + IgnoreEvents []string `json:"ignoreEvents,omitempty"` + Groups []string `json:"groups,omitempty"` + IgnoreGroups []string `json:"ignoreGroups,omitempty"` + Apps []string `json:"apps,omitempty"` + IgnoreApps []string `json:"ignoreApps,omitempty"` + Labels map[string]string `json:"labels,omitempty"` + IgnoreLabels map[string]string `json:"ignoreLabels,omitempty"` +} + +type NotificationReceiver struct { + Name string `json:"name"` + Slack *NotificationReceiverSlack `json:"slack,omitempty"` + Webhook *NotificationReceiverWebhook `json:"webhook,omitempty"` +} + +func (n *NotificationReceiver) Mask() { + if n.Slack != nil { + n.Slack.Mask() + } + if n.Webhook != nil { + n.Webhook.Mask() + } +} + +type NotificationReceiverSlack struct { + HookURL string `json:"hookURL"` + OAuthToken string `json:"oauthToken"` // Deprecated: use OAuthTokenData instead. + OAuthTokenData string `json:"oauthTokenData"` + OAuthTokenFile string `json:"oauthTokenFile"` + ChannelID string `json:"channelID"` + MentionedAccounts []string `json:"mentionedAccounts,omitempty"` + MentionedGroups []string `json:"mentionedGroups,omitempty"` +} + +func (n *NotificationReceiverSlack) Mask() { + if len(n.HookURL) != 0 { + n.HookURL = maskString + } + if len(n.OAuthToken) != 0 { + n.OAuthToken = maskString + } + if len(n.OAuthTokenData) != 0 { + n.OAuthTokenData = maskString + } +} + +func (n *NotificationReceiverSlack) Validate() error { + mentionedAccounts := make([]string, 0, len(n.MentionedAccounts)) + for _, mentionedAccount := range n.MentionedAccounts { + formatMentionedAccount := strings.TrimPrefix(mentionedAccount, "@") + mentionedAccounts = append(mentionedAccounts, formatMentionedAccount) + } + mentionedGroups := make([]string, 0, len(n.MentionedGroups)) + for _, mentionedGroup := range n.MentionedGroups { + if !strings.Contains(mentionedGroup, "!subteam^") { + formatMentionedGroup := fmt.Sprintf("", mentionedGroup) + mentionedGroups = append(mentionedGroups, formatMentionedGroup) + } else { + mentionedGroups = append(mentionedGroups, mentionedGroup) + } + } + if len(mentionedGroups) > 0 { + n.MentionedGroups = mentionedGroups + } + if len(mentionedAccounts) > 0 { + n.MentionedAccounts = mentionedAccounts + } + if n.HookURL != "" && (n.OAuthToken != "" || n.OAuthTokenFile != "" || n.OAuthTokenData != "" || n.ChannelID != "") { + return errors.New("only one of sending via hook URL or API should be used") + } + if n.HookURL != "" { + return nil + } + if n.ChannelID == "" || (n.OAuthToken == "" && n.OAuthTokenFile == "" && n.OAuthTokenData == "") { + return errors.New("missing channelID or OAuth token configuration") + } + if (n.OAuthToken != "" && n.OAuthTokenFile != "") || (n.OAuthToken != "" && n.OAuthTokenData != "") || (n.OAuthTokenFile != "" && n.OAuthTokenData != "") { + return errors.New("only one of OAuthToken, OAuthTokenData and OAuthTokenFile should be set") + } + return nil +} + +type NotificationReceiverWebhook struct { + URL string `json:"url"` + SignatureKey string `json:"signatureKey,omitempty" default:"PipeCD-Signature"` + SignatureValue string `json:"signatureValue,omitempty"` + SignatureValueFile string `json:"signatureValueFile,omitempty"` +} + +func (n *NotificationReceiverWebhook) Mask() { + if len(n.URL) != 0 { + n.URL = maskString + } + if len(n.SignatureKey) != 0 { + n.SignatureKey = maskString + } + if len(n.SignatureValue) != 0 { + n.SignatureValue = maskString + } + if len(n.SignatureValueFile) != 0 { + n.SignatureValueFile = maskString + } +} + +func (n *NotificationReceiverWebhook) LoadSignatureValue() (string, error) { + if n.SignatureValue != "" && n.SignatureValueFile != "" { + return "", errors.New("only either signatureValue or signatureValueFile can be set") + } + if n.SignatureValue != "" { + return n.SignatureValue, nil + } + if n.SignatureValueFile != "" { + val, err := os.ReadFile(n.SignatureValueFile) + if err != nil { + return "", err + } + return strings.TrimSuffix(string(val), "\n"), nil + } + return "", nil +} + +type SecretManagement struct { + // Which management service should be used. + // Available values: KEY_PAIR, GCP_KMS, AWS_KMS + Type model.SecretManagementType `json:"type"` + + KeyPair *SecretManagementKeyPair + GCPKMS *SecretManagementGCPKMS +} + +type genericSecretManagement struct { + Type model.SecretManagementType `json:"type"` + Config json.RawMessage `json:"config"` +} + +func (s *SecretManagement) MarshalJSON() ([]byte, error) { + var ( + err error + config json.RawMessage + ) + + switch s.Type { + case model.SecretManagementTypeKeyPair: + config, err = json.Marshal(s.KeyPair) + case model.SecretManagementTypeGCPKMS: + config, err = json.Marshal(s.GCPKMS) + default: + err = fmt.Errorf("unsupported secret management type: %s", s.Type) + } + + if err != nil { + return nil, err + } + + return json.Marshal(&genericSecretManagement{ + Type: s.Type, + Config: config, + }) +} + +func (s *SecretManagement) UnmarshalJSON(data []byte) error { + var err error + g := genericSecretManagement{} + if err = json.Unmarshal(data, &g); err != nil { + return err + } + + switch g.Type { + case model.SecretManagementTypeKeyPair: + s.Type = model.SecretManagementTypeKeyPair + s.KeyPair = &SecretManagementKeyPair{} + if len(g.Config) > 0 { + err = json.Unmarshal(g.Config, s.KeyPair) + } + case model.SecretManagementTypeGCPKMS: + s.Type = model.SecretManagementTypeGCPKMS + s.GCPKMS = &SecretManagementGCPKMS{} + if len(g.Config) > 0 { + err = json.Unmarshal(g.Config, s.GCPKMS) + } + default: + err = fmt.Errorf("unsupported secret management type: %s", s.Type) + } + return err +} + +func (s *SecretManagement) Mask() { + if s.KeyPair != nil { + s.KeyPair.Mask() + } + if s.GCPKMS != nil { + s.GCPKMS.Mask() + } +} + +func (s *SecretManagement) Validate() error { + switch s.Type { + case model.SecretManagementTypeKeyPair: + return s.KeyPair.Validate() + case model.SecretManagementTypeGCPKMS: + return s.GCPKMS.Validate() + default: + return fmt.Errorf("unsupported sealed secret management type: %s", s.Type) + } +} + +type SecretManagementKeyPair struct { + // The path to the private RSA key file. + PrivateKeyFile string `json:"privateKeyFile"` + // Base64 encoded string of private key. + PrivateKeyData string `json:"privateKeyData,omitempty"` + // The path to the public RSA key file. + PublicKeyFile string `json:"publicKeyFile"` + // Base64 encoded string of public key. + PublicKeyData string `json:"publicKeyData,omitempty"` +} + +func (s *SecretManagementKeyPair) Validate() error { + if s.PrivateKeyFile == "" && s.PrivateKeyData == "" { + return errors.New("either privateKeyFile or privateKeyData must be set") + } + if s.PrivateKeyFile != "" && s.PrivateKeyData != "" { + return errors.New("only privateKeyFile or privateKeyData can be set") + } + if s.PublicKeyFile == "" && s.PublicKeyData == "" { + return errors.New("either publicKeyFile or publicKeyData must be set") + } + if s.PublicKeyFile != "" && s.PublicKeyData != "" { + return errors.New("only publicKeyFile or publicKeyData can be set") + } + return nil +} + +func (s *SecretManagementKeyPair) Mask() { + if len(s.PrivateKeyFile) != 0 { + s.PrivateKeyFile = maskString + } + if len(s.PrivateKeyData) != 0 { + s.PrivateKeyData = maskString + } +} + +func (s *SecretManagementKeyPair) LoadPrivateKey() ([]byte, error) { + if s.PrivateKeyData != "" { + return base64.StdEncoding.DecodeString(s.PrivateKeyData) + } + if s.PrivateKeyFile != "" { + return os.ReadFile(s.PrivateKeyFile) + } + return nil, errors.New("either privateKeyFile or privateKeyData must be set") +} + +func (s *SecretManagementKeyPair) LoadPublicKey() ([]byte, error) { + if s.PublicKeyData != "" { + return base64.StdEncoding.DecodeString(s.PublicKeyData) + } + if s.PublicKeyFile != "" { + return os.ReadFile(s.PublicKeyFile) + } + return nil, errors.New("either publicKeyFile or publicKeyData must be set") +} + +type SecretManagementGCPKMS struct { + // Configurable fields when using Google Cloud KMS. + // The key name used for decrypting the sealed secret. + KeyName string `json:"keyName"` + // The path to the service account used to decrypt secret. + DecryptServiceAccountFile string `json:"decryptServiceAccountFile"` + // The path to the service account used to encrypt secret. + EncryptServiceAccountFile string `json:"encryptServiceAccountFile"` +} + +func (s *SecretManagementGCPKMS) Validate() error { + if s.KeyName == "" { + return fmt.Errorf("keyName must be set") + } + if s.DecryptServiceAccountFile == "" { + return fmt.Errorf("decryptServiceAccountFile must be set") + } + if s.EncryptServiceAccountFile == "" { + return fmt.Errorf("encryptServiceAccountFile must be set") + } + return nil +} + +func (s *SecretManagementGCPKMS) Mask() { + if len(s.DecryptServiceAccountFile) != 0 { + s.DecryptServiceAccountFile = maskString + } + if len(s.EncryptServiceAccountFile) != 0 { + s.EncryptServiceAccountFile = maskString + } +} + +type PipedEventWatcher struct { + // Interval to fetch the latest event and compare it with one defined in EventWatcher config files + CheckInterval Duration `json:"checkInterval,omitempty"` + // The configuration list of git repositories to be observed. + // Only the repositories in this list will be observed by Piped. + GitRepos []PipedEventWatcherGitRepo `json:"gitRepos,omitempty"` +} + +func (p *PipedEventWatcher) Validate() error { + seen := make(map[string]struct{}, len(p.GitRepos)) + for i, repo := range p.GitRepos { + // Validate the existence of repo ID. + if repo.RepoID == "" { + return fmt.Errorf("missing repoID at index %d", i) + } + // Validate if duplicated repository settings exist. + if _, ok := seen[repo.RepoID]; ok { + return fmt.Errorf("duplicated repo id (%s) found in the eventWatcher directive", repo.RepoID) + } + seen[repo.RepoID] = struct{}{} + } + return nil +} + +type PipedEventWatcherGitRepo struct { + // Id of the git repository. This must be unique within + // the repos' elements. + RepoID string `json:"repoId,omitempty"` + // The commit message used to push after replacing values. + // Default message is used if not given. + CommitMessage string `json:"commitMessage,omitempty"` + // The file path patterns to be included. + // Patterns can be used like "foo/*.yaml". + Includes []string `json:"includes,omitempty"` + // The file path patterns to be excluded. + // Patterns can be used like "foo/*.yaml". + // This is prioritized if both includes and this one are given. + Excludes []string `json:"excludes,omitempty"` +} diff --git a/pkg/configv1/piped_test.go b/pkg/configv1/piped_test.go new file mode 100644 index 0000000000..a5f4f2694c --- /dev/null +++ b/pkg/configv1/piped_test.go @@ -0,0 +1,1520 @@ +// Copyright 2024 The PipeCD 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" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/pipe-cd/pipecd/pkg/model" +) + +func TestPipedConfig(t *testing.T) { + testcases := []struct { + fileName string + expectedKind Kind + expectedAPIVersion string + expectedSpec interface{} + expectedError error + }{ + { + fileName: "testdata/piped/piped-config.yaml", + expectedKind: KindPiped, + expectedAPIVersion: "pipecd.dev/v1beta1", + expectedSpec: &PipedSpec{ + ProjectID: "test-project", + PipedID: "test-piped", + PipedKeyFile: "etc/piped/key", + APIAddress: "your-pipecd.domain", + WebAddress: "https://your-pipecd.domain", + SyncInterval: Duration(time.Minute), + AppConfigSyncInterval: Duration(time.Minute), + Git: PipedGit{ + Username: "username", + Email: "username@email.com", + SSHKeyFile: "/etc/piped-secret/ssh-key", + }, + Repositories: []PipedRepository{ + { + RepoID: "repo1", + Remote: "git@github.com:org/repo1.git", + Branch: "master", + }, + { + RepoID: "repo2", + Remote: "git@github.com:org/repo2.git", + Branch: "master", + }, + }, + ChartRepositories: []HelmChartRepository{ + { + Type: HTTPHelmChartRepository, + Name: "fantastic-charts", + Address: "https://fantastic-charts.storage.googleapis.com", + }, + { + Type: HTTPHelmChartRepository, + Name: "private-charts", + Address: "https://private-charts.com", + Username: "basic-username", + Password: "basic-password", + Insecure: true, + }, + }, + ChartRegistries: []HelmChartRegistry{ + { + Type: OCIHelmChartRegistry, + Address: "registry.example.com", + Username: "sample-username", + Password: "sample-password", + }, + }, + PlatformProviders: []PipedPlatformProvider{ + { + Name: "kubernetes-default", + Type: model.PlatformProviderKubernetes, + Labels: map[string]string{ + "group": "workload", + }, + KubernetesConfig: &PlatformProviderKubernetesConfig{ + MasterURL: "https://example.com", + KubeConfigPath: "/etc/kube/config", + AppStateInformer: KubernetesAppStateInformer{ + IncludeResources: []KubernetesResourceMatcher{ + { + APIVersion: "pipecd.dev/v1beta1", + }, + { + APIVersion: "networking.gke.io/v1beta1", + Kind: "ManagedCertificate", + }, + }, + ExcludeResources: []KubernetesResourceMatcher{ + { + APIVersion: "v1", + Kind: "Endpoints", + }, + }, + }, + }, + }, + { + Name: "kubernetes-dev", + Type: model.PlatformProviderKubernetes, + Labels: map[string]string{ + "group": "config", + }, + KubernetesConfig: &PlatformProviderKubernetesConfig{}, + }, + { + Name: "terraform", + Type: model.PlatformProviderTerraform, + TerraformConfig: &PlatformProviderTerraformConfig{ + Vars: []string{ + "project=gcp-project", + "region=us-centra1", + }, + DriftDetectionEnabled: newBoolPointer(false), + }, + }, + { + Name: "cloudrun", + Type: model.PlatformProviderCloudRun, + CloudRunConfig: &PlatformProviderCloudRunConfig{ + Project: "gcp-project-id", + Region: "cloud-run-region", + CredentialsFile: "/etc/piped-secret/gcp-service-account.json", + }, + }, + { + Name: "lambda", + Type: model.PlatformProviderLambda, + LambdaConfig: &PlatformProviderLambdaConfig{ + Region: "us-east-1", + }, + }, + }, + AnalysisProviders: []PipedAnalysisProvider{ + { + Name: "prometheus-dev", + Type: model.AnalysisProviderPrometheus, + PrometheusConfig: &AnalysisProviderPrometheusConfig{ + Address: "https://your-prometheus.dev", + }, + }, + { + Name: "datadog-dev", + Type: model.AnalysisProviderDatadog, + DatadogConfig: &AnalysisProviderDatadogConfig{ + Address: "https://your-datadog.dev", + APIKeyFile: "/etc/piped-secret/datadog-api-key", + ApplicationKeyFile: "/etc/piped-secret/datadog-application-key", + }, + }, + { + Name: "stackdriver-dev", + Type: model.AnalysisProviderStackdriver, + StackdriverConfig: &AnalysisProviderStackdriverConfig{ + ServiceAccountFile: "/etc/piped-secret/gcp-service-account.json", + }, + }, + }, + Notifications: Notifications{ + Routes: []NotificationRoute{ + { + Name: "dev-slack", + Labels: map[string]string{ + "env": "dev", + "team": "pipecd", + }, + Receiver: "dev-slack-channel", + }, + { + Name: "prod-slack", + Labels: map[string]string{ + "env": "dev", + }, + Events: []string{"DEPLOYMENT_TRIGGERED", "DEPLOYMENT_SUCCEEDED"}, + Receiver: "prod-slack-channel", + }, + { + Name: "integration-slack", + Receiver: "integration-slack-api", + }, + { + Name: "all-events-to-ci", + Receiver: "ci-webhook", + }, + }, + Receivers: []NotificationReceiver{ + { + Name: "dev-slack-channel", + Slack: &NotificationReceiverSlack{ + HookURL: "https://slack.com/dev", + }, + }, + { + Name: "prod-slack-channel", + Slack: &NotificationReceiverSlack{ + HookURL: "https://slack.com/prod", + }, + }, + { + Name: "integration-slack-api", + Slack: &NotificationReceiverSlack{ + OAuthToken: "token", + ChannelID: "testid", + }, + }, + { + Name: "hookurl-with-mentioned-groups", + Slack: &NotificationReceiverSlack{ + HookURL: "https://slack.com/dev", + MentionedGroups: []string{"", ""}, + }, + }, + { + Name: "hookurl-with-mentioned-accounts", + Slack: &NotificationReceiverSlack{ + HookURL: "https://slack.com/dev", + MentionedAccounts: []string{"user1", "user2"}, + }, + }, + { + Name: "hookurl-with-mentioned-both-accounts-and-groups", + Slack: &NotificationReceiverSlack{ + HookURL: "https://slack.com/dev", + MentionedAccounts: []string{"user1", "user2"}, + MentionedGroups: []string{"", ""}, + }, + }, + { + Name: "integration-slack-api-with-mentioned-accounts", + Slack: &NotificationReceiverSlack{ + OAuthToken: "token", + ChannelID: "testid", + MentionedAccounts: []string{"user1", "user2"}, + }, + }, + { + Name: "integration-slack-api-with-mentioned-groups", + Slack: &NotificationReceiverSlack{ + OAuthToken: "token", + ChannelID: "testid", + MentionedGroups: []string{"", ""}, + }, + }, + { + Name: "integration-slack-api-with-mentioned-both-accounts-groups", + Slack: &NotificationReceiverSlack{ + OAuthToken: "token", + ChannelID: "testid", + MentionedAccounts: []string{"user1", "user2"}, + MentionedGroups: []string{"", ""}, + }, + }, + { + Name: "integration-slack-api-with-oauthTokenData", + Slack: &NotificationReceiverSlack{ + OAuthTokenData: "token", + ChannelID: "testid", + }, + }, + { + Name: "integration-slack-api-with-oauthTokenFile", + Slack: &NotificationReceiverSlack{ + OAuthTokenFile: "foo/bar", + ChannelID: "testid", + }, + }, + { + Name: "integration-slack-api-with-oauthTokenFile-and-mentioned-accounts", + Slack: &NotificationReceiverSlack{ + OAuthTokenFile: "foo/bar", + ChannelID: "testid", + MentionedAccounts: []string{"user1", "user2"}, + }, + }, + { + Name: "integration-slack-api-with-oauthTokenFile-and-mentioned-groups", + Slack: &NotificationReceiverSlack{ + OAuthTokenFile: "foo/bar", + ChannelID: "testid", + MentionedGroups: []string{"", ""}, + }, + }, + { + Name: "integration-slack-api-with-oauthTokenFile-and-mentioned-both-accounts-and-groups", + Slack: &NotificationReceiverSlack{ + OAuthTokenFile: "foo/bar", + ChannelID: "testid", + MentionedAccounts: []string{"user1", "user2"}, + MentionedGroups: []string{"", ""}, + }, + }, + { + Name: "integration-slack-api-with-oauthTokenData-and-mentioned-accounts", + Slack: &NotificationReceiverSlack{ + OAuthTokenData: "token", + ChannelID: "testid", + MentionedAccounts: []string{"user1", "user2"}, + }, + }, + { + Name: "integration-slack-api-with-oauthTokenData-and-mentioned-groups", + Slack: &NotificationReceiverSlack{ + OAuthTokenData: "token", + ChannelID: "testid", + MentionedGroups: []string{"", ""}, + }, + }, + { + Name: "integration-slack-api-with-oauthTokenData-and-mentioned-both-accounts-and-groups", + Slack: &NotificationReceiverSlack{ + OAuthTokenData: "token", + ChannelID: "testid", + MentionedAccounts: []string{"user1", "user2"}, + MentionedGroups: []string{"", ""}, + }, + }, + { + Name: "ci-webhook", + Webhook: &NotificationReceiverWebhook{ + URL: "https://pipecd.dev/dev-hook", + SignatureKey: "PipeCD-Signature", + SignatureValue: "random-signature-string", + }, + }, + }, + }, + SecretManagement: &SecretManagement{ + Type: model.SecretManagementTypeKeyPair, + KeyPair: &SecretManagementKeyPair{ + PrivateKeyFile: "/etc/piped-secret/pair-private-key", + PublicKeyFile: "/etc/piped-secret/pair-public-key", + }, + }, + EventWatcher: PipedEventWatcher{ + CheckInterval: Duration(10 * time.Minute), + GitRepos: []PipedEventWatcherGitRepo{ + { + RepoID: "repo-1", + CommitMessage: "Update values by Event watcher", + Includes: []string{"event-watcher-dev.yaml", "event-watcher-stg.yaml"}, + }, + }, + }, + }, + expectedError: nil, + }, + } + for _, tc := range testcases { + t.Run(tc.fileName, func(t *testing.T) { + cfg, err := LoadFromYAML(tc.fileName) + require.Equal(t, tc.expectedError, err) + if err == nil { + assert.Equal(t, tc.expectedKind, cfg.Kind) + assert.Equal(t, tc.expectedAPIVersion, cfg.APIVersion) + assert.Equal(t, tc.expectedSpec, cfg.spec) + } + }) + } +} + +func TestPipedEventWatcherValidate(t *testing.T) { + testcases := []struct { + name string + eventWatcher PipedEventWatcher + wantErr bool + wantPipedEventWatcher PipedEventWatcher + }{ + { + name: "missing repo id", + wantErr: true, + eventWatcher: PipedEventWatcher{ + GitRepos: []PipedEventWatcherGitRepo{ + { + RepoID: "", + }, + }, + }, + wantPipedEventWatcher: PipedEventWatcher{ + GitRepos: []PipedEventWatcherGitRepo{ + { + RepoID: "", + }, + }, + }, + }, + { + name: "duplicated repo exists", + wantErr: true, + eventWatcher: PipedEventWatcher{ + GitRepos: []PipedEventWatcherGitRepo{ + { + RepoID: "foo", + }, + { + RepoID: "foo", + }, + }, + }, + wantPipedEventWatcher: PipedEventWatcher{ + GitRepos: []PipedEventWatcherGitRepo{ + { + RepoID: "foo", + }, + { + RepoID: "foo", + }, + }, + }, + }, + { + name: "repos are unique", + wantErr: false, + eventWatcher: PipedEventWatcher{ + GitRepos: []PipedEventWatcherGitRepo{ + { + RepoID: "foo", + }, + { + RepoID: "bar", + }, + }, + }, + wantPipedEventWatcher: PipedEventWatcher{ + GitRepos: []PipedEventWatcherGitRepo{ + { + RepoID: "foo", + }, + { + RepoID: "bar", + }, + }, + }, + }, + } + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + err := tc.eventWatcher.Validate() + assert.Equal(t, tc.wantErr, err != nil) + assert.Equal(t, tc.wantPipedEventWatcher, tc.eventWatcher) + }) + } +} + +func TestPipedSlackNotificationValidate(t *testing.T) { + testcases := []struct { + name string + notificationReceiver *NotificationReceiverSlack + wantErr bool + }{ + { + name: "both hook url and oauth token data is set", + notificationReceiver: &NotificationReceiverSlack{ + HookURL: "https://slack.com/dev", + OAuthTokenData: "token", + ChannelID: "testid", + }, + wantErr: true, + }, + { + name: "both hook url and oauth token file is set", + notificationReceiver: &NotificationReceiverSlack{ + HookURL: "https://slack.com/dev", + OAuthTokenFile: "foo/bar", + ChannelID: "testid", + }, + wantErr: true, + }, + { + name: "oauth token data is set, but channel id is empty", + notificationReceiver: &NotificationReceiverSlack{ + OAuthTokenData: "token", + ChannelID: "", + }, + wantErr: true, + }, + { + name: "oauth token file is set, but channel id is empty", + notificationReceiver: &NotificationReceiverSlack{ + OAuthTokenFile: "foo/bar", + ChannelID: "", + }, + wantErr: true, + }, + { + name: "both oauth token data and file are set", + notificationReceiver: &NotificationReceiverSlack{ + OAuthTokenData: "token", + OAuthTokenFile: "foo/bar", + ChannelID: "testid", + }, + wantErr: true, + }, + { + name: "both oauth token and file are set", + notificationReceiver: &NotificationReceiverSlack{ + OAuthToken: "token", + OAuthTokenFile: "foo/bar", + ChannelID: "testid", + }, + wantErr: true, + }, + { + name: "both oauth token raw and base64 are set", + notificationReceiver: &NotificationReceiverSlack{ + OAuthToken: "token", + OAuthTokenData: "foo/bar", + ChannelID: "testid", + }, + wantErr: true, + }, + } + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + err := tc.notificationReceiver.Validate() + assert.Equal(t, tc.wantErr, err != nil) + }) + } +} + +func TestNotificationReceiverWebhook_LoadSignatureValue(t *testing.T) { + testcase := []struct { + name string + webhook *NotificationReceiverWebhook + want string + wantErr bool + }{ + { + name: "set signatureValue", + webhook: &NotificationReceiverWebhook{ + URL: "https://example.com", + SignatureValue: "foo", + }, + want: "foo", + wantErr: false, + }, + { + name: "set signatureValueFile", + webhook: &NotificationReceiverWebhook{ + URL: "https://example.com", + SignatureValueFile: "testdata/piped/notification-receiver-webhook", + }, + want: "foo", + wantErr: false, + }, + { + name: "set both of them", + webhook: &NotificationReceiverWebhook{ + URL: "https://example.com", + SignatureValue: "foo", + SignatureValueFile: "testdata/piped/notification-receiver-webhook", + }, + want: "", + wantErr: true, + }, + } + for _, tc := range testcase { + t.Run(tc.name, func(t *testing.T) { + got, err := tc.webhook.LoadSignatureValue() + assert.Equal(t, tc.wantErr, err != nil) + assert.Equal(t, tc.want, got) + }) + } +} + +func TestPipedConfigMask(t *testing.T) { + testcase := []struct { + name string + spec *PipedSpec + want *PipedSpec + wantErr bool + }{ + { + name: "mask", + spec: &PipedSpec{ + ProjectID: "foo", + PipedID: "foo", + PipedKeyFile: "foo", + PipedKeyData: "foo", + Name: "foo", + APIAddress: "foo", + WebAddress: "foo", + SyncInterval: Duration(time.Minute), + AppConfigSyncInterval: Duration(time.Minute), + Git: PipedGit{ + Username: "foo", + Email: "foo", + SSHConfigFilePath: "foo", + Host: "foo", + HostName: "foo", + SSHKeyFile: "foo", + SSHKeyData: "foo", + Password: "foo", + }, + Repositories: []PipedRepository{ + { + RepoID: "foo", + Remote: "foo", + Branch: "foo", + }, + }, + ChartRepositories: []HelmChartRepository{ + { + Type: "foo", + Name: "foo", + Address: "foo", + Username: "foo", + Password: "foo", + Insecure: true, + GitRemote: "foo", + SSHKeyFile: "foo", + }, + }, + ChartRegistries: []HelmChartRegistry{ + { + Type: "foo", + Address: "foo", + Username: "foo", + Password: "foo", + }, + }, + PlatformProviders: []PipedPlatformProvider{ + { + Name: "foo", + Type: model.PlatformProviderKubernetes, + KubernetesConfig: &PlatformProviderKubernetesConfig{ + MasterURL: "foo", + KubeConfigPath: "foo", + AppStateInformer: KubernetesAppStateInformer{ + Namespace: "", + IncludeResources: []KubernetesResourceMatcher{ + { + APIVersion: "foo", + Kind: "foo", + }, + }, + ExcludeResources: []KubernetesResourceMatcher{ + { + APIVersion: "foo", + Kind: "foo", + }, + }, + }, + }, + }, + { + Name: "bar", + Type: model.PlatformProviderCloudRun, + CloudRunConfig: &PlatformProviderCloudRunConfig{ + Project: "bar", + Region: "bar", + CredentialsFile: "/etc/cloudrun/credentials", + }, + }, + }, + AnalysisProviders: []PipedAnalysisProvider{ + { + Name: "foo", + Type: "foo", + PrometheusConfig: &AnalysisProviderPrometheusConfig{ + Address: "foo", + UsernameFile: "foo", + PasswordFile: "foo", + }, + DatadogConfig: &AnalysisProviderDatadogConfig{ + Address: "foo", + APIKeyFile: "foo", + ApplicationKeyFile: "foo", + APIKeyData: "foo", + ApplicationKeyData: "foo", + }, + StackdriverConfig: &AnalysisProviderStackdriverConfig{ + ServiceAccountFile: "foo", + }, + }, + }, + Notifications: Notifications{ + Routes: []NotificationRoute{ + { + Name: "foo", + Receiver: "foo", + Events: []string{"foo"}, + IgnoreEvents: []string{"foo"}, + Groups: []string{"foo"}, + IgnoreGroups: []string{"foo"}, + Apps: []string{"foo"}, + IgnoreApps: []string{"foo"}, + Labels: map[string]string{"foo": "foo"}, + IgnoreLabels: map[string]string{"foo": "foo"}, + }, + }, + Receivers: []NotificationReceiver{ + { + Name: "foo", + Slack: &NotificationReceiverSlack{ + HookURL: "foo", + OAuthTokenData: "foo", + OAuthTokenFile: "foo/bar", + ChannelID: "testid", + }, + Webhook: &NotificationReceiverWebhook{ + URL: "foo", + SignatureKey: "foo", + SignatureValue: "foo", + SignatureValueFile: "foo", + }, + }, + }, + }, + SecretManagement: &SecretManagement{ + Type: "foo", + KeyPair: &SecretManagementKeyPair{ + PrivateKeyFile: "foo", + PrivateKeyData: "foo", + PublicKeyFile: "foo", + PublicKeyData: "foo", + }, + GCPKMS: &SecretManagementGCPKMS{ + KeyName: "foo", + DecryptServiceAccountFile: "foo", + EncryptServiceAccountFile: "foo", + }, + }, + EventWatcher: PipedEventWatcher{ + CheckInterval: Duration(time.Minute), + GitRepos: []PipedEventWatcherGitRepo{ + { + RepoID: "foo", + CommitMessage: "foo", + Includes: []string{"foo"}, + Excludes: []string{"foo"}, + }, + }, + }, + AppSelector: map[string]string{ + "foo": "foo", + }, + }, + want: &PipedSpec{ + ProjectID: "foo", + PipedID: "foo", + PipedKeyFile: maskString, + PipedKeyData: maskString, + Name: "foo", + APIAddress: "foo", + WebAddress: "foo", + SyncInterval: Duration(time.Minute), + AppConfigSyncInterval: Duration(time.Minute), + Git: PipedGit{ + Username: "foo", + Email: "foo", + SSHConfigFilePath: maskString, + Host: "foo", + HostName: "foo", + SSHKeyFile: maskString, + SSHKeyData: maskString, + Password: maskString, + }, + Repositories: []PipedRepository{ + { + RepoID: "foo", + Remote: "foo", + Branch: "foo", + }, + }, + ChartRepositories: []HelmChartRepository{ + { + Type: "foo", + Name: "foo", + Address: "foo", + Username: "foo", + Password: maskString, + Insecure: true, + GitRemote: "foo", + SSHKeyFile: maskString, + }, + }, + ChartRegistries: []HelmChartRegistry{ + { + Type: "foo", + Address: "foo", + Username: "foo", + Password: maskString, + }, + }, + PlatformProviders: []PipedPlatformProvider{ + { + Name: "foo", + Type: model.PlatformProviderKubernetes, + KubernetesConfig: &PlatformProviderKubernetesConfig{ + MasterURL: "foo", + KubeConfigPath: "foo", + AppStateInformer: KubernetesAppStateInformer{ + Namespace: "", + IncludeResources: []KubernetesResourceMatcher{ + { + APIVersion: "foo", + Kind: "foo", + }, + }, + ExcludeResources: []KubernetesResourceMatcher{ + { + APIVersion: "foo", + Kind: "foo", + }, + }, + }, + }, + }, + { + Name: "bar", + Type: model.PlatformProviderCloudRun, + CloudRunConfig: &PlatformProviderCloudRunConfig{ + Project: "bar", + Region: "bar", + CredentialsFile: "******", + }, + }, + }, + AnalysisProviders: []PipedAnalysisProvider{ + { + Name: "foo", + Type: "foo", + PrometheusConfig: &AnalysisProviderPrometheusConfig{ + Address: "foo", + UsernameFile: "foo", + PasswordFile: maskString, + }, + DatadogConfig: &AnalysisProviderDatadogConfig{ + Address: "foo", + APIKeyFile: maskString, + ApplicationKeyFile: maskString, + APIKeyData: maskString, + ApplicationKeyData: maskString, + }, + StackdriverConfig: &AnalysisProviderStackdriverConfig{ + ServiceAccountFile: maskString, + }, + }, + }, + Notifications: Notifications{ + Routes: []NotificationRoute{ + { + Name: "foo", + Receiver: "foo", + Events: []string{"foo"}, + IgnoreEvents: []string{"foo"}, + Groups: []string{"foo"}, + IgnoreGroups: []string{"foo"}, + Apps: []string{"foo"}, + IgnoreApps: []string{"foo"}, + Labels: map[string]string{"foo": "foo"}, + IgnoreLabels: map[string]string{"foo": "foo"}, + }, + }, + Receivers: []NotificationReceiver{ + { + Name: "foo", + Slack: &NotificationReceiverSlack{ + HookURL: maskString, + ChannelID: "testid", + OAuthTokenData: maskString, + OAuthTokenFile: "foo/bar", + }, + Webhook: &NotificationReceiverWebhook{ + URL: maskString, + SignatureKey: maskString, + SignatureValue: maskString, + SignatureValueFile: maskString, + }, + }, + }, + }, + SecretManagement: &SecretManagement{ + Type: "foo", + KeyPair: &SecretManagementKeyPair{ + PrivateKeyFile: maskString, + PrivateKeyData: maskString, + PublicKeyFile: "foo", + PublicKeyData: "foo", + }, + GCPKMS: &SecretManagementGCPKMS{ + KeyName: "foo", + DecryptServiceAccountFile: maskString, + EncryptServiceAccountFile: maskString, + }, + }, + EventWatcher: PipedEventWatcher{ + CheckInterval: Duration(time.Minute), + GitRepos: []PipedEventWatcherGitRepo{ + { + RepoID: "foo", + CommitMessage: "foo", + Includes: []string{"foo"}, + Excludes: []string{"foo"}, + }, + }, + }, + AppSelector: map[string]string{ + "foo": "foo", + }, + }, + wantErr: false, + }, + } + + for _, tc := range testcase { + t.Run(tc.name, func(t *testing.T) { + tc.spec.Mask() + assert.Equal(t, tc.want, tc.spec) + }) + } +} + +func TestPipedSpecClone(t *testing.T) { + testcases := []struct { + name string + originalSpec *PipedSpec + expectedSpec *PipedSpec + expectedError error + }{ + { + name: "clone success", + originalSpec: &PipedSpec{ + ProjectID: "test-project", + PipedID: "test-piped", + PipedKeyFile: "etc/piped/key", + APIAddress: "your-pipecd.domain", + WebAddress: "https://your-pipecd.domain", + SyncInterval: Duration(time.Minute), + AppConfigSyncInterval: Duration(time.Minute), + Git: PipedGit{ + Username: "username", + Email: "username@email.com", + SSHKeyFile: "/etc/piped-secret/ssh-key", + Password: "Password", + }, + Repositories: []PipedRepository{ + { + RepoID: "repo1", + Remote: "git@github.com:org/repo1.git", + Branch: "master", + }, + { + RepoID: "repo2", + Remote: "git@github.com:org/repo2.git", + Branch: "master", + }, + }, + ChartRepositories: []HelmChartRepository{ + { + Type: HTTPHelmChartRepository, + Name: "fantastic-charts", + Address: "https://fantastic-charts.storage.googleapis.com", + }, + { + Type: HTTPHelmChartRepository, + Name: "private-charts", + Address: "https://private-charts.com", + Username: "basic-username", + Password: "basic-password", + Insecure: true, + }, + }, + ChartRegistries: []HelmChartRegistry{ + { + Type: OCIHelmChartRegistry, + Address: "registry.example.com", + Username: "sample-username", + Password: "sample-password", + }, + }, + PlatformProviders: []PipedPlatformProvider{ + { + Name: "kubernetes-default", + Type: model.PlatformProviderKubernetes, + KubernetesConfig: &PlatformProviderKubernetesConfig{ + MasterURL: "https://example.com", + KubeConfigPath: "/etc/kube/config", + AppStateInformer: KubernetesAppStateInformer{ + IncludeResources: []KubernetesResourceMatcher{ + { + APIVersion: "pipecd.dev/v1beta1", + }, + { + APIVersion: "networking.gke.io/v1beta1", + Kind: "ManagedCertificate", + }, + }, + ExcludeResources: []KubernetesResourceMatcher{ + { + APIVersion: "v1", + Kind: "Endpoints", + }, + }, + }, + }, + }, + { + Name: "kubernetes-dev", + Type: model.PlatformProviderKubernetes, + KubernetesConfig: &PlatformProviderKubernetesConfig{}, + }, + { + Name: "terraform", + Type: model.PlatformProviderTerraform, + TerraformConfig: &PlatformProviderTerraformConfig{ + Vars: []string{ + "project=gcp-project", + "region=us-centra1", + }, + }, + }, + { + Name: "cloudrun", + Type: model.PlatformProviderCloudRun, + CloudRunConfig: &PlatformProviderCloudRunConfig{ + Project: "gcp-project-id", + Region: "cloud-run-region", + CredentialsFile: "/etc/piped-secret/gcp-service-account.json", + }, + }, + { + Name: "lambda", + Type: model.PlatformProviderLambda, + LambdaConfig: &PlatformProviderLambdaConfig{ + Region: "us-east-1", + }, + }, + }, + AnalysisProviders: []PipedAnalysisProvider{ + { + Name: "prometheus-dev", + Type: model.AnalysisProviderPrometheus, + PrometheusConfig: &AnalysisProviderPrometheusConfig{ + Address: "https://your-prometheus.dev", + }, + }, + { + Name: "datadog-dev", + Type: model.AnalysisProviderDatadog, + DatadogConfig: &AnalysisProviderDatadogConfig{ + Address: "https://your-datadog.dev", + APIKeyFile: "/etc/piped-secret/datadog-api-key", + ApplicationKeyFile: "/etc/piped-secret/datadog-application-key", + APIKeyData: "datadog-api-key", + ApplicationKeyData: "datadog-application-key", + }, + }, + { + Name: "stackdriver-dev", + Type: model.AnalysisProviderStackdriver, + StackdriverConfig: &AnalysisProviderStackdriverConfig{ + ServiceAccountFile: "/etc/piped-secret/gcp-service-account.json", + }, + }, + }, + Notifications: Notifications{ + Routes: []NotificationRoute{ + { + Name: "dev-slack", + Labels: map[string]string{ + "env": "dev", + "team": "pipecd", + }, + Receiver: "dev-slack-channel", + }, + { + Name: "prod-slack", + Labels: map[string]string{ + "env": "dev", + }, + Events: []string{"DEPLOYMENT_TRIGGERED", "DEPLOYMENT_SUCCEEDED"}, + Receiver: "prod-slack-channel", + }, + { + Name: "all-events-to-ci", + Receiver: "ci-webhook", + }, + }, + Receivers: []NotificationReceiver{ + { + Name: "dev-slack-channel", + Slack: &NotificationReceiverSlack{ + HookURL: "https://slack.com/dev", + }, + }, + { + Name: "prod-slack-channel", + Slack: &NotificationReceiverSlack{ + HookURL: "https://slack.com/prod", + }, + }, + { + Name: "ci-webhook", + Webhook: &NotificationReceiverWebhook{ + URL: "https://pipecd.dev/dev-hook", + SignatureKey: "PipeCD-Signature", + SignatureValue: "random-signature-string", + }, + }, + }, + }, + SecretManagement: &SecretManagement{ + Type: model.SecretManagementTypeKeyPair, + KeyPair: &SecretManagementKeyPair{ + PrivateKeyFile: "/etc/piped-secret/pair-private-key", + PublicKeyFile: "/etc/piped-secret/pair-public-key", + }, + }, + EventWatcher: PipedEventWatcher{ + CheckInterval: Duration(10 * time.Minute), + GitRepos: []PipedEventWatcherGitRepo{ + { + RepoID: "repo-1", + CommitMessage: "Update values by Event watcher", + Includes: []string{"event-watcher-dev.yaml", "event-watcher-stg.yaml"}, + }, + }, + }, + }, + expectedSpec: &PipedSpec{ + ProjectID: "test-project", + PipedID: "test-piped", + PipedKeyFile: "etc/piped/key", + APIAddress: "your-pipecd.domain", + WebAddress: "https://your-pipecd.domain", + SyncInterval: Duration(time.Minute), + AppConfigSyncInterval: Duration(time.Minute), + Git: PipedGit{ + Username: "username", + Email: "username@email.com", + SSHKeyFile: "/etc/piped-secret/ssh-key", + Password: "Password", + }, + Repositories: []PipedRepository{ + { + RepoID: "repo1", + Remote: "git@github.com:org/repo1.git", + Branch: "master", + }, + { + RepoID: "repo2", + Remote: "git@github.com:org/repo2.git", + Branch: "master", + }, + }, + ChartRepositories: []HelmChartRepository{ + { + Type: HTTPHelmChartRepository, + Name: "fantastic-charts", + Address: "https://fantastic-charts.storage.googleapis.com", + }, + { + Type: HTTPHelmChartRepository, + Name: "private-charts", + Address: "https://private-charts.com", + Username: "basic-username", + Password: "basic-password", + Insecure: true, + }, + }, + ChartRegistries: []HelmChartRegistry{ + { + Type: OCIHelmChartRegistry, + Address: "registry.example.com", + Username: "sample-username", + Password: "sample-password", + }, + }, + PlatformProviders: []PipedPlatformProvider{ + { + Name: "kubernetes-default", + Type: model.PlatformProviderKubernetes, + KubernetesConfig: &PlatformProviderKubernetesConfig{ + MasterURL: "https://example.com", + KubeConfigPath: "/etc/kube/config", + AppStateInformer: KubernetesAppStateInformer{ + IncludeResources: []KubernetesResourceMatcher{ + { + APIVersion: "pipecd.dev/v1beta1", + }, + { + APIVersion: "networking.gke.io/v1beta1", + Kind: "ManagedCertificate", + }, + }, + ExcludeResources: []KubernetesResourceMatcher{ + { + APIVersion: "v1", + Kind: "Endpoints", + }, + }, + }, + }, + }, + { + Name: "kubernetes-dev", + Type: model.PlatformProviderKubernetes, + KubernetesConfig: &PlatformProviderKubernetesConfig{}, + }, + { + Name: "terraform", + Type: model.PlatformProviderTerraform, + TerraformConfig: &PlatformProviderTerraformConfig{ + Vars: []string{ + "project=gcp-project", + "region=us-centra1", + }, + }, + }, + { + Name: "cloudrun", + Type: model.PlatformProviderCloudRun, + CloudRunConfig: &PlatformProviderCloudRunConfig{ + Project: "gcp-project-id", + Region: "cloud-run-region", + CredentialsFile: "/etc/piped-secret/gcp-service-account.json", + }, + }, + { + Name: "lambda", + Type: model.PlatformProviderLambda, + LambdaConfig: &PlatformProviderLambdaConfig{ + Region: "us-east-1", + }, + }, + }, + AnalysisProviders: []PipedAnalysisProvider{ + { + Name: "prometheus-dev", + Type: model.AnalysisProviderPrometheus, + PrometheusConfig: &AnalysisProviderPrometheusConfig{ + Address: "https://your-prometheus.dev", + }, + }, + { + Name: "datadog-dev", + Type: model.AnalysisProviderDatadog, + DatadogConfig: &AnalysisProviderDatadogConfig{ + Address: "https://your-datadog.dev", + APIKeyFile: "/etc/piped-secret/datadog-api-key", + ApplicationKeyFile: "/etc/piped-secret/datadog-application-key", + APIKeyData: "datadog-api-key", + ApplicationKeyData: "datadog-application-key", + }, + }, + { + Name: "stackdriver-dev", + Type: model.AnalysisProviderStackdriver, + StackdriverConfig: &AnalysisProviderStackdriverConfig{ + ServiceAccountFile: "/etc/piped-secret/gcp-service-account.json", + }, + }, + }, + Notifications: Notifications{ + Routes: []NotificationRoute{ + { + Name: "dev-slack", + Labels: map[string]string{ + "env": "dev", + "team": "pipecd", + }, + Receiver: "dev-slack-channel", + }, + { + Name: "prod-slack", + Labels: map[string]string{ + "env": "dev", + }, + Events: []string{"DEPLOYMENT_TRIGGERED", "DEPLOYMENT_SUCCEEDED"}, + Receiver: "prod-slack-channel", + }, + { + Name: "all-events-to-ci", + Receiver: "ci-webhook", + }, + }, + Receivers: []NotificationReceiver{ + { + Name: "dev-slack-channel", + Slack: &NotificationReceiverSlack{ + HookURL: "https://slack.com/dev", + }, + }, + { + Name: "prod-slack-channel", + Slack: &NotificationReceiverSlack{ + HookURL: "https://slack.com/prod", + }, + }, + { + Name: "ci-webhook", + Webhook: &NotificationReceiverWebhook{ + URL: "https://pipecd.dev/dev-hook", + SignatureKey: "PipeCD-Signature", + SignatureValue: "random-signature-string", + }, + }, + }, + }, + SecretManagement: &SecretManagement{ + Type: model.SecretManagementTypeKeyPair, + KeyPair: &SecretManagementKeyPair{ + PrivateKeyFile: "/etc/piped-secret/pair-private-key", + PublicKeyFile: "/etc/piped-secret/pair-public-key", + }, + }, + EventWatcher: PipedEventWatcher{ + CheckInterval: Duration(10 * time.Minute), + GitRepos: []PipedEventWatcherGitRepo{ + { + RepoID: "repo-1", + CommitMessage: "Update values by Event watcher", + Includes: []string{"event-watcher-dev.yaml", "event-watcher-stg.yaml"}, + }, + }, + }, + }, + expectedError: nil, + }, + } + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + cloned, err := tc.originalSpec.Clone() + require.Equal(t, tc.expectedError, err) + if err == nil { + assert.Equal(t, tc.expectedSpec, cloned) + } + }) + } +} + +func TestFindPlatformProvidersByLabel(t *testing.T) { + pipedSpec := &PipedSpec{ + PlatformProviders: []PipedPlatformProvider{ + { + Name: "provider-1", + Type: model.PlatformProviderKubernetes, + Labels: map[string]string{ + "group": "group-1", + "foo": "foo-1", + }, + }, + { + Name: "provider-2", + Type: model.PlatformProviderKubernetes, + Labels: map[string]string{ + "group": "group-2", + "foo": "foo-2", + }, + }, + { + Name: "provider-3", + Type: model.PlatformProviderCloudRun, + Labels: map[string]string{ + "group": "group-1", + "foo": "foo-3", + }, + }, + { + Name: "provider-4", + Type: model.PlatformProviderKubernetes, + Labels: map[string]string{ + "group": "group-2", + "foo": "foo-4", + }, + }, + }, + } + + testcases := []struct { + name string + labels map[string]string + want []PipedPlatformProvider + }{ + { + name: "empty due to missing label", + labels: map[string]string{ + "group": "group-4", + }, + want: []PipedPlatformProvider{}, + }, + { + name: "found exactly one provider", + labels: map[string]string{ + "group": "group-1", + }, + want: []PipedPlatformProvider{ + { + Name: "provider-1", + Type: model.PlatformProviderKubernetes, + Labels: map[string]string{ + "group": "group-1", + "foo": "foo-1", + }, + }, + }, + }, + { + name: "found multiple providers", + labels: map[string]string{ + "group": "group-1", + }, + want: []PipedPlatformProvider{ + { + Name: "provider-1", + Type: model.PlatformProviderKubernetes, + Labels: map[string]string{ + "group": "group-1", + "foo": "foo-1", + }, + }, + }, + }, + } + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + got := pipedSpec.FindPlatformProvidersByLabels(tc.labels, model.ApplicationKind_KUBERNETES) + assert.Equal(t, tc.want, got) + }) + } +} + +func TestPipeGitValidate(t *testing.T) { + t.Parallel() + testcases := []struct { + name string + git PipedGit + err error + }{ + { + name: "Both SSH and Password are not valid", + git: PipedGit{ + SSHKeyData: "sshkey1", + Password: "Password", + }, + err: errors.New("cannot configure both sshKeyData or sshKeyFile and password authentication"), + }, + { + name: "Both SSH and Password is not valid", + git: PipedGit{ + SSHKeyFile: "sshkeyfile", + SSHKeyData: "sshkeydata", + Password: "Password", + }, + err: errors.New("cannot configure both sshKeyData or sshKeyFile and password authentication"), + }, + { + name: "SSH key data is not empty", + git: PipedGit{ + SSHKeyData: "sshkey2", + }, + err: nil, + }, + { + name: "SSH key file is not empty", + git: PipedGit{ + SSHKeyFile: "sshkey2", + }, + err: nil, + }, + { + name: "Both SSH file and data is not empty", + git: PipedGit{ + SSHKeyData: "sshkeydata", + SSHKeyFile: "sshkeyfile", + }, + err: errors.New("only either sshKeyFile or sshKeyData can be set"), + }, + { + name: "Password is valid", + git: PipedGit{ + Username: "Username", + Password: "Password", + }, + err: nil, + }, + { + name: "Username is empty", + git: PipedGit{ + Username: "", + Password: "Password", + }, + err: errors.New("both username and password must be set"), + }, + { + name: "Git config is empty", + git: PipedGit{}, + err: nil, + }, + } + for _, tc := range testcases { + tc := tc + t.Run(tc.git.SSHKeyData, func(t *testing.T) { + t.Parallel() + err := tc.git.Validate() + assert.Equal(t, tc.err, err) + }) + } +} diff --git a/pkg/configv1/replicas.go b/pkg/configv1/replicas.go new file mode 100644 index 0000000000..c27c3735a1 --- /dev/null +++ b/pkg/configv1/replicas.go @@ -0,0 +1,83 @@ +// Copyright 2024 The PipeCD 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 ( + "encoding/json" + "fmt" + "math" + "strconv" + "strings" +) + +type Replicas struct { + Number int + IsPercentage bool +} + +func (r Replicas) String() string { + s := strconv.FormatInt(int64(r.Number), 10) + if r.IsPercentage { + return s + "%" + } + return s +} + +func (r Replicas) Calculate(total, defaultValue int) int { + if r.Number == 0 { + return defaultValue + } + if !r.IsPercentage { + return r.Number + } + num := float64(r.Number*total) / 100.0 + return int(math.Ceil(num)) +} + +func (r Replicas) MarshalJSON() ([]byte, error) { + return json.Marshal(r.String()) +} + +func (r *Replicas) UnmarshalJSON(b []byte) error { + var v interface{} + if err := json.Unmarshal(b, &v); err != nil { + return err + } + switch raw := v.(type) { + case float64: + *r = Replicas{ + Number: int(raw), + IsPercentage: false, + } + return nil + case string: + replicas := Replicas{ + IsPercentage: false, + } + if strings.HasSuffix(raw, "%") { + replicas.IsPercentage = true + raw = strings.TrimSuffix(raw, "%") + } + value, err := strconv.Atoi(raw) + if err != nil { + return fmt.Errorf("invalid replicas: %v", err) + } + replicas.Number = value + *r = replicas + return nil + default: + return fmt.Errorf("invalid replicas: %v", string(b)) + } +} diff --git a/pkg/configv1/replicas_test.go b/pkg/configv1/replicas_test.go new file mode 100644 index 0000000000..c7eeb91a8c --- /dev/null +++ b/pkg/configv1/replicas_test.go @@ -0,0 +1,127 @@ +// Copyright 2024 The PipeCD 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 ( + "encoding/json" + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestReplicasMarshal(t *testing.T) { + type wrapper struct { + Replicas Replicas + } + + testcases := []struct { + name string + input wrapper + expected string + }{ + { + name: "normal number", + input: wrapper{ + Replicas{ + Number: 1, + IsPercentage: false, + }, + }, + expected: "{\"Replicas\":\"1\"}", + }, + { + name: "percentage number", + input: wrapper{ + Replicas{ + Number: 1, + IsPercentage: true, + }, + }, + expected: "{\"Replicas\":\"1%\"}", + }, + } + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + got, err := json.Marshal(tc.input) + require.NoError(t, err) + assert.Equal(t, tc.expected, string(got)) + }) + } +} + +func TestReplicasUnmarshal(t *testing.T) { + type wrapper struct { + Replicas Replicas + } + + testcases := []struct { + name string + input string + expected *wrapper + expectedErr error + }{ + { + name: "normal number", + input: "{\"Replicas\": 1}", + expected: &wrapper{ + Replicas{ + Number: 1, + IsPercentage: false, + }, + }, + expectedErr: nil, + }, + { + name: "normal number by string", + input: "{\"Replicas\":\"1\"}", + expected: &wrapper{ + Replicas{ + Number: 1, + IsPercentage: false, + }, + }, + expectedErr: nil, + }, + { + name: "percentage number", + input: "{\"Replicas\":\"1%\"}", + expected: &wrapper{ + Replicas{ + Number: 1, + IsPercentage: true, + }, + }, + expectedErr: nil, + }, + { + name: "wrong string format", + input: "{\"Replicas\":\"1a%\"}", + expected: nil, + expectedErr: fmt.Errorf("invalid replicas: strconv.Atoi: parsing \"1a\": invalid syntax"), + }, + } + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + got := &wrapper{} + err := json.Unmarshal([]byte(tc.input), got) + assert.Equal(t, tc.expectedErr, err) + if tc.expected != nil { + assert.Equal(t, tc.expected, got) + } + }) + } +} diff --git a/pkg/configv1/testdata/.pipe/README.md b/pkg/configv1/testdata/.pipe/README.md new file mode 100644 index 0000000000..8f2d9fb7da --- /dev/null +++ b/pkg/configv1/testdata/.pipe/README.md @@ -0,0 +1,4 @@ +## Samples of Shared Configuration + +This directory contains samples of defining Notification, MetricsTemplate and PipelineTemplate. +These files must be placed in `.pipe` directory of repository and they will be used across all applications in this repository. diff --git a/pkg/configv1/testdata/.pipe/analysis-template.yaml b/pkg/configv1/testdata/.pipe/analysis-template.yaml new file mode 100644 index 0000000000..6897806e43 --- /dev/null +++ b/pkg/configv1/testdata/.pipe/analysis-template.yaml @@ -0,0 +1,55 @@ +apiVersion: pipecd.dev/v1beta1 +kind: AnalysisTemplate +spec: + metrics: + app_http_error_percentage: + query: http_error_percentage{env={{ .App.Env }}, app={{ .App.Name }}} + expected: + max: 0.1 + interval: 1m + provider: datadog-dev + + container_cpu_usage_seconds_total: + interval: 10s + provider: prometheus-dev + failureLimit: 2 + expected: + max: 0.0001 + query: | + sum( + max(kube_pod_labels{label_app=~"{{ .App.Name }}", label_pipecd_dev_variant=~"canary"}) by (label_app, label_pipecd_dev_variant, pod) + * + on(pod) + group_right(label_app, label_pipecd_dev_variant) + label_replace( + sum by(pod_name) ( + rate(container_cpu_usage_seconds_total{namespace="default"}[5m]) + ), "pod", "$1", "pod_name", "(.+)" + ) + ) by (label_app, label_pipecd_dev_variant) + + grpc_error_rate-percentage: + interval: 1m + provider: prometheus-dev + failureLimit: 1 + expected: + max: 10 + query: | + 100 - sum( + rate( + grpc_server_handled_total{ + grpc_code!="OK", + kubernetes_namespace="{{ .Args.namespace }}", + kubernetes_pod_name=~"{{ .App.Name }}-[0-9a-zA-Z]+(-[0-9a-zA-Z]+)" + }[{{ .Args.interval }}] + ) + ) + / + sum( + rate( + grpc_server_started_total{ + kubernetes_namespace="{{ .Args.namespace }}", + kubernetes_pod_name=~"{{ .App.Name }}-[0-9a-zA-Z]+(-[0-9a-zA-Z]+)" + }[{{ .Args.interval }}] + ) + ) * 100 diff --git a/pkg/configv1/testdata/.pipe/event-watcher.yaml b/pkg/configv1/testdata/.pipe/event-watcher.yaml new file mode 100644 index 0000000000..fdbe3fd5fe --- /dev/null +++ b/pkg/configv1/testdata/.pipe/event-watcher.yaml @@ -0,0 +1,14 @@ +apiVersion: pipecd.dev/v1beta1 +kind: EventWatcher +spec: + events: + - name: app1-image-update + replacements: + - file: app1/deployment.yaml + yamlField: $.spec.template.spec.containers[0].image + - name: app2-helm-release + labels: + repoId: repo-1 + replacements: + - file: app2/.pipe.yaml + yamlField: $.spec.input.helmChart.version diff --git a/pkg/configv1/testdata/application/cloudrun-app-bluegreen.yaml b/pkg/configv1/testdata/application/cloudrun-app-bluegreen.yaml new file mode 100644 index 0000000000..d5faee008f --- /dev/null +++ b/pkg/configv1/testdata/application/cloudrun-app-bluegreen.yaml @@ -0,0 +1,21 @@ +# https://cloud.google.com/run/docs/rollouts-rollbacks-traffic-migration +apiVersion: pipecd.dev/v1beta1 +kind: CloudRunApp +spec: + input: + image: gcr.io/demo-project/demoapp:v1.0.0 + pipeline: + stages: + # Deploy workloads of the new version. + # But this is still receiving no traffic. + - name: CLOUDRUN_PROMOTE + # Change the traffic routing state where + # the new version will receive 100% of the traffic as soon as possible. + # This is known as blue-green strategy. + - name: CLOUDRUN_PROMOTE + with: + canary: 100 + # Optional: We can also add an ANALYSIS stage to verify the new version. + # If this stage finds any not good metrics of the new version, + # a rollback process to the previous version will be executed. + - name: ANALYSIS diff --git a/pkg/configv1/testdata/application/cloudrun-app-canary.yaml b/pkg/configv1/testdata/application/cloudrun-app-canary.yaml new file mode 100644 index 0000000000..0e2d8ff50a --- /dev/null +++ b/pkg/configv1/testdata/application/cloudrun-app-canary.yaml @@ -0,0 +1,26 @@ +# https://cloud.google.com/run/docs/rollouts-rollbacks-traffic-migration +apiVersion: pipecd.dev/v1beta1 +kind: CloudRunApp +spec: + input: + image: gcr.io/demo-project/demoapp:v1.0.0 + pipeline: + stages: + # Deploy workloads of the new version. + # But this is still receiving no traffic. + - name: CLOUDRUN_PROMOTE + # Change the traffic routing state where + # the new version will receive the specified percentage of traffic. + # This is known as multi-phase canary strategy. + - name: CLOUDRUN_PROMOTE + with: + canary: 10 + # Optional: We can also add an ANALYSIS stage to verify the new version. + # If this stage finds any not good metrics of the new version, + # a rollback process to the previous version will be executed. + - name: ANALYSIS + # Change the traffic routing state where + # thre new version will receive 100% of the traffic. + - name: CLOUDRUN_PROMOTE + with: + canary: 100 diff --git a/pkg/configv1/testdata/application/cloudrun-app.yaml b/pkg/configv1/testdata/application/cloudrun-app.yaml new file mode 100644 index 0000000000..ce274c98fc --- /dev/null +++ b/pkg/configv1/testdata/application/cloudrun-app.yaml @@ -0,0 +1,2 @@ +apiVersion: pipecd.dev/v1beta1 +kind: CloudRunApp diff --git a/pkg/configv1/testdata/application/custom-sync-without-run.yaml b/pkg/configv1/testdata/application/custom-sync-without-run.yaml new file mode 100644 index 0000000000..3f9fcdc61d --- /dev/null +++ b/pkg/configv1/testdata/application/custom-sync-without-run.yaml @@ -0,0 +1,11 @@ +apiVersion: pipecd.dev/v1beta1 +kind: LambdaApp +spec: + pipeline: + stages: + - name: CUSTOM_SYNC + desc: "deploy by sam" + with: + timeout: 6h + envs: + AWS_PROFILE: default diff --git a/pkg/configv1/testdata/application/custom-sync.yaml b/pkg/configv1/testdata/application/custom-sync.yaml new file mode 100644 index 0000000000..8d65a8e1fa --- /dev/null +++ b/pkg/configv1/testdata/application/custom-sync.yaml @@ -0,0 +1,14 @@ +apiVersion: pipecd.dev/v1beta1 +kind: LambdaApp +spec: + pipeline: + stages: + - name: CUSTOM_SYNC + desc: "deploy by sam" + with: + timeout: 6h + envs: + AWS_PROFILE: default + run: | + sam build + sam deploy -g --profile $AWS_PROFILE diff --git a/pkg/configv1/testdata/application/ecs-app-invalid-access-type.yaml b/pkg/configv1/testdata/application/ecs-app-invalid-access-type.yaml new file mode 100644 index 0000000000..4f41eb974e --- /dev/null +++ b/pkg/configv1/testdata/application/ecs-app-invalid-access-type.yaml @@ -0,0 +1,7 @@ +apiVersion: pipecd.dev/v1beta1 +kind: ECSApp +spec: + input: + serviceDefinitionFile: /path/to/servicedef.yaml + taskDefinitionFile: /path/to/taskdef.yaml + accessType: XXX \ No newline at end of file diff --git a/pkg/configv1/testdata/application/ecs-app-service-discovery.yaml b/pkg/configv1/testdata/application/ecs-app-service-discovery.yaml new file mode 100644 index 0000000000..cc9da20611 --- /dev/null +++ b/pkg/configv1/testdata/application/ecs-app-service-discovery.yaml @@ -0,0 +1,7 @@ +apiVersion: pipecd.dev/v1beta1 +kind: ECSApp +spec: + input: + serviceDefinitionFile: /path/to/servicedef.yaml + taskDefinitionFile: /path/to/taskdef.yaml + accessType: SERVICE_DISCOVERY \ No newline at end of file diff --git a/pkg/configv1/testdata/application/ecs-app.yaml b/pkg/configv1/testdata/application/ecs-app.yaml new file mode 100644 index 0000000000..95f8b3f1ce --- /dev/null +++ b/pkg/configv1/testdata/application/ecs-app.yaml @@ -0,0 +1,11 @@ +apiVersion: pipecd.dev/v1beta1 +kind: ECSApp +spec: + input: + serviceDefinitionFile: /path/to/servicedef.yaml + taskDefinitionFile: /path/to/taskdef.yaml + targetGroups: + primary: + targetGroupArn: arn:aws:elasticloadbalancing:xyz + containerName: web + containerPort: 80 diff --git a/pkg/configv1/testdata/application/generic-analysis.yaml b/pkg/configv1/testdata/application/generic-analysis.yaml new file mode 100644 index 0000000000..d601e0f5ed --- /dev/null +++ b/pkg/configv1/testdata/application/generic-analysis.yaml @@ -0,0 +1,39 @@ +apiVersion: pipecd.dev/v1beta1 +kind: KubernetesApp +spec: + pipeline: + stages: + - name: ANALYSIS + with: + duration: 10m + metrics: + - query: grpc_error_percentage + expected: + max: 0.1 + interval: 1m + failureLimit: 1 + provider: prometheus-dev + - query: grpc_succeed_percentage + expected: + min: 0.9 + interval: 1m + failureLimit: 1 + provider: prometheus-dev + - name: ANALYSIS + with: + duration: 10m + logs: + - query: | + resource.labels.pod_id="pod1" + interval: 1m + failureLimit: 3 + provider: stackdriver-dev + - name: ANALYSIS + with: + duration: 10m + https: + - url: https://canary-endpoint.dev + method: GET + expectedCode: 200 + failureLimit: 1 + interval: 1m diff --git a/pkg/configv1/testdata/application/generic-postsync.yaml b/pkg/configv1/testdata/application/generic-postsync.yaml new file mode 100644 index 0000000000..0f1726f13b --- /dev/null +++ b/pkg/configv1/testdata/application/generic-postsync.yaml @@ -0,0 +1,11 @@ +apiVersion: pipecd.dev/v1beta1 +kind: KubernetesApp +spec: + postSync: + chain: + applications: + - name: app-1 + - labels: + env: staging + foo: bar + - kind: ECSApp diff --git a/pkg/configv1/testdata/application/generic-trigger.yaml b/pkg/configv1/testdata/application/generic-trigger.yaml new file mode 100644 index 0000000000..2b1aea2f75 --- /dev/null +++ b/pkg/configv1/testdata/application/generic-trigger.yaml @@ -0,0 +1,7 @@ +apiVersion: pipecd.dev/v1beta1 +kind: KubernetesApp +spec: + trigger: + onCommit: + paths: + - deployment.yaml diff --git a/pkg/configv1/testdata/application/k8s-app-bluegreen-with-analysis.yaml b/pkg/configv1/testdata/application/k8s-app-bluegreen-with-analysis.yaml new file mode 100644 index 0000000000..006b3a353d --- /dev/null +++ b/pkg/configv1/testdata/application/k8s-app-bluegreen-with-analysis.yaml @@ -0,0 +1,28 @@ +# Pipeline for a Kubernetes application. +# This makes a progressive delivery with BlueGreen strategy. +# This also has a ANALYSIS stage for running smoke test againts the stage. +apiVersion: pipecd.dev/v1beta1 +kind: KubernetesApp +spec: + pipeline: + stages: + - name: K8S_CANARY_ROLLOUT + with: + replicas: 100% + - name: ANALYSIS + with: + duration: 10m + failureLimit: 2 + https: + - template: + name: http_stage_check + - name: K8S_TRAFFIC_ROUTING + with: + all: canary + - name: K8S_PRIMARY_ROLLOUT + - name: K8S_TRAFFIC_ROUTING + with: + all: primary + - name: K8S_CANARY_CLEAN + trafficRouting: + method: pod diff --git a/pkg/configv1/testdata/application/k8s-app-bluegreen.yaml b/pkg/configv1/testdata/application/k8s-app-bluegreen.yaml new file mode 100644 index 0000000000..8f6d2b1c3b --- /dev/null +++ b/pkg/configv1/testdata/application/k8s-app-bluegreen.yaml @@ -0,0 +1,28 @@ +# Pipeline for a Kubernetes application. +# This makes a progressive delivery with BlueGreen strategy. +apiVersion: pipecd.dev/v1beta1 +kind: KubernetesApp +spec: + description: | + application description first string + application description second string + planner: + alwaysUsePipeline: true + trigger: + onOutOfSync: + disabled: true + pipeline: + stages: + - name: K8S_CANARY_ROLLOUT + with: + replicas: 100% + - name: K8S_TRAFFIC_ROUTING + with: + canary: 100 + - name: K8S_PRIMARY_ROLLOUT + - name: K8S_TRAFFIC_ROUTING + with: + primary: 100 + - name: K8S_CANARY_CLEAN + trafficRouting: + method: podselector diff --git a/pkg/configv1/testdata/application/k8s-app-canary.yaml b/pkg/configv1/testdata/application/k8s-app-canary.yaml new file mode 100644 index 0000000000..a244944114 --- /dev/null +++ b/pkg/configv1/testdata/application/k8s-app-canary.yaml @@ -0,0 +1,298 @@ +# Progressive delivery with canary strategy. +apiVersion: pipecd.dev/v1beta1 +kind: KubernetesApp +spec: + pipeline: + stages: + # Deploy the workloads of CANARY variant. In this case, the number of + # workload replicas of CANARY variant is 10% of the replicas number of PRIMARY variant. + - name: K8S_CANARY_ROLLOUT + with: + replicas: 10% + # Update the workload of PRIMARY variant to the new version. + - name: K8S_PRIMARY_ROLLOUT + # Destroy all workloads of CANARY variant. + - name: K8S_CANARY_CLEAN + +--- +# Progressive delivery with canary strategy. +# This also adds an Approval stage to wait until got +# an approval from one of the specified approvers. +apiVersion: pipecd.dev/v1beta1 +kind: KubernetesApp +spec: + pipeline: + stages: + - name: K8S_CANARY_ROLLOUT + with: + replicas: 2 + - name: WAIT_APPROVAL + with: + approvers: + - user-foo + - user-bar + - name: K8S_PRIMARY_ROLLOUT + - name: K8S_CANARY_CLEAN + +--- +# Progressive delivery with canary strategy. +# This has an Analysis stage for verifying the deployment process. +# The analysis is just based on the metrics, log, http response from canary version. +apiVersion: pipecd.dev/v1beta1 +kind: KubernetesApp +spec: + pipeline: + stages: + - name: K8S_CANARY_ROLLOUT + with: + replicas: 10% + - name: ANALYSIS + with: + duration: 10m + metrics: + - query: grpc_error_percentage + expected: + max: 0.1 + interval: 1m + failureLimit: 1 + provider: prometheus-dev + logs: + - query: | + resource.type="k8s_container" + resource.labels.cluster_name="cluster-1" + resource.labels.namespace_name="stg" + resource.labels.pod_id="pod1" + interval: 1m + failureLimit: 3 + provider: stackdriver-dev + https: + - url: https://canary-endpoint.dev + method: GET + expectedCode: 200 + failureLimit: 1 + interval: 1m + - name: K8S_PRIMARY_ROLLOUT + - name: K8S_CANARY_CLEAN + +--- +# Progressive delivery with canary strategy. +# The canary process has multiple phases: from 10% then analysis +# then up to 20% then analysis then 100%. +apiVersion: pipecd.dev/v1beta1 +kind: KubernetesApp +spec: + pipeline: + stages: + - name: K8S_CANARY_ROLLOUT + with: + replicas: 10% + - name: ANALYSIS + with: + duration: 10m + - name: K8S_CANARY_ROLLOUT + with: + replicas: 20% + - name: ANALYSIS + with: + duration: 10m + - name: K8S_PRIMARY_ROLLOUT + - name: K8S_CANARY_CLEAN + +--- +# Progressive delivery with canary strategy. +# This has an Analysis stage for verifying the deployment process. +# The analysis stage is configured to use metrics templates at .pipe directory. +apiVersion: pipecd.dev/v1beta1 +kind: KubernetesApp +spec: + pipeline: + stages: + - name: K8S_CANARY_ROLLOUT + with: + replicas: 10% + - name: ANALYSIS + with: + duration: 10m + metrics: + - template: + name: prometheus_grpc_error_percentage + - template: + name: prometheus_grpc_error_percentage + logs: + - template: + name: stackdriver_log_error + https: + - template: + name: http_canary_check + - name: K8S_PRIMARY_ROLLOUT + - name: K8S_CANARY_CLEAN + +--- +# Progressive delivery with canary strategy. +# This has an Analysis stage for verifying the deployment process. +# The analysis stage is configured to use metrics with custom args. +apiVersion: pipecd.dev/v1beta1 +kind: KubernetesApp +spec: + pipeline: + stages: + - name: K8S_CANARY_ROLLOUT + with: + replicas: 10% + - name: ANALYSIS + with: + duration: 10m + metrics: + - template: + name: grpc_error_rate_percentage + args: + namespace: default + - name: K8S_PRIMARY_ROLLOUT + - name: K8S_CANARY_CLEAN + +--- +# Canary deployment that has an analysis stage to verify canary. +# This deploys both canary and baseline version. +# The baseline pod is a pod that is based on our currently running production version. +# We want to collect metrics against a “new” copy of our old container so +# we don’t muddy the waters testing against a pod that might have been running for a long time. +# The analysis stage is based on the comparision between baseline and stage workloads. +apiVersion: pipecd.dev/v1beta1 +kind: KubernetesApp +spec: + pipeline: + stages: + - name: K8S_BASELINE_ROLLOUT + with: + replicas: 10% + - name: K8S_CANARY_ROLLOUT + with: + replicas: 10% + - name: ANALYSIS + with: + duration: 10m + - name: K8S_PRIMARY_ROLLOUT + - name: K8S_BASELINE_CLEAN + - name: K8S_CANARY_CLEAN + +# Progressive delivery with canary strategy. +# This has an Analysis stage for verifying the deployment process. +# This is run the analysis with dynamic data as well as one with static data. +apiVersion: pipecd.dev/v1beta1 +kind: KubernetesApp +spec: + pipeline: + stages: + - name: K8S_CANARY_ROLLOUT + with: + replicas: 10% + - name: ANALYSIS + with: + duration: 10m + metrics: + - template: + name: prometheus_grpc_error_percentage + logs: + - template: + name: stackdriver_log_error + https: + - template: + name: http_canary_check + dynamic: + metrics: + - query: grpc_error_percentage + provider: prometheus-dev + #sensitivity: SENSITIVE + logs: + - query: | + resource.type="k8s_container" + resource.labels.cluster_name="cluster-1" + resource.labels.namespace_name="stg" + provider: stackdriver-dev + https: + - url: https://canary-endpoint.dev + method: GET + expectedCode: 200 + interval: 1m + - name: K8S_PRIMARY_ROLLOUT + - name: K8S_CANARY_CLEAN + +# Stage represents a temporary desired state for the application. +# Users can declarative a list of stages to archive the final desired state. +# This is a pod that is based on our currently running production version. +# We want to collect metrics against a “new” copy of our old container so +# we don’t muddy the waters testing against a pod that might have been running for a long time. +# https://www.spinnaker.io/guides/user/canary/best-practices/#compare-canary-against-baseline-not-against-production +# K8S_BASELINE_ROLLOUT + +# Requirements: +# Multiple canary stages +# Automated analysis +# - between baseline and canary +# - based on metrics, logs of only canary +# Various targets: deployment, daemonset, statefulset + +# # List of deployments for the same commit +# # that must be succeeded before running the deployment for this application. +# requireDeployments: +# - app: demoapp +# env: dev +# - app: anotherapp +# # Make a pull request to promote other applicationzwww +# # (or promote changes through environments of the same application) +# # after the success of this deployment. +# promote: +# - app: demoapp +# env: prod +# transforms: +# - source: pipe.yaml +# destination: pipe.yaml +# regex: git@github.com:org/config-repo.git:charts/demoapp?ref=(.*) +# replacement: git@github.com:org/config-repo.git:charts/demoapp?ref={{ $1 }} +# pullRequest: +# title: Update demoapp service in prod +# commit: Update demo app service in prod +# desc: | +# Update demoapp service to {{ .App.Input.Version }} + +--- +# Progressive delivery with canary strategy. +apiVersion: pipecd.dev/v1beta1 +kind: KubernetesApp +spec: + pipeline: + stages: + - name: K8S_CANARY_ROLLOUT + with: + replicas: 10% + patches: + - target: + kind: ConfigMap + name: envoy-config + documentRoot: $.data.envoy-config + yamlOps: + - op: replace + path: $.resources[0].virtual_hosts[0].routes[0].route.weighted_clusters.clusters[0].weight + value: 50 + - op: replace + path: $.resources[0].virtual_hosts[0].routes[0].route.weighted_clusters.clusters[1].weight + value: 50 + + - name: K8S_CANARY_ROLLOUT + with: + replicas: 10% + patches: + - target: + kind: ConfigMap + name: envoy-config + documentRoot: $.data.envoy-config + yamlOps: + - op: replace + path: $.resources[0].virtual_hosts[0].routes[0].route.weighted_clusters.clusters[0].weight + value: 10 + - op: replace + path: $.resources[0].virtual_hosts[0].routes[0].route.weighted_clusters.clusters[1].weight + value: 90 + + - name: K8S_PRIMARY_ROLLOUT + - name: K8S_CANARY_CLEAN diff --git a/pkg/configv1/testdata/application/k8s-app-envoy-bluegreen.yaml b/pkg/configv1/testdata/application/k8s-app-envoy-bluegreen.yaml new file mode 100644 index 0000000000..e90a8c8041 --- /dev/null +++ b/pkg/configv1/testdata/application/k8s-app-envoy-bluegreen.yaml @@ -0,0 +1,16 @@ +apiVersion: pipecd.dev/v1beta1 +kind: KubernetesApp +spec: + pipeline: + stages: + - name: K8S_CANARY_ROLLOUT + with: + replicas: 100% + - name: K8S_TRAFFIC_ROUTING + with: + all: canary + - name: K8S_PRIMARY_ROLLOUT + - name: K8S_TRAFFIC_ROUTING + with: + all: primary + - name: K8S_CANARY_CLEAN diff --git a/pkg/configv1/testdata/application/k8s-app-envoy-canary.yaml b/pkg/configv1/testdata/application/k8s-app-envoy-canary.yaml new file mode 100644 index 0000000000..6d0dc734ed --- /dev/null +++ b/pkg/configv1/testdata/application/k8s-app-envoy-canary.yaml @@ -0,0 +1,16 @@ +apiVersion: pipecd.dev/v1beta1 +kind: KubernetesApp +spec: + pipeline: + stages: + - name: K8S_CANARY_ROLLOUT + with: + replicas: 10% + - name: K8S_TRAFFIC_ROUTING + with: + canary: 10 + - name: K8S_PRIMARY_ROLLOUT + - name: K8S_TRAFFIC_ROUTING + with: + primary: 100 + - name: K8S_CANARY_CLEAN diff --git a/pkg/configv1/testdata/application/k8s-app-helm.yaml b/pkg/configv1/testdata/application/k8s-app-helm.yaml new file mode 100644 index 0000000000..92569a6fe7 --- /dev/null +++ b/pkg/configv1/testdata/application/k8s-app-helm.yaml @@ -0,0 +1,38 @@ +apiVersion: pipecd.dev/v1beta1 +kind: KubernetesApp +spec: + input: + # Helm chart sourced from current Git repo. + helmChart: + path: charts/demoapp + helmValueFiles: + - values.yaml + helmVersion: 3.1.1 + +--- +apiVersion: pipecd.dev/v1beta1 +kind: KubernetesApp +spec: + input: + # Helm chart sourced from another Git repo. + helmChart: + git: git@github.com:org/chart-repo.git + path: charts/demoapp + ref: v1.0.0 + helmValueFiles: + - values.yaml + helmVersion: 3.1.1 + +--- +apiVersion: pipecd.dev/v1beta1 +kind: KubernetesApp +spec: + input: + # Helm chart sourced from a Helm repository. + helmChart: + repository: https://helm.com/stable + name: demoapp + version: 1.0.0 + helmValueFiles: + - values.yaml + helmVersion: 3.1.1 diff --git a/pkg/configv1/testdata/application/k8s-app-istio-bluegreen.yaml b/pkg/configv1/testdata/application/k8s-app-istio-bluegreen.yaml new file mode 100644 index 0000000000..0655b3a509 --- /dev/null +++ b/pkg/configv1/testdata/application/k8s-app-istio-bluegreen.yaml @@ -0,0 +1,41 @@ +apiVersion: pipecd.dev/v1beta1 +kind: KubernetesApp +spec: + pipeline: + stages: + # Deploy the workloads of CANARY variant. In this case, the number of + # workload replicas of CANARY variant is the same with PRIMARY variant. + - name: K8S_CANARY_ROLLOUT + with: + replicas: 100% + # The percentage of traffic each variant should receive. + # In this case, CANARY variant will receive all of the traffic. + - name: K8S_TRAFFIC_ROUTING + with: + all: canary + # Update the workload of PRIMARY variant to the new version. + - name: K8S_PRIMARY_ROLLOUT + # The percentage of traffic each variant should receive. + # In this case, PRIMARY variant will receive all of the traffic. + - name: K8S_TRAFFIC_ROUTING + with: + all: primary + # Destroy all workloads of CANARY variant. + - name: K8S_CANARY_CLEAN + # Specify application service. + service: + name: demoapp + # Specify application workloads. + workloads: + - name: demoapp + # Configuration for CANARY variant. + canaryVariant: + suffix: canary + createService: true + # Configuration for BASELINE variant. + baselineVariant: + suffix: baseline + createService: true + # Configuration for traffic splitting. + trafficRouting: + method: istio # pod (change label in service to switch traffic), smi, envoy diff --git a/pkg/configv1/testdata/application/k8s-app-istio-canary.yaml b/pkg/configv1/testdata/application/k8s-app-istio-canary.yaml new file mode 100644 index 0000000000..109ad87927 --- /dev/null +++ b/pkg/configv1/testdata/application/k8s-app-istio-canary.yaml @@ -0,0 +1,56 @@ +# Progressive delivery with canary strategy. +apiVersion: pipecd.dev/v1beta1 +kind: KubernetesApp +spec: + pipeline: + stages: + # Deploy the workloads of CANARY variant. In this case, the number of + # workload replicas of CANARY variant is 10% of the replicas number of PRIMARY variant. + - name: K8S_CANARY_ROLLOUT + with: + replicas: 10% + # The percentage of traffic each variant should receive. + # In this case, CANARY variant will receive 10% of traffic, + # while PRIMARY will receive 90% of traffic. + - name: K8S_TRAFFIC_ROUTING + with: + canary: 10 + # Update the workload of PRIMARY variant to the new version. + - name: K8S_PRIMARY_ROLLOUT + # The percentage of traffic each variant should receive. + # In this case, PRIMARY variant will receive all of the traffic. + - name: K8S_TRAFFIC_ROUTING + with: + primary: 100 + # Destroy all workloads of CANARY variant. + - name: K8S_CANARY_CLEAN + +--- +# Progressive delivery with canary strategy. +# The canary process has multiple phases: from 10% then analysis +# then up to 20% then analysis then 100%. +apiVersion: pipecd.dev/v1beta1 +kind: KubernetesApp +spec: + pipeline: + stages: + - name: K8S_CANARY_ROLLOUT + with: + replicas: 20% + - name: K8S_TRAFFIC_ROUTING + with: + canary: 10 + - name: ANALYSIS + with: + duration: 10m + - name: K8S_TRAFFIC_ROUTING + with: + canary: 20 + - name: ANALYSIS + with: + duration: 10m + - name: K8S_PRIMARY_ROLLOUT + - name: K8S_TRAFFIC_ROUTING + with: + primary: 100 + - name: K8S_CANARY_CLEAN diff --git a/pkg/configv1/testdata/application/k8s-app-kustomization.yaml b/pkg/configv1/testdata/application/k8s-app-kustomization.yaml new file mode 100644 index 0000000000..7632fa0a91 --- /dev/null +++ b/pkg/configv1/testdata/application/k8s-app-kustomization.yaml @@ -0,0 +1,5 @@ +apiVersion: pipecd.dev/v1beta1 +kind: KubernetesApp +spec: + input: + kubectlVersion: 3.1.1 diff --git a/pkg/configv1/testdata/application/k8s-app-resource-route.yaml b/pkg/configv1/testdata/application/k8s-app-resource-route.yaml new file mode 100644 index 0000000000..38479dafd7 --- /dev/null +++ b/pkg/configv1/testdata/application/k8s-app-resource-route.yaml @@ -0,0 +1,16 @@ +apiVersion: pipecd.dev/v1beta1 +kind: KubernetesApp +spec: + resourceRoutes: + - match: + kind: Ingress + provider: + name: ConfigCluster + - match: + kind: Service + name: Foo + provider: + name: ConfigCluster + - provider: + labels: + group: workload diff --git a/pkg/configv1/testdata/application/k8s-app-use-pipeline-template.yaml b/pkg/configv1/testdata/application/k8s-app-use-pipeline-template.yaml new file mode 100644 index 0000000000..ad409367e4 --- /dev/null +++ b/pkg/configv1/testdata/application/k8s-app-use-pipeline-template.yaml @@ -0,0 +1,5 @@ +apiVersion: pipecd.dev/v1beta1 +kind: KubernetesApp +spec: + pipeline: + useTemplate: k8s-canary-with-analysis diff --git a/pkg/configv1/testdata/application/k8s-plain-yaml.yaml b/pkg/configv1/testdata/application/k8s-plain-yaml.yaml new file mode 100644 index 0000000000..f7b99f70b4 --- /dev/null +++ b/pkg/configv1/testdata/application/k8s-plain-yaml.yaml @@ -0,0 +1,9 @@ +apiVersion: pipecd.dev/v1beta1 +kind: KubernetesApp +spec: + input: + manifests: + - demoapp-deployment.yaml + kubectlVersion: 2.1.1 + sealedSecrets: + - path: sealed-secret.yaml diff --git a/pkg/configv1/testdata/application/lambda-app-bluegreen.yaml b/pkg/configv1/testdata/application/lambda-app-bluegreen.yaml new file mode 100644 index 0000000000..faed917016 --- /dev/null +++ b/pkg/configv1/testdata/application/lambda-app-bluegreen.yaml @@ -0,0 +1,16 @@ +# Using version, alias, additional version to do canary, bluegreen. +# https://docs.aws.amazon.com/lambda/latest/dg/configuration-aliases.html +apiVersion: pipecd.dev/v1beta1 +kind: LambdaApp +spec: + pipeline: + stages: + # Deploy workloads of the new version. + # But this is still receiving no traffic. + - name: LAMBDA_CANARY_ROLLOUT + # Change the traffic routing state where + # the new version will receive 100% of the traffic as soon as possible. + # This is known as blue-green strategy. + - name: LAMBDA_PROMOTE + with: + percent: 100 diff --git a/pkg/configv1/testdata/application/lambda-app-canary.yaml b/pkg/configv1/testdata/application/lambda-app-canary.yaml new file mode 100644 index 0000000000..4f581ad867 --- /dev/null +++ b/pkg/configv1/testdata/application/lambda-app-canary.yaml @@ -0,0 +1,21 @@ +# Using version, alias, additional version to do canary, bluegreen. +# https://docs.aws.amazon.com/lambda/latest/dg/configuration-aliases.html +apiVersion: pipecd.dev/v1beta1 +kind: LambdaApp +spec: + pipeline: + stages: + # Deploy workloads of the new version. + # But this is still receiving no traffic. + - name: LAMBDA_CANARY_ROLLOUT + # Change the traffic routing state where + # the new version will receive the specified percentage of traffic. + # This is known as multi-phase canary strategy. + - name: LAMBDA_PROMOTE + with: + percent: 10 + # Change the traffic routing state where + # thre new version will receive 100% of the traffic. + - name: LAMBDA_PROMOTE + with: + percent: 100 diff --git a/pkg/configv1/testdata/application/lambda-app.yaml b/pkg/configv1/testdata/application/lambda-app.yaml new file mode 100644 index 0000000000..34c9394f08 --- /dev/null +++ b/pkg/configv1/testdata/application/lambda-app.yaml @@ -0,0 +1,2 @@ +apiVersion: pipecd.dev/v1beta1 +kind: LambdaApp diff --git a/pkg/configv1/testdata/application/terraform-app-empty.yaml b/pkg/configv1/testdata/application/terraform-app-empty.yaml new file mode 100644 index 0000000000..105b69b066 --- /dev/null +++ b/pkg/configv1/testdata/application/terraform-app-empty.yaml @@ -0,0 +1,2 @@ +apiVersion: pipecd.dev/v1beta1 +kind: TerraformApp diff --git a/pkg/configv1/testdata/application/terraform-app-secret-management.yaml b/pkg/configv1/testdata/application/terraform-app-secret-management.yaml new file mode 100644 index 0000000000..d14876e383 --- /dev/null +++ b/pkg/configv1/testdata/application/terraform-app-secret-management.yaml @@ -0,0 +1,14 @@ +apiVersion: pipecd.dev/v1beta1 +kind: TerraformApp +spec: + input: + workspace: dev + terraformVersion: 0.12.23 + trigger: + onOutOfSync: + disabled: false + encryption: + encryptedSecrets: + serviceAccount: ENCRYPTED_DATA_GENERATED_FROM_WEB + decryptionTargets: + - service-account.yaml diff --git a/pkg/configv1/testdata/application/terraform-app-with-approval.yaml b/pkg/configv1/testdata/application/terraform-app-with-approval.yaml new file mode 100644 index 0000000000..23c638ce99 --- /dev/null +++ b/pkg/configv1/testdata/application/terraform-app-with-approval.yaml @@ -0,0 +1,37 @@ +apiVersion: pipecd.dev/v1beta1 +kind: TerraformApp +spec: + input: + workspace: dev + terraformVersion: 0.12.23 + pipeline: + stages: + - name: TERRAFORM_PLAN + - name: WAIT_APPROVAL + with: + approvers: + - foo + - bar + - name: TERRAFORM_APPLY + +#--- +# apiVersion: pipecd.dev/v1beta1 +# kind: TerraformApp +# spec: +# input: +# terraformVersion: 0.12.23 +# pipeline: +# stages: +# - name: TERRAFORM_PLAN +# with: +# workspace: dev +# - name: TERRAFORM_APPLY +# with: +# workspace: dev +# - name: WAIT_APPROVAL +# - name: TERRAFORM_PLAN +# with: +# workspace: prod +# - name: TERRAFORM_APPLY +# with: +# workspace: prod diff --git a/pkg/configv1/testdata/application/terraform-app-with-exit.yaml b/pkg/configv1/testdata/application/terraform-app-with-exit.yaml new file mode 100644 index 0000000000..194643c12f --- /dev/null +++ b/pkg/configv1/testdata/application/terraform-app-with-exit.yaml @@ -0,0 +1,39 @@ +apiVersion: pipecd.dev/v1beta1 +kind: TerraformApp +spec: + input: + workspace: dev + terraformVersion: 0.12.23 + pipeline: + stages: + - name: TERRAFORM_PLAN + with: + exitOnNoChanges: true + - name: WAIT_APPROVAL + with: + approvers: + - foo + - bar + - name: TERRAFORM_APPLY + +#--- +# apiVersion: pipecd.dev/v1beta1 +# kind: TerraformApp +# spec: +# input: +# terraformVersion: 0.12.23 +# pipeline: +# stages: +# - name: TERRAFORM_PLAN +# with: +# workspace: dev +# - name: TERRAFORM_APPLY +# with: +# workspace: dev +# - name: WAIT_APPROVAL +# - name: TERRAFORM_PLAN +# with: +# workspace: prod +# - name: TERRAFORM_APPLY +# with: +# workspace: prod diff --git a/pkg/configv1/testdata/application/terraform-app.yaml b/pkg/configv1/testdata/application/terraform-app.yaml new file mode 100644 index 0000000000..26719e2582 --- /dev/null +++ b/pkg/configv1/testdata/application/terraform-app.yaml @@ -0,0 +1,6 @@ +apiVersion: pipecd.dev/v1beta1 +kind: TerraformApp +spec: + input: + workspace: dev + terraformVersion: 0.12.23 diff --git a/pkg/configv1/testdata/application/truebydefaultbool-false-explicitly.yaml b/pkg/configv1/testdata/application/truebydefaultbool-false-explicitly.yaml new file mode 100644 index 0000000000..d54005e372 --- /dev/null +++ b/pkg/configv1/testdata/application/truebydefaultbool-false-explicitly.yaml @@ -0,0 +1,8 @@ +apiVersion: pipecd.dev/v1beta1 +kind: KubernetesApp +spec: + trigger: + onOutOfSync: + disabled: false + input: + autoRollback: false diff --git a/pkg/configv1/testdata/application/truebydefaultbool-not-specified.yaml b/pkg/configv1/testdata/application/truebydefaultbool-not-specified.yaml new file mode 100644 index 0000000000..83c7cd1f96 --- /dev/null +++ b/pkg/configv1/testdata/application/truebydefaultbool-not-specified.yaml @@ -0,0 +1,2 @@ +apiVersion: pipecd.dev/v1beta1 +kind: KubernetesApp diff --git a/pkg/configv1/testdata/application/truebydefaultbool-true-explicitly.yaml b/pkg/configv1/testdata/application/truebydefaultbool-true-explicitly.yaml new file mode 100644 index 0000000000..5862ab5e1f --- /dev/null +++ b/pkg/configv1/testdata/application/truebydefaultbool-true-explicitly.yaml @@ -0,0 +1,8 @@ +apiVersion: pipecd.dev/v1beta1 +kind: KubernetesApp +spec: + trigger: + onOutOfSync: + disabled: true + input: + autoRollback: true diff --git a/pkg/configv1/testdata/control-plane/control-plane-config.yaml b/pkg/configv1/testdata/control-plane/control-plane-config.yaml new file mode 100644 index 0000000000..721c55280b --- /dev/null +++ b/pkg/configv1/testdata/control-plane/control-plane-config.yaml @@ -0,0 +1,39 @@ +apiVersion: pipecd.dev/v1beta1 +kind: ControlPlane +spec: + projects: + - id: abc + staticAdmin: + username: test-user + passwordHash: test-password + + sharedSSOConfigs: + - name: github + provider: GITHUB + github: + clientId: client-id + clientSecret: client-secret + baseUrl: base-url + uploadUrl: upload-url + + datastore: + type: FIRESTORE + config: + namespace: pipecd-test + environment: unit-test + project: project + credentialsFile: "datastore-credentials-file.json" + + filestore: + type: GCS + config: + bucket: bucket + credentialsFile: "filestore-credentials-file.json" + + cache: + ttl: 5m + + insightCollector: + deployment: + enabled: true + schedule: "0 10 * * *" diff --git a/pkg/configv1/testdata/piped/notification-receiver-webhook b/pkg/configv1/testdata/piped/notification-receiver-webhook new file mode 100644 index 0000000000..257cc5642c --- /dev/null +++ b/pkg/configv1/testdata/piped/notification-receiver-webhook @@ -0,0 +1 @@ +foo diff --git a/pkg/configv1/testdata/piped/piped-config.yaml b/pkg/configv1/testdata/piped/piped-config.yaml new file mode 100644 index 0000000000..9f92f437d9 --- /dev/null +++ b/pkg/configv1/testdata/piped/piped-config.yaml @@ -0,0 +1,245 @@ +apiVersion: pipecd.dev/v1beta1 +kind: Piped +spec: + projectID: test-project + pipedID: test-piped + pipedKeyFile: etc/piped/key + apiAddress: your-pipecd.domain + webAddress: https://your-pipecd.domain + syncInterval: 1m + + git: + username: username + email: username@email.com + sshKeyFile: /etc/piped-secret/ssh-key + + repositories: + - repoId: repo1 + remote: git@github.com:org/repo1.git + branch: master + - repoId: repo2 + remote: git@github.com:org/repo2.git + branch: master + + chartRepositories: + - name: fantastic-charts + address: https://fantastic-charts.storage.googleapis.com + - name: private-charts + address: https://private-charts.com + username: basic-username + password: basic-password + insecure: true + + chartRegistries: + - type: OCI + address: registry.example.com + username: sample-username + password: sample-password + + platformProviders: + - name: kubernetes-default + type: KUBERNETES + labels: + group: workload + config: + masterURL: https://example.com + kubeConfigPath: /etc/kube/config + appStateInformer: + includeResources: + - apiVersion: pipecd.dev/v1beta1 + - apiVersion: networking.gke.io/v1beta1 + kind: ManagedCertificate + excludeResources: + - apiVersion: v1 + kind: Endpoints + + - name: kubernetes-dev + type: KUBERNETES + labels: + group: config + + - name: terraform + type: TERRAFORM + config: + vars: + - "project=gcp-project" + - "region=us-centra1" + driftDetectionEnabled: false + + - name: cloudrun + type: CLOUDRUN + config: + project: gcp-project-id + region: cloud-run-region + credentialsFile: /etc/piped-secret/gcp-service-account.json + + - name: lambda + type: LAMBDA + config: + region: us-east-1 + + analysisProviders: + - name: prometheus-dev + type: PROMETHEUS + config: + address: https://your-prometheus.dev + - name: datadog-dev + type: DATADOG + config: + address: https://your-datadog.dev + apiKeyFile: /etc/piped-secret/datadog-api-key + applicationKeyFile: /etc/piped-secret/datadog-application-key + - name: stackdriver-dev + type: STACKDRIVER + config: + serviceAccountFile: /etc/piped-secret/gcp-service-account.json + + notifications: + routes: + - name: dev-slack + labels: + env: dev + team: pipecd + receiver: dev-slack-channel + - name: prod-slack + events: + - DEPLOYMENT_TRIGGERED + - DEPLOYMENT_SUCCEEDED + labels: + env: dev + receiver: prod-slack-channel + - name: integration-slack + receiver: integration-slack-api + - name: all-events-to-ci + receiver: ci-webhook + receivers: + - name: dev-slack-channel + slack: + hookURL: https://slack.com/dev + - name: prod-slack-channel + slack: + hookURL: https://slack.com/prod + - name: integration-slack-api + slack: + oauthToken: token + channelID: testid + - name: hookurl-with-mentioned-groups + slack: + hookURL: https://slack.com/dev + mentionedGroups: + - 'group1' + - '' + - name: hookurl-with-mentioned-accounts + slack: + hookURL: https://slack.com/dev + mentionedAccounts: + - 'user1' + - '@user2' + - name: hookurl-with-mentioned-both-accounts-and-groups + slack: + hookURL: https://slack.com/dev + mentionedAccounts: + - 'user1' + - '@user2' + mentionedGroups: + - 'group1' + - '' + - name: integration-slack-api-with-mentioned-accounts + slack: + oauthToken: token + channelID: testid + mentionedAccounts: + - 'user1' + - '@user2' + - name: integration-slack-api-with-mentioned-groups + slack: + oauthToken: token + channelID: testid + mentionedGroups: + - 'group1' + - '' + - name: integration-slack-api-with-mentioned-both-accounts-groups + slack: + oauthToken: token + channelID: testid + mentionedAccounts: + - 'user1' + - '@user2' + mentionedGroups: + - 'group1' + - '' + - name: integration-slack-api-with-oauthTokenData + slack: + oauthTokenData: token + channelID: testid + - name: integration-slack-api-with-oauthTokenFile + slack: + oauthTokenFile: 'foo/bar' + channelID: testid + - name: integration-slack-api-with-oauthTokenFile-and-mentioned-accounts + slack: + oauthTokenFile: 'foo/bar' + channelID: testid + mentionedAccounts: + - 'user1' + - '@user2' + - name: integration-slack-api-with-oauthTokenFile-and-mentioned-groups + slack: + oauthTokenFile: 'foo/bar' + channelID: testid + mentionedGroups: + - 'group1' + - '' + - name: integration-slack-api-with-oauthTokenFile-and-mentioned-both-accounts-and-groups + slack: + oauthTokenFile: 'foo/bar' + channelID: testid + mentionedAccounts: + - 'user1' + - '@user2' + mentionedGroups: + - 'group1' + - '' + - name: integration-slack-api-with-oauthTokenData-and-mentioned-accounts + slack: + oauthTokenData: token + channelID: testid + mentionedAccounts: + - 'user1' + - '@user2' + - name: integration-slack-api-with-oauthTokenData-and-mentioned-groups + slack: + oauthTokenData: token + channelID: testid + mentionedGroups: + - 'group1' + - '' + - name: integration-slack-api-with-oauthTokenData-and-mentioned-both-accounts-and-groups + slack: + oauthTokenData: token + channelID: testid + mentionedAccounts: + - 'user1' + - '@user2' + mentionedGroups: + - 'group1' + - '' + - name: ci-webhook + webhook: + url: https://pipecd.dev/dev-hook + signatureValue: random-signature-string + + secretManagement: + type: KEY_PAIR + config: + privateKeyFile: /etc/piped-secret/pair-private-key + publicKeyFile: /etc/piped-secret/pair-public-key + + eventWatcher: + checkInterval: 10m + gitRepos: + - repoId: repo-1 + commitMessage: Update values by Event watcher + includes: + - event-watcher-dev.yaml + - event-watcher-stg.yaml diff --git a/pkg/configv1/testdata/sealedsecret/invalid.yaml b/pkg/configv1/testdata/sealedsecret/invalid.yaml new file mode 100644 index 0000000000..84b3606498 --- /dev/null +++ b/pkg/configv1/testdata/sealedsecret/invalid.yaml @@ -0,0 +1,4 @@ +apiVersion: "pipecd.dev/v1beta1" +kind: SealedSecret +spec: + encryptedData: "" diff --git a/pkg/configv1/testdata/sealedsecret/ok.yaml b/pkg/configv1/testdata/sealedsecret/ok.yaml new file mode 100644 index 0000000000..feb23b149e --- /dev/null +++ b/pkg/configv1/testdata/sealedsecret/ok.yaml @@ -0,0 +1,15 @@ +apiVersion: "pipecd.dev/v1beta1" +kind: SealedSecret +spec: + template: | + apiVersion: v1 + kind: Secret + metadata: + name: mysecret + type: Opaque + data: + username: {{ .encryptedItems.username }} + password: {{ .encryptedItems.password }} + encryptedItems: + username: encrypted-username + password: encrypted-password