Skip to content

Commit 394e1f8

Browse files
authored
Merge pull request #6575 from NHSDigital/add-mavis-server
Add mavis server
2 parents aba122b + ee17b82 commit 394e1f8

15 files changed

Lines changed: 498 additions & 317 deletions

File tree

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,3 +90,7 @@ dump.rdb
9090
# Data that gets downloaded by CLI tools
9191
db/data/dfe-schools.zip
9292
db/data/nhs-gp-practices.csv
93+
94+
# Python
95+
__pycache__/
96+
*.pyc

.tool-versions

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ hk 1.37.0
44
nodejs 22.15.0
55
pkl 0.31.0
66
postgres 17.2
7+
python 3.14.4
78
redis 8.2.1
89
ruby 4.0.2
910
shellcheck 0.11.0

bin/mavis-server

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
#/usr/bin/env sh
2+
3+
set -eu
4+
5+
bin_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
6+
root_dir=$(CDPATH= cd -- "$bin_dir/.." && pwd)
7+
8+
export PYTHONPATH=$root_dir/python
9+
10+
exec python -m mavis.server "$@"
11+

docs/aws-setup.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,4 +35,4 @@ sso_registration_scopes = sso:account:access
3535
- Run `aws configure sso`. This will prompt you to log in to your AWS account and grant the necessary permissions for the CLI to access AWS services. When prompted for a region enter `eu-west-2` and for output format enter `json`.
3636
- Install the Session Manager plugin for the AWS CLI by following the instructions in the [AWS Systems Manager Session Manager documentation](https://docs.aws.amazon.com/systems-manager/latest/userguide/session-manager-working-with-install-plugin.html).
3737
- Run `aws sso login` to log in to your AWS account and establish a session. This will allow you to access AWS resources using the CLI.
38-
- You should now be able to shell into a running service. The simplest way to do this is using the `script/shell.sh` script, e.g. `script/shell.sh qa`.
38+
- You should now be able to shell into a running service. The simplest way to do this is using the `bin/mavis-server shell` command, e.g. `bin/mavis-server shell qa`.

python/mavis/__init__.py

Whitespace-only changes.

python/mavis/server/__init__.py

Whitespace-only changes.

python/mavis/server/__main__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
from .cli import main
2+
3+
if __name__ == "__main__":
4+
main()

python/mavis/server/aws.py

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
import json
2+
import subprocess
3+
4+
from .helpers import run_command
5+
6+
REGION = "eu-west-2"
7+
PRODUCTION_ENVS = {"production", "production-data-replication"}
8+
9+
10+
def cluster(env):
11+
return f"mavis-{env}"
12+
13+
14+
def s3_bucket(env):
15+
if env in PRODUCTION_ENVS:
16+
return "mavis-filetransfer-production"
17+
return "mavis-filetransfer-development"
18+
19+
20+
def ensure_authenticated(exit_without_login=False):
21+
"""Check AWS auth; attempt SSO login if needed."""
22+
result = subprocess.run(
23+
["aws", "sts", "get-caller-identity"],
24+
capture_output=True,
25+
)
26+
if result.returncode == 0:
27+
return
28+
if exit_without_login:
29+
raise RuntimeError(
30+
"Not authenticated with AWS. Run 'aws sso login' and try again."
31+
)
32+
print("Not authenticated with AWS. Attempting SSO login...")
33+
login = subprocess.run(["aws", "sso", "login"])
34+
if login.returncode != 0:
35+
raise RuntimeError("AWS SSO login failed.")
36+
recheck = subprocess.run(
37+
["aws", "sts", "get-caller-identity"],
38+
capture_output=True,
39+
)
40+
if recheck.returncode != 0:
41+
raise RuntimeError("Still not authenticated after SSO login.")
42+
43+
44+
def aws_json(*cmd):
45+
"""Run an AWS CLI command and return parsed JSON output."""
46+
result = subprocess.run(["aws", *cmd], capture_output=True, text=True)
47+
if result.returncode != 0:
48+
raise RuntimeError(f"aws {' '.join(cmd)}:\n{result.stderr.strip()}")
49+
return json.loads(result.stdout)
50+
51+
52+
def resolve_task(env, task_id=None, task_ip=None, service=None):
53+
"""
54+
Resolve to (short_task_id, container_name). Three mutually exclusive modes:
55+
56+
- task_id — validate the specific task is running
57+
- task_ip — search all running tasks in the cluster for a matching IP
58+
- service — return the first running task in the service; defaults to
59+
mavis-{env}-ops, or mavis-{env}-web for data-replication envs
60+
"""
61+
cl = cluster(env)
62+
63+
if task_id:
64+
tasks = aws_json(
65+
"ecs",
66+
"describe-tasks",
67+
"--region",
68+
REGION,
69+
"--cluster",
70+
cl,
71+
"--tasks",
72+
task_id,
73+
).get("tasks", [])
74+
if not tasks or tasks[0]["lastStatus"] != "RUNNING":
75+
raise RuntimeError(f"Task {task_id} is not running in cluster {cl}")
76+
return task_id, _application_container(tasks[0])
77+
78+
if task_ip:
79+
task_arns = aws_json(
80+
"ecs",
81+
"list-tasks",
82+
"--region",
83+
REGION,
84+
"--cluster",
85+
cl,
86+
"--desired-status",
87+
"RUNNING",
88+
).get("taskArns", [])
89+
if not task_arns:
90+
raise RuntimeError(f"No running tasks found in cluster {cl}")
91+
tasks = aws_json(
92+
"ecs",
93+
"describe-tasks",
94+
"--region",
95+
REGION,
96+
"--cluster",
97+
cl,
98+
"--tasks",
99+
*task_arns,
100+
).get("tasks", [])
101+
for task in tasks:
102+
if _task_private_ip(task) == task_ip:
103+
return _short_id(task), _application_container(task)
104+
raise RuntimeError(f"No running task with IP {task_ip} found in cluster {cl}")
105+
106+
if not service:
107+
service = _default_service(env)
108+
109+
task_arns = aws_json(
110+
"ecs",
111+
"list-tasks",
112+
"--region",
113+
REGION,
114+
"--cluster",
115+
cl,
116+
"--service-name",
117+
service,
118+
"--desired-status",
119+
"RUNNING",
120+
).get("taskArns", [])
121+
if not task_arns:
122+
raise RuntimeError(f"No running tasks found in service {service}")
123+
tasks = aws_json(
124+
"ecs",
125+
"describe-tasks",
126+
"--region",
127+
REGION,
128+
"--cluster",
129+
cl,
130+
"--tasks",
131+
*task_arns,
132+
).get("tasks", [])
133+
for task in tasks:
134+
container = _application_container(task)
135+
if container:
136+
return _short_id(task), container
137+
raise RuntimeError(
138+
f"No running tasks with an application container found in service {service}"
139+
)
140+
141+
142+
def run_remote_command(
143+
env, task_id, remote_command, container=None, replace_process=False
144+
):
145+
"""Execute a command in an ECS task, returning the exit code."""
146+
command = [
147+
"aws",
148+
"ecs",
149+
"execute-command",
150+
"--region",
151+
REGION,
152+
"--cluster",
153+
cluster(env),
154+
"--task",
155+
task_id,
156+
"--command",
157+
remote_command,
158+
"--interactive",
159+
]
160+
if container:
161+
command += ["--container", container]
162+
return run_command(command, replace_process=replace_process)
163+
164+
165+
# --- private helpers ---
166+
167+
168+
def _default_service(env):
169+
if env.endswith("data-replication"):
170+
return f"mavis-{env}"
171+
return f"mavis-{env}-ops"
172+
173+
174+
def _short_id(task):
175+
return task["taskArn"].split("/")[-1]
176+
177+
178+
def _application_container(task):
179+
for c in task.get("containers", []):
180+
if (
181+
c.get("name") == "application"
182+
and c.get("lastStatus") == "RUNNING"
183+
and c.get("runtimeId")
184+
):
185+
return c["name"]
186+
return None
187+
188+
189+
def _task_private_ip(task):
190+
for attachment in task.get("attachments", []):
191+
for detail in attachment.get("details", []):
192+
if detail.get("name") == "privateIPv4Address":
193+
return detail.get("value")
194+
return None

python/mavis/server/cli.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import argparse
2+
3+
from . import get_file, put_file, shell
4+
5+
6+
def main():
7+
parser = argparse.ArgumentParser(
8+
prog="mavis-server",
9+
description="MAVIS server management CLI",
10+
)
11+
subparsers = parser.add_subparsers(dest="command", required=True)
12+
13+
get_file.register(subparsers)
14+
put_file.register(subparsers)
15+
shell.register(subparsers)
16+
17+
args = parser.parse_args()
18+
# TODO: Clean this error reporting up
19+
args.func(args)

python/mavis/server/get_file.py

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import os
2+
import secrets
3+
import sys
4+
5+
from . import aws
6+
from .helpers import confirm_production, run_command
7+
8+
9+
def register(subparsers):
10+
parser = subparsers.add_parser(
11+
"get-file",
12+
help="Download a file from an ECS container to local",
13+
description=(
14+
"Download a file from inside an ECS container to a local path, "
15+
"using S3 as an intermediary. The S3 object is always cleaned up."
16+
),
17+
)
18+
parser.add_argument("env", help="Environment name (cluster will be mavis-ENV)")
19+
parser.add_argument("remote_path", help="Path of the file inside the container")
20+
parser.add_argument(
21+
"local_path",
22+
nargs="?",
23+
default=None,
24+
help="Local destination (file or directory). Defaults to tmp in the project root.",
25+
)
26+
parser.add_argument("--service", help="Override the ECS service name")
27+
parser.add_argument(
28+
"--task-id", dest="task_id", help="Connect to a specific task by ID"
29+
)
30+
parser.add_argument(
31+
"--task-ip",
32+
dest="task_ip",
33+
help="Connect to a task by its private IPv4 address",
34+
)
35+
parser.add_argument(
36+
"-x",
37+
"--exit-without-login",
38+
dest="exit_without_login",
39+
action="store_true",
40+
help="Exit instead of prompting for AWS SSO login",
41+
)
42+
parser.set_defaults(func=run)
43+
44+
45+
def run(args):
46+
env = args.env
47+
48+
confirm_production(env)
49+
aws.ensure_authenticated(exit_without_login=args.exit_without_login)
50+
51+
task_id, container = aws.resolve_task(
52+
env, task_id=args.task_id, task_ip=args.task_ip, service=args.service
53+
)
54+
bucket = aws.s3_bucket(env)
55+
key = f"temp-{secrets.token_hex(8)}"
56+
s3_uri = f"s3://{bucket}/{key}"
57+
58+
local_dest = _local_destination(args.remote_path, args.local_path)
59+
60+
try:
61+
upload_result = aws.run_remote_command(
62+
env,
63+
task_id,
64+
f"aws s3 cp {args.remote_path} {s3_uri} --region {aws.REGION}",
65+
container=container,
66+
)
67+
if not upload_result:
68+
sys.exit("Error: Failed to copy file from container to S3")
69+
70+
download_result = run_command(
71+
["aws", "s3", "cp", s3_uri, local_dest, "--region", aws.REGION]
72+
)
73+
if not download_result:
74+
sys.exit("Error: Download from S3 failed with code")
75+
finally:
76+
run_command(
77+
["aws", "s3", "rm", s3_uri, "--region", aws.REGION],
78+
)
79+
80+
print(f"File successfully downloaded to {local_dest}")
81+
82+
83+
def _local_destination(remote_path, local_path):
84+
"""
85+
Resolve the local download destination.
86+
87+
If local_path is given and is an existing directory, save as
88+
<local_path>/<basename of remote_path>. If local_path is a file path
89+
(or doesn't exist yet), use it as-is. Defaults to ./<basename>.
90+
"""
91+
filename = os.path.basename(remote_path.rstrip("/"))
92+
if local_path is None:
93+
return os.path.join(".", filename)
94+
if os.path.isdir(local_path):
95+
return os.path.join(local_path, filename)
96+
return local_path

0 commit comments

Comments
 (0)