Script Python para Deploy Automatizado usando MD5 e SFTP
Contratei uma hospedagem na Locaweb recentemente para meus sites em Django / Python e eu como sempre querendo automatizar tudo o tempo todo, criei um script em Python que realiza o deploy da minha aplicação lá no servidor remoto.
Objetivo: Copiar uma pasta e todo o seu conteúdo recursivamente para o servidor usando Secure File Transfer Protocol (SFTP), sincronizando as duas pastas (local e remota), isto é, identificar quais arquivos modificaram (usando md5) e copiar apenas eles para o servidor, removendo todos os arquivos e pastas remotas que foram deletados localmente desde a última sincronização. Tudo isto de um jeito super rápido, ocupando o mínimo de banda possível e feito com apenas 1 clique. Semelhante ao deploy do Google App Engine, para quem já utilizou.
Características:
- Biblioteca Paramiko – Estou utilizando uma biblioteca poderosa que implementa a parte de comunicação remota (SSH e SFTP e muito mais), chamada Paramiko.
- Índice MD5 – Para comparar arquivos locais e remotos, utilizo MD5, isto é, uma espécie de checksum / hashcode, que lê os bytes de um arquivo e me gera uma string única, sobre a qual eu relizo a comparação. Para não ter que fazer download do arquivo remoto apenas para calcular o md5, criei um índice, que nada mais é do que um dicionário em python que contém como chave o caminho completo do arquivo remoto e como valor o seu md5. Atualizo esse índice durante a sincronização.
- Arquivo de Configuração (Properties) – Estou utilizando um arquivo properties para isolar os dados de conexão ao servidor, tais como usuário e senha e mantê-los em uma pasta em separado.
- Barra de Progresso – Tentei implementar uma espécie de barra de progresso em modo texto, que imprime 100 caracteres “ponto”, e cada caractere representa 1% do upload do arquivo durante a cópia para o servidor.
Não se esqueçam de instalar a biblioteca paramiko, tem informações aqui no site do autor: http://www.lag.net/paramiko/
Aqui estão os pacotes necessários para as pessoas que utilizam o windows:
– Windows 64 bits (Python 2.7)
paramiko-1.7.7.1.win-amd64-py2.7.exe
pycrypto-2.3.win-amd64-py2.7.exe
– Windows 32 bits (Python 2.7)
paramiko-1.7.7.1.win32-py2.7.exe
pycrypto-2.3.win32-py2.7.exe
Para instalar em linux basta fazer o download do .tar.gz, acessar o diretório expandido e digitar o comando easy_install .
Vou postar agora o código fonte, são 2 arquivos, um para a configuração (properties – .ini) e o outro realmente que realiza o deploy (.py).
Arquivo de Configuração: settings.ini
[deploy] HOSTNAME: meuservidor.com ou 187.200.123.12 PORT: 22 USERNAME: usuario PASSWORD: senha DIR_LOCAL_RAIZ: /app/pythonProjects DIR_REMOTO_RAIZ: /home/storage/abc/julianajabur WSGI: /home/storage/abc/julianajabur/public_html/julianajabur/index.wsgi INDICE_MD5: /home/storage/abc/julianajabur/python_conf/julianajabur/indexmd5
Explicando os parâmetros acima:
- HOSTNAME = Endereço do servidor, pode ser um ip ou um nome DNS
- PORT = Porta de comunicação, 22 é a porta padrão para ftp
- USERNAME = usuário
- PASSWORD = senha
- DIR_LOCAL_RAIZ = caminho raiz da pasta que eu quero copiar, importante: aqui NÃO É a pasta a ser copiada, e sim apenas o caminho inicial dela, utilizo isso para fazer o de/para da estrutura de arquivos local versus a remota.
- DIR_REMOTO_RAIZ = idem ao de cima, só que aplicado à pasta remota.
- WSGI – aqui é o caminho até o seu arquivo WSGI, pois eu realizo uma cópia dele por último, para ter o mesmo efeito de touch arquivo.wsgi, indicando ao apache que sua aplicação foi mudada e deve ser atualizada.
- INDICE_WSGI = Caminho remoto onde vai ser gravado o arquivo de índice md5, observação, deve ser um diretório fora da pasta do seu projeto, para não atrapalhar no processo de sincronização de pastas.
Arquivo SFTPLocaweb.py
#! /usr/bin/python
# -*- coding: iso-8859-1 -*-
import os, paramiko, hashlib, sys, pickle
from stat import S_ISREG, S_ISDIR
from ConfigParser import RawConfigParser
class SFTPLocaweb:
def __init__(self):
self.caminhoArquivoConfiguracao = '../../../../python_conf/julianajabur/settings.ini'
config = self.getConfigurationFile()
self.hostname = config.get('deploy', 'HOSTNAME')
self.port = config.getint('deploy', 'PORT')
self.username = config.get('deploy', 'USERNAME')
self.password = config.get('deploy', 'PASSWORD')
self.dirLocalRaiz = config.get('deploy', 'DIR_LOCAL_RAIZ')
self.dirRemotoRaiz = config.get('deploy', 'DIR_REMOTO_RAIZ')
self.wsgi = config.get('deploy', 'WSGI')
self.indice_md5 = config.get('deploy', 'INDICE_MD5')
self.pastaOrigem = ''
self.pastaDestino = ''
self.sftp = None
self.dicionario_md5 = {}
self.statusTransferencia = 0
self.totais = {}
self.transport = None
def getConfigurationFile(self):
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
PYTHON_CONF = os.path.abspath(self.pathJoin(BASE_DIR, self.caminhoArquivoConfiguracao))
config = RawConfigParser()
config.read(PYTHON_CONF)
return config
def getConexaoSSH(self):
try:
print 'Estabelecendo conexão com: ', self.hostname, self.port, '...'
self.transport = paramiko.Transport((self.hostname, self.port))
self.transport.connect(username=self.username, password=self.password, hostkey=None)
self.sftp = paramiko.SFTPClient.from_transport(self.transport)
except Exception, e:
print '*** Erro ao se conectar com o servidor: %s: %s' % (e.__class__, e)
sys.exit()
try:
self.transport.close()
except:
pass
def pathJoin(self, raiz, diretorio):
return os.path.join(raiz, diretorio).replace('\\', '/')
def criarDiretorioRemoto(self, diretorio):
try:
self.sftp.mkdir(diretorio)
print ' (diretório criado) ', diretorio
return 1
except IOError:
print ' (diretório já existe) ', diretorio
return 0
def getDiretorioRemotoFromLocal(self, diretorioLocal):
return diretorioLocal.replace(self.dirLocalRaiz, self.dirRemotoRaiz)
def getDiretorioLocalFromRemoto(self, diretorioRemoto):
return diretorioRemoto.replace(self.dirRemotoRaiz, self.dirLocalRaiz)
def existeArquivoIndice(self):
try:
if self.sftp.stat(self.indice_md5):
return True
except:
return False
def recuperaMd5(self, entrada):
try:
md5 = self.dicionario_md5[entrada]
return md5
except KeyError:
return None
def atualizarIndice(self):
for key in self.dicionario_md5.keys():
if not os.path.exists(self.getDiretorioLocalFromRemoto(key)):
self.dicionario_md5.pop(key)
def carregarDicionarioMd5(self):
if self.existeArquivoIndice():
indice_local = self.getDiretorioLocalFromRemoto(self.indice_md5)
self.sftp.get(self.indice_md5, indice_local)
indice = open(indice_local, 'rb')
self.dicionario_md5 = pickle.load(indice)
indice.close()
def salvarDicionarioMd5(self):
indice_local = self.getDiretorioLocalFromRemoto(self.indice_md5)
indice = open(indice_local, 'wb')
pickle.dump(self.dicionario_md5, indice)
indice.close()
self.copiarArquivoParaServidor(indice_local, self.indice_md5)
def isArquivosIguaisMD5(self, local_file, remote_file):
m_local = hashlib.md5()
m_local.update(open(local_file, "rb").read())
md5Local = m_local.digest()
md5Remoto = self.recuperaMd5(remote_file)
if md5Local == md5Remoto:
return True
else:
return md5Local
def calcularMd5(self, local_file):
m_local = hashlib.md5()
m_local.update(open(local_file, "rb").read())
return m_local.digest()
def acompanharTransferenciaArquivo(self, tamanhoTransferido, tamanhoTotal):
try:
porcentagem = int((float(tamanhoTransferido) / float(tamanhoTotal)) * 100)
pontos = porcentagem - self.statusTransferencia
if pontos > 0:
self.statusTransferencia = self.statusTransferencia + pontos
sys.stdout.write(pontos * '.')
except Exception, e:
print '*** Exceção Lançada: %s: %s' % (e.__class__, e)
def copiarArquivoParaServidor(self, local_file, remote_file):
tentativas = 0
try:
self.statusTransferencia = 0
self.sftp.put(local_file, remote_file, self.acompanharTransferenciaArquivo)
print ''
except:
tentativas += 1
print 'ERRO ao enviar o arquivo ', local_file
self.copiarArquivoParaServidor(self, local_file, remote_file)
return tentativas
def sincronizarPastas(self, dirLocal, dirRemoto):
self.totais = {}
self.pastaOrigem = dirLocal
self.pastaDestino = dirRemoto
self.getConexaoSSH()
self.carregarDicionarioMd5()
self.executarCopia()
self.atualizarIndice()
print 'Copiando o indice md5: ',
self.salvarDicionarioMd5()
print 'Copiando o index.wsgi: ',
self.copiarArquivoParaServidor(self.getDiretorioLocalFromRemoto(self.wsgi), self.wsgi)
self.transport.close()
def deletarRecursosRemotos(self, dirRemoto):
try:
print 'PROCESSANDO A PASTA REMOTA - ', dirRemoto
for entrada in self.sftp.listdir(dirRemoto):
remote_entry = self.pathJoin(dirRemoto, entrada)
remote_entry = remote_entry.replace('\\','/')
if self.isRemoteDir(remote_entry):
self.deletarRecursosRemotos(remote_entry)
elif self.isRemoteFile(remote_entry):
local_file = self.getDiretorioLocalFromRemoto(remote_entry)
if not os.path.exists(local_file):
print " (arquivo removido):", remote_entry, " (" + self.formataTamanhoArquivo(self.sftp.stat(remote_entry).st_size) + ") "
self.sftp.remove(remote_entry)
self.contabilizarTotais('arquivos_removidos', 1)
if self.sftp.listdir != '' and not os.path.exists(self.getDiretorioLocalFromRemoto(dirRemoto)):
print ' (diretório removido): ', dirRemoto
self.sftp.rmdir(dirRemoto)
self.contabilizarTotais('diretorios_removidos', 1)
except Exception, e:
print '*** Exceção Lançada ao deletar Recursos Remotos: %s: %s' % (e.__class__, e)
sys.exit()
def isRemoteDir (self, remote_path):
try:
st = self.sftp.stat( remote_path )
return S_ISDIR(st.st_mode)
except Exception:
return False
def isRemoteFile (self, remote_path):
try:
st = self.sftp.stat( remote_path )
return S_ISREG(st.st_mode)
except Exception:
return False
def formataTamanhoArquivo(self, tamanho):
tipo = 1
while(tamanho > 1024):
tamanho = float(tamanho) / 1024.0
tipo += 1
if(tipo == 1):
tamanho = "%.2f bytes" % (tamanho)
elif(tipo == 2):
tamanho = "%.2f Kb" % (tamanho)
elif(tipo == 3):
tamanho = "%.2f Mb" % (tamanho)
elif(tipo == 4):
tamanho = "%.2f Gb" % (tamanho)
return tamanho.replace(".00", "")
def contabilizarTotais(self, tipo, valor):
self.totais[tipo] = self.getResultadoTotal(tipo) + valor
def getResultadoTotal(self, chave):
try:
return self.totais[chave]
except:
return 0
def executarCopia(self):
print '=' * 60
print 'Local = ' + self.pastaOrigem
print 'Remoto = ' + self.pastaDestino
print '=' * 60
try:
diretorioRemoto = self.pastaDestino
self.contabilizarTotais('diretorios_criados', self.criarDiretorioRemoto(diretorioRemoto))
for raiz, diretorios, arquivos in os.walk(self.pastaOrigem):
raiz = raiz.replace('\\', '/')
print 'PROCESSANDO A PASTA LOCAL - ', self.pathJoin(self.pastaOrigem, raiz)
for diretorio in diretorios:
self.contabilizarTotais('diretorios', 1)
diretorioRemoto = self.getDiretorioRemotoFromLocal(self.pathJoin(raiz, diretorio))
self.contabilizarTotais('diretorios_criados', self.criarDiretorioRemoto(diretorioRemoto))
for arquivo in arquivos:
self.contabilizarTotais('arquivos', 1)
local_file = self.pathJoin(raiz, arquivo)
remote_file = self.getDiretorioRemotoFromLocal(local_file)
is_up_to_date = False
try:
# verifica se o arquivo remoto existe
if self.sftp.stat(remote_file):
md5 = self.isArquivosIguaisMD5(local_file, remote_file)
if md5 == True:
print " (não modificado):", arquivo + " (" + self.formataTamanhoArquivo(os.path.getsize(local_file)) + ")"
self.contabilizarTotais('arquivos_naomodificados', 1)
is_up_to_date = True
else:
print " (modificado):", arquivo + " (" + self.formataTamanhoArquivo(os.path.getsize(local_file)) + ") ",
self.dicionario_md5[remote_file] = md5
self.contabilizarTotais('arquivos_modificados', 1)
except:
print " (novo):", arquivo + " (" + self.formataTamanhoArquivo(os.path.getsize(local_file)) + ") ",
self.contabilizarTotais('arquivos_novos', 1)
md5 = self.calcularMd5(local_file)
self.dicionario_md5[remote_file] = md5
if not is_up_to_date:
self.contabilizarTotais('tentativas', self.copiarArquivoParaServidor(local_file, remote_file))
self.deletarRecursosRemotos(self.pastaDestino)
except Exception, e:
print '*** Exceção Lançada ao copiar arquivo: %s: %s' % (e.__class__, e)
sys.exit()
print '=' * 60
print 'Número de tentativas para erro (retry):', self.getResultadoTotal('tentativas')
print 'Total de diretórios criados:', self.getResultadoTotal('diretorios_criados')
print 'Total de arquivos novos:', self.getResultadoTotal('arquivos_novos')
print 'Total de arquivos modificados:', self.getResultadoTotal('arquivos_modificados')
print 'Total de arquivos não modificados:', self.getResultadoTotal('arquivos_naomodificados')
print 'Total de diretórios remotos removidos:', self.getResultadoTotal('diretorios_removidos')
print 'Total de arquivos remotos removidos:', self.getResultadoTotal('arquivos_removidos')
print 'Total de diretórios:', self.getResultadoTotal('diretorios')
print 'Total de arquivos:', self.getResultadoTotal('arquivos')
print 'Completo!'
print '=' * 60
Preste atenção ao parâmetro self.caminhoArquivoConfiguracao, setando para o diretório onde se encontra o seu arquivo de configuração (settings.ini)
Exemplo de utilização (Chamada do Deploy):
#! /usr/bin/python # -*- coding: iso-8859-1 -*- from julianajabur.deploy import ajustarFlex, SFTPLocaweb sftpLocaweb = SFTPLocaweb.SFTPLocaweb() dir_local_wsgi='/app/pythonProjects/wsgi_apps/julianajabur' dir_remote_wsgi = "/home/storage/abc/julianajabur/wsgi_apps/julianajabur" sftpLocaweb.sincronizarPastas(dir_local_wsgi, dir_remote_wsgi) print '\n\n\n\n' dir_local_public='/app/pythonProjects/public_html/julianajabur' dir_remote_public = "/home/storage/abc/julianajabur/public_html/julianajabur" sftpLocaweb.sincronizarPastas(dir_local_public, dir_remote_public)
E por último segue o resultado da execução do script (SysOut), impresso na linha de comando:
Estabelecendo conexão com: servidor.com 22 ... ============================================================ Local = /app/pythonProjects/wsgi_apps/julianajabur Remoto = /home/storage/abc/julianajabur/wsgi_apps/julianajabur ============================================================ (diretório já existe) /home/storage/abc/julianajabur/wsgi_apps/julianajabur PROCESSANDO A PASTA LOCAL - /app/pythonProjects/wsgi_apps/julianajabur (diretório já existe) /home/storage/abc/julianajabur/wsgi_apps/julianajabur/.settings (diretório já existe) /home/storage/abc/julianajabur/wsgi_apps/julianajabur/julianajabur (não modificado): SFTPLocaweb.py (11.93 Kb) (não modificado): .project (422 bytes) (não modificado): .pydevproject (670 bytes) PROCESSANDO A PASTA LOCAL - /app/pythonProjects/wsgi_apps/julianajabur/.settings (não modificado): org.eclipse.ltk.core.refactoring.prefs (134 bytes) (não modificado): org.eclipse.core.resources.prefs (276 bytes) PROCESSANDO A PASTA LOCAL - /app/pythonProjects/wsgi_apps/julianajabur/julianajabur (diretório já existe) /home/storage/abc/julianajabur/wsgi_apps/julianajabur/julianajabur/julianajaburapp (diretório já existe) /home/storage/abc/julianajabur/wsgi_apps/julianajabur/julianajabur/deploy (não modificado): urls.py (650 bytes) (não modificado): urls.pyc (1004 bytes) (não modificado): __init__.pyc (141 bytes) (não modificado): __init__.py (0 bytes) (não modificado): settings.pyc (3.04 Kb) (não modificado): manage.py (654 bytes) (não modificado): amfgateway.pyc (1.46 Kb) (não modificado): amfgateway.py (855 bytes) (não modificado): settings.py (3.57 Kb) PROCESSANDO A PASTA LOCAL - /app/pythonProjects/wsgi_apps/julianajabur/julianajabur/julianajaburapp (não modificado): models.pyc (751 bytes) (não modificado): __init__.pyc (157 bytes) (não modificado): views.py (538 bytes) (não modificado): models.py (212 bytes) (não modificado): __init__.py (0 bytes) (não modificado): views.pyc (1.11 Kb) (não modificado): tests.py (539 bytes) (não modificado): admin.pyc (336 bytes) (não modificado): admin.py (122 bytes) PROCESSANDO A PASTA LOCAL - /app/pythonProjects/wsgi_apps/julianajabur/julianajabur/deploy (não modificado): __init__.pyc (160 bytes) (não modificado): __init__.py (0 bytes) (não modificado): SFTPLocaweb.py (11.07 Kb) (não modificado): deployLocaweb.py (600 bytes) (não modificado): SFTPLocaweb.pyc (11.45 Kb) (não modificado): ajustarFlex.py (1.19 Kb) (não modificado): ajustarFlex.pyc (1.90 Kb) PROCESSANDO A PASTA REMOTA - /home/storage/abc/julianajabur/wsgi_apps/julianajabur PROCESSANDO A PASTA REMOTA - /home/storage/abc/julianajabur/wsgi_apps/julianajabur/.settings PROCESSANDO A PASTA REMOTA - /home/storage/abc/julianajabur/wsgi_apps/julianajabur/julianajabur PROCESSANDO A PASTA REMOTA - /home/storage/abc/julianajabur/wsgi_apps/julianajabur/julianajabur/julianajaburapp PROCESSANDO A PASTA REMOTA - /home/storage/abc/julianajabur/wsgi_apps/julianajabur/julianajabur/deploy ============================================================ Número de tentativas para erro (retry): 0 Total de diretórios criados: 0 Total de arquivos novos: 0 Total de arquivos modificados: 0 Total de arquivos não modificados: 30 Total de diretórios remotos removidos: 0 Total de arquivos remotos removidos: 0 Total de diretórios: 4 Total de arquivos: 30 Completo! ============================================================ Copiando o indice md5: .................................................................................................... Copiando o index.wsgi: ....................................................................................................
Até a próxima pessoal, espero que este script ajude muitas pessoas, qualquer dúvida, basta deixar um comentário aqui no blog e eu respondo.
Sintam-se à vontade para manipular o script como vocês bem entenderem, e se melhorarem ele, me enviem uma cópia com as melhorias para eu postar aqui no blog, …
Abraços,
Victor Jabur