# 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