Skip to content

Commit a71fd03

Browse files
authored
Merge pull request #134 from developmentseed/docker-secrets
Use docker secrets for EDL credentials
2 parents 02fcccd + 3e45d37 commit a71fd03

File tree

5 files changed

+178
-25
lines changed

5 files changed

+178
-25
lines changed

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
secrets/
2+
13
# Byte-compiled / optimized / DLL files
24
__pycache__/
35
*.py[cod]

Dockerfile

Lines changed: 7 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,5 @@
1-
# check=skip=JSONArgsRecommended
21
FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim
32

4-
ARG EARTHDATA_USERNAME
5-
ARG EARTHDATA_PASSWORD
6-
7-
# Check if EARTHDATA_USERNAME and EARTHDATA_PASSWORD are provided
8-
RUN <<'EOF'
9-
if [ -z "$EARTHDATA_USERNAME" ] || [ -z "$EARTHDATA_PASSWORD" ]; then
10-
echo "Error: EARTHDATA_USERNAME and EARTHDATA_PASSWORD build args must be provided"
11-
exit 1
12-
fi
13-
14-
echo "machine urs.earthdata.nasa.gov login ${EARTHDATA_USERNAME} password ${EARTHDATA_PASSWORD}" > ~/.netrc
15-
unset EARTHDATA_USERNAME EARTHDATA_PASSWORD
16-
EOF
17-
183
RUN <<'EOF'
194
apt-get update
205
apt-get -y --no-install-recommends install libexpat1
@@ -24,11 +9,16 @@ EOF
249
WORKDIR /app
2510

2611
COPY pyproject.toml uv.lock README.md LICENSE ./
12+
RUN uv sync --no-dev --frozen --extra uvicorn
13+
2714
COPY titiler ./titiler
2815

29-
RUN uv sync --no-dev --frozen --extra uvicorn
16+
RUN \
17+
--mount=type=secret,id=earthdata-username,required,env=EARTHDATA_USERNAME \
18+
--mount=type=secret,id=earthdata-password,required,env=EARTHDATA_PASSWORD \
19+
echo "machine urs.earthdata.nasa.gov login ${EARTHDATA_USERNAME:?} password ${EARTHDATA_PASSWORD:?}" > ~/.netrc
3020

3121
# http://www.uvicorn.org/settings/
3222
ENV HOST=0.0.0.0
3323
ENV PORT=80
34-
CMD uv run --no-dev uvicorn titiler.cmr.main:app --host ${HOST} --port ${PORT} --log-level debug --reload
24+
CMD ["/bin/sh", "-c", "uv run --no-dev uvicorn titiler.cmr.main:app --host ${HOST} --port ${PORT} --log-level debug --reload"]

README.md

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -103,8 +103,27 @@ echo "machine urs.earthdata.nasa.gov login ${EARTHDATA_USERNAME} password ${EART
103103
104104
## Docker deployment
105105

106-
You can run the application in a docker container using the docker-compose.yml file.
107-
The docker container is configured to read the `EARTHDATA_USERNAME` and `EARTHDATA_PASSWORD` environment variables so make sure set those before starting the docker network.
106+
You can run the application in a docker container using the `docker-compose.yml`
107+
file. The docker container is configured to use secrets for your Earthdata Login
108+
credentials, so make sure the secrets exist by first running the following
109+
script:
110+
111+
```bash
112+
uv run scripts/write-secrets.py
113+
```
114+
115+
The script will look for your Earthdata Login credentials in the following
116+
places, in descending order of precedence:
117+
118+
- Exported `EARTHDATA_USERNAME` and `EARTHDATA_PASSWORD` environment variables
119+
- A `.env.secrets` file
120+
- A `.env` file
121+
- A netrc file (defaults to `~/.netrc`, but can be set via `NETRC` environment
122+
variable)
123+
- As a last resort, prompts for input
124+
125+
If you ever change your credentials, rerun the script to repopulate the secrets.
126+
Once the secrets are generated, you can start the application as follows:
108127

