Config Inspector (סקירת משתני סביבה)

summary:

כלי אדמין שמציג תמונת מצב של הקונפיגורציה ומשתני הסביבה, עם הסתרת ערכים רגישים.

מה זה Config Inspector?

Config Inspector הוא כלי אדמין שמציג תמונת מצב של הקונפיגורציה: אילו משתני סביבה מוגדרים, מה הערך הפעיל שלהם, מה ברירת המחדל בקוד, והאם הערך שונה מברירת המחדל.

הכלי נותן מענה לשאלות שקשה לענות עליהן מול Render Dashboard:

  • האם המשתנה הזה בכלל מוגדר, או שאנחנו רצים על ברירת מחדל?

  • מה ברירת המחדל שבקוד, ובמה הערך שברנדר שונה ממנה?

  • לאיזה שירות שייך המשתנה, ואיפה צריך להגדיר אותו?

  • אילו משתנים הכרחיים חסרים?

איך נכנסים?

דרך ה-UI: דף Settings ← קטגוריית כלי אדמין ← Config Inspector

ישירות:

GET /admin/config-inspector

הערה

הדף זמין רק לאדמינים (@admin_required ב-webapp/app.py). ערך מוצג ממוסך (********) רק אם הוא מסווג כרגיש — לפי שם המשתנה או לפי סימון מפורש sensitive=True; כל שאר הערכים מוצגים כמות שהם. הסיווג מפורט ב- מיסוך ערכים רגישים, וחשוב לקרוא אותו לפני הוספת משתנה שהוא סוד.

שני עמודים — ולמה

הדף מחולק לשני טאבים, וההפרדה ביניהם אינה קוסמטית אלא נובעת ממגבלה אמיתית.

עמוד 1: שירות ה-Webapp (עם Status וערך פעיל)

העמוד הראשון מציג רק משתנים ששייכים (גם) לשירות ה-Webapp, ורק הם מקבלים עמודות Status ו-Active Value.

הסיבה: ה-Config Inspector הוא קוד שרץ בתוך תהליך ה-Webapp. כשהוא קורא os.environ הוא רואה אך ורק את משתני הסביבה של אותו תהליך. הבוט, שרת ה-MCP וה-webserver הם שירותי Render נפרדים, כל אחד עם ENV משלו — ותהליך ה-Webapp פשוט אינו יכול לראות אותם.

לכן, אילו היינו מציגים Status למשתנה של הבוט, הוא היה מוצג כ-Default או Missing גם כשהוא מוגדר מצוין בשירות הבוט. מידע שגוי גרוע מהיעדר מידע — ולכן העמוד הראשון מסונן, והסינון נאכף בקוד:

# services/config_inspector_service.py — get_config_overview
for definition in self.CONFIG_DEFINITIONS.values():
    # עמוד ראשי: רק משתנים ששייכים (גם) לשירות ה-webapp
    if "webapp" not in definition.services:
        continue

בראש העמוד מוצגים כרטיסי סיכום (סה“כ / שונו מדיפולט / הוגדרו בסביבה / חסרים / ברירת מחדל), שורת סינון (קטגוריה + סטטוס), פירוט לפי קטגוריות, וטבלת המשתנים המלאה. כפתור ”העתק הכל“ מייצא את השורות המוצגות בפורמט KEY=VALUE (שורות .env).

עמוד 2: שירותים אחרים — Bot / MCP / Webserver

העמוד השני מציג את כל המשתנים שיש להם שירות שאינו webapp — כולל משתנים משותפים שמופיעים גם בעמוד הראשון.

מה מוצג: Key / שירות / Default Value / תיאור.

מה לא מוצג, ולמה: אין כאן ``Status`` ואין ``Active Value`` — מאותה סיבה בדיוק שתוארה למעלה. הערכים חיים בתהליכים אחרים ואינם נגישים מכאן. לערכים בפועל יש לבדוק ב-Render Dashboard של השירות הרלוונטי.

  • שורה של משתנה שמוגדר גם בוובאפ מסומנת בתגית ”גם Webapp“ — הערך שלה מופיע בעמוד הראשון.

  • שורת הסינון בעמוד זה היא לפי קטגוריה ולפי שירות (ולא לפי סטטוס — אין כאן סטטוס).

  • ”העתק הכל“ בעמוד זה מעתיק KEY=<ברירת מחדל> ומציין זאת מפורשות, כדי שלא ייווצר רושם שאלו הערכים החיים.

