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