chore(release): add external service smoke checks
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 11s
Deploy / Deploy Staging (push) Successful in 1m51s
Deploy / Deploy Production (push) Has been skipped

This commit is contained in:
Xiaoxia AI
2026-06-21 12:50:33 +08:00
parent 1708401bcd
commit ee6a98398f
3 changed files with 227 additions and 0 deletions
+2
View File
@@ -23,6 +23,8 @@
- `GENERATED_FILES_HOST_DIR` 生产环境不得指向 staging 目录。
- 本地校验文件:`python scripts/validate_release_env.py /var/lib/xiaoxia-saas-production/.env --strict-external`
- 容器内校验已注入环境:`docker exec xiaoxia-api-production python /app/scripts/validate_release_env.py --from-environ --strict-external`
- 外部服务 smoke`docker exec xiaoxia-api-production python /app/scripts/smoke_external_services.py --strict`
- SMTP 真发信 smoke`docker exec xiaoxia-api-production python /app/scripts/smoke_external_services.py --strict --send-email-to <测试邮箱>`
- 如开启邮件/session
- `ENABLE_EMAIL_DELIVERY=true` 前先验证 SMTP 凭证。
- `ENABLE_REDIS_SESSIONS=true` 前先验证 Redis 连通性。
+129
View File
@@ -0,0 +1,129 @@
"""Smoke test external production services without printing secrets."""
from __future__ import annotations
import argparse
import os
import smtplib
import tempfile
import time
from pathlib import Path
import redis
try:
import oss2
except ImportError: # pragma: no cover
oss2 = None
def is_enabled(value: str | None) -> bool:
return str(value or "").lower() in {"1", "true", "yes", "on"}
def require_env(name: str) -> str:
value = os.getenv(name)
if not value:
raise RuntimeError(f"missing required env: {name}")
return value
def smoke_redis(strict: bool) -> None:
enabled = is_enabled(os.getenv("ENABLE_REDIS_SESSIONS"))
if not strict and not enabled:
print("SKIP redis sessions: ENABLE_REDIS_SESSIONS is disabled")
return
client = redis.Redis.from_url(require_env("REDIS_URL"), decode_responses=True, socket_timeout=5)
if client.ping() is not True:
raise RuntimeError("redis ping failed")
key = f"xiaoxia:smoke:{int(time.time())}"
client.setex(key, 30, "ok")
if client.get(key) != "ok":
raise RuntimeError("redis set/get smoke failed")
client.delete(key)
print("OK redis sessions")
def smoke_smtp(strict: bool, send_email_to: str | None) -> None:
enabled = is_enabled(os.getenv("ENABLE_EMAIL_DELIVERY"))
if not strict and not enabled:
print("SKIP smtp: ENABLE_EMAIL_DELIVERY is disabled")
return
host = require_env("SMTP_HOST")
port = int(require_env("SMTP_PORT"))
user = os.getenv("SMTP_USER", "")
password = os.getenv("SMTP_PASSWORD", "")
from_email = require_env("SMTP_FROM_EMAIL")
use_tls = is_enabled(os.getenv("SMTP_USE_TLS", "true"))
with smtplib.SMTP(host, port, timeout=10) as server:
if use_tls:
server.starttls()
if user or password:
server.login(user, password)
if send_email_to:
server.sendmail(
from_email,
[send_email_to],
"Subject: Xiaoxia SaaS external service smoke\n\nOK: SMTP smoke test.",
)
action = "send" if send_email_to else "connect/login"
print(f"OK smtp {action}")
def smoke_oss(strict: bool) -> None:
endpoint = os.getenv("OSS_ENDPOINT", "")
access_key_id = os.getenv("OSS_ACCESS_KEY_ID", "")
access_key_secret = os.getenv("OSS_ACCESS_KEY_SECRET", "")
bucket_name = os.getenv("OSS_BUCKET_NAME", "")
if not strict and not (endpoint and access_key_id and access_key_secret and bucket_name):
print("SKIP oss: OSS credentials are not fully configured")
return
for name, value in {
"OSS_ENDPOINT": endpoint,
"OSS_ACCESS_KEY_ID": access_key_id,
"OSS_ACCESS_KEY_SECRET": access_key_secret,
"OSS_BUCKET_NAME": bucket_name,
}.items():
if not value:
raise RuntimeError(f"missing required env: {name}")
if oss2 is None:
raise RuntimeError("oss2 is required for OSS smoke")
auth = oss2.Auth(access_key_id, access_key_secret)
bucket = oss2.Bucket(auth, endpoint, bucket_name)
key = f"smoke/external-services-{int(time.time())}.txt"
with tempfile.NamedTemporaryFile("w", delete=False, encoding="utf-8") as handle:
handle.write("xiaoxia oss smoke ok\n")
local_path = Path(handle.name)
try:
bucket.put_object_from_file(key, str(local_path), headers={"Content-Type": "text/plain"})
if not bucket.object_exists(key):
raise RuntimeError("oss uploaded object does not exist")
bucket.get_object(key).read()
bucket.delete_object(key)
finally:
local_path.unlink(missing_ok=True)
print("OK oss upload/download/delete")
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--strict", action="store_true", help="Fail when disabled/unconfigured services are missing.")
parser.add_argument(
"--send-email-to", help="Actually send a test email to this address. Otherwise SMTP only connects/logs in."
)
args = parser.parse_args()
smoke_redis(args.strict)
smoke_smtp(args.strict, args.send_email_to)
smoke_oss(args.strict)
print("OK external service smoke complete")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,96 @@
import types
import scripts.smoke_external_services as smoke
def test_external_smoke_skips_disabled_services(monkeypatch, capsys):
for key in [
"ENABLE_REDIS_SESSIONS",
"ENABLE_EMAIL_DELIVERY",
"OSS_ENDPOINT",
"OSS_ACCESS_KEY_ID",
"OSS_ACCESS_KEY_SECRET",
"OSS_BUCKET_NAME",
]:
monkeypatch.delenv(key, raising=False)
smoke.smoke_redis(strict=False)
smoke.smoke_smtp(strict=False, send_email_to=None)
smoke.smoke_oss(strict=False)
output = capsys.readouterr().out
assert "SKIP redis sessions" in output
assert "SKIP smtp" in output
assert "SKIP oss" in output
def test_external_smoke_requires_oss_in_strict_mode(monkeypatch):
monkeypatch.delenv("OSS_ENDPOINT", raising=False)
try:
smoke.smoke_oss(strict=True)
except RuntimeError as error:
assert "missing required env: OSS_ENDPOINT" in str(error)
else:
raise AssertionError("strict OSS smoke should fail without credentials")
def test_external_smoke_redis_round_trip(monkeypatch, capsys):
class FakeRedis:
def __init__(self):
self.values = {}
def ping(self):
return True
def setex(self, key, seconds, value):
self.values[key] = value
def get(self, key):
return self.values[key]
def delete(self, key):
self.values.pop(key, None)
fake_client = FakeRedis()
monkeypatch.setenv("ENABLE_REDIS_SESSIONS", "true")
monkeypatch.setenv("REDIS_URL", "redis://redis:6379/0")
monkeypatch.setattr(smoke.redis.Redis, "from_url", lambda *args, **kwargs: fake_client)
smoke.smoke_redis(strict=False)
assert "OK redis sessions" in capsys.readouterr().out
def test_external_smoke_smtp_connect_login(monkeypatch, capsys):
actions = []
class FakeSMTP:
def __init__(self, host, port, timeout):
actions.append((host, port, timeout))
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def starttls(self):
actions.append("tls")
def login(self, user, password):
actions.append(("login", user, password))
monkeypatch.setenv("ENABLE_EMAIL_DELIVERY", "true")
monkeypatch.setenv("SMTP_HOST", "smtp.local")
monkeypatch.setenv("SMTP_PORT", "587")
monkeypatch.setenv("SMTP_USER", "mailer")
monkeypatch.setenv("SMTP_PASSWORD", "secret")
monkeypatch.setenv("SMTP_FROM_EMAIL", "noreply@example.test")
monkeypatch.setenv("SMTP_USE_TLS", "true")
monkeypatch.setattr(smoke.smtplib, "SMTP", FakeSMTP)
smoke.smoke_smtp(strict=False, send_email_to=None)
assert actions == [("smtp.local", 587, 10), "tls", ("login", "mailer", "secret")]
assert "OK smtp connect/login" in capsys.readouterr().out