Skip to content
Closed
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions phylogenetic/defaults/config.yaml
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
# Sequences must be FASTA and metadata must be TSV
# Both files must be zstd compressed

# TODO XXX - remove commented lines below - only for testing the snakemake storage stuff
inputs:
- name: ncbi
metadata: "s3://nextstrain-data/files/workflows/zika/metadata.tsv.zst"
sequences: "s3://nextstrain-data/files/workflows/zika/sequences.fasta.zst"
# metadata: "https://data.nextstrain.org/files/workflows/zika/metadata.tsv.zst"
# sequences: "https://data.nextstrain.org/files/workflows/zika/sequences.fasta.zst"
Comment thread
jameshadfield marked this conversation as resolved.
Outdated

additional_inputs:
- name: usvi
metadata: "data/metadata_usvi.tsv"
sequences: "data/sequences_usvi.fasta"
# metadata: "data/metadata_usvi.tsv"
# sequences: "data/sequences_usvi.fasta"
metadata: https://raw.githubusercontent.com/nextstrain/zika/refs/heads/main/phylogenetic/data/metadata_usvi.tsv
sequences: https://raw.githubusercontent.com/nextstrain/zika/refs/heads/main/phylogenetic/data/sequences_usvi.fasta

# Config files
exclude: "exclude.txt"
Expand Down Expand Up @@ -41,3 +46,4 @@ traits:
- region
- country
sampling_bias_correction: 3
#
96 changes: 6 additions & 90 deletions phylogenetic/rules/merge_inputs.smk
Original file line number Diff line number Diff line change
Expand Up @@ -22,55 +22,7 @@ This part of the workflow usually includes the following steps:

"""


# ------------- helper functions to collect, merge & download input files ------------------- #
NEXTSTRAIN_PUBLIC_BUCKET = "s3://nextstrain-data/"

def _parse_config_input(input):
"""
Parses information from an individual config-defined input, i.e. an element within `config.inputs` or `config.additional_inputs`
and returns information snakemake rules can use to obtain the underlying data.

The structure of `input` is a dictionary with keys:
- name:string (required)
- metadata:string (optional) - a s3 URI or a local file path
- sequences:string (optional) - a s3 URI or a local file path

Returns a dictionary with optional keys:
- metadata:string - the relative path to the metadata file. If the original data was remote then this represents
the output of a rule which downloads the file
- metadata_location:string - the URI for the remote file if applicable else `None`
- sequences:string - the relative path to the sequences FASTA. If the original data was remote then this represents
the output of a rule which downloads the file
- sequences_location:string - the URI for the remote file if applicable else `None`

