cpflow 4.2.0 → 5.0.0.rc.1
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/workflows/address-review.md +216 -0
- data/.claude/commands/address-review.md +547 -0
- data/.claude/commands/update-changelog.md +367 -0
- data/.github/workflows/claude.yml +5 -0
- data/.overcommit.yml +43 -3
- data/.rubocop.yml +3 -3
- data/CHANGELOG.md +28 -4
- data/CONTRIBUTING.md +28 -0
- data/Gemfile +8 -7
- data/Gemfile.lock +92 -72
- data/README.md +55 -20
- data/cpflow.gemspec +5 -5
- data/docs/ai-github-flow-prompt.md +61 -0
- data/docs/ci-automation.md +335 -28
- data/docs/commands.md +67 -4
- data/docs/migrating-heroku-to-control-plane.md +12 -0
- data/docs/postgres.md +5 -0
- data/docs/redis.md +6 -0
- data/docs/releasing.md +153 -0
- data/lib/command/ai_github_flow_prompt.rb +47 -0
- data/lib/command/base.rb +25 -0
- data/lib/command/cleanup_images.rb +1 -1
- data/lib/command/cleanup_stale_apps.rb +1 -1
- data/lib/command/copy_image_from_upstream.rb +14 -3
- data/lib/command/deploy_image.rb +40 -9
- data/lib/command/exists.rb +13 -2
- data/lib/command/generate.rb +153 -4
- data/lib/command/generate_github_actions.rb +170 -0
- data/lib/command/generator_helpers.rb +31 -0
- data/lib/command/github_flow_readiness.rb +37 -0
- data/lib/command/promote_app_from_upstream.rb +13 -2
- data/lib/command/run.rb +1 -1
- data/lib/command/terraform/generate.rb +1 -0
- data/lib/command/version.rb +1 -0
- data/lib/constants/exit_code.rb +1 -0
- data/lib/core/config.rb +8 -0
- data/lib/core/controlplane.rb +9 -7
- data/lib/core/controlplane_api_direct.rb +3 -3
- data/lib/core/github_flow_readiness/checks.rb +143 -0
- data/lib/core/github_flow_readiness_service.rb +453 -0
- data/lib/core/repo_introspection.rb +118 -0
- data/lib/core/terraform_config/dsl.rb +1 -1
- data/lib/core/terraform_config/local_variable.rb +1 -1
- data/lib/cpflow/version.rb +1 -1
- data/lib/cpflow.rb +65 -3
- data/lib/generator_templates/Dockerfile +59 -3
- data/lib/generator_templates/controlplane.yml +27 -39
- data/lib/generator_templates/entrypoint.sh +1 -1
- data/lib/generator_templates/release_script.sh +23 -0
- data/lib/generator_templates/templates/app.yml +5 -8
- data/lib/generator_templates/templates/rails.yml +2 -11
- data/lib/generator_templates_sqlite/controlplane.yml +46 -0
- data/lib/generator_templates_sqlite/release_script.sh +25 -0
- data/lib/generator_templates_sqlite/templates/app.yml +15 -0
- data/lib/generator_templates_sqlite/templates/db.yml +6 -0
- data/lib/generator_templates_sqlite/templates/rails.yml +32 -0
- data/lib/generator_templates_sqlite/templates/storage.yml +6 -0
- data/lib/github_flow_templates/.github/actions/cpflow-build-docker-image/action.yml +131 -0
- data/lib/github_flow_templates/.github/actions/cpflow-delete-control-plane-app/action.yml +24 -0
- data/lib/github_flow_templates/.github/actions/cpflow-delete-control-plane-app/delete-app.sh +50 -0
- data/lib/github_flow_templates/.github/actions/cpflow-detect-release-phase/action.yml +62 -0
- data/lib/github_flow_templates/.github/actions/cpflow-setup-environment/action.yml +98 -0
- data/lib/github_flow_templates/.github/actions/cpflow-validate-config/action.yml +85 -0
- data/lib/github_flow_templates/.github/actions/cpflow-wait-for-health/action.yml +92 -0
- data/lib/github_flow_templates/.github/cpflow-help.md +73 -0
- data/lib/github_flow_templates/.github/workflows/cpflow-cleanup-stale-review-apps.yml +56 -0
- data/lib/github_flow_templates/.github/workflows/cpflow-delete-review-app.yml +142 -0
- data/lib/github_flow_templates/.github/workflows/cpflow-deploy-review-app.yml +445 -0
- data/lib/github_flow_templates/.github/workflows/cpflow-deploy-staging.yml +140 -0
- data/lib/github_flow_templates/.github/workflows/cpflow-help-command.yml +58 -0
- data/lib/github_flow_templates/.github/workflows/cpflow-promote-staging-to-production.yml +490 -0
- data/lib/github_flow_templates/.github/workflows/cpflow-review-app-help.yml +45 -0
- data/rakelib/create_release.rake +662 -37
- data/script/check_command_docs +4 -2
- data/script/check_cpln_links +25 -11
- data/script/precommit/check_command_docs +22 -0
- data/script/precommit/check_cpln_links +21 -0
- data/script/precommit/check_trailing_newlines +68 -0
- data/script/precommit/get_changed_files +49 -0
- data/script/precommit/ruby_autofix +52 -0
- data/script/precommit/ruby_lint +33 -0
- metadata +54 -14
data/docs/releasing.md
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# Releasing the Gem
|
|
2
|
+
|
|
3
|
+
This project follows a changelog-first Ruby gem release process, modeled after
|
|
4
|
+
the React on Rails release flow but without any npm publishing steps.
|
|
5
|
+
|
|
6
|
+
## Release Process
|
|
7
|
+
|
|
8
|
+
### 1. Update the Changelog
|
|
9
|
+
|
|
10
|
+
Always update `CHANGELOG.md` before running the release task.
|
|
11
|
+
|
|
12
|
+
1. Ensure all desired changes are merged to `main`.
|
|
13
|
+
2. Run `/update-changelog release` to find merged PRs, add entries under the
|
|
14
|
+
right headings, compute the next version, stamp the version header, update
|
|
15
|
+
compare links, and open a changelog PR. Use `/update-changelog rc`,
|
|
16
|
+
`/update-changelog beta`, or an explicit version like
|
|
17
|
+
`/update-changelog 4.2.0.rc.1` when preparing a prerelease or fixed target
|
|
18
|
+
version.
|
|
19
|
+
3. Review the generated changelog PR and verify the version number matches the
|
|
20
|
+
intended release level:
|
|
21
|
+
- Breaking changes: major
|
|
22
|
+
- Added features or enhancements: minor
|
|
23
|
+
- Fixes, improvements, deprecations, removals, or security updates: patch
|
|
24
|
+
4. Merge the changelog PR before releasing.
|
|
25
|
+
|
|
26
|
+
If updating the changelog manually, move the relevant `Unreleased` entries into
|
|
27
|
+
a versioned header:
|
|
28
|
+
|
|
29
|
+
```markdown
|
|
30
|
+
## [4.2.0] - 2026-05-05
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Then update the compare links at the bottom of `CHANGELOG.md`, including the
|
|
34
|
+
`Unreleased` link and the new version link, and merge those changelog changes
|
|
35
|
+
before releasing.
|
|
36
|
+
|
|
37
|
+
The release task reads the latest versioned `CHANGELOG.md` header and can create
|
|
38
|
+
the GitHub release from that section automatically.
|
|
39
|
+
|
|
40
|
+
### 2. Run the Release Task
|
|
41
|
+
|
|
42
|
+
The recommended command has no arguments:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
bundle exec rake release
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
With no arguments, `rake release`:
|
|
49
|
+
|
|
50
|
+
1. Reads the first versioned `CHANGELOG.md` header, such as `## [4.2.0]`.
|
|
51
|
+
2. Uses that version when it is newer than the current gem version.
|
|
52
|
+
3. Uses the current version if the changelog version matches the gem version
|
|
53
|
+
but has not been tagged yet.
|
|
54
|
+
4. Falls back to a patch bump if no new changelog version is found.
|
|
55
|
+
|
|
56
|
+
Other supported forms:
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
bundle exec rake "release[patch]"
|
|
60
|
+
bundle exec rake "release[minor]"
|
|
61
|
+
bundle exec rake "release[major]"
|
|
62
|
+
bundle exec rake "release[4.2.0]"
|
|
63
|
+
bundle exec rake "release[4.2.0.rc.1]"
|
|
64
|
+
bundle exec rake "release[4.2.0,true]"
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Use RubyGems version format for prereleases: `4.2.0.rc.1`, not
|
|
68
|
+
`4.2.0-rc.1`.
|
|
69
|
+
|
|
70
|
+
Full argument list:
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
bundle exec rake "release[version,dry_run,override_version_policy]"
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Environment variables:
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
VERBOSE=1
|
|
80
|
+
RUBYGEMS_OTP=<code>
|
|
81
|
+
RELEASE_VERSION_POLICY_OVERRIDE=true
|
|
82
|
+
GEM_RELEASE_MAX_RETRIES=<n>
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
### 3. What the Task Does
|
|
86
|
+
|
|
87
|
+
`bundle exec rake release` performs the gem-only release:
|
|
88
|
+
|
|
89
|
+
1. Requires a clean working tree.
|
|
90
|
+
2. Verifies `gem-release` is available through Bundler.
|
|
91
|
+
3. For real releases, verifies GitHub CLI authentication and write access.
|
|
92
|
+
4. Pulls the latest changes with `git pull --rebase`.
|
|
93
|
+
5. Resolves the target version from the changelog or explicit argument.
|
|
94
|
+
6. Requires stable releases to run from `main`; prereleases may run from another
|
|
95
|
+
branch.
|
|
96
|
+
7. Validates the target version is newer than the latest tag and is consistent
|
|
97
|
+
with the changelog section when the section indicates a bump level.
|
|
98
|
+
8. Bumps `lib/cpflow/version.rb` and updates `Gemfile.lock`.
|
|
99
|
+
9. Commits the version bump, tags `vX.Y.Z`, and pushes the commit and tags.
|
|
100
|
+
10. Publishes the `cpflow` gem to RubyGems.org.
|
|
101
|
+
11. Creates or updates the GitHub release from the matching changelog section.
|
|
102
|
+
|
|
103
|
+
The older `bundle exec rake "create_release[4.2.0,false]"` task name remains as
|
|
104
|
+
a compatibility alias, but new releases should use `bundle exec rake release`.
|
|
105
|
+
|
|
106
|
+
### 4. Sync GitHub Release Notes Manually
|
|
107
|
+
|
|
108
|
+
If the GitHub release was not created automatically, update and commit the
|
|
109
|
+
matching changelog section, then run:
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
bundle exec rake "sync_github_release[4.2.0]"
|
|
113
|
+
bundle exec rake "sync_github_release[4.2.0,true]"
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
`sync_github_release` reads notes from `CHANGELOG.md` and creates or updates the
|
|
117
|
+
GitHub release for the corresponding `vX.Y.Z` tag.
|
|
118
|
+
|
|
119
|
+
## Pre-Release Checklist
|
|
120
|
+
|
|
121
|
+
Before running the release:
|
|
122
|
+
|
|
123
|
+
1. `git checkout main`
|
|
124
|
+
2. `git pull --rebase`
|
|
125
|
+
3. `bundle install`
|
|
126
|
+
4. `gh auth status`
|
|
127
|
+
5. Confirm RubyGems credentials can publish `cpflow`.
|
|
128
|
+
6. Confirm `CHANGELOG.md` has a committed section for the target version.
|
|
129
|
+
7. Run a dry run:
|
|
130
|
+
|
|
131
|
+
```bash
|
|
132
|
+
bundle exec rake "release[4.2.0,true]"
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
## If a Release Fails
|
|
136
|
+
|
|
137
|
+
Check what was published before retrying:
|
|
138
|
+
|
|
139
|
+
```bash
|
|
140
|
+
gem list cpflow -r -a
|
|
141
|
+
gh release view v4.2.0
|
|
142
|
+
git tag -l v4.2.0
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
If the gem was published but GitHub release creation failed, fix GitHub CLI
|
|
146
|
+
authentication or permissions and run:
|
|
147
|
+
|
|
148
|
+
```bash
|
|
149
|
+
bundle exec rake "sync_github_release[4.2.0]"
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
If the tag was pushed but the gem was not published, delete or correct the tag
|
|
153
|
+
and version commit intentionally before trying again.
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../core/repo_introspection"
|
|
4
|
+
|
|
5
|
+
module Command
|
|
6
|
+
class AiGithubFlowPrompt < Base
|
|
7
|
+
NAME = "ai-github-flow-prompt"
|
|
8
|
+
DESCRIPTION = "Prints the recommended AI prompt for adding the Control Plane GitHub Flow to a repo"
|
|
9
|
+
LONG_DESCRIPTION = <<~DESC
|
|
10
|
+
Prints a copy-paste prompt for an AI agent to roll out the reusable Control Plane GitHub Flow:
|
|
11
|
+
- verifies the repo is deployable from a clean clone before generating files
|
|
12
|
+
- scaffolds `.controlplane/` and `cpflow-*` GitHub Actions files when the repo qualifies
|
|
13
|
+
- stops on external blockers or product decisions instead of forcing a broken rollout
|
|
14
|
+
DESC
|
|
15
|
+
EXAMPLES = <<~EX
|
|
16
|
+
```sh
|
|
17
|
+
# Prints the recommended AI rollout prompt for the current repo
|
|
18
|
+
cpflow ai-github-flow-prompt
|
|
19
|
+
```
|
|
20
|
+
EX
|
|
21
|
+
WITH_INFO_HEADER = false
|
|
22
|
+
VALIDATIONS = [].freeze
|
|
23
|
+
REQUIRES_STARTUP_CHECKS = false
|
|
24
|
+
|
|
25
|
+
def call
|
|
26
|
+
puts prompt
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def prompt
|
|
32
|
+
<<~PROMPT
|
|
33
|
+
Set up Control Plane GitHub Flow for this repo. 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
|
+
|
|
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.
|
|
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 uses persistent `db` and `storage` volumes plus a release script that 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
|
+
|
|
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
|
+
PROMPT
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def inferred_app_prefix
|
|
44
|
+
RepoIntrospection.inferred_app_prefix(Dir.pwd)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
data/lib/command/base.rb
CHANGED
|
@@ -40,6 +40,8 @@ module Command
|
|
|
40
40
|
WITH_INFO_HEADER = true
|
|
41
41
|
# Which validations to run before the command
|
|
42
42
|
VALIDATIONS = %w[config].freeze
|
|
43
|
+
# Whether or not to run CLI startup checks such as cpln availability and update checks
|
|
44
|
+
REQUIRES_STARTUP_CHECKS = true
|
|
43
45
|
|
|
44
46
|
def initialize(config)
|
|
45
47
|
@config = config
|
|
@@ -316,6 +318,18 @@ module Command
|
|
|
316
318
|
}
|
|
317
319
|
end
|
|
318
320
|
|
|
321
|
+
def self.staging_branch_option(required: false)
|
|
322
|
+
{
|
|
323
|
+
name: :staging_branch,
|
|
324
|
+
params: {
|
|
325
|
+
banner: "BRANCH",
|
|
326
|
+
desc: "Branch that should auto-deploy staging; defaults to main/master",
|
|
327
|
+
type: :string,
|
|
328
|
+
required: required
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
end
|
|
332
|
+
|
|
319
333
|
def self.logs_limit_option(required: false)
|
|
320
334
|
{
|
|
321
335
|
name: :limit,
|
|
@@ -477,6 +491,17 @@ module Command
|
|
|
477
491
|
}
|
|
478
492
|
}
|
|
479
493
|
end
|
|
494
|
+
|
|
495
|
+
def self.use_digest_image_ref_option(required: false)
|
|
496
|
+
{
|
|
497
|
+
name: :use_digest_image_ref,
|
|
498
|
+
params: {
|
|
499
|
+
desc: "Uses the image's digest (SHA256 value) for referencing the Docker image",
|
|
500
|
+
type: :boolean,
|
|
501
|
+
required: required
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
end
|
|
480
505
|
# rubocop:enable Metrics/MethodLength
|
|
481
506
|
|
|
482
507
|
def self.all_options
|
|
@@ -26,7 +26,7 @@ module Command
|
|
|
26
26
|
|
|
27
27
|
progress.puts("Images to delete:")
|
|
28
28
|
images_to_delete.each do |image|
|
|
29
|
-
created = Shell.color(
|
|
29
|
+
created = Shell.color(image[:created].to_s, :red)
|
|
30
30
|
reason = Shell.color(image[:reason], :red)
|
|
31
31
|
progress.puts(" - #{image[:name]} (#{created} - #{reason})")
|
|
32
32
|
end
|
|
@@ -22,7 +22,7 @@ module Command
|
|
|
22
22
|
|
|
23
23
|
progress.puts("Stale apps:")
|
|
24
24
|
stale_apps.each do |app|
|
|
25
|
-
progress.puts(" - #{app[:name]} (#{Shell.color(
|
|
25
|
+
progress.puts(" - #{app[:name]} (#{Shell.color(app[:date].to_s, :red)})")
|
|
26
26
|
end
|
|
27
27
|
|
|
28
28
|
return unless confirm_delete
|
|
@@ -5,14 +5,14 @@ module Command
|
|
|
5
5
|
NAME = "copy-image-from-upstream"
|
|
6
6
|
OPTIONS = [
|
|
7
7
|
app_option(required: true),
|
|
8
|
-
upstream_token_option
|
|
8
|
+
upstream_token_option,
|
|
9
9
|
image_option
|
|
10
10
|
].freeze
|
|
11
11
|
DESCRIPTION = "Copies an image (by default the latest) from a source org to the current org"
|
|
12
12
|
LONG_DESCRIPTION = <<~DESC
|
|
13
13
|
- Copies an image (by default the latest) from a source org to the current org
|
|
14
14
|
- The source app must be specified either through the `CPLN_UPSTREAM` env var or `upstream` in the `.controlplane/controlplane.yml` file
|
|
15
|
-
-
|
|
15
|
+
- The token for the source org must be provided through `--upstream-token`/`-t` or the `CPLN_UPSTREAM_TOKEN` env var
|
|
16
16
|
- A `cpln` profile will be temporarily created to pull the image from the source org
|
|
17
17
|
DESC
|
|
18
18
|
EXAMPLES = <<~EX
|
|
@@ -20,6 +20,9 @@ module Command
|
|
|
20
20
|
# Copies the latest image from the source org to the current org.
|
|
21
21
|
cpflow copy-image-from-upstream -a $APP_NAME --upstream-token $UPSTREAM_TOKEN
|
|
22
22
|
|
|
23
|
+
# Equivalent call using an env var (avoids exposing the token via the OS process table).
|
|
24
|
+
CPLN_UPSTREAM_TOKEN=$UPSTREAM_TOKEN cpflow copy-image-from-upstream -a $APP_NAME
|
|
25
|
+
|
|
23
26
|
# Copies a specific image from the source org to the current org.
|
|
24
27
|
cpflow copy-image-from-upstream -a $APP_NAME --upstream-token $UPSTREAM_TOKEN --image appimage:123
|
|
25
28
|
```
|
|
@@ -30,7 +33,9 @@ module Command
|
|
|
30
33
|
|
|
31
34
|
@upstream = ENV.fetch("CPLN_UPSTREAM", nil) || config[:upstream]
|
|
32
35
|
@upstream_org = ENV.fetch("CPLN_ORG_UPSTREAM", nil) || config.find_app_config(@upstream)&.dig(:cpln_org)
|
|
36
|
+
@upstream_token = config.options[:upstream_token] || ENV.fetch("CPLN_UPSTREAM_TOKEN", nil)
|
|
33
37
|
ensure_upstream_org!
|
|
38
|
+
ensure_upstream_token!
|
|
34
39
|
|
|
35
40
|
create_upstream_profile
|
|
36
41
|
fetch_upstream_image_url
|
|
@@ -51,6 +56,12 @@ module Command
|
|
|
51
56
|
"and CPLN_ORG_UPSTREAM env var is not set."
|
|
52
57
|
end
|
|
53
58
|
|
|
59
|
+
def ensure_upstream_token!
|
|
60
|
+
return if @upstream_token && !@upstream_token.strip.empty?
|
|
61
|
+
|
|
62
|
+
raise "Missing upstream token. Pass `--upstream-token`/`-t` or set the `CPLN_UPSTREAM_TOKEN` env var."
|
|
63
|
+
end
|
|
64
|
+
|
|
54
65
|
def create_upstream_profile
|
|
55
66
|
step("Creating upstream profile") do
|
|
56
67
|
loop do
|
|
@@ -58,7 +69,7 @@ module Command
|
|
|
58
69
|
break unless cp.profile_exists?(@upstream_profile)
|
|
59
70
|
end
|
|
60
71
|
|
|
61
|
-
cp.profile_create(@upstream_profile,
|
|
72
|
+
cp.profile_create(@upstream_profile, @upstream_token)
|
|
62
73
|
end
|
|
63
74
|
end
|
|
64
75
|
|
data/lib/command/deploy_image.rb
CHANGED
|
@@ -7,7 +7,8 @@ module Command
|
|
|
7
7
|
NAME = "deploy-image"
|
|
8
8
|
OPTIONS = [
|
|
9
9
|
app_option(required: true),
|
|
10
|
-
run_release_phase_option
|
|
10
|
+
run_release_phase_option,
|
|
11
|
+
use_digest_image_ref_option
|
|
11
12
|
].freeze
|
|
12
13
|
DESCRIPTION = "Deploys the latest image to app workloads, and runs a release script (optional)"
|
|
13
14
|
LONG_DESCRIPTION = <<~DESC
|
|
@@ -15,24 +16,19 @@ module Command
|
|
|
15
16
|
- Runs a release script before deploying if `release_script` is specified in the `.controlplane/controlplane.yml` file and `--run-release-phase` is provided
|
|
16
17
|
- The release script is run in the context of `cpflow run` with the latest image
|
|
17
18
|
- If the release script exits with a non-zero code, the command will stop executing and also exit with a non-zero code
|
|
19
|
+
- If `use_digest_image_ref` is `true` in the `.controlplane/controlplane.yml` file or `--use-digest-image-ref` option is provided, deployed image's reference will include its digest
|
|
18
20
|
DESC
|
|
19
21
|
|
|
20
22
|
def call # rubocop:disable Metrics/MethodLength
|
|
21
23
|
run_release_script if config.options[:run_release_phase]
|
|
22
24
|
|
|
23
25
|
deployed_endpoints = {}
|
|
24
|
-
|
|
25
|
-
image = cp.latest_image
|
|
26
|
-
if cp.fetch_image_details(image).nil?
|
|
27
|
-
raise "Image '#{image}' does not exist in the Docker repository on Control Plane " \
|
|
28
|
-
"(see https://console.cpln.io/console/org/#{config.org}/repository/#{config.app}). " \
|
|
29
|
-
"Use `cpflow build-image` first."
|
|
30
|
-
end
|
|
26
|
+
image = resolve_image_to_deploy
|
|
31
27
|
|
|
32
28
|
config[:app_workloads].each do |workload|
|
|
33
29
|
workload_data = cp.fetch_workload!(workload)
|
|
34
30
|
workload_data.dig("spec", "containers").each do |container|
|
|
35
|
-
next unless container["image"].match?(%r{^/org/#{config.org}/image/#{config.app}
|
|
31
|
+
next unless container["image"].match?(%r{^/org/#{config.org}/image/#{config.app}[:@]})
|
|
36
32
|
|
|
37
33
|
container_name = container["name"]
|
|
38
34
|
step("Deploying image '#{image}' for workload '#{container_name}'") do
|
|
@@ -50,6 +46,41 @@ module Command
|
|
|
50
46
|
|
|
51
47
|
private
|
|
52
48
|
|
|
49
|
+
def resolve_image_to_deploy
|
|
50
|
+
image = cp.latest_image
|
|
51
|
+
# Preserve the pre-existing fail-fast check so missing images are reported
|
|
52
|
+
# before workloads are touched.
|
|
53
|
+
image_details = fetch_image_details!(image)
|
|
54
|
+
|
|
55
|
+
return image unless config.use_digest_image_ref?
|
|
56
|
+
|
|
57
|
+
# Control Plane accepts the tagged digest form returned here; latest_image currently returns app:N.
|
|
58
|
+
"#{image}@#{image_digest!(image, image_details)}"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def fetch_image_details!(image)
|
|
62
|
+
image_details = cp.fetch_image_details(image)
|
|
63
|
+
raise image_not_found_message(image) if image_details.nil?
|
|
64
|
+
|
|
65
|
+
image_details
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def image_digest!(image, image_details)
|
|
69
|
+
digest = image_details["digest"]
|
|
70
|
+
raise "Image '#{image}' does not have a digest available." if digest.nil? || digest.empty?
|
|
71
|
+
# SHA-256 only; expand the regex if Control Plane ever returns sha512 or other digest algorithms.
|
|
72
|
+
# OCI digests are always lowercase hex per the OCI image spec.
|
|
73
|
+
raise "Unexpected digest format for image '#{image}'." unless digest.match?(/\Asha256:[a-f0-9]{64}\z/)
|
|
74
|
+
|
|
75
|
+
digest
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def image_not_found_message(image)
|
|
79
|
+
"Image '#{image}' does not exist in the Docker repository on Control Plane " \
|
|
80
|
+
"(see https://console.cpln.io/console/org/#{config.org}/repository/#{config.app}). " \
|
|
81
|
+
"Use `cpflow build-image` first."
|
|
82
|
+
end
|
|
83
|
+
|
|
53
84
|
def endpoint_for_workload(workload_data)
|
|
54
85
|
endpoint = workload_data.dig("status", "endpoint")
|
|
55
86
|
Resolv.getaddress(endpoint.split("/").last)
|
data/lib/command/exists.rb
CHANGED
|
@@ -9,15 +9,26 @@ module Command
|
|
|
9
9
|
DESCRIPTION = "Shell-checks if an application (GVC) exists, useful in scripts"
|
|
10
10
|
LONG_DESCRIPTION = <<~DESC
|
|
11
11
|
- Shell-checks if an application (GVC) exists, useful in scripts, e.g.:
|
|
12
|
+
- Exits 0 when the app exists, 3 when it does not exist, and 64 for other errors.
|
|
12
13
|
DESC
|
|
13
14
|
EXAMPLES = <<~EX
|
|
14
15
|
```sh
|
|
15
|
-
|
|
16
|
+
cpflow exists -a "$APP_NAME"
|
|
17
|
+
status=$?
|
|
18
|
+
if [ "$status" -eq 0 ]; then
|
|
19
|
+
echo "exists"
|
|
20
|
+
elif [ "$status" -eq 3 ]; then
|
|
21
|
+
echo "not found"
|
|
22
|
+
else
|
|
23
|
+
echo "error: cpflow exists exited $status"
|
|
24
|
+
fi
|
|
16
25
|
```
|
|
17
26
|
EX
|
|
18
27
|
|
|
19
28
|
def call
|
|
20
|
-
exit(cp.fetch_gvc.nil? ? ExitCode::
|
|
29
|
+
exit(cp.fetch_gvc.nil? ? ExitCode::NOT_FOUND : ExitCode::SUCCESS)
|
|
30
|
+
rescue StandardError => e
|
|
31
|
+
Shell.abort(e.message)
|
|
21
32
|
end
|
|
22
33
|
end
|
|
23
34
|
end
|
data/lib/command/generate.rb
CHANGED
|
@@ -1,32 +1,181 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
5
|
+
require_relative "generator_helpers"
|
|
6
|
+
require_relative "../core/repo_introspection"
|
|
7
|
+
|
|
3
8
|
module Command
|
|
4
|
-
class Generator < Thor::Group
|
|
9
|
+
class Generator < Thor::Group # rubocop:disable Metrics/ClassLength
|
|
5
10
|
include Thor::Actions
|
|
11
|
+
include GeneratorHelpers
|
|
12
|
+
|
|
13
|
+
COMMON_TEMPLATE_FILES = %w[
|
|
14
|
+
Dockerfile
|
|
15
|
+
entrypoint.sh
|
|
16
|
+
].freeze
|
|
17
|
+
POSTGRES_TEMPLATE_FILES = %w[
|
|
18
|
+
controlplane.yml
|
|
19
|
+
templates/app.yml
|
|
20
|
+
templates/postgres.yml
|
|
21
|
+
templates/rails.yml
|
|
22
|
+
release_script.sh
|
|
23
|
+
].freeze
|
|
24
|
+
SQLITE_TEMPLATE_FILES = %w[
|
|
25
|
+
controlplane.yml
|
|
26
|
+
release_script.sh
|
|
27
|
+
templates/app.yml
|
|
28
|
+
templates/db.yml
|
|
29
|
+
templates/rails.yml
|
|
30
|
+
templates/storage.yml
|
|
31
|
+
].freeze
|
|
32
|
+
|
|
33
|
+
# Fallback Ruby version when the repo doesn't pin one via `.ruby-version`,
|
|
34
|
+
# `.tool-versions`, or the `Gemfile`. Keep this on a supported release line
|
|
35
|
+
# (https://www.ruby-lang.org/en/downloads/branches/).
|
|
36
|
+
DEFAULT_RUBY_VERSION = "3.3"
|
|
6
37
|
|
|
7
38
|
def copy_files
|
|
8
|
-
|
|
39
|
+
generated_paths = copy_template_files("generator_templates", base_template_files)
|
|
40
|
+
generated_paths += copy_template_files("generator_templates_sqlite", SQLITE_TEMPLATE_FILES) if sqlite_project?
|
|
41
|
+
substitute_template_variables(generated_paths)
|
|
42
|
+
make_shell_scripts_executable(generated_paths)
|
|
9
43
|
end
|
|
10
44
|
|
|
11
45
|
def self.source_root
|
|
12
46
|
Cpflow.root_path.join("lib")
|
|
13
47
|
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def copy_template_files(root_dir, relative_paths)
|
|
52
|
+
relative_paths.map { |relative_path| copy_template_file(root_dir, relative_path) }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def copy_template_file(root_dir, relative_path)
|
|
56
|
+
destination_path = File.join(".controlplane", relative_path)
|
|
57
|
+
empty_directory(File.dirname(destination_path), verbose: false)
|
|
58
|
+
copy_file(
|
|
59
|
+
File.join(root_dir, relative_path),
|
|
60
|
+
destination_path,
|
|
61
|
+
force: true,
|
|
62
|
+
verbose: ENV.fetch("HIDE_COMMAND_OUTPUT", nil) != "true"
|
|
63
|
+
)
|
|
64
|
+
destination_path
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def base_template_files
|
|
68
|
+
COMMON_TEMPLATE_FILES + (sqlite_project? ? [] : POSTGRES_TEMPLATE_FILES)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def template_variables
|
|
72
|
+
{
|
|
73
|
+
"__APP_PREFIX__" => inferred_app_prefix,
|
|
74
|
+
"__RUBY_VERSION__" => inferred_ruby_version,
|
|
75
|
+
"__ASSET_PRECOMPILE_HOOK_RUN__" => asset_precompile_hook_run
|
|
76
|
+
}
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def inferred_app_prefix
|
|
80
|
+
RepoIntrospection.inferred_app_prefix(Dir.pwd)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def inferred_ruby_version
|
|
84
|
+
RepoIntrospection.inferred_ruby_version_string(Dir.pwd) || DEFAULT_RUBY_VERSION
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def sqlite_project?
|
|
88
|
+
return @sqlite_project if instance_variable_defined?(:@sqlite_project)
|
|
89
|
+
|
|
90
|
+
@sqlite_project = sqlite_database_in_production?
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def asset_precompile_hook_run
|
|
94
|
+
command = normalized_asset_precompile_hook_command
|
|
95
|
+
return "" unless command
|
|
96
|
+
return "" unless single_line_asset_precompile_hook?(command)
|
|
97
|
+
|
|
98
|
+
"RUN #{command}\n\n"
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def single_line_asset_precompile_hook?(command)
|
|
102
|
+
return true unless command.match?(/[\r\n]/)
|
|
103
|
+
|
|
104
|
+
Shell.warn("Skipping asset precompile hook: value must be a single line: #{command.inspect}")
|
|
105
|
+
false
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def sqlite_database_in_production?
|
|
109
|
+
RepoIntrospection.sqlite_database_in_production?(Dir.pwd)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def normalized_asset_precompile_hook_command
|
|
113
|
+
command = shakapacker_precompile_hook || react_on_rails_auto_bundle_hook
|
|
114
|
+
return unless command
|
|
115
|
+
|
|
116
|
+
command.start_with?("rake ") ? "bundle exec #{command}" : command
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def shakapacker_precompile_hook
|
|
120
|
+
return unless File.file?("config/shakapacker.yml")
|
|
121
|
+
|
|
122
|
+
# Parse rather than regex-match: Shakapacker emits an environment-keyed YAML file
|
|
123
|
+
# (the hook usually lives under `default:` or `production:`), and folded or quoted
|
|
124
|
+
# multi-line values would also defeat a single-line regex.
|
|
125
|
+
config = YAML.safe_load(File.read("config/shakapacker.yml"), aliases: true)
|
|
126
|
+
hook = extract_shakapacker_precompile_hook(config)
|
|
127
|
+
hook unless hook.nil? || hook.empty?
|
|
128
|
+
rescue Psych::SyntaxError
|
|
129
|
+
nil
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
SHAKAPACKER_HOOK_SCOPES = %w[production default].freeze
|
|
133
|
+
private_constant :SHAKAPACKER_HOOK_SCOPES
|
|
134
|
+
|
|
135
|
+
def extract_shakapacker_precompile_hook(config)
|
|
136
|
+
return nil unless config.is_a?(Hash)
|
|
137
|
+
|
|
138
|
+
scoped = SHAKAPACKER_HOOK_SCOPES.filter_map do |key|
|
|
139
|
+
section = config[key]
|
|
140
|
+
section["precompile_hook"] if section.is_a?(Hash) && section["precompile_hook"].is_a?(String)
|
|
141
|
+
end.first
|
|
142
|
+
scoped || (config["precompile_hook"] if config["precompile_hook"].is_a?(String))
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def react_on_rails_auto_bundle_hook
|
|
146
|
+
return unless react_on_rails_auto_load_bundle?
|
|
147
|
+
|
|
148
|
+
"bundle exec rake react_on_rails:generate_packs"
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def react_on_rails_auto_load_bundle?
|
|
152
|
+
return false unless File.file?("config/initializers/react_on_rails.rb")
|
|
153
|
+
|
|
154
|
+
File.readlines("config/initializers/react_on_rails.rb")
|
|
155
|
+
.reject { |line| line.lstrip.start_with?("#") }
|
|
156
|
+
.any? { |line| line.match?(/config\.auto_load_bundle\s*=\s*true\b/) }
|
|
157
|
+
end
|
|
14
158
|
end
|
|
15
159
|
|
|
16
160
|
class Generate < Base
|
|
17
161
|
NAME = "generate"
|
|
18
162
|
DESCRIPTION = "Creates base Control Plane config and template files"
|
|
19
163
|
LONG_DESCRIPTION = <<~DESC
|
|
20
|
-
Creates base Control Plane config and template files
|
|
164
|
+
Creates base Control Plane config and template files for a Rails project:
|
|
165
|
+
- infers the app prefix from the current directory and wires staging, review, and production entries
|
|
166
|
+
- infers the Docker base Ruby version from `.ruby-version`, `.tool-versions`, or the app's `Gemfile`
|
|
167
|
+
- preserves repo-defined asset precompile hooks, including React on Rails auto bundle generation
|
|
168
|
+
- detects SQLite in `config/database.yml` and generates persistent `db` and `storage` volume templates instead of the default Postgres workload
|
|
21
169
|
DESC
|
|
22
170
|
EXAMPLES = <<~EX
|
|
23
171
|
```sh
|
|
24
|
-
# Creates .controlplane directory with Control Plane config and
|
|
172
|
+
# Creates .controlplane directory with Control Plane config and starter templates
|
|
25
173
|
cpflow generate
|
|
26
174
|
```
|
|
27
175
|
EX
|
|
28
176
|
WITH_INFO_HEADER = false
|
|
29
177
|
VALIDATIONS = [].freeze
|
|
178
|
+
REQUIRES_STARTUP_CHECKS = false
|
|
30
179
|
|
|
31
180
|
def call
|
|
32
181
|
if controlplane_directory_exists?
|