chronos-ruby 1.2.1 → 1.2.2
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/CHANGELOG.md +18 -0
- data/README.md +158 -106
- data/docs/adr/legacy-deploy-cli.md +21 -0
- data/docs/adr/transitional-deploy-cli.md +11 -0
- data/docs/compatibility.md +31 -18
- data/docs/deploy-cli.md +35 -0
- data/docs/legacy-test-environment.md +59 -0
- data/docs/modules/rails-legacy.md +2 -2
- data/docs/modules/sidekiq-legacy.md +7 -10
- data/docs/release-1.1.4-readiness.md +44 -0
- data/docs/release-1.2.2-readiness.md +27 -0
- data/exe/chronos +3 -0
- data/lib/chronos/agent.rb +16 -5
- data/lib/chronos/cli.rb +117 -0
- data/lib/chronos/core/bounded_copy.rb +69 -0
- data/lib/chronos/core/notice.rb +4 -28
- data/lib/chronos/core/sanitizer.rb +1 -1
- data/lib/chronos/core/sql_lexer.rb +73 -0
- data/lib/chronos/core/sql_normalizer.rb +7 -7
- data/lib/chronos/core/telemetry_event.rb +5 -18
- data/lib/chronos/deploy/configuration.rb +96 -0
- data/lib/chronos/deploy/error.rb +14 -0
- data/lib/chronos/deploy/installer.rb +65 -0
- data/lib/chronos/deploy/providers.rb +50 -0
- data/lib/chronos/deploy/runner.rb +44 -0
- data/lib/chronos/deploy/secure_directory.rb +125 -0
- data/lib/chronos/rails/active_record_query_inspector.rb +2 -4
- data/lib/chronos/rails/installer.rb +1 -1
- data/lib/chronos/rails/railtie.rb +1 -1
- data/lib/chronos/rails.rb +3 -3
- data/lib/chronos/rake_tasks.rb +13 -1
- data/lib/chronos/version.rb +1 -1
- data/lib/generators/chronos/deploy/install/install_generator.rb +26 -0
- metadata +41 -3
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
require "strscan"
|
|
2
|
+
|
|
3
|
+
module Chronos
|
|
4
|
+
module Core
|
|
5
|
+
# @responsibility Mask SQL literals before truncation and reject incomplete or ambiguous lexical input.
|
|
6
|
+
# Conservative lexical validation is not a dialect-complete SQL parser.
|
|
7
|
+
class SqlLexer
|
|
8
|
+
MAX_BYTES = 4096
|
|
9
|
+
|
|
10
|
+
def initialize(sql, adapter = "")
|
|
11
|
+
@sql = sql
|
|
12
|
+
@postgres = adapter.to_s =~ /postgres/i
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def call
|
|
16
|
+
return nil unless @sql.is_a?(String) && @sql.valid_encoding? && @sql.bytesize <= MAX_BYTES
|
|
17
|
+
|
|
18
|
+
@scanner = StringScanner.new(@sql)
|
|
19
|
+
result = ""
|
|
20
|
+
until @scanner.eos?
|
|
21
|
+
token = next_token
|
|
22
|
+
return nil if token.nil?
|
|
23
|
+
|
|
24
|
+
result << token
|
|
25
|
+
end
|
|
26
|
+
result
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def single_select?
|
|
30
|
+
text = call
|
|
31
|
+
return false unless text && text.lstrip =~ /\ASELECT\b/i
|
|
32
|
+
|
|
33
|
+
text.sub(/;\s*\z/, "") !~ /;/
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def next_token
|
|
39
|
+
return " " if @scanner.scan(/\s+|--[^\r\n]*|\#[^\r\n]*/)
|
|
40
|
+
return block_comment if @scanner.peek(2) == "/*"
|
|
41
|
+
return quoted if ["'", '"', "`"].include?(@scanner.peek(1))
|
|
42
|
+
return dollar_literal if @scanner.check(/\$(?:[A-Za-z_][A-Za-z0-9_]*)?\$/)
|
|
43
|
+
|
|
44
|
+
@scanner.getch
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def block_comment
|
|
48
|
+
text = @scanner.scan(%r{/\*.*?\*/}m)
|
|
49
|
+
return nil unless text
|
|
50
|
+
return nil if text.start_with?("/*!", "/*M!") || text[2...-2].include?("/*")
|
|
51
|
+
|
|
52
|
+
" "
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def quoted
|
|
56
|
+
quote = @scanner.peek(1)
|
|
57
|
+
delimiter = Regexp.escape(quote)
|
|
58
|
+
pattern = /#{delimiter}(?:#{delimiter * 2}|[^#{delimiter}\\])*#{delimiter}/m
|
|
59
|
+
text = @scanner.scan(pattern)
|
|
60
|
+
return nil unless text
|
|
61
|
+
return "?" if quote == "'" || (quote == '"' && !@postgres)
|
|
62
|
+
|
|
63
|
+
identifier = text[1...-1]
|
|
64
|
+
identifier =~ /\A[A-Za-z_][A-Za-z0-9_$]*\z/ ? text : "?"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def dollar_literal
|
|
68
|
+
delimiter = @scanner.scan(/\$(?:[A-Za-z_][A-Za-z0-9_]*)?\$/)
|
|
69
|
+
@scanner.scan_until(/#{Regexp.escape(delimiter)}/m) ? "?" : nil
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
require "digest"
|
|
2
|
+
require "chronos/core/sql_lexer"
|
|
2
3
|
|
|
3
4
|
module Chronos
|
|
4
5
|
module Core
|
|
@@ -15,7 +16,7 @@ module Chronos
|
|
|
15
16
|
# @errors Malformed or unreadable input returns bounded unknown metadata.
|
|
16
17
|
# @performance Input and normalized output are capped before fingerprinting.
|
|
17
18
|
class SqlNormalizer
|
|
18
|
-
MAX_SQL_BYTES =
|
|
19
|
+
MAX_SQL_BYTES = SqlLexer::MAX_BYTES
|
|
19
20
|
MAX_NORMALIZED_BYTES = 512
|
|
20
21
|
KEYWORDS = %w(
|
|
21
22
|
select insert update delete from into where join left right inner outer on set values
|
|
@@ -23,7 +24,7 @@ module Chronos
|
|
|
23
24
|
).freeze
|
|
24
25
|
|
|
25
26
|
def call(sql, metadata = {})
|
|
26
|
-
normalized = normalize(sql)
|
|
27
|
+
normalized = normalize(sql, metadata)
|
|
27
28
|
{
|
|
28
29
|
"adapter" => adapter(metadata), "operation" => operation(normalized),
|
|
29
30
|
"table" => table(normalized), "normalized_query" => normalized,
|
|
@@ -41,11 +42,10 @@ module Chronos
|
|
|
41
42
|
|
|
42
43
|
private
|
|
43
44
|
|
|
44
|
-
def normalize(sql)
|
|
45
|
-
text =
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
text = text.gsub(/\$([A-Za-z_][A-Za-z0-9_]*)?\$.*?\$\1\$/m, "?")
|
|
45
|
+
def normalize(sql, metadata)
|
|
46
|
+
text = SqlLexer.new(sql, adapter(metadata)).call
|
|
47
|
+
return "" unless text
|
|
48
|
+
|
|
49
49
|
text = text.gsub(/\b(?:0x[0-9a-f]+|\d+(?:\.\d+)?)\b/i, "?")
|
|
50
50
|
text = text.gsub(/\b(?:true|false|null)\b/i, "?")
|
|
51
51
|
text = text.gsub(/\(\s*\?(?:\s*,\s*\?)+\s*\)/, "(?)")
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
require "securerandom"
|
|
2
2
|
require "time"
|
|
3
|
+
require "chronos/core/bounded_copy"
|
|
3
4
|
|
|
4
5
|
module Chronos
|
|
5
6
|
module Core
|
|
@@ -21,31 +22,17 @@ module Chronos
|
|
|
21
22
|
attr_reader :event_id, :event_type, :timestamp, :context, :payload
|
|
22
23
|
|
|
23
24
|
def initialize(event_type, payload = {}, context = {}, options = {})
|
|
24
|
-
type = event_type.to_s
|
|
25
|
+
type = event_type.to_s.dup
|
|
25
26
|
raise ArgumentError, "unsupported telemetry event type" unless TYPES.include?(type)
|
|
26
27
|
|
|
27
28
|
clock = options[:clock] || proc { Time.now }
|
|
28
|
-
@event_id = (options[:event_id] || SecureRandom.uuid).to_s.freeze
|
|
29
|
+
@event_id = (options[:event_id] || SecureRandom.uuid).to_s.dup.freeze
|
|
29
30
|
@event_type = type.freeze
|
|
30
31
|
@timestamp = clock.call.utc.iso8601(6).freeze
|
|
31
|
-
@context =
|
|
32
|
-
@payload =
|
|
32
|
+
@context = BoundedCopy.new.call(context.is_a?(Hash) ? context : {})
|
|
33
|
+
@payload = BoundedCopy.new.call(payload.is_a?(Hash) ? payload : {})
|
|
33
34
|
freeze
|
|
34
35
|
end
|
|
35
|
-
|
|
36
|
-
private
|
|
37
|
-
|
|
38
|
-
def deep_freeze(value)
|
|
39
|
-
if value.is_a?(Hash)
|
|
40
|
-
value.each do |key, child|
|
|
41
|
-
deep_freeze(key)
|
|
42
|
-
deep_freeze(child)
|
|
43
|
-
end
|
|
44
|
-
elsif value.is_a?(Array)
|
|
45
|
-
value.each { |child| deep_freeze(child) }
|
|
46
|
-
end
|
|
47
|
-
value.freeze
|
|
48
|
-
end
|
|
49
36
|
end
|
|
50
37
|
|
|
51
38
|
# Serializes framework telemetry into the common Chronos event envelope.
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
require "yaml"
|
|
2
|
+
require "uri"
|
|
3
|
+
require "chronos/deploy/error"
|
|
4
|
+
|
|
5
|
+
module Chronos
|
|
6
|
+
module Deploy
|
|
7
|
+
# @responsibility Validate and resolve public options, canonical ENV and legacy aliases.
|
|
8
|
+
# Resolves explicit CLI configuration without booting an application or agent.
|
|
9
|
+
class Configuration
|
|
10
|
+
FIELDS = %w(project_id project_key host environment service_name deploy_id revision version).freeze
|
|
11
|
+
ALIASES = {"environment" => "DEPLOY_ENVIRONMENT", "service_name" => "SERVICE_NAME",
|
|
12
|
+
"deploy_id" => "DEPLOY_ID", "revision" => "GIT_SHA", "version" => "APP_VERSION"}.freeze
|
|
13
|
+
HOST_ERROR = "Host must be an absolute HTTPS URL without credentials, query or fragment.".freeze
|
|
14
|
+
|
|
15
|
+
def self.read(options, env)
|
|
16
|
+
root = File.realpath(File.expand_path(options[:root] || Dir.pwd))
|
|
17
|
+
public_values = read_public(root)
|
|
18
|
+
values = FIELDS.each_with_object({}) do |field, result|
|
|
19
|
+
result[field] = resolve(field, options, env, public_values)
|
|
20
|
+
end
|
|
21
|
+
values["host"] ||= "https://chronosmonitor.com.br"
|
|
22
|
+
validate_host!(values["host"])
|
|
23
|
+
values
|
|
24
|
+
rescue Errno::ENOENT, Errno::ENOTDIR, Errno::EACCES
|
|
25
|
+
raise Error, "Configuration root or file is unavailable; check --root and permissions."
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.read_public(root)
|
|
29
|
+
path = File.join(root, "config/chronos-deploy.yml")
|
|
30
|
+
if File.symlink?(File.join(root, "config")) || File.symlink?(path)
|
|
31
|
+
raise Error, "Configuration path must not contain symlinks."
|
|
32
|
+
end
|
|
33
|
+
return {} unless File.exist?(path)
|
|
34
|
+
raise Error, "Public configuration must be a regular file." unless File.file?(path)
|
|
35
|
+
raise Error, "Public configuration exceeds 64 KiB." if File.size(path) > 65_536
|
|
36
|
+
|
|
37
|
+
parsed = YAML.safe_load(File.read(path))
|
|
38
|
+
values = parsed.nil? ? {} : parsed
|
|
39
|
+
raise ArgumentError unless values.is_a?(Hash) && (values.keys - (FIELDS - ["project_key"])).empty?
|
|
40
|
+
|
|
41
|
+
values.each_value { |value| validate_text!(value) }
|
|
42
|
+
values
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def self.resolve(field, options, env, public_values)
|
|
46
|
+
prefix = %w(revision version).include?(field) ? "CHRONOS_DEPLOY_" : "CHRONOS_"
|
|
47
|
+
primary = env["#{prefix}#{field.upcase}"]
|
|
48
|
+
alternate = env[ALIASES[field]] if ALIASES[field]
|
|
49
|
+
if primary && alternate && primary != alternate
|
|
50
|
+
raise Error, "Conflicting canonical and alias environment variables; keep only one source."
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
value = options[field.to_sym] || primary || alternate || public_values[field]
|
|
54
|
+
validate_text!(value)
|
|
55
|
+
value
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def self.validate_text!(value)
|
|
59
|
+
raise ArgumentError unless value.nil? || value.is_a?(String)
|
|
60
|
+
return if value.nil?
|
|
61
|
+
return if value.valid_encoding? && value.encoding.ascii_compatible? && !value.match(/[[:cntrl:]]/)
|
|
62
|
+
|
|
63
|
+
raise Error, "Configuration values must be text without control characters."
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def self.validate_host!(host)
|
|
67
|
+
uri = URI.parse(host)
|
|
68
|
+
valid = uri.scheme == "https" && uri.host && !uri.userinfo && !uri.query && !uri.fragment
|
|
69
|
+
raise Error, HOST_ERROR unless valid
|
|
70
|
+
rescue URI::InvalidURIError
|
|
71
|
+
raise Error, HOST_ERROR
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def self.validate!(values, command)
|
|
75
|
+
required = %w(project_id project_key environment)
|
|
76
|
+
required << "deploy_id" if command != "verify"
|
|
77
|
+
missing = required.select { |field| values[field].to_s.strip.empty? }
|
|
78
|
+
raise Error.new("Supply the required configuration fields.", :missing_fields => missing) unless missing.empty?
|
|
79
|
+
|
|
80
|
+
validate_release!(values) unless command == "verify"
|
|
81
|
+
%w(environment service_name deploy_id revision version).each do |field|
|
|
82
|
+
if values[field] && values[field].bytesize > 128
|
|
83
|
+
raise Error, "Deployment metadata exceeds the 128-byte protocol limit."
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
true
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def self.validate_release!(values)
|
|
90
|
+
return unless values["revision"].to_s.strip.empty? && values["version"].to_s.strip.empty?
|
|
91
|
+
|
|
92
|
+
raise Error, "Supply the revision or version of the published artifact."
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
module Chronos
|
|
2
|
+
module Deploy
|
|
3
|
+
# @responsibility Carry static actionable errors and allowlisted diagnostics without raw input.
|
|
4
|
+
# Messages must be static: never interpolate input or exception messages.
|
|
5
|
+
class Error < StandardError
|
|
6
|
+
attr_reader :details
|
|
7
|
+
|
|
8
|
+
def initialize(message, details = {})
|
|
9
|
+
super(message)
|
|
10
|
+
@details = details
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
require "chronos/deploy/error"
|
|
2
|
+
require "chronos/deploy/providers"
|
|
3
|
+
|
|
4
|
+
module Chronos
|
|
5
|
+
module Deploy
|
|
6
|
+
# @responsibility Generate provider fragments while preserving existing customer integrations.
|
|
7
|
+
# Explicit, idempotent generation of static fragments below a caller-selected root.
|
|
8
|
+
class Installer
|
|
9
|
+
PATHS = Providers::PATHS
|
|
10
|
+
|
|
11
|
+
def install(options)
|
|
12
|
+
raise Error, "Use --check without --force." if options[:force] && options[:check]
|
|
13
|
+
|
|
14
|
+
relative = PATHS[options[:provider]]
|
|
15
|
+
raise Error, "Choose a supported --provider; see chronos --help." unless relative
|
|
16
|
+
|
|
17
|
+
root = installation_root(options)
|
|
18
|
+
require "chronos/deploy/secure_directory"
|
|
19
|
+
SecureDirectory.with_root(root) do |directory|
|
|
20
|
+
install_into(directory, relative, options)
|
|
21
|
+
end
|
|
22
|
+
rescue Errno::EEXIST, Errno::EACCES, Errno::ENOTDIR, Errno::ELOOP
|
|
23
|
+
raise Error, "Integration destination is unavailable or changed; inspect it before retrying."
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def install_into(directory, relative, options)
|
|
29
|
+
content = Providers.template(options[:provider])
|
|
30
|
+
existing = directory.with_parent(relative, false) { |parent, name| parent && parent.read(name) }
|
|
31
|
+
return "unchanged" if unchanged?(existing, relative, content, options)
|
|
32
|
+
raise Error, "Integration is missing; run install without --check." if options[:check]
|
|
33
|
+
return "would_write_#{relative}" if options[:dry_run]
|
|
34
|
+
|
|
35
|
+
directory.with_parent(relative, true) do |parent, name|
|
|
36
|
+
current = parent.read(name)
|
|
37
|
+
unless unchanged?(current, relative, content, options)
|
|
38
|
+
parent.write(name, content, options[:provider] == "kamal", options[:force])
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
"created_#{relative}; merge after successful publication; supply explicit published identity and revision"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def installation_root(options)
|
|
45
|
+
root = File.expand_path(options[:root] || Dir.pwd)
|
|
46
|
+
unless File.directory?(root) && !File.symlink?(root)
|
|
47
|
+
raise Error, "Installation root must be an existing directory, not a symlink."
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
root
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def unchanged?(existing, relative, content, options)
|
|
54
|
+
return false if existing.nil?
|
|
55
|
+
return true if existing == content
|
|
56
|
+
return false if options[:force] && !options[:check]
|
|
57
|
+
|
|
58
|
+
proposed = content.lines.map { |line| "+#{line}" }.join
|
|
59
|
+
raise Error.new("Existing integration differs; merge the proposed template or explicitly use --force.",
|
|
60
|
+
:path => relative,
|
|
61
|
+
:diff => "--- existing (content withheld for privacy)\n+++ proposed\n#{proposed}")
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
module Chronos
|
|
2
|
+
module Deploy
|
|
3
|
+
# Static integration fragments never interpolate customer input or credentials.
|
|
4
|
+
module Providers
|
|
5
|
+
PATHS = {
|
|
6
|
+
"kamal" => ".kamal/hooks/post-deploy",
|
|
7
|
+
"capistrano" => "lib/capistrano/tasks/chronos_deploy.rake",
|
|
8
|
+
"github-actions" => "config/chronos/deploy/github-actions.steps.yml",
|
|
9
|
+
"gitlab-ci" => "config/chronos/deploy/gitlab-ci.job.yml",
|
|
10
|
+
"jenkins" => "config/chronos/deploy/jenkins.post.groovy",
|
|
11
|
+
"azure-devops" => "config/chronos/deploy/azure-pipelines.steps.yml"
|
|
12
|
+
}.freeze
|
|
13
|
+
COMMAND = "bundle exec chronos deploy notify --on-error=warn".freeze
|
|
14
|
+
MARKER = "# Chronos deploy template v1; credentials from environment only.\n".freeze
|
|
15
|
+
INSTRUCTIONS = "# Supply CHRONOS_DEPLOY_ID and CHRONOS_DEPLOY_REVISION/VERSION " \
|
|
16
|
+
"from the actual publication.\n".freeze
|
|
17
|
+
CAPISTRANO = <<-'RUBY'.freeze
|
|
18
|
+
# Do not also load chronos/capistrano. Runs locally once after publication.
|
|
19
|
+
namespace :chronos_cli do
|
|
20
|
+
task :notify do
|
|
21
|
+
if Rake::Task.task_defined?("chronos:notify_deploy")
|
|
22
|
+
raise "Remove the duplicate chronos/capistrano integration"
|
|
23
|
+
end
|
|
24
|
+
system("bundle", "exec", "chronos", "deploy", "notify", "--on-error=warn")
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
after "deploy:published", "chronos_cli:notify"
|
|
28
|
+
RUBY
|
|
29
|
+
BODIES = {
|
|
30
|
+
"kamal" => "exec #{COMMAND}\n",
|
|
31
|
+
"capistrano" => CAPISTRANO,
|
|
32
|
+
"github-actions" => "# Merge into the real job after successful publication.\n" \
|
|
33
|
+
"- name: Notify Chronos\n run: #{COMMAND}\n",
|
|
34
|
+
"gitlab-ci" => "# Merge script into the successful deployment job; preserve artifact outputs.\n" \
|
|
35
|
+
".chronos_deploy_notification:\n script:\n - #{COMMAND}\n",
|
|
36
|
+
"jenkins" => "// Merge into existing post { success { ... } }; export actual publication outputs.\n" \
|
|
37
|
+
"sh '#{COMMAND}'\n",
|
|
38
|
+
"azure-devops" => "# Merge after successful publication in the same job.\n" \
|
|
39
|
+
"- script: #{COMMAND}\n displayName: Notify Chronos\n condition: succeeded()\n"
|
|
40
|
+
}.freeze
|
|
41
|
+
|
|
42
|
+
def self.template(provider)
|
|
43
|
+
comments = MARKER + INSTRUCTIONS
|
|
44
|
+
comments = comments.gsub(/^#/, "//") if provider == "jenkins"
|
|
45
|
+
shebang = provider == "kamal" ? "#!/bin/sh\n" : ""
|
|
46
|
+
shebang + comments + BODIES.fetch(provider)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
require "logger"
|
|
2
|
+
require "chronos"
|
|
3
|
+
require "chronos/deploy/configuration"
|
|
4
|
+
|
|
5
|
+
module Chronos
|
|
6
|
+
module Deploy
|
|
7
|
+
# @responsibility Execute the existing deploy or receipt API with bounded transport and guaranteed close.
|
|
8
|
+
# One explicit operation through the existing agent, always closed on exit.
|
|
9
|
+
class Runner
|
|
10
|
+
def call(command, values, timeout)
|
|
11
|
+
Configuration.validate!(values, command)
|
|
12
|
+
begin
|
|
13
|
+
configure(values, timeout, command)
|
|
14
|
+
outcome = if command == "verify"
|
|
15
|
+
Chronos.verify_integration
|
|
16
|
+
else
|
|
17
|
+
Chronos.notify_deploy({:deploy_id => values["deploy_id"], :revision => values["revision"],
|
|
18
|
+
:version => values["version"]}, timeout)
|
|
19
|
+
end
|
|
20
|
+
ensure
|
|
21
|
+
closed = Chronos.close(timeout)
|
|
22
|
+
end
|
|
23
|
+
command == "notify" ? outcome == true && closed != false : outcome
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def configure(values, timeout, command)
|
|
29
|
+
Chronos.configure do |config|
|
|
30
|
+
values.each do |key, value|
|
|
31
|
+
attribute = key == "version" ? "app_version" : key
|
|
32
|
+
config.public_send("#{attribute}=", value) unless value.nil?
|
|
33
|
+
end
|
|
34
|
+
config.logger = Logger.new(File::NULL)
|
|
35
|
+
config.timeout = timeout
|
|
36
|
+
config.open_timeout = timeout
|
|
37
|
+
config.max_retries = 0
|
|
38
|
+
config.backlog_size = 0
|
|
39
|
+
config.dependency_reporting = false if command == "verify"
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
require "fiddle/import"
|
|
2
|
+
require "securerandom"
|
|
3
|
+
require "chronos/deploy/error"
|
|
4
|
+
|
|
5
|
+
module Chronos
|
|
6
|
+
module Deploy
|
|
7
|
+
# @responsibility Anchor installer reads and writes to directory descriptors without following symlinks.
|
|
8
|
+
# POSIX openat/mkdirat/linkat/renameat prevent parent-path replacement from redirecting writes.
|
|
9
|
+
class SecureDirectory
|
|
10
|
+
# Fixed POSIX entry points; no shell, dynamic symbol names, or user-provided native code.
|
|
11
|
+
module Native
|
|
12
|
+
extend Fiddle::Importer
|
|
13
|
+
dlload Fiddle::Handle::DEFAULT
|
|
14
|
+
extern "int openat(int, const char*, int, unsigned int)"
|
|
15
|
+
extern "int mkdirat(int, const char*, unsigned int)"
|
|
16
|
+
extern "int linkat(int, const char*, int, const char*, int)"
|
|
17
|
+
extern "int renameat(int, const char*, int, const char*)"
|
|
18
|
+
extern "int unlinkat(int, const char*, int)"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def self.with_root(root)
|
|
22
|
+
raise Error, "Secure installation requires POSIX no-follow support." unless File.const_defined?(:NOFOLLOW)
|
|
23
|
+
|
|
24
|
+
handle = File.open(root, File::RDONLY | File::NOFOLLOW | File::NONBLOCK)
|
|
25
|
+
raise Error, "Installation root must be a directory." unless handle.stat.directory?
|
|
26
|
+
|
|
27
|
+
yield new(handle)
|
|
28
|
+
ensure
|
|
29
|
+
handle.close if handle && !handle.closed?
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def with_parent(relative, create)
|
|
33
|
+
handles = []
|
|
34
|
+
current = self
|
|
35
|
+
parts = relative.split("/")
|
|
36
|
+
parts[0...-1].each do |part|
|
|
37
|
+
child = current.directory(part, create)
|
|
38
|
+
return yield(nil, parts.last) unless child
|
|
39
|
+
|
|
40
|
+
handles << child
|
|
41
|
+
current = self.class.new(child)
|
|
42
|
+
end
|
|
43
|
+
yield current, parts.last
|
|
44
|
+
ensure
|
|
45
|
+
handles.reverse_each(&:close)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def initialize(handle)
|
|
49
|
+
@handle = handle
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def directory(name, create)
|
|
53
|
+
file = open_file(name, File::RDONLY | File::NONBLOCK)
|
|
54
|
+
if file.nil? && create
|
|
55
|
+
status = Native.mkdirat(@handle.fileno, name, 0o755)
|
|
56
|
+
check!(status) unless status.zero? || Fiddle.last_error == Errno::EEXIST::Errno
|
|
57
|
+
file = open_file(name, File::RDONLY | File::NONBLOCK)
|
|
58
|
+
end
|
|
59
|
+
return nil unless file
|
|
60
|
+
return file if file.stat.directory?
|
|
61
|
+
|
|
62
|
+
file.close
|
|
63
|
+
raise Error, "Installation parent must be a directory."
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def read(name)
|
|
67
|
+
file = open_file(name, File::RDONLY | File::NONBLOCK)
|
|
68
|
+
return nil unless file
|
|
69
|
+
|
|
70
|
+
begin
|
|
71
|
+
raise Error, "Integration must be a regular file of at most 64 KiB." unless file.stat.file?
|
|
72
|
+
|
|
73
|
+
content = file.read(65_537) || ""
|
|
74
|
+
raise Error, "Integration must be a regular file of at most 64 KiB." if content.bytesize > 65_536
|
|
75
|
+
|
|
76
|
+
content
|
|
77
|
+
ensure
|
|
78
|
+
file.close
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def write(name, content, executable, force)
|
|
83
|
+
temporary = ".chronos-deploy-#{SecureRandom.hex(16)}"
|
|
84
|
+
file = open_file(temporary, File::WRONLY | File::CREAT | File::EXCL, 0o600)
|
|
85
|
+
raise Error, "Installation directory is unavailable." unless file
|
|
86
|
+
|
|
87
|
+
begin
|
|
88
|
+
file.write(content)
|
|
89
|
+
file.chmod(executable ? 0o755 : 0o644)
|
|
90
|
+
file.close
|
|
91
|
+
if force
|
|
92
|
+
check!(Native.renameat(@handle.fileno, temporary, @handle.fileno, name))
|
|
93
|
+
else
|
|
94
|
+
check!(Native.linkat(@handle.fileno, temporary, @handle.fileno, name, 0))
|
|
95
|
+
end
|
|
96
|
+
ensure
|
|
97
|
+
file.close unless file.closed?
|
|
98
|
+
Native.unlinkat(@handle.fileno, temporary, 0)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
private
|
|
103
|
+
|
|
104
|
+
def open_file(name, flags, mode = 0)
|
|
105
|
+
if name.empty? || name.include?("/") || name.include?("\0") || %w(. ..).include?(name)
|
|
106
|
+
raise Error, "Unsafe installation path component."
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
descriptor = Native.openat(@handle.fileno, name, flags | File::NOFOLLOW, mode)
|
|
110
|
+
return nil if descriptor < 0 && Fiddle.last_error == Errno::ENOENT::Errno
|
|
111
|
+
|
|
112
|
+
check!(descriptor)
|
|
113
|
+
file = File.for_fd(descriptor, (flags & File::WRONLY).zero? ? "r" : "w")
|
|
114
|
+
file.close_on_exec = true
|
|
115
|
+
file
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def check!(status)
|
|
119
|
+
return status if status >= 0
|
|
120
|
+
|
|
121
|
+
raise Error, "Installation path changed, contains a symlink, or is unavailable; inspect it before retrying."
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
require "json"
|
|
2
|
+
require "chronos/core/sql_lexer"
|
|
2
3
|
|
|
3
4
|
module Chronos
|
|
4
5
|
module Rails
|
|
@@ -63,10 +64,7 @@ module Chronos
|
|
|
63
64
|
return false unless connection
|
|
64
65
|
return false unless (query["operation"] || query[:operation]).to_s == "SELECT"
|
|
65
66
|
return false if table(query).empty?
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
normalized = (query["normalized_query"] || query[:normalized_query]).to_s
|
|
69
|
-
normalized.sub(/;\s*\z/, "") !~ /;/
|
|
67
|
+
Core::SqlLexer.new(raw_sql, adapter_name(connection)).single_select?
|
|
70
68
|
end
|
|
71
69
|
|
|
72
70
|
def indexes(connection, table_name)
|
|
@@ -7,7 +7,7 @@ module Chronos
|
|
|
7
7
|
# @limits It does not configure credentials or depend on private Rails APIs.
|
|
8
8
|
# @collaborators Rails application middleware, NotificationsSubscriber, and Chronos facade.
|
|
9
9
|
# @thread_safety A mutex protects the per-application installation registry.
|
|
10
|
-
# @compatibility Rails
|
|
10
|
+
# @compatibility Rails 7; optional integrations are feature-detected.
|
|
11
11
|
# @example
|
|
12
12
|
# Chronos::Rails::Installer.new.install(Rails.application)
|
|
13
13
|
# @errors Missing optional Rails capabilities return false without affecting boot.
|
|
@@ -7,7 +7,7 @@ module Chronos
|
|
|
7
7
|
# @limits It performs no autoloading and does not use private Rails initialization APIs.
|
|
8
8
|
# @collaborators Rails::Railtie and Chronos::Rails::Installer.
|
|
9
9
|
# @thread_safety Rails invokes the initializer during serialized application boot.
|
|
10
|
-
# @compatibility Rails
|
|
10
|
+
# @compatibility Rails 7 public initialization and task APIs.
|
|
11
11
|
# @example
|
|
12
12
|
# require "chronos/rails"
|
|
13
13
|
# @errors Installer contains optional integration failures and allows Rails to boot.
|
data/lib/chronos/rails.rb
CHANGED
|
@@ -9,13 +9,13 @@ require "chronos/rails/installer"
|
|
|
9
9
|
require "chronos/rails/railtie" if defined?(::Rails::Railtie)
|
|
10
10
|
|
|
11
11
|
module Chronos
|
|
12
|
-
#
|
|
12
|
+
# Transitional Rails integration loaded explicitly after Rails is available.
|
|
13
13
|
#
|
|
14
14
|
# @responsibility Namespace Railtie, installer, and notification subscribers.
|
|
15
15
|
# @motivation Keep Rails and ActiveSupport out of the framework-independent core.
|
|
16
|
-
# @limits
|
|
16
|
+
# @limits Uses public Rails integration APIs and feature detection.
|
|
17
17
|
# @thread_safety Installation and subscriptions are protected against duplication.
|
|
18
|
-
# @compatibility Rails
|
|
18
|
+
# @compatibility Rails 7 with its documented Ruby 2.7–3.4 combinations.
|
|
19
19
|
module Rails
|
|
20
20
|
class << self
|
|
21
21
|
# Returns the namespace that owns the Rails application class.
|
data/lib/chronos/rake_tasks.rb
CHANGED
|
@@ -7,7 +7,7 @@ module Chronos
|
|
|
7
7
|
#
|
|
8
8
|
# @responsibility Register the integration verification task with deterministic JSON output.
|
|
9
9
|
# @motivation Give Rails and plain Ruby operators one repeatable end-to-end credential check.
|
|
10
|
-
# @limits
|
|
10
|
+
# @limits Deploy reads explicit CLI ENV; verification uses the configured agent. Neither prints credentials.
|
|
11
11
|
# @collaborators Rake and Chronos.verify_integration.
|
|
12
12
|
# @thread_safety Installation checks the process-global Rake registry and is intended for boot time.
|
|
13
13
|
# @compatibility Rake versions compatible with Ruby 2.2.10 through Ruby 2.6.
|
|
@@ -22,6 +22,7 @@ module Chronos
|
|
|
22
22
|
TASK_NAME = "chronos:verify_integration".freeze
|
|
23
23
|
|
|
24
24
|
def self.install(options = {})
|
|
25
|
+
install_deploy(options)
|
|
25
26
|
return false if ::Rake::Task.task_defined?(TASK_NAME)
|
|
26
27
|
|
|
27
28
|
output = options[:output] || $stdout
|
|
@@ -37,5 +38,16 @@ module Chronos
|
|
|
37
38
|
end
|
|
38
39
|
true
|
|
39
40
|
end
|
|
41
|
+
|
|
42
|
+
def self.install_deploy(options)
|
|
43
|
+
return if ::Rake::Task.task_defined?("chronos:deploy")
|
|
44
|
+
|
|
45
|
+
task "chronos:deploy" do
|
|
46
|
+
require "chronos/cli"
|
|
47
|
+
status = Chronos::CLI.new(options[:output] || $stdout, options[:env] || ENV).run(["deploy", "notify"])
|
|
48
|
+
(options[:exit] || proc { |code| exit(code) }).call(status) unless status.zero?
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
private_class_method :install_deploy
|
|
40
52
|
end
|
|
41
53
|
end
|
data/lib/chronos/version.rb
CHANGED