Raises InvalidConfigError
"""
name = input['name']

info = {'metadata': None, 'metadata_location': None, 'sequences': None, 'sequences_location': None}

def _source(uri, *, s3, local):
if uri.startswith('s3://'):
return s3
elif uri.lower().startswith(('http://','https://')):
raise InvalidConfigError("Workflow cannot yet handle HTTP[S] inputs")
# USVI files are expected to be part of the workflow source,
# and are _not_ expected to be provided via the analysis directory
elif uri.startswith('data/metadata_usvi.tsv') or uri.startswith('data/sequences_usvi.fasta'):
return workflow.source_path("../" + uri)
return local

if location:=input.get('metadata', False):
info['metadata'] = _source(location, s3=f"data/{name}/metadata.tsv", local=location)
info['metadata_location'] = _source(location, s3=location, local=None)

if location:=input.get('sequences', False):
info['sequences'] = _source(location, s3=f"data/{name}/sequences.fasta", local=location)
info['sequences_location'] = _source(location, s3=location, local=None)

return info

include: "remote_files.smk"
Comment thread
jameshadfield marked this conversation as resolved.

def _gather_inputs():
all_inputs = [*config['inputs'], *config.get('additional_inputs', [])]
Expand All @@ -85,7 +37,11 @@ def _gather_inputs():
if not all(['name' in i and ('sequences' in i or 'metadata' in i) for i in all_inputs]):
raise InvalidConfigError("Each input (config.inputs and config.additional_inputs) must have a 'name' and 'metadata' and/or 'sequences'")

return {i['name']: _parse_config_input(i) for i in all_inputs}
available_keys = set(['name', 'metadata', 'sequences'])
if any([len(set(el.keys())-available_keys)>0 for el in all_inputs]):
raise InvalidConfigError(f"Each input (config.inputs and config.additional_inputs) can only include keys of {', '.join(available_keys)}")

return {el['name']: {k:(v if k=='name' else path_or_url(v)) for k,v in el.items()} for el in all_inputs}

input_sources = _gather_inputs()

Expand All @@ -97,44 +53,6 @@ def input_sequences(wildcards):
inputs = [info['sequences'] for info in input_sources.values() if info.get('sequences', None)]
return inputs[0] if len(inputs)==1 else "results/sequences_merged.fasta"

rule download_s3_sequences:
output:
sequences = "data/{input_name}/sequences.fasta",
log:
"logs/{input_name}/download_s3_sequences.txt",
benchmark:
"benchmarks/{input_name}/download_s3_sequences.txt"
params:
address = lambda w: input_sources[w.input_name]['sequences_location'],
no_sign_request=lambda w: "--no-sign-request" \
if input_sources[w.input_name]['sequences_location'].startswith(NEXTSTRAIN_PUBLIC_BUCKET) \
else "",
shell:
r"""
exec &> >(tee {log:q})

aws s3 cp {params.no_sign_request:q} {params.address:q} - | zstd -d > {output.sequences}
"""

rule download_s3_metadata:
output:
metadata = "data/{input_name}/metadata.tsv",
log:
"logs/{input_name}/download_s3_metadata.txt",
benchmark:
"benchmarks/{input_name}/download_s3_metadata.txt"
params:
address = lambda w: input_sources[w.input_name]['metadata_location'],
no_sign_request=lambda w: "--no-sign-request" \
if input_sources[w.input_name]['metadata_location'].startswith(NEXTSTRAIN_PUBLIC_BUCKET) \
else "",
shell:
r"""
exec &> >(tee {log:q})

aws s3 cp {params.no_sign_request:q} {params.address:q} - | zstd -d > {output.metadata}
"""

rule merge_metadata:
"""
This rule should only be invoked if there are multiple defined metadata inputs
Expand Down Expand Up @@ -180,5 +98,3 @@ rule merge_sequences:

seqkit rmdup {input:q} > {output.sequences:q}
"""

# -------------------------------------------------------------------------------------------- #
9 changes: 9 additions & 0 deletions phylogenetic/rules/prepare_sequences.smk
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,15 @@ rule filter:
--min-length {params.min_length:q}
"""

rule upload_filter:
"""TESTING ONLY TODO XXX REMOVE"""
input: "results/filtered.fasta"
output: path_or_url("s3://nextstrain-scratch/testing-zika-filtered.fasta")
shell:
r"""
cp {input[0]} {output[0]}
"""

rule align:
"""
Aligning sequences to {input.reference}
Expand Down
82 changes: 82 additions & 0 deletions phylogenetic/rules/remote_files.smk
Comment thread
joverlee521 marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
"""
Helper functions to set-up storage plugins for remote inputs/outputs.
See the docstring of `path_or_url` for usage instructions.

