id: helm-preview-environment-lifecycle-and-ephemeral-teardown-gate
namespace: company.platform
description: |
Deploys a pull request preview of your Helm chart into its own Kubernetes namespace, smoke tests it,
keeps it for a review window, and uninstalls it afterwards. A failed smoke test tears it down at once.
inputs:
- id: pr_number
type: STRING
required: false
displayName: Pull Request Number
description: "Pull request number that names the preview (release and namespace
preview-pr-<number>). Webhook runs can send it as pr_number in the JSON
body."
- id: image_tag
type: STRING
required: false
displayName: Application Image Tag
description: "Image tag to deploy, written to the chart value image.tag. Webhook
runs can send it as image_tag in the JSON body."
- id: chart_repository
type: STRING
required: false
displayName: Helm Chart Repository
description: "URL of the Helm chart repository. For webhook runs, set a default
here or pass it through the trigger's inputs."
- id: chart_name
type: STRING
required: false
displayName: Helm Chart Name
description: "Name of the chart in the repository. It must support the value
image.tag and the ingress values enabled and hosts."
- id: chart_version
type: STRING
required: false
displayName: Helm Chart Version
description: "Exact chart version to deploy."
- id: preview_domain
type: STRING
required: false
displayName: Preview Domain
description: "Domain that previews are served under. The ingress host becomes
pr-<number>.<domain> and must resolve from the Kestra worker."
- id: health_path
type: STRING
defaults: "/"
displayName: Smoke Test Path
description: "HTTP path requested on the preview for the smoke test. It must
return 200 when the app is healthy."
- id: preview_ttl_minutes
type: INT
defaults: 60
min: 1
displayName: Preview Retention Window (Minutes)
description: "How long a healthy preview stays up before it is uninstalled.
Resume the paused execution to uninstall it earlier."
- id: auto_teardown
type: BOOL
defaults: true
displayName: Auto-Teardown
description: "When true, the release is uninstalled after the retention window.
When false, a healthy preview is left running and you remove it yourself."
triggers:
- id: ci_preview_webhook
type: io.kestra.plugin.core.trigger.Webhook
description: "Called by CI when a pull request is opened or updated, with a JSON
body such as {\"pr_number\": \"108\", \"image_tag\": \"sha-abc123\"}."
key: "{{ secret('HELM_PREVIEW_WEBHOOK_KEY') }}"
variables:
pr: "{{ trigger.body.pr_number is defined ? trigger.body.pr_number :
(inputs.pr_number is defined ? inputs.pr_number : '') }}"
tag: "{{ trigger.body.image_tag is defined ? trigger.body.image_tag :
(inputs.image_tag is defined ? inputs.image_tag : '') }}"
release_name: "preview-pr-{{ vars.pr }}"
preview_host: "pr-{{ vars.pr }}.{{ inputs.preview_domain is defined ?
inputs.preview_domain : '' }}"
tasks:
- id: validate_parameters
type: io.kestra.plugin.core.execution.Assert
description: "Stops before anything is deployed when a value is missing or has
an unsafe format. The pull request number and image tag may come from a
webhook body, so they are checked here."
errorMessage: "Missing or invalid parameters. pr_number must be digits,
image_tag must be a plain tag, and chart_repository, chart_name,
chart_version and preview_domain must be set."
conditions:
- "{{ vars.pr | regexMatch(regex='^[0-9]{1,8}$') }}"
- "{{ vars.tag | regexMatch(regex='^[A-Za-z0-9._-]{1,128}$') }}"
- "{{ (inputs.chart_repository is defined ? inputs.chart_repository : '')
| regexMatch(regex='^https://') }}"
- "{{ (inputs.chart_name is defined ? inputs.chart_name : '') |
regexMatch(regex='^[A-Za-z0-9._-]+$') }}"
- "{{ (inputs.chart_version is defined ? inputs.chart_version : '') |
regexMatch(regex='^[A-Za-z0-9._+-]+$') }}"
- "{{ (inputs.preview_domain is defined ? inputs.preview_domain : '') |
regexMatch(regex='^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$') }}"
- "{{ vars.pr == (vars.pr | trim) }}"
- "{{ vars.tag == (vars.tag | trim) }}"
- id: render_and_lint_chart
type: io.kestra.plugin.helm.Template
description: "Renders the chart offline with the preview values, so template
errors fail before the cluster is touched."
releaseName: "{{ vars.release_name }}"
namespace: "{{ vars.release_name }}"
chart:
repository: "{{ inputs.chart_repository }}"
name: "{{ inputs.chart_name }}"
version: "{{ inputs.chart_version }}"
values:
image:
tag: "{{ vars.tag }}"
ingress:
enabled: true
hosts:
- host: "{{ vars.preview_host }}"
paths:
- path: /
pathType: ImplementationSpecific
- id: deploy_preview_release
type: io.kestra.plugin.helm.Upgrade
description: "Installs or updates the preview release in its own namespace and
waits for it to become ready. A failed deployment is rolled back."
releaseName: "{{ vars.release_name }}"
namespace: "{{ vars.release_name }}"
createNamespace: true
wait: WATCHER
rollbackOnFailure: true
timeout: PT5M
chart:
repository: "{{ inputs.chart_repository }}"
name: "{{ inputs.chart_name }}"
version: "{{ inputs.chart_version }}"
values:
image:
tag: "{{ vars.tag }}"
ingress:
enabled: true
hosts:
- host: "{{ vars.preview_host }}"
paths:
- path: /
pathType: ImplementationSpecific
connection:
masterUrl: "{{ secret('K8S_MASTER_URL') }}"
caCertData: "{{ secret('K8S_CA_CERT') }}"
oauthToken: "{{ secret('K8S_TOKEN') }}"
- id: verify_preview_status
type: io.kestra.plugin.helm.Status
description: "Reads the release status back from the cluster."
releaseName: "{{ vars.release_name }}"
namespace: "{{ vars.release_name }}"
connection:
masterUrl: "{{ secret('K8S_MASTER_URL') }}"
caCertData: "{{ secret('K8S_CA_CERT') }}"
oauthToken: "{{ secret('K8S_TOKEN') }}"
- id: smoke_test_preview_endpoint
type: io.kestra.plugin.core.http.Request
description: "Requests the preview through its ingress and retries while it
starts. If it never answers with 2xx the task is tolerated as a warning,
and the next step takes the failure path."
uri: "http://{{ vars.preview_host }}{{ inputs.health_path }}"
method: GET
allowFailure: true
options:
timeout:
connectTimeout: PT10S
readIdleTimeout: PT15S
retry:
type: constant
interval: PT10S
maxAttempts: 6
- id: evaluate_smoke_test_result
type: io.kestra.plugin.core.flow.If
description: "Keeps a healthy preview for the retention window. On a failed
smoke test it alerts Slack, uninstalls the release and fails the
execution."
condition: "{{ outputs.smoke_test_preview_endpoint.code is defined and
outputs.smoke_test_preview_endpoint.code == 200 }}"
then:
- id: log_smoke_test_success
type: io.kestra.plugin.core.log.Log
message: "Preview {{ vars.release_name }} is healthy (HTTP {{
outputs.smoke_test_preview_endpoint.code }}) at http://{{
vars.preview_host }}{{ inputs.health_path }}."
- id: hold_for_review
type: io.kestra.plugin.core.flow.If
description: "Holds the preview for the retention window, unless auto_teardown
is off."
condition: "{{ inputs.auto_teardown }}"
then:
- id: review_window
type: io.kestra.plugin.core.flow.Pause
description: "Waits for the retention window, then continues to teardown. Resume
it earlier to uninstall the preview sooner."
pauseDuration: "PT{{ inputs.preview_ttl_minutes }}M"
else:
- id: alert_smoke_test_failed
type: io.kestra.plugin.slack.notifications.SlackIncomingWebhook
description: "Tells the channel that the smoke test failed."
url: "{{ secret('SLACK_WEBHOOK_URL') }}"
payload: |
{
"text": ":rotating_light: *Helm Preview Smoke Test Failed*",
"attachments": [
{
"color": "#E24A4A",
"fields": [
{"title": "Release", "value": {{ vars.release_name | toJson }}, "short": true},
{"title": "HTTP Status", "value": {{ (outputs.smoke_test_preview_endpoint.code is defined ? (outputs.smoke_test_preview_endpoint.code | string) : 'no response') | toJson }}, "short": true},
{"title": "Host", "value": {{ vars.preview_host | toJson }}, "short": false},
{"title": "Action", "value": "The release is being uninstalled.", "short": false},
{"title": "Execution", "value": {{ execution.id | toJson }}, "short": false}
]
}
]
}
- id: teardown_failed_preview
type: io.kestra.plugin.helm.Uninstall
description: "Removes the unhealthy release straight away."
releaseName: "{{ vars.release_name }}"
namespace: "{{ vars.release_name }}"
ignoreNotFound: true
connection:
masterUrl: "{{ secret('K8S_MASTER_URL') }}"
caCertData: "{{ secret('K8S_CA_CERT') }}"
oauthToken: "{{ secret('K8S_TOKEN') }}"
- id: fail_execution
type: io.kestra.plugin.core.execution.Fail
description: "Fails the execution so that CI sees the broken preview."
errorMessage: "Smoke test failed for {{ vars.release_name }}: HTTP {{
outputs.smoke_test_preview_endpoint.code is defined ?
outputs.smoke_test_preview_endpoint.code : 'no response' }}. The
release was uninstalled."
finally:
- id: teardown_preview
type: io.kestra.plugin.helm.Uninstall
description: "Uninstalls the release when this execution deployed it and
auto_teardown is on. It runs after success and after failure, and does
nothing if the release is already gone."
runIf: "{{ inputs.auto_teardown and outputs.deploy_preview_release is defined }}"
releaseName: "{{ vars.release_name }}"
namespace: "{{ vars.release_name }}"
ignoreNotFound: true
connection:
masterUrl: "{{ secret('K8S_MASTER_URL') }}"
caCertData: "{{ secret('K8S_CA_CERT') }}"
oauthToken: "{{ secret('K8S_TOKEN') }}"
- id: notify_teardown
type: io.kestra.plugin.slack.notifications.SlackIncomingWebhook
description: "Tells the channel that the preview was removed."
runIf: "{{ inputs.auto_teardown and outputs.deploy_preview_release is defined }}"
url: "{{ secret('SLACK_WEBHOOK_URL') }}"
payload: |
{
"text": {{ (":broom: Helm preview " ~ vars.release_name ~ " was uninstalled (execution " ~ execution.id ~ ").") | toJson }}
}
errors:
- id: alert_pipeline_failure
type: io.kestra.plugin.slack.notifications.SlackIncomingWebhook
description: "Tells the channel when the preview flow stops with an error."
url: "{{ secret('SLACK_WEBHOOK_URL') }}"
payload: |
{
"text": {{ (":boom: Helm preview flow stopped with an error in execution " ~ execution.id ~ ". The release is uninstalled in the finally step when it was deployed and auto_teardown is on.") | toJson }}
}