ארבעת הסטטוסים — ואיך נמנעים מ-”Modified“ שגוי

סטטוס

מתי מתקבל

משמעות

Default

אין ערך בסביבה ויש ברירת מחדל בקוד — או שהערך בסביבה זהה לברירת המחדל

רצים על ברירת המחדל. אין מה לעשות

Set

יש ערך בסביבה ו**אין ברירת מחדל בקוד**

המשתנה הוגדר (למשל ברנדר). זו אינה סטייה — אין דיפולט שממנו אפשר לסטות

Modified

יש ערך בסביבה, יש ברירת מחדל, והם שונים

סטייה אמיתית מברירת המחדל — כאן צריך להסתכל

Missing

אין ערך בסביבה, אין ברירת מחדל, והמשתנה מסומן required=True

משתנה הכרחי חסר. מוצג גם כאזהרה בראש הדף

הלוגיקה ממומשת ב-ConfigService.determine_status.

אזהרה

הכלל השורשי: ה-default שרשום ב-ConfigDefinition חייב להיות זהה תו-בתו לברירת המחדל האמיתית בקוד. אחרת מתקבל סטטוס Modified שקרי — משתנה שמוגדר נכון ייראה כאילו מישהו שינה אותו, ואי אפשר יהיה לסמוך על העמודה הזו.

שתי הטעויות שמייצרות Modified שגוי:

1. דיפולט משוער במקום זה שבקוד. אם הקוד עושה os.getenv("X", "60") אבל בהגדרה נרשם default="30" — משתנה שמוגדר ל-60 ברנדר יוצג כ-Modified, למרות שהוא זהה לברירת המחדל בפועל.

2. המצאת דיפולט למשתנה שאין לו דיפולט בקוד. אם בקוד יש os.getenv("TOKEN") בלי ערך שני, אין ברירת מחדל — וההגדרה צריכה להשאיר default ריק. אז הסטטוס יהיה Set (נכון), ולא Modified (מטעה).

הבדיקה לפני הוספה — לאתר את ברירת המחדל האמיתית ולהעתיק אותה כמות שהיא:

rg -n -w 'MY_VAR' -t py -g '!tests/**'

-w מחפש את שם המשתנה כמילה שלמה, ולכן תופס גם os.getenv("MY_VAR"), גם os.getenv('MY_VAR') וגם config.MY_VAR, בלי להיתפס ל-MY_VAR_OTHER.

לאיזה שירות שייך המשתנה? — לבדוק, לא לנחש

השדה services בהגדרה קובע באיזה עמוד המשתנה יופיע. שיוך שגוי מייצר רעש: משתנה של הוובאפ בלבד שמסומן כשייך לכולם מופיע בעמוד 2 כאילו צריך להגדיר אותו בארבעה מקומות.

חשוב

אל תסיקו את השיוך משם המשתנה. שם שנשמע גלובלי (PUSH_*, UPTIME_*, MAINTENANCE_*) לא אומר שהמשתנה נצרך בכל השירותים. בסבב ניקוי אחד הוסרו 68 שיוכים שגויים שנקבעו לפי תחושה.

הנוהל (שלושה צעדים):

  1. למצוא איפה המשתנה נצרך בפועל — חיפוש אחד תופס גם os.getenv (בכל סוג גרשיים) וגם גישה דרך אובייקט הקונפיג (config.MY_VAR):

    rg -n -w 'MY_VAR' -t py -g '!tests/**'
    
  2. לזהות לאיזה שירות שייך הקובץ שנמצא — לפי נקודות הכניסה:

    קובץ / תיקייה

    שירות

    נקודת כניסה

    main.py, handlers/, *_handler.py

    bot

    main.py

    webapp/

    webapp

    webapp/app.py

    mcp_server/

    mcp

    mcp_server/app.py

    services/webserver.py

    webserver

    services/webserver.py

    scripts/

    scripts

    סקריפטים ידניים/CI

  3. קובץ משותף — לפי שרשרת ה-imports. קובץ כמו database/, services/ או utils.py אינו שייך לשירות מסוים בפני עצמו: הוא שייך לשירות רק אם הוא נטען בשרשרת ה-imports של אותה נקודת כניסה. משתנה שנצרך רק ב-webapp/app.py אינו שייך ל-bot/mcp/webserver, נקודה.

