Monitorizar alterações de ficheiros
Introdução
No dia-a-dia acabamos por fazer coisas simples, por vezes sem pensar, e que podem ser úteis para outras pessoas.
No meu trabalho utilizo vários hosts no desenvolvimento de software (normalmente um host por projecto em curso). Por vezes há projectos em que o meu papel é, essencialmente, de supervisão, ou seja, normalmente não estou a acompanhar o código que é desenvolvido no âmbito desses projectos.
Na primeira experiência de mera supervisão cheguei a situações completamente caóticas, praticamente ingeríveis, que me obrigaram a passar muitas horas a olhar para as linhas modificadas desde o último “commit” no sistema de controlo de versões. O que eu precisava era de algo muito simples, de algo que em pontos estratégicos do dia me dissesse que ficheiros foram modificados desde a última verificação.
Os ingredientes
Como qualquer programador habituado a uma dada linguagem, eu comecei logo por pensar numa solução em Perl, com expressões regulares, etc. mas depois resolvi analisar os comandos simples e bons que já fazem umas quantas iterações do que eu iria necessitar de fazer, a saber:
- date
- stat (já devem ter reparado que gosto de usar este)
- find
- grep
- touch
- echo
- sed
date
O comando date é muito flexível. Neste exemplo uso-o para obter a data actual no formato “2009-03-20 11:52:24 +0000”, usando a invocação:
$ date +"%F %T %z" 2009-03-20 15:12:39 +0000
stat
O stat permite-nos saber o estado de um ficheiro. Alguma informação útil que é fornecida por este comando (vulgo: aquela que já alguma vez usei):
- o número de hard-links existentes para o ficheiro;
- a data de acesso do ficheiro;
- a data de modificação do ficheiro;
- o id do utilizador;
- o id do grupo;
- as permissões do ficheiro.
find
O nome diz tudo. Permite-nos encontrar o que muito bem entendermos (ficheiros, directórios, links simbólicos, devices, …).
grep
É o find para conteúdo (de ficheiros ou stream).
touch
Altera as datas de um ficheiro.
echo
Imprime strings para o STDOUT.
sed
Faz a edição de texto em stream, ou seja, por cada linha que passa pelo pipe são aplicadas as transformações definidas.
A receita
Uma vez que um dos objectivos era tornar o script completamente configurável sem ter de o editar, optei por definir que seriam necessários dois argumentos (é discutível, mas foi a abordagem que segui e que passarei a explicar mais à frente).
O primeiro argumento é o directório que pretendemos “monitorizar” e, sem ele, não há aplicação para ninguém.
O segundo argumento é uma etiqueta (taskname) que, sendo facultativo, é boa ideia usar (caso contrário é usado o nome default). Esta etiqueta servirá para gerar um ficheiro (CSV) onde irá constar o histórico de diferenças. Porque é que não uso um valor transformado do primeiro argumento (ex. substituição das barras por underscores)? Porque não é tão raro assim ter projectos em máquinas distintas que envolvem os mesmos directórios e, como tal, quero um ficheiro de histórico por cada um dos projectos. Uma vez que guardo estes ficheiros na minha home e esta é montada por NFS, se usasse só o directório passaria a ter todas as alterações no mesmo ficheiro, ficando sem saber o que mudou e onde.
A data actual é boa para duas coisas:
- colocar na mensagem do mail que vai ser disparado;
- adicionar uma coluna no ficheiro CSV com a data e hora da verificação (outra coluna será o nome do ficheiro modificado).
Se o ficheiro CSV já existir, então pretende-se ir buscar a data e hora da última modificação para colocar na mensagem do mail. Lembram-se de eu ter falado do stat? Vamos dar-lhe uso:
$ stat -c '%y' o_meu_ficheiro 2009-03-20 13:00:01.000000000 +0000
O stat devolve o tempo com fracções de segundo mas o date fica-se pelos segundos. Como parece mal uma mensagem com duas informações inconsistentes, optei por uniformizar o resultado do stat com recurso ao sed.
$ stat -c '%y' o_meu_ficheiro | sed 's/\.\([0-9]*\) / /' 2009-03-20 13:00:01 +0000
O sed recebe uma expressão regular e aplica-a a tudo o que lhe é passado. Neste caso estamos a substituir (s inicial) a primeira ocorrência de ”.” seguido de zero ou mais algarismos ”[0-9]*”, seguido de espaço por um único espaço.
O que se segue é uma pesquisa dos ficheiros que foram modificados. Existem dois cenários mas a diferença entre eles é ínfima. No caso do CSV já existir, queremos saber todos os ficheiros que foram modificados desde a data em que este foi modificado até à data em que o script está a correr:
find "$dirname" -type f -newer "$filename"
Caso contrário, vamos assumir que queremos os que foram modificados nas últimas 24h:
find "$dirname" -type f -mtime -1
Possivelmente vamos ter ficheiros que não nos interessam, nomeadamente os ficheiros de swap que alguns editores de texto geram e os ficheiros relacionados com o controlo de versões. Nestes casos adicionam-se grep's a cada uma das linhas anteriores para efectuar essa filtragem.
Para ignorarmos ficheiros/directórios escondidos:
grep -v "/\."
Para ignorarmos directórios do sistema de controlo de versões (neste caso é CVS - esta devia estar mais bem definida):
grep -v "/CVS/"
E a ciência fica-se por aqui… O resto é utilização de variáveis e uns echo para imprimir o conteúdo.
O bolo
#!/bin/bash dirname=$1 if [[ "n$dirname" == "n" ]]; then echo "ERROR: Missing directory to control." 1>&2 exit fi if [ ! -d $dirname ]; then echo "ERROR: Specified directory doesn't exist ($dirname)" 1>&2 exit fi taskname=$2 if [[ "n$taskname" == "n" ]]; then taskname="default" fi filename="${HOME}/.msilva_supervise/.task_$taskname" edate=`date +"%F %T %z"`; sentence="Changed files summary for $taskname (directory $dirname) at $edate" flist="" if [ -f "$filename" ]; then cdate=`stat -c '%y' "$filename" | sed 's/\.\([0-9]*\) / /'`; sentence="$sentence since $cdate" for file in `find "$dirname" -type f -newer "$filename" | grep -v "/CVS/" | grep -v "/\."` ; do echo "\"$edate\";\"$file\"" >> $filename flist="$flist\n$file" done else sentence="$sentence during the last 24 hours." for file in `find "$dirname" -type f -mtime -1 | grep -v "/CVS/" | grep -v "/\."` ; do echo "\"$edate\";\"$file\"" >> $filename flist="$file\n$flist" done fi if [[ "n$flist" == "n" ]]; then flist="NO FILES WERE CHANGED!\n" fi echo -e "$sentence\n\n$flist\n-- \nAutomatic verification system 0.1\n\nSonaecom 2009\n" 1>&2 touch $filename
A cereja
Caso não tenham reparado, os echo são redireccionados para o STDERR (é esse o significado de 1>&2 - redirecciona o que é impresso no STDOUT [1] para o STDERR [2]). Porquê? O cron, por omissão, envia para o e-mail interno do utilizador que agenda a tarefa uma mensagem com tudo o que foi impresso no STDERR para que este saiba que erros ocorreram durante a execução. Imprimindo uma mensagem “bonita” para o STDERR, recebemos um mail “bonito”. :D
Adicionalmente o cron permite definir um MAILTO para que as ditas mensagens sejam entregues noutra caixa de correio. Executei o crontab em modo de edição:
$ crontab -e
e escrevi o seguinte:
MAILTO=mylocallist@localhost 0 8,13,19 * * 1-5 ~/scripts/supervise/supervise.sh /srv/apps/the_big_application/ project_big_app
A partir daqui, todos os dias da semana, às 8h, 13h e 19h, o script é executado e o mail é entregue ao endereço mylocallist@localhost.
Agradecimentos
Um grande “muito obrigado” ao Bruno Tavares por me ter puxado os pés para a Terra e me ter guiado para “keep it simple”, usando o find (eu já andava a “voar” com consultas ao servidor de controlo de versões, armazenamento de diffs, etc.)