cpflow 6.0.0.rc.0 → 6.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.agents/agent-workflow.yml +16 -38
- data/.agents/bin/README.md +7 -8
- data/.agents/legacy-workflow-policy.yml +11 -0
- data/.agents/shaka.md +20 -0
- data/.agents/trusted-actions.yml +12 -0
- data/.coderabbit.yaml +1 -0
- data/.github/actions/cpflow-build-docker-image/action.yml +153 -22
- data/.github/actions/cpflow-delete-control-plane-app/delete-app.sh +3 -0
- data/.github/actions/cpflow-preview-promotion/action.yml +82 -0
- data/.github/actions/cpflow-preview-promotion/preview-promotion.sh +90 -0
- data/.github/workflows/cpflow-deploy-review-app.yml +13 -1
- data/.github/workflows/cpflow-deploy-staging.yml +12 -1
- data/.github/workflows/cpflow-promote-staging-to-production.yml +128 -9
- data/.github/workflows/rspec.yml +24 -0
- data/AGENTS.md +18 -3
- data/CHANGELOG.md +18 -3
- data/CONTRIBUTING.md +1 -1
- data/Gemfile.lock +1 -1
- data/README.md +5 -2
- data/docs/ai-github-flow-prompt.md +2 -2
- data/docs/ci-automation.md +178 -14
- data/docs/commands.md +26 -13
- data/docs/postgres-review-apps.md +90 -0
- data/docs/postgres.md +233 -375
- data/docs/secrets-and-env-values.md +42 -0
- data/docs/sidebars.ts +1 -0
- data/lib/command/ai_github_flow_prompt.rb +2 -2
- data/lib/command/apply_template.rb +36 -4
- data/lib/command/base.rb +56 -3
- data/lib/command/delete.rb +174 -4
- data/lib/command/deploy_image.rb +6 -0
- data/lib/command/generate.rb +247 -3
- data/lib/command/generate_github_actions.rb +7 -1
- data/lib/command/setup_app.rb +92 -10
- data/lib/command/update_github_actions.rb +81 -43
- data/lib/core/config.rb +73 -0
- data/lib/core/controlplane.rb +22 -2
- data/lib/core/controlplane_api.rb +28 -2
- data/lib/core/controlplane_api_direct.rb +17 -0
- data/lib/core/disposable_postgres_helpers.rb +167 -0
- data/lib/core/doctor_service.rb +7 -0
- data/lib/core/github_flow_readiness/checks.rb +2 -1
- data/lib/core/github_flow_readiness_service.rb +4 -0
- data/lib/core/repo_introspection.rb +112 -2
- data/lib/cpflow/version.rb +1 -1
- data/lib/generator_templates/.dockerignore +16 -0
- data/lib/generator_templates/Dockerfile +9 -5
- data/lib/generator_templates/entrypoint.sh +2 -0
- data/lib/generator_templates/templates/postgres.yml +9 -0
- data/lib/generator_templates_sqlite/controlplane.yml +2 -1
- data/lib/generator_templates_sqlite/release_script.sh +1 -1
- data/lib/generator_templates_sqlite/templates/rails.yml +2 -1
- data/lib/github_flow_templates/.github/cpflow-help.md +39 -12
- data/lib/github_flow_templates/.github/workflows/cpflow-deploy-review-app.yml +1 -0
- data/lib/github_flow_templates/.github/workflows/cpflow-deploy-staging.yml +1 -0
- data/lib/github_flow_templates/.github/workflows/cpflow-promote-staging-to-production.yml +128 -9
- data/lib/github_flow_templates/bin/pin-cpflow-github-ref +37 -13
- data/lib/github_flow_templates/bin/test-cpflow-github-flow +62 -43
- metadata +11 -3
|
@@ -11,6 +11,48 @@ set these values directly at the GVC or workload levels, so none of these ENV va
|
|
|
11
11
|
|
|
12
12
|
## Review app secrets
|
|
13
13
|
|
|
14
|
+
For credentials and initialization scripts created by the PostgreSQL template, see
|
|
15
|
+
[PostgreSQL for disposable review apps](./postgres-review-apps.md).
|
|
16
|
+
|
|
17
|
+
For same-repository review apps that need only disposable credentials, opt in on
|
|
18
|
+
the dynamically matched review-app entry in `.controlplane/controlplane.yml`:
|
|
19
|
+
|
|
20
|
+
```yaml
|
|
21
|
+
apps:
|
|
22
|
+
my-app-review:
|
|
23
|
+
match_if_app_name_starts_with: true
|
|
24
|
+
generated_review_secret_keys:
|
|
25
|
+
- SECRET_KEY_BASE
|
|
26
|
+
- RENDERER_PASSWORD
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
`cpflow setup-app` then creates a separate `APP_NAME-secrets` dictionary and
|
|
30
|
+
policy for each PR app, tags both resources with the exact app name, fills each
|
|
31
|
+
configured key with a random 256-bit hex value, and never prints the values.
|
|
32
|
+
`--refresh-templates` fills missing keys without rotating existing values.
|
|
33
|
+
Initial template application skips templates named for the generated dictionary
|
|
34
|
+
and policy, so templates cannot replace its values or broaden its access.
|
|
35
|
+
Refresh also skips the generated policy template.
|
|
36
|
+
Setup refuses to reuse a dictionary without the exact app tag or a policy that
|
|
37
|
+
targets another secret or binds another principal; it rechecks the policy
|
|
38
|
+
immediately before granting the app identity. Keep this
|
|
39
|
+
setting off persistent apps and do
|
|
40
|
+
not combine it with custom `secrets_name` or `secrets_policy_name`. Templates
|
|
41
|
+
that use `{{APP_SECRETS}}` automatically point at the PR-specific dictionary.
|
|
42
|
+
`cpflow delete` and delete-mode stale cleanup remove that dictionary and policy
|
|
43
|
+
before removing the app, provided the policy still targets the dictionary and
|
|
44
|
+
has no other bindings. Generated dictionaries carry an app marker; if a prior
|
|
45
|
+
attempt removed the policy first, cleanup can use that marker to remove only the
|
|
46
|
+
matching disposable dictionary. An unmarked policy is also left for inspection
|
|
47
|
+
if its dictionary is already gone. If the GVC was removed separately before disposable credential cleanup finished, rerun
|
|
48
|
+
`cpflow delete` with the same app name to finish secret cleanup. The generated
|
|
49
|
+
delete action calls that same recovery path when the GVC is already absent.
|
|
50
|
+
Cleanup still checks the app-specific names and tag if the key-generation option
|
|
51
|
+
was later removed. Unexpected grants or unmarked secrets leave those resources
|
|
52
|
+
for inspection.
|
|
53
|
+
This generates credentials only; external licenses, database accounts, and
|
|
54
|
+
provider tokens still require their own review-only provisioning.
|
|
55
|
+
|
|
14
56
|
Review apps run application code from pull requests. For repositories with external contributors, do not put sensitive
|
|
15
57
|
production or long-lived staging secrets in review apps. A secret reference such as `cpln://secret/...` protects the
|
|
16
58
|
value while it is stored in Control Plane configuration, but the value becomes readable by application code after it is
|
data/docs/sidebars.ts
CHANGED
|
@@ -32,9 +32,9 @@ module Command
|
|
|
32
32
|
<<~PROMPT
|
|
33
33
|
Set up Control Plane GitHub Flow for this repo. First make sure the `cpflow` CLI is available: use the repo's existing `bundle exec cpflow` if present, otherwise install the published `cpflow` Ruby gem with `gem install cpflow`; if neither is possible, stop and report that blocker. Use the same `cpflow` invocation for the rest of the rollout. Start with `cpflow github-flow-readiness` and stop on any reported blockers. The repo must be deployable from a clean clone: published package versions, complete runtime scaffold, and a production Dockerfile that can build the app. If any package version is unpublished, inaccessible from CI, or requires credentials that are not already modeled in the repo or GitHub settings, stop and report the blocker instead of generating workflow files. If the repo is a legacy sample pinned to an obsolete Ruby or Bundler toolchain, if it does not even have a production Dockerfile yet, or if it is a monorepo without an already-decided single app boundary for this flow, stop and report that as a prerequisite instead of forcing the rollout.
|
|
34
34
|
|
|
35
|
-
If `.controlplane/` is missing, run `cpflow generate`. Treat the generated app names as the repo-name default (`#{inferred_app_prefix}`) and rename them only if the project needs a different prefix. Then run `cpflow generate-github-actions` (or `cpflow generate-github-actions --staging-branch BRANCH` when staging should deploy from a branch other than `main`/`master`), keep review apps opt-in via `+review-app-deploy`, make sure any `STAGING_APP_BRANCH` repository variable is also present in the generated staging workflow's `on.push.branches` filter, and list the GitHub secrets and variables that must be configured. Do not hand-edit duplicated upstream refs into the generated wrappers. Keep every generated Control Plane Flow source pin synchronized at the same `vX.Y.Z`: each reusable-workflow `uses: ...@vX.Y.Z` ref, the production promotion `.cpflow` checkout ref, and its `control_plane_flow_ref` setup input. By default, `cpflow generate-github-actions` derives these values from the installed `cpflow` gem version; temporary explicit ref overrides are for unreleased testing and must keep all three values synchronized. Keep the generated `.github/actions/cpflow-*` files from that same gem unchanged; upstream reusable workflows invoke those checked-in local actions and separately check out the matching runtime source at `.cpflow`. When bumping the `cpflow` gem in a downstream repo,
|
|
35
|
+
If `.controlplane/` is missing, run `cpflow generate`. Treat the generated app names as the repo-name default (`#{inferred_app_prefix}`) and rename them only if the project needs a different prefix. Then run `cpflow generate-github-actions` (or `cpflow generate-github-actions --staging-branch BRANCH` when staging should deploy from a branch other than `main`/`master`), keep review apps opt-in via `+review-app-deploy`, make sure any `STAGING_APP_BRANCH` repository variable is also present in the generated staging workflow's `on.push.branches` filter, and list the GitHub secrets and variables that must be configured. Do not hand-edit duplicated upstream refs into the generated wrappers. Keep every generated Control Plane Flow source pin synchronized at the same `vX.Y.Z`: each reusable-workflow `uses: ...@vX.Y.Z` ref, the production promotion `.cpflow` checkout ref, and its `control_plane_flow_ref` setup input. By default, `cpflow generate-github-actions` derives these values from the installed `cpflow` gem version; temporary explicit ref overrides are for unreleased testing and must keep all three values synchronized. Keep the generated `.github/actions/cpflow-*` files from that same gem unchanged; upstream reusable workflows invoke those checked-in local actions and separately check out the matching runtime source at `.cpflow`. When bumping the `cpflow` gem in a downstream repo, `cpflow update-github-actions` refreshes local actions and helpers while preserving all top-level workflows, including their refs and triggers. Use `cpflow update-github-actions --workflows FILE...` to explicitly select wrappers for replacement, then review the diff and reapply downstream customizations. A differing existing validator blocks all writes: preserve its downstream checks in executable `bin/test-cpflow-github-flow-custom`, then remove or rename the old generated validator before retrying. The generated validator invokes that preserved extension and propagates its failures. Follow `docs/ci-automation.md` for the migration procedure, keep source pins compatible with the updated actions, and run `bin/test-cpflow-github-flow` in the same PR (or `bin/test-cpflow-github-flow bundle exec cpflow` when using Bundler). Keep the normal generated review-app setup simple: review apps require only `CPLN_TOKEN_STAGING` when the generated review app config can be inferred. For public demos, starter staging apps, and long-lived review apps, keep the app workload `type: standard` with one warm replica, set its autoscaling metric to `disabled`, and enable `capacityAI: true` so Control Plane can right-size CPU and memory allocation at that fixed replica count. Shared Postgres and other stateful workloads are the usual exceptions and should stay manually sized; Capacity AI is for supported stateless app/service workloads. If true idle scale-to-zero is explicitly required, create a separate `serverless` workload before first deploy or plan a delete/recreate migration because Control Plane will not change an existing `standard` workload to `serverless` in place. For shared review-app resources such as one staging database, use `shared_secret_grants` and `{{SHARED_SECRET_DATABASE}}` placeholders instead of hardcoding the base app secret name; this keeps review-app policy binding and cleanup automatic while avoiding per-PR database cost. Document the one-time Control Plane bootstrap command for persistent staging and production apps with `cpflow setup-app --skip-post-creation-hook`; for existing apps or later template updates, document `cpflow apply-template` and the need for the app identity to have `reveal` on the app secret policy. Do not imply the staging deploy or promotion workflows create those persistent GVCs. For production promotion, document a protected `production` GitHub Environment with required reviewers, prevent self-review, and `CPLN_TOKEN_PRODUCTION` stored as an environment secret, not as a repository or organization secret.
|
|
36
36
|
|
|
37
|
-
Keep Node available in the final image if asset compilation or SSR depends on ExecJS, Yarn, `pnpm`, or npm after the main install layer. Make sure the generated Dockerfile uses a Ruby base image compatible with the app's declared Ruby requirement. Preserve repo-defined frontend build hooks: if `config/shakapacker.yml` defines a `precompile_hook`, or React on Rails enables `config.auto_load_bundle = true`, confirm the generated Dockerfile runs that codegen step before `rails assets:precompile`. If `config/database.yml` shows SQLite in production, confirm that the generated scaffold
|
|
37
|
+
Keep Node available in the final image if asset compilation or SSR depends on ExecJS, Yarn, `pnpm`, or npm after the main install layer. Make sure the generated Dockerfile uses a Ruby base image compatible with the app's declared Ruby requirement. Preserve repo-defined frontend build hooks: if `config/shakapacker.yml` defines a `precompile_hook`, or React on Rails enables `config.auto_load_bundle = true`, confirm the generated Dockerfile runs that codegen step before `rails assets:precompile`. If `config/database.yml` shows SQLite in production, confirm that the generated scaffold redirects every production database file into persistent `/app/data` (or keeps it under persistent `/app/storage`) without mounting over the image's `/app/db` migrations, stores Active Storage under `/app/storage`, and runs `rails db:prepare`; otherwise keep the default Postgres workload. If the public workload is not named `rails`, set `PRIMARY_WORKLOAD` or adjust the generated workflows. Inspect the Dockerfile and package sources for private GitHub dependencies or `RUN --mount=type=ssh`; if present, wire `DOCKER_BUILD_SSH_KEY`, optionally set `DOCKER_BUILD_SSH_KNOWN_HOSTS` for non-GitHub SSH hosts, and keep `DOCKER_BUILD_EXTRA_ARGS` to newline-delimited single tokens such as `--build-arg=FOO=bar`.
|
|
38
38
|
|
|
39
39
|
Run the real local validations you can: Docker build if feasible, repo tests or smoke checks, YAML validation, and any CI-equivalent build steps. Push the branch and check the GitHub Actions results. Only stop early for a real external blocker or a product decision that changes scope.
|
|
40
40
|
PROMPT
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "../core/disposable_postgres_helpers"
|
|
4
|
+
|
|
3
5
|
module Command
|
|
4
6
|
class ApplyTemplate < Base # rubocop:disable Metrics/ClassLength
|
|
5
7
|
NAME = "apply-template"
|
|
@@ -10,7 +12,9 @@ module Command
|
|
|
10
12
|
location_option,
|
|
11
13
|
skip_confirm_option,
|
|
12
14
|
add_app_identity_option,
|
|
13
|
-
preserve_existing_runtime_option
|
|
15
|
+
preserve_existing_runtime_option,
|
|
16
|
+
exclude_secret_template_option,
|
|
17
|
+
exclude_policy_template_option
|
|
14
18
|
].freeze
|
|
15
19
|
DESCRIPTION = "Applies application-specific configs from templates"
|
|
16
20
|
LONG_DESCRIPTION = <<~DESC
|
|
@@ -19,6 +23,7 @@ module Command
|
|
|
19
23
|
- Picks templates from the `.controlplane/templates` directory
|
|
20
24
|
- Templates are ordinary Control Plane templates but with variable preprocessing
|
|
21
25
|
- Use `--preserve-existing-runtime` to retain each workload container's configured app image, even when the workload is unready, and skip existing secret resources entirely while applying other template changes
|
|
26
|
+
- Use `--exclude-secret-template NAME` and `--exclude-policy-template NAME` to exclude exact named templates without changing workload image handling
|
|
22
27
|
- Missing or invalid workload images use only an unambiguous app image from ready workloads; refresh fails before applying templates when no safe fallback exists
|
|
23
28
|
|
|
24
29
|
**Preprocessed template variables:**
|
|
@@ -61,7 +66,8 @@ module Command
|
|
|
61
66
|
@skipped_templates = []
|
|
62
67
|
|
|
63
68
|
templates = @template_parser.parse(@names_to_filenames.values)
|
|
64
|
-
templates =
|
|
69
|
+
templates = filter_existing_resources(templates)
|
|
70
|
+
DisposablePostgresHelpers.new(config, cp).prepare_templates!(templates)
|
|
65
71
|
pending_templates = confirm_templates(templates)
|
|
66
72
|
add_app_identity_template(pending_templates) if config.options[:add_app_identity]
|
|
67
73
|
pending_templates.each do |template|
|
|
@@ -77,6 +83,27 @@ module Command
|
|
|
77
83
|
|
|
78
84
|
private
|
|
79
85
|
|
|
86
|
+
def filter_existing_resources(templates)
|
|
87
|
+
templates = preserve_existing_runtime(templates) if config.options[:preserve_existing_runtime]
|
|
88
|
+
templates = exclude_named_template(templates, :exclude_secret_template, "secret")
|
|
89
|
+
exclude_named_template(templates, :exclude_policy_template, "policy")
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def exclude_named_template(templates, option, kind)
|
|
93
|
+
name = config.options[option]
|
|
94
|
+
return templates unless name
|
|
95
|
+
raise "--#{option.to_s.tr('_', '-')} requires a #{kind} name." if name.empty?
|
|
96
|
+
|
|
97
|
+
templates.filter_map do |template|
|
|
98
|
+
if template["kind"] == kind && template["name"] == name
|
|
99
|
+
report_skipped(template)
|
|
100
|
+
next
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
template
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
80
107
|
def template_kind(template)
|
|
81
108
|
case template["kind"]
|
|
82
109
|
when "gvc"
|
|
@@ -154,13 +181,18 @@ module Command
|
|
|
154
181
|
cache_existing_workloads(templates)
|
|
155
182
|
ready_fallback_image = unambiguous_ready_app_image
|
|
156
183
|
|
|
184
|
+
skip_existing_secret_resources(templates).each do |template|
|
|
185
|
+
preserve_workload_images(template, ready_fallback_image) if template["kind"] == "workload"
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def skip_existing_secret_resources(templates)
|
|
157
190
|
templates.filter_map do |template|
|
|
158
191
|
if template["kind"] == "secret" && cp.fetch_secret(template["name"])
|
|
159
192
|
report_skipped(template)
|
|
160
193
|
next
|
|
161
194
|
end
|
|
162
195
|
|
|
163
|
-
preserve_workload_images(template, ready_fallback_image) if template["kind"] == "workload"
|
|
164
196
|
template
|
|
165
197
|
end
|
|
166
198
|
end
|
|
@@ -312,7 +344,7 @@ module Command
|
|
|
312
344
|
return unless @skipped_templates.any?
|
|
313
345
|
|
|
314
346
|
skipped = @skipped_templates.map { |template| " - [#{template_kind(template)}] #{template['name']}" }.join("\n")
|
|
315
|
-
progress.puts("\n#{Shell.color('Skipped templates
|
|
347
|
+
progress.puts("\n#{Shell.color('Skipped templates:', :blue)}\n#{skipped}")
|
|
316
348
|
end
|
|
317
349
|
end
|
|
318
350
|
end
|
data/lib/command/base.rb
CHANGED
|
@@ -483,6 +483,28 @@ module Command
|
|
|
483
483
|
}
|
|
484
484
|
end
|
|
485
485
|
|
|
486
|
+
def self.exclude_secret_template_option(required: false)
|
|
487
|
+
{
|
|
488
|
+
name: :exclude_secret_template,
|
|
489
|
+
params: {
|
|
490
|
+
desc: "Excludes one named secret template while applying templates",
|
|
491
|
+
type: :string,
|
|
492
|
+
required: required
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
end
|
|
496
|
+
|
|
497
|
+
def self.exclude_policy_template_option(required: false)
|
|
498
|
+
{
|
|
499
|
+
name: :exclude_policy_template,
|
|
500
|
+
params: {
|
|
501
|
+
desc: "Excludes one named policy template while applying templates",
|
|
502
|
+
type: :string,
|
|
503
|
+
required: required
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
end
|
|
507
|
+
|
|
486
508
|
def self.skip_pre_deletion_hook_option(required: false)
|
|
487
509
|
{
|
|
488
510
|
name: :skip_pre_deletion_hook,
|
|
@@ -688,15 +710,46 @@ module Command
|
|
|
688
710
|
end
|
|
689
711
|
|
|
690
712
|
def shared_secret_policy_targets_secret?(grant, policy)
|
|
713
|
+
policy_targets_secret?(policy, grant.fetch(:secret_name))
|
|
714
|
+
end
|
|
715
|
+
|
|
716
|
+
def policy_targets_secret?(policy, secret_name)
|
|
691
717
|
target_links = Array(policy["targetLinks"])
|
|
692
718
|
|
|
693
719
|
policy["targetKind"] == "secret" &&
|
|
694
720
|
target_links.one? &&
|
|
695
|
-
|
|
721
|
+
secret_policy_target_links(secret_name).include?(target_links.first)
|
|
696
722
|
end
|
|
697
723
|
|
|
698
|
-
def
|
|
699
|
-
|
|
724
|
+
def generated_review_app_tag(resource)
|
|
725
|
+
tags = resource["tags"]
|
|
726
|
+
return nil unless tags.is_a?(Hash)
|
|
727
|
+
|
|
728
|
+
tags[::Config::GENERATED_REVIEW_APP_TAG]
|
|
729
|
+
end
|
|
730
|
+
|
|
731
|
+
def generated_review_secret_owned_by_app?(secret, secret_name = config.secrets)
|
|
732
|
+
secret.is_a?(Hash) && secret["name"] == secret_name && secret["type"] == "dictionary" &&
|
|
733
|
+
generated_review_app_tag(secret) == config.app
|
|
734
|
+
end
|
|
735
|
+
|
|
736
|
+
def generated_review_policy_binding_state(policy)
|
|
737
|
+
bindings = Array(policy["bindings"])
|
|
738
|
+
return :unbound if bindings.empty?
|
|
739
|
+
return :unsafe unless generated_review_policy_bindings_for_app?(bindings)
|
|
740
|
+
|
|
741
|
+
case bindings.map { |binding| binding["permissions"] }.uniq
|
|
742
|
+
when [[]] then :unbound
|
|
743
|
+
when [["reveal"]] then :bound
|
|
744
|
+
else :unsafe
|
|
745
|
+
end
|
|
746
|
+
end
|
|
747
|
+
|
|
748
|
+
def generated_review_policy_bindings_for_app?(bindings)
|
|
749
|
+
bindings.all? { |binding| Array(binding["principalLinks"]) == [config.identity_link] }
|
|
750
|
+
end
|
|
751
|
+
|
|
752
|
+
def secret_policy_target_links(secret_name)
|
|
700
753
|
[
|
|
701
754
|
"//secret/#{secret_name}",
|
|
702
755
|
"/org/#{config.org}/secret/#{secret_name}"
|
data/lib/command/delete.rb
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "../core/disposable_postgres_helpers"
|
|
4
|
+
|
|
3
5
|
module Command
|
|
4
6
|
class Delete < Base # rubocop:disable Metrics/ClassLength
|
|
5
7
|
NAME = "delete"
|
|
@@ -14,6 +16,7 @@ module Command
|
|
|
14
16
|
- Deletes the whole app (GVC with all workloads, all volumesets and all images) or a specific workload
|
|
15
17
|
- Also unbinds the app from the secrets policy and any configured `shared_secret_grants` policies, as long as both the identity and each policy exist (and are bound)
|
|
16
18
|
- For the app-specific secrets policy, removes every permission held by the app identity; for `shared_secret_grants`, removes only `reveal`
|
|
19
|
+
- Removes a marked per-app dictionary and exact-target unbound policy for generated review credentials, including after the opt-in is removed from a dynamically matched review-app entry
|
|
17
20
|
- Will ask for explicit user confirmation
|
|
18
21
|
- Runs a pre-deletion hook before the app is deleted if `hooks.pre_deletion` is specified in the `.controlplane/controlplane.yml` file
|
|
19
22
|
- If the hook exits with a non-zero code, the command will stop executing and also exit with a non-zero code
|
|
@@ -50,8 +53,10 @@ module Command
|
|
|
50
53
|
end
|
|
51
54
|
|
|
52
55
|
def delete_whole_app
|
|
53
|
-
return
|
|
56
|
+
return handle_missing_app if cp.fetch_gvc.nil?
|
|
54
57
|
|
|
58
|
+
postgres_helpers.validate!
|
|
59
|
+
validate_disposable_review_secret_resources!
|
|
55
60
|
check_volumesets
|
|
56
61
|
check_images
|
|
57
62
|
return unless confirm_delete(config.app)
|
|
@@ -61,11 +66,104 @@ module Command
|
|
|
61
66
|
policy_unbinds = secret_policy_unbinds
|
|
62
67
|
run_pre_deletion_hook unless config.options[:skip_pre_deletion_hook]
|
|
63
68
|
unbind_identity_from_policy(policy_unbinds)
|
|
69
|
+
delete_app_resources
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def handle_missing_app
|
|
73
|
+
progress.puts("App '#{config.app}' does not exist.")
|
|
74
|
+
cleanup_state = validate_disposable_review_secret_resources!
|
|
75
|
+
postgres_resources = postgres_helpers.validate!
|
|
76
|
+
return if cleanup_state == :none && postgres_resources.empty?
|
|
77
|
+
return unless confirm_delete("disposable secrets for app #{config.app}")
|
|
78
|
+
|
|
79
|
+
unbind_missing_app_disposable_policy! if cleanup_state == :own_binding
|
|
80
|
+
delete_generated_review_secret_resources
|
|
81
|
+
delete_postgres_helpers
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def validate_disposable_review_secret_resources!
|
|
85
|
+
cleanup_state = disposable_review_secret_cleanup_state
|
|
86
|
+
if cleanup_state == :unsafe
|
|
87
|
+
raise "Review app secret resources have unexpected ownership, grants, or target; leaving them for inspection."
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
cleanup_state
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def disposable_review_secret_cleanup_state
|
|
94
|
+
names = config.disposable_review_secret_resource_names
|
|
95
|
+
return :none unless names
|
|
96
|
+
|
|
97
|
+
secret_name, policy_name = names
|
|
98
|
+
policy = cp.fetch_policy(policy_name)
|
|
99
|
+
secret = cp.fetch_secret(secret_name)
|
|
100
|
+
return :none if policy.nil? && secret.nil?
|
|
101
|
+
|
|
102
|
+
classify_disposable_review_secret_resources(policy, secret, secret_name)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def classify_disposable_review_secret_resources(policy, secret, secret_name)
|
|
106
|
+
return generated_review_secret_owned_by_app?(secret, secret_name) ? :safe : :unsafe if policy.nil?
|
|
107
|
+
return :unsafe unless generated_review_secret_policy?(policy, secret_name)
|
|
108
|
+
return :unsafe unless generated_review_secret_or_absent?(secret, secret_name)
|
|
109
|
+
return :safe if unbound_review_policy?(policy)
|
|
110
|
+
|
|
111
|
+
only_app_identity_bindings?(policy) ? :own_binding : :unsafe
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def generated_review_secret_or_absent?(secret, secret_name)
|
|
115
|
+
secret.nil? || generated_review_secret_owned_by_app?(secret, secret_name)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def only_app_identity_bindings?(policy)
|
|
119
|
+
generated_review_policy_binding_state(policy) == :bound
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def unbound_review_policy?(policy)
|
|
123
|
+
generated_review_policy_binding_state(policy) == :unbound
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def unbind_missing_app_disposable_policy!
|
|
127
|
+
secret_name, policy_name = config.disposable_review_secret_resource_names
|
|
128
|
+
policy = cp.fetch_policy(policy_name)
|
|
129
|
+
return if policy.nil?
|
|
130
|
+
|
|
131
|
+
unless generated_review_secret_policy?(policy, secret_name) &&
|
|
132
|
+
(unbound_review_policy?(policy) || only_app_identity_bindings?(policy))
|
|
133
|
+
refuse_unsafe_review_secret_resources!
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
remove_missing_app_policy_bindings(policy, policy_name)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def remove_missing_app_policy_bindings(policy, policy_name)
|
|
140
|
+
identity_policy_permissions(policy).each do |permission|
|
|
141
|
+
step("Unbinding deleted app identity from disposable review app policy") do
|
|
142
|
+
cp.unbind_identity_from_policy(config.identity_link, policy_name, permission: permission)
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def delete_app_resources
|
|
64
148
|
delete_volumesets
|
|
149
|
+
# Keep the GVC discoverable by stale-app cleanup if disposable resource deletion fails.
|
|
150
|
+
# The app identity has already been unbound from its secret policy.
|
|
151
|
+
delete_generated_review_secret_resources
|
|
152
|
+
delete_postgres_helpers
|
|
65
153
|
delete_gvc
|
|
66
154
|
delete_images
|
|
67
155
|
end
|
|
68
156
|
|
|
157
|
+
def postgres_helpers
|
|
158
|
+
@postgres_helpers ||= DisposablePostgresHelpers.new(config, cp)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def delete_postgres_helpers
|
|
162
|
+
postgres_helpers.delete do |kind, name, &operation|
|
|
163
|
+
step("Deleting disposable PostgreSQL #{kind} '#{name}'", &operation)
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
69
167
|
def check_volumesets
|
|
70
168
|
@volumesets = cp.fetch_volumesets["items"]
|
|
71
169
|
return progress.puts("No volumesets to delete from app '#{config.app}'.") unless @volumesets.any?
|
|
@@ -103,6 +201,53 @@ module Command
|
|
|
103
201
|
end
|
|
104
202
|
end
|
|
105
203
|
|
|
204
|
+
def delete_generated_review_secret_resources
|
|
205
|
+
names = config.disposable_review_secret_resource_names
|
|
206
|
+
return unless names
|
|
207
|
+
|
|
208
|
+
secret_name, policy_name = names
|
|
209
|
+
|
|
210
|
+
policy = cp.fetch_policy(policy_name)
|
|
211
|
+
secret = cp.fetch_secret(secret_name)
|
|
212
|
+
return delete_generated_review_secret_without_policy(secret, secret_name) if policy.nil?
|
|
213
|
+
|
|
214
|
+
delete_generated_review_secret_with_policy(policy, secret, secret_name, policy_name)
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def delete_generated_review_secret_without_policy(secret, secret_name)
|
|
218
|
+
return if secret.nil?
|
|
219
|
+
|
|
220
|
+
refuse_unsafe_review_secret_resources! unless generated_review_secret_owned_by_app?(secret, secret_name)
|
|
221
|
+
|
|
222
|
+
step("Deleting orphaned disposable review app secret dictionary") { cp.delete_secret(secret_name) }
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def delete_generated_review_secret_with_policy(policy, secret, secret_name, policy_name)
|
|
226
|
+
refuse_unsafe_review_secret_resources! unless disposable_review_secret_policy?(policy, secret_name)
|
|
227
|
+
|
|
228
|
+
if secret
|
|
229
|
+
refuse_unsafe_review_secret_resources! unless generated_review_secret_owned_by_app?(secret, secret_name)
|
|
230
|
+
|
|
231
|
+
step("Deleting disposable review app secret dictionary") { cp.delete_secret(secret_name) }
|
|
232
|
+
end
|
|
233
|
+
step("Deleting disposable review app secret policy") { cp.delete_policy(policy_name) }
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def refuse_unsafe_review_secret_resources!
|
|
237
|
+
raise "Review app secret resources have unexpected ownership, grants, or target; " \
|
|
238
|
+
"leaving secret resources for inspection."
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def disposable_review_secret_policy?(policy, secret_name)
|
|
242
|
+
generated_review_secret_policy?(policy, secret_name) && unbound_review_policy?(policy)
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def generated_review_secret_policy?(policy, secret_name)
|
|
246
|
+
policy_targets_secret?(policy, secret_name) &&
|
|
247
|
+
generated_review_app_tag(policy) == config.app &&
|
|
248
|
+
%w[target targetQuery gvc].all? { |key| policy[key].nil? }
|
|
249
|
+
end
|
|
250
|
+
|
|
106
251
|
def delete_workload(workload)
|
|
107
252
|
step("Deleting workload '#{workload}' from app '#{config.app}'") do
|
|
108
253
|
cp.delete_workload(workload)
|
|
@@ -136,10 +281,13 @@ module Command
|
|
|
136
281
|
end
|
|
137
282
|
|
|
138
283
|
def secret_policy_unbinds
|
|
139
|
-
|
|
284
|
+
unless cp.fetch_identity(config.identity)
|
|
285
|
+
return [disposable_review_secret_policy_unbind(include_current: true)].compact
|
|
286
|
+
end
|
|
140
287
|
|
|
141
288
|
[
|
|
142
289
|
app_secret_policy_unbind,
|
|
290
|
+
disposable_review_secret_policy_unbind,
|
|
143
291
|
*shared_secret_policy_unbinds
|
|
144
292
|
].compact
|
|
145
293
|
end
|
|
@@ -151,6 +299,28 @@ module Command
|
|
|
151
299
|
)
|
|
152
300
|
end
|
|
153
301
|
|
|
302
|
+
def disposable_review_secret_policy_unbind(include_current: false)
|
|
303
|
+
names = config.disposable_review_secret_resource_names
|
|
304
|
+
return unless names && (include_current || names.last != config.secrets_policy)
|
|
305
|
+
|
|
306
|
+
secret_name, policy_name = names
|
|
307
|
+
policy = verified_disposable_review_secret_policy(secret_name, policy_name)
|
|
308
|
+
return unless policy
|
|
309
|
+
|
|
310
|
+
policy_unbind_for(policy_name, "Unbinding identity from disposable review app policy '#{policy_name}'", policy)
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
def verified_disposable_review_secret_policy(secret_name, policy_name)
|
|
314
|
+
secret = cp.fetch_secret(secret_name)
|
|
315
|
+
return if secret && !generated_review_secret_owned_by_app?(secret, secret_name)
|
|
316
|
+
|
|
317
|
+
policy = cp.fetch_policy(policy_name)
|
|
318
|
+
return unless policy && generated_review_secret_policy?(policy, secret_name) &&
|
|
319
|
+
(unbound_review_policy?(policy) || only_app_identity_bindings?(policy))
|
|
320
|
+
|
|
321
|
+
policy
|
|
322
|
+
end
|
|
323
|
+
|
|
154
324
|
def shared_secret_policy_unbinds
|
|
155
325
|
config.shared_secret_grants.filter_map do |grant|
|
|
156
326
|
shared_secret_policy_unbind(grant)
|
|
@@ -190,8 +360,8 @@ module Command
|
|
|
190
360
|
)
|
|
191
361
|
end
|
|
192
362
|
|
|
193
|
-
def policy_unbind_for(policy_name, message)
|
|
194
|
-
policy
|
|
363
|
+
def policy_unbind_for(policy_name, message, policy = nil)
|
|
364
|
+
policy ||= cp.fetch_policy(policy_name)
|
|
195
365
|
return if policy.nil?
|
|
196
366
|
|
|
197
367
|
permissions = identity_policy_permissions(policy)
|
data/lib/command/deploy_image.rb
CHANGED
|
@@ -145,6 +145,7 @@ module Command
|
|
|
145
145
|
|
|
146
146
|
def update_workload_image_ref(workload, container, image)
|
|
147
147
|
attempts = 0
|
|
148
|
+
mark_deployment_started
|
|
148
149
|
|
|
149
150
|
loop do
|
|
150
151
|
attempts += 1
|
|
@@ -158,6 +159,11 @@ module Command
|
|
|
158
159
|
end
|
|
159
160
|
end
|
|
160
161
|
|
|
162
|
+
def mark_deployment_started
|
|
163
|
+
marker_path = ENV.fetch("CPFLOW_DEPLOYMENT_STARTED_FILE", "")
|
|
164
|
+
File.write(marker_path, "started\n") unless marker_path.empty?
|
|
165
|
+
end
|
|
166
|
+
|
|
161
167
|
def workload_image_update_limit(output)
|
|
162
168
|
return WORKLOAD_IMAGE_UPDATE_CONFLICT_MAX_ATTEMPTS if output.match?(WORKLOAD_IMAGE_UPDATE_CONFLICT_PATTERN)
|
|
163
169
|
|