cpflow 4.2.0 → 5.0.0.rc.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/.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 +6 -0
- data/Gemfile +8 -7
- data/Gemfile.lock +92 -72
- data/README.md +43 -15
- 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 +65 -4
- data/docs/releasing.md +153 -0
- data/lib/command/ai_github_flow_prompt.rb +47 -0
- data/lib/command/base.rb +14 -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/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/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/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 +47 -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 +53 -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 +46 -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 +52 -14
data/lib/core/controlplane.rb
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "shellwords"
|
|
4
|
+
|
|
3
5
|
class Controlplane # rubocop:disable Metrics/ClassLength
|
|
4
6
|
attr_reader :config, :api, :gvc, :org
|
|
5
7
|
|
|
@@ -93,14 +95,14 @@ class Controlplane # rubocop:disable Metrics/ClassLength
|
|
|
93
95
|
def image_build(image, dockerfile:, docker_context:, docker_args: [], build_args: [])
|
|
94
96
|
# https://docs.controlplane.com/guides/push-image#step-2
|
|
95
97
|
# Might need to use `docker buildx build` if compatiblitity issues arise
|
|
96
|
-
cmd = "docker build --platform=linux/amd64 -t
|
|
97
|
-
cmd
|
|
98
|
+
cmd = ["docker", "build", "--platform=linux/amd64", "-t", image, "-f", dockerfile]
|
|
99
|
+
cmd << "--progress=plain" if ControlplaneApiDirect.trace
|
|
98
100
|
|
|
99
|
-
cmd
|
|
100
|
-
build_args.each { |build_arg| cmd
|
|
101
|
-
cmd
|
|
101
|
+
cmd.concat(docker_args)
|
|
102
|
+
build_args.each { |build_arg| cmd.concat(["--build-arg", build_arg]) }
|
|
103
|
+
cmd << docker_context
|
|
102
104
|
|
|
103
|
-
perform!(cmd)
|
|
105
|
+
perform!(Shellwords.join(cmd))
|
|
104
106
|
end
|
|
105
107
|
|
|
106
108
|
def fetch_image_details(image)
|
|
@@ -321,7 +323,7 @@ class Controlplane # rubocop:disable Metrics/ClassLength
|
|
|
321
323
|
# domain
|
|
322
324
|
|
|
323
325
|
def find_domain_route(data)
|
|
324
|
-
port = data["spec"]["ports"].find { |current_port|
|
|
326
|
+
port = data["spec"]["ports"].find { |current_port| [80, 443].include?(current_port["number"]) }
|
|
325
327
|
return nil if port.nil? || port["routes"].nil?
|
|
326
328
|
|
|
327
329
|
route = port["routes"].find { |current_route| current_route["prefix"] == "/" }
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
class RedactedDebugOutput
|
|
4
4
|
SAFE_HEADERS = %w[Content-Type Content-Length Accept Host Date Cache-Control Connection].freeze
|
|
5
|
-
HEADER_REGEX = /^([A-Za-z
|
|
5
|
+
HEADER_REGEX = /^([A-Za-z-]+): (.+)$/
|
|
6
6
|
|
|
7
7
|
def <<(msg)
|
|
8
8
|
$stdout << redact(msg)
|
|
@@ -37,7 +37,7 @@ class ControlplaneApiDirect
|
|
|
37
37
|
# /^[\w\-._]{1134}$/ # 'cpln profile token' format
|
|
38
38
|
# ).freeze
|
|
39
39
|
|
|
40
|
-
API_TOKEN_REGEX = /^[\w\-._]
|
|
40
|
+
API_TOKEN_REGEX = /^[\w\-._]+$/
|
|
41
41
|
API_TOKEN_EXPIRY_SECONDS = 300
|
|
42
42
|
|
|
43
43
|
class << self
|
|
@@ -111,7 +111,7 @@ class ControlplaneApiDirect
|
|
|
111
111
|
def should_refresh_api_token?
|
|
112
112
|
return false unless api_token[:comes_from_profile]
|
|
113
113
|
|
|
114
|
-
payload, = JWT.decode(api_token[:token], nil, false)
|
|
114
|
+
payload, = JWT.decode(api_token[:token], nil, false, algorithms: [])
|
|
115
115
|
difference_in_seconds = payload["exp"] - Time.now.to_i
|
|
116
116
|
|
|
117
117
|
difference_in_seconds <= API_TOKEN_EXPIRY_SECONDS
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GithubFlowReadiness
|
|
4
|
+
Result = Struct.new(:status, :message, keyword_init: true)
|
|
5
|
+
|
|
6
|
+
# Each check class accepts the host service in its initializer (so it can reach the
|
|
7
|
+
# shared lockfile parser, HTTP version cache, etc.), exposes a single `call` method,
|
|
8
|
+
# and returns either a `Result`, an array of `Result`s, or `nil` (skipped). Adding
|
|
9
|
+
# a new check is "create a class with `call` and register it in
|
|
10
|
+
# `GithubFlowReadinessService::CHECKS`".
|
|
11
|
+
module Checks
|
|
12
|
+
class Base
|
|
13
|
+
def initialize(service)
|
|
14
|
+
@service = service
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
attr_reader :service
|
|
20
|
+
|
|
21
|
+
def root_path
|
|
22
|
+
service.root_path
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def pass(message)
|
|
26
|
+
Result.new(status: :pass, message: message)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def fail_result(message)
|
|
30
|
+
Result.new(status: :fail, message: message)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def warn_result(message)
|
|
34
|
+
Result.new(status: :warn, message: message)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def info_result(message)
|
|
38
|
+
Result.new(status: :info, message: message)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def format_path_list(paths)
|
|
42
|
+
paths.map { |path| "`#{path}`" }.join(", ")
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def first_existing_path(paths)
|
|
46
|
+
paths.find { |relative_path| root_path.join(relative_path).file? }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def missing_paths_for(paths)
|
|
50
|
+
paths.reject { |relative_path| root_path.join(relative_path).file? }
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
class RailsApp < Base
|
|
55
|
+
REQUIRED_PATHS = ["Gemfile", "bin/rails", "config/application.rb", "config.ru"].freeze
|
|
56
|
+
|
|
57
|
+
def call
|
|
58
|
+
missing = missing_paths_for(REQUIRED_PATHS)
|
|
59
|
+
return pass("Rails app scaffold found (#{format_path_list(REQUIRED_PATHS)}).") if missing.empty?
|
|
60
|
+
|
|
61
|
+
fail_result("Missing Rails runtime scaffold: #{format_path_list(missing)}.")
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
class RubyVersion < Base
|
|
66
|
+
# Oldest Ruby line still receiving security backports (ruby-lang.org/en/downloads/branches/).
|
|
67
|
+
# Bump this constant when the upstream list drops the 3.3 series.
|
|
68
|
+
THRESHOLD = Gem::Version.new("3.3.0")
|
|
69
|
+
|
|
70
|
+
def call
|
|
71
|
+
version = service.inferred_ruby_version
|
|
72
|
+
return warn_result("Could not determine the app Ruby version.") unless version
|
|
73
|
+
return pass("Ruby #{version} is modern enough for rollout.") if version >= THRESHOLD
|
|
74
|
+
|
|
75
|
+
fail_result("Ruby #{version} is legacy. Upgrade the repo toolchain before adding the GitHub flow.")
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
class BundlerVersion < Base
|
|
80
|
+
THRESHOLD = Gem::Version.new("2.0.0")
|
|
81
|
+
|
|
82
|
+
def call
|
|
83
|
+
version = service.lockfile_bundler_version
|
|
84
|
+
return warn_result("Could not determine the Bundler version from `Gemfile.lock`.") unless version
|
|
85
|
+
return pass("Bundler #{version} is modern enough for rollout.") if version >= THRESHOLD
|
|
86
|
+
|
|
87
|
+
fail_result("Bundler #{version} is legacy. Upgrade the repo toolchain before adding the GitHub flow.")
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
class Dockerfile < Base
|
|
92
|
+
PATHS = ["Dockerfile", ".controlplane/Dockerfile"].freeze
|
|
93
|
+
|
|
94
|
+
def call
|
|
95
|
+
path = first_existing_path(PATHS)
|
|
96
|
+
return pass("Found production Dockerfile at `#{path}`.") if path
|
|
97
|
+
|
|
98
|
+
fail_result(
|
|
99
|
+
"No production Dockerfile found at `Dockerfile` or `.controlplane/Dockerfile`. " \
|
|
100
|
+
"Add and validate one before generating the Control Plane GitHub flow."
|
|
101
|
+
)
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
class SqliteProduction < Base
|
|
106
|
+
def call
|
|
107
|
+
return unless service.sqlite_database_in_production?
|
|
108
|
+
|
|
109
|
+
info_result(
|
|
110
|
+
"Production database config uses SQLite. `cpflow generate` will scaffold " \
|
|
111
|
+
"persistent `db` and `storage` volumes."
|
|
112
|
+
)
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
class GemSources < Base
|
|
117
|
+
def call
|
|
118
|
+
non_public = service.gem_dependencies.reject { |dep| service.public_rubygems_dependency?(dep) }
|
|
119
|
+
return pass("All direct Ruby gems resolve from public RubyGems sources.") if non_public.empty?
|
|
120
|
+
|
|
121
|
+
names = non_public.map { |dep| dep[:name] }.sort
|
|
122
|
+
warn_result(
|
|
123
|
+
"Direct Ruby dependencies using git/path or non-public gem sources need manual review: " \
|
|
124
|
+
"#{names.map { |name| "`#{name}`" }.join(', ')}."
|
|
125
|
+
)
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
class GemExactPins < Base
|
|
130
|
+
def call
|
|
131
|
+
service.exact_pin_registry_result(service.rubygems_registry_check)
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
class NpmExactPins < Base
|
|
136
|
+
def call
|
|
137
|
+
return service.package_json_parse_error_result if service.package_json_parse_error
|
|
138
|
+
|
|
139
|
+
service.exact_pin_registry_result(service.npm_registry_check)
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler"
|
|
4
|
+
require "cgi"
|
|
5
|
+
require "net/http"
|
|
6
|
+
require "yaml"
|
|
7
|
+
|
|
8
|
+
require_relative "repo_introspection"
|
|
9
|
+
require_relative "github_flow_readiness/checks"
|
|
10
|
+
|
|
11
|
+
# Drives the readiness checks that gate `cpflow generate-github-actions`. The actual
|
|
12
|
+
# checks live in `GithubFlowReadiness::Checks`; this class is the host that owns the
|
|
13
|
+
# shared lockfile parser, package.json parser, HTTP version cache, and registry-check
|
|
14
|
+
# helpers used across multiple checks. Add a new check by creating a class with `call`
|
|
15
|
+
# under `GithubFlowReadiness::Checks` and registering it in `CHECKS`.
|
|
16
|
+
class GithubFlowReadinessService # rubocop:disable Metrics/ClassLength
|
|
17
|
+
Result = GithubFlowReadiness::Result
|
|
18
|
+
RegistryCheck = Struct.new(
|
|
19
|
+
:dependencies,
|
|
20
|
+
:empty_message,
|
|
21
|
+
:missing_prefix,
|
|
22
|
+
:unknown_prefix,
|
|
23
|
+
:success_noun,
|
|
24
|
+
:availability_proc,
|
|
25
|
+
:registry_name,
|
|
26
|
+
keyword_init: true
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
CHECKS = [
|
|
30
|
+
GithubFlowReadiness::Checks::RailsApp,
|
|
31
|
+
GithubFlowReadiness::Checks::RubyVersion,
|
|
32
|
+
GithubFlowReadiness::Checks::BundlerVersion,
|
|
33
|
+
GithubFlowReadiness::Checks::Dockerfile,
|
|
34
|
+
GithubFlowReadiness::Checks::SqliteProduction,
|
|
35
|
+
GithubFlowReadiness::Checks::GemSources,
|
|
36
|
+
GithubFlowReadiness::Checks::GemExactPins,
|
|
37
|
+
GithubFlowReadiness::Checks::NpmExactPins
|
|
38
|
+
].freeze
|
|
39
|
+
|
|
40
|
+
PUBLIC_RUBYGEMS_REMOTE = "https://rubygems.org"
|
|
41
|
+
REGISTRY_FETCH_THREADS = 8
|
|
42
|
+
REGISTRY_FETCH_TIMEOUT_SECONDS = 60
|
|
43
|
+
|
|
44
|
+
attr_reader :root_path
|
|
45
|
+
|
|
46
|
+
def initialize(root_path: Dir.pwd)
|
|
47
|
+
@root_path = Pathname.new(root_path)
|
|
48
|
+
@package_json_parse_error = false
|
|
49
|
+
@rubygems_versions_cache = build_registry_cache
|
|
50
|
+
@npm_versions_cache = build_registry_cache
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def results
|
|
54
|
+
@results ||= CHECKS.flat_map { |klass| wrap_check_result(klass.new(self).call) }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def blockers?
|
|
58
|
+
results.any? { |result| result.status == :fail }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def summary
|
|
62
|
+
if blockers?
|
|
63
|
+
"Blockers found. Fix them before generating the Control Plane GitHub flow."
|
|
64
|
+
else
|
|
65
|
+
"No blocking readiness issues detected. Validate the real production build path before merging."
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# ------------------------------------------------------------------
|
|
70
|
+
# Helpers exposed to check classes (and stubbed by specs).
|
|
71
|
+
# ------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
def gem_dependencies
|
|
74
|
+
@gem_dependencies ||= load_gem_dependencies
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def public_rubygems_dependency?(dependency)
|
|
78
|
+
return false unless dependency[:source_type] == :rubygems
|
|
79
|
+
|
|
80
|
+
remotes = dependency[:source_remotes]
|
|
81
|
+
remotes.empty? || remotes.all? { |remote| remote == PUBLIC_RUBYGEMS_REMOTE }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def inferred_ruby_version
|
|
85
|
+
version_string = RepoIntrospection.inferred_ruby_version_string(root_path.to_s)
|
|
86
|
+
Gem::Version.new(version_string) if version_string
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def lockfile_bundler_version
|
|
90
|
+
file_path = root_path.join("Gemfile.lock")
|
|
91
|
+
return unless file_path.file?
|
|
92
|
+
|
|
93
|
+
lines = file_path.readlines(chomp: true)
|
|
94
|
+
bundler_index = lines.index("BUNDLED WITH")
|
|
95
|
+
return unless bundler_index
|
|
96
|
+
|
|
97
|
+
version = lines[(bundler_index + 1)..]&.find { |line| !line.strip.empty? }&.strip
|
|
98
|
+
return unless version
|
|
99
|
+
|
|
100
|
+
Gem::Version.new(version)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def sqlite_database_in_production?
|
|
104
|
+
RepoIntrospection.sqlite_database_in_production?(root_path.to_s)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def parsed_package_json
|
|
108
|
+
return @parsed_package_json if instance_variable_defined?(:@parsed_package_json)
|
|
109
|
+
|
|
110
|
+
package_json_path = root_path.join("package.json")
|
|
111
|
+
@package_json_parse_error = false
|
|
112
|
+
return @parsed_package_json = nil unless package_json_path.file?
|
|
113
|
+
|
|
114
|
+
@parsed_package_json = JSON.parse(package_json_path.read)
|
|
115
|
+
rescue JSON::ParserError
|
|
116
|
+
@package_json_parse_error = true
|
|
117
|
+
@parsed_package_json = nil
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def package_json_parse_error
|
|
121
|
+
# Calling `parsed_package_json` here is the explicit "make sure parsing has run"
|
|
122
|
+
# trigger, so a reader of `package_json_parse_error` does not have to know that the
|
|
123
|
+
# flag is populated lazily. Guarded so memoized state isn't re-fetched.
|
|
124
|
+
parsed_package_json unless instance_variable_defined?(:@parsed_package_json)
|
|
125
|
+
@package_json_parse_error
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def package_json_parse_error_result
|
|
129
|
+
Result.new(
|
|
130
|
+
status: :warn,
|
|
131
|
+
message: "Could not parse `package.json`; exact-pinned direct npm package readiness could not be fully verified."
|
|
132
|
+
)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def rubygems_registry_check
|
|
136
|
+
RegistryCheck.new(
|
|
137
|
+
dependencies: exact_rubygems_dependencies,
|
|
138
|
+
empty_message: "No exact-pinned direct Ruby gems to verify.",
|
|
139
|
+
missing_prefix: "Direct Ruby gem versions not available on RubyGems",
|
|
140
|
+
unknown_prefix: "Could not verify some exact-pinned Ruby gems against RubyGems",
|
|
141
|
+
success_noun: "direct Ruby gem",
|
|
142
|
+
availability_proc: method(:rubygems_requirement_available?),
|
|
143
|
+
registry_name: "RubyGems"
|
|
144
|
+
)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def npm_registry_check
|
|
148
|
+
RegistryCheck.new(
|
|
149
|
+
dependencies: exact_npm_dependencies,
|
|
150
|
+
empty_message: "No exact-pinned direct npm packages to verify.",
|
|
151
|
+
missing_prefix: "Direct npm package versions not available on npm",
|
|
152
|
+
unknown_prefix: "Could not verify some exact-pinned npm packages against npm",
|
|
153
|
+
success_noun: "direct npm package",
|
|
154
|
+
availability_proc: method(:npm_dependency_available?),
|
|
155
|
+
registry_name: "npm"
|
|
156
|
+
)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def exact_pin_registry_result(check)
|
|
160
|
+
return Result.new(status: :info, message: check.empty_message) if check.dependencies.empty?
|
|
161
|
+
|
|
162
|
+
grouped = partition_dependencies(check.dependencies, check.availability_proc)
|
|
163
|
+
results = []
|
|
164
|
+
results << registry_unavailable_result(check, grouped[:unavailable]) if grouped[:unavailable].any?
|
|
165
|
+
results << registry_unknown_result(check, grouped[:unknown]) if grouped[:unknown].any?
|
|
166
|
+
return results if results.any?
|
|
167
|
+
|
|
168
|
+
Result.new(status: :pass, message: registry_success_message(check))
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Stubbed in specs; keep public.
|
|
172
|
+
def fetch_rubygems_versions(name)
|
|
173
|
+
fetch_with_cache(rubygems_versions_cache, name) { fetch_versions_from_rubygems(name) }
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Stubbed in specs; keep public.
|
|
177
|
+
def fetch_npm_versions(name)
|
|
178
|
+
fetch_with_cache(npm_versions_cache, name) { fetch_versions_from_npm(name) }
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
private
|
|
182
|
+
|
|
183
|
+
attr_reader :rubygems_versions_cache, :npm_versions_cache
|
|
184
|
+
|
|
185
|
+
# Wrap a check's return value into an array. Avoid Kernel#Array on a Result Struct,
|
|
186
|
+
# which would unpack it into [status, message] instead of wrapping it.
|
|
187
|
+
def wrap_check_result(value)
|
|
188
|
+
return [] if value.nil?
|
|
189
|
+
return value if value.is_a?(Array)
|
|
190
|
+
|
|
191
|
+
[value]
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def build_registry_cache
|
|
195
|
+
{ store: {}, mutex: Mutex.new }
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def exact_rubygems_dependencies
|
|
199
|
+
gem_dependencies.select do |dependency|
|
|
200
|
+
public_rubygems_dependency?(dependency) && dependency[:exact_version]
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def exact_npm_dependencies
|
|
205
|
+
package_json = parsed_package_json
|
|
206
|
+
return [] unless package_json
|
|
207
|
+
|
|
208
|
+
collect_exact_dependencies(
|
|
209
|
+
package_json.fetch("dependencies", {}),
|
|
210
|
+
package_json.fetch("devDependencies", {})
|
|
211
|
+
)
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def collect_exact_dependencies(*dependency_sets)
|
|
215
|
+
dependency_sets.flat_map { |dependencies| exact_dependency_entries(dependencies) }
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def exact_dependency_entries(dependencies)
|
|
219
|
+
dependencies.filter_map do |name, version|
|
|
220
|
+
{ name: name, exact_version: version } if exact_version_string?(version)
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def exact_version_string?(version)
|
|
225
|
+
version.is_a?(String) && version.match?(/\A\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?\z/)
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def rubygems_requirement_available?(dependency)
|
|
229
|
+
versions = fetch_rubygems_versions(dependency[:name])
|
|
230
|
+
return nil unless versions
|
|
231
|
+
|
|
232
|
+
requirement = dependency[:requirement]
|
|
233
|
+
versions.any? { |version| requirement.satisfied_by?(Gem::Version.new(version)) }
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def npm_dependency_available?(dependency)
|
|
237
|
+
versions = fetch_npm_versions(dependency[:name])
|
|
238
|
+
return nil unless versions
|
|
239
|
+
|
|
240
|
+
versions.include?(dependency[:exact_version])
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# Fan out registry lookups across a small thread pool. Each HTTP call has a 5s timeout
|
|
244
|
+
# (see `http_get`), and the join deadline below bounds cases such as DNS resolution
|
|
245
|
+
# hangs that Net::HTTP does not cover. Results are memoized per dependency name;
|
|
246
|
+
# serially this scaled linearly with dependency count, which made readiness slow for
|
|
247
|
+
# repos with many exact pins.
|
|
248
|
+
def partition_dependencies(dependencies, availability_proc)
|
|
249
|
+
results = fetch_availability_in_parallel(dependencies, availability_proc)
|
|
250
|
+
results.each_with_object(unavailable: [], unknown: []) do |(dependency, status), grouped|
|
|
251
|
+
case status
|
|
252
|
+
when false
|
|
253
|
+
grouped[:unavailable] << dependency
|
|
254
|
+
when nil
|
|
255
|
+
grouped[:unknown] << dependency
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def fetch_availability_in_parallel(dependencies, availability_proc)
|
|
261
|
+
queue = Queue.new
|
|
262
|
+
indexed = dependencies.each_with_index.to_a
|
|
263
|
+
indexed.each { |entry| queue << entry }
|
|
264
|
+
results = Array.new(dependencies.length)
|
|
265
|
+
result_state = { mutex: Mutex.new, timed_out: false }
|
|
266
|
+
|
|
267
|
+
workers = build_availability_workers(queue, availability_proc, results, dependencies.length, result_state)
|
|
268
|
+
wait_for_availability_workers(workers, result_state)
|
|
269
|
+
fill_missing_availability_results(indexed, results)
|
|
270
|
+
results
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def build_availability_workers(queue, availability_proc, results, dependency_count, result_state)
|
|
274
|
+
Array.new([REGISTRY_FETCH_THREADS, dependency_count].min) do
|
|
275
|
+
Thread.new { drain_availability_queue(queue, availability_proc, results, result_state) }
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
def wait_for_availability_workers(workers, result_state)
|
|
280
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + REGISTRY_FETCH_TIMEOUT_SECONDS
|
|
281
|
+
workers.each do |worker|
|
|
282
|
+
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
283
|
+
next if worker.join(remaining.positive? ? remaining : 0)
|
|
284
|
+
|
|
285
|
+
# Timed-out workers are not killed; their in-flight HTTP calls have their own
|
|
286
|
+
# short timeouts and then exit before writing any more results.
|
|
287
|
+
mark_availability_timeout(result_state)
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def fill_missing_availability_results(indexed, results)
|
|
292
|
+
indexed.each { |dependency, index| results[index] ||= [dependency, nil] }
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def drain_availability_queue(queue, availability_proc, results, result_state)
|
|
296
|
+
loop do
|
|
297
|
+
break if availability_timed_out?(result_state)
|
|
298
|
+
|
|
299
|
+
dependency, index = queue.pop(true)
|
|
300
|
+
write_availability_result(results, index, [dependency, availability_proc.call(dependency)], result_state)
|
|
301
|
+
rescue ThreadError
|
|
302
|
+
break
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
def availability_timed_out?(result_state)
|
|
307
|
+
result_state[:mutex].synchronize { result_state[:timed_out] }
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
def mark_availability_timeout(result_state)
|
|
311
|
+
result_state[:mutex].synchronize { result_state[:timed_out] = true }
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
def write_availability_result(results, index, value, result_state)
|
|
315
|
+
result_state[:mutex].synchronize do
|
|
316
|
+
results[index] = value unless result_state[:timed_out]
|
|
317
|
+
end
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
# Worker threads in `fetch_availability_in_parallel` may share the cache, so guard
|
|
321
|
+
# both the duplicate-fetch check and the assignment with a mutex. The mutex is released
|
|
322
|
+
# before `yield` so a slow HTTP request does not block other workers from reading cached
|
|
323
|
+
# entries; the trade-off is that N threads racing on a cold cache for the same name can
|
|
324
|
+
# all fire HTTP requests in parallel. The duplicate-fetch window is bounded:
|
|
325
|
+
# the queue assigns each (dependency, index) entry to exactly one worker, so a name
|
|
326
|
+
# only races when two workers happen to look up the same `name` from different
|
|
327
|
+
# dependencies. At most REGISTRY_FETCH_THREADS duplicate requests can fire per cold
|
|
328
|
+
# name on the first parallel sweep — acceptable because the fetches are idempotent and
|
|
329
|
+
# the public registries (rubygems.org, registry.npmjs.org) are designed to absorb that.
|
|
330
|
+
# On the write side this means the second `cache[:store][name] = value` overwrites the
|
|
331
|
+
# first; that is safe (no `||=` guard needed) precisely because both racers fetched the
|
|
332
|
+
# same registry data and so write the same value.
|
|
333
|
+
def fetch_with_cache(cache, name)
|
|
334
|
+
cache[:mutex].synchronize do
|
|
335
|
+
return cache[:store][name] if cache[:store].key?(name)
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
value = yield
|
|
339
|
+
cache[:mutex].synchronize { cache[:store][name] = value }
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
def fetch_versions_from_rubygems(name)
|
|
343
|
+
uri = URI("https://rubygems.org/api/v1/versions/#{CGI.escape(name)}.json")
|
|
344
|
+
response = http_get(uri)
|
|
345
|
+
return nil unless response.is_a?(Net::HTTPSuccess)
|
|
346
|
+
|
|
347
|
+
JSON.parse(response.body).map { |entry| entry["number"] }
|
|
348
|
+
rescue JSON::ParserError
|
|
349
|
+
nil
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
def fetch_versions_from_npm(name)
|
|
353
|
+
uri = URI("https://registry.npmjs.org/#{npm_package_path_segment(name)}")
|
|
354
|
+
response = http_get(uri)
|
|
355
|
+
return nil unless response.is_a?(Net::HTTPSuccess)
|
|
356
|
+
|
|
357
|
+
JSON.parse(response.body).fetch("versions", {}).keys
|
|
358
|
+
rescue JSON::ParserError, URI::InvalidURIError
|
|
359
|
+
nil
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
# Encodes the `/` in scoped package names so the registry path is valid.
|
|
363
|
+
# Other characters that are rare-but-legal in npm names (e.g. `%`) are not
|
|
364
|
+
# encoded here; URI construction errors are rescued in `fetch_versions_from_npm`
|
|
365
|
+
# and treated as unknown availability.
|
|
366
|
+
def npm_package_path_segment(name)
|
|
367
|
+
name.gsub("/", "%2F")
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
def http_get(uri)
|
|
371
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
372
|
+
http.use_ssl = true
|
|
373
|
+
http.open_timeout = 5
|
|
374
|
+
http.read_timeout = 5
|
|
375
|
+
http.get(uri.request_uri)
|
|
376
|
+
rescue StandardError => e
|
|
377
|
+
warn "github_flow_readiness: HTTP GET #{uri} failed: #{e.class}: #{e.message}" if ENV["CPFLOW_DEBUG"]
|
|
378
|
+
nil
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
def load_gem_dependencies
|
|
382
|
+
lockfile_path = root_path.join("Gemfile.lock")
|
|
383
|
+
return [] unless lockfile_path.file?
|
|
384
|
+
|
|
385
|
+
parse_gem_dependencies(lockfile_path)
|
|
386
|
+
rescue StandardError => e
|
|
387
|
+
warn "cpflow: failed to parse Gemfile.lock: #{e.class}: #{e.message}" if ENV["CPFLOW_DEBUG"]
|
|
388
|
+
[]
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
# Parse Gemfile.lock via Bundler::LockfileParser rather than Bundler::Dsl#eval_gemfile.
|
|
392
|
+
# `eval_gemfile` instance_evals the user's Gemfile, which executes arbitrary Ruby. Readiness
|
|
393
|
+
# checks run against untrusted project trees, so we keep the trust boundary at "parse the
|
|
394
|
+
# lockfile only" — no Ruby from the user's repo is ever executed here.
|
|
395
|
+
def parse_gem_dependencies(lockfile_path)
|
|
396
|
+
parser = Bundler::LockfileParser.new(lockfile_path.read)
|
|
397
|
+
parser.dependencies.values.map do |dependency|
|
|
398
|
+
spec = parser.specs.find { |locked_spec| locked_spec.name == dependency.name }
|
|
399
|
+
build_gem_dependency(dependency, source: spec&.source)
|
|
400
|
+
end
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
def build_gem_dependency(dependency, source:)
|
|
404
|
+
{
|
|
405
|
+
name: dependency.name,
|
|
406
|
+
exact_version: exact_gem_version(dependency),
|
|
407
|
+
requirement: dependency.requirement,
|
|
408
|
+
source_type: gem_source_type(source),
|
|
409
|
+
source_remotes: gem_source_remotes(source)
|
|
410
|
+
}
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
def exact_gem_version(dependency)
|
|
414
|
+
dependency.requirement.requirements.first.last.to_s if dependency.requirement.exact?
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
def gem_source_type(source)
|
|
418
|
+
return :rubygems if source.nil? || source.is_a?(Bundler::Source::Rubygems)
|
|
419
|
+
return :path if source.is_a?(Bundler::Source::Path)
|
|
420
|
+
return :git if source.is_a?(Bundler::Source::Git)
|
|
421
|
+
|
|
422
|
+
:other
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
def gem_source_remotes(source)
|
|
426
|
+
return [] unless source.respond_to?(:remotes)
|
|
427
|
+
|
|
428
|
+
Array(source.remotes).map { |remote| normalize_remote(remote) }
|
|
429
|
+
end
|
|
430
|
+
|
|
431
|
+
def normalize_remote(remote)
|
|
432
|
+
remote.to_s.sub(%r{/+\z}, "")
|
|
433
|
+
end
|
|
434
|
+
|
|
435
|
+
def format_dependencies(dependencies)
|
|
436
|
+
dependencies.map { |dependency| "`#{dependency[:name]}@#{dependency[:exact_version]}`" }.join(", ")
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
def registry_success_message(check)
|
|
440
|
+
dependency_count = check.dependencies.length
|
|
441
|
+
noun = "#{check.success_noun}#{'s' if dependency_count != 1}"
|
|
442
|
+
|
|
443
|
+
"Checked #{dependency_count} exact-pinned #{noun}; all appear available on #{check.registry_name}."
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
def registry_unavailable_result(check, dependencies)
|
|
447
|
+
Result.new(status: :fail, message: "#{check.missing_prefix}: #{format_dependencies(dependencies)}.")
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
def registry_unknown_result(check, dependencies)
|
|
451
|
+
Result.new(status: :warn, message: "#{check.unknown_prefix}: #{format_dependencies(dependencies)}.")
|
|
452
|
+
end
|
|
453
|
+
end
|