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.
@@ -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 = 4096
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 = bounded(sql.to_s, MAX_SQL_BYTES)
46
- text = text.gsub(%r{/\*.*?\*/}m, " ").gsub(/--[^\r\n]*/, " ")
47
- text = text.gsub(/'(?:''|[^'])*'/, "?")
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 = deep_freeze(context.is_a?(Hash) ? context : {})
32
- @payload = deep_freeze(payload.is_a?(Hash) ? 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
- return false unless raw_sql.to_s.lstrip =~ /\ASELECT\b/i
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 4.2 through Rails 5.2 without Zeitwerk.
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 4.2 through Rails 5.2; no Zeitwerk requirement.
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
- # Legacy Rails integration loaded explicitly after Rails is available.
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 Version 0.5 targets public APIs present in Rails 4.2 and 5.2.
16
+ # @limits Uses public Rails integration APIs and feature detection.
17
17
  # @thread_safety Installation and subscriptions are protected against duplication.
18
- # @compatibility Rails 4.2 through Rails 5.2 with their supported legacy Rubies.
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.
@@ -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 It never reads environment variables, prints credentials, or modifies application data.
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
@@ -1,4 +1,4 @@
1
1
  module Chronos
2
2
  # Current version of the transitional Chronos Ruby agent.
3
- VERSION = "1.2.1".freeze
3
+ VERSION = "1.2.2".freeze
4
4
  end