cpflow 5.0.0.rc.1 → 5.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.
Files changed (65) hide show
  1. checksums.yaml +4 -4
  2. data/{lib/github_flow_templates/.github → .github}/actions/cpflow-delete-control-plane-app/action.yml +5 -0
  3. data/{lib/github_flow_templates/.github → .github}/actions/cpflow-detect-release-phase/action.yml +7 -0
  4. data/.github/actions/cpflow-setup-environment/action.yml +161 -0
  5. data/.github/workflows/cpflow-cleanup-stale-review-apps.yml +69 -0
  6. data/.github/workflows/cpflow-delete-review-app.yml +182 -0
  7. data/.github/workflows/cpflow-deploy-review-app.yml +507 -0
  8. data/.github/workflows/cpflow-deploy-staging.yml +168 -0
  9. data/.github/workflows/cpflow-help-command.yml +78 -0
  10. data/.github/workflows/cpflow-promote-staging-to-production.yml +510 -0
  11. data/.github/workflows/cpflow-review-app-help.yml +51 -0
  12. data/.github/workflows/rspec-shared.yml +3 -0
  13. data/.github/workflows/trigger-docs-site.yml +90 -0
  14. data/.rubocop.yml +14 -1
  15. data/CHANGELOG.md +43 -1
  16. data/CONTRIBUTING.md +27 -0
  17. data/Gemfile.lock +2 -2
  18. data/README.md +7 -3
  19. data/cpflow.gemspec +1 -1
  20. data/docs/ai-github-flow-prompt.md +1 -1
  21. data/docs/assets/cpflow-deploying.svg +46 -0
  22. data/docs/ci-automation.md +111 -8
  23. data/docs/commands.md +11 -5
  24. data/docs/thruster.md +149 -0
  25. data/docs/troubleshooting.md +8 -0
  26. data/lib/command/apply_template.rb +6 -2
  27. data/lib/command/base.rb +1 -0
  28. data/lib/command/cleanup_stale_apps.rb +53 -14
  29. data/lib/command/delete.rb +3 -1
  30. data/lib/command/deploy_image.rb +5 -2
  31. data/lib/command/generate.rb +7 -3
  32. data/lib/command/generate_github_actions.rb +21 -9
  33. data/lib/command/generator_helpers.rb +5 -1
  34. data/lib/command/info.rb +3 -1
  35. data/lib/command/run.rb +16 -1
  36. data/lib/command/test.rb +1 -3
  37. data/lib/core/controlplane.rb +17 -6
  38. data/lib/core/controlplane_api.rb +3 -1
  39. data/lib/core/controlplane_api_direct.rb +50 -27
  40. data/lib/core/doctor_service.rb +2 -2
  41. data/lib/core/github_flow_readiness_service.rb +26 -2
  42. data/lib/core/repo_introspection.rb +41 -3
  43. data/lib/core/shell.rb +3 -1
  44. data/lib/core/terraform_config/policy.rb +1 -1
  45. data/lib/cpflow/version.rb +1 -1
  46. data/lib/cpflow.rb +27 -13
  47. data/lib/generator_templates/templates/rails.yml +4 -0
  48. data/lib/generator_templates_sqlite/templates/rails.yml +4 -0
  49. data/lib/github_flow_templates/.github/cpflow-help.md +30 -1
  50. data/lib/github_flow_templates/.github/workflows/cpflow-cleanup-stale-review-apps.yml +10 -44
  51. data/lib/github_flow_templates/.github/workflows/cpflow-delete-review-app.yml +15 -114
  52. data/lib/github_flow_templates/.github/workflows/cpflow-deploy-review-app.yml +10 -413
  53. data/lib/github_flow_templates/.github/workflows/cpflow-deploy-staging.yml +12 -123
  54. data/lib/github_flow_templates/.github/workflows/cpflow-help-command.yml +10 -33
  55. data/lib/github_flow_templates/.github/workflows/cpflow-promote-staging-to-production.yml +13 -475
  56. data/lib/github_flow_templates/.github/workflows/cpflow-review-app-help.yml +12 -30
  57. data/lib/github_flow_templates/bin/pin-cpflow-github-ref +72 -0
  58. data/lib/github_flow_templates/bin/test-cpflow-github-flow +89 -0
  59. data/rakelib/create_release.rake +4 -4
  60. metadata +26 -17
  61. data/lib/github_flow_templates/.github/actions/cpflow-setup-environment/action.yml +0 -98
  62. /data/{lib/github_flow_templates/.github → .github}/actions/cpflow-build-docker-image/action.yml +0 -0
  63. /data/{lib/github_flow_templates/.github → .github}/actions/cpflow-delete-control-plane-app/delete-app.sh +0 -0
  64. /data/{lib/github_flow_templates/.github → .github}/actions/cpflow-validate-config/action.yml +0 -0
  65. /data/{lib/github_flow_templates/.github → .github}/actions/cpflow-wait-for-health/action.yml +0 -0
