Initial commit
This commit is contained in:
71
lib/opendax/config_loader.rb
Normal file
71
lib/opendax/config_loader.rb
Normal file
@@ -0,0 +1,71 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'yaml'
|
||||
|
||||
module Opendax
|
||||
# Loads config/app.yml and deep-merges an environment overlay when present.
|
||||
#
|
||||
# Overlay resolution (first match wins):
|
||||
# 1. OPENDAX_ENV=local|staging|production → config/app.{env}.yml
|
||||
# 2. If OPENDAX_ENV unset and config/app.local.yml exists → use local overlay
|
||||
#
|
||||
# Never commit real secrets to config/app.yml — use app.local.yml or app.production.yml.
|
||||
module ConfigLoader
|
||||
BASE_PATH = 'config/app.yml'
|
||||
|
||||
module_function
|
||||
|
||||
def load
|
||||
base = load_yaml_hash!(BASE_PATH, 'base config')
|
||||
overlay_path = resolve_overlay_path
|
||||
return base unless overlay_path && File.exist?(overlay_path)
|
||||
|
||||
overlay = load_yaml_hash!(overlay_path, 'overlay config')
|
||||
deep_merge(base, overlay)
|
||||
end
|
||||
|
||||
def load_yaml_hash!(path, label)
|
||||
data = YAML.load_file(path)
|
||||
return data if data.is_a?(Hash)
|
||||
|
||||
raise TypeError,
|
||||
"#{label} #{path} must be a YAML mapping (Hash), got #{data.class}. " \
|
||||
'Check syntax — quote values that contain colons (e.g. database passwords).'
|
||||
end
|
||||
|
||||
def resolve_overlay_path
|
||||
env = ENV['OPENDAX_ENV'].to_s.strip
|
||||
if env.empty?
|
||||
return 'config/app.local.yml' if File.exist?('config/app.local.yml')
|
||||
|
||||
return nil
|
||||
end
|
||||
|
||||
"config/app.#{env}.yml"
|
||||
end
|
||||
|
||||
def active_overlay
|
||||
path = resolve_overlay_path
|
||||
path if path && File.exist?(path)
|
||||
end
|
||||
|
||||
def deep_merge(base, overlay)
|
||||
unless base.is_a?(Hash) && overlay.is_a?(Hash)
|
||||
raise TypeError,
|
||||
"deep_merge expects Hash + Hash, got #{base.class} + #{overlay.class}"
|
||||
end
|
||||
|
||||
base.merge(overlay) do |key, old_val, new_val|
|
||||
if old_val.is_a?(Hash) && new_val.is_a?(Hash)
|
||||
deep_merge(old_val, new_val)
|
||||
elsif old_val.is_a?(Hash) && !new_val.is_a?(Hash)
|
||||
raise TypeError,
|
||||
"Config type mismatch at #{key}: base has Hash but overlay has " \
|
||||
"#{new_val.class}. Quote scalar values in app.staging.yml (passwords with ':' need quotes)."
|
||||
else
|
||||
new_val
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
46
lib/opendax/payload.rb
Normal file
46
lib/opendax/payload.rb
Normal file
@@ -0,0 +1,46 @@
|
||||
require 'jwt'
|
||||
|
||||
module Opendax
|
||||
|
||||
class Error < StandardError
|
||||
end
|
||||
|
||||
class Payload
|
||||
def initialize(params)
|
||||
@secret = params[:secret]
|
||||
@expire = params[:expire] || 600
|
||||
|
||||
raise Opendax::Error.new unless @secret
|
||||
raise Opendax::Error.new unless @expire > 0
|
||||
end
|
||||
|
||||
def generate!(params)
|
||||
raise Opendax::Error.new unless params[:service]
|
||||
raise Opendax::Error.new unless params[:image]
|
||||
|
||||
token = params.merge({
|
||||
'iat': Time.now.to_i,
|
||||
'exp': (Time.now + @expire).to_i
|
||||
})
|
||||
|
||||
JWT.encode token, @secret, 'HS256'
|
||||
end
|
||||
|
||||
def decode!(token)
|
||||
JWT.decode(token, @secret, true, {
|
||||
algorithm: 'HS256'
|
||||
}).first
|
||||
end
|
||||
|
||||
def safe_decode(token)
|
||||
begin
|
||||
decode!(token)
|
||||
rescue JWT::ExpiredSignature
|
||||
rescue JWT::ImmatureSignature
|
||||
rescue JWT::VerificationError
|
||||
rescue JWT::DecodeError
|
||||
nil
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
79
lib/opendax/renderer.rb
Normal file
79
lib/opendax/renderer.rb
Normal file
@@ -0,0 +1,79 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'openssl'
|
||||
require 'sshkey'
|
||||
require 'pathname'
|
||||
require 'yaml'
|
||||
require 'base64'
|
||||
require 'fileutils'
|
||||
|
||||
module Opendax
|
||||
# Renderer is class for rendering Opendax templates.
|
||||
class Renderer
|
||||
TEMPLATE_PATH = Pathname.new('./templates')
|
||||
|
||||
BARONG_KEY = 'config/secrets/barong.key'
|
||||
APPLOGIC_KEY = 'config/secrets/applogic.key'
|
||||
SSH_KEY = 'config/secrets/app.key'
|
||||
|
||||
def render
|
||||
@config ||= config
|
||||
@utils ||= utils
|
||||
@name ||= @config['app']['name'].downcase
|
||||
@barong_key ||= OpenSSL::PKey::RSA.new(File.read(BARONG_KEY), '')
|
||||
@applogic_key ||= OpenSSL::PKey::RSA.new(File.read(APPLOGIC_KEY), '')
|
||||
@barong_private_key ||= Base64.urlsafe_encode64(@barong_key.to_pem)
|
||||
@barong_public_key ||= Base64.urlsafe_encode64(@barong_key.public_key.to_pem)
|
||||
@applogic_private_key ||= Base64.urlsafe_encode64(@applogic_key.to_pem)
|
||||
@applogic_public_key ||= Base64.urlsafe_encode64(@applogic_key.public_key.to_pem)
|
||||
|
||||
Dir.glob("#{TEMPLATE_PATH}/**/*.erb", File::FNM_DOTMATCH).each do |file|
|
||||
output_file = template_name(file)
|
||||
FileUtils.chmod 0o644, output_file if File.exist?(output_file)
|
||||
render_file(file, output_file)
|
||||
FileUtils.chmod 0o444, output_file if @config['render_protect']
|
||||
end
|
||||
end
|
||||
|
||||
def render_file(file, out_file)
|
||||
puts "Rendering #{out_file}"
|
||||
result = ERB.new(File.read(file), trim_mode: '-').result(binding)
|
||||
dir = File.dirname(out_file)
|
||||
FileUtils.mkdir(dir) unless Dir.exist?(dir)
|
||||
File.write(out_file, result)
|
||||
end
|
||||
|
||||
def ssl_helper(arg)
|
||||
@config['ssl']['enabled'] ? arg << 's' : arg
|
||||
end
|
||||
|
||||
def template_name(file)
|
||||
path = Pathname.new(file)
|
||||
out_path = path.relative_path_from(TEMPLATE_PATH).sub('.erb', '')
|
||||
|
||||
File.join('.', out_path)
|
||||
end
|
||||
|
||||
def render_keys
|
||||
generate_key(BARONG_KEY)
|
||||
generate_key(APPLOGIC_KEY)
|
||||
generate_key(SSH_KEY, public: true)
|
||||
end
|
||||
|
||||
def generate_key(filename, public: false)
|
||||
return if File.file?(filename)
|
||||
|
||||
key = SSHKey.generate(type: 'RSA', bits: 2048)
|
||||
File.write(filename, key.private_key)
|
||||
File.write("#{filename}.pub", key.ssh_public_key) if public
|
||||
end
|
||||
|
||||
def config
|
||||
Opendax::ConfigLoader.load
|
||||
end
|
||||
|
||||
def utils
|
||||
YAML.load_file('./config/utils.yml')
|
||||
end
|
||||
end
|
||||
end
|
||||
81
lib/opendax/vault.rb
Normal file
81
lib/opendax/vault.rb
Normal file
@@ -0,0 +1,81 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Opendax
|
||||
class Vault
|
||||
POLICIES_NAMES = ["barong", "finex_engine", "peatio_rails", "peatio_crypto", "peatio_upstream", "peatio_matching"]
|
||||
|
||||
def vault_secrets_path
|
||||
'config/vault-secrets.yml'
|
||||
end
|
||||
|
||||
def vault_exec(command)
|
||||
`docker-compose exec -T vault sh -c '#{command}'`
|
||||
end
|
||||
|
||||
def secrets(command, endpoints, options = '')
|
||||
endpoints.each { |endpoint| vault_exec("vault secrets #{command} #{options} #{endpoint}") }
|
||||
end
|
||||
|
||||
def setup
|
||||
puts '----- Checking Vault status -----'
|
||||
vault_status = YAML.safe_load(vault_exec('vault status -format yaml'))
|
||||
|
||||
return if vault_status.nil?
|
||||
|
||||
if vault_status['initialized']
|
||||
puts '----- Vault is initialized -----'
|
||||
begin
|
||||
vault_secrets = YAML.safe_load(File.read(vault_secrets_path))
|
||||
rescue SystemCallError => e
|
||||
puts 'Vault keys are missing'
|
||||
return
|
||||
end
|
||||
vault_root_token = vault_secrets['root_token']
|
||||
unseal_keys = vault_secrets['unseal_keys_b64'][0, 3]
|
||||
else
|
||||
puts '----- Initializing Vault -----'
|
||||
vault_init_output = YAML.safe_load(vault_exec('vault operator init -format yaml --recovery-shares=3 --recovery-threshold=2'))
|
||||
File.write(vault_secrets_path, YAML.dump(vault_init_output))
|
||||
vault_root_token = vault_init_output['root_token']
|
||||
unseal_keys = vault_init_output['unseal_keys_b64'][0, 3]
|
||||
end
|
||||
|
||||
if vault_status['sealed']
|
||||
puts '----- Unsealing Vault -----'
|
||||
unseal_keys.each { |key| vault_exec("vault operator unseal #{key}") }
|
||||
else
|
||||
puts '----- Vault is unsealed -----'
|
||||
end
|
||||
|
||||
return vault_root_token if vault_status['initialized']
|
||||
|
||||
puts '----- Vault login -----'
|
||||
vault_exec("vault login #{vault_root_token}")
|
||||
|
||||
puts '----- Configuring the endpoints -----'
|
||||
secrets('enable', %w[totp transit])
|
||||
secrets('disable', ['secret'])
|
||||
secrets('enable', ['kv'], '-path=secret -version=1')
|
||||
vault_root_token
|
||||
end
|
||||
|
||||
def load_policies(deployment_name, vault_root_token)
|
||||
puts '----- Vault login -----'
|
||||
vault_exec("vault login #{vault_root_token}")
|
||||
|
||||
tokens = {}
|
||||
POLICIES_NAMES.each do |policy|
|
||||
name = "#{deployment_name.downcase}_#{policy}"
|
||||
|
||||
puts "Loading #{name} policy..."
|
||||
vault_exec("vault policy write #{name} /tmp/policies/#{policy}.hcl")
|
||||
|
||||
puts "Creating the #{name} token..."
|
||||
vault_token_create_output = YAML.safe_load(vault_exec("vault token create -policy=#{name} -renewable=true -ttl=240h -period=240h -format=yaml"))
|
||||
tokens["#{policy}_token"] = vault_token_create_output["auth"]["client_token"]
|
||||
end
|
||||
|
||||
tokens
|
||||
end
|
||||
end
|
||||
end
|
||||
74
lib/opendax/webhook.rb
Normal file
74
lib/opendax/webhook.rb
Normal file
@@ -0,0 +1,74 @@
|
||||
require 'sinatra/base'
|
||||
require 'json'
|
||||
require 'yaml'
|
||||
|
||||
require_relative 'payload'
|
||||
require_relative 'renderer'
|
||||
|
||||
class Webhook < Sinatra::Base
|
||||
CONFIG_PATH = 'config/app.yml'.freeze
|
||||
|
||||
set :show_exceptions, false
|
||||
|
||||
def initialize
|
||||
super
|
||||
@services = %w[barong peatio frontend AdminTower applogic finex-engine finex-api]
|
||||
secret = ENV['WEBHOOK_JWT_SECRET']
|
||||
raise 'WEBHOOK_JWT_SECRET is not set' if secret.to_s.empty?
|
||||
@decoder = Opendax::Payload.new(secret: secret)
|
||||
end
|
||||
|
||||
def update_config(service, image)
|
||||
config = YAML.load_file(CONFIG_PATH)
|
||||
config["images"][service] = image
|
||||
File.open(CONFIG_PATH, 'w') {|f| f.write config.to_yaml }
|
||||
end
|
||||
|
||||
before do
|
||||
content_type 'application/json'
|
||||
end
|
||||
|
||||
get '/deploy/ping' do
|
||||
'pong'
|
||||
end
|
||||
|
||||
get '/deploy/:token' do |token|
|
||||
decoded = @decoder.safe_decode(token)
|
||||
return answer(400, 'invalid token') unless decoded
|
||||
|
||||
service = decoded['service']
|
||||
image = decoded['image']
|
||||
|
||||
return answer(400, 'service is not specified') unless service
|
||||
return answer(400, 'image is not specified') unless image
|
||||
return answer(404, 'unknown service') unless @services.include? service
|
||||
return answer(400, 'invalid image') if (%r(^(([-_\w\.]){,20}(\/|:))+([-\w\.]{,20})$) =~ image) == nil
|
||||
|
||||
system "docker image pull #{image}"
|
||||
|
||||
unless $?.success?
|
||||
system("docker image inspect #{image} > /dev/null")
|
||||
return answer(404, 'invalid image') unless $?.success?
|
||||
end
|
||||
|
||||
if $?.success?
|
||||
update_config(service, image)
|
||||
|
||||
renderer = Opendax::Renderer.new
|
||||
renderer.render
|
||||
|
||||
system "docker-compose up -Vd #{service}"
|
||||
end
|
||||
|
||||
return answer(500, 'could not restart container') unless $?.success?
|
||||
return answer(200, "service #{service} updated with image #{image}")
|
||||
end
|
||||
|
||||
def answer(response_status, message)
|
||||
status response_status
|
||||
|
||||
{
|
||||
message: message
|
||||
}.to_json
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user