Skip to content

Commit 5e9f31b

Browse files
authored
Merge pull request Caltech-IPAC#1698 from Caltech-IPAC/FIREFLY-1648-envvars-parsing
FIREFLY-1648: fix Dockerfile parsing envVars bugs
2 parents 8b24020 + e49769a commit 5e9f31b

File tree

6 files changed

+326
-10
lines changed

6 files changed

+326
-10
lines changed

docker/Dockerfile

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ RUN yarn install --ignore-platform --frozen-lockfile
3434
FROM node_module AS dev_env
3535

3636
RUN apt-get update \
37-
&& apt-get install -y vim procps \
37+
&& apt-get install -y vim procps python3 \
3838
# cleanup
3939
&& rm -rf /var/lib/apt/lists/*;
4040

@@ -52,7 +52,7 @@ ENV env=local
5252
WORKDIR ${CATALINA_HOME}
5353

5454
# Tomcat scripts, config files, etc
55-
COPY firefly/docker/*.sh ./
55+
COPY firefly/docker/*.sh firefly/docker/*.py ./
5656
COPY firefly/docker/tomcat-users.xml conf/
5757

5858
# set up directory protections, copy stuff around, add tomcat user and group
@@ -120,7 +120,7 @@ ARG gid=91
120120
# - this is a big part of the layer so do it early
121121
# - emacs removed because it is so big: to readd emacs: emacs-nox
122122
RUN apt-get update && apt-get install -y \
123-
vim procps wget unzip \
123+
vim procps wget unzip python3 \
124124
&& rm -rf /var/lib/apt/lists/*;
125125

126126
# These are the users replaceable environment variables, basically runtime arguments
@@ -132,7 +132,8 @@ RUN apt-get update && apt-get install -y \
132132
ENV JVM_CORES=0\
133133
DEBUG=false \
134134
CLEANUP_INTERVAL=12h \
135-
PROPS=''
135+
PROPS='' \
136+
PYTHONUNBUFFERED=1
136137

137138
# ----------------------------------------------------------
138139
# ----------------------------------------------------------
@@ -153,7 +154,7 @@ RUN mkdir -p conf/Catalina/localhost /local/www /firefly/config /firefly/workare
153154
&& rm -r logs && ln -s /firefly/logs logs
154155

155156
# These are the file that are executed at startup: start tomcat, logging, help, etc
156-
COPY firefly/docker/*.sh firefly/docker/*.txt ${CATALINA_HOME}/
157+
COPY firefly/docker/*.sh firefly/docker/*.txt firefly/docker/*.py ${CATALINA_HOME}/
157158

158159
# Tomcat config files, tomcat-users is for the admin username and password
159160
# context.xml set delegate to true for we can use the classpath of tomcat
@@ -182,4 +183,5 @@ EXPOSE 8080 5050
182183
USER tomcat
183184

184185
#CMD ["/bin/bash", "./launchTomcat.sh"]
185-
ENTRYPOINT ["/bin/bash", "-c", "./launchTomcat.sh ${*}", "--"]
186+
#ENTRYPOINT ["/bin/bash", "-c", "./launchTomcat.sh ${*}", "--"]
187+
ENTRYPOINT ["python3", "./entrypoint.py"]

docker/devMode.sh

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,4 +14,5 @@ for n in *.war; do \
1414
done
1515

1616
cd ${CATALINA_HOME}
17-
${CATALINA_HOME}/launchTomcat.sh
17+
#${CATALINA_HOME}/launchTomcat.sh
18+
python3 ${CATALINA_HOME}/entrypoint.py

docker/entrypoint.py

Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
#!/usr/bin/env python3
2+
3+
import sys
4+
import os
5+
import random
6+
import base64
7+
import subprocess
8+
import shlex
9+
10+
11+
def extract_war_files(webapps_dir, webapps_ref, path_prefix):
12+
"""
13+
Prepare webapps on first-time startup:
14+
- Extract WAR files from webapps-ref to webapps.
15+
- Modify log4j to send logs to stdout.
16+
- Modify context path (pathPrefix) if given.
17+
18+
:param webapps_dir: Path to the webapps directory.
19+
:param webapps_ref: Path to the webapps reference directory.
20+
:param path_prefix: Prefix for the context path.
21+
"""
22+
if not os.listdir(webapps_dir):
23+
for war in os.listdir(webapps_ref):
24+
if war.endswith(".war"):
25+
fn = os.path.splitext(war)[0]
26+
prefix = path_prefix.replace("/", "#").strip("#")
27+
war_dir = os.path.join(webapps_dir, f"{prefix}#{fn}" if prefix else fn)
28+
os.makedirs(war_dir, exist_ok=True)
29+
subprocess.call(["unzip", "-oqd", war_dir, os.path.join(webapps_ref, war)])
30+
subprocess.call(["sed", "-E", "-i.bak", "s/##out--//", os.path.join(war_dir, "WEB-INF/classes/log4j2.properties")])
31+
32+
33+
def add_multi_props_env_var():
34+
"""
35+
Process environment variable `PROPS`, where:
36+
- Key-value pairs are separated by `;`
37+
- Use double semicolons (`;;`) to escape semicolon `;`
38+
39+
Returns:
40+
str: JVM `-Dkey=value` options.
41+
"""
42+
props_opts = ""
43+
props_env = os.getenv("PROPS", "")
44+
45+
if props_env:
46+
placeholder = "__SEMICOLON__"
47+
props_env = props_env.replace(";;", placeholder)
48+
49+
for prop in props_env.split(";"):
50+
prop = prop.replace(placeholder, ";")
51+
if "=" in prop:
52+
key, value = prop.split("=", 1)
53+
key = key.strip()
54+
value = shlex.quote(value.strip())
55+
props_opts += f" -D{key}={value}"
56+
57+
return props_opts
58+
59+
60+
def add_single_prop_env_vars():
61+
"""
62+
Process environment variables that match `PROPS_*`, replacing:
63+
- `__` in variable names with `.`
64+
- Supports secrets and hard-to-escape characters.
65+
66+
Returns:
67+
str: JVM `-Dkey=value` options.
68+
"""
69+
props_opts = ""
70+
71+
for key, value in os.environ.items():
72+
if key.startswith("PROPS_"):
73+
prop_key = key.replace("PROPS_", "").replace("__", ".").strip()
74+
value = shlex.quote(value.strip())
75+
props_opts += f" -D{prop_key}={value}"
76+
77+
return props_opts
78+
79+
80+
def log_env_info(path_prefix, visualize_fits_search_path):
81+
"""Log environment variables and configuration information."""
82+
print(f"""
83+
========== Information: You can set environment variables using -e on the docker run line =====
84+
85+
Environment Variables:
86+
Description Name Value
87+
----------- -------- -----
88+
Admin username ADMIN_USER {os.getenv('ADMIN_USER')}
89+
Admin password ADMIN_PASSWORD {os.getenv('ADMIN_PASSWORD')}
90+
Additional data path VISUALIZE_FITS_SEARCH_PATH {visualize_fits_search_path}
91+
Clean internal (e.g., 720m, 5h) CLEANUP_INTERVAL {os.getenv('CLEANUP_INTERVAL', '')}
92+
Context path prefix PATH_PREFIX {path_prefix}
93+
94+
Advanced environment variables:
95+
Run tomcat with debug DEBUG {os.getenv('DEBUG', '')}
96+
Extra firefly properties (*) PROPS {os.getenv('PROPS', '')}
97+
Redis host PROPS_redis__host {os.getenv('PROPS_redis__host', '')}
98+
SSO host PROPS_sso__req__auth__hosts {os.getenv('PROPS_sso__req__auth__hosts', '')}
99+
firefly.options (JSON string) PROPS_FIREFLY_OPTIONS {os.getenv('PROPS_FIREFLY_OPTIONS', '')}
100+
(*) key=value pairs separated by semicolon; use double semicolons to escape semicolon
101+
102+
Ports:
103+
8080 - http
104+
5050 - debug
105+
106+
Volume Mount Points:
107+
/firefly/logs : logs directory
108+
/firefly/workarea : work area for temporary files
109+
/firefly/shared-workarea : work area for files shared between multiple instances of the application
110+
/external : default external data directory visible to Firefly
111+
112+
Less used:
113+
/firefly/config : used to override application properties
114+
/firefly/logs/statistics : directory for statistics logs
115+
/firefly/alerts : alerts monitor will watch this directory for application alerts
116+
117+
Command line options:
118+
--help : show help message, examples, stop
119+
--debug : start in debug mode
120+
""")
121+
122+
123+
def show_help(name):
124+
"""Show help message and exit."""
125+
with open("./start-examples.txt") as f:
126+
print(f.read().replace("ipac/firefly", name))
127+
128+
war_files = [f for f in os.listdir(webapps_ref) if f.endswith(".war")]
129+
if war_files and war_files[0] == "firefly.war":
130+
with open("./customize-firefly.txt") as f:
131+
print(f.read())
132+
sys.exit(0)
133+
134+
135+
def dry_run(cmd, webapps_dir):
136+
"""Display a dry run of the command and environment setup."""
137+
print("\n\n----------------------")
138+
print(f"Command: {' '.join(cmd)}")
139+
print(f"CATALINA_OPTS: {os.getenv('CATALINA_OPTS')}\n")
140+
141+
print(f"Contents of '{webapps_dir}':")
142+
for item in os.listdir(webapps_dir):
143+
item_path = os.path.join(webapps_dir, item)
144+
if os.path.isdir(item_path):
145+
print(f" [DIR] {item}")
146+
else:
147+
print(f" [FILE] {item}")
148+
print()
149+
sys.exit(0)
150+
151+
152+
# ================================ Main Execution =============================
153+
def main():
154+
"""Main execution function for the entrypoint script."""
155+
156+
os.environ["JPDA_ADDRESS"] = "*:5050"
157+
catalina_home = os.getenv("CATALINA_HOME", "/usr/local/tomcat")
158+
visualize_fits_search_path = os.getenv("VISUALIZE_FITS_SEARCH_PATH", "")
159+
start_mode = os.getenv("START_MODE", "run")
160+
name = os.getenv("BUILD_TIME_NAME", "ipac/firefly")
161+
admin_user = os.getenv("ADMIN_USER", "admin")
162+
admin_password = os.getenv("ADMIN_PASSWORD", base64.b64encode(str(random.randint(100000, 999999)).encode()).decode()[:8])
163+
use_admin_auth = os.getenv("USE_ADMIN_AUTH", "true").lower()
164+
path_prefix = os.getenv("PATH_PREFIX") or os.getenv("baseURL") or "" # use PATH_PREFIX; baseURL is for backward compatibility
165+
166+
vis_path = "/external" if not visualize_fits_search_path \
167+
else f"/external:{visualize_fits_search_path}"
168+
169+
catalina_opts = " ".join([
170+
f"-XX:InitialRAMPercentage={os.getenv('INIT_RAM_PERCENT', '10')}",
171+
f"-XX:MaxRAMPercentage={os.getenv('MAX_RAM_PERCENT', '80')}",
172+
"-XX:+UnlockExperimentalVMOptions", "-XX:TrimNativeHeapInterval=30000",
173+
f"-DADMIN_USER={admin_user}",
174+
f"-DADMIN_PASSWORD={admin_password}",
175+
f"-Dhost.name={os.getenv('HOSTNAME', '')}",
176+
f"-Dserver.cores={os.getenv('JVM_CORES', '')}",
177+
"-Djava.net.preferIPv4Stack=true",
178+
"-Dwork.directory=/firefly/workarea",
179+
"-Dshared.work.directory=/firefly/shared-workarea",
180+
"-Dserver_config_dir=/firefly/config",
181+
"-Dstats.log.dir=/firefly/logs/statistics",
182+
"-Dalerts.dir=/firefly/alerts",
183+
f"-Dvisualize.fits.search.path={vis_path}"
184+
])
185+
186+
# Remove admin protection if disabled
187+
if use_admin_auth == "false":
188+
catalina_opts += " -DADMIN_PROTECTED="
189+
190+
# extract and add properties defined as environment variables
191+
catalina_opts += add_multi_props_env_var()
192+
catalina_opts += add_single_prop_env_vars()
193+
194+
# Set environment variables so they persist in Tomcat
195+
os.environ["CATALINA_PID"] = os.path.join(catalina_home, "bin", "catalina.pid")
196+
os.environ["CATALINA_OPTS"] = catalina_opts
197+
os.environ["ADMIN_USER"] = admin_user
198+
os.environ["ADMIN_PASSWORD"] = admin_password
199+
debug_mode = os.getenv("DEBUG", "false").lower() == "true" or (len(sys.argv) > 1 and sys.argv[1] == "--debug")
200+
cmd = [f"{catalina_home}/bin/catalina.sh"]
201+
if debug_mode:
202+
cmd.append("jpda")
203+
cmd.append(start_mode)
204+
205+
# log environment information
206+
log_env_info(path_prefix, visualize_fits_search_path)
207+
208+
# Setup examples
209+
subprocess.call("./setupFireflyExample.sh", shell=True)
210+
211+
# Prepare webapps on first-time startup
212+
webapps_dir = os.path.join(catalina_home, "webapps")
213+
webapps_ref = os.path.join(catalina_home, "webapps-ref")
214+
extract_war_files(webapps_dir, webapps_ref, path_prefix)
215+
216+
# check for no-ops flags
217+
if len(sys.argv) > 1:
218+
arg = sys.argv[1]
219+
if arg in ["--help", "-help", "-h"]:
220+
show_help(name)
221+
elif arg == "--dry-run":
222+
dry_run(cmd, webapps_dir)
223+
224+
# Start background cleanup
225+
subprocess.Popen([f"{catalina_home}/cleanup.sh", "/firefly/workarea", "/firefly/shared-workarea"])
226+
227+
# Start Tomcat; Replace the current process with Tomcat
228+
print("Starting Tomcat...")
229+
os.execvp(cmd[0], cmd)
230+
231+
232+
if __name__ == "__main__":
233+
main()

docker/entrypoint_test.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import unittest
2+
from unittest.mock import patch
3+
import os
4+
import entrypoint # Import your script
5+
6+
class TestEntrypoint(unittest.TestCase):
7+
8+
9+
@patch.dict(os.environ, {
10+
"PROPS": (
11+
"logging.level=DEBUG;"
12+
"app.mode=production;"
13+
"key=value;;with semicolon;"
14+
"key2=value with \"quotes\";"
15+
"key3=value with 'single-quotes'"
16+
)
17+
})
18+
def test_addMultiPropsEnvVar(self):
19+
"""Test addMultiPropsEnvVar() processes PROPS correctly."""
20+
result = entrypoint.add_multi_props_env_var()
21+
print("Test: addMultiPropsEnvVar()")
22+
print("Actual Output :", result)
23+
24+
# Expected formatted JVM options
25+
expected_options = [
26+
"-Dlogging.level=DEBUG",
27+
"-Dapp.mode=production",
28+
"-Dkey='value;with semicolon'",
29+
"-Dkey2='value with \"quotes\"'",
30+
"""-Dkey3='value with '"'"'single-quotes'"'"''"""
31+
]
32+
33+
for option in expected_options:
34+
self.assertIn(option, result)
35+
36+
@patch.dict(os.environ, {
37+
"PROPS_logging__level": "DEBUG",
38+
"PROPS_app__mode": "production",
39+
"PROPS_special__key": "value with spaces",
40+
"PROPS_with__semicolon": "value; with semicolon",
41+
"PROPS_json": '{"abc": 123, "def": "value"}'
42+
})
43+
def test_addSinglePropEnvVars(self):
44+
"""Test addSinglePropEnvVars() processes PROPS_* environment variables correctly."""
45+
result = entrypoint.add_single_prop_env_vars()
46+
print("Test: test_addSinglePropEnvVars()")
47+
print("Actual Output :", result)
48+
49+
# Expected formatted JVM options
50+
expected_options = [
51+
"-Dlogging.level=DEBUG",
52+
"-Dapp.mode=production",
53+
"-Dspecial.key='value with spaces'",
54+
"-Dwith.semicolon='value; with semicolon'",
55+
"-Djson='{\"abc\": 123, \"def\": \"value\"}'"
56+
]
57+
58+
for option in expected_options:
59+
self.assertIn(option, result)
60+
61+
if __name__ == "__main__":
62+
unittest.main()

firefly-docker.env

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,3 +51,4 @@
5151
ADMIN_PASSWORD
5252
env
5353

54+

0 commit comments

Comments
 (0)