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:

  1. Biblioteca Paramiko – Estou utilizando uma biblioteca poderosa que implementa a parte de comunicação remota (SSH e SFTP e muito mais), chamada Paramiko.
  2. Í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.
  3. 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.
  4. 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:

  1. HOSTNAME = Endereço do servidor, pode ser um ip ou um nome DNS
  2. PORT = Porta de comunicação, 22 é a porta padrão para ftp
  3. USERNAME = usuário
  4. PASSWORD = senha
  5. 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.
  6. DIR_REMOTO_RAIZ = idem ao de cima, só que aplicado à pasta remota.
  7. 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.
  8. 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