chronos-ruby 1.1.2 → 1.1.4
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 +40 -0
- data/README.md +189 -96
- data/docs/adr/legacy-deploy-cli.md +21 -0
- data/docs/compatibility.md +1 -1
- data/docs/deploy-cli.md +36 -0
- data/docs/legacy-test-environment.md +59 -0
- data/docs/modules/sidekiq-legacy.md +2 -2
- data/docs/release-1.1.3-readiness.md +22 -0
- data/docs/release-1.1.4-readiness.md +44 -0
- data/docs/troubleshooting.md +1 -1
- 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/rake_tasks.rb +13 -1
- data/lib/chronos/version.rb +1 -1
- data/lib/generators/chronos/deploy/install/install_generator.rb +26 -0
- metadata +40 -6
data/docs/deploy-cli.md
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# Deploy pela CLI legada
|
|
2
|
+
|
|
3
|
+
A CLI está incluída na release legada 1.1.4 em preparação. Após sua publicação e instalação, configure pelo ambiente:
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
export CHRONOS_PROJECT_ID=seu-projeto
|
|
7
|
+
# Injete CHRONOS_PROJECT_KEY pelo mecanismo de secrets da publicação.
|
|
8
|
+
export CHRONOS_ENVIRONMENT=production
|
|
9
|
+
export CHRONOS_DEPLOY_ID=identidade-estavel-da-publicacao
|
|
10
|
+
export CHRONOS_DEPLOY_REVISION=revisao-do-artefato-publicado
|
|
11
|
+
bundle exec chronos deploy notify
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Não é necessário copiar um script Ruby. A CLI não inicializa Rails e não lê credentials ou initializer. `CHRONOS_SERVICE_NAME` deve corresponder ao serviço do runtime. `CHRONOS_DEPLOY_VERSION` pode substituir a revisão. Host padrão: https://chronosmonitor.com.br; override: CHRONOS_HOST, somente HTTPS sem credenciais, query ou fragmento. TLS mantém a verificação existente.
|
|
15
|
+
|
|
16
|
+
Aliases: DEPLOY_ENVIRONMENT, SERVICE_NAME, DEPLOY_ID, GIT_SHA e APP_VERSION. Conflitos com ENV canônica são erros. Opções públicas explícitas prevalecem sobre ENV e `config/chronos-deploy.yml`; a chave somente entra por CHRONOS_PROJECT_KEY. YAML aceita apenas project_id, host, environment, service_name, deploy_id, revision e version, com valores string. Nunca grave secrets nesse arquivo.
|
|
17
|
+
|
|
18
|
+
`deploy diagnose --offline` exige a configuração completa de notify e verifica sua leitura local, sem autenticar credenciais. `deploy verify` usa o receipt existente, sem criar Deployment; receipt não comprova projeção. `--help` e `--version` são locais. `--timeout` aceita 0 < segundos <= 60; tentativas adicionais do transporte e backlog são desativados na CLI. O timeout limita operações do transporte e flush, não é um deadline global do processo.
|
|
19
|
+
|
|
20
|
+
Saída operacional JSON schema_version=1: success e state; erros de configuração incluem orientação estática e, quando necessário, missing_fields. Verify acrescenta verification_status do contrato de receipt. Códigos: 0 sucesso local ou entrega confirmada pela gem, 1 configuração/uso inválidos, 2 entrega não confirmada, 3 falha interna. `--on-error=warn` converte somente entrega não confirmada em status 0 e preserva success=false. Um false pode acontecer após aceite remoto; não faça retry cego. Reenvio com mesmo deploy ID pode consumir outro evento; dependências podem adicionar eventos. Não há garantia de cobrança única.
|
|
21
|
+
|
|
22
|
+
Gere integrações explicitamente:
|
|
23
|
+
|
|
24
|
+
```sh
|
|
25
|
+
bundle exec chronos deploy install --provider kamal --dry-run
|
|
26
|
+
bundle exec chronos deploy install --provider kamal
|
|
27
|
+
bundle exec chronos deploy install --provider kamal --check
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Providers: kamal, capistrano, github-actions, gitlab-ci, jenkins, azure-devops. Use `--root PATH` para destino explícito. O generator `rails generate chronos:deploy:install --provider=kamal` usa o mesmo instalador. Arquivos iguais não mudam; conflitos falham. A resposta de conflito oferece a proposta de template, omitindo o conteúdo anterior por privacidade. Faça merge manual antes de considerar `--force`. Symlinks no caminho de destino são rejeitados. O instalador usa operações relativas a descritores de diretórios POSIX (Linux/macOS), sem mudar o diretório global do processo, para impedir redirecionamento por troca concorrente de symlinks. Leituras de arquivos existentes são limitadas a 64 KiB e não seguem symlinks. A raiz deve pertencer a um contexto de publicação confiável; isso não protege contra uma conta local já autorizada a modificar o projeto inteiro. Nenhum pipeline é ativado automaticamente.
|
|
31
|
+
|
|
32
|
+
Kamal cria .kamal/hooks/post-deploy; Capistrano cria lib/capistrano/tasks/chronos_deploy.rake após deploy:published, localmente uma vez. Não combine com chronos/capistrano. Os quatro CI criam fragmentos em config/chronos/deploy: faça merge no job real depois da publicação bem sucedida, exportando identidade e revisão do artefato efetivamente publicado. O template GitLab é oculto e precisa ser incorporado ao job existente.
|
|
33
|
+
|
|
34
|
+
Aplicações antigas continuam usando API/receitas existentes até confirmar a capability da versão instalada. O SaaS deve selecionar a receita por faixa explícita de versões, sem remover scripts customizados. Publicação, backport e promoção dependem dos gates em docs/adr/legacy-deploy-cli.md.
|
|
35
|
+
|
|
36
|
+
Preparação de testes: [guia Docker para macOS](legacy-test-environment.md). Não é necessário instalar Rubies antigos ou alterar o Bundler global do Mac.
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# Preparar o ambiente de testes legado no macOS
|
|
2
|
+
|
|
3
|
+
Este procedimento testa a trilha `legacy/1`. Não publica a gem nem usa credenciais reais do SaaS.
|
|
4
|
+
|
|
5
|
+
## 1. Iniciar Docker Desktop
|
|
6
|
+
|
|
7
|
+
Instale ou atualize a edição correspondente ao processador do Mac, conforme a [documentação oficial](https://docs.docker.com/desktop/setup/install/mac-install/). Abra o aplicativo e aguarde o engine iniciar:
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
open -a Docker
|
|
11
|
+
docker version
|
|
12
|
+
docker info
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
`docker version` precisa mostrar Client e Server. Se aparecer erro no socket ou “Cannot connect”, o engine ainda não está disponível. Confirme o contexto com `docker context ls`; selecione o contexto local do Docker Desktop caso outro contexto esteja ativo.
|
|
16
|
+
|
|
17
|
+
## 2. Entrar no repositório e executar o primeiro Ruby
|
|
18
|
+
|
|
19
|
+
```sh
|
|
20
|
+
cd /Users/antoniojefferson/Develop/Projects/Rails/chronos-ruby
|
|
21
|
+
sh script/test_legacy_docker 2.2.10
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
O helper usa linux/amd64 explicitamente para uniformizar as imagens antigas em Mac Intel e Apple Silicon. A [documentação de plataformas do Docker](https://docs.docker.com/build/building/multi-platform/) explica a emulação; ela pode ser mais lenta.
|
|
25
|
+
|
|
26
|
+
O repositório é montado somente para leitura. O helper copia os arquivos para /work no container, exclui configuração local do Bundler, .git, .aws e .env, e remove somente os lockfiles dessa cópia. Isso é necessário porque o lockfile atual do repositório foi gerado com ferramentas modernas e não corresponde ao gemspec legado. Nenhum lockfile local é alterado.
|
|
27
|
+
|
|
28
|
+
O container instala Bundler 1.17.3 e resolve as dependências do gemspec, incluindo Rake 12.3, RuboCop 0.49 e Minitest 5.10.3. O script executa a suíte existente, os testes da CLI/adaptadores/receptor HTTPS e o smoke da gem instalada. Certificado de teste é gerado temporariamente e explicitamente confiado; verificação TLS não é desligada.
|
|
29
|
+
|
|
30
|
+
## 3. Executar a matriz Ruby completa
|
|
31
|
+
|
|
32
|
+
```sh
|
|
33
|
+
for version in 2.2.10 2.3.8 2.4.10 2.5.9 2.6.10; do
|
|
34
|
+
sh script/test_legacy_docker "$version" || break
|
|
35
|
+
done
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Pare no primeiro erro e preserve o log. Uma execução interrompida não aprova os demais Rubies. Registre por imagem: versão Ruby, OpenSSL/JSON, resultado da suíte e resultado do pacote instalado.
|
|
39
|
+
|
|
40
|
+
## 4. Executar os pares Rails suportados
|
|
41
|
+
|
|
42
|
+
```sh
|
|
43
|
+
sh script/test_legacy_docker 2.2.10 examples/rails-4.2
|
|
44
|
+
sh script/test_legacy_docker 2.3.8 examples/rails-4.2
|
|
45
|
+
sh script/test_legacy_docker 2.5.9 examples/rails-5.2
|
|
46
|
+
sh script/test_legacy_docker 2.6.10 examples/rails-5.2
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Esses comandos executam o smoke existente da aplicação e o novo smoke do generator de deploy, em diretório temporário. Eles não ampliam os pares suportados.
|
|
50
|
+
|
|
51
|
+
## 5. Tratar falhas de preparação
|
|
52
|
+
|
|
53
|
+
- Falha no pull ou acesso a rubygems.org: verificar conexão, proxy e disponibilidade do registro.
|
|
54
|
+
- Erro de certificado/OpenSSL nas imagens antigas: registrar imagem e mensagem, corrigir CA ou preparar imagem derivada com certificados confiáveis compatíveis. Não usar HTTP, ssl_verify=false ou mirrors sem validação TLS.
|
|
55
|
+
- Dependência incompatível: identificar a dependência e fixar versão compatível na definição da imagem/matriz antes de repetir. Não modernizar o gemspec legado para fazer o teste passar.
|
|
56
|
+
- Erro de arquitetura: confirmar execução linux/amd64 e suporte de emulação no Docker Desktop.
|
|
57
|
+
- Erro RuboCop/RSpec/CLI: é resultado da validação do código, não falha de instalação; corrigir antes de promover a release.
|
|
58
|
+
|
|
59
|
+
O helper prepara execuções isoladas, mas ainda não substitui imagens reproduzíveis fixadas por digest e dependências resolvidas por runtime. A aprovação final da matriz deve preservar esses registros. Depois dos gates locais, permanecem os testes SaaS de receipt/ingestão/ledger/projeção e os smokes autorizados dos fornecedores.
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
# Sidekiq 4/5 legacy integration
|
|
2
2
|
|
|
3
|
-
Version `0.6.0.pre.1` starts the legacy jobs line with optional Sidekiq 4 and 5 client and server middleware. Version `1.1.
|
|
3
|
+
Version `0.6.0.pre.1` starts the legacy jobs line with optional Sidekiq 4 and 5 client and server middleware. Version `1.1.3` installs it automatically when Sidekiq is available in a Rails application:
|
|
4
4
|
|
|
5
5
|
```ruby
|
|
6
6
|
gem "sidekiq", "~> 5.0"
|
|
7
|
-
gem "chronos-ruby", "~> 1.1.
|
|
7
|
+
gem "chronos-ruby", "~> 1.1.4", :require => "chronos/rails"
|
|
8
8
|
```
|
|
9
9
|
|
|
10
10
|
Plain Ruby applications continue to use `require "chronos/sidekiq"` explicitly. Both entry points install middleware through the public `configure_client` and `configure_server` APIs. They do nothing when Sidekiq is unavailable, and the framework-independent core never requires Sidekiq. Installation adds no Chronos thread or Redis/database connection per job; delivery continues through the agent's existing fixed worker pool.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Version 1.1.3 release evidence
|
|
2
|
+
|
|
3
|
+
Version `1.1.3` is a backward-compatible documentation and package-metadata maintenance release of the stable legacy line. It clarifies that the gem captures exceptions, metrics, and telemetry from Ruby and Rails applications and identifies https://chronosmonitor.com.br as the official Chronos Monitor platform for receiving and visualizing those observability signals.
|
|
4
|
+
|
|
5
|
+
The release preserves Ruby 2.2.10–2.6.10, Rails 4.2/5.2, Sidekiq 4.2.10/5.2.10, and protocol `schema_version: "1.0"`. It changes no runtime behavior, public API, collected field, framework dependency, or compatibility claim.
|
|
6
|
+
|
|
7
|
+
## Required gates
|
|
8
|
+
|
|
9
|
+
- complete unit, integration, contract, privacy, concurrency, transport, and lint suite on Ruby 2.2.10–2.6.10;
|
|
10
|
+
- real Rails 4.2 applications on Ruby 2.2.10/2.3.8 and Rails 5.2 applications on Ruby 2.5.9/2.6.10;
|
|
11
|
+
- real Sidekiq 4.2.10 and 5.2.10 client/server middleware smoke tests;
|
|
12
|
+
- documentation and pull request description verification;
|
|
13
|
+
- dependency audit with the CI-pinned tooling;
|
|
14
|
+
- repeatable query-analysis and Rack benchmarks plus the bounded fake-endpoint privacy/load gate;
|
|
15
|
+
- gem build, package inspection, SHA-256 checksum, SPDX SBOM, and RubyGems Trusted Publishing.
|
|
16
|
+
|
|
17
|
+
The release tag must be exactly `v1.1.3` and match `Chronos::VERSION`. Do not publish manually. After the reviewed commit and all gates are green:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
git tag v1.1.3
|
|
21
|
+
git push origin v1.1.3
|
|
22
|
+
```
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# Version 1.1.4 release preparation
|
|
2
|
+
|
|
3
|
+
Version `1.1.4` prepares deploy automation for the stable legacy line. It packages the `chronos` CLI, explicit ENV/YAML configuration, optional provider installers and Rails/Rake adapters. It preserves Ruby >= 2.2.10 and < 2.7, the existing Rails/Sidekiq pairs, and protocol `schema_version: "1.0"`. This is a limited maintenance exception; it does not reopen general feature development on the legacy line.
|
|
4
|
+
|
|
5
|
+
## Operational contract
|
|
6
|
+
|
|
7
|
+
- No notification, generator execution, or application file changes on install, require, help, or version.
|
|
8
|
+
- Notify requires explicit project credentials, environment, stable publication ID, and actual published revision/version. It uses `Chronos.notify_deploy` and always closes the agent.
|
|
9
|
+
- Verify reuses `Chronos.verify_integration`, validates its receipt, disables dependency reporting, and does not create a deploy. A receipt is not proof of SaaS projection.
|
|
10
|
+
- CLI transport requires verified HTTPS. Retries and backlog are disabled for this short-lived process; transport and flush operations have bounded timeouts, not a single global process deadline.
|
|
11
|
+
- Exit codes: 0 confirmed/local success, 1 invalid configuration, 2 unconfirmed delivery, 3 internal failure. Warning policy only converts code 2 to 0 and retains `success=false`.
|
|
12
|
+
- Repeated notify calls keep deployment identity but can generate another event and consumption. Dependency events can add consumption. Do not retry blindly after an unconfirmed result.
|
|
13
|
+
- Installers preserve conflicting files, offer a privacy-safe proposed template, and reject destination symlinks. POSIX descriptor-relative operations prevent parent-path swaps from redirecting creation or publication. CI fragments require manual merge after the actual publication.
|
|
14
|
+
|
|
15
|
+
## Evidence and pending gates
|
|
16
|
+
|
|
17
|
+
Local Ruby 3.4 checks are supplementary evidence only. They do not establish compatibility with the supported legacy runtimes.
|
|
18
|
+
|
|
19
|
+
Before tagging, require:
|
|
20
|
+
|
|
21
|
+
- core suite, CLI, transport, privacy, shutdown/fork, and RuboCop 0.49 gates on Ruby 2.2.10, 2.3.8, 2.4.10, 2.5.9, and 2.6.10;
|
|
22
|
+
- Rails 4.2 on Ruby 2.2.10/2.3.8 and Rails 5.2 on Ruby 2.5.9/2.6.10, including deploy generator smoke;
|
|
23
|
+
- Sidekiq 4.2.10 on Ruby 2.2.10 and Sidekiq 5.2.10 on Ruby 2.5.9;
|
|
24
|
+
- built gem installed into clean supported runtimes, executable/help/offline diagnostics and HTTPS notification/receipt tests;
|
|
25
|
+
- dependency audit with the CI-pinned tooling, documentation and PR-body verification;
|
|
26
|
+
- query-analysis/Rack benchmarks and fake-endpoint load/privacy gates;
|
|
27
|
+
- authorized provider smoke tests and SaaS evidence for receipt, ingestion, identity, consumption, and projection; keep these distinct;
|
|
28
|
+
- reviewed source, package inspection, checksum, SPDX SBOM, and maintainer approval.
|
|
29
|
+
|
|
30
|
+
Use the [Docker test instructions](../README.md#tests). The working-tree lockfile originated from modern tooling; the isolated Docker helper resolves dependencies only in its container copy. It does not prove the direct CI workflow's dependency resolution will pass; that remains a required check.
|
|
31
|
+
|
|
32
|
+
## SaaS rollout and transitional backport
|
|
33
|
+
|
|
34
|
+
The CLI capability is introduced by legacy release `1.1.4`. The transitional main-line backport is not included in this release. The SaaS must select recipes using explicitly validated release ranges for each line, never a single cross-line minimum. Keep old script recipes for older clients and do not delete customer-edited scripts.
|
|
35
|
+
|
|
36
|
+
## Publication
|
|
37
|
+
|
|
38
|
+
No release has been published by this preparation. Never replace previously published artifacts. After all gates and approval, the release tag must be exactly `v1.1.4` and match `Chronos::VERSION`. Pushing that tag triggers the release workflow and its publication job; do not create or push it before approval. Publish through the configured Trusted Publishing workflow rather than a manual gem push.
|
|
39
|
+
|
|
40
|
+
## Security regression coverage
|
|
41
|
+
|
|
42
|
+
`spec/security/` covers SQL literal confidentiality through the final APM envelope, complete original-query validation before inspection, sensitive symbols through final serialization, cyclic/deep diagnostic input and caller ownership, and deterministic symlink/concurrent-file interleavings. Conservative SQL handling omits oversized or ambiguous queries; plan evidence remains best effort. The installer uses fixed POSIX calls through standard-library Fiddle and does not execute shell commands. Unsupported filesystem backends must fail closed.
|
|
43
|
+
|
|
44
|
+
Security-fix validation: the core/security suite passed on MRI 2.2.10, 2.3.8, 2.4.10, 2.5.9 and 2.6.10 in isolated Linux/amd64 containers: 221 RSpec examples, the complete RuboCop 0.49.1 inspection (225 files), 25 standalone source tests (127 assertions), gem build/install, local diagnostics, and six installed-executable HTTPS tests (36 assertions) per runtime. The expanded installed-gem smoke also verifies explicit Kamal hook generation and idempotent check in a path containing spaces. These results do not close the separate Rails/Sidekiq, supplier, SaaS, audit, benchmark, or remote CI gates.
|
data/docs/troubleshooting.md
CHANGED
|
@@ -56,7 +56,7 @@ Require `chronos/capistrano` after the Capistrano/Rake task DSL is loaded. The i
|
|
|
56
56
|
|
|
57
57
|
## Sidekiq telemetry is missing
|
|
58
58
|
|
|
59
|
-
In Rails, require `chronos/rails` and configure Chronos before jobs run; version 1.1.
|
|
59
|
+
In Rails, require `chronos/rails` and configure Chronos before jobs run; version 1.1.3 detects Sidekiq and installs its public client/server middleware automatically. In plain Ruby, require `chronos/sidekiq` explicitly after Sidekiq is available. Sidekiq remains optional in both cases. A job already enqueued before client middleware installation may lack propagated trace context, but server timing and failure capture can still run.
|
|
60
60
|
|
|
61
61
|
## A failed Sidekiq job appears twice
|
|
62
62
|
|
data/exe/chronos
ADDED
data/lib/chronos/agent.rb
CHANGED
|
@@ -242,12 +242,17 @@ module Chronos
|
|
|
242
242
|
end
|
|
243
243
|
|
|
244
244
|
def context_for_capture(additional)
|
|
245
|
-
|
|
245
|
+
current = context_hash(@context_store.get)
|
|
246
|
+
additions = context_hash(additional)
|
|
247
|
+
buffer = breadcrumb_buffer(additions) || breadcrumb_buffer(current)
|
|
248
|
+
copier = Core::BoundedCopy.new
|
|
249
|
+
merged = deep_merge(copier.call(current), copier.call(additions))
|
|
246
250
|
merged.delete(:__chronos_captured_exceptions)
|
|
247
251
|
merged.delete("__chronos_captured_exceptions")
|
|
248
252
|
merged.delete(:__chronos_captured_events)
|
|
249
253
|
merged.delete("__chronos_captured_events")
|
|
250
|
-
|
|
254
|
+
merged.delete(:__chronos_breadcrumbs)
|
|
255
|
+
merged.delete("__chronos_breadcrumbs")
|
|
251
256
|
if buffer.respond_to?(:to_a)
|
|
252
257
|
merged[:context] = context_hash(merged[:context]).merge("breadcrumbs" => buffer.to_a)
|
|
253
258
|
end
|
|
@@ -256,9 +261,13 @@ module Chronos
|
|
|
256
261
|
context_hash(additional)
|
|
257
262
|
end
|
|
258
263
|
|
|
264
|
+
def breadcrumb_buffer(context)
|
|
265
|
+
context[:__chronos_breadcrumbs] || context["__chronos_breadcrumbs"]
|
|
266
|
+
end
|
|
267
|
+
|
|
259
268
|
def telemetry_context(additional)
|
|
260
269
|
merged = context_for_capture(additional)
|
|
261
|
-
context = context_hash(merged.delete(:context) || merged.delete("context"))
|
|
270
|
+
context = context_hash(merged.delete(:context) || merged.delete("context")).dup
|
|
262
271
|
parameters = merged.delete(:parameters) || merged.delete("parameters")
|
|
263
272
|
user = merged.delete(:user) || merged.delete("user")
|
|
264
273
|
context["parameters"] = parameters if parameters.is_a?(Hash) && !parameters.empty?
|
|
@@ -266,9 +275,11 @@ module Chronos
|
|
|
266
275
|
context.merge(merged)
|
|
267
276
|
end
|
|
268
277
|
|
|
269
|
-
def deep_merge(left, right)
|
|
278
|
+
def deep_merge(left, right, depth = 0)
|
|
279
|
+
return {} if depth >= Core::BoundedCopy::MAX_DEPTH
|
|
280
|
+
|
|
270
281
|
left.merge(right) do |_key, old_value, new_value|
|
|
271
|
-
old_value.is_a?(Hash) && new_value.is_a?(Hash) ? deep_merge(old_value, new_value) : new_value
|
|
282
|
+
old_value.is_a?(Hash) && new_value.is_a?(Hash) ? deep_merge(old_value, new_value, depth + 1) : new_value
|
|
272
283
|
end
|
|
273
284
|
end
|
|
274
285
|
|
data/lib/chronos/cli.rb
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
require "optparse"
|
|
2
|
+
require "json"
|
|
3
|
+
require "chronos/version"
|
|
4
|
+
require "chronos/deploy/configuration"
|
|
5
|
+
require "chronos/deploy/installer"
|
|
6
|
+
|
|
7
|
+
module Chronos
|
|
8
|
+
# @responsibility Parse explicit deployment commands and return sanitized operational results.
|
|
9
|
+
# Local command parser; operational responses use a versioned, allowlisted JSON envelope.
|
|
10
|
+
class CLI
|
|
11
|
+
HELP = "chronos deploy notify|verify|diagnose --offline|install --provider NAME\n" \
|
|
12
|
+
"Options: --root PATH, --project-id ID, --environment NAME, --service-name NAME,\n" \
|
|
13
|
+
"--deploy-id ID, --revision SHA, --version VERSION, --host URL, --timeout SECONDS,\n" \
|
|
14
|
+
"--json, --on-error=warn, --dry-run, --check, --force\n" \
|
|
15
|
+
"Project key: CHRONOS_PROJECT_KEY only. No application boot.\n" \
|
|
16
|
+
"Exit: 0 confirmed/local success; 1 invalid configuration; 2 unconfirmed delivery; 3 internal failure.\n" \
|
|
17
|
+
"warn returns 0 for unconfirmed delivery, retaining success=false. Never retry blindly.\n".freeze
|
|
18
|
+
|
|
19
|
+
def initialize(output = $stdout, env = ENV)
|
|
20
|
+
@output = output
|
|
21
|
+
@env = env
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def run(arguments)
|
|
25
|
+
args = arguments.dup
|
|
26
|
+
return local(HELP) if help?(args)
|
|
27
|
+
return local(Chronos::VERSION) if args == ["--version"]
|
|
28
|
+
raise ArgumentError unless args.shift == "deploy"
|
|
29
|
+
|
|
30
|
+
command = args.shift
|
|
31
|
+
raise ArgumentError unless %w(notify verify diagnose install).include?(command)
|
|
32
|
+
|
|
33
|
+
options = parse_options(args)
|
|
34
|
+
return local(HELP) if options[:help]
|
|
35
|
+
raise ArgumentError unless args.empty? && options[:timeout] > 0 && options[:timeout] <= 60
|
|
36
|
+
|
|
37
|
+
validate_options!(command, options)
|
|
38
|
+
execute(command, options)
|
|
39
|
+
rescue Deploy::Error => e
|
|
40
|
+
result(false, "invalid_configuration_or_usage", 1, e.details.merge(:message => e.message))
|
|
41
|
+
rescue OptionParser::ParseError, ArgumentError, Psych::Exception
|
|
42
|
+
result(false, "invalid_configuration_or_usage", 1,
|
|
43
|
+
:message => "Invalid options or public configuration; see chronos --help.")
|
|
44
|
+
rescue StandardError
|
|
45
|
+
result(false, "internal_failure", 3)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def help?(args)
|
|
51
|
+
args.empty? || %w(help --help -h).include?(args.first)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def parse_options(args)
|
|
55
|
+
options = {:timeout => 5.0}
|
|
56
|
+
parser = OptionParser.new
|
|
57
|
+
%w(root project-id environment service-name deploy-id revision version host provider).each do |name|
|
|
58
|
+
parser.on("--#{name} VALUE") { |value| options[name.tr("-", "_").to_sym] = value }
|
|
59
|
+
end
|
|
60
|
+
%w(json offline dry-run check force).each do |name|
|
|
61
|
+
parser.on("--#{name}") { options[name.tr("-", "_").to_sym] = true }
|
|
62
|
+
end
|
|
63
|
+
parser.on("--timeout SECONDS", Float) { |value| options[:timeout] = value }
|
|
64
|
+
parser.on("--on-error POLICY", %w(warn fail)) { |value| options[:on_error] = value }
|
|
65
|
+
parser.on("--help") { options[:help] = true }
|
|
66
|
+
parser.parse!(args)
|
|
67
|
+
options
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def execute(command, options)
|
|
71
|
+
return result(true, Deploy::Installer.new.install(options), 0) if command == "install"
|
|
72
|
+
|
|
73
|
+
values = Deploy::Configuration.read(options, @env)
|
|
74
|
+
if command == "diagnose"
|
|
75
|
+
raise Deploy::Error, "Diagnose requires --offline." unless options[:offline]
|
|
76
|
+
|
|
77
|
+
Deploy::Configuration.validate!(values, "notify")
|
|
78
|
+
return result(true, "local_configuration_only", 0,
|
|
79
|
+
:message => "Local configuration is complete; credentials and delivery were not authenticated.")
|
|
80
|
+
end
|
|
81
|
+
deliver(command, values, options)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def deliver(command, values, options)
|
|
85
|
+
require "chronos/deploy/runner"
|
|
86
|
+
delivery = Deploy::Runner.new.call(command, values, options[:timeout])
|
|
87
|
+
success = command == "verify" ? delivery.success? : delivery == true
|
|
88
|
+
details = command == "verify" ? {:verification_status => delivery.status} : {}
|
|
89
|
+
code = success || options[:on_error] == "warn" ? 0 : 2
|
|
90
|
+
result(success, success ? "confirmed" : "unconfirmed", code, details)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def validate_options!(command, options)
|
|
94
|
+
common = [:root, :json, :help]
|
|
95
|
+
operational = [:project_id, :environment, :service_name, :deploy_id, :revision, :version, :host, :timeout]
|
|
96
|
+
allowed = command == "install" ? common + [:provider, :dry_run, :check, :force, :timeout] : common + operational
|
|
97
|
+
allowed += [:offline] if command == "diagnose"
|
|
98
|
+
allowed += [:on_error] if %w(notify verify).include?(command)
|
|
99
|
+
unless (options.keys - allowed).empty?
|
|
100
|
+
raise Deploy::Error, "Option does not apply to this command; see chronos --help."
|
|
101
|
+
end
|
|
102
|
+
raise Deploy::Error, "Use --check without --force." if options[:force] && options[:check]
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def local(message)
|
|
106
|
+
@output.puts(message)
|
|
107
|
+
0
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def result(success, state, code, details = {})
|
|
111
|
+
data = {:schema_version => 1, :success => success, :state => state}.merge(details)
|
|
112
|
+
# JSON is the default operational format; --json makes that intent explicit.
|
|
113
|
+
@output.puts(JSON.generate(data))
|
|
114
|
+
code
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
module Chronos
|
|
2
|
+
module Core
|
|
3
|
+
# @responsibility Copy untrusted diagnostic trees without cycles, unbounded recursion or caller mutation.
|
|
4
|
+
# Ruby 2.2-compatible structural budgets apply before immutable event construction.
|
|
5
|
+
class BoundedCopy
|
|
6
|
+
MAX_DEPTH = 10
|
|
7
|
+
MAX_ITEMS = 100
|
|
8
|
+
MAX_NODES = 2_000
|
|
9
|
+
MAX_STRING_BYTES = 8192
|
|
10
|
+
|
|
11
|
+
def call(value)
|
|
12
|
+
copy(value, 0, :nodes => 0, :seen => {}.compare_by_identity)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
private
|
|
16
|
+
|
|
17
|
+
def copy(value, depth, state)
|
|
18
|
+
state[:nodes] += 1
|
|
19
|
+
return "<node limit reached>".freeze if state[:nodes] > MAX_NODES
|
|
20
|
+
return "<maximum depth reached>".freeze if depth >= MAX_DEPTH
|
|
21
|
+
return container(value, depth, state) if value.is_a?(Hash) || value.is_a?(Array)
|
|
22
|
+
|
|
23
|
+
scalar(value)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def container(value, depth, state)
|
|
27
|
+
return "<circular reference>".freeze if state[:seen][value]
|
|
28
|
+
|
|
29
|
+
state[:seen][value] = true
|
|
30
|
+
result = value.is_a?(Hash) ? copy_hash(value, depth, state) : copy_array(value, depth, state)
|
|
31
|
+
state[:seen].delete(value)
|
|
32
|
+
result.freeze
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def copy_hash(value, depth, state)
|
|
36
|
+
result = {}
|
|
37
|
+
value.each do |key, child|
|
|
38
|
+
break if result.length >= MAX_ITEMS || state[:nodes] >= MAX_NODES
|
|
39
|
+
|
|
40
|
+
result[copy(key, depth + 1, state)] = copy(child, depth + 1, state)
|
|
41
|
+
end
|
|
42
|
+
result
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def copy_array(value, depth, state)
|
|
46
|
+
result = []
|
|
47
|
+
value.each do |child|
|
|
48
|
+
break if result.length >= MAX_ITEMS || state[:nodes] >= MAX_NODES
|
|
49
|
+
|
|
50
|
+
result << copy(child, depth + 1, state)
|
|
51
|
+
end
|
|
52
|
+
result
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def scalar(value)
|
|
56
|
+
case value
|
|
57
|
+
when String
|
|
58
|
+
return "<string limit reached>".freeze if value.bytesize > MAX_STRING_BYTES
|
|
59
|
+
|
|
60
|
+
value.dup.freeze
|
|
61
|
+
when NilClass, TrueClass, FalseClass, Symbol, Numeric
|
|
62
|
+
value
|
|
63
|
+
else
|
|
64
|
+
"<#{value.class.name}>".freeze
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
data/lib/chronos/core/notice.rb
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
require "chronos/core/bounded_copy"
|
|
2
|
+
|
|
1
3
|
module Chronos
|
|
2
4
|
module Core
|
|
3
5
|
# Immutable value object containing a normalized Ruby exception.
|
|
@@ -22,8 +24,9 @@ module Chronos
|
|
|
22
24
|
attr_reader(*ATTRIBUTES)
|
|
23
25
|
|
|
24
26
|
def initialize(attributes)
|
|
27
|
+
copied = BoundedCopy.new.call(attributes)
|
|
25
28
|
ATTRIBUTES.each do |attribute|
|
|
26
|
-
instance_variable_set("@#{attribute}",
|
|
29
|
+
instance_variable_set("@#{attribute}", copied[attribute])
|
|
27
30
|
end
|
|
28
31
|
freeze
|
|
29
32
|
end
|
|
@@ -33,33 +36,6 @@ module Chronos
|
|
|
33
36
|
result[attribute] = public_send(attribute)
|
|
34
37
|
end
|
|
35
38
|
end
|
|
36
|
-
|
|
37
|
-
private
|
|
38
|
-
|
|
39
|
-
def immutable_copy(value)
|
|
40
|
-
case value
|
|
41
|
-
when Hash
|
|
42
|
-
value.each_with_object({}) do |(key, child), copy|
|
|
43
|
-
copy[immutable_copy(key)] = immutable_copy(child)
|
|
44
|
-
end
|
|
45
|
-
when Array
|
|
46
|
-
value.map { |child| immutable_copy(child) }
|
|
47
|
-
when String
|
|
48
|
-
value.dup
|
|
49
|
-
when NilClass, TrueClass, FalseClass, Symbol, Numeric
|
|
50
|
-
value
|
|
51
|
-
else
|
|
52
|
-
value.dup
|
|
53
|
-
end.freeze
|
|
54
|
-
rescue TypeError
|
|
55
|
-
value
|
|
56
|
-
rescue StandardError
|
|
57
|
-
begin
|
|
58
|
-
value.dup.freeze
|
|
59
|
-
rescue StandardError
|
|
60
|
-
value
|
|
61
|
-
end
|
|
62
|
-
end
|
|
63
39
|
end
|
|
64
40
|
end
|
|
65
41
|
end
|
|
@@ -53,7 +53,7 @@ module Chronos
|
|
|
53
53
|
def sanitize_type(value, depth, state)
|
|
54
54
|
return sanitize_hash(value, depth, state) if value.is_a?(Hash)
|
|
55
55
|
return sanitize_array(value, depth, state) if value.is_a?(Array)
|
|
56
|
-
return @sensitive_value_filter.call(value) if value.is_a?(String)
|
|
56
|
+
return @sensitive_value_filter.call(value.to_s) if value.is_a?(String) || value.is_a?(Symbol)
|
|
57
57
|
|
|
58
58
|
value
|
|
59
59
|
end
|
|
@@ -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.
|