109128
```bash
110129
docker compose up --build

docker-compose.yml

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,36 @@
1+
secrets:
2+
# To generate the secret files, run the following command:
3+
#
4+
# uv run scripts/write-secrets.py
5+
#
6+
# See comments in the script describing how it obtains the values to write.
7+
#
8+
# Unfortunately, when specifying secrets via docker compose, we are currently
9+
# forced to use secret files rather than the more convenient environment
10+
# variable option due to a bug in how docker compose passes env-var-based
11+
# secret values to the docker build process.
12+
#
13+
# Although the following issues are closed, there still appears to be broken
14+
# behavior, and there are other related issues mentioned in various comments:
15+
#
16+
# - https://github.com/docker/compose/issues/13255
17+
# - https://github.com/docker/compose/issues/13235
18+
#
19+
earthdata-username:
20+
file: ./secrets/earthdata-username.txt
21+
earthdata-password:
22+
file: ./secrets/earthdata-password.txt
23+
124
services:
225
tiler:
326
container_name: tiler-cmr
427
platform: linux/amd64
528
build:
629
context: .
730
dockerfile: Dockerfile
8-
args:
9-
EARTHDATA_USERNAME: ${EARTHDATA_USERNAME}
10-
EARTHDATA_PASSWORD: ${EARTHDATA_PASSWORD}
31+
secrets:
32+
- earthdata-username
33+
- earthdata-password
1134
ports:
1235
- "8081:8081"
1336
volumes:
@@ -20,9 +43,6 @@ services:
2043
- TITILER_CMR_DEBUG=TRUE
2144
- TITILER_CMR_S3_AUTH_STRATEGY=netrc
2245
- TITILER_CMR_S3_AUTH_ACCESS=external
23-
# earthdata authentication
24-
- EARTHDATA_USERNAME=${EARTHDATA_USERNAME}
25-
- EARTHDATA_PASSWORD=${EARTHDATA_PASSWORD}
2646
# GDAL Config
2747
# This option controls the default GDAL raster block cache size.
2848
# If its value is small (less than 100000), it is assumed to be measured in megabytes, otherwise in bytes.

scripts/write-secrets.py

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
"""Initialize Docker secrets with Earthdata credentials for local development.
2+
3+
Run this once prior to running "docker compose up --build". There is no need
4+
to run it again unless your Earthdata credentials change.
5+
6+
Creates the following secret files within the ./secrets directory (git-ignored)
7+
for use with Docker:
8+
9+
- earthdata-username.txt
10+
- earthdata-password.txt
11+
12+
If necessary, creates the ./secrets directory with permissions 700, and creates
13+
files within it with permissions 600.
14+
15+
Attempts to populate each secret as follows, in descending order of precedence:
16+
17+
1. Looks for corresponding environment variable (EARTHDATA_USERNAME and
18+
EARTHDATA_PASSWORD).
19+
2. Loads from .env.secrets file, if exists.
20+
3. Loads from .env file, if exists.
21+
4. Loads from netrc file, if exists (defaults to ~/.netrc, but can set NETRC env var)
22+
5. Prompts user as last resort.
23+
24+
This will ALWAYS overwrite existing secret files.
25+
"""
26+
27+
# /// script
28+
# requires-python = ">=3.12"
29+
# dependencies = [
30+
# "python-dotenv>=1.2.1",
31+
# ]
32+
# ///
33+
import logging
34+
import netrc
35+
import os
36+
from getpass import getpass
37+
from pathlib import Path
38+
from typing import Mapping
39+
40+
from dotenv import dotenv_values
41+
42+
logging.basicConfig(level=logging.INFO)
43+
44+
45+
def read_env() -> Mapping[str, str]:
46+
"""Read environment variables, env files, and netrc as unified 'environment'.
47+
48+
Returns a mapping of "environment variables", obtained from the following
49+
sources in descending order of precedence:
50+
51+
- Environment variables (highest precedence)
52+
- Variables from .env.secrets, if it exists
53+
- Variables from .env, if it exists
54+
- netrc file, if it exists (default location can be overridden via NETRC
55+
environment variable): if an entry is found for host urs.earthdata.nasa.gov,
56+
sets default values on returned mapping for EARTHDATA_USERNAME and
57+
EARTHDATA_PASSWORD (i.e., only visible if these are not already set by
58+
a source listed above, with higher precedence)
59+
"""
60+
env = {
61+
**dotenv_values(".env"),
62+
**dotenv_values(".env.secrets"),
63+
**os.environ,
64+
}
65+
66+
if auth := netrc.netrc(env.get("NETRC")).authenticators("urs.earthdata.nasa.gov"):
67+
username, _, password = auth
68+
env.setdefault("EARTHDATA_USERNAME", username)
69+
env.setdefault("EARTHDATA_PASSWORD", password)
70+
71+
return env
72+
73+
74+
def ensure_secrets_dir() -> Path:
75+
"""Make sure the secrets directory exists with mode 0o700 (rwx for user only)."""
76+
path = Path("./secrets")
77+
path.mkdir(mode=0o700, parents=True, exist_ok=True)
78+
79+
return path
80+
81+
82+
def to_kebab_case(name: str) -> str:
83+
"""Convert variable name to kebab case.
84+
85+
Example: EARTHDATA_USERNAME becomes earthdata-username
86+
"""
87+
return name.lower().replace("_", "-")
88+
89+
90+
def ask_user(prompt: str, sensitive: bool) -> str:
91+
"""Prompt user for a possibly sensitive value."""
92+
return getpass(prompt) if sensitive else input(prompt)
93+
94+
95+
def write_secret(secrets_dir: Path, name: str, value: str) -> None:
96+
"""Write a secret (with only rw perms for user) to the secrets directory."""
97+
file = (secrets_dir / name).with_suffix(".txt")
98+
file.write_text(value)
99+
file.chmod(0o600)
100+
101+
102+
def main():
103+
"""Write Earthdata credentials as secrets for Docker.
104+
105+
Looks for EDL credentials in the environment, and if not found, prompts
106+
user to enter values, then writes them as secret files for Docker to use.
107+
"""
108+
env = read_env()
109+
secrets_dir = ensure_secrets_dir()
110+
111+
descriptors = (
112+
("EARTHDATA_USERNAME", "Earthdata username: ", False),
113+
("EARTHDATA_PASSWORD", "Earthdata password: ", True),
114+
)
115+
116+
for name, prompt, sensitive in descriptors:
117+
value = env.get(name) or ask_user(prompt, sensitive)
118+
write_secret(secrets_dir, to_kebab_case(name), value)
119+
120+
121+
if __name__ == "__main__":
122+
main()

0 commit comments

Comments
 (0)