|
86 | 86 | SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') |
87 | 87 |
|
88 | 88 | # Makeability Lab Global Variables, including Makeability Lab version |
89 | | -ML_WEBSITE_VERSION = "2.31.0" # Keep this updated with each release and also change the short description below |
90 | | -ML_WEBSITE_VERSION_DESCRIPTION = "A project's roster in the public API now reports each person's title over the span of their role (#1435), so someone on a project since 2012 reads as what they are today rather than the title they held back then. Finished stints still read as what that person was at the time." |
| 89 | +ML_WEBSITE_VERSION = "2.31.1" # Keep this updated with each release and also change the short description below |
| 90 | +ML_WEBSITE_VERSION_DESCRIPTION = "The Django log path is now derived from the project root instead of a hardcoded container path, and an unwritable log directory degrades gracefully instead of killing startup (#1283). Because the servers have no console, /version.json now reports whether file logging is actually live." |
91 | 91 | DATE_MAKEABILITYLAB_FORMED = datetime.date(2012, 1, 1) # Date Makeability Lab was formed |
92 | 92 | MAX_BANNERS = 7 # Maximum number of banners on a page |
93 | 93 |
|
|
105 | 105 | # See: https://docs.djangoproject.com/en/2.0/topics/logging/ |
106 | 106 | # https://lincolnloop.com/blog/django-logging-right-way/ |
107 | 107 | # For the log format, see: https://stackoverflow.com/a/26276689/388117 |
| 108 | +# |
| 109 | +# Log-file path (issue #1283): this used to be hardcoded to /code/media/debug.log, |
| 110 | +# an absolute container-specific path. Django evaluates LOGGING at django.setup(), |
| 111 | +# so on any host lacking that exact directory (e.g. GitHub Actions CI) startup died |
| 112 | +# with FileNotFoundError before a single request/test ran. Derive the path from |
| 113 | +# BASE_DIR instead (still under media/, the bind-mounted tree, so the file stays |
| 114 | +# readable over SSH — see docs/DEPLOYMENT.md), allow an ML_LOG_DIR env override, and if |
| 115 | +# the directory can't be created or written, fall back to a NullHandler so a bad |
| 116 | +# log path never crashes startup. |
| 117 | +# |
| 118 | +# Degrading is silent by default, and that is dangerous here: the 'django' logger |
| 119 | +# has only the 'file' handler, and the 'website' logger's console handler is gated |
| 120 | +# by require_debug_true (False in prod), so an unwritable log dir means the app runs |
| 121 | +# completely blind. We have no console access on the -test or prod servers, so the |
| 122 | +# degraded state is surfaced two web-reachable ways instead: the 'log_to_file' field |
| 123 | +# on /version.json (website/views/version.py) and a warning callout on the admin |
| 124 | +# dashboard (website/templates/admin/index.html). |
| 125 | +def _ensure_log_dir_writable(log_dir): |
| 126 | + """Create ``log_dir`` if needed and return True if it looks writable. |
| 127 | +
|
| 128 | + Named for the side effect: this *creates* the directory (``os.makedirs``) |
| 129 | + rather than merely inspecting it. Used to decide whether the file log handler |
| 130 | + is active or degrades to a NullHandler, so a bad log path never crashes |
| 131 | + ``django.setup()`` (issue #1283). |
| 132 | +
|
| 133 | + Two known limits, both accepted as strictly better than the previous |
| 134 | + unconditional crash: |
| 135 | +
|
| 136 | + 1. This checks the *directory*, not the eventual log file. A dir that is |
| 137 | + writable but already holds a root-owned, read-only ``debug.log`` would |
| 138 | + still let RotatingFileHandler raise on open. That doesn't match the real |
| 139 | + deploy model, where media/ is owned by the app's own user. |
| 140 | + 2. ``os.access(dir, os.W_OK)`` returns True for root regardless of the |
| 141 | + directory mode, so a mode-555 dir wouldn't be caught when running as root. |
| 142 | + The deployed container runs as ``apache`` (UID 48, see Dockerfile), so the |
| 143 | + guard is meaningful where it matters; only the root devcontainer bypasses |
| 144 | + it. The common failures — missing dir, uncreatable dir, read-only |
| 145 | + filesystem — are caught either way. |
| 146 | + """ |
| 147 | + try: |
| 148 | + os.makedirs(log_dir, exist_ok=True) |
| 149 | + return os.access(log_dir, os.W_OK) |
| 150 | + except OSError: |
| 151 | + return False |
| 152 | + |
| 153 | + |
| 154 | +def _file_log_handler(log_file, level, enabled): |
| 155 | + """Return the ``LOGGING['handlers']['file']`` config dict. |
| 156 | +
|
| 157 | + When ``enabled`` is False (the log dir isn't writable) this returns a |
| 158 | + NullHandler instead, which keeps every logger's ``'file'`` handler reference |
| 159 | + valid while never touching disk — so startup degrades instead of dying. |
| 160 | +
|
| 161 | + Split out of the ``LOGGING`` literal so both branches are directly testable; |
| 162 | + ``LOGGING`` is evaluated once at import, so a test can't re-derive it. |
| 163 | + See ``website/tests/test_logging_config.py``. |
| 164 | + """ |
| 165 | + if not enabled: |
| 166 | + return {'class': 'logging.NullHandler'} |
| 167 | + return { |
| 168 | + 'level': level, |
| 169 | + 'class': 'logging.handlers.RotatingFileHandler', |
| 170 | + 'filename': log_file, |
| 171 | + 'maxBytes': 1024*1024*5, # 5 MB |
| 172 | + 'backupCount': 6, |
| 173 | + 'formatter': 'verbose', # can switch between verbose and simple |
| 174 | + } |
| 175 | + |
| 176 | + |
| 177 | +# NOTE: this default must stay in sync with MEDIA_ROOT (defined further down as |
| 178 | +# os.path.join(BASE_DIR, 'media')) — the web-served /logs/debug.log URL only works |
| 179 | +# because the log lives inside the media root. MEDIA_ROOT isn't defined yet here |
| 180 | +# (LOGGING has to be built before it), hence the duplicated expression; |
| 181 | +# test_default_log_file_is_under_media_root pins the two together. |
| 182 | +LOG_DIR = os.environ.get('ML_LOG_DIR', os.path.join(BASE_DIR, 'media')) |
| 183 | +LOG_FILE = os.path.join(LOG_DIR, 'debug.log') |
| 184 | + |
| 185 | +# Uppercase on purpose: Django only exposes uppercase module attributes through |
| 186 | +# django.conf.settings, and both the /version.json view and the admin dashboard |
| 187 | +# read this to surface a degraded-logging warning. |
| 188 | +LOG_TO_FILE = _ensure_log_dir_writable(LOG_DIR) |
| 189 | + |
| 190 | +if not LOG_TO_FILE: |
| 191 | + # Secondary signal only. There is no console access on the -test or prod |
| 192 | + # servers, so this print is really for local dev and the emailed buildlog; |
| 193 | + # the channels that actually work remotely are /version.json (log_to_file |
| 194 | + # field) and the warning callout on the admin dashboard. |
| 195 | + print(f"WARNING: log dir {LOG_DIR!r} is not writable — file logging disabled " |
| 196 | + f"(NullHandler). Check /version.json 'log_to_file'.") |
| 197 | + |
108 | 198 | LOGGING = { |
109 | 199 | 'version': 1, |
110 | 200 | 'disable_existing_loggers': False, |
|
125 | 215 | }, |
126 | 216 | }, |
127 | 217 | 'handlers': { |
128 | | - 'file': { |
129 | | - # The file handler writes /code/media/debug.log, which lands in the |
130 | | - # bind-mounted web root and is intentionally exposed via the /logs/ |
131 | | - # URL per docs/DEPLOYMENT.md (Jason Howe's design — convenient |
132 | | - # remote debugging in exchange for some info disclosure). To shrink |
133 | | - # that exposure in production, we log at INFO when DEBUG is off, |
134 | | - # but keep DEBUG-level file logging in local dev where DEBUG is on |
135 | | - # and the file isn't publicly reachable. |
136 | | - 'level': 'DEBUG' if DEBUG else 'INFO', |
137 | | - 'class': 'logging.handlers.RotatingFileHandler', |
138 | | - 'filename': '/code/media/debug.log', |
139 | | - 'maxBytes': 1024*1024*5, # 5 MB |
140 | | - 'backupCount': 6, |
141 | | - 'formatter': 'verbose', # can switch between verbose and simple |
142 | | - }, |
| 218 | + # The file handler writes LOG_FILE (media/debug.log by default), which lands |
| 219 | + # in the bind-mounted web root — that's what makes it readable over SSH at |
| 220 | + # /cse/web/research/makelab/www[-test]/debug.log. (docs/DEPLOYMENT.md also |
| 221 | + # describes a /logs/ URL per Jason Howe's design, but that URL 404s on both |
| 222 | + # prod and test as of 2026-07-28.) Since the file still sits in a web-served |
| 223 | + # tree, stay conservative: log at INFO when DEBUG is off, but keep DEBUG-level |
| 224 | + # file logging in local dev where DEBUG is on and nothing is public. If the |
| 225 | + # log dir isn't writable (LOG_TO_FILE is False), degrade to a NullHandler so |
| 226 | + # startup never dies (issue #1283). |
| 227 | + 'file': _file_log_handler(LOG_FILE, 'DEBUG' if DEBUG else 'INFO', LOG_TO_FILE), |
143 | 228 | 'console': { |
144 | 229 | 'level': 'DEBUG', |
145 | 230 | 'filters': ['require_debug_true'], |
|
366 | 451 | # The MEDIA_URL is required by Django see and is a URL that handles the media served |
367 | 452 | # from MEDIA_ROOT, used for managing stored files. |
368 | 453 | # See: https://docs.djangoproject.com/en/4.2/ref/settings/#media-url |
| 454 | +# |
| 455 | +# NOTE: LOG_DIR (defined up with LOGGING, which has to be built before this) hard-codes |
| 456 | +# the same expression, because the web-served /logs/debug.log URL only works while the |
| 457 | +# log file lives inside the media root. If you move MEDIA_ROOT, move LOG_DIR with it — |
| 458 | +# test_default_log_file_is_under_media_root fails loudly if the two ever diverge. |
369 | 459 | MEDIA_ROOT = os.path.join(BASE_DIR, 'media') |
370 | 460 | MEDIA_URL = '/media/' |
371 | 461 |
|
|
0 commit comments