טיפ

PORT הוא חריג מוצדק: הוא לא בהכרח מופיע בקוד של השירות, אבל כל שירות web צריך אותו בפקודת ההרצה. חריגים כאלה — לתעד בהערה ליד ההגדרה.

משתנה שמשרת כמה שירותים — לתעד בכל המקומות

משתנה יכול להיות מוגדר בכמה שירותי Render במקביל (למשל MONGODB_URL). במקרה כזה מתעדים אותו בכל המקומות הרלוונטיים, כל אחד במקום שלו — ולא בוחרים ”בית“ אחד:

  1. ``services`` בהגדרה — כל השירותים, לא רק העיקרי:

    "MONGODB_URL": ConfigDefinition(
        key="MONGODB_URL",
        services=("webapp", "bot", "mcp", "webserver"),
        ...
    )
    
  2. ``docs/environment-variables.rst`` — עמודת ”רכיב“ בטבלה המרכזית חייבת לשקף את אותה רשימת שירותים. השורה הזו היא הרפרנס לאנשי DevOps.

  3. תיאור תואם בשני המקומות — ה-description שב-ConfigDefinition (מה שמוצג בטבלת ה-Config Inspector) וההסבר שב-environment-variables.rst צריכים לומר את אותו דבר. שני התיאורים נקראים זה לצד זה כשמדבגים תקלת קונפיגורציה, ותיאורים סותרים גרועים מהיעדר תיאור.

מתי הערך נקרא — ומה קורה כשהוא שגוי

כל משתנה בעמודים האלה ניתן להגדרה ברנדר, בלי יוצא מן הכלל. מה שמשתנה בין משתנה למשתנה הוא מתי בתהליך הוא נקרא, וממילא גם מתי תגלו שהערך שגוי.

חשוב

אל תסיקו מ“נקרא בטעינה“ שהמשתנה נעול. התהליך קורא אותו מחדש בכל עלייה, ושינוי ברנדר מפעיל דיפלוי. BOT_TOKEN ו-MONGODB_URL נקראים בטעינה, ומוגדרים ברנדר כרגיל.

שתי הצורות בקוד

נקרא בטעינה — הקריאה רצה ברגע ה-import, בלי שאיש קרא לפונקציה. שתי צורות:

השמה ברמת המודול. DB_HEALTH_TOKEN ב-services/webserver.py:

DB_HEALTH_TOKEN = os.getenv("DB_HEALTH_TOKEN", "")

יצירת אובייקט הקונפיג ברמת המודול — וזו הצורה הרחבה יותר בפרויקט. config.py מסתיים ביצירת אינסטנס גלובלי, ו-BotConfig הוא BaseSettings של pydantic שקורא את הסביבה ברגע היצירה. שדות כמו BOT_TOKEN ו-MONGODB_URL מוגדרים שם כשדות חובה, ולכן ערך חסר מפיל את ה-import של config.py עצמו — ואיתו כל מי שמייבא אותו.

נקרא בזמן ריצה — הקריאה בתוך גוף פונקציה, ולכן היא רצה רק כשקוראים לפונקציה. _patterns ב-mcp_server/repo_policy.py קורא את MCP_REPO_DENYLIST_EXTRA בכל בדיקת גישה לקובץ, ו-_allowed_docs_repos ב-mcp_server/docs_handlers.py קורא את MCP_DOCS_REPO בכל קריאה לכלי התיעוד.

טיפ

