diff --git a/Gemfile b/Gemfile index 537cabc5..67a8c4d8 100644 --- a/Gemfile +++ b/Gemfile @@ -54,6 +54,7 @@ gem 'mobility' gem 'pundit' gem 'rails-i18n' gem 'rails_warden' +gem 'rubyzip' gem 'rugged' gem 'validates_hostname' gem 'whenever', require: false diff --git a/Gemfile.lock b/Gemfile.lock index 7941b035..4a2c2ff6 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -423,6 +423,7 @@ DEPENDENCIES rails_warden rbnacl (< 5.0) rubocop-rails + rubyzip rugged sass-rails (~> 5.0) selenium-webdriver diff --git a/app/models/deploy.rb b/app/models/deploy.rb new file mode 100644 index 00000000..2fe5396d --- /dev/null +++ b/app/models/deploy.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +require 'open3' +# Este modelo implementa los distintos tipos de alojamiento que provee +# Sutty. +# +# Los datos se guardan en la tabla `deploys`. Para guardar los +# atributos, cada modelo tiene que definir su propio `store +# :attributes`. +class Deploy < ApplicationRecord + belongs_to :site + + def deploy + raise NotImplementedError + end + + def limit + raise NotImplementedError + end + + # Corre un comando y devuelve true si terminó correctamente + def run(cmd) + r = 1 + + Dir.chdir(site.path) do + Open3.popen2(env, cmd, unsetenv_others: true) do |_, _o, t| + r = t.value + end + end + + r.exited? + end +end diff --git a/app/models/deploy_local.rb b/app/models/deploy_local.rb new file mode 100644 index 00000000..330bd023 --- /dev/null +++ b/app/models/deploy_local.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +# Alojamiento local, solo genera el sitio, con lo que no necesita hacer +# nada más +class DeployLocal < Deploy + store :values, accessors: %i[fqdn destination], coder: YAML + + before_create :fqdn!, :destination! + before_destroy :remove_destination! + + # Realizamos la construcción del sitio usando Jekyll y un entorno + # limpio para no pasarle secretos + # + # Pasamos variables de entorno mínimas para no filtrar secretos de + # Sutty + # + # TODO: Recolectar estadísticas y enviarlas a la base de datos + def deploy + yarn && bundle && jekyll_build + end + + # Sólo permitimos un deploy local + def limit + 1 + end + + private + + # Un entorno que solo tiene lo que necesitamos + def env + paths = [File.dirname(`which bundle`), '/usr/bin'] + + { 'PATH' => paths.join(':'), 'JEKYLL_ENV' => 'production' } + end + + def yarn_lock + File.join(site.path, 'yarn.lock') + end + + def yarn_lock? + File.exist? yarn_lock + end + + # Corre yarn dentro del repositorio + def yarn + return unless yarn_lock? + + run 'yarn' + end + + def bundle + run 'bundle' + end + + def jekyll_build + run "bundle exec jekyll build --destination \"#{escaped_destination}\"" + end + + def fqdn! + self.fqdn ||= "#{site.name}.#{ENV.fetch('SUTTY', 'sutty.nl')}" + end + + def destination! + self.destination ||= File.join(Rails.root, '_deploy', fqdn) + end + + # no debería haber espacios ni caracteres especiales, pero por si + # acaso... + def escaped_destination + Shellwords.escape destination + end + + # Eliminar el destino si se elimina el deploy + def remove_destination! + FileUtils.rm_rf destination + end +end diff --git a/app/models/deploy_zip.rb b/app/models/deploy_zip.rb new file mode 100644 index 00000000..15c81e1b --- /dev/null +++ b/app/models/deploy_zip.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +# Genera un ZIP a partir del sitio ya construido +# +# TODO: Firmar con minisign +class DeployZip < Deploy + store :values, accessors: %i[fqdn destination file path], coder: YAML + + before_create :fqdn!, :destination! + before_create :file!, :path! + + # Una vez que el sitio está generado, tomar todos los archivos y + # y generar un zip accesible públicamente. + # + # TODO: Recolectar estadísticas y enviarlas a la base de datos + def deploy + Dir.chdir(destination) do + Zip::File.open(path, Zip::File::CREATE) do |z| + Dir.glob('./**/**').each do |f| + File.directory?(f) ? z.mkdir(f) : z.add(f, f) + end + end + end + + File.exist? path + end + + private + + # Copiamos de DeployLocal para no cargar todos los métodos de + # compilación... + def fqdn! + self.fqdn ||= "#{site.name}.#{ENV.fetch('SUTTY', 'sutty.nl')}" + end + + def destination! + self.destination ||= File.join(Rails.root, '_deploy', fqdn) + end + + def file! + self.file ||= "#{fqdn}.zip" + end + + def path! + self.path = File.join(destination, file) + end +end diff --git a/app/models/site.rb b/app/models/site.rb index 607bbbf4..bd3f01ed 100644 --- a/app/models/site.rb +++ b/app/models/site.rb @@ -13,6 +13,7 @@ class Site < ApplicationRecord belongs_to :design belongs_to :licencia + has_many :deploys has_many :roles has_many :usuaries, -> { where('roles.rol = ?', 'usuarie') }, through: :roles diff --git a/db/migrate/20190723220002_create_deploys.rb b/db/migrate/20190723220002_create_deploys.rb new file mode 100644 index 00000000..85a20259 --- /dev/null +++ b/db/migrate/20190723220002_create_deploys.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +# Crea la tabla de deploys posibles para un sitio +class CreateDeploys < ActiveRecord::Migration[5.2] + def change + create_table :deploys do |t| + t.timestamps + t.belongs_to :site, index: true + t.string :type, index: true + t.text :values + end + end +end diff --git a/db/schema.rb b/db/schema.rb index edcf00e3..28ca10a5 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -12,7 +12,17 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 20_190_719_221_653) do +ActiveRecord::Schema.define(version: 20_190_723_220_002) do + create_table 'deploys', force: :cascade do |t| + t.datetime 'created_at', null: false + t.datetime 'updated_at', null: false + t.integer 'site_id' + t.string 'type' + t.text 'values' + t.index ['site_id'], name: 'index_deploys_on_site_id' + t.index ['type'], name: 'index_deploys_on_type' + end + create_table 'designs', force: :cascade do |t| t.datetime 'created_at', null: false t.datetime 'updated_at', null: false diff --git a/doc/crear_sitios.md b/doc/crear_sitios.md index b89e192a..92a309c6 100644 --- a/doc/crear_sitios.md +++ b/doc/crear_sitios.md @@ -106,6 +106,9 @@ falta (algunas plantillas tienen requisitos extraños). Las plantillas se instalan como gemas en los sitios, de forma que podemos cambiarla desde las opciones del sitio luego. +Para poder cambiar el diseño, necesitamos poder editar la configuración +del sitio en _config.yml y colocar el nombre de la gema en `theme:` + ## Internamente Al crear un sitio, clonar el esqueleto en el lugar correcto. Al @@ -162,3 +165,50 @@ actualizaciones muestra el historial para que les usuaries vean cuales son los cambios que se van a aplicar. Este historial viene del repositorio git con lo que tenemos que tomarnos la costumbre de escribir commits completos con explicación. + +## Alojamiento + +Para elegir las opciones de alojamiento, agregamos clases que +implementan el nombre del alojamiento y sus opciones específicas: + +* Local (sitio.sutty.nl): Deploy local. Genera el sitio en + _deploy/name.sutty.nl y lo pone a disposición del servidor web (es + como veniamos trabajando hasta ahora). También admite dominios + propios. + + Solo se puede tener uno y no es opcional porque lo necesitamos para + poder hacer el resto de deploys + +* Neocities: Deploy a neocities.org, necesita API key. Se conecta con + neocities usando la API y envía los archivos. + + Solo se puede tener uno (pendiente hablar con neocities si podemos + hacer esto). + +* Zip: genera un zip con el contenido del sitio y provee una url de + descarga estilo https://sutty.nl/sites/site.zip + + Solo se puede tener uno + +* SSH: Deploy al servidor SSH que especifiquemos. Necesita que le + pasemos user@host, puerto y ruta (quizás la ruta no es necesaria y + vaya junto con user@host). Informar a les usuaries la llave pública + de Sutty para que la agreguen. + + No hay límite a los servidores SSH que se pueden agregar. + +La clase Deploy::* tiene que implementar estas interfaces: + + * `#limit` devuelve un entero que es la cantidad de deploys de este + tipo que se pueden agregar, `nil` significa sin límite + * `#deploy` el método que realiza el deploy. En realidad este método + hay que correrlo por fuera de Sutty... + * `#attributes` los valores serializados que acepta el Deploy + +Son modelos que pueden guardar atributos en la base y a través de los +que se pueden tomar los datos a través de la API (cuando tengamos un +método de deployment). + +El plan es migrar todo esto a de forma que la compilación se +haga por separado de sutty. Este es un plan intermedio hasta que +tengamos tiempo de hacerlo realmente. diff --git a/test/factories/deploy_local.rb b/test/factories/deploy_local.rb new file mode 100644 index 00000000..e7441c36 --- /dev/null +++ b/test/factories/deploy_local.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :deploy_local do + site + end +end diff --git a/test/factories/deploy_zip.rb b/test/factories/deploy_zip.rb new file mode 100644 index 00000000..756be34b --- /dev/null +++ b/test/factories/deploy_zip.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :deploy_zip do + site + end +end diff --git a/test/models/deploy_local_test.rb b/test/models/deploy_local_test.rb new file mode 100644 index 00000000..39ba7019 --- /dev/null +++ b/test/models/deploy_local_test.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class DeployZipTest < ActiveSupport::TestCase + test 'se puede deployear' do + deploy_local = create :deploy_local + + assert deploy_local.deploy + assert File.directory?(deploy_local.destination) + + assert deploy_local.destroy + assert_not File.directory?(deploy_local.destination) + end +end diff --git a/test/models/deploy_zip_test.rb b/test/models/deploy_zip_test.rb new file mode 100644 index 00000000..cbe55591 --- /dev/null +++ b/test/models/deploy_zip_test.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +class DeployLocalTest < ActiveSupport::TestCase + test 'se puede deployear' do + site = create :site + local = create :deploy_local, site: site + deploy = create :deploy_zip, site: site + + # Primero tenemos que generar el sitio + local.deploy + + escaped_path = Shellwords.escape(deploy.path) + + assert deploy.deploy + assert File.file?(deploy.path) + assert_equal 'application/zip', + `file --mime-type "#{escaped_path}"`.split(' ').last + + local.destroy + end +end