-
Notifications
You must be signed in to change notification settings - Fork 1
Fix multiple FTPS upload issues #90
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
jeremyestein
wants to merge
6
commits into
dev
Choose a base branch
from
jeremy/ftps
base: dev
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
67c7bf3
Make sure only one FTPS connection is active at once, regardless of
jeremyestein 8e7849b
Allow to upload multiple files in one connection
jeremyestein 70bfa7d
Upload all files as a single tar file
jeremyestein 0f9c0d5
Include successful and unsuccessful FTPS uploads in telemetry
jeremyestein cd0c6f4
Comment clarifications
jeremyestein cb9af75
Add link to Slab article
jeremyestein File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,41 @@ | ||
| # DSH | ||
|
|
||
| Only files from the `pseudonymised` directory are uploaded. | ||
| There are safeguards to avoid accidentally uploading from any other directory. | ||
|
|
||
| ## DSH FTPS | ||
|
|
||
| ### Write-only uploader accounts | ||
|
|
||
| See slab article on | ||
| [how to configure the uploader accounts](https://uclh.slab.com/posts/ftps-dsh-uploads-9otokl8x). | ||
|
|
||
| ### Notifications | ||
|
|
||
| One email notification is sent from the DSH per uploaded file. | ||
| We need to upload ~hundreds every day, therefore we use a temporary TAR file so that | ||
| all our parquets are uploaded in one go. | ||
|
|
||
| The TAR file is named according to the *time of upload*, but the file structure within it | ||
| is done according to the event times of the data. | ||
|
|
||
| Example output of `tar tvf`: | ||
| ``` | ||
| -rw-r--r-- 0 root root 10622 24 Aug 17:17 2024-09-12/2024-09-12.4e121edfa3d75b935975bdf2db2c32e229ab3c2764873b506b3fec192bd0b8ec.1570.noCh.cmH2O.parquet | ||
| -rw-r--r-- 0 root root 10982 24 Aug 17:17 2024-09-12/2024-09-12.6aae1d263b6029b2344750c9e56a2ccadbd2bf6b08bd0fb6469f4274d96fbb3d.1408.noCh.s.parquet | ||
| ... | ||
| ``` | ||
|
|
||
| Naming by upload time means that subsequently uploaded TAR files | ||
| will never overwrite previous ones. | ||
| This allows for incremental uploads; that is, the addition of extra data | ||
| (eg. new variables, new patients) | ||
| for dates that have already had an upload in the past. | ||
| *However*, the extracted files will clash in name, as the names | ||
| of the parquets within are anchored to the original event date. | ||
| It would be the job of a future DSH extractor script to do the right thing here. | ||
| Eg. to have a rule that extracts from later uploads always take precedence. | ||
| See [issue #84](https://github.com/SAFEHR-data/waveform-controller/issues/84) . | ||
|
|
||
| The uploaded file name is stored in JSON on the GAE in the daily uploaded sentinel file: | ||
| eg. `waveform-export/ftps-logs/2024-09-12/2024-09-12.uploaded.json` |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -1,13 +1,20 @@ | ||||||
| import argparse | ||||||
| import json | ||||||
| import logging | ||||||
| import os | ||||||
| import tarfile | ||||||
| from pathlib import Path | ||||||
| from tempfile import NamedTemporaryFile | ||||||
| from time import perf_counter | ||||||
| from typing import Any | ||||||
|
|
||||||
| import settings | ||||||
| from core.uploader._ftps import _connect_to_ftp, _create_and_set_as_cwd | ||||||
| from core.uploader._ftps import _connect_to_ftp, _create_and_set_as_cwd_multi_path | ||||||
|
|
||||||
| import settings | ||||||
| import telemetry | ||||||
| from locations import WAVEFORM_PSEUDONYMISED_PARQUET | ||||||
|
|
||||||
| logger = logging.getLogger(__name__) | ||||||
|
|
||||||
|
|
||||||
| def do_upload_cli(): | ||||||
| parser = argparse.ArgumentParser() | ||||||
|
|
@@ -17,46 +24,128 @@ def do_upload_cli(): | |||||
| help="file to upload relative to pseudonymised folder", | ||||||
| ) | ||||||
| args = parser.parse_args() | ||||||
| do_upload(args.file_to_upload) | ||||||
| do_upload_multiple(args.file_to_upload) | ||||||
|
|
||||||
|
|
||||||
| def do_upload(abs_file_to_upload: Path): | ||||||
| def do_upload_multiple_with_telemetry( | ||||||
| file_list: list[Path], remote_tar_filename: str, wc_date: str | ||||||
| ): | ||||||
| start_perf = perf_counter() | ||||||
| logger.info( | ||||||
| "Calling do_upload_multiple to create temp tar file %s", remote_tar_filename | ||||||
| ) | ||||||
| attrs = { | ||||||
| "obs_date": wc_date, | ||||||
| } | ||||||
| try: | ||||||
| do_upload_multiple(file_list, remote_tar_filename) | ||||||
| except Exception as e: | ||||||
| attrs["error.type"] = str(type(e)) | ||||||
| logger.exception( | ||||||
| "FTPS upload failed for remote filename %s", remote_tar_filename, exc_info=e | ||||||
| ) | ||||||
| raise | ||||||
| finally: | ||||||
| perf_time = perf_counter() - start_perf | ||||||
| telemetry.ftps_uploaded.add(1, attributes=attrs) | ||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. How do we want telemetry to count successful uploads now? Once per archive or once per parquet? Could do this?
Suggested change
|
||||||
| telemetry.ftps_time_taken.record(perf_time) | ||||||
| return perf_time | ||||||
|
|
||||||
|
|
||||||
| def do_upload_multiple( | ||||||
| abs_files_to_upload: list[Path], remote_tar_filename: str | ||||||
| ) -> None: | ||||||
| """We need to ensure that a user cannot accidentally ask for a file to be uploaded | ||||||
| unless it's under the correct directory that we know contains pseudonymised data.""" | ||||||
| logger = logging.getLogger(__name__) | ||||||
| # Keep things simple, paths must be absolute | ||||||
| if not abs_file_to_upload.is_absolute(): | ||||||
| raise ValueError("File must be relative to pseudonymised folder") | ||||||
| # Even an absolute path may contain a ".." or a symlink. Fully resolve so we | ||||||
| # know what we are dealing with. | ||||||
| file_to_upload = abs_file_to_upload.resolve() | ||||||
| # Check the file is still under the "safe" directory for upload. | ||||||
| if not file_to_upload.is_relative_to(WAVEFORM_PSEUDONYMISED_PARQUET): | ||||||
| raise ValueError( | ||||||
| f"File {file_to_upload} must be under {WAVEFORM_PSEUDONYMISED_PARQUET}. " | ||||||
| f"If this is unexpected, maybe you are using symlinks or '..' in the path?" | ||||||
| ) | ||||||
| if not file_to_upload.exists(): | ||||||
| raise ValueError(f"File {file_to_upload} does not exist") | ||||||
| logger.info( | ||||||
| "Connecting to FTPS server %s:%s, with username %s", | ||||||
| settings.FTPS_HOST, | ||||||
| settings.FTPS_PORT, | ||||||
| settings.FTPS_USERNAME, | ||||||
| rel_norm_files_to_upload = [] | ||||||
| for abs_file in abs_files_to_upload: | ||||||
| if not abs_file.is_absolute(): | ||||||
| raise ValueError("File must be relative to pseudonymised folder") | ||||||
| # Even an absolute path may contain a ".." or a symlink. Fully resolve so we | ||||||
| # know what we are dealing with. | ||||||
| norm_file = abs_file.resolve() | ||||||
| # Check the file is still under the "safe" directory for upload. | ||||||
| try: | ||||||
| rel_norm_file = norm_file.relative_to(WAVEFORM_PSEUDONYMISED_PARQUET) | ||||||
| except ValueError as e: | ||||||
| raise ValueError( | ||||||
| f"File {norm_file} must be under {WAVEFORM_PSEUDONYMISED_PARQUET}. " | ||||||
| f"If this is unexpected, maybe you are using symlinks or '..' in the path?" | ||||||
| ) from e | ||||||
| if not norm_file.exists(): | ||||||
| raise ValueError(f"File {norm_file} does not exist") | ||||||
| rel_norm_files_to_upload.append(rel_norm_file) | ||||||
| # We get one notification email per file uploaded, so tar it up to reduce this. | ||||||
| # Use a directory under WAVEFORM_PSEUDONYMISED_PARQUET so we keep all the pseudon data | ||||||
| # in one place. | ||||||
| tmp_tar_dir = WAVEFORM_PSEUDONYMISED_PARQUET / "tmp_tar" | ||||||
| tmp_tar_dir.mkdir(exist_ok=True) | ||||||
| remote_project_dir = ( | ||||||
| Path("waveform-export") / settings.INSTANCE_NAME / "pseudonymised" | ||||||
| ) | ||||||
| ftp = _connect_to_ftp( | ||||||
| settings.FTPS_HOST, | ||||||
| settings.FTPS_PORT, | ||||||
| settings.FTPS_USERNAME, | ||||||
| settings.FTPS_PASSWORD, | ||||||
| logger.info( | ||||||
| "tmp_tar_dir: %s,\nremote_project_dir = %s", tmp_tar_dir, remote_project_dir | ||||||
| ) | ||||||
| remote_project_dir = str(Path("waveform-export") / settings.INSTANCE_NAME) | ||||||
| _create_and_set_as_cwd(ftp, remote_project_dir) | ||||||
| remote_filename = os.path.basename(file_to_upload) | ||||||
| command = f"STOR {remote_filename}" | ||||||
| logger.info("Uploading file %s", file_to_upload) | ||||||
| with open(file_to_upload, "rb") as file_to_upload_fh: | ||||||
| ftp.storbinary(command, file_to_upload_fh) | ||||||
| print("Directory listing: ") | ||||||
| ftp.dir() | ||||||
| ftp.quit() | ||||||
| with NamedTemporaryFile( | ||||||
| dir=tmp_tar_dir, delete_on_close=False, delete=False | ||||||
| ) as temp_tar_file_path: | ||||||
| logger.info("Making temp tarfile: %s", temp_tar_file_path.name) | ||||||
| with tarfile.TarFile(fileobj=temp_tar_file_path, mode="w") as tar_file: | ||||||
| for file_to_upload in rel_norm_files_to_upload: | ||||||
| tar_file.add( | ||||||
| WAVEFORM_PSEUDONYMISED_PARQUET / file_to_upload, | ||||||
| arcname=file_to_upload, | ||||||
| ) | ||||||
| # tar writer has finished writing, but flush to disk and seek to beginning of file | ||||||
| temp_tar_file_path.flush() | ||||||
| temp_tar_file_path.seek(0) | ||||||
| logger.info( | ||||||
| "Connecting to FTPS server %s:%s, with username %s", | ||||||
| settings.FTPS_HOST, | ||||||
| settings.FTPS_PORT, | ||||||
| settings.FTPS_USERNAME, | ||||||
| ) | ||||||
| ftp = _connect_to_ftp( | ||||||
| settings.FTPS_HOST, | ||||||
| settings.FTPS_PORT, | ||||||
| settings.FTPS_USERNAME, | ||||||
| settings.FTPS_PASSWORD, | ||||||
| ) | ||||||
| _create_and_set_as_cwd_multi_path(ftp, remote_project_dir) | ||||||
| command = f"STOR {remote_tar_filename}" | ||||||
| tar_file_size = Path(temp_tar_file_path.name).stat().st_size | ||||||
| logger.info( | ||||||
| "Uploading temp tarfile as %s in remote dir %s (%s bytes)", | ||||||
| remote_tar_filename, | ||||||
| remote_project_dir, | ||||||
| tar_file_size, | ||||||
| ) | ||||||
| resp_code = ftp.storbinary(command, temp_tar_file_path) | ||||||
| # Log but don't check the response code; rely on raising one | ||||||
| # of the ftplib exceptions to detect errors | ||||||
| logger.info("FTP response code: %s", resp_code) | ||||||
| # I wanted to upload with a ".part" suffix, then rename to remove the | ||||||
| # suffix, to make it very clear to the DSH end that the file transfer completed. | ||||||
| # However, renaming results in error_perm (550 Permission denied), presumably because | ||||||
| # of the write-only policy. | ||||||
| print("Directory listing: ") | ||||||
| ftp.dir() | ||||||
| ftp.quit() | ||||||
|
|
||||||
|
|
||||||
| def write_ftps_sentinel( | ||||||
| overall_stats_dict: dict[str, Any], | ||||||
| sentinel_file: Path, | ||||||
| uploaded_files: list[Path], | ||||||
| ): | ||||||
| sentinel_data = { | ||||||
| "overall": overall_stats_dict, | ||||||
| "uploaded_files": uploaded_files, | ||||||
| } | ||||||
| with open(sentinel_file, "w") as fh: | ||||||
| json.dump( | ||||||
| sentinel_data, | ||||||
| fh, | ||||||
| indent=0, | ||||||
| ) | ||||||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We need to add the second argument now?