grep על שם המשתנה מספיק כדי להבחין: תוצאה בעמודה 0 היא קריאה בטעינה, תוצאה מוזחת היא בתוך פונקציה. משתנה שאינו מופיע בחיפוש כלל — לחפש אותו כשדה ב-config.py.

מה זה אומר בפועל

מצב

נקרא בטעינה

נקרא בזמן ריצה

ערך שגוי או חסר

השירות עלול לא לעלות בכלל

היכולת הבודדת נשברת, השאר עובד

מתי תדעו

בדיפלוי, מיד

רק כשמישהו מפעיל את היכולת

היכולת אינה בשימוש

נקרא בכל מקרה

לא נקרא לעולם

הכשל-מהר הזה הוא לרוב מכוון: create_app ב-mcp_server/app.py קורא ל- assert_strong_secret (מוגדרת ב-mcp_server/oauth_identity.py), וזו זורקת RuntimeError כש-SECRET_KEY ריק, ברירת-מחדל מוכרת, או קצר מדי. שירות ה-MCP לא יעלה — כי מפתח חלש שם הוא חור אבטחה, לא ברירת מחדל רכה.

שלוש מלכודות

ייבוא ”עצל“ שהוא בכל זאת בטעינה. create_app ב-mcp_server/app.py מייבא את database בתוך גוף הפונקציה, עם הערה שאומרת lazy heavy import. אבל אותו קובץ קורא ל-create_app ברמת המודול, ולכן הייבוא הזה — וכל מה שנגרר אחריו — קורה בעלייה. המיקום התחבירי אינו התשובה; מסלול הקריאה בפועל הוא התשובה.

קריאה בטעינה אבל מותנית. create_app קורא ל-SECRET_KEY רק אחרי שהוא בדק ש-MCP_SERVER_URL ו-WEBAPP_URL שניהם מוגדרים. במצב PAT-only הוא אינו נקרא כלל, והשירות עולה בלעדיו.

קוד שרץ רק כשהקובץ הוא נקודת הכניסה. WEB_HOST ו-WEB_PORT ב- services/webserver.py יושבים תחת if __name__ == "__main__":. הרצה ישירה של הקובץ קוראת אותם; ייבוא create_app ממנו — לא.

הערה

שלוש המלכודות אומרות אותו דבר: הסיווג נקבע במעקב אחרי מסלול הקריאה, לא בהצצה לשורה אחת. אותו קובץ יכול להחזיק את שתי הצורות — services/webserver.py מחזיק גם את DB_HEALTH_TOKEN שנקרא בטעינה וגם את WEB_HOST שאינו נקרא בייבוא בכלל.

הגדרה ברנדר — שלוש אפשרויות שמירה

התיעוד של Render מגדיר את מה שקורה בשמירה:

  • Save, rebuild, and deploy — בילד חדש ודיפלוי עם הערכים החדשים.

  • Save and deploy — דיפלוי מחדש של הבילד הקיים עם הערכים החדשים.

  • Save only — שמירה בלי דיפלוי.

אזהרה

ב-Save only שום סוג לא רואה את הערך החדש עד הדיפלוי הבא. גם משתנה שנקרא בזמן ריצה קורא מ-os.environ של התהליך שכבר רץ, ורנדר אינו משנה אותו מתחתיו. ההבחנה בין שתי הצורות אינה ”מיד מול בהפעלה הבאה“ — היא מתי מתגלה ערך שגוי.

מיסוך ערכים רגישים

ערך ממוסך (********) בשני מקרים:

  • לפי שם המשתנה — SENSITIVE_PATTERNS (TOKEN, KEY, PASSWORD, SECRET, URI, CREDENTIALS, AUTH, PRIVATE, CERT, DSN, CONNECTION_STRING).

  • לפי סימון מפורש — sensitive=True בהגדרה.

הערה

URL הוסר מהרשימה בכוונה: כתובת ציבורית (WEBAPP_URL, MCP_SERVER_URL) אינה סוד, ומיסוכה הפך את הדף לחסר תועלת. כתובת שמכילה credentials — למשל MONGODB_URL בפורמט scheme://user:pass@host — מסומנת sensitive=True מפורשות, ובנוסף קיים זיהוי אוטומטי של תבנית ה-credentials בתוך URL.

