Skip to content

Commit 000cd11

Browse files
committed
fix: address load test review feedback
1 parent 564d47b commit 000cd11

5 files changed

Lines changed: 70 additions & 4 deletions

File tree

‎scripts/load_test/README.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,8 @@ Start workflow 동작:
5353
- datasource username/password는 Parameter Store에 복사하지 않습니다.
5454
5. load-test MySQL EC2 user data가 prod MySQL 백업 S3 bucket의 최신 dump를 내려받아 복원합니다.
5555
6. `scripts/load_test/start.sh`가 Terraform output에서 필요한 값을 읽습니다.
56-
7. `switch_stage_to_loadtest=true`이면 stage 앱을 `dev,loadtest` profile로 재기동합니다. username/password는 앱의 Parameter Store 연동이 loadtest 경로에서 읽습니다.
56+
7. `scripts/load_test/start.sh`가 SSM으로 load-test MySQL EC2의 `cloud-init` 완료와 `/opt/solid-connection/load-test-db-ready` marker 생성을 확인합니다. 기본 대기 시간은 3600초입니다.
57+
8. `switch_stage_to_loadtest=true`이면 stage 앱을 `dev,loadtest` profile로 재기동합니다. username/password는 앱의 Parameter Store 연동이 loadtest 경로에서 읽습니다.
5758

5859
Start workflow는 load-generator EC2를 만들지 않습니다. 부하 생성용 EC2는 비용 누수를 막기 위해 Run workflow에서만 생성합니다.
5960

‎scripts/load_test/generate_bruno_k6.py‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,17 @@
1212

1313

1414
def brace_delta(line):
15+
"""Return the net brace balance contributed by a single Bruno line."""
1516
return line.count("{") - line.count("}")
1617

1718

1819
def read_text(path):
20+
"""Read a Bruno file while tolerating invalid byte sequences."""
1921
return path.read_text(encoding="utf-8", errors="replace")
2022

2123

2224
def collect_blocks(text):
25+
"""Collect top-level Bruno blocks keyed by block name."""
2326
lines = text.splitlines()
2427
blocks = {}
2528
index = 0
@@ -52,6 +55,7 @@ def collect_blocks(text):
5255

5356

5457
def parse_fields(block):
58+
"""Parse simple Bruno key-value fields from a block body."""
5559
fields = {}
5660
for line in block.splitlines():
5761
match = re.match(r"^\s*(~?[^:]+):\s*(.*)$", line)
@@ -63,6 +67,7 @@ def parse_fields(block):
6367

6468

6569
def parse_meta(blocks):
70+
"""Extract display metadata and sort order from parsed Bruno blocks."""
6671
meta = parse_fields(blocks.get("meta", [""])[0])
6772
seq = meta.get("seq", "999999")
6873
try:
@@ -76,6 +81,7 @@ def parse_meta(blocks):
7681

7782

7883
def parse_vars(blocks):
84+
"""Extract pre-request variables defined in Bruno request blocks."""
7985
variables = {}
8086
for block in blocks.get("vars:pre-request", []):
8187
for key, value in parse_fields(block).items():
@@ -84,10 +90,12 @@ def parse_vars(blocks):
8490

8591

8692
def clean_body_json(block):
93+
"""Return the JSON body text exactly as k6 should send it."""
8794
return block.strip()
8895

8996

9097
def parse_multipart(block):
98+
"""Parse Bruno multipart form fields into k6-friendly metadata."""
9199
fields = []
92100
for line in block.splitlines():
93101
match = re.match(r"^\s*(~?[^:]+):\s*(.*?)\s*$", line)
@@ -133,6 +141,7 @@ def parse_multipart(block):
133141

134142

135143
def parse_request(path, collection_dir):
144+
"""Parse one Bruno request file into the intermediate request model."""
136145
text = read_text(path)
137146
blocks = collect_blocks(text)
138147
method = next((name for name in HTTP_METHODS if name in blocks), None)
@@ -171,6 +180,7 @@ def parse_request(path, collection_dir):
171180