<https://snakemake.readthedocs.io/en/stable/snakefiles/storage.html>
"""

from urllib.parse import urlparse

PUBLIC_BUCKETS = set(['nextstrain-data']) # TODO XXX

_storage_registry = {} # keeps track of registered storage plugins to enable reuse

def _storage_s3(*, bucket, keep_local, force_signed):
"""
Returns a Snakemake storage plugin for S3 endpoints
"""
retries=2 # num S3 retries attempted

# If the bucket is public then we make an unsigned request (i.e. no AWS credentials
# need to be set). Note that this won't work for uploads.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we should drop this special-treatment of known public buckets? It would simplify the code and let us avoid the force_signed argument. For dev purposes I find it frustrating to have to load credentials to fetch data in s3://nextstrain-data, but we could instead avoid this by using https://data.nextstrain.org/... URLs.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm open to public buckets being referred to by the https: URLs.

And then keeping s3 handling for less public (private? restricted use?) datasets in case that usecase shows up if it hasn't already.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1 for removing the special treatment for public buckets.

I say follow our own guidance from ncov/remote_inputs docs:

If you’re running workflows on AWS or GCP compute that fetch this data, please use the S3 or GS URLs, respectively, for cheaper (for us) and faster (for you) data transfers. Otherwise, please use the https://data.nextstrain.org/ URLs.

Note that even though the s3://nextstrain-data/ and gs://nextstrain-data/ buckets are public, the defaults for most S3 and GS clients require some user to be authenticated, though the specific user/account doesn’t matter. In the rare case you need to access the S3 or GS buckets anonymously, the easiest way is to configure your inputs using https://nextstrain-data.s3.amazonaws.com/files/ncov/open/ or https://storage.googleapis.com/nextstrain-data/files/ncov/open/ URLs instead.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, I'd say that advice is conditioned on the situation where we have to be authenticated for public bucket requests. But this PR shows we don't. So if we pay a small code-complexity price we can get cheaper & faster transfers (S3) without the frustrating UX of needing to provide credentials. If it were only data downloading I'd keep this PR as is, it was only when testing/considering uploads that I started to question it. I'll think about this some more.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With the misleading errors around credentials, should we enforce credential requirements here, e.g.:

assert "AWS_ACCESS_KEY_ID" in os.environ and "AWS_SECRET_ACCESS_KEY" in os.environ, \
        "Must set `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` environment variables for S3 files"

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've updated the code and I'm happy with the UX now. Here's the logic of when we use signed vs unsigned:

If the bucket is public then we may use an unsigned request which has the nice UX of not needing credentials to be present. If we've made other signed requests or credentials are present then we just sign everything. This has implications for upload: if you attempt to upload to a public bucket without credentials then we allow that here and you'll get a subsequent AccessDenied error when the upload is attempted.

and a table of situations:

S3 buckets credentials present credentials missing
download private / private + public signed Error 1
public signed unsigned
upload private / private + public signed Error 1
public signed Error 2

Error 1:

AWS credentials are required to access <S3 URI>

Error 2:

WorkflowError:
Failed to store output in storage <S3 URI>
ClientError: An error occurred (AccessDenied) when calling the CreateBucket operation: Anonymous users cannot invoke this API. Please authenticate.
WorkflowError:
Failed to get mtime of <S3 URI>
ClientError: An error occurred (AccessDenied) when calling the ListObjects operation: Access Denied

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for thinking this through @jameshadfield, I really like the outlined behavior!

Providing bad credentials produce slightly different errors, but they should be informative for the user:

$ nextstrain build --image nextstrain/base:branch-snakemake-v9 --env AWS_ACCESS_KEY_ID=foo --env AWS_SECRET_ACCESS_KEY=bar phylogenetic/ --forcerun merge_metadata
Assuming unrestricted shared filesystem usage.
host: 0f11191f17e0
Building DAG of jobs...
WorkflowError:
Failed to retrieve input from storage.
WorkflowError:
    Failed to get mtime of s3://nextstrain-data/files/workflows/zika/metadata.tsv.zst
    ClientError: An error occurred (InvalidAccessKeyId) when calling the ListObjects operation: The AWS Access Key Id you provided does not exist in our records.
$ nextstrain build --image nextstrain/base:branch-snakemake-v9 --env AWS_ACCESS_KEY_ID=foo --env AWS_SECRET_ACCESS_KEY=bar phylogenetic/ --forcerun filter
Assuming unrestricted shared filesystem usage.
host: 2d666b298294
Building DAG of jobs...
WorkflowError:
Failed to check existence of s3://nextstrain-data/files/workflows/zika/metadata.tsv.zst
ClientError: An error occurred (403) when calling the HeadObject operation: Forbidden

if not force_signed and bucket in PUBLIC_BUCKETS:
if provider:=_storage_registry.get('s3_unsigned', None):
return provider

from botocore import UNSIGNED
storage s3_unsigned:
provider="s3",
signature_version=UNSIGNED,
retries=retries,
keep_local=keep_local,

_storage_registry['s3_unsigned'] = storage.s3_unsigned
return _storage_registry['s3_unsigned']

# Default: resource fetched via a signed request, which will require AWS credentials
if provider:=_storage_registry.get('s3_signed', None):
return provider

# the tag appears in the local file path, so reference 'signed' to give a hint about credential errors
storage s3_signed:
provider="s3",
retries=retries,
keep_local=keep_local,

_storage_registry['s3_signed'] = storage.s3_signed
return _storage_registry['s3_signed']

def _storage_http(*, keep_local):
if provider:=_storage_registry.get('http', None):
return provider

storage:
provider="http",
allow_redirects=True,
supports_head=True,
keep_local=keep_local,

_storage_registry['http'] = storage.http
return _storage_registry['http']

def path_or_url(uri, *, keep_local=True, force_signed=False):
"""
Returns the URI wrapped by an applicable storage plugin.
Local filepaths will be returned unchanged.