המסקנה המעשית: משתנה חדש שהוא סוד ושמו אינו מכיל אחת מהמילים ברשימה — סמנו ``sensitive=True`` ידנית.

צ’קליסט: הוספת משתנה סביבה חדש

  1. לאתר את הצריכה בפועל — grep על שם המשתנה (לאיזה שירות שייך המשתנה? — לבדוק, לא לנחש).

  2. להעתיק את ברירת המחדל מהקוד תו-בתו — ואם אין דיפולט, להשאיר ריק כדי לקבל Set ולא Modified (ארבעת הסטטוסים — ואיך נמנעים מ-”Modified“ שגוי).

  3. לקבוע ``services`` לפי הצריכה שנמצאה — כל השירותים שצורכים, ורק הם.

  4. ``sensitive=True`` אם זה סוד ששמו לא נתפס אוטומטית (מיסוך ערכים רגישים). ואם המשתנה נקרא בעליית התהליך וכשל בו מפיל את השירות — לציין זאת בתיאור (מתי הערך נקרא — ומה קורה כשהוא שגוי).

  5. לעדכן את ``docs/environment-variables.rst`` — שורה בטבלה המרכזית עם עמודת ”רכיב“ תואמת ותיאור זהה. זו חובה לפי ההנחיה שבראש אותו עמוד.

  6. לוודא בדף שהמשתנה מופיע בעמוד הנכון ושהסטטוס הגיוני (משתנה שלא נגעתם בו ברנדר אמור להיות Default, לא Modified).

טסטים: tests/test_config_inspector_service.py.

מדידת הפער ואכיפתו

הטבלה נכתבת ביד, ולכן היא נסחפת: מישהו מוסיף os.getenv ולא מצהיר עליו, והמשתנה חי בפרודקשן בלי שאפשר לראות אותו בשום מקום. שני כלים סוגרים את זה.

למדוד — scripts/audit_config_definitions.py משווה בין המשתנים שנצרכים בקוד (os.getenv, os.environ, ושדות של BaseSettings — pydantic קורא אותם לפי שם השדה) לבין המוצהרים, ומדפיס לכל אחד את הקבצים שצורכים אותו, את הדיפולט שנמצא בקוד, ואת השירותים לפי סגור ה-import:

python scripts/audit_config_definitions.py              # דוח קריא
python scripts/audit_config_definitions.py --json       # פלט JSON
python scripts/audit_config_definitions.py --keys-only  # שמות בלבד

אזהרה

הסקריפט מציע, הוא לא פוסק. הפלט מפריד בין סגור ודאי (ייבוא ברמת המודול) לסגור רופף (כולל ייבוא בתוך פונקציות), כי לשניהם יש כיווני טעות מוכחים: קובץ יכול לשבת בסגור בזכות ייבוא שמותנה בדגל כבוי, ולהפך — קובץ שמיובא רק בתוך פונקציה נקרא בכל זאת בעליית התהליך כשמישהו קורא לה. ההחלטה מתקבלת מול הקבצים שהסקריפט מצרף, לא מול השורה שהוא מציע.

לאכוף — tests/test_config_definitions_coverage.py מריץ את אותו ניתוח ונכשל על משתנה שנצרך ואינו מוצהר. יש בו ALLOWED_UNDECLARED: חריגים שאינם קונפיגורציה של שירות — תשתית בדיקות, קוד צד-שלישי שנשמר בריפו, פנימיים של פריימוורק ומערכת הפעלה, ובניית תיעוד. כל כניסה שם דורשת נימוק; allowlist בלי נימוקים הופך לפח אשפה. בדיקה שנייה באותו קובץ סוגרת את הכיוון ההפוך — הצהרה בלי שורה ב-environment-variables.

הערה

מה שהניתוח אינו יכול לתפוס: קריאה דינמית (os.getenv(name) עם משתנה ולא מחרוזת) וייבוא דינמי. הבדיקה מונעת סחיפה של המקרה הנפוץ; היא אינה מוכיחה שהטבלה מלאה.

ראו גם