172181

173182
def order_key(request):
183+
"""Sort auth-ending requests after regular requests and login first."""
174184
url = request["url"]
175185
if LOGIN_PATH in url:
176186
return (0, request["relativePath"], request["seq"])
@@ -180,6 +190,7 @@ def order_key(request):
180190

181191

182192
def load_requests(collection_dir, include_external, include_destructive):
193+
"""Load and filter HTTP requests from a Bruno collection directory."""
183194
requests = []
184195
for path in sorted(collection_dir.rglob("*.bru")):
185196
if path.name in ("collection.bru", "folder.bru"):
@@ -201,6 +212,7 @@ def load_requests(collection_dir, include_external, include_destructive):
201212

202213

203214
def render_k6(requests):
215+
"""Render parsed Bruno requests as a self-contained k6 JavaScript file."""
204216
regular_requests = [request for request in requests if LOGIN_PATH not in request["url"]]
205217
payload = json.dumps(regular_requests, ensure_ascii=True, indent=2)
206218

@@ -400,6 +412,7 @@ def render_k6(requests):
400412

401413

402414
def main():
415+
"""Parse CLI arguments and write the generated k6 script."""
403416
parser = argparse.ArgumentParser(description="Generate a k6 script from a Bruno collection.")
404417
parser.add_argument("--collection-dir", required=True, type=Path)
405418
parser.add_argument("--output", required=True, type=Path)

‎scripts/load_test/run_k6.sh‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -253,7 +253,7 @@ fi
253253
while IFS= read -r -d '' source_path; do
254254
relative_path="${source_path#"$LOCAL_K6_DIR"/}"
255255
sync_file "$load_generator_instance_id" "$load_generator_k6_dir" "$relative_path"
256-
done < <(find "$LOCAL_K6_DIR" -type f -print0)
256+
done < <(find "$LOCAL_K6_DIR" -type f \( -name '*.js' -o -name '*.ts' -o -name '*.json' -o -name '*.sh' \) -print0)
257257

258258
run_commands_json="$(jq -cn \
259259
--arg k6_dir "$load_generator_k6_dir" \

‎scripts/load_test/start.sh‎

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ DATABASE_NAME=""
77
SWITCH_STAGE_TO_LOADTEST="false"
88
STAGE_APP_DIR="/home/ubuntu/solid-connection-dev"
99
STAGE_COMPOSE_FILE="docker-compose.dev.yml"
10-
SSM_COMMAND_TIMEOUT_SECONDS="${SSM_COMMAND_TIMEOUT_SECONDS:-1800}"
10+
SSM_COMMAND_TIMEOUT_SECONDS="${SSM_COMMAND_TIMEOUT_SECONDS:-3600}"
1111
SKIP_TERRAFORM_APPLY="false"
1212