TODO XXX - document usage more thoroughly
"""
info = urlparse(uri)

if info.scheme=='': # local
return uri # no storage wrapper

if info.scheme=='s3':
return _storage_s3(bucket=info.netloc, keep_local=keep_local, force_signed=force_signed)(uri)

if info.scheme in ['http', 'https']:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need to support plain HTTP? Requiring HTTPS doesn't feel onerous and is kind of a security best-practice

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's currently supported in ncov so I think we're trying to keep the functionality the same here.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

...ok, but -- is it actually used in ncov? because this feels like a great time for a deprecation…

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

True! Especially since the Snakemake upgrade is a breaking change anyways...

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

return _storage_http(keep_local=keep_local)(uri)

# TODO XXX - Google? We allowed this in ncov <https://github.com/nextstrain/ncov/blob/41cf6470d3140963ff3e02c29241f80ae8ed9c33/workflow/snakemake_rules/remote_files.smk#L62>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1 for supporting GS urls if possible (e.g. Terra users, etc)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1, I've included the GS plugin (snakemake-storage-plugin-gcs) in nextstrain/docker-base#257

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds good - can one of you add it to this PR?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

added in 70e16f8, but not sure how to test here...

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@j23414 are you able to test the GS stuff? I presume Broad is mirroring the zika files?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, seems like Broad is mirroring everything in nextstrain-data to GS, so I found
https://storage.googleapis.com/nextstrain-data/files/workflows/zika/metadata.tsv.zst
(gs://nextstrain-data/files/workflows/zika/metadata.tsv.zst) which we can use to test.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@j23414 and I discussed GS in person yesterday.

We ran into authentication errors that indicates you need to set up Application Default Credentials in order to use the GS plugin. I have not found any way of passing in credentials via envvars for this plugin....

Even the old Snakemake v7 GS provider required you to login via gcloud auth before running Snakemake. I'm not sure how well this ever worked with the Docker runtime. The gcloud auth command creates a JSON file that is stored at ~/.config/gcloud/application_default_credentials.json, which is not accessible in the docker runtime. You can use GOOGLE_APPLICATION_CREDENTIALS to define a separate location for the credentials file, so you could create the credentials JSON within the pathogen repo and pass the --env GOOGLE_APPLICATION_CREDENTIALS=... to use it in the Docker runtime, but I'm not sure how safe that is...

All that is to say, I'm walking back on my support for GS and think it's not worth the trouble.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for testing! I'm going to take this code over to ncov now and will remove GS. I'll add a specific conditional to catch the GS scheme (and HTTP) and raise an error telling users to get in touch with us because we can add those functionality if pushed to do so.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.


raise Exception(f"Input address {uri!r} (scheme={info.scheme!r}) is from a non-supported remote")
Loading