sutty/app/models/deploy.rb

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