Skip to content

Fix six issues preventing a fresh production install - #314

Open
hdt-gif wants to merge 6 commits into
NatLabRockies:devfrom
hdt-gif:fix-fresh-install-deployment
Open

Fix six issues preventing a fresh production install#314
hdt-gif wants to merge 6 commits into
NatLabRockies:devfrom
hdt-gif:fix-fresh-install-deployment

Conversation

@hdt-gif

@hdt-gif hdt-gif commented Sep 8, 2026

Copy link
Copy Markdown

Deploying Engage from scratch onto a self-hosted server, I hit six issues that stop a fresh install running under settings.prod. All predate this branch and none are specific to my deployment — I think anyone standing Engage up outside NREL's own environment would meet all of them.

Happy to split this into separate PRs if you'd prefer.

Reference data has never loaded on a fresh install

compose/run-calliope-app.sh runs loaddata --app template before migrate. On an empty database those tables don't exist yet, so the load fails — and because the script has no set -e, the error prints and is ignored. The container then starts normally with no abstract technologies, no parameters and no template types, leaving an app you can't build models in.

It also only loads the template fixtures. Those reference api.Parameter and api.Abstract_Tech rows by id, and template/models.py has a pre_save hook that looks them up, so the api fixtures have to load first or the whole batch rolls back.

Fixing the order alone isn't safe, though: these fixtures carry hardcoded primary keys (admin_parameter.json is pk 1–154), and loaddata updates the row at each id rather than skipping it. Running them unconditionally on every boot would overwrite live records.

compose/seed-reference-data.sh therefore checks first — seeds an empty database, skips a populated one, and fails closed if it can't tell.

settings.prod can't start

Three independent problems:

  • gunicorn is added to INSTALLED_APPS by settings/prod.py but isn't in requirements.txt, and no entrypoint invokes it
  • /opt/python/log — the production logging handler writes there and nothing creates it, so Django raises at startup. batch/handlers.py already works around this with an explicit makedirs, which suggests it was known
  • Static filesDEBUG=False means Django stops serving STATIC_ROOT, so the site renders unstyled. Added WhiteNoise, which avoids requiring a separate web server for single-host deployments

Broker TLS is not configurable

CELERY_BROKER_USE_SSL is hardcoded to CERT_REQUIRED. That's correct against a managed broker with transit encryption, but impossible against a plain Redis container on the same host — every worker connection fails. Now read from the environment, still defaulting to True, so existing deployments are unchanged.

Secure cookies make login impossible without HTTPS

SESSION_COOKIE_SECURE and CSRF_COOKIE_SECURE are hardcoded True. Correct behind TLS, but on a plain-HTTP instance the browser withholds the CSRF cookie entirely and every login returns "CSRF verification failed". Also read from the environment now, still defaulting to True. Adds CSRF_TRUSTED_ORIGINS.

Race condition in build_model

api/views/outputs.py sets run.run_options in memory, dispatches the Celery task, and only then calls save(). build_model re-reads the Run from the database, so a worker that starts before that save sees run_options as NULL and get_model_yaml_set fails with

TypeError: 'NoneType' object is not iterable

Timing-dependent, so it depends on the machine — builds succeeded consistently on a 4-core laptop and failed consistently on a 2-core server. Fixed by saving before dispatch.

QuerySet.first() called with an argument

compute_environment = ComputeEnvironment.objects.filter(is_default=True).first(0)

first() takes no arguments, so the fallback raises TypeError instead of returning the default environment, and the user sees "Please contact admin at engage@nrel.gov". It's the error path that's broken, which is presumably why it has survived — the UI always supplies a valid run_env, so only a request that omits it reaches this line.

createsuperuser produces an account that can't open a model

Only the registration view creates a User_Profile. An account made any other way has none, and activity.html reads

{{ comment.created|timezone:user.user_profile.timezone }}

Django's template engine silently swallows ObjectDoesNotExist, so the missing profile resolves to an empty string and the filter raises

ValueError: ZoneInfo keys must be normalized relative paths, got:

The model page then 500s for that user and works for everyone else. Since createsuperuser is the documented way to create an admin account in Getting Started, following the setup guide produces an account that can't browse models. Added a post_save receiver on User so a profile exists regardless of how the account was created.

Testing

Verified on a fresh Ubuntu 26.04 host with an empty database: reference data seeds correctly, the app starts under settings.prod with gunicorn, static files serve, both Celery workers connect, and the bundled national-scale sample model builds and solves — 36 seconds, 55 output files with plausible capacities and costs. Restore of a pg_dump into a scratch database matched row counts exactly.

