diff --git a/.env b/.env index 3b0cb6a..1a60b44 100644 --- a/.env +++ b/.env @@ -4,4 +4,4 @@ POSTGRES_PASSWORD='esus' POSTGRESQL_PORT=54351 APP_PORT=88 PGWEB_PORT=8099 -TIMEZONE='America/Bahia' +TIMEZONE='America/Bahia' \ No newline at end of file diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..1a60b44 --- /dev/null +++ b/.env.example @@ -0,0 +1,7 @@ +POSTGRES_DB='esus' +POSTGRES_USER='postgres' +POSTGRES_PASSWORD='esus' +POSTGRESQL_PORT=54351 +APP_PORT=88 +PGWEB_PORT=8099 +TIMEZONE='America/Bahia' \ No newline at end of file diff --git a/.gitignore b/.gitignore index 0ae55ed..9582596 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,9 @@ data __pycache__ *.pyc *.backup -.webassets-cache \ No newline at end of file +.webassets-cache +.env +client_secret_*.json +credentials.json +token.json +venv \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index d77aaeb..29f3a75 100644 --- a/Dockerfile +++ b/Dockerfile @@ -43,6 +43,4 @@ COPY ./run.sh run.sh RUN chmod +x /var/www/html/install.sh RUN chmod +x /var/www/html/run.sh -EXPOSE 8080 - CMD "/var/www/html/run.sh" \ No newline at end of file diff --git a/Makefile b/Makefile index 052540a..1c5cc44 100644 --- a/Makefile +++ b/Makefile @@ -21,4 +21,15 @@ dev-update: terminal: docker exec -it esus_app bash db-terminal: - docker exec -it esus_psql bash \ No newline at end of file + docker exec -it esus_psql bash +cloud-backup: + docker exec -it esus_cron sh -c "curl localhost:5000/backup-database" +google-oauth: + cd cron/app; \ + python3 --version; \ + pip3 install virtualenv; \ + virtualenv cron/app/venv; \ + chmod +x ./venv/bin/activate; \ + ./venv/bin/activate; \ + pip install -r requirements.txt; \ + python googleoauth.py; \ \ No newline at end of file diff --git a/README.md b/README.md index 06e084a..1e0902a 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,9 @@ # eSUS PEC -É um sistema bastante utilizado por profissionais de saúde da Atenção Básica para registros de pacientes e dados de saúde. Esse repositório se propõe a criar uma estrutura docker com linux para viabilizar o deploy do sistema em qualquer ambiente que tenha docker e facilitar a instalação e atualização. +Compatível e testado com +![version](https://img.shields.io/badge/version-5.0.12-blue) ![version](https://img.shields.io/badge/version-5.0.8-blue) ![version](https://img.shields.io/badge/version-4.5.5-blue) ![version](https://img.shields.io/badge/version-4.2.6-blue) ![version](https://img.shields.io/badge/version-5.0.14-blue) + +É um sistema bastante utilizado por profissionais de saúde da Atenção Básica para registros de pacientes e dados de saúde. Esse repositório se propõe a criar uma estrutura docker com linux para viabilizar o deploy do sistema em qualquer ambiente que tenha docker e facilitar a instalação e atualização do sistema [e-SUS PEC](https://sisaps.saude.gov.br/esus/) ## Preparando pacotes @@ -82,7 +85,7 @@ pg_restore -U "postgres" -d "esus" -1 /home/seu_arquivo.backup Fora do container, na pasta raiz do projeto execute, substituindo o nome do pacote `eSUS-AB-PEC-5.0.8-Linux64.jar` para a versão que você vai instalar em sua máquina. ```sh -sh build.sh -f eSUS-AB-PEC-5.0.8-Linux64.jar +sh build.sh -f eSUS-AB-PEC-5.0.14-Linux64.jar ``` ## Known Issues (Bugs Conhecidos) @@ -90,3 +93,23 @@ sh build.sh -f eSUS-AB-PEC-5.0.8-Linux64.jar - Testes realizados com versão `4.2.7` e `4.2.8` não foram bem sucedidos - A versão 4.2.8 está com erro no formulário de cadastro, nas requisições ao banco de dados, pelo endpoint graphql, retorna "Não autorizado" - Verificar sempre a memória caso queira fazer depois em servidor. Senão ele trará no console um `Killed` inesperado https://stackoverflow.com/questions/37071106/spring-boot-application-quits-unexpectedly-with-killed +- Não instale a versão `5.0.8`, do de cabeça, não carrega alguns exames e atendimentos de forma aparentemente aleatória, corrigido após instalar versão `5.0.14` + +## Serviço de backup + +Para fazer funcionar o serviço de backup na nuvem pelo [Google Drive](https://developers.google.com/drive/api/v3/reference) ela deve estar relacionada a um Google Drive, se assim não for irá armazenar os backups apenas localmente. Para o uso de backup na nuvem é necessário: + +1. [Criar uma chave de Client ID Google](https://developers.google.com/drive/api/quickstart/python) +2. Salve o arquivo json baixado com segurança e cole na pasta como `cron/app/credentials.json` +3. Execute +```sh +make google-oauth +``` +Para iniciar autenticação Google e permitir acessos. É necessário para criar o `token.json` que guardará as credenciais. Caso o comando não funcione, provavelmente você não está no linux ou não tem o `python3` instalado no seu linux. **É importante que ao executar o comando você esteja dentro da pasta raiz do projeto.** + +Para realizar um teste de backup execute: +``` +make cloud-backup +``` + +As configurações de tempo de expiração de backup estão disponíveis em `env.py` \ No newline at end of file diff --git a/cron/Dockerfile b/cron/Dockerfile new file mode 100644 index 0000000..0a3c544 --- /dev/null +++ b/cron/Dockerfile @@ -0,0 +1,29 @@ +FROM python:alpine3.16 + +# instalando dependências para o container +RUN apk add --no-cache postgresql-client tzdata curl + +ARG TIMEZONE + +# Configurando timezone +RUN ls /usr/share/zoneinfo +RUN cp "/usr/share/zoneinfo/${TIMEZONE}" /etc/localtime +RUN echo "${TIMEZONE}" > /etc/timezone +RUN date +RUN apk del tzdata + +# Copiando arquivos de uso do cron +ADD crontab.txt /crontab.txt +ADD script.sh /script.sh +COPY entry.sh /entry.sh +RUN chmod 755 /script.sh /entry.sh +RUN /usr/bin/crontab /crontab.txt + +# Criando o cenário para rodar a aplicação python +WORKDIR /home +COPY app . +COPY wsgi.py / +RUN pip install -r requirements.txt + +WORKDIR / +CMD ["/entry.sh"] \ No newline at end of file diff --git a/cron/app/__init__.py b/cron/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/cron/app/app.py b/cron/app/app.py new file mode 100644 index 0000000..e0b21f1 --- /dev/null +++ b/cron/app/app.py @@ -0,0 +1,91 @@ +from __future__ import print_function +from datetime import datetime + +import os +import sys +from flask import Flask, Response + +import os.path +from googleapiclient.discovery import build +from googleapiclient.http import MediaFileUpload +from googleapiclient.errors import HttpError + +from .env import BACKUP_EXPIRATION_TIME, DATABASE_HOST, DATABASE_NAME, DATABASE_USER, FILENAME, GOOGLE_DRIVE_FOLDER_ID, FILE_EXTENSION +from .googleoauth import get_google_credentials + +app = Flask(__name__) + +def get_file_in_path(filename): + return os.path.join(os.path.dirname(os.path.realpath(__file__)), filename) + +def upload_file(service, filename, mime_type, folder_id): + try: + file_metadata = { + 'name': filename, + 'parents': [folder_id], + 'mimeType': mime_type + } + + media = MediaFileUpload(get_file_in_path(filename), mimetype=mime_type, resumable=True) + + file = service.files().create( + body=file_metadata, + media_body=media, + fields='id' + ).execute() + except HttpError as error: + print(f'An error occurred: {error}') + file = None + + return file.get('id') + +@app.route("/backup-database") +def backup_database(): + from sh import pg_dump + + print('Realizando backup do banco de dados...', file=sys.stderr) + pg_dump('--host', DATABASE_HOST, '--port', '5432', '-U', DATABASE_USER, '-w', '--format', 'custom', '--blobs', '--encoding', + 'UTF8', '--no-privileges', '--no-tablespaces', '--no-unlogged-table-data', '--file', f'/home/{FILENAME}', DATABASE_NAME) + + + # upload de arquivo + print('Autenticando no Google...', file=sys.stderr) + service = build('drive', 'v3', credentials=get_google_credentials()) + + print('Realizando upload para Google Drive...', file=sys.stderr) + file_id = upload_file(service=service, filename=FILENAME, mime_type='application/octet-stream', folder_id=GOOGLE_DRIVE_FOLDER_ID) + print('File uploaded:', file_id) + + # Google Drive API: https://developers.google.com/drive/api/v3/reference + print('Listando arquivos na pasta de backup...', file=sys.stderr) + # fields props: https://developers.google.com/drive/api/v3/reference/files + # query props: https://developers.google.com/drive/api/guides/search-files#python + results = service.files().list(q=f'"{GOOGLE_DRIVE_FOLDER_ID}" in parents and trashed = false', orderBy='createdTime desc', + pageSize=20, fields="nextPageToken, files(id, name, mimeType, description, trashed)").execute() + items = results.get('files', []) + + if not items: + print('No files found.', file=sys.stderr) + return + else: + for item in items: + print('{:<40} {:<20} {:<20}'.format(item['name'], item['mimeType'], item['trashed']), file=sys.stderr) + + print('Excluindo arquivos antigos...', file=sys.stderr) + for item in items: + filename_datetime = item['name'].replace(FILE_EXTENSION, '') + try: + print(datetime.now()) + print(BACKUP_EXPIRATION_TIME) + if datetime.strptime(filename_datetime, '%Y_%m_%d_%H_%M_%S') < BACKUP_EXPIRATION_TIME: + filename = f'{filename_datetime}{FILE_EXTENSION}' + print(f'Excluindo {filename}', file=sys.stderr) + os.remove(get_file_in_path(filename)) + service.files().delete(fileId=item['id']).execute() + except Exception as e: + print('error', e) + + return Response('Backup realizado', status=201) + +if __name__ == '__main__': + get_google_credentials() diff --git a/cron/app/env.py b/cron/app/env.py new file mode 100644 index 0000000..4b15cf6 --- /dev/null +++ b/cron/app/env.py @@ -0,0 +1,13 @@ +from datetime import datetime, timedelta +import os + +DATABASE_HOST = os.getenv('POSTGRES_HOST', 'psql') +DATABASE_NAME = os.getenv('POSTGRES_DB', 'esus') +DATABASE_USER = os.getenv('POSTGRES_USER', 'postgres') + +NOW = datetime.now() +FILE_EXTENSION = '.backup' +FILENAME = f'{NOW.strftime("%Y_%m_%d_%H_%M_%S")}{FILE_EXTENSION}' + +GOOGLE_DRIVE_FOLDER_ID = os.getenv('GOOGLE_DRIVE_FOLDER_ID', '1osoeAhww2IM2V2W_xbgcRoROHEk_DAPw') +BACKUP_EXPIRATION_TIME = datetime.now() - timedelta(days=7) \ No newline at end of file diff --git a/cron/app/googleoauth.py b/cron/app/googleoauth.py new file mode 100644 index 0000000..8b47759 --- /dev/null +++ b/cron/app/googleoauth.py @@ -0,0 +1,43 @@ +from __future__ import print_function + +import os.path + +from google.auth.transport.requests import Request +from google.oauth2.credentials import Credentials +from google_auth_oauthlib.flow import InstalledAppFlow + +# If modifying these scopes, delete the file token.json. +SCOPES = [ + 'https://www.googleapis.com/auth/drive.appdata', + 'https://www.googleapis.com/auth/drive.file', + 'https://www.googleapis.com/auth/drive.install', + 'https://www.googleapis.com/auth/drive', + 'https://www.googleapis.com/auth/drive.metadata.readonly' + ] + +CREDEDNTIALS = os.path.join(os.path.dirname(os.path.realpath(__file__)), 'credentials.json') +TOKEN = os.path.join(os.path.dirname(os.path.realpath(__file__)), 'token.json') + +def get_google_credentials(): + creds = None + # The file token.json stores the user's access and refresh tokens, and is + # created automatically when the authorization flow completes for the first + # time. + if os.path.exists(TOKEN): + creds = Credentials.from_authorized_user_file(TOKEN, SCOPES) + # If there are no (valid) credentials available, let the user log in. + if not creds or not creds.valid: + if creds and creds.expired and creds.refresh_token: + creds.refresh(Request()) + else: + flow = InstalledAppFlow.from_client_secrets_file(CREDEDNTIALS, SCOPES) + creds = flow.run_local_server(host='localhost', port=8080, open_browser=False) + # Save the credentials for the next run + with open(TOKEN, 'w') as token: + token.write(creds.to_json()) + + return creds + + +if __name__ == '__main__': + get_google_credentials() \ No newline at end of file diff --git a/cron/app/requirements.txt b/cron/app/requirements.txt new file mode 100644 index 0000000..d7c342a --- /dev/null +++ b/cron/app/requirements.txt @@ -0,0 +1,6 @@ +Flask==2.2.2 +gunicorn==20.1.0 +sh==1.14.3 +google-api-python-client +google-auth-httplib2 +google-auth-oauthlib \ No newline at end of file diff --git a/cron/crontab.txt b/cron/crontab.txt new file mode 100644 index 0000000..b67519f --- /dev/null +++ b/cron/crontab.txt @@ -0,0 +1,3 @@ +# m h dom mon dow user command +* 0 * * * /script.sh >> /var/log/script.log + \ No newline at end of file diff --git a/cron/entry.sh b/cron/entry.sh new file mode 100644 index 0000000..3c37199 --- /dev/null +++ b/cron/entry.sh @@ -0,0 +1,6 @@ +#!/bin/sh + +# start python app +gunicorn --bind 0.0.0.0:5000 -D --enable-stdio-inheritance --capture-output --log-level debug --reload wsgi:app +# start cron +/usr/sbin/crond -f -l 8 \ No newline at end of file diff --git a/cron/script.sh b/cron/script.sh new file mode 100644 index 0000000..3584e37 --- /dev/null +++ b/cron/script.sh @@ -0,0 +1,5 @@ +#!/bin/sh + +# code goes here. +curl http://localhost:5000/backup-database +echo "This is a script, run by cron!" diff --git a/cron/wsgi.py b/cron/wsgi.py new file mode 100644 index 0000000..7172746 --- /dev/null +++ b/cron/wsgi.py @@ -0,0 +1,4 @@ +from home.app import app + +if __name__ == "__main__": + app.run() \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index a62563d..90f8839 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -33,3 +33,20 @@ services: - "${APP_PORT}:8080" depends_on: - psql + cron: + container_name: esus_cron + restart: always + build: + context: cron + dockerfile: Dockerfile + args: + - TIMEZONE=${TIMEZONE} + volumes: + - ./cron/crontab.txt:/crontab.txt + - ./cron/app:/home + environment: + - POSTGRES_DB=${POSTGRES_DB} + - POSTGRES_USER=${POSTGRES_USER} + - PGPASSWORD=${POSTGRES_PASSWORD} + depends_on: + - psql