data/docs/thruster.md ADDED
@@ -0,0 +1,149 @@
1
+ # Thruster HTTP/2 Proxy on Control Plane
2
+
3
+ [Thruster](https://github.com/basecamp/thruster) is Basecamp's zero-config HTTP/2 proxy for
4
+ Ruby web applications. It provides HTTP/2 support, asset caching, compression, and early
5
+ hints. Running it on Control Plane requires settings that differ from a standalone (e.g.,
6
+ VPS) deployment, and getting them wrong produces a confusing `502 Bad Gateway` with a
7
+ "protocol error" message.
8
+
9
+ This page documents the configuration that works.
10
+
11
+ ## TL;DR
12
+
13
+ - Workload port: `protocol: http` (not `http2`).
14
+ - Dockerfile `CMD` runs Thruster: `CMD ["bundle", "exec", "thrust", "bin/rails", "server"]`.
15
+ - End users still get HTTP/2; Control Plane's load balancer handles TLS termination.
16
+
17
+ ## Why `protocol: http` and not `http2`
18
+
19
+ ### Standalone Thruster (e.g., VPS)
20
+
21
+ ```
22
+ User → HTTPS/HTTP2 → Thruster → HTTP/1.1 → Rails
23
+ (Thruster handles TLS + HTTP/2)
24
+ ```
25
+
26
+ ### Control Plane + Thruster
27
+
28
+ ```
29
+ User → HTTPS/HTTP2 → Control Plane LB → HTTP/1.1 → Thruster → HTTP/1.1 → Rails
30
+ (LB handles TLS) (protocol: http) (caching, compression)
31
+ ```
32
+
33
+ In the diagram above, `protocol: http` is the workload port setting that governs the
34
+ LB→Thruster hop; `caching, compression` describes what Thruster contributes in this
35
+ path.
36
+
37
+ On Control Plane, the load balancer terminates TLS and speaks HTTP/2 to the browser —
38
+ so Thruster never sees TLS or HTTP/2 on its incoming side. This is the opposite of a
39
+ standalone (VPS) deployment, where Thruster itself handles TLS and HTTP/2. Setting
40
+ `protocol: http2` on the workload port tells the load balancer to expect HTTP/2 from
41
+ the container, which Thruster does not emit on that hop, and protocol negotiation fails
42
+ with `502 Bad Gateway`.
43
+
44
+ Even with `protocol: http`, end users still get:
45
+
46
+ - HTTP/2 to the browser (from the Control Plane load balancer)
47
+ - HTTP/2 multiplexing (from the Control Plane load balancer)
48
+ - Asset caching and compression (from Thruster)
49
+ - Efficient static file serving (from Thruster)
50
+ - Early Hints (103) from Thruster (reaches the browser only if the load balancer forwards 103 responses)
51
+
52
+ ## Workload template
53
+
54
+ In `.controlplane/templates/rails.yml`:
55
+
56
+ ```yaml
57
+ ports:
58
+ - number: 3000
59
+ protocol: http # Required when fronting Rails with Thruster. Do not use http2.
60
+ ```
61
+
62
+ ## Dockerfile
63
+
64
+ The container's `CMD` must launch Thruster. On Control Plane/Kubernetes the Dockerfile
65
+ `CMD` determines container startup — the `Procfile` is not used (unlike Heroku).
66
+
67
+ ```dockerfile
68
+ # .controlplane/Dockerfile
69
+ CMD ["bundle", "exec", "thrust", "bin/rails", "server"]
70
+ ```
71
+
72
+ ## Troubleshooting
73
+
74
+ ### `502 Bad Gateway` with "protocol error"
75
+
76
+ The workload port is set to `protocol: http2`. Change it to `protocol: http` in
77
+ `rails.yml`, then push the workload spec.
78
+
79
+ `cpflow apply-template` rewrites the workload from the template. If you have tuned
80
+ CPU, memory, autoscaling, firewall, or other workload fields directly in the
81
+ Control Plane UI (or via `cpln`) without mirroring those changes back into
82
+ `rails.yml`, those edits will be reset. Either reconcile `rails.yml` with the live
83
+ spec first, or change the port field in place:
84
+
85
+ ```sh
86
+ # Option A — apply the full template (resets any drift between rails.yml and the live spec):
87
+ cpflow apply-template rails -a <app>
88
+
89
+ # Option B — edit only the port protocol in place (preserves UI-tuned fields):
90
+ cpln workload edit <workload> --gvc <gvc> --org <org>
91
+ # change spec.containers[].ports[].protocol from http2 to http
92
+ ```
93
+
94
+ Inspect the current spec before choosing if you're unsure what would change:
95
+
96
+ ```sh
97
+ cpln workload get <workload> --gvc <gvc> --org <org> -o yaml
98
+ ```
99
+
100
+ Note: `cpflow deploy-image` alone is not sufficient — it only updates the container
101
+ image reference and does not modify the workload's port configuration. Run it
102
+ *after* the protocol change has been applied if you also want to ship a new image.
103
+
104
+ The remaining troubleshooting commands use the raw Control Plane CLI (`cpln`) rather
105
+ than `cpflow`; see
106
+ [the Control Plane CLI quickstart](https://shakadocs.controlplane.com/quickstart/quick-start-3-cli#getting-started-with-the-cli)
107
+ if you don't already have it installed.
108
+
109
+ ### Verify Thruster is the process running as PID 1
110
+
111
+ `/proc/1/cmdline` stores arguments NUL-separated with no trailing newline, so pipe it
112
+ through `tr` to make the output readable:
113
+
114
+ ```sh
115
+ cpln workload exec <workload> --gvc <gvc> --org <org> --location <location> \
116
+ -- sh -c "tr '\0' ' ' < /proc/1/cmdline && echo"
117
+ ```
118
+
119
+ You should see `thrust` in the output. If you see `rails` or `puma` directly, the
120
+ Dockerfile `CMD` is not invoking Thruster.
121
+
122
+ ### Test HTTP connectivity through Thruster
123
+
124
+ This hits Thruster on port 3000 — not Rails directly. A `200 OK` confirms the
125
+ Thruster → Rails path within the container is healthy.
126
+
127
+ ```sh
128
+ cpln workload exec <workload> --gvc <gvc> --org <org> --location <location> \
129
+ -- curl -s localhost:3000
130
+ ```
131
+
132
+ ### Inspect the workload port configuration
133
+
134
+ ```sh
135
+ cpln workload get <workload> --gvc <gvc> --org <org> -o json | jq '.spec.containers[].ports'
136
+ ```
137
+
138
+ ## Reference implementation
139
+
140
+ A working setup lives in the
141
+ [react-webpack-rails-tutorial](https://github.com/shakacode/react-webpack-rails-tutorial)
142
+ repository — see
143
+ [`.controlplane/templates/rails.yml`](https://github.com/shakacode/react-webpack-rails-tutorial/blob/master/.controlplane/templates/rails.yml)
144
+ on the `master` branch.
145
+
146
+ ## Further reading
147
+
148
+ - [Thruster on GitHub](https://github.com/basecamp/thruster)
149
+ - [DHH on Rails 8 with Thruster](https://world.hey.com/dhh/rails-8-with-thruster-by-default-c953f5e3)
@@ -4,3 +4,11 @@
4
4
  ## App Web Page Shows `upstream request timeout`
5
5
 
6
6
  If you get a blank screen showing the message `upstream request timeout` on your app after running `cpflow open -a my-app-name`, check out the application logs. Your image has been promoted and your app crashing when starting.
7
+
8
+ ## `502 Bad Gateway` with "protocol error" (Rails + Thruster)
9
+
10
+ If a Rails app fronted by [Thruster](https://github.com/basecamp/thruster) returns `502 Bad Gateway` with a "protocol error" message, the workload port is likely set to `protocol: http2`.
11
+
12
+ Thruster accepts HTTP/1.1 connections from the load balancer, so the workload port must be `protocol: http`. Control Plane's load balancer still serves HTTP/2 to end users.
13
+
14
+ See [Thruster HTTP/2 Proxy on Control Plane](./thruster.md) for the full configuration and debugging commands.
@@ -91,6 +91,10 @@ module Command
91
91
  raise "Can't find templates above, please create them."
92
92
  end
93
93
 
94
+ # The confirm_* helpers below prompt the user and mutate state (@asked_for_confirmation,
95
+ # report_skipped). `confirm_app` and `confirm_workload` return booleans but have side
96
+ # effects, so their names intentionally lack `?` (suppressed via the inline disables
97
+ # below). `confirm_apply` doesn't trip the cop and needs no annotation.
94
98
  def confirm_apply(message)
95
99
  return true if config.options[:yes]
96
100
 
@@ -98,7 +102,7 @@ module Command
98
102
  Shell.confirm(message)
99
103
  end
100
104
 
101
- def confirm_app(template)
105
+ def confirm_app(template) # rubocop:disable Naming/PredicateMethod
102
106
  app = cp.fetch_gvc(template["name"])
103
107
  return true unless app
104
108
 
@@ -109,7 +113,7 @@ module Command
109
113
  false
110
114
  end
111
115
 
112
- def confirm_workload(template)
116
+ def confirm_workload(template) # rubocop:disable Naming/PredicateMethod
113
117
  workload = cp.fetch_workload(template["name"])
114
118
  return true unless workload
115
119
 
data/lib/command/base.rb CHANGED
@@ -502,6 +502,7 @@ module Command
502
502
  }
503
503
  }
504
504
  end
505
+
505
506
  # rubocop:enable Metrics/MethodLength
506
507
 
507
508
  def self.all_options
@@ -2,20 +2,44 @@
2
2
 
3
3
  module Command
4
4
  class CleanupStaleApps < Base
5
+ CLEANUP_MODE_OPTION = {
6
+ name: :mode,
7
+ params: {
8
+ banner: "MODE",
9
+ desc: "Action to take on stale apps: `delete` (default) or `stop`",
10
+ type: :string,
11
+ required: false,
12
+ default: "delete",
13
+ valid_regex: /^(delete|stop)$/
14
+ }
15
+ }.freeze
16
+
5
17
  NAME = "cleanup-stale-apps"
6
18
  OPTIONS = [
7
19
  app_option(required: true),
8
- skip_confirm_option
20
+ skip_confirm_option,
21
+ CLEANUP_MODE_OPTION
9
22
  ].freeze
10
- DESCRIPTION = "Deletes the whole app (GVC with all workloads, all volumesets and all images) for all stale apps"
23
+ DESCRIPTION = "Deletes or stops stale apps based on the latest image's creation date"
11
24
  LONG_DESCRIPTION = <<~DESC
12
- - Deletes the whole app (GVC with all workloads, all volumesets and all images) for all stale apps
13
- - Also unbinds the app from the secrets policy, as long as both the identity and the policy exist (and are bound)
14
- - Stale apps are identified based on the creation date of the latest image
25
+ - Acts on stale apps based on the creation date of the latest image, or the GVC if no images exist
26
+ - With `--mode=delete` (default): deletes the whole app (GVC with all workloads, all volumesets and all images), and unbinds the app from the secrets policy as long as both the identity and the policy exist (and are bound)
27
+ - With `--mode=stop`: suspends all workloads via `cpflow ps:stop` no GVC, volumeset, or image is removed; resume with `cpflow ps:start`
28
+ - `--mode=stop` only suspends workloads listed in `app_workloads` + `additional_workloads`; workloads present in the live GVC but missing from the config are skipped silently
29
+ - `--mode=stop` returns once each workload is marked suspended; it does not wait for the workload to reach a not-ready state
15
30
  - Specify the amount of days after an app should be considered stale through `stale_app_image_deployed_days` in the `.controlplane/controlplane.yml` file
16
- - If `match_if_app_name_starts_with` is `true` in the `.controlplane/controlplane.yml` file, it will delete all stale apps that start with the name
31
+ - If `match_if_app_name_starts_with` is `true` in the `.controlplane/controlplane.yml` file, it will act on all stale apps that start with the name
17
32
  - Will ask for explicit user confirmation
18
33
  DESC
34
+ EXAMPLES = <<~EX
35
+ ```sh
36
+ # Deletes stale apps (default).
37
+ cpflow cleanup-stale-apps -a $APP_NAME
38
+
39
+ # Stops stale apps instead of deleting them; resume with `cpflow ps:start`.
40
+ cpflow cleanup-stale-apps -a $APP_NAME --mode=stop
41
+ ```
42
+ EX
19
43
 
20
44
  def call # rubocop:disable Metrics/MethodLength
21
45
  return progress.puts("No stale apps found.") if stale_apps.empty?
@@ -25,11 +49,11 @@ module Command
25
49
  progress.puts(" - #{app[:name]} (#{Shell.color(app[:date].to_s, :red)})")
26
50
  end
27
51
 
28
- return unless confirm_delete
52
+ return unless confirm_action
29
53
 
30
54
  progress.puts
31
55
  stale_apps.each do |app|
32
- delete_app(app[:name])
56
+ process_app(app[:name])
33
57
  progress.puts
34
58
  end
35
59
  end
@@ -50,9 +74,11 @@ module Command
50
74
 
51
75
  images = cp.query_images(app_name)["items"].select { |item| item["name"].start_with?("#{app_name}:") }
52
76
  image = cp.latest_image_from(images, app_name: app_name, name_only: false)
53
- next unless image
54
77
 
55
- created_date = DateTime.parse(image["created"])
78
+ created_at = image ? image["created"] : gvc["created"]
79
+ next unless created_at
80
+
81
+ created_date = DateTime.parse(created_at)
56
82
  diff_in_days = (now - created_date).to_i
57
83
  next unless diff_in_days >= stale_app_image_deployed_days
58
84
 
@@ -66,14 +92,27 @@ module Command
66
92
  end
67
93
  end
68
94
 
69
- def confirm_delete
95
+ def confirm_action
70
96
  return true if config.options[:yes]
71
97
 
72
- Shell.confirm("\nAre you sure you want to delete these #{stale_apps.length} apps?")
98
+ Shell.confirm("\nAre you sure you want to #{action_description} these #{stale_apps.length} apps?")
99
+ end
100
+
101
+ def process_app(app)
102
+ if mode == "stop"
103
+ progress.puts("Stopping app '#{app}'")
104
+ run_cpflow_command("ps:stop", "-a", app)
105
+ else
106
+ run_cpflow_command("delete", "-a", app, "--yes")
107
+ end
108
+ end
109
+
110
+ def action_description
111
+ mode == "stop" ? "suspend all workloads in" : "delete"
73
112
  end
74
113
 
75
- def delete_app(app)
76
- run_cpflow_command("delete", "-a", app, "--yes")
114
+ def mode
115
+ @mode ||= config.options[:mode]
77
116
  end
78
117
  end
79
118
  end
@@ -81,7 +81,9 @@ module Command
81
81
  progress.puts("#{Shell.color(message, :red)}\n#{images_list}\n\n")
82
82
  end
83
83
 
84
- def confirm_delete(item)
84
+ # Prompts the user and writes to progress on confirm — returns boolean but
85
+ # has side effects, so the method name intentionally lacks `?`.
86
+ def confirm_delete(item) # rubocop:disable Naming/PredicateMethod
85
87
  return true if config.options[:yes]
86
88
 
87
89
  confirmed = Shell.confirm("Are you sure you want to delete '#{item}'?")
@@ -31,10 +31,13 @@ module Command
31
31
  next unless container["image"].match?(%r{^/org/#{config.org}/image/#{config.app}[:@]})
32
32
 
33
33
  container_name = container["name"]
34
- step("Deploying image '#{image}' for workload '#{container_name}'") do
34
+ step("Deploying image '#{image}' for workload '#{workload}'") do
35
35
  cp.workload_set_image_ref(workload, container: container_name, image: image)
36
- deployed_endpoints[container_name] = endpoint_for_workload(workload_data)
36
+ deployed_endpoints[workload] = endpoint_for_workload(workload_data)
37
37
  end
38
+ # Deploy the first matching app-image container per workload; CPLN workloads
39
+ # are expected to have a single container that runs the app image.
40
+ break
38
41
  end
39
42
  end
40
43
 
@@ -93,9 +93,13 @@ module Command
93
93
  def asset_precompile_hook_run
94
94
  command = normalized_asset_precompile_hook_command
95
95
  return "" unless command
96
- return "" unless single_line_asset_precompile_hook?(command)
97
96
 
98
- "RUN #{command}\n\n"
97
+ # Folded YAML scalars carry a trailing newline even when they hold one command.
98
+ stripped = command.strip
99
+ return "" if stripped.empty?
100
+ return "" unless single_line_asset_precompile_hook?(stripped)
101
+
102
+ "RUN #{stripped}\n\n"
99
103
  end
100
104
 
101
105
  def single_line_asset_precompile_hook?(command)
@@ -122,7 +126,7 @@ module Command
122
126
  # Parse rather than regex-match: Shakapacker emits an environment-keyed YAML file
123
127
  # (the hook usually lives under `default:` or `production:`), and folded or quoted
124
128
  # multi-line values would also defeat a single-line regex.
125
- config = YAML.safe_load(File.read("config/shakapacker.yml"), aliases: true)
129
+ config = YAML.safe_load_file("config/shakapacker.yml", aliases: true)
126
130
  hook = extract_shakapacker_precompile_hook(config)
127
131
  hook unless hook.nil? || hook.empty?
128
132
  rescue Psych::SyntaxError
@@ -14,8 +14,9 @@ module Command
14
14
 
15
15
  def copy_files
16
16
  relative_paths = generated_files
17
+ replacements = template_variables
17
18
  copy_template_files(relative_paths)
18
- substitute_template_variables(relative_paths)
19
+ substitute_template_variables(relative_paths, replacements)
19
20
  make_shell_scripts_executable(relative_paths)
20
21
  end
21
22
 
@@ -39,9 +40,9 @@ module Command
39
40
 
40
41
  def template_variables
41
42
  {
42
- "__CPFLOW_VERSION__" => ::Cpflow::VERSION,
43
+ "__CPFLOW_GITHUB_ACTIONS_REF__" => cpflow_github_actions_ref,
43
44
  "__STAGING_BRANCH_FILTER__" => staging_branch_filter,
44
- "__STAGING_APP_BRANCH_EXPRESSION__" => staging_app_branch_expression
45
+ "__STAGING_BRANCH_DEFAULT__" => staging_branch_default
45
46
  }
46
47
  end
47
48
 
@@ -58,12 +59,23 @@ module Command
58
59
  branches.map(&:to_json).join(", ")
59
60
  end
60
61
 
61
- def staging_app_branch_expression
62
- return "${{ vars.STAGING_APP_BRANCH }}" unless staging_branch
62
+ def staging_branch_default
63
+ staging_branch.to_s
64
+ end
65
+
66
+ def cpflow_github_actions_ref
67
+ ref = ENV.fetch("CPFLOW_GITHUB_ACTIONS_REF", default_cpflow_github_actions_ref).to_s.strip
68
+ return default_cpflow_github_actions_ref if ref.empty?
69
+
70
+ if ref.match?(/[[:space:]]/)
71
+ Shell.abort("Invalid CPFLOW_GITHUB_ACTIONS_REF: #{ref.inspect}. Refs cannot contain whitespace.")
72
+ end
73
+
74
+ ref
75
+ end
63
76
 
64
- # `valid_staging_branch?` excludes quotes, so this single-quoted GitHub
65
- # expression literal cannot be broken by the generated branch name.
66
- "${{ vars.STAGING_APP_BRANCH || '#{staging_branch}' }}"
77
+ def default_cpflow_github_actions_ref
78
+ "v#{::Cpflow::VERSION}"
67
79
  end
68
80
  end
69
81
 
@@ -84,7 +96,7 @@ module Command
84
96
  DESC
85
97
  EXAMPLES = <<~EX
86
98
  ```sh
87
- # Creates .github/actions and .github/workflows files for the Control Plane flow
99
+ # Creates thin .github/workflows wrappers for the Control Plane flow
88
100
  cpflow generate-github-actions
89
101
 
90
102
  # Creates the flow with staging deploys triggered from develop
@@ -22,10 +22,14 @@ module Command
22
22
 
23
23
  def make_shell_scripts_executable(file_paths)
24
24
  Array(file_paths).each do |path|
25
- next unless File.file?(path) && File.extname(path) == ".sh"
25
+ next unless File.file?(path) && executable_script?(path)
26
26
 
27
27
  FileUtils.chmod(0o755, path)
28
28
  end
29
29
  end
30
+
31
+ def executable_script?(path)
32
+ File.extname(path) == ".sh" || File.open(path, &:gets).to_s.start_with?("#!")
33
+ end
30
34
  end
31
35
  end
data/lib/command/info.rb CHANGED
@@ -106,7 +106,9 @@ module Command
106
106
  @app_workloads.keys.find { |app_name| config.app_matches?(app_name, app, config.apps[app.to_sym]) }
107
107
  end
108
108
 
109
- def check_any_app_starts_with(app)
109
+ # Returns boolean but mutates @missing_apps_starting_with and writes to stdout,
110
+ # so the method name intentionally lacks `?`.
111
+ def check_any_app_starts_with(app) # rubocop:disable Naming/PredicateMethod
110
112
  if any_app_starts_with?(app)
111
113
  false
112
114
  else
data/lib/command/run.rb CHANGED
@@ -265,7 +265,22 @@ module Command
265
265
 
266
266
  def run_interactive
267
267
  progress.puts("Connecting to replica '#{replica}'...\n\n")
268
- cp.workload_exec(runner_workload, replica, location: location, container: container, command: command)
268
+ # workload_exec returns false on non-zero exit, nil when signal-killed (e.g. Ctrl-C).
269
+ # Both fall through to the cleanup hint instead of the generic "non-zero status" abort.
270
+ success = cp.workload_exec(runner_workload, replica, location: location, container: container, command: command)
271
+ return if success
272
+
273
+ print_interactive_cleanup_hint
274
+ exit(success.nil? ? ExitCode::INTERRUPT : ExitCode::ERROR_DEFAULT)
275
+ end
276
+
277
+ def print_interactive_cleanup_hint
278
+ progress.puts(Shell.color(
279
+ "\nThe interactive session ended with a non-zero exit or signal from the upstream CLI. " \
280
+ "If the runner workload is still running, stop it with:\n " \
281
+ "cpflow ps:stop #{app_workload_replica_args.join(' ')} --location #{location}",
282
+ :yellow
283
+ ))
269
284
  end
270
285
 
271
286
  def run_non_interactive
data/lib/command/test.rb CHANGED
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "debug"
4
-
5
3
  module Command
6
4
  class Test < Base
7
5
  NAME = "test"
@@ -15,7 +13,7 @@ module Command
15
13
 
16
14
  def call
17
15
  # Modify this method to trigger the code you want to test.
18
- # You can use `debugger` to debug.
16
+ # Add `require "debug"` locally if you want to use `debugger`.
19
17
  # You can use `run_cpflow_command` to simulate a command
20
18
  # (e.g., `run_cpflow_command("deploy-image", "-a", "my-app-name")`).
21
19
  end
@@ -99,7 +99,7 @@ class Controlplane # rubocop:disable Metrics/ClassLength
99
99
  cmd << "--progress=plain" if ControlplaneApiDirect.trace
100
100
 
101
101
  cmd.concat(docker_args)
102
- build_args.each { |build_arg| cmd.concat(["--build-arg", build_arg]) }
102
+ build_args.each { |build_arg| cmd.push("--build-arg", build_arg) }
103
103
  cmd << docker_context
104
104
 
105
105
  perform!(Shellwords.join(cmd))
@@ -282,7 +282,7 @@ class Controlplane # rubocop:disable Metrics/ClassLength
282
282
  cmd = "cpln workload exec #{workload} #{gvc_org} --replica #{replica} --location #{location} -it"
283
283
  cmd += " --container #{container}" if container
284
284
  cmd += " -- #{command}"
285
- perform!(cmd, output_mode: :all)
285
+ perform(cmd, output_mode: :all)
286
286
  end
287
287
 
288
288
  def start_cron_workload(workload, job_start_yaml, location:)
@@ -472,8 +472,19 @@ class Controlplane # rubocop:disable Metrics/ClassLength
472
472
  private
473
473
 
474
474
  def org_exists?
475
- items = api.list_orgs["items"]
475
+ result = api.list_orgs
476
+
477
+ # HTTP 404 from the list-orgs endpoint returns nil in ControlplaneApiDirect.
478
+ # Defer scoped-token/org problems to the command's target API call, which
479
+ # can produce a resource-specific error.
480
+ return true unless result
481
+
482
+ items = result.fetch("items", [])
476
483
  items.any? { |item| item["name"] == org }
484
+ rescue ControlplaneApiDirect::ForbiddenError => e
485
+ raise unless e.url == ControlplaneApi::LIST_ORGS_PATH
486
+
487
+ true
477
488
  end
478
489
 
479
490
  def ensure_org_exists!
@@ -519,7 +530,9 @@ class Controlplane # rubocop:disable Metrics/ClassLength
519
530
  kernel_system_with_pid_handling(cmd)
520
531
  end
521
532
 
522
- # NOTE: full analogue of Kernel.system which returns pids and saves it to child_pids for proper killing
533
+ # NOTE: full analogue of Kernel.system which returns pids and saves it to child_pids for proper killing.
534
+ # Returns true on zero exit, false on non-zero exit, nil when the process was signal-killed.
535
+ # SystemCallError (e.g. cpln binary missing) propagates — startup checks ensure this is unreachable in practice.
523
536
  def kernel_system_with_pid_handling(cmd)
524
537
  pid = Process.spawn(cmd)
525
538
  $child_pids << pid # rubocop:disable Style/GlobalVars
@@ -528,8 +541,6 @@ class Controlplane # rubocop:disable Metrics/ClassLength
528
541
  $child_pids.delete(pid) # rubocop:disable Style/GlobalVars
529
542
 
530
543
  status.exited? ? status.success? : nil
531
- rescue SystemCallError
532
- nil
533
544
  end
534
545
 
535
546
  def perform!(cmd, output_mode: nil, sensitive_data_pattern: nil)
@@ -1,8 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class ControlplaneApi # rubocop:disable Metrics/ClassLength
4
+ LIST_ORGS_PATH = "/org"
5
+
4
6
  def list_orgs
5
- api_json("/org", method: :get)
7
+ api_json(LIST_ORGS_PATH, method: :get)
6
8
  end
7
9
 
8
10
  def gvc_list(org:)