1313
usage() {
@@ -21,7 +21,7 @@ Options:
2121
--switch-stage-to-loadtest Restart stage app through SSM with dev,loadtest profiles
2222
--stage-app-dir PATH Default: /home/ubuntu/solid-connection-dev
2323
--stage-compose-file VALUE Default: docker-compose.dev.yml
24-
--ssm-command-timeout-seconds Default: 1800
24+
--ssm-command-timeout-seconds Default: 3600
2525
--skip-terraform-apply
2626
-h, --help
2727
EOF
@@ -115,19 +115,66 @@ send_ssm_command() {
115115
done
116116
}
117117

118+
wait_for_ssm() {
119+
local instance_id="$1"
120+
local started_at
121+
started_at="$(date +%s)"
122+
123+
while true; do
124+
local ping_status
125+
ping_status="$(aws ssm describe-instance-information \
126+
--filters "Key=InstanceIds,Values=${instance_id}" \
127+
--query "InstanceInformationList[0].PingStatus" \
128+
--output text 2>/dev/null || true)"
129+
130+
if [[ "$ping_status" == "Online" ]]; then
131+
break
132+
fi
133+
134+
if (( $(date +%s) - started_at > SSM_COMMAND_TIMEOUT_SECONDS )); then
135+
echo "SSM agent did not become online after ${SSM_COMMAND_TIMEOUT_SECONDS}s: ${instance_id}" >&2
136+
exit 1
137+
fi
138+
139+
sleep 10
140+
done
141+
}
142+
143+
wait_for_load_test_db_restore() {
144+
local instance_id="$1"
145+
local commands_json
146+
147+
commands_json="$(jq -cn \
148+
'{
149+
commands: [
150+
"set -euo pipefail",
151+
"READY_FILE=/opt/solid-connection/load-test-db-ready",
152+
"cloud-init status --wait --long || { journalctl -u cloud-final --no-pager -n 200 || true; exit 1; }",
153+
"test -f \"$READY_FILE\" || { echo \"Load-test DB ready marker was not created: $READY_FILE\" >&2; journalctl -u cloud-final --no-pager -n 200 || true; exit 1; }",
154+
"cat \"$READY_FILE\""
155+
]
156+
}')"
157+
158+
wait_for_ssm "$instance_id"
159+
send_ssm_command "$instance_id" "Wait for load-test DB restore" "$commands_json"
160+
}
161+
118162
if [[ "$SKIP_TERRAFORM_APPLY" != "true" ]]; then
119163
terraform -chdir="$TERRAFORM_DIR" init
120164
terraform -chdir="$TERRAFORM_DIR" apply -auto-approve -var-file="$VAR_FILE"
121165
fi
122166

123167
stage_instance_id="$(tf_output stage_api_instance_id)"
124168
stage_public_ip="$(tf_output stage_api_public_ip)"
169+
loadtest_db_instance_id="$(tf_output load_test_db_instance_id)"
125170
loadtest_endpoint="$(tf_output load_test_db_endpoint)"
126171
loadtest_port="$(tf_output load_test_db_port)"
127172
loadtest_db_name="$(tf_output load_test_db_name)"
128173

129174
DATABASE_NAME="${DATABASE_NAME:-$loadtest_db_name}"
130175

176+
wait_for_load_test_db_restore "$loadtest_db_instance_id"
177+
131178
if [[ "$SWITCH_STAGE_TO_LOADTEST" == "true" ]]; then
132179
stage_commands_json="$(jq -cn \
133180
--arg app_dir "$STAGE_APP_DIR" \

‎scripts/load_test/tests/test_generate_bruno_k6.py‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,17 @@
66

77

88
class GenerateBrunoK6Test(unittest.TestCase):
9+
"""Tests for Bruno request parsing and k6 rendering."""
10+
911
def write_bru(self, root, relative_path, content):
12+
"""Write a Bruno fixture file under a temporary collection root."""
1013
path = root / relative_path
1114
path.parent.mkdir(parents=True, exist_ok=True)
1215
path.write_text(content.strip() + "\n", encoding="utf-8")
1316
return path
1417

1518
def test_load_requests_filters_external_and_destructive_requests(self):
19+
"""Only internal non-destructive Bruno requests should be loaded by default."""
1620
with tempfile.TemporaryDirectory() as tmp:
1721
root = Path(tmp)
1822
self.write_bru(
@@ -87,6 +91,7 @@ def test_load_requests_filters_external_and_destructive_requests(self):
8791
self.assertEqual(requests[1]["vars"], {"email": "user1@example.com"})
8892

8993
def test_render_k6_excludes_login_request_and_keeps_request_metadata(self):
94+
"""The generated script should log in separately and retain request tags."""
9095
requests = [
9196
{
9297
"name": "POST /auth/email/sign-in",

0 commit comments

Comments
 (0)