237 lines
6.1 KiB
Ruby
237 lines
6.1 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
require 'open3'
|
|
|
|
# Este modelo implementa los distintos tipos de alojamiento que provee
|
|
# Sutty.
|
|
#
|
|
# Cuando cambia el hostname de un Deploy, generamos un
|
|
# DeployAlternativeDomain en su lugar. Esto permite que no se rompan
|
|
# links preexistentes y que el nombre no pueda ser tomado por alguien
|
|
# más.
|
|
#
|
|
# TODO: Cambiar el nombre a algo que no sea industrial/militar.
|
|
class Deploy < ApplicationRecord
|
|
# Un sitio puede tener muchas formas de publicarse.
|
|
belongs_to :site
|
|
# Puede tener muchos access logs a través del hostname
|
|
has_many :access_logs, primary_key: 'hostname', foreign_key: 'host'
|
|
# Registro de las tareas ejecutadas
|
|
has_many :build_stats, dependent: :destroy
|
|
|
|
# Siempre generar el hostname
|
|
after_initialize :default_hostname!
|
|
# Eliminar los archivos generados por el deploy.
|
|
before_destroy :remove_destination!
|
|
# Cambiar el lugar del destino antes de guardar los cambios, para que
|
|
# el hostname anterior siga estando disponible.
|
|
before_update :rename_destination!, if: :destination_changed?
|
|
# Los hostnames alternativos se crean después de actualizar, cuando ya
|
|
# se modificó el hostname.
|
|
around_update :create_alternative_domain!, if: :destination_changed?
|
|
|
|
# Siempre tienen que pertenecer a un sitio
|
|
validates :site, presence: true
|
|
# El hostname tiene que ser único en toda la plataforma
|
|
validates :hostname, uniqueness: true
|
|
# Cada deploy puede implementar su propia validación
|
|
validates :hostname, hostname: true, unless: :implements_hostname_validation?
|
|
# Verificar que se puede cambiar de lugar el destino y no hay nada
|
|
# preexistente.
|
|
validate :destination_can_change?, if: :destination_changed?
|
|
|
|
# Retrocompatibilidad: Encuentra el site_name a partir del hostname.
|
|
#
|
|
# @return [String,Nil]
|
|
def self.site_name_from_hostname(hostname)
|
|
where(hostname: hostname).includes(:site).pluck(:name).first
|
|
end
|
|
|
|
# Detecta si el destino existe y si no es un symlink roto.
|
|
def exist?
|
|
File.exist? destination
|
|
end
|
|
|
|
# Detecta si el link está roto
|
|
def broken?
|
|
File.symlink?(destination) && !File.exist?(File.readlink(destination))
|
|
end
|
|
|
|
# Ubicación del deploy
|
|
#
|
|
# @return [String] Una ruta en el sistema de archivos
|
|
def destination
|
|
File.join(Rails.root, '_deploy', hostname)
|
|
end
|
|
|
|
# Ubicación anterior del deploy
|
|
#
|
|
# @return [String] Una ruta en el sistema de archivos
|
|
def destination_was
|
|
return destination unless will_save_change_to_hostname?
|
|
|
|
File.join(Rails.root, '_deploy', hostname_was)
|
|
end
|
|
|
|
# Determina si la ubicación cambió
|
|
def destination_changed?
|
|
persisted? && will_save_change_to_hostname?
|
|
end
|
|
|
|
# Genera el hostname
|
|
#
|
|
# @return [String]
|
|
def default_hostname
|
|
raise NotImplementedError
|
|
end
|
|
|
|
# Devolver la URL
|
|
#
|
|
# @return [String]
|
|
def url
|
|
"https://#{hostname}"
|
|
end
|
|
|
|
# Ejecutar la tarea
|
|
#
|
|
# @return [Boolean]
|
|
def deploy
|
|
raise NotImplementedError
|
|
end
|
|
|
|
# El espacio ocupado por este deploy.
|
|
#
|
|
# @return [Integer]
|
|
def size
|
|
raise NotImplementedError
|
|
end
|
|
|
|
# Empezar a contar el tiempo
|
|
#
|
|
# @return [Time]
|
|
def time_start
|
|
@start = Time.now
|
|
end
|
|
|
|
# Detener el contador
|
|
#
|
|
# @return [Time]
|
|
def time_stop
|
|
@stop = Time.now
|
|
end
|
|
|
|
# Obtener la demora de la tarea
|
|
#
|
|
# @return [Float]
|
|
def time_spent_in_seconds
|
|
(@stop - @start).round(3)
|
|
end
|
|
|
|
# El directorio donde se almacenan las gemas.
|
|
#
|
|
# TODO: En un momento podíamos tenerlas todas compartidas y ahorrar
|
|
# espacio, pero bundler empezó a mezclar cosas.
|
|
#
|
|
# @return [String]
|
|
def gems_dir
|
|
@gems_dir ||= Rails.root.join('_storage', 'gems', site.name)
|
|
end
|
|
|
|
# Corre un comando, lo registra en la base de datos y devuelve el
|
|
# estado.
|
|
#
|
|
# @param [String]
|
|
# @return [Boolean]
|
|
def run(cmd)
|
|
r = nil
|
|
lines = []
|
|
|
|
time_start
|
|
Dir.chdir(site.path) do
|
|
Open3.popen2e(env, cmd, unsetenv_others: true) do |_, o, t|
|
|
r = t.value
|
|
# XXX: Tenemos que leer línea por línea porque en salidas largas
|
|
# se cuelga la IO
|
|
# TODO: Enviar a un websocket para ver el proceso en vivo?
|
|
o.each do |line|
|
|
lines << line
|
|
end
|
|
end
|
|
end
|
|
time_stop
|
|
|
|
build_stats.create action: readable_cmd(cmd),
|
|
log: lines.join,
|
|
seconds: time_spent_in_seconds,
|
|
bytes: size,
|
|
status: r&.success?
|
|
|
|
r&.success?
|
|
end
|
|
|
|
private
|
|
|
|
# Genera el hostname pero permitir la inicialización del valor. Luego
|
|
# validamos que sea el formato correcto.
|
|
#
|
|
# @return [Boolean]
|
|
def default_hostname!
|
|
self.hostname ||= default_hostname
|
|
end
|
|
|
|
# Cambia la ubicación de destino cuando cambia el hostname.
|
|
def rename_destination!
|
|
return unless File.exist? destination_was
|
|
|
|
FileUtils.mv destination_was, destination
|
|
end
|
|
|
|
# Elimina los archivos generados por el deploy
|
|
#
|
|
# @return [Boolean]
|
|
def remove_destination!
|
|
raise NotImplementedError
|
|
end
|
|
|
|
# Cuando el deploy cambia de hostname, generamos un dominio
|
|
# alternativo para no romper links hacia este sitio.
|
|
def create_alternative_domain!
|
|
hw = hostname_was
|
|
|
|
# Aplicar la actualización
|
|
yield
|
|
|
|
# Crear el deploy alternativo con el nombre anterior una vez que
|
|
# lo cambiamos en la base de datos.
|
|
ad = site.deploys.create(type: 'DeployAlternativeDomain', hostname: hw)
|
|
ad.deploy if ad.persisted?
|
|
end
|
|
|
|
# Devuelve un error si el destino ya existe. No debería fallar si ya
|
|
# pasamos la validación de cambio de nombres, pero siempre puede haber
|
|
# directorios y links sueltos.
|
|
def destination_can_change?
|
|
return true unless persisted?
|
|
|
|
remove_destination! if broken?
|
|
|
|
return true unless exist?
|
|
|
|
errors.add :hostname, I18n.t('activerecord.errors.models.deploy.attributes.hostname.destination_exist')
|
|
end
|
|
|
|
# Convierte el comando en una versión resumida.
|
|
#
|
|
# @param [String]
|
|
# @return [String]
|
|
def readable_cmd(cmd)
|
|
cmd.split(' -', 2).first.tr(' ', '_')
|
|
end
|
|
|
|
# Cada deploy puede decidir su propia validación
|
|
#
|
|
# @return [Boolean]
|
|
def implements_hostname_validation?
|
|
false
|
|
end
|
|
end
|