From 41ae6d18fd54bf8e8e3a237cf46b46978e22a924 Mon Sep 17 00:00:00 2001 From: Shinnosuke Sawada-Dazai Date: Thu, 12 Sep 2024 17:55:40 +0900 Subject: [PATCH 01/84] Enable the persistence of the Grafana Tempo (#5208) Signed-off-by: Shinnosuke Sawada-Dazai --- manifests/pipecd/values.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/manifests/pipecd/values.yaml b/manifests/pipecd/values.yaml index 231922b7ef..e5cc4d226c 100644 --- a/manifests/pipecd/values.yaml +++ b/manifests/pipecd/values.yaml @@ -265,6 +265,8 @@ grafana: # Head to the below link to see all available values. # https://github.com/grafana/helm-charts/tree/main/charts/tempo tempo: + persistence: + enabled: true tempo: metricsGenerator: enabled: true From 2d089a8a813b264262710d5efcf38ca1dffb77a5 Mon Sep 17 00:00:00 2001 From: Shinnosuke Sawada-Dazai Date: Thu, 12 Sep 2024 18:28:46 +0900 Subject: [PATCH 02/84] Revert "Enable the persistence of the Grafana Tempo (#5208)" (#5209) This reverts commit 41ae6d18fd54bf8e8e3a237cf46b46978e22a924. Signed-off-by: Shinnosuke Sawada-Dazai --- manifests/pipecd/values.yaml | 2 -- 1 file changed, 2 deletions(-) diff --git a/manifests/pipecd/values.yaml b/manifests/pipecd/values.yaml index e5cc4d226c..231922b7ef 100644 --- a/manifests/pipecd/values.yaml +++ b/manifests/pipecd/values.yaml @@ -265,8 +265,6 @@ grafana: # Head to the below link to see all available values. # https://github.com/grafana/helm-charts/tree/main/charts/tempo tempo: - persistence: - enabled: true tempo: metricsGenerator: enabled: true From 6b004fa1090cf4d16749eede2843e64631c9da54 Mon Sep 17 00:00:00 2001 From: Tetsuya Kikuchi <97105818+t-kikuc@users.noreply.github.com> Date: Thu, 12 Sep 2024 18:47:34 +0900 Subject: [PATCH 03/84] [docs] Update docs of ECS and Lambda (#5210) * Update feature statuses to Alpha Signed-off-by: t-kikuc * add note for standalone tasks Signed-off-by: t-kikuc * Add required IAM actions for ECS & Lambda Livestates Signed-off-by: t-kikuc --------- Signed-off-by: t-kikuc --- docs/content/en/docs-dev/feature-status/_index.md | 12 +++++++----- .../install-piped/required-permissions.md | 6 ++++++ 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/docs/content/en/docs-dev/feature-status/_index.md b/docs/content/en/docs-dev/feature-status/_index.md index 265ca77a55..25b11caa07 100644 --- a/docs/content/en/docs-dev/feature-status/_index.md +++ b/docs/content/en/docs-dev/feature-status/_index.md @@ -67,9 +67,9 @@ Please note that the phases (Incubating, Alpha, Beta, and Stable) are applied to | Quick sync deployment | Beta | | Deployment with a defined pipeline (e.g. canary, analysis) | Beta | | [Automated rollback](../user-guide/managing-application/rolling-back-a-deployment/) | Beta | -| [Automated configuration drift detection](../user-guide/managing-application/configuration-drift-detection/) | Incubating | -| [Application live state](../user-guide/managing-application/application-live-state/) | Incubating | -| [Plan preview](../user-guide/plan-preview) | Incubating | +| [Automated configuration drift detection](../user-guide/managing-application/configuration-drift-detection/) | Alpha | +| [Application live state](../user-guide/managing-application/application-live-state/) | Alpha | +| [Plan preview](../user-guide/plan-preview) | Alpha | | [Manifest attachment](../user-guide/managing-application/manifest-attachment) | Alpha | ### Amazon ECS @@ -79,14 +79,16 @@ Please note that the phases (Incubating, Alpha, Beta, and Stable) are applied to | Quick sync deployment | Alpha | | Deployment with a defined pipeline (e.g. canary, analysis) | Alpha | | [Automated rollback](../user-guide/managing-application/rolling-back-a-deployment/) | Beta | -| [Automated configuration drift detection](../user-guide/managing-application/configuration-drift-detection/) | Incubating | -| [Application live state](../user-guide/managing-application/application-live-state/) | Incubating | +| [Automated configuration drift detection](../user-guide/managing-application/configuration-drift-detection/) | Alpha *1 | +| [Application live state](../user-guide/managing-application/application-live-state/) | Alpha *1 | | Quick sync deployment for [ECS Service Discovery](https://docs.aws.amazon.com/AmazonECS/latest/developerguide/service-discovery.html) | Alpha | | Deployment with a defined pipeline for [ECS Service Discovery](https://docs.aws.amazon.com/AmazonECS/latest/developerguide/service-discovery.html) | Alpha | | Support [AWS App Mesh](https://aws.amazon.com/app-mesh/) | Incubating | | [Plan preview](../user-guide/plan-preview) | Alpha | | [Manifest attachment](../user-guide/managing-application/manifest-attachment) | Alpha | +*1. Not supported yet for standalone tasks. + ## Piped Agent | Feature | Phase | diff --git a/docs/content/en/docs-dev/installation/install-piped/required-permissions.md b/docs/content/en/docs-dev/installation/install-piped/required-permissions.md index eed0f2266b..7350b65846 100644 --- a/docs/content/en/docs-dev/installation/install-piped/required-permissions.md +++ b/docs/content/en/docs-dev/installation/install-piped/required-permissions.md @@ -26,7 +26,12 @@ You need IAM actions like the following example. You can restrict `Resource`. "ecs:DeleteTaskSet", "ecs:DeregisterTaskDefinition", "ecs:DescribeServices", + "ecs:DescribeTaskDefinition", "ecs:DescribeTaskSets", + "ecs:DescribeTasks", + "ecs:ListClusters", + "ecs:ListServices", + "ecs:ListTasks", "ecs:RegisterTaskDefinition", "ecs:RunTask", "ecs:TagResource", @@ -71,6 +76,7 @@ You need IAM actions like the following example. You can restrict `Resource`. "lambda:CreateFunction", "lambda:GetAlias", "lambda:GetFunction", + "lambda:ListFunctions", "lambda:PublishVersion", "lambda:TagResource", "lambda:UntagResource", From 5d3e17dda9739f2a09a5f14074d61aea2e32a7de Mon Sep 17 00:00:00 2001 From: Khanh Tran <32532742+khanhtc1202@users.noreply.github.com> Date: Tue, 17 Sep 2024 13:31:37 +0900 Subject: [PATCH 04/84] fix: upgrade dayjs from 1.11.12 to 1.11.13 (#5211) Snyk has created this PR to upgrade dayjs from 1.11.12 to 1.11.13. See this package in yarn: dayjs See this project in Snyk: https://app.snyk.io/org/pipecd/project/f41c5767-b506-4f59-beb9-ef662258eb9a?utm_source=github&utm_medium=referral&page=upgrade-pr Signed-off-by: t-kikuc Co-authored-by: snyk-bot --- web/package.json | 2 +- web/yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/web/package.json b/web/package.json index c39d221de1..ad14c16d69 100644 --- a/web/package.json +++ b/web/package.json @@ -70,7 +70,7 @@ "@types/yup": "^0.29.14", "clsx": "^1.2.1", "dagre": "^0.8.5", - "dayjs": "^1.11.12", + "dayjs": "^1.11.13", "dotenv": "^8.6.0", "echarts": "^5.5.1", "formik": "^2.2.9", diff --git a/web/yarn.lock b/web/yarn.lock index 11a26c0916..ef91d47aaa 100644 --- a/web/yarn.lock +++ b/web/yarn.lock @@ -2834,10 +2834,10 @@ data-urls@^3.0.2: whatwg-mimetype "^3.0.0" whatwg-url "^11.0.0" -dayjs@^1.11.12: - version "1.11.12" - resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.11.12.tgz#5245226cc7f40a15bf52e0b99fd2a04669ccac1d" - integrity sha512-Rt2g+nTbLlDWZTwwrIXjy9MeiZmSDI375FvZs72ngxx8PDC6YXOeR3q5LAuPzjZQxhiWdRKac7RKV+YyQYfYIg== +dayjs@^1.11.13: + version "1.11.13" + resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.11.13.tgz#92430b0139055c3ebb60150aa13e860a4b5a366c" + integrity sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg== debug@2.6.9: version "2.6.9" From 5b9d417b3ff341562f1ae0a76628e156ad525785 Mon Sep 17 00:00:00 2001 From: Yoshiki Fujikane <40124947+ffjlabo@users.noreply.github.com> Date: Wed, 18 Sep 2024 12:54:42 +0900 Subject: [PATCH 05/84] Regard DSP as done only when preparation is successful considering of reuse the provider (#5216) * Regard DSP as done only when preparation is successful considering of reuse the provider Signed-off-by: Yoshiki Fujikane * Same fix for provider.GetReadOnly Signed-off-by: Yoshiki Fujikane --------- Signed-off-by: Yoshiki Fujikane --- pkg/app/piped/deploysource/deploysource.go | 4 ++-- pkg/app/pipedv1/deploysource/deploysource.go | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pkg/app/piped/deploysource/deploysource.go b/pkg/app/piped/deploysource/deploysource.go index ba963949cd..5809e459d8 100644 --- a/pkg/app/piped/deploysource/deploysource.go +++ b/pkg/app/piped/deploysource/deploysource.go @@ -90,7 +90,7 @@ func (p *provider) Get(ctx context.Context, lw io.Writer) (*DeploySource, error) if !p.done { p.source, p.err = p.prepare(ctx, lw) - p.done = true + p.done = p.err == nil // If there is an error, we should re-prepare it next time. } if p.err != nil { @@ -114,7 +114,7 @@ func (p *provider) GetReadOnly(ctx context.Context, lw io.Writer) (*DeploySource if !p.done { p.source, p.err = p.prepare(ctx, lw) - p.done = true + p.done = p.err == nil // If there is an error, we should re-prepare it next time. } if p.err != nil { diff --git a/pkg/app/pipedv1/deploysource/deploysource.go b/pkg/app/pipedv1/deploysource/deploysource.go index dcfde1c6e7..e50e9b20f4 100644 --- a/pkg/app/pipedv1/deploysource/deploysource.go +++ b/pkg/app/pipedv1/deploysource/deploysource.go @@ -89,7 +89,7 @@ func (p *provider) Get(ctx context.Context, lw io.Writer) (*DeploySource, error) if !p.done { p.source, p.err = p.prepare(ctx, lw) - p.done = true + p.done = p.err == nil // If there is an error, we should re-prepare it next time. } if p.err != nil { From 9fcf50d41088a1a159941204ae779f6b8f748055 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 18 Sep 2024 15:56:17 +0900 Subject: [PATCH 06/84] Bump express from 4.19.2 to 4.21.0 in /web (#5212) Bumps [express](https://github.com/expressjs/express) from 4.19.2 to 4.21.0. - [Release notes](https://github.com/expressjs/express/releases) - [Changelog](https://github.com/expressjs/express/blob/4.21.0/History.md) - [Commits](https://github.com/expressjs/express/compare/4.19.2...4.21.0) --- updated-dependencies: - dependency-name: express dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- web/yarn.lock | 211 ++++++++++++++++++++++++++++++++++---------------- 1 file changed, 143 insertions(+), 68 deletions(-) diff --git a/web/yarn.lock b/web/yarn.lock index ef91d47aaa..b146e1d3fb 100644 --- a/web/yarn.lock +++ b/web/yarn.lock @@ -2306,10 +2306,10 @@ binary-extensions@^2.0.0: resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.0.0.tgz#23c0df14f6a88077f5f986c0d167ec03c3d5537c" integrity sha512-Phlt0plgpIIBOGTT/ehfFnbNlfsDEiqmzE2KRXoX1bLIlir4X/MR+zSyBEkL05ffWgnRSf/DXv+WrUAVr93/ow== -body-parser@1.20.2: - version "1.20.2" - resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.20.2.tgz#6feb0e21c4724d06de7ff38da36dad4f57a747fd" - integrity sha512-ml9pReCu3M61kGlqoTm2umSXTlRTuGTx0bfYj+uIUKKYycG5NtSbeetV3faSU6R7ajOPw0g/J1PvK4qNy7s5bA== +body-parser@1.20.3: + version "1.20.3" + resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.20.3.tgz#1953431221c6fb5cd63c4b36d53fab0928e548c6" + integrity sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g== dependencies: bytes "3.1.2" content-type "~1.0.5" @@ -2319,7 +2319,7 @@ body-parser@1.20.2: http-errors "2.0.0" iconv-lite "0.4.24" on-finished "2.4.1" - qs "6.11.0" + qs "6.13.0" raw-body "2.5.2" type-is "~1.6.18" unpipe "1.0.0" @@ -2393,13 +2393,16 @@ bytes@3.1.2: resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.2.tgz#8b0beeb98605adf1b128fa4386403c009e0221a5" integrity sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg== -call-bind@^1.0.0: - version "1.0.2" - resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.2.tgz#b1d4e89e688119c3c9a903ad30abb2f6a919be3c" - integrity sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA== +call-bind@^1.0.7: + version "1.0.7" + resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.7.tgz#06016599c40c56498c18769d2730be242b6fa3b9" + integrity sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w== dependencies: - function-bind "^1.1.1" - get-intrinsic "^1.0.2" + es-define-property "^1.0.0" + es-errors "^1.3.0" + function-bind "^1.1.2" + get-intrinsic "^1.2.4" + set-function-length "^1.2.1" callsites@^3.0.0: version "3.1.0" @@ -2890,6 +2893,15 @@ default-gateway@^6.0.3: dependencies: execa "^5.0.0" +define-data-property@^1.1.4: + version "1.1.4" + resolved "https://registry.yarnpkg.com/define-data-property/-/define-data-property-1.1.4.tgz#894dc141bb7d3060ae4366f6a0107e68fbe48c5e" + integrity sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A== + dependencies: + es-define-property "^1.0.0" + es-errors "^1.3.0" + gopd "^1.0.1" + define-lazy-prop@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz#3f7ae421129bcaaac9bc74905c98a0009ec9ee7f" @@ -3083,6 +3095,11 @@ encodeurl@~1.0.2: resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59" integrity sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k= +encodeurl@~2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-2.0.0.tgz#7b8ea898077d7e409d3ac45474ea38eaf0857a58" + integrity sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg== + enhanced-resolve@^5.0.0, enhanced-resolve@^5.17.1: version "5.17.1" resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.17.1.tgz#67bfbbcc2f81d511be77d686a90267ef7f898a15" @@ -3137,6 +3154,18 @@ es-abstract@^1.17.0, es-abstract@^1.17.0-next.1, es-abstract@^1.17.5: string.prototype.trimleft "^2.1.1" string.prototype.trimright "^2.1.1" +es-define-property@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/es-define-property/-/es-define-property-1.0.0.tgz#c7faefbdff8b2696cf5f46921edfb77cc4ba3845" + integrity sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ== + dependencies: + get-intrinsic "^1.2.4" + +es-errors@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/es-errors/-/es-errors-1.3.0.tgz#05f75a25dab98e4fb1dcd5e1472c0546d5057c8f" + integrity sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw== + es-module-lexer@^1.2.1: version "1.5.4" resolved "https://registry.yarnpkg.com/es-module-lexer/-/es-module-lexer-1.5.4.tgz#a8efec3a3da991e60efa6b633a7cad6ab8d26b78" @@ -3407,36 +3436,36 @@ expect@^29.5.0: jest-util "^29.5.0" express@^4.17.3: - version "4.19.2" - resolved "https://registry.yarnpkg.com/express/-/express-4.19.2.tgz#e25437827a3aa7f2a827bc8171bbbb664a356465" - integrity sha512-5T6nhjsT+EOMzuck8JjBHARTHfMht0POzlA60WV2pMD3gyXw2LZnZ+ueGdNxG+0calOJcWKbpFcuzLZ91YWq9Q== + version "4.21.0" + resolved "https://registry.yarnpkg.com/express/-/express-4.21.0.tgz#d57cb706d49623d4ac27833f1cbc466b668eb915" + integrity sha512-VqcNGcj/Id5ZT1LZ/cfihi3ttTn+NJmkli2eZADigjq29qTlWi/hAQ43t/VLPq8+UX06FCEx3ByOYet6ZFblng== dependencies: accepts "~1.3.8" array-flatten "1.1.1" - body-parser "1.20.2" + body-parser "1.20.3" content-disposition "0.5.4" content-type "~1.0.4" cookie "0.6.0" cookie-signature "1.0.6" debug "2.6.9" depd "2.0.0" - encodeurl "~1.0.2" + encodeurl "~2.0.0" escape-html "~1.0.3" etag "~1.8.1" - finalhandler "1.2.0" + finalhandler "1.3.1" fresh "0.5.2" http-errors "2.0.0" - merge-descriptors "1.0.1" + merge-descriptors "1.0.3" methods "~1.1.2" on-finished "2.4.1" parseurl "~1.3.3" - path-to-regexp "0.1.7" + path-to-regexp "0.1.10" proxy-addr "~2.0.7" - qs "6.11.0" + qs "6.13.0" range-parser "~1.2.1" safe-buffer "5.2.1" - send "0.18.0" - serve-static "1.15.0" + send "0.19.0" + serve-static "1.16.2" setprototypeof "1.2.0" statuses "2.0.1" type-is "~1.6.18" @@ -3548,13 +3577,13 @@ filter-obj@^1.1.0: resolved "https://registry.yarnpkg.com/filter-obj/-/filter-obj-1.1.0.tgz#9b311112bc6c6127a16e016c6c5d7f19e0805c5b" integrity sha1-mzERErxsYSehbgFsbF1/GeCAXFs= -finalhandler@1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.2.0.tgz#7d23fe5731b207b4640e4fcd00aec1f9207a7b32" - integrity sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg== +finalhandler@1.3.1: + version "1.3.1" + resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.3.1.tgz#0c575f1d1d324ddd1da35ad7ece3df7d19088019" + integrity sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ== dependencies: debug "2.6.9" - encodeurl "~1.0.2" + encodeurl "~2.0.0" escape-html "~1.0.3" on-finished "2.4.1" parseurl "~1.3.3" @@ -3687,6 +3716,11 @@ function-bind@^1.1.1: resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d" integrity sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A== +function-bind@^1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.2.tgz#2c02d864d97f3ea6c8830c464cbd11ab6eab7a1c" + integrity sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA== + functional-red-black-tree@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz#1b0ab3bd553b2a0d6399d29c0e3ea0b252078327" @@ -3702,14 +3736,16 @@ get-caller-file@^2.0.5: resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e" integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg== -get-intrinsic@^1.0.2: - version "1.1.3" - resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.1.3.tgz#063c84329ad93e83893c7f4f243ef63ffa351385" - integrity sha512-QJVz1Tj7MS099PevUG5jvnt9tSkXN8K14dxQlikJuPt4uD9hHAHjLyLBiLR5zELelBdD9QNRAXZzsJx0WaDL9A== +get-intrinsic@^1.1.3, get-intrinsic@^1.2.4: + version "1.2.4" + resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.2.4.tgz#e385f5a4b5227d449c3eabbad05494ef0abbeadd" + integrity sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ== dependencies: - function-bind "^1.1.1" - has "^1.0.3" + es-errors "^1.3.0" + function-bind "^1.1.2" + has-proto "^1.0.1" has-symbols "^1.0.3" + hasown "^2.0.0" get-package-type@^0.1.0: version "0.1.0" @@ -3792,6 +3828,13 @@ google-protobuf@^3.21.4: resolved "https://registry.yarnpkg.com/google-protobuf/-/google-protobuf-3.21.4.tgz#2f933e8b6e5e9f8edde66b7be0024b68f77da6c9" integrity sha512-MnG7N936zcKTco4Jd2PX2U96Kf9PxygAPKBug+74LHzmHXmceN16MmRcdgZv+DGef/S9YvQAfRsNCn4cjf9yyQ== +gopd@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/gopd/-/gopd-1.0.1.tgz#29ff76de69dac7489b7c0918a5788e56477c332c" + integrity sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA== + dependencies: + get-intrinsic "^1.1.3" + graceful-fs@^4.1.2, graceful-fs@^4.1.6, graceful-fs@^4.2.0, graceful-fs@^4.2.11, graceful-fs@^4.2.4, graceful-fs@^4.2.6, graceful-fs@^4.2.9: version "4.2.11" resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3" @@ -3829,6 +3872,18 @@ has-flag@^4.0.0: resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b" integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ== +has-property-descriptors@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz#963ed7d071dc7bf5f084c5bfbe0d1b6222586854" + integrity sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg== + dependencies: + es-define-property "^1.0.0" + +has-proto@^1.0.1: + version "1.0.3" + resolved "https://registry.yarnpkg.com/has-proto/-/has-proto-1.0.3.tgz#b31ddfe9b0e6e9914536a6ab286426d0214f77fd" + integrity sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q== + has-symbols@^1.0.0, has-symbols@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.1.tgz#9f5214758a44196c406d9bd76cebf81ec2dd31e8" @@ -3846,6 +3901,13 @@ has@^1.0.3: dependencies: function-bind "^1.1.1" +hasown@^2.0.0: + version "2.0.2" + resolved "https://registry.yarnpkg.com/hasown/-/hasown-2.0.2.tgz#003eaf91be7adc372e84ec59dc37252cedb80003" + integrity sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ== + dependencies: + function-bind "^1.1.2" + hast-util-parse-selector@^2.0.0: version "2.2.4" resolved "https://registry.yarnpkg.com/hast-util-parse-selector/-/hast-util-parse-selector-2.2.4.tgz#60c99d0b519e12ab4ed32e58f150ec3f61ed1974" @@ -5129,10 +5191,10 @@ memfs@^3.4.3: dependencies: fs-monkey "^1.0.3" -merge-descriptors@1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-1.0.1.tgz#b00aaa556dd8b44568150ec9d1b953f3f90cbb61" - integrity sha1-sAqqVW3YtEVoFQ7J0blT8/kMu2E= +merge-descriptors@1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-1.0.3.tgz#d80319a65f3c7935351e5cfdac8f9318504dbed5" + integrity sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ== merge-stream@^2.0.0: version "2.0.0" @@ -5353,16 +5415,16 @@ object-assign@^4.1.1: resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" integrity sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM= +object-inspect@^1.13.1: + version "1.13.2" + resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.13.2.tgz#dea0088467fb991e67af4058147a24824a3043ff" + integrity sha512-IRZSRuzJiynemAXPYtPe5BoI/RESNYR7TYm50MC5Mqbd3Jmw5y790sErYw3V6SryFJD64b74qQQs9wn5Bg/k3g== + object-inspect@^1.7.0: version "1.7.0" resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.7.0.tgz#f4f6bd181ad77f006b5ece60bd0b6f398ff74a67" integrity sha512-a7pEHdh1xKIAgTySUGgLMx/xwDZskN1Ud6egYYN3EdRW4ZMPNEDUTF+hwy2LUC+Bl+SyLXANnwz/jyh/qutKUw== -object-inspect@^1.9.0: - version "1.12.2" - resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.12.2.tgz#c0641f26394532f28ab8d796ab954e43c009a8ea" - integrity sha512-z+cPxW0QGUp0mcqcsgQyLVRDoXFQbXOwBaqyF7VIgI4TWNQsDHrBpUQslRmIfAoYWdYzs6UlKJtB2XJpTaNSpQ== - object-keys@^1.0.11, object-keys@^1.0.12, object-keys@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/object-keys/-/object-keys-1.1.1.tgz#1c47f272df277f3b1daf061677d9c82e2322c60e" @@ -5597,10 +5659,10 @@ path-parse@^1.0.6: resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735" integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw== -path-to-regexp@0.1.7: - version "0.1.7" - resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-0.1.7.tgz#df604178005f522f15eb4490e7247a1bfaa67f8c" - integrity sha1-32BBeABfUi8V60SQ5yR6G/qmf4w= +path-to-regexp@0.1.10: + version "0.1.10" + resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-0.1.10.tgz#67e9108c5c0551b9e5326064387de4763c4d5f8b" + integrity sha512-7lf7qcQidTku0Gu3YDPc8DJ1q7OOucfa/BSsIwjuh56VU7katFvuM8hULfkwB3Fns/rsVF7PwPKVw1sl5KQS9w== path-to-regexp@^1.7.0: version "1.8.0" @@ -5812,12 +5874,12 @@ pure-rand@^6.0.0: resolved "https://registry.yarnpkg.com/pure-rand/-/pure-rand-6.0.2.tgz#a9c2ddcae9b68d736a8163036f088a2781c8b306" integrity sha512-6Yg0ekpKICSjPswYOuC5sku/TSWaRYlA0qsXqJgM/d/4pLPHPuTxK7Nbf7jFKzAeedUhR8C7K9Uv63FBsSo8xQ== -qs@6.11.0: - version "6.11.0" - resolved "https://registry.yarnpkg.com/qs/-/qs-6.11.0.tgz#fd0d963446f7a65e1367e01abd85429453f0c37a" - integrity sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q== +qs@6.13.0: + version "6.13.0" + resolved "https://registry.yarnpkg.com/qs/-/qs-6.13.0.tgz#6ca3bd58439f7e245655798997787b0d88a51906" + integrity sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg== dependencies: - side-channel "^1.0.4" + side-channel "^1.0.6" query-string@^6.14.1: version "6.14.1" @@ -6342,10 +6404,10 @@ semver@^6.0.0, semver@^6.3.0: resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4" integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== -send@0.18.0: - version "0.18.0" - resolved "https://registry.yarnpkg.com/send/-/send-0.18.0.tgz#670167cc654b05f5aa4a767f9113bb371bc706be" - integrity sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg== +send@0.19.0: + version "0.19.0" + resolved "https://registry.yarnpkg.com/send/-/send-0.19.0.tgz#bbc5a388c8ea6c048967049dbeac0e4a3f09d7f8" + integrity sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw== dependencies: debug "2.6.9" depd "2.0.0" @@ -6388,15 +6450,27 @@ serve-index@^1.9.1: mime-types "~2.1.17" parseurl "~1.3.2" -serve-static@1.15.0: - version "1.15.0" - resolved "https://registry.yarnpkg.com/serve-static/-/serve-static-1.15.0.tgz#faaef08cffe0a1a62f60cad0c4e513cff0ac9540" - integrity sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g== +serve-static@1.16.2: + version "1.16.2" + resolved "https://registry.yarnpkg.com/serve-static/-/serve-static-1.16.2.tgz#b6a5343da47f6bdd2673848bf45754941e803296" + integrity sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw== dependencies: - encodeurl "~1.0.2" + encodeurl "~2.0.0" escape-html "~1.0.3" parseurl "~1.3.3" - send "0.18.0" + send "0.19.0" + +set-function-length@^1.2.1: + version "1.2.2" + resolved "https://registry.yarnpkg.com/set-function-length/-/set-function-length-1.2.2.tgz#aac72314198eaed975cf77b2c3b6b880695e5449" + integrity sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg== + dependencies: + define-data-property "^1.1.4" + es-errors "^1.3.0" + function-bind "^1.1.2" + get-intrinsic "^1.2.4" + gopd "^1.0.1" + has-property-descriptors "^1.0.2" setprototypeof@1.1.0: version "1.1.0" @@ -6435,14 +6509,15 @@ side-channel@^1.0.2: es-abstract "^1.17.0-next.1" object-inspect "^1.7.0" -side-channel@^1.0.4: - version "1.0.4" - resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.0.4.tgz#efce5c8fdc104ee751b25c58d4290011fa5ea2cf" - integrity sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw== +side-channel@^1.0.6: + version "1.0.6" + resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.0.6.tgz#abd25fb7cd24baf45466406b1096b7831c9215f2" + integrity sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA== dependencies: - call-bind "^1.0.0" - get-intrinsic "^1.0.2" - object-inspect "^1.9.0" + call-bind "^1.0.7" + es-errors "^1.3.0" + get-intrinsic "^1.2.4" + object-inspect "^1.13.1" signal-exit@^3.0.3: version "3.0.3" From 0e7a17704ab6c21895482826466cc02fc21ae457 Mon Sep 17 00:00:00 2001 From: Yoshiki Fujikane <40124947+ffjlabo@users.noreply.github.com> Date: Thu, 19 Sep 2024 10:25:22 +0900 Subject: [PATCH 07/84] Use TargetDSP on SCRIPT_RUN_ROLLBACK stage (#5215) Signed-off-by: Yoshiki Fujikane --- pkg/app/piped/executor/kubernetes/rollback.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/pkg/app/piped/executor/kubernetes/rollback.go b/pkg/app/piped/executor/kubernetes/rollback.go index 0b89525426..8a4164dd2e 100644 --- a/pkg/app/piped/executor/kubernetes/rollback.go +++ b/pkg/app/piped/executor/kubernetes/rollback.go @@ -195,6 +195,13 @@ func (e *rollbackExecutor) ensureScriptRunRollback(ctx context.Context) model.St return model.StageStatus_STAGE_SUCCESS } + ds, err := e.TargetDSP.Get(ctx, e.LogPersister) + if err != nil { + e.LogPersister.Errorf("Failed to prepare target deploy source data (%v)", err) + return model.StageStatus_STAGE_FAILURE + } + e.appDir = ds.AppDir + envStr, ok := e.Stage.Metadata["env"] env := make(map[string]string, 0) if ok { From 29dd1b6dc71e4256b2785e8a60731940c0a1cb62 Mon Sep 17 00:00:00 2001 From: Shinnosuke Sawada-Dazai Date: Thu, 19 Sep 2024 15:12:50 +0900 Subject: [PATCH 08/84] Add Deploysource as proto model (#5112) * Pass the deployment source directory to the plugins Signed-off-by: Shinnosuke Sawada-Dazai * Add Deploysource Signed-off-by: Shinnosuke Sawada-Dazai * Remove GenericApplicationSpec from DeploymentSource Signed-off-by: Shinnosuke Sawada-Dazai * Do make gen/code Signed-off-by: Shinnosuke Sawada-Dazai * Use bytes as application_config Signed-off-by: Shinnosuke Sawada-Dazai --------- Signed-off-by: Shinnosuke Sawada-Dazai --- pkg/app/pipedv1/plugin/inputs.go | 38 -- pkg/model/deployment_source.pb.go | 184 ++++++++++ pkg/model/deployment_source.pb.validate.go | 142 ++++++++ pkg/model/deployment_source.proto | 28 ++ pkg/plugin/api/v1alpha1/deployment/api.pb.go | 333 +++++++++--------- .../v1alpha1/deployment/api.pb.validate.go | 67 +++- pkg/plugin/api/v1alpha1/deployment/api.proto | 14 +- web/model/deployment_source_pb.d.ts | 32 ++ web/model/deployment_source_pb.js | 260 ++++++++++++++ 9 files changed, 872 insertions(+), 226 deletions(-) delete mode 100644 pkg/app/pipedv1/plugin/inputs.go create mode 100644 pkg/model/deployment_source.pb.go create mode 100644 pkg/model/deployment_source.pb.validate.go create mode 100644 pkg/model/deployment_source.proto create mode 100644 web/model/deployment_source_pb.d.ts create mode 100644 web/model/deployment_source_pb.js diff --git a/pkg/app/pipedv1/plugin/inputs.go b/pkg/app/pipedv1/plugin/inputs.go deleted file mode 100644 index 3fd1c167fe..0000000000 --- a/pkg/app/pipedv1/plugin/inputs.go +++ /dev/null @@ -1,38 +0,0 @@ -// 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 plugin - -import ( - "os/exec" - - "github.com/pipe-cd/pipecd/pkg/app/pipedv1/deploysource" - "github.com/pipe-cd/pipecd/pkg/git" - "github.com/pipe-cd/pipecd/pkg/plugin/api/v1alpha1/deployment" -) - -func GetPlanSourceCloner(input *deployment.PlanPluginInput) (deploysource.SourceCloner, error) { - gitPath, err := exec.LookPath("git") - if err != nil { - return nil, err - } - - cloner := deploysource.NewLocalSourceCloner( - git.NewRepo(input.GetSourceRemoteUrl(), gitPath, input.GetSourceRemoteUrl(), input.GetDeployment().GetGitPath().GetRepo().GetBranch(), nil), - "target", - input.GetDeployment().GetGitPath().GetRepo().GetBranch(), - ) - - return cloner, nil -} diff --git a/pkg/model/deployment_source.pb.go b/pkg/model/deployment_source.pb.go new file mode 100644 index 0000000000..aa44d082bb --- /dev/null +++ b/pkg/model/deployment_source.pb.go @@ -0,0 +1,184 @@ +// 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. + +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.27.1 +// protoc v3.21.12 +// source: pkg/model/deployment_source.proto + +package model + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type DeploymentSource struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // The application directory where the source code is located. + ApplicationDirectory string `protobuf:"bytes,1,opt,name=application_directory,json=applicationDirectory,proto3" json:"application_directory,omitempty"` + // The git commit revision of the source code. + Revision string `protobuf:"bytes,2,opt,name=revision,proto3" json:"revision,omitempty"` + // The configuration of the application which is specific for plugins. + ApplicationConfig []byte `protobuf:"bytes,3,opt,name=application_config,json=applicationConfig,proto3" json:"application_config,omitempty"` +} + +func (x *DeploymentSource) Reset() { + *x = DeploymentSource{} + if protoimpl.UnsafeEnabled { + mi := &file_pkg_model_deployment_source_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *DeploymentSource) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DeploymentSource) ProtoMessage() {} + +func (x *DeploymentSource) ProtoReflect() protoreflect.Message { + mi := &file_pkg_model_deployment_source_proto_msgTypes[0] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DeploymentSource.ProtoReflect.Descriptor instead. +func (*DeploymentSource) Descriptor() ([]byte, []int) { + return file_pkg_model_deployment_source_proto_rawDescGZIP(), []int{0} +} + +func (x *DeploymentSource) GetApplicationDirectory() string { + if x != nil { + return x.ApplicationDirectory + } + return "" +} + +func (x *DeploymentSource) GetRevision() string { + if x != nil { + return x.Revision + } + return "" +} + +func (x *DeploymentSource) GetApplicationConfig() []byte { + if x != nil { + return x.ApplicationConfig + } + return nil +} + +var File_pkg_model_deployment_source_proto protoreflect.FileDescriptor + +var file_pkg_model_deployment_source_proto_rawDesc = []byte{ + 0x0a, 0x21, 0x70, 0x6b, 0x67, 0x2f, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x2f, 0x64, 0x65, 0x70, 0x6c, + 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x5f, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x70, 0x72, + 0x6f, 0x74, 0x6f, 0x12, 0x05, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x22, 0x92, 0x01, 0x0a, 0x10, 0x44, + 0x65, 0x70, 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x53, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, + 0x33, 0x0a, 0x15, 0x61, 0x70, 0x70, 0x6c, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x64, + 0x69, 0x72, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x14, + 0x61, 0x70, 0x70, 0x6c, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x44, 0x69, 0x72, 0x65, 0x63, + 0x74, 0x6f, 0x72, 0x79, 0x12, 0x1a, 0x0a, 0x08, 0x72, 0x65, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, + 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x72, 0x65, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, + 0x12, 0x2d, 0x0a, 0x12, 0x61, 0x70, 0x70, 0x6c, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, + 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x11, 0x61, 0x70, + 0x70, 0x6c, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x42, + 0x25, 0x5a, 0x23, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x70, 0x69, + 0x70, 0x65, 0x2d, 0x63, 0x64, 0x2f, 0x70, 0x69, 0x70, 0x65, 0x63, 0x64, 0x2f, 0x70, 0x6b, 0x67, + 0x2f, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, +} + +var ( + file_pkg_model_deployment_source_proto_rawDescOnce sync.Once + file_pkg_model_deployment_source_proto_rawDescData = file_pkg_model_deployment_source_proto_rawDesc +) + +func file_pkg_model_deployment_source_proto_rawDescGZIP() []byte { + file_pkg_model_deployment_source_proto_rawDescOnce.Do(func() { + file_pkg_model_deployment_source_proto_rawDescData = protoimpl.X.CompressGZIP(file_pkg_model_deployment_source_proto_rawDescData) + }) + return file_pkg_model_deployment_source_proto_rawDescData +} + +var file_pkg_model_deployment_source_proto_msgTypes = make([]protoimpl.MessageInfo, 1) +var file_pkg_model_deployment_source_proto_goTypes = []interface{}{ + (*DeploymentSource)(nil), // 0: model.DeploymentSource +} +var file_pkg_model_deployment_source_proto_depIdxs = []int32{ + 0, // [0:0] is the sub-list for method output_type + 0, // [0:0] is the sub-list for method input_type + 0, // [0:0] is the sub-list for extension type_name + 0, // [0:0] is the sub-list for extension extendee + 0, // [0:0] is the sub-list for field type_name +} + +func init() { file_pkg_model_deployment_source_proto_init() } +func file_pkg_model_deployment_source_proto_init() { + if File_pkg_model_deployment_source_proto != nil { + return + } + if !protoimpl.UnsafeEnabled { + file_pkg_model_deployment_source_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*DeploymentSource); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: file_pkg_model_deployment_source_proto_rawDesc, + NumEnums: 0, + NumMessages: 1, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_pkg_model_deployment_source_proto_goTypes, + DependencyIndexes: file_pkg_model_deployment_source_proto_depIdxs, + MessageInfos: file_pkg_model_deployment_source_proto_msgTypes, + }.Build() + File_pkg_model_deployment_source_proto = out.File + file_pkg_model_deployment_source_proto_rawDesc = nil + file_pkg_model_deployment_source_proto_goTypes = nil + file_pkg_model_deployment_source_proto_depIdxs = nil +} diff --git a/pkg/model/deployment_source.pb.validate.go b/pkg/model/deployment_source.pb.validate.go new file mode 100644 index 0000000000..79e6c58d4f --- /dev/null +++ b/pkg/model/deployment_source.pb.validate.go @@ -0,0 +1,142 @@ +// Code generated by protoc-gen-validate. DO NOT EDIT. +// source: pkg/model/deployment_source.proto + +package model + +import ( + "bytes" + "errors" + "fmt" + "net" + "net/mail" + "net/url" + "regexp" + "sort" + "strings" + "time" + "unicode/utf8" + + "google.golang.org/protobuf/types/known/anypb" +) + +// ensure the imports are used +var ( + _ = bytes.MinRead + _ = errors.New("") + _ = fmt.Print + _ = utf8.UTFMax + _ = (*regexp.Regexp)(nil) + _ = (*strings.Reader)(nil) + _ = net.IPv4len + _ = time.Duration(0) + _ = (*url.URL)(nil) + _ = (*mail.Address)(nil) + _ = anypb.Any{} + _ = sort.Sort +) + +// Validate checks the field values on DeploymentSource with the rules defined +// in the proto definition for this message. If any rules are violated, the +// first error encountered is returned, or nil if there are no violations. +func (m *DeploymentSource) Validate() error { + return m.validate(false) +} + +// ValidateAll checks the field values on DeploymentSource with the rules +// defined in the proto definition for this message. If any rules are +// violated, the result is a list of violation errors wrapped in +// DeploymentSourceMultiError, or nil if none found. +func (m *DeploymentSource) ValidateAll() error { + return m.validate(true) +} + +func (m *DeploymentSource) validate(all bool) error { + if m == nil { + return nil + } + + var errors []error + + // no validation rules for ApplicationDirectory + + // no validation rules for Revision + + // no validation rules for ApplicationConfig + + if len(errors) > 0 { + return DeploymentSourceMultiError(errors) + } + + return nil +} + +// DeploymentSourceMultiError is an error wrapping multiple validation errors +// returned by DeploymentSource.ValidateAll() if the designated constraints +// aren't met. +type DeploymentSourceMultiError []error + +// Error returns a concatenation of all the error messages it wraps. +func (m DeploymentSourceMultiError) Error() string { + var msgs []string + for _, err := range m { + msgs = append(msgs, err.Error()) + } + return strings.Join(msgs, "; ") +} + +// AllErrors returns a list of validation violation errors. +func (m DeploymentSourceMultiError) AllErrors() []error { return m } + +// DeploymentSourceValidationError is the validation error returned by +// DeploymentSource.Validate if the designated constraints aren't met. +type DeploymentSourceValidationError struct { + field string + reason string + cause error + key bool +} + +// Field function returns field value. +func (e DeploymentSourceValidationError) Field() string { return e.field } + +// Reason function returns reason value. +func (e DeploymentSourceValidationError) Reason() string { return e.reason } + +// Cause function returns cause value. +func (e DeploymentSourceValidationError) Cause() error { return e.cause } + +// Key function returns key value. +func (e DeploymentSourceValidationError) Key() bool { return e.key } + +// ErrorName returns error name. +func (e DeploymentSourceValidationError) ErrorName() string { return "DeploymentSourceValidationError" } + +// Error satisfies the builtin error interface +func (e DeploymentSourceValidationError) Error() string { + cause := "" + if e.cause != nil { + cause = fmt.Sprintf(" | caused by: %v", e.cause) + } + + key := "" + if e.key { + key = "key for " + } + + return fmt.Sprintf( + "invalid %sDeploymentSource.%s: %s%s", + key, + e.field, + e.reason, + cause) +} + +var _ error = DeploymentSourceValidationError{} + +var _ interface { + Field() string + Reason() string + Key() bool + Cause() error + ErrorName() string +} = DeploymentSourceValidationError{} diff --git a/pkg/model/deployment_source.proto b/pkg/model/deployment_source.proto new file mode 100644 index 0000000000..f9a24ff278 --- /dev/null +++ b/pkg/model/deployment_source.proto @@ -0,0 +1,28 @@ +// 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. + +syntax = "proto3"; + +package model; + +option go_package = "github.com/pipe-cd/pipecd/pkg/model"; + +message DeploymentSource { + // The application directory where the source code is located. + string application_directory = 1; + // The git commit revision of the source code. + string revision = 2; + // The configuration of the application which is specific for plugins. + bytes application_config = 3; +} diff --git a/pkg/plugin/api/v1alpha1/deployment/api.pb.go b/pkg/plugin/api/v1alpha1/deployment/api.pb.go index 326bad3cc5..38c10b894e 100644 --- a/pkg/plugin/api/v1alpha1/deployment/api.pb.go +++ b/pkg/plugin/api/v1alpha1/deployment/api.pb.go @@ -526,14 +526,12 @@ type PlanPluginInput struct { // The deployment to build a plan for. Deployment *model.Deployment `protobuf:"bytes,1,opt,name=deployment,proto3" json:"deployment,omitempty"` - // The remote URL of the deployment source, where plugin can find the deployments sources (manifests). - SourceRemoteUrl string `protobuf:"bytes,2,opt,name=source_remote_url,json=sourceRemoteUrl,proto3" json:"source_remote_url,omitempty"` - // Last successful commit hash and config file name. - // Use to build deployment source object for last successful deployment. - LastSuccessfulCommitHash string `protobuf:"bytes,3,opt,name=last_successful_commit_hash,json=lastSuccessfulCommitHash,proto3" json:"last_successful_commit_hash,omitempty"` - LastSuccessfulConfigFileName string `protobuf:"bytes,4,opt,name=last_successful_config_file_name,json=lastSuccessfulConfigFileName,proto3" json:"last_successful_config_file_name,omitempty"` // The configuration of plugin that handles the deployment. - PluginConfig []byte `protobuf:"bytes,5,opt,name=plugin_config,json=pluginConfig,proto3" json:"plugin_config,omitempty"` + PluginConfig []byte `protobuf:"bytes,2,opt,name=plugin_config,json=pluginConfig,proto3" json:"plugin_config,omitempty"` + // The running deployment source. + RunningDeploymentSource *model.DeploymentSource `protobuf:"bytes,3,opt,name=running_deployment_source,json=runningDeploymentSource,proto3" json:"running_deployment_source,omitempty"` + // The target deployment source. + TargetDeploymentSource *model.DeploymentSource `protobuf:"bytes,4,opt,name=target_deployment_source,json=targetDeploymentSource,proto3" json:"target_deployment_source,omitempty"` } func (x *PlanPluginInput) Reset() { @@ -575,30 +573,23 @@ func (x *PlanPluginInput) GetDeployment() *model.Deployment { return nil } -func (x *PlanPluginInput) GetSourceRemoteUrl() string { - if x != nil { - return x.SourceRemoteUrl - } - return "" -} - -func (x *PlanPluginInput) GetLastSuccessfulCommitHash() string { +func (x *PlanPluginInput) GetPluginConfig() []byte { if x != nil { - return x.LastSuccessfulCommitHash + return x.PluginConfig } - return "" + return nil } -func (x *PlanPluginInput) GetLastSuccessfulConfigFileName() string { +func (x *PlanPluginInput) GetRunningDeploymentSource() *model.DeploymentSource { if x != nil { - return x.LastSuccessfulConfigFileName + return x.RunningDeploymentSource } - return "" + return nil } -func (x *PlanPluginInput) GetPluginConfig() []byte { +func (x *PlanPluginInput) GetTargetDeploymentSource() *model.DeploymentSource { if x != nil { - return x.PluginConfig + return x.TargetDeploymentSource } return nil } @@ -711,146 +702,147 @@ var file_pkg_plugin_api_v1alpha1_deployment_api_proto_rawDesc = []byte{ 0x69, 0x64, 0x61, 0x74, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x16, 0x70, 0x6b, 0x67, 0x2f, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x2f, 0x63, 0x6f, 0x6d, 0x6d, 0x6f, 0x6e, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x1a, 0x70, 0x6b, 0x67, 0x2f, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x2f, 0x64, - 0x65, 0x70, 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, - 0x6f, 0x0a, 0x18, 0x44, 0x65, 0x74, 0x65, 0x72, 0x6d, 0x69, 0x6e, 0x65, 0x56, 0x65, 0x72, 0x73, - 0x69, 0x6f, 0x6e, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x53, 0x0a, 0x05, 0x69, - 0x6e, 0x70, 0x75, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x33, 0x2e, 0x67, 0x72, 0x70, - 0x63, 0x2e, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x2e, 0x64, 0x65, 0x70, 0x6c, 0x6f, 0x79, 0x6d, - 0x65, 0x6e, 0x74, 0x61, 0x70, 0x69, 0x2e, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2e, - 0x50, 0x6c, 0x61, 0x6e, 0x50, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x49, 0x6e, 0x70, 0x75, 0x74, 0x42, - 0x08, 0xfa, 0x42, 0x05, 0x8a, 0x01, 0x02, 0x10, 0x01, 0x52, 0x05, 0x69, 0x6e, 0x70, 0x75, 0x74, - 0x22, 0x4f, 0x0a, 0x19, 0x44, 0x65, 0x74, 0x65, 0x72, 0x6d, 0x69, 0x6e, 0x65, 0x56, 0x65, 0x72, - 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x32, 0x0a, - 0x08, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, - 0x16, 0x2e, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x2e, 0x41, 0x72, 0x74, 0x69, 0x66, 0x61, 0x63, 0x74, - 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x08, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, - 0x73, 0x22, 0x6f, 0x0a, 0x18, 0x44, 0x65, 0x74, 0x65, 0x72, 0x6d, 0x69, 0x6e, 0x65, 0x53, 0x74, - 0x72, 0x61, 0x74, 0x65, 0x67, 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x53, 0x0a, - 0x05, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x33, 0x2e, 0x67, + 0x65, 0x70, 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, + 0x21, 0x70, 0x6b, 0x67, 0x2f, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x2f, 0x64, 0x65, 0x70, 0x6c, 0x6f, + 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x5f, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x70, 0x72, 0x6f, + 0x74, 0x6f, 0x22, 0x6f, 0x0a, 0x18, 0x44, 0x65, 0x74, 0x65, 0x72, 0x6d, 0x69, 0x6e, 0x65, 0x56, + 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x53, + 0x0a, 0x05, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x33, 0x2e, + 0x67, 0x72, 0x70, 0x63, 0x2e, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x2e, 0x64, 0x65, 0x70, 0x6c, + 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x70, 0x69, 0x2e, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, + 0x61, 0x31, 0x2e, 0x50, 0x6c, 0x61, 0x6e, 0x50, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x49, 0x6e, 0x70, + 0x75, 0x74, 0x42, 0x08, 0xfa, 0x42, 0x05, 0x8a, 0x01, 0x02, 0x10, 0x01, 0x52, 0x05, 0x69, 0x6e, + 0x70, 0x75, 0x74, 0x22, 0x4f, 0x0a, 0x19, 0x44, 0x65, 0x74, 0x65, 0x72, 0x6d, 0x69, 0x6e, 0x65, + 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, + 0x12, 0x32, 0x0a, 0x08, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x01, 0x20, 0x03, + 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x2e, 0x41, 0x72, 0x74, 0x69, 0x66, + 0x61, 0x63, 0x74, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x08, 0x76, 0x65, 0x72, 0x73, + 0x69, 0x6f, 0x6e, 0x73, 0x22, 0x6f, 0x0a, 0x18, 0x44, 0x65, 0x74, 0x65, 0x72, 0x6d, 0x69, 0x6e, + 0x65, 0x53, 0x74, 0x72, 0x61, 0x74, 0x65, 0x67, 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, + 0x12, 0x53, 0x0a, 0x05, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, + 0x33, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x2e, 0x64, 0x65, + 0x70, 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x70, 0x69, 0x2e, 0x76, 0x31, 0x61, 0x6c, + 0x70, 0x68, 0x61, 0x31, 0x2e, 0x50, 0x6c, 0x61, 0x6e, 0x50, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x49, + 0x6e, 0x70, 0x75, 0x74, 0x42, 0x08, 0xfa, 0x42, 0x05, 0x8a, 0x01, 0x02, 0x10, 0x01, 0x52, 0x05, + 0x69, 0x6e, 0x70, 0x75, 0x74, 0x22, 0x6f, 0x0a, 0x19, 0x44, 0x65, 0x74, 0x65, 0x72, 0x6d, 0x69, + 0x6e, 0x65, 0x53, 0x74, 0x72, 0x61, 0x74, 0x65, 0x67, 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, + 0x73, 0x65, 0x12, 0x38, 0x0a, 0x0d, 0x73, 0x79, 0x6e, 0x63, 0x5f, 0x73, 0x74, 0x72, 0x61, 0x74, + 0x65, 0x67, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x13, 0x2e, 0x6d, 0x6f, 0x64, 0x65, + 0x6c, 0x2e, 0x53, 0x79, 0x6e, 0x63, 0x53, 0x74, 0x72, 0x61, 0x74, 0x65, 0x67, 0x79, 0x52, 0x0c, + 0x73, 0x79, 0x6e, 0x63, 0x53, 0x74, 0x72, 0x61, 0x74, 0x65, 0x67, 0x79, 0x12, 0x18, 0x0a, 0x07, + 0x73, 0x75, 0x6d, 0x6d, 0x61, 0x72, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x73, + 0x75, 0x6d, 0x6d, 0x61, 0x72, 0x79, 0x22, 0xc6, 0x02, 0x0a, 0x1e, 0x42, 0x75, 0x69, 0x6c, 0x64, + 0x50, 0x69, 0x70, 0x65, 0x6c, 0x69, 0x6e, 0x65, 0x53, 0x79, 0x6e, 0x63, 0x53, 0x74, 0x61, 0x67, + 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1a, 0x0a, 0x08, 0x72, 0x6f, 0x6c, + 0x6c, 0x62, 0x61, 0x63, 0x6b, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x08, 0x72, 0x6f, 0x6c, + 0x6c, 0x62, 0x61, 0x63, 0x6b, 0x12, 0x66, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x67, 0x65, 0x73, 0x18, + 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x4e, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x70, 0x6c, 0x75, + 0x67, 0x69, 0x6e, 0x2e, 0x64, 0x65, 0x70, 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x70, + 0x69, 0x2e, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2e, 0x42, 0x75, 0x69, 0x6c, 0x64, + 0x50, 0x69, 0x70, 0x65, 0x6c, 0x69, 0x6e, 0x65, 0x53, 0x79, 0x6e, 0x63, 0x53, 0x74, 0x61, 0x67, + 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x2e, 0x53, 0x74, 0x61, 0x67, 0x65, 0x43, + 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x06, 0x73, 0x74, 0x61, 0x67, 0x65, 0x73, 0x1a, 0x9f, 0x01, + 0x0a, 0x0b, 0x53, 0x74, 0x61, 0x67, 0x65, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x0e, 0x0a, + 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x1b, 0x0a, + 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x42, 0x07, 0xfa, 0x42, 0x04, + 0x72, 0x02, 0x10, 0x01, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x64, 0x65, + 0x73, 0x63, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x64, 0x65, 0x73, 0x63, 0x12, 0x18, + 0x0a, 0x07, 0x74, 0x69, 0x6d, 0x65, 0x6f, 0x75, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x07, 0x74, 0x69, 0x6d, 0x65, 0x6f, 0x75, 0x74, 0x12, 0x1d, 0x0a, 0x05, 0x69, 0x6e, 0x64, 0x65, + 0x78, 0x18, 0x05, 0x20, 0x01, 0x28, 0x05, 0x42, 0x07, 0xfa, 0x42, 0x04, 0x1a, 0x02, 0x28, 0x00, + 0x52, 0x05, 0x69, 0x6e, 0x64, 0x65, 0x78, 0x12, 0x16, 0x0a, 0x06, 0x63, 0x6f, 0x6e, 0x66, 0x69, + 0x67, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x06, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x22, + 0x4f, 0x0a, 0x1f, 0x42, 0x75, 0x69, 0x6c, 0x64, 0x50, 0x69, 0x70, 0x65, 0x6c, 0x69, 0x6e, 0x65, + 0x53, 0x79, 0x6e, 0x63, 0x53, 0x74, 0x61, 0x67, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, + 0x73, 0x65, 0x12, 0x2c, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x67, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, + 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x2e, 0x50, 0x69, 0x70, 0x65, 0x6c, + 0x69, 0x6e, 0x65, 0x53, 0x74, 0x61, 0x67, 0x65, 0x52, 0x06, 0x73, 0x74, 0x61, 0x67, 0x65, 0x73, + 0x22, 0x39, 0x0a, 0x1b, 0x42, 0x75, 0x69, 0x6c, 0x64, 0x51, 0x75, 0x69, 0x63, 0x6b, 0x53, 0x79, + 0x6e, 0x63, 0x53, 0x74, 0x61, 0x67, 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, + 0x1a, 0x0a, 0x08, 0x72, 0x6f, 0x6c, 0x6c, 0x62, 0x61, 0x63, 0x6b, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x08, 0x52, 0x08, 0x72, 0x6f, 0x6c, 0x6c, 0x62, 0x61, 0x63, 0x6b, 0x22, 0x4c, 0x0a, 0x1c, 0x42, + 0x75, 0x69, 0x6c, 0x64, 0x51, 0x75, 0x69, 0x63, 0x6b, 0x53, 0x79, 0x6e, 0x63, 0x53, 0x74, 0x61, + 0x67, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x2c, 0x0a, 0x06, 0x73, + 0x74, 0x61, 0x67, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x6d, 0x6f, + 0x64, 0x65, 0x6c, 0x2e, 0x50, 0x69, 0x70, 0x65, 0x6c, 0x69, 0x6e, 0x65, 0x53, 0x74, 0x61, 0x67, + 0x65, 0x52, 0x06, 0x73, 0x74, 0x61, 0x67, 0x65, 0x73, 0x22, 0x1b, 0x0a, 0x19, 0x46, 0x65, 0x74, + 0x63, 0x68, 0x44, 0x65, 0x66, 0x69, 0x6e, 0x65, 0x64, 0x53, 0x74, 0x61, 0x67, 0x65, 0x73, 0x52, + 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0x34, 0x0a, 0x1a, 0x46, 0x65, 0x74, 0x63, 0x68, 0x44, + 0x65, 0x66, 0x69, 0x6e, 0x65, 0x64, 0x53, 0x74, 0x61, 0x67, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, + 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x67, 0x65, 0x73, 0x18, 0x01, + 0x20, 0x03, 0x28, 0x09, 0x52, 0x06, 0x73, 0x74, 0x61, 0x67, 0x65, 0x73, 0x22, 0x9b, 0x02, 0x0a, + 0x0f, 0x50, 0x6c, 0x61, 0x6e, 0x50, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x49, 0x6e, 0x70, 0x75, 0x74, + 0x12, 0x3b, 0x0a, 0x0a, 0x64, 0x65, 0x70, 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x18, 0x01, + 0x20, 0x01, 0x28, 0x0b, 0x32, 0x11, 0x2e, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x2e, 0x44, 0x65, 0x70, + 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x42, 0x08, 0xfa, 0x42, 0x05, 0x8a, 0x01, 0x02, 0x10, + 0x01, 0x52, 0x0a, 0x64, 0x65, 0x70, 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x12, 0x23, 0x0a, + 0x0d, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x5f, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x02, + 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0c, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x43, 0x6f, 0x6e, 0x66, + 0x69, 0x67, 0x12, 0x53, 0x0a, 0x19, 0x72, 0x75, 0x6e, 0x6e, 0x69, 0x6e, 0x67, 0x5f, 0x64, 0x65, + 0x70, 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x5f, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x18, + 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x17, 0x2e, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x2e, 0x44, 0x65, + 0x70, 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x53, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x17, + 0x72, 0x75, 0x6e, 0x6e, 0x69, 0x6e, 0x67, 0x44, 0x65, 0x70, 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, + 0x74, 0x53, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x51, 0x0a, 0x18, 0x74, 0x61, 0x72, 0x67, 0x65, + 0x74, 0x5f, 0x64, 0x65, 0x70, 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x5f, 0x73, 0x6f, 0x75, + 0x72, 0x63, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x17, 0x2e, 0x6d, 0x6f, 0x64, 0x65, + 0x6c, 0x2e, 0x44, 0x65, 0x70, 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x53, 0x6f, 0x75, 0x72, + 0x63, 0x65, 0x52, 0x16, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x44, 0x65, 0x70, 0x6c, 0x6f, 0x79, + 0x6d, 0x65, 0x6e, 0x74, 0x53, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x32, 0x9a, 0x06, 0x0a, 0x11, 0x44, + 0x65, 0x70, 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, + 0x12, 0x95, 0x01, 0x0a, 0x12, 0x46, 0x65, 0x74, 0x63, 0x68, 0x44, 0x65, 0x66, 0x69, 0x6e, 0x65, + 0x64, 0x53, 0x74, 0x61, 0x67, 0x65, 0x73, 0x12, 0x3d, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x70, + 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x2e, 0x64, 0x65, 0x70, 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, + 0x61, 0x70, 0x69, 0x2e, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2e, 0x46, 0x65, 0x74, + 0x63, 0x68, 0x44, 0x65, 0x66, 0x69, 0x6e, 0x65, 0x64, 0x53, 0x74, 0x61, 0x67, 0x65, 0x73, 0x52, + 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x3e, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x70, 0x6c, + 0x75, 0x67, 0x69, 0x6e, 0x2e, 0x64, 0x65, 0x70, 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x61, + 0x70, 0x69, 0x2e, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2e, 0x46, 0x65, 0x74, 0x63, + 0x68, 0x44, 0x65, 0x66, 0x69, 0x6e, 0x65, 0x64, 0x53, 0x74, 0x61, 0x67, 0x65, 0x73, 0x52, 0x65, + 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x92, 0x01, 0x0a, 0x11, 0x44, 0x65, 0x74, + 0x65, 0x72, 0x6d, 0x69, 0x6e, 0x65, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x3c, + 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x2e, 0x64, 0x65, 0x70, + 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x70, 0x69, 0x2e, 0x76, 0x31, 0x61, 0x6c, 0x70, + 0x68, 0x61, 0x31, 0x2e, 0x44, 0x65, 0x74, 0x65, 0x72, 0x6d, 0x69, 0x6e, 0x65, 0x56, 0x65, 0x72, + 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x3d, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x2e, 0x64, 0x65, 0x70, 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x70, 0x69, 0x2e, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, - 0x31, 0x2e, 0x50, 0x6c, 0x61, 0x6e, 0x50, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x49, 0x6e, 0x70, 0x75, - 0x74, 0x42, 0x08, 0xfa, 0x42, 0x05, 0x8a, 0x01, 0x02, 0x10, 0x01, 0x52, 0x05, 0x69, 0x6e, 0x70, - 0x75, 0x74, 0x22, 0x6f, 0x0a, 0x19, 0x44, 0x65, 0x74, 0x65, 0x72, 0x6d, 0x69, 0x6e, 0x65, 0x53, - 0x74, 0x72, 0x61, 0x74, 0x65, 0x67, 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, - 0x38, 0x0a, 0x0d, 0x73, 0x79, 0x6e, 0x63, 0x5f, 0x73, 0x74, 0x72, 0x61, 0x74, 0x65, 0x67, 0x79, - 0x18, 0x01, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x13, 0x2e, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x2e, 0x53, - 0x79, 0x6e, 0x63, 0x53, 0x74, 0x72, 0x61, 0x74, 0x65, 0x67, 0x79, 0x52, 0x0c, 0x73, 0x79, 0x6e, - 0x63, 0x53, 0x74, 0x72, 0x61, 0x74, 0x65, 0x67, 0x79, 0x12, 0x18, 0x0a, 0x07, 0x73, 0x75, 0x6d, - 0x6d, 0x61, 0x72, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x73, 0x75, 0x6d, 0x6d, - 0x61, 0x72, 0x79, 0x22, 0xc6, 0x02, 0x0a, 0x1e, 0x42, 0x75, 0x69, 0x6c, 0x64, 0x50, 0x69, 0x70, - 0x65, 0x6c, 0x69, 0x6e, 0x65, 0x53, 0x79, 0x6e, 0x63, 0x53, 0x74, 0x61, 0x67, 0x65, 0x73, 0x52, - 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1a, 0x0a, 0x08, 0x72, 0x6f, 0x6c, 0x6c, 0x62, 0x61, - 0x63, 0x6b, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x08, 0x72, 0x6f, 0x6c, 0x6c, 0x62, 0x61, - 0x63, 0x6b, 0x12, 0x66, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x67, 0x65, 0x73, 0x18, 0x02, 0x20, 0x03, - 0x28, 0x0b, 0x32, 0x4e, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, + 0x31, 0x2e, 0x44, 0x65, 0x74, 0x65, 0x72, 0x6d, 0x69, 0x6e, 0x65, 0x56, 0x65, 0x72, 0x73, 0x69, + 0x6f, 0x6e, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x92, 0x01, + 0x0a, 0x11, 0x44, 0x65, 0x74, 0x65, 0x72, 0x6d, 0x69, 0x6e, 0x65, 0x53, 0x74, 0x72, 0x61, 0x74, + 0x65, 0x67, 0x79, 0x12, 0x3c, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x70, 0x6c, 0x75, 0x67, 0x69, + 0x6e, 0x2e, 0x64, 0x65, 0x70, 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x70, 0x69, 0x2e, + 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2e, 0x44, 0x65, 0x74, 0x65, 0x72, 0x6d, 0x69, + 0x6e, 0x65, 0x53, 0x74, 0x72, 0x61, 0x74, 0x65, 0x67, 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, + 0x74, 0x1a, 0x3d, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x2e, + 0x64, 0x65, 0x70, 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x70, 0x69, 0x2e, 0x76, 0x31, + 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2e, 0x44, 0x65, 0x74, 0x65, 0x72, 0x6d, 0x69, 0x6e, 0x65, + 0x53, 0x74, 0x72, 0x61, 0x74, 0x65, 0x67, 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, + 0x22, 0x00, 0x12, 0xa4, 0x01, 0x0a, 0x17, 0x42, 0x75, 0x69, 0x6c, 0x64, 0x50, 0x69, 0x70, 0x65, + 0x6c, 0x69, 0x6e, 0x65, 0x53, 0x79, 0x6e, 0x63, 0x53, 0x74, 0x61, 0x67, 0x65, 0x73, 0x12, 0x42, + 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x2e, 0x64, 0x65, 0x70, + 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x70, 0x69, 0x2e, 0x76, 0x31, 0x61, 0x6c, 0x70, + 0x68, 0x61, 0x31, 0x2e, 0x42, 0x75, 0x69, 0x6c, 0x64, 0x50, 0x69, 0x70, 0x65, 0x6c, 0x69, 0x6e, + 0x65, 0x53, 0x79, 0x6e, 0x63, 0x53, 0x74, 0x61, 0x67, 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x1a, 0x43, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x2e, 0x64, 0x65, 0x70, 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x70, 0x69, 0x2e, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2e, 0x42, 0x75, 0x69, 0x6c, 0x64, 0x50, 0x69, 0x70, 0x65, 0x6c, 0x69, 0x6e, 0x65, 0x53, 0x79, 0x6e, 0x63, 0x53, 0x74, 0x61, 0x67, 0x65, 0x73, 0x52, - 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x2e, 0x53, 0x74, 0x61, 0x67, 0x65, 0x43, 0x6f, 0x6e, 0x66, - 0x69, 0x67, 0x52, 0x06, 0x73, 0x74, 0x61, 0x67, 0x65, 0x73, 0x1a, 0x9f, 0x01, 0x0a, 0x0b, 0x53, - 0x74, 0x61, 0x67, 0x65, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, - 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x1b, 0x0a, 0x04, 0x6e, 0x61, - 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x42, 0x07, 0xfa, 0x42, 0x04, 0x72, 0x02, 0x10, - 0x01, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x64, 0x65, 0x73, 0x63, 0x18, - 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x64, 0x65, 0x73, 0x63, 0x12, 0x18, 0x0a, 0x07, 0x74, - 0x69, 0x6d, 0x65, 0x6f, 0x75, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x74, 0x69, - 0x6d, 0x65, 0x6f, 0x75, 0x74, 0x12, 0x1d, 0x0a, 0x05, 0x69, 0x6e, 0x64, 0x65, 0x78, 0x18, 0x05, - 0x20, 0x01, 0x28, 0x05, 0x42, 0x07, 0xfa, 0x42, 0x04, 0x1a, 0x02, 0x28, 0x00, 0x52, 0x05, 0x69, - 0x6e, 0x64, 0x65, 0x78, 0x12, 0x16, 0x0a, 0x06, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x06, - 0x20, 0x01, 0x28, 0x0c, 0x52, 0x06, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x22, 0x4f, 0x0a, 0x1f, - 0x42, 0x75, 0x69, 0x6c, 0x64, 0x50, 0x69, 0x70, 0x65, 0x6c, 0x69, 0x6e, 0x65, 0x53, 0x79, 0x6e, - 0x63, 0x53, 0x74, 0x61, 0x67, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, - 0x2c, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x67, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, - 0x14, 0x2e, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x2e, 0x50, 0x69, 0x70, 0x65, 0x6c, 0x69, 0x6e, 0x65, - 0x53, 0x74, 0x61, 0x67, 0x65, 0x52, 0x06, 0x73, 0x74, 0x61, 0x67, 0x65, 0x73, 0x22, 0x39, 0x0a, - 0x1b, 0x42, 0x75, 0x69, 0x6c, 0x64, 0x51, 0x75, 0x69, 0x63, 0x6b, 0x53, 0x79, 0x6e, 0x63, 0x53, - 0x74, 0x61, 0x67, 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1a, 0x0a, 0x08, - 0x72, 0x6f, 0x6c, 0x6c, 0x62, 0x61, 0x63, 0x6b, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x08, - 0x72, 0x6f, 0x6c, 0x6c, 0x62, 0x61, 0x63, 0x6b, 0x22, 0x4c, 0x0a, 0x1c, 0x42, 0x75, 0x69, 0x6c, - 0x64, 0x51, 0x75, 0x69, 0x63, 0x6b, 0x53, 0x79, 0x6e, 0x63, 0x53, 0x74, 0x61, 0x67, 0x65, 0x73, - 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x2c, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x67, - 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x6d, 0x6f, 0x64, 0x65, 0x6c, - 0x2e, 0x50, 0x69, 0x70, 0x65, 0x6c, 0x69, 0x6e, 0x65, 0x53, 0x74, 0x61, 0x67, 0x65, 0x52, 0x06, - 0x73, 0x74, 0x61, 0x67, 0x65, 0x73, 0x22, 0x1b, 0x0a, 0x19, 0x46, 0x65, 0x74, 0x63, 0x68, 0x44, - 0x65, 0x66, 0x69, 0x6e, 0x65, 0x64, 0x53, 0x74, 0x61, 0x67, 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, - 0x65, 0x73, 0x74, 0x22, 0x34, 0x0a, 0x1a, 0x46, 0x65, 0x74, 0x63, 0x68, 0x44, 0x65, 0x66, 0x69, - 0x6e, 0x65, 0x64, 0x53, 0x74, 0x61, 0x67, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, - 0x65, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x67, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, - 0x09, 0x52, 0x06, 0x73, 0x74, 0x61, 0x67, 0x65, 0x73, 0x22, 0xaf, 0x02, 0x0a, 0x0f, 0x50, 0x6c, - 0x61, 0x6e, 0x50, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x49, 0x6e, 0x70, 0x75, 0x74, 0x12, 0x3b, 0x0a, - 0x0a, 0x64, 0x65, 0x70, 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, - 0x0b, 0x32, 0x11, 0x2e, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x2e, 0x44, 0x65, 0x70, 0x6c, 0x6f, 0x79, - 0x6d, 0x65, 0x6e, 0x74, 0x42, 0x08, 0xfa, 0x42, 0x05, 0x8a, 0x01, 0x02, 0x10, 0x01, 0x52, 0x0a, - 0x64, 0x65, 0x70, 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x12, 0x33, 0x0a, 0x11, 0x73, 0x6f, - 0x75, 0x72, 0x63, 0x65, 0x5f, 0x72, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x5f, 0x75, 0x72, 0x6c, 0x18, - 0x02, 0x20, 0x01, 0x28, 0x09, 0x42, 0x07, 0xfa, 0x42, 0x04, 0x72, 0x02, 0x10, 0x01, 0x52, 0x0f, - 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x55, 0x72, 0x6c, 0x12, - 0x3d, 0x0a, 0x1b, 0x6c, 0x61, 0x73, 0x74, 0x5f, 0x73, 0x75, 0x63, 0x63, 0x65, 0x73, 0x73, 0x66, - 0x75, 0x6c, 0x5f, 0x63, 0x6f, 0x6d, 0x6d, 0x69, 0x74, 0x5f, 0x68, 0x61, 0x73, 0x68, 0x18, 0x03, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x18, 0x6c, 0x61, 0x73, 0x74, 0x53, 0x75, 0x63, 0x63, 0x65, 0x73, - 0x73, 0x66, 0x75, 0x6c, 0x43, 0x6f, 0x6d, 0x6d, 0x69, 0x74, 0x48, 0x61, 0x73, 0x68, 0x12, 0x46, - 0x0a, 0x20, 0x6c, 0x61, 0x73, 0x74, 0x5f, 0x73, 0x75, 0x63, 0x63, 0x65, 0x73, 0x73, 0x66, 0x75, - 0x6c, 0x5f, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x5f, 0x66, 0x69, 0x6c, 0x65, 0x5f, 0x6e, 0x61, - 0x6d, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x1c, 0x6c, 0x61, 0x73, 0x74, 0x53, 0x75, - 0x63, 0x63, 0x65, 0x73, 0x73, 0x66, 0x75, 0x6c, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x46, 0x69, - 0x6c, 0x65, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x23, 0x0a, 0x0d, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, - 0x5f, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0c, 0x70, - 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x32, 0x9a, 0x06, 0x0a, 0x11, - 0x44, 0x65, 0x70, 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, - 0x65, 0x12, 0x95, 0x01, 0x0a, 0x12, 0x46, 0x65, 0x74, 0x63, 0x68, 0x44, 0x65, 0x66, 0x69, 0x6e, - 0x65, 0x64, 0x53, 0x74, 0x61, 0x67, 0x65, 0x73, 0x12, 0x3d, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, - 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x2e, 0x64, 0x65, 0x70, 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, - 0x74, 0x61, 0x70, 0x69, 0x2e, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2e, 0x46, 0x65, - 0x74, 0x63, 0x68, 0x44, 0x65, 0x66, 0x69, 0x6e, 0x65, 0x64, 0x53, 0x74, 0x61, 0x67, 0x65, 0x73, - 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x3e, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x70, - 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x2e, 0x64, 0x65, 0x70, 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, - 0x61, 0x70, 0x69, 0x2e, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2e, 0x46, 0x65, 0x74, - 0x63, 0x68, 0x44, 0x65, 0x66, 0x69, 0x6e, 0x65, 0x64, 0x53, 0x74, 0x61, 0x67, 0x65, 0x73, 0x52, - 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x92, 0x01, 0x0a, 0x11, 0x44, 0x65, - 0x74, 0x65, 0x72, 0x6d, 0x69, 0x6e, 0x65, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x12, - 0x3c, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x2e, 0x64, 0x65, - 0x70, 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x70, 0x69, 0x2e, 0x76, 0x31, 0x61, 0x6c, - 0x70, 0x68, 0x61, 0x31, 0x2e, 0x44, 0x65, 0x74, 0x65, 0x72, 0x6d, 0x69, 0x6e, 0x65, 0x56, 0x65, - 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x3d, 0x2e, - 0x67, 0x72, 0x70, 0x63, 0x2e, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x2e, 0x64, 0x65, 0x70, 0x6c, - 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x70, 0x69, 0x2e, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, - 0x61, 0x31, 0x2e, 0x44, 0x65, 0x74, 0x65, 0x72, 0x6d, 0x69, 0x6e, 0x65, 0x56, 0x65, 0x72, 0x73, - 0x69, 0x6f, 0x6e, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x92, - 0x01, 0x0a, 0x11, 0x44, 0x65, 0x74, 0x65, 0x72, 0x6d, 0x69, 0x6e, 0x65, 0x53, 0x74, 0x72, 0x61, - 0x74, 0x65, 0x67, 0x79, 0x12, 0x3c, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x70, 0x6c, 0x75, 0x67, - 0x69, 0x6e, 0x2e, 0x64, 0x65, 0x70, 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x70, 0x69, - 0x2e, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2e, 0x44, 0x65, 0x74, 0x65, 0x72, 0x6d, - 0x69, 0x6e, 0x65, 0x53, 0x74, 0x72, 0x61, 0x74, 0x65, 0x67, 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, - 0x73, 0x74, 0x1a, 0x3d, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, + 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x9b, 0x01, 0x0a, 0x14, 0x42, 0x75, + 0x69, 0x6c, 0x64, 0x51, 0x75, 0x69, 0x63, 0x6b, 0x53, 0x79, 0x6e, 0x63, 0x53, 0x74, 0x61, 0x67, + 0x65, 0x73, 0x12, 0x3f, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x2e, 0x64, 0x65, 0x70, 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x70, 0x69, 0x2e, 0x76, - 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2e, 0x44, 0x65, 0x74, 0x65, 0x72, 0x6d, 0x69, 0x6e, - 0x65, 0x53, 0x74, 0x72, 0x61, 0x74, 0x65, 0x67, 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, - 0x65, 0x22, 0x00, 0x12, 0xa4, 0x01, 0x0a, 0x17, 0x42, 0x75, 0x69, 0x6c, 0x64, 0x50, 0x69, 0x70, - 0x65, 0x6c, 0x69, 0x6e, 0x65, 0x53, 0x79, 0x6e, 0x63, 0x53, 0x74, 0x61, 0x67, 0x65, 0x73, 0x12, - 0x42, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x2e, 0x64, 0x65, - 0x70, 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x70, 0x69, 0x2e, 0x76, 0x31, 0x61, 0x6c, - 0x70, 0x68, 0x61, 0x31, 0x2e, 0x42, 0x75, 0x69, 0x6c, 0x64, 0x50, 0x69, 0x70, 0x65, 0x6c, 0x69, - 0x6e, 0x65, 0x53, 0x79, 0x6e, 0x63, 0x53, 0x74, 0x61, 0x67, 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, - 0x65, 0x73, 0x74, 0x1a, 0x43, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x70, 0x6c, 0x75, 0x67, 0x69, - 0x6e, 0x2e, 0x64, 0x65, 0x70, 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x70, 0x69, 0x2e, - 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2e, 0x42, 0x75, 0x69, 0x6c, 0x64, 0x50, 0x69, - 0x70, 0x65, 0x6c, 0x69, 0x6e, 0x65, 0x53, 0x79, 0x6e, 0x63, 0x53, 0x74, 0x61, 0x67, 0x65, 0x73, - 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x9b, 0x01, 0x0a, 0x14, 0x42, - 0x75, 0x69, 0x6c, 0x64, 0x51, 0x75, 0x69, 0x63, 0x6b, 0x53, 0x79, 0x6e, 0x63, 0x53, 0x74, 0x61, - 0x67, 0x65, 0x73, 0x12, 0x3f, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x70, 0x6c, 0x75, 0x67, 0x69, + 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2e, 0x42, 0x75, 0x69, 0x6c, 0x64, 0x51, 0x75, 0x69, + 0x63, 0x6b, 0x53, 0x79, 0x6e, 0x63, 0x53, 0x74, 0x61, 0x67, 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, + 0x65, 0x73, 0x74, 0x1a, 0x40, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x2e, 0x64, 0x65, 0x70, 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x70, 0x69, 0x2e, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2e, 0x42, 0x75, 0x69, 0x6c, 0x64, 0x51, 0x75, - 0x69, 0x63, 0x6b, 0x53, 0x79, 0x6e, 0x63, 0x53, 0x74, 0x61, 0x67, 0x65, 0x73, 0x52, 0x65, 0x71, - 0x75, 0x65, 0x73, 0x74, 0x1a, 0x40, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x70, 0x6c, 0x75, 0x67, - 0x69, 0x6e, 0x2e, 0x64, 0x65, 0x70, 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x70, 0x69, - 0x2e, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2e, 0x42, 0x75, 0x69, 0x6c, 0x64, 0x51, - 0x75, 0x69, 0x63, 0x6b, 0x53, 0x79, 0x6e, 0x63, 0x53, 0x74, 0x61, 0x67, 0x65, 0x73, 0x52, 0x65, - 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x42, 0x3e, 0x5a, 0x3c, 0x67, 0x69, 0x74, 0x68, - 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x70, 0x69, 0x70, 0x65, 0x2d, 0x63, 0x64, 0x2f, 0x70, - 0x69, 0x70, 0x65, 0x63, 0x64, 0x2f, 0x70, 0x6b, 0x67, 0x2f, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, - 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2f, 0x64, 0x65, - 0x70, 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, + 0x69, 0x63, 0x6b, 0x53, 0x79, 0x6e, 0x63, 0x53, 0x74, 0x61, 0x67, 0x65, 0x73, 0x52, 0x65, 0x73, + 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x42, 0x3e, 0x5a, 0x3c, 0x67, 0x69, 0x74, 0x68, 0x75, + 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x70, 0x69, 0x70, 0x65, 0x2d, 0x63, 0x64, 0x2f, 0x70, 0x69, + 0x70, 0x65, 0x63, 0x64, 0x2f, 0x70, 0x6b, 0x67, 0x2f, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x2f, + 0x61, 0x70, 0x69, 0x2f, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2f, 0x64, 0x65, 0x70, + 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, } var ( @@ -883,6 +875,7 @@ var file_pkg_plugin_api_v1alpha1_deployment_api_proto_goTypes = []interface{}{ (model.SyncStrategy)(0), // 13: model.SyncStrategy (*model.PipelineStage)(nil), // 14: model.PipelineStage (*model.Deployment)(nil), // 15: model.Deployment + (*model.DeploymentSource)(nil), // 16: model.DeploymentSource } var file_pkg_plugin_api_v1alpha1_deployment_api_proto_depIdxs = []int32{ 10, // 0: grpc.plugin.deploymentapi.v1alpha1.DetermineVersionsRequest.input:type_name -> grpc.plugin.deploymentapi.v1alpha1.PlanPluginInput @@ -893,21 +886,23 @@ var file_pkg_plugin_api_v1alpha1_deployment_api_proto_depIdxs = []int32{ 14, // 5: grpc.plugin.deploymentapi.v1alpha1.BuildPipelineSyncStagesResponse.stages:type_name -> model.PipelineStage 14, // 6: grpc.plugin.deploymentapi.v1alpha1.BuildQuickSyncStagesResponse.stages:type_name -> model.PipelineStage 15, // 7: grpc.plugin.deploymentapi.v1alpha1.PlanPluginInput.deployment:type_name -> model.Deployment - 8, // 8: grpc.plugin.deploymentapi.v1alpha1.DeploymentService.FetchDefinedStages:input_type -> grpc.plugin.deploymentapi.v1alpha1.FetchDefinedStagesRequest - 0, // 9: grpc.plugin.deploymentapi.v1alpha1.DeploymentService.DetermineVersions:input_type -> grpc.plugin.deploymentapi.v1alpha1.DetermineVersionsRequest - 2, // 10: grpc.plugin.deploymentapi.v1alpha1.DeploymentService.DetermineStrategy:input_type -> grpc.plugin.deploymentapi.v1alpha1.DetermineStrategyRequest - 4, // 11: grpc.plugin.deploymentapi.v1alpha1.DeploymentService.BuildPipelineSyncStages:input_type -> grpc.plugin.deploymentapi.v1alpha1.BuildPipelineSyncStagesRequest - 6, // 12: grpc.plugin.deploymentapi.v1alpha1.DeploymentService.BuildQuickSyncStages:input_type -> grpc.plugin.deploymentapi.v1alpha1.BuildQuickSyncStagesRequest - 9, // 13: grpc.plugin.deploymentapi.v1alpha1.DeploymentService.FetchDefinedStages:output_type -> grpc.plugin.deploymentapi.v1alpha1.FetchDefinedStagesResponse - 1, // 14: grpc.plugin.deploymentapi.v1alpha1.DeploymentService.DetermineVersions:output_type -> grpc.plugin.deploymentapi.v1alpha1.DetermineVersionsResponse - 3, // 15: grpc.plugin.deploymentapi.v1alpha1.DeploymentService.DetermineStrategy:output_type -> grpc.plugin.deploymentapi.v1alpha1.DetermineStrategyResponse - 5, // 16: grpc.plugin.deploymentapi.v1alpha1.DeploymentService.BuildPipelineSyncStages:output_type -> grpc.plugin.deploymentapi.v1alpha1.BuildPipelineSyncStagesResponse - 7, // 17: grpc.plugin.deploymentapi.v1alpha1.DeploymentService.BuildQuickSyncStages:output_type -> grpc.plugin.deploymentapi.v1alpha1.BuildQuickSyncStagesResponse - 13, // [13:18] is the sub-list for method output_type - 8, // [8:13] is the sub-list for method input_type - 8, // [8:8] is the sub-list for extension type_name - 8, // [8:8] is the sub-list for extension extendee - 0, // [0:8] is the sub-list for field type_name + 16, // 8: grpc.plugin.deploymentapi.v1alpha1.PlanPluginInput.running_deployment_source:type_name -> model.DeploymentSource + 16, // 9: grpc.plugin.deploymentapi.v1alpha1.PlanPluginInput.target_deployment_source:type_name -> model.DeploymentSource + 8, // 10: grpc.plugin.deploymentapi.v1alpha1.DeploymentService.FetchDefinedStages:input_type -> grpc.plugin.deploymentapi.v1alpha1.FetchDefinedStagesRequest + 0, // 11: grpc.plugin.deploymentapi.v1alpha1.DeploymentService.DetermineVersions:input_type -> grpc.plugin.deploymentapi.v1alpha1.DetermineVersionsRequest + 2, // 12: grpc.plugin.deploymentapi.v1alpha1.DeploymentService.DetermineStrategy:input_type -> grpc.plugin.deploymentapi.v1alpha1.DetermineStrategyRequest + 4, // 13: grpc.plugin.deploymentapi.v1alpha1.DeploymentService.BuildPipelineSyncStages:input_type -> grpc.plugin.deploymentapi.v1alpha1.BuildPipelineSyncStagesRequest + 6, // 14: grpc.plugin.deploymentapi.v1alpha1.DeploymentService.BuildQuickSyncStages:input_type -> grpc.plugin.deploymentapi.v1alpha1.BuildQuickSyncStagesRequest + 9, // 15: grpc.plugin.deploymentapi.v1alpha1.DeploymentService.FetchDefinedStages:output_type -> grpc.plugin.deploymentapi.v1alpha1.FetchDefinedStagesResponse + 1, // 16: grpc.plugin.deploymentapi.v1alpha1.DeploymentService.DetermineVersions:output_type -> grpc.plugin.deploymentapi.v1alpha1.DetermineVersionsResponse + 3, // 17: grpc.plugin.deploymentapi.v1alpha1.DeploymentService.DetermineStrategy:output_type -> grpc.plugin.deploymentapi.v1alpha1.DetermineStrategyResponse + 5, // 18: grpc.plugin.deploymentapi.v1alpha1.DeploymentService.BuildPipelineSyncStages:output_type -> grpc.plugin.deploymentapi.v1alpha1.BuildPipelineSyncStagesResponse + 7, // 19: grpc.plugin.deploymentapi.v1alpha1.DeploymentService.BuildQuickSyncStages:output_type -> grpc.plugin.deploymentapi.v1alpha1.BuildQuickSyncStagesResponse + 15, // [15:20] is the sub-list for method output_type + 10, // [10:15] is the sub-list for method input_type + 10, // [10:10] is the sub-list for extension type_name + 10, // [10:10] is the sub-list for extension extendee + 0, // [0:10] is the sub-list for field type_name } func init() { file_pkg_plugin_api_v1alpha1_deployment_api_proto_init() } diff --git a/pkg/plugin/api/v1alpha1/deployment/api.pb.validate.go b/pkg/plugin/api/v1alpha1/deployment/api.pb.validate.go index e6d36c4628..4bb43a33fa 100644 --- a/pkg/plugin/api/v1alpha1/deployment/api.pb.validate.go +++ b/pkg/plugin/api/v1alpha1/deployment/api.pb.validate.go @@ -1349,22 +1349,65 @@ func (m *PlanPluginInput) validate(all bool) error { } } - if utf8.RuneCountInString(m.GetSourceRemoteUrl()) < 1 { - err := PlanPluginInputValidationError{ - field: "SourceRemoteUrl", - reason: "value length must be at least 1 runes", + // no validation rules for PluginConfig + + if all { + switch v := interface{}(m.GetRunningDeploymentSource()).(type) { + case interface{ ValidateAll() error }: + if err := v.ValidateAll(); err != nil { + errors = append(errors, PlanPluginInputValidationError{ + field: "RunningDeploymentSource", + reason: "embedded message failed validation", + cause: err, + }) + } + case interface{ Validate() error }: + if err := v.Validate(); err != nil { + errors = append(errors, PlanPluginInputValidationError{ + field: "RunningDeploymentSource", + reason: "embedded message failed validation", + cause: err, + }) + } } - if !all { - return err + } else if v, ok := interface{}(m.GetRunningDeploymentSource()).(interface{ Validate() error }); ok { + if err := v.Validate(); err != nil { + return PlanPluginInputValidationError{ + field: "RunningDeploymentSource", + reason: "embedded message failed validation", + cause: err, + } } - errors = append(errors, err) } - // no validation rules for LastSuccessfulCommitHash - - // no validation rules for LastSuccessfulConfigFileName - - // no validation rules for PluginConfig + if all { + switch v := interface{}(m.GetTargetDeploymentSource()).(type) { + case interface{ ValidateAll() error }: + if err := v.ValidateAll(); err != nil { + errors = append(errors, PlanPluginInputValidationError{ + field: "TargetDeploymentSource", + reason: "embedded message failed validation", + cause: err, + }) + } + case interface{ Validate() error }: + if err := v.Validate(); err != nil { + errors = append(errors, PlanPluginInputValidationError{ + field: "TargetDeploymentSource", + reason: "embedded message failed validation", + cause: err, + }) + } + } + } else if v, ok := interface{}(m.GetTargetDeploymentSource()).(interface{ Validate() error }); ok { + if err := v.Validate(); err != nil { + return PlanPluginInputValidationError{ + field: "TargetDeploymentSource", + reason: "embedded message failed validation", + cause: err, + } + } + } if len(errors) > 0 { return PlanPluginInputMultiError(errors) diff --git a/pkg/plugin/api/v1alpha1/deployment/api.proto b/pkg/plugin/api/v1alpha1/deployment/api.proto index 9a114f7cef..ba61d68c55 100644 --- a/pkg/plugin/api/v1alpha1/deployment/api.proto +++ b/pkg/plugin/api/v1alpha1/deployment/api.proto @@ -20,6 +20,7 @@ option go_package = "github.com/pipe-cd/pipecd/pkg/plugin/api/v1alpha1/deploymen import "validate/validate.proto"; import "pkg/model/common.proto"; import "pkg/model/deployment.proto"; +import "pkg/model/deployment_source.proto"; // PlannerService defines the public APIs for remote planners. service DeploymentService { @@ -101,12 +102,11 @@ message FetchDefinedStagesResponse { message PlanPluginInput { // The deployment to build a plan for. model.Deployment deployment = 1 [(validate.rules).message.required = true]; - // The remote URL of the deployment source, where plugin can find the deployments sources (manifests). - string source_remote_url = 2 [(validate.rules).string.min_len = 1]; - // Last successful commit hash and config file name. - // Use to build deployment source object for last successful deployment. - string last_successful_commit_hash = 3; - string last_successful_config_file_name = 4; // The configuration of plugin that handles the deployment. - bytes plugin_config = 5; + bytes plugin_config = 2; + + // The running deployment source. + model.DeploymentSource running_deployment_source = 3; + // The target deployment source. + model.DeploymentSource target_deployment_source = 4; } diff --git a/web/model/deployment_source_pb.d.ts b/web/model/deployment_source_pb.d.ts new file mode 100644 index 0000000000..eb41955001 --- /dev/null +++ b/web/model/deployment_source_pb.d.ts @@ -0,0 +1,32 @@ +import * as jspb from 'google-protobuf' + + + +export class DeploymentSource extends jspb.Message { + getApplicationDirectory(): string; + setApplicationDirectory(value: string): DeploymentSource; + + getRevision(): string; + setRevision(value: string): DeploymentSource; + + getApplicationConfig(): Uint8Array | string; + getApplicationConfig_asU8(): Uint8Array; + getApplicationConfig_asB64(): string; + setApplicationConfig(value: Uint8Array | string): DeploymentSource; + + serializeBinary(): Uint8Array; + toObject(includeInstance?: boolean): DeploymentSource.AsObject; + static toObject(includeInstance: boolean, msg: DeploymentSource): DeploymentSource.AsObject; + static serializeBinaryToWriter(message: DeploymentSource, writer: jspb.BinaryWriter): void; + static deserializeBinary(bytes: Uint8Array): DeploymentSource; + static deserializeBinaryFromReader(message: DeploymentSource, reader: jspb.BinaryReader): DeploymentSource; +} + +export namespace DeploymentSource { + export type AsObject = { + applicationDirectory: string, + revision: string, + applicationConfig: Uint8Array | string, + } +} + diff --git a/web/model/deployment_source_pb.js b/web/model/deployment_source_pb.js new file mode 100644 index 0000000000..5890613f7a --- /dev/null +++ b/web/model/deployment_source_pb.js @@ -0,0 +1,260 @@ +// source: pkg/model/deployment_source.proto +/** + * @fileoverview + * @enhanceable + * @suppress {missingRequire} reports error on implicit type usages. + * @suppress {messageConventions} JS Compiler reports an error if a variable or + * field starts with 'MSG_' and isn't a translatable message. + * @public + */ +// GENERATED CODE -- DO NOT EDIT! +/* eslint-disable */ +// @ts-nocheck + +var jspb = require('google-protobuf'); +var goog = jspb; +var global = + (typeof globalThis !== 'undefined' && globalThis) || + (typeof window !== 'undefined' && window) || + (typeof global !== 'undefined' && global) || + (typeof self !== 'undefined' && self) || + (function () { return this; }).call(null) || + Function('return this')(); + +goog.exportSymbol('proto.model.DeploymentSource', null, global); +/** + * Generated by JsPbCodeGenerator. + * @param {Array=} opt_data Optional initial data array, typically from a + * server response, or constructed directly in Javascript. The array is used + * in place and becomes part of the constructed object. It is not cloned. + * If no data is provided, the constructed object will be empty, but still + * valid. + * @extends {jspb.Message} + * @constructor + */ +proto.model.DeploymentSource = function(opt_data) { + jspb.Message.initialize(this, opt_data, 0, -1, null, null); +}; +goog.inherits(proto.model.DeploymentSource, jspb.Message); +if (goog.DEBUG && !COMPILED) { + /** + * @public + * @override + */ + proto.model.DeploymentSource.displayName = 'proto.model.DeploymentSource'; +} + + + +if (jspb.Message.GENERATE_TO_OBJECT) { +/** + * Creates an object representation of this proto. + * Field names that are reserved in JavaScript and will be renamed to pb_name. + * Optional fields that are not set will be set to undefined. + * To access a reserved field use, foo.pb_, eg, foo.pb_default. + * For the list of reserved names please see: + * net/proto2/compiler/js/internal/generator.cc#kKeyword. + * @param {boolean=} opt_includeInstance Deprecated. whether to include the + * JSPB instance for transitional soy proto support: + * http://goto/soy-param-migration + * @return {!Object} + */ +proto.model.DeploymentSource.prototype.toObject = function(opt_includeInstance) { + return proto.model.DeploymentSource.toObject(opt_includeInstance, this); +}; + + +/** + * Static version of the {@see toObject} method. + * @param {boolean|undefined} includeInstance Deprecated. Whether to include + * the JSPB instance for transitional soy proto support: + * http://goto/soy-param-migration + * @param {!proto.model.DeploymentSource} msg The msg instance to transform. + * @return {!Object} + * @suppress {unusedLocalVariables} f is only used for nested messages + */ +proto.model.DeploymentSource.toObject = function(includeInstance, msg) { + var f, obj = { + applicationDirectory: jspb.Message.getFieldWithDefault(msg, 1, ""), + revision: jspb.Message.getFieldWithDefault(msg, 2, ""), + applicationConfig: msg.getApplicationConfig_asB64() + }; + + if (includeInstance) { + obj.$jspbMessageInstance = msg; + } + return obj; +}; +} + + +/** + * Deserializes binary data (in protobuf wire format). + * @param {jspb.ByteSource} bytes The bytes to deserialize. + * @return {!proto.model.DeploymentSource} + */ +proto.model.DeploymentSource.deserializeBinary = function(bytes) { + var reader = new jspb.BinaryReader(bytes); + var msg = new proto.model.DeploymentSource; + return proto.model.DeploymentSource.deserializeBinaryFromReader(msg, reader); +}; + + +/** + * Deserializes binary data (in protobuf wire format) from the + * given reader into the given message object. + * @param {!proto.model.DeploymentSource} msg The message object to deserialize into. + * @param {!jspb.BinaryReader} reader The BinaryReader to use. + * @return {!proto.model.DeploymentSource} + */ +proto.model.DeploymentSource.deserializeBinaryFromReader = function(msg, reader) { + while (reader.nextField()) { + if (reader.isEndGroup()) { + break; + } + var field = reader.getFieldNumber(); + switch (field) { + case 1: + var value = /** @type {string} */ (reader.readString()); + msg.setApplicationDirectory(value); + break; + case 2: + var value = /** @type {string} */ (reader.readString()); + msg.setRevision(value); + break; + case 3: + var value = /** @type {!Uint8Array} */ (reader.readBytes()); + msg.setApplicationConfig(value); + break; + default: + reader.skipField(); + break; + } + } + return msg; +}; + + +/** + * Serializes the message to binary data (in protobuf wire format). + * @return {!Uint8Array} + */ +proto.model.DeploymentSource.prototype.serializeBinary = function() { + var writer = new jspb.BinaryWriter(); + proto.model.DeploymentSource.serializeBinaryToWriter(this, writer); + return writer.getResultBuffer(); +}; + + +/** + * Serializes the given message to binary data (in protobuf wire + * format), writing to the given BinaryWriter. + * @param {!proto.model.DeploymentSource} message + * @param {!jspb.BinaryWriter} writer + * @suppress {unusedLocalVariables} f is only used for nested messages + */ +proto.model.DeploymentSource.serializeBinaryToWriter = function(message, writer) { + var f = undefined; + f = message.getApplicationDirectory(); + if (f.length > 0) { + writer.writeString( + 1, + f + ); + } + f = message.getRevision(); + if (f.length > 0) { + writer.writeString( + 2, + f + ); + } + f = message.getApplicationConfig_asU8(); + if (f.length > 0) { + writer.writeBytes( + 3, + f + ); + } +}; + + +/** + * optional string application_directory = 1; + * @return {string} + */ +proto.model.DeploymentSource.prototype.getApplicationDirectory = function() { + return /** @type {string} */ (jspb.Message.getFieldWithDefault(this, 1, "")); +}; + + +/** + * @param {string} value + * @return {!proto.model.DeploymentSource} returns this + */ +proto.model.DeploymentSource.prototype.setApplicationDirectory = function(value) { + return jspb.Message.setProto3StringField(this, 1, value); +}; + + +/** + * optional string revision = 2; + * @return {string} + */ +proto.model.DeploymentSource.prototype.getRevision = function() { + return /** @type {string} */ (jspb.Message.getFieldWithDefault(this, 2, "")); +}; + + +/** + * @param {string} value + * @return {!proto.model.DeploymentSource} returns this + */ +proto.model.DeploymentSource.prototype.setRevision = function(value) { + return jspb.Message.setProto3StringField(this, 2, value); +}; + + +/** + * optional bytes application_config = 3; + * @return {string} + */ +proto.model.DeploymentSource.prototype.getApplicationConfig = function() { + return /** @type {string} */ (jspb.Message.getFieldWithDefault(this, 3, "")); +}; + + +/** + * optional bytes application_config = 3; + * This is a type-conversion wrapper around `getApplicationConfig()` + * @return {string} + */ +proto.model.DeploymentSource.prototype.getApplicationConfig_asB64 = function() { + return /** @type {string} */ (jspb.Message.bytesAsB64( + this.getApplicationConfig())); +}; + + +/** + * optional bytes application_config = 3; + * Note that Uint8Array is not supported on all browsers. + * @see http://caniuse.com/Uint8Array + * This is a type-conversion wrapper around `getApplicationConfig()` + * @return {!Uint8Array} + */ +proto.model.DeploymentSource.prototype.getApplicationConfig_asU8 = function() { + return /** @type {!Uint8Array} */ (jspb.Message.bytesAsU8( + this.getApplicationConfig())); +}; + + +/** + * @param {!(string|Uint8Array)} value + * @return {!proto.model.DeploymentSource} returns this + */ +proto.model.DeploymentSource.prototype.setApplicationConfig = function(value) { + return jspb.Message.setProto3BytesField(this, 3, value); +}; + + +goog.object.extend(exports, proto.model); From 65c98a6fcf5f728428a842eb98984bc636c31966 Mon Sep 17 00:00:00 2001 From: Shinnosuke Sawada-Dazai Date: Tue, 24 Sep 2024 10:20:39 +0900 Subject: [PATCH 09/84] Make controller's os.RemoveAll single-threaded (#5217) * Make controller's os.RemoveAll single-threaded Signed-off-by: Shinnosuke Sawada-Dazai * Close the workingDirRemovalCh Signed-off-by: Shinnosuke Sawada-Dazai --------- Signed-off-by: Shinnosuke Sawada-Dazai --- pkg/app/piped/controller/controller.go | 41 ++++++++++++++++++-------- 1 file changed, 28 insertions(+), 13 deletions(-) diff --git a/pkg/app/piped/controller/controller.go b/pkg/app/piped/controller/controller.go index 7924eae0da..f7709f562f 100644 --- a/pkg/app/piped/controller/controller.go +++ b/pkg/app/piped/controller/controller.go @@ -138,6 +138,9 @@ type controller struct { // WaitGroup for waiting the completions of all planners, schedulers. wg sync.WaitGroup + // workingDirRemovalCh is used to single-threaded removal of working directory. + workingDirRemovalCh chan string + workspaceDir string syncInternal time.Duration gracePeriod time.Duration @@ -186,6 +189,8 @@ func NewController( mostRecentlySuccessfulCommits: make(map[string]string), mostRecentlySuccessfulConfigFilenames: make(map[string]string), + workingDirRemovalCh: make(chan string), + syncInternal: 10 * time.Second, gracePeriod: gracePeriod, logger: lg, @@ -219,6 +224,16 @@ func (c *controller) Run(ctx context.Context) error { close(lpStoppedCh) }() + // Start workspace cleaner. + // This will remove the workspace directory of the completed planner/scheduler. + go func() { + for ws := range c.workingDirRemovalCh { + if err := os.RemoveAll(ws); err != nil { + c.logger.Error("failed to remove working directory", zap.String("workDir", ws), zap.Error(err)) + } + } + }() + ticker := time.NewTicker(c.syncInternal) defer ticker.Stop() c.logger.Info("start syncing planners and schedulers") @@ -246,10 +261,19 @@ func (c *controller) shutdown(cancel func(), stoppedCh <-chan error) error { cancel() err := <-stoppedCh + // Stop the workspace cleaner. + // all calls of removeWorkingDir is completed because the c.wg.Wait() is done. + // so we can close the channel safely. + close(c.workingDirRemovalCh) + c.logger.Info("controller has been stopped") return err } +func (c *controller) removeWorkingDir(ws string) { + c.workingDirRemovalCh <- ws +} + // checkCommands lists all unhandled commands for running deployments // and forwards them to their planners and schedulers. func (c *controller) checkCommands() { @@ -483,10 +507,8 @@ func (c *controller) startNewPlanner(ctx context.Context, d *model.Deployment) ( ) cleanup := func() { - logger.Info("cleaning up working directory for planner") - if err := os.RemoveAll(workingDir); err != nil { - logger.Warn("failed to clean working directory", zap.Error(err)) - } + c.removeWorkingDir(workingDir) + logger.Info("cleaned working directory for planner") } // Start running planner. @@ -627,15 +649,8 @@ func (c *controller) startNewScheduler(ctx context.Context, d *model.Deployment) ) cleanup := func() { - logger.Info("cleaning up working directory for scheduler", zap.String("working-dir", workingDir)) - err := os.RemoveAll(workingDir) - if err == nil { - return - } - logger.Warn("failed to clean working directory", - zap.String("working-dir", workingDir), - zap.Error(err), - ) + c.removeWorkingDir(workingDir) + logger.Info("cleaned working directory for scheduler", zap.String("working-dir", workingDir)) } // Start running scheduler. From 083c810b2aa274fe86d4a7bcd4f891e5120174bb Mon Sep 17 00:00:00 2001 From: Khanh Tran <32532742+khanhtc1202@users.noreply.github.com> Date: Tue, 24 Sep 2024 10:27:09 +0900 Subject: [PATCH 10/84] Update deployment readme (#5220) Signed-off-by: khanhtc1202 --- CONTRIBUTING.md | 1 + cmd/pipecd/README.md | 5 +++-- web/README.md | 12 ++++++++++++ 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e5c088c60b..71725dca6f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,6 +2,7 @@ If you're interested in contributing to PipeCD, this document will provide clear instructions on how to get involved. +> Note: Don't bother reading policies and flows, just want to manipulate the code? Jump to the [Development](#development) section. The [Open Source Guides](https://opensource.guide/) website offers a collection of resources for individuals, communities, and companies who want to learn how to run and contribute to an open source project. Both contributors and newcomers to open source will find the following guides especially helpful: diff --git a/cmd/pipecd/README.md b/cmd/pipecd/README.md index 9e78bdc79a..8815c8589e 100644 --- a/cmd/pipecd/README.md +++ b/cmd/pipecd/README.md @@ -4,6 +4,7 @@ ## Prerequisites - [Go 1.22 or later](https://go.dev/) +- [NodeJS v20 or later](https://nodejs.org/en/) - [Docker](https://www.docker.com/) - [kind](https://kind.sigs.k8s.io/docs/user/quick-start/#installation) (If you want to run Control Plane locally) - [helm 3.8](https://helm.sh/docs/intro/install/) (If you want to run Control Plane locally) @@ -22,7 +23,7 @@ For the full list of available commands, please see the Makefile at the root of ## How to run Control Plane locally -1. Start running a Kubernetes cluster +1. Start running a Kubernetes cluster (If you don't have any Kubernetes cluster to use) ``` console make kind-up @@ -53,4 +54,4 @@ For the full list of available commands, please see the Makefile at the root of Point your web browser to [http://localhost:8080](http://localhost:8080) to login with the configured static admin account: project = `quickstart`, username = `hello-pipecd`, password = `hello-pipecd`. ## How to run Piped locally and add an application to your cluster -See [How to run Piped agent locally](https://github.com/pipe-cd/pipecd/tree/master/cmd/piped#how-to-run-piped-agent-locally). \ No newline at end of file +See [How to run Piped agent locally](https://github.com/pipe-cd/pipecd/tree/master/cmd/piped#how-to-run-piped-agent-locally). diff --git a/web/README.md b/web/README.md index b63d35837a..30cc40717e 100644 --- a/web/README.md +++ b/web/README.md @@ -28,7 +28,19 @@ src ## Development +### Prerequisites + +- [NodeJS v20 or later](https://nodejs.org/en/) +- [Yarn](https://yarnpkg.com/) + ### Running with Mocks(msw) + +First time running, you need to install dependencies. + +```bash +make update/web-deps +``` + We use `msw` for mocking API, so you can see UI without running API server. ```bash From b60d1bfb2a1af86591411ad51bd83fd3e4079268 Mon Sep 17 00:00:00 2001 From: Tetsuya Kikuchi <97105818+t-kikuc@users.noreply.github.com> Date: Tue, 24 Sep 2024 10:49:44 +0900 Subject: [PATCH 11/84] Update RELEASE to v0.49.0 (#5221) Signed-off-by: t-kikuc --- RELEASE | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/RELEASE b/RELEASE index a0e1a85613..b5019e3940 100644 --- a/RELEASE +++ b/RELEASE @@ -1,6 +1,6 @@ # Generated by `make release` command. # DO NOT EDIT. -tag: v0.48.9 +tag: v0.49.0 releaseNoteGenerator: showCommitter: false From 909fd76a4976cb0c81f2ad00ed52f398ec49a156 Mon Sep 17 00:00:00 2001 From: Tetsuya Kikuchi <97105818+t-kikuc@users.noreply.github.com> Date: Tue, 24 Sep 2024 11:01:06 +0900 Subject: [PATCH 12/84] Generate v0.49.x docs (#5222) Signed-off-by: t-kikuc --- docs/config.toml | 4 + docs/content/en/docs-v0.49.x/_index.md | 5 + .../en/docs-v0.49.x/concepts/_index.md | 75 ++ .../contribution-guidelines/_index.md | 7 + .../architectural-overview.md | 36 + .../contribution-guidelines/contributing.md | 9 + .../en/docs-v0.49.x/examples/_index.md | 93 ++ docs/content/en/docs-v0.49.x/faq/_index.md | 66 ++ .../en/docs-v0.49.x/feature-status/_index.md | 144 +++ .../en/docs-v0.49.x/installation/_index.md | 20 + .../install-control-plane/_index.md | 9 + .../installing-controlplane-on-ECS.md | 12 + .../installing-controlplane-on-k8s.md | 172 ++++ .../installation/install-piped/_index.md | 9 + .../install-piped/installing-on-cloudrun.md | 174 ++++ .../install-piped/installing-on-fargate.md | 199 +++++ .../installing-on-google-cloud-vm.md | 138 +++ .../install-piped/installing-on-kubernetes.md | 246 +++++ .../installing-on-single-machine.md | 52 ++ .../install-piped/required-permissions.md | 102 +++ .../en/docs-v0.49.x/overview/_index.md | 78 ++ .../en/docs-v0.49.x/quickstart/_index.md | 118 +++ .../en/docs-v0.49.x/releases/_index.md | 6 + .../en/docs-v0.49.x/user-guide/_index.md | 9 + .../user-guide/command-line-tool.md | 393 ++++++++ .../user-guide/configuration-reference.md | 839 ++++++++++++++++++ .../docs-v0.49.x/user-guide/event-watcher.md | 233 +++++ .../user-guide/examples/_index.md | 11 + .../examples/k8s-app-bluegreen-with-istio.md | 126 +++ .../k8s-app-bluegreen-with-pod-selector.md | 11 + .../examples/k8s-app-canary-with-istio.md | 124 +++ .../k8s-app-canary-with-pod-selector.md | 122 +++ .../en/docs-v0.49.x/user-guide/insights.md | 35 + .../user-guide/managing-application/_index.md | 9 + .../adding-an-application.md | 140 +++ .../application-live-state.md | 18 + .../cancelling-a-deployment.md | 17 + .../configuration-drift-detection.md | 101 +++ .../customizing-deployment/_index.md | 14 + .../adding-a-manual-approval.md | 39 + .../adding-a-wait-stage.md | 29 + .../automated-deployment-analysis.md | 295 ++++++ .../customizing-deployment/custom-sync.md | 61 ++ .../customizing-deployment/script-run.md | 190 ++++ .../defining-app-configuration/_index.md | 9 + .../defining-app-configuration/cloudrun.md | 87 ++ .../defining-app-configuration/ecs.md | 164 ++++ .../defining-app-configuration/kubernetes.md | 121 +++ .../defining-app-configuration/lambda.md | 171 ++++ .../defining-app-configuration/terraform.md | 42 + .../managing-application/deployment-chain.md | 64 ++ .../manifest-attachment.md | 65 ++ .../rolling-back-a-deployment.md | 21 + .../managing-application/secret-management.md | 122 +++ .../triggering-a-deployment.md | 50 ++ .../managing-controlplane/_index.md | 7 + .../managing-controlplane/adding-a-project.md | 24 + .../architecture-overview.md | 40 + .../user-guide/managing-controlplane/auth.md | 183 ++++ .../configuration-reference.md | 176 ++++ .../registering-a-piped.md | 16 + .../user-guide/managing-piped/_index.md | 11 + .../managing-piped/adding-a-cloud-provider.md | 134 +++ .../managing-piped/adding-a-git-repository.md | 41 + .../adding-a-platform-provider.md | 132 +++ .../adding-an-analysis-provider.md | 55 ++ ...dding-helm-chart-repository-or-registry.md | 60 ++ .../managing-piped/configuration-reference.md | 277 ++++++ .../configuring-event-watcher.md | 62 ++ .../configuring-notifications.md | 137 +++ .../remote-upgrade-remote-config.md | 39 + .../managing-piped/runtime-options.md | 76 ++ .../managing-piped/using-pprof-in-piped.md | 48 + .../en/docs-v0.49.x/user-guide/metrics.md | 124 +++ .../docs-v0.49.x/user-guide/plan-preview.md | 52 ++ .../user-guide/terraform-provider-pipecd.md | 68 ++ docs/layouts/docs-v0.49.x/baseof.html | 32 + docs/layouts/docs-v0.49.x/baseof.print.html | 26 + docs/layouts/docs-v0.49.x/list.html | 32 + docs/layouts/docs-v0.49.x/list.print.html | 3 + docs/layouts/docs-v0.49.x/single.html | 3 + docs/main.go | 2 +- 82 files changed, 7365 insertions(+), 1 deletion(-) create mode 100755 docs/content/en/docs-v0.49.x/_index.md create mode 100644 docs/content/en/docs-v0.49.x/concepts/_index.md create mode 100755 docs/content/en/docs-v0.49.x/contribution-guidelines/_index.md create mode 100644 docs/content/en/docs-v0.49.x/contribution-guidelines/architectural-overview.md create mode 100644 docs/content/en/docs-v0.49.x/contribution-guidelines/contributing.md create mode 100755 docs/content/en/docs-v0.49.x/examples/_index.md create mode 100644 docs/content/en/docs-v0.49.x/faq/_index.md create mode 100644 docs/content/en/docs-v0.49.x/feature-status/_index.md create mode 100644 docs/content/en/docs-v0.49.x/installation/_index.md create mode 100644 docs/content/en/docs-v0.49.x/installation/install-control-plane/_index.md create mode 100644 docs/content/en/docs-v0.49.x/installation/install-control-plane/installing-controlplane-on-ECS.md create mode 100644 docs/content/en/docs-v0.49.x/installation/install-control-plane/installing-controlplane-on-k8s.md create mode 100644 docs/content/en/docs-v0.49.x/installation/install-piped/_index.md create mode 100644 docs/content/en/docs-v0.49.x/installation/install-piped/installing-on-cloudrun.md create mode 100644 docs/content/en/docs-v0.49.x/installation/install-piped/installing-on-fargate.md create mode 100644 docs/content/en/docs-v0.49.x/installation/install-piped/installing-on-google-cloud-vm.md create mode 100644 docs/content/en/docs-v0.49.x/installation/install-piped/installing-on-kubernetes.md create mode 100644 docs/content/en/docs-v0.49.x/installation/install-piped/installing-on-single-machine.md create mode 100644 docs/content/en/docs-v0.49.x/installation/install-piped/required-permissions.md create mode 100644 docs/content/en/docs-v0.49.x/overview/_index.md create mode 100644 docs/content/en/docs-v0.49.x/quickstart/_index.md create mode 100644 docs/content/en/docs-v0.49.x/releases/_index.md create mode 100755 docs/content/en/docs-v0.49.x/user-guide/_index.md create mode 100644 docs/content/en/docs-v0.49.x/user-guide/command-line-tool.md create mode 100644 docs/content/en/docs-v0.49.x/user-guide/configuration-reference.md create mode 100644 docs/content/en/docs-v0.49.x/user-guide/event-watcher.md create mode 100755 docs/content/en/docs-v0.49.x/user-guide/examples/_index.md create mode 100644 docs/content/en/docs-v0.49.x/user-guide/examples/k8s-app-bluegreen-with-istio.md create mode 100644 docs/content/en/docs-v0.49.x/user-guide/examples/k8s-app-bluegreen-with-pod-selector.md create mode 100644 docs/content/en/docs-v0.49.x/user-guide/examples/k8s-app-canary-with-istio.md create mode 100644 docs/content/en/docs-v0.49.x/user-guide/examples/k8s-app-canary-with-pod-selector.md create mode 100644 docs/content/en/docs-v0.49.x/user-guide/insights.md create mode 100644 docs/content/en/docs-v0.49.x/user-guide/managing-application/_index.md create mode 100644 docs/content/en/docs-v0.49.x/user-guide/managing-application/adding-an-application.md create mode 100644 docs/content/en/docs-v0.49.x/user-guide/managing-application/application-live-state.md create mode 100644 docs/content/en/docs-v0.49.x/user-guide/managing-application/cancelling-a-deployment.md create mode 100644 docs/content/en/docs-v0.49.x/user-guide/managing-application/configuration-drift-detection.md create mode 100644 docs/content/en/docs-v0.49.x/user-guide/managing-application/customizing-deployment/_index.md create mode 100644 docs/content/en/docs-v0.49.x/user-guide/managing-application/customizing-deployment/adding-a-manual-approval.md create mode 100644 docs/content/en/docs-v0.49.x/user-guide/managing-application/customizing-deployment/adding-a-wait-stage.md create mode 100644 docs/content/en/docs-v0.49.x/user-guide/managing-application/customizing-deployment/automated-deployment-analysis.md create mode 100644 docs/content/en/docs-v0.49.x/user-guide/managing-application/customizing-deployment/custom-sync.md create mode 100644 docs/content/en/docs-v0.49.x/user-guide/managing-application/customizing-deployment/script-run.md create mode 100644 docs/content/en/docs-v0.49.x/user-guide/managing-application/defining-app-configuration/_index.md create mode 100644 docs/content/en/docs-v0.49.x/user-guide/managing-application/defining-app-configuration/cloudrun.md create mode 100644 docs/content/en/docs-v0.49.x/user-guide/managing-application/defining-app-configuration/ecs.md create mode 100644 docs/content/en/docs-v0.49.x/user-guide/managing-application/defining-app-configuration/kubernetes.md create mode 100644 docs/content/en/docs-v0.49.x/user-guide/managing-application/defining-app-configuration/lambda.md create mode 100644 docs/content/en/docs-v0.49.x/user-guide/managing-application/defining-app-configuration/terraform.md create mode 100644 docs/content/en/docs-v0.49.x/user-guide/managing-application/deployment-chain.md create mode 100644 docs/content/en/docs-v0.49.x/user-guide/managing-application/manifest-attachment.md create mode 100644 docs/content/en/docs-v0.49.x/user-guide/managing-application/rolling-back-a-deployment.md create mode 100755 docs/content/en/docs-v0.49.x/user-guide/managing-application/secret-management.md create mode 100644 docs/content/en/docs-v0.49.x/user-guide/managing-application/triggering-a-deployment.md create mode 100644 docs/content/en/docs-v0.49.x/user-guide/managing-controlplane/_index.md create mode 100644 docs/content/en/docs-v0.49.x/user-guide/managing-controlplane/adding-a-project.md create mode 100644 docs/content/en/docs-v0.49.x/user-guide/managing-controlplane/architecture-overview.md create mode 100644 docs/content/en/docs-v0.49.x/user-guide/managing-controlplane/auth.md create mode 100644 docs/content/en/docs-v0.49.x/user-guide/managing-controlplane/configuration-reference.md create mode 100644 docs/content/en/docs-v0.49.x/user-guide/managing-controlplane/registering-a-piped.md create mode 100644 docs/content/en/docs-v0.49.x/user-guide/managing-piped/_index.md create mode 100644 docs/content/en/docs-v0.49.x/user-guide/managing-piped/adding-a-cloud-provider.md create mode 100644 docs/content/en/docs-v0.49.x/user-guide/managing-piped/adding-a-git-repository.md create mode 100644 docs/content/en/docs-v0.49.x/user-guide/managing-piped/adding-a-platform-provider.md create mode 100644 docs/content/en/docs-v0.49.x/user-guide/managing-piped/adding-an-analysis-provider.md create mode 100644 docs/content/en/docs-v0.49.x/user-guide/managing-piped/adding-helm-chart-repository-or-registry.md create mode 100644 docs/content/en/docs-v0.49.x/user-guide/managing-piped/configuration-reference.md create mode 100644 docs/content/en/docs-v0.49.x/user-guide/managing-piped/configuring-event-watcher.md create mode 100644 docs/content/en/docs-v0.49.x/user-guide/managing-piped/configuring-notifications.md create mode 100644 docs/content/en/docs-v0.49.x/user-guide/managing-piped/remote-upgrade-remote-config.md create mode 100644 docs/content/en/docs-v0.49.x/user-guide/managing-piped/runtime-options.md create mode 100644 docs/content/en/docs-v0.49.x/user-guide/managing-piped/using-pprof-in-piped.md create mode 100644 docs/content/en/docs-v0.49.x/user-guide/metrics.md create mode 100644 docs/content/en/docs-v0.49.x/user-guide/plan-preview.md create mode 100644 docs/content/en/docs-v0.49.x/user-guide/terraform-provider-pipecd.md create mode 100644 docs/layouts/docs-v0.49.x/baseof.html create mode 100644 docs/layouts/docs-v0.49.x/baseof.print.html create mode 100644 docs/layouts/docs-v0.49.x/list.html create mode 100644 docs/layouts/docs-v0.49.x/list.print.html create mode 100644 docs/layouts/docs-v0.49.x/single.html diff --git a/docs/config.toml b/docs/config.toml index 36fa6ceb48..b78d7c0afe 100644 --- a/docs/config.toml +++ b/docs/config.toml @@ -167,6 +167,10 @@ no = 'Sorry to hear that. Please + This page describes several core concepts in PipeCD. +--- + +![](/images/architecture-overview.png) +

+Component Architecture +

+ +### Piped + +`piped` is a single binary component you run as an agent in your cluster, your local network to handle the deployment tasks. +It can be run inside a Kubernetes cluster by simply starting a Pod or a Deployment. +This component is designed to be stateless, so it can also be run in a single VM or even your local machine. + +### Control Plane + +A centralized component managing deployment data and provides gRPC API for connecting `piped`s as well as all web-functionalities of PipeCD such as +authentication, showing deployment list/details, application list/details, delivery insights... + +### Project + +A project is a logical group of applications to be managed by a group of users. +Each project can have multiple `piped` instances from different clouds or environments. + +There are three types of project roles: + +- **Viewer** has only permissions of viewing to deployment and application in the project. +- **Editor** has all viewer permissions, plus permissions for actions that modify state such as manually trigger/cancel the deployment. +- **Admin** has all editor permissions, plus permissions for managing project data, managing project `piped`. + +### Application + +A collect of resources (containers, services, infrastructure components...) and configurations that are managed together. +PipeCD supports multiple kinds of applications such as `KUBERNETES`, `TERRAFORM`, `ECS`, `CLOUDRUN`, `LAMBDA`... + +### Application Configuration + +A YAML file that contains information to define and configure application. +Each application requires one file at application directory stored in the Git repository. +The default file name is `app.pipecd.yaml`. + +### Application Directory + +A directory in Git repository containing application configuration file and application manifests. +Each application must have one application directory. + +### Deployment + +A deployment is a process that does transition from the current state (running state) to the desired state (specified state in Git) of a specific application. +When the deployment is success, it means the running state is being synced with the desired state specified in the target commit. + +### Sync Strategy + +There are 3 strategies that PipeCD supports while syncing your application state with its configuration stored in Git. Which are: +- Quick Sync: a fast way to make the running application state as same as its Git stored configuration. The generated pipeline contains only one predefined `SYNC` stage. +- Pipeline Sync: sync the running application state with its Git stored configuration through a pipeline defined in its application configuration. +- Auto Sync: depends on your defined application configuration, `piped` will decide the best way to sync your application state with its Git stored configuration. + +### Platform Provider + +Note: The previous name of this concept was Cloud Provider. + +PipeCD supports multiple platforms and multiple kinds of applications. +Platform Provider defines which platform, cloud and where application should be deployed to. + +Currently, PipeCD is supporting these five platform providers: `KUBERNETES`, `ECS`, `TERRAFORM`, `CLOUDRUN`, `LAMBDA`. + +### Analysis Provider +An external product that provides metrics/logs to evaluate deployments, such as `Prometheus`, `Datadog`, `Stackdriver`, `CloudWatch` and so on. +It is mainly used in the [Automated deployment analysis](../user-guide/managing-application/customizing-deployment/automated-deployment-analysis/) context. diff --git a/docs/content/en/docs-v0.49.x/contribution-guidelines/_index.md b/docs/content/en/docs-v0.49.x/contribution-guidelines/_index.md new file mode 100755 index 0000000000..b47753d9aa --- /dev/null +++ b/docs/content/en/docs-v0.49.x/contribution-guidelines/_index.md @@ -0,0 +1,7 @@ +--- +title: "Contributor Guide" +linkTitle: "Contributor Guide" +weight: 6 +description: > + This guide is for anyone who want to contribute to PipeCD project. We are so excited to have you! +--- diff --git a/docs/content/en/docs-v0.49.x/contribution-guidelines/architectural-overview.md b/docs/content/en/docs-v0.49.x/contribution-guidelines/architectural-overview.md new file mode 100644 index 0000000000..c7569db0f4 --- /dev/null +++ b/docs/content/en/docs-v0.49.x/contribution-guidelines/architectural-overview.md @@ -0,0 +1,36 @@ +--- +title: "Architectural overview" +linkTitle: "Architectural overview" +weight: 3 +description: > + This page describes the architecture of PipeCD. +--- + +![](/images/architecture-overview.png) +

+Component Architecture +

+ +### Piped + +A single binary component runs in your cluster, your local network to handle the deployment tasks. +It can be run inside a Kubernetes cluster by simply starting a Pod or a Deployment. +This component is designed to be stateless, so it can also be run in a single VM or even your local machine. + +### Control Plane + +A centralized component manages deployment data and provides gRPC API for connecting `piped`s as well as all web-functionalities of PipeCD such as +authentication, showing deployment list/details, application list/details, delivery insights... + +Control Plane contains the following components: +- `server`: a service to provide api for piped, web and serve static assets for web. +- `ops`: a service to provide administrative features for Control Plane owner like adding/managing projects. +- `cache`: a redis cache service for caching internal data. +- `datastore`: data storage for storing deployment, application data + - this can be a fully-managed service such as `Firestore`, `Cloud SQL`... + - or a self-managed such as `MySQL` +- `filestore`: file storage for storing logs, application states + - this can a fully-managed service such as `GCS`, `S3`... + - or a self-managed service such as `Minio` + +For more information, see [Architecture overview of Control Plane](../../user-guide/managing-controlplane/architecture-overview/). diff --git a/docs/content/en/docs-v0.49.x/contribution-guidelines/contributing.md b/docs/content/en/docs-v0.49.x/contribution-guidelines/contributing.md new file mode 100644 index 0000000000..87eb1a51c0 --- /dev/null +++ b/docs/content/en/docs-v0.49.x/contribution-guidelines/contributing.md @@ -0,0 +1,9 @@ +--- +title: "Contributing" +linkTitle: "Contributing" +weight: 1 +description: > + This page describes how to contribute to PipeCD. +--- + +PipeCD is an open source project that anyone in the community can use, improve, and enjoy. We'd love you to join us! [Contributing to PipeCD](https://github.com/pipe-cd/pipecd/tree/master/CONTRIBUTING.md) is the best place to start with. \ No newline at end of file diff --git a/docs/content/en/docs-v0.49.x/examples/_index.md b/docs/content/en/docs-v0.49.x/examples/_index.md new file mode 100755 index 0000000000..23ee01ffba --- /dev/null +++ b/docs/content/en/docs-v0.49.x/examples/_index.md @@ -0,0 +1,93 @@ +--- +title: "Examples" +linkTitle: "Examples" +weight: 7 +description: > + Some examples of PipeCD in action! +--- + +One of the best ways to see what PipeCD can do, and learn how to deploy your applications with it, is to see some real examples. + +We have prepared some examples for each kind of application. +The examples can be found at the following repository: + +https://github.com/pipe-cd/examples + +### Kubernetes Applications + +| Name | Description | +|-----------------------------------------------------------------------------|-------------| +| [simple](https://github.com/pipe-cd/examples/tree/master/kubernetes/simple) | Deploy plain-yaml manifests in application directory without using pipeline. | +| [helm-local-chart](https://github.com/pipe-cd/examples/tree/master/kubernetes/helm-local-chart) | Deploy a helm chart sourced from the same Git repository. | +| [helm-remote-chart](https://github.com/pipe-cd/examples/tree/master/kubernetes/helm-remote-chart) | Deploy a helm chart sourced from a [Helm Chart Repository](https://helm.sh/docs/topics/chart_repository/). | +| [helm-remote-git-chart](https://github.com/pipe-cd/examples/tree/master/kubernetes/helm-remote-git-chart) | Deploy a helm chart sourced from another Git repository. | +| [kustomize-local-base](https://github.com/pipe-cd/examples/tree/master/kubernetes/kustomize-local-base) | Deploy a kustomize package that just uses the local bases from the same Git repository. | +| [kustomize-remote-base](https://github.com/pipe-cd/examples/tree/master/kubernetes/kustomize-remote-base) | Deploy a kustomize package that uses remote bases from other Git repositories. | +| [canary](https://github.com/pipe-cd/examples/tree/master/kubernetes/canary) | Deployment pipeline with canary strategy. | +| [canary-by-config-change](https://github.com/pipe-cd/examples/tree/master/kubernetes/canary-by-config-change) | Deployment pipeline with canary strategy when ConfigMap was changed. | +| [canary-patch](https://github.com/pipe-cd/examples/tree/master/kubernetes/canary-patch) | Demonstrate how to customize manifests for Canary variant using [patches](../user-guide/configuration-reference/#kubernetescanaryrolloutstageoptions) option. | +| [bluegreen](https://github.com/pipe-cd/examples/tree/master/kubernetes/bluegreen) | Deployment pipeline with bluegreen strategy. This also contains a manual approval stage. | +| [mesh-istio-canary](https://github.com/pipe-cd/examples/tree/master/kubernetes/mesh-istio-canary) | Deployment pipeline with canary strategy by using Istio for traffic routing. | +| [mesh-istio-bluegreen](https://github.com/pipe-cd/examples/tree/master/kubernetes/mesh-istio-bluegreen) | Deployment pipeline with bluegreen strategy by using Istio for traffic routing. | +| [mesh-smi-canary](https://github.com/pipe-cd/examples/tree/master/kubernetes/mesh-smi-canary) | Deployment pipeline with canary strategy by using SMI for traffic routing. | +| [mesh-smi-bluegreen](https://github.com/pipe-cd/examples/tree/master/kubernetes/mesh-smi-bluegreen) | Deployment pipeline with bluegreen strategy by using SMI for traffic routing. | +| [wait-approval](https://github.com/pipe-cd/examples/tree/master/kubernetes/wait-approval) | Deployment pipeline that contains a manual approval stage. | +| [multi-steps-canary](https://github.com/pipe-cd/examples/tree/master/kubernetes/multi-steps-canary) | Deployment pipeline with multiple canary steps. | +| [analysis-by-metrics](https://github.com/pipe-cd/examples/tree/master/kubernetes/analysis-by-metrics) | Deployment pipeline with analysis stage by metrics. | +| [analysis-by-http](https://github.com/pipe-cd/examples/tree/master/kubernetes/analysis-by-http) | Deployment pipeline with analysis stage by running http requests. | +| [analysis-by-log](https://github.com/pipe-cd/examples/tree/master/kubernetes/analysis-by-log) | Deployment pipeline with analysis stage by checking logs. | +| [analysis-with-baseline](https://github.com/pipe-cd/examples/tree/master/kubernetes/analysis-with-baseline) | Deployment pipeline with analysis stage by comparing baseline and canary. | +| [secret-management](https://github.com/pipe-cd/examples/tree/master/kubernetes/secret-management) | Demonstrate how to manage sensitive data by using [Secret Management](../user-guide/managing-application/secret-management/) feature. | + +### Terraform Applications + +| Name | Description | +|-----------------------------------------------------------------------------|-------------| +| [simple](https://github.com/pipe-cd/examples/tree/master/terraform/simple) | Automatically applies when any changes were detected. | +| [local-module](https://github.com/pipe-cd/examples/tree/master/terraform/local-module) | Deploy application that using local terraform modules from the same Git repository. | +| [remote-module](https://github.com/pipe-cd/examples/tree/master/terraform/remote-module) | Deploy application that using remote terraform modules from other Git repositories. | +| [wait-approval](https://github.com/pipe-cd/examples/tree/master/terraform/wait-approval) | Deployment pipeline that contains a manual approval stage. | +| [autorollback](https://github.com/pipe-cd/examples/tree/master/terraform/autorollback) | Automatically rollback the changes when deployment was failed. | +| [secret-management](https://github.com/pipe-cd/examples/tree/master/terraform/secret-management) | Demonstrate how to manage sensitive data by using [Secret Management](../user-guide/managing-application/secret-management/) feature. | + +### Cloud Run Applications + +| Name | Description | +|-----------------------------------------------------------------------------|-------------| +| [simple](https://github.com/pipe-cd/examples/tree/master/cloudrun/simple) | Quick sync by rolling out the new version and switching all traffic to it. | +| [canary](https://github.com/pipe-cd/examples/tree/master/cloudrun/canary) | Deployment pipeline with canary strategy. | +| [analysis](https://github.com/pipe-cd/examples/tree/master/cloudrun/analysis) | Deployment pipeline that contains an analysis stage. | +| [secret-management](https://github.com/pipe-cd/examples/tree/master/cloudrun/secret-management) | Demonstrate how to manage sensitive data by using [Secret Management](../user-guide/managing-application/secret-management/) feature. | +| [wait-approval](https://github.com/pipe-cd/examples/tree/master/cloudrun/wait-approval) | Deployment pipeline that contains a manual approval stage. | + +### Lambda Applications + +| Name | Description | +|-----------------------------------------------------------------------------|-------------| +| [simple](https://github.com/pipe-cd/examples/tree/master/lambda/simple) | Quick sync by rolling out the new version and switching all traffic to it. | +| [canary](https://github.com/pipe-cd/examples/tree/master/lambda/canary) | Deployment pipeline with canary strategy. | +| [analysis](https://github.com/pipe-cd/examples/tree/master/lambda/analysis) | Deployment pipeline that contains an analysis stage. | +| [secret-management](https://github.com/pipe-cd/examples/tree/master/lambda/secret-management) | Demonstrate how to manage sensitive data by using [Secret Management](../user-guide/managing-application/secret-management/) feature. | +| [wait-approval](https://github.com/pipe-cd/examples/tree/master/lambda/wait-approval) | Deployment pipeline that contains a manual approval stage. | +| [remote-git](https://github.com/pipe-cd/examples/tree/master/lambda/remote-git) | Deploy the lambda code sourced from another Git repository. | +| [zip-packing-s3](https://github.com/pipe-cd/examples/tree/master/lambda/zip-packing-s3) | Deployment pipeline of kind Lambda which uses s3 stored zip file as function code. | + +### ECS Applications + +| Name | Description | +|-----------------------------------------------------------------------------|-------------| +| [simple](https://github.com/pipe-cd/examples/tree/master/ecs/simple) | Quick sync by rolling out the new version and switching all traffic to it. | +| [simple-via-servicediscovery](https://github.com/pipe-cd/examples/tree/master/ecs/servicediscovery/simple) | Quick sync by rolling out the new version and switching all traffic to it for ECS Service Discovery. | +| [canary](https://github.com/pipe-cd/examples/tree/master/ecs/canary) | Deployment pipeline with canary strategy. | +| [canary-via-servicediscovery](https://github.com/pipe-cd/examples/tree/master/ecs/servicediscovery/canary) | Deployment pipeline with canary strategy for ECS Service Discovery. | +| [bluegreen](https://github.com/pipe-cd/examples/tree/master/ecs/bluegreen) | Deployment pipeline with blue-green strategy. | +| [secret-management](https://github.com/pipe-cd/examples/tree/master/ecs/secret-management) | Demonstrate how to manage sensitive data by using [Secret Management](../user-guide/managing-application/secret-management/) feature. | +| [wait-approval](https://github.com/pipe-cd/examples/tree/master/ecs/wait-approval) | Deployment pipeline that contains a manual approval stage. | +| [standalone-task](https://github.com/pipe-cd/examples/tree/master/ecs/standalone-task) | Deployment Standalone Task. (`Standalone task is only supported for Quick sync`) | + + +### Deployment chain + +| Name | Description | +|-----------------------------------------------------------------------------|-------------| +| [simple](https://github.com/pipe-cd/examples/tree/master/deployment-chain/simple) | Simple deployment chain which uses application name as a filter in chain configuration. | diff --git a/docs/content/en/docs-v0.49.x/faq/_index.md b/docs/content/en/docs-v0.49.x/faq/_index.md new file mode 100644 index 0000000000..e4a99acc8c --- /dev/null +++ b/docs/content/en/docs-v0.49.x/faq/_index.md @@ -0,0 +1,66 @@ +--- +title: "FAQ" +linkTitle: "FAQ" +weight: 9 +description: > + List of frequently asked questions. +--- + +If you have any other questions, please feel free to create the issue in the [pipe-cd/pipecd](https://github.com/pipe-cd/pipecd/issues/new/choose) repository or contact us on [Cloud Native Slack](https://slack.cncf.io) (channel [#pipecd](https://app.slack.com/client/T08PSQ7BQ/C01B27F9T0X)). + +### 1. What kind of application (platform provider) will be supported? + +Currently, PipeCD can be used to deploy `Kubernetes`, `ECS`, `Terraform`, `CloudRun`, `Lambda` applications. + +In the near future we also want to support `Crossplane`... + +### 2. What kind of templating methods for Kubernetes application will be supported? + +Currently, PipeCD is supporting `Helm` and `Kustomize` as templating method for Kubernetes applications. + +### 3. Istio is supported now? + +Yes, you can use PipeCD for both mesh (Istio, SMI) applications and non-mesh applications. + +### 4. What are the differences between PipeCD and FluxCD? + +- Not just Kubernetes applications, PipeCD also provides a unified interface for other cloud services (CloudRun, AWS Lamda...) and Terraform +- One tool for both GitOps sync and progressive deployment +- Supports multiple Git repositories +- Has web UI for better visibility + - Log viewer for each deployment + - Visualization of application component/state in realtime + - Show configuration drift in realtime +- Also supports Canary and BlueGreen for non-mesh applications +- Has built-in secrets management +- Shows the delivery performance insights + +### 5. What are the differences between PipeCD and ArgoCD? + +- Not just Kubernetes applications, PipeCD also provides a unified interface for other cloud services (GCP CloudRun, AWS Lamda...) and Terraform +- One tool for both GitOps sync and progressive deployment +- Don't need another CRD or changing the existing manifests for doing Canary/BlueGreen. PipeCD just uses the standard Kubernetes deployment object +- Easier and safer to operate multi-tenancy, multi-cluster for multiple teams (even some teams are running in a private/restricted network) +- Has built-in secrets management +- Shows the delivery performance insights + +### 6. What should I do if I lost my Piped key? + +You can create a new Piped key. Go to the `Piped` tab at `Settings` page, and click the vertical ellipsis of the Piped that you would like to create the new Piped key. Don't forget deleting the old Key, too. + +### 7. What is the strong point if PipeCD is used only for Kubernetes? + +- Simple interface, easy to understand no extra CRD required +- Easy to install, upgrade, and manage (both the ControlPlane and the agent Piped) +- Not strict depend on any Kubernetes API, not being part of issues for your Kubernetes cluster versioning upgrade +- Easy to interact with any CI; Plan preview feature gives you an early look at what will be changed in your cluster even before manifests update +- Insights show metrics like lead time, deployment frequency, MTTR, and change failure rate to measure delivery performance + +### 8. Is it open source? + +Yes, PipeCD is fully open source project with APACHE LICENSE, VERSION 2.0!! + +### 9. How should I investigate high CPU usage or memory usage in piped, or when OOM occurs? + +If you're noticing high CPU usage, memory usage, or facing OOM issues in Piped, you can use the built-in support for `pprof`, a tool for visualization and analysis of profiling data. +`pprof` can help you identify the parts of your application that are consuming the most resources. For more detailed information and examples of how to use `pprof` in Piped, please refer to our [Using Pprof in Piped guide](../managing-piped/using-pprof-in-piped). diff --git a/docs/content/en/docs-v0.49.x/feature-status/_index.md b/docs/content/en/docs-v0.49.x/feature-status/_index.md new file mode 100644 index 0000000000..25b11caa07 --- /dev/null +++ b/docs/content/en/docs-v0.49.x/feature-status/_index.md @@ -0,0 +1,144 @@ +--- +title: "Feature Status" +linkTitle: "Feature Status" +weight: 8 +description: > + This page lists the relative maturity of every PipeCD features. +--- + +Please note that the phases (Incubating, Alpha, Beta, and Stable) are applied to individual features within the project, not to the project as a whole. + +## Feature Phase Definitions + +| Phase | Definition | +|-|-| +| Incubating | Under planning/developing the prototype and still not ready to be used. | +| Alpha | Demo-able, works end-to-end but has limitations. No guarantees on backward compatibility. | +| Beta | **Usable in production**. Documented. | +| Stable | Production hardened. Backward compatibility. Documented. | + +## Provider + +### Kubernetes + +| Feature | Phase | +|-|-| +| Quick sync deployment | Beta | +| Deployment with a defined pipeline (e.g. canary, analysis) | Beta | +| [Automated rollback](../user-guide/managing-application/rolling-back-a-deployment/) | Beta | +| [Automated configuration drift detection](../user-guide/managing-application/configuration-drift-detection/) | Beta | +| [Application live state](../user-guide/managing-application/application-live-state/) | Beta | +| Support Helm | Beta | +| Support Kustomize | Beta | +| Support Istio service mesh | Beta | +| Support SMI service mesh | Incubating | +| Support [AWS App Mesh](https://aws.amazon.com/app-mesh/) | Incubating | +| [Plan preview](../user-guide/plan-preview) | Beta | +| [Manifest attachment](../user-guide/managing-application/manifest-attachment) | Alpha | + +### Terraform + +| Feature | Phase | +|-|-| +| Quick sync deployment | Beta | +| Deployment with a defined pipeline (e.g. manual-approval) | Beta | +| [Automated rollback](../user-guide/managing-application/rolling-back-a-deployment/) | Beta | +| [Automated configuration drift detection](../user-guide/managing-application/configuration-drift-detection/) | Alpha | +| [Application live state](../user-guide/managing-application/application-live-state/) | Incubating | +| [Plan preview](../user-guide/plan-preview) | Beta | +| [Manifest attachment](../user-guide/managing-application/manifest-attachment) | Alpha | + +### Cloud Run + +| Feature | Phase | +|-|-| +| Quick sync deployment | Beta | +| Deployment with a defined pipeline (e.g. canary, analysis) | Beta | +| [Automated rollback](../user-guide/managing-application/rolling-back-a-deployment/) | Beta | +| [Automated configuration drift detection](../user-guide/managing-application/configuration-drift-detection/) | Beta | +| [Application live state](../user-guide/managing-application/application-live-state/) | Beta | +| [Plan preview](../user-guide/plan-preview) | Beta | +| [Manifest attachment](../user-guide/managing-application/manifest-attachment) | Alpha | + +### Lambda + +| Feature | Phase | +|-|-| +| Quick sync deployment | Beta | +| Deployment with a defined pipeline (e.g. canary, analysis) | Beta | +| [Automated rollback](../user-guide/managing-application/rolling-back-a-deployment/) | Beta | +| [Automated configuration drift detection](../user-guide/managing-application/configuration-drift-detection/) | Alpha | +| [Application live state](../user-guide/managing-application/application-live-state/) | Alpha | +| [Plan preview](../user-guide/plan-preview) | Alpha | +| [Manifest attachment](../user-guide/managing-application/manifest-attachment) | Alpha | + +### Amazon ECS + +| Feature | Phase | +|-|-| +| Quick sync deployment | Alpha | +| Deployment with a defined pipeline (e.g. canary, analysis) | Alpha | +| [Automated rollback](../user-guide/managing-application/rolling-back-a-deployment/) | Beta | +| [Automated configuration drift detection](../user-guide/managing-application/configuration-drift-detection/) | Alpha *1 | +| [Application live state](../user-guide/managing-application/application-live-state/) | Alpha *1 | +| Quick sync deployment for [ECS Service Discovery](https://docs.aws.amazon.com/AmazonECS/latest/developerguide/service-discovery.html) | Alpha | +| Deployment with a defined pipeline for [ECS Service Discovery](https://docs.aws.amazon.com/AmazonECS/latest/developerguide/service-discovery.html) | Alpha | +| Support [AWS App Mesh](https://aws.amazon.com/app-mesh/) | Incubating | +| [Plan preview](../user-guide/plan-preview) | Alpha | +| [Manifest attachment](../user-guide/managing-application/manifest-attachment) | Alpha | + +*1. Not supported yet for standalone tasks. + +## Piped Agent + +| Feature | Phase | +|-|-| +| [Deployment wait stage](../user-guide/managing-application/customizing-deployment/adding-a-wait-stage/) | Beta | +| [Deployment manual approval stage](../user-guide/managing-application/customizing-deployment/adding-a-manual-approval/) | Beta | +| [Notification](../user-guide/managing-piped/configuring-notifications/) to Slack | Beta | +| [Notification](../user-guide/managing-piped/configuring-notifications/) to external service via webhook | Beta | +| [Secrets management](../user-guide/managing-application/secret-management/) - Storing secrets safely in the Git repository | Beta | +| [Event watcher](../user-guide/event-watcher/) - Updating files in Git automatically for given events | Beta | +| [Pipectl](../user-guide/command-line-tool/) - Command-line tool for interacting with Control Plane | Beta | +| Deployment plugin - Allow executing user-created deployment plugin | Incubating | +| [ADA](../user-guide/managing-application/customizing-deployment/automated-deployment-analysis/) (Automated Deployment Analysis) by Prometheus metrics | Beta | +| [ADA](../user-guide/managing-application/customizing-deployment/automated-deployment-analysis/) by Datadog metrics | Beta | +| [ADA](../user-guide/managing-application/customizing-deployment/automated-deployment-analysis/) by Stackdriver metrics | Incubating | +| [ADA](../user-guide/managing-application/customizing-deployment/automated-deployment-analysis/) by Stackdriver log | Incubating | +| [ADA](../user-guide/managing-application/customizing-deployment/automated-deployment-analysis/) by CloudWatch metrics | Incubating | +| [ADA](../user-guide/managing-application/customizing-deployment/automated-deployment-analysis/) by CloudWatch log | Incubating | +| [ADA](../user-guide/managing-application/customizing-deployment/automated-deployment-analysis/) by HTTP request (smoke test...) | Incubating | +| [Remote upgrade](../user-guide/managing-piped/remote-upgrade-remote-config/#remote-upgrade) - Ability to upgrade Piped from the web console | Beta | +| [Remote config](../user-guide/managing-piped/remote-upgrade-remote-config/#remote-config) - Watch and reload configuration from a remote location such as Git | Beta | + +## Control Plane + +| Feature | Phase | +|-|-| +| Project/Piped/Application/Deployment management | Beta | +| Rendering deployment pipeline in realtime | Beta | +| Canceling a deployment from console | Beta | +| Triggering a deployment manually from console | Beta | +| RBAC on PipeCD resources such as Application, Piped... | Alpha | +| Authentication by username/password for static admin | Beta | +| GitHub & GitHub Enterprise Server SSO | Beta | +| Support GCP [Firestore](https://cloud.google.com/firestore) as data store | Beta | +| Support [MySQL v8.0](https://www.mysql.com/) as data store | Beta | +| Support file store as data store | Alpha | +| Support GCP [GCS](https://cloud.google.com/storage) as file store | Beta | +| Support AWS [S3](https://aws.amazon.com/s3/) as file store | Beta | +| Support [Minio](https://github.com/minio/minio) as file store | Beta | +| Support using file storage such as GCS, S3, Minio for both data store and file store (It means no database is required to run control plane) | Incubating | +| [Insights](../user-guide/insights/) - Show the delivery performance of a team or an application | Incubating | +| [Deployment Chain](../user-guide/managing-application/deployment-chain/) - Allow rolling out to multiple clusters gradually or promoting across environments | Alpha | +| [Metrics](../user-guide/managing-controlplane/metrics/) - Dashboards for PipeCD and Piped metrics | Beta | + +## [pipectl](../user-guide/command-line-tool/) + +### [pipectl init](../user-guide/command-line-tool.md#generating-an-application-config-apppipecdyaml) + +| Feature | Phase | +|-|-| +| Kubernetes - QuickSync | Incubating | +| ECS - QuickSync | Alpha | +| ECS - Pipeline Sync | Incubating | diff --git a/docs/content/en/docs-v0.49.x/installation/_index.md b/docs/content/en/docs-v0.49.x/installation/_index.md new file mode 100644 index 0000000000..76a1629a37 --- /dev/null +++ b/docs/content/en/docs-v0.49.x/installation/_index.md @@ -0,0 +1,20 @@ +--- +title: "Installation" +linkTitle: "Installation" +weight: 4 +description: > + Complete guideline for installing and configuring PipeCD on your own. +--- + +Before starting to install PipeCD, let’s have a look at PipeCD’s components, determine your role, and which components you will interact with while installing/using PipeCD. You’re recommended to read about PipeCD’s [Control Plane](../concepts/#control-plane) and [Piped](../concepts/#piped) on the concepts page. + +![](/images/architecture-overview-with-roles.png) +

+PipeCD's components with roles +

+ +Basically, there are two types of users/roles that exist in the PipeCD system, which are: +- Developers/Production team: Users who use PipeCD to manage their applications’ deployments. You will interact with Piped and may or may not need to install Piped by yourself. +- Operators/Platform team: Users who operate the PipeCD for other developers can use it. You will interact with the Control Plane and Piped, you will be the one who installs the Control Plane and keeps it up for other Pipeds to connect while managing their applications’ deployments. + +This section contains the guideline for installing PipeCD's Control Plane and Piped step by step. You can choose what to read based on your roles. diff --git a/docs/content/en/docs-v0.49.x/installation/install-control-plane/_index.md b/docs/content/en/docs-v0.49.x/installation/install-control-plane/_index.md new file mode 100644 index 0000000000..fe68c06c8f --- /dev/null +++ b/docs/content/en/docs-v0.49.x/installation/install-control-plane/_index.md @@ -0,0 +1,9 @@ +--- +title: "Install Control Plane" +linkTitle: "Install Control Plane" +weight: 3 +description: > + This page describes how to install a control plane. +--- + +Since Control Plane is a centralized component managing deployment data and provides gRPC API, it needs some components fo storing data or credential... and so on. We explain how to deploy Control Plane components. diff --git a/docs/content/en/docs-v0.49.x/installation/install-control-plane/installing-controlplane-on-ECS.md b/docs/content/en/docs-v0.49.x/installation/install-control-plane/installing-controlplane-on-ECS.md new file mode 100644 index 0000000000..acf89b201e --- /dev/null +++ b/docs/content/en/docs-v0.49.x/installation/install-control-plane/installing-controlplane-on-ECS.md @@ -0,0 +1,12 @@ +--- +title: "Installing Control Plane on ECS" +linkTitle: "Installing Control Plane on ECS" +weight: 2 +description: > + This page describes how to install control plane on ECS. +--- + +Currently, we provide the example of deploying Control Plane to ECS using terraform. + +Please refer to the blog post :) +[PipeCD best practice 02 - control plane on ECS]({{< ref "/blog/control-plane-on-ecs.md" >}} "PipeCD best practice 02 - control plane on ECS"). diff --git a/docs/content/en/docs-v0.49.x/installation/install-control-plane/installing-controlplane-on-k8s.md b/docs/content/en/docs-v0.49.x/installation/install-control-plane/installing-controlplane-on-k8s.md new file mode 100644 index 0000000000..b41a8c71bd --- /dev/null +++ b/docs/content/en/docs-v0.49.x/installation/install-control-plane/installing-controlplane-on-k8s.md @@ -0,0 +1,172 @@ +--- +title: "Installing Control Plane on Kubernetes" +linkTitle: "Installing Control Plane on Kubernetes" +weight: 1 +description: > + This page describes how to install control plane on a Kubernetes cluster. +--- + +## Prerequisites + +- Having a running Kubernetes cluster +- Installed [Helm](https://helm.sh/docs/intro/install/) (3.8.0 or later) + +## Installation + +### 1. Preparing an encryption key + +PipeCD requires a key for encrypting sensitive data or signing JWT token while authenticating. You can use one of the following commands to generate an encryption key. + +``` console +openssl rand 64 | base64 > encryption-key + +# or +cat /dev/urandom | head -c64 | base64 > encryption-key +``` + +### 2. Preparing Control Plane configuration file and installing + +![](/images/control-plane-components.png) +

+Control Plane Architecture +

+ +The Control Plane of PipeCD is constructed by several components, as shown in the above graph (for more in detail please read [Control Plane architecture overview docs](../../../user-guide/managing-controlplane/architecture-overview/)). As mentioned in the graph, the PipeCD's data can be stored in one of the provided fully-managed or self-managed services. So you have to decide which kind of [data store](../../../user-guide/managing-controlplane/architecture-overview/#data-store) and [file store](../../../user-guide/managing-controlplane/architecture-overview/#file-store) you want to use and prepare a Control Plane configuration file suitable for that choice. + +#### Using Firestore and GCS + +PipeCD requires a GCS bucket and service account files to access Firestore and GCS service. Here is an example of configuration file: + +``` yaml +apiVersion: "pipecd.dev/v1beta1" +kind: ControlPlane +spec: + stateKey: {RANDOM_STRING} + datastore: + type: FIRESTORE + config: + namespace: pipecd + environment: dev + project: {YOUR_GCP_PROJECT_NAME} + # Must be a service account with "Cloud Datastore User" and "Cloud Datastore Index Admin" roles + # since PipeCD needs them to creates the needed Firestore composite indexes in the background. + credentialsFile: /etc/pipecd-secret/firestore-service-account + filestore: + type: GCS + config: + bucket: {YOUR_BUCKET_NAME} + # Must be a service account with "Storage Object Admin (roles/storage.objectAdmin)" role on the given bucket + # since PipeCD need to write file object such as deployment log file to that bucket. + credentialsFile: /etc/pipecd-secret/gcs-service-account +``` + +See [ConfigurationReference](../../../user-guide/managing-controlplane/configuration-reference/) for the full configuration. + +After all, install the Control Plane as bellow: + +``` console +helm upgrade -i pipecd oci://ghcr.io/pipe-cd/chart/pipecd --version {{< blocks/latest_version >}} --namespace={NAMESPACE} \ + --set-file config.data=path-to-control-plane-configuration-file \ + --set-file secret.encryptionKey.data=path-to-encryption-key-file \ + --set-file secret.firestoreServiceAccount.data=path-to-service-account-file \ + --set-file secret.gcsServiceAccount.data=path-to-service-account-file +``` + +Currently, besides `Firestore` PipeCD supports other databases as its datastore such as `MySQL`. Also as for filestore, PipeCD supports `AWS S3` and `MINIO` either. + +For example, in case of using `MySQL` as datastore and `MINIO` as filestore, the ControlPlane configuration will be as follow: + +```yaml +apiVersion: "pipecd.dev/v1beta1" +kind: ControlPlane +spec: + stateKey: {RANDOM_STRING} + datastore: + type: MYSQL + config: + url: {YOUR_MYSQL_ADDRESS} + database: {YOUR_DATABASE_NAME} + filestore: + type: MINIO + config: + endpoint: {YOUR_MINIO_ADDRESS} + bucket: {YOUR_BUCKET_NAME} + accessKeyFile: /etc/pipecd-secret/minio-access-key + secretKeyFile: /etc/pipecd-secret/minio-secret-key + autoCreateBucket: true +``` + +You can find required configurations to use other datastores and filestores from [ConfigurationReference](../../../user-guide/managing-controlplane/configuration-reference/). + +__Caution__: In case of using `MySQL` as Control Plane's datastore, please note that the implementation of PipeCD requires some features that only available on [MySQL v8](https://dev.mysql.com/doc/refman/8.0/en/), make sure your MySQL service is satisfied the requirement. + +### 3. Accessing the PipeCD web + +If your installation was including an [ingress](https://github.com/pipe-cd/pipecd/blob/master/manifests/pipecd/values.yaml#L7), the PipeCD web can be accessed by the ingress's IP address or domain. +Otherwise, private PipeCD web can be accessed by using `kubectl port-forward` to expose the installed Control Plane on your localhost: + +``` console +kubectl port-forward svc/pipecd 8080 --namespace={NAMESPACE} +``` + +Now go to [http://localhost:8080](http://localhost:8080) on your browser, you will see a page to login to your project. + +Up to here, you have a installed PipeCD's Control Plane. To logging in, you need to initialize a new project. + +### 4. Initialize a new project + +To create a new project, you need to access to the `ops` pod in your installed PipeCD control plane, using `kubectl port-forward` command: + +```console +kubectl port-forward service/pipecd-ops 9082 --namespace={NAMESPACE} +``` + +Then, access to [http://localhost:9082](http://localhost:9082). + +On that page, you will see the list of registered projects and a link to register new projects. Registering a new project requires only a unique ID string and an optional description text. + +Once a new project has been registered, a static admin (username, password) will be automatically generated for the project admin, you can use that to login via the login form in the above section. + +For more about adding a new project in detail, please read the following [docs](../../../user-guide/managing-controlplane/adding-a-project/). + +### 4'. Upgrade Control Plane version + +To upgrade the PipeCD Control Plane, preparations and commands remain as you do when installing PipeCD Control Plane. Only need to change the version flag in command to the specified version you want to upgrade your PipeCD Control Plane to. + +``` console +helm upgrade -i pipecd oci://ghcr.io/pipe-cd/chart/pipecd --version {NEW_VERSION} --namespace={NAMESPACE} \ + --set-file config.data=path-to-control-plane-configuration-file \ + --set-file secret.encryptionKey.data=path-to-encryption-key-file \ + --set-file secret.firestoreServiceAccount.data=path-to-service-account-file \ + --set-file secret.gcsServiceAccount.data=path-to-service-account-file +``` + +## Production Hardening + +This part provides guidance for a production hardened deployment of the control plane. + +- Publishing the control plane + + You can allow external access to the control plane by enabling the [ingress](https://github.com/pipe-cd/pipecd/blob/master/manifests/pipecd/values.yaml#L7) configuration. + +- End-to-End TLS + + After switching to HTTPs, do not forget to set the `api.args.secureCookie` parameter to be `true` to disallow using cookie on unsecured HTTP connection. + + Alternatively in the case of GKE Ingress, PipeCD also requires a TLS certificate for internal use. This can be a self-signed one and generated by this command: + + ``` console + openssl req -x509 -nodes -days 3650 -newkey rsa:2048 -keyout tls.key -out tls.crt -subj "/CN={YOUR_DOMAIN}" + ``` + Those key and cert can be configured via [`secret.internalTLSKey.data`](https://github.com/pipe-cd/pipecd/blob/master/manifests/pipecd/values.yaml#L118) and [`secret.internalTLSCert.data`](https://github.com/pipe-cd/pipecd/blob/master/manifests/pipecd/values.yaml#L121). + + To enable internal tls connection, please set the `gateway.internalTLS.enabled` parameter to be `true`. + + Otherwise, the `cloud.google.com/app-protocols` annotation is also should be configured as the following: + + ``` yaml + service: + port: 443 + annotations: + cloud.google.com/app-protocols: '{"service":"HTTP2"}' + ``` diff --git a/docs/content/en/docs-v0.49.x/installation/install-piped/_index.md b/docs/content/en/docs-v0.49.x/installation/install-piped/_index.md new file mode 100644 index 0000000000..71a5199f66 --- /dev/null +++ b/docs/content/en/docs-v0.49.x/installation/install-piped/_index.md @@ -0,0 +1,9 @@ +--- +title: "Install Piped" +linkTitle: "Install Piped" +weight: 3 +description: > + This page describes how to install a Piped. +--- + +Since Piped is a stateless agent, no database or storage is required to run. In addition, a Piped can interact with one or multiple platform providers, so the number of Piped and where they should run is entirely up to your preference. For example, you can run your Pipeds in a Kubernetes cluster to deploy not just Kubernetes applications but your Terraform and Cloud Run applications as well. diff --git a/docs/content/en/docs-v0.49.x/installation/install-piped/installing-on-cloudrun.md b/docs/content/en/docs-v0.49.x/installation/install-piped/installing-on-cloudrun.md new file mode 100644 index 0000000000..2919f6ef2e --- /dev/null +++ b/docs/content/en/docs-v0.49.x/installation/install-piped/installing-on-cloudrun.md @@ -0,0 +1,174 @@ +--- +title: "Installing on Cloud Run" +linkTitle: "Installing on Cloud Run" +weight: 3 +description: > + This page describes how to install Piped on Cloud Run. +--- + +## Prerequisites + +##### Having piped's ID and Key strings +- Ensure that the `piped` has been registered and you are having its PIPED_ID and PIPED_KEY strings. +- If you are not having them, this [page](../../../user-guide/managing-controlplane/registering-a-piped/) guides you how to register a new one. + +##### Preparing SSH key +- If your Git repositories are private, `piped` requires a private SSH key to access those repositories. +- Please checkout [this documentation](https://help.github.com/en/github/authenticating-to-github/generating-a-new-ssh-key-and-adding-it-to-the-ssh-agent) for how to generate a new SSH key pair. Then add the public key to your repositories. (If you are using GitHub, you can add it to Deploy Keys at the repository's Settings page.) + +## Installation + +- Preparing a piped configuration file as the following: + + ``` yaml + apiVersion: pipecd.dev/v1beta1 + kind: Piped + spec: + projectID: {PROJECT_ID} + pipedID: {PIPED_ID} + pipedKeyData: {BASE64_ENCODED_PIPED_KEY} + # Write in a format like "host:443" because the communication is done via gRPC. + apiAddress: {CONTROL_PLANE_API_ADDRESS} + + git: + sshKeyData: {BASE64_ENCODED_PRIVATE_SSH_KEY} + + repositories: + - repoId: {REPO_ID_OR_NAME} + remote: git@github.com:{GIT_ORG}/{GIT_REPO}.git + branch: {GIT_BRANCH} + + # Optional + # Enable this Piped to handle Cloud Run application. + platformProviders: + - name: cloudrun-in-project + type: CLOUDRUN + config: + project: {GCP_PROJECT_ID} + region: {GCP_PROJECT_REGION} + + # Optional + # Uncomment this if you want to enable this Piped to handle Terraform application. + # - name: terraform-gcp + # type: TERRAFORM + + # Optional + # Uncomment this if you want to enable SecretManagement feature. + # https://pipecd.dev//docs/user-guide/managing-application/secret-management/ + # secretManagement: + # type: KEY_PAIR + # config: + # privateKeyData: {BASE64_ENCODED_PRIVATE_KEY} + # publicKeyData: {BASE64_ENCODED_PUBLIC_KEY} + ``` + +See [ConfigurationReference](../../../user-guide/managing-piped/configuration-reference/) for the full configuration. + +- Creating a new secret in [SecretManager](https://cloud.google.com/secret-manager/docs/creating-and-accessing-secrets) to store above configuration data securely + + ``` console + gcloud secrets create cloudrun-piped-config --data-file={PATH_TO_CONFIG_FILE} + ``` + + then make sure that Cloud Run has the ability to access that secret as [this guide](https://cloud.google.com/run/docs/configuring/secrets#access-secret). + +- Running Piped in Cloud Run + + Prepare a Cloud Run service manifest file as below. + + {{< tabpane >}} + {{< tab lang="yaml" header="Piped with Remote-upgrade" >}} +# Enable remote-upgrade feature of Piped. +# https://pipecd.dev/docs/user-guide/managing-piped/remote-upgrade-remote-config/#remote-upgrade +# This allows upgrading Piped to a new version from the web console. + +apiVersion: serving.knative.dev/v1 +kind: Service +metadata: + name: piped + annotaions: + run.googleapis.com/ingress: internal + run.googleapis.com/ingress-status: internal +spec: + template: + metadata: + annotations: + autoscaling.knative.dev/maxScale: '1' # This must be 1. + autoscaling.knative.dev/minScale: '1' # This must be 1. + run.googleapis.com/cpu-throttling: "false" # This is required. + spec: + containerConcurrency: 1 # This must be 1 to ensure Piped work correctly. + containers: + - image: ghcr.io/pipe-cd/launcher:{{< blocks/latest_version >}} + args: + - launcher + - --launcher-admin-port=9086 + - --config-file=/etc/piped-config/config.yaml + ports: + - containerPort: 9086 + volumeMounts: + - mountPath: /etc/piped-config + name: piped-config + resources: + limits: + cpu: 1000m + memory: 2Gi + volumes: + - name: piped-config + secret: + secretName: cloudrun-piped-config + items: + - path: config.yaml + key: latest + {{< /tab >}} + {{< tab lang="yaml" header="Piped" >}} +# This just installs a Piped with the specified version. +# Whenever you want to upgrade that Piped to a new version or update its config data you have to restart it. + +apiVersion: serving.knative.dev/v1 +kind: Service +metadata: + name: piped + annotaions: + run.googleapis.com/ingress: internal + run.googleapis.com/ingress-status: internal +spec: + template: + metadata: + annotations: + autoscaling.knative.dev/maxScale: '1' # This must be 1. + autoscaling.knative.dev/minScale: '1' # This must be 1. + run.googleapis.com/cpu-throttling: "false" # This is required. + spec: + containerConcurrency: 1 # This must be 1. + containers: + - image: ghcr.io/pipe-cd/piped:{{< blocks/latest_version >}} + args: + - piped + - --config-file=/etc/piped-config/config.yaml + ports: + - containerPort: 9085 + volumeMounts: + - mountPath: /etc/piped-config + name: piped-config + resources: + limits: + cpu: 1000m + memory: 2Gi + volumes: + - name: piped-config + secret: + secretName: cloudrun-piped-config + items: + - path: config.yaml + key: latest + {{< /tab >}} + {{< /tabpane >}} + + Run Piped service on Cloud Run with the following command: + + ``` console + gcloud beta run services replace cloudrun-piped-service.yaml + ``` + + Note: Make sure that the created secret is accessible from this Piped service. See more [here](https://cloud.google.com/run/docs/configuring/secrets#access-secret). diff --git a/docs/content/en/docs-v0.49.x/installation/install-piped/installing-on-fargate.md b/docs/content/en/docs-v0.49.x/installation/install-piped/installing-on-fargate.md new file mode 100644 index 0000000000..32031b7fa6 --- /dev/null +++ b/docs/content/en/docs-v0.49.x/installation/install-piped/installing-on-fargate.md @@ -0,0 +1,199 @@ +--- +title: "Installing on ECS Fargate" +linkTitle: "Installing on ECS Fargate" +weight: 4 +description: > + This page describes how to install Piped as a task on ECS cluster backed by AWS Fargate. +--- + +## Prerequisites + +##### Having piped's ID and Key strings +- Ensure that the `piped` has been registered and you are having its PIPED_ID and PIPED_KEY strings. +- If you are not having them, this [page](../../../user-guide/managing-controlplane/registering-a-piped/) guides you how to register a new one. + +##### Preparing SSH key +- If your Git repositories are private, `piped` requires a private SSH key to access those repositories. +- Please checkout [this documentation](https://help.github.com/en/github/authenticating-to-github/generating-a-new-ssh-key-and-adding-it-to-the-ssh-agent) for how to generate a new SSH key pair. Then add the public key to your repositories. (If you are using GitHub, you can add it to Deploy Keys at the repository's Settings page.) + +## Installation + +- Preparing a piped configuration file as follows: + + ``` yaml + apiVersion: pipecd.dev/v1beta1 + kind: Piped + spec: + projectID: {PROJECT_ID} + pipedID: {PIPED_ID} + pipedKeyData: {BASE64_ENCODED_PIPED_KEY} + # Write in a format like "host:443" because the communication is done via gRPC. + apiAddress: {CONTROL_PLANE_API_ADDRESS} + + git: + sshKeyData: {BASE64_ENCODED_PRIVATE_SSH_KEY} + + repositories: + - repoId: {REPO_ID_OR_NAME} + remote: git@github.com:{GIT_ORG}/{GIT_REPO}.git + branch: {GIT_BRANCH} + + # Optional + # Enable this Piped to handle ECS application. + platformProviders: + - name: ecs-dev + type: ECS + config: + region: {ECS_RUNNING_REGION} + + # Optional + # Uncomment this if you want to enable this Piped to handle Terraform application. + # - name: terraform-dev + # type: TERRAFORM + + # Optional + # Uncomment this if you want to enable SecretManagement feature. + # https://pipecd.dev//docs/user-guide/managing-application/secret-management/ + # secretManagement: + # type: KEY_PAIR + # config: + # privateKeyData: {BASE64_ENCODED_PRIVATE_KEY} + # publicKeyData: {BASE64_ENCODED_PUBLIC_KEY} + ``` + +See [ConfigurationReference](../../../user-guide/managing-piped/configuration-reference/) for the full configuration. + +- Store the above configuration data to AWS to enable using it while creating piped task. Both [AWS Secrets Manager](https://aws.amazon.com/secrets-manager/) and [AWS Systems Manager Parameter Store](https://docs.aws.amazon.com/systems-manager/latest/userguide/systems-manager-parameter-store.html) are available to address this task. + + {{< tabpane >}} + {{< tab lang="bash" header="Store in AWS Secrets Manager" >}} + aws secretsmanager create-secret --name PipedConfig \ + --description "Configuration of piped running as ECS Fargate task" \ + --secret-string `base64 piped-config.yaml` + {{< /tab >}} + {{< tab lang="bash" header="Store in AWS Systems Manager Parameter Store" >}} + aws ssm put-parameter \ + --name PipedConfig \ + --value `base64 piped-config.yaml` \ + --type SecureString + {{< /tab >}} + {{< /tabpane >}} + +- Prepare task definition for your piped task. Basically, you can just define your piped TaskDefinition as normal TaskDefinition, the only thing that needs to be beware is, in case you used [AWS Secrets Manager](https://aws.amazon.com/secrets-manager/) to store piped configuration, to enable your piped accesses it's configuration we created as a secret on above, you need to add `secretsmanager:GetSecretValue` policy to your piped task `executionRole`. Read more in [Required IAM permissions for Amazon ECS secrets](https://docs.aws.amazon.com/AmazonECS/latest/developerguide/specifying-sensitive-data-secrets.html). + + A sample TaskDefinition for Piped as follows + + {{< tabpane >}} + {{< tab lang="json" header="Piped with Remote-upgrade" >}} +# Enable remote-upgrade feature of Piped. +# https://pipecd.dev/docs/user-guide/managing-piped/remote-upgrade-remote-config/#remote-upgrade +# This allows upgrading Piped to a new version from the web console. + +{ + "family": "piped", + "executionRoleArn": "{PIPED_TASK_EXECUTION_ROLE_ARN}", + "containerDefinitions": [ + { + "name": "piped", + "essential": true, + "image": "ghcr.io/pipe-cd/launcher:{{< blocks/latest_version >}}", + "entryPoint": [ + "sh", + "-c" + ], + "command": [ + "/bin/sh -c \"launcher launcher --config-data=$(echo $CONFIG_DATA)\"" + ], + "secrets": [ + { + "valueFrom": "{PIPED_SECRET_MANAGER_ARN}", + "name": "CONFIG_DATA" + } + ], + } + ], + "requiresCompatibilities": [ + "FARGATE" + ], + "networkMode": "awsvpc", + "memory": "512", + "cpu": "256" +} + {{< /tab >}} + {{< tab lang="json" header="Piped" >}} +# This just installs a Piped with the specified version. +# Whenever you want to upgrade that Piped to a new version or update its config data you have to restart it. + +{ + "family": "piped", + "executionRoleArn": "{PIPED_TASK_EXECUTION_ROLE_ARN}", + "containerDefinitions": [ + { + "name": "piped", + "essential": true, + "image": "ghcr.io/pipe-cd/piped:{{< blocks/latest_version >}}", + "entryPoint": [ + "sh", + "-c" + ], + "command": [ + "/bin/sh -c \"piped piped --config-data=$(echo $CONFIG_DATA)\"" + ], + "secrets": [ + { + "valueFrom": "{PIPED_SECRET_MANAGER_ARN}", + "name": "CONFIG_DATA" + } + ], + } + ], + "requiresCompatibilities": [ + "FARGATE" + ], + "networkMode": "awsvpc", + "memory": "512", + "cpu": "256" +} + {{< /tab >}} + {{< /tabpane >}} + + Register this piped task definition and start piped task: + + ```console + aws ecs register-task-definition --cli-input-json file://taskdef.json + aws ecs run-task --cluster {ECS_CLUSTER} --task-definition piped + ``` + + Once the task is created, it will run continuously because of the piped execution. Since this task is run without [startedBy](https://docs.aws.amazon.com/AmazonECS/latest/APIReference/API_StartTask.html#API_StartTask_RequestSyntax) setting, in case the piped is stopped, it will not automatically be restarted. To do so, you must define an ECS service to control piped task deployment. + + A sample Service definition to control piped task deployment. + + ```json + { + "cluster": "{ECS_CLUSTER}", + "serviceName": "piped", + "desiredCount": 1, # This must be 1. + "taskDefinition": "{PIPED_TASK_DEFINITION_ARN}", + "deploymentConfiguration": { + "minimumHealthyPercent": 0, + "maximumPercent": 100 + }, + "schedulingStrategy": "REPLICA", + "launchType": "FARGATE", + "networkConfiguration": { + "awsvpcConfiguration": { + "assignPublicIp": "ENABLED", # This is need to enable ECS deployment to pull piped container images. + ... + } + } + } + ``` + + Then start your piped task controller service. + + ```console + aws ecs create-service \ + --cluster {ECS_CLUSTER} \ + --service-name piped \ + --cli-input-json file://service.json + ``` diff --git a/docs/content/en/docs-v0.49.x/installation/install-piped/installing-on-google-cloud-vm.md b/docs/content/en/docs-v0.49.x/installation/install-piped/installing-on-google-cloud-vm.md new file mode 100644 index 0000000000..84cb85160f --- /dev/null +++ b/docs/content/en/docs-v0.49.x/installation/install-piped/installing-on-google-cloud-vm.md @@ -0,0 +1,138 @@ +--- +title: "Installing on Google Cloud VM" +linkTitle: "Installing on Google Cloud VM" +weight: 2 +description: > + This page describes how to install Piped on Google Cloud VM. +--- + +## Prerequisites + +##### Having piped's ID and Key strings +- Ensure that the `piped` has been registered and you are having its `PIPED_ID` and `PIPED_KEY` strings. +- If you are not having them, this [page](../../../user-guide/managing-controlplane/registering-a-piped/) guides you how to register a new one. + +##### Preparing SSH key +- If your Git repositories are private, `piped` requires a private SSH key to access those repositories. +- Please checkout [this documentation](https://help.github.com/en/github/authenticating-to-github/generating-a-new-ssh-key-and-adding-it-to-the-ssh-agent) for how to generate a new SSH key pair. Then add the public key to your repositories. (If you are using GitHub, you can add it to Deploy Keys at the repository's Settings page.) + +## Installation + +- Preparing a piped configuration file as the following: + + ``` yaml + apiVersion: pipecd.dev/v1beta1 + kind: Piped + spec: + projectID: {PROJECT_ID} + pipedID: {PIPED_ID} + pipedKeyData: {BASE64_ENCODED_PIPED_KEY} + # Write in a format like "host:443" because the communication is done via gRPC. + apiAddress: {CONTROL_PLANE_API_ADDRESS} + + git: + sshKeyData: {BASE64_ENCODED_PRIVATE_SSH_KEY} + + repositories: + - repoId: {REPO_ID_OR_NAME} + remote: git@github.com:{GIT_ORG}/{GIT_REPO}.git + branch: {GIT_BRANCH} + + # Optional + # Uncomment this if you want to enable this Piped to handle Cloud Run application. + # platformProviders: + # - name: cloudrun-in-project + # type: CLOUDRUN + # config: + # project: {GCP_PROJECT_ID} + # region: {GCP_PROJECT_REGION} + + # Optional + # Uncomment this if you want to enable this Piped to handle Terraform application. + # - name: terraform-gcp + # type: TERRAFORM + + # Optional + # Uncomment this if you want to enable SecretManagement feature. + # https://pipecd.dev//docs/user-guide/managing-application/secret-management/ + # secretManagement: + # type: KEY_PAIR + # config: + # privateKeyData: {BASE64_ENCODED_PRIVATE_KEY} + # publicKeyData: {BASE64_ENCODED_PUBLIC_KEY} + ``` + +See [ConfigurationReference](../../../user-guide/managing-piped/configuration-reference/) for the full configuration. + +- Creating a new secret in [SecretManager](https://cloud.google.com/secret-manager/docs/creating-and-accessing-secrets) to store above configuration data securely + + ``` shell + gcloud secrets create vm-piped-config --data-file={PATH_TO_CONFIG_FILE} + ``` + +- Creating a new Service Account for Piped and giving it needed roles + + ``` shell + gcloud iam service-accounts create vm-piped \ + --description="Using by Piped running on Google Cloud VM" \ + --display-name="vm-piped" + + # Allow Piped to access the created secret. + gcloud secrets add-iam-policy-binding vm-piped-config \ + --member="serviceAccount:vm-piped@{GCP_PROJECT_ID}.iam.gserviceaccount.com" \ + --role="roles/secretmanager.secretAccessor" + + # Allow Piped to write its log messages to Google Cloud Logging service. + gcloud projects add-iam-policy-binding {GCP_PROJECT_ID} \ + --member="serviceAccount:vm-piped@{GCP_PROJECT_ID}.iam.gserviceaccount.com" \ + --role="roles/logging.logWriter" + + # Optional + # If you want to use this Piped to handle Cloud Run application + # run the following command to give it the needed roles. + # https://cloud.google.com/run/docs/reference/iam/roles#additional-configuration + # + # gcloud projects add-iam-policy-binding {GCP_PROJECT_ID} \ + # --member="serviceAccount:vm-piped@{GCP_PROJECT_ID}.iam.gserviceaccount.com" \ + # --role="roles/run.developer" + # + # gcloud iam service-accounts add-iam-policy-binding {GCP_PROJECT_NUMBER}-compute@developer.gserviceaccount.com \ + # --member="serviceAccount:vm-piped@{GCP_PROJECT_ID}.iam.gserviceaccount.com" \ + # --role="roles/iam.serviceAccountUser" + ``` + +- Running Piped on a Google Cloud VM + + {{< tabpane >}} + {{< tab lang="console" header="Piped with Remote-upgrade" >}} +# Enable remote-upgrade feature of Piped. +# https://pipecd.dev/docs/user-guide/managing-piped/remote-upgrade-remote-config/#remote-upgrade +# This allows upgrading Piped to a new version from the web console. + + gcloud compute instances create-with-container vm-piped \ + --container-image="ghcr.io/pipe-cd/launcher:{{< blocks/latest_version >}}" \ + --container-arg="launcher" \ + --container-arg="--config-from-gcp-secret=true" \ + --container-arg="--gcp-secret-id=projects/{GCP_PROJECT_ID}/secrets/vm-piped-config/versions/{SECRET_VERSION}" \ + --network="{VPC_NETWORK}" \ + --subnet="{VPC_SUBNET}" \ + --scopes="cloud-platform" \ + --service-account="vm-piped@{GCP_PROJECT_ID}.iam.gserviceaccount.com" + {{< /tab >}} + {{< tab lang="console" header="Piped" >}} +# This just installs a Piped with the specified version. +# Whenever you want to upgrade that Piped to a new version or update its config data you have to restart it. + + gcloud compute instances create-with-container vm-piped \ + --container-image="ghcr.io/pipe-cd/piped:{{< blocks/latest_version >}}" \ + --container-arg="piped" \ + --container-arg="--config-gcp-secret=projects/{GCP_PROJECT_ID}/secrets/vm-piped-config/versions/{SECRET_VERSION}" \ + --network="{VPC_NETWORK}" \ + --subnet="{VPC_SUBNET}" \ + --scopes="cloud-platform" \ + --service-account="vm-piped@{GCP_PROJECT_ID}.iam.gserviceaccount.com" + {{< /tab >}} + {{< /tabpane >}} + +After that, you can see on PipeCD web at `Settings` page that Piped is connecting to the Control Plane. +You can also view Piped log as described [here](https://cloud.google.com/compute/docs/containers/deploying-containers#viewing_logs). diff --git a/docs/content/en/docs-v0.49.x/installation/install-piped/installing-on-kubernetes.md b/docs/content/en/docs-v0.49.x/installation/install-piped/installing-on-kubernetes.md new file mode 100644 index 0000000000..d72c124fd5 --- /dev/null +++ b/docs/content/en/docs-v0.49.x/installation/install-piped/installing-on-kubernetes.md @@ -0,0 +1,246 @@ +--- +title: "Installing on Kubernetes cluster" +linkTitle: "Installing on Kubernetes cluster" +weight: 1 +description: > + This page describes how to install Piped on Kubernetes cluster. +--- + +## Prerequisites + +##### Having piped's ID and Key strings +- Ensure that the `piped` has been registered and you are having its PIPED_ID and PIPED_KEY strings. +- If you are not having them, this [page](../../../user-guide/managing-controlplane/registering-a-piped/) guides you how to register a new one. + +##### Preparing SSH key +- If your Git repositories are private, `piped` requires a private SSH key to access those repositories. +- Please checkout [this documentation](https://help.github.com/en/github/authenticating-to-github/generating-a-new-ssh-key-and-adding-it-to-the-ssh-agent) for how to generate a new SSH key pair. Then add the public key to your repositories. (If you are using GitHub, you can add it to Deploy Keys at the repository's Settings page.) + +## In the cluster-wide mode +This way requires installing cluster-level resources. Piped installed with this way can perform deployment workloads against any other namespaces than the where Piped runs on. + +- Preparing a piped configuration file as the following + + ``` yaml + apiVersion: pipecd.dev/v1beta1 + kind: Piped + spec: + projectID: {PROJECT_ID} + pipedID: {PIPED_ID} + pipedKeyFile: /etc/piped-secret/piped-key + # Write in a format like "host:443" because the communication is done via gRPC. + apiAddress: {CONTROL_PLANE_API_ADDRESS} + git: + sshKeyFile: /etc/piped-secret/ssh-key + repositories: + - repoId: {REPO_ID_OR_NAME} + remote: git@github.com:{GIT_ORG}/{GIT_REPO}.git + branch: {GIT_BRANCH} + syncInterval: 1m + ``` + +See [ConfigurationReference](../../../user-guide/managing-piped/configuration-reference/) for the full configuration. + +- Installing by using [Helm](https://helm.sh/docs/intro/install/) (3.8.0 or later) + + {{< tabpane >}} + {{< tab lang="bash" header="Piped" >}} +# This command just installs a Piped with the specified version. +# Whenever you want to upgrade that Piped to a new version or update its config data +# you have to restart it by re-running this command. + +helm upgrade -i dev-piped oci://ghcr.io/pipe-cd/chart/piped --version={{< blocks/latest_version >}} --namespace={NAMESPACE} \ + --set-file config.data={PATH_TO_PIPED_CONFIG_FILE} \ + --set-file secret.data.piped-key={PATH_TO_PIPED_KEY_FILE} \ + --set-file secret.data.ssh-key={PATH_TO_PRIVATE_SSH_KEY_FILE} + {{< /tab >}} + {{< tab lang="bash" header="Piped with Remote-upgrade" >}} +# Enable remote-upgrade feature of Piped. +# https://pipecd.dev/docs/user-guide/managing-piped/remote-upgrade-remote-config/#remote-upgrade +# This allows upgrading Piped to a new version from the web console. +# But we still need to restart Piped when we want to update its config data. + +helm upgrade -i dev-piped oci://ghcr.io/pipe-cd/chart/piped --version={{< blocks/latest_version >}} --namespace={NAMESPACE} \ + --set launcher.enabled=true \ + --set-file config.data={PATH_TO_PIPED_CONFIG_FILE} \ + --set-file secret.data.piped-key={PATH_TO_PIPED_KEY_FILE} \ + --set-file secret.data.ssh-key={PATH_TO_PRIVATE_SSH_KEY_FILE} + {{< /tab >}} + {{< tab lang="bash" header="Piped with Remote-upgrade and Remote-config" >}} +# Enable both remote-upgrade and remote-config features of Piped. +# https://pipecd.dev/docs/user-guide/managing-piped/remote-upgrade-remote-config/#remote-config +# Beside of the ability to upgrade Piped to a new version from the web console, +# remote-config allows loading the Piped config stored in a remote location such as a Git repository. +# Whenever the config data is changed, it loads the new config and restarts Piped to use that new config. + +helm upgrade -i dev-piped oci://ghcr.io/pipe-cd/chart/piped --version={{< blocks/latest_version >}} --namespace={NAMESPACE} \ + --set launcher.enabled=true \ + --set launcher.configFromGitRepo.enabled=true \ + --set launcher.configFromGitRepo.repoUrl=git@github.com:{GIT_ORG}/{GIT_REPO}.git \ + --set launcher.configFromGitRepo.branch={GIT_BRANCH} \ + --set launcher.configFromGitRepo.configFile={RELATIVE_PATH_TO_PIPED_CONFIG_FILE_IN_GIT_REPO} \ + --set launcher.configFromGitRepo.sshKeyFile=/etc/piped-secret/ssh-key \ + --set-file secret.data.piped-key={PATH_TO_PIPED_KEY_FILE} \ + --set-file secret.data.ssh-key={PATH_TO_PRIVATE_SSH_KEY_FILE} + {{< /tab >}} + {{< /tabpane >}} + + Note: Be sure to set `--set args.insecure=true` if your Control Plane has not TLS-enabled yet. + + See [values.yaml](https://github.com/pipe-cd/pipecd/blob/master/manifests/piped/values.yaml) for the full values. + +## In the namespaced mode +The previous way requires installing cluster-level resources. If you want to restrict Piped's permission within the namespace where Piped runs on, this way is for you. +Most parts are identical to the previous way, but some are slightly different. + +- Adding a new cloud provider like below to the previous piped configuration file + + ``` yaml + apiVersion: pipecd.dev/v1beta1 + kind: Piped + spec: + projectID: {PROJECT_ID} + pipedID: {PIPED_ID} + pipedKeyFile: /etc/piped-secret/piped-key + # Write in a format like "host:443" because the communication is done via gRPC. + apiAddress: {CONTROL_PLANE_API_ADDRESS} + git: + sshKeyFile: /etc/piped-secret/ssh-key + repositories: + - repoId: REPO_ID_OR_NAME + remote: git@github.com:{GIT_ORG}/{GIT_REPO}.git + branch: {GIT_BRANCH} + syncInterval: 1m + # This is needed to restrict to limit the access range to within a namespace. + platformProviders: + - name: my-kubernetes + type: KUBERNETES + config: + appStateInformer: + namespace: {NAMESPACE} + ``` + +- Installing by using [Helm](https://helm.sh/docs/intro/install/) (3.8.0 or later) + + {{< tabpane >}} + {{< tab lang="bash" header="Piped" >}} +# This command just installs a Piped with the specified version. +# Whenever you want to upgrade that Piped to a new version or update its config data +# you have to restart it by re-running this command. + +helm upgrade -i dev-piped oci://ghcr.io/pipe-cd/chart/piped --version={{< blocks/latest_version >}} --namespace={NAMESPACE} \ + --set-file config.data={PATH_TO_PIPED_CONFIG_FILE} \ + --set-file secret.data.piped-key={PATH_TO_PIPED_KEY_FILE} \ + --set-file secret.data.ssh-key={PATH_TO_PRIVATE_SSH_KEY_FILE} \ + --set args.enableDefaultKubernetesCloudProvider=false \ + --set rbac.scope=namespace + {{< /tab >}} + {{< tab lang="bash" header="Piped with Remote-upgrade" >}} +# Enable remote-upgrade feature of Piped. +# https://pipecd.dev/docs/user-guide/managing-piped/remote-upgrade-remote-config/#remote-upgrade +# This allows upgrading Piped to a new version from the web console. +# But we still need to restart Piped when we want to update its config data. + +helm upgrade -i dev-piped oci://ghcr.io/pipe-cd/chart/piped --version={{< blocks/latest_version >}} --namespace={NAMESPACE} \ + --set launcher.enabled=true \ + --set-file config.data={PATH_TO_PIPED_CONFIG_FILE} \ + --set-file secret.data.piped-key={PATH_TO_PIPED_KEY_FILE} \ + --set-file secret.data.ssh-key={PATH_TO_PRIVATE_SSH_KEY_FILE} \ + --set args.enableDefaultKubernetesCloudProvider=false \ + --set rbac.scope=namespace + {{< /tab >}} + {{< tab lang="bash" header="Piped with Remote-upgrade and Remote-config" >}} +# Enable both remote-upgrade and remote-config features of Piped. +# https://pipecd.dev/docs/user-guide/managing-piped/remote-upgrade-remote-config/#remote-config +# Beside of the ability to upgrade Piped to a new version from the web console, +# remote-config allows loading the Piped config stored in a remote location such as a Git repository. +# Whenever the config data is changed, it loads the new config and restarts Piped to use that new config. + +helm upgrade -i dev-piped oci://ghcr.io/pipe-cd/chart/piped --version={{< blocks/latest_version >}} --namespace={NAMESPACE} \ + --set launcher.enabled=true \ + --set launcher.configFromGitRepo.enabled=true \ + --set launcher.configFromGitRepo.repoUrl=git@github.com:{GIT_ORG}/{GIT_REPO}.git \ + --set launcher.configFromGitRepo.branch={GIT_BRANCH} \ + --set launcher.configFromGitRepo.configFile={RELATIVE_PATH_TO_PIPED_CONFIG_FILE_IN_GIT_REPO} \ + --set launcher.configFromGitRepo.sshKeyFile=/etc/piped-secret/ssh-key \ + --set-file secret.data.piped-key={PATH_TO_PIPED_KEY_FILE} \ + --set-file secret.data.ssh-key={PATH_TO_PRIVATE_SSH_KEY_FILE} \ + --set args.enableDefaultKubernetesCloudProvider=false \ + --set rbac.scope=namespace + {{< /tab >}} + {{< /tabpane >}} + +#### In case on OpenShift less than 4.2 + +OpenShift uses an arbitrarily assigned user ID when it starts a container. +Starting from OpenShift 4.2, it also inserts that user into `/etc/passwd` for using by the application inside the container, +but before that version, the assigned user is missing in that file. That blocks workloads of `gcr.io/pipecd/piped` image. +Therefore if you are running on OpenShift with a version before 4.2, please use `gcr.io/pipecd/piped-okd` image with the following command: + +- Installing by using [Helm](https://helm.sh/docs/intro/install/) (3.8.0 or later) + + {{< tabpane >}} + {{< tab lang="bash" header="Piped" >}} +# This command just installs a Piped with the specified version. +# Whenever you want to upgrade that Piped to a new version or update its config data +# you have to restart it by re-running this command. + +helm upgrade -i dev-piped oci://ghcr.io/pipe-cd/chart/piped --version={{< blocks/latest_version >}} --namespace={NAMESPACE} \ + --set-file config.data={PATH_TO_PIPED_CONFIG_FILE} \ + --set-file secret.data.piped-key={PATH_TO_PIPED_KEY_FILE} \ + --set-file secret.data.ssh-key={PATH_TO_PRIVATE_SSH_KEY_FILE} \ + --set args.enableDefaultKubernetesCloudProvider=false \ + --set rbac.scope=namespace + --set args.addLoginUserToPasswd=true \ + --set securityContext.runAsNonRoot=true \ + --set securityContext.runAsUser={UID} \ + --set securityContext.fsGroup={FS_GROUP} \ + --set securityContext.runAsGroup=0 \ + --set image.repository="ghcr.io/pipe-cd/piped-okd" + {{< /tab >}} + {{< tab lang="bash" header="Piped with Remote-upgrade" >}} +# Enable remote-upgrade feature of Piped. +# https://pipecd.dev/docs/user-guide/managing-piped/remote-upgrade-remote-config/#remote-upgrade +# This allows upgrading Piped to a new version from the web console. +# But we still need to restart Piped when we want to update its config data. + +helm upgrade -i dev-piped oci://ghcr.io/pipe-cd/chart/piped --version={{< blocks/latest_version >}} --namespace={NAMESPACE} \ + --set launcher.enabled=true \ + --set-file config.data={PATH_TO_PIPED_CONFIG_FILE} \ + --set-file secret.data.piped-key={PATH_TO_PIPED_KEY_FILE} \ + --set-file secret.data.ssh-key={PATH_TO_PRIVATE_SSH_KEY_FILE} \ + --set args.enableDefaultKubernetesCloudProvider=false \ + --set rbac.scope=namespace + --set args.addLoginUserToPasswd=true \ + --set securityContext.runAsNonRoot=true \ + --set securityContext.runAsUser={UID} \ + --set securityContext.fsGroup={FS_GROUP} \ + --set securityContext.runAsGroup=0 \ + --set launcher.image.repository="ghcr.io/pipe-cd/launcher-okd" + {{< /tab >}} + {{< tab lang="bash" header="Piped with Remote-upgrade and Remote-config" >}} +# Enable both remote-upgrade and remote-config features of Piped. +# https://pipecd.dev/docs/user-guide/managing-piped/remote-upgrade-remote-config/#remote-config +# Beside of the ability to upgrade Piped to a new version from the web console, +# remote-config allows loading the Piped config stored in a remote location such as a Git repository. +# Whenever the config data is changed, it loads the new config and restarts Piped to use that new config. + +helm upgrade -i dev-piped oci://ghcr.io/pipe-cd/chart/piped --version={{< blocks/latest_version >}} --namespace={NAMESPACE} \ + --set launcher.enabled=true \ + --set launcher.configFromGitRepo.enabled=true \ + --set launcher.configFromGitRepo.repoUrl=git@github.com:{GIT_ORG}/{GIT_REPO}.git \ + --set launcher.configFromGitRepo.branch={GIT_BRANCH} \ + --set launcher.configFromGitRepo.configFile={RELATIVE_PATH_TO_PIPED_CONFIG_FILE_IN_GIT_REPO} \ + --set launcher.configFromGitRepo.sshKeyFile=/etc/piped-secret/ssh-key \ + --set-file secret.data.piped-key={PATH_TO_PIPED_KEY_FILE} \ + --set-file secret.data.ssh-key={PATH_TO_PRIVATE_SSH_KEY_FILE} \ + --set args.enableDefaultKubernetesCloudProvider=false \ + --set rbac.scope=namespace + --set args.addLoginUserToPasswd=true \ + --set securityContext.runAsNonRoot=true \ + --set securityContext.runAsUser={UID} \ + --set securityContext.fsGroup={FS_GROUP} \ + --set securityContext.runAsGroup=0 \ + --set launcher.image.repository="ghcr.io/pipe-cd/launcher-okd" + {{< /tab >}} + {{< /tabpane >}} diff --git a/docs/content/en/docs-v0.49.x/installation/install-piped/installing-on-single-machine.md b/docs/content/en/docs-v0.49.x/installation/install-piped/installing-on-single-machine.md new file mode 100644 index 0000000000..018d9cf55e --- /dev/null +++ b/docs/content/en/docs-v0.49.x/installation/install-piped/installing-on-single-machine.md @@ -0,0 +1,52 @@ +--- +title: "Installing on a single machine" +linkTitle: "Installing on a single machine" +weight: 5 +description: > + This page describes how to install a Piped on a single machine. +--- + +## Prerequisites + +##### Having piped's ID and Key strings +- Ensure that the `piped` has been registered and you are having its PIPED_ID and PIPED_KEY strings. +- If you are not having them, this [page](../../../user-guide/managing-controlplane/registering-a-piped/) guides you how to register a new one. + +##### Preparing SSH key +- If your Git repositories are private, `piped` requires a private SSH key to access those repositories. +- Please checkout [this documentation](https://help.github.com/en/github/authenticating-to-github/generating-a-new-ssh-key-and-adding-it-to-the-ssh-agent) for how to generate a new SSH key pair. Then add the public key to your repositories. (If you are using GitHub, you can add it to Deploy Keys at the repository's Settings page.) + +## Installation + +- Downloading the latest `piped` binary for your machine + + https://github.com/pipe-cd/pipecd/releases + +- Preparing a piped configuration file as the following: + + ``` yaml + apiVersion: pipecd.dev/v1beta1 + kind: Piped + spec: + projectID: {PROJECT_ID} + pipedID: {PIPED_ID} + pipedKeyFile: {PATH_TO_PIPED_KEY_FILE} + # Write in a format like "host:443" because the communication is done via gRPC. + apiAddress: {CONTROL_PLANE_API_ADDRESS} + git: + sshKeyFile: {PATH_TO_SSH_KEY_FILE} + repositories: + - repoId: {REPO_ID_OR_NAME} + remote: git@github.com:{GIT_ORG}/{GIT_REPO}.git + branch: {GIT_BRANCH} + syncInterval: 1m + ``` + +See [ConfigurationReference](../../../user-guide/managing-piped/configuration-reference/) for the full configuration. + +- Start running the `piped` + + ``` console + ./piped piped --config-file={PATH_TO_PIPED_CONFIG_FILE} + ``` + diff --git a/docs/content/en/docs-v0.49.x/installation/install-piped/required-permissions.md b/docs/content/en/docs-v0.49.x/installation/install-piped/required-permissions.md new file mode 100644 index 0000000000..7350b65846 --- /dev/null +++ b/docs/content/en/docs-v0.49.x/installation/install-piped/required-permissions.md @@ -0,0 +1,102 @@ +--- +title: "Required Permissions" +linkTitle: "Required Permissions" +weight: 6 +description: > + This page describes what permissions are required for a Piped to deploy applications. +--- + +A Piped requires some permissions to deploy applications, depending on the platform. + +Note: If you run a piped as an ECS task, you need to attach the permissions on the piped task's `task role`, not `task execution role`. + +## For ECSApp + +You need IAM actions like the following example. You can restrict `Resource`. + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "ecs:CreateService", + "ecs:CreateTaskSet", + "ecs:DeleteTaskSet", + "ecs:DeregisterTaskDefinition", + "ecs:DescribeServices", + "ecs:DescribeTaskDefinition", + "ecs:DescribeTaskSets", + "ecs:DescribeTasks", + "ecs:ListClusters", + "ecs:ListServices", + "ecs:ListTasks", + "ecs:RegisterTaskDefinition", + "ecs:RunTask", + "ecs:TagResource", + "ecs:UpdateService", + "ecs:UpdateServicePrimaryTaskSet", + "elasticloadbalancing:DescribeListeners", + "elasticloadbalancing:DescribeRules", + "elasticloadbalancing:DescribeTargetGroups", + "elasticloadbalancing:ModifyListener", + "elasticloadbalancing:ModifyRule" + ], + "Resource": [ + "*" + ] + }, + { + "Effect": "Allow", + "Action": [ + "iam:PassRole" + ], + "Resource": [ + "arn:aws:iam:::role/", + "arn:aws:iam:::role/" + ] + } + ] +} +``` + +## For LambdaApp + +You need IAM actions like the following example. You can restrict `Resource`. + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "lambda:CreateAlias", + "lambda:CreateFunction", + "lambda:GetAlias", + "lambda:GetFunction", + "lambda:ListFunctions", + "lambda:PublishVersion", + "lambda:TagResource", + "lambda:UntagResource", + "lambda:UpdateAlias", + "lambda:UpdateFunctionCode", + "lambda:UpdateFunctionConfiguration" + ], + "Resource": [ + "*" + ] + }, + { + "Effect": "Allow", + "Action": [ + "iam:PassRole" + ], + "Resource": [ + "arn:aws:iam:::role/" + ] + } + ] +} +``` \ No newline at end of file diff --git a/docs/content/en/docs-v0.49.x/overview/_index.md b/docs/content/en/docs-v0.49.x/overview/_index.md new file mode 100644 index 0000000000..9fbaf09e67 --- /dev/null +++ b/docs/content/en/docs-v0.49.x/overview/_index.md @@ -0,0 +1,78 @@ +--- +title: "Overview" +linkTitle: "Overview" +weight: 1 +description: > + Overview about PipeCD. +--- + +![](/images/pipecd-explanation.png) +

+PipeCD - a GitOps style continuous delivery solution +

+ +## What Is PipeCD? + +{{% pageinfo %}} +PipeCD provides a unified continuous delivery solution for multiple application kinds on multi-cloud that empowers engineers to deploy faster with more confidence, a GitOps tool that enables doing deployment operations by pull request on Git. +{{% /pageinfo %}} + +## Why PipeCD? + +- Simple, unified and easy to use but powerful pipeline definition to construct your deployment +- Same deployment interface to deploy applications of any platform, including Kubernetes, Terraform, GCP Cloud Run, AWS Lambda, AWS ECS +- No CRD or applications' manifest changes are required; Only need a pipeline definition along with your application manifests +- No deployment credentials are exposed or required outside the application cluster +- Built-in deployment analysis as part of the deployment pipeline to measure impact based on metrics, logs, emitted requests +- Easy to interact with any CI; The CI tests and builds artifacts, PipeCD takes the rest +- Insights show metrics like lead time, deployment frequency, MTTR and change failure rate to measure delivery performance +- Designed to manage thousands of cross-platform applications in multi-cloud for company scale but also work well for small projects + +## PipeCD's Characteristics in detail + +**Visibility** +- Deployment pipeline UI shows clarify what is happening +- Separate logs viewer for each individual deployment +- Realtime visualization of application state +- Deployment notifications to slack, webhook endpoints +- Insights show metrics like lead time, deployment frequency, MTTR and change failure rate to measure delivery performance + +**Automation** +- Automated deployment analysis to measure deployment impact based on metrics, logs, emitted requests +- Automatically roll back to the previous state as soon as analysis or a pipeline stage fails +- Automatically detect configuration drift to notify and render the changes +- Automatically trigger a new deployment when a defined event has occurred (e.g. container image pushed, helm chart published, etc) + +**Safety and Security** +- Support single sign-on and role-based access control +- Credentials are not exposed outside the cluster and not saved in the Control Plane +- Piped makes only outbound requests and can run inside a restricted network +- Built-in secrets management + +**Multi-provider & Multi-Tenancy** +- Support multiple application kinds on multi-cloud including Kubernetes, Terraform, Cloud Run, AWS Lambda, Amazon ECS +- Support multiple analysis providers including Prometheus, Datadog, Stackdriver, and more +- Easy to operate multi-cluster, multi-tenancy by separating Control Plane and Piped + +**Open Source** + +- Released as an Open Source project +- Under APACHE 2.0 license, see [LICENSE](https://github.com/pipe-cd/pipecd/blob/master/LICENSE) + +## Where should I go next? + +For a good understanding of the PipeCD's components. +- [Concepts](../concepts): describes each components. +- [FAQ](../faq): describes the difference between PipeCD and other tools. + +If you are an **operator** wanting to install and configure PipeCD for other developers. +- [Quickstart](../quickstart/) +- [Managing Control Plane](../user-guide/managing-controlplane/) +- [Managing Piped](../user-guide/managing-piped/) + +If you are a **user** using PipeCD to deploy your application/infrastructure: +- [User Guide](../user-guide/) +- [Examples](../user-guide/examples) + +If you want to be a **contributor**: +- [Contributor Guide](../contribution-guidelines/) diff --git a/docs/content/en/docs-v0.49.x/quickstart/_index.md b/docs/content/en/docs-v0.49.x/quickstart/_index.md new file mode 100644 index 0000000000..ad0681530a --- /dev/null +++ b/docs/content/en/docs-v0.49.x/quickstart/_index.md @@ -0,0 +1,118 @@ +--- +title: "Quickstart" +linkTitle: "Quickstart" +weight: 3 +description: > + This page describes how to quickly get started with PipeCD on Kubernetes. +--- + +This page is a guideline for installing PipeCD into your Kubernetes cluster and deploying a "hello world" application to that same Kubernetes cluster. + +Note: + +- It's not required to install the PipeCD control plane to the cluster where your applications are running. Please read this [blog post](/blog/2021/12/29/pipecd-best-practice-01-operate-your-own-pipecd-cluster/) to understand more about PipeCD in real life use cases. +- If you want to experiment with PipeCD freely or don't have a Kubernetes cluster, we recommend [this Tutorial](https://github.com/pipe-cd/tutorial). + +### Prerequisites +- Having a Kubernetes cluster and connect to it via [kubectl](https://kubernetes.io/docs/tasks/tools/install-kubectl/). + +### 1. Installing PipeCD in quickstart mode + +Across the [PipeCD concepts](/docs/concepts/), PipeCD platform is constructed by 2 components: Control Plane and Piped (the agent). + +#### 1.1. Installing PipeCD Control Plane + +```console +$ kubectl create namespace pipecd +$ kubectl apply -n pipecd -f https://raw.githubusercontent.com/pipe-cd/pipecd/master/quickstart/manifests/control-plane.yaml +``` + +The PipeCD control plane will be installed with a default project named `quickstart`. To access the PipeCD Control Plane UI, run the following command + +```console +$ kubectl port-forward -n pipecd svc/pipecd 8080 +``` + +You can access the PipeCD console at [http://localhost:8080?project=quickstart](http://localhost:8080?project=quickstart) + +To login, you can use the configured static admin account as below: +- username: `hello-pipecd` +- password: `hello-pipecd` + +And you will access the main page of PipeCD Control Plane console, which looks like this + +![](/images/pipecd-control-plane-mainpage.png) + +For more about PipeCD control plane management, please check [Managing ControlPlane](/docs/user-guide/managing-controlplane/). + +#### 1.2. Installing Piped + +Next, in order to perform CD tasks, you need to install a Piped agent to the cluster. + +From your logged in tab, navigate to the PipeCD setting page at [http://localhost:8080/settings/piped?project=quickstart](http://localhost:8080/settings/piped?project=quickstart). + +You will find the `+ADD` button around the top left of this page, click there and insert information to register the `piped`. + +![](/images/quickstart-adding-piped.png) + +Click on the `Save` button, and then you can see the piped-id and secret-key. + +![](/images/quickstart-piped-registered.png) + +You need to copy two values, `Piped Id` and `Base64 Encoded Piped Key`, and fill in `` and `` respectively this below command + +```console +$ curl -s https://raw.githubusercontent.com/pipe-cd/pipecd/master/quickstart/manifests/piped.yaml | \ + sed -e 's///g' \ + -e 's///g' | \ + kubectl apply -n pipecd -f - +``` + +For more about Piped management, please check [Managing Piped](/docs/user-guide/managing-piped/). + +That's all! You are ready to use PipeCD to manage your application's deployment. + +You can check the readiness of all PipeCD components via command + +```console +$ kubectl get pod -n pipecd +NAME READY STATUS RESTARTS AGE +pipecd-cache-56c7c65ddc-xqcst 1/1 Running 0 38m +pipecd-gateway-58589b55f9-9nbrv 1/1 Running 0 38m +pipecd-minio-677999d5bb-xnb78 1/1 Running 0 38m +pipecd-mysql-6fff49fbc7-hkvt4 1/1 Running 0 38m +pipecd-ops-779d6844db-nvbwn 1/1 Running 0 38m +pipecd-server-5769df7fcb-9hc45 1/1 Running 1 (38m ago) 38m +piped-8477b5d55d-74s5v 1/1 Running 0 97s +``` + +### 2. Deploy a Kubernetes application with PipeCD + +Above is all that is necessary to set up your own PipeCD (both control plane and agent), let's use the installed one to deploy your first Kubernetes application with PipeCD. + +Navigate to the `Applications` page, click on the `+ADD` button on the top left corner. + +Go to the `ADD FROM SUGGESTIONS` tab, then select: +- Piped: `dev` (you just registered) +- PlatformProvider: `kubernetes-default` + +You should see a lot of suggested applications. Select one of listed applications and click the `SAVE` button to register. + +![](/images/quickstart-adding-application-from-suggestions.png) + +After a bit, the first deployment is complete and will automatically sync the application to the state specified in the current Git commit. + +![](/images/quickstart-first-deployment.png) + +For more about manage applications' deployment with PipeCD, referrence to [Managing application](/docs/user-guide/managing-application/) + +### 3. Cleanup +When you’re finished experimenting with PipeCD quickstart mode, you can uninstall it using: + +``` console +$ kubectl delete ns pipecd +``` + +### What's next? + +To prepare your PipeCD for a production environment, please visit the [Installation](../installation/) guideline. For guidelines to use PipeCD to deploy your application in daily usage, please visit the [User guide](../user-guide/) docs. diff --git a/docs/content/en/docs-v0.49.x/releases/_index.md b/docs/content/en/docs-v0.49.x/releases/_index.md new file mode 100644 index 0000000000..25ac023e4b --- /dev/null +++ b/docs/content/en/docs-v0.49.x/releases/_index.md @@ -0,0 +1,6 @@ +--- +title: "Releases ⧉" +manualLink: "https://github.com/pipe-cd/pipecd/releases" +manualLinkTarget: "_blank" +weight: 99 +--- \ No newline at end of file diff --git a/docs/content/en/docs-v0.49.x/user-guide/_index.md b/docs/content/en/docs-v0.49.x/user-guide/_index.md new file mode 100755 index 0000000000..5482b97115 --- /dev/null +++ b/docs/content/en/docs-v0.49.x/user-guide/_index.md @@ -0,0 +1,9 @@ +--- +title: "User Guide" +linkTitle: "User Guide" +weight: 5 +description: > + Guideline to use PipeCD, from installation to common features for daily usage. +--- + + diff --git a/docs/content/en/docs-v0.49.x/user-guide/command-line-tool.md b/docs/content/en/docs-v0.49.x/user-guide/command-line-tool.md new file mode 100644 index 0000000000..8c8450ee52 --- /dev/null +++ b/docs/content/en/docs-v0.49.x/user-guide/command-line-tool.md @@ -0,0 +1,393 @@ +--- +title: "Command-line tool: pipectl" +linkTitle: "Command-line tool: pipectl" +weight: 9 +description: > + This page describes how to install and use pipectl to manage PipeCD's resources. +--- + +Besides using web UI, PipeCD also provides a command-line tool, pipectl, which allows you to run commands against your project's resources. +You can use pipectl to add and sync applications, wait for a deployment status. + +## Installation + +The Pipectl command-line tool can be installed using one of the following methods: + +### Directly download and set up binary + +1. Download the appropriate version for your platform from [PipeCD Releases](https://github.com/pipe-cd/pipecd/releases). + + We recommend using the latest version of pipectl to avoid unforeseen issues. + Run the following script: + + ``` console + # OS="darwin" or "linux" + curl -Lo ./pipectl https://github.com/pipe-cd/pipecd/releases/download/{{< blocks/latest_version >}}/pipectl_{{< blocks/latest_version >}}_{OS}_amd64 + ``` + +2. Make the pipectl binary executable. + + ``` console + chmod +x ./pipectl + ``` + +3. Move the binary to your PATH. + + ``` console + sudo mv ./pipectl /usr/local/bin/pipectl + ``` + +4. Test to ensure the version you installed is up-to-date. + + ``` console + pipectl version + ``` + +### Using Asdf + +About [Asdf](https://asdf-vm.com/) + +1. Add pipectl plugin to asdf. (If you have not yet `asdf add plugin add pipectl`.) + ```console + asdf plugin add pipectl + ``` + +2. Install pipectl. Available versions are [here](https://github.com/pipe-cd/pipecd/releases). + ```console + asdf install pipectl {VERSION} + ``` + +3. Set a version. + ```console + asdf global pipectl {VERSION} + ``` + +4. Test to ensure the version you installed is up-to-date. + + ``` console + pipectl version + ``` + +### Using Aqua + +About [Aqua](https://aquaproj.github.io/) + +1. Add pipectl to `aqua.yaml`. (If you want to select a version, use `aqua g -i -s pipe-cd/pipecd/pipectl`) + ```console + aqua g -i pipe-cd/pipecd/pipectl + ``` + +2. Install pipectl. + ```console + aqua i + ``` + +3. Test to ensure the version you installed is up-to-date. + ```console + pipectl version + ``` + +### Using Homebrew + +About [Homebrew](https://brew.sh/) + +1. Add the `pipe-cd/tap` and fetch new formulae from GitHub. + ```console + brew tap pipe-cd/tap + brew update + ``` + +2. Install pipectl. + ```console + brew install pipectl + ``` + +3. Test to ensure the version you installed is up-to-date. + ```console + pipectl version + ``` + +### Run in Docker container + +We are storing every version of docker image for pipectl on Google Cloud Container Registry. +Available versions are [here](https://github.com/pipe-cd/pipecd/releases). + +``` +docker run --rm gcr.io/pipecd/pipectl:{VERSION} -h +``` + +## Authentication + +In order for pipectl to authenticate with PipeCD's Control Plane, it needs an API key, which can be created from `Settings/API Key` tab on the web UI. +There are two kinds of key role: `READ_ONLY` and `READ_WRITE`. Depending on the command, it might require an appropriate role to execute. + +![](/images/settings-api-key.png) +

+Adding a new API key from Settings tab +

+ +When executing a command of pipectl you have to specify either a string of API key via `--api-key` flag or a path to the API key file via `--api-key-file` flag. + +## Usage + +### Help + +Run `help` to know the available commands: + +``` console +$ pipectl --help + +The command line tool for PipeCD. + +Usage: + pipectl [command] + +Available Commands: + application Manage application resources. + deployment Manage deployment resources. + encrypt Encrypt the plaintext entered in either stdin or the --input-file flag. + event Manage event resources. + help Help about any command + init Generate an application config (app.pipecd.yaml) easily and interactively. + piped Manage piped resources. + plan-preview Show plan preview against the specified commit. + quickstart Quick prepare PipeCD control plane in quickstart mode. + version Print the information of current binary. + +Flags: + -h, --help help for pipectl + --log-encoding string The encoding type for logger [json|console|humanize]. (default "humanize") + --log-level string The minimum enabled logging level. (default "info") + --metrics Whether metrics is enabled or not. (default true) + --profile If true enables uploading the profiles to Stackdriver. + --profile-debug-logging If true enables logging debug information of profiler. + --profiler-credentials-file string The path to the credentials file using while sending profiles to Stackdriver. + +Use "pipectl [command] --help" for more information about a command. +``` + +### Adding a new application + +Add a new application into the project: + +``` console +pipectl application add \ + --address=CONTROL_PLANE_API_ADDRESS \ + --api-key=API_KEY \ + --app-name=simple \ + --app-kind=KUBERNETES \ + --piped-id=PIPED_ID \ + --platform-provider=kubernetes-default \ + --repo-id=examples \ + --app-dir=kubernetes/simple +``` + +Run `help` to know what command flags should be specified: + +``` console +$ pipectl application add --help + +Add a new application. + +Usage: + pipectl application add [flags] + +Flags: + --app-dir string The relative path from the root of repository to the application directory. + --app-kind string The kind of application. (KUBERNETES|TERRAFORM|LAMBDA|CLOUDRUN) + --app-name string The application name. + --platform-provider string The platform provider name. One of the registered providers in the piped configuration. The previous name of this field is cloud-provider. + --config-file-name string The configuration file name. (default "app.pipecd.yaml") + --description string The description of the application. + -h, --help help for add + --piped-id string The ID of piped that should handle this application. + --repo-id string The repository ID. One the registered repositories in the piped configuration. + +Global Flags: + --address string The address to Control Plane api. + --api-key string The API key used while authenticating with Control Plane. + --api-key-file string Path to the file containing API key used while authenticating with Control Plane. + --cert-file string The path to the TLS certificate file. + --insecure Whether disabling transport security while connecting to Control Plane. + --log-encoding string The encoding type for logger [json|console|humanize]. (default "humanize") + --log-level string The minimum enabled logging level. (default "info") + --metrics Whether metrics is enabled or not. (default true) + --profile If true enables uploading the profiles to Stackdriver. + --profile-debug-logging If true enables logging debug information of profiler. + --profiler-credentials-file string The path to the credentials file using while sending profiles to Stackdriver. +``` + +### Syncing an application + +- Send a request to sync an application and exit immediately when the deployment is triggered: + + ``` console + pipectl application sync \ + --address={CONTROL_PLANE_API_ADDRESS} \ + --api-key={API_KEY} \ + --app-id={APPLICATION_ID} + ``` + +- Send a request to sync an application and wait until the triggered deployment reaches one of the specified statuses: + + ``` console + pipectl application sync \ + --address={CONTROL_PLANE_API_ADDRESS} \ + --api-key={API_KEY} \ + --app-id={APPLICATION_ID} \ + --wait-status=DEPLOYMENT_SUCCESS,DEPLOYMENT_FAILURE + ``` + +### Getting an application + +Display the information of a given application in JSON format: + +``` console +pipectl application get \ + --address={CONTROL_PLANE_API_ADDRESS} \ + --api-key={API_KEY} \ + --app-id={APPLICATION_ID} +``` + +### Listing applications + +Find and display the information of matching applications in JSON format: + +``` console +pipectl application list \ + --address={CONTROL_PLANE_API_ADDRESS} \ + --api-key={API_KEY} \ + --app-name={APPLICATION_NAME} \ + --app-kind=KUBERNETES \ +``` + +### Disable an application + +Disable an application with given id: + +``` console +pipectl application disable \ + --address={CONTROL_PLANE_API_ADDRESS} \ + --api-key={API_KEY} \ + --app-id={APPLICATION_ID} +``` + +### Deleting an application + +Delete an application with given id: + +``` console +pipectl application delete \ + --address={CONTROL_PLANE_API_ADDRESS} \ + --api-key={API_KEY} \ + --app-id={APPLICATION_ID} +``` + +### List deployments + +Show the list of deployments based on filters. + +```console +pipectl deployment list \ + --address={CONTROL_PLANE_API_ADDRESS} \ + --api-key={API_KEY} \ + --app-id={APPLICATION_ID} +``` + +### Waiting a deployment status + +Wait until a given deployment reaches one of the specified statuses: + +``` console +pipectl deployment wait-status \ + --address={CONTROL_PLANE_API_ADDRESS} \ + --api-key={API_KEY} \ + --deployment-id={DEPLOYMENT_ID} \ + --status=DEPLOYMENT_SUCCESS +``` + +### Get deployment stages log + +Get deployment stages log. + +```console +pipectl deployment logs \ + --address={CONTROL_PLANE_API_ADDRESS} \ + --api-key={API_KEY} \ + --deployment-id={DEPLOYMENT_ID} +``` + +### Registering an event for EventWatcher + +Register an event that can be used by EventWatcher: + +``` console +pipectl event register \ + --address={CONTROL_PLANE_API_ADDRESS} \ + --api-key={API_KEY} \ + --name=example-image-pushed \ + --data=gcr.io/pipecd/example:v0.1.0 +``` + +### Encrypting the data you want to use when deploying + +Encrypt the plaintext entered either in stdin or via the `--input-file` flag. + +You can encrypt it the same way you do [from the web](../managing-application/secret-management/#encrypting-secret-data). + +- From stdin: + + ``` console + pipectl encrypt \ + --address={CONTROL_PLANE_API_ADDRESS} \ + --api-key={API_KEY} \ + --piped-id={PIPED_ID} <{PATH_TO_SECRET_FILE} + ``` + +- From the `--input-file` flag: + + ``` console + pipectl encrypt \ + --address={CONTROL_PLANE_API_ADDRESS} \ + --api-key={API_KEY} \ + --piped-id={PIPED_ID} \ + --input-file={PATH_TO_SECRET_FILE} + ``` + +Note: The docs for pipectl available command is maybe outdated, we suggest users use the `help` command for the updated usage while using pipectl. + +### Generating an application config (app.pipecd.yaml) + + +Generate an app.pipecd.yaml interactively: + +``` console +$ pipectl init +Which platform? Enter the number [0]Kubernetes [1]ECS: 1 +Name of the application: myApp +... +``` + +After the above interaction, you can get the config YAML: + +```yaml +apiVersion: pipecd.dev/v1beta1 +kind: ECSApp +spec: + name: myApp + input: + serviceDefinitionFile: serviceDef.yaml + taskDefinitionFile: taskDef.yaml + targetGroups: + primary: + targetGroupArn: arn:aws:elasticloadbalancing:ap-northeast-1:123456789012:targetgroup/xxx/xxx + containerName: web + containerPort: 80 + description: Generated by `pipectl init`. See https://pipecd.dev/docs/user-guide/configuration-reference/ for more. +``` + +See [Feature Status](../feature-status/_index.md#pipectl-init). + +### You want more? + +We always want to add more needed commands into pipectl. Please let us know what command you want to add by creating issues in the [pipe-cd/pipecd](https://github.com/pipe-cd/pipecd/issues) repository. We also welcome your pull request to add the command. diff --git a/docs/content/en/docs-v0.49.x/user-guide/configuration-reference.md b/docs/content/en/docs-v0.49.x/user-guide/configuration-reference.md new file mode 100644 index 0000000000..f2fa64d848 --- /dev/null +++ b/docs/content/en/docs-v0.49.x/user-guide/configuration-reference.md @@ -0,0 +1,839 @@ +--- +title: "Configuration reference" +linkTitle: "Configuration reference" +weight: 11 +description: > + This page describes all configurable fields in the application configuration and analysis template. +--- + +## Kubernetes Application + +``` yaml +apiVersion: pipecd.dev/v1beta1 +kind: KubernetesApp +spec: + input: + pipeline: + ... +``` + +| Field | Type | Description | Required | +|-|-|-|-| +| name | string | The application name. | Yes (if you want to create PipeCD application through the application configuration file) | +| labels | map[string]string | Additional attributes to identify applications. | No | +| description | string | Notes on the Application. | No | +| input | [KubernetesDeploymentInput](#kubernetesdeploymentinput) | Input for Kubernetes deployment such as kubectl version, helm version, manifests filter... | No | +| trigger | [DeploymentTrigger](#deploymenttrigger) | Configuration for trigger used to determine should we trigger a new deployment or not. | No | +| planner | [DeploymentPlanner](#deploymentplanner) | Configuration for planner used while planning deployment. | No | +| commitMatcher | [CommitMatcher](#commitmatcher) | Forcibly use QuickSync or Pipeline when commit message matched the specified pattern. | No | +| quickSync | [KubernetesQuickSync](#kubernetesquicksync) | Configuration for quick sync. | No | +| pipeline | [Pipeline](#pipeline) | Pipeline for deploying progressively. | No | +| service | [KubernetesService](#kubernetesservice) | Which Kubernetes resource should be considered as the Service of application. Empty means the first Service resource will be used. | No | +| workloads | [][KubernetesWorkload](#kubernetesworkload) | Which Kubernetes resources should be considered as the Workloads of application. Empty means all Deployment resources. | No | +| trafficRouting | [KubernetesTrafficRouting](#kubernetestrafficrouting) | How to change traffic routing percentages. | No | +| encryption | [SecretEncryption](#secretencryption) | List of encrypted secrets and targets that should be decrypted before using. | No | +| attachment | [Attachment](#attachment) | List of attachment sources and targets that should be attached to manifests before using. | No | +| timeout | duration | The maximum length of time to execute deployment before giving up. Default is 6h. | No | +| notification | [DeploymentNotification](#deploymentnotification) | Additional configuration used while sending notification to external services. | No | +| postSync | [PostSync](#postsync) | Additional configuration used as extra actions once the deployment is triggered. | No | +| variantLabel | [KubernetesVariantLabel](#kubernetesvariantlabel) | The label will be configured to variant manifests used to distinguish them. | No | +| eventWatcher | [][EventWatcher](#eventwatcher) | List of configurations for event watcher. | No | +| driftDetection | [DriftDetection](#driftdetection) | Configuration for drift detection. | No | + +### Annotations + +Kubernetes resources can be managed by some annotations provided by PipeCD. + +| Annotation key | Target resource(s) | Possible values | Description | +|-|-|-|-| +| `pipecd.dev/ignore-drift-detection` | any | "true" | Whether the drift detection should ignore this resource. | +| `pipecd.dev/server-side-apply` | any | "true" | Use server side apply instead of client side apply. | +| `pipecd.dev/sync-by-replace` | any | "enabled" | Use `replace` instead of `apply`. | +| `pipecd.dev/force-sync-by-replace` | any | "enabled" | Use `replace --force` instead of `apply`. | + +## Terraform application + +``` yaml +apiVersion: pipecd.dev/v1beta1 +kind: TerraformApp +spec: + input: + pipeline: + ... +``` + +| Field | Type | Description | Required | +|-|-|-|-| +| name | string | The application name. | Yes if you set the application through the application configuration file | +| labels | map[string]string | Additional attributes to identify applications. | No | +| description | string | Notes on the Application. | No | +| input | [TerraformDeploymentInput](#terraformdeploymentinput) | Input for Terraform deployment such as terraform version, workspace... | No | +| trigger | [DeploymentTrigger](#deploymenttrigger) | Configuration for trigger used to determine should we trigger a new deployment or not. | No | +| planner | [DeploymentPlanner](#deploymentplanner) | Configuration for planner used while planning deployment. | No | +| quickSync | [TerraformQuickSync](#terraformquicksync) | Configuration for quick sync. | No | +| pipeline | [Pipeline](#pipeline) | Pipeline for deploying progressively. | No | +| encryption | [SecretEncryption](#secretencryption) | List of encrypted secrets and targets that should be decrypted before using. | No | +| attachment | [Attachment](#attachment) | List of attachment sources and targets that should be attached to manifests before using. | No | +| timeout | duration | The maximum length of time to execute deployment before giving up. Default is 6h. | No | +| notification | [DeploymentNotification](#deploymentnotification) | Additional configuration used while sending notification to external services. | No | +| postSync | [PostSync](#postsync) | Additional configuration used as extra actions once the deployment is triggered. | No | +| eventWatcher | [][EventWatcher](#eventwatcher) | List of configurations for event watcher. | No | + +## Cloud Run application + +``` yaml +apiVersion: pipecd.dev/v1beta1 +kind: CloudRunApp +spec: + input: + pipeline: + ... +``` + +| Field | Type | Description | Required | +|-|-|-|-| +| name | string | The application name. | Yes if you set the application through the application configuration file | +| labels | map[string]string | Additional attributes to identify applications. | No | +| description | string | Notes on the Application. | No | +| input | [CloudRunDeploymentInput](#cloudrundeploymentinput) | Input for Cloud Run deployment such as docker image... | No | +| trigger | [DeploymentTrigger](#deploymenttrigger) | Configuration for trigger used to determine should we trigger a new deployment or not. | No | +| planner | [DeploymentPlanner](#deploymentplanner) | Configuration for planner used while planning deployment. | No | +| quickSync | [CloudRunQuickSync](#cloudrunquicksync) | Configuration for quick sync. | No | +| pipeline | [Pipeline](#pipeline) | Pipeline for deploying progressively. | No | +| encryption | [SecretEncryption](#secretencryption) | List of encrypted secrets and targets that should be decrypted before using. | No | +| attachment | [Attachment](#attachment) | List of attachment sources and targets that should be attached to manifests before using. | No | +| timeout | duration | The maximum length of time to execute deployment before giving up. Default is 6h. | No | +| notification | [DeploymentNotification](#deploymentnotification) | Additional configuration used while sending notification to external services. | No | +| postSync | [PostSync](#postsync) | Additional configuration used as extra actions once the deployment is triggered. | No | +| eventWatcher | [][EventWatcher](#eventwatcher) | List of configurations for event watcher. | No | + +## Lambda application + +``` yaml +apiVersion: pipecd.dev/v1beta1 +kind: LambdaApp +spec: + pipeline: + ... +``` + +| Field | Type | Description | Required | +|-|-|-|-| +| name | string | The application name. | Yes if you set the application through the application configuration file | +| labels | map[string]string | Additional attributes to identify applications. | No | +| description | string | Notes on the Application. | No | +| input | [LambdaDeploymentInput](#lambdadeploymentinput) | Input for Lambda deployment such as path to function manifest file... | No | +| architectures | []string| Specific architecture for which a function supports (Default x86_64). | No | +| trigger | [DeploymentTrigger](#deploymenttrigger) | Configuration for trigger used to determine should we trigger a new deployment or not. | No | +| planner | [DeploymentPlanner](#deploymentplanner) | Configuration for planner used while planning deployment. | No | +| quickSync | [LambdaQuickSync](#lambdaquicksync) | Configuration for quick sync. | No | +| pipeline | [Pipeline](#pipeline) | Pipeline for deploying progressively. | No | +| encryption | [SecretEncryption](#secretencryption) | List of encrypted secrets and targets that should be decrypted before using. | No | +| attachment | [Attachment](#attachment) | List of attachment sources and targets that should be attached to manifests before using. | No | +| timeout | duration | The maximum length of time to execute deployment before giving up. Default is 6h. | No | +| notification | [DeploymentNotification](#deploymentnotification) | Additional configuration used while sending notification to external services. | No | +| postSync | [PostSync](#postsync) | Additional configuration used as extra actions once the deployment is triggered. | No | +| eventWatcher | [][EventWatcher](#eventwatcher) | List of configurations for event watcher. | No | + +## ECS application + +``` yaml +apiVersion: pipecd.dev/v1beta1 +kind: ECSApp +spec: + input: + pipeline: + ... +``` + +| Field | Type | Description | Required | +|-|-|-|-| +| name | string | The application name. | Yes if you set the application through the application configuration file | +| labels | map[string]string | Additional attributes to identify applications. | No | +| description | string | Notes on the Application. | No | +| input | [ECSDeploymentInput](#ecsdeploymentinput) | Input for ECS deployment such as path to TaskDefinition, Service... | No | +| trigger | [DeploymentTrigger](#deploymenttrigger) | Configuration for trigger used to determine should we trigger a new deployment or not. | No | +| planner | [DeploymentPlanner](#deploymentplanner) | Configuration for planner used while planning deployment. | No | +| quickSync | [ECSQuickSync](#ecsquicksync) | Configuration for quick sync. | No | +| pipeline | [Pipeline](#pipeline) | Pipeline for deploying progressively. | No | +| encryption | [SecretEncryption](#secretencryption) | List of encrypted secrets and targets that should be decrypted before using. | No | +| attachment | [Attachment](#attachment) | List of attachment sources and targets that should be attached to manifests before using. | No | +| timeout | duration | The maximum length of time to execute deployment before giving up. Default is 6h. | No | +| notification | [DeploymentNotification](#deploymentnotification) | Additional configuration used while sending notification to external services. | No | +| postSync | [PostSync](#postsync) | Additional configuration used as extra actions once the deployment is triggered. | No | +| eventWatcher | [][EventWatcher](#eventwatcher) | List of configurations for event watcher. | No | + +## Analysis Template Configuration + +``` yaml +apiVersion: pipecd.dev/v1beta1 +kind: AnalysisTemplate +spec: + metrics: + grpc_error_rate_percentage: + interval: 1m + provider: prometheus-dev + failureLimit: 1 + expected: + max: 10 + query: awesome_query +``` + +| Field | Type | Description | Required | +|-|-|-|-| +| metrics | map[string][AnalysisMetrics](#analysismetrics) | Template for metrics. | No | + +## Event Watcher Configuration (deprecated) + +```yaml +apiVersion: pipecd.dev/v1beta1 +kind: EventWatcher +spec: + events: + - name: helloworld-image-update + replacements: + - file: helloworld/deployment.yaml + yamlField: $.spec.template.spec.containers[0].image +``` + +| Field | Type | Description | Required | +|-|-|-|-| +| name | string | The event name. | Yes | +| labels | map[string]string | Additional attributes of event. This can make an event definition unique even if the one with the same name exists. | No | +| replacements | [][EventWatcherReplacement](#eventwatcherreplacement) | List of places where will be replaced when the new event matches. | Yes | + +### EventWatcherReplacement +One of `yamlField` or `regex` is required. + +| Field | Type | Description | Required | +|-|-|-|-| +| file | string | The relative path from the repository root to the file to be updated. | Yes | +| yamlField | string | 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`. | No | +| regex | string | The regex string that specify what should be replaced. The only 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])` | No | + +## CommitMatcher + +| Field | Type | Description | Required | +|-|-|-|-| +| quickSync | string | Regular expression string to forcibly do QuickSync when it matches the commit message. | No | +| pipeline | string | Regular expression string to forcibly do Pipeline when it matches the commit message. | No | + +## SecretEncryption + +| Field | Type | Description | Required | +|-|-|-|-| +| encryptedSecrets | map[string]string | List of encrypted secrets. | No | +| decryptionTargets | []string | List of files to be decrypted before using. | No | + +## Attachment + +| Field | Type | Description | Required | +|-|-|-|-| +| sources | map[string]string | List of attaching files with key is its refer name. | No | +| targets | []string | List of files which should contain the attachments. | No | + +## DeploymentPlanner + +| Field | Type | Description | Required | +|-|-|-|-| +| alwaysUsePipeline | bool | Always use the defined pipeline to deploy the application in all deployments. Default is `false`. | No | + +## DeploymentTrigger + +| Field | Type | Description | Required | +|-|-|-|-| +| onCommit | [OnCommit](#oncommit) | Controls triggering new deployment when new Git commits touched the application. | No | +| onCommand | [OnCommand](#oncommand) | Controls triggering new deployment when received a new `SYNC` command. | No | +| onOutOfSync | [OnOutOfSync](#onoutofsync) | Controls triggering new deployment when application is at `OUT_OF_SYNC` state. | No | +| onChain | [OnChain](#onchain) | Controls triggering new deployment when the application is counted as a node of some chains. | No | + +### OnCommit + +| Field | Type | Description | Required | +|-|-|-|-| +| disabled | bool | Whether to exclude application from triggering target when new Git commits touched it. Default is `false`. | No | +| paths | []string | List of directories or files where any changes of them will be considered as touching the application. Regular expression can be used. Empty means watching all changes under the application directory. | No | +| ignores | []string | List of directories or files where any changes of them will NOT be considered as touching the application. Regular expression can be used. This config has a higher priority compare to `paths`. | No | + +### OnCommand + +| Field | Type | Description | Required | +|-|-|-|-| +| disabled | bool | Whether to exclude application from triggering target when received a new `SYNC` command. Default is `false`. | No | + +### OnOutOfSync + +| Field | Type | Description | Required | +|-|-|-|-| +| disabled | bool | Whether to exclude application from triggering target when application is at `OUT_OF_SYNC` state. Default is `true`. | No | +| minWindow | duration | 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. Default is `5m`. | No | + +### OnChain + +| Field | Type | Description | Required | +|-|-|-|-| +| disabled | bool | Whether to exclude application from triggering target when application is counted as a node of some chains. Default is `true`. | No | + +## Pipeline + +| Field | Type | Description | Required | +|-|-|-|-| +| stages | [][PipelineStage](#pipelinestage) | List of deployment pipeline stages. | No | + +### PipelineStage + +| Field | Type | Description | Required | +|-|-|-|-| +| id | string | The unique ID of the stage. | No | +| name | string | One of the provided stage names. | Yes | +| desc | string | The description about the stage. | No | +| timeout | duration | The maximum time the stage can be taken to run. | No | +| with | [StageOptions](#stageoptions) | Specific configuration for the stage. This must be one of these [StageOptions](#stageoptions). | No | + +## DeploymentNotification + +| Field | Type | Description | Required | +|-|-|-|-| +| mentions | [][NotificationMention](#notificationmention) | List of users to be notified for each event. | No | + +### NotificationMention + +| Field | Type | Description | Required | +|-|-|-|-| +| event | string | The event to be notified to users. | Yes | +| slack | []string | Deprecated: Please use `slackUsers` instead. List of user IDs for mentioning in Slack. See [here](https://api.slack.com/reference/surfaces/formatting#mentioning-users) for more information on how to check them. | No | +| slackUsers | []string | List of user IDs for mentioning in Slack. See [here](https://api.slack.com/reference/surfaces/formatting#mentioning-users) for more information on how to check them. | No | +| slackGroups | []string | List of group IDs for mentioning in Slack. See [here](https://api.slack.com/reference/surfaces/formatting#mentioning-groups) for more information on how to check them. | No | + +## KubernetesDeploymentInput + +| Field | Type | Description | Required | +|-|-|-|-| +| manifests | []string | List of manifest files in the application directory used to deploy. Empty means all manifest files in the directory will be used. | No | +| kubectlVersion | string | Version of kubectl will be used. Empty means the version set on [piped config](../managing-piped/configuration-reference/#platformproviderkubernetesconfig) or [default version](https://github.com/pipe-cd/pipecd/blob/master/pkg/app/piped/toolregistry/install.go#L29) will be used. | No | +| kustomizeVersion | string | Version of kustomize will be used. Empty means the [default version](https://github.com/pipe-cd/pipecd/blob/master/pkg/app/piped/toolregistry/install.go#L30) will be used. | No | +| kustomizeOptions | map[string]string | List of options that should be used by Kustomize commands. | No | +| helmVersion | string | Version of helm will be used. Empty means the [default version](https://github.com/pipe-cd/pipecd/blob/master/pkg/app/piped/toolregistry/install.go#L31) will be used. | No | +| helmChart | [HelmChart](#helmchart) | Where to fetch helm chart. | No | +| helmOptions | [HelmOptions](#helmoptions) | Configurable parameters for helm commands. | No | +| namespace | string | The namespace where manifests will be applied. | No | +| autoRollback | bool | Automatically reverts all deployment changes on failure. Default is `true`. | No | +| autoCreateNamespace | bool | Automatically create a new namespace if it does not exist. Default is `false`. | No | + +### HelmChart + +| Field | Type | Description | Required | +|-|-|-|-| +| gitRemote | string | Git remote address where the chart is placing. Empty means the same repository. | No | +| ref | string | The commit SHA or tag value. Only valid when gitRemote is not empty. | No | +| path | string | Relative path from the repository root to the chart directory. | No | +| repository | string | The name of a registered Helm Chart Repository. | No | +| name | string | The chart name. | No | +| version | string | The chart version. | No | + +### HelmOptions + +| Field | Type | Description | Required | +|-|-|-|-| +| releaseName | string | The release name of helm deployment. By default, the release name is equal to the application name. | No | +| setValues | map[string]string | List of values. | No | +| valueFiles | []string | List of value files should be loaded. Only local files stored under the application directory or remote files served at the http(s) endpoint are allowed. | No | +| setFiles | map[string]string | List of file path for values. | No | +| apiVersions | []string | Kubernetes api versions used for Capabilities.APIVersions. | No | +| kubeVersion | string | Kubernetes version used for Capabilities.KubeVersion. | No | + +## KubernetesVariantLabel + +| Field | Type | Description | Required | +|-|-|-|-| +| key | string | The key of the label. Default is `pipecd.dev/variant`. | No | +| primaryValue | string | The label value for PRIMARY variant. Default is `primary`. | No | +| canaryValue | string | The label value for CANARY variant. Default is `canary`. | No | +| baselineValue | string | The label value for BASELINE variant. Default is `baseline`. | No | + +## KubernetesQuickSync + +| Field | Type | Description | Required | +|-|-|-|-| +| addVariantLabelToSelector | bool | Whether the PRIMARY variant label should be added to manifests if they were missing. Default is `false`. | No | +| prune | bool | Whether the resources that are no longer defined in Git should be removed or not. Default is `false` | No | + +## KubernetesService + +| Field | Type | Description | Required | +|-|-|-|-| +| name | string | The name of Service manifest. | No | + +## KubernetesWorkload + +| Field | Type | Description | Required | +|-|-|-|-| +| kind | string | The kind name of workload manifests. Currently, only `Deployment` is supported. In the future, we also want to support `ReplicationController`, `DaemonSet`, `StatefulSet`. | No | +| name | string | The name of workload manifest. | No | + +## KubernetesTrafficRouting + +| Field | Type | Description | Required | +|-|-|-|-| +| method | string | Which traffic routing method will be used. Available values are `istio`, `smi`, `podselector`. Default is `podselector`. | No | +| istio | [IstioTrafficRouting](#istiotrafficrouting)| Istio configuration when the method is `istio`. | No | + +### IstioTrafficRouting + +| Field | Type | Description | Required | +|-|-|-|-| +| editableRoutes | []string | List of routes in the VirtualService that can be changed to update traffic routing. Empty means all routes should be updated. | No | +| host | string | The service host. | No | +| virtualService | [IstioVirtualService](#istiovirtualservice) | The reference to VirtualService manifest. Empty means the first VirtualService resource will be used. | No | + +#### IstioVirtualService + +| Field | Type | Description | Required | +|-|-|-|-| +| name | string | The name of VirtualService manifest. | No | + +## TerraformDeploymentInput + +| Field | Type | Description | Required | +|-|-|-|-| +| workspace | string | The terraform workspace name. Empty means `default` workspace. | No | +| terraformVersion | string | The version of terraform should be used. Empty means the pre-installed version will be used. | No | +| vars | []string | List of variables that will be set directly on terraform commands with `-var` flag. The variable must be formatted by `key=value`. | No | +| varFiles | []string | List of variable files that will be set on terraform commands with `-var-file` flag. | No | +| commandFlags | [TerraformCommandFlags](#terraformcommandflags) | List of additional flags will be used while executing terraform commands. | No | +| commandEnvs | [TerraformCommandEnvs](#terraformcommandenvs) | List of additional environment variables will be used while executing terraform commands. | No | +| autoRollback | bool | Automatically reverts all changes from all stages when one of them failed. | No | + +### TerraformCommandFlags + +| Field | Type | Description | Required | +|-|-|-|-| +| shared | []string | List of additional flags used for all Terraform commands. | No | +| init | []string | List of additional flags used for Terraform `init` command. | No | +| plan | []string | List of additional flags used for Terraform `plan` command. | No | +| apply | []string | List of additional flags used for Terraform `apply` command. | No | + +### TerraformCommandEnvs + +| Field | Type | Description | Required | +|-|-|-|-| +| shared | []string | List of additional environment variables used for all Terraform commands. | No | +| init | []string | List of additional environment variables used for Terraform `init` command. | No | +| plan | []string | List of additional environment variables used for Terraform `plan` command. | No | +| apply | []string | List of additional environment variables used for Terraform `apply` command. | No | + +## TerraformQuickSync + +| Field | Type | Description | Required | +|-|-|-|-| +| retries | int | How many times to retry applying terraform changes. Default is `0`. | No | + +## CloudRunDeploymentInput + +| Field | Type | Description | Required | +|-|-|-|-| +| serviceManifestFile | string | The name of service manifest file placing in application directory. Default is `service.yaml`. | No | +| autoRollback | bool | Automatically reverts to the previous state when the deployment is failed. Default is `true`. | No | + +## CloudRunQuickSync + +| Field | Type | Description | Required | +|-|-|-|-| + +## LambdaDeploymentInput + +| Field | Type | Description | Required | +|-|-|-|-| +| functionManifestFile | string | The name of function manifest file placing in application directory. Default is `function.yaml`. | No | +| autoRollback | bool | Automatically reverts to the previous state when the deployment is failed. Default is `true`. | No | + +### Specific function.yaml + +One of `image`, `s3Bucket`, or `source` is required. + +- If you use `s3Bucket`, `s3Key` and `s3ObjectVersion` are required. + +- If you use `s3Bucket` or `source`, `handler` and `runtime` are required. + +See [Configuring Lambda application](../managing-application/defining-app-configuration/lambda) for more details. + +| Field | Type | Description | Required | +|------------------|------------------|------------------------------------|----------| +| name | string | Name of the Lambda function | Yes | +| role | string | IAM role ARN | Yes | +| image | string | URI of the container image | No | +| s3Bucket | string | S3 bucket name for code package | No | +| s3Key | string | S3 key for code package | No | +| s3ObjectVersion | string | S3 object version for code package | No | +| source | [source](#source) | Git settings | No | +| handler | string | Lambda function handler | No | +| runtime | string | Runtime environment | No | +| architectures | [][Architecture](#architecture) | Supported architectures | No | +| ephemeralStorage | [EphemeralStorage](#ephemeralstorage)| Ephemeral storage configuration | No | +| memory | int32 | Memory allocation (in MB) | Yes | +| timeout | int32 | Function timeout (in seconds) | Yes | +| tags | map[string]string| Key-value pairs for tags | No | +| environments | map[string]string| Environment variables | No | +| vpcConfig | [VPCConfig](#vpcconfig) | VPC configuration | No | +| layers | []string | ARNs of [layers](https://docs.aws.amazon.com/lambda/latest/dg/chapter-layers.html) to depend on | No | + +#### Source + +| Field | Type | Description | Required | +|-------|--------|--------------------------|----------| +| git | string | Git repository URL | Yes | +| ref | string | Git branch/tag/reference| Yes | +| path | string | Path within the repository | Yes | + +#### Architecture + +| Field | Type | Description | Required | +|-------|--------|------------------------|----------| +| name | string | Name of the architecture | Yes | + +#### EphemeralStorage + +| Field | Type | Description | Required | +|-------|-------|------------------------------|----------| +| size | int32 | Size of the ephemeral storage| Yes | + +#### VPCConfig + +| Field | Type | Description | Required | +|-----------------|----------|-----------------------------|----------| +| securityGroupIds| []string | List of security group IDs | No | +| subnetIds | []string | List of subnet IDs | No | + + +## LambdaQuickSync + +| Field | Type | Description | Required | +|-|-|-|-| + +## ECSDeploymentInput + +| Field | Type | Description | Required | +|-|-|-|-| +| serviceDefinitionFile | string | The path ECS Service configuration file. Allow file in both `yaml` and `json` format. The default value is `service.json`. See [here](https://docs.aws.amazon.com/AmazonECS/latest/developerguide/service_definition_parameters.html) and [Restrictions](#restrictions-of-service-definition) for parameters.| No | +| taskDefinitionFile | string | The path to ECS TaskDefinition configuration file. Allow file in both `yaml` and `json` format. The default value is `taskdef.json`. See [here](https://docs.aws.amazon.com/AmazonECS/latest/developerguide/task_definition_parameters.html) and [Restrictions](#restrictions-of-task-definition) for parameters. | No | +| targetGroups | [ECSTargetGroupInput](#ecstargetgroupinput) | The target groups configuration, will be used to routing traffic to created task sets. | Yes (if you want to perform progressive delivery) | +| runStandaloneTask | bool | Run standalone tasks during deployments. About standalone task, see [here](https://docs.aws.amazon.com/AmazonECS/latest/userguide/ecs_run_task-v2.html). The default value is `true`. | +| accessType | string | How the ECS service is accessed. One of `ELB` or `SERVICE_DISCOVERY`. See examples [here](https://github.com/pipe-cd/examples/tree/master/ecs/servicediscovery/simple). The default value is `ELB`. | + +### Restrictions of Service Definition + +There are some restrictions in configuring a service definition file. + +- As long as `desiredCount` is 0 or not set, `desiredCount` of your service will NOT be updated in deployments. + - If `desiredCount` is 0 or not set for a new service, the service's `desiredCount` will be 0. +- `capacityProviderStrategy` is not supported. +- `clientToken` is not supported. +- `deploymentController` is required and must be `EXTERNAL`. +- `loadBalancers` is not supported. Use `targetGroups` in [ECSDeploymentInput](#ecsdeploymentinput) instead. +- `platformFamily` is not supported. +- `propagateTags` is always set as `SERVICE`. +- `taskDefinition` is not supported. PipeCD uses the definition in `taskDefinitionFile` in [ECSDeploymentInput](#ecsdeploymentinput). + +### Restrictions of Task Definition + +There are some restrictions in configuring a task definition file. + +- `placementConstraints` is not supported. +- `proxyConfiguration` is not supported. +- `tags` is not supported. + +### ECSTargetGroupInput + +| Field | Type | Description | Required | +|-|-|-|-| +| primary | [ECSTargetGroupObject](#ecstargetgroupobject) | The PRIMARY target group, will be used to register the PRIMARY ECS task set. | Yes | +| canary | [ECSTargetGroupObject](#ecstargetgroupobject) | The CANARY target group, will be used to register the CANARY ECS task set if exist. It's required to enable PipeCD to perform the multi-stage deployment. | No | + +#### ECSTargetGroupObject + +| Field | Type | Description | Required | +|-|-|-|-| +| targetGroupArn | string | The name of the container (as it appears in a container definition) to associate with the load balancer | Yes | +| containerName | string | The full Amazon Resource Name (ARN) of the Elastic Load Balancing target group or groups associated with a service or task set. | Yes | +| containerPort | int | The port on the container to associate with the load balancer. | Yes | +| LoadBalancerName | string | The name of the load balancer to associate with the Amazon ECS service or task set. | No | + +Note: The available values are identical to those found in the aws-sdk-go-v2 Types.LoadBalancer. For more details, please refer to [this link](https://pkg.go.dev/github.com/aws/aws-sdk-go-v2/service/ecs/types#LoadBalancer) . + +## ECSQuickSync + +| Field | Type | Description | Required | +|-|-|-|-| +| recreate | bool | Whether to delete old tasksets before creating new ones or not. Default to false. | No | + +## AnalysisMetrics + +| Field | Type | Description | Required | +|-|-|-|-| +| provider | string | The unique name of provider defined in the Piped Configuration. | Yes | +| strategy | string | The strategy name. One of `THRESHOLD` or `PREVIOUS` or `CANARY_BASELINE` or `CANARY_PRIMARY` is available. Defaults to `THRESHOLD`. | No | +| query | string | A query performed against the [Analysis Provider](../../concepts/#analysis-provider). The stage will be skipped if no data points were returned. | Yes | +| expected | [AnalysisExpected](#analysisexpected) | The statically defined expected query result. This field is ignored if there was no data point as a result of the query. | Yes if the strategy is `THRESHOLD` | +| interval | duration | Run a query at specified intervals. | Yes | +| failureLimit | int | Acceptable number of failures. e.g. If 1 is set, the `ANALYSIS` stage will end with failure after two queries results failed. Defaults to 1. | No | +| skipOnNoData | bool | If true, it considers as a success when no data returned from the analysis provider. Defaults to false. | No | +| deviation | string | 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`. | No | +| baselineArgs | map[string][string] | The custom arguments to be populated for the Baseline query. They can be reffered as `{{ .VariantCustomArgs.xxx }}`. | No | +| canaryArgs | map[string][string] | The custom arguments to be populated for the Canary query. They can be reffered as `{{ .VariantCustomArgs.xxx }}`. | No | +| primaryArgs | map[string][string] | The custom arguments to be populated for the Primary query. They can be reffered as `{{ .VariantCustomArgs.xxx }}`. | No | +| timeout | duration | How long after which the query times out. | No | +| template | [AnalysisTemplateRef](#analysistemplateref) | Reference to the template to be used. | No | + + +### AnalysisExpected + +| Field | Type | Description | Required | +|-|-|-|-| +| min | float64 | Failure, if the query result is less than this value. | No | +| max | float64 | Failure, if the query result is larger than this value. | No | + +### AnalysisTemplateRef + +| Field | Type | Description | Required | +|-|-|-|-| +| name | string | The template name to refer. | Yes | +| appArgs | map[string]string | The arguments for custom-args. | No | + +## AnalysisLog + +| Field | Type | Description | Required | +|-|-|-|-| + +## AnalysisHttp + +| Field | Type | Description | Required | +|-|-|-|-| + +## SkipOptions + +| Field | Type | Description | Required | +|-|-|-|-| +| commitMessagePrefixes | []string | List of commit message's prefixes. The stage will be skipped when the prefix of the commit's message matches any of them. Empty means the stage will not be skipped by this condition. | No | +| paths | []string | List of paths to directories or files. When all commit changes match them, the stage will be skipped. Empty means the stage will not be skipped by this condition. Regular expression can be used. | No | + +## StageOptions + +### KubernetesPrimaryRolloutStageOptions + +| Field | Type | Description | Required | +|-|-|-|-| +| suffix | string | Suffix that should be used when naming the PRIMARY variant's resources. Default is `primary`. | No | +| createService | bool | Whether the PRIMARY service should be created. Default is `false`. | No | +| addVariantLabelToSelector | bool | Whether the PRIMARY variant label should be added to manifests if they were missing. Default is `false`. | No | +| prune | bool | Whether the resources that are no longer defined in Git should be removed or not. Default is `false` | No | + +### KubernetesCanaryRolloutStageOptions + +| Field | Type | Description | Required | +|-|-|-|-| +| replicas | int | How many pods for CANARY workloads. Default is `1` pod. Alternatively, can be specified a string suffixed by "%" to indicate a percentage value compared to the pod number of PRIMARY | No | +| suffix | string | Suffix that should be used when naming the CANARY variant's resources. Default is `canary`. | No | +| createService | bool | Whether the CANARY service should be created. Default is `false`. | No | +| patches | [][KubernetesResourcePatch](#kubernetesresourcepatch) | List of patches used to customize manifests for CANARY variant. | No | + +### KubernetesCanaryCleanStageOptions + +| Field | Type | Description | Required | +|-|-|-|-| +| | | | | + +### KubernetesBaselineRolloutStageOptions + +| Field | Type | Description | Required | +|-|-|-|-| +| replicas | int | How many pods for BASELINE workloads. Default is `1` pod. Alternatively, can be specified a string suffixed by "%" to indicate a percentage value compared to the pod number of PRIMARY | No | +| suffix | string | Suffix that should be used when naming the BASELINE variant's resources. Default is `baseline`. | No | +| createService | bool | Whether the BASELINE service should be created. Default is `false`. | No | + +### KubernetesBaselineCleanStageOptions + +| Field | Type | Description | Required | +|-|-|-|-| +| | | | | + +### KubernetesTrafficRoutingStageOptions +This stage routes traffic with the method specified in [KubernetesTrafficRouting](#kubernetestrafficrouting). +When using `podselector` method as a traffic routing method, routing is done by updating the Service selector. +Therefore, note that all traffic will be routed to the primary if the the primary variant's service is rolled out by running the `K8S_PRIMARY_ROLLOUT` stage. + +| Field | Type | Description | Required | +|-|-|-|-| +| all | string | Which variant should receive all traffic. Available values are "primary", "canary", "baseline". Default is `primary`. | No | +| primary | [Percentage](#percentage) | The percentage of traffic should be routed to PRIMARY variant. | No | +| canary | [Percentage](#percentage) | The percentage of traffic should be routed to CANARY variant. | No | +| baseline | [Percentage](#percentage) | The percentage of traffic should be routed to BASELINE variant. | No | + +### TerraformPlanStageOptions + +| Field | Type | Description | Required | +|-|-|-|-| +| exitOnNoChanges | bool | Whether exiting the pipeline when the result has no changes | No | + +### TerraformApplyStageOptions + +| Field | Type | Description | Required | +|-|-|-|-| +| retries | int | How many times to retry applying terraform changes. Default is `0`. | No | + +### CloudRunPromoteStageOptions + +| Field | Type | Description | Required | +|-|-|-|-| +| percent | [Percentage](#percentage) | Percentage of traffic should be routed to the new version. | No | + +### LambdaCanaryRolloutStageOptions + +| Field | Type | Description | Required | +|-|-|-|-| + +### LambdaPromoteStageOptions + +| Field | Type | Description | Required | +|-|-|-|-| +| percent | [Percentage](#percentage) | Percentage of traffic should be routed to the new version. | No | + +### ECSPrimaryRolloutStageOptions + +| Field | Type | Description | Required | +|-|-|-|-| + +### ECSCanaryRolloutStageOptions + +| Field | Type | Description | Required | +|-|-|-|-| +| scale | [Percentage](#percentage) | The percentage of workloads should be rolled out as CANARY variant's workload. | Yes | + +### ECSTrafficRoutingStageOptions + +| Field | Type | Description | Required | +|-|-|-|-| +| primary | [Percentage](#percentage) | The percentage of traffic should be routed to PRIMARY variant. | No | +| canary | [Percentage](#percentage) | The percentage of traffic should be routed to CANARY variant. | No | + +Note: By default, the sum of traffic is rounded to 100. If both `primary` and `canary` numbers are not set, the PRIMARY variant will receive 100% while the CANARY variant will receive 0% of the traffic. + +### AnalysisStageOptions + +| Field | Type | Description | Required | +|-|-|-|-| +| duration | duration | Maximum time to perform the analysis. | Yes | +| metrics | [][AnalysisMetrics](#analysismetrics) | Configuration for analysis by metrics. | No | +| skipOn | [SkipOptions](#skipoptions) | When to skip this stage. | No | + +### WaitStageOptions + +| Field | Type | Description | Required | +|-|-|-|-| +| duration | duration | Time to wait. | Yes | +| skipOn | [SkipOptions](#skipoptions) | When to skip this stage. | No | + +### WaitApprovalStageOptions + +| Field | Type | Description | Required | +|-|-|-|-| +| timeout | duration | The maximum length of time to wait before giving up. Default is 6h. | No | +| approvers | []string | List of username who has permission to approve. | Yes | +| minApproverNum | int | Number of minimum needed approvals to make this stage complete. Default is 1. | No | +| skipOn | [SkipOptions](#skipoptions) | When to skip this stage. | No | + +### CustomSyncStageOptions (deprecated) +| Field | Type | Description | Required | +|-|-|-|-| +| timeout | duration | The maximum time the stage can be taken to run. Default is `6h`| No | +| envs | map[string]string | Environment variables used with scripts. | No | +| run | string | Script run on this stage. | Yes | + +### ScriptRunStageOptions +| Field | Type | Description | Required | +|-|-|-|-| +| run | string | Script run on this stage. | Yes | +| env | map[string]string | Environment variables used with scripts. | No | +| timeout | duration | The maximum time the stage can be taken to run. Default is `6h`| No | +| skipOn | [SkipOptions](#skipoptions) | When to skip this stage. | No | + +## PostSync + +| Field | Type | Description | Required | +|-|-|-|-| +| chain | [DeploymentChain](#deploymentchain) | Deployment chain configuration, used to determine and build deployments that should be triggered once the current deployment is triggered. | No | + +### DeploymentChain + +| Field | Type | Description | Required | +|-|-|-|-| +| applications | [][DeploymentChainApplication](#deploymentchainapplication) | The list of applications which should be triggered once deployment of this application rolled out successfully. | Yes | + +#### DeploymentChainApplication + +| Field | Type | Description | Required | +|-|-|-|-| +| name | string | The name of PipeCD application, note that application name is not unique in PipeCD datastore | No | +| kind | string | The kind of the PipeCD application, which should be triggered as a node in deployment chain. The value will be one of: KUBERNETES, TERRAFORM, CLOUDRUN, LAMBDA, ECS. | No | + +## EventWatcher + +| Field | Type | Description | Required | +|-|-|-|-| +| matcher | [EventWatcherMatcher](#eventwatchermatcher) | Which event will be handled. | Yes | +| handler | [EventWatcherHandler](#eventwatcherhandler) | What to do for the event which matched by the above matcher. | Yes | + +### EventWatcherMatcher + +| Field | Type | Description | Required | +|-|-|-|-| +| name | string | The event name. | Yes | +| labels | map[string]string | Additional attributes of event. This can make an event definition unique even if the one with the same name exists. | No | + +### EventWatcherHandler + +| Field | Type | Description | Required | +|-|-|-|-| +| type | string | The handler type. Currently, only `GIT_UPDATE` is supported. | Yes | +| config | [EventWatcherHandlerConfig](#eventwatcherhandlerconfig) | Configuration for the event watcher handler. | Yes | + +### EventWatcherHandlerConfig + +| Field | Type | Description | Required | +|-|-|-|-| +| commitMessage | string | The commit message used to push after replacing values. Default message is used if not given. | No | +| makePullRequest | bool | Whether to create a new branch or not when commit changes in event watcher. Default is `false`. | No | +| replacements | [][EventWatcherReplacement](#eventwatcherreplacement) | List of places where will be replaced when the new event matches. | Yes | + +## DriftDetection + +| Field | Type | Description | Required | +|-|-|-|-| +| ignoreFields | []string | List of fields path in manifests, which its diff should be ignored. This is available for only `KubernetesApp`. | No | + +## PipeCD rich defined types + +### Percentage +A wrapper of type `int` to represent percentage data. Basically, you can pass `10` or `"10"` or `10%` and they will be treated as `10%` in PipeCD. + +### KubernetesResourcePatch + +| Field | Type | Description | Required | +|-|-|-|-| +| target | [KubernetesResourcePatchTarget](#kubernetesresourcepatchtarget) | Which manifest, which field will be the target of patch operations. | Yes | +| ops | [][KubernetesResourcePatchOp](#kubernetesresourcepatchop) | List of operations should be applied to the above target. | No | + +### KubernetesResourcePatchTarget + +| Field | Type | Description | Required | +|-|-|-|-| +| kind | string | The resource kind. e.g. `ConfigMap` | Yes | +| name | string | The resource name. e.g. `config-map-name` | Yes | +| documentRoot | string | In case you want to manipulate the YAML or JSON data specified in a field of the manfiest, 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. e.g. `$.data.envoy-config` | No | + +### KubernetesResourcePatchOp + +| Field | Type | Description | Required | +|-|-|-|-| +| op | string | The operation type. This must be one of `yaml-replace`, `yaml-add`, `yaml-remove`, `json-replace`, `text-regex`. Default is `yaml-replace`. | No | +| path | string | The path string pointing to the manipulated field. For yaml operations it looks like `$.foo.array[0].bar`. | No | +| value | string | The value string whose content will be used as new value for the field. | No | diff --git a/docs/content/en/docs-v0.49.x/user-guide/event-watcher.md b/docs/content/en/docs-v0.49.x/user-guide/event-watcher.md new file mode 100644 index 0000000000..b24c63e67a --- /dev/null +++ b/docs/content/en/docs-v0.49.x/user-guide/event-watcher.md @@ -0,0 +1,233 @@ +--- +title: "Connect between CI and CD with event watcher" +linkTitle: "Event watcher" +weight: 5 +description: > + A helper facility to automatically update files when it finds out a new event. +--- + +![](/images/diff-by-eventwatcher.png) + +The only way to upgrade your application with PipeCD is modifying configuration files managed by the Git repositories. +It brings benefits quite a bit, but it can be painful to manually update them every time in some cases (e.g. continuous deployment to your development environment for debugging, the latest prerelease to the staging environment). + +If you're experiencing any of the above pains, Event watcher is for you. +Event watcher works as a helper facility to seamlessly link CI and CD. This feature lets you automatically update files managed by your Piped when an arbitrary event has occurred. +While it empowers you to build pretty versatile workflows, the canonical use case is that you trigger a new deployment by image updates, package releases, etc. + +This guide walks you through configuring Event watcher and how to push an Event. + +## Prerequisites +Before we get into configuring EventWatcher, be sure to configure Piped. See [here](../managing-piped/configuring-event-watcher/) for more details. + +## Usage +File updating can be done by registering the latest value corresponding to the Event in the Control Plane and comparing it with the current value. + +Therefore, you mainly need to: +1. define which values in which files should be updated when a new Event found. +1. integrate a step to push an Event to the Control Plane using `pipectl` into your CI workflow. + +### 1. Defining Events +#### Use the `.pipe/` directory +>NOTE: This way is deprecated and will be removed in the future, so please use the application configuration. + +Prepare EventWatcher configuration files under the `.pipe/` directory at the root of your Git repository. +In that files, you define which values in which files should be updated when the Piped found out a new Event. + +For instance, suppose you want to update the Kubernetes manifest defined in `helloworld/deployment.yaml` when an Event with the name `helloworld-image-update` occurs: + +```yaml +apiVersion: pipecd.dev/v1beta1 +kind: EventWatcher +spec: + events: + - name: helloworld-image-update + replacements: + - file: helloworld/deployment.yaml + yamlField: $.spec.template.spec.containers[0].image +``` + +The full list of configurable `EventWatcher` fields are [here](../configuration-reference/#event-watcher-configuration-deprecated). + +#### Use the application configuration + +Define what to do for which event in the application configuration file of the target application. + +- `matcher`: Which event should be handled. +- `handler`: What to do for the event which is specified by matcher. + +For instance, suppose you want to update the Kubernetes manifest defined in `helloworld/deployment.yaml` when an Event with the name `helloworld-image-update` occurs: +```yaml +apiVersion: pipecd.dev/v1beta1 +kind: KubernetesApp +spec: + name: helloworld + eventWatcher: + - matcher: + name: helloworld-image-update + handler: + type: GIT_UPDATE + config: + replacements: + - file: deployment.yaml + yamlField: $.spec.template.spec.containers[0].image +``` + +The full list of configurable `eventWatcher` fields are [here](../configuration-reference/#eventwatcher). + +### 2. Pushing an Event with `pipectl` +To register a new value corresponding to Event such as the above in the Control Plane, you need to perform `pipectl`. +And we highly recommend integrating a step for that into your CI workflow. + +You first need to set-up the `pipectl`: + +- Install it on your CI system or where you want to run according to [this guide](../command-line-tool/#installation). +- Grab the API key to which the `READ_WRITE` role is attached according to [this guide](../command-line-tool/#authentication). + +Once you're all set up, pushing a new Event to the Control Plane by the following command: + +```bash +pipectl event register \ + --address={CONTROL_PLANE_API_ADDRESS} \ + --api-key={API_KEY} \ + --name=helloworld-image-update \ + --data=gcr.io/pipecd/helloworld:v0.2.0 +``` + +You can see the status on the event list page. + +![](/images/event-list-page.png) + + +After a while, Piped will create a commit as shown below: + +```diff + spec: + containers: + - name: helloworld +- image: gcr.io/pipecd/helloworld:v0.1.0 ++ image: gcr.io/pipecd/helloworld:v0.2.0 +``` + +NOTE: Keep in mind that it may take a little while because Piped periodically fetches the new events from the Control Plane. You can change its interval according to [here](../managing-piped/configuration-reference/#eventwatcher). + +### [optional] Using labels +Event watcher is a project-wide feature, hence an event name is unique inside a project. That is, you can update multiple repositories at the same time if you use the same event name for different events. + +On the contrary, if you want to explicitly distinguish those, we recommend using labels. You can make an event definition unique by using any number of labels with arbitrary keys and values. +Suppose you define an event with the labels `env: dev` and `appName: helloworld`: + +When you use the `.pipe/` directory, you can configure like below. +```yaml +apiVersion: pipecd.dev/v1beta1 +kind: EventWatcher +spec: + events: + - name: image-update + labels: + env: dev + appName: helloworld + replacements: + - file: helloworld/deployment.yaml + yamlField: $.spec.template.spec.containers[0].image +``` + +The other example is like below. +```yaml +apiVersion: pipecd.dev/v1beta1 +kind: ApplicationKind +spec: + name: helloworld + eventWatcher: + - matcher: + name: image-update + labels: + env: dev + appName: helloworld + handler: + type: GIT_UPDATE + config: + replacements: + - file: deployment.yaml + yamlField: $.spec.template.spec.containers[0].image +``` + +The file update will be executed only when the labels are explicitly specified with the `--labels` flag. + +```bash +pipectl event register \ + --address=CONTROL_PLANE_API_ADDRESS \ + --api-key=API_KEY \ + --name=image-update \ + --labels env=dev,appName=helloworld \ + --data=gcr.io/pipecd/helloworld:v0.2.0 +``` + +Note that it is considered a match only when labels are an exact match. + +## Examples +Suppose you want to update your configuration file after releasing a new Helm chart. + +You define the configuration for event watcher in `helloworld/app.pipecd.yaml` file like: + +```yaml +apiVersion: pipecd.dev/v1beta1 +kind: KubernetesApp +spec: + input: + helmChart: + name: helloworld + version: 0.1.0 + eventWatcher: + - matcher: + name: image-update + labels: + env: dev + appName: helloworld + handler: + type: GIT_UPDATE + config: + replacements: + - file: app.pipecd.yaml + yamlField: $.spec.input.helmChart.version +``` + +Push a new version `0.2.0` as data when the Helm release is completed. + +```bash +pipectl event register \ + --address=CONTROL_PLANE_API_ADDRESS \ + --api-key=API_KEY \ + --name=helm-release \ + --labels env=dev,appName=helloworld \ + --data=0.2.0 +``` + +Then you'll see that Piped updates as: + +```diff +apiVersion: pipecd.dev/v1beta1 +kind: KubernetesApp +spec: + input: + helmChart: + name: helloworld +- version: 0.1.0 ++ version: 0.2.0 + eventWatcher: + - matcher: + name: image-update + labels: + env: dev + appName: helloworld + handler: + type: GIT_UPDATE + config: + replacements: + - file: app.pipecd.yaml + yamlField: $.spec.input.helmChart.version +``` + +## Github Actions +If you're using Github Actions in your CI workflow, [actions-event-register](https://github.com/marketplace/actions/pipecd-register-event) is for you! +With it, you can easily register events without any installation. diff --git a/docs/content/en/docs-v0.49.x/user-guide/examples/_index.md b/docs/content/en/docs-v0.49.x/user-guide/examples/_index.md new file mode 100755 index 0000000000..aa65fb850e --- /dev/null +++ b/docs/content/en/docs-v0.49.x/user-guide/examples/_index.md @@ -0,0 +1,11 @@ +--- +title: "Examples" +linkTitle: "Examples" +weight: 12 +description: > + Some examples of PipeCD in action! +--- + +One of the best ways to see what PipeCD can do, and learn how to deploy your applications with it, is to see some real examples. + +We have prepared some examples for each kind of application, please visit the [PipeCD examples](../../examples/) page for details. diff --git a/docs/content/en/docs-v0.49.x/user-guide/examples/k8s-app-bluegreen-with-istio.md b/docs/content/en/docs-v0.49.x/user-guide/examples/k8s-app-bluegreen-with-istio.md new file mode 100644 index 0000000000..7544f8ca79 --- /dev/null +++ b/docs/content/en/docs-v0.49.x/user-guide/examples/k8s-app-bluegreen-with-istio.md @@ -0,0 +1,126 @@ +--- +title: "BlueGreen deployment for Kubernetes app with Istio" +linkTitle: "BlueGreen k8s app with Istio" +weight: 2 +description: > + How to enable blue-green deployment for Kubernetes application with Istio. +--- + +Similar to [canary deployment](../k8s-app-canary-with-istio/), PipeCD allows you to enable and automate the blue-green deployment strategy for your application based on Istio's weighted routing feature. + +In both canary and blue-green strategies, the old version and the new version of the application get deployed at the same time. +But while the canary strategy slowly routes the traffic to the new version, the blue-green strategy quickly routes all traffic to one of the versions. + +In this guide, we will show you how to configure the application configuration file to apply the blue-green strategy. + +Complete source code for this example is hosted in [pipe-cd/examples](https://github.com/pipe-cd/examples/tree/master/kubernetes/mesh-istio-bluegreen) repository. + +## Before you begin + +- Add a new Kubernetes application by following the instructions in [this guide](../../managing-application/adding-an-application/) +- Ensure having `pipecd.dev/variant: primary` [label](https://github.com/pipe-cd/examples/blob/master/kubernetes/mesh-istio-bluegreen/deployment.yaml#L17) and [selector](https://github.com/pipe-cd/examples/blob/master/kubernetes/mesh-istio-bluegreen/deployment.yaml#L12) in the workload template +- Ensure having at least one Istio's `DestinationRule` and defining the needed subsets (`primary` and `canary`) with `pipecd.dev/variant` label + +``` yaml +apiVersion: networking.istio.io/v1beta1 +kind: DestinationRule +metadata: + name: mesh-istio-bluegreen +spec: + host: mesh-istio-bluegreen + subsets: + - name: primary + labels: + pipecd.dev/variant: primary + - name: canary + labels: + pipecd.dev/variant: canary + trafficPolicy: + tls: + mode: ISTIO_MUTUAL +``` + +- Ensure having at least one Istio's `VirtualService` manifest and all traffic is routed to the `primary` + +``` yaml +apiVersion: networking.istio.io/v1beta1 +kind: VirtualService +metadata: + name: mesh-istio-bluegreen +spec: + hosts: + - mesh-istio-bluegreen.pipecd.dev + gateways: + - mesh-istio-bluegreen + http: + - route: + - destination: + host: mesh-istio-bluegreen + subset: primary + weight: 100 +``` + +## Enabling blue-green strategy + +- Add the following application configuration file into the application directory in the Git repository. + +``` yaml +apiVersion: pipecd.dev/v1beta1 +kind: KubernetesApp +spec: + pipeline: + stages: + - name: K8S_CANARY_ROLLOUT + with: + replicas: 100% + - name: K8S_TRAFFIC_ROUTING + with: + all: canary + - name: WAIT_APPROVAL + - name: K8S_PRIMARY_ROLLOUT + - name: K8S_TRAFFIC_ROUTING + with: + all: primary + - name: K8S_CANARY_CLEAN + trafficRouting: + method: istio + istio: + host: mesh-istio-bluegreen +``` + +- Send a PR to update the container image version in the Deployment manifest and merge it to trigger a new deployment. PipeCD will plan the deployment with the specified blue-green strategy. + +![](/images/example-bluegreen-kubernetes-istio.png) +

+Deployment Details Page +

+ +- Now you have an automated blue-green deployment for your application. 🎉 + +## Understanding what happened + +In this example, you configured the application configuration file to switch all traffic from an old to a new version of the application using Istio's weighted routing feature. + +- Stage 1: `K8S_CANARY_ROLLOUT` ensures that the workloads of canary variant (new version) should be deployed. But at this time, they still handle nothing, all traffic is handled by workloads of primary variant. +The number of workloads (e.g. pod) for canary variant is configured to be 100% of the replicas number of primary varant. + +![](/images/example-bluegreen-kubernetes-istio-stage-1.png) + +- Stage 2: `K8S_TRAFFIC_ROUTING` ensures that all traffic should be routed to canary variant. Because the `trafficRouting` is configured to use Istio, PipeCD will find Istio's VirtualService resource of this application to control the traffic percentage. +(You can add an [ANALYSIS](../../managing-application/customizing-deployment/automated-deployment-analysis/) stage after this to validate the new version. When any negative impacts are detected, an auto-rollback stage will be executed to switch all traffic back to the primary variant.) + +![](/images/example-bluegreen-kubernetes-istio-stage-2.png) + +- Stage 3: `WAIT_APPROVAL` waits for a manual approval from someone in your team. + +- Stage 4: `K8S_PRIMARY_ROLLOUT` ensures that all resources of primary variant will be updated to the new version. + +![](/images/example-bluegreen-kubernetes-istio-stage-4.png) + +- Stage 5: `K8S_TRAFFIC_ROUTING` ensures that all traffic should be routed to primary variant. Now primary variant is running the new version so it means all traffic is handled by the new version. + +![](/images/example-bluegreen-kubernetes-istio-stage-5.png) + +- Stage 6: `K8S_CANARY_CLEAN` ensures all created resources for canary variant should be destroyed. + +![](/images/example-bluegreen-kubernetes-istio-stage-6.png) diff --git a/docs/content/en/docs-v0.49.x/user-guide/examples/k8s-app-bluegreen-with-pod-selector.md b/docs/content/en/docs-v0.49.x/user-guide/examples/k8s-app-bluegreen-with-pod-selector.md new file mode 100644 index 0000000000..c303b64cbe --- /dev/null +++ b/docs/content/en/docs-v0.49.x/user-guide/examples/k8s-app-bluegreen-with-pod-selector.md @@ -0,0 +1,11 @@ +--- +title: "BlueGreen deployment for Kubernetes app with PodSelector" +linkTitle: "BlueGreen k8s app with PodSelector" +weight: 4 +description: > + How to enable blue-green deployment for Kubernetes application with PodSelector. +--- + +> TBA + +For applications that are not deployed on a service mesh, PipeCD can enable blue-green deployment with Kubernetes L4 networking. diff --git a/docs/content/en/docs-v0.49.x/user-guide/examples/k8s-app-canary-with-istio.md b/docs/content/en/docs-v0.49.x/user-guide/examples/k8s-app-canary-with-istio.md new file mode 100644 index 0000000000..286b361ded --- /dev/null +++ b/docs/content/en/docs-v0.49.x/user-guide/examples/k8s-app-canary-with-istio.md @@ -0,0 +1,124 @@ +--- +title: "Canary deployment for Kubernetes app with Istio" +linkTitle: "Canary k8s app with Istio" +weight: 1 +description: > + How to enable canary deployment for Kubernetes application with Istio. +--- + +> Canary release is a technique to reduce the risk of introducing a new software version in production by slowly rolling out the change to a small subset of users before rolling it out to the entire infrastructure and making it available to everybody. +> -- [martinfowler.com/canaryrelease](https://martinfowler.com/bliki/CanaryRelease.html) + +With Istio, we can accomplish this goal by configuring a sequence of rules that route a percentage of traffic to each [variant](../../managing-application/defining-app-configuration/kubernetes/#sync-with-the-specified-pipeline) of the application. +And with PipeCD, you can enable and automate the canary strategy for your Kubernetes application even easier. + +In this guide, we will show you how to configure the application configuration file to send 10% of traffic to the new version and keep 90% to the primary variant. Then after waiting for manual approval, you will complete the migration by sending 100% of traffic to the new version. + +Complete source code for this example is hosted in [pipe-cd/examples](https://github.com/pipe-cd/examples/tree/master/kubernetes/mesh-istio-canary) repository. + +## Before you begin + +- Add a new Kubernetes application by following the instructions in [this guide](../../managing-application/adding-an-application/) +- Ensure having `pipecd.dev/variant: primary` [label](https://github.com/pipe-cd/examples/blob/master/kubernetes/mesh-istio-canary/deployment.yaml#L17) and [selector](https://github.com/pipe-cd/examples/blob/master/kubernetes/mesh-istio-canary/deployment.yaml#L12) in the workload template +- Ensure having at least one Istio's `DestinationRule` and defining the needed subsets (`primary` and `canary`) with `pipecd.dev/variant` label + +``` yaml +apiVersion: networking.istio.io/v1beta1 +kind: DestinationRule +metadata: + name: mesh-istio-canary +spec: + host: mesh-istio-canary.default.svc.cluster.local + subsets: + - name: primary + labels: + pipecd.dev/variant: primary + - name: canary + labels: + pipecd.dev/variant: canary +``` + +- Ensure having at least one Istio's `VirtualService` manifest and all traffic is routed to the `primary` + +``` yaml +apiVersion: networking.istio.io/v1beta1 +kind: VirtualService +metadata: + name: mesh-istio-canary +spec: + hosts: + - mesh-istio-canary.pipecd.dev + gateways: + - mesh-istio-canary + http: + - route: + - destination: + host: mesh-istio-canary.default.svc.cluster.local + subset: primary + weight: 100 +``` + +## Enabling canary strategy + +- Add the following application configuration file into the application directory in Git. + +``` yaml +apiVersion: pipecd.dev/v1beta1 +kind: KubernetesApp +spec: + pipeline: + stages: + - name: K8S_CANARY_ROLLOUT + with: + replicas: 50% + - name: K8S_TRAFFIC_ROUTING + with: + canary: 10 + primary: 90 + - name: WAIT_APPROVAL + - name: K8S_PRIMARY_ROLLOUT + - name: K8S_TRAFFIC_ROUTING + with: + primary: 100 + - name: K8S_CANARY_CLEAN + trafficRouting: + method: istio + istio: + host: mesh-istio-canary.default.svc.cluster.local +``` + +- Send a PR to update the container image version in the Deployment manifest and merge it to trigger a new deployment. PipeCD will plan the deployment with the specified canary strategy. + +![](/images/example-canary-kubernetes-istio.png) +

+Deployment Details Page +

+ +- Now you have an automated canary deployment for your application. 🎉 + +## Understanding what happened + +In this example, you configured the application configuration file to migrate traffic from an old to a new version of the application using Istio's weighted routing feature. + +- Stage 1: `K8S_CANARY_ROLLOUT` ensures that the workloads of canary variant (new version) should be deployed. But at this time, they still handle nothing, all traffic are handled by workloads of primary variant. +The number of workloads (e.g. pod) for canary variant is configured to be 50% of the replicas number of primary varant. + +![](/images/example-canary-kubernetes-istio-stage-1.png) + +- Stage 2: `K8S_TRAFFIC_ROUTING` ensures that 10% of traffic should be routed to canary variant and 90% to primary variant. Because the `trafficRouting` is configured to use Istio, PipeCD will find Istio's VirtualService resource of this application to control the traffic percentage. + +![](/images/example-canary-kubernetes-istio-stage-2.png) + +- Stage 3: `WAIT_APPROVAL` waits for a manual approval from someone in your team. + +- Stage 4: `K8S_PRIMARY_ROLLOUT` ensures that all resources of primary variant will be updated to the new version. + +![](/images/example-canary-kubernetes-istio-stage-4.png) + +- Stage 5: `K8S_TRAFFIC_ROUTING` ensures that all traffic should be routed to primary variant. Now primary variant is running the new version so it means all traffic is handled by the new version. + +![](/images/example-canary-kubernetes-istio-stage-5.png) + +- Stage 6: `K8S_CANARY_CLEAN` ensures all created resources for canary variant should be destroyed. + +![](/images/example-canary-kubernetes-istio-stage-6.png) diff --git a/docs/content/en/docs-v0.49.x/user-guide/examples/k8s-app-canary-with-pod-selector.md b/docs/content/en/docs-v0.49.x/user-guide/examples/k8s-app-canary-with-pod-selector.md new file mode 100644 index 0000000000..5993bc101e --- /dev/null +++ b/docs/content/en/docs-v0.49.x/user-guide/examples/k8s-app-canary-with-pod-selector.md @@ -0,0 +1,122 @@ +--- +title: "Canary deployment for Kubernetes app with PodSelector" +linkTitle: "Canary k8s app with PodSelector" +weight: 3 +description: > + How to enable canary deployment for Kubernetes application with PodSelector. +--- + +Using service mesh like [Istio](../k8s-app-canary-with-istio/) helps you doing canary deployment easier with many powerful features, but not all teams are ready to use service mesh in their environment. This page will walk you through using PipeCD to enable canary deployment for Kubernetes application running in a non-mesh environment. + +Basically, the idea behind is described as this [Kubernetes document](https://kubernetes.io/docs/concepts/cluster-administration/manage-deployment/#canary-deployments); the Service resource uses the common label set to route the traffic to both canary and primary workloads, and percentage of traffic for each variant is based on their replicas number. + +## Enabling canary strategy + +Assume your application has the following `Service` and `Deployment` manifests: + +- service.yaml + +``` yaml +apiVersion: v1 +kind: Service +metadata: + name: helloworld +spec: + selector: + app: helloworld + ports: + - protocol: TCP + port: 9085 +``` + +- deployment.yaml + +``` yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: helloworld + labels: + app: helloworld + pipecd.dev/variant: primary +spec: + replicas: 30 + revisionHistoryLimit: 2 + selector: + matchLabels: + app: helloworld + pipecd.dev/variant: primary + template: + metadata: + labels: + app: helloworld + pipecd.dev/variant: primary + spec: + containers: + - name: helloworld + image: gcr.io/pipecd/helloworld:v0.1.0 + args: + - server + ports: + - containerPort: 9085 +``` + +In PipeCD context, manifests defined in Git are the manifests for primary variant, so please note to ensure that your deployment manifest contains `pipecd.dev/variant: primary` label and selector in the spec. + +To enable canary strategy for this Kubernetes application, you will update your application configuration file to be as below: + +``` yaml +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 50% of the replicas number of PRIMARY variant. + - name: K8S_CANARY_ROLLOUT + with: + replicas: 50% + - name: WAIT_APPROVAL + with: + duration: 10s + # Update the workload of PRIMARY variant to the new version. + - name: K8S_PRIMARY_ROLLOUT + # Destroy all workloads of CANARY variant. + - name: K8S_CANARY_CLEAN +``` + +That is all, now let try to send a PR to update the container image version in the Deployment manifest and merge it to trigger a new deployment. Then, PipeCD will plan the deployment with the specified canary strategy. + +![](/images/example-canary-kubernetes.png) +

+Deployment Details Page +

+ +Complete source code for this example is hosted in [pipe-cd/examples](https://github.com/pipe-cd/examples/tree/master/kubernetes/canary) repository. + +## Understanding what happened + +In this example, you configured your application to be deployed with a canary strategy using a native feature of Kubernetes: pod selector. +The traffic will be routed to both canary and primary workloads because they are sharing the same label: `app: helloworld`. +The percentage of traffic for each variant is based on the respective number of pods. + +Here are what happened in details: + +- Before deploying, all traffic gets routed to primary workloads. + + + +- Stage 1: `K8S_CANARY_ROLLOUT` ensures that the workloads of canary variant (new version) should be deployed. +The number of workloads (e.g. pod) for canary variant is configured to be 50% of the replicas number of primary variant. It means 15 canary pods will be started, and they receive 33.3% traffic while primary workloads receive the remaining 66.7% traffic. + + + +- Stage 2: `WAIT_APPROVAL` waits for a manual approval from someone in your team. + +- Stage 3: `K8S_PRIMARY_ROLLOUT` ensures that all resources of primary variant will be updated to the new version. + + + +- Stage 4: `K8S_CANARY_CLEAN` ensures all created resources for canary variant should be destroyed. After that, the primary workloads running in with the new version will receive all traffic. + + diff --git a/docs/content/en/docs-v0.49.x/user-guide/insights.md b/docs/content/en/docs-v0.49.x/user-guide/insights.md new file mode 100644 index 0000000000..ed77d21ee3 --- /dev/null +++ b/docs/content/en/docs-v0.49.x/user-guide/insights.md @@ -0,0 +1,35 @@ +--- +title: "Insights" +linkTitle: "Insights" +weight: 7 +description: > + This page describes how to see delivery performance. +--- + +![](/images/insights.png) + +### Application metrics + +The topmost block helps you understand how many applications your project has. + +### Deployment metrics + +Based on your executed deployment data, PipeCD provides charts that help you better understand the delivery performance of your organization. + +You can view daily, and monthly data visualizations of your entire project, a specific application, or a group of applications that match a list of labels. + +#### Deployment Frequency +How often does your application/project deploy code to production. + +#### Change Failure Rate +How often deployment failures occur in production that requires an immediate remedy (fix, rollback...). + +#### Lead Time for Changes +How long does it take to go from code committed to code successfully running on production. + +> WIP + +#### Mean Time To Restore +How long does it generally take to restore service when a service incident occurs. + +> WIP diff --git a/docs/content/en/docs-v0.49.x/user-guide/managing-application/_index.md b/docs/content/en/docs-v0.49.x/user-guide/managing-application/_index.md new file mode 100644 index 0000000000..99468227f5 --- /dev/null +++ b/docs/content/en/docs-v0.49.x/user-guide/managing-application/_index.md @@ -0,0 +1,9 @@ +--- +title: "Managing application" +linkTitle: "Managing application" +weight: 2 +description: > + This guide is for developers who have PipeCD installed for them and are using PipeCD to deploy their applications. +--- + +> Note: You must have at least one activated/running Piped to enable using any of the following features of PipeCD. Please refer to [Piped installation docs](../../installation/install-piped/) if you do not have any Piped in your pocket. diff --git a/docs/content/en/docs-v0.49.x/user-guide/managing-application/adding-an-application.md b/docs/content/en/docs-v0.49.x/user-guide/managing-application/adding-an-application.md new file mode 100644 index 0000000000..822b446c99 --- /dev/null +++ b/docs/content/en/docs-v0.49.x/user-guide/managing-application/adding-an-application.md @@ -0,0 +1,140 @@ +--- +title: "Adding an application" +linkTitle: "Adding an application" +weight: 1 +description: > + This page describes how to add a new application. +--- + +An application is a collection of resources and configurations that are managed together. +It represents the service which you are going to deploy. With PipeCD, all application's manifests and its application configuration (`app.pipecd.yaml`) must be committed into a directory of a Git repository. That directory is called as application directory. + +Each application can be handled by one and only one `piped`. Currently, PipeCD is supporting 5 kinds of application: Kubernetes, Terraform, CloudRun, Lambda, ECS. + +Before deploying an application, it must be registered to help PipeCD knows +- where the application configuration is placed +- which `piped` should handle it and which platform the application should be deployed to + +Through the web console, you can register a new application in one of the following ways: +- Picking from a list of unused apps suggested by Pipeds while scanning Git repositories (Recommended) +- Manually configuring application information + +(If you prefer to use [`pipectl`](../../command-line-tool/#adding-a-new-application) command-line tool, see its usage for the details.) + +## Picking from a list of unused apps suggested by Pipeds + +You have to __prepare a configuration file__ which contains your application configuration and store that file in the Git repository which your Piped is watching first to enable adding a new application this way. + +The application configuration file name must be suffixed by `.pipecd.yaml` because Piped periodically checks for files with this suffix. + +{{< tabpane >}} +{{< tab lang="yaml" header="KubernetesApp" >}} +# For application's configuration in detail for KubernetesApp, please visit +# https://pipecd.dev/docs/user-guide/managing-application/defining-app-configuration/kubernetes/ + +apiVersion: pipecd.dev/v1beta1 +kind: KubernetesApp +spec: + name: foo + labels: + team: bar +{{< /tab >}} +{{< tab lang="yaml" header="TerraformApp" >}} +# For application's configuration in detail for TerraformApp, please visit +# https://pipecd.dev/docs/user-guide/managing-application/defining-app-configuration/terraform/ + +apiVersion: pipecd.dev/v1beta1 +kind: TerraformApp +spec: + name: foo + labels: + team: bar +{{< /tab >}} +{{< tab lang="yaml" header="LambdaApp" >}} +# For application's configuration in detail for LambdaApp, please visit +# https://pipecd.dev/docs/user-guide/managing-application/defining-app-configuration/lambda/ + +apiVersion: pipecd.dev/v1beta1 +kind: LambdaApp +spec: + name: foo + labels: + team: bar +{{< /tab >}} +{{< tab lang="yaml" header="CloudRunApp" >}} +# For application's configuration in detail for CloudRunApp, please visit +# https://pipecd.dev/docs/user-guide/managing-application/defining-app-configuration/cloudrun/ + +apiVersion: pipecd.dev/v1beta1 +kind: CloudRunApp +spec: + name: foo + labels: + team: bar +{{< /tab >}} +{{< tab lang="yaml" header="ECSApp" >}} +# For application's configuration in detail for ECSApp, please visit +# https://pipecd.dev/docs/user-guide/managing-application/defining-app-configuration/ecs/ + +apiVersion: pipecd.dev/v1beta1 +kind: ECSApp +spec: + name: foo + labels: + team: bar +{{< /tab >}} +{{< /tabpane >}} + +To define your application deployment pipeline which contains the guideline to show Piped how to deploy your application, please visit [Defining app configuration](../defining-app-configuration/). + +Go to the PipeCD web console on application list page, click the `+ADD` button at the top left corner of the application list page and then go to the `ADD FROM GIT` tab. + +Select the Piped and Platform Provider that you deploy to, once the Piped that's watching your Git repository catches the new unregistered application configuration file, it will be listed up in this panel. Click `ADD` to complete the registration. + +![](/images/registering-an-application-from-suggestions-new.png) +

+

+ +## Manually configuring application information + +This way, you can postpone the preparation for your application's configuration after submitting all the necessary information about your app on the web console. + +By clicking on `+ADD` button at the application list page, a popup will be revealed from the right side as below: + +![](/images/registering-an-application-manually-new.png) +

+

+ +After filling all the required fields, click `Save` button to complete the application registering. + +Here are the list of fields in the register form: + +| Field | Description | Required | +|-|-|-|-| +| Name | The application name | Yes | +| Kind | The application kind. Select one of these values: `Kubernetes`, `Terraform`, `CloudRun`, `Lambda` and `ECS`. | Yes | +| Piped | The piped that handles this application. Select one of the registered `piped`s at `Settings/Piped` page. | Yes | +| Repository | The Git repository contains application configuration and application configuration. Select one of the registered repositories in `piped` configuration. | Yes | +| Path | The relative path from the root of the Git repository to the directory containing application configuration and application configuration. Use `./` means repository root. | Yes | +| Config Filename | The name of application configuration file. Default is `app.pipecd.yaml`. | No | +| Platform Provider | Where the application will be deployed to. Select one of the registered cloud/platform providers in `piped` configuration. This field name previously was `Cloud Provider`. | Yes | + +> Note: Labels couldn't be set via this form. If you want, try the way to register via the application configuration defined in the Git repository. + +After submitting the form, one more step left is adding the application configuration file for that application into the application directory in Git repository same as we prepared in [the above method](../adding-an-application/#picking-from-a-list-of-unused-apps-suggested-by-pipeds). + +Please refer [Define your app's configuration](../defining-app-configuration/) or [pipecd/examples](../../examples/) for the examples of being supported application kind. + +## Updating an application +Regardless of which method you used to register the application, the web console can only be used to disable/enable/delete the application, besides the adding operation. All updates on application information must be done via the application configuration file stored in Git as a single source of truth. + +```yaml +apiVersion: pipecd.dev/v1beta1 +kind: AppKind +spec: + name: new-name + labels: + team: new-team +``` + +Refer to [configuration reference](../../configuration-reference/) to see the full list of configurable fields. diff --git a/docs/content/en/docs-v0.49.x/user-guide/managing-application/application-live-state.md b/docs/content/en/docs-v0.49.x/user-guide/managing-application/application-live-state.md new file mode 100644 index 0000000000..6cab5cd950 --- /dev/null +++ b/docs/content/en/docs-v0.49.x/user-guide/managing-application/application-live-state.md @@ -0,0 +1,18 @@ +--- +title: "Application live state" +linkTitle: "Application live state" +weight: 7 +description: > + The live states of application components as well as their health status. +--- + +By default, `piped` continuously monitors the running resources/components of all deployed applications to determine the state of them and then send those results to the control plane. The application state will be visualized and rendered at the application details page in realtime. That helps developers can see what is running in the cluster as well as their health status. The application state includes: +- visual graph of application resources/components. Each resource/component node includes its metadata and health status. +- health status of the whole application. Application health status is `HEALTHY` if and only if the health statuses of all of its resources/components are `HEALTHY`. + +![](/images/application-details.png) +

+Application Details Page +

+ +By clicking on the resource/component node, a popup will be revealed from the right side to show more details about that resource/component. diff --git a/docs/content/en/docs-v0.49.x/user-guide/managing-application/cancelling-a-deployment.md b/docs/content/en/docs-v0.49.x/user-guide/managing-application/cancelling-a-deployment.md new file mode 100644 index 0000000000..457a305e70 --- /dev/null +++ b/docs/content/en/docs-v0.49.x/user-guide/managing-application/cancelling-a-deployment.md @@ -0,0 +1,17 @@ +--- +title: "Cancelling a deployment" +linkTitle: "Cancelling a deployment" +weight: 5 +description: > + This page describes how to cancel a running deployment. +--- + +A running deployment can be cancelled from web UI at the deployment details page. + +If the application rollback is enabled in the application configuration, the rollback process will be executed after the cancelling. You can also explicitly specify to rollback after the cancelling or not from the web UI by clicking on `▼` mark on the right side of the `CANCEL` button to select your option. + +![](/images/cancel-deployment.png) +

+Cancel a Deployment from web UI +

+ diff --git a/docs/content/en/docs-v0.49.x/user-guide/managing-application/configuration-drift-detection.md b/docs/content/en/docs-v0.49.x/user-guide/managing-application/configuration-drift-detection.md new file mode 100644 index 0000000000..6090abf7f5 --- /dev/null +++ b/docs/content/en/docs-v0.49.x/user-guide/managing-application/configuration-drift-detection.md @@ -0,0 +1,101 @@ +--- +title: "Configuration drift detection" +linkTitle: "Configuration drift detection" +weight: 8 +description: > + Automatically detecting the configuration drift. +--- + +Configuration Drift is a phenomenon where running resources of service become more and more different from the definitions in Git as time goes on, due to manual ad-hoc changes and updates. +As PipeCD is using Git as a single source of truth, all application resources and infrastructure changes should be done by making a pull request to Git. Whenever a configuration drift occurs it should be notified to the developers and be fixed. + +PipeCD includes `Configuration Drift Detection` feature, which periodically compares running resources/configurations with the definitions in Git to detect the configuration drift and shows the comparing result in the application details web page as well as sends the notifications to the developers. + +### Detection Result +There are three statuses for the drift detection result: `SYNCED`, `OUT_OF_SYNC`, `DEPLOYING`. + +###### SYNCED + +This status means no configuration drift was detected. All resources/configurations are synced from the definitions in Git. From the application details page, this status is shown by a green "Synced" mark. + +![](/images/application-synced.png) +

+Application is in SYNCED state +

+ +###### OUT_OF_SYNC + +This status means a configuration drift was detected. An application is in this status when at least one of the following conditions is satisfied: +- at least one resource is defined in Git but NOT running in the cluster +- at least one resource is NOT defined in Git but running in the cluster +- at least one resource that is both defined in Git and running in the cluster but NOT in the same configuration + +This status is shown by a red "Out of Sync" mark on the application details page. + +![](/images/application-out-of-sync.png) +

+Application is in OUT_OF_SYNC state +

+ +Click on the "SHOW DETAILS" button to see more details about why the application is in the `OUT_OF_SYNC` status. In the below example, the replicas number of a Deployment was not matching, it was `300` in Git but `3` in the cluster. + +![](/images/application-out-of-sync-details.png) +

+The details shows why the application is in OUT_OF_SYNC state +

+ +###### DEPLOYING + +This status means the application is deploying and the configuration drift detection is not running a white. Whenever a new deployment of the application was started, the detection process will temporarily be stopped until that deployment finishes and will be continued after that. + +### How to enable + +This feature is automatically enabled for all applications. + +You can change the checking interval as well as [configure the notification](../../managing-piped/configuring-notifications/) for these events in `piped` configuration. + +Note: If you want to trigger deployment automatically when `OUT_OF_SYNC` occurs, see [Trigger configuration](./triggering-a-deployment/#trigger-configuration). + +### Ignore drift detection for specific fields + +> Note: This feature is currently supported for only Kubernetes application. + +You can also ignore drift detection for specified fields in your application manifests. In other words, even if the selected fields have different values between live state and Git, the application status will not be set to `Out of Sync`. + +For example, suppose you have the application's manifest as below + +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: simple +spec: + replicas: 2 + template: + spec: + containers: + - args: + - hi + - hello + image: gcr.io/pipecd/helloworld:v1.0.0 + name: helloworld +``` + +If you want to ignore the drift detection for the two sceans +- pod's replicas +- `helloworld` container's args + +Add the following statements to `app.pipecd.yaml` to ignore diff on those fields. + +```yaml +spec: + ... + driftDetection: + ignoreFields: + - apps/v1:Deployment:default:simple#spec.replicas + - apps/v1:Deployment:default:simple#spec.template.spec.containers.0.args +``` + +Note: The `ignoreFields` is in format `apiVersion:kind:namespace:name#yamlFieldPath` + +For more information, see the [configuration reference](../../configuration-reference/#driftdetection). diff --git a/docs/content/en/docs-v0.49.x/user-guide/managing-application/customizing-deployment/_index.md b/docs/content/en/docs-v0.49.x/user-guide/managing-application/customizing-deployment/_index.md new file mode 100644 index 0000000000..3f42bbdd32 --- /dev/null +++ b/docs/content/en/docs-v0.49.x/user-guide/managing-application/customizing-deployment/_index.md @@ -0,0 +1,14 @@ +--- +title: "Customizing application's deployment pipeline" +linkTitle: "Customizing deployment" +weight: 3 +description: > + This page describes how to customize an application's deployment pipeline with PipeCD defined stages. +--- + +In the previous section, we knew how to use PipeCD supporting application kind's stages to build up a pipeline that defines how Piped should deploy your application. In this section, aside from the application kind specified stages, we will talk about some commonly defined pipeline stages, which can be used to build up a more fashionable deployment pipeline for your application. + +![](/images/deployment-wait-stage.png) +

+Example deployment with a WAIT stage +

diff --git a/docs/content/en/docs-v0.49.x/user-guide/managing-application/customizing-deployment/adding-a-manual-approval.md b/docs/content/en/docs-v0.49.x/user-guide/managing-application/customizing-deployment/adding-a-manual-approval.md new file mode 100644 index 0000000000..3ee946b5fd --- /dev/null +++ b/docs/content/en/docs-v0.49.x/user-guide/managing-application/customizing-deployment/adding-a-manual-approval.md @@ -0,0 +1,39 @@ +--- +title: "Adding a manual approval stage" +linkTitle: "Manual approval stage" +weight: 2 +description: > + This page describes how to add a manual approval stage. +--- + +While deploying an application to production environments, some teams require manual approvals before continuing. +The manual approval stage enables you to control when the deployment is allowed to continue by requiring a specific person or team to approve. +This stage is named by `WAIT_APPROVAL` and you can add it to your pipeline before some stages should be approved before they can be executed. + +``` yaml +apiVersion: pipecd.dev/v1beta1 +kind: KubernetesApp +spec: + pipeline: + stages: + - name: K8S_CANARY_ROLLOUT + - name: WAIT_APPROVAL + with: + timeout: 6h + approvers: + - user-abc + - name: K8S_PRIMARY_ROLLOUT +``` + +As above example, the deployment requires an approval from `user-abc` before `K8S_PRIMARY_ROLLOUT` stage can be executed. + +The value of user ID in the `approvers` list depends on your [SSO configuration](../../../managing-controlplane/auth/), it must be GitHub's user ID if your SSO was configured to use GitHub provider, it must be Gmail account if your SSO was configured to use Google provider. + +In case the `approvers` field was not configured, anyone in the project who has `Editor` or `Admin` role can approve the deployment pipeline. + +Also, it will end with failure when the time specified in `timeout` has elapsed. Default is `6h`. + +![](/images/deployment-wait-approval-stage.png) +

+Deployment with a WAIT_APPROVAL stage +

diff --git a/docs/content/en/docs-v0.49.x/user-guide/managing-application/customizing-deployment/adding-a-wait-stage.md b/docs/content/en/docs-v0.49.x/user-guide/managing-application/customizing-deployment/adding-a-wait-stage.md new file mode 100644 index 0000000000..f2d381d8f8 --- /dev/null +++ b/docs/content/en/docs-v0.49.x/user-guide/managing-application/customizing-deployment/adding-a-wait-stage.md @@ -0,0 +1,29 @@ +--- +title: "Adding a wait stage" +linkTitle: "Wait stage" +weight: 1 +description: > + This page describes how to add a WAIT stage. +--- + +In addition to waiting for approvals from someones, the deployment pipeline can be configured to wait an amount of time before continuing. +This can be done by adding the `WAIT` stage into the pipeline. This stage has one configurable field is `duration` to configure how long should be waited. + +``` yaml +apiVersion: pipecd.dev/v1beta1 +kind: KubernetesApp +spec: + pipeline: + stages: + - name: K8S_CANARY_ROLLOUT + - name: WAIT + with: + duration: 5m + - name: K8S_PRIMARY_ROLLOUT + - name: K8S_CANARY_CLEAN +``` + +![](/images/deployment-wait-stage.png) +

+Deployment with a WAIT stage +

diff --git a/docs/content/en/docs-v0.49.x/user-guide/managing-application/customizing-deployment/automated-deployment-analysis.md b/docs/content/en/docs-v0.49.x/user-guide/managing-application/customizing-deployment/automated-deployment-analysis.md new file mode 100644 index 0000000000..6a3fec2ddb --- /dev/null +++ b/docs/content/en/docs-v0.49.x/user-guide/managing-application/customizing-deployment/automated-deployment-analysis.md @@ -0,0 +1,295 @@ +--- +title: "Adding an automated deployment analysis stage" +linkTitle: "Automated deployment analysis stage" +weight: 3 +description: > + This page describes how to configure Automated Deployment Analysis feature. +--- + +Automated Deployment Analysis (ADA) evaluates the impact of the deployment you are in the middle of by analyzing the metrics data, log entries, and the responses of the configured HTTP requests. +The analysis of the newly deployed application is often carried out in a manual, ad-hoc or statistically incorrect manner. +ADA automates that and helps to build a robust deployment process. +ADA is available as a stage in the pipeline specified in the application configuration file. + +ADA does the analysis by periodically performing queries against the [Analysis Provider](../../../../concepts/#analysis-provider) and evaluating the results to know the impact of the deployment. Then based on these evaluating results, the deployment can be rolled back immediately to minimize any negative impacts. + +The canonical use case for this stage is to determine if your canary deployment should proceed. + +![](/images/deployment-analysis-stage.png) +

+Automatic rollback based on the analysis result +

+ +## Prerequisites +Before enabling ADA inside the pipeline, all required Analysis Providers must be configured in the Piped Configuration according to [this guide](../../../managing-piped/adding-an-analysis-provider/). + +## Analysis by metrics +### Strategies +You can choose one of the four strategies to fit your use case. + +- `THRESHOLD`: A simple method to compare against a statically defined threshold (same as the typical analysis method up to `v0.18.0`). +- `PREVIOUS`: A method to compare metrics with the last successful deployment. +- `CANARY_BASELINE`: A method to compare the metrics between the Canary and Baseline variants. +- `CANARY_PRIMARY`(not recommended): A method to compare the metrics between the Canary and Primary variants. + +`THRESHOLD` is the simplest strategy, so it's for you if you attempt to evaluate this feature. + +`THRESHOLD` only checks if the query result falls within the statically specified range, whereas others evaluate by checking the deviation of two time-series data. +Therefore, those configuration fields are slightly different from each other. The next section covers how to configure the ADA stage for each strategy. + +### Configuration +Here is an example for the `THRESHOLD` strategy. + +```yaml +apiVersion: pipecd.dev/v1beta1 +kind: KubernetesApp +spec: + pipeline: + stages: + - name: ANALYSIS + with: + duration: 30m + metrics: + - strategy: THRESHOLD + provider: my-prometheus + interval: 5m + expected: + max: 0.01 + query: | + sum (rate(http_requests_total{status=~"5.*"}[5m])) + / + sum (rate(http_requests_total[5m])) +``` + +In the `provider` field, put the name of the provider in Piped configuration prepared in the [Prerequisites](#prerequisites) section. + +The `ANALYSIS` stage will continue to run for the period specified in the `duration` field. +In the meantime, Piped sends the given `query` to the Analysis Provider at each specified `interval`. + +For each query, it checks if the result is within the expected range. If it's not expected, this `ANALYSIS` stage will fail (typically the rollback stage will be started). +You can change the acceptable number of failures by setting the `failureLimit` field. + +The other strategies are basically the same, but there are slight differences. Let's take a look at them. + +##### PREVIOUS strategy +In the `PREVIOUS` strategy, Piped queries the analysis provider with the time range when the deployment was previously successful, and compares that metrics with the current metrics. + +```yaml +apiVersion: pipecd.dev/v1beta1 +kind: KubernetesApp +spec: + pipeline: + stages: + - name: ANALYSIS + with: + duration: 30m + metrics: + - strategy: PREVIOUS + provider: my-prometheus + deviation: HIGH + interval: 5m + query: | + sum (rate(http_requests_total{status=~"5.*"}[5m])) + / + sum (rate(http_requests_total[5m])) +``` + +In the `THRESHOLD` strategy, we used `expected` to evaluate the deployment, but here we use `deviation` instead. +The stage fails on deviation in the specified direction. In the above example, it fails if the current metrics is higher than the previous. + +##### CANARY strategy + +**With baseline**: + +In the `CANARY_BASELINE` strategy, Piped checks if there is a significant difference between the metrics of the two running variants, Canary and Baseline. + +```yaml +apiVersion: pipecd.dev/v1beta1 +kind: KubernetesApp +spec: + pipeline: + stages: + - name: ANALYSIS + with: + duration: 30m + metrics: + - strategy: CANARY_BASELINE + provider: my-prometheus + deviation: HIGH + interval: 5m + query: | + sum (rate(http_requests_total{job="foo-{{ .Variant.Name }}", status=~"5.*"}[5m])) + / + sum (rate(http_requests_total{job="foo-{{ .Variant.Name }}"}[5m])) +``` + +Like `PREVIOUS`, you specify the conditions for failure with `deviation`. + +It generates different queries for Canary and Baseline to compare the metrics. You can use the Variant args to template the queries. +Analysis Template uses the [Go templating engine](https://golang.org/pkg/text/template/) which only replaces values. This allows variant-specific data to be embedded in the query. + +The available built-in args currently are: + +| Property | Type | Description | +|-|-|-| +| Variant.Name | string | "canary", "baseline", or "primary" will be populated | + +Also, you can define the custom args using `baselineArgs` and `canaryArgs`, and refer them like `{{ .VariantCustom.Args.job }}`. + +```yaml + metrics: + - strategy: CANARY_BASELINE + provider: my-prometheus + deviation: HIGH + baselineArgs: + job: bar + canaryArgs: + job: baz + interval: 5m + query: cpu_usage{job="{{ .VariantCustomArgs.job }}", status=~"5.*"} +``` + +**With primary (not recommended)**: + +If for some reason you cannot provide the Baseline variant, you can also compare Canary and Primary. +However, we recommend that you compare it with Baseline that is a variant launched at the same time as Canary as much as possible. + +##### Comparison algorithm +The metric comparison algorithm in PipeCD uses a nonparametric statistical test called [Mann-Whitney U test](https://en.wikipedia.org/wiki/Mann%E2%80%93Whitney_U_test) to check for a significant difference between two metrics collection (like Canary and Baseline, or the previous deployment and the current metrics). + +### Example pipelines + +**Analyze the canary variant using the `THRESHOLD` strategy:** + +```yaml +apiVersion: pipecd.dev/v1beta1 +kind: KubernetesApp +spec: + pipeline: + stages: + - name: K8S_CANARY_ROLLOUT + with: + replicas: 20% + - name: ANALYSIS + with: + duration: 30m + metrics: + - provider: my-prometheus + interval: 10m + expected: + max: 0.1 + query: rate(cpu_usage_total{app="foo"}[10m]) + - name: K8S_PRIMARY_ROLLOUT + - name: K8S_CANARY_CLEAN +``` + +**Analyze the primary variant using the `PREVIOUS` strategy:** + +```yaml +apiVersion: pipecd.dev/v1beta1 +kind: KubernetesApp +spec: + pipeline: + stages: + - name: K8S_PRIMARY_ROLLOUT + - name: ANALYSIS + with: + duration: 30m + metrics: + - strategy: PREVIOUS + provider: my-prometheus + interval: 5m + deviation: HIGH + query: rate(cpu_usage_total{app="foo"}[5m]) +``` + +**Analyze the canary variant using the `CANARY_BASELINE` strategy:** + +```yaml +apiVersion: pipecd.dev/v1beta1 +kind: KubernetesApp +spec: + pipeline: + stages: + - name: K8S_CANARY_ROLLOUT + with: + replicas: 20% + - name: K8S_BASELINE_ROLLOUT + with: + replicas: 20% + - name: ANALYSIS + with: + duration: 30m + metrics: + - strategy: CANARY_BASELINE + provider: my-prometheus + interval: 10m + deviation: HIGH + query: rate(cpu_usage_total{app="foo", variant="{{ .Variant.Name }}"}[10m]) + - name: K8S_PRIMARY_ROLLOUT + - name: K8S_CANARY_CLEAN + - name: K8S_BASELINE_CLEAN +``` + +The full list of configurable `ANALYSIS` stage fields are [here](../../../configuration-reference/#analysisstageoptions). + +See more the [example](https://github.com/pipe-cd/examples/blob/master/kubernetes/analysis-by-metrics/app.pipecd.yaml). + +## Analysis by logs + +>TBA + +## Analysis by http + +>TBA + +### [Optional] Analysis Template +Analysis Templating is a feature that allows you to define some shared analysis configurations to be used by multiple applications. These templates must be placed at the `.pipe` directory at the root of the Git repository. Any application in that Git repository can use to the defined template by specifying the name of the template in the application configuration file. + +```yaml +apiVersion: pipecd.dev/v1beta1 +kind: AnalysisTemplate +spec: + metrics: + http_error_rate: + interval: 30m + provider: my-prometheus + expected: + max: 0 + query: | + sum without(status) (rate(http_requests_total{status=~"5.*", job="{{ .App.Name }}"}[1m])) + / + sum without(status) (rate(http_requests_total{job="{{ .App.Name }}"}[1m])) +``` + +Once the AnalysisTemplate is defined, you can reference from the application configuration using the `template` field. + +```yaml +apiVersion: pipecd.dev/v1beta1 +kind: KubernetesApp +spec: + pipeline: + stages: + - name: ANALYSIS + with: + duration: 30m + metrics: + - template: + name: http_error_rate +``` + +Analysis Template uses the [Go templating engine](https://golang.org/pkg/text/template/) which only replaces values. This allows deployment-specific data to be embedded in the analysis template. + +The available built-in args are: + +| Property | Type | Description | +|-|-|-| +| App.Name | string | Application Name. | +| K8s.Namespace | string | The Kubernetes namespace where manifests will be applied. | + +Also, custom args is supported. Custom args placeholders can be defined as `{{ .AppCustomArgs. }}`. + +Of course, it can be used in conjunction with [Variant args](#canary-strategy). + +See [here](https://github.com/pipe-cd/examples/blob/master/.pipe/analysis-template.yaml) for more examples. +And the full list of configurable `AnalysisTemplate` fields are [here](/docs/user-guide/configuration-reference/#analysis-template-configuration). diff --git a/docs/content/en/docs-v0.49.x/user-guide/managing-application/customizing-deployment/custom-sync.md b/docs/content/en/docs-v0.49.x/user-guide/managing-application/customizing-deployment/custom-sync.md new file mode 100644 index 0000000000..47d7d7a534 --- /dev/null +++ b/docs/content/en/docs-v0.49.x/user-guide/managing-application/customizing-deployment/custom-sync.md @@ -0,0 +1,61 @@ +--- +title: "Custom Sync" +linkTitle: "Custom Sync" +weight: 4 +description: > + Specific guide for configuring Custom Sync +--- + +`CUSTOM_SYNC` is one stage in the pipeline and you can define scripts to deploy run in this stage. + +> Note: This feature is marked as a deprecated feature and will be removed later. + +## How to configure Custom Sync + +Add a `CUSTOM_SYNC` to your pipeline and write commands to deploy your infrastructure. +The commands run in the directory where this application configuration file exists. + +```yaml +apiVersion: pipecd.dev/v1beta1 +kind: LambdaApp +spec: + name: sam-simple + labels: + env: example + team: abc + planner: + # Must add this configuration to force use CUSTOM_SYNC stage. + alwaysUsePipeline: true + pipeline: + stages: + - name: CUSTOM_SYNC + with: + envs: + AWS_PROFILE: "sample" + run: | + cd sam-app + sam build + echo y | sam deploy --profile $AWS_PROFILE +``` + +![](/images/custom-sync.png) + +Note: +1. You can use `CUSTOM_SYNC` with any current supporting application kind, but keep `alwaysUsePipeline` true to not run the application kind's default `QUICK_SYNC`. +2. Only one `CUSTOM_SYNC` stage should be used in an application pipeline. +3. The commands run with the enviroment variable `PATH` that refers `~/.piped/tools` at first. + +The public piped image available in PipeCD main repo (ref: [Dockerfile](https://github.com/pipe-cd/pipecd/blob/master/cmd/piped/Dockerfile)) is based on [alpine](https://hub.docker.com/_/alpine/) and only has a few UNIX command available (ref: [piped-base Dockerfile](https://github.com/pipe-cd/pipecd/blob/master/tool/piped-base/Dockerfile)). If you want to use your commands (`sam` in the above example), you can: + +- Prepare your own environment container image then add [piped binary](https://github.com/pipe-cd/pipecd/releases) to it. +- Build your own container image based on `ghcr.io/pipe-cd/piped` image. +- Manually update your running piped container (not recommended). + +## Auto Rollback + +When `autoRollback` is enabled, the deployment will be rolled back in the same way as [Rolling Back](../../rolling-back-a-deployment). + +When the rolling back process is triggered in the pipeline including `CUSTOM_SYNC`, `CUSTOM_SYNC_ROLLBACK` stage will be added to the deployment pipeline. +`CUSTOM_SYNC_ROLLBACK` is different from `ROLLBACK` that applications set defaultly, it runs the same commands as `CUSTOM_SYNC` in the runnning commit to reverts all the applied changes. + +![](/images/custom-sync-rollback.png) diff --git a/docs/content/en/docs-v0.49.x/user-guide/managing-application/customizing-deployment/script-run.md b/docs/content/en/docs-v0.49.x/user-guide/managing-application/customizing-deployment/script-run.md new file mode 100644 index 0000000000..dd4ba0544f --- /dev/null +++ b/docs/content/en/docs-v0.49.x/user-guide/managing-application/customizing-deployment/script-run.md @@ -0,0 +1,190 @@ +--- +title: "Script Run stage" +linkTitle: "Script Run stage" +weight: 4 +description: > + Specific guide for configuring Script Run stage +--- + +`SCRIPT_RUN` stage is one stage in the pipeline and you can execute any commands. + +> Note: This feature is at the alpha status. Currently you can use it on all application kinds, but the rollback feature is only for the application kind of KubernetesApp. + +## How to configure SCRIPT_RUN stage + +Add a `SCRIPT_RUN` to your pipeline and write commands. + +```yaml +apiVersion: pipecd.dev/v1beta1 +kind: KubernetesApp +spec: + name: canary-with-script-run + labels: + env: example + team: product + pipeline: + stages: + - name: K8S_CANARY_ROLLOUT + with: + replicas: 10% + - name: WAIT + with: + duration: 10s + - name: SCRIPT_RUN + with: + env: + MSG: "execute script1" + run: | + echo $MSG + sleep 10 + - name: K8S_PRIMARY_ROLLOUT + - name: K8S_CANARY_CLEAN + - name: SCRIPT_RUN + with: + env: + MSG: "execute script2" + run: | + echo $MSG + sleep 10 +``` + +You can define the command as `run`. +Also, if you want to some values as variables, you can define them as `env`. + +The commands run in the directory where this application configuration file exists. + +![](/images/script-run.png) + +### Execute the script file + +If your script is so long, you can separate the script as a file. +You can put the file with the app.pipecd.yaml in the same dir and then you can execute the script like this. + +``` +apiVersion: pipecd.dev/v1beta1 +kind: KubernetesApp +spec: + name: script-run + pipeline: + stages: + - name: SCRIPT_RUN + with: + run: | + sh script.sh +``` + +``` +. +├── app.pipecd.yaml +└── script.sh +``` + +## Builtin commands + +Currently, you can use the commands which are installed in the environment for the piped. + +For example, If you are using the container platform and the offcial piped container image, you can use the command below. +- git +- ssh +- jq +- curl +- commands installed by piped in $PIPED_TOOL_DIR (check at runtime) +- built-in commands installed in the base image + +The public piped image available in PipeCD main repo (ref: [Dockerfile](https://github.com/pipe-cd/pipecd/blob/master/cmd/piped/Dockerfile)) is based on [alpine](https://hub.docker.com/_/alpine/) and only has a few UNIX commands available (ref: [piped-base Dockerfile](https://github.com/pipe-cd/pipecd/blob/master/tool/piped-base/Dockerfile)). + +If you want to use your commands, you can realize it with either step below. +- Prepare your own environment container image then add [piped binary](https://github.com/pipe-cd/pipecd/releases) to it. +- Build your own container image based on `ghcr.io/pipe-cd/piped` image. + +## Default environment values + +You can use the envrionment values related to the deployment. + +| Name | Description | Example | +|-|-|-| +|SR_DEPLOYMENT_ID| The deployment id | 877625fc-196a-40f9-b6a9-99decd5494a0 | +|SR_APPLICATION_ID| The application id | 8d7609e0-9ff6-4dc7-a5ac-39660768606a | +|SR_APPLICATION_NAME| The application name | example | +|SR_TRIGGERED_AT| The timestamp when the deployment is triggered | 1719571113 | +|SR_TRIGGERED_COMMIT_HASH| The commit hash that triggered the deployment | 2bf969a3dad043aaf8ae6419943255e49377da0d | +|SR_REPOSITORY_URL| The repository url configured in the piped config | git@github.com:org/repo.git, https://github.com/org/repo | +|SR_SUMMARY| The summary of the deployment | Sync with the specified pipeline because piped received a command from user via web console or pipectl| +|SR_CONTEXT_RAW| The json encoded string of above values | {"deploymentID":"877625fc-196a-40f9-b6a9-99decd5494a0","applicationID":"8d7609e0-9ff6-4dc7-a5ac-39660768606a","applicationName":"example","triggeredAt":1719571113,"triggeredCommitHash":"2bf969a3dad043aaf8ae6419943255e49377da0d","repositoryURL":"git@github.com:org/repo.git","labels":{"env":"example","team":"product"}} | +|SR_LABELS_XXX| The label attached to the deployment. The env name depends on the label name. For example, if a deployment has the labels `env:prd` and `team:server`, `SR_LABELS_ENV` and `SR_LABELS_TEAM` are registered. | prd, server | + +### Use `SR_CONTEXT_RAW` with jq + +You can use jq command to refer to the values from `SR_CONTEXT_RAW`. + +``` + - name: SCRIPT_RUN + with: + run: | + echo "Get deploymentID from SR_CONTEXT_RAW" + echo $SR_CONTEXT_RAW | jq -r '.deploymentID' + sleep 10 + onRollback: | + echo "rollback script-run" +``` + +## Rollback + +> Note: Currently, this feature is only for the application kind of KubernetesApp. + +You can define the command as `onRollback` to execute when to rollback similar to `run`. +Execute the command to rollback SCRIPT_RUN to the point where the deployment was canceled or failed. + +```yaml +apiVersion: pipecd.dev/v1beta1 +kind: KubernetesApp +spec: + name: canary-with-script-run + labels: + env: example + team: product + pipeline: + stages: + - name: K8S_CANARY_ROLLOUT + with: + replicas: 10% + - name: WAIT + with: + duration: 10s + - name: SCRIPT_RUN + with: + env: + MSG: "execute script1" + R_MSG: "rollback script1" + run: | + echo $MSG + sleep 10 + onRollback: | + echo $R_MSG + sleep 10 + - name: K8S_PRIMARY_ROLLOUT + - name: K8S_CANARY_CLEAN +``` + +![](/images/script-run-onRollback.png) + +The command defined as `onRollback` is executed as `SCRIPT_RUN_ROLLBACK` stage after each `ROLLBACK` stage. + +When there are multiple SCRIPT_RUN stages, they are executed in the same order as SCRIPT_RUN on the pipeline. +Also, only for the executed SCRIPT_RUNs are rollbacked. + +For example, consider when deployment proceeds in the following order from 1 to 7. +``` +1. K8S_CANARY_ROLLOUT +2. WAIT +3. SCRIPT_RUN +4. K8S_PRIMARY_ROLLOUT +5. SCRIPT_RUN +6. K8S_CANARY_CLEAN +7. SCRIPT_RUN +``` + +Then +- If 3 is canceled or fails while running, only SCRIPT_RUN of 3 will be rollbacked. +- If 4 is canceled or fails while running, only SCRIPT_RUN of 3 will be rollbacked. +- If 6 is canceled or fails while running, only SCRIPT_RUNs 3 and 5 will be rollbacked. The order of executing is 3 -> 5. diff --git a/docs/content/en/docs-v0.49.x/user-guide/managing-application/defining-app-configuration/_index.md b/docs/content/en/docs-v0.49.x/user-guide/managing-application/defining-app-configuration/_index.md new file mode 100644 index 0000000000..6bcca6b06f --- /dev/null +++ b/docs/content/en/docs-v0.49.x/user-guide/managing-application/defining-app-configuration/_index.md @@ -0,0 +1,9 @@ +--- +title: "Defining application's configuration" +linkTitle: "Defining app configuration" +weight: 2 +description: > + This page describes how to configure your application's deployment for each application kind. +--- + +In the previous section, we knew that each PipeCD application requires a configuration file (we call it the application configuration file) that contains the application's information (such as name, label, etc) and also defines how should Piped deploy that application. In this section, we will show you how to define a deployment pipeline like that for each kind of PipeCD supporting application. diff --git a/docs/content/en/docs-v0.49.x/user-guide/managing-application/defining-app-configuration/cloudrun.md b/docs/content/en/docs-v0.49.x/user-guide/managing-application/defining-app-configuration/cloudrun.md new file mode 100644 index 0000000000..7333dedf93 --- /dev/null +++ b/docs/content/en/docs-v0.49.x/user-guide/managing-application/defining-app-configuration/cloudrun.md @@ -0,0 +1,87 @@ +--- +title: "Configuring Cloud Run application" +linkTitle: "Cloud Run" +weight: 3 +description: > + Specific guide to configuring deployment for Cloud Run application. +--- + +Deploying a Cloud Run application requires a `service.yaml` file placing inside the application directory. That file contains the service specification used by Cloud Run as following: + +``` yaml +apiVersion: serving.knative.dev/v1 +kind: Service +metadata: + name: SERVICE_NAME +spec: + template: + metadata: + annotations: + autoscaling.knative.dev/maxScale: '5' + spec: + containerConcurrency: 80 + containers: + - args: + - server + image: gcr.io/pipecd/helloworld:v0.5 + ports: + - containerPort: 9085 + resources: + limits: + cpu: 1000m + memory: 128Mi +``` + +## Quick sync + +By default, when the [pipeline](../../../configuration-reference/#cloud-run-application) was not specified, PipeCD triggers a quick sync deployment for the merged pull request. +Quick sync for a Cloud Run deployment will roll out the new version and switch all traffic to it. + +## Sync with the specified pipeline + +The [pipeline](../../../configuration-reference/#cloud-run-application) field in the application configuration is used to customize the way to do the deployment. +You can add a manual approval before routing traffic to the new version or add an analysis stage the do some smoke tests against the new version before allowing them to receive the real traffic. + +These are the provided stages for Cloud Run application you can use to build your pipeline: + +- `CLOUDRUN_PROMOTE` + - promote the new version to receive an amount of traffic + +and other common stages: +- `WAIT` +- `WAIT_APPROVAL` +- `ANALYSIS` + +See the description of each stage at [Customize application deployment](../../customizing-deployment/). + +Here is an example that rolls out the new version gradually: + +``` yaml +apiVersion: pipecd.dev/v1beta1 +kind: CloudRunApp +spec: + pipeline: + stages: + # Promote new version to receive 10% of traffic. + - name: CLOUDRUN_PROMOTE + with: + percent: 10 + - name: WAIT + with: + duration: 10m + # Promote new version to receive 50% of traffic. + - name: CLOUDRUN_PROMOTE + with: + percent: 50 + - name: WAIT + with: + duration: 10m + # Promote new version to receive all traffic. + - name: CLOUDRUN_PROMOTE + with: + percent: 100 +``` + +## Reference + +See [Configuration Reference](../../../configuration-reference/#cloud-run-application) for the full configuration. diff --git a/docs/content/en/docs-v0.49.x/user-guide/managing-application/defining-app-configuration/ecs.md b/docs/content/en/docs-v0.49.x/user-guide/managing-application/defining-app-configuration/ecs.md new file mode 100644 index 0000000000..bf6c6001a2 --- /dev/null +++ b/docs/content/en/docs-v0.49.x/user-guide/managing-application/defining-app-configuration/ecs.md @@ -0,0 +1,164 @@ +--- +title: "Configuring ECS application" +linkTitle: "ECS" +weight: 5 +description: > + Specific guide to configuring deployment for Amazon ECS application. +--- + +There are two main ways to deploy an Amazon ECS application. +- Your application is a one-time or periodic batch job. + - it's a standalone task. + - you need to prepare `TaskDefinition` +- Your application is deployed to run continuously or behind a load balancer. + - you need to prepare `TaskDefinition` and `Service` + +To deploy an Amazon ECS application, the `TaskDefinition` configuration file must be located in the application directory. This file contains all configuration for [ECS TaskDefinition](https://docs.aws.amazon.com/AmazonECS/latest/developerguide/task_definitions.html) object and will be used by Piped agent while deploying your application/service to the ECS cluster. + +To deploy your application to run continuously or to place it behind a load balancer, You need to create [ECS Service](https://docs.aws.amazon.com/AmazonECS/latest/developerguide/ecs_services.html). The `Service` configuration file also must be located in the application directory. This file contains all configurations for [ECS Service](https://docs.aws.amazon.com/AmazonECS/latest/developerguide/ecs_services.html) object. + +If you're not familiar with ECS, you can get examples for those files from [here](../../../../examples/#ecs-applications). + +Note: + +You can generate an application config file easily and interactively by [`pipectl init`](../../command-line-tool.md#generating-an-application-config-apppipecdyaml). + + +## Quick sync + +By default, when the [pipeline](../../../configuration-reference/#ecs-application) was not specified, PipeCD triggers a quick sync deployment for the merged pull request. +Quick sync for an ECS deployment will roll out the new version and switch all traffic to it immediately. +> In case of standalone task, only Quick sync is supported. + +Here is an example for Quick sync. + + {{< tabpane >}} + {{< tab lang="yaml" header="application" >}} +apiVersion: pipecd.dev/v1beta1 +kind: ECSApp +spec: + name: simple + labels: + env: example + team: xyz + input: + # Path to Service configuration file in Yaml/JSON format. + serviceDefinitionFile: servicedef.yaml + # Path to TaskDefinition configuration file in Yaml/JSON format. + # Default is `taskdef.json` + taskDefinitionFile: taskdef.yaml + targetGroups: + primary: + targetGroupArn: arn:aws:elasticloadbalancing:ap-northeast-1:XXXX:targetgroup/ecs-lb/YYYY + containerName: web + containerPort: 80 + {{< /tab >}} + {{< tab lang="yaml" header="standalone task" >}} +apiVersion: pipecd.dev/v1beta1 +kind: ECSApp +spec: + name: standalonetask-fargate + labels: + env: example + team: xyz + input: + # Path to TaskDefinition configuration file in Yaml/JSON format. + # Default is `taskdef.json` + taskDefinitionFile: taskdef.yaml + clusterArn: arn:aws:ecs:ap-northeast-1:XXXX:cluster/test-cluster + awsvpcConfiguration: + assignPublicIp: ENABLED + subnets: + - subnet-YYYY + - subnet-YYYY + securityGroups: + - sg-YYYY + {{< /tab >}} + {{< /tabpane >}} + +## Sync with the specified pipeline + +The [pipeline](../../../configuration-reference/#ecs-application) field in the application configuration is used to customize the way to do the deployment. +You can add a manual approval before routing traffic to the new version or add an analysis stage the do some smoke tests against the new version before allowing them to receive the real traffic. + +These are the provided stages for ECS application you can use to build your pipeline: + +- `ECS_CANARY_ROLLOUT` + - deploy workloads of the new version as CANARY variant, but it is still receiving no traffic. +- `ECS_PRIMARY_ROLLOUT` + - deploy workloads of the new version as PRIMARY variant, but it is still receiving no traffic. +- `ECS_TRAFFIC_ROUTING` + - routing traffic to the specified variants. +- `ECS_CANARY_CLEAN` + - destroy all workloads of CANARY variant. + +and other common stages: +- `WAIT` +- `WAIT_APPROVAL` +- `ANALYSIS` + +See the description of each stage at [Customize application deployment](../../customizing-deployment/). + +Here is an example that rolls out the new version gradually: + +``` yaml +apiVersion: pipecd.dev/v1beta1 +kind: ECSApp +spec: + input: + # Path to Service configuration file in Yaml/JSON format. + serviceDefinitionFile: servicedef.yaml + # Path to TaskDefinition configuration file in Yaml/JSON format. + # Default is `taskdef.json` + taskDefinitionFile: taskdef.yaml + targetGroups: + primary: + targetGroupArn: arn:aws:elasticloadbalancing:ap-northeast-1:XXXX:targetgroup/ecs-canary-blue/YYYY + containerName: web + containerPort: 80 + canary: + targetGroupArn: arn:aws:elasticloadbalancing:ap-northeast-1:XXXX:targetgroup/ecs-canary-green/YYYY + containerName: web + containerPort: 80 + pipeline: + stages: + # Deploy the workloads of CANARY variant, the number of workload + # for CANARY variant is equal to 30% of PRIMARY's workload. + # But this is still receiving no traffic. + - name: ECS_CANARY_ROLLOUT + with: + scale: 30 + # Change the traffic routing state where + # the CANARY workloads will receive the specified percentage of traffic. + # This is known as multi-phase canary strategy. + - name: ECS_TRAFFIC_ROUTING + with: + canary: 20 + # 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 + # Update the workload of PRIMARY variant to the new version. + - name: ECS_PRIMARY_ROLLOUT + # Change the traffic routing state where + # the PRIMARY workloads will receive 100% of the traffic. + - name: ECS_TRAFFIC_ROUTING + with: + primary: 100 + # Destroy all workloads of CANARY variant. + - name: ECS_CANARY_CLEAN +``` + +## NOTE + +- When you use an ELB for deployments, all listener rules that have the same target groups as configured in app.pipecd.yaml will be controlled. + - That means you need to link target groups to your listener rules before deployments. + - For more information and diagrams, see [Issue#4733 [ECS] Modify ELB listener rules other than defaults without adding config](https://github.com/pipe-cd/pipecd/pull/4733). +- When you use [Service Connect](https://docs.aws.amazon.com/AmazonECS/latest/developerguide/service-connect.html), you cannot use Canary or Blue/Green deployment yet because Service Connect does not support the external deployment yet. +- When you use AutoScaling for a service, you can disable reconciling `desiredCount` by following steps. + 1. Create a service without defining `desiredCount` in the service definition file. See [Restrictions of Service Definition](../../../configuration-reference/#restrictions-of-service-definition). + 2. Configure AutoScaling by yourself. + +## Reference + +See [Configuration Reference](../../../configuration-reference/#ecs-application) for the full configuration. diff --git a/docs/content/en/docs-v0.49.x/user-guide/managing-application/defining-app-configuration/kubernetes.md b/docs/content/en/docs-v0.49.x/user-guide/managing-application/defining-app-configuration/kubernetes.md new file mode 100644 index 0000000000..8669874db1 --- /dev/null +++ b/docs/content/en/docs-v0.49.x/user-guide/managing-application/defining-app-configuration/kubernetes.md @@ -0,0 +1,121 @@ +--- +title: "Configuring Kubernetes application" +linkTitle: "Kubernetes" +weight: 1 +description: > + Specific guide to configuring deployment for Kubernetes application. +--- + +Based on the application configuration and the pull request changes, PipeCD plans how to execute the deployment: doing quick sync or doing progressive sync with the specified pipeline. + +Note: + +You can generate an application config file easily and interactively by [`pipectl init`](../../command-line-tool.md#generating-an-application-config-apppipecdyaml). + + +## Quick sync + +Quick sync is a fast way to sync application to the state specified in the target Git commit without any progressive strategy. It just applies all the defined manifiests to sync the application. +The quick sync will be planned in one of the following cases: +- no pipeline was specified in the application configuration file +- [pipeline](../../../configuration-reference/#pipeline) was specified but the PR did not make any changes on workload (e.g. Deployment's pod template) or config (e.g. ConfigMap, Secret) + +For example, the application configuration as below is missing the pipeline field. This means any pull request touches the application will trigger a quick sync deployment. + +``` yaml +apiVersion: pipecd.dev/v1beta1 +kind: KubernetesApp +spec: + input: + helmChart: + repository: pipecd + name: helloworld + version: v0.3.0 +``` + +In another case, even when the pipeline was specified, a PR that just changes the Deployment's replicas number for scaling will also trigger a quick sync deployment. + +## Sync with the specified pipeline + +The `pipeline` field in the application configuration is used to customize the way to do deployment by specifying and configuring the execution stages. You may want to configure those stages to enable a progressive deployment with a strategy like canary, blue-green, a manual approval, an analysis stage. + +To enable customization, PipeCD defines three variants for each Kubernetes application: primary (aka stable), baseline and canary. +- `primary` runs the current version of code and configuration. +- `baseline` runs the same version of code and configuration as the primary variant. (Creating a brand-new baseline workload ensures that the metrics produced are free of any effects caused by long-running processes.) +- `canary` runs the proposed change of code or configuration. + +Depending on the configured pipeline, any variants can exist and receive the traffic during the deployment process but once the deployment is completed, only the `primary` variant should be remained. + +These are the provided stages for Kubernetes application you can use to build your pipeline: + +- `K8S_PRIMARY_ROLLOUT` + - update the primary resources to the state defined in the target commit +- `K8S_CANARY_ROLLOUT` + - generate canary resources based on the definition of the primary resource in the target commit and apply them +- `K8S_CANARY_CLEAN` + - remove all canary resources +- `K8S_BASELINE_ROLLOUT` + - generate baseline resources based on the definition of the primary resource in the target commit and apply them +- `K8S_BASELINE_CLEAN` + - remove all baseline resources +- `K8S_TRAFFIC_ROUTING` + - split traffic between variants + +and other common stages: +- `WAIT` +- `WAIT_APPROVAL` +- `ANALYSIS` + +See the description of each stage at [Customize application deployment](../../customizing-deployment/). + +## Manifest Templating + +In addition to plain-YAML, PipeCD also supports Helm and Kustomize for templating application manifests. + +A helm chart can be loaded from: +- the same git repository with the application directory, we call as a `local chart` + +``` yaml +apiVersion: pipecd.dev/v1beta1 +kind: KubernetesApp +spec: + input: + helmChart: + path: ../../local/helm-charts/helloworld +``` + +- a different git repository, we call as a `remote git chart` + +``` yaml +apiVersion: pipecd.dev/v1beta1 +kind: KubernetesApp +spec: + input: + helmChart: + gitRemote: git@github.com:pipe-cd/manifests.git + ref: v0.5.0 + path: manifests/helloworld +``` + +- a Helm chart repository, we call as a `remote chart` + +``` yaml +apiVersion: pipecd.dev/v1beta1 +kind: KubernetesApp +spec: + input: + helmChart: + repository: pipecd + name: helloworld + version: v0.5.0 +``` + +A kustomize base can be loaded from: +- the same git repository with the application directory, we call as a `local base` +- a different git repository, we call as a `remote base` + +See [Examples](../../../examples/#kubernetes-applications) for more specific. + +## Reference + +See [Configuration Reference](../../../configuration-reference/#kubernetes-application) for the full configuration. diff --git a/docs/content/en/docs-v0.49.x/user-guide/managing-application/defining-app-configuration/lambda.md b/docs/content/en/docs-v0.49.x/user-guide/managing-application/defining-app-configuration/lambda.md new file mode 100644 index 0000000000..d6bf0a15e8 --- /dev/null +++ b/docs/content/en/docs-v0.49.x/user-guide/managing-application/defining-app-configuration/lambda.md @@ -0,0 +1,171 @@ +--- +title: "Configuring Lambda application" +linkTitle: "Lambda" +weight: 4 +description: > + Specific guide to configuring deployment for Lambda application. +--- + +Deploying a Lambda application requires a `function.yaml` file placing inside the application directory. That file contains values to be used to deploy Lambda function on your AWS cluster. +Currently, Piped supports deploying all types of Lambda deployment packages: +- container images (called [container image as Lambda function](https://aws.amazon.com/blogs/aws/new-for-aws-lambda-container-image-support/)) +- `.zip` file archives (which stored in AWS S3) + +Besides, Piped also supports deploying your Lambda function __directly from the function source code__ which is stored in a remote git repository. + +#### Deploy container image as Lambda function + +A sample `function.yaml` file for container image as Lambda function used deployment as follows: + +```yaml +apiVersion: pipecd.dev/v1beta1 +kind: LambdaFunction +spec: + name: SimpleFunction + image: ecr.ap-northeast-1.amazonaws.com/lambda-test:v0.0.1 + role: arn:aws:iam::76xxxxxxx:role/lambda-role + # The amount of memory available to the Lambda application + # at runtime. The value can be any multiple of 1 MB. + memory: 512 + # Timeout of the Lambda application, the value must + # in between 1 to 900 seconds. + timeout: 30 + tags: + app: simple + environments: + FOO: bar + # ephemeralStorage is optional value. If you define a ephemeral storage to lambda, you can + # use this field. The value must be in between 512 to 10240 MB. + ephemeralStorage: + size: 512 + # vpcConfig is optional value. If you define a vpc configuration to lambda, you can + # use this field. + vpcConfig: + securityGroupIds: + - sg-01234 + - sg-56789 + subnetIds: + - subnet-01234 + - subnet-56789 +``` + +Except the `tags` and the `environments` field, all others are required fields for the deployment to run. + +The `role` value represents the service role (for your Lambda function to run), not for Piped agent to deploy your Lambda application. To be able to pull container images from AWS ECR, besides policies to run as usual, you need to add `Lambda.ElasticContainerRegistry` __read__ permission to your Lambda function service role. + +The `environments` field represents environment variables that can be accessed by your Lambda application at runtime. __In case of no value set for this field, all environment variables for the deploying Lambda application will be revoked__, so make sure you set all currently required environment variables of your running Lambda application on `function.yaml` if you migrate your app to PipeCD deployment. + +#### Deploy .zip file archives as Lambda function + +It's recommended to use container image as Lambda function due to its simplicity, but as mentioned above, below is a sample `function.yaml` file for Lambda which uses zip packing source code stored in AWS S3. + +```yaml +apiVersion: pipecd.dev/v1beta1 +kind: LambdaFunction +spec: + name: SimpleZipPackingS3Function + role: arn:aws:iam::76xxxxxxx:role/lambda-role + # --- 3 next lines allow Piped to determine your Lambda function code stored in AWS S3. + s3Bucket: pipecd-sample-lambda + s3Key: pipecd-sample-src + s3ObjectVersion: 1pTK9_v0Kd7I8Sk4n6abzCL + # --- + handler: app.lambdaHandler + runtime: nodejs14.x + memory: 512 + timeout: 30 + environments: + FOO: bar + tags: + app: simple-zip-s3 +``` + +Value for the `runtime` field should be listed in [AWS Lambda runtimes official docs](https://docs.aws.amazon.com/lambda/latest/dg/lambda-runtimes.html). All other fields setting are remained as in the case of using [container image as Lambda function](#deploy-container-image-as-lambda-function) pattern. + +#### Deploy source code directly as Lambda function + +In case you don’t have a separated CI pipeline that provides artifacts (such as container image, built zip files) as its outputs and want to set up a simple pipeline to deploy the Lambda function directly from your source code, this deployment package is for you. + +```yaml +apiVersion: pipecd.dev/v1beta1 +kind: LambdaFunction +spec: + name: SimpleCanaryZipFunction + role: arn:aws:iam::76xxxxxxx:role/lambda-role + # source configuration use to determine the source code of your Lambda function. + source: + # git remote address where the source code is placing. + git: git@github.com:username/lambda-function-code.git + # the commit SHA or tag for remote git. Use branch name means you will always use + # the latest code of that branch as Lambda function code which is NOT recommended. + ref: dede7cdea5bbd3fdbcc4674bfcd2b2f9e0579603 + # relative path from the repository root directory to the function code directory. + path: hello-world + handler: app.lambdaHandler + runtime: nodejs14.x + memory: 128 + timeout: 5 + tags: + app: canary-zip +``` + +All other fields setting are remained as in the case of using [.zip archives as Lambda function](#deploy-zip-file-archives-as-lambda-function) pattern. + +## Quick sync + +By default, when the [pipeline](../../../configuration-reference/#lambda-application) was not specified, PipeCD triggers a quick sync deployment for the merged pull request. +Quick sync for a Lambda deployment will roll out the new version and switch all traffic to it. + +## Sync with the specified pipeline + +The [pipeline](../../../configuration-reference/#lambda-application) field in the application configuration is used to customize the way to do the deployment. +You can add a manual approval before routing traffic to the new version or add an analysis stage the do some smoke tests against the new version before allowing them to receive the real traffic. + +These are the provided stages for Lambda application you can use to build your pipeline: + +- `LAMBDA_CANARY_ROLLOUT` + - deploy workloads of the new version, but it is still receiving no traffic. +- `LAMBDA_PROMOTE` + - promote the new version to receive an amount of traffic. + +and other common stages: +- `WAIT` +- `WAIT_APPROVAL` +- `ANALYSIS` + +See the description of each stage at [Customize application deployment](../../customizing-deployment/). + +Here is an example that rolls out the new version gradually: + +``` yaml +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 + # Promote new version to receive 10% of traffic. + - name: LAMBDA_PROMOTE + with: + percent: 10 + - name: WAIT + with: + duration: 10m + # Promote new version to receive 50% of traffic. + - name: LAMBDA_PROMOTE + with: + percent: 50 + - name: WAIT + with: + duration: 10m + # Promote new version to receive all traffic. + - name: LAMBDA_PROMOTE + with: + percent: 100 +``` + +## Reference + +See [Configuration Reference](../../../configuration-reference/#lambda-application) for the full configuration. diff --git a/docs/content/en/docs-v0.49.x/user-guide/managing-application/defining-app-configuration/terraform.md b/docs/content/en/docs-v0.49.x/user-guide/managing-application/defining-app-configuration/terraform.md new file mode 100644 index 0000000000..351992e133 --- /dev/null +++ b/docs/content/en/docs-v0.49.x/user-guide/managing-application/defining-app-configuration/terraform.md @@ -0,0 +1,42 @@ +--- +title: "Configuring Terraform application" +linkTitle: "Terraform" +weight: 2 +description: > + Specific guide to configuring deployment for Terraform application. +--- + +## Quick Sync + +By default, when the [pipeline](../../../configuration-reference/#terraform-application) was not specified, PipeCD triggers a quick sync deployment for the merged pull request. +Quick sync for a Terraform deployment does `terraform plan` and if there are any changes detected it applies those changes automatically. + +## Sync with the specified pipeline + +The [pipeline](../../../configuration-reference/#terraform-application) field in the application configuration is used to customize the way to do the deployment. +You can add a manual approval before doing `terraform apply` or add an analysis stage after applying the changes to determine the impact of those changes. + +These are the provided stages for Terraform application you can use to build your pipeline: + +- `TERRAFORM_PLAN` + - do the terraform plan and show the changes will be applied +- `TERRAFORM_APPLY` + - apply all the infrastructure changes + +and other common stages: +- `WAIT` +- `WAIT_APPROVAL` +- `ANALYSIS` + +See the description of each stage at [Customize application deployment](../../customizing-deployment/). + +## Module location + +Terraform module can be loaded from: + +- the same git repository with the application directory, we call as a `local module` +- a different git repository, we call as a `remote module` + +## Reference + +See [Configuration Reference](../../../configuration-reference/#terraform-application) for the full configuration. diff --git a/docs/content/en/docs-v0.49.x/user-guide/managing-application/deployment-chain.md b/docs/content/en/docs-v0.49.x/user-guide/managing-application/deployment-chain.md new file mode 100644 index 0000000000..052a539234 --- /dev/null +++ b/docs/content/en/docs-v0.49.x/user-guide/managing-application/deployment-chain.md @@ -0,0 +1,64 @@ +--- +title: "Deployment chain" +linkTitle: "Deployment chain" +weight: 11 +description: > + Specific guide for configuring chain of deployments. +--- + +For users who want to use PipeCD to build a complex deployment flow, which contains multiple applications across multiple application kinds and roll out them to multiple clusters gradually or promoting across environments, this guideline will show you how to use PipeCD to achieve that requirement. + +## Configuration + +The idea of this feature is to trigger the whole deployment chain when a specified deployment is triggered. To enable trigger the deployment chain, we need to add a configuration section named `postSync` which contains all configurations that be used when the deployment is triggered. For this `Deployment Chain` feature, configuration for it is under `postSync.chain` section. + +A canonical configuration looks as below: + +```yaml +apiVersion: pipecd.dev/v1beta1 +kind: TerraformApp +spec: + input: + ... + pipeline: + ... + postSync: + chain: + applications: + # Find all applications with name `application-2` and trigger them. + - name: application-2 + # Fill all applications with name `application-3` of kind `KUBERNETES` + # and trigger them. + - name: application-3 + kind: KUBERNETES +``` + +As a result, the above configuration will be used to create a deployment chain like the below figure + +![](/images/deployment-chain-figure.png) + +In the context of the deployment chain in PipeCD, a chain is made up of many `blocks`, and each block contains multiple `nodes` which is the reference to a deployment. The first block in the chain always contains only one node, which is the deployment that triggers the whole chain. Other blocks of the chain are built using filters which are configurable via `postSync.chain.applications` section. As for the above example, the second block `Block 2` contains 2 different nodes, which are 2 different PipeCD applications with the same name `application-2`. + +__Tip__: + +1. If you followed all the configuration references and built your deployment chain configuration, but some deployments in your defined chain are not triggered as you want, please re-check those deployments [`trigger configuration`](../triggering-a-deployment/#trigger-configuration). The `onChain` trigger is __disabled by default__; you need to enable that configuration to enable your deployment to be triggered as a node in the deployment chain. +2. Values configured under `postSync.chain.applications` - we call it __Application matcher__'s values are merged using `AND` operator. Currently, only `name` and `kind` are supported, but `labels` will also be supported soon. + +See [Examples](../../examples/#deployment-chain) for more specific. + +## Deployment chain characteristic + +Something you need to care about while creating your deployment chain with PipeCD + +1. The deployment chain blocks are run in sequence, one by one. But all nodes in the same block are run in parallel, you should ensure that all nodes(deployments) in the same block do not depend on each other. +2. Once a node in a block has finished with `FAILURE` or `CANCELLED` status, the containing block will be set to fail, and all other nodes which have not yet finished will be set to `CANCELLED` status (those nodes will be rolled back if they're in the middle of its deploying process). Consequently, all blocks after that failed block will be set to `CANCELLED` status and be stopped. + +## Console view + +![](/images/deployment-chain-console.png) + +The UI for this deployment chain feature currently is under development, we can only __view deployments in chain one by one__ on the deployments page and deployment detail page as usual. + +## Reference + +See [Configuration Reference](../../configuration-reference/#postsync) for the full configuration. diff --git a/docs/content/en/docs-v0.49.x/user-guide/managing-application/manifest-attachment.md b/docs/content/en/docs-v0.49.x/user-guide/managing-application/manifest-attachment.md new file mode 100644 index 0000000000..affb77e2ce --- /dev/null +++ b/docs/content/en/docs-v0.49.x/user-guide/managing-application/manifest-attachment.md @@ -0,0 +1,65 @@ +--- +title: "Manifest attachment" +linkTitle: "Manifest attachment" +weight: 10 +description: > + Attach configuration cross manifest files while deployment. +--- + +For insensitive data which needs to be attached/mounted as a configuration of other resources, Kubernetes ConfigMaps is a simple and bright idea. How about the other application kinds, which need something as simple as k8s ConfigMaps? PipeCD has attachment feature for your usecase. + +## Configuration + +Suppose you have `config.yaml` file which contains + +```yaml +mysql: + rootPassword: "test" + database: "pipecd" +``` + +Then your application configuration will be configured like this + +```yaml +apiVersion: pipecd.dev/v1beta1 +kind: ECSApp +spec: + name: secret-management + labels: + env: example + team: xyz + input: + ... + attachment: + sources: + config: config.yaml + targets: + - taskdef.yaml +``` + +The configuration says that: The file `config.yaml` will be used as an attachment for others, its content will be referred as `config`. The target files, that can use the `config.yaml` file as an attachment, are currently configured to `taskdef.yaml` file. + +And in the "target" file, which uses `config.yaml` file content + +```yaml +... +containerDefinitions: + - command: "echo {{ .attachment.config }}" + image: nginx:1 + cpu: 100 + memory: 100 + name: web +... +``` + +In all cases, `Piped` will perform attaching the attachment file content at last, right before using it to handle any deployment tasks. + +__Tip__: + +This feature can be used in combo with PipeCD [SecretManagement feature](../secret-management). You can encrypt your secret data using PipeCD secret encryption function, it will be decrypted and placed in your configuration files; then the PipeCD attachment feature will attach that decrypted configuration to the manifest of resource, which requires that configuration. + +See examples for detail. + +## Examples + +- [examples/ecs/attachment](https://github.com/pipe-cd/examples/tree/master/ecs/attachment) diff --git a/docs/content/en/docs-v0.49.x/user-guide/managing-application/rolling-back-a-deployment.md b/docs/content/en/docs-v0.49.x/user-guide/managing-application/rolling-back-a-deployment.md new file mode 100644 index 0000000000..4997f41bb5 --- /dev/null +++ b/docs/content/en/docs-v0.49.x/user-guide/managing-application/rolling-back-a-deployment.md @@ -0,0 +1,21 @@ +--- +title: "Rolling back a deployment" +linkTitle: "Rolling back a deployment" +weight: 6 +description: > + This page describes when a deployment is rolled back automatically and how to manually rollback a deployment. +--- + +Rolling back a deployment can be automated by enabling the `autoRollback` field in the application configuration of the application. When `autoRollback` is enabled, the deployment will be rolled back if any of the following conditions are met: +- a stage of the deployment pipeline was failed +- an analysis stage determined that the deployment had a negative impact +- any error occurs while deploying + +When the rolling back process is triggered, a new `ROLLBACK` stage will be added to the deployment pipeline and it reverts all the applied changes. + +![](/images/rolled-back-deployment.png) +

+A deployment was rolled back +

+ +Alternatively, manually rolling back a running deployment can be done from web UI by clicking on `Cancel with rollback` button. diff --git a/docs/content/en/docs-v0.49.x/user-guide/managing-application/secret-management.md b/docs/content/en/docs-v0.49.x/user-guide/managing-application/secret-management.md new file mode 100755 index 0000000000..c1ddc15912 --- /dev/null +++ b/docs/content/en/docs-v0.49.x/user-guide/managing-application/secret-management.md @@ -0,0 +1,122 @@ +--- +title: "Secret management" +linkTitle: "Secret management" +weight: 9 +description: > + Storing secrets safely in the Git repository. +--- + +When doing GitOps, user wants to use Git as a single source of truth. But storing credentials like Kubernetes Secret or Terraform's credentials directly in Git is not safe. +This feature helps you keep that sensitive information safely in Git, right next to your application manifests. + +Basically, the flow will look like this: +- user encrypts their secret data via the PipeCD's Web UI and stores the encrypted data in Git +- `Piped` decrypts them before doing deployment tasks + +## Prerequisites + +Before using this feature, `Piped` needs to be started with a key pair for secret encryption. + +You can use the following command to generate a key pair: + +``` console +openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out private-key +openssl pkey -in private-key -pubout -out public-key +``` + +Then specify them while [installing](../../../installation/install-piped/installing-on-kubernetes) the `Piped` with these options: + +``` console +--set-file secret.data.secret-public-key=PATH_TO_PUBLIC_KEY_FILE \ +--set-file secret.data.secret-private-key=PATH_TO_PRIVATE_KEY_FILE +``` + +Finally, enable this feature in Piped configuration file with `secretManagement` field as below: + +``` yaml +apiVersion: pipecd.dev/v1beta1 +kind: Piped +spec: + pipedID: your-piped-id + ... + secretManagement: + type: KEY_PAIR + config: + privateKeyFile: /etc/piped-secret/secret-private-key + publicKeyFile: /etc/piped-secret/secret-public-key +``` + +## Encrypting secret data + +In order to encrypt the secret data, go to the application list page and click on the options icon at the right side of the application row, choose "Encrypt Secret" option. +After that, input your secret data and click on "ENCRYPT" button. +The encrypted data should be shown for you. Copy it to store in Git. + +![](/images/sealed-secret-application-list.png) +

+Application list page +

+ +
+ +![](/images/sealed-secret-encrypting-form.png) +

+The form for encrypting secret data +

+ +## Storing encrypted secrets in Git + +To make encrypted secrets available to an application, they must be specified in the application configuration file of that application. + +- `encryptedSecrets` contains a list of the encrypted secrets. +- `decryptionTargets` contains a list of files that are using one of the encrypted secrets and should be decrypted by `Piped`. + +``` yaml +apiVersion: pipecd.dev/v1beta1 +# One of Piped defined app kind such as: KubernetesApp +kind: {APPLICATION_KIND} +spec: + encryption: + encryptedSecrets: + password: encrypted-data + decryptionTargets: + - secret.yaml +``` + +## Accessing encrypted secrets + +Any file in the application directory can use `.encryptedSecrets` context to access secrets you have encrypted and stored in the application configuration. + +For example, + +- Accessing by a Kubernets Secret manfiest + +``` yaml +apiVersion: v1 +kind: Secret +metadata: + name: simple-sealed-secret +data: + password: "{{ .encryptedSecrets.password }}" +``` + +- Configuring ENV variable of a Lambda function to use a encrypted secret + +``` yaml +apiVersion: pipecd.dev/v1beta1 +kind: LambdaFunction +spec: + name: HelloFunction + environments: + KEY: "{{ .encryptedSecrets.key }}" +``` + +In all cases, `Piped` will decrypt the encrypted secrets and render the decryption target files before using to handle any deployment tasks. + +## Examples + +- [examples/kubernetes/secret-management](https://github.com/pipe-cd/examples/tree/master/kubernetes/secret-management) +- [examples/cloudrun/secret-management](https://github.com/pipe-cd/examples/tree/master/cloudrun/secret-management) +- [examples/lambda/secret-management](https://github.com/pipe-cd/examples/tree/master/lambda/secret-management) +- [examples/terraform/secret-management](https://github.com/pipe-cd/examples/tree/master/terraform/secret-management) +- [examples/ecs/secret-management](https://github.com/pipe-cd/examples/tree/master/ecs/secret-management) diff --git a/docs/content/en/docs-v0.49.x/user-guide/managing-application/triggering-a-deployment.md b/docs/content/en/docs-v0.49.x/user-guide/managing-application/triggering-a-deployment.md new file mode 100644 index 0000000000..3fcb5559ab --- /dev/null +++ b/docs/content/en/docs-v0.49.x/user-guide/managing-application/triggering-a-deployment.md @@ -0,0 +1,50 @@ +--- +title: "Triggering a deployment" +linkTitle: "Triggering a deployment" +weight: 4 +description: > + This page describes when a deployment is triggered automatically and how to manually trigger a deployment. +--- + +PipeCD uses Git as a single source of truth; all application resources are defined declaratively and immutably in Git. Whenever a developer wants to update the application or infrastructure, they will send a pull request to that Git repository to propose the change. The state defined in Git is the desired state for the application and infrastructure running in the cluster. + +PipeCD applies the proposed changes to running resources in the cluster by triggering needed deployments for applications. The deployment mission is syncing all running resources of the application in the cluster to the state specified in the newest commit in Git. + +By default, when a new merged pull request touches an application, a new deployment for that application will be triggered to execute the sync process. But users can configure the application to control when a new deployment should be triggered or not. For example, using [`onOutOfSync`](#trigger-configuration) to enable the ability to attempt to resolve `OUT_OF_SYNC` state whenever a configuration drift has been detected. + +### Trigger configuration + +Configuration for the trigger used to determine whether we trigger a new deployment. There are several configurable types: +- `onCommit`: Controls triggering new deployment when new Git commits touched the application. +- `onCommand`: Controls triggering new deployment when received a new `SYNC` command. +- `onOutOfSync`: Controls triggering new deployment when application is at `OUT_OF_SYNC` state. +- `onChain`: Controls triggering new deployment when the application is counted as a node of some chains. + +See [Configuration Reference](../../configuration-reference/#deploymenttrigger) for the full configuration. + +After a new deployment was triggered, it will be queued to handle by the appropriate `piped`. And at this time the deployment pipeline was not decided yet. +`piped` schedules all deployments of applications to ensure that for each application only one deployment will be executed at the same time. +When no deployment of an application is running, `piped` picks queueing one to plan the deploying pipeline. +`piped` plans the deploying pipeline based on the application configuration and the diff between the running state and the specified state in the newest commit. +For example: + +- when the merged pull request updated a Deployment's container image or updated a mounting ConfigMap or Secret, `piped` planner will decide that the deployment should use the specified pipeline to do a progressive deployment. +- when the merged pull request just updated the `replicas` number, `piped` planner will decide to use a quick sync to scale the resources. + +You can force `piped` planner to decide to use the [QuickSync](../../../concepts/#sync-strategy) or the specified pipeline based on the commit message by configuring [CommitMatcher](../../configuration-reference/#commitmatcher) in the application configuration. + +After being planned, the deployment will be executed as the decided pipeline. The deployment execution including the state of each stage as well as their logs can be viewed in realtime at the deployment details page. + +![](/images/deployment-details.png) +

+A Running Deployment at the Deployment Details Page +

+ +As explained above, by default all deployments will be triggered automatically by checking the merged commits but you also can manually trigger a new deployment from web UI. +By clicking on `SYNC` button at the application details page, a new deployment for that application will be triggered to sync the application to be the state specified at the newest commit of the master branch (default branch). + +![](/images/application-details.png) +

+Application Details Page +

+ diff --git a/docs/content/en/docs-v0.49.x/user-guide/managing-controlplane/_index.md b/docs/content/en/docs-v0.49.x/user-guide/managing-controlplane/_index.md new file mode 100644 index 0000000000..51de59988b --- /dev/null +++ b/docs/content/en/docs-v0.49.x/user-guide/managing-controlplane/_index.md @@ -0,0 +1,7 @@ +--- +title: "Managing Control Plane" +linkTitle: "Managing Control Plane" +weight: 4 +description: > + This guide is for administrators and operators wanting to install and configure PipeCD for other developers. +--- diff --git a/docs/content/en/docs-v0.49.x/user-guide/managing-controlplane/adding-a-project.md b/docs/content/en/docs-v0.49.x/user-guide/managing-controlplane/adding-a-project.md new file mode 100644 index 0000000000..e162c6adf5 --- /dev/null +++ b/docs/content/en/docs-v0.49.x/user-guide/managing-controlplane/adding-a-project.md @@ -0,0 +1,24 @@ +--- +title: "Adding a project" +linkTitle: "Adding a project" +weight: 2 +description: > + This page describes how to set up a new project. +--- + +The control plane ops can add a new project for a team. +Project adding can be simply done from an internal web page prepared for the ops. +Because that web service is running in an `ops` pod, so in order to access it, using `kubectl port-forward` command to forward a local port to a port on the `ops` pod as following: + +``` console +kubectl port-forward service/pipecd-ops 9082 --namespace={NAMESPACE} +``` + +Then, access to [http://localhost:9082](http://localhost:9082). + +On that page, you will see the list of registered projects and a link to register new projects. +Registering a new project requires only a unique ID string and an optional description text. + +Once a new project has been registered, a static admin (username, password) will be automatically generated for the project admin. You can send that information to the project admin. The project admin first uses the provided static admin information to log in to PipeCD. After that, they can change the static admin information, configure the SSO, RBAC or disable static admin user. + +__Caution:__ The Role-Based Access Control (RBAC) setting is required to enable your team login using SSO, please make sure you have that setup before disable static admin user. \ No newline at end of file diff --git a/docs/content/en/docs-v0.49.x/user-guide/managing-controlplane/architecture-overview.md b/docs/content/en/docs-v0.49.x/user-guide/managing-controlplane/architecture-overview.md new file mode 100644 index 0000000000..4166700b69 --- /dev/null +++ b/docs/content/en/docs-v0.49.x/user-guide/managing-controlplane/architecture-overview.md @@ -0,0 +1,40 @@ +--- +title: "Architecture overview" +linkTitle: "Architecture overview" +weight: 1 +description: > + This page describes the architecture of control plane. +--- + +![](/images/control-plane-components.png) +

+Component Architecture +

+ +The control plane is a centralized part of PipeCD. It contains several services as below to manage the application, deployment data and handle all requests from `piped`s and web clients: + +##### Server + +`server` handles all incoming gRPC requests from `piped`s, web clients, incoming HTTP requests such as auth callback from third party services. +It also serves all web assets including HTML, JS, CSS... +This service can be easily scaled by updating the pod number. + +##### Cache + +`cache` is a single pod service for caching internal data used by `server` service. Currently, this `cache` service is powered by `redis`. +You can configure the control plane to use a fully-managed redis cache service instead of launching a cache pod in your cluster. + +##### Ops + +`ops` is a single pod service for operating PipeCD owner's tasks. +For example, it provides an internal web page for adding and managing projects; it periodically removes the old data; it collects and saves the deployment insights. + +##### Data Store + +`Data store` is a storage for storing model data such as applications and deployments. This can be a fully-managed service such as GCP [Firestore](https://cloud.google.com/firestore), GCP [Cloud SQL](https://cloud.google.com/sql) or AWS [RDS](https://aws.amazon.com/rds/) (currently we choose [MySQL v8](https://www.mysql.com/) as supported relational data store). You can also configure the control plane to use a self-managed MySQL server. +When installing the control plane, you have to choose one of the provided data store services. + +##### File Store + +`File store` is a storage for storing stage logs, application live states. This can be a fully-managed service such as GCP [GCS](https://cloud.google.com/storage), AWS [S3](https://aws.amazon.com/s3/), or a self-managed service such as [Minio](https://github.com/minio/minio). +When installing the control plane, you have to choose one of the provided file store services. diff --git a/docs/content/en/docs-v0.49.x/user-guide/managing-controlplane/auth.md b/docs/content/en/docs-v0.49.x/user-guide/managing-controlplane/auth.md new file mode 100644 index 0000000000..a86d9e5f79 --- /dev/null +++ b/docs/content/en/docs-v0.49.x/user-guide/managing-controlplane/auth.md @@ -0,0 +1,183 @@ +--- +title: "Authentication and authorization" +linkTitle: "Authentication and authorization" +weight: 3 +description: > + This page describes about PipeCD Authentication and Authorization. +--- + +![](/images/settings-project-v0.38.x.png) + +### Static Admin + +When the PipeCD owner [adds a new project](../adding-a-project/), an admin account will be automatically generated for the project. After that, PipeCD owner sends that static admin information including username, password strings to the project admin, who can use that information to log in to PipeCD web with the admin role. + +After logging, the project admin should change the provided username and password. Or disable the static admin account after configuring the single sign-on for the project. + +### Single Sign-On (SSO) + +Single sign-on (SSO) allows users to log in to PipeCD by relying on a trusted third-party service. + +**Supported service** + +- GitHub +- Generic OIDC + +> Note: In the future, we want to support such as Google Gmail, Bitbucket... + +#### Github + +Before configuring the SSO, you need an OAuth application of the using service. For example, GitHub SSO requires creating a GitHub OAuth application as described in this page: + +https://docs.github.com/en/developers/apps/creating-an-oauth-app + +The authorization callback URL should be `https://YOUR_PIPECD_ADDRESS/auth/callback`. + +![](/images/settings-update-sso.png) + +#### Generic OIDC + +PipeCD supports any OIDC provider, with tested providers including Keycloak, Auth0, and AWS Cognito. The only supported authentication flow currently is the Authorization Code Grant. + +Requirements: + +- The IdToken will be used to decide the user's role and username. +- The IdToken must contain information about the Username and Role. + - Supported Claims Key for Username (in order of priority): `username`, `preferred_username`,`name`, `cognito:username` + - Supported Claims Key for Role (in order of priority): `groups`, `roles`, `cognito:groups`, `custom:roles`, `custom:groups` + - Supported Claims Key for Avatar (in order of priority): `picture`, `avatar_url` + +Provider Configuration Examples: + +##### Keycloak + +- **Client authentication**: On +- **Valid redirect URIs**: `https://YOUR_PIPECD_ADDRESS/auth/callback` +- **Client scopes**: Add a new mapper to the `-dedicated` scope. For instance, map Group Membership information to the groups claim (Full group path should be off). + +- **Control Plane configuration**: + + ```yaml + apiVersion: "pipecd.dev/v1beta1" + kind: ControlPlane + spec: + sharedSSOConfigs: + - name: oidc + provider: OIDC + oidc: + clientId: + clientSecret: + issuer: https:///realms/ + redirect_uri: https:///auth/callback + scopes: + - openid + - profile + ``` + +##### Auth0 + +- **Allowed Callback URLs**: `https://YOUR_PIPECD_ADDRESS/auth/callback` +- **Control Plane configuration**: + + ```yaml + apiVersion: "pipecd.dev/v1beta1" + kind: ControlPlane + spec: + sharedSSOConfigs: + - name: oidc + provider: OIDC + oidc: + clientId: + clientSecret: + issuer: https:// + redirect_uri: https:///auth/callback + scopes: + - openid + - profile + ``` + +- **Roles/Groups Claims** + For Role or Groups information mapping using Auth0 Actions, here is an example for setting `custom:roles`: + + ```javascript + exports.onExecutePostLogin = async (event, api) => { + let namespace = "custom"; + if (namespace && !namespace.endsWith("/")) { + namespace += ":"; + } + api.idToken.setCustomClaim(namespace + "roles", event.authorization.roles); + }; + ``` + +##### AWS Cognito + +- **Allowed Callback URLs**: `https://YOUR_PIPECD_ADDRESS/auth/callback` + +- **Control Plane configuration**: + + ```yaml + apiVersion: "pipecd.dev/v1beta1" + kind: ControlPlane + spec: + sharedSSOConfigs: + - name: oidc + provider: OIDC + oidc: + clientId: + clientSecret: + issuer: https://cognito-idp..amazonaws.com/ + redirect_uri: https:///auth/callback + scopes: + - openid + - profile + ``` + +The project can be configured to use a shared SSO configuration (shared OAuth application) instead of needing a new one. In that case, while creating the project, the PipeCD owner specifies the name of the shared SSO configuration should be used, and then the project admin can skip configuring SSO at the settings page. + +### Role-Based Access Control (RBAC) + +Role-based access control (RBAC) allows restricting access on the PipeCD web-based on the roles of user groups within the project. Before using this feature, the SSO must be configured. + +PipeCD provides three built-in roles: + +- `Viewer`: has only permissions to view existing resources or data. +- `Editor`: has all viewer permissions, plus permissions for actions that modify state, such as manually syncing application, canceling deployment... +- `Admin`: has all editor permissions, plus permissions for updating project configurations. + +#### Configuring the PipeCD's roles + +The below table represents PipeCD's resources with actions on those resources. + +| resource | get | list | create | update | delete | +| :---------- | :-: | :--: | :----: | :----: | :----: | +| application | ○ | ○ | ○ | ○ | ○ | +| deployment | ○ | ○ | | ○ | | +| event | | ○ | | | | +| piped | ○ | ○ | ○ | ○ | | +| project | ○ | | | ○ | | +| apiKey | | ○ | ○ | ○ | | +| insight | ○ | | | | | + +Each role is defined as a combination of multiple policies under this format. + +``` +resources=RESOURCE_NAMES;actions=ACTION_NAMES +``` + +The `*` represents all resources and all actions for a resource. + +``` +resources=*;actions=ACTION_NAMES +resources=RESOURCE_NAMES;actions=* +resources=*;actions=* +``` + +#### Configuring the PipeCD's user groups + +User Group represents a relation with a specific team (GitHub)/group (Google) and an arbitrary role. All users belong to a team/group will have all permissions of that team/group. + +In case of using the GitHub team as a PipeCD user group, the PipeCD user group must be set in lowercase. For example, if your GitHub team is named `ORG/ABC-TEAM`, the PipeCD user group would be set as `ORG/abc-team`. (It's follow the GitHub team URL as github.com/orgs/{organization-name}/teams/{TEAM-NAME}) + +Note: You CANNOT assign multiple roles to a team/group, should create a new role with suitable permissions instead. + +![](/images/settings-add-user-group.png) diff --git a/docs/content/en/docs-v0.49.x/user-guide/managing-controlplane/configuration-reference.md b/docs/content/en/docs-v0.49.x/user-guide/managing-controlplane/configuration-reference.md new file mode 100644 index 0000000000..0554faa006 --- /dev/null +++ b/docs/content/en/docs-v0.49.x/user-guide/managing-controlplane/configuration-reference.md @@ -0,0 +1,176 @@ +--- +title: "Configuration reference" +linkTitle: "Configuration reference" +weight: 6 +description: > + This page describes all configurable fields in the Control Plane configuration. +--- + +``` yaml +apiVersion: pipecd.dev/v1beta1 +kind: ControlPlane +spec: + address: https://your-pipecd-address + ... +``` + +## Control Plane Configuration + +| Field | Type | Description | Required | +|-|-|-|-| +| stateKey | string | A randomly generated string used to sign oauth state. | Yes | +| datastore | [DataStore](#datastore) | Storage for storing application, deployment data. | Yes | +| filestore | [FileStore](#filestore) | File storage for storing deployment logs and application states. | Yes | +| cache | [Cache](#cache) | Internal cache configuration. | No | +| address | string | The address to the control plane. This is required if SSO is enabled. | No | +| insightCollector | [InsightCollector](#insightcollector) | Option to run collector of Insights feature. | No | +| sharedSSOConfigs | [][SharedSSOConfig](#sharedssoconfig) | List of shared SSO configurations that can be used by any projects. | No | +| projects | [][Project](#project) | List of debugging/quickstart projects. Please note that do not use this to configure the projects running in the production. | No | + +## DataStore + +| Field | Type | Description | Required | +|-|-|-|-| +| type | string | Which type of data store should be used. Can be one of the following values
`FIRESTORE`, `MYSQL`. | Yes | +| config | [DataStoreConfig](#datastoreconfig) | Specific configuration for the datastore type. This must be one of these DataStoreConfig. | Yes | + +## DataStoreConfig + +Must be one of the following objects: + +### DataStoreFireStoreConfig + +| Field | Type | Description | Required | +|-|-|-|-| +| namespace | string | The root path element considered as a logical namespace, e.g. `pipecd`. | Yes | +| environment | string | 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}`. | Yes | +| collectionNamePrefix | string | The prefix for collection name. This can be used to avoid conflicts with existing collections in your Firestore database. | No | +| project | string | The name of GCP project hosting the Firestore. | Yes | +| credentialsFile | string | The path to the service account file for accessing Firestores. | No | + + +### DataStoreMySQLConfig + +| Field | Type | Description | Required | +|-|-|-|-| +| url | string | The address to MySQL server. Should attach with the database port info as `127.0.0.1:3307` in case you want to use another port than the default value. | Yes | +| database | string | The name of database. | No (If you set it via URL) | +| usernameFile | string | Path to the file containing the username. | No | +| passwordFile | string | Path to the file containing the password. | No | + + +## FileStore + +| Field | Type | Description | Required | +|-|-|-|-| +| type | string | Which type of file store should be used. Can be one of the following values
`GCS`, `S3`, `MINIO` | Yes | +| config | [FileStoreConfig](#filestoreconfig) | Specific configuration for the filestore type. This must be one of these FileStoreConfig. | Yes | + +## FileStoreConfig + +Must be one of the following objects: + +### FileStoreGCSConfig + +| Field | Type | Description | Required | +|-|-|-|-| +| bucket | string | The bucket name. | Yes | +| credentialsFile | string | The path to the service account file for accessing GCS. | No | + +### FileStoreS3Config + +| Field | Type | Description | Required | +|-|-|-|-| +| bucket | string | The AWS S3 bucket name. | Yes | +| region | string | The AWS region name. | Yes | +| profile | string | The AWS profile name. Default value is `default`. | No | +| credentialsFile | string | The path to AWS credential file. Requires only if you want to auth by specified credential file, by default PipeCD will use `$HOME/.aws/credentials` file. | No | +| roleARN | string | The IAM role arn to use when assuming an role. Requires only if you want to auth by `WebIdentity` pattern. | No | +| tokenFile | string | The path to the WebIdentity token PipeCD should use to assume a role with. Requires only if you want to auth by `WebIdentity` pattern. | No | + +### FileStoreMinioConfig + +| Field | Type | Description | Required | +|-|-|-|-| +| endpoint | string | The address of Minio. | Yes | +| bucket | string | The bucket name. | Yes | +| accessKeyFile | string | The path to the access key file. | No | +| secretKeyFile | string | The path to the secret key file. | No | +| autoCreateBucket | bool | Whether the given bucket should be made automatically if not exists. | No | + +## Cache + +| Field | Type | Description | Required | +|-|-|-|-| +| ttl | duration | The time that in-memory cache items are stored before they are considered as stale. | Yes | + +## Project + +| Field | Type | Description | Required | +|-|-|-|-| +| id | string | The unique identifier of the project. | Yes | +| desc | string | The description about the project. | No | +| staticAdmin | [ProjectStaticUser](#projectstaticuser) | Static admin account of the project. | Yes | + +## ProjectStaticUser + +| Field | Type | Description | Required | +|-|-|-|-| +| username | string | The username string. | Yes | +| passwordHash | string | The bcrypt hashed value of the password string. | Yes | + +## InsightCollector + +| Field | Type | Description | Required | +|-|-|-|-| +| application | [InsightCollectorApplication](#insightcollectorapplication) | Application metrics collector. | No | +| deployment | [InsightCollectorDeployment](#insightcollectordeployment) | Deployment metrics collector. | No | + +## InsightCollectorApplication + +| Field | Type | Description | Required | +|-|-|-|-| +| enabled | bool | Whether to enable. Default is `true` | No | +| schedule | string | When collector will be executed. Default is `0 * * * *` | No | + +## InsightCollectorDeployment + +| Field | Type | Description | Required | +|-|-|-|-| +| enabled | bool | Whether to enable. Default is `true` | No | +| schedule | string | When collector will be executed. Default is `30 * * * *` | No | +| chunkMaxCount | int | The maximum number of deployment items could be stored in a chunk. Default is `1000` | No | + +## SharedSSOConfig + +| Field | Type | Description | Required | +|-|-|-|-| +| name | string | The unique name of the configuration. | Yes | +| provider | string | The SSO service provider. Currently, only `GITHUB` and `OIDC` is supported. | Yes | +| sessionTtl | int | The time to live of session for SSO login. Unit is `hour`. Default is 7 * 24 hours. | No | +| github | [SSOConfigGitHub](#ssoconfiggithub) | GitHub sso configuration. | No | +| oidc | [SSOConfigOIDC](#ssoconfigoidc) | OIDC sso configuration. | No | + +## SSOConfigGitHub + +| Field | Type | Description | Required | +|-|-|-|-| +| clientId | string | The client id string of GitHub oauth app. | Yes | +| clientSecret | string | The client secret string of GitHub oauth app. | Yes | +| baseUrl | string | The address of GitHub service. Required if enterprise. | No | +| uploadUrl | string | The upload url of GitHub service. | No | +| proxyUrl | string | The address of the proxy used while communicating with the GitHub service. | No | + +## SSOConfigOIDC + +| Field | Type | Description | Required | +|-|-|-|-| +| clientId | string | The client id string of OpenID Connect oauth app. | Yes | +| clientSecret | string | The client secret string of OpenID Connect oauth app. | Yes | +| issuer | string | The address of OpenID Connect service. | Yes | +| redirectUri | string | The address of the redirect URI. | Yes | +| authorizationEndpoint | string | The address of the authorization endpoint. | No | +| tokenEndpoint | string | The address of the token endpoint. | No | +| userInfoEndpoint | string | The address of the user info endpoint. | No | +| proxyUrl | string | The address of the proxy used while communicating with the OpenID Connect service. | No | +| scopes | []string | Scopes to request from the OpenID Connect service. Default is `openid`. Some providers may require other scopes. | No | diff --git a/docs/content/en/docs-v0.49.x/user-guide/managing-controlplane/registering-a-piped.md b/docs/content/en/docs-v0.49.x/user-guide/managing-controlplane/registering-a-piped.md new file mode 100644 index 0000000000..9719f26f8d --- /dev/null +++ b/docs/content/en/docs-v0.49.x/user-guide/managing-controlplane/registering-a-piped.md @@ -0,0 +1,16 @@ +--- +title: "Registering a piped" +linkTitle: "Registering a piped" +weight: 4 +description: > + This page describes how to register a new piped to a project. +--- + +The list of pipeds are shown in the Settings page. Anyone who has the project admin role can register a new piped by clicking on the `+ADD` button. + +
+ +![](/images/settings-add-piped.png) +

+Registering a new piped +

diff --git a/docs/content/en/docs-v0.49.x/user-guide/managing-piped/_index.md b/docs/content/en/docs-v0.49.x/user-guide/managing-piped/_index.md new file mode 100644 index 0000000000..8ce33fc697 --- /dev/null +++ b/docs/content/en/docs-v0.49.x/user-guide/managing-piped/_index.md @@ -0,0 +1,11 @@ +--- +title: "Managing Piped" +linkTitle: "Managing Piped" +weight: 3 +description: > + This guide is for administrators and operators wanting to install and configure piped for other developers. +--- + +In order to use Piped you need to register through PipeCD control plane, so please refer [register a Piped docs](../managing-controlplane/registering-a-piped/) if you do not have already. After registering successfully, you can monitor your Piped live state via the PipeCD console on the settings page. + +![piped-list-page](/images/piped-list-page.png) diff --git a/docs/content/en/docs-v0.49.x/user-guide/managing-piped/adding-a-cloud-provider.md b/docs/content/en/docs-v0.49.x/user-guide/managing-piped/adding-a-cloud-provider.md new file mode 100644 index 0000000000..e05aad45af --- /dev/null +++ b/docs/content/en/docs-v0.49.x/user-guide/managing-piped/adding-a-cloud-provider.md @@ -0,0 +1,134 @@ +--- +title: "Adding a cloud provider" +linkTitle: "Adding cloud provider" +weight: 3 +description: > + This page describes how to add a cloud provider to enable its applications. +--- + +> NOTE: Starting from version v0.35.0, the CloudProvider concept is being replaced by PlatformProvider. It's a name change due to the PipeCD vision improvement. __The CloudProvider configuration is marked as deprecated, please migrate your piped agent configuration to use PlatformProvider__. + +PipeCD supports multiple clouds and multiple application kinds. +Cloud provider defines which cloud and where the application should be deployed to. +So while registering a new application, the name of a configured cloud provider is required. + +Currently, PipeCD is supporting these five kinds of cloud providers: `KUBERNETES`, `ECS`, `TERRAFORM`, `CLOUDRUN`, `LAMBDA`. +A new cloud provider can be enabled by adding a [CloudProvider](../configuration-reference/#cloudprovider) struct to the piped configuration file. +A piped can have one or multiple cloud provider instances from the same or different cloud provider kind. + +The next sections show the specific configuration for each kind of cloud provider. + +### Configuring Kubernetes cloud provider + +By default, piped deploys Kubernetes application to the cluster where the piped is running in. An external cluster can be connected by specifying the `masterURL` and `kubeConfigPath` in the [configuration](../configuration-reference/#cloudproviderkubernetesconfig). + +And, the default resources (defined at [here](https://github.com/pipe-cd/pipecd/blob/master/pkg/app/piped/platformprovider/kubernetes/resourcekey.go)) from all namespaces of the Kubernetes cluster will be watched for rendering the application state in realtime and detecting the configuration drift. In case you want to restrict piped to watch only a single namespace, let specify the namespace in the [KubernetesAppStateInformer](../configuration-reference/#kubernetesappstateinformer) field. You can also add other resources or exclude resources to/from the watching targets by that field. + +Below configuration snippet just specifies a name and type of cloud provider. It means the cloud provider `kubernetes-dev` will connect to the Kubernetes cluster where the piped is running in, and this cloud provider watches all of the predefined resources from all namespaces inside that cluster. + +``` yaml +apiVersion: pipecd.dev/v1beta1 +kind: Piped +spec: + ... + cloudProviders: + - name: kubernetes-dev + type: KUBERNETES +``` + +See [ConfigurationReference](../configuration-reference/#cloudproviderkubernetesconfig) for the full configuration. + +### Configuring Terraform cloud provider + +A terraform cloud provider contains a list of shared terraform variables that will be applied while running the deployment of its applications. + +``` yaml +apiVersion: pipecd.dev/v1beta1 +kind: Piped +spec: + ... + cloudProviders: + - name: terraform-dev + type: TERRAFORM + config: + vars: + - "project=pipecd" +``` + +See [ConfigurationReference](../configuration-reference/#cloudproviderterraformconfig) for the full configuration. + +### Configuring Cloud Run cloud provider + +Adding a Cloud Run provider requires the name of the Google Cloud project and the region name where Cloud Run service is running. A service account file for accessing to Cloud Run is also required if the machine running the piped does not have enough permissions to access. + +``` yaml +apiVersion: pipecd.dev/v1beta1 +kind: Piped +spec: + ... + cloudProviders: + - name: cloudrun-dev + type: CLOUDRUN + config: + project: {GCP_PROJECT} + region: {CLOUDRUN_REGION} + credentialsFile: {PATH_TO_THE_SERVICE_ACCOUNT_FILE} +``` + +See [ConfigurationReference](../configuration-reference/#cloudprovidercloudrunconfig) for the full configuration. + +### Configuring Lambda cloud provider + +Adding a Lambda provider requires the region name where Lambda service is running. + +```yaml +apiVersion: pipecd.dev/v1beta1 +kind: Piped +spec: + ... + cloudProviders: + - name: lambda-dev + type: LAMBDA + config: + region: {LAMBDA_REGION} + profile: default + credentialsFile: {PATH_TO_THE_CREDENTIAL_FILE} +``` + +You will generally need your AWS credentials to authenticate with Lambda. Piped provides multiple methods of loading these credentials. +It attempts to retrieve credentials in the following order: +1. From the environment variables. Available environment variables are `AWS_ACCESS_KEY_ID` or `AWS_ACCESS_KEY` and `AWS_SECRET_ACCESS_KEY` or `AWS_SECRET_KEY`. +2. From the given credentials file. (the `credentialsFile field in above sample`) +3. From the pod running in EKS cluster via STS (SecurityTokenService). +4. From the EC2 Instance Role. + +Therefore, you don't have to set credentialsFile if you use the environment variables or the EC2 Instance Role. Keep in mind the IAM role/user that you use with your Piped must possess the IAM policy permission for at least `Lambda.Function` and `Lambda.Alias` resources controll (list/read/write). + +See [ConfigurationReference](../configuration-reference/#cloudproviderlambdaconfig) for the full configuration. + +### Configuring ECS cloud provider + +Adding a ECS provider requires the region name where ECS cluster is running. + +```yaml +apiVersion: pipecd.dev/v1beta1 +kind: Piped +spec: + ... + cloudProviders: + - name: ecs-dev + type: ECS + config: + region: {ECS_CLUSTER_REGION} + profile: default + credentialsFile: {PATH_TO_THE_CREDENTIAL_FILE} +``` + +Just same as Lambda cloud provider, there are several ways to authorize Piped agent to enable it performs deployment jobs. +It attempts to retrieve credentials in the following order: +1. From the environment variables. Available environment variables are `AWS_ACCESS_KEY_ID` or `AWS_ACCESS_KEY` and `AWS_SECRET_ACCESS_KEY` or `AWS_SECRET_KEY`. +2. From the given credentials file. (the `credentialsFile field in above sample`) +3. From the pod running in EKS cluster via STS (SecurityTokenService). +4. From the EC2 Instance Role. + +See [ConfigurationReference](../configuration-reference/#cloudproviderecsconfig) for the full configuration. diff --git a/docs/content/en/docs-v0.49.x/user-guide/managing-piped/adding-a-git-repository.md b/docs/content/en/docs-v0.49.x/user-guide/managing-piped/adding-a-git-repository.md new file mode 100644 index 0000000000..97bf68b200 --- /dev/null +++ b/docs/content/en/docs-v0.49.x/user-guide/managing-piped/adding-a-git-repository.md @@ -0,0 +1,41 @@ +--- +title: "Adding a git repository" +linkTitle: "Adding git repository" +weight: 2 +description: > + This page describes how to add a new Git repository. +--- + +In the `piped` configuration file, we specify the list of Git repositories should be handled by the `piped`. +A Git repository contains one or more deployable applications where each application is put inside a directory called as [application directory](../../../concepts/#application-directory). +That directory contains an application configuration file as well as application manifests. +The `piped` periodically checks the new commits and fetches the needed manifests from those repositories for executing the deployment. + +A single `piped` can be configured to handle one or more Git repositories. +In order to enable a new Git repository, let's add a new [GitRepository](../configuration-reference/#gitrepository) block to the `repositories` field in the `piped` configuration file. + +For example, with the following snippet, `piped` will take the `master` branch of [pipe-cd/examples](https://github.com/pipe-cd/examples) repository as a target Git repository for doing deployments. + +``` yaml +apiVersion: pipecd.dev/v1beta1 +kind: Piped +spec: + ... + repositories: + - repoId: examples + remote: git@github.com:pipe-cd/examples.git + branch: master +``` + +In most of the cases, we want to deal with private Git repositories. For accessing those private repositories, `piped` needs a private SSH key, which can be configured while [installing](../../../installation/install-piped/installing-on-kubernetes/) with `secret.sshKey` in the Helm chart. + +``` console +helm install dev-piped pipecd/piped --version={VERSION} \ + --set-file config.data={PATH_TO_PIPED_CONFIG_FILE} \ + --set-file secret.data.piped-key={PATH_TO_PIPED_KEY_FILE} \ + --set-file secret.data.ssh-key={PATH_TO_PRIVATE_SSH_KEY_FILE} +``` + +You can see this [configuration reference](../configuration-reference/#git) for more configurable fields about Git commands. + +Currently, `piped` allows configuring only one private SSH key for all specified Git repositories. So you can configure the same SSH key for all of those private repositories, or break them into separate `piped`s. In the near future, we also want to update `piped` to support loading multiple SSH keys. diff --git a/docs/content/en/docs-v0.49.x/user-guide/managing-piped/adding-a-platform-provider.md b/docs/content/en/docs-v0.49.x/user-guide/managing-piped/adding-a-platform-provider.md new file mode 100644 index 0000000000..d231c26e38 --- /dev/null +++ b/docs/content/en/docs-v0.49.x/user-guide/managing-piped/adding-a-platform-provider.md @@ -0,0 +1,132 @@ +--- +title: "Adding a platform provider" +linkTitle: "Adding platform provider" +weight: 4 +description: > + This page describes how to add a platform provider to enable its applications. +--- + +PipeCD supports multiple platforms and multiple application kinds which run on those platforms. +Platform provider defines which platform and where the application should be deployed to. +So while registering a new application, the name of a configured platform provider is required. + +Currently, PipeCD is supporting these five kinds of platform providers: `KUBERNETES`, `ECS`, `TERRAFORM`, `CLOUDRUN`, `LAMBDA`. +A new platform provider can be enabled by adding a [PlatformProvider](../configuration-reference/#platformprovider) struct to the piped configuration file. +A piped can have one or multiple platform provider instances from the same or different platform provider kind. + +The next sections show the specific configuration for each kind of platform provider. + +### Configuring Kubernetes platform provider + +By default, piped deploys Kubernetes application to the cluster where the piped is running in. An external cluster can be connected by specifying the `masterURL` and `kubeConfigPath` in the [configuration](../configuration-reference/#platformproviderkubernetesconfig). + +And, the default resources (defined at [here](https://github.com/pipe-cd/pipecd/blob/master/pkg/app/piped/platformprovider/kubernetes/resourcekey.go)) from all namespaces of the Kubernetes cluster will be watched for rendering the application state in realtime and detecting the configuration drift. In case you want to restrict piped to watch only a single namespace, let specify the namespace in the [KubernetesAppStateInformer](../configuration-reference/#kubernetesappstateinformer) field. You can also add other resources or exclude resources to/from the watching targets by that field. + +Below configuration snippet just specifies a name and type of platform provider. It means the platform provider `kubernetes-dev` will connect to the Kubernetes cluster where the piped is running in, and this platform provider watches all of the predefined resources from all namespaces inside that cluster. + +``` yaml +apiVersion: pipecd.dev/v1beta1 +kind: Piped +spec: + ... + platformProviders: + - name: kubernetes-dev + type: KUBERNETES +``` + +See [ConfigurationReference](../configuration-reference/#platformproviderkubernetesconfig) for the full configuration. + +### Configuring Terraform platform provider + +A terraform platform provider contains a list of shared terraform variables that will be applied while running the deployment of its applications. + +``` yaml +apiVersion: pipecd.dev/v1beta1 +kind: Piped +spec: + ... + platformProviders: + - name: terraform-dev + type: TERRAFORM + config: + vars: + - "project=pipecd" +``` + +See [ConfigurationReference](../configuration-reference/#platformproviderterraformconfig) for the full configuration. + +### Configuring Cloud Run platform provider + +Adding a Cloud Run provider requires the name of the Google Cloud project and the region name where Cloud Run service is running. A service account file for accessing to Cloud Run is also required if the machine running the piped does not have enough permissions to access. + +``` yaml +apiVersion: pipecd.dev/v1beta1 +kind: Piped +spec: + ... + platformProviders: + - name: cloudrun-dev + type: CLOUDRUN + config: + project: {GCP_PROJECT} + region: {CLOUDRUN_REGION} + credentialsFile: {PATH_TO_THE_SERVICE_ACCOUNT_FILE} +``` + +See [ConfigurationReference](../configuration-reference/#platformprovidercloudrunconfig) for the full configuration. + +### Configuring Lambda platform provider + +Adding a Lambda provider requires the region name where Lambda service is running. + +```yaml +apiVersion: pipecd.dev/v1beta1 +kind: Piped +spec: + ... + platformProviders: + - name: lambda-dev + type: LAMBDA + config: + region: {LAMBDA_REGION} + profile: default + credentialsFile: {PATH_TO_THE_CREDENTIAL_FILE} +``` + +You will generally need your AWS credentials to authenticate with Lambda. Piped provides multiple methods of loading these credentials. +It attempts to retrieve credentials in the following order: +1. From the environment variables. Available environment variables are `AWS_ACCESS_KEY_ID` or `AWS_ACCESS_KEY` and `AWS_SECRET_ACCESS_KEY` or `AWS_SECRET_KEY`. +2. From the given credentials file. (the `credentialsFile field in above sample`) +3. From the pod running in EKS cluster via STS (SecurityTokenService). +4. From the EC2 Instance Role. + +Therefore, you don't have to set credentialsFile if you use the environment variables or the EC2 Instance Role. Keep in mind the IAM role/user that you use with your Piped must possess the IAM policy permission for at least `Lambda.Function` and `Lambda.Alias` resources controll (list/read/write). + +See [ConfigurationReference](../configuration-reference/#platformproviderlambdaconfig) for the full configuration. + +### Configuring ECS platform provider + +Adding a ECS provider requires the region name where ECS cluster is running. + +```yaml +apiVersion: pipecd.dev/v1beta1 +kind: Piped +spec: + ... + platformProviders: + - name: ecs-dev + type: ECS + config: + region: {ECS_CLUSTER_REGION} + profile: default + credentialsFile: {PATH_TO_THE_CREDENTIAL_FILE} +``` + +Just same as Lambda platform provider, there are several ways to authorize Piped agent to enable it performs deployment jobs. +It attempts to retrieve credentials in the following order: +1. From the environment variables. Available environment variables are `AWS_ACCESS_KEY_ID` or `AWS_ACCESS_KEY` and `AWS_SECRET_ACCESS_KEY` or `AWS_SECRET_KEY`. +2. From the given credentials file. (the `credentialsFile field in above sample`) +3. From the pod running in EKS cluster via STS (SecurityTokenService). +4. From the EC2 Instance Role. + +See [ConfigurationReference](../configuration-reference/#platformproviderecsconfig) for the full configuration. diff --git a/docs/content/en/docs-v0.49.x/user-guide/managing-piped/adding-an-analysis-provider.md b/docs/content/en/docs-v0.49.x/user-guide/managing-piped/adding-an-analysis-provider.md new file mode 100644 index 0000000000..cc87d3a416 --- /dev/null +++ b/docs/content/en/docs-v0.49.x/user-guide/managing-piped/adding-an-analysis-provider.md @@ -0,0 +1,55 @@ +--- +title: "Adding an analysis provider" +linkTitle: "Adding analysis provider" +weight: 6 +description: > + This page describes how to add an analysis provider for doing deployment analysis. +--- + +To enable [Automated deployment analysis](../../managing-application/customizing-deployment/automated-deployment-analysis/) feature, you have to set the needed information for Piped to connect to the [Analysis Provider](../../../concepts/#analysis-provider). + +Currently, PipeCD supports the following providers: +- [Prometheus](https://prometheus.io/) +- [Datadog](https://datadoghq.com/) + + +## Prometheus +Piped queries the [range query endpoint](https://prometheus.io/docs/prometheus/latest/querying/api/#range-queries) to obtain metrics used to evaluate the deployment. + +You need to define the Prometheus server address accessible to Piped. + +```yaml +apiVersion: pipecd.dev/v1beta1 +kind: Piped +spec: + analysisProviders: + - name: prometheus-dev + type: PROMETHEUS + config: + address: https://your-prometheus.dev +``` +The full list of configurable fields are [here](../configuration-reference/#analysisproviderprometheusconfig). + +## Datadog +Piped queries the [MetricsApi.QueryMetrics](https://docs.datadoghq.com/api/latest/metrics/#query-timeseries-points) endpoint to obtain metrics used to evaluate the deployment. + +```yaml +apiVersion: pipecd.dev/v1beta1 +kind: Piped +spec: + analysisProviders: + - name: datadog-dev + type: DATADOG + config: + apiKeyFile: /etc/piped-secret/datadog-api-key + applicationKeyFile: /etc/piped-secret/datadog-application-key +``` + +The full list of configurable fields are [here](../configuration-reference/#analysisproviderdatadogconfig). + +If you choose `Helm` as the installation method, we recommend using `--set-file` to mount the key files while performing the [upgrading process](../../../installation/install-piped/installing-on-kubernetes/#in-the-cluster-wide-mode). + +```console +--set-file secret.data.datadog-api-key={PATH_TO_API_KEY_FILE} \ +--set-file secret.data.datadog-application-key={PATH_TO_APPLICATION_KEY_FILE} +``` diff --git a/docs/content/en/docs-v0.49.x/user-guide/managing-piped/adding-helm-chart-repository-or-registry.md b/docs/content/en/docs-v0.49.x/user-guide/managing-piped/adding-helm-chart-repository-or-registry.md new file mode 100644 index 0000000000..79581d2d65 --- /dev/null +++ b/docs/content/en/docs-v0.49.x/user-guide/managing-piped/adding-helm-chart-repository-or-registry.md @@ -0,0 +1,60 @@ +--- +title: "Adding a Helm chart repository or registry" +linkTitle: "Adding Helm chart repo or registry" +weight: 5 +description: > + This page describes how to add a new Helm chart repository or registry. +--- + +PipeCD supports Kubernetes applications that are using Helm for templating and packaging. In addition to being able to deploy a Helm chart that is sourced from the same Git repository (`local chart`) or from a different Git repository (`remote git chart`), an application can use a chart sourced from a Helm chart repository. + +### Adding Helm chart repository + +A Helm [chart repository](https://helm.sh/docs/topics/chart_repository/) is a location backed by an HTTP server where packaged charts can be stored and shared. Before an application can be configured to use a chart from a Helm chart repository, that chart repository must be enabled in the related `piped` by adding the [ChartRepository](../configuration-reference/#chartrepository) struct to the piped configuration file. + +``` yaml +# piped configuration file +apiVersion: pipecd.dev/v1beta1 +kind: Piped +spec: + ... + chartRepositories: + - name: pipecd + address: https://charts.pipecd.dev +``` + +For example, the above snippet enables the official chart repository of PipeCD project. After that, you can configure the Kubernetes application to load a chart from that chart repository for executing the deployment. + +``` yaml +# Application configuration file. +apiVersion: pipecd.dev/v1beta1 +kind: KubernetesApp +spec: + input: + # Helm chart sourced from a Helm Chart Repository. + helmChart: + repository: pipecd + name: helloworld + version: v0.5.0 +``` + +In case the chart repository is backed by HTTP basic authentication, the username and password strings are required in [configuration](../configuration-reference/#chartrepository). + +### Adding Helm chart registry + +A Helm chart [registry](https://helm.sh/docs/topics/registries/) is a mechanism enabled by default in Helm 3.8.0 and later that allows the OCI registry to be used for storage and distribution of Helm charts. + +Before an application can be configured to use a chart from a registry, that registry must be enabled in the related `piped` by adding the [ChartRegistry](../configuration-reference/#chartregistry) struct to the piped configuration file if authentication is enabled at the registry. + +``` yaml +# piped configuration file +apiVersion: pipecd.dev/v1beta1 +kind: Piped +spec: + ... + chartRegistries: + - type: OCI + address: registry.example.com + username: sample-username + password: sample-password +``` diff --git a/docs/content/en/docs-v0.49.x/user-guide/managing-piped/configuration-reference.md b/docs/content/en/docs-v0.49.x/user-guide/managing-piped/configuration-reference.md new file mode 100644 index 0000000000..c65dcf1352 --- /dev/null +++ b/docs/content/en/docs-v0.49.x/user-guide/managing-piped/configuration-reference.md @@ -0,0 +1,277 @@ +--- +title: "Configuration reference" +linkTitle: "Configuration reference" +weight: 9 +description: > + This page describes all configurable fields in the piped configuration. +--- + +``` yaml +apiVersion: pipecd.dev/v1beta1 +kind: Piped +spec: + projectID: ... + pipedID: ... + ... +``` + +## Piped Configuration + +| Field | Type | Description | Required | +|-|-|-|-| +| projectID | string | The identifier of the PipeCD project where this piped belongs to. | Yes | +| pipedID | string | The generated ID for this piped. | Yes | +| pipedKeyFile | string | The path to the file containing the generated key string for this piped. | Yes | +| pipedKeyData | string | Base64 encoded string of Piped key. Either pipedKeyFile or pipedKeyData must be set. | Yes | +| apiAddress | string | The address used to connect to the Control Plane's API in format `host:port`. | Yes | +| syncInterval | duration | How often to check whether an application should be synced. Default is `1m`. | No | +| appConfigSyncInterval | duration | How often to check whether application configuration files should be synced. Default is `1m`. | No | +| git | [Git](#git) | Git configuration needed for Git commands. | No | +| repositories | [][Repository](#gitrepository) | List of Git repositories this piped will handle. | No | +| chartRepositories | [][ChartRepository](#chartrepository) | List of Helm chart repositories that should be added while starting up. | No | +| chartRegistries | [][ChartRegistry](#chartregistry) | List of helm chart registries that should be logged in while starting up. | No | +| cloudProviders | [][CloudProvider](#cloudprovider) | List of cloud providers can be used by this piped. This field is deprecated, use `platformProviders` instead. | No | +| platformProviders | [][PlatformProvider](#platformprovider) | List of platform providers can be used by this piped. | No | +| analysisProviders | [][AnalysisProvider](#analysisprovider) | List of analysis providers can be used by this piped. | No | +| eventWatcher | [EventWatcher](#eventwatcher) | Optional Event watcher settings. | No | +| secretManagement | [SecretManagement](#secretmanagement) | The using secret management method. | No | +| notifications | [Notifications](#notifications) | Sending notifications to Slack, Webhook... | No | +| appSelector | map[string]string | List of labels to filter all applications this piped will handle. Currently, it is only be used to filter the applications suggested for adding from the control plane. | No | + +## Git + +| Field | Type | Description | Required | +|-|-|-|-| +| username | string | The username that will be configured for `git` user. Default is `piped`. | No | +| email | string | The email that will be configured for `git` user. Default is `pipecd.dev@gmail.com`. | No | +| sshConfigFilePath | string | Where to write ssh config file. Default is `$HOME/.ssh/config`. | No | +| host | string | The host name. Default is `github.com`. | No | +| hostName | string | The hostname or IP address of the remote git server. Default is the same value with Host. | No | +| sshKeyFile | string | The path to the private ssh key file. This will be used to clone the source code of the specified git repositories. | No | +| sshKeyData | string | Base64 encoded string of SSH key. | No | +| password | string | The base64 encoded password for git used while cloning above Git repository. | No | + +## GitRepository + +| Field | Type | Description | Required | +|-|-|-|-| +| repoID | string | Unique identifier to the repository. This must be unique in the piped scope. | Yes | +| remote | string | Remote address of the repository used to clone the source code. e.g. `git@github.com:org/repo.git` | Yes | +| branch | string | The branch will be handled. | Yes | + +## ChartRepository + +| Field | Type | Description | Required | +|-|-|-|-| +| type | string | The repository type. Currently, HTTP and GIT are supported. Default is HTTP. | No | +| name | string | The name of the Helm chart repository. Note that is not a Git repository but a [Helm chart repository](https://helm.sh/docs/topics/chart_repository/). | Yes if type is HTTP | +| address | string | The address to the Helm chart repository. | Yes if type is HTTP | +| username | string | Username used for the repository backed by HTTP basic authentication. | No | +| password | string | Password used for the repository backed by HTTP basic authentication. | No | +| insecure | bool | Whether to skip TLS certificate checks for the repository or not. | No | +| gitRemote | string | Remote address of the Git repository used to clone Helm charts. | Yes if type is GIT | +| sshKeyFile | string | The path to the private ssh key file used while cloning Helm charts from above Git repository. | No | + +## ChartRegistry + +| Field | Type | Description | Required | +|-|-|-|-| +| type | string | The registry type. Currently, only OCI is supported. Default is OCI. | No | +| address | string | The address to the registry. | Yes | +| username | string | Username used for the registry authentication. | No | +| password | string | Password used for the registry authentication. | No | + +## CloudProvider + +This field is deprecated, please use [PlatformProvider](#platformprovider) instead. + +## PlatformProvider + +| Field | Type | Description | Required | +|-|-|-|-| +| name | string | The name of the platform provider. | Yes | +| type | string | The platform provider type. Must be one of the following values:
`KUBERNETES`, `TERRAFORM`, `ECS`, `CLOUDRUN`, `LAMBDA`. | Yes | +| config | [PlatformProviderConfig](#platformproviderconfig) | Specific configuration for the specified type of platform provider. | No | + +## PlatformProviderConfig + +Must be one of the following structs: + +### PlatformProviderKubernetesConfig + +| Field | Type | Description | Required | +|-|-|-|-| +| masterURL | string | The master URL of the kubernetes cluster. Empty means in-cluster. | No | +| kubectlVersion | string | Version of kubectl which will be used to connect to your cluster. Empty means the version set on [piped config](../user-guide/managing-piped/configuration-reference/#platformproviderkubernetesconfig) or [default version](https://github.com/pipe-cd/pipecd/blob/master/tool/piped-base/install-kubectl.sh#L24) will be used. | No | +| kubeConfigPath | string | The path to the kubeconfig file. Empty means in-cluster. | No | +| appStateInformer | [KubernetesAppStateInformer](#kubernetesappstateinformer) | Configuration for application resource informer. | No | + +### PlatformProviderTerraformConfig + +| Field | Type | Description | Required | +|-|-|-|-| +| vars | []string | List of variables that will be set directly on terraform commands with `-var` flag. The variable must be formatted by `key=value`. | No | +| driftDetectionEnabled | bool | Enable drift detection. This is a temporary option and will be possibly removed in the future release. Default is `true` | No | + +### PlatformProviderCloudRunConfig + +| Field | Type | Description | Required | +|-|-|-|-| +| project | string | The GCP project hosting the Cloud Run service. | Yes | +| region | string | The region of running Cloud Run service. | Yes | +| credentialsFile | string | The path to the service account file for accessing Cloud Run service. | No | + +### PlatformProviderLambdaConfig + +| Field | Type | Description | Required | +|-|-|-|-| +| region | string | The region of running Lambda service. | Yes | +| credentialsFile | string | The path to the credential file for logging into AWS cluster. If this value is not provided, piped will read credential info from environment variables. It expects the format [~/.aws/credentials](https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-files.html). | No | +| roleARN | string | The IAM role arn to use when assuming an role. Required if you want to use the AWS SecurityTokenService. | No | +| tokenFile | string | The path to the WebIdentity token the SDK should use to assume a role with. Required if you want to use the AWS SecurityTokenService. | No | +| profile | string | The profile to use for logging into AWS cluster. The default value is `default`. | No | + +### PlatformProviderECSConfig + +| Field | Type | Description | Required | +|-|-|-|-| +| region | string | The region of running ECS cluster. | Yes | +| credentialsFile | string | The path to the credential file for logging into AWS cluster. If this value is not provided, piped will read credential info from environment variables. It expects the format [~/.aws/credentials](https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-files.html) | No | +| roleARN | string | The IAM role arn to use when assuming an role. Required if you want to use the AWS SecurityTokenService. | No | +| tokenFile | string | The path to the WebIdentity token the SDK should use to assume a role with. Required if you want to use the AWS SecurityTokenService. | No | +| profile | string | The profile to use for logging into AWS cluster. The default value is `default`. | No | + +## KubernetesAppStateInformer + +| Field | Type | Description | Required | +|-|-|-|-| +| namespace | string | Only watches the specified namespace. Empty means watching all namespaces. | No | +| includeResources | [][KubernetesResourcematcher](#kubernetesresourcematcher) | List of resources that should be added to the watching targets. | No | +| excludeResources | [][KubernetesResourcematcher](#kubernetesresourcematcher) | List of resources that should be ignored from the watching targets. | No | + +### KubernetesResourceMatcher + +| Field | Type | Description | Required | +|-|-|-|-| +| apiVersion | string | The APIVersion of the kubernetes resource. | Yes | +| kind | string | The kind name of the kubernetes resource. Empty means all kinds are matching. | No | + +## AnalysisProvider + +| Field | Type | Description | Required | +|-|-|-|-| +| name | string | The unique name of the analysis provider. | Yes | +| type | string | The provider type. Currently, only PROMETHEUS, DATADOG are available. | Yes | +| config | [AnalysisProviderConfig](#analysisproviderconfig) | Specific configuration for the specified type of analysis provider. | Yes | + +## AnalysisProviderConfig + +Must be one of the following structs: + +### AnalysisProviderPrometheusConfig +| Field | Type | Description | Required | +|-|-|-|-| +| address | string | The Prometheus server address. | Yes | +| usernameFile | string | The path to the username file. | No | +| passwordFile | string | The path to the password file. | No | + +### AnalysisProviderDatadogConfig +| Field | Type | Description | Required | +|-|-|-|-| +| address | string | The address of Datadog API server. Only "datadoghq.com", "us3.datadoghq.com", "datadoghq.eu", "ddog-gov.com" are available. Defaults to "datadoghq.com" | No | +| apiKeyFile | string | The path to the api key file. | Yes | +| applicationKeyFile | string | The path to the application key file. | Yes | +| apiKeyData | string | Base64 API Key for Datadog API server. Either apiKeyData or apiKeyFile must be set | No | +| applicationKeyData | string | Base64 Application Key for Datadog API server. Either applicationKeyFile or applicationKeyData must be set | No | + +## EventWatcher + +| Field | Type | Description | Required | +|-|-|-|-| +| checkInterval | duration | Interval to fetch the latest event and compare it with one defined in EventWatcher config files. Defaults to `1m`. | No | +| gitRepos | [][EventWatcherGitRepo](#eventwatchergitrepo) | The configuration list of git repositories to be observed. Only the repositories in this list will be observed by Piped. | No | + +### EventWatcherGitRepo + +| Field | Type | Description | Required | +|-|-|-|-| +| repoId | string | Id of the git repository. This must be unique within the repos' elements. | Yes | +| commitMessage | string | The commit message used to push after replacing values. Default message is used if not given. | No | +| includes | []string | The paths to EventWatcher files to be included. Patterns can be used like `foo/*.yaml`. | No | +| excludes | []string | The paths to EventWatcher files to be excluded. Patterns can be used like `foo/*.yaml`. This is prioritized if both includes and this are given. | No | + +## SecretManagement + +| Field | Type | Description | Required | +|-|-|-|-| +| type | string | Which management method should be used. Default is `KEY_PAIR`. | Yes | +| config | [SecretManagementConfig](#secretmanagementconfig) | Configration for using secret management method. | Yes | + +## SecretManagementConfig + +Must be one of the following structs: + +### SecretManagementKeyPair + +| Field | Type | Description | Required | +|-|-|-|-| +| privateKeyFile | string | Path to the private RSA key file. | Yes | +| privateKeyData | string | Base64 encoded string of private RSA key. Either privateKeyFile or privateKeyData must be set. | No | +| publicKeyFile | string | Path to the public RSA key file. | Yes | +| publicKeyData | string | Base64 encoded string of public RSA key. Either publicKeyFile or publicKeyData must be set. | No | + +### SecretManagementGCPKMS + +> WIP + +## Notifications + +| Field | Type | Description | Required | +|-|-|-|-| +| routes | [][NotificationRoute](#notificationroute) | List of notification routes. | No | +| receivers | [][NotificationReceiver](#notificationreceiver) | List of notification receivers. | No | + +### NotificationRoute + +| Field | Type | Description | Required | +|-|-|-|-| +| name | string | The name of the route. | Yes | +| receiver | string | The name of receiver who will receive all matched events. | Yes | +| events | []string | List of events that should be routed to the receiver. | No | +| ignoreEvents | []string | List of events that should be ignored. | No | +| groups | []string | List of event groups should be routed to the receiver. | No | +| ignoreGroups | []string | List of event groups should be ignored. | No | +| apps | []string | List of applications where their events should be routed to the receiver. | No | +| ignoreApps | []string | List of applications where their events should be ignored. | No | +| labels | map[string]string | List of labels where their events should be routed to the receiver. | No | +| ignoreLabels | map[string]string | List of labels where their events should be ignored. | No | + + +### NotificationReceiver + +| Field | Type | Description | Required | +|-|-|-|-| +| name | string | The name of the receiver. | Yes | +| slack | [NotificationReciverSlack](#notificationreceiverslack) | Configuration for slack receiver. | No | +| webhook | [NotificationReceiverWebhook](#notificationreceiverwebhook) | Configuration for webhook receiver. | No | + +#### NotificationReceiverSlack + +| Field | Type | Description | Required | +|-|-|-|-| +| hookURL | string | The hookURL of a slack channel. | Yes | +| oauthToken | string | [The token for Slack API use.](https://api.slack.com/authentication/basics) (deprecated)| No | +| oauthTokenData | string | Base64 encoded string of [The token for Slack API use.](https://api.slack.com/authentication/basics) | No | +| oauthTokenFile | string | The path to the oautoken file | No | +| channelID | string | The channel id which slack api send to. | No | +| mentionedAccounts | []string | The accounts to which slack api referes. This field supports both `@username` and `username` writing styles.| No | +| mentionedGroups | []string | The groups to which slack api referes. This field supports both `` and `groupname` writing styles.| No | + +#### NotificationReceiverWebhook + +| Field | Type | Description | Required | +|-|-|-|-| +| url | string | The URL where notification event will be sent to. | Yes | +| signatureKey | string | The HTTP header key used to store the configured signature in each event. Default is "PipeCD-Signature". | No | +| signatureValue | string | The value of signature included in header of each event request. It can be used to verify the received events. | No | +| signatureValueFile | string | The path to the signature value file. | No | diff --git a/docs/content/en/docs-v0.49.x/user-guide/managing-piped/configuring-event-watcher.md b/docs/content/en/docs-v0.49.x/user-guide/managing-piped/configuring-event-watcher.md new file mode 100644 index 0000000000..1a7b0ae10c --- /dev/null +++ b/docs/content/en/docs-v0.49.x/user-guide/managing-piped/configuring-event-watcher.md @@ -0,0 +1,62 @@ +--- +title: "Configuring event watcher" +linkTitle: "Configuring event watcher" +weight: 7 +description: > + This page describes how to configure piped to enable event watcher. +--- + +To enable [EventWatcher](../../event-watcher/), you have to configure your piped at first. + +### Grant write permission +The [SSH key used by Piped](../configuration-reference/#git) must be a key with write-access because piped needs to commit and push to your git repository when any incoming event matches. + +### Specify Git repositories to be observed +Piped watches events only for the Git repositories specified in the `gitRepos` list. +You need to add all repositories you want to enable Eventwatcher. + +```yaml +apiVersion: pipecd.dev/v1beta1 +kind: Piped +spec: + eventWatcher: + gitRepos: + - repoId: repo-1 + - repoId: repo-2 + - repoId: repo-3 +``` + +### [optional] Specify Eventwatcher files Piped will use +>NOTE: This way is valid only for defining events using [.pipe/](../../event-watcher/#use-the-pipe-directory). + +If multiple Pipeds handle a single repository, you can prevent conflicts by splitting into the multiple EventWatcher files and setting `includes/excludes` to specify the files that should be monitored by this Piped. + +Say for instance, if you only want the Piped to use the Eventwatcher files under `.pipe/dev/`: + +```yaml +apiVersion: pipecd.dev/v1beta1 +kind: Piped +spec: + eventWatcher: + gitRepos: + - repoId: repo-1 + commitMessage: Update values by Event watcher + includes: + - dev/*.yaml +``` + +`excludes` is prioritized if both `includes` and `excludes` are given. + +The full list of configurable fields are [here](../configuration-reference/#eventwatcher). + +### [optional] Settings for git user +By default, every git commit uses `piped` as a username and `pipecd.dev@gmail.com` as an email. You can change it with the [git](../configuration-reference/#git) field. + +```yaml +apiVersion: pipecd.dev/v1beta1 +kind: Piped +spec: + git: + username: foo + email: foo@example.com +``` diff --git a/docs/content/en/docs-v0.49.x/user-guide/managing-piped/configuring-notifications.md b/docs/content/en/docs-v0.49.x/user-guide/managing-piped/configuring-notifications.md new file mode 100644 index 0000000000..9e0c366108 --- /dev/null +++ b/docs/content/en/docs-v0.49.x/user-guide/managing-piped/configuring-notifications.md @@ -0,0 +1,137 @@ +--- +title: "Configuring notifications" +linkTitle: "Configuring notifications" +weight: 8 +description: > + This page describes how to configure piped to send notifications to external services. +--- + +PipeCD events (deployment triggered, planned, completed, analysis result, piped started...) can be sent to external services like Slack or a Webhook service. While forwarding those events to a chat service helps developers have a quick and convenient way to know the deployment's current status, forwarding to a Webhook service may be useful for triggering other related tasks like CI jobs. + +PipeCD events are emitted and sent by the `piped` component. So all the needed configurations can be specified in the `piped` configuration file. +Notification configuration including: +- a list of `Route`s which used to match events and decide where the event should be sent to +- a list of `Receiver`s which used to know how to send events to the external service + +[Notification Route](../configuration-reference/#notificationroute) matches events based on their metadata like `name`, `group`, `app`, `labels`. +Below is the list of supporting event names and their groups. + +| Event | Group | Supported | Description | +|-|-|-|-| +| DEPLOYMENT_TRIGGERED | DEPLOYMENT |

| | +| DEPLOYMENT_PLANNED | DEPLOYMENT |

| | +| DEPLOYMENT_APPROVED | DEPLOYMENT |

| | +| DEPLOYMENT_WAIT_APPROVAL | DEPLOYMENT |

| | +| DEPLOYMENT_ROLLING_BACK | DEPLOYMENT |

| PipeCD sends a notification when a deployment is completed, while it does not send a notification when a deployment status changes to DEPLOYMENT_ROLLING_BACK because it is not a completion status. See [#4547](https://github.com/pipe-cd/pipecd/issues/4547) | +| DEPLOYMENT_SUCCEEDED | DEPLOYMENT |

| | +| DEPLOYMENT_FAILED | DEPLOYMENT |

| | +| DEPLOYMENT_CANCELLED | DEPLOYMENT |

| | +| DEPLOYMENT_TRIGGER_FAILED | DEPLOYMENT |

| | +| APPLICATION_SYNCED | APPLICATION_SYNC |

| | +| APPLICATION_OUT_OF_SYNC | APPLICATION_SYNC |

| | +| APPLICATION_HEALTHY | APPLICATION_HEALTH |

| | +| APPLICATION_UNHEALTHY | APPLICATION_HEALTH |

| | +| PIPED_STARTED | PIPED |

| | +| PIPED_STOPPED | PIPED |

| | + +### Sending notifications to Slack + +``` yaml +apiVersion: pipecd.dev/v1beta1 +kind: Piped +spec: + notifications: + routes: + # Sending all event which contains labels `env: dev` to dev-slack-channel. + - name: dev-slack + labels: + env: dev + receiver: dev-slack-channel + # Only sending deployment started and completed events which contains + # labels `env: prod` and `team: pipecd` to prod-slack-channel. + - name: prod-slack + events: + - DEPLOYMENT_TRIGGERED + - DEPLOYMENT_SUCCEEDED + labels: + env: prod + team: pipecd + receiver: prod-slack-channel + - name: integration-slack + receiver: integration-slack-api + 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: + oauthTokenData: "token" + channelID: "testid" + - name: hookurl-with-mentioned-accounts + slack: + hookURL: https://slack.com/dev, + mentionedAccounts: + - '@user1' + - 'user2' + - name: integration-slack-api-with-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: + - 'groupID1' + - '' +``` + + +![](/images/slack-notification-deployment.png) +

+Deployment was triggered, planned and completed successfully +

+ +![](/images/slack-notification-piped-started.png) +

+A piped has been started +

+ + +For detailed configuration, please check the [configuration reference for Notifications](../configuration-reference/#notifications) section. + +### Sending notifications to external services via webhook + +``` yaml +apiVersion: pipecd.dev/v1beta1 +kind: Piped +spec: + notifications: + routes: + # Sending all events an external service. + - name: all-events-to-a-external-service + receiver: a-webhook-service + receivers: + - name: a-webhook-service + webhook: + url: {WEBHOOK_SERVICE_URL} + signatureValue: {RANDOM_SIGNATURE_STRING} +``` + +For detailed configuration, please check the [configuration reference for NotificationReceiverWebhook](../configuration-reference/#notificationreceiverwebhook) section. diff --git a/docs/content/en/docs-v0.49.x/user-guide/managing-piped/remote-upgrade-remote-config.md b/docs/content/en/docs-v0.49.x/user-guide/managing-piped/remote-upgrade-remote-config.md new file mode 100644 index 0000000000..eec51632dd --- /dev/null +++ b/docs/content/en/docs-v0.49.x/user-guide/managing-piped/remote-upgrade-remote-config.md @@ -0,0 +1,39 @@ +--- +title: "Remote upgrade and remote config" +linkTitle: "Remote upgrade and remote config" +weight: 1 +description: > + This page describes how to use remote upgrade and remote config features. +--- + +## Remote upgrade + +The remote upgrade is the ability to restart the currently running Piped with another version from the web console. +This reduces the effort involved in updating Piped to newer versions. +All Pipeds that are running by the provided Piped container image can be enabled to use this feature. +It means Pipeds running on a Kubernetes cluster, a virtual machine, a serverless service can be upgraded remotely from the web console. + +Basically, in order to use this feature you must run Piped with `/launcher` command instead of `/piped` command as usual. +Please check the [installation](../../../installation/install-piped/) guide on each environment to see the details. + +After starting Piped with the remote-upgrade feature, you can go to the Settings page then click on `UPGRADE` button on the top-right corner. +A dialog will be shown for selecting which Pipeds you want to upgrade and what version they should run. + +![](/images/settings-remote-upgrade.png) +

+Select a list of Pipeds to upgrade from Settings page +

+ +## Remote config + +Although the remote-upgrade allows you remotely restart your Pipeds to run any new version you want, if your Piped is loading its config locally where Piped is running, you still need to manually restart Piped after adding any change on that config data. Remote-config is for you to remove that kind of manual operation. + +Remote-config is the ability to load Piped config data from a remote location such as a Git repository. Not only that, but it also watches the config periodically to detect any changes on that config and restarts Piped to reflect the new configuration automatically. + +This feature requires the remote-upgrade feature to be enabled simultaneously. Currently, we only support remote config from a Git repository, but other remote locations could be supported in the future. Please check the [installation](../../../installation/install-piped/) guide on each environment to know how to configure Piped to load a remote config file. + + +## Summary + +- By `remote-upgrade` you can upgrade your Piped to a newer version by clicking on the web console +- By `remote-config` you can enforce your Piped to use the latest config data just by updating its config file stored in a Git repository diff --git a/docs/content/en/docs-v0.49.x/user-guide/managing-piped/runtime-options.md b/docs/content/en/docs-v0.49.x/user-guide/managing-piped/runtime-options.md new file mode 100644 index 0000000000..a0c0790383 --- /dev/null +++ b/docs/content/en/docs-v0.49.x/user-guide/managing-piped/runtime-options.md @@ -0,0 +1,76 @@ +--- +title: "Runtime Options" +linkTitle: "Runtime Options" +weight: 11 +description: > + This page describes configurable options for executing Piped and launcher. +--- + +You can configure some options when running Piped and launcher. + +## Options for Piped + +``` +Usage: + piped piped [flags] + +Flags: + --add-login-user-to-passwd Whether to add login user to $HOME/passwd. This is typically for applications running as a random user ID. + --admin-port int The port number used to run a HTTP server for admin tasks such as metrics, healthz. (default 9085) + --app-manifest-cache-count int The number of app manifests to cache. The cache-key contains the commit hash. The default is 150. (default 150) + --cert-file string The path to the TLS certificate file. + --config-aws-secret string The ARN of secret that contains Piped config and be stored in AWS Secrets Manager. + --config-data string The base64 encoded string of the configuration data. + --config-file string The path to the configuration file. + --config-gcp-secret string The resource ID of secret that contains Piped config and be stored in GCP SecretManager. + --enable-default-kubernetes-cloud-provider Whether the default kubernetes provider is enabled or not. This feature is deprecated. + --grace-period duration How long to wait for graceful shutdown. (default 30s) + -h, --help help for piped + --insecure Whether disabling transport security while connecting to control-plane. + --launcher-version string The version of launcher which initialized this Piped. + --tools-dir string The path to directory where to install needed tools such as kubectl, helm, kustomize. (default "~/.piped/tools") + +Global Flags: + --log-encoding string The encoding type for logger [json|console|humanize]. (default "humanize") + --log-level string The minimum enabled logging level. (default "info") + --metrics Whether metrics is enabled or not. (default true) + --profile If true enables uploading the profiles to Stackdriver. + --profile-debug-logging If true enables logging debug information of profiler. + --profiler-credentials-file string The path to the credentials file using while sending profiles to Stackdriver. +``` + +## Options for launcher + +``` +Usage: + launcher launcher [flags] + +Flags: + --aws-secret-id string The ARN of secret that contains Piped config in AWS Secrets Manager service. + --cert-file string The path to the TLS certificate file. + --check-interval duration Interval to periodically check desired config/version to restart Piped. Default is 1m. (default 1m0s) + --config-data string The base64 encoded string of the configuration data. + --config-file string The path to the configuration file. + --config-from-aws-secret Whether to load Piped config that is being stored in AWS Secrets Manager service. + --config-from-gcp-secret Whether to load Piped config that is being stored in GCP SecretManager service. + --config-from-git-repo Whether to load Piped config that is being stored in a git repository. + --default-version string The version should be run when no desired version was specified. Empty means using the same version with Launcher. + --gcp-secret-id string The resource ID of secret that contains Piped config in GCP SecretManager service. + --git-branch string Branch of git repository to for Piped config. + --git-piped-config-file string Relative path within git repository to locate Piped config file. + --git-repo-url string The remote URL of git repository to fetch Piped config. + --git-ssh-key-file string The path to SSH private key to fetch private git repository. + --grace-period duration How long to wait for graceful shutdown. (default 30s) + -h, --help help for launcher + --home-dir string The working directory of Launcher. + --insecure Whether disabling transport security while connecting to control-plane. + --launcher-admin-port int The port number used to run a HTTP server for admin tasks such as metrics, healthz. + +Global Flags: + --log-encoding string The encoding type for logger [json|console|humanize]. (default "humanize") + --log-level string The minimum enabled logging level. (default "info") + --metrics Whether metrics is enabled or not. (default true) + --profile If true enables uploading the profiles to Stackdriver. + --profile-debug-logging If true enables logging debug information of profiler. + --profiler-credentials-file string The path to the credentials file using while sending profiles to Stackdriver. +``` diff --git a/docs/content/en/docs-v0.49.x/user-guide/managing-piped/using-pprof-in-piped.md b/docs/content/en/docs-v0.49.x/user-guide/managing-piped/using-pprof-in-piped.md new file mode 100644 index 0000000000..43c1e68152 --- /dev/null +++ b/docs/content/en/docs-v0.49.x/user-guide/managing-piped/using-pprof-in-piped.md @@ -0,0 +1,48 @@ +--- +title: "Using Pprof in Piped" +linkTitle: "Using Pprof in Piped" +weight: 10 +description: > + This guide is for developers who want to use pprof for performance profiling in Piped. +--- + +Piped provides built-in support for pprof, a tool for visualization and analysis of profiling data. It's a part of the standard Go library. + +In Piped, several routes are registered to serve the profiling data in a format understood by the pprof tool. Here are the routes: + +- `/debug/pprof/`: This route serves an index page that lists the available profiling data. +- `/debug/pprof/profile`: This route serves CPU profiling data. +- `/debug/pprof/trace`: This route serves execution trace data. + +You can access these routes to get the profiling data. For example, to get the CPU profiling data, you can access the `/debug/pprof/profile` route. + +Note that using these features in a production environment may impact performance. + +This document explains the basic usage of [pprof](https://pkg.go.dev/net/http/pprof) in Piped. For more detailed information or specific use cases, please refer to the official Go documentation. + +## How to use pprof + +1. Access the pprof index page + ```bash + curl http://localhost:9085/debug/pprof/ + ``` + This will return an HTML page that lists the available profiling data. + +2. Get the Cpi Profile + ```bash + curl http://localhost:9085/debug/pprof/profile > cpu.pprof + ``` + This will save the CPU profiling data to a file named cpu.pprof. You can then analyze this data using the pprof tool: + ```bash + go tool pprof cpu.pprof + ``` + +3. Get the Execution Trace + ```bash + curl http://localhost:9085/debug/pprof/trace > trace.out + ``` + This will save the execution trace data to a file named trace.out. You can then view this trace using the go tool trace command: + ```bash + go tool trace trace.out + ``` + Please replace localhost:9085 with the actual address and port of your Piped's admin server. diff --git a/docs/content/en/docs-v0.49.x/user-guide/metrics.md b/docs/content/en/docs-v0.49.x/user-guide/metrics.md new file mode 100644 index 0000000000..fee0d7e9c2 --- /dev/null +++ b/docs/content/en/docs-v0.49.x/user-guide/metrics.md @@ -0,0 +1,124 @@ +--- +title: "Metrics" +linkTitle: "Metrics" +weight: 8 +description: > + This page describes how to enable monitoring system for collecting PipeCD' metrics. +--- + +PipeCD comes with a monitoring system including Prometheus, Alertmanager, and Grafana. +This page walks you through how to set up and use them. + +## Monitoring overview + +![](/images/metrics-architecture.png) +

+Monitoring Architecture +

+ +Both the Control plane and piped agent have their own "admin servers" (the default port number is 9085), which are simple HTTP servers providing operational information such as health status, running version, go profile, and monitoring metrics. + +The piped agent collects its metrics and periodically sends them to the Control plane. The Control plane then compacts its resource usage and cluster information with the metrics sent by the piped agent and re-publishes them via its admin server. When the PipeCD monitoring feature is turned on, Prometheus, Alertmanager, and Grafana are deployed with the Control plane, and Prometheus retrieves metrics information from the Control plane's admin server. + +Developers managing the piped agent can also get metrics directly from the piped agent and monitor them with their custom monitoring service. + +## Enable monitoring system +To enable monitoring system for PipeCD, you first need to set the following value to `helm install` when [installing](../../../installation/install-controlplane/#2-preparing-control-plane-configuration-file-and-installing). + +``` +--set monitoring.enabled=true +``` + +## Dashboards +If you've already enabled monitoring system in the previous section, you can access Grafana using port forwarding: + +``` +kubectl port-forward -n {NAMESPACE} svc/{PIPECD_RELEASE_NAME}-grafana 3000:80 +``` + +#### Control Plane dashboards +There are three dashboards related to Control Plane: +- Overview - usage stats of PipeCD +- Incoming Requests - gRPC and HTTP requests stats to check for any negative impact on users +- Go - processes stats of PipeCD components + +#### Piped dashboards +Visualize the metrics of Piped registered in the Control plane. +- Overview - usage stats of piped agents +- Process - resource usage of piped agent +- Go - processes stats of piped agents. + +#### Cluster dashboards +Because cluster dashboards tracks cluster-wide metrics, defaults to disable. You can enable it with: + +``` +--monitoring.clusterStats=true +``` + +There are three dashboards that track metrics for: +- Node - nodes stats within the Kubernetes cluster where PipeCD runs on +- Pod - stats for pods that make PipeCD up +- Prometheus - stats for Prometheus itself + +## Alert notifications +If you want to send alert notifications to external services like Slack, you need to set an alertmanager configuration file. + +For example, let's say you use Slack as a receiver. Create `values.yaml` and put the following configuration to there. + +```yaml +prometheus: + alertmanagerFiles: + alertmanager.yml: + global: + slack_api_url: {YOUR_WEBHOOK_URL} + route: + receiver: slack-notifications + receivers: + - name: slack-notifications + slack_configs: + - channel: '#your-channel' +``` + +And give it to the `helm install` command when [installing](../../../installation/install-controlplane/#2-preparing-control-plane-configuration-file-and-installing). + +``` +--values=values.yaml +``` + +See [here](https://prometheus.io/docs/alerting/latest/configuration/) for more details on AlertManager's configuration. + +## Piped agent metrics + +| Metric | Type | Description | +| --- | --- | --- | +| `cloudprovider_kubernetes_tool_calls_total` | counter | Number of calls made to run the tool like kubectl, kustomize. | +| `deployment_status` | gauge | The current status of deployment. 1 for current status, 0 for others. | +| `livestatestore_kubernetes_api_requests_total` | counter | Number of requests sent to kubernetes api server. | +| `livestatestore_kubernetes_resource_events_total` | counter | Number of resource events received from kubernetes server. | +| `plan_preview_command_handled_total` | counter | Total number of plan-preview commands handled at piped. | +| `plan_preview_command_handling_seconds` | histogram | Histogram of handling seconds of plan-preview commands. | +| `plan_preview_command_received_total` | counter | Total number of plan-preview commands received at piped. | + +## Control plane metrics + +All Piped's metrics are sent to the control plane so that they are also available on the control plane's metrics server. + +| Metric | Type | Description | +| --- | --- | --- | +| `cache_get_operation_total` | counter | Number of cache get operation while processing. | +| `grpcapi_create_deployment_total` | counter | Number of successful CreateDeployment RPC with project label. | +| `http_request_duration_milliseconds` | histogram | Histogram of request latencies in milliseconds. | +| `http_requests_total` | counter | Total number of HTTP requests. | +| `insight_application_total` | gauge | Number of applications currently controlled by control plane. | + +## Health Checking + +The below components expose their endpoint for health checking. +- server +- ops +- piped +- launcher (only when you run with designating the `launcher-admin-port` option.) + +The spec of the health check endpoint is as below. +- Path: `/healthz` +- Port: the same as admin server's port. 9085 by default. diff --git a/docs/content/en/docs-v0.49.x/user-guide/plan-preview.md b/docs/content/en/docs-v0.49.x/user-guide/plan-preview.md new file mode 100644 index 0000000000..c0a212d5df --- /dev/null +++ b/docs/content/en/docs-v0.49.x/user-guide/plan-preview.md @@ -0,0 +1,52 @@ +--- +title: "Confidently review your changes with Plan Preview" +linkTitle: "Plan preview" +weight: 6 +description: > + Enables the ability to preview the deployment plan against a given commit before merging. +--- + +In order to help developers review the pull request with a better experience and more confidence to approve it to trigger the actual deployments, +PipeCD provides a way to preview the deployment plan of all updated applications by that pull request. + +Here are what will be included currently in the result of plan-preview process: + +- which application will be deployed once the pull request got merged +- which deployment strategy (QUICK_SYNC or PIPELINE_SYNC) will be used +- which resources will be added, deleted, or modified + +This feature will available for all application kinds: KUBERNETES, TERRAFORM, CLOUD_RUN, LAMBDA and Amazon ECS. + +![](/images/plan-preview-comment.png) +

+PlanPreview with GitHub actions pipe-cd/actions-plan-preview +

+ +## Prerequisites + +- Ensure the version of your Piped is at least `v0.11.0`. +- Having an API key that has `READ_WRITE` role to authenticate with PipeCD's Control Plane. A new key can be generated from `settings/api-key` page of your PipeCD web. + +## Usage + +Plan-preview result can be requested by using `pipectl` command-line tool as below: + +``` console +pipectl plan-preview \ + --address={ PIPECD_CONTROL_PLANE_ADDRESS } \ + --api-key={ PIPECD_API_KEY } \ + --repo-remote-url={ REPO_REMOTE_GIT_SSH_URL } \ + --head-branch={ HEAD_BRANCH } \ + --head-commit={ HEAD_COMMIT } \ + --base-branch={ BASE_BRANCH } +``` + +You can run it locally or integrate it to your CI system to run automatically when a new pull request is opened/updated. Use `--help` to see more options. + +``` console +pipectl plan-preview --help +``` + +## GitHub Actions + +If you are using GitHub Actions, you can seamlessly integrate our prepared [actions-plan-preview](https://github.com/pipe-cd/actions-plan-preview) to your workflows. This automatically comments the plan-preview result on the pull request when it is opened or updated. You can also trigger to run plan-preview manually by leave a comment `/pipecd plan-preview` on the pull request. diff --git a/docs/content/en/docs-v0.49.x/user-guide/terraform-provider-pipecd.md b/docs/content/en/docs-v0.49.x/user-guide/terraform-provider-pipecd.md new file mode 100644 index 0000000000..5175deb0c7 --- /dev/null +++ b/docs/content/en/docs-v0.49.x/user-guide/terraform-provider-pipecd.md @@ -0,0 +1,68 @@ +--- +title: "PipeCD Terraform provider" +linkTitle: "PipeCD Terraform provider" +weight: 10 +description: > + This page describes how to manage PipeCD resources with Terraform using terraform-provider-pipecd. +--- + +Besides using web UI and command line tool, PipeCD community also provides Terraform module, [terraform-provider-pipecd](https://registry.terraform.io/providers/pipe-cd/pipecd/latest), which allows you to manage PipeCD resources. +This provider enables us to add, update, and delete PipeCD resources as Infrastructure as Code (IaC). Storing resources as code in a version control system like Git repository ensures more reliability, security, and makes it more friendly for engineers to manage PipeCD resources with the power of Git leverage. + +## Usage + +### Setup Terraform provider +Add terraform block to declare that you use PipeCD Terraform provider. You need to input a controle plane's host name and API key via provider settings or environment variables. API key is available on the web UI. + +```hcl +terraform { + required_providers { + pipecd = { + source = "pipe-cd/pipecd" + version = "0.1.0" + } + } + required_version = ">= 1.4" +} + +provider "pipecd" { + # pipecd_host = "" // optional, if not set, read from environments as PIPECD_HOST + # pipecd_api_key = "" // optional, if not set, read from environments as PIPECD_API_KEY +} +``` + +### Manage Piped agent +Add `pipecd_piped` resource to manage a Piped agent. + +```hcl +resource "pipecd_piped" "mypiped" { + name = "mypiped" + description = "This is my piped" + id = "my-piped-id" +} +``` + +### Adding a new application +Add `pipecd_application` resource to manage an application. + +```hcl +// CloudRun Application +resource "pipecd_application" "main" { + kind = "CLOUDRUN" + name = "example-application" + description = "This is the simple application" + platform_provider = "cloudrun-inproject" + piped_id = "your-piped-id" + git = { + repository_id = "examples" + remote = "git@github.com:pipe-cd/examples.git" + branch = "master" + path = "cloudrun/simple" + filename = "app.pipecd.yaml" + } +} +``` + +### You want more? + +We always want to add more needed resources into the Terraform provider. Please let the maintainers know what resources you want to add by creating issues in the [pipe-cd/terraform-provider-pipecd](https://github.com/pipe-cd/terraform-provider-pipecd/) repository. We also welcome your pull request to contribute! diff --git a/docs/layouts/docs-v0.49.x/baseof.html b/docs/layouts/docs-v0.49.x/baseof.html new file mode 100644 index 0000000000..20af4e6526 --- /dev/null +++ b/docs/layouts/docs-v0.49.x/baseof.html @@ -0,0 +1,32 @@ + + + + {{ partial "head.html" . }} + + +
+ {{ partial "navbar.html" . }} +
+
+
+
+ + +
+ {{ partial "version-banner.html" . }} + {{ if not .Site.Params.ui.breadcrumb_disable }}{{ partial "breadcrumb.html" . }}{{ end }} + {{ block "main" . }}{{ end }} +
+
+
+ {{ partial "footer.html" . }} +
+ {{ partial "scripts.html" . }} + + \ No newline at end of file diff --git a/docs/layouts/docs-v0.49.x/baseof.print.html b/docs/layouts/docs-v0.49.x/baseof.print.html new file mode 100644 index 0000000000..d37e99012b --- /dev/null +++ b/docs/layouts/docs-v0.49.x/baseof.print.html @@ -0,0 +1,26 @@ + + + + {{ partial "head.html" . }} + + +
+ {{ partial "navbar.html" . }} +
+
+
+
+
+
+
+
+
+ {{ block "main" . }}{{ end }} +
+
+
+ {{ partial "footer.html" . }} +
+ {{ partial "scripts.html" . }} + + diff --git a/docs/layouts/docs-v0.49.x/list.html b/docs/layouts/docs-v0.49.x/list.html new file mode 100644 index 0000000000..b59b8d0047 --- /dev/null +++ b/docs/layouts/docs-v0.49.x/list.html @@ -0,0 +1,32 @@ +{{ define "main" }} +
+

{{ .Title }}

+ {{ with .Params.description }}
{{ . | markdownify }}
{{ end }} + + {{ .Content }} + {{ partial "section-index.html" . }} + {{ if (and (not .Params.hide_feedback) (.Site.Params.ui.feedback.enable) (.Site.GoogleAnalytics)) }} + {{ partial "feedback.html" .Site.Params.ui.feedback }} +
+ {{ end }} + {{ if (.Site.DisqusShortname) }} +
+ {{ partial "disqus-comment.html" . }} + {{ end }} + {{ partial "page-meta-lastmod.html" . }} +
+{{ end }} diff --git a/docs/layouts/docs-v0.49.x/list.print.html b/docs/layouts/docs-v0.49.x/list.print.html new file mode 100644 index 0000000000..1b04015886 --- /dev/null +++ b/docs/layouts/docs-v0.49.x/list.print.html @@ -0,0 +1,3 @@ +{{ define "main" }} +{{ partial "print/render" . }} +{{ end }} diff --git a/docs/layouts/docs-v0.49.x/single.html b/docs/layouts/docs-v0.49.x/single.html new file mode 100644 index 0000000000..00cb3ab911 --- /dev/null +++ b/docs/layouts/docs-v0.49.x/single.html @@ -0,0 +1,3 @@ +{{ define "main" }} +{{ .Render "content" }} +{{ end }} \ No newline at end of file diff --git a/docs/main.go b/docs/main.go index f841b6ce5e..2086c8ac72 100644 --- a/docs/main.go +++ b/docs/main.go @@ -28,7 +28,7 @@ import ( const dir = "/public" // Don't update here manually. /hack/gen-release-docs.sh does. -const latestPath = "/docs-v0.48.x/" +const latestPath = "/docs-v0.49.x/" func main() { var ( From e317934878e4950c19683576533456fcce412269 Mon Sep 17 00:00:00 2001 From: Tetsuya Kikuchi <97105818+t-kikuc@users.noreply.github.com> Date: Tue, 24 Sep 2024 15:11:09 +0900 Subject: [PATCH 13/84] Use `make release` for major/minor releases (#5225) * Generate v0.49.x docs Signed-off-by: t-kikuc * Use `make release` for major/minor releases Signed-off-by: t-kikuc --------- Signed-off-by: t-kikuc --- RELEASES.md | 39 +++++++++++++++++++-------------------- 1 file changed, 19 insertions(+), 20 deletions(-) diff --git a/RELEASES.md b/RELEASES.md index 3dfc3385ae..331172d8d0 100644 --- a/RELEASES.md +++ b/RELEASES.md @@ -18,29 +18,20 @@ Note: The team can release Release candidates(vX.Y.Z-rcXYZ) for versions at any ## Major release This refers to the release of new features or breaking changes. -### Confirm the changelog and Create Release Note -- Run the release script +### Confirm the changelog and generate docs +- Run `make release`. This example assumes that `vX.Y.Z` will be released: ```shell - make release/init version=vX.Y.Z - ```` - - `RELEASE` file will be updated. - -- Push the above changes and Create a pull request to confirm the changelog. - You can confirm the changelog through the reviewing comment in pull request by GitHub Actions. - For more information, Please see [actions-gh-release](https://github.com/pipe-cd/actions-gh-release). + make release version=vX.Y.Z + ``` -### Generate document for new version -- Run the release document script + The `RELEASE` file will be updated and docs `vX.Y.x` will be generated. - This example assumes that `vX.Y.Z` will be released: - ```shell - make release/docs version=vX.Y.Z - ```` +- Push the above changes and create a pull request to confirm the changelog. + You can confirm the changelog through the reviewing comment in the pull request by [actions-gh-release](https://github.com/pipe-cd/actions-gh-release). -- Make a pull request to `master` branch with the above changes and get reviews and merge. +- Get reviews and merge. ### Cut a new release - Before cutting a new release, wait for all jobs in GitHub Actions to pass on the master branch. @@ -66,12 +57,20 @@ This may also contain some minor features, but ensure that it does NOT contain a - Get reviews and merge. -### Confirm the changelog and Create Release Note -- As well as [Major release](https://github.com/pipe-cd/pipecd/blob/master/RELEASES.md#confirm-the-changelog-and-create-release-note), create a pull request to create a release note on the `master` branch. +### Confirm the changelog and create Release Note + +- Run `make release/init`. + ```shell + make release/init version=vX.Y.Z + ``` + + The `RELEASE` file will be updated. + +- Push the above changes and create a pull request to `master` to confirm the changelog. - Get a review and merge. -### Backport fixes and Release note +### Backport fixes and Release Note - Run `cherry_pick` workflow - Label the merged PR you want to cherry pick with `cherry-pick` , `vX.Y.Z` (e.g. v0.48.6 https://github.com/pipe-cd/pipecd/pulls?q=is%3Apr+label%3Acherry-pick+is%3Aclosed+label%3Av0.48.6) From 90f6a1fdd24507cb7074f5e8b3d31998fda0de87 Mon Sep 17 00:00:00 2001 From: Yoshiki Fujikane <40124947+ffjlabo@users.noreply.github.com> Date: Wed, 25 Sep 2024 09:50:16 +0900 Subject: [PATCH 14/84] Modify spec for multi cluster deployment (#5219) Signed-off-by: Yoshiki Fujikane --- .../0014-multi-cluster-deployment-for-k8s.md | 112 ++++++++++++------ 1 file changed, 76 insertions(+), 36 deletions(-) diff --git a/docs/rfcs/0014-multi-cluster-deployment-for-k8s.md b/docs/rfcs/0014-multi-cluster-deployment-for-k8s.md index 8eacb8976b..a803203bca 100644 --- a/docs/rfcs/0014-multi-cluster-deployment-for-k8s.md +++ b/docs/rfcs/0014-multi-cluster-deployment-for-k8s.md @@ -41,7 +41,7 @@ Piped asynchronously applies the resources to each environment based on the plat For example, consider deploying a microservice called `microservice-a` to the clusters called `cluster-hoge`, `cluster-fuga`. At first, we will prepare one application with one `app.pipecd.yaml` and some manifests like this. -Set the item `multiTarget` in spec.quickSync of app.pipecd.yaml, and set the dir containing the manifests you want to deploy and the platform provider to which you want to deploy. +Set the item `multiTarget` in spec.input of app.pipecd.yaml, and set the dir containing the manifests you want to deploy and the platform provider to which you want to deploy. This will deploy to `cluster-hoge` and `cluster-fuga` at the same time when quickSync is executed. ``` @@ -54,26 +54,41 @@ microservice-a │   └── service.yaml ├── cluster-hoge │   └── kustomization.yaml - ├── cluster-fuga - │   └── kustomization.yaml - └── kustomization.yaml + └── cluster-fuga +    └── kustomization.yaml ``` ```app.pipecd.yaml apiVersion: pipecd.dev/v1beta1 kind: KubernetesApp spec: - name: multi-cluster-app + name: microservice-a labels: env: prd + team: product quickSync: + prune: true + input: multiTarget: - - provider: + - targetID: hoge + provider: name: cluster-hoge # platform provider name - resourceDir: ./cluster-hoge # the resource dir - - provider: - name: cluster-fuga - resourceDir: ./cluster-fuga + manifests: ./cluster-hoge # the resource dir + kubectlVersion: 1.30 + kustomizeDir: ./cluster-hoge + kustomizeVersion: v5.4.3 + kustomizeOptions: + enable-helm: "" + load-restrictor: "LoadRestrictionsNone" + - targetID: fuga + provider: + name: cluster-fuga # platform provider name + kubectlVersion: 1.30 + kustomizeDir: ./cluster-fuga + kustomizeVersion: v5.4.3 + kustomizeOptions: + enable-helm: "" + load-restrictor: "LoadRestrictionsNone" ``` **Rollback** @@ -82,21 +97,37 @@ Similarly, when rolling back, multiple environments are rolled back at the same If at least one of the rollback processes succeeds, we consider the rollback successful. This ensures that the rollback is executed for other environments even if one of the deployment environments is inaccessible. -``` +```app.pipecd.yaml apiVersion: pipecd.dev/v1beta1 kind: KubernetesApp spec: - name: multi-cluster-app + name: microservice-a labels: env: prd + team: product quickSync: + prune: true + input: multiTarget: - - provider: + - targetID: hoge + provider: name: cluster-hoge # platform provider name - resourceDir: ./cluster-hoge # the resource dir - - provider: - name: cluster-fuga - resourceDir: ./cluster-fuga + manifests: ./cluster-hoge # the resource dir + kubectlVersion: 1.30 + kustomizeDir: ./cluster-hoge + kustomizeVersion: v5.4.3 + kustomizeOptions: + enable-helm: "" + load-restrictor: "LoadRestrictionsNone" + - targetID: fuga + provider: + name: cluster-fuga # platform provider name + kubectlVersion: 1.30 + kustomizeDir: ./cluster-fuga + kustomizeVersion: v5.4.3 + kustomizeOptions: + enable-helm: "" + load-restrictor: "LoadRestrictionsNone" ``` @@ -107,7 +138,7 @@ Piped asynchronously applies to each environment based on the platform provider For example, consider deploying a microservice called `microservice-a` to the clusters called `cluster-hoge`, `cluster-fuga`. At first, we will prepare one application with one `app.pipecd.yaml` and some manifests like this. -Set the item `multiTarget` in spec.quickSync of app.pipecd.yaml, and set the dir containing the manifests you want to deploy and the platform provider to which you want to deploy. +Set the item `multiTarget` in spec.input of app.pipecd.yaml, and set the dir containing the manifests you want to deploy and the platform provider to which you want to deploy. Also, set the item `multiTarget` in each stage config. This allows applications to be applied to multiple environments at the same time when one stage is executed. @@ -121,46 +152,55 @@ microservice-a │   └── service.yaml ├── cluster-hoge │   └── kustomization.yaml - ├── cluster-fuga - │   └── kustomization.yaml - └── kustomization.yaml + └── cluster-fuga +    └── kustomization.yaml ``` ``` apiVersion: pipecd.dev/v1beta1 kind: KubernetesApp spec: - name: multi-cluster-app + name: microservice-a labels: - env: example + env: prd team: product quickSync: prune: true + input: multiTarget: - - provider: - name: cluster-hoge - resourceDir: ./cluster-hoge - - provider: - name: cluster-fuga - resourceDir: ./cluster-fuga + - targetID: hoge + provider: + name: cluster-hoge # platform provider name + manifests: ./cluster-hoge # the resource dir + kubectlVersion: 1.30 + kustomizeDir: ./cluster-hoge + kustomizeVersion: v5.4.3 + kustomizeOptions: + enable-helm: "" + load-restrictor: "LoadRestrictionsNone" + - targetID: fuga + provider: + name: cluster-fuga # platform provider name + kubectlVersion: 1.30 + kustomizeDir: ./cluster-fuga + kustomizeVersion: v5.4.3 + kustomizeOptions: + enable-helm: "" + load-restrictor: "LoadRestrictionsNone" pipeline: stages: - name: K8S_CANARY_ROLLOUT with: replicas: 10% multiTarget: - - provider: - name: cluster-hoge - resourceDir: ./cluster-hoge - - provider: - name: cluster-fuga - resourceDir: ./cluster-fuga + - targetID: hoge + - targetID: fuga ... ``` **Rollback** -When rolling back, multiple environments are rolled back at the same time based on the information specified in `spec.quickSync.multiTarget`. +When rolling back, multiple environments are rolled back at the same time based on the information specified in `spec.input.multiTarget`. If at least one of the rollback processes succeeds, we consider the rollback successful. This ensures that the rollback is executed for other environments even if one of the deployment environments is inaccessible. From fb46bdbbbdf9b69b5ffc36dcee0ea8d6273281e0 Mon Sep 17 00:00:00 2001 From: Shinnosuke Sawada-Dazai Date: Wed, 25 Sep 2024 13:27:14 +0900 Subject: [PATCH 15/84] Implement k8s plugin's FetchDefinedStages and BuildQuickSyncStages (#5223) * Pass the deployment source directory to the plugins Signed-off-by: Shinnosuke Sawada-Dazai * Add Deploysource Signed-off-by: Shinnosuke Sawada-Dazai * Remove GenericApplicationSpec from DeploymentSource Signed-off-by: Shinnosuke Sawada-Dazai * Do make gen/code Signed-off-by: Shinnosuke Sawada-Dazai * Rename planner plugin to deployment plugin Signed-off-by: Shinnosuke Sawada-Dazai * Implement FetchDefinedStages Signed-off-by: Shinnosuke Sawada-Dazai * Implement BuildQuickSyncStages Signed-off-by: Shinnosuke Sawada-Dazai * Add TODO comment at DetermineVersions Signed-off-by: Shinnosuke Sawada-Dazai --------- Signed-off-by: Shinnosuke Sawada-Dazai --- .../{planner => deployment}/pipeline.go | 45 ++++++++-- .../plugin/kubernetes/deployment/server.go | 85 +++++++++++++++++++ .../plugin/kubernetes/planner/server.go | 63 -------------- pkg/app/pipedv1/plugin/kubernetes/server.go | 5 +- 4 files changed, 127 insertions(+), 71 deletions(-) rename pkg/app/pipedv1/plugin/kubernetes/{planner => deployment}/pipeline.go (63%) create mode 100644 pkg/app/pipedv1/plugin/kubernetes/deployment/server.go delete mode 100644 pkg/app/pipedv1/plugin/kubernetes/planner/server.go diff --git a/pkg/app/pipedv1/plugin/kubernetes/planner/pipeline.go b/pkg/app/pipedv1/plugin/kubernetes/deployment/pipeline.go similarity index 63% rename from pkg/app/pipedv1/plugin/kubernetes/planner/pipeline.go rename to pkg/app/pipedv1/plugin/kubernetes/deployment/pipeline.go index cd95743cb2..e4292bac00 100644 --- a/pkg/app/pipedv1/plugin/kubernetes/planner/pipeline.go +++ b/pkg/app/pipedv1/plugin/kubernetes/deployment/pipeline.go @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -package planner +package deployment import ( "fmt" @@ -23,11 +23,47 @@ import ( "github.com/pipe-cd/pipecd/pkg/model" ) +type Stage string + +const ( + // StageK8sSync represents the state where + // all resources should be synced with the Git state. + StageK8sSync Stage = "K8S_SYNC" + // StageK8sPrimaryRollout represents the state where + // the PRIMARY variant resources has been updated to the new version/configuration. + StageK8sPrimaryRollout Stage = "K8S_PRIMARY_ROLLOUT" + // StageK8sCanaryRollout represents the state where + // the CANARY variant resources has been rolled out with the new version/configuration. + StageK8sCanaryRollout Stage = "K8S_CANARY_ROLLOUT" + // StageK8sCanaryClean represents the state where + // the CANARY variant resources has been cleaned. + StageK8sCanaryClean Stage = "K8S_CANARY_CLEAN" + // StageK8sBaselineRollout represents the state where + // the BASELINE variant resources has been rolled out. + StageK8sBaselineRollout Stage = "K8S_BASELINE_ROLLOUT" + // StageK8sBaselineClean represents the state where + // the BASELINE variant resources has been cleaned. + StageK8sBaselineClean Stage = "K8S_BASELINE_CLEAN" + // StageK8sTrafficRouting represents the state where the traffic to application + // should be splitted as the specified percentage to PRIMARY, CANARY, BASELINE variants. + StageK8sTrafficRouting Stage = "K8S_TRAFFIC_ROUTING" +) + +var AllStages = []Stage{ + StageK8sSync, + StageK8sPrimaryRollout, + StageK8sCanaryRollout, + StageK8sCanaryClean, + StageK8sBaselineRollout, + StageK8sBaselineClean, + StageK8sTrafficRouting, +} + const ( - PredefinedStageK8sSync = "K8sSync" - PredefinedStageRollback = "Rollback" + PredefinedStageK8sSync = "K8sSync" + PredefinedStageRollback = "Rollback" ) - + var predefinedStages = map[string]config.PipelineStage{ PredefinedStageK8sSync: { ID: PredefinedStageK8sSync, @@ -54,7 +90,6 @@ func MakeInitialStageMetadata(cfg config.PipelineStage) map[string]string { } } - func buildQuickSyncPipeline(autoRollback bool, now time.Time) []*model.PipelineStage { var ( preStageID = "" diff --git a/pkg/app/pipedv1/plugin/kubernetes/deployment/server.go b/pkg/app/pipedv1/plugin/kubernetes/deployment/server.go new file mode 100644 index 0000000000..6123c4d7e0 --- /dev/null +++ b/pkg/app/pipedv1/plugin/kubernetes/deployment/server.go @@ -0,0 +1,85 @@ +// 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 deployment + +import ( + "context" + "time" + + "github.com/pipe-cd/pipecd/pkg/plugin/api/v1alpha1/deployment" + "github.com/pipe-cd/pipecd/pkg/regexpool" + + "go.uber.org/zap" + "google.golang.org/grpc" +) + +type DeploymentService struct { + deployment.UnimplementedDeploymentServiceServer + + RegexPool *regexpool.Pool + Logger *zap.Logger +} + +// NewDeploymentService creates a new planService. +func NewDeploymentService( + logger *zap.Logger, +) *DeploymentService { + return &DeploymentService{ + RegexPool: regexpool.DefaultPool(), + Logger: logger.Named("planner"), + } +} + +// Register registers all handling of this service into the specified gRPC server. +func (a *DeploymentService) Register(server *grpc.Server) { + deployment.RegisterDeploymentServiceServer(server, a) +} + +// DetermineStrategy implements deployment.DeploymentServiceServer. +func (a *DeploymentService) DetermineStrategy(context.Context, *deployment.DetermineStrategyRequest) (*deployment.DetermineStrategyResponse, error) { + panic("unimplemented") +} + +// DetermineVersions implements deployment.DeploymentServiceServer. +func (a *DeploymentService) DetermineVersions(context.Context, *deployment.DetermineVersionsRequest) (*deployment.DetermineVersionsResponse, error) { + // TODO: how to determine whether the runnning or target deployment to use? + panic("unimplemented") +} + +// BuildPipelineSyncStages implements deployment.DeploymentServiceServer. +func (a *DeploymentService) BuildPipelineSyncStages(context.Context, *deployment.BuildPipelineSyncStagesRequest) (*deployment.BuildPipelineSyncStagesResponse, error) { + panic("unimplemented") +} + +// BuildQuickSyncStages implements deployment.DeploymentServiceServer. +func (a *DeploymentService) BuildQuickSyncStages(ctx context.Context, request *deployment.BuildQuickSyncStagesRequest) (*deployment.BuildQuickSyncStagesResponse, error) { + now := time.Now() + stages := buildQuickSyncPipeline(request.GetRollback(), now) + return &deployment.BuildQuickSyncStagesResponse{ + Stages: stages, + }, nil +} + +// FetchDefinedStages implements deployment.DeploymentServiceServer. +func (a *DeploymentService) FetchDefinedStages(context.Context, *deployment.FetchDefinedStagesRequest) (*deployment.FetchDefinedStagesResponse, error) { + stages := make([]string, 0, len(AllStages)) + for _, s := range AllStages { + stages = append(stages, string(s)) + } + + return &deployment.FetchDefinedStagesResponse{ + Stages: stages, + }, nil +} diff --git a/pkg/app/pipedv1/plugin/kubernetes/planner/server.go b/pkg/app/pipedv1/plugin/kubernetes/planner/server.go deleted file mode 100644 index a84fbf8e78..0000000000 --- a/pkg/app/pipedv1/plugin/kubernetes/planner/server.go +++ /dev/null @@ -1,63 +0,0 @@ -// 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 planner - -import ( - "context" - "fmt" - - "github.com/pipe-cd/pipecd/pkg/plugin/api/v1alpha1/deployment" - "github.com/pipe-cd/pipecd/pkg/regexpool" - - "go.uber.org/zap" - "google.golang.org/grpc" -) - -type secretDecrypter interface { - Decrypt(string) (string, error) -} - -type PlannerService struct { - deployment.UnimplementedDeploymentServiceServer - - Decrypter secretDecrypter - RegexPool *regexpool.Pool - Logger *zap.Logger -} - -// Register registers all handling of this service into the specified gRPC server. -func (a *PlannerService) Register(server *grpc.Server) { - deployment.RegisterDeploymentServiceServer(server, a) -} - -// NewPlannerService creates a new planService. -func NewPlannerService( - decrypter secretDecrypter, - logger *zap.Logger, -) *PlannerService { - return &PlannerService{ - Decrypter: decrypter, - RegexPool: regexpool.DefaultPool(), - Logger: logger.Named("planner"), - } -} - -func (ps *PlannerService) DetermineStrategy(ctx context.Context, in *deployment.DetermineStrategyRequest) (*deployment.DetermineStrategyResponse, error) { - return nil, fmt.Errorf("not implemented yet") -} - -func (ps *PlannerService) DetermineVersions(ctx context.Context, in *deployment.DetermineVersionsRequest) (*deployment.DetermineVersionsResponse, error) { - return nil, fmt.Errorf("not implemented yet") -} diff --git a/pkg/app/pipedv1/plugin/kubernetes/server.go b/pkg/app/pipedv1/plugin/kubernetes/server.go index ef936ec132..76103cb955 100644 --- a/pkg/app/pipedv1/plugin/kubernetes/server.go +++ b/pkg/app/pipedv1/plugin/kubernetes/server.go @@ -18,7 +18,7 @@ import ( "context" "time" - "github.com/pipe-cd/pipecd/pkg/app/pipedv1/plugin/kubernetes/planner" + "github.com/pipe-cd/pipecd/pkg/app/pipedv1/plugin/kubernetes/deployment" "github.com/pipe-cd/pipecd/pkg/cli" "github.com/pipe-cd/pipecd/pkg/rpc" "github.com/spf13/cobra" @@ -71,8 +71,7 @@ func (s *server) run(ctx context.Context, input cli.Input) (runErr error) { // Start a gRPC server for handling external API requests. { var ( - service = planner.NewPlannerService( - nil, // TODO: Inject the real secret decrypter. It should be a instance of pipedv1/plugin/secrets.Decrypter. + service = deployment.NewDeploymentService( input.Logger, ) opts = []rpc.Option{ From 00fddfdfc5035ac2f2066629115b019fe8d6ba69 Mon Sep 17 00:00:00 2001 From: Khanh Tran <32532742+khanhtc1202@users.noreply.github.com> Date: Wed, 25 Sep 2024 13:27:55 +0900 Subject: [PATCH 16/84] Add OpenSSF best practices badge to README (#5226) Signed-off-by: khanhtc1202 --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index e1e54f9def..d2cc03f129 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,4 @@ +[![OpenSSF Best Practices](https://www.bestpractices.dev/projects/7489/badge)](https://www.bestpractices.dev/projects/7489) [![Build](https://github.com/pipe-cd/pipecd/actions/workflows/build.yaml/badge.svg)](https://github.com/pipe-cd/pipecd/actions/workflows/build.yaml) [![Test](https://github.com/pipe-cd/pipecd/actions/workflows/test.yaml/badge.svg)](https://github.com/pipe-cd/pipecd/actions/workflows/test.yaml) [![Go Report Card](https://goreportcard.com/badge/github.com/pipe-cd/pipecd)](https://goreportcard.com/report/github.com/pipe-cd/pipecd) From 31e91dea98cf2a7012d800929609ef90eced4b5a Mon Sep 17 00:00:00 2001 From: Shinnosuke Sawada-Dazai Date: Wed, 25 Sep 2024 14:28:33 +0900 Subject: [PATCH 17/84] Add InstallTool RPC method for PluginService (#5218) Signed-off-by: Shinnosuke Sawada-Dazai --- .../pipedv1/cmd/piped/service/service.pb.go | 207 +++++++++++++-- .../cmd/piped/service/service.pb.validate.go | 239 ++++++++++++++++++ .../pipedv1/cmd/piped/service/service.proto | 31 +++ .../cmd/piped/service/service_grpc.pb.go | 40 +++ 4 files changed, 501 insertions(+), 16 deletions(-) diff --git a/pkg/app/pipedv1/cmd/piped/service/service.pb.go b/pkg/app/pipedv1/cmd/piped/service/service.pb.go index 0446436b93..4805116003 100644 --- a/pkg/app/pipedv1/cmd/piped/service/service.pb.go +++ b/pkg/app/pipedv1/cmd/piped/service/service.pb.go @@ -129,6 +129,134 @@ func (x *DecryptSecretResponse) GetDecryptedSecret() string { return "" } +type InstallToolRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // Name of the tool. + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + // Version of the tool. + Version string `protobuf:"bytes,2,opt,name=version,proto3" json:"version,omitempty"` + // Install script to install the tool. + // This script is templated with the following variables: + // - {{ .Name }}: name of the tool. + // - {{ .Version }}: version of the tool. + // - {{ .OutPath }}: file path where the tool will be installed. + // - {{ .TmpDir }}: directory where the tool will be downloaded and extracted. + // - {{ .Arch }}: GOARCH of the current machine. + // - {{ .Os }}: GOOS of the current machine. + // + // The script should return 0 if the installation is successful. + // Otherwise, it should return a non-zero value. + // + // The tool should be placed at {{ .OutPath }} + // e.g.) cp path/to/kubectl {{ .OutPath }} + // Then piped move it to the correct directory / filename and make it executable. + InstallScript string `protobuf:"bytes,3,opt,name=install_script,json=installScript,proto3" json:"install_script,omitempty"` +} + +func (x *InstallToolRequest) Reset() { + *x = InstallToolRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_pkg_app_pipedv1_cmd_piped_service_service_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *InstallToolRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*InstallToolRequest) ProtoMessage() {} + +func (x *InstallToolRequest) ProtoReflect() protoreflect.Message { + mi := &file_pkg_app_pipedv1_cmd_piped_service_service_proto_msgTypes[2] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use InstallToolRequest.ProtoReflect.Descriptor instead. +func (*InstallToolRequest) Descriptor() ([]byte, []int) { + return file_pkg_app_pipedv1_cmd_piped_service_service_proto_rawDescGZIP(), []int{2} +} + +func (x *InstallToolRequest) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *InstallToolRequest) GetVersion() string { + if x != nil { + return x.Version + } + return "" +} + +func (x *InstallToolRequest) GetInstallScript() string { + if x != nil { + return x.InstallScript + } + return "" +} + +type InstallToolResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // Installed path of the tool. + InstalledPath string `protobuf:"bytes,1,opt,name=installed_path,json=installedPath,proto3" json:"installed_path,omitempty"` +} + +func (x *InstallToolResponse) Reset() { + *x = InstallToolResponse{} + if protoimpl.UnsafeEnabled { + mi := &file_pkg_app_pipedv1_cmd_piped_service_service_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *InstallToolResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*InstallToolResponse) ProtoMessage() {} + +func (x *InstallToolResponse) ProtoReflect() protoreflect.Message { + mi := &file_pkg_app_pipedv1_cmd_piped_service_service_proto_msgTypes[3] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use InstallToolResponse.ProtoReflect.Descriptor instead. +func (*InstallToolResponse) Descriptor() ([]byte, []int) { + return file_pkg_app_pipedv1_cmd_piped_service_service_proto_rawDescGZIP(), []int{3} +} + +func (x *InstallToolResponse) GetInstalledPath() string { + if x != nil { + return x.InstalledPath + } + return "" +} + var File_pkg_app_pipedv1_cmd_piped_service_service_proto protoreflect.FileDescriptor var file_pkg_app_pipedv1_cmd_piped_service_service_proto_rawDesc = []byte{ @@ -145,19 +273,38 @@ var file_pkg_app_pipedv1_cmd_piped_service_service_proto_rawDesc = []byte{ 0x70, 0x74, 0x53, 0x65, 0x63, 0x72, 0x65, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x29, 0x0a, 0x10, 0x64, 0x65, 0x63, 0x72, 0x79, 0x70, 0x74, 0x65, 0x64, 0x5f, 0x73, 0x65, 0x63, 0x72, 0x65, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0f, 0x64, 0x65, 0x63, 0x72, - 0x79, 0x70, 0x74, 0x65, 0x64, 0x53, 0x65, 0x63, 0x72, 0x65, 0x74, 0x32, 0x77, 0x0a, 0x0d, 0x50, - 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x66, 0x0a, 0x0d, - 0x44, 0x65, 0x63, 0x72, 0x79, 0x70, 0x74, 0x53, 0x65, 0x63, 0x72, 0x65, 0x74, 0x12, 0x28, 0x2e, + 0x79, 0x70, 0x74, 0x65, 0x64, 0x53, 0x65, 0x63, 0x72, 0x65, 0x74, 0x22, 0x84, 0x01, 0x0a, 0x12, + 0x49, 0x6e, 0x73, 0x74, 0x61, 0x6c, 0x6c, 0x54, 0x6f, 0x6f, 0x6c, 0x52, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x12, 0x1b, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, + 0x42, 0x07, 0xfa, 0x42, 0x04, 0x72, 0x02, 0x10, 0x01, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, + 0x21, 0x0a, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, + 0x42, 0x07, 0xfa, 0x42, 0x04, 0x72, 0x02, 0x10, 0x01, 0x52, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, + 0x6f, 0x6e, 0x12, 0x2e, 0x0a, 0x0e, 0x69, 0x6e, 0x73, 0x74, 0x61, 0x6c, 0x6c, 0x5f, 0x73, 0x63, + 0x72, 0x69, 0x70, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x42, 0x07, 0xfa, 0x42, 0x04, 0x72, + 0x02, 0x10, 0x01, 0x52, 0x0d, 0x69, 0x6e, 0x73, 0x74, 0x61, 0x6c, 0x6c, 0x53, 0x63, 0x72, 0x69, + 0x70, 0x74, 0x22, 0x3c, 0x0a, 0x13, 0x49, 0x6e, 0x73, 0x74, 0x61, 0x6c, 0x6c, 0x54, 0x6f, 0x6f, + 0x6c, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x25, 0x0a, 0x0e, 0x69, 0x6e, 0x73, + 0x74, 0x61, 0x6c, 0x6c, 0x65, 0x64, 0x5f, 0x70, 0x61, 0x74, 0x68, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x0d, 0x69, 0x6e, 0x73, 0x74, 0x61, 0x6c, 0x6c, 0x65, 0x64, 0x50, 0x61, 0x74, 0x68, + 0x32, 0xd9, 0x01, 0x0a, 0x0d, 0x50, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x53, 0x65, 0x72, 0x76, 0x69, + 0x63, 0x65, 0x12, 0x66, 0x0a, 0x0d, 0x44, 0x65, 0x63, 0x72, 0x79, 0x70, 0x74, 0x53, 0x65, 0x63, + 0x72, 0x65, 0x74, 0x12, 0x28, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x70, 0x69, 0x70, 0x65, 0x64, + 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x44, 0x65, 0x63, 0x72, 0x79, 0x70, 0x74, + 0x53, 0x65, 0x63, 0x72, 0x65, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x29, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x70, 0x69, 0x70, 0x65, 0x64, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x44, 0x65, 0x63, 0x72, 0x79, 0x70, 0x74, 0x53, 0x65, 0x63, 0x72, 0x65, 0x74, - 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x29, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x70, - 0x69, 0x70, 0x65, 0x64, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x44, 0x65, 0x63, - 0x72, 0x79, 0x70, 0x74, 0x53, 0x65, 0x63, 0x72, 0x65, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, - 0x73, 0x65, 0x22, 0x00, 0x42, 0x3d, 0x5a, 0x3b, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, - 0x6f, 0x6d, 0x2f, 0x70, 0x69, 0x70, 0x65, 0x2d, 0x63, 0x64, 0x2f, 0x70, 0x69, 0x70, 0x65, 0x63, - 0x64, 0x2f, 0x70, 0x6b, 0x67, 0x2f, 0x61, 0x70, 0x70, 0x2f, 0x70, 0x69, 0x70, 0x65, 0x64, 0x76, - 0x31, 0x2f, 0x63, 0x6d, 0x64, 0x2f, 0x70, 0x69, 0x70, 0x65, 0x64, 0x2f, 0x73, 0x65, 0x72, 0x76, - 0x69, 0x63, 0x65, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, + 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x60, 0x0a, 0x0b, 0x49, 0x6e, + 0x73, 0x74, 0x61, 0x6c, 0x6c, 0x54, 0x6f, 0x6f, 0x6c, 0x12, 0x26, 0x2e, 0x67, 0x72, 0x70, 0x63, + 0x2e, 0x70, 0x69, 0x70, 0x65, 0x64, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x49, + 0x6e, 0x73, 0x74, 0x61, 0x6c, 0x6c, 0x54, 0x6f, 0x6f, 0x6c, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, + 0x74, 0x1a, 0x27, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x70, 0x69, 0x70, 0x65, 0x64, 0x2e, 0x73, + 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x49, 0x6e, 0x73, 0x74, 0x61, 0x6c, 0x6c, 0x54, 0x6f, + 0x6f, 0x6c, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x42, 0x3d, 0x5a, 0x3b, + 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x70, 0x69, 0x70, 0x65, 0x2d, + 0x63, 0x64, 0x2f, 0x70, 0x69, 0x70, 0x65, 0x63, 0x64, 0x2f, 0x70, 0x6b, 0x67, 0x2f, 0x61, 0x70, + 0x70, 0x2f, 0x70, 0x69, 0x70, 0x65, 0x64, 0x76, 0x31, 0x2f, 0x63, 0x6d, 0x64, 0x2f, 0x70, 0x69, + 0x70, 0x65, 0x64, 0x2f, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x62, 0x06, 0x70, 0x72, 0x6f, + 0x74, 0x6f, 0x33, } var ( @@ -172,16 +319,20 @@ func file_pkg_app_pipedv1_cmd_piped_service_service_proto_rawDescGZIP() []byte { return file_pkg_app_pipedv1_cmd_piped_service_service_proto_rawDescData } -var file_pkg_app_pipedv1_cmd_piped_service_service_proto_msgTypes = make([]protoimpl.MessageInfo, 2) +var file_pkg_app_pipedv1_cmd_piped_service_service_proto_msgTypes = make([]protoimpl.MessageInfo, 4) var file_pkg_app_pipedv1_cmd_piped_service_service_proto_goTypes = []interface{}{ (*DecryptSecretRequest)(nil), // 0: grpc.piped.service.DecryptSecretRequest (*DecryptSecretResponse)(nil), // 1: grpc.piped.service.DecryptSecretResponse + (*InstallToolRequest)(nil), // 2: grpc.piped.service.InstallToolRequest + (*InstallToolResponse)(nil), // 3: grpc.piped.service.InstallToolResponse } var file_pkg_app_pipedv1_cmd_piped_service_service_proto_depIdxs = []int32{ 0, // 0: grpc.piped.service.PluginService.DecryptSecret:input_type -> grpc.piped.service.DecryptSecretRequest - 1, // 1: grpc.piped.service.PluginService.DecryptSecret:output_type -> grpc.piped.service.DecryptSecretResponse - 1, // [1:2] is the sub-list for method output_type - 0, // [0:1] is the sub-list for method input_type + 2, // 1: grpc.piped.service.PluginService.InstallTool:input_type -> grpc.piped.service.InstallToolRequest + 1, // 2: grpc.piped.service.PluginService.DecryptSecret:output_type -> grpc.piped.service.DecryptSecretResponse + 3, // 3: grpc.piped.service.PluginService.InstallTool:output_type -> grpc.piped.service.InstallToolResponse + 2, // [2:4] is the sub-list for method output_type + 0, // [0:2] is the sub-list for method input_type 0, // [0:0] is the sub-list for extension type_name 0, // [0:0] is the sub-list for extension extendee 0, // [0:0] is the sub-list for field type_name @@ -217,6 +368,30 @@ func file_pkg_app_pipedv1_cmd_piped_service_service_proto_init() { return nil } } + file_pkg_app_pipedv1_cmd_piped_service_service_proto_msgTypes[2].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*InstallToolRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_pkg_app_pipedv1_cmd_piped_service_service_proto_msgTypes[3].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*InstallToolResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } } type x struct{} out := protoimpl.TypeBuilder{ @@ -224,7 +399,7 @@ func file_pkg_app_pipedv1_cmd_piped_service_service_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: file_pkg_app_pipedv1_cmd_piped_service_service_proto_rawDesc, NumEnums: 0, - NumMessages: 2, + NumMessages: 4, NumExtensions: 0, NumServices: 1, }, diff --git a/pkg/app/pipedv1/cmd/piped/service/service.pb.validate.go b/pkg/app/pipedv1/cmd/piped/service/service.pb.validate.go index 2bf6916868..a81734ec1a 100644 --- a/pkg/app/pipedv1/cmd/piped/service/service.pb.validate.go +++ b/pkg/app/pipedv1/cmd/piped/service/service.pb.validate.go @@ -251,3 +251,242 @@ var _ interface { Cause() error ErrorName() string } = DecryptSecretResponseValidationError{} + +// Validate checks the field values on InstallToolRequest with the rules +// defined in the proto definition for this message. If any rules are +// violated, the first error encountered is returned, or nil if there are no violations. +func (m *InstallToolRequest) Validate() error { + return m.validate(false) +} + +// ValidateAll checks the field values on InstallToolRequest with the rules +// defined in the proto definition for this message. If any rules are +// violated, the result is a list of violation errors wrapped in +// InstallToolRequestMultiError, or nil if none found. +func (m *InstallToolRequest) ValidateAll() error { + return m.validate(true) +} + +func (m *InstallToolRequest) validate(all bool) error { + if m == nil { + return nil + } + + var errors []error + + if utf8.RuneCountInString(m.GetName()) < 1 { + err := InstallToolRequestValidationError{ + field: "Name", + reason: "value length must be at least 1 runes", + } + if !all { + return err + } + errors = append(errors, err) + } + + if utf8.RuneCountInString(m.GetVersion()) < 1 { + err := InstallToolRequestValidationError{ + field: "Version", + reason: "value length must be at least 1 runes", + } + if !all { + return err + } + errors = append(errors, err) + } + + if utf8.RuneCountInString(m.GetInstallScript()) < 1 { + err := InstallToolRequestValidationError{ + field: "InstallScript", + reason: "value length must be at least 1 runes", + } + if !all { + return err + } + errors = append(errors, err) + } + + if len(errors) > 0 { + return InstallToolRequestMultiError(errors) + } + + return nil +} + +// InstallToolRequestMultiError is an error wrapping multiple validation errors +// returned by InstallToolRequest.ValidateAll() if the designated constraints +// aren't met. +type InstallToolRequestMultiError []error + +// Error returns a concatenation of all the error messages it wraps. +func (m InstallToolRequestMultiError) Error() string { + var msgs []string + for _, err := range m { + msgs = append(msgs, err.Error()) + } + return strings.Join(msgs, "; ") +} + +// AllErrors returns a list of validation violation errors. +func (m InstallToolRequestMultiError) AllErrors() []error { return m } + +// InstallToolRequestValidationError is the validation error returned by +// InstallToolRequest.Validate if the designated constraints aren't met. +type InstallToolRequestValidationError struct { + field string + reason string + cause error + key bool +} + +// Field function returns field value. +func (e InstallToolRequestValidationError) Field() string { return e.field } + +// Reason function returns reason value. +func (e InstallToolRequestValidationError) Reason() string { return e.reason } + +// Cause function returns cause value. +func (e InstallToolRequestValidationError) Cause() error { return e.cause } + +// Key function returns key value. +func (e InstallToolRequestValidationError) Key() bool { return e.key } + +// ErrorName returns error name. +func (e InstallToolRequestValidationError) ErrorName() string { + return "InstallToolRequestValidationError" +} + +// Error satisfies the builtin error interface +func (e InstallToolRequestValidationError) Error() string { + cause := "" + if e.cause != nil { + cause = fmt.Sprintf(" | caused by: %v", e.cause) + } + + key := "" + if e.key { + key = "key for " + } + + return fmt.Sprintf( + "invalid %sInstallToolRequest.%s: %s%s", + key, + e.field, + e.reason, + cause) +} + +var _ error = InstallToolRequestValidationError{} + +var _ interface { + Field() string + Reason() string + Key() bool + Cause() error + ErrorName() string +} = InstallToolRequestValidationError{} + +// Validate checks the field values on InstallToolResponse with the rules +// defined in the proto definition for this message. If any rules are +// violated, the first error encountered is returned, or nil if there are no violations. +func (m *InstallToolResponse) Validate() error { + return m.validate(false) +} + +// ValidateAll checks the field values on InstallToolResponse with the rules +// defined in the proto definition for this message. If any rules are +// violated, the result is a list of violation errors wrapped in +// InstallToolResponseMultiError, or nil if none found. +func (m *InstallToolResponse) ValidateAll() error { + return m.validate(true) +} + +func (m *InstallToolResponse) validate(all bool) error { + if m == nil { + return nil + } + + var errors []error + + // no validation rules for InstalledPath + + if len(errors) > 0 { + return InstallToolResponseMultiError(errors) + } + + return nil +} + +// InstallToolResponseMultiError is an error wrapping multiple validation +// errors returned by InstallToolResponse.ValidateAll() if the designated +// constraints aren't met. +type InstallToolResponseMultiError []error + +// Error returns a concatenation of all the error messages it wraps. +func (m InstallToolResponseMultiError) Error() string { + var msgs []string + for _, err := range m { + msgs = append(msgs, err.Error()) + } + return strings.Join(msgs, "; ") +} + +// AllErrors returns a list of validation violation errors. +func (m InstallToolResponseMultiError) AllErrors() []error { return m } + +// InstallToolResponseValidationError is the validation error returned by +// InstallToolResponse.Validate if the designated constraints aren't met. +type InstallToolResponseValidationError struct { + field string + reason string + cause error + key bool +} + +// Field function returns field value. +func (e InstallToolResponseValidationError) Field() string { return e.field } + +// Reason function returns reason value. +func (e InstallToolResponseValidationError) Reason() string { return e.reason } + +// Cause function returns cause value. +func (e InstallToolResponseValidationError) Cause() error { return e.cause } + +// Key function returns key value. +func (e InstallToolResponseValidationError) Key() bool { return e.key } + +// ErrorName returns error name. +func (e InstallToolResponseValidationError) ErrorName() string { + return "InstallToolResponseValidationError" +} + +// Error satisfies the builtin error interface +func (e InstallToolResponseValidationError) Error() string { + cause := "" + if e.cause != nil { + cause = fmt.Sprintf(" | caused by: %v", e.cause) + } + + key := "" + if e.key { + key = "key for " + } + + return fmt.Sprintf( + "invalid %sInstallToolResponse.%s: %s%s", + key, + e.field, + e.reason, + cause) +} + +var _ error = InstallToolResponseValidationError{} + +var _ interface { + Field() string + Reason() string + Key() bool + Cause() error + ErrorName() string +} = InstallToolResponseValidationError{} diff --git a/pkg/app/pipedv1/cmd/piped/service/service.proto b/pkg/app/pipedv1/cmd/piped/service/service.proto index 83d7f8a76e..777ae9d3eb 100644 --- a/pkg/app/pipedv1/cmd/piped/service/service.proto +++ b/pkg/app/pipedv1/cmd/piped/service/service.proto @@ -23,6 +23,9 @@ import "validate/validate.proto"; service PluginService { // DecryptSecret decrypts the given secret. rpc DecryptSecret(DecryptSecretRequest) returns (DecryptSecretResponse) {} + // InstallTool installs the given tool. + // installed binary's filename becomes `name-version`. + rpc InstallTool(InstallToolRequest) returns (InstallToolResponse) {} } message DecryptSecretRequest { @@ -32,3 +35,31 @@ message DecryptSecretRequest { message DecryptSecretResponse { string decrypted_secret = 1; } + +message InstallToolRequest { + // Name of the tool. + string name = 1 [(validate.rules).string.min_len = 1]; + // Version of the tool. + string version = 2 [(validate.rules).string.min_len = 1]; + // Install script to install the tool. + // This script is templated with the following variables: + // - {{ .Name }}: name of the tool. + // - {{ .Version }}: version of the tool. + // - {{ .OutPath }}: file path where the tool will be installed. + // - {{ .TmpDir }}: directory where the tool will be downloaded and extracted. + // - {{ .Arch }}: GOARCH of the current machine. + // - {{ .Os }}: GOOS of the current machine. + // + // The script should return 0 if the installation is successful. + // Otherwise, it should return a non-zero value. + // + // The tool should be placed at {{ .OutPath }} + // e.g.) cp path/to/kubectl {{ .OutPath }} + // Then piped move it to the correct directory / filename and make it executable. + string install_script = 3 [(validate.rules).string.min_len = 1]; +} + +message InstallToolResponse { + // Installed path of the tool. + string installed_path = 1; +} diff --git a/pkg/app/pipedv1/cmd/piped/service/service_grpc.pb.go b/pkg/app/pipedv1/cmd/piped/service/service_grpc.pb.go index 62538ebf0c..9ccc290e02 100644 --- a/pkg/app/pipedv1/cmd/piped/service/service_grpc.pb.go +++ b/pkg/app/pipedv1/cmd/piped/service/service_grpc.pb.go @@ -24,6 +24,9 @@ const _ = grpc.SupportPackageIsVersion7 type PluginServiceClient interface { // DecryptSecret decrypts the given secret. DecryptSecret(ctx context.Context, in *DecryptSecretRequest, opts ...grpc.CallOption) (*DecryptSecretResponse, error) + // InstallTool installs the given tool. + // installed binary's filename becomes `name-version`. + InstallTool(ctx context.Context, in *InstallToolRequest, opts ...grpc.CallOption) (*InstallToolResponse, error) } type pluginServiceClient struct { @@ -43,12 +46,24 @@ func (c *pluginServiceClient) DecryptSecret(ctx context.Context, in *DecryptSecr return out, nil } +func (c *pluginServiceClient) InstallTool(ctx context.Context, in *InstallToolRequest, opts ...grpc.CallOption) (*InstallToolResponse, error) { + out := new(InstallToolResponse) + err := c.cc.Invoke(ctx, "/grpc.piped.service.PluginService/InstallTool", in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + // PluginServiceServer is the server API for PluginService service. // All implementations must embed UnimplementedPluginServiceServer // for forward compatibility type PluginServiceServer interface { // DecryptSecret decrypts the given secret. DecryptSecret(context.Context, *DecryptSecretRequest) (*DecryptSecretResponse, error) + // InstallTool installs the given tool. + // installed binary's filename becomes `name-version`. + InstallTool(context.Context, *InstallToolRequest) (*InstallToolResponse, error) mustEmbedUnimplementedPluginServiceServer() } @@ -59,6 +74,9 @@ type UnimplementedPluginServiceServer struct { func (UnimplementedPluginServiceServer) DecryptSecret(context.Context, *DecryptSecretRequest) (*DecryptSecretResponse, error) { return nil, status.Errorf(codes.Unimplemented, "method DecryptSecret not implemented") } +func (UnimplementedPluginServiceServer) InstallTool(context.Context, *InstallToolRequest) (*InstallToolResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method InstallTool not implemented") +} func (UnimplementedPluginServiceServer) mustEmbedUnimplementedPluginServiceServer() {} // UnsafePluginServiceServer may be embedded to opt out of forward compatibility for this service. @@ -90,6 +108,24 @@ func _PluginService_DecryptSecret_Handler(srv interface{}, ctx context.Context, return interceptor(ctx, in, info, handler) } +func _PluginService_InstallTool_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(InstallToolRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(PluginServiceServer).InstallTool(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: "/grpc.piped.service.PluginService/InstallTool", + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(PluginServiceServer).InstallTool(ctx, req.(*InstallToolRequest)) + } + return interceptor(ctx, in, info, handler) +} + // PluginService_ServiceDesc is the grpc.ServiceDesc for PluginService service. // It's only intended for direct use with grpc.RegisterService, // and not to be introspected or modified (even as a copy) @@ -101,6 +137,10 @@ var PluginService_ServiceDesc = grpc.ServiceDesc{ MethodName: "DecryptSecret", Handler: _PluginService_DecryptSecret_Handler, }, + { + MethodName: "InstallTool", + Handler: _PluginService_InstallTool_Handler, + }, }, Streams: []grpc.StreamDesc{}, Metadata: "pkg/app/pipedv1/cmd/piped/service/service.proto", From c3386cf0fcb37c7eebce7177feea16771796e3dc Mon Sep 17 00:00:00 2001 From: Shinnosuke Sawada-Dazai Date: Wed, 25 Sep 2024 15:03:54 +0900 Subject: [PATCH 18/84] Add test for buildQuickSyncPipeline (#5227) Signed-off-by: Shinnosuke Sawada-Dazai --- .../plugin/kubernetes/deployment/pipeline.go | 21 ++++- .../kubernetes/deployment/pipeline_test.go | 89 +++++++++++++++++++ 2 files changed, 107 insertions(+), 3 deletions(-) create mode 100644 pkg/app/pipedv1/plugin/kubernetes/deployment/pipeline_test.go diff --git a/pkg/app/pipedv1/plugin/kubernetes/deployment/pipeline.go b/pkg/app/pipedv1/plugin/kubernetes/deployment/pipeline.go index e4292bac00..388aa5f959 100644 --- a/pkg/app/pipedv1/plugin/kubernetes/deployment/pipeline.go +++ b/pkg/app/pipedv1/plugin/kubernetes/deployment/pipeline.go @@ -47,6 +47,8 @@ const ( // StageK8sTrafficRouting represents the state where the traffic to application // should be splitted as the specified percentage to PRIMARY, CANARY, BASELINE variants. StageK8sTrafficRouting Stage = "K8S_TRAFFIC_ROUTING" + // StageK8sRollback represents the state where all deployed resources should be rollbacked. + StageK8sRollback Stage = "K8S_ROLLBACK" ) var AllStages = []Stage{ @@ -57,19 +59,32 @@ var AllStages = []Stage{ StageK8sBaselineRollout, StageK8sBaselineClean, StageK8sTrafficRouting, + StageK8sRollback, +} + +func (s Stage) String() string { + return string(s) } const ( PredefinedStageK8sSync = "K8sSync" - PredefinedStageRollback = "Rollback" + PredefinedStageRollback = "K8sRollback" ) var predefinedStages = map[string]config.PipelineStage{ PredefinedStageK8sSync: { - ID: PredefinedStageK8sSync, - Name: model.StageK8sSync, + ID: PredefinedStageK8sSync, + // TODO: we have to change config.PipelineStage.Name to string before releasing pipedv1? + // because we don't want to define stages at piped side. We want to define them at the plugin side. + // Or plugins should use the model.Stage type instead of string or some defined type. + Name: model.Stage(StageK8sSync), Desc: "Sync by applying all manifests", }, + PredefinedStageRollback: { + ID: PredefinedStageRollback, + Name: model.Stage(StageK8sRollback), + Desc: "Rollback the deployment", + }, } // GetPredefinedStage finds and returns the predefined stage for the given id. diff --git a/pkg/app/pipedv1/plugin/kubernetes/deployment/pipeline_test.go b/pkg/app/pipedv1/plugin/kubernetes/deployment/pipeline_test.go new file mode 100644 index 0000000000..26f608ed77 --- /dev/null +++ b/pkg/app/pipedv1/plugin/kubernetes/deployment/pipeline_test.go @@ -0,0 +1,89 @@ +// 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 deployment + +import ( + "testing" + "time" + + "github.com/pipe-cd/pipecd/pkg/model" + "github.com/stretchr/testify/assert" +) + +func TestBuildQuickSyncPipeline(t *testing.T) { + t.Parallel() + + now := time.Now() + + tests := []struct { + name string + autoRollback bool + expected []*model.PipelineStage + }{ + { + name: "without auto rollback", + autoRollback: false, + expected: []*model.PipelineStage{ + { + Id: PredefinedStageK8sSync, + Name: StageK8sSync.String(), + Desc: "Sync by applying all manifests", + Index: 0, + Predefined: true, + Visible: true, + Status: model.StageStatus_STAGE_NOT_STARTED_YET, + Metadata: nil, + CreatedAt: now.Unix(), + UpdatedAt: now.Unix(), + }, + }, + }, + { + name: "with auto rollback", + autoRollback: true, + expected: []*model.PipelineStage{ + { + Id: PredefinedStageK8sSync, + Name: StageK8sSync.String(), + Desc: "Sync by applying all manifests", + Index: 0, + Predefined: true, + Visible: true, + Status: model.StageStatus_STAGE_NOT_STARTED_YET, + Metadata: nil, + CreatedAt: now.Unix(), + UpdatedAt: now.Unix(), + }, + { + Id: PredefinedStageRollback, + Name: StageK8sRollback.String(), + Desc: "Rollback the deployment", + Predefined: true, + Visible: false, + Status: model.StageStatus_STAGE_NOT_STARTED_YET, + CreatedAt: now.Unix(), + UpdatedAt: now.Unix(), + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + actual := buildQuickSyncPipeline(tt.autoRollback, now) + assert.Equal(t, tt.expected, actual) + }) + } +} From 32b33b4c61e44a27299cc8012041f970cba043ad Mon Sep 17 00:00:00 2001 From: Shinnosuke Sawada-Dazai Date: Thu, 26 Sep 2024 17:00:24 +0900 Subject: [PATCH 19/84] Add stage_index field at BuildQuickSyncStagesRequest (#5229) Signed-off-by: Shinnosuke Sawada-Dazai --- pkg/plugin/api/v1alpha1/deployment/api.pb.go | 170 +++++++++--------- .../v1alpha1/deployment/api.pb.validate.go | 2 + pkg/plugin/api/v1alpha1/deployment/api.proto | 3 +- 3 files changed, 94 insertions(+), 81 deletions(-) diff --git a/pkg/plugin/api/v1alpha1/deployment/api.pb.go b/pkg/plugin/api/v1alpha1/deployment/api.pb.go index 38c10b894e..118fa6d71d 100644 --- a/pkg/plugin/api/v1alpha1/deployment/api.pb.go +++ b/pkg/plugin/api/v1alpha1/deployment/api.pb.go @@ -343,7 +343,8 @@ type BuildQuickSyncStagesRequest struct { sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields - Rollback bool `protobuf:"varint,1,opt,name=rollback,proto3" json:"rollback,omitempty"` + StageIndex int32 `protobuf:"varint,1,opt,name=stage_index,json=stageIndex,proto3" json:"stage_index,omitempty"` + Rollback bool `protobuf:"varint,2,opt,name=rollback,proto3" json:"rollback,omitempty"` } func (x *BuildQuickSyncStagesRequest) Reset() { @@ -378,6 +379,13 @@ func (*BuildQuickSyncStagesRequest) Descriptor() ([]byte, []int) { return file_pkg_plugin_api_v1alpha1_deployment_api_proto_rawDescGZIP(), []int{6} } +func (x *BuildQuickSyncStagesRequest) GetStageIndex() int32 { + if x != nil { + return x.StageIndex + } + return 0 +} + func (x *BuildQuickSyncStagesRequest) GetRollback() bool { if x != nil { return x.Rollback @@ -757,92 +765,94 @@ var file_pkg_plugin_api_v1alpha1_deployment_api_proto_rawDesc = []byte{ 0x73, 0x65, 0x12, 0x2c, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x67, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x2e, 0x50, 0x69, 0x70, 0x65, 0x6c, 0x69, 0x6e, 0x65, 0x53, 0x74, 0x61, 0x67, 0x65, 0x52, 0x06, 0x73, 0x74, 0x61, 0x67, 0x65, 0x73, - 0x22, 0x39, 0x0a, 0x1b, 0x42, 0x75, 0x69, 0x6c, 0x64, 0x51, 0x75, 0x69, 0x63, 0x6b, 0x53, 0x79, + 0x22, 0x5a, 0x0a, 0x1b, 0x42, 0x75, 0x69, 0x6c, 0x64, 0x51, 0x75, 0x69, 0x63, 0x6b, 0x53, 0x79, 0x6e, 0x63, 0x53, 0x74, 0x61, 0x67, 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, - 0x1a, 0x0a, 0x08, 0x72, 0x6f, 0x6c, 0x6c, 0x62, 0x61, 0x63, 0x6b, 0x18, 0x01, 0x20, 0x01, 0x28, - 0x08, 0x52, 0x08, 0x72, 0x6f, 0x6c, 0x6c, 0x62, 0x61, 0x63, 0x6b, 0x22, 0x4c, 0x0a, 0x1c, 0x42, - 0x75, 0x69, 0x6c, 0x64, 0x51, 0x75, 0x69, 0x63, 0x6b, 0x53, 0x79, 0x6e, 0x63, 0x53, 0x74, 0x61, - 0x67, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x2c, 0x0a, 0x06, 0x73, - 0x74, 0x61, 0x67, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x6d, 0x6f, - 0x64, 0x65, 0x6c, 0x2e, 0x50, 0x69, 0x70, 0x65, 0x6c, 0x69, 0x6e, 0x65, 0x53, 0x74, 0x61, 0x67, - 0x65, 0x52, 0x06, 0x73, 0x74, 0x61, 0x67, 0x65, 0x73, 0x22, 0x1b, 0x0a, 0x19, 0x46, 0x65, 0x74, - 0x63, 0x68, 0x44, 0x65, 0x66, 0x69, 0x6e, 0x65, 0x64, 0x53, 0x74, 0x61, 0x67, 0x65, 0x73, 0x52, - 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0x34, 0x0a, 0x1a, 0x46, 0x65, 0x74, 0x63, 0x68, 0x44, - 0x65, 0x66, 0x69, 0x6e, 0x65, 0x64, 0x53, 0x74, 0x61, 0x67, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, - 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x67, 0x65, 0x73, 0x18, 0x01, - 0x20, 0x03, 0x28, 0x09, 0x52, 0x06, 0x73, 0x74, 0x61, 0x67, 0x65, 0x73, 0x22, 0x9b, 0x02, 0x0a, - 0x0f, 0x50, 0x6c, 0x61, 0x6e, 0x50, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x49, 0x6e, 0x70, 0x75, 0x74, - 0x12, 0x3b, 0x0a, 0x0a, 0x64, 0x65, 0x70, 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x18, 0x01, - 0x20, 0x01, 0x28, 0x0b, 0x32, 0x11, 0x2e, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x2e, 0x44, 0x65, 0x70, - 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x42, 0x08, 0xfa, 0x42, 0x05, 0x8a, 0x01, 0x02, 0x10, - 0x01, 0x52, 0x0a, 0x64, 0x65, 0x70, 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x12, 0x23, 0x0a, - 0x0d, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x5f, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x02, - 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0c, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x43, 0x6f, 0x6e, 0x66, - 0x69, 0x67, 0x12, 0x53, 0x0a, 0x19, 0x72, 0x75, 0x6e, 0x6e, 0x69, 0x6e, 0x67, 0x5f, 0x64, 0x65, - 0x70, 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x5f, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x18, - 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x17, 0x2e, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x2e, 0x44, 0x65, - 0x70, 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x53, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x17, - 0x72, 0x75, 0x6e, 0x6e, 0x69, 0x6e, 0x67, 0x44, 0x65, 0x70, 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, - 0x74, 0x53, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x51, 0x0a, 0x18, 0x74, 0x61, 0x72, 0x67, 0x65, - 0x74, 0x5f, 0x64, 0x65, 0x70, 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x5f, 0x73, 0x6f, 0x75, - 0x72, 0x63, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x17, 0x2e, 0x6d, 0x6f, 0x64, 0x65, - 0x6c, 0x2e, 0x44, 0x65, 0x70, 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x53, 0x6f, 0x75, 0x72, - 0x63, 0x65, 0x52, 0x16, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x44, 0x65, 0x70, 0x6c, 0x6f, 0x79, - 0x6d, 0x65, 0x6e, 0x74, 0x53, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x32, 0x9a, 0x06, 0x0a, 0x11, 0x44, - 0x65, 0x70, 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, - 0x12, 0x95, 0x01, 0x0a, 0x12, 0x46, 0x65, 0x74, 0x63, 0x68, 0x44, 0x65, 0x66, 0x69, 0x6e, 0x65, - 0x64, 0x53, 0x74, 0x61, 0x67, 0x65, 0x73, 0x12, 0x3d, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x70, + 0x1f, 0x0a, 0x0b, 0x73, 0x74, 0x61, 0x67, 0x65, 0x5f, 0x69, 0x6e, 0x64, 0x65, 0x78, 0x18, 0x01, + 0x20, 0x01, 0x28, 0x05, 0x52, 0x0a, 0x73, 0x74, 0x61, 0x67, 0x65, 0x49, 0x6e, 0x64, 0x65, 0x78, + 0x12, 0x1a, 0x0a, 0x08, 0x72, 0x6f, 0x6c, 0x6c, 0x62, 0x61, 0x63, 0x6b, 0x18, 0x02, 0x20, 0x01, + 0x28, 0x08, 0x52, 0x08, 0x72, 0x6f, 0x6c, 0x6c, 0x62, 0x61, 0x63, 0x6b, 0x22, 0x4c, 0x0a, 0x1c, + 0x42, 0x75, 0x69, 0x6c, 0x64, 0x51, 0x75, 0x69, 0x63, 0x6b, 0x53, 0x79, 0x6e, 0x63, 0x53, 0x74, + 0x61, 0x67, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x2c, 0x0a, 0x06, + 0x73, 0x74, 0x61, 0x67, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x6d, + 0x6f, 0x64, 0x65, 0x6c, 0x2e, 0x50, 0x69, 0x70, 0x65, 0x6c, 0x69, 0x6e, 0x65, 0x53, 0x74, 0x61, + 0x67, 0x65, 0x52, 0x06, 0x73, 0x74, 0x61, 0x67, 0x65, 0x73, 0x22, 0x1b, 0x0a, 0x19, 0x46, 0x65, + 0x74, 0x63, 0x68, 0x44, 0x65, 0x66, 0x69, 0x6e, 0x65, 0x64, 0x53, 0x74, 0x61, 0x67, 0x65, 0x73, + 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0x34, 0x0a, 0x1a, 0x46, 0x65, 0x74, 0x63, 0x68, + 0x44, 0x65, 0x66, 0x69, 0x6e, 0x65, 0x64, 0x53, 0x74, 0x61, 0x67, 0x65, 0x73, 0x52, 0x65, 0x73, + 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x67, 0x65, 0x73, 0x18, + 0x01, 0x20, 0x03, 0x28, 0x09, 0x52, 0x06, 0x73, 0x74, 0x61, 0x67, 0x65, 0x73, 0x22, 0x9b, 0x02, + 0x0a, 0x0f, 0x50, 0x6c, 0x61, 0x6e, 0x50, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x49, 0x6e, 0x70, 0x75, + 0x74, 0x12, 0x3b, 0x0a, 0x0a, 0x64, 0x65, 0x70, 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x18, + 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x11, 0x2e, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x2e, 0x44, 0x65, + 0x70, 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x42, 0x08, 0xfa, 0x42, 0x05, 0x8a, 0x01, 0x02, + 0x10, 0x01, 0x52, 0x0a, 0x64, 0x65, 0x70, 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x12, 0x23, + 0x0a, 0x0d, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x5f, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, + 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0c, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x43, 0x6f, 0x6e, + 0x66, 0x69, 0x67, 0x12, 0x53, 0x0a, 0x19, 0x72, 0x75, 0x6e, 0x6e, 0x69, 0x6e, 0x67, 0x5f, 0x64, + 0x65, 0x70, 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x5f, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, + 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x17, 0x2e, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x2e, 0x44, + 0x65, 0x70, 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x53, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, + 0x17, 0x72, 0x75, 0x6e, 0x6e, 0x69, 0x6e, 0x67, 0x44, 0x65, 0x70, 0x6c, 0x6f, 0x79, 0x6d, 0x65, + 0x6e, 0x74, 0x53, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x51, 0x0a, 0x18, 0x74, 0x61, 0x72, 0x67, + 0x65, 0x74, 0x5f, 0x64, 0x65, 0x70, 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x5f, 0x73, 0x6f, + 0x75, 0x72, 0x63, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x17, 0x2e, 0x6d, 0x6f, 0x64, + 0x65, 0x6c, 0x2e, 0x44, 0x65, 0x70, 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x53, 0x6f, 0x75, + 0x72, 0x63, 0x65, 0x52, 0x16, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x44, 0x65, 0x70, 0x6c, 0x6f, + 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x53, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x32, 0x9a, 0x06, 0x0a, 0x11, + 0x44, 0x65, 0x70, 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, + 0x65, 0x12, 0x95, 0x01, 0x0a, 0x12, 0x46, 0x65, 0x74, 0x63, 0x68, 0x44, 0x65, 0x66, 0x69, 0x6e, + 0x65, 0x64, 0x53, 0x74, 0x61, 0x67, 0x65, 0x73, 0x12, 0x3d, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, + 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x2e, 0x64, 0x65, 0x70, 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, + 0x74, 0x61, 0x70, 0x69, 0x2e, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2e, 0x46, 0x65, + 0x74, 0x63, 0x68, 0x44, 0x65, 0x66, 0x69, 0x6e, 0x65, 0x64, 0x53, 0x74, 0x61, 0x67, 0x65, 0x73, + 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x3e, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x2e, 0x64, 0x65, 0x70, 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x70, 0x69, 0x2e, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2e, 0x46, 0x65, 0x74, 0x63, 0x68, 0x44, 0x65, 0x66, 0x69, 0x6e, 0x65, 0x64, 0x53, 0x74, 0x61, 0x67, 0x65, 0x73, 0x52, - 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x3e, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x70, 0x6c, - 0x75, 0x67, 0x69, 0x6e, 0x2e, 0x64, 0x65, 0x70, 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x61, - 0x70, 0x69, 0x2e, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2e, 0x46, 0x65, 0x74, 0x63, - 0x68, 0x44, 0x65, 0x66, 0x69, 0x6e, 0x65, 0x64, 0x53, 0x74, 0x61, 0x67, 0x65, 0x73, 0x52, 0x65, - 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x92, 0x01, 0x0a, 0x11, 0x44, 0x65, 0x74, - 0x65, 0x72, 0x6d, 0x69, 0x6e, 0x65, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x3c, - 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x2e, 0x64, 0x65, 0x70, - 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x70, 0x69, 0x2e, 0x76, 0x31, 0x61, 0x6c, 0x70, - 0x68, 0x61, 0x31, 0x2e, 0x44, 0x65, 0x74, 0x65, 0x72, 0x6d, 0x69, 0x6e, 0x65, 0x56, 0x65, 0x72, - 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x3d, 0x2e, 0x67, - 0x72, 0x70, 0x63, 0x2e, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x2e, 0x64, 0x65, 0x70, 0x6c, 0x6f, - 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x70, 0x69, 0x2e, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, - 0x31, 0x2e, 0x44, 0x65, 0x74, 0x65, 0x72, 0x6d, 0x69, 0x6e, 0x65, 0x56, 0x65, 0x72, 0x73, 0x69, - 0x6f, 0x6e, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x92, 0x01, - 0x0a, 0x11, 0x44, 0x65, 0x74, 0x65, 0x72, 0x6d, 0x69, 0x6e, 0x65, 0x53, 0x74, 0x72, 0x61, 0x74, - 0x65, 0x67, 0x79, 0x12, 0x3c, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x70, 0x6c, 0x75, 0x67, 0x69, - 0x6e, 0x2e, 0x64, 0x65, 0x70, 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x70, 0x69, 0x2e, - 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2e, 0x44, 0x65, 0x74, 0x65, 0x72, 0x6d, 0x69, - 0x6e, 0x65, 0x53, 0x74, 0x72, 0x61, 0x74, 0x65, 0x67, 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, - 0x74, 0x1a, 0x3d, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x2e, - 0x64, 0x65, 0x70, 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x70, 0x69, 0x2e, 0x76, 0x31, - 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2e, 0x44, 0x65, 0x74, 0x65, 0x72, 0x6d, 0x69, 0x6e, 0x65, - 0x53, 0x74, 0x72, 0x61, 0x74, 0x65, 0x67, 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, - 0x22, 0x00, 0x12, 0xa4, 0x01, 0x0a, 0x17, 0x42, 0x75, 0x69, 0x6c, 0x64, 0x50, 0x69, 0x70, 0x65, - 0x6c, 0x69, 0x6e, 0x65, 0x53, 0x79, 0x6e, 0x63, 0x53, 0x74, 0x61, 0x67, 0x65, 0x73, 0x12, 0x42, - 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x2e, 0x64, 0x65, 0x70, - 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x70, 0x69, 0x2e, 0x76, 0x31, 0x61, 0x6c, 0x70, - 0x68, 0x61, 0x31, 0x2e, 0x42, 0x75, 0x69, 0x6c, 0x64, 0x50, 0x69, 0x70, 0x65, 0x6c, 0x69, 0x6e, - 0x65, 0x53, 0x79, 0x6e, 0x63, 0x53, 0x74, 0x61, 0x67, 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, - 0x73, 0x74, 0x1a, 0x43, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, - 0x2e, 0x64, 0x65, 0x70, 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x70, 0x69, 0x2e, 0x76, - 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2e, 0x42, 0x75, 0x69, 0x6c, 0x64, 0x50, 0x69, 0x70, - 0x65, 0x6c, 0x69, 0x6e, 0x65, 0x53, 0x79, 0x6e, 0x63, 0x53, 0x74, 0x61, 0x67, 0x65, 0x73, 0x52, - 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x9b, 0x01, 0x0a, 0x14, 0x42, 0x75, - 0x69, 0x6c, 0x64, 0x51, 0x75, 0x69, 0x63, 0x6b, 0x53, 0x79, 0x6e, 0x63, 0x53, 0x74, 0x61, 0x67, - 0x65, 0x73, 0x12, 0x3f, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, + 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x92, 0x01, 0x0a, 0x11, 0x44, 0x65, + 0x74, 0x65, 0x72, 0x6d, 0x69, 0x6e, 0x65, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x12, + 0x3c, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x2e, 0x64, 0x65, + 0x70, 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x70, 0x69, 0x2e, 0x76, 0x31, 0x61, 0x6c, + 0x70, 0x68, 0x61, 0x31, 0x2e, 0x44, 0x65, 0x74, 0x65, 0x72, 0x6d, 0x69, 0x6e, 0x65, 0x56, 0x65, + 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x3d, 0x2e, + 0x67, 0x72, 0x70, 0x63, 0x2e, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x2e, 0x64, 0x65, 0x70, 0x6c, + 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x70, 0x69, 0x2e, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, + 0x61, 0x31, 0x2e, 0x44, 0x65, 0x74, 0x65, 0x72, 0x6d, 0x69, 0x6e, 0x65, 0x56, 0x65, 0x72, 0x73, + 0x69, 0x6f, 0x6e, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x92, + 0x01, 0x0a, 0x11, 0x44, 0x65, 0x74, 0x65, 0x72, 0x6d, 0x69, 0x6e, 0x65, 0x53, 0x74, 0x72, 0x61, + 0x74, 0x65, 0x67, 0x79, 0x12, 0x3c, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x70, 0x6c, 0x75, 0x67, + 0x69, 0x6e, 0x2e, 0x64, 0x65, 0x70, 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x70, 0x69, + 0x2e, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2e, 0x44, 0x65, 0x74, 0x65, 0x72, 0x6d, + 0x69, 0x6e, 0x65, 0x53, 0x74, 0x72, 0x61, 0x74, 0x65, 0x67, 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x1a, 0x3d, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x2e, 0x64, 0x65, 0x70, 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x70, 0x69, 0x2e, 0x76, - 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2e, 0x42, 0x75, 0x69, 0x6c, 0x64, 0x51, 0x75, 0x69, - 0x63, 0x6b, 0x53, 0x79, 0x6e, 0x63, 0x53, 0x74, 0x61, 0x67, 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, - 0x65, 0x73, 0x74, 0x1a, 0x40, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x70, 0x6c, 0x75, 0x67, 0x69, + 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2e, 0x44, 0x65, 0x74, 0x65, 0x72, 0x6d, 0x69, 0x6e, + 0x65, 0x53, 0x74, 0x72, 0x61, 0x74, 0x65, 0x67, 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, + 0x65, 0x22, 0x00, 0x12, 0xa4, 0x01, 0x0a, 0x17, 0x42, 0x75, 0x69, 0x6c, 0x64, 0x50, 0x69, 0x70, + 0x65, 0x6c, 0x69, 0x6e, 0x65, 0x53, 0x79, 0x6e, 0x63, 0x53, 0x74, 0x61, 0x67, 0x65, 0x73, 0x12, + 0x42, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x2e, 0x64, 0x65, + 0x70, 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x70, 0x69, 0x2e, 0x76, 0x31, 0x61, 0x6c, + 0x70, 0x68, 0x61, 0x31, 0x2e, 0x42, 0x75, 0x69, 0x6c, 0x64, 0x50, 0x69, 0x70, 0x65, 0x6c, 0x69, + 0x6e, 0x65, 0x53, 0x79, 0x6e, 0x63, 0x53, 0x74, 0x61, 0x67, 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, + 0x65, 0x73, 0x74, 0x1a, 0x43, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x70, 0x6c, 0x75, 0x67, 0x69, + 0x6e, 0x2e, 0x64, 0x65, 0x70, 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x70, 0x69, 0x2e, + 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2e, 0x42, 0x75, 0x69, 0x6c, 0x64, 0x50, 0x69, + 0x70, 0x65, 0x6c, 0x69, 0x6e, 0x65, 0x53, 0x79, 0x6e, 0x63, 0x53, 0x74, 0x61, 0x67, 0x65, 0x73, + 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x9b, 0x01, 0x0a, 0x14, 0x42, + 0x75, 0x69, 0x6c, 0x64, 0x51, 0x75, 0x69, 0x63, 0x6b, 0x53, 0x79, 0x6e, 0x63, 0x53, 0x74, 0x61, + 0x67, 0x65, 0x73, 0x12, 0x3f, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x2e, 0x64, 0x65, 0x70, 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x70, 0x69, 0x2e, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2e, 0x42, 0x75, 0x69, 0x6c, 0x64, 0x51, 0x75, - 0x69, 0x63, 0x6b, 0x53, 0x79, 0x6e, 0x63, 0x53, 0x74, 0x61, 0x67, 0x65, 0x73, 0x52, 0x65, 0x73, - 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x42, 0x3e, 0x5a, 0x3c, 0x67, 0x69, 0x74, 0x68, 0x75, - 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x70, 0x69, 0x70, 0x65, 0x2d, 0x63, 0x64, 0x2f, 0x70, 0x69, - 0x70, 0x65, 0x63, 0x64, 0x2f, 0x70, 0x6b, 0x67, 0x2f, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x2f, - 0x61, 0x70, 0x69, 0x2f, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2f, 0x64, 0x65, 0x70, - 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, + 0x69, 0x63, 0x6b, 0x53, 0x79, 0x6e, 0x63, 0x53, 0x74, 0x61, 0x67, 0x65, 0x73, 0x52, 0x65, 0x71, + 0x75, 0x65, 0x73, 0x74, 0x1a, 0x40, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x70, 0x6c, 0x75, 0x67, + 0x69, 0x6e, 0x2e, 0x64, 0x65, 0x70, 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x70, 0x69, + 0x2e, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2e, 0x42, 0x75, 0x69, 0x6c, 0x64, 0x51, + 0x75, 0x69, 0x63, 0x6b, 0x53, 0x79, 0x6e, 0x63, 0x53, 0x74, 0x61, 0x67, 0x65, 0x73, 0x52, 0x65, + 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x42, 0x3e, 0x5a, 0x3c, 0x67, 0x69, 0x74, 0x68, + 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x70, 0x69, 0x70, 0x65, 0x2d, 0x63, 0x64, 0x2f, 0x70, + 0x69, 0x70, 0x65, 0x63, 0x64, 0x2f, 0x70, 0x6b, 0x67, 0x2f, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, + 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2f, 0x64, 0x65, + 0x70, 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, } var ( diff --git a/pkg/plugin/api/v1alpha1/deployment/api.pb.validate.go b/pkg/plugin/api/v1alpha1/deployment/api.pb.validate.go index 4bb43a33fa..9182375f68 100644 --- a/pkg/plugin/api/v1alpha1/deployment/api.pb.validate.go +++ b/pkg/plugin/api/v1alpha1/deployment/api.pb.validate.go @@ -863,6 +863,8 @@ func (m *BuildQuickSyncStagesRequest) validate(all bool) error { var errors []error + // no validation rules for StageIndex + // no validation rules for Rollback if len(errors) > 0 { diff --git a/pkg/plugin/api/v1alpha1/deployment/api.proto b/pkg/plugin/api/v1alpha1/deployment/api.proto index ba61d68c55..f1299fe120 100644 --- a/pkg/plugin/api/v1alpha1/deployment/api.proto +++ b/pkg/plugin/api/v1alpha1/deployment/api.proto @@ -84,7 +84,8 @@ message BuildPipelineSyncStagesResponse { } message BuildQuickSyncStagesRequest { - bool rollback = 1; + int32 stage_index = 1; + bool rollback = 2; } message BuildQuickSyncStagesResponse { From 18866453871f76c07c44189335775ca33ffa0ca2 Mon Sep 17 00:00:00 2001 From: Shinnosuke Sawada-Dazai Date: Thu, 26 Sep 2024 17:13:01 +0900 Subject: [PATCH 20/84] Sort the rollbackStages to fix the flaky tests (#5230) Signed-off-by: Shinnosuke Sawada-Dazai --- pkg/app/pipedv1/controller/planner.go | 1 + pkg/app/pipedv1/controller/planner_test.go | 16 ++++++++++++++++ 2 files changed, 17 insertions(+) diff --git a/pkg/app/pipedv1/controller/planner.go b/pkg/app/pipedv1/controller/planner.go index 06e8c657eb..8de0147aab 100644 --- a/pkg/app/pipedv1/controller/planner.go +++ b/pkg/app/pipedv1/controller/planner.go @@ -478,6 +478,7 @@ func (p *planner) buildPipelineSyncStages(ctx context.Context, cfg *config.Gener // Sort stages by index. sort.Sort(model.PipelineStages(stages)) + sort.Sort(model.PipelineStages(rollbackStages)) // Build requires for each stage. preStageID := "" diff --git a/pkg/app/pipedv1/controller/planner_test.go b/pkg/app/pipedv1/controller/planner_test.go index ddcdb6e875..1b87af3b6d 100644 --- a/pkg/app/pipedv1/controller/planner_test.go +++ b/pkg/app/pipedv1/controller/planner_test.go @@ -148,12 +148,14 @@ func TestBuildQuickSyncStages(t *testing.T) { quickStages: []*model.PipelineStage{ { Id: "plugin-1-stage-1", + Index: 1, Visible: true, }, }, rollbackStages: []*model.PipelineStage{ { Id: "plugin-1-rollback", + Index: 1, Visible: false, }, }, @@ -162,12 +164,14 @@ func TestBuildQuickSyncStages(t *testing.T) { quickStages: []*model.PipelineStage{ { Id: "plugin-2-stage-1", + Index: 2, Visible: true, }, }, rollbackStages: []*model.PipelineStage{ { Id: "plugin-2-rollback", + Index: 2, Visible: false, }, }, @@ -182,18 +186,22 @@ func TestBuildQuickSyncStages(t *testing.T) { expectedStages: []*model.PipelineStage{ { Id: "plugin-1-stage-1", + Index: 1, Visible: true, }, { Id: "plugin-2-stage-1", + Index: 2, Visible: true, }, { Id: "plugin-1-rollback", + Index: 1, Visible: false, }, { Id: "plugin-2-rollback", + Index: 2, Visible: false, }, }, @@ -458,16 +466,19 @@ func TestBuildPipelineSyncStages(t *testing.T) { pipelineStages: []*model.PipelineStage{ { Id: "plugin-1-stage-1", + Index: 0, Name: "plugin-1-stage-1", Visible: true, }, { Id: "plugin-1-stage-2", + Index: 1, Name: "plugin-1-stage-2", Visible: true, }, { Id: "plugin-1-stage-3", + Index: 2, Name: "plugin-1-stage-3", Visible: true, }, @@ -475,6 +486,7 @@ func TestBuildPipelineSyncStages(t *testing.T) { rollbackStages: []*model.PipelineStage{ { Id: "plugin-1-rollback", + Index: 0, Name: "plugin-1-rollback", Visible: false, }, @@ -484,6 +496,7 @@ func TestBuildPipelineSyncStages(t *testing.T) { pipelineStages: []*model.PipelineStage{ { Id: "plugin-2-stage-1", + Index: 3, Name: "plugin-2-stage-1", Visible: true, }, @@ -491,6 +504,7 @@ func TestBuildPipelineSyncStages(t *testing.T) { rollbackStages: []*model.PipelineStage{ { Id: "plugin-2-rollback", + Index: 3, Name: "plugin-2-rollback", Visible: false, }, @@ -553,11 +567,13 @@ func TestBuildPipelineSyncStages(t *testing.T) { }, { Id: "plugin-1-rollback", + Index: 0, Name: "plugin-1-rollback", Visible: false, }, { Id: "plugin-2-rollback", + Index: 3, Name: "plugin-2-rollback", Visible: false, }, From 997339c565a0b3a2d4d71f66f10c9f512962c1d2 Mon Sep 17 00:00:00 2001 From: Shinnosuke Sawada-Dazai Date: Thu, 26 Sep 2024 17:17:06 +0900 Subject: [PATCH 21/84] Send StageIndex to plugins (#5231) Signed-off-by: Shinnosuke Sawada-Dazai --- pkg/app/pipedv1/controller/planner.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/pkg/app/pipedv1/controller/planner.go b/pkg/app/pipedv1/controller/planner.go index 8de0147aab..6ed3dcef47 100644 --- a/pkg/app/pipedv1/controller/planner.go +++ b/pkg/app/pipedv1/controller/planner.go @@ -402,8 +402,9 @@ func (p *planner) buildQuickSyncStages(ctx context.Context, cfg *config.GenericA rollbackStages = []*model.PipelineStage{} rollback = *cfg.Planner.AutoRollback ) - for _, plg := range p.plugins { - res, err := plg.BuildQuickSyncStages(ctx, &deployment.BuildQuickSyncStagesRequest{Rollback: rollback}) + // TODO: Consider how to define the order of plugins. + for i, plg := range p.plugins { + res, err := plg.BuildQuickSyncStages(ctx, &deployment.BuildQuickSyncStagesRequest{StageIndex: int32(i), Rollback: rollback}) if err != nil { return nil, fmt.Errorf("failed to build quick sync stage deployment (%w)", err) } From e9e076d027dff06ee55a6df08be7ba92cd1e199b Mon Sep 17 00:00:00 2001 From: Shinnosuke Sawada-Dazai Date: Fri, 27 Sep 2024 08:56:08 +0900 Subject: [PATCH 22/84] Add toolregistry client as DeploymentService member (#5228) * Implement toolregistry client Signed-off-by: Shinnosuke Sawada-Dazai * Add toolRegistry member at DeploymentService Signed-off-by: Shinnosuke Sawada-Dazai --------- Signed-off-by: Shinnosuke Sawada-Dazai --- .../plugin/kubernetes/deployment/server.go | 14 +++++-- .../plugin/toolregistry/toolregistry.go | 38 +++++++++++++++++++ 2 files changed, 48 insertions(+), 4 deletions(-) create mode 100644 pkg/app/pipedv1/plugin/toolregistry/toolregistry.go diff --git a/pkg/app/pipedv1/plugin/kubernetes/deployment/server.go b/pkg/app/pipedv1/plugin/kubernetes/deployment/server.go index 6123c4d7e0..a06b87a360 100644 --- a/pkg/app/pipedv1/plugin/kubernetes/deployment/server.go +++ b/pkg/app/pipedv1/plugin/kubernetes/deployment/server.go @@ -25,11 +25,16 @@ import ( "google.golang.org/grpc" ) +type toolRegistry interface { + InstallTool(ctx context.Context, name, version string) (path string, err error) +} + type DeploymentService struct { deployment.UnimplementedDeploymentServiceServer - RegexPool *regexpool.Pool - Logger *zap.Logger + RegexPool *regexpool.Pool + Logger *zap.Logger + ToolRegistry toolRegistry } // NewDeploymentService creates a new planService. @@ -37,8 +42,9 @@ func NewDeploymentService( logger *zap.Logger, ) *DeploymentService { return &DeploymentService{ - RegexPool: regexpool.DefaultPool(), - Logger: logger.Named("planner"), + RegexPool: regexpool.DefaultPool(), + Logger: logger.Named("planner"), + ToolRegistry: nil, // TODO: set the tool registry } } diff --git a/pkg/app/pipedv1/plugin/toolregistry/toolregistry.go b/pkg/app/pipedv1/plugin/toolregistry/toolregistry.go new file mode 100644 index 0000000000..3675aea62d --- /dev/null +++ b/pkg/app/pipedv1/plugin/toolregistry/toolregistry.go @@ -0,0 +1,38 @@ +// 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 toolregistry + +import ( + "context" + + "github.com/pipe-cd/pipecd/pkg/app/pipedv1/cmd/piped/service" +) + +type ToolRegistry struct { + client service.PluginServiceClient +} + +func (r *ToolRegistry) InstallTool(ctx context.Context, name, version string) (path string, err error) { + res, err := r.client.InstallTool(ctx, &service.InstallToolRequest{ + Name: name, + Version: version, + }) + + if err != nil { + return "", err + } + + return res.GetInstalledPath(), nil +} From 7d15a9cb39ccb7515f7780eb617e6cc5660454ca Mon Sep 17 00:00:00 2001 From: Tetsuya Kikuchi <97105818+t-kikuc@users.noreply.github.com> Date: Fri, 27 Sep 2024 09:08:03 +0900 Subject: [PATCH 23/84] [docs] Rearrange the core values on the top page (#5233) * Generate v0.49.x docs Signed-off-by: t-kikuc * Rearrange core-values Signed-off-by: t-kikuc --------- Signed-off-by: t-kikuc --- docs/content/en/_index.html | 40 ++++++++++++++++++------------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/docs/content/en/_index.html b/docs/content/en/_index.html index a7e7612192..c2f0f5feb1 100644 --- a/docs/content/en/_index.html +++ b/docs/content/en/_index.html @@ -35,26 +35,15 @@
- {{< blocks/value title="Visibility" image="visibility.png" image_position="left" >}} + {{< blocks/value title="Multi-provider & Multi-Tenancy" image="multi-provider.png" image_position="left" >}}
    -
  • Deployment pipeline UI shows clarify what is happening
  • -
  • Separate logs viewer for each individual deployment
  • -
  • Realtime visualization of application state
  • -
  • Deployment notifications to slack, webhook endpoints
  • -
  • Insights show the delivery performance
  • -
- {{< /blocks/value >}} - - {{< blocks/value title="Automation" image="automation.png" image_position="right" >}} -
    -
  • Automated deployment analysis based on metrics, logs, emitted requests
  • -
  • Automatically roll back to the previous state as soon as analysis or a pipeline stage fails
  • -
  • Automatically detect configuration drift to notify and render the changes
  • -
  • Automatically trigger a new deployment when a defined event has occurred (e.g. container image pushed, helm chart published, etc)
  • +
  • Support multiple application kinds on multi-cloud including Kubernetes, Terraform, Cloud Run, AWS Lambda, Amazon ECS
  • +
  • Support multiple analysis providers including Prometheus, Datadog, Stackdriver, and more
  • +
  • Easy to operate multi-cluster, multi-tenancy by separating control-plane and piped
{{< /blocks/value >}} - {{< blocks/value title="Secure" image="secure.png" image_position="left" >}} + {{< blocks/value title="Secure" image="secure.png" image_position="right" >}}
  • Support single sign-on and role-based access control
  • Credentials are not exposed outside the cluster and not saved in the control-plane
  • @@ -63,11 +52,22 @@
{{< /blocks/value >}} - {{< blocks/value title="Multi-provider & Multi-Tenancy" image="multi-provider.png" image_position="right" >}} + {{< blocks/value title="Automation" image="automation.png" image_position="left" >}}
    -
  • Support multiple application kinds on multi-cloud including Kubernetes, Terraform, Cloud Run, AWS Lambda, Amazon ECS
  • -
  • Support multiple analysis providers including Prometheus, Datadog, Stackdriver, and more
  • -
  • Easy to operate multi-cluster, multi-tenancy by separating control-plane and piped
  • +
  • Automated deployment analysis based on metrics, logs, emitted requests
  • +
  • Automatically roll back to the previous state as soon as analysis or a pipeline stage fails
  • +
  • Automatically detect configuration drift to notify and render the changes
  • +
  • Automatically trigger a new deployment when a defined event has occurred (e.g. container image pushed, helm chart published, etc)
  • +
+ {{< /blocks/value >}} + + {{< blocks/value title="Visibility" image="visibility.png" image_position="right" >}} +
    +
  • Deployment pipeline UI shows clarify what is happening
  • +
  • Separate logs viewer for each individual deployment
  • +
  • Realtime visualization of application state
  • +
  • Deployment notifications to slack, webhook endpoints
  • +
  • Insights show the delivery performance
{{< /blocks/value >}}
From 5f05a5841fa8a15dc124bc5b2db69faebe33d318 Mon Sep 17 00:00:00 2001 From: Tetsuya Kikuchi <97105818+t-kikuc@users.noreply.github.com> Date: Fri, 27 Sep 2024 09:08:32 +0900 Subject: [PATCH 24/84] [docs] Remove AWS App Mesh from feature status (#5234) * Generate v0.49.x docs Signed-off-by: t-kikuc * Remove AppMesh from feature status Signed-off-by: t-kikuc --------- Signed-off-by: t-kikuc --- docs/content/en/docs-dev/feature-status/_index.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/docs/content/en/docs-dev/feature-status/_index.md b/docs/content/en/docs-dev/feature-status/_index.md index 25b11caa07..17e2b7add4 100644 --- a/docs/content/en/docs-dev/feature-status/_index.md +++ b/docs/content/en/docs-dev/feature-status/_index.md @@ -32,7 +32,6 @@ Please note that the phases (Incubating, Alpha, Beta, and Stable) are applied to | Support Kustomize | Beta | | Support Istio service mesh | Beta | | Support SMI service mesh | Incubating | -| Support [AWS App Mesh](https://aws.amazon.com/app-mesh/) | Incubating | | [Plan preview](../user-guide/plan-preview) | Beta | | [Manifest attachment](../user-guide/managing-application/manifest-attachment) | Alpha | @@ -83,7 +82,6 @@ Please note that the phases (Incubating, Alpha, Beta, and Stable) are applied to | [Application live state](../user-guide/managing-application/application-live-state/) | Alpha *1 | | Quick sync deployment for [ECS Service Discovery](https://docs.aws.amazon.com/AmazonECS/latest/developerguide/service-discovery.html) | Alpha | | Deployment with a defined pipeline for [ECS Service Discovery](https://docs.aws.amazon.com/AmazonECS/latest/developerguide/service-discovery.html) | Alpha | -| Support [AWS App Mesh](https://aws.amazon.com/app-mesh/) | Incubating | | [Plan preview](../user-guide/plan-preview) | Alpha | | [Manifest attachment](../user-guide/managing-application/manifest-attachment) | Alpha | From 4d5357485b53250b369fcbbdc851b5c1e2b0c31b Mon Sep 17 00:00:00 2001 From: Khanh Tran <32532742+khanhtc1202@users.noreply.github.com> Date: Fri, 27 Sep 2024 09:19:33 +0900 Subject: [PATCH 25/84] Add PipelineStage.Rollback and mark PipelineStage.Visible as deprecated (#5232) * Add PipelineStage.Rollback and marke PipelineStage.Visible as deprecated Signed-off-by: khanhtc1202 * Fix flaky tests and update TODO Signed-off-by: khanhtc1202 --------- Signed-off-by: khanhtc1202 --- pkg/app/pipedv1/controller/planner.go | 20 +- pkg/app/pipedv1/controller/planner_test.go | 232 +++++++++------------ pkg/model/deployment.pb.go | 43 ++-- pkg/model/deployment.pb.validate.go | 2 + pkg/model/deployment.proto | 3 +- web/model/deployment_pb.d.ts | 4 + web/model/deployment_pb.js | 30 +++ web/src/__fixtures__/dummy-pipeline.ts | 1 + 8 files changed, 183 insertions(+), 152 deletions(-) diff --git a/pkg/app/pipedv1/controller/planner.go b/pkg/app/pipedv1/controller/planner.go index 6ed3dcef47..13efadba7a 100644 --- a/pkg/app/pipedv1/controller/planner.go +++ b/pkg/app/pipedv1/controller/planner.go @@ -408,15 +408,20 @@ func (p *planner) buildQuickSyncStages(ctx context.Context, cfg *config.GenericA if err != nil { return nil, fmt.Errorf("failed to build quick sync stage deployment (%w)", err) } + // TODO: Ensure responsed stages indexies is valid. for i := range res.Stages { - // TODO: Consider add Stage.Rollback to specify a stage is a rollback stage or forward stage instead. - if res.Stages[i].Visible { - stages = append(stages, res.Stages[i]) - } else { + if res.Stages[i].Rollback { rollbackStages = append(rollbackStages, res.Stages[i]) + } else { + stages = append(stages, res.Stages[i]) } } } + + // Sort stages by index. + sort.Sort(model.PipelineStages(stages)) + sort.Sort(model.PipelineStages(rollbackStages)) + stages = append(stages, rollbackStages...) if len(stages) == 0 { return nil, fmt.Errorf("unable to build quick sync stages for deployment") @@ -468,11 +473,12 @@ func (p *planner) buildPipelineSyncStages(ctx context.Context, cfg *config.Gener if err != nil { return nil, fmt.Errorf("failed to build pipeline sync stages for deployment (%w)", err) } + // TODO: Ensure responsed stages indexies is valid. for i := range res.Stages { - if res.Stages[i].Visible { - stages = append(stages, res.Stages[i]) - } else { + if res.Stages[i].Rollback { rollbackStages = append(rollbackStages, res.Stages[i]) + } else { + stages = append(stages, res.Stages[i]) } } } diff --git a/pkg/app/pipedv1/controller/planner_test.go b/pkg/app/pipedv1/controller/planner_test.go index 1b87af3b6d..9f57817686 100644 --- a/pkg/app/pipedv1/controller/planner_test.go +++ b/pkg/app/pipedv1/controller/planner_test.go @@ -112,14 +112,13 @@ func TestBuildQuickSyncStages(t *testing.T) { &fakePlugin{ quickStages: []*model.PipelineStage{ { - Id: "plugin-1-stage-1", - Visible: true, + Id: "plugin-1-stage-1", }, }, rollbackStages: []*model.PipelineStage{ { - Id: "plugin-1-rollback", - Visible: false, + Id: "plugin-1-rollback", + Rollback: true, }, }, }, @@ -132,12 +131,11 @@ func TestBuildQuickSyncStages(t *testing.T) { wantErr: false, expectedStages: []*model.PipelineStage{ { - Id: "plugin-1-stage-1", - Visible: true, + Id: "plugin-1-stage-1", }, { - Id: "plugin-1-rollback", - Visible: false, + Id: "plugin-1-rollback", + Rollback: true, }, }, }, @@ -147,32 +145,30 @@ func TestBuildQuickSyncStages(t *testing.T) { &fakePlugin{ quickStages: []*model.PipelineStage{ { - Id: "plugin-1-stage-1", - Index: 1, - Visible: true, + Id: "plugin-1-stage-1", + Index: 0, }, }, rollbackStages: []*model.PipelineStage{ { - Id: "plugin-1-rollback", - Index: 1, - Visible: false, + Id: "plugin-1-rollback", + Index: 0, + Rollback: true, }, }, }, &fakePlugin{ quickStages: []*model.PipelineStage{ { - Id: "plugin-2-stage-1", - Index: 2, - Visible: true, + Id: "plugin-2-stage-1", + Index: 1, }, }, rollbackStages: []*model.PipelineStage{ { - Id: "plugin-2-rollback", - Index: 2, - Visible: false, + Id: "plugin-2-rollback", + Index: 1, + Rollback: true, }, }, }, @@ -185,24 +181,22 @@ func TestBuildQuickSyncStages(t *testing.T) { wantErr: false, expectedStages: []*model.PipelineStage{ { - Id: "plugin-1-stage-1", - Index: 1, - Visible: true, + Id: "plugin-1-stage-1", + Index: 0, }, { - Id: "plugin-2-stage-1", - Index: 2, - Visible: true, + Id: "plugin-2-stage-1", + Index: 1, }, { - Id: "plugin-1-rollback", - Index: 1, - Visible: false, + Id: "plugin-1-rollback", + Index: 0, + Rollback: true, }, { - Id: "plugin-2-rollback", - Index: 2, - Visible: false, + Id: "plugin-2-rollback", + Index: 1, + Rollback: true, }, }, }, @@ -212,28 +206,28 @@ func TestBuildQuickSyncStages(t *testing.T) { &fakePlugin{ quickStages: []*model.PipelineStage{ { - Id: "plugin-1-stage-1", - Visible: true, + Id: "plugin-1-stage-1", + Index: 0, }, }, rollbackStages: []*model.PipelineStage{ { - Id: "plugin-1-rollback", - Visible: false, + Id: "plugin-1-rollback", + Rollback: true, }, }, }, &fakePlugin{ quickStages: []*model.PipelineStage{ { - Id: "plugin-2-stage-1", - Visible: true, + Id: "plugin-2-stage-1", + Index: 1, }, }, rollbackStages: []*model.PipelineStage{ { - Id: "plugin-2-rollback", - Visible: false, + Id: "plugin-2-rollback", + Rollback: true, }, }, }, @@ -246,12 +240,12 @@ func TestBuildQuickSyncStages(t *testing.T) { wantErr: false, expectedStages: []*model.PipelineStage{ { - Id: "plugin-1-stage-1", - Visible: true, + Id: "plugin-1-stage-1", + Index: 0, }, { - Id: "plugin-2-stage-1", - Visible: true, + Id: "plugin-2-stage-1", + Index: 1, }, }, }, @@ -285,21 +279,22 @@ func TestBuildPipelineSyncStages(t *testing.T) { &fakePlugin{ pipelineStages: []*model.PipelineStage{ { - Id: "plugin-1-stage-1", - Name: "plugin-1-stage-1", - Visible: true, + Id: "plugin-1-stage-1", + Index: 0, + Name: "plugin-1-stage-1", }, { - Id: "plugin-1-stage-2", - Name: "plugin-1-stage-2", - Visible: true, + Id: "plugin-1-stage-2", + Index: 1, + Name: "plugin-1-stage-2", }, }, rollbackStages: []*model.PipelineStage{ { - Id: "plugin-1-rollback", - Name: "plugin-1-rollback", - Visible: false, + Id: "plugin-1-rollback", + Index: 0, + Name: "plugin-1-rollback", + Rollback: true, }, }, }, @@ -324,22 +319,21 @@ func TestBuildPipelineSyncStages(t *testing.T) { wantErr: false, expectedStages: []*model.PipelineStage{ { - Id: "plugin-1-stage-1", - Name: "plugin-1-stage-1", - Index: 0, - Visible: true, + Id: "plugin-1-stage-1", + Name: "plugin-1-stage-1", + Index: 0, }, { Id: "plugin-1-stage-2", Name: "plugin-1-stage-2", Index: 1, Requires: []string{"plugin-1-stage-1"}, - Visible: true, }, { - Id: "plugin-1-rollback", - Name: "plugin-1-rollback", - Visible: false, + Id: "plugin-1-rollback", + Name: "plugin-1-rollback", + Index: 0, + Rollback: true, }, }, }, @@ -349,40 +343,36 @@ func TestBuildPipelineSyncStages(t *testing.T) { &fakePlugin{ pipelineStages: []*model.PipelineStage{ { - Id: "plugin-1-stage-1", - Name: "plugin-1-stage-1", - Visible: true, + Id: "plugin-1-stage-1", + Name: "plugin-1-stage-1", }, { - Id: "plugin-1-stage-2", - Name: "plugin-1-stage-2", - Visible: true, + Id: "plugin-1-stage-2", + Name: "plugin-1-stage-2", }, { - Id: "plugin-1-stage-3", - Name: "plugin-1-stage-3", - Visible: true, + Id: "plugin-1-stage-3", + Name: "plugin-1-stage-3", }, }, rollbackStages: []*model.PipelineStage{ { - Id: "plugin-1-rollback", - Name: "plugin-1-rollback", - Visible: false, + Id: "plugin-1-rollback", + Index: 0, + Name: "plugin-1-rollback", + Rollback: true, }, }, }, &fakePlugin{ pipelineStages: []*model.PipelineStage{ { - Id: "plugin-2-stage-1", - Name: "plugin-2-stage-1", - Visible: true, + Id: "plugin-2-stage-1", + Name: "plugin-2-stage-1", }, { - Id: "plugin-2-stage-2", - Name: "plugin-2-stage-2", - Visible: true, + Id: "plugin-2-stage-2", + Name: "plugin-2-stage-2", }, }, }, @@ -419,43 +409,39 @@ func TestBuildPipelineSyncStages(t *testing.T) { wantErr: false, expectedStages: []*model.PipelineStage{ { - Id: "plugin-1-stage-1", - Name: "plugin-1-stage-1", - Index: 0, - Visible: true, + Id: "plugin-1-stage-1", + Name: "plugin-1-stage-1", + Index: 0, }, { Id: "plugin-1-stage-2", Name: "plugin-1-stage-2", Index: 1, Requires: []string{"plugin-1-stage-1"}, - Visible: true, }, { Id: "plugin-2-stage-1", Name: "plugin-2-stage-1", Index: 2, Requires: []string{"plugin-1-stage-2"}, - Visible: true, }, { Id: "plugin-1-stage-3", Name: "plugin-1-stage-3", Index: 3, Requires: []string{"plugin-2-stage-1"}, - Visible: true, }, { Id: "plugin-2-stage-2", Name: "plugin-2-stage-2", Index: 4, Requires: []string{"plugin-1-stage-3"}, - Visible: true, }, { - Id: "plugin-1-rollback", - Name: "plugin-1-rollback", - Visible: false, + Id: "plugin-1-rollback", + Name: "plugin-1-rollback", + Index: 0, + Rollback: true, }, }, }, @@ -465,48 +451,40 @@ func TestBuildPipelineSyncStages(t *testing.T) { &fakePlugin{ pipelineStages: []*model.PipelineStage{ { - Id: "plugin-1-stage-1", - Index: 0, - Name: "plugin-1-stage-1", - Visible: true, + Id: "plugin-1-stage-1", + Name: "plugin-1-stage-1", }, { - Id: "plugin-1-stage-2", - Index: 1, - Name: "plugin-1-stage-2", - Visible: true, + Id: "plugin-1-stage-2", + Name: "plugin-1-stage-2", }, { - Id: "plugin-1-stage-3", - Index: 2, - Name: "plugin-1-stage-3", - Visible: true, + Id: "plugin-1-stage-3", + Name: "plugin-1-stage-3", }, }, rollbackStages: []*model.PipelineStage{ { - Id: "plugin-1-rollback", - Index: 0, - Name: "plugin-1-rollback", - Visible: false, + Id: "plugin-1-rollback", + Index: 0, + Name: "plugin-1-rollback", + Rollback: true, }, }, }, &fakePlugin{ pipelineStages: []*model.PipelineStage{ { - Id: "plugin-2-stage-1", - Index: 3, - Name: "plugin-2-stage-1", - Visible: true, + Id: "plugin-2-stage-1", + Name: "plugin-2-stage-1", }, }, rollbackStages: []*model.PipelineStage{ { - Id: "plugin-2-rollback", - Index: 3, - Name: "plugin-2-rollback", - Visible: false, + Id: "plugin-2-rollback", + Index: 2, + Name: "plugin-2-rollback", + Rollback: true, }, }, }, @@ -539,43 +517,39 @@ func TestBuildPipelineSyncStages(t *testing.T) { wantErr: false, expectedStages: []*model.PipelineStage{ { - Id: "plugin-1-stage-1", - Name: "plugin-1-stage-1", - Index: 0, - Visible: true, + Id: "plugin-1-stage-1", + Name: "plugin-1-stage-1", + Index: 0, }, { Id: "plugin-1-stage-2", Name: "plugin-1-stage-2", Index: 1, Requires: []string{"plugin-1-stage-1"}, - Visible: true, }, { Id: "plugin-2-stage-1", Name: "plugin-2-stage-1", Index: 2, Requires: []string{"plugin-1-stage-2"}, - Visible: true, }, { Id: "plugin-1-stage-3", Name: "plugin-1-stage-3", Index: 3, Requires: []string{"plugin-2-stage-1"}, - Visible: true, }, { - Id: "plugin-1-rollback", - Index: 0, - Name: "plugin-1-rollback", - Visible: false, + Id: "plugin-1-rollback", + Index: 0, + Name: "plugin-1-rollback", + Rollback: true, }, { - Id: "plugin-2-rollback", - Index: 3, - Name: "plugin-2-rollback", - Visible: false, + Id: "plugin-2-rollback", + Index: 2, + Name: "plugin-2-rollback", + Rollback: true, }, }, }, diff --git a/pkg/model/deployment.pb.go b/pkg/model/deployment.pb.go index b58edb4356..57c0fc8860 100644 --- a/pkg/model/deployment.pb.go +++ b/pkg/model/deployment.pb.go @@ -578,12 +578,15 @@ type PipelineStage struct { Predefined bool `protobuf:"varint,5,opt,name=predefined,proto3" json:"predefined,omitempty"` Requires []string `protobuf:"bytes,6,rep,name=requires,proto3" json:"requires,omitempty"` // Whether this stage should be rendered or not. + // + // Deprecated: Do not use. Visible bool `protobuf:"varint,7,opt,name=visible,proto3" json:"visible,omitempty"` Status StageStatus `protobuf:"varint,8,opt,name=status,proto3,enum=model.StageStatus" json:"status,omitempty"` // The human-readable description why the stage is at current status. StatusReason string `protobuf:"bytes,9,opt,name=status_reason,json=statusReason,proto3" json:"status_reason,omitempty"` Metadata map[string]string `protobuf:"bytes,10,rep,name=metadata,proto3" json:"metadata,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"bytes,2,opt,name=value,proto3"` RetriedCount int32 `protobuf:"varint,11,opt,name=retried_count,json=retriedCount,proto3" json:"retried_count,omitempty"` + Rollback bool `protobuf:"varint,12,opt,name=rollback,proto3" json:"rollback,omitempty"` CompletedAt int64 `protobuf:"varint,13,opt,name=completed_at,json=completedAt,proto3" json:"completed_at,omitempty"` CreatedAt int64 `protobuf:"varint,14,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"` UpdatedAt int64 `protobuf:"varint,15,opt,name=updated_at,json=updatedAt,proto3" json:"updated_at,omitempty"` @@ -663,6 +666,7 @@ func (x *PipelineStage) GetRequires() []string { return nil } +// Deprecated: Do not use. func (x *PipelineStage) GetVisible() bool { if x != nil { return x.Visible @@ -698,6 +702,13 @@ func (x *PipelineStage) GetRetriedCount() int32 { return 0 } +func (x *PipelineStage) GetRollback() bool { + if x != nil { + return x.Rollback + } + return false +} + func (x *PipelineStage) GetCompletedAt() int64 { if x != nil { return x.CompletedAt @@ -919,7 +930,7 @@ var file_pkg_model_deployment_proto_rawDesc = []byte{ 0x79, 0x6e, 0x63, 0x53, 0x74, 0x72, 0x61, 0x74, 0x65, 0x67, 0x79, 0x12, 0x29, 0x0a, 0x10, 0x73, 0x74, 0x72, 0x61, 0x74, 0x65, 0x67, 0x79, 0x5f, 0x73, 0x75, 0x6d, 0x6d, 0x61, 0x72, 0x79, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0f, 0x73, 0x74, 0x72, 0x61, 0x74, 0x65, 0x67, 0x79, 0x53, - 0x75, 0x6d, 0x6d, 0x61, 0x72, 0x79, 0x22, 0xbe, 0x04, 0x0a, 0x0d, 0x50, 0x69, 0x70, 0x65, 0x6c, + 0x75, 0x6d, 0x6d, 0x61, 0x72, 0x79, 0x22, 0xde, 0x04, 0x0a, 0x0d, 0x50, 0x69, 0x70, 0x65, 0x6c, 0x69, 0x6e, 0x65, 0x53, 0x74, 0x61, 0x67, 0x65, 0x12, 0x17, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x42, 0x07, 0xfa, 0x42, 0x04, 0x72, 0x02, 0x10, 0x01, 0x52, 0x02, 0x69, 0x64, 0x12, 0x1b, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x42, @@ -930,20 +941,22 @@ var file_pkg_model_deployment_proto_rawDesc = []byte{ 0x65, 0x66, 0x69, 0x6e, 0x65, 0x64, 0x18, 0x05, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0a, 0x70, 0x72, 0x65, 0x64, 0x65, 0x66, 0x69, 0x6e, 0x65, 0x64, 0x12, 0x1a, 0x0a, 0x08, 0x72, 0x65, 0x71, 0x75, 0x69, 0x72, 0x65, 0x73, 0x18, 0x06, 0x20, 0x03, 0x28, 0x09, 0x52, 0x08, 0x72, 0x65, 0x71, 0x75, - 0x69, 0x72, 0x65, 0x73, 0x12, 0x18, 0x0a, 0x07, 0x76, 0x69, 0x73, 0x69, 0x62, 0x6c, 0x65, 0x18, - 0x07, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x76, 0x69, 0x73, 0x69, 0x62, 0x6c, 0x65, 0x12, 0x34, - 0x0a, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x08, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x12, - 0x2e, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x2e, 0x53, 0x74, 0x61, 0x67, 0x65, 0x53, 0x74, 0x61, 0x74, - 0x75, 0x73, 0x42, 0x08, 0xfa, 0x42, 0x05, 0x82, 0x01, 0x02, 0x10, 0x01, 0x52, 0x06, 0x73, 0x74, - 0x61, 0x74, 0x75, 0x73, 0x12, 0x23, 0x0a, 0x0d, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x5f, 0x72, - 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x18, 0x09, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x73, 0x74, 0x61, - 0x74, 0x75, 0x73, 0x52, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x12, 0x3e, 0x0a, 0x08, 0x6d, 0x65, 0x74, - 0x61, 0x64, 0x61, 0x74, 0x61, 0x18, 0x0a, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x22, 0x2e, 0x6d, 0x6f, - 0x64, 0x65, 0x6c, 0x2e, 0x50, 0x69, 0x70, 0x65, 0x6c, 0x69, 0x6e, 0x65, 0x53, 0x74, 0x61, 0x67, - 0x65, 0x2e, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, - 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x12, 0x23, 0x0a, 0x0d, 0x72, 0x65, 0x74, - 0x72, 0x69, 0x65, 0x64, 0x5f, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x05, - 0x52, 0x0c, 0x72, 0x65, 0x74, 0x72, 0x69, 0x65, 0x64, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x2a, + 0x69, 0x72, 0x65, 0x73, 0x12, 0x1c, 0x0a, 0x07, 0x76, 0x69, 0x73, 0x69, 0x62, 0x6c, 0x65, 0x18, + 0x07, 0x20, 0x01, 0x28, 0x08, 0x42, 0x02, 0x18, 0x01, 0x52, 0x07, 0x76, 0x69, 0x73, 0x69, 0x62, + 0x6c, 0x65, 0x12, 0x34, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x08, 0x20, 0x01, + 0x28, 0x0e, 0x32, 0x12, 0x2e, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x2e, 0x53, 0x74, 0x61, 0x67, 0x65, + 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x42, 0x08, 0xfa, 0x42, 0x05, 0x82, 0x01, 0x02, 0x10, 0x01, + 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x23, 0x0a, 0x0d, 0x73, 0x74, 0x61, 0x74, + 0x75, 0x73, 0x5f, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x18, 0x09, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x0c, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x12, 0x3e, 0x0a, + 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x18, 0x0a, 0x20, 0x03, 0x28, 0x0b, 0x32, + 0x22, 0x2e, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x2e, 0x50, 0x69, 0x70, 0x65, 0x6c, 0x69, 0x6e, 0x65, + 0x53, 0x74, 0x61, 0x67, 0x65, 0x2e, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x45, 0x6e, + 0x74, 0x72, 0x79, 0x52, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x12, 0x23, 0x0a, + 0x0d, 0x72, 0x65, 0x74, 0x72, 0x69, 0x65, 0x64, 0x5f, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x0b, + 0x20, 0x01, 0x28, 0x05, 0x52, 0x0c, 0x72, 0x65, 0x74, 0x72, 0x69, 0x65, 0x64, 0x43, 0x6f, 0x75, + 0x6e, 0x74, 0x12, 0x1a, 0x0a, 0x08, 0x72, 0x6f, 0x6c, 0x6c, 0x62, 0x61, 0x63, 0x6b, 0x18, 0x0c, + 0x20, 0x01, 0x28, 0x08, 0x52, 0x08, 0x72, 0x6f, 0x6c, 0x6c, 0x62, 0x61, 0x63, 0x6b, 0x12, 0x2a, 0x0a, 0x0c, 0x63, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x18, 0x0d, 0x20, 0x01, 0x28, 0x03, 0x42, 0x07, 0xfa, 0x42, 0x04, 0x22, 0x02, 0x28, 0x00, 0x52, 0x0b, 0x63, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x64, 0x41, 0x74, 0x12, 0x26, 0x0a, 0x0a, 0x63, 0x72, diff --git a/pkg/model/deployment.pb.validate.go b/pkg/model/deployment.pb.validate.go index ec30dd0b76..cfde3bbe3d 100644 --- a/pkg/model/deployment.pb.validate.go +++ b/pkg/model/deployment.pb.validate.go @@ -642,6 +642,8 @@ func (m *PipelineStage) validate(all bool) error { // no validation rules for RetriedCount + // no validation rules for Rollback + if m.GetCompletedAt() < 0 { err := PipelineStageValidationError{ field: "CompletedAt", diff --git a/pkg/model/deployment.proto b/pkg/model/deployment.proto index 69eaf467b1..5e82a62934 100644 --- a/pkg/model/deployment.proto +++ b/pkg/model/deployment.proto @@ -135,12 +135,13 @@ message PipelineStage { bool predefined = 5; repeated string requires = 6; // Whether this stage should be rendered or not. - bool visible = 7; + bool visible = 7 [deprecated=true]; StageStatus status = 8 [(validate.rules).enum.defined_only = true]; // The human-readable description why the stage is at current status. string status_reason = 9; map metadata = 10; int32 retried_count = 11; + bool rollback = 12; int64 completed_at = 13 [(validate.rules).int64.gte = 0]; int64 created_at = 14 [(validate.rules).int64.gt = 0]; int64 updated_at = 15 [(validate.rules).int64.gt = 0]; diff --git a/web/model/deployment_pb.d.ts b/web/model/deployment_pb.d.ts index d22f831a30..b026a275c5 100644 --- a/web/model/deployment_pb.d.ts +++ b/web/model/deployment_pb.d.ts @@ -198,6 +198,9 @@ export class PipelineStage extends jspb.Message { getRetriedCount(): number; setRetriedCount(value: number): PipelineStage; + getRollback(): boolean; + setRollback(value: boolean): PipelineStage; + getCompletedAt(): number; setCompletedAt(value: number): PipelineStage; @@ -228,6 +231,7 @@ export namespace PipelineStage { statusReason: string, metadataMap: Array<[string, string]>, retriedCount: number, + rollback: boolean, completedAt: number, createdAt: number, updatedAt: number, diff --git a/web/model/deployment_pb.js b/web/model/deployment_pb.js index 3ac416b413..ef80e51d03 100644 --- a/web/model/deployment_pb.js +++ b/web/model/deployment_pb.js @@ -1390,6 +1390,7 @@ proto.model.PipelineStage.toObject = function(includeInstance, msg) { statusReason: jspb.Message.getFieldWithDefault(msg, 9, ""), metadataMap: (f = msg.getMetadataMap()) ? f.toObject(includeInstance, undefined) : [], retriedCount: jspb.Message.getFieldWithDefault(msg, 11, 0), + rollback: jspb.Message.getBooleanFieldWithDefault(msg, 12, false), completedAt: jspb.Message.getFieldWithDefault(msg, 13, 0), createdAt: jspb.Message.getFieldWithDefault(msg, 14, 0), updatedAt: jspb.Message.getFieldWithDefault(msg, 15, 0) @@ -1475,6 +1476,10 @@ proto.model.PipelineStage.deserializeBinaryFromReader = function(msg, reader) { var value = /** @type {number} */ (reader.readInt32()); msg.setRetriedCount(value); break; + case 12: + var value = /** @type {boolean} */ (reader.readBool()); + msg.setRollback(value); + break; case 13: var value = /** @type {number} */ (reader.readInt64()); msg.setCompletedAt(value); @@ -1590,6 +1595,13 @@ proto.model.PipelineStage.serializeBinaryToWriter = function(message, writer) { f ); } + f = message.getRollback(); + if (f) { + writer.writeBool( + 12, + f + ); + } f = message.getCompletedAt(); if (f !== 0) { writer.writeInt64( @@ -1836,6 +1848,24 @@ proto.model.PipelineStage.prototype.setRetriedCount = function(value) { }; +/** + * optional bool rollback = 12; + * @return {boolean} + */ +proto.model.PipelineStage.prototype.getRollback = function() { + return /** @type {boolean} */ (jspb.Message.getBooleanFieldWithDefault(this, 12, false)); +}; + + +/** + * @param {boolean} value + * @return {!proto.model.PipelineStage} returns this + */ +proto.model.PipelineStage.prototype.setRollback = function(value) { + return jspb.Message.setProto3BooleanField(this, 12, value); +}; + + /** * optional int64 completed_at = 13; * @return {number} diff --git a/web/src/__fixtures__/dummy-pipeline.ts b/web/src/__fixtures__/dummy-pipeline.ts index 05289b5c4a..ce0a2e3395 100644 --- a/web/src/__fixtures__/dummy-pipeline.ts +++ b/web/src/__fixtures__/dummy-pipeline.ts @@ -16,6 +16,7 @@ export const dummyPipelineStage: PipelineStage.AsObject = { statusReason: "", metadataMap: [], retriedCount: 0, + rollback: false, completedAt: completedAt.unix(), createdAt: createdAt.unix(), updatedAt: updatedAt.unix(), From 1689c638d76a644d75f3d53f7fbeb795aabdde43 Mon Sep 17 00:00:00 2001 From: Khanh Tran <32532742+khanhtc1202@users.noreply.github.com> Date: Fri, 27 Sep 2024 09:46:22 +0900 Subject: [PATCH 26/84] Mark PipelineStage.Predefined as deprecated field (#5236) Signed-off-by: khanhtc1202 --- pkg/model/deployment.pb.go | 159 +++++++++++++++++++------------------ pkg/model/deployment.proto | 2 +- 2 files changed, 82 insertions(+), 79 deletions(-) diff --git a/pkg/model/deployment.pb.go b/pkg/model/deployment.pb.go index 57c0fc8860..52e7c48f0a 100644 --- a/pkg/model/deployment.pb.go +++ b/pkg/model/deployment.pb.go @@ -575,6 +575,8 @@ type PipelineStage struct { // Stage index from the stage list in configuration. Index int32 `protobuf:"varint,4,opt,name=index,proto3" json:"index,omitempty"` // Whether this stage is the predefined one by planner. + // + // Deprecated: Do not use. Predefined bool `protobuf:"varint,5,opt,name=predefined,proto3" json:"predefined,omitempty"` Requires []string `protobuf:"bytes,6,rep,name=requires,proto3" json:"requires,omitempty"` // Whether this stage should be rendered or not. @@ -652,6 +654,7 @@ func (x *PipelineStage) GetIndex() int32 { return 0 } +// Deprecated: Do not use. func (x *PipelineStage) GetPredefined() bool { if x != nil { return x.Predefined @@ -930,90 +933,90 @@ var file_pkg_model_deployment_proto_rawDesc = []byte{ 0x79, 0x6e, 0x63, 0x53, 0x74, 0x72, 0x61, 0x74, 0x65, 0x67, 0x79, 0x12, 0x29, 0x0a, 0x10, 0x73, 0x74, 0x72, 0x61, 0x74, 0x65, 0x67, 0x79, 0x5f, 0x73, 0x75, 0x6d, 0x6d, 0x61, 0x72, 0x79, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0f, 0x73, 0x74, 0x72, 0x61, 0x74, 0x65, 0x67, 0x79, 0x53, - 0x75, 0x6d, 0x6d, 0x61, 0x72, 0x79, 0x22, 0xde, 0x04, 0x0a, 0x0d, 0x50, 0x69, 0x70, 0x65, 0x6c, + 0x75, 0x6d, 0x6d, 0x61, 0x72, 0x79, 0x22, 0xe2, 0x04, 0x0a, 0x0d, 0x50, 0x69, 0x70, 0x65, 0x6c, 0x69, 0x6e, 0x65, 0x53, 0x74, 0x61, 0x67, 0x65, 0x12, 0x17, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x42, 0x07, 0xfa, 0x42, 0x04, 0x72, 0x02, 0x10, 0x01, 0x52, 0x02, 0x69, 0x64, 0x12, 0x1b, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x42, 0x07, 0xfa, 0x42, 0x04, 0x72, 0x02, 0x10, 0x01, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x64, 0x65, 0x73, 0x63, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x64, 0x65, 0x73, 0x63, 0x12, 0x14, 0x0a, 0x05, 0x69, 0x6e, 0x64, 0x65, 0x78, 0x18, 0x04, 0x20, 0x01, 0x28, - 0x05, 0x52, 0x05, 0x69, 0x6e, 0x64, 0x65, 0x78, 0x12, 0x1e, 0x0a, 0x0a, 0x70, 0x72, 0x65, 0x64, - 0x65, 0x66, 0x69, 0x6e, 0x65, 0x64, 0x18, 0x05, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0a, 0x70, 0x72, - 0x65, 0x64, 0x65, 0x66, 0x69, 0x6e, 0x65, 0x64, 0x12, 0x1a, 0x0a, 0x08, 0x72, 0x65, 0x71, 0x75, - 0x69, 0x72, 0x65, 0x73, 0x18, 0x06, 0x20, 0x03, 0x28, 0x09, 0x52, 0x08, 0x72, 0x65, 0x71, 0x75, - 0x69, 0x72, 0x65, 0x73, 0x12, 0x1c, 0x0a, 0x07, 0x76, 0x69, 0x73, 0x69, 0x62, 0x6c, 0x65, 0x18, - 0x07, 0x20, 0x01, 0x28, 0x08, 0x42, 0x02, 0x18, 0x01, 0x52, 0x07, 0x76, 0x69, 0x73, 0x69, 0x62, - 0x6c, 0x65, 0x12, 0x34, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x08, 0x20, 0x01, - 0x28, 0x0e, 0x32, 0x12, 0x2e, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x2e, 0x53, 0x74, 0x61, 0x67, 0x65, - 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x42, 0x08, 0xfa, 0x42, 0x05, 0x82, 0x01, 0x02, 0x10, 0x01, - 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x23, 0x0a, 0x0d, 0x73, 0x74, 0x61, 0x74, - 0x75, 0x73, 0x5f, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x18, 0x09, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x0c, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x12, 0x3e, 0x0a, - 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x18, 0x0a, 0x20, 0x03, 0x28, 0x0b, 0x32, - 0x22, 0x2e, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x2e, 0x50, 0x69, 0x70, 0x65, 0x6c, 0x69, 0x6e, 0x65, - 0x53, 0x74, 0x61, 0x67, 0x65, 0x2e, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x45, 0x6e, - 0x74, 0x72, 0x79, 0x52, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x12, 0x23, 0x0a, - 0x0d, 0x72, 0x65, 0x74, 0x72, 0x69, 0x65, 0x64, 0x5f, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x0b, - 0x20, 0x01, 0x28, 0x05, 0x52, 0x0c, 0x72, 0x65, 0x74, 0x72, 0x69, 0x65, 0x64, 0x43, 0x6f, 0x75, - 0x6e, 0x74, 0x12, 0x1a, 0x0a, 0x08, 0x72, 0x6f, 0x6c, 0x6c, 0x62, 0x61, 0x63, 0x6b, 0x18, 0x0c, - 0x20, 0x01, 0x28, 0x08, 0x52, 0x08, 0x72, 0x6f, 0x6c, 0x6c, 0x62, 0x61, 0x63, 0x6b, 0x12, 0x2a, - 0x0a, 0x0c, 0x63, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x18, 0x0d, - 0x20, 0x01, 0x28, 0x03, 0x42, 0x07, 0xfa, 0x42, 0x04, 0x22, 0x02, 0x28, 0x00, 0x52, 0x0b, 0x63, - 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x64, 0x41, 0x74, 0x12, 0x26, 0x0a, 0x0a, 0x63, 0x72, - 0x65, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x18, 0x0e, 0x20, 0x01, 0x28, 0x03, 0x42, 0x07, - 0xfa, 0x42, 0x04, 0x22, 0x02, 0x20, 0x00, 0x52, 0x09, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, - 0x41, 0x74, 0x12, 0x26, 0x0a, 0x0a, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, - 0x18, 0x0f, 0x20, 0x01, 0x28, 0x03, 0x42, 0x07, 0xfa, 0x42, 0x04, 0x22, 0x02, 0x20, 0x00, 0x52, - 0x09, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x64, 0x41, 0x74, 0x1a, 0x3b, 0x0a, 0x0d, 0x4d, 0x65, - 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, - 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, - 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x76, 0x61, - 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0xe7, 0x01, 0x0a, 0x06, 0x43, 0x6f, 0x6d, 0x6d, - 0x69, 0x74, 0x12, 0x1b, 0x0a, 0x04, 0x68, 0x61, 0x73, 0x68, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, - 0x42, 0x07, 0xfa, 0x42, 0x04, 0x72, 0x02, 0x10, 0x01, 0x52, 0x04, 0x68, 0x61, 0x73, 0x68, 0x12, - 0x21, 0x0a, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, - 0x42, 0x07, 0xfa, 0x42, 0x04, 0x72, 0x02, 0x10, 0x01, 0x52, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, - 0x67, 0x65, 0x12, 0x1f, 0x0a, 0x06, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x18, 0x03, 0x20, 0x01, - 0x28, 0x09, 0x42, 0x07, 0xfa, 0x42, 0x04, 0x72, 0x02, 0x10, 0x01, 0x52, 0x06, 0x61, 0x75, 0x74, - 0x68, 0x6f, 0x72, 0x12, 0x1f, 0x0a, 0x06, 0x62, 0x72, 0x61, 0x6e, 0x63, 0x68, 0x18, 0x04, 0x20, - 0x01, 0x28, 0x09, 0x42, 0x07, 0xfa, 0x42, 0x04, 0x72, 0x02, 0x10, 0x01, 0x52, 0x06, 0x62, 0x72, - 0x61, 0x6e, 0x63, 0x68, 0x12, 0x21, 0x0a, 0x0c, 0x70, 0x75, 0x6c, 0x6c, 0x5f, 0x72, 0x65, 0x71, - 0x75, 0x65, 0x73, 0x74, 0x18, 0x05, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x70, 0x75, 0x6c, 0x6c, - 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x10, 0x0a, 0x03, 0x75, 0x72, 0x6c, 0x18, 0x06, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x75, 0x72, 0x6c, 0x12, 0x26, 0x0a, 0x0a, 0x63, 0x72, 0x65, - 0x61, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x18, 0x0e, 0x20, 0x01, 0x28, 0x03, 0x42, 0x07, 0xfa, - 0x42, 0x04, 0x22, 0x02, 0x20, 0x00, 0x52, 0x09, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x41, - 0x74, 0x2a, 0xc1, 0x01, 0x0a, 0x10, 0x44, 0x65, 0x70, 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, - 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x16, 0x0a, 0x12, 0x44, 0x45, 0x50, 0x4c, 0x4f, 0x59, - 0x4d, 0x45, 0x4e, 0x54, 0x5f, 0x50, 0x45, 0x4e, 0x44, 0x49, 0x4e, 0x47, 0x10, 0x00, 0x12, 0x16, - 0x0a, 0x12, 0x44, 0x45, 0x50, 0x4c, 0x4f, 0x59, 0x4d, 0x45, 0x4e, 0x54, 0x5f, 0x50, 0x4c, 0x41, - 0x4e, 0x4e, 0x45, 0x44, 0x10, 0x01, 0x12, 0x16, 0x0a, 0x12, 0x44, 0x45, 0x50, 0x4c, 0x4f, 0x59, - 0x4d, 0x45, 0x4e, 0x54, 0x5f, 0x52, 0x55, 0x4e, 0x4e, 0x49, 0x4e, 0x47, 0x10, 0x02, 0x12, 0x1b, - 0x0a, 0x17, 0x44, 0x45, 0x50, 0x4c, 0x4f, 0x59, 0x4d, 0x45, 0x4e, 0x54, 0x5f, 0x52, 0x4f, 0x4c, - 0x4c, 0x49, 0x4e, 0x47, 0x5f, 0x42, 0x41, 0x43, 0x4b, 0x10, 0x03, 0x12, 0x16, 0x0a, 0x12, 0x44, - 0x45, 0x50, 0x4c, 0x4f, 0x59, 0x4d, 0x45, 0x4e, 0x54, 0x5f, 0x53, 0x55, 0x43, 0x43, 0x45, 0x53, - 0x53, 0x10, 0x04, 0x12, 0x16, 0x0a, 0x12, 0x44, 0x45, 0x50, 0x4c, 0x4f, 0x59, 0x4d, 0x45, 0x4e, - 0x54, 0x5f, 0x46, 0x41, 0x49, 0x4c, 0x55, 0x52, 0x45, 0x10, 0x05, 0x12, 0x18, 0x0a, 0x14, 0x44, - 0x45, 0x50, 0x4c, 0x4f, 0x59, 0x4d, 0x45, 0x4e, 0x54, 0x5f, 0x43, 0x41, 0x4e, 0x43, 0x45, 0x4c, - 0x4c, 0x45, 0x44, 0x10, 0x06, 0x2a, 0x9b, 0x01, 0x0a, 0x0b, 0x53, 0x74, 0x61, 0x67, 0x65, 0x53, - 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x19, 0x0a, 0x15, 0x53, 0x54, 0x41, 0x47, 0x45, 0x5f, 0x4e, - 0x4f, 0x54, 0x5f, 0x53, 0x54, 0x41, 0x52, 0x54, 0x45, 0x44, 0x5f, 0x59, 0x45, 0x54, 0x10, 0x00, - 0x12, 0x11, 0x0a, 0x0d, 0x53, 0x54, 0x41, 0x47, 0x45, 0x5f, 0x52, 0x55, 0x4e, 0x4e, 0x49, 0x4e, - 0x47, 0x10, 0x01, 0x12, 0x11, 0x0a, 0x0d, 0x53, 0x54, 0x41, 0x47, 0x45, 0x5f, 0x53, 0x55, 0x43, - 0x43, 0x45, 0x53, 0x53, 0x10, 0x02, 0x12, 0x11, 0x0a, 0x0d, 0x53, 0x54, 0x41, 0x47, 0x45, 0x5f, - 0x46, 0x41, 0x49, 0x4c, 0x55, 0x52, 0x45, 0x10, 0x03, 0x12, 0x13, 0x0a, 0x0f, 0x53, 0x54, 0x41, - 0x47, 0x45, 0x5f, 0x43, 0x41, 0x4e, 0x43, 0x45, 0x4c, 0x4c, 0x45, 0x44, 0x10, 0x04, 0x12, 0x11, - 0x0a, 0x0d, 0x53, 0x54, 0x41, 0x47, 0x45, 0x5f, 0x53, 0x4b, 0x49, 0x50, 0x50, 0x45, 0x44, 0x10, - 0x05, 0x12, 0x10, 0x0a, 0x0c, 0x53, 0x54, 0x41, 0x47, 0x45, 0x5f, 0x45, 0x58, 0x49, 0x54, 0x45, - 0x44, 0x10, 0x06, 0x2a, 0x4e, 0x0a, 0x0b, 0x54, 0x72, 0x69, 0x67, 0x67, 0x65, 0x72, 0x4b, 0x69, - 0x6e, 0x64, 0x12, 0x0d, 0x0a, 0x09, 0x4f, 0x4e, 0x5f, 0x43, 0x4f, 0x4d, 0x4d, 0x49, 0x54, 0x10, - 0x00, 0x12, 0x0e, 0x0a, 0x0a, 0x4f, 0x4e, 0x5f, 0x43, 0x4f, 0x4d, 0x4d, 0x41, 0x4e, 0x44, 0x10, - 0x01, 0x12, 0x12, 0x0a, 0x0e, 0x4f, 0x4e, 0x5f, 0x4f, 0x55, 0x54, 0x5f, 0x4f, 0x46, 0x5f, 0x53, - 0x59, 0x4e, 0x43, 0x10, 0x02, 0x12, 0x0c, 0x0a, 0x08, 0x4f, 0x4e, 0x5f, 0x43, 0x48, 0x41, 0x49, - 0x4e, 0x10, 0x03, 0x42, 0x25, 0x5a, 0x23, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, - 0x6d, 0x2f, 0x70, 0x69, 0x70, 0x65, 0x2d, 0x63, 0x64, 0x2f, 0x70, 0x69, 0x70, 0x65, 0x63, 0x64, - 0x2f, 0x70, 0x6b, 0x67, 0x2f, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, - 0x6f, 0x33, + 0x05, 0x52, 0x05, 0x69, 0x6e, 0x64, 0x65, 0x78, 0x12, 0x22, 0x0a, 0x0a, 0x70, 0x72, 0x65, 0x64, + 0x65, 0x66, 0x69, 0x6e, 0x65, 0x64, 0x18, 0x05, 0x20, 0x01, 0x28, 0x08, 0x42, 0x02, 0x18, 0x01, + 0x52, 0x0a, 0x70, 0x72, 0x65, 0x64, 0x65, 0x66, 0x69, 0x6e, 0x65, 0x64, 0x12, 0x1a, 0x0a, 0x08, + 0x72, 0x65, 0x71, 0x75, 0x69, 0x72, 0x65, 0x73, 0x18, 0x06, 0x20, 0x03, 0x28, 0x09, 0x52, 0x08, + 0x72, 0x65, 0x71, 0x75, 0x69, 0x72, 0x65, 0x73, 0x12, 0x1c, 0x0a, 0x07, 0x76, 0x69, 0x73, 0x69, + 0x62, 0x6c, 0x65, 0x18, 0x07, 0x20, 0x01, 0x28, 0x08, 0x42, 0x02, 0x18, 0x01, 0x52, 0x07, 0x76, + 0x69, 0x73, 0x69, 0x62, 0x6c, 0x65, 0x12, 0x34, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, + 0x18, 0x08, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x12, 0x2e, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x2e, 0x53, + 0x74, 0x61, 0x67, 0x65, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x42, 0x08, 0xfa, 0x42, 0x05, 0x82, + 0x01, 0x02, 0x10, 0x01, 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x23, 0x0a, 0x0d, + 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x5f, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x18, 0x09, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x0c, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x65, 0x61, 0x73, 0x6f, + 0x6e, 0x12, 0x3e, 0x0a, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x18, 0x0a, 0x20, + 0x03, 0x28, 0x0b, 0x32, 0x22, 0x2e, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x2e, 0x50, 0x69, 0x70, 0x65, + 0x6c, 0x69, 0x6e, 0x65, 0x53, 0x74, 0x61, 0x67, 0x65, 0x2e, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, + 0x74, 0x61, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, + 0x61, 0x12, 0x23, 0x0a, 0x0d, 0x72, 0x65, 0x74, 0x72, 0x69, 0x65, 0x64, 0x5f, 0x63, 0x6f, 0x75, + 0x6e, 0x74, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x05, 0x52, 0x0c, 0x72, 0x65, 0x74, 0x72, 0x69, 0x65, + 0x64, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x1a, 0x0a, 0x08, 0x72, 0x6f, 0x6c, 0x6c, 0x62, 0x61, + 0x63, 0x6b, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x08, 0x52, 0x08, 0x72, 0x6f, 0x6c, 0x6c, 0x62, 0x61, + 0x63, 0x6b, 0x12, 0x2a, 0x0a, 0x0c, 0x63, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x64, 0x5f, + 0x61, 0x74, 0x18, 0x0d, 0x20, 0x01, 0x28, 0x03, 0x42, 0x07, 0xfa, 0x42, 0x04, 0x22, 0x02, 0x28, + 0x00, 0x52, 0x0b, 0x63, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x64, 0x41, 0x74, 0x12, 0x26, + 0x0a, 0x0a, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x18, 0x0e, 0x20, 0x01, + 0x28, 0x03, 0x42, 0x07, 0xfa, 0x42, 0x04, 0x22, 0x02, 0x20, 0x00, 0x52, 0x09, 0x63, 0x72, 0x65, + 0x61, 0x74, 0x65, 0x64, 0x41, 0x74, 0x12, 0x26, 0x0a, 0x0a, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, + 0x64, 0x5f, 0x61, 0x74, 0x18, 0x0f, 0x20, 0x01, 0x28, 0x03, 0x42, 0x07, 0xfa, 0x42, 0x04, 0x22, + 0x02, 0x20, 0x00, 0x52, 0x09, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x64, 0x41, 0x74, 0x1a, 0x3b, + 0x0a, 0x0d, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, + 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, + 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0xe7, 0x01, 0x0a, 0x06, + 0x43, 0x6f, 0x6d, 0x6d, 0x69, 0x74, 0x12, 0x1b, 0x0a, 0x04, 0x68, 0x61, 0x73, 0x68, 0x18, 0x01, + 0x20, 0x01, 0x28, 0x09, 0x42, 0x07, 0xfa, 0x42, 0x04, 0x72, 0x02, 0x10, 0x01, 0x52, 0x04, 0x68, + 0x61, 0x73, 0x68, 0x12, 0x21, 0x0a, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x02, + 0x20, 0x01, 0x28, 0x09, 0x42, 0x07, 0xfa, 0x42, 0x04, 0x72, 0x02, 0x10, 0x01, 0x52, 0x07, 0x6d, + 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x12, 0x1f, 0x0a, 0x06, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, + 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x42, 0x07, 0xfa, 0x42, 0x04, 0x72, 0x02, 0x10, 0x01, 0x52, + 0x06, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x12, 0x1f, 0x0a, 0x06, 0x62, 0x72, 0x61, 0x6e, 0x63, + 0x68, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x42, 0x07, 0xfa, 0x42, 0x04, 0x72, 0x02, 0x10, 0x01, + 0x52, 0x06, 0x62, 0x72, 0x61, 0x6e, 0x63, 0x68, 0x12, 0x21, 0x0a, 0x0c, 0x70, 0x75, 0x6c, 0x6c, + 0x5f, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x18, 0x05, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, + 0x70, 0x75, 0x6c, 0x6c, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x10, 0x0a, 0x03, 0x75, + 0x72, 0x6c, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x75, 0x72, 0x6c, 0x12, 0x26, 0x0a, + 0x0a, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x18, 0x0e, 0x20, 0x01, 0x28, + 0x03, 0x42, 0x07, 0xfa, 0x42, 0x04, 0x22, 0x02, 0x20, 0x00, 0x52, 0x09, 0x63, 0x72, 0x65, 0x61, + 0x74, 0x65, 0x64, 0x41, 0x74, 0x2a, 0xc1, 0x01, 0x0a, 0x10, 0x44, 0x65, 0x70, 0x6c, 0x6f, 0x79, + 0x6d, 0x65, 0x6e, 0x74, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x16, 0x0a, 0x12, 0x44, 0x45, + 0x50, 0x4c, 0x4f, 0x59, 0x4d, 0x45, 0x4e, 0x54, 0x5f, 0x50, 0x45, 0x4e, 0x44, 0x49, 0x4e, 0x47, + 0x10, 0x00, 0x12, 0x16, 0x0a, 0x12, 0x44, 0x45, 0x50, 0x4c, 0x4f, 0x59, 0x4d, 0x45, 0x4e, 0x54, + 0x5f, 0x50, 0x4c, 0x41, 0x4e, 0x4e, 0x45, 0x44, 0x10, 0x01, 0x12, 0x16, 0x0a, 0x12, 0x44, 0x45, + 0x50, 0x4c, 0x4f, 0x59, 0x4d, 0x45, 0x4e, 0x54, 0x5f, 0x52, 0x55, 0x4e, 0x4e, 0x49, 0x4e, 0x47, + 0x10, 0x02, 0x12, 0x1b, 0x0a, 0x17, 0x44, 0x45, 0x50, 0x4c, 0x4f, 0x59, 0x4d, 0x45, 0x4e, 0x54, + 0x5f, 0x52, 0x4f, 0x4c, 0x4c, 0x49, 0x4e, 0x47, 0x5f, 0x42, 0x41, 0x43, 0x4b, 0x10, 0x03, 0x12, + 0x16, 0x0a, 0x12, 0x44, 0x45, 0x50, 0x4c, 0x4f, 0x59, 0x4d, 0x45, 0x4e, 0x54, 0x5f, 0x53, 0x55, + 0x43, 0x43, 0x45, 0x53, 0x53, 0x10, 0x04, 0x12, 0x16, 0x0a, 0x12, 0x44, 0x45, 0x50, 0x4c, 0x4f, + 0x59, 0x4d, 0x45, 0x4e, 0x54, 0x5f, 0x46, 0x41, 0x49, 0x4c, 0x55, 0x52, 0x45, 0x10, 0x05, 0x12, + 0x18, 0x0a, 0x14, 0x44, 0x45, 0x50, 0x4c, 0x4f, 0x59, 0x4d, 0x45, 0x4e, 0x54, 0x5f, 0x43, 0x41, + 0x4e, 0x43, 0x45, 0x4c, 0x4c, 0x45, 0x44, 0x10, 0x06, 0x2a, 0x9b, 0x01, 0x0a, 0x0b, 0x53, 0x74, + 0x61, 0x67, 0x65, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x19, 0x0a, 0x15, 0x53, 0x54, 0x41, + 0x47, 0x45, 0x5f, 0x4e, 0x4f, 0x54, 0x5f, 0x53, 0x54, 0x41, 0x52, 0x54, 0x45, 0x44, 0x5f, 0x59, + 0x45, 0x54, 0x10, 0x00, 0x12, 0x11, 0x0a, 0x0d, 0x53, 0x54, 0x41, 0x47, 0x45, 0x5f, 0x52, 0x55, + 0x4e, 0x4e, 0x49, 0x4e, 0x47, 0x10, 0x01, 0x12, 0x11, 0x0a, 0x0d, 0x53, 0x54, 0x41, 0x47, 0x45, + 0x5f, 0x53, 0x55, 0x43, 0x43, 0x45, 0x53, 0x53, 0x10, 0x02, 0x12, 0x11, 0x0a, 0x0d, 0x53, 0x54, + 0x41, 0x47, 0x45, 0x5f, 0x46, 0x41, 0x49, 0x4c, 0x55, 0x52, 0x45, 0x10, 0x03, 0x12, 0x13, 0x0a, + 0x0f, 0x53, 0x54, 0x41, 0x47, 0x45, 0x5f, 0x43, 0x41, 0x4e, 0x43, 0x45, 0x4c, 0x4c, 0x45, 0x44, + 0x10, 0x04, 0x12, 0x11, 0x0a, 0x0d, 0x53, 0x54, 0x41, 0x47, 0x45, 0x5f, 0x53, 0x4b, 0x49, 0x50, + 0x50, 0x45, 0x44, 0x10, 0x05, 0x12, 0x10, 0x0a, 0x0c, 0x53, 0x54, 0x41, 0x47, 0x45, 0x5f, 0x45, + 0x58, 0x49, 0x54, 0x45, 0x44, 0x10, 0x06, 0x2a, 0x4e, 0x0a, 0x0b, 0x54, 0x72, 0x69, 0x67, 0x67, + 0x65, 0x72, 0x4b, 0x69, 0x6e, 0x64, 0x12, 0x0d, 0x0a, 0x09, 0x4f, 0x4e, 0x5f, 0x43, 0x4f, 0x4d, + 0x4d, 0x49, 0x54, 0x10, 0x00, 0x12, 0x0e, 0x0a, 0x0a, 0x4f, 0x4e, 0x5f, 0x43, 0x4f, 0x4d, 0x4d, + 0x41, 0x4e, 0x44, 0x10, 0x01, 0x12, 0x12, 0x0a, 0x0e, 0x4f, 0x4e, 0x5f, 0x4f, 0x55, 0x54, 0x5f, + 0x4f, 0x46, 0x5f, 0x53, 0x59, 0x4e, 0x43, 0x10, 0x02, 0x12, 0x0c, 0x0a, 0x08, 0x4f, 0x4e, 0x5f, + 0x43, 0x48, 0x41, 0x49, 0x4e, 0x10, 0x03, 0x42, 0x25, 0x5a, 0x23, 0x67, 0x69, 0x74, 0x68, 0x75, + 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x70, 0x69, 0x70, 0x65, 0x2d, 0x63, 0x64, 0x2f, 0x70, 0x69, + 0x70, 0x65, 0x63, 0x64, 0x2f, 0x70, 0x6b, 0x67, 0x2f, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x62, 0x06, + 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, } var ( diff --git a/pkg/model/deployment.proto b/pkg/model/deployment.proto index 5e82a62934..ebc334612b 100644 --- a/pkg/model/deployment.proto +++ b/pkg/model/deployment.proto @@ -132,7 +132,7 @@ message PipelineStage { // Stage index from the stage list in configuration. int32 index = 4; // Whether this stage is the predefined one by planner. - bool predefined = 5; + bool predefined = 5 [deprecated=true]; repeated string requires = 6; // Whether this stage should be rendered or not. bool visible = 7 [deprecated=true]; From 988949abb723756dddfb0c1340e33e6001f26f1d Mon Sep 17 00:00:00 2001 From: Shinnosuke Sawada-Dazai Date: Fri, 27 Sep 2024 10:21:02 +0900 Subject: [PATCH 27/84] Copy pkg/config to pkg/configv1 (#5237) Signed-off-by: Shinnosuke Sawada-Dazai --- .github/labeler.yaml | 2 + pkg/configv1/analysis.go | 178 ++ pkg/configv1/analysis_template.go | 63 + pkg/configv1/analysis_template_test.go | 110 ++ pkg/configv1/analysis_test.go | 65 + pkg/configv1/application.go | 798 +++++++++ pkg/configv1/application_cloudrun.go | 53 + pkg/configv1/application_cloudrun_test.go | 77 + pkg/configv1/application_ecs.go | 162 ++ pkg/configv1/application_ecs_test.go | 165 ++ pkg/configv1/application_kubernetes.go | 313 ++++ pkg/configv1/application_kubernetes_test.go | 195 +++ pkg/configv1/application_lambda.go | 57 + pkg/configv1/application_lambda_test.go | 181 ++ pkg/configv1/application_terraform.go | 92 + pkg/configv1/application_terraform_test.go | 263 +++ pkg/configv1/application_test.go | 872 ++++++++++ pkg/configv1/config.go | 255 +++ pkg/configv1/config_test.go | 112 ++ pkg/configv1/control_plane.go | 323 ++++ pkg/configv1/control_plane_test.go | 116 ++ pkg/configv1/duration.go | 52 + pkg/configv1/event_watcher.go | 230 +++ pkg/configv1/event_watcher_test.go | 254 +++ pkg/configv1/feature_flag.go | 37 + pkg/configv1/launcher.go | 73 + pkg/configv1/percentage.go | 65 + pkg/configv1/percentage_test.go | 121 ++ pkg/configv1/piped.go | 1290 ++++++++++++++ pkg/configv1/piped_test.go | 1520 +++++++++++++++++ pkg/configv1/replicas.go | 83 + pkg/configv1/replicas_test.go | 127 ++ pkg/configv1/testdata/.pipe/README.md | 4 + .../testdata/.pipe/analysis-template.yaml | 55 + .../testdata/.pipe/event-watcher.yaml | 14 + .../application/cloudrun-app-bluegreen.yaml | 21 + .../application/cloudrun-app-canary.yaml | 26 + .../testdata/application/cloudrun-app.yaml | 2 + .../application/custom-sync-without-run.yaml | 11 + .../testdata/application/custom-sync.yaml | 14 + .../ecs-app-invalid-access-type.yaml | 7 + .../ecs-app-service-discovery.yaml | 7 + .../testdata/application/ecs-app.yaml | 11 + .../application/generic-analysis.yaml | 39 + .../application/generic-postsync.yaml | 11 + .../testdata/application/generic-trigger.yaml | 7 + .../k8s-app-bluegreen-with-analysis.yaml | 28 + .../application/k8s-app-bluegreen.yaml | 28 + .../testdata/application/k8s-app-canary.yaml | 298 ++++ .../application/k8s-app-envoy-bluegreen.yaml | 16 + .../application/k8s-app-envoy-canary.yaml | 16 + .../testdata/application/k8s-app-helm.yaml | 38 + .../application/k8s-app-istio-bluegreen.yaml | 41 + .../application/k8s-app-istio-canary.yaml | 56 + .../application/k8s-app-kustomization.yaml | 5 + .../application/k8s-app-resource-route.yaml | 16 + .../k8s-app-use-pipeline-template.yaml | 5 + .../testdata/application/k8s-plain-yaml.yaml | 9 + .../application/lambda-app-bluegreen.yaml | 16 + .../application/lambda-app-canary.yaml | 21 + .../testdata/application/lambda-app.yaml | 2 + .../application/terraform-app-empty.yaml | 2 + .../terraform-app-secret-management.yaml | 14 + .../terraform-app-with-approval.yaml | 37 + .../application/terraform-app-with-exit.yaml | 39 + .../testdata/application/terraform-app.yaml | 6 + .../truebydefaultbool-false-explicitly.yaml | 8 + .../truebydefaultbool-not-specified.yaml | 2 + .../truebydefaultbool-true-explicitly.yaml | 8 + .../control-plane/control-plane-config.yaml | 39 + .../piped/notification-receiver-webhook | 1 + pkg/configv1/testdata/piped/piped-config.yaml | 245 +++ .../testdata/sealedsecret/invalid.yaml | 4 + pkg/configv1/testdata/sealedsecret/ok.yaml | 15 + 74 files changed, 9548 insertions(+) create mode 100644 pkg/configv1/analysis.go create mode 100644 pkg/configv1/analysis_template.go create mode 100644 pkg/configv1/analysis_template_test.go create mode 100644 pkg/configv1/analysis_test.go create mode 100644 pkg/configv1/application.go create mode 100644 pkg/configv1/application_cloudrun.go create mode 100644 pkg/configv1/application_cloudrun_test.go create mode 100644 pkg/configv1/application_ecs.go create mode 100644 pkg/configv1/application_ecs_test.go create mode 100644 pkg/configv1/application_kubernetes.go create mode 100644 pkg/configv1/application_kubernetes_test.go create mode 100644 pkg/configv1/application_lambda.go create mode 100644 pkg/configv1/application_lambda_test.go create mode 100644 pkg/configv1/application_terraform.go create mode 100644 pkg/configv1/application_terraform_test.go create mode 100644 pkg/configv1/application_test.go create mode 100644 pkg/configv1/config.go create mode 100644 pkg/configv1/config_test.go create mode 100644 pkg/configv1/control_plane.go create mode 100644 pkg/configv1/control_plane_test.go create mode 100644 pkg/configv1/duration.go create mode 100644 pkg/configv1/event_watcher.go create mode 100644 pkg/configv1/event_watcher_test.go create mode 100644 pkg/configv1/feature_flag.go create mode 100644 pkg/configv1/launcher.go create mode 100644 pkg/configv1/percentage.go create mode 100644 pkg/configv1/percentage_test.go create mode 100644 pkg/configv1/piped.go create mode 100644 pkg/configv1/piped_test.go create mode 100644 pkg/configv1/replicas.go create mode 100644 pkg/configv1/replicas_test.go create mode 100644 pkg/configv1/testdata/.pipe/README.md create mode 100644 pkg/configv1/testdata/.pipe/analysis-template.yaml create mode 100644 pkg/configv1/testdata/.pipe/event-watcher.yaml create mode 100644 pkg/configv1/testdata/application/cloudrun-app-bluegreen.yaml create mode 100644 pkg/configv1/testdata/application/cloudrun-app-canary.yaml create mode 100644 pkg/configv1/testdata/application/cloudrun-app.yaml create mode 100644 pkg/configv1/testdata/application/custom-sync-without-run.yaml create mode 100644 pkg/configv1/testdata/application/custom-sync.yaml create mode 100644 pkg/configv1/testdata/application/ecs-app-invalid-access-type.yaml create mode 100644 pkg/configv1/testdata/application/ecs-app-service-discovery.yaml create mode 100644 pkg/configv1/testdata/application/ecs-app.yaml create mode 100644 pkg/configv1/testdata/application/generic-analysis.yaml create mode 100644 pkg/configv1/testdata/application/generic-postsync.yaml create mode 100644 pkg/configv1/testdata/application/generic-trigger.yaml create mode 100644 pkg/configv1/testdata/application/k8s-app-bluegreen-with-analysis.yaml create mode 100644 pkg/configv1/testdata/application/k8s-app-bluegreen.yaml create mode 100644 pkg/configv1/testdata/application/k8s-app-canary.yaml create mode 100644 pkg/configv1/testdata/application/k8s-app-envoy-bluegreen.yaml create mode 100644 pkg/configv1/testdata/application/k8s-app-envoy-canary.yaml create mode 100644 pkg/configv1/testdata/application/k8s-app-helm.yaml create mode 100644 pkg/configv1/testdata/application/k8s-app-istio-bluegreen.yaml create mode 100644 pkg/configv1/testdata/application/k8s-app-istio-canary.yaml create mode 100644 pkg/configv1/testdata/application/k8s-app-kustomization.yaml create mode 100644 pkg/configv1/testdata/application/k8s-app-resource-route.yaml create mode 100644 pkg/configv1/testdata/application/k8s-app-use-pipeline-template.yaml create mode 100644 pkg/configv1/testdata/application/k8s-plain-yaml.yaml create mode 100644 pkg/configv1/testdata/application/lambda-app-bluegreen.yaml create mode 100644 pkg/configv1/testdata/application/lambda-app-canary.yaml create mode 100644 pkg/configv1/testdata/application/lambda-app.yaml create mode 100644 pkg/configv1/testdata/application/terraform-app-empty.yaml create mode 100644 pkg/configv1/testdata/application/terraform-app-secret-management.yaml create mode 100644 pkg/configv1/testdata/application/terraform-app-with-approval.yaml create mode 100644 pkg/configv1/testdata/application/terraform-app-with-exit.yaml create mode 100644 pkg/configv1/testdata/application/terraform-app.yaml create mode 100644 pkg/configv1/testdata/application/truebydefaultbool-false-explicitly.yaml create mode 100644 pkg/configv1/testdata/application/truebydefaultbool-not-specified.yaml create mode 100644 pkg/configv1/testdata/application/truebydefaultbool-true-explicitly.yaml create mode 100644 pkg/configv1/testdata/control-plane/control-plane-config.yaml create mode 100644 pkg/configv1/testdata/piped/notification-receiver-webhook create mode 100644 pkg/configv1/testdata/piped/piped-config.yaml create mode 100644 pkg/configv1/testdata/sealedsecret/invalid.yaml create mode 100644 pkg/configv1/testdata/sealedsecret/ok.yaml 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 From c1a06aa922a994340220a4bc5de62f6630c29b8a Mon Sep 17 00:00:00 2001 From: Tetsuya Kikuchi <97105818+t-kikuc@users.noreply.github.com> Date: Mon, 30 Sep 2024 09:11:00 +0900 Subject: [PATCH 28/84] Fix panic in ECS driftdetection when a taskdef in livestates does not exist (#5240) * Generate v0.49.x docs Signed-off-by: t-kikuc * fix: avoid panic when live taskdef does not exist Signed-off-by: t-kikuc --------- Signed-off-by: t-kikuc --- pkg/app/piped/driftdetector/ecs/detector.go | 27 ++++++++++++--------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/pkg/app/piped/driftdetector/ecs/detector.go b/pkg/app/piped/driftdetector/ecs/detector.go index 4ea1f4fa06..3852ae79de 100644 --- a/pkg/app/piped/driftdetector/ecs/detector.go +++ b/pkg/app/piped/driftdetector/ecs/detector.go @@ -252,15 +252,18 @@ func ignoreParameters(liveManifests provider.ECSManifests, headManifests provide liveService.TaskSets = nil liveTask := liveManifests.TaskDefinition - liveTask.RegisteredAt = nil - liveTask.RegisteredBy = nil - liveTask.RequiresAttributes = nil - liveTask.Revision = 0 // TODO: Find a way to compare the revision if possible. - liveTask.TaskDefinitionArn = nil - for i := range liveTask.ContainerDefinitions { - for j := range liveTask.ContainerDefinitions[i].PortMappings { - // We ignore diff of HostPort because it has several default values. See https://docs.aws.amazon.com/AmazonECS/latest/APIReference/API_ContainerDefinition.html#ECS-Type-ContainerDefinition-portMappings. - liveTask.ContainerDefinitions[i].PortMappings[j].HostPort = nil + // When liveTask does not exist, e.g. right after the service is created. + if liveTask != nil { + liveTask.RegisteredAt = nil + liveTask.RegisteredBy = nil + liveTask.RequiresAttributes = nil + liveTask.Revision = 0 // TODO: Find a way to compare the revision if possible. + liveTask.TaskDefinitionArn = nil + for i := range liveTask.ContainerDefinitions { + for j := range liveTask.ContainerDefinitions[i].PortMappings { + // We ignore diff of HostPort because it has several default values. See https://docs.aws.amazon.com/AmazonECS/latest/APIReference/API_ContainerDefinition.html#ECS-Type-ContainerDefinition-portMappings. + liveTask.ContainerDefinitions[i].PortMappings[j].HostPort = nil + } } } @@ -290,8 +293,10 @@ func ignoreParameters(liveManifests provider.ECSManifests, headManifests provide headService.Tags = nil headTask := headManifests.TaskDefinition - headTask.Status = types.TaskDefinitionStatusActive // If livestate's status is not ACTIVE, we should re-deploy a new task definition. - headTask.Compatibilities = liveTask.Compatibilities // Users can specify Compatibilities in a task definition file, but it is not used when registering a task definition. + headTask.Status = types.TaskDefinitionStatusActive // If livestate's status is not ACTIVE, we should re-deploy a new task definition. + if liveTask != nil { + headTask.Compatibilities = liveTask.Compatibilities // Users can specify Compatibilities in a task definition file, but it is not used when registering a task definition. + } for i := range headTask.ContainerDefinitions { cd := &headTask.ContainerDefinitions[i] From c4ca5c3db41181d5b5255e62980aa8c4259e680a Mon Sep 17 00:00:00 2001 From: Khanh Tran <32532742+khanhtc1202@users.noreply.github.com> Date: Mon, 30 Sep 2024 17:17:04 +0900 Subject: [PATCH 29/84] Add requires for stages in case of multi plugins quicksync pattern (#5239) Signed-off-by: khanhtc1202 --- pkg/app/pipedv1/controller/planner.go | 12 ++++++++++++ pkg/app/pipedv1/controller/planner_test.go | 10 ++++++---- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/pkg/app/pipedv1/controller/planner.go b/pkg/app/pipedv1/controller/planner.go index 13efadba7a..fe079f7fc8 100644 --- a/pkg/app/pipedv1/controller/planner.go +++ b/pkg/app/pipedv1/controller/planner.go @@ -422,6 +422,18 @@ func (p *planner) buildQuickSyncStages(ctx context.Context, cfg *config.GenericA sort.Sort(model.PipelineStages(stages)) sort.Sort(model.PipelineStages(rollbackStages)) + // In case there is more than one forward stage, build requires for each stage + // based on the order of stages. + if len(stages) > 1 { + preStageID := "" + for _, s := range stages { + if preStageID != "" { + s.Requires = []string{preStageID} + } + preStageID = s.Id + } + } + stages = append(stages, rollbackStages...) if len(stages) == 0 { return nil, fmt.Errorf("unable to build quick sync stages for deployment") diff --git a/pkg/app/pipedv1/controller/planner_test.go b/pkg/app/pipedv1/controller/planner_test.go index 9f57817686..8b0bdc9966 100644 --- a/pkg/app/pipedv1/controller/planner_test.go +++ b/pkg/app/pipedv1/controller/planner_test.go @@ -185,8 +185,9 @@ func TestBuildQuickSyncStages(t *testing.T) { Index: 0, }, { - Id: "plugin-2-stage-1", - Index: 1, + Id: "plugin-2-stage-1", + Index: 1, + Requires: []string{"plugin-1-stage-1"}, }, { Id: "plugin-1-rollback", @@ -244,8 +245,9 @@ func TestBuildQuickSyncStages(t *testing.T) { Index: 0, }, { - Id: "plugin-2-stage-1", - Index: 1, + Id: "plugin-2-stage-1", + Index: 1, + Requires: []string{"plugin-1-stage-1"}, }, }, }, From c16aebbe831ca42add9302a9cf61a4da8aa52249 Mon Sep 17 00:00:00 2001 From: Shinnosuke Sawada-Dazai Date: Tue, 1 Oct 2024 09:44:28 +0900 Subject: [PATCH 30/84] Use index from requests in buildQuickSyncPipeline (#5242) Signed-off-by: Shinnosuke Sawada-Dazai --- pkg/app/pipedv1/plugin/kubernetes/deployment/pipeline.go | 8 ++++---- .../pipedv1/plugin/kubernetes/deployment/pipeline_test.go | 5 ++++- pkg/app/pipedv1/plugin/kubernetes/deployment/server.go | 2 +- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/pkg/app/pipedv1/plugin/kubernetes/deployment/pipeline.go b/pkg/app/pipedv1/plugin/kubernetes/deployment/pipeline.go index 388aa5f959..f7e57369b4 100644 --- a/pkg/app/pipedv1/plugin/kubernetes/deployment/pipeline.go +++ b/pkg/app/pipedv1/plugin/kubernetes/deployment/pipeline.go @@ -105,7 +105,7 @@ func MakeInitialStageMetadata(cfg config.PipelineStage) map[string]string { } } -func buildQuickSyncPipeline(autoRollback bool, now time.Time) []*model.PipelineStage { +func buildQuickSyncPipeline(index int32, autoRollback bool, now time.Time) []*model.PipelineStage { var ( preStageID = "" stage, _ = GetPredefinedStage(PredefinedStageK8sSync) @@ -113,16 +113,16 @@ func buildQuickSyncPipeline(autoRollback bool, now time.Time) []*model.PipelineS out = make([]*model.PipelineStage, 0, len(stages)) ) - for i, s := range stages { + for _, s := range stages { id := s.ID if id == "" { - id = fmt.Sprintf("stage-%d", i) + id = fmt.Sprintf("kubernetes-stage-%d", index) } stage := &model.PipelineStage{ Id: id, Name: s.Name.String(), Desc: s.Desc, - Index: int32(i), + Index: int32(index), Predefined: true, Visible: true, Status: model.StageStatus_STAGE_NOT_STARTED_YET, diff --git a/pkg/app/pipedv1/plugin/kubernetes/deployment/pipeline_test.go b/pkg/app/pipedv1/plugin/kubernetes/deployment/pipeline_test.go index 26f608ed77..3e8f604828 100644 --- a/pkg/app/pipedv1/plugin/kubernetes/deployment/pipeline_test.go +++ b/pkg/app/pipedv1/plugin/kubernetes/deployment/pipeline_test.go @@ -29,11 +29,13 @@ func TestBuildQuickSyncPipeline(t *testing.T) { tests := []struct { name string + index int32 autoRollback bool expected []*model.PipelineStage }{ { name: "without auto rollback", + index: 0, autoRollback: false, expected: []*model.PipelineStage{ { @@ -52,6 +54,7 @@ func TestBuildQuickSyncPipeline(t *testing.T) { }, { name: "with auto rollback", + index: 0, autoRollback: true, expected: []*model.PipelineStage{ { @@ -82,7 +85,7 @@ func TestBuildQuickSyncPipeline(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - actual := buildQuickSyncPipeline(tt.autoRollback, now) + actual := buildQuickSyncPipeline(tt.index, tt.autoRollback, now) assert.Equal(t, tt.expected, actual) }) } diff --git a/pkg/app/pipedv1/plugin/kubernetes/deployment/server.go b/pkg/app/pipedv1/plugin/kubernetes/deployment/server.go index a06b87a360..5a4c5d206f 100644 --- a/pkg/app/pipedv1/plugin/kubernetes/deployment/server.go +++ b/pkg/app/pipedv1/plugin/kubernetes/deployment/server.go @@ -72,7 +72,7 @@ func (a *DeploymentService) BuildPipelineSyncStages(context.Context, *deployment // BuildQuickSyncStages implements deployment.DeploymentServiceServer. func (a *DeploymentService) BuildQuickSyncStages(ctx context.Context, request *deployment.BuildQuickSyncStagesRequest) (*deployment.BuildQuickSyncStagesResponse, error) { now := time.Now() - stages := buildQuickSyncPipeline(request.GetRollback(), now) + stages := buildQuickSyncPipeline(request.GetStageIndex(), request.GetRollback(), now) return &deployment.BuildQuickSyncStagesResponse{ Stages: stages, }, nil From 077b7b639a3b128b3163c7023fc64bf6584e8295 Mon Sep 17 00:00:00 2001 From: Shinnosuke Sawada-Dazai Date: Tue, 1 Oct 2024 11:14:17 +0900 Subject: [PATCH 31/84] Add k8s plugin's toolregistry implementation (#5243) * Pass install script from arg Signed-off-by: Shinnosuke Sawada-Dazai * Add k8s plugin's toolregistry Signed-off-by: Shinnosuke Sawada-Dazai --------- Signed-off-by: Shinnosuke Sawada-Dazai --- .../kubernetes/toolregistry/registry.go | 52 +++++++++++++++++++ .../plugin/kubernetes/toolregistry/scripts.go | 33 ++++++++++++ .../plugin/toolregistry/toolregistry.go | 7 +-- 3 files changed, 89 insertions(+), 3 deletions(-) create mode 100644 pkg/app/pipedv1/plugin/kubernetes/toolregistry/registry.go create mode 100644 pkg/app/pipedv1/plugin/kubernetes/toolregistry/scripts.go diff --git a/pkg/app/pipedv1/plugin/kubernetes/toolregistry/registry.go b/pkg/app/pipedv1/plugin/kubernetes/toolregistry/registry.go new file mode 100644 index 0000000000..d10ca33058 --- /dev/null +++ b/pkg/app/pipedv1/plugin/kubernetes/toolregistry/registry.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 toolregistry installs and manages the needed tools +// such as kubectl, helm... for executing tasks in pipeline. +package toolregistry + +import ( + "context" + + "github.com/pipe-cd/pipecd/pkg/app/pipedv1/plugin/toolregistry" +) + +// Registry provides functions to get path to the needed tools. +type Registry interface { + Kubectl(ctx context.Context, version string) (string, error) + Kustomize(ctx context.Context, version string) (string, error) + Helm(ctx context.Context, version string) (string, error) +} + +func NewRegistry(client toolregistry.ToolRegistry) Registry { + return ®istry{ + client: client, + } +} + +type registry struct { + client toolregistry.ToolRegistry +} + +func (r *registry) Kubectl(ctx context.Context, version string) (string, error) { + return r.client.InstallTool(ctx, "kubectl", version, kubectlInstallScript) +} + +func (r *registry) Kustomize(ctx context.Context, version string) (string, error) { + return r.client.InstallTool(ctx, "kustomize", version, kustomizeInstallScript) +} + +func (r *registry) Helm(ctx context.Context, version string) (string, error) { + return r.client.InstallTool(ctx, "helm", version, helmInstallScript) +} diff --git a/pkg/app/pipedv1/plugin/kubernetes/toolregistry/scripts.go b/pkg/app/pipedv1/plugin/kubernetes/toolregistry/scripts.go new file mode 100644 index 0000000000..9daea5f758 --- /dev/null +++ b/pkg/app/pipedv1/plugin/kubernetes/toolregistry/scripts.go @@ -0,0 +1,33 @@ +// 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 toolregistry + +const kubectlInstallScript = ` +cd {{ .TmpDir }} +curl -LO https://storage.googleapis.com/kubernetes-release/release/v{{ .Version }}/bin/{{ .Os }}/{{ .Arch }}/kubectl +mv kubectl {{ .OutPath }} +` + +const kustomizeInstallScript = ` +cd {{ .TmpDir }} +curl -L https://github.com/kubernetes-sigs/kustomize/releases/download/kustomize/v{{ .Version }}/kustomize_v{{ .Version }}_{{ .Os }}_{{ .Arch }}.tar.gz | tar xvz +mv kustomize {{ .OutPath }} +` + +const helmInstallScript = ` +cd {{ .TmpDir }} +curl -L https://get.helm.sh/helm-v{{ .Version }}-{{ .Os }}-{{ .Arch }}.tar.gz | tar xvz +mv {{ .Os }}-{{ .Arch }}/helm {{ .OutPath }} +` diff --git a/pkg/app/pipedv1/plugin/toolregistry/toolregistry.go b/pkg/app/pipedv1/plugin/toolregistry/toolregistry.go index 3675aea62d..4e674b79c9 100644 --- a/pkg/app/pipedv1/plugin/toolregistry/toolregistry.go +++ b/pkg/app/pipedv1/plugin/toolregistry/toolregistry.go @@ -24,10 +24,11 @@ type ToolRegistry struct { client service.PluginServiceClient } -func (r *ToolRegistry) InstallTool(ctx context.Context, name, version string) (path string, err error) { +func (r *ToolRegistry) InstallTool(ctx context.Context, name, version, script string) (path string, err error) { res, err := r.client.InstallTool(ctx, &service.InstallToolRequest{ - Name: name, - Version: version, + Name: name, + Version: version, + InstallScript: script, }) if err != nil { From ac9ed878a63940c155eb82fc5a64d65eccb82ab5 Mon Sep 17 00:00:00 2001 From: Tetsuya Kikuchi <97105818+t-kikuc@users.noreply.github.com> Date: Tue, 1 Oct 2024 13:54:37 +0900 Subject: [PATCH 32/84] Upgrade aws-sdk-go-v2 (#5241) * Generate v0.49.x docs Signed-off-by: t-kikuc * upgrade aws-sdk-go-v2 Signed-off-by: t-kikuc * upgrade aws-sdk-go-v2/**/* Signed-off-by: t-kikuc * fix compile errors caused by the breaking change Signed-off-by: t-kikuc * fix a test due to added RestartPolicy Signed-off-by: t-kikuc * fix: avoid panic when live taskdef does not exist Signed-off-by: t-kikuc * fix go.sum by `make update/go-deps` Signed-off-by: t-kikuc --------- Signed-off-by: t-kikuc --- go.mod | 44 ++++----- go.sum | 92 +++++++++---------- pkg/app/piped/platformprovider/ecs/client.go | 2 +- .../piped/platformprovider/ecs/diff_test.go | 2 +- pkg/filestore/s3/s3.go | 2 +- 5 files changed, 69 insertions(+), 73 deletions(-) diff --git a/go.mod b/go.mod index 9e89f672cb..0e09fd9963 100644 --- a/go.mod +++ b/go.mod @@ -10,14 +10,14 @@ require ( github.com/DataDog/datadog-api-client-go v1.0.0-beta.16 github.com/Masterminds/sprig/v3 v3.2.2 github.com/NYTimes/gziphandler v0.0.0-20170623195520-56545f4a5d46 - github.com/aws/aws-sdk-go-v2 v1.17.7 - github.com/aws/aws-sdk-go-v2/config v1.18.19 - github.com/aws/aws-sdk-go-v2/credentials v1.13.18 - github.com/aws/aws-sdk-go-v2/service/ecs v1.24.2 - github.com/aws/aws-sdk-go-v2/service/elasticloadbalancingv2 v1.19.7 - github.com/aws/aws-sdk-go-v2/service/lambda v1.30.2 - github.com/aws/aws-sdk-go-v2/service/s3 v1.31.0 - github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.18.0 + github.com/aws/aws-sdk-go-v2 v1.31.0 + github.com/aws/aws-sdk-go-v2/config v1.27.38 + github.com/aws/aws-sdk-go-v2/credentials v1.17.36 + github.com/aws/aws-sdk-go-v2/service/ecs v1.46.2 + github.com/aws/aws-sdk-go-v2/service/elasticloadbalancingv2 v1.38.2 + github.com/aws/aws-sdk-go-v2/service/lambda v1.62.0 + github.com/aws/aws-sdk-go-v2/service/s3 v1.63.2 + github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.33.2 github.com/coreos/go-oidc/v3 v3.11.0 github.com/creasty/defaults v1.6.0 github.com/envoyproxy/go-control-plane v0.12.0 @@ -88,20 +88,20 @@ require ( github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect github.com/agext/levenshtein v1.2.2 // indirect github.com/apparentlymart/go-textseg v1.0.0 // indirect - github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.10 // indirect - github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.13.1 // indirect - github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.31 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.25 // indirect - github.com/aws/aws-sdk-go-v2/internal/ini v1.3.32 // indirect - github.com/aws/aws-sdk-go-v2/internal/v4a v1.0.23 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.11 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.26 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.25 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.14.0 // indirect - github.com/aws/aws-sdk-go-v2/service/sso v1.12.6 // indirect - github.com/aws/aws-sdk-go-v2/service/ssooidc v1.14.6 // indirect - github.com/aws/aws-sdk-go-v2/service/sts v1.18.7 // indirect - github.com/aws/smithy-go v1.13.5 // indirect + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.5 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.14 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.18 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.18 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.18 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.5 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.3.20 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.20 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.17.18 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.23.2 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.27.2 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.31.2 // indirect + github.com/aws/smithy-go v1.21.0 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cespare/xxhash/v2 v2.2.0 // indirect diff --git a/go.sum b/go.sum index 41664e880e..f37c15fd84 100644 --- a/go.sum +++ b/go.sum @@ -113,53 +113,50 @@ github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5 github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/asaskevich/govalidator v0.0.0-20190424111038-f61b66f89f4a/go.mod h1:lB+ZfQJz7igIIfQNfa7Ml4HSf2uFQQRzpGGRXenZAgY= github.com/aslakhellesoy/gox v1.0.100/go.mod h1:AJl542QsKKG96COVsv0N74HHzVQgDIQPceVUh1aeU2M= -github.com/aws/aws-sdk-go-v2 v1.17.3/go.mod h1:uzbQtefpm44goOPmdKyAlXSNcwlRgF3ePWVW6EtJvvw= -github.com/aws/aws-sdk-go-v2 v1.17.7 h1:CLSjnhJSTSogvqUGhIC6LqFKATMRexcxLZ0i/Nzk9Eg= -github.com/aws/aws-sdk-go-v2 v1.17.7/go.mod h1:uzbQtefpm44goOPmdKyAlXSNcwlRgF3ePWVW6EtJvvw= -github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.10 h1:dK82zF6kkPeCo8J1e+tGx4JdvDIQzj7ygIoLg8WMuGs= -github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.10/go.mod h1:VeTZetY5KRJLuD/7fkQXMU6Mw7H5m/KP2J5Iy9osMno= -github.com/aws/aws-sdk-go-v2/config v1.18.19 h1:AqFK6zFNtq4i1EYu+eC7lcKHYnZagMn6SW171la0bGw= -github.com/aws/aws-sdk-go-v2/config v1.18.19/go.mod h1:XvTmGMY8d52ougvakOv1RpiTLPz9dlG/OQHsKU/cMmY= -github.com/aws/aws-sdk-go-v2/credentials v1.13.18 h1:EQMdtHwz0ILTW1hoP+EwuWhwCG1hD6l3+RWFQABET4c= -github.com/aws/aws-sdk-go-v2/credentials v1.13.18/go.mod h1:vnwlwjIe+3XJPBYKu1et30ZPABG3VaXJYr8ryohpIyM= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.13.1 h1:gt57MN3liKiyGopcqgNzJb2+d9MJaKT/q1OksHNXVE4= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.13.1/go.mod h1:lfUx8puBRdM5lVVMQlwt2v+ofiG/X6Ms+dy0UkG/kXw= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.27/go.mod h1:a1/UpzeyBBerajpnP5nGZa9mGzsBn5cOKxm6NWQsvoI= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.31 h1:sJLYcS+eZn5EeNINGHSCRAwUJMFVqklwkH36Vbyai7M= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.31/go.mod h1:QT0BqUvX1Bh2ABdTGnjqEjvjzrCfIniM9Sc8zn9Yndo= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.21/go.mod h1:+Gxn8jYn5k9ebfHEqlhrMirFjSW0v0C9fI+KN5vk2kE= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.25 h1:1mnRASEKnkqsntcxHaysxwgVoUUp5dkiB+l3llKnqyg= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.25/go.mod h1:zBHOPwhBc3FlQjQJE/D3IfPWiWaQmT06Vq9aNukDo0k= -github.com/aws/aws-sdk-go-v2/internal/ini v1.3.32 h1:p5luUImdIqywn6JpQsW3tq5GNOxKmOnEpybzPx+d1lk= -github.com/aws/aws-sdk-go-v2/internal/ini v1.3.32/go.mod h1:XGhIBZDEgfqmFIugclZ6FU7v75nHhBDtzuB4xB/tEi4= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.0.23 h1:DWYZIsyqagnWL00f8M/SOr9fN063OEQWn9LLTbdYXsk= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.0.23/go.mod h1:uIiFgURZbACBEQJfqTZPb/jxO7R+9LeoHUFudtIdeQI= -github.com/aws/aws-sdk-go-v2/service/ecs v1.24.2 h1:W94oEzOVUhefAqBtt33gOnsIEB0qFwK4akzhfD/eReI= -github.com/aws/aws-sdk-go-v2/service/ecs v1.24.2/go.mod h1:fMCHV5nbbpjoVHlKIcasH51tyDKha+ofZHVhQyXLRlI= -github.com/aws/aws-sdk-go-v2/service/elasticloadbalancingv2 v1.19.7 h1:XpIms0tmerNg/t6IiGrbKU6Au25CHyXqs8Yc3zOET5o= -github.com/aws/aws-sdk-go-v2/service/elasticloadbalancingv2 v1.19.7/go.mod h1:AE8U+Wj27eSDhWhAQp0BJlUi2vIqQ7ndd/e+Hnn+qus= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.11 h1:y2+VQzC6Zh2ojtV2LoC0MNwHWc6qXv/j2vrQtlftkdA= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.11/go.mod h1:iV4q2hsqtNECrfmlXyord9u4zyuFEJX9eLgLpSPzWA8= -github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.26 h1:CeuSeq/8FnYpPtnuIeLQEEvDv9zUjneuYi8EghMBdwQ= -github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.26/go.mod h1:2UqAAwMUXKeRkAHIlDJqvMVgOWkUi/AUXPk/YIe+Dg4= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.25 h1:5LHn8JQ0qvjD9L9JhMtylnkcw7j05GDZqM9Oin6hpr0= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.25/go.mod h1:/95IA+0lMnzW6XzqYJRpjjsAbKEORVeO0anQqjd2CNU= -github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.14.0 h1:e2ooMhpYGhDnBfSvIyusvAwX7KexuZaHbQY2Dyei7VU= -github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.14.0/go.mod h1:bh2E0CXKZsQN+faiKVqC40vfNMAWheoULBCnEgO9K+8= -github.com/aws/aws-sdk-go-v2/service/lambda v1.30.2 h1:JEUEgBM8HZ27ahhZsIlgfj7xPITxkRoHXdpW7lLzGB0= -github.com/aws/aws-sdk-go-v2/service/lambda v1.30.2/go.mod h1:PmNd6f36wPbp2+B3ZSuvHqqSwggfagEdI18tIb8s91o= -github.com/aws/aws-sdk-go-v2/service/s3 v1.31.0 h1:B1G2pSPvbAtQjilPq+Y7jLIzCOwKzuVEl+aBBaNG0AQ= -github.com/aws/aws-sdk-go-v2/service/s3 v1.31.0/go.mod h1:ncltU6n4Nof5uJttDtcNQ537uNuwYqsZZQcpkd2/GUQ= -github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.18.0 h1:UQDiRZyaHQGPXIuCYqKsz/wIVZknCiZdRmPW8buD/xc= -github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.18.0/go.mod h1:jAeo/PdIJZuDSwsvxJS94G4d6h8tStj7WXVuKwLHWU8= -github.com/aws/aws-sdk-go-v2/service/sso v1.12.6 h1:5V7DWLBd7wTELVz5bPpwzYy/sikk0gsgZfj40X+l5OI= -github.com/aws/aws-sdk-go-v2/service/sso v1.12.6/go.mod h1:Y1VOmit/Fn6Tz1uFAeCO6Q7M2fmfXSCLeL5INVYsLuY= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.14.6 h1:B8cauxOH1W1v7rd8RdI/MWnoR4Ze0wIHWrb90qczxj4= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.14.6/go.mod h1:Lh/bc9XUf8CfOY6Jp5aIkQtN+j1mc+nExc+KXj9jx2s= -github.com/aws/aws-sdk-go-v2/service/sts v1.18.7 h1:bWNgNdRko2x6gqa0blfATqAZKZokPIeM1vfmQt2pnvM= -github.com/aws/aws-sdk-go-v2/service/sts v1.18.7/go.mod h1:JuTnSoeePXmMVe9G8NcjjwgOKEfZ4cOjMuT2IBT/2eI= -github.com/aws/smithy-go v1.13.5 h1:hgz0X/DX0dGqTYpGALqXJoRKRj5oQ7150i5FdTePzO8= -github.com/aws/smithy-go v1.13.5/go.mod h1:Tg+OJXh4MB2R/uN61Ko2f6hTZwB/ZYGOtib8J3gBHzA= +github.com/aws/aws-sdk-go-v2 v1.31.0 h1:3V05LbxTSItI5kUqNwhJrrrY1BAXxXt0sN0l72QmG5U= +github.com/aws/aws-sdk-go-v2 v1.31.0/go.mod h1:ztolYtaEUtdpf9Wftr31CJfLVjOnD/CVRkKOOYgF8hA= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.5 h1:xDAuZTn4IMm8o1LnBZvmrL8JA1io4o3YWNXgohbf20g= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.5/go.mod h1:wYSv6iDS621sEFLfKvpPE2ugjTuGlAG7iROg0hLOkfc= +github.com/aws/aws-sdk-go-v2/config v1.27.38 h1:mMVyJJuSUdbD4zKXoxDgWrgM60QwlFEg+JhihCq6wCw= +github.com/aws/aws-sdk-go-v2/config v1.27.38/go.mod h1:6xOiNEn58bj/64MPKx89r6G/el9JZn8pvVbquSqTKK4= +github.com/aws/aws-sdk-go-v2/credentials v1.17.36 h1:zwI5WrT+oWWfzSKoTNmSyeBKQhsFRJRv+PGW/UZW+Yk= +github.com/aws/aws-sdk-go-v2/credentials v1.17.36/go.mod h1:3AG/sY1rc9NJrNWcN/3KPU4SIDPGTrd/qegKB0TnFdE= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.14 h1:C/d03NAmh8C4BZXhuRNboF/DqhBkBCeDiJDcaqIT5pA= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.14/go.mod h1:7I0Ju7p9mCIdlrfS+JCgqcYD0VXz/N4yozsox+0o078= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.18 h1:kYQ3H1u0ANr9KEKlGs/jTLrBFPo8P8NaH/w7A01NeeM= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.18/go.mod h1:r506HmK5JDUh9+Mw4CfGJGSSoqIiLCndAuqXuhbv67Y= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.18 h1:Z7IdFUONvTcvS7YuhtVxN99v2cCoHRXOS4mTr0B/pUc= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.18/go.mod h1:DkKMmksZVVyat+Y+r1dEOgJEfUeA7UngIHWeKsi0yNc= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1 h1:VaRN3TlFdd6KxX1x3ILT5ynH6HvKgqdiXoTxAF4HQcQ= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1/go.mod h1:FbtygfRFze9usAadmnGJNc8KsP346kEe+y2/oyhGAGc= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.18 h1:OWYvKL53l1rbsUmW7bQyJVsYU/Ii3bbAAQIIFNbM0Tk= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.18/go.mod h1:CUx0G1v3wG6l01tUB+j7Y8kclA8NSqK4ef0YG79a4cg= +github.com/aws/aws-sdk-go-v2/service/ecs v1.46.2 h1:mC8vCpzGYi87z5Ot+LcIU7rpabkX88os9ZvtelIhHu0= +github.com/aws/aws-sdk-go-v2/service/ecs v1.46.2/go.mod h1:/IMvyX4u5s4Ed0kzD+vWdPK92zm/q4CN1afJeDCsdhE= +github.com/aws/aws-sdk-go-v2/service/elasticloadbalancingv2 v1.38.2 h1:0pVeGkp7MqM3k3Il75hA6xI2USdkjaUv58SXJwvFIGY= +github.com/aws/aws-sdk-go-v2/service/elasticloadbalancingv2 v1.38.2/go.mod h1:V/sx2Ja18AlrvTGQsilx8CAH0CPm+hpKdT9RbSpceik= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.5 h1:QFASJGfT8wMXtuP3D5CRmMjARHv9ZmzFUMJznHDOY3w= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.5/go.mod h1:QdZ3OmoIjSX+8D1OPAzPxDfjXASbBMDsz9qvtyIhtik= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.3.20 h1:rTWjG6AvWekO2B1LHeM3ktU7MqyX9rzWQ7hgzneZW7E= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.3.20/go.mod h1:RGW2DDpVc8hu6Y6yG8G5CHVmVOAn1oV8rNKOHRJyswg= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.20 h1:Xbwbmk44URTiHNx6PNo0ujDE6ERlsCKJD3u1zfnzAPg= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.20/go.mod h1:oAfOFzUB14ltPZj1rWwRc3d/6OgD76R8KlvU3EqM9Fg= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.17.18 h1:eb+tFOIl9ZsUe2259/BKPeniKuz4/02zZFH/i4Nf8Rg= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.17.18/go.mod h1:GVCC2IJNJTmdlyEsSmofEy7EfJncP7DNnXDzRjJ5Keg= +github.com/aws/aws-sdk-go-v2/service/lambda v1.62.0 h1:k0f2zx3Rt8Hw0iIXMyZN6Y/JCboKKiL6oSHPbVB0rIw= +github.com/aws/aws-sdk-go-v2/service/lambda v1.62.0/go.mod h1:mivSaHqW3Atf5TDU1YyujR+HMv+snxCMoYaVd9d30O4= +github.com/aws/aws-sdk-go-v2/service/s3 v1.63.2 h1:1iXmXy8SJzQVMGvo40TSzBYS9ig6BSyXfRIMzLfmBfE= +github.com/aws/aws-sdk-go-v2/service/s3 v1.63.2/go.mod h1:NLTqRLe3pUNu3nTEHI6XlHLKYmc8fbHUdMxAB6+s41Q= +github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.33.2 h1:C79sbcbdKuBpBpTDy1MNrJx5/Wii7gcwt0Jkd5QCGNA= +github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.33.2/go.mod h1:WyLS5qwXHtjKAONYZq/4ewdd+hcVsa3LBu77Ow5uj3k= +github.com/aws/aws-sdk-go-v2/service/sso v1.23.2 h1:yzi/y/vKlLyzOfG7pSu5ONNGRxHIgLeDrV4w2AMRCo0= +github.com/aws/aws-sdk-go-v2/service/sso v1.23.2/go.mod h1:XRlMvmad0ZNL+75C5FYdMvbbLkd6qiqz6foR1nA1PXY= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.27.2 h1:3gb6pYhYLjo8rB1h2Tqs61wpjRd3rQymYcVq/pp0yxI= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.27.2/go.mod h1:FnvDM4sfa+isJ3kDXIzAB9GAwVSzFzSy97uZ3IsHo4E= +github.com/aws/aws-sdk-go-v2/service/sts v1.31.2 h1:O6tyji8mXmBGsHvTCB0VIhrDw19lGTUSbKIyjnw79s8= +github.com/aws/aws-sdk-go-v2/service/sts v1.31.2/go.mod h1:yMWe0F+XG0DkRZK5ODZhG7BEFYhLXi2dqGsv6tX0cgI= +github.com/aws/smithy-go v1.21.0 h1:H7L8dtDRk0P1Qm6y0ji7MCYMQObJ5R9CRpyPhRUkLYA= +github.com/aws/smithy-go v1.21.0/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= @@ -374,7 +371,6 @@ github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE= -github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-github/v29 v29.0.3 h1:IktKCTwU//aFHnpA+2SLIi7Oo9uhAzgsdZNbcAqhgdc= diff --git a/pkg/app/piped/platformprovider/ecs/client.go b/pkg/app/piped/platformprovider/ecs/client.go index 8a023beb95..8b61d1bcca 100644 --- a/pkg/app/piped/platformprovider/ecs/client.go +++ b/pkg/app/piped/platformprovider/ecs/client.go @@ -505,7 +505,7 @@ func (c *client) ModifyListeners(ctx context.Context, listenerArns []string, rou } // The default rule needs to be modified by ModifyListener API. - if rule.IsDefault { + if aws.ToBool(rule.IsDefault) { _, err := c.elbClient.ModifyListener(ctx, &elasticloadbalancingv2.ModifyListenerInput{ ListenerArn: &listenerArn, DefaultActions: modifiedActions, diff --git a/pkg/app/piped/platformprovider/ecs/diff_test.go b/pkg/app/piped/platformprovider/ecs/diff_test.go index d78f661e8d..b108f6e00b 100644 --- a/pkg/app/piped/platformprovider/ecs/diff_test.go +++ b/pkg/app/piped/platformprovider/ecs/diff_test.go @@ -102,7 +102,7 @@ func TestDiffResult_Render(t *testing.T) { Events: null # 2. TaskDefinition -@@ -17,7 +17,7 @@ +@@ -18,7 +18,7 @@ FirelensConfiguration: null HealthCheck: null Hostname: null diff --git a/pkg/filestore/s3/s3.go b/pkg/filestore/s3/s3.go index 711307e0a4..14ecd4c95f 100644 --- a/pkg/filestore/s3/s3.go +++ b/pkg/filestore/s3/s3.go @@ -183,7 +183,7 @@ func (s *Store) List(ctx context.Context, prefix string) ([]filestore.ObjectAttr for _, obj := range page.Contents { objects = append(objects, filestore.ObjectAttrs{ Path: aws.ToString(obj.Key), - Size: obj.Size, + Size: aws.ToInt64(obj.Size), Etag: aws.ToString(obj.ETag), UpdatedAt: aws.ToTime(obj.LastModified).Unix(), }) From 552173b02e325cff3c426edf150647d095f76edd Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 1 Oct 2024 14:23:17 +0900 Subject: [PATCH 33/84] [bot] Update contributors (#5245) Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: t-kikuc <97105818+t-kikuc@users.noreply.github.com> --- README.md | 36 ++++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index d2cc03f129..156cf741d2 100644 --- a/README.md +++ b/README.md @@ -87,25 +87,25 @@ You can find a list of publicly recognized users of the PipeCD in the [ADOPTERS. - + + - - + - + @@ -119,34 +119,35 @@ You can find a list of publicly recognized users of the PipeCD in the [ADOPTERS. + - - - - - - - - - - + - + + + + + + + + + + - + @@ -164,11 +165,10 @@ You can find a list of publicly recognized users of the PipeCD in the [ADOPTERS. - + - # From 44cfc932b2061f4cfbdbd134c912543919b1119d Mon Sep 17 00:00:00 2001 From: Shinnosuke Sawada-Dazai Date: Tue, 1 Oct 2024 15:01:21 +0900 Subject: [PATCH 34/84] Add test for pipedv1's buildPlan method, and remove platform specific config from configv1's GenericApplicationConfig (#5238) * Add test for buildPlan Signed-off-by: Shinnosuke Sawada-Dazai * Fix test input Signed-off-by: Shinnosuke Sawada-Dazai * Remove platform-specific fields from GenericApplicationSpec Signed-off-by: Shinnosuke Sawada-Dazai * Remove broken k8s-specific test from configv1 Signed-off-by: Shinnosuke Sawada-Dazai * Fix the test for pipedv1 buildPlan Signed-off-by: Shinnosuke Sawada-Dazai * Remove mistakenly commited changes Signed-off-by: Shinnosuke Sawada-Dazai * Remove stage-specific tests that are broken Signed-off-by: Shinnosuke Sawada-Dazai * Move parsing GenericApplicationSpec to configv1 Signed-off-by: Shinnosuke Sawada-Dazai * Add KindApplication and remove ParseApplication Signed-off-by: Shinnosuke Sawada-Dazai --------- Signed-off-by: Shinnosuke Sawada-Dazai --- pkg/app/pipedv1/controller/planner.go | 83 +++-- pkg/app/pipedv1/controller/planner_test.go | 374 +++++++++++++++++++- pkg/configv1/application.go | 203 ----------- pkg/configv1/application_kubernetes_test.go | 195 ---------- pkg/configv1/application_lambda_test.go | 181 ---------- pkg/configv1/application_terraform_test.go | 263 -------------- pkg/configv1/application_test.go | 225 ------------ pkg/configv1/config.go | 11 + 8 files changed, 427 insertions(+), 1108 deletions(-) delete mode 100644 pkg/configv1/application_kubernetes_test.go delete mode 100644 pkg/configv1/application_lambda_test.go delete mode 100644 pkg/configv1/application_terraform_test.go diff --git a/pkg/app/pipedv1/controller/planner.go b/pkg/app/pipedv1/controller/planner.go index fe079f7fc8..276a2124d6 100644 --- a/pkg/app/pipedv1/controller/planner.go +++ b/pkg/app/pipedv1/controller/planner.go @@ -18,8 +18,6 @@ import ( "context" "encoding/json" "fmt" - "io" - "path/filepath" "sort" "time" @@ -30,10 +28,9 @@ import ( "go.uber.org/zap" "github.com/pipe-cd/pipecd/pkg/app/pipedv1/controller/controllermetrics" - "github.com/pipe-cd/pipecd/pkg/app/pipedv1/deploysource" "github.com/pipe-cd/pipecd/pkg/app/pipedv1/metadatastore" "github.com/pipe-cd/pipecd/pkg/app/server/service/pipedservice" - "github.com/pipe-cd/pipecd/pkg/config" + "github.com/pipe-cd/pipecd/pkg/configv1" "github.com/pipe-cd/pipecd/pkg/model" pluginapi "github.com/pipe-cd/pipecd/pkg/plugin/api/v1alpha1" "github.com/pipe-cd/pipecd/pkg/plugin/api/v1alpha1/deployment" @@ -192,27 +189,30 @@ func (p *planner) Run(ctx context.Context) error { controllermetrics.UpdateDeploymentStatus(p.deployment, p.doneDeploymentStatus) }() - repoCfg := config.PipedRepository{ - RepoID: p.deployment.GitPath.Repo.Id, - Remote: p.deployment.GitPath.Repo.Remote, - Branch: p.deployment.GitPath.Repo.Branch, - } + // TODO: Prepare running deploy source and target deploy source. + var runningDS, targetDS *model.DeploymentSource - // Prepare target deploy source. - targetDSP := deploysource.NewProvider( - filepath.Join(p.workingDir, "deploysource"), - deploysource.NewGitSourceCloner(p.gitClient, repoCfg, "target", p.deployment.Trigger.Commit.Hash), - *p.deployment.GitPath, - nil, // TODO: Revise this secret decryter, is this need? - ) + // repoCfg := config.PipedRepository{ + // RepoID: p.deployment.GitPath.Repo.Id, + // Remote: p.deployment.GitPath.Repo.Remote, + // Branch: p.deployment.GitPath.Repo.Branch, + // } - targetDS, err := targetDSP.Get(ctx, io.Discard) - if err != nil { - return fmt.Errorf("error while preparing deploy source data (%v)", err) - } + // Prepare target deploy source. + // targetDSP := deploysource.NewProvider( + // filepath.Join(p.workingDir, "deploysource"), + // deploysource.NewGitSourceCloner(p.gitClient, repoCfg, "target", p.deployment.Trigger.Commit.Hash), + // *p.deployment.GitPath, + // nil, // TODO: Revise this secret decryter, is this need? + // ) + + // targetDS, err := targetDSP.Get(ctx, io.Discard) + // if err != nil { + // return fmt.Errorf("error while preparing deploy source data (%v)", err) + // } // TODO: Pass running DS as well if need? - out, err := p.buildPlan(ctx, targetDS) + out, err := p.buildPlan(ctx, runningDS, targetDS) // If the deployment was already cancelled, we ignore the plan result. select { @@ -243,13 +243,15 @@ func (p *planner) Run(ctx context.Context) error { // - CommitMatcher ensure pipeline/quick sync based on the commit message // - Force quick sync if there is no previous deployment (aka. this is the first deploy) // - Based on PlannerService.DetermineStrategy returned by plugins -func (p *planner) buildPlan(ctx context.Context, targetDS *deploysource.DeploySource) (*plannerOutput, error) { +func (p *planner) buildPlan(ctx context.Context, runningDS, targetDS *model.DeploymentSource) (*plannerOutput, error) { out := &plannerOutput{} input := &deployment.PlanPluginInput{ - Deployment: p.deployment, + Deployment: p.deployment, + RunningDeploymentSource: runningDS, + TargetDeploymentSource: targetDS, // TODO: Add more planner input fields. - // NOTE: As discussed we pass targetDS & runningDS here. + // we need passing PluginConfig } // Build deployment target versions. @@ -270,20 +272,25 @@ func (p *planner) buildPlan(ctx context.Context, targetDS *deploysource.DeploySo } } - cfg := targetDS.GenericApplicationConfig + cfg, err := config.DecodeYAML(targetDS.GetApplicationConfig()) + if err != nil { + p.logger.Error("unable to parse application config", zap.Error(err)) + return nil, err + } + spec := cfg.ApplicationSpec // In case the strategy has been decided by trigger. // For example: user triggered the deployment via web console. switch p.deployment.Trigger.SyncStrategy { case model.SyncStrategy_QUICK_SYNC: - if stages, err := p.buildQuickSyncStages(ctx, cfg); err == nil { + if stages, err := p.buildQuickSyncStages(ctx, spec); err == nil { out.SyncStrategy = model.SyncStrategy_QUICK_SYNC out.Summary = p.deployment.Trigger.StrategySummary out.Stages = stages return out, nil } case model.SyncStrategy_PIPELINE: - if stages, err := p.buildPipelineSyncStages(ctx, cfg); err == nil { + if stages, err := p.buildPipelineSyncStages(ctx, spec); err == nil { out.SyncStrategy = model.SyncStrategy_PIPELINE out.Summary = p.deployment.Trigger.StrategySummary out.Stages = stages @@ -292,8 +299,8 @@ func (p *planner) buildPlan(ctx context.Context, targetDS *deploysource.DeploySo } // When no pipeline was configured, do the quick sync. - if cfg.Pipeline == nil || len(cfg.Pipeline.Stages) == 0 { - if stages, err := p.buildQuickSyncStages(ctx, cfg); err == nil { + if spec.Pipeline == nil || len(spec.Pipeline.Stages) == 0 { + if stages, err := p.buildQuickSyncStages(ctx, spec); err == nil { out.SyncStrategy = model.SyncStrategy_QUICK_SYNC out.Summary = "Quick sync due to the pipeline was not configured" out.Stages = stages @@ -302,8 +309,8 @@ func (p *planner) buildPlan(ctx context.Context, targetDS *deploysource.DeploySo } // Force to use pipeline when the `spec.planner.alwaysUsePipeline` was configured. - if cfg.Planner.AlwaysUsePipeline { - if stages, err := p.buildPipelineSyncStages(ctx, cfg); err == nil { + if spec.Planner.AlwaysUsePipeline { + if stages, err := p.buildPipelineSyncStages(ctx, spec); err == nil { out.SyncStrategy = model.SyncStrategy_PIPELINE out.Summary = "Sync with the specified pipeline (alwaysUsePipeline was set)" out.Stages = stages @@ -315,10 +322,10 @@ func (p *planner) buildPlan(ctx context.Context, targetDS *deploysource.DeploySo // This deployment is triggered by a commit with the intent to perform pipeline. // Commit Matcher will be ignored when triggered by a command. - if pattern := cfg.CommitMatcher.Pipeline; pattern != "" && p.deployment.Trigger.Commander == "" { + if pattern := spec.CommitMatcher.Pipeline; pattern != "" && p.deployment.Trigger.Commander == "" { if pipelineRegex, err := regexPool.Get(pattern); err == nil && pipelineRegex.MatchString(p.deployment.Trigger.Commit.Message) { - if stages, err := p.buildPipelineSyncStages(ctx, cfg); err == nil { + if stages, err := p.buildPipelineSyncStages(ctx, spec); err == nil { out.SyncStrategy = model.SyncStrategy_PIPELINE out.Summary = fmt.Sprintf("Sync progressively because the commit message was matching %q", pattern) out.Stages = stages @@ -329,10 +336,10 @@ func (p *planner) buildPlan(ctx context.Context, targetDS *deploysource.DeploySo // This deployment is triggered by a commit with the intent to synchronize. // Commit Matcher will be ignored when triggered by a command. - if pattern := cfg.CommitMatcher.QuickSync; pattern != "" && p.deployment.Trigger.Commander == "" { + if pattern := spec.CommitMatcher.QuickSync; pattern != "" && p.deployment.Trigger.Commander == "" { if syncRegex, err := regexPool.Get(pattern); err == nil && syncRegex.MatchString(p.deployment.Trigger.Commit.Message) { - if stages, err := p.buildQuickSyncStages(ctx, cfg); err == nil { + if stages, err := p.buildQuickSyncStages(ctx, spec); err == nil { out.SyncStrategy = model.SyncStrategy_QUICK_SYNC out.Summary = fmt.Sprintf("Quick sync because the commit message was matching %q", pattern) out.Stages = stages @@ -343,7 +350,7 @@ func (p *planner) buildPlan(ctx context.Context, targetDS *deploysource.DeploySo // Quick sync if this is the first time to deploy this application or it was unable to retrieve running commit hash. if p.lastSuccessfulCommitHash == "" { - if stages, err := p.buildQuickSyncStages(ctx, cfg); err == nil { + if stages, err := p.buildQuickSyncStages(ctx, spec); err == nil { out.SyncStrategy = model.SyncStrategy_QUICK_SYNC out.Summary = "Quick sync, it seems this is the first deployment of the application" out.Stages = stages @@ -372,14 +379,14 @@ func (p *planner) buildPlan(ctx context.Context, targetDS *deploysource.DeploySo switch strategy { case model.SyncStrategy_QUICK_SYNC: - if stages, err := p.buildQuickSyncStages(ctx, cfg); err == nil { + if stages, err := p.buildQuickSyncStages(ctx, spec); err == nil { out.SyncStrategy = model.SyncStrategy_QUICK_SYNC out.Summary = summary out.Stages = stages return out, nil } case model.SyncStrategy_PIPELINE: - if stages, err := p.buildPipelineSyncStages(ctx, cfg); err == nil { + if stages, err := p.buildPipelineSyncStages(ctx, spec); err == nil { out.SyncStrategy = model.SyncStrategy_PIPELINE out.Summary = summary out.Stages = stages diff --git a/pkg/app/pipedv1/controller/planner_test.go b/pkg/app/pipedv1/controller/planner_test.go index 8b0bdc9966..be4ad3cf78 100644 --- a/pkg/app/pipedv1/controller/planner_test.go +++ b/pkg/app/pipedv1/controller/planner_test.go @@ -16,13 +16,16 @@ package controller import ( "context" + "encoding/json" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "go.uber.org/zap" "google.golang.org/grpc" - "github.com/pipe-cd/pipecd/pkg/config" + "github.com/pipe-cd/pipecd/pkg/configv1" "github.com/pipe-cd/pipecd/pkg/model" pluginapi "github.com/pipe-cd/pipecd/pkg/plugin/api/v1alpha1" "github.com/pipe-cd/pipecd/pkg/plugin/api/v1alpha1/deployment" @@ -30,6 +33,7 @@ import ( type fakePlugin struct { pluginapi.PluginClient + syncStrategy *deployment.DetermineStrategyResponse quickStages []*model.PipelineStage pipelineStages []*model.PipelineStage rollbackStages []*model.PipelineStage @@ -70,10 +74,12 @@ func (p *fakePlugin) BuildPipelineSyncStages(ctx context.Context, req *deploymen }, nil } func (p *fakePlugin) DetermineStrategy(ctx context.Context, req *deployment.DetermineStrategyRequest, opts ...grpc.CallOption) (*deployment.DetermineStrategyResponse, error) { - return nil, nil + return p.syncStrategy, nil } func (p *fakePlugin) DetermineVersions(ctx context.Context, req *deployment.DetermineVersionsRequest, opts ...grpc.CallOption) (*deployment.DetermineVersionsResponse, error) { - return nil, nil + return &deployment.DetermineVersionsResponse{ + Versions: []*model.ArtifactVersion{}, + }, nil } func (p *fakePlugin) FetchDefinedStages(ctx context.Context, req *deployment.FetchDefinedStagesRequest, opts ...grpc.CallOption) (*deployment.FetchDefinedStagesResponse, error) { stages := make([]string, 0, len(p.quickStages)+len(p.pipelineStages)+len(p.rollbackStages)) @@ -576,3 +582,365 @@ func TestBuildPipelineSyncStages(t *testing.T) { }) } } + +func TestPlanner_BuildPlan(t *testing.T) { + t.Parallel() + + testcases := []struct { + name string + isFirstDeploy bool + plugins []pluginapi.PluginClient + cfg *config.GenericApplicationSpec + deployment *model.Deployment + wantErr bool + expectedOutput *plannerOutput + }{ + { + name: "quick sync strategy triggered by web console", + isFirstDeploy: false, + plugins: []pluginapi.PluginClient{ + &fakePlugin{ + quickStages: []*model.PipelineStage{ + { + Id: "plugin-1-stage-1", + Visible: true, + }, + }, + }, + }, + cfg: &config.GenericApplicationSpec{ + Planner: config.DeploymentPlanner{ + AutoRollback: pointerBool(true), + }, + }, + deployment: &model.Deployment{ + Trigger: &model.DeploymentTrigger{ + SyncStrategy: model.SyncStrategy_QUICK_SYNC, + StrategySummary: "Triggered by web console", + }, + }, + wantErr: false, + expectedOutput: &plannerOutput{ + SyncStrategy: model.SyncStrategy_QUICK_SYNC, + Summary: "Triggered by web console", + Stages: []*model.PipelineStage{ + { + Id: "plugin-1-stage-1", + Visible: true, + }, + }, + Versions: []*model.ArtifactVersion{ + { + Kind: model.ArtifactVersion_UNKNOWN, + Version: versionUnknown, + }, + }, + }, + }, + { + name: "pipeline sync strategy triggered by web console", + isFirstDeploy: false, + plugins: []pluginapi.PluginClient{ + &fakePlugin{ + pipelineStages: []*model.PipelineStage{ + { + Id: "plugin-1-stage-1", + Name: "plugin-1-stage-1", + Visible: true, + }, + }, + }, + }, + cfg: &config.GenericApplicationSpec{ + Planner: config.DeploymentPlanner{ + AutoRollback: pointerBool(true), + }, + Pipeline: &config.DeploymentPipeline{ + Stages: []config.PipelineStage{ + { + ID: "plugin-1-stage-1", + Name: "plugin-1-stage-1", + }, + }, + }, + }, + deployment: &model.Deployment{ + Trigger: &model.DeploymentTrigger{ + SyncStrategy: model.SyncStrategy_PIPELINE, + StrategySummary: "Triggered by web console", + }, + }, + wantErr: false, + expectedOutput: &plannerOutput{ + SyncStrategy: model.SyncStrategy_PIPELINE, + Summary: "Triggered by web console", + Stages: []*model.PipelineStage{ + { + Id: "plugin-1-stage-1", + Name: "plugin-1-stage-1", + Index: 0, + Visible: true, + }, + }, + Versions: []*model.ArtifactVersion{ + { + Kind: model.ArtifactVersion_UNKNOWN, + Version: versionUnknown, + }, + }, + }, + }, + { + name: "quick sync due to no pipeline configured", + isFirstDeploy: false, + plugins: []pluginapi.PluginClient{ + &fakePlugin{ + quickStages: []*model.PipelineStage{ + { + Id: "plugin-1-stage-1", + Visible: true, + }, + }, + }, + }, + cfg: &config.GenericApplicationSpec{ + Planner: config.DeploymentPlanner{ + AutoRollback: pointerBool(true), + }, + }, + deployment: &model.Deployment{ + Trigger: &model.DeploymentTrigger{}, + }, + wantErr: false, + expectedOutput: &plannerOutput{ + SyncStrategy: model.SyncStrategy_QUICK_SYNC, + Summary: "Quick sync due to the pipeline was not configured", + Stages: []*model.PipelineStage{ + { + Id: "plugin-1-stage-1", + Visible: true, + }, + }, + Versions: []*model.ArtifactVersion{ + { + Kind: model.ArtifactVersion_UNKNOWN, + Version: versionUnknown, + }, + }, + }, + }, + { + name: "pipeline sync due to alwaysUsePipeline", + isFirstDeploy: false, + plugins: []pluginapi.PluginClient{ + &fakePlugin{ + pipelineStages: []*model.PipelineStage{ + { + Id: "plugin-1-stage-1", + Name: "plugin-1-stage-1", + Visible: true, + }, + }, + }, + }, + cfg: &config.GenericApplicationSpec{ + Planner: config.DeploymentPlanner{ + AlwaysUsePipeline: true, + AutoRollback: pointerBool(true), + }, + Pipeline: &config.DeploymentPipeline{ + Stages: []config.PipelineStage{ + { + ID: "plugin-1-stage-1", + Name: "plugin-1-stage-1", + }, + }, + }, + }, + deployment: &model.Deployment{ + Trigger: &model.DeploymentTrigger{}, + }, + wantErr: false, + expectedOutput: &plannerOutput{ + SyncStrategy: model.SyncStrategy_PIPELINE, + Summary: "Sync with the specified pipeline (alwaysUsePipeline was set)", + Stages: []*model.PipelineStage{ + { + Id: "plugin-1-stage-1", + Name: "plugin-1-stage-1", + Index: 0, + Visible: true, + }, + }, + Versions: []*model.ArtifactVersion{ + { + Kind: model.ArtifactVersion_UNKNOWN, + Version: versionUnknown, + }, + }, + }, + }, + { + name: "quick sync due to first deployment", + isFirstDeploy: true, + plugins: []pluginapi.PluginClient{ + &fakePlugin{ + quickStages: []*model.PipelineStage{ + { + Id: "plugin-1-stage-1", + Visible: true, + }, + }, + }, + }, + cfg: &config.GenericApplicationSpec{ + Planner: config.DeploymentPlanner{ + AutoRollback: pointerBool(true), + }, + Pipeline: &config.DeploymentPipeline{ + Stages: []config.PipelineStage{ + { + ID: "plugin-1-stage-1", + Name: "plugin-1-stage-1", + }, + }, + }, + }, + deployment: &model.Deployment{ + Trigger: &model.DeploymentTrigger{}, + }, + wantErr: false, + expectedOutput: &plannerOutput{ + SyncStrategy: model.SyncStrategy_QUICK_SYNC, + Summary: "Quick sync, it seems this is the first deployment of the application", + Stages: []*model.PipelineStage{ + { + Id: "plugin-1-stage-1", + Visible: true, + }, + }, + Versions: []*model.ArtifactVersion{ + { + Kind: model.ArtifactVersion_UNKNOWN, + Version: versionUnknown, + }, + }, + }, + }, + { + name: "pipeline sync determined by plugin", + isFirstDeploy: false, + plugins: []pluginapi.PluginClient{ + &fakePlugin{ + syncStrategy: &deployment.DetermineStrategyResponse{ + SyncStrategy: model.SyncStrategy_PIPELINE, + Summary: "determined by plugin", + }, + pipelineStages: []*model.PipelineStage{ + { + Id: "plugin-1-stage-1", + Name: "plugin-1-stage-1", + Visible: true, + }, + }, + quickStages: []*model.PipelineStage{ + { + Id: "plugin-1-quick-stage-1", + Visible: true, + }, + }, + }, + }, + cfg: &config.GenericApplicationSpec{ + Planner: config.DeploymentPlanner{ + AutoRollback: pointerBool(true), + }, + Pipeline: &config.DeploymentPipeline{ + Stages: []config.PipelineStage{ + { + ID: "plugin-1-stage-1", + Name: "plugin-1-stage-1", + }, + }, + }, + }, + deployment: &model.Deployment{ + Trigger: &model.DeploymentTrigger{}, + }, + wantErr: false, + expectedOutput: &plannerOutput{ + SyncStrategy: model.SyncStrategy_PIPELINE, + Summary: "determined by plugin", + Stages: []*model.PipelineStage{ + { + Id: "plugin-1-stage-1", + Name: "plugin-1-stage-1", + Index: 0, + Visible: true, + }, + }, + Versions: []*model.ArtifactVersion{ + { + Kind: model.ArtifactVersion_UNKNOWN, + Version: versionUnknown, + }, + }, + }, + }, + } + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + stageBasedPluginMap := make(map[string]pluginapi.PluginClient) + for _, p := range tc.plugins { + stages, _ := p.FetchDefinedStages(context.TODO(), &deployment.FetchDefinedStagesRequest{}) + for _, s := range stages.Stages { + stageBasedPluginMap[s] = p + } + } + planner := &planner{ + plugins: tc.plugins, + stageBasedPluginsMap: stageBasedPluginMap, + deployment: tc.deployment, + lastSuccessfulCommitHash: "", + lastSuccessfulConfigFilename: "", + workingDir: "", + apiClient: nil, + gitClient: nil, + notifier: nil, + logger: zap.NewNop(), + nowFunc: func() time.Time { return time.Now() }, + } + + if !tc.isFirstDeploy { + planner.lastSuccessfulCommitHash = "123" + } + + runningDS := &model.DeploymentSource{} + + type genericConfig struct { + Kind config.Kind `json:"kind"` + APIVersion string `json:"apiVersion,omitempty"` + Spec any `json:"spec"` + } + + jsonBytes, err := json.Marshal(genericConfig{ + Kind: config.KindApplication, + APIVersion: config.VersionV1Beta1, + Spec: tc.cfg, + }) + + require.NoError(t, err) + targetDS := &model.DeploymentSource{ + ApplicationConfig: jsonBytes, + } + out, err := planner.buildPlan(context.TODO(), runningDS, targetDS) + if tc.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + assert.Equal(t, tc.expectedOutput, out) + }) + } +} diff --git a/pkg/configv1/application.go b/pkg/configv1/application.go index 3492219e5a..a2c49495b1 100644 --- a/pkg/configv1/application.go +++ b/pkg/configv1/application.go @@ -122,26 +122,6 @@ type OnChain struct { } 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 @@ -219,44 +199,6 @@ type DeploymentPipeline struct { // 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"` @@ -264,151 +206,6 @@ type genericPipelineStage struct { 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"` diff --git a/pkg/configv1/application_kubernetes_test.go b/pkg/configv1/application_kubernetes_test.go deleted file mode 100644 index 54b9f39d65..0000000000 --- a/pkg/configv1/application_kubernetes_test.go +++ /dev/null @@ -1,195 +0,0 @@ -// 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_test.go b/pkg/configv1/application_lambda_test.go deleted file mode 100644 index eb3299865b..0000000000 --- a/pkg/configv1/application_lambda_test.go +++ /dev/null @@ -1,181 +0,0 @@ -// 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_test.go b/pkg/configv1/application_terraform_test.go deleted file mode 100644 index 6018a4c70b..0000000000 --- a/pkg/configv1/application_terraform_test.go +++ /dev/null @@ -1,263 +0,0 @@ -// 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 index b97bfaba39..1ab3e1afaa 100644 --- a/pkg/configv1/application_test.go +++ b/pkg/configv1/application_test.go @@ -15,8 +15,6 @@ package config import ( - "encoding/json" - "fmt" "testing" "time" @@ -76,34 +74,6 @@ func TestHasStage(t *testing.T) { } } -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 @@ -647,201 +617,6 @@ func TestGenericPostSyncConfiguration(t *testing.T) { } } -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 diff --git a/pkg/configv1/config.go b/pkg/configv1/config.go index 495330b7a7..5fe663744a 100644 --- a/pkg/configv1/config.go +++ b/pkg/configv1/config.go @@ -49,6 +49,8 @@ const ( KindCloudRunApp Kind = "CloudRunApp" // KindECSApp represents application configuration for an AWS ECS. KindECSApp Kind = "ECSApp" + // KindApplication represents a generic application configuration. + KindApplication Kind = "Application" ) const ( @@ -76,6 +78,9 @@ type Config struct { APIVersion string spec interface{} + ApplicationSpec *GenericApplicationSpec + + // TODO: remove these fields KubernetesApplicationSpec *KubernetesApplicationSpec TerraformApplicationSpec *TerraformApplicationSpec CloudRunApplicationSpec *CloudRunApplicationSpec @@ -99,6 +104,10 @@ func (c *Config) init(kind Kind, apiVersion string) error { c.APIVersion = apiVersion switch kind { + case KindApplication: + c.ApplicationSpec = &GenericApplicationSpec{} + c.spec = c.ApplicationSpec + case KindKubernetesApp: c.KubernetesApplicationSpec = &KubernetesApplicationSpec{} c.spec = c.KubernetesApplicationSpec @@ -240,6 +249,8 @@ func (k Kind) ToApplicationKind() (model.ApplicationKind, bool) { func (c *Config) GetGenericApplication() (GenericApplicationSpec, bool) { switch c.Kind { + case KindApplication: + return *c.ApplicationSpec, true case KindKubernetesApp: return c.KubernetesApplicationSpec.GenericApplicationSpec, true case KindTerraformApp: From 8c03a834b847f02a07c93d5e69c5e1f4ea5bf62f Mon Sep 17 00:00:00 2001 From: Khanh Tran <32532742+khanhtc1202@users.noreply.github.com> Date: Wed, 2 Oct 2024 01:06:59 +0900 Subject: [PATCH 35/84] Update web development readme (#5247) Signed-off-by: khanhtc1202 --- docs/static/images/play-environment-get-me.png | Bin 0 -> 402525 bytes web/README.md | 4 ++++ 2 files changed, 4 insertions(+) create mode 100644 docs/static/images/play-environment-get-me.png diff --git a/docs/static/images/play-environment-get-me.png b/docs/static/images/play-environment-get-me.png new file mode 100644 index 0000000000000000000000000000000000000000..52dc903d8b6ecb72bdcd3a3ca238764400cb55d8 GIT binary patch literal 402525 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 P~bXBu#{KrIQJb?ez{=W2H4LJ-< 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%0Dq1 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
-{{ $editURL := printf "%s/edit/%s/content/%s" $gh_repo $gh_branch $pathFormatted }} -{{ if and ($gh_subdir) (.Site.Language.Lang) }} -{{ $editURL = printf "%s/edit/%s/%s/content/%s/%s" $gh_repo $gh_branch $gh_subdir ($.Site.Language.Lang) $pathFormatted }} -{{ else if .Site.Language.Lang }} -{{ $editURL = printf "%s/edit/%s/content/%s/%s" $gh_repo $gh_branch ($.Site.Language.Lang) $pathFormatted }} -{{ else if $gh_subdir }} -{{ $editURL = printf "%s/edit/%s/%s/content/%s" $gh_repo $gh_branch $gh_subdir $pathFormatted }} +{{ $editURL := printf "%s/edit/%s" $gh_repo $gh_branch }} +{{ if .File }} + {{ if .File.LogicalName }} + {{ if $gh_subdir }} + {{ $editURL = printf "%s/%s" $editURL $gh_subdir }} + {{ end }} + {{ $editURL = printf "%s/content" $editURL }} + {{ if .Site.Language.Lang }} + {{ $editURL = printf "%s/%s" $editURL .Site.Language.Lang }} + {{ end }} + {{ $editURL = printf "%s/%s" $editURL .File.Path }} + {{ end }} {{ end }} + {{ $issuesURL := printf "%s/issues/new?title=%s" $gh_repo (htmlEscape $.Title )}} {{ T "post_edit_this" }} {{ T "post_create_issue" }} From cbbd67d665f06756218322dd175f94c21fc0dba1 Mon Sep 17 00:00:00 2001 From: Shinnosuke Sawada-Dazai Date: Thu, 24 Oct 2024 17:38:31 +0900 Subject: [PATCH 71/84] Implement LoadManifests with Kustomize template (#5291) --- .../plugin/kubernetes/provider/kustomize.go | 65 +++++++++++++++++++ .../kubernetes/provider/kustomize_test.go | 55 ++++++++++++++++ .../plugin/kubernetes/provider/loader.go | 27 +++++++- .../testdata/testkustomize/deployment.yaml | 21 ++++++ .../testdata/testkustomize/kustomization.yaml | 5 ++ 5 files changed, 171 insertions(+), 2 deletions(-) create mode 100644 pkg/app/pipedv1/plugin/kubernetes/provider/kustomize.go create mode 100644 pkg/app/pipedv1/plugin/kubernetes/provider/kustomize_test.go create mode 100644 pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testkustomize/deployment.yaml create mode 100644 pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testkustomize/kustomization.yaml diff --git a/pkg/app/pipedv1/plugin/kubernetes/provider/kustomize.go b/pkg/app/pipedv1/plugin/kubernetes/provider/kustomize.go new file mode 100644 index 0000000000..819ce13c91 --- /dev/null +++ b/pkg/app/pipedv1/plugin/kubernetes/provider/kustomize.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 provider + +import ( + "bytes" + "context" + "fmt" + "os/exec" + + "go.uber.org/zap" +) + +type Kustomize struct { + execPath string + logger *zap.Logger +} + +func NewKustomize(path string, logger *zap.Logger) *Kustomize { + return &Kustomize{ + execPath: path, + logger: logger, + } +} + +func (c *Kustomize) Template(ctx context.Context, appName, appDir string, opts map[string]string) (string, error) { + args := []string{ + "build", + ".", + } + + for k, v := range opts { + args = append(args, fmt.Sprintf("--%s", k)) + if v != "" { + args = append(args, v) + } + } + + var stdout, stderr bytes.Buffer + cmd := exec.CommandContext(ctx, c.execPath, args...) + cmd.Dir = appDir + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + c.logger.Info(fmt.Sprintf("start templating a Kustomize application %s", appName), + zap.Any("args", args), + ) + + if err := cmd.Run(); err != nil { + return stdout.String(), fmt.Errorf("%w: %s", err, stderr.String()) + } + return stdout.String(), nil +} diff --git a/pkg/app/pipedv1/plugin/kubernetes/provider/kustomize_test.go b/pkg/app/pipedv1/plugin/kubernetes/provider/kustomize_test.go new file mode 100644 index 0000000000..1ec350d999 --- /dev/null +++ b/pkg/app/pipedv1/plugin/kubernetes/provider/kustomize_test.go @@ -0,0 +1,55 @@ +// 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 provider + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap" + + "github.com/pipe-cd/pipecd/pkg/app/pipedv1/plugin/kubernetes/toolregistry" + "github.com/pipe-cd/pipecd/pkg/app/pipedv1/plugin/toolregistry/toolregistrytest" +) + +func TestKustomizeTemplate(t *testing.T) { + t.Parallel() + + var ( + ctx = context.TODO() + appName = "testapp" + appDir = "testdata/testkustomize" + ) + + c, err := toolregistrytest.NewToolRegistry(t) + require.NoError(t, err) + + r := toolregistry.NewRegistry(c) + + t.Cleanup(func() { c.Close() }) + + kustomizePath, err := r.Kustomize(context.Background(), "5.4.3") + require.NoError(t, err) + require.NotEmpty(t, kustomizePath) + + kustomize := NewKustomize(kustomizePath, zap.NewNop()) + out, err := kustomize.Template(ctx, appName, appDir, map[string]string{ + "load-restrictor": "LoadRestrictionsNone", + }) + require.NoError(t, err) + assert.True(t, len(out) > 0) +} diff --git a/pkg/app/pipedv1/plugin/kubernetes/provider/loader.go b/pkg/app/pipedv1/plugin/kubernetes/provider/loader.go index 87511789e8..54853a6970 100644 --- a/pkg/app/pipedv1/plugin/kubernetes/provider/loader.go +++ b/pkg/app/pipedv1/plugin/kubernetes/provider/loader.go @@ -15,6 +15,7 @@ package provider import ( + "context" "errors" "fmt" "io/fs" @@ -24,6 +25,7 @@ import ( "strconv" "strings" + "go.uber.org/zap" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "sigs.k8s.io/yaml" @@ -39,6 +41,7 @@ const ( ) type LoaderInput struct { + AppName string AppDir string ConfigFilename string Manifests []string @@ -46,13 +49,22 @@ type LoaderInput struct { Namespace string TemplatingMethod TemplatingMethod + KustomizeVersion string + KustomizeOptions map[string]string + // TODO: define fields for LoaderInput. } type Loader struct { + toolRegistry ToolRegistry +} + +type ToolRegistry interface { + Kustomize(ctx context.Context, version string) (string, error) + Helm(ctx context.Context, version string) (string, error) } -func (l *Loader) LoadManifests(input LoaderInput) (manifests []Manifest, err error) { +func (l *Loader) LoadManifests(ctx context.Context, input LoaderInput) (manifests []Manifest, err error) { defer func() { // Override namespace if set because ParseManifests does not parse it // if namespace is not explicitly specified in the manifests. @@ -64,7 +76,18 @@ func (l *Loader) LoadManifests(input LoaderInput) (manifests []Manifest, err err case TemplatingMethodHelm: return nil, errors.New("not implemented yet") case TemplatingMethodKustomize: - return nil, errors.New("not implemented yet") + kustomizePath, err := l.toolRegistry.Kustomize(ctx, input.KustomizeVersion) + if err != nil { + return nil, fmt.Errorf("failed to get kustomize tool: %w", err) + } + + k := NewKustomize(kustomizePath, zap.NewNop()) // TODO: pass logger + data, err := k.Template(ctx, input.AppName, input.AppDir, input.KustomizeOptions) + if err != nil { + return nil, fmt.Errorf("failed to template kustomize manifests: %w", err) + } + + return ParseManifests(data) case TemplatingMethodNone: return LoadPlainYAMLManifests(input.AppDir, input.Manifests, input.ConfigFilename) default: diff --git a/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testkustomize/deployment.yaml b/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testkustomize/deployment.yaml new file mode 100644 index 0000000000..904afe8742 --- /dev/null +++ b/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testkustomize/deployment.yaml @@ -0,0 +1,21 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: the-deployment +spec: + replicas: 3 + selector: + matchLabels: + deployment: hello + template: + metadata: + labels: + deployment: hello + spec: + containers: + - name: helloworld + image: ghcr.io/pipe-cd/helloworld:v0.49.2 + args: + - server + ports: + - containerPort: 9085 diff --git a/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testkustomize/kustomization.yaml b/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testkustomize/kustomization.yaml new file mode 100644 index 0000000000..c7cf5bb89a --- /dev/null +++ b/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testkustomize/kustomization.yaml @@ -0,0 +1,5 @@ +commonLabels: + app: hello + +resources: + - deployment.yaml \ No newline at end of file From 4ebcb7c351c34d53ed50bf1d90b67e436273af08 Mon Sep 17 00:00:00 2001 From: Khanh Tran <32532742+khanhtc1202@users.noreply.github.com> Date: Fri, 25 Oct 2024 08:12:06 +0700 Subject: [PATCH 72/84] Remove order for stages in case of quick sync (#5292) * Remove order for stages in case of quick sync Signed-off-by: khanhtc1202 * Fix failed test Signed-off-by: khanhtc1202 --------- Signed-off-by: khanhtc1202 --- pkg/app/pipedv1/controller/planner.go | 24 +-- pkg/app/pipedv1/controller/planner_test.go | 34 +-- .../plugin/kubernetes/deployment/pipeline.go | 7 +- .../kubernetes/deployment/pipeline_test.go | 5 +- .../plugin/kubernetes/deployment/server.go | 2 +- pkg/plugin/api/v1alpha1/deployment/api.pb.go | 198 +++++++++--------- .../v1alpha1/deployment/api.pb.validate.go | 2 - pkg/plugin/api/v1alpha1/deployment/api.proto | 3 +- 8 files changed, 113 insertions(+), 162 deletions(-) diff --git a/pkg/app/pipedv1/controller/planner.go b/pkg/app/pipedv1/controller/planner.go index 4efa554c5b..bb7f118d73 100644 --- a/pkg/app/pipedv1/controller/planner.go +++ b/pkg/app/pipedv1/controller/planner.go @@ -30,7 +30,7 @@ import ( "github.com/pipe-cd/pipecd/pkg/app/pipedv1/controller/controllermetrics" "github.com/pipe-cd/pipecd/pkg/app/pipedv1/metadatastore" "github.com/pipe-cd/pipecd/pkg/app/server/service/pipedservice" - "github.com/pipe-cd/pipecd/pkg/configv1" + config "github.com/pipe-cd/pipecd/pkg/configv1" "github.com/pipe-cd/pipecd/pkg/model" pluginapi "github.com/pipe-cd/pipecd/pkg/plugin/api/v1alpha1" "github.com/pipe-cd/pipecd/pkg/plugin/api/v1alpha1/deployment" @@ -409,13 +409,11 @@ func (p *planner) buildQuickSyncStages(ctx context.Context, cfg *config.GenericA rollbackStages = []*model.PipelineStage{} rollback = *cfg.Planner.AutoRollback ) - // TODO: Consider how to define the order of plugins. - for i, plg := range p.plugins { - res, err := plg.BuildQuickSyncStages(ctx, &deployment.BuildQuickSyncStagesRequest{StageIndex: int32(i), Rollback: rollback}) + for _, plg := range p.plugins { + res, err := plg.BuildQuickSyncStages(ctx, &deployment.BuildQuickSyncStagesRequest{Rollback: rollback}) if err != nil { return nil, fmt.Errorf("failed to build quick sync stage deployment (%w)", err) } - // TODO: Ensure responsed stages indexies is valid. for i := range res.Stages { if res.Stages[i].Rollback { rollbackStages = append(rollbackStages, res.Stages[i]) @@ -425,22 +423,6 @@ func (p *planner) buildQuickSyncStages(ctx context.Context, cfg *config.GenericA } } - // Sort stages by index. - sort.Sort(model.PipelineStages(stages)) - sort.Sort(model.PipelineStages(rollbackStages)) - - // In case there is more than one forward stage, build requires for each stage - // based on the order of stages. - if len(stages) > 1 { - preStageID := "" - for _, s := range stages { - if preStageID != "" { - s.Requires = []string{preStageID} - } - preStageID = s.Id - } - } - stages = append(stages, rollbackStages...) if len(stages) == 0 { return nil, fmt.Errorf("unable to build quick sync stages for deployment") diff --git a/pkg/app/pipedv1/controller/planner_test.go b/pkg/app/pipedv1/controller/planner_test.go index 9947761c91..90fae65122 100644 --- a/pkg/app/pipedv1/controller/planner_test.go +++ b/pkg/app/pipedv1/controller/planner_test.go @@ -25,7 +25,7 @@ import ( "go.uber.org/zap" "google.golang.org/grpc" - "github.com/pipe-cd/pipecd/pkg/configv1" + config "github.com/pipe-cd/pipecd/pkg/configv1" "github.com/pipe-cd/pipecd/pkg/model" pluginapi "github.com/pipe-cd/pipecd/pkg/plugin/api/v1alpha1" "github.com/pipe-cd/pipecd/pkg/plugin/api/v1alpha1/deployment" @@ -151,14 +151,12 @@ func TestBuildQuickSyncStages(t *testing.T) { &fakePlugin{ quickStages: []*model.PipelineStage{ { - Id: "plugin-1-stage-1", - Index: 0, + Id: "plugin-1-stage-1", }, }, rollbackStages: []*model.PipelineStage{ { Id: "plugin-1-rollback", - Index: 0, Rollback: true, }, }, @@ -166,14 +164,12 @@ func TestBuildQuickSyncStages(t *testing.T) { &fakePlugin{ quickStages: []*model.PipelineStage{ { - Id: "plugin-2-stage-1", - Index: 1, + Id: "plugin-2-stage-1", }, }, rollbackStages: []*model.PipelineStage{ { Id: "plugin-2-rollback", - Index: 1, Rollback: true, }, }, @@ -187,22 +183,17 @@ func TestBuildQuickSyncStages(t *testing.T) { wantErr: false, expectedStages: []*model.PipelineStage{ { - Id: "plugin-1-stage-1", - Index: 0, + Id: "plugin-1-stage-1", }, { - Id: "plugin-2-stage-1", - Index: 1, - Requires: []string{"plugin-1-stage-1"}, + Id: "plugin-2-stage-1", }, { Id: "plugin-1-rollback", - Index: 0, Rollback: true, }, { Id: "plugin-2-rollback", - Index: 1, Rollback: true, }, }, @@ -213,8 +204,7 @@ func TestBuildQuickSyncStages(t *testing.T) { &fakePlugin{ quickStages: []*model.PipelineStage{ { - Id: "plugin-1-stage-1", - Index: 0, + Id: "plugin-1-stage-1", }, }, rollbackStages: []*model.PipelineStage{ @@ -227,8 +217,7 @@ func TestBuildQuickSyncStages(t *testing.T) { &fakePlugin{ quickStages: []*model.PipelineStage{ { - Id: "plugin-2-stage-1", - Index: 1, + Id: "plugin-2-stage-1", }, }, rollbackStages: []*model.PipelineStage{ @@ -247,13 +236,10 @@ func TestBuildQuickSyncStages(t *testing.T) { wantErr: false, expectedStages: []*model.PipelineStage{ { - Id: "plugin-1-stage-1", - Index: 0, + Id: "plugin-1-stage-1", }, { - Id: "plugin-2-stage-1", - Index: 1, - Requires: []string{"plugin-1-stage-1"}, + Id: "plugin-2-stage-1", }, }, }, @@ -266,7 +252,7 @@ func TestBuildQuickSyncStages(t *testing.T) { } stages, err := planner.buildQuickSyncStages(context.TODO(), tc.cfg) require.Equal(t, tc.wantErr, err != nil) - assert.Equal(t, tc.expectedStages, stages) + assert.ElementsMatch(t, tc.expectedStages, stages) }) } } diff --git a/pkg/app/pipedv1/plugin/kubernetes/deployment/pipeline.go b/pkg/app/pipedv1/plugin/kubernetes/deployment/pipeline.go index f7e57369b4..6334644a4f 100644 --- a/pkg/app/pipedv1/plugin/kubernetes/deployment/pipeline.go +++ b/pkg/app/pipedv1/plugin/kubernetes/deployment/pipeline.go @@ -105,7 +105,7 @@ func MakeInitialStageMetadata(cfg config.PipelineStage) map[string]string { } } -func buildQuickSyncPipeline(index int32, autoRollback bool, now time.Time) []*model.PipelineStage { +func buildQuickSyncPipeline(autoRollback bool, now time.Time) []*model.PipelineStage { var ( preStageID = "" stage, _ = GetPredefinedStage(PredefinedStageK8sSync) @@ -113,16 +113,15 @@ func buildQuickSyncPipeline(index int32, autoRollback bool, now time.Time) []*mo out = make([]*model.PipelineStage, 0, len(stages)) ) - for _, s := range stages { + for i, s := range stages { id := s.ID if id == "" { - id = fmt.Sprintf("kubernetes-stage-%d", index) + id = fmt.Sprintf("kubernetes-stage-%d", i) } stage := &model.PipelineStage{ Id: id, Name: s.Name.String(), Desc: s.Desc, - Index: int32(index), Predefined: true, Visible: true, Status: model.StageStatus_STAGE_NOT_STARTED_YET, diff --git a/pkg/app/pipedv1/plugin/kubernetes/deployment/pipeline_test.go b/pkg/app/pipedv1/plugin/kubernetes/deployment/pipeline_test.go index 3e8f604828..26f608ed77 100644 --- a/pkg/app/pipedv1/plugin/kubernetes/deployment/pipeline_test.go +++ b/pkg/app/pipedv1/plugin/kubernetes/deployment/pipeline_test.go @@ -29,13 +29,11 @@ func TestBuildQuickSyncPipeline(t *testing.T) { tests := []struct { name string - index int32 autoRollback bool expected []*model.PipelineStage }{ { name: "without auto rollback", - index: 0, autoRollback: false, expected: []*model.PipelineStage{ { @@ -54,7 +52,6 @@ func TestBuildQuickSyncPipeline(t *testing.T) { }, { name: "with auto rollback", - index: 0, autoRollback: true, expected: []*model.PipelineStage{ { @@ -85,7 +82,7 @@ func TestBuildQuickSyncPipeline(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - actual := buildQuickSyncPipeline(tt.index, tt.autoRollback, now) + actual := buildQuickSyncPipeline(tt.autoRollback, now) assert.Equal(t, tt.expected, actual) }) } diff --git a/pkg/app/pipedv1/plugin/kubernetes/deployment/server.go b/pkg/app/pipedv1/plugin/kubernetes/deployment/server.go index 4dd4fd64b4..7619e52ec4 100644 --- a/pkg/app/pipedv1/plugin/kubernetes/deployment/server.go +++ b/pkg/app/pipedv1/plugin/kubernetes/deployment/server.go @@ -95,7 +95,7 @@ func (a *DeploymentService) BuildPipelineSyncStages(context.Context, *deployment // BuildQuickSyncStages implements deployment.DeploymentServiceServer. func (a *DeploymentService) BuildQuickSyncStages(ctx context.Context, request *deployment.BuildQuickSyncStagesRequest) (*deployment.BuildQuickSyncStagesResponse, error) { now := time.Now() - stages := buildQuickSyncPipeline(request.GetStageIndex(), request.GetRollback(), now) + stages := buildQuickSyncPipeline(request.GetRollback(), now) return &deployment.BuildQuickSyncStagesResponse{ Stages: stages, }, nil diff --git a/pkg/plugin/api/v1alpha1/deployment/api.pb.go b/pkg/plugin/api/v1alpha1/deployment/api.pb.go index d350a34a1b..cea4990c70 100644 --- a/pkg/plugin/api/v1alpha1/deployment/api.pb.go +++ b/pkg/plugin/api/v1alpha1/deployment/api.pb.go @@ -343,8 +343,7 @@ type BuildQuickSyncStagesRequest struct { sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields - StageIndex int32 `protobuf:"varint,1,opt,name=stage_index,json=stageIndex,proto3" json:"stage_index,omitempty"` - Rollback bool `protobuf:"varint,2,opt,name=rollback,proto3" json:"rollback,omitempty"` + Rollback bool `protobuf:"varint,1,opt,name=rollback,proto3" json:"rollback,omitempty"` } func (x *BuildQuickSyncStagesRequest) Reset() { @@ -379,13 +378,6 @@ func (*BuildQuickSyncStagesRequest) Descriptor() ([]byte, []int) { return file_pkg_plugin_api_v1alpha1_deployment_api_proto_rawDescGZIP(), []int{6} } -func (x *BuildQuickSyncStagesRequest) GetStageIndex() int32 { - if x != nil { - return x.StageIndex - } - return 0 -} - func (x *BuildQuickSyncStagesRequest) GetRollback() bool { if x != nil { return x.Rollback @@ -838,112 +830,110 @@ var file_pkg_plugin_api_v1alpha1_deployment_api_proto_rawDesc = []byte{ 0x63, 0x53, 0x74, 0x61, 0x67, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x2c, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x67, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x2e, 0x50, 0x69, 0x70, 0x65, 0x6c, 0x69, 0x6e, 0x65, - 0x53, 0x74, 0x61, 0x67, 0x65, 0x52, 0x06, 0x73, 0x74, 0x61, 0x67, 0x65, 0x73, 0x22, 0x5a, 0x0a, + 0x53, 0x74, 0x61, 0x67, 0x65, 0x52, 0x06, 0x73, 0x74, 0x61, 0x67, 0x65, 0x73, 0x22, 0x39, 0x0a, 0x1b, 0x42, 0x75, 0x69, 0x6c, 0x64, 0x51, 0x75, 0x69, 0x63, 0x6b, 0x53, 0x79, 0x6e, 0x63, 0x53, - 0x74, 0x61, 0x67, 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1f, 0x0a, 0x0b, - 0x73, 0x74, 0x61, 0x67, 0x65, 0x5f, 0x69, 0x6e, 0x64, 0x65, 0x78, 0x18, 0x01, 0x20, 0x01, 0x28, - 0x05, 0x52, 0x0a, 0x73, 0x74, 0x61, 0x67, 0x65, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x12, 0x1a, 0x0a, - 0x08, 0x72, 0x6f, 0x6c, 0x6c, 0x62, 0x61, 0x63, 0x6b, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, - 0x08, 0x72, 0x6f, 0x6c, 0x6c, 0x62, 0x61, 0x63, 0x6b, 0x22, 0x4c, 0x0a, 0x1c, 0x42, 0x75, 0x69, - 0x6c, 0x64, 0x51, 0x75, 0x69, 0x63, 0x6b, 0x53, 0x79, 0x6e, 0x63, 0x53, 0x74, 0x61, 0x67, 0x65, - 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x2c, 0x0a, 0x06, 0x73, 0x74, 0x61, - 0x67, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x6d, 0x6f, 0x64, 0x65, - 0x6c, 0x2e, 0x50, 0x69, 0x70, 0x65, 0x6c, 0x69, 0x6e, 0x65, 0x53, 0x74, 0x61, 0x67, 0x65, 0x52, - 0x06, 0x73, 0x74, 0x61, 0x67, 0x65, 0x73, 0x22, 0x1b, 0x0a, 0x19, 0x46, 0x65, 0x74, 0x63, 0x68, - 0x44, 0x65, 0x66, 0x69, 0x6e, 0x65, 0x64, 0x53, 0x74, 0x61, 0x67, 0x65, 0x73, 0x52, 0x65, 0x71, - 0x75, 0x65, 0x73, 0x74, 0x22, 0x34, 0x0a, 0x1a, 0x46, 0x65, 0x74, 0x63, 0x68, 0x44, 0x65, 0x66, - 0x69, 0x6e, 0x65, 0x64, 0x53, 0x74, 0x61, 0x67, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, - 0x73, 0x65, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x67, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, - 0x28, 0x09, 0x52, 0x06, 0x73, 0x74, 0x61, 0x67, 0x65, 0x73, 0x22, 0xd5, 0x02, 0x0a, 0x0f, 0x50, - 0x6c, 0x61, 0x6e, 0x50, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x49, 0x6e, 0x70, 0x75, 0x74, 0x12, 0x3b, - 0x0a, 0x0a, 0x64, 0x65, 0x70, 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x18, 0x01, 0x20, 0x01, - 0x28, 0x0b, 0x32, 0x11, 0x2e, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x2e, 0x44, 0x65, 0x70, 0x6c, 0x6f, - 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x42, 0x08, 0xfa, 0x42, 0x05, 0x8a, 0x01, 0x02, 0x10, 0x01, 0x52, - 0x0a, 0x64, 0x65, 0x70, 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x12, 0x23, 0x0a, 0x0d, 0x70, - 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x5f, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x02, 0x20, 0x01, - 0x28, 0x0c, 0x52, 0x0c, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, - 0x12, 0x70, 0x0a, 0x19, 0x72, 0x75, 0x6e, 0x6e, 0x69, 0x6e, 0x67, 0x5f, 0x64, 0x65, 0x70, 0x6c, - 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x5f, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x18, 0x03, 0x20, + 0x74, 0x61, 0x67, 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1a, 0x0a, 0x08, + 0x72, 0x6f, 0x6c, 0x6c, 0x62, 0x61, 0x63, 0x6b, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x08, + 0x72, 0x6f, 0x6c, 0x6c, 0x62, 0x61, 0x63, 0x6b, 0x22, 0x4c, 0x0a, 0x1c, 0x42, 0x75, 0x69, 0x6c, + 0x64, 0x51, 0x75, 0x69, 0x63, 0x6b, 0x53, 0x79, 0x6e, 0x63, 0x53, 0x74, 0x61, 0x67, 0x65, 0x73, + 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x2c, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x67, + 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x6d, 0x6f, 0x64, 0x65, 0x6c, + 0x2e, 0x50, 0x69, 0x70, 0x65, 0x6c, 0x69, 0x6e, 0x65, 0x53, 0x74, 0x61, 0x67, 0x65, 0x52, 0x06, + 0x73, 0x74, 0x61, 0x67, 0x65, 0x73, 0x22, 0x1b, 0x0a, 0x19, 0x46, 0x65, 0x74, 0x63, 0x68, 0x44, + 0x65, 0x66, 0x69, 0x6e, 0x65, 0x64, 0x53, 0x74, 0x61, 0x67, 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, + 0x65, 0x73, 0x74, 0x22, 0x34, 0x0a, 0x1a, 0x46, 0x65, 0x74, 0x63, 0x68, 0x44, 0x65, 0x66, 0x69, + 0x6e, 0x65, 0x64, 0x53, 0x74, 0x61, 0x67, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, + 0x65, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x67, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, + 0x09, 0x52, 0x06, 0x73, 0x74, 0x61, 0x67, 0x65, 0x73, 0x22, 0xd5, 0x02, 0x0a, 0x0f, 0x50, 0x6c, + 0x61, 0x6e, 0x50, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x49, 0x6e, 0x70, 0x75, 0x74, 0x12, 0x3b, 0x0a, + 0x0a, 0x64, 0x65, 0x70, 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x0b, 0x32, 0x11, 0x2e, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x2e, 0x44, 0x65, 0x70, 0x6c, 0x6f, 0x79, + 0x6d, 0x65, 0x6e, 0x74, 0x42, 0x08, 0xfa, 0x42, 0x05, 0x8a, 0x01, 0x02, 0x10, 0x01, 0x52, 0x0a, + 0x64, 0x65, 0x70, 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x12, 0x23, 0x0a, 0x0d, 0x70, 0x6c, + 0x75, 0x67, 0x69, 0x6e, 0x5f, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x02, 0x20, 0x01, 0x28, + 0x0c, 0x52, 0x0c, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, + 0x70, 0x0a, 0x19, 0x72, 0x75, 0x6e, 0x6e, 0x69, 0x6e, 0x67, 0x5f, 0x64, 0x65, 0x70, 0x6c, 0x6f, + 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x5f, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x18, 0x03, 0x20, 0x01, + 0x28, 0x0b, 0x32, 0x34, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, + 0x2e, 0x64, 0x65, 0x70, 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x70, 0x69, 0x2e, 0x76, + 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2e, 0x44, 0x65, 0x70, 0x6c, 0x6f, 0x79, 0x6d, 0x65, + 0x6e, 0x74, 0x53, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x17, 0x72, 0x75, 0x6e, 0x6e, 0x69, 0x6e, + 0x67, 0x44, 0x65, 0x70, 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x53, 0x6f, 0x75, 0x72, 0x63, + 0x65, 0x12, 0x6e, 0x0a, 0x18, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x5f, 0x64, 0x65, 0x70, 0x6c, + 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x5f, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x34, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x2e, 0x64, 0x65, 0x70, 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x70, 0x69, 0x2e, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2e, 0x44, 0x65, 0x70, 0x6c, 0x6f, 0x79, 0x6d, - 0x65, 0x6e, 0x74, 0x53, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x17, 0x72, 0x75, 0x6e, 0x6e, 0x69, - 0x6e, 0x67, 0x44, 0x65, 0x70, 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x53, 0x6f, 0x75, 0x72, - 0x63, 0x65, 0x12, 0x6e, 0x0a, 0x18, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x5f, 0x64, 0x65, 0x70, - 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x5f, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x18, 0x04, - 0x20, 0x01, 0x28, 0x0b, 0x32, 0x34, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x70, 0x6c, 0x75, 0x67, - 0x69, 0x6e, 0x2e, 0x64, 0x65, 0x70, 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x70, 0x69, - 0x2e, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2e, 0x44, 0x65, 0x70, 0x6c, 0x6f, 0x79, - 0x6d, 0x65, 0x6e, 0x74, 0x53, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x16, 0x74, 0x61, 0x72, 0x67, - 0x65, 0x74, 0x44, 0x65, 0x70, 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x53, 0x6f, 0x75, 0x72, - 0x63, 0x65, 0x22, 0xd2, 0x01, 0x0a, 0x10, 0x44, 0x65, 0x70, 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, - 0x74, 0x53, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x33, 0x0a, 0x15, 0x61, 0x70, 0x70, 0x6c, 0x69, - 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x79, - 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x14, 0x61, 0x70, 0x70, 0x6c, 0x69, 0x63, 0x61, 0x74, - 0x69, 0x6f, 0x6e, 0x44, 0x69, 0x72, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x79, 0x12, 0x1a, 0x0a, 0x08, - 0x72, 0x65, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, - 0x72, 0x65, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x2d, 0x0a, 0x12, 0x61, 0x70, 0x70, 0x6c, - 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x03, - 0x20, 0x01, 0x28, 0x0c, 0x52, 0x11, 0x61, 0x70, 0x70, 0x6c, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, - 0x6e, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x3e, 0x0a, 0x1b, 0x61, 0x70, 0x70, 0x6c, 0x69, - 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x5f, 0x66, 0x69, - 0x6c, 0x65, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x19, 0x61, 0x70, - 0x70, 0x6c, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x46, - 0x69, 0x6c, 0x65, 0x6e, 0x61, 0x6d, 0x65, 0x32, 0x9a, 0x06, 0x0a, 0x11, 0x44, 0x65, 0x70, 0x6c, - 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x95, 0x01, - 0x0a, 0x12, 0x46, 0x65, 0x74, 0x63, 0x68, 0x44, 0x65, 0x66, 0x69, 0x6e, 0x65, 0x64, 0x53, 0x74, - 0x61, 0x67, 0x65, 0x73, 0x12, 0x3d, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x70, 0x6c, 0x75, 0x67, - 0x69, 0x6e, 0x2e, 0x64, 0x65, 0x70, 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x70, 0x69, - 0x2e, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2e, 0x46, 0x65, 0x74, 0x63, 0x68, 0x44, - 0x65, 0x66, 0x69, 0x6e, 0x65, 0x64, 0x53, 0x74, 0x61, 0x67, 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, - 0x65, 0x73, 0x74, 0x1a, 0x3e, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x70, 0x6c, 0x75, 0x67, 0x69, + 0x65, 0x6e, 0x74, 0x53, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x16, 0x74, 0x61, 0x72, 0x67, 0x65, + 0x74, 0x44, 0x65, 0x70, 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x53, 0x6f, 0x75, 0x72, 0x63, + 0x65, 0x22, 0xd2, 0x01, 0x0a, 0x10, 0x44, 0x65, 0x70, 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, + 0x53, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x33, 0x0a, 0x15, 0x61, 0x70, 0x70, 0x6c, 0x69, 0x63, + 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x79, 0x18, + 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x14, 0x61, 0x70, 0x70, 0x6c, 0x69, 0x63, 0x61, 0x74, 0x69, + 0x6f, 0x6e, 0x44, 0x69, 0x72, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x79, 0x12, 0x1a, 0x0a, 0x08, 0x72, + 0x65, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x72, + 0x65, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x2d, 0x0a, 0x12, 0x61, 0x70, 0x70, 0x6c, 0x69, + 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x03, 0x20, + 0x01, 0x28, 0x0c, 0x52, 0x11, 0x61, 0x70, 0x70, 0x6c, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, + 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x3e, 0x0a, 0x1b, 0x61, 0x70, 0x70, 0x6c, 0x69, 0x63, + 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x5f, 0x66, 0x69, 0x6c, + 0x65, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x19, 0x61, 0x70, 0x70, + 0x6c, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x46, 0x69, + 0x6c, 0x65, 0x6e, 0x61, 0x6d, 0x65, 0x32, 0x9a, 0x06, 0x0a, 0x11, 0x44, 0x65, 0x70, 0x6c, 0x6f, + 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x95, 0x01, 0x0a, + 0x12, 0x46, 0x65, 0x74, 0x63, 0x68, 0x44, 0x65, 0x66, 0x69, 0x6e, 0x65, 0x64, 0x53, 0x74, 0x61, + 0x67, 0x65, 0x73, 0x12, 0x3d, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x2e, 0x64, 0x65, 0x70, 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x70, 0x69, 0x2e, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2e, 0x46, 0x65, 0x74, 0x63, 0x68, 0x44, 0x65, - 0x66, 0x69, 0x6e, 0x65, 0x64, 0x53, 0x74, 0x61, 0x67, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, - 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x92, 0x01, 0x0a, 0x11, 0x44, 0x65, 0x74, 0x65, 0x72, 0x6d, - 0x69, 0x6e, 0x65, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x3c, 0x2e, 0x67, 0x72, - 0x70, 0x63, 0x2e, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x2e, 0x64, 0x65, 0x70, 0x6c, 0x6f, 0x79, - 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x70, 0x69, 0x2e, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, - 0x2e, 0x44, 0x65, 0x74, 0x65, 0x72, 0x6d, 0x69, 0x6e, 0x65, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, - 0x6e, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x3d, 0x2e, 0x67, 0x72, 0x70, 0x63, - 0x2e, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x2e, 0x64, 0x65, 0x70, 0x6c, 0x6f, 0x79, 0x6d, 0x65, - 0x6e, 0x74, 0x61, 0x70, 0x69, 0x2e, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2e, 0x44, - 0x65, 0x74, 0x65, 0x72, 0x6d, 0x69, 0x6e, 0x65, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x73, - 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x92, 0x01, 0x0a, 0x11, 0x44, - 0x65, 0x74, 0x65, 0x72, 0x6d, 0x69, 0x6e, 0x65, 0x53, 0x74, 0x72, 0x61, 0x74, 0x65, 0x67, 0x79, - 0x12, 0x3c, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x2e, 0x64, - 0x65, 0x70, 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x70, 0x69, 0x2e, 0x76, 0x31, 0x61, - 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2e, 0x44, 0x65, 0x74, 0x65, 0x72, 0x6d, 0x69, 0x6e, 0x65, 0x53, - 0x74, 0x72, 0x61, 0x74, 0x65, 0x67, 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x3d, + 0x66, 0x69, 0x6e, 0x65, 0x64, 0x53, 0x74, 0x61, 0x67, 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x1a, 0x3e, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, + 0x2e, 0x64, 0x65, 0x70, 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x70, 0x69, 0x2e, 0x76, + 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2e, 0x46, 0x65, 0x74, 0x63, 0x68, 0x44, 0x65, 0x66, + 0x69, 0x6e, 0x65, 0x64, 0x53, 0x74, 0x61, 0x67, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, + 0x73, 0x65, 0x22, 0x00, 0x12, 0x92, 0x01, 0x0a, 0x11, 0x44, 0x65, 0x74, 0x65, 0x72, 0x6d, 0x69, + 0x6e, 0x65, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x3c, 0x2e, 0x67, 0x72, 0x70, + 0x63, 0x2e, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x2e, 0x64, 0x65, 0x70, 0x6c, 0x6f, 0x79, 0x6d, + 0x65, 0x6e, 0x74, 0x61, 0x70, 0x69, 0x2e, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2e, + 0x44, 0x65, 0x74, 0x65, 0x72, 0x6d, 0x69, 0x6e, 0x65, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, + 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x3d, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, + 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x2e, 0x64, 0x65, 0x70, 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, + 0x74, 0x61, 0x70, 0x69, 0x2e, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2e, 0x44, 0x65, + 0x74, 0x65, 0x72, 0x6d, 0x69, 0x6e, 0x65, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x52, + 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x92, 0x01, 0x0a, 0x11, 0x44, 0x65, + 0x74, 0x65, 0x72, 0x6d, 0x69, 0x6e, 0x65, 0x53, 0x74, 0x72, 0x61, 0x74, 0x65, 0x67, 0x79, 0x12, + 0x3c, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x2e, 0x64, 0x65, + 0x70, 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x70, 0x69, 0x2e, 0x76, 0x31, 0x61, 0x6c, + 0x70, 0x68, 0x61, 0x31, 0x2e, 0x44, 0x65, 0x74, 0x65, 0x72, 0x6d, 0x69, 0x6e, 0x65, 0x53, 0x74, + 0x72, 0x61, 0x74, 0x65, 0x67, 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x3d, 0x2e, + 0x67, 0x72, 0x70, 0x63, 0x2e, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x2e, 0x64, 0x65, 0x70, 0x6c, + 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x70, 0x69, 0x2e, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, + 0x61, 0x31, 0x2e, 0x44, 0x65, 0x74, 0x65, 0x72, 0x6d, 0x69, 0x6e, 0x65, 0x53, 0x74, 0x72, 0x61, + 0x74, 0x65, 0x67, 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0xa4, + 0x01, 0x0a, 0x17, 0x42, 0x75, 0x69, 0x6c, 0x64, 0x50, 0x69, 0x70, 0x65, 0x6c, 0x69, 0x6e, 0x65, + 0x53, 0x79, 0x6e, 0x63, 0x53, 0x74, 0x61, 0x67, 0x65, 0x73, 0x12, 0x42, 0x2e, 0x67, 0x72, 0x70, + 0x63, 0x2e, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x2e, 0x64, 0x65, 0x70, 0x6c, 0x6f, 0x79, 0x6d, + 0x65, 0x6e, 0x74, 0x61, 0x70, 0x69, 0x2e, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2e, + 0x42, 0x75, 0x69, 0x6c, 0x64, 0x50, 0x69, 0x70, 0x65, 0x6c, 0x69, 0x6e, 0x65, 0x53, 0x79, 0x6e, + 0x63, 0x53, 0x74, 0x61, 0x67, 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x43, + 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x2e, 0x64, 0x65, 0x70, + 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x70, 0x69, 0x2e, 0x76, 0x31, 0x61, 0x6c, 0x70, + 0x68, 0x61, 0x31, 0x2e, 0x42, 0x75, 0x69, 0x6c, 0x64, 0x50, 0x69, 0x70, 0x65, 0x6c, 0x69, 0x6e, + 0x65, 0x53, 0x79, 0x6e, 0x63, 0x53, 0x74, 0x61, 0x67, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, + 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x9b, 0x01, 0x0a, 0x14, 0x42, 0x75, 0x69, 0x6c, 0x64, 0x51, + 0x75, 0x69, 0x63, 0x6b, 0x53, 0x79, 0x6e, 0x63, 0x53, 0x74, 0x61, 0x67, 0x65, 0x73, 0x12, 0x3f, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x2e, 0x64, 0x65, 0x70, 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x70, 0x69, 0x2e, 0x76, 0x31, 0x61, 0x6c, 0x70, - 0x68, 0x61, 0x31, 0x2e, 0x44, 0x65, 0x74, 0x65, 0x72, 0x6d, 0x69, 0x6e, 0x65, 0x53, 0x74, 0x72, - 0x61, 0x74, 0x65, 0x67, 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, - 0xa4, 0x01, 0x0a, 0x17, 0x42, 0x75, 0x69, 0x6c, 0x64, 0x50, 0x69, 0x70, 0x65, 0x6c, 0x69, 0x6e, - 0x65, 0x53, 0x79, 0x6e, 0x63, 0x53, 0x74, 0x61, 0x67, 0x65, 0x73, 0x12, 0x42, 0x2e, 0x67, 0x72, - 0x70, 0x63, 0x2e, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x2e, 0x64, 0x65, 0x70, 0x6c, 0x6f, 0x79, - 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x70, 0x69, 0x2e, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, - 0x2e, 0x42, 0x75, 0x69, 0x6c, 0x64, 0x50, 0x69, 0x70, 0x65, 0x6c, 0x69, 0x6e, 0x65, 0x53, 0x79, + 0x68, 0x61, 0x31, 0x2e, 0x42, 0x75, 0x69, 0x6c, 0x64, 0x51, 0x75, 0x69, 0x63, 0x6b, 0x53, 0x79, 0x6e, 0x63, 0x53, 0x74, 0x61, 0x67, 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, - 0x43, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x2e, 0x64, 0x65, - 0x70, 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x70, 0x69, 0x2e, 0x76, 0x31, 0x61, 0x6c, - 0x70, 0x68, 0x61, 0x31, 0x2e, 0x42, 0x75, 0x69, 0x6c, 0x64, 0x50, 0x69, 0x70, 0x65, 0x6c, 0x69, - 0x6e, 0x65, 0x53, 0x79, 0x6e, 0x63, 0x53, 0x74, 0x61, 0x67, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, - 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x9b, 0x01, 0x0a, 0x14, 0x42, 0x75, 0x69, 0x6c, 0x64, - 0x51, 0x75, 0x69, 0x63, 0x6b, 0x53, 0x79, 0x6e, 0x63, 0x53, 0x74, 0x61, 0x67, 0x65, 0x73, 0x12, - 0x3f, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x2e, 0x64, 0x65, + 0x40, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x2e, 0x64, 0x65, 0x70, 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x70, 0x69, 0x2e, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2e, 0x42, 0x75, 0x69, 0x6c, 0x64, 0x51, 0x75, 0x69, 0x63, 0x6b, 0x53, - 0x79, 0x6e, 0x63, 0x53, 0x74, 0x61, 0x67, 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, - 0x1a, 0x40, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x2e, 0x64, - 0x65, 0x70, 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x70, 0x69, 0x2e, 0x76, 0x31, 0x61, - 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2e, 0x42, 0x75, 0x69, 0x6c, 0x64, 0x51, 0x75, 0x69, 0x63, 0x6b, - 0x53, 0x79, 0x6e, 0x63, 0x53, 0x74, 0x61, 0x67, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, - 0x73, 0x65, 0x22, 0x00, 0x42, 0x3e, 0x5a, 0x3c, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, - 0x6f, 0x6d, 0x2f, 0x70, 0x69, 0x70, 0x65, 0x2d, 0x63, 0x64, 0x2f, 0x70, 0x69, 0x70, 0x65, 0x63, - 0x64, 0x2f, 0x70, 0x6b, 0x67, 0x2f, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x2f, 0x61, 0x70, 0x69, - 0x2f, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2f, 0x64, 0x65, 0x70, 0x6c, 0x6f, 0x79, - 0x6d, 0x65, 0x6e, 0x74, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, + 0x79, 0x6e, 0x63, 0x53, 0x74, 0x61, 0x67, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, + 0x65, 0x22, 0x00, 0x42, 0x3e, 0x5a, 0x3c, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, + 0x6d, 0x2f, 0x70, 0x69, 0x70, 0x65, 0x2d, 0x63, 0x64, 0x2f, 0x70, 0x69, 0x70, 0x65, 0x63, 0x64, + 0x2f, 0x70, 0x6b, 0x67, 0x2f, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x2f, 0x61, 0x70, 0x69, 0x2f, + 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2f, 0x64, 0x65, 0x70, 0x6c, 0x6f, 0x79, 0x6d, + 0x65, 0x6e, 0x74, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, } var ( diff --git a/pkg/plugin/api/v1alpha1/deployment/api.pb.validate.go b/pkg/plugin/api/v1alpha1/deployment/api.pb.validate.go index b076737b0d..cf678a4453 100644 --- a/pkg/plugin/api/v1alpha1/deployment/api.pb.validate.go +++ b/pkg/plugin/api/v1alpha1/deployment/api.pb.validate.go @@ -863,8 +863,6 @@ func (m *BuildQuickSyncStagesRequest) validate(all bool) error { var errors []error - // no validation rules for StageIndex - // no validation rules for Rollback if len(errors) > 0 { diff --git a/pkg/plugin/api/v1alpha1/deployment/api.proto b/pkg/plugin/api/v1alpha1/deployment/api.proto index 42246ad2bc..062b5426e0 100644 --- a/pkg/plugin/api/v1alpha1/deployment/api.proto +++ b/pkg/plugin/api/v1alpha1/deployment/api.proto @@ -83,8 +83,7 @@ message BuildPipelineSyncStagesResponse { } message BuildQuickSyncStagesRequest { - int32 stage_index = 1; - bool rollback = 2; + bool rollback = 1; } message BuildQuickSyncStagesResponse { From 6cc8e88f97d237c61a9ca8d5888f5464366f1584 Mon Sep 17 00:00:00 2001 From: Yoshiki Fujikane <40124947+ffjlabo@users.noreply.github.com> Date: Fri, 25 Oct 2024 18:06:50 +0900 Subject: [PATCH 73/84] Add event context (#5295) * Add contexts to the RegisterEventRequest Signed-off-by: Yoshiki Fujikane * Add contexts to model.Event Signed-off-by: Yoshiki Fujikane * Store event context in Control Plane Signed-off-by: Yoshiki Fujikane * Add trailers when commiting on event watcher Signed-off-by: Yoshiki Fujikane * Fix for failed build Signed-off-by: Yoshiki Fujikane --------- Signed-off-by: Yoshiki Fujikane --- pkg/app/pipectl/cmd/event/register.go | 15 +- pkg/app/piped/eventwatcher/eventwatcher.go | 16 +- pkg/app/pipedv1/eventwatcher/eventwatcher.go | 16 +- pkg/app/server/grpcapi/api.go | 1 + .../server/service/apiservice/service.pb.go | 634 +++++++++--------- .../service/apiservice/service.pb.validate.go | 2 + .../server/service/apiservice/service.proto | 1 + pkg/git/gittest/git.mock.go | 8 +- pkg/git/repo.go | 15 +- pkg/git/repo_test.go | 9 +- pkg/model/event.pb.go | 80 ++- pkg/model/event.pb.validate.go | 2 + pkg/model/event.proto | 6 +- web/model/event_pb.d.ts | 4 + web/model/event_pb.js | 34 + 15 files changed, 472 insertions(+), 371 deletions(-) diff --git a/pkg/app/pipectl/cmd/event/register.go b/pkg/app/pipectl/cmd/event/register.go index fe911092f7..fd2ed10711 100644 --- a/pkg/app/pipectl/cmd/event/register.go +++ b/pkg/app/pipectl/cmd/event/register.go @@ -28,9 +28,10 @@ import ( type register struct { root *command - name string - data string - labels map[string]string + name string + data string + labels map[string]string + contexts map[string]string } func newRegisterCommand(root *command) *cobra.Command { @@ -46,6 +47,7 @@ func newRegisterCommand(root *command) *cobra.Command { cmd.Flags().StringVar(&r.name, "name", r.name, "The name of event.") cmd.Flags().StringVar(&r.data, "data", r.data, "The string value of event data.") cmd.Flags().StringToStringVar(&r.labels, "labels", r.labels, "The list of labels for event. Format: key=value,key2=value2") + cmd.Flags().StringToStringVar(&r.contexts, "contexts", r.contexts, "The list of the values for the event context. Format: key=value,key2=value2") cmd.MarkFlagRequired("name") cmd.MarkFlagRequired("data") @@ -61,9 +63,10 @@ func (r *register) run(ctx context.Context, input cli.Input) error { defer cli.Close() req := &apiservice.RegisterEventRequest{ - Name: r.name, - Data: r.data, - Labels: r.labels, + Name: r.name, + Data: r.data, + Labels: r.labels, + Contexts: r.contexts, } res, err := cli.RegisterEvent(ctx, req) diff --git a/pkg/app/piped/eventwatcher/eventwatcher.go b/pkg/app/piped/eventwatcher/eventwatcher.go index 4670d013f9..46755ae528 100644 --- a/pkg/app/piped/eventwatcher/eventwatcher.go +++ b/pkg/app/piped/eventwatcher/eventwatcher.go @@ -21,6 +21,7 @@ import ( "context" "errors" "fmt" + "maps" "os" "path/filepath" "regexp/syntax" @@ -386,7 +387,7 @@ func (w *watcher) execute(ctx context.Context, repo git.Repo, repoID string, eve } switch handler.Type { case config.EventWatcherHandlerTypeGitUpdate: - branchName, err := w.commitFiles(ctx, latestEvent.Data, matcher.Name, handler.Config.CommitMessage, e.GitPath, handler.Config.Replacements, tmpRepo, handler.Config.MakePullRequest) + branchName, err := w.commitFiles(ctx, latestEvent, matcher.Name, handler.Config.CommitMessage, e.GitPath, handler.Config.Replacements, tmpRepo, handler.Config.MakePullRequest) if err != nil { w.logger.Error("failed to commit outdated files", zap.Error(err)) handledEvent := &pipedservice.ReportEventStatusesRequest_Event{ @@ -538,7 +539,7 @@ func (w *watcher) updateValues(ctx context.Context, repo git.Repo, repoID string }) continue } - _, err := w.commitFiles(ctx, latestEvent.Data, e.Name, commitMsg, "", e.Replacements, tmpRepo, false) + _, err := w.commitFiles(ctx, latestEvent, e.Name, commitMsg, "", e.Replacements, tmpRepo, false) if err != nil { w.logger.Error("failed to commit outdated files", zap.Error(err)) handledEvents = append(handledEvents, &pipedservice.ReportEventStatusesRequest_Event{ @@ -602,7 +603,7 @@ func (w *watcher) updateValues(ctx context.Context, repo git.Repo, repoID string } // commitFiles commits changes if the data in Git is different from the latest event. -func (w *watcher) commitFiles(ctx context.Context, latestData, eventName, commitMsg, gitPath string, replacements []config.EventWatcherReplacement, repo git.Repo, newBranch bool) (string, error) { +func (w *watcher) commitFiles(ctx context.Context, latestEvent *model.Event, eventName, commitMsg, gitPath string, replacements []config.EventWatcherReplacement, repo git.Repo, newBranch bool) (string, error) { // Determine files to be changed by comparing with the latest event. changes := make(map[string][]byte, len(replacements)) for _, r := range replacements { @@ -619,13 +620,13 @@ func (w *watcher) commitFiles(ctx context.Context, latestData, eventName, commit path := filepath.Join(repo.GetPath(), filePath) switch { case r.YAMLField != "": - newContent, upToDate, err = modifyYAML(path, r.YAMLField, latestData) + newContent, upToDate, err = modifyYAML(path, r.YAMLField, latestEvent.Data) case r.JSONField != "": // TODO: Empower Event watcher to parse JSON format case r.HCLField != "": // TODO: Empower Event watcher to parse HCL format case r.Regex != "": - newContent, upToDate, err = modifyText(path, r.Regex, latestData) + newContent, upToDate, err = modifyText(path, r.Regex, latestEvent.Data) } if err != nil { return "", err @@ -644,12 +645,13 @@ func (w *watcher) commitFiles(ctx context.Context, latestData, eventName, commit } args := argsTemplate{ - Value: latestData, + Value: latestEvent.Data, EventName: eventName, } commitMsg = parseCommitMsg(commitMsg, args) branch := makeBranchName(newBranch, eventName, repo.GetClonedBranch()) - if err := repo.CommitChanges(ctx, branch, commitMsg, newBranch, changes); err != nil { + trailers := maps.Clone(latestEvent.Contexts) + if err := repo.CommitChanges(ctx, branch, commitMsg, newBranch, changes, trailers); err != nil { return "", fmt.Errorf("failed to perform git commit: %w", err) } w.logger.Info(fmt.Sprintf("event watcher will update values of Event %q", eventName)) diff --git a/pkg/app/pipedv1/eventwatcher/eventwatcher.go b/pkg/app/pipedv1/eventwatcher/eventwatcher.go index 4670d013f9..46755ae528 100644 --- a/pkg/app/pipedv1/eventwatcher/eventwatcher.go +++ b/pkg/app/pipedv1/eventwatcher/eventwatcher.go @@ -21,6 +21,7 @@ import ( "context" "errors" "fmt" + "maps" "os" "path/filepath" "regexp/syntax" @@ -386,7 +387,7 @@ func (w *watcher) execute(ctx context.Context, repo git.Repo, repoID string, eve } switch handler.Type { case config.EventWatcherHandlerTypeGitUpdate: - branchName, err := w.commitFiles(ctx, latestEvent.Data, matcher.Name, handler.Config.CommitMessage, e.GitPath, handler.Config.Replacements, tmpRepo, handler.Config.MakePullRequest) + branchName, err := w.commitFiles(ctx, latestEvent, matcher.Name, handler.Config.CommitMessage, e.GitPath, handler.Config.Replacements, tmpRepo, handler.Config.MakePullRequest) if err != nil { w.logger.Error("failed to commit outdated files", zap.Error(err)) handledEvent := &pipedservice.ReportEventStatusesRequest_Event{ @@ -538,7 +539,7 @@ func (w *watcher) updateValues(ctx context.Context, repo git.Repo, repoID string }) continue } - _, err := w.commitFiles(ctx, latestEvent.Data, e.Name, commitMsg, "", e.Replacements, tmpRepo, false) + _, err := w.commitFiles(ctx, latestEvent, e.Name, commitMsg, "", e.Replacements, tmpRepo, false) if err != nil { w.logger.Error("failed to commit outdated files", zap.Error(err)) handledEvents = append(handledEvents, &pipedservice.ReportEventStatusesRequest_Event{ @@ -602,7 +603,7 @@ func (w *watcher) updateValues(ctx context.Context, repo git.Repo, repoID string } // commitFiles commits changes if the data in Git is different from the latest event. -func (w *watcher) commitFiles(ctx context.Context, latestData, eventName, commitMsg, gitPath string, replacements []config.EventWatcherReplacement, repo git.Repo, newBranch bool) (string, error) { +func (w *watcher) commitFiles(ctx context.Context, latestEvent *model.Event, eventName, commitMsg, gitPath string, replacements []config.EventWatcherReplacement, repo git.Repo, newBranch bool) (string, error) { // Determine files to be changed by comparing with the latest event. changes := make(map[string][]byte, len(replacements)) for _, r := range replacements { @@ -619,13 +620,13 @@ func (w *watcher) commitFiles(ctx context.Context, latestData, eventName, commit path := filepath.Join(repo.GetPath(), filePath) switch { case r.YAMLField != "": - newContent, upToDate, err = modifyYAML(path, r.YAMLField, latestData) + newContent, upToDate, err = modifyYAML(path, r.YAMLField, latestEvent.Data) case r.JSONField != "": // TODO: Empower Event watcher to parse JSON format case r.HCLField != "": // TODO: Empower Event watcher to parse HCL format case r.Regex != "": - newContent, upToDate, err = modifyText(path, r.Regex, latestData) + newContent, upToDate, err = modifyText(path, r.Regex, latestEvent.Data) } if err != nil { return "", err @@ -644,12 +645,13 @@ func (w *watcher) commitFiles(ctx context.Context, latestData, eventName, commit } args := argsTemplate{ - Value: latestData, + Value: latestEvent.Data, EventName: eventName, } commitMsg = parseCommitMsg(commitMsg, args) branch := makeBranchName(newBranch, eventName, repo.GetClonedBranch()) - if err := repo.CommitChanges(ctx, branch, commitMsg, newBranch, changes); err != nil { + trailers := maps.Clone(latestEvent.Contexts) + if err := repo.CommitChanges(ctx, branch, commitMsg, newBranch, changes, trailers); err != nil { return "", fmt.Errorf("failed to perform git commit: %w", err) } w.logger.Info(fmt.Sprintf("event watcher will update values of Event %q", eventName)) diff --git a/pkg/app/server/grpcapi/api.go b/pkg/app/server/grpcapi/api.go index 7e77ed763d..5b95226818 100644 --- a/pkg/app/server/grpcapi/api.go +++ b/pkg/app/server/grpcapi/api.go @@ -792,6 +792,7 @@ func (a *API) RegisterEvent(ctx context.Context, req *apiservice.RegisterEventRe Name: req.Name, Data: req.Data, Labels: req.Labels, + Contexts: req.Contexts, EventKey: model.MakeEventKey(req.Name, req.Labels), ProjectId: key.ProjectId, Status: model.EventStatus_EVENT_NOT_HANDLED, diff --git a/pkg/app/server/service/apiservice/service.pb.go b/pkg/app/server/service/apiservice/service.pb.go index 481ea247db..1af9b8073b 100644 --- a/pkg/app/server/service/apiservice/service.pb.go +++ b/pkg/app/server/service/apiservice/service.pb.go @@ -1821,9 +1821,10 @@ type RegisterEventRequest struct { sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields - Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` - Data string `protobuf:"bytes,2,opt,name=data,proto3" json:"data,omitempty"` - Labels map[string]string `protobuf:"bytes,3,rep,name=labels,proto3" json:"labels,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"bytes,2,opt,name=value,proto3"` + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + Data string `protobuf:"bytes,2,opt,name=data,proto3" json:"data,omitempty"` + Labels map[string]string `protobuf:"bytes,3,rep,name=labels,proto3" json:"labels,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"bytes,2,opt,name=value,proto3"` + Contexts map[string]string `protobuf:"bytes,4,rep,name=contexts,proto3" json:"contexts,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"bytes,2,opt,name=value,proto3"` } func (x *RegisterEventRequest) Reset() { @@ -1879,6 +1880,13 @@ func (x *RegisterEventRequest) GetLabels() map[string]string { return nil } +func (x *RegisterEventRequest) GetContexts() map[string]string { + if x != nil { + return x.Contexts + } + return nil +} + type RegisterEventResponse struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache @@ -2643,7 +2651,7 @@ var file_pkg_app_server_service_apiservice_service_proto_rawDesc = []byte{ 0x01, 0x20, 0x01, 0x28, 0x09, 0x42, 0x07, 0xfa, 0x42, 0x04, 0x72, 0x02, 0x10, 0x01, 0x52, 0x07, 0x70, 0x69, 0x70, 0x65, 0x64, 0x49, 0x64, 0x22, 0x16, 0x0a, 0x14, 0x44, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x50, 0x69, 0x70, 0x65, 0x64, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, - 0xf8, 0x01, 0x0a, 0x14, 0x52, 0x65, 0x67, 0x69, 0x73, 0x74, 0x65, 0x72, 0x45, 0x76, 0x65, 0x6e, + 0x8e, 0x03, 0x0a, 0x14, 0x52, 0x65, 0x67, 0x69, 0x73, 0x74, 0x65, 0x72, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1b, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x42, 0x07, 0xfa, 0x42, 0x04, 0x72, 0x02, 0x10, 0x01, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x1b, 0x0a, 0x04, 0x64, 0x61, 0x74, 0x61, 0x18, 0x02, 0x20, @@ -2654,250 +2662,260 @@ var file_pkg_app_server_service_apiservice_service_proto_rawDesc = []byte{ 0x69, 0x73, 0x74, 0x65, 0x72, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x2e, 0x4c, 0x61, 0x62, 0x65, 0x6c, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x42, 0x18, 0xfa, 0x42, 0x09, 0x9a, 0x01, 0x06, 0x22, 0x04, 0x72, 0x02, 0x10, 0x01, 0xfa, 0x42, 0x09, 0x9a, 0x01, - 0x06, 0x2a, 0x04, 0x72, 0x02, 0x10, 0x01, 0x52, 0x06, 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x73, 0x1a, - 0x39, 0x0a, 0x0b, 0x4c, 0x61, 0x62, 0x65, 0x6c, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, - 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, - 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0x3b, 0x0a, 0x15, 0x52, 0x65, - 0x67, 0x69, 0x73, 0x74, 0x65, 0x72, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, - 0x6e, 0x73, 0x65, 0x12, 0x22, 0x0a, 0x08, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, - 0x01, 0x20, 0x01, 0x28, 0x09, 0x42, 0x07, 0xfa, 0x42, 0x04, 0x72, 0x02, 0x10, 0x01, 0x52, 0x07, - 0x65, 0x76, 0x65, 0x6e, 0x74, 0x49, 0x64, 0x22, 0xed, 0x01, 0x0a, 0x19, 0x52, 0x65, 0x71, 0x75, - 0x65, 0x73, 0x74, 0x50, 0x6c, 0x61, 0x6e, 0x50, 0x72, 0x65, 0x76, 0x69, 0x65, 0x77, 0x52, 0x65, - 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x2f, 0x0a, 0x0f, 0x72, 0x65, 0x70, 0x6f, 0x5f, 0x72, 0x65, - 0x6d, 0x6f, 0x74, 0x65, 0x5f, 0x75, 0x72, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x42, 0x07, - 0xfa, 0x42, 0x04, 0x72, 0x02, 0x10, 0x01, 0x52, 0x0d, 0x72, 0x65, 0x70, 0x6f, 0x52, 0x65, 0x6d, - 0x6f, 0x74, 0x65, 0x55, 0x72, 0x6c, 0x12, 0x28, 0x0a, 0x0b, 0x68, 0x65, 0x61, 0x64, 0x5f, 0x62, - 0x72, 0x61, 0x6e, 0x63, 0x68, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x42, 0x07, 0xfa, 0x42, 0x04, - 0x72, 0x02, 0x10, 0x01, 0x52, 0x0a, 0x68, 0x65, 0x61, 0x64, 0x42, 0x72, 0x61, 0x6e, 0x63, 0x68, - 0x12, 0x28, 0x0a, 0x0b, 0x68, 0x65, 0x61, 0x64, 0x5f, 0x63, 0x6f, 0x6d, 0x6d, 0x69, 0x74, 0x18, - 0x03, 0x20, 0x01, 0x28, 0x09, 0x42, 0x07, 0xfa, 0x42, 0x04, 0x72, 0x02, 0x10, 0x01, 0x52, 0x0a, - 0x68, 0x65, 0x61, 0x64, 0x43, 0x6f, 0x6d, 0x6d, 0x69, 0x74, 0x12, 0x28, 0x0a, 0x0b, 0x62, 0x61, - 0x73, 0x65, 0x5f, 0x62, 0x72, 0x61, 0x6e, 0x63, 0x68, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x42, - 0x07, 0xfa, 0x42, 0x04, 0x72, 0x02, 0x10, 0x01, 0x52, 0x0a, 0x62, 0x61, 0x73, 0x65, 0x42, 0x72, - 0x61, 0x6e, 0x63, 0x68, 0x12, 0x21, 0x0a, 0x07, 0x74, 0x69, 0x6d, 0x65, 0x6f, 0x75, 0x74, 0x18, - 0x05, 0x20, 0x01, 0x28, 0x03, 0x42, 0x07, 0xfa, 0x42, 0x04, 0x22, 0x02, 0x28, 0x00, 0x52, 0x07, - 0x74, 0x69, 0x6d, 0x65, 0x6f, 0x75, 0x74, 0x22, 0x38, 0x0a, 0x1a, 0x52, 0x65, 0x71, 0x75, 0x65, - 0x73, 0x74, 0x50, 0x6c, 0x61, 0x6e, 0x50, 0x72, 0x65, 0x76, 0x69, 0x65, 0x77, 0x52, 0x65, 0x73, - 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x1a, 0x0a, 0x08, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, - 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x09, 0x52, 0x08, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, - 0x73, 0x22, 0x70, 0x0a, 0x1c, 0x47, 0x65, 0x74, 0x50, 0x6c, 0x61, 0x6e, 0x50, 0x72, 0x65, 0x76, - 0x69, 0x65, 0x77, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, - 0x74, 0x12, 0x1a, 0x0a, 0x08, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x73, 0x18, 0x01, 0x20, - 0x03, 0x28, 0x09, 0x52, 0x08, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x73, 0x12, 0x34, 0x0a, - 0x16, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x5f, 0x68, 0x61, 0x6e, 0x64, 0x6c, 0x65, 0x5f, - 0x74, 0x69, 0x6d, 0x65, 0x6f, 0x75, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x14, 0x63, - 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x48, 0x61, 0x6e, 0x64, 0x6c, 0x65, 0x54, 0x69, 0x6d, 0x65, - 0x6f, 0x75, 0x74, 0x22, 0x5a, 0x0a, 0x1d, 0x47, 0x65, 0x74, 0x50, 0x6c, 0x61, 0x6e, 0x50, 0x72, - 0x65, 0x76, 0x69, 0x65, 0x77, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x73, 0x52, 0x65, 0x73, 0x70, - 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x39, 0x0a, 0x07, 0x72, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x73, 0x18, - 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x2e, 0x50, 0x6c, - 0x61, 0x6e, 0x50, 0x72, 0x65, 0x76, 0x69, 0x65, 0x77, 0x43, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, - 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x52, 0x07, 0x72, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x73, 0x22, - 0x84, 0x01, 0x0a, 0x0e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, - 0x73, 0x74, 0x12, 0x25, 0x0a, 0x09, 0x70, 0x6c, 0x61, 0x69, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x18, - 0x01, 0x20, 0x01, 0x28, 0x09, 0x42, 0x07, 0xfa, 0x42, 0x04, 0x72, 0x02, 0x10, 0x01, 0x52, 0x09, - 0x70, 0x6c, 0x61, 0x69, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x12, 0x22, 0x0a, 0x08, 0x70, 0x69, 0x70, - 0x65, 0x64, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x42, 0x07, 0xfa, 0x42, 0x04, - 0x72, 0x02, 0x10, 0x01, 0x52, 0x07, 0x70, 0x69, 0x70, 0x65, 0x64, 0x49, 0x64, 0x12, 0x27, 0x0a, - 0x0f, 0x62, 0x61, 0x73, 0x65, 0x36, 0x34, 0x5f, 0x65, 0x6e, 0x63, 0x6f, 0x64, 0x69, 0x6e, 0x67, - 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0e, 0x62, 0x61, 0x73, 0x65, 0x36, 0x34, 0x45, 0x6e, - 0x63, 0x6f, 0x64, 0x69, 0x6e, 0x67, 0x22, 0x3a, 0x0a, 0x0f, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, - 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x27, 0x0a, 0x0a, 0x63, 0x69, 0x70, - 0x68, 0x65, 0x72, 0x74, 0x65, 0x78, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x42, 0x07, 0xfa, - 0x42, 0x04, 0x72, 0x02, 0x10, 0x01, 0x52, 0x0a, 0x63, 0x69, 0x70, 0x68, 0x65, 0x72, 0x74, 0x65, - 0x78, 0x74, 0x22, 0x51, 0x0a, 0x08, 0x53, 0x74, 0x61, 0x67, 0x65, 0x4c, 0x6f, 0x67, 0x12, 0x27, - 0x0a, 0x06, 0x62, 0x6c, 0x6f, 0x63, 0x6b, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x0f, - 0x2e, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x2e, 0x4c, 0x6f, 0x67, 0x42, 0x6c, 0x6f, 0x63, 0x6b, 0x52, - 0x06, 0x62, 0x6c, 0x6f, 0x63, 0x6b, 0x73, 0x12, 0x1c, 0x0a, 0x09, 0x63, 0x6f, 0x6d, 0x70, 0x6c, - 0x65, 0x74, 0x65, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x09, 0x63, 0x6f, 0x6d, 0x70, - 0x6c, 0x65, 0x74, 0x65, 0x64, 0x22, 0x44, 0x0a, 0x14, 0x4c, 0x69, 0x73, 0x74, 0x53, 0x74, 0x61, - 0x67, 0x65, 0x4c, 0x6f, 0x67, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x2c, 0x0a, - 0x0d, 0x64, 0x65, 0x70, 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x01, - 0x20, 0x01, 0x28, 0x09, 0x42, 0x07, 0xfa, 0x42, 0x04, 0x72, 0x02, 0x10, 0x01, 0x52, 0x0c, 0x64, - 0x65, 0x70, 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x49, 0x64, 0x22, 0xd6, 0x01, 0x0a, 0x15, - 0x4c, 0x69, 0x73, 0x74, 0x53, 0x74, 0x61, 0x67, 0x65, 0x4c, 0x6f, 0x67, 0x73, 0x52, 0x65, 0x73, - 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x5c, 0x0a, 0x0a, 0x73, 0x74, 0x61, 0x67, 0x65, 0x5f, 0x6c, - 0x6f, 0x67, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x3d, 0x2e, 0x67, 0x72, 0x70, 0x63, - 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x61, 0x70, 0x69, 0x73, 0x65, 0x72, 0x76, - 0x69, 0x63, 0x65, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x53, 0x74, 0x61, 0x67, 0x65, 0x4c, 0x6f, 0x67, - 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2e, 0x53, 0x74, 0x61, 0x67, 0x65, 0x4c, - 0x6f, 0x67, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x09, 0x73, 0x74, 0x61, 0x67, 0x65, 0x4c, - 0x6f, 0x67, 0x73, 0x1a, 0x5f, 0x0a, 0x0e, 0x53, 0x74, 0x61, 0x67, 0x65, 0x4c, 0x6f, 0x67, 0x73, - 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x37, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, - 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x21, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x73, 0x65, - 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x61, 0x70, 0x69, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, - 0x2e, 0x53, 0x74, 0x61, 0x67, 0x65, 0x4c, 0x6f, 0x67, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, - 0x3a, 0x02, 0x38, 0x01, 0x32, 0xc0, 0x14, 0x0a, 0x0a, 0x41, 0x50, 0x49, 0x53, 0x65, 0x72, 0x76, - 0x69, 0x63, 0x65, 0x12, 0x73, 0x0a, 0x0e, 0x41, 0x64, 0x64, 0x41, 0x70, 0x70, 0x6c, 0x69, 0x63, - 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x2e, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x73, 0x65, 0x72, - 0x76, 0x69, 0x63, 0x65, 0x2e, 0x61, 0x70, 0x69, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, - 0x41, 0x64, 0x64, 0x41, 0x70, 0x70, 0x6c, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, - 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2f, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x73, 0x65, 0x72, - 0x76, 0x69, 0x63, 0x65, 0x2e, 0x61, 0x70, 0x69, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, - 0x41, 0x64, 0x64, 0x41, 0x70, 0x70, 0x6c, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, - 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x76, 0x0a, 0x0f, 0x53, 0x79, 0x6e, 0x63, - 0x41, 0x70, 0x70, 0x6c, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x2f, 0x2e, 0x67, 0x72, - 0x70, 0x63, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x61, 0x70, 0x69, 0x73, 0x65, - 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x53, 0x79, 0x6e, 0x63, 0x41, 0x70, 0x70, 0x6c, 0x69, 0x63, - 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x30, 0x2e, 0x67, + 0x06, 0x2a, 0x04, 0x72, 0x02, 0x10, 0x01, 0x52, 0x06, 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x73, 0x12, + 0x57, 0x0a, 0x08, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x73, 0x18, 0x04, 0x20, 0x03, 0x28, + 0x0b, 0x32, 0x3b, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, + 0x2e, 0x61, 0x70, 0x69, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x67, 0x69, + 0x73, 0x74, 0x65, 0x72, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, + 0x2e, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x08, + 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x73, 0x1a, 0x39, 0x0a, 0x0b, 0x4c, 0x61, 0x62, 0x65, + 0x6c, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, + 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, + 0x02, 0x38, 0x01, 0x1a, 0x3b, 0x0a, 0x0d, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x73, 0x45, + 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, + 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, + 0x22, 0x3b, 0x0a, 0x15, 0x52, 0x65, 0x67, 0x69, 0x73, 0x74, 0x65, 0x72, 0x45, 0x76, 0x65, 0x6e, + 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x22, 0x0a, 0x08, 0x65, 0x76, 0x65, + 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x42, 0x07, 0xfa, 0x42, 0x04, + 0x72, 0x02, 0x10, 0x01, 0x52, 0x07, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x49, 0x64, 0x22, 0xed, 0x01, + 0x0a, 0x19, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x50, 0x6c, 0x61, 0x6e, 0x50, 0x72, 0x65, + 0x76, 0x69, 0x65, 0x77, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x2f, 0x0a, 0x0f, 0x72, + 0x65, 0x70, 0x6f, 0x5f, 0x72, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x5f, 0x75, 0x72, 0x6c, 0x18, 0x01, + 0x20, 0x01, 0x28, 0x09, 0x42, 0x07, 0xfa, 0x42, 0x04, 0x72, 0x02, 0x10, 0x01, 0x52, 0x0d, 0x72, + 0x65, 0x70, 0x6f, 0x52, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x55, 0x72, 0x6c, 0x12, 0x28, 0x0a, 0x0b, + 0x68, 0x65, 0x61, 0x64, 0x5f, 0x62, 0x72, 0x61, 0x6e, 0x63, 0x68, 0x18, 0x02, 0x20, 0x01, 0x28, + 0x09, 0x42, 0x07, 0xfa, 0x42, 0x04, 0x72, 0x02, 0x10, 0x01, 0x52, 0x0a, 0x68, 0x65, 0x61, 0x64, + 0x42, 0x72, 0x61, 0x6e, 0x63, 0x68, 0x12, 0x28, 0x0a, 0x0b, 0x68, 0x65, 0x61, 0x64, 0x5f, 0x63, + 0x6f, 0x6d, 0x6d, 0x69, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x42, 0x07, 0xfa, 0x42, 0x04, + 0x72, 0x02, 0x10, 0x01, 0x52, 0x0a, 0x68, 0x65, 0x61, 0x64, 0x43, 0x6f, 0x6d, 0x6d, 0x69, 0x74, + 0x12, 0x28, 0x0a, 0x0b, 0x62, 0x61, 0x73, 0x65, 0x5f, 0x62, 0x72, 0x61, 0x6e, 0x63, 0x68, 0x18, + 0x04, 0x20, 0x01, 0x28, 0x09, 0x42, 0x07, 0xfa, 0x42, 0x04, 0x72, 0x02, 0x10, 0x01, 0x52, 0x0a, + 0x62, 0x61, 0x73, 0x65, 0x42, 0x72, 0x61, 0x6e, 0x63, 0x68, 0x12, 0x21, 0x0a, 0x07, 0x74, 0x69, + 0x6d, 0x65, 0x6f, 0x75, 0x74, 0x18, 0x05, 0x20, 0x01, 0x28, 0x03, 0x42, 0x07, 0xfa, 0x42, 0x04, + 0x22, 0x02, 0x28, 0x00, 0x52, 0x07, 0x74, 0x69, 0x6d, 0x65, 0x6f, 0x75, 0x74, 0x22, 0x38, 0x0a, + 0x1a, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x50, 0x6c, 0x61, 0x6e, 0x50, 0x72, 0x65, 0x76, + 0x69, 0x65, 0x77, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x1a, 0x0a, 0x08, 0x63, + 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x09, 0x52, 0x08, 0x63, + 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x73, 0x22, 0x70, 0x0a, 0x1c, 0x47, 0x65, 0x74, 0x50, 0x6c, + 0x61, 0x6e, 0x50, 0x72, 0x65, 0x76, 0x69, 0x65, 0x77, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x73, + 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1a, 0x0a, 0x08, 0x63, 0x6f, 0x6d, 0x6d, 0x61, + 0x6e, 0x64, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x09, 0x52, 0x08, 0x63, 0x6f, 0x6d, 0x6d, 0x61, + 0x6e, 0x64, 0x73, 0x12, 0x34, 0x0a, 0x16, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x5f, 0x68, + 0x61, 0x6e, 0x64, 0x6c, 0x65, 0x5f, 0x74, 0x69, 0x6d, 0x65, 0x6f, 0x75, 0x74, 0x18, 0x02, 0x20, + 0x01, 0x28, 0x03, 0x52, 0x14, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x48, 0x61, 0x6e, 0x64, + 0x6c, 0x65, 0x54, 0x69, 0x6d, 0x65, 0x6f, 0x75, 0x74, 0x22, 0x5a, 0x0a, 0x1d, 0x47, 0x65, 0x74, + 0x50, 0x6c, 0x61, 0x6e, 0x50, 0x72, 0x65, 0x76, 0x69, 0x65, 0x77, 0x52, 0x65, 0x73, 0x75, 0x6c, + 0x74, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x39, 0x0a, 0x07, 0x72, 0x65, + 0x73, 0x75, 0x6c, 0x74, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x6d, 0x6f, + 0x64, 0x65, 0x6c, 0x2e, 0x50, 0x6c, 0x61, 0x6e, 0x50, 0x72, 0x65, 0x76, 0x69, 0x65, 0x77, 0x43, + 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x52, 0x07, 0x72, 0x65, + 0x73, 0x75, 0x6c, 0x74, 0x73, 0x22, 0x84, 0x01, 0x0a, 0x0e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, + 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x25, 0x0a, 0x09, 0x70, 0x6c, 0x61, 0x69, + 0x6e, 0x74, 0x65, 0x78, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x42, 0x07, 0xfa, 0x42, 0x04, + 0x72, 0x02, 0x10, 0x01, 0x52, 0x09, 0x70, 0x6c, 0x61, 0x69, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x12, + 0x22, 0x0a, 0x08, 0x70, 0x69, 0x70, 0x65, 0x64, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, + 0x09, 0x42, 0x07, 0xfa, 0x42, 0x04, 0x72, 0x02, 0x10, 0x01, 0x52, 0x07, 0x70, 0x69, 0x70, 0x65, + 0x64, 0x49, 0x64, 0x12, 0x27, 0x0a, 0x0f, 0x62, 0x61, 0x73, 0x65, 0x36, 0x34, 0x5f, 0x65, 0x6e, + 0x63, 0x6f, 0x64, 0x69, 0x6e, 0x67, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0e, 0x62, 0x61, + 0x73, 0x65, 0x36, 0x34, 0x45, 0x6e, 0x63, 0x6f, 0x64, 0x69, 0x6e, 0x67, 0x22, 0x3a, 0x0a, 0x0f, + 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, + 0x27, 0x0a, 0x0a, 0x63, 0x69, 0x70, 0x68, 0x65, 0x72, 0x74, 0x65, 0x78, 0x74, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x09, 0x42, 0x07, 0xfa, 0x42, 0x04, 0x72, 0x02, 0x10, 0x01, 0x52, 0x0a, 0x63, 0x69, + 0x70, 0x68, 0x65, 0x72, 0x74, 0x65, 0x78, 0x74, 0x22, 0x51, 0x0a, 0x08, 0x53, 0x74, 0x61, 0x67, + 0x65, 0x4c, 0x6f, 0x67, 0x12, 0x27, 0x0a, 0x06, 0x62, 0x6c, 0x6f, 0x63, 0x6b, 0x73, 0x18, 0x01, + 0x20, 0x03, 0x28, 0x0b, 0x32, 0x0f, 0x2e, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x2e, 0x4c, 0x6f, 0x67, + 0x42, 0x6c, 0x6f, 0x63, 0x6b, 0x52, 0x06, 0x62, 0x6c, 0x6f, 0x63, 0x6b, 0x73, 0x12, 0x1c, 0x0a, + 0x09, 0x63, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, + 0x52, 0x09, 0x63, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x64, 0x22, 0x44, 0x0a, 0x14, 0x4c, + 0x69, 0x73, 0x74, 0x53, 0x74, 0x61, 0x67, 0x65, 0x4c, 0x6f, 0x67, 0x73, 0x52, 0x65, 0x71, 0x75, + 0x65, 0x73, 0x74, 0x12, 0x2c, 0x0a, 0x0d, 0x64, 0x65, 0x70, 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, + 0x74, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x42, 0x07, 0xfa, 0x42, 0x04, 0x72, + 0x02, 0x10, 0x01, 0x52, 0x0c, 0x64, 0x65, 0x70, 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x49, + 0x64, 0x22, 0xd6, 0x01, 0x0a, 0x15, 0x4c, 0x69, 0x73, 0x74, 0x53, 0x74, 0x61, 0x67, 0x65, 0x4c, + 0x6f, 0x67, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x5c, 0x0a, 0x0a, 0x73, + 0x74, 0x61, 0x67, 0x65, 0x5f, 0x6c, 0x6f, 0x67, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, + 0x3d, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x61, + 0x70, 0x69, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x53, 0x74, + 0x61, 0x67, 0x65, 0x4c, 0x6f, 0x67, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2e, + 0x53, 0x74, 0x61, 0x67, 0x65, 0x4c, 0x6f, 0x67, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x09, + 0x73, 0x74, 0x61, 0x67, 0x65, 0x4c, 0x6f, 0x67, 0x73, 0x1a, 0x5f, 0x0a, 0x0e, 0x53, 0x74, 0x61, + 0x67, 0x65, 0x4c, 0x6f, 0x67, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, + 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x37, 0x0a, + 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x21, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x61, 0x70, 0x69, 0x73, - 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x53, 0x79, 0x6e, 0x63, 0x41, 0x70, 0x70, 0x6c, 0x69, - 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, - 0x12, 0x73, 0x0a, 0x0e, 0x47, 0x65, 0x74, 0x41, 0x70, 0x70, 0x6c, 0x69, 0x63, 0x61, 0x74, 0x69, - 0x6f, 0x6e, 0x12, 0x2e, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, - 0x65, 0x2e, 0x61, 0x70, 0x69, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x47, 0x65, 0x74, - 0x41, 0x70, 0x70, 0x6c, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, - 0x73, 0x74, 0x1a, 0x2f, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, - 0x65, 0x2e, 0x61, 0x70, 0x69, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x47, 0x65, 0x74, - 0x41, 0x70, 0x70, 0x6c, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, - 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x79, 0x0a, 0x10, 0x4c, 0x69, 0x73, 0x74, 0x41, 0x70, 0x70, - 0x6c, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x30, 0x2e, 0x67, 0x72, 0x70, 0x63, - 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x61, 0x70, 0x69, 0x73, 0x65, 0x72, 0x76, - 0x69, 0x63, 0x65, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x41, 0x70, 0x70, 0x6c, 0x69, 0x63, 0x61, 0x74, - 0x69, 0x6f, 0x6e, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x31, 0x2e, 0x67, 0x72, + 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x53, 0x74, 0x61, 0x67, 0x65, 0x4c, 0x6f, 0x67, 0x52, + 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x32, 0xc0, 0x14, 0x0a, 0x0a, 0x41, + 0x50, 0x49, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x73, 0x0a, 0x0e, 0x41, 0x64, 0x64, + 0x41, 0x70, 0x70, 0x6c, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x2e, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x61, 0x70, 0x69, 0x73, 0x65, - 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x41, 0x70, 0x70, 0x6c, 0x69, 0x63, - 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, - 0x12, 0x7c, 0x0a, 0x11, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x41, 0x70, 0x70, 0x6c, 0x69, 0x63, - 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x31, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x73, 0x65, 0x72, - 0x76, 0x69, 0x63, 0x65, 0x2e, 0x61, 0x70, 0x69, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, - 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x41, 0x70, 0x70, 0x6c, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, - 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x32, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, - 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x61, 0x70, 0x69, 0x73, 0x65, 0x72, 0x76, 0x69, - 0x63, 0x65, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x41, 0x70, 0x70, 0x6c, 0x69, 0x63, 0x61, - 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x7c, - 0x0a, 0x11, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x41, 0x70, 0x70, 0x6c, 0x69, 0x63, 0x61, 0x74, - 0x69, 0x6f, 0x6e, 0x12, 0x31, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, - 0x63, 0x65, 0x2e, 0x61, 0x70, 0x69, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x44, 0x65, - 0x6c, 0x65, 0x74, 0x65, 0x41, 0x70, 0x70, 0x6c, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, - 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x32, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x73, 0x65, - 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x61, 0x70, 0x69, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, - 0x2e, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x41, 0x70, 0x70, 0x6c, 0x69, 0x63, 0x61, 0x74, 0x69, - 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x7c, 0x0a, 0x11, - 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x41, 0x70, 0x70, 0x6c, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, - 0x6e, 0x12, 0x31, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, - 0x2e, 0x61, 0x70, 0x69, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x45, 0x6e, 0x61, 0x62, - 0x6c, 0x65, 0x41, 0x70, 0x70, 0x6c, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, - 0x75, 0x65, 0x73, 0x74, 0x1a, 0x32, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x73, 0x65, 0x72, 0x76, - 0x69, 0x63, 0x65, 0x2e, 0x61, 0x70, 0x69, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x45, - 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x41, 0x70, 0x70, 0x6c, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, - 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x7f, 0x0a, 0x12, 0x44, 0x69, - 0x73, 0x61, 0x62, 0x6c, 0x65, 0x41, 0x70, 0x70, 0x6c, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, - 0x12, 0x32, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, - 0x61, 0x70, 0x69, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x44, 0x69, 0x73, 0x61, 0x62, - 0x6c, 0x65, 0x41, 0x70, 0x70, 0x6c, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, - 0x75, 0x65, 0x73, 0x74, 0x1a, 0x33, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x73, 0x65, 0x72, 0x76, - 0x69, 0x63, 0x65, 0x2e, 0x61, 0x70, 0x69, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x44, - 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x41, 0x70, 0x70, 0x6c, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, - 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x9a, 0x01, 0x0a, 0x1b, - 0x52, 0x65, 0x6e, 0x61, 0x6d, 0x65, 0x41, 0x70, 0x70, 0x6c, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, - 0x6e, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x46, 0x69, 0x6c, 0x65, 0x12, 0x3b, 0x2e, 0x67, 0x72, + 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x41, 0x64, 0x64, 0x41, 0x70, 0x70, 0x6c, 0x69, 0x63, 0x61, + 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2f, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x61, 0x70, 0x69, 0x73, 0x65, - 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x6e, 0x61, 0x6d, 0x65, 0x41, 0x70, 0x70, 0x6c, - 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x46, 0x69, 0x6c, - 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x3c, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, + 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x41, 0x64, 0x64, 0x41, 0x70, 0x70, 0x6c, 0x69, 0x63, 0x61, + 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x76, + 0x0a, 0x0f, 0x53, 0x79, 0x6e, 0x63, 0x41, 0x70, 0x70, 0x6c, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, + 0x6e, 0x12, 0x2f, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, + 0x2e, 0x61, 0x70, 0x69, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x53, 0x79, 0x6e, 0x63, + 0x41, 0x70, 0x70, 0x6c, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x1a, 0x30, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, + 0x65, 0x2e, 0x61, 0x70, 0x69, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x53, 0x79, 0x6e, + 0x63, 0x41, 0x70, 0x70, 0x6c, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x70, + 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x73, 0x0a, 0x0e, 0x47, 0x65, 0x74, 0x41, 0x70, 0x70, + 0x6c, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x2e, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x61, 0x70, 0x69, 0x73, 0x65, 0x72, 0x76, 0x69, - 0x63, 0x65, 0x2e, 0x52, 0x65, 0x6e, 0x61, 0x6d, 0x65, 0x41, 0x70, 0x70, 0x6c, 0x69, 0x63, 0x61, - 0x74, 0x69, 0x6f, 0x6e, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x46, 0x69, 0x6c, 0x65, 0x52, 0x65, - 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x70, 0x0a, 0x0d, 0x47, 0x65, 0x74, 0x44, - 0x65, 0x70, 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x12, 0x2d, 0x2e, 0x67, 0x72, 0x70, 0x63, - 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x61, 0x70, 0x69, 0x73, 0x65, 0x72, 0x76, - 0x69, 0x63, 0x65, 0x2e, 0x47, 0x65, 0x74, 0x44, 0x65, 0x70, 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, - 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2e, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, + 0x63, 0x65, 0x2e, 0x47, 0x65, 0x74, 0x41, 0x70, 0x70, 0x6c, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, + 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2f, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x61, 0x70, 0x69, 0x73, 0x65, 0x72, 0x76, 0x69, - 0x63, 0x65, 0x2e, 0x47, 0x65, 0x74, 0x44, 0x65, 0x70, 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, - 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x76, 0x0a, 0x0f, 0x4c, 0x69, - 0x73, 0x74, 0x44, 0x65, 0x70, 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x73, 0x12, 0x2f, 0x2e, - 0x67, 0x72, 0x70, 0x63, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x61, 0x70, 0x69, - 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x44, 0x65, 0x70, 0x6c, - 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x30, + 0x63, 0x65, 0x2e, 0x47, 0x65, 0x74, 0x41, 0x70, 0x70, 0x6c, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, + 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x79, 0x0a, 0x10, 0x4c, + 0x69, 0x73, 0x74, 0x41, 0x70, 0x70, 0x6c, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x12, + 0x30, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x61, + 0x70, 0x69, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x41, 0x70, + 0x70, 0x6c, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, + 0x74, 0x1a, 0x31, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, + 0x2e, 0x61, 0x70, 0x69, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x4c, 0x69, 0x73, 0x74, + 0x41, 0x70, 0x70, 0x6c, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x52, 0x65, 0x73, 0x70, + 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x7c, 0x0a, 0x11, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, + 0x41, 0x70, 0x70, 0x6c, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x31, 0x2e, 0x67, 0x72, + 0x70, 0x63, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x61, 0x70, 0x69, 0x73, 0x65, + 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x41, 0x70, 0x70, 0x6c, + 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x32, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x61, 0x70, - 0x69, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x44, 0x65, 0x70, - 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, - 0x22, 0x00, 0x12, 0x67, 0x0a, 0x0a, 0x47, 0x65, 0x74, 0x43, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, - 0x12, 0x2a, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, - 0x61, 0x70, 0x69, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x47, 0x65, 0x74, 0x43, 0x6f, - 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2b, 0x2e, 0x67, + 0x69, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x41, + 0x70, 0x70, 0x6c, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, + 0x73, 0x65, 0x22, 0x00, 0x12, 0x7c, 0x0a, 0x11, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x41, 0x70, + 0x70, 0x6c, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x31, 0x2e, 0x67, 0x72, 0x70, 0x63, + 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x61, 0x70, 0x69, 0x73, 0x65, 0x72, 0x76, + 0x69, 0x63, 0x65, 0x2e, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x41, 0x70, 0x70, 0x6c, 0x69, 0x63, + 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x32, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x61, 0x70, 0x69, 0x73, - 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x47, 0x65, 0x74, 0x43, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, - 0x64, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x61, 0x0a, 0x08, 0x47, - 0x65, 0x74, 0x50, 0x69, 0x70, 0x65, 0x64, 0x12, 0x28, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x73, + 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x41, 0x70, 0x70, + 0x6c, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, + 0x22, 0x00, 0x12, 0x7c, 0x0a, 0x11, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x41, 0x70, 0x70, 0x6c, + 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x31, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x61, 0x70, 0x69, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, - 0x65, 0x2e, 0x47, 0x65, 0x74, 0x50, 0x69, 0x70, 0x65, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, - 0x74, 0x1a, 0x29, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, - 0x2e, 0x61, 0x70, 0x69, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x47, 0x65, 0x74, 0x50, - 0x69, 0x70, 0x65, 0x64, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x70, - 0x0a, 0x0d, 0x52, 0x65, 0x67, 0x69, 0x73, 0x74, 0x65, 0x72, 0x50, 0x69, 0x70, 0x65, 0x64, 0x12, + 0x65, 0x2e, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x41, 0x70, 0x70, 0x6c, 0x69, 0x63, 0x61, 0x74, + 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x32, 0x2e, 0x67, 0x72, 0x70, + 0x63, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x61, 0x70, 0x69, 0x73, 0x65, 0x72, + 0x76, 0x69, 0x63, 0x65, 0x2e, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x41, 0x70, 0x70, 0x6c, 0x69, + 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, + 0x12, 0x7f, 0x0a, 0x12, 0x44, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x41, 0x70, 0x70, 0x6c, 0x69, + 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x32, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x73, 0x65, + 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x61, 0x70, 0x69, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, + 0x2e, 0x44, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x41, 0x70, 0x70, 0x6c, 0x69, 0x63, 0x61, 0x74, + 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x33, 0x2e, 0x67, 0x72, 0x70, + 0x63, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x61, 0x70, 0x69, 0x73, 0x65, 0x72, + 0x76, 0x69, 0x63, 0x65, 0x2e, 0x44, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x41, 0x70, 0x70, 0x6c, + 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, + 0x00, 0x12, 0x9a, 0x01, 0x0a, 0x1b, 0x52, 0x65, 0x6e, 0x61, 0x6d, 0x65, 0x41, 0x70, 0x70, 0x6c, + 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x46, 0x69, 0x6c, + 0x65, 0x12, 0x3b, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, + 0x2e, 0x61, 0x70, 0x69, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x6e, 0x61, + 0x6d, 0x65, 0x41, 0x70, 0x70, 0x6c, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x43, 0x6f, 0x6e, + 0x66, 0x69, 0x67, 0x46, 0x69, 0x6c, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x3c, + 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x61, 0x70, + 0x69, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x6e, 0x61, 0x6d, 0x65, 0x41, + 0x70, 0x70, 0x6c, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, + 0x46, 0x69, 0x6c, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x70, + 0x0a, 0x0d, 0x47, 0x65, 0x74, 0x44, 0x65, 0x70, 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x12, 0x2d, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x61, - 0x70, 0x69, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x67, 0x69, 0x73, 0x74, - 0x65, 0x72, 0x50, 0x69, 0x70, 0x65, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2e, + 0x70, 0x69, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x47, 0x65, 0x74, 0x44, 0x65, 0x70, + 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2e, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x61, 0x70, - 0x69, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x67, 0x69, 0x73, 0x74, 0x65, - 0x72, 0x50, 0x69, 0x70, 0x65, 0x64, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, - 0x12, 0x6a, 0x0a, 0x0b, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x50, 0x69, 0x70, 0x65, 0x64, 0x12, - 0x2b, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x61, - 0x70, 0x69, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, - 0x50, 0x69, 0x70, 0x65, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2c, 0x2e, 0x67, - 0x72, 0x70, 0x63, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x61, 0x70, 0x69, 0x73, - 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x50, 0x69, 0x70, - 0x65, 0x64, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x6a, 0x0a, 0x0b, - 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x50, 0x69, 0x70, 0x65, 0x64, 0x12, 0x2b, 0x2e, 0x67, 0x72, - 0x70, 0x63, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x61, 0x70, 0x69, 0x73, 0x65, - 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x50, 0x69, 0x70, 0x65, - 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2c, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, - 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x61, 0x70, 0x69, 0x73, 0x65, 0x72, 0x76, 0x69, - 0x63, 0x65, 0x2e, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x50, 0x69, 0x70, 0x65, 0x64, 0x52, 0x65, - 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x6d, 0x0a, 0x0c, 0x44, 0x69, 0x73, 0x61, - 0x62, 0x6c, 0x65, 0x50, 0x69, 0x70, 0x65, 0x64, 0x12, 0x2c, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, - 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x61, 0x70, 0x69, 0x73, 0x65, 0x72, 0x76, 0x69, - 0x63, 0x65, 0x2e, 0x44, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x50, 0x69, 0x70, 0x65, 0x64, 0x52, - 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2d, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x73, 0x65, + 0x69, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x47, 0x65, 0x74, 0x44, 0x65, 0x70, 0x6c, + 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, + 0x12, 0x76, 0x0a, 0x0f, 0x4c, 0x69, 0x73, 0x74, 0x44, 0x65, 0x70, 0x6c, 0x6f, 0x79, 0x6d, 0x65, + 0x6e, 0x74, 0x73, 0x12, 0x2f, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, + 0x63, 0x65, 0x2e, 0x61, 0x70, 0x69, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x4c, 0x69, + 0x73, 0x74, 0x44, 0x65, 0x70, 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x73, 0x52, 0x65, 0x71, + 0x75, 0x65, 0x73, 0x74, 0x1a, 0x30, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x73, 0x65, 0x72, 0x76, + 0x69, 0x63, 0x65, 0x2e, 0x61, 0x70, 0x69, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x4c, + 0x69, 0x73, 0x74, 0x44, 0x65, 0x70, 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x73, 0x52, 0x65, + 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x67, 0x0a, 0x0a, 0x47, 0x65, 0x74, 0x43, + 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x12, 0x2a, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x61, 0x70, 0x69, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, - 0x2e, 0x44, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x50, 0x69, 0x70, 0x65, 0x64, 0x52, 0x65, 0x73, - 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x70, 0x0a, 0x0d, 0x52, 0x65, 0x67, 0x69, 0x73, - 0x74, 0x65, 0x72, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x12, 0x2d, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, - 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x61, 0x70, 0x69, 0x73, 0x65, 0x72, 0x76, 0x69, - 0x63, 0x65, 0x2e, 0x52, 0x65, 0x67, 0x69, 0x73, 0x74, 0x65, 0x72, 0x45, 0x76, 0x65, 0x6e, 0x74, - 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2e, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x73, + 0x2e, 0x47, 0x65, 0x74, 0x43, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x1a, 0x2b, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, + 0x65, 0x2e, 0x61, 0x70, 0x69, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x47, 0x65, 0x74, + 0x43, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, + 0x00, 0x12, 0x61, 0x0a, 0x08, 0x47, 0x65, 0x74, 0x50, 0x69, 0x70, 0x65, 0x64, 0x12, 0x28, 0x2e, + 0x67, 0x72, 0x70, 0x63, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x61, 0x70, 0x69, + 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x47, 0x65, 0x74, 0x50, 0x69, 0x70, 0x65, 0x64, + 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x29, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x61, 0x70, 0x69, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, - 0x65, 0x2e, 0x52, 0x65, 0x67, 0x69, 0x73, 0x74, 0x65, 0x72, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x52, - 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x7f, 0x0a, 0x12, 0x52, 0x65, 0x71, - 0x75, 0x65, 0x73, 0x74, 0x50, 0x6c, 0x61, 0x6e, 0x50, 0x72, 0x65, 0x76, 0x69, 0x65, 0x77, 0x12, - 0x32, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x61, - 0x70, 0x69, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, - 0x74, 0x50, 0x6c, 0x61, 0x6e, 0x50, 0x72, 0x65, 0x76, 0x69, 0x65, 0x77, 0x52, 0x65, 0x71, 0x75, - 0x65, 0x73, 0x74, 0x1a, 0x33, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, - 0x63, 0x65, 0x2e, 0x61, 0x70, 0x69, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x52, 0x65, - 0x71, 0x75, 0x65, 0x73, 0x74, 0x50, 0x6c, 0x61, 0x6e, 0x50, 0x72, 0x65, 0x76, 0x69, 0x65, 0x77, - 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x88, 0x01, 0x0a, 0x15, 0x47, - 0x65, 0x74, 0x50, 0x6c, 0x61, 0x6e, 0x50, 0x72, 0x65, 0x76, 0x69, 0x65, 0x77, 0x52, 0x65, 0x73, - 0x75, 0x6c, 0x74, 0x73, 0x12, 0x35, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x73, 0x65, 0x72, 0x76, - 0x69, 0x63, 0x65, 0x2e, 0x61, 0x70, 0x69, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x47, - 0x65, 0x74, 0x50, 0x6c, 0x61, 0x6e, 0x50, 0x72, 0x65, 0x76, 0x69, 0x65, 0x77, 0x52, 0x65, 0x73, - 0x75, 0x6c, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x36, 0x2e, 0x67, 0x72, - 0x70, 0x63, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x61, 0x70, 0x69, 0x73, 0x65, - 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x47, 0x65, 0x74, 0x50, 0x6c, 0x61, 0x6e, 0x50, 0x72, 0x65, - 0x76, 0x69, 0x65, 0x77, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, - 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x5e, 0x0a, 0x07, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, - 0x12, 0x27, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, - 0x61, 0x70, 0x69, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, - 0x70, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x28, 0x2e, 0x67, 0x72, 0x70, 0x63, + 0x65, 0x2e, 0x47, 0x65, 0x74, 0x50, 0x69, 0x70, 0x65, 0x64, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, + 0x73, 0x65, 0x22, 0x00, 0x12, 0x70, 0x0a, 0x0d, 0x52, 0x65, 0x67, 0x69, 0x73, 0x74, 0x65, 0x72, + 0x50, 0x69, 0x70, 0x65, 0x64, 0x12, 0x2d, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x73, 0x65, 0x72, + 0x76, 0x69, 0x63, 0x65, 0x2e, 0x61, 0x70, 0x69, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, + 0x52, 0x65, 0x67, 0x69, 0x73, 0x74, 0x65, 0x72, 0x50, 0x69, 0x70, 0x65, 0x64, 0x52, 0x65, 0x71, + 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2e, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x73, 0x65, 0x72, 0x76, + 0x69, 0x63, 0x65, 0x2e, 0x61, 0x70, 0x69, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x52, + 0x65, 0x67, 0x69, 0x73, 0x74, 0x65, 0x72, 0x50, 0x69, 0x70, 0x65, 0x64, 0x52, 0x65, 0x73, 0x70, + 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x6a, 0x0a, 0x0b, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, + 0x50, 0x69, 0x70, 0x65, 0x64, 0x12, 0x2b, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x73, 0x65, 0x72, + 0x76, 0x69, 0x63, 0x65, 0x2e, 0x61, 0x70, 0x69, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, + 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x50, 0x69, 0x70, 0x65, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x1a, 0x2c, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, + 0x65, 0x2e, 0x61, 0x70, 0x69, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x55, 0x70, 0x64, + 0x61, 0x74, 0x65, 0x50, 0x69, 0x70, 0x65, 0x64, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, + 0x22, 0x00, 0x12, 0x6a, 0x0a, 0x0b, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x50, 0x69, 0x70, 0x65, + 0x64, 0x12, 0x2b, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, + 0x2e, 0x61, 0x70, 0x69, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x45, 0x6e, 0x61, 0x62, + 0x6c, 0x65, 0x50, 0x69, 0x70, 0x65, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2c, + 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x61, 0x70, + 0x69, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x50, + 0x69, 0x70, 0x65, 0x64, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x6d, + 0x0a, 0x0c, 0x44, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x50, 0x69, 0x70, 0x65, 0x64, 0x12, 0x2c, + 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x61, 0x70, + 0x69, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x44, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, + 0x50, 0x69, 0x70, 0x65, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2d, 0x2e, 0x67, + 0x72, 0x70, 0x63, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x61, 0x70, 0x69, 0x73, + 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x44, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x50, 0x69, + 0x70, 0x65, 0x64, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x70, 0x0a, + 0x0d, 0x52, 0x65, 0x67, 0x69, 0x73, 0x74, 0x65, 0x72, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x12, 0x2d, + 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x61, 0x70, + 0x69, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x67, 0x69, 0x73, 0x74, 0x65, + 0x72, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2e, 0x2e, + 0x67, 0x72, 0x70, 0x63, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x61, 0x70, 0x69, + 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x67, 0x69, 0x73, 0x74, 0x65, 0x72, + 0x45, 0x76, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, + 0x7f, 0x0a, 0x12, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x50, 0x6c, 0x61, 0x6e, 0x50, 0x72, + 0x65, 0x76, 0x69, 0x65, 0x77, 0x12, 0x32, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x73, 0x65, 0x72, + 0x76, 0x69, 0x63, 0x65, 0x2e, 0x61, 0x70, 0x69, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, + 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x50, 0x6c, 0x61, 0x6e, 0x50, 0x72, 0x65, 0x76, 0x69, + 0x65, 0x77, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x33, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x61, 0x70, 0x69, 0x73, 0x65, 0x72, 0x76, - 0x69, 0x63, 0x65, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, - 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x70, 0x0a, 0x0d, 0x4c, 0x69, 0x73, 0x74, 0x53, 0x74, 0x61, - 0x67, 0x65, 0x4c, 0x6f, 0x67, 0x73, 0x12, 0x2d, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x73, 0x65, + 0x69, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x50, 0x6c, 0x61, 0x6e, 0x50, + 0x72, 0x65, 0x76, 0x69, 0x65, 0x77, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, + 0x12, 0x88, 0x01, 0x0a, 0x15, 0x47, 0x65, 0x74, 0x50, 0x6c, 0x61, 0x6e, 0x50, 0x72, 0x65, 0x76, + 0x69, 0x65, 0x77, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x73, 0x12, 0x35, 0x2e, 0x67, 0x72, 0x70, + 0x63, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x61, 0x70, 0x69, 0x73, 0x65, 0x72, + 0x76, 0x69, 0x63, 0x65, 0x2e, 0x47, 0x65, 0x74, 0x50, 0x6c, 0x61, 0x6e, 0x50, 0x72, 0x65, 0x76, + 0x69, 0x65, 0x77, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, + 0x74, 0x1a, 0x36, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, + 0x2e, 0x61, 0x70, 0x69, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x47, 0x65, 0x74, 0x50, + 0x6c, 0x61, 0x6e, 0x50, 0x72, 0x65, 0x76, 0x69, 0x65, 0x77, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, + 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x5e, 0x0a, 0x07, 0x45, + 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x12, 0x27, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x61, 0x70, 0x69, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, - 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x53, 0x74, 0x61, 0x67, 0x65, 0x4c, 0x6f, 0x67, 0x73, 0x52, 0x65, - 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2e, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x73, 0x65, 0x72, - 0x76, 0x69, 0x63, 0x65, 0x2e, 0x61, 0x70, 0x69, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, - 0x4c, 0x69, 0x73, 0x74, 0x53, 0x74, 0x61, 0x67, 0x65, 0x4c, 0x6f, 0x67, 0x73, 0x52, 0x65, 0x73, - 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x42, 0x3d, 0x5a, 0x3b, 0x67, 0x69, 0x74, 0x68, 0x75, - 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x70, 0x69, 0x70, 0x65, 0x2d, 0x63, 0x64, 0x2f, 0x70, 0x69, - 0x70, 0x65, 0x63, 0x64, 0x2f, 0x70, 0x6b, 0x67, 0x2f, 0x61, 0x70, 0x70, 0x2f, 0x73, 0x65, 0x72, - 0x76, 0x65, 0x72, 0x2f, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2f, 0x61, 0x70, 0x69, 0x73, - 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, + 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, + 0x28, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x61, + 0x70, 0x69, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, + 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x70, 0x0a, 0x0d, 0x4c, + 0x69, 0x73, 0x74, 0x53, 0x74, 0x61, 0x67, 0x65, 0x4c, 0x6f, 0x67, 0x73, 0x12, 0x2d, 0x2e, 0x67, + 0x72, 0x70, 0x63, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x61, 0x70, 0x69, 0x73, + 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x53, 0x74, 0x61, 0x67, 0x65, + 0x4c, 0x6f, 0x67, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2e, 0x2e, 0x67, 0x72, + 0x70, 0x63, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x61, 0x70, 0x69, 0x73, 0x65, + 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x53, 0x74, 0x61, 0x67, 0x65, 0x4c, + 0x6f, 0x67, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x42, 0x3d, 0x5a, + 0x3b, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x70, 0x69, 0x70, 0x65, + 0x2d, 0x63, 0x64, 0x2f, 0x70, 0x69, 0x70, 0x65, 0x63, 0x64, 0x2f, 0x70, 0x6b, 0x67, 0x2f, 0x61, + 0x70, 0x70, 0x2f, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x2f, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, + 0x65, 0x2f, 0x61, 0x70, 0x69, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x62, 0x06, 0x70, 0x72, + 0x6f, 0x74, 0x6f, 0x33, } var ( @@ -2912,7 +2930,7 @@ func file_pkg_app_server_service_apiservice_service_proto_rawDescGZIP() []byte { return file_pkg_app_server_service_apiservice_service_proto_rawDescData } -var file_pkg_app_server_service_apiservice_service_proto_msgTypes = make([]protoimpl.MessageInfo, 49) +var file_pkg_app_server_service_apiservice_service_proto_msgTypes = make([]protoimpl.MessageInfo, 50) var file_pkg_app_server_service_apiservice_service_proto_goTypes = []interface{}{ (*AddApplicationRequest)(nil), // 0: grpc.service.apiservice.AddApplicationRequest (*AddApplicationResponse)(nil), // 1: grpc.service.apiservice.AddApplicationResponse @@ -2962,82 +2980,84 @@ var file_pkg_app_server_service_apiservice_service_proto_goTypes = []interface{} nil, // 45: grpc.service.apiservice.ListApplicationsRequest.LabelsEntry nil, // 46: grpc.service.apiservice.ListDeploymentsRequest.LabelsEntry nil, // 47: grpc.service.apiservice.RegisterEventRequest.LabelsEntry - nil, // 48: grpc.service.apiservice.ListStageLogsResponse.StageLogsEntry - (*model.ApplicationGitPath)(nil), // 49: model.ApplicationGitPath - (model.ApplicationKind)(0), // 50: model.ApplicationKind - (*model.Application)(nil), // 51: model.Application - (*model.Piped)(nil), // 52: model.Piped - (*model.Deployment)(nil), // 53: model.Deployment - (*model.Command)(nil), // 54: model.Command - (*model.PlanPreviewCommandResult)(nil), // 55: model.PlanPreviewCommandResult - (*model.LogBlock)(nil), // 56: model.LogBlock + nil, // 48: grpc.service.apiservice.RegisterEventRequest.ContextsEntry + nil, // 49: grpc.service.apiservice.ListStageLogsResponse.StageLogsEntry + (*model.ApplicationGitPath)(nil), // 50: model.ApplicationGitPath + (model.ApplicationKind)(0), // 51: model.ApplicationKind + (*model.Application)(nil), // 52: model.Application + (*model.Piped)(nil), // 53: model.Piped + (*model.Deployment)(nil), // 54: model.Deployment + (*model.Command)(nil), // 55: model.Command + (*model.PlanPreviewCommandResult)(nil), // 56: model.PlanPreviewCommandResult + (*model.LogBlock)(nil), // 57: model.LogBlock } var file_pkg_app_server_service_apiservice_service_proto_depIdxs = []int32{ - 49, // 0: grpc.service.apiservice.AddApplicationRequest.git_path:type_name -> model.ApplicationGitPath - 50, // 1: grpc.service.apiservice.AddApplicationRequest.kind:type_name -> model.ApplicationKind - 51, // 2: grpc.service.apiservice.GetApplicationResponse.application:type_name -> model.Application + 50, // 0: grpc.service.apiservice.AddApplicationRequest.git_path:type_name -> model.ApplicationGitPath + 51, // 1: grpc.service.apiservice.AddApplicationRequest.kind:type_name -> model.ApplicationKind + 52, // 2: grpc.service.apiservice.GetApplicationResponse.application:type_name -> model.Application 45, // 3: grpc.service.apiservice.ListApplicationsRequest.labels:type_name -> grpc.service.apiservice.ListApplicationsRequest.LabelsEntry - 51, // 4: grpc.service.apiservice.ListApplicationsResponse.applications:type_name -> model.Application - 52, // 5: grpc.service.apiservice.GetPipedResponse.piped:type_name -> model.Piped - 49, // 6: grpc.service.apiservice.UpdateApplicationRequest.git_path:type_name -> model.ApplicationGitPath - 53, // 7: grpc.service.apiservice.GetDeploymentResponse.deployment:type_name -> model.Deployment + 52, // 4: grpc.service.apiservice.ListApplicationsResponse.applications:type_name -> model.Application + 53, // 5: grpc.service.apiservice.GetPipedResponse.piped:type_name -> model.Piped + 50, // 6: grpc.service.apiservice.UpdateApplicationRequest.git_path:type_name -> model.ApplicationGitPath + 54, // 7: grpc.service.apiservice.GetDeploymentResponse.deployment:type_name -> model.Deployment 46, // 8: grpc.service.apiservice.ListDeploymentsRequest.labels:type_name -> grpc.service.apiservice.ListDeploymentsRequest.LabelsEntry - 53, // 9: grpc.service.apiservice.ListDeploymentsResponse.deployments:type_name -> model.Deployment - 54, // 10: grpc.service.apiservice.GetCommandResponse.command:type_name -> model.Command + 54, // 9: grpc.service.apiservice.ListDeploymentsResponse.deployments:type_name -> model.Deployment + 55, // 10: grpc.service.apiservice.GetCommandResponse.command:type_name -> model.Command 47, // 11: grpc.service.apiservice.RegisterEventRequest.labels:type_name -> grpc.service.apiservice.RegisterEventRequest.LabelsEntry - 55, // 12: grpc.service.apiservice.GetPlanPreviewResultsResponse.results:type_name -> model.PlanPreviewCommandResult - 56, // 13: grpc.service.apiservice.StageLog.blocks:type_name -> model.LogBlock - 48, // 14: grpc.service.apiservice.ListStageLogsResponse.stage_logs:type_name -> grpc.service.apiservice.ListStageLogsResponse.StageLogsEntry - 42, // 15: grpc.service.apiservice.ListStageLogsResponse.StageLogsEntry.value:type_name -> grpc.service.apiservice.StageLog - 0, // 16: grpc.service.apiservice.APIService.AddApplication:input_type -> grpc.service.apiservice.AddApplicationRequest - 2, // 17: grpc.service.apiservice.APIService.SyncApplication:input_type -> grpc.service.apiservice.SyncApplicationRequest - 4, // 18: grpc.service.apiservice.APIService.GetApplication:input_type -> grpc.service.apiservice.GetApplicationRequest - 6, // 19: grpc.service.apiservice.APIService.ListApplications:input_type -> grpc.service.apiservice.ListApplicationsRequest - 18, // 20: grpc.service.apiservice.APIService.UpdateApplication:input_type -> grpc.service.apiservice.UpdateApplicationRequest - 20, // 21: grpc.service.apiservice.APIService.DeleteApplication:input_type -> grpc.service.apiservice.DeleteApplicationRequest - 14, // 22: grpc.service.apiservice.APIService.EnableApplication:input_type -> grpc.service.apiservice.EnableApplicationRequest - 16, // 23: grpc.service.apiservice.APIService.DisableApplication:input_type -> grpc.service.apiservice.DisableApplicationRequest - 22, // 24: grpc.service.apiservice.APIService.RenameApplicationConfigFile:input_type -> grpc.service.apiservice.RenameApplicationConfigFileRequest - 24, // 25: grpc.service.apiservice.APIService.GetDeployment:input_type -> grpc.service.apiservice.GetDeploymentRequest - 26, // 26: grpc.service.apiservice.APIService.ListDeployments:input_type -> grpc.service.apiservice.ListDeploymentsRequest - 28, // 27: grpc.service.apiservice.APIService.GetCommand:input_type -> grpc.service.apiservice.GetCommandRequest - 8, // 28: grpc.service.apiservice.APIService.GetPiped:input_type -> grpc.service.apiservice.GetPipedRequest - 10, // 29: grpc.service.apiservice.APIService.RegisterPiped:input_type -> grpc.service.apiservice.RegisterPipedRequest - 12, // 30: grpc.service.apiservice.APIService.UpdatePiped:input_type -> grpc.service.apiservice.UpdatePipedRequest - 30, // 31: grpc.service.apiservice.APIService.EnablePiped:input_type -> grpc.service.apiservice.EnablePipedRequest - 32, // 32: grpc.service.apiservice.APIService.DisablePiped:input_type -> grpc.service.apiservice.DisablePipedRequest - 34, // 33: grpc.service.apiservice.APIService.RegisterEvent:input_type -> grpc.service.apiservice.RegisterEventRequest - 36, // 34: grpc.service.apiservice.APIService.RequestPlanPreview:input_type -> grpc.service.apiservice.RequestPlanPreviewRequest - 38, // 35: grpc.service.apiservice.APIService.GetPlanPreviewResults:input_type -> grpc.service.apiservice.GetPlanPreviewResultsRequest - 40, // 36: grpc.service.apiservice.APIService.Encrypt:input_type -> grpc.service.apiservice.EncryptRequest - 43, // 37: grpc.service.apiservice.APIService.ListStageLogs:input_type -> grpc.service.apiservice.ListStageLogsRequest - 1, // 38: grpc.service.apiservice.APIService.AddApplication:output_type -> grpc.service.apiservice.AddApplicationResponse - 3, // 39: grpc.service.apiservice.APIService.SyncApplication:output_type -> grpc.service.apiservice.SyncApplicationResponse - 5, // 40: grpc.service.apiservice.APIService.GetApplication:output_type -> grpc.service.apiservice.GetApplicationResponse - 7, // 41: grpc.service.apiservice.APIService.ListApplications:output_type -> grpc.service.apiservice.ListApplicationsResponse - 19, // 42: grpc.service.apiservice.APIService.UpdateApplication:output_type -> grpc.service.apiservice.UpdateApplicationResponse - 21, // 43: grpc.service.apiservice.APIService.DeleteApplication:output_type -> grpc.service.apiservice.DeleteApplicationResponse - 15, // 44: grpc.service.apiservice.APIService.EnableApplication:output_type -> grpc.service.apiservice.EnableApplicationResponse - 17, // 45: grpc.service.apiservice.APIService.DisableApplication:output_type -> grpc.service.apiservice.DisableApplicationResponse - 23, // 46: grpc.service.apiservice.APIService.RenameApplicationConfigFile:output_type -> grpc.service.apiservice.RenameApplicationConfigFileResponse - 25, // 47: grpc.service.apiservice.APIService.GetDeployment:output_type -> grpc.service.apiservice.GetDeploymentResponse - 27, // 48: grpc.service.apiservice.APIService.ListDeployments:output_type -> grpc.service.apiservice.ListDeploymentsResponse - 29, // 49: grpc.service.apiservice.APIService.GetCommand:output_type -> grpc.service.apiservice.GetCommandResponse - 9, // 50: grpc.service.apiservice.APIService.GetPiped:output_type -> grpc.service.apiservice.GetPipedResponse - 11, // 51: grpc.service.apiservice.APIService.RegisterPiped:output_type -> grpc.service.apiservice.RegisterPipedResponse - 13, // 52: grpc.service.apiservice.APIService.UpdatePiped:output_type -> grpc.service.apiservice.UpdatePipedResponse - 31, // 53: grpc.service.apiservice.APIService.EnablePiped:output_type -> grpc.service.apiservice.EnablePipedResponse - 33, // 54: grpc.service.apiservice.APIService.DisablePiped:output_type -> grpc.service.apiservice.DisablePipedResponse - 35, // 55: grpc.service.apiservice.APIService.RegisterEvent:output_type -> grpc.service.apiservice.RegisterEventResponse - 37, // 56: grpc.service.apiservice.APIService.RequestPlanPreview:output_type -> grpc.service.apiservice.RequestPlanPreviewResponse - 39, // 57: grpc.service.apiservice.APIService.GetPlanPreviewResults:output_type -> grpc.service.apiservice.GetPlanPreviewResultsResponse - 41, // 58: grpc.service.apiservice.APIService.Encrypt:output_type -> grpc.service.apiservice.EncryptResponse - 44, // 59: grpc.service.apiservice.APIService.ListStageLogs:output_type -> grpc.service.apiservice.ListStageLogsResponse - 38, // [38:60] is the sub-list for method output_type - 16, // [16:38] is the sub-list for method input_type - 16, // [16:16] is the sub-list for extension type_name - 16, // [16:16] is the sub-list for extension extendee - 0, // [0:16] is the sub-list for field type_name + 48, // 12: grpc.service.apiservice.RegisterEventRequest.contexts:type_name -> grpc.service.apiservice.RegisterEventRequest.ContextsEntry + 56, // 13: grpc.service.apiservice.GetPlanPreviewResultsResponse.results:type_name -> model.PlanPreviewCommandResult + 57, // 14: grpc.service.apiservice.StageLog.blocks:type_name -> model.LogBlock + 49, // 15: grpc.service.apiservice.ListStageLogsResponse.stage_logs:type_name -> grpc.service.apiservice.ListStageLogsResponse.StageLogsEntry + 42, // 16: grpc.service.apiservice.ListStageLogsResponse.StageLogsEntry.value:type_name -> grpc.service.apiservice.StageLog + 0, // 17: grpc.service.apiservice.APIService.AddApplication:input_type -> grpc.service.apiservice.AddApplicationRequest + 2, // 18: grpc.service.apiservice.APIService.SyncApplication:input_type -> grpc.service.apiservice.SyncApplicationRequest + 4, // 19: grpc.service.apiservice.APIService.GetApplication:input_type -> grpc.service.apiservice.GetApplicationRequest + 6, // 20: grpc.service.apiservice.APIService.ListApplications:input_type -> grpc.service.apiservice.ListApplicationsRequest + 18, // 21: grpc.service.apiservice.APIService.UpdateApplication:input_type -> grpc.service.apiservice.UpdateApplicationRequest + 20, // 22: grpc.service.apiservice.APIService.DeleteApplication:input_type -> grpc.service.apiservice.DeleteApplicationRequest + 14, // 23: grpc.service.apiservice.APIService.EnableApplication:input_type -> grpc.service.apiservice.EnableApplicationRequest + 16, // 24: grpc.service.apiservice.APIService.DisableApplication:input_type -> grpc.service.apiservice.DisableApplicationRequest + 22, // 25: grpc.service.apiservice.APIService.RenameApplicationConfigFile:input_type -> grpc.service.apiservice.RenameApplicationConfigFileRequest + 24, // 26: grpc.service.apiservice.APIService.GetDeployment:input_type -> grpc.service.apiservice.GetDeploymentRequest + 26, // 27: grpc.service.apiservice.APIService.ListDeployments:input_type -> grpc.service.apiservice.ListDeploymentsRequest + 28, // 28: grpc.service.apiservice.APIService.GetCommand:input_type -> grpc.service.apiservice.GetCommandRequest + 8, // 29: grpc.service.apiservice.APIService.GetPiped:input_type -> grpc.service.apiservice.GetPipedRequest + 10, // 30: grpc.service.apiservice.APIService.RegisterPiped:input_type -> grpc.service.apiservice.RegisterPipedRequest + 12, // 31: grpc.service.apiservice.APIService.UpdatePiped:input_type -> grpc.service.apiservice.UpdatePipedRequest + 30, // 32: grpc.service.apiservice.APIService.EnablePiped:input_type -> grpc.service.apiservice.EnablePipedRequest + 32, // 33: grpc.service.apiservice.APIService.DisablePiped:input_type -> grpc.service.apiservice.DisablePipedRequest + 34, // 34: grpc.service.apiservice.APIService.RegisterEvent:input_type -> grpc.service.apiservice.RegisterEventRequest + 36, // 35: grpc.service.apiservice.APIService.RequestPlanPreview:input_type -> grpc.service.apiservice.RequestPlanPreviewRequest + 38, // 36: grpc.service.apiservice.APIService.GetPlanPreviewResults:input_type -> grpc.service.apiservice.GetPlanPreviewResultsRequest + 40, // 37: grpc.service.apiservice.APIService.Encrypt:input_type -> grpc.service.apiservice.EncryptRequest + 43, // 38: grpc.service.apiservice.APIService.ListStageLogs:input_type -> grpc.service.apiservice.ListStageLogsRequest + 1, // 39: grpc.service.apiservice.APIService.AddApplication:output_type -> grpc.service.apiservice.AddApplicationResponse + 3, // 40: grpc.service.apiservice.APIService.SyncApplication:output_type -> grpc.service.apiservice.SyncApplicationResponse + 5, // 41: grpc.service.apiservice.APIService.GetApplication:output_type -> grpc.service.apiservice.GetApplicationResponse + 7, // 42: grpc.service.apiservice.APIService.ListApplications:output_type -> grpc.service.apiservice.ListApplicationsResponse + 19, // 43: grpc.service.apiservice.APIService.UpdateApplication:output_type -> grpc.service.apiservice.UpdateApplicationResponse + 21, // 44: grpc.service.apiservice.APIService.DeleteApplication:output_type -> grpc.service.apiservice.DeleteApplicationResponse + 15, // 45: grpc.service.apiservice.APIService.EnableApplication:output_type -> grpc.service.apiservice.EnableApplicationResponse + 17, // 46: grpc.service.apiservice.APIService.DisableApplication:output_type -> grpc.service.apiservice.DisableApplicationResponse + 23, // 47: grpc.service.apiservice.APIService.RenameApplicationConfigFile:output_type -> grpc.service.apiservice.RenameApplicationConfigFileResponse + 25, // 48: grpc.service.apiservice.APIService.GetDeployment:output_type -> grpc.service.apiservice.GetDeploymentResponse + 27, // 49: grpc.service.apiservice.APIService.ListDeployments:output_type -> grpc.service.apiservice.ListDeploymentsResponse + 29, // 50: grpc.service.apiservice.APIService.GetCommand:output_type -> grpc.service.apiservice.GetCommandResponse + 9, // 51: grpc.service.apiservice.APIService.GetPiped:output_type -> grpc.service.apiservice.GetPipedResponse + 11, // 52: grpc.service.apiservice.APIService.RegisterPiped:output_type -> grpc.service.apiservice.RegisterPipedResponse + 13, // 53: grpc.service.apiservice.APIService.UpdatePiped:output_type -> grpc.service.apiservice.UpdatePipedResponse + 31, // 54: grpc.service.apiservice.APIService.EnablePiped:output_type -> grpc.service.apiservice.EnablePipedResponse + 33, // 55: grpc.service.apiservice.APIService.DisablePiped:output_type -> grpc.service.apiservice.DisablePipedResponse + 35, // 56: grpc.service.apiservice.APIService.RegisterEvent:output_type -> grpc.service.apiservice.RegisterEventResponse + 37, // 57: grpc.service.apiservice.APIService.RequestPlanPreview:output_type -> grpc.service.apiservice.RequestPlanPreviewResponse + 39, // 58: grpc.service.apiservice.APIService.GetPlanPreviewResults:output_type -> grpc.service.apiservice.GetPlanPreviewResultsResponse + 41, // 59: grpc.service.apiservice.APIService.Encrypt:output_type -> grpc.service.apiservice.EncryptResponse + 44, // 60: grpc.service.apiservice.APIService.ListStageLogs:output_type -> grpc.service.apiservice.ListStageLogsResponse + 39, // [39:61] is the sub-list for method output_type + 17, // [17:39] is the sub-list for method input_type + 17, // [17:17] is the sub-list for extension type_name + 17, // [17:17] is the sub-list for extension extendee + 0, // [0:17] is the sub-list for field type_name } func init() { file_pkg_app_server_service_apiservice_service_proto_init() } @@ -3593,7 +3613,7 @@ func file_pkg_app_server_service_apiservice_service_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: file_pkg_app_server_service_apiservice_service_proto_rawDesc, NumEnums: 0, - NumMessages: 49, + NumMessages: 50, NumExtensions: 0, NumServices: 1, }, diff --git a/pkg/app/server/service/apiservice/service.pb.validate.go b/pkg/app/server/service/apiservice/service.pb.validate.go index 0a04fb8ccb..28545e0f2a 100644 --- a/pkg/app/server/service/apiservice/service.pb.validate.go +++ b/pkg/app/server/service/apiservice/service.pb.validate.go @@ -4180,6 +4180,8 @@ func (m *RegisterEventRequest) validate(all bool) error { } } + // no validation rules for Contexts + if len(errors) > 0 { return RegisterEventRequestMultiError(errors) } diff --git a/pkg/app/server/service/apiservice/service.proto b/pkg/app/server/service/apiservice/service.proto index e5e18f6b63..240c560d1e 100644 --- a/pkg/app/server/service/apiservice/service.proto +++ b/pkg/app/server/service/apiservice/service.proto @@ -229,6 +229,7 @@ message RegisterEventRequest { string name = 1 [(validate.rules).string.min_len = 1]; string data = 2 [(validate.rules).string.min_len = 1]; map labels = 3 [(validate.rules).map.keys.string.min_len = 1, (validate.rules).map.values.string.min_len = 1]; + map contexts = 4; } message RegisterEventResponse { diff --git a/pkg/git/gittest/git.mock.go b/pkg/git/gittest/git.mock.go index fbb260bdc3..3ebd0ef5a2 100644 --- a/pkg/git/gittest/git.mock.go +++ b/pkg/git/gittest/git.mock.go @@ -93,17 +93,17 @@ func (mr *MockRepoMockRecorder) Clean() *gomock.Call { } // CommitChanges mocks base method. -func (m *MockRepo) CommitChanges(arg0 context.Context, arg1, arg2 string, arg3 bool, arg4 map[string][]byte) error { +func (m *MockRepo) CommitChanges(arg0 context.Context, arg1, arg2 string, arg3 bool, arg4 map[string][]byte, arg5 map[string]string) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "CommitChanges", arg0, arg1, arg2, arg3, arg4) + ret := m.ctrl.Call(m, "CommitChanges", arg0, arg1, arg2, arg3, arg4, arg5) ret0, _ := ret[0].(error) return ret0 } // CommitChanges indicates an expected call of CommitChanges. -func (mr *MockRepoMockRecorder) CommitChanges(arg0, arg1, arg2, arg3, arg4 interface{}) *gomock.Call { +func (mr *MockRepoMockRecorder) CommitChanges(arg0, arg1, arg2, arg3, arg4, arg5 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CommitChanges", reflect.TypeOf((*MockRepo)(nil).CommitChanges), arg0, arg1, arg2, arg3, arg4) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CommitChanges", reflect.TypeOf((*MockRepo)(nil).CommitChanges), arg0, arg1, arg2, arg3, arg4, arg5) } // Copy mocks base method. diff --git a/pkg/git/repo.go b/pkg/git/repo.go index 115f84e0be..3363f6eee1 100644 --- a/pkg/git/repo.go +++ b/pkg/git/repo.go @@ -47,7 +47,7 @@ type Repo interface { Pull(ctx context.Context, branch string) error MergeRemoteBranch(ctx context.Context, branch, commit, mergeCommitMessage string) error Push(ctx context.Context, branch string) error - CommitChanges(ctx context.Context, branch, message string, newBranch bool, changes map[string][]byte) error + CommitChanges(ctx context.Context, branch, message string, newBranch bool, changes map[string][]byte, trailers map[string]string) error } type repo struct { @@ -224,7 +224,7 @@ func (r *repo) Push(ctx context.Context, branch string) error { } // CommitChanges commits some changes into a branch. -func (r *repo) CommitChanges(ctx context.Context, branch, message string, newBranch bool, changes map[string][]byte) error { +func (r *repo) CommitChanges(ctx context.Context, branch, message string, newBranch bool, changes map[string][]byte, trailers map[string]string) error { if newBranch { if err := r.checkoutNewBranch(ctx, branch); err != nil { return fmt.Errorf("failed to checkout new branch, branch: %v, error: %v", branch, err) @@ -248,7 +248,7 @@ func (r *repo) CommitChanges(ctx context.Context, branch, message string, newBra } } // Commit the changes. - if err := r.addCommit(ctx, message); err != nil { + if err := r.addCommit(ctx, message, trailers); err != nil { return fmt.Errorf("failed to commit, branch: %s, error: %v", branch, err) } return nil @@ -267,12 +267,17 @@ func (r *repo) checkoutNewBranch(ctx context.Context, branch string) error { return nil } -func (r repo) addCommit(ctx context.Context, message string) error { +func (r repo) addCommit(ctx context.Context, message string, trailers map[string]string) error { out, err := r.runGitCommand(ctx, "add", ".") if err != nil { return formatCommandError(err, out) } - out, err = r.runGitCommand(ctx, "commit", "-m", message) + + args := []string{"commit", "-m", message} + for k, v := range trailers { + args = append(args, fmt.Sprintf("--trailer=%s: %s", k, v)) + } + out, err = r.runGitCommand(ctx, args...) if err != nil { msg := string(out) if strings.Contains(msg, "nothing to commit, working tree clean") { diff --git a/pkg/git/repo_test.go b/pkg/git/repo_test.go index ffff188c15..7166b63a09 100644 --- a/pkg/git/repo_test.go +++ b/pkg/git/repo_test.go @@ -60,7 +60,7 @@ func TestChangedFiles(t *testing.T) { err = os.WriteFile(readmeFilePath, []byte("new content"), os.ModePerm) require.NoError(t, err) - err = r.addCommit(ctx, "Added new file") + err = r.addCommit(ctx, "Added new file", nil) require.NoError(t, err) headCommit, err := r.GetCommitForRev(ctx, "HEAD") @@ -105,16 +105,17 @@ func TestAddCommit(t *testing.T) { err = os.WriteFile(path, []byte("content"), os.ModePerm) require.NoError(t, err) - err = r.addCommit(ctx, "Added new file") + err = r.addCommit(ctx, "Added new file", map[string]string{"Test-Hoge": "fuga"}) require.NoError(t, err) - err = r.addCommit(ctx, "No change") + err = r.addCommit(ctx, "No change", nil) require.Equal(t, ErrNoChange, err) commits, err = r.ListCommits(ctx, "") require.NoError(t, err) require.Equal(t, 2, len(commits)) assert.Equal(t, "Added new file", commits[0].Message) + assert.Equal(t, "Test-Hoge: fuga", commits[0].Body) } func TestCommitChanges(t *testing.T) { @@ -143,7 +144,7 @@ func TestCommitChanges(t *testing.T) { "README.md": []byte("new-readme"), "a/b/c/new.txt": []byte("new-hello"), } - err = r.CommitChanges(ctx, "new-branch", "New commit with changes", true, changes) + err = r.CommitChanges(ctx, "new-branch", "New commit with changes", true, changes, nil) require.NoError(t, err) commits, err = r.ListCommits(ctx, "") diff --git a/pkg/model/event.pb.go b/pkg/model/event.pb.go index 1f38d094e0..97c53b0fe6 100644 --- a/pkg/model/event.pb.go +++ b/pkg/model/event.pb.go @@ -101,13 +101,17 @@ type Event struct { // The ID of the project this event belongs to. ProjectId string `protobuf:"bytes,4,opt,name=project_id,json=projectId,proto3" json:"project_id,omitempty"` // The key/value pairs that are attached to event. - // This is intended to be used to specify additional attributes of event. + // This is mainly used to control the behavior of the piped on event watcher. Labels map[string]string `protobuf:"bytes,5,rep,name=labels,proto3" json:"labels,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"bytes,2,opt,name=value,proto3"` // A fixed-length identifier consists of its own name and labels. EventKey string `protobuf:"bytes,6,opt,name=event_key,json=eventKey,proto3" json:"event_key,omitempty"` // The handle status of event. Status EventStatus `protobuf:"varint,8,opt,name=status,proto3,enum=model.EventStatus" json:"status,omitempty"` StatusDescription string `protobuf:"bytes,9,opt,name=status_description,json=statusDescription,proto3" json:"status_description,omitempty"` + // The key/value pairs that are attached to event. + // This is intended to add more information from event trigger side. + // E.g. send the app code commit hash to Deployment. + Contexts map[string]string `protobuf:"bytes,10,rep,name=contexts,proto3" json:"contexts,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"bytes,2,opt,name=value,proto3"` // Unix time when the event was handled. HandledAt int64 `protobuf:"varint,13,opt,name=handled_at,json=handledAt,proto3" json:"handled_at,omitempty"` // Unix time when the event was created. @@ -204,6 +208,13 @@ func (x *Event) GetStatusDescription() string { return "" } +func (x *Event) GetContexts() map[string]string { + if x != nil { + return x.Contexts + } + return nil +} + func (x *Event) GetHandledAt() int64 { if x != nil { return x.HandledAt @@ -231,7 +242,7 @@ var file_pkg_model_event_proto_rawDesc = []byte{ 0x0a, 0x15, 0x70, 0x6b, 0x67, 0x2f, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x2f, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x05, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x1a, 0x17, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x2f, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, - 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0xef, 0x03, 0x0a, 0x05, 0x45, 0x76, 0x65, 0x6e, + 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0xe4, 0x04, 0x0a, 0x05, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x12, 0x17, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x42, 0x07, 0xfa, 0x42, 0x04, 0x72, 0x02, 0x10, 0x01, 0x52, 0x02, 0x69, 0x64, 0x12, 0x1b, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x42, 0x07, 0xfa, 0x42, 0x04, 0x72, 0x02, 0x10, @@ -251,27 +262,34 @@ var file_pkg_model_event_proto_rawDesc = []byte{ 0x10, 0x01, 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x2d, 0x0a, 0x12, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x5f, 0x64, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x09, 0x20, 0x01, 0x28, 0x09, 0x52, 0x11, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x44, 0x65, - 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x1d, 0x0a, 0x0a, 0x68, 0x61, 0x6e, - 0x64, 0x6c, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x18, 0x0d, 0x20, 0x01, 0x28, 0x03, 0x52, 0x09, 0x68, - 0x61, 0x6e, 0x64, 0x6c, 0x65, 0x64, 0x41, 0x74, 0x12, 0x26, 0x0a, 0x0a, 0x63, 0x72, 0x65, 0x61, - 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x18, 0x0e, 0x20, 0x01, 0x28, 0x03, 0x42, 0x07, 0xfa, 0x42, - 0x04, 0x22, 0x02, 0x20, 0x00, 0x52, 0x09, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x41, 0x74, - 0x12, 0x26, 0x0a, 0x0a, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x18, 0x0f, - 0x20, 0x01, 0x28, 0x03, 0x42, 0x07, 0xfa, 0x42, 0x04, 0x22, 0x02, 0x20, 0x00, 0x52, 0x09, 0x75, - 0x70, 0x64, 0x61, 0x74, 0x65, 0x64, 0x41, 0x74, 0x1a, 0x39, 0x0a, 0x0b, 0x4c, 0x61, 0x62, 0x65, - 0x6c, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, - 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, - 0x02, 0x38, 0x01, 0x4a, 0x04, 0x08, 0x07, 0x10, 0x08, 0x2a, 0x5e, 0x0a, 0x0b, 0x45, 0x76, 0x65, - 0x6e, 0x74, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x15, 0x0a, 0x11, 0x45, 0x56, 0x45, 0x4e, - 0x54, 0x5f, 0x4e, 0x4f, 0x54, 0x5f, 0x48, 0x41, 0x4e, 0x44, 0x4c, 0x45, 0x44, 0x10, 0x00, 0x12, - 0x11, 0x0a, 0x0d, 0x45, 0x56, 0x45, 0x4e, 0x54, 0x5f, 0x53, 0x55, 0x43, 0x43, 0x45, 0x53, 0x53, - 0x10, 0x01, 0x12, 0x11, 0x0a, 0x0d, 0x45, 0x56, 0x45, 0x4e, 0x54, 0x5f, 0x46, 0x41, 0x49, 0x4c, - 0x55, 0x52, 0x45, 0x10, 0x02, 0x12, 0x12, 0x0a, 0x0e, 0x45, 0x56, 0x45, 0x4e, 0x54, 0x5f, 0x4f, - 0x55, 0x54, 0x44, 0x41, 0x54, 0x45, 0x44, 0x10, 0x03, 0x42, 0x25, 0x5a, 0x23, 0x67, 0x69, 0x74, - 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x70, 0x69, 0x70, 0x65, 0x2d, 0x63, 0x64, 0x2f, - 0x70, 0x69, 0x70, 0x65, 0x63, 0x64, 0x2f, 0x70, 0x6b, 0x67, 0x2f, 0x6d, 0x6f, 0x64, 0x65, 0x6c, - 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, + 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x36, 0x0a, 0x08, 0x63, 0x6f, 0x6e, + 0x74, 0x65, 0x78, 0x74, 0x73, 0x18, 0x0a, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6d, 0x6f, + 0x64, 0x65, 0x6c, 0x2e, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x2e, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x78, + 0x74, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x08, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, + 0x73, 0x12, 0x1d, 0x0a, 0x0a, 0x68, 0x61, 0x6e, 0x64, 0x6c, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x18, + 0x0d, 0x20, 0x01, 0x28, 0x03, 0x52, 0x09, 0x68, 0x61, 0x6e, 0x64, 0x6c, 0x65, 0x64, 0x41, 0x74, + 0x12, 0x26, 0x0a, 0x0a, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x18, 0x0e, + 0x20, 0x01, 0x28, 0x03, 0x42, 0x07, 0xfa, 0x42, 0x04, 0x22, 0x02, 0x20, 0x00, 0x52, 0x09, 0x63, + 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x41, 0x74, 0x12, 0x26, 0x0a, 0x0a, 0x75, 0x70, 0x64, 0x61, + 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x18, 0x0f, 0x20, 0x01, 0x28, 0x03, 0x42, 0x07, 0xfa, 0x42, + 0x04, 0x22, 0x02, 0x20, 0x00, 0x52, 0x09, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x64, 0x41, 0x74, + 0x1a, 0x39, 0x0a, 0x0b, 0x4c, 0x61, 0x62, 0x65, 0x6c, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, + 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, + 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x1a, 0x3b, 0x0a, 0x0d, 0x43, + 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, + 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, + 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x76, + 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x4a, 0x04, 0x08, 0x07, 0x10, 0x08, 0x2a, 0x5e, + 0x0a, 0x0b, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x15, 0x0a, + 0x11, 0x45, 0x56, 0x45, 0x4e, 0x54, 0x5f, 0x4e, 0x4f, 0x54, 0x5f, 0x48, 0x41, 0x4e, 0x44, 0x4c, + 0x45, 0x44, 0x10, 0x00, 0x12, 0x11, 0x0a, 0x0d, 0x45, 0x56, 0x45, 0x4e, 0x54, 0x5f, 0x53, 0x55, + 0x43, 0x43, 0x45, 0x53, 0x53, 0x10, 0x01, 0x12, 0x11, 0x0a, 0x0d, 0x45, 0x56, 0x45, 0x4e, 0x54, + 0x5f, 0x46, 0x41, 0x49, 0x4c, 0x55, 0x52, 0x45, 0x10, 0x02, 0x12, 0x12, 0x0a, 0x0e, 0x45, 0x56, + 0x45, 0x4e, 0x54, 0x5f, 0x4f, 0x55, 0x54, 0x44, 0x41, 0x54, 0x45, 0x44, 0x10, 0x03, 0x42, 0x25, + 0x5a, 0x23, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x70, 0x69, 0x70, + 0x65, 0x2d, 0x63, 0x64, 0x2f, 0x70, 0x69, 0x70, 0x65, 0x63, 0x64, 0x2f, 0x70, 0x6b, 0x67, 0x2f, + 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, } var ( @@ -287,20 +305,22 @@ func file_pkg_model_event_proto_rawDescGZIP() []byte { } var file_pkg_model_event_proto_enumTypes = make([]protoimpl.EnumInfo, 1) -var file_pkg_model_event_proto_msgTypes = make([]protoimpl.MessageInfo, 2) +var file_pkg_model_event_proto_msgTypes = make([]protoimpl.MessageInfo, 3) var file_pkg_model_event_proto_goTypes = []interface{}{ (EventStatus)(0), // 0: model.EventStatus (*Event)(nil), // 1: model.Event nil, // 2: model.Event.LabelsEntry + nil, // 3: model.Event.ContextsEntry } var file_pkg_model_event_proto_depIdxs = []int32{ 2, // 0: model.Event.labels:type_name -> model.Event.LabelsEntry 0, // 1: model.Event.status:type_name -> model.EventStatus - 2, // [2:2] is the sub-list for method output_type - 2, // [2:2] is the sub-list for method input_type - 2, // [2:2] is the sub-list for extension type_name - 2, // [2:2] is the sub-list for extension extendee - 0, // [0:2] is the sub-list for field type_name + 3, // 2: model.Event.contexts:type_name -> model.Event.ContextsEntry + 3, // [3:3] is the sub-list for method output_type + 3, // [3:3] is the sub-list for method input_type + 3, // [3:3] is the sub-list for extension type_name + 3, // [3:3] is the sub-list for extension extendee + 0, // [0:3] is the sub-list for field type_name } func init() { file_pkg_model_event_proto_init() } @@ -328,7 +348,7 @@ func file_pkg_model_event_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: file_pkg_model_event_proto_rawDesc, NumEnums: 1, - NumMessages: 2, + NumMessages: 3, NumExtensions: 0, NumServices: 0, }, diff --git a/pkg/model/event.pb.validate.go b/pkg/model/event.pb.validate.go index f1a9de42ec..41a7c472a1 100644 --- a/pkg/model/event.pb.validate.go +++ b/pkg/model/event.pb.validate.go @@ -126,6 +126,8 @@ func (m *Event) validate(all bool) error { // no validation rules for StatusDescription + // no validation rules for Contexts + // no validation rules for HandledAt if m.GetCreatedAt() <= 0 { diff --git a/pkg/model/event.proto b/pkg/model/event.proto index 9425fcfd00..70d9e0ae09 100644 --- a/pkg/model/event.proto +++ b/pkg/model/event.proto @@ -38,13 +38,17 @@ message Event { // The ID of the project this event belongs to. string project_id = 4 [(validate.rules).string.min_len = 1]; // The key/value pairs that are attached to event. - // This is intended to be used to specify additional attributes of event. + // This is mainly used to control the behavior of the piped on event watcher. map labels = 5; // A fixed-length identifier consists of its own name and labels. string event_key = 6 [(validate.rules).string.min_len = 1]; // The handle status of event. EventStatus status = 8 [(validate.rules).enum.defined_only = true]; string status_description = 9; + // The key/value pairs that are attached to event. + // This is intended to add more information from event trigger side. + // E.g. send the app code commit hash to Deployment. + map contexts = 10; // Unix time when the event was handled. int64 handled_at = 13; diff --git a/web/model/event_pb.d.ts b/web/model/event_pb.d.ts index 1914ed816f..b1d0c0558b 100644 --- a/web/model/event_pb.d.ts +++ b/web/model/event_pb.d.ts @@ -28,6 +28,9 @@ export class Event extends jspb.Message { getStatusDescription(): string; setStatusDescription(value: string): Event; + getContextsMap(): jspb.Map; + clearContextsMap(): Event; + getHandledAt(): number; setHandledAt(value: number): Event; @@ -55,6 +58,7 @@ export namespace Event { eventKey: string, status: EventStatus, statusDescription: string, + contextsMap: Array<[string, string]>, handledAt: number, createdAt: number, updatedAt: number, diff --git a/web/model/event_pb.js b/web/model/event_pb.js index 9dc60336d3..3e5d7ad722 100644 --- a/web/model/event_pb.js +++ b/web/model/event_pb.js @@ -86,6 +86,7 @@ proto.model.Event.toObject = function(includeInstance, msg) { eventKey: jspb.Message.getFieldWithDefault(msg, 6, ""), status: jspb.Message.getFieldWithDefault(msg, 8, 0), statusDescription: jspb.Message.getFieldWithDefault(msg, 9, ""), + contextsMap: (f = msg.getContextsMap()) ? f.toObject(includeInstance, undefined) : [], handledAt: jspb.Message.getFieldWithDefault(msg, 13, 0), createdAt: jspb.Message.getFieldWithDefault(msg, 14, 0), updatedAt: jspb.Message.getFieldWithDefault(msg, 15, 0) @@ -159,6 +160,12 @@ proto.model.Event.deserializeBinaryFromReader = function(msg, reader) { var value = /** @type {string} */ (reader.readString()); msg.setStatusDescription(value); break; + case 10: + var value = msg.getContextsMap(); + reader.readMessage(value, function(message, reader) { + jspb.Map.deserializeBinary(message, reader, jspb.BinaryReader.prototype.readString, jspb.BinaryReader.prototype.readString, null, "", ""); + }); + break; case 13: var value = /** @type {number} */ (reader.readInt64()); msg.setHandledAt(value); @@ -253,6 +260,10 @@ proto.model.Event.serializeBinaryToWriter = function(message, writer) { f ); } + f = message.getContextsMap(true); + if (f && f.getLength() > 0) { + f.serializeBinary(10, writer, jspb.BinaryWriter.prototype.writeString, jspb.BinaryWriter.prototype.writeString); + } f = message.getHandledAt(); if (f !== 0) { writer.writeInt64( @@ -426,6 +437,29 @@ proto.model.Event.prototype.setStatusDescription = function(value) { }; +/** + * map contexts = 10; + * @param {boolean=} opt_noLazyCreate Do not create the map if + * empty, instead returning `undefined` + * @return {!jspb.Map} + */ +proto.model.Event.prototype.getContextsMap = function(opt_noLazyCreate) { + return /** @type {!jspb.Map} */ ( + jspb.Message.getMapField(this, 10, opt_noLazyCreate, + null)); +}; + + +/** + * Clears values from the map. The map will be non-null. + * @return {!proto.model.Event} returns this + */ +proto.model.Event.prototype.clearContextsMap = function() { + this.getContextsMap().clear(); + return this; +}; + + /** * optional int64 handled_at = 13; * @return {number} From 91000fec16e8418dfe7ca7befdeaec96607c10e0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 25 Oct 2024 23:11:34 +0900 Subject: [PATCH 74/84] Bump http-proxy-middleware from 2.0.6 to 2.0.7 in /web (#5296) Bumps [http-proxy-middleware](https://github.com/chimurai/http-proxy-middleware) from 2.0.6 to 2.0.7. - [Release notes](https://github.com/chimurai/http-proxy-middleware/releases) - [Changelog](https://github.com/chimurai/http-proxy-middleware/blob/v2.0.7/CHANGELOG.md) - [Commits](https://github.com/chimurai/http-proxy-middleware/compare/v2.0.6...v2.0.7) --- updated-dependencies: - dependency-name: http-proxy-middleware dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- web/yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/web/yarn.lock b/web/yarn.lock index b146e1d3fb..b75ba70e2c 100644 --- a/web/yarn.lock +++ b/web/yarn.lock @@ -4060,9 +4060,9 @@ http-proxy-agent@^5.0.0: debug "4" http-proxy-middleware@^2.0.3: - version "2.0.6" - resolved "https://registry.yarnpkg.com/http-proxy-middleware/-/http-proxy-middleware-2.0.6.tgz#e1a4dd6979572c7ab5a4e4b55095d1f32a74963f" - integrity sha512-ya/UeJ6HVBYxrgYotAZo1KvPWlgB48kUJLDePFeneHsVujFaW5WNj2NgWCAE//B1Dl02BIfYlpNgBy8Kf8Rjmw== + version "2.0.7" + resolved "https://registry.yarnpkg.com/http-proxy-middleware/-/http-proxy-middleware-2.0.7.tgz#915f236d92ae98ef48278a95dedf17e991936ec6" + integrity sha512-fgVY8AV7qU7z/MmXJ/rxwbrtQH4jBQ9m7kp3llF0liB7glmFeVZFBepQb32T3y8n8k2+AEYuMPCpinYW+/CuRA== dependencies: "@types/http-proxy" "^1.17.8" http-proxy "^1.18.1" From 6512b1f3fcba7342c11cf96968e753c78e66561b Mon Sep 17 00:00:00 2001 From: Shinnosuke Sawada-Dazai Date: Mon, 28 Oct 2024 13:01:51 +0900 Subject: [PATCH 75/84] Implement TemplateLocalChart with helm (#5294) * Implement TemplateLocalChart with helm Signed-off-by: Shinnosuke Sawada-Dazai * Copy testdata for helm test Signed-off-by: Shinnosuke Sawada-Dazai * Add helm test Signed-off-by: Shinnosuke Sawada-Dazai --------- Signed-off-by: Shinnosuke Sawada-Dazai --- .../pipedv1/plugin/kubernetes/config/helm.go | 31 +++ .../plugin/kubernetes/provider/helm.go | 161 ++++++++++++++++ .../plugin/kubernetes/provider/helm_test.go | 178 ++++++++++++++++++ .../provider/testdata/testchart/.helmignore | 23 +++ .../provider/testdata/testchart/Chart.yaml | 23 +++ .../testdata/testchart/templates/NOTES.txt | 21 +++ .../testdata/testchart/templates/_helpers.tpl | 63 +++++++ .../testchart/templates/deployment.yaml | 62 ++++++ .../testdata/testchart/templates/hpa.yaml | 28 +++ .../testdata/testchart/templates/ingress.yaml | 42 +++++ .../testdata/testchart/templates/service.yaml | 16 ++ .../testchart/templates/serviceaccount.yaml | 13 ++ .../templates/tests/test-connection.yaml | 15 ++ .../provider/testdata/testchart/values.yaml | 79 ++++++++ .../testhelm/appconfdir/app.pipecd.yaml | 0 .../testhelm/appconfdir/dir/values.yaml | 0 .../testhelm/appconfdir/invalid-symlink | 1 + .../testhelm/appconfdir/valid-symlink | 1 + .../testdata/testhelm/appconfdir/values.yaml | 0 .../provider/testdata/testhelm/values.yaml | 0 20 files changed, 757 insertions(+) create mode 100644 pkg/app/pipedv1/plugin/kubernetes/config/helm.go create mode 100644 pkg/app/pipedv1/plugin/kubernetes/provider/helm.go create mode 100644 pkg/app/pipedv1/plugin/kubernetes/provider/helm_test.go create mode 100644 pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testchart/.helmignore create mode 100644 pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testchart/Chart.yaml create mode 100644 pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testchart/templates/NOTES.txt create mode 100644 pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testchart/templates/_helpers.tpl create mode 100644 pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testchart/templates/deployment.yaml create mode 100644 pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testchart/templates/hpa.yaml create mode 100644 pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testchart/templates/ingress.yaml create mode 100644 pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testchart/templates/service.yaml create mode 100644 pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testchart/templates/serviceaccount.yaml create mode 100644 pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testchart/templates/tests/test-connection.yaml create mode 100644 pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testchart/values.yaml create mode 100644 pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testhelm/appconfdir/app.pipecd.yaml create mode 100644 pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testhelm/appconfdir/dir/values.yaml create mode 120000 pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testhelm/appconfdir/invalid-symlink create mode 120000 pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testhelm/appconfdir/valid-symlink create mode 100644 pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testhelm/appconfdir/values.yaml create mode 100644 pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testhelm/values.yaml diff --git a/pkg/app/pipedv1/plugin/kubernetes/config/helm.go b/pkg/app/pipedv1/plugin/kubernetes/config/helm.go new file mode 100644 index 0000000000..b0a3f3879c --- /dev/null +++ b/pkg/app/pipedv1/plugin/kubernetes/config/helm.go @@ -0,0 +1,31 @@ +// 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 + +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"` +} diff --git a/pkg/app/pipedv1/plugin/kubernetes/provider/helm.go b/pkg/app/pipedv1/plugin/kubernetes/provider/helm.go new file mode 100644 index 0000000000..a6796ec867 --- /dev/null +++ b/pkg/app/pipedv1/plugin/kubernetes/provider/helm.go @@ -0,0 +1,161 @@ +// 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 provider + +import ( + "bytes" + "context" + "fmt" + "net/url" + "os" + "os/exec" + "path/filepath" + "strings" + + "go.uber.org/zap" + + "github.com/pipe-cd/pipecd/pkg/app/pipedv1/plugin/kubernetes/config" +) + +var ( + allowedURLSchemes = []string{"http", "https"} +) + +type Helm struct { + execPath string + logger *zap.Logger +} + +func NewHelm(path string, logger *zap.Logger) *Helm { + return &Helm{ + execPath: path, + logger: logger, + } +} + +func (h *Helm) TemplateLocalChart(ctx context.Context, appName, appDir, namespace, chartPath string, opts *config.InputHelmOptions) (string, error) { + releaseName := appName + if opts != nil && opts.ReleaseName != "" { + releaseName = opts.ReleaseName + } + + args := []string{ + "template", + "--no-hooks", + "--include-crds", + releaseName, + chartPath, + } + + if namespace != "" { + args = append(args, fmt.Sprintf("--namespace=%s", namespace)) + } + + if opts != nil { + for k, v := range opts.SetValues { + args = append(args, "--set", fmt.Sprintf("%s=%s", k, v)) + } + for _, v := range opts.ValueFiles { + if err := verifyHelmValueFilePath(appDir, v); err != nil { + h.logger.Error("failed to verify values file path", zap.Error(err)) + return "", err + } + args = append(args, "-f", v) + } + for k, v := range opts.SetFiles { + args = append(args, "--set-file", fmt.Sprintf("%s=%s", k, v)) + } + for _, v := range opts.APIVersions { + args = append(args, "--api-versions", v) + } + if opts.KubeVersion != "" { + args = append(args, "--kube-version", opts.KubeVersion) + } + } + + var stdout, stderr bytes.Buffer + cmd := exec.CommandContext(ctx, h.execPath, args...) + cmd.Dir = appDir + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + h.logger.Info(fmt.Sprintf("start templating a local chart (or cloned remote git chart) for application %s", appName), + zap.Any("args", args), + ) + + if err := cmd.Run(); err != nil { + return stdout.String(), fmt.Errorf("%w: %s", err, stderr.String()) + } + return stdout.String(), nil +} + +// verifyHelmValueFilePath verifies if the path of the values file references +// a remote URL or inside the path where the application configuration file (i.e. *.pipecd.yaml) is located. +func verifyHelmValueFilePath(appDir, valueFilePath string) error { + url, err := url.Parse(valueFilePath) + if err == nil && url.Scheme != "" { + for _, s := range allowedURLSchemes { + if strings.EqualFold(url.Scheme, s) { + return nil + } + } + + return fmt.Errorf("scheme %s is not allowed to load values file", url.Scheme) + } + + // valueFilePath is a path where non-default Helm values file is located. + if !filepath.IsAbs(valueFilePath) { + valueFilePath = filepath.Join(appDir, valueFilePath) + } + + if isSymlink(valueFilePath) { + if valueFilePath, err = resolveSymlinkToAbsPath(valueFilePath, appDir); err != nil { + return err + } + } + + // If a path outside of appDir is specified as the path for the values file, + // it may indicate that someone trying to illegally read a file as values file that + // exists in the environment where Piped is running. + if !strings.HasPrefix(valueFilePath, appDir) { + return fmt.Errorf("values file %s references outside the application configuration directory", valueFilePath) + } + + return nil +} + +// isSymlink returns the path is whether symbolic link or not. +func isSymlink(path string) bool { + lstat, err := os.Lstat(path) + if err != nil { + return false + } + + return lstat.Mode()&os.ModeSymlink == os.ModeSymlink +} + +// resolveSymlinkToAbsPath resolves symbolic link to an absolute path. +func resolveSymlinkToAbsPath(path, absParentDir string) (string, error) { + resolved, err := os.Readlink(path) + if err != nil { + return "", err + } + + if !filepath.IsAbs(resolved) { + resolved = filepath.Join(absParentDir, resolved) + } + + return resolved, nil +} diff --git a/pkg/app/pipedv1/plugin/kubernetes/provider/helm_test.go b/pkg/app/pipedv1/plugin/kubernetes/provider/helm_test.go new file mode 100644 index 0000000000..b32a2152b3 --- /dev/null +++ b/pkg/app/pipedv1/plugin/kubernetes/provider/helm_test.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 provider + +import ( + "context" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + + "github.com/pipe-cd/pipecd/pkg/app/pipedv1/plugin/kubernetes/toolregistry" + "github.com/pipe-cd/pipecd/pkg/app/pipedv1/plugin/toolregistry/toolregistrytest" +) + +func TestTemplateLocalChart(t *testing.T) { + t.Parallel() + + var ( + ctx = context.Background() + appName = "testapp" + appDir = "testdata" + chartPath = "testchart" + ) + + c, err := toolregistrytest.NewToolRegistry(t) + require.NoError(t, err) + t.Cleanup(func() { c.Close() }) + + r := toolregistry.NewRegistry(c) + helmPath, err := r.Helm(ctx, "3.16.1") + require.NoError(t, err) + + helm := NewHelm(helmPath, zap.NewNop()) + out, err := helm.TemplateLocalChart(ctx, appName, appDir, "", chartPath, nil) + require.NoError(t, err) + + out = strings.TrimPrefix(out, "---") + manifests := strings.Split(out, "---") + assert.Equal(t, 3, len(manifests)) +} + +func TestTemplateLocalChart_WithNamespace(t *testing.T) { + t.Parallel() + + var ( + ctx = context.Background() + appName = "testapp" + appDir = "testdata" + chartPath = "testchart" + namespace = "testnamespace" + ) + + c, err := toolregistrytest.NewToolRegistry(t) + require.NoError(t, err) + t.Cleanup(func() { c.Close() }) + + r := toolregistry.NewRegistry(c) + helmPath, err := r.Helm(ctx, "3.16.1") + require.NoError(t, err) + + helm := NewHelm(helmPath, zap.NewNop()) + out, err := helm.TemplateLocalChart(ctx, appName, appDir, namespace, chartPath, nil) + require.NoError(t, err) + + out = strings.TrimPrefix(out, "---") + + manifests, _ := ParseManifests(out) + for _, manifest := range manifests { + metadata, _, err := unstructured.NestedMap(manifest.Body.Object, "metadata") + require.NoError(t, err) + require.Equal(t, namespace, metadata["namespace"]) + } +} + +func TestVerifyHelmValueFilePath(t *testing.T) { + t.Parallel() + + testcases := []struct { + name string + appDir string + valueFilePath string + wantErr bool + }{ + { + name: "Values file locates inside the app dir", + appDir: "testdata/testhelm/appconfdir", + valueFilePath: "values.yaml", + wantErr: false, + }, + { + name: "Values file locates inside the app dir (with ..)", + appDir: "testdata/testhelm/appconfdir", + valueFilePath: "../../../testdata/testhelm/appconfdir/values.yaml", + wantErr: false, + }, + { + name: "Values file locates under the app dir", + appDir: "testdata/testhelm/appconfdir", + valueFilePath: "dir/values.yaml", + wantErr: false, + }, + { + name: "Values file locates under the app dir (with ..)", + appDir: "testdata/testhelm/appconfdir", + valueFilePath: "../../../testdata/testhelm/appconfdir/dir/values.yaml", + wantErr: false, + }, + { + name: "arbitrary file locates outside the app dir", + appDir: "testdata/testhelm/appconfdir", + valueFilePath: "/etc/hosts", + wantErr: true, + }, + { + name: "arbitrary file locates outside the app dir (with ..)", + appDir: "testdata/testhelm/appconfdir", + valueFilePath: "../../../../../../../../../../../../etc/hosts", + wantErr: true, + }, + { + name: "Values file locates allowed remote URL (http)", + appDir: "testdata/testhelm/appconfdir", + valueFilePath: "http://exmaple.com/values.yaml", + wantErr: false, + }, + { + name: "Values file locates allowed remote URL (https)", + appDir: "testdata/testhelm/appconfdir", + valueFilePath: "https://exmaple.com/values.yaml", + wantErr: false, + }, + { + name: "Values file locates disallowed remote URL (ftp)", + appDir: "testdata/testhelm/appconfdir", + valueFilePath: "ftp://exmaple.com/values.yaml", + wantErr: true, + }, + { + name: "Values file is symlink targeting valid values file", + appDir: "testdata/testhelm/appconfdir", + valueFilePath: "valid-symlink", + wantErr: false, + }, + { + name: "Values file is symlink targeting invalid values file", + appDir: "testdata/testhelm/appconfdir", + valueFilePath: "invalid-symlink", + wantErr: true, + }, + } + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + err := verifyHelmValueFilePath(tc.appDir, tc.valueFilePath) + if tc.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + }) + } +} diff --git a/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testchart/.helmignore b/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testchart/.helmignore new file mode 100644 index 0000000000..0e8a0eb36f --- /dev/null +++ b/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testchart/.helmignore @@ -0,0 +1,23 @@ +# Patterns to ignore when building packages. +# This supports shell glob matching, relative path matching, and +# negation (prefixed with !). Only one pattern per line. +.DS_Store +# Common VCS dirs +.git/ +.gitignore +.bzr/ +.bzrignore +.hg/ +.hgignore +.svn/ +# Common backup files +*.swp +*.bak +*.tmp +*.orig +*~ +# Various IDEs +.project +.idea/ +*.tmproj +.vscode/ diff --git a/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testchart/Chart.yaml b/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testchart/Chart.yaml new file mode 100644 index 0000000000..5bbebd26c2 --- /dev/null +++ b/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testchart/Chart.yaml @@ -0,0 +1,23 @@ +apiVersion: v2 +name: testchart +description: A Helm chart for Kubernetes + +# A chart can be either an 'application' or a 'library' chart. +# +# Application charts are a collection of templates that can be packaged into versioned archives +# to be deployed. +# +# Library charts provide useful utilities or functions for the chart developer. They're included as +# a dependency of application charts to inject those utilities and functions into the rendering +# pipeline. Library charts do not define any templates and therefore cannot be deployed. +type: application + +# This is the chart version. This version number should be incremented each time you make changes +# to the chart and its templates, including the app version. +# Versions are expected to follow Semantic Versioning (https://semver.org/) +version: 0.1.0 + +# This is the version number of the application being deployed. This version number should be +# incremented each time you make changes to the application. Versions are not expected to +# follow Semantic Versioning. They should reflect the version the application is using. +appVersion: 1.16.0 diff --git a/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testchart/templates/NOTES.txt b/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testchart/templates/NOTES.txt new file mode 100644 index 0000000000..9b8fb51f68 --- /dev/null +++ b/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testchart/templates/NOTES.txt @@ -0,0 +1,21 @@ +1. Get the application URL by running these commands: +{{- if .Values.ingress.enabled }} +{{- range $host := .Values.ingress.hosts }} + {{- range .paths }} + http{{ if $.Values.ingress.tls }}s{{ end }}://{{ $host.host }}{{ . }} + {{- end }} +{{- end }} +{{- else if contains "NodePort" .Values.service.type }} + export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "testchart.fullname" . }}) + export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}") + echo http://$NODE_IP:$NODE_PORT +{{- else if contains "LoadBalancer" .Values.service.type }} + NOTE: It may take a few minutes for the LoadBalancer IP to be available. + You can watch the status of by running 'kubectl get --namespace {{ .Release.Namespace }} svc -w {{ include "testchart.fullname" . }}' + export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "testchart.fullname" . }} --template "{{"{{ range (index .status.loadBalancer.ingress 0) }}{{.}}{{ end }}"}}") + echo http://$SERVICE_IP:{{ .Values.service.port }} +{{- else if contains "ClusterIP" .Values.service.type }} + export POD_NAME=$(kubectl get pods --namespace {{ .Release.Namespace }} -l "app.kubernetes.io/name={{ include "testchart.name" . }},app.kubernetes.io/instance={{ .Release.Name }}" -o jsonpath="{.items[0].metadata.name}") + echo "Visit http://127.0.0.1:8080 to use your application" + kubectl --namespace {{ .Release.Namespace }} port-forward $POD_NAME 8080:80 +{{- end }} diff --git a/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testchart/templates/_helpers.tpl b/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testchart/templates/_helpers.tpl new file mode 100644 index 0000000000..698af2572c --- /dev/null +++ b/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testchart/templates/_helpers.tpl @@ -0,0 +1,63 @@ +{{/* vim: set filetype=mustache: */}} +{{/* +Expand the name of the chart. +*/}} +{{- define "testchart.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Create a default fully qualified app name. +We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). +If release name contains chart name it will be used as a full name. +*/}} +{{- define "testchart.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- $name := default .Chart.Name .Values.nameOverride }} +{{- if contains $name .Release.Name }} +{{- .Release.Name | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} +{{- end }} + +{{/* +Create chart name and version as used by the chart label. +*/}} +{{- define "testchart.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Common labels +*/}} +{{- define "testchart.labels" -}} +helm.sh/chart: {{ include "testchart.chart" . }} +{{ include "testchart.selectorLabels" . }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{/* +Selector labels +*/}} +{{- define "testchart.selectorLabels" -}} +app.kubernetes.io/name: {{ include "testchart.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{/* +Create the name of the service account to use +*/}} +{{- define "testchart.serviceAccountName" -}} +{{- if .Values.serviceAccount.create }} +{{- default (include "testchart.fullname" .) .Values.serviceAccount.name }} +{{- else }} +{{- default "default" .Values.serviceAccount.name }} +{{- end }} +{{- end }} diff --git a/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testchart/templates/deployment.yaml b/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testchart/templates/deployment.yaml new file mode 100644 index 0000000000..b9c4cf95df --- /dev/null +++ b/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testchart/templates/deployment.yaml @@ -0,0 +1,62 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "testchart.fullname" . }} + labels: + {{- include "testchart.labels" . | nindent 4 }} + namespace: {{.Release.Namespace}} +spec: +{{- if not .Values.autoscaling.enabled }} + replicas: {{ .Values.replicaCount }} +{{- end }} + selector: + matchLabels: + {{- include "testchart.selectorLabels" . | nindent 6 }} + template: + metadata: + {{- with .Values.podAnnotations }} + annotations: + {{- toYaml . | nindent 8 }} + {{- end }} + labels: + {{- include "testchart.selectorLabels" . | nindent 8 }} + spec: + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + serviceAccountName: {{ include "testchart.serviceAccountName" . }} + securityContext: + {{- toYaml .Values.podSecurityContext | nindent 8 }} + containers: + - name: {{ .Chart.Name }} + securityContext: + {{- toYaml .Values.securityContext | nindent 12 }} + image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + ports: + - name: http + containerPort: 80 + protocol: TCP + livenessProbe: + httpGet: + path: / + port: http + readinessProbe: + httpGet: + path: / + port: http + resources: + {{- toYaml .Values.resources | nindent 12 }} + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} diff --git a/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testchart/templates/hpa.yaml b/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testchart/templates/hpa.yaml new file mode 100644 index 0000000000..58c5a47d7e --- /dev/null +++ b/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testchart/templates/hpa.yaml @@ -0,0 +1,28 @@ +{{- if .Values.autoscaling.enabled }} +apiVersion: autoscaling/v2beta1 +kind: HorizontalPodAutoscaler +metadata: + name: {{ include "testchart.fullname" . }} + labels: + {{- include "testchart.labels" . | nindent 4 }} +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: {{ include "testchart.fullname" . }} + minReplicas: {{ .Values.autoscaling.minReplicas }} + maxReplicas: {{ .Values.autoscaling.maxReplicas }} + metrics: + {{- if .Values.autoscaling.targetCPUUtilizationPercentage }} + - type: Resource + resource: + name: cpu + targetAverageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }} + {{- end }} + {{- if .Values.autoscaling.targetMemoryUtilizationPercentage }} + - type: Resource + resource: + name: memory + targetAverageUtilization: {{ .Values.autoscaling.targetMemoryUtilizationPercentage }} + {{- end }} +{{- end }} diff --git a/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testchart/templates/ingress.yaml b/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testchart/templates/ingress.yaml new file mode 100644 index 0000000000..7c17e022f3 --- /dev/null +++ b/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testchart/templates/ingress.yaml @@ -0,0 +1,42 @@ +{{- if .Values.ingress.enabled -}} +{{- $fullName := include "testchart.fullname" . -}} +{{- $svcPort := .Values.service.port -}} +{{- if semverCompare ">=1.14-0" .Capabilities.KubeVersion.GitVersion -}} +apiVersion: networking.k8s.io/v1beta1 +{{- else -}} +apiVersion: extensions/v1beta1 +{{- end }} +kind: Ingress +metadata: + name: {{ $fullName }} + labels: + {{- include "testchart.labels" . | nindent 4 }} + {{- with .Values.ingress.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} + namespace: {{.Release.Namespace}} +spec: + {{- if .Values.ingress.tls }} + tls: + {{- range .Values.ingress.tls }} + - hosts: + {{- range .hosts }} + - {{ . | quote }} + {{- end }} + secretName: {{ .secretName }} + {{- end }} + {{- end }} + rules: + {{- range .Values.ingress.hosts }} + - host: {{ .host | quote }} + http: + paths: + {{- range .paths }} + - path: {{ . }} + backend: + serviceName: {{ $fullName }} + servicePort: {{ $svcPort }} + {{- end }} + {{- end }} + {{- end }} diff --git a/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testchart/templates/service.yaml b/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testchart/templates/service.yaml new file mode 100644 index 0000000000..d8c6e26de7 --- /dev/null +++ b/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testchart/templates/service.yaml @@ -0,0 +1,16 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "testchart.fullname" . }} + labels: + {{- include "testchart.labels" . | nindent 4 }} + namespace: {{.Release.Namespace}} +spec: + type: {{ .Values.service.type }} + ports: + - port: {{ .Values.service.port }} + targetPort: http + protocol: TCP + name: http + selector: + {{- include "testchart.selectorLabels" . | nindent 4 }} diff --git a/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testchart/templates/serviceaccount.yaml b/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testchart/templates/serviceaccount.yaml new file mode 100644 index 0000000000..4537db7747 --- /dev/null +++ b/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testchart/templates/serviceaccount.yaml @@ -0,0 +1,13 @@ +{{- if .Values.serviceAccount.create -}} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "testchart.serviceAccountName" . }} + namespace: {{.Release.Namespace}} + labels: + {{- include "testchart.labels" . | nindent 4 }} + {{- with .Values.serviceAccount.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +{{- end }} diff --git a/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testchart/templates/tests/test-connection.yaml b/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testchart/templates/tests/test-connection.yaml new file mode 100644 index 0000000000..94ec750986 --- /dev/null +++ b/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testchart/templates/tests/test-connection.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Pod +metadata: + name: "{{ include "testchart.fullname" . }}-test-connection" + labels: + {{- include "testchart.labels" . | nindent 4 }} + annotations: + "helm.sh/hook": test-success +spec: + containers: + - name: wget + image: busybox + command: ['wget'] + args: ['{{ include "testchart.fullname" . }}:{{ .Values.service.port }}'] + restartPolicy: Never diff --git a/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testchart/values.yaml b/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testchart/values.yaml new file mode 100644 index 0000000000..6c45a41504 --- /dev/null +++ b/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testchart/values.yaml @@ -0,0 +1,79 @@ +# Default values for testchart. +# This is a YAML-formatted file. +# Declare variables to be passed into your templates. + +replicaCount: 1 + +image: + repository: nginx + pullPolicy: IfNotPresent + # Overrides the image tag whose default is the chart appVersion. + tag: "" + +imagePullSecrets: [] +nameOverride: "" +fullnameOverride: "" + +serviceAccount: + # Specifies whether a service account should be created + create: true + # Annotations to add to the service account + annotations: {} + # The name of the service account to use. + # If not set and create is true, a name is generated using the fullname template + name: "" + +podAnnotations: {} + +podSecurityContext: {} + # fsGroup: 2000 + +securityContext: {} + # capabilities: + # drop: + # - ALL + # readOnlyRootFilesystem: true + # runAsNonRoot: true + # runAsUser: 1000 + +service: + type: ClusterIP + port: 80 + +ingress: + enabled: false + annotations: {} + # kubernetes.io/ingress.class: nginx + # kubernetes.io/tls-acme: "true" + hosts: + - host: chart-example.local + paths: [] + tls: [] + # - secretName: chart-example-tls + # hosts: + # - chart-example.local + +resources: {} + # We usually recommend not to specify default resources and to leave this as a conscious + # choice for the user. This also increases chances charts run on environments with little + # resources, such as Minikube. If you do want to specify resources, uncomment the following + # lines, adjust them as necessary, and remove the curly braces after 'resources:'. + # limits: + # cpu: 100m + # memory: 128Mi + # requests: + # cpu: 100m + # memory: 128Mi + +autoscaling: + enabled: false + minReplicas: 1 + maxReplicas: 100 + targetCPUUtilizationPercentage: 80 + # targetMemoryUtilizationPercentage: 80 + +nodeSelector: {} + +tolerations: [] + +affinity: {} diff --git a/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testhelm/appconfdir/app.pipecd.yaml b/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testhelm/appconfdir/app.pipecd.yaml new file mode 100644 index 0000000000..e69de29bb2 diff --git a/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testhelm/appconfdir/dir/values.yaml b/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testhelm/appconfdir/dir/values.yaml new file mode 100644 index 0000000000..e69de29bb2 diff --git a/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testhelm/appconfdir/invalid-symlink b/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testhelm/appconfdir/invalid-symlink new file mode 120000 index 0000000000..555dec973e --- /dev/null +++ b/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testhelm/appconfdir/invalid-symlink @@ -0,0 +1 @@ +/etc/hosts \ No newline at end of file diff --git a/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testhelm/appconfdir/valid-symlink b/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testhelm/appconfdir/valid-symlink new file mode 120000 index 0000000000..a53324e8c5 --- /dev/null +++ b/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testhelm/appconfdir/valid-symlink @@ -0,0 +1 @@ +dir/values.yaml \ No newline at end of file diff --git a/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testhelm/appconfdir/values.yaml b/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testhelm/appconfdir/values.yaml new file mode 100644 index 0000000000..e69de29bb2 diff --git a/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testhelm/values.yaml b/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testhelm/values.yaml new file mode 100644 index 0000000000..e69de29bb2 From c842a89677f773f8c13f085282e36770d6de4225 Mon Sep 17 00:00:00 2001 From: Shinnosuke Sawada-Dazai Date: Tue, 29 Oct 2024 10:08:54 +0900 Subject: [PATCH 76/84] Copy pkg/diff to pkg/plugin/diff except DiffStructureds and RenderByCommand (#5297) * Copy almost of pkg/diff to under the plugin package Signed-off-by: Shinnosuke Sawada-Dazai * Move pkg/app/pipedv1/plugin/diff to pkg/plugin/diff Signed-off-by: Shinnosuke Sawada-Dazai --------- Signed-off-by: Shinnosuke Sawada-Dazai --- pkg/plugin/diff/diff.go | 440 ++++++++++++++++++ pkg/plugin/diff/diff_test.go | 321 +++++++++++++ pkg/plugin/diff/renderer.go | 224 +++++++++ pkg/plugin/diff/renderer_test.go | 206 ++++++++ pkg/plugin/diff/result.go | 155 ++++++ pkg/plugin/diff/result_test.go | 102 ++++ pkg/plugin/diff/testdata/complex-node.yaml | 33 ++ pkg/plugin/diff/testdata/has_diff.yaml | 71 +++ .../diff/testdata/ignore_adding_map_keys.yaml | 58 +++ pkg/plugin/diff/testdata/no_diff.yaml | 84 ++++ 10 files changed, 1694 insertions(+) create mode 100644 pkg/plugin/diff/diff.go create mode 100644 pkg/plugin/diff/diff_test.go create mode 100644 pkg/plugin/diff/renderer.go create mode 100644 pkg/plugin/diff/renderer_test.go create mode 100644 pkg/plugin/diff/result.go create mode 100644 pkg/plugin/diff/result_test.go create mode 100644 pkg/plugin/diff/testdata/complex-node.yaml create mode 100644 pkg/plugin/diff/testdata/has_diff.yaml create mode 100644 pkg/plugin/diff/testdata/ignore_adding_map_keys.yaml create mode 100644 pkg/plugin/diff/testdata/no_diff.yaml diff --git a/pkg/plugin/diff/diff.go b/pkg/plugin/diff/diff.go new file mode 100644 index 0000000000..9b188bd1c1 --- /dev/null +++ b/pkg/plugin/diff/diff.go @@ -0,0 +1,440 @@ +// 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 diff + +import ( + "fmt" + "reflect" + "strconv" + "strings" + + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" +) + +type differ struct { + ignoreAddingMapKeys bool + equateEmpty bool + compareNumberAndNumericString bool + ignoredPaths map[string]struct{} + ignoreConfig map[string][]string + + result *Result +} + +type Option func(*differ) + +// WithIgnoreAddingMapKeys configures differ to ignore all map keys +// that were added to the second object and missing in the first one. +func WithIgnoreAddingMapKeys() Option { + return func(d *differ) { + d.ignoreAddingMapKeys = true + } +} + +// WithEquateEmpty configures differ to consider all maps/slides with a length of zero and nil to be equal. +func WithEquateEmpty() Option { + return func(d *differ) { + d.equateEmpty = true + } +} + +// WithCompareNumberAndNumericString configures differ to compare a number with a numeric string. +// Differ parses the string to number before comparing their values. +// e.g. 1.5 == "1.5" +func WithCompareNumberAndNumericString() Option { + return func(d *differ) { + d.compareNumberAndNumericString = true + } +} + +// WithIgnoreConfig configures ignored fields. +func WithIgnoreConfig(config map[string][]string) Option { + return func(d *differ) { + d.ignoreConfig = config + } +} + +func (d *differ) initIgnoredPaths(key string) { + paths := d.ignoreConfig[key] + d.ignoredPaths = make(map[string]struct{}, len(paths)) + + for _, path := range paths { + d.ignoredPaths[path] = struct{}{} + } +} + +// DiffUnstructureds calculates the diff between two unstructured objects. +// If you compare non-k8s manifests, use DiffStructureds instead. +func DiffUnstructureds(x, y unstructured.Unstructured, key string, opts ...Option) (*Result, error) { + var ( + path = []PathStep{} + vx = reflect.ValueOf(x.Object) + vy = reflect.ValueOf(y.Object) + d = &differ{result: &Result{}} + ) + for _, opt := range opts { + opt(d) + } + + d.initIgnoredPaths(key) + + if err := d.diff(path, vx, vy); err != nil { + return nil, err + } + + d.result.sort() + return d.result, nil +} + +func (d *differ) diff(path []PathStep, vx, vy reflect.Value) error { + if !vx.IsValid() { + if d.equateEmpty && isEmptyInterface(vy) { + return nil + } + + d.addNode(path, nil, vy.Type(), vx, vy) + return nil + } + + if !vy.IsValid() { + if d.equateEmpty && isEmptyInterface(vx) { + return nil + } + + d.addNode(path, vx.Type(), nil, vx, vy) + return nil + } + + if isNumberValue(vx) && isNumberValue(vy) { + return d.diffNumber(path, vx, vy) + } + + if d.compareNumberAndNumericString { + if isNumberValue(vx) { + if y, ok := convertToNumber(vy); ok { + return d.diffNumber(path, vx, y) + } + } + + if isNumberValue(vy) { + if x, ok := convertToNumber(vx); ok { + return d.diffNumber(path, x, vy) + } + } + } + + if vx.Type() != vy.Type() { + d.addNode(path, vx.Type(), vy.Type(), vx, vy) + return nil + } + + switch vx.Kind() { + case reflect.Map: + return d.diffMap(path, vx, vy) + + case reflect.Slice, reflect.Array: + return d.diffSlice(path, vx, vy) + + case reflect.Interface: + return d.diffInterface(path, vx, vy) + + case reflect.String: + return d.diffString(path, vx, vy) + + case reflect.Bool: + return d.diffBool(path, vx, vy) + + default: + return fmt.Errorf("%v kind is not handled", vx.Kind()) + } +} + +func (d *differ) diffSlice(path []PathStep, vx, vy reflect.Value) error { + if vx.IsNil() || vy.IsNil() { + d.addNode(path, vx.Type(), vy.Type(), vx, vy) + return nil + } + + minLen := vx.Len() + if minLen > vy.Len() { + minLen = vy.Len() + } + + for i := 0; i < minLen; i++ { + nextPath := newSlicePath(path, i) + nextValueX := vx.Index(i) + nextValueY := vy.Index(i) + if err := d.diff(nextPath, nextValueX, nextValueY); err != nil { + return err + } + } + + for i := minLen; i < vx.Len(); i++ { + nextPath := newSlicePath(path, i) + nextValueX := vx.Index(i) + d.addNode(nextPath, nextValueX.Type(), nextValueX.Type(), nextValueX, reflect.Value{}) + } + + for i := minLen; i < vy.Len(); i++ { + nextPath := newSlicePath(path, i) + nextValueY := vy.Index(i) + d.addNode(nextPath, nextValueY.Type(), nextValueY.Type(), reflect.Value{}, nextValueY) + } + + return nil +} + +func (d *differ) diffMap(path []PathStep, vx, vy reflect.Value) error { + if vx.IsNil() || vy.IsNil() { + d.addNode(path, vx.Type(), vy.Type(), vx, vy) + return nil + } + + keys := append(vx.MapKeys(), vy.MapKeys()...) + checks := make(map[string]struct{}) + + for _, k := range keys { + if k.Kind() != reflect.String { + return fmt.Errorf("unsupport %v as key type of a map", k.Kind()) + } + if _, ok := checks[k.String()]; ok { + continue + } + + nextValueY := vy.MapIndex(k) + // Don't need to check the key existing in the first(LiveManifest) one but missing in the seccond(GitManifest) one + // when IgnoreAddingMapKeys is configured. + if d.ignoreAddingMapKeys && !nextValueY.IsValid() { + continue + } + + nextPath := newMapPath(path, k.String()) + nextValueX := vx.MapIndex(k) + checks[k.String()] = struct{}{} + if err := d.diff(nextPath, nextValueX, nextValueY); err != nil { + return err + } + } + return nil +} + +func (d *differ) diffInterface(path []PathStep, vx, vy reflect.Value) error { + if isEmptyInterface(vx) && isEmptyInterface(vy) { + return nil + } + + if vx.IsNil() || vy.IsNil() { + d.addNode(path, vx.Type(), vy.Type(), vx, vy) + return nil + } + + vx, vy = vx.Elem(), vy.Elem() + return d.diff(path, vx, vy) +} + +func (d *differ) diffString(path []PathStep, vx, vy reflect.Value) error { + if vx.String() == vy.String() { + return nil + } + d.addNode(path, vx.Type(), vy.Type(), vx, vy) + return nil +} + +func (d *differ) diffBool(path []PathStep, vx, vy reflect.Value) error { + if vx.Bool() == vy.Bool() { + return nil + } + d.addNode(path, vx.Type(), vy.Type(), vx, vy) + return nil +} + +func (d *differ) diffNumber(path []PathStep, vx, vy reflect.Value) error { + if floatNumber(vx) == floatNumber(vy) { + return nil + } + + d.addNode(path, vx.Type(), vy.Type(), vx, vy) + return nil +} + +// isEmptyInterface reports whether v is nil or zero value or its element is an empty map, an empty slice. +func isEmptyInterface(v reflect.Value) bool { + if !v.IsValid() || v.IsNil() || v.IsZero() { + return true + } + if v.Kind() != reflect.Interface { + return false + } + + e := v.Elem() + + // When the value that the interface v contains is a zero value (false boolean, zero number, empty string...). + if e.IsZero() { + return true + } + + switch e.Kind() { + case reflect.Array, reflect.Slice, reflect.Map: + return e.Len() == 0 + default: + return false + } +} + +func floatNumber(v reflect.Value) float64 { + switch v.Kind() { + case reflect.Float32, reflect.Float64: + return v.Float() + default: + return float64(v.Int()) + } +} + +func isNumberValue(v reflect.Value) bool { + switch v.Kind() { + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, reflect.Float32, reflect.Float64: + return true + default: + return false + } +} + +func convertToNumber(v reflect.Value) (reflect.Value, bool) { + switch v.Kind() { + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, reflect.Float32, reflect.Float64: + return v, true + case reflect.String: + if n, err := strconv.ParseFloat(v.String(), 64); err == nil { + return reflect.ValueOf(n), true + } + return v, false + default: + return v, false + } +} + +func newSlicePath(path []PathStep, index int) []PathStep { + next := make([]PathStep, len(path)) + copy(next, path) + next = append(next, PathStep{ + Type: SliceIndexPathStep, + SliceIndex: index, + }) + return next +} + +func newMapPath(path []PathStep, index string) []PathStep { + next := make([]PathStep, len(path)) + copy(next, path) + next = append(next, PathStep{ + Type: MapIndexPathStep, + MapIndex: index, + }) + return next +} + +func (d *differ) addNode(path []PathStep, tx, ty reflect.Type, vx, vy reflect.Value) { + if len(d.ignoredPaths) > 0 { + pathString := makePathString(path) + if d.isIgnoredPath(pathString) { + return + } + nvx := d.ignoredValue(vx, pathString) + nvy := d.ignoredValue(vy, pathString) + + d.result.addNode(path, tx, ty, nvx, nvy) + return + } + + d.result.addNode(path, tx, ty, vx, vy) +} + +func (d *differ) ignoredValue(v reflect.Value, prefix string) reflect.Value { + switch v.Kind() { + case reflect.Map: + nv := reflect.MakeMap(v.Type()) + keys := v.MapKeys() + for _, k := range keys { + nprefix := prefix + "." + k.String() + if d.isIgnoredPath(nprefix) { + continue + } + + sub := v.MapIndex(k) + filtered := d.ignoredValue(sub, nprefix) + if !filtered.IsValid() { + continue + } + nv.SetMapIndex(k, filtered) + } + return nv + + case reflect.Slice, reflect.Array: + nv := reflect.MakeSlice(v.Type(), 0, 0) + for i := 0; i < v.Len(); i++ { + nprefix := prefix + "." + strconv.Itoa(i) + if d.isIgnoredPath(nprefix) { + continue + } + + filtered := d.ignoredValue(v.Index(i), nprefix) + if !filtered.IsValid() { + continue + } + nv = reflect.Append(nv, filtered) + } + return nv + + case reflect.Interface: + nprefix := prefix + "." + v.String() + if d.isIgnoredPath(nprefix) { + return reflect.New(v.Type()) + } + return d.ignoredValue(v.Elem(), prefix) + + case reflect.String: + return v + + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, + reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + return v + + case reflect.Float32, reflect.Float64: + return v + + default: + nprefix := prefix + "." + v.String() + if d.isIgnoredPath(nprefix) { + return reflect.New(v.Type()) + } + return v + } +} + +func (d *differ) isIgnoredPath(pathString string) bool { + var pathSubStr string + pathElms := strings.Split(pathString, ".") + + for i, path := range pathElms { + if i != 0 { + pathSubStr += "." + } + pathSubStr += path + if _, found := d.ignoredPaths[pathSubStr]; found { + return true + } + } + return false +} diff --git a/pkg/plugin/diff/diff_test.go b/pkg/plugin/diff/diff_test.go new file mode 100644 index 0000000000..d1bffcf4c5 --- /dev/null +++ b/pkg/plugin/diff/diff_test.go @@ -0,0 +1,321 @@ +// 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 diff + +import ( + "os" + "reflect" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "sigs.k8s.io/yaml" +) + +func TestDiff(t *testing.T) { + testcases := []struct { + name string + yamlFile string + resourceKey string + options []Option + diffNum int + diffString string + }{ + { + name: "no diff", + yamlFile: "testdata/no_diff.yaml", + options: []Option{ + WithEquateEmpty(), + WithIgnoreAddingMapKeys(), + WithCompareNumberAndNumericString(), + }, + diffNum: 0, + }, + { + name: "no diff by ignoring all adding map keys", + yamlFile: "testdata/ignore_adding_map_keys.yaml", + options: []Option{ + WithIgnoreAddingMapKeys(), + }, + diffNum: 0, + }, + { + name: "diff by ignoring specified field with correct key", + yamlFile: "testdata/has_diff.yaml", + resourceKey: "deployment-key", + options: []Option{ + WithIgnoreConfig( + map[string][]string{ + "deployment-key": { + "spec.replicas", + "spec.template.spec.containers.0.args.1", + "spec.template.spec.strategy.rollingUpdate.maxSurge", + "spec.template.spec.containers.3.livenessProbe.initialDelaySeconds", + }, + }, + ), + }, + diffNum: 6, + diffString: ` spec: + template: + metadata: + labels: + #spec.template.metadata.labels.app +- app: simple ++ app: simple2 + + #spec.template.metadata.labels.component +- component: foo + + spec: + containers: + - + #spec.template.spec.containers.1.image +- image: gcr.io/pipecd/helloworld:v2.0.0 ++ image: gcr.io/pipecd/helloworld:v2.1.0 + + - + #spec.template.spec.containers.2.image +- image: + + #spec.template.spec.containers.3 ++ - image: new-image ++ livenessProbe: ++ exec: ++ command: ++ - cat ++ - /tmp/healthy ++ name: foo + + #spec.template.spec.strategy ++ strategy: ++ rollingUpdate: ++ maxUnavailable: 25% ++ type: RollingUpdate + +`, + }, + { + name: "diff by ignoring specified field with wrong resource key", + yamlFile: "testdata/has_diff.yaml", + resourceKey: "deployment-key", + options: []Option{ + WithIgnoreConfig( + map[string][]string{ + "crd-key": { + "spec.replicas", + "spec.template.spec.containers.0.args.1", + "spec.template.spec.strategy.rollingUpdate.maxSurge", + "spec.template.spec.containers.3.livenessProbe.initialDelaySeconds", + }, + }, + ), + }, + diffNum: 8, + diffString: ` spec: + #spec.replicas +- replicas: 2 ++ replicas: 3 + + template: + metadata: + labels: + #spec.template.metadata.labels.app +- app: simple ++ app: simple2 + + #spec.template.metadata.labels.component +- component: foo + + spec: + containers: + - args: + #spec.template.spec.containers.0.args.1 +- - hello + + - + #spec.template.spec.containers.1.image +- image: gcr.io/pipecd/helloworld:v2.0.0 ++ image: gcr.io/pipecd/helloworld:v2.1.0 + + - + #spec.template.spec.containers.2.image +- image: + + #spec.template.spec.containers.3 ++ - image: new-image ++ livenessProbe: ++ exec: ++ command: ++ - cat ++ - /tmp/healthy ++ initialDelaySeconds: 5 ++ name: foo + + #spec.template.spec.strategy ++ strategy: ++ rollingUpdate: ++ maxSurge: 25% ++ maxUnavailable: 25% ++ type: RollingUpdate + +`, + }, + { + name: "has diff", + yamlFile: "testdata/has_diff.yaml", + diffNum: 8, + diffString: ` spec: + #spec.replicas +- replicas: 2 ++ replicas: 3 + + template: + metadata: + labels: + #spec.template.metadata.labels.app +- app: simple ++ app: simple2 + + #spec.template.metadata.labels.component +- component: foo + + spec: + containers: + - args: + #spec.template.spec.containers.0.args.1 +- - hello + + - + #spec.template.spec.containers.1.image +- image: gcr.io/pipecd/helloworld:v2.0.0 ++ image: gcr.io/pipecd/helloworld:v2.1.0 + + - + #spec.template.spec.containers.2.image +- image: + + #spec.template.spec.containers.3 ++ - image: new-image ++ livenessProbe: ++ exec: ++ command: ++ - cat ++ - /tmp/healthy ++ initialDelaySeconds: 5 ++ name: foo + + #spec.template.spec.strategy ++ strategy: ++ rollingUpdate: ++ maxSurge: 25% ++ maxUnavailable: 25% ++ type: RollingUpdate + +`, + }, + } + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + objs, err := loadUnstructureds(tc.yamlFile) + require.NoError(t, err) + require.Equal(t, 2, len(objs)) + + result, err := DiffUnstructureds(objs[0], objs[1], tc.resourceKey, tc.options...) + require.NoError(t, err) + assert.Equal(t, tc.diffNum, result.NumNodes()) + + renderer := NewRenderer(WithLeftPadding(1)) + ds := renderer.Render(result.Nodes()) + + assert.Equal(t, tc.diffString, ds) + }) + } +} + +func loadUnstructureds(path string) ([]unstructured.Unstructured, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + + const separator = "\n---" + parts := strings.Split(string(data), separator) + out := make([]unstructured.Unstructured, 0, len(parts)) + + for _, part := range parts { + // Ignore all the cases where no content between separator. + part = strings.TrimSpace(part) + if len(part) == 0 { + continue + } + var obj unstructured.Unstructured + if err := yaml.Unmarshal([]byte(part), &obj); err != nil { + return nil, err + } + out = append(out, obj) + } + return out, nil +} + +func TestIsEmptyInterface(t *testing.T) { + testcases := []struct { + name string + v interface{} + expected bool + }{ + { + name: "nil", + v: nil, + expected: true, + }, + { + name: "nil map", + v: map[string]int(nil), + expected: true, + }, + { + name: "empty map", + v: map[string]int{}, + expected: true, + }, + { + name: "nil slice", + v: []int(nil), + expected: true, + }, + { + name: "empty slice", + v: []int{}, + expected: true, + }, + { + name: "number", + v: 1, + expected: false, + }, + } + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + s := []interface{}{tc.v} + v := reflect.ValueOf(s) + + got := isEmptyInterface(v.Index(0)) + assert.Equal(t, tc.expected, got) + }) + } +} diff --git a/pkg/plugin/diff/renderer.go b/pkg/plugin/diff/renderer.go new file mode 100644 index 0000000000..8fb6c320fe --- /dev/null +++ b/pkg/plugin/diff/renderer.go @@ -0,0 +1,224 @@ +// 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 diff + +import ( + "fmt" + "reflect" + "sort" + "strconv" + "strings" +) + +type Renderer struct { + leftPadding int + maskPathPrefix string +} + +type RenderOption func(*Renderer) + +const maskString = "*****" + +func WithLeftPadding(p int) RenderOption { + return func(r *Renderer) { + r.leftPadding = p + } +} + +func WithMaskPath(prefix string) RenderOption { + return func(r *Renderer) { + r.maskPathPrefix = prefix + } +} + +func NewRenderer(opts ...RenderOption) *Renderer { + r := &Renderer{} + for _, opt := range opts { + opt(r) + } + return r +} + +func (r *Renderer) Render(ns Nodes) string { + if len(ns) == 0 { + return "" + } + + var prePath []PathStep + var b strings.Builder + + printValue := func(mark string, v reflect.Value, lastStep PathStep, depth int) { + if !v.IsValid() { + return + } + + nodeString, nl := renderNodeValue(v, "") + if lastStep.Type == SliceIndexPathStep { + nl = false + } + + switch { + case lastStep.Type == SliceIndexPathStep: + b.WriteString(fmt.Sprintf("%s%*s- ", mark, depth*2-1, "")) + case nl: + b.WriteString(fmt.Sprintf("%s%*s%s:\n", mark, depth*2-1, "", lastStep.String())) + default: + b.WriteString(fmt.Sprintf("%s%*s%s: ", mark, depth*2-1, "", lastStep.String())) + } + + parts := strings.Split(nodeString, "\n") + for i, p := range parts { + if lastStep.Type != SliceIndexPathStep { + if nl { + b.WriteString(fmt.Sprintf("%s%*s%s\n", mark, depth*2+1, "", p)) + } else { + b.WriteString(fmt.Sprintf("%s\n", p)) + } + continue + } + if i == 0 { + b.WriteString(fmt.Sprintf("%s\n", p)) + continue + } + b.WriteString(fmt.Sprintf("%s%*s%s\n", mark, depth*2+1, "", p)) + } + } + + for _, n := range ns { + duplicateDepth := pathDuplicateDepth(n.Path, prePath) + prePath = n.Path + pathLen := len(n.Path) + + var array bool + for i := duplicateDepth; i < pathLen-1; i++ { + if n.Path[i].Type == SliceIndexPathStep { + b.WriteString(fmt.Sprintf("%*s-", (r.leftPadding+i)*2, "")) + array = true + continue + } + if array { + b.WriteString(fmt.Sprintf(" %s:\n", n.Path[i].String())) + array = false + continue + } + b.WriteString(fmt.Sprintf("%*s%s:\n", (r.leftPadding+i)*2, "", n.Path[i].String())) + } + if array { + b.WriteString("\n") + } + + lastStep := n.Path[pathLen-1] + valueX, valueY := n.ValueX, n.ValueY + if r.maskPathPrefix != "" && strings.HasPrefix(n.PathString, r.maskPathPrefix) { + valueX = reflect.ValueOf(maskString) + valueY = reflect.ValueOf(maskString) + } + + b.WriteString(fmt.Sprintf("%*s#%s\n", (r.leftPadding+pathLen-1)*2, "", n.PathString)) + printValue("-", valueX, lastStep, r.leftPadding+pathLen-1) + printValue("+", valueY, lastStep, r.leftPadding+pathLen-1) + b.WriteString("\n") + } + + return b.String() +} + +func pathDuplicateDepth(x, y []PathStep) int { + minLen := len(x) + if minLen > len(y) { + minLen = len(y) + } + + for i := 0; i < minLen; i++ { + if x[i] == y[i] { + continue + } + return i + } + return 0 +} + +func renderNodeValue(v reflect.Value, prefix string) (string, bool) { + switch v.Kind() { + case reflect.Map: + out := make([]string, 0, v.Len()) + keys := v.MapKeys() + sort.Slice(keys, func(i, j int) bool { + return keys[i].String() < keys[j].String() + }) + for _, k := range keys { + sub := v.MapIndex(k) + subString, nl := renderNodeValue(sub, prefix+" ") + if !nl { + out = append(out, fmt.Sprintf("%s%s: %s", prefix, k.String(), subString)) + continue + } + out = append(out, fmt.Sprintf("%s%s:\n%s", prefix, k.String(), subString)) + } + if len(out) == 0 { + return "", false + } + return strings.Join(out, "\n"), true + + case reflect.Slice, reflect.Array: + out := make([]string, 0, v.Len()) + for i := 0; i < v.Len(); i++ { + sub, _ := renderNodeValue(v.Index(i), prefix+" ") + parts := strings.Split(sub, "\n") + for i, p := range parts { + p = strings.TrimPrefix(p, prefix+" ") + if i == 0 { + out = append(out, fmt.Sprintf("%s- %s", prefix, p)) + continue + } + out = append(out, fmt.Sprintf("%s %s", prefix, p)) + } + } + return strings.Join(out, "\n"), true + + case reflect.Interface: + return renderNodeValue(v.Elem(), prefix) + + case reflect.String: + return v.String(), false + + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, + reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + return strconv.FormatInt(v.Int(), 10), false + + case reflect.Float32, reflect.Float64: + return strconv.FormatFloat(v.Float(), 'f', -1, 64), false + + default: + return v.String(), false + } +} + +func RenderPrimitiveValue(v reflect.Value) string { + switch v.Kind() { + case reflect.String: + return v.String() + + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, + reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + return strconv.FormatInt(v.Int(), 10) + + case reflect.Float32, reflect.Float64: + return strconv.FormatFloat(v.Float(), 'f', -1, 64) + + default: + return v.String() + } +} diff --git a/pkg/plugin/diff/renderer_test.go b/pkg/plugin/diff/renderer_test.go new file mode 100644 index 0000000000..9879176063 --- /dev/null +++ b/pkg/plugin/diff/renderer_test.go @@ -0,0 +1,206 @@ +// 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 diff + +import ( + "os" + "reflect" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRenderNodeValue(t *testing.T) { + var ( + mapOfPrimative = map[string]string{ + "one": "1", + "two": "2", + } + mapOfMap = map[string]interface{}{ + "one": map[string]string{ + "one": "1-1", + "two": "1-2", + }, + "two": map[string]string{ + "one": "2-1", + "two": "2-2", + }, + } + mapOfSlice = map[string]interface{}{ + "one": []string{"one-1", "one-2"}, + "two": []string{"two-1", "two-2"}, + } + ) + + testcases := []struct { + name string + value reflect.Value + expected string + }{ + { + name: "int value", + value: reflect.ValueOf(1), + expected: "1", + }, + { + name: "float value", + value: reflect.ValueOf(1.25), + expected: "1.25", + }, + { + name: "string value", + value: reflect.ValueOf("hello"), + expected: "hello", + }, + { + name: "slice of primitive elements", + value: func() reflect.Value { + v := []int{1, 2, 3} + return reflect.ValueOf(v) + }(), + expected: `- 1 +- 2 +- 3`, + }, + { + name: "slice of interface", + value: func() reflect.Value { + v := []interface{}{ + map[string]int{ + "1-one": 1, + "2-two": 2, + }, + map[string]int{ + "3-three": 3, + "4-four": 4, + }, + } + return reflect.ValueOf(v) + }(), + expected: `- 1-one: 1 + 2-two: 2 +- 3-three: 3 + 4-four: 4`, + }, + { + name: "simple map", + value: reflect.ValueOf(map[string]string{ + "one": "one-value", + "two": "two-value", + }), + expected: `one: one-value +two: two-value`, + }, + { + name: "nested map", + value: func() reflect.Value { + v := map[string]interface{}{ + "1-number": 1, + "2-string": "hello", + "3-map-of-primitive": mapOfPrimative, + "4-map-of-map": mapOfMap, + "5-map-of-slice": mapOfSlice, + "6-slice": []string{"a", "b"}, + "7-string": "hi", + } + return reflect.ValueOf(v) + }(), + expected: `1-number: 1 +2-string: hello +3-map-of-primitive: + one: 1 + two: 2 +4-map-of-map: + one: + one: 1-1 + two: 1-2 + two: + one: 2-1 + two: 2-2 +5-map-of-slice: + one: + - one-1 + - one-2 + two: + - two-1 + - two-2 +6-slice: + - a + - b +7-string: hi`, + }, + } + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + got, _ := renderNodeValue(tc.value, "") + assert.Equal(t, tc.expected, got) + }) + } +} + +func TestRenderNodeValueComplex(t *testing.T) { + // Complex node. Note that the keys in the yaml file must be in order. + objs, err := loadUnstructureds("testdata/complex-node.yaml") + require.NoError(t, err) + require.Equal(t, 1, len(objs)) + + root := reflect.ValueOf(objs[0].Object) + got, _ := renderNodeValue(root, "") + + data, err := os.ReadFile("testdata/complex-node.yaml") + require.NoError(t, err) + assert.Equal(t, string(data), got) +} + +func TestRenderPrimitiveValue(t *testing.T) { + testcases := []struct { + name string + value interface{} + expected string + }{ + { + name: "string", + value: "hello", + expected: "hello", + }, + { + name: "int", + value: 1, + expected: "1", + }, + { + name: "float", + value: 1.25, + expected: "1.25", + }, + { + name: "map", + value: map[string]int{ + "one": 1, + }, + expected: "", + }, + } + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + v := reflect.ValueOf(tc.value) + got := RenderPrimitiveValue(v) + assert.Equal(t, tc.expected, got) + }) + } +} diff --git a/pkg/plugin/diff/result.go b/pkg/plugin/diff/result.go new file mode 100644 index 0000000000..c3c0623a77 --- /dev/null +++ b/pkg/plugin/diff/result.go @@ -0,0 +1,155 @@ +// 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 diff + +import ( + "errors" + "reflect" + "regexp" + "sort" + "strconv" + "strings" +) + +var ( + ErrNotFound = errors.New("not found") +) + +type Result struct { + nodes []Node +} + +func (r *Result) HasDiff() bool { + return len(r.nodes) > 0 +} + +func (r *Result) NumNodes() int { + return len(r.nodes) +} + +func (r *Result) Nodes() Nodes { + return r.nodes +} + +type Node struct { + Path []PathStep + PathString string + TypeX reflect.Type + TypeY reflect.Type + ValueX reflect.Value + ValueY reflect.Value +} + +type PathStepType string + +const ( + MapIndexPathStep PathStepType = "MapIndex" + SliceIndexPathStep PathStepType = "SliceIndex" +) + +type PathStep struct { + Type PathStepType + MapIndex string + SliceIndex int +} + +func (s PathStep) String() string { + switch s.Type { + case SliceIndexPathStep: + return strconv.FormatInt(int64(s.SliceIndex), 10) + case MapIndexPathStep: + return s.MapIndex + default: + return "" + } +} + +func (n Node) StringX() string { + return RenderPrimitiveValue(n.ValueX) +} + +func (n Node) StringY() string { + return RenderPrimitiveValue(n.ValueY) +} + +type Nodes []Node + +func (ns Nodes) FindOne(query string) (*Node, error) { + reg, err := regexp.Compile(query) + if err != nil { + return nil, err + } + + for i := range ns { + matched := reg.MatchString(ns[i].PathString) + if !matched { + continue + } + return &ns[i], nil + } + return nil, ErrNotFound +} + +func (ns Nodes) Find(query string) (Nodes, error) { + reg, err := regexp.Compile(query) + if err != nil { + return nil, err + } + + nodes := make([]Node, 0) + for i := range ns { + matched := reg.MatchString(ns[i].PathString) + if !matched { + continue + } + nodes = append(nodes, ns[i]) + } + return nodes, nil +} + +func (ns Nodes) FindByPrefix(prefix string) Nodes { + nodes := make([]Node, 0) + for i := range ns { + if strings.HasPrefix(ns[i].PathString, prefix) { + nodes = append(nodes, ns[i]) + } + } + return nodes +} + +func (r *Result) addNode(path []PathStep, typeX, typeY reflect.Type, valueX, valueY reflect.Value) { + r.nodes = append(r.nodes, Node{ + Path: path, + PathString: makePathString(path), + TypeX: typeX, + TypeY: typeY, + ValueX: valueX, + ValueY: valueY, + }) +} + +func (r *Result) sort() { + sort.Slice(r.nodes, func(i, j int) bool { + return r.nodes[i].PathString < r.nodes[j].PathString + }) +} + +func makePathString(path []PathStep) string { + steps := make([]string, 0, len(path)) + for _, s := range path { + steps = append(steps, s.String()) + } + return strings.Join(steps, ".") +} diff --git a/pkg/plugin/diff/result_test.go b/pkg/plugin/diff/result_test.go new file mode 100644 index 0000000000..c42eeb3578 --- /dev/null +++ b/pkg/plugin/diff/result_test.go @@ -0,0 +1,102 @@ +// 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 diff + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestFindOne(t *testing.T) { + nodes := []Node{ + {PathString: "spec.template.spec"}, + } + + testcases := []struct { + name string + nodes Nodes + query string + expected *Node + exepectedError error + }{ + { + name: "nil list", + query: ".+", + exepectedError: ErrNotFound, + }, + { + name: "not found", + nodes: nodes, + query: `spec\.not-found\..+`, + exepectedError: ErrNotFound, + }, + { + name: "found", + nodes: nodes, + query: `spec\.template\..+`, + expected: &nodes[0], + }, + } + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + n, err := tc.nodes.FindOne(tc.query) + assert.Equal(t, tc.expected, n) + assert.Equal(t, tc.exepectedError, err) + }) + } +} + +func TestFind(t *testing.T) { + nodes := []Node{ + {PathString: "spec.replicas"}, + {PathString: "spec.template.spec.containers.0.image"}, + {PathString: "spec.template.spec.containers.1.image"}, + } + + testcases := []struct { + name string + nodes Nodes + query string + expected []Node + }{ + { + name: "nil list", + query: ".+", + expected: []Node{}, + }, + { + name: "not found", + nodes: nodes, + query: `spec\.not-found\..+`, + expected: []Node{}, + }, + { + name: "found two nodes", + nodes: nodes, + query: `spec\.template\.spec\.containers\.\d+.image$`, + expected: []Node{nodes[1], nodes[2]}, + }, + } + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + ns, err := tc.nodes.Find(tc.query) + assert.Equal(t, Nodes(tc.expected), ns) + assert.NoError(t, err) + }) + } +} diff --git a/pkg/plugin/diff/testdata/complex-node.yaml b/pkg/plugin/diff/testdata/complex-node.yaml new file mode 100644 index 0000000000..a263b81a1c --- /dev/null +++ b/pkg/plugin/diff/testdata/complex-node.yaml @@ -0,0 +1,33 @@ +apiVersion: apps/v1 +kind: Foo +metadata: + labels: + app: simple + pipecd.dev/managed-by: piped + name: simple +spec: + replicas: 2 + selector: + matchLabels: + app: simple + template: + metadata: + labels: + app: simple + spec: + containers: + - args: + - hi + - hello + image: gcr.io/pipecd/helloworld:v1.0.0 + name: helloworld + ports: + - containerPort: 9085 + - image: envoy:1.10.0 + livenessProbe: + exec: + command: + - cat + - /tmp/healthy + initialDelaySeconds: 5 + name: envoy \ No newline at end of file diff --git a/pkg/plugin/diff/testdata/has_diff.yaml b/pkg/plugin/diff/testdata/has_diff.yaml new file mode 100644 index 0000000000..4b18bdb355 --- /dev/null +++ b/pkg/plugin/diff/testdata/has_diff.yaml @@ -0,0 +1,71 @@ +apiVersion: apps/v1 +kind: Foo +metadata: + name: simple + labels: + app: simple + pipecd.dev/managed-by: piped +spec: + replicas: 2 + selector: + matchLabels: + app: simple + template: + metadata: + labels: + app: simple + component: foo + spec: + containers: + - name: helloworld + image: gcr.io/pipecd/helloworld:v1.0.0 + args: + - hi + - hello + ports: + - containerPort: 9085 + - name: bar + image: gcr.io/pipecd/helloworld:v2.0.0 + - name: helloword2 + image: "" +--- +apiVersion: apps/v1 +kind: Foo +metadata: + name: simple + labels: + pipecd.dev/managed-by: piped + app: simple +spec: + replicas: 3 + selector: + matchLabels: + app: simple + template: + metadata: + labels: + app: simple2 + spec: + strategy: + rollingUpdate: + maxSurge: 25% + maxUnavailable: 25% + type: RollingUpdate + containers: + - name: helloworld + image: gcr.io/pipecd/helloworld:v1.0.0 + args: + - hi + ports: + - containerPort: 9085 + - name: bar + image: gcr.io/pipecd/helloworld:v2.1.0 + - name: helloword2 + - name: foo + image: new-image + livenessProbe: + exec: + command: + - cat + - /tmp/healthy + initialDelaySeconds: 5 diff --git a/pkg/plugin/diff/testdata/ignore_adding_map_keys.yaml b/pkg/plugin/diff/testdata/ignore_adding_map_keys.yaml new file mode 100644 index 0000000000..b618e40768 --- /dev/null +++ b/pkg/plugin/diff/testdata/ignore_adding_map_keys.yaml @@ -0,0 +1,58 @@ +apiVersion: apps/v1 +kind: Foo +metadata: + name: simple + labels: + app: simple + pipecd.dev/managed-by: piped + pipecd.dev/resource-key: apps/v1:Foo:default:simple + pipecd.dev/variant: primary +spec: + replicas: 2 + selector: + matchLabels: + app: simple + template: + metadata: + labels: + app: simple + spec: + newSliceFields: + - a + - b + containers: + - name: helloworld + image: gcr.io/pipecd/helloworld:v1.0.0 + args: + - hi + - hello + ports: + - containerPort: 9085 +status: + desc: ok +--- +apiVersion: apps/v1 +kind: Foo +metadata: + name: simple + labels: + pipecd.dev/managed-by: piped + app: simple +spec: + replicas: 2 + selector: + matchLabels: + app: simple + template: + metadata: + labels: + app: simple + spec: + containers: + - name: helloworld + image: gcr.io/pipecd/helloworld:v1.0.0 + args: + - hi + - hello + ports: + - containerPort: 9085 diff --git a/pkg/plugin/diff/testdata/no_diff.yaml b/pkg/plugin/diff/testdata/no_diff.yaml new file mode 100644 index 0000000000..3f5f689c5e --- /dev/null +++ b/pkg/plugin/diff/testdata/no_diff.yaml @@ -0,0 +1,84 @@ +apiVersion: apps/v1 +kind: Foo +metadata: + name: simple + labels: + app: simple + pipecd.dev/managed-by: piped + zeroBool1: false + zeroString1: "" + zeroInt1: 0 + zeroFloat1: 0.0 +spec: + replicas: 2 + number: 1 + selector: + matchLabels: + app: simple + template: + metadata: + labels: + app: simple + spec: + containers: + - name: helloworld + image: gcr.io/pipecd/helloworld:v1.0.0 + args: + - hi + - hello + ports: + - containerPort: 9085 + # Zero map and nil map should be equal. + resources: + null + emptyList: + [] + emptyMap: {} + resources: + limits: + cpu: 1 + memory: 1Gi + requests: + cpu: 1 + memory: 1Gi +--- +apiVersion: apps/v1 +kind: Foo +metadata: + name: simple + labels: + pipecd.dev/managed-by: piped + app: simple + zeroBool2: false + zeroString2: "" + zeroInt2: 0 + zeroFloat2: 0.0 +spec: + replicas: 2 + number: 1.0 + selector: + matchLabels: + app: simple + template: + metadata: + labels: + app: simple + spec: + containers: + - name: helloworld + image: gcr.io/pipecd/helloworld:v1.0.0 + args: + - hi + - hello + ports: + - containerPort: 9085 + # Zero map and nil map should be equal. + resources: {} + emptyList2: [] + resources: + limits: + cpu: "1" + memory: 1Gi + requests: + cpu: "1" + memory: 1Gi From 8b49a8443048b4c84600f192c590214162c61cac Mon Sep 17 00:00:00 2001 From: Shinnosuke Sawada-Dazai Date: Wed, 30 Oct 2024 12:59:13 +0900 Subject: [PATCH 77/84] Implement k8s manifest diff (#5298) Signed-off-by: Shinnosuke Sawada-Dazai --- .../plugin/kubernetes/provider/diff.go | 84 ++++++ .../plugin/kubernetes/provider/diff_test.go | 239 ++++++++++++++++++ .../plugin/kubernetes/provider/diffutil.go | 120 +++++++++ .../kubernetes/provider/diffutil_test.go | 218 ++++++++++++++++ .../plugin/kubernetes/provider/resource.go | 65 ++++- 5 files changed, 721 insertions(+), 5 deletions(-) create mode 100644 pkg/app/pipedv1/plugin/kubernetes/provider/diff.go create mode 100644 pkg/app/pipedv1/plugin/kubernetes/provider/diff_test.go create mode 100644 pkg/app/pipedv1/plugin/kubernetes/provider/diffutil.go create mode 100644 pkg/app/pipedv1/plugin/kubernetes/provider/diffutil_test.go diff --git a/pkg/app/pipedv1/plugin/kubernetes/provider/diff.go b/pkg/app/pipedv1/plugin/kubernetes/provider/diff.go new file mode 100644 index 0000000000..3f83ffb0e3 --- /dev/null +++ b/pkg/app/pipedv1/plugin/kubernetes/provider/diff.go @@ -0,0 +1,84 @@ +// 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 provider + +import ( + "go.uber.org/zap" + v1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + + "github.com/pipe-cd/pipecd/pkg/plugin/diff" +) + + +func Diff(old, new Manifest, logger *zap.Logger, opts ...diff.Option) (*diff.Result, error) { + if old.Key.IsSecret() && new.Key.IsSecret() { + var err error + old.Body, err = normalizeNewSecret(old.Body, new.Body) + if err != nil { + return nil, err + } + } + + key := old.Key.String() + + normalizedOld, err := remarshal(old.Body) + if err != nil { + logger.Info("compare manifests directly since it was unable to remarshal old Kubernetes manifest to normalize special fields", zap.Error(err)) + return diff.DiffUnstructureds(*old.Body, *new.Body, key, opts...) + } + + normalizedNew, err := remarshal(new.Body) + if err != nil { + logger.Info("compare manifests directly since it was unable to remarshal new Kubernetes manifest to normalize special fields", zap.Error(err)) + return diff.DiffUnstructureds(*old.Body, *new.Body, key, opts...) + } + + return diff.DiffUnstructureds(*normalizedOld, *normalizedNew, key, opts...) +} + +func normalizeNewSecret(old, new *unstructured.Unstructured) (*unstructured.Unstructured, error) { + var o, n v1.Secret + runtime.DefaultUnstructuredConverter.FromUnstructured(old.Object, &o) + runtime.DefaultUnstructuredConverter.FromUnstructured(new.Object, &n) + + // Move as much as possible fields from `o.Data` to `o.StringData` to make `o` close to `n` to minimize the diff. + for k, v := range o.Data { + // Skip if the field also exists in StringData. + if _, ok := o.StringData[k]; ok { + continue + } + + if _, ok := n.StringData[k]; !ok { + continue + } + + if o.StringData == nil { + o.StringData = make(map[string]string) + } + + // If the field is existing in `n.StringData`, we should move that field from `o.Data` to `o.StringData` + o.StringData[k] = string(v) + delete(o.Data, k) + } + + newO, err := runtime.DefaultUnstructuredConverter.ToUnstructured(&o) + if err != nil { + return nil, err + } + + return &unstructured.Unstructured{Object: newO}, nil +} diff --git a/pkg/app/pipedv1/plugin/kubernetes/provider/diff_test.go b/pkg/app/pipedv1/plugin/kubernetes/provider/diff_test.go new file mode 100644 index 0000000000..fd25a9070d --- /dev/null +++ b/pkg/app/pipedv1/plugin/kubernetes/provider/diff_test.go @@ -0,0 +1,239 @@ +// 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 provider + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap" + + "github.com/pipe-cd/pipecd/pkg/plugin/diff" +) + +func TestDiff(t *testing.T) { + t.Parallel() + + testcases := []struct { + name string + manifests string + expected string + diffNum int + falsePositive bool + }{ + { + name: "Secret no diff 1", + manifests: `apiVersion: apps/v1 +kind: Secret +metadata: + name: secret-management +--- +apiVersion: apps/v1 +kind: Secret +metadata: + name: secret-management +`, + expected: "", + diffNum: 0, + }, + { + name: "Secret no diff 2", + manifests: `apiVersion: apps/v1 +kind: Secret +metadata: + name: secret-management +data: + password: hoge +stringData: + foo: bar +--- +apiVersion: apps/v1 +kind: Secret +metadata: + name: secret-management +data: + password: hoge +stringData: + foo: bar +`, + expected: "", + diffNum: 0, + }, + { + name: "Secret no diff with merge", + manifests: `apiVersion: apps/v1 +kind: Secret +metadata: + name: secret-management +data: + password: hoge + foo: YmFy +--- +apiVersion: apps/v1 +kind: Secret +metadata: + name: secret-management +data: + password: hoge +stringData: + foo: bar +`, + expected: "", + diffNum: 0, + }, + { + name: "Secret no diff override false-positive", + manifests: `apiVersion: apps/v1 +kind: Secret +metadata: + name: secret-management +data: + password: hoge + foo: YmFy +--- +apiVersion: apps/v1 +kind: Secret +metadata: + name: secret-management +data: + password: hoge + foo: Zm9v +stringData: + foo: bar +`, + expected: "", + diffNum: 0, + falsePositive: true, + }, + { + name: "Secret has diff", + manifests: `apiVersion: apps/v1 +kind: Secret +metadata: + name: secret-management +data: + foo: YmFy +--- +apiVersion: apps/v1 +kind: Secret +metadata: + name: secret-management +data: + password: hoge +stringData: + foo: bar +`, + expected: ` #data ++ data: ++ password: hoge + +`, + diffNum: 1, + }, + { + name: "Pod no diff 1", + manifests: `apiVersion: v1 +kind: Pod +metadata: + name: static-web + labels: + role: myrole +spec: + containers: + - name: web + image: nginx + resources: + limits: + memory: "2Gi" +--- +apiVersion: v1 +kind: Pod +metadata: + name: static-web + labels: + role: myrole +spec: + containers: + - name: web + image: nginx + ports: + resources: + limits: + memory: "2Gi" +`, + expected: "", + diffNum: 0, + falsePositive: false, + }, + { + name: "Pod no diff 2", + manifests: `apiVersion: v1 +kind: Pod +metadata: + name: static-web + labels: + role: myrole +spec: + containers: + - name: web + image: nginx + resources: + limits: + memory: "1536Mi" +--- +apiVersion: v1 +kind: Pod +metadata: + name: static-web + labels: + role: myrole +spec: + containers: + - name: web + image: nginx + ports: + resources: + limits: + memory: "1.5Gi" +`, + expected: "", + diffNum: 0, + falsePositive: false, + }, + } + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + manifests, err := ParseManifests(tc.manifests) + require.NoError(t, err) + require.Equal(t, 2, len(manifests)) + old, new := manifests[0], manifests[1] + + result, err := Diff(old, new, zap.NewNop(), diff.WithEquateEmpty(), diff.WithIgnoreAddingMapKeys(), diff.WithCompareNumberAndNumericString()) + require.NoError(t, err) + + renderer := diff.NewRenderer(diff.WithLeftPadding(1)) + ds := renderer.Render(result.Nodes()) + if tc.falsePositive { + assert.NotEqual(t, tc.diffNum, result.NumNodes()) + assert.NotEqual(t, tc.expected, ds) + } else { + assert.Equal(t, tc.diffNum, result.NumNodes()) + assert.Equal(t, tc.expected, ds) + } + }) + } +} diff --git a/pkg/app/pipedv1/plugin/kubernetes/provider/diffutil.go b/pkg/app/pipedv1/plugin/kubernetes/provider/diffutil.go new file mode 100644 index 0000000000..4ff4c731a7 --- /dev/null +++ b/pkg/app/pipedv1/plugin/kubernetes/provider/diffutil.go @@ -0,0 +1,120 @@ +// 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 provider + +import ( + "bytes" + "encoding/json" + "reflect" + + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/kubernetes/scheme" +) + +// All functions in this file is borrowed from argocd/gitops-engine and modified +// All function except `remarshal` is borrowed from +// https://github.com/argoproj/gitops-engine/blob/0bc2f8c395f67123156d4ce6b667bf730618307f/pkg/utils/json/json.go +// and `remarshal` function is borrowed from +// https://github.com/argoproj/gitops-engine/blob/b0c5e00ccfa5d1e73087a18dc59e2e4c72f5f175/pkg/diff/diff.go#L685-L723 + +// https://github.com/ksonnet/ksonnet/blob/master/pkg/kubecfg/diff.go +func removeFields(config, live interface{}) interface{} { + switch c := config.(type) { + case map[string]interface{}: + l, ok := live.(map[string]interface{}) + if ok { + return removeMapFields(c, l) + } + return live + case []interface{}: + l, ok := live.([]interface{}) + if ok { + return removeListFields(c, l) + } + return live + default: + return live + } + +} + +// removeMapFields remove all non-existent fields in the live that don't exist in the config +func removeMapFields(config, live map[string]interface{}) map[string]interface{} { + result := map[string]interface{}{} + for k, v1 := range config { + v2, ok := live[k] + if !ok { + continue + } + if v2 != nil { + v2 = removeFields(v1, v2) + } + result[k] = v2 + } + return result +} + +func removeListFields(config, live []interface{}) []interface{} { + // If live is longer than config, then the extra elements at the end of the + // list will be returned as-is so they appear in the diff. + result := make([]interface{}, 0, len(live)) + for i, v2 := range live { + if len(config) > i { + if v2 != nil { + v2 = removeFields(config[i], v2) + } + result = append(result, v2) + } else { + result = append(result, v2) + } + } + return result +} + +// remarshal checks resource kind and version and re-marshal using corresponding struct custom marshaller. +// This ensures that expected resource state is formatter same as actual resource state in kubernetes +// and allows to find differences between actual and target states more accurately. +// Remarshalling also strips any type information (e.g. float64 vs. int) from the unstructured +// object. This is important for diffing since it will cause godiff to report a false difference. +func remarshal(obj *unstructured.Unstructured) (*unstructured.Unstructured, error) { + data, err := json.Marshal(obj) + if err != nil { + return nil, err + } + item, err := scheme.Scheme.New(obj.GroupVersionKind()) + if err != nil { + // This is common. the scheme is not registered + return nil, err + } + // This will drop any omitempty fields, perform resource conversion etc... + unmarshalledObj := reflect.New(reflect.TypeOf(item).Elem()).Interface() + // Unmarshal data into unmarshalledObj, but detect if there are any unknown fields that are not + // found in the target GVK object. + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&unmarshalledObj); err != nil { + // Likely a field present in obj that is not present in the GVK type, or user + // may have specified an invalid spec in git, so return original object + return nil, err + } + unstrBody, err := runtime.DefaultUnstructuredConverter.ToUnstructured(unmarshalledObj) + if err != nil { + return nil, err + } + // Remove all default values specified by custom formatter (e.g. creationTimestamp) + unstrBody = removeMapFields(obj.Object, unstrBody) + return &unstructured.Unstructured{Object: unstrBody}, nil +} diff --git a/pkg/app/pipedv1/plugin/kubernetes/provider/diffutil_test.go b/pkg/app/pipedv1/plugin/kubernetes/provider/diffutil_test.go new file mode 100644 index 0000000000..223e37bd15 --- /dev/null +++ b/pkg/app/pipedv1/plugin/kubernetes/provider/diffutil_test.go @@ -0,0 +1,218 @@ +// 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 provider + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestRemoveMapFields(t *testing.T) { + t.Parallel() + + testcases := []struct { + name string + config map[string]interface{} + live map[string]interface{} + expected map[string]interface{} + }{ + { + name: "Empty map", + config: make(map[string]interface{}, 0), + live: make(map[string]interface{}, 0), + expected: make(map[string]interface{}, 0), + }, + { + name: "Not nested 1", + config: map[string]interface{}{ + "key a": "value a", + }, + live: map[string]interface{}{ + "key a": "value a", + "key b": "value b", + }, + expected: map[string]interface{}{ + "key a": "value a", + }, + }, + { + name: "Not nested 2", + config: map[string]interface{}{ + "key a": "value a", + "key b": "value b", + }, + live: map[string]interface{}{ + "key a": "value a", + }, + expected: map[string]interface{}{ + "key a": "value a", + }, + }, + { + name: "Nested live deleted", + config: map[string]interface{}{ + "key a": "value a", + }, + live: map[string]interface{}{ + "key a": "value a", + "key b": map[string]interface{}{ + "nested key a": "nested value a", + }, + }, + expected: map[string]interface{}{ + "key a": "value a", + }, + }, + { + name: "Nested same", + config: map[string]interface{}{ + "key a": "value a", + "key b": map[string]interface{}{ + "nested key a": "nested value a", + }, + }, + live: map[string]interface{}{ + "key a": "value a", + "key b": map[string]interface{}{ + "nested key a": "nested value a", + }, + }, + expected: map[string]interface{}{ + "key a": "value a", + "key b": map[string]interface{}{ + "nested key a": "nested value a", + }, + }, + }, + { + name: "Nested nested live deleted", + config: map[string]interface{}{ + "key a": "value a", + "key b": map[string]interface{}{ + "nested key a": "nested value a", + }, + }, + live: map[string]interface{}{ + "key a": "value a", + "key b": map[string]interface{}{ + "nested key a": "nested value a", + "nested key b": "nested value b", + }, + }, + expected: map[string]interface{}{ + "key a": "value a", + "key b": map[string]interface{}{ + "nested key a": "nested value a", + }, + }, + }, + { + name: "Nested array", + config: map[string]interface{}{ + "key a": "value a", + "key b": []interface{}{ + "a", "b", 3, + }, + }, + live: map[string]interface{}{ + "key a": "value a", + "key b": []interface{}{ + "a", "b", 3, + }, + }, + expected: map[string]interface{}{ + "key a": "value a", + "key b": []interface{}{ + "a", "b", 3, + }, + }, + }, + { + name: "Nested array 2", + config: map[string]interface{}{ + "key a": "value a", + "key b": []interface{}{ + "a", "b", 3, 4, + }, + }, + live: map[string]interface{}{ + "key a": "value a", + "key b": []interface{}{ + "a", "b", 3, + }, + }, + expected: map[string]interface{}{ + "key a": "value a", + "key b": []interface{}{ + "a", "b", 3, + }, + }, + }, + { + name: "Nested array remain", + config: map[string]interface{}{ + "key a": "value a", + "key b": []interface{}{ + "a", "b", + }, + }, + live: map[string]interface{}{ + "key a": "value a", + "key b": []interface{}{ + "a", "b", map[string]interface{}{ + "aa": "aa", + }, + }, + }, + expected: map[string]interface{}{ + "key a": "value a", + "key b": []interface{}{ + "a", "b", map[string]interface{}{ + "aa": "aa", + }, + }, + }, + }, + { + name: "Nested array same", + config: map[string]interface{}{ + "key a": "value a", + "key b": []interface{}{ + "a", "b", 3, + }, + }, + live: map[string]interface{}{ + "key a": "value a", + "key b": []interface{}{ + "b", "a", 3, + }, + }, + expected: map[string]interface{}{ + "key a": "value a", + "key b": []interface{}{ + "b", "a", 3, + }, + }, + }, + } + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + removed := removeMapFields(tc.config, tc.live) + assert.Equal(t, tc.expected, removed) + }) + } +} diff --git a/pkg/app/pipedv1/plugin/kubernetes/provider/resource.go b/pkg/app/pipedv1/plugin/kubernetes/provider/resource.go index 3337e1d4d6..35e5f009aa 100644 --- a/pkg/app/pipedv1/plugin/kubernetes/provider/resource.go +++ b/pkg/app/pipedv1/plugin/kubernetes/provider/resource.go @@ -20,15 +20,53 @@ import ( "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" ) +var builtInAPIVersions = map[string]struct{}{ + "admissionregistration.k8s.io/v1": {}, + "admissionregistration.k8s.io/v1beta1": {}, + "apiextensions.k8s.io/v1": {}, + "apiextensions.k8s.io/v1beta1": {}, + "apiregistration.k8s.io/v1": {}, + "apiregistration.k8s.io/v1beta1": {}, + "apps/v1": {}, + "authentication.k8s.io/v1": {}, + "authentication.k8s.io/v1beta1": {}, + "authorization.k8s.io/v1": {}, + "authorization.k8s.io/v1beta1": {}, + "autoscaling/v1": {}, + "autoscaling/v2beta1": {}, + "autoscaling/v2beta2": {}, + "batch/v1": {}, + "batch/v1beta1": {}, + "certificates.k8s.io/v1beta1": {}, + "coordination.k8s.io/v1": {}, + "coordination.k8s.io/v1beta1": {}, + "extensions/v1beta1": {}, + "internal.autoscaling.k8s.io/v1alpha1": {}, + "metrics.k8s.io/v1beta1": {}, + "networking.k8s.io/v1": {}, + "networking.k8s.io/v1beta1": {}, + "node.k8s.io/v1beta1": {}, + "policy/v1": {}, + "policy/v1beta1": {}, + "rbac.authorization.k8s.io/v1": {}, + "rbac.authorization.k8s.io/v1beta1": {}, + "scheduling.k8s.io/v1": {}, + "scheduling.k8s.io/v1beta1": {}, + "storage.k8s.io/v1": {}, + "storage.k8s.io/v1beta1": {}, + "v1": {}, +} - -const KindDeployment = "Deployment" +const ( + KindDeployment = "Deployment" + KindSecret = "Secret" +) type ResourceKey struct { APIVersion string - Kind string - Namespace string - Name string + Kind string + Namespace string + Name string } func (k ResourceKey) String() string { @@ -48,3 +86,20 @@ func MakeResourceKey(obj *unstructured.Unstructured) ResourceKey { } return k } + +func (k ResourceKey) IsSecret() bool { + if k.Kind != KindSecret { + return false + } + if !IsKubernetesBuiltInResource(k.APIVersion) { + return false + } + return true +} + +func IsKubernetesBuiltInResource(apiVersion string) bool { + _, ok := builtInAPIVersions[apiVersion] + // TODO: Change the way to detect whether an APIVersion is built-in or not + // rather than depending on this fixed list. + return ok +} From ad3c8779717d91d0415baec5128f91508a8f9fc0 Mon Sep 17 00:00:00 2001 From: Yoshiki Fujikane <40124947+ffjlabo@users.noreply.github.com> Date: Thu, 31 Oct 2024 10:08:19 +0900 Subject: [PATCH 78/84] Add docs for pipectl event register --contexts on the event watcher usage page (#5299) * Add docs for pipectl event register --contexts on the event watcher usage page Signed-off-by: Yoshiki Fujikane * Fix docs Signed-off-by: Yoshiki Fujikane * Fix command Signed-off-by: Yoshiki Fujikane --------- Signed-off-by: Yoshiki Fujikane --- .../docs-dev/user-guide/command-line-tool.md | 2 ++ .../en/docs-dev/user-guide/event-watcher.md | 32 ++++++++++++++++++ docs/static/images/event-watcher-contexts.png | Bin 0 -> 248205 bytes 3 files changed, 34 insertions(+) create mode 100644 docs/static/images/event-watcher-contexts.png diff --git a/docs/content/en/docs-dev/user-guide/command-line-tool.md b/docs/content/en/docs-dev/user-guide/command-line-tool.md index 66615fa6fd..5133fbc0de 100644 --- a/docs/content/en/docs-dev/user-guide/command-line-tool.md +++ b/docs/content/en/docs-dev/user-guide/command-line-tool.md @@ -328,6 +328,8 @@ pipectl event register \ --data=gcr.io/pipecd/example:v0.1.0 ``` +See more on [usage of Event Watcher](./event-watcher.md). + ### Encrypting the data you want to use when deploying Encrypt the plaintext entered either in stdin or via the `--input-file` flag. diff --git a/docs/content/en/docs-dev/user-guide/event-watcher.md b/docs/content/en/docs-dev/user-guide/event-watcher.md index b24c63e67a..3af7adf3b5 100644 --- a/docs/content/en/docs-dev/user-guide/event-watcher.md +++ b/docs/content/en/docs-dev/user-guide/event-watcher.md @@ -165,6 +165,38 @@ pipectl event register \ Note that it is considered a match only when labels are an exact match. +### [optional] Using contexts + +You can also attach additional metadata to the event. +This information can be added as a trailer to the git commit when Event Watcher using the GIT_UPDATE handler. +This can be useful when attaching information from the source code repository to the manifest repository. + +For example, you can attach the source code commit link to the manifest repository. + +```bash +pipectl event register \ + --address=CONTROL_PLANE_API_ADDRESS \ + --api-key=API_KEY \ + --name=sample \ + --data=gcr.io/pipecd/helloworld:v0.48.0 \ + --contexts Source-Commit-Hash=xxxxxxx,Source-Commit-URL=https://github.com/pipe-cd/pipecd/commit/xxxxxxx +``` + +```bash +# In manifest repository +$ git show +commit ff46cdc9a3ce87a9a66436269251a4870ac55183 (HEAD -> main, origin/main, origin/HEAD) +Author: ffjlabo +Date: Wed Oct 30 16:56:36 2024 +0900 + + Replace values with "gcr.io/pipecd/helloworld:v0.48.0" set by Event "simple" + + Source-Commit-Hash: xxxxxxx + Source-Commit-URL: https://github.com/pipe-cd/pipecd/commit/xxxxxxx +``` + +![](/images/event-watcher-contexts.png) + ## Examples Suppose you want to update your configuration file after releasing a new Helm chart. diff --git a/docs/static/images/event-watcher-contexts.png b/docs/static/images/event-watcher-contexts.png new file mode 100644 index 0000000000000000000000000000000000000000..27dc0324e3308bab3d54668f1cef826c9c3a84cc GIT binary patch literal 248205 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

iP)32 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 IoyRZ!53K?oYXATM literal 0 HcmV?d00001 From 04eb2fc0cd96a1b292cff004431ad4f36a85303a Mon Sep 17 00:00:00 2001 From: Shinnosuke Sawada-Dazai Date: Thu, 31 Oct 2024 13:53:39 +0900 Subject: [PATCH 79/84] Delete platform-specific specs from configv1 (#5300) Signed-off-by: Shinnosuke Sawada-Dazai --- pkg/configv1/application.go | 41 --- pkg/configv1/application_cloudrun.go | 53 --- pkg/configv1/application_cloudrun_test.go | 77 ----- pkg/configv1/application_ecs.go | 162 --------- pkg/configv1/application_ecs_test.go | 165 --------- pkg/configv1/application_kubernetes.go | 313 ------------------ pkg/configv1/application_lambda.go | 57 ---- pkg/configv1/application_terraform.go | 92 ----- pkg/configv1/application_test.go | 222 ++++--------- pkg/configv1/config.go | 76 +---- pkg/configv1/config_test.go | 112 ------- .../application/cloudrun-app-bluegreen.yaml | 21 -- .../application/cloudrun-app-canary.yaml | 26 -- .../testdata/application/cloudrun-app.yaml | 2 - .../application/custom-sync-without-run.yaml | 11 - .../testdata/application/custom-sync.yaml | 14 - .../ecs-app-invalid-access-type.yaml | 7 - .../ecs-app-service-discovery.yaml | 7 - .../testdata/application/ecs-app.yaml | 11 - .../k8s-app-bluegreen-with-analysis.yaml | 28 -- .../application/k8s-app-bluegreen.yaml | 28 -- .../testdata/application/k8s-app-canary.yaml | 298 ----------------- .../application/k8s-app-envoy-bluegreen.yaml | 16 - .../application/k8s-app-envoy-canary.yaml | 16 - .../testdata/application/k8s-app-helm.yaml | 38 --- .../application/k8s-app-istio-bluegreen.yaml | 41 --- .../application/k8s-app-istio-canary.yaml | 56 ---- .../application/k8s-app-kustomization.yaml | 5 - .../application/k8s-app-resource-route.yaml | 16 - .../k8s-app-use-pipeline-template.yaml | 5 - .../testdata/application/k8s-plain-yaml.yaml | 9 - .../application/lambda-app-bluegreen.yaml | 16 - .../application/lambda-app-canary.yaml | 21 -- .../testdata/application/lambda-app.yaml | 2 - .../application/terraform-app-empty.yaml | 2 - .../terraform-app-secret-management.yaml | 14 - .../terraform-app-with-approval.yaml | 37 --- .../application/terraform-app-with-exit.yaml | 39 --- .../testdata/application/terraform-app.yaml | 6 - .../truebydefaultbool-false-explicitly.yaml | 2 - .../truebydefaultbool-true-explicitly.yaml | 2 - 41 files changed, 82 insertions(+), 2084 deletions(-) delete mode 100644 pkg/configv1/application_cloudrun.go delete mode 100644 pkg/configv1/application_cloudrun_test.go delete mode 100644 pkg/configv1/application_ecs.go delete mode 100644 pkg/configv1/application_ecs_test.go delete mode 100644 pkg/configv1/application_kubernetes.go delete mode 100644 pkg/configv1/application_lambda.go delete mode 100644 pkg/configv1/application_terraform.go delete mode 100644 pkg/configv1/config_test.go delete mode 100644 pkg/configv1/testdata/application/cloudrun-app-bluegreen.yaml delete mode 100644 pkg/configv1/testdata/application/cloudrun-app-canary.yaml delete mode 100644 pkg/configv1/testdata/application/cloudrun-app.yaml delete mode 100644 pkg/configv1/testdata/application/custom-sync-without-run.yaml delete mode 100644 pkg/configv1/testdata/application/custom-sync.yaml delete mode 100644 pkg/configv1/testdata/application/ecs-app-invalid-access-type.yaml delete mode 100644 pkg/configv1/testdata/application/ecs-app-service-discovery.yaml delete mode 100644 pkg/configv1/testdata/application/ecs-app.yaml delete mode 100644 pkg/configv1/testdata/application/k8s-app-bluegreen-with-analysis.yaml delete mode 100644 pkg/configv1/testdata/application/k8s-app-bluegreen.yaml delete mode 100644 pkg/configv1/testdata/application/k8s-app-canary.yaml delete mode 100644 pkg/configv1/testdata/application/k8s-app-envoy-bluegreen.yaml delete mode 100644 pkg/configv1/testdata/application/k8s-app-envoy-canary.yaml delete mode 100644 pkg/configv1/testdata/application/k8s-app-helm.yaml delete mode 100644 pkg/configv1/testdata/application/k8s-app-istio-bluegreen.yaml delete mode 100644 pkg/configv1/testdata/application/k8s-app-istio-canary.yaml delete mode 100644 pkg/configv1/testdata/application/k8s-app-kustomization.yaml delete mode 100644 pkg/configv1/testdata/application/k8s-app-resource-route.yaml delete mode 100644 pkg/configv1/testdata/application/k8s-app-use-pipeline-template.yaml delete mode 100644 pkg/configv1/testdata/application/k8s-plain-yaml.yaml delete mode 100644 pkg/configv1/testdata/application/lambda-app-bluegreen.yaml delete mode 100644 pkg/configv1/testdata/application/lambda-app-canary.yaml delete mode 100644 pkg/configv1/testdata/application/lambda-app.yaml delete mode 100644 pkg/configv1/testdata/application/terraform-app-empty.yaml delete mode 100644 pkg/configv1/testdata/application/terraform-app-secret-management.yaml delete mode 100644 pkg/configv1/testdata/application/terraform-app-with-approval.yaml delete mode 100644 pkg/configv1/testdata/application/terraform-app-with-exit.yaml delete mode 100644 pkg/configv1/testdata/application/terraform-app.yaml diff --git a/pkg/configv1/application.go b/pkg/configv1/application.go index a2c49495b1..703673a7d6 100644 --- a/pkg/configv1/application.go +++ b/pkg/configv1/application.go @@ -17,8 +17,6 @@ package config import ( "encoding/json" "fmt" - "os" - "path/filepath" "strings" "github.com/pipe-cd/pipecd/pkg/model" @@ -303,23 +301,6 @@ func (a *AnalysisStageOptions) Validate() error { 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"` @@ -571,25 +552,3 @@ func (dd *DriftDetection) Validate() error { } 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 deleted file mode 100644 index 2f061f5ac6..0000000000 --- a/pkg/configv1/application_cloudrun.go +++ /dev/null @@ -1,53 +0,0 @@ -// 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 deleted file mode 100644 index 2d419a78f7..0000000000 --- a/pkg/configv1/application_cloudrun_test.go +++ /dev/null @@ -1,77 +0,0 @@ -// 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 deleted file mode 100644 index 469183913b..0000000000 --- a/pkg/configv1/application_ecs.go +++ /dev/null @@ -1,162 +0,0 @@ -// 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 deleted file mode 100644 index ed6a5054ca..0000000000 --- a/pkg/configv1/application_ecs_test.go +++ /dev/null @@ -1,165 +0,0 @@ -// 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 deleted file mode 100644 index b8ad067a9d..0000000000 --- a/pkg/configv1/application_kubernetes.go +++ /dev/null @@ -1,313 +0,0 @@ -// 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_lambda.go b/pkg/configv1/application_lambda.go deleted file mode 100644 index 5106e9df51..0000000000 --- a/pkg/configv1/application_lambda.go +++ /dev/null @@ -1,57 +0,0 @@ -// 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_terraform.go b/pkg/configv1/application_terraform.go deleted file mode 100644 index f94d3f092b..0000000000 --- a/pkg/configv1/application_terraform.go +++ /dev/null @@ -1,92 +0,0 @@ -// 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_test.go b/pkg/configv1/application_test.go index 1ab3e1afaa..440aa6b11a 100644 --- a/pkg/configv1/application_test.go +++ b/pkg/configv1/application_test.go @@ -24,6 +24,10 @@ import ( "github.com/pipe-cd/pipecd/pkg/model" ) +func newBoolPointer(b bool) *bool { + return &b +} + func TestHasStage(t *testing.T) { testcases := []struct { name string @@ -379,37 +383,26 @@ func TestGenericTriggerConfiguration(t *testing.T) { 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), + expectedSpec: &GenericApplicationSpec{ + Timeout: Duration(6 * time.Hour), + Trigger: Trigger{ + OnCommit: OnCommit{ + Disabled: false, + Paths: []string{ + "deployment.yaml", }, }, - Planner: DeploymentPlanner{ - AutoRollback: newBoolPointer(true), + OnOutOfSync: OnOutOfSync{ + Disabled: newBoolPointer(true), + MinWindow: Duration(5 * time.Minute), + }, + OnChain: OnChain{ + Disabled: newBoolPointer(true), }, }, - Input: KubernetesDeploymentInput{ + Planner: DeploymentPlanner{ AutoRollback: newBoolPointer(true), }, - VariantLabel: KubernetesVariantLabel{ - Key: "pipecd.dev/variant", - PrimaryValue: "primary", - BaselineValue: "baseline", - CanaryValue: "canary", - }, }, expectedError: nil, }, @@ -439,31 +432,20 @@ func TestTrueByDefaultBoolConfiguration(t *testing.T) { 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), - }, + expectedSpec: &GenericApplicationSpec{ + Timeout: Duration(6 * time.Hour), + Trigger: Trigger{ + OnOutOfSync: OnOutOfSync{ + Disabled: newBoolPointer(true), + MinWindow: Duration(5 * time.Minute), }, - Planner: DeploymentPlanner{ - AutoRollback: newBoolPointer(true), + OnChain: OnChain{ + Disabled: newBoolPointer(true), }, }, - Input: KubernetesDeploymentInput{ + Planner: DeploymentPlanner{ AutoRollback: newBoolPointer(true), }, - VariantLabel: KubernetesVariantLabel{ - Key: "pipecd.dev/variant", - PrimaryValue: "primary", - BaselineValue: "baseline", - CanaryValue: "canary", - }, }, expectedError: nil, }, @@ -471,30 +453,19 @@ func TestTrueByDefaultBoolConfiguration(t *testing.T) { 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), - }, + expectedSpec: &GenericApplicationSpec{ + Timeout: Duration(6 * time.Hour), + Trigger: Trigger{ + OnOutOfSync: OnOutOfSync{ + Disabled: newBoolPointer(false), + MinWindow: Duration(5 * time.Minute), }, - Planner: DeploymentPlanner{ - AutoRollback: newBoolPointer(true), + OnChain: OnChain{ + Disabled: newBoolPointer(true), }, }, - Input: KubernetesDeploymentInput{ - AutoRollback: newBoolPointer(false), - }, - VariantLabel: KubernetesVariantLabel{ - Key: "pipecd.dev/variant", - PrimaryValue: "primary", - BaselineValue: "baseline", - CanaryValue: "canary", + Planner: DeploymentPlanner{ + AutoRollback: newBoolPointer(true), }, }, expectedError: nil, @@ -503,31 +474,20 @@ func TestTrueByDefaultBoolConfiguration(t *testing.T) { 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), - }, + expectedSpec: &GenericApplicationSpec{ + Timeout: Duration(6 * time.Hour), + Trigger: Trigger{ + OnOutOfSync: OnOutOfSync{ + Disabled: newBoolPointer(true), + MinWindow: Duration(5 * time.Minute), }, - Planner: DeploymentPlanner{ - AutoRollback: newBoolPointer(true), + OnChain: OnChain{ + Disabled: newBoolPointer(true), }, }, - Input: KubernetesDeploymentInput{ + Planner: DeploymentPlanner{ AutoRollback: newBoolPointer(true), }, - VariantLabel: KubernetesVariantLabel{ - Key: "pipecd.dev/variant", - PrimaryValue: "primary", - BaselineValue: "baseline", - CanaryValue: "canary", - }, }, expectedError: nil, }, @@ -557,49 +517,38 @@ func TestGenericPostSyncConfiguration(t *testing.T) { 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), - }, + expectedSpec: &GenericApplicationSpec{ + Timeout: Duration(6 * time.Hour), + Trigger: Trigger{ + OnOutOfSync: OnOutOfSync{ + Disabled: newBoolPointer(true), + MinWindow: Duration(5 * time.Minute), }, - Planner: DeploymentPlanner{ - AutoRollback: newBoolPointer(true), + OnChain: OnChain{ + Disabled: newBoolPointer(true), }, - PostSync: &PostSync{ - DeploymentChain: &DeploymentChain{ - ApplicationMatchers: []ChainApplicationMatcher{ - { - Name: "app-1", - }, - { - Labels: map[string]string{ - "env": "staging", - "foo": "bar", - }, - }, - { - Kind: "ECSApp", + }, + 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, }, @@ -616,32 +565,3 @@ func TestGenericPostSyncConfiguration(t *testing.T) { }) } } - -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 index 5fe663744a..f455fbd1e6 100644 --- a/pkg/configv1/config.go +++ b/pkg/configv1/config.go @@ -23,8 +23,6 @@ import ( "github.com/creasty/defaults" "sigs.k8s.io/yaml" - - "github.com/pipe-cd/pipecd/pkg/model" ) const ( @@ -39,15 +37,25 @@ 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. + // + // Deprecated: use KindApplication instead. KindKubernetesApp Kind = "KubernetesApp" // KindTerraformApp represents application configuration for a Terraform application. // This application contains a single workspace of a terraform root module. + // + // Deprecated: use KindApplication instead. KindTerraformApp Kind = "TerraformApp" // KindLambdaApp represents application configuration for an AWS Lambda application. + // + // Deprecated: use KindApplication instead. KindLambdaApp Kind = "LambdaApp" // KindCloudRunApp represents application configuration for a CloudRun application. + // + // Deprecated: use KindApplication instead. KindCloudRunApp Kind = "CloudRunApp" // KindECSApp represents application configuration for an AWS ECS. + // + // Deprecated: use KindApplication instead. KindECSApp Kind = "ECSApp" // KindApplication represents a generic application configuration. KindApplication Kind = "Application" @@ -80,13 +88,6 @@ type Config struct { ApplicationSpec *GenericApplicationSpec - // TODO: remove these fields - KubernetesApplicationSpec *KubernetesApplicationSpec - TerraformApplicationSpec *TerraformApplicationSpec - CloudRunApplicationSpec *CloudRunApplicationSpec - LambdaApplicationSpec *LambdaApplicationSpec - ECSApplicationSpec *ECSApplicationSpec - PipedSpec *PipedSpec ControlPlaneSpec *ControlPlaneSpec AnalysisTemplateSpec *AnalysisTemplateSpec @@ -104,30 +105,10 @@ func (c *Config) init(kind Kind, apiVersion string) error { c.APIVersion = apiVersion switch kind { - case KindApplication: + case KindApplication, KindKubernetesApp, KindTerraformApp, KindCloudRunApp, KindLambdaApp, KindECSApp: c.ApplicationSpec = &GenericApplicationSpec{} c.spec = c.ApplicationSpec - 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 @@ -229,38 +210,3 @@ func DecodeYAML(data []byte) (*Config, error) { } 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 KindApplication: - return *c.ApplicationSpec, true - 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 deleted file mode 100644 index 0cfc80b5ae..0000000000 --- a/pkg/configv1/config_test.go +++ /dev/null @@ -1,112 +0,0 @@ -// 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/testdata/application/cloudrun-app-bluegreen.yaml b/pkg/configv1/testdata/application/cloudrun-app-bluegreen.yaml deleted file mode 100644 index d5faee008f..0000000000 --- a/pkg/configv1/testdata/application/cloudrun-app-bluegreen.yaml +++ /dev/null @@ -1,21 +0,0 @@ -# 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 deleted file mode 100644 index 0e2d8ff50a..0000000000 --- a/pkg/configv1/testdata/application/cloudrun-app-canary.yaml +++ /dev/null @@ -1,26 +0,0 @@ -# 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 deleted file mode 100644 index ce274c98fc..0000000000 --- a/pkg/configv1/testdata/application/cloudrun-app.yaml +++ /dev/null @@ -1,2 +0,0 @@ -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 deleted file mode 100644 index 3f9fcdc61d..0000000000 --- a/pkg/configv1/testdata/application/custom-sync-without-run.yaml +++ /dev/null @@ -1,11 +0,0 @@ -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 deleted file mode 100644 index 8d65a8e1fa..0000000000 --- a/pkg/configv1/testdata/application/custom-sync.yaml +++ /dev/null @@ -1,14 +0,0 @@ -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 deleted file mode 100644 index 4f41eb974e..0000000000 --- a/pkg/configv1/testdata/application/ecs-app-invalid-access-type.yaml +++ /dev/null @@ -1,7 +0,0 @@ -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 deleted file mode 100644 index cc9da20611..0000000000 --- a/pkg/configv1/testdata/application/ecs-app-service-discovery.yaml +++ /dev/null @@ -1,7 +0,0 @@ -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 deleted file mode 100644 index 95f8b3f1ce..0000000000 --- a/pkg/configv1/testdata/application/ecs-app.yaml +++ /dev/null @@ -1,11 +0,0 @@ -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/k8s-app-bluegreen-with-analysis.yaml b/pkg/configv1/testdata/application/k8s-app-bluegreen-with-analysis.yaml deleted file mode 100644 index 006b3a353d..0000000000 --- a/pkg/configv1/testdata/application/k8s-app-bluegreen-with-analysis.yaml +++ /dev/null @@ -1,28 +0,0 @@ -# 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 deleted file mode 100644 index 8f6d2b1c3b..0000000000 --- a/pkg/configv1/testdata/application/k8s-app-bluegreen.yaml +++ /dev/null @@ -1,28 +0,0 @@ -# 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 deleted file mode 100644 index a244944114..0000000000 --- a/pkg/configv1/testdata/application/k8s-app-canary.yaml +++ /dev/null @@ -1,298 +0,0 @@ -# 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 deleted file mode 100644 index e90a8c8041..0000000000 --- a/pkg/configv1/testdata/application/k8s-app-envoy-bluegreen.yaml +++ /dev/null @@ -1,16 +0,0 @@ -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 deleted file mode 100644 index 6d0dc734ed..0000000000 --- a/pkg/configv1/testdata/application/k8s-app-envoy-canary.yaml +++ /dev/null @@ -1,16 +0,0 @@ -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 deleted file mode 100644 index 92569a6fe7..0000000000 --- a/pkg/configv1/testdata/application/k8s-app-helm.yaml +++ /dev/null @@ -1,38 +0,0 @@ -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 deleted file mode 100644 index 0655b3a509..0000000000 --- a/pkg/configv1/testdata/application/k8s-app-istio-bluegreen.yaml +++ /dev/null @@ -1,41 +0,0 @@ -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 deleted file mode 100644 index 109ad87927..0000000000 --- a/pkg/configv1/testdata/application/k8s-app-istio-canary.yaml +++ /dev/null @@ -1,56 +0,0 @@ -# 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 deleted file mode 100644 index 7632fa0a91..0000000000 --- a/pkg/configv1/testdata/application/k8s-app-kustomization.yaml +++ /dev/null @@ -1,5 +0,0 @@ -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 deleted file mode 100644 index 38479dafd7..0000000000 --- a/pkg/configv1/testdata/application/k8s-app-resource-route.yaml +++ /dev/null @@ -1,16 +0,0 @@ -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 deleted file mode 100644 index ad409367e4..0000000000 --- a/pkg/configv1/testdata/application/k8s-app-use-pipeline-template.yaml +++ /dev/null @@ -1,5 +0,0 @@ -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 deleted file mode 100644 index f7b99f70b4..0000000000 --- a/pkg/configv1/testdata/application/k8s-plain-yaml.yaml +++ /dev/null @@ -1,9 +0,0 @@ -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 deleted file mode 100644 index faed917016..0000000000 --- a/pkg/configv1/testdata/application/lambda-app-bluegreen.yaml +++ /dev/null @@ -1,16 +0,0 @@ -# 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 deleted file mode 100644 index 4f581ad867..0000000000 --- a/pkg/configv1/testdata/application/lambda-app-canary.yaml +++ /dev/null @@ -1,21 +0,0 @@ -# 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 deleted file mode 100644 index 34c9394f08..0000000000 --- a/pkg/configv1/testdata/application/lambda-app.yaml +++ /dev/null @@ -1,2 +0,0 @@ -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 deleted file mode 100644 index 105b69b066..0000000000 --- a/pkg/configv1/testdata/application/terraform-app-empty.yaml +++ /dev/null @@ -1,2 +0,0 @@ -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 deleted file mode 100644 index d14876e383..0000000000 --- a/pkg/configv1/testdata/application/terraform-app-secret-management.yaml +++ /dev/null @@ -1,14 +0,0 @@ -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 deleted file mode 100644 index 23c638ce99..0000000000 --- a/pkg/configv1/testdata/application/terraform-app-with-approval.yaml +++ /dev/null @@ -1,37 +0,0 @@ -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 deleted file mode 100644 index 194643c12f..0000000000 --- a/pkg/configv1/testdata/application/terraform-app-with-exit.yaml +++ /dev/null @@ -1,39 +0,0 @@ -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 deleted file mode 100644 index 26719e2582..0000000000 --- a/pkg/configv1/testdata/application/terraform-app.yaml +++ /dev/null @@ -1,6 +0,0 @@ -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 index d54005e372..0aefa1d812 100644 --- a/pkg/configv1/testdata/application/truebydefaultbool-false-explicitly.yaml +++ b/pkg/configv1/testdata/application/truebydefaultbool-false-explicitly.yaml @@ -4,5 +4,3 @@ spec: trigger: onOutOfSync: disabled: false - input: - autoRollback: false diff --git a/pkg/configv1/testdata/application/truebydefaultbool-true-explicitly.yaml b/pkg/configv1/testdata/application/truebydefaultbool-true-explicitly.yaml index 5862ab5e1f..608db641cf 100644 --- a/pkg/configv1/testdata/application/truebydefaultbool-true-explicitly.yaml +++ b/pkg/configv1/testdata/application/truebydefaultbool-true-explicitly.yaml @@ -4,5 +4,3 @@ spec: trigger: onOutOfSync: disabled: true - input: - autoRollback: true From 548a804d344d7c5faa70bcb6bd0889aa2a9269af Mon Sep 17 00:00:00 2001 From: Tetsuya Kikuchi <97105818+t-kikuc@users.noreply.github.com> Date: Thu, 31 Oct 2024 14:38:10 +0900 Subject: [PATCH 80/84] Remove Compatibilities from ecs taskdef examples (#5301) Signed-off-by: t-kikuc --- examples/ecs/attachment/taskdef.yaml | 2 -- examples/ecs/bluegreen/taskdef.yaml | 2 -- examples/ecs/canary/taskdef.yaml | 2 -- examples/ecs/secret-management/taskdef.yaml | 2 -- examples/ecs/servicediscovery/canary/taskdef.yaml | 2 -- examples/ecs/servicediscovery/simple/taskdef.yaml | 2 -- examples/ecs/simple/taskdef.yaml | 2 -- .../launch-type/ec2/network-mode/awsvpc/taskdef.yaml | 2 -- .../launch-type/ec2/network-mode/bridge/taskdef.yaml | 2 -- examples/ecs/standalone-task/launch-type/fargate/taskdef.yaml | 2 -- 10 files changed, 20 deletions(-) diff --git a/examples/ecs/attachment/taskdef.yaml b/examples/ecs/attachment/taskdef.yaml index 14804fc958..8789ddb140 100644 --- a/examples/ecs/attachment/taskdef.yaml +++ b/examples/ecs/attachment/taskdef.yaml @@ -9,8 +9,6 @@ containerDefinitions: name: web portMappings: - containerPort: 80 -compatibilities: - - FARGATE requiresCompatibilities: - FARGATE networkMode: awsvpc diff --git a/examples/ecs/bluegreen/taskdef.yaml b/examples/ecs/bluegreen/taskdef.yaml index 886f2eeef6..f5c18d5c30 100644 --- a/examples/ecs/bluegreen/taskdef.yaml +++ b/examples/ecs/bluegreen/taskdef.yaml @@ -9,8 +9,6 @@ containerDefinitions: name: web portMappings: - containerPort: 80 -compatibilities: - - FARGATE requiresCompatibilities: - FARGATE networkMode: awsvpc diff --git a/examples/ecs/canary/taskdef.yaml b/examples/ecs/canary/taskdef.yaml index 886f2eeef6..f5c18d5c30 100644 --- a/examples/ecs/canary/taskdef.yaml +++ b/examples/ecs/canary/taskdef.yaml @@ -9,8 +9,6 @@ containerDefinitions: name: web portMappings: - containerPort: 80 -compatibilities: - - FARGATE requiresCompatibilities: - FARGATE networkMode: awsvpc diff --git a/examples/ecs/secret-management/taskdef.yaml b/examples/ecs/secret-management/taskdef.yaml index 886f2eeef6..f5c18d5c30 100644 --- a/examples/ecs/secret-management/taskdef.yaml +++ b/examples/ecs/secret-management/taskdef.yaml @@ -9,8 +9,6 @@ containerDefinitions: name: web portMappings: - containerPort: 80 -compatibilities: - - FARGATE requiresCompatibilities: - FARGATE networkMode: awsvpc diff --git a/examples/ecs/servicediscovery/canary/taskdef.yaml b/examples/ecs/servicediscovery/canary/taskdef.yaml index b16de161f9..761961667d 100644 --- a/examples/ecs/servicediscovery/canary/taskdef.yaml +++ b/examples/ecs/servicediscovery/canary/taskdef.yaml @@ -9,8 +9,6 @@ containerDefinitions: name: web portMappings: - containerPort: 80 -compatibilities: - - FARGATE requiresCompatibilities: - FARGATE networkMode: awsvpc diff --git a/examples/ecs/servicediscovery/simple/taskdef.yaml b/examples/ecs/servicediscovery/simple/taskdef.yaml index c418d4f6c4..8e8f9099f0 100644 --- a/examples/ecs/servicediscovery/simple/taskdef.yaml +++ b/examples/ecs/servicediscovery/simple/taskdef.yaml @@ -9,8 +9,6 @@ containerDefinitions: name: web portMappings: - containerPort: 80 -compatibilities: - - FARGATE requiresCompatibilities: - FARGATE networkMode: awsvpc diff --git a/examples/ecs/simple/taskdef.yaml b/examples/ecs/simple/taskdef.yaml index 886f2eeef6..f5c18d5c30 100644 --- a/examples/ecs/simple/taskdef.yaml +++ b/examples/ecs/simple/taskdef.yaml @@ -9,8 +9,6 @@ containerDefinitions: name: web portMappings: - containerPort: 80 -compatibilities: - - FARGATE requiresCompatibilities: - FARGATE networkMode: awsvpc diff --git a/examples/ecs/standalone-task/launch-type/ec2/network-mode/awsvpc/taskdef.yaml b/examples/ecs/standalone-task/launch-type/ec2/network-mode/awsvpc/taskdef.yaml index f1373f1ab2..52faee4124 100644 --- a/examples/ecs/standalone-task/launch-type/ec2/network-mode/awsvpc/taskdef.yaml +++ b/examples/ecs/standalone-task/launch-type/ec2/network-mode/awsvpc/taskdef.yaml @@ -9,8 +9,6 @@ containerDefinitions: name: web portMappings: - containerPort: 80 -compatibilities: - - EC2 requiresCompatibilities: - EC2 networkMode: awsvpc diff --git a/examples/ecs/standalone-task/launch-type/ec2/network-mode/bridge/taskdef.yaml b/examples/ecs/standalone-task/launch-type/ec2/network-mode/bridge/taskdef.yaml index 5a9225b10b..d0af73b4b7 100644 --- a/examples/ecs/standalone-task/launch-type/ec2/network-mode/bridge/taskdef.yaml +++ b/examples/ecs/standalone-task/launch-type/ec2/network-mode/bridge/taskdef.yaml @@ -9,8 +9,6 @@ containerDefinitions: name: web portMappings: - containerPort: 80 -compatibilities: - - EC2 requiresCompatibilities: - EC2 networkMode: bridge diff --git a/examples/ecs/standalone-task/launch-type/fargate/taskdef.yaml b/examples/ecs/standalone-task/launch-type/fargate/taskdef.yaml index e5a60a6bdd..e5a32a365e 100644 --- a/examples/ecs/standalone-task/launch-type/fargate/taskdef.yaml +++ b/examples/ecs/standalone-task/launch-type/fargate/taskdef.yaml @@ -9,8 +9,6 @@ containerDefinitions: name: web portMappings: - containerPort: 80 -compatibilities: - - FARGATE requiresCompatibilities: - FARGATE networkMode: awsvpc From fa30a1df8ad987e6c1aa817ac88355b3a8d176ef Mon Sep 17 00:00:00 2001 From: Shinnosuke Sawada-Dazai Date: Thu, 31 Oct 2024 17:03:08 +0900 Subject: [PATCH 81/84] Make configv1.Config generic type (#5302) Signed-off-by: Shinnosuke Sawada-Dazai --- pkg/app/pipedv1/controller/planner.go | 4 +- pkg/configv1/analysis_template.go | 4 +- pkg/configv1/application_test.go | 12 +-- pkg/configv1/config.go | 108 +++++++------------------- pkg/configv1/control_plane_test.go | 10 +-- pkg/configv1/event_watcher.go | 4 +- pkg/configv1/piped_test.go | 4 +- 7 files changed, 45 insertions(+), 101 deletions(-) diff --git a/pkg/app/pipedv1/controller/planner.go b/pkg/app/pipedv1/controller/planner.go index bb7f118d73..fa398d2cc1 100644 --- a/pkg/app/pipedv1/controller/planner.go +++ b/pkg/app/pipedv1/controller/planner.go @@ -272,12 +272,12 @@ func (p *planner) buildPlan(ctx context.Context, runningDS, targetDS *deployment } } - cfg, err := config.DecodeYAML(targetDS.GetApplicationConfig()) + cfg, err := config.DecodeYAML[*config.GenericApplicationSpec](targetDS.GetApplicationConfig()) if err != nil { p.logger.Error("unable to parse application config", zap.Error(err)) return nil, err } - spec := cfg.ApplicationSpec + spec := cfg.Spec // In case the strategy has been decided by trigger. // For example: user triggered the deployment via web console. diff --git a/pkg/configv1/analysis_template.go b/pkg/configv1/analysis_template.go index d00be027e4..557c8fd0ca 100644 --- a/pkg/configv1/analysis_template.go +++ b/pkg/configv1/analysis_template.go @@ -47,12 +47,12 @@ func LoadAnalysisTemplate(repoRoot string) (*AnalysisTemplateSpec, error) { continue } path := filepath.Join(dir, f.Name()) - cfg, err := LoadFromYAML(path) + cfg, err := LoadFromYAML[*AnalysisTemplateSpec](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 cfg.Spec, nil } } return nil, ErrNotFound diff --git a/pkg/configv1/application_test.go b/pkg/configv1/application_test.go index 440aa6b11a..15eb2f17bb 100644 --- a/pkg/configv1/application_test.go +++ b/pkg/configv1/application_test.go @@ -409,12 +409,12 @@ func TestGenericTriggerConfiguration(t *testing.T) { } for _, tc := range testcases { t.Run(tc.fileName, func(t *testing.T) { - cfg, err := LoadFromYAML(tc.fileName) + cfg, err := LoadFromYAML[*GenericApplicationSpec](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) + assert.Equal(t, tc.expectedSpec, cfg.Spec) } }) } @@ -494,12 +494,12 @@ func TestTrueByDefaultBoolConfiguration(t *testing.T) { } for _, tc := range testcases { t.Run(tc.fileName, func(t *testing.T) { - cfg, err := LoadFromYAML(tc.fileName) + cfg, err := LoadFromYAML[*GenericApplicationSpec](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) + assert.Equal(t, tc.expectedSpec, cfg.Spec) } }) } @@ -555,12 +555,12 @@ func TestGenericPostSyncConfiguration(t *testing.T) { } for _, tc := range testcases { t.Run(tc.fileName, func(t *testing.T) { - cfg, err := LoadFromYAML(tc.fileName) + cfg, err := LoadFromYAML[*GenericApplicationSpec](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) + assert.Equal(t, tc.expectedSpec, cfg.Spec) } }) } diff --git a/pkg/configv1/config.go b/pkg/configv1/config.go index f455fbd1e6..700f405433 100644 --- a/pkg/configv1/config.go +++ b/pkg/configv1/config.go @@ -15,7 +15,6 @@ package config import ( - "bytes" "encoding/json" "errors" "fmt" @@ -79,126 +78,71 @@ var ( ErrNotFound = errors.New("not found") ) +// Spec[T] represents both of follows +// - the type is pointer type of T +// - the type has Validate method +type Spec[T any] interface { + *T + Validate() error +} + // Config represents configuration data load from file. // The spec is depend on the kind of configuration. -type Config struct { +type Config[T Spec[RT], RT any] struct { Kind Kind APIVersion string - spec interface{} - - ApplicationSpec *GenericApplicationSpec - - 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 KindApplication, KindKubernetesApp, KindTerraformApp, KindCloudRunApp, KindLambdaApp, KindECSApp: - c.ApplicationSpec = &GenericApplicationSpec{} - c.spec = c.ApplicationSpec - - 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 + Spec T } -// 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 { +func (c *Config[T, RT]) UnmarshalJSON(data []byte) error { + // Define a type alias Config[T, RT] to avoid infinite recursion. + type alias Config[T, RT] + a := alias{} + if err := json.Unmarshal(data, &a); err != nil { return err } + *c = Config[T, RT](a) - if len(gc.Spec) > 0 { - dec := json.NewDecoder(bytes.NewReader(gc.Spec)) - dec.DisallowUnknownFields() - err = dec.Decode(c.spec) + // Set default values. + if c.Spec == nil { + c.Spec = new(RT) } - return err -} -type validator interface { - Validate() error + return nil } // Validate validates the value of all fields. -func (c *Config) Validate() error { +func (c *Config[T, RT]) 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 { + if err := c.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) { +func LoadFromYAML[T Spec[RT], RT any](file string) (*Config[T, RT], error) { data, err := os.ReadFile(file) if err != nil { return nil, err } - return DecodeYAML(data) + return DecodeYAML[T, RT](data) } // DecodeYAML unmarshals config YAML data to config struct. // It also validates the configuration after decoding. -func DecodeYAML(data []byte) (*Config, error) { +func DecodeYAML[T Spec[RT], RT any](data []byte) (*Config[T, RT], error) { js, err := yaml.YAMLToJSON(data) if err != nil { return nil, err } - c := &Config{} + c := &Config[T, RT]{} if err := json.Unmarshal(js, c); err != nil { return nil, err } diff --git a/pkg/configv1/control_plane_test.go b/pkg/configv1/control_plane_test.go index 333c2ec88e..019d8b7e95 100644 --- a/pkg/configv1/control_plane_test.go +++ b/pkg/configv1/control_plane_test.go @@ -96,20 +96,20 @@ func TestControlPlaneConfig(t *testing.T) { } for _, tc := range testcases { t.Run(tc.fileName, func(t *testing.T) { - cfg, err := LoadFromYAML(tc.fileName) + cfg, err := LoadFromYAML[*ControlPlaneSpec](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)) + require.Equal(t, 1, len(cfg.Spec.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)) + assert.True(t, proto.Equal(&tc.expectedSpec.SharedSSOConfigs[0].ProjectSSOConfig, &cfg.Spec.SharedSSOConfigs[0].ProjectSSOConfig)) tc.expectedSpec.SharedSSOConfigs = nil - cfg.ControlPlaneSpec.SharedSSOConfigs = nil - assert.Equal(t, tc.expectedSpec, cfg.ControlPlaneSpec) + cfg.Spec.SharedSSOConfigs = nil + assert.Equal(t, tc.expectedSpec, cfg.Spec) } }) } diff --git a/pkg/configv1/event_watcher.go b/pkg/configv1/event_watcher.go index 282df6d3ed..3bbcad5b04 100644 --- a/pkg/configv1/event_watcher.go +++ b/pkg/configv1/event_watcher.go @@ -132,12 +132,12 @@ func LoadEventWatcher(repoRoot string, includePatterns, excludePatterns []string } for _, f := range filtered { path := filepath.Join(dir, f) - cfg, err := LoadFromYAML(path) + cfg, err := LoadFromYAML[*EventWatcherSpec](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...) + spec.Events = append(spec.Events, cfg.Spec.Events...) } } diff --git a/pkg/configv1/piped_test.go b/pkg/configv1/piped_test.go index a5f4f2694c..96f7b54d4e 100644 --- a/pkg/configv1/piped_test.go +++ b/pkg/configv1/piped_test.go @@ -366,12 +366,12 @@ func TestPipedConfig(t *testing.T) { } for _, tc := range testcases { t.Run(tc.fileName, func(t *testing.T) { - cfg, err := LoadFromYAML(tc.fileName) + cfg, err := LoadFromYAML[*PipedSpec](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) + assert.Equal(t, tc.expectedSpec, cfg.Spec) } }) } From 3a264ccdf57521bd433f6599203cc6eb51252d09 Mon Sep 17 00:00:00 2001 From: Khanh Tran <32532742+khanhtc1202@users.noreply.github.com> Date: Fri, 1 Nov 2024 07:06:30 +0700 Subject: [PATCH 82/84] Add tests for pipedv1 executor package (#5303) Signed-off-by: khanhtc1202 --- pkg/app/pipedv1/executor/executor.go | 23 ++++--- pkg/app/pipedv1/executor/executor_test.go | 72 +++++++++++++++++++++ pkg/app/pipedv1/executor/stopsignal_test.go | 58 +++++++++++++++++ 3 files changed, 143 insertions(+), 10 deletions(-) create mode 100644 pkg/app/pipedv1/executor/executor_test.go create mode 100644 pkg/app/pipedv1/executor/stopsignal_test.go diff --git a/pkg/app/pipedv1/executor/executor.go b/pkg/app/pipedv1/executor/executor.go index 7e73c7df1e..08afda2e27 100644 --- a/pkg/app/pipedv1/executor/executor.go +++ b/pkg/app/pipedv1/executor/executor.go @@ -72,17 +72,19 @@ type Input struct { // Deploy source at target commit TargetDSP deploysource.Provider // Deploy source at running commit - RunningDSP deploysource.Provider - GitClient GitClient - CommandLister CommandLister - LogPersister LogPersister - MetadataStore metadatastore.MetadataStore - AppManifestsCache cache.Cache - AnalysisResultStore AnalysisResultStore - Logger *zap.Logger - Notifier Notifier + RunningDSP deploysource.Provider + GitClient GitClient + CommandLister CommandLister + LogPersister LogPersister + MetadataStore metadatastore.MetadataStore + AppManifestsCache cache.Cache + AnalysisResultStore AnalysisResultStore + Logger *zap.Logger + Notifier Notifier } +// DetermineStageStatus determines the final status of the stage based on the given stop signal. +// Normal is the case when the stop signal is StopSignalNone. func DetermineStageStatus(sig StopSignalType, ori, got model.StageStatus) model.StageStatus { switch sig { case StopSignalNone: @@ -93,6 +95,7 @@ func DetermineStageStatus(sig StopSignalType, ori, got model.StageStatus) model. return model.StageStatus_STAGE_CANCELLED case StopSignalTimeout: return model.StageStatus_STAGE_FAILURE + default: + return model.StageStatus_STAGE_FAILURE } - return model.StageStatus_STAGE_FAILURE } diff --git a/pkg/app/pipedv1/executor/executor_test.go b/pkg/app/pipedv1/executor/executor_test.go new file mode 100644 index 0000000000..88e1142164 --- /dev/null +++ b/pkg/app/pipedv1/executor/executor_test.go @@ -0,0 +1,72 @@ +// 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 executor + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/pipe-cd/pipecd/pkg/model" +) + +func TestDetermineStageStatus(t *testing.T) { + testcases := []struct { + name string + sig StopSignalType + ori model.StageStatus + got model.StageStatus + expected model.StageStatus + }{ + { + name: "No stop signal, should get got status", + sig: StopSignalNone, + ori: model.StageStatus_STAGE_RUNNING, + got: model.StageStatus_STAGE_SUCCESS, + expected: model.StageStatus_STAGE_SUCCESS, + }, { + name: "Terminated signal given, should get original status", + sig: StopSignalTerminate, + ori: model.StageStatus_STAGE_RUNNING, + got: model.StageStatus_STAGE_SKIPPED, + expected: model.StageStatus_STAGE_RUNNING, + }, { + name: "Timeout signal given, should get failed status", + sig: StopSignalTimeout, + ori: model.StageStatus_STAGE_RUNNING, + got: model.StageStatus_STAGE_RUNNING, + expected: model.StageStatus_STAGE_FAILURE, + }, { + name: "Cancel signal given, should get cancelled status", + sig: StopSignalCancel, + ori: model.StageStatus_STAGE_RUNNING, + got: model.StageStatus_STAGE_RUNNING, + expected: model.StageStatus_STAGE_CANCELLED, + }, { + name: "Unknown signal type given, should get failed status", + sig: StopSignalType("unknown"), + ori: model.StageStatus_STAGE_RUNNING, + got: model.StageStatus_STAGE_RUNNING, + expected: model.StageStatus_STAGE_FAILURE, + }, + } + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + got := DetermineStageStatus(tc.sig, tc.ori, tc.got) + assert.Equal(t, tc.expected, got) + }) + } +} diff --git a/pkg/app/pipedv1/executor/stopsignal_test.go b/pkg/app/pipedv1/executor/stopsignal_test.go new file mode 100644 index 0000000000..113869ceb4 --- /dev/null +++ b/pkg/app/pipedv1/executor/stopsignal_test.go @@ -0,0 +1,58 @@ +// 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 executor + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestNewStopSignal(t *testing.T) { + signal, handler := NewStopSignal() + assert.NotNil(t, signal) + assert.NotNil(t, signal.Context()) + assert.NotNil(t, signal.Ch()) + assert.NotNil(t, handler) + assert.Equal(t, StopSignalNone, signal.Signal()) +} + +func TestCancel(t *testing.T) { + signal, handler := NewStopSignal() + handler.Cancel() + assert.Equal(t, StopSignalCancel, signal.Signal()) + assert.Equal(t, StopSignalCancel, <-signal.Ch()) +} + +func TestTimeout(t *testing.T) { + signal, handler := NewStopSignal() + handler.Timeout() + assert.Equal(t, StopSignalTimeout, signal.Signal()) + assert.Equal(t, StopSignalTimeout, <-signal.Ch()) +} + +func TestTerminate(t *testing.T) { + signal, handler := NewStopSignal() + handler.Terminate() + assert.Equal(t, StopSignalTerminate, signal.Signal()) + assert.Equal(t, StopSignalTerminate, <-signal.Ch()) +} + +func TestTerminated(t *testing.T) { + signal, handler := NewStopSignal() + assert.False(t, signal.Terminated()) + handler.Terminate() + assert.True(t, signal.Terminated()) +} From 883383be81f0585887dafa25f96c6e5cbe6b3b94 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 1 Nov 2024 14:03:12 +0900 Subject: [PATCH 83/84] [bot] Update contributors (#5305) Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: t-kikuc <97105818+t-kikuc@users.noreply.github.com> --- README.md | 37 ++++++++++++++++++++----------------- 1 file changed, 20 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 156cf741d2..e8436bb485 100644 --- a/README.md +++ b/README.md @@ -89,8 +89,8 @@ You can find a list of publicly recognized users of the PipeCD in the [ADOPTERS. - + @@ -99,15 +99,15 @@ You can find a list of publicly recognized users of the PipeCD in the [ADOPTERS. - + - - - + + + @@ -126,33 +126,35 @@ You can find a list of publicly recognized users of the PipeCD in the [ADOPTERS. - - - - - - - - - - - + + + + + + + + + + + + + - + @@ -169,6 +171,7 @@ You can find a list of publicly recognized users of the PipeCD in the [ADOPTERS. + # From d7f43699085c9da2ddcf913d8866d881cf8038c3 Mon Sep 17 00:00:00 2001 From: Tetsuya Kikuchi <97105818+t-kikuc@users.noreply.github.com> Date: Fri, 1 Nov 2024 15:03:49 +0900 Subject: [PATCH 84/84] Clone manifests not to modify original manifests (#5306) Signed-off-by: t-kikuc --- pkg/app/piped/driftdetector/ecs/detector.go | 37 ++++++++++++------- .../piped/driftdetector/ecs/detector_test.go | 14 +++++-- 2 files changed, 35 insertions(+), 16 deletions(-) diff --git a/pkg/app/piped/driftdetector/ecs/detector.go b/pkg/app/piped/driftdetector/ecs/detector.go index a83309d940..cfc1ad0471 100644 --- a/pkg/app/piped/driftdetector/ecs/detector.go +++ b/pkg/app/piped/driftdetector/ecs/detector.go @@ -209,11 +209,11 @@ func (d *detector) checkApplication(ctx context.Context, app *model.Application, d.logger.Info(fmt.Sprintf("application %s has live ecs definition files", app.Id)) // Ignore some fields whech are not necessary or unable to detect diff. - ignoreParameters(liveManifests, headManifests) + live, head := ignoreParameters(liveManifests, headManifests) result, err := provider.Diff( - liveManifests, - headManifests, + live, + head, diff.WithEquateEmpty(), diff.WithIgnoreAddingMapKeys(), diff.WithCompareNumberAndNumericString(), @@ -237,8 +237,8 @@ func (d *detector) checkApplication(ctx context.Context, app *model.Application, // TODO: Maybe we should check diff of following fields when not set in the head manifests in some way. Currently they are ignored: // - service.PlatformVersion // - service.RoleArn -func ignoreParameters(liveManifests provider.ECSManifests, headManifests provider.ECSManifests) { - liveService := liveManifests.ServiceDefinition +func ignoreParameters(liveManifests provider.ECSManifests, headManifests provider.ECSManifests) (live, head provider.ECSManifests) { + liveService := *liveManifests.ServiceDefinition liveService.CreatedAt = nil liveService.CreatedBy = nil liveService.Events = nil @@ -251,9 +251,10 @@ func ignoreParameters(liveManifests provider.ECSManifests, headManifests provide liveService.TaskDefinition = nil // TODO: Find a way to compare the task definition if possible. liveService.TaskSets = nil - liveTask := liveManifests.TaskDefinition - // When liveTask does not exist, e.g. right after the service is created. - if liveTask != nil { + var liveTask types.TaskDefinition + if liveManifests.TaskDefinition != nil { + // When liveTask does not exist, e.g. right after the service is created. + liveTask = *liveManifests.TaskDefinition liveTask.RegisteredAt = nil liveTask.RegisteredBy = nil liveTask.RequiresAttributes = nil @@ -267,7 +268,7 @@ func ignoreParameters(liveManifests provider.ECSManifests, headManifests provide } } - headService := headManifests.ServiceDefinition + headService := *headManifests.ServiceDefinition if headService.PlatformVersion == nil { // The LATEST platform version is used by default if PlatformVersion is not specified. // See https://docs.aws.amazon.com/AmazonECS/latest/APIReference/API_CreateService.html#ECS-CreateService-request-platformVersion. @@ -279,37 +280,43 @@ func ignoreParameters(liveManifests provider.ECSManifests, headManifests provide headService.RoleArn = liveService.RoleArn } if headService.NetworkConfiguration != nil && headService.NetworkConfiguration.AwsvpcConfiguration != nil { - awsvpcCfg := headService.NetworkConfiguration.AwsvpcConfiguration + awsvpcCfg := *headService.NetworkConfiguration.AwsvpcConfiguration + awsvpcCfg.Subnets = slices.Clone(awsvpcCfg.Subnets) slices.Sort(awsvpcCfg.Subnets) if len(awsvpcCfg.AssignPublicIp) == 0 { // AssignPublicIp is DISABLED by default. // See https://docs.aws.amazon.com/AmazonECS/latest/APIReference/API_AwsVpcConfiguration.html#ECS-Type-AwsVpcConfiguration-assignPublicIp. awsvpcCfg.AssignPublicIp = types.AssignPublicIpDisabled } + headService.NetworkConfiguration = &types.NetworkConfiguration{AwsvpcConfiguration: &awsvpcCfg} } // Sort the subnets of the live service as well if liveService.NetworkConfiguration != nil && liveService.NetworkConfiguration.AwsvpcConfiguration != nil { - awsvpcCfg := liveService.NetworkConfiguration.AwsvpcConfiguration + awsvpcCfg := *liveService.NetworkConfiguration.AwsvpcConfiguration + awsvpcCfg.Subnets = slices.Clone(awsvpcCfg.Subnets) slices.Sort(awsvpcCfg.Subnets) + liveService.NetworkConfiguration = &types.NetworkConfiguration{AwsvpcConfiguration: &awsvpcCfg} } // TODO: In order to check diff of the tags, we need to add pipecd-managed tags and sort. liveService.Tags = nil headService.Tags = nil - headTask := headManifests.TaskDefinition + headTask := *headManifests.TaskDefinition headTask.Status = types.TaskDefinitionStatusActive // If livestate's status is not ACTIVE, we should re-deploy a new task definition. - if liveTask != nil { + if liveManifests.TaskDefinition != nil { headTask.Compatibilities = liveTask.Compatibilities // Users can specify Compatibilities in a task definition file, but it is not used when registering a task definition. } + headTask.ContainerDefinitions = slices.Clone(headManifests.TaskDefinition.ContainerDefinitions) for i := range headTask.ContainerDefinitions { cd := &headTask.ContainerDefinitions[i] if cd.Essential == nil { // Essential is true by default. See https://docs.aws.amazon.com/AmazonECS/latest/APIReference/API_ContainerDefinition.html#ECS-Type-ContainerDefinition-es. cd.Essential = aws.Bool(true) } + cd.PortMappings = slices.Clone(cd.PortMappings) for j := range cd.PortMappings { pm := &cd.PortMappings[j] if len(pm.Protocol) == 0 { @@ -319,6 +326,10 @@ func ignoreParameters(liveManifests provider.ECSManifests, headManifests provide pm.HostPort = nil // We ignore diff of HostPort because it has several default values. } } + + live = provider.ECSManifests{ServiceDefinition: &liveService, TaskDefinition: &liveTask} + head = provider.ECSManifests{ServiceDefinition: &headService, TaskDefinition: &headTask} + return live, head } func (d *detector) loadConfigs(app *model.Application, repo git.Repo, headCommit git.Commit) (provider.ECSManifests, error) { diff --git a/pkg/app/piped/driftdetector/ecs/detector_test.go b/pkg/app/piped/driftdetector/ecs/detector_test.go index e068c151f8..b7f7a37bfd 100644 --- a/pkg/app/piped/driftdetector/ecs/detector_test.go +++ b/pkg/app/piped/driftdetector/ecs/detector_test.go @@ -162,10 +162,11 @@ func TestIgnoreParameters(t *testing.T) { }, } - ignoreParameters(livestate, headManifest) + ignoredLive, ignoredHead := ignoreParameters(livestate, headManifest) + result, err := provider.Diff( - livestate, - headManifest, + ignoredLive, + ignoredHead, diff.WithEquateEmpty(), diff.WithIgnoreAddingMapKeys(), diff.WithCompareNumberAndNumericString(), @@ -173,6 +174,13 @@ func TestIgnoreParameters(t *testing.T) { assert.NoError(t, err) assert.Equal(t, false, result.Diff.HasDiff()) + + // Check if the original manifests are not modified. + assert.Equal(t, []string{"1_test-subnet", "0_test-subnet"}, headManifest.ServiceDefinition.NetworkConfiguration.AwsvpcConfiguration.Subnets) + assert.Equal(t, []string{"1_test-sg", "0_test-sg"}, livestate.ServiceDefinition.NetworkConfiguration.AwsvpcConfiguration.SecurityGroups) + assert.Equal(t, 0, len(headManifest.TaskDefinition.Status)) + assert.Nil(t, headManifest.TaskDefinition.ContainerDefinitions[1].Essential) + assert.Equal(t, 0, len(headManifest.TaskDefinition.ContainerDefinitions[0].PortMappings[0].Protocol)) } func TestIgnoreAutoScalingDiff(t *testing.T) {

nq zUBQE%dUu*y=F$SAEGwMwh^9A%D|9aN*CL7U_9T!k&n4iBAS_M}j37eq4wT`+A7*~S zfY|tHn^Q?ZBv8X1vj{htIbsToD=R8Y6u}TN&@R9cTydv7=WA2uABGiSzSp{c)+GIX z0Qml{VIDFd+PbJC34`Mq+I%J%C}HJ2Mx4c4S6KDCt@aANTc6CZ1~)$r=*8@(3t34S ztqYT-J;-y)H(lSZF(>ZEXAm<6L(vpHS<;6JJQ!2nOS!Ih#|Vox@6R-W4U4pgSL}w( z|0qBi{OGR`yY71TDId|!Grd95L@=+d=xLN##p)T%tU(EWjEZ(NykgTY3>ZGWjnFWx z6gkmcc;k{?K9EJO-R;~d6Q{FkfVU{(p5$d+J|Fnw9BnpfnKQZ&qYO@>=X94pHgHm) zYK$W3{}DC*L#S_}Lc?@xX&_M6AU%06YQ!A>hOQ>>q-4rf2X>8Jz;IPeDz&Nk*xL0l z0RK29#8ejU-;M)(x(51+t4Roi8tZ=ewuAbZsH`E91AJ2*@wwYB~;K?OA$yK+iY>+zpb&QY~l$ zjiBvcrO5Ot7(4-BV|`T%_4jdbjr(H040LA|AZKxbH9-(b=aF^J{*P~$cgTJ3OGX`y zD53N-FJumdWoMt&SXoikD0j`+cw-eBgUIkNCRsH9#?eEytxmRRh`}=7`L3D@ib8^` zLlWex08tJY_A?1v@sMmRnJ+qDhv6y5fl8o|FTs5t`idv71~8v)>cAD5dP3c&1U4S~ zFGNH{Or(j;e*f_pVe*!n+%;al#nR8c*FtrapWF#cQZC_^|FS`&Tpq?K!7GL40VLr^fq~&bvfEL$FkUB6?eZ&E7f`;UcD+;;42lQM99MHi7PM9?n1Q@!Qoa>yTMh3x?14 zs0WaJJH?|Ut)-v+Y)?m|Ij~De9X9Ulxu<5J`wQR2hR_Bj#xWRV_7s@2nxc*~r+|rV z|1oA~7(9)!J5y>!z6VA6edq)!+G`lrf=IFaiF@^`AL^p<3c-oh0b&cnnQE6<=ejkH z*^Y-43-JZ_islEO*26k3o4>eSq?C-ZXec$DwfLioHl`NIHe>VgyV5oYK}Hu) zP!E|FFUdm|Xd1Ij(h~L)*VllEB3}TD5%6e#@%Ph-)IH(cYE9GS?Myx_;XN_vIe$Mt z+NnMY@!iDlt&AFJbQQ5!v})nVdCO3SD-{?XXWhUuLzl;onFMM@o-C{B&9%?gRSr1y z-uz|rM+0u=$%=Q^E{ z>;ee`ziBhOGH&6Fa`I#7gw~N{A|i6G%QI&D)6;9FEp~&&4ry{2M8Ers!}>y|s+-VM zsDb>dbT;|>+ClT|eb^9vmFsC)7qgZ?lZ4Qn80>=j8{@Kub%#i87Y&NqXwE+}O;z>A z_z3S7`Bd032lzBEZ|2U4+I_0na&M~I{WsadpF##ZJ=&)BdA}kJ|AX@H&)B`oLm(8h zABz;ROWwic#B`DC(W6JLX=;3-u{yb}CH}@QaNE|QXHpdXq3KkXRy zmLV47$n~4sgDYkFu0<2?aYfE#6V{JEo|SAQ$c3JEj)}^9@|0dyc9-PO^j_VDiuYLjQPKWl|HP9I z4@8@NSLU;iPzef@9-_06fMd8$W6_%QNWcnaGbr?Dg2ACYNni@5Fo)=(3S>#o>2e}< z6S%nc8{3hdQZRyZQ;P~Dk#kyq6c38-;Fryo?nUoKW8JqXNc&>!i){Yvxp4FK|u6F>&xN8M(AXc;LBy3+>w#*gJN zZ6)iS!R^~%nX0ZHp^~B5LHUA4tO?}>Lm96DBqbk8y+XC`VwMlM<(qt*k^s@_WQ{4pSB%2^7A& z#ZZ1aD(V4Nj^|T<^U0FjNsnX`WTE&Lq*}SNL0)Xul5jtgqiY)hP~B@FAUk>N*xT_* z5*F0WaO8;jR8@c-hp2MP%rZy>80c)+1WkiBfc2E)Fs+KU(k*zCrIW0P;U$fz5RR?> z@+GNY3aFdZkN`6KQ8y-di8?(R#!33zYUW#?Ia{`h<4&5j}$Fm4C}kCEtWV5!IlWGiWhw*a-* zfdGM>$-*ieh$7LisIy6-+e%^C)Wj8M0G+9iGdmq)+-}E`XiU)CaIF_KKSTgC8-Wge zkCjytRjIqLz#1b5{vRL0b#px?%kSNzOp*A3edVN8)(K6YJbJDrX22nXZ$HXYE+fxW z3D;d#$7CMPHpb_O;8fvJtFs#0`-@%SN5qp3-$u4L`N`nyx2)ZjQX%f*Y0!BKDFcM0 zbtRB>)~*=b7p}ps*XTi)P*6{Dt$DQzY*5b5a9vmcm0hf#IDx{wx8bb$gNOLQAnVU`wXTYPa$>C0WXQ+B3V+s2P<2) zMS+5J9NWk>PTOp(?uZn}3)|5arHiHR!h2%w-FMmQSg5OKs`+d}KB2hjBy--fe>kSt zs+V!nj&K-npZuGZ@9OCxala~TmHqdri|l3OisDO8Lp934+M3^E!}o4l$M z-3eTXsZk#6fljgTFyg~O+8H?uihl_oiTW8I%EWI;B>mesUfIkNR~Eg_u(?cU3_00n=d#{mN}E_bH+zD}XFm z`!OwAH(EC|_LhVO&H3-?Z_OX*6^U40rfpd-T;Is>4~HFX&67Oys3ul+G4q^IBDXBw zsz8R6zFN)-6JiyYj%qLp8Ha>6f;r5OZ}?LV^Ut#K&;N&JrwTbn>%-h|_WQDE<&M`F z1{RwYe>(V-DhqDcr5F{uu1hhqSkDUs+xUqAY!X(&k<~C?r~WD%>I8t(!PIn25m|;T z=}*9Lu9oDjB19v`+kGPQ3B>U_5KbQ3yLT_i?OvnI^$i%6RCgRVg24r^6L!*&_@uyP zc}h|oNO3l{<0H|!>*hBwKpKrQcyU*fH>iovb%dikYo?cXhPmb?8YYg_N$VKmPt#k%ay+xVZV;F!(0IL`qYE zeG3nuWFasuPjunI{re(S4os#g8e?FT!qbDxHPt7Q=$L@;1;JEGkrIa(<0#Vfs&DB~ z&#X-L2!P%V2Sh7Mm=W$>hEEE5Y3P_vL}}>y_ZtT~1RjBeuOUuc%l;g?WFi7E)N6pA zk@n{~oq3=?MOnsTV>Ki+IRCW<8nv9HSFT>EtBY`GT91zlaJ$=glpE>MCA)ugc12>v zCAnTLD@;U!Z>b2w@~d@sNawblYh6qKjqMktY5&|lv7SPh!}QQvsZhwgCwL3)d zg1=H%Fi3{M1lN*yHCFT=dvHl*K1+#I#XoT9@7MCbVgyK4+IvQyt3=L!-`Rfe&iwh? zB2R*Kk?}P<<{w-3zn$&h@APP%gX~@i*)@?LDChrr07&B<^hk_GEA#i+?0^4b|KVMB zS*q>vSS63$-*TE?T&zEz_m2Y&(t!NV#0(`it3O++JW@#Syj3^vndtW5)%WkO zv-%bN)|NX&4#S%^$vx(a?Ei8h*~PX$<+FQz;EYmDOkHw@*k~=oN70m}&yO*q6`s#T zdVgxK{^f7~lqI7Vx8HkVa@OE`s_?Iqlg>gW7I(y+*7aDwlwzf}n+0)O&E;>qvi~}p zR#HeWUOoI1b0J6<&D0Xscwon)M>(8xNY@mDAMR$q3LQhrUfWzTf;0a*x^Mrg*@eEi zx2WOPzKnl*hWzTAf`UoWHi_5nr)ZgUI@@YQwT+W>@1@4%b0t{*bvA$ZFSI@^sy(U! zcYpffqkGl!`8y+1rde=e4kZcbrC(aoeLpw$18{u4e*}yC^rPN~)LGUGV*CPyN3?{j zPdV8aUzxu=dcOB_{j-qHGe@Yzg}HlMqS&!} zCQpjkjs|IET3eRyOJBaHoMJe$^O68D<^ua~rFbzIa>IBD35;6iceBEOJUkatC_AnD zxDp3rk8QDvOvN}gWhd!rtgwmB3@Gp0c+dn?4+z- z(5L|PPb>5}z4S#Clji%He|Q0CdJtS5Y4^U01O}M0ZZ7#(#Shi>(DgWZOs()S>ekD2 zU3$Oc=V*Rw4QNlmGS-{O+Lp=om$ToFu7oz*D4GmQBh*$-;d{dIow11X>chUy*G~Xy zy<~geS2ojqcz&=pRI;&_{V#XozgG&9m8@SId2slY3Cu2&3pWtj;_p*WV6uz+*;5$WAka$Gsk^NbK%9fEp+4q zFzh=~suFz6e?9G=_N^Z;(F;=SGwwz7nZFri{_R`P?UdW&73FSH&*z;7+~f4g|d z^ZTGAJtEE+^TWyN=j--wfALdo2I?B9N$=)2{1iZcx4H~^0v8s0MUnc4FyyDN|MknD zyQDA~b4K8&F!`$k|Hn5Q9w!y0EumGv3#I>jg$!e0X)>DBH~jp@ZyrInG$dpei-tBo zKeE63eT{^RrU)4M~Z45u7#9DSrX!nJcWFhrIsEEMql`kb4u%@!3 zcCQi9P9gJd)9%@oTWT+EYqZ3#UfN<6s&3wN$uLj9r8mMf&7^ozXCy3KCxVU#;0Y2WMJ73v&BfI$`jV_JG6MQNEWxoTw zr%IM*LJ^9kqiI?>j_X1pJq{d4g2;|!LfqAE-n_9uJe=#8$fL6sZqfU?cf6&q z2_Y8S{dyUnF^>;UG;`Rw=CLA@X0;RJi_ zw5n>DN>fid&86?;YqxWrsVYe_^NDI&>*CC+YQ^Q>;qpz7Upcp5LUyk@v9e59mX6z0 zFet(JPRa|d@!hR2tsE*IC~mACi4+;fsjT0ryo;YJ5Jhzo%RcO&cVfz@3k`G>j+SW9 zoKvzV#mjAv|s^;m0hgVq( z+bBOrH<@geYZ>;ql?&6mpbQXm=Ke)!(SAEQVhMIZMIM<&%=cw?yEpa$Bb+Epj z(e9$AfJYlDU=HV;d*$Nw3G;8z~r{dB`t@jNaQRs%-tg5E6(ypr2TCz$uEth zFvqwjrQN%)bK$uMQ~dg}f1qscgEuiirP!58mdG?=MZ;wNWh^w%UYK#Je8R%UG<*j&tQg=nh1!O6)hZ&~w{#4{l9aw`{GX zrN-&ETd6)+x@msC2D$|D;d`@6&7B-rg}ewE46MSvpbAs~pkuImJS{=8rC>m~3ujf) zgK;iQyXQMDNY?tO(_Q3JDSZO4%bNOW7VmjzW?$aK*7;>wX$oFi_8M@K_33E|0UtF+ z!*0jPrKdZZg;;XdB$x~{u|5c_E8PHps2Y!2Pmy1#s<9=VLMOYS(_NZDtVUsbS-rUq zexHr?U`p9s8%_yZA|~Q5rkbh2XDzqduGx3DeKJd9f$cSUl8~t$*5o?IZFDkx@b0p? zQG3L-+Wg$<2LqDNJ3iCJwyh5eOVo_3+7ux8G#87CQc>KYclD-Sg&2V~Ir|GcM z2h7ae+~)Y5{$6b0z7KviOSrtC{ER5Zg1K0+I*5#7-J?P_y7;^xRkJ&1c$tWEPgP1L z?S`inn2NpLHC@5SjlQ@=Oj25F9kU`Q*nM*6V`$3BTOdY`68=)FqV3e<<*8^#TGVqUFVXook7~Wg9ej zNXAh6-SM(@pwbn zxTLEs>{G_gi~&2rzQ-qZ*Bq`x1TU}UR8yn2#i#M5w5ez{u!FykHlo0%!TgHavmO&& zTotoG#5t<@tB(k)#cuZp!s4A&nvaxuiV50N-~! z>+yQm_$`N9l@4k;94^Ofqj5u$)0(rbV}42cF0ohXXb(2$IKq(Wg0YLE22Lfjk6Q)*g~YD%no*!;Jp@^ z1i8BBkXb_AL%Rl6mwoTs;%9Num|$1VCt2muBOcUlL=spRoiWqPbl4h;`e3vaO4orj2F?gE3f-IAp50?VSWfhf(P45hx_o_V9X~o)-7ZK3)l4^~~yP1>knz!?=%p+yL`w=o8Q+}kai`{k5 zb{lK6ihi@NZ60Y3xMm($tYA0L;fY`POISSsSA z!~`$&G;0y&U+VM|@`JQy{FlDx-NlVD&&g)YEbXoa(%l{7oiCf1a6Utu>*I#^+yy?*+6LEJjP+4tfNYJ zwxT)=#a(8*A!BV~*Y~zsR4=cKRl>8GKUFL>$QwwLL?1$vNCwJ|!4cDA&&aquKyyBD zbf#llPXA6)qV?GX%J^Ulxbl&4!|jJLMsPm0@m^g7)~mXU2O)*B<04JtcoC86nc#%m)CH+v>Kz?dRzS3N^f| z0*sm&hsdZAa{N5}ZI0|xG|J4bgg{SxH6p;kaD{+!L;}Kb{(YyWrs0BVcb%|pZ16Sh z{~AvGc5Iypp*NddOXm6U#_(NAc87DObOenxM%q=}19qRQCnj)vD4q$%891u}i2S9^ zIq+3~ZOW(Q#7_A9_WihQLf7)gYoFRt`gf37%Q9p z62bFfzCwiu4?I{0DUTp|?|5Ghbdz1SA7nW?;{<`mpt3q? z!Qk#eR^!PnyOZUtcYH=t{OxB? z>A_m+bkx~pgf@;$ z+LNj65hM^QfWxPzY1BC5lP#$yYu*eX%rJtre-~N*12(jL4Nx4;Q@mA9k8qwUk|4Iun5a)txu&*I8PZfa4x{(V*5{03&9;?~vcoWTkNnH`-kEKbJa zNz2jb6t@}DUL>tKPE1vAy4hg+@C0BMirZ~v+()C>bX`xdjTLMp3Vp#K$@gq1>Pyhs ztzK4UJ7Gg)!^c~8)~@PVmZ$peJH>taOj@m4s3vzmc>SRK#J-t9Wre&Fxjs)d9dM;j zvScnCouMs??L|XYb10GS4(;N-^KZQ6GVFy5{c*Iz_0ty=B59EJIgnC*yO#1NiTu_Y znl-Z%t-D*hfrLcw#g_Z;n$kZGZ}N}(^{kgyr1J?vTnM&P=2X=|3kJpJWgmx1vX~S~ zIe_MJNm`c54BrD#S%J&@1kJfnYGKjW=Bywe`YhBd#9cyQ;I=UfKjGn>Pfji@H-I)b z8?O(xGgLcz&v9xzdA}^_^V8(|4SXt0*=K9;nG zo{ei*>6ett+|FpR<7_K??k&CBEWV01M(|P!a2b2QJ?{T1)F&;WmU*u|b$%IVDB0cz zZcs-~o^4xG_;!K&_4McL-}Iqz{gfaF^Z6^+tTXYYXCSWcDAN)}i z-1wY&**IjKP1IBbTHRqg^{sR9u%A_ggzHn7Keg4=u{To-3U9`D)atd{SHd|qB<;Iz z0JTd}tpJwDqh(I4cQnw16P&YZ?Z*DjrdwS>7l$XnrP zI?$#jEV-fj1yT3sGex3T98sIkN8*zb(&Hm@(K_T19iJ#241e5||IlI3tR@E85c*N1 z`iiXIq*_G?oNYAlaQ{P;Oa`)P(9diQeeI`OCAlEj%P&*qWc}vb^V3qR4&PNNnD+V{g!%M#@_<7T~Sd}G)$m!kea{zI9Z+*?Lc3R=|){TH39o7ZQ6^!~8Ts3UO zpLa6b{<*bSVb%L}Ch)9!of&2_+X(m6NM3iM4$g0Mf>c+QmR7}<;v&Sa3aX~}Da6~X zP}0l|{1zk%Ys`LpqDsF0G`8{xQ)bTK32;+9y6=u+3*GaPM+`SBGVT6|xd?Jo^puvS zLd7Ov)|}>zmiZQj6|H&5b3uJn0ruJ0KGyFAlD!;P#&@?ge)cU0r78HjoNO>@EwFl$ z7)JvV6r04+f9p3o~>;}oQTRPjcjNk@XSyADlPdC@~ zGv%_p<-EM|a*y*%5~pl7YKkZ2*+~qjRZ%(lSdSlGWF=6towPZz7N^J@Y;H&}9k0j_ zt|#`^2maN0!v!HIqlurn-u?(7ngWYwJ|v|lHkn{tvzOYbBQv+4wu-DDR(Fme9ZMC| z%A+v!pa~G@JD9ioZIbFYkfMeB4uKY)+Zpw}|*;Y&j700u7o2s8QFh=kpuhc?~` zhTj@os&NUbh5cN82sm!8yQ07GE^o}A19Wq59&=Y>(h>wEHQ{yBu-tVe$w%5NA6`Af z+4i@h-v3}(t8wp%$-TXu&yKxI<}*rEf?#Fb6bwX&7UP*&bp3x z7#+5+TMly6g%;&r+kMh1LV@e>@*6ACZ;2R8Gx!1-ym#_W+NF|ri3g@nU%t%29!`Si zZ+R(iJz47dF{&N5I8;pK>$A#tn{Y6ZJw{9yFS3F<%sLuS^$~ZW<5dn79;!g$Drh|` zvFL|PmU>4$){lJ{BAD2foCGlz6(?TgcQA{4k*r%duT;~_3KYGi-7jC@-|zdz3>9j* z`JL|2d8D#FwO}Makwlj*WJ@jK&`}~ywOS=sWQ+)|Tg7KiUw&!TEj4Cmbr1D?Yh0|P zlX)ccBr*q?Y2AlX*y&{M{4tZ>a1;GAvYnE%=g1{FrOpN;<=j9`PH97Hk>I=ht>%yw zna`smFt^61J*!W6M$Lk=>Ye)^2N5J2&+JL9gesAd(}7=9wete61V8?G^Nu8a&Lc%F z?F-s!ml)a({H&EU@6o@%aOT#nl~e|g-p=Cih>+}*ACjuQmv7iiAEBJZWNT`=lt;~i zum{~5;p^*LF8DKT-MX65b?k!VbhH}CH|{LC6;9eDOJ&Y^HTZTJ5S@nJtd^&0aL4&< zyrE(?`c0JdTET$&yT;@$wZR_!CtH|`_~`Dp&e0FIPj2LbQvXOOof8eA)jblm8aL3c zT$LEV(q^Y=T3cJU!iy(al>s_l(xb9Q2T%QXyRg%#K;ym`Ql(t_-@8cpf`izH`5gmZLd z7UI-b>efeo7(URg?bZc&m{Ki=I>bZKc&u5!eP*{)CisDCZ1IgwTY39Qy@sty?%9bM zJY!+6XIC|0r5&vNZn=<3@eq1y@D4N_Bm{|~ryRi>Fny{(yUpM0(baQ$hVW`s8zYG5 zFqLk0nQ)Pi&g(~Su)#zRqI;iaFUX|t9& ze^pRs9%>`bLfNXmU0U=R=`yf(^6mIblq}-J>8>u6!*DIADO%&#b znh&xW^e3z8)`*CNxFr5c4*ixLaJ$gUkjLBW#eZBW(`kOzucujn)sEgM)dj|zCB{Ef zM6C&QE|w{bZpF%Avg)R+6yGg8Y8~k^ET;|2W4=@({0bL3xy^d_I>P#7dKLr%{@1pS`S*8!N{!ZBM5RH1L0=QEz!EcI*1}L1{|f%-HJB zg}Ck3FP-Pgq!6)LDe-1`MUei}*1blvNe*vT+P7LHl{gTy1N0jA#~^3ax4N^}(1Oy) za_FKX4E?=JlwH@D8Xpr;-7I8Wea+fT83VbgXhWN5Jj|CK`q#qK*u{<2ovz&7&PvQ# z2b#^I8=cjp&Dp#Qmm_F0B}LOhI^*WX7`2)M+t2B;_%ToU2XZ!kqvJXC1NJsvzOdej z!y1Hg3*P(25MTF!S2{B=0>;^3PWHb4@=>ip`y#3;=|F?BA8V&hjG=O#XhdbkkVliE z8J)|CLl*w2-cqo@uYSfwy_B7$w= zfwz=``DK1Pt^V#+W7EsutAP=06` zr6^b9?)aM!$e_(l>TY88$Y;bxhk4nTSJY#WPlAa|KS(6a+hGT+LME3P7lRiPGfQ`u zSpTA8yzeskB`dhu!RHLn{Ge72iD*IX{!n|H%f{twC;Lg#ObNBHV>46R4)(o1+@8ek zYHrn)kfVk>;`|9hu&3>F>hMxq3{gBEsNHxvK779s<%ul* zbT$fDO1hZC&=*Ya6a;FMtJ+3GmJz-ZeYG^@`Aa=_S6BvNa^_p~-Ou*X>?1V>Jqc2x zt8Bd~VnVGW#X-k%}msLIa0+3C?*-^lc3&2h@NgXTTPzoXy6Jlp6Wv|e+P zg^kf{Ll7&Zj9){U`ED`@4r?&MS>NSqCwArV9SQC&}U41u_^ zmvFlh`grau?opgC5j>T3`lMoAPo;$l?2Caq-w$t}KIhsfHu-N-S$?4=wPKg5Ms4b2 zM*8=g>=HV}s)y4t_=I%+yP;hJ6DG!Cz!6i2 zhgZ6M6v-+yx_6Q<{fgVOxO`HjUW3;5UQ3IGCt8e7afbUnd_us`_%nBc&t{sw871VA z!TMj@L-d$8xk`|b+PwWp}w!z1eym;Ge} zwBv~N+_^!N)j2W?q~VlUG^)AuYeOJ z&773@IxI5j79r@PcLJ5zUs%AVZLgzeZ< zmmq8O2rpa^Zte$`-w%kZvvqIu=Nf#^g1E3X=?Yygta+^TwQ6JP8<)(`D#}B#>;rZ{+dsGKZbOPe@y^)f}O?yXqLO zjlBQX`G`XxjE2n8dn9S2D3NLVAa$}1ahCo5TL|+I$Jvb$+wW$6dv$ify=T#R;uk%Uvr_DtGLGD%YcZJd(5)$=_;AOmu&_Khu>h1+yupLw7wiLiyL)pc z@M`voo%$B?x6^6SJOr2z0}Ern_Y!$ZC%9H#KZX<=o77;I1}8@yi2MA%I5?!4ud+&` zo#|A?nH#5)1?|wG8w?W-yn(dmEmR*8ImWWfd$Jnt9wJ*RYWz{>Oz!!nIGIWG3eEr7 z+MZKA8F6t~TCcC9Ts!j)8G9GxpkG4(IW+PU==QP@wyE@N`aG^rN)-;=tx8qnI84X7 z8k}afE*kcv!S2@Ugi1#1^)0^lE9lQ}I5lDSSICg^>Bp76?^x`BJ{4*Q`A?H7jao5b zV~J%l4^PEMCq88ovR02RbrCDkuYRd0?n#XxOZqErPpstdZ|0kCu8*v4Lk)5sRm}#0 z?Q{!}y*mCuuRtkwS``K9nPN6CF8$ahMA`ekQvEyUbuSjKDK%| zi1~(`OY6tGaq@6;PdUg6o0Efu8Y{zAzIPU8$t(YY{6$dGC~JHC6!DfMuT^2}#-xh! zD;8jG!lIXjgfv5F{V$Fkcue2Z6|EeWhy^RL=BF4uC+&BL=^>IeXUlkQQk z{oS7-IgL{LX3nE7uY?1jYyt|okI?ZzkAk$O#{iNhA zJ;9x|oZx(!>rS_%n?jedqsdlJ`+RwF@iqoXkVO_42hs><{Awm>TrHDfea+@rR(aXb z_+Jza|F$q4<|6aDkFp1SWd;3H`a`NO_V$a5M{JaErv(v;YtRFdcQIa6`ggqp#EE29o{w65WG!XcEipsnZVy(n?jtZq@8(n3KfhtWM)kkFuj#k|$`m9i zap~V&PR6lhy3o?@-`x(wW5qN?@YJcWUv~MA7yVz?5T|==PoMZVf6b8p>s$Uoqx?@F zx_AfJPLJWG+2{Y2kCG#D zXKl=~fq32EppVYTdB~U4V4v7IvCo{~IQ`Yr`H|S4Y?K>Jf2*=;EFL>$)SHDEHK;h? zo4d?qQ26flX;KB2eG@sh%Q(`HxmO4dDAg|uc>K(JXMkDU_J>Qo7>!rXyzJQ4f zly%jyP5>Q3H;Y+k>g2zxJ*g|V`1~Bnrq4H)R5sx_P&+h%!;0)4?g!paH)Rzvvul_l z>shCrZipvc-i)oW4+&h;xfwdsmNed|DiYf6IRSO@e=Zc}1?96;6O=ykXb@HMZd!IXQ1g1|bp!bjMi)jhq{1W{`$c z=$i7umIjN>^{JiN!TP%rG!}ow(-SUJ^#VP37kvZvItM+siJsF(BqP>C z=}!}M6njmZ5w0@7xd)Be^{2=xYn7oIR#Z==@2j9ke?HW?&r7sSRe251Al+CNc#)MV zM_A(r$tutpTe^C~*MTcA|Mns6?cI~!W;S&tywnokOC7!5WcvG=u+|b>`@C{akP1{d zm&LX3u4}8GYXDN-Si1)y4Rk?r^;WM~-&{5;o(-nnB-c5I$>PUV1Z`yiRF9W)Vp2dy zn)k|}j2Xoq%G9sC?o3SJO1YU=2Ud1Iy*udu=x_|(y=`3kxL`upfOP64QA!N*y|$nG zbn~X`z&+t7K+l|l_F%P|jcg{v$7`m7L{M@<%1h#^qsei~o{|}CEj{SJ#)yPlO?ck`o&PX)H&cW;G~2DI*m1IU ztQ=Zr_!U(-0tx%hi1&7D&+*@g5M;V$ zPFu?n=1;M{hQ3(fAv(^^?hpHVnHP|0g{4)(R;;!*!W?G1ssz#-&-5*ICGJEDAC{Jm z^y{p}(e;n%6^fN8_yI6!uIW1`t1j|Juw=p+zA_@eZ2RNPZrWAO?OcP^mPe_>gLl?l z7c79$h4>l>G^kCPo!wG&rs`GUS)!Agar(*+DBxDV)%tH*t&EKoW?tef44yi`=K|iW z5oOw~d^tmVg<>Z4aqEI1ueaxuAvL_9;hXlK8xXE{n8*+7TM(=DfV`Y9J}ta!b)+>z zfAfj;(xsMidKq1q(9pr7m5QHJeKTzrXeUEW_rlNYHLbpM{TJJy{72N0cT=m-y3Q4} z!@cYAjT>DR%8{GD=_c@XtW`0)Mu^UR(-)6@;o4H~Ki||~I$~=9yt#8^p&qgkN4Hc8 zoOgmIgyTa4_A!i4AJe5M-xNN>Lzvu5;@j+tSem+KpL7u%MtBcRu3mFytsH8u~D zp%c!$YgN$YgPG9At@637t=a7Mcp%F&%WV6@CJ#!V)qUp-URF;>*xLC7RU=kLZpfRV z{3*Mf8RXf^V>R-4MSN9cRAx zlz6-r9z>?W`g-^#juHU$UJPO=Zq<$0-q76` zc5ELMeLp){8>&>O2Ide%5C%L-^*Zt#2Y|*uHPT!GQanE9%*=g(?I+2?R{4h7JacHW z8$5k_0t~QQz^O3eJD9Q94^dd-Z1(JEIkuIFxBUFd3Jv}6Sf2ZC}&x9j@`63xY$ zWoH#uO1xuYN$x|u3SFr)#hLlbI?!nt7MnnfM=!Su{P+s~xs*^n#s>)w;`> zJA3-jcp!mRuD>Z+P=5eaty)_SOb_3hR$na0cBa;WJd$4gt07nW;?S6~)!`@k1w#zT zyn-vbJA0wKcwlQk>BV6Gtgpb3|Ke0j);$ovcGGr-u1<41lzHti7qu1X60~j)!`Ejs zs?`FMTqL^HDxxs!Up^UhY+#>40NH^5W{Z#`b{tgxp>18SVHF_{jXm^lnkM_p9k?5K zB`!>S43TU9F!pO2@A~|7EdX;m!uLk#8bA^A-? z6Eam03wy!D-AenFE}_5Sl$5PPh=W#F-@=&9wl#0L17O?bP2**Do`a8Y?g*mkY*Y0; zE>mb;nF*r5%!!R^PTqB;&Y2Ly{Udm8-fAbk&_Rwj7@^aLnYvGED>0N5l>sCU-YLS~ z!H3`QbHeg&ogd03^BQKZ9112C9UnMZM@Zs`K$C8WlSLbuleLe*~VIvTI)^1 zzJ6*JIh*`y3y%O4H~pC&UjBt%;DeL!!j0X9@#>Ouc)^GlQ7wA zt#c*MZ-hTWk?MPs$7vNuw#-`5KO<(biziVg;!d{YX2LZ-#%A$7DLw7i_2#ISEWd9f zE|2|M_89}JG`(57<=ocQ*zb8&oB6e_m7k0n;nO;-A)l>}MI5$*y8BwtDiM9M#WBo* z7C!9-y(tUnHT@yE=ES8g&MQL#tcOQ19@7#sQo-X^{yZz!Ir=v@<{Tcw!1a!wv z6&~r*OdWDzD39(uhM&X2W22mx3rwg`MeSvVO>5+4Y$|Dg_c6-uu@P~YLOJ%8iFnma z7*2mED|A&~j)1}6L*0c{fG1RW$N{4W@4l<~*)WUw3UnLEysnT7-ZqthGV_9y?pf=& za;9&@unUSsN95#qm;k{u4<7C!e*Gi~?q)6Y%W16uXVrS%3~%gjV;Ee~G`5(uf3I0K zSn0HruU)BzHYkhXfNNmuZ`zE$pLzF3x`P`*KLpXezRbitT3l^;aED#QEiJv=0Hk@M zfkp4M@B>!aqW32EUrt&AJSvb6aan^cDs5AHQV|lS$+i#1`c?#=%^TdN53IWr{$`lz zM)8c-xhLnSuza6F%&ppEGI2}U;KO;3?;nc(qZhy%Bpua}^D1TzTB9K=mmz3e``$sm zU+(LZwUi&lGZhi1IM}oNR_jw{eK}$%UkS6UL&xpv1zRmGaI&CKz@ zf>*k(Euo(xX~tG^mGJAT?RAwae%@kM1v4{NKZJ5K;-ip`Gdy#1we~@IkQ%=pix=C6 zR++uEoDF81$^vm!LWG_=ujo5Cof&^qEto~Q%~Wy;B>ql039 z0q;#4wo<1tv3j&IR#(#n2(a$Z)#H~!Ycy~P_mEC8&g;v}ujM^@LsLNCB6&G9KP+b3 zBXlsD0K0n=@b&0D!huO8_$k>7T9GDzJe91$c5t=dr9bFYe=h}{pW)?uJZmIfIh$Jf zUc+c#IanKQuO@vs`JSLDeqY2TMT)2B7HwuV2}P@{Dp8>Hs=;w-y$*NqrPX*m)5$>Mo?daT{nzr$ z#j6xn65B@Sj73!mKS#KNJfFz~YUc~t2KFb*bf#th^zW0-{7o_V5AXSRj~aJF3*xsF z_kbeh=zfyCvSzj7!;hgzdg`w9D^b_bH@d~uVq z*mD(z(vxy^v|Nn{zfI^+$zS}21&$W$Luz!!*xj+63|)l9?Z+k|xr5fdb#NrY32^@H z@O`h8%`?JAkXw(9DAwUu%!L+kbUOM9KTEiaq@*K&%)-UPMHV&4x#bl`TKFmwR@zUt z;i5@hWR5E$yD`{QrrzaV|S+J$qc;;aJ86(H= zRoTtWvfc=N)zn?Se79g?wr`z&uJKJ&UW^r?)YsX#730ScBLp9ele%#be^SpzeHLfr z{<_)R|2`Fm++op0y3*il(b_kw(S&bY4qm;%cb~@zn8b@-BfU^msW^p!_KH=+pYy|Q zf-_;ARrMdgQ~an}w!8Ez%s8uXF-6p=eyuAZZ5S7j3faZC&96Ou7`T%oRJ*5?^aRZk z{nh+H{_xXYew<0`A4f#ao?tBkGV8HYbq&ST$ixfJtoq_+)GkLgxXjE|i-Q9K_|hdn zNhYZ3u?t(iyGdm%JtYCQZ)Is?cupbK@(R^5#Je=?CdT#kS+KPc!ZXC24eZ)&UF&XI zNFtxER7P*_hYSMg$=CrV_%MH43p;@V5Cu~OXn8{DASQ5wx&c0L)t_>3(1A-lqw`HO z%)3cAv5tkxEO;x_Cy<&eFE9XY?HnKK<4x6C9>lZ;NO)_4TDB^sIagUdX7)D+i5a@n z{Ui$?FaP_H=Tqe^S+ucuG*^%t$J6P6Wkp?_pH+_Gwr_x>SYzP?*wyoJKINd>dMVcK zhVODL_lK02{3qwXl%XrF(DjvR44+Wg{dE|4(!FJ*;7Ags&PU(O;L0WG6|uIX*f*z$ zD(j!~m+F0UmPgZeFVvSlK{>9^_WZh5-(23S$#FZy3C)&$=f;jO#V@=5LbB`1<+fT4 z){Ul&4}&f{v1SbSMw@F(pS~6wlhs$ekagln^V%4Yvb|65Z@L`@kD#2uoGS+l(RF5t zI$58O3JlygUqOA7Z*UyHJ4_5FeUHY?vxDEI7v;wET5tq-3Z8^>vXu|Mk>Xjq?_iO` zp-MxQIwE%QwfP{_CI?~Da5_d2rFgUdJv-OA{ay{ps=Bq79z<-()^>J{;*fUN{8KM8 z`I+(<`7%6xG>x#=fTxA584`#h#>{UiTs&{}n3zkaa>hVG-#?Y8>rCRX}t(UnIXz^Ujbi+vK& zrq8k!*(*D2)mP3&wL#EJN^E`E3_Br(-1zni!J&Dq>2%cTWV%!K}w-#o0X zQt)hx_11NG^>gC<$*;^sB&R-#1uP!niI_=b2pWXVMIcfsKQcd`B*Fy+Cz@sibCHru zo3Rg9fO^eD%sbKr8YB@*SAseE;Ze}N)ybVzbSq`l|AsMms!Q%@-k(*Wf|TX^Bt7NC z)^c=$GHwK|&+$?Zz|mgFf&(On+x9&S2GG+s!Lb$lhUdR*gsqUE_ZSP`QtSai)X&H^ z8|oUqUH>&5|1JHpE8E!f-Y)B-(jb{JLd%|cV>E)CL{)*`qfzH#zT9!x!8%Pl@sn}= zbl!xz+X?p!MR6l`1qI7$<@I6peY+b8^-F)n7PVmnHD^MBsh1iE?9FK@k{|Ei|>a6!z|vK8B<_ z!DA)-tjfOZ?BNkBaGN-fN^+scG^b60(1ZUa@vou;Ff{(tOq_OS86ZmrYN>Eu2cjk- z7DTbv=~~)7;>Vcp0jOt*0X1497L}v`$ZztDrP?D z`ZEwjR@EvSP7ul_>eDHE7@0xB5iB5{RpQr)k{?e&fu8kT!+iUkKl;N4m{I;+p9Wa>_>3gJD}y{%OFU$TH-Ebut6@~1MU zK@>+_k{*uUhvds2*7CMtDrdeoZp^86L>s3Fk3eoo=(l3~bGXednD!5PW-UmLB}lp= zoYh!_evt6oE)?re*~xq(D{BDIO1`MGt>1HfA+BzT#~88?oW>beRz;MnCO+hARM=Wm z{O)RDe=3tch+V7|30)C7MY60}7K}3b8|F;W2@7>dEh>H1^c}jlnr^EfVbvK&icRNy zTqOgYZ6gQS#Cjt71*G605u%qXAuK0%(^d-BlDo;bI>{RSB`!55+uCYX6OwVBN{u*k zZ?GhJ8YF)tGax8g5q7&jC3JT9^V}r&V+*C!74jvK+Xc6+o*-sRz3z1l$S3S)sgiM3 zVq`qY6}GlcZ^nZAeil8JXIHb~qA2di+=2>72=V_G+g&NJ-y=~c%*&DKA4E4zBp^t> z_Ud`hNd7Jx;3QRQKuHXH^+j^Q3I71wu zBw7_1|DHhTYaei(`Smk-`g`LzioJvao~^wLut1!-d|laYb1{*z3fA#8Lph`o3w`>P zWe6T1*fJ}MlC(PLlqFBUNpv5h0)YRGP*;gxc38ojRf{2jx(`*ZJ)HRj7+y44^X|KD zKdTYprBVw43$~Tr<67SrdTVNR^glrr|M=ahMuiq`v>TDpM4H-)!!Y!k1(Oe?8xwB8 z??UG{;v#s5P8kKY)$W4`(apyQ5dsSz$DIxGi2LBT_7ff9R(5}p9p5b=aXZ85CgdzGDrCxnJg@V=F8>LL9DtTiy3In z(4lP~Oi!EcmUsgaOHsS7j}>pzJ6h%Y`*WD{vJy`aHgGXnUZS=~l~6IYkbd9K0hg&Z zECRNQx5l&j;p%a2K}BMKAd`bov{SWm9Jc`ZR?G76?N?tv$xppM$!0WLtzpxMI)qDM zj`cxO>!!2Hs#;oz>8EKoy@UD#YZ{`~`xi`Es0>i&rUwITz^hX7X4ijsff<$X5eN%5 zPJ;l(#b@ zTgYB)a&d$^ER_87#eXW*)11od5(#J7ov>+$Nk3{|w0DMW6FJ6Vqa)n8W`zr5-C16W9WJ>+9mdBU z48%+Q>$HXK>S=RHe6U@C_>I3l+%ddm_+6@2_IsJmEIIqLYDdb&;bCDbe>q|;})-bD+DTPqX~!o41CRg5BOTgK~3p3F=H}Sgz3!W8G)RVYw8;v4I-n zPwJ#-8_rr(R?wHu#M_ybO>GPiW+zhWr-dP>HTV`@xwo>I9J#qB5xE300w{N4`6shWTNdxrIgs@>66CW z{jBnT>B$$XN4g#*J^vH}^FNwk%9Gg+pTl*%-)Rj@Bwg-o*R+K8&9LMb^THxph{Do-nJU=ZkofO zjJ=j_+7R`{_mcbPYQdMTL~&>5;GdZhuP00p4q`{+8s!1aE?O=(0cwf8!oj!{0vuOP8Q3pzW!vB z3Nnmd7`MxSTXX63DjQfBfo$M`igN7PD2Ynjc$ADT?5J5P?P#=`(=0Fi+^DZ@ssXW zht88O7DvVcM)h~BNgfq}S9%>LhA79F^#V}x)YrynUAumR7_DfAY+E!$vC?9<3WW^ox2hYz@I1g#Z;Kye=6BP4 zrjY4joSa`$0@$)@ErxXwe9wyEyz% z={l{-NzBw&y`!Q{Oo9rZK_Yv_hGnJ@f}6+0fA{;@P=35w4>>09br+zV&wLW;&%zf5 zXUH(1apcOV=#PVeny$%AuZb|860!mONA$T0H06|o0c1ls3p@so#x`!2Y`>Ogzr_Ab z+KS+qF6$C+V!^Y2b~7I?o6+K}HGBAN1yorW>M&p9&p-mz_Nez7bBbqtptVBaFK=Y!p`6=?{eMqil|`OE#255l<8FnOtsy4c+pZC zoD{%-W}6DYDwXesZFx|yIlMVd9*?q<3AY)#t&n@Cs7T~p=liOJDG%kNRnlLliXb%| z$BaAOb5xU;#M&i)iPP@LtzG}6Oo|unS>anyxmuJPUxFVJR>7b-7tH`aQ<%$W6531Y z!OHIzIbLVCmW2&!a3+1XtqYJReaS?<CywQ7Fl(wRoZq$tlAg+Su;l&Vry-0rEGBC zH%HiM*;Kk@XU=&z=UW$}K5g<8ib#!SrQ+aQH8ese_Lwn%%_K`xWcnAXSm#;uCCTpR zON~DV7&%ru6vfGRSF@V+O*{(ol4*<3M6`JLOG1tnUnVN7fiUa!vu%X#1w@&wD$mvh#6v4Ku2k;#gwMPdUKG=GKrOhpF)lK>ypq+N zd;`9wh9slqF(wtxnP$ryy5sfYvP|7(dD~fc2LR2qQA0SmYfmnR-MY}b)=WM4Qk17w zzK#;BM8&z&-c+gGn8vI1NujG0ylv*vD*!<#K9U>`}Zi>9F23^Nr|(*AY@D1Eu#HmVTk0Ah|(aQ(NdhKOo$6Zlt^N>rj_CBvlZH*u9N!2*+>sgWFF1!nb~c*W_3D z-h<>c{q{Y1!VJ!euZ#nZnr!W zhEr4VI;PDx0S4$?aQM&7;_6;Bjz}B>^bg?^%DmMJc!*iYN}1Xu19sEJVF8!FOW0Y7 z7wnUVy2!Wq?y{iA(U*yP5H8(1LoH&uqV(9r7LisE{Nqi-|H7LdZxr4?>YyTVyz(7>J^OgCnX* zC1r|B{=m%~l(#$T&Sn;^2H*{JAcOd(2B*Wt%X7>9{#uIP%(#NY=Rmvc0f*lhf(BUl zjgz)7+0Y=s(-XvRDKR(4W6ditL4@c7ZI=1i{u%I2S6!PK4A(po*s`q0l}wv zTLaONTi2};kZS!7*dt!^h$hzIn3xv;X|WN_fVT{Zg|j}wFU8)6@|Ue07kV)L*bHtM zIelgQubdpKlfjpqN#=e+Wjq@zTMS;Z#~6(0s^*1#<;%Z}8VH%!YJ&oso30nES? z2SgB;oh-U#z9c(0G2#s80SZ!V5WF!{5w1a3P~rLbKo7lzWTut^tuIc~@@V$%Yta#R zzl_~K_BCIQF4#^%>n3*IU%0Mw<%?z_KA`U>--Mt#A$xAc_dkU1hkp~kJIXgr!MsKz zI@%o3UuD~_S`#&Fe{azL3wHb?pYkTUq0Sv-@+>4&k}>#m_GEz~%_>kw2XkztI}HXj z2S$}$RITDR3yI}cA5TneK#YJ_N@1eJSYtNb;!t%qs zv*@C+SV<$dsnvJ$201scqrKYXM5$vvKGt5p0jp>eBB_XTJ!`IzC=E6$eF+ z$^aatZv94vU}x6!dXch$NaL1n==0s31oD}aMIL@LV^cw`+~=Wwi4Vq%bxi_Pa$?{m{R=Y(e=gKa6ax=!f&P1;x?XyqeU0Z$FT z89m;!pq!3aCH$l`67ODE(0_jD@*Uo%9g;>4^11gSquIpV3u|mPqBCTq$NyNB``;h? z5YwC48-H~caP$;yGHlm`gq7*G;|j9-9YVX?Dw>HFh&vCFbXvpLr6WTL!I$q%19JGO z?%Mn>Bp|G}vDP(#Ifr#U0~bfvRK|zC4L>zjyBW(b`thl_zCi$p(D%wBX^GEp0^jD& zsg=tTm0rucK!VG(mu^7(hMx%fm^+n5MERYcDG(l0QBitt0cPCw2#XQ)v#gyQIT&zd zibl=MB;1tG-CV>GRy-xH%KWADG%0-Wz$x?u;m}J-@{e@Fe-~y~-`bvgjZtGb?z9|t zA;j>$evr{OOy3X)79F(F1e>J~-`g8quzTf2f-uB|d2IKgz zs-KtEpNsh&4(Xv+u*`r47|Zu#=hyJ84j%UB)}-1 zC!cOPpIsk0!{C3+?71QgzaAWlYL{54G_zRTy-S(>zH>F^-H~&8gJT^a_@uc!RyYO>ly&c#vqHP^ zKdbGYCI`d`m_xtvXk@OHzo_5hP8L%>5hw`bzH;u@aO9t@_CMDR{N$_se3qeYcm4&h z%rb!9O#nr6j~fLM0(ynv$W!NfT?!M|517Vv6C}A8 z*1Z4j0!)AfkOHYkf9tjOFWEcronPdDmODY{fnKDNEZ#=Na3pfJiUHlKk+dsg_|Lw+BY65!y8Q$)Vf3tW0^I&?W>6ueNH1g~2 z{;W`V3zWS9AkO$4)_x4gcb&T5*oixAeLeL5nsNDGTJ56!aWMvVX>Rrx`wVHmo&Dv2 z7pTn#kSz`m%H#}GtJl_~oo35k`LV>%nnUD&TlL=`1qBUyrtUS4KDl47=1a)2_*371 z?!(rL?dJcFy*Cepy6yhQQInFWkR;39Vh!2Lu0o47%h)N#zJ;-mwNer)vSo>~8zS4- zlgKX1*eUxAhAe|I{H{^={XF$N_g$aoukRnf{|x56T-UkId7amJo$I2QLT?w16oW!H zI~4M^PDWn|w&^sU}|@<*eLsNPGGvg9~@zEtducOdwT9VVF8 zBron&lR(~sm#y9A1!wshIrV-7O$oTq691SM{L8QcUK|6Nhw8zuAK%G|>;U~a-3|;o zd$!HvU3aV?NnAWvA}Hp$X;)Hpd!s-Hgjk2TUsOE)9Mzht!{C*w)GgTl{LSvG)sUZ3 z&oaLg|MVpL&H|?irxccf7k#(+AOjD*s&K%MZ~vdZ=-Z_x-U5$ukx!ZM)0NNUI}jDI ztKGbriFX1hAafZU!R4!=Xi0ox?P_x@`%$K$+I-*k=y%jv|tF zO}zcbNBCR!XlinReh4ag`{!AqOq6^0rCvE;S;oVdqCSMhTuEH^QwaS_HGF`(lGBGj z-}s*fe9nYCg<&mE*89h^xxW_4wR4&je|mjgw=*b2w-pmLKS8Ij*K?gASNpKs&dnt6 zIg}(@D|)*9<3Dm;e?EfT!ckz23nr$HFl|EUbHk0yw`E(W zZf-1WUHId-X0@@B;+_lcmXwe>=N-gB z+mwSB@&4mtux*hz!nN@9TqK=A+3clzRQ-S!zPlRxV{G`Fw+Dw%o>ITS%UsC!gRuZt zu|%O(wHUTqy2`_ASQ?S=skq2{ZlrRMioWmJHREN0s-;Z(p|=-@wg96Al&~ga7~*o) z!TAsD>%N3y`?W9Q_+WUuDM@0^MSv{L5Q|$E7d9chj+5i_c&2XXG zc~!r>1pi2EB3eUm&{urLoiOJYqypF45)+Jy`AwKwzz%MCZtM;BkFPCvI{@$`1%o); zw2qF%s|r!6oMJwG)H|>lPJ~C$#A`O{<3O;t`7uu1!L-Om>kR|1&cKfE@x&>yoPXRO z?q8$-i5I|Wuh$trPdax1j8`X~b(Rk}*kY2>Eu6$2fOC$!%DGqn_4R-HDL@25l~(pV z>Zb+t+aD+BKycV&H$`0>_%{Wp8V-s^3{FU;(_NT;Dku!3+%^(y@Y z{QE$$CxD2QP8VYH(^wOf)$QK`nH&w(;ed#sQjvd@=>NTk ziSQ=%l1ifkPygmh_<61yIyLYaGUf0t1&r( zueX;(Kd1X2k12aVMIYAo{qAx)dk6UWBBNUSe>#Kk*K^cJ8pUZYfye*n;s3TECytS9 zx;bj>!tR`}J(O#W6U|kBIoBL%^6Ki5Z2cmP%tbf1zl4w{ttep4&x?d$O((6=TQ`2g zpG_poeLiUy|9#Ok=F4@ZspJBj0?s!VZ*A%74qTn%KL!ijDp(*7t3I0=77k+idcxHnSvi zS*5-DW;#=s2e~aUK2_VBwjf65SMF>R)~4beKfGP~+ALr&KTu@9xiah4%vbV0r+KC& z+QiqoK7^q#U=QVhaJ$sA&Q=#~Vwb99E6Q!&rraG@9L(n1cb}FmF*{5^l4k0$_=r=I zce-kVP06sEft!eRu6|KL11pfA!iVAL{;iGW!q-M8e(hwwJ_6ar#^iNpRxlTGtc+G8 z6=mEtM)I?r&hL*Xl_ukP^C~`VuaDDWd$Oas$z+6IgQ0g2h9%2x&ZOG+<==|6<`|lK zPp3v**k9r>EMeHT_gl=SERgF=QOE`KOqg636L&DHSH{6Jqg9CV$1IX!pVL+`J%u27 z?90;5w(HH&JAj=izW10}TjjT2a0eK8l}jLvSJSiu{)6{$%6u#;Mbb+{jF2=`u_mnXiojrZXga&KB$$e-{Vj| zUwmG3K1R?i#~nAahBooLXP@Zw?m%y<60N49HY`p1*e1C7eXs@7(nrpu7}kF6NESTX z&bpGDsM4Qf7NG_KXIM_P*=>BC2~TK^V&#m&AF}Rc3HW$=Uuw0jlrgDt<0PK@C$E$y(P!Q0g`Wg*!*6e<>ar(ELzpwKg3Umu(8adekD+` z7zm&JRIj~37~OGb(!n)L;wr`qy26>o+dhP|IYt{gUpsy#-ELLi7n@t6-w?)P-~61< zZNzhWLuI%yFns}q<+trGc+JgzT_M(V2dq@@t)+%}Fi-BassruixGHQfwjCJ6JjKeW zH%{7{FkezPIqFNu#U5V-j;P#pi-twwZbNDq8Q4dvn3C0_#&u+AGR7?I4VaVuPp@x` zLj}J{*yR%GIvNEx@kLghDGPn8xJ6KBQ%;!iS=vz~)ZB(6nMJH33@WWUPnA!_*(&d( z^=4`FQp?CpL|lF+kwwV!BR2Ph`*L)=ReaOAU#!Y*;O$3a#Huy{lbyGf5@U(b>Day= z&ClZYbr1#5M(hyrYc0a<@4I_KWyHDaw(@V)-OT9<+;`~2-Gq&!JxjH#beyWxCGD7t zcMsL8%m{xNb|0M&%tcTtKF$#K+un*%4dqO5eLC!ot8~DoC;1MnlyOwO1skZ7=s1abc{ZlhGy%ihCvI-*#P^u0-_SWadorDHwL`Qu#bBW1o72 z!qriRFjtuMG{s8fRid;vD(nS@EIFn9(TR(*L9{aYQg+3L}7T!XHp&wSzV7q zR~qxxVcqUr`_XCtuH*B(F*fAVeu#EG z-myv$LVTrNwf^OxW<>P$RK>@)vyKTE4?()Qug-%Rpb6?pjf&JW4)B!m7eJ{7+)RJr zeEs;9*YGUwp*s!ze%s4zq#za#W$t`B;0)2W2F*sYqY$i;@Cc7-?w#=uYXi1<3h~Ze zh|QHZN^FR3`UkH}FMs!Eo_b>+m>u7W*$=$m8Z^k!6|@~J&L!a6fC9~Zs2!`f6NdS` zbEv0qDzzTnxmLAaY}1`_i@3S^2=w=}6s7tjSfD-{mZ(9;>S34#x*AR8s?%v1T-Yun zSzkR&)dGw@e14mND*;w6U3F1BPtKm9NYtjAOMHIkq+8#uTOIh0mzRmRo0~4)ef(a< z(0jh9&5(Qta7EhP5A%&Hy%q|UUSE$I(y89w%=UinxUw8)5VMu8nXbCvm67|$<@;Bl zuVdQgHfD^M&=)p0RJwdO>S$%{EE7E~W-<~8?fMv>*o=(R0Thjh^lR0R+jd^Kv>lM# zccr9fdlQdy1d%zUKl*tza9oh_?ACSXrFzCeShUeG(AUEUQgJYZS_i_Cp>kQ7LO=GBBQhdmZ;>g zy}9w+b?MhZ{Fu)vYzwhQ-Kx9)*{9rX{+;!A>ur2Qp`E4m1|)!kmuaOZwpp||1z|2w zjl+dP2H9NR72ET~`I6D0ift=nT38TtcBrf{-lWjtaXh*(udBq-q~zSDl09z*)E+Zx z0y_`w^<<;p84IHQu*IC$&d_ulnL<58bpc%=LC$r#JR~p?h!_{rR+U)Dea@rW&#)nt z6Bk*&=pYS2uZt2IfQgY-4k{!%BsB5z+xeNdGU6YvgSJtY1>rEzS3u96o5Jp%bjy(C znCNDBNFbIDF=g}V)k6bWm#LP;I26Qs>mzdc;libcbPY(iphoRDM&ylMzYw(E(mK2s zE!v&2ALUTsvD_+d^w#{>{s=WUaJ@fz|oiS3s+UhY%N|c@yqR4 z)#AKn;+kjZJ`8to1JDy$SkC}8{5&Lj;)%p!jZ5Jn~%Rou-clCArCn7nV=<eH)hw|Y0Pz>8WavmE;i z>LJaDjmcn+ylwD+yW;_PsHiFCKI_D83{Q2Rk*+@jg(k zy~l=Fd1BJ?{n0#c$|W?eIA#jPVMl9$0qNmJD(hT2uL|&N;%~}hvr<$14vOXl2c~MT z3h@gsA;*30CqCnoYRPRTlTVxZ(T_d6KvGL-W z0psTGmumgB@GZ=RjP?r!UGOr(LU~>LbubkNCumRtEFj1uU(AKpx=+(M-0V75^F_xY>4LoLbZW8Rq)|K8?*3RL{d`siBMc zv|_@;MgXSGv20J|9)Oyb;!Z*V7SEA3?Yu>9p&^m1yv&t=o z304ykp-YO7Bm~w|%pMA0&OOa2ypP#XB||whRj`@RtK)90yZ}+kwnP^nOtFsWoSt!> zY%0?;X~-M@$DKL9!JyqrFgeTVjQc(#2sh(6K_HY~I1r zz$eukQ}H5_GxhFA7aM$=xYJ9o!H=sq1XyV}RZ?q#1cB9vLDh2QdjY6CstY@^sXVK6+KKn+&J{^3R>Vr@0?OK(!ANOT)p>RCS^|3t z%O8=eRpuX`I#e#?Ua1ey7?adHlSNnE;x5toW@}?QX(@ZSLgI=Da3}Q=I~VowMky}g z4#}NIB3n%+yVPWhNcl7DYxTs_8$(xV^nE)?rzTX^7>G{!o%BO>R2qgnaaFr{?e*=n zn}S!ATLih8M5)GwV9pOOJ%~I~cJ}cNo2zt3G#|X|c1?Z6)u|GB%E%S^aTNbqZwKCM zY@1Yx@1K5wz${l7mshT#q_BFpqBVV6ci(nL;x)(Kv;CY$`XC#-3D`f2`JC6XQX*}xqdc3!wf z-ei|#rNK!JtA^@Tdch}dYv7YK4xah+Ji{LWPj)(6I=oOA@7p5SP%59=vX$hwT}B)# zfZcHP0f1&cKkcm9xOUA??(e^RRF?w@uK>G;&Tgq{@x}*r$m>jC7=6k~GSW)N7$>5x z7H^*9C3xO1bD53@OCd~Qv(CpikwWnWGW#3VfQyEx2fjVdTeav$-u_&V{C zgO|6t?NoLK^~|=!R^99Xt6xG>);7qDHkJB6*U?Cx3oa%C-<$6Wm)fbkJDtsyJl(okZ7NphAFudR7j)7Skf2&o?jY`Oro_d_vKa!r|0 z!|Ygb3t7G(!93*Jc5yo9jafsShu$P%ZQgcI-}@y%-@H;#2rwbwapFxmUZ3wV#sOyF zeYbZ%EV@3_>^pI_-@I7v9u>nW=8U;99t}JT6%Eu4UJ2~i0tLrfpj>BfM{AfOPJw3F zv0PL0sQe8hu@yC0Vs{(enwmp#t;r~eFSCg5Vi5DiM=TFMh9-d4>&M)PSy?Ul91Sq6LzetufcmJ)$I@G9jWukc(}ju*3^lg1w4d|~>oV1(DyWYqysRX!kF7Q1T1BGa>UsTO}rp>~-+-mh;Z z|IVXhd_vWPVVs?TEA>4%t2%RC6Nzyn+X95w(GfRjc-Spi3R-X&P@LX7Io2n{?FU4} znQ^}wcz?NV&LC5IUhXHvuGih-hZNDA z#xG)Pr13*^C!^*Id)c;FTN0%Ql;SqC{vmd)z8NBdBf< zt(0j=rFOPX?Z*$f<#2582WbT!qXd03oe%r}rY!a^m(Na1suWx{%-YprHtLPFN#aDB z0yCZ0KL^K8CwjMu*bj6YsOw33uVP>%M#S-j(98RZkC?tm9+tg@TWO~tNnlqcUuMwaG&z}p%WrkD^ETD#ttSQrXoQ<+g(2u6wE+9 zFJMq?_l6A|W+ne=vN^h>c6i%!XtCci$sTu_UJkh-;l5z!gla$I z+NCYMyc}$Cu&GywU$azpu#93Us(uUHTdXda)?K?F570oLJM1)c3|0N&&IhKE1`qO; zIPO^cMV>>{fFdJ zwXHET(#jIW4Y`^)%-szD;s^X4>@`6&v`<$?U%NDjR6UHG$)rGDt~nhpb-Vjz3g7|V zb}V2pCP)#MRhfJZ=u_W5vS#yL>$h~%ZTJL8t2rtCB#Mu0-gAO^UG*RW&g;N%+^g~| zmlgnNoSM#PWki*MmUgP}mWES6bE# zfQqABiBJYjfp5J1!~Ju8Z|a5s?KMTBR03`^HA;X4gt>1bFn(*bB|#D`wLTWKtx?9R zV;U3qpnG4giM$fOuH*7l>r}6SW0Cy4M(x!y2md?@h(IQIxfjmlj#YZcznR3CKSK;a z)UyLc*~2n0ICOE*eH38{eCikbm|xSZ_C*XnQB`XO8+w&f_JIn+{KV9;O(M<@H!R$p zrQQ6v7)Rxxqla?MMBsPEX_4V*4S|09#k@M%Ex>;=!wx}{-%|9genLDSM1nmSPr&s< zdBz*UOYoN+P!fms-Z+In4+7y1O5Co`G>Mh|`C}vb?b`IiTxO_5FIP}(VS*%NpMGt; zeTsRQabdK2lsQ+Go~jC3Qx--G%cMO9^GFhROcd8|$eMFC%-nOoH#3@-Y7V_kTZoo% zehnb$TKSla3E-p@)yO*D47|EVM{w?1L8epBB#P-yybR04%)RtBLga0Y@`+bVJVmL} zIlLI@3DCxWph|{nAb6q3w{=^;CZg#%NDwcrQo#UNM8F;`Ir%TcpHm~iJNqQeC>rZEPtWr%qjqr?o`V$aU z-WfeSnZCvL)`lA@u+?bEa%GKD6`G^lxli1-DpuGM)nDn(Xt)C)x%c?HeNTt?^?X() z66EE#sK}s{WYD)pUQ`{LYSOUh=k#tXmBWUm7Qm*gqpOL>&pgT}+q|-H>*+A-j?9{4 zxNY=^W7*`f)Z}&3!Kkx4akj_<(yAP)FRa3ai#T=j77jR8ZCH4RZlF_(4e!)5h|wXO zj3kUryk=j=m?HS{ystxEQKAd2yEr7PC1rx(Jd4K7Rz4lx085I^m?YfTQdm}v6*@cE zb$IK39F!b~IhjS~iKpj+28Y>8AGoGBml}d`p9CbBTTsQX3##|7CpWf-bCo;^0b3vQ zYaL2|vm3i1r#mVEc)3!2Y0SXMx=jqkqo z$CFc*@HtJ(eK0**3%EP{%5dE)Z*Nyz8t3s$Loo1S<0zplbT+iz!es}9+qpaA0}ME$ z!85cBeCc8I7a3SwyuSFK0}M@9jOvM7&DjVq=SyYIlXHmNf|6;i$UjV2ShiTM2R3IE zDguORKfNE`=rkHM=A)5h+nZxuEr3F=x#vHKOIrFhFuHZDgRVh4gmLO|^8B|!OY|>r zQcaQ>uKH?=rJqQvIUS|M>iNE6dlT!X8=#tNP$CAreI+r6IUabqG>=^Q^(U^RAp)&env*hsV|zGBD~{!ft2z~=^3{Mi_#W6*zCMt z%500p__Yv`4X+BBi@k zyZUkO3rg%qY`fRdj)*@lx7=x9u}tbtUF8F)&tjq-3)lQDdAldKx`+U8y2CesL={v6 zxFO}8ZDK3PzU*D1JhuVX#s^ME#jd<*U@h#QJy^Haq4*t5aa|yX*YFyl-rCp9^%t6* zxS6iXBI(dy02AyIvc3{41XHf`TCEuKgoWY&|G**or1)KtScju=#CuiV9}pT^-c`Wv@cQIm{LI0Dt`$-Vp=1z< z=3b6Q*^!dsh-wN-XEMb0^@3fJ>9@p0yJK*;QGlFL&P1eIr_t-%Fa9!(-~PyT6(~s# z-b{l3+HwBZl{^toQbQ=VhWz#&e|$!&o1pn3u-kI@zh3>{%m6sk|Mo8%9AG4?Gzw4s z64Cz)+yC270fB&%tzuu*+ii#Z@3;7GxBc^Xa#f_05*)j8_WvKI?cYWqnH{7V0#^~e z|FfWpq^DvQ<-PQ8FZF+Tl>dMF{~wzE2muOEDr}ElU5DN*veDKAW#1jH``^=%BsDo@xG08`f5z~*P? zyZ)Kd(U4~kpvpu+dM9GyM=Y8J|Hh+zrjq3O0p7N60mQadK|w)F7>ndnWDw;0VrPd3 ze4T8L2Sn8%m8LN+9`qT@py^uC!tqpsbpV)NFZc-Bqy( zvPnCdixY>J0a}hBDbASN{~V=PO38o=NHeMSP4ieX@m-Kv>oc?X^8R62Rz>mxw{wrx zvEOjtAQ4axNO@_-^iAO^cYi-ukX&IB0BbtXAu+&}x$A`3#VMX~s5Cu?Z9h02b{@ny5ozx=5m2oc0;M-$KO)wo6{t|2 z>OZEK{aPN%COa~t@Llv4uyrIuzbMOdv6fN=@Og7o_=REHr1ht5MU24mBnH)te!A~} z|G4-FbQE4Zx@#jsH%$S(O`_w3qmA5RHPhQ?J~+E#7$*#DuXihQ0#W zBfV0mm(~MsGcy667keN+>eB^wE`J|-^5Q2Eo9@OJ&Q086gLB(NLMw?|nMQ2RrmFz5 ztp#X#R6s4Ykk2IL$^n=0$Kr9oB2U07phi@^CgpCq0Mmppi8~k;Z>-*Gi4mLvf~f*~ zAoB}v0rqDHv^nwiogc(rxRa7=2`Xb2HU8uy6M(&m0d685zzFa0e(L;dB9asw5cSs0Kz@u2ss3WNRyay|hpNnjIuD=c`ZGK;#{-B(l z?w4!_DVngSl0?`U!}4ak)AMd`6>-f#5(QLEXiWhWSx+!6{ zD}C@5(dFF%PLDc!-!>aI!?yUw?_z$vn&cv%CIu+6+e4j2{*&D%*<14^i4jVHMRoP3}dYemNmPH#G&1yvyb z3I?L6B!*%(s&*|iJyEDhG0&@xVy37DOhKGeKI-68Xa@q2)hz&iw>;-Fs93czeRuc3 z6kwQ20@n2O^!hP61|(V2G)b;fz$bp^%av49zl)*F;{1amGT&T)y6)b!h2eNDO}g3h z(OUOfLVwFcP~HQem9pN!f<(p#v@m9X+~Fcnu~pn>b!ibyyY`GD4Ji3WN`b<-pLvaz zRGR+`4n#l$xuxS$`2wn$*=-LA3uO;G*M*l)-A*bfWF3 zxZ{W&Q2#I$N)#-9h&WI2f54VtXpaY^2$~)L*0w3PZ$DS5k>1EOsHW|sK5ojP9CNsz zr!`(VQzthrfe6$t4-8w_&W{2HxN+l?^evz+|4_sWs%ttX00?QODjt&D8r|6zAI^Ry z^IL26FN>x*0f;;{k@C1hqzqfgz!Ye3EPxPu<5MG0tHuDDC-&&s>sOF@KwvcT7GMpp zx(&hew!P)QNaW&4Q?V3@`23w5OW3Z^G}T4rj! z3dpp(D!uV1r_J{L-ssf`EZ&eX*g5_kfL^G9M1CHQ@+EGT$>Ssk-~1NXxvT=zWH#R0UIKhq&H^hOK|HgVfT!5L4?K=qgFOKN1Nd-Yk9!x9e{(8IJMMD`@1IDKX)asPM zeksa8LfA4^8`KKQ7cD>@9ZEq&mQ9_|tXLO_ZX}7D;dN0_(AdeM1yWQdsnr2v!D+z) zh|09tPp=;Vulz7!4aBLqkIy(8TGEeze<`;wDVW61b~UW;VZ6*}dgfa`>dCd(sl?`4 z1j@TT7L!d;7U1h?%CSOIo7g<@1#W>6yu@H9TgvBo&ar$$7UM1oYoFay^jA5dre; zu-=M!aI&qx4E4i1yJNcn8Z~ZHDU(<;m5)!)wx)*5*mQqvHtGUJNZ9o#9)_+&TG_3R znJDxmsA`FjxQ)msNtP^BoNmRAU3f6JnB_a;|P<4Ylis9p;CC6*RaQqcZDYjLYJ$Ghd)1fa0Kkf z7YxJ;MbP%NZ{=~(I$*i)o%0pT_95C zQSGdDFvs2}-+{}P(!kAK&SGZy9Z);NMg$_9kS_dtVdmcDYroAhgP1r-zefm4G4$Y>_vI-@=| z|C0Q-0!xJKE;+@^lPH^Q#`oA@N4dqCSzuX3werffedtCyX|@u)VR}N zC?#~2{+mk?(l7-pfT<^EJWR5!*b+D-rXKzizpk{9tYYIdAab@Z6dwC_siQ|rf#{J0 zFZIkCmfzbMfbsMgJoe$R5E2 z$SBJXzR3)~Wo{d<|y@l&m8~bE)fkRHc#+;&;K|B=J&z#$G%~+g8YvI$V%D-p`k|W z_nh{BKL7u30RJx;fE+nhB)`!BJzvw=H)QVQ*?;j{mmoBtB&i9+#vRt7R{(iHRVV1# z_(yO|Qxq@bURvyy_819C08rMj=N};n`(?<3V=WylmkWWb3kXt321S&qw_U1_JVhNx zh9db4#M+OC$!uByEtAzU=;VxhNXJP+RMY*ajBf|c1O#161~pHMrbzC!9zfv*S$B;) zWG{$Ld;(xKWgxuKMu5^!0y)S z{tb-&09BRmP=9m%Tvr3+gZ9-M5lJ~NiA*9@(g5)~-WEUe9$@HLAPZ`WJg4``U;?P= zzuss1e#L-p2oQ(gA|W&04kw{Wz$*y>&Zh*u1vz-^F28=Z4xh`!6QwxN{}~53+q-Sg ztWSepGSkQ-`moRM?g_SP<|A7`t1g2uU;hzOBWdmpwLb1Sh1x=%0&CDo6f0<^cx&`k z%o^5LYcSpPvn#`ndk2gAY?frKm$SsevrOLV=~d>pAtV)Ww&mh{@$?<$8M#_ijnk;dTbWl$E{Qnz5)^)cKBXTq^mDeA@RB%Lcz zoTM2>8!Q45;Mc(EXdq4ijiI+mO~c}`?%x9)kmnj<8IrTY?3-3j>g=OAX~mI>}Gw^w3fJyA<0+_6m=-zt$)t@C;VD(Gu}PaI}%4 z8bLtblIQ=5?}7g-6HkEbv4su|NLHxBP1k|NIFo z@v-PgRm!eog}J-adGC^3cyyAaJF;x9pqW%su!{%TAalt)RRO6-db}<5A)qkcEOV*z zJ!x5f*vnk}g_NV84!dy5?kOp+PzFqPWlrHsVTDjF#gAIik29WjehWqunf}Ohz(6&2 z!LMi|sc|TW=ln(zHqT?0s0MP3D^Zok4a&;S??LU_Hdyqp(RW< z;H0@ts(dQ+zPjq8N|YY=2W>O4|3?ciE5KGJipfuuxUFuRExSjZsEc`(dMMX=#J8&0}}BJ>q2iua~ZemJcQ zJ(aX7t-N-@Z531gY;Nf!t|FEAWo??&HOMTL2cH!mTwfZmq*0XUoutp3uwZK-5Yylo zyoC_@smHwX>Q-v{+suRkzgV4{7ipF3gCu&=y?hjVK6?BU(5pER5ioLXF^rTqL_DGJ znj_}u6}FI?JFY)JXT2x2im)J=m%Ev|nczNR9jQG6IXw?H$T=-y!!qQ1SOol#7^&=G@yR+|so*q|H0##m7aGK`$`I>!atXZf&LDQ~4 z2a4@&Nh!F%;$@~|QBQPO_OW_S91)Ml%K(BS{+=IjTT>vfESPtdXBHoJeb(v00MX!z zc~{+Pe3tqf-~w`S(B>6Q;_*9+v=z|SM$2Gj&lF`q^NiB~%{C&6y87hQh^QA(uaam) z@zY9SA|yTj6rlZ|iiabon{;x%R6E${qWz>>KSTjX5(gYGk5JH?ftRbKHfamVUS~`n zer?b(p0s&@_IM?4gl24Ux=Q@i<7L{Dep^e%5oUc-{Ea}uD!X>9d0q@W3kc{?H?C=Lc|ESGL*4zmuA0x=b$ z_{6YDW&+Rl8WG6Fiu;R63Zr&CybjPNiIF%5kSUZKajdHq;LY^6!If4QB#p+h0=*~2 z+~#zbn|Sl~Ld|4B1N-pSteRgzv+dmhd=J!w2_b%ykLpUVv=iV$DtJhVpt0`;Z27y? zi(U$eyBC;b)DPN*D}%eMx)E=9cQ(=0c85zPJg&e*-3e$}wTf}$fJyw2OL*rS+(~$Q zHIQcsTXiJIc=wa2cPl?q!~e=$f7gg5I?rTO@_tcHRT7v4cF1EZ86s?b@$*!qrs`6lpoq${h*d|8)MCw^exY7&-477^Qi&D4yrJ%F6#auQ7-#Gu zU6}H|4dlLxwdw^E(k+(y3LC|Zaa`G>z^n0Zr9GvSp*W{n0>OmC&SNlpztc~;u zaT`Pp+`2 z@AGUO$L&1|AL1qR>dw$`BM`$*==c`}ju{l$Oq$$7ctv8+?$A-;d4nCn92ihaIf7kJ zp4neSbcpbI1DW(7u2M3ol&MGs<1;RlRKBEv(`a}#1)Z`lF$tX445oxfYE!cKUF$vF zxoir0&gR|9IUUR3O_$`??R^)td;o6r3UmG>m`Srw0=oARfd(6+8N6@Dk*PHskvEnSu6%=_&1di&#J{{vo|(+i*GMBv{E^M=jUlC2|o) zBiAQ~($p2@AV=qcihEx=@61G2HnKo|UzVEuex9Z(%=Lm*)Z|ckU`8a&8OR01BCf`| zX?N?uPH2<)OF+2H5({9xK((RdL3BljVnxi4I@*XeCw61M2G-z05aFwCEP zg^j-Nd#RnBZm+9-*E3yut_rZj9y{5)i=wU~}90{z*=<02~P4O5L_9^PLHaN!Y261>N-h*+R;-^Nd4 z&4xjGRf%bnj(b=3-7N#l*V(rY8l)QBa=P8POwllF=oAckvyx5IZ5jaaJ{!`m3gt4x zei)BWcV2rd&y%*>7(UqD{U*iVoMD4oa%&d|ts}kFVH)nWb#7^UcRHUP9&&D4&$Wkg z%#muI3zqg~x*F=a6ca|K7*ZP(4qAi*YCyZ{XH*xolS_m`L3ex^!Fa}6r8fO+nYVQ- z0qpEB+xk3X+_*N6%0gRX@ohmc)(4nJnQdGZRzfC7VIF;>pFnCs@mB2=vWX51iPAQZ zFK9|(Jx8_Gi^l;OLm}a6L$I#^m%kBQ2C({5PjB!)yx11BdIjIALttxy*{YmEU79Kr(`&ZH z_cvr-d2M&%l@oHcBkvfGZU7*Hb`vWb3p_X{(<6G7d}=cc&(^5veC&7KN<*!oGk7SrdCw5P@*-IFAAE-|V-kRoQ$~=9y zIwLijH|u+Q(%)2O*CD8F(aPUfhM0FuEw2$X8uBXc`A#W z5m8Mb8MEBe@fK|1Ply9a+sMAXKyuN#FN}*_UO`?=Lm^g$Ve7nQ+ev2j35uN_PdV~C zR0Hbl)Zf&j0m_Fvid5t=O;$*39?}z(=f1{G1x~`HAE6X^FX$`a;u@r56>$p^3&>#O z`f3_3595HZR3qqBU3qupv$()_#8?yqHIBj?&h}a8B(Izv+G*JGoMCAPiR3qU9Qc~F zAChFL0tC)O$PEe93aW=NIS!Qy3M~r7+VAhh$?#q-EeOgcq9`@WVd+F?phEFtIlwGx zGjVKbfzv#N9pJiicp2ig{SC4p{~-zlUxTt{f-BT!CyEa7I6)0R+ONh03~7 zz$lfDcIW15BqbtGWQ)|C1(Y)0_6KybO#Pz&FvP#al%|Xu$WK!p)v3t}NemsE(ya8# z+$&sX`=Uym2eUsl*%q1-<~_`by`a_WFkFcBPV@F09z`0Ked1^JvALVAiIZ?(IFrNf znXAcilYu>lU<>$&fv`nlY8YkHeI(o$Xn;V^N5M_rJ(UFe&jx2c%R_La-9g&&h%Y%$ zXDqbK?hI{#4!L~t-nfkAFp6y>5dNZ;lMSNVjG8u)DTvpqM{TmhtckuI_(q7oJGQr# z@7OYO^z`P4tx4m}1Pa68Tkdu?f7oY-K^cf{8jBSny_|Fj!LH{@N9EjZkFmk2b@EHw zZt+Mc?xSTr8H3%CO+$sJ`c}_wPPVauO>JnnFs^{|k|Hr9gYq|Qp=4FnmzQ@+xCrVz z(`-G->AD$KJ2R4boJoxu33c6y?o|ZPZsRzu8Bwtoj#NWhUAVxH8JK;3po$QLBX)t+ zMTlOd#mdMx+?5E$>c7TCP|PS1Ul^GTW~C520R@y4yiIA-*L4ry+pa+-pM|~E zI8&3n(VAm#syR=#b@3 zdEkY}$7*NoK&RUG$6>GDOMh;jLTFfmK`O@-3Og$^`|6pUqLnyb0uIHxDI8voV&KYZ zsh|o$>J_Fj+ZXDW(*pOOWPkom;2~}L?(~`oQ;jJ&th7h^hLfadUcn(of$iq@g?ut^ zM)SEqkDyh~@Rjh0_I%T86xc6Z$k#Oq^TyiDih zDlCzGFa*nquBM0zQs_s~RVu-ovz7ySZEoae6h4r!EtRw0aYY}HBdCyP)~5J-L0)BB z#rH7DD-etWW+i0k56@Fz7-kj<0tw;a9u1YvQ}(&g$8q2GL#gp9Bh-wFj4fgoKW3WQB;IzaME7ZA_Pa7J=O*e6) zZqW)-y3vFjGgHt^I^uWd-OFRbzDUC%}Pq0Z1Hb38$ zT$yM1Q@~cJcz3|^vFGse%YWM3m$kuAdRT`(Ips_H-Q!|Oeufel?epHccjT>Y!kc}u zNw~(-;VUwUK;$P#F4g3AJZ~NGrL{ri8&&f4Mh&DCf=-`XYBF6Yh@nhRA1^F8p=87i zSJBQ-IEd`pq$kUQ(YvPCSVAgz>hc1gi~V_`+Q-( zlPWOb=(7l<`K zBBti_LLwm}gRg0bRj2!pQ|J6ghUu`S2g?~((XYL4gANBeiCf(rtxh2c?Pl4{Nqt(xx8_${w5*|lh!S*Vn4`9 z>}xUZ8p)oN93l^9g;_f#FxzGPND(rBX~8{g4caZ3xty?;v<6R*fZq;#lbmhP4pbO( z$rK-n3e)=`3)!dJNMeDc&DZH6A1~>BNNr2VN(EGa@nXeu1duIkX*W<$Td)z@8IiY{ zW~4H|B!-QpmAM<4t(*~-t_O^rmxJqsH*ldue%E^Gv+odYILy}JxvLXtlMUY8_)j>D zENz4BvmyBQr>@Hw&_aa|2xBwKeiyQ{1=a!Qa?KvvzJ7kx%qaqL+(=C@`Tt|@EyJSh z+qPdt5JWnZ?h*tfL>d$jK|)$uq$Q-gK}teEx&)N&?uMa}7LXiZ=#g$_{YS6oe&6SQ z@9SF6TA$X3_04U=Z5U?G^ZXyje(d}2_`Av7Z}kufiuI@aBTTd4`69EfVL{fbeGZ#` zGoQ;IliYxn-+bp4-!#Ea?&0i<+qP$rKBiQ1{et=-X7oXP%1j|m%9}5#-mZT+W>~OL z`rc5fw-?769ITq8++D{iknjz}jB`xg`6&!kqPjDX#Wtw;%!a3WvkCKX;$>od?8Hwr z;s)d8riNAvG+FdpQp5?xbRTmqyjW-PCa6RbbL?S5N-+`;B-P>eceXEk;4rQxZH!%= zMZ30e<7P~qg-((ae{VMExNT8zweFX!(nM{4V%2Nptj>&eU#`*Z=-t%MgcG2Q%vx@7 z5z;+J5*-%J0MVgq0xF8W5Gk-m^8RyL=+}qv^Px)1>CwAi-h=&6snTPYe0vcWNVq0o zn^DR*lG$J@t$8%Hhk7T+h6s_YQ@*FpH4G}>Xjq^!ID+viP`+1n_{peZ2aOtV<9!c& zhud`5p2US63lAhHjW5$5zyIw0t0t*HgY@`l)HZ7fds8(NM;JI{Ci>G9gRsfhOooql zz2Y~1CaU~uyf-i?iqpWvcuqO#Di6T^jyGhEE}^&sk%9}x{%k(DN;{ZB;37!F0^uB6 zsF+4qvi_KZ*Qjm&8+Xri9pt!!a(wL8c(3n2X0cF#!4Ws~*Kb$I7je`>X=jnRx!CCn z(`4jXbPNT$1s);NU?Ip!B1?LwBb3UyL@bw$g=r*O;|Pp*CN0{5;n5h$=Yv;xw^q3V zP!&kp|24@Kj|TklU!OCy7)h`I6hYm7LQP{jtOg0vBrS^lpy`|57hU8|fF>+$9EQs` zf>y{Xjo-z1aLEx8MDO4a@Vj)oFgUXoFGum zkzX^@p2GItSBNFd4-%Gih$AC~mgk~hwGQ1S+FAtAlrFZ0i-KB;B?FH<71n-)V1_Cf z>0Z0;*1@P67G{7Ha%*Fv?I}!U=Vfa0p(yaSAn2T4xAYU8UV?yMr`%ogs9n!Os4^sP zh<>lRgQK8vD0VLtk@9H({!~P%o!Ieh$fnKe+JoQ|qoIzynJ2(?aX~~fm}N20{^4%8 z;7mb`pXdVU7BW6=U4D%a*yJyXz&sbN$Ke7#3Jl%As6Ru7zj=tVoU+s;d(XHz3gG4OJ6{r_k&JtUe+(>cw+?cQ7D~qY6?DP!Hk0Dx$i*i=t&#ViB zq7a3H-r>hFTScj?YPA{K=7WZMJ_=3ZT-j7e$iFzZ9b~T^O3B5wF^}i8ssi)ca6WPyZ#+NLlk5u8f`I0)n$&nEaw|t z!Q-2c@00q?LId9}%*+YTc|c>b{FOcVEeEhjU$=exAEGj%o9|C>6-ML0cy`F&rb`=y->kl4gkbz5(h3HuDb}q zOz7D}};JG}P$F=15N z3Y1CHitD@(5SGxazCn*R*pO*i3S3~Mn3$*i`qu<}0uDNQ69usk#=i7{ECn@LK!!MH zl?Epo9)ose*xl}zPMT|fS(gY=i7NA!QG}sYy~bDJNjbo*zXtebVTm5#ZX&BG6jM1u z(rkgIPs5oz#-4kl5#SlS`w6JAKB>lg1K=&>;cv}0f!Abtn0~ZM7JA8*Av9?w)H@3=leAg?hNl6pezZPn>~Z8 zy$7`3gs!7bR7Y`aHYEFwvWR9E*!mus+;g;XS@cEUZpz)t-AwN-(!M#2*{Oa8EaR6I zog%xA9<@O6POlo)s?UpB zyoaLob;pQ* zz&JTdRi+ftWY}(eM|*S*8~e>N0q4Fk5ulkIN|HN^WmuFWHux~Hz~zzH#Hx7I>Zbjo3Pe?mlI^phP$u0$Zk^8bovC?zDa7Rw}aCWbzm+&NT?`mP!ZbjaMC&n zAwoF|Ehzy!w>3b0uDm%n!(?pc1;~L-3IsDwj(#?bKt$P*h}YGEM3=v{Ag>ZdixvY( zvU%J58|E4g@3ZLuGPr4KeFLrjMCH*ga+11bb8vehYj+3fP=Gcw1I+3pi9oEflZfAl zlG4P+tYg?pZ;aKNebE*eXz{u2`}iFmU5%8LM9_ipUHQ(ao8Kxh<5y(i0A~yQf(t~) zEGxQ%cCRR9kSya8XwI&unsXyFQEe>h6>-8f$RD{~w-p{+3p5pUv`O99HTry}54(pZ z#`Z&9N-=mJJz+LV<}!=Gh^thpy^bjr<;UIHp75ZHYeqXNhvbX}bq+IK z(g}22Dog)-M+(s4PkMPDc`Ap%);Ie%bgjm19XIoGX5hgQ?ivmpw(0Tg+H22E-KX|W zPg)RjY1&okKo6dWY_fc{_|UAfiS-3un#P)eb8`l=o01D<&atnXN$)$8?r#E+RyUru zgVZ63>n*iky)s!x7V(?9Q@YNnN}pm- zo*nMruL4j}xT2s+n%{_y66B`$uQ>8yp+B#k$ngkZ1;)TJJS%7HICZoQl2QlUd=~8h z-O?ymg&8>Ul5*E?x{>&>P`n;FglqPjE&k%{y}0)8yq2tiEuhp@-TcTw;|l0p(jJp~ zqfs$(qvD++VAr_vx`>oOqh+fV$?vfM)~u-OzIP>XtJfp0lBAOp8{G&8l)(bG)l9jC zyn9u;Ewm0p%8zWvU8P%ef4YI*W8L*pt%#u0_-#i#WuNl9%E!YwS8%w_N_yNtX~g|h z#OJd{ojNG%^{;`6~m1;X>8 zx>j$ibiF$E-n5{ONoHx|G>x1aj=XkOj#CR@j6MxzvD6G#F};}@^HYWNFcqr-M~Z#; zAM1uw)KDsqV{15@vMp1qjBS(D&IBIMLiqW*iI$eFfrBbNU7(je-MXdEJlL( zok_poCOd^GO|0A1&IvBM1`kM7kqc)IWVD?TE!u!7y}RbgT)DLOwC0H3Ga>H ztD=DZ8MM8q@oBKm)4TtWv?^k;O8%xzU%(bFE3Qer#<2<708F)}xC*dnXav1S>@Y>s zc+I1|E3y5y$Ma$|E6t*u!_`~A?y^@7fHe!@*$wVE?AycZJGd$8Uy8xiYciB#zHo)8 zrGX-aTroit{%NaMXhY*I)I^>6H*6el*htNOd$WQx%RSUA?~HKjuXp{~P)=r{N30nz zXHmPTE+5&am%4qN%b{^O3+>*x0`Wku>wzxoBta!T`@zQ<5EPM$xU4(zMd$A!agoXfym8I(wz(G&Z)Ew(a@@zOLUg zWKnNYzfoM|X6GlETf3ayFuZ%!g19On=e=eYcA@_-3**0@xBsmi@j#IRt*Iq%TKVN) zX5ld=_b+tH zwQSM2KmJRAPatY6mMibm+U4d$pUtwIV3hq_s%fe#o{EylrkeYvq=;j-raTbbkW(x- zlzm;S=ihi<5BDwVf;A@OHWXN@;E`ZpT(9w}f~}9gSiy@GbmRl~a`OjZguqAIrVBt|ex_;@k}(5@2J|JK?|_$3l{I%tVcaaBjO~ySslDIRkA#c|jAn zw!*oK*;s0jshi}6-2#W749d!N)MH#Z%s4&!AnJ*Yt%Y5zy;Go)6EXOukeQlA#ZG}S z%3WwKM;#%M+<;O!K}#QHINLH0a{Dvzk25RqH%sZvBTK-FSc+5A!Yi6{v54Gz!Ysc* zb<%h+RH3% zi_UblLq@`W{eh|I{s)qtlLG23>7Vn2YFeZ+CzraxQ9hX#^u+pS!mm2%3)!}fy@S<;b~H@BTuN)WZ@hg>;uUu^|Q-*5B`cgB`xX4{3} z`wgvc5!?p8$Hmu@3uCTSINGLQ+`Mz946BlyEf_*U&YmZ9?RptAQZ?HryZ$Muz+a1c z*l8QPj6P*jQT4mJ1b8KsHakq4m&O;!YlZ*YLD{}i+lM?$p8mfqW8YCQ*Rc9mw%^D5 zZ4%rmR1p!FSS``$!0el3i(1A_o4Q1T`E=!;reB90k!|>0@Ct(iyNOX%c{;E(bb`uw zOCg(uMaegf6|nOnb$FvZm+k>MrS_cYOG=-o8hEH+PC98`%$?i2X4mW)trJ=R5Itam1|xY_LrQ*n$7(8Dq8Vw45Y!Y+88{{NXMAS z{v|i^`+^-D34nh%3jCJY;(qH$G%Dyv&6Be(zJV6IE_?a}^t{tI+>LYeYpivv&37f9 zzW1L|LP7_SLgDci;x358#@I2f{x9$l`xGc5m966s)9vOH4{}EW~KfLTpA~FE<{AJhQ{DQyn zF#JmpOk@ClZKfUUnSXuv|N7DY=oLSiA_G9A?8JY`#{YM}@84h4C>j|6s(fO0_aDBq ze|SscX~+-Iv{Lnd7I6IcUr+N%2AKdGo&WMzc<>*7)qnoeT$#WJm^Px!_5bqy|Nj@j z|MxEd@XSDBO#lIA|7N|T<-NcAs>KcPS@d3$$qN1MKX5F4k%1XNt4~l-5a>dBv60Yq z6R>g21Sp9nAglku4SEDw0;=G5>nUVtwE1j}oCnx?8mxqzwlU_aK{g!e_wG|@04DZ9 zfQEhs>)&~CHBt}K1ttJCZ2~;#wgqfTnE`XsNiqlTNdGdt8X&PuPml)w=|SX>2747=Ba@%*kz9t|SX&g6K9}{(ceI@iNS#Mzm;qp>FM+f9v>izl z1?O)U1@IA@fCC=s#_#PJ{NCgYI%um|WJbwB(QP?|ISDW&695n*2~2yq%jd4)2S~^h zI1{J@N;WOQFi&79fY34(ELy8sfPtfd%Z^n3yCBk?k3>Wv6GxS4z&1NPFCE05ki2uJ zfRmpi(X){6KEPBL>eO+UHx+w%wjAnS0?}hIn(^h&^=Ajg_72FPE)WOJk`=BAc)dL5BT`9JWr6ZGr&X6cE#Sv+eOCOK#TMkNGLqi9>08xDnW!L z8!3&0qWm#{Ez1mtj_SP3KUtjNxbjXp?g!fKAn+zqPIJ#ex5(yz^Txd-%iTeDf$&T$ z6CCR#b00Sm8O`ULZ0^+g##yrYvxI8Ye|60NXFROs56Y1D84xx9JR3j7_}r~b6mqs! zs9-{-EEJz$5=>EVw)cT}(4emK9PyJM9F}!*BmmXPX%B?^=hn%7`PPkl-3f1E1RXaM zmO6rF2L#ltN{jql+Cx#?f#782FW-@UKh74DWXuz}8O_3h zeJC=MMszMd*Z%*)s&EBXW6M`%e!Bok zloIw63$0WH;6R}$Mu^IzNkd2U2p;lENQdG&YOkfn!r#=E-E?TO~REs5Z&uik1PQLl^y z0|SzC@)nw1-1eD-c<7KA!oyuG(~(8f@7R^;-rV~%V4kfucjE*z1yb-CT?u<#y71Vo z^vGFOH$BfcROdcf+&n!2x@8gomHxDj5iAZ&^>>7y1N=e)z`S)@HVnbQ+@?i@6C!u% z8gMNpgNP(LMtI4BwhXyg!0?{@(Gw{-ss9BN{kMY51BK&E-$m8${qw(k);%wM!8bsT zcDGh6b0f0%oIc<0h0YnUw^|@K)rj6pIzcYNTaWCpo+awc95}zW3_162xyEn@pZ@)% zoy?3s#li~1VmaDYeQ~C^v_^Y*&!#y$GWY;EZsFLHla2Ly);~(yOsZ91MTDaGIn0f# zSg?fLb=aAixc#hd(N*N8_|1cB&y28Az{yWR5_3NEgV*na&o3Gys; zO6c3}tPuUDh71>}6v8g0$dz69g-Z?si&d;=9Sa{=)D^2;M{m1b_A_yBPXfhwcaE~7 zP4LhGEpNVp`7+Zh}d`QppqVS4+%7>j5@j1Nk-@fn!DnQrr4z;!3oor_fc zuOPbFRz3}Z%x<{F5qk9|4U1>FJ0pa59MjkRU`Gh;KAIA!wiL`BZ7#_1_84L#Z!Eas zwwWece*-+~T1?J&-vGfGw|y*G^|v5j@dWY97Pmn5w!b>}QI_w(c%z+sZIg`^2EvH@ z?@Bj*Tq22cbsMUcf}}J+WEb0gc=b43_^6WH__o@c7{3_AuOKm(wjpbQ`7AUC>H?n^ z_g`=L?drUFeHe}QqzfV9K&gRtYh<3$F2hvO&*_k5cBZ~`v8jV zFrGX%zBA#}SB62dlQ;r6nCCu!@>ky~m5V%+|drNrp zUif>|fGQ~=z?N<|j1-Auq<{6Cf1$-2;X;j1`_)CvC=nJNn?3Jszkg`q3IX@!<{4|=b)QMPf2Rcu2)SOjg0rnc zf}4K$6X&^68YNwc{rj4gZLewHnLSL;7t*IKk>l|Ukf260(H)D(gaS%`=t@^b)ufh1 z*Vv*SWa$AyZ#aDcAk$Os{6yxcAk$`}UgrWPiy056j=_Xmu3_0d$<`#lhM(ijb25rj z7cY$BwuAJVcf-v3-Hvx&h@%jE@2n8f>!k1gDp^1ePJKu zl6={VCrlfl@XUNvQ@ZppNbHiIwzKmKf^Z7e$Haa_4|R~UB74io zddKMw1WW~nt~BJYilTDjObHCE%fWeW>?vZNA~gJpOkIgE~jsMufCZUCA3sk zCkrvX@N{!)S)ENhULBk1cXLx$PEUJp;gw3N8|{*I!mnVp;l(Kwf4DsY;pXTeS9sf) z%(dHa`Qr}yATQtb9NbIey^amw5q$uMCQaNokpbE0|Hs$o8-HxRp3MPi;sJt|*GvBV zBh&aoG`XB)&(P_3^+dQTG$w`55`ZgP&n@2b_kw zTE$77fV0CvX!48J2GIA2KZa9@2qF=CyV67fW~BoO%als^u#dJ8lKJ%Qy6$DP`jVp( zm#yg6Mjl^0L8f0qZ!!9~Y{hUB?BFEIfe+<0aQwb~D3iqr7PE~BD(%#Jxj0g3QPPY+ z=E@*<+&Kmvf9~;=4&jAnCN=W*Rg|h=ycz*jI+fH+a1Ea5Qf*)<`ko7;?JJf35igV8 zHd@Z`2YBc7Iys2v0aw6NbvwXug3J&lvo*&nolk|?Ff_26xa%CBi*aNQIkyYVgnD@W zAe=}vEJv1r`wbOGTHOqiF@bj+0a2X|Kz^PG{;*s4Jo%cQP?u}c z6(F;+et@(A^}v31tjC;W%~}#O7b{e{`*m1sEnhIn*lB=a3s~H`RN8@a7c6-5%IDA? zjfZXbgalpi-f(n{OAq>4z`hy_Bs5*g3UNHO$Kg8h& z;xl&!^Dxzr<^a*BdFn$_&|*D31vMf$p8J~BNA~KTfGmJQI)OI$xeEy3*mHQFkEJhf z8=l#jHvum1-dqQkQ7@hrV)=f{i{x+pTJV;L;|Q56B4*@@f;jFkxNL-yH94l~)At^o zlcqr&=0cezz4wtjk56deD)Y(wpNV!F7Ot!tonuHypYpb(lHOgVL}$_15mBV}2F=KA ziUfWR_??+vA-@x$6VH)-M+A6tA8{1&sTk7`(-cI>gNPj$1()3i zzqegUEbE*kFI+Me^XNVJI51or>D&d(9i)f_rmCvP-Kcq-19#W!sv8YIUYes%MK=q2 z7xM=^63xJ&5IZ=rcqe2S0k$1?3JdMl8m>KzKAx(VGlW+TP{$nK4^IqPUm)j6A+MWn zROt^pSkn!)<&;a|=PG_*HKA_XE5`rn^EAkGQ}TgAruOb_*T4?=|HI4?CAdGv%=*`28`q}lG`fiBiaIwcSy!&Pq$mBm3BVhr{J{D^ z%PQX=*ITkqmM-M;5W0@&47gnDlsb`v;T+w^?1{~S(_ka@R;5;b3ZIqM<&EYaZ$HL* z@#cJgicU!+<$8w6FD=@6eV8K_u4H&{vDPW5bc`QK6pq`2aR+Z{2V6CdPK3U^0!kEM zXW$ah3i2uzuz$b3^;*>Eb(Rp_=BL~7$7I9?|M_d z&p-E}l$3&ba7S=-=qEC+ccRO-ePiQxhgKLrL%V;Vj_}jj1gN`a*l@kKB_Ejsb)r6V zB3B=4K_LN}>vpcXWD77b8z0ep%4b-kCp0VtG43E+o!EeJ%_T(fxFA!;97!17K|JY{ z30UZJ)NLH*2+TsM>kotd4G7+T6I7n#hZCF9_&qUX2+%SwOzg82!P5s}!7inQ1k-2a z{WI5BzIL`cpT|HYXT0C9m5$v#CnKD`DKjf0 ztRM)5Upn<^oA(~E2y)X?L0od6mu*egS=3i7zkqKY=?N^~M6yvVn`+xH1m~tOb}Y(( z=lM~;o@_ph5CgMJHtRBq37W;Q6Gr+Y)gau{78DUOViX?oPg8`+dYOYdN@Uz`Uyk=dh>( z<3=5;yUD(lNou0L^4FaehuC5NRuN8N6Y3%U;7a_&1FW@vHTz+EI2Yf8A(Rf!{#s)| zH2J7tDTQ9I`UK8}I=Z1NpUm=d#qN9?s3woy)25|Gci&&@-VZZ>aAUS-FxxbEI_U{xIB7r6qBu%a&BaAZ%pR-T&IY_5QL;7?-9d zb?Za96v8%&+U!A3=^U;Xn`3O@ZLYf{+G-bwN5;@Om$!&3W(2~Hzn$?V&ih7)vi4|m zWoHJ%G$vc&Cv|A~%t4+?&!0>EKNg;91h2&P8Cfc1kd>);KOs$@(A&Fo$Fyb*;G{A9 zEJ=4o`^jPe@ArbSO9h zD#4%f@p-?UYq^E|;tGM`doPW*3gBl%MHW^@ZgmDN0+0`{kVg_n3aTip#>Em}K`Zf} z=SvN$4PKV_i)XLtAOs^dEd0x;coGJH+#K59eT`h@cgtH!A|+Lrz2D;IIx zY2*}dN-srvh5T)w(7g!AiVfR}!-|s;SJbM4#q>>;7p{`Qwz|&z7x-xKAr_*V{^Q2bpnJiyIDs6~7bm;l@Qliv$@)HW#N2q1vg9jg6KEsF& z-d|HDbH?lESOt%B6^p=c8*?8wiaED?>cv)sukg3>k6PYRA zE;dCBD``sxz6xSfqb2m@?g_fmv_YpkS(#fRNkfJ^mD7f3R`|er2O`S`zVFS{==?@L zUF)8l57cg$+YkkgwyT|HP>el=3q@#foRy3X{95Yh0PeQ?>36!N_){yZtHu+2oc66V zIAI9>=n>d=Tw+KeQ0)~ppUu|=O`qcV92B3eJ!#Q|CoLt!&r3+ILXs{bik+|&X0`fU z78q}b=^uFFR6N>~gYvdr^Mxnqi27B!x$qB4YKJrapaj2TnrgNSmehR>t?V9q4@;{%68t`993f}sPa@F4Tvxe} z$`>n&8uoe5H)h?q5;yIEGP%{V|0UXe9lYe~)i~`*{O|O#&|4zZ8vWU!=_)X&EhDlQ z{&I$j_Yg+kL!d^ur#hm{@NP8bM{`2XjMcdn7MGBxfS+JkqUH4^OiVh11cmxs{`_pb zgs(~`)H31=Zj>cKrF}8z+`Rb@26zm=PW}jHMLfL>UosP4NIzFixmvftAEj;m2~6JY zU=iWGSyAv@v}7N5{-JX=a0iR8&n< z9__ckKDNI)2qgL3!VPZDB{KWLy5_JC2Pkgc6%YKjz&f^?*H-o3{SitJ*rm!8F2v;kS18h>GH!RS0ZgkwLfa4y#tFH%{U4GDC7!be^Vx&wx+V z9qh`mDt#A$+%zD~OwT<^l~%HRL95p8PAf1+Grr163fHA;%DH+V(n29%XOx%4k>XOa zKeSfQ0G*az@4f20dfvISj775I36%6q2^Cg|NE=*kez>v>u#omnm@YzuQxj;9VIDDi z6u+!j>q?BOetJ_LQTp4YEx`GuEkQf7$2oj!gdLviL3B{rA|iZ&;U7~a>D@0fSjgGC z6bSohb&k3Fa<@blp`qk7OntgKHHjCI3F+6-u$b$5Iq~7MrOx(|D+Bh&OkavHT0RlTiQkx5ug z#Fp~kC^!V`6sSbsog=okHIa9vSq&;)3%woGpL99kaD-iIVTGR4FBgci;~@`g_qxeV zVF5R?%z_2*RQAf^BKu{;hq)d}Qn~JVuquku;VMT2`}LlFPrSeV^{{e<-i*MSrnVLD zS!DoiI~%5k4XZd&2f9{fJ>yN0%V_?r?H?Ex(XEA+-YTPbzN(?;!L7cLKlq7hg8Rso znDBEMtj|1)U;aMdVKkpm`%~E8c4pFkGUiL<&Z3qy3dMiGFHlMgFcVxG(jnK$0lJbw z%;oMX{k8)P-=B}ibsl_W28n;w7bB7?S`shmRGl|Q8BH?u6_?LcZ?};_nM^!o-fmWw zjB;}=U6S77oq9(avl5AxzWmrgZ0cr~C-|G>E|;lKcYxSo%SSFbVf>uvVEIQNmrlLt zPh3~S6v!S4&nhLqvi4%<=~uI#VgR=+C1*mB6uF`|F#8=SAGi=kZaz8*Iw&k$^G>uG zzd-h*Zn`8ZX|C=`el@#v!{D8Lx@V(!mQ)~&VLK51B{6uBzx5H0v_}wz!26Bsp`0p+ zV?ds;5WgkpT=hdDTEq}%!{nCl>Zy|tEL8<>40R21(Wx0$vLncE7{Iw86oe&Q5f!em zz{@?a{g`y4VbPZO-~@fX2FYf*Zwm&ExFJ5e4&5(|ZOw{#`-v5%VzF*o5*YK$N+kNE zX|jfP3$fj?45`-L&-r8&4+_t7Cd4BIx2bpL}t z0}nbw!C|xF#Xp=iW-=(4>)O%Tj(>{C7z~;O8d);xzEiXL68#pErn|m(0&Bxxs9^x= zOdeRLGF_$ssN|L+Eq<@iwXLcq%8deCNT|KpGzkP(P8I+b6B~2=%q#(w0pkmv@wa2x zmU*As-Yuhi|DM#~PtkZG4s?6C6#S2j^J-Qa#|}4-ocMTraiMav;TR`i;mvlFJV9O@>YYB z(z;1wJ3YeCqu;Pl)?^sl?b8nfR_QCwbB+xe{z$xVTNT4Avm|Wvz(Yu7j(~c90)ux7 zP$^hj5{q)Uv=VDYjoZsmmW=ea(~irGx-&D|s3Z!(GdBI6Gei?J^hE95T_`JRE;cFj zJ1>(Ji~cvy*kbZb!GQ4IC|;=y1VCC*7Chq@0LzSJC#q*1UPl5YjPMmfUQHPFh_fp$ z&F7qfPn?pDm#f%J4?^uEpl2S5$E8D#tCud$H03MK67|i-J14@-h37|+z<2tcRn#-mreW2jk8W zWyj;z4J==-AzS_wq{YDYHrn~~PKXX+N=U6qxWJc{;ku&9gQWJN+sxE##Frl*x*iu3{3q5m~3ZersE-dH!B#!BL{j>bO;_UgBchw~{G5PP0}uETtwQRXGor9bZzlRBh7DSiU_5GX_Q=mAB-%d{l@zTYiI`pNuL%t)~rfq zLDgWkabV~a+UAcA(L5V%umweOWDxDXFi)%luLM!#EyBQC-+?)sH4OzWx({%DWwuyD z7edn(B~ABS(7!e}Ww`Uk+CKRA`Od%8u`l0%IQITQ^d$XXlb#zlJAocWY7MF)mR6d4 zfv8KotUX^rsTgV@C(DhpGZqM>r}2}YV<*Ao#9QE9dJz_&pNECgVk9|~S%l#=`Pwkt zL^}QDk$Q!s&i7BXn_x^=s3K3`MPuZ9CrX}86KsC1(3PW34+~Dhn0+MbL_oU_!kdxc zYLn-G%D^Sh3jOO6{DkrS36I6yWsdW##xK!c`6PL6V9XpLr9$2PTy&X5Nu{6e77xdZ zcKOVtCpi>KuVYBWINKv*uTQAYn;DV@ax}DtBN>s4RY2HQozo(e|6@WeEv*Ubi-4^` zzS)P?)d4=D6SK-M>0Z(6S(!~zb(WDd9!xcUoB6b95Y~Ab4ov}zqaV}_afh=bg)PAi z_PRZQP(Y<~$SEVPz<-o2N#p{k4b>|dGhaDO%w-LoOg#C)7@kaIcQsz0>eTRO?#8E_{2K68iZZxF6=-xIjkeSWg|>>Y ztUlO*YQqp*IFk>F-Ye@tP6S+>)h+IW(JRqh40R5+$%7@mj=f+jFKFfGZznG$;DRZc zGwn08LV+-0fw09J8hkvrBHr=8qyS5N@JD=Z-kO=au-RYfonC#Z;HLRZ=rygNFwc{( z)AoHyH(noiTk&>&;cU*Fa> zTn+Rz6FtaAy72N0=%0sS0I$z`FXvRp00LyK#zZFotTkKPC62f7-_9F+Dce7bi~*cZ zWB+FX3@e#KjW$>Ygn^|7U~V3^wUHhu`S?NP%Rl1lPt^6X8ZfhEiDML8MGCy3;&`ph zQM6YMJN@>qny-_>rvLU^sK?RPl~ z^AaURn3L?aNud|KUJQPx0J}K1m_aH={}41s1GmD?@aJIR7bJ#ZX_2QX!((YvAui3m zOv$KT?=34wp4(pPOwLbw@Bs|v`Fe_u)6eosWHq`}STPhjY;I>p`}P;7*WSnS?Ikc; z2-}#oZm<%$DU#`If^YudWd!l~)b~QlE+~Gl@St3v%{5?i=f^N!HRr|r2n%0%Gxy0y zba3>0Scj@wWmA(O-Y~P0gG7trfv1;tSw7~vH;*mnvTy|bJ-F#LB9oRm9ZcawkVEvi z=db5x;5^-0{HAS5$9oa%T1j`%rQ>BQ&WQReSNPyTM#5Rf-3W9?$*kdJ#Alm*hIc|Z z_cv0zcoJ)}o3joaE7t@Kk8j}$Kce#EVua3Jo_=+)yhen#5|ALAA##LW7m;N+L%a_Z zb@r%#fARlOinJ1cGtb8#81&ZQy-3*HEmp+-Bq~h)3yd*}A&66*2u}lmcp3W355PhkK#Z_@hMOksa6}>vg-5RAYaR z{{wiTL1D?DqWs=}*4nPtFHA{|Z zW$}QeI(G{OV@9r1D-k_+m})wnDOj^bjW7!ff5iVeq%$|ZOui~A3f~fcjxeiP)=%S@ zNAH<%BoOMa^mVF_#V|CuBvm9i*~vx@Y?WK_KGkG@TW`~8;r8WxI}4`XLi>tJSK3)3 z!F|y@x2N2{#j1w}ogqiOFq^OzlHtjHjbokhyh^kuIHISEt*2o3qV05TgG((iSn$oy zO5W!D7(F18BsnZ);8|9D+&GA(je$|d@a183jll5jWsQe-j6XU;Qy?bSDjTj|9v!W<=|pvFXF40gIjPf7wT3LY1SpokMB5O zoquEy9-Qjho7p_Gd=Do{8Cgd>LS6ltZXDPnJPkMikjk0jI9FH!uxtu^EY!DTK(v59 zh##w}+9eGN%Sy?4aed~r*XRW$UkH|Qg?#o%o798Ui8ZxEAE;d?fZ>k2S4Bq=p(9n6 zevUWfRIE5Np)n7>{@Q>I;SZfs1U;M!(e-<4un%tIF#sn zupr)d{5tMCFN1Xw!*F*z%p`r;3v!_w)97l}G6)cN)(?HTj3a?FoAmbx$Kqcv(TvBn`!Gq-D z<~S%HBPaEC2nR&kLv-9u9*>z$RAPh5lk1Wylngbo<t`=RNS4Z8jmuPebhArF|89J?ij7Su&SwFo zo2lBOi&z6II3~nH$ZDqY_5G_8P;Q~5JnH5Gmg|pOMOU~`Zq(A?Zzlj6!#}xLPoHg+ z(jA6MV)bsbws6LNQFM_ii#@m{gihF?J%HqlDudpzzw4g%L-i8?u_&K;x3wx^8Hajm zV~VB4Tl+l@dFt5SKLJbd&Zh*Yk&6j$n_A??oVAdjk>2k+u2G$FZn>B%1xG^XX01~g zCVRX}#D(2ws8Q#d=0J0hm$-H2AXe=z>MB;e8Z7RlARFWZC%zh#k$YU*0q06hP zXn!)8z(wDj<&w>&X`hC@sTc@FDYS7|y$V>WRQ-6%;yj%DO{>rAG#Jmfu7Gwi0P}Hl ziagNvadzWQW1JTw8TNYY_X8Lgcg<{NCYOtpgK+X) zH80NP&rii{!9tDB>6^JV->?iG)48G^f}y*%FrQMkYqjmU?Aw_*em!Xq2wq<5!iU3! zLk+--R@iO{d7-fKZvJjwPkd3z4M|5HC6IUFj@QG%2M zr2YuauBdpe2TwoaW=TGfR&AxuOn5tPml=g``}lou^685~^hvPGfd872R{apCLM=gDp5KMwakHYNjYM`6%A31%NTl=7?D2{?W z>KUlHv&?)WVVA_3qg1GlUAUVV&y+uVZwhXgc9civ>%6>UdMP1~LXJnmwvz8U|+Y-tM9X=*;%Z~|lW*COxhCXAF)iOym23s~$UJVeCC=$^d zJgK6Hg<=JbQM8Mm*N-o71(JZSf#$6}!S0lWoD9la2FcySD2$!pH`=@uIQik?ikh8f zrC+diB>1JhakS4D83O1y-zw}gKIJhlG9NJONVs*A)=EN7n<?R=7zFJZ-P zTWqttJovGL&jauZ1x9}1(-Ouea~UMMq}g|71Q_O6`2VOe)1jwOoXtYc+BGXk#?nFO zH|=Em!6G^37Sb}#!n8*E+HK=ohjF~_z0_B^^oUuX1yqb|i^rYbH?m<_+1j<`M3}NwZ*W|K`(A{Y~L7yvO99 z6vZ$O%8Jvq%M?q@XXGiYA*>nDVdb7*Z3~H-YL(O83PKoIg>hc-_7W-NUR>j+c?x58* zvSnV$r*i$N5ASUK$kxT!!GD1-^W?n;g@U@Jx2)nz(MCQ9|GiA-nPLZ)bR#IBbR(&Nq;$75(nv{v^TD;xxA$7Ey*=mr`M&Eq zKh|}zl=tw5KRY)fDW`W;jfdE3G0 z05{AcUy|?6#_yBhNU1g_tHwQh1=s&rOf>*=ID=9mZ=!dWatkSP5heV5>~Wn4KhN2! zZOqPZq{K=WK9jw6;4@?~mWh8jESRh#c4wq)YOt^ChDe3K({TEPq&F41)JCVmGb{hk z)-(?6S|JbQ4--$h7nSOp=ixA`cc41)r>e-3dWI;ZOIxX)BS|O{R2XemIzJX=8 z6%Y96Ce%fhUrt#xPI5`~p7iWpCwtmtkP>?_K10br=zB6|kMXSul#%l4kWbxigM>>* zf*m+L)aJA3hEtUAX-;T+YYb#sN5(v(mv1)40pW`oFUGi4O;dyUtNXoSYiZPRX10L`mR?d2U{MFWGC_~9X0`EBh>nKS@#IyU=+YUCe)@WW9F_NZOt%ZJOufrbXIEF; zhRJ#E{bl5(;yXa=x0N=w`u>XMzt+4Ei3xrl4p-XSb8aW3+PsGNKppk*hL5I~hkrjP zlsK>2yOhj&e9toj)?Gl5!ZyxsmyAR=-XMou#lU`AW{IKiia4tx-}^Oza=BIIf;vM} zzQlW*^SvxWl2iRLW=QGvn<7^`Ol?`B?AvM!h+%4?gsvMI14%lbm#%>=I}#Q~In47vtZB-k;> z+mmpk!%kNdXS@k?Ta^Z%r0NHZ8Tcm*IWO|=F66nZJTc7!1kpk$#sfp&q?^0e%$Gn$ zaB-T9aJ3)0<`>gG0HG|Y`recI80Dt5p>?1hR-D2ivgAj~l4fpIQXO_UVdy>%nU*d_ z$@a$34Tb1E-@*wR!Cjyk`xc|`co4BStwnPzE^Sj&kdIcAbGfPzCm&ozZnp>`gRo?+ zFpaRHKTKV&b{6Ho$Lg?im?)!?VLaWH7k}WY6)G#|t%c^IOoeaCQt52Z(Z)W|P3GR@ zD?jVGUFoj*(M@UDt*1pI0WC(}E^911RhH(X7)i8ot_VP%1c_@j6Oy$bqK*Q@ujtZl zh16|%daZVxC)Q0whP9BEL&O=({N$%D*FH|r|Kk&FBABFBZ31IwJlE|Ck>0ScZxqH7 zfB>e5mmq4z2lpG1TU5`7(-a+7eQpKgGynF}M&hi21{x zzA}+-cS=RfgbV$P%ZrI#AS2?#G?}Qdw7#gL;_#ocjeq(vK^Bu5ZQm(Fx-|5!ZTe|V zbQt7h(Ff5Y@v+bSD6bT#&z=+Au&vlW*phLCSynzXGvm7nVKQ3mXbWItD$T2Yrubr` zXqWN=kRVN86K4paFTQ-b9PxdAEKj}Vb641vk7B32W^U9z`}=2ycG`**ea$8&c|iIv zWjJb=Vg;P`sm@-yNi7!7cbi>5&VKqd@Fo@NU(UdkjFxP$8Y&7nIq1VB{&cpj4=L8_ z20Jd1Jgvt?D4pCB-yBgjqU1v`Yx&X4kyh5A|B(RFwsuh$cM8S-C9Z^4e*$=|jZ8O$;P)y>2>G zyaL{4g<|gi{z8Qw#{tJ{W0Z6e2(bEj&mAa!B3Z>4>;w8qeLs+aXA^NRn2!R%zq#XI zhB6u4`*|;02H&*|1c&}gCGe{c{nLv3$B%B}q9`=yl=Ahs;=kQS|MAk?TJFJ@%#@78 zW&AI`KU~;&slG)bMa5c<|o2UR4f`>4f{To*GCwNGunhgnvX8`{JCy379eVV(dV*bg_ zuU=5CF+Y?!x1RDf!W1&kD&JJH+nx$f<7VCJMiREQmKd&EywEw64K||RkXSR3XkR$3W887D`zs*8l_H>ky2h6Ftig?^9mM|iFj+@&J21s!l z=Qhx2xShD@wLfy?jYyvXdTF4#*ctfQKLjxwtk{#X?Va&XGW*AsA;jM+DB9x2*ZvOCJt0p9&BXoEn(-l%)a*YfE2B; zoStqECCwAM!xBpi$$6oAaP11Y!5fb}s`pR|8=u+mG7M#Aer#IX;rjd@bfB z@PYmFZo%uD3)QEOt@3(IRKbBBJ>0pFBtF-J5M;M^*{Z6_a|oYA5W5_UnsZ`QPhJz{l~Egz4b(U zqnU$K-f z`xOdw<(Z){HHU9@rkFF~DwYi^dGY&9l0s?zAE8ud6k-u)O9v6+>WIkTqJ}*< zf);t|_MJpBkBkS%xLmJ1V~Qw#fbps_ibjZwU{YZ(lCITV;;;*lh`4^mNEmkN@msBr z1>cbaX~AwjqiCGHMe-E6lX?0?gqD9!rSGFX;lIY9U%y+oWJIkf)1WAR^ogj4k6|+7 z8Nraxrp03(+~`j_z=xRZgcW964hZI$uQBd}31u(MJ9-b$NeG^%gsi1@yfnoT1JmP9%mg0Qp?(%p4_YsuULQE21Z{C1BF7$j|*xph_Whd zz^ur@uLrrYn4AW#GR7c#HR{-xZ^|jL`gTD-2`K>+$}c<)!yz5b=08{skm7CScHTzR z```~zue;FneR&m>(4VaiK7bek(|w^mhL_%%1c==FwVZ`s;G9gA;$0SKL( zia{b)6+$oby+6+yV?;1VhYR%RHCBmVD}f+52BVD6z@oJZE9wb_{Q4T620vu9)|3ku z=ZOKEBjPYs5##&%^mvsQE|rhs_@)SLEI#;I;Bl(^K5>FDQ$?Q=bD?OknjZ{bPlGt^ z35qvz0Y*n+BQuIQw`L8Lc_#zc6}DOyt2_csO*4dp3aUKFoHjW#JMl>7Kp zv`^F@-E7%_P>GFzq_{Zs>VR$PC{`hIB(OLL_fBc3;iB9*fWeqb zO34{`-nob6%g$N;c5&=X;8d)U*UbL1zP`?- zP&p;~Ck`K9YewpXogUrkDMFv=4#d4`AN-KL!T+8bDHkHjG6#ZLFC*6ZO+;&uS(GJR z_1cq+7$N3!_r^Ph?uI)f?e6kKy%-CjGLCDNd*5g6q*$JxMjnom_c3uE&gWw)>!8%` z<-%I7gxN}UtTBG(7!N!m$kHYyJ+%gbOmT7T(Jd{}FSGoAe$;<#gkKz(2`_+teu0kW zCnInW2X%G}nMX+Gi@7HF{%XhO1yLp`+2c3?v~GK_FK~tYhzT}fB&ouAsT$l%R8N;c zTr5Y9t}EDjhAA5*Ryq^sh;X;buwer4Q%Q0Ga`ZXa!aC39(#=B|G1qU*Ic#*xlQ6q0 ztiUOMPJ~E}sVzgfh{*X6#wmMA)pISwMq<^a!deDIUzMpNc@PGCV?(n^mlv$fZfWIj zBY{c?i{=$it#xB7Ck|fpkMmMPHxj>4S+e9!9z#YbBf`uOozsqs1@c579UcO>ZuuUq z-|elTTQQ*3icEb1mykttJAPLiPO)=lmHRfly&#bJ%sm2BHw9W15A4fI4hx2OxOH&T zh*(rlD`B{Dr#max@u_10k%?wt_+?+r5wE%&c<|RqZTd9EU{(24>0x}w^u2+xhOiP;X5vi>`)LqPg~cv( z1AWG4!2|CF!Z625sS6+{-?}n+S8NzxSv%;SX3m4EMWuPcAwQg52;OIqVHpX-5Y}7X zUqRoAVI@6@1RA86M^3F%F&Ff2-8zZ54JmdO0^7s5oQsiDl!B%XCz#Oh(rM`zThNV5 zFXkB(?+y2=SuJ5v4V zAXN%EHf~$fh{R_a?&)J^nDZr3l86(NJG5)LjNxLf{Yw$<_@F(m+JiDs@#bL;gZa^e z1`Tj~37E%>@t#y;t=WBp=N^-~(ttCL*HFAtX061@T95-ALxav`XcB zD}X)`88h;)%|G#0L z{rQ!&Jpzt+^n(`5qrYeak=`EV9f>^&7GjdwFr|`5KxKn z<-U>lqUg;ORU0_2)!q1R7inHbUzbjeFNQlCEv+Zhh%;RAmsz|nF z%$PWh*a{$ZRDVT}+9iG$r(#fFF_29o^w7NXL7X1TbF{>U4Oi@dk;2D35y=PsCB85`}tt5XA=)$dOcqsEX%XM9Zv{ zZSuKLsMe?=p44xdl8-9z-@;7YgiZqk7eDCI&$PV&GsdF!l3RT~rmwI+sp9H&uh(GE zMzTd^D1_p-7_Z6h*Ny(1k62}tL269UG-FHgK!~oQOIma)cs?DeMm3OLk0!sMvx1tr zD(#bo+ISG6w;@7uJ*dq!AQfY7*odc2^ia3>OmM@HyK3$3`P=!LBZf9#DFSVY^wY)E z_R8Vyct9dts2Sw+V!Mpa34;Ag9h~%KAnRB+61uM$&<1&};d;tAFpFY@AixRIEU7|R zI3r*>MzkHzjnm7aeg5{kWMTAX`03fo&4Mf)doLSx-Jx(D+)&4jxlkQf@XP>13c2ql zV7Djt&BE|qYZ~d>elUouLn8KdU!uV?1TRbieJ)g+6NunIm3+KiJM2bs64tOS3bM; znYX#WV(xvKrbP^;%a3aETI;3Y+L~>_5sCIK+W~`=z(nGiK}&dKdeKO&QW&Q`Hp0*% zwlc-y$^|?Q>>DJvDBd`-t7k@8lV0d>7JRWkZjq`5uf}JKGw&F_pPo;l{?XH=M%w{0 z{V@CWLE*dXI*lR4(R_%2JT07!->3odM;WuqqgDQIdP)BjHxiT}kfvSL;XCR(=v$zB zbp?YNguT@DBB+jQs4;9Y=Uul-N8RsTRHRP64YtGdj!3;<)x=UsTIB8P(nz230tpVL zR7wEutyKe8xeD~N(Wkrmy&scm+++v1iOy8r^uttQn;oDlN6^%e@Z=zMi~ni1y)__A zF~&fqr0Bg^G|DH`r*53{S#toqzsM^9geGl$p9oMlY+t#L3@@pM;5NZ+uk0`ZILUc~u&0PM zl^j9~dHufny38>T1-uz|s8WRMu$Cg#_r?j`A~COLp2;H%xrDxf)OARf)-II*z*x>T z-RmC_&1g758uucY@_XBJabz~rL>pfSsJ8eZkGs-2k~mQfhT()R5UAV{hVW9>&Qsl^ z2w#PPusfgLy|8~%+%VJ_Oc}<2F={*XkUvB>PJ;%Ibo`>KReSL=KsQZ0hYzeHe_GG{ zX)FBtl@e24SHH_M{L|~{f75}7#>os)4q{PIjZ4Z2y44R59CKBm*Ml+*LOXy#I zmr#W=Ucu&Eao8R~E0=o3hk0ZoQPM4pjgMO}Ud35fmeL6@-l-(I%V;|Y87Yf&OSNC@ z1U*&zlDv|^+v_kSCM1Me9S*R1Hc|a5tY@7_{)n}1v5w#l^WD#m3*11z{mjKz4^23T zYb-`8>opAoi`Z8vm`9wuR-3a3iD-S{8Y42%Z6Vs5Pa@s|cI0Q5h>b4Kl?omZ@2-9R z@1T!tc&TaP=^u(u{P9AXsmu{S%P~tIED00)Ro-W_&;z7J@MH>?Ruc3r;D%C&#qn~z zWQea>3!xg|y~EqH7ADv(=*%(zLpg{Bp98JlqmLBGb@SRH81?Lncl7xYXcR2K-Y{8s z&+;nffPR))re-8xb^evs6^9gNq#BXq&3SVGl_QIJMg(qxzTh0~z&27~+rG6#41Ijd z;wuY3=XRUU*yA^SJsU)XVC@^3wgd8Vr|yU?;k}5qpHOpzkTkI`%M@PtE_`+1MU)`9>J~C7T$gdhu+HF%tqCy?K?_?eI&1En; zm}txL%!>`{U4U!ku{u$wx4eo_74TX+CB}!?V9b-pd-M3;FKF~bKJo(XdcLw@1MDDK zqx#SO+!1bm>;rUn>;RThBCUmJO^P5@R7HrP|7$7=ie+>i)0W_s0#B7#`|maz7Yw& zb75RwW`y^*cKy$E!xshQK&@Q#otxarKf#fE=o$10S$2}Pe}Wf(fvWt|G4PKc-E?v8 zqwm;9gW2HUOWA(sn=gAnDT@8%opI{#zwN(XBC98S%XC{_`v2xjtcc)<N_3bRVPV|adx1JMAVtr zfq8fx3Q2PiJbQu^xs)niDFWIK)u7}jZ8bu`eY3JvAv*w=Cq95bdFlF4iS063EdrtX zk#gMxFo5dqc2qQCdFP36C!GKb(dc+1a!#%Y8fmpgf;8WylQYhu?Rb!w5;<%xT#5q{ z@e!AcXB|3xIB`zj8&;64U9>4HpP*^)hN3!X`>MfSjG%aWXf)pSz#c)gZ$uyUJ*OX_ z-*#BX94@gw13eINJLoS|LmhZ=bS#F+`SDxZ&7osjP@JAQe)23PV|w--1q7z3;?Din1ygtwJcy{q2p zjGKQMaAql$STi2Y4J4DM$r({%H}1=x&6o5mhN^vJ416B_AC5T;K|PYuo$j!K&_0pj zYRMD6lKF@)(-?GFu@IRK(&U|AFf|D4@=U;u26*iUxTw#Q2Ao^cA*Hyer2pEO+@uRR z5v(qvAJwXX6Lf`O1n0i&G{d6-17^KbToDgQgbSE?SAZX>fwQ5E>*6ZvT0RfyDEIj` z&yMA=e)Ka;IEa%u!gT2AFNX}v14=upO)jv-vU*2Bmcp#d^9gO0mVaAhZ=w&rSg}tx z+GMV4#^W~+;)Gjx5k^ zM#Q+U5QOy$LkA#?tF7O$|HSsJ7@=nfF)q{1bdwEw=b;6SSQPgG<6o)38_Jo}V3uIG zr&a#N3q1z>xax#i-`VB(hQQ1JIP`#`8UIeDzcavc{qN+WoUb;up~(Z8q11djQcri> zJ|@{~j}OvO;~xB)MZr)#vFa}j_ozDd*m|YVJMsSE1)vfP{kr3Ff~ykG9Ak=c`of#_ zUnTCOSO_U4=YF2}@CPOSPhtD7yL&br!xuHGm{m4|d?Fzhha!r>qu2ImOgYVj1^$YRCTM%W*+KNh6OsJRJU8msmt{R&^I8+ z4_X~{MD~cMIyRsm#s*d+>5`0D#@U}v_T#p2PWWDjrw8MkkyRh-hPu!;!Rvt-p!OTg z^ybxImsQ#6{8$<5|8fHXDu@6MwFBjLZr&WYC&!q8!x*O%t~;U?ZQRU5gs>ifr?4iM zBYw!w_I8&mA2Kax_RLY9D-$xn6&ktvnxIxTMdYTN6(cYRVz;Kj1_^}y(IcKTP2Q1qcc+YGyz zSUvrDrE+hIWj=OL*{qykP3&K30m77Bg?hUk;T8B$UF zmWFvIV>9j}NSOjg#AToyUtx)(bq0^9G3eOoviYCznedEW*q z%?p@930pKr57mte>`O?Sgz;NB^g$AFgM0UVTBZ_%pisIC%-j&d9XR3?oc%Q1kv3h zH~)wxWhEaLdA|VJtT8OLTznwxwicW~l|l|Z)uXRJ zkoKHb53B%QpD9}CAHx4ux&tspn-=f5*T|T4c5)j$-G~u;h-~O0ezuG`rTMVMpfQAg zySmo;MUuliYjgg1ccRv<_J!u#2T`C9KJBx@$2FU2^zmiZFrl9Hl z9$Q|tG?LkEm&yA&*RlCG@JXjtJI?cy%l9tyPqVPpP!sRL%-N&~;R~(7;k*b5%gPs( zt2Jm4*paVoA^eGkSCq}Pn+B2o^0awJz02Qtqi*72(Ep*W_z{OFqTIM1KBZe0>AJ_d z1g!ve9QSgi)m!7M_!}G0cEp6~DOi}(si)k?G&L|fR75TGn4dd(9_hfja>6YnWbA#uH>(UcDFiHWPZ41?|#;qqGj(K?Y4Q)5rA2&H_yi<1xS9*$nx4%_$0O?Bvi$mMC*csY;H6}}U8&`@zWbv6*aFbK+ zjpGW7>$YgwsSmbUV1uwgrcLw0$~9%;?bTMy_PH#eVgj|OSRa8rohrbBA>HnF@p7;^ zfdyUbsV)AxDVAn*ft_om3(_ei00rv}f<{@Ra0zLt-{p*E~{5>o^O)3DZi%k#=4>kC?Ap+}^J)wE#{~K(t`6o<{`mHBQ8AAoR6P zd1$cAz6fc&9xa^f#JU|KS19I8wKy3Y?VEF~mh;6WLp#?ACfSUKa75h{ccuv1)DSQE zLw@v&efy_s{No|!9<9pUVWDd9&)}a=kB~8hU$dd-B3flU5-Mz$gYB6lq61j4X}vU* zpD4-d5TSy3pN6N7Jq{L?_%5&>h>~hR!c&+-cGDObMH=n*2>tAr0nF#d9m0Ko<;UF` z3CGQ$C#^^z)!C3MeTE_yZEs6smZwLsc0ypJ5F_N;2zQ$aJ5-m$SqoJ~tApaaa|fR^ z9z%hVMIfWQjTN( z%~h)J*Inu7Se|X{xqP=cpPae^{vJk`1^6f*E(u_)4m}4pnm6|8cL({CL|+D00Oe14 z({a_GLZr+`>vUBsYbipAo9k*8!-Pfv`GnXh*a^j_SDavKj+8lakJGl@k(Ux${8`vJIT}M1 zVY+i}Zp<Y%9-a_I}k7Wi`dy6XSh$It{PY)oe7j5XjkV7Z6Y@ypgf_+-VE`Dik&!rc^FY__n!W_(S?e`I9 zlfE~IyV_>#_9Zi~lkucq&1oKh=iupOW8Jh*k#x=I)fJCC_6_7>A0>C(yyvBNC|~hC z6pSG^p=u$i*d1Q3y`o>D}jdB~jc4QnKW3Zc)LNKKPxmx6q!Rv$!0j zc9p8iu^r*Zo;E5_jYB3se-37oqBz@OTA&6 zF>UW86F8Q$CNN;?9+f0_0E zDRi!o1BP3U_j9WZ7SJ14{GYf@m7P8j3=?Lx2wkIW_a*SOSiQBD|8^0a_ zH2_o~rePkyMUa)XmKji${p_dx05^c7HIRst&ei!-Q$Qgd(wyFLAw9j!yiXIV1rjNNHT_sXLHpXBKbR%W$1(R6) zG_OW!*6kdvy0Ak3`VH_|;YZ(~)+z@>wN;k4vU$9dfp;~j_a4|IjXL9AWQa!6Ams&h zEkCv@Amv9d8ZCxGN5~Tb|J!j5J|G=(@4mWD@|s-*XfBF+t}aQ?L+qbk=+DdB2o~q2 z$7{tKi7S4ry7Yc*EWo zGH?TsD~2xN+(K5qfNMUSoi)Q>GNt{V^4`ZI@qjqEA04T*z(m01xErV1z(|CcwD~-E#RxHI{t|*BzNzfB?kEfoDd6 zWqOtB*#fjmad8HzKpoI_mr*B!;qocBvTBr;JHndLx$+7u(A1($2<5%J;(U$55K`dF zG)y!EKnNiXzc=PI;GM7vd<(JYC))iXHbsJWHR|?z+iv#(VZtueNqZuUv(Or;&u7nK zcy+}Y!&rtKeu+V*$o6D?q@oLZfl~o7)RzLY&!R?mw!vmv4so@DPb=K}Bds|C0{-nrPq0B>>rtz@@JE|4 z?8*^Y1oy`H5B!G}3b!cpfLlvTgV)S^k58BtOzb9g*#F>(tQ;;3pNhbj9oSqwb#>-! zi5JEv)GG?~lE;FA+V>s_I?GB*2?$&yz{QaadQ3q_gmXjlF`D~5MY+|D?t20u6e1e9 zI1P%46fPq(gEGpUowquNZym=wm??26DVghf_gM434C2?n{rhpnKUdc!L>cf1WMe;y+yDPe!@s zTS8{0xG~o~OzEddj{S|0NujOz5TKFZi~$%RrVZrNG^-e0>XOmQ!vn|cI^wsSBe~{d zcZz)ClFcE3rdC$He0Q(DevFocI?c{kFN9IP#W_A;RkxoM2AJX$J+1U$n9Xc4A4@dq zM`QqTQ^~Tu0hpuU#HSq_({*J5h&KhmuqqBWL&ff_KOzW)0i**^EIoT#mNhX7rGN?F zP?lo@M5rageqe|$$EhW?$U4kDd~kJ0$^<|MdRZ;Lznhi%FVl42P@Q6oV^}G_v1M&5~KaM zTET~N@m)$BmRu=t&4w{|VLrC)8h#AoL^-U#%x2hSz6L|~qQ>>}1nx#&&vDt`c}#8R zm!9^d18^t`&eXO&V4#~I6soU3ppQ@8AAFg|rnr^+qWf`cr+Ys5IZ#tK9PIQ~(!7ff z<%5?67ke7}?&xceb0o<`VP_dUXx1;UORyIMI-4#Uel2t2S;g~Sl_!4hCOmyAguPu( z-HmPv6uF$Td2ckJD=szg3P}6}#ITSC!gl zml&|BD&ffMNxXNaD9%L`8Pxfy{Km~HBIhV(c^!iDH9;-x`yQazeMCNE0`K ztA#P-H^ot(a$qJ|H2O4gf5BZ(?4`F~v-v!m;5iKGe;>2QvN6iHbMKB~H5gaJ3UPmK zf#t>cV)`Yzp^}8a;}etlCgqeb2fFq1y)XLR$wF14cQ6otQe`=@=1kO5ltEz&4f6du zt%FPxen@TP(&U&sQx{-Txpmfk(ZxPssu%-;BeH1$*u*EgLqkzC0at9RYa2ei)70lk zQ_g!YZNVWsjCWG|?eYku2daTmNsD#TtPYjF8#QPjR3JxMBKWWbWJL`|xpc1>XB9rA z9rDSh`^MFw?W+nOF<_ks-ov1gh%;`E=CG~2YPH|N=p-Q{%stA*Qr3jkEC2pc!-<*h z*WhjJGWOZj0j_rwutMs{tkH|-c~_pEBj+2krc9H3eaGCa&4$Xg^Gh^G>#%Jw>-Rd( zD|bEm>Vv2<6mm3e0O~$f=}75H`0k-KZ&zV-dX#j zn8u!3xx)vkh3Z8E2k@6Lb;VDRFq7(2xiTBeV|((U`}4^GmJ^FGyI4P(!wh>)|BeQi zI_1`A>((+>$J-QrWvp@|b!;+TBGxJ9?ut93-0}{TDDC$8NkrFleJ4|0;z~82^A5%g zjb*tVpXcqW&YXPQO!7jpR+stWDymbvTK+n&kA&w9A5F@p$(=Bb%G#GnIqzqcz7`~X znp{TvawXHiRuxMp_n5(`hdQlgKaed{oSEh8jw4SgFbMr1Y9dQutZ@Hk^BIX_uWUmx3_&4R8OXe<{)D-)HF+?fqI^uqhrG8|Zl$~ty(KzTojVt}j z|59MP@h%P)*}f|UL%`Y`V%eilS*D@Dz-;)n(R!EfHFqq6T-YupL6AUX0+bku<(#L>x8yVj`JxVZu3ZlH14!FsZOX?_l$>{t}h2>OZL{XMc2}ca@%rbB9 zZ^=V&k*#NXAWWM zmw)D^{PEPRgqPz(xxY?C{`DvR{CgB6SU3r-9<+X{LVx#<{P`pQ`g>mLPmek8>He`{ z|K+s&o!}dI?gVC=UUhKowcmPrfBpU%7C4e!ZuR{8tNh!)t@l93n9Um#(F*^q#s9DO zj(qDlAq9%O@{-Z--0Z)`x}#5M-$g@T?_YV7)AS!AKUAbfKgwzU#^e8z3u<{ zi`>`8&~}XNI{I{eZ@v8a0l58u_g80DOZ-2){C-JTKJ2^(3pal&0sG5SK<&Z%o8Zto z_dmRRV>(zqByp@Zm;Ppp{nwNF^#i7Gf%mr>opbVkc=_!36sY)f62zsxe_h&tx!GU* z3Qi@wzaPyC$Nz_y|NsB(AINba`^W9O`4V!j*5GA8!0t(vqDoWDOEwv;7{17)@E91q zZx{P=s3tffyqPavQ|W{8^Msuj!8CVf`$HFOY15m2MNSXcc*5jdK zi=GN+$IdPHXTR8Nzlh&|+ySZ#E|NTpXyfS7-V{qP9WDuDQRnL&K^O?syjhXpcahx^ z%M)GI&45%*juPJ_`g3(u+xXs2(Kqvhi-!Mh{dD_g#{D2lxeu08`Y$`!mVsoP&mj9g zVj!RhAC@eGTsqORp+)a#uFgwX7VW@>G5`Q6dOK*RpcUX#iGp%Gan4Wh32mq%9~P)h zR=v6^N*1j3a_Cw-T$o$TwZK_x#cMmUfdl1|y}NeHdL`$f1ZlRCNcx>O@f5X$_ZVYm z$hL(`6T&S;6DF&E8%1Auh>*c(ky$)~;a{6gMmcNKOC~%$2Z%ve7`g+)sqZ^>SHi zi%gqK!z5n}e9P+u=sOp5(fsiYw;(~dXZj$4-*_X-I- z7CKV`cv4(OVEX%zElT2MUx870{N*x6EBa~(8K+{ox|YD`VG-Ix7VPgy0}7r+<{p6T zYw@Go@lS;j$Z262jVkM%0;{b0Yh^2Uc0qxe4!o<)9}@w}FrauxLEw2h zR8c)y>ij&QWP9$IkXZzrH2--g0vi`lgNaG7DHfZ*Hc;9+?YyZ2n{5;RnL20JZfmtl zsz+7_9!GJE@&v&6X^(pVsl9;l z^?BrBB-rJIAq*OJ@F=do#}0UfQCF(eE9)~Cne^cS79yRCxzqyUcP>B`k}ELmrm#!# zAaLRX{e#K+Y`YB@Cr}UecTdNXUay;nhkRvGn~z(1Yd!lW@?z{(0?PJbH99 z`jQl?_h!Dm61^tUQ6UuI3O*0PFJ@$4+pUf#YmtM01Ww=<<6OgWEaf-KJX9h$Dm?yUru5MQhXUnF+SX5vBo!YI2_@-vz=UGyxzIb= z6@{cMMk_+m_WJx#MtK`qU<1vek0VjQNSLadL4O|_q8}*Y%p?R#pyA!{eWrC;*BN@P zrdCh88mv~qRyIetm2MiMOV6F=gG4~U;RbvIPAY`6W;8is~B}!^Giz>KSkWkp+qmA z88cW><-0gg@Fc0vY#_fuwp)L5%;cUjf6_FwLN<-3Z{I>Krq&o{EaEfDeaT2#j^`$q zgq7&6YQM#JitGY@2_;Nv|bwOhrl<_2gAufL#8wPY#r;+;KeWm zw36&Y$P#ZIA(E=tnOA$9GyQOw z)K>m%@_p8KpkNfY4zLs`XQ`E2@obyC-OOy8fHu&?dpgW-FYhc@bh=lCLC5XA`Xu*> z*$5I={l_V>Hc@Fa&cy7-vvc3pphY*H?7F+ivGwNo2?oGkmRj5|)>B+G>Q>Cv_Fj8L z`y41fJq1nV$nnB)n%Y4Xn*@xcjIN=w>`M;j=@}}jBbI^@n(?=&j70HAb1KQSKKfld zAwC0Ak|TuH=>RytbZqol+AXOB{DyPds3J}?aTK?NPz_KpBrV!W&ajxM{UmdLUD@OJ zj(A03NZxw52p3KxF@o^~zDsZF8eot`-4MBnKFLx&Hwv*RciG>NC;Sf5t$^c6VE2I0 z3FpnZMc&AJm~_LWO;{HO}MfuGkfa8L00d6KsPEGcn-^YKj8 z+86=B1H_JgI303FPr?u10NpzUpE8G*CIWCUs#iFz;2*2}LhgA;>LPz#^;4aoPs>a4 zOMGSL{DjgI+n-j$Z~K8jymrKC?ctWxXmsQ)y%K}VX+mu*Qi(4}wp~Hg;LZR+3gv&{{(1?i(8`txzuD^e=TEo4f?1iUjh||T(}&WTxgC;> zRH`nx$6#h@Ots5fUEUHB)!r$y^t5v;DWgnttT@hN+N@?HyuiNBeHV;MbYF2wv`qIrhR!8G(LSHcZ!^r1G|b~n%V3@A!vqMQ z=wv!|e76C^+uh{2vP4H6K;mAd<29YSeO0(p)#}Swiwts}rnScN^M~oC9%a;jpVa=5 z0oO@>t@JL^Dwbk*Kl&3CE82-c{5x9$R(@wkDqUSUo|VnnE)8*E^2@)IzN%k!s_FS9 zTs%HhX1}3~4y?|l+ers+Sh>rEKJ=!3Fd*>DUltv!a(|c=;&W#bD-%u$`nL?9_8x>X zoOrz!@@z$53ZI6x#56yVLQ8uUZL5C!(kjrNo(;|9U=m%e%G_4 zo+DDat;}N;LO*)vp*NfhOq~$`6$#jCF0oc2VE0qhO*g@;ri$14TZENN?OV|GcvCzF zndI>7M#Y4x4?z+Wkl#2|o~l(Ihtl9C2bMDcnGd({37AuUE15#|T!0e3pyia~(c{Fq z(59aQxoM@rn-xen6yHg|RiRjfz&STbX#6l*&$CZ}NvRo)zmZw6$#JE3qX=>*4 z_mK=`4jD)KP=td|&+KF1V&{CK6mp|rES3fJP90Jn z&DAF?dvc>A_5es?S^CuZRqc5RAhoS+vs#W`OlrU0P-dB9wm#vW;G)Z8%1P&xV z1dz=bf@Qj86Tu6)k0zg1{XDU6vulUyw!p#;6--HC4uk5V5KyW6Zy zH`~m%T`AguSXkAp{bL>EmY4J2QN(ed(-xF^tcu3xSLO=p+IuF_Nz({-HF*o@8}~Bs z=EdCm!kLvX8f`?(s{$voCqdx2ym~CI}%1xBJ1B+p*Nb3l)hGnM_7b-EO%yk zf1X~c?Qb@tdM@ zc6}4D3A*>X0cwH9{%vtWhs00Nb{bQ2iBp$|Wdf^%G5#p-M?YSL6=ca5nW}DGUW>O! z-nwNlin4d=%cd0VjoaNcx%sBU0}sNOzUd0vguUE|Is{^QNlLjs2_niK?zHUs3Vbet z4_th|x4bSw<{W=Q9K94-$&+$u?p@9R5cm!Q|3tv+A)4)pdEf@#W>hK8PAjxq)l?t$ zyD%>-+FL&fHZ>95klr%OV>*En!UkBz3XsM!OT_Vhav;7cr(qom9a=>&8qCoA>?4=2 zFI5UP({uA!A!+E)YGy;#ir8%q>Eizgdk8?xaO%nchxi10`x>IwVR}vCH z-qdsCO=*o;g5}qVt$hGd2BEiP1_CPcXgLM7-eq&|si>@-l9bf7&{m4~fj?LEW#sNBJwF=qYgnR2`9GxJ zK2Qv%c{n7BR&4%Q4Gpn2XI6Mf)n|13L>GjD`NE7v;fn>kfPD$Iv~;y%?IZ0>_Y#6R zSD^g`n@G4V)#J``m=4VfWw8L%mnZpE1)Hy?NW0+z37=Cj2p$Gclpw;ePDYh3jjzx{ zKHJKj+{vv+jLTPma26av1W(?$zz^xB$% z?P>CItlEy+kMBj5`^^$1Tlwj1J{C~?tEl>UA%*H7bg+WooZ~|d7%o*f$t`tD7Dmbk z@uPZg^A9+?La|*4jV9{Sz#cY!6?N@=Jvp|8EObHwy~xpet6!jru> zm%q+CgP0GY=$;UFsTh!TBp&ADt*Q(&ihS^(?(591L#AbntF*HG;=JSQuVpR0pxFpq zP}ynDP%9reK}ZmY;EeH$Bi*&Y8eA4~KhF{Ycqqx#i0jsR`P$evK)Rp(#Io9VsYDHX zu>6qR+cXqv$qR7-H50dB`xCnf!Z|jK*YRv^>rKeqH#H8sm9N^BSJz1piCZoN20>M} z0wWhYcZkoYw#W-ap7Nzvvm!~uAF0pZ{-h{MApu4@s@$fKlPDUs_K_SN2~6~D#THsc z>aY}>R`wYmr3SJY_q7@Jq#FRd!C`VYsj^n7F^t&=@UQ~FQ3e2h)>O(6^u8ch?qJO^ zu=jncN#?A6U6;a1`3}r!h~+sZgM}Z=os`B4k)g9^2ONAr)z zbf6Q0+u+N?2V`GJBC`wmLI24=evbJ*A@rM}tT_jGwEk}&F|$W*;11zO&jX+GKYq;_ zRycwCXD$E7LV>?nOy&@z`W|Nw8oF_i4f-EX9{Cr|bZ`P^CKUepbvfwi=;#BMa#~V) z{@j6H&wo65_#skXh~Wh2e;i@^H%Ux!|MXTK^p98M-~_%1cN|R3^smQ% z8q@-bjmi`i(83?|qW%5nA}_-UT>bJw^PmU(PoFC&MP6%fzn|j&%U39r5>DW9c;UgY zv%f#|zk16?qLqisyZ8UP{tnMS1F~*$g8o`Q5Ux5u-U`^jrdn)VORxbKQ}ueo9njfgPu$|`*>iKZp~U{=M?}^idgkgQsFvOoYz5PB>vm^q7!0(;@|S?3 zuCXuy*suOK=IEK(T&O4nZHLcxdd%cpGpQrohLCr&{@!!q9`8!7_x$Na{|(RopYMzC z7q|lvmxbg0yg~|m?VwzQ_{<1SSth=>J|Io(i@)yps69#A1&ENPaURkCAaoVC)1)>4 zx4oYkr~00Nh?LNG7g)N!g5S~(01bmdtI#Nt&vHZpbC!23(Xqq-9|>L;UTC5PoR2DGakLv{Y33AW zeniTPgI+^eip>yGH`M>kpQCP49R*vLV;l%+|BKX(lSJ4g$QaZtcd`V%p+isBS|MRZUkl932gtD^{WV@XfBK69Q*8T&;|96++AFi1c z^%nRu-X8}HZv=PeaO36WmX`TU$=9o4oY5}pu+VL+_p zi*HsP$~tOiBQJ?gs#XG~XqirX!K^Uk4B@q1Q~;?ZIoU2VHNr1t#S&1|H2v(3F3m+;9%R zl8KN(z$;h)19i9SVw`~WL(=6YRYay>JzQDF$(FHHsSg#U6&WpGA%bg{$yv+K8Gu4I z280tO+H+7ND+|Iku;_mIJ!E|_KuZLuzJy<(IIR2h2H|`Ps-#kI{cd?9kYMsGn%`*H zV1$-ecXCl{&k&j2UbfP=S$_RmyCAnGl-@Vv{&M-o7>Y@M9%znWYakAOJbp8a$78s-fBFEZ+K+fA zLM3<@has(Hzeqr}K@~Y2mEmd+^iibVffw5F?tMxde-C7fD?*kf$@Z@oIiL=5| ze;|lprx5A9HR} ziHsfT7f!d_Bqw;m{WhozJZIj=`e7O&q``5n?^7DX8w2TGi2g4Byaonb2Ex3j(O2DN z#@v`9N6H-27~GMph#-zcPwT|Cj(1p4EcvGx3VmJ zLTLHXkDO8bUd{W!*C`ecwngf|5xMdG7P^D^za*k?Sf9+9hN(i1U^wT`2*)_)16pjNc*(P+>LaV7|p*v<<1+b_EoR)*hCUEU+q67hxt?+mW`2Y-t%m*NH zrj8sbcg}K#t(1D)dZ@g}TAl_Gi@*Z>I%^x0LuHtn-C$DfJe^NpsK>Q74U^}dQ?cV4 zNvcL5G_&}AQ+WCuS65nOF+V9T!@_JG(eB7S&;k%Am{-5FooGg&yi%@LgX5mbCKMZe zz^>|x?_vsn2ZU@$&t+INtRk523_x`Q5PxIBDi>;(iq!-#D(rKON&V51!F`MiI4Stl zP|>iS)}ng6Gll@Rdp~e%0TE+t#MbOI5}kDp1z&u}eA2YAdZ8q^$;(CaYU|qlTjRbV zyL8#w9_cM_5ZZ4**ixwyJuDE;Q#vNMX_#`m)*#?UafCcNj6n!8h4F?nw+>-$S5CE- zECaM^x-%#>9H#&&N(?4RfVw8ENU-1NF=Xg5GjNsg}*^E}Jt&{}VxbF9EwdJGiX zi<6qRpL3ydO@1^bQ>aq&3|6NQk{2Yz78NI-=Q>7?6W67?RR* zPwqk_^RXsCx?UQ4z&=r1nD|=6Ig?D5mgC-MqDlcSgr`qwFOLH0_nD}oe)SsTITJC5H*#&b!+5W)6(ZJO%CKq@w{1H^4QW##g5V;}ZEX?1 zh61a5bM$Ear|Rd{7Y2GEWyMQ~Ti4AFkQ-^Y;XKf-OVEn({Eal_5gV^boEj0hk}pCm z%WdTdg(%|OsF;kp6Gb$=OS}LNT*6mTT?}zu02!@I2(=P!%-u+9Y8Q%661ha+i@mA6 zotf{wcuC&2y}q^wNjOItFW0ShIxfOUZkk?usRRNJ2rZxsK&ptqZN7g z48SG93oEyU!yf84Kx0qzY5=OZg=prMCfSRkYeF+Qv^hEFh8_EH@jg!INy=t%jlrSW2ZK z>K734y!{Jm*wAu?)FE!Eyw&lL@cWSQvv2S&|nvtB>EU~-m$If-*pDJ=90Kzchn z>lIl&z@OU`d3^p`BD4!cjxgkkp` zY+1-JF}VSzzZCx(%r87`QhKrZ(vwu`buB>kVA1-i+aYMLR3*I;pZ%)2OY0EPTTe7! zzy7BJ__tTtH=cWGq(QG$TrT|hpM@%pm_s3(qMSShu{z;#Sgc%RFN+hxjil7g>FYtdL8L`pcbYkoCbT# z^xO8Jl`)m4tOMfUHhQWvc9OLAyX3`d9#u@wO93=|tn$+mN{F%9TOXi6nA_kLy9%|Z z8-pUUO84FR+vUF57+3c3=wv^h99h=XRIB&4g@CnKH@=?)`?;+ zjL2dzLjbbdVn84f8{b33FoHn)z?97YMU~2}vJP6Y^;z6xjj>*HEt&}V00hIFp|>Fq z771^YlZw#XAA!i?!oJIGWOTu0q^d$hLo|;hA@2QU(HqRw z)4aN7KwT(&?9-OhON6xrN}8`+TDfOK?R;Pjtq|OYgq`<4i%nuRi zOYgpcdTVkRQtixfxspikg5OLtiR5jRoA<+niY398~g4;Qklyt>>k_$1rP27&jr$Q%x51$LFH2a>7}9;$YM#UIgCMcQw+de z*@H1-qo{9Z)>zK24OgQo=B`Cj-sR4(Hg7v8v{H?=qv*?(Ytxc4zyg6EqJOs6JZv7_ z(Tx2lxCR7WKd7|k{X!w7#XjL;VQxSA%7fdPKfnmauWbl%SLuiGBF?jf&c9m|=00?9 zK#GY$WC3h$wJS^hb*rel*oG{5E`OFh*mn%646qPAGiIo(RdWI21?Lv8 zgN8*2)b+utO#gPRLl!u8JDH7v2OD&MUc@xwvtn-TM13^j%AA8aDkcIl+23Ff2R%6I zv2L=v5ggAi_Lr616hesJ>1~-&(qE+MFifN|{X5!pTB~KvHK62IGUB65PaPVut3R>m zhUB%I`CO~3Uv&7@WyA~Vtm@1u54{N<7S<(V3_G2?OfKYsqWKQiv2MC3hg= z&7K9{wSfg#_CxG94K0HQ7`ad^lnU()plV`J+*X;+(#U!UWdr%|ZIUqkvpMgk1~bG_ zIPkd9=UDz(#`;u|_tGPG#F8cB1~Qkx1R*cyE}hOrOt%BzP@jI7v3mAk7@| zbS3&MOJKtX%0}N95r>MouMo~b22^L*wcIn%JQe`luh$q%WAJ1Pc>F)_e78oB=Xk^O zT8}2vafse?h~or_5!okL=(@s4^X!T?veeR)7TrLkooMFjC1rTkSrO<=Cq_cA_u$g* z_G;b({q08xIR5em4O$QoQND%s_MPyv8&Yb2*i6Xz}9L*6EY8RQ;xmPHA=8# zJ#7Pd#M-An%=f?FP0sQ}3LRr&2z-XLijj>(aAM{lj3_}EV4_AKMU6ce#XY)EC$W2o z9d&wc%pFmpG4NKaXQ=W~ry0lljgNiaf#n)8(vxX+5^v}#kD&rS6(O!vx#WE){8-J+ zXUzllxHU2-LAtaKGR)Mk#jc>C2s!KP4bqp#YaT>j9$a>szBdJGlVHDbkS&$KXyOXy zElBvX^auzMIW7j!%$QD-v+l?V##os>E|@;}a{FwH5~3M?>~G>Mbu6nZV?f`==6eq5WGq z%F92brkSm6;&J=VN7Kf6NX$Quwn#bY$yNTP2At5A0*wv_D~1jexC``W)I9^Q^Vh?H z^(=qdb2A}dFjfx$_-0T#aO|bB@K_DGf=ZGNjOtXWpCm1Ho#|*qHb2B)UHgy;-Rl9P zBqVJnn_oyPv<_@8752dGjR%qv**^Uy`yZy%QH5ySHvlKlhKu?Xv#DW( zn}sZgRzz+I-SDj*Sy;!jA!OnM2B%T^G;b3M;0jL^*f(Ahe(qlU{UdGxN_EB4_j<2H zzY4Ey!>#WWkGtdM@VKRNddlIz!R}-hbjpj3qFfP;%`|!oRDehre8Ppq5>Lj4KCaS`x`2*|}K~o^tY8Z-s-_{E<@;PyFsQ<>-96IK{*(|CbupgaF}0 z^NYQ1{LLJy`!}@rgN+6CiuN5SsdK;=Sqzr=be2_%g;!8MA<{g#IIVnL>EGF^$lmog zLKMpT^~1GY8_ILk{Dy-Y*b!{qIA+$$$urEU29zyOCu$UOUAHoX#yVjF)GRh2c)iRR zC lc06FF2%SC7xg0O*Y$^tBl^FbDqhog0O{k_jK*DGk*Ss#8gTkz&+zjalDdM8H z_0mY~#~_u1ux7sTEC~JS%Iw}>Vv8kM1DE`dGxA?l{(90fpUBB+eGNT?`miV*cAzf! zzwjcdT$-iBcq)gSL&w7Z>u>)j{}q`9)YVjG=a1U+2L?p{{`A}`y&-3$Nq zBh3;BdDm8Es(c`E@W1|$4i@M@7~Qgi|2GE@`!Nb(e-j-W(f_+wi1^(Nc!xxmv}ykJ z+z!yqOEGT~Pl}(e09!k!b$gPpMxNmia9WR`GAsaR{5$JT_`MLZ9Tj0fnLdzja^w4g zMz%I)O~hj@o!78Z`1d*@9OqX15Sj%zwS-=o!-Ma0s+7L6L@U34SVQS{Qq!o&DtZB+ z_sz35=P}q)+bqD{U0|q$Zi0w24`|^w08;!pSmp>FzD@P*?^tl1l>mo<*!1T?O%`rh zg@BXyduoIjZA(FT(&W9fEWZr_ZS|k1-kuYB9G?K`-7x&cDNe-MO2%dy?=qW_R_jx*v8{+;9~un#ALg0?N3ntB8>F4drNu_+_gD}`OQ*j4qDP}@E<}1+cYX*%^b9UY z0Am5E^Z)d5@_lq6GUKoZ(H$oDNlI=oADnuXm0SLe=o-Y*-3OPGLIF}w5) zG$u&epgyWa)WIdW#oeHk67|}&dm9Ha2HC(}a%d}?^ukyW?jP&vk>0)ta1J^ibAW_D zLAN2kd*b1z)8#G;R!C95;Q^BG4Hz>czIpcgi)mN=1r8qU5$u=?v{}|5s8!~$Fw8fW z?tSb_KvZ}4gUApR)@)5@b#(R@ao7=C2>yye0W$I;%7$3hTWAjt4YCALY3)-_6?ulA1dwCzfN0b@XIK3wt8NRf}cz` z*T?XnoF{VtOJ)OeB~2`WK7oS)(aG!MeMg;B$;5!={BUnYpjV%&O6DXw>%~<4*jCp` zfvS;Z#M5{VwWcDZTR9TG_XCevq{+1fci<3A6Y^fZ?5?FfT(fhuh#v|&dq!gf6E|;K z&s7rUveIK&mHh_2YCM}*qAJ7I>Qo6JTSQ}9Fc7g+WlVseDmYv|!5tZe^y;mGpb=|d zld99g`~Ugkw&UZg$l$vNu{utvqTKPBn1VFLH>X|;L0-jUn3PIOZv&4j3JA8^0&R9^ zYOt9IdS;oMl9XLMC9k3B)*T_1@cWyOx&`3W$SVuBsdf4j34T%!$)>map}Q%01M|zf zsq2(7-r+=ZT|}K{sR=6u{P6HPK00>ttX&)rywxY5bGa7m1)iM+h~9eXd0y)F_Dhvj z=iO;|W832ukseeO)Q$6qhj+6u(h1t^;|u;&eYwX0*e4JY&6%r%kSD7v>RZt0 z&jWyI--)NHK9~8l4m?Pwc<$K9C^z1NhBX2ao)=~E++da`OHW1T(#$U1_>T(FG2!P` z{IK@>+cW^moTxm={xC`Gt4Pvr%{B)v7ttiJT3kRFSD?}n*{TqcHq=rJGG)L72A?TJ z)NVHE-xEZIeyl@4s30v`D+nM}iF{zh%3n-(>Muu-(sx&YLOORAI;gw+OoL$$b_-`W z!w(^ax28s_DuRXp{YbmYs==N`{Xh%k>*qul9ep%cf&cPa{WZX+WUtdv$qlufaO(J~o$}D@3m&8RLV+3#E-GQjAKNtxC=^V?eSFzRVYhZW)FBdzZT& zqXAeffJpVYyzpu9rH;T(4z@V=#?^=4^Hoyi8lA*uGTyaXA$);LdBj&a+!jz>KcNwN zDKG@kQK#B%C!b!bYe*KMne*$vMe#g7)g0^dAtd<10-@Y-j10p0wBglN;T7;RS~en3#)E+GEL)R28>_`x_EQPxe(d``qEJ?~&FHI40^12^dSS-$e4G(|d8G zW`z3tCIoB$%Ibq4Cw-2QlAP6COq0fWoKTP8(=VSk{RzhQJab2cg_(?hiv6UiCXvNb%A||;&o&^0l*fufb0S|sa?f&l3*pjywKrK zLiGsHn#iex%$$a;#~ zD?t;}Tn12z>*1cl*=Mob*#^HNl5)OD?k$st2;Q#LqnluowAw>}TDjtg;1RX% zskn98T73%ufQlEpFl6!pM3UVq(68MBN8QCY?B)T>U~Zo$z`S5lu(`3SPF)?U>E%`c zrtYt&k-)t3PrIz-S>?HfGbh?P0>-YGHB-=j-dJ_oD5zx^?$D?&!{f3yQWqF z&$gP)4MuxaZc^5*0;_H=$v3kVxg=cPW-de#G;}>`%+)aeqcqPo<1x=mS_n?haREyI zs2*k=>8Br0I_@W^F#|?!RDz_jr^l*YJfAo9{A?`RA?f5%E{N>1V?24va>^|`^~LY* z=Sh$W_IR~gEgJnS*)wI4sJvvFKDighlRq8ma+P81pq>nw_fKjy-4k)HKOsaocj<4d zj?!s`h2huD_H#A8>(`i_pY*&mByM9*X%;!arlNC~S&d+glkuHQ2`ShLGCs$D1G7Y& z>N@olvi|dSD)fDP1y(J|7^_>e7V2J-T$O(D&*#mfj^1FRChnU43@K0_8FU6+3EBl| zHbHT|zg@HcNm*j6AwYrVTF-QyZV52*AC}7b>C1Fk-9ZZlA*(#Jv)#lSq&>Y5Kw{CE z_j1@ZjCi4bm2@Zbsh=DNP;6xeU`2gwRtIC;8mP~1q`mbk;Zsf8-Da#m$@| zrpQ{0HnNMjjy~DQw*vsw3Kw*cfCl{V$k1)GV;L=w6ad$3A4j?kcl zRJosfHPtWd!k)QUk*aiV_RtH}<-=OMdEi~CJM6)jDiW1G{hWw&5ASpA@k1`3Yi}!h zB%vbE&jGCTjbHIHb(mF70cf0Fm&^_Hx*GLfS+K24_8F1eG}!+@d1pC6 zcWvk%JDIc0^3ClJ##QL@7sKI_<<-+Ohu-L1t1Atbo9R{K?_z17b>^c3Qxe0nQSTmT z-OH`xjnIt4-7}p1R1yXC)l4C%M}WsupnDaT5qnV-drrDzbTup9HEqW3i8o+EE|Bfb z7&2tW@Yn4&BlG=UDl}U&hhT*Nwu_r&^X*bAJLB|@)E?hd&3!1X04xk~*DRwr+0H4e za0~`M2M&DBO*1SD8Qt?e!eQ~qpuIrk?FU3=OdiuL18@(bbV(z)6|+*L@DSYX_X{As zr-&jRVShz|STgw89@s1pRpN?*>BQeFIE1s>%j!0C*;9l4%1KwVz#8!-%nm!CDSw2> zRiHml?)YOUG$>ruONVL2lch{_Xo&8tGpYosHmQK7S}3@z$|~+rlu38k?PHas^md4-;6}^`F*6R(usqSGzArc8EBD_ znOkJ#E@n<&dFDcgmHQFeu^{z{cS$;HJbIXFj`%m;)(=X+FQ^&}mUO6Bt=tt6TH?BaB5ReQ{Erpe`9}gGIhVYGYGqUdWfO>a+l;Aj-GWuap%3S&eVLmlG%J`{|Dc` zcAK-DO`OX?`pO`U(bt620;&b%Rd_}Z7zp@YRl{Qlp__^T@JAd3(WZu{b$wO z%vthXSze&CDF*uKxQ$V2p8T_^qQe0i8r0{H2Tl!rm)t|lVIM}zg6AQ--ce{lep0Kw z3;vJa{tjYC0QHxk7`H(uX(fIIsEnrU$1tO;4&3oHF&bJo?>As}t}=&AZNF|nsY z?iwz^4jA=B&2{54f~K)F)tv=fAF`MWBFT0hP`wPz_Q$%(dGzk3Pgh*=+<4>(iqpYD zx#=26JJx5X-DQ{`si~Rqq_M@kBrf#f+-e#yyuA(y0u(&AEnm414WhmAmbk7X22Q+jAo4Ej=Op5z|$6o8M@*H_sL^>TejWK1jv}$XI)j-YVtrN1V8wxCYvQ-%clD z0m7cXL-1WaX<`VT#jdSoj<*aWDkXDskr2V=ughgSXGSLX)pOmyZn1o`ct=m z!Gu>kxpMv)iNzW+*rq=rasRpD!>oyCDYJ^KMp8eM3S_{<+r>F=j$ z;)k6iF)tgse7jwTH3Lm{k7BB90Uf+0n1nAk%9^egGX8u>E%4CL>KenXn!~9>4E160 zz=7v}`cypQL)C(Xn%rUkL^gZkjO(lYAGyx!p0@`u-T^*Z)sO?dSTYZV>QL1Nbde`3 z3(+F8GbI)eoxIexsSkqvouVNoSd%b|w78LxlNOmx;7Up~?Nd4D({D{^L9|c(wi=ie zRXy>q1<>wSw1JeMwUF`fB4=_>7z`{;4-RctsbWEQOsPcflb~8M1AF`$359^=8h*PWy;jk ztBAb7gkDYkk$%kS_`}(e8xVOj7}GVwTy)9u;e2>;#^)D)E2LxksP*nen&B9dyg zqcG-ho0pC;dnb{^K&!Bt{Pj(~w&pe?)Oz#~xZ87Y1yrwH@3(lL0f{FkWyY+-=+)SH z4YH_@s!55g;M3cSzwZ`(V|rPAf7UD+n}C)$b^UyGUhErec`iQf+U9~q4Ykv1uRc>s zuk+BcMwCHy6Tw$H;T-mi0A??%oRT{psq_kmC33>l|)*+o9ee&q5YS0LyeRS#EA^(anSI=hosI zD`m!5=Y`%q4mvbCC7g(KUHTq!ru9nj2IxyedqE@#W;8kG9@*#X(?OOXlSL1X4XHE1G{~1USf~u!@@T`f>-aC@mvdrS%a;}d-d?q#UFwK z!DVvj-s_}3$^spQdkaVhN8TSKB*m1y+2rqckM!bu4X|Fq4^hdY_Oe;Kj~5e~$Ay_PgY+OzN9796t4hT=xrwo60K4w78D`0JawAK^Wu|>lskL zI(8c*04F5Am@=yGwfU2q-)wE0ZlnL&Wn|v%D?4UJ1d5A1OB-cr5hk=zxl;yNP?n=# zKL#g1A`q}joxOU~gg;DmvEGDqe0{#J^H1~Gx&cx_`U!uuQ*sJQtyzU}Zl|V%E>1!9?g$fO4c7miE2@Wk(4O7U z+D3NdPQY;usGyq&{tz)z4u^?8qudOYUt=tO1CHc?v)o?`hwLr3nb9nzyL-66#fkDn zYeF~Yl`e6E7N@WX&900)V-CA9-Rp)d_F*ZxDea|otE7RFFi`uk`({b8NZ7LwZ>{OQ z^zA5b#~DuJ+4;1Qv);09&dqDe7fJIN=GKt;n1hb%p6c7F;r^t_Z4f{xkb2StYAuK0 znww7h8s=uZA27B@hNjfG@mTp?q1f;{H@@`y`0& zvoR$Im8l)1Tx#Yb!21lkugkMs+L!Ez=x*gao`kV0G6?&Sd+(9%&Hc`ih?H|S{{4kL z5f949`7#$l3At!Ew!YIGKx&9*M#~-32eWLcu1qI*g{}rnJ1bESM`$|jkjCLLuTW1^ zoM~3>X81py$IQNS+DrX`h=3Um>n~J7GmClaICX3L%$dPn;@_m1TpT{2OQ@hMK(QYp z;-}WcthmXww1x_%o>RZrDh6GVVzMC((G%e7W6Z3_R@=*+tr25E4vh87RcU#7?e(`~ zyLw%Bwv#EpuN$ng8a+EAM;g_nM=jPH)GcyOlB<;beB(e&b)bibFF|%eXUnkyRqGbl zjv#izyeHL?$8;rEcKqx6NFQ-NdT+c9lP+I9(xN`sd0qWQmK{C#&ytwW9*t{#DdQxec>1wXufWK4sU`;R zx6cXm@!=fQAF$89cZUb(E=UmOu+wr-Ooh}9Sns)=e^Q;;&y=wJD!0iyp>VkS`a_Uu zt7LJmF`ZoIwEIxtv+qs&eYIC6EVKe_@3ilJ=%^^U=-{= zECoO`Z?3^E-%`S7WZe2v=4EAWFD$kN&p0mna%L|p+y)WQ&nbS`mrzY>u~62Hid}s0 zaWA}bHZyAw>$xxqc>8^@C6RAvF-~^P7~nQ!LqxuAr-{yZXV_JvU$5@>i*6fdWtKspnt)sJ^N_pfJc(eFwt#hm{A?tR9Co;CDu3x2hX>zaEi zKa-;Gn_Q1bxz9`x^{vG$+5cM>8*ZU5@%@}w%a7}Uj(oWBs->;#+aQ}av|gx^+|t@yR}IOikB-8}kzsfz;AW8_gI$Mwy5wJd zc6mv*6kePl+OZL9!8_HikR*NmtUHM;Es7;cp3s->$Va13rdotlLC+|I&cPr?0m{0P z(-V_;#rLo1?mcF{z%$tb9XLG{Ih3?4)3T#Kt(Z@iL1SPlAe%3&bUswN@UAHlUwe+6 zPhsDz#9Faf_!~}&H`h+_vVU~TaeUc7{bLIH^Klo-%e7H+`8-kPg@^n+8GO~aYj?eW z2=q2IJpa@x!X5m~u0oDb(X)yqN=fyzojifZVqXJq+2b^Pn1mYRx2_5;Rg}LHVamFL zoeg`5mp`U7^rF_XL!aCTC4GJ5Hd6^kcYO5eNs;t^!skbGj{1EB!$9?I{b4JHA2ISf z;l>E;LJqKw8Fh|I?DnDLD^$J2=dtiWrS98ISh9u4~!B%B)q)~g?0W8bmW$7)Rtwl1-lL-PKjPiK2&kB!J z^Xp@6eo#(`do4X>%HGIX`?Qt^el^v2g%3%2&D*(^VVb{Pp9!?_L~%#a6-F=UYQlt? z&uAHD<&|GJX4bp!r-V@4*fYP}pGMp{!0Di38io@M77I zZA#R&wly);#9K9S4Xc*oSXQ>TXviEo9O&kK|2^)f<}ek+qfy7{Yp^X`SoZ2anuu7D zoQBi*#XyK=O*)M&od|!6Wpo$a@}d_AaBb6v*;pFOQSV(+%ZZM`#69^a}ILkVH+ zIqfeSO;sLn;*GHul>)uJlXtXDz#ZZ9wQ?z+HC>mWgCP|jWJ#)@iDemUxeV&eS;08x zmx;i^JUZ$+#P*q^q~gl+60&7PN|Rf^wNF=Yr*uF2#TkK{*t>h%6V3& zlef|&)-yO(q>Ry}ENcjvnVRGP^x9!x&vRwmj@9uVx71~-0DTlSVzm2~OB3{)GK`{B z-g{1Oq(7H#F@QLXvU&8LVmv%7CO~pr?i+>V5SyZtGNy8D3c14%Xq(mZW9s)>i{JEn z=SY^BgP2*}RY-xy_}x*0F%be>!Rw;Ozv#J34JoRB6F9|}mwlmVbxgQ$`CexDw%8nr z{wKXKWcZmT%XPxTmrR)#-^0{io?w4n8}b*br(`D74euyuJibb+pSGQ*|DnhAY(9gy zBgI=R9PmdjQ=XsE15;Z8tDL^g#Idc6Bzv48?Xj!TqQ9>uwl>O3;xSJKJgy`25NFR1 z8tRdt$%3AiwU!FG-ejJCMuNhCoradm(v5HVJF^?#`Qiyfg|Sg8Kt#RHQQVU;E(6JOJK>10e$JBdy z2qhoC)oA%D{^1DK+nLyz5qIS&R0LbEF6QvB?w?23~MA1K{A z_MbHOUgtl^{3ckNe|8gY3V9NUTfG02dexg#6x$e3we!?kpw)$#SxJ1oizBz5gwB_n zfLNBgtaP=_XU|bhuOFbx`(++7)gS2lq?5tUmt*oA!Fht|C@d~@p^Ux&$P>#!FU#Qp ziO8}-9t~gY>dVLjf3v+s(lxWDhhnu*YmxMgGUePeMwqSFeQIaccKJ&MnRfSNH-$Yn<~cUsF9PopVMP`6#NMf4 zmqO*zVwfEp=KO+WK+E$Z#O0h3(-XkUod9UwM?xT3_Cs4b}$y?J;oE^6c*N*-r?7<%Eo_m5Yhj88M`SbB_7xxE2wSC!y zV2pPv(mxccc=w9_AR7hUuTL9}$5hQ3U^BzBBSTtN3x2ulH<;Q{G4=MZ8DzgD*5G)tEG}kXa`<+th@I_tdFzch>q(ed9NehW=Kmz*#dYD+ zXcDjSrCX+V%CVeUckXEpKd!>wqSmxU8S%?sdPXcxf;kgC0S5O7Q>$Xo>||#?kti!E z5PM$tI*;a-RI5loTgN(M&01|@S6NJ4-7kp?`q90=M%+)PNnL7&Ly9T&nI-92@v!yi zjLYmbwB1&YQnw;%d+P9RuClVS4~q=$L9TCBqr3U*tL92xg7)9VBIuv*X3;QwU!$En z8s`*^OcNCkKJ?sPtYW6#-8D7hUEV_x5cS?a&;H#0)ty?=A3%kv+>O5tOKx^IlYm;D z{s*y?Z{frIZcWx32Aoywle<^^);)YT<43&LqmJo{|9%zOGhLN$=woEd)Vbn;?f$`r ze=oT6>c5?j^;eC-1~bdngnZvcV>z6@(&(Evg$-0_$ppxWdH}r?NXB1zsd#(QwMO*P za>PzqemIuc-jR0R!)Dw+Hr`*)c_6Fzx0GJ^YsTbX(E%^75QHGp+LV6n$~MP^+#U60 zy&Aqu%u2Cx@iM>jSZMlT6YuL#${PNzTyh60SgVb4{o52zw>gtNTK-iq$D0iO(x?$*Xic|~!S=?cH55&0fV{C6Qx!R63rp1zHo1R*>{>ptm1fqe$5(A^m3P>D zeMjRRjq2i=b&e!+Da{fm*hOA+_dO=(Ju-eM>=4R)g4<~8_9mE281wEBeq_@_c8LLj zkQ=DfQT(Wf#YGApO5r{1H)d%z^Vjjy)(b~)SKE}w8L@8im5XoKG0fzrxT07+J-#?p zM+pZPi0PGS9dms2GoM8t4*#}*YjlhHZVt^RToCR(7pcXvj%T7(`n4 zneI9az(g*_%NX-9x;6`m(w>)7f5tEfY-e6fA6`(l`XMaBCqO4*np!$^r_ zyr}p^{F0jKgPbclJoWBqN9j{eRn7AA8CqX{{WUu6-!6*2wYQmPu$P|aJ>nybk>xX8 z3^{V+lCPdwV!Jr-W;UoRKZnx{Goe%pn4U~IxoxSSMUYo8TVJ*{9nplCxL z(-!a3S~$En^=sAr+e8$-C!h79LsU$+WTn-g8z-!)pH4>PDE9B3-VeWiv4GFWr?jE> zR9Wu;c}&_aFZS5^9b2ynhUK@Ty2k!QXa*ksSl*Jh^)fIZFM%4Z>bHOzUmH%0B+vtO zW`@p^D)*N{ueG~-4tDD!k`)@>Q(Hw6CuTRq&hjh7Xvb7cu;=~iO@^R%xiHQ+CW_H& z!L$0u-s>s)(K9kepVzhcV|a8<$txL)i@6=OKj;3NQa?6m;Vnr+CoxWeI)fHa>CiR_ z;SQZsKf;~v<^5pf^?_Wq%$pP~FRr!I)~=U9ROWPL;;ED07CPiv({}%1V zfKViklRjKzHvKiRj7gN$w_bNE|7w1cX|*c`y1j92d!|EisE^^u@-~!kX!jd3Oxx`> z@vmEp_t_|h^^j@ln|$);?MR~tSK0JqrdR{tIqWXqV)|0S(TZg|U)(_}w2h7|u1%K? zBb>9G{0NhYa9@)N=17@Ms^on zeMx*T4{XEDWo-QVN?yu{pTg|C_l5M33L$5PGPVNrJ5s;hvjSG?PDgB=luonSO! zH}x?EYwq$($tufFy6%zRBaPeV*XvmX${{?#do^yPVVi^2o1Gd;zKA+y@0FKQbp$f> zr-UfZ3ULSBU3jN_ig%W%%@15qHYMfgxJ?%#J?$k}@Tzn@%wO7No~XL=)KgNeY7^$F z8`x^p|H@Ak_jK_(+U2l2Wys1BFogqG&l&6pl5WB**cPyPLMSSSjWW*!Ys;=VT& zuDz!cUp%=4x}Q?LHSQUmAd9`LiCc>L66US(nG~WUK6vH^V`#`N6fc@G)_LY0>wkZa zm1WYmo_BGt0fut7d`>5;IIrO5cZ}o~40^gNpGESAa*`O3Moh4fQ^o*?U8WS64bnuR1SubiWD?1U|GW$e=88$T7}P4Sy4 zywWKGFWB9pUkc3Rjcm2LesQQ6Cnx!Vq-+MBbx@!#ol1?#--V<)e~^`4UHsNty;1Z* z-v28O1@jh{#kb+{a2$1A1gd4Vdu%mv-^-agrJ5cUX7=k<`e)m_r+6W1W3SX;I7B-p zD#NJgs`rZn?#J^N38UQSzT9QFF+etF6ciXO8iZ@I--9^T=YG}0K~sH9{zHW4KZ|ox z*fFOo`(YRvMNK8>Bw;($Sl$VreYZ}mM)_uZe#q*cE11SkG_L*r9(sk*#D#a+OT@d!{xQY)i2GDR)#vHv(Eo!$@ztyiiPFRlL&)UEbTC%>_<-SCfi5@Gh9+-I}Cn~e#b1~K{!jV?Na zFk!@APk62F8AeOtoau;y#Q7J`8cxeTcb`PDHeEyE*IqM6ev2fG%q1gqfcR4@vu_IQ z#Jw-w&}=0Q*SZ(ll<~!Ein3zQlcFXj2C3L2BiZVRX$YIzIdm?Z_V}ed%iT=RMj1fJ zqy1r;cMcSF;uj4f-C{Pb7R{vnX=f`HpO}k~7(rC9%ltqm`91t6vBRO1azWtM$Nma# z9092^*Tw`-l2Mv%lF^`sJxK8y_MS-`nevjGrN8cAcJ}%lE7O+3=B!o?{WjM|j>@=Q z>h1;U2#GKpmFy;4@N#aw#ih#FN-PQ+(mlftOHGZSRpyLy=!#68fb@Rj_958O1Ce>C zXDOc$UcK>WTJ67I@A5@YNgaz^BeQ7XDh*$*bkO#g?xjj`0w01YW8`b3vIr^E zsn~SrjszYuwTZf>1S2Cj9!o3~b`ntSs`9x!j5uR6UIEKjmdm-O6^MQnD1Xi5`2Dgk zCrO8DEeOoYXPsKEG+1=qNHWY&A206nzHU)6f5?chvv-7Y_v+hYDK}(yiTl4vcTkSk zi9rsxnsZ(g2AGq5kI6_vx1is2UyqohWF3jWy;)&4F*cXe`8r_rU=M~HpBUL5#u1>z z>uR0CTY3*oMYDxGG#EiV!Hq9$f6|^h`Wi_F4v65HJ7FCq`Xz!%d4020)>O&p`jIBN zT~^o&jD=Y2Kq2)E3$vkBn9+C_e3w_(Rs2OfKLNW_!$43oI{+Ws)0L8G*oTvu29(Gw z&UsrTPNTFYad+PQh}yY%hFva{hV3}W3r~iY`fHu8YeG@@={Ppof$JkL}=&Q7$)c{{-be~_=CL>B>y{>FoYes zd~R_Pq=cD0%DZ&ycAAad)jZ{Lp^~sDKM6ryx7#uIp=iemI!GHxj3{~{v+Xx0nD$GD zEEs|Y>_!s>tTjJ#R-c7w6*F0b6I6lfi)hb95{Pj|MXTv5RTKtRpca?fJbEbC!&!Kh zf0>S*-j9^^EsgTLS|m@kC6EvC_;xUWmPmhOwYkmOF!o9iA?5d}2P@^@OTNARUJr86 zFAwS5Z!UYiNgqLmuvaP`vKQOMerm*i?~-R*7W1=1W6C%Z%r`;nd`7|J&aapGajs9i z8r~M7d4H|u6!7C1+)?;AMC7&+@yj7-kH2g(>SxoWvu*;rZ=@Mte@jVi#Sc)?*9XuV z()HvTY-c`Pb=dMUkY5OObgaP zi1{e^T+^_wZ-uxLj<_xnVz_m08h&~p zOg`~BqH_tv8!7qdJs)$w;?cV0p8G#Ztv9duMX>y-uYORDmJqio;rk#fK`^_)a{z!| z(=+~)q65dS4Qf+yZ!L>KJ>Wf*Sa<@@=^cTSzeL{&p>U?1;9|tOJom^h1>&b3@}Ft< z&1-@6>Sn4ii92XhVW5vG!=-tdiIM_C(}DY5NK} zuDp3+Wp(1{F-qS(Y6|l6#}{YM-Tpl1LoU92uJ}J*%cn7)lLODugEY3hbRQWcDhw1; z=GWsvzuT@)#oIxKS;Kni#8sKdzs~#LA0pj6p;c~bH_kPWG+1V5daVY(q1i<9L>G$Jb7R zwhi3IonxU_TZutifA{8rA%^3+1QuhMhSV^5)*7&ywi}kVmC)`R8gjC&u~74^4KXH+ z`5t3B-o)gp&m#=8TacBdy}Mh$f_#uM_Fu~;<_4AD5Gb^g4-Z;kN!5bQSbqIUvf~;! zfUW}I4G`XIBe;I+zYvapIx8p&|87ThIYuDdni?Q4*0%FBz|uwUfi|6ll@mwy6xm_a za-gLGONvpAiJIFQrqQIouzupBtBcDQ6HtX#xE?!w?&;ru)1NOjj<}c)6QD0auCDL` zhFQ$*VE67PFA4K&bJ-1OzZ%;b13nIZ`Jq?0gz*f_m0^hJ)7Mj8PP^Ae%eS_GtUpVW zYTR|-qO(i`Kx^iI`TiF|p7|38`#Jx3?*DliK1;DZ`OUZ>O7AASmZh0@QaMNa%(Jkc z-0IIFjt>oqC*RkTPfseqh7)WR%P2^4tvNj+A)^(ikFZ>sytPcVjfWIDqO&ue9vo(Ds-c?axYzI!A(<$Y6)L+S|D7v%svmLTbz>ULD2dFYlM@@|RPa6b5 z`G1`L|Lb7>^SGp_TaoymP|yGx(?^u;j5nPlQt*4fYUtgWR1k>h!PD3^`UGby7 zQvqv0>V=&6YWTlDL^y}`5yJM$iE9zn36^Zj(amw&c#`Th;% zF@X_Hx>g+ZzOyUs+pWbqJMW)AcRk8QM2z~B^`{361yh+i7(GNvXgYn!Fet6pPNVOj z>wEQtO5B&g2ZXR=U{MODGR5cS<_dp287)!!w|&%wi2%kggdY0@>&VbllGv&L>sJ zNDwr!N+Q1n(VSm0s;a~rz@YtKH7Q-Q7EAP{?DqizSc#_KFTVqL!cHzMtp}MFPPlu3T8@v*I zEPoXM8$1Moj4s+@qhQ3D*EzO`x&6O*PA)MM8*lE-_ZOK!4(eDRWKWq`Tqwi}a2+48 zcIqmX*YWz8W^l8txH!4-8XJ8*YinL5&ub=K;wetepkL2TPc!be@U1j{Zyz&Jc;Lp8 zi6w3m+t%0q^!tsBq9j}B8PSWO1D^VH;vPN=;?Gv_BGSND6)}64<;>@{8?z{mi$&|FJ%R z&by)clIfuTrAL$Lb(gBSk8>U$&*(LH{Z*?!FyJ-|8vEs5wf3;4#U$nMK9dTy#Ww}w SmGDFG>()&r*<2Z8zyAY9+GW20 literal 0 HcmV?d00001 diff --git a/web/README.md b/web/README.md index 30cc40717e..66d0c5b57a 100644 --- a/web/README.md +++ b/web/README.md @@ -82,3 +82,7 @@ Take Chrome for example; 2. Open the developer tools and go to the network panel. 3. Find the `GetMe` request and select it. 4. Copy the whole value of the `Cookie` in "Request Headers" and paste it to `API_COOKIE={COOKIE}` in the `.env` file. + +![](https://github.com/pipe-cd/pipecd/blob/master/docs/static/images/play-environment-get-me.png) + +TIP: If you don't want to step up (or don't have) a PipeCD controlplane API server, you can login to [https://play.pipecd.dev](https://play.pipecd.dev/login?project=play) and use its API with your authenticated account. From a2d531bb639840e71358221a22276e79d28953fc Mon Sep 17 00:00:00 2001 From: Tetsuya Kikuchi <97105818+t-kikuc@users.noreply.github.com> Date: Wed, 2 Oct 2024 15:28:19 +0900 Subject: [PATCH 36/84] Remove an unused field 'configFilePathInGitRepo' (#5248) * Generate v0.49.x docs Signed-off-by: t-kikuc * remove unused field 'configFilePathInGitRepo' Signed-off-by: t-kikuc --------- Signed-off-by: t-kikuc --- pkg/app/launcher/cmd/launcher/launcher.go | 37 +++++++++++------------ 1 file changed, 18 insertions(+), 19 deletions(-) diff --git a/pkg/app/launcher/cmd/launcher/launcher.go b/pkg/app/launcher/cmd/launcher/launcher.go index ec9e9d56c8..0cdc2e12db 100644 --- a/pkg/app/launcher/cmd/launcher/launcher.go +++ b/pkg/app/launcher/cmd/launcher/launcher.go @@ -58,25 +58,24 @@ const ( var ignoreFlags map[string]struct{} type launcher struct { - configFile string - configData string - configFromGCPSecret bool - gcpSecretID string - configFromAWSSecret bool - awsSecretID string - configFromGitRepo bool - gitRepoURL string - gitBranch string - gitPipedConfigFile string - gitSSHKeyFile string - configFilePathInGitRepo string - insecure bool - certFile string - homeDir string - defaultVersion string - launcherAdminPort int - checkInterval time.Duration - gracePeriod time.Duration + configFile string + configData string + configFromGCPSecret bool + gcpSecretID string + configFromAWSSecret bool + awsSecretID string + configFromGitRepo bool + gitRepoURL string + gitBranch string + gitPipedConfigFile string + gitSSHKeyFile string + insecure bool + certFile string + homeDir string + defaultVersion string + launcherAdminPort int + checkInterval time.Duration + gracePeriod time.Duration runningVersion string runningConfigData []byte From 38d647adf6709d428ec37a0ee437c2cbc2a9b932 Mon Sep 17 00:00:00 2001 From: Shinnosuke Sawada-Dazai Date: Wed, 2 Oct 2024 16:43:31 +0900 Subject: [PATCH 37/84] Add test for k8s plugin's toolregistry (#5246) --- .../kubernetes/toolregistry/registry.go | 24 ++-- .../kubernetes/toolregistry/registry_test.go | 93 ++++++++++++ .../toolregistrytest/toolregistrytest.go | 134 ++++++++++++++++++ 3 files changed, 237 insertions(+), 14 deletions(-) create mode 100644 pkg/app/pipedv1/plugin/kubernetes/toolregistry/registry_test.go create mode 100644 pkg/app/pipedv1/plugin/toolregistry/toolregistrytest/toolregistrytest.go diff --git a/pkg/app/pipedv1/plugin/kubernetes/toolregistry/registry.go b/pkg/app/pipedv1/plugin/kubernetes/toolregistry/registry.go index d10ca33058..1674dc2459 100644 --- a/pkg/app/pipedv1/plugin/kubernetes/toolregistry/registry.go +++ b/pkg/app/pipedv1/plugin/kubernetes/toolregistry/registry.go @@ -18,35 +18,31 @@ package toolregistry import ( "context" - - "github.com/pipe-cd/pipecd/pkg/app/pipedv1/plugin/toolregistry" ) -// Registry provides functions to get path to the needed tools. -type Registry interface { - Kubectl(ctx context.Context, version string) (string, error) - Kustomize(ctx context.Context, version string) (string, error) - Helm(ctx context.Context, version string) (string, error) +type client interface { + InstallTool(ctx context.Context, name, version, script string) (string, error) } -func NewRegistry(client toolregistry.ToolRegistry) Registry { - return ®istry{ +func NewRegistry(client client) *Registry { + return &Registry{ client: client, } } -type registry struct { - client toolregistry.ToolRegistry +// Registry provides functions to get path to the needed tools. +type Registry struct { + client client } -func (r *registry) Kubectl(ctx context.Context, version string) (string, error) { +func (r *Registry) Kubectl(ctx context.Context, version string) (string, error) { return r.client.InstallTool(ctx, "kubectl", version, kubectlInstallScript) } -func (r *registry) Kustomize(ctx context.Context, version string) (string, error) { +func (r *Registry) Kustomize(ctx context.Context, version string) (string, error) { return r.client.InstallTool(ctx, "kustomize", version, kustomizeInstallScript) } -func (r *registry) Helm(ctx context.Context, version string) (string, error) { +func (r *Registry) Helm(ctx context.Context, version string) (string, error) { return r.client.InstallTool(ctx, "helm", version, helmInstallScript) } diff --git a/pkg/app/pipedv1/plugin/kubernetes/toolregistry/registry_test.go b/pkg/app/pipedv1/plugin/kubernetes/toolregistry/registry_test.go new file mode 100644 index 0000000000..92f75f5789 --- /dev/null +++ b/pkg/app/pipedv1/plugin/kubernetes/toolregistry/registry_test.go @@ -0,0 +1,93 @@ +// 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 toolregistry + +import ( + "context" + "os/exec" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/pipe-cd/pipecd/pkg/app/pipedv1/plugin/toolregistry/toolregistrytest" +) + +func TestRegistry_Kubectl(t *testing.T) { + t.Parallel() + + c, err := toolregistrytest.NewToolRegistry(t) + require.NoError(t, err) + + r := NewRegistry(c) + + t.Cleanup(func() { c.Close() }) + + p, err := r.Kubectl(context.Background(), "1.30.2") + require.NoError(t, err) + require.NotEmpty(t, p) + + out, err := exec.CommandContext(context.Background(), p, "version", "--client=true").CombinedOutput() + require.NoError(t, err) + + expected := "Client Version: v1.30.2\nKustomize Version: v5.0.4-0.20230601165947-6ce0bf390ce3" + + assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(string(out))) +} + +func TestRegistry_Kustomize(t *testing.T) { + t.Parallel() + + c, err := toolregistrytest.NewToolRegistry(t) + require.NoError(t, err) + + r := NewRegistry(c) + + t.Cleanup(func() { c.Close() }) + + p, err := r.Kustomize(context.Background(), "5.4.3") + require.NoError(t, err) + require.NotEmpty(t, p) + + out, err := exec.CommandContext(context.Background(), p, "version").CombinedOutput() + require.NoError(t, err) + + expected := "v5.4.3" + + assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(string(out))) +} + +func TestRegistry_Helm(t *testing.T) { + t.Parallel() + + c, err := toolregistrytest.NewToolRegistry(t) + require.NoError(t, err) + + r := NewRegistry(c) + + t.Cleanup(func() { c.Close() }) + + p, err := r.Helm(context.Background(), "3.16.1") + require.NoError(t, err) + require.NotEmpty(t, p) + + out, err := exec.CommandContext(context.Background(), p, "version").CombinedOutput() + require.NoError(t, err) + + expected := `version.BuildInfo{Version:"v3.16.1", GitCommit:"5a5449dc42be07001fd5771d56429132984ab3ab", GitTreeState:"clean", GoVersion:"go1.22.7"}` + + assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(string(out))) +} diff --git a/pkg/app/pipedv1/plugin/toolregistry/toolregistrytest/toolregistrytest.go b/pkg/app/pipedv1/plugin/toolregistry/toolregistrytest/toolregistrytest.go new file mode 100644 index 0000000000..ef471c4d72 --- /dev/null +++ b/pkg/app/pipedv1/plugin/toolregistry/toolregistrytest/toolregistrytest.go @@ -0,0 +1,134 @@ +// 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 toolregistrytest + +import ( + "bytes" + "context" + "os" + "os/exec" + "runtime" + "testing" + "text/template" +) + +type ToolRegistry struct { + testingT *testing.T + tmpDir string +} + +type templateValues struct { + Name string + Version string + OutPath string + TmpDir string + Arch string + Os string +} + +func NewToolRegistry(t *testing.T) (*ToolRegistry, error) { + tmpDir, err := os.MkdirTemp("", "tool-registry-test") + if err != nil { + return nil, err + } + return &ToolRegistry{ + testingT: t, + tmpDir: tmpDir, + }, nil +} + +func (r *ToolRegistry) newTmpDir() (string, error) { + return os.MkdirTemp(r.tmpDir, "") +} + +func (r *ToolRegistry) binDir() (string, error) { + target := r.tmpDir + "/bin" + if err := os.MkdirAll(target, 0o755); err != nil { + return "", err + } + return target, nil +} + +func (r *ToolRegistry) outPath() (string, error) { + target, err := r.newTmpDir() + if err != nil { + return "", err + } + return target + "/out", nil +} + +func (r *ToolRegistry) InstallTool(ctx context.Context, name, version, script string) (path string, err error) { + outPath, err := r.outPath() + if err != nil { + return "", err + } + + tmpDir, err := r.newTmpDir() + if err != nil { + return "", err + } + + binDir, err := r.binDir() + if err != nil { + return "", err + } + + t, err := template.New("install script").Parse(script) + if err != nil { + return "", err + } + + vars := templateValues{ + Name: name, + Version: version, + OutPath: outPath, + TmpDir: tmpDir, + Arch: runtime.GOARCH, + Os: runtime.GOOS, + } + var buf bytes.Buffer + if err := t.Execute(&buf, vars); err != nil { + return "", err + } + + cmd := exec.CommandContext(ctx, "/bin/sh", "-c", buf.String()) + if out, err := cmd.CombinedOutput(); err != nil { + r.testingT.Log(string(out)) + return "", err + } + + if err := os.Chmod(outPath, 0o755); err != nil { + return "", err + } + + target := binDir + "/" + name + "-" + version + if out, err := exec.CommandContext(ctx, "/bin/sh", "-c", "mv "+outPath+" "+target).CombinedOutput(); err != nil { + r.testingT.Log(string(out)) + return "", err + } + + if err := os.RemoveAll(tmpDir); err != nil { + return "", err + } + + return target, nil +} + +func (r *ToolRegistry) Close() error { + if err := os.RemoveAll(r.tmpDir); err != nil { + return err + } + return nil +} From 2ef9ad391d8ac1436f21a49a1781d98d29c7e6ec Mon Sep 17 00:00:00 2001 From: Tetsuya Kikuchi <97105818+t-kikuc@users.noreply.github.com> Date: Thu, 3 Oct 2024 09:55:02 +0900 Subject: [PATCH 38/84] Support fetching a piped config from AWS SSM Parameter Store (#5249) * Generate v0.49.x docs Signed-off-by: t-kikuc * add --aws-ssm-parameter Signed-off-by: t-kikuc * add config-aws-ssm-parameter to piped Signed-off-by: t-kikuc * update docs of runtime options Signed-off-by: t-kikuc --------- Signed-off-by: t-kikuc --- .../managing-piped/runtime-options.md | 41 +++---- go.mod | 1 + go.sum | 2 + pkg/app/launcher/cmd/launcher/launcher.go | 102 ++++++++++++------ pkg/app/piped/cmd/piped/piped.go | 51 +++++++-- pkg/app/piped/cmd/piped/piped_test.go | 30 +++--- 6 files changed, 156 insertions(+), 71 deletions(-) diff --git a/docs/content/en/docs-dev/user-guide/managing-piped/runtime-options.md b/docs/content/en/docs-dev/user-guide/managing-piped/runtime-options.md index a0c0790383..fda06bdff2 100644 --- a/docs/content/en/docs-dev/user-guide/managing-piped/runtime-options.md +++ b/docs/content/en/docs-dev/user-guide/managing-piped/runtime-options.md @@ -20,6 +20,7 @@ Flags: --app-manifest-cache-count int The number of app manifests to cache. The cache-key contains the commit hash. The default is 150. (default 150) --cert-file string The path to the TLS certificate file. --config-aws-secret string The ARN of secret that contains Piped config and be stored in AWS Secrets Manager. + --config-aws-ssm-parameter string The name of parameter of Piped config stored in AWS Systems Manager Parameter Store. SecureString is also supported. --config-data string The base64 encoded string of the configuration data. --config-file string The path to the configuration file. --config-gcp-secret string The resource ID of secret that contains Piped config and be stored in GCP SecretManager. @@ -46,25 +47,27 @@ Usage: launcher launcher [flags] Flags: - --aws-secret-id string The ARN of secret that contains Piped config in AWS Secrets Manager service. - --cert-file string The path to the TLS certificate file. - --check-interval duration Interval to periodically check desired config/version to restart Piped. Default is 1m. (default 1m0s) - --config-data string The base64 encoded string of the configuration data. - --config-file string The path to the configuration file. - --config-from-aws-secret Whether to load Piped config that is being stored in AWS Secrets Manager service. - --config-from-gcp-secret Whether to load Piped config that is being stored in GCP SecretManager service. - --config-from-git-repo Whether to load Piped config that is being stored in a git repository. - --default-version string The version should be run when no desired version was specified. Empty means using the same version with Launcher. - --gcp-secret-id string The resource ID of secret that contains Piped config in GCP SecretManager service. - --git-branch string Branch of git repository to for Piped config. - --git-piped-config-file string Relative path within git repository to locate Piped config file. - --git-repo-url string The remote URL of git repository to fetch Piped config. - --git-ssh-key-file string The path to SSH private key to fetch private git repository. - --grace-period duration How long to wait for graceful shutdown. (default 30s) - -h, --help help for launcher - --home-dir string The working directory of Launcher. - --insecure Whether disabling transport security while connecting to control-plane. - --launcher-admin-port int The port number used to run a HTTP server for admin tasks such as metrics, healthz. + --aws-secret-id string The ARN of secret that contains Piped config in AWS Secrets Manager service. + --aws-ssm-parameter string The name of parameter of Piped config stored in AWS Systems Manager Parameter Store. SecureString is also supported. + --cert-file string The path to the TLS certificate file. + --check-interval duration Interval to periodically check desired config/version to restart Piped. Default is 1m. (default 1m0s) + --config-data string The base64 encoded string of the configuration data. + --config-file string The path to the configuration file. + --config-from-aws-secret Whether to load Piped config that is being stored in AWS Secrets Manager service. + --config-from-aws-ssm-parameter-store Whether to load Piped config that is being stored in AWS Systems Manager Parameter Store. + --config-from-gcp-secret Whether to load Piped config that is being stored in GCP SecretManager service. + --config-from-git-repo Whether to load Piped config that is being stored in a git repository. + --default-version string The version should be run when no desired version was specified. Empty means using the same version with Launcher. + --gcp-secret-id string The resource ID of secret that contains Piped config in GCP SecretManager service. + --git-branch string Branch of git repository to for Piped config. + --git-piped-config-file string Relative path within git repository to locate Piped config file. + --git-repo-url string The remote URL of git repository to fetch Piped config. + --git-ssh-key-file string The path to SSH private key to fetch private git repository. + --grace-period duration How long to wait for graceful shutdown. (default 30s) + -h, --help help for launcher + --home-dir string The working directory of Launcher. + --insecure Whether disabling transport security while connecting to control-plane. + --launcher-admin-port int The port number used to run a HTTP server for admin tasks such as metrics, healthz. Global Flags: --log-encoding string The encoding type for logger [json|console|humanize]. (default "humanize") diff --git a/go.mod b/go.mod index 0e09fd9963..2000936e95 100644 --- a/go.mod +++ b/go.mod @@ -18,6 +18,7 @@ require ( github.com/aws/aws-sdk-go-v2/service/lambda v1.62.0 github.com/aws/aws-sdk-go-v2/service/s3 v1.63.2 github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.33.2 + github.com/aws/aws-sdk-go-v2/service/ssm v1.54.3 github.com/coreos/go-oidc/v3 v3.11.0 github.com/creasty/defaults v1.6.0 github.com/envoyproxy/go-control-plane v0.12.0 diff --git a/go.sum b/go.sum index f37c15fd84..c5456cc25a 100644 --- a/go.sum +++ b/go.sum @@ -149,6 +149,8 @@ github.com/aws/aws-sdk-go-v2/service/s3 v1.63.2 h1:1iXmXy8SJzQVMGvo40TSzBYS9ig6B github.com/aws/aws-sdk-go-v2/service/s3 v1.63.2/go.mod h1:NLTqRLe3pUNu3nTEHI6XlHLKYmc8fbHUdMxAB6+s41Q= github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.33.2 h1:C79sbcbdKuBpBpTDy1MNrJx5/Wii7gcwt0Jkd5QCGNA= github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.33.2/go.mod h1:WyLS5qwXHtjKAONYZq/4ewdd+hcVsa3LBu77Ow5uj3k= +github.com/aws/aws-sdk-go-v2/service/ssm v1.54.3 h1:Ctzev3ppcc46m2FgrLEZhsHMEr1G1lrJcd9Cmoy/QJk= +github.com/aws/aws-sdk-go-v2/service/ssm v1.54.3/go.mod h1:qs3TBNpFEnVubl0WL3jruj7NJMF1RCAPEPQ1f+fLTBE= github.com/aws/aws-sdk-go-v2/service/sso v1.23.2 h1:yzi/y/vKlLyzOfG7pSu5ONNGRxHIgLeDrV4w2AMRCo0= github.com/aws/aws-sdk-go-v2/service/sso v1.23.2/go.mod h1:XRlMvmad0ZNL+75C5FYdMvbbLkd6qiqz6foR1nA1PXY= github.com/aws/aws-sdk-go-v2/service/ssooidc v1.27.2 h1:3gb6pYhYLjo8rB1h2Tqs61wpjRd3rQymYcVq/pp0yxI= diff --git a/pkg/app/launcher/cmd/launcher/launcher.go b/pkg/app/launcher/cmd/launcher/launcher.go index 0cdc2e12db..9ab209225f 100644 --- a/pkg/app/launcher/cmd/launcher/launcher.go +++ b/pkg/app/launcher/cmd/launcher/launcher.go @@ -30,8 +30,10 @@ import ( secretmanager "cloud.google.com/go/secretmanager/apiv1" secretmanagerpb "cloud.google.com/go/secretmanager/apiv1/secretmanagerpb" + "github.com/aws/aws-sdk-go-v2/aws" awsconfig "github.com/aws/aws-sdk-go-v2/config" awssecretsmanager "github.com/aws/aws-sdk-go-v2/service/secretsmanager" + awsssm "github.com/aws/aws-sdk-go-v2/service/ssm" "github.com/spf13/cobra" "go.uber.org/zap" "golang.org/x/sync/errgroup" @@ -58,24 +60,26 @@ const ( var ignoreFlags map[string]struct{} type launcher struct { - configFile string - configData string - configFromGCPSecret bool - gcpSecretID string - configFromAWSSecret bool - awsSecretID string - configFromGitRepo bool - gitRepoURL string - gitBranch string - gitPipedConfigFile string - gitSSHKeyFile string - insecure bool - certFile string - homeDir string - defaultVersion string - launcherAdminPort int - checkInterval time.Duration - gracePeriod time.Duration + configFile string + configData string + configFromGCPSecret bool + gcpSecretID string + configFromAWSSecret bool + awsSecretID string + configFromAWSSsmParameterStore bool + awsSsmParameter string + configFromGitRepo bool + gitRepoURL string + gitBranch string + gitPipedConfigFile string + gitSSHKeyFile string + insecure bool + certFile string + homeDir string + defaultVersion string + launcherAdminPort int + checkInterval time.Duration + gracePeriod time.Duration runningVersion string runningConfigData []byte @@ -108,6 +112,9 @@ func NewCommand() *cobra.Command { cmd.Flags().BoolVar(&l.configFromAWSSecret, "config-from-aws-secret", l.configFromAWSSecret, "Whether to load Piped config that is being stored in AWS Secrets Manager service.") cmd.Flags().StringVar(&l.awsSecretID, "aws-secret-id", l.awsSecretID, "The ARN of secret that contains Piped config in AWS Secrets Manager service.") + cmd.Flags().BoolVar(&l.configFromAWSSsmParameterStore, "config-from-aws-ssm-parameter-store", l.configFromAWSSsmParameterStore, "Whether to load Piped config that is being stored in AWS Systems Manager Parameter Store.") + cmd.Flags().StringVar(&l.awsSsmParameter, "aws-ssm-parameter", l.awsSsmParameter, "The name of parameter of Piped config stored in AWS Systems Manager Parameter Store. SecureString is also supported.") + cmd.Flags().BoolVar(&l.configFromGitRepo, "config-from-git-repo", l.configFromGitRepo, "Whether to load Piped config that is being stored in a git repository.") cmd.Flags().StringVar(&l.gitRepoURL, "git-repo-url", l.gitRepoURL, "The remote URL of git repository to fetch Piped config.") cmd.Flags().StringVar(&l.gitBranch, "git-branch", l.gitBranch, "Branch of git repository to for Piped config.") @@ -126,21 +133,23 @@ func NewCommand() *cobra.Command { // TODO: Find a better way to automatically maintain this ignore list. ignoreFlags = map[string]struct{}{ - "config-file": {}, - "config-data": {}, - "config-from-gcp-secret": {}, - "gcp-secret-id": {}, - "config-from-git-repo": {}, - "config-from-aws-secret": {}, - "aws-secret-id": {}, - "git-repo-url": {}, - "git-branch": {}, - "git-piped-config-file": {}, - "git-ssh-key-file": {}, - "home-dir": {}, - "default-version": {}, - "launcher-admin-port": {}, - "check-interval": {}, + "config-file": {}, + "config-data": {}, + "config-from-gcp-secret": {}, + "gcp-secret-id": {}, + "config-from-git-repo": {}, + "config-from-aws-secret": {}, + "aws-secret-id": {}, + "config-from-aws-ssm-parameter-store": {}, + "aws-ssm-parameter": {}, + "git-repo-url": {}, + "git-branch": {}, + "git-piped-config-file": {}, + "git-ssh-key-file": {}, + "home-dir": {}, + "default-version": {}, + "launcher-admin-port": {}, + "check-interval": {}, } return cmd @@ -157,6 +166,11 @@ func (l *launcher) validateFlags() error { return fmt.Errorf("aws-secret-id must be set to load Piped config from AWS Secrets Manager service") } } + if l.configFromAWSSsmParameterStore { + if l.awsSsmParameter == "" { + return fmt.Errorf("aws-ssm-parameter must be set to load Piped config from AWS Systems Manager Parameter Store") + } + } if l.configFromGitRepo { if l.gitRepoURL == "" { return fmt.Errorf("git-repo-url must be set to load config from a git repository") @@ -455,6 +469,27 @@ func (l *launcher) loadConfigData(ctx context.Context) ([]byte, error) { return decoded, nil } + if l.configFromAWSSsmParameterStore { + cfg, err := awsconfig.LoadDefaultConfig(ctx) + if err != nil { + return nil, err + } + client := awsssm.NewFromConfig(cfg) + in := &awsssm.GetParameterInput{ + Name: &l.awsSsmParameter, + WithDecryption: aws.Bool(true), + } + result, err := client.GetParameter(ctx, in) + if err != nil { + return nil, err + } + decoded, err := base64.StdEncoding.DecodeString(*result.Parameter.Value) + if err != nil { + return nil, err + } + return decoded, nil + } + if l.configFromGitRepo { // Pull to update the local data. if err := l.configRepo.Pull(ctx, l.gitBranch); err != nil { @@ -468,6 +503,7 @@ func (l *launcher) loadConfigData(ctx context.Context) ([]byte, error) { "config-data", "config-from-gcp-secret", "config-from-aws-secret", + "config-from-aws-ssm-parameter-store", "config-from-git-repo", }, ", ")) } diff --git a/pkg/app/piped/cmd/piped/piped.go b/pkg/app/piped/cmd/piped/piped.go index 72290df48c..cda20facdd 100644 --- a/pkg/app/piped/cmd/piped/piped.go +++ b/pkg/app/piped/cmd/piped/piped.go @@ -31,8 +31,10 @@ import ( secretmanager "cloud.google.com/go/secretmanager/apiv1" secretmanagerpb "cloud.google.com/go/secretmanager/apiv1/secretmanagerpb" + "github.com/aws/aws-sdk-go-v2/aws" awsconfig "github.com/aws/aws-sdk-go-v2/config" awssecretsmanager "github.com/aws/aws-sdk-go-v2/service/secretsmanager" + awsssm "github.com/aws/aws-sdk-go-v2/service/ssm" "github.com/go-logr/logr" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/collectors" @@ -92,10 +94,11 @@ const ( ) type piped struct { - configFile string - configData string - configGCPSecret string - configAWSSecret string + configFile string + configData string + configGCPSecret string + configAWSSecret string + configAWSSsmParameter string insecure bool certFile string @@ -131,6 +134,7 @@ func NewCommand() *cobra.Command { cmd.Flags().StringVar(&p.configData, "config-data", p.configData, "The base64 encoded string of the configuration data.") cmd.Flags().StringVar(&p.configGCPSecret, "config-gcp-secret", p.configGCPSecret, "The resource ID of secret that contains Piped config and be stored in GCP SecretManager.") cmd.Flags().StringVar(&p.configAWSSecret, "config-aws-secret", p.configAWSSecret, "The ARN of secret that contains Piped config and be stored in AWS Secrets Manager.") + cmd.Flags().StringVar(&p.configAWSSsmParameter, "config-aws-ssm-parameter", p.configAWSSsmParameter, "The name of parameter of Piped config stored in AWS Systems Manager Parameter Store. SecureString is also supported.") cmd.Flags().BoolVar(&p.insecure, "insecure", p.insecure, "Whether disabling transport security while connecting to control-plane.") cmd.Flags().StringVar(&p.certFile, "cert-file", p.certFile, "The path to the TLS certificate file.") @@ -735,6 +739,18 @@ func (p *piped) loadConfig(ctx context.Context) (*config.PipedSpec, error) { return extract(cfg) } + if p.configAWSSsmParameter != "" { + data, err := p.getConfigDataFromAWSSsmParameterStore(ctx) + if err != nil { + return nil, fmt.Errorf("failed to load config from AWS Systems Manager Parameter Store (%w)", err) + } + cfg, err := config.DecodeYAML(data) + if err != nil { + return nil, err + } + return extract(cfg) + } + return nil, fmt.Errorf("one of config-file, config-gcp-secret or config-aws-secret must be set") } @@ -935,6 +951,29 @@ func (p *piped) getConfigDataFromAWSSecretsManager(ctx context.Context) ([]byte, return decoded, nil } +func (p *piped) getConfigDataFromAWSSsmParameterStore(ctx context.Context) ([]byte, error) { + cfg, err := awsconfig.LoadDefaultConfig(ctx) + if err != nil { + return nil, err + } + client := awsssm.NewFromConfig(cfg) + + in := &awsssm.GetParameterInput{ + Name: &p.configAWSSsmParameter, + WithDecryption: aws.Bool(true), + } + result, err := client.GetParameter(ctx, in) + if err != nil { + return nil, err + } + + decoded, err := base64.StdEncoding.DecodeString(*result.Parameter.Value) + if err != nil { + return nil, err + } + return decoded, nil +} + func registerMetrics(pipedID, projectID, launcherVersion string) *prometheus.Registry { r := prometheus.NewRegistry() wrapped := prometheus.WrapRegistererWith( @@ -1009,13 +1048,13 @@ func stopCommandHandler(ctx context.Context, cmdLister commandstore.Lister, logg func (p *piped) hasTooManyConfigFlags() error { cnt := 0 - for _, v := range []string{p.configFile, p.configGCPSecret, p.configAWSSecret} { + for _, v := range []string{p.configFile, p.configGCPSecret, p.configAWSSecret, p.configAWSSsmParameter} { if v != "" { cnt++ } } if cnt > 1 { - return fmt.Errorf("only one of config-file, config-gcp-secret or config-aws-secret could be set") + return fmt.Errorf("only one of config-file, config-gcp-secret, config-aws-secret, or config-aws-ssm-parameter could be set") } return nil } diff --git a/pkg/app/piped/cmd/piped/piped_test.go b/pkg/app/piped/cmd/piped/piped_test.go index 42401be665..be56c6be90 100644 --- a/pkg/app/piped/cmd/piped/piped_test.go +++ b/pkg/app/piped/cmd/piped/piped_test.go @@ -31,36 +31,40 @@ func TestHasTooManyConfigFlags(t *testing.T) { { title: "no config", p: &piped{ - configFile: "", - configGCPSecret: "", - configAWSSecret: "", + configFile: "", + configGCPSecret: "", + configAWSSecret: "", + configAWSSsmParameter: "", }, expectErr: false, }, { title: "only one config is set", p: &piped{ - configFile: "config.yaml", - configGCPSecret: "", - configAWSSecret: "", + configFile: "config.yaml", + configGCPSecret: "", + configAWSSecret: "", + configAWSSsmParameter: "", }, expectErr: false, }, { title: "two configs are set", p: &piped{ - configFile: "config.yaml", - configGCPSecret: "xxx", - configAWSSecret: "", + configFile: "config.yaml", + configGCPSecret: "xxx", + configAWSSecret: "", + configAWSSsmParameter: "", }, expectErr: true, }, { - title: "three configs are set", + title: "all configs are set", p: &piped{ - configFile: "config.yaml", - configGCPSecret: "xxx", - configAWSSecret: "yyy", + configFile: "config.yaml", + configGCPSecret: "xxx", + configAWSSecret: "yyy", + configAWSSsmParameter: "zzz", }, expectErr: true, }, From c866fa37de1443537cb736af1afc42493e693643 Mon Sep 17 00:00:00 2001 From: Shinnosuke Sawada-Dazai Date: Fri, 4 Oct 2024 14:44:14 +0900 Subject: [PATCH 39/84] Copy platform provider kubernetes under the plugin directory (#5250) * Copy kubernetes platformprovider under the plugin Signed-off-by: Shinnosuke Sawada-Dazai * Remove pipedv0 toolregistry dependency from pipedv1 Signed-off-by: Shinnosuke Sawada-Dazai --------- Signed-off-by: Shinnosuke Sawada-Dazai --- .../plugin/kubernetes/provider/applier.go | 283 +++++++++ .../plugin/kubernetes/provider/cache.go | 68 +++ .../provider/chartrepo/chartrepo.go | 87 +++ .../plugin/kubernetes/provider/deployment.go | 110 ++++ .../kubernetes/provider/deployment_test.go | 358 +++++++++++ .../plugin/kubernetes/provider/diff.go | 305 ++++++++++ .../plugin/kubernetes/provider/diff_test.go | 572 ++++++++++++++++++ .../plugin/kubernetes/provider/diffutil.go | 120 ++++ .../kubernetes/provider/diffutil_test.go | 218 +++++++ .../plugin/kubernetes/provider/hasher.go | 157 +++++ .../plugin/kubernetes/provider/hasher_test.go | 169 ++++++ .../plugin/kubernetes/provider/helm.go | 430 +++++++++++++ .../plugin/kubernetes/provider/helm_test.go | 177 ++++++ .../plugin/kubernetes/provider/kubectl.go | 285 +++++++++ .../plugin/kubernetes/provider/kubernetes.go | 45 ++ .../provider/kubernetesmetrics/metrics.go | 81 +++ .../kubernetestest/kubernetes.mock.go | 144 +++++ .../plugin/kubernetes/provider/kustomize.go | 67 ++ .../kubernetes/provider/kustomize_test.go | 52 ++ .../plugin/kubernetes/provider/loader.go | 230 +++++++ .../plugin/kubernetes/provider/loader_test.go | 78 +++ .../plugin/kubernetes/provider/manifest.go | 249 ++++++++ .../kubernetes/provider/manifest_test.go | 193 ++++++ .../provider/resource/deployment.go | 24 + .../kubernetes/provider/resource/pod.go | 57 ++ .../provider/resource/statefulset.go | 24 + .../plugin/kubernetes/provider/resourcekey.go | 280 +++++++++ .../plugin/kubernetes/provider/state.go | 572 ++++++++++++++++++ .../provider/testdata/diff_by_command.yaml | 69 +++ .../testdata/diff_by_command_no_change.yaml | 51 ++ .../testdata/diff_ignore_missing_fields.yaml | 101 ++++ .../provider/testdata/diff_ignore_order.yaml | 51 ++ .../provider/testdata/diff_multi_diffs.yaml | 53 ++ .../provider/testdata/diff_no_diff.yaml | 51 ++ .../provider/testdata/diff_redact.yaml | 17 + .../provider/testdata/testchart/.helmignore | 23 + .../provider/testdata/testchart/Chart.yaml | 23 + .../testdata/testchart/templates/NOTES.txt | 21 + .../testdata/testchart/templates/_helpers.tpl | 63 ++ .../testchart/templates/deployment.yaml | 62 ++ .../testdata/testchart/templates/hpa.yaml | 28 + .../testdata/testchart/templates/ingress.yaml | 42 ++ .../testdata/testchart/templates/service.yaml | 16 + .../testchart/templates/serviceaccount.yaml | 13 + .../templates/tests/test-connection.yaml | 15 + .../provider/testdata/testchart/values.yaml | 79 +++ .../testhelm/appconfdir/app.pipecd.yaml | 0 .../testhelm/appconfdir/dir/values.yaml | 0 .../testhelm/appconfdir/invalid-symlink | 1 + .../testhelm/appconfdir/valid-symlink | 1 + .../testdata/testhelm/appconfdir/values.yaml | 0 .../provider/testdata/testhelm/values.yaml | 0 .../testdata/testkustomize/deployment.yaml | 33 + .../testdata/testkustomize/kustomization.yaml | 5 + tool/codegen/codegen.sh | 4 + 55 files changed, 6257 insertions(+) create mode 100644 pkg/app/pipedv1/plugin/kubernetes/provider/applier.go create mode 100644 pkg/app/pipedv1/plugin/kubernetes/provider/cache.go create mode 100644 pkg/app/pipedv1/plugin/kubernetes/provider/chartrepo/chartrepo.go create mode 100644 pkg/app/pipedv1/plugin/kubernetes/provider/deployment.go create mode 100644 pkg/app/pipedv1/plugin/kubernetes/provider/deployment_test.go create mode 100644 pkg/app/pipedv1/plugin/kubernetes/provider/diff.go create mode 100644 pkg/app/pipedv1/plugin/kubernetes/provider/diff_test.go create mode 100644 pkg/app/pipedv1/plugin/kubernetes/provider/diffutil.go create mode 100644 pkg/app/pipedv1/plugin/kubernetes/provider/diffutil_test.go create mode 100644 pkg/app/pipedv1/plugin/kubernetes/provider/hasher.go create mode 100644 pkg/app/pipedv1/plugin/kubernetes/provider/hasher_test.go create mode 100644 pkg/app/pipedv1/plugin/kubernetes/provider/helm.go create mode 100644 pkg/app/pipedv1/plugin/kubernetes/provider/helm_test.go create mode 100644 pkg/app/pipedv1/plugin/kubernetes/provider/kubectl.go create mode 100644 pkg/app/pipedv1/plugin/kubernetes/provider/kubernetes.go create mode 100644 pkg/app/pipedv1/plugin/kubernetes/provider/kubernetesmetrics/metrics.go create mode 100644 pkg/app/pipedv1/plugin/kubernetes/provider/kubernetestest/kubernetes.mock.go create mode 100644 pkg/app/pipedv1/plugin/kubernetes/provider/kustomize.go create mode 100644 pkg/app/pipedv1/plugin/kubernetes/provider/kustomize_test.go create mode 100644 pkg/app/pipedv1/plugin/kubernetes/provider/loader.go create mode 100644 pkg/app/pipedv1/plugin/kubernetes/provider/loader_test.go create mode 100644 pkg/app/pipedv1/plugin/kubernetes/provider/manifest.go create mode 100644 pkg/app/pipedv1/plugin/kubernetes/provider/manifest_test.go create mode 100644 pkg/app/pipedv1/plugin/kubernetes/provider/resource/deployment.go create mode 100644 pkg/app/pipedv1/plugin/kubernetes/provider/resource/pod.go create mode 100644 pkg/app/pipedv1/plugin/kubernetes/provider/resource/statefulset.go create mode 100644 pkg/app/pipedv1/plugin/kubernetes/provider/resourcekey.go create mode 100644 pkg/app/pipedv1/plugin/kubernetes/provider/state.go create mode 100644 pkg/app/pipedv1/plugin/kubernetes/provider/testdata/diff_by_command.yaml create mode 100644 pkg/app/pipedv1/plugin/kubernetes/provider/testdata/diff_by_command_no_change.yaml create mode 100644 pkg/app/pipedv1/plugin/kubernetes/provider/testdata/diff_ignore_missing_fields.yaml create mode 100644 pkg/app/pipedv1/plugin/kubernetes/provider/testdata/diff_ignore_order.yaml create mode 100644 pkg/app/pipedv1/plugin/kubernetes/provider/testdata/diff_multi_diffs.yaml create mode 100644 pkg/app/pipedv1/plugin/kubernetes/provider/testdata/diff_no_diff.yaml create mode 100644 pkg/app/pipedv1/plugin/kubernetes/provider/testdata/diff_redact.yaml create mode 100644 pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testchart/.helmignore create mode 100644 pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testchart/Chart.yaml create mode 100644 pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testchart/templates/NOTES.txt create mode 100644 pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testchart/templates/_helpers.tpl create mode 100644 pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testchart/templates/deployment.yaml create mode 100644 pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testchart/templates/hpa.yaml create mode 100644 pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testchart/templates/ingress.yaml create mode 100644 pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testchart/templates/service.yaml create mode 100644 pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testchart/templates/serviceaccount.yaml create mode 100644 pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testchart/templates/tests/test-connection.yaml create mode 100644 pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testchart/values.yaml create mode 100644 pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testhelm/appconfdir/app.pipecd.yaml create mode 100644 pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testhelm/appconfdir/dir/values.yaml create mode 120000 pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testhelm/appconfdir/invalid-symlink create mode 120000 pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testhelm/appconfdir/valid-symlink create mode 100644 pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testhelm/appconfdir/values.yaml create mode 100644 pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testhelm/values.yaml create mode 100644 pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testkustomize/deployment.yaml create mode 100644 pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testkustomize/kustomization.yaml diff --git a/pkg/app/pipedv1/plugin/kubernetes/provider/applier.go b/pkg/app/pipedv1/plugin/kubernetes/provider/applier.go new file mode 100644 index 0000000000..41039b37aa --- /dev/null +++ b/pkg/app/pipedv1/plugin/kubernetes/provider/applier.go @@ -0,0 +1,283 @@ +// 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 provider + +import ( + "context" + "errors" + "fmt" + "sync" + + "go.uber.org/zap" + + "github.com/pipe-cd/pipecd/pkg/app/pipedv1/plugin/kubernetes/toolregistry" + "github.com/pipe-cd/pipecd/pkg/config" +) + +type Applier interface { + // ApplyManifest does applying the given manifest. + ApplyManifest(ctx context.Context, manifest Manifest) error + // CreateManifest does creating resource from given manifest. + CreateManifest(ctx context.Context, manifest Manifest) error + // ReplaceManifest does replacing resource from given manifest. + ReplaceManifest(ctx context.Context, manifest Manifest) error + // ForceReplaceManifest does force replacing resource from given manifest. + ForceReplaceManifest(ctx context.Context, manifest Manifest) error + // Delete deletes the given resource from Kubernetes cluster. + Delete(ctx context.Context, key ResourceKey) error +} + +type applier struct { + input config.KubernetesDeploymentInput + platformProvider config.PlatformProviderKubernetesConfig + logger *zap.Logger + toolregistry toolregistry.Registry + + kubectl *Kubectl + initOnce sync.Once + initErr error +} + +func NewApplier(input config.KubernetesDeploymentInput, cp config.PlatformProviderKubernetesConfig, logger *zap.Logger, toolregistry toolregistry.Registry) Applier { + return &applier{ + input: input, + platformProvider: cp, + logger: logger.Named("kubernetes-applier"), + toolregistry: toolregistry, + } +} + +// ApplyManifest does applying the given manifest. +func (a *applier) ApplyManifest(ctx context.Context, manifest Manifest) error { + a.initOnce.Do(func() { + a.kubectl, a.initErr = a.findKubectl(ctx, a.getToolVersionToRun()) + }) + if a.initErr != nil { + return a.initErr + } + + if a.input.AutoCreateNamespace { + err := a.kubectl.CreateNamespace( + ctx, + a.platformProvider.KubeConfigPath, + a.getNamespaceToRun(manifest.Key), + ) + if err != nil && !errors.Is(err, errResourceAlreadyExists) { + return err + } + } + + return a.kubectl.Apply( + ctx, + a.platformProvider.KubeConfigPath, + a.getNamespaceToRun(manifest.Key), + manifest, + ) +} + +// CreateManifest uses kubectl to create the given manifests. +func (a *applier) CreateManifest(ctx context.Context, manifest Manifest) error { + a.initOnce.Do(func() { + a.kubectl, a.initErr = a.findKubectl(ctx, a.getToolVersionToRun()) + }) + if a.initErr != nil { + return a.initErr + } + + if a.input.AutoCreateNamespace { + err := a.kubectl.CreateNamespace( + ctx, + a.platformProvider.KubeConfigPath, + a.getNamespaceToRun(manifest.Key), + ) + if err != nil && !errors.Is(err, errResourceAlreadyExists) { + return err + } + } + + return a.kubectl.Create( + ctx, + a.platformProvider.KubeConfigPath, + a.getNamespaceToRun(manifest.Key), + manifest, + ) +} + +// ReplaceManifest uses kubectl to replace the given manifests. +func (a *applier) ReplaceManifest(ctx context.Context, manifest Manifest) error { + a.initOnce.Do(func() { + a.kubectl, a.initErr = a.findKubectl(ctx, a.getToolVersionToRun()) + }) + if a.initErr != nil { + return a.initErr + } + + err := a.kubectl.Replace( + ctx, + a.platformProvider.KubeConfigPath, + a.getNamespaceToRun(manifest.Key), + manifest, + ) + if err == nil { + return nil + } + + if errors.Is(err, errorReplaceNotFound) { + return ErrNotFound + } + + return err +} + +// ForceReplaceManifest uses kubectl to forcefully replace the given manifests. +func (a *applier) ForceReplaceManifest(ctx context.Context, manifest Manifest) error { + a.initOnce.Do(func() { + a.kubectl, a.initErr = a.findKubectl(ctx, a.getToolVersionToRun()) + }) + if a.initErr != nil { + return a.initErr + } + + err := a.kubectl.ForceReplace( + ctx, + a.platformProvider.KubeConfigPath, + a.getNamespaceToRun(manifest.Key), + manifest, + ) + if err == nil { + return nil + } + + if errors.Is(err, errorReplaceNotFound) { + return ErrNotFound + } + + return err +} + +// Delete deletes the given resource from Kubernetes cluster. +// If the resource key is different, this returns ErrNotFound. +func (a *applier) Delete(ctx context.Context, k ResourceKey) (err error) { + a.initOnce.Do(func() { + a.kubectl, a.initErr = a.findKubectl(ctx, a.getToolVersionToRun()) + }) + if a.initErr != nil { + return a.initErr + } + + m, err := a.kubectl.Get( + ctx, + a.platformProvider.KubeConfigPath, + a.getNamespaceToRun(k), + k, + ) + + if err != nil { + return err + } + + if k.String() != m.GetAnnotations()[LabelResourceKey] { + return ErrNotFound + } + + return a.kubectl.Delete( + ctx, + a.platformProvider.KubeConfigPath, + a.getNamespaceToRun(k), + k, + ) +} + +// getNamespaceToRun returns namespace used on kubectl apply/delete commands. +// priority: config.KubernetesDeploymentInput > kubernetes.ResourceKey +func (a *applier) getNamespaceToRun(k ResourceKey) string { + if a.input.Namespace != "" { + return a.input.Namespace + } + return k.Namespace +} + +// getToolVersionToRun returns version of kubectl which should be used for commands. +// priority: applicationConfig.KubectlVersion > pipedConfig.KubectlVersion +func (a *applier) getToolVersionToRun() string { + if a.input.KubectlVersion != "" { + return a.input.KubectlVersion + } + return a.platformProvider.KubectlVersion +} + +func (a *applier) findKubectl(ctx context.Context, version string) (*Kubectl, error) { + path, err := a.toolregistry.Kubectl(ctx, version) + if err != nil { + return nil, fmt.Errorf("no kubectl %s (%v)", version, err) + } + return NewKubectl(version, path), nil +} + +type multiApplier struct { + appliers []Applier +} + +// NewMultiApplier creates an applier that duplicates its operations to all the provided appliers. +func NewMultiApplier(appliers ...Applier) Applier { + return &multiApplier{ + appliers: appliers, + } +} + +func (a *multiApplier) ApplyManifest(ctx context.Context, manifest Manifest) error { + for _, a := range a.appliers { + if err := a.ApplyManifest(ctx, manifest); err != nil { + return err + } + } + return nil +} + +func (a *multiApplier) CreateManifest(ctx context.Context, manifest Manifest) error { + for _, a := range a.appliers { + if err := a.CreateManifest(ctx, manifest); err != nil { + return err + } + } + return nil +} + +func (a *multiApplier) ReplaceManifest(ctx context.Context, manifest Manifest) error { + for _, a := range a.appliers { + if err := a.ReplaceManifest(ctx, manifest); err != nil { + return err + } + } + return nil +} + +func (a *multiApplier) ForceReplaceManifest(ctx context.Context, manifest Manifest) error { + for _, a := range a.appliers { + if err := a.ForceReplaceManifest(ctx, manifest); err != nil { + return err + } + } + return nil +} + +func (a *multiApplier) Delete(ctx context.Context, key ResourceKey) error { + for _, a := range a.appliers { + if err := a.Delete(ctx, key); err != nil { + return err + } + } + return nil +} diff --git a/pkg/app/pipedv1/plugin/kubernetes/provider/cache.go b/pkg/app/pipedv1/plugin/kubernetes/provider/cache.go new file mode 100644 index 0000000000..9a79663bcd --- /dev/null +++ b/pkg/app/pipedv1/plugin/kubernetes/provider/cache.go @@ -0,0 +1,68 @@ +// 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 provider + +import ( + "errors" + "fmt" + + "go.uber.org/zap" + + "github.com/pipe-cd/pipecd/pkg/cache" +) + +type AppManifestsCache struct { + AppID string + Cache cache.Cache + Logger *zap.Logger +} + +func (c AppManifestsCache) Get(commit string) ([]Manifest, bool) { + key := appManifestsCacheKey(c.AppID, commit) + item, err := c.Cache.Get(key) + if err == nil { + return item.([]Manifest), true + } + + if errors.Is(err, cache.ErrNotFound) { + c.Logger.Info("app manifests were not found in cache", + zap.String("app-id", c.AppID), + zap.String("commit-hash", commit), + ) + return nil, false + } + + c.Logger.Error("failed while retrieving app manifests from cache", + zap.String("app-id", c.AppID), + zap.String("commit-hash", commit), + zap.Error(err), + ) + return nil, false +} + +func (c AppManifestsCache) Put(commit string, manifests []Manifest) { + key := appManifestsCacheKey(c.AppID, commit) + if err := c.Cache.Put(key, manifests); err != nil { + c.Logger.Error("failed while putting app manifests into cache", + zap.String("app-id", c.AppID), + zap.String("commit-hash", commit), + zap.Error(err), + ) + } +} + +func appManifestsCacheKey(appID, commit string) string { + return fmt.Sprintf("%s/%s", appID, commit) +} diff --git a/pkg/app/pipedv1/plugin/kubernetes/provider/chartrepo/chartrepo.go b/pkg/app/pipedv1/plugin/kubernetes/provider/chartrepo/chartrepo.go new file mode 100644 index 0000000000..dfb42e125d --- /dev/null +++ b/pkg/app/pipedv1/plugin/kubernetes/provider/chartrepo/chartrepo.go @@ -0,0 +1,87 @@ +// 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 chartrepo manages a list of configured helm repositories. +package chartrepo + +import ( + "context" + "fmt" + "os/exec" + + "go.uber.org/zap" + "golang.org/x/sync/singleflight" + + "github.com/pipe-cd/pipecd/pkg/config" +) + +var updateGroup = &singleflight.Group{} + +type registry interface { + Helm(ctx context.Context, version string) (string, error) +} + +// Add installs all specified Helm Chart repositories. +// https://helm.sh/docs/topics/chart_repository/ +// helm repo add fantastic-charts https://fantastic-charts.storage.googleapis.com +// helm repo add fantastic-charts https://fantastic-charts.storage.googleapis.com --username my-username --password my-password +func Add(ctx context.Context, repos []config.HelmChartRepository, reg registry, logger *zap.Logger) error { + helm, err := reg.Helm(ctx, "") + if err != nil { + return fmt.Errorf("failed to find helm to add repos (%w)", err) + } + + for _, repo := range repos { + args := []string{"repo", "add", repo.Name, repo.Address} + if repo.Insecure { + args = append(args, "--insecure-skip-tls-verify") + } + if repo.Username != "" || repo.Password != "" { + args = append(args, "--username", repo.Username, "--password", repo.Password) + } + cmd := exec.CommandContext(ctx, helm, args...) + out, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("failed to add chart repository %s: %s (%w)", repo.Name, string(out), err) + } + logger.Info(fmt.Sprintf("successfully added chart repository: %s", repo.Name)) + } + return nil +} + +func Update(ctx context.Context, reg registry, logger *zap.Logger) error { + _, err, _ := updateGroup.Do("update", func() (interface{}, error) { + return nil, update(ctx, reg, logger) + }) + return err +} + +func update(ctx context.Context, reg registry, logger *zap.Logger) error { + logger.Info("start updating Helm chart repositories") + + helm, err := reg.Helm(ctx, "") + if err != nil { + return fmt.Errorf("failed to find helm to update repos (%w)", err) + } + + args := []string{"repo", "update"} + cmd := exec.CommandContext(ctx, helm, args...) + out, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("failed to update Helm chart repositories: %s (%w)", string(out), err) + } + + logger.Info("successfully updated Helm chart repositories") + return nil +} diff --git a/pkg/app/pipedv1/plugin/kubernetes/provider/deployment.go b/pkg/app/pipedv1/plugin/kubernetes/provider/deployment.go new file mode 100644 index 0000000000..ce7ffd1d1f --- /dev/null +++ b/pkg/app/pipedv1/plugin/kubernetes/provider/deployment.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 provider + +import ( + "sort" + + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" +) + +func FindReferencingConfigMapsInDeployment(d *appsv1.Deployment) []string { + m := make(map[string]struct{}, 0) + + // Find all configmaps specified in Volumes. + for _, v := range d.Spec.Template.Spec.Volumes { + if cm := v.ConfigMap; cm != nil { + m[cm.Name] = struct{}{} + } + } + + findInContainers := func(containers []corev1.Container) { + for _, c := range containers { + for _, env := range c.Env { + if source := env.ValueFrom; source != nil { + if ref := source.ConfigMapKeyRef; ref != nil { + m[ref.Name] = struct{}{} + } + } + } + for _, env := range c.EnvFrom { + if ref := env.ConfigMapRef; ref != nil { + m[ref.Name] = struct{}{} + } + } + } + } + + // Find all configmaps specified in Env. + findInContainers(d.Spec.Template.Spec.Containers) + findInContainers(d.Spec.Template.Spec.InitContainers) + + if len(m) == 0 { + return nil + } + + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + sort.Strings(out) + + return out +} + +func FindReferencingSecretsInDeployment(d *appsv1.Deployment) []string { + m := make(map[string]struct{}, 0) + + // Find all secrets specified in Volumes. + for _, v := range d.Spec.Template.Spec.Volumes { + if s := v.Secret; s != nil { + m[s.SecretName] = struct{}{} + } + } + + findInContainers := func(containers []corev1.Container) { + for _, c := range containers { + for _, env := range c.Env { + if source := env.ValueFrom; source != nil { + if ref := source.SecretKeyRef; ref != nil { + m[ref.Name] = struct{}{} + } + } + } + for _, env := range c.EnvFrom { + if ref := env.SecretRef; ref != nil { + m[ref.Name] = struct{}{} + } + } + } + } + + // Find all secrets specified in Env. + findInContainers(d.Spec.Template.Spec.Containers) + findInContainers(d.Spec.Template.Spec.InitContainers) + + if len(m) == 0 { + return nil + } + + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + sort.Strings(out) + + return out +} diff --git a/pkg/app/pipedv1/plugin/kubernetes/provider/deployment_test.go b/pkg/app/pipedv1/plugin/kubernetes/provider/deployment_test.go new file mode 100644 index 0000000000..4c83cc78ef --- /dev/null +++ b/pkg/app/pipedv1/plugin/kubernetes/provider/deployment_test.go @@ -0,0 +1,358 @@ +// 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 provider + +import ( + "testing" + + appsv1 "k8s.io/api/apps/v1" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestFindReferencingConfigMapsInDeployment(t *testing.T) { + t.Parallel() + + testcases := []struct { + name string + manifest string + expected []string + }{ + { + name: "no configmap", + manifest: ` +apiVersion: apps/v1 +kind: Deployment +metadata: + name: simple + labels: + app: simple +spec: + replicas: 2 + selector: + matchLabels: + app: simple + pipecd.dev/variant: primary + template: + metadata: + labels: + app: simple + pipecd.dev/variant: primary + annotations: + sidecar.istio.io/inject: "false" + spec: + containers: + - name: helloworld + image: gcr.io/pipecd/helloworld:v0.5.0 + args: + - server + ports: + - containerPort: 9085 +`, + expected: nil, + }, + { + name: "one configmap", + manifest: ` +apiVersion: apps/v1 +kind: Deployment +metadata: + name: canary-by-config-change + labels: + app: canary-by-config-change +spec: + replicas: 2 + selector: + matchLabels: + app: canary-by-config-change + pipecd.dev/variant: primary + template: + metadata: + labels: + app: canary-by-config-change + pipecd.dev/variant: primary + spec: + containers: + - name: helloworld + image: gcr.io/pipecd/helloworld:v0.5.0 + args: + - server + ports: + - containerPort: 9085 + volumeMounts: + - name: config + mountPath: /etc/pipecd-config + readOnly: true + volumes: + - name: config + configMap: + name: canary-by-config-change +`, + expected: []string{ + "canary-by-config-change", + }, + }, + { + name: "multiple configmaps", + manifest: ` +apiVersion: apps/v1 +kind: Deployment +metadata: + name: canary-by-config-change + labels: + app: canary-by-config-change +spec: + replicas: 2 + selector: + matchLabels: + app: canary-by-config-change + pipecd.dev/variant: primary + template: + metadata: + labels: + app: canary-by-config-change + pipecd.dev/variant: primary + spec: + initContainers: + - name: init + image: gcr.io/pipecd/helloworld:v0.5.0 + env: + - name: env1 + valueFrom: + configMapKeyRef: + name: init-configmap-1 + key: key1 + containers: + - name: helloworld + image: gcr.io/pipecd/helloworld:v0.5.0 + args: + - server + ports: + - containerPort: 9085 + env: + - name: env1 + valueFrom: + configMapKeyRef: + name: configmap-1 + key: key1 + - name: env2 + valueFrom: + configMapKeyRef: + name: configmap-2 + key: key2 + volumeMounts: + - name: config + mountPath: /etc/pipecd-config + readOnly: true + volumes: + - name: config + configMap: + name: canary-by-config-change + - name: config2 + configMap: + name: configmap-2 +`, + expected: []string{ + "canary-by-config-change", + "configmap-1", + "configmap-2", + "init-configmap-1", + }, + }, + } + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + manifests, err := ParseManifests(tc.manifest) + require.NoError(t, err) + require.Equal(t, 1, len(manifests)) + + d := &appsv1.Deployment{} + err = manifests[0].ConvertToStructuredObject(d) + require.NoError(t, err) + + out := FindReferencingConfigMapsInDeployment(d) + assert.Equal(t, tc.expected, out) + }) + } +} + +func TestFindReferencingSecretsInDeployment(t *testing.T) { + t.Parallel() + + testcases := []struct { + name string + manifest string + expected []string + }{ + { + name: "no secret", + manifest: ` +apiVersion: apps/v1 +kind: Deployment +metadata: + name: simple + labels: + app: simple +spec: + replicas: 2 + selector: + matchLabels: + app: simple + pipecd.dev/variant: primary + template: + metadata: + labels: + app: simple + pipecd.dev/variant: primary + annotations: + sidecar.istio.io/inject: "false" + spec: + containers: + - name: helloworld + image: gcr.io/pipecd/helloworld:v0.5.0 + args: + - server + ports: + - containerPort: 9085 +`, + expected: nil, + }, + { + name: "one secret", + manifest: ` +apiVersion: apps/v1 +kind: Deployment +metadata: + name: canary-by-config-change + labels: + app: canary-by-config-change +spec: + replicas: 2 + selector: + matchLabels: + app: canary-by-config-change + pipecd.dev/variant: primary + template: + metadata: + labels: + app: canary-by-config-change + pipecd.dev/variant: primary + spec: + containers: + - name: helloworld + image: gcr.io/pipecd/helloworld:v0.5.0 + args: + - server + ports: + - containerPort: 9085 + volumeMounts: + - name: config + mountPath: /etc/pipecd-config + readOnly: true + volumes: + - name: config + secret: + secretName: canary-by-config-change +`, + expected: []string{ + "canary-by-config-change", + }, + }, + { + name: "multiple secrets", + manifest: ` +apiVersion: apps/v1 +kind: Deployment +metadata: + name: canary-by-config-change + labels: + app: canary-by-config-change +spec: + replicas: 2 + selector: + matchLabels: + app: canary-by-config-change + pipecd.dev/variant: primary + template: + metadata: + labels: + app: canary-by-config-change + pipecd.dev/variant: primary + spec: + initContainers: + - name: init + image: gcr.io/pipecd/helloworld:v0.5.0 + env: + - name: env1 + valueFrom: + secretKeyRef: + name: init-secret-1 + key: key1 + containers: + - name: helloworld + image: gcr.io/pipecd/helloworld:v0.5.0 + args: + - server + ports: + - containerPort: 9085 + env: + - name: env1 + valueFrom: + secretKeyRef: + name: secret-1 + key: key1 + - name: env2 + valueFrom: + secretKeyRef: + name: secret-2 + key: key2 + volumeMounts: + - name: config + mountPath: /etc/pipecd-config + readOnly: true + volumes: + - name: config + secret: + secretName: canary-by-config-change + - name: config2 + secret: + secretName: secret-2 +`, + expected: []string{ + "canary-by-config-change", + "init-secret-1", + "secret-1", + "secret-2", + }, + }, + } + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + manifests, err := ParseManifests(tc.manifest) + require.NoError(t, err) + require.Equal(t, 1, len(manifests)) + + d := &appsv1.Deployment{} + err = manifests[0].ConvertToStructuredObject(d) + require.NoError(t, err) + + out := FindReferencingSecretsInDeployment(d) + assert.Equal(t, tc.expected, out) + }) + } +} diff --git a/pkg/app/pipedv1/plugin/kubernetes/provider/diff.go b/pkg/app/pipedv1/plugin/kubernetes/provider/diff.go new file mode 100644 index 0000000000..a5bb170fbc --- /dev/null +++ b/pkg/app/pipedv1/plugin/kubernetes/provider/diff.go @@ -0,0 +1,305 @@ +// 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 provider + +import ( + "bytes" + "fmt" + "os" + "os/exec" + "sort" + "strings" + + "go.uber.org/zap" + v1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + + "github.com/pipe-cd/pipecd/pkg/diff" +) + +const ( + diffCommand = "diff" +) + +type DiffListResult struct { + Adds []Manifest + Deletes []Manifest + Changes []DiffListChange +} + +func (r *DiffListResult) NoChange() bool { + return len(r.Adds)+len(r.Deletes)+len(r.Changes) == 0 +} + +type DiffListChange struct { + Old Manifest + New Manifest + Diff *diff.Result +} + +func Diff(old, new Manifest, logger *zap.Logger, opts ...diff.Option) (*diff.Result, error) { + if old.Key.IsSecret() && new.Key.IsSecret() { + var err error + old.u, err = normalizeNewSecret(old.u, new.u) + if err != nil { + return nil, err + } + } + + key := old.Key.String() + + normalizedOld, err := remarshal(old.u) + if err != nil { + logger.Info("compare manifests directly since it was unable to remarshal old Kubernetes manifest to normalize special fields", zap.Error(err)) + return diff.DiffUnstructureds(*old.u, *new.u, key, opts...) + } + + normalizedNew, err := remarshal(new.u) + if err != nil { + logger.Info("compare manifests directly since it was unable to remarshal new Kubernetes manifest to normalize special fields", zap.Error(err)) + return diff.DiffUnstructureds(*old.u, *new.u, key, opts...) + } + + return diff.DiffUnstructureds(*normalizedOld, *normalizedNew, key, opts...) +} + +func DiffList(olds, news []Manifest, logger *zap.Logger, opts ...diff.Option) (*DiffListResult, error) { + adds, deletes, newChanges, oldChanges := groupManifests(olds, news) + cr := &DiffListResult{ + Adds: adds, + Deletes: deletes, + Changes: make([]DiffListChange, 0, len(newChanges)), + } + + for i := 0; i < len(newChanges); i++ { + result, err := Diff(oldChanges[i], newChanges[i], logger, opts...) + if err != nil { + return nil, err + } + if !result.HasDiff() { + continue + } + cr.Changes = append(cr.Changes, DiffListChange{ + Old: oldChanges[i], + New: newChanges[i], + Diff: result, + }) + } + + return cr, nil +} + +func normalizeNewSecret(old, new *unstructured.Unstructured) (*unstructured.Unstructured, error) { + var o, n v1.Secret + runtime.DefaultUnstructuredConverter.FromUnstructured(old.Object, &o) + runtime.DefaultUnstructuredConverter.FromUnstructured(new.Object, &n) + + // Move as much as possible fields from `o.Data` to `o.StringData` to make `o` close to `n` to minimize the diff. + for k, v := range o.Data { + // Skip if the field also exists in StringData. + if _, ok := o.StringData[k]; ok { + continue + } + + if _, ok := n.StringData[k]; !ok { + continue + } + + if o.StringData == nil { + o.StringData = make(map[string]string) + } + + // If the field is existing in `n.StringData`, we should move that field from `o.Data` to `o.StringData` + o.StringData[k] = string(v) + delete(o.Data, k) + } + + newO, err := runtime.DefaultUnstructuredConverter.ToUnstructured(&o) + if err != nil { + return nil, err + } + + return &unstructured.Unstructured{Object: newO}, nil +} + +type DiffRenderOptions struct { + MaskSecret bool + MaskConfigMap bool + // Maximum number of changed manifests should be shown. + // Zero means rendering all. + MaxChangedManifests int + // If true, use "diff" command to render. + UseDiffCommand bool +} + +func (r *DiffListResult) Render(opt DiffRenderOptions) string { + var b strings.Builder + index := 0 + for _, delete := range r.Deletes { + index++ + b.WriteString(fmt.Sprintf("- %d. %s\n\n", index, delete.Key.ReadableString())) + } + for _, add := range r.Adds { + index++ + b.WriteString(fmt.Sprintf("+ %d. %s\n\n", index, add.Key.ReadableString())) + } + + maxPrintDiffs := len(r.Changes) + if opt.MaxChangedManifests != 0 && opt.MaxChangedManifests < maxPrintDiffs { + maxPrintDiffs = opt.MaxChangedManifests + } + + var prints = 0 + for _, change := range r.Changes { + key := change.Old.Key + opts := []diff.RenderOption{ + diff.WithLeftPadding(1), + } + + needMaskValue := false + if opt.MaskSecret && key.IsSecret() { + opts = append(opts, diff.WithMaskPath("data")) + needMaskValue = true + } else if opt.MaskConfigMap && key.IsConfigMap() { + opts = append(opts, diff.WithMaskPath("data")) + needMaskValue = true + } + renderer := diff.NewRenderer(opts...) + + index++ + b.WriteString(fmt.Sprintf("# %d. %s\n\n", index, key.ReadableString())) + + // Use our diff check in one of the following cases: + // - not explicit set useDiffCommand option. + // - requires masking secret or configmap value. + if !opt.UseDiffCommand || needMaskValue { + b.WriteString(renderer.Render(change.Diff.Nodes())) + } else { + // TODO: Find a way to mask values in case of using unix `diff` command. + d, err := diffByCommand(diffCommand, change.Old, change.New) + if err != nil { + b.WriteString(fmt.Sprintf("An error occurred while rendering diff (%v)", err)) + } else { + b.Write(d) + } + } + b.WriteString("\n") + + prints++ + if prints >= maxPrintDiffs { + break + } + } + + if prints < len(r.Changes) { + b.WriteString(fmt.Sprintf("... (omitted %d other changed manifests\n", len(r.Changes)-prints)) + } + + return b.String() +} + +func diffByCommand(command string, old, new Manifest) ([]byte, error) { + oldBytes, err := old.YamlBytes() + if err != nil { + return nil, err + } + + newBytes, err := new.YamlBytes() + if err != nil { + return nil, err + } + + oldFile, err := os.CreateTemp("", "old") + if err != nil { + return nil, err + } + defer os.Remove(oldFile.Name()) + if _, err := oldFile.Write(oldBytes); err != nil { + return nil, err + } + + newFile, err := os.CreateTemp("", "new") + if err != nil { + return nil, err + } + defer os.Remove(newFile.Name()) + if _, err := newFile.Write(newBytes); err != nil { + return nil, err + } + + var stdout, stderr bytes.Buffer + cmd := exec.Command(command, "-u", "-N", oldFile.Name(), newFile.Name()) + cmd.Stdout = &stdout + cmd.Stderr = &stderr + err = cmd.Run() + if stdout.Len() > 0 { + // diff exits with a non-zero status when the files don't match. + // Ignore that failure as long as we get output. + err = nil + } + if err != nil { + return nil, fmt.Errorf("failed to run diff, err = %w, %s", err, stderr.String()) + } + + // Remove two-line header from output. + data := bytes.TrimSpace(stdout.Bytes()) + rows := bytes.SplitN(data, []byte("\n"), 3) + if len(rows) == 3 { + return rows[2], nil + } + return data, nil +} + +func groupManifests(olds, news []Manifest) (adds, deletes, newChanges, oldChanges []Manifest) { + // Sort the manifests before comparing. + sort.Slice(news, func(i, j int) bool { + return news[i].Key.IsLessWithIgnoringNamespace(news[j].Key) + }) + sort.Slice(olds, func(i, j int) bool { + return olds[i].Key.IsLessWithIgnoringNamespace(olds[j].Key) + }) + + var n, o int + for { + if n >= len(news) || o >= len(olds) { + break + } + if news[n].Key.IsEqualWithIgnoringNamespace(olds[o].Key) { + newChanges = append(newChanges, news[n]) + oldChanges = append(oldChanges, olds[o]) + n++ + o++ + continue + } + // Has in news but not in olds so this should be a added one. + if news[n].Key.IsLessWithIgnoringNamespace(olds[o].Key) { + adds = append(adds, news[n]) + n++ + continue + } + // Has in olds but not in news so this should be an deleted one. + deletes = append(deletes, olds[o]) + o++ + } + + if len(news) > n { + adds = append(adds, news[n:]...) + } + if len(olds) > o { + deletes = append(deletes, olds[o:]...) + } + return +} diff --git a/pkg/app/pipedv1/plugin/kubernetes/provider/diff_test.go b/pkg/app/pipedv1/plugin/kubernetes/provider/diff_test.go new file mode 100644 index 0000000000..8641318ab3 --- /dev/null +++ b/pkg/app/pipedv1/plugin/kubernetes/provider/diff_test.go @@ -0,0 +1,572 @@ +// 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 provider + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap" + + "github.com/pipe-cd/pipecd/pkg/diff" +) + +func TestGroupManifests(t *testing.T) { + t.Parallel() + + testcases := []struct { + name string + olds []Manifest + news []Manifest + expectedAdds []Manifest + expectedDeletes []Manifest + expectedNewChanges []Manifest + expectedOldChanges []Manifest + }{ + { + name: "empty list", + }, + { + name: "only adds", + news: []Manifest{ + {Key: ResourceKey{Name: "b"}}, + {Key: ResourceKey{Name: "a"}}, + }, + expectedAdds: []Manifest{ + {Key: ResourceKey{Name: "a"}}, + {Key: ResourceKey{Name: "b"}}, + }, + }, + { + name: "only deletes", + olds: []Manifest{ + {Key: ResourceKey{Name: "b"}}, + {Key: ResourceKey{Name: "a"}}, + }, + expectedDeletes: []Manifest{ + {Key: ResourceKey{Name: "a"}}, + {Key: ResourceKey{Name: "b"}}, + }, + }, + { + name: "only inters", + olds: []Manifest{ + {Key: ResourceKey{Name: "b"}}, + {Key: ResourceKey{Name: "a"}}, + }, + news: []Manifest{ + {Key: ResourceKey{Name: "a"}}, + {Key: ResourceKey{Name: "b"}}, + }, + expectedNewChanges: []Manifest{ + {Key: ResourceKey{Name: "a"}}, + {Key: ResourceKey{Name: "b"}}, + }, + expectedOldChanges: []Manifest{ + {Key: ResourceKey{Name: "a"}}, + {Key: ResourceKey{Name: "b"}}, + }, + }, + { + name: "all kinds", + olds: []Manifest{ + {Key: ResourceKey{Name: "b"}}, + {Key: ResourceKey{Name: "a"}}, + {Key: ResourceKey{Name: "c"}}, + }, + news: []Manifest{ + {Key: ResourceKey{Name: "a"}}, + {Key: ResourceKey{Name: "d"}}, + {Key: ResourceKey{Name: "b"}}, + }, + expectedAdds: []Manifest{ + {Key: ResourceKey{Name: "d"}}, + }, + expectedDeletes: []Manifest{ + {Key: ResourceKey{Name: "c"}}, + }, + expectedNewChanges: []Manifest{ + {Key: ResourceKey{Name: "a"}}, + {Key: ResourceKey{Name: "b"}}, + }, + expectedOldChanges: []Manifest{ + {Key: ResourceKey{Name: "a"}}, + {Key: ResourceKey{Name: "b"}}, + }, + }, + } + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + adds, deletes, newChanges, oldChanges := groupManifests(tc.olds, tc.news) + assert.Equal(t, tc.expectedAdds, adds) + assert.Equal(t, tc.expectedDeletes, deletes) + assert.Equal(t, tc.expectedNewChanges, newChanges) + assert.Equal(t, tc.expectedOldChanges, oldChanges) + }) + } +} + +func TestDiffByCommand(t *testing.T) { + t.Parallel() + + testcases := []struct { + name string + command string + manifests string + expected string + expectedErr bool + }{ + { + name: "no command", + command: "non-existent-diff", + manifests: "testdata/diff_by_command_no_change.yaml", + expected: "", + expectedErr: true, + }, + { + name: "no diff", + command: diffCommand, + manifests: "testdata/diff_by_command_no_change.yaml", + expected: "", + }, + { + name: "has diff", + command: diffCommand, + manifests: "testdata/diff_by_command.yaml", + expected: `@@ -6,7 +6,7 @@ + pipecd.dev/managed-by: piped + name: simple + spec: +- replicas: 2 ++ replicas: 3 + selector: + matchLabels: + app: simple +@@ -18,6 +18,7 @@ + containers: + - args: + - a ++ - d + - b + - c + image: gcr.io/pipecd/first:v1.0.0 +@@ -26,7 +27,6 @@ + - containerPort: 9085 + - args: + - xx +- - yy + - zz + image: gcr.io/pipecd/second:v1.0.0 + name: second`, + }, + } + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + manifests, err := LoadManifestsFromYAMLFile(tc.manifests) + require.NoError(t, err) + require.Equal(t, 2, len(manifests)) + + got, err := diffByCommand(tc.command, manifests[0], manifests[1]) + if tc.expectedErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + assert.Equal(t, tc.expected, string(got)) + }) + } +} + +func TestDiff(t *testing.T) { + t.Parallel() + + testcases := []struct { + name string + manifests string + expected string + diffNum int + falsePositive bool + }{ + { + name: "Secret no diff 1", + manifests: `apiVersion: apps/v1 +kind: Secret +metadata: + name: secret-management +--- +apiVersion: apps/v1 +kind: Secret +metadata: + name: secret-management +`, + expected: "", + diffNum: 0, + }, + { + name: "Secret no diff 2", + manifests: `apiVersion: apps/v1 +kind: Secret +metadata: + name: secret-management +data: + password: hoge +stringData: + foo: bar +--- +apiVersion: apps/v1 +kind: Secret +metadata: + name: secret-management +data: + password: hoge +stringData: + foo: bar +`, + expected: "", + diffNum: 0, + }, + { + name: "Secret no diff with merge", + manifests: `apiVersion: apps/v1 +kind: Secret +metadata: + name: secret-management +data: + password: hoge + foo: YmFy +--- +apiVersion: apps/v1 +kind: Secret +metadata: + name: secret-management +data: + password: hoge +stringData: + foo: bar +`, + expected: "", + diffNum: 0, + }, + { + name: "Secret no diff override false-positive", + manifests: `apiVersion: apps/v1 +kind: Secret +metadata: + name: secret-management +data: + password: hoge + foo: YmFy +--- +apiVersion: apps/v1 +kind: Secret +metadata: + name: secret-management +data: + password: hoge + foo: Zm9v +stringData: + foo: bar +`, + expected: "", + diffNum: 0, + falsePositive: true, + }, + { + name: "Secret has diff", + manifests: `apiVersion: apps/v1 +kind: Secret +metadata: + name: secret-management +data: + foo: YmFy +--- +apiVersion: apps/v1 +kind: Secret +metadata: + name: secret-management +data: + password: hoge +stringData: + foo: bar +`, + expected: ` #data ++ data: ++ password: hoge + +`, + diffNum: 1, + }, + { + name: "Pod no diff 1", + manifests: `apiVersion: v1 +kind: Pod +metadata: + name: static-web + labels: + role: myrole +spec: + containers: + - name: web + image: nginx + resources: + limits: + memory: "2Gi" +--- +apiVersion: v1 +kind: Pod +metadata: + name: static-web + labels: + role: myrole +spec: + containers: + - name: web + image: nginx + ports: + resources: + limits: + memory: "2Gi" +`, + expected: "", + diffNum: 0, + falsePositive: false, + }, + { + name: "Pod no diff 2", + manifests: `apiVersion: v1 +kind: Pod +metadata: + name: static-web + labels: + role: myrole +spec: + containers: + - name: web + image: nginx + resources: + limits: + memory: "1536Mi" +--- +apiVersion: v1 +kind: Pod +metadata: + name: static-web + labels: + role: myrole +spec: + containers: + - name: web + image: nginx + ports: + resources: + limits: + memory: "1.5Gi" +`, + expected: "", + diffNum: 0, + falsePositive: false, + }, + } + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + manifests, err := ParseManifests(tc.manifests) + require.NoError(t, err) + require.Equal(t, 2, len(manifests)) + old, new := manifests[0], manifests[1] + + result, err := Diff(old, new, zap.NewNop(), diff.WithEquateEmpty(), diff.WithIgnoreAddingMapKeys(), diff.WithCompareNumberAndNumericString()) + require.NoError(t, err) + + renderer := diff.NewRenderer(diff.WithLeftPadding(1)) + ds := renderer.Render(result.Nodes()) + if tc.falsePositive { + assert.NotEqual(t, tc.diffNum, result.NumNodes()) + assert.NotEqual(t, tc.expected, ds) + } else { + assert.Equal(t, tc.diffNum, result.NumNodes()) + assert.Equal(t, tc.expected, ds) + } + }) + } +} + +func TestNoDiff(t *testing.T) { + t.Parallel() + + testcases := []struct { + name string + manifest string + }{ + { + name: "limits.memory 1.5Gi", + manifest: `apiVersion: apps/v1 +kind: Deployment +metadata: + name: simple +spec: + template: + spec: + containers: + - image: ghcr.io/pipe-cd/helloworld:v0.32.0 + name: helloworld + resources: + limits: + memory: 1.5Gi`, + }, + { + name: "limits.cpu 1.5", + manifest: `apiVersion: apps/v1 +kind: Deployment +metadata: + name: simple +spec: + template: + spec: + containers: + - image: ghcr.io/pipe-cd/helloworld:v0.32.0 + name: helloworld + resources: + limits: + cpu: "1.5"`, + }, + { + name: "limits.memory 1Gi", + manifest: `apiVersion: apps/v1 +kind: Deployment +metadata: + name: simple +spec: + template: + spec: + containers: + - image: ghcr.io/pipe-cd/helloworld:v0.32.0 + name: helloworld + resources: + limits: + memory: 1Gi`, + }, + { + name: "limits.cpu 1", + manifest: `apiVersion: apps/v1 +kind: Deployment +metadata: + name: simple +spec: + template: + spec: + containers: + - image: ghcr.io/pipe-cd/helloworld:v0.32.0 + name: helloworld + resources: + limits: + cpu: "1"`, + }, + { + name: "requests.memory 1.5Gi", + manifest: `apiVersion: apps/v1 +kind: Deployment +metadata: + name: simple +spec: + template: + spec: + containers: + - image: ghcr.io/pipe-cd/helloworld:v0.32.0 + name: helloworld + resources: + requests: + memory: 1.5Gi`, + }, + { + name: "requests.cpu 1.5", + manifest: `apiVersion: apps/v1 +kind: Deployment +metadata: + name: simple +spec: + template: + spec: + containers: + - image: ghcr.io/pipe-cd/helloworld:v0.32.0 + name: helloworld + resources: + requests: + cpu: "1.5"`, + }, + { + name: "requests.memory 1Gi", + manifest: `apiVersion: apps/v1 +kind: Deployment +metadata: + name: simple +spec: + template: + spec: + containers: + - image: ghcr.io/pipe-cd/helloworld:v0.32.0 + name: helloworld + resources: + requests: + memory: 1Gi`, + }, + { + name: "requests.cpu 1", + manifest: `apiVersion: apps/v1 +kind: Deployment +metadata: + name: simple +spec: + template: + spec: + containers: + - image: ghcr.io/pipe-cd/helloworld:v0.32.0 + name: helloworld + resources: + requests: + cpu: "1"`, + }, + } + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + manifests, err := ParseManifests(tc.manifest) + require.NoError(t, err) + + result, err := DiffList(manifests, manifests, zap.NewNop(), diff.WithEquateEmpty(), diff.WithIgnoreAddingMapKeys(), diff.WithCompareNumberAndNumericString()) + require.NoError(t, err) + + assert.True(t, result.NoChange()) + for _, change := range result.Changes { + t.Log(change.Old.Key, change.New.Key) + for _, node := range change.Diff.Nodes() { + t.Log(node.PathString) + t.Log(node.ValueX) + t.Log(node.ValueY) + t.Log("---") + } + } + for _, add := range result.Adds { + t.Log(add.Key) + } + for _, delete := range result.Deletes { + t.Log(delete.Key) + } + }) + } +} diff --git a/pkg/app/pipedv1/plugin/kubernetes/provider/diffutil.go b/pkg/app/pipedv1/plugin/kubernetes/provider/diffutil.go new file mode 100644 index 0000000000..4ff4c731a7 --- /dev/null +++ b/pkg/app/pipedv1/plugin/kubernetes/provider/diffutil.go @@ -0,0 +1,120 @@ +// 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 provider + +import ( + "bytes" + "encoding/json" + "reflect" + + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/kubernetes/scheme" +) + +// All functions in this file is borrowed from argocd/gitops-engine and modified +// All function except `remarshal` is borrowed from +// https://github.com/argoproj/gitops-engine/blob/0bc2f8c395f67123156d4ce6b667bf730618307f/pkg/utils/json/json.go +// and `remarshal` function is borrowed from +// https://github.com/argoproj/gitops-engine/blob/b0c5e00ccfa5d1e73087a18dc59e2e4c72f5f175/pkg/diff/diff.go#L685-L723 + +// https://github.com/ksonnet/ksonnet/blob/master/pkg/kubecfg/diff.go +func removeFields(config, live interface{}) interface{} { + switch c := config.(type) { + case map[string]interface{}: + l, ok := live.(map[string]interface{}) + if ok { + return removeMapFields(c, l) + } + return live + case []interface{}: + l, ok := live.([]interface{}) + if ok { + return removeListFields(c, l) + } + return live + default: + return live + } + +} + +// removeMapFields remove all non-existent fields in the live that don't exist in the config +func removeMapFields(config, live map[string]interface{}) map[string]interface{} { + result := map[string]interface{}{} + for k, v1 := range config { + v2, ok := live[k] + if !ok { + continue + } + if v2 != nil { + v2 = removeFields(v1, v2) + } + result[k] = v2 + } + return result +} + +func removeListFields(config, live []interface{}) []interface{} { + // If live is longer than config, then the extra elements at the end of the + // list will be returned as-is so they appear in the diff. + result := make([]interface{}, 0, len(live)) + for i, v2 := range live { + if len(config) > i { + if v2 != nil { + v2 = removeFields(config[i], v2) + } + result = append(result, v2) + } else { + result = append(result, v2) + } + } + return result +} + +// remarshal checks resource kind and version and re-marshal using corresponding struct custom marshaller. +// This ensures that expected resource state is formatter same as actual resource state in kubernetes +// and allows to find differences between actual and target states more accurately. +// Remarshalling also strips any type information (e.g. float64 vs. int) from the unstructured +// object. This is important for diffing since it will cause godiff to report a false difference. +func remarshal(obj *unstructured.Unstructured) (*unstructured.Unstructured, error) { + data, err := json.Marshal(obj) + if err != nil { + return nil, err + } + item, err := scheme.Scheme.New(obj.GroupVersionKind()) + if err != nil { + // This is common. the scheme is not registered + return nil, err + } + // This will drop any omitempty fields, perform resource conversion etc... + unmarshalledObj := reflect.New(reflect.TypeOf(item).Elem()).Interface() + // Unmarshal data into unmarshalledObj, but detect if there are any unknown fields that are not + // found in the target GVK object. + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&unmarshalledObj); err != nil { + // Likely a field present in obj that is not present in the GVK type, or user + // may have specified an invalid spec in git, so return original object + return nil, err + } + unstrBody, err := runtime.DefaultUnstructuredConverter.ToUnstructured(unmarshalledObj) + if err != nil { + return nil, err + } + // Remove all default values specified by custom formatter (e.g. creationTimestamp) + unstrBody = removeMapFields(obj.Object, unstrBody) + return &unstructured.Unstructured{Object: unstrBody}, nil +} diff --git a/pkg/app/pipedv1/plugin/kubernetes/provider/diffutil_test.go b/pkg/app/pipedv1/plugin/kubernetes/provider/diffutil_test.go new file mode 100644 index 0000000000..223e37bd15 --- /dev/null +++ b/pkg/app/pipedv1/plugin/kubernetes/provider/diffutil_test.go @@ -0,0 +1,218 @@ +// 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 provider + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestRemoveMapFields(t *testing.T) { + t.Parallel() + + testcases := []struct { + name string + config map[string]interface{} + live map[string]interface{} + expected map[string]interface{} + }{ + { + name: "Empty map", + config: make(map[string]interface{}, 0), + live: make(map[string]interface{}, 0), + expected: make(map[string]interface{}, 0), + }, + { + name: "Not nested 1", + config: map[string]interface{}{ + "key a": "value a", + }, + live: map[string]interface{}{ + "key a": "value a", + "key b": "value b", + }, + expected: map[string]interface{}{ + "key a": "value a", + }, + }, + { + name: "Not nested 2", + config: map[string]interface{}{ + "key a": "value a", + "key b": "value b", + }, + live: map[string]interface{}{ + "key a": "value a", + }, + expected: map[string]interface{}{ + "key a": "value a", + }, + }, + { + name: "Nested live deleted", + config: map[string]interface{}{ + "key a": "value a", + }, + live: map[string]interface{}{ + "key a": "value a", + "key b": map[string]interface{}{ + "nested key a": "nested value a", + }, + }, + expected: map[string]interface{}{ + "key a": "value a", + }, + }, + { + name: "Nested same", + config: map[string]interface{}{ + "key a": "value a", + "key b": map[string]interface{}{ + "nested key a": "nested value a", + }, + }, + live: map[string]interface{}{ + "key a": "value a", + "key b": map[string]interface{}{ + "nested key a": "nested value a", + }, + }, + expected: map[string]interface{}{ + "key a": "value a", + "key b": map[string]interface{}{ + "nested key a": "nested value a", + }, + }, + }, + { + name: "Nested nested live deleted", + config: map[string]interface{}{ + "key a": "value a", + "key b": map[string]interface{}{ + "nested key a": "nested value a", + }, + }, + live: map[string]interface{}{ + "key a": "value a", + "key b": map[string]interface{}{ + "nested key a": "nested value a", + "nested key b": "nested value b", + }, + }, + expected: map[string]interface{}{ + "key a": "value a", + "key b": map[string]interface{}{ + "nested key a": "nested value a", + }, + }, + }, + { + name: "Nested array", + config: map[string]interface{}{ + "key a": "value a", + "key b": []interface{}{ + "a", "b", 3, + }, + }, + live: map[string]interface{}{ + "key a": "value a", + "key b": []interface{}{ + "a", "b", 3, + }, + }, + expected: map[string]interface{}{ + "key a": "value a", + "key b": []interface{}{ + "a", "b", 3, + }, + }, + }, + { + name: "Nested array 2", + config: map[string]interface{}{ + "key a": "value a", + "key b": []interface{}{ + "a", "b", 3, 4, + }, + }, + live: map[string]interface{}{ + "key a": "value a", + "key b": []interface{}{ + "a", "b", 3, + }, + }, + expected: map[string]interface{}{ + "key a": "value a", + "key b": []interface{}{ + "a", "b", 3, + }, + }, + }, + { + name: "Nested array remain", + config: map[string]interface{}{ + "key a": "value a", + "key b": []interface{}{ + "a", "b", + }, + }, + live: map[string]interface{}{ + "key a": "value a", + "key b": []interface{}{ + "a", "b", map[string]interface{}{ + "aa": "aa", + }, + }, + }, + expected: map[string]interface{}{ + "key a": "value a", + "key b": []interface{}{ + "a", "b", map[string]interface{}{ + "aa": "aa", + }, + }, + }, + }, + { + name: "Nested array same", + config: map[string]interface{}{ + "key a": "value a", + "key b": []interface{}{ + "a", "b", 3, + }, + }, + live: map[string]interface{}{ + "key a": "value a", + "key b": []interface{}{ + "b", "a", 3, + }, + }, + expected: map[string]interface{}{ + "key a": "value a", + "key b": []interface{}{ + "b", "a", 3, + }, + }, + }, + } + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + removed := removeMapFields(tc.config, tc.live) + assert.Equal(t, tc.expected, removed) + }) + } +} diff --git a/pkg/app/pipedv1/plugin/kubernetes/provider/hasher.go b/pkg/app/pipedv1/plugin/kubernetes/provider/hasher.go new file mode 100644 index 0000000000..80a13009a4 --- /dev/null +++ b/pkg/app/pipedv1/plugin/kubernetes/provider/hasher.go @@ -0,0 +1,157 @@ +// 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. + +/* +Copyright 2017 The Kubernetes Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package provider + +import ( + "crypto/sha256" + "encoding/json" + "errors" + "fmt" + + v1 "k8s.io/api/core/v1" +) + +// HashManifests computes the hash of a list of manifests. +func HashManifests(manifests []Manifest) (string, error) { + if len(manifests) == 0 { + return "", errors.New("no manifest to hash") + } + + hasher := sha256.New() + for _, m := range manifests { + var encoded string + var err error + + switch { + case m.Key.IsConfigMap(): + obj := &v1.ConfigMap{} + if err := m.ConvertToStructuredObject(obj); err != nil { + return "", err + } + encoded, err = encodeConfigMap(obj) + case m.Key.IsSecret(): + obj := &v1.Secret{} + if err := m.ConvertToStructuredObject(obj); err != nil { + return "", err + } + encoded, err = encodeSecret(obj) + default: + var encodedBytes []byte + encodedBytes, err = m.MarshalJSON() + encoded = string(encodedBytes) + } + + if err != nil { + return "", err + } + if _, err := hasher.Write([]byte(encoded)); err != nil { + return "", err + } + } + + hex := fmt.Sprintf("%x", hasher.Sum(nil)) + return encodeHash(hex) +} + +// Borrowed from https://github.com/kubernetes/kubernetes/blob/ +// ea0764452222146c47ec826977f49d7001b0ea8c/staging/src/k8s.io/kubectl/pkg/util/hash/hash.go +// encodeHash extracts the first 40 bits of the hash from the hex string +// (1 hex char represents 4 bits), and then maps vowels and vowel-like hex +// characters to consonants to prevent bad words from being formed (the theory +// is that no vowels makes it really hard to make bad words). Since the string +// is hex, the only vowels it can contain are 'a' and 'e'. +// We picked some arbitrary consonants to map to from the same character set as GenerateName. +// See: https://github.com/kubernetes/apimachinery/blob/dc1f89aff9a7509782bde3b68824c8043a3e58cc/pkg/util/rand/rand.go#L75 +// If the hex string contains fewer than ten characters, returns an error. +func encodeHash(hex string) (string, error) { + if len(hex) < 10 { + return "", errors.New("the hex string must contain at least 10 characters") + } + enc := []rune(hex[:10]) + for i := range enc { + switch enc[i] { + case '0': + enc[i] = 'g' + case '1': + enc[i] = 'h' + case '3': + enc[i] = 'k' + case 'a': + enc[i] = 'm' + case 'e': + enc[i] = 't' + } + } + return string(enc), nil +} + +// Borrowed from https://github.com/kubernetes/kubernetes/blob/ +// ea0764452222146c47ec826977f49d7001b0ea8c/staging/src/k8s.io/kubectl/pkg/util/hash/hash.go +// encodeConfigMap encodes a ConfigMap. +// Data, Kind, and Name are taken into account. +func encodeConfigMap(cm *v1.ConfigMap) (string, error) { + // json.Marshal sorts the keys in a stable order in the encoding + m := map[string]interface{}{ + "kind": "ConfigMap", + "name": cm.Name, + "data": cm.Data, + } + if cm.Immutable != nil { + m["immutable"] = *cm.Immutable + } + if len(cm.BinaryData) > 0 { + m["binaryData"] = cm.BinaryData + } + data, err := json.Marshal(m) + if err != nil { + return "", err + } + return string(data), nil +} + +// Borrowed from https://github.com/kubernetes/kubernetes/blob/ +// ea0764452222146c47ec826977f49d7001b0ea8c/staging/src/k8s.io/kubectl/pkg/util/hash/hash.go +// encodeSecret encodes a Secret. +// Data, Kind, Name, and Type are taken into account. +func encodeSecret(sec *v1.Secret) (string, error) { + m := map[string]interface{}{ + "kind": "Secret", + "type": sec.Type, + "name": sec.Name, + "data": sec.Data, + } + if sec.Immutable != nil { + m["immutable"] = *sec.Immutable + } + // json.Marshal sorts the keys in a stable order in the encoding + data, err := json.Marshal(m) + if err != nil { + return "", err + } + return string(data), nil +} diff --git a/pkg/app/pipedv1/plugin/kubernetes/provider/hasher_test.go b/pkg/app/pipedv1/plugin/kubernetes/provider/hasher_test.go new file mode 100644 index 0000000000..a11b0b9e49 --- /dev/null +++ b/pkg/app/pipedv1/plugin/kubernetes/provider/hasher_test.go @@ -0,0 +1,169 @@ +// 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 provider + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestHashManifests(t *testing.T) { + t.Parallel() + + testcases := []struct { + name string + manifests string + expected string + expectedError error + }{ + { + name: "no manifests", + expectedError: errors.New("no manifest to hash"), + }, + { + name: "configmap: emptydata", + manifests: ` +apiVersion: v1 +kind: ConfigMap +data: {} +binaryData: {} +`, + expected: "42745tchd9", + }, + { + name: "configmap: one key", + manifests: ` +apiVersion: v1 +kind: ConfigMap +data: + one: "" +binaryData: {} +`, + expected: "9g67k2htb6", + }, + { + name: "configmap: there keys for checking order", + manifests: ` +apiVersion: v1 +kind: ConfigMap +data: + two: "2" + one: "" + three: "3" +binaryData: {} +`, + expected: "f5h7t85m9b", + }, + { + name: "secret: emptydata", + manifests: ` +apiVersion: v1 +kind: Secret +type: my-type +data: {} +`, + expected: "t75bgf6ctb", + }, + { + name: "secret: one key", + manifests: ` +apiVersion: v1 +kind: Secret +type: my-type +data: + "one": "" +`, + expected: "74bd68bm66", + }, + { + name: "secret: there keys for checking order", + manifests: ` +apiVersion: v1 +kind: Secret +type: my-type +data: + two: Mg== + one: "" + three: Mw== +`, + expected: "dgcb6h9tmk", + }, + { + name: "multiple configs", + manifests: ` +apiVersion: v1 +kind: ConfigMap +data: + two: "2" + three: "3" +binaryData: {} +--- +apiVersion: v1 +kind: Secret +type: my-type +data: + one: "" + three: Mw== +`, + expected: "57hhd7795k", + }, + { + name: "not config manifest", + manifests: ` +apiVersion: apps/v1 +kind: Foo +metadata: + name: simple + labels: + app: simple + pipecd.dev/managed-by: piped +spec: + replicas: 2 + selector: + matchLabels: + app: simple + template: + metadata: + labels: + app: simple + component: foo + spec: + containers: + - name: helloworld + image: gcr.io/pipecd/helloworld:v1.0.0 + args: + - hi + - hello + ports: + - containerPort: 9085 +`, + expected: "db48kd6689", + }, + } + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + manifests, err := ParseManifests(tc.manifests) + require.NoError(t, err) + + out, err := HashManifests(manifests) + assert.Equal(t, tc.expected, out) + assert.Equal(t, tc.expectedError, err) + }) + } +} diff --git a/pkg/app/pipedv1/plugin/kubernetes/provider/helm.go b/pkg/app/pipedv1/plugin/kubernetes/provider/helm.go new file mode 100644 index 0000000000..0efbe75880 --- /dev/null +++ b/pkg/app/pipedv1/plugin/kubernetes/provider/helm.go @@ -0,0 +1,430 @@ +// 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 provider + +import ( + "bytes" + "context" + "fmt" + "net/url" + "os" + "os/exec" + "path/filepath" + "strings" + + "go.uber.org/zap" + + "github.com/pipe-cd/pipecd/pkg/app/pipedv1/plugin/kubernetes/provider/chartrepo" + "github.com/pipe-cd/pipecd/pkg/config" +) + +var ( + allowedURLSchemes = []string{"http", "https"} +) + +type helmRegistry interface { + Helm(ctx context.Context, version string) (string, error) +} + +type Helm struct { + version string + execPath string + logger *zap.Logger + toolregistry helmRegistry +} + +func NewHelm(version, path string, logger *zap.Logger, toolregistry helmRegistry) *Helm { + return &Helm{ + version: version, + execPath: path, + logger: logger, + toolregistry: toolregistry, + } +} + +func (h *Helm) TemplateLocalChart(ctx context.Context, appName, appDir, namespace, chartPath string, opts *config.InputHelmOptions) (string, error) { + releaseName := appName + if opts != nil && opts.ReleaseName != "" { + releaseName = opts.ReleaseName + } + + args := []string{ + "template", + "--no-hooks", + "--include-crds", + releaseName, + chartPath, + } + + if namespace != "" { + args = append(args, fmt.Sprintf("--namespace=%s", namespace)) + } + + if opts != nil { + for k, v := range opts.SetValues { + args = append(args, "--set", fmt.Sprintf("%s=%s", k, v)) + } + for _, v := range opts.ValueFiles { + if err := verifyHelmValueFilePath(appDir, v); err != nil { + h.logger.Error("failed to verify values file path", zap.Error(err)) + return "", err + } + args = append(args, "-f", v) + } + for k, v := range opts.SetFiles { + args = append(args, "--set-file", fmt.Sprintf("%s=%s", k, v)) + } + for _, v := range opts.APIVersions { + args = append(args, "--api-versions", v) + } + if opts.KubeVersion != "" { + args = append(args, "--kube-version", opts.KubeVersion) + } + } + + var stdout, stderr bytes.Buffer + cmd := exec.CommandContext(ctx, h.execPath, args...) + cmd.Dir = appDir + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + h.logger.Info(fmt.Sprintf("start templating a local chart (or cloned remote git chart) for application %s", appName), + zap.Any("args", args), + ) + + if err := cmd.Run(); err != nil { + return stdout.String(), fmt.Errorf("%w: %s", err, stderr.String()) + } + return stdout.String(), nil +} + +type helmRemoteGitChart struct { + GitRemote string + Ref string + Path string +} + +func (h *Helm) TemplateRemoteGitChart(ctx context.Context, appName, appDir, namespace string, chart helmRemoteGitChart, gitClient gitClient, opts *config.InputHelmOptions) (string, error) { + // Firstly, we need to download the remote repository. + repoDir, err := os.MkdirTemp("", "helm-remote-chart") + if err != nil { + return "", fmt.Errorf("unable to created temporary directory for storing remote helm chart: %w", err) + } + defer os.RemoveAll(repoDir) + + repo, err := gitClient.Clone(ctx, chart.GitRemote, chart.GitRemote, "", repoDir) + if err != nil { + return "", fmt.Errorf("unable to clone git repository containing remote helm chart: %w", err) + } + + if chart.Ref != "" { + if err := repo.Checkout(ctx, chart.Ref); err != nil { + return "", fmt.Errorf("unable to checkout to specified ref %s: %w", chart.Ref, err) + } + } + chartPath := filepath.Join(repoDir, chart.Path) + + // After that handle it as a local chart. + return h.TemplateLocalChart(ctx, appName, appDir, namespace, chartPath, opts) +} + +type helmRemoteChart struct { + Repository string + Name string + Version string + Insecure bool +} + +func (h *Helm) TemplateRemoteChart(ctx context.Context, appName, appDir, namespace string, chart helmRemoteChart, opts *config.InputHelmOptions) (string, error) { + releaseName := appName + if opts != nil && opts.ReleaseName != "" { + releaseName = opts.ReleaseName + } + + args := []string{ + "template", + "--no-hooks", + "--include-crds", + releaseName, + fmt.Sprintf("%s/%s", chart.Repository, chart.Name), + fmt.Sprintf("--version=%s", chart.Version), + } + + if chart.Insecure { + args = append(args, "--insecure-skip-tls-verify") + } + + if namespace != "" { + args = append(args, fmt.Sprintf("--namespace=%s", namespace)) + } + + if opts != nil { + for k, v := range opts.SetValues { + args = append(args, "--set", fmt.Sprintf("%s=%s", k, v)) + } + for _, v := range opts.ValueFiles { + if err := verifyHelmValueFilePath(appDir, v); err != nil { + h.logger.Error("failed to verify values file path", zap.Error(err)) + return "", err + } + args = append(args, "-f", v) + } + for k, v := range opts.SetFiles { + args = append(args, "--set-file", fmt.Sprintf("%s=%s", k, v)) + } + for _, v := range opts.APIVersions { + args = append(args, "--api-versions", v) + } + if opts.KubeVersion != "" { + args = append(args, "--kube-version", opts.KubeVersion) + } + } + + h.logger.Info(fmt.Sprintf("start templating a chart from Helm repository for application %s", appName), + zap.Any("args", args), + ) + + executor := func() (string, error) { + var stdout, stderr bytes.Buffer + cmd := exec.CommandContext(ctx, h.execPath, args...) + cmd.Dir = appDir + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + return stdout.String(), fmt.Errorf("%w: %s", err, stderr.String()) + } + return stdout.String(), nil + } + + out, err := executor() + if err == nil { + return out, nil + } + + if !strings.Contains(err.Error(), "helm repo update") { + return "", err + } + + // If the error is a "Not Found", we update the repositories and try again. + if e := chartrepo.Update(ctx, h.toolregistry, h.logger); e != nil { + h.logger.Error("failed to update Helm chart repositories", zap.Error(e)) + return "", err + } + return executor() +} + +// verifyHelmValueFilePath verifies if the path of the values file references +// a remote URL or inside the path where the application configuration file (i.e. *.pipecd.yaml) is located. +func verifyHelmValueFilePath(appDir, valueFilePath string) error { + url, err := url.Parse(valueFilePath) + if err == nil && url.Scheme != "" { + for _, s := range allowedURLSchemes { + if strings.EqualFold(url.Scheme, s) { + return nil + } + } + + return fmt.Errorf("scheme %s is not allowed to load values file", url.Scheme) + } + + // valueFilePath is a path where non-default Helm values file is located. + if !filepath.IsAbs(valueFilePath) { + valueFilePath = filepath.Join(appDir, valueFilePath) + } + + if isSymlink(valueFilePath) { + if valueFilePath, err = resolveSymlinkToAbsPath(valueFilePath, appDir); err != nil { + return err + } + } + + // If a path outside of appDir is specified as the path for the values file, + // it may indicate that someone trying to illegally read a file as values file that + // exists in the environment where Piped is running. + if !strings.HasPrefix(valueFilePath, appDir) { + return fmt.Errorf("values file %s references outside the application configuration directory", valueFilePath) + } + + return nil +} + +// isSymlink returns the path is whether symbolic link or not. +func isSymlink(path string) bool { + lstat, err := os.Lstat(path) + if err != nil { + return false + } + + return lstat.Mode()&os.ModeSymlink == os.ModeSymlink +} + +// resolveSymlinkToAbsPath resolves symbolic link to an absolute path. +func resolveSymlinkToAbsPath(path, absParentDir string) (string, error) { + resolved, err := os.Readlink(path) + if err != nil { + return "", err + } + + if !filepath.IsAbs(resolved) { + resolved = filepath.Join(absParentDir, resolved) + } + + return resolved, nil +} + +func (h *Helm) UpgradeLocalChart(ctx context.Context, appName, appDir, namespace, chartPath string, opts *config.InputHelmOptions) (string, error) { + releaseName := appName + if opts != nil && opts.ReleaseName != "" { + releaseName = opts.ReleaseName + } + + args := []string{ + "upgrade", + "--install", + releaseName, + chartPath, + } + + if namespace != "" { + args = append(args, fmt.Sprintf("--namespace=%s", namespace)) + } + + if opts != nil { + for k, v := range opts.SetValues { + args = append(args, "--set", fmt.Sprintf("%s=%s", k, v)) + } + for _, v := range opts.ValueFiles { + if err := verifyHelmValueFilePath(appDir, v); err != nil { + h.logger.Error("failed to verify values file path", zap.Error(err)) + return "", err + } + args = append(args, "-f", v) + } + for k, v := range opts.SetFiles { + args = append(args, "--set-file", fmt.Sprintf("%s=%s", k, v)) + } + } + var stdout, stderr bytes.Buffer + cmd := exec.CommandContext(ctx, h.execPath, args...) + cmd.Dir = appDir + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + h.logger.Info(fmt.Sprintf("start upgrading a release (or cloned remote git chart) for application %s", appName), + zap.Any("args", args), + ) + + if err := cmd.Run(); err != nil { + return stdout.String(), fmt.Errorf("%w: %s", err, stderr.String()) + } + return stdout.String(), nil +} + +func (h *Helm) UpgradeRemoteGitChart(ctx context.Context, appName, appDir, namespace string, chart helmRemoteGitChart, gitClient gitClient, opts *config.InputHelmOptions) (string, error) { + repoDir, err := os.MkdirTemp("", "helm-remote-chart") + if err != nil { + return "", fmt.Errorf("unable to created temporary directory for storing remote helm chart: %w", err) + } + defer os.RemoveAll(repoDir) + + repo, err := gitClient.Clone(ctx, chart.GitRemote, chart.GitRemote, "", repoDir) + if err != nil { + return "", fmt.Errorf("unable to clone git repository containing remote helm chart: %w", err) + } + + if chart.Ref != "" { + if err := repo.Checkout(ctx, chart.Ref); err != nil { + return "", fmt.Errorf("unable to checkout to specified ref %s: %w", chart.Ref, err) + } + } + chartPath := filepath.Join(repoDir, chart.Path) + + // After that handle it as a local chart. + return h.UpgradeLocalChart(ctx, appName, appDir, namespace, chartPath, opts) +} + +func (h *Helm) UpgradeRemoteChart(ctx context.Context, appName, appDir, namespace string, chart helmRemoteChart, gitClient gitClient, opts *config.InputHelmOptions) (string, error) { + releaseName := appName + if opts != nil && opts.ReleaseName != "" { + releaseName = opts.ReleaseName + } + + args := []string{ + "upgrade", + "--install", + releaseName, + fmt.Sprintf("%s/%s", chart.Repository, chart.Name), + fmt.Sprintf("--version=%s", chart.Version), + } + + if chart.Insecure { + args = append(args, "--insecure-skip-tls-verify") + } + + if namespace != "" { + args = append(args, fmt.Sprintf("--namespace=%s", namespace)) + } + + if opts != nil { + for k, v := range opts.SetValues { + args = append(args, "--set", fmt.Sprintf("%s=%s", k, v)) + } + for _, v := range opts.ValueFiles { + if err := verifyHelmValueFilePath(appDir, v); err != nil { + h.logger.Error("failed to verify values file path", zap.Error(err)) + return "", err + } + args = append(args, "-f", v) + } + for k, v := range opts.SetFiles { + args = append(args, "--set-file", fmt.Sprintf("%s=%s", k, v)) + } + } + + h.logger.Info(fmt.Sprintf("start upgrading a release from Helm repository for application %s", appName), + zap.Any("args", args), + ) + + executor := func() (string, error) { + var stdout, stderr bytes.Buffer + cmd := exec.CommandContext(ctx, h.execPath, args...) + cmd.Dir = appDir + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + return stdout.String(), fmt.Errorf("%w: %s", err, stderr.String()) + } + return stdout.String(), nil + } + + out, err := executor() + if err == nil { + return out, nil + } + + if !strings.Contains(err.Error(), "helm repo update") { + return "", err + } + + // If the error is a "Not Found", we update the repositories and try again. + if e := chartrepo.Update(ctx, h.toolregistry, h.logger); e != nil { + h.logger.Error("failed to update Helm chart repositories", zap.Error(e)) + return "", err + } + return executor() + +} diff --git a/pkg/app/pipedv1/plugin/kubernetes/provider/helm_test.go b/pkg/app/pipedv1/plugin/kubernetes/provider/helm_test.go new file mode 100644 index 0000000000..6552222440 --- /dev/null +++ b/pkg/app/pipedv1/plugin/kubernetes/provider/helm_test.go @@ -0,0 +1,177 @@ +// 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 provider + +import ( + "context" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap" + + "github.com/pipe-cd/pipecd/pkg/app/pipedv1/plugin/kubernetes/toolregistry" + "github.com/pipe-cd/pipecd/pkg/app/pipedv1/plugin/toolregistry/toolregistrytest" +) + +func TestTemplateLocalChart(t *testing.T) { + t.Parallel() + + var ( + ctx = context.Background() + appName = "testapp" + appDir = "testdata" + chartPath = "testchart" + ) + + r, err := toolregistrytest.NewToolRegistry(t) + require.NoError(t, err) + t.Cleanup(func() { r.Close() }) + + registry := toolregistry.NewRegistry(r) + helmPath, err := registry.Helm(ctx, "3.8.2") + require.NoError(t, err) + + helm := NewHelm("", helmPath, zap.NewNop(), registry) + out, err := helm.TemplateLocalChart(ctx, appName, appDir, "", chartPath, nil) + require.NoError(t, err) + + out = strings.TrimPrefix(out, "---") + manifests := strings.Split(out, "---") + assert.Equal(t, 3, len(manifests)) +} + +func TestTemplateLocalChart_WithNamespace(t *testing.T) { + t.Parallel() + + var ( + ctx = context.Background() + appName = "testapp" + appDir = "testdata" + chartPath = "testchart" + namespace = "testnamespace" + ) + + r, err := toolregistrytest.NewToolRegistry(t) + require.NoError(t, err) + t.Cleanup(func() { r.Close() }) + + registry := toolregistry.NewRegistry(r) + helmPath, err := registry.Helm(ctx, "3.8.2") + require.NoError(t, err) + + helm := NewHelm("", helmPath, zap.NewNop(), registry) + out, err := helm.TemplateLocalChart(ctx, appName, appDir, namespace, chartPath, nil) + require.NoError(t, err) + + out = strings.TrimPrefix(out, "---") + + manifests, _ := ParseManifests(out) + for _, manifest := range manifests { + metadata, err := manifest.GetNestedMap("metadata") + require.NoError(t, err) + require.Equal(t, namespace, metadata["namespace"]) + } +} + +func TestVerifyHelmValueFilePath(t *testing.T) { + t.Parallel() + + testcases := []struct { + name string + appDir string + valueFilePath string + wantErr bool + }{ + { + name: "Values file locates inside the app dir", + appDir: "testdata/testhelm/appconfdir", + valueFilePath: "values.yaml", + wantErr: false, + }, + { + name: "Values file locates inside the app dir (with ..)", + appDir: "testdata/testhelm/appconfdir", + valueFilePath: "../../../testdata/testhelm/appconfdir/values.yaml", + wantErr: false, + }, + { + name: "Values file locates under the app dir", + appDir: "testdata/testhelm/appconfdir", + valueFilePath: "dir/values.yaml", + wantErr: false, + }, + { + name: "Values file locates under the app dir (with ..)", + appDir: "testdata/testhelm/appconfdir", + valueFilePath: "../../../testdata/testhelm/appconfdir/dir/values.yaml", + wantErr: false, + }, + { + name: "arbitrary file locates outside the app dir", + appDir: "testdata/testhelm/appconfdir", + valueFilePath: "/etc/hosts", + wantErr: true, + }, + { + name: "arbitrary file locates outside the app dir (with ..)", + appDir: "testdata/testhelm/appconfdir", + valueFilePath: "../../../../../../../../../../../../etc/hosts", + wantErr: true, + }, + { + name: "Values file locates allowed remote URL (http)", + appDir: "testdata/testhelm/appconfdir", + valueFilePath: "http://exmaple.com/values.yaml", + wantErr: false, + }, + { + name: "Values file locates allowed remote URL (https)", + appDir: "testdata/testhelm/appconfdir", + valueFilePath: "https://exmaple.com/values.yaml", + wantErr: false, + }, + { + name: "Values file locates disallowed remote URL (ftp)", + appDir: "testdata/testhelm/appconfdir", + valueFilePath: "ftp://exmaple.com/values.yaml", + wantErr: true, + }, + { + name: "Values file is symlink targeting valid values file", + appDir: "testdata/testhelm/appconfdir", + valueFilePath: "valid-symlink", + wantErr: false, + }, + { + name: "Values file is symlink targeting invalid values file", + appDir: "testdata/testhelm/appconfdir", + valueFilePath: "invalid-symlink", + wantErr: true, + }, + } + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + err := verifyHelmValueFilePath(tc.appDir, tc.valueFilePath) + if tc.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + }) + } +} diff --git a/pkg/app/pipedv1/plugin/kubernetes/provider/kubectl.go b/pkg/app/pipedv1/plugin/kubernetes/provider/kubectl.go new file mode 100644 index 0000000000..09541dd10b --- /dev/null +++ b/pkg/app/pipedv1/plugin/kubernetes/provider/kubectl.go @@ -0,0 +1,285 @@ +// 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 provider + +import ( + "bytes" + "context" + "errors" + "fmt" + "os/exec" + "strings" + + "k8s.io/client-go/rest" + + "github.com/pipe-cd/pipecd/pkg/app/pipedv1/plugin/kubernetes/provider/kubernetesmetrics" +) + +var ( + errorReplaceNotFound = errors.New("specified resource is not found") + errorNotFoundLiteral = "Error from server (NotFound)" + errResourceAlreadyExists = errors.New("resource already exists") + errAlreadyExistsLiteral = "Error from server (AlreadyExists)" +) + +type Kubectl struct { + version string + execPath string + config *rest.Config +} + +func NewKubectl(version, path string) *Kubectl { + return &Kubectl{ + version: version, + execPath: path, + } +} + +func (c *Kubectl) Apply(ctx context.Context, kubeconfig, namespace string, manifest Manifest) (err error) { + defer func() { + kubernetesmetrics.IncKubectlCallsCounter( + c.version, + kubernetesmetrics.LabelApplyCommand, + err == nil, + ) + }() + + data, err := manifest.YamlBytes() + if err != nil { + return err + } + + args := make([]string, 0, 8) + if kubeconfig != "" { + args = append(args, "--kubeconfig", kubeconfig) + } + if namespace != "" { + args = append(args, "--namespace", namespace) + } + + args = append(args, "apply") + if annotation := manifest.GetAnnotations()[LabelServerSideApply]; annotation == UseServerSideApply { + args = append(args, "--server-side") + } + args = append(args, "-f", "-") + + cmd := exec.CommandContext(ctx, c.execPath, args...) + r := bytes.NewReader(data) + cmd.Stdin = r + + out, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("failed to apply: %s (%w)", string(out), err) + } + return nil +} + +func (c *Kubectl) Create(ctx context.Context, kubeconfig, namespace string, manifest Manifest) (err error) { + defer func() { + kubernetesmetrics.IncKubectlCallsCounter( + c.version, + kubernetesmetrics.LabelCreateCommand, + err == nil, + ) + }() + + data, err := manifest.YamlBytes() + if err != nil { + return err + } + + args := make([]string, 0, 7) + if kubeconfig != "" { + args = append(args, "--kubeconfig", kubeconfig) + } + if namespace != "" { + args = append(args, "--namespace", namespace) + } + args = append(args, "create", "-f", "-") + + cmd := exec.CommandContext(ctx, c.execPath, args...) + r := bytes.NewReader(data) + cmd.Stdin = r + + out, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("failed to create: %s (%w)", string(out), err) + } + return nil +} + +func (c *Kubectl) Replace(ctx context.Context, kubeconfig, namespace string, manifest Manifest) (err error) { + defer func() { + kubernetesmetrics.IncKubectlCallsCounter( + c.version, + kubernetesmetrics.LabelReplaceCommand, + err == nil, + ) + }() + + data, err := manifest.YamlBytes() + if err != nil { + return err + } + + args := make([]string, 0, 7) + if kubeconfig != "" { + args = append(args, "--kubeconfig", kubeconfig) + } + if namespace != "" { + args = append(args, "--namespace", namespace) + } + args = append(args, "replace", "-f", "-") + + cmd := exec.CommandContext(ctx, c.execPath, args...) + r := bytes.NewReader(data) + cmd.Stdin = r + + out, err := cmd.CombinedOutput() + if err == nil { + return nil + } + + if strings.Contains(string(out), errorNotFoundLiteral) { + return errorReplaceNotFound + } + + return fmt.Errorf("failed to replace: %s (%w)", string(out), err) +} + +func (c *Kubectl) ForceReplace(ctx context.Context, kubeconfig, namespace string, manifest Manifest) (err error) { + defer func() { + kubernetesmetrics.IncKubectlCallsCounter( + c.version, + kubernetesmetrics.LabelReplaceCommand, + err == nil, + ) + }() + + data, err := manifest.YamlBytes() + if err != nil { + return err + } + + args := make([]string, 0, 7) + if kubeconfig != "" { + args = append(args, "--kubeconfig", kubeconfig) + } + if namespace != "" { + args = append(args, "--namespace", namespace) + } + args = append(args, "replace", "--force", "-f", "-") + + cmd := exec.CommandContext(ctx, c.execPath, args...) + r := bytes.NewReader(data) + cmd.Stdin = r + + out, err := cmd.CombinedOutput() + if err == nil { + return nil + } + + if strings.Contains(string(out), errorNotFoundLiteral) { + return errorReplaceNotFound + } + + return fmt.Errorf("failed to replace: %s (%w)", string(out), err) +} + +func (c *Kubectl) Delete(ctx context.Context, kubeconfig, namespace string, r ResourceKey) (err error) { + defer func() { + kubernetesmetrics.IncKubectlCallsCounter( + c.version, + kubernetesmetrics.LabelDeleteCommand, + err == nil, + ) + }() + + args := make([]string, 0, 7) + if kubeconfig != "" { + args = append(args, "--kubeconfig", kubeconfig) + } + if namespace != "" { + args = append(args, "--namespace", namespace) + } + args = append(args, "delete", r.Kind, r.Name) + + cmd := exec.CommandContext(ctx, c.execPath, args...) + out, err := cmd.CombinedOutput() + + if strings.Contains(string(out), "(NotFound)") { + return fmt.Errorf("failed to delete: %s, (%w), %v", string(out), ErrNotFound, err) + } + if err != nil { + return fmt.Errorf("failed to delete: %s, %v", string(out), err) + } + return nil +} + +func (c *Kubectl) Get(ctx context.Context, kubeconfig, namespace string, r ResourceKey) (m Manifest, err error) { + defer func() { + kubernetesmetrics.IncKubectlCallsCounter( + c.version, + kubernetesmetrics.LabelGetCommand, + err == nil, + ) + }() + + args := make([]string, 0, 7) + if kubeconfig != "" { + args = append(args, "--kubeconfig", kubeconfig) + } + if namespace != "" { + args = append(args, "--namespace", namespace) + } + args = append(args, "get", r.Kind, r.Name, "-o", "yaml") + + cmd := exec.CommandContext(ctx, c.execPath, args...) + out, err := cmd.CombinedOutput() + + if strings.Contains(string(out), "(NotFound)") { + return Manifest{}, fmt.Errorf("not found manifest %v, (%w), %v", r, ErrNotFound, err) + } + if err != nil { + return Manifest{}, fmt.Errorf("failed to get: %s, %v", string(out), err) + } + ms, err := ParseManifests(string(out)) + if err != nil { + return Manifest{}, fmt.Errorf("failed to parse manifests %v: %v", r, err) + } + if len(ms) == 0 { + return Manifest{}, fmt.Errorf("not found manifest %v, (%w)", r, ErrNotFound) + } + return ms[0], nil +} + +func (c *Kubectl) CreateNamespace(ctx context.Context, kubeconfig, namespace string) (err error) { + args := make([]string, 0, 7) + if kubeconfig != "" { + args = append(args, "--kubeconfig", kubeconfig) + } + args = append(args, "create", "namespace", namespace) + + cmd := exec.CommandContext(ctx, c.execPath, args...) + out, err := cmd.CombinedOutput() + + if strings.Contains(string(out), errAlreadyExistsLiteral) { + return errResourceAlreadyExists + } + if err != nil { + return fmt.Errorf("failed to create namespace: %s, %v", string(out), err) + } + return nil +} diff --git a/pkg/app/pipedv1/plugin/kubernetes/provider/kubernetes.go b/pkg/app/pipedv1/plugin/kubernetes/provider/kubernetes.go new file mode 100644 index 0000000000..6a8805951a --- /dev/null +++ b/pkg/app/pipedv1/plugin/kubernetes/provider/kubernetes.go @@ -0,0 +1,45 @@ +// 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 provider + +import ( + "errors" +) + +var ( + ErrNotFound = errors.New("not found") +) + +const ( + LabelManagedBy = "pipecd.dev/managed-by" // Always be piped. + LabelPiped = "pipecd.dev/piped" // The id of piped handling this application. + LabelApplication = "pipecd.dev/application" // The application this resource belongs to. + LabelCommitHash = "pipecd.dev/commit-hash" // Hash value of the deployed commit. + LabelResourceKey = "pipecd.dev/resource-key" // The resource key generated by apiVersion, namespace and name. e.g. apps/v1/Deployment/namespace/demo-app + LabelOriginalAPIVersion = "pipecd.dev/original-api-version" // The api version defined in git configuration. e.g. apps/v1 + LabelIgnoreDriftDirection = "pipecd.dev/ignore-drift-detection" // Whether the drift detection should ignore this resource. + LabelSyncReplace = "pipecd.dev/sync-by-replace" // Use replace instead of apply. + LabelForceSyncReplace = "pipecd.dev/force-sync-by-replace" // Use replace --force instead of apply. + LabelServerSideApply = "pipecd.dev/server-side-apply" // Use server side apply instead of client side apply. + AnnotationConfigHash = "pipecd.dev/config-hash" // The hash value of all mouting config resources. + AnnotationOrder = "pipecd.dev/order" // The order number of resource used to sort them before using. + + ManagedByPiped = "piped" + IgnoreDriftDetectionTrue = "true" + UseReplaceEnabled = "enabled" + UseServerSideApply = "true" + + kustomizationFileName = "kustomization.yaml" +) diff --git a/pkg/app/pipedv1/plugin/kubernetes/provider/kubernetesmetrics/metrics.go b/pkg/app/pipedv1/plugin/kubernetes/provider/kubernetesmetrics/metrics.go new file mode 100644 index 0000000000..d7575aeba9 --- /dev/null +++ b/pkg/app/pipedv1/plugin/kubernetes/provider/kubernetesmetrics/metrics.go @@ -0,0 +1,81 @@ +// 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 kubernetesmetrics + +import ( + "github.com/prometheus/client_golang/prometheus" +) + +const ( + toolKey = "tool" + versionKey = "version" + toolCommandKey = "command" + commandOutputKey = "status" +) + +type Tool string + +const ( + LabelToolKubectl Tool = "kubectl" +) + +type ToolCommand string + +const ( + LabelApplyCommand ToolCommand = "apply" + LabelCreateCommand ToolCommand = "create" + LabelReplaceCommand ToolCommand = "replace" + LabelDeleteCommand ToolCommand = "delete" + LabelGetCommand ToolCommand = "get" +) + +type CommandOutput string + +const ( + LabelOutputSuccess CommandOutput = "success" + LabelOutputFailre CommandOutput = "failure" +) + +var ( + toolCallsCounter = prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "cloudprovider_kubernetes_tool_calls_total", + Help: "Number of calls made to run the tool like kubectl, kustomize.", + }, + []string{ + toolKey, + versionKey, + toolCommandKey, + commandOutputKey, + }, + ) +) + +func IncKubectlCallsCounter(version string, command ToolCommand, success bool) { + status := LabelOutputSuccess + if !success { + status = LabelOutputFailre + } + toolCallsCounter.With(prometheus.Labels{ + toolKey: string(LabelToolKubectl), + versionKey: version, + toolCommandKey: string(command), + commandOutputKey: string(status), + }).Inc() +} + +func Register(r prometheus.Registerer) { + r.MustRegister(toolCallsCounter) +} diff --git a/pkg/app/pipedv1/plugin/kubernetes/provider/kubernetestest/kubernetes.mock.go b/pkg/app/pipedv1/plugin/kubernetes/provider/kubernetestest/kubernetes.mock.go new file mode 100644 index 0000000000..c72d75bc37 --- /dev/null +++ b/pkg/app/pipedv1/plugin/kubernetes/provider/kubernetestest/kubernetes.mock.go @@ -0,0 +1,144 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/pipe-cd/pipecd/pkg/app/pipedv1/plugin/kubernetes/provider (interfaces: Applier,Loader) + +// Package kubernetestest is a generated GoMock package. +package kubernetestest + +import ( + context "context" + reflect "reflect" + + gomock "github.com/golang/mock/gomock" + provider "github.com/pipe-cd/pipecd/pkg/app/pipedv1/plugin/kubernetes/provider" +) + +// MockApplier is a mock of Applier interface. +type MockApplier struct { + ctrl *gomock.Controller + recorder *MockApplierMockRecorder +} + +// MockApplierMockRecorder is the mock recorder for MockApplier. +type MockApplierMockRecorder struct { + mock *MockApplier +} + +// NewMockApplier creates a new mock instance. +func NewMockApplier(ctrl *gomock.Controller) *MockApplier { + mock := &MockApplier{ctrl: ctrl} + mock.recorder = &MockApplierMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockApplier) EXPECT() *MockApplierMockRecorder { + return m.recorder +} + +// ApplyManifest mocks base method. +func (m *MockApplier) ApplyManifest(arg0 context.Context, arg1 provider.Manifest) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ApplyManifest", arg0, arg1) + ret0, _ := ret[0].(error) + return ret0 +} + +// ApplyManifest indicates an expected call of ApplyManifest. +func (mr *MockApplierMockRecorder) ApplyManifest(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ApplyManifest", reflect.TypeOf((*MockApplier)(nil).ApplyManifest), arg0, arg1) +} + +// CreateManifest mocks base method. +func (m *MockApplier) CreateManifest(arg0 context.Context, arg1 provider.Manifest) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateManifest", arg0, arg1) + ret0, _ := ret[0].(error) + return ret0 +} + +// CreateManifest indicates an expected call of CreateManifest. +func (mr *MockApplierMockRecorder) CreateManifest(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateManifest", reflect.TypeOf((*MockApplier)(nil).CreateManifest), arg0, arg1) +} + +// Delete mocks base method. +func (m *MockApplier) Delete(arg0 context.Context, arg1 provider.ResourceKey) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Delete", arg0, arg1) + ret0, _ := ret[0].(error) + return ret0 +} + +// Delete indicates an expected call of Delete. +func (mr *MockApplierMockRecorder) Delete(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Delete", reflect.TypeOf((*MockApplier)(nil).Delete), arg0, arg1) +} + +// ForceReplaceManifest mocks base method. +func (m *MockApplier) ForceReplaceManifest(arg0 context.Context, arg1 provider.Manifest) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ForceReplaceManifest", arg0, arg1) + ret0, _ := ret[0].(error) + return ret0 +} + +// ForceReplaceManifest indicates an expected call of ForceReplaceManifest. +func (mr *MockApplierMockRecorder) ForceReplaceManifest(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ForceReplaceManifest", reflect.TypeOf((*MockApplier)(nil).ForceReplaceManifest), arg0, arg1) +} + +// ReplaceManifest mocks base method. +func (m *MockApplier) ReplaceManifest(arg0 context.Context, arg1 provider.Manifest) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ReplaceManifest", arg0, arg1) + ret0, _ := ret[0].(error) + return ret0 +} + +// ReplaceManifest indicates an expected call of ReplaceManifest. +func (mr *MockApplierMockRecorder) ReplaceManifest(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ReplaceManifest", reflect.TypeOf((*MockApplier)(nil).ReplaceManifest), arg0, arg1) +} + +// MockLoader is a mock of Loader interface. +type MockLoader struct { + ctrl *gomock.Controller + recorder *MockLoaderMockRecorder +} + +// MockLoaderMockRecorder is the mock recorder for MockLoader. +type MockLoaderMockRecorder struct { + mock *MockLoader +} + +// NewMockLoader creates a new mock instance. +func NewMockLoader(ctrl *gomock.Controller) *MockLoader { + mock := &MockLoader{ctrl: ctrl} + mock.recorder = &MockLoaderMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockLoader) EXPECT() *MockLoaderMockRecorder { + return m.recorder +} + +// LoadManifests mocks base method. +func (m *MockLoader) LoadManifests(arg0 context.Context) ([]provider.Manifest, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "LoadManifests", arg0) + ret0, _ := ret[0].([]provider.Manifest) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// LoadManifests indicates an expected call of LoadManifests. +func (mr *MockLoaderMockRecorder) LoadManifests(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "LoadManifests", reflect.TypeOf((*MockLoader)(nil).LoadManifests), arg0) +} diff --git a/pkg/app/pipedv1/plugin/kubernetes/provider/kustomize.go b/pkg/app/pipedv1/plugin/kubernetes/provider/kustomize.go new file mode 100644 index 0000000000..6538950b5f --- /dev/null +++ b/pkg/app/pipedv1/plugin/kubernetes/provider/kustomize.go @@ -0,0 +1,67 @@ +// 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 provider + +import ( + "bytes" + "context" + "fmt" + "os/exec" + + "go.uber.org/zap" +) + +type Kustomize struct { + version string + execPath string + logger *zap.Logger +} + +func NewKustomize(version, path string, logger *zap.Logger) *Kustomize { + return &Kustomize{ + version: version, + execPath: path, + logger: logger, + } +} + +func (c *Kustomize) Template(ctx context.Context, appName, appDir string, opts map[string]string) (string, error) { + args := []string{ + "build", + ".", + } + + for k, v := range opts { + args = append(args, fmt.Sprintf("--%s", k)) + if v != "" { + args = append(args, v) + } + } + + var stdout, stderr bytes.Buffer + cmd := exec.CommandContext(ctx, c.execPath, args...) + cmd.Dir = appDir + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + c.logger.Info(fmt.Sprintf("start templating a Kustomize application %s", appName), + zap.Any("args", args), + ) + + if err := cmd.Run(); err != nil { + return stdout.String(), fmt.Errorf("%w: %s", err, stderr.String()) + } + return stdout.String(), nil +} diff --git a/pkg/app/pipedv1/plugin/kubernetes/provider/kustomize_test.go b/pkg/app/pipedv1/plugin/kubernetes/provider/kustomize_test.go new file mode 100644 index 0000000000..7f4cd75e67 --- /dev/null +++ b/pkg/app/pipedv1/plugin/kubernetes/provider/kustomize_test.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 provider + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap" + + "github.com/pipe-cd/pipecd/pkg/app/pipedv1/plugin/kubernetes/toolregistry" + "github.com/pipe-cd/pipecd/pkg/app/pipedv1/plugin/toolregistry/toolregistrytest" +) + +func TestKustomizeTemplate(t *testing.T) { + t.Parallel() + + var ( + ctx = context.TODO() + appName = "testapp" + appDir = "testdata/testkustomize" + ) + + r, err := toolregistrytest.NewToolRegistry(t) + require.NoError(t, err) + t.Cleanup(func() { r.Close() }) + + registry := toolregistry.NewRegistry(r) + kustomizePath, err := registry.Kustomize(ctx, "5.4.3") + require.NoError(t, err) + + kustomize := NewKustomize("", kustomizePath, zap.NewNop()) + out, err := kustomize.Template(ctx, appName, appDir, map[string]string{ + "load-restrictor": "LoadRestrictionsNone", + }) + require.NoError(t, err) + assert.True(t, len(out) > 0) +} diff --git a/pkg/app/pipedv1/plugin/kubernetes/provider/loader.go b/pkg/app/pipedv1/plugin/kubernetes/provider/loader.go new file mode 100644 index 0000000000..d48abb96cf --- /dev/null +++ b/pkg/app/pipedv1/plugin/kubernetes/provider/loader.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 provider + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "strconv" + "sync" + + "go.uber.org/zap" + + "github.com/pipe-cd/pipecd/pkg/config" + "github.com/pipe-cd/pipecd/pkg/git" +) + +type TemplatingMethod string + +const ( + TemplatingMethodHelm TemplatingMethod = "helm" + TemplatingMethodKustomize TemplatingMethod = "kustomize" + TemplatingMethodNone TemplatingMethod = "none" +) + +type Loader interface { + // LoadManifests renders and loads all manifests for application. + LoadManifests(ctx context.Context) ([]Manifest, error) +} + +type gitClient interface { + Clone(ctx context.Context, repoID, remote, branch, destination string) (git.Repo, error) +} + +type registry interface { + Kubectl(ctx context.Context, version string) (string, error) + Kustomize(ctx context.Context, version string) (string, error) + Helm(ctx context.Context, version string) (string, error) +} + +type loader struct { + appName string + appDir string + repoDir string + configFileName string + input config.KubernetesDeploymentInput + gc gitClient + logger *zap.Logger + toolregistry registry + + templatingMethod TemplatingMethod + kustomize *Kustomize + helm *Helm + initOnce sync.Once + initErr error +} + +func NewLoader( + appName, appDir, repoDir, configFileName string, + input config.KubernetesDeploymentInput, + gc gitClient, + logger *zap.Logger, + toolregistry registry, +) Loader { + + return &loader{ + appName: appName, + appDir: appDir, + repoDir: repoDir, + configFileName: configFileName, + input: input, + gc: gc, + logger: logger.Named("kubernetes-loader"), + toolregistry: toolregistry, + } +} + +// LoadManifests renders and loads all manifests for application. +func (l *loader) LoadManifests(ctx context.Context) (manifests []Manifest, err error) { + defer func() { + // Override namespace if set because ParseManifests does not parse it + // if namespace is not explicitly specified in the manifests. + setNamespace(manifests, l.input.Namespace) + sortManifests(manifests) + }() + l.initOnce.Do(func() { + var initErrorHelm, initErrorKustomize error + l.templatingMethod = determineTemplatingMethod(l.input, l.appDir) + if l.templatingMethod != TemplatingMethodNone { + l.helm, initErrorHelm = l.findHelm(ctx, l.input.HelmVersion) + l.kustomize, initErrorKustomize = l.findKustomize(ctx, l.input.KustomizeVersion) + l.initErr = errors.Join(initErrorHelm, initErrorKustomize) + } + }) + if l.initErr != nil { + return nil, l.initErr + } + + switch l.templatingMethod { + case TemplatingMethodHelm: + var data string + switch { + case l.input.HelmChart.GitRemote != "": + chart := helmRemoteGitChart{ + GitRemote: l.input.HelmChart.GitRemote, + Ref: l.input.HelmChart.Ref, + Path: l.input.HelmChart.Path, + } + data, err = l.helm.TemplateRemoteGitChart(ctx, + l.appName, + l.appDir, + l.input.Namespace, + chart, + l.gc, + l.input.HelmOptions) + + case l.input.HelmChart.Repository != "": + chart := helmRemoteChart{ + Repository: l.input.HelmChart.Repository, + Name: l.input.HelmChart.Name, + Version: l.input.HelmChart.Version, + Insecure: l.input.HelmChart.Insecure, + } + data, err = l.helm.TemplateRemoteChart(ctx, + l.appName, + l.appDir, + l.input.Namespace, + chart, + l.input.HelmOptions) + + default: + data, err = l.helm.TemplateLocalChart(ctx, + l.appName, + l.appDir, + l.input.Namespace, + l.input.HelmChart.Path, + l.input.HelmOptions) + } + + if err != nil { + err = fmt.Errorf("unable to run helm template: %w", err) + return + } + manifests, err = ParseManifests(data) + + case TemplatingMethodKustomize: + var data string + data, err = l.kustomize.Template(ctx, l.appName, l.appDir, l.input.KustomizeOptions) + if err != nil { + err = fmt.Errorf("unable to run kustomize template: %w", err) + return + } + manifests, err = ParseManifests(data) + + case TemplatingMethodNone: + manifests, err = LoadPlainYAMLManifests(l.appDir, l.input.Manifests, l.configFileName) + + default: + err = fmt.Errorf("unsupport templating method %v", l.templatingMethod) + } + + return +} + +func setNamespace(manifests []Manifest, namespace string) { + if namespace == "" { + return + } + for i := range manifests { + manifests[i].Key.Namespace = namespace + } +} + +func sortManifests(manifests []Manifest) { + if len(manifests) < 2 { + return + } + sort.Slice(manifests, func(i, j int) bool { + iAns := manifests[i].GetAnnotations() + // Ignore the converting error since it is not so much important. + iIndex, _ := strconv.Atoi(iAns[AnnotationOrder]) + + jAns := manifests[j].GetAnnotations() + // Ignore the converting error since it is not so much important. + jIndex, _ := strconv.Atoi(jAns[AnnotationOrder]) + + return iIndex < jIndex + }) +} + +func (l *loader) findKustomize(ctx context.Context, version string) (*Kustomize, error) { + path, err := l.toolregistry.Kustomize(ctx, version) + if err != nil { + return nil, fmt.Errorf("no kustomize %s (%v)", version, err) + } + return NewKustomize(version, path, l.logger), nil +} + +func (l *loader) findHelm(ctx context.Context, version string) (*Helm, error) { + path, err := l.toolregistry.Helm(ctx, version) + if err != nil { + return nil, fmt.Errorf("no helm %s (%v)", version, err) + } + return NewHelm(version, path, l.logger, l.toolregistry), nil +} + +func determineTemplatingMethod(input config.KubernetesDeploymentInput, appDirPath string) TemplatingMethod { + if input.HelmChart != nil { + return TemplatingMethodHelm + } + if _, err := os.Stat(filepath.Join(appDirPath, kustomizationFileName)); err == nil { + return TemplatingMethodKustomize + } + return TemplatingMethodNone +} diff --git a/pkg/app/pipedv1/plugin/kubernetes/provider/loader_test.go b/pkg/app/pipedv1/plugin/kubernetes/provider/loader_test.go new file mode 100644 index 0000000000..1810fc1163 --- /dev/null +++ b/pkg/app/pipedv1/plugin/kubernetes/provider/loader_test.go @@ -0,0 +1,78 @@ +// 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 provider + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" +) + +func TestSortManifests(t *testing.T) { + maker := func(name string, annotations map[string]string) Manifest { + m := Manifest{ + Key: ResourceKey{Name: name}, + u: &unstructured.Unstructured{ + Object: map[string]interface{}{}, + }, + } + m.AddAnnotations(annotations) + return m + } + + testcases := []struct { + name string + manifests []Manifest + want []Manifest + }{ + { + name: "empty", + }, + { + name: "one manifest", + manifests: []Manifest{ + maker("name-1", map[string]string{AnnotationOrder: "0"}), + }, + want: []Manifest{ + maker("name-1", map[string]string{AnnotationOrder: "0"}), + }, + }, + { + name: "multiple manifests", + manifests: []Manifest{ + maker("name-2", map[string]string{AnnotationOrder: "2"}), + maker("name--1", map[string]string{AnnotationOrder: "-1"}), + maker("name-nil", nil), + maker("name-0", map[string]string{AnnotationOrder: "0"}), + maker("name-1", map[string]string{AnnotationOrder: "1"}), + }, + want: []Manifest{ + maker("name--1", map[string]string{AnnotationOrder: "-1"}), + maker("name-nil", nil), + maker("name-0", map[string]string{AnnotationOrder: "0"}), + maker("name-1", map[string]string{AnnotationOrder: "1"}), + maker("name-2", map[string]string{AnnotationOrder: "2"}), + }, + }, + } + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + sortManifests(tc.manifests) + assert.Equal(t, tc.want, tc.manifests) + }) + } +} diff --git a/pkg/app/pipedv1/plugin/kubernetes/provider/manifest.go b/pkg/app/pipedv1/plugin/kubernetes/provider/manifest.go new file mode 100644 index 0000000000..26752e3e4b --- /dev/null +++ b/pkg/app/pipedv1/plugin/kubernetes/provider/manifest.go @@ -0,0 +1,249 @@ +// 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 provider + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "sigs.k8s.io/yaml" + + "github.com/pipe-cd/pipecd/pkg/model" +) + +type Manifest struct { + Key ResourceKey + u *unstructured.Unstructured +} + +func MakeManifest(key ResourceKey, u *unstructured.Unstructured) Manifest { + return Manifest{ + Key: key, + u: u, + } +} + +func (m Manifest) Duplicate(name string) Manifest { + u := m.u.DeepCopy() + u.SetName(name) + + key := m.Key + key.Name = name + + return Manifest{ + Key: key, + u: u, + } +} + +func (m Manifest) YamlBytes() ([]byte, error) { + return yaml.Marshal(m.u) +} + +func (m Manifest) MarshalJSON() ([]byte, error) { + return m.u.MarshalJSON() +} + +func (m Manifest) AddAnnotations(annotations map[string]string) { + if len(annotations) == 0 { + return + } + + annos := m.u.GetAnnotations() + if annos == nil { + m.u.SetAnnotations(annotations) + return + } + for k, v := range annotations { + annos[k] = v + } + m.u.SetAnnotations(annos) +} + +func (m Manifest) GetAnnotations() map[string]string { + return m.u.GetAnnotations() +} + +func (m Manifest) GetNestedStringMap(fields ...string) (map[string]string, error) { + sm, _, err := unstructured.NestedStringMap(m.u.Object, fields...) + if err != nil { + return nil, err + } + + return sm, nil +} + +func (m Manifest) GetNestedMap(fields ...string) (map[string]interface{}, error) { + sm, _, err := unstructured.NestedMap(m.u.Object, fields...) + if err != nil { + return nil, err + } + + return sm, nil +} + +// AddStringMapValues adds or overrides the given key-values into the string map +// that can be found at the specified fields. +func (m Manifest) AddStringMapValues(values map[string]string, fields ...string) error { + curMap, _, err := unstructured.NestedStringMap(m.u.Object, fields...) + if err != nil { + return err + } + + if curMap == nil { + return unstructured.SetNestedStringMap(m.u.Object, values, fields...) + } + for k, v := range values { + curMap[k] = v + } + return unstructured.SetNestedStringMap(m.u.Object, curMap, fields...) +} + +func (m Manifest) GetSpec() (interface{}, error) { + spec, ok, err := unstructured.NestedFieldNoCopy(m.u.Object, "spec") + if err != nil { + return nil, err + } + if !ok { + return nil, fmt.Errorf("spec was not found") + } + return spec, nil +} + +func (m Manifest) SetStructuredSpec(spec interface{}) error { + data, err := yaml.Marshal(spec) + if err != nil { + return err + } + + unstructuredSpec := make(map[string]interface{}) + if err := yaml.Unmarshal(data, &unstructuredSpec); err != nil { + return err + } + + return unstructured.SetNestedField(m.u.Object, unstructuredSpec, "spec") +} + +func (m Manifest) ConvertToStructuredObject(o interface{}) error { + data, err := m.MarshalJSON() + if err != nil { + return err + } + return json.Unmarshal(data, o) +} + +func ParseFromStructuredObject(s interface{}) (Manifest, error) { + data, err := json.Marshal(s) + if err != nil { + return Manifest{}, err + } + + obj := &unstructured.Unstructured{} + if err := obj.UnmarshalJSON(data); err != nil { + return Manifest{}, err + } + + return Manifest{ + Key: MakeResourceKey(obj), + u: obj, + }, nil +} + +func LoadPlainYAMLManifests(dir string, names []string, configFileName string) ([]Manifest, error) { + // If no name was specified we have to walk the app directory to collect the manifest list. + if len(names) == 0 { + err := filepath.Walk(dir, func(path string, f os.FileInfo, err error) error { + if err != nil { + return err + } + if path == dir { + return nil + } + if f.IsDir() { + return filepath.SkipDir + } + ext := filepath.Ext(f.Name()) + if ext != ".yaml" && ext != ".yml" && ext != ".json" { + return nil + } + if model.IsApplicationConfigFile(f.Name()) { + return nil + } + if f.Name() == configFileName { + return nil + } + names = append(names, f.Name()) + return nil + }) + if err != nil { + return nil, err + } + } + + manifests := make([]Manifest, 0, len(names)) + for _, name := range names { + path := filepath.Join(dir, name) + ms, err := LoadManifestsFromYAMLFile(path) + if err != nil { + return nil, fmt.Errorf("failed to load manifest at %s (%w)", path, err) + } + manifests = append(manifests, ms...) + } + + return manifests, nil +} + +func LoadManifestsFromYAMLFile(path string) ([]Manifest, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + return ParseManifests(string(data)) +} + +func ParseManifests(data string) ([]Manifest, error) { + const separator = "\n---" + var ( + parts = strings.Split(data, separator) + manifests = make([]Manifest, 0, len(parts)) + ) + + for i, part := range parts { + // Ignore all the cases where no content between separator. + if len(strings.TrimSpace(part)) == 0 { + continue + } + // Append new line which trim by document separator. + if i != len(parts)-1 { + part += "\n" + } + var obj unstructured.Unstructured + if err := yaml.Unmarshal([]byte(part), &obj); err != nil { + return nil, err + } + if len(obj.Object) == 0 { + continue + } + manifests = append(manifests, Manifest{ + Key: MakeResourceKey(&obj), + u: &obj, + }) + } + return manifests, nil +} diff --git a/pkg/app/pipedv1/plugin/kubernetes/provider/manifest_test.go b/pkg/app/pipedv1/plugin/kubernetes/provider/manifest_test.go new file mode 100644 index 0000000000..7a6b39d4e7 --- /dev/null +++ b/pkg/app/pipedv1/plugin/kubernetes/provider/manifest_test.go @@ -0,0 +1,193 @@ +// 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 provider + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" +) + +func TestParseManifests(t *testing.T) { + maker := func(name, kind string, metadata map[string]interface{}) Manifest { + return Manifest{ + Key: ResourceKey{ + APIVersion: "v1", + Kind: kind, + Name: name, + Namespace: "default", + }, + u: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": kind, + "metadata": metadata, + }, + }, + } + } + + testcases := []struct { + name string + manifests string + want []Manifest + }{ + { + name: "empty1", + }, + { + name: "empty2", + manifests: "---", + }, + { + name: "empty3", + manifests: "\n---", + }, + { + name: "empty4", + manifests: "\n---\n", + }, + { + name: "multiple empty manifests", + manifests: "---\n---\n---\n---\n---\n", + }, + { + name: "one manifest", + manifests: `--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: envoy-config + creationTimestamp: "2022-12-09T01:23:45Z" +`, + want: []Manifest{ + maker("envoy-config", "ConfigMap", map[string]interface{}{ + "name": "envoy-config", + "creationTimestamp": "2022-12-09T01:23:45Z", + }), + }, + }, + { + name: "contains new line at the end of file", + manifests: ` +apiVersion: v1 +kind: Kind1 +metadata: + name: config + extra: | + single-new-line +`, + want: []Manifest{ + maker("config", "Kind1", map[string]interface{}{ + "name": "config", + "extra": "single-new-line\n", + }), + }, + }, + { + name: "not contains new line at the end of file", + manifests: ` +apiVersion: v1 +kind: Kind1 +metadata: + name: config + extra: | + no-new-line`, + want: []Manifest{ + maker("config", "Kind1", map[string]interface{}{ + "name": "config", + "extra": "no-new-line", + }), + }, + }, + { + name: "multiple manifests", + manifests: ` +apiVersion: v1 +kind: Kind1 +metadata: + name: config1 + extra: |- + no-new-line +--- +apiVersion: v1 +kind: Kind2 +metadata: + name: config2 + extra: | + single-new-line-1 +--- +apiVersion: v1 +kind: Kind3 +metadata: + name: config3 + extra: | + single-new-line-2 + + +--- +apiVersion: v1 +kind: Kind4 +metadata: + name: config4 + extra: |+ + multiple-new-line-1 + + +--- +apiVersion: v1 +kind: Kind5 +metadata: + name: config5 + extra: |+ + multiple-new-line-2 + + +`, + want: []Manifest{ + maker("config1", "Kind1", map[string]interface{}{ + "name": "config1", + "extra": "no-new-line", + }), + maker("config2", "Kind2", map[string]interface{}{ + "name": "config2", + "extra": "single-new-line-1\n", + }), + maker("config3", "Kind3", map[string]interface{}{ + "name": "config3", + "extra": "single-new-line-2\n", + }), + maker("config4", "Kind4", map[string]interface{}{ + "name": "config4", + "extra": "multiple-new-line-1\n\n\n", + }), + maker("config5", "Kind5", map[string]interface{}{ + "name": "config5", + "extra": "multiple-new-line-2\n\n\n", + }), + }, + }, + } + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + m, err := ParseManifests(tc.manifests) + require.NoError(t, err) + assert.ElementsMatch(t, m, tc.want) + }) + } +} diff --git a/pkg/app/pipedv1/plugin/kubernetes/provider/resource/deployment.go b/pkg/app/pipedv1/plugin/kubernetes/provider/resource/deployment.go new file mode 100644 index 0000000000..5a82fd77ec --- /dev/null +++ b/pkg/app/pipedv1/plugin/kubernetes/provider/resource/deployment.go @@ -0,0 +1,24 @@ +// 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 resource + +type Deployment struct { + Spec DeploymentSpec +} + +type DeploymentSpec struct { + Replicas int + Template PodTemplateSpec +} diff --git a/pkg/app/pipedv1/plugin/kubernetes/provider/resource/pod.go b/pkg/app/pipedv1/plugin/kubernetes/provider/resource/pod.go new file mode 100644 index 0000000000..576bfad0d5 --- /dev/null +++ b/pkg/app/pipedv1/plugin/kubernetes/provider/resource/pod.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 resource + +type PodTemplateSpec struct { + Spec PodSpec +} + +type PodSpec struct { + InitContainers []Container + Containers []Container + Volumes []Volume +} + +type Container struct { + Name string + Image string + VolumeMounts []VolumeMount +} + +type Volume struct { + Name string + VolumeSource `json:",inline"` +} + +type VolumeSource struct { + Secret *SecretVolumeSource + ConfigMap *ConfigMapVolumeSource +} + +type SecretVolumeSource struct { + SecretName string +} + +type LocalObjectReference struct { + Name string +} + +type ConfigMapVolumeSource struct { + LocalObjectReference `json:",inline"` +} + +type VolumeMount struct { + Name string +} diff --git a/pkg/app/pipedv1/plugin/kubernetes/provider/resource/statefulset.go b/pkg/app/pipedv1/plugin/kubernetes/provider/resource/statefulset.go new file mode 100644 index 0000000000..acdf8dfed4 --- /dev/null +++ b/pkg/app/pipedv1/plugin/kubernetes/provider/resource/statefulset.go @@ -0,0 +1,24 @@ +// 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 resource + +type StatefulSet struct { + Spec DeploymentSpec +} + +type StatefulSetSpec struct { + Replicas int + Template PodTemplateSpec +} diff --git a/pkg/app/pipedv1/plugin/kubernetes/provider/resourcekey.go b/pkg/app/pipedv1/plugin/kubernetes/provider/resourcekey.go new file mode 100644 index 0000000000..5e86911cd3 --- /dev/null +++ b/pkg/app/pipedv1/plugin/kubernetes/provider/resourcekey.go @@ -0,0 +1,280 @@ +// 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 provider + +import ( + "fmt" + "strings" + + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" +) + +var builtInAPIVersions = map[string]struct{}{ + "admissionregistration.k8s.io/v1": {}, + "admissionregistration.k8s.io/v1beta1": {}, + "apiextensions.k8s.io/v1": {}, + "apiextensions.k8s.io/v1beta1": {}, + "apiregistration.k8s.io/v1": {}, + "apiregistration.k8s.io/v1beta1": {}, + "apps/v1": {}, + "authentication.k8s.io/v1": {}, + "authentication.k8s.io/v1beta1": {}, + "authorization.k8s.io/v1": {}, + "authorization.k8s.io/v1beta1": {}, + "autoscaling/v1": {}, + "autoscaling/v2beta1": {}, + "autoscaling/v2beta2": {}, + "batch/v1": {}, + "batch/v1beta1": {}, + "certificates.k8s.io/v1beta1": {}, + "coordination.k8s.io/v1": {}, + "coordination.k8s.io/v1beta1": {}, + "extensions/v1beta1": {}, + "internal.autoscaling.k8s.io/v1alpha1": {}, + "metrics.k8s.io/v1beta1": {}, + "networking.k8s.io/v1": {}, + "networking.k8s.io/v1beta1": {}, + "node.k8s.io/v1beta1": {}, + "policy/v1": {}, + "policy/v1beta1": {}, + "rbac.authorization.k8s.io/v1": {}, + "rbac.authorization.k8s.io/v1beta1": {}, + "scheduling.k8s.io/v1": {}, + "scheduling.k8s.io/v1beta1": {}, + "storage.k8s.io/v1": {}, + "storage.k8s.io/v1beta1": {}, + "v1": {}, +} + +const ( + KindDeployment = "Deployment" + KindStatefulSet = "StatefulSet" + KindDaemonSet = "DaemonSet" + KindReplicaSet = "ReplicaSet" + KindPod = "Pod" + KindJob = "Job" + KindCronJob = "CronJob" + KindConfigMap = "ConfigMap" + KindSecret = "Secret" + KindPersistentVolume = "PersistentVolume" + KindPersistentVolumeClaim = "PersistentVolumeClaim" + KindService = "Service" + KindIngress = "Ingress" + KindServiceAccount = "ServiceAccount" + KindRole = "Role" + KindRoleBinding = "RoleBinding" + KindClusterRole = "ClusterRole" + KindClusterRoleBinding = "ClusterRoleBinding" + KindNameSpace = "NameSpace" + KindPodDisruptionBudget = "PodDisruptionBudget" + KindCustomResourceDefinition = "CustomResourceDefinition" + + DefaultNamespace = "default" +) + +type APIVersionKind struct { + APIVersion string + Kind string +} + +type ResourceKey struct { + APIVersion string + Kind string + Namespace string + Name string +} + +func (k ResourceKey) String() string { + return fmt.Sprintf("%s:%s:%s:%s", k.APIVersion, k.Kind, k.Namespace, k.Name) +} + +func (k ResourceKey) ReadableString() string { + return fmt.Sprintf("name=%q, kind=%q, namespace=%q, apiVersion=%q", k.Name, k.Kind, k.Namespace, k.APIVersion) +} + +func (k ResourceKey) IsZero() bool { + return k.APIVersion == "" && + k.Kind == "" && + k.Namespace == "" && + k.Name == "" +} + +func (k ResourceKey) IsDeployment() bool { + if k.Kind != KindDeployment { + return false + } + if !IsKubernetesBuiltInResource(k.APIVersion) { + return false + } + return true +} + +func (k ResourceKey) IsReplicaSet() bool { + if k.Kind != KindReplicaSet { + return false + } + if !IsKubernetesBuiltInResource(k.APIVersion) { + return false + } + return true +} + +func (k ResourceKey) IsWorkload() bool { + if !IsKubernetesBuiltInResource(k.APIVersion) { + return false + } + + switch k.Kind { + case KindDeployment: + return true + case KindReplicaSet: + return true + case KindDaemonSet: + return true + case KindPod: + return true + } + + return false +} + +func (k ResourceKey) IsService() bool { + if k.Kind != KindService { + return false + } + if !IsKubernetesBuiltInResource(k.APIVersion) { + return false + } + return true +} + +func (k ResourceKey) IsConfigMap() bool { + if k.Kind != KindConfigMap { + return false + } + if !IsKubernetesBuiltInResource(k.APIVersion) { + return false + } + return true +} + +func (k ResourceKey) IsSecret() bool { + if k.Kind != KindSecret { + return false + } + if !IsKubernetesBuiltInResource(k.APIVersion) { + return false + } + return true +} + +// IsLess reports whether the key should sort before the given key. +func (k ResourceKey) IsLess(a ResourceKey) bool { + if k.APIVersion < a.APIVersion { + return true + } else if k.APIVersion > a.APIVersion { + return false + } + + if k.Kind < a.Kind { + return true + } else if k.Kind > a.Kind { + return false + } + + if k.Namespace < a.Namespace { + return true + } else if k.Namespace > a.Namespace { + return false + } + + if k.Name < a.Name { + return true + } else if k.Name > a.Name { + return false + } + return false +} + +// IsLessWithIgnoringNamespace reports whether the key should sort before the given key, +// but this ignores the comparation of the namesapce. +func (k ResourceKey) IsLessWithIgnoringNamespace(a ResourceKey) bool { + if k.APIVersion < a.APIVersion { + return true + } else if k.APIVersion > a.APIVersion { + return false + } + + if k.Kind < a.Kind { + return true + } else if k.Kind > a.Kind { + return false + } + + if k.Name < a.Name { + return true + } else if k.Name > a.Name { + return false + } + return false +} + +// IsEqualWithIgnoringNamespace checks whether the key is equal to the given key, +// but this ignores the comparation of the namesapce. +func (k ResourceKey) IsEqualWithIgnoringNamespace(a ResourceKey) bool { + if k.APIVersion != a.APIVersion { + return false + } + if k.Kind != a.Kind { + return false + } + if k.Name != a.Name { + return false + } + return true +} + +func MakeResourceKey(obj *unstructured.Unstructured) ResourceKey { + k := ResourceKey{ + APIVersion: obj.GetAPIVersion(), + Kind: obj.GetKind(), + Namespace: obj.GetNamespace(), + Name: obj.GetName(), + } + if k.Namespace == "" { + k.Namespace = DefaultNamespace + } + return k +} + +func DecodeResourceKey(key string) (ResourceKey, error) { + parts := strings.Split(key, ":") + if len(parts) != 4 { + return ResourceKey{}, fmt.Errorf("malformed key") + } + return ResourceKey{ + APIVersion: parts[0], + Kind: parts[1], + Namespace: parts[2], + Name: parts[3], + }, nil +} + +func IsKubernetesBuiltInResource(apiVersion string) bool { + _, ok := builtInAPIVersions[apiVersion] + // TODO: Change the way to detect whether an APIVersion is built-in or not + // rather than depending on this fixed list. + return ok +} diff --git a/pkg/app/pipedv1/plugin/kubernetes/provider/state.go b/pkg/app/pipedv1/plugin/kubernetes/provider/state.go new file mode 100644 index 0000000000..2e2b7c7e4b --- /dev/null +++ b/pkg/app/pipedv1/plugin/kubernetes/provider/state.go @@ -0,0 +1,572 @@ +// 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 provider + +import ( + "fmt" + "sort" + "strings" + "time" + + appsv1 "k8s.io/api/apps/v1" + batchv1 "k8s.io/api/batch/v1" + corev1 "k8s.io/api/core/v1" + networkingv1 "k8s.io/api/networking/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/client-go/kubernetes/scheme" + + "github.com/pipe-cd/pipecd/pkg/model" +) + +func MakeKubernetesResourceState(uid string, key ResourceKey, obj *unstructured.Unstructured, now time.Time) model.KubernetesResourceState { + var ( + owners = obj.GetOwnerReferences() + ownerIDs = make([]string, 0, len(owners)) + creationTime = obj.GetCreationTimestamp() + status, desc = determineResourceHealth(key, obj) + ) + + for _, owner := range owners { + ownerIDs = append(ownerIDs, string(owner.UID)) + } + sort.Strings(ownerIDs) + + state := model.KubernetesResourceState{ + Id: uid, + OwnerIds: ownerIDs, + // TODO: Think about adding more parents by using label selectors + ParentIds: ownerIDs, + Name: key.Name, + ApiVersion: key.APIVersion, + Kind: key.Kind, + Namespace: obj.GetNamespace(), + + HealthStatus: status, + HealthDescription: desc, + + CreatedAt: creationTime.Unix(), + UpdatedAt: now.Unix(), + } + + return state +} + +func determineResourceHealth(key ResourceKey, obj *unstructured.Unstructured) (status model.KubernetesResourceState_HealthStatus, desc string) { + if !IsKubernetesBuiltInResource(key.APIVersion) { + desc = fmt.Sprintf("\"%s/%s\" was applied successfully but its health status couldn't be determined exactly. (Because tracking status for this kind of resource is not supported yet.)", key.APIVersion, key.Kind) + return + } + + switch key.Kind { + case KindDeployment: + return determineDeploymentHealth(obj) + case KindStatefulSet: + return determineStatefulSetHealth(obj) + case KindDaemonSet: + return determineDaemonSetHealth(obj) + case KindReplicaSet: + return determineReplicaSetHealth(obj) + case KindPod: + return determinePodHealth(obj) + case KindJob: + return determineJobHealth(obj) + case KindCronJob: + return determineCronJobHealth(obj) + case KindService: + return determineServiceHealth(obj) + case KindIngress: + return determineIngressHealth(obj) + case KindConfigMap: + return determineConfigMapHealth(obj) + case KindPersistentVolume: + return determinePersistentVolumeHealth(obj) + case KindPersistentVolumeClaim: + return determinePVCHealth(obj) + case KindSecret: + return determineSecretHealth(obj) + case KindServiceAccount: + return determineServiceAccountHealth(obj) + case KindRole: + return determineRoleHealth(obj) + case KindRoleBinding: + return determineRoleBindingHealth(obj) + case KindClusterRole: + return determineClusterRoleHealth(obj) + case KindClusterRoleBinding: + return determineClusterRoleBindingHealth(obj) + case KindNameSpace: + return determineNameSpace(obj) + case KindPodDisruptionBudget: + return determinePodDisruptionBudgetHealth(obj) + default: + desc = "Unimplemented or unknown resource" + return + } +} + +func determineRoleHealth(obj *unstructured.Unstructured) (status model.KubernetesResourceState_HealthStatus, desc string) { + desc = fmt.Sprintf("%q was applied successfully", obj.GetName()) + status = model.KubernetesResourceState_HEALTHY + return +} + +func determineRoleBindingHealth(obj *unstructured.Unstructured) (status model.KubernetesResourceState_HealthStatus, desc string) { + desc = fmt.Sprintf("%q was applied successfully", obj.GetName()) + status = model.KubernetesResourceState_HEALTHY + return +} + +func determineClusterRoleHealth(obj *unstructured.Unstructured) (status model.KubernetesResourceState_HealthStatus, desc string) { + desc = fmt.Sprintf("%q was applied successfully", obj.GetName()) + status = model.KubernetesResourceState_HEALTHY + return +} + +func determineClusterRoleBindingHealth(obj *unstructured.Unstructured) (status model.KubernetesResourceState_HealthStatus, desc string) { + desc = fmt.Sprintf("%q was applied successfully", obj.GetName()) + status = model.KubernetesResourceState_HEALTHY + return +} + +func determineDeploymentHealth(obj *unstructured.Unstructured) (status model.KubernetesResourceState_HealthStatus, desc string) { + d := &appsv1.Deployment{} + err := scheme.Scheme.Convert(obj, d, nil) + if err != nil { + status = model.KubernetesResourceState_OTHER + desc = fmt.Sprintf("Unexpected error while calculating: unable to convert %T to %T: %v", obj, d, err) + return + } + + status = model.KubernetesResourceState_OTHER + if d.Spec.Paused { + desc = "Deployment is paused" + return + } + + // Referred to: + // https://github.com/kubernetes/kubernetes/blob/7942dca975b7be9386540df3c17e309c3cb2de60/staging/src/k8s.io/kubectl/pkg/polymorphichelpers/rollout_status.go#L75 + if d.Generation > d.Status.ObservedGeneration { + desc = "Waiting for rollout to finish because observed deployment generation less than desired generation" + return + } + // TimedOutReason is added in a deployment when its newest replica set fails to show any progress + // within the given deadline (progressDeadlineSeconds). + const timedOutReason = "ProgressDeadlineExceeded" + var cond *appsv1.DeploymentCondition + for i := range d.Status.Conditions { + c := d.Status.Conditions[i] + if c.Type == appsv1.DeploymentProgressing { + cond = &c + break + } + } + if cond != nil && cond.Reason == timedOutReason { + desc = fmt.Sprintf("Deployment %q exceeded its progress deadline", obj.GetName()) + } + + if d.Spec.Replicas == nil { + desc = "The number of desired replicas is unspecified" + return + } + if d.Status.UpdatedReplicas < *d.Spec.Replicas { + desc = fmt.Sprintf("Waiting for remaining %d/%d replicas to be updated", d.Status.UpdatedReplicas, *d.Spec.Replicas) + return + } + if d.Status.UpdatedReplicas < d.Status.Replicas { + desc = fmt.Sprintf("%d old replicas are pending termination", d.Status.Replicas-d.Status.UpdatedReplicas) + return + } + if d.Status.AvailableReplicas < d.Status.Replicas { + desc = fmt.Sprintf("Waiting for remaining %d/%d replicas to be available", d.Status.Replicas-d.Status.AvailableReplicas, d.Status.Replicas) + return + } + + status = model.KubernetesResourceState_HEALTHY + return +} + +func determineStatefulSetHealth(obj *unstructured.Unstructured) (status model.KubernetesResourceState_HealthStatus, desc string) { + s := &appsv1.StatefulSet{} + err := scheme.Scheme.Convert(obj, s, nil) + if err != nil { + status = model.KubernetesResourceState_OTHER + desc = fmt.Sprintf("Unexpected error while calculating: unable to convert %T to %T: %v", obj, s, err) + return + } + + // Referred to: + // https://github.com/kubernetes/kubernetes/blob/7942dca975b7be9386540df3c17e309c3cb2de60/staging/src/k8s.io/kubectl/pkg/polymorphichelpers/rollout_status.go#L130-L149 + status = model.KubernetesResourceState_OTHER + if s.Status.ObservedGeneration == 0 || s.Generation > s.Status.ObservedGeneration { + desc = "Waiting for statefulset spec update to be observed" + return + } + + if s.Spec.Replicas == nil { + desc = "The number of desired replicas is unspecified" + return + } + if *s.Spec.Replicas != s.Status.ReadyReplicas { + desc = fmt.Sprintf("The number of ready replicas (%d) is different from the desired number (%d)", s.Status.ReadyReplicas, *s.Spec.Replicas) + return + } + + // Check if the partitioned roll out is in progress. + if s.Spec.UpdateStrategy.Type == appsv1.RollingUpdateStatefulSetStrategyType && s.Spec.UpdateStrategy.RollingUpdate != nil { + if s.Spec.Replicas != nil && s.Spec.UpdateStrategy.RollingUpdate.Partition != nil { + if s.Status.UpdatedReplicas < (*s.Spec.Replicas - *s.Spec.UpdateStrategy.RollingUpdate.Partition) { + desc = fmt.Sprintf("Waiting for partitioned roll out to finish because %d out of %d new pods have been updated", + s.Status.UpdatedReplicas, (*s.Spec.Replicas - *s.Spec.UpdateStrategy.RollingUpdate.Partition)) + return + } + } + status = model.KubernetesResourceState_HEALTHY + return + } + + if s.Status.UpdateRevision != s.Status.CurrentRevision { + desc = fmt.Sprintf("Waiting for statefulset rolling update to complete %d pods at revision %s", s.Status.UpdatedReplicas, s.Status.UpdateRevision) + return + } + + status = model.KubernetesResourceState_HEALTHY + return +} + +func determineDaemonSetHealth(obj *unstructured.Unstructured) (status model.KubernetesResourceState_HealthStatus, desc string) { + d := &appsv1.DaemonSet{} + err := scheme.Scheme.Convert(obj, d, nil) + if err != nil { + status = model.KubernetesResourceState_OTHER + desc = fmt.Sprintf("Unexpected error while calculating: unable to convert %T to %T: %v", obj, d, err) + return + } + + // Referred to: + // https://github.com/kubernetes/kubernetes/blob/7942dca975b7be9386540df3c17e309c3cb2de60/staging/src/k8s.io/kubectl/pkg/polymorphichelpers/rollout_status.go#L107-L115 + status = model.KubernetesResourceState_OTHER + if d.Status.ObservedGeneration == 0 || d.Generation > d.Status.ObservedGeneration { + desc = "Waiting for rollout to finish because observed daemon set generation less than desired generation" + return + } + if d.Status.UpdatedNumberScheduled < d.Status.DesiredNumberScheduled { + desc = fmt.Sprintf("Waiting for daemon set %q rollout to finish because %d out of %d new pods have been updated", d.Name, d.Status.UpdatedNumberScheduled, d.Status.DesiredNumberScheduled) + return + } + if d.Status.NumberAvailable < d.Status.DesiredNumberScheduled { + desc = fmt.Sprintf("Waiting for daemon set %q rollout to finish because %d of %d updated pods are available", d.Name, d.Status.NumberAvailable, d.Status.DesiredNumberScheduled) + return + } + + if d.Status.NumberMisscheduled > 0 { + desc = fmt.Sprintf("%d nodes that are running the daemon pod, but are not supposed to run the daemon pod", d.Status.NumberMisscheduled) + return + } + if d.Status.NumberUnavailable > 0 { + desc = fmt.Sprintf("%d nodes that should be running the daemon pod and have none of the daemon pod running and available", d.Status.NumberUnavailable) + return + } + + status = model.KubernetesResourceState_HEALTHY + return +} + +func determineReplicaSetHealth(obj *unstructured.Unstructured) (status model.KubernetesResourceState_HealthStatus, desc string) { + r := &appsv1.ReplicaSet{} + err := scheme.Scheme.Convert(obj, r, nil) + if err != nil { + status = model.KubernetesResourceState_OTHER + desc = fmt.Sprintf("Unexpected error while calculating: unable to convert %T to %T: %v", obj, r, err) + return + } + + status = model.KubernetesResourceState_OTHER + if r.Status.ObservedGeneration == 0 || r.Generation > r.Status.ObservedGeneration { + desc = "Waiting for rollout to finish because observed replica set generation less than desired generation" + return + } + + var cond *appsv1.ReplicaSetCondition + for i := range r.Status.Conditions { + c := r.Status.Conditions[i] + if c.Type == appsv1.ReplicaSetReplicaFailure { + cond = &c + break + } + } + switch { + case cond != nil && cond.Status == corev1.ConditionTrue: + desc = cond.Message + return + case r.Spec.Replicas == nil: + desc = "The number of desired replicas is unspecified" + return + case r.Status.AvailableReplicas < *r.Spec.Replicas: + desc = fmt.Sprintf("Waiting for rollout to finish because only %d/%d replicas are available", r.Status.AvailableReplicas, *r.Spec.Replicas) + return + case *r.Spec.Replicas != r.Status.ReadyReplicas: + desc = fmt.Sprintf("The number of ready replicas (%d) is different from the desired number (%d)", r.Status.ReadyReplicas, *r.Spec.Replicas) + return + } + + status = model.KubernetesResourceState_HEALTHY + return +} + +func determineCronJobHealth(obj *unstructured.Unstructured) (status model.KubernetesResourceState_HealthStatus, desc string) { + desc = fmt.Sprintf("%q was applied successfully", obj.GetName()) + status = model.KubernetesResourceState_HEALTHY + return +} + +func determineJobHealth(obj *unstructured.Unstructured) (status model.KubernetesResourceState_HealthStatus, desc string) { + job := &batchv1.Job{} + err := scheme.Scheme.Convert(obj, job, nil) + if err != nil { + status = model.KubernetesResourceState_OTHER + desc = fmt.Sprintf("Unexpected error while calculating: unable to convert %T to %T: %v", obj, job, err) + return + } + + var ( + failed bool + completed bool + message string + ) + for _, condition := range job.Status.Conditions { + switch condition.Type { + case batchv1.JobFailed: + failed = true + completed = true + message = condition.Message + case batchv1.JobComplete: + completed = true + message = condition.Message + } + if failed { + break + } + } + + switch { + case !completed: + status = model.KubernetesResourceState_HEALTHY + desc = "Job is in progress" + case failed: + status = model.KubernetesResourceState_OTHER + desc = message + default: + status = model.KubernetesResourceState_HEALTHY + desc = message + } + + return +} + +func determinePodHealth(obj *unstructured.Unstructured) (status model.KubernetesResourceState_HealthStatus, desc string) { + p := &corev1.Pod{} + err := scheme.Scheme.Convert(obj, p, nil) + if err != nil { + status = model.KubernetesResourceState_OTHER + desc = fmt.Sprintf("Unexpected error while calculating: unable to convert %T to %T: %v", obj, p, err) + return + } + + // Determine based on its container statuses. + if p.Spec.RestartPolicy == corev1.RestartPolicyAlways { + var messages []string + for _, s := range p.Status.ContainerStatuses { + waiting := s.State.Waiting + if waiting == nil { + continue + } + if strings.HasPrefix(waiting.Reason, "Err") || strings.HasSuffix(waiting.Reason, "Error") || strings.HasSuffix(waiting.Reason, "BackOff") { + status = model.KubernetesResourceState_OTHER + messages = append(messages, waiting.Message) + } + } + + if status == model.KubernetesResourceState_OTHER { + desc = strings.Join(messages, ", ") + return + } + } + + // Determine based on its phase. + switch p.Status.Phase { + case corev1.PodRunning, corev1.PodSucceeded: + status = model.KubernetesResourceState_HEALTHY + desc = p.Status.Message + default: + status = model.KubernetesResourceState_OTHER + desc = p.Status.Message + } + return +} + +func determineIngressHealth(obj *unstructured.Unstructured) (status model.KubernetesResourceState_HealthStatus, desc string) { + check := func(ingressList []corev1.LoadBalancerIngress) { + if len(ingressList) == 0 { + status = model.KubernetesResourceState_OTHER + desc = "Ingress points for the load-balancer are in progress" + return + } + status = model.KubernetesResourceState_HEALTHY + } + + v1Ingress := &networkingv1.Ingress{} + err := scheme.Scheme.Convert(obj, v1Ingress, nil) + if err == nil { + check(v1Ingress.Status.LoadBalancer.Ingress) + return + } + + status = model.KubernetesResourceState_OTHER + desc = fmt.Sprintf("Unexpected error while calculating: unable to convert %T to %T: %v", obj, v1Ingress, err) + return +} + +func determineServiceHealth(obj *unstructured.Unstructured) (status model.KubernetesResourceState_HealthStatus, desc string) { + s := &corev1.Service{} + err := scheme.Scheme.Convert(obj, s, nil) + if err != nil { + status = model.KubernetesResourceState_OTHER + desc = fmt.Sprintf("Unexpected error while calculating: unable to convert %T to %T: %v", obj, s, err) + return + } + + status = model.KubernetesResourceState_HEALTHY + if s.Spec.Type != corev1.ServiceTypeLoadBalancer { + return + } + if len(s.Status.LoadBalancer.Ingress) == 0 { + status = model.KubernetesResourceState_OTHER + desc = "Ingress points for the load-balancer are in progress" + return + } + return +} + +func determineConfigMapHealth(obj *unstructured.Unstructured) (status model.KubernetesResourceState_HealthStatus, desc string) { + desc = fmt.Sprintf("%q was applied successfully", obj.GetName()) + status = model.KubernetesResourceState_HEALTHY + return +} + +func determineSecretHealth(obj *unstructured.Unstructured) (status model.KubernetesResourceState_HealthStatus, desc string) { + desc = fmt.Sprintf("%q was applied successfully", obj.GetName()) + status = model.KubernetesResourceState_HEALTHY + return +} + +func determinePersistentVolumeHealth(obj *unstructured.Unstructured) (status model.KubernetesResourceState_HealthStatus, desc string) { + pv := &corev1.PersistentVolume{} + err := scheme.Scheme.Convert(obj, pv, nil) + if err != nil { + status = model.KubernetesResourceState_OTHER + desc = fmt.Sprintf("Unexpected error while calculating: unable to convert %T to %T: %v", obj, pv, err) + return + } + + switch pv.Status.Phase { + case corev1.VolumeBound, corev1.VolumeAvailable: + status = model.KubernetesResourceState_HEALTHY + desc = pv.Status.Message + default: + status = model.KubernetesResourceState_OTHER + desc = pv.Status.Message + } + return +} + +func determinePVCHealth(obj *unstructured.Unstructured) (status model.KubernetesResourceState_HealthStatus, desc string) { + pvc := &corev1.PersistentVolumeClaim{} + err := scheme.Scheme.Convert(obj, pvc, nil) + if err != nil { + status = model.KubernetesResourceState_OTHER + desc = fmt.Sprintf("Unexpected error while calculating: unable to convert %T to %T: %v", obj, pvc, err) + return + } + switch pvc.Status.Phase { + case corev1.ClaimLost: + status = model.KubernetesResourceState_OTHER + desc = "Lost its underlying PersistentVolume" + case corev1.ClaimPending: + status = model.KubernetesResourceState_OTHER + desc = "Being not yet bound" + case corev1.ClaimBound: + status = model.KubernetesResourceState_HEALTHY + default: + status = model.KubernetesResourceState_OTHER + desc = "The current phase of PersistentVolumeClaim is unexpected" + } + return +} + +func determineServiceAccountHealth(obj *unstructured.Unstructured) (status model.KubernetesResourceState_HealthStatus, desc string) { + desc = fmt.Sprintf("%q was applied successfully", obj.GetName()) + status = model.KubernetesResourceState_HEALTHY + return +} + +func determinePodDisruptionBudgetHealth(obj *unstructured.Unstructured) (status model.KubernetesResourceState_HealthStatus, desc string) { + desc = fmt.Sprintf("%q was applied successfully", obj.GetName()) + status = model.KubernetesResourceState_HEALTHY + return +} + +func determineNameSpace(obj *unstructured.Unstructured) (status model.KubernetesResourceState_HealthStatus, desc string) { + ns := &corev1.Namespace{} + err := scheme.Scheme.Convert(obj, ns, nil) + if err != nil { + status = model.KubernetesResourceState_OTHER + desc = fmt.Sprintf("Unexpected error while calculating: unable to convert %T to %T: %v", obj, ns, err) + return + } + + switch ns.Status.Phase { + case corev1.NamespaceActive: + // Go to determine based on the status' conditions. + case corev1.NamespaceTerminating: + status = model.KubernetesResourceState_OTHER + desc = "NameSpace is gracefully terminated" + return + default: + status = model.KubernetesResourceState_OTHER + desc = fmt.Sprintf("The NameSpace is at an unexpected phase: %s", ns.Status.Phase) + return + } + + status = model.KubernetesResourceState_HEALTHY + + var cond *corev1.NamespaceCondition + for i := range ns.Status.Conditions { + c := ns.Status.Conditions[i] + switch c.Type { + case corev1.NamespaceDeletionDiscoveryFailure, corev1.NamespaceDeletionContentFailure, corev1.NamespaceDeletionGVParsingFailure: + cond = &c + } + if cond != nil { + break + } + } + + if cond != nil && cond.Status == corev1.ConditionTrue { + status = model.KubernetesResourceState_OTHER + desc = cond.Message + return + } + return +} diff --git a/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/diff_by_command.yaml b/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/diff_by_command.yaml new file mode 100644 index 0000000000..645055e62d --- /dev/null +++ b/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/diff_by_command.yaml @@ -0,0 +1,69 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: simple + labels: + app: simple + pipecd.dev/managed-by: piped +spec: + replicas: 2 + selector: + matchLabels: + app: simple + template: + metadata: + labels: + app: simple + spec: + containers: + - name: first + image: gcr.io/pipecd/first:v1.0.0 + args: + - a + - b + - c + ports: + - containerPort: 9085 + - name: second + image: gcr.io/pipecd/second:v1.0.0 + args: + - xx + - yy + - zz + ports: + - containerPort: 9085 +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: simple + labels: + app: simple + pipecd.dev/managed-by: piped +spec: + replicas: 3 + selector: + matchLabels: + app: simple + template: + metadata: + labels: + app: simple + spec: + containers: + - name: first + image: gcr.io/pipecd/first:v1.0.0 + args: + - a + - d + - b + - c + ports: + - containerPort: 9085 + - name: second + image: gcr.io/pipecd/second:v1.0.0 + args: + - xx + - zz + ports: + - containerPort: 9085 diff --git a/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/diff_by_command_no_change.yaml b/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/diff_by_command_no_change.yaml new file mode 100644 index 0000000000..e5462ba31b --- /dev/null +++ b/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/diff_by_command_no_change.yaml @@ -0,0 +1,51 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: simple + labels: + app: simple + pipecd.dev/managed-by: piped +spec: + replicas: 2 + selector: + matchLabels: + app: simple + template: + metadata: + labels: + app: simple + spec: + containers: + - name: helloworld + image: gcr.io/pipecd/helloworld:v1.0.0 + args: + - hi + - hello + ports: + - containerPort: 9085 +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: simple + labels: + app: simple + pipecd.dev/managed-by: piped +spec: + replicas: 2 + selector: + matchLabels: + app: simple + template: + metadata: + labels: + app: simple + spec: + containers: + - name: helloworld + image: gcr.io/pipecd/helloworld:v1.0.0 + args: + - hi + - hello + ports: + - containerPort: 9085 diff --git a/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/diff_ignore_missing_fields.yaml b/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/diff_ignore_missing_fields.yaml new file mode 100644 index 0000000000..9edc380f09 --- /dev/null +++ b/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/diff_ignore_missing_fields.yaml @@ -0,0 +1,101 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: canary + labels: + app: canary +spec: + replicas: 2 + selector: + matchLabels: + app: canary + template: + metadata: + labels: + app: canary + spec: + containers: + - name: helloworld + image: gcr.io/kapetanios/pipecd-helloworld:v0.0.2-159-g2fde42c + args: + - server + ports: + - containerPort: 9085 +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + annotations: + deployment.kubernetes.io/revision: "1" + kubectl.kubernetes.io/last-applied-configuration: | + {"apiVersion":"apps/v1","kind":"Deployment","metadata":{"annotations":{"pipecd.dev/application":"7230d36c-dceb-4037-b3c8-94abc57b2eda","pipecd.dev/commit-hash":"ef981187e5817c589617a114d5d5ae36adfbb373","pipecd.dev/managed-by":"piped","pipecd.dev/original-api-version":"apps/v1","pipecd.dev/piped":"70feaff4-a6b7-4d03-b5a9-26b2cbabf77b","pipecd.dev/resource-key":"apps/v1:Deployment:default:canary","pipecd.dev/variant":"primary"},"labels":{"app":"canary"},"name":"canary","namespace":"default"},"spec":{"replicas":2,"selector":{"matchLabels":{"app":"canary"}},"template":{"metadata":{"labels":{"app":"canary"}},"spec":{"containers":[{"args":["server"],"image":"gcr.io/kapetanios/pipecd-helloworld:v0.0.2-159-g2fde42c","name":"helloworld","ports":[{"containerPort":9085}]}]}}}} + pipecd.dev/application: 7230d36c-dceb-4037-b3c8-94abc57b2eda + pipecd.dev/commit-hash: ef981187e5817c589617a114d5d5ae36adfbb373 + pipecd.dev/managed-by: piped + pipecd.dev/original-api-version: apps/v1 + pipecd.dev/piped: 70feaff4-a6b7-4d03-b5a9-26b2cbabf77b + pipecd.dev/resource-key: apps/v1:Deployment:default:canary + pipecd.dev/variant: primary + creationTimestamp: "2020-06-18T14:23:30Z" + generation: 2 + labels: + app: canary + name: canary + namespace: default + resourceVersion: "3713438" + selfLink: /apis/apps/v1/namespaces/default/deployments/canary + uid: 00e655f8-0c27-477e-9178-97dab0d91316 +spec: + progressDeadlineSeconds: 600 + replicas: 2 + revisionHistoryLimit: 10 + selector: + matchLabels: + app: canary + strategy: + rollingUpdate: + maxSurge: 25% + maxUnavailable: 25% + type: RollingUpdate + template: + metadata: + creationTimestamp: null + labels: + app: canary + spec: + containers: + - args: + - server + image: gcr.io/kapetanios/pipecd-helloworld:v0.0.2-159-g2fde42c + imagePullPolicy: IfNotPresent + name: helloworld + ports: + - containerPort: 9085 + protocol: TCP + resources: {} + terminationMessagePath: /dev/termination-log + terminationMessagePolicy: File + dnsPolicy: ClusterFirst + restartPolicy: Always + schedulerName: default-scheduler + securityContext: {} + terminationGracePeriodSeconds: 30 +status: + availableReplicas: 2 + conditions: + - lastTransitionTime: "2020-06-18T14:23:31Z" + lastUpdateTime: "2020-06-18T14:23:31Z" + message: Deployment has minimum availability. + reason: MinimumReplicasAvailable + status: "True" + type: Available + - lastTransitionTime: "2020-06-18T14:23:30Z" + lastUpdateTime: "2020-06-18T14:23:31Z" + message: ReplicaSet "canary-78d4c97d9c" has successfully progressed. + reason: NewReplicaSetAvailable + status: "True" + type: Progressing + observedGeneration: 2 + readyReplicas: 2 + replicas: 2 + updatedReplicas: 2 \ No newline at end of file diff --git a/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/diff_ignore_order.yaml b/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/diff_ignore_order.yaml new file mode 100644 index 0000000000..82c4487051 --- /dev/null +++ b/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/diff_ignore_order.yaml @@ -0,0 +1,51 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: simple + labels: + app: simple + pipecd.dev/managed-by: piped +spec: + replicas: 2 + selector: + matchLabels: + app: simple + template: + metadata: + labels: + app: simple + spec: + containers: + - name: helloworld + image: gcr.io/pipecd/helloworld:v1.0.0 + args: + - hello + - hi + ports: + - containerPort: 9085 +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: simple + labels: + pipecd.dev/managed-by: piped + app: simple +spec: + replicas: 2 + selector: + matchLabels: + app: simple + template: + metadata: + labels: + app: simple + spec: + containers: + - name: helloworld + image: gcr.io/pipecd/helloworld:v1.0.0 + args: + - hi + - hello + ports: + - containerPort: 9085 diff --git a/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/diff_multi_diffs.yaml b/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/diff_multi_diffs.yaml new file mode 100644 index 0000000000..ce4f073fde --- /dev/null +++ b/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/diff_multi_diffs.yaml @@ -0,0 +1,53 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: simple + labels: + app: simple + pipecd.dev/managed-by: piped + change: first +spec: + replicas: 2 + selector: + matchLabels: + app: simple + template: + metadata: + labels: + app: simple + spec: + containers: + - name: helloworld + image: gcr.io/pipecd/helloworld:v1.0.0 + args: + - hi + - hello + ports: + - containerPort: 9085 +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: simple + labels: + pipecd.dev/managed-by: piped + app: simple + change: second +spec: + replicas: 2 + selector: + matchLabels: + app: simple + template: + metadata: + labels: + app: simple + spec: + containers: + - name: helloworld + image: gcr.io/pipecd/helloworld:v2.0.0 + args: + - hi + - hello + ports: + - containerPort: 9085 diff --git a/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/diff_no_diff.yaml b/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/diff_no_diff.yaml new file mode 100644 index 0000000000..62d9cd9ac2 --- /dev/null +++ b/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/diff_no_diff.yaml @@ -0,0 +1,51 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: simple + labels: + app: simple + pipecd.dev/managed-by: piped +spec: + replicas: 2 + selector: + matchLabels: + app: simple + template: + metadata: + labels: + app: simple + spec: + containers: + - name: helloworld + image: gcr.io/pipecd/helloworld:v1.0.0 + args: + - hi + - hello + ports: + - containerPort: 9085 +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: simple + labels: + pipecd.dev/managed-by: piped + app: simple +spec: + replicas: 2 + selector: + matchLabels: + app: simple + template: + metadata: + labels: + app: simple + spec: + containers: + - name: helloworld + image: gcr.io/pipecd/helloworld:v1.0.0 + args: + - hi + - hello + ports: + - containerPort: 9085 diff --git a/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/diff_redact.yaml b/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/diff_redact.yaml new file mode 100644 index 0000000000..73802b9f3a --- /dev/null +++ b/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/diff_redact.yaml @@ -0,0 +1,17 @@ +apiVersion: v1 +metadata: + name: pipecd-secrets + namespace: default +kind: Secret +type: Opaque +data: + service-account.json: real-secret-data-1 +--- +apiVersion: v1 +metadata: + name: pipecd-secrets + namespace: default +kind: Secret +type: Opaque +data: + service-account.json: real-secret-data-2 diff --git a/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testchart/.helmignore b/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testchart/.helmignore new file mode 100644 index 0000000000..0e8a0eb36f --- /dev/null +++ b/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testchart/.helmignore @@ -0,0 +1,23 @@ +# Patterns to ignore when building packages. +# This supports shell glob matching, relative path matching, and +# negation (prefixed with !). Only one pattern per line. +.DS_Store +# Common VCS dirs +.git/ +.gitignore +.bzr/ +.bzrignore +.hg/ +.hgignore +.svn/ +# Common backup files +*.swp +*.bak +*.tmp +*.orig +*~ +# Various IDEs +.project +.idea/ +*.tmproj +.vscode/ diff --git a/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testchart/Chart.yaml b/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testchart/Chart.yaml new file mode 100644 index 0000000000..5bbebd26c2 --- /dev/null +++ b/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testchart/Chart.yaml @@ -0,0 +1,23 @@ +apiVersion: v2 +name: testchart +description: A Helm chart for Kubernetes + +# A chart can be either an 'application' or a 'library' chart. +# +# Application charts are a collection of templates that can be packaged into versioned archives +# to be deployed. +# +# Library charts provide useful utilities or functions for the chart developer. They're included as +# a dependency of application charts to inject those utilities and functions into the rendering +# pipeline. Library charts do not define any templates and therefore cannot be deployed. +type: application + +# This is the chart version. This version number should be incremented each time you make changes +# to the chart and its templates, including the app version. +# Versions are expected to follow Semantic Versioning (https://semver.org/) +version: 0.1.0 + +# This is the version number of the application being deployed. This version number should be +# incremented each time you make changes to the application. Versions are not expected to +# follow Semantic Versioning. They should reflect the version the application is using. +appVersion: 1.16.0 diff --git a/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testchart/templates/NOTES.txt b/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testchart/templates/NOTES.txt new file mode 100644 index 0000000000..9b8fb51f68 --- /dev/null +++ b/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testchart/templates/NOTES.txt @@ -0,0 +1,21 @@ +1. Get the application URL by running these commands: +{{- if .Values.ingress.enabled }} +{{- range $host := .Values.ingress.hosts }} + {{- range .paths }} + http{{ if $.Values.ingress.tls }}s{{ end }}://{{ $host.host }}{{ . }} + {{- end }} +{{- end }} +{{- else if contains "NodePort" .Values.service.type }} + export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "testchart.fullname" . }}) + export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}") + echo http://$NODE_IP:$NODE_PORT +{{- else if contains "LoadBalancer" .Values.service.type }} + NOTE: It may take a few minutes for the LoadBalancer IP to be available. + You can watch the status of by running 'kubectl get --namespace {{ .Release.Namespace }} svc -w {{ include "testchart.fullname" . }}' + export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "testchart.fullname" . }} --template "{{"{{ range (index .status.loadBalancer.ingress 0) }}{{.}}{{ end }}"}}") + echo http://$SERVICE_IP:{{ .Values.service.port }} +{{- else if contains "ClusterIP" .Values.service.type }} + export POD_NAME=$(kubectl get pods --namespace {{ .Release.Namespace }} -l "app.kubernetes.io/name={{ include "testchart.name" . }},app.kubernetes.io/instance={{ .Release.Name }}" -o jsonpath="{.items[0].metadata.name}") + echo "Visit http://127.0.0.1:8080 to use your application" + kubectl --namespace {{ .Release.Namespace }} port-forward $POD_NAME 8080:80 +{{- end }} diff --git a/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testchart/templates/_helpers.tpl b/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testchart/templates/_helpers.tpl new file mode 100644 index 0000000000..698af2572c --- /dev/null +++ b/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testchart/templates/_helpers.tpl @@ -0,0 +1,63 @@ +{{/* vim: set filetype=mustache: */}} +{{/* +Expand the name of the chart. +*/}} +{{- define "testchart.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Create a default fully qualified app name. +We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). +If release name contains chart name it will be used as a full name. +*/}} +{{- define "testchart.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- $name := default .Chart.Name .Values.nameOverride }} +{{- if contains $name .Release.Name }} +{{- .Release.Name | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} +{{- end }} + +{{/* +Create chart name and version as used by the chart label. +*/}} +{{- define "testchart.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Common labels +*/}} +{{- define "testchart.labels" -}} +helm.sh/chart: {{ include "testchart.chart" . }} +{{ include "testchart.selectorLabels" . }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{/* +Selector labels +*/}} +{{- define "testchart.selectorLabels" -}} +app.kubernetes.io/name: {{ include "testchart.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{/* +Create the name of the service account to use +*/}} +{{- define "testchart.serviceAccountName" -}} +{{- if .Values.serviceAccount.create }} +{{- default (include "testchart.fullname" .) .Values.serviceAccount.name }} +{{- else }} +{{- default "default" .Values.serviceAccount.name }} +{{- end }} +{{- end }} diff --git a/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testchart/templates/deployment.yaml b/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testchart/templates/deployment.yaml new file mode 100644 index 0000000000..b9c4cf95df --- /dev/null +++ b/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testchart/templates/deployment.yaml @@ -0,0 +1,62 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "testchart.fullname" . }} + labels: + {{- include "testchart.labels" . | nindent 4 }} + namespace: {{.Release.Namespace}} +spec: +{{- if not .Values.autoscaling.enabled }} + replicas: {{ .Values.replicaCount }} +{{- end }} + selector: + matchLabels: + {{- include "testchart.selectorLabels" . | nindent 6 }} + template: + metadata: + {{- with .Values.podAnnotations }} + annotations: + {{- toYaml . | nindent 8 }} + {{- end }} + labels: + {{- include "testchart.selectorLabels" . | nindent 8 }} + spec: + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + serviceAccountName: {{ include "testchart.serviceAccountName" . }} + securityContext: + {{- toYaml .Values.podSecurityContext | nindent 8 }} + containers: + - name: {{ .Chart.Name }} + securityContext: + {{- toYaml .Values.securityContext | nindent 12 }} + image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + ports: + - name: http + containerPort: 80 + protocol: TCP + livenessProbe: + httpGet: + path: / + port: http + readinessProbe: + httpGet: + path: / + port: http + resources: + {{- toYaml .Values.resources | nindent 12 }} + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} diff --git a/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testchart/templates/hpa.yaml b/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testchart/templates/hpa.yaml new file mode 100644 index 0000000000..58c5a47d7e --- /dev/null +++ b/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testchart/templates/hpa.yaml @@ -0,0 +1,28 @@ +{{- if .Values.autoscaling.enabled }} +apiVersion: autoscaling/v2beta1 +kind: HorizontalPodAutoscaler +metadata: + name: {{ include "testchart.fullname" . }} + labels: + {{- include "testchart.labels" . | nindent 4 }} +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: {{ include "testchart.fullname" . }} + minReplicas: {{ .Values.autoscaling.minReplicas }} + maxReplicas: {{ .Values.autoscaling.maxReplicas }} + metrics: + {{- if .Values.autoscaling.targetCPUUtilizationPercentage }} + - type: Resource + resource: + name: cpu + targetAverageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }} + {{- end }} + {{- if .Values.autoscaling.targetMemoryUtilizationPercentage }} + - type: Resource + resource: + name: memory + targetAverageUtilization: {{ .Values.autoscaling.targetMemoryUtilizationPercentage }} + {{- end }} +{{- end }} diff --git a/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testchart/templates/ingress.yaml b/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testchart/templates/ingress.yaml new file mode 100644 index 0000000000..7c17e022f3 --- /dev/null +++ b/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testchart/templates/ingress.yaml @@ -0,0 +1,42 @@ +{{- if .Values.ingress.enabled -}} +{{- $fullName := include "testchart.fullname" . -}} +{{- $svcPort := .Values.service.port -}} +{{- if semverCompare ">=1.14-0" .Capabilities.KubeVersion.GitVersion -}} +apiVersion: networking.k8s.io/v1beta1 +{{- else -}} +apiVersion: extensions/v1beta1 +{{- end }} +kind: Ingress +metadata: + name: {{ $fullName }} + labels: + {{- include "testchart.labels" . | nindent 4 }} + {{- with .Values.ingress.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} + namespace: {{.Release.Namespace}} +spec: + {{- if .Values.ingress.tls }} + tls: + {{- range .Values.ingress.tls }} + - hosts: + {{- range .hosts }} + - {{ . | quote }} + {{- end }} + secretName: {{ .secretName }} + {{- end }} + {{- end }} + rules: + {{- range .Values.ingress.hosts }} + - host: {{ .host | quote }} + http: + paths: + {{- range .paths }} + - path: {{ . }} + backend: + serviceName: {{ $fullName }} + servicePort: {{ $svcPort }} + {{- end }} + {{- end }} + {{- end }} diff --git a/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testchart/templates/service.yaml b/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testchart/templates/service.yaml new file mode 100644 index 0000000000..d8c6e26de7 --- /dev/null +++ b/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testchart/templates/service.yaml @@ -0,0 +1,16 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "testchart.fullname" . }} + labels: + {{- include "testchart.labels" . | nindent 4 }} + namespace: {{.Release.Namespace}} +spec: + type: {{ .Values.service.type }} + ports: + - port: {{ .Values.service.port }} + targetPort: http + protocol: TCP + name: http + selector: + {{- include "testchart.selectorLabels" . | nindent 4 }} diff --git a/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testchart/templates/serviceaccount.yaml b/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testchart/templates/serviceaccount.yaml new file mode 100644 index 0000000000..4537db7747 --- /dev/null +++ b/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testchart/templates/serviceaccount.yaml @@ -0,0 +1,13 @@ +{{- if .Values.serviceAccount.create -}} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "testchart.serviceAccountName" . }} + namespace: {{.Release.Namespace}} + labels: + {{- include "testchart.labels" . | nindent 4 }} + {{- with .Values.serviceAccount.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +{{- end }} diff --git a/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testchart/templates/tests/test-connection.yaml b/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testchart/templates/tests/test-connection.yaml new file mode 100644 index 0000000000..94ec750986 --- /dev/null +++ b/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testchart/templates/tests/test-connection.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Pod +metadata: + name: "{{ include "testchart.fullname" . }}-test-connection" + labels: + {{- include "testchart.labels" . | nindent 4 }} + annotations: + "helm.sh/hook": test-success +spec: + containers: + - name: wget + image: busybox + command: ['wget'] + args: ['{{ include "testchart.fullname" . }}:{{ .Values.service.port }}'] + restartPolicy: Never diff --git a/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testchart/values.yaml b/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testchart/values.yaml new file mode 100644 index 0000000000..6c45a41504 --- /dev/null +++ b/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testchart/values.yaml @@ -0,0 +1,79 @@ +# Default values for testchart. +# This is a YAML-formatted file. +# Declare variables to be passed into your templates. + +replicaCount: 1 + +image: + repository: nginx + pullPolicy: IfNotPresent + # Overrides the image tag whose default is the chart appVersion. + tag: "" + +imagePullSecrets: [] +nameOverride: "" +fullnameOverride: "" + +serviceAccount: + # Specifies whether a service account should be created + create: true + # Annotations to add to the service account + annotations: {} + # The name of the service account to use. + # If not set and create is true, a name is generated using the fullname template + name: "" + +podAnnotations: {} + +podSecurityContext: {} + # fsGroup: 2000 + +securityContext: {} + # capabilities: + # drop: + # - ALL + # readOnlyRootFilesystem: true + # runAsNonRoot: true + # runAsUser: 1000 + +service: + type: ClusterIP + port: 80 + +ingress: + enabled: false + annotations: {} + # kubernetes.io/ingress.class: nginx + # kubernetes.io/tls-acme: "true" + hosts: + - host: chart-example.local + paths: [] + tls: [] + # - secretName: chart-example-tls + # hosts: + # - chart-example.local + +resources: {} + # We usually recommend not to specify default resources and to leave this as a conscious + # choice for the user. This also increases chances charts run on environments with little + # resources, such as Minikube. If you do want to specify resources, uncomment the following + # lines, adjust them as necessary, and remove the curly braces after 'resources:'. + # limits: + # cpu: 100m + # memory: 128Mi + # requests: + # cpu: 100m + # memory: 128Mi + +autoscaling: + enabled: false + minReplicas: 1 + maxReplicas: 100 + targetCPUUtilizationPercentage: 80 + # targetMemoryUtilizationPercentage: 80 + +nodeSelector: {} + +tolerations: [] + +affinity: {} diff --git a/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testhelm/appconfdir/app.pipecd.yaml b/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testhelm/appconfdir/app.pipecd.yaml new file mode 100644 index 0000000000..e69de29bb2 diff --git a/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testhelm/appconfdir/dir/values.yaml b/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testhelm/appconfdir/dir/values.yaml new file mode 100644 index 0000000000..e69de29bb2 diff --git a/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testhelm/appconfdir/invalid-symlink b/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testhelm/appconfdir/invalid-symlink new file mode 120000 index 0000000000..555dec973e --- /dev/null +++ b/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testhelm/appconfdir/invalid-symlink @@ -0,0 +1 @@ +/etc/hosts \ No newline at end of file diff --git a/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testhelm/appconfdir/valid-symlink b/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testhelm/appconfdir/valid-symlink new file mode 120000 index 0000000000..a53324e8c5 --- /dev/null +++ b/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testhelm/appconfdir/valid-symlink @@ -0,0 +1 @@ +dir/values.yaml \ No newline at end of file diff --git a/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testhelm/appconfdir/values.yaml b/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testhelm/appconfdir/values.yaml new file mode 100644 index 0000000000..e69de29bb2 diff --git a/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testhelm/values.yaml b/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testhelm/values.yaml new file mode 100644 index 0000000000..e69de29bb2 diff --git a/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testkustomize/deployment.yaml b/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testkustomize/deployment.yaml new file mode 100644 index 0000000000..1360acf696 --- /dev/null +++ b/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testkustomize/deployment.yaml @@ -0,0 +1,33 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: the-deployment +spec: + replicas: 3 + selector: + matchLabels: + deployment: hello + template: + metadata: + labels: + deployment: hello + spec: + containers: + - name: the-container + image: monopole/hello:1 + command: ["/hello", + "--port=8080", + "--enableRiskyFeature=$(ENABLE_RISKY)"] + ports: + - containerPort: 8080 + env: + - name: ALT_GREETING + valueFrom: + configMapKeyRef: + name: the-map + key: altGreeting + - name: ENABLE_RISKY + valueFrom: + configMapKeyRef: + name: the-map + key: enableRisky \ No newline at end of file diff --git a/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testkustomize/kustomization.yaml b/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testkustomize/kustomization.yaml new file mode 100644 index 0000000000..c7cf5bb89a --- /dev/null +++ b/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testkustomize/kustomization.yaml @@ -0,0 +1,5 @@ +commonLabels: + app: hello + +resources: + - deployment.yaml \ No newline at end of file diff --git a/tool/codegen/codegen.sh b/tool/codegen/codegen.sh index 8f30860e33..d56fb744c5 100755 --- a/tool/codegen/codegen.sh +++ b/tool/codegen/codegen.sh @@ -106,6 +106,7 @@ mockPackageNames=( "datastoretest" "filestoretest" "kubernetestest" + "kubernetestest" "cachetest" "gittest" "jwttest" @@ -116,6 +117,7 @@ mockDestinations=( "pkg/datastore/datastoretest/datastore.mock.go" "pkg/filestore/filestoretest/filestore.mock.go" "pkg/app/piped/platformprovider/kubernetes/kubernetestest/kubernetes.mock.go" + "pkg/app/pipedv1/plugin/kubernetes/provider/kubernetestest/kubernetes.mock.go" "pkg/cache/cachetest/cache.mock.go" "pkg/git/gittest/git.mock.go" "pkg/jwt/jwttest/jwt.mock.go" @@ -126,6 +128,7 @@ mockSources=( "github.com/pipe-cd/pipecd/pkg/datastore" "github.com/pipe-cd/pipecd/pkg/filestore" "github.com/pipe-cd/pipecd/pkg/app/piped/platformprovider/kubernetes" + "github.com/pipe-cd/pipecd/pkg/app/pipedv1/plugin/kubernetes/provider" "github.com/pipe-cd/pipecd/pkg/cache" "github.com/pipe-cd/pipecd/pkg/git" "github.com/pipe-cd/pipecd/pkg/jwt" @@ -136,6 +139,7 @@ mockInterfaces=( "ProjectStore,PipedStore,ApplicationStore,DeploymentStore,CommandStore" "Store" "Applier,Loader" + "Applier,Loader" "Getter,Putter,Deleter,Cache" "Repo" "Signer,Verifier" From 083e13a525f8b2708dbd4fde7d0f79c168f5917d Mon Sep 17 00:00:00 2001 From: Khanh Tran <32532742+khanhtc1202@users.noreply.github.com> Date: Mon, 7 Oct 2024 07:03:52 +0700 Subject: [PATCH 40/84] Update feature status (#5256) Signed-off-by: khanhtc1202 --- docs/content/en/docs-v0.49.x/feature-status/_index.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/docs/content/en/docs-v0.49.x/feature-status/_index.md b/docs/content/en/docs-v0.49.x/feature-status/_index.md index 25b11caa07..17e2b7add4 100644 --- a/docs/content/en/docs-v0.49.x/feature-status/_index.md +++ b/docs/content/en/docs-v0.49.x/feature-status/_index.md @@ -32,7 +32,6 @@ Please note that the phases (Incubating, Alpha, Beta, and Stable) are applied to | Support Kustomize | Beta | | Support Istio service mesh | Beta | | Support SMI service mesh | Incubating | -| Support [AWS App Mesh](https://aws.amazon.com/app-mesh/) | Incubating | | [Plan preview](../user-guide/plan-preview) | Beta | | [Manifest attachment](../user-guide/managing-application/manifest-attachment) | Alpha | @@ -83,7 +82,6 @@ Please note that the phases (Incubating, Alpha, Beta, and Stable) are applied to | [Application live state](../user-guide/managing-application/application-live-state/) | Alpha *1 | | Quick sync deployment for [ECS Service Discovery](https://docs.aws.amazon.com/AmazonECS/latest/developerguide/service-discovery.html) | Alpha | | Deployment with a defined pipeline for [ECS Service Discovery](https://docs.aws.amazon.com/AmazonECS/latest/developerguide/service-discovery.html) | Alpha | -| Support [AWS App Mesh](https://aws.amazon.com/app-mesh/) | Incubating | | [Plan preview](../user-guide/plan-preview) | Alpha | | [Manifest attachment](../user-guide/managing-application/manifest-attachment) | Alpha | From 401f018dcc3f34ea71412c8792a29549fa8a5970 Mon Sep 17 00:00:00 2001 From: Khanh Tran <32532742+khanhtc1202@users.noreply.github.com> Date: Mon, 7 Oct 2024 07:05:16 +0700 Subject: [PATCH 41/84] Update quickstart header note (#5255) Signed-off-by: khanhtc1202 --- docs/content/en/docs-dev/quickstart/_index.md | 4 +++- docs/content/en/docs-v0.49.x/quickstart/_index.md | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/content/en/docs-dev/quickstart/_index.md b/docs/content/en/docs-dev/quickstart/_index.md index ad0681530a..a19b1b3b38 100644 --- a/docs/content/en/docs-dev/quickstart/_index.md +++ b/docs/content/en/docs-dev/quickstart/_index.md @@ -6,7 +6,9 @@ description: > This page describes how to quickly get started with PipeCD on Kubernetes. --- -This page is a guideline for installing PipeCD into your Kubernetes cluster and deploying a "hello world" application to that same Kubernetes cluster. +PipeCD is constructed by two components: the Control plane and the piped (agent) (ref: [PipeCD concepts](../concepts/)). The control plane can be thought of as a regular web service application that can be installed anywhere, while the piped agent is a single binary that can run as a pod in a Kubernetes cluster, a container on ECS, a serverless function like Lambda, Cloud Run, or a process running directly on your local machine. + +This page is a guideline for installing PipeCD (both two components) into your Kubernetes cluster and deploying a "hello world" application to that same Kubernetes cluster. Note: diff --git a/docs/content/en/docs-v0.49.x/quickstart/_index.md b/docs/content/en/docs-v0.49.x/quickstart/_index.md index ad0681530a..a19b1b3b38 100644 --- a/docs/content/en/docs-v0.49.x/quickstart/_index.md +++ b/docs/content/en/docs-v0.49.x/quickstart/_index.md @@ -6,7 +6,9 @@ description: > This page describes how to quickly get started with PipeCD on Kubernetes. --- -This page is a guideline for installing PipeCD into your Kubernetes cluster and deploying a "hello world" application to that same Kubernetes cluster. +PipeCD is constructed by two components: the Control plane and the piped (agent) (ref: [PipeCD concepts](../concepts/)). The control plane can be thought of as a regular web service application that can be installed anywhere, while the piped agent is a single binary that can run as a pod in a Kubernetes cluster, a container on ECS, a serverless function like Lambda, Cloud Run, or a process running directly on your local machine. + +This page is a guideline for installing PipeCD (both two components) into your Kubernetes cluster and deploying a "hello world" application to that same Kubernetes cluster. Note: From 2fc66fa152450e530f5e5fa920092d6ec8165435 Mon Sep 17 00:00:00 2001 From: HoangNguyen689 Date: Sun, 6 Oct 2024 18:13:42 -0700 Subject: [PATCH 42/84] Remove subnet ordering in head manifest (#5254) Signed-off-by: HoangNguyen689 --- pkg/app/piped/driftdetector/ecs/detector.go | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/pkg/app/piped/driftdetector/ecs/detector.go b/pkg/app/piped/driftdetector/ecs/detector.go index 3852ae79de..a83309d940 100644 --- a/pkg/app/piped/driftdetector/ecs/detector.go +++ b/pkg/app/piped/driftdetector/ecs/detector.go @@ -280,7 +280,7 @@ func ignoreParameters(liveManifests provider.ECSManifests, headManifests provide } if headService.NetworkConfiguration != nil && headService.NetworkConfiguration.AwsvpcConfiguration != nil { awsvpcCfg := headService.NetworkConfiguration.AwsvpcConfiguration - slices.Sort(awsvpcCfg.Subnets) // Livestate's Subnets are sorted by ECS. (SecurityGroups and ContainerDefinitions are not sorted) + slices.Sort(awsvpcCfg.Subnets) if len(awsvpcCfg.AssignPublicIp) == 0 { // AssignPublicIp is DISABLED by default. // See https://docs.aws.amazon.com/AmazonECS/latest/APIReference/API_AwsVpcConfiguration.html#ECS-Type-AwsVpcConfiguration-assignPublicIp. @@ -288,6 +288,12 @@ func ignoreParameters(liveManifests provider.ECSManifests, headManifests provide } } + // Sort the subnets of the live service as well + if liveService.NetworkConfiguration != nil && liveService.NetworkConfiguration.AwsvpcConfiguration != nil { + awsvpcCfg := liveService.NetworkConfiguration.AwsvpcConfiguration + slices.Sort(awsvpcCfg.Subnets) + } + // TODO: In order to check diff of the tags, we need to add pipecd-managed tags and sort. liveService.Tags = nil headService.Tags = nil From d1f3d7e56d9ff3b36c3fd27899209e039e577243 Mon Sep 17 00:00:00 2001 From: Shinnosuke Sawada-Dazai Date: Mon, 7 Oct 2024 12:15:38 +0900 Subject: [PATCH 43/84] Revert "Copy platform provider kubernetes under the plugin directory (#5250)" (#5253) This reverts commit c866fa37de1443537cb736af1afc42493e693643. Signed-off-by: Shinnosuke Sawada-Dazai --- .../plugin/kubernetes/provider/applier.go | 283 --------- .../plugin/kubernetes/provider/cache.go | 68 --- .../provider/chartrepo/chartrepo.go | 87 --- .../plugin/kubernetes/provider/deployment.go | 110 ---- .../kubernetes/provider/deployment_test.go | 358 ----------- .../plugin/kubernetes/provider/diff.go | 305 ---------- .../plugin/kubernetes/provider/diff_test.go | 572 ------------------ .../plugin/kubernetes/provider/diffutil.go | 120 ---- .../kubernetes/provider/diffutil_test.go | 218 ------- .../plugin/kubernetes/provider/hasher.go | 157 ----- .../plugin/kubernetes/provider/hasher_test.go | 169 ------ .../plugin/kubernetes/provider/helm.go | 430 ------------- .../plugin/kubernetes/provider/helm_test.go | 177 ------ .../plugin/kubernetes/provider/kubectl.go | 285 --------- .../plugin/kubernetes/provider/kubernetes.go | 45 -- .../provider/kubernetesmetrics/metrics.go | 81 --- .../kubernetestest/kubernetes.mock.go | 144 ----- .../plugin/kubernetes/provider/kustomize.go | 67 -- .../kubernetes/provider/kustomize_test.go | 52 -- .../plugin/kubernetes/provider/loader.go | 230 ------- .../plugin/kubernetes/provider/loader_test.go | 78 --- .../plugin/kubernetes/provider/manifest.go | 249 -------- .../kubernetes/provider/manifest_test.go | 193 ------ .../provider/resource/deployment.go | 24 - .../kubernetes/provider/resource/pod.go | 57 -- .../provider/resource/statefulset.go | 24 - .../plugin/kubernetes/provider/resourcekey.go | 280 --------- .../plugin/kubernetes/provider/state.go | 572 ------------------ .../provider/testdata/diff_by_command.yaml | 69 --- .../testdata/diff_by_command_no_change.yaml | 51 -- .../testdata/diff_ignore_missing_fields.yaml | 101 ---- .../provider/testdata/diff_ignore_order.yaml | 51 -- .../provider/testdata/diff_multi_diffs.yaml | 53 -- .../provider/testdata/diff_no_diff.yaml | 51 -- .../provider/testdata/diff_redact.yaml | 17 - .../provider/testdata/testchart/.helmignore | 23 - .../provider/testdata/testchart/Chart.yaml | 23 - .../testdata/testchart/templates/NOTES.txt | 21 - .../testdata/testchart/templates/_helpers.tpl | 63 -- .../testchart/templates/deployment.yaml | 62 -- .../testdata/testchart/templates/hpa.yaml | 28 - .../testdata/testchart/templates/ingress.yaml | 42 -- .../testdata/testchart/templates/service.yaml | 16 - .../testchart/templates/serviceaccount.yaml | 13 - .../templates/tests/test-connection.yaml | 15 - .../provider/testdata/testchart/values.yaml | 79 --- .../testhelm/appconfdir/app.pipecd.yaml | 0 .../testhelm/appconfdir/dir/values.yaml | 0 .../testhelm/appconfdir/invalid-symlink | 1 - .../testhelm/appconfdir/valid-symlink | 1 - .../testdata/testhelm/appconfdir/values.yaml | 0 .../provider/testdata/testhelm/values.yaml | 0 .../testdata/testkustomize/deployment.yaml | 33 - .../testdata/testkustomize/kustomization.yaml | 5 - tool/codegen/codegen.sh | 4 - 55 files changed, 6257 deletions(-) delete mode 100644 pkg/app/pipedv1/plugin/kubernetes/provider/applier.go delete mode 100644 pkg/app/pipedv1/plugin/kubernetes/provider/cache.go delete mode 100644 pkg/app/pipedv1/plugin/kubernetes/provider/chartrepo/chartrepo.go delete mode 100644 pkg/app/pipedv1/plugin/kubernetes/provider/deployment.go delete mode 100644 pkg/app/pipedv1/plugin/kubernetes/provider/deployment_test.go delete mode 100644 pkg/app/pipedv1/plugin/kubernetes/provider/diff.go delete mode 100644 pkg/app/pipedv1/plugin/kubernetes/provider/diff_test.go delete mode 100644 pkg/app/pipedv1/plugin/kubernetes/provider/diffutil.go delete mode 100644 pkg/app/pipedv1/plugin/kubernetes/provider/diffutil_test.go delete mode 100644 pkg/app/pipedv1/plugin/kubernetes/provider/hasher.go delete mode 100644 pkg/app/pipedv1/plugin/kubernetes/provider/hasher_test.go delete mode 100644 pkg/app/pipedv1/plugin/kubernetes/provider/helm.go delete mode 100644 pkg/app/pipedv1/plugin/kubernetes/provider/helm_test.go delete mode 100644 pkg/app/pipedv1/plugin/kubernetes/provider/kubectl.go delete mode 100644 pkg/app/pipedv1/plugin/kubernetes/provider/kubernetes.go delete mode 100644 pkg/app/pipedv1/plugin/kubernetes/provider/kubernetesmetrics/metrics.go delete mode 100644 pkg/app/pipedv1/plugin/kubernetes/provider/kubernetestest/kubernetes.mock.go delete mode 100644 pkg/app/pipedv1/plugin/kubernetes/provider/kustomize.go delete mode 100644 pkg/app/pipedv1/plugin/kubernetes/provider/kustomize_test.go delete mode 100644 pkg/app/pipedv1/plugin/kubernetes/provider/loader.go delete mode 100644 pkg/app/pipedv1/plugin/kubernetes/provider/loader_test.go delete mode 100644 pkg/app/pipedv1/plugin/kubernetes/provider/manifest.go delete mode 100644 pkg/app/pipedv1/plugin/kubernetes/provider/manifest_test.go delete mode 100644 pkg/app/pipedv1/plugin/kubernetes/provider/resource/deployment.go delete mode 100644 pkg/app/pipedv1/plugin/kubernetes/provider/resource/pod.go delete mode 100644 pkg/app/pipedv1/plugin/kubernetes/provider/resource/statefulset.go delete mode 100644 pkg/app/pipedv1/plugin/kubernetes/provider/resourcekey.go delete mode 100644 pkg/app/pipedv1/plugin/kubernetes/provider/state.go delete mode 100644 pkg/app/pipedv1/plugin/kubernetes/provider/testdata/diff_by_command.yaml delete mode 100644 pkg/app/pipedv1/plugin/kubernetes/provider/testdata/diff_by_command_no_change.yaml delete mode 100644 pkg/app/pipedv1/plugin/kubernetes/provider/testdata/diff_ignore_missing_fields.yaml delete mode 100644 pkg/app/pipedv1/plugin/kubernetes/provider/testdata/diff_ignore_order.yaml delete mode 100644 pkg/app/pipedv1/plugin/kubernetes/provider/testdata/diff_multi_diffs.yaml delete mode 100644 pkg/app/pipedv1/plugin/kubernetes/provider/testdata/diff_no_diff.yaml delete mode 100644 pkg/app/pipedv1/plugin/kubernetes/provider/testdata/diff_redact.yaml delete mode 100644 pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testchart/.helmignore delete mode 100644 pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testchart/Chart.yaml delete mode 100644 pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testchart/templates/NOTES.txt delete mode 100644 pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testchart/templates/_helpers.tpl delete mode 100644 pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testchart/templates/deployment.yaml delete mode 100644 pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testchart/templates/hpa.yaml delete mode 100644 pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testchart/templates/ingress.yaml delete mode 100644 pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testchart/templates/service.yaml delete mode 100644 pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testchart/templates/serviceaccount.yaml delete mode 100644 pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testchart/templates/tests/test-connection.yaml delete mode 100644 pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testchart/values.yaml delete mode 100644 pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testhelm/appconfdir/app.pipecd.yaml delete mode 100644 pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testhelm/appconfdir/dir/values.yaml delete mode 120000 pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testhelm/appconfdir/invalid-symlink delete mode 120000 pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testhelm/appconfdir/valid-symlink delete mode 100644 pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testhelm/appconfdir/values.yaml delete mode 100644 pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testhelm/values.yaml delete mode 100644 pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testkustomize/deployment.yaml delete mode 100644 pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testkustomize/kustomization.yaml diff --git a/pkg/app/pipedv1/plugin/kubernetes/provider/applier.go b/pkg/app/pipedv1/plugin/kubernetes/provider/applier.go deleted file mode 100644 index 41039b37aa..0000000000 --- a/pkg/app/pipedv1/plugin/kubernetes/provider/applier.go +++ /dev/null @@ -1,283 +0,0 @@ -// 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 provider - -import ( - "context" - "errors" - "fmt" - "sync" - - "go.uber.org/zap" - - "github.com/pipe-cd/pipecd/pkg/app/pipedv1/plugin/kubernetes/toolregistry" - "github.com/pipe-cd/pipecd/pkg/config" -) - -type Applier interface { - // ApplyManifest does applying the given manifest. - ApplyManifest(ctx context.Context, manifest Manifest) error - // CreateManifest does creating resource from given manifest. - CreateManifest(ctx context.Context, manifest Manifest) error - // ReplaceManifest does replacing resource from given manifest. - ReplaceManifest(ctx context.Context, manifest Manifest) error - // ForceReplaceManifest does force replacing resource from given manifest. - ForceReplaceManifest(ctx context.Context, manifest Manifest) error - // Delete deletes the given resource from Kubernetes cluster. - Delete(ctx context.Context, key ResourceKey) error -} - -type applier struct { - input config.KubernetesDeploymentInput - platformProvider config.PlatformProviderKubernetesConfig - logger *zap.Logger - toolregistry toolregistry.Registry - - kubectl *Kubectl - initOnce sync.Once - initErr error -} - -func NewApplier(input config.KubernetesDeploymentInput, cp config.PlatformProviderKubernetesConfig, logger *zap.Logger, toolregistry toolregistry.Registry) Applier { - return &applier{ - input: input, - platformProvider: cp, - logger: logger.Named("kubernetes-applier"), - toolregistry: toolregistry, - } -} - -// ApplyManifest does applying the given manifest. -func (a *applier) ApplyManifest(ctx context.Context, manifest Manifest) error { - a.initOnce.Do(func() { - a.kubectl, a.initErr = a.findKubectl(ctx, a.getToolVersionToRun()) - }) - if a.initErr != nil { - return a.initErr - } - - if a.input.AutoCreateNamespace { - err := a.kubectl.CreateNamespace( - ctx, - a.platformProvider.KubeConfigPath, - a.getNamespaceToRun(manifest.Key), - ) - if err != nil && !errors.Is(err, errResourceAlreadyExists) { - return err - } - } - - return a.kubectl.Apply( - ctx, - a.platformProvider.KubeConfigPath, - a.getNamespaceToRun(manifest.Key), - manifest, - ) -} - -// CreateManifest uses kubectl to create the given manifests. -func (a *applier) CreateManifest(ctx context.Context, manifest Manifest) error { - a.initOnce.Do(func() { - a.kubectl, a.initErr = a.findKubectl(ctx, a.getToolVersionToRun()) - }) - if a.initErr != nil { - return a.initErr - } - - if a.input.AutoCreateNamespace { - err := a.kubectl.CreateNamespace( - ctx, - a.platformProvider.KubeConfigPath, - a.getNamespaceToRun(manifest.Key), - ) - if err != nil && !errors.Is(err, errResourceAlreadyExists) { - return err - } - } - - return a.kubectl.Create( - ctx, - a.platformProvider.KubeConfigPath, - a.getNamespaceToRun(manifest.Key), - manifest, - ) -} - -// ReplaceManifest uses kubectl to replace the given manifests. -func (a *applier) ReplaceManifest(ctx context.Context, manifest Manifest) error { - a.initOnce.Do(func() { - a.kubectl, a.initErr = a.findKubectl(ctx, a.getToolVersionToRun()) - }) - if a.initErr != nil { - return a.initErr - } - - err := a.kubectl.Replace( - ctx, - a.platformProvider.KubeConfigPath, - a.getNamespaceToRun(manifest.Key), - manifest, - ) - if err == nil { - return nil - } - - if errors.Is(err, errorReplaceNotFound) { - return ErrNotFound - } - - return err -} - -// ForceReplaceManifest uses kubectl to forcefully replace the given manifests. -func (a *applier) ForceReplaceManifest(ctx context.Context, manifest Manifest) error { - a.initOnce.Do(func() { - a.kubectl, a.initErr = a.findKubectl(ctx, a.getToolVersionToRun()) - }) - if a.initErr != nil { - return a.initErr - } - - err := a.kubectl.ForceReplace( - ctx, - a.platformProvider.KubeConfigPath, - a.getNamespaceToRun(manifest.Key), - manifest, - ) - if err == nil { - return nil - } - - if errors.Is(err, errorReplaceNotFound) { - return ErrNotFound - } - - return err -} - -// Delete deletes the given resource from Kubernetes cluster. -// If the resource key is different, this returns ErrNotFound. -func (a *applier) Delete(ctx context.Context, k ResourceKey) (err error) { - a.initOnce.Do(func() { - a.kubectl, a.initErr = a.findKubectl(ctx, a.getToolVersionToRun()) - }) - if a.initErr != nil { - return a.initErr - } - - m, err := a.kubectl.Get( - ctx, - a.platformProvider.KubeConfigPath, - a.getNamespaceToRun(k), - k, - ) - - if err != nil { - return err - } - - if k.String() != m.GetAnnotations()[LabelResourceKey] { - return ErrNotFound - } - - return a.kubectl.Delete( - ctx, - a.platformProvider.KubeConfigPath, - a.getNamespaceToRun(k), - k, - ) -} - -// getNamespaceToRun returns namespace used on kubectl apply/delete commands. -// priority: config.KubernetesDeploymentInput > kubernetes.ResourceKey -func (a *applier) getNamespaceToRun(k ResourceKey) string { - if a.input.Namespace != "" { - return a.input.Namespace - } - return k.Namespace -} - -// getToolVersionToRun returns version of kubectl which should be used for commands. -// priority: applicationConfig.KubectlVersion > pipedConfig.KubectlVersion -func (a *applier) getToolVersionToRun() string { - if a.input.KubectlVersion != "" { - return a.input.KubectlVersion - } - return a.platformProvider.KubectlVersion -} - -func (a *applier) findKubectl(ctx context.Context, version string) (*Kubectl, error) { - path, err := a.toolregistry.Kubectl(ctx, version) - if err != nil { - return nil, fmt.Errorf("no kubectl %s (%v)", version, err) - } - return NewKubectl(version, path), nil -} - -type multiApplier struct { - appliers []Applier -} - -// NewMultiApplier creates an applier that duplicates its operations to all the provided appliers. -func NewMultiApplier(appliers ...Applier) Applier { - return &multiApplier{ - appliers: appliers, - } -} - -func (a *multiApplier) ApplyManifest(ctx context.Context, manifest Manifest) error { - for _, a := range a.appliers { - if err := a.ApplyManifest(ctx, manifest); err != nil { - return err - } - } - return nil -} - -func (a *multiApplier) CreateManifest(ctx context.Context, manifest Manifest) error { - for _, a := range a.appliers { - if err := a.CreateManifest(ctx, manifest); err != nil { - return err - } - } - return nil -} - -func (a *multiApplier) ReplaceManifest(ctx context.Context, manifest Manifest) error { - for _, a := range a.appliers { - if err := a.ReplaceManifest(ctx, manifest); err != nil { - return err - } - } - return nil -} - -func (a *multiApplier) ForceReplaceManifest(ctx context.Context, manifest Manifest) error { - for _, a := range a.appliers { - if err := a.ForceReplaceManifest(ctx, manifest); err != nil { - return err - } - } - return nil -} - -func (a *multiApplier) Delete(ctx context.Context, key ResourceKey) error { - for _, a := range a.appliers { - if err := a.Delete(ctx, key); err != nil { - return err - } - } - return nil -} diff --git a/pkg/app/pipedv1/plugin/kubernetes/provider/cache.go b/pkg/app/pipedv1/plugin/kubernetes/provider/cache.go deleted file mode 100644 index 9a79663bcd..0000000000 --- a/pkg/app/pipedv1/plugin/kubernetes/provider/cache.go +++ /dev/null @@ -1,68 +0,0 @@ -// 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 provider - -import ( - "errors" - "fmt" - - "go.uber.org/zap" - - "github.com/pipe-cd/pipecd/pkg/cache" -) - -type AppManifestsCache struct { - AppID string - Cache cache.Cache - Logger *zap.Logger -} - -func (c AppManifestsCache) Get(commit string) ([]Manifest, bool) { - key := appManifestsCacheKey(c.AppID, commit) - item, err := c.Cache.Get(key) - if err == nil { - return item.([]Manifest), true - } - - if errors.Is(err, cache.ErrNotFound) { - c.Logger.Info("app manifests were not found in cache", - zap.String("app-id", c.AppID), - zap.String("commit-hash", commit), - ) - return nil, false - } - - c.Logger.Error("failed while retrieving app manifests from cache", - zap.String("app-id", c.AppID), - zap.String("commit-hash", commit), - zap.Error(err), - ) - return nil, false -} - -func (c AppManifestsCache) Put(commit string, manifests []Manifest) { - key := appManifestsCacheKey(c.AppID, commit) - if err := c.Cache.Put(key, manifests); err != nil { - c.Logger.Error("failed while putting app manifests into cache", - zap.String("app-id", c.AppID), - zap.String("commit-hash", commit), - zap.Error(err), - ) - } -} - -func appManifestsCacheKey(appID, commit string) string { - return fmt.Sprintf("%s/%s", appID, commit) -} diff --git a/pkg/app/pipedv1/plugin/kubernetes/provider/chartrepo/chartrepo.go b/pkg/app/pipedv1/plugin/kubernetes/provider/chartrepo/chartrepo.go deleted file mode 100644 index dfb42e125d..0000000000 --- a/pkg/app/pipedv1/plugin/kubernetes/provider/chartrepo/chartrepo.go +++ /dev/null @@ -1,87 +0,0 @@ -// 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 chartrepo manages a list of configured helm repositories. -package chartrepo - -import ( - "context" - "fmt" - "os/exec" - - "go.uber.org/zap" - "golang.org/x/sync/singleflight" - - "github.com/pipe-cd/pipecd/pkg/config" -) - -var updateGroup = &singleflight.Group{} - -type registry interface { - Helm(ctx context.Context, version string) (string, error) -} - -// Add installs all specified Helm Chart repositories. -// https://helm.sh/docs/topics/chart_repository/ -// helm repo add fantastic-charts https://fantastic-charts.storage.googleapis.com -// helm repo add fantastic-charts https://fantastic-charts.storage.googleapis.com --username my-username --password my-password -func Add(ctx context.Context, repos []config.HelmChartRepository, reg registry, logger *zap.Logger) error { - helm, err := reg.Helm(ctx, "") - if err != nil { - return fmt.Errorf("failed to find helm to add repos (%w)", err) - } - - for _, repo := range repos { - args := []string{"repo", "add", repo.Name, repo.Address} - if repo.Insecure { - args = append(args, "--insecure-skip-tls-verify") - } - if repo.Username != "" || repo.Password != "" { - args = append(args, "--username", repo.Username, "--password", repo.Password) - } - cmd := exec.CommandContext(ctx, helm, args...) - out, err := cmd.CombinedOutput() - if err != nil { - return fmt.Errorf("failed to add chart repository %s: %s (%w)", repo.Name, string(out), err) - } - logger.Info(fmt.Sprintf("successfully added chart repository: %s", repo.Name)) - } - return nil -} - -func Update(ctx context.Context, reg registry, logger *zap.Logger) error { - _, err, _ := updateGroup.Do("update", func() (interface{}, error) { - return nil, update(ctx, reg, logger) - }) - return err -} - -func update(ctx context.Context, reg registry, logger *zap.Logger) error { - logger.Info("start updating Helm chart repositories") - - helm, err := reg.Helm(ctx, "") - if err != nil { - return fmt.Errorf("failed to find helm to update repos (%w)", err) - } - - args := []string{"repo", "update"} - cmd := exec.CommandContext(ctx, helm, args...) - out, err := cmd.CombinedOutput() - if err != nil { - return fmt.Errorf("failed to update Helm chart repositories: %s (%w)", string(out), err) - } - - logger.Info("successfully updated Helm chart repositories") - return nil -} diff --git a/pkg/app/pipedv1/plugin/kubernetes/provider/deployment.go b/pkg/app/pipedv1/plugin/kubernetes/provider/deployment.go deleted file mode 100644 index ce7ffd1d1f..0000000000 --- a/pkg/app/pipedv1/plugin/kubernetes/provider/deployment.go +++ /dev/null @@ -1,110 +0,0 @@ -// 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 provider - -import ( - "sort" - - appsv1 "k8s.io/api/apps/v1" - corev1 "k8s.io/api/core/v1" -) - -func FindReferencingConfigMapsInDeployment(d *appsv1.Deployment) []string { - m := make(map[string]struct{}, 0) - - // Find all configmaps specified in Volumes. - for _, v := range d.Spec.Template.Spec.Volumes { - if cm := v.ConfigMap; cm != nil { - m[cm.Name] = struct{}{} - } - } - - findInContainers := func(containers []corev1.Container) { - for _, c := range containers { - for _, env := range c.Env { - if source := env.ValueFrom; source != nil { - if ref := source.ConfigMapKeyRef; ref != nil { - m[ref.Name] = struct{}{} - } - } - } - for _, env := range c.EnvFrom { - if ref := env.ConfigMapRef; ref != nil { - m[ref.Name] = struct{}{} - } - } - } - } - - // Find all configmaps specified in Env. - findInContainers(d.Spec.Template.Spec.Containers) - findInContainers(d.Spec.Template.Spec.InitContainers) - - if len(m) == 0 { - return nil - } - - out := make([]string, 0, len(m)) - for k := range m { - out = append(out, k) - } - sort.Strings(out) - - return out -} - -func FindReferencingSecretsInDeployment(d *appsv1.Deployment) []string { - m := make(map[string]struct{}, 0) - - // Find all secrets specified in Volumes. - for _, v := range d.Spec.Template.Spec.Volumes { - if s := v.Secret; s != nil { - m[s.SecretName] = struct{}{} - } - } - - findInContainers := func(containers []corev1.Container) { - for _, c := range containers { - for _, env := range c.Env { - if source := env.ValueFrom; source != nil { - if ref := source.SecretKeyRef; ref != nil { - m[ref.Name] = struct{}{} - } - } - } - for _, env := range c.EnvFrom { - if ref := env.SecretRef; ref != nil { - m[ref.Name] = struct{}{} - } - } - } - } - - // Find all secrets specified in Env. - findInContainers(d.Spec.Template.Spec.Containers) - findInContainers(d.Spec.Template.Spec.InitContainers) - - if len(m) == 0 { - return nil - } - - out := make([]string, 0, len(m)) - for k := range m { - out = append(out, k) - } - sort.Strings(out) - - return out -} diff --git a/pkg/app/pipedv1/plugin/kubernetes/provider/deployment_test.go b/pkg/app/pipedv1/plugin/kubernetes/provider/deployment_test.go deleted file mode 100644 index 4c83cc78ef..0000000000 --- a/pkg/app/pipedv1/plugin/kubernetes/provider/deployment_test.go +++ /dev/null @@ -1,358 +0,0 @@ -// 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 provider - -import ( - "testing" - - appsv1 "k8s.io/api/apps/v1" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestFindReferencingConfigMapsInDeployment(t *testing.T) { - t.Parallel() - - testcases := []struct { - name string - manifest string - expected []string - }{ - { - name: "no configmap", - manifest: ` -apiVersion: apps/v1 -kind: Deployment -metadata: - name: simple - labels: - app: simple -spec: - replicas: 2 - selector: - matchLabels: - app: simple - pipecd.dev/variant: primary - template: - metadata: - labels: - app: simple - pipecd.dev/variant: primary - annotations: - sidecar.istio.io/inject: "false" - spec: - containers: - - name: helloworld - image: gcr.io/pipecd/helloworld:v0.5.0 - args: - - server - ports: - - containerPort: 9085 -`, - expected: nil, - }, - { - name: "one configmap", - manifest: ` -apiVersion: apps/v1 -kind: Deployment -metadata: - name: canary-by-config-change - labels: - app: canary-by-config-change -spec: - replicas: 2 - selector: - matchLabels: - app: canary-by-config-change - pipecd.dev/variant: primary - template: - metadata: - labels: - app: canary-by-config-change - pipecd.dev/variant: primary - spec: - containers: - - name: helloworld - image: gcr.io/pipecd/helloworld:v0.5.0 - args: - - server - ports: - - containerPort: 9085 - volumeMounts: - - name: config - mountPath: /etc/pipecd-config - readOnly: true - volumes: - - name: config - configMap: - name: canary-by-config-change -`, - expected: []string{ - "canary-by-config-change", - }, - }, - { - name: "multiple configmaps", - manifest: ` -apiVersion: apps/v1 -kind: Deployment -metadata: - name: canary-by-config-change - labels: - app: canary-by-config-change -spec: - replicas: 2 - selector: - matchLabels: - app: canary-by-config-change - pipecd.dev/variant: primary - template: - metadata: - labels: - app: canary-by-config-change - pipecd.dev/variant: primary - spec: - initContainers: - - name: init - image: gcr.io/pipecd/helloworld:v0.5.0 - env: - - name: env1 - valueFrom: - configMapKeyRef: - name: init-configmap-1 - key: key1 - containers: - - name: helloworld - image: gcr.io/pipecd/helloworld:v0.5.0 - args: - - server - ports: - - containerPort: 9085 - env: - - name: env1 - valueFrom: - configMapKeyRef: - name: configmap-1 - key: key1 - - name: env2 - valueFrom: - configMapKeyRef: - name: configmap-2 - key: key2 - volumeMounts: - - name: config - mountPath: /etc/pipecd-config - readOnly: true - volumes: - - name: config - configMap: - name: canary-by-config-change - - name: config2 - configMap: - name: configmap-2 -`, - expected: []string{ - "canary-by-config-change", - "configmap-1", - "configmap-2", - "init-configmap-1", - }, - }, - } - - for _, tc := range testcases { - t.Run(tc.name, func(t *testing.T) { - manifests, err := ParseManifests(tc.manifest) - require.NoError(t, err) - require.Equal(t, 1, len(manifests)) - - d := &appsv1.Deployment{} - err = manifests[0].ConvertToStructuredObject(d) - require.NoError(t, err) - - out := FindReferencingConfigMapsInDeployment(d) - assert.Equal(t, tc.expected, out) - }) - } -} - -func TestFindReferencingSecretsInDeployment(t *testing.T) { - t.Parallel() - - testcases := []struct { - name string - manifest string - expected []string - }{ - { - name: "no secret", - manifest: ` -apiVersion: apps/v1 -kind: Deployment -metadata: - name: simple - labels: - app: simple -spec: - replicas: 2 - selector: - matchLabels: - app: simple - pipecd.dev/variant: primary - template: - metadata: - labels: - app: simple - pipecd.dev/variant: primary - annotations: - sidecar.istio.io/inject: "false" - spec: - containers: - - name: helloworld - image: gcr.io/pipecd/helloworld:v0.5.0 - args: - - server - ports: - - containerPort: 9085 -`, - expected: nil, - }, - { - name: "one secret", - manifest: ` -apiVersion: apps/v1 -kind: Deployment -metadata: - name: canary-by-config-change - labels: - app: canary-by-config-change -spec: - replicas: 2 - selector: - matchLabels: - app: canary-by-config-change - pipecd.dev/variant: primary - template: - metadata: - labels: - app: canary-by-config-change - pipecd.dev/variant: primary - spec: - containers: - - name: helloworld - image: gcr.io/pipecd/helloworld:v0.5.0 - args: - - server - ports: - - containerPort: 9085 - volumeMounts: - - name: config - mountPath: /etc/pipecd-config - readOnly: true - volumes: - - name: config - secret: - secretName: canary-by-config-change -`, - expected: []string{ - "canary-by-config-change", - }, - }, - { - name: "multiple secrets", - manifest: ` -apiVersion: apps/v1 -kind: Deployment -metadata: - name: canary-by-config-change - labels: - app: canary-by-config-change -spec: - replicas: 2 - selector: - matchLabels: - app: canary-by-config-change - pipecd.dev/variant: primary - template: - metadata: - labels: - app: canary-by-config-change - pipecd.dev/variant: primary - spec: - initContainers: - - name: init - image: gcr.io/pipecd/helloworld:v0.5.0 - env: - - name: env1 - valueFrom: - secretKeyRef: - name: init-secret-1 - key: key1 - containers: - - name: helloworld - image: gcr.io/pipecd/helloworld:v0.5.0 - args: - - server - ports: - - containerPort: 9085 - env: - - name: env1 - valueFrom: - secretKeyRef: - name: secret-1 - key: key1 - - name: env2 - valueFrom: - secretKeyRef: - name: secret-2 - key: key2 - volumeMounts: - - name: config - mountPath: /etc/pipecd-config - readOnly: true - volumes: - - name: config - secret: - secretName: canary-by-config-change - - name: config2 - secret: - secretName: secret-2 -`, - expected: []string{ - "canary-by-config-change", - "init-secret-1", - "secret-1", - "secret-2", - }, - }, - } - - for _, tc := range testcases { - t.Run(tc.name, func(t *testing.T) { - manifests, err := ParseManifests(tc.manifest) - require.NoError(t, err) - require.Equal(t, 1, len(manifests)) - - d := &appsv1.Deployment{} - err = manifests[0].ConvertToStructuredObject(d) - require.NoError(t, err) - - out := FindReferencingSecretsInDeployment(d) - assert.Equal(t, tc.expected, out) - }) - } -} diff --git a/pkg/app/pipedv1/plugin/kubernetes/provider/diff.go b/pkg/app/pipedv1/plugin/kubernetes/provider/diff.go deleted file mode 100644 index a5bb170fbc..0000000000 --- a/pkg/app/pipedv1/plugin/kubernetes/provider/diff.go +++ /dev/null @@ -1,305 +0,0 @@ -// 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 provider - -import ( - "bytes" - "fmt" - "os" - "os/exec" - "sort" - "strings" - - "go.uber.org/zap" - v1 "k8s.io/api/core/v1" - "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" - "k8s.io/apimachinery/pkg/runtime" - - "github.com/pipe-cd/pipecd/pkg/diff" -) - -const ( - diffCommand = "diff" -) - -type DiffListResult struct { - Adds []Manifest - Deletes []Manifest - Changes []DiffListChange -} - -func (r *DiffListResult) NoChange() bool { - return len(r.Adds)+len(r.Deletes)+len(r.Changes) == 0 -} - -type DiffListChange struct { - Old Manifest - New Manifest - Diff *diff.Result -} - -func Diff(old, new Manifest, logger *zap.Logger, opts ...diff.Option) (*diff.Result, error) { - if old.Key.IsSecret() && new.Key.IsSecret() { - var err error - old.u, err = normalizeNewSecret(old.u, new.u) - if err != nil { - return nil, err - } - } - - key := old.Key.String() - - normalizedOld, err := remarshal(old.u) - if err != nil { - logger.Info("compare manifests directly since it was unable to remarshal old Kubernetes manifest to normalize special fields", zap.Error(err)) - return diff.DiffUnstructureds(*old.u, *new.u, key, opts...) - } - - normalizedNew, err := remarshal(new.u) - if err != nil { - logger.Info("compare manifests directly since it was unable to remarshal new Kubernetes manifest to normalize special fields", zap.Error(err)) - return diff.DiffUnstructureds(*old.u, *new.u, key, opts...) - } - - return diff.DiffUnstructureds(*normalizedOld, *normalizedNew, key, opts...) -} - -func DiffList(olds, news []Manifest, logger *zap.Logger, opts ...diff.Option) (*DiffListResult, error) { - adds, deletes, newChanges, oldChanges := groupManifests(olds, news) - cr := &DiffListResult{ - Adds: adds, - Deletes: deletes, - Changes: make([]DiffListChange, 0, len(newChanges)), - } - - for i := 0; i < len(newChanges); i++ { - result, err := Diff(oldChanges[i], newChanges[i], logger, opts...) - if err != nil { - return nil, err - } - if !result.HasDiff() { - continue - } - cr.Changes = append(cr.Changes, DiffListChange{ - Old: oldChanges[i], - New: newChanges[i], - Diff: result, - }) - } - - return cr, nil -} - -func normalizeNewSecret(old, new *unstructured.Unstructured) (*unstructured.Unstructured, error) { - var o, n v1.Secret - runtime.DefaultUnstructuredConverter.FromUnstructured(old.Object, &o) - runtime.DefaultUnstructuredConverter.FromUnstructured(new.Object, &n) - - // Move as much as possible fields from `o.Data` to `o.StringData` to make `o` close to `n` to minimize the diff. - for k, v := range o.Data { - // Skip if the field also exists in StringData. - if _, ok := o.StringData[k]; ok { - continue - } - - if _, ok := n.StringData[k]; !ok { - continue - } - - if o.StringData == nil { - o.StringData = make(map[string]string) - } - - // If the field is existing in `n.StringData`, we should move that field from `o.Data` to `o.StringData` - o.StringData[k] = string(v) - delete(o.Data, k) - } - - newO, err := runtime.DefaultUnstructuredConverter.ToUnstructured(&o) - if err != nil { - return nil, err - } - - return &unstructured.Unstructured{Object: newO}, nil -} - -type DiffRenderOptions struct { - MaskSecret bool - MaskConfigMap bool - // Maximum number of changed manifests should be shown. - // Zero means rendering all. - MaxChangedManifests int - // If true, use "diff" command to render. - UseDiffCommand bool -} - -func (r *DiffListResult) Render(opt DiffRenderOptions) string { - var b strings.Builder - index := 0 - for _, delete := range r.Deletes { - index++ - b.WriteString(fmt.Sprintf("- %d. %s\n\n", index, delete.Key.ReadableString())) - } - for _, add := range r.Adds { - index++ - b.WriteString(fmt.Sprintf("+ %d. %s\n\n", index, add.Key.ReadableString())) - } - - maxPrintDiffs := len(r.Changes) - if opt.MaxChangedManifests != 0 && opt.MaxChangedManifests < maxPrintDiffs { - maxPrintDiffs = opt.MaxChangedManifests - } - - var prints = 0 - for _, change := range r.Changes { - key := change.Old.Key - opts := []diff.RenderOption{ - diff.WithLeftPadding(1), - } - - needMaskValue := false - if opt.MaskSecret && key.IsSecret() { - opts = append(opts, diff.WithMaskPath("data")) - needMaskValue = true - } else if opt.MaskConfigMap && key.IsConfigMap() { - opts = append(opts, diff.WithMaskPath("data")) - needMaskValue = true - } - renderer := diff.NewRenderer(opts...) - - index++ - b.WriteString(fmt.Sprintf("# %d. %s\n\n", index, key.ReadableString())) - - // Use our diff check in one of the following cases: - // - not explicit set useDiffCommand option. - // - requires masking secret or configmap value. - if !opt.UseDiffCommand || needMaskValue { - b.WriteString(renderer.Render(change.Diff.Nodes())) - } else { - // TODO: Find a way to mask values in case of using unix `diff` command. - d, err := diffByCommand(diffCommand, change.Old, change.New) - if err != nil { - b.WriteString(fmt.Sprintf("An error occurred while rendering diff (%v)", err)) - } else { - b.Write(d) - } - } - b.WriteString("\n") - - prints++ - if prints >= maxPrintDiffs { - break - } - } - - if prints < len(r.Changes) { - b.WriteString(fmt.Sprintf("... (omitted %d other changed manifests\n", len(r.Changes)-prints)) - } - - return b.String() -} - -func diffByCommand(command string, old, new Manifest) ([]byte, error) { - oldBytes, err := old.YamlBytes() - if err != nil { - return nil, err - } - - newBytes, err := new.YamlBytes() - if err != nil { - return nil, err - } - - oldFile, err := os.CreateTemp("", "old") - if err != nil { - return nil, err - } - defer os.Remove(oldFile.Name()) - if _, err := oldFile.Write(oldBytes); err != nil { - return nil, err - } - - newFile, err := os.CreateTemp("", "new") - if err != nil { - return nil, err - } - defer os.Remove(newFile.Name()) - if _, err := newFile.Write(newBytes); err != nil { - return nil, err - } - - var stdout, stderr bytes.Buffer - cmd := exec.Command(command, "-u", "-N", oldFile.Name(), newFile.Name()) - cmd.Stdout = &stdout - cmd.Stderr = &stderr - err = cmd.Run() - if stdout.Len() > 0 { - // diff exits with a non-zero status when the files don't match. - // Ignore that failure as long as we get output. - err = nil - } - if err != nil { - return nil, fmt.Errorf("failed to run diff, err = %w, %s", err, stderr.String()) - } - - // Remove two-line header from output. - data := bytes.TrimSpace(stdout.Bytes()) - rows := bytes.SplitN(data, []byte("\n"), 3) - if len(rows) == 3 { - return rows[2], nil - } - return data, nil -} - -func groupManifests(olds, news []Manifest) (adds, deletes, newChanges, oldChanges []Manifest) { - // Sort the manifests before comparing. - sort.Slice(news, func(i, j int) bool { - return news[i].Key.IsLessWithIgnoringNamespace(news[j].Key) - }) - sort.Slice(olds, func(i, j int) bool { - return olds[i].Key.IsLessWithIgnoringNamespace(olds[j].Key) - }) - - var n, o int - for { - if n >= len(news) || o >= len(olds) { - break - } - if news[n].Key.IsEqualWithIgnoringNamespace(olds[o].Key) { - newChanges = append(newChanges, news[n]) - oldChanges = append(oldChanges, olds[o]) - n++ - o++ - continue - } - // Has in news but not in olds so this should be a added one. - if news[n].Key.IsLessWithIgnoringNamespace(olds[o].Key) { - adds = append(adds, news[n]) - n++ - continue - } - // Has in olds but not in news so this should be an deleted one. - deletes = append(deletes, olds[o]) - o++ - } - - if len(news) > n { - adds = append(adds, news[n:]...) - } - if len(olds) > o { - deletes = append(deletes, olds[o:]...) - } - return -} diff --git a/pkg/app/pipedv1/plugin/kubernetes/provider/diff_test.go b/pkg/app/pipedv1/plugin/kubernetes/provider/diff_test.go deleted file mode 100644 index 8641318ab3..0000000000 --- a/pkg/app/pipedv1/plugin/kubernetes/provider/diff_test.go +++ /dev/null @@ -1,572 +0,0 @@ -// 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 provider - -import ( - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "go.uber.org/zap" - - "github.com/pipe-cd/pipecd/pkg/diff" -) - -func TestGroupManifests(t *testing.T) { - t.Parallel() - - testcases := []struct { - name string - olds []Manifest - news []Manifest - expectedAdds []Manifest - expectedDeletes []Manifest - expectedNewChanges []Manifest - expectedOldChanges []Manifest - }{ - { - name: "empty list", - }, - { - name: "only adds", - news: []Manifest{ - {Key: ResourceKey{Name: "b"}}, - {Key: ResourceKey{Name: "a"}}, - }, - expectedAdds: []Manifest{ - {Key: ResourceKey{Name: "a"}}, - {Key: ResourceKey{Name: "b"}}, - }, - }, - { - name: "only deletes", - olds: []Manifest{ - {Key: ResourceKey{Name: "b"}}, - {Key: ResourceKey{Name: "a"}}, - }, - expectedDeletes: []Manifest{ - {Key: ResourceKey{Name: "a"}}, - {Key: ResourceKey{Name: "b"}}, - }, - }, - { - name: "only inters", - olds: []Manifest{ - {Key: ResourceKey{Name: "b"}}, - {Key: ResourceKey{Name: "a"}}, - }, - news: []Manifest{ - {Key: ResourceKey{Name: "a"}}, - {Key: ResourceKey{Name: "b"}}, - }, - expectedNewChanges: []Manifest{ - {Key: ResourceKey{Name: "a"}}, - {Key: ResourceKey{Name: "b"}}, - }, - expectedOldChanges: []Manifest{ - {Key: ResourceKey{Name: "a"}}, - {Key: ResourceKey{Name: "b"}}, - }, - }, - { - name: "all kinds", - olds: []Manifest{ - {Key: ResourceKey{Name: "b"}}, - {Key: ResourceKey{Name: "a"}}, - {Key: ResourceKey{Name: "c"}}, - }, - news: []Manifest{ - {Key: ResourceKey{Name: "a"}}, - {Key: ResourceKey{Name: "d"}}, - {Key: ResourceKey{Name: "b"}}, - }, - expectedAdds: []Manifest{ - {Key: ResourceKey{Name: "d"}}, - }, - expectedDeletes: []Manifest{ - {Key: ResourceKey{Name: "c"}}, - }, - expectedNewChanges: []Manifest{ - {Key: ResourceKey{Name: "a"}}, - {Key: ResourceKey{Name: "b"}}, - }, - expectedOldChanges: []Manifest{ - {Key: ResourceKey{Name: "a"}}, - {Key: ResourceKey{Name: "b"}}, - }, - }, - } - - for _, tc := range testcases { - t.Run(tc.name, func(t *testing.T) { - adds, deletes, newChanges, oldChanges := groupManifests(tc.olds, tc.news) - assert.Equal(t, tc.expectedAdds, adds) - assert.Equal(t, tc.expectedDeletes, deletes) - assert.Equal(t, tc.expectedNewChanges, newChanges) - assert.Equal(t, tc.expectedOldChanges, oldChanges) - }) - } -} - -func TestDiffByCommand(t *testing.T) { - t.Parallel() - - testcases := []struct { - name string - command string - manifests string - expected string - expectedErr bool - }{ - { - name: "no command", - command: "non-existent-diff", - manifests: "testdata/diff_by_command_no_change.yaml", - expected: "", - expectedErr: true, - }, - { - name: "no diff", - command: diffCommand, - manifests: "testdata/diff_by_command_no_change.yaml", - expected: "", - }, - { - name: "has diff", - command: diffCommand, - manifests: "testdata/diff_by_command.yaml", - expected: `@@ -6,7 +6,7 @@ - pipecd.dev/managed-by: piped - name: simple - spec: -- replicas: 2 -+ replicas: 3 - selector: - matchLabels: - app: simple -@@ -18,6 +18,7 @@ - containers: - - args: - - a -+ - d - - b - - c - image: gcr.io/pipecd/first:v1.0.0 -@@ -26,7 +27,6 @@ - - containerPort: 9085 - - args: - - xx -- - yy - - zz - image: gcr.io/pipecd/second:v1.0.0 - name: second`, - }, - } - - for _, tc := range testcases { - t.Run(tc.name, func(t *testing.T) { - manifests, err := LoadManifestsFromYAMLFile(tc.manifests) - require.NoError(t, err) - require.Equal(t, 2, len(manifests)) - - got, err := diffByCommand(tc.command, manifests[0], manifests[1]) - if tc.expectedErr { - assert.Error(t, err) - } else { - assert.NoError(t, err) - } - assert.Equal(t, tc.expected, string(got)) - }) - } -} - -func TestDiff(t *testing.T) { - t.Parallel() - - testcases := []struct { - name string - manifests string - expected string - diffNum int - falsePositive bool - }{ - { - name: "Secret no diff 1", - manifests: `apiVersion: apps/v1 -kind: Secret -metadata: - name: secret-management ---- -apiVersion: apps/v1 -kind: Secret -metadata: - name: secret-management -`, - expected: "", - diffNum: 0, - }, - { - name: "Secret no diff 2", - manifests: `apiVersion: apps/v1 -kind: Secret -metadata: - name: secret-management -data: - password: hoge -stringData: - foo: bar ---- -apiVersion: apps/v1 -kind: Secret -metadata: - name: secret-management -data: - password: hoge -stringData: - foo: bar -`, - expected: "", - diffNum: 0, - }, - { - name: "Secret no diff with merge", - manifests: `apiVersion: apps/v1 -kind: Secret -metadata: - name: secret-management -data: - password: hoge - foo: YmFy ---- -apiVersion: apps/v1 -kind: Secret -metadata: - name: secret-management -data: - password: hoge -stringData: - foo: bar -`, - expected: "", - diffNum: 0, - }, - { - name: "Secret no diff override false-positive", - manifests: `apiVersion: apps/v1 -kind: Secret -metadata: - name: secret-management -data: - password: hoge - foo: YmFy ---- -apiVersion: apps/v1 -kind: Secret -metadata: - name: secret-management -data: - password: hoge - foo: Zm9v -stringData: - foo: bar -`, - expected: "", - diffNum: 0, - falsePositive: true, - }, - { - name: "Secret has diff", - manifests: `apiVersion: apps/v1 -kind: Secret -metadata: - name: secret-management -data: - foo: YmFy ---- -apiVersion: apps/v1 -kind: Secret -metadata: - name: secret-management -data: - password: hoge -stringData: - foo: bar -`, - expected: ` #data -+ data: -+ password: hoge - -`, - diffNum: 1, - }, - { - name: "Pod no diff 1", - manifests: `apiVersion: v1 -kind: Pod -metadata: - name: static-web - labels: - role: myrole -spec: - containers: - - name: web - image: nginx - resources: - limits: - memory: "2Gi" ---- -apiVersion: v1 -kind: Pod -metadata: - name: static-web - labels: - role: myrole -spec: - containers: - - name: web - image: nginx - ports: - resources: - limits: - memory: "2Gi" -`, - expected: "", - diffNum: 0, - falsePositive: false, - }, - { - name: "Pod no diff 2", - manifests: `apiVersion: v1 -kind: Pod -metadata: - name: static-web - labels: - role: myrole -spec: - containers: - - name: web - image: nginx - resources: - limits: - memory: "1536Mi" ---- -apiVersion: v1 -kind: Pod -metadata: - name: static-web - labels: - role: myrole -spec: - containers: - - name: web - image: nginx - ports: - resources: - limits: - memory: "1.5Gi" -`, - expected: "", - diffNum: 0, - falsePositive: false, - }, - } - - for _, tc := range testcases { - t.Run(tc.name, func(t *testing.T) { - manifests, err := ParseManifests(tc.manifests) - require.NoError(t, err) - require.Equal(t, 2, len(manifests)) - old, new := manifests[0], manifests[1] - - result, err := Diff(old, new, zap.NewNop(), diff.WithEquateEmpty(), diff.WithIgnoreAddingMapKeys(), diff.WithCompareNumberAndNumericString()) - require.NoError(t, err) - - renderer := diff.NewRenderer(diff.WithLeftPadding(1)) - ds := renderer.Render(result.Nodes()) - if tc.falsePositive { - assert.NotEqual(t, tc.diffNum, result.NumNodes()) - assert.NotEqual(t, tc.expected, ds) - } else { - assert.Equal(t, tc.diffNum, result.NumNodes()) - assert.Equal(t, tc.expected, ds) - } - }) - } -} - -func TestNoDiff(t *testing.T) { - t.Parallel() - - testcases := []struct { - name string - manifest string - }{ - { - name: "limits.memory 1.5Gi", - manifest: `apiVersion: apps/v1 -kind: Deployment -metadata: - name: simple -spec: - template: - spec: - containers: - - image: ghcr.io/pipe-cd/helloworld:v0.32.0 - name: helloworld - resources: - limits: - memory: 1.5Gi`, - }, - { - name: "limits.cpu 1.5", - manifest: `apiVersion: apps/v1 -kind: Deployment -metadata: - name: simple -spec: - template: - spec: - containers: - - image: ghcr.io/pipe-cd/helloworld:v0.32.0 - name: helloworld - resources: - limits: - cpu: "1.5"`, - }, - { - name: "limits.memory 1Gi", - manifest: `apiVersion: apps/v1 -kind: Deployment -metadata: - name: simple -spec: - template: - spec: - containers: - - image: ghcr.io/pipe-cd/helloworld:v0.32.0 - name: helloworld - resources: - limits: - memory: 1Gi`, - }, - { - name: "limits.cpu 1", - manifest: `apiVersion: apps/v1 -kind: Deployment -metadata: - name: simple -spec: - template: - spec: - containers: - - image: ghcr.io/pipe-cd/helloworld:v0.32.0 - name: helloworld - resources: - limits: - cpu: "1"`, - }, - { - name: "requests.memory 1.5Gi", - manifest: `apiVersion: apps/v1 -kind: Deployment -metadata: - name: simple -spec: - template: - spec: - containers: - - image: ghcr.io/pipe-cd/helloworld:v0.32.0 - name: helloworld - resources: - requests: - memory: 1.5Gi`, - }, - { - name: "requests.cpu 1.5", - manifest: `apiVersion: apps/v1 -kind: Deployment -metadata: - name: simple -spec: - template: - spec: - containers: - - image: ghcr.io/pipe-cd/helloworld:v0.32.0 - name: helloworld - resources: - requests: - cpu: "1.5"`, - }, - { - name: "requests.memory 1Gi", - manifest: `apiVersion: apps/v1 -kind: Deployment -metadata: - name: simple -spec: - template: - spec: - containers: - - image: ghcr.io/pipe-cd/helloworld:v0.32.0 - name: helloworld - resources: - requests: - memory: 1Gi`, - }, - { - name: "requests.cpu 1", - manifest: `apiVersion: apps/v1 -kind: Deployment -metadata: - name: simple -spec: - template: - spec: - containers: - - image: ghcr.io/pipe-cd/helloworld:v0.32.0 - name: helloworld - resources: - requests: - cpu: "1"`, - }, - } - - for _, tc := range testcases { - t.Run(tc.name, func(t *testing.T) { - manifests, err := ParseManifests(tc.manifest) - require.NoError(t, err) - - result, err := DiffList(manifests, manifests, zap.NewNop(), diff.WithEquateEmpty(), diff.WithIgnoreAddingMapKeys(), diff.WithCompareNumberAndNumericString()) - require.NoError(t, err) - - assert.True(t, result.NoChange()) - for _, change := range result.Changes { - t.Log(change.Old.Key, change.New.Key) - for _, node := range change.Diff.Nodes() { - t.Log(node.PathString) - t.Log(node.ValueX) - t.Log(node.ValueY) - t.Log("---") - } - } - for _, add := range result.Adds { - t.Log(add.Key) - } - for _, delete := range result.Deletes { - t.Log(delete.Key) - } - }) - } -} diff --git a/pkg/app/pipedv1/plugin/kubernetes/provider/diffutil.go b/pkg/app/pipedv1/plugin/kubernetes/provider/diffutil.go deleted file mode 100644 index 4ff4c731a7..0000000000 --- a/pkg/app/pipedv1/plugin/kubernetes/provider/diffutil.go +++ /dev/null @@ -1,120 +0,0 @@ -// 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 provider - -import ( - "bytes" - "encoding/json" - "reflect" - - "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/client-go/kubernetes/scheme" -) - -// All functions in this file is borrowed from argocd/gitops-engine and modified -// All function except `remarshal` is borrowed from -// https://github.com/argoproj/gitops-engine/blob/0bc2f8c395f67123156d4ce6b667bf730618307f/pkg/utils/json/json.go -// and `remarshal` function is borrowed from -// https://github.com/argoproj/gitops-engine/blob/b0c5e00ccfa5d1e73087a18dc59e2e4c72f5f175/pkg/diff/diff.go#L685-L723 - -// https://github.com/ksonnet/ksonnet/blob/master/pkg/kubecfg/diff.go -func removeFields(config, live interface{}) interface{} { - switch c := config.(type) { - case map[string]interface{}: - l, ok := live.(map[string]interface{}) - if ok { - return removeMapFields(c, l) - } - return live - case []interface{}: - l, ok := live.([]interface{}) - if ok { - return removeListFields(c, l) - } - return live - default: - return live - } - -} - -// removeMapFields remove all non-existent fields in the live that don't exist in the config -func removeMapFields(config, live map[string]interface{}) map[string]interface{} { - result := map[string]interface{}{} - for k, v1 := range config { - v2, ok := live[k] - if !ok { - continue - } - if v2 != nil { - v2 = removeFields(v1, v2) - } - result[k] = v2 - } - return result -} - -func removeListFields(config, live []interface{}) []interface{} { - // If live is longer than config, then the extra elements at the end of the - // list will be returned as-is so they appear in the diff. - result := make([]interface{}, 0, len(live)) - for i, v2 := range live { - if len(config) > i { - if v2 != nil { - v2 = removeFields(config[i], v2) - } - result = append(result, v2) - } else { - result = append(result, v2) - } - } - return result -} - -// remarshal checks resource kind and version and re-marshal using corresponding struct custom marshaller. -// This ensures that expected resource state is formatter same as actual resource state in kubernetes -// and allows to find differences between actual and target states more accurately. -// Remarshalling also strips any type information (e.g. float64 vs. int) from the unstructured -// object. This is important for diffing since it will cause godiff to report a false difference. -func remarshal(obj *unstructured.Unstructured) (*unstructured.Unstructured, error) { - data, err := json.Marshal(obj) - if err != nil { - return nil, err - } - item, err := scheme.Scheme.New(obj.GroupVersionKind()) - if err != nil { - // This is common. the scheme is not registered - return nil, err - } - // This will drop any omitempty fields, perform resource conversion etc... - unmarshalledObj := reflect.New(reflect.TypeOf(item).Elem()).Interface() - // Unmarshal data into unmarshalledObj, but detect if there are any unknown fields that are not - // found in the target GVK object. - decoder := json.NewDecoder(bytes.NewReader(data)) - decoder.DisallowUnknownFields() - if err := decoder.Decode(&unmarshalledObj); err != nil { - // Likely a field present in obj that is not present in the GVK type, or user - // may have specified an invalid spec in git, so return original object - return nil, err - } - unstrBody, err := runtime.DefaultUnstructuredConverter.ToUnstructured(unmarshalledObj) - if err != nil { - return nil, err - } - // Remove all default values specified by custom formatter (e.g. creationTimestamp) - unstrBody = removeMapFields(obj.Object, unstrBody) - return &unstructured.Unstructured{Object: unstrBody}, nil -} diff --git a/pkg/app/pipedv1/plugin/kubernetes/provider/diffutil_test.go b/pkg/app/pipedv1/plugin/kubernetes/provider/diffutil_test.go deleted file mode 100644 index 223e37bd15..0000000000 --- a/pkg/app/pipedv1/plugin/kubernetes/provider/diffutil_test.go +++ /dev/null @@ -1,218 +0,0 @@ -// 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 provider - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestRemoveMapFields(t *testing.T) { - t.Parallel() - - testcases := []struct { - name string - config map[string]interface{} - live map[string]interface{} - expected map[string]interface{} - }{ - { - name: "Empty map", - config: make(map[string]interface{}, 0), - live: make(map[string]interface{}, 0), - expected: make(map[string]interface{}, 0), - }, - { - name: "Not nested 1", - config: map[string]interface{}{ - "key a": "value a", - }, - live: map[string]interface{}{ - "key a": "value a", - "key b": "value b", - }, - expected: map[string]interface{}{ - "key a": "value a", - }, - }, - { - name: "Not nested 2", - config: map[string]interface{}{ - "key a": "value a", - "key b": "value b", - }, - live: map[string]interface{}{ - "key a": "value a", - }, - expected: map[string]interface{}{ - "key a": "value a", - }, - }, - { - name: "Nested live deleted", - config: map[string]interface{}{ - "key a": "value a", - }, - live: map[string]interface{}{ - "key a": "value a", - "key b": map[string]interface{}{ - "nested key a": "nested value a", - }, - }, - expected: map[string]interface{}{ - "key a": "value a", - }, - }, - { - name: "Nested same", - config: map[string]interface{}{ - "key a": "value a", - "key b": map[string]interface{}{ - "nested key a": "nested value a", - }, - }, - live: map[string]interface{}{ - "key a": "value a", - "key b": map[string]interface{}{ - "nested key a": "nested value a", - }, - }, - expected: map[string]interface{}{ - "key a": "value a", - "key b": map[string]interface{}{ - "nested key a": "nested value a", - }, - }, - }, - { - name: "Nested nested live deleted", - config: map[string]interface{}{ - "key a": "value a", - "key b": map[string]interface{}{ - "nested key a": "nested value a", - }, - }, - live: map[string]interface{}{ - "key a": "value a", - "key b": map[string]interface{}{ - "nested key a": "nested value a", - "nested key b": "nested value b", - }, - }, - expected: map[string]interface{}{ - "key a": "value a", - "key b": map[string]interface{}{ - "nested key a": "nested value a", - }, - }, - }, - { - name: "Nested array", - config: map[string]interface{}{ - "key a": "value a", - "key b": []interface{}{ - "a", "b", 3, - }, - }, - live: map[string]interface{}{ - "key a": "value a", - "key b": []interface{}{ - "a", "b", 3, - }, - }, - expected: map[string]interface{}{ - "key a": "value a", - "key b": []interface{}{ - "a", "b", 3, - }, - }, - }, - { - name: "Nested array 2", - config: map[string]interface{}{ - "key a": "value a", - "key b": []interface{}{ - "a", "b", 3, 4, - }, - }, - live: map[string]interface{}{ - "key a": "value a", - "key b": []interface{}{ - "a", "b", 3, - }, - }, - expected: map[string]interface{}{ - "key a": "value a", - "key b": []interface{}{ - "a", "b", 3, - }, - }, - }, - { - name: "Nested array remain", - config: map[string]interface{}{ - "key a": "value a", - "key b": []interface{}{ - "a", "b", - }, - }, - live: map[string]interface{}{ - "key a": "value a", - "key b": []interface{}{ - "a", "b", map[string]interface{}{ - "aa": "aa", - }, - }, - }, - expected: map[string]interface{}{ - "key a": "value a", - "key b": []interface{}{ - "a", "b", map[string]interface{}{ - "aa": "aa", - }, - }, - }, - }, - { - name: "Nested array same", - config: map[string]interface{}{ - "key a": "value a", - "key b": []interface{}{ - "a", "b", 3, - }, - }, - live: map[string]interface{}{ - "key a": "value a", - "key b": []interface{}{ - "b", "a", 3, - }, - }, - expected: map[string]interface{}{ - "key a": "value a", - "key b": []interface{}{ - "b", "a", 3, - }, - }, - }, - } - - for _, tc := range testcases { - t.Run(tc.name, func(t *testing.T) { - removed := removeMapFields(tc.config, tc.live) - assert.Equal(t, tc.expected, removed) - }) - } -} diff --git a/pkg/app/pipedv1/plugin/kubernetes/provider/hasher.go b/pkg/app/pipedv1/plugin/kubernetes/provider/hasher.go deleted file mode 100644 index 80a13009a4..0000000000 --- a/pkg/app/pipedv1/plugin/kubernetes/provider/hasher.go +++ /dev/null @@ -1,157 +0,0 @@ -// 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. - -/* -Copyright 2017 The Kubernetes Authors. -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - http://www.apache.org/licenses/LICENSE-2.0 -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -package provider - -import ( - "crypto/sha256" - "encoding/json" - "errors" - "fmt" - - v1 "k8s.io/api/core/v1" -) - -// HashManifests computes the hash of a list of manifests. -func HashManifests(manifests []Manifest) (string, error) { - if len(manifests) == 0 { - return "", errors.New("no manifest to hash") - } - - hasher := sha256.New() - for _, m := range manifests { - var encoded string - var err error - - switch { - case m.Key.IsConfigMap(): - obj := &v1.ConfigMap{} - if err := m.ConvertToStructuredObject(obj); err != nil { - return "", err - } - encoded, err = encodeConfigMap(obj) - case m.Key.IsSecret(): - obj := &v1.Secret{} - if err := m.ConvertToStructuredObject(obj); err != nil { - return "", err - } - encoded, err = encodeSecret(obj) - default: - var encodedBytes []byte - encodedBytes, err = m.MarshalJSON() - encoded = string(encodedBytes) - } - - if err != nil { - return "", err - } - if _, err := hasher.Write([]byte(encoded)); err != nil { - return "", err - } - } - - hex := fmt.Sprintf("%x", hasher.Sum(nil)) - return encodeHash(hex) -} - -// Borrowed from https://github.com/kubernetes/kubernetes/blob/ -// ea0764452222146c47ec826977f49d7001b0ea8c/staging/src/k8s.io/kubectl/pkg/util/hash/hash.go -// encodeHash extracts the first 40 bits of the hash from the hex string -// (1 hex char represents 4 bits), and then maps vowels and vowel-like hex -// characters to consonants to prevent bad words from being formed (the theory -// is that no vowels makes it really hard to make bad words). Since the string -// is hex, the only vowels it can contain are 'a' and 'e'. -// We picked some arbitrary consonants to map to from the same character set as GenerateName. -// See: https://github.com/kubernetes/apimachinery/blob/dc1f89aff9a7509782bde3b68824c8043a3e58cc/pkg/util/rand/rand.go#L75 -// If the hex string contains fewer than ten characters, returns an error. -func encodeHash(hex string) (string, error) { - if len(hex) < 10 { - return "", errors.New("the hex string must contain at least 10 characters") - } - enc := []rune(hex[:10]) - for i := range enc { - switch enc[i] { - case '0': - enc[i] = 'g' - case '1': - enc[i] = 'h' - case '3': - enc[i] = 'k' - case 'a': - enc[i] = 'm' - case 'e': - enc[i] = 't' - } - } - return string(enc), nil -} - -// Borrowed from https://github.com/kubernetes/kubernetes/blob/ -// ea0764452222146c47ec826977f49d7001b0ea8c/staging/src/k8s.io/kubectl/pkg/util/hash/hash.go -// encodeConfigMap encodes a ConfigMap. -// Data, Kind, and Name are taken into account. -func encodeConfigMap(cm *v1.ConfigMap) (string, error) { - // json.Marshal sorts the keys in a stable order in the encoding - m := map[string]interface{}{ - "kind": "ConfigMap", - "name": cm.Name, - "data": cm.Data, - } - if cm.Immutable != nil { - m["immutable"] = *cm.Immutable - } - if len(cm.BinaryData) > 0 { - m["binaryData"] = cm.BinaryData - } - data, err := json.Marshal(m) - if err != nil { - return "", err - } - return string(data), nil -} - -// Borrowed from https://github.com/kubernetes/kubernetes/blob/ -// ea0764452222146c47ec826977f49d7001b0ea8c/staging/src/k8s.io/kubectl/pkg/util/hash/hash.go -// encodeSecret encodes a Secret. -// Data, Kind, Name, and Type are taken into account. -func encodeSecret(sec *v1.Secret) (string, error) { - m := map[string]interface{}{ - "kind": "Secret", - "type": sec.Type, - "name": sec.Name, - "data": sec.Data, - } - if sec.Immutable != nil { - m["immutable"] = *sec.Immutable - } - // json.Marshal sorts the keys in a stable order in the encoding - data, err := json.Marshal(m) - if err != nil { - return "", err - } - return string(data), nil -} diff --git a/pkg/app/pipedv1/plugin/kubernetes/provider/hasher_test.go b/pkg/app/pipedv1/plugin/kubernetes/provider/hasher_test.go deleted file mode 100644 index a11b0b9e49..0000000000 --- a/pkg/app/pipedv1/plugin/kubernetes/provider/hasher_test.go +++ /dev/null @@ -1,169 +0,0 @@ -// 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 provider - -import ( - "errors" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestHashManifests(t *testing.T) { - t.Parallel() - - testcases := []struct { - name string - manifests string - expected string - expectedError error - }{ - { - name: "no manifests", - expectedError: errors.New("no manifest to hash"), - }, - { - name: "configmap: emptydata", - manifests: ` -apiVersion: v1 -kind: ConfigMap -data: {} -binaryData: {} -`, - expected: "42745tchd9", - }, - { - name: "configmap: one key", - manifests: ` -apiVersion: v1 -kind: ConfigMap -data: - one: "" -binaryData: {} -`, - expected: "9g67k2htb6", - }, - { - name: "configmap: there keys for checking order", - manifests: ` -apiVersion: v1 -kind: ConfigMap -data: - two: "2" - one: "" - three: "3" -binaryData: {} -`, - expected: "f5h7t85m9b", - }, - { - name: "secret: emptydata", - manifests: ` -apiVersion: v1 -kind: Secret -type: my-type -data: {} -`, - expected: "t75bgf6ctb", - }, - { - name: "secret: one key", - manifests: ` -apiVersion: v1 -kind: Secret -type: my-type -data: - "one": "" -`, - expected: "74bd68bm66", - }, - { - name: "secret: there keys for checking order", - manifests: ` -apiVersion: v1 -kind: Secret -type: my-type -data: - two: Mg== - one: "" - three: Mw== -`, - expected: "dgcb6h9tmk", - }, - { - name: "multiple configs", - manifests: ` -apiVersion: v1 -kind: ConfigMap -data: - two: "2" - three: "3" -binaryData: {} ---- -apiVersion: v1 -kind: Secret -type: my-type -data: - one: "" - three: Mw== -`, - expected: "57hhd7795k", - }, - { - name: "not config manifest", - manifests: ` -apiVersion: apps/v1 -kind: Foo -metadata: - name: simple - labels: - app: simple - pipecd.dev/managed-by: piped -spec: - replicas: 2 - selector: - matchLabels: - app: simple - template: - metadata: - labels: - app: simple - component: foo - spec: - containers: - - name: helloworld - image: gcr.io/pipecd/helloworld:v1.0.0 - args: - - hi - - hello - ports: - - containerPort: 9085 -`, - expected: "db48kd6689", - }, - } - - for _, tc := range testcases { - t.Run(tc.name, func(t *testing.T) { - manifests, err := ParseManifests(tc.manifests) - require.NoError(t, err) - - out, err := HashManifests(manifests) - assert.Equal(t, tc.expected, out) - assert.Equal(t, tc.expectedError, err) - }) - } -} diff --git a/pkg/app/pipedv1/plugin/kubernetes/provider/helm.go b/pkg/app/pipedv1/plugin/kubernetes/provider/helm.go deleted file mode 100644 index 0efbe75880..0000000000 --- a/pkg/app/pipedv1/plugin/kubernetes/provider/helm.go +++ /dev/null @@ -1,430 +0,0 @@ -// 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 provider - -import ( - "bytes" - "context" - "fmt" - "net/url" - "os" - "os/exec" - "path/filepath" - "strings" - - "go.uber.org/zap" - - "github.com/pipe-cd/pipecd/pkg/app/pipedv1/plugin/kubernetes/provider/chartrepo" - "github.com/pipe-cd/pipecd/pkg/config" -) - -var ( - allowedURLSchemes = []string{"http", "https"} -) - -type helmRegistry interface { - Helm(ctx context.Context, version string) (string, error) -} - -type Helm struct { - version string - execPath string - logger *zap.Logger - toolregistry helmRegistry -} - -func NewHelm(version, path string, logger *zap.Logger, toolregistry helmRegistry) *Helm { - return &Helm{ - version: version, - execPath: path, - logger: logger, - toolregistry: toolregistry, - } -} - -func (h *Helm) TemplateLocalChart(ctx context.Context, appName, appDir, namespace, chartPath string, opts *config.InputHelmOptions) (string, error) { - releaseName := appName - if opts != nil && opts.ReleaseName != "" { - releaseName = opts.ReleaseName - } - - args := []string{ - "template", - "--no-hooks", - "--include-crds", - releaseName, - chartPath, - } - - if namespace != "" { - args = append(args, fmt.Sprintf("--namespace=%s", namespace)) - } - - if opts != nil { - for k, v := range opts.SetValues { - args = append(args, "--set", fmt.Sprintf("%s=%s", k, v)) - } - for _, v := range opts.ValueFiles { - if err := verifyHelmValueFilePath(appDir, v); err != nil { - h.logger.Error("failed to verify values file path", zap.Error(err)) - return "", err - } - args = append(args, "-f", v) - } - for k, v := range opts.SetFiles { - args = append(args, "--set-file", fmt.Sprintf("%s=%s", k, v)) - } - for _, v := range opts.APIVersions { - args = append(args, "--api-versions", v) - } - if opts.KubeVersion != "" { - args = append(args, "--kube-version", opts.KubeVersion) - } - } - - var stdout, stderr bytes.Buffer - cmd := exec.CommandContext(ctx, h.execPath, args...) - cmd.Dir = appDir - cmd.Stdout = &stdout - cmd.Stderr = &stderr - - h.logger.Info(fmt.Sprintf("start templating a local chart (or cloned remote git chart) for application %s", appName), - zap.Any("args", args), - ) - - if err := cmd.Run(); err != nil { - return stdout.String(), fmt.Errorf("%w: %s", err, stderr.String()) - } - return stdout.String(), nil -} - -type helmRemoteGitChart struct { - GitRemote string - Ref string - Path string -} - -func (h *Helm) TemplateRemoteGitChart(ctx context.Context, appName, appDir, namespace string, chart helmRemoteGitChart, gitClient gitClient, opts *config.InputHelmOptions) (string, error) { - // Firstly, we need to download the remote repository. - repoDir, err := os.MkdirTemp("", "helm-remote-chart") - if err != nil { - return "", fmt.Errorf("unable to created temporary directory for storing remote helm chart: %w", err) - } - defer os.RemoveAll(repoDir) - - repo, err := gitClient.Clone(ctx, chart.GitRemote, chart.GitRemote, "", repoDir) - if err != nil { - return "", fmt.Errorf("unable to clone git repository containing remote helm chart: %w", err) - } - - if chart.Ref != "" { - if err := repo.Checkout(ctx, chart.Ref); err != nil { - return "", fmt.Errorf("unable to checkout to specified ref %s: %w", chart.Ref, err) - } - } - chartPath := filepath.Join(repoDir, chart.Path) - - // After that handle it as a local chart. - return h.TemplateLocalChart(ctx, appName, appDir, namespace, chartPath, opts) -} - -type helmRemoteChart struct { - Repository string - Name string - Version string - Insecure bool -} - -func (h *Helm) TemplateRemoteChart(ctx context.Context, appName, appDir, namespace string, chart helmRemoteChart, opts *config.InputHelmOptions) (string, error) { - releaseName := appName - if opts != nil && opts.ReleaseName != "" { - releaseName = opts.ReleaseName - } - - args := []string{ - "template", - "--no-hooks", - "--include-crds", - releaseName, - fmt.Sprintf("%s/%s", chart.Repository, chart.Name), - fmt.Sprintf("--version=%s", chart.Version), - } - - if chart.Insecure { - args = append(args, "--insecure-skip-tls-verify") - } - - if namespace != "" { - args = append(args, fmt.Sprintf("--namespace=%s", namespace)) - } - - if opts != nil { - for k, v := range opts.SetValues { - args = append(args, "--set", fmt.Sprintf("%s=%s", k, v)) - } - for _, v := range opts.ValueFiles { - if err := verifyHelmValueFilePath(appDir, v); err != nil { - h.logger.Error("failed to verify values file path", zap.Error(err)) - return "", err - } - args = append(args, "-f", v) - } - for k, v := range opts.SetFiles { - args = append(args, "--set-file", fmt.Sprintf("%s=%s", k, v)) - } - for _, v := range opts.APIVersions { - args = append(args, "--api-versions", v) - } - if opts.KubeVersion != "" { - args = append(args, "--kube-version", opts.KubeVersion) - } - } - - h.logger.Info(fmt.Sprintf("start templating a chart from Helm repository for application %s", appName), - zap.Any("args", args), - ) - - executor := func() (string, error) { - var stdout, stderr bytes.Buffer - cmd := exec.CommandContext(ctx, h.execPath, args...) - cmd.Dir = appDir - cmd.Stdout = &stdout - cmd.Stderr = &stderr - - if err := cmd.Run(); err != nil { - return stdout.String(), fmt.Errorf("%w: %s", err, stderr.String()) - } - return stdout.String(), nil - } - - out, err := executor() - if err == nil { - return out, nil - } - - if !strings.Contains(err.Error(), "helm repo update") { - return "", err - } - - // If the error is a "Not Found", we update the repositories and try again. - if e := chartrepo.Update(ctx, h.toolregistry, h.logger); e != nil { - h.logger.Error("failed to update Helm chart repositories", zap.Error(e)) - return "", err - } - return executor() -} - -// verifyHelmValueFilePath verifies if the path of the values file references -// a remote URL or inside the path where the application configuration file (i.e. *.pipecd.yaml) is located. -func verifyHelmValueFilePath(appDir, valueFilePath string) error { - url, err := url.Parse(valueFilePath) - if err == nil && url.Scheme != "" { - for _, s := range allowedURLSchemes { - if strings.EqualFold(url.Scheme, s) { - return nil - } - } - - return fmt.Errorf("scheme %s is not allowed to load values file", url.Scheme) - } - - // valueFilePath is a path where non-default Helm values file is located. - if !filepath.IsAbs(valueFilePath) { - valueFilePath = filepath.Join(appDir, valueFilePath) - } - - if isSymlink(valueFilePath) { - if valueFilePath, err = resolveSymlinkToAbsPath(valueFilePath, appDir); err != nil { - return err - } - } - - // If a path outside of appDir is specified as the path for the values file, - // it may indicate that someone trying to illegally read a file as values file that - // exists in the environment where Piped is running. - if !strings.HasPrefix(valueFilePath, appDir) { - return fmt.Errorf("values file %s references outside the application configuration directory", valueFilePath) - } - - return nil -} - -// isSymlink returns the path is whether symbolic link or not. -func isSymlink(path string) bool { - lstat, err := os.Lstat(path) - if err != nil { - return false - } - - return lstat.Mode()&os.ModeSymlink == os.ModeSymlink -} - -// resolveSymlinkToAbsPath resolves symbolic link to an absolute path. -func resolveSymlinkToAbsPath(path, absParentDir string) (string, error) { - resolved, err := os.Readlink(path) - if err != nil { - return "", err - } - - if !filepath.IsAbs(resolved) { - resolved = filepath.Join(absParentDir, resolved) - } - - return resolved, nil -} - -func (h *Helm) UpgradeLocalChart(ctx context.Context, appName, appDir, namespace, chartPath string, opts *config.InputHelmOptions) (string, error) { - releaseName := appName - if opts != nil && opts.ReleaseName != "" { - releaseName = opts.ReleaseName - } - - args := []string{ - "upgrade", - "--install", - releaseName, - chartPath, - } - - if namespace != "" { - args = append(args, fmt.Sprintf("--namespace=%s", namespace)) - } - - if opts != nil { - for k, v := range opts.SetValues { - args = append(args, "--set", fmt.Sprintf("%s=%s", k, v)) - } - for _, v := range opts.ValueFiles { - if err := verifyHelmValueFilePath(appDir, v); err != nil { - h.logger.Error("failed to verify values file path", zap.Error(err)) - return "", err - } - args = append(args, "-f", v) - } - for k, v := range opts.SetFiles { - args = append(args, "--set-file", fmt.Sprintf("%s=%s", k, v)) - } - } - var stdout, stderr bytes.Buffer - cmd := exec.CommandContext(ctx, h.execPath, args...) - cmd.Dir = appDir - cmd.Stdout = &stdout - cmd.Stderr = &stderr - - h.logger.Info(fmt.Sprintf("start upgrading a release (or cloned remote git chart) for application %s", appName), - zap.Any("args", args), - ) - - if err := cmd.Run(); err != nil { - return stdout.String(), fmt.Errorf("%w: %s", err, stderr.String()) - } - return stdout.String(), nil -} - -func (h *Helm) UpgradeRemoteGitChart(ctx context.Context, appName, appDir, namespace string, chart helmRemoteGitChart, gitClient gitClient, opts *config.InputHelmOptions) (string, error) { - repoDir, err := os.MkdirTemp("", "helm-remote-chart") - if err != nil { - return "", fmt.Errorf("unable to created temporary directory for storing remote helm chart: %w", err) - } - defer os.RemoveAll(repoDir) - - repo, err := gitClient.Clone(ctx, chart.GitRemote, chart.GitRemote, "", repoDir) - if err != nil { - return "", fmt.Errorf("unable to clone git repository containing remote helm chart: %w", err) - } - - if chart.Ref != "" { - if err := repo.Checkout(ctx, chart.Ref); err != nil { - return "", fmt.Errorf("unable to checkout to specified ref %s: %w", chart.Ref, err) - } - } - chartPath := filepath.Join(repoDir, chart.Path) - - // After that handle it as a local chart. - return h.UpgradeLocalChart(ctx, appName, appDir, namespace, chartPath, opts) -} - -func (h *Helm) UpgradeRemoteChart(ctx context.Context, appName, appDir, namespace string, chart helmRemoteChart, gitClient gitClient, opts *config.InputHelmOptions) (string, error) { - releaseName := appName - if opts != nil && opts.ReleaseName != "" { - releaseName = opts.ReleaseName - } - - args := []string{ - "upgrade", - "--install", - releaseName, - fmt.Sprintf("%s/%s", chart.Repository, chart.Name), - fmt.Sprintf("--version=%s", chart.Version), - } - - if chart.Insecure { - args = append(args, "--insecure-skip-tls-verify") - } - - if namespace != "" { - args = append(args, fmt.Sprintf("--namespace=%s", namespace)) - } - - if opts != nil { - for k, v := range opts.SetValues { - args = append(args, "--set", fmt.Sprintf("%s=%s", k, v)) - } - for _, v := range opts.ValueFiles { - if err := verifyHelmValueFilePath(appDir, v); err != nil { - h.logger.Error("failed to verify values file path", zap.Error(err)) - return "", err - } - args = append(args, "-f", v) - } - for k, v := range opts.SetFiles { - args = append(args, "--set-file", fmt.Sprintf("%s=%s", k, v)) - } - } - - h.logger.Info(fmt.Sprintf("start upgrading a release from Helm repository for application %s", appName), - zap.Any("args", args), - ) - - executor := func() (string, error) { - var stdout, stderr bytes.Buffer - cmd := exec.CommandContext(ctx, h.execPath, args...) - cmd.Dir = appDir - cmd.Stdout = &stdout - cmd.Stderr = &stderr - - if err := cmd.Run(); err != nil { - return stdout.String(), fmt.Errorf("%w: %s", err, stderr.String()) - } - return stdout.String(), nil - } - - out, err := executor() - if err == nil { - return out, nil - } - - if !strings.Contains(err.Error(), "helm repo update") { - return "", err - } - - // If the error is a "Not Found", we update the repositories and try again. - if e := chartrepo.Update(ctx, h.toolregistry, h.logger); e != nil { - h.logger.Error("failed to update Helm chart repositories", zap.Error(e)) - return "", err - } - return executor() - -} diff --git a/pkg/app/pipedv1/plugin/kubernetes/provider/helm_test.go b/pkg/app/pipedv1/plugin/kubernetes/provider/helm_test.go deleted file mode 100644 index 6552222440..0000000000 --- a/pkg/app/pipedv1/plugin/kubernetes/provider/helm_test.go +++ /dev/null @@ -1,177 +0,0 @@ -// 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 provider - -import ( - "context" - "strings" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "go.uber.org/zap" - - "github.com/pipe-cd/pipecd/pkg/app/pipedv1/plugin/kubernetes/toolregistry" - "github.com/pipe-cd/pipecd/pkg/app/pipedv1/plugin/toolregistry/toolregistrytest" -) - -func TestTemplateLocalChart(t *testing.T) { - t.Parallel() - - var ( - ctx = context.Background() - appName = "testapp" - appDir = "testdata" - chartPath = "testchart" - ) - - r, err := toolregistrytest.NewToolRegistry(t) - require.NoError(t, err) - t.Cleanup(func() { r.Close() }) - - registry := toolregistry.NewRegistry(r) - helmPath, err := registry.Helm(ctx, "3.8.2") - require.NoError(t, err) - - helm := NewHelm("", helmPath, zap.NewNop(), registry) - out, err := helm.TemplateLocalChart(ctx, appName, appDir, "", chartPath, nil) - require.NoError(t, err) - - out = strings.TrimPrefix(out, "---") - manifests := strings.Split(out, "---") - assert.Equal(t, 3, len(manifests)) -} - -func TestTemplateLocalChart_WithNamespace(t *testing.T) { - t.Parallel() - - var ( - ctx = context.Background() - appName = "testapp" - appDir = "testdata" - chartPath = "testchart" - namespace = "testnamespace" - ) - - r, err := toolregistrytest.NewToolRegistry(t) - require.NoError(t, err) - t.Cleanup(func() { r.Close() }) - - registry := toolregistry.NewRegistry(r) - helmPath, err := registry.Helm(ctx, "3.8.2") - require.NoError(t, err) - - helm := NewHelm("", helmPath, zap.NewNop(), registry) - out, err := helm.TemplateLocalChart(ctx, appName, appDir, namespace, chartPath, nil) - require.NoError(t, err) - - out = strings.TrimPrefix(out, "---") - - manifests, _ := ParseManifests(out) - for _, manifest := range manifests { - metadata, err := manifest.GetNestedMap("metadata") - require.NoError(t, err) - require.Equal(t, namespace, metadata["namespace"]) - } -} - -func TestVerifyHelmValueFilePath(t *testing.T) { - t.Parallel() - - testcases := []struct { - name string - appDir string - valueFilePath string - wantErr bool - }{ - { - name: "Values file locates inside the app dir", - appDir: "testdata/testhelm/appconfdir", - valueFilePath: "values.yaml", - wantErr: false, - }, - { - name: "Values file locates inside the app dir (with ..)", - appDir: "testdata/testhelm/appconfdir", - valueFilePath: "../../../testdata/testhelm/appconfdir/values.yaml", - wantErr: false, - }, - { - name: "Values file locates under the app dir", - appDir: "testdata/testhelm/appconfdir", - valueFilePath: "dir/values.yaml", - wantErr: false, - }, - { - name: "Values file locates under the app dir (with ..)", - appDir: "testdata/testhelm/appconfdir", - valueFilePath: "../../../testdata/testhelm/appconfdir/dir/values.yaml", - wantErr: false, - }, - { - name: "arbitrary file locates outside the app dir", - appDir: "testdata/testhelm/appconfdir", - valueFilePath: "/etc/hosts", - wantErr: true, - }, - { - name: "arbitrary file locates outside the app dir (with ..)", - appDir: "testdata/testhelm/appconfdir", - valueFilePath: "../../../../../../../../../../../../etc/hosts", - wantErr: true, - }, - { - name: "Values file locates allowed remote URL (http)", - appDir: "testdata/testhelm/appconfdir", - valueFilePath: "http://exmaple.com/values.yaml", - wantErr: false, - }, - { - name: "Values file locates allowed remote URL (https)", - appDir: "testdata/testhelm/appconfdir", - valueFilePath: "https://exmaple.com/values.yaml", - wantErr: false, - }, - { - name: "Values file locates disallowed remote URL (ftp)", - appDir: "testdata/testhelm/appconfdir", - valueFilePath: "ftp://exmaple.com/values.yaml", - wantErr: true, - }, - { - name: "Values file is symlink targeting valid values file", - appDir: "testdata/testhelm/appconfdir", - valueFilePath: "valid-symlink", - wantErr: false, - }, - { - name: "Values file is symlink targeting invalid values file", - appDir: "testdata/testhelm/appconfdir", - valueFilePath: "invalid-symlink", - wantErr: true, - }, - } - - for _, tc := range testcases { - t.Run(tc.name, func(t *testing.T) { - err := verifyHelmValueFilePath(tc.appDir, tc.valueFilePath) - if tc.wantErr { - require.Error(t, err) - } else { - require.NoError(t, err) - } - }) - } -} diff --git a/pkg/app/pipedv1/plugin/kubernetes/provider/kubectl.go b/pkg/app/pipedv1/plugin/kubernetes/provider/kubectl.go deleted file mode 100644 index 09541dd10b..0000000000 --- a/pkg/app/pipedv1/plugin/kubernetes/provider/kubectl.go +++ /dev/null @@ -1,285 +0,0 @@ -// 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 provider - -import ( - "bytes" - "context" - "errors" - "fmt" - "os/exec" - "strings" - - "k8s.io/client-go/rest" - - "github.com/pipe-cd/pipecd/pkg/app/pipedv1/plugin/kubernetes/provider/kubernetesmetrics" -) - -var ( - errorReplaceNotFound = errors.New("specified resource is not found") - errorNotFoundLiteral = "Error from server (NotFound)" - errResourceAlreadyExists = errors.New("resource already exists") - errAlreadyExistsLiteral = "Error from server (AlreadyExists)" -) - -type Kubectl struct { - version string - execPath string - config *rest.Config -} - -func NewKubectl(version, path string) *Kubectl { - return &Kubectl{ - version: version, - execPath: path, - } -} - -func (c *Kubectl) Apply(ctx context.Context, kubeconfig, namespace string, manifest Manifest) (err error) { - defer func() { - kubernetesmetrics.IncKubectlCallsCounter( - c.version, - kubernetesmetrics.LabelApplyCommand, - err == nil, - ) - }() - - data, err := manifest.YamlBytes() - if err != nil { - return err - } - - args := make([]string, 0, 8) - if kubeconfig != "" { - args = append(args, "--kubeconfig", kubeconfig) - } - if namespace != "" { - args = append(args, "--namespace", namespace) - } - - args = append(args, "apply") - if annotation := manifest.GetAnnotations()[LabelServerSideApply]; annotation == UseServerSideApply { - args = append(args, "--server-side") - } - args = append(args, "-f", "-") - - cmd := exec.CommandContext(ctx, c.execPath, args...) - r := bytes.NewReader(data) - cmd.Stdin = r - - out, err := cmd.CombinedOutput() - if err != nil { - return fmt.Errorf("failed to apply: %s (%w)", string(out), err) - } - return nil -} - -func (c *Kubectl) Create(ctx context.Context, kubeconfig, namespace string, manifest Manifest) (err error) { - defer func() { - kubernetesmetrics.IncKubectlCallsCounter( - c.version, - kubernetesmetrics.LabelCreateCommand, - err == nil, - ) - }() - - data, err := manifest.YamlBytes() - if err != nil { - return err - } - - args := make([]string, 0, 7) - if kubeconfig != "" { - args = append(args, "--kubeconfig", kubeconfig) - } - if namespace != "" { - args = append(args, "--namespace", namespace) - } - args = append(args, "create", "-f", "-") - - cmd := exec.CommandContext(ctx, c.execPath, args...) - r := bytes.NewReader(data) - cmd.Stdin = r - - out, err := cmd.CombinedOutput() - if err != nil { - return fmt.Errorf("failed to create: %s (%w)", string(out), err) - } - return nil -} - -func (c *Kubectl) Replace(ctx context.Context, kubeconfig, namespace string, manifest Manifest) (err error) { - defer func() { - kubernetesmetrics.IncKubectlCallsCounter( - c.version, - kubernetesmetrics.LabelReplaceCommand, - err == nil, - ) - }() - - data, err := manifest.YamlBytes() - if err != nil { - return err - } - - args := make([]string, 0, 7) - if kubeconfig != "" { - args = append(args, "--kubeconfig", kubeconfig) - } - if namespace != "" { - args = append(args, "--namespace", namespace) - } - args = append(args, "replace", "-f", "-") - - cmd := exec.CommandContext(ctx, c.execPath, args...) - r := bytes.NewReader(data) - cmd.Stdin = r - - out, err := cmd.CombinedOutput() - if err == nil { - return nil - } - - if strings.Contains(string(out), errorNotFoundLiteral) { - return errorReplaceNotFound - } - - return fmt.Errorf("failed to replace: %s (%w)", string(out), err) -} - -func (c *Kubectl) ForceReplace(ctx context.Context, kubeconfig, namespace string, manifest Manifest) (err error) { - defer func() { - kubernetesmetrics.IncKubectlCallsCounter( - c.version, - kubernetesmetrics.LabelReplaceCommand, - err == nil, - ) - }() - - data, err := manifest.YamlBytes() - if err != nil { - return err - } - - args := make([]string, 0, 7) - if kubeconfig != "" { - args = append(args, "--kubeconfig", kubeconfig) - } - if namespace != "" { - args = append(args, "--namespace", namespace) - } - args = append(args, "replace", "--force", "-f", "-") - - cmd := exec.CommandContext(ctx, c.execPath, args...) - r := bytes.NewReader(data) - cmd.Stdin = r - - out, err := cmd.CombinedOutput() - if err == nil { - return nil - } - - if strings.Contains(string(out), errorNotFoundLiteral) { - return errorReplaceNotFound - } - - return fmt.Errorf("failed to replace: %s (%w)", string(out), err) -} - -func (c *Kubectl) Delete(ctx context.Context, kubeconfig, namespace string, r ResourceKey) (err error) { - defer func() { - kubernetesmetrics.IncKubectlCallsCounter( - c.version, - kubernetesmetrics.LabelDeleteCommand, - err == nil, - ) - }() - - args := make([]string, 0, 7) - if kubeconfig != "" { - args = append(args, "--kubeconfig", kubeconfig) - } - if namespace != "" { - args = append(args, "--namespace", namespace) - } - args = append(args, "delete", r.Kind, r.Name) - - cmd := exec.CommandContext(ctx, c.execPath, args...) - out, err := cmd.CombinedOutput() - - if strings.Contains(string(out), "(NotFound)") { - return fmt.Errorf("failed to delete: %s, (%w), %v", string(out), ErrNotFound, err) - } - if err != nil { - return fmt.Errorf("failed to delete: %s, %v", string(out), err) - } - return nil -} - -func (c *Kubectl) Get(ctx context.Context, kubeconfig, namespace string, r ResourceKey) (m Manifest, err error) { - defer func() { - kubernetesmetrics.IncKubectlCallsCounter( - c.version, - kubernetesmetrics.LabelGetCommand, - err == nil, - ) - }() - - args := make([]string, 0, 7) - if kubeconfig != "" { - args = append(args, "--kubeconfig", kubeconfig) - } - if namespace != "" { - args = append(args, "--namespace", namespace) - } - args = append(args, "get", r.Kind, r.Name, "-o", "yaml") - - cmd := exec.CommandContext(ctx, c.execPath, args...) - out, err := cmd.CombinedOutput() - - if strings.Contains(string(out), "(NotFound)") { - return Manifest{}, fmt.Errorf("not found manifest %v, (%w), %v", r, ErrNotFound, err) - } - if err != nil { - return Manifest{}, fmt.Errorf("failed to get: %s, %v", string(out), err) - } - ms, err := ParseManifests(string(out)) - if err != nil { - return Manifest{}, fmt.Errorf("failed to parse manifests %v: %v", r, err) - } - if len(ms) == 0 { - return Manifest{}, fmt.Errorf("not found manifest %v, (%w)", r, ErrNotFound) - } - return ms[0], nil -} - -func (c *Kubectl) CreateNamespace(ctx context.Context, kubeconfig, namespace string) (err error) { - args := make([]string, 0, 7) - if kubeconfig != "" { - args = append(args, "--kubeconfig", kubeconfig) - } - args = append(args, "create", "namespace", namespace) - - cmd := exec.CommandContext(ctx, c.execPath, args...) - out, err := cmd.CombinedOutput() - - if strings.Contains(string(out), errAlreadyExistsLiteral) { - return errResourceAlreadyExists - } - if err != nil { - return fmt.Errorf("failed to create namespace: %s, %v", string(out), err) - } - return nil -} diff --git a/pkg/app/pipedv1/plugin/kubernetes/provider/kubernetes.go b/pkg/app/pipedv1/plugin/kubernetes/provider/kubernetes.go deleted file mode 100644 index 6a8805951a..0000000000 --- a/pkg/app/pipedv1/plugin/kubernetes/provider/kubernetes.go +++ /dev/null @@ -1,45 +0,0 @@ -// 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 provider - -import ( - "errors" -) - -var ( - ErrNotFound = errors.New("not found") -) - -const ( - LabelManagedBy = "pipecd.dev/managed-by" // Always be piped. - LabelPiped = "pipecd.dev/piped" // The id of piped handling this application. - LabelApplication = "pipecd.dev/application" // The application this resource belongs to. - LabelCommitHash = "pipecd.dev/commit-hash" // Hash value of the deployed commit. - LabelResourceKey = "pipecd.dev/resource-key" // The resource key generated by apiVersion, namespace and name. e.g. apps/v1/Deployment/namespace/demo-app - LabelOriginalAPIVersion = "pipecd.dev/original-api-version" // The api version defined in git configuration. e.g. apps/v1 - LabelIgnoreDriftDirection = "pipecd.dev/ignore-drift-detection" // Whether the drift detection should ignore this resource. - LabelSyncReplace = "pipecd.dev/sync-by-replace" // Use replace instead of apply. - LabelForceSyncReplace = "pipecd.dev/force-sync-by-replace" // Use replace --force instead of apply. - LabelServerSideApply = "pipecd.dev/server-side-apply" // Use server side apply instead of client side apply. - AnnotationConfigHash = "pipecd.dev/config-hash" // The hash value of all mouting config resources. - AnnotationOrder = "pipecd.dev/order" // The order number of resource used to sort them before using. - - ManagedByPiped = "piped" - IgnoreDriftDetectionTrue = "true" - UseReplaceEnabled = "enabled" - UseServerSideApply = "true" - - kustomizationFileName = "kustomization.yaml" -) diff --git a/pkg/app/pipedv1/plugin/kubernetes/provider/kubernetesmetrics/metrics.go b/pkg/app/pipedv1/plugin/kubernetes/provider/kubernetesmetrics/metrics.go deleted file mode 100644 index d7575aeba9..0000000000 --- a/pkg/app/pipedv1/plugin/kubernetes/provider/kubernetesmetrics/metrics.go +++ /dev/null @@ -1,81 +0,0 @@ -// 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 kubernetesmetrics - -import ( - "github.com/prometheus/client_golang/prometheus" -) - -const ( - toolKey = "tool" - versionKey = "version" - toolCommandKey = "command" - commandOutputKey = "status" -) - -type Tool string - -const ( - LabelToolKubectl Tool = "kubectl" -) - -type ToolCommand string - -const ( - LabelApplyCommand ToolCommand = "apply" - LabelCreateCommand ToolCommand = "create" - LabelReplaceCommand ToolCommand = "replace" - LabelDeleteCommand ToolCommand = "delete" - LabelGetCommand ToolCommand = "get" -) - -type CommandOutput string - -const ( - LabelOutputSuccess CommandOutput = "success" - LabelOutputFailre CommandOutput = "failure" -) - -var ( - toolCallsCounter = prometheus.NewCounterVec( - prometheus.CounterOpts{ - Name: "cloudprovider_kubernetes_tool_calls_total", - Help: "Number of calls made to run the tool like kubectl, kustomize.", - }, - []string{ - toolKey, - versionKey, - toolCommandKey, - commandOutputKey, - }, - ) -) - -func IncKubectlCallsCounter(version string, command ToolCommand, success bool) { - status := LabelOutputSuccess - if !success { - status = LabelOutputFailre - } - toolCallsCounter.With(prometheus.Labels{ - toolKey: string(LabelToolKubectl), - versionKey: version, - toolCommandKey: string(command), - commandOutputKey: string(status), - }).Inc() -} - -func Register(r prometheus.Registerer) { - r.MustRegister(toolCallsCounter) -} diff --git a/pkg/app/pipedv1/plugin/kubernetes/provider/kubernetestest/kubernetes.mock.go b/pkg/app/pipedv1/plugin/kubernetes/provider/kubernetestest/kubernetes.mock.go deleted file mode 100644 index c72d75bc37..0000000000 --- a/pkg/app/pipedv1/plugin/kubernetes/provider/kubernetestest/kubernetes.mock.go +++ /dev/null @@ -1,144 +0,0 @@ -// Code generated by MockGen. DO NOT EDIT. -// Source: github.com/pipe-cd/pipecd/pkg/app/pipedv1/plugin/kubernetes/provider (interfaces: Applier,Loader) - -// Package kubernetestest is a generated GoMock package. -package kubernetestest - -import ( - context "context" - reflect "reflect" - - gomock "github.com/golang/mock/gomock" - provider "github.com/pipe-cd/pipecd/pkg/app/pipedv1/plugin/kubernetes/provider" -) - -// MockApplier is a mock of Applier interface. -type MockApplier struct { - ctrl *gomock.Controller - recorder *MockApplierMockRecorder -} - -// MockApplierMockRecorder is the mock recorder for MockApplier. -type MockApplierMockRecorder struct { - mock *MockApplier -} - -// NewMockApplier creates a new mock instance. -func NewMockApplier(ctrl *gomock.Controller) *MockApplier { - mock := &MockApplier{ctrl: ctrl} - mock.recorder = &MockApplierMockRecorder{mock} - return mock -} - -// EXPECT returns an object that allows the caller to indicate expected use. -func (m *MockApplier) EXPECT() *MockApplierMockRecorder { - return m.recorder -} - -// ApplyManifest mocks base method. -func (m *MockApplier) ApplyManifest(arg0 context.Context, arg1 provider.Manifest) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "ApplyManifest", arg0, arg1) - ret0, _ := ret[0].(error) - return ret0 -} - -// ApplyManifest indicates an expected call of ApplyManifest. -func (mr *MockApplierMockRecorder) ApplyManifest(arg0, arg1 interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ApplyManifest", reflect.TypeOf((*MockApplier)(nil).ApplyManifest), arg0, arg1) -} - -// CreateManifest mocks base method. -func (m *MockApplier) CreateManifest(arg0 context.Context, arg1 provider.Manifest) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "CreateManifest", arg0, arg1) - ret0, _ := ret[0].(error) - return ret0 -} - -// CreateManifest indicates an expected call of CreateManifest. -func (mr *MockApplierMockRecorder) CreateManifest(arg0, arg1 interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateManifest", reflect.TypeOf((*MockApplier)(nil).CreateManifest), arg0, arg1) -} - -// Delete mocks base method. -func (m *MockApplier) Delete(arg0 context.Context, arg1 provider.ResourceKey) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Delete", arg0, arg1) - ret0, _ := ret[0].(error) - return ret0 -} - -// Delete indicates an expected call of Delete. -func (mr *MockApplierMockRecorder) Delete(arg0, arg1 interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Delete", reflect.TypeOf((*MockApplier)(nil).Delete), arg0, arg1) -} - -// ForceReplaceManifest mocks base method. -func (m *MockApplier) ForceReplaceManifest(arg0 context.Context, arg1 provider.Manifest) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "ForceReplaceManifest", arg0, arg1) - ret0, _ := ret[0].(error) - return ret0 -} - -// ForceReplaceManifest indicates an expected call of ForceReplaceManifest. -func (mr *MockApplierMockRecorder) ForceReplaceManifest(arg0, arg1 interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ForceReplaceManifest", reflect.TypeOf((*MockApplier)(nil).ForceReplaceManifest), arg0, arg1) -} - -// ReplaceManifest mocks base method. -func (m *MockApplier) ReplaceManifest(arg0 context.Context, arg1 provider.Manifest) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "ReplaceManifest", arg0, arg1) - ret0, _ := ret[0].(error) - return ret0 -} - -// ReplaceManifest indicates an expected call of ReplaceManifest. -func (mr *MockApplierMockRecorder) ReplaceManifest(arg0, arg1 interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ReplaceManifest", reflect.TypeOf((*MockApplier)(nil).ReplaceManifest), arg0, arg1) -} - -// MockLoader is a mock of Loader interface. -type MockLoader struct { - ctrl *gomock.Controller - recorder *MockLoaderMockRecorder -} - -// MockLoaderMockRecorder is the mock recorder for MockLoader. -type MockLoaderMockRecorder struct { - mock *MockLoader -} - -// NewMockLoader creates a new mock instance. -func NewMockLoader(ctrl *gomock.Controller) *MockLoader { - mock := &MockLoader{ctrl: ctrl} - mock.recorder = &MockLoaderMockRecorder{mock} - return mock -} - -// EXPECT returns an object that allows the caller to indicate expected use. -func (m *MockLoader) EXPECT() *MockLoaderMockRecorder { - return m.recorder -} - -// LoadManifests mocks base method. -func (m *MockLoader) LoadManifests(arg0 context.Context) ([]provider.Manifest, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "LoadManifests", arg0) - ret0, _ := ret[0].([]provider.Manifest) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// LoadManifests indicates an expected call of LoadManifests. -func (mr *MockLoaderMockRecorder) LoadManifests(arg0 interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "LoadManifests", reflect.TypeOf((*MockLoader)(nil).LoadManifests), arg0) -} diff --git a/pkg/app/pipedv1/plugin/kubernetes/provider/kustomize.go b/pkg/app/pipedv1/plugin/kubernetes/provider/kustomize.go deleted file mode 100644 index 6538950b5f..0000000000 --- a/pkg/app/pipedv1/plugin/kubernetes/provider/kustomize.go +++ /dev/null @@ -1,67 +0,0 @@ -// 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 provider - -import ( - "bytes" - "context" - "fmt" - "os/exec" - - "go.uber.org/zap" -) - -type Kustomize struct { - version string - execPath string - logger *zap.Logger -} - -func NewKustomize(version, path string, logger *zap.Logger) *Kustomize { - return &Kustomize{ - version: version, - execPath: path, - logger: logger, - } -} - -func (c *Kustomize) Template(ctx context.Context, appName, appDir string, opts map[string]string) (string, error) { - args := []string{ - "build", - ".", - } - - for k, v := range opts { - args = append(args, fmt.Sprintf("--%s", k)) - if v != "" { - args = append(args, v) - } - } - - var stdout, stderr bytes.Buffer - cmd := exec.CommandContext(ctx, c.execPath, args...) - cmd.Dir = appDir - cmd.Stdout = &stdout - cmd.Stderr = &stderr - - c.logger.Info(fmt.Sprintf("start templating a Kustomize application %s", appName), - zap.Any("args", args), - ) - - if err := cmd.Run(); err != nil { - return stdout.String(), fmt.Errorf("%w: %s", err, stderr.String()) - } - return stdout.String(), nil -} diff --git a/pkg/app/pipedv1/plugin/kubernetes/provider/kustomize_test.go b/pkg/app/pipedv1/plugin/kubernetes/provider/kustomize_test.go deleted file mode 100644 index 7f4cd75e67..0000000000 --- a/pkg/app/pipedv1/plugin/kubernetes/provider/kustomize_test.go +++ /dev/null @@ -1,52 +0,0 @@ -// 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 provider - -import ( - "context" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "go.uber.org/zap" - - "github.com/pipe-cd/pipecd/pkg/app/pipedv1/plugin/kubernetes/toolregistry" - "github.com/pipe-cd/pipecd/pkg/app/pipedv1/plugin/toolregistry/toolregistrytest" -) - -func TestKustomizeTemplate(t *testing.T) { - t.Parallel() - - var ( - ctx = context.TODO() - appName = "testapp" - appDir = "testdata/testkustomize" - ) - - r, err := toolregistrytest.NewToolRegistry(t) - require.NoError(t, err) - t.Cleanup(func() { r.Close() }) - - registry := toolregistry.NewRegistry(r) - kustomizePath, err := registry.Kustomize(ctx, "5.4.3") - require.NoError(t, err) - - kustomize := NewKustomize("", kustomizePath, zap.NewNop()) - out, err := kustomize.Template(ctx, appName, appDir, map[string]string{ - "load-restrictor": "LoadRestrictionsNone", - }) - require.NoError(t, err) - assert.True(t, len(out) > 0) -} diff --git a/pkg/app/pipedv1/plugin/kubernetes/provider/loader.go b/pkg/app/pipedv1/plugin/kubernetes/provider/loader.go deleted file mode 100644 index d48abb96cf..0000000000 --- a/pkg/app/pipedv1/plugin/kubernetes/provider/loader.go +++ /dev/null @@ -1,230 +0,0 @@ -// 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 provider - -import ( - "context" - "errors" - "fmt" - "os" - "path/filepath" - "sort" - "strconv" - "sync" - - "go.uber.org/zap" - - "github.com/pipe-cd/pipecd/pkg/config" - "github.com/pipe-cd/pipecd/pkg/git" -) - -type TemplatingMethod string - -const ( - TemplatingMethodHelm TemplatingMethod = "helm" - TemplatingMethodKustomize TemplatingMethod = "kustomize" - TemplatingMethodNone TemplatingMethod = "none" -) - -type Loader interface { - // LoadManifests renders and loads all manifests for application. - LoadManifests(ctx context.Context) ([]Manifest, error) -} - -type gitClient interface { - Clone(ctx context.Context, repoID, remote, branch, destination string) (git.Repo, error) -} - -type registry interface { - Kubectl(ctx context.Context, version string) (string, error) - Kustomize(ctx context.Context, version string) (string, error) - Helm(ctx context.Context, version string) (string, error) -} - -type loader struct { - appName string - appDir string - repoDir string - configFileName string - input config.KubernetesDeploymentInput - gc gitClient - logger *zap.Logger - toolregistry registry - - templatingMethod TemplatingMethod - kustomize *Kustomize - helm *Helm - initOnce sync.Once - initErr error -} - -func NewLoader( - appName, appDir, repoDir, configFileName string, - input config.KubernetesDeploymentInput, - gc gitClient, - logger *zap.Logger, - toolregistry registry, -) Loader { - - return &loader{ - appName: appName, - appDir: appDir, - repoDir: repoDir, - configFileName: configFileName, - input: input, - gc: gc, - logger: logger.Named("kubernetes-loader"), - toolregistry: toolregistry, - } -} - -// LoadManifests renders and loads all manifests for application. -func (l *loader) LoadManifests(ctx context.Context) (manifests []Manifest, err error) { - defer func() { - // Override namespace if set because ParseManifests does not parse it - // if namespace is not explicitly specified in the manifests. - setNamespace(manifests, l.input.Namespace) - sortManifests(manifests) - }() - l.initOnce.Do(func() { - var initErrorHelm, initErrorKustomize error - l.templatingMethod = determineTemplatingMethod(l.input, l.appDir) - if l.templatingMethod != TemplatingMethodNone { - l.helm, initErrorHelm = l.findHelm(ctx, l.input.HelmVersion) - l.kustomize, initErrorKustomize = l.findKustomize(ctx, l.input.KustomizeVersion) - l.initErr = errors.Join(initErrorHelm, initErrorKustomize) - } - }) - if l.initErr != nil { - return nil, l.initErr - } - - switch l.templatingMethod { - case TemplatingMethodHelm: - var data string - switch { - case l.input.HelmChart.GitRemote != "": - chart := helmRemoteGitChart{ - GitRemote: l.input.HelmChart.GitRemote, - Ref: l.input.HelmChart.Ref, - Path: l.input.HelmChart.Path, - } - data, err = l.helm.TemplateRemoteGitChart(ctx, - l.appName, - l.appDir, - l.input.Namespace, - chart, - l.gc, - l.input.HelmOptions) - - case l.input.HelmChart.Repository != "": - chart := helmRemoteChart{ - Repository: l.input.HelmChart.Repository, - Name: l.input.HelmChart.Name, - Version: l.input.HelmChart.Version, - Insecure: l.input.HelmChart.Insecure, - } - data, err = l.helm.TemplateRemoteChart(ctx, - l.appName, - l.appDir, - l.input.Namespace, - chart, - l.input.HelmOptions) - - default: - data, err = l.helm.TemplateLocalChart(ctx, - l.appName, - l.appDir, - l.input.Namespace, - l.input.HelmChart.Path, - l.input.HelmOptions) - } - - if err != nil { - err = fmt.Errorf("unable to run helm template: %w", err) - return - } - manifests, err = ParseManifests(data) - - case TemplatingMethodKustomize: - var data string - data, err = l.kustomize.Template(ctx, l.appName, l.appDir, l.input.KustomizeOptions) - if err != nil { - err = fmt.Errorf("unable to run kustomize template: %w", err) - return - } - manifests, err = ParseManifests(data) - - case TemplatingMethodNone: - manifests, err = LoadPlainYAMLManifests(l.appDir, l.input.Manifests, l.configFileName) - - default: - err = fmt.Errorf("unsupport templating method %v", l.templatingMethod) - } - - return -} - -func setNamespace(manifests []Manifest, namespace string) { - if namespace == "" { - return - } - for i := range manifests { - manifests[i].Key.Namespace = namespace - } -} - -func sortManifests(manifests []Manifest) { - if len(manifests) < 2 { - return - } - sort.Slice(manifests, func(i, j int) bool { - iAns := manifests[i].GetAnnotations() - // Ignore the converting error since it is not so much important. - iIndex, _ := strconv.Atoi(iAns[AnnotationOrder]) - - jAns := manifests[j].GetAnnotations() - // Ignore the converting error since it is not so much important. - jIndex, _ := strconv.Atoi(jAns[AnnotationOrder]) - - return iIndex < jIndex - }) -} - -func (l *loader) findKustomize(ctx context.Context, version string) (*Kustomize, error) { - path, err := l.toolregistry.Kustomize(ctx, version) - if err != nil { - return nil, fmt.Errorf("no kustomize %s (%v)", version, err) - } - return NewKustomize(version, path, l.logger), nil -} - -func (l *loader) findHelm(ctx context.Context, version string) (*Helm, error) { - path, err := l.toolregistry.Helm(ctx, version) - if err != nil { - return nil, fmt.Errorf("no helm %s (%v)", version, err) - } - return NewHelm(version, path, l.logger, l.toolregistry), nil -} - -func determineTemplatingMethod(input config.KubernetesDeploymentInput, appDirPath string) TemplatingMethod { - if input.HelmChart != nil { - return TemplatingMethodHelm - } - if _, err := os.Stat(filepath.Join(appDirPath, kustomizationFileName)); err == nil { - return TemplatingMethodKustomize - } - return TemplatingMethodNone -} diff --git a/pkg/app/pipedv1/plugin/kubernetes/provider/loader_test.go b/pkg/app/pipedv1/plugin/kubernetes/provider/loader_test.go deleted file mode 100644 index 1810fc1163..0000000000 --- a/pkg/app/pipedv1/plugin/kubernetes/provider/loader_test.go +++ /dev/null @@ -1,78 +0,0 @@ -// 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 provider - -import ( - "testing" - - "github.com/stretchr/testify/assert" - "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" -) - -func TestSortManifests(t *testing.T) { - maker := func(name string, annotations map[string]string) Manifest { - m := Manifest{ - Key: ResourceKey{Name: name}, - u: &unstructured.Unstructured{ - Object: map[string]interface{}{}, - }, - } - m.AddAnnotations(annotations) - return m - } - - testcases := []struct { - name string - manifests []Manifest - want []Manifest - }{ - { - name: "empty", - }, - { - name: "one manifest", - manifests: []Manifest{ - maker("name-1", map[string]string{AnnotationOrder: "0"}), - }, - want: []Manifest{ - maker("name-1", map[string]string{AnnotationOrder: "0"}), - }, - }, - { - name: "multiple manifests", - manifests: []Manifest{ - maker("name-2", map[string]string{AnnotationOrder: "2"}), - maker("name--1", map[string]string{AnnotationOrder: "-1"}), - maker("name-nil", nil), - maker("name-0", map[string]string{AnnotationOrder: "0"}), - maker("name-1", map[string]string{AnnotationOrder: "1"}), - }, - want: []Manifest{ - maker("name--1", map[string]string{AnnotationOrder: "-1"}), - maker("name-nil", nil), - maker("name-0", map[string]string{AnnotationOrder: "0"}), - maker("name-1", map[string]string{AnnotationOrder: "1"}), - maker("name-2", map[string]string{AnnotationOrder: "2"}), - }, - }, - } - - for _, tc := range testcases { - t.Run(tc.name, func(t *testing.T) { - sortManifests(tc.manifests) - assert.Equal(t, tc.want, tc.manifests) - }) - } -} diff --git a/pkg/app/pipedv1/plugin/kubernetes/provider/manifest.go b/pkg/app/pipedv1/plugin/kubernetes/provider/manifest.go deleted file mode 100644 index 26752e3e4b..0000000000 --- a/pkg/app/pipedv1/plugin/kubernetes/provider/manifest.go +++ /dev/null @@ -1,249 +0,0 @@ -// 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 provider - -import ( - "encoding/json" - "fmt" - "os" - "path/filepath" - "strings" - - "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" - "sigs.k8s.io/yaml" - - "github.com/pipe-cd/pipecd/pkg/model" -) - -type Manifest struct { - Key ResourceKey - u *unstructured.Unstructured -} - -func MakeManifest(key ResourceKey, u *unstructured.Unstructured) Manifest { - return Manifest{ - Key: key, - u: u, - } -} - -func (m Manifest) Duplicate(name string) Manifest { - u := m.u.DeepCopy() - u.SetName(name) - - key := m.Key - key.Name = name - - return Manifest{ - Key: key, - u: u, - } -} - -func (m Manifest) YamlBytes() ([]byte, error) { - return yaml.Marshal(m.u) -} - -func (m Manifest) MarshalJSON() ([]byte, error) { - return m.u.MarshalJSON() -} - -func (m Manifest) AddAnnotations(annotations map[string]string) { - if len(annotations) == 0 { - return - } - - annos := m.u.GetAnnotations() - if annos == nil { - m.u.SetAnnotations(annotations) - return - } - for k, v := range annotations { - annos[k] = v - } - m.u.SetAnnotations(annos) -} - -func (m Manifest) GetAnnotations() map[string]string { - return m.u.GetAnnotations() -} - -func (m Manifest) GetNestedStringMap(fields ...string) (map[string]string, error) { - sm, _, err := unstructured.NestedStringMap(m.u.Object, fields...) - if err != nil { - return nil, err - } - - return sm, nil -} - -func (m Manifest) GetNestedMap(fields ...string) (map[string]interface{}, error) { - sm, _, err := unstructured.NestedMap(m.u.Object, fields...) - if err != nil { - return nil, err - } - - return sm, nil -} - -// AddStringMapValues adds or overrides the given key-values into the string map -// that can be found at the specified fields. -func (m Manifest) AddStringMapValues(values map[string]string, fields ...string) error { - curMap, _, err := unstructured.NestedStringMap(m.u.Object, fields...) - if err != nil { - return err - } - - if curMap == nil { - return unstructured.SetNestedStringMap(m.u.Object, values, fields...) - } - for k, v := range values { - curMap[k] = v - } - return unstructured.SetNestedStringMap(m.u.Object, curMap, fields...) -} - -func (m Manifest) GetSpec() (interface{}, error) { - spec, ok, err := unstructured.NestedFieldNoCopy(m.u.Object, "spec") - if err != nil { - return nil, err - } - if !ok { - return nil, fmt.Errorf("spec was not found") - } - return spec, nil -} - -func (m Manifest) SetStructuredSpec(spec interface{}) error { - data, err := yaml.Marshal(spec) - if err != nil { - return err - } - - unstructuredSpec := make(map[string]interface{}) - if err := yaml.Unmarshal(data, &unstructuredSpec); err != nil { - return err - } - - return unstructured.SetNestedField(m.u.Object, unstructuredSpec, "spec") -} - -func (m Manifest) ConvertToStructuredObject(o interface{}) error { - data, err := m.MarshalJSON() - if err != nil { - return err - } - return json.Unmarshal(data, o) -} - -func ParseFromStructuredObject(s interface{}) (Manifest, error) { - data, err := json.Marshal(s) - if err != nil { - return Manifest{}, err - } - - obj := &unstructured.Unstructured{} - if err := obj.UnmarshalJSON(data); err != nil { - return Manifest{}, err - } - - return Manifest{ - Key: MakeResourceKey(obj), - u: obj, - }, nil -} - -func LoadPlainYAMLManifests(dir string, names []string, configFileName string) ([]Manifest, error) { - // If no name was specified we have to walk the app directory to collect the manifest list. - if len(names) == 0 { - err := filepath.Walk(dir, func(path string, f os.FileInfo, err error) error { - if err != nil { - return err - } - if path == dir { - return nil - } - if f.IsDir() { - return filepath.SkipDir - } - ext := filepath.Ext(f.Name()) - if ext != ".yaml" && ext != ".yml" && ext != ".json" { - return nil - } - if model.IsApplicationConfigFile(f.Name()) { - return nil - } - if f.Name() == configFileName { - return nil - } - names = append(names, f.Name()) - return nil - }) - if err != nil { - return nil, err - } - } - - manifests := make([]Manifest, 0, len(names)) - for _, name := range names { - path := filepath.Join(dir, name) - ms, err := LoadManifestsFromYAMLFile(path) - if err != nil { - return nil, fmt.Errorf("failed to load manifest at %s (%w)", path, err) - } - manifests = append(manifests, ms...) - } - - return manifests, nil -} - -func LoadManifestsFromYAMLFile(path string) ([]Manifest, error) { - data, err := os.ReadFile(path) - if err != nil { - return nil, err - } - return ParseManifests(string(data)) -} - -func ParseManifests(data string) ([]Manifest, error) { - const separator = "\n---" - var ( - parts = strings.Split(data, separator) - manifests = make([]Manifest, 0, len(parts)) - ) - - for i, part := range parts { - // Ignore all the cases where no content between separator. - if len(strings.TrimSpace(part)) == 0 { - continue - } - // Append new line which trim by document separator. - if i != len(parts)-1 { - part += "\n" - } - var obj unstructured.Unstructured - if err := yaml.Unmarshal([]byte(part), &obj); err != nil { - return nil, err - } - if len(obj.Object) == 0 { - continue - } - manifests = append(manifests, Manifest{ - Key: MakeResourceKey(&obj), - u: &obj, - }) - } - return manifests, nil -} diff --git a/pkg/app/pipedv1/plugin/kubernetes/provider/manifest_test.go b/pkg/app/pipedv1/plugin/kubernetes/provider/manifest_test.go deleted file mode 100644 index 7a6b39d4e7..0000000000 --- a/pkg/app/pipedv1/plugin/kubernetes/provider/manifest_test.go +++ /dev/null @@ -1,193 +0,0 @@ -// 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 provider - -import ( - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" -) - -func TestParseManifests(t *testing.T) { - maker := func(name, kind string, metadata map[string]interface{}) Manifest { - return Manifest{ - Key: ResourceKey{ - APIVersion: "v1", - Kind: kind, - Name: name, - Namespace: "default", - }, - u: &unstructured.Unstructured{ - Object: map[string]interface{}{ - "apiVersion": "v1", - "kind": kind, - "metadata": metadata, - }, - }, - } - } - - testcases := []struct { - name string - manifests string - want []Manifest - }{ - { - name: "empty1", - }, - { - name: "empty2", - manifests: "---", - }, - { - name: "empty3", - manifests: "\n---", - }, - { - name: "empty4", - manifests: "\n---\n", - }, - { - name: "multiple empty manifests", - manifests: "---\n---\n---\n---\n---\n", - }, - { - name: "one manifest", - manifests: `--- -apiVersion: v1 -kind: ConfigMap -metadata: - name: envoy-config - creationTimestamp: "2022-12-09T01:23:45Z" -`, - want: []Manifest{ - maker("envoy-config", "ConfigMap", map[string]interface{}{ - "name": "envoy-config", - "creationTimestamp": "2022-12-09T01:23:45Z", - }), - }, - }, - { - name: "contains new line at the end of file", - manifests: ` -apiVersion: v1 -kind: Kind1 -metadata: - name: config - extra: | - single-new-line -`, - want: []Manifest{ - maker("config", "Kind1", map[string]interface{}{ - "name": "config", - "extra": "single-new-line\n", - }), - }, - }, - { - name: "not contains new line at the end of file", - manifests: ` -apiVersion: v1 -kind: Kind1 -metadata: - name: config - extra: | - no-new-line`, - want: []Manifest{ - maker("config", "Kind1", map[string]interface{}{ - "name": "config", - "extra": "no-new-line", - }), - }, - }, - { - name: "multiple manifests", - manifests: ` -apiVersion: v1 -kind: Kind1 -metadata: - name: config1 - extra: |- - no-new-line ---- -apiVersion: v1 -kind: Kind2 -metadata: - name: config2 - extra: | - single-new-line-1 ---- -apiVersion: v1 -kind: Kind3 -metadata: - name: config3 - extra: | - single-new-line-2 - - ---- -apiVersion: v1 -kind: Kind4 -metadata: - name: config4 - extra: |+ - multiple-new-line-1 - - ---- -apiVersion: v1 -kind: Kind5 -metadata: - name: config5 - extra: |+ - multiple-new-line-2 - - -`, - want: []Manifest{ - maker("config1", "Kind1", map[string]interface{}{ - "name": "config1", - "extra": "no-new-line", - }), - maker("config2", "Kind2", map[string]interface{}{ - "name": "config2", - "extra": "single-new-line-1\n", - }), - maker("config3", "Kind3", map[string]interface{}{ - "name": "config3", - "extra": "single-new-line-2\n", - }), - maker("config4", "Kind4", map[string]interface{}{ - "name": "config4", - "extra": "multiple-new-line-1\n\n\n", - }), - maker("config5", "Kind5", map[string]interface{}{ - "name": "config5", - "extra": "multiple-new-line-2\n\n\n", - }), - }, - }, - } - - for _, tc := range testcases { - t.Run(tc.name, func(t *testing.T) { - m, err := ParseManifests(tc.manifests) - require.NoError(t, err) - assert.ElementsMatch(t, m, tc.want) - }) - } -} diff --git a/pkg/app/pipedv1/plugin/kubernetes/provider/resource/deployment.go b/pkg/app/pipedv1/plugin/kubernetes/provider/resource/deployment.go deleted file mode 100644 index 5a82fd77ec..0000000000 --- a/pkg/app/pipedv1/plugin/kubernetes/provider/resource/deployment.go +++ /dev/null @@ -1,24 +0,0 @@ -// 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 resource - -type Deployment struct { - Spec DeploymentSpec -} - -type DeploymentSpec struct { - Replicas int - Template PodTemplateSpec -} diff --git a/pkg/app/pipedv1/plugin/kubernetes/provider/resource/pod.go b/pkg/app/pipedv1/plugin/kubernetes/provider/resource/pod.go deleted file mode 100644 index 576bfad0d5..0000000000 --- a/pkg/app/pipedv1/plugin/kubernetes/provider/resource/pod.go +++ /dev/null @@ -1,57 +0,0 @@ -// 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 resource - -type PodTemplateSpec struct { - Spec PodSpec -} - -type PodSpec struct { - InitContainers []Container - Containers []Container - Volumes []Volume -} - -type Container struct { - Name string - Image string - VolumeMounts []VolumeMount -} - -type Volume struct { - Name string - VolumeSource `json:",inline"` -} - -type VolumeSource struct { - Secret *SecretVolumeSource - ConfigMap *ConfigMapVolumeSource -} - -type SecretVolumeSource struct { - SecretName string -} - -type LocalObjectReference struct { - Name string -} - -type ConfigMapVolumeSource struct { - LocalObjectReference `json:",inline"` -} - -type VolumeMount struct { - Name string -} diff --git a/pkg/app/pipedv1/plugin/kubernetes/provider/resource/statefulset.go b/pkg/app/pipedv1/plugin/kubernetes/provider/resource/statefulset.go deleted file mode 100644 index acdf8dfed4..0000000000 --- a/pkg/app/pipedv1/plugin/kubernetes/provider/resource/statefulset.go +++ /dev/null @@ -1,24 +0,0 @@ -// 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 resource - -type StatefulSet struct { - Spec DeploymentSpec -} - -type StatefulSetSpec struct { - Replicas int - Template PodTemplateSpec -} diff --git a/pkg/app/pipedv1/plugin/kubernetes/provider/resourcekey.go b/pkg/app/pipedv1/plugin/kubernetes/provider/resourcekey.go deleted file mode 100644 index 5e86911cd3..0000000000 --- a/pkg/app/pipedv1/plugin/kubernetes/provider/resourcekey.go +++ /dev/null @@ -1,280 +0,0 @@ -// 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 provider - -import ( - "fmt" - "strings" - - "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" -) - -var builtInAPIVersions = map[string]struct{}{ - "admissionregistration.k8s.io/v1": {}, - "admissionregistration.k8s.io/v1beta1": {}, - "apiextensions.k8s.io/v1": {}, - "apiextensions.k8s.io/v1beta1": {}, - "apiregistration.k8s.io/v1": {}, - "apiregistration.k8s.io/v1beta1": {}, - "apps/v1": {}, - "authentication.k8s.io/v1": {}, - "authentication.k8s.io/v1beta1": {}, - "authorization.k8s.io/v1": {}, - "authorization.k8s.io/v1beta1": {}, - "autoscaling/v1": {}, - "autoscaling/v2beta1": {}, - "autoscaling/v2beta2": {}, - "batch/v1": {}, - "batch/v1beta1": {}, - "certificates.k8s.io/v1beta1": {}, - "coordination.k8s.io/v1": {}, - "coordination.k8s.io/v1beta1": {}, - "extensions/v1beta1": {}, - "internal.autoscaling.k8s.io/v1alpha1": {}, - "metrics.k8s.io/v1beta1": {}, - "networking.k8s.io/v1": {}, - "networking.k8s.io/v1beta1": {}, - "node.k8s.io/v1beta1": {}, - "policy/v1": {}, - "policy/v1beta1": {}, - "rbac.authorization.k8s.io/v1": {}, - "rbac.authorization.k8s.io/v1beta1": {}, - "scheduling.k8s.io/v1": {}, - "scheduling.k8s.io/v1beta1": {}, - "storage.k8s.io/v1": {}, - "storage.k8s.io/v1beta1": {}, - "v1": {}, -} - -const ( - KindDeployment = "Deployment" - KindStatefulSet = "StatefulSet" - KindDaemonSet = "DaemonSet" - KindReplicaSet = "ReplicaSet" - KindPod = "Pod" - KindJob = "Job" - KindCronJob = "CronJob" - KindConfigMap = "ConfigMap" - KindSecret = "Secret" - KindPersistentVolume = "PersistentVolume" - KindPersistentVolumeClaim = "PersistentVolumeClaim" - KindService = "Service" - KindIngress = "Ingress" - KindServiceAccount = "ServiceAccount" - KindRole = "Role" - KindRoleBinding = "RoleBinding" - KindClusterRole = "ClusterRole" - KindClusterRoleBinding = "ClusterRoleBinding" - KindNameSpace = "NameSpace" - KindPodDisruptionBudget = "PodDisruptionBudget" - KindCustomResourceDefinition = "CustomResourceDefinition" - - DefaultNamespace = "default" -) - -type APIVersionKind struct { - APIVersion string - Kind string -} - -type ResourceKey struct { - APIVersion string - Kind string - Namespace string - Name string -} - -func (k ResourceKey) String() string { - return fmt.Sprintf("%s:%s:%s:%s", k.APIVersion, k.Kind, k.Namespace, k.Name) -} - -func (k ResourceKey) ReadableString() string { - return fmt.Sprintf("name=%q, kind=%q, namespace=%q, apiVersion=%q", k.Name, k.Kind, k.Namespace, k.APIVersion) -} - -func (k ResourceKey) IsZero() bool { - return k.APIVersion == "" && - k.Kind == "" && - k.Namespace == "" && - k.Name == "" -} - -func (k ResourceKey) IsDeployment() bool { - if k.Kind != KindDeployment { - return false - } - if !IsKubernetesBuiltInResource(k.APIVersion) { - return false - } - return true -} - -func (k ResourceKey) IsReplicaSet() bool { - if k.Kind != KindReplicaSet { - return false - } - if !IsKubernetesBuiltInResource(k.APIVersion) { - return false - } - return true -} - -func (k ResourceKey) IsWorkload() bool { - if !IsKubernetesBuiltInResource(k.APIVersion) { - return false - } - - switch k.Kind { - case KindDeployment: - return true - case KindReplicaSet: - return true - case KindDaemonSet: - return true - case KindPod: - return true - } - - return false -} - -func (k ResourceKey) IsService() bool { - if k.Kind != KindService { - return false - } - if !IsKubernetesBuiltInResource(k.APIVersion) { - return false - } - return true -} - -func (k ResourceKey) IsConfigMap() bool { - if k.Kind != KindConfigMap { - return false - } - if !IsKubernetesBuiltInResource(k.APIVersion) { - return false - } - return true -} - -func (k ResourceKey) IsSecret() bool { - if k.Kind != KindSecret { - return false - } - if !IsKubernetesBuiltInResource(k.APIVersion) { - return false - } - return true -} - -// IsLess reports whether the key should sort before the given key. -func (k ResourceKey) IsLess(a ResourceKey) bool { - if k.APIVersion < a.APIVersion { - return true - } else if k.APIVersion > a.APIVersion { - return false - } - - if k.Kind < a.Kind { - return true - } else if k.Kind > a.Kind { - return false - } - - if k.Namespace < a.Namespace { - return true - } else if k.Namespace > a.Namespace { - return false - } - - if k.Name < a.Name { - return true - } else if k.Name > a.Name { - return false - } - return false -} - -// IsLessWithIgnoringNamespace reports whether the key should sort before the given key, -// but this ignores the comparation of the namesapce. -func (k ResourceKey) IsLessWithIgnoringNamespace(a ResourceKey) bool { - if k.APIVersion < a.APIVersion { - return true - } else if k.APIVersion > a.APIVersion { - return false - } - - if k.Kind < a.Kind { - return true - } else if k.Kind > a.Kind { - return false - } - - if k.Name < a.Name { - return true - } else if k.Name > a.Name { - return false - } - return false -} - -// IsEqualWithIgnoringNamespace checks whether the key is equal to the given key, -// but this ignores the comparation of the namesapce. -func (k ResourceKey) IsEqualWithIgnoringNamespace(a ResourceKey) bool { - if k.APIVersion != a.APIVersion { - return false - } - if k.Kind != a.Kind { - return false - } - if k.Name != a.Name { - return false - } - return true -} - -func MakeResourceKey(obj *unstructured.Unstructured) ResourceKey { - k := ResourceKey{ - APIVersion: obj.GetAPIVersion(), - Kind: obj.GetKind(), - Namespace: obj.GetNamespace(), - Name: obj.GetName(), - } - if k.Namespace == "" { - k.Namespace = DefaultNamespace - } - return k -} - -func DecodeResourceKey(key string) (ResourceKey, error) { - parts := strings.Split(key, ":") - if len(parts) != 4 { - return ResourceKey{}, fmt.Errorf("malformed key") - } - return ResourceKey{ - APIVersion: parts[0], - Kind: parts[1], - Namespace: parts[2], - Name: parts[3], - }, nil -} - -func IsKubernetesBuiltInResource(apiVersion string) bool { - _, ok := builtInAPIVersions[apiVersion] - // TODO: Change the way to detect whether an APIVersion is built-in or not - // rather than depending on this fixed list. - return ok -} diff --git a/pkg/app/pipedv1/plugin/kubernetes/provider/state.go b/pkg/app/pipedv1/plugin/kubernetes/provider/state.go deleted file mode 100644 index 2e2b7c7e4b..0000000000 --- a/pkg/app/pipedv1/plugin/kubernetes/provider/state.go +++ /dev/null @@ -1,572 +0,0 @@ -// 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 provider - -import ( - "fmt" - "sort" - "strings" - "time" - - appsv1 "k8s.io/api/apps/v1" - batchv1 "k8s.io/api/batch/v1" - corev1 "k8s.io/api/core/v1" - networkingv1 "k8s.io/api/networking/v1" - "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" - "k8s.io/client-go/kubernetes/scheme" - - "github.com/pipe-cd/pipecd/pkg/model" -) - -func MakeKubernetesResourceState(uid string, key ResourceKey, obj *unstructured.Unstructured, now time.Time) model.KubernetesResourceState { - var ( - owners = obj.GetOwnerReferences() - ownerIDs = make([]string, 0, len(owners)) - creationTime = obj.GetCreationTimestamp() - status, desc = determineResourceHealth(key, obj) - ) - - for _, owner := range owners { - ownerIDs = append(ownerIDs, string(owner.UID)) - } - sort.Strings(ownerIDs) - - state := model.KubernetesResourceState{ - Id: uid, - OwnerIds: ownerIDs, - // TODO: Think about adding more parents by using label selectors - ParentIds: ownerIDs, - Name: key.Name, - ApiVersion: key.APIVersion, - Kind: key.Kind, - Namespace: obj.GetNamespace(), - - HealthStatus: status, - HealthDescription: desc, - - CreatedAt: creationTime.Unix(), - UpdatedAt: now.Unix(), - } - - return state -} - -func determineResourceHealth(key ResourceKey, obj *unstructured.Unstructured) (status model.KubernetesResourceState_HealthStatus, desc string) { - if !IsKubernetesBuiltInResource(key.APIVersion) { - desc = fmt.Sprintf("\"%s/%s\" was applied successfully but its health status couldn't be determined exactly. (Because tracking status for this kind of resource is not supported yet.)", key.APIVersion, key.Kind) - return - } - - switch key.Kind { - case KindDeployment: - return determineDeploymentHealth(obj) - case KindStatefulSet: - return determineStatefulSetHealth(obj) - case KindDaemonSet: - return determineDaemonSetHealth(obj) - case KindReplicaSet: - return determineReplicaSetHealth(obj) - case KindPod: - return determinePodHealth(obj) - case KindJob: - return determineJobHealth(obj) - case KindCronJob: - return determineCronJobHealth(obj) - case KindService: - return determineServiceHealth(obj) - case KindIngress: - return determineIngressHealth(obj) - case KindConfigMap: - return determineConfigMapHealth(obj) - case KindPersistentVolume: - return determinePersistentVolumeHealth(obj) - case KindPersistentVolumeClaim: - return determinePVCHealth(obj) - case KindSecret: - return determineSecretHealth(obj) - case KindServiceAccount: - return determineServiceAccountHealth(obj) - case KindRole: - return determineRoleHealth(obj) - case KindRoleBinding: - return determineRoleBindingHealth(obj) - case KindClusterRole: - return determineClusterRoleHealth(obj) - case KindClusterRoleBinding: - return determineClusterRoleBindingHealth(obj) - case KindNameSpace: - return determineNameSpace(obj) - case KindPodDisruptionBudget: - return determinePodDisruptionBudgetHealth(obj) - default: - desc = "Unimplemented or unknown resource" - return - } -} - -func determineRoleHealth(obj *unstructured.Unstructured) (status model.KubernetesResourceState_HealthStatus, desc string) { - desc = fmt.Sprintf("%q was applied successfully", obj.GetName()) - status = model.KubernetesResourceState_HEALTHY - return -} - -func determineRoleBindingHealth(obj *unstructured.Unstructured) (status model.KubernetesResourceState_HealthStatus, desc string) { - desc = fmt.Sprintf("%q was applied successfully", obj.GetName()) - status = model.KubernetesResourceState_HEALTHY - return -} - -func determineClusterRoleHealth(obj *unstructured.Unstructured) (status model.KubernetesResourceState_HealthStatus, desc string) { - desc = fmt.Sprintf("%q was applied successfully", obj.GetName()) - status = model.KubernetesResourceState_HEALTHY - return -} - -func determineClusterRoleBindingHealth(obj *unstructured.Unstructured) (status model.KubernetesResourceState_HealthStatus, desc string) { - desc = fmt.Sprintf("%q was applied successfully", obj.GetName()) - status = model.KubernetesResourceState_HEALTHY - return -} - -func determineDeploymentHealth(obj *unstructured.Unstructured) (status model.KubernetesResourceState_HealthStatus, desc string) { - d := &appsv1.Deployment{} - err := scheme.Scheme.Convert(obj, d, nil) - if err != nil { - status = model.KubernetesResourceState_OTHER - desc = fmt.Sprintf("Unexpected error while calculating: unable to convert %T to %T: %v", obj, d, err) - return - } - - status = model.KubernetesResourceState_OTHER - if d.Spec.Paused { - desc = "Deployment is paused" - return - } - - // Referred to: - // https://github.com/kubernetes/kubernetes/blob/7942dca975b7be9386540df3c17e309c3cb2de60/staging/src/k8s.io/kubectl/pkg/polymorphichelpers/rollout_status.go#L75 - if d.Generation > d.Status.ObservedGeneration { - desc = "Waiting for rollout to finish because observed deployment generation less than desired generation" - return - } - // TimedOutReason is added in a deployment when its newest replica set fails to show any progress - // within the given deadline (progressDeadlineSeconds). - const timedOutReason = "ProgressDeadlineExceeded" - var cond *appsv1.DeploymentCondition - for i := range d.Status.Conditions { - c := d.Status.Conditions[i] - if c.Type == appsv1.DeploymentProgressing { - cond = &c - break - } - } - if cond != nil && cond.Reason == timedOutReason { - desc = fmt.Sprintf("Deployment %q exceeded its progress deadline", obj.GetName()) - } - - if d.Spec.Replicas == nil { - desc = "The number of desired replicas is unspecified" - return - } - if d.Status.UpdatedReplicas < *d.Spec.Replicas { - desc = fmt.Sprintf("Waiting for remaining %d/%d replicas to be updated", d.Status.UpdatedReplicas, *d.Spec.Replicas) - return - } - if d.Status.UpdatedReplicas < d.Status.Replicas { - desc = fmt.Sprintf("%d old replicas are pending termination", d.Status.Replicas-d.Status.UpdatedReplicas) - return - } - if d.Status.AvailableReplicas < d.Status.Replicas { - desc = fmt.Sprintf("Waiting for remaining %d/%d replicas to be available", d.Status.Replicas-d.Status.AvailableReplicas, d.Status.Replicas) - return - } - - status = model.KubernetesResourceState_HEALTHY - return -} - -func determineStatefulSetHealth(obj *unstructured.Unstructured) (status model.KubernetesResourceState_HealthStatus, desc string) { - s := &appsv1.StatefulSet{} - err := scheme.Scheme.Convert(obj, s, nil) - if err != nil { - status = model.KubernetesResourceState_OTHER - desc = fmt.Sprintf("Unexpected error while calculating: unable to convert %T to %T: %v", obj, s, err) - return - } - - // Referred to: - // https://github.com/kubernetes/kubernetes/blob/7942dca975b7be9386540df3c17e309c3cb2de60/staging/src/k8s.io/kubectl/pkg/polymorphichelpers/rollout_status.go#L130-L149 - status = model.KubernetesResourceState_OTHER - if s.Status.ObservedGeneration == 0 || s.Generation > s.Status.ObservedGeneration { - desc = "Waiting for statefulset spec update to be observed" - return - } - - if s.Spec.Replicas == nil { - desc = "The number of desired replicas is unspecified" - return - } - if *s.Spec.Replicas != s.Status.ReadyReplicas { - desc = fmt.Sprintf("The number of ready replicas (%d) is different from the desired number (%d)", s.Status.ReadyReplicas, *s.Spec.Replicas) - return - } - - // Check if the partitioned roll out is in progress. - if s.Spec.UpdateStrategy.Type == appsv1.RollingUpdateStatefulSetStrategyType && s.Spec.UpdateStrategy.RollingUpdate != nil { - if s.Spec.Replicas != nil && s.Spec.UpdateStrategy.RollingUpdate.Partition != nil { - if s.Status.UpdatedReplicas < (*s.Spec.Replicas - *s.Spec.UpdateStrategy.RollingUpdate.Partition) { - desc = fmt.Sprintf("Waiting for partitioned roll out to finish because %d out of %d new pods have been updated", - s.Status.UpdatedReplicas, (*s.Spec.Replicas - *s.Spec.UpdateStrategy.RollingUpdate.Partition)) - return - } - } - status = model.KubernetesResourceState_HEALTHY - return - } - - if s.Status.UpdateRevision != s.Status.CurrentRevision { - desc = fmt.Sprintf("Waiting for statefulset rolling update to complete %d pods at revision %s", s.Status.UpdatedReplicas, s.Status.UpdateRevision) - return - } - - status = model.KubernetesResourceState_HEALTHY - return -} - -func determineDaemonSetHealth(obj *unstructured.Unstructured) (status model.KubernetesResourceState_HealthStatus, desc string) { - d := &appsv1.DaemonSet{} - err := scheme.Scheme.Convert(obj, d, nil) - if err != nil { - status = model.KubernetesResourceState_OTHER - desc = fmt.Sprintf("Unexpected error while calculating: unable to convert %T to %T: %v", obj, d, err) - return - } - - // Referred to: - // https://github.com/kubernetes/kubernetes/blob/7942dca975b7be9386540df3c17e309c3cb2de60/staging/src/k8s.io/kubectl/pkg/polymorphichelpers/rollout_status.go#L107-L115 - status = model.KubernetesResourceState_OTHER - if d.Status.ObservedGeneration == 0 || d.Generation > d.Status.ObservedGeneration { - desc = "Waiting for rollout to finish because observed daemon set generation less than desired generation" - return - } - if d.Status.UpdatedNumberScheduled < d.Status.DesiredNumberScheduled { - desc = fmt.Sprintf("Waiting for daemon set %q rollout to finish because %d out of %d new pods have been updated", d.Name, d.Status.UpdatedNumberScheduled, d.Status.DesiredNumberScheduled) - return - } - if d.Status.NumberAvailable < d.Status.DesiredNumberScheduled { - desc = fmt.Sprintf("Waiting for daemon set %q rollout to finish because %d of %d updated pods are available", d.Name, d.Status.NumberAvailable, d.Status.DesiredNumberScheduled) - return - } - - if d.Status.NumberMisscheduled > 0 { - desc = fmt.Sprintf("%d nodes that are running the daemon pod, but are not supposed to run the daemon pod", d.Status.NumberMisscheduled) - return - } - if d.Status.NumberUnavailable > 0 { - desc = fmt.Sprintf("%d nodes that should be running the daemon pod and have none of the daemon pod running and available", d.Status.NumberUnavailable) - return - } - - status = model.KubernetesResourceState_HEALTHY - return -} - -func determineReplicaSetHealth(obj *unstructured.Unstructured) (status model.KubernetesResourceState_HealthStatus, desc string) { - r := &appsv1.ReplicaSet{} - err := scheme.Scheme.Convert(obj, r, nil) - if err != nil { - status = model.KubernetesResourceState_OTHER - desc = fmt.Sprintf("Unexpected error while calculating: unable to convert %T to %T: %v", obj, r, err) - return - } - - status = model.KubernetesResourceState_OTHER - if r.Status.ObservedGeneration == 0 || r.Generation > r.Status.ObservedGeneration { - desc = "Waiting for rollout to finish because observed replica set generation less than desired generation" - return - } - - var cond *appsv1.ReplicaSetCondition - for i := range r.Status.Conditions { - c := r.Status.Conditions[i] - if c.Type == appsv1.ReplicaSetReplicaFailure { - cond = &c - break - } - } - switch { - case cond != nil && cond.Status == corev1.ConditionTrue: - desc = cond.Message - return - case r.Spec.Replicas == nil: - desc = "The number of desired replicas is unspecified" - return - case r.Status.AvailableReplicas < *r.Spec.Replicas: - desc = fmt.Sprintf("Waiting for rollout to finish because only %d/%d replicas are available", r.Status.AvailableReplicas, *r.Spec.Replicas) - return - case *r.Spec.Replicas != r.Status.ReadyReplicas: - desc = fmt.Sprintf("The number of ready replicas (%d) is different from the desired number (%d)", r.Status.ReadyReplicas, *r.Spec.Replicas) - return - } - - status = model.KubernetesResourceState_HEALTHY - return -} - -func determineCronJobHealth(obj *unstructured.Unstructured) (status model.KubernetesResourceState_HealthStatus, desc string) { - desc = fmt.Sprintf("%q was applied successfully", obj.GetName()) - status = model.KubernetesResourceState_HEALTHY - return -} - -func determineJobHealth(obj *unstructured.Unstructured) (status model.KubernetesResourceState_HealthStatus, desc string) { - job := &batchv1.Job{} - err := scheme.Scheme.Convert(obj, job, nil) - if err != nil { - status = model.KubernetesResourceState_OTHER - desc = fmt.Sprintf("Unexpected error while calculating: unable to convert %T to %T: %v", obj, job, err) - return - } - - var ( - failed bool - completed bool - message string - ) - for _, condition := range job.Status.Conditions { - switch condition.Type { - case batchv1.JobFailed: - failed = true - completed = true - message = condition.Message - case batchv1.JobComplete: - completed = true - message = condition.Message - } - if failed { - break - } - } - - switch { - case !completed: - status = model.KubernetesResourceState_HEALTHY - desc = "Job is in progress" - case failed: - status = model.KubernetesResourceState_OTHER - desc = message - default: - status = model.KubernetesResourceState_HEALTHY - desc = message - } - - return -} - -func determinePodHealth(obj *unstructured.Unstructured) (status model.KubernetesResourceState_HealthStatus, desc string) { - p := &corev1.Pod{} - err := scheme.Scheme.Convert(obj, p, nil) - if err != nil { - status = model.KubernetesResourceState_OTHER - desc = fmt.Sprintf("Unexpected error while calculating: unable to convert %T to %T: %v", obj, p, err) - return - } - - // Determine based on its container statuses. - if p.Spec.RestartPolicy == corev1.RestartPolicyAlways { - var messages []string - for _, s := range p.Status.ContainerStatuses { - waiting := s.State.Waiting - if waiting == nil { - continue - } - if strings.HasPrefix(waiting.Reason, "Err") || strings.HasSuffix(waiting.Reason, "Error") || strings.HasSuffix(waiting.Reason, "BackOff") { - status = model.KubernetesResourceState_OTHER - messages = append(messages, waiting.Message) - } - } - - if status == model.KubernetesResourceState_OTHER { - desc = strings.Join(messages, ", ") - return - } - } - - // Determine based on its phase. - switch p.Status.Phase { - case corev1.PodRunning, corev1.PodSucceeded: - status = model.KubernetesResourceState_HEALTHY - desc = p.Status.Message - default: - status = model.KubernetesResourceState_OTHER - desc = p.Status.Message - } - return -} - -func determineIngressHealth(obj *unstructured.Unstructured) (status model.KubernetesResourceState_HealthStatus, desc string) { - check := func(ingressList []corev1.LoadBalancerIngress) { - if len(ingressList) == 0 { - status = model.KubernetesResourceState_OTHER - desc = "Ingress points for the load-balancer are in progress" - return - } - status = model.KubernetesResourceState_HEALTHY - } - - v1Ingress := &networkingv1.Ingress{} - err := scheme.Scheme.Convert(obj, v1Ingress, nil) - if err == nil { - check(v1Ingress.Status.LoadBalancer.Ingress) - return - } - - status = model.KubernetesResourceState_OTHER - desc = fmt.Sprintf("Unexpected error while calculating: unable to convert %T to %T: %v", obj, v1Ingress, err) - return -} - -func determineServiceHealth(obj *unstructured.Unstructured) (status model.KubernetesResourceState_HealthStatus, desc string) { - s := &corev1.Service{} - err := scheme.Scheme.Convert(obj, s, nil) - if err != nil { - status = model.KubernetesResourceState_OTHER - desc = fmt.Sprintf("Unexpected error while calculating: unable to convert %T to %T: %v", obj, s, err) - return - } - - status = model.KubernetesResourceState_HEALTHY - if s.Spec.Type != corev1.ServiceTypeLoadBalancer { - return - } - if len(s.Status.LoadBalancer.Ingress) == 0 { - status = model.KubernetesResourceState_OTHER - desc = "Ingress points for the load-balancer are in progress" - return - } - return -} - -func determineConfigMapHealth(obj *unstructured.Unstructured) (status model.KubernetesResourceState_HealthStatus, desc string) { - desc = fmt.Sprintf("%q was applied successfully", obj.GetName()) - status = model.KubernetesResourceState_HEALTHY - return -} - -func determineSecretHealth(obj *unstructured.Unstructured) (status model.KubernetesResourceState_HealthStatus, desc string) { - desc = fmt.Sprintf("%q was applied successfully", obj.GetName()) - status = model.KubernetesResourceState_HEALTHY - return -} - -func determinePersistentVolumeHealth(obj *unstructured.Unstructured) (status model.KubernetesResourceState_HealthStatus, desc string) { - pv := &corev1.PersistentVolume{} - err := scheme.Scheme.Convert(obj, pv, nil) - if err != nil { - status = model.KubernetesResourceState_OTHER - desc = fmt.Sprintf("Unexpected error while calculating: unable to convert %T to %T: %v", obj, pv, err) - return - } - - switch pv.Status.Phase { - case corev1.VolumeBound, corev1.VolumeAvailable: - status = model.KubernetesResourceState_HEALTHY - desc = pv.Status.Message - default: - status = model.KubernetesResourceState_OTHER - desc = pv.Status.Message - } - return -} - -func determinePVCHealth(obj *unstructured.Unstructured) (status model.KubernetesResourceState_HealthStatus, desc string) { - pvc := &corev1.PersistentVolumeClaim{} - err := scheme.Scheme.Convert(obj, pvc, nil) - if err != nil { - status = model.KubernetesResourceState_OTHER - desc = fmt.Sprintf("Unexpected error while calculating: unable to convert %T to %T: %v", obj, pvc, err) - return - } - switch pvc.Status.Phase { - case corev1.ClaimLost: - status = model.KubernetesResourceState_OTHER - desc = "Lost its underlying PersistentVolume" - case corev1.ClaimPending: - status = model.KubernetesResourceState_OTHER - desc = "Being not yet bound" - case corev1.ClaimBound: - status = model.KubernetesResourceState_HEALTHY - default: - status = model.KubernetesResourceState_OTHER - desc = "The current phase of PersistentVolumeClaim is unexpected" - } - return -} - -func determineServiceAccountHealth(obj *unstructured.Unstructured) (status model.KubernetesResourceState_HealthStatus, desc string) { - desc = fmt.Sprintf("%q was applied successfully", obj.GetName()) - status = model.KubernetesResourceState_HEALTHY - return -} - -func determinePodDisruptionBudgetHealth(obj *unstructured.Unstructured) (status model.KubernetesResourceState_HealthStatus, desc string) { - desc = fmt.Sprintf("%q was applied successfully", obj.GetName()) - status = model.KubernetesResourceState_HEALTHY - return -} - -func determineNameSpace(obj *unstructured.Unstructured) (status model.KubernetesResourceState_HealthStatus, desc string) { - ns := &corev1.Namespace{} - err := scheme.Scheme.Convert(obj, ns, nil) - if err != nil { - status = model.KubernetesResourceState_OTHER - desc = fmt.Sprintf("Unexpected error while calculating: unable to convert %T to %T: %v", obj, ns, err) - return - } - - switch ns.Status.Phase { - case corev1.NamespaceActive: - // Go to determine based on the status' conditions. - case corev1.NamespaceTerminating: - status = model.KubernetesResourceState_OTHER - desc = "NameSpace is gracefully terminated" - return - default: - status = model.KubernetesResourceState_OTHER - desc = fmt.Sprintf("The NameSpace is at an unexpected phase: %s", ns.Status.Phase) - return - } - - status = model.KubernetesResourceState_HEALTHY - - var cond *corev1.NamespaceCondition - for i := range ns.Status.Conditions { - c := ns.Status.Conditions[i] - switch c.Type { - case corev1.NamespaceDeletionDiscoveryFailure, corev1.NamespaceDeletionContentFailure, corev1.NamespaceDeletionGVParsingFailure: - cond = &c - } - if cond != nil { - break - } - } - - if cond != nil && cond.Status == corev1.ConditionTrue { - status = model.KubernetesResourceState_OTHER - desc = cond.Message - return - } - return -} diff --git a/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/diff_by_command.yaml b/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/diff_by_command.yaml deleted file mode 100644 index 645055e62d..0000000000 --- a/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/diff_by_command.yaml +++ /dev/null @@ -1,69 +0,0 @@ -apiVersion: apps/v1 -kind: Deployment -metadata: - name: simple - labels: - app: simple - pipecd.dev/managed-by: piped -spec: - replicas: 2 - selector: - matchLabels: - app: simple - template: - metadata: - labels: - app: simple - spec: - containers: - - name: first - image: gcr.io/pipecd/first:v1.0.0 - args: - - a - - b - - c - ports: - - containerPort: 9085 - - name: second - image: gcr.io/pipecd/second:v1.0.0 - args: - - xx - - yy - - zz - ports: - - containerPort: 9085 ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: simple - labels: - app: simple - pipecd.dev/managed-by: piped -spec: - replicas: 3 - selector: - matchLabels: - app: simple - template: - metadata: - labels: - app: simple - spec: - containers: - - name: first - image: gcr.io/pipecd/first:v1.0.0 - args: - - a - - d - - b - - c - ports: - - containerPort: 9085 - - name: second - image: gcr.io/pipecd/second:v1.0.0 - args: - - xx - - zz - ports: - - containerPort: 9085 diff --git a/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/diff_by_command_no_change.yaml b/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/diff_by_command_no_change.yaml deleted file mode 100644 index e5462ba31b..0000000000 --- a/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/diff_by_command_no_change.yaml +++ /dev/null @@ -1,51 +0,0 @@ -apiVersion: apps/v1 -kind: Deployment -metadata: - name: simple - labels: - app: simple - pipecd.dev/managed-by: piped -spec: - replicas: 2 - selector: - matchLabels: - app: simple - template: - metadata: - labels: - app: simple - spec: - containers: - - name: helloworld - image: gcr.io/pipecd/helloworld:v1.0.0 - args: - - hi - - hello - ports: - - containerPort: 9085 ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: simple - labels: - app: simple - pipecd.dev/managed-by: piped -spec: - replicas: 2 - selector: - matchLabels: - app: simple - template: - metadata: - labels: - app: simple - spec: - containers: - - name: helloworld - image: gcr.io/pipecd/helloworld:v1.0.0 - args: - - hi - - hello - ports: - - containerPort: 9085 diff --git a/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/diff_ignore_missing_fields.yaml b/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/diff_ignore_missing_fields.yaml deleted file mode 100644 index 9edc380f09..0000000000 --- a/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/diff_ignore_missing_fields.yaml +++ /dev/null @@ -1,101 +0,0 @@ -apiVersion: apps/v1 -kind: Deployment -metadata: - name: canary - labels: - app: canary -spec: - replicas: 2 - selector: - matchLabels: - app: canary - template: - metadata: - labels: - app: canary - spec: - containers: - - name: helloworld - image: gcr.io/kapetanios/pipecd-helloworld:v0.0.2-159-g2fde42c - args: - - server - ports: - - containerPort: 9085 ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - annotations: - deployment.kubernetes.io/revision: "1" - kubectl.kubernetes.io/last-applied-configuration: | - {"apiVersion":"apps/v1","kind":"Deployment","metadata":{"annotations":{"pipecd.dev/application":"7230d36c-dceb-4037-b3c8-94abc57b2eda","pipecd.dev/commit-hash":"ef981187e5817c589617a114d5d5ae36adfbb373","pipecd.dev/managed-by":"piped","pipecd.dev/original-api-version":"apps/v1","pipecd.dev/piped":"70feaff4-a6b7-4d03-b5a9-26b2cbabf77b","pipecd.dev/resource-key":"apps/v1:Deployment:default:canary","pipecd.dev/variant":"primary"},"labels":{"app":"canary"},"name":"canary","namespace":"default"},"spec":{"replicas":2,"selector":{"matchLabels":{"app":"canary"}},"template":{"metadata":{"labels":{"app":"canary"}},"spec":{"containers":[{"args":["server"],"image":"gcr.io/kapetanios/pipecd-helloworld:v0.0.2-159-g2fde42c","name":"helloworld","ports":[{"containerPort":9085}]}]}}}} - pipecd.dev/application: 7230d36c-dceb-4037-b3c8-94abc57b2eda - pipecd.dev/commit-hash: ef981187e5817c589617a114d5d5ae36adfbb373 - pipecd.dev/managed-by: piped - pipecd.dev/original-api-version: apps/v1 - pipecd.dev/piped: 70feaff4-a6b7-4d03-b5a9-26b2cbabf77b - pipecd.dev/resource-key: apps/v1:Deployment:default:canary - pipecd.dev/variant: primary - creationTimestamp: "2020-06-18T14:23:30Z" - generation: 2 - labels: - app: canary - name: canary - namespace: default - resourceVersion: "3713438" - selfLink: /apis/apps/v1/namespaces/default/deployments/canary - uid: 00e655f8-0c27-477e-9178-97dab0d91316 -spec: - progressDeadlineSeconds: 600 - replicas: 2 - revisionHistoryLimit: 10 - selector: - matchLabels: - app: canary - strategy: - rollingUpdate: - maxSurge: 25% - maxUnavailable: 25% - type: RollingUpdate - template: - metadata: - creationTimestamp: null - labels: - app: canary - spec: - containers: - - args: - - server - image: gcr.io/kapetanios/pipecd-helloworld:v0.0.2-159-g2fde42c - imagePullPolicy: IfNotPresent - name: helloworld - ports: - - containerPort: 9085 - protocol: TCP - resources: {} - terminationMessagePath: /dev/termination-log - terminationMessagePolicy: File - dnsPolicy: ClusterFirst - restartPolicy: Always - schedulerName: default-scheduler - securityContext: {} - terminationGracePeriodSeconds: 30 -status: - availableReplicas: 2 - conditions: - - lastTransitionTime: "2020-06-18T14:23:31Z" - lastUpdateTime: "2020-06-18T14:23:31Z" - message: Deployment has minimum availability. - reason: MinimumReplicasAvailable - status: "True" - type: Available - - lastTransitionTime: "2020-06-18T14:23:30Z" - lastUpdateTime: "2020-06-18T14:23:31Z" - message: ReplicaSet "canary-78d4c97d9c" has successfully progressed. - reason: NewReplicaSetAvailable - status: "True" - type: Progressing - observedGeneration: 2 - readyReplicas: 2 - replicas: 2 - updatedReplicas: 2 \ No newline at end of file diff --git a/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/diff_ignore_order.yaml b/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/diff_ignore_order.yaml deleted file mode 100644 index 82c4487051..0000000000 --- a/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/diff_ignore_order.yaml +++ /dev/null @@ -1,51 +0,0 @@ -apiVersion: apps/v1 -kind: Deployment -metadata: - name: simple - labels: - app: simple - pipecd.dev/managed-by: piped -spec: - replicas: 2 - selector: - matchLabels: - app: simple - template: - metadata: - labels: - app: simple - spec: - containers: - - name: helloworld - image: gcr.io/pipecd/helloworld:v1.0.0 - args: - - hello - - hi - ports: - - containerPort: 9085 ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: simple - labels: - pipecd.dev/managed-by: piped - app: simple -spec: - replicas: 2 - selector: - matchLabels: - app: simple - template: - metadata: - labels: - app: simple - spec: - containers: - - name: helloworld - image: gcr.io/pipecd/helloworld:v1.0.0 - args: - - hi - - hello - ports: - - containerPort: 9085 diff --git a/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/diff_multi_diffs.yaml b/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/diff_multi_diffs.yaml deleted file mode 100644 index ce4f073fde..0000000000 --- a/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/diff_multi_diffs.yaml +++ /dev/null @@ -1,53 +0,0 @@ -apiVersion: apps/v1 -kind: Deployment -metadata: - name: simple - labels: - app: simple - pipecd.dev/managed-by: piped - change: first -spec: - replicas: 2 - selector: - matchLabels: - app: simple - template: - metadata: - labels: - app: simple - spec: - containers: - - name: helloworld - image: gcr.io/pipecd/helloworld:v1.0.0 - args: - - hi - - hello - ports: - - containerPort: 9085 ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: simple - labels: - pipecd.dev/managed-by: piped - app: simple - change: second -spec: - replicas: 2 - selector: - matchLabels: - app: simple - template: - metadata: - labels: - app: simple - spec: - containers: - - name: helloworld - image: gcr.io/pipecd/helloworld:v2.0.0 - args: - - hi - - hello - ports: - - containerPort: 9085 diff --git a/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/diff_no_diff.yaml b/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/diff_no_diff.yaml deleted file mode 100644 index 62d9cd9ac2..0000000000 --- a/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/diff_no_diff.yaml +++ /dev/null @@ -1,51 +0,0 @@ -apiVersion: apps/v1 -kind: Deployment -metadata: - name: simple - labels: - app: simple - pipecd.dev/managed-by: piped -spec: - replicas: 2 - selector: - matchLabels: - app: simple - template: - metadata: - labels: - app: simple - spec: - containers: - - name: helloworld - image: gcr.io/pipecd/helloworld:v1.0.0 - args: - - hi - - hello - ports: - - containerPort: 9085 ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: simple - labels: - pipecd.dev/managed-by: piped - app: simple -spec: - replicas: 2 - selector: - matchLabels: - app: simple - template: - metadata: - labels: - app: simple - spec: - containers: - - name: helloworld - image: gcr.io/pipecd/helloworld:v1.0.0 - args: - - hi - - hello - ports: - - containerPort: 9085 diff --git a/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/diff_redact.yaml b/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/diff_redact.yaml deleted file mode 100644 index 73802b9f3a..0000000000 --- a/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/diff_redact.yaml +++ /dev/null @@ -1,17 +0,0 @@ -apiVersion: v1 -metadata: - name: pipecd-secrets - namespace: default -kind: Secret -type: Opaque -data: - service-account.json: real-secret-data-1 ---- -apiVersion: v1 -metadata: - name: pipecd-secrets - namespace: default -kind: Secret -type: Opaque -data: - service-account.json: real-secret-data-2 diff --git a/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testchart/.helmignore b/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testchart/.helmignore deleted file mode 100644 index 0e8a0eb36f..0000000000 --- a/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testchart/.helmignore +++ /dev/null @@ -1,23 +0,0 @@ -# Patterns to ignore when building packages. -# This supports shell glob matching, relative path matching, and -# negation (prefixed with !). Only one pattern per line. -.DS_Store -# Common VCS dirs -.git/ -.gitignore -.bzr/ -.bzrignore -.hg/ -.hgignore -.svn/ -# Common backup files -*.swp -*.bak -*.tmp -*.orig -*~ -# Various IDEs -.project -.idea/ -*.tmproj -.vscode/ diff --git a/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testchart/Chart.yaml b/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testchart/Chart.yaml deleted file mode 100644 index 5bbebd26c2..0000000000 --- a/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testchart/Chart.yaml +++ /dev/null @@ -1,23 +0,0 @@ -apiVersion: v2 -name: testchart -description: A Helm chart for Kubernetes - -# A chart can be either an 'application' or a 'library' chart. -# -# Application charts are a collection of templates that can be packaged into versioned archives -# to be deployed. -# -# Library charts provide useful utilities or functions for the chart developer. They're included as -# a dependency of application charts to inject those utilities and functions into the rendering -# pipeline. Library charts do not define any templates and therefore cannot be deployed. -type: application - -# This is the chart version. This version number should be incremented each time you make changes -# to the chart and its templates, including the app version. -# Versions are expected to follow Semantic Versioning (https://semver.org/) -version: 0.1.0 - -# This is the version number of the application being deployed. This version number should be -# incremented each time you make changes to the application. Versions are not expected to -# follow Semantic Versioning. They should reflect the version the application is using. -appVersion: 1.16.0 diff --git a/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testchart/templates/NOTES.txt b/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testchart/templates/NOTES.txt deleted file mode 100644 index 9b8fb51f68..0000000000 --- a/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testchart/templates/NOTES.txt +++ /dev/null @@ -1,21 +0,0 @@ -1. Get the application URL by running these commands: -{{- if .Values.ingress.enabled }} -{{- range $host := .Values.ingress.hosts }} - {{- range .paths }} - http{{ if $.Values.ingress.tls }}s{{ end }}://{{ $host.host }}{{ . }} - {{- end }} -{{- end }} -{{- else if contains "NodePort" .Values.service.type }} - export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "testchart.fullname" . }}) - export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}") - echo http://$NODE_IP:$NODE_PORT -{{- else if contains "LoadBalancer" .Values.service.type }} - NOTE: It may take a few minutes for the LoadBalancer IP to be available. - You can watch the status of by running 'kubectl get --namespace {{ .Release.Namespace }} svc -w {{ include "testchart.fullname" . }}' - export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "testchart.fullname" . }} --template "{{"{{ range (index .status.loadBalancer.ingress 0) }}{{.}}{{ end }}"}}") - echo http://$SERVICE_IP:{{ .Values.service.port }} -{{- else if contains "ClusterIP" .Values.service.type }} - export POD_NAME=$(kubectl get pods --namespace {{ .Release.Namespace }} -l "app.kubernetes.io/name={{ include "testchart.name" . }},app.kubernetes.io/instance={{ .Release.Name }}" -o jsonpath="{.items[0].metadata.name}") - echo "Visit http://127.0.0.1:8080 to use your application" - kubectl --namespace {{ .Release.Namespace }} port-forward $POD_NAME 8080:80 -{{- end }} diff --git a/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testchart/templates/_helpers.tpl b/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testchart/templates/_helpers.tpl deleted file mode 100644 index 698af2572c..0000000000 --- a/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testchart/templates/_helpers.tpl +++ /dev/null @@ -1,63 +0,0 @@ -{{/* vim: set filetype=mustache: */}} -{{/* -Expand the name of the chart. -*/}} -{{- define "testchart.name" -}} -{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} -{{- end }} - -{{/* -Create a default fully qualified app name. -We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). -If release name contains chart name it will be used as a full name. -*/}} -{{- define "testchart.fullname" -}} -{{- if .Values.fullnameOverride }} -{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} -{{- else }} -{{- $name := default .Chart.Name .Values.nameOverride }} -{{- if contains $name .Release.Name }} -{{- .Release.Name | trunc 63 | trimSuffix "-" }} -{{- else }} -{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} -{{- end }} -{{- end }} -{{- end }} - -{{/* -Create chart name and version as used by the chart label. -*/}} -{{- define "testchart.chart" -}} -{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} -{{- end }} - -{{/* -Common labels -*/}} -{{- define "testchart.labels" -}} -helm.sh/chart: {{ include "testchart.chart" . }} -{{ include "testchart.selectorLabels" . }} -{{- if .Chart.AppVersion }} -app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} -{{- end }} -app.kubernetes.io/managed-by: {{ .Release.Service }} -{{- end }} - -{{/* -Selector labels -*/}} -{{- define "testchart.selectorLabels" -}} -app.kubernetes.io/name: {{ include "testchart.name" . }} -app.kubernetes.io/instance: {{ .Release.Name }} -{{- end }} - -{{/* -Create the name of the service account to use -*/}} -{{- define "testchart.serviceAccountName" -}} -{{- if .Values.serviceAccount.create }} -{{- default (include "testchart.fullname" .) .Values.serviceAccount.name }} -{{- else }} -{{- default "default" .Values.serviceAccount.name }} -{{- end }} -{{- end }} diff --git a/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testchart/templates/deployment.yaml b/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testchart/templates/deployment.yaml deleted file mode 100644 index b9c4cf95df..0000000000 --- a/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testchart/templates/deployment.yaml +++ /dev/null @@ -1,62 +0,0 @@ -apiVersion: apps/v1 -kind: Deployment -metadata: - name: {{ include "testchart.fullname" . }} - labels: - {{- include "testchart.labels" . | nindent 4 }} - namespace: {{.Release.Namespace}} -spec: -{{- if not .Values.autoscaling.enabled }} - replicas: {{ .Values.replicaCount }} -{{- end }} - selector: - matchLabels: - {{- include "testchart.selectorLabels" . | nindent 6 }} - template: - metadata: - {{- with .Values.podAnnotations }} - annotations: - {{- toYaml . | nindent 8 }} - {{- end }} - labels: - {{- include "testchart.selectorLabels" . | nindent 8 }} - spec: - {{- with .Values.imagePullSecrets }} - imagePullSecrets: - {{- toYaml . | nindent 8 }} - {{- end }} - serviceAccountName: {{ include "testchart.serviceAccountName" . }} - securityContext: - {{- toYaml .Values.podSecurityContext | nindent 8 }} - containers: - - name: {{ .Chart.Name }} - securityContext: - {{- toYaml .Values.securityContext | nindent 12 }} - image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" - imagePullPolicy: {{ .Values.image.pullPolicy }} - ports: - - name: http - containerPort: 80 - protocol: TCP - livenessProbe: - httpGet: - path: / - port: http - readinessProbe: - httpGet: - path: / - port: http - resources: - {{- toYaml .Values.resources | nindent 12 }} - {{- with .Values.nodeSelector }} - nodeSelector: - {{- toYaml . | nindent 8 }} - {{- end }} - {{- with .Values.affinity }} - affinity: - {{- toYaml . | nindent 8 }} - {{- end }} - {{- with .Values.tolerations }} - tolerations: - {{- toYaml . | nindent 8 }} - {{- end }} diff --git a/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testchart/templates/hpa.yaml b/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testchart/templates/hpa.yaml deleted file mode 100644 index 58c5a47d7e..0000000000 --- a/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testchart/templates/hpa.yaml +++ /dev/null @@ -1,28 +0,0 @@ -{{- if .Values.autoscaling.enabled }} -apiVersion: autoscaling/v2beta1 -kind: HorizontalPodAutoscaler -metadata: - name: {{ include "testchart.fullname" . }} - labels: - {{- include "testchart.labels" . | nindent 4 }} -spec: - scaleTargetRef: - apiVersion: apps/v1 - kind: Deployment - name: {{ include "testchart.fullname" . }} - minReplicas: {{ .Values.autoscaling.minReplicas }} - maxReplicas: {{ .Values.autoscaling.maxReplicas }} - metrics: - {{- if .Values.autoscaling.targetCPUUtilizationPercentage }} - - type: Resource - resource: - name: cpu - targetAverageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }} - {{- end }} - {{- if .Values.autoscaling.targetMemoryUtilizationPercentage }} - - type: Resource - resource: - name: memory - targetAverageUtilization: {{ .Values.autoscaling.targetMemoryUtilizationPercentage }} - {{- end }} -{{- end }} diff --git a/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testchart/templates/ingress.yaml b/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testchart/templates/ingress.yaml deleted file mode 100644 index 7c17e022f3..0000000000 --- a/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testchart/templates/ingress.yaml +++ /dev/null @@ -1,42 +0,0 @@ -{{- if .Values.ingress.enabled -}} -{{- $fullName := include "testchart.fullname" . -}} -{{- $svcPort := .Values.service.port -}} -{{- if semverCompare ">=1.14-0" .Capabilities.KubeVersion.GitVersion -}} -apiVersion: networking.k8s.io/v1beta1 -{{- else -}} -apiVersion: extensions/v1beta1 -{{- end }} -kind: Ingress -metadata: - name: {{ $fullName }} - labels: - {{- include "testchart.labels" . | nindent 4 }} - {{- with .Values.ingress.annotations }} - annotations: - {{- toYaml . | nindent 4 }} - {{- end }} - namespace: {{.Release.Namespace}} -spec: - {{- if .Values.ingress.tls }} - tls: - {{- range .Values.ingress.tls }} - - hosts: - {{- range .hosts }} - - {{ . | quote }} - {{- end }} - secretName: {{ .secretName }} - {{- end }} - {{- end }} - rules: - {{- range .Values.ingress.hosts }} - - host: {{ .host | quote }} - http: - paths: - {{- range .paths }} - - path: {{ . }} - backend: - serviceName: {{ $fullName }} - servicePort: {{ $svcPort }} - {{- end }} - {{- end }} - {{- end }} diff --git a/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testchart/templates/service.yaml b/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testchart/templates/service.yaml deleted file mode 100644 index d8c6e26de7..0000000000 --- a/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testchart/templates/service.yaml +++ /dev/null @@ -1,16 +0,0 @@ -apiVersion: v1 -kind: Service -metadata: - name: {{ include "testchart.fullname" . }} - labels: - {{- include "testchart.labels" . | nindent 4 }} - namespace: {{.Release.Namespace}} -spec: - type: {{ .Values.service.type }} - ports: - - port: {{ .Values.service.port }} - targetPort: http - protocol: TCP - name: http - selector: - {{- include "testchart.selectorLabels" . | nindent 4 }} diff --git a/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testchart/templates/serviceaccount.yaml b/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testchart/templates/serviceaccount.yaml deleted file mode 100644 index 4537db7747..0000000000 --- a/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testchart/templates/serviceaccount.yaml +++ /dev/null @@ -1,13 +0,0 @@ -{{- if .Values.serviceAccount.create -}} -apiVersion: v1 -kind: ServiceAccount -metadata: - name: {{ include "testchart.serviceAccountName" . }} - namespace: {{.Release.Namespace}} - labels: - {{- include "testchart.labels" . | nindent 4 }} - {{- with .Values.serviceAccount.annotations }} - annotations: - {{- toYaml . | nindent 4 }} - {{- end }} -{{- end }} diff --git a/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testchart/templates/tests/test-connection.yaml b/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testchart/templates/tests/test-connection.yaml deleted file mode 100644 index 94ec750986..0000000000 --- a/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testchart/templates/tests/test-connection.yaml +++ /dev/null @@ -1,15 +0,0 @@ -apiVersion: v1 -kind: Pod -metadata: - name: "{{ include "testchart.fullname" . }}-test-connection" - labels: - {{- include "testchart.labels" . | nindent 4 }} - annotations: - "helm.sh/hook": test-success -spec: - containers: - - name: wget - image: busybox - command: ['wget'] - args: ['{{ include "testchart.fullname" . }}:{{ .Values.service.port }}'] - restartPolicy: Never diff --git a/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testchart/values.yaml b/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testchart/values.yaml deleted file mode 100644 index 6c45a41504..0000000000 --- a/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testchart/values.yaml +++ /dev/null @@ -1,79 +0,0 @@ -# Default values for testchart. -# This is a YAML-formatted file. -# Declare variables to be passed into your templates. - -replicaCount: 1 - -image: - repository: nginx - pullPolicy: IfNotPresent - # Overrides the image tag whose default is the chart appVersion. - tag: "" - -imagePullSecrets: [] -nameOverride: "" -fullnameOverride: "" - -serviceAccount: - # Specifies whether a service account should be created - create: true - # Annotations to add to the service account - annotations: {} - # The name of the service account to use. - # If not set and create is true, a name is generated using the fullname template - name: "" - -podAnnotations: {} - -podSecurityContext: {} - # fsGroup: 2000 - -securityContext: {} - # capabilities: - # drop: - # - ALL - # readOnlyRootFilesystem: true - # runAsNonRoot: true - # runAsUser: 1000 - -service: - type: ClusterIP - port: 80 - -ingress: - enabled: false - annotations: {} - # kubernetes.io/ingress.class: nginx - # kubernetes.io/tls-acme: "true" - hosts: - - host: chart-example.local - paths: [] - tls: [] - # - secretName: chart-example-tls - # hosts: - # - chart-example.local - -resources: {} - # We usually recommend not to specify default resources and to leave this as a conscious - # choice for the user. This also increases chances charts run on environments with little - # resources, such as Minikube. If you do want to specify resources, uncomment the following - # lines, adjust them as necessary, and remove the curly braces after 'resources:'. - # limits: - # cpu: 100m - # memory: 128Mi - # requests: - # cpu: 100m - # memory: 128Mi - -autoscaling: - enabled: false - minReplicas: 1 - maxReplicas: 100 - targetCPUUtilizationPercentage: 80 - # targetMemoryUtilizationPercentage: 80 - -nodeSelector: {} - -tolerations: [] - -affinity: {} diff --git a/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testhelm/appconfdir/app.pipecd.yaml b/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testhelm/appconfdir/app.pipecd.yaml deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testhelm/appconfdir/dir/values.yaml b/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testhelm/appconfdir/dir/values.yaml deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testhelm/appconfdir/invalid-symlink b/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testhelm/appconfdir/invalid-symlink deleted file mode 120000 index 555dec973e..0000000000 --- a/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testhelm/appconfdir/invalid-symlink +++ /dev/null @@ -1 +0,0 @@ -/etc/hosts \ No newline at end of file diff --git a/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testhelm/appconfdir/valid-symlink b/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testhelm/appconfdir/valid-symlink deleted file mode 120000 index a53324e8c5..0000000000 --- a/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testhelm/appconfdir/valid-symlink +++ /dev/null @@ -1 +0,0 @@ -dir/values.yaml \ No newline at end of file diff --git a/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testhelm/appconfdir/values.yaml b/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testhelm/appconfdir/values.yaml deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testhelm/values.yaml b/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testhelm/values.yaml deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testkustomize/deployment.yaml b/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testkustomize/deployment.yaml deleted file mode 100644 index 1360acf696..0000000000 --- a/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testkustomize/deployment.yaml +++ /dev/null @@ -1,33 +0,0 @@ -apiVersion: apps/v1 -kind: Deployment -metadata: - name: the-deployment -spec: - replicas: 3 - selector: - matchLabels: - deployment: hello - template: - metadata: - labels: - deployment: hello - spec: - containers: - - name: the-container - image: monopole/hello:1 - command: ["/hello", - "--port=8080", - "--enableRiskyFeature=$(ENABLE_RISKY)"] - ports: - - containerPort: 8080 - env: - - name: ALT_GREETING - valueFrom: - configMapKeyRef: - name: the-map - key: altGreeting - - name: ENABLE_RISKY - valueFrom: - configMapKeyRef: - name: the-map - key: enableRisky \ No newline at end of file diff --git a/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testkustomize/kustomization.yaml b/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testkustomize/kustomization.yaml deleted file mode 100644 index c7cf5bb89a..0000000000 --- a/pkg/app/pipedv1/plugin/kubernetes/provider/testdata/testkustomize/kustomization.yaml +++ /dev/null @@ -1,5 +0,0 @@ -commonLabels: - app: hello - -resources: - - deployment.yaml \ No newline at end of file diff --git a/tool/codegen/codegen.sh b/tool/codegen/codegen.sh index d56fb744c5..8f30860e33 100755 --- a/tool/codegen/codegen.sh +++ b/tool/codegen/codegen.sh @@ -106,7 +106,6 @@ mockPackageNames=( "datastoretest" "filestoretest" "kubernetestest" - "kubernetestest" "cachetest" "gittest" "jwttest" @@ -117,7 +116,6 @@ mockDestinations=( "pkg/datastore/datastoretest/datastore.mock.go" "pkg/filestore/filestoretest/filestore.mock.go" "pkg/app/piped/platformprovider/kubernetes/kubernetestest/kubernetes.mock.go" - "pkg/app/pipedv1/plugin/kubernetes/provider/kubernetestest/kubernetes.mock.go" "pkg/cache/cachetest/cache.mock.go" "pkg/git/gittest/git.mock.go" "pkg/jwt/jwttest/jwt.mock.go" @@ -128,7 +126,6 @@ mockSources=( "github.com/pipe-cd/pipecd/pkg/datastore" "github.com/pipe-cd/pipecd/pkg/filestore" "github.com/pipe-cd/pipecd/pkg/app/piped/platformprovider/kubernetes" - "github.com/pipe-cd/pipecd/pkg/app/pipedv1/plugin/kubernetes/provider" "github.com/pipe-cd/pipecd/pkg/cache" "github.com/pipe-cd/pipecd/pkg/git" "github.com/pipe-cd/pipecd/pkg/jwt" @@ -139,7 +136,6 @@ mockInterfaces=( "ProjectStore,PipedStore,ApplicationStore,DeploymentStore,CommandStore" "Store" "Applier,Loader" - "Applier,Loader" "Getter,Putter,Deleter,Cache" "Repo" "Signer,Verifier" From f017312df3e14251f9e65dfceb14e83c608b5805 Mon Sep 17 00:00:00 2001 From: Tetsuya Kikuchi <97105818+t-kikuc@users.noreply.github.com> Date: Mon, 7 Oct 2024 15:38:13 +0900 Subject: [PATCH 44/84] Support directly designating a gitSSHKey instead of File for launcher (#5258) * Generate v0.49.x docs Signed-off-by: t-kikuc * add --git-ssh-key-env Signed-off-by: t-kikuc * update docs: add '--git-ssh-key-env' Signed-off-by: t-kikuc * add '\n' at the end of ssh key Signed-off-by: t-kikuc * Directly use data instead of env Signed-off-by: t-kikuc * Clarify the flag description Signed-off-by: t-kikuc * fix error message: 'and' -> 'or' Signed-off-by: t-kikuc --------- Signed-off-by: t-kikuc --- .../managing-piped/runtime-options.md | 3 ++- pkg/app/launcher/cmd/launcher/launcher.go | 25 ++++++++++++++++++- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/docs/content/en/docs-dev/user-guide/managing-piped/runtime-options.md b/docs/content/en/docs-dev/user-guide/managing-piped/runtime-options.md index fda06bdff2..6b8ba10365 100644 --- a/docs/content/en/docs-dev/user-guide/managing-piped/runtime-options.md +++ b/docs/content/en/docs-dev/user-guide/managing-piped/runtime-options.md @@ -62,7 +62,8 @@ Flags: --git-branch string Branch of git repository to for Piped config. --git-piped-config-file string Relative path within git repository to locate Piped config file. --git-repo-url string The remote URL of git repository to fetch Piped config. - --git-ssh-key-file string The path to SSH private key to fetch private git repository. + --git-ssh-key-data string Base64 encoded value of SSH private key to fetch Piped config from the private git repository. + --git-ssh-key-file string The path to SSH private key to fetch Piped config from private git repository. --grace-period duration How long to wait for graceful shutdown. (default 30s) -h, --help help for launcher --home-dir string The working directory of Launcher. diff --git a/pkg/app/launcher/cmd/launcher/launcher.go b/pkg/app/launcher/cmd/launcher/launcher.go index 9ab209225f..0962a353f0 100644 --- a/pkg/app/launcher/cmd/launcher/launcher.go +++ b/pkg/app/launcher/cmd/launcher/launcher.go @@ -73,6 +73,7 @@ type launcher struct { gitBranch string gitPipedConfigFile string gitSSHKeyFile string + gitSSHKeyData string insecure bool certFile string homeDir string @@ -119,7 +120,8 @@ func NewCommand() *cobra.Command { cmd.Flags().StringVar(&l.gitRepoURL, "git-repo-url", l.gitRepoURL, "The remote URL of git repository to fetch Piped config.") cmd.Flags().StringVar(&l.gitBranch, "git-branch", l.gitBranch, "Branch of git repository to for Piped config.") cmd.Flags().StringVar(&l.gitPipedConfigFile, "git-piped-config-file", l.gitPipedConfigFile, "Relative path within git repository to locate Piped config file.") - cmd.Flags().StringVar(&l.gitSSHKeyFile, "git-ssh-key-file", l.gitSSHKeyFile, "The path to SSH private key to fetch private git repository.") + cmd.Flags().StringVar(&l.gitSSHKeyFile, "git-ssh-key-file", l.gitSSHKeyFile, "The path to SSH private key to fetch Piped config from the private git repository.") + cmd.Flags().StringVar(&l.gitSSHKeyData, "git-ssh-key-data", l.gitSSHKeyData, "The base64 encoded value of SSH private key to fetch Piped config from the private git repository.") cmd.Flags().BoolVar(&l.insecure, "insecure", l.insecure, "Whether disabling transport security while connecting to control-plane.") cmd.Flags().StringVar(&l.certFile, "cert-file", l.certFile, "The path to the TLS certificate file.") @@ -146,6 +148,7 @@ func NewCommand() *cobra.Command { "git-branch": {}, "git-piped-config-file": {}, "git-ssh-key-file": {}, + "git-ssh-key-data": {}, "home-dir": {}, "default-version": {}, "launcher-admin-port": {}, @@ -181,6 +184,9 @@ func (l *launcher) validateFlags() error { if l.gitPipedConfigFile == "" { return fmt.Errorf("git-piped-config-path must be set to load config from a git repository") } + if l.gitSSHKeyFile != "" && l.gitSSHKeyData != "" { + return fmt.Errorf("only one of git-ssh-key-file or git-ssh-key-data can be set") + } } return nil } @@ -227,6 +233,23 @@ func (l *launcher) run(ctx context.Context, input cli.Input) error { if l.gitSSHKeyFile != "" { options = append(options, git.WithGitEnv(fmt.Sprintf("GIT_SSH_COMMAND=ssh -i %s -o StrictHostKeyChecking=no -F /dev/null", l.gitSSHKeyFile))) } + if l.gitSSHKeyData != "" { + decodedKey, err := base64.StdEncoding.DecodeString(l.gitSSHKeyData) + if err != nil { + return fmt.Errorf("failed to decode SSH key data, (%w)", err) + } + tmpKeyFile, err := os.CreateTemp("", "git-ssh-key-data") + if err != nil { + return fmt.Errorf("failed to create a temp file for SSH key data (%w)", err) + } + if _, err = tmpKeyFile.Write(decodedKey); err != nil { + return fmt.Errorf("failed to write SSH key data to a temp file (%w)", err) + } + + options = append(options, git.WithGitEnv(fmt.Sprintf("GIT_SSH_COMMAND=ssh -i %s -o StrictHostKeyChecking=no -F /dev/null", tmpKeyFile.Name()))) + defer os.Remove(tmpKeyFile.Name()) + } + gc, err := git.NewClient(options...) if err != nil { input.Logger.Error("failed to initialize git client", zap.Error(err)) From 3ce7977d46848c16524e734ade6ae2cbae999c5f Mon Sep 17 00:00:00 2001 From: Shinnosuke Sawada-Dazai Date: Tue, 8 Oct 2024 11:34:45 +0900 Subject: [PATCH 45/84] Implement DetermineVersions of k8s plugin (#5257) * Implement DetermineVersions Signed-off-by: Shinnosuke Sawada-Dazai * Add test cases and fixed the failure Signed-off-by: Shinnosuke Sawada-Dazai --------- Signed-off-by: Shinnosuke Sawada-Dazai --- .../plugin/kubernetes/deployment/determine.go | 91 +++++ .../kubernetes/deployment/determine_test.go | 312 ++++++++++++++++++ .../plugin/kubernetes/deployment/server.go | 29 +- .../plugin/kubernetes/provider/loader.go | 19 ++ .../plugin/kubernetes/provider/manifest.go | 29 ++ 5 files changed, 477 insertions(+), 3 deletions(-) create mode 100644 pkg/app/pipedv1/plugin/kubernetes/deployment/determine.go create mode 100644 pkg/app/pipedv1/plugin/kubernetes/deployment/determine_test.go create mode 100644 pkg/app/pipedv1/plugin/kubernetes/provider/loader.go create mode 100644 pkg/app/pipedv1/plugin/kubernetes/provider/manifest.go diff --git a/pkg/app/pipedv1/plugin/kubernetes/deployment/determine.go b/pkg/app/pipedv1/plugin/kubernetes/deployment/determine.go new file mode 100644 index 0000000000..54e39c75a1 --- /dev/null +++ b/pkg/app/pipedv1/plugin/kubernetes/deployment/determine.go @@ -0,0 +1,91 @@ +// 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 deployment + +import ( + "fmt" + "strings" + + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + + "github.com/pipe-cd/pipecd/pkg/app/pipedv1/plugin/kubernetes/provider" + "github.com/pipe-cd/pipecd/pkg/model" +) + +type containerImage struct { + name string + tag string +} + +// parseContainerImage splits the container image into name and tag. +// The image should be in the format of "name:tag". +// If the tag is not specified, it will be empty. +func parseContainerImage(image string) (img containerImage) { + parts := strings.Split(image, ":") + if len(parts) == 2 { + img.tag = parts[1] + } + paths := strings.Split(parts[0], "/") + img.name = paths[len(paths)-1] + return +} + +// determineVersions decides artifact versions of an application. +// It finds all container images that are being specified in the workload manifests then returns their names and tags. +func determineVersions(manifests []provider.Manifest) ([]*model.ArtifactVersion, error) { + imageMap := map[string]struct{}{} + for _, m := range manifests { + // TODO: we should consider other fields like spec.jobTempate.spec.template.spec.containers because CronJob uses this format. + containers, ok, err := unstructured.NestedSlice(m.Body.Object, "spec", "template", "spec", "containers") + if err != nil { + // if the containers field is not an array, it will return an error. + // we define this as error because the 'containers' is plural form, so it should be an array. + return nil, err + } + if !ok { + continue + } + // Remove duplicate images on multiple manifests. + for _, c := range containers { + m, ok := c.(map[string]interface{}) + if !ok { + // TODO: Add logging. + continue + } + img, ok := m["image"] + if !ok { + continue + } + imgStr, ok := img.(string) + if !ok { + return nil, fmt.Errorf("invalid image format: %T(%v)", img, img) + } + imageMap[imgStr] = struct{}{} + } + } + + versions := make([]*model.ArtifactVersion, 0, len(imageMap)) + for i := range imageMap { + image := parseContainerImage(i) + versions = append(versions, &model.ArtifactVersion{ + Kind: model.ArtifactVersion_CONTAINER_IMAGE, + Version: image.tag, + Name: image.name, + Url: i, + }) + } + + return versions, nil +} diff --git a/pkg/app/pipedv1/plugin/kubernetes/deployment/determine_test.go b/pkg/app/pipedv1/plugin/kubernetes/deployment/determine_test.go new file mode 100644 index 0000000000..242fcf99e0 --- /dev/null +++ b/pkg/app/pipedv1/plugin/kubernetes/deployment/determine_test.go @@ -0,0 +1,312 @@ +// 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 deployment + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "sigs.k8s.io/yaml" + + "github.com/pipe-cd/pipecd/pkg/app/pipedv1/plugin/kubernetes/provider" + "github.com/pipe-cd/pipecd/pkg/model" +) + +func mustUnmarshalYAML[T any](t *testing.T, data []byte) T { + t.Helper() + + // Convert YAML to JSON. + // we define structs without defining UnmarshalYAML method, so we can't use yaml.Unmarshal directly. + // Instead, we convert YAML to JSON and then unmarshal JSON to the struct. + j, err := yaml.YAMLToJSON(data) + require.NoError(t, err) + + // then, unmarshal JSON to the struct. + var m T + require.NoError(t, json.Unmarshal(j, &m)) + + return m +} + +func TestParseContainerImage(t *testing.T) { + tests := []struct { + name string + image string + want containerImage + }{ + { + name: "image with tag", + image: "nginx:1.19.3", + want: containerImage{name: "nginx", tag: "1.19.3"}, + }, + { + name: "image without tag", + image: "nginx", + want: containerImage{name: "nginx", tag: ""}, + }, + { + name: "image with tag and registry", + image: "docker.io/nginx:1.19.3", + want: containerImage{name: "nginx", tag: "1.19.3"}, + }, + { + name: "image with tag and repository", + image: "myrepo/nginx:1.19.3", + want: containerImage{name: "nginx", tag: "1.19.3"}, + }, + { + name: "image with tag, registry and repository", + image: "docker.io/myrepo/nginx:1.19.3", + want: containerImage{name: "nginx", tag: "1.19.3"}, + }, + { + name: "image without tag, with registry and repository", + image: "docker.io/myrepo/nginx", + want: containerImage{name: "nginx", tag: ""}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := parseContainerImage(tt.image) + assert.Equal(t, tt.want, got) + }) + } +} +func TestDetermineVersions(t *testing.T) { + tests := []struct { + name string + manifests []string + want []*model.ArtifactVersion + wantErr bool + }{ + { + name: "single manifest with one container", + manifests: []string{ + ` +apiVersion: apps/v1 +kind: Deployment +metadata: + name: nginx-deployment +spec: + template: + spec: + containers: + - name: nginx + image: nginx:1.19.3 +`, + }, + want: []*model.ArtifactVersion{ + { + Kind: model.ArtifactVersion_CONTAINER_IMAGE, + Version: "1.19.3", + Name: "nginx", + Url: "nginx:1.19.3", + }, + }, + }, + { + name: "multiple manifests with multiple containers", + manifests: []string{ + ` +apiVersion: apps/v1 +kind: Deployment +metadata: + name: nginx-deployment +spec: + template: + spec: + containers: + - name: nginx + image: nginx:1.19.3 +`, + ` +apiVersion: apps/v1 +kind: Deployment +metadata: + name: redis-deployment +spec: + template: + spec: + containers: + - name: redis + image: redis:6.0.9 +`, + }, + want: []*model.ArtifactVersion{ + { + Kind: model.ArtifactVersion_CONTAINER_IMAGE, + Version: "1.19.3", + Name: "nginx", + Url: "nginx:1.19.3", + }, + { + Kind: model.ArtifactVersion_CONTAINER_IMAGE, + Version: "6.0.9", + Name: "redis", + Url: "redis:6.0.9", + }, + }, + }, + { + name: "manifest with duplicate images", + manifests: []string{ + ` +apiVersion: apps/v1 +kind: Deployment +metadata: + name: nginx-deployment +spec: + template: + spec: + containers: + - name: nginx + image: nginx:1.19.3 + - name: nginx + image: nginx:1.19.3 +`, + }, + want: []*model.ArtifactVersion{ + { + Kind: model.ArtifactVersion_CONTAINER_IMAGE, + Version: "1.19.3", + Name: "nginx", + Url: "nginx:1.19.3", + }, + }, + }, + { + name: "manifest with no containers", + manifests: []string{ + ` +apiVersion: apps/v1 +kind: Deployment +metadata: + name: empty-deployment +spec: + template: + spec: + containers: [] +`, + }, + want: []*model.ArtifactVersion{}, + }, + { + name: "manifest with missing image field", + manifests: []string{ + ` +apiVersion: apps/v1 +kind: Deployment +metadata: + name: missing-image-deployment +spec: + template: + spec: + containers: + - name: nginx +`, + }, + want: []*model.ArtifactVersion{}, + }, + { + name: "manifest with non-string image field", + manifests: []string{ + ` +apiVersion: apps/v1 +kind: Deployment +metadata: + name: non-string-image-deployment +spec: + template: + spec: + containers: + - name: nginx + image: 12345 +`, + }, + want: nil, + wantErr: true, + }, + { + name: "manifest with no containers field", + manifests: []string{ + ` +apiVersion: apps/v1 +kind: Deployment +metadata: + name: no-containers-deployment +spec: + template: + spec: {} +`, + }, + want: []*model.ArtifactVersion{}, + wantErr: false, + }, + { + name: "manifest with invalid containers field -- returns error", + manifests: []string{ + ` +apiVersion: apps/v1 +kind: Deployment +metadata: + name: no-containers-deployment +spec: + template: + spec: + containers: "invalid-containers-field" +`, + }, + wantErr: true, + }, + { + name: "manifest with invalid containers field -- skipped", + manifests: []string{ + ` +apiVersion: apps/v1 +kind: Deployment +metadata: + name: no-containers-deployment +spec: + template: + spec: + containers: + - "invalid-containers-field" +`, + }, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var manifests []provider.Manifest + for _, data := range tt.manifests { + manifests = append(manifests, mustUnmarshalYAML[provider.Manifest](t, []byte(strings.TrimSpace(data)))) + } + got, err := determineVersions(manifests) + if tt.wantErr { + require.Error(t, err) + return + } else { + require.NoError(t, err) + } + assert.ElementsMatch(t, tt.want, got) + }) + } +} diff --git a/pkg/app/pipedv1/plugin/kubernetes/deployment/server.go b/pkg/app/pipedv1/plugin/kubernetes/deployment/server.go index 5a4c5d206f..4dd4fd64b4 100644 --- a/pkg/app/pipedv1/plugin/kubernetes/deployment/server.go +++ b/pkg/app/pipedv1/plugin/kubernetes/deployment/server.go @@ -18,23 +18,32 @@ import ( "context" "time" + "github.com/pipe-cd/pipecd/pkg/app/pipedv1/plugin/kubernetes/provider" "github.com/pipe-cd/pipecd/pkg/plugin/api/v1alpha1/deployment" "github.com/pipe-cd/pipecd/pkg/regexpool" "go.uber.org/zap" "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" ) type toolRegistry interface { InstallTool(ctx context.Context, name, version string) (path string, err error) } +type loader interface { + // LoadManifests renders and loads all manifests for application. + LoadManifests(ctx context.Context, input provider.LoaderInput) ([]provider.Manifest, error) +} + type DeploymentService struct { deployment.UnimplementedDeploymentServiceServer RegexPool *regexpool.Pool Logger *zap.Logger ToolRegistry toolRegistry + Loader loader } // NewDeploymentService creates a new planService. @@ -59,9 +68,23 @@ func (a *DeploymentService) DetermineStrategy(context.Context, *deployment.Deter } // DetermineVersions implements deployment.DeploymentServiceServer. -func (a *DeploymentService) DetermineVersions(context.Context, *deployment.DetermineVersionsRequest) (*deployment.DetermineVersionsResponse, error) { - // TODO: how to determine whether the runnning or target deployment to use? - panic("unimplemented") +func (a *DeploymentService) DetermineVersions(ctx context.Context, request *deployment.DetermineVersionsRequest) (*deployment.DetermineVersionsResponse, error) { + manifests, err := a.Loader.LoadManifests(ctx, provider.LoaderInput{ + // TODO: fill the input + }) + + if err != nil { + return nil, status.Error(codes.Internal, err.Error()) + } + + versions, err := determineVersions(manifests) + if err != nil { + return nil, status.Error(codes.Internal, err.Error()) + } + + return &deployment.DetermineVersionsResponse{ + Versions: versions, + }, nil } // BuildPipelineSyncStages implements deployment.DeploymentServiceServer. diff --git a/pkg/app/pipedv1/plugin/kubernetes/provider/loader.go b/pkg/app/pipedv1/plugin/kubernetes/provider/loader.go new file mode 100644 index 0000000000..aeb59eb1e0 --- /dev/null +++ b/pkg/app/pipedv1/plugin/kubernetes/provider/loader.go @@ -0,0 +1,19 @@ +// 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 provider + +type LoaderInput struct { + // TODO: define fields for LoaderInput. +} diff --git a/pkg/app/pipedv1/plugin/kubernetes/provider/manifest.go b/pkg/app/pipedv1/plugin/kubernetes/provider/manifest.go new file mode 100644 index 0000000000..9856d60558 --- /dev/null +++ b/pkg/app/pipedv1/plugin/kubernetes/provider/manifest.go @@ -0,0 +1,29 @@ +// 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 provider + +import "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + +// Manifest represents a Kubernetes resource manifest. +type Manifest struct { + // TODO: define ResourceKey and add as a field here. + Body *unstructured.Unstructured +} + +// UnmarshalJSON implements the json.Unmarshaler interface. +func (m *Manifest) UnmarshalJSON(data []byte) error { + m.Body = new(unstructured.Unstructured) + return m.Body.UnmarshalJSON(data) +} From ea78506d84872fa11e74c2aac298fd7ef17d090b Mon Sep 17 00:00:00 2001 From: Khanh Tran <32532742+khanhtc1202@users.noreply.github.com> Date: Tue, 8 Oct 2024 11:26:27 +0700 Subject: [PATCH 46/84] Add workflow steps to build and publish quickstart manifests (#5260) --- .github/workflows/publish_image_chart.yaml | 23 ++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/.github/workflows/publish_image_chart.yaml b/.github/workflows/publish_image_chart.yaml index 32072602d4..40d2f8cdd5 100644 --- a/.github/workflows/publish_image_chart.yaml +++ b/.github/workflows/publish_image_chart.yaml @@ -146,3 +146,26 @@ jobs: event-name: helm-release labels: helmRepo=pipecd data: ${{ env.PIPECD_VERSION }} + + # Building and publishing quickstart manifests. + - name: Build quickstart manifests + if: startsWith(github.ref, 'refs/tags/') + run: | + helm template pipecd oci://ghcr.io/pipe-cd/chart/pipecd --version ${{ env.PIPECD_VERSION }} -n pipecd -f quickstart/control-plane-values.yaml > quickstart/manifests/control-plane.yaml + helm template piped oci://ghcr.io/pipe-cd/chart/piped --version ${{ env.PIPECD_VERSION }} -n pipecd --set quickstart.enabled=true --set quickstart.pipedId=\ --set quickstart.pipedKeyData=\ > quickstart/manifests/piped.yaml + - name: Publish quickstart manifests + if: startsWith(github.ref, 'refs/tags/') + uses: peter-evans/create-pull-request@v6 + with: + title: "[bot] Publish quickstart manifests" + commit-message: "[bot] Publish quickstart manifests" + branch: "create-pull-request/publish-quickstart-manifests" + body: | + Automated changes by [create-pull-request](https://github.com/peter-evans/create-pull-request) GitHub action. + The workflow is defined [here](https://github.com/pipe-cd/pipecd/blob/master/.github/workflows/publish_image_chart.yaml). + + **Note:** You need to **close and reopen this PR** manually to trigger status check workflows. (Or just click `Update branch` if possible.) + For details, see https://github.com/peter-evans/create-pull-request/blob/main/docs/concepts-guidelines.md#triggering-further-workflow-runs. + delete-branch: true + signoff: true + token: ${{ secrets.GITHUB_TOKEN }} From c4aca591a776cedda1fcc6d9967bbc4c3f529537 Mon Sep 17 00:00:00 2001 From: Khanh Tran <32532742+khanhtc1202@users.noreply.github.com> Date: Tue, 8 Oct 2024 11:46:09 +0700 Subject: [PATCH 47/84] Update quickstart README (#5261) Signed-off-by: khanhtc1202 --- quickstart/README.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/quickstart/README.md b/quickstart/README.md index 279b1a4dc0..76b8f4cd3f 100644 --- a/quickstart/README.md +++ b/quickstart/README.md @@ -26,7 +26,6 @@ kubectl apply -n pipecd -f ./manifests/control-plane.yaml | ---- | ---- | | | piped id | | | base64-encoded piped key | -| | the manifest repo url | 5. deploy piped to the namespace: pipecd @@ -41,7 +40,7 @@ The manifests directory contains raw Kubernetes manifests files. The 2 files are For `control-plane.yaml` ```shell -$ helm template pipecd oci://ghcr.io/pipe-cd/chart/pipecd --version v0.48.6 -n pipecd --create-namespace -f quickstart/control-plane-values.yaml +$ helm template pipecd oci://ghcr.io/pipe-cd/chart/pipecd --version v0.48.6 -n pipecd -f quickstart/control-plane-values.yaml ``` For `piped.yaml` From cf39825c4986dedfceee37bda8d19497a117565d Mon Sep 17 00:00:00 2001 From: Shinnosuke Sawada-Dazai Date: Tue, 8 Oct 2024 13:54:03 +0900 Subject: [PATCH 48/84] Update workflow not to run by matrix (#5262) Signed-off-by: Shinnosuke Sawada-Dazai --- .github/workflows/publish_image_chart.yaml | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/.github/workflows/publish_image_chart.yaml b/.github/workflows/publish_image_chart.yaml index 40d2f8cdd5..e3c401483c 100644 --- a/.github/workflows/publish_image_chart.yaml +++ b/.github/workflows/publish_image_chart.yaml @@ -147,14 +147,31 @@ jobs: labels: helmRepo=pipecd data: ${{ env.PIPECD_VERSION }} + release-quickstart-manifests: + runs-on: ubuntu-latest + needs: artifacts + if: startsWith(github.ref, 'refs/tags/') + permissions: + contents: write + steps: + # setup tools and repositories + - name: Install helm + uses: Azure/setup-helm@v1 + with: + version: ${{ env.HELM_VERSION }} + - uses: actions/checkout@v3 + with: + fetch-depth: 0 + + - name: Determine version + run: echo "PIPECD_VERSION=$(git describe --tags --always --abbrev=7)" >> $GITHUB_ENV + # Building and publishing quickstart manifests. - name: Build quickstart manifests - if: startsWith(github.ref, 'refs/tags/') run: | helm template pipecd oci://ghcr.io/pipe-cd/chart/pipecd --version ${{ env.PIPECD_VERSION }} -n pipecd -f quickstart/control-plane-values.yaml > quickstart/manifests/control-plane.yaml helm template piped oci://ghcr.io/pipe-cd/chart/piped --version ${{ env.PIPECD_VERSION }} -n pipecd --set quickstart.enabled=true --set quickstart.pipedId=\ --set quickstart.pipedKeyData=\ > quickstart/manifests/piped.yaml - name: Publish quickstart manifests - if: startsWith(github.ref, 'refs/tags/') uses: peter-evans/create-pull-request@v6 with: title: "[bot] Publish quickstart manifests" From c22b053b6c79aca4e98a44e4edfff355cbb528e7 Mon Sep 17 00:00:00 2001 From: Tetsuya Kikuchi <97105818+t-kikuc@users.noreply.github.com> Date: Tue, 8 Oct 2024 14:07:59 +0900 Subject: [PATCH 49/84] Update RELEASE to v0.49.1 and update v0.49.x docs (#5263) * update RELEASE to v0.49.1 Signed-off-by: t-kikuc * update docs of v0.49.x by `make release version=v0.49.1` Signed-off-by: t-kikuc --------- Signed-off-by: t-kikuc --- RELEASE | 2 +- .../managing-piped/runtime-options.md | 42 ++++++++++--------- 2 files changed, 24 insertions(+), 20 deletions(-) diff --git a/RELEASE b/RELEASE index b5019e3940..45841f2604 100644 --- a/RELEASE +++ b/RELEASE @@ -1,6 +1,6 @@ # Generated by `make release` command. # DO NOT EDIT. -tag: v0.49.0 +tag: v0.49.1 releaseNoteGenerator: showCommitter: false diff --git a/docs/content/en/docs-v0.49.x/user-guide/managing-piped/runtime-options.md b/docs/content/en/docs-v0.49.x/user-guide/managing-piped/runtime-options.md index a0c0790383..6b8ba10365 100644 --- a/docs/content/en/docs-v0.49.x/user-guide/managing-piped/runtime-options.md +++ b/docs/content/en/docs-v0.49.x/user-guide/managing-piped/runtime-options.md @@ -20,6 +20,7 @@ Flags: --app-manifest-cache-count int The number of app manifests to cache. The cache-key contains the commit hash. The default is 150. (default 150) --cert-file string The path to the TLS certificate file. --config-aws-secret string The ARN of secret that contains Piped config and be stored in AWS Secrets Manager. + --config-aws-ssm-parameter string The name of parameter of Piped config stored in AWS Systems Manager Parameter Store. SecureString is also supported. --config-data string The base64 encoded string of the configuration data. --config-file string The path to the configuration file. --config-gcp-secret string The resource ID of secret that contains Piped config and be stored in GCP SecretManager. @@ -46,25 +47,28 @@ Usage: launcher launcher [flags] Flags: - --aws-secret-id string The ARN of secret that contains Piped config in AWS Secrets Manager service. - --cert-file string The path to the TLS certificate file. - --check-interval duration Interval to periodically check desired config/version to restart Piped. Default is 1m. (default 1m0s) - --config-data string The base64 encoded string of the configuration data. - --config-file string The path to the configuration file. - --config-from-aws-secret Whether to load Piped config that is being stored in AWS Secrets Manager service. - --config-from-gcp-secret Whether to load Piped config that is being stored in GCP SecretManager service. - --config-from-git-repo Whether to load Piped config that is being stored in a git repository. - --default-version string The version should be run when no desired version was specified. Empty means using the same version with Launcher. - --gcp-secret-id string The resource ID of secret that contains Piped config in GCP SecretManager service. - --git-branch string Branch of git repository to for Piped config. - --git-piped-config-file string Relative path within git repository to locate Piped config file. - --git-repo-url string The remote URL of git repository to fetch Piped config. - --git-ssh-key-file string The path to SSH private key to fetch private git repository. - --grace-period duration How long to wait for graceful shutdown. (default 30s) - -h, --help help for launcher - --home-dir string The working directory of Launcher. - --insecure Whether disabling transport security while connecting to control-plane. - --launcher-admin-port int The port number used to run a HTTP server for admin tasks such as metrics, healthz. + --aws-secret-id string The ARN of secret that contains Piped config in AWS Secrets Manager service. + --aws-ssm-parameter string The name of parameter of Piped config stored in AWS Systems Manager Parameter Store. SecureString is also supported. + --cert-file string The path to the TLS certificate file. + --check-interval duration Interval to periodically check desired config/version to restart Piped. Default is 1m. (default 1m0s) + --config-data string The base64 encoded string of the configuration data. + --config-file string The path to the configuration file. + --config-from-aws-secret Whether to load Piped config that is being stored in AWS Secrets Manager service. + --config-from-aws-ssm-parameter-store Whether to load Piped config that is being stored in AWS Systems Manager Parameter Store. + --config-from-gcp-secret Whether to load Piped config that is being stored in GCP SecretManager service. + --config-from-git-repo Whether to load Piped config that is being stored in a git repository. + --default-version string The version should be run when no desired version was specified. Empty means using the same version with Launcher. + --gcp-secret-id string The resource ID of secret that contains Piped config in GCP SecretManager service. + --git-branch string Branch of git repository to for Piped config. + --git-piped-config-file string Relative path within git repository to locate Piped config file. + --git-repo-url string The remote URL of git repository to fetch Piped config. + --git-ssh-key-data string Base64 encoded value of SSH private key to fetch Piped config from the private git repository. + --git-ssh-key-file string The path to SSH private key to fetch Piped config from private git repository. + --grace-period duration How long to wait for graceful shutdown. (default 30s) + -h, --help help for launcher + --home-dir string The working directory of Launcher. + --insecure Whether disabling transport security while connecting to control-plane. + --launcher-admin-port int The port number used to run a HTTP server for admin tasks such as metrics, healthz. Global Flags: --log-encoding string The encoding type for logger [json|console|humanize]. (default "humanize") From 70d793dcb0b1635d81a99e398f6705c4cf8b75c0 Mon Sep 17 00:00:00 2001 From: Khanh Tran <32532742+khanhtc1202@users.noreply.github.com> Date: Tue, 8 Oct 2024 12:51:30 +0700 Subject: [PATCH 50/84] Update release docs (#5264) Signed-off-by: khanhtc1202 --- RELEASES.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/RELEASES.md b/RELEASES.md index 331172d8d0..7ee3fc37f6 100644 --- a/RELEASES.md +++ b/RELEASES.md @@ -66,6 +66,13 @@ This may also contain some minor features, but ensure that it does NOT contain a The `RELEASE` file will be updated. +- (Optional) if the patch contains changes with docs update, also need to run `make release/docs` + ```shell + make release/docs version=vX.Y.Z + ``` + + Note: You can use `make release version=vX.Y.Z` command to perform both init and docs sync tasks. + - Push the above changes and create a pull request to `master` to confirm the changelog. - Get a review and merge. From 0e6c745430a6f7113e3fe3730103585fd0946f61 Mon Sep 17 00:00:00 2001 From: Shinnosuke Sawada-Dazai Date: Tue, 8 Oct 2024 15:20:27 +0900 Subject: [PATCH 51/84] Fix the workflow publishes quickstart manifests (#5266) * Add pull-requests permission to the workflow Signed-off-by: Shinnosuke Sawada-Dazai * Set the base branch to the default branch of the repository Signed-off-by: Shinnosuke Sawada-Dazai * Use `github.ref_name` to determine the version of manifests Signed-off-by: Shinnosuke Sawada-Dazai * Checkout master branch to make PR to master branch Signed-off-by: Shinnosuke Sawada-Dazai --------- Signed-off-by: Shinnosuke Sawada-Dazai --- .github/workflows/publish_image_chart.yaml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/publish_image_chart.yaml b/.github/workflows/publish_image_chart.yaml index e3c401483c..c190955da6 100644 --- a/.github/workflows/publish_image_chart.yaml +++ b/.github/workflows/publish_image_chart.yaml @@ -153,6 +153,7 @@ jobs: if: startsWith(github.ref, 'refs/tags/') permissions: contents: write + pull-requests: write steps: # setup tools and repositories - name: Install helm @@ -162,15 +163,14 @@ jobs: - uses: actions/checkout@v3 with: fetch-depth: 0 - - - name: Determine version - run: echo "PIPECD_VERSION=$(git describe --tags --always --abbrev=7)" >> $GITHUB_ENV + ref: master # Building and publishing quickstart manifests. + # we use `github.ref_name` to get the tag name without the `refs/tags/` prefix. - name: Build quickstart manifests run: | - helm template pipecd oci://ghcr.io/pipe-cd/chart/pipecd --version ${{ env.PIPECD_VERSION }} -n pipecd -f quickstart/control-plane-values.yaml > quickstart/manifests/control-plane.yaml - helm template piped oci://ghcr.io/pipe-cd/chart/piped --version ${{ env.PIPECD_VERSION }} -n pipecd --set quickstart.enabled=true --set quickstart.pipedId=\ --set quickstart.pipedKeyData=\ > quickstart/manifests/piped.yaml + helm template pipecd oci://ghcr.io/pipe-cd/chart/pipecd --version ${{ github.ref_name }} -n pipecd -f quickstart/control-plane-values.yaml > quickstart/manifests/control-plane.yaml + helm template piped oci://ghcr.io/pipe-cd/chart/piped --version ${{ github.ref_name }} -n pipecd --set quickstart.enabled=true --set quickstart.pipedId=\ --set quickstart.pipedKeyData=\ > quickstart/manifests/piped.yaml - name: Publish quickstart manifests uses: peter-evans/create-pull-request@v6 with: From 3dbf164e5536c576e0773a3e129152deb4069382 Mon Sep 17 00:00:00 2001 From: Khanh Tran <32532742+khanhtc1202@users.noreply.github.com> Date: Tue, 8 Oct 2024 13:44:41 +0700 Subject: [PATCH 52/84] Update quickstart manifests (#5267) Signed-off-by: khanhtc1202 --- quickstart/manifests/control-plane.yaml | 131 +++++++++++++++++------- quickstart/manifests/piped.yaml | 33 +++--- 2 files changed, 112 insertions(+), 52 deletions(-) diff --git a/quickstart/manifests/control-plane.yaml b/quickstart/manifests/control-plane.yaml index 373ff70135..b6c2b4b9c4 100644 --- a/quickstart/manifests/control-plane.yaml +++ b/quickstart/manifests/control-plane.yaml @@ -5,10 +5,10 @@ kind: Secret metadata: name: pipecd labels: - helm.sh/chart: pipecd-v0.48.6 + helm.sh/chart: pipecd-v0.49.1 app.kubernetes.io/name: pipecd app.kubernetes.io/instance: pipecd - app.kubernetes.io/version: "v0.48.6" + app.kubernetes.io/version: "v0.49.1" app.kubernetes.io/managed-by: Helm type: Opaque data: @@ -22,10 +22,10 @@ kind: ConfigMap metadata: name: pipecd labels: - helm.sh/chart: pipecd-v0.48.6 + helm.sh/chart: pipecd-v0.49.1 app.kubernetes.io/name: pipecd app.kubernetes.io/instance: pipecd - app.kubernetes.io/version: "v0.48.6" + app.kubernetes.io/version: "v0.49.1" app.kubernetes.io/managed-by: Helm data: control-plane-config.yaml: |- @@ -57,10 +57,10 @@ kind: ConfigMap metadata: name: pipecd-gateway-envoy-config labels: - helm.sh/chart: pipecd-v0.48.6 + helm.sh/chart: pipecd-v0.49.1 app.kubernetes.io/name: pipecd app.kubernetes.io/instance: pipecd - app.kubernetes.io/version: "v0.48.6" + app.kubernetes.io/version: "v0.49.1" app.kubernetes.io/managed-by: Helm app.kubernetes.io/component: gateway data: @@ -78,7 +78,7 @@ data: socket_address: address: 0.0.0.0 port_value: 9090 - filter_chains: + filter_chains: # We cannot turn off ext_authz by default, so we have to turn it off in config for each route that doesn't need authz. - filters: - name: envoy.filters.network.http_connection_manager typed_config: @@ -90,13 +90,26 @@ data: typed_config: "@type": type.googleapis.com/envoy.extensions.access_loggers.stream.v3.StdoutAccessLog http_filters: + - name: envoy.filters.http.ext_authz + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthz + grpc_service: + envoy_grpc: + cluster_name: grpc-envoy-ext-authz + timeout: 10s + transport_api_version: V3 + include_peer_certificate: false - name: envoy.filters.http.grpc_web + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.http.grpc_web.v3.GrpcWeb - name: envoy.filters.http.grpc_stats typed_config: "@type": type.googleapis.com/envoy.extensions.filters.http.grpc_stats.v3.FilterConfig stats_for_all_methods: true enable_upstream_stats: true - name: envoy.filters.http.router + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router route_config: name: local_route virtual_hosts: @@ -109,38 +122,66 @@ data: grpc: route: cluster: grpc-piped-service + typed_per_filter_config: + envoy.filters.http.ext_authz: + "@type": type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthzPerRoute + disabled: true - match: prefix: /pipe.api.service.pipedservice.PipedService/ grpc: route: cluster: grpc-piped-service prefix_rewrite: /grpc.service.pipedservice.PipedService/ + typed_per_filter_config: + envoy.filters.http.ext_authz: + "@type": type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthzPerRoute + disabled: true - match: prefix: /grpc.service.webservice.WebService/ grpc: route: cluster: grpc-web-service + typed_per_filter_config: + envoy.filters.http.ext_authz: + "@type": type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthzPerRoute + disabled: true - match: prefix: /pipe.api.service.webservice.WebService/ grpc: route: cluster: grpc-web-service prefix_rewrite: /grpc.service.webservice.WebService/ + typed_per_filter_config: + envoy.filters.http.ext_authz: + "@type": type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthzPerRoute + disabled: true - match: prefix: /grpc.service.apiservice.APIService/ grpc: route: cluster: grpc-api-service + typed_per_filter_config: + envoy.filters.http.ext_authz: + "@type": type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthzPerRoute + disabled: true - match: prefix: /pipe.api.service.apiservice.APIService/ grpc: route: cluster: grpc-api-service prefix_rewrite: /grpc.service.apiservice.APIService/ + typed_per_filter_config: + envoy.filters.http.ext_authz: + "@type": type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthzPerRoute + disabled: true - match: prefix: / route: cluster: server-http + typed_per_filter_config: + envoy.filters.http.ext_authz: + "@type": type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthzPerRoute + disabled: true clusters: - name: grpc-piped-service http2_protocol_options: {} @@ -205,6 +246,22 @@ data: port_value: 9082 track_cluster_stats: request_response_sizes: true + - name: grpc-envoy-ext-authz + http2_protocol_options: {} + connect_timeout: 0.25s + type: STRICT_DNS + lb_policy: ROUND_ROBIN + load_assignment: + cluster_name: grpc-envoy-ext-authz + endpoints: + - lb_endpoints: + - endpoint: + address: + socket_address: + address: pipecd-server + port_value: 9086 + track_cluster_stats: + request_response_sizes: true --- # Source: pipecd/templates/service.yaml apiVersion: v1 @@ -212,10 +269,10 @@ kind: Service metadata: name: pipecd labels: - helm.sh/chart: pipecd-v0.48.6 + helm.sh/chart: pipecd-v0.49.1 app.kubernetes.io/name: pipecd app.kubernetes.io/instance: pipecd - app.kubernetes.io/version: "v0.48.6" + app.kubernetes.io/version: "v0.49.1" app.kubernetes.io/managed-by: Helm app.kubernetes.io/component: ingress annotations: @@ -236,10 +293,10 @@ kind: Service metadata: name: pipecd-gateway labels: - helm.sh/chart: pipecd-v0.48.6 + helm.sh/chart: pipecd-v0.49.1 app.kubernetes.io/name: pipecd app.kubernetes.io/instance: pipecd - app.kubernetes.io/version: "v0.48.6" + app.kubernetes.io/version: "v0.49.1" app.kubernetes.io/managed-by: Helm app.kubernetes.io/component: gateway spec: @@ -259,10 +316,10 @@ kind: Service metadata: name: pipecd-server labels: - helm.sh/chart: pipecd-v0.48.6 + helm.sh/chart: pipecd-v0.49.1 app.kubernetes.io/name: pipecd app.kubernetes.io/instance: pipecd - app.kubernetes.io/version: "v0.48.6" + app.kubernetes.io/version: "v0.49.1" app.kubernetes.io/managed-by: Helm app.kubernetes.io/component: server spec: @@ -295,10 +352,10 @@ kind: Service metadata: name: pipecd-cache labels: - helm.sh/chart: pipecd-v0.48.6 + helm.sh/chart: pipecd-v0.49.1 app.kubernetes.io/name: pipecd app.kubernetes.io/instance: pipecd - app.kubernetes.io/version: "v0.48.6" + app.kubernetes.io/version: "v0.49.1" app.kubernetes.io/managed-by: Helm app.kubernetes.io/component: cache spec: @@ -318,10 +375,10 @@ kind: Service metadata: name: pipecd-ops labels: - helm.sh/chart: pipecd-v0.48.6 + helm.sh/chart: pipecd-v0.49.1 app.kubernetes.io/name: pipecd app.kubernetes.io/instance: pipecd - app.kubernetes.io/version: "v0.48.6" + app.kubernetes.io/version: "v0.49.1" app.kubernetes.io/managed-by: Helm app.kubernetes.io/component: ops spec: @@ -345,10 +402,10 @@ kind: Service metadata: name: pipecd-mysql labels: - helm.sh/chart: pipecd-v0.48.6 + helm.sh/chart: pipecd-v0.49.1 app.kubernetes.io/name: pipecd app.kubernetes.io/instance: pipecd - app.kubernetes.io/version: "v0.48.6" + app.kubernetes.io/version: "v0.49.1" app.kubernetes.io/managed-by: Helm app.kubernetes.io/component: mysql spec: @@ -368,10 +425,10 @@ kind: Service metadata: name: pipecd-minio labels: - helm.sh/chart: pipecd-v0.48.6 + helm.sh/chart: pipecd-v0.49.1 app.kubernetes.io/name: pipecd app.kubernetes.io/instance: pipecd - app.kubernetes.io/version: "v0.48.6" + app.kubernetes.io/version: "v0.49.1" app.kubernetes.io/managed-by: Helm app.kubernetes.io/component: minio spec: @@ -392,10 +449,10 @@ kind: Deployment metadata: name: pipecd-gateway labels: - helm.sh/chart: pipecd-v0.48.6 + helm.sh/chart: pipecd-v0.49.1 app.kubernetes.io/name: pipecd app.kubernetes.io/instance: pipecd - app.kubernetes.io/version: "v0.48.6" + app.kubernetes.io/version: "v0.49.1" app.kubernetes.io/managed-by: Helm app.kubernetes.io/component: gateway spec: @@ -411,10 +468,12 @@ spec: app.kubernetes.io/name: pipecd app.kubernetes.io/instance: pipecd app.kubernetes.io/component: gateway + annotations: + checksum/config: 13ac2a3f8ea383423fa098e13fda490d4c01ea04fcf4de03b4318de43a8a5607 # ref; https://helm.sh/docs/howto/charts_tips_and_tricks/#automatically-roll-deployments spec: containers: - name: envoy - image: envoyproxy/envoy-alpine:v1.18.3 + image: envoyproxy/envoy:v1.31.0 imagePullPolicy: IfNotPresent command: - envoy @@ -460,10 +519,10 @@ kind: Deployment metadata: name: pipecd-server labels: - helm.sh/chart: pipecd-v0.48.6 + helm.sh/chart: pipecd-v0.49.1 app.kubernetes.io/name: pipecd app.kubernetes.io/instance: pipecd - app.kubernetes.io/version: "v0.48.6" + app.kubernetes.io/version: "v0.49.1" app.kubernetes.io/managed-by: Helm app.kubernetes.io/component: server spec: @@ -492,7 +551,7 @@ spec: done; containers: - name: server - image: "ghcr.io/pipe-cd/pipecd:v0.48.6" + image: "ghcr.io/pipe-cd/pipecd:v0.49.1" imagePullPolicy: IfNotPresent args: - server @@ -555,10 +614,10 @@ kind: Deployment metadata: name: pipecd-cache labels: - helm.sh/chart: pipecd-v0.48.6 + helm.sh/chart: pipecd-v0.49.1 app.kubernetes.io/name: pipecd app.kubernetes.io/instance: pipecd - app.kubernetes.io/version: "v0.48.6" + app.kubernetes.io/version: "v0.49.1" app.kubernetes.io/managed-by: Helm app.kubernetes.io/component: cache spec: @@ -591,10 +650,10 @@ kind: Deployment metadata: name: pipecd-ops labels: - helm.sh/chart: pipecd-v0.48.6 + helm.sh/chart: pipecd-v0.49.1 app.kubernetes.io/name: pipecd app.kubernetes.io/instance: pipecd - app.kubernetes.io/version: "v0.48.6" + app.kubernetes.io/version: "v0.49.1" app.kubernetes.io/managed-by: Helm app.kubernetes.io/component: ops spec: @@ -625,7 +684,7 @@ spec: done; containers: - name: ops - image: "ghcr.io/pipe-cd/pipecd:v0.48.6" + image: "ghcr.io/pipe-cd/pipecd:v0.49.1" imagePullPolicy: IfNotPresent args: - ops @@ -671,10 +730,10 @@ kind: Deployment metadata: name: pipecd-mysql labels: - helm.sh/chart: pipecd-v0.48.6 + helm.sh/chart: pipecd-v0.49.1 app.kubernetes.io/name: pipecd app.kubernetes.io/instance: pipecd - app.kubernetes.io/version: "v0.48.6" + app.kubernetes.io/version: "v0.49.1" app.kubernetes.io/managed-by: Helm app.kubernetes.io/component: mysql spec: @@ -712,10 +771,10 @@ kind: Deployment metadata: name: pipecd-minio labels: - helm.sh/chart: pipecd-v0.48.6 + helm.sh/chart: pipecd-v0.49.1 app.kubernetes.io/name: pipecd app.kubernetes.io/instance: pipecd - app.kubernetes.io/version: "v0.48.6" + app.kubernetes.io/version: "v0.49.1" app.kubernetes.io/managed-by: Helm app.kubernetes.io/component: minio spec: diff --git a/quickstart/manifests/piped.yaml b/quickstart/manifests/piped.yaml index 623cfc9c05..1915e1bfec 100644 --- a/quickstart/manifests/piped.yaml +++ b/quickstart/manifests/piped.yaml @@ -5,10 +5,10 @@ kind: ServiceAccount metadata: name: piped labels: - helm.sh/chart: piped-v0.48.6 + helm.sh/chart: piped-v0.49.1 app.kubernetes.io/name: piped app.kubernetes.io/instance: piped - app.kubernetes.io/version: "v0.48.6" + app.kubernetes.io/version: "v0.49.1" app.kubernetes.io/managed-by: Helm --- # Source: piped/templates/secret.yaml @@ -17,10 +17,10 @@ kind: Secret metadata: name: piped labels: - helm.sh/chart: piped-v0.48.6 + helm.sh/chart: piped-v0.49.1 app.kubernetes.io/name: piped app.kubernetes.io/instance: piped - app.kubernetes.io/version: "v0.48.6" + app.kubernetes.io/version: "v0.49.1" app.kubernetes.io/managed-by: Helm type: Opaque data: @@ -31,10 +31,10 @@ kind: ConfigMap metadata: name: piped labels: - helm.sh/chart: piped-v0.48.6 + helm.sh/chart: piped-v0.49.1 app.kubernetes.io/name: piped app.kubernetes.io/instance: piped - app.kubernetes.io/version: "v0.48.6" + app.kubernetes.io/version: "v0.49.1" app.kubernetes.io/managed-by: Helm data: piped-config.yaml: |- @@ -57,10 +57,10 @@ kind: ClusterRole metadata: name: piped labels: - helm.sh/chart: piped-v0.48.6 + helm.sh/chart: piped-v0.49.1 app.kubernetes.io/name: piped app.kubernetes.io/instance: piped - app.kubernetes.io/version: "v0.48.6" + app.kubernetes.io/version: "v0.49.1" app.kubernetes.io/managed-by: Helm rules: @@ -81,10 +81,10 @@ kind: ClusterRoleBinding metadata: name: piped labels: - helm.sh/chart: piped-v0.48.6 + helm.sh/chart: piped-v0.49.1 app.kubernetes.io/name: piped app.kubernetes.io/instance: piped - app.kubernetes.io/version: "v0.48.6" + app.kubernetes.io/version: "v0.49.1" app.kubernetes.io/managed-by: Helm roleRef: apiGroup: rbac.authorization.k8s.io @@ -101,10 +101,10 @@ kind: Service metadata: name: piped labels: - helm.sh/chart: piped-v0.48.6 + helm.sh/chart: piped-v0.49.1 app.kubernetes.io/name: piped app.kubernetes.io/instance: piped - app.kubernetes.io/version: "v0.48.6" + app.kubernetes.io/version: "v0.49.1" app.kubernetes.io/managed-by: Helm spec: type: ClusterIP @@ -122,10 +122,10 @@ kind: Deployment metadata: name: piped labels: - helm.sh/chart: piped-v0.48.6 + helm.sh/chart: piped-v0.49.1 app.kubernetes.io/name: piped app.kubernetes.io/instance: piped - app.kubernetes.io/version: "v0.48.6" + app.kubernetes.io/version: "v0.49.1" app.kubernetes.io/managed-by: Helm spec: replicas: 1 @@ -142,13 +142,13 @@ spec: app.kubernetes.io/instance: piped annotations: sidecar.istio.io/inject: "false" - rollme: "F9ME0" + rollme: "qoHIs" spec: serviceAccountName: piped containers: - name: piped imagePullPolicy: IfNotPresent - image: "ghcr.io/pipe-cd/piped:v0.48.6" + image: "ghcr.io/pipe-cd/piped:v0.49.1" args: - piped - --config-file=/etc/piped-config/piped-config.yaml @@ -157,6 +157,7 @@ spec: - --log-encoding=humanize - --log-level=info - --add-login-user-to-passwd=false + - --app-manifest-cache-count=150 - --insecure=true ports: - name: admin From 4b2499ee0ced39ddc5279f5034a1989763ca068a Mon Sep 17 00:00:00 2001 From: Khanh Tran <32532742+khanhtc1202@users.noreply.github.com> Date: Wed, 9 Oct 2024 10:07:50 +0700 Subject: [PATCH 53/84] Remove pipectl quickstart command (#5268) * Remove pipectl quickstart command Signed-off-by: khanhtc1202 * Remove the quickstart from docs Signed-off-by: khanhtc1202 --------- Signed-off-by: khanhtc1202 --- cmd/pipectl/main.go | 2 - .../docs-dev/user-guide/command-line-tool.md | 1 - .../user-guide/command-line-tool.md | 1 - pkg/app/pipectl/cmd/quickstart/quickstart.go | 486 ------------------ pkg/app/pipectl/cmd/quickstart/tool_darwin.go | 21 - pkg/app/pipectl/cmd/quickstart/tool_linux.go | 21 - 6 files changed, 532 deletions(-) delete mode 100644 pkg/app/pipectl/cmd/quickstart/quickstart.go delete mode 100644 pkg/app/pipectl/cmd/quickstart/tool_darwin.go delete mode 100644 pkg/app/pipectl/cmd/quickstart/tool_linux.go diff --git a/cmd/pipectl/main.go b/cmd/pipectl/main.go index 0aaf9449f5..95ad8c819d 100644 --- a/cmd/pipectl/main.go +++ b/cmd/pipectl/main.go @@ -24,7 +24,6 @@ import ( "github.com/pipe-cd/pipecd/pkg/app/pipectl/cmd/initialize" "github.com/pipe-cd/pipecd/pkg/app/pipectl/cmd/piped" "github.com/pipe-cd/pipecd/pkg/app/pipectl/cmd/planpreview" - "github.com/pipe-cd/pipecd/pkg/app/pipectl/cmd/quickstart" "github.com/pipe-cd/pipecd/pkg/cli" ) @@ -41,7 +40,6 @@ func main() { planpreview.NewCommand(), piped.NewCommand(), encrypt.NewCommand(), - quickstart.NewCommand(), initialize.NewCommand(), ) diff --git a/docs/content/en/docs-dev/user-guide/command-line-tool.md b/docs/content/en/docs-dev/user-guide/command-line-tool.md index 8c8450ee52..c823e35ab1 100644 --- a/docs/content/en/docs-dev/user-guide/command-line-tool.md +++ b/docs/content/en/docs-dev/user-guide/command-line-tool.md @@ -151,7 +151,6 @@ Available Commands: init Generate an application config (app.pipecd.yaml) easily and interactively. piped Manage piped resources. plan-preview Show plan preview against the specified commit. - quickstart Quick prepare PipeCD control plane in quickstart mode. version Print the information of current binary. Flags: diff --git a/docs/content/en/docs-v0.49.x/user-guide/command-line-tool.md b/docs/content/en/docs-v0.49.x/user-guide/command-line-tool.md index 8c8450ee52..c823e35ab1 100644 --- a/docs/content/en/docs-v0.49.x/user-guide/command-line-tool.md +++ b/docs/content/en/docs-v0.49.x/user-guide/command-line-tool.md @@ -151,7 +151,6 @@ Available Commands: init Generate an application config (app.pipecd.yaml) easily and interactively. piped Manage piped resources. plan-preview Show plan preview against the specified commit. - quickstart Quick prepare PipeCD control plane in quickstart mode. version Print the information of current binary. Flags: diff --git a/pkg/app/pipectl/cmd/quickstart/quickstart.go b/pkg/app/pipectl/cmd/quickstart/quickstart.go deleted file mode 100644 index eca1bbd8d8..0000000000 --- a/pkg/app/pipectl/cmd/quickstart/quickstart.go +++ /dev/null @@ -1,486 +0,0 @@ -// 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 quickstart - -import ( - "bytes" - "context" - "errors" - "fmt" - "html/template" - "os" - "os/exec" - "path" - "runtime" - "strings" - "sync" - "time" - - "github.com/manifoldco/promptui" - "github.com/spf13/cobra" - - "github.com/pipe-cd/pipecd/pkg/backoff" - "github.com/pipe-cd/pipecd/pkg/cli" - "github.com/pipe-cd/pipecd/pkg/version" -) - -const ( - defaultHelmVersion = "3.8.2" - - helmControlPlaneReleaseName = "pipecd" - helmPipedReleaseName = "piped" - - helmChartControlPlaneRepoName = "oci://ghcr.io/pipe-cd/chart/pipecd" - helmChartPipedRepoName = "oci://ghcr.io/pipe-cd/chart/piped" - - helmQuickstartValueRemotePath = "https://raw.githubusercontent.com/pipe-cd/pipecd/%s/quickstart/control-plane-values.yaml" - - pipecdDefaultNamespace = "pipecd" - - controlPlaneLocalhost = "http://localhost:8080/settings/piped?project=quickstart" - - pipedIDLabel = "ID" - pipedKeyLabel = "Key" - pipedGitRemoteRepo = "GitRemoteRepo" - - deploymentReadyRetryTime = 3 - deploymentReadyRetryDuration = time.Minute - deploymentReadyCheckDuration = 5 * time.Second -) - -type command struct { - version string - toolsDir string - namespace string - - uninstall bool -} - -func NewCommand() *cobra.Command { - home, err := os.UserHomeDir() - if err != nil { - panic(fmt.Sprintf("failed to detect the current user's home directory: %v", err)) - } - - defaultToolsDir := path.Join(home, ".pipectl", "tools") - if err = os.MkdirAll(defaultToolsDir, 0755); err != nil { - panic(fmt.Sprintf("failed to prepare tools dir: %v", err)) - } - - c := &command{ - version: version.Get().Version, - toolsDir: defaultToolsDir, - namespace: pipecdDefaultNamespace, - } - - cmd := &cobra.Command{ - Use: "quickstart", - Short: "Quick prepare PipeCD control plane in quickstart mode.", - Long: "Quick prepare PipeCD control plane in quickstart mode.\nTo install PipeCD control plane for real-life usage, please read the docs: https://pipecd.dev/docs/installation/install-controlplane", - RunE: cli.WithContext(c.run), - } - - cmd.Flags().StringVar(&c.version, "version", c.version, "The Control Plane version. Default is the version of pipectl.") - cmd.Flags().StringVar(&c.toolsDir, "tools-dir", c.toolsDir, "The path to directory where to install tools such as helm.") - cmd.Flags().StringVar(&c.namespace, "namespace", c.namespace, "The Kubernetes cluster namespace where to install Control Plane.") - - cmd.Flags().BoolVar(&c.uninstall, "uninstall", c.uninstall, "Uninstall the quickstart mode installed PipeCD control plane.") - - return cmd -} - -func (c *command) run(ctx context.Context, input cli.Input) error { - helm, err := c.getHelm(ctx) - if err != nil { - return fmt.Errorf("failed to prepare required tools (helm) for installation: %v", err) - } - - if c.uninstall { - return c.uninstallAll(ctx, helm, input) - } - - if err = c.installControlPlane(ctx, helm, input); err != nil { - input.Logger.Error("Failed to install PipeCD control plane!!") - return err - } - - var wg sync.WaitGroup - if err = c.exposeService(ctx, &wg, input); err != nil { - input.Logger.Error("Failed to expose PipeCD control plane service!!") - return err - } - - if err = c.installPiped(ctx, helm, input); err != nil { - input.Logger.Error("Failed to install piped!!") - return err - } - - input.Logger.Info("\nPipeCD console is ready at http://localhost:8080/") - - // Wait until users hit SIG_KILL. - wg.Wait() - - return nil -} - -func (c *command) installControlPlane(ctx context.Context, helm string, input cli.Input) error { - input.Logger.Info("Installing the controlplane in quickstart mode...") - - args := []string{ - "upgrade", - "--install", - helmControlPlaneReleaseName, - helmChartControlPlaneRepoName, - "--version", - c.version, - "--namespace", - c.namespace, - "--create-namespace", - "--values", - fmt.Sprintf(helmQuickstartValueRemotePath, c.version), - "--set", - fmt.Sprintf("mysql.image=%s", selectMySQLImage()), - } - - var stderr, stdout bytes.Buffer - cmd := exec.CommandContext(ctx, helm, args...) - cmd.Stderr = &stderr - cmd.Stdout = &stdout - - if err := cmd.Run(); err != nil { - return fmt.Errorf("%w: %s", err, stderr.String()) - } - - input.Logger.Info(stdout.String()) - input.Logger.Info("Intalled the controlplane successfully!") - - return nil -} - -func (c *command) installPiped(ctx context.Context, helm string, input cli.Input) error { - input.Logger.Info("\nInstalling the piped for quickstart...") - - input.Logger.Info("\nOpenning PipeCD control plane at http://localhost:8080/\nPlease login using the following account:\n- Username: hello-pipecd\n- Password: hello-pipecd\nFor more information refer to https://pipecd.dev/docs/quickstart/\n") - - if err := openbrowser(controlPlaneLocalhost); err != nil { - return fmt.Errorf("failed to open PipeCD control plane: %w", err) - } - - input.Logger.Info("Fill up your registered Piped information:") - - pipedID := getPromptInput(pipedIDLabel) - pipedKey := getPromptInput(pipedKeyLabel) - sourceRepo := getPromptInput(pipedGitRemoteRepo) - - args := []string{ - "upgrade", - "--install", - helmPipedReleaseName, - helmChartPipedRepoName, - "--version", - c.version, - "--namespace", - c.namespace, - "--set", - "quickstart.enabled=true", - "--set", - fmt.Sprintf("quickstart.pipedId=%s", pipedID), - "--set", - fmt.Sprintf("secret.data.piped-key=%s", pipedKey), - "--set", - fmt.Sprintf("quickstart.gitRepoRemote=%s", sourceRepo), - } - - var stderr, stdout bytes.Buffer - cmd := exec.CommandContext(ctx, helm, args...) - cmd.Stderr = &stderr - cmd.Stdout = &stdout - - if err := cmd.Run(); err != nil { - return fmt.Errorf("%w: %s", err, stderr.String()) - } - - input.Logger.Info(stdout.String()) - input.Logger.Info("Intalled the piped successfully!") - - return nil -} - -func (c *command) printExposeState(ctx context.Context, input cli.Input) { - binName := "bash" - epath, err := exec.LookPath(binName) - if err != nil { - return - } - - if epath != "" { - binName = epath - } - kubectl, _ := c.getKubectl() - cmdText := fmt.Sprintf("%s -n %s", kubectl, c.namespace) + - ` get pods --no-headers | awk '{print $3}' | sort | uniq -c | awk '{total+=$1; statuses[$2]=$1} END {for (status in statuses) printf " %s %s", status, statuses[status]}'` - args := []string{ - "-c", - cmdText, - } - cmd := exec.CommandContext(ctx, binName, args...) - var stdout bytes.Buffer - cmd.Stdout = &stdout - cmd.Run() - input.Logger.Sugar().Infof("PipeCD control plane status:%s", stdout.String()) -} - -func (c *command) exposeService(ctx context.Context, wg *sync.WaitGroup, input cli.Input) error { - input.Logger.Info("\nWaiting for PipeCD control plane to be ready...") - notify := make(chan struct{}) - go func() { - ticker := time.NewTicker(deploymentReadyCheckDuration) - for { - select { - case <-ticker.C: - c.printExposeState(ctx, input) - case <-notify: - return - } - } - }() - defer close(notify) - kubectl, err := c.getKubectl() - if err != nil { - return fmt.Errorf("failed to prepare required tool (kubectl) for installation: %v", err) - } - - // Wait the control plane service to be ready. - args := []string{ - "rollout", - "status", - "deploy/pipecd-server", - "-n", - c.namespace, - } - var stdout bytes.Buffer - cmd := exec.CommandContext(ctx, kubectl, args...) - cmd.Stdout = &stdout - - retry := backoff.NewRetry(deploymentReadyRetryTime, backoff.NewConstant(deploymentReadyRetryDuration)) - var serverIsReady bool - for retry.WaitNext(ctx) { - cmd.Run() - if strings.Contains(stdout.String(), "successfully rolled out") { - notify <- struct{}{} - serverIsReady = true - break - } - } - - if !serverIsReady { - return fmt.Errorf("failed while waiting for server to be ready") - } - - // Expose the PipeCD control plane to localhost:8080. - args = []string{ - "port-forward", - "svc/pipecd", - "8080", - "-n", - c.namespace, - } - var stderr bytes.Buffer - cmd = exec.CommandContext(ctx, kubectl, args...) - cmd.Stderr = &stderr - - wg.Add(1) - go func() { - cmd.Run() - defer wg.Done() - }() - - return nil -} - -func getPromptInput(label string) string { - validate := func(input string) error { - switch label { - case pipedIDLabel: - if len(input) != 36 { - return fmt.Errorf("invalid ID") - } - case pipedKeyLabel: - if len(input) != 50 { - return fmt.Errorf("invalid Key") - } - default: - if len(input) == 0 { - return fmt.Errorf("missing value: %s", label) - } - } - return nil - } - - prompt := promptui.Prompt{ - Label: label, - Validate: validate, - } - - result, err := prompt.Run() - if err != nil { - return "" - } - return result -} - -func openbrowser(url string) error { - var err error - switch runtime.GOOS { - case "linux": - err = exec.Command("xdg-open", url).Start() - case "darwin": - err = exec.Command("open", url).Start() - default: - err = fmt.Errorf("unsupported platform: %s", runtime.GOOS) - } - return err -} - -func selectMySQLImage() string { - var mysqlImage string - switch runtime.GOARCH { - case "amd64": - mysqlImage = "mysql" - case "arm64": - mysqlImage = "arm64v8/mysql" - default: - mysqlImage = "mysql" - } - return mysqlImage -} - -func (c *command) uninstallAll(ctx context.Context, helm string, input cli.Input) error { - input.Logger.Info("Uninstalling PipeCD components...") - - var stderr, stdout bytes.Buffer - - // Uninstall PipeCD control plane. - args := []string{ - "uninstall", - helmControlPlaneReleaseName, - "--namespace", - c.namespace, - } - - cmd := exec.CommandContext(ctx, helm, args...) - cmd.Stderr = &stderr - cmd.Stdout = &stdout - - if err := cmd.Run(); err != nil { - return fmt.Errorf("%w: %s", err, stderr.String()) - } - - // Uninstall Piped. - args = []string{ - "uninstall", - helmPipedReleaseName, - "--namespace", - c.namespace, - } - - cmd = exec.CommandContext(ctx, helm, args...) - cmd.Stderr = &stderr - cmd.Stdout = &stdout - - if err := cmd.Run(); err != nil { - return fmt.Errorf("%w: %s", err, stderr.String()) - } - - input.Logger.Info(stdout.String()) - input.Logger.Info("Uninstalled the PipeCD components successfully!") - - return nil -} - -func (c *command) getKubectl() (string, error) { - binName := "kubectl" - - fi, err := os.Stat(path.Join(c.toolsDir, binName)) - if err != nil && !os.IsNotExist(err) { - return "", err - } - - if fi != nil { - return path.Join(c.toolsDir, binName), nil - } - - epath, err := exec.LookPath(binName) - if err != nil && !errors.Is(err, exec.ErrNotFound) { - return "", err - } - - if epath != "" { - return epath, nil - } - - return "", fmt.Errorf("%s not found", binName) -} - -// getHelm finds and returns helm executable binary in the following priority: -// 1. pre-installed in command specified toolsDir (default is $HOME/.pipectl/tools) -// 2. $PATH -// 3. install new helm to command specified toolsDir -func (c *command) getHelm(ctx context.Context) (string, error) { - binName := "helm" - - fi, err := os.Stat(path.Join(c.toolsDir, binName)) - if err != nil && !os.IsNotExist(err) { - return "", err - } - - // If the Helm executable binary exists in tools dir, use it. - if fi != nil { - return path.Join(c.toolsDir, binName), nil - } - - // If the Helm executable binary exists in $PATH, use it. - epath, err := exec.LookPath(binName) - if err != nil && !errors.Is(err, exec.ErrNotFound) { - return "", err - } - - if epath != "" { - return epath, nil - } - - // Install helm to command toolsDir. - helmInstallScriptTmpl := template.Must(template.New("helm").Parse(helmInstallScript)) - var ( - buf bytes.Buffer - data = map[string]interface{}{ - "Version": defaultHelmVersion, - "BinDir": c.toolsDir, - } - ) - if err := helmInstallScriptTmpl.Execute(&buf, data); err != nil { - return "", fmt.Errorf("failed to install helm %s (%v)", defaultHelmVersion, err) - } - - var ( - script = buf.String() - cmd = exec.CommandContext(ctx, "/bin/sh", "-c", script) - ) - if _, err := cmd.CombinedOutput(); err != nil { - return "", fmt.Errorf("failed to install helm %s (%v)", defaultHelmVersion, err) - } - - return path.Join(c.toolsDir, binName), nil -} diff --git a/pkg/app/pipectl/cmd/quickstart/tool_darwin.go b/pkg/app/pipectl/cmd/quickstart/tool_darwin.go deleted file mode 100644 index f5982e424d..0000000000 --- a/pkg/app/pipectl/cmd/quickstart/tool_darwin.go +++ /dev/null @@ -1,21 +0,0 @@ -// 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 quickstart - -var helmInstallScript = ` -curl -L https://get.helm.sh/helm-v{{ .Version }}-darwin-amd64.tar.gz | tar xvz -C {{ .BinDir }} -mv {{ .BinDir }}/darwin-amd64/helm {{ .BinDir }}/helm -chmod +x {{ .BinDir }}/helm -` diff --git a/pkg/app/pipectl/cmd/quickstart/tool_linux.go b/pkg/app/pipectl/cmd/quickstart/tool_linux.go deleted file mode 100644 index e9d8831ef7..0000000000 --- a/pkg/app/pipectl/cmd/quickstart/tool_linux.go +++ /dev/null @@ -1,21 +0,0 @@ -// 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 quickstart - -var helmInstallScript = ` -curl -L https://get.helm.sh/helm-v{{ .Version }}-linux-amd64.tar.gz | tar xvz -C {{ .BinDir }} -mv {{ .BinDir }}/linux-amd64/helm {{ .BinDir }}/helm -chmod +x {{ .BinDir }}/helm -` From f38cd8469fd4444b68fa7dd9aebacbdcce21e3c4 Mon Sep 17 00:00:00 2001 From: Tetsuya Kikuchi <97105818+t-kikuc@users.noreply.github.com> Date: Wed, 9 Oct 2024 12:14:38 +0900 Subject: [PATCH 54/84] Enabled to configure the interval of livestate store for Lambda (#5269) * add liveStateInterval for Lambda Signed-off-by: t-kikuc * add docs of liveStateInterval Signed-off-by: t-kikuc * fix a test Signed-off-by: t-kikuc * Rename to 'awsAPIPollingInterval' Signed-off-by: t-kikuc --------- Signed-off-by: t-kikuc --- .../user-guide/managing-piped/configuration-reference.md | 1 + pkg/app/piped/livestatestore/lambda/lambda.go | 2 +- pkg/config/piped.go | 6 ++++++ pkg/config/piped_test.go | 3 ++- 4 files changed, 10 insertions(+), 2 deletions(-) diff --git a/docs/content/en/docs-dev/user-guide/managing-piped/configuration-reference.md b/docs/content/en/docs-dev/user-guide/managing-piped/configuration-reference.md index c65dcf1352..534a310007 100644 --- a/docs/content/en/docs-dev/user-guide/managing-piped/configuration-reference.md +++ b/docs/content/en/docs-dev/user-guide/managing-piped/configuration-reference.md @@ -130,6 +130,7 @@ Must be one of the following structs: | roleARN | string | The IAM role arn to use when assuming an role. Required if you want to use the AWS SecurityTokenService. | No | | tokenFile | string | The path to the WebIdentity token the SDK should use to assume a role with. Required if you want to use the AWS SecurityTokenService. | No | | profile | string | The profile to use for logging into AWS cluster. The default value is `default`. | No | +| awsAPIPollingInterval | duration | The interval of periodical calls of AWS APIs. Currently, this is an interval of refreshing the live state of Lambda functions. Default is 15s. | No | ### PlatformProviderECSConfig diff --git a/pkg/app/piped/livestatestore/lambda/lambda.go b/pkg/app/piped/livestatestore/lambda/lambda.go index 6f150b86ef..e547e37292 100644 --- a/pkg/app/piped/livestatestore/lambda/lambda.go +++ b/pkg/app/piped/livestatestore/lambda/lambda.go @@ -58,7 +58,7 @@ func NewStore(cfg *config.PlatformProviderLambdaConfig, platformProvider string, client: client, logger: logger.Named("store"), }, - interval: 15 * time.Second, + interval: time.Duration(cfg.AwsAPIPollingInterval), logger: logger, firstSyncedCh: make(chan error, 1), } diff --git a/pkg/config/piped.go b/pkg/config/piped.go index 7a48d732ad..a08f36438d 100644 --- a/pkg/config/piped.go +++ b/pkg/config/piped.go @@ -712,6 +712,12 @@ type PlatformProviderLambdaConfig struct { // 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"` + // The interval of periodical calls of AWS APIs. + // Currently this is used for live state of Lambda functions, + // but in the future, this might also be used for other polling features. + // Default is 15s. + // To reduce AWS API calls, this interval should be larger. + AwsAPIPollingInterval Duration `json:"awsAPIPollingInterval,omitempty" default:"15s"` } func (c *PlatformProviderLambdaConfig) Mask() { diff --git a/pkg/config/piped_test.go b/pkg/config/piped_test.go index a5f4f2694c..cabc18cb47 100644 --- a/pkg/config/piped_test.go +++ b/pkg/config/piped_test.go @@ -146,7 +146,8 @@ func TestPipedConfig(t *testing.T) { Name: "lambda", Type: model.PlatformProviderLambda, LambdaConfig: &PlatformProviderLambdaConfig{ - Region: "us-east-1", + Region: "us-east-1", + AwsAPIPollingInterval: Duration(15 * time.Second), }, }, }, From 9c8f33d2604c31694c747f1fd5c7326af913db9d Mon Sep 17 00:00:00 2001 From: Tetsuya Kikuchi <97105818+t-kikuc@users.noreply.github.com> Date: Wed, 9 Oct 2024 14:27:17 +0900 Subject: [PATCH 55/84] Update RELEASE to v0.49.2 and sync docs of v0.49.x (#5272) Signed-off-by: t-kikuc --- RELEASE | 2 +- .../user-guide/managing-piped/configuration-reference.md | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/RELEASE b/RELEASE index 45841f2604..8ac900219f 100644 --- a/RELEASE +++ b/RELEASE @@ -1,6 +1,6 @@ # Generated by `make release` command. # DO NOT EDIT. -tag: v0.49.1 +tag: v0.49.2 releaseNoteGenerator: showCommitter: false diff --git a/docs/content/en/docs-v0.49.x/user-guide/managing-piped/configuration-reference.md b/docs/content/en/docs-v0.49.x/user-guide/managing-piped/configuration-reference.md index c65dcf1352..534a310007 100644 --- a/docs/content/en/docs-v0.49.x/user-guide/managing-piped/configuration-reference.md +++ b/docs/content/en/docs-v0.49.x/user-guide/managing-piped/configuration-reference.md @@ -130,6 +130,7 @@ Must be one of the following structs: | roleARN | string | The IAM role arn to use when assuming an role. Required if you want to use the AWS SecurityTokenService. | No | | tokenFile | string | The path to the WebIdentity token the SDK should use to assume a role with. Required if you want to use the AWS SecurityTokenService. | No | | profile | string | The profile to use for logging into AWS cluster. The default value is `default`. | No | +| awsAPIPollingInterval | duration | The interval of periodical calls of AWS APIs. Currently, this is an interval of refreshing the live state of Lambda functions. Default is 15s. | No | ### PlatformProviderECSConfig From 4824dc210dbce9b6b5c7c56a1da91ad12601011d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 9 Oct 2024 06:17:15 +0000 Subject: [PATCH 56/84] [bot] Publish quickstart manifests (#5274) Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: t-kikuc <97105818+t-kikuc@users.noreply.github.com> --- quickstart/manifests/control-plane.yaml | 70 ++++++++++++------------- quickstart/manifests/piped.yaml | 32 +++++------ 2 files changed, 51 insertions(+), 51 deletions(-) diff --git a/quickstart/manifests/control-plane.yaml b/quickstart/manifests/control-plane.yaml index b6c2b4b9c4..5db47ecf80 100644 --- a/quickstart/manifests/control-plane.yaml +++ b/quickstart/manifests/control-plane.yaml @@ -5,10 +5,10 @@ kind: Secret metadata: name: pipecd labels: - helm.sh/chart: pipecd-v0.49.1 + helm.sh/chart: pipecd-v0.49.2 app.kubernetes.io/name: pipecd app.kubernetes.io/instance: pipecd - app.kubernetes.io/version: "v0.49.1" + app.kubernetes.io/version: "v0.49.2" app.kubernetes.io/managed-by: Helm type: Opaque data: @@ -22,10 +22,10 @@ kind: ConfigMap metadata: name: pipecd labels: - helm.sh/chart: pipecd-v0.49.1 + helm.sh/chart: pipecd-v0.49.2 app.kubernetes.io/name: pipecd app.kubernetes.io/instance: pipecd - app.kubernetes.io/version: "v0.49.1" + app.kubernetes.io/version: "v0.49.2" app.kubernetes.io/managed-by: Helm data: control-plane-config.yaml: |- @@ -57,10 +57,10 @@ kind: ConfigMap metadata: name: pipecd-gateway-envoy-config labels: - helm.sh/chart: pipecd-v0.49.1 + helm.sh/chart: pipecd-v0.49.2 app.kubernetes.io/name: pipecd app.kubernetes.io/instance: pipecd - app.kubernetes.io/version: "v0.49.1" + app.kubernetes.io/version: "v0.49.2" app.kubernetes.io/managed-by: Helm app.kubernetes.io/component: gateway data: @@ -269,10 +269,10 @@ kind: Service metadata: name: pipecd labels: - helm.sh/chart: pipecd-v0.49.1 + helm.sh/chart: pipecd-v0.49.2 app.kubernetes.io/name: pipecd app.kubernetes.io/instance: pipecd - app.kubernetes.io/version: "v0.49.1" + app.kubernetes.io/version: "v0.49.2" app.kubernetes.io/managed-by: Helm app.kubernetes.io/component: ingress annotations: @@ -293,10 +293,10 @@ kind: Service metadata: name: pipecd-gateway labels: - helm.sh/chart: pipecd-v0.49.1 + helm.sh/chart: pipecd-v0.49.2 app.kubernetes.io/name: pipecd app.kubernetes.io/instance: pipecd - app.kubernetes.io/version: "v0.49.1" + app.kubernetes.io/version: "v0.49.2" app.kubernetes.io/managed-by: Helm app.kubernetes.io/component: gateway spec: @@ -316,10 +316,10 @@ kind: Service metadata: name: pipecd-server labels: - helm.sh/chart: pipecd-v0.49.1 + helm.sh/chart: pipecd-v0.49.2 app.kubernetes.io/name: pipecd app.kubernetes.io/instance: pipecd - app.kubernetes.io/version: "v0.49.1" + app.kubernetes.io/version: "v0.49.2" app.kubernetes.io/managed-by: Helm app.kubernetes.io/component: server spec: @@ -352,10 +352,10 @@ kind: Service metadata: name: pipecd-cache labels: - helm.sh/chart: pipecd-v0.49.1 + helm.sh/chart: pipecd-v0.49.2 app.kubernetes.io/name: pipecd app.kubernetes.io/instance: pipecd - app.kubernetes.io/version: "v0.49.1" + app.kubernetes.io/version: "v0.49.2" app.kubernetes.io/managed-by: Helm app.kubernetes.io/component: cache spec: @@ -375,10 +375,10 @@ kind: Service metadata: name: pipecd-ops labels: - helm.sh/chart: pipecd-v0.49.1 + helm.sh/chart: pipecd-v0.49.2 app.kubernetes.io/name: pipecd app.kubernetes.io/instance: pipecd - app.kubernetes.io/version: "v0.49.1" + app.kubernetes.io/version: "v0.49.2" app.kubernetes.io/managed-by: Helm app.kubernetes.io/component: ops spec: @@ -402,10 +402,10 @@ kind: Service metadata: name: pipecd-mysql labels: - helm.sh/chart: pipecd-v0.49.1 + helm.sh/chart: pipecd-v0.49.2 app.kubernetes.io/name: pipecd app.kubernetes.io/instance: pipecd - app.kubernetes.io/version: "v0.49.1" + app.kubernetes.io/version: "v0.49.2" app.kubernetes.io/managed-by: Helm app.kubernetes.io/component: mysql spec: @@ -425,10 +425,10 @@ kind: Service metadata: name: pipecd-minio labels: - helm.sh/chart: pipecd-v0.49.1 + helm.sh/chart: pipecd-v0.49.2 app.kubernetes.io/name: pipecd app.kubernetes.io/instance: pipecd - app.kubernetes.io/version: "v0.49.1" + app.kubernetes.io/version: "v0.49.2" app.kubernetes.io/managed-by: Helm app.kubernetes.io/component: minio spec: @@ -449,10 +449,10 @@ kind: Deployment metadata: name: pipecd-gateway labels: - helm.sh/chart: pipecd-v0.49.1 + helm.sh/chart: pipecd-v0.49.2 app.kubernetes.io/name: pipecd app.kubernetes.io/instance: pipecd - app.kubernetes.io/version: "v0.49.1" + app.kubernetes.io/version: "v0.49.2" app.kubernetes.io/managed-by: Helm app.kubernetes.io/component: gateway spec: @@ -469,7 +469,7 @@ spec: app.kubernetes.io/instance: pipecd app.kubernetes.io/component: gateway annotations: - checksum/config: 13ac2a3f8ea383423fa098e13fda490d4c01ea04fcf4de03b4318de43a8a5607 # ref; https://helm.sh/docs/howto/charts_tips_and_tricks/#automatically-roll-deployments + checksum/config: ac80f8a16c58c8ffa66de6834af83e26be00331ccd2e02990fd0d4df72a17088 # ref; https://helm.sh/docs/howto/charts_tips_and_tricks/#automatically-roll-deployments spec: containers: - name: envoy @@ -519,10 +519,10 @@ kind: Deployment metadata: name: pipecd-server labels: - helm.sh/chart: pipecd-v0.49.1 + helm.sh/chart: pipecd-v0.49.2 app.kubernetes.io/name: pipecd app.kubernetes.io/instance: pipecd - app.kubernetes.io/version: "v0.49.1" + app.kubernetes.io/version: "v0.49.2" app.kubernetes.io/managed-by: Helm app.kubernetes.io/component: server spec: @@ -551,7 +551,7 @@ spec: done; containers: - name: server - image: "ghcr.io/pipe-cd/pipecd:v0.49.1" + image: "ghcr.io/pipe-cd/pipecd:v0.49.2" imagePullPolicy: IfNotPresent args: - server @@ -614,10 +614,10 @@ kind: Deployment metadata: name: pipecd-cache labels: - helm.sh/chart: pipecd-v0.49.1 + helm.sh/chart: pipecd-v0.49.2 app.kubernetes.io/name: pipecd app.kubernetes.io/instance: pipecd - app.kubernetes.io/version: "v0.49.1" + app.kubernetes.io/version: "v0.49.2" app.kubernetes.io/managed-by: Helm app.kubernetes.io/component: cache spec: @@ -650,10 +650,10 @@ kind: Deployment metadata: name: pipecd-ops labels: - helm.sh/chart: pipecd-v0.49.1 + helm.sh/chart: pipecd-v0.49.2 app.kubernetes.io/name: pipecd app.kubernetes.io/instance: pipecd - app.kubernetes.io/version: "v0.49.1" + app.kubernetes.io/version: "v0.49.2" app.kubernetes.io/managed-by: Helm app.kubernetes.io/component: ops spec: @@ -684,7 +684,7 @@ spec: done; containers: - name: ops - image: "ghcr.io/pipe-cd/pipecd:v0.49.1" + image: "ghcr.io/pipe-cd/pipecd:v0.49.2" imagePullPolicy: IfNotPresent args: - ops @@ -730,10 +730,10 @@ kind: Deployment metadata: name: pipecd-mysql labels: - helm.sh/chart: pipecd-v0.49.1 + helm.sh/chart: pipecd-v0.49.2 app.kubernetes.io/name: pipecd app.kubernetes.io/instance: pipecd - app.kubernetes.io/version: "v0.49.1" + app.kubernetes.io/version: "v0.49.2" app.kubernetes.io/managed-by: Helm app.kubernetes.io/component: mysql spec: @@ -771,10 +771,10 @@ kind: Deployment metadata: name: pipecd-minio labels: - helm.sh/chart: pipecd-v0.49.1 + helm.sh/chart: pipecd-v0.49.2 app.kubernetes.io/name: pipecd app.kubernetes.io/instance: pipecd - app.kubernetes.io/version: "v0.49.1" + app.kubernetes.io/version: "v0.49.2" app.kubernetes.io/managed-by: Helm app.kubernetes.io/component: minio spec: diff --git a/quickstart/manifests/piped.yaml b/quickstart/manifests/piped.yaml index 1915e1bfec..e9e43fdca0 100644 --- a/quickstart/manifests/piped.yaml +++ b/quickstart/manifests/piped.yaml @@ -5,10 +5,10 @@ kind: ServiceAccount metadata: name: piped labels: - helm.sh/chart: piped-v0.49.1 + helm.sh/chart: piped-v0.49.2 app.kubernetes.io/name: piped app.kubernetes.io/instance: piped - app.kubernetes.io/version: "v0.49.1" + app.kubernetes.io/version: "v0.49.2" app.kubernetes.io/managed-by: Helm --- # Source: piped/templates/secret.yaml @@ -17,10 +17,10 @@ kind: Secret metadata: name: piped labels: - helm.sh/chart: piped-v0.49.1 + helm.sh/chart: piped-v0.49.2 app.kubernetes.io/name: piped app.kubernetes.io/instance: piped - app.kubernetes.io/version: "v0.49.1" + app.kubernetes.io/version: "v0.49.2" app.kubernetes.io/managed-by: Helm type: Opaque data: @@ -31,10 +31,10 @@ kind: ConfigMap metadata: name: piped labels: - helm.sh/chart: piped-v0.49.1 + helm.sh/chart: piped-v0.49.2 app.kubernetes.io/name: piped app.kubernetes.io/instance: piped - app.kubernetes.io/version: "v0.49.1" + app.kubernetes.io/version: "v0.49.2" app.kubernetes.io/managed-by: Helm data: piped-config.yaml: |- @@ -57,10 +57,10 @@ kind: ClusterRole metadata: name: piped labels: - helm.sh/chart: piped-v0.49.1 + helm.sh/chart: piped-v0.49.2 app.kubernetes.io/name: piped app.kubernetes.io/instance: piped - app.kubernetes.io/version: "v0.49.1" + app.kubernetes.io/version: "v0.49.2" app.kubernetes.io/managed-by: Helm rules: @@ -81,10 +81,10 @@ kind: ClusterRoleBinding metadata: name: piped labels: - helm.sh/chart: piped-v0.49.1 + helm.sh/chart: piped-v0.49.2 app.kubernetes.io/name: piped app.kubernetes.io/instance: piped - app.kubernetes.io/version: "v0.49.1" + app.kubernetes.io/version: "v0.49.2" app.kubernetes.io/managed-by: Helm roleRef: apiGroup: rbac.authorization.k8s.io @@ -101,10 +101,10 @@ kind: Service metadata: name: piped labels: - helm.sh/chart: piped-v0.49.1 + helm.sh/chart: piped-v0.49.2 app.kubernetes.io/name: piped app.kubernetes.io/instance: piped - app.kubernetes.io/version: "v0.49.1" + app.kubernetes.io/version: "v0.49.2" app.kubernetes.io/managed-by: Helm spec: type: ClusterIP @@ -122,10 +122,10 @@ kind: Deployment metadata: name: piped labels: - helm.sh/chart: piped-v0.49.1 + helm.sh/chart: piped-v0.49.2 app.kubernetes.io/name: piped app.kubernetes.io/instance: piped - app.kubernetes.io/version: "v0.49.1" + app.kubernetes.io/version: "v0.49.2" app.kubernetes.io/managed-by: Helm spec: replicas: 1 @@ -142,13 +142,13 @@ spec: app.kubernetes.io/instance: piped annotations: sidecar.istio.io/inject: "false" - rollme: "qoHIs" + rollme: "qZ3uW" spec: serviceAccountName: piped containers: - name: piped imagePullPolicy: IfNotPresent - image: "ghcr.io/pipe-cd/piped:v0.49.1" + image: "ghcr.io/pipe-cd/piped:v0.49.2" args: - piped - --config-file=/etc/piped-config/piped-config.yaml From 6072a152f828d67150cd33ecf3596348422dde45 Mon Sep 17 00:00:00 2001 From: Shinnosuke Sawada-Dazai Date: Fri, 11 Oct 2024 11:33:59 +0900 Subject: [PATCH 57/84] Add application_config_filename field to DeploymentSource (#5277) * Add application_config_filename field to DeploymentSource Signed-off-by: Shinnosuke Sawada-Dazai * Update pkg/model/deployment_source.proto Co-authored-by: Tetsuya Kikuchi <97105818+t-kikuc@users.noreply.github.com> Signed-off-by: Shinnosuke Sawada-Dazai * Run make gen/code Signed-off-by: Shinnosuke Sawada-Dazai --------- Signed-off-by: Shinnosuke Sawada-Dazai Co-authored-by: Tetsuya Kikuchi <97105818+t-kikuc@users.noreply.github.com> --- pkg/model/deployment_source.pb.go | 18 ++++++++++-- pkg/model/deployment_source.pb.validate.go | 2 ++ pkg/model/deployment_source.proto | 3 ++ web/model/deployment_source_pb.d.ts | 4 +++ web/model/deployment_source_pb.js | 32 +++++++++++++++++++++- 5 files changed, 56 insertions(+), 3 deletions(-) diff --git a/pkg/model/deployment_source.pb.go b/pkg/model/deployment_source.pb.go index aa44d082bb..b44071830a 100644 --- a/pkg/model/deployment_source.pb.go +++ b/pkg/model/deployment_source.pb.go @@ -45,6 +45,9 @@ type DeploymentSource struct { Revision string `protobuf:"bytes,2,opt,name=revision,proto3" json:"revision,omitempty"` // The configuration of the application which is specific for plugins. ApplicationConfig []byte `protobuf:"bytes,3,opt,name=application_config,json=applicationConfig,proto3" json:"application_config,omitempty"` + // The filename of the application configuration file. + // The plugins can use this to avoid mistakenly reading this file as a manifest. + ApplicationConfigFilename string `protobuf:"bytes,4,opt,name=application_config_filename,json=applicationConfigFilename,proto3" json:"application_config_filename,omitempty"` } func (x *DeploymentSource) Reset() { @@ -100,12 +103,19 @@ func (x *DeploymentSource) GetApplicationConfig() []byte { return nil } +func (x *DeploymentSource) GetApplicationConfigFilename() string { + if x != nil { + return x.ApplicationConfigFilename + } + return "" +} + var File_pkg_model_deployment_source_proto protoreflect.FileDescriptor var file_pkg_model_deployment_source_proto_rawDesc = []byte{ 0x0a, 0x21, 0x70, 0x6b, 0x67, 0x2f, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x2f, 0x64, 0x65, 0x70, 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x5f, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x70, 0x72, - 0x6f, 0x74, 0x6f, 0x12, 0x05, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x22, 0x92, 0x01, 0x0a, 0x10, 0x44, + 0x6f, 0x74, 0x6f, 0x12, 0x05, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x22, 0xd2, 0x01, 0x0a, 0x10, 0x44, 0x65, 0x70, 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x53, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x33, 0x0a, 0x15, 0x61, 0x70, 0x70, 0x6c, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x14, @@ -114,7 +124,11 @@ var file_pkg_model_deployment_source_proto_rawDesc = []byte{ 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x72, 0x65, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x2d, 0x0a, 0x12, 0x61, 0x70, 0x70, 0x6c, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x11, 0x61, 0x70, - 0x70, 0x6c, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x42, + 0x70, 0x6c, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, + 0x3e, 0x0a, 0x1b, 0x61, 0x70, 0x70, 0x6c, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x63, + 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x5f, 0x66, 0x69, 0x6c, 0x65, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x04, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x19, 0x61, 0x70, 0x70, 0x6c, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, + 0x6e, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x46, 0x69, 0x6c, 0x65, 0x6e, 0x61, 0x6d, 0x65, 0x42, 0x25, 0x5a, 0x23, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x70, 0x69, 0x70, 0x65, 0x2d, 0x63, 0x64, 0x2f, 0x70, 0x69, 0x70, 0x65, 0x63, 0x64, 0x2f, 0x70, 0x6b, 0x67, 0x2f, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, diff --git a/pkg/model/deployment_source.pb.validate.go b/pkg/model/deployment_source.pb.validate.go index 79e6c58d4f..484099d384 100644 --- a/pkg/model/deployment_source.pb.validate.go +++ b/pkg/model/deployment_source.pb.validate.go @@ -63,6 +63,8 @@ func (m *DeploymentSource) validate(all bool) error { // no validation rules for ApplicationConfig + // no validation rules for ApplicationConfigFilename + if len(errors) > 0 { return DeploymentSourceMultiError(errors) } diff --git a/pkg/model/deployment_source.proto b/pkg/model/deployment_source.proto index f9a24ff278..efa35ba789 100644 --- a/pkg/model/deployment_source.proto +++ b/pkg/model/deployment_source.proto @@ -25,4 +25,7 @@ message DeploymentSource { string revision = 2; // The configuration of the application which is specific for plugins. bytes application_config = 3; + // The filename of the application configuration file. + // The plugins can use this to avoid mistakenly reading this file as a manifest. + string application_config_filename = 4; } diff --git a/web/model/deployment_source_pb.d.ts b/web/model/deployment_source_pb.d.ts index eb41955001..8e48c1df44 100644 --- a/web/model/deployment_source_pb.d.ts +++ b/web/model/deployment_source_pb.d.ts @@ -14,6 +14,9 @@ export class DeploymentSource extends jspb.Message { getApplicationConfig_asB64(): string; setApplicationConfig(value: Uint8Array | string): DeploymentSource; + getApplicationConfigFilename(): string; + setApplicationConfigFilename(value: string): DeploymentSource; + serializeBinary(): Uint8Array; toObject(includeInstance?: boolean): DeploymentSource.AsObject; static toObject(includeInstance: boolean, msg: DeploymentSource): DeploymentSource.AsObject; @@ -27,6 +30,7 @@ export namespace DeploymentSource { applicationDirectory: string, revision: string, applicationConfig: Uint8Array | string, + applicationConfigFilename: string, } } diff --git a/web/model/deployment_source_pb.js b/web/model/deployment_source_pb.js index 5890613f7a..6a45a08fb9 100644 --- a/web/model/deployment_source_pb.js +++ b/web/model/deployment_source_pb.js @@ -77,7 +77,8 @@ proto.model.DeploymentSource.toObject = function(includeInstance, msg) { var f, obj = { applicationDirectory: jspb.Message.getFieldWithDefault(msg, 1, ""), revision: jspb.Message.getFieldWithDefault(msg, 2, ""), - applicationConfig: msg.getApplicationConfig_asB64() + applicationConfig: msg.getApplicationConfig_asB64(), + applicationConfigFilename: jspb.Message.getFieldWithDefault(msg, 4, "") }; if (includeInstance) { @@ -126,6 +127,10 @@ proto.model.DeploymentSource.deserializeBinaryFromReader = function(msg, reader) var value = /** @type {!Uint8Array} */ (reader.readBytes()); msg.setApplicationConfig(value); break; + case 4: + var value = /** @type {string} */ (reader.readString()); + msg.setApplicationConfigFilename(value); + break; default: reader.skipField(); break; @@ -176,6 +181,13 @@ proto.model.DeploymentSource.serializeBinaryToWriter = function(message, writer) f ); } + f = message.getApplicationConfigFilename(); + if (f.length > 0) { + writer.writeString( + 4, + f + ); + } }; @@ -257,4 +269,22 @@ proto.model.DeploymentSource.prototype.setApplicationConfig = function(value) { }; +/** + * optional string application_config_filename = 4; + * @return {string} + */ +proto.model.DeploymentSource.prototype.getApplicationConfigFilename = function() { + return /** @type {string} */ (jspb.Message.getFieldWithDefault(this, 4, "")); +}; + + +/** + * @param {string} value + * @return {!proto.model.DeploymentSource} returns this + */ +proto.model.DeploymentSource.prototype.setApplicationConfigFilename = function(value) { + return jspb.Message.setProto3StringField(this, 4, value); +}; + + goog.object.extend(exports, proto.model); From 3fb745587aa3f1f2eed9bca9fc9bd50fa56a79e0 Mon Sep 17 00:00:00 2001 From: Shinnosuke Sawada-Dazai Date: Fri, 11 Oct 2024 12:00:13 +0900 Subject: [PATCH 58/84] Implement find manifests in k8s plugin (#5271) * Define the K8sResourceReference struct Signed-off-by: Shinnosuke Sawada-Dazai * Define the KindDeployment Signed-off-by: Shinnosuke Sawada-Dazai * Implement findWorkloadManifests Signed-off-by: Shinnosuke Sawada-Dazai * Add godoc comment for findManifests Signed-off-by: Shinnosuke Sawada-Dazai * Add test for findManifests Signed-off-by: Shinnosuke Sawada-Dazai * Add godoc comment for findWorkloadManifests Signed-off-by: Shinnosuke Sawada-Dazai * Add test for findWorkloadManifests Signed-off-by: Shinnosuke Sawada-Dazai --------- Signed-off-by: Shinnosuke Sawada-Dazai --- .../plugin/kubernetes/config/application.go | 22 ++ .../plugin/kubernetes/deployment/determine.go | 35 ++ .../kubernetes/deployment/determine_test.go | 311 ++++++++++++++++++ .../plugin/kubernetes/provider/resource.go | 17 + 4 files changed, 385 insertions(+) create mode 100644 pkg/app/pipedv1/plugin/kubernetes/config/application.go create mode 100644 pkg/app/pipedv1/plugin/kubernetes/provider/resource.go diff --git a/pkg/app/pipedv1/plugin/kubernetes/config/application.go b/pkg/app/pipedv1/plugin/kubernetes/config/application.go new file mode 100644 index 0000000000..2ac63b960e --- /dev/null +++ b/pkg/app/pipedv1/plugin/kubernetes/config/application.go @@ -0,0 +1,22 @@ +// 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 + +// K8sResourceReference represents a reference to a Kubernetes resource. +// It is used to specify the resources which are treated as the workload of an application. +type K8sResourceReference struct { + Kind string `json:"kind"` + Name string `json:"name"` +} diff --git a/pkg/app/pipedv1/plugin/kubernetes/deployment/determine.go b/pkg/app/pipedv1/plugin/kubernetes/deployment/determine.go index 54e39c75a1..717b69ffb6 100644 --- a/pkg/app/pipedv1/plugin/kubernetes/deployment/determine.go +++ b/pkg/app/pipedv1/plugin/kubernetes/deployment/determine.go @@ -20,6 +20,7 @@ import ( "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "github.com/pipe-cd/pipecd/pkg/app/pipedv1/plugin/kubernetes/config" "github.com/pipe-cd/pipecd/pkg/app/pipedv1/plugin/kubernetes/provider" "github.com/pipe-cd/pipecd/pkg/model" ) @@ -89,3 +90,37 @@ func determineVersions(manifests []provider.Manifest) ([]*model.ArtifactVersion, return versions, nil } + +// findManifests returns the manifests that have the specified kind and name. +func findManifests(kind, name string, manifests []provider.Manifest) []provider.Manifest { + out := make([]provider.Manifest, 0, len(manifests)) + for _, m := range manifests { + if m.Body.GetKind() != kind { + continue + } + if name != "" && m.Body.GetName() != name { + continue + } + out = append(out, m) + } + return out +} + +// findWorkloadManifests returns the manifests that have the specified references. +// the default kind is Deployment if it is not specified. +func findWorkloadManifests(manifests []provider.Manifest, refs []config.K8sResourceReference) []provider.Manifest { + if len(refs) == 0 { + return findManifests(provider.KindDeployment, "", manifests) + } + + workloads := make([]provider.Manifest, 0) + for _, ref := range refs { + kind := provider.KindDeployment + if ref.Kind != "" { + kind = ref.Kind + } + ms := findManifests(kind, ref.Name, manifests) + workloads = append(workloads, ms...) + } + return workloads +} diff --git a/pkg/app/pipedv1/plugin/kubernetes/deployment/determine_test.go b/pkg/app/pipedv1/plugin/kubernetes/deployment/determine_test.go index 242fcf99e0..84a9824693 100644 --- a/pkg/app/pipedv1/plugin/kubernetes/deployment/determine_test.go +++ b/pkg/app/pipedv1/plugin/kubernetes/deployment/determine_test.go @@ -23,6 +23,7 @@ import ( "github.com/stretchr/testify/require" "sigs.k8s.io/yaml" + "github.com/pipe-cd/pipecd/pkg/app/pipedv1/plugin/kubernetes/config" "github.com/pipe-cd/pipecd/pkg/app/pipedv1/plugin/kubernetes/provider" "github.com/pipe-cd/pipecd/pkg/model" ) @@ -310,3 +311,313 @@ spec: }) } } + +func TestFindManifests(t *testing.T) { + tests := []struct { + name string + kind string + nameField string + manifests []string + want []provider.Manifest + }{ + { + name: "find by kind", + kind: "Deployment", + manifests: []string{ + ` +apiVersion: apps/v1 +kind: Deployment +metadata: + name: nginx-deployment +spec: + template: + spec: + containers: + - name: nginx + image: nginx:1.19.3 +`, + ` +apiVersion: v1 +kind: Service +metadata: + name: nginx-service +spec: + selector: + app: nginx +`, + }, + want: []provider.Manifest{ + mustUnmarshalYAML[provider.Manifest](t, []byte(strings.TrimSpace(` +apiVersion: apps/v1 +kind: Deployment +metadata: + name: nginx-deployment +spec: + template: + spec: + containers: + - name: nginx + image: nginx:1.19.3 +`))), + }, + }, + { + name: "find by kind and name", + kind: "Deployment", + nameField: "nginx-deployment", + manifests: []string{ + ` +apiVersion: apps/v1 +kind: Deployment +metadata: + name: nginx-deployment +spec: + template: + spec: + containers: + - name: nginx + image: nginx:1.19.3 +`, + ` +apiVersion: apps/v1 +kind: Deployment +metadata: + name: redis-deployment +spec: + template: + spec: + containers: + - name: redis + image: redis:6.0.9 +`, + }, + want: []provider.Manifest{ + mustUnmarshalYAML[provider.Manifest](t, []byte(strings.TrimSpace(` +apiVersion: apps/v1 +kind: Deployment +metadata: + name: nginx-deployment +spec: + template: + spec: + containers: + - name: nginx + image: nginx:1.19.3 +`))), + }, + }, + { + name: "no match", + kind: "StatefulSet", + manifests: []string{ + ` +apiVersion: apps/v1 +kind: Deployment +metadata: + name: nginx-deployment +spec: + template: + spec: + containers: + - name: nginx + image: nginx:1.19.3 +`, + }, + want: []provider.Manifest{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var manifests []provider.Manifest + for _, data := range tt.manifests { + manifests = append(manifests, mustUnmarshalYAML[provider.Manifest](t, []byte(strings.TrimSpace(data)))) + } + got := findManifests(tt.kind, tt.nameField, manifests) + assert.ElementsMatch(t, tt.want, got) + }) + } +} + +func TestFindWorkloadManifests(t *testing.T) { + tests := []struct { + name string + manifests []string + refs []config.K8sResourceReference + want []provider.Manifest + }{ + { + name: "default to Deployment kind", + manifests: []string{ + ` +apiVersion: apps/v1 +kind: Deployment +metadata: + name: nginx-deployment +spec: + template: + spec: + containers: + - name: nginx + image: nginx:1.19.3 +`, + ` +apiVersion: v1 +kind: Service +metadata: + name: nginx-service +spec: + selector: + app: nginx +`, + }, + refs: nil, + want: []provider.Manifest{ + mustUnmarshalYAML[provider.Manifest](t, []byte(strings.TrimSpace(` +apiVersion: apps/v1 +kind: Deployment +metadata: + name: nginx-deployment +spec: + template: + spec: + containers: + - name: nginx + image: nginx:1.19.3 +`))), + }, + }, + { + name: "specified kind and name", + manifests: []string{ + ` +apiVersion: apps/v1 +kind: Deployment +metadata: + name: nginx-deployment +spec: + template: + spec: + containers: + - name: nginx + image: nginx:1.19.3 +`, + ` +apiVersion: apps/v1 +kind: Deployment +metadata: + name: redis-deployment +spec: + template: + spec: + containers: + - name: redis + image: redis:6.0.9 +`, + }, + refs: []config.K8sResourceReference{ + { + Kind: "Deployment", + Name: "nginx-deployment", + }, + }, + want: []provider.Manifest{ + mustUnmarshalYAML[provider.Manifest](t, []byte(strings.TrimSpace(` +apiVersion: apps/v1 +kind: Deployment +metadata: + name: nginx-deployment +spec: + template: + spec: + containers: + - name: nginx + image: nginx:1.19.3 +`))), + }, + }, + { + name: "specified kind only", + manifests: []string{ + ` +apiVersion: apps/v1 +kind: Deployment +metadata: + name: nginx-deployment +spec: + template: + spec: + containers: + - name: nginx + image: nginx:1.19.3 +`, + ` +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: redis-statefulset +spec: + template: + spec: + containers: + - name: redis + image: redis:6.0.9 +`, + }, + refs: []config.K8sResourceReference{ + { + Kind: "StatefulSet", + }, + }, + want: []provider.Manifest{ + mustUnmarshalYAML[provider.Manifest](t, []byte(strings.TrimSpace(` +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: redis-statefulset +spec: + template: + spec: + containers: + - name: redis + image: redis:6.0.9 +`))), + }, + }, + { + name: "no match", + manifests: []string{ + ` +apiVersion: apps/v1 +kind: Deployment +metadata: + name: nginx-deployment +spec: + template: + spec: + containers: + - name: nginx + image: nginx:1.19.3 +`, + }, + refs: []config.K8sResourceReference{ + { + Kind: "StatefulSet", + Name: "redis-statefulset", + }, + }, + want: []provider.Manifest{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var manifests []provider.Manifest + for _, data := range tt.manifests { + manifests = append(manifests, mustUnmarshalYAML[provider.Manifest](t, []byte(strings.TrimSpace(data)))) + } + got := findWorkloadManifests(manifests, tt.refs) + assert.ElementsMatch(t, tt.want, got) + }) + } +} diff --git a/pkg/app/pipedv1/plugin/kubernetes/provider/resource.go b/pkg/app/pipedv1/plugin/kubernetes/provider/resource.go new file mode 100644 index 0000000000..35d95aff8f --- /dev/null +++ b/pkg/app/pipedv1/plugin/kubernetes/provider/resource.go @@ -0,0 +1,17 @@ +// 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 provider + +const KindDeployment = "Deployment" From 1c516d22b0c7a6bb471c7165be7c9299648788e9 Mon Sep 17 00:00:00 2001 From: Tetsuya Kikuchi <97105818+t-kikuc@users.noreply.github.com> Date: Fri, 11 Oct 2024 15:02:05 +0900 Subject: [PATCH 59/84] Separate What and Why (#5278) Signed-off-by: t-kikuc --- .github/PULL_REQUEST_TEMPLATE.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 3eaf20df4c..6916d37bd5 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,4 +1,6 @@ -**What this PR does / why we need it**: +**What this PR does**: + +**Why we need it**: **Which issue(s) this PR fixes**: From 45e18f7f553d02cfef33569e5a2b68518841c544 Mon Sep 17 00:00:00 2001 From: Shinnosuke Sawada-Dazai Date: Fri, 11 Oct 2024 15:07:36 +0900 Subject: [PATCH 60/84] Use chan only to notify command is done (#5244) * Use chan only to notify command is done to avoid race condition Signed-off-by: Shinnosuke Sawada-Dazai * Add test for exit code Signed-off-by: Shinnosuke Sawada-Dazai --------- Signed-off-by: Shinnosuke Sawada-Dazai --- pkg/app/launcher/cmd/launcher/binary.go | 22 ++++++++++---- pkg/app/launcher/cmd/launcher/binary_test.go | 32 ++++++++++++++++++++ 2 files changed, 48 insertions(+), 6 deletions(-) diff --git a/pkg/app/launcher/cmd/launcher/binary.go b/pkg/app/launcher/cmd/launcher/binary.go index 8b5a15f61a..5d86f673b9 100644 --- a/pkg/app/launcher/cmd/launcher/binary.go +++ b/pkg/app/launcher/cmd/launcher/binary.go @@ -21,6 +21,7 @@ import ( "os" "os/exec" "path/filepath" + "sync/atomic" "syscall" "time" @@ -29,7 +30,8 @@ import ( type command struct { cmd *exec.Cmd - stoppedCh chan error + stoppedCh chan struct{} + result atomic.Pointer[error] } func (c *command) IsRunning() bool { @@ -50,9 +52,16 @@ func (c *command) GracefulStop(period time.Duration) error { select { case <-timer.C: c.cmd.Process.Kill() - return <-c.stoppedCh - case err := <-c.stoppedCh: - return err + <-c.stoppedCh + if perr := c.result.Load(); perr != nil { + return *perr + } + return nil + case <-c.stoppedCh: + if perr := c.result.Load(); perr != nil { + return *perr + } + return nil } } @@ -68,11 +77,12 @@ func runBinary(execPath string, args []string) (*command, error) { c := &command{ cmd: cmd, - stoppedCh: make(chan error, 1), + stoppedCh: make(chan struct{}), + result: atomic.Pointer[error]{}, } go func() { err := cmd.Wait() - c.stoppedCh <- err + c.result.Store(&err) close(c.stoppedCh) }() diff --git a/pkg/app/launcher/cmd/launcher/binary_test.go b/pkg/app/launcher/cmd/launcher/binary_test.go index 1e277c727c..598b0177fb 100644 --- a/pkg/app/launcher/cmd/launcher/binary_test.go +++ b/pkg/app/launcher/cmd/launcher/binary_test.go @@ -15,6 +15,7 @@ package launcher import ( + "strconv" "testing" "time" @@ -49,3 +50,34 @@ func TestGracefulStopCommand(t *testing.T) { }) } } + +func TestGracefulStopCommandResult(t *testing.T) { + testcases := []struct { + name string + exitCode int + assertion assert.ErrorAssertionFunc + }{ + { + name: "successfully exit", + exitCode: 0, + assertion: assert.NoError, + }, + { + name: "exit with an error", + exitCode: 1, + assertion: assert.Error, + }, + } + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + cmd, err := runBinary("sh", []string{"-c", "exit " + strconv.Itoa(tc.exitCode)}) + require.NoError(t, err) + require.NotNil(t, cmd) + + time.Sleep(100 * time.Millisecond) // to avoid GracefulStop executed before the command exits + tc.assertion(t, cmd.GracefulStop(time.Second)) + assert.False(t, cmd.IsRunning()) + }) + } +} From 7da4bd5e9e05b3af4e1bfe994a712767106d8fe7 Mon Sep 17 00:00:00 2001 From: Khanh Tran <32532742+khanhtc1202@users.noreply.github.com> Date: Fri, 11 Oct 2024 16:00:39 +0700 Subject: [PATCH 61/84] Remove Cloud Providers related docs (#5279) Signed-off-by: khanhtc1202 --- .../managing-piped/adding-a-cloud-provider.md | 134 ------------------ .../managing-piped/configuration-reference.md | 5 - .../managing-piped/adding-a-cloud-provider.md | 134 ------------------ .../managing-piped/configuration-reference.md | 5 - 4 files changed, 278 deletions(-) delete mode 100644 docs/content/en/docs-dev/user-guide/managing-piped/adding-a-cloud-provider.md delete mode 100644 docs/content/en/docs-v0.49.x/user-guide/managing-piped/adding-a-cloud-provider.md diff --git a/docs/content/en/docs-dev/user-guide/managing-piped/adding-a-cloud-provider.md b/docs/content/en/docs-dev/user-guide/managing-piped/adding-a-cloud-provider.md deleted file mode 100644 index e05aad45af..0000000000 --- a/docs/content/en/docs-dev/user-guide/managing-piped/adding-a-cloud-provider.md +++ /dev/null @@ -1,134 +0,0 @@ ---- -title: "Adding a cloud provider" -linkTitle: "Adding cloud provider" -weight: 3 -description: > - This page describes how to add a cloud provider to enable its applications. ---- - -> NOTE: Starting from version v0.35.0, the CloudProvider concept is being replaced by PlatformProvider. It's a name change due to the PipeCD vision improvement. __The CloudProvider configuration is marked as deprecated, please migrate your piped agent configuration to use PlatformProvider__. - -PipeCD supports multiple clouds and multiple application kinds. -Cloud provider defines which cloud and where the application should be deployed to. -So while registering a new application, the name of a configured cloud provider is required. - -Currently, PipeCD is supporting these five kinds of cloud providers: `KUBERNETES`, `ECS`, `TERRAFORM`, `CLOUDRUN`, `LAMBDA`. -A new cloud provider can be enabled by adding a [CloudProvider](../configuration-reference/#cloudprovider) struct to the piped configuration file. -A piped can have one or multiple cloud provider instances from the same or different cloud provider kind. - -The next sections show the specific configuration for each kind of cloud provider. - -### Configuring Kubernetes cloud provider - -By default, piped deploys Kubernetes application to the cluster where the piped is running in. An external cluster can be connected by specifying the `masterURL` and `kubeConfigPath` in the [configuration](../configuration-reference/#cloudproviderkubernetesconfig). - -And, the default resources (defined at [here](https://github.com/pipe-cd/pipecd/blob/master/pkg/app/piped/platformprovider/kubernetes/resourcekey.go)) from all namespaces of the Kubernetes cluster will be watched for rendering the application state in realtime and detecting the configuration drift. In case you want to restrict piped to watch only a single namespace, let specify the namespace in the [KubernetesAppStateInformer](../configuration-reference/#kubernetesappstateinformer) field. You can also add other resources or exclude resources to/from the watching targets by that field. - -Below configuration snippet just specifies a name and type of cloud provider. It means the cloud provider `kubernetes-dev` will connect to the Kubernetes cluster where the piped is running in, and this cloud provider watches all of the predefined resources from all namespaces inside that cluster. - -``` yaml -apiVersion: pipecd.dev/v1beta1 -kind: Piped -spec: - ... - cloudProviders: - - name: kubernetes-dev - type: KUBERNETES -``` - -See [ConfigurationReference](../configuration-reference/#cloudproviderkubernetesconfig) for the full configuration. - -### Configuring Terraform cloud provider - -A terraform cloud provider contains a list of shared terraform variables that will be applied while running the deployment of its applications. - -``` yaml -apiVersion: pipecd.dev/v1beta1 -kind: Piped -spec: - ... - cloudProviders: - - name: terraform-dev - type: TERRAFORM - config: - vars: - - "project=pipecd" -``` - -See [ConfigurationReference](../configuration-reference/#cloudproviderterraformconfig) for the full configuration. - -### Configuring Cloud Run cloud provider - -Adding a Cloud Run provider requires the name of the Google Cloud project and the region name where Cloud Run service is running. A service account file for accessing to Cloud Run is also required if the machine running the piped does not have enough permissions to access. - -``` yaml -apiVersion: pipecd.dev/v1beta1 -kind: Piped -spec: - ... - cloudProviders: - - name: cloudrun-dev - type: CLOUDRUN - config: - project: {GCP_PROJECT} - region: {CLOUDRUN_REGION} - credentialsFile: {PATH_TO_THE_SERVICE_ACCOUNT_FILE} -``` - -See [ConfigurationReference](../configuration-reference/#cloudprovidercloudrunconfig) for the full configuration. - -### Configuring Lambda cloud provider - -Adding a Lambda provider requires the region name where Lambda service is running. - -```yaml -apiVersion: pipecd.dev/v1beta1 -kind: Piped -spec: - ... - cloudProviders: - - name: lambda-dev - type: LAMBDA - config: - region: {LAMBDA_REGION} - profile: default - credentialsFile: {PATH_TO_THE_CREDENTIAL_FILE} -``` - -You will generally need your AWS credentials to authenticate with Lambda. Piped provides multiple methods of loading these credentials. -It attempts to retrieve credentials in the following order: -1. From the environment variables. Available environment variables are `AWS_ACCESS_KEY_ID` or `AWS_ACCESS_KEY` and `AWS_SECRET_ACCESS_KEY` or `AWS_SECRET_KEY`. -2. From the given credentials file. (the `credentialsFile field in above sample`) -3. From the pod running in EKS cluster via STS (SecurityTokenService). -4. From the EC2 Instance Role. - -Therefore, you don't have to set credentialsFile if you use the environment variables or the EC2 Instance Role. Keep in mind the IAM role/user that you use with your Piped must possess the IAM policy permission for at least `Lambda.Function` and `Lambda.Alias` resources controll (list/read/write). - -See [ConfigurationReference](../configuration-reference/#cloudproviderlambdaconfig) for the full configuration. - -### Configuring ECS cloud provider - -Adding a ECS provider requires the region name where ECS cluster is running. - -```yaml -apiVersion: pipecd.dev/v1beta1 -kind: Piped -spec: - ... - cloudProviders: - - name: ecs-dev - type: ECS - config: - region: {ECS_CLUSTER_REGION} - profile: default - credentialsFile: {PATH_TO_THE_CREDENTIAL_FILE} -``` - -Just same as Lambda cloud provider, there are several ways to authorize Piped agent to enable it performs deployment jobs. -It attempts to retrieve credentials in the following order: -1. From the environment variables. Available environment variables are `AWS_ACCESS_KEY_ID` or `AWS_ACCESS_KEY` and `AWS_SECRET_ACCESS_KEY` or `AWS_SECRET_KEY`. -2. From the given credentials file. (the `credentialsFile field in above sample`) -3. From the pod running in EKS cluster via STS (SecurityTokenService). -4. From the EC2 Instance Role. - -See [ConfigurationReference](../configuration-reference/#cloudproviderecsconfig) for the full configuration. diff --git a/docs/content/en/docs-dev/user-guide/managing-piped/configuration-reference.md b/docs/content/en/docs-dev/user-guide/managing-piped/configuration-reference.md index 534a310007..207807ced3 100644 --- a/docs/content/en/docs-dev/user-guide/managing-piped/configuration-reference.md +++ b/docs/content/en/docs-dev/user-guide/managing-piped/configuration-reference.md @@ -30,7 +30,6 @@ spec: | repositories | [][Repository](#gitrepository) | List of Git repositories this piped will handle. | No | | chartRepositories | [][ChartRepository](#chartrepository) | List of Helm chart repositories that should be added while starting up. | No | | chartRegistries | [][ChartRegistry](#chartregistry) | List of helm chart registries that should be logged in while starting up. | No | -| cloudProviders | [][CloudProvider](#cloudprovider) | List of cloud providers can be used by this piped. This field is deprecated, use `platformProviders` instead. | No | | platformProviders | [][PlatformProvider](#platformprovider) | List of platform providers can be used by this piped. | No | | analysisProviders | [][AnalysisProvider](#analysisprovider) | List of analysis providers can be used by this piped. | No | | eventWatcher | [EventWatcher](#eventwatcher) | Optional Event watcher settings. | No | @@ -81,10 +80,6 @@ spec: | username | string | Username used for the registry authentication. | No | | password | string | Password used for the registry authentication. | No | -## CloudProvider - -This field is deprecated, please use [PlatformProvider](#platformprovider) instead. - ## PlatformProvider | Field | Type | Description | Required | diff --git a/docs/content/en/docs-v0.49.x/user-guide/managing-piped/adding-a-cloud-provider.md b/docs/content/en/docs-v0.49.x/user-guide/managing-piped/adding-a-cloud-provider.md deleted file mode 100644 index e05aad45af..0000000000 --- a/docs/content/en/docs-v0.49.x/user-guide/managing-piped/adding-a-cloud-provider.md +++ /dev/null @@ -1,134 +0,0 @@ ---- -title: "Adding a cloud provider" -linkTitle: "Adding cloud provider" -weight: 3 -description: > - This page describes how to add a cloud provider to enable its applications. ---- - -> NOTE: Starting from version v0.35.0, the CloudProvider concept is being replaced by PlatformProvider. It's a name change due to the PipeCD vision improvement. __The CloudProvider configuration is marked as deprecated, please migrate your piped agent configuration to use PlatformProvider__. - -PipeCD supports multiple clouds and multiple application kinds. -Cloud provider defines which cloud and where the application should be deployed to. -So while registering a new application, the name of a configured cloud provider is required. - -Currently, PipeCD is supporting these five kinds of cloud providers: `KUBERNETES`, `ECS`, `TERRAFORM`, `CLOUDRUN`, `LAMBDA`. -A new cloud provider can be enabled by adding a [CloudProvider](../configuration-reference/#cloudprovider) struct to the piped configuration file. -A piped can have one or multiple cloud provider instances from the same or different cloud provider kind. - -The next sections show the specific configuration for each kind of cloud provider. - -### Configuring Kubernetes cloud provider - -By default, piped deploys Kubernetes application to the cluster where the piped is running in. An external cluster can be connected by specifying the `masterURL` and `kubeConfigPath` in the [configuration](../configuration-reference/#cloudproviderkubernetesconfig). - -And, the default resources (defined at [here](https://github.com/pipe-cd/pipecd/blob/master/pkg/app/piped/platformprovider/kubernetes/resourcekey.go)) from all namespaces of the Kubernetes cluster will be watched for rendering the application state in realtime and detecting the configuration drift. In case you want to restrict piped to watch only a single namespace, let specify the namespace in the [KubernetesAppStateInformer](../configuration-reference/#kubernetesappstateinformer) field. You can also add other resources or exclude resources to/from the watching targets by that field. - -Below configuration snippet just specifies a name and type of cloud provider. It means the cloud provider `kubernetes-dev` will connect to the Kubernetes cluster where the piped is running in, and this cloud provider watches all of the predefined resources from all namespaces inside that cluster. - -``` yaml -apiVersion: pipecd.dev/v1beta1 -kind: Piped -spec: - ... - cloudProviders: - - name: kubernetes-dev - type: KUBERNETES -``` - -See [ConfigurationReference](../configuration-reference/#cloudproviderkubernetesconfig) for the full configuration. - -### Configuring Terraform cloud provider - -A terraform cloud provider contains a list of shared terraform variables that will be applied while running the deployment of its applications. - -``` yaml -apiVersion: pipecd.dev/v1beta1 -kind: Piped -spec: - ... - cloudProviders: - - name: terraform-dev - type: TERRAFORM - config: - vars: - - "project=pipecd" -``` - -See [ConfigurationReference](../configuration-reference/#cloudproviderterraformconfig) for the full configuration. - -### Configuring Cloud Run cloud provider - -Adding a Cloud Run provider requires the name of the Google Cloud project and the region name where Cloud Run service is running. A service account file for accessing to Cloud Run is also required if the machine running the piped does not have enough permissions to access. - -``` yaml -apiVersion: pipecd.dev/v1beta1 -kind: Piped -spec: - ... - cloudProviders: - - name: cloudrun-dev - type: CLOUDRUN - config: - project: {GCP_PROJECT} - region: {CLOUDRUN_REGION} - credentialsFile: {PATH_TO_THE_SERVICE_ACCOUNT_FILE} -``` - -See [ConfigurationReference](../configuration-reference/#cloudprovidercloudrunconfig) for the full configuration. - -### Configuring Lambda cloud provider - -Adding a Lambda provider requires the region name where Lambda service is running. - -```yaml -apiVersion: pipecd.dev/v1beta1 -kind: Piped -spec: - ... - cloudProviders: - - name: lambda-dev - type: LAMBDA - config: - region: {LAMBDA_REGION} - profile: default - credentialsFile: {PATH_TO_THE_CREDENTIAL_FILE} -``` - -You will generally need your AWS credentials to authenticate with Lambda. Piped provides multiple methods of loading these credentials. -It attempts to retrieve credentials in the following order: -1. From the environment variables. Available environment variables are `AWS_ACCESS_KEY_ID` or `AWS_ACCESS_KEY` and `AWS_SECRET_ACCESS_KEY` or `AWS_SECRET_KEY`. -2. From the given credentials file. (the `credentialsFile field in above sample`) -3. From the pod running in EKS cluster via STS (SecurityTokenService). -4. From the EC2 Instance Role. - -Therefore, you don't have to set credentialsFile if you use the environment variables or the EC2 Instance Role. Keep in mind the IAM role/user that you use with your Piped must possess the IAM policy permission for at least `Lambda.Function` and `Lambda.Alias` resources controll (list/read/write). - -See [ConfigurationReference](../configuration-reference/#cloudproviderlambdaconfig) for the full configuration. - -### Configuring ECS cloud provider - -Adding a ECS provider requires the region name where ECS cluster is running. - -```yaml -apiVersion: pipecd.dev/v1beta1 -kind: Piped -spec: - ... - cloudProviders: - - name: ecs-dev - type: ECS - config: - region: {ECS_CLUSTER_REGION} - profile: default - credentialsFile: {PATH_TO_THE_CREDENTIAL_FILE} -``` - -Just same as Lambda cloud provider, there are several ways to authorize Piped agent to enable it performs deployment jobs. -It attempts to retrieve credentials in the following order: -1. From the environment variables. Available environment variables are `AWS_ACCESS_KEY_ID` or `AWS_ACCESS_KEY` and `AWS_SECRET_ACCESS_KEY` or `AWS_SECRET_KEY`. -2. From the given credentials file. (the `credentialsFile field in above sample`) -3. From the pod running in EKS cluster via STS (SecurityTokenService). -4. From the EC2 Instance Role. - -See [ConfigurationReference](../configuration-reference/#cloudproviderecsconfig) for the full configuration. diff --git a/docs/content/en/docs-v0.49.x/user-guide/managing-piped/configuration-reference.md b/docs/content/en/docs-v0.49.x/user-guide/managing-piped/configuration-reference.md index 534a310007..207807ced3 100644 --- a/docs/content/en/docs-v0.49.x/user-guide/managing-piped/configuration-reference.md +++ b/docs/content/en/docs-v0.49.x/user-guide/managing-piped/configuration-reference.md @@ -30,7 +30,6 @@ spec: | repositories | [][Repository](#gitrepository) | List of Git repositories this piped will handle. | No | | chartRepositories | [][ChartRepository](#chartrepository) | List of Helm chart repositories that should be added while starting up. | No | | chartRegistries | [][ChartRegistry](#chartregistry) | List of helm chart registries that should be logged in while starting up. | No | -| cloudProviders | [][CloudProvider](#cloudprovider) | List of cloud providers can be used by this piped. This field is deprecated, use `platformProviders` instead. | No | | platformProviders | [][PlatformProvider](#platformprovider) | List of platform providers can be used by this piped. | No | | analysisProviders | [][AnalysisProvider](#analysisprovider) | List of analysis providers can be used by this piped. | No | | eventWatcher | [EventWatcher](#eventwatcher) | Optional Event watcher settings. | No | @@ -81,10 +80,6 @@ spec: | username | string | Username used for the registry authentication. | No | | password | string | Password used for the registry authentication. | No | -## CloudProvider - -This field is deprecated, please use [PlatformProvider](#platformprovider) instead. - ## PlatformProvider | Field | Type | Description | Required | From 039f9d30f52b8b889da48c570a4ccbbf5e590112 Mon Sep 17 00:00:00 2001 From: Yoshiki Fujikane <40124947+ffjlabo@users.noreply.github.com> Date: Wed, 16 Oct 2024 21:27:35 +0900 Subject: [PATCH 62/84] Fix image uri for cloud run install (#5281) --- .../installation/install-piped/installing-on-cloudrun.md | 4 ++-- .../installation/install-piped/installing-on-cloudrun.md | 4 ++-- .../installation/install-piped/installing-on-cloudrun.md | 4 ++-- .../installation/install-piped/installing-on-cloudrun.md | 4 ++-- .../installation/install-piped/installing-on-cloudrun.md | 4 ++-- .../installation/install-piped/installing-on-cloudrun.md | 4 ++-- .../installation/install-piped/installing-on-cloudrun.md | 4 ++-- .../installation/install-piped/installing-on-cloudrun.md | 4 ++-- .../installation/install-piped/installing-on-cloudrun.md | 4 ++-- .../installation/install-piped/installing-on-cloudrun.md | 4 ++-- .../installation/install-piped/installing-on-cloudrun.md | 4 ++-- .../installation/install-piped/installing-on-cloudrun.md | 4 ++-- 12 files changed, 24 insertions(+), 24 deletions(-) diff --git a/docs/content/en/docs-dev/installation/install-piped/installing-on-cloudrun.md b/docs/content/en/docs-dev/installation/install-piped/installing-on-cloudrun.md index 2919f6ef2e..786f920829 100644 --- a/docs/content/en/docs-dev/installation/install-piped/installing-on-cloudrun.md +++ b/docs/content/en/docs-dev/installation/install-piped/installing-on-cloudrun.md @@ -99,7 +99,7 @@ spec: spec: containerConcurrency: 1 # This must be 1 to ensure Piped work correctly. containers: - - image: ghcr.io/pipe-cd/launcher:{{< blocks/latest_version >}} + - image: gcr.io/pipecd/launcher:{{< blocks/latest_version >}} args: - launcher - --launcher-admin-port=9086 @@ -142,7 +142,7 @@ spec: spec: containerConcurrency: 1 # This must be 1. containers: - - image: ghcr.io/pipe-cd/piped:{{< blocks/latest_version >}} + - image: gcr.io/pipecd/piped:{{< blocks/latest_version >}} args: - piped - --config-file=/etc/piped-config/config.yaml diff --git a/docs/content/en/docs-v0.39.x/installation/install-piped/installing-on-cloudrun.md b/docs/content/en/docs-v0.39.x/installation/install-piped/installing-on-cloudrun.md index dd1dcb8161..f3cf82e3b7 100644 --- a/docs/content/en/docs-v0.39.x/installation/install-piped/installing-on-cloudrun.md +++ b/docs/content/en/docs-v0.39.x/installation/install-piped/installing-on-cloudrun.md @@ -96,7 +96,7 @@ spec: spec: containerConcurrency: 1 # This must be 1 to ensure Piped work correctly. containers: - - image: ghcr.io/pipe-cd/launcher:{{< blocks/latest_version >}} + - image: gcr.io/pipecd/launcher:{{< blocks/latest_version >}} args: - launcher - --launcher-admin-port=9086 @@ -138,7 +138,7 @@ spec: spec: containerConcurrency: 1 # This must be 1. containers: - - image: ghcr.io/pipe-cd/piped:{{< blocks/latest_version >}} + - image: gcr.io/pipecd/piped:{{< blocks/latest_version >}} args: - piped - --config-file=/etc/piped-config/config.yaml diff --git a/docs/content/en/docs-v0.40.x/installation/install-piped/installing-on-cloudrun.md b/docs/content/en/docs-v0.40.x/installation/install-piped/installing-on-cloudrun.md index dd1dcb8161..f3cf82e3b7 100644 --- a/docs/content/en/docs-v0.40.x/installation/install-piped/installing-on-cloudrun.md +++ b/docs/content/en/docs-v0.40.x/installation/install-piped/installing-on-cloudrun.md @@ -96,7 +96,7 @@ spec: spec: containerConcurrency: 1 # This must be 1 to ensure Piped work correctly. containers: - - image: ghcr.io/pipe-cd/launcher:{{< blocks/latest_version >}} + - image: gcr.io/pipecd/launcher:{{< blocks/latest_version >}} args: - launcher - --launcher-admin-port=9086 @@ -138,7 +138,7 @@ spec: spec: containerConcurrency: 1 # This must be 1. containers: - - image: ghcr.io/pipe-cd/piped:{{< blocks/latest_version >}} + - image: gcr.io/pipecd/piped:{{< blocks/latest_version >}} args: - piped - --config-file=/etc/piped-config/config.yaml diff --git a/docs/content/en/docs-v0.41.x/installation/install-piped/installing-on-cloudrun.md b/docs/content/en/docs-v0.41.x/installation/install-piped/installing-on-cloudrun.md index dd1dcb8161..f3cf82e3b7 100644 --- a/docs/content/en/docs-v0.41.x/installation/install-piped/installing-on-cloudrun.md +++ b/docs/content/en/docs-v0.41.x/installation/install-piped/installing-on-cloudrun.md @@ -96,7 +96,7 @@ spec: spec: containerConcurrency: 1 # This must be 1 to ensure Piped work correctly. containers: - - image: ghcr.io/pipe-cd/launcher:{{< blocks/latest_version >}} + - image: gcr.io/pipecd/launcher:{{< blocks/latest_version >}} args: - launcher - --launcher-admin-port=9086 @@ -138,7 +138,7 @@ spec: spec: containerConcurrency: 1 # This must be 1. containers: - - image: ghcr.io/pipe-cd/piped:{{< blocks/latest_version >}} + - image: gcr.io/pipecd/piped:{{< blocks/latest_version >}} args: - piped - --config-file=/etc/piped-config/config.yaml diff --git a/docs/content/en/docs-v0.42.x/installation/install-piped/installing-on-cloudrun.md b/docs/content/en/docs-v0.42.x/installation/install-piped/installing-on-cloudrun.md index 2919f6ef2e..786f920829 100644 --- a/docs/content/en/docs-v0.42.x/installation/install-piped/installing-on-cloudrun.md +++ b/docs/content/en/docs-v0.42.x/installation/install-piped/installing-on-cloudrun.md @@ -99,7 +99,7 @@ spec: spec: containerConcurrency: 1 # This must be 1 to ensure Piped work correctly. containers: - - image: ghcr.io/pipe-cd/launcher:{{< blocks/latest_version >}} + - image: gcr.io/pipecd/launcher:{{< blocks/latest_version >}} args: - launcher - --launcher-admin-port=9086 @@ -142,7 +142,7 @@ spec: spec: containerConcurrency: 1 # This must be 1. containers: - - image: ghcr.io/pipe-cd/piped:{{< blocks/latest_version >}} + - image: gcr.io/pipecd/piped:{{< blocks/latest_version >}} args: - piped - --config-file=/etc/piped-config/config.yaml diff --git a/docs/content/en/docs-v0.43.x/installation/install-piped/installing-on-cloudrun.md b/docs/content/en/docs-v0.43.x/installation/install-piped/installing-on-cloudrun.md index 2919f6ef2e..786f920829 100644 --- a/docs/content/en/docs-v0.43.x/installation/install-piped/installing-on-cloudrun.md +++ b/docs/content/en/docs-v0.43.x/installation/install-piped/installing-on-cloudrun.md @@ -99,7 +99,7 @@ spec: spec: containerConcurrency: 1 # This must be 1 to ensure Piped work correctly. containers: - - image: ghcr.io/pipe-cd/launcher:{{< blocks/latest_version >}} + - image: gcr.io/pipecd/launcher:{{< blocks/latest_version >}} args: - launcher - --launcher-admin-port=9086 @@ -142,7 +142,7 @@ spec: spec: containerConcurrency: 1 # This must be 1. containers: - - image: ghcr.io/pipe-cd/piped:{{< blocks/latest_version >}} + - image: gcr.io/pipecd/piped:{{< blocks/latest_version >}} args: - piped - --config-file=/etc/piped-config/config.yaml diff --git a/docs/content/en/docs-v0.44.x/installation/install-piped/installing-on-cloudrun.md b/docs/content/en/docs-v0.44.x/installation/install-piped/installing-on-cloudrun.md index 2919f6ef2e..786f920829 100644 --- a/docs/content/en/docs-v0.44.x/installation/install-piped/installing-on-cloudrun.md +++ b/docs/content/en/docs-v0.44.x/installation/install-piped/installing-on-cloudrun.md @@ -99,7 +99,7 @@ spec: spec: containerConcurrency: 1 # This must be 1 to ensure Piped work correctly. containers: - - image: ghcr.io/pipe-cd/launcher:{{< blocks/latest_version >}} + - image: gcr.io/pipecd/launcher:{{< blocks/latest_version >}} args: - launcher - --launcher-admin-port=9086 @@ -142,7 +142,7 @@ spec: spec: containerConcurrency: 1 # This must be 1. containers: - - image: ghcr.io/pipe-cd/piped:{{< blocks/latest_version >}} + - image: gcr.io/pipecd/piped:{{< blocks/latest_version >}} args: - piped - --config-file=/etc/piped-config/config.yaml diff --git a/docs/content/en/docs-v0.45.x/installation/install-piped/installing-on-cloudrun.md b/docs/content/en/docs-v0.45.x/installation/install-piped/installing-on-cloudrun.md index 2919f6ef2e..786f920829 100644 --- a/docs/content/en/docs-v0.45.x/installation/install-piped/installing-on-cloudrun.md +++ b/docs/content/en/docs-v0.45.x/installation/install-piped/installing-on-cloudrun.md @@ -99,7 +99,7 @@ spec: spec: containerConcurrency: 1 # This must be 1 to ensure Piped work correctly. containers: - - image: ghcr.io/pipe-cd/launcher:{{< blocks/latest_version >}} + - image: gcr.io/pipecd/launcher:{{< blocks/latest_version >}} args: - launcher - --launcher-admin-port=9086 @@ -142,7 +142,7 @@ spec: spec: containerConcurrency: 1 # This must be 1. containers: - - image: ghcr.io/pipe-cd/piped:{{< blocks/latest_version >}} + - image: gcr.io/pipecd/piped:{{< blocks/latest_version >}} args: - piped - --config-file=/etc/piped-config/config.yaml diff --git a/docs/content/en/docs-v0.46.x/installation/install-piped/installing-on-cloudrun.md b/docs/content/en/docs-v0.46.x/installation/install-piped/installing-on-cloudrun.md index 2919f6ef2e..786f920829 100644 --- a/docs/content/en/docs-v0.46.x/installation/install-piped/installing-on-cloudrun.md +++ b/docs/content/en/docs-v0.46.x/installation/install-piped/installing-on-cloudrun.md @@ -99,7 +99,7 @@ spec: spec: containerConcurrency: 1 # This must be 1 to ensure Piped work correctly. containers: - - image: ghcr.io/pipe-cd/launcher:{{< blocks/latest_version >}} + - image: gcr.io/pipecd/launcher:{{< blocks/latest_version >}} args: - launcher - --launcher-admin-port=9086 @@ -142,7 +142,7 @@ spec: spec: containerConcurrency: 1 # This must be 1. containers: - - image: ghcr.io/pipe-cd/piped:{{< blocks/latest_version >}} + - image: gcr.io/pipecd/piped:{{< blocks/latest_version >}} args: - piped - --config-file=/etc/piped-config/config.yaml diff --git a/docs/content/en/docs-v0.47.x/installation/install-piped/installing-on-cloudrun.md b/docs/content/en/docs-v0.47.x/installation/install-piped/installing-on-cloudrun.md index 2919f6ef2e..786f920829 100644 --- a/docs/content/en/docs-v0.47.x/installation/install-piped/installing-on-cloudrun.md +++ b/docs/content/en/docs-v0.47.x/installation/install-piped/installing-on-cloudrun.md @@ -99,7 +99,7 @@ spec: spec: containerConcurrency: 1 # This must be 1 to ensure Piped work correctly. containers: - - image: ghcr.io/pipe-cd/launcher:{{< blocks/latest_version >}} + - image: gcr.io/pipecd/launcher:{{< blocks/latest_version >}} args: - launcher - --launcher-admin-port=9086 @@ -142,7 +142,7 @@ spec: spec: containerConcurrency: 1 # This must be 1. containers: - - image: ghcr.io/pipe-cd/piped:{{< blocks/latest_version >}} + - image: gcr.io/pipecd/piped:{{< blocks/latest_version >}} args: - piped - --config-file=/etc/piped-config/config.yaml diff --git a/docs/content/en/docs-v0.48.x/installation/install-piped/installing-on-cloudrun.md b/docs/content/en/docs-v0.48.x/installation/install-piped/installing-on-cloudrun.md index 2919f6ef2e..786f920829 100644 --- a/docs/content/en/docs-v0.48.x/installation/install-piped/installing-on-cloudrun.md +++ b/docs/content/en/docs-v0.48.x/installation/install-piped/installing-on-cloudrun.md @@ -99,7 +99,7 @@ spec: spec: containerConcurrency: 1 # This must be 1 to ensure Piped work correctly. containers: - - image: ghcr.io/pipe-cd/launcher:{{< blocks/latest_version >}} + - image: gcr.io/pipecd/launcher:{{< blocks/latest_version >}} args: - launcher - --launcher-admin-port=9086 @@ -142,7 +142,7 @@ spec: spec: containerConcurrency: 1 # This must be 1. containers: - - image: ghcr.io/pipe-cd/piped:{{< blocks/latest_version >}} + - image: gcr.io/pipecd/piped:{{< blocks/latest_version >}} args: - piped - --config-file=/etc/piped-config/config.yaml diff --git a/docs/content/en/docs-v0.49.x/installation/install-piped/installing-on-cloudrun.md b/docs/content/en/docs-v0.49.x/installation/install-piped/installing-on-cloudrun.md index 2919f6ef2e..786f920829 100644 --- a/docs/content/en/docs-v0.49.x/installation/install-piped/installing-on-cloudrun.md +++ b/docs/content/en/docs-v0.49.x/installation/install-piped/installing-on-cloudrun.md @@ -99,7 +99,7 @@ spec: spec: containerConcurrency: 1 # This must be 1 to ensure Piped work correctly. containers: - - image: ghcr.io/pipe-cd/launcher:{{< blocks/latest_version >}} + - image: gcr.io/pipecd/launcher:{{< blocks/latest_version >}} args: - launcher - --launcher-admin-port=9086 @@ -142,7 +142,7 @@ spec: spec: containerConcurrency: 1 # This must be 1. containers: - - image: ghcr.io/pipe-cd/piped:{{< blocks/latest_version >}} + - image: gcr.io/pipecd/piped:{{< blocks/latest_version >}} args: - piped - --config-file=/etc/piped-config/config.yaml From dd779a48fd80c645538ab0ed2a256d54c267e813 Mon Sep 17 00:00:00 2001 From: Khanh Tran <32532742+khanhtc1202@users.noreply.github.com> Date: Sat, 19 Oct 2024 06:06:46 +0700 Subject: [PATCH 63/84] Update pipectl image URI in docs (#5283) Signed-off-by: khanhtc1202 --- docs/content/en/docs-dev/user-guide/command-line-tool.md | 2 +- docs/content/en/docs-v0.39.x/user-guide/command-line-tool.md | 2 +- docs/content/en/docs-v0.40.x/user-guide/command-line-tool.md | 2 +- docs/content/en/docs-v0.41.x/user-guide/command-line-tool.md | 2 +- docs/content/en/docs-v0.42.x/user-guide/command-line-tool.md | 2 +- docs/content/en/docs-v0.43.x/user-guide/command-line-tool.md | 2 +- docs/content/en/docs-v0.44.x/user-guide/command-line-tool.md | 2 +- docs/content/en/docs-v0.45.x/user-guide/command-line-tool.md | 2 +- docs/content/en/docs-v0.46.x/user-guide/command-line-tool.md | 2 +- docs/content/en/docs-v0.47.x/user-guide/command-line-tool.md | 2 +- docs/content/en/docs-v0.48.x/user-guide/command-line-tool.md | 2 +- docs/content/en/docs-v0.49.x/user-guide/command-line-tool.md | 2 +- 12 files changed, 12 insertions(+), 12 deletions(-) diff --git a/docs/content/en/docs-dev/user-guide/command-line-tool.md b/docs/content/en/docs-dev/user-guide/command-line-tool.md index c823e35ab1..2daaadf44e 100644 --- a/docs/content/en/docs-dev/user-guide/command-line-tool.md +++ b/docs/content/en/docs-dev/user-guide/command-line-tool.md @@ -113,7 +113,7 @@ We are storing every version of docker image for pipectl on Google Cloud Contain Available versions are [here](https://github.com/pipe-cd/pipecd/releases). ``` -docker run --rm gcr.io/pipecd/pipectl:{VERSION} -h +docker run --rm ghcr.io/pipe-cd/pipectl:{VERSION} -h ``` ## Authentication diff --git a/docs/content/en/docs-v0.39.x/user-guide/command-line-tool.md b/docs/content/en/docs-v0.39.x/user-guide/command-line-tool.md index 10af71bbff..1f1bcffeec 100644 --- a/docs/content/en/docs-v0.39.x/user-guide/command-line-tool.md +++ b/docs/content/en/docs-v0.39.x/user-guide/command-line-tool.md @@ -46,7 +46,7 @@ We are storing every version of docker image for pipectl on Google Cloud Contain Available versions are [here](https://github.com/pipe-cd/pipecd/releases). ``` -docker run --rm gcr.io/pipecd/pipectl:{VERSION} -h +docker run --rm ghcr.io/pipe-cd/pipectl:{VERSION} -h ``` ## Authentication diff --git a/docs/content/en/docs-v0.40.x/user-guide/command-line-tool.md b/docs/content/en/docs-v0.40.x/user-guide/command-line-tool.md index 10af71bbff..1f1bcffeec 100644 --- a/docs/content/en/docs-v0.40.x/user-guide/command-line-tool.md +++ b/docs/content/en/docs-v0.40.x/user-guide/command-line-tool.md @@ -46,7 +46,7 @@ We are storing every version of docker image for pipectl on Google Cloud Contain Available versions are [here](https://github.com/pipe-cd/pipecd/releases). ``` -docker run --rm gcr.io/pipecd/pipectl:{VERSION} -h +docker run --rm ghcr.io/pipe-cd/pipectl:{VERSION} -h ``` ## Authentication diff --git a/docs/content/en/docs-v0.41.x/user-guide/command-line-tool.md b/docs/content/en/docs-v0.41.x/user-guide/command-line-tool.md index d9c3ae6f72..43d78a1750 100644 --- a/docs/content/en/docs-v0.41.x/user-guide/command-line-tool.md +++ b/docs/content/en/docs-v0.41.x/user-guide/command-line-tool.md @@ -46,7 +46,7 @@ We are storing every version of docker image for pipectl on Google Cloud Contain Available versions are [here](https://github.com/pipe-cd/pipecd/releases). ``` -docker run --rm gcr.io/pipecd/pipectl:{VERSION} -h +docker run --rm ghcr.io/pipe-cd/pipectl:{VERSION} -h ``` ## Authentication diff --git a/docs/content/en/docs-v0.42.x/user-guide/command-line-tool.md b/docs/content/en/docs-v0.42.x/user-guide/command-line-tool.md index ce46265b74..ebab0cc0be 100644 --- a/docs/content/en/docs-v0.42.x/user-guide/command-line-tool.md +++ b/docs/content/en/docs-v0.42.x/user-guide/command-line-tool.md @@ -69,7 +69,7 @@ We are storing every version of docker image for pipectl on Google Cloud Contain Available versions are [here](https://github.com/pipe-cd/pipecd/releases). ``` -docker run --rm gcr.io/pipecd/pipectl:{VERSION} -h +docker run --rm ghcr.io/pipe-cd/pipectl:{VERSION} -h ``` ## Authentication diff --git a/docs/content/en/docs-v0.43.x/user-guide/command-line-tool.md b/docs/content/en/docs-v0.43.x/user-guide/command-line-tool.md index 04d222392f..4640b19b2c 100644 --- a/docs/content/en/docs-v0.43.x/user-guide/command-line-tool.md +++ b/docs/content/en/docs-v0.43.x/user-guide/command-line-tool.md @@ -69,7 +69,7 @@ We are storing every version of docker image for pipectl on Google Cloud Contain Available versions are [here](https://github.com/pipe-cd/pipecd/releases). ``` -docker run --rm gcr.io/pipecd/pipectl:{VERSION} -h +docker run --rm ghcr.io/pipe-cd/pipectl:{VERSION} -h ``` ## Authentication diff --git a/docs/content/en/docs-v0.44.x/user-guide/command-line-tool.md b/docs/content/en/docs-v0.44.x/user-guide/command-line-tool.md index f2f2feca8e..fcc009f684 100644 --- a/docs/content/en/docs-v0.44.x/user-guide/command-line-tool.md +++ b/docs/content/en/docs-v0.44.x/user-guide/command-line-tool.md @@ -69,7 +69,7 @@ We are storing every version of docker image for pipectl on Google Cloud Contain Available versions are [here](https://github.com/pipe-cd/pipecd/releases). ``` -docker run --rm gcr.io/pipecd/pipectl:{VERSION} -h +docker run --rm ghcr.io/pipe-cd/pipectl:{VERSION} -h ``` ## Authentication diff --git a/docs/content/en/docs-v0.45.x/user-guide/command-line-tool.md b/docs/content/en/docs-v0.45.x/user-guide/command-line-tool.md index f2f2feca8e..fcc009f684 100644 --- a/docs/content/en/docs-v0.45.x/user-guide/command-line-tool.md +++ b/docs/content/en/docs-v0.45.x/user-guide/command-line-tool.md @@ -69,7 +69,7 @@ We are storing every version of docker image for pipectl on Google Cloud Contain Available versions are [here](https://github.com/pipe-cd/pipecd/releases). ``` -docker run --rm gcr.io/pipecd/pipectl:{VERSION} -h +docker run --rm ghcr.io/pipe-cd/pipectl:{VERSION} -h ``` ## Authentication diff --git a/docs/content/en/docs-v0.46.x/user-guide/command-line-tool.md b/docs/content/en/docs-v0.46.x/user-guide/command-line-tool.md index 432685deb9..f81112ef65 100644 --- a/docs/content/en/docs-v0.46.x/user-guide/command-line-tool.md +++ b/docs/content/en/docs-v0.46.x/user-guide/command-line-tool.md @@ -69,7 +69,7 @@ We are storing every version of docker image for pipectl on Google Cloud Contain Available versions are [here](https://github.com/pipe-cd/pipecd/releases). ``` -docker run --rm gcr.io/pipecd/pipectl:{VERSION} -h +docker run --rm ghcr.io/pipe-cd/pipectl:{VERSION} -h ``` ## Authentication diff --git a/docs/content/en/docs-v0.47.x/user-guide/command-line-tool.md b/docs/content/en/docs-v0.47.x/user-guide/command-line-tool.md index cb26e5a133..947feb6406 100644 --- a/docs/content/en/docs-v0.47.x/user-guide/command-line-tool.md +++ b/docs/content/en/docs-v0.47.x/user-guide/command-line-tool.md @@ -107,7 +107,7 @@ We are storing every version of docker image for pipectl on Google Cloud Contain Available versions are [here](https://github.com/pipe-cd/pipecd/releases). ``` -docker run --rm gcr.io/pipecd/pipectl:{VERSION} -h +docker run --rm ghcr.io/pipe-cd/pipectl:{VERSION} -h ``` ## Authentication diff --git a/docs/content/en/docs-v0.48.x/user-guide/command-line-tool.md b/docs/content/en/docs-v0.48.x/user-guide/command-line-tool.md index 8c8450ee52..05b038cc6a 100644 --- a/docs/content/en/docs-v0.48.x/user-guide/command-line-tool.md +++ b/docs/content/en/docs-v0.48.x/user-guide/command-line-tool.md @@ -113,7 +113,7 @@ We are storing every version of docker image for pipectl on Google Cloud Contain Available versions are [here](https://github.com/pipe-cd/pipecd/releases). ``` -docker run --rm gcr.io/pipecd/pipectl:{VERSION} -h +docker run --rm ghcr.io/pipe-cd/pipectl:{VERSION} -h ``` ## Authentication diff --git a/docs/content/en/docs-v0.49.x/user-guide/command-line-tool.md b/docs/content/en/docs-v0.49.x/user-guide/command-line-tool.md index c823e35ab1..2daaadf44e 100644 --- a/docs/content/en/docs-v0.49.x/user-guide/command-line-tool.md +++ b/docs/content/en/docs-v0.49.x/user-guide/command-line-tool.md @@ -113,7 +113,7 @@ We are storing every version of docker image for pipectl on Google Cloud Contain Available versions are [here](https://github.com/pipe-cd/pipecd/releases). ``` -docker run --rm gcr.io/pipecd/pipectl:{VERSION} -h +docker run --rm ghcr.io/pipe-cd/pipectl:{VERSION} -h ``` ## Authentication From 23ca1b392d32a0fa120d4e36c446791b208b55b1 Mon Sep 17 00:00:00 2001 From: Khanh Tran <32532742+khanhtc1202@users.noreply.github.com> Date: Sat, 19 Oct 2024 06:06:59 +0700 Subject: [PATCH 64/84] Update image referenced in install piped docs (#5282) Signed-off-by: khanhtc1202 --- .../installation/install-piped/installing-on-kubernetes.md | 4 ++-- .../installation/install-piped/installing-on-kubernetes.md | 4 ++-- .../installation/install-piped/installing-on-kubernetes.md | 4 ++-- .../installation/install-piped/installing-on-kubernetes.md | 4 ++-- .../installation/install-piped/installing-on-kubernetes.md | 4 ++-- .../installation/install-piped/installing-on-kubernetes.md | 4 ++-- .../installation/install-piped/installing-on-kubernetes.md | 4 ++-- .../installation/install-piped/installing-on-kubernetes.md | 4 ++-- .../installation/install-piped/installing-on-kubernetes.md | 4 ++-- .../installation/install-piped/installing-on-kubernetes.md | 4 ++-- .../installation/install-piped/installing-on-kubernetes.md | 4 ++-- .../installation/install-piped/installing-on-kubernetes.md | 4 ++-- 12 files changed, 24 insertions(+), 24 deletions(-) diff --git a/docs/content/en/docs-dev/installation/install-piped/installing-on-kubernetes.md b/docs/content/en/docs-dev/installation/install-piped/installing-on-kubernetes.md index d72c124fd5..932888081f 100644 --- a/docs/content/en/docs-dev/installation/install-piped/installing-on-kubernetes.md +++ b/docs/content/en/docs-dev/installation/install-piped/installing-on-kubernetes.md @@ -174,8 +174,8 @@ helm upgrade -i dev-piped oci://ghcr.io/pipe-cd/chart/piped --version={{< blocks OpenShift uses an arbitrarily assigned user ID when it starts a container. Starting from OpenShift 4.2, it also inserts that user into `/etc/passwd` for using by the application inside the container, -but before that version, the assigned user is missing in that file. That blocks workloads of `gcr.io/pipecd/piped` image. -Therefore if you are running on OpenShift with a version before 4.2, please use `gcr.io/pipecd/piped-okd` image with the following command: +but before that version, the assigned user is missing in that file. That blocks workloads of `ghcr.io/pipe-cd/piped` image. +Therefore if you are running on OpenShift with a version before 4.2, please use `ghcr.io/pipe-cd/piped-okd` image with the following command: - Installing by using [Helm](https://helm.sh/docs/intro/install/) (3.8.0 or later) diff --git a/docs/content/en/docs-v0.39.x/installation/install-piped/installing-on-kubernetes.md b/docs/content/en/docs-v0.39.x/installation/install-piped/installing-on-kubernetes.md index be1d40e8c6..3b4f295efc 100644 --- a/docs/content/en/docs-v0.39.x/installation/install-piped/installing-on-kubernetes.md +++ b/docs/content/en/docs-v0.39.x/installation/install-piped/installing-on-kubernetes.md @@ -172,8 +172,8 @@ helm upgrade -i dev-piped oci://ghcr.io/pipe-cd/chart/piped --version={{< blocks OpenShift uses an arbitrarily assigned user ID when it starts a container. Starting from OpenShift 4.2, it also inserts that user into `/etc/passwd` for using by the application inside the container, -but before that version, the assigned user is missing in that file. That blocks workloads of `gcr.io/pipecd/piped` image. -Therefore if you are running on OpenShift with a version before 4.2, please use `gcr.io/pipecd/piped-okd` image with the following command: +but before that version, the assigned user is missing in that file. That blocks workloads of `ghcr.io/pipe-cd/piped` image. +Therefore if you are running on OpenShift with a version before 4.2, please use `ghcr.io/pipe-cd/piped-okd` image with the following command: - Installing by using [Helm](https://helm.sh/docs/intro/install/) (3.8.0 or later) diff --git a/docs/content/en/docs-v0.40.x/installation/install-piped/installing-on-kubernetes.md b/docs/content/en/docs-v0.40.x/installation/install-piped/installing-on-kubernetes.md index be1d40e8c6..3b4f295efc 100644 --- a/docs/content/en/docs-v0.40.x/installation/install-piped/installing-on-kubernetes.md +++ b/docs/content/en/docs-v0.40.x/installation/install-piped/installing-on-kubernetes.md @@ -172,8 +172,8 @@ helm upgrade -i dev-piped oci://ghcr.io/pipe-cd/chart/piped --version={{< blocks OpenShift uses an arbitrarily assigned user ID when it starts a container. Starting from OpenShift 4.2, it also inserts that user into `/etc/passwd` for using by the application inside the container, -but before that version, the assigned user is missing in that file. That blocks workloads of `gcr.io/pipecd/piped` image. -Therefore if you are running on OpenShift with a version before 4.2, please use `gcr.io/pipecd/piped-okd` image with the following command: +but before that version, the assigned user is missing in that file. That blocks workloads of `ghcr.io/pipe-cd/piped` image. +Therefore if you are running on OpenShift with a version before 4.2, please use `ghcr.io/pipe-cd/piped-okd` image with the following command: - Installing by using [Helm](https://helm.sh/docs/intro/install/) (3.8.0 or later) diff --git a/docs/content/en/docs-v0.41.x/installation/install-piped/installing-on-kubernetes.md b/docs/content/en/docs-v0.41.x/installation/install-piped/installing-on-kubernetes.md index be1d40e8c6..3b4f295efc 100644 --- a/docs/content/en/docs-v0.41.x/installation/install-piped/installing-on-kubernetes.md +++ b/docs/content/en/docs-v0.41.x/installation/install-piped/installing-on-kubernetes.md @@ -172,8 +172,8 @@ helm upgrade -i dev-piped oci://ghcr.io/pipe-cd/chart/piped --version={{< blocks OpenShift uses an arbitrarily assigned user ID when it starts a container. Starting from OpenShift 4.2, it also inserts that user into `/etc/passwd` for using by the application inside the container, -but before that version, the assigned user is missing in that file. That blocks workloads of `gcr.io/pipecd/piped` image. -Therefore if you are running on OpenShift with a version before 4.2, please use `gcr.io/pipecd/piped-okd` image with the following command: +but before that version, the assigned user is missing in that file. That blocks workloads of `ghcr.io/pipe-cd/piped` image. +Therefore if you are running on OpenShift with a version before 4.2, please use `ghcr.io/pipe-cd/piped-okd` image with the following command: - Installing by using [Helm](https://helm.sh/docs/intro/install/) (3.8.0 or later) diff --git a/docs/content/en/docs-v0.42.x/installation/install-piped/installing-on-kubernetes.md b/docs/content/en/docs-v0.42.x/installation/install-piped/installing-on-kubernetes.md index d72c124fd5..932888081f 100644 --- a/docs/content/en/docs-v0.42.x/installation/install-piped/installing-on-kubernetes.md +++ b/docs/content/en/docs-v0.42.x/installation/install-piped/installing-on-kubernetes.md @@ -174,8 +174,8 @@ helm upgrade -i dev-piped oci://ghcr.io/pipe-cd/chart/piped --version={{< blocks OpenShift uses an arbitrarily assigned user ID when it starts a container. Starting from OpenShift 4.2, it also inserts that user into `/etc/passwd` for using by the application inside the container, -but before that version, the assigned user is missing in that file. That blocks workloads of `gcr.io/pipecd/piped` image. -Therefore if you are running on OpenShift with a version before 4.2, please use `gcr.io/pipecd/piped-okd` image with the following command: +but before that version, the assigned user is missing in that file. That blocks workloads of `ghcr.io/pipe-cd/piped` image. +Therefore if you are running on OpenShift with a version before 4.2, please use `ghcr.io/pipe-cd/piped-okd` image with the following command: - Installing by using [Helm](https://helm.sh/docs/intro/install/) (3.8.0 or later) diff --git a/docs/content/en/docs-v0.43.x/installation/install-piped/installing-on-kubernetes.md b/docs/content/en/docs-v0.43.x/installation/install-piped/installing-on-kubernetes.md index d72c124fd5..932888081f 100644 --- a/docs/content/en/docs-v0.43.x/installation/install-piped/installing-on-kubernetes.md +++ b/docs/content/en/docs-v0.43.x/installation/install-piped/installing-on-kubernetes.md @@ -174,8 +174,8 @@ helm upgrade -i dev-piped oci://ghcr.io/pipe-cd/chart/piped --version={{< blocks OpenShift uses an arbitrarily assigned user ID when it starts a container. Starting from OpenShift 4.2, it also inserts that user into `/etc/passwd` for using by the application inside the container, -but before that version, the assigned user is missing in that file. That blocks workloads of `gcr.io/pipecd/piped` image. -Therefore if you are running on OpenShift with a version before 4.2, please use `gcr.io/pipecd/piped-okd` image with the following command: +but before that version, the assigned user is missing in that file. That blocks workloads of `ghcr.io/pipe-cd/piped` image. +Therefore if you are running on OpenShift with a version before 4.2, please use `ghcr.io/pipe-cd/piped-okd` image with the following command: - Installing by using [Helm](https://helm.sh/docs/intro/install/) (3.8.0 or later) diff --git a/docs/content/en/docs-v0.44.x/installation/install-piped/installing-on-kubernetes.md b/docs/content/en/docs-v0.44.x/installation/install-piped/installing-on-kubernetes.md index d72c124fd5..932888081f 100644 --- a/docs/content/en/docs-v0.44.x/installation/install-piped/installing-on-kubernetes.md +++ b/docs/content/en/docs-v0.44.x/installation/install-piped/installing-on-kubernetes.md @@ -174,8 +174,8 @@ helm upgrade -i dev-piped oci://ghcr.io/pipe-cd/chart/piped --version={{< blocks OpenShift uses an arbitrarily assigned user ID when it starts a container. Starting from OpenShift 4.2, it also inserts that user into `/etc/passwd` for using by the application inside the container, -but before that version, the assigned user is missing in that file. That blocks workloads of `gcr.io/pipecd/piped` image. -Therefore if you are running on OpenShift with a version before 4.2, please use `gcr.io/pipecd/piped-okd` image with the following command: +but before that version, the assigned user is missing in that file. That blocks workloads of `ghcr.io/pipe-cd/piped` image. +Therefore if you are running on OpenShift with a version before 4.2, please use `ghcr.io/pipe-cd/piped-okd` image with the following command: - Installing by using [Helm](https://helm.sh/docs/intro/install/) (3.8.0 or later) diff --git a/docs/content/en/docs-v0.45.x/installation/install-piped/installing-on-kubernetes.md b/docs/content/en/docs-v0.45.x/installation/install-piped/installing-on-kubernetes.md index d72c124fd5..932888081f 100644 --- a/docs/content/en/docs-v0.45.x/installation/install-piped/installing-on-kubernetes.md +++ b/docs/content/en/docs-v0.45.x/installation/install-piped/installing-on-kubernetes.md @@ -174,8 +174,8 @@ helm upgrade -i dev-piped oci://ghcr.io/pipe-cd/chart/piped --version={{< blocks OpenShift uses an arbitrarily assigned user ID when it starts a container. Starting from OpenShift 4.2, it also inserts that user into `/etc/passwd` for using by the application inside the container, -but before that version, the assigned user is missing in that file. That blocks workloads of `gcr.io/pipecd/piped` image. -Therefore if you are running on OpenShift with a version before 4.2, please use `gcr.io/pipecd/piped-okd` image with the following command: +but before that version, the assigned user is missing in that file. That blocks workloads of `ghcr.io/pipe-cd/piped` image. +Therefore if you are running on OpenShift with a version before 4.2, please use `ghcr.io/pipe-cd/piped-okd` image with the following command: - Installing by using [Helm](https://helm.sh/docs/intro/install/) (3.8.0 or later) diff --git a/docs/content/en/docs-v0.46.x/installation/install-piped/installing-on-kubernetes.md b/docs/content/en/docs-v0.46.x/installation/install-piped/installing-on-kubernetes.md index d72c124fd5..932888081f 100644 --- a/docs/content/en/docs-v0.46.x/installation/install-piped/installing-on-kubernetes.md +++ b/docs/content/en/docs-v0.46.x/installation/install-piped/installing-on-kubernetes.md @@ -174,8 +174,8 @@ helm upgrade -i dev-piped oci://ghcr.io/pipe-cd/chart/piped --version={{< blocks OpenShift uses an arbitrarily assigned user ID when it starts a container. Starting from OpenShift 4.2, it also inserts that user into `/etc/passwd` for using by the application inside the container, -but before that version, the assigned user is missing in that file. That blocks workloads of `gcr.io/pipecd/piped` image. -Therefore if you are running on OpenShift with a version before 4.2, please use `gcr.io/pipecd/piped-okd` image with the following command: +but before that version, the assigned user is missing in that file. That blocks workloads of `ghcr.io/pipe-cd/piped` image. +Therefore if you are running on OpenShift with a version before 4.2, please use `ghcr.io/pipe-cd/piped-okd` image with the following command: - Installing by using [Helm](https://helm.sh/docs/intro/install/) (3.8.0 or later) diff --git a/docs/content/en/docs-v0.47.x/installation/install-piped/installing-on-kubernetes.md b/docs/content/en/docs-v0.47.x/installation/install-piped/installing-on-kubernetes.md index d72c124fd5..932888081f 100644 --- a/docs/content/en/docs-v0.47.x/installation/install-piped/installing-on-kubernetes.md +++ b/docs/content/en/docs-v0.47.x/installation/install-piped/installing-on-kubernetes.md @@ -174,8 +174,8 @@ helm upgrade -i dev-piped oci://ghcr.io/pipe-cd/chart/piped --version={{< blocks OpenShift uses an arbitrarily assigned user ID when it starts a container. Starting from OpenShift 4.2, it also inserts that user into `/etc/passwd` for using by the application inside the container, -but before that version, the assigned user is missing in that file. That blocks workloads of `gcr.io/pipecd/piped` image. -Therefore if you are running on OpenShift with a version before 4.2, please use `gcr.io/pipecd/piped-okd` image with the following command: +but before that version, the assigned user is missing in that file. That blocks workloads of `ghcr.io/pipe-cd/piped` image. +Therefore if you are running on OpenShift with a version before 4.2, please use `ghcr.io/pipe-cd/piped-okd` image with the following command: - Installing by using [Helm](https://helm.sh/docs/intro/install/) (3.8.0 or later) diff --git a/docs/content/en/docs-v0.48.x/installation/install-piped/installing-on-kubernetes.md b/docs/content/en/docs-v0.48.x/installation/install-piped/installing-on-kubernetes.md index d72c124fd5..932888081f 100644 --- a/docs/content/en/docs-v0.48.x/installation/install-piped/installing-on-kubernetes.md +++ b/docs/content/en/docs-v0.48.x/installation/install-piped/installing-on-kubernetes.md @@ -174,8 +174,8 @@ helm upgrade -i dev-piped oci://ghcr.io/pipe-cd/chart/piped --version={{< blocks OpenShift uses an arbitrarily assigned user ID when it starts a container. Starting from OpenShift 4.2, it also inserts that user into `/etc/passwd` for using by the application inside the container, -but before that version, the assigned user is missing in that file. That blocks workloads of `gcr.io/pipecd/piped` image. -Therefore if you are running on OpenShift with a version before 4.2, please use `gcr.io/pipecd/piped-okd` image with the following command: +but before that version, the assigned user is missing in that file. That blocks workloads of `ghcr.io/pipe-cd/piped` image. +Therefore if you are running on OpenShift with a version before 4.2, please use `ghcr.io/pipe-cd/piped-okd` image with the following command: - Installing by using [Helm](https://helm.sh/docs/intro/install/) (3.8.0 or later) diff --git a/docs/content/en/docs-v0.49.x/installation/install-piped/installing-on-kubernetes.md b/docs/content/en/docs-v0.49.x/installation/install-piped/installing-on-kubernetes.md index d72c124fd5..932888081f 100644 --- a/docs/content/en/docs-v0.49.x/installation/install-piped/installing-on-kubernetes.md +++ b/docs/content/en/docs-v0.49.x/installation/install-piped/installing-on-kubernetes.md @@ -174,8 +174,8 @@ helm upgrade -i dev-piped oci://ghcr.io/pipe-cd/chart/piped --version={{< blocks OpenShift uses an arbitrarily assigned user ID when it starts a container. Starting from OpenShift 4.2, it also inserts that user into `/etc/passwd` for using by the application inside the container, -but before that version, the assigned user is missing in that file. That blocks workloads of `gcr.io/pipecd/piped` image. -Therefore if you are running on OpenShift with a version before 4.2, please use `gcr.io/pipecd/piped-okd` image with the following command: +but before that version, the assigned user is missing in that file. That blocks workloads of `ghcr.io/pipe-cd/piped` image. +Therefore if you are running on OpenShift with a version before 4.2, please use `ghcr.io/pipe-cd/piped-okd` image with the following command: - Installing by using [Helm](https://helm.sh/docs/intro/install/) (3.8.0 or later) From 2f86f7dca297f7f2d402c2040a4a4e14837587ab Mon Sep 17 00:00:00 2001 From: Shinnosuke Sawada-Dazai Date: Tue, 22 Oct 2024 16:27:09 +0900 Subject: [PATCH 65/84] Implement LoadPlainYAMLManifests (#5284) Signed-off-by: Shinnosuke Sawada-Dazai --- .../plugin/kubernetes/provider/kubernetes.go | 19 + .../plugin/kubernetes/provider/loader.go | 166 +++++++++ .../plugin/kubernetes/provider/loader_test.go | 339 ++++++++++++++++++ .../plugin/kubernetes/provider/manifest.go | 2 +- .../plugin/kubernetes/provider/resource.go | 33 ++ 5 files changed, 558 insertions(+), 1 deletion(-) create mode 100644 pkg/app/pipedv1/plugin/kubernetes/provider/kubernetes.go create mode 100644 pkg/app/pipedv1/plugin/kubernetes/provider/loader_test.go diff --git a/pkg/app/pipedv1/plugin/kubernetes/provider/kubernetes.go b/pkg/app/pipedv1/plugin/kubernetes/provider/kubernetes.go new file mode 100644 index 0000000000..04d14d2cd1 --- /dev/null +++ b/pkg/app/pipedv1/plugin/kubernetes/provider/kubernetes.go @@ -0,0 +1,19 @@ +// 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 provider + +const ( + AnnotationOrder = "pipecd.dev/order" // The order number of resource used to sort them before using. +) diff --git a/pkg/app/pipedv1/plugin/kubernetes/provider/loader.go b/pkg/app/pipedv1/plugin/kubernetes/provider/loader.go index aeb59eb1e0..87511789e8 100644 --- a/pkg/app/pipedv1/plugin/kubernetes/provider/loader.go +++ b/pkg/app/pipedv1/plugin/kubernetes/provider/loader.go @@ -14,6 +14,172 @@ package provider +import ( + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "slices" + "strconv" + "strings" + + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "sigs.k8s.io/yaml" + + "github.com/pipe-cd/pipecd/pkg/model" +) + +type TemplatingMethod string + +const ( + TemplatingMethodHelm TemplatingMethod = "helm" + TemplatingMethodKustomize TemplatingMethod = "kustomize" + TemplatingMethodNone TemplatingMethod = "none" +) + type LoaderInput struct { + AppDir string + ConfigFilename string + Manifests []string + + Namespace string + TemplatingMethod TemplatingMethod + // TODO: define fields for LoaderInput. } + +type Loader struct { +} + +func (l *Loader) LoadManifests(input LoaderInput) (manifests []Manifest, err error) { + defer func() { + // Override namespace if set because ParseManifests does not parse it + // if namespace is not explicitly specified in the manifests. + setNamespace(manifests, input.Namespace) + sortManifests(manifests) + }() + + switch input.TemplatingMethod { + case TemplatingMethodHelm: + return nil, errors.New("not implemented yet") + case TemplatingMethodKustomize: + return nil, errors.New("not implemented yet") + case TemplatingMethodNone: + return LoadPlainYAMLManifests(input.AppDir, input.Manifests, input.ConfigFilename) + default: + return nil, fmt.Errorf("unsupported templating method %s", input.TemplatingMethod) + } +} + +func setNamespace(manifests []Manifest, namespace string) { + if namespace == "" { + return + } + for i := range manifests { + manifests[i].Key.Namespace = namespace + } +} + +func sortManifests(manifests []Manifest) { + if len(manifests) < 2 { + return + } + + slices.SortFunc(manifests, func(a, b Manifest) int { + iAns := a.Body.GetAnnotations() + // Ignore the converting error since it is not so much important. + iIndex, _ := strconv.Atoi(iAns[AnnotationOrder]) + + jAns := b.Body.GetAnnotations() + // Ignore the converting error since it is not so much important. + jIndex, _ := strconv.Atoi(jAns[AnnotationOrder]) + + return iIndex - jIndex + }) +} + +func LoadPlainYAMLManifests(dir string, names []string, configFilename string) ([]Manifest, error) { + // If no name was specified we have to walk the app directory to collect the manifest list. + if len(names) == 0 { + err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if path == dir { + return nil + } + if d.IsDir() { + return fs.SkipDir + } + if ext := filepath.Ext(d.Name()); ext != ".yaml" && ext != ".yml" && ext != ".json" { + return nil + } + if model.IsApplicationConfigFile(d.Name()) { + // MEMO: can we remove this check because we have configFilename? + return nil + } + if d.Name() == configFilename { + return nil + } + names = append(names, d.Name()) + return nil + }) + if err != nil { + return nil, err + } + } + + manifests := make([]Manifest, 0, len(names)) + for _, name := range names { + path := filepath.Join(dir, name) + ms, err := LoadManifestsFromYAMLFile(path) + if err != nil { + return nil, fmt.Errorf("failed to load manifest at %s (%w)", path, err) + } + manifests = append(manifests, ms...) + } + + return manifests, nil +} + +// LoadManifestsFromYAMLFile loads the manifests from the given file. +func LoadManifestsFromYAMLFile(path string) ([]Manifest, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + return ParseManifests(string(data)) +} + +// ParseManifests parses the given data and returns a list of Manifest. +func ParseManifests(data string) ([]Manifest, error) { + const separator = "\n---" + var ( + parts = strings.Split(data, separator) + manifests = make([]Manifest, 0, len(parts)) + ) + + for i, part := range parts { + // Ignore all the cases where no content between separator. + if len(strings.TrimSpace(part)) == 0 { + continue + } + // Append new line which trim by document separator. + if i != len(parts)-1 { + part += "\n" + } + var obj unstructured.Unstructured + if err := yaml.Unmarshal([]byte(part), &obj); err != nil { + return nil, err + } + if len(obj.Object) == 0 { + continue + } + manifests = append(manifests, Manifest{ + Key: MakeResourceKey(&obj), + Body: &obj, + }) + } + return manifests, nil +} diff --git a/pkg/app/pipedv1/plugin/kubernetes/provider/loader_test.go b/pkg/app/pipedv1/plugin/kubernetes/provider/loader_test.go new file mode 100644 index 0000000000..2f17402902 --- /dev/null +++ b/pkg/app/pipedv1/plugin/kubernetes/provider/loader_test.go @@ -0,0 +1,339 @@ +// 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 provider + +import ( + "os" + "path/filepath" + "reflect" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" +) + +func TestParseManifests(t *testing.T) { + tests := []struct { + name string + data string + want []Manifest + wantErr bool + }{ + { + name: "single manifest", + data: ` +apiVersion: v1 +kind: ConfigMap +metadata: + name: test-config +`, + want: []Manifest{ + { + Key: ResourceKey{ + APIVersion: "v1", + Kind: "ConfigMap", + Name: "test-config", + }, + Body: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": map[string]interface{}{ + "name": "test-config", + }, + }, + }, + }, + }, + wantErr: false, + }, + { + name: "multiple manifests", + data: ` +apiVersion: v1 +kind: ConfigMap +metadata: + name: test-config +--- +apiVersion: v1 +kind: Service +metadata: + name: test-service +`, + want: []Manifest{ + { + Key: ResourceKey{ + APIVersion: "v1", + Kind: "ConfigMap", + Name: "test-config", + }, + Body: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": map[string]interface{}{ + "name": "test-config", + }, + }, + }, + }, + { + Key: ResourceKey{ + APIVersion: "v1", + Kind: "Service", + Name: "test-service", + }, + Body: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "Service", + "metadata": map[string]interface{}{ + "name": "test-service", + }, + }, + }, + }, + }, + wantErr: false, + }, + { + name: "invalid manifest", + data: ` +apiVersion: v1 +kind: ConfigMap +metadata: + name: test-config +--- +invalid yaml +`, + want: nil, + wantErr: true, + }, + { + name: "empty manifest", + data: ` +--- +`, + want: []Manifest{}, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ParseManifests(tt.data) + if (err != nil) != tt.wantErr { + t.Errorf("ParseManifests() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("ParseManifests() got = %v, want %v", got, tt.want) + } + }) + } +} + +func TestLoadPlainYAMLManifests(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + dir string + names []string + configFilename string + setup func(dir string) error + want []Manifest + wantErr bool + }{ + { + name: "load single manifest", + dir: "testdata/single", + names: []string{"configmap.yaml"}, + configFilename: "pipecd-config.yaml", + setup: func(dir string) error { + return os.WriteFile(filepath.Join(dir, "configmap.yaml"), []byte(` +apiVersion: v1 +kind: ConfigMap +metadata: + name: test-config +`), 0644) + }, + want: []Manifest{ + { + Key: ResourceKey{ + APIVersion: "v1", + Kind: "ConfigMap", + Name: "test-config", + }, + Body: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": map[string]interface{}{ + "name": "test-config", + }, + }, + }, + }, + }, + wantErr: false, + }, + { + name: "ignore config file", + dir: "testdata/ignore-config", + names: []string{}, + configFilename: "pipecd-config.yaml", + setup: func(dir string) error { + // Place dummy files to ensure the loader ignores them. + if err := os.WriteFile(filepath.Join(dir, "pipecd-config.yaml"), []byte(` +apiVersion: v1 +kind: ConfigMap +metadata: + name: pipecd-config +`), 0644); err != nil { + return err + } + return os.WriteFile(filepath.Join(dir, "service.yaml"), []byte(` +apiVersion: v1 +kind: Service +metadata: + name: test-service +`), 0644) + }, + want: []Manifest{ + { + Key: ResourceKey{ + APIVersion: "v1", + Kind: "Service", + Name: "test-service", + }, + Body: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "Service", + "metadata": map[string]interface{}{ + "name": "test-service", + }, + }, + }, + }, + }, + wantErr: false, + }, + { + name: "load multiple manifests", + dir: "testdata/multiple", + names: []string{"configmap.yaml", "service.yaml"}, + configFilename: "pipecd-config.yaml", + setup: func(dir string) error { + if err := os.WriteFile(filepath.Join(dir, "configmap.yaml"), []byte(` +apiVersion: v1 +kind: ConfigMap +metadata: + name: test-config +`), 0644); err != nil { + return err + } + return os.WriteFile(filepath.Join(dir, "service.yaml"), []byte(` +apiVersion: v1 +kind: Service +metadata: + name: test-service +`), 0644) + }, + want: []Manifest{ + { + Key: ResourceKey{ + APIVersion: "v1", + Kind: "ConfigMap", + Name: "test-config", + }, + Body: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": map[string]interface{}{ + "name": "test-config", + }, + }, + }, + }, + { + Key: ResourceKey{ + APIVersion: "v1", + Kind: "Service", + Name: "test-service", + }, + Body: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "Service", + "metadata": map[string]interface{}{ + "name": "test-service", + }, + }, + }, + }, + }, + wantErr: false, + }, + { + name: "invalid manifest", + dir: "testdata/invalid", + names: []string{"invalid.yaml"}, + configFilename: "pipecd-config.yaml", + setup: func(dir string) error { + return os.WriteFile(filepath.Join(dir, "invalid.yaml"), []byte(` +invalid yaml content +`), 0644) + }, + want: nil, + wantErr: true, + }, + { + name: "no manifests", + dir: "testdata/empty", + names: []string{}, + configFilename: "pipecd-config.yaml", + setup: func(dir string) error { + return nil + }, + want: []Manifest{}, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + dir := filepath.Join(t.TempDir(), tt.dir) + require.NoError(t, os.MkdirAll(dir, 0755)) + + if tt.setup != nil { + require.NoError(t, tt.setup(dir)) + } + + got, err := LoadPlainYAMLManifests(dir, tt.names, tt.configFilename) + if tt.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + assert.ElementsMatch(t, tt.want, got) + }) + } +} diff --git a/pkg/app/pipedv1/plugin/kubernetes/provider/manifest.go b/pkg/app/pipedv1/plugin/kubernetes/provider/manifest.go index 9856d60558..93b3cc7b9e 100644 --- a/pkg/app/pipedv1/plugin/kubernetes/provider/manifest.go +++ b/pkg/app/pipedv1/plugin/kubernetes/provider/manifest.go @@ -18,7 +18,7 @@ import "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" // Manifest represents a Kubernetes resource manifest. type Manifest struct { - // TODO: define ResourceKey and add as a field here. + Key ResourceKey Body *unstructured.Unstructured } diff --git a/pkg/app/pipedv1/plugin/kubernetes/provider/resource.go b/pkg/app/pipedv1/plugin/kubernetes/provider/resource.go index 35d95aff8f..3337e1d4d6 100644 --- a/pkg/app/pipedv1/plugin/kubernetes/provider/resource.go +++ b/pkg/app/pipedv1/plugin/kubernetes/provider/resource.go @@ -14,4 +14,37 @@ package provider +import ( + "fmt" + + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" +) + + + const KindDeployment = "Deployment" + +type ResourceKey struct { + APIVersion string + Kind string + Namespace string + Name string +} + +func (k ResourceKey) String() string { + return fmt.Sprintf("%s:%s:%s:%s", k.APIVersion, k.Kind, k.Namespace, k.Name) +} + +func (k ResourceKey) ReadableString() string { + return fmt.Sprintf("name=%q, kind=%q, namespace=%q, apiVersion=%q", k.Name, k.Kind, k.Namespace, k.APIVersion) +} + +func MakeResourceKey(obj *unstructured.Unstructured) ResourceKey { + k := ResourceKey{ + APIVersion: obj.GetAPIVersion(), + Kind: obj.GetKind(), + Namespace: obj.GetNamespace(), + Name: obj.GetName(), + } + return k +} From 4f5446caa90a2e812d3215484796f82c493acaf9 Mon Sep 17 00:00:00 2001 From: Khanh Tran <32532742+khanhtc1202@users.noreply.github.com> Date: Wed, 23 Oct 2024 09:32:32 +0700 Subject: [PATCH 66/84] Init rfc for piped plugin arch (#5285) * Init rfc for piped plugin arch Signed-off-by: khanhtc1202 * Update docs/rfcs/0015-pipecd-plugin-arch-meta.md Co-authored-by: Tetsuya Kikuchi <97105818+t-kikuc@users.noreply.github.com> Signed-off-by: Khanh Tran <32532742+khanhtc1202@users.noreply.github.com> * Update docs/rfcs/0015-pipecd-plugin-arch-meta.md Co-authored-by: Tetsuya Kikuchi <97105818+t-kikuc@users.noreply.github.com> Signed-off-by: Khanh Tran <32532742+khanhtc1202@users.noreply.github.com> * Update docs/rfcs/0015-pipecd-plugin-arch-meta.md Co-authored-by: Tetsuya Kikuchi <97105818+t-kikuc@users.noreply.github.com> Signed-off-by: Khanh Tran <32532742+khanhtc1202@users.noreply.github.com> * Update docs/rfcs/0015-pipecd-plugin-arch-meta.md Co-authored-by: Tetsuya Kikuchi <97105818+t-kikuc@users.noreply.github.com> Signed-off-by: Khanh Tran <32532742+khanhtc1202@users.noreply.github.com> * Update docs/rfcs/0015-pipecd-plugin-arch-meta.md Co-authored-by: Tetsuya Kikuchi <97105818+t-kikuc@users.noreply.github.com> Signed-off-by: Khanh Tran <32532742+khanhtc1202@users.noreply.github.com> * Update rfc Signed-off-by: khanhtc1202 --------- Signed-off-by: khanhtc1202 Signed-off-by: Khanh Tran <32532742+khanhtc1202@users.noreply.github.com> Co-authored-by: Tetsuya Kikuchi <97105818+t-kikuc@users.noreply.github.com> --- docs/rfcs/0015-pipecd-plugin-arch-meta.md | 163 ++++++++++++++++++++++ docs/rfcs/assets/0015-config-context.png | Bin 0 -> 99307 bytes docs/rfcs/assets/0015-piped-protocol.png | Bin 0 -> 35921 bytes 3 files changed, 163 insertions(+) create mode 100644 docs/rfcs/0015-pipecd-plugin-arch-meta.md create mode 100644 docs/rfcs/assets/0015-config-context.png create mode 100644 docs/rfcs/assets/0015-piped-protocol.png diff --git a/docs/rfcs/0015-pipecd-plugin-arch-meta.md b/docs/rfcs/0015-pipecd-plugin-arch-meta.md new file mode 100644 index 0000000000..c826b85723 --- /dev/null +++ b/docs/rfcs/0015-pipecd-plugin-arch-meta.md @@ -0,0 +1,163 @@ +- Start Date: 2024-10-01 +- Target Version: 1.0 + +# Summary + +This can be consider as the biggest step forward to make PipeCD fit its vision: The one CD for all. + +# Motivation + +Up to this time, PipeCD archived several goals as its defined design in the initial stage, which included "being a consistent interface which can be used to make progressive delivery process easy for many application platforms". As its goal, the PipeCD currently supports 5 kind of application platforms, which are Kubernetes, ECS, Terraform, Lambda, and Cloud Run. But we want it not just like that, we want to make PipeCD support progressive delivery for whatever application platforms the market use today. + +To achieve that goal, there is only one way, which enables PipeCD to accept new platforms as plugin of the piped agent, which can free the limit of what platform PipeCD could support as a CD system. + +Also, with the power of plugin architecture, PipeCD can allow multi-versions of the same platform plugin, say the same plugin for CD on Kubernetes, but one is built-in and the other implemented by other developers. That is the future of PipeCD, and the maintainer team wants to bring it to. + +# Detailed design + +At the time this RFC is writen, there was serveral issues created on PipeCD main repo for tracking the plugin architecture tasks. This RFC summarizes the decisions the maintainer team has agreed to regarding the design of the PipeCD plugin architecture. + +After this line in the documentation, pipedv1 is a mention to plugin-arch piped, while pipedv0 is a mention to up-to-now piped. + +### The approach + +We agreed that pipedv0 will be supported as least until the end of 2025, which mean we have to find a way to ensure our single PipeCD control plane can work with both pipedv0 and pipedv1 at the same time. That leads to this issue at [pipecd/issues/5252](https://github.com/pipe-cd/pipecd/issues/5252). + +The key point of the control plane supports both pipedv0 and v1 approach is: platform related concepts like platform provider and kind are remained on the data model (for pipedv0), but we don't adding logic based on those concepts anymore. Pipedv1 logic will be built only around the plugins. + +As at this point, we have migration plan for platform related concepts in configuration as below + +**For platform provider** + +Instead of Platform Provider, we plan to introduce the config for the plugin and define deployTargets. + +```yaml +apiVersion: pipecd.dev/v1beta1 +kind: Piped +spec: +... + plugin: + - name: k8s_plugin + port: 8081 + deployTargets: + - name: dev + labels: + env: dev + config: # depends on plugins + masterURL: http://cluster-dev + kubeConfigPath: ./kubeconfig-dev +``` + +**For kind** + +Instead of Kind, we plan to introduce the label to represent the application kind. + +```yaml +apiVersion: pipecd.dev/v1beta1 +kind: Application +metadata: + labels: + kind: KUBERNETES # <- like this +spec: + name: myApp +``` + +The control plane will be updated so that it can accept platform provider from pipedv0 and deployTargets from pipedv1. + +### The protocol + +The protocol means to protocol used to connect between piped and its plugins. +As at the starting point, we had 2 options +- WASM +- gRPC + +For less study maintaining cost, we choose gRPC. Also, other components connections in PipeCD system used gRPC as well. + +![](assets/0015-piped-protocol.png) + +Sample piped plugins configuration + +```yaml +apiVersion: pipecd.dev/v1beta1 +kind: Piped +spec: +... + plugin: + - name: k8s_plugin + port: 8081 + sourceURL: # http or local path + deployTargets: + - name: dev + labels: + env: dev + config: # depends on plugins +``` + +Boostrap flow: +- Start piped (as entrypoint of piped pod container for example) +- Piped loads plugins' configuration +- Piped pull plugins from sourceURL and places it to piped plugins dir +- Piped starts plugins with plugins configuration passed at this point +- Plugins start its grpc servers +- Plugins ping and send status ready to piped plugin management grpc server +-> ready to be used at this point + +In case of failure: +- Piped failured: Restart containers / taskset as piped is the entrypoint +- Plugins failured: + - Ping failed -> piped will be notified that plugins are not working + - Based on piped logic, will restart the failured plugins (plugin management grpc logic) + +Left over question: In case of using launcher to managing piped, and the piped failured, the launcher will restart the piped, not the plugins, in that case, how should we manage the plugins' instances effectively? + +### The configuration + +The configuration which up-to-now placed in piped and application (kind specific) manifests will be rearrangement as below + +![](assets/0015-config-context.png) + +We need futher discussion on this but currently, we agreed to separate configuration as piped context and plugin context as above. + +### The interfaces + +The Piped agent has serveral main features, which are + +- Plan and execute deployment +- Calculate the plan preview result +- Calculate the drift between live and "git source" manifests +- Fetch and build the state of the live manifest +- Insight feature + +Across these features, we have to support 5 kinds of platforms (up to now), and each deployment we have serveral kind of stages, which are + +- K8s stages +- Cloud run stages +- Terraform stages +- ECS stages +- Lambda stages +- Wait stage +- Wait approval stage +- Analysis stage +- Script run stage +- Custom sync stage (deprecated) + +Based on the requirement, we would create plugins based on 3 big interfaces + +- Deployment interface: response to how deployment should be done (include: plan, execute deployment tasks) +- LiveState interface: response to fetch and build state of live manifest +- Drift interface: response to calculate the drift between 2 given manifests (live - git or running - target) + +Plugin can choose how and which interface it implement such as + +- K8s plugin can implement all 3 interfaces +- Wait approval plugin can implement only Deployment interface (since it has nothing to deal with livestate and drift features) + +Also, we may need support interface which implemented in piped side (optional) for tasks such as: Decrypt piped secret. + +### The running flow + +# Unresolved questions + +What parts of the design are still TBD? + +There are still many areas for improvement, and we welcome your ideas and suggestions. diff --git a/docs/rfcs/assets/0015-config-context.png b/docs/rfcs/assets/0015-config-context.png new file mode 100644 index 0000000000000000000000000000000000000000..c2afd3491ee8ed4ce7d67f2b1dd4b3d9c148c3cd GIT binary patch literal 99307 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 HpL)@-e9=5)x;Ifk*Z;K1y3I8UsxMy>X_zz) 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 EKenJ6R{#J2 literal 0 HcmV?d00001 diff --git a/docs/rfcs/assets/0015-piped-protocol.png b/docs/rfcs/assets/0015-piped-protocol.png new file mode 100644 index 0000000000000000000000000000000000000000..3e29f3629f7f49afde2d531e386ef641fc1dd585 GIT binary patch literal 35921 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 q>i-(dzizslZvRG(|1TrV774L%PNNx%N#g Date: Wed, 23 Oct 2024 11:33:32 +0900 Subject: [PATCH 67/84] Fix command-line-tool.md: GCR to GHCR (#5286) * Update command-line-tool.md We migrated from GCR to GHCR, but the documentation still mentioned GCR, so I updated it accordingly. Signed-off-by: reimei <34594195+Reimei1213@users.noreply.github.com> * docs: update docs-dev/user-guide/command-line-tool.md Signed-off-by: reimei --------- Signed-off-by: reimei <34594195+Reimei1213@users.noreply.github.com> Signed-off-by: reimei --- docs/content/en/docs-dev/user-guide/command-line-tool.md | 2 +- docs/content/en/docs-v0.49.x/user-guide/command-line-tool.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/content/en/docs-dev/user-guide/command-line-tool.md b/docs/content/en/docs-dev/user-guide/command-line-tool.md index 2daaadf44e..66615fa6fd 100644 --- a/docs/content/en/docs-dev/user-guide/command-line-tool.md +++ b/docs/content/en/docs-dev/user-guide/command-line-tool.md @@ -109,7 +109,7 @@ About [Homebrew](https://brew.sh/) ### Run in Docker container -We are storing every version of docker image for pipectl on Google Cloud Container Registry. +We are storing every version of docker image for pipectl on GitHub Container Registry. Available versions are [here](https://github.com/pipe-cd/pipecd/releases). ``` diff --git a/docs/content/en/docs-v0.49.x/user-guide/command-line-tool.md b/docs/content/en/docs-v0.49.x/user-guide/command-line-tool.md index 2daaadf44e..66615fa6fd 100644 --- a/docs/content/en/docs-v0.49.x/user-guide/command-line-tool.md +++ b/docs/content/en/docs-v0.49.x/user-guide/command-line-tool.md @@ -109,7 +109,7 @@ About [Homebrew](https://brew.sh/) ### Run in Docker container -We are storing every version of docker image for pipectl on Google Cloud Container Registry. +We are storing every version of docker image for pipectl on GitHub Container Registry. Available versions are [here](https://github.com/pipe-cd/pipecd/releases). ``` From 2c02136258388ab0d26a87fe15ca5c81d92f17c0 Mon Sep 17 00:00:00 2001 From: Khanh Tran <32532742+khanhtc1202@users.noreply.github.com> Date: Wed, 23 Oct 2024 13:06:35 +0700 Subject: [PATCH 68/84] Add note to aware users for checking piped repositories config (#5288) Signed-off-by: khanhtc1202 --- .../user-guide/managing-application/adding-an-application.md | 2 ++ .../user-guide/managing-application/adding-an-application.md | 2 ++ 2 files changed, 4 insertions(+) diff --git a/docs/content/en/docs-dev/user-guide/managing-application/adding-an-application.md b/docs/content/en/docs-dev/user-guide/managing-application/adding-an-application.md index 822b446c99..b9e4599326 100644 --- a/docs/content/en/docs-dev/user-guide/managing-application/adding-an-application.md +++ b/docs/content/en/docs-dev/user-guide/managing-application/adding-an-application.md @@ -11,6 +11,8 @@ It represents the service which you are going to deploy. With PipeCD, all applic Each application can be handled by one and only one `piped`. Currently, PipeCD is supporting 5 kinds of application: Kubernetes, Terraform, CloudRun, Lambda, ECS. +> Note: Be sure your application manifests repository is listed in [Piped managing repositories configuration](../managing-piped/configuration-reference/#gitrepository:~:text=No-,repositories,-%5B%5DRepository). + Before deploying an application, it must be registered to help PipeCD knows - where the application configuration is placed - which `piped` should handle it and which platform the application should be deployed to diff --git a/docs/content/en/docs-v0.49.x/user-guide/managing-application/adding-an-application.md b/docs/content/en/docs-v0.49.x/user-guide/managing-application/adding-an-application.md index 822b446c99..b9e4599326 100644 --- a/docs/content/en/docs-v0.49.x/user-guide/managing-application/adding-an-application.md +++ b/docs/content/en/docs-v0.49.x/user-guide/managing-application/adding-an-application.md @@ -11,6 +11,8 @@ It represents the service which you are going to deploy. With PipeCD, all applic Each application can be handled by one and only one `piped`. Currently, PipeCD is supporting 5 kinds of application: Kubernetes, Terraform, CloudRun, Lambda, ECS. +> Note: Be sure your application manifests repository is listed in [Piped managing repositories configuration](../managing-piped/configuration-reference/#gitrepository:~:text=No-,repositories,-%5B%5DRepository). + Before deploying an application, it must be registered to help PipeCD knows - where the application configuration is placed - which `piped` should handle it and which platform the application should be deployed to From db18917e6070d57491681d88cbb5bfc1d33a4ff4 Mon Sep 17 00:00:00 2001 From: Shinnosuke Sawada-Dazai Date: Wed, 23 Oct 2024 15:57:20 +0900 Subject: [PATCH 69/84] Move model.DeploymentSource to deployment.DeploymentSource (#5290) Signed-off-by: Shinnosuke Sawada-Dazai --- pkg/app/pipedv1/controller/planner.go | 4 +- pkg/app/pipedv1/controller/planner_test.go | 4 +- pkg/model/deployment_source.pb.go | 198 -------- pkg/model/deployment_source.pb.validate.go | 144 ------ pkg/model/deployment_source.proto | 31 -- pkg/plugin/api/v1alpha1/deployment/api.pb.go | 427 +++++++++++------- .../v1alpha1/deployment/api.pb.validate.go | 108 +++++ pkg/plugin/api/v1alpha1/deployment/api.proto | 17 +- web/model/deployment_source_pb.d.ts | 36 -- web/model/deployment_source_pb.js | 290 ------------ 10 files changed, 391 insertions(+), 868 deletions(-) delete mode 100644 pkg/model/deployment_source.pb.go delete mode 100644 pkg/model/deployment_source.pb.validate.go delete mode 100644 pkg/model/deployment_source.proto delete mode 100644 web/model/deployment_source_pb.d.ts delete mode 100644 web/model/deployment_source_pb.js diff --git a/pkg/app/pipedv1/controller/planner.go b/pkg/app/pipedv1/controller/planner.go index 276a2124d6..4efa554c5b 100644 --- a/pkg/app/pipedv1/controller/planner.go +++ b/pkg/app/pipedv1/controller/planner.go @@ -190,7 +190,7 @@ func (p *planner) Run(ctx context.Context) error { }() // TODO: Prepare running deploy source and target deploy source. - var runningDS, targetDS *model.DeploymentSource + var runningDS, targetDS *deployment.DeploymentSource // repoCfg := config.PipedRepository{ // RepoID: p.deployment.GitPath.Repo.Id, @@ -243,7 +243,7 @@ func (p *planner) Run(ctx context.Context) error { // - CommitMatcher ensure pipeline/quick sync based on the commit message // - Force quick sync if there is no previous deployment (aka. this is the first deploy) // - Based on PlannerService.DetermineStrategy returned by plugins -func (p *planner) buildPlan(ctx context.Context, runningDS, targetDS *model.DeploymentSource) (*plannerOutput, error) { +func (p *planner) buildPlan(ctx context.Context, runningDS, targetDS *deployment.DeploymentSource) (*plannerOutput, error) { out := &plannerOutput{} input := &deployment.PlanPluginInput{ diff --git a/pkg/app/pipedv1/controller/planner_test.go b/pkg/app/pipedv1/controller/planner_test.go index be4ad3cf78..9947761c91 100644 --- a/pkg/app/pipedv1/controller/planner_test.go +++ b/pkg/app/pipedv1/controller/planner_test.go @@ -916,7 +916,7 @@ func TestPlanner_BuildPlan(t *testing.T) { planner.lastSuccessfulCommitHash = "123" } - runningDS := &model.DeploymentSource{} + runningDS := &deployment.DeploymentSource{} type genericConfig struct { Kind config.Kind `json:"kind"` @@ -931,7 +931,7 @@ func TestPlanner_BuildPlan(t *testing.T) { }) require.NoError(t, err) - targetDS := &model.DeploymentSource{ + targetDS := &deployment.DeploymentSource{ ApplicationConfig: jsonBytes, } out, err := planner.buildPlan(context.TODO(), runningDS, targetDS) diff --git a/pkg/model/deployment_source.pb.go b/pkg/model/deployment_source.pb.go deleted file mode 100644 index b44071830a..0000000000 --- a/pkg/model/deployment_source.pb.go +++ /dev/null @@ -1,198 +0,0 @@ -// 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. - -// Code generated by protoc-gen-go. DO NOT EDIT. -// versions: -// protoc-gen-go v1.27.1 -// protoc v3.21.12 -// source: pkg/model/deployment_source.proto - -package model - -import ( - protoreflect "google.golang.org/protobuf/reflect/protoreflect" - protoimpl "google.golang.org/protobuf/runtime/protoimpl" - reflect "reflect" - sync "sync" -) - -const ( - // Verify that this generated code is sufficiently up-to-date. - _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) - // Verify that runtime/protoimpl is sufficiently up-to-date. - _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) -) - -type DeploymentSource struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - - // The application directory where the source code is located. - ApplicationDirectory string `protobuf:"bytes,1,opt,name=application_directory,json=applicationDirectory,proto3" json:"application_directory,omitempty"` - // The git commit revision of the source code. - Revision string `protobuf:"bytes,2,opt,name=revision,proto3" json:"revision,omitempty"` - // The configuration of the application which is specific for plugins. - ApplicationConfig []byte `protobuf:"bytes,3,opt,name=application_config,json=applicationConfig,proto3" json:"application_config,omitempty"` - // The filename of the application configuration file. - // The plugins can use this to avoid mistakenly reading this file as a manifest. - ApplicationConfigFilename string `protobuf:"bytes,4,opt,name=application_config_filename,json=applicationConfigFilename,proto3" json:"application_config_filename,omitempty"` -} - -func (x *DeploymentSource) Reset() { - *x = DeploymentSource{} - if protoimpl.UnsafeEnabled { - mi := &file_pkg_model_deployment_source_proto_msgTypes[0] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } -} - -func (x *DeploymentSource) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*DeploymentSource) ProtoMessage() {} - -func (x *DeploymentSource) ProtoReflect() protoreflect.Message { - mi := &file_pkg_model_deployment_source_proto_msgTypes[0] - if protoimpl.UnsafeEnabled && x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use DeploymentSource.ProtoReflect.Descriptor instead. -func (*DeploymentSource) Descriptor() ([]byte, []int) { - return file_pkg_model_deployment_source_proto_rawDescGZIP(), []int{0} -} - -func (x *DeploymentSource) GetApplicationDirectory() string { - if x != nil { - return x.ApplicationDirectory - } - return "" -} - -func (x *DeploymentSource) GetRevision() string { - if x != nil { - return x.Revision - } - return "" -} - -func (x *DeploymentSource) GetApplicationConfig() []byte { - if x != nil { - return x.ApplicationConfig - } - return nil -} - -func (x *DeploymentSource) GetApplicationConfigFilename() string { - if x != nil { - return x.ApplicationConfigFilename - } - return "" -} - -var File_pkg_model_deployment_source_proto protoreflect.FileDescriptor - -var file_pkg_model_deployment_source_proto_rawDesc = []byte{ - 0x0a, 0x21, 0x70, 0x6b, 0x67, 0x2f, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x2f, 0x64, 0x65, 0x70, 0x6c, - 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x5f, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x70, 0x72, - 0x6f, 0x74, 0x6f, 0x12, 0x05, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x22, 0xd2, 0x01, 0x0a, 0x10, 0x44, - 0x65, 0x70, 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x53, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, - 0x33, 0x0a, 0x15, 0x61, 0x70, 0x70, 0x6c, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x64, - 0x69, 0x72, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x14, - 0x61, 0x70, 0x70, 0x6c, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x44, 0x69, 0x72, 0x65, 0x63, - 0x74, 0x6f, 0x72, 0x79, 0x12, 0x1a, 0x0a, 0x08, 0x72, 0x65, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, - 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x72, 0x65, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, - 0x12, 0x2d, 0x0a, 0x12, 0x61, 0x70, 0x70, 0x6c, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, - 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x11, 0x61, 0x70, - 0x70, 0x6c, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, - 0x3e, 0x0a, 0x1b, 0x61, 0x70, 0x70, 0x6c, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x63, - 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x5f, 0x66, 0x69, 0x6c, 0x65, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x04, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x19, 0x61, 0x70, 0x70, 0x6c, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, - 0x6e, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x46, 0x69, 0x6c, 0x65, 0x6e, 0x61, 0x6d, 0x65, 0x42, - 0x25, 0x5a, 0x23, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x70, 0x69, - 0x70, 0x65, 0x2d, 0x63, 0x64, 0x2f, 0x70, 0x69, 0x70, 0x65, 0x63, 0x64, 0x2f, 0x70, 0x6b, 0x67, - 0x2f, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, -} - -var ( - file_pkg_model_deployment_source_proto_rawDescOnce sync.Once - file_pkg_model_deployment_source_proto_rawDescData = file_pkg_model_deployment_source_proto_rawDesc -) - -func file_pkg_model_deployment_source_proto_rawDescGZIP() []byte { - file_pkg_model_deployment_source_proto_rawDescOnce.Do(func() { - file_pkg_model_deployment_source_proto_rawDescData = protoimpl.X.CompressGZIP(file_pkg_model_deployment_source_proto_rawDescData) - }) - return file_pkg_model_deployment_source_proto_rawDescData -} - -var file_pkg_model_deployment_source_proto_msgTypes = make([]protoimpl.MessageInfo, 1) -var file_pkg_model_deployment_source_proto_goTypes = []interface{}{ - (*DeploymentSource)(nil), // 0: model.DeploymentSource -} -var file_pkg_model_deployment_source_proto_depIdxs = []int32{ - 0, // [0:0] is the sub-list for method output_type - 0, // [0:0] is the sub-list for method input_type - 0, // [0:0] is the sub-list for extension type_name - 0, // [0:0] is the sub-list for extension extendee - 0, // [0:0] is the sub-list for field type_name -} - -func init() { file_pkg_model_deployment_source_proto_init() } -func file_pkg_model_deployment_source_proto_init() { - if File_pkg_model_deployment_source_proto != nil { - return - } - if !protoimpl.UnsafeEnabled { - file_pkg_model_deployment_source_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*DeploymentSource); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } - } - type x struct{} - out := protoimpl.TypeBuilder{ - File: protoimpl.DescBuilder{ - GoPackagePath: reflect.TypeOf(x{}).PkgPath(), - RawDescriptor: file_pkg_model_deployment_source_proto_rawDesc, - NumEnums: 0, - NumMessages: 1, - NumExtensions: 0, - NumServices: 0, - }, - GoTypes: file_pkg_model_deployment_source_proto_goTypes, - DependencyIndexes: file_pkg_model_deployment_source_proto_depIdxs, - MessageInfos: file_pkg_model_deployment_source_proto_msgTypes, - }.Build() - File_pkg_model_deployment_source_proto = out.File - file_pkg_model_deployment_source_proto_rawDesc = nil - file_pkg_model_deployment_source_proto_goTypes = nil - file_pkg_model_deployment_source_proto_depIdxs = nil -} diff --git a/pkg/model/deployment_source.pb.validate.go b/pkg/model/deployment_source.pb.validate.go deleted file mode 100644 index 484099d384..0000000000 --- a/pkg/model/deployment_source.pb.validate.go +++ /dev/null @@ -1,144 +0,0 @@ -// Code generated by protoc-gen-validate. DO NOT EDIT. -// source: pkg/model/deployment_source.proto - -package model - -import ( - "bytes" - "errors" - "fmt" - "net" - "net/mail" - "net/url" - "regexp" - "sort" - "strings" - "time" - "unicode/utf8" - - "google.golang.org/protobuf/types/known/anypb" -) - -// ensure the imports are used -var ( - _ = bytes.MinRead - _ = errors.New("") - _ = fmt.Print - _ = utf8.UTFMax - _ = (*regexp.Regexp)(nil) - _ = (*strings.Reader)(nil) - _ = net.IPv4len - _ = time.Duration(0) - _ = (*url.URL)(nil) - _ = (*mail.Address)(nil) - _ = anypb.Any{} - _ = sort.Sort -) - -// Validate checks the field values on DeploymentSource with the rules defined -// in the proto definition for this message. If any rules are violated, the -// first error encountered is returned, or nil if there are no violations. -func (m *DeploymentSource) Validate() error { - return m.validate(false) -} - -// ValidateAll checks the field values on DeploymentSource with the rules -// defined in the proto definition for this message. If any rules are -// violated, the result is a list of violation errors wrapped in -// DeploymentSourceMultiError, or nil if none found. -func (m *DeploymentSource) ValidateAll() error { - return m.validate(true) -} - -func (m *DeploymentSource) validate(all bool) error { - if m == nil { - return nil - } - - var errors []error - - // no validation rules for ApplicationDirectory - - // no validation rules for Revision - - // no validation rules for ApplicationConfig - - // no validation rules for ApplicationConfigFilename - - if len(errors) > 0 { - return DeploymentSourceMultiError(errors) - } - - return nil -} - -// DeploymentSourceMultiError is an error wrapping multiple validation errors -// returned by DeploymentSource.ValidateAll() if the designated constraints -// aren't met. -type DeploymentSourceMultiError []error - -// Error returns a concatenation of all the error messages it wraps. -func (m DeploymentSourceMultiError) Error() string { - var msgs []string - for _, err := range m { - msgs = append(msgs, err.Error()) - } - return strings.Join(msgs, "; ") -} - -// AllErrors returns a list of validation violation errors. -func (m DeploymentSourceMultiError) AllErrors() []error { return m } - -// DeploymentSourceValidationError is the validation error returned by -// DeploymentSource.Validate if the designated constraints aren't met. -type DeploymentSourceValidationError struct { - field string - reason string - cause error - key bool -} - -// Field function returns field value. -func (e DeploymentSourceValidationError) Field() string { return e.field } - -// Reason function returns reason value. -func (e DeploymentSourceValidationError) Reason() string { return e.reason } - -// Cause function returns cause value. -func (e DeploymentSourceValidationError) Cause() error { return e.cause } - -// Key function returns key value. -func (e DeploymentSourceValidationError) Key() bool { return e.key } - -// ErrorName returns error name. -func (e DeploymentSourceValidationError) ErrorName() string { return "DeploymentSourceValidationError" } - -// Error satisfies the builtin error interface -func (e DeploymentSourceValidationError) Error() string { - cause := "" - if e.cause != nil { - cause = fmt.Sprintf(" | caused by: %v", e.cause) - } - - key := "" - if e.key { - key = "key for " - } - - return fmt.Sprintf( - "invalid %sDeploymentSource.%s: %s%s", - key, - e.field, - e.reason, - cause) -} - -var _ error = DeploymentSourceValidationError{} - -var _ interface { - Field() string - Reason() string - Key() bool - Cause() error - ErrorName() string -} = DeploymentSourceValidationError{} diff --git a/pkg/model/deployment_source.proto b/pkg/model/deployment_source.proto deleted file mode 100644 index efa35ba789..0000000000 --- a/pkg/model/deployment_source.proto +++ /dev/null @@ -1,31 +0,0 @@ -// 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. - -syntax = "proto3"; - -package model; - -option go_package = "github.com/pipe-cd/pipecd/pkg/model"; - -message DeploymentSource { - // The application directory where the source code is located. - string application_directory = 1; - // The git commit revision of the source code. - string revision = 2; - // The configuration of the application which is specific for plugins. - bytes application_config = 3; - // The filename of the application configuration file. - // The plugins can use this to avoid mistakenly reading this file as a manifest. - string application_config_filename = 4; -} diff --git a/pkg/plugin/api/v1alpha1/deployment/api.pb.go b/pkg/plugin/api/v1alpha1/deployment/api.pb.go index 118fa6d71d..d350a34a1b 100644 --- a/pkg/plugin/api/v1alpha1/deployment/api.pb.go +++ b/pkg/plugin/api/v1alpha1/deployment/api.pb.go @@ -537,9 +537,9 @@ type PlanPluginInput struct { // The configuration of plugin that handles the deployment. PluginConfig []byte `protobuf:"bytes,2,opt,name=plugin_config,json=pluginConfig,proto3" json:"plugin_config,omitempty"` // The running deployment source. - RunningDeploymentSource *model.DeploymentSource `protobuf:"bytes,3,opt,name=running_deployment_source,json=runningDeploymentSource,proto3" json:"running_deployment_source,omitempty"` + RunningDeploymentSource *DeploymentSource `protobuf:"bytes,3,opt,name=running_deployment_source,json=runningDeploymentSource,proto3" json:"running_deployment_source,omitempty"` // The target deployment source. - TargetDeploymentSource *model.DeploymentSource `protobuf:"bytes,4,opt,name=target_deployment_source,json=targetDeploymentSource,proto3" json:"target_deployment_source,omitempty"` + TargetDeploymentSource *DeploymentSource `protobuf:"bytes,4,opt,name=target_deployment_source,json=targetDeploymentSource,proto3" json:"target_deployment_source,omitempty"` } func (x *PlanPluginInput) Reset() { @@ -588,20 +588,96 @@ func (x *PlanPluginInput) GetPluginConfig() []byte { return nil } -func (x *PlanPluginInput) GetRunningDeploymentSource() *model.DeploymentSource { +func (x *PlanPluginInput) GetRunningDeploymentSource() *DeploymentSource { if x != nil { return x.RunningDeploymentSource } return nil } -func (x *PlanPluginInput) GetTargetDeploymentSource() *model.DeploymentSource { +func (x *PlanPluginInput) GetTargetDeploymentSource() *DeploymentSource { if x != nil { return x.TargetDeploymentSource } return nil } +type DeploymentSource struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // The application directory where the source code is located. + ApplicationDirectory string `protobuf:"bytes,1,opt,name=application_directory,json=applicationDirectory,proto3" json:"application_directory,omitempty"` + // The git commit revision of the source code. + Revision string `protobuf:"bytes,2,opt,name=revision,proto3" json:"revision,omitempty"` + // The configuration of the application which is specific for plugins. + ApplicationConfig []byte `protobuf:"bytes,3,opt,name=application_config,json=applicationConfig,proto3" json:"application_config,omitempty"` + // The filename of the application configuration file. + // The plugins can use this to avoid mistakenly reading this file as a manifest. + ApplicationConfigFilename string `protobuf:"bytes,4,opt,name=application_config_filename,json=applicationConfigFilename,proto3" json:"application_config_filename,omitempty"` +} + +func (x *DeploymentSource) Reset() { + *x = DeploymentSource{} + if protoimpl.UnsafeEnabled { + mi := &file_pkg_plugin_api_v1alpha1_deployment_api_proto_msgTypes[11] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *DeploymentSource) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DeploymentSource) ProtoMessage() {} + +func (x *DeploymentSource) ProtoReflect() protoreflect.Message { + mi := &file_pkg_plugin_api_v1alpha1_deployment_api_proto_msgTypes[11] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DeploymentSource.ProtoReflect.Descriptor instead. +func (*DeploymentSource) Descriptor() ([]byte, []int) { + return file_pkg_plugin_api_v1alpha1_deployment_api_proto_rawDescGZIP(), []int{11} +} + +func (x *DeploymentSource) GetApplicationDirectory() string { + if x != nil { + return x.ApplicationDirectory + } + return "" +} + +func (x *DeploymentSource) GetRevision() string { + if x != nil { + return x.Revision + } + return "" +} + +func (x *DeploymentSource) GetApplicationConfig() []byte { + if x != nil { + return x.ApplicationConfig + } + return nil +} + +func (x *DeploymentSource) GetApplicationConfigFilename() string { + if x != nil { + return x.ApplicationConfigFilename + } + return "" +} + type BuildPipelineSyncStagesRequest_StageConfig struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache @@ -627,7 +703,7 @@ type BuildPipelineSyncStagesRequest_StageConfig struct { func (x *BuildPipelineSyncStagesRequest_StageConfig) Reset() { *x = BuildPipelineSyncStagesRequest_StageConfig{} if protoimpl.UnsafeEnabled { - mi := &file_pkg_plugin_api_v1alpha1_deployment_api_proto_msgTypes[11] + mi := &file_pkg_plugin_api_v1alpha1_deployment_api_proto_msgTypes[12] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -640,7 +716,7 @@ func (x *BuildPipelineSyncStagesRequest_StageConfig) String() string { func (*BuildPipelineSyncStagesRequest_StageConfig) ProtoMessage() {} func (x *BuildPipelineSyncStagesRequest_StageConfig) ProtoReflect() protoreflect.Message { - mi := &file_pkg_plugin_api_v1alpha1_deployment_api_proto_msgTypes[11] + mi := &file_pkg_plugin_api_v1alpha1_deployment_api_proto_msgTypes[12] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -710,149 +786,164 @@ var file_pkg_plugin_api_v1alpha1_deployment_api_proto_rawDesc = []byte{ 0x69, 0x64, 0x61, 0x74, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x16, 0x70, 0x6b, 0x67, 0x2f, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x2f, 0x63, 0x6f, 0x6d, 0x6d, 0x6f, 0x6e, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x1a, 0x70, 0x6b, 0x67, 0x2f, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x2f, 0x64, - 0x65, 0x70, 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, - 0x21, 0x70, 0x6b, 0x67, 0x2f, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x2f, 0x64, 0x65, 0x70, 0x6c, 0x6f, - 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x5f, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x70, 0x72, 0x6f, - 0x74, 0x6f, 0x22, 0x6f, 0x0a, 0x18, 0x44, 0x65, 0x74, 0x65, 0x72, 0x6d, 0x69, 0x6e, 0x65, 0x56, - 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x53, - 0x0a, 0x05, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x33, 0x2e, - 0x67, 0x72, 0x70, 0x63, 0x2e, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x2e, 0x64, 0x65, 0x70, 0x6c, - 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x70, 0x69, 0x2e, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, - 0x61, 0x31, 0x2e, 0x50, 0x6c, 0x61, 0x6e, 0x50, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x49, 0x6e, 0x70, - 0x75, 0x74, 0x42, 0x08, 0xfa, 0x42, 0x05, 0x8a, 0x01, 0x02, 0x10, 0x01, 0x52, 0x05, 0x69, 0x6e, - 0x70, 0x75, 0x74, 0x22, 0x4f, 0x0a, 0x19, 0x44, 0x65, 0x74, 0x65, 0x72, 0x6d, 0x69, 0x6e, 0x65, - 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, - 0x12, 0x32, 0x0a, 0x08, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x01, 0x20, 0x03, - 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x2e, 0x41, 0x72, 0x74, 0x69, 0x66, - 0x61, 0x63, 0x74, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x08, 0x76, 0x65, 0x72, 0x73, - 0x69, 0x6f, 0x6e, 0x73, 0x22, 0x6f, 0x0a, 0x18, 0x44, 0x65, 0x74, 0x65, 0x72, 0x6d, 0x69, 0x6e, - 0x65, 0x53, 0x74, 0x72, 0x61, 0x74, 0x65, 0x67, 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, - 0x12, 0x53, 0x0a, 0x05, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, - 0x33, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x2e, 0x64, 0x65, - 0x70, 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x70, 0x69, 0x2e, 0x76, 0x31, 0x61, 0x6c, - 0x70, 0x68, 0x61, 0x31, 0x2e, 0x50, 0x6c, 0x61, 0x6e, 0x50, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x49, - 0x6e, 0x70, 0x75, 0x74, 0x42, 0x08, 0xfa, 0x42, 0x05, 0x8a, 0x01, 0x02, 0x10, 0x01, 0x52, 0x05, - 0x69, 0x6e, 0x70, 0x75, 0x74, 0x22, 0x6f, 0x0a, 0x19, 0x44, 0x65, 0x74, 0x65, 0x72, 0x6d, 0x69, - 0x6e, 0x65, 0x53, 0x74, 0x72, 0x61, 0x74, 0x65, 0x67, 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, - 0x73, 0x65, 0x12, 0x38, 0x0a, 0x0d, 0x73, 0x79, 0x6e, 0x63, 0x5f, 0x73, 0x74, 0x72, 0x61, 0x74, - 0x65, 0x67, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x13, 0x2e, 0x6d, 0x6f, 0x64, 0x65, - 0x6c, 0x2e, 0x53, 0x79, 0x6e, 0x63, 0x53, 0x74, 0x72, 0x61, 0x74, 0x65, 0x67, 0x79, 0x52, 0x0c, - 0x73, 0x79, 0x6e, 0x63, 0x53, 0x74, 0x72, 0x61, 0x74, 0x65, 0x67, 0x79, 0x12, 0x18, 0x0a, 0x07, - 0x73, 0x75, 0x6d, 0x6d, 0x61, 0x72, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x73, - 0x75, 0x6d, 0x6d, 0x61, 0x72, 0x79, 0x22, 0xc6, 0x02, 0x0a, 0x1e, 0x42, 0x75, 0x69, 0x6c, 0x64, - 0x50, 0x69, 0x70, 0x65, 0x6c, 0x69, 0x6e, 0x65, 0x53, 0x79, 0x6e, 0x63, 0x53, 0x74, 0x61, 0x67, - 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1a, 0x0a, 0x08, 0x72, 0x6f, 0x6c, - 0x6c, 0x62, 0x61, 0x63, 0x6b, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x08, 0x72, 0x6f, 0x6c, - 0x6c, 0x62, 0x61, 0x63, 0x6b, 0x12, 0x66, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x67, 0x65, 0x73, 0x18, - 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x4e, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x70, 0x6c, 0x75, - 0x67, 0x69, 0x6e, 0x2e, 0x64, 0x65, 0x70, 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x70, - 0x69, 0x2e, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2e, 0x42, 0x75, 0x69, 0x6c, 0x64, - 0x50, 0x69, 0x70, 0x65, 0x6c, 0x69, 0x6e, 0x65, 0x53, 0x79, 0x6e, 0x63, 0x53, 0x74, 0x61, 0x67, - 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x2e, 0x53, 0x74, 0x61, 0x67, 0x65, 0x43, - 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x06, 0x73, 0x74, 0x61, 0x67, 0x65, 0x73, 0x1a, 0x9f, 0x01, - 0x0a, 0x0b, 0x53, 0x74, 0x61, 0x67, 0x65, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x0e, 0x0a, - 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x1b, 0x0a, - 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x42, 0x07, 0xfa, 0x42, 0x04, - 0x72, 0x02, 0x10, 0x01, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x64, 0x65, - 0x73, 0x63, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x64, 0x65, 0x73, 0x63, 0x12, 0x18, - 0x0a, 0x07, 0x74, 0x69, 0x6d, 0x65, 0x6f, 0x75, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x07, 0x74, 0x69, 0x6d, 0x65, 0x6f, 0x75, 0x74, 0x12, 0x1d, 0x0a, 0x05, 0x69, 0x6e, 0x64, 0x65, - 0x78, 0x18, 0x05, 0x20, 0x01, 0x28, 0x05, 0x42, 0x07, 0xfa, 0x42, 0x04, 0x1a, 0x02, 0x28, 0x00, - 0x52, 0x05, 0x69, 0x6e, 0x64, 0x65, 0x78, 0x12, 0x16, 0x0a, 0x06, 0x63, 0x6f, 0x6e, 0x66, 0x69, - 0x67, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x06, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x22, - 0x4f, 0x0a, 0x1f, 0x42, 0x75, 0x69, 0x6c, 0x64, 0x50, 0x69, 0x70, 0x65, 0x6c, 0x69, 0x6e, 0x65, - 0x53, 0x79, 0x6e, 0x63, 0x53, 0x74, 0x61, 0x67, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, - 0x73, 0x65, 0x12, 0x2c, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x67, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, - 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x2e, 0x50, 0x69, 0x70, 0x65, 0x6c, - 0x69, 0x6e, 0x65, 0x53, 0x74, 0x61, 0x67, 0x65, 0x52, 0x06, 0x73, 0x74, 0x61, 0x67, 0x65, 0x73, - 0x22, 0x5a, 0x0a, 0x1b, 0x42, 0x75, 0x69, 0x6c, 0x64, 0x51, 0x75, 0x69, 0x63, 0x6b, 0x53, 0x79, - 0x6e, 0x63, 0x53, 0x74, 0x61, 0x67, 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, - 0x1f, 0x0a, 0x0b, 0x73, 0x74, 0x61, 0x67, 0x65, 0x5f, 0x69, 0x6e, 0x64, 0x65, 0x78, 0x18, 0x01, - 0x20, 0x01, 0x28, 0x05, 0x52, 0x0a, 0x73, 0x74, 0x61, 0x67, 0x65, 0x49, 0x6e, 0x64, 0x65, 0x78, - 0x12, 0x1a, 0x0a, 0x08, 0x72, 0x6f, 0x6c, 0x6c, 0x62, 0x61, 0x63, 0x6b, 0x18, 0x02, 0x20, 0x01, - 0x28, 0x08, 0x52, 0x08, 0x72, 0x6f, 0x6c, 0x6c, 0x62, 0x61, 0x63, 0x6b, 0x22, 0x4c, 0x0a, 0x1c, - 0x42, 0x75, 0x69, 0x6c, 0x64, 0x51, 0x75, 0x69, 0x63, 0x6b, 0x53, 0x79, 0x6e, 0x63, 0x53, 0x74, - 0x61, 0x67, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x2c, 0x0a, 0x06, - 0x73, 0x74, 0x61, 0x67, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x6d, - 0x6f, 0x64, 0x65, 0x6c, 0x2e, 0x50, 0x69, 0x70, 0x65, 0x6c, 0x69, 0x6e, 0x65, 0x53, 0x74, 0x61, - 0x67, 0x65, 0x52, 0x06, 0x73, 0x74, 0x61, 0x67, 0x65, 0x73, 0x22, 0x1b, 0x0a, 0x19, 0x46, 0x65, - 0x74, 0x63, 0x68, 0x44, 0x65, 0x66, 0x69, 0x6e, 0x65, 0x64, 0x53, 0x74, 0x61, 0x67, 0x65, 0x73, - 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0x34, 0x0a, 0x1a, 0x46, 0x65, 0x74, 0x63, 0x68, - 0x44, 0x65, 0x66, 0x69, 0x6e, 0x65, 0x64, 0x53, 0x74, 0x61, 0x67, 0x65, 0x73, 0x52, 0x65, 0x73, - 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x67, 0x65, 0x73, 0x18, - 0x01, 0x20, 0x03, 0x28, 0x09, 0x52, 0x06, 0x73, 0x74, 0x61, 0x67, 0x65, 0x73, 0x22, 0x9b, 0x02, - 0x0a, 0x0f, 0x50, 0x6c, 0x61, 0x6e, 0x50, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x49, 0x6e, 0x70, 0x75, - 0x74, 0x12, 0x3b, 0x0a, 0x0a, 0x64, 0x65, 0x70, 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x18, - 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x11, 0x2e, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x2e, 0x44, 0x65, - 0x70, 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x42, 0x08, 0xfa, 0x42, 0x05, 0x8a, 0x01, 0x02, - 0x10, 0x01, 0x52, 0x0a, 0x64, 0x65, 0x70, 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x12, 0x23, - 0x0a, 0x0d, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x5f, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, - 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0c, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x43, 0x6f, 0x6e, - 0x66, 0x69, 0x67, 0x12, 0x53, 0x0a, 0x19, 0x72, 0x75, 0x6e, 0x6e, 0x69, 0x6e, 0x67, 0x5f, 0x64, - 0x65, 0x70, 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x5f, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, - 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x17, 0x2e, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x2e, 0x44, - 0x65, 0x70, 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x53, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, - 0x17, 0x72, 0x75, 0x6e, 0x6e, 0x69, 0x6e, 0x67, 0x44, 0x65, 0x70, 0x6c, 0x6f, 0x79, 0x6d, 0x65, - 0x6e, 0x74, 0x53, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x51, 0x0a, 0x18, 0x74, 0x61, 0x72, 0x67, - 0x65, 0x74, 0x5f, 0x64, 0x65, 0x70, 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x5f, 0x73, 0x6f, - 0x75, 0x72, 0x63, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x17, 0x2e, 0x6d, 0x6f, 0x64, - 0x65, 0x6c, 0x2e, 0x44, 0x65, 0x70, 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x53, 0x6f, 0x75, - 0x72, 0x63, 0x65, 0x52, 0x16, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x44, 0x65, 0x70, 0x6c, 0x6f, - 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x53, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x32, 0x9a, 0x06, 0x0a, 0x11, - 0x44, 0x65, 0x70, 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, - 0x65, 0x12, 0x95, 0x01, 0x0a, 0x12, 0x46, 0x65, 0x74, 0x63, 0x68, 0x44, 0x65, 0x66, 0x69, 0x6e, - 0x65, 0x64, 0x53, 0x74, 0x61, 0x67, 0x65, 0x73, 0x12, 0x3d, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, - 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x2e, 0x64, 0x65, 0x70, 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, - 0x74, 0x61, 0x70, 0x69, 0x2e, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2e, 0x46, 0x65, - 0x74, 0x63, 0x68, 0x44, 0x65, 0x66, 0x69, 0x6e, 0x65, 0x64, 0x53, 0x74, 0x61, 0x67, 0x65, 0x73, - 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x3e, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x70, - 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x2e, 0x64, 0x65, 0x70, 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, - 0x61, 0x70, 0x69, 0x2e, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2e, 0x46, 0x65, 0x74, - 0x63, 0x68, 0x44, 0x65, 0x66, 0x69, 0x6e, 0x65, 0x64, 0x53, 0x74, 0x61, 0x67, 0x65, 0x73, 0x52, - 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x92, 0x01, 0x0a, 0x11, 0x44, 0x65, - 0x74, 0x65, 0x72, 0x6d, 0x69, 0x6e, 0x65, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x12, - 0x3c, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x2e, 0x64, 0x65, - 0x70, 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x70, 0x69, 0x2e, 0x76, 0x31, 0x61, 0x6c, - 0x70, 0x68, 0x61, 0x31, 0x2e, 0x44, 0x65, 0x74, 0x65, 0x72, 0x6d, 0x69, 0x6e, 0x65, 0x56, 0x65, - 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x3d, 0x2e, - 0x67, 0x72, 0x70, 0x63, 0x2e, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x2e, 0x64, 0x65, 0x70, 0x6c, - 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x70, 0x69, 0x2e, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, - 0x61, 0x31, 0x2e, 0x44, 0x65, 0x74, 0x65, 0x72, 0x6d, 0x69, 0x6e, 0x65, 0x56, 0x65, 0x72, 0x73, - 0x69, 0x6f, 0x6e, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x92, - 0x01, 0x0a, 0x11, 0x44, 0x65, 0x74, 0x65, 0x72, 0x6d, 0x69, 0x6e, 0x65, 0x53, 0x74, 0x72, 0x61, - 0x74, 0x65, 0x67, 0x79, 0x12, 0x3c, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x70, 0x6c, 0x75, 0x67, - 0x69, 0x6e, 0x2e, 0x64, 0x65, 0x70, 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x70, 0x69, - 0x2e, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2e, 0x44, 0x65, 0x74, 0x65, 0x72, 0x6d, - 0x69, 0x6e, 0x65, 0x53, 0x74, 0x72, 0x61, 0x74, 0x65, 0x67, 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, - 0x73, 0x74, 0x1a, 0x3d, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, + 0x65, 0x70, 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, + 0x6f, 0x0a, 0x18, 0x44, 0x65, 0x74, 0x65, 0x72, 0x6d, 0x69, 0x6e, 0x65, 0x56, 0x65, 0x72, 0x73, + 0x69, 0x6f, 0x6e, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x53, 0x0a, 0x05, 0x69, + 0x6e, 0x70, 0x75, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x33, 0x2e, 0x67, 0x72, 0x70, + 0x63, 0x2e, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x2e, 0x64, 0x65, 0x70, 0x6c, 0x6f, 0x79, 0x6d, + 0x65, 0x6e, 0x74, 0x61, 0x70, 0x69, 0x2e, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2e, + 0x50, 0x6c, 0x61, 0x6e, 0x50, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x49, 0x6e, 0x70, 0x75, 0x74, 0x42, + 0x08, 0xfa, 0x42, 0x05, 0x8a, 0x01, 0x02, 0x10, 0x01, 0x52, 0x05, 0x69, 0x6e, 0x70, 0x75, 0x74, + 0x22, 0x4f, 0x0a, 0x19, 0x44, 0x65, 0x74, 0x65, 0x72, 0x6d, 0x69, 0x6e, 0x65, 0x56, 0x65, 0x72, + 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x32, 0x0a, + 0x08, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, + 0x16, 0x2e, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x2e, 0x41, 0x72, 0x74, 0x69, 0x66, 0x61, 0x63, 0x74, + 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x08, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, + 0x73, 0x22, 0x6f, 0x0a, 0x18, 0x44, 0x65, 0x74, 0x65, 0x72, 0x6d, 0x69, 0x6e, 0x65, 0x53, 0x74, + 0x72, 0x61, 0x74, 0x65, 0x67, 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x53, 0x0a, + 0x05, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x33, 0x2e, 0x67, + 0x72, 0x70, 0x63, 0x2e, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x2e, 0x64, 0x65, 0x70, 0x6c, 0x6f, + 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x70, 0x69, 0x2e, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, + 0x31, 0x2e, 0x50, 0x6c, 0x61, 0x6e, 0x50, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x49, 0x6e, 0x70, 0x75, + 0x74, 0x42, 0x08, 0xfa, 0x42, 0x05, 0x8a, 0x01, 0x02, 0x10, 0x01, 0x52, 0x05, 0x69, 0x6e, 0x70, + 0x75, 0x74, 0x22, 0x6f, 0x0a, 0x19, 0x44, 0x65, 0x74, 0x65, 0x72, 0x6d, 0x69, 0x6e, 0x65, 0x53, + 0x74, 0x72, 0x61, 0x74, 0x65, 0x67, 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, + 0x38, 0x0a, 0x0d, 0x73, 0x79, 0x6e, 0x63, 0x5f, 0x73, 0x74, 0x72, 0x61, 0x74, 0x65, 0x67, 0x79, + 0x18, 0x01, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x13, 0x2e, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x2e, 0x53, + 0x79, 0x6e, 0x63, 0x53, 0x74, 0x72, 0x61, 0x74, 0x65, 0x67, 0x79, 0x52, 0x0c, 0x73, 0x79, 0x6e, + 0x63, 0x53, 0x74, 0x72, 0x61, 0x74, 0x65, 0x67, 0x79, 0x12, 0x18, 0x0a, 0x07, 0x73, 0x75, 0x6d, + 0x6d, 0x61, 0x72, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x73, 0x75, 0x6d, 0x6d, + 0x61, 0x72, 0x79, 0x22, 0xc6, 0x02, 0x0a, 0x1e, 0x42, 0x75, 0x69, 0x6c, 0x64, 0x50, 0x69, 0x70, + 0x65, 0x6c, 0x69, 0x6e, 0x65, 0x53, 0x79, 0x6e, 0x63, 0x53, 0x74, 0x61, 0x67, 0x65, 0x73, 0x52, + 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1a, 0x0a, 0x08, 0x72, 0x6f, 0x6c, 0x6c, 0x62, 0x61, + 0x63, 0x6b, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x08, 0x72, 0x6f, 0x6c, 0x6c, 0x62, 0x61, + 0x63, 0x6b, 0x12, 0x66, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x67, 0x65, 0x73, 0x18, 0x02, 0x20, 0x03, + 0x28, 0x0b, 0x32, 0x4e, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x2e, 0x64, 0x65, 0x70, 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x70, 0x69, 0x2e, 0x76, - 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2e, 0x44, 0x65, 0x74, 0x65, 0x72, 0x6d, 0x69, 0x6e, - 0x65, 0x53, 0x74, 0x72, 0x61, 0x74, 0x65, 0x67, 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, - 0x65, 0x22, 0x00, 0x12, 0xa4, 0x01, 0x0a, 0x17, 0x42, 0x75, 0x69, 0x6c, 0x64, 0x50, 0x69, 0x70, - 0x65, 0x6c, 0x69, 0x6e, 0x65, 0x53, 0x79, 0x6e, 0x63, 0x53, 0x74, 0x61, 0x67, 0x65, 0x73, 0x12, - 0x42, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x2e, 0x64, 0x65, - 0x70, 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x70, 0x69, 0x2e, 0x76, 0x31, 0x61, 0x6c, - 0x70, 0x68, 0x61, 0x31, 0x2e, 0x42, 0x75, 0x69, 0x6c, 0x64, 0x50, 0x69, 0x70, 0x65, 0x6c, 0x69, - 0x6e, 0x65, 0x53, 0x79, 0x6e, 0x63, 0x53, 0x74, 0x61, 0x67, 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, - 0x65, 0x73, 0x74, 0x1a, 0x43, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x70, 0x6c, 0x75, 0x67, 0x69, - 0x6e, 0x2e, 0x64, 0x65, 0x70, 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x70, 0x69, 0x2e, - 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2e, 0x42, 0x75, 0x69, 0x6c, 0x64, 0x50, 0x69, - 0x70, 0x65, 0x6c, 0x69, 0x6e, 0x65, 0x53, 0x79, 0x6e, 0x63, 0x53, 0x74, 0x61, 0x67, 0x65, 0x73, - 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x9b, 0x01, 0x0a, 0x14, 0x42, - 0x75, 0x69, 0x6c, 0x64, 0x51, 0x75, 0x69, 0x63, 0x6b, 0x53, 0x79, 0x6e, 0x63, 0x53, 0x74, 0x61, - 0x67, 0x65, 0x73, 0x12, 0x3f, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x70, 0x6c, 0x75, 0x67, 0x69, + 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2e, 0x42, 0x75, 0x69, 0x6c, 0x64, 0x50, 0x69, 0x70, + 0x65, 0x6c, 0x69, 0x6e, 0x65, 0x53, 0x79, 0x6e, 0x63, 0x53, 0x74, 0x61, 0x67, 0x65, 0x73, 0x52, + 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x2e, 0x53, 0x74, 0x61, 0x67, 0x65, 0x43, 0x6f, 0x6e, 0x66, + 0x69, 0x67, 0x52, 0x06, 0x73, 0x74, 0x61, 0x67, 0x65, 0x73, 0x1a, 0x9f, 0x01, 0x0a, 0x0b, 0x53, + 0x74, 0x61, 0x67, 0x65, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, + 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x1b, 0x0a, 0x04, 0x6e, 0x61, + 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x42, 0x07, 0xfa, 0x42, 0x04, 0x72, 0x02, 0x10, + 0x01, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x64, 0x65, 0x73, 0x63, 0x18, + 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x64, 0x65, 0x73, 0x63, 0x12, 0x18, 0x0a, 0x07, 0x74, + 0x69, 0x6d, 0x65, 0x6f, 0x75, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x74, 0x69, + 0x6d, 0x65, 0x6f, 0x75, 0x74, 0x12, 0x1d, 0x0a, 0x05, 0x69, 0x6e, 0x64, 0x65, 0x78, 0x18, 0x05, + 0x20, 0x01, 0x28, 0x05, 0x42, 0x07, 0xfa, 0x42, 0x04, 0x1a, 0x02, 0x28, 0x00, 0x52, 0x05, 0x69, + 0x6e, 0x64, 0x65, 0x78, 0x12, 0x16, 0x0a, 0x06, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x06, + 0x20, 0x01, 0x28, 0x0c, 0x52, 0x06, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x22, 0x4f, 0x0a, 0x1f, + 0x42, 0x75, 0x69, 0x6c, 0x64, 0x50, 0x69, 0x70, 0x65, 0x6c, 0x69, 0x6e, 0x65, 0x53, 0x79, 0x6e, + 0x63, 0x53, 0x74, 0x61, 0x67, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, + 0x2c, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x67, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, + 0x14, 0x2e, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x2e, 0x50, 0x69, 0x70, 0x65, 0x6c, 0x69, 0x6e, 0x65, + 0x53, 0x74, 0x61, 0x67, 0x65, 0x52, 0x06, 0x73, 0x74, 0x61, 0x67, 0x65, 0x73, 0x22, 0x5a, 0x0a, + 0x1b, 0x42, 0x75, 0x69, 0x6c, 0x64, 0x51, 0x75, 0x69, 0x63, 0x6b, 0x53, 0x79, 0x6e, 0x63, 0x53, + 0x74, 0x61, 0x67, 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1f, 0x0a, 0x0b, + 0x73, 0x74, 0x61, 0x67, 0x65, 0x5f, 0x69, 0x6e, 0x64, 0x65, 0x78, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x05, 0x52, 0x0a, 0x73, 0x74, 0x61, 0x67, 0x65, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x12, 0x1a, 0x0a, + 0x08, 0x72, 0x6f, 0x6c, 0x6c, 0x62, 0x61, 0x63, 0x6b, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, + 0x08, 0x72, 0x6f, 0x6c, 0x6c, 0x62, 0x61, 0x63, 0x6b, 0x22, 0x4c, 0x0a, 0x1c, 0x42, 0x75, 0x69, + 0x6c, 0x64, 0x51, 0x75, 0x69, 0x63, 0x6b, 0x53, 0x79, 0x6e, 0x63, 0x53, 0x74, 0x61, 0x67, 0x65, + 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x2c, 0x0a, 0x06, 0x73, 0x74, 0x61, + 0x67, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x6d, 0x6f, 0x64, 0x65, + 0x6c, 0x2e, 0x50, 0x69, 0x70, 0x65, 0x6c, 0x69, 0x6e, 0x65, 0x53, 0x74, 0x61, 0x67, 0x65, 0x52, + 0x06, 0x73, 0x74, 0x61, 0x67, 0x65, 0x73, 0x22, 0x1b, 0x0a, 0x19, 0x46, 0x65, 0x74, 0x63, 0x68, + 0x44, 0x65, 0x66, 0x69, 0x6e, 0x65, 0x64, 0x53, 0x74, 0x61, 0x67, 0x65, 0x73, 0x52, 0x65, 0x71, + 0x75, 0x65, 0x73, 0x74, 0x22, 0x34, 0x0a, 0x1a, 0x46, 0x65, 0x74, 0x63, 0x68, 0x44, 0x65, 0x66, + 0x69, 0x6e, 0x65, 0x64, 0x53, 0x74, 0x61, 0x67, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, + 0x73, 0x65, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x67, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, + 0x28, 0x09, 0x52, 0x06, 0x73, 0x74, 0x61, 0x67, 0x65, 0x73, 0x22, 0xd5, 0x02, 0x0a, 0x0f, 0x50, + 0x6c, 0x61, 0x6e, 0x50, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x49, 0x6e, 0x70, 0x75, 0x74, 0x12, 0x3b, + 0x0a, 0x0a, 0x64, 0x65, 0x70, 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x18, 0x01, 0x20, 0x01, + 0x28, 0x0b, 0x32, 0x11, 0x2e, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x2e, 0x44, 0x65, 0x70, 0x6c, 0x6f, + 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x42, 0x08, 0xfa, 0x42, 0x05, 0x8a, 0x01, 0x02, 0x10, 0x01, 0x52, + 0x0a, 0x64, 0x65, 0x70, 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x12, 0x23, 0x0a, 0x0d, 0x70, + 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x5f, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x02, 0x20, 0x01, + 0x28, 0x0c, 0x52, 0x0c, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, + 0x12, 0x70, 0x0a, 0x19, 0x72, 0x75, 0x6e, 0x6e, 0x69, 0x6e, 0x67, 0x5f, 0x64, 0x65, 0x70, 0x6c, + 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x5f, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x18, 0x03, 0x20, + 0x01, 0x28, 0x0b, 0x32, 0x34, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x2e, 0x64, 0x65, 0x70, 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x70, 0x69, 0x2e, - 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2e, 0x42, 0x75, 0x69, 0x6c, 0x64, 0x51, 0x75, - 0x69, 0x63, 0x6b, 0x53, 0x79, 0x6e, 0x63, 0x53, 0x74, 0x61, 0x67, 0x65, 0x73, 0x52, 0x65, 0x71, - 0x75, 0x65, 0x73, 0x74, 0x1a, 0x40, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x70, 0x6c, 0x75, 0x67, + 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2e, 0x44, 0x65, 0x70, 0x6c, 0x6f, 0x79, 0x6d, + 0x65, 0x6e, 0x74, 0x53, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x17, 0x72, 0x75, 0x6e, 0x6e, 0x69, + 0x6e, 0x67, 0x44, 0x65, 0x70, 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x53, 0x6f, 0x75, 0x72, + 0x63, 0x65, 0x12, 0x6e, 0x0a, 0x18, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x5f, 0x64, 0x65, 0x70, + 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x5f, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x18, 0x04, + 0x20, 0x01, 0x28, 0x0b, 0x32, 0x34, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x2e, 0x64, 0x65, 0x70, 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x70, 0x69, - 0x2e, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2e, 0x42, 0x75, 0x69, 0x6c, 0x64, 0x51, - 0x75, 0x69, 0x63, 0x6b, 0x53, 0x79, 0x6e, 0x63, 0x53, 0x74, 0x61, 0x67, 0x65, 0x73, 0x52, 0x65, - 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x42, 0x3e, 0x5a, 0x3c, 0x67, 0x69, 0x74, 0x68, - 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x70, 0x69, 0x70, 0x65, 0x2d, 0x63, 0x64, 0x2f, 0x70, - 0x69, 0x70, 0x65, 0x63, 0x64, 0x2f, 0x70, 0x6b, 0x67, 0x2f, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, - 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2f, 0x64, 0x65, - 0x70, 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, + 0x2e, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2e, 0x44, 0x65, 0x70, 0x6c, 0x6f, 0x79, + 0x6d, 0x65, 0x6e, 0x74, 0x53, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x16, 0x74, 0x61, 0x72, 0x67, + 0x65, 0x74, 0x44, 0x65, 0x70, 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x53, 0x6f, 0x75, 0x72, + 0x63, 0x65, 0x22, 0xd2, 0x01, 0x0a, 0x10, 0x44, 0x65, 0x70, 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, + 0x74, 0x53, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x33, 0x0a, 0x15, 0x61, 0x70, 0x70, 0x6c, 0x69, + 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x79, + 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x14, 0x61, 0x70, 0x70, 0x6c, 0x69, 0x63, 0x61, 0x74, + 0x69, 0x6f, 0x6e, 0x44, 0x69, 0x72, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x79, 0x12, 0x1a, 0x0a, 0x08, + 0x72, 0x65, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, + 0x72, 0x65, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x2d, 0x0a, 0x12, 0x61, 0x70, 0x70, 0x6c, + 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x03, + 0x20, 0x01, 0x28, 0x0c, 0x52, 0x11, 0x61, 0x70, 0x70, 0x6c, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, + 0x6e, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x3e, 0x0a, 0x1b, 0x61, 0x70, 0x70, 0x6c, 0x69, + 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x5f, 0x66, 0x69, + 0x6c, 0x65, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x19, 0x61, 0x70, + 0x70, 0x6c, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x46, + 0x69, 0x6c, 0x65, 0x6e, 0x61, 0x6d, 0x65, 0x32, 0x9a, 0x06, 0x0a, 0x11, 0x44, 0x65, 0x70, 0x6c, + 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x95, 0x01, + 0x0a, 0x12, 0x46, 0x65, 0x74, 0x63, 0x68, 0x44, 0x65, 0x66, 0x69, 0x6e, 0x65, 0x64, 0x53, 0x74, + 0x61, 0x67, 0x65, 0x73, 0x12, 0x3d, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x70, 0x6c, 0x75, 0x67, + 0x69, 0x6e, 0x2e, 0x64, 0x65, 0x70, 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x70, 0x69, + 0x2e, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2e, 0x46, 0x65, 0x74, 0x63, 0x68, 0x44, + 0x65, 0x66, 0x69, 0x6e, 0x65, 0x64, 0x53, 0x74, 0x61, 0x67, 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, + 0x65, 0x73, 0x74, 0x1a, 0x3e, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x70, 0x6c, 0x75, 0x67, 0x69, + 0x6e, 0x2e, 0x64, 0x65, 0x70, 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x70, 0x69, 0x2e, + 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2e, 0x46, 0x65, 0x74, 0x63, 0x68, 0x44, 0x65, + 0x66, 0x69, 0x6e, 0x65, 0x64, 0x53, 0x74, 0x61, 0x67, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, + 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x92, 0x01, 0x0a, 0x11, 0x44, 0x65, 0x74, 0x65, 0x72, 0x6d, + 0x69, 0x6e, 0x65, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x3c, 0x2e, 0x67, 0x72, + 0x70, 0x63, 0x2e, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x2e, 0x64, 0x65, 0x70, 0x6c, 0x6f, 0x79, + 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x70, 0x69, 0x2e, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, + 0x2e, 0x44, 0x65, 0x74, 0x65, 0x72, 0x6d, 0x69, 0x6e, 0x65, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, + 0x6e, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x3d, 0x2e, 0x67, 0x72, 0x70, 0x63, + 0x2e, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x2e, 0x64, 0x65, 0x70, 0x6c, 0x6f, 0x79, 0x6d, 0x65, + 0x6e, 0x74, 0x61, 0x70, 0x69, 0x2e, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2e, 0x44, + 0x65, 0x74, 0x65, 0x72, 0x6d, 0x69, 0x6e, 0x65, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x73, + 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x92, 0x01, 0x0a, 0x11, 0x44, + 0x65, 0x74, 0x65, 0x72, 0x6d, 0x69, 0x6e, 0x65, 0x53, 0x74, 0x72, 0x61, 0x74, 0x65, 0x67, 0x79, + 0x12, 0x3c, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x2e, 0x64, + 0x65, 0x70, 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x70, 0x69, 0x2e, 0x76, 0x31, 0x61, + 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2e, 0x44, 0x65, 0x74, 0x65, 0x72, 0x6d, 0x69, 0x6e, 0x65, 0x53, + 0x74, 0x72, 0x61, 0x74, 0x65, 0x67, 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x3d, + 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x2e, 0x64, 0x65, 0x70, + 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x70, 0x69, 0x2e, 0x76, 0x31, 0x61, 0x6c, 0x70, + 0x68, 0x61, 0x31, 0x2e, 0x44, 0x65, 0x74, 0x65, 0x72, 0x6d, 0x69, 0x6e, 0x65, 0x53, 0x74, 0x72, + 0x61, 0x74, 0x65, 0x67, 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, + 0xa4, 0x01, 0x0a, 0x17, 0x42, 0x75, 0x69, 0x6c, 0x64, 0x50, 0x69, 0x70, 0x65, 0x6c, 0x69, 0x6e, + 0x65, 0x53, 0x79, 0x6e, 0x63, 0x53, 0x74, 0x61, 0x67, 0x65, 0x73, 0x12, 0x42, 0x2e, 0x67, 0x72, + 0x70, 0x63, 0x2e, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x2e, 0x64, 0x65, 0x70, 0x6c, 0x6f, 0x79, + 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x70, 0x69, 0x2e, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, + 0x2e, 0x42, 0x75, 0x69, 0x6c, 0x64, 0x50, 0x69, 0x70, 0x65, 0x6c, 0x69, 0x6e, 0x65, 0x53, 0x79, + 0x6e, 0x63, 0x53, 0x74, 0x61, 0x67, 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, + 0x43, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x2e, 0x64, 0x65, + 0x70, 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x70, 0x69, 0x2e, 0x76, 0x31, 0x61, 0x6c, + 0x70, 0x68, 0x61, 0x31, 0x2e, 0x42, 0x75, 0x69, 0x6c, 0x64, 0x50, 0x69, 0x70, 0x65, 0x6c, 0x69, + 0x6e, 0x65, 0x53, 0x79, 0x6e, 0x63, 0x53, 0x74, 0x61, 0x67, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, + 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x9b, 0x01, 0x0a, 0x14, 0x42, 0x75, 0x69, 0x6c, 0x64, + 0x51, 0x75, 0x69, 0x63, 0x6b, 0x53, 0x79, 0x6e, 0x63, 0x53, 0x74, 0x61, 0x67, 0x65, 0x73, 0x12, + 0x3f, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x2e, 0x64, 0x65, + 0x70, 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x70, 0x69, 0x2e, 0x76, 0x31, 0x61, 0x6c, + 0x70, 0x68, 0x61, 0x31, 0x2e, 0x42, 0x75, 0x69, 0x6c, 0x64, 0x51, 0x75, 0x69, 0x63, 0x6b, 0x53, + 0x79, 0x6e, 0x63, 0x53, 0x74, 0x61, 0x67, 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, + 0x1a, 0x40, 0x2e, 0x67, 0x72, 0x70, 0x63, 0x2e, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x2e, 0x64, + 0x65, 0x70, 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x70, 0x69, 0x2e, 0x76, 0x31, 0x61, + 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2e, 0x42, 0x75, 0x69, 0x6c, 0x64, 0x51, 0x75, 0x69, 0x63, 0x6b, + 0x53, 0x79, 0x6e, 0x63, 0x53, 0x74, 0x61, 0x67, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, + 0x73, 0x65, 0x22, 0x00, 0x42, 0x3e, 0x5a, 0x3c, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, + 0x6f, 0x6d, 0x2f, 0x70, 0x69, 0x70, 0x65, 0x2d, 0x63, 0x64, 0x2f, 0x70, 0x69, 0x70, 0x65, 0x63, + 0x64, 0x2f, 0x70, 0x6b, 0x67, 0x2f, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x2f, 0x61, 0x70, 0x69, + 0x2f, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2f, 0x64, 0x65, 0x70, 0x6c, 0x6f, 0x79, + 0x6d, 0x65, 0x6e, 0x74, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, } var ( @@ -867,7 +958,7 @@ func file_pkg_plugin_api_v1alpha1_deployment_api_proto_rawDescGZIP() []byte { return file_pkg_plugin_api_v1alpha1_deployment_api_proto_rawDescData } -var file_pkg_plugin_api_v1alpha1_deployment_api_proto_msgTypes = make([]protoimpl.MessageInfo, 12) +var file_pkg_plugin_api_v1alpha1_deployment_api_proto_msgTypes = make([]protoimpl.MessageInfo, 13) var file_pkg_plugin_api_v1alpha1_deployment_api_proto_goTypes = []interface{}{ (*DetermineVersionsRequest)(nil), // 0: grpc.plugin.deploymentapi.v1alpha1.DetermineVersionsRequest (*DetermineVersionsResponse)(nil), // 1: grpc.plugin.deploymentapi.v1alpha1.DetermineVersionsResponse @@ -880,24 +971,24 @@ var file_pkg_plugin_api_v1alpha1_deployment_api_proto_goTypes = []interface{}{ (*FetchDefinedStagesRequest)(nil), // 8: grpc.plugin.deploymentapi.v1alpha1.FetchDefinedStagesRequest (*FetchDefinedStagesResponse)(nil), // 9: grpc.plugin.deploymentapi.v1alpha1.FetchDefinedStagesResponse (*PlanPluginInput)(nil), // 10: grpc.plugin.deploymentapi.v1alpha1.PlanPluginInput - (*BuildPipelineSyncStagesRequest_StageConfig)(nil), // 11: grpc.plugin.deploymentapi.v1alpha1.BuildPipelineSyncStagesRequest.StageConfig - (*model.ArtifactVersion)(nil), // 12: model.ArtifactVersion - (model.SyncStrategy)(0), // 13: model.SyncStrategy - (*model.PipelineStage)(nil), // 14: model.PipelineStage - (*model.Deployment)(nil), // 15: model.Deployment - (*model.DeploymentSource)(nil), // 16: model.DeploymentSource + (*DeploymentSource)(nil), // 11: grpc.plugin.deploymentapi.v1alpha1.DeploymentSource + (*BuildPipelineSyncStagesRequest_StageConfig)(nil), // 12: grpc.plugin.deploymentapi.v1alpha1.BuildPipelineSyncStagesRequest.StageConfig + (*model.ArtifactVersion)(nil), // 13: model.ArtifactVersion + (model.SyncStrategy)(0), // 14: model.SyncStrategy + (*model.PipelineStage)(nil), // 15: model.PipelineStage + (*model.Deployment)(nil), // 16: model.Deployment } var file_pkg_plugin_api_v1alpha1_deployment_api_proto_depIdxs = []int32{ 10, // 0: grpc.plugin.deploymentapi.v1alpha1.DetermineVersionsRequest.input:type_name -> grpc.plugin.deploymentapi.v1alpha1.PlanPluginInput - 12, // 1: grpc.plugin.deploymentapi.v1alpha1.DetermineVersionsResponse.versions:type_name -> model.ArtifactVersion + 13, // 1: grpc.plugin.deploymentapi.v1alpha1.DetermineVersionsResponse.versions:type_name -> model.ArtifactVersion 10, // 2: grpc.plugin.deploymentapi.v1alpha1.DetermineStrategyRequest.input:type_name -> grpc.plugin.deploymentapi.v1alpha1.PlanPluginInput - 13, // 3: grpc.plugin.deploymentapi.v1alpha1.DetermineStrategyResponse.sync_strategy:type_name -> model.SyncStrategy - 11, // 4: grpc.plugin.deploymentapi.v1alpha1.BuildPipelineSyncStagesRequest.stages:type_name -> grpc.plugin.deploymentapi.v1alpha1.BuildPipelineSyncStagesRequest.StageConfig - 14, // 5: grpc.plugin.deploymentapi.v1alpha1.BuildPipelineSyncStagesResponse.stages:type_name -> model.PipelineStage - 14, // 6: grpc.plugin.deploymentapi.v1alpha1.BuildQuickSyncStagesResponse.stages:type_name -> model.PipelineStage - 15, // 7: grpc.plugin.deploymentapi.v1alpha1.PlanPluginInput.deployment:type_name -> model.Deployment - 16, // 8: grpc.plugin.deploymentapi.v1alpha1.PlanPluginInput.running_deployment_source:type_name -> model.DeploymentSource - 16, // 9: grpc.plugin.deploymentapi.v1alpha1.PlanPluginInput.target_deployment_source:type_name -> model.DeploymentSource + 14, // 3: grpc.plugin.deploymentapi.v1alpha1.DetermineStrategyResponse.sync_strategy:type_name -> model.SyncStrategy + 12, // 4: grpc.plugin.deploymentapi.v1alpha1.BuildPipelineSyncStagesRequest.stages:type_name -> grpc.plugin.deploymentapi.v1alpha1.BuildPipelineSyncStagesRequest.StageConfig + 15, // 5: grpc.plugin.deploymentapi.v1alpha1.BuildPipelineSyncStagesResponse.stages:type_name -> model.PipelineStage + 15, // 6: grpc.plugin.deploymentapi.v1alpha1.BuildQuickSyncStagesResponse.stages:type_name -> model.PipelineStage + 16, // 7: grpc.plugin.deploymentapi.v1alpha1.PlanPluginInput.deployment:type_name -> model.Deployment + 11, // 8: grpc.plugin.deploymentapi.v1alpha1.PlanPluginInput.running_deployment_source:type_name -> grpc.plugin.deploymentapi.v1alpha1.DeploymentSource + 11, // 9: grpc.plugin.deploymentapi.v1alpha1.PlanPluginInput.target_deployment_source:type_name -> grpc.plugin.deploymentapi.v1alpha1.DeploymentSource 8, // 10: grpc.plugin.deploymentapi.v1alpha1.DeploymentService.FetchDefinedStages:input_type -> grpc.plugin.deploymentapi.v1alpha1.FetchDefinedStagesRequest 0, // 11: grpc.plugin.deploymentapi.v1alpha1.DeploymentService.DetermineVersions:input_type -> grpc.plugin.deploymentapi.v1alpha1.DetermineVersionsRequest 2, // 12: grpc.plugin.deploymentapi.v1alpha1.DeploymentService.DetermineStrategy:input_type -> grpc.plugin.deploymentapi.v1alpha1.DetermineStrategyRequest @@ -1054,6 +1145,18 @@ func file_pkg_plugin_api_v1alpha1_deployment_api_proto_init() { } } file_pkg_plugin_api_v1alpha1_deployment_api_proto_msgTypes[11].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*DeploymentSource); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_pkg_plugin_api_v1alpha1_deployment_api_proto_msgTypes[12].Exporter = func(v interface{}, i int) interface{} { switch v := v.(*BuildPipelineSyncStagesRequest_StageConfig); i { case 0: return &v.state @@ -1072,7 +1175,7 @@ func file_pkg_plugin_api_v1alpha1_deployment_api_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: file_pkg_plugin_api_v1alpha1_deployment_api_proto_rawDesc, NumEnums: 0, - NumMessages: 12, + NumMessages: 13, NumExtensions: 0, NumServices: 1, }, diff --git a/pkg/plugin/api/v1alpha1/deployment/api.pb.validate.go b/pkg/plugin/api/v1alpha1/deployment/api.pb.validate.go index 9182375f68..b076737b0d 100644 --- a/pkg/plugin/api/v1alpha1/deployment/api.pb.validate.go +++ b/pkg/plugin/api/v1alpha1/deployment/api.pb.validate.go @@ -1489,6 +1489,114 @@ var _ interface { ErrorName() string } = PlanPluginInputValidationError{} +// Validate checks the field values on DeploymentSource with the rules defined +// in the proto definition for this message. If any rules are violated, the +// first error encountered is returned, or nil if there are no violations. +func (m *DeploymentSource) Validate() error { + return m.validate(false) +} + +// ValidateAll checks the field values on DeploymentSource with the rules +// defined in the proto definition for this message. If any rules are +// violated, the result is a list of violation errors wrapped in +// DeploymentSourceMultiError, or nil if none found. +func (m *DeploymentSource) ValidateAll() error { + return m.validate(true) +} + +func (m *DeploymentSource) validate(all bool) error { + if m == nil { + return nil + } + + var errors []error + + // no validation rules for ApplicationDirectory + + // no validation rules for Revision + + // no validation rules for ApplicationConfig + + // no validation rules for ApplicationConfigFilename + + if len(errors) > 0 { + return DeploymentSourceMultiError(errors) + } + + return nil +} + +// DeploymentSourceMultiError is an error wrapping multiple validation errors +// returned by DeploymentSource.ValidateAll() if the designated constraints +// aren't met. +type DeploymentSourceMultiError []error + +// Error returns a concatenation of all the error messages it wraps. +func (m DeploymentSourceMultiError) Error() string { + var msgs []string + for _, err := range m { + msgs = append(msgs, err.Error()) + } + return strings.Join(msgs, "; ") +} + +// AllErrors returns a list of validation violation errors. +func (m DeploymentSourceMultiError) AllErrors() []error { return m } + +// DeploymentSourceValidationError is the validation error returned by +// DeploymentSource.Validate if the designated constraints aren't met. +type DeploymentSourceValidationError struct { + field string + reason string + cause error + key bool +} + +// Field function returns field value. +func (e DeploymentSourceValidationError) Field() string { return e.field } + +// Reason function returns reason value. +func (e DeploymentSourceValidationError) Reason() string { return e.reason } + +// Cause function returns cause value. +func (e DeploymentSourceValidationError) Cause() error { return e.cause } + +// Key function returns key value. +func (e DeploymentSourceValidationError) Key() bool { return e.key } + +// ErrorName returns error name. +func (e DeploymentSourceValidationError) ErrorName() string { return "DeploymentSourceValidationError" } + +// Error satisfies the builtin error interface +func (e DeploymentSourceValidationError) Error() string { + cause := "" + if e.cause != nil { + cause = fmt.Sprintf(" | caused by: %v", e.cause) + } + + key := "" + if e.key { + key = "key for " + } + + return fmt.Sprintf( + "invalid %sDeploymentSource.%s: %s%s", + key, + e.field, + e.reason, + cause) +} + +var _ error = DeploymentSourceValidationError{} + +var _ interface { + Field() string + Reason() string + Key() bool + Cause() error + ErrorName() string +} = DeploymentSourceValidationError{} + // Validate checks the field values on // BuildPipelineSyncStagesRequest_StageConfig with the rules defined in the // proto definition for this message. If any rules are violated, the first diff --git a/pkg/plugin/api/v1alpha1/deployment/api.proto b/pkg/plugin/api/v1alpha1/deployment/api.proto index f1299fe120..42246ad2bc 100644 --- a/pkg/plugin/api/v1alpha1/deployment/api.proto +++ b/pkg/plugin/api/v1alpha1/deployment/api.proto @@ -20,7 +20,6 @@ option go_package = "github.com/pipe-cd/pipecd/pkg/plugin/api/v1alpha1/deploymen import "validate/validate.proto"; import "pkg/model/common.proto"; import "pkg/model/deployment.proto"; -import "pkg/model/deployment_source.proto"; // PlannerService defines the public APIs for remote planners. service DeploymentService { @@ -107,7 +106,19 @@ message PlanPluginInput { bytes plugin_config = 2; // The running deployment source. - model.DeploymentSource running_deployment_source = 3; + DeploymentSource running_deployment_source = 3; // The target deployment source. - model.DeploymentSource target_deployment_source = 4; + DeploymentSource target_deployment_source = 4; +} + +message DeploymentSource { + // The application directory where the source code is located. + string application_directory = 1; + // The git commit revision of the source code. + string revision = 2; + // The configuration of the application which is specific for plugins. + bytes application_config = 3; + // The filename of the application configuration file. + // The plugins can use this to avoid mistakenly reading this file as a manifest. + string application_config_filename = 4; } diff --git a/web/model/deployment_source_pb.d.ts b/web/model/deployment_source_pb.d.ts deleted file mode 100644 index 8e48c1df44..0000000000 --- a/web/model/deployment_source_pb.d.ts +++ /dev/null @@ -1,36 +0,0 @@ -import * as jspb from 'google-protobuf' - - - -export class DeploymentSource extends jspb.Message { - getApplicationDirectory(): string; - setApplicationDirectory(value: string): DeploymentSource; - - getRevision(): string; - setRevision(value: string): DeploymentSource; - - getApplicationConfig(): Uint8Array | string; - getApplicationConfig_asU8(): Uint8Array; - getApplicationConfig_asB64(): string; - setApplicationConfig(value: Uint8Array | string): DeploymentSource; - - getApplicationConfigFilename(): string; - setApplicationConfigFilename(value: string): DeploymentSource; - - serializeBinary(): Uint8Array; - toObject(includeInstance?: boolean): DeploymentSource.AsObject; - static toObject(includeInstance: boolean, msg: DeploymentSource): DeploymentSource.AsObject; - static serializeBinaryToWriter(message: DeploymentSource, writer: jspb.BinaryWriter): void; - static deserializeBinary(bytes: Uint8Array): DeploymentSource; - static deserializeBinaryFromReader(message: DeploymentSource, reader: jspb.BinaryReader): DeploymentSource; -} - -export namespace DeploymentSource { - export type AsObject = { - applicationDirectory: string, - revision: string, - applicationConfig: Uint8Array | string, - applicationConfigFilename: string, - } -} - diff --git a/web/model/deployment_source_pb.js b/web/model/deployment_source_pb.js deleted file mode 100644 index 6a45a08fb9..0000000000 --- a/web/model/deployment_source_pb.js +++ /dev/null @@ -1,290 +0,0 @@ -// source: pkg/model/deployment_source.proto -/** - * @fileoverview - * @enhanceable - * @suppress {missingRequire} reports error on implicit type usages. - * @suppress {messageConventions} JS Compiler reports an error if a variable or - * field starts with 'MSG_' and isn't a translatable message. - * @public - */ -// GENERATED CODE -- DO NOT EDIT! -/* eslint-disable */ -// @ts-nocheck - -var jspb = require('google-protobuf'); -var goog = jspb; -var global = - (typeof globalThis !== 'undefined' && globalThis) || - (typeof window !== 'undefined' && window) || - (typeof global !== 'undefined' && global) || - (typeof self !== 'undefined' && self) || - (function () { return this; }).call(null) || - Function('return this')(); - -goog.exportSymbol('proto.model.DeploymentSource', null, global); -/** - * Generated by JsPbCodeGenerator. - * @param {Array=} opt_data Optional initial data array, typically from a - * server response, or constructed directly in Javascript. The array is used - * in place and becomes part of the constructed object. It is not cloned. - * If no data is provided, the constructed object will be empty, but still - * valid. - * @extends {jspb.Message} - * @constructor - */ -proto.model.DeploymentSource = function(opt_data) { - jspb.Message.initialize(this, opt_data, 0, -1, null, null); -}; -goog.inherits(proto.model.DeploymentSource, jspb.Message); -if (goog.DEBUG && !COMPILED) { - /** - * @public - * @override - */ - proto.model.DeploymentSource.displayName = 'proto.model.DeploymentSource'; -} - - - -if (jspb.Message.GENERATE_TO_OBJECT) { -/** - * Creates an object representation of this proto. - * Field names that are reserved in JavaScript and will be renamed to pb_name. - * Optional fields that are not set will be set to undefined. - * To access a reserved field use, foo.pb_, eg, foo.pb_default. - * For the list of reserved names please see: - * net/proto2/compiler/js/internal/generator.cc#kKeyword. - * @param {boolean=} opt_includeInstance Deprecated. whether to include the - * JSPB instance for transitional soy proto support: - * http://goto/soy-param-migration - * @return {!Object} - */ -proto.model.DeploymentSource.prototype.toObject = function(opt_includeInstance) { - return proto.model.DeploymentSource.toObject(opt_includeInstance, this); -}; - - -/** - * Static version of the {@see toObject} method. - * @param {boolean|undefined} includeInstance Deprecated. Whether to include - * the JSPB instance for transitional soy proto support: - * http://goto/soy-param-migration - * @param {!proto.model.DeploymentSource} msg The msg instance to transform. - * @return {!Object} - * @suppress {unusedLocalVariables} f is only used for nested messages - */ -proto.model.DeploymentSource.toObject = function(includeInstance, msg) { - var f, obj = { - applicationDirectory: jspb.Message.getFieldWithDefault(msg, 1, ""), - revision: jspb.Message.getFieldWithDefault(msg, 2, ""), - applicationConfig: msg.getApplicationConfig_asB64(), - applicationConfigFilename: jspb.Message.getFieldWithDefault(msg, 4, "") - }; - - if (includeInstance) { - obj.$jspbMessageInstance = msg; - } - return obj; -}; -} - - -/** - * Deserializes binary data (in protobuf wire format). - * @param {jspb.ByteSource} bytes The bytes to deserialize. - * @return {!proto.model.DeploymentSource} - */ -proto.model.DeploymentSource.deserializeBinary = function(bytes) { - var reader = new jspb.BinaryReader(bytes); - var msg = new proto.model.DeploymentSource; - return proto.model.DeploymentSource.deserializeBinaryFromReader(msg, reader); -}; - - -/** - * Deserializes binary data (in protobuf wire format) from the - * given reader into the given message object. - * @param {!proto.model.DeploymentSource} msg The message object to deserialize into. - * @param {!jspb.BinaryReader} reader The BinaryReader to use. - * @return {!proto.model.DeploymentSource} - */ -proto.model.DeploymentSource.deserializeBinaryFromReader = function(msg, reader) { - while (reader.nextField()) { - if (reader.isEndGroup()) { - break; - } - var field = reader.getFieldNumber(); - switch (field) { - case 1: - var value = /** @type {string} */ (reader.readString()); - msg.setApplicationDirectory(value); - break; - case 2: - var value = /** @type {string} */ (reader.readString()); - msg.setRevision(value); - break; - case 3: - var value = /** @type {!Uint8Array} */ (reader.readBytes()); - msg.setApplicationConfig(value); - break; - case 4: - var value = /** @type {string} */ (reader.readString()); - msg.setApplicationConfigFilename(value); - break; - default: - reader.skipField(); - break; - } - } - return msg; -}; - - -/** - * Serializes the message to binary data (in protobuf wire format). - * @return {!Uint8Array} - */ -proto.model.DeploymentSource.prototype.serializeBinary = function() { - var writer = new jspb.BinaryWriter(); - proto.model.DeploymentSource.serializeBinaryToWriter(this, writer); - return writer.getResultBuffer(); -}; - - -/** - * Serializes the given message to binary data (in protobuf wire - * format), writing to the given BinaryWriter. - * @param {!proto.model.DeploymentSource} message - * @param {!jspb.BinaryWriter} writer - * @suppress {unusedLocalVariables} f is only used for nested messages - */ -proto.model.DeploymentSource.serializeBinaryToWriter = function(message, writer) { - var f = undefined; - f = message.getApplicationDirectory(); - if (f.length > 0) { - writer.writeString( - 1, - f - ); - } - f = message.getRevision(); - if (f.length > 0) { - writer.writeString( - 2, - f - ); - } - f = message.getApplicationConfig_asU8(); - if (f.length > 0) { - writer.writeBytes( - 3, - f - ); - } - f = message.getApplicationConfigFilename(); - if (f.length > 0) { - writer.writeString( - 4, - f - ); - } -}; - - -/** - * optional string application_directory = 1; - * @return {string} - */ -proto.model.DeploymentSource.prototype.getApplicationDirectory = function() { - return /** @type {string} */ (jspb.Message.getFieldWithDefault(this, 1, "")); -}; - - -/** - * @param {string} value - * @return {!proto.model.DeploymentSource} returns this - */ -proto.model.DeploymentSource.prototype.setApplicationDirectory = function(value) { - return jspb.Message.setProto3StringField(this, 1, value); -}; - - -/** - * optional string revision = 2; - * @return {string} - */ -proto.model.DeploymentSource.prototype.getRevision = function() { - return /** @type {string} */ (jspb.Message.getFieldWithDefault(this, 2, "")); -}; - - -/** - * @param {string} value - * @return {!proto.model.DeploymentSource} returns this - */ -proto.model.DeploymentSource.prototype.setRevision = function(value) { - return jspb.Message.setProto3StringField(this, 2, value); -}; - - -/** - * optional bytes application_config = 3; - * @return {string} - */ -proto.model.DeploymentSource.prototype.getApplicationConfig = function() { - return /** @type {string} */ (jspb.Message.getFieldWithDefault(this, 3, "")); -}; - - -/** - * optional bytes application_config = 3; - * This is a type-conversion wrapper around `getApplicationConfig()` - * @return {string} - */ -proto.model.DeploymentSource.prototype.getApplicationConfig_asB64 = function() { - return /** @type {string} */ (jspb.Message.bytesAsB64( - this.getApplicationConfig())); -}; - - -/** - * optional bytes application_config = 3; - * Note that Uint8Array is not supported on all browsers. - * @see http://caniuse.com/Uint8Array - * This is a type-conversion wrapper around `getApplicationConfig()` - * @return {!Uint8Array} - */ -proto.model.DeploymentSource.prototype.getApplicationConfig_asU8 = function() { - return /** @type {!Uint8Array} */ (jspb.Message.bytesAsU8( - this.getApplicationConfig())); -}; - - -/** - * @param {!(string|Uint8Array)} value - * @return {!proto.model.DeploymentSource} returns this - */ -proto.model.DeploymentSource.prototype.setApplicationConfig = function(value) { - return jspb.Message.setProto3BytesField(this, 3, value); -}; - - -/** - * optional string application_config_filename = 4; - * @return {string} - */ -proto.model.DeploymentSource.prototype.getApplicationConfigFilename = function() { - return /** @type {string} */ (jspb.Message.getFieldWithDefault(this, 4, "")); -}; - - -/** - * @param {string} value - * @return {!proto.model.DeploymentSource} returns this - */ -proto.model.DeploymentSource.prototype.setApplicationConfigFilename = function(value) { - return jspb.Message.setProto3StringField(this, 4, value); -}; - - -goog.object.extend(exports, proto.model); From c265522cdb3750519fd2d165d328b0ef17597971 Mon Sep 17 00:00:00 2001 From: The Anh Nguyen Date: Wed, 23 Oct 2024 16:10:29 +0700 Subject: [PATCH 70/84] fix(docs): "Edit this page" URLs to correctly point to GitHub repository structure (#5289) * fix edit post link Signed-off-by: The Anh Nguyen * add condition to check gh_subdir & language Signed-off-by: The Anh Nguyen * remove _index.md condition Signed-off-by: The Anh Nguyen --------- Signed-off-by: The Anh Nguyen --- docs/layouts/partials/page-meta-links.html | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/docs/layouts/partials/page-meta-links.html b/docs/layouts/partials/page-meta-links.html index 0b82e9ba12..be9ed7974c 100644 --- a/docs/layouts/partials/page-meta-links.html +++ b/docs/layouts/partials/page-meta-links.html @@ -1,19 +1,24 @@ {{ if .Path }} -{{ $pathFormatted := replace .Path "\\" "/" }} {{ $gh_repo := ($.Param "github_repo") }} {{ $gh_subdir := ($.Param "github_subdir") }} {{ $gh_project_repo := ($.Param "github_project_repo") }} {{ $gh_branch := (default "master" ($.Param "github_branch")) }} {{ if $gh_repo }}