Tag: python paramiko deploy automatizado locaweb

Script Python para Deploy Automatizado usando MD5 e SFTP

Boa Noite pessoal,

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

Advertisements