HokulaniTopping and others added 6 commits September 7, 2026 14:58
Three things stopped this app from starting in production, independent of
where it is deployed:

- gunicorn was added to INSTALLED_APPS by settings/prod.py but was never in
  requirements.txt, and no entrypoint invoked it.
- prod.py's logging config writes to /opt/python/log/django.log, but nothing
  created that directory, so Django raised on startup. batch/handlers.py works
  around this with an explicit makedirs, which suggests it was already known.
- DEBUG is False under prod.py, so Django stops serving STATIC_ROOT itself and
  the site renders unstyled. WhiteNoise now serves static files from inside the
  gunicorn process, avoiding a separate web server for single-host deploys.

Also makes broker TLS configurable. CELERY_BROKER_USE_SSL was hardcoded to
CERT_REQUIRED, which is correct for a managed broker with transit encryption
but impossible against a plain redis container on the same host -- it fails
every worker connection. It now reads from the environment and still defaults
to True, so existing deployments are unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The startup script ran `loaddata --app template` before `migrate`. On a fresh
database those tables do not exist yet, so the load failed -- and because the
script had no `set -e`, the error was printed and ignored. The result is that
reference data has never actually loaded on a new install: no abstract techs,
no parameters, no template types, leaving the app unusable for building models
until someone runs the documented setup steps by hand.

Fixing the order alone is not safe, though. These fixtures carry hardcoded
primary keys (admin_parameter.json is pk 1-154), and loaddata UPDATES the row
at each id rather than skipping it. Running them unconditionally on every boot
would overwrite live records -- including data restored from another Engage
instance, which is exactly what this deployment needs to do.

seed-reference-data.sh therefore checks first: it seeds an empty database,
skips a populated one, and fails closed if it cannot tell. It also loads the
api fixtures before the template ones, since template/models.py runs a pre_save
hook that looks up Abstract_Tech and Parameter rows by id.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
SESSION_COOKIE_SECURE and CSRF_COOKIE_SECURE were hardcoded True. That is
correct behind TLS, but on a plain-HTTP deployment the browser withholds the
CSRF cookie entirely, so every form submission -- including login -- fails with
"CSRF verification failed. Request aborted."

Both now read from the environment and still default to True, so any existing
HTTPS deployment is unchanged. Only an instance that explicitly opts out gets
insecure cookies. Also adds CSRF_TRUSTED_ORIGINS, which Django needs when the
form's origin is not an HTTPS host it already trusts.

This is the same pattern already used for DJANGO_SECURE_SSL_REDIRECT and
CELERY_BROKER_USE_SSL: safe by default, relaxable for a test box.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
build_model reads its Run back from the database, but the view set
run.run_options in memory and only called save() *after* apply_async. A worker
that picked the task up before that save saw run_options as NULL, and
get_model_yaml_set failed with "'NoneType' object is not iterable".

Timing-dependent, so it depends on the machine: builds succeeded on a
4-core laptop and failed consistently on a 2-core EC2 instance, where the
worker reliably won the race.

Saving before dispatch closes the window. The later save() is still needed to
record build_task, which cannot exist until apply_async returns.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
QuerySet.first() takes no arguments, so the fallback raised

    TypeError: first() takes 1 positional argument but 2 were given

rather than returning the default ComputeEnvironment. Any run started without
a valid run_env hit it and the user saw "Please contact admin at
engage@nrel.gov" instead of their model building.

The fallback exists precisely for when the requested environment cannot be
found, so it was the error path that was broken -- which is why it survived
since 2022: the UI always supplies a valid run_env, and only a request that
omits it reaches this line.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Only the registration view created a User_Profile. An account made any other
way -- `createsuperuser`, the Django admin, a script -- had none, and
activity.html reads

    {{ comment.created|timezone:user.user_profile.timezone }}

Django's template engine silently swallows ObjectDoesNotExist, so the missing
profile resolved to an empty string and the timezone filter raised

    ValueError: ZoneInfo keys must be normalized relative paths, got:

The model page then returned a 500 for that user while working for everyone
else. `createsuperuser` is the documented way to create an admin account in the
Getting Started guide, so following the setup instructions produced an account
that could not open a model.

A post_save receiver on User closes it for every path. The timezone default is
on the field, so profiles created this way are complete.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants