snapshot of https://github.com/zulip/zulip.git @ 48c69911c7dd4e03b20552eb3bd77ad8151c1357 (2026-09-15)
Some checks failed
Code scanning / CodeQL (push) Failing after 15s
Zulip production suite / Ubuntu 22.04 production build (push) Failing after 26s
Zulip production suite / Debian 12 production install with custom db name and user (push) Has been skipped
Zulip production suite / Ubuntu 22.04 production install and PostgreSQL upgrade with pgroonga (push) Has been skipped
Zulip production suite / Ubuntu 24.04 production install (push) Has been skipped
Zulip production suite / Ubuntu 26.04 production install (push) Has been skipped
Zulip production suite / Debian 13 production install (push) Has been skipped
Zulip production suite / 7.0 Version Upgrade (push) Has been skipped
Zulip production suite / 8.0 Version Upgrade (push) Has been skipped
Zulip production suite / 6.0 Version Upgrade (push) Has been skipped
Zulip production suite / 10.0 Version Upgrade (push) Has been skipped
Zulip production suite / 9.0 Version Upgrade (push) Has been skipped
Zulip production suite / 12.0 Version Upgrade (push) Has been skipped
Zulip production suite / 11.0 Version Upgrade (push) Has been skipped
GitHub Actions Security Analysis with zizmor / zizmor (push) Failing after 3s
Zulip CI / Debian 12 (Python 3.11, backend + documentation) (push) Failing after 26s
Zulip CI / Ubuntu 22.04 (Python 3.10, backend + frontend) (push) Failing after 3s
Zulip CI / Ubuntu 24.04 (Python 3.12, backend) (push) Failing after 25s
Zulip CI / Ubuntu 26.04 (Python 3.14, backend) (push) Failing after 25s
Zulip CI / Debian 13 (Python 3.13, backend) (push) Failing after 28s
Zulip production suite / Required jobs (push) Has been cancelled
Zulip CI / Required jobs (push) Has been cancelled
API Documentation Update Check / check-feature-level-updated (push) Has been cancelled
API Documentation Update Check / notify-if-api-docs-changed (push) Has been cancelled
Some checks failed
Code scanning / CodeQL (push) Failing after 15s
Zulip production suite / Ubuntu 22.04 production build (push) Failing after 26s
Zulip production suite / Debian 12 production install with custom db name and user (push) Has been skipped
Zulip production suite / Ubuntu 22.04 production install and PostgreSQL upgrade with pgroonga (push) Has been skipped
Zulip production suite / Ubuntu 24.04 production install (push) Has been skipped
Zulip production suite / Ubuntu 26.04 production install (push) Has been skipped
Zulip production suite / Debian 13 production install (push) Has been skipped
Zulip production suite / 7.0 Version Upgrade (push) Has been skipped
Zulip production suite / 8.0 Version Upgrade (push) Has been skipped
Zulip production suite / 6.0 Version Upgrade (push) Has been skipped
Zulip production suite / 10.0 Version Upgrade (push) Has been skipped
Zulip production suite / 9.0 Version Upgrade (push) Has been skipped
Zulip production suite / 12.0 Version Upgrade (push) Has been skipped
Zulip production suite / 11.0 Version Upgrade (push) Has been skipped
GitHub Actions Security Analysis with zizmor / zizmor (push) Failing after 3s
Zulip CI / Debian 12 (Python 3.11, backend + documentation) (push) Failing after 26s
Zulip CI / Ubuntu 22.04 (Python 3.10, backend + frontend) (push) Failing after 3s
Zulip CI / Ubuntu 24.04 (Python 3.12, backend) (push) Failing after 25s
Zulip CI / Ubuntu 26.04 (Python 3.14, backend) (push) Failing after 25s
Zulip CI / Debian 13 (Python 3.13, backend) (push) Failing after 28s
Zulip production suite / Required jobs (push) Has been cancelled
Zulip CI / Required jobs (push) Has been cancelled
API Documentation Update Check / check-feature-level-updated (push) Has been cancelled
API Documentation Update Check / notify-if-api-docs-changed (push) Has been cancelled
This commit is contained in:
11
scripts/README.md
Normal file
11
scripts/README.md
Normal file
@@ -0,0 +1,11 @@
|
||||
This directory contains scripts that:
|
||||
|
||||
- Generally do not require access to Django or the database (those are
|
||||
"management commands"), and thus are suitable to run operationally.
|
||||
|
||||
- Are useful for managing a production deployment of Zulip (many are
|
||||
also used in a Zulip development environment, though
|
||||
development-only scripts live in `tools/`).
|
||||
|
||||
For more details, see
|
||||
https://zulip.readthedocs.io/en/latest/subsystems/directory-structure.html.
|
||||
0
scripts/__init__.py
Normal file
0
scripts/__init__.py
Normal file
14
scripts/get-django-setting
Executable file
14
scripts/get-django-setting
Executable file
@@ -0,0 +1,14 @@
|
||||
#!/usr/bin/env python3
|
||||
import os
|
||||
import sys
|
||||
|
||||
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
sys.path.append(BASE_DIR)
|
||||
from scripts.lib.setup_path import setup_path
|
||||
|
||||
setup_path()
|
||||
|
||||
os.environ["DJANGO_SETTINGS_MODULE"] = "zproject.settings"
|
||||
from django.conf import settings
|
||||
|
||||
print(getattr(settings, sys.argv[1]))
|
||||
0
scripts/lib/__init__.py
Normal file
0
scripts/lib/__init__.py
Normal file
17
scripts/lib/build-pgroonga
Executable file
17
scripts/lib/build-pgroonga
Executable file
@@ -0,0 +1,17 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euxo pipefail
|
||||
|
||||
version="4.0.6"
|
||||
sha256=d0048944763c18f91bc67e043aafa64c2c53f6246547c9474311efbc05ccfe66
|
||||
|
||||
tmpdir="$(mktemp -d)"
|
||||
trap 'rm -r "$tmpdir"' EXIT
|
||||
cd "$tmpdir"
|
||||
tarball="pgroonga-$version.tar.gz"
|
||||
curl -fLO --retry 3 "https://github.com/pgroonga/pgroonga/releases/download/$version/$tarball"
|
||||
sha256sum -c <<<"$sha256 $tarball"
|
||||
tar -xzf "$tarball"
|
||||
cd "pgroonga-$version"
|
||||
|
||||
meson setup build .
|
||||
meson install -C build
|
||||
100
scripts/lib/check-database-compatibility
Executable file
100
scripts/lib/check-database-compatibility
Executable file
@@ -0,0 +1,100 @@
|
||||
#!/usr/bin/env python3
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
ZULIP_PATH = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
sys.path.insert(0, ZULIP_PATH)
|
||||
|
||||
from scripts.lib.setup_path import setup_path
|
||||
from scripts.lib.zulip_tools import (
|
||||
DEPLOYMENTS_DIR,
|
||||
assert_not_running_as_root,
|
||||
get_config,
|
||||
get_config_file,
|
||||
parse_version_from,
|
||||
)
|
||||
from version import ZULIP_VERSION as NEW_VERSION
|
||||
|
||||
assert_not_running_as_root()
|
||||
setup_path()
|
||||
os.environ["DJANGO_SETTINGS_MODULE"] = "zproject.settings"
|
||||
|
||||
import django
|
||||
from django.db import connection
|
||||
from django.db.migrations.loader import MigrationLoader
|
||||
|
||||
from zerver.lib.migration_status import STALE_MIGRATIONS
|
||||
|
||||
django.setup()
|
||||
|
||||
django_pg_version = connection.cursor().connection.server_version // 10000
|
||||
if os.path.exists("/etc/init.d/postgresql") and os.path.exists("/etc/zulip/zulip.conf"):
|
||||
postgresql_version = int(get_config(get_config_file(), "postgresql", "version", "0"))
|
||||
if postgresql_version == 0:
|
||||
postgresql_version = django_pg_version
|
||||
subprocess.check_call(
|
||||
[
|
||||
"crudini",
|
||||
"--set",
|
||||
"/etc/zulip/zulip.conf",
|
||||
"postgresql",
|
||||
"version",
|
||||
str(postgresql_version),
|
||||
]
|
||||
)
|
||||
elif postgresql_version != django_pg_version:
|
||||
logging.critical(
|
||||
"PostgreSQL version mismatch: %d (running) vs %d (configured)",
|
||||
django_pg_version,
|
||||
postgresql_version,
|
||||
)
|
||||
logging.info(
|
||||
"/etc/zulip/zulip.conf claims that Zulip is running PostgreSQL\n"
|
||||
"%d, but the server is connected to a PostgreSQL running\n"
|
||||
"version %d. Check the output from pg_lsclusters to verify\n"
|
||||
"which clusters are running, and update /etc/zulip/zulip.conf to match.\n"
|
||||
"\n"
|
||||
"In general, this results from manually upgrading PostgreSQL; you\n"
|
||||
"should follow our instructions for using our tool to do so:\n"
|
||||
"https://zulip.readthedocs.io/en/stable/production/upgrade.html#upgrading-postgresql",
|
||||
postgresql_version,
|
||||
django_pg_version,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
if django_pg_version < 14:
|
||||
logging.critical("Unsupported PostgreSQL version: %d", postgresql_version)
|
||||
logging.info(
|
||||
"Please upgrade to PostgreSQL 14 or newer first.\n"
|
||||
"See https://zulip.readthedocs.io/en/stable/production/"
|
||||
"upgrade.html#upgrading-postgresql"
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
loader = MigrationLoader(connection)
|
||||
missing = set(loader.applied_migrations)
|
||||
|
||||
missing.difference_update(STALE_MIGRATIONS)
|
||||
|
||||
for key, migration in loader.disk_migrations.items():
|
||||
missing.discard(key)
|
||||
missing.difference_update(migration.replaces)
|
||||
if not missing:
|
||||
sys.exit(0)
|
||||
|
||||
print("Migrations which are currently applied, but missing in the new version:")
|
||||
for app, migration_name in sorted(missing):
|
||||
print(f" {app} - {migration_name}")
|
||||
|
||||
current_version = parse_version_from(os.path.join(DEPLOYMENTS_DIR, "current"))
|
||||
logging.error(
|
||||
"This is not an upgrade -- the current deployment (version %s) "
|
||||
"contains %s database migrations which %s (version %s) does not.",
|
||||
current_version,
|
||||
len(missing),
|
||||
ZULIP_PATH,
|
||||
NEW_VERSION,
|
||||
)
|
||||
sys.exit(1)
|
||||
233
scripts/lib/check_rabbitmq_queue.py
Normal file
233
scripts/lib/check_rabbitmq_queue.py
Normal file
@@ -0,0 +1,233 @@
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from typing import Any
|
||||
|
||||
ZULIP_PATH = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
sys.path.append(ZULIP_PATH)
|
||||
from scripts.lib.zulip_tools import atomic_nagios_write, get_config, get_config_file
|
||||
|
||||
normal_queues = [
|
||||
"deferred_work",
|
||||
"deferred_email_senders",
|
||||
"digest_emails",
|
||||
"email_mirror",
|
||||
"email_senders",
|
||||
"embed_links",
|
||||
"embedded_bots",
|
||||
"missedmessage_emails",
|
||||
"missedmessage_mobile_notifications",
|
||||
"outgoing_webhooks",
|
||||
"thumbnail",
|
||||
"user_activity",
|
||||
"user_activity_interval",
|
||||
]
|
||||
|
||||
# Soft reactivations share the deferred_work queue unless a server opts
|
||||
# into a dedicated queue; only then is there a consumer to monitor.
|
||||
if get_config(get_config_file(), "application_server", "dedicated_soft_reactivation_queue", False):
|
||||
normal_queues.append("soft_reactivation")
|
||||
|
||||
mobile_notification_shards = int(
|
||||
get_config(get_config_file(), "application_server", "mobile_notification_shards", "1")
|
||||
)
|
||||
user_activity_shards = int(
|
||||
get_config(get_config_file(), "application_server", "user_activity_shards", "1")
|
||||
)
|
||||
|
||||
OK = 0
|
||||
WARNING = 1
|
||||
CRITICAL = 2
|
||||
UNKNOWN = 3
|
||||
|
||||
states = {
|
||||
0: "OK",
|
||||
1: "WARNING",
|
||||
2: "CRITICAL",
|
||||
3: "UNKNOWN",
|
||||
}
|
||||
|
||||
MAX_SECONDS_TO_CLEAR: defaultdict[str, int] = defaultdict(
|
||||
lambda: 30,
|
||||
deferred_work=600,
|
||||
# Soft reactivations are the deferred_work backfills split into their
|
||||
# own queue; keep deferred_work's generous clear-time budget so the slow
|
||||
# backfills that motivated the split don't trip false alerts.
|
||||
soft_reactivation=600,
|
||||
digest_emails=1200,
|
||||
missedmessage_mobile_notifications=120,
|
||||
embed_links=60,
|
||||
email_senders=90,
|
||||
deferred_email_senders=3600,
|
||||
)
|
||||
CRITICAL_SECONDS_TO_CLEAR: defaultdict[str, int] = defaultdict(
|
||||
lambda: 60,
|
||||
deferred_work=900,
|
||||
soft_reactivation=900,
|
||||
missedmessage_mobile_notifications=180,
|
||||
digest_emails=1800,
|
||||
embed_links=90,
|
||||
email_senders=300,
|
||||
deferred_email_senders=4500,
|
||||
)
|
||||
|
||||
|
||||
def analyze_queue_stats(
|
||||
queue_name: str, stats: dict[str, Any], queue_count_rabbitmqctl: int
|
||||
) -> dict[str, Any]:
|
||||
now = int(time.time())
|
||||
if stats == {}:
|
||||
return dict(status=UNKNOWN, name=queue_name, message="invalid or no stats data")
|
||||
|
||||
if now - stats["update_time"] > 180 and queue_count_rabbitmqctl > 10:
|
||||
# Queue isn't updating the stats file and has some events in
|
||||
# the backlog, it's likely stuck.
|
||||
#
|
||||
# TODO: There's an unlikely race condition here - if the queue
|
||||
# was fully emptied and was idle due to no new events coming
|
||||
# for over 180 seconds, suddenly gets a burst of events and
|
||||
# this code runs exactly in the very small time window between
|
||||
# those events popping up and the queue beginning to process
|
||||
# the first one (which will refresh the stats file at the very
|
||||
# start), we'll incorrectly return the CRITICAL status. The
|
||||
# chance of that happening should be negligible because the queue
|
||||
# worker should wake up immediately and log statistics before
|
||||
# starting to process the first event.
|
||||
return dict(
|
||||
status=CRITICAL,
|
||||
name=queue_name,
|
||||
message="queue appears to be stuck, last update {}, queue size {}".format(
|
||||
stats["update_time"], queue_count_rabbitmqctl
|
||||
),
|
||||
)
|
||||
|
||||
current_size = queue_count_rabbitmqctl
|
||||
average_consume_time = stats["recent_average_consume_time"]
|
||||
if average_consume_time is None:
|
||||
# Queue just started; we can't effectively estimate anything.
|
||||
#
|
||||
# If the queue is stuck in this state and not processing
|
||||
# anything, eventually the `update_time` rule above will fire.
|
||||
return dict(status=OK, name=queue_name, message="")
|
||||
|
||||
expected_time_to_clear_backlog = current_size * average_consume_time
|
||||
if expected_time_to_clear_backlog > MAX_SECONDS_TO_CLEAR[queue_name]:
|
||||
if expected_time_to_clear_backlog > CRITICAL_SECONDS_TO_CLEAR[queue_name]:
|
||||
status = CRITICAL
|
||||
else:
|
||||
status = WARNING
|
||||
|
||||
return dict(
|
||||
status=status,
|
||||
name=queue_name,
|
||||
message=f"clearing the backlog will take too long: {expected_time_to_clear_backlog}s, size: {current_size}",
|
||||
)
|
||||
|
||||
return dict(status=OK, name=queue_name, message="")
|
||||
|
||||
|
||||
WARN_COUNT_THRESHOLD_DEFAULT = 10
|
||||
CRITICAL_COUNT_THRESHOLD_DEFAULT = 50
|
||||
|
||||
|
||||
def check_other_queues(queue_counts_dict: dict[str, int]) -> list[dict[str, Any]]:
|
||||
"""Do a simple queue size check for queues whose workers don't publish stats files."""
|
||||
|
||||
results = []
|
||||
for queue, count in queue_counts_dict.items():
|
||||
if queue in normal_queues:
|
||||
continue
|
||||
|
||||
if count > CRITICAL_COUNT_THRESHOLD_DEFAULT:
|
||||
results.append(dict(status=CRITICAL, name=queue, message=f"count critical: {count}"))
|
||||
elif count > WARN_COUNT_THRESHOLD_DEFAULT:
|
||||
results.append(dict(status=WARNING, name=queue, message=f"count warning: {count}"))
|
||||
else:
|
||||
results.append(dict(status=OK, name=queue, message=""))
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def check_rabbitmq_queues() -> None:
|
||||
pattern = re.compile(r"(\w+)\t(\d+)\t(\d+)")
|
||||
if "USER" in os.environ and os.environ["USER"] not in ["root", "rabbitmq"]:
|
||||
print("This script must be run as the root or rabbitmq user")
|
||||
|
||||
list_queues_output = subprocess.check_output(
|
||||
["/usr/sbin/rabbitmqctl", "list_queues", "name", "messages", "consumers"],
|
||||
text=True,
|
||||
)
|
||||
queue_counts_rabbitmqctl = {}
|
||||
queues_with_consumers = []
|
||||
for line in list_queues_output.split("\n"):
|
||||
line = line.strip()
|
||||
m = pattern.match(line)
|
||||
if m:
|
||||
queue = m.group(1)
|
||||
count = int(m.group(2))
|
||||
consumers = int(m.group(3))
|
||||
queue_counts_rabbitmqctl[queue] = count
|
||||
if consumers > 0 and not queue.startswith("notify_tornado"):
|
||||
queues_with_consumers.append(queue)
|
||||
|
||||
queue_stats_dir = subprocess.check_output(
|
||||
[os.path.join(ZULIP_PATH, "scripts/get-django-setting"), "QUEUE_STATS_DIR"],
|
||||
text=True,
|
||||
).strip()
|
||||
queue_stats: dict[str, dict[str, Any]] = {}
|
||||
|
||||
check_queues = normal_queues
|
||||
if mobile_notification_shards > 1:
|
||||
# For sharded queue workers, where there's a separate queue
|
||||
# for each shard, we need to make sure none of those are
|
||||
# backlogged.
|
||||
check_queues += [
|
||||
f"missedmessage_mobile_notifications_shard{d}"
|
||||
for d in range(1, mobile_notification_shards + 1)
|
||||
]
|
||||
if user_activity_shards > 1:
|
||||
check_queues += [f"user_activity_shard{d}" for d in range(1, user_activity_shards + 1)]
|
||||
|
||||
queues_to_check = set(check_queues).intersection(set(queues_with_consumers))
|
||||
for queue in queues_to_check:
|
||||
fn = queue + ".stats"
|
||||
file_path = os.path.join(queue_stats_dir, fn)
|
||||
if not os.path.exists(file_path):
|
||||
queue_stats[queue] = {}
|
||||
continue
|
||||
|
||||
with open(file_path) as f:
|
||||
try:
|
||||
queue_stats[queue] = json.load(f)
|
||||
except json.decoder.JSONDecodeError:
|
||||
queue_stats[queue] = {}
|
||||
|
||||
results = []
|
||||
for queue_name, stats in queue_stats.items():
|
||||
results.append(analyze_queue_stats(queue_name, stats, queue_counts_rabbitmqctl[queue_name]))
|
||||
|
||||
results.extend(check_other_queues(queue_counts_rabbitmqctl))
|
||||
|
||||
status = max(result["status"] for result in results)
|
||||
|
||||
if status > 0:
|
||||
queue_error_template = "queue {} problem: {}:{}"
|
||||
error_message = "; ".join(
|
||||
queue_error_template.format(result["name"], states[result["status"]], result["message"])
|
||||
for result in results
|
||||
if result["status"] > 0
|
||||
)
|
||||
sys.exit(
|
||||
atomic_nagios_write(
|
||||
"check-rabbitmq-results",
|
||||
"critical" if status == CRITICAL else "warning",
|
||||
error_message,
|
||||
)
|
||||
)
|
||||
else:
|
||||
atomic_nagios_write("check-rabbitmq-results", "ok", "queues normal")
|
||||
48
scripts/lib/clean_emoji_cache.py
Executable file
48
scripts/lib/clean_emoji_cache.py
Executable file
@@ -0,0 +1,48 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
|
||||
ZULIP_PATH = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
sys.path.append(ZULIP_PATH)
|
||||
from scripts.lib.zulip_tools import (
|
||||
get_environment,
|
||||
get_recent_deployments,
|
||||
parse_cache_script_args,
|
||||
purge_unused_caches,
|
||||
)
|
||||
|
||||
ENV = get_environment()
|
||||
EMOJI_CACHE_PATH = "/srv/zulip-emoji-cache"
|
||||
|
||||
|
||||
def get_caches_in_use(threshold_days: int) -> set[str]:
|
||||
setups_to_check = {ZULIP_PATH}
|
||||
caches_in_use = set()
|
||||
|
||||
if ENV == "prod":
|
||||
setups_to_check |= get_recent_deployments(threshold_days)
|
||||
if ENV == "dev":
|
||||
CACHE_SYMLINK = os.path.join(ZULIP_PATH, "static", "generated", "emoji")
|
||||
CURRENT_CACHE = os.path.dirname(os.path.realpath(CACHE_SYMLINK))
|
||||
caches_in_use.add(CURRENT_CACHE)
|
||||
|
||||
for setup_dir in setups_to_check:
|
||||
emoji_link_path = os.path.join(setup_dir, "static/generated/emoji")
|
||||
if not os.path.islink(emoji_link_path):
|
||||
# This happens for a deployment directory extracted from a
|
||||
# tarball, which just has a copy of the emoji data, not a symlink.
|
||||
continue
|
||||
# The actual cache path doesn't include the /emoji
|
||||
caches_in_use.add(os.path.dirname(os.readlink(emoji_link_path)))
|
||||
return caches_in_use
|
||||
|
||||
|
||||
def main(args: argparse.Namespace) -> None:
|
||||
caches_in_use = get_caches_in_use(args.threshold_days)
|
||||
purge_unused_caches(EMOJI_CACHE_PATH, caches_in_use, "emoji cache", args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
args = parse_cache_script_args("This script cleans unused Zulip emoji caches.")
|
||||
main(args)
|
||||
22
scripts/lib/clean_unused_caches.py
Executable file
22
scripts/lib/clean_unused_caches.py
Executable file
@@ -0,0 +1,22 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
ZULIP_PATH = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
sys.path.append(ZULIP_PATH)
|
||||
from scripts.lib import clean_emoji_cache
|
||||
from scripts.lib.zulip_tools import parse_cache_script_args
|
||||
|
||||
|
||||
def main(args: argparse.Namespace) -> None:
|
||||
os.chdir(ZULIP_PATH)
|
||||
shutil.rmtree("/srv/zulip-venv-cache", ignore_errors=True) # Replaced as of 10.0
|
||||
shutil.rmtree("/srv/zulip-npm-cache", ignore_errors=True) # Replaced as of 7.0
|
||||
clean_emoji_cache.main(args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
args = parse_cache_script_args("This script cleans unused Zulip caches.")
|
||||
main(args)
|
||||
36
scripts/lib/create-production-venv
Executable file
36
scripts/lib/create-production-venv
Executable file
@@ -0,0 +1,36 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import os
|
||||
import platform
|
||||
import sys
|
||||
|
||||
ZULIP_PATH = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
if ZULIP_PATH not in sys.path:
|
||||
sys.path.append(ZULIP_PATH)
|
||||
|
||||
from scripts.lib.setup_venv import get_venv_dependencies
|
||||
from scripts.lib.zulip_tools import os_families, run
|
||||
|
||||
parser = argparse.ArgumentParser(description="Create a production virtualenv with caching")
|
||||
parser.add_argument("deploy_path")
|
||||
args = parser.parse_args()
|
||||
|
||||
# install dependencies for setting up the virtualenv
|
||||
distro_info = platform.freedesktop_os_release()
|
||||
vendor = distro_info["ID"]
|
||||
os_version = distro_info["VERSION_ID"]
|
||||
VENV_DEPENDENCIES = get_venv_dependencies(vendor, os_version)
|
||||
if "debian" in os_families():
|
||||
run(["apt-get", "-y", "install", "--no-install-recommends", *VENV_DEPENDENCIES])
|
||||
elif "fedora" in os_families():
|
||||
run(["yum", "-y", "install", *VENV_DEPENDENCIES])
|
||||
else:
|
||||
print("Unsupported platform: {}".format(distro_info["ID"]))
|
||||
sys.exit(1)
|
||||
|
||||
os.chdir(ZULIP_PATH)
|
||||
run(["scripts/lib/install-uv"])
|
||||
run(
|
||||
["uv", "sync", "--frozen", "--no-managed-python", "--only-group=prod"],
|
||||
env={k: v for k, v in os.environ.items() if k not in {"PYTHONDEVMODE", "PYTHONWARNINGS"}},
|
||||
)
|
||||
790
scripts/lib/install
Executable file
790
scripts/lib/install
Executable file
@@ -0,0 +1,790 @@
|
||||
#!/usr/bin/env bash
|
||||
set -e
|
||||
|
||||
usage() {
|
||||
# A subset of this documentation also appears in docs/production/install.md
|
||||
cat <<'EOF'
|
||||
Usage:
|
||||
install --hostname=zulip.example.com --email=zulip-admin@example.com [options...]
|
||||
install --help
|
||||
|
||||
Options:
|
||||
--hostname=zulip.example.com
|
||||
The user-accessible domain name for this Zulip server, i.e.,
|
||||
what users will type in their web browser. Required, unless
|
||||
--no-init-db or --puppet-classes is set, and --certbot is not.
|
||||
Use Punycode for non-ASCII domains.
|
||||
|
||||
--email=zulip-admin@example.com
|
||||
The email address of the person or team who should get support
|
||||
and error emails from this Zulip server. Required, unless
|
||||
--no-init-db or --puppet-classes is set and --certbot is not.
|
||||
|
||||
--certbot
|
||||
Obtains a free SSL certificate for the server using Certbot,
|
||||
https://certbot.eff.org/ Recommended. Conflicts with --self-signed-cert.
|
||||
--self-signed-cert
|
||||
Generate a self-signed SSL certificate for the server. This isn’t suitable for
|
||||
production use, but may be convenient for testing. Conflicts with --certbot.
|
||||
|
||||
--postgresql-database-name=zulip
|
||||
Sets the PostgreSQL database name.
|
||||
--postgresql-database-user=zulip
|
||||
Sets the PostgreSQL database user.
|
||||
--postgresql-version=18
|
||||
Sets the version of PostgreSQL that will be installed.
|
||||
|
||||
--no-init-db
|
||||
Does not do any database initialization; use when you already have a Zulip
|
||||
database.
|
||||
--puppet-classes
|
||||
Comma-separated list of Puppet classes to install; defaults to
|
||||
'zulip:::profile::standalone'. Implies --no-init-db.
|
||||
|
||||
--no-overwrite-settings
|
||||
Preserve existing `/etc/zulip` configuration files.
|
||||
--no-dist-upgrade
|
||||
Skip the initial `apt-get dist-upgrade`.
|
||||
|
||||
--push-notifications
|
||||
With this option, the Zulip installer registers your server for the Mobile
|
||||
Push Notification Service, and sets up the initial default configuration.
|
||||
You will be immediately prompted to agree to the Terms of Service, and your
|
||||
server will be registered at the end of the installation process.
|
||||
--no-push-notifications
|
||||
Disable push notifications registration (the default if neither
|
||||
flag is provided).
|
||||
|
||||
--no-submit-usage-statistics
|
||||
If you enable push notifications, by default your server
|
||||
will submit basic metadata (required for billing and for
|
||||
determining free plan eligibility), as well as aggregate usage
|
||||
statistics. You can disable submitting usage statistics by passing
|
||||
this flag.
|
||||
If push notifications are not enabled, data won't be submitted, so
|
||||
this flag is redundant.
|
||||
|
||||
--agree-to-terms-of-service
|
||||
If you enable push notifications, you can pass this flag to indicate
|
||||
that you have read and agree to the Zulip Terms of Service:
|
||||
<https://zulip.com/policies/terms>.
|
||||
This skips the Terms of Service prompt, allowing for running the installer
|
||||
with --push-notifications in scripts without requiring user input.
|
||||
|
||||
|
||||
EOF
|
||||
}
|
||||
|
||||
system_requirements_failure() {
|
||||
set +x
|
||||
echo >&2
|
||||
cat >&2
|
||||
cat <<EOF >&2
|
||||
|
||||
For more information, see:
|
||||
https://zulip.readthedocs.io/en/latest/production/requirements.html
|
||||
EOF
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Shell option parsing. Over time, we'll want to move some of the
|
||||
# environment variables below into this self-documenting system.
|
||||
args="$(getopt -o '' --long help,hostname:,email:,certbot,self-signed-cert,cacert:,postgresql-database-name:,postgresql-database-user:,postgresql-version:,no-init-db,puppet-classes:,no-overwrite-settings,no-dist-upgrade,push-notifications,no-push-notifications,no-submit-usage-statistics,agree-to-terms-of-service -n "$0" -- "$@")"
|
||||
|
||||
eval "set -- $args"
|
||||
while true; do
|
||||
case "$1" in
|
||||
--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
|
||||
--hostname)
|
||||
EXTERNAL_HOST="$2"
|
||||
shift
|
||||
shift
|
||||
;;
|
||||
--email)
|
||||
ZULIP_ADMINISTRATOR="$2"
|
||||
shift
|
||||
shift
|
||||
;;
|
||||
|
||||
--certbot)
|
||||
USE_CERTBOT=1
|
||||
shift
|
||||
;;
|
||||
--self-signed-cert)
|
||||
SELF_SIGNED_CERT=1
|
||||
shift
|
||||
;;
|
||||
--postgresql-database-name)
|
||||
POSTGRESQL_DATABASE_NAME="$2"
|
||||
shift
|
||||
shift
|
||||
;;
|
||||
--postgresql-database-user)
|
||||
POSTGRESQL_DATABASE_USER="$2"
|
||||
shift
|
||||
shift
|
||||
;;
|
||||
--postgresql-version)
|
||||
POSTGRESQL_VERSION="$2"
|
||||
shift
|
||||
shift
|
||||
;;
|
||||
--no-init-db)
|
||||
NO_INIT_DB=1
|
||||
shift
|
||||
;;
|
||||
--puppet-classes)
|
||||
PUPPET_CLASSES="$2"
|
||||
NO_INIT_DB=1
|
||||
shift
|
||||
shift
|
||||
;;
|
||||
|
||||
--no-overwrite-settings)
|
||||
NO_OVERWRITE_SETTINGS=1
|
||||
shift
|
||||
;;
|
||||
--no-dist-upgrade)
|
||||
NO_DIST_UPGRADE=1
|
||||
shift
|
||||
;;
|
||||
--push-notifications)
|
||||
if [ -n "$NO_PUSH_NOTIFICATIONS" ]; then
|
||||
echo "error: --push-notifications and --no-push-notifications are incompatible." >&2
|
||||
exit 1
|
||||
fi
|
||||
PUSH_NOTIFICATIONS=1
|
||||
shift
|
||||
;;
|
||||
--no-push-notifications)
|
||||
if [ -n "$PUSH_NOTIFICATIONS" ]; then
|
||||
echo "error: --push-notifications and --no-push-notifications are incompatible." >&2
|
||||
exit 1
|
||||
fi
|
||||
NO_PUSH_NOTIFICATIONS=1
|
||||
shift
|
||||
;;
|
||||
--no-submit-usage-statistics)
|
||||
NO_SUBMIT_USAGE_STATISTICS=1
|
||||
shift
|
||||
;;
|
||||
--agree-to-terms-of-service)
|
||||
AGREE_TO_TERMS_OF_SERVICE_FLAG=1
|
||||
shift
|
||||
;;
|
||||
--)
|
||||
shift
|
||||
break
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ "$#" -gt 0 ]; then
|
||||
usage >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
## Options from environment variables.
|
||||
#
|
||||
# Specify options for apt.
|
||||
read -r -a APT_OPTIONS <<<"${APT_OPTIONS:-}"
|
||||
# Install additional packages.
|
||||
read -r -a ADDITIONAL_PACKAGES <<<"${ADDITIONAL_PACKAGES:-}"
|
||||
# Comma-separated list of Puppet manifests to install. The default is
|
||||
# zulip::profile::standalone for an all-in-one system or
|
||||
# zulip::profile::docker for Docker. Use
|
||||
# e.g. zulip::profile::app_frontend for a Zulip frontend server.
|
||||
PUPPET_CLASSES="${PUPPET_CLASSES:-zulip::profile::standalone}"
|
||||
POSTGRESQL_VERSION="${POSTGRESQL_VERSION:-18}"
|
||||
|
||||
if [ -n "$SELF_SIGNED_CERT" ] && [ -n "$USE_CERTBOT" ]; then
|
||||
set +x
|
||||
echo "error: --self-signed-cert and --certbot are incompatible" >&2
|
||||
echo >&2
|
||||
usage >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "$EXTERNAL_HOST" ] || [ -z "$ZULIP_ADMINISTRATOR" ]; then
|
||||
if [ -n "$USE_CERTBOT" ] || [ -z "$NO_INIT_DB" ]; then
|
||||
usage >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$EXTERNAL_HOST" = zulip.example.com ] \
|
||||
|| [ "$ZULIP_ADMINISTRATOR" = zulip-admin@example.com ]; then
|
||||
# These example values are specifically checked for and would fail
|
||||
# later; see zerver/checks.py
|
||||
echo 'error: The example hostname and email must be replaced with real values.' >&2
|
||||
echo >&2
|
||||
usage >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
case "$EXTERNAL_HOST" in
|
||||
*[![:ascii:]]*)
|
||||
# Non-ASCII hostnames are specifically checked for and would
|
||||
# fail later; see zerver/checks.py
|
||||
echo "error: The hostname ($EXTERNAL_HOST) contains non-ASCII characters." >&2
|
||||
echo 'Internationalized domain names must be specified in punycode ("xn--") form:' >&2
|
||||
# python3 is not installed until later, so it may not be
|
||||
# available to compute the suggested encoding.
|
||||
if command -v python3 >/dev/null 2>&1; then
|
||||
suggestion="$(python3 -c '
|
||||
import sys
|
||||
|
||||
hostname, sep, port = sys.argv[1].partition(":")
|
||||
print(
|
||||
".".join(
|
||||
"xn--" + label.encode("punycode").decode() if not label.isascii() else label
|
||||
for label in hostname.split(".")
|
||||
)
|
||||
+ sep
|
||||
+ port
|
||||
)' "$EXTERNAL_HOST")"
|
||||
echo " --hostname=$suggestion" >&2
|
||||
fi
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
case "$POSTGRESQL_VERSION" in
|
||||
[0-9] | [0-9].* | 1[0-3] | 1[0-3].*)
|
||||
echo "error: PostgreSQL 14 or newer is required." >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ -z "$PUSH_NOTIFICATIONS" ] && [ -z "$NO_PUSH_NOTIFICATIONS" ]; then
|
||||
# Unless specified, we default to --no-push-notifications
|
||||
NO_PUSH_NOTIFICATIONS=1
|
||||
fi
|
||||
|
||||
# We set these to Python-style True/False string values, for easy
|
||||
# insertion into the settings.py file.
|
||||
if [ -n "$PUSH_NOTIFICATIONS" ]; then
|
||||
SERVICE_MOBILE_PUSH="True"
|
||||
|
||||
# --push-notifications also enables SUBMIT_USAGE_STATISTICS, unless the user
|
||||
# explicitly opted out by passing --no-submit-usage-statistics.
|
||||
if [ -n "$NO_SUBMIT_USAGE_STATISTICS" ]; then
|
||||
SERVICE_SUBMIT_USAGE_STATISTICS="False"
|
||||
else
|
||||
SERVICE_SUBMIT_USAGE_STATISTICS="True"
|
||||
fi
|
||||
else
|
||||
SERVICE_MOBILE_PUSH="False"
|
||||
# SUBMIT_USAGE_STATISTICS without PUSH_NOTIFICATIONS is an unusual
|
||||
# configuration that we don't need to offer in the installer.
|
||||
SERVICE_SUBMIT_USAGE_STATISTICS="False"
|
||||
fi
|
||||
|
||||
# ToS acceptance needs to be ensured if --push-notifications is passed.
|
||||
if [ -n "$PUSH_NOTIFICATIONS" ]; then
|
||||
# If the user provided the --agree-to-terms-of-service flag, we can just proceed.
|
||||
if [ -n "$AGREE_TO_TERMS_OF_SERVICE_FLAG" ]; then
|
||||
PUSH_NOTIFICATIONS_SERVICE_TOS_AGREED=1
|
||||
echo "Push notifications will be enabled, as you agreed to the Zulip Terms of Service"
|
||||
echo "by passing the --agree-to-terms-of-service flag."
|
||||
sleep 2
|
||||
else
|
||||
# If user asked for push notifications, prompt for ToS acceptance.
|
||||
echo
|
||||
echo "You chose to register your server for the Mobile Push Notifications Service."
|
||||
echo "Doing so will share basic metadata with the service's maintainers, including:"
|
||||
echo
|
||||
echo "* The server's configured hostname: $EXTERNAL_HOST"
|
||||
echo "* The server's configured contact email address: $ZULIP_ADMINISTRATOR"
|
||||
echo "* Basic metadata about each organization hosted by the server; see:"
|
||||
echo " <https://zulip.com/doc-permalinks/basic-metadata>"
|
||||
if [ -z "$NO_SUBMIT_USAGE_STATISTICS" ]; then
|
||||
echo "* The server's usage statistics; see:"
|
||||
echo " <https://zulip.com/doc-permalinks/usage-statistics>"
|
||||
fi
|
||||
echo
|
||||
echo "For details on why a centralized push notification service is necessary, see:"
|
||||
echo " <https://zulip.com/doc-permalinks/why-service>"
|
||||
echo
|
||||
echo "Use of this service is governed by the Zulip Terms of Service:"
|
||||
echo " <https://zulip.com/policies/terms>"
|
||||
echo
|
||||
read -r -p "Do you want to agree to the Zulip Terms of Service and proceed? [Y/n] " tos_prompt
|
||||
echo
|
||||
|
||||
# Normalize the user’s response to lowercase.
|
||||
case "${tos_prompt,,}" in
|
||||
"" | y | yes)
|
||||
echo "Great! Push notifications will be enabled; continuing with installation..."
|
||||
PUSH_NOTIFICATIONS_SERVICE_TOS_AGREED=1
|
||||
sleep 2
|
||||
;;
|
||||
*)
|
||||
echo "In order to enable push notifications, you must agree to the Terms of Service."
|
||||
echo "If you do not want to enable push notifications, run the command without the --push-notifications flag."
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
fi
|
||||
|
||||
# Do set -x after option parsing is complete
|
||||
set -x
|
||||
|
||||
ZULIP_PATH="$(readlink -f "$(dirname "$0")"/../..)"
|
||||
|
||||
# Force a known locale. Some packages on PyPI fail to install in some locales.
|
||||
export LC_ALL="C.UTF-8"
|
||||
export LANG="C.UTF-8"
|
||||
export LANGUAGE="C.UTF-8"
|
||||
|
||||
# Force a known path; this fixes problems on Debian where `su` from
|
||||
# non-root may not adjust `$PATH` to root's.
|
||||
export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
|
||||
|
||||
# Check for a supported OS release.
|
||||
if [ -f /etc/os-release ]; then
|
||||
os_info="$(
|
||||
. /etc/os-release
|
||||
printf '%s\n' "$ID" "$ID_LIKE" "$VERSION_ID" "$VERSION_CODENAME"
|
||||
)"
|
||||
{
|
||||
read -r os_id
|
||||
read -r os_id_like
|
||||
read -r os_version_id
|
||||
read -r os_version_codename || true
|
||||
} <<<"$os_info"
|
||||
case " $os_id $os_id_like " in
|
||||
*' debian '*)
|
||||
package_system="apt"
|
||||
;;
|
||||
*' rhel '*)
|
||||
package_system="yum"
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
if ! "$ZULIP_PATH/scripts/lib/supported-os"; then
|
||||
system_requirements_failure <<EOF
|
||||
Unsupported OS release: $os_id $os_version_id
|
||||
|
||||
Zulip in production is supported only on:
|
||||
- Debian 12
|
||||
- Debian 13
|
||||
- Ubuntu 22.04 LTS
|
||||
- Ubuntu 24.04 LTS
|
||||
- Ubuntu 26.04 LTS
|
||||
EOF
|
||||
fi
|
||||
|
||||
machine="$(uname -m)"
|
||||
if [ "$machine" != x86_64 ] && [ "$machine" != aarch64 ]; then
|
||||
system_requirements_failure <<EOF
|
||||
Unsupported CPU architecture: $machine (expected x86_64 or aarch64).
|
||||
EOF
|
||||
fi
|
||||
|
||||
dpkg_architecture="$(dpkg --print-architecture)"
|
||||
if [ "$dpkg_architecture" != amd64 ] && [ "$dpkg_architecture" != arm64 ]; then
|
||||
system_requirements_failure <<EOF
|
||||
Unsupported OS architecture: $dpkg_architecture (expected amd64 or arm64).
|
||||
EOF
|
||||
fi
|
||||
|
||||
has_universe() {
|
||||
apt-cache policy \
|
||||
| grep -q "^ release v=$os_version_id,o=Ubuntu,a=$os_version_codename,n=$os_version_codename,l=Ubuntu,c=universe"
|
||||
}
|
||||
|
||||
if [ "$os_id" = ubuntu ] && ! has_universe && ! { apt-get update && has_universe; }; then
|
||||
system_requirements_failure <<EOF
|
||||
You must enable the Ubuntu Universe repository before installing
|
||||
Zulip. You can do this with:
|
||||
|
||||
sudo add-apt-repository universe
|
||||
sudo apt update
|
||||
EOF
|
||||
fi
|
||||
|
||||
case ",$PUPPET_CLASSES," in
|
||||
*,zulip::profile::standalone,* | *,zulip::profile::postgresql,*)
|
||||
if [ "$package_system" = apt ]; then
|
||||
# We're going to install PostgreSQL from the PostgreSQL apt
|
||||
# repository; this may conflict with the existing PostgreSQL.
|
||||
OTHER_PG="$(dpkg --get-selections \
|
||||
| grep -E '^postgresql-[0-9]+\s+install$' \
|
||||
| grep -v "^postgresql-$POSTGRESQL_VERSION\b" \
|
||||
| cut -f 1)" || true
|
||||
if [ -n "$OTHER_PG" ]; then
|
||||
INDENTED="${OTHER_PG//$'\n'/$'\n' }"
|
||||
SPACED="${OTHER_PG//$'\n'/ }"
|
||||
cat <<EOF
|
||||
|
||||
The following PostgreSQL servers were found to already be installed:
|
||||
|
||||
$INDENTED
|
||||
|
||||
Zulip needs to install PostgreSQL $POSTGRESQL_VERSION, but does not wish
|
||||
to uninstall existing databases in order to do so. Remove all other
|
||||
PostgreSQL servers manually before running the installer:
|
||||
|
||||
sudo apt-get remove $SPACED
|
||||
|
||||
EOF
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
# Check for at least ~1.86GB of RAM before starting installation;
|
||||
# otherwise users will find out about insufficient RAM via weird
|
||||
# errors like a segfault running `pip install`.
|
||||
# Additionally, some AWS images that are advertised to be 2 GB
|
||||
# are actually 1880000B in size.
|
||||
mem_kb=$(head -n1 /proc/meminfo | awk '{print $2}')
|
||||
if [ "$mem_kb" -lt 1860000 ]; then
|
||||
set +x
|
||||
echo -e '\033[0;31m' >&2
|
||||
echo "Insufficient RAM. Zulip requires at least 2GB of RAM." >&2
|
||||
echo >&2
|
||||
echo -e '\033[0m' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Anything under 5GB, we recommended allocating 2GB of swap. Error
|
||||
# out if there's no swap.
|
||||
swap_kb=$(grep SwapTotal /proc/meminfo | awk '{print $2}')
|
||||
if [ "$mem_kb" -lt 5000000 ] && [ "$swap_kb" -eq 0 ]; then
|
||||
set +x
|
||||
echo -e '\033[0;31m' >&2
|
||||
echo "No swap allocated; when running with < 5GB of RAM, we recommend at least 2GB of swap." >&2
|
||||
echo "https://www.digitalocean.com/community/tutorials/how-to-add-swap-space-on-ubuntu-22-04" >&2
|
||||
echo >&2
|
||||
echo -e '\033[0m' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Do package update, e.g. do `apt-get update` on Debian
|
||||
if [ "$package_system" = apt ]; then
|
||||
# setup-apt-repo does an `apt-get update`
|
||||
"$ZULIP_PATH"/scripts/lib/setup-apt-repo
|
||||
elif [ "$package_system" = yum ]; then
|
||||
"$ZULIP_PATH"/scripts/lib/setup-yum-repo
|
||||
fi
|
||||
|
||||
# Check early for missing SSL certificates
|
||||
if [ "$PUPPET_CLASSES" = "zulip::profile::standalone" ] && [ -z "$USE_CERTBOT""$SELF_SIGNED_CERT" ] && { ! [ -e "/etc/ssl/private/zulip.key" ] || ! [ -e "/etc/ssl/certs/zulip.combined-chain.crt" ]; }; then
|
||||
set +x
|
||||
cat <<EOF
|
||||
|
||||
No SSL certificate found. One or both required files is missing:
|
||||
/etc/ssl/private/zulip.key
|
||||
/etc/ssl/certs/zulip.combined-chain.crt
|
||||
|
||||
Suggested solutions:
|
||||
* For most sites, the --certbot option is recommended.
|
||||
* If you have your own key and cert, see docs linked below
|
||||
for how to install them.
|
||||
* For non-production testing, try the --self-signed-cert option.
|
||||
|
||||
For help and more details, see our SSL documentation:
|
||||
https://zulip.readthedocs.io/en/latest/production/ssl-certificates.html
|
||||
|
||||
Once fixed, just rerun scripts/setup/install; it'll pick up from here!
|
||||
|
||||
EOF
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# don't run dist-upgrade in one click apps to make the
|
||||
# installation process more seamless.
|
||||
if [ -z "$NO_DIST_UPGRADE" ]; then
|
||||
if [ "$package_system" = apt ]; then
|
||||
apt-get -y dist-upgrade "${APT_OPTIONS[@]}"
|
||||
elif [ "$package_system" = yum ]; then
|
||||
# On CentOS, there is no need to do `yum -y upgrade` because `yum -y
|
||||
# update` already does the same thing.
|
||||
:
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$package_system" = apt ]; then
|
||||
# Note that any additions to these lists must also be added to
|
||||
# `zulip::profile::base` such that the new dependency is seen by
|
||||
# upgrades, as well as new installs.
|
||||
if ! apt-get install -y --no-install-recommends \
|
||||
python3 python3-yaml puppet git curl jq crudini \
|
||||
"${ADDITIONAL_PACKAGES[@]}"; then
|
||||
set +x
|
||||
echo -e '\033[0;31m' >&2
|
||||
echo "Installing packages failed; is network working and (on Ubuntu) the universe repository enabled?" >&2
|
||||
echo >&2
|
||||
echo -e '\033[0m' >&2
|
||||
exit 1
|
||||
fi
|
||||
elif [ "$package_system" = yum ]; then
|
||||
if ! yum install -y \
|
||||
python3 python3-pyyaml puppet git curl jq crudini \
|
||||
"${ADDITIONAL_PACKAGES[@]}"; then
|
||||
set +x
|
||||
echo -e '\033[0;31m' >&2
|
||||
echo "Installing packages failed; is network working?" >&2
|
||||
echo >&2
|
||||
echo -e '\033[0m' >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# We generate a self-signed cert even with certbot, so we can use the
|
||||
# webroot authenticator, which requires nginx be set up with a
|
||||
# certificate.
|
||||
if [ -n "$SELF_SIGNED_CERT" ] || [ -n "$USE_CERTBOT" ]; then
|
||||
"$ZULIP_PATH"/scripts/setup/generate-self-signed-cert \
|
||||
--exists-ok "${EXTERNAL_HOST:-$(hostname)}"
|
||||
fi
|
||||
|
||||
# Generate /etc/zulip/zulip.conf .
|
||||
mkdir -p /etc/zulip
|
||||
has_class() {
|
||||
grep -qx "$1" /var/lib/puppet/classes.txt
|
||||
}
|
||||
|
||||
# puppet apply --noop fails unless the user that it _would_ chown
|
||||
# files to exists; https://tickets.puppetlabs.com/browse/PUP-3907
|
||||
#
|
||||
# The home directory here should match what's declared in base.pp.
|
||||
id -u zulip &>/dev/null || useradd -m zulip --home-dir /home/zulip
|
||||
if [ -n "$NO_OVERWRITE_SETTINGS" ] && [ -e "/etc/zulip/zulip.conf" ]; then
|
||||
"$ZULIP_PATH"/scripts/zulip-puppet-apply --noop \
|
||||
--write-catalog-summary \
|
||||
--classfile=/var/lib/puppet/classes.txt
|
||||
else
|
||||
# Write out more than we need, and remove sections that are not
|
||||
# applicable to the classes that are actually necessary.
|
||||
cat <<EOF >/etc/zulip/zulip.conf
|
||||
[machine]
|
||||
puppet_classes = $PUPPET_CLASSES
|
||||
deploy_type = production
|
||||
|
||||
[postgresql]
|
||||
version = $POSTGRESQL_VERSION
|
||||
EOF
|
||||
|
||||
"$ZULIP_PATH"/scripts/zulip-puppet-apply --noop \
|
||||
--write-catalog-summary \
|
||||
--classfile=/var/lib/puppet/classes.txt
|
||||
|
||||
# We only need the PostgreSQL version setting on database hosts,
|
||||
# or hosts which talk directly to the database (e.g. application
|
||||
# hosts); but we don't know if this is a database host until we
|
||||
# have the catalog summary.
|
||||
if (! has_class "zulip::postgresql_common" && ! has_class "zulip::postgresql_client") || [ "$package_system" != apt ]; then
|
||||
crudini --del /etc/zulip/zulip.conf postgresql
|
||||
fi
|
||||
|
||||
if [ -n "$POSTGRESQL_DATABASE_NAME" ]; then
|
||||
crudini --set /etc/zulip/zulip.conf postgresql database_name "$POSTGRESQL_DATABASE_NAME"
|
||||
fi
|
||||
|
||||
if [ -n "$POSTGRESQL_DATABASE_USER" ]; then
|
||||
crudini --set /etc/zulip/zulip.conf postgresql database_user "$POSTGRESQL_DATABASE_USER"
|
||||
fi
|
||||
fi
|
||||
|
||||
if has_class "zulip::app_frontend_base"; then
|
||||
# Frontend deploys use /home/zulip/deployments; without this, the
|
||||
# install directory is also only readable by root.
|
||||
mkdir -p /home/zulip/deployments
|
||||
deploy_path=$("$ZULIP_PATH"/scripts/lib/zulip_tools.py make_deploy_path)
|
||||
mv "$ZULIP_PATH" "$deploy_path"
|
||||
ln -nsf /home/zulip/deployments/next "$ZULIP_PATH"
|
||||
ln -nsf "$deploy_path" /home/zulip/deployments/next
|
||||
|
||||
# Create and activate a virtualenv
|
||||
"$deploy_path"/scripts/lib/create-production-venv "$deploy_path"
|
||||
|
||||
"$deploy_path"/scripts/lib/install-node
|
||||
|
||||
if [ -z "$NO_OVERWRITE_SETTINGS" ] || ! [ -e "/etc/zulip/settings.py" ]; then
|
||||
cp -a "$deploy_path"/zproject/prod_settings_template.py /etc/zulip/settings.py
|
||||
if [ -n "$EXTERNAL_HOST" ]; then
|
||||
sed -i "s/^EXTERNAL_HOST =.*/EXTERNAL_HOST = '$EXTERNAL_HOST'/" /etc/zulip/settings.py
|
||||
fi
|
||||
if [ -n "$ZULIP_ADMINISTRATOR" ]; then
|
||||
sed -i "s/^ZULIP_ADMINISTRATOR =.*/ZULIP_ADMINISTRATOR = '$ZULIP_ADMINISTRATOR'/" /etc/zulip/settings.py
|
||||
fi
|
||||
|
||||
# Set SERVICE settings based on what the user provided.
|
||||
if grep -q -E "^#?\s*ZULIP_SERVICE_PUSH_NOTIFICATIONS = " /etc/zulip/settings.py; then
|
||||
sed -i -E "s/^#?\s*ZULIP_SERVICE_PUSH_NOTIFICATIONS = .*/ZULIP_SERVICE_PUSH_NOTIFICATIONS = $SERVICE_MOBILE_PUSH/" /etc/zulip/settings.py
|
||||
else
|
||||
echo "ZULIP_SERVICE_PUSH_NOTIFICATIONS = $SERVICE_MOBILE_PUSH" >>/etc/zulip/settings.py
|
||||
fi
|
||||
|
||||
if grep -q -E "^#?\s*ZULIP_SERVICE_SUBMIT_USAGE_STATISTICS = " /etc/zulip/settings.py; then
|
||||
sed -i -E "s/^#?\s*ZULIP_SERVICE_SUBMIT_USAGE_STATISTICS = .*/ZULIP_SERVICE_SUBMIT_USAGE_STATISTICS = $SERVICE_SUBMIT_USAGE_STATISTICS/" /etc/zulip/settings.py
|
||||
else
|
||||
echo "ZULIP_SERVICE_SUBMIT_USAGE_STATISTICS = $SERVICE_SUBMIT_USAGE_STATISTICS" >>/etc/zulip/settings.py
|
||||
fi
|
||||
fi
|
||||
ln -nsf /etc/zulip/settings.py "$deploy_path"/zproject/prod_settings.py
|
||||
"$deploy_path"/scripts/setup/generate_secrets.py --production
|
||||
else
|
||||
deploy_path="$ZULIP_PATH"
|
||||
fi
|
||||
|
||||
"$deploy_path"/scripts/zulip-puppet-apply -f
|
||||
|
||||
if [ "$package_system" = apt ]; then
|
||||
apt-get -y --with-new-pkgs upgrade
|
||||
elif [ "$package_system" = yum ]; then
|
||||
# No action is required because `yum update` already does upgrade.
|
||||
:
|
||||
fi
|
||||
|
||||
if [ -n "$USE_CERTBOT" ]; then
|
||||
"$deploy_path"/scripts/setup/setup-certbot \
|
||||
"$EXTERNAL_HOST" --email "$ZULIP_ADMINISTRATOR"
|
||||
fi
|
||||
|
||||
if has_class "zulip::nginx" && ! has_class "zulip::profile::docker"; then
|
||||
# Check nginx was configured properly now that we've installed it.
|
||||
# Most common failure mode is certs not having been installed.
|
||||
if ! nginx -t; then
|
||||
(
|
||||
set +x
|
||||
cat <<EOF
|
||||
|
||||
Verifying the Zulip nginx configuration failed!
|
||||
|
||||
This is almost always a problem with your SSL certificates. See:
|
||||
https://zulip.readthedocs.io/en/latest/production/ssl-certificates.html
|
||||
|
||||
Once fixed, just rerun scripts/setup/install; it'll pick up from here!
|
||||
|
||||
EOF
|
||||
exit 1
|
||||
)
|
||||
fi
|
||||
fi
|
||||
|
||||
if has_class "zulip::profile::rabbitmq"; then
|
||||
if ! rabbitmqctl status >/dev/null; then
|
||||
set +x
|
||||
cat <<EOF
|
||||
|
||||
RabbitMQ seems to not have started properly after the installation process.
|
||||
Often this is caused by misconfigured /etc/hosts in virtualized environments.
|
||||
For more information, see:
|
||||
https://github.com/zulip/zulip/issues/53#issuecomment-143805121
|
||||
|
||||
EOF
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Set up a basic .gitconfig for the 'zulip' user
|
||||
if [ -n "$ZULIP_ADMINISTRATOR" ]; then
|
||||
(
|
||||
cd / # Make sure the current working directory is readable by zulip
|
||||
su zulip -c "git config --global user.email $ZULIP_ADMINISTRATOR"
|
||||
su zulip -c "git config --global user.name 'Zulip Server ($EXTERNAL_HOST)'"
|
||||
)
|
||||
fi
|
||||
|
||||
if ! has_class "zulip::app_frontend_base"; then
|
||||
set +x
|
||||
cat <<EOF
|
||||
|
||||
Success!
|
||||
|
||||
Not configuring PostgreSQL, or /home/zulip/deployments, because this
|
||||
is not a front-end install.
|
||||
|
||||
EOF
|
||||
exit 0
|
||||
fi
|
||||
|
||||
ln -nsf "$deploy_path" /home/zulip/deployments/current
|
||||
ln -nsf /etc/zulip/settings.py "$deploy_path"/zproject/prod_settings.py
|
||||
mkdir -p "$deploy_path"/prod-static/serve
|
||||
cp -rT "$deploy_path"/prod-static/serve /home/zulip/prod-static
|
||||
chown -R zulip:zulip /home/zulip /var/log/zulip /etc/zulip/settings.py
|
||||
|
||||
if ! [ -e /home/zulip/deployments/current/zulip-git-version ]; then
|
||||
mkdir /srv/zulip.git
|
||||
chown zulip:zulip /srv/zulip.git
|
||||
su zulip -c 'git clone --bare --mirror /home/zulip/deployments/current /srv/zulip.git'
|
||||
su zulip -c 'cd /home/zulip/deployments/current && ./scripts/lib/update-git-upstream && ./tools/cache-zulip-git-version'
|
||||
fi
|
||||
|
||||
if ! [ -e "/home/zulip/prod-static/generated" ]; then
|
||||
# If we're installing from a Git checkout, we need to run
|
||||
# `tools/update-prod-static` in order to build the static
|
||||
# assets.
|
||||
su zulip -c '/home/zulip/deployments/current/tools/update-prod-static'
|
||||
fi
|
||||
|
||||
if [ -n "$NO_INIT_DB" ]; then
|
||||
set +x
|
||||
cat <<EOF
|
||||
|
||||
Success!
|
||||
|
||||
Stopping because --no-init-db was passed. To complete the
|
||||
installation, configure PostgreSQL by creating the database and
|
||||
database user, and then run:
|
||||
|
||||
su zulip -c '/home/zulip/deployments/current/scripts/setup/initialize-database'
|
||||
su zulip -c '/home/zulip/deployments/current/manage.py generate_realm_creation_link'
|
||||
EOF
|
||||
|
||||
if [ -n "$PUSH_NOTIFICATIONS" ]; then
|
||||
echo
|
||||
echo "Since you specified you want to enable the push notification service, you'll also need to "
|
||||
echo "manually register your server by running:"
|
||||
echo
|
||||
echo " su zulip -c '/home/zulip/deployments/current/manage.py register_server'"
|
||||
echo
|
||||
fi
|
||||
exit 0
|
||||
else
|
||||
/home/zulip/deployments/current/scripts/setup/create-database
|
||||
su zulip -c '/home/zulip/deployments/current/scripts/setup/initialize-database --quiet'
|
||||
|
||||
if [ -n "$PUSH_NOTIFICATIONS" ]; then
|
||||
if [ -z "$PUSH_NOTIFICATIONS_SERVICE_TOS_AGREED" ]; then
|
||||
# This should be impossible as the installation should have aborted early if the user
|
||||
# passed --push-notifications without accepting the ToS. But we include this as a precaution
|
||||
# to not allow future bugs to cause ToS acceptance to be skipped.
|
||||
SKIP_TOS_AGREEMENT_OPTION=""
|
||||
else
|
||||
SKIP_TOS_AGREEMENT_OPTION="--agree-to-terms-of-service"
|
||||
fi
|
||||
set +x
|
||||
echo "Services enabled, attempting to register server..."
|
||||
set -x
|
||||
|
||||
# Without PYTHONUNBUFFERED=1, stdout/stderr might end up displayed not in chronological order, resulting
|
||||
# in confusing output to the user.
|
||||
su zulip -c "PYTHONUNBUFFERED=1 /home/zulip/deployments/current/manage.py register_server ${SKIP_TOS_AGREEMENT_OPTION}" \
|
||||
|| {
|
||||
set +x
|
||||
echo
|
||||
echo "WARNING: Server registration for push notifications failed."
|
||||
echo "This does not affect the rest of your Zulip installation."
|
||||
echo
|
||||
echo "To enable push notifications after resolving the issue, run:"
|
||||
echo " su zulip -c '/home/zulip/deployments/current/manage.py register_server'"
|
||||
echo
|
||||
set -x
|
||||
}
|
||||
fi
|
||||
|
||||
su zulip -c '/home/zulip/deployments/current/manage.py generate_realm_creation_link'
|
||||
fi
|
||||
41
scripts/lib/install-node
Executable file
41
scripts/lib/install-node
Executable file
@@ -0,0 +1,41 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
version=24.19.0
|
||||
arch="$(uname -m)"
|
||||
|
||||
case $arch in
|
||||
x86_64)
|
||||
tarball="node-v$version-linux-x64.tar.xz"
|
||||
sha256=14b342e71204f811bde6153be8e04b62aef63c236fef92b55f9c83154b409647
|
||||
;;
|
||||
|
||||
aarch64)
|
||||
tarball="node-v$version-linux-arm64.tar.xz"
|
||||
sha256=01443c1e1a29e531ccad5a46fefa6df490d2189c49f7955904aecdbb0fe86fdc
|
||||
;;
|
||||
esac
|
||||
|
||||
check_version() {
|
||||
out="$(node --version)" && [ "$out" = "v$version" ] \
|
||||
&& [ /usr/local/bin/pnpm -ef /srv/zulip-node/lib/node_modules/corepack/dist/pnpm.js ]
|
||||
}
|
||||
|
||||
if ! check_version 2>/dev/null; then
|
||||
set -x
|
||||
tmpdir="$(mktemp -d)"
|
||||
trap 'rm -r "$tmpdir"' EXIT
|
||||
cd "$tmpdir"
|
||||
curl -fLO --retry 3 "https://nodejs.org/dist/v$version/$tarball"
|
||||
sha256sum -c <<<"$sha256 $tarball"
|
||||
rm -rf /srv/zulip-node
|
||||
mkdir -p /srv/zulip-node
|
||||
tar -xJf "$tarball" --no-same-owner --strip-components=1 -C /srv/zulip-node
|
||||
ln -sf /srv/zulip-node/bin/{corepack,node,npm,npx} /usr/local/bin
|
||||
COREPACK_DEFAULT_TO_LATEST=0 /usr/local/bin/corepack enable
|
||||
|
||||
# Clean up after previous versions of this script
|
||||
rm -rf /srv/zulip-yarn /usr/bin/yarn /usr/local/nvm
|
||||
|
||||
check_version
|
||||
fi
|
||||
25
scripts/lib/install-uv
Executable file
25
scripts/lib/install-uv
Executable file
@@ -0,0 +1,25 @@
|
||||
#!/usr/bin/env bash
|
||||
set -eu
|
||||
|
||||
version=0.12.5
|
||||
arch="$(uname -m)"
|
||||
tarball="uv-$arch-unknown-linux-gnu.tar.gz"
|
||||
declare -A sha256=(
|
||||
[aarch64]=9bf43b4d1a07665bf64d4c4e710930b382321a785e0eb10aac07f46471f86a31
|
||||
[x86_64]=68a509da24b06b4223a1c0175fb5eb5bc79342b76cbeff0cfe51ac3f5b17b6b2
|
||||
)
|
||||
|
||||
check_version() {
|
||||
out="$(uv --version)" && [[ "$out" = "uv $version ("* ]]
|
||||
}
|
||||
|
||||
if ! check_version 2>/dev/null; then
|
||||
set -x
|
||||
tmpdir="$(mktemp -d)"
|
||||
trap 'rm -r "$tmpdir"' EXIT
|
||||
cd "$tmpdir"
|
||||
curl -fLO --retry 3 "https://github.com/astral-sh/uv/releases/download/$version/$tarball"
|
||||
sha256sum -c <<<"${sha256[$arch]} $tarball"
|
||||
tar -xzf "$tarball" --no-same-owner --strip-components=1 -C /usr/local/bin "uv-$arch-unknown-linux-gnu"/{uv,uvx}
|
||||
check_version
|
||||
fi
|
||||
39
scripts/lib/node_cache.py
Normal file
39
scripts/lib/node_cache.py
Normal file
@@ -0,0 +1,39 @@
|
||||
import os
|
||||
|
||||
from scripts.lib.zulip_tools import run
|
||||
|
||||
DEFAULT_PRODUCTION = False
|
||||
|
||||
|
||||
def setup_node_modules(production: bool = DEFAULT_PRODUCTION) -> None:
|
||||
if os.path.islink("node_modules"):
|
||||
os.unlink("node_modules")
|
||||
|
||||
skip = False
|
||||
|
||||
try:
|
||||
with open("node_modules/.pnpm/lock.yaml") as a, open("pnpm-lock.yaml") as b:
|
||||
if a.read() == b.read():
|
||||
skip = True
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
# We need this check when switching between branches without `starlight_help`
|
||||
# package. `node_modules` will be removed when working on a non `starlight_help`
|
||||
# branch, but if `node_modules/.pnpm/lock.yaml` has not been updated by that
|
||||
# branch, we will end up in a situation where we might not have `node_modules`
|
||||
# even when we run the provision command.
|
||||
if not os.path.exists("starlight_help/node_modules"):
|
||||
skip = False
|
||||
|
||||
if not skip:
|
||||
run(
|
||||
[
|
||||
"/usr/local/bin/corepack",
|
||||
"pnpm",
|
||||
"install",
|
||||
"--frozen-lockfile",
|
||||
"--prefer-offline",
|
||||
*(["--prod"] if production else []),
|
||||
],
|
||||
)
|
||||
107
scripts/lib/puppet_cache.py
Normal file
107
scripts/lib/puppet_cache.py
Normal file
@@ -0,0 +1,107 @@
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from urllib.request import urlopen
|
||||
|
||||
import yaml
|
||||
|
||||
from .zulip_tools import run
|
||||
|
||||
ZULIP_PATH = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
ZULIP_SRV_PATH = "/srv"
|
||||
|
||||
PUPPET_MODULES_CACHE_PATH = os.path.join(ZULIP_SRV_PATH, "zulip-puppet-cache")
|
||||
PUPPET_DEPS_FILE_PATH = os.path.join(ZULIP_PATH, "puppet/deps.yaml")
|
||||
PUPPET_THIRDPARTY = os.path.join(PUPPET_MODULES_CACHE_PATH, "current")
|
||||
|
||||
|
||||
def generate_sha1sum_puppet_modules() -> str:
|
||||
data = {}
|
||||
with open(PUPPET_DEPS_FILE_PATH) as fb:
|
||||
data["deps.yaml"] = fb.read().strip()
|
||||
data["puppet-version"] = subprocess.check_output(
|
||||
# This is 10x faster than `puppet --version`
|
||||
["ruby", "-r", "puppet/version", "-e", "puts Puppet.version"],
|
||||
text=True,
|
||||
).strip()
|
||||
|
||||
sha1sum = hashlib.sha1()
|
||||
sha1sum.update(json.dumps(data, sort_keys=True).encode())
|
||||
return sha1sum.hexdigest()
|
||||
|
||||
|
||||
def setup_puppet_modules() -> None:
|
||||
sha1sum = generate_sha1sum_puppet_modules()
|
||||
target_path = os.path.join(PUPPET_MODULES_CACHE_PATH, sha1sum)
|
||||
success_stamp = os.path.join(target_path, ".success-stamp")
|
||||
# Check if a cached version already exists
|
||||
if not os.path.exists(success_stamp):
|
||||
do_puppet_module_install(target_path, success_stamp)
|
||||
|
||||
if os.path.islink(PUPPET_THIRDPARTY):
|
||||
os.remove(PUPPET_THIRDPARTY)
|
||||
elif os.path.isdir(PUPPET_THIRDPARTY):
|
||||
shutil.rmtree(PUPPET_THIRDPARTY)
|
||||
os.symlink(target_path, PUPPET_THIRDPARTY)
|
||||
|
||||
|
||||
def do_puppet_module_install(
|
||||
target_path: str,
|
||||
success_stamp: str,
|
||||
) -> None:
|
||||
os.makedirs(target_path, exist_ok=True)
|
||||
with open(PUPPET_DEPS_FILE_PATH) as yaml_file:
|
||||
deps = yaml.safe_load(yaml_file)
|
||||
for module, metadata in deps.items():
|
||||
install_puppet_module(target_path, module, metadata["version"], metadata["sha256sum"])
|
||||
with open(success_stamp, "w"):
|
||||
pass
|
||||
|
||||
|
||||
def install_puppet_module(
|
||||
target_path: str, module: str, version: str, expected_sha256sum: str
|
||||
) -> None:
|
||||
print(f"Fetching {module}-{version} from forgeapi.puppet.com...")
|
||||
with urlopen(
|
||||
f"https://forgeapi.puppet.com/v3/releases/{module}-{version}", timeout=60
|
||||
) as forge_resp:
|
||||
forge_data = json.load(forge_resp)
|
||||
|
||||
forge_sha256sum = forge_data["file_sha256"]
|
||||
if forge_sha256sum != expected_sha256sum:
|
||||
raise Exception(
|
||||
f"Forge API returned unexpected SHA256 sum for {module}-{version}: "
|
||||
f"expected {expected_sha256sum}, got {forge_sha256sum}"
|
||||
)
|
||||
|
||||
with tempfile.NamedTemporaryFile(
|
||||
prefix=f"zulip-puppet-{module}-{version}-",
|
||||
suffix=".tar.gz",
|
||||
) as tarball:
|
||||
with urlopen(
|
||||
"https://forgeapi.puppet.com" + forge_data["file_uri"], timeout=60
|
||||
) as tarball_resp:
|
||||
tarball_content = tarball_resp.read()
|
||||
local_sha256sum = hashlib.sha256(tarball_content).hexdigest()
|
||||
if local_sha256sum != expected_sha256sum:
|
||||
raise Exception(
|
||||
f"Downloaded file had unexpected SHA256 sum for {module}-{version}: "
|
||||
f"expected {expected_sha256sum}, got {forge_sha256sum}"
|
||||
)
|
||||
tarball.write(tarball_content)
|
||||
tarball.flush()
|
||||
|
||||
run(
|
||||
[
|
||||
"puppet",
|
||||
"module",
|
||||
"--modulepath",
|
||||
target_path,
|
||||
"install",
|
||||
tarball.name,
|
||||
"--ignore-dependencies",
|
||||
],
|
||||
)
|
||||
20
scripts/lib/queue_workers.py
Executable file
20
scripts/lib/queue_workers.py
Executable file
@@ -0,0 +1,20 @@
|
||||
#!/usr/bin/env python3
|
||||
import os
|
||||
import sys
|
||||
|
||||
BASE_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
sys.path.append(BASE_DIR)
|
||||
from scripts.lib.setup_path import setup_path
|
||||
|
||||
setup_path()
|
||||
|
||||
os.environ["DJANGO_SETTINGS_MODULE"] = "zproject.settings"
|
||||
|
||||
import django
|
||||
|
||||
django.setup()
|
||||
from zerver.worker.queue_processors import get_active_worker_queues
|
||||
|
||||
if __name__ == "__main__":
|
||||
for worker in sorted(get_active_worker_queues()):
|
||||
print(worker)
|
||||
92
scripts/lib/run_hooks.py
Executable file
92
scripts/lib/run_hooks.py
Executable file
@@ -0,0 +1,92 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
sys.path.append(os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
from scripts.lib.zulip_tools import (
|
||||
DEPLOYMENTS_DIR,
|
||||
assert_running_as_root,
|
||||
get_deploy_root,
|
||||
get_zulip_pwent,
|
||||
parse_version_from,
|
||||
su_to_zulip,
|
||||
)
|
||||
|
||||
assert_running_as_root()
|
||||
|
||||
# Updates to the below choices to add a new hook type should
|
||||
# adjust puppet's `app_frontend_base.pp` as well.
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("kind", choices=["pre-deploy", "post-deploy"], help="")
|
||||
parser.add_argument("--from-git", action="store_true", help="Upgrading from git")
|
||||
args = parser.parse_args()
|
||||
|
||||
from version import ZULIP_MERGE_BASE as NEW_ZULIP_MERGE_BASE
|
||||
from version import ZULIP_VERSION as NEW_ZULIP_VERSION
|
||||
|
||||
deploy_path = get_deploy_root()
|
||||
|
||||
if args.kind == "post-deploy":
|
||||
old_dir_name = "last"
|
||||
if not os.path.exists(DEPLOYMENTS_DIR + "/last"):
|
||||
# Fresh installs which are doing an OS upgrade don't have a
|
||||
# "last" yet
|
||||
old_dir_name = "current"
|
||||
else:
|
||||
old_dir_name = "current"
|
||||
old_version = parse_version_from(DEPLOYMENTS_DIR + "/" + old_dir_name)
|
||||
old_merge_base = parse_version_from(DEPLOYMENTS_DIR + "/" + old_dir_name, merge_base=True)
|
||||
|
||||
path = f"/etc/zulip/hooks/{args.kind}.d"
|
||||
if not os.path.exists(path):
|
||||
sys.exit(0)
|
||||
|
||||
# Pass in, via environment variables, the old/new "version
|
||||
# string" (which is a `git describe` output)
|
||||
env = os.environ.copy()
|
||||
env["ZULIP_OLD_VERSION"] = old_version
|
||||
env["ZULIP_NEW_VERSION"] = NEW_ZULIP_VERSION
|
||||
|
||||
# preexec_fn=su_to_zulip normally handles this, but our explicit
|
||||
# env overrides that
|
||||
env["HOME"] = get_zulip_pwent().pw_dir
|
||||
|
||||
|
||||
def resolve_version_string(version: str) -> str:
|
||||
return subprocess.check_output(
|
||||
["git", "rev-parse", version], cwd=deploy_path, preexec_fn=su_to_zulip, text=True
|
||||
).strip()
|
||||
|
||||
|
||||
if args.from_git:
|
||||
# If we have a git repo, we also resolve those `git describe`
|
||||
# values to full commit hashes, as well as provide the
|
||||
# merge-base of the old/new commits with mainline.
|
||||
env["ZULIP_OLD_COMMIT"] = resolve_version_string(old_version)
|
||||
env["ZULIP_NEW_COMMIT"] = resolve_version_string(NEW_ZULIP_VERSION)
|
||||
env["ZULIP_OLD_MERGE_BASE_COMMIT"] = resolve_version_string(old_merge_base)
|
||||
env["ZULIP_NEW_MERGE_BASE_COMMIT"] = resolve_version_string(NEW_ZULIP_MERGE_BASE)
|
||||
|
||||
failures = []
|
||||
for script_name in sorted(f for f in os.listdir(path) if f.endswith(".hook")):
|
||||
result = subprocess.run(
|
||||
[os.path.join(path, script_name)],
|
||||
check=False,
|
||||
cwd=deploy_path,
|
||||
preexec_fn=su_to_zulip,
|
||||
env=env,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
# Pre-deploy hooks abort on the first failure; post-deploy
|
||||
# hooks are best-effort and a failure of one does not abort
|
||||
# the rest of them.
|
||||
if args.kind == "pre-deploy":
|
||||
sys.exit(1)
|
||||
failures.append(script_name)
|
||||
|
||||
if failures:
|
||||
print("Failed hooks:")
|
||||
for failed_script in failures:
|
||||
print(f" {failed_script}")
|
||||
120
scripts/lib/setup-apt-repo
Executable file
120
scripts/lib/setup-apt-repo
Executable file
@@ -0,0 +1,120 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# This script handles adding custom apt repositories into
|
||||
# /etc/apt/sources.list.d/ files. It bundles the GPG keys which are
|
||||
# used to verify the repositories (via `apt-key`), to explicitly pin
|
||||
# the trusted signing keys, as opposed to blindly trusting HTTPS.
|
||||
#
|
||||
# Each /etc/apt/sources.list.d/foo.list file is created via `--list
|
||||
# foo`, where `foo` defaults to `zulip`. The default `zulip.list` is
|
||||
# installed in `scripts/lib/install` / `tools/lib/provision.py`, and
|
||||
# other `.list` files may be installed by Puppet.
|
||||
set -x
|
||||
set -e
|
||||
set -u
|
||||
set -o pipefail
|
||||
shopt -s extglob
|
||||
|
||||
verify=false
|
||||
args="$(getopt -o '' --long verify,list: -- "$@")"
|
||||
eval "set -- $args"
|
||||
LIST=zulip
|
||||
while true; do
|
||||
case "$1" in
|
||||
--verify)
|
||||
verify=true
|
||||
shift
|
||||
;;
|
||||
--list)
|
||||
LIST="$2"
|
||||
shift
|
||||
shift
|
||||
;;
|
||||
--)
|
||||
shift
|
||||
break
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Ensure the directory for LAST_DEPENDENCIES_HASH exists
|
||||
mkdir -p /var/lib/zulip
|
||||
|
||||
SOURCES_FILE=/etc/apt/sources.list.d/$LIST.list
|
||||
PREF_FILE=/etc/apt/preferences.d/$LIST.pref
|
||||
STAMP_FILE=/etc/apt/sources.list.d/$LIST.list.apt-update-in-progress
|
||||
|
||||
# All paths, for purposes of hashing, should be relative to the deploy
|
||||
# root.
|
||||
cd "$(dirname "$0")/../.."
|
||||
|
||||
LIST_PATH="scripts/setup/apt-repos/$LIST"
|
||||
if ! [ -d "$LIST_PATH" ]; then
|
||||
echo "Not a valid value for --list: '$LIST'"
|
||||
echo ""
|
||||
echo "Valid values are:"
|
||||
ls -1 "scripts/setup/apt-repos/"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
release="$(. /etc/os-release && printf '%s' "$VERSION_CODENAME")"
|
||||
|
||||
if [ ! -f "$LIST_PATH/$release.list" ]; then
|
||||
cat <<EOF
|
||||
Unsupported release $release for sources.list file $LIST. To add a
|
||||
new release, make a $LIST_PATH/$release.list file based on existing
|
||||
.list files in that directory.
|
||||
|
||||
EOF
|
||||
exit 1
|
||||
fi
|
||||
|
||||
DEPENDENCIES_HASH="$(sha256sum "$LIST_PATH"/?(*.asc|"$release.list"|"$LIST.pref"|custom.sh) "scripts/lib/setup-apt-repo")"
|
||||
DEPENDENCIES_HASH_FILE="/var/lib/zulip/setup-repositories-state-$LIST"
|
||||
# Ensure that DEPENDENCIES_HASH_FILE exists before hashing it.
|
||||
touch "$DEPENDENCIES_HASH_FILE"
|
||||
LAST_DEPENDENCIES_HASH="$(cat "$DEPENDENCIES_HASH_FILE")"
|
||||
|
||||
# First, we only do anything in setup-apt-repo if any of its inputs
|
||||
# (apt keys, code, etc.) changed.
|
||||
if [ "$DEPENDENCIES_HASH" = "$LAST_DEPENDENCIES_HASH" ]; then
|
||||
exit 0
|
||||
elif [ "$verify" == true ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Hash to check if the configuration is changed by the script later.
|
||||
hashes=$(sha256sum "$SOURCES_FILE" "$PREF_FILE" 2>/dev/null || true)
|
||||
|
||||
if [ -e "$LIST_PATH/$LIST.pref" ]; then
|
||||
cp "$LIST_PATH/$LIST.pref" "$PREF_FILE"
|
||||
else
|
||||
rm -f "$PREF_FILE"
|
||||
fi
|
||||
|
||||
pre_setup_deps=(apt-transport-https ca-certificates gnupg curl)
|
||||
if ! apt-get -dy install "${pre_setup_deps[@]}"; then
|
||||
apt-get update
|
||||
fi
|
||||
apt-get -y install "${pre_setup_deps[@]}"
|
||||
|
||||
cp "$LIST_PATH/"*.asc /etc/apt/keyrings/
|
||||
cp "$LIST_PATH/$release.list" "$SOURCES_FILE"
|
||||
|
||||
if [ -e "$LIST_PATH/custom.sh" ]; then
|
||||
export LIST_PATH
|
||||
export STAMP_FILE
|
||||
bash "$LIST_PATH/custom.sh"
|
||||
fi
|
||||
|
||||
if [ "$hashes" = "$(sha256sum "$SOURCES_FILE" "$PREF_FILE" 2>/dev/null || true)" ] && ! [ -e "$STAMP_FILE" ]; then
|
||||
echo "APT configuration did not change; skipping apt-get update"
|
||||
else
|
||||
# We create this stamp file to ensure `apt-get update` will be run
|
||||
# the next time this script is invoked, and each time after, until
|
||||
# `apt-get update` finishes successfully.
|
||||
touch "$STAMP_FILE"
|
||||
apt-get update && rm -f "$STAMP_FILE"
|
||||
fi
|
||||
|
||||
echo "$DEPENDENCIES_HASH" >"$DEPENDENCIES_HASH_FILE"
|
||||
63
scripts/lib/setup-yum-repo
Executable file
63
scripts/lib/setup-yum-repo
Executable file
@@ -0,0 +1,63 @@
|
||||
#!/usr/bin/env bash
|
||||
set -x
|
||||
set -e
|
||||
|
||||
is_prod=false
|
||||
args="$(getopt -o '' --long prod -- "$@")"
|
||||
eval "set -- $args"
|
||||
while true; do
|
||||
case "$1" in
|
||||
--prod)
|
||||
is_prod=true
|
||||
shift
|
||||
;;
|
||||
--)
|
||||
shift
|
||||
break
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
is_centos=false
|
||||
is_rhel=false
|
||||
if [ -e /etc/centos-release ]; then
|
||||
is_centos=true
|
||||
yum install -y epel-release
|
||||
|
||||
if [ "$is_prod" = true ]; then
|
||||
# IUS is needed for installing python36u-mod_wsgi on prod env
|
||||
yum localinstall -y https://centos7.iuscommunity.org/ius-release.rpm
|
||||
fi
|
||||
|
||||
elif grep -q "Red Hat" /etc/redhat-release; then
|
||||
is_rhel=true
|
||||
yum localinstall -y https://dl.fedoraproject.org/pub/epel/epel-release-latest-7.noarch.rpm
|
||||
fi
|
||||
|
||||
yum update -y
|
||||
|
||||
RHVER="$(rpm -qf --queryformat="%{VERSION}" /etc/redhat-release)"
|
||||
RHARCH="$(rpm -qf --queryformat="%{ARCH}" /etc/redhat-release)"
|
||||
PGVER=10
|
||||
if [ "$is_centos" = true ]; then
|
||||
# "Development Tools" is the equivalent of build-essential
|
||||
yum groupinstall -y "Development Tools"
|
||||
|
||||
# PostgreSQL $PGVER
|
||||
yum localinstall -y "https://yum.postgresql.org/$PGVER/redhat/rhel-$RHVER-$RHARCH/pgdg-redhat-repo-latest.noarch.rpm"
|
||||
|
||||
# PGroonga
|
||||
# https://pgroonga.github.io/install/centos.html
|
||||
yum localinstall -y https://packages.groonga.org/centos/groonga-release-latest.noarch.rpm
|
||||
elif [ "$is_rhel" = true ]; then
|
||||
yum groupinstall -y "Development Tools"
|
||||
yum localinstall -y https://download.postgresql.org/pub/repos/yum/10/redhat/rhel-latest-x86_64/pgdg-redhat10-10-2.noarch.rpm
|
||||
yum localinstall -y https://packages.groonga.org/centos/groonga-release-latest.noarch.rpm
|
||||
else
|
||||
# dnf5 (Fedora 41+) dropped the `groupinstall` alias and no longer
|
||||
# resolves the "Development Tools" display name, so install by group ID.
|
||||
dnf group install -y development-tools
|
||||
# TODO make the postgres version a variable.
|
||||
PGVER=13
|
||||
dnf install -y "https://download.postgresql.org/pub/repos/yum/reporpms/F-$RHVER-x86_64/pgdg-fedora-repo-latest.noarch.rpm"
|
||||
fi
|
||||
28
scripts/lib/setup_path.py
Normal file
28
scripts/lib/setup_path.py
Normal file
@@ -0,0 +1,28 @@
|
||||
"""
|
||||
Use libraries from a virtualenv (by modifying sys.path) in production.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def setup_path() -> None:
|
||||
BASE_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
venv = os.path.realpath(os.path.join(BASE_DIR, ".venv"))
|
||||
if sys.prefix != venv:
|
||||
sys.path = list(
|
||||
filter(
|
||||
# zulip-py3-venv was an historical virtualenv symlink
|
||||
lambda p: "/zulip-py3-venv/" not in p and "/.venv/" not in p,
|
||||
sys.path,
|
||||
)
|
||||
)
|
||||
activate_this = os.path.join(venv, "bin", "activate_this.py")
|
||||
activate_locals = dict(__file__=activate_this)
|
||||
with open(activate_this) as f:
|
||||
exec(f.read(), activate_locals) # noqa: S102
|
||||
# Check that the python version running this function
|
||||
# is same as python version that created the virtualenv.
|
||||
python_version = "python{}.{}".format(*sys.version_info[:2])
|
||||
if not os.path.exists(os.path.join(venv, "lib", python_version)):
|
||||
raise RuntimeError(venv + " was not set up for this Python version")
|
||||
71
scripts/lib/setup_venv.py
Normal file
71
scripts/lib/setup_venv.py
Normal file
@@ -0,0 +1,71 @@
|
||||
import os
|
||||
|
||||
from scripts.lib.zulip_tools import os_families
|
||||
|
||||
ZULIP_PATH = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
VENV_DEPENDENCIES = [
|
||||
"build-essential",
|
||||
"libffi-dev",
|
||||
"libldap2-dev",
|
||||
"python3-dev", # Needed to install typed-ast dependency of mypy
|
||||
"python3-pip",
|
||||
"virtualenv",
|
||||
"libxml2-dev", # Used for installing talon-core and python-xmlsec
|
||||
"libxslt1-dev", # Used for installing talon-core
|
||||
"libpq-dev", # Needed by psycopg2
|
||||
"libssl-dev", # Needed to build pycurl and other libraries
|
||||
"libmagic1", # Used for install python-magic
|
||||
"libyaml-dev", # For fast YAML parsing in PyYAML
|
||||
# Needed by python-xmlsec:
|
||||
"libxmlsec1-dev",
|
||||
"pkg-config",
|
||||
"jq", # No longer used in production (clean me up later)
|
||||
"libsasl2-dev", # For building python-ldap from source
|
||||
"libvips", # For thumbnailing
|
||||
"libvips-tools",
|
||||
"libicu-dev", # For building pyicu
|
||||
"libcrypt-dev", # For building uwsgi
|
||||
]
|
||||
|
||||
COMMON_YUM_VENV_DEPENDENCIES = [
|
||||
"libffi-devel",
|
||||
"openldap-devel",
|
||||
"libyaml-devel",
|
||||
# Needed by python-xmlsec:
|
||||
"gcc",
|
||||
"python3-devel",
|
||||
"libxml2-devel",
|
||||
"xmlsec1-devel",
|
||||
"xmlsec1-openssl-devel",
|
||||
"libtool-ltdl-devel",
|
||||
"libxslt-devel",
|
||||
"postgresql-libs", # libpq-dev on apt
|
||||
"openssl-devel",
|
||||
"jq",
|
||||
"vips", # For thumbnailing
|
||||
"vips-tools",
|
||||
]
|
||||
|
||||
REDHAT_VENV_DEPENDENCIES = [
|
||||
*COMMON_YUM_VENV_DEPENDENCIES,
|
||||
"python36-devel",
|
||||
"python-virtualenv",
|
||||
]
|
||||
|
||||
FEDORA_VENV_DEPENDENCIES = [
|
||||
*COMMON_YUM_VENV_DEPENDENCIES,
|
||||
"python3-pip",
|
||||
"virtualenv", # see https://unix.stackexchange.com/questions/27877/install-virtualenv-on-fedora-16
|
||||
]
|
||||
|
||||
|
||||
def get_venv_dependencies(vendor: str, os_version: str) -> list[str]:
|
||||
if "debian" in os_families():
|
||||
return VENV_DEPENDENCIES
|
||||
elif "rhel" in os_families():
|
||||
return REDHAT_VENV_DEPENDENCIES
|
||||
elif "fedora" in os_families():
|
||||
return FEDORA_VENV_DEPENDENCIES
|
||||
else:
|
||||
raise AssertionError("Invalid vendor")
|
||||
126
scripts/lib/sharding.py
Executable file
126
scripts/lib/sharding.py
Executable file
@@ -0,0 +1,126 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import filecmp
|
||||
import grp
|
||||
import json
|
||||
import os
|
||||
import pwd
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
BASE_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
sys.path.append(BASE_DIR)
|
||||
from scripts.lib.setup_path import setup_path
|
||||
|
||||
setup_path()
|
||||
|
||||
from scripts.lib.zulip_tools import get_config_file, get_tornado_ports
|
||||
|
||||
|
||||
def nginx_quote(s: str) -> str:
|
||||
return '"' + s.replace("\\", "\\\\").replace('"', '\\"') + '"'
|
||||
|
||||
|
||||
# Basic system to do Tornado sharding. Writes two output .tmp files that need
|
||||
# to be renamed to the following files to finalize the changes:
|
||||
# * /etc/zulip/nginx_sharding_map.conf; nginx needs to be reloaded after changing.
|
||||
# * /etc/zulip/sharding.json; supervisor Django process needs to be reloaded
|
||||
# after changing. TODO: We can probably make this live-reload by statting the file.
|
||||
#
|
||||
# TODO: Restructure this to automatically generate a sharding layout.
|
||||
def write_updated_configs() -> None:
|
||||
config_file = get_config_file()
|
||||
ports = get_tornado_ports(config_file)
|
||||
|
||||
expected_ports = list(range(9800, ports[-1] + 1))
|
||||
assert ports == expected_ports, f"ports ({ports}) must be contiguous, starting with 9800"
|
||||
|
||||
with (
|
||||
open("/etc/zulip/nginx_sharding_map.conf.tmp", "w") as nginx_sharding_conf_f,
|
||||
open("/etc/zulip/sharding.json.tmp", "w") as sharding_json_f,
|
||||
):
|
||||
if len(ports) == 1:
|
||||
nginx_sharding_conf_f.write('map "" $tornado_server {\n')
|
||||
nginx_sharding_conf_f.write(" default http://tornado;\n")
|
||||
nginx_sharding_conf_f.write("}\n")
|
||||
sharding_json_f.write("{}\n")
|
||||
return
|
||||
|
||||
nginx_sharding_conf_f.write("map $host $tornado_server {\n")
|
||||
nginx_sharding_conf_f.write(" default http://tornado9800;\n")
|
||||
shard_map: dict[str, int | list[int]] = {}
|
||||
shard_regexes: list[tuple[str, int | list[int]]] = []
|
||||
external_host = subprocess.check_output(
|
||||
[os.path.join(BASE_DIR, "scripts/get-django-setting"), "EXTERNAL_HOST"],
|
||||
text=True,
|
||||
).strip()
|
||||
for key, shards in config_file["tornado_sharding"].items():
|
||||
if key.endswith("_regex"):
|
||||
ports = [int(port) for port in key.removesuffix("_regex").split("_")]
|
||||
shard_regexes.append((shards, ports[0] if len(ports) == 1 else ports))
|
||||
nginx_sharding_conf_f.write(
|
||||
f" {nginx_quote('~*' + shards)} http://tornado{'_'.join(map(str, ports))};\n"
|
||||
)
|
||||
else:
|
||||
ports = [int(port) for port in key.split("_")]
|
||||
for shard in shards.split():
|
||||
if "." in shard:
|
||||
host = shard
|
||||
else:
|
||||
host = f"{shard}.{external_host}"
|
||||
assert host not in shard_map, f"host {host} duplicated"
|
||||
shard_map[host] = ports[0] if len(ports) == 1 else ports
|
||||
nginx_sharding_conf_f.write(
|
||||
f" {nginx_quote(host)} http://tornado{'_'.join(map(str, ports))};\n"
|
||||
)
|
||||
nginx_sharding_conf_f.write("\n")
|
||||
nginx_sharding_conf_f.write("}\n")
|
||||
|
||||
data = {"shard_map": shard_map, "shard_regexes": shard_regexes}
|
||||
sharding_json_f.write(json.dumps(data) + "\n")
|
||||
|
||||
for fh in (nginx_sharding_conf_f, sharding_json_f):
|
||||
os.fchown(
|
||||
fh.fileno(),
|
||||
pwd.getpwnam("zulip").pw_uid,
|
||||
grp.getgrnam("zulip").gr_gid,
|
||||
)
|
||||
os.fchmod(fh.fileno(), 0o644)
|
||||
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Adjust Tornado sharding configuration",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--errors-ok",
|
||||
action="store_true",
|
||||
help="Exits 1 if there are no changes; if there are errors or changes, exits 0.",
|
||||
)
|
||||
options = parser.parse_args()
|
||||
|
||||
config_file_path = "/etc/zulip"
|
||||
base_files = ["nginx_sharding_map.conf", "sharding.json"]
|
||||
full_real_paths = [f"{config_file_path}/{filename}" for filename in base_files]
|
||||
full_new_paths = [f"{filename}.tmp" for filename in full_real_paths]
|
||||
try:
|
||||
write_updated_configs()
|
||||
for old, new in zip(full_real_paths, full_new_paths, strict=False):
|
||||
if not filecmp.cmp(old, new):
|
||||
# There are changes; leave .tmp files and exit 0
|
||||
if "SUPPRESS_SHARDING_NOTICE" not in os.environ:
|
||||
print("===> Updated sharding; run scripts/refresh-sharding-and-restart")
|
||||
sys.exit(0)
|
||||
# No changes; clean up and exit 1
|
||||
for filename in full_new_paths:
|
||||
os.unlink(filename)
|
||||
sys.exit(1)
|
||||
except AssertionError as e:
|
||||
# Clean up whichever files we made
|
||||
for filename in full_new_paths:
|
||||
if os.path.exists(filename):
|
||||
os.unlink(filename)
|
||||
if options.errors_ok:
|
||||
sys.exit(0)
|
||||
else:
|
||||
print(e, file=sys.stderr)
|
||||
sys.exit(2)
|
||||
77
scripts/lib/supervisor.py
Normal file
77
scripts/lib/supervisor.py
Normal file
@@ -0,0 +1,77 @@
|
||||
import socket
|
||||
import time
|
||||
from http.client import HTTPConnection
|
||||
from xmlrpc import client
|
||||
|
||||
from typing_extensions import override
|
||||
|
||||
|
||||
class UnixStreamHTTPConnection(HTTPConnection):
|
||||
@override
|
||||
def connect(self) -> None:
|
||||
self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
connected = False
|
||||
for i in range(2):
|
||||
try:
|
||||
self.sock.connect(self.host)
|
||||
connected = True
|
||||
break
|
||||
except FileNotFoundError:
|
||||
# Backoff and retry
|
||||
time.sleep(2**i)
|
||||
if not connected:
|
||||
raise Exception(
|
||||
"Failed to connect to supervisor -- check that it is running, by running 'service supervisor status'"
|
||||
)
|
||||
|
||||
|
||||
class UnixStreamTransport(client.Transport):
|
||||
def __init__(self, socket_path: str) -> None:
|
||||
self.socket_path = socket_path
|
||||
super().__init__()
|
||||
|
||||
@override
|
||||
def make_connection(self, host: tuple[str, dict[str, str]] | str) -> UnixStreamHTTPConnection:
|
||||
return UnixStreamHTTPConnection(self.socket_path)
|
||||
|
||||
|
||||
def rpc() -> client.ServerProxy:
|
||||
return client.ServerProxy(
|
||||
"http://localhost", transport=UnixStreamTransport("/var/run/supervisor.sock")
|
||||
)
|
||||
|
||||
|
||||
def list_supervisor_processes(
|
||||
filter_names: list[str] | None = None, *, only_running: bool | None = None
|
||||
) -> list[str]:
|
||||
results = []
|
||||
processes = rpc().supervisor.getAllProcessInfo()
|
||||
assert isinstance(processes, list)
|
||||
for process in processes:
|
||||
if process["group"] != process["name"]:
|
||||
name = f"{process['group']}:{process['name']}"
|
||||
else:
|
||||
name = process["name"]
|
||||
|
||||
if filter_names:
|
||||
match = False
|
||||
for filter_name in filter_names:
|
||||
# zulip-tornado:* matches zulip-tornado:9800 and zulip-tornado
|
||||
if filter_name.endswith(":*") and (
|
||||
name.startswith(filter_name.removesuffix("*"))
|
||||
or name == filter_name.removesuffix(":*")
|
||||
):
|
||||
match = True
|
||||
break
|
||||
if name == filter_name:
|
||||
match = True
|
||||
break
|
||||
if not match:
|
||||
continue
|
||||
|
||||
if only_running is None:
|
||||
results.append(name)
|
||||
elif only_running == (process["statename"] in ("RUNNING", "STARTING")):
|
||||
results.append(name)
|
||||
|
||||
return results
|
||||
25
scripts/lib/supported-os
Executable file
25
scripts/lib/supported-os
Executable file
@@ -0,0 +1,25 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# This script serves only to verify that the OS is a supported
|
||||
# version, before we attempt to rely on that version in
|
||||
# upgrade-zulip-stage-3
|
||||
|
||||
if [ -f /etc/os-release ]; then
|
||||
os_info="$(
|
||||
. /etc/os-release
|
||||
printf '%s\n' "$ID" "$VERSION_ID"
|
||||
)"
|
||||
{
|
||||
read -r os_id
|
||||
read -r os_version_id
|
||||
} <<<"$os_info"
|
||||
fi
|
||||
|
||||
case "$os_id $os_version_id" in
|
||||
'debian 12' | 'debian 13' | 'ubuntu 22.04' | 'ubuntu 24.04' | 'ubuntu 26.04')
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
50
scripts/lib/unpack-zulip
Executable file
50
scripts/lib/unpack-zulip
Executable file
@@ -0,0 +1,50 @@
|
||||
#!/usr/bin/env python3
|
||||
import glob
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
os.environ["PYTHONUNBUFFERED"] = "y"
|
||||
|
||||
sys.path.append(os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
import version
|
||||
from scripts.lib.zulip_tools import (
|
||||
DEPLOYMENTS_DIR,
|
||||
ENDC,
|
||||
FAIL,
|
||||
get_deployment_version,
|
||||
is_invalid_upgrade,
|
||||
make_deploy_path,
|
||||
overwrite_symlink,
|
||||
)
|
||||
|
||||
if len(sys.argv) != 2:
|
||||
print(FAIL + f"Usage: {sys.argv[0]} <tarball>" + ENDC)
|
||||
sys.exit(1)
|
||||
|
||||
tarball_path = sys.argv[1]
|
||||
|
||||
deploy_path = make_deploy_path()
|
||||
|
||||
extract_path = tempfile.mkdtemp()
|
||||
subprocess.check_call(["tar", "-xf", tarball_path, "-C", extract_path])
|
||||
|
||||
new_version = get_deployment_version(extract_path)
|
||||
current_version = version.ZULIP_VERSION
|
||||
if is_invalid_upgrade(current_version, new_version):
|
||||
print(
|
||||
FAIL + "Your current version is very old. Please first upgrade to version "
|
||||
f"1.4.3 and then upgrade to {new_version}." + ENDC
|
||||
)
|
||||
shutil.rmtree(extract_path)
|
||||
sys.exit(1)
|
||||
|
||||
shutil.move(glob.glob(os.path.join(extract_path, "zulip-server-*"))[0], deploy_path)
|
||||
os.rmdir(extract_path)
|
||||
overwrite_symlink("/etc/zulip/settings.py", os.path.join(deploy_path, "zproject/prod_settings.py"))
|
||||
overwrite_symlink(deploy_path, os.path.join(DEPLOYMENTS_DIR, "next"))
|
||||
|
||||
print(deploy_path)
|
||||
sys.exit(0)
|
||||
11
scripts/lib/update-git-upstream
Executable file
11
scripts/lib/update-git-upstream
Executable file
@@ -0,0 +1,11 @@
|
||||
#!/usr/bin/env bash
|
||||
set -eu
|
||||
|
||||
upstream_url="https://github.com/zulip/zulip.git"
|
||||
if ! git remote | grep -qx upstream; then
|
||||
git remote add upstream "$upstream_url"
|
||||
else
|
||||
git remote set-url upstream "$upstream_url"
|
||||
fi
|
||||
|
||||
git fetch --prune --quiet --tags --all
|
||||
79
scripts/lib/upgrade-zulip
Executable file
79
scripts/lib/upgrade-zulip
Executable file
@@ -0,0 +1,79 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import configparser
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
TARBALL_ARCHIVE_PATH = "/home/zulip/archives"
|
||||
os.environ["PYTHONUNBUFFERED"] = "y"
|
||||
|
||||
sys.path.append(os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
from scripts.lib.zulip_tools import (
|
||||
DEPLOYMENTS_DIR,
|
||||
assert_running_as_root,
|
||||
get_config_file,
|
||||
get_deploy_options,
|
||||
get_deployment_lock,
|
||||
release_deployment_lock,
|
||||
su_to_zulip,
|
||||
)
|
||||
|
||||
config_file: configparser.RawConfigParser = get_config_file()
|
||||
deploy_options = get_deploy_options(config_file)
|
||||
|
||||
assert_running_as_root(strip_lib_from_paths=True)
|
||||
|
||||
# make sure we have appropriate file permissions
|
||||
os.umask(0o22)
|
||||
|
||||
logging.Formatter.converter = time.gmtime
|
||||
logging.basicConfig(format="%(asctime)s upgrade-zulip: %(message)s", level=logging.INFO)
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("tarball", help="Path to Zulip Server tarball")
|
||||
args, extra_options = parser.parse_known_args()
|
||||
|
||||
error_rerun_script = f"{DEPLOYMENTS_DIR}/current/scripts/upgrade-zulip {args.tarball}"
|
||||
get_deployment_lock(error_rerun_script)
|
||||
|
||||
try:
|
||||
# Copy the release tarball to an archival path that's readable by
|
||||
# the Zulip user, and then unpack it from that directory, so that
|
||||
# we can unpack using the Zulip user even if the original path was
|
||||
# not readable by the Zulip user.
|
||||
logging.info("Archiving the tarball under %s", TARBALL_ARCHIVE_PATH)
|
||||
os.makedirs(TARBALL_ARCHIVE_PATH, exist_ok=True)
|
||||
archived_tarball_path = os.path.join(TARBALL_ARCHIVE_PATH, os.path.basename(args.tarball))
|
||||
shutil.copy(args.tarball, archived_tarball_path)
|
||||
subprocess.check_output(["chown", "-R", "zulip:zulip", TARBALL_ARCHIVE_PATH])
|
||||
|
||||
logging.info("Unpacking the tarball")
|
||||
unpack_zulip = os.path.realpath(os.path.join(os.path.dirname(__file__), "unpack-zulip"))
|
||||
deploy_path = subprocess.check_output(
|
||||
[unpack_zulip, archived_tarball_path], preexec_fn=su_to_zulip, text=True
|
||||
)
|
||||
|
||||
# chdir to deploy_path and then run upgrade-zulip-stage-2 from the
|
||||
# new version of Zulip (having the upgrade logic run from the new
|
||||
# version is much better for fixing bugs in the upgrade process).
|
||||
deploy_path = deploy_path.strip()
|
||||
os.chdir(deploy_path)
|
||||
try:
|
||||
subprocess.check_call(
|
||||
[
|
||||
os.path.abspath("./scripts/lib/upgrade-zulip-stage-2"),
|
||||
deploy_path,
|
||||
*deploy_options,
|
||||
*extra_options,
|
||||
]
|
||||
)
|
||||
except subprocess.CalledProcessError:
|
||||
# There's no use in showing a stacktrace here; it just hides
|
||||
# the error from stage 2.
|
||||
sys.exit(1)
|
||||
finally:
|
||||
release_deployment_lock()
|
||||
229
scripts/lib/upgrade-zulip-from-git
Executable file
229
scripts/lib/upgrade-zulip-from-git
Executable file
@@ -0,0 +1,229 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
LOCAL_GIT_CACHE_DIR = "/srv/zulip.git"
|
||||
os.environ["PYTHONUNBUFFERED"] = "y"
|
||||
|
||||
sys.path.append(os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
from scripts.lib.zulip_tools import (
|
||||
DEPLOYMENTS_DIR,
|
||||
assert_running_as_root,
|
||||
get_config,
|
||||
get_config_file,
|
||||
get_deploy_options,
|
||||
get_deploy_root,
|
||||
get_deployment_lock,
|
||||
make_deploy_path,
|
||||
overwrite_symlink,
|
||||
release_deployment_lock,
|
||||
su_to_zulip,
|
||||
upgrade_script_arg_parser,
|
||||
)
|
||||
|
||||
config_file = get_config_file()
|
||||
deploy_options = get_deploy_options(config_file)
|
||||
remote_url = get_config(
|
||||
config_file, "deployment", "git_repo_url", "https://github.com/zulip/zulip.git"
|
||||
)
|
||||
|
||||
assert_running_as_root(strip_lib_from_paths=True)
|
||||
|
||||
# make sure we have appropriate file permissions
|
||||
os.umask(0o22)
|
||||
|
||||
logging.Formatter.converter = time.gmtime
|
||||
logging.basicConfig(format="%(asctime)s upgrade-zulip-from-git: %(message)s", level=logging.INFO)
|
||||
|
||||
parser = argparse.ArgumentParser(add_help=False)
|
||||
parser.add_argument("refname", help="Git reference, e.g. a branch, tag, or commit ID.")
|
||||
git_ref = parser.add_mutually_exclusive_group()
|
||||
git_ref.add_argument(
|
||||
"--remote-url", help="Override the Git remote URL configured in /etc/zulip/zulip.conf."
|
||||
)
|
||||
git_ref.add_argument(
|
||||
"--local-ref",
|
||||
action="store_true",
|
||||
help="Provided branch name has been pushed directly to /srv/zulip.git already",
|
||||
)
|
||||
|
||||
# Any unknown options are passed through verbatim to upgrade-zulip-stage-2
|
||||
# and on to upgrade-zulip-stage-3. To make those passthrough options
|
||||
# discoverable, when the user runs --help we show a combined help that
|
||||
# also documents the stage-3 options.
|
||||
if {"-h", "--help"} & set(sys.argv[1:]):
|
||||
argparse.ArgumentParser(parents=[parser, upgrade_script_arg_parser()]).parse_args(["--help"])
|
||||
|
||||
args, extra_options = parser.parse_known_args()
|
||||
|
||||
refname = args.refname
|
||||
# Command line remote URL will be given preference above the one
|
||||
# in /etc/zulip/zulip.conf.
|
||||
if args.remote_url:
|
||||
remote_url = args.remote_url
|
||||
|
||||
os.makedirs(DEPLOYMENTS_DIR, exist_ok=True)
|
||||
|
||||
error_rerun_script = f"{DEPLOYMENTS_DIR}/current/scripts/upgrade-zulip-from-git {refname}"
|
||||
get_deployment_lock(error_rerun_script)
|
||||
|
||||
try:
|
||||
deploy_path = make_deploy_path()
|
||||
|
||||
# Populate LOCAL_GIT_CACHE_DIR with both the requested remote and zulip/zulip.
|
||||
if not os.path.exists(LOCAL_GIT_CACHE_DIR):
|
||||
logging.info("Making local repository cache")
|
||||
subprocess.check_call(
|
||||
["git", "init", "--bare", "-q", LOCAL_GIT_CACHE_DIR],
|
||||
stdout=subprocess.DEVNULL,
|
||||
)
|
||||
subprocess.check_call(
|
||||
["git", "remote", "add", "origin", remote_url],
|
||||
cwd=LOCAL_GIT_CACHE_DIR,
|
||||
)
|
||||
|
||||
if os.stat(LOCAL_GIT_CACHE_DIR).st_uid == 0:
|
||||
subprocess.check_call(["chown", "-R", "zulip:zulip", LOCAL_GIT_CACHE_DIR])
|
||||
|
||||
os.chdir(LOCAL_GIT_CACHE_DIR)
|
||||
if not args.local_ref:
|
||||
subprocess.check_call(
|
||||
["git", "remote", "set-url", "origin", remote_url], preexec_fn=su_to_zulip
|
||||
)
|
||||
|
||||
fetch_spec = subprocess.check_output(
|
||||
["git", "config", "remote.origin.fetch"],
|
||||
preexec_fn=su_to_zulip,
|
||||
text=True,
|
||||
).strip()
|
||||
if fetch_spec in ("+refs/*:refs/*", "+refs/heads/*:refs/heads/*"):
|
||||
# The refspec impinges on refs/heads/ -- this is an old mirror
|
||||
# configuration.
|
||||
logging.info("Cleaning up mirrored repository")
|
||||
# remotes.origin.mirror may not be set -- we do not use
|
||||
# check_call to ignore errors if it's already missing
|
||||
subprocess.call(
|
||||
["git", "config", "--unset", "remote.origin.mirror"],
|
||||
preexec_fn=su_to_zulip,
|
||||
)
|
||||
subprocess.check_call(
|
||||
["git", "config", "remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*"],
|
||||
preexec_fn=su_to_zulip,
|
||||
)
|
||||
matching_refs = subprocess.check_output(
|
||||
["git", "for-each-ref", "--format=%(refname)", "refs/pull/", "refs/heads/"],
|
||||
preexec_fn=su_to_zulip,
|
||||
text=True,
|
||||
).splitlines()
|
||||
|
||||
# We can't use `git worktree list --porcelain -z` here because
|
||||
# Ubuntu 22.04 only has git 2.34.1, and -z was
|
||||
# introduced in 2.36
|
||||
worktree_data = subprocess.check_output(
|
||||
["git", "worktree", "list", "--porcelain"],
|
||||
preexec_fn=su_to_zulip,
|
||||
text=True,
|
||||
).splitlines()
|
||||
keep_refs = set()
|
||||
for worktree_line in worktree_data:
|
||||
if worktree_line.startswith("branch "):
|
||||
keep_refs.add(worktree_line.removeprefix("branch "))
|
||||
|
||||
delete_input = "".join(
|
||||
f"delete {refname}\n" for refname in matching_refs if refname not in keep_refs
|
||||
)
|
||||
subprocess.run(
|
||||
["git", "update-ref", "--stdin"],
|
||||
check=True,
|
||||
preexec_fn=su_to_zulip,
|
||||
input=delete_input,
|
||||
text=True,
|
||||
)
|
||||
|
||||
logging.info("Repacking repository after pruning unnecessary refs...")
|
||||
subprocess.check_call(
|
||||
["git", "gc", "--prune=now"],
|
||||
preexec_fn=su_to_zulip,
|
||||
)
|
||||
|
||||
if not args.local_ref:
|
||||
subprocess.check_call(
|
||||
[os.path.join(get_deploy_root(), "scripts/lib/update-git-upstream")],
|
||||
preexec_fn=su_to_zulip,
|
||||
)
|
||||
|
||||
# Generate the deployment directory via git worktree from our local repository.
|
||||
try_refs = [
|
||||
f"refs/tags/{refname}",
|
||||
f"refs/heads/{refname}" if args.local_ref else f"refs/remotes/origin/{refname}",
|
||||
]
|
||||
for ref in try_refs:
|
||||
result = subprocess.run(
|
||||
["git", "rev-parse", "--verify", ref],
|
||||
preexec_fn=su_to_zulip,
|
||||
text=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
fullref = ref
|
||||
commit_hash = result.stdout.strip()
|
||||
break
|
||||
elif result.returncode != 128:
|
||||
result.check_returncode()
|
||||
else:
|
||||
logging.error(
|
||||
"Failed to resolve %s as a tag or %s branch name!",
|
||||
refname,
|
||||
"local" if args.local_ref else "remote",
|
||||
)
|
||||
sys.exit(1)
|
||||
refname = fullref
|
||||
|
||||
subprocess.check_call(
|
||||
["git", "worktree", "add", "--detach", deploy_path, refname],
|
||||
stdout=subprocess.DEVNULL,
|
||||
preexec_fn=su_to_zulip,
|
||||
)
|
||||
os.chdir(deploy_path)
|
||||
extra_flags = []
|
||||
if not refname.startswith("refs/tags/"):
|
||||
extra_flags = ["-t"]
|
||||
subprocess.check_call(
|
||||
[
|
||||
"git",
|
||||
"checkout",
|
||||
"-q",
|
||||
*extra_flags,
|
||||
"-b",
|
||||
"deployment-" + os.path.basename(deploy_path),
|
||||
refname,
|
||||
],
|
||||
preexec_fn=su_to_zulip,
|
||||
)
|
||||
|
||||
overwrite_symlink("/etc/zulip/settings.py", "zproject/prod_settings.py")
|
||||
|
||||
overwrite_symlink(deploy_path, os.path.join(DEPLOYMENTS_DIR, "next"))
|
||||
|
||||
try:
|
||||
subprocess.check_call(
|
||||
[
|
||||
os.path.join(deploy_path, "scripts", "lib", "upgrade-zulip-stage-2"),
|
||||
deploy_path,
|
||||
"--from-git",
|
||||
*deploy_options,
|
||||
*extra_options,
|
||||
]
|
||||
)
|
||||
except subprocess.CalledProcessError:
|
||||
# There's no use in showing a stacktrace here; it just hides
|
||||
# the error from stage 2.
|
||||
sys.exit(1)
|
||||
finally:
|
||||
release_deployment_lock()
|
||||
22
scripts/lib/upgrade-zulip-stage-2
Executable file
22
scripts/lib/upgrade-zulip-stage-2
Executable file
@@ -0,0 +1,22 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# This script serves only to verify that the OS is a supported
|
||||
# version, before we attempt to rely on that version in
|
||||
# upgrade-zulip-stage-3
|
||||
|
||||
set -eu
|
||||
|
||||
ZULIP_PATH="$(readlink -f "$(dirname "$0")"/../..)"
|
||||
if ! "$ZULIP_PATH/scripts/lib/supported-os"; then
|
||||
echo "Unsupported platform!"
|
||||
echo
|
||||
echo "Sorry! The support for your OS has been discontinued."
|
||||
echo "Please upgrade your OS to a supported release first,"
|
||||
echo "by following these instructions:"
|
||||
echo
|
||||
echo "https://zulip.readthedocs.io/en/latest/production/upgrade.html#upgrading-the-operating-system"
|
||||
echo
|
||||
exit 1
|
||||
fi
|
||||
|
||||
exec "$ZULIP_PATH/scripts/lib/upgrade-zulip-stage-3" "$@"
|
||||
372
scripts/lib/upgrade-zulip-stage-3
Executable file
372
scripts/lib/upgrade-zulip-stage-3
Executable file
@@ -0,0 +1,372 @@
|
||||
#!/usr/bin/env python3
|
||||
#
|
||||
# This script contains the actual logic for upgrading from an old
|
||||
# version of Zulip to the new version. upgrade-zulip-stage-3 is
|
||||
# always run from the new version of Zulip, so any bug fixes take
|
||||
# effect on the very next upgrade.
|
||||
import argparse
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
os.environ["PYTHONUNBUFFERED"] = "y"
|
||||
|
||||
# Force a known locale. Some packages on PyPI fail to install in some locales.
|
||||
os.environ["LC_ALL"] = "C.UTF-8"
|
||||
os.environ["LANG"] = "C.UTF-8"
|
||||
os.environ["LANGUAGE"] = "C.UTF-8"
|
||||
|
||||
sys.path.append(os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
from scripts.lib.zulip_tools import (
|
||||
DEPLOYMENTS_DIR,
|
||||
assert_running_as_root,
|
||||
get_config,
|
||||
get_config_file,
|
||||
parse_version_from,
|
||||
su_to_zulip,
|
||||
upgrade_script_arg_parser,
|
||||
)
|
||||
|
||||
assert_running_as_root()
|
||||
|
||||
# Set a known, reliable PATH
|
||||
os.environ["PATH"] = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
|
||||
|
||||
logging.Formatter.converter = time.gmtime
|
||||
logging.basicConfig(format="%(asctime)s upgrade-zulip-stage-3: %(message)s", level=logging.INFO)
|
||||
|
||||
# make sure we have appropriate file permissions
|
||||
os.umask(0o22)
|
||||
|
||||
parser = argparse.ArgumentParser(parents=[upgrade_script_arg_parser()])
|
||||
parser.add_argument("deploy_path", metavar="deploy_path", help="Path to deployment directory")
|
||||
parser.add_argument(
|
||||
"--from-git", action="store_true", help="Upgrading from git, so run update-prod-static."
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.skip_restart:
|
||||
if args.less_graceful:
|
||||
logging.warning("Ignored --less-graceful; --skip-restart is always graceful.")
|
||||
args.less_graceful = False
|
||||
if args.skip_migrations:
|
||||
logging.warning(
|
||||
"Ignored --skip-migrations; all upgrades with --skip-restart asserts no migrations."
|
||||
)
|
||||
args.skip_migrations = False
|
||||
if args.skip_puppet:
|
||||
logging.warning(
|
||||
"Ignored --skip-puppet; all upgrades with --skip-restart asserts no puppet changes."
|
||||
)
|
||||
args.skip_puppet = False
|
||||
|
||||
if not args.skip_puppet and args.less_graceful:
|
||||
logging.warning("Ignored --less-graceful; all upgrades without --skip-puppet are ungraceful.")
|
||||
|
||||
if args.only_django:
|
||||
logging.error(
|
||||
"Only restarting Django into a new deployment leads to inconsistent caches "
|
||||
"with Tornado; use --skip-client-reloads instead."
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
deploy_path = args.deploy_path
|
||||
os.chdir(deploy_path)
|
||||
|
||||
config_file = get_config_file()
|
||||
|
||||
IS_SERVER_UP = True
|
||||
HAS_FILLED_CACHES = False
|
||||
|
||||
if args.from_git:
|
||||
logging.info("Caching Zulip Git version...")
|
||||
subprocess.check_call(["./tools/cache-zulip-git-version"], preexec_fn=su_to_zulip)
|
||||
|
||||
from version import ZULIP_VERSION as NEW_ZULIP_VERSION
|
||||
|
||||
old_version = parse_version_from(DEPLOYMENTS_DIR + "/current")
|
||||
logging.info("Upgrading from %s to %s, in %s", old_version, NEW_ZULIP_VERSION, deploy_path)
|
||||
|
||||
migrations_needed = False
|
||||
|
||||
|
||||
def fill_memcached_caches() -> None:
|
||||
global HAS_FILLED_CACHES
|
||||
if HAS_FILLED_CACHES or migrations_needed:
|
||||
return
|
||||
subprocess.check_call(
|
||||
["./manage.py", "fill_memcached_caches", "--automated", "--skip-checks"],
|
||||
preexec_fn=su_to_zulip,
|
||||
)
|
||||
HAS_FILLED_CACHES = True
|
||||
|
||||
|
||||
def shutdown_server(fill_caches: bool = True) -> None:
|
||||
global IS_SERVER_UP
|
||||
|
||||
if args.skip_restart:
|
||||
logging.info("Upgrade would require shutting down Zulip -- aborting!")
|
||||
sys.exit(1)
|
||||
|
||||
if fill_caches:
|
||||
fill_memcached_caches()
|
||||
|
||||
if IS_SERVER_UP:
|
||||
logging.info("Stopping Zulip...")
|
||||
subprocess.check_call(["./scripts/stop-server"], preexec_fn=su_to_zulip)
|
||||
IS_SERVER_UP = False
|
||||
|
||||
|
||||
if not (args.skip_restart or args.skip_puppet):
|
||||
# We need to temporarily hold pgroonga, if installed -- upgrading
|
||||
# it without running the appropriate upgrade SQL can cause
|
||||
# PostgreSQL to crash
|
||||
if get_config(config_file, "machine", "pgroonga", False):
|
||||
subprocess.check_call(["apt-mark", "hold", "postgresql-*-pgdg-pgroonga"])
|
||||
logging.info("Upgrading system packages...")
|
||||
subprocess.check_call(["apt-get", "update"])
|
||||
subprocess.check_call(["apt-get", "-y", "--with-new-pkgs", "upgrade"])
|
||||
if get_config(config_file, "machine", "pgroonga", False):
|
||||
subprocess.check_call(["apt-mark", "unhold", "postgresql-*-pgdg-pgroonga"])
|
||||
|
||||
# Now we should have an environment set up where we can run our tools;
|
||||
# first, creating the production venv.
|
||||
subprocess.check_call(
|
||||
[os.path.join(deploy_path, "scripts", "lib", "create-production-venv"), deploy_path]
|
||||
)
|
||||
|
||||
# Check to make sure that this upgrade is not actually a database
|
||||
# downgrade.
|
||||
if not args.skip_downgrade_check:
|
||||
subprocess.check_call(
|
||||
[os.path.join(deploy_path, "scripts", "lib", "check-database-compatibility")],
|
||||
preexec_fn=su_to_zulip,
|
||||
)
|
||||
|
||||
# Make sure the right version of node is installed
|
||||
subprocess.check_call([os.path.join(deploy_path, "scripts", "lib", "install-node")])
|
||||
|
||||
# Generate any new secrets that were added in the new version required.
|
||||
# TODO: Do caching to only run this when it has changed.
|
||||
subprocess.check_call(
|
||||
[os.path.join(deploy_path, "scripts", "setup", "generate_secrets.py"), "--production"]
|
||||
)
|
||||
|
||||
# TODO/compatibility: Puppet class names for the manifest renames in
|
||||
# the 11.0 release. Can be removed when we can no longer directly
|
||||
# upgrade from 10.x.
|
||||
class_renames = {
|
||||
"zulip::postfix_localmail": "zulip::local_mailserver",
|
||||
}
|
||||
classes = re.split(r"\s*,\s*", get_config(config_file, "machine", "puppet_classes", ""))
|
||||
new_classes = [class_renames.get(c, c) for c in classes]
|
||||
if classes != new_classes:
|
||||
if args.skip_restart:
|
||||
logging.error("Would need to adjust puppet classes -- aborting!")
|
||||
sys.exit(1)
|
||||
logging.info("Adjusting Puppet classes for renames...")
|
||||
subprocess.check_call(
|
||||
[
|
||||
"crudini",
|
||||
"--set",
|
||||
"/etc/zulip/zulip.conf",
|
||||
"machine",
|
||||
"puppet_classes",
|
||||
", ".join(new_classes),
|
||||
]
|
||||
)
|
||||
|
||||
# And then, building/installing the static assets.
|
||||
if args.ignore_static_assets:
|
||||
# For the OS version upgrade use case, the static assets are
|
||||
# already in place, and we don't need to do anything. Further,
|
||||
# neither of the options below will work for all installations,
|
||||
# because if we installed from Git, `prod-static/serve` may be
|
||||
# empty so we can't do the non-Git thing, whereas if we installed
|
||||
# from a tarball, we won't have a `tools/` directory and thus
|
||||
# cannot run `tools/update-prod-static`.
|
||||
pass
|
||||
elif args.from_git:
|
||||
# Because `upgrade-zulip-from-git` needs to build static assets, it
|
||||
# is at risk of being OOM killed on systems with limited free RAM.
|
||||
mem_bytes = os.sysconf("SC_PAGE_SIZE") * os.sysconf("SC_PHYS_PAGES")
|
||||
mem_gib = mem_bytes / (1024.0**3) # e.g. 3.74
|
||||
|
||||
# Ideally, we'd have 2 thresholds here, depending on whether the
|
||||
# system is running queue workers multithreaded or multiprocess.
|
||||
# See puppet/zulip/manifests/app_frontend_base.pp for background.
|
||||
if mem_gib < 4.2:
|
||||
logging.info("Shutting down server to ensure sufficient free RAM for webpack.")
|
||||
shutdown_server(fill_caches=False)
|
||||
|
||||
# Note: The fact that this is before we apply Puppet changes means
|
||||
# that we don't support adding new Puppet dependencies of
|
||||
# update-prod-static with the Git upgrade process. But it'll fail
|
||||
# safely; this seems like a worthwhile tradeoff to minimize downtime.
|
||||
logging.info("Building static assets...")
|
||||
try:
|
||||
subprocess.check_call(["./tools/update-prod-static"], preexec_fn=su_to_zulip)
|
||||
except subprocess.CalledProcessError:
|
||||
logging.error("Failed to build static assets.")
|
||||
if IS_SERVER_UP:
|
||||
logging.error("Usually the cause is insufficient free RAM to run webpack.")
|
||||
logging.error("Try stopping the Zulip server (scripts/stop-server) and trying again.")
|
||||
sys.exit(1)
|
||||
|
||||
else:
|
||||
# Since this doesn't do any actual work, it's likely safe to have
|
||||
# this run before we apply Puppet changes (saving a bit of downtime).
|
||||
logging.info("Installing static assets...")
|
||||
subprocess.check_call(
|
||||
["cp", "-rT", os.path.join(deploy_path, "prod-static/serve"), "/home/zulip/prod-static"],
|
||||
preexec_fn=su_to_zulip,
|
||||
)
|
||||
|
||||
# Ensure the local uploads directory exists before we run system
|
||||
# checks, since check_uploads_settings verifies it. Puppet would
|
||||
# create it later in the upgrade, but the system check runs first.
|
||||
# `os.path.isdir` follows symlinks, so admins who have replaced the
|
||||
# directory with a symlink to alternate storage are not disturbed.
|
||||
if not os.path.isdir("/home/zulip/uploads"):
|
||||
subprocess.check_call(
|
||||
["install", "-d", "-o", "zulip", "-g", "zulip", "-m", "0755", "/home/zulip/uploads"]
|
||||
)
|
||||
|
||||
# Perform system checks -- including database checks, so we don't need
|
||||
# to do them when we do migrations, below.
|
||||
if not args.skip_checks:
|
||||
subprocess.check_call(["./manage.py", "check", "--database", "default"], preexec_fn=su_to_zulip)
|
||||
|
||||
# Our next optimization is to check whether any migrations are needed
|
||||
# before we start the critical section of the restart. This saves
|
||||
# about 1s of downtime in a no-op upgrade. We omit this check if we
|
||||
# already stopped the server above, due to low memory.
|
||||
if not IS_SERVER_UP:
|
||||
migrations_needed = True
|
||||
elif not args.skip_migrations:
|
||||
logging.info("Checking for needed migrations")
|
||||
migrations_output = subprocess.check_output(
|
||||
["./manage.py", "showmigrations", "--skip-checks"], preexec_fn=su_to_zulip, text=True
|
||||
)
|
||||
for ln in migrations_output.split("\n"):
|
||||
line_str = ln.strip()
|
||||
if line_str.startswith("[ ]"):
|
||||
migrations_needed = True
|
||||
|
||||
if args.skip_restart and migrations_needed:
|
||||
logging.error("Would need to apply migrations -- aborting!")
|
||||
sys.exit(1)
|
||||
|
||||
# Install hooks before we check for puppet changes, so we skip an
|
||||
# unnecessary stop/start cycle if hook changes are the only ones.
|
||||
logging.info("Installing hooks")
|
||||
subprocess.check_call(
|
||||
["./scripts/zulip-puppet-apply", "--tags", "hooks", "--force"],
|
||||
)
|
||||
|
||||
# If we are planning on running puppet, we can pre-run it in --noop
|
||||
# mode and see if it will actually make any changes; if not, we can
|
||||
# skip it during the upgrade. We omit this check if the server is
|
||||
# already stopped, since it's not better than just pressing on.
|
||||
has_puppet_changes = True
|
||||
if not args.skip_puppet and IS_SERVER_UP:
|
||||
logging.info("Pre-checking for puppet changes...")
|
||||
try_puppet = subprocess.run(
|
||||
["./scripts/zulip-puppet-apply", "--noop", "--force"],
|
||||
stdout=subprocess.DEVNULL,
|
||||
check=False,
|
||||
)
|
||||
if try_puppet.returncode == 0:
|
||||
if args.skip_restart:
|
||||
logging.info("Verified no Puppet changes are necessary.")
|
||||
else:
|
||||
logging.info("No puppet changes found, skipping!")
|
||||
args.skip_puppet = True
|
||||
has_puppet_changes = False
|
||||
elif try_puppet.returncode == 2:
|
||||
logging.error("Puppet error -- aborting!")
|
||||
sys.exit(1)
|
||||
elif args.skip_restart:
|
||||
logging.error("Would need to apply puppet changes -- aborting!")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if args.skip_restart:
|
||||
logging.info("Successfully configured in %s!", deploy_path)
|
||||
else:
|
||||
# NOTE: Here begins the most likely critical period, where we may be
|
||||
# shutting down the server; we should strive to minimize the number of
|
||||
# steps that happen between here and the "Restarting Zulip" line
|
||||
# below.
|
||||
hooks_args = []
|
||||
if args.from_git:
|
||||
hooks_args = ["--from-git"]
|
||||
|
||||
subprocess.check_call(["./scripts/lib/run_hooks.py", "pre-deploy", *hooks_args])
|
||||
|
||||
if not args.skip_puppet:
|
||||
# Puppet may adjust random services; to minimize risk of issues
|
||||
# due to inconsistent state, we shut down the server first.
|
||||
shutdown_server()
|
||||
logging.info("Applying Puppet changes...")
|
||||
subprocess.check_call(["./scripts/zulip-puppet-apply", "--force"])
|
||||
subprocess.check_call(["apt-get", "-y", "--with-new-pkgs", "upgrade"])
|
||||
# Puppet may have reloaded supervisor, and in so doing started
|
||||
# services; mark as potentially needing to stop the server.
|
||||
IS_SERVER_UP = True
|
||||
|
||||
if migrations_needed:
|
||||
# Database migrations assume that they run on a database in
|
||||
# quiesced state.
|
||||
shutdown_server()
|
||||
logging.info("Applying database migrations...")
|
||||
subprocess.check_call(
|
||||
["./manage.py", "migrate", "--noinput", "--skip-checks"], preexec_fn=su_to_zulip
|
||||
)
|
||||
|
||||
logging.info("Restarting Zulip...")
|
||||
start_args = ["--skip-checks"]
|
||||
if args.only_django and IS_SERVER_UP:
|
||||
# Calling `start-server --only-django` only starts Django,
|
||||
# which is almost certainly not what we want after an upgrade.
|
||||
# If we had to stop services for other reasons (e.g. puppet),
|
||||
# we ignore --only-django and downgrade it to
|
||||
# --skip-client-reloads
|
||||
start_args.append("--only-django")
|
||||
else:
|
||||
start_args.append("--skip-client-reloads")
|
||||
if not HAS_FILLED_CACHES:
|
||||
start_args.append("--fill-cache")
|
||||
if IS_SERVER_UP:
|
||||
if args.less_graceful:
|
||||
start_args.append("--less-graceful")
|
||||
subprocess.check_call(["./scripts/restart-server", *start_args], preexec_fn=su_to_zulip)
|
||||
else:
|
||||
subprocess.check_call(["./scripts/start-server", *start_args], preexec_fn=su_to_zulip)
|
||||
|
||||
logging.info("Upgrade complete!")
|
||||
|
||||
subprocess.check_call(["./scripts/lib/run_hooks.py", "post-deploy", *hooks_args])
|
||||
|
||||
if not args.skip_client_reloads and not args.only_django:
|
||||
subprocess.check_call(["./scripts/reload-clients", "--background"], preexec_fn=su_to_zulip)
|
||||
|
||||
if args.audit_fts_indexes:
|
||||
logging.info("Correcting full-text search indexes for updated dictionary files")
|
||||
logging.info("This may take a while but the server should work while it runs.")
|
||||
subprocess.check_call(
|
||||
["./manage.py", "audit_fts_indexes", "--automated", "--skip-checks"], preexec_fn=su_to_zulip
|
||||
)
|
||||
|
||||
if not args.skip_purge_old_deployments:
|
||||
logging.info("Purging old deployments...")
|
||||
subprocess.check_call(["./scripts/purge-old-deployments"])
|
||||
else:
|
||||
logging.info("Skipping purging old deployments.")
|
||||
|
||||
if args.skip_puppet and has_puppet_changes:
|
||||
logging.info("Showing un-applied Puppet changes:")
|
||||
subprocess.check_call(["./scripts/zulip-puppet-apply", "--noop", "--show_diff"])
|
||||
29
scripts/lib/warn-rabbitmq-nodename-change
Executable file
29
scripts/lib/warn-rabbitmq-nodename-change
Executable file
@@ -0,0 +1,29 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# For security reasons, we need to configure RabbitMQ to listen only
|
||||
# on localhost, which we cannot do if the nodename contains the
|
||||
# hostname (e.g. rabbit@zulip-host). Containing the hostname also
|
||||
# makes it brittle to hostname changes, which are (for example) common
|
||||
# in containerized environments.
|
||||
|
||||
set -eu
|
||||
|
||||
# Try to find the current nodename
|
||||
CURRENT=$(sh -c 'cd /usr/lib/rabbitmq/bin/ && . ./rabbitmq-env && echo $NODENAME')
|
||||
if [ "$CURRENT" != "zulip@localhost" ]; then
|
||||
cat <<EOF
|
||||
|
||||
***** WARNING *****
|
||||
|
||||
We are renaming the rabbitmq server's nodename from '$CURRENT' to
|
||||
'zulip@localhost', as rabbitmq is being reconfigured to listen only on
|
||||
localhost. This will also make the server more resilient to hostname
|
||||
changes. This will only affect you if you were using the rabbitmq
|
||||
server for other, non-Zulip uses.
|
||||
|
||||
*******************
|
||||
|
||||
EOF
|
||||
|
||||
service rabbitmq-server stop || true
|
||||
fi
|
||||
766
scripts/lib/zulip_tools.py
Executable file
766
scripts/lib/zulip_tools.py
Executable file
@@ -0,0 +1,766 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import configparser
|
||||
import functools
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
import pwd
|
||||
import random
|
||||
import shlex
|
||||
import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import uuid
|
||||
import zoneinfo
|
||||
from collections.abc import Sequence
|
||||
from datetime import datetime, timedelta
|
||||
from typing import IO, Any, Literal, overload
|
||||
from urllib.parse import SplitResult
|
||||
|
||||
DEPLOYMENTS_DIR = "/home/zulip/deployments"
|
||||
LOCK_DIR = os.path.join(DEPLOYMENTS_DIR, "lock")
|
||||
TIMESTAMP_FORMAT = "%Y-%m-%d-%H-%M-%S"
|
||||
|
||||
# Color codes
|
||||
OKBLUE = "\033[94m"
|
||||
OKGREEN = "\033[92m"
|
||||
WARNING = "\033[93m"
|
||||
FAIL = "\033[91m"
|
||||
ENDC = "\033[0m"
|
||||
BLACKONYELLOW = "\x1b[0;30;43m"
|
||||
WHITEONRED = "\x1b[0;37;41m"
|
||||
BOLDRED = "\x1b[1;31m"
|
||||
BOLD = "\x1b[1m"
|
||||
GRAY = "\x1b[90m"
|
||||
|
||||
GREEN = "\x1b[32m"
|
||||
YELLOW = "\x1b[33m"
|
||||
BLUE = "\x1b[34m"
|
||||
MAGENTA = "\x1b[35m"
|
||||
CYAN = "\x1b[36m"
|
||||
|
||||
|
||||
def overwrite_symlink(src: str, dst: str) -> None:
|
||||
dir, base = os.path.split(dst)
|
||||
while True:
|
||||
# Note: creating a temporary filename like this is not generally
|
||||
# secure. It’s fine in this case because os.symlink refuses to
|
||||
# overwrite an existing target; we handle the error and try again.
|
||||
tmp = os.path.join(dir, f".{base}.{random.randrange(1 << 40):010x}")
|
||||
try:
|
||||
os.symlink(src, tmp)
|
||||
except FileExistsError:
|
||||
continue
|
||||
break
|
||||
try:
|
||||
os.rename(tmp, dst)
|
||||
except BaseException:
|
||||
os.remove(tmp)
|
||||
raise
|
||||
|
||||
|
||||
def parse_cache_script_args(description: str) -> argparse.Namespace:
|
||||
# Keep this in sync with clean_unused_caches in provision_inner.py
|
||||
parser = argparse.ArgumentParser(description=description)
|
||||
|
||||
parser.add_argument(
|
||||
"--threshold",
|
||||
dest="threshold_days",
|
||||
type=int,
|
||||
default=14,
|
||||
metavar="<days>",
|
||||
help="Any cache which is not in "
|
||||
"use by a deployment not older than threshold days(current "
|
||||
"installation in dev) and older than threshold days will be "
|
||||
"deleted. (defaults to 14)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
help="If specified then script will only print the caches "
|
||||
"that it will delete/keep back. It will not delete any cache.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--verbose",
|
||||
action="store_true",
|
||||
help="If specified then script will print a detailed report "
|
||||
"of what is being will deleted/kept back.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-print-headings",
|
||||
dest="no_headings",
|
||||
action="store_true",
|
||||
help="If specified then script will not print headings for what will be deleted/kept back.",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
args.verbose |= args.dry_run # Always print a detailed report in case of dry run.
|
||||
return args
|
||||
|
||||
|
||||
def get_deploy_root() -> str:
|
||||
return os.path.realpath(
|
||||
os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "..")),
|
||||
)
|
||||
|
||||
|
||||
def parse_version_from(deploy_path: str, merge_base: bool = False) -> str:
|
||||
if not os.path.exists(os.path.join(deploy_path, "zulip-git-version")):
|
||||
try:
|
||||
# Pull this tool from _our_ deploy root, since it may not
|
||||
# exist historically, but run it the cwd of the old
|
||||
# deploy, so we set up its remote.
|
||||
subprocess.check_call(
|
||||
[os.path.join(get_deploy_root(), "scripts", "lib", "update-git-upstream")],
|
||||
cwd=deploy_path,
|
||||
preexec_fn=su_to_zulip,
|
||||
)
|
||||
subprocess.check_call(
|
||||
[os.path.join(deploy_path, "tools", "cache-zulip-git-version")],
|
||||
cwd=deploy_path,
|
||||
preexec_fn=su_to_zulip,
|
||||
)
|
||||
except subprocess.CalledProcessError:
|
||||
pass
|
||||
try:
|
||||
varname = "ZULIP_MERGE_BASE" if merge_base else "ZULIP_VERSION"
|
||||
return subprocess.check_output(
|
||||
[sys.executable, "-c", f"from version import {varname}; print({varname})"],
|
||||
cwd=deploy_path,
|
||||
text=True,
|
||||
).strip()
|
||||
except subprocess.CalledProcessError:
|
||||
return "0.0.0"
|
||||
|
||||
|
||||
def get_deployment_version(extract_path: str) -> str:
|
||||
version = "0.0.0"
|
||||
for item in os.listdir(extract_path):
|
||||
item_path = os.path.join(extract_path, item)
|
||||
if item.startswith("zulip-server") and os.path.isdir(item_path):
|
||||
version = parse_version_from(item_path)
|
||||
break
|
||||
return version
|
||||
|
||||
|
||||
def is_invalid_upgrade(current_version: str, new_version: str) -> bool:
|
||||
if new_version > "1.4.3" and current_version <= "1.3.10":
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def get_zulip_pwent() -> pwd.struct_passwd:
|
||||
deploy_root_uid = os.stat(get_deploy_root()).st_uid
|
||||
if deploy_root_uid != 0:
|
||||
return pwd.getpwuid(deploy_root_uid)
|
||||
|
||||
# In the case that permissions got messed up and the deployment
|
||||
# directory is unexpectedly owned by root, we fallback to the
|
||||
# `zulip` user as that's the correct value in production.
|
||||
return pwd.getpwnam("zulip")
|
||||
|
||||
|
||||
def get_postgres_pwent() -> pwd.struct_passwd:
|
||||
try:
|
||||
return pwd.getpwnam("postgres")
|
||||
except KeyError:
|
||||
return get_zulip_pwent()
|
||||
|
||||
|
||||
def su_to_zulip(save_suid: bool = False) -> None:
|
||||
"""Warning: su_to_zulip assumes that the zulip checkout is owned by
|
||||
the zulip user (or whatever normal user is running the Zulip
|
||||
installation). It should never be run from the installer or other
|
||||
production contexts before /home/zulip/deployments/current is
|
||||
created."""
|
||||
pwent = get_zulip_pwent()
|
||||
os.setgid(pwent.pw_gid)
|
||||
if save_suid:
|
||||
os.setresuid(pwent.pw_uid, pwent.pw_uid, os.getuid())
|
||||
else:
|
||||
os.setuid(pwent.pw_uid)
|
||||
os.environ["HOME"] = pwent.pw_dir
|
||||
|
||||
|
||||
def make_deploy_path() -> str:
|
||||
timestamp = datetime.now().strftime(TIMESTAMP_FORMAT) # noqa: DTZ005
|
||||
return os.path.join(DEPLOYMENTS_DIR, timestamp)
|
||||
|
||||
|
||||
TEMPLATE_DATABASE_DIR = "test-backend/databases"
|
||||
|
||||
|
||||
def get_dev_uuid_var_path(create_if_missing: bool = False) -> str:
|
||||
zulip_path = get_deploy_root()
|
||||
uuid_path = os.path.join(os.path.realpath(os.path.dirname(zulip_path)), ".zulip-dev-uuid")
|
||||
if os.path.exists(uuid_path):
|
||||
with open(uuid_path) as f:
|
||||
zulip_uuid = f.read().strip()
|
||||
else:
|
||||
if create_if_missing:
|
||||
zulip_uuid = str(uuid.uuid4())
|
||||
# We need root access here, since the path will be under /srv/ in the
|
||||
# development environment.
|
||||
run_as_root(["sh", "-c", 'echo "$1" > "$2"', "-", zulip_uuid, uuid_path])
|
||||
else:
|
||||
raise AssertionError("Missing UUID file; please run tools/provision!")
|
||||
|
||||
result_path = os.path.join(zulip_path, "var", zulip_uuid)
|
||||
os.makedirs(result_path, exist_ok=True)
|
||||
return result_path
|
||||
|
||||
|
||||
def get_deployment_lock(error_rerun_script: str) -> None:
|
||||
start_time = time.time()
|
||||
got_lock = False
|
||||
while time.time() - start_time < 300:
|
||||
try:
|
||||
os.mkdir(LOCK_DIR)
|
||||
got_lock = True
|
||||
break
|
||||
except OSError:
|
||||
print(
|
||||
WARNING
|
||||
+ "Another deployment in progress; waiting for lock... "
|
||||
+ f"(If no deployment is running, rmdir {LOCK_DIR})"
|
||||
+ ENDC,
|
||||
flush=True,
|
||||
)
|
||||
time.sleep(3)
|
||||
|
||||
if not got_lock:
|
||||
print(
|
||||
FAIL
|
||||
+ "Deployment already in progress. Please run\n"
|
||||
+ f" {error_rerun_script}\n"
|
||||
+ "manually when the previous deployment finishes, or run\n"
|
||||
+ f" rmdir {LOCK_DIR}\n"
|
||||
+ "if the previous deployment crashed."
|
||||
+ ENDC
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def release_deployment_lock() -> None:
|
||||
shutil.rmtree(LOCK_DIR)
|
||||
|
||||
|
||||
def run(args: Sequence[str], **kwargs: Any) -> None:
|
||||
# Output what we're doing in the `set -x` style
|
||||
print(f"+ {shlex.join(args)}", flush=True)
|
||||
|
||||
try:
|
||||
subprocess.check_call(args, **kwargs)
|
||||
except subprocess.CalledProcessError as error:
|
||||
print()
|
||||
if error.returncode < 0:
|
||||
try:
|
||||
signal_name = signal.Signals(-error.returncode).name
|
||||
except ValueError:
|
||||
signal_name = f"unknown signal {-error.returncode}"
|
||||
print(
|
||||
WHITEONRED
|
||||
+ f"Subcommand of {sys.argv[0]} died with {signal_name}: {shlex.join(args)}"
|
||||
+ ENDC
|
||||
)
|
||||
else:
|
||||
print(
|
||||
WHITEONRED
|
||||
+ f"Subcommand of {sys.argv[0]} failed with exit status {error.returncode}: {shlex.join(args)}"
|
||||
+ ENDC
|
||||
)
|
||||
print(WHITEONRED + "Actual error output for the subcommand is just above this." + ENDC)
|
||||
print()
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def log_management_command(cmd: Sequence[str], log_path: str) -> None:
|
||||
log_dir = os.path.dirname(log_path)
|
||||
if not os.path.exists(log_dir):
|
||||
os.makedirs(log_dir)
|
||||
|
||||
formatter = logging.Formatter("%(asctime)s: %(message)s")
|
||||
file_handler = logging.FileHandler(log_path)
|
||||
file_handler.setFormatter(formatter)
|
||||
logger = logging.getLogger("zulip.management")
|
||||
logger.addHandler(file_handler)
|
||||
logger.setLevel(logging.INFO)
|
||||
|
||||
logger.info("Ran %s", shlex.join(cmd))
|
||||
|
||||
|
||||
def get_environment() -> str:
|
||||
if os.path.exists(DEPLOYMENTS_DIR):
|
||||
return "prod"
|
||||
return "dev"
|
||||
|
||||
|
||||
def get_recent_deployments(threshold_days: int | None) -> set[str]:
|
||||
# Returns a list of deployments not older than threshold days
|
||||
# including `/root/zulip` directory if it exists.
|
||||
recent = set()
|
||||
if threshold_days is not None:
|
||||
threshold_date = datetime.now() - timedelta(days=threshold_days) # noqa: DTZ005
|
||||
else:
|
||||
threshold_date = None
|
||||
if os.path.isdir(DEPLOYMENTS_DIR):
|
||||
for dir_name in os.listdir(DEPLOYMENTS_DIR):
|
||||
target_dir = os.path.join(DEPLOYMENTS_DIR, dir_name)
|
||||
if not os.path.isdir(target_dir):
|
||||
# Skip things like uwsgi sockets, symlinks, etc.
|
||||
continue
|
||||
if not os.path.exists(os.path.join(target_dir, "zerver")):
|
||||
# Skip things like "lock" that aren't actually a deployment directory
|
||||
continue
|
||||
try:
|
||||
date = datetime.strptime(dir_name, TIMESTAMP_FORMAT) # noqa: DTZ007
|
||||
if threshold_date is None or date >= threshold_date:
|
||||
recent.add(target_dir)
|
||||
except ValueError:
|
||||
# Always include deployments whose name is not in the format of a timestamp.
|
||||
recent.add(target_dir)
|
||||
# If it is a symlink then include the target as well.
|
||||
if os.path.islink(target_dir):
|
||||
recent.add(os.path.realpath(target_dir))
|
||||
if os.path.exists("/root/zulip"):
|
||||
recent.add("/root/zulip")
|
||||
return recent
|
||||
|
||||
|
||||
def get_threshold_timestamp(threshold_days: int) -> int:
|
||||
# Given number of days, this function returns timestamp corresponding
|
||||
# to the time prior to given number of days.
|
||||
threshold = datetime.now() - timedelta(days=threshold_days) # noqa: DTZ005
|
||||
threshold_timestamp = int(time.mktime(threshold.utctimetuple()))
|
||||
return threshold_timestamp
|
||||
|
||||
|
||||
def get_caches_to_be_purged(
|
||||
caches_dir: str, caches_in_use: set[str], threshold_days: int
|
||||
) -> set[str]:
|
||||
# Given a directory containing caches, a list of caches in use
|
||||
# and threshold days, this function return a list of caches
|
||||
# which can be purged. Remove the cache only if it is:
|
||||
# 1: Not in use by the current installation(in dev as well as in prod).
|
||||
# 2: Not in use by a deployment not older than `threshold_days` (in prod).
|
||||
# 3: Not in use by '/root/zulip'.
|
||||
# 4: Not older than `threshold_days`.
|
||||
caches_to_purge = set()
|
||||
threshold_timestamp = get_threshold_timestamp(threshold_days)
|
||||
for cache_dir_base in os.listdir(caches_dir):
|
||||
cache_dir = os.path.join(caches_dir, cache_dir_base)
|
||||
if cache_dir in caches_in_use:
|
||||
# Never purge a cache which is in use.
|
||||
continue
|
||||
if os.path.getctime(cache_dir) < threshold_timestamp:
|
||||
caches_to_purge.add(cache_dir)
|
||||
return caches_to_purge
|
||||
|
||||
|
||||
def purge_unused_caches(
|
||||
caches_dir: str,
|
||||
caches_in_use: set[str],
|
||||
cache_type: str,
|
||||
args: argparse.Namespace,
|
||||
) -> None:
|
||||
if not os.path.exists(caches_dir):
|
||||
return
|
||||
|
||||
all_caches = {os.path.join(caches_dir, cache) for cache in os.listdir(caches_dir)}
|
||||
caches_to_purge = get_caches_to_be_purged(caches_dir, caches_in_use, args.threshold_days)
|
||||
caches_to_keep = all_caches - caches_to_purge
|
||||
|
||||
maybe_perform_purging(
|
||||
caches_to_purge, caches_to_keep, cache_type, args.dry_run, args.verbose, args.no_headings
|
||||
)
|
||||
if args.verbose:
|
||||
print("Done!")
|
||||
|
||||
|
||||
def generate_sha1sum_emoji(zulip_path: str) -> str:
|
||||
sha = hashlib.sha1()
|
||||
|
||||
filenames = [
|
||||
"web/images/zulip-emoji/zulip.png",
|
||||
"tools/setup/emoji/emoji_map.json",
|
||||
"tools/setup/emoji/build_emoji",
|
||||
"tools/setup/emoji/emoji_setup_utils.py",
|
||||
"tools/setup/emoji/emoji_names.py",
|
||||
"zerver/management/data/unified_reactions.json",
|
||||
]
|
||||
|
||||
for filename in filenames:
|
||||
file_path = os.path.join(zulip_path, filename)
|
||||
with open(file_path, "rb") as reader:
|
||||
sha.update(reader.read())
|
||||
|
||||
# Take into account the version of `emoji-datasource-google` package
|
||||
# while generating success stamp.
|
||||
with open(os.path.join(zulip_path, "node_modules/emoji-datasource-google/package.json")) as fp:
|
||||
emoji_datasource_version = json.load(fp)["version"]
|
||||
sha.update(emoji_datasource_version.encode())
|
||||
|
||||
return sha.hexdigest()
|
||||
|
||||
|
||||
def maybe_perform_purging(
|
||||
dirs_to_purge: set[str],
|
||||
dirs_to_keep: set[str],
|
||||
dir_type: str,
|
||||
dry_run: bool,
|
||||
verbose: bool,
|
||||
no_headings: bool,
|
||||
) -> None:
|
||||
if dry_run:
|
||||
print("Performing a dry run...")
|
||||
if not no_headings:
|
||||
print(f"Cleaning unused {dir_type}s...")
|
||||
|
||||
for directory in dirs_to_purge:
|
||||
if verbose:
|
||||
print(f"Cleaning unused {dir_type}: {directory}")
|
||||
if not dry_run:
|
||||
run_as_root(["rm", "-rf", directory])
|
||||
|
||||
for directory in dirs_to_keep:
|
||||
if verbose:
|
||||
print(f"Keeping used {dir_type}: {directory}")
|
||||
|
||||
|
||||
@functools.lru_cache(None)
|
||||
def os_families() -> set[str]:
|
||||
"""
|
||||
Known families:
|
||||
debian (includes: debian, ubuntu)
|
||||
ubuntu (includes: ubuntu)
|
||||
fedora (includes: fedora, rhel, centos)
|
||||
rhel (includes: rhel, centos)
|
||||
centos (includes: centos)
|
||||
"""
|
||||
distro_info = platform.freedesktop_os_release()
|
||||
return {distro_info["ID"], *distro_info.get("ID_LIKE", "").split()}
|
||||
|
||||
|
||||
def get_tzdata_zi() -> IO[str]:
|
||||
for path in zoneinfo.TZPATH:
|
||||
filename = os.path.join(path, "tzdata.zi")
|
||||
if os.path.exists(filename):
|
||||
return open(filename)
|
||||
raise RuntimeError("Missing time zone data (tzdata.zi)")
|
||||
|
||||
|
||||
def files_and_string_digest(filenames: Sequence[str], extra_strings: Sequence[str]) -> str:
|
||||
# see is_digest_obsolete for more context
|
||||
sha1sum = hashlib.sha1()
|
||||
for fn in filenames:
|
||||
with open(fn, "rb") as file_to_hash:
|
||||
sha1sum.update(file_to_hash.read())
|
||||
|
||||
for extra_string in extra_strings:
|
||||
sha1sum.update(extra_string.encode())
|
||||
|
||||
return sha1sum.hexdigest()
|
||||
|
||||
|
||||
def is_digest_obsolete(
|
||||
hash_name: str, filenames: Sequence[str], extra_strings: Sequence[str] = []
|
||||
) -> bool:
|
||||
"""
|
||||
In order to determine if we need to run some
|
||||
process, we calculate a digest of the important
|
||||
files and strings whose respective contents
|
||||
or values may indicate such a need.
|
||||
|
||||
filenames = files we should hash the contents of
|
||||
extra_strings = strings we should hash directly
|
||||
|
||||
Grep for callers to see examples of how this is used.
|
||||
|
||||
To elaborate on extra_strings, they will typically
|
||||
be things like:
|
||||
|
||||
- package versions (that we import)
|
||||
- settings values (that we stringify with
|
||||
json, deterministically)
|
||||
"""
|
||||
last_hash_path = os.path.join(get_dev_uuid_var_path(), hash_name)
|
||||
try:
|
||||
with open(last_hash_path) as f:
|
||||
old_hash = f.read()
|
||||
except FileNotFoundError:
|
||||
# This is normal for a fresh checkout--a missing
|
||||
# digest is an obsolete digest.
|
||||
return True
|
||||
|
||||
new_hash = files_and_string_digest(filenames, extra_strings)
|
||||
|
||||
return new_hash != old_hash
|
||||
|
||||
|
||||
def write_new_digest(
|
||||
hash_name: str, filenames: Sequence[str], extra_strings: Sequence[str] = []
|
||||
) -> None:
|
||||
hash_path = os.path.join(get_dev_uuid_var_path(), hash_name)
|
||||
new_hash = files_and_string_digest(filenames, extra_strings)
|
||||
with open(hash_path, "w") as f:
|
||||
f.write(new_hash)
|
||||
|
||||
# Be a little verbose here--our callers ensure we
|
||||
# only write new digests when things have changed, and
|
||||
# making this system more transparent to developers
|
||||
# can help them troubleshoot provisioning glitches.
|
||||
print("New digest written to: " + hash_path)
|
||||
|
||||
|
||||
def is_root() -> bool:
|
||||
if "posix" in os.name and os.geteuid() == 0:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def run_as_root(args: list[str], **kwargs: Any) -> None:
|
||||
sudo_args = kwargs.pop("sudo_args", [])
|
||||
if not is_root():
|
||||
args = ["sudo", *sudo_args, "--", *args]
|
||||
run(args, **kwargs)
|
||||
|
||||
|
||||
def assert_not_running_as_root() -> None:
|
||||
script_name = os.path.abspath(sys.argv[0])
|
||||
if is_root():
|
||||
pwent = get_zulip_pwent()
|
||||
msg = (
|
||||
f"{os.path.basename(script_name)} should not be run as root. Use `su {pwent.pw_name}` to switch to the 'zulip'\n"
|
||||
f"user before rerunning this, or use \n su {pwent.pw_name} -c '{script_name} ...'\n"
|
||||
"to switch users and run this as a single command."
|
||||
)
|
||||
print(msg)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def assert_running_as_root(strip_lib_from_paths: bool = False) -> None:
|
||||
script_name = os.path.abspath(sys.argv[0])
|
||||
# Since these Python scripts are run inside a thin shell wrapper,
|
||||
# we need to replace the paths in order to ensure we instruct
|
||||
# users to (re)run the right command.
|
||||
if strip_lib_from_paths:
|
||||
script_name = script_name.replace("scripts/lib/upgrade", "scripts/upgrade")
|
||||
if not is_root():
|
||||
print(f"{script_name} must be run as root.")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
@overload
|
||||
def get_config(
|
||||
config_file: configparser.RawConfigParser,
|
||||
section: str,
|
||||
key: str,
|
||||
default_value: None = None,
|
||||
) -> str | None: ...
|
||||
@overload
|
||||
def get_config(
|
||||
config_file: configparser.RawConfigParser,
|
||||
section: str,
|
||||
key: str,
|
||||
default_value: str,
|
||||
) -> str: ...
|
||||
@overload
|
||||
def get_config(
|
||||
config_file: configparser.RawConfigParser, section: str, key: str, default_value: bool
|
||||
) -> bool: ...
|
||||
def get_config(
|
||||
config_file: configparser.RawConfigParser,
|
||||
section: str,
|
||||
key: str,
|
||||
default_value: str | bool | None = None,
|
||||
) -> str | bool | None:
|
||||
if config_file.has_option(section, key):
|
||||
val = config_file.get(section, key)
|
||||
if isinstance(default_value, bool):
|
||||
# This list is parallel to puppet/zulip/lib/puppet/functions/zulipconf.rb
|
||||
return val.lower() in ["1", "y", "t", "true", "yes", "enable", "enabled"]
|
||||
return val
|
||||
return default_value
|
||||
|
||||
|
||||
def get_config_file() -> configparser.RawConfigParser:
|
||||
config_file = configparser.RawConfigParser()
|
||||
config_file.read("/etc/zulip/zulip.conf")
|
||||
return config_file
|
||||
|
||||
|
||||
def get_deploy_options(config_file: configparser.RawConfigParser) -> list[str]:
|
||||
return shlex.split(get_config(config_file, "deployment", "deploy_options", ""))
|
||||
|
||||
|
||||
def get_tornado_ports(config_file: configparser.RawConfigParser) -> list[int]:
|
||||
ports = []
|
||||
if config_file.has_section("tornado_sharding"):
|
||||
ports = sorted(
|
||||
{
|
||||
int(port)
|
||||
for key in config_file.options("tornado_sharding")
|
||||
for port in key.removesuffix("_regex").split("_")
|
||||
}
|
||||
)
|
||||
if not ports:
|
||||
ports = [9800]
|
||||
return ports
|
||||
|
||||
|
||||
def get_or_create_dev_uuid_var_path(path: str) -> str:
|
||||
absolute_path = f"{get_dev_uuid_var_path()}/{path}"
|
||||
os.makedirs(absolute_path, exist_ok=True)
|
||||
return absolute_path
|
||||
|
||||
|
||||
def is_vagrant_env_host(path: str) -> bool:
|
||||
return ".vagrant" in os.listdir(path)
|
||||
|
||||
|
||||
def has_application_server(once: bool = False) -> bool:
|
||||
if once:
|
||||
return os.path.exists("/etc/supervisor/conf.d/zulip/zulip-once.conf")
|
||||
return (
|
||||
# Current path
|
||||
os.path.exists("/etc/supervisor/conf.d/zulip/zulip.conf")
|
||||
# Old path, relevant for upgrades
|
||||
or os.path.exists("/etc/supervisor/conf.d/zulip.conf")
|
||||
)
|
||||
|
||||
|
||||
def has_process_fts_updates() -> bool:
|
||||
return (
|
||||
# Current path
|
||||
os.path.exists("/etc/supervisor/conf.d/zulip/zulip_db.conf")
|
||||
# Old path, relevant for upgrades
|
||||
or os.path.exists("/etc/supervisor/conf.d/zulip_db.conf")
|
||||
)
|
||||
|
||||
|
||||
def deport(netloc: str) -> str:
|
||||
"""Remove the port from a hostname:port string. Brackets on a literal
|
||||
IPv6 address are included."""
|
||||
r = SplitResult("", netloc, "", "", "")
|
||||
assert r.hostname is not None
|
||||
return "[" + r.hostname + "]" if ":" in r.hostname else r.hostname
|
||||
|
||||
|
||||
def start_script_arg_parser(action: str, add_help: bool = False) -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(add_help=add_help)
|
||||
parser.add_argument("--fill-cache", action="store_true", help="Fill the memcached caches")
|
||||
parser.add_argument(
|
||||
"--skip-checks", action="store_true", help="Skip syntax and database checks"
|
||||
)
|
||||
which_services = parser.add_mutually_exclusive_group()
|
||||
which_services.add_argument(
|
||||
"--skip-client-reloads",
|
||||
action="store_true",
|
||||
help="Do not send reload events to web clients",
|
||||
)
|
||||
which_services.add_argument(
|
||||
"--only-django",
|
||||
action="store_true",
|
||||
help=f"Only {action} Django (not Tornado or workers)",
|
||||
)
|
||||
which_services.add_argument(
|
||||
"--tornado-reshard",
|
||||
action="store_true",
|
||||
help="Restart changed Tornado shards",
|
||||
)
|
||||
if action == "restart":
|
||||
parser.add_argument(
|
||||
"--less-graceful",
|
||||
action="store_true",
|
||||
help="Restart with more concern for expediency than minimizing availability interruption",
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
def upgrade_script_arg_parser() -> argparse.ArgumentParser:
|
||||
"""Options shared by upgrade-zulip-stage-3 and its wrapper scripts.
|
||||
|
||||
Wrappers like upgrade-zulip-from-git use this as a parent parser so
|
||||
that --help advertises the same options stage-3 accepts, and forward
|
||||
the parsed values through to stage-3.
|
||||
"""
|
||||
parser = argparse.ArgumentParser(
|
||||
add_help=False, parents=[start_script_arg_parser(action="restart", add_help=False)]
|
||||
)
|
||||
parser.add_argument(
|
||||
"--skip-restart",
|
||||
action="store_true",
|
||||
help="Configure, but do not restart into, the new version; aborts if any system-wide changes would happen.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--skip-puppet", action="store_true", help="Skip doing puppet/apt upgrades."
|
||||
)
|
||||
parser.add_argument("--skip-migrations", action="store_true", help="Skip doing migrations.")
|
||||
parser.add_argument(
|
||||
"--skip-downgrade-check",
|
||||
action="store_true",
|
||||
help="Skip the safety check to prevent database downgrades.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--ignore-static-assets",
|
||||
action="store_true",
|
||||
help="Do not attempt to copy/manage static assets.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--skip-purge-old-deployments",
|
||||
action="store_true",
|
||||
help="Skip purging old deployments.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--audit-fts-indexes",
|
||||
action="store_true",
|
||||
help="Audit and fix full text search indexes.",
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
def atomic_nagios_write(
|
||||
name: str,
|
||||
status: Literal["ok", "warning", "critical", "unknown"],
|
||||
message: str | None = None,
|
||||
event_time: int | None = None,
|
||||
) -> int:
|
||||
if message is None:
|
||||
message = status
|
||||
if event_time is None:
|
||||
event_time = int(time.time())
|
||||
if status == "ok":
|
||||
status_int = 0
|
||||
elif status == "warning":
|
||||
status_int = 1
|
||||
elif status == "critical":
|
||||
status_int = 2
|
||||
elif status == "unknown":
|
||||
status_int = 3
|
||||
|
||||
path = "/var/lib/nagios_state/" + name
|
||||
with open(path + ".tmp", "w") as fh:
|
||||
fh.write("|".join([str(event_time), str(status_int), status, message]) + "\n")
|
||||
os.rename(path + ".tmp", path)
|
||||
|
||||
# Return code should be if the cron job ran to completion
|
||||
# successfully, not if the result of the check was outside of
|
||||
# bounds ("ok" / "critical"); this prevents the Sentry cron
|
||||
# wrapper from spamming with a "failure" email if the nagios check
|
||||
# requires multiple failures in a row.
|
||||
if status == "unknown":
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
cmd = sys.argv[1]
|
||||
if cmd == "make_deploy_path":
|
||||
print(make_deploy_path())
|
||||
elif cmd == "get_dev_uuid":
|
||||
print(get_dev_uuid_var_path())
|
||||
595
scripts/log-search
Executable file
595
scripts/log-search
Executable file
@@ -0,0 +1,595 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import argparse
|
||||
import calendar
|
||||
import gzip
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import signal
|
||||
import statistics
|
||||
import sys
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
from enum import Enum, auto
|
||||
from re import Match
|
||||
from typing import TextIO
|
||||
|
||||
ZULIP_PATH = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
sys.path.append(ZULIP_PATH)
|
||||
|
||||
from scripts.lib.setup_path import setup_path
|
||||
|
||||
setup_path()
|
||||
|
||||
os.environ["DJANGO_SETTINGS_MODULE"] = "zproject.settings"
|
||||
|
||||
from typing import Protocol
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from scripts.lib.zulip_tools import (
|
||||
BOLD,
|
||||
CYAN,
|
||||
ENDC,
|
||||
FAIL,
|
||||
GRAY,
|
||||
OKBLUE,
|
||||
get_config,
|
||||
get_config_file,
|
||||
)
|
||||
|
||||
|
||||
def parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description="Search logfiles, ignoring commonly-fetched URLs.")
|
||||
log_selection = parser.add_argument_group("File selection")
|
||||
log_selection_options = log_selection.add_mutually_exclusive_group()
|
||||
access_log_retention_days = int(
|
||||
get_config(get_config_file(), "application_server", "access_log_retention_days", "14")
|
||||
)
|
||||
log_selection_options.add_argument(
|
||||
"--log-files",
|
||||
"-n",
|
||||
help="Number of log files to search",
|
||||
choices=range(1, access_log_retention_days + 2),
|
||||
metavar=f"[1-{access_log_retention_days + 1}]",
|
||||
type=int,
|
||||
)
|
||||
log_selection_options.add_argument(
|
||||
"--all-logs",
|
||||
"-A",
|
||||
help="Parse all logfiles, not just most recent",
|
||||
action="store_true",
|
||||
)
|
||||
log_selection_options.add_argument(
|
||||
"--min-hours",
|
||||
"-H",
|
||||
help="Estimated minimum number of hours; includes previous log file, if estimated less than this",
|
||||
type=int,
|
||||
choices=range(24),
|
||||
default=3,
|
||||
)
|
||||
log_selection.add_argument(
|
||||
"--nginx",
|
||||
"-N",
|
||||
help="Parse from NGINX logs, not server.log",
|
||||
action="store_true",
|
||||
)
|
||||
|
||||
filtering = parser.add_argument_group("Filtering")
|
||||
filtering.add_argument(
|
||||
"filter_terms",
|
||||
help="IP address, hostname, user-id, HTTP method, path, datetime prefix, or status code to search for; multiple are AND'ed together",
|
||||
nargs="+",
|
||||
)
|
||||
filtering.add_argument(
|
||||
"--all-lines",
|
||||
"-L",
|
||||
help="Show all matching lines; equivalent to -suaemtpr",
|
||||
action="store_true",
|
||||
)
|
||||
filtering.add_argument("--static", "-s", help="Include static file paths", action="store_true")
|
||||
filtering.add_argument("--uploads", "-u", help="Include file upload paths", action="store_true")
|
||||
filtering.add_argument("--avatars", "-a", help="Include avatar paths", action="store_true")
|
||||
filtering.add_argument("--events", "-e", help="Include event fetch paths", action="store_true")
|
||||
filtering.add_argument("--messages", "-m", help="Include message paths", action="store_true")
|
||||
filtering.add_argument(
|
||||
"--typing",
|
||||
"-t",
|
||||
help="Include typing notification path",
|
||||
action="store_true",
|
||||
)
|
||||
filtering.add_argument("--presence", "-p", help="Include presence paths", action="store_true")
|
||||
filtering.add_argument(
|
||||
"--report", "-r", help="Include Sentry reporting paths", action="store_true"
|
||||
)
|
||||
filtering.add_argument(
|
||||
"--no-other", "-O", help="Exclude paths not explicitly included", action="store_true"
|
||||
)
|
||||
filtering.add_argument(
|
||||
"--client",
|
||||
"--user-agent",
|
||||
"-C",
|
||||
help="Only include requests whose client/user-agent contains this string",
|
||||
)
|
||||
filtering.add_argument(
|
||||
"--extra",
|
||||
"-X",
|
||||
help="Only include requests whose logline includes this in [extra] flags",
|
||||
)
|
||||
|
||||
output = parser.add_argument_group("Output")
|
||||
output.add_argument("--full-line", "-F", help="Show full matching line", action="store_true")
|
||||
output.add_argument("--timeline", "-T", help="Show start, end, and gaps", action="store_true")
|
||||
output.add_argument("--stats", "-S", help="Compute and show statistics", action="store_true")
|
||||
return parser
|
||||
|
||||
|
||||
def maybe_gzip(logfile_name: str) -> TextIO:
|
||||
if logfile_name.endswith(".gz"):
|
||||
return gzip.open(logfile_name, "rt")
|
||||
return open(logfile_name)
|
||||
|
||||
|
||||
NGINX_LOG_LINE_RE = re.compile(
|
||||
r"""
|
||||
(?P<ip> \S+ ) \s+
|
||||
- \s+
|
||||
(?P<user> \S+ ) \s+
|
||||
\[
|
||||
(?P<date> \d+/\w+/\d+ )
|
||||
:
|
||||
(?P<time> \d+:\d+:\d+ )
|
||||
\s+ [+-]\d+
|
||||
\] \s+
|
||||
"
|
||||
(?P<method> \S+ )
|
||||
\s+
|
||||
(?P<full_path> (?P<path> [^"?]+ ) (\?[^"]*)? )
|
||||
\s+
|
||||
(?P<http_version> HTTP/[^"]+ )
|
||||
" \s+
|
||||
(?P<code> \d+ ) \s+
|
||||
(?P<bytes> \d+ ) \s+
|
||||
"(?P<referer> [^"]* )" \s+
|
||||
"(?P<user_agent> [^"]* )" \s+
|
||||
(?P<hostname> \S+ ) \s+
|
||||
(?P<duration> \S+ )
|
||||
""",
|
||||
re.VERBOSE,
|
||||
)
|
||||
|
||||
PYTHON_LOG_LINE_RE = re.compile(
|
||||
r"""
|
||||
(?P<date> \d+-\d+-\d+ ) \s+
|
||||
(?P<time> \d+:\d+:\d+\.\d+ ) \s+
|
||||
INFO \s+ # All access log lines are INFO
|
||||
(pid:\d+ \s+) ?
|
||||
\[ (?P<source> zr(:\d+)?) \] \s+
|
||||
(?P<ip>
|
||||
\d{1,3}(\.\d{1,3}){3}
|
||||
| ([a-f0-9:]+:+){1,7}[a-f0-9]*
|
||||
) \s+
|
||||
(?P<method> [A-Z]+ ) \s+
|
||||
(?P<code> \d+ ) \s+
|
||||
(?P<duration> \S+ ) \s+ # This can be "217ms" or "1.7s"
|
||||
( \( (?P<perf> [^)]+ ) \) \s+ )*
|
||||
(?P<full_path> (?P<path> /\S* ) ) \s+
|
||||
(?P<extra> .* ) # Multiple extra things can go here
|
||||
\(
|
||||
(?P<user>
|
||||
( (?P<user_id> \d+ ) | unauth )
|
||||
@
|
||||
(?P<hostname> \S+ )
|
||||
| zulip-server:\S+
|
||||
| scim-client:\S+
|
||||
| internal
|
||||
) \s+ via \s+ (?P<user_agent> .* )
|
||||
\)
|
||||
""",
|
||||
re.VERBOSE,
|
||||
)
|
||||
|
||||
|
||||
class FilterType(Enum):
|
||||
HOSTNAME = auto()
|
||||
CLIENT_IP = auto()
|
||||
USER_ID = auto()
|
||||
AUTHED = auto()
|
||||
METHOD = auto()
|
||||
PATH = auto()
|
||||
STATUS = auto()
|
||||
DATETIME = auto()
|
||||
|
||||
|
||||
class FilterFunc(Protocol):
|
||||
def __call__(self, m: Match[str], t: str = ...) -> bool: ...
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = parser().parse_args()
|
||||
|
||||
(filter_types, filter_funcs, substr_terms) = parse_filters(args)
|
||||
logfile_names = parse_logfile_names(args)
|
||||
if args.timeline and args.nginx:
|
||||
print("! nginx logs not suggested for timeline, due to imprecision", file=sys.stderr)
|
||||
|
||||
use_color = sys.stdout.isatty()
|
||||
lowered_terms = [term.lower() for term in substr_terms]
|
||||
if args.stats:
|
||||
durations: list[int] | None = []
|
||||
else:
|
||||
durations = None
|
||||
try:
|
||||
for logfile_name in reversed(logfile_names):
|
||||
with maybe_gzip(logfile_name) as logfile:
|
||||
for logline in logfile:
|
||||
# As a performance optimization, just do a substring
|
||||
# check before we parse the line fully
|
||||
lowered = logline.lower()
|
||||
if not all(f in lowered for f in lowered_terms):
|
||||
continue
|
||||
|
||||
if args.nginx:
|
||||
match = NGINX_LOG_LINE_RE.match(logline)
|
||||
else:
|
||||
match = PYTHON_LOG_LINE_RE.match(logline)
|
||||
if match is None:
|
||||
# We expect other types of loglines in the Python logfiles
|
||||
if args.nginx:
|
||||
print(f"! Failed to parse:\n{logline}", file=sys.stderr)
|
||||
continue
|
||||
if passes_filters(filter_funcs, match, args):
|
||||
print_line(
|
||||
match,
|
||||
args,
|
||||
filter_types=filter_types,
|
||||
use_color=use_color,
|
||||
durations=durations,
|
||||
)
|
||||
except BrokenPipeError:
|
||||
# Python flushes standard streams on exit; redirect remaining output
|
||||
# to devnull to avoid another BrokenPipeError at shutdown
|
||||
devnull = os.open(os.devnull, os.O_WRONLY)
|
||||
os.dup2(devnull, sys.stdout.fileno())
|
||||
sys.exit(1)
|
||||
except KeyboardInterrupt:
|
||||
sys.exit(signal.SIGINT + 128)
|
||||
|
||||
if durations is not None:
|
||||
# Prepend [0] to make the percentiles 1-indexed, instead of 0-indexed
|
||||
percentiles = [0, *statistics.quantiles(durations, n=100)]
|
||||
print()
|
||||
print(f"Total requests: {len(durations)}")
|
||||
print(f"Min duration: {min(durations):>5}ms")
|
||||
print(f"p50 duration: {int(percentiles[50]):>5}ms")
|
||||
print(f"p75 duration: {int(percentiles[75]):>5}ms")
|
||||
print(f"p90 duration: {int(percentiles[90]):>5}ms")
|
||||
print(f"p95 duration: {int(percentiles[95]):>5}ms")
|
||||
print(f"p99 duration: {int(percentiles[99]):>5}ms")
|
||||
print(f"Max duration: {max(durations):>5}ms")
|
||||
|
||||
|
||||
def parse_logfile_names(args: argparse.Namespace) -> list[str]:
|
||||
if args.nginx:
|
||||
base_path = "/var/log/nginx/access.log"
|
||||
else:
|
||||
base_path = "/var/log/zulip/server.log"
|
||||
|
||||
for term in args.filter_terms:
|
||||
date_term = re.match(r"2\d\d\d-\d\d-\d\d", term)
|
||||
if not date_term:
|
||||
continue
|
||||
# They're limiting to a specific day; find the right logfile
|
||||
# which is going to have any matches
|
||||
rotations = int(
|
||||
(datetime.now(tz=timezone.utc).date() - date.fromisoformat(date_term[0]))
|
||||
/ timedelta(days=1)
|
||||
)
|
||||
access_log_retention_days = int(
|
||||
get_config(get_config_file(), "application_server", "access_log_retention_days", "14")
|
||||
)
|
||||
if rotations > access_log_retention_days:
|
||||
raise RuntimeError(f"Date is too old (more than {access_log_retention_days} days ago)")
|
||||
if rotations == 0:
|
||||
return [base_path]
|
||||
if rotations == 1:
|
||||
return [f"{base_path}.1"]
|
||||
else:
|
||||
return [f"{base_path}.{rotations}.gz"]
|
||||
|
||||
logfile_names = [base_path]
|
||||
if args.all_logs:
|
||||
logfile_count = 15
|
||||
elif args.log_files is not None:
|
||||
logfile_count = args.log_files
|
||||
else:
|
||||
# Detect if there was a logfile rotation in the last
|
||||
# (min-hours)-ish hours, and if so include the previous
|
||||
# logfile as well.
|
||||
logfile_count = 1
|
||||
try:
|
||||
current_size = os.path.getsize(base_path)
|
||||
past_size = os.path.getsize(base_path + ".1")
|
||||
if current_size < (args.min_hours / 24.0) * past_size:
|
||||
logfile_count = 2
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
for n in range(1, logfile_count):
|
||||
logname = f"{base_path}.{n}"
|
||||
if n > 1:
|
||||
logname += ".gz"
|
||||
logfile_names.append(logname)
|
||||
return logfile_names
|
||||
|
||||
|
||||
month_no_to_name_lookup = {f"{k:02d}": v for k, v in enumerate(calendar.month_abbr)}
|
||||
month_name_to_no_lookup = {v: k for k, v in month_no_to_name_lookup.items()}
|
||||
|
||||
|
||||
def convert_from_nginx_date(date: str) -> str:
|
||||
day_of_month, month_abbr, year = date.split("/")
|
||||
return f"{year}-{month_name_to_no_lookup[month_abbr]}-{day_of_month}"
|
||||
|
||||
|
||||
def convert_to_nginx_date(date: str) -> str:
|
||||
year, month_no, day_of_month = date.split("-")
|
||||
return f"{day_of_month}/{month_no_to_name_lookup[month_no]}/{year}"
|
||||
|
||||
|
||||
def parse_filters(
|
||||
args: argparse.Namespace,
|
||||
) -> tuple[set[FilterType], list[FilterFunc], list[str]]:
|
||||
# The heuristics below are not intended to be precise -- they
|
||||
# certainly count things as "IPv4" or "IPv6" addresses that are
|
||||
# invalid. However, we expect the input here to already be
|
||||
# reasonably well-formed.
|
||||
|
||||
filter_types = set()
|
||||
filter_funcs = []
|
||||
filter_terms = []
|
||||
|
||||
if args.events and not args.nginx:
|
||||
logging.warning("Adding --nginx -- /events requests do not appear in Django logs.")
|
||||
args.nginx = True
|
||||
|
||||
for filter_term in args.filter_terms:
|
||||
if re.match(r"[1-5][0-9][0-9]$", filter_term):
|
||||
filter_func = lambda m, t=filter_term: m["code"] == t
|
||||
filter_type = FilterType.STATUS
|
||||
if not args.nginx and filter_term == "502":
|
||||
logging.warning("Adding --nginx -- 502's do not appear in Django logs.")
|
||||
args.nginx = True
|
||||
elif re.match(r"[1-5]xx$", filter_term):
|
||||
filter_term = filter_term[0]
|
||||
filter_func = lambda m, t=filter_term: m["code"].startswith(t)
|
||||
filter_type = FilterType.STATUS
|
||||
elif re.match(r"\d+$", filter_term):
|
||||
if args.nginx:
|
||||
raise parser().error("Cannot parse user-ids with nginx logs; try without --nginx")
|
||||
filter_func = lambda m, t=filter_term: m["user_id"] == t
|
||||
filter_type = FilterType.USER_ID
|
||||
elif re.match(r"\d{1,3}(\.\d{1,3}){3}$", filter_term):
|
||||
filter_func = lambda m, t=filter_term: m["ip"] == t
|
||||
filter_type = FilterType.CLIENT_IP
|
||||
elif re.match(r"([a-f0-9:]+:+){1,7}[a-f0-9]+$", filter_term):
|
||||
filter_func = lambda m, t=filter_term: m["ip"] == t
|
||||
filter_type = FilterType.CLIENT_IP
|
||||
elif re.match(r"DELETE|GET|HEAD|OPTIONS|PATCH|POST|PUT", filter_term):
|
||||
filter_func = lambda m, t=filter_term: m["method"].upper() == t
|
||||
filter_type = FilterType.METHOD
|
||||
elif re.match(r"(2\d\d\d-\d\d-\d\d)( \d(\d(:(\d(\d(:(\d\d?)?)?)?)?)?)?)?", filter_term):
|
||||
if args.nginx:
|
||||
datetime_parts = filter_term.split(" ")
|
||||
filter_term = ":".join(
|
||||
[convert_to_nginx_date(datetime_parts[0]), *datetime_parts[1:]]
|
||||
)
|
||||
filter_func = lambda m, t=filter_term: f"{m['date']}:{m['time']}".startswith(t)
|
||||
else:
|
||||
filter_func = lambda m, t=filter_term: f"{m['date']} {m['time']}".startswith(t)
|
||||
filter_type = FilterType.DATETIME
|
||||
elif filter_term in ["authed", "unauthed"]:
|
||||
if args.nginx:
|
||||
raise parser().error("Cannot parse user-ids with nginx logs; try without --nginx")
|
||||
filter_type = FilterType.USER_ID
|
||||
if filter_term == "authed":
|
||||
filter_func = lambda m, t=None: m["user_id"] is not None and m["user_id"] != ""
|
||||
filter_term = None
|
||||
filter_types.add(FilterType.AUTHED)
|
||||
else:
|
||||
filter_func = lambda m, t=None: m["user_id"] is None
|
||||
filter_term = "(unauth@"
|
||||
elif re.match(r"[a-z0-9]([a-z0-9-]*[a-z0-9])?$", filter_term.lower()):
|
||||
filter_term = filter_term.lower()
|
||||
if args.nginx:
|
||||
filter_func = lambda m, t=filter_term: m["hostname"].startswith(t + ".")
|
||||
else:
|
||||
filter_func = lambda m, t=filter_term: m["hostname"] == t
|
||||
filter_type = FilterType.HOSTNAME
|
||||
elif re.match(r"[a-z0-9-]+(\.[a-z0-9-]+)+$", filter_term.lower()) and re.search(
|
||||
r"[a-z-]", filter_term.lower()
|
||||
):
|
||||
if not args.nginx:
|
||||
raise parser().error("Cannot parse full domains with Python logs; try --nginx")
|
||||
filter_term = filter_term.lower()
|
||||
filter_func = lambda m, t=filter_term: m["hostname"] == t
|
||||
filter_type = FilterType.HOSTNAME
|
||||
elif re.match(r"/\S*$", filter_term):
|
||||
filter_func = lambda m, t=filter_term: m["path"] == t
|
||||
filter_type = FilterType.PATH
|
||||
args.all_lines = True
|
||||
else:
|
||||
raise RuntimeError(
|
||||
f"Can't parse {filter_term} as an IP, hostname, user-id, HTTP method, path, or status code."
|
||||
)
|
||||
if filter_type in filter_types:
|
||||
parser().error("Supplied the same time of value more than once, which cannot match!")
|
||||
filter_types.add(filter_type)
|
||||
filter_funcs.append(filter_func)
|
||||
if filter_term is not None:
|
||||
filter_terms.append(filter_term)
|
||||
|
||||
if args.client:
|
||||
filter_funcs.append(lambda m, t=args.client: t in m["user_agent"])
|
||||
filter_terms.append(args.client)
|
||||
|
||||
if args.extra:
|
||||
if args.nginx:
|
||||
raise parser().error("nginx logs do not contain [extra] data; try without --nginx")
|
||||
filter_funcs.append(lambda m, t=args.extra: t in m["extra"])
|
||||
filter_terms.append(args.extra)
|
||||
|
||||
return (filter_types, filter_funcs, filter_terms)
|
||||
|
||||
|
||||
def passes_filters(
|
||||
string_filters: list[FilterFunc],
|
||||
match: Match[str],
|
||||
args: argparse.Namespace,
|
||||
) -> bool:
|
||||
if not all(f(match) for f in string_filters):
|
||||
return False
|
||||
|
||||
if args.all_lines:
|
||||
return True
|
||||
|
||||
path = match["path"]
|
||||
if path.startswith("/static/"):
|
||||
return args.static
|
||||
elif path.startswith("/user_uploads/"):
|
||||
return args.uploads
|
||||
elif path.startswith(("/user_avatars/", "/avatar/")):
|
||||
return args.avatars
|
||||
elif re.match(r"/(json|api/v1)/events($|\?|/)", path):
|
||||
return args.events
|
||||
elif path in ("/api/v1/typing", "/json/typing"):
|
||||
return args.typing
|
||||
elif re.match(r"/(json|api/v1)/messages($|\?|/)", path):
|
||||
return args.messages
|
||||
elif path in ("/api/v1/users/me/presence", "/json/users/me/presence"):
|
||||
return args.presence
|
||||
elif path == "/error_tracing":
|
||||
return args.report
|
||||
else:
|
||||
return not args.no_other
|
||||
|
||||
|
||||
last_match_end: datetime | None = None
|
||||
|
||||
|
||||
def print_line(
|
||||
match: Match[str],
|
||||
args: argparse.Namespace,
|
||||
filter_types: set[FilterType],
|
||||
use_color: bool,
|
||||
durations: list[int] | None = None,
|
||||
) -> None:
|
||||
global last_match_end
|
||||
|
||||
if match["duration"].endswith("ms"):
|
||||
duration_ms = int(match["duration"].removesuffix("ms"))
|
||||
else:
|
||||
duration_ms = int(float(match["duration"].removesuffix("s")) * 1000)
|
||||
if durations is not None:
|
||||
durations.append(duration_ms)
|
||||
|
||||
if args.full_line:
|
||||
print(match.group(0))
|
||||
return
|
||||
|
||||
if args.nginx:
|
||||
date = convert_from_nginx_date(match["date"])
|
||||
else:
|
||||
date = match["date"]
|
||||
if args.all_logs or (args.log_files is not None and args.log_files > 1):
|
||||
ts = date + " " + match["time"]
|
||||
else:
|
||||
ts = match["time"]
|
||||
|
||||
if match["duration"].endswith("ms"):
|
||||
duration_ms = int(match["duration"].removesuffix("ms"))
|
||||
else:
|
||||
duration_ms = int(float(match["duration"].removesuffix("s")) * 1000)
|
||||
|
||||
code = int(match["code"])
|
||||
indicator = " "
|
||||
color = ""
|
||||
if code == 401:
|
||||
indicator = ":"
|
||||
color = CYAN
|
||||
elif code == 499:
|
||||
indicator = "-"
|
||||
color = GRAY
|
||||
elif code >= 400 and code < 499:
|
||||
indicator = ">"
|
||||
color = OKBLUE
|
||||
elif code >= 500 and code <= 599:
|
||||
indicator = "!"
|
||||
color = FAIL
|
||||
|
||||
if use_color:
|
||||
url = f"{BOLD}{match['full_path']}"
|
||||
else:
|
||||
url = match["full_path"]
|
||||
color = ""
|
||||
|
||||
if FilterType.HOSTNAME not in filter_types:
|
||||
hostname = match["hostname"]
|
||||
if hostname is None:
|
||||
hostname = "???." + settings.EXTERNAL_HOST
|
||||
elif not args.nginx:
|
||||
if hostname != "root":
|
||||
hostname += "." + settings.EXTERNAL_HOST
|
||||
elif settings.EXTERNAL_HOST == "zulipchat.com":
|
||||
hostname = "zulip.com"
|
||||
else:
|
||||
hostname = settings.EXTERNAL_HOST
|
||||
url = "https://" + hostname + url
|
||||
|
||||
user_id = ""
|
||||
if not args.nginx and match["user_id"] is not None:
|
||||
user_id = match["user_id"] + "@"
|
||||
|
||||
if args.timeline:
|
||||
logline_end = datetime.fromisoformat(date + " " + match["time"])
|
||||
logline_start = logline_end - timedelta(milliseconds=duration_ms)
|
||||
if last_match_end is not None:
|
||||
gap_ms = int((logline_start - last_match_end) / timedelta(milliseconds=1))
|
||||
if gap_ms > 5000:
|
||||
print()
|
||||
print(f"========== {int(gap_ms / 1000):>4} second gap ==========")
|
||||
print()
|
||||
elif gap_ms > 1000:
|
||||
print(f"============ {gap_ms:>5}ms gap ============")
|
||||
elif gap_ms > 0:
|
||||
print(f"------------ {gap_ms:>5}ms gap ------------")
|
||||
else:
|
||||
print(f"!!!!!!!!!! {abs(gap_ms):>5}ms overlap !!!!!!!!!!")
|
||||
if args.all_logs or (args.log_files is not None and args.log_files > 1):
|
||||
print(logline_start.isoformat(" ", timespec="milliseconds") + " (start)")
|
||||
else:
|
||||
print(logline_start.time().isoformat(timespec="milliseconds") + " (start)")
|
||||
last_match_end = logline_end
|
||||
|
||||
if args.nginx:
|
||||
show_user = False
|
||||
elif FilterType.AUTHED in filter_types:
|
||||
show_user = True
|
||||
elif FilterType.USER_ID in filter_types:
|
||||
show_user = False
|
||||
else:
|
||||
show_user = True
|
||||
parts = [
|
||||
ts,
|
||||
f"{duration_ms:>5}ms",
|
||||
f"{user_id:7}" if show_user else None,
|
||||
f"{match['ip']:39}" if FilterType.CLIENT_IP not in filter_types else None,
|
||||
indicator + match["code"],
|
||||
f"{match['method']:6}",
|
||||
url,
|
||||
]
|
||||
|
||||
print(color + " ".join(p for p in parts if p is not None) + (ENDC if use_color else ""))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
78
scripts/nagios/check-rabbitmq-consumers
Executable file
78
scripts/nagios/check-rabbitmq-consumers
Executable file
@@ -0,0 +1,78 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from collections import defaultdict
|
||||
|
||||
ZULIP_PATH = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
sys.path.append(ZULIP_PATH)
|
||||
from scripts.lib.check_rabbitmq_queue import normal_queues
|
||||
from scripts.lib.zulip_tools import (
|
||||
atomic_nagios_write,
|
||||
get_config,
|
||||
get_config_file,
|
||||
get_tornado_ports,
|
||||
)
|
||||
|
||||
if "USER" in os.environ and os.environ["USER"] not in ["root", "rabbitmq"]:
|
||||
print("This script must be run as the root or rabbitmq user")
|
||||
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.parse_args()
|
||||
|
||||
config_file = get_config_file()
|
||||
TORNADO_PROCESSES = len(get_tornado_ports(config_file))
|
||||
|
||||
output = subprocess.check_output(["/usr/sbin/rabbitmqctl", "list_consumers"], text=True)
|
||||
|
||||
consumers: dict[str, int] = defaultdict(int)
|
||||
|
||||
queues = {
|
||||
*normal_queues,
|
||||
# All tornado queues get grouped into this, even if sharded
|
||||
"notify_tornado",
|
||||
}
|
||||
|
||||
for queue_name in queues:
|
||||
queue_name = queue_name.strip()
|
||||
consumers[queue_name] = 0
|
||||
|
||||
for line in output.split("\n"):
|
||||
parts = line.split("\t")
|
||||
if len(parts) >= 2:
|
||||
queue_name = parts[0]
|
||||
if queue_name.startswith("notify_tornado_"):
|
||||
queue_name = "notify_tornado"
|
||||
|
||||
# Collapse sharded queues into a single queue for this check.
|
||||
queue_name = re.sub(r"_shard(\d+)", "", queue_name)
|
||||
consumers[queue_name] += 1
|
||||
|
||||
now = int(time.time())
|
||||
|
||||
for queue_name, count in consumers.items():
|
||||
target_count = 1
|
||||
if queue_name == "notify_tornado":
|
||||
target_count = TORNADO_PROCESSES
|
||||
elif queue_name == "missedmessage_mobile_notifications":
|
||||
target_count = int(
|
||||
get_config(config_file, "application_server", "mobile_notification_shards", "1")
|
||||
)
|
||||
elif queue_name == "user_activity":
|
||||
target_count = int(
|
||||
get_config(config_file, "application_server", "user_activity_shards", "1")
|
||||
)
|
||||
else:
|
||||
target_count = int(
|
||||
get_config(config_file, "application_server", f"{queue_name}_workers", "1")
|
||||
)
|
||||
|
||||
atomic_nagios_write(
|
||||
"check-rabbitmq-consumers-" + queue_name,
|
||||
"critical" if count < target_count else "ok",
|
||||
f"queue {queue_name} has {count} consumers, needs {target_count}",
|
||||
)
|
||||
12
scripts/nagios/check-rabbitmq-queue
Executable file
12
scripts/nagios/check-rabbitmq-queue
Executable file
@@ -0,0 +1,12 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
ZULIP_PATH = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
sys.path.append(ZULIP_PATH)
|
||||
|
||||
from scripts.lib.check_rabbitmq_queue import check_rabbitmq_queues
|
||||
|
||||
if __name__ == "__main__":
|
||||
check_rabbitmq_queues()
|
||||
101
scripts/purge-old-deployments
Executable file
101
scripts/purge-old-deployments
Executable file
@@ -0,0 +1,101 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
ZULIP_PATH = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
sys.path.append(ZULIP_PATH)
|
||||
from scripts.lib import clean_unused_caches
|
||||
from scripts.lib.zulip_tools import (
|
||||
DEPLOYMENTS_DIR,
|
||||
get_recent_deployments,
|
||||
maybe_perform_purging,
|
||||
su_to_zulip,
|
||||
)
|
||||
|
||||
LOCAL_GIT_CACHE_DIR = "/srv/zulip.git"
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="This script can be used for cleaning old unused deployments.",
|
||||
epilog="Orphaned/unused caches older than threshold days will be automatically "
|
||||
"examined and removed.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--threshold",
|
||||
dest="threshold_days",
|
||||
type=int,
|
||||
default=14,
|
||||
metavar="<days>",
|
||||
help="Deployments older than threshold days will be deleted. (defaults to 14)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
help="If specified then script will only print the deployments and "
|
||||
"caches that it will delete/keep back. It will not delete anything.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--verbose",
|
||||
action="store_true",
|
||||
help="If specified then script will print a detailed report of what is going on.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-print-headings",
|
||||
dest="no_headings",
|
||||
action="store_true",
|
||||
help="If specified then script will not print headings for what will be deleted/kept back.",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
args.verbose |= args.dry_run # Always print a detailed report in case of dry run.
|
||||
return args
|
||||
|
||||
|
||||
def get_deployments_to_be_purged(recent_deployments: set[str]) -> set[str]:
|
||||
all_deployments = {
|
||||
os.path.join(DEPLOYMENTS_DIR, deployment) for deployment in os.listdir(DEPLOYMENTS_DIR)
|
||||
}
|
||||
deployments_to_purge = set()
|
||||
for deployment in all_deployments:
|
||||
# Deployments whose name is not in the format of a timestamp are
|
||||
# always included in the recent_deployments and are not deleted.
|
||||
if not os.path.isdir(deployment):
|
||||
# Skip things like uwsgi sockets.
|
||||
continue
|
||||
if not os.path.exists(os.path.join(deployment, "zerver")):
|
||||
# Skip things like "lock" that aren't actually a deployment directory
|
||||
continue
|
||||
if deployment not in recent_deployments:
|
||||
deployments_to_purge.add(deployment)
|
||||
return deployments_to_purge
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = parse_args()
|
||||
deployments_to_keep = get_recent_deployments(args.threshold_days)
|
||||
deployments_to_purge = get_deployments_to_be_purged(deployments_to_keep)
|
||||
|
||||
maybe_perform_purging(
|
||||
deployments_to_purge, deployments_to_keep, "deployment", args.dry_run, args.verbose, True
|
||||
)
|
||||
|
||||
if not args.dry_run:
|
||||
if os.path.exists(LOCAL_GIT_CACHE_DIR):
|
||||
subprocess.check_call(
|
||||
["git", "worktree", "prune"], cwd=LOCAL_GIT_CACHE_DIR, preexec_fn=su_to_zulip
|
||||
)
|
||||
|
||||
if not args.no_headings:
|
||||
print("Deployments cleaned successfully...")
|
||||
print("Cleaning orphaned/unused caches...")
|
||||
|
||||
# Call 'clean_unused_caches.py' script to clean any orphaned/unused caches.
|
||||
clean_unused_caches.main(args)
|
||||
print("Done!")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
29
scripts/refresh-sharding-and-restart
Executable file
29
scripts/refresh-sharding-and-restart
Executable file
@@ -0,0 +1,29 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -e
|
||||
|
||||
# Stand up the new zulip-tornado supervisor instances, and write out
|
||||
# the newly generated config files, with .tmp suffix
|
||||
SUPPRESS_SHARDING_NOTICE=1 "$(dirname "$0")/zulip-puppet-apply" -f
|
||||
|
||||
# Verify, before we move them into place
|
||||
if ! [ -e /etc/zulip/nginx_sharding_map.conf.tmp ] || ! [ -e /etc/zulip/sharding.json.tmp ]; then
|
||||
echo "No sharding updates found to apply."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# In the ordering of operations below, the crucial detail is that
|
||||
# Django, Tornado, and workers need to be restarted before reloading
|
||||
# nginx. Django and Tornado have in-memory maps of which realm belongs
|
||||
# to which shard. Reloading nginx will cause users' tornado requests
|
||||
# to be routed according to the new sharding scheme. If that happens
|
||||
# before Django is restarted, updating its realm->shard map, users on
|
||||
# realms whose shard has changed will have their tornado requests
|
||||
# handled by the new tornado process, while Django will still use the
|
||||
# old process for its internal communication with tornado when
|
||||
# servicing the user's requests. That's a bad state that leads to
|
||||
# clients getting into reload loops ending in crashing on 500 response
|
||||
# while Django is restarting. For this reason it's important to
|
||||
# reload nginx only after Django and Tornado.
|
||||
"$(dirname "$0")/restart-server" --tornado-reshard
|
||||
service nginx reload
|
||||
105
scripts/reload-clients
Executable file
105
scripts/reload-clients
Executable file
@@ -0,0 +1,105 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import configparser
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
sys.path.append(os.path.join(os.path.dirname(__file__), ".."))
|
||||
from scripts.lib.setup_path import setup_path
|
||||
|
||||
setup_path()
|
||||
|
||||
import requests
|
||||
from requests.adapters import HTTPAdapter
|
||||
from urllib3.util import Retry
|
||||
|
||||
from scripts.lib.zulip_tools import get_config, get_config_file, get_tornado_ports
|
||||
|
||||
config_file = get_config_file()
|
||||
reload_rate = int(
|
||||
get_config(
|
||||
config_file,
|
||||
"application_server",
|
||||
"client_reload_rate",
|
||||
"50",
|
||||
)
|
||||
)
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument(
|
||||
"--rate", type=int, help="Number of clients to reload per second", default=reload_rate
|
||||
)
|
||||
parser.add_argument("--background", action="store_true", help="Daemonize the process")
|
||||
|
||||
args = parser.parse_args()
|
||||
reload_rate = args.rate
|
||||
|
||||
secret_config_file = configparser.RawConfigParser()
|
||||
secret_config_file.read("/etc/zulip/zulip-secrets.conf")
|
||||
shared_secret = get_config(secret_config_file, "secrets", "shared_secret")
|
||||
assert shared_secret
|
||||
|
||||
# Perform relatively slow retries (2s, 4s, 8s) with backoff, including
|
||||
# on POST requests. Failure to send this request successfully means
|
||||
# that clients may fail to reload, so we want to be somewhat resilient
|
||||
# to failures. Since we are on localhost, we do not expect network
|
||||
# failures, only Tornado restarts, to cause failures here.
|
||||
retry = Retry(total=3, backoff_factor=1, allowed_methods=Retry.DEFAULT_ALLOWED_METHODS | {"POST"})
|
||||
c = requests.Session()
|
||||
c.mount("http://", HTTPAdapter(max_retries=retry))
|
||||
|
||||
log_filename = None
|
||||
if args.background:
|
||||
# Double-fork to daemonize
|
||||
pid = os.fork()
|
||||
if pid > 0:
|
||||
sys.exit(0)
|
||||
os.setsid()
|
||||
pid = os.fork()
|
||||
if pid > 0:
|
||||
sys.exit(0)
|
||||
log_filename = "/var/log/zulip/reload-clients.log"
|
||||
|
||||
logging.Formatter.converter = time.gmtime
|
||||
logging.basicConfig(
|
||||
format="%(asctime)s reload-clients: %(message)s",
|
||||
level=logging.INFO,
|
||||
filename=log_filename,
|
||||
)
|
||||
|
||||
first = True
|
||||
server_total = 0
|
||||
for port in get_tornado_ports(config_file):
|
||||
logging.info("Starting to send client reload events to Tornado port %d", port)
|
||||
try:
|
||||
shard_total = 0
|
||||
complete = False
|
||||
# Rather than make a sustained one request per second, we batch
|
||||
# into 5-second chunks of 5 times the client_reload_rate
|
||||
SECONDS_PER_BATCH = 5
|
||||
while not complete:
|
||||
if not first:
|
||||
time.sleep(SECONDS_PER_BATCH)
|
||||
first = False
|
||||
|
||||
logging.info("Sending reload events to %d clients", reload_rate * SECONDS_PER_BATCH)
|
||||
resp = c.post(
|
||||
f"http://127.0.0.1:{port}/api/internal/web_reload_clients",
|
||||
data={"client_count": reload_rate * SECONDS_PER_BATCH, "secret": shared_secret},
|
||||
timeout=5,
|
||||
proxies={"http": ""}, # Make sure we don't go through Smokescreen
|
||||
)
|
||||
resp.raise_for_status()
|
||||
shard_total += resp.json()["sent_events"]
|
||||
complete = resp.json()["complete"]
|
||||
logging.info("Sent %d reload events to Tornado port %d", shard_total, port)
|
||||
server_total += shard_total
|
||||
except requests.exceptions.HTTPError:
|
||||
# Failures in one shard likely won't affect other shards --
|
||||
# give up on this shard, and try the next one,
|
||||
logging.exception("Failed to send web_reload_clients request to Tornado port %d", port)
|
||||
|
||||
if len(get_tornado_ports(config_file)) > 1:
|
||||
logging.info("Sent total of %d reload events, across all Tornado instances", server_total)
|
||||
337
scripts/restart-server
Executable file
337
scripts/restart-server
Executable file
@@ -0,0 +1,337 @@
|
||||
#!/usr/bin/env python3
|
||||
import contextlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import pwd
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
sys.path.append(os.path.join(os.path.dirname(__file__), ".."))
|
||||
from scripts.lib.setup_path import setup_path
|
||||
|
||||
setup_path()
|
||||
|
||||
from scripts.lib.supervisor import list_supervisor_processes
|
||||
from scripts.lib.zulip_tools import (
|
||||
DEPLOYMENTS_DIR,
|
||||
ENDC,
|
||||
LOCK_DIR,
|
||||
OKGREEN,
|
||||
WARNING,
|
||||
get_config,
|
||||
get_config_file,
|
||||
get_tornado_ports,
|
||||
has_application_server,
|
||||
has_process_fts_updates,
|
||||
overwrite_symlink,
|
||||
start_script_arg_parser,
|
||||
su_to_zulip,
|
||||
)
|
||||
|
||||
action = "restart"
|
||||
if not sys.argv[0].endswith("restart-server"):
|
||||
action = "start"
|
||||
verbing = action.title() + "ing"
|
||||
|
||||
logging.Formatter.converter = time.gmtime
|
||||
logging.basicConfig(format=f"%(asctime)s {action}-server: %(message)s", level=logging.INFO)
|
||||
|
||||
parser = start_script_arg_parser(action=action, add_help=True)
|
||||
args = parser.parse_args()
|
||||
|
||||
deploy_path = os.path.realpath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
os.chdir(deploy_path)
|
||||
|
||||
username = pwd.getpwuid(os.getuid()).pw_name
|
||||
if username == "root":
|
||||
su_to_zulip()
|
||||
elif username != "zulip":
|
||||
logging.error("Must be run as user 'zulip'.")
|
||||
sys.exit(1)
|
||||
|
||||
if os.environ.get("RUNNING_UNDER_CRON") and os.path.exists(LOCK_DIR):
|
||||
logging.info("Skipping cron-triggered restart during deploy.")
|
||||
sys.exit(1)
|
||||
|
||||
if args.tornado_reshard:
|
||||
tornado_reshard_files = ["/etc/zulip/sharding.json", "/etc/zulip/nginx_sharding_map.conf"]
|
||||
if not all(os.path.exists(f"{path}.tmp") for path in tornado_reshard_files):
|
||||
logging.info(
|
||||
"No resharding changes to apply! Edit zulip.conf and run refresh-sharding-and-restart"
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
if not args.skip_checks:
|
||||
logging.info("Running syntax and database checks")
|
||||
subprocess.check_call(["./manage.py", "check", "--database", "default"])
|
||||
|
||||
if args.fill_cache:
|
||||
logging.info("Filling memcached caches")
|
||||
subprocess.check_call(["./manage.py", "fill_memcached_caches", "--automated", "--skip-checks"])
|
||||
|
||||
current_symlink = os.path.join(DEPLOYMENTS_DIR, "current")
|
||||
last_symlink = os.path.join(DEPLOYMENTS_DIR, "last")
|
||||
change_symlink = os.readlink(current_symlink) != deploy_path
|
||||
if change_symlink:
|
||||
overwrite_symlink(os.readlink(current_symlink), last_symlink)
|
||||
overwrite_symlink(deploy_path, current_symlink)
|
||||
|
||||
config_file = get_config_file()
|
||||
tornado_ports = get_tornado_ports(config_file)
|
||||
workers = []
|
||||
|
||||
if has_application_server():
|
||||
# Start by restarting the workers and similar processes, one at a
|
||||
# time. Workers can always support processing events with old event
|
||||
# contents, but cannot necessarily understand events enqueued by a
|
||||
# newer Django process. Restarting them one at a time, rather than
|
||||
# all-at-once, minimizes the downtime of each, and reduces startup
|
||||
# contention.
|
||||
#
|
||||
# For "start" or less-graceful circumstances, we don't need to
|
||||
# iterate; we'll stop all of them at once, and start them all later.
|
||||
# In those cases, using the glob form is faster -- but if we do need
|
||||
# to iterate, we need to expand the glob.
|
||||
if action == "start" or args.less_graceful:
|
||||
workers.append("zulip-workers:*")
|
||||
else:
|
||||
workers.extend(list_supervisor_processes(["zulip-workers:*"]))
|
||||
|
||||
if has_application_server(once=True):
|
||||
# These used to be included in "zulip-workers:*"; since we may
|
||||
# be restarting an older version of Zulip, which has not
|
||||
# applied puppet to reload the new list of processes, only
|
||||
# stop them if they currently exist according to
|
||||
# `supervisorctl`.
|
||||
workers.extend(
|
||||
list_supervisor_processes(
|
||||
[
|
||||
"zulip_deliver_scheduled_emails",
|
||||
"zulip_deliver_scheduled_messages",
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
# This is an optional service, so may or may not exist
|
||||
workers.extend(list_supervisor_processes(["zulip-katex"]))
|
||||
|
||||
# This does have some Python code, which reads from settings.py,
|
||||
# so we need to restart it on every deploy. A short outage during
|
||||
# the restart is fine, as clients will transparently retry.
|
||||
workers.append("zulip-tus")
|
||||
|
||||
workers.extend(list_supervisor_processes(["zulip-email-server"]))
|
||||
|
||||
if has_process_fts_updates():
|
||||
workers.append("process-fts-updates")
|
||||
|
||||
# Before we start (re)starting main services, make sure to start any
|
||||
# optional auxiliary services that we don't stop, but do expect to be
|
||||
# running, and aren't currently. These get new versions by getting
|
||||
# updated supervisor files, and puppet restarts them -- so we never
|
||||
# restart them in here, only start them.
|
||||
aux_services = list_supervisor_processes(["go-camo", "smokescreen"], only_running=False)
|
||||
if aux_services:
|
||||
subprocess.check_call(["supervisorctl", "start", *aux_services])
|
||||
|
||||
if args.only_django:
|
||||
workers = []
|
||||
check_services = ["zulip-django"]
|
||||
else:
|
||||
check_services = [*workers, "zulip-django", "zulip-tornado:*"]
|
||||
|
||||
# If none of the workers nor the application servers are running, this
|
||||
# is actually a "start," not a restart, which means we will defer
|
||||
# workers to later.
|
||||
running_services = list_supervisor_processes(check_services, only_running=True)
|
||||
if action == "restart" and len(running_services) == 0:
|
||||
action = "start"
|
||||
verbing = "Starting"
|
||||
elif action == "start":
|
||||
existing_services = list_supervisor_processes(check_services)
|
||||
if existing_services == running_services:
|
||||
# Check if the version that is currently running is the
|
||||
# version that we would have started. We do this via
|
||||
# comparing our CWD to the CWD of the oldest uwsgi worker.
|
||||
oldest_pid = subprocess.check_output(
|
||||
["pgrep", "--oldest", "--full", "zulip-django uWSGI worker"], text=True
|
||||
).strip()
|
||||
running_cwd = os.readlink(f"/proc/{oldest_pid}/cwd")
|
||||
if deploy_path != running_cwd:
|
||||
logging.warning("Mismatch between 'current' symlink and currently-running code!")
|
||||
logging.warning("Already-running deploy: %s", running_cwd)
|
||||
logging.warning("We would have started: %s", deploy_path)
|
||||
logging.warning("To restart, call %s/scripts/restart-server", deploy_path)
|
||||
else:
|
||||
logging.info("Zulip is already started; nothing to do!")
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
def restart_or_start(service: str) -> None:
|
||||
our_verb = action
|
||||
existing_services = list_supervisor_processes([service])
|
||||
running_services = list_supervisor_processes([service], only_running=True)
|
||||
if our_verb == "restart" and len(running_services) == 0:
|
||||
our_verb = "start"
|
||||
elif our_verb == "start" and existing_services == running_services:
|
||||
logging.info("%s already started!", service)
|
||||
return
|
||||
subprocess.check_call(["supervisorctl", our_verb, service])
|
||||
|
||||
|
||||
def ports_as_set(val: int | list[int]) -> frozenset[int]:
|
||||
return frozenset(val) if isinstance(val, list) else frozenset([val])
|
||||
|
||||
|
||||
def update_tornado_sharding() -> list[int]:
|
||||
with open("/etc/zulip/sharding.json") as old_shard_fh:
|
||||
old_sharding = json.load(old_shard_fh)
|
||||
with open("/etc/zulip/sharding.json.tmp") as new_shard_fh:
|
||||
new_sharding = json.load(new_shard_fh)
|
||||
affected_tornados: set[int] = set()
|
||||
for realm in set().union(old_sharding["shard_map"], new_sharding["shard_map"]):
|
||||
old_ports = ports_as_set(old_sharding["shard_map"].get(realm, []))
|
||||
new_ports = ports_as_set(new_sharding["shard_map"].get(realm, []))
|
||||
if old_ports != new_ports:
|
||||
# A realm sharded across multiple ports gets requests at
|
||||
# random from nginx, and each does an X-Accel-Redirect to
|
||||
# the user's right Tornado instance. So all ports in the
|
||||
# set need a restart when we add or remove ports.
|
||||
affected_tornados |= old_ports | new_ports
|
||||
old_regex_set = {
|
||||
(regex, ports_as_set(ports)) for (regex, ports) in old_sharding["shard_regexes"]
|
||||
}
|
||||
new_regex_set = {
|
||||
(regex, ports_as_set(ports)) for (regex, ports) in new_sharding["shard_regexes"]
|
||||
}
|
||||
for regex, ports in old_regex_set ^ new_regex_set:
|
||||
affected_tornados |= ports
|
||||
|
||||
tornado_reshard_files = ["/etc/zulip/sharding.json", "/etc/zulip/nginx_sharding_map.conf"]
|
||||
for path in tornado_reshard_files:
|
||||
os.rename(f"{path}.tmp", path)
|
||||
|
||||
return list(affected_tornados)
|
||||
|
||||
|
||||
if action == "restart" and len(workers) > 0:
|
||||
if args.less_graceful:
|
||||
# The less graceful form stops every worker now; we start them
|
||||
# back up at the end.
|
||||
logging.info("Stopping workers")
|
||||
subprocess.check_call(["supervisorctl", "stop", *workers])
|
||||
else:
|
||||
# We cannot pass all of these to one `supervisorctl restart`
|
||||
# because that takes them all down at once, waits until they are
|
||||
# all down, and then brings them back up; doing them sequentially
|
||||
# requires multiple `supervisorctl restart` calls.
|
||||
for worker in workers:
|
||||
logging.info("Restarting %s", worker)
|
||||
restart_or_start(worker)
|
||||
|
||||
if has_application_server():
|
||||
if args.tornado_reshard:
|
||||
affected_tornado_ports = update_tornado_sharding()
|
||||
logging.info("Tornado ports affected by this resharding: %s", affected_tornado_ports)
|
||||
else:
|
||||
affected_tornado_ports = tornado_ports
|
||||
if not args.only_django:
|
||||
# Next, we restart the Tornado processes sequentially, in order to
|
||||
# minimize downtime of the tornado service caused by too many
|
||||
# Python processes restarting at the same time, resulting in each
|
||||
# receiving insufficient priority. This is important, because
|
||||
# Tornado being unavailable for too long is the main source of
|
||||
# user-visible downtime when we restart a Zulip server. We do
|
||||
# this before restarting Django, in case there are new event types
|
||||
# which it will need to know how to deal with.
|
||||
if len(tornado_ports) > 1:
|
||||
for p in affected_tornado_ports:
|
||||
# Restart Tornado processes individually for a better rate of
|
||||
# restarts. This also avoids behavior with restarting a whole
|
||||
# supervisord group where if any individual process is slow to
|
||||
# stop, the whole bundle stays stopped for an extended time.
|
||||
logging.info("%s Tornado process on port %s", verbing, p)
|
||||
restart_or_start(f"zulip-tornado:zulip-tornado-port-{p}")
|
||||
else:
|
||||
logging.info("%s Tornado process", verbing)
|
||||
restart_or_start("zulip-tornado:*")
|
||||
|
||||
# Finally, restart the Django uWSGI processes.
|
||||
if (
|
||||
action == "restart"
|
||||
and not args.less_graceful
|
||||
and get_config(config_file, "application_server", "rolling_restart", False)
|
||||
and os.path.exists("/home/zulip/deployments/uwsgi-control")
|
||||
):
|
||||
# See if it's currently running
|
||||
uwsgi_status = subprocess.run(
|
||||
["supervisorctl", "status", "zulip-django"],
|
||||
stdout=subprocess.DEVNULL,
|
||||
check=False,
|
||||
)
|
||||
if uwsgi_status.returncode == 0:
|
||||
logging.info("Starting rolling restart of django server")
|
||||
with contextlib.suppress(FileNotFoundError):
|
||||
os.unlink("/var/lib/zulip/django-workers.ready")
|
||||
with open("/home/zulip/deployments/uwsgi-control", "w") as control_socket:
|
||||
# "c" is chain-reloading:
|
||||
# https://uwsgi-docs.readthedocs.io/en/latest/MasterFIFO.html#available-commands
|
||||
control_socket.write("c")
|
||||
n = 0
|
||||
while not os.path.exists("/var/lib/zulip/django-workers.ready"):
|
||||
time.sleep(1)
|
||||
n += 1
|
||||
if n % 5 == 0:
|
||||
logging.info("...")
|
||||
logging.info("Chain reloading complete")
|
||||
else:
|
||||
logging.info("Starting django server")
|
||||
subprocess.check_call(["supervisorctl", "start", "zulip-django"])
|
||||
else:
|
||||
logging.info("%s django server", verbing)
|
||||
restart_or_start("zulip-django")
|
||||
|
||||
using_sso = subprocess.check_output(["./scripts/get-django-setting", "USING_APACHE_SSO"])
|
||||
if using_sso.strip() == b"True":
|
||||
logging.info("Restarting Apache WSGI process...")
|
||||
subprocess.check_call(["pkill", "-x", "apache2", "-u", "zulip"])
|
||||
|
||||
# If we were doing this non-gracefully, or starting as opposed to
|
||||
# restarting, we need to turn the workers (back) on. There's no
|
||||
# advantage to doing this not-all-at-once.
|
||||
if (action == "start" or args.less_graceful) and not args.only_django:
|
||||
workers = list_supervisor_processes(workers, only_running=False)
|
||||
if workers:
|
||||
logging.info("Starting workers")
|
||||
subprocess.check_call(["supervisorctl", "start", *workers])
|
||||
|
||||
if (
|
||||
has_application_server()
|
||||
and not args.skip_client_reloads
|
||||
and not args.only_django
|
||||
and not args.tornado_reshard
|
||||
):
|
||||
# All of the servers have been (re)started; now enqueue events in
|
||||
# the Tornado servers to tell clients to reload.
|
||||
logging.info("Sending reload events to clients in the background")
|
||||
subprocess.check_call(["./scripts/reload-clients", "--background"])
|
||||
|
||||
logging.info("Done!")
|
||||
print(OKGREEN + f"Zulip {action}ed successfully!" + ENDC)
|
||||
|
||||
if change_symlink and "PWD" in os.environ:
|
||||
for symlink in [last_symlink, current_symlink]:
|
||||
if os.path.commonprefix([os.environ["PWD"], symlink]) == symlink:
|
||||
print(
|
||||
"""
|
||||
{}Your shell entered its current directory through a symlink:
|
||||
{}
|
||||
which has now changed. Your shell will not see this change until you run:
|
||||
cd {}
|
||||
to traverse the symlink again.{}
|
||||
""".format(WARNING, symlink, shlex.quote(os.environ["PWD"]), ENDC),
|
||||
file=sys.stderr,
|
||||
)
|
||||
0
scripts/setup/__init__.py
Normal file
0
scripts/setup/__init__.py
Normal file
2
scripts/setup/apt-repos/ksplice/jammy.list
Normal file
2
scripts/setup/apt-repos/ksplice/jammy.list
Normal file
@@ -0,0 +1,2 @@
|
||||
deb [signed-by=/etc/apt/keyrings/ksplice.asc] http://www.ksplice.com/apt jammy ksplice
|
||||
deb-src [signed-by=/etc/apt/keyrings/ksplice.asc] http://www.ksplice.com/apt jammy ksplice
|
||||
65
scripts/setup/apt-repos/ksplice/ksplice.asc
Normal file
65
scripts/setup/apt-repos/ksplice/ksplice.asc
Normal file
@@ -0,0 +1,65 @@
|
||||
-----BEGIN PGP PUBLIC KEY BLOCK-----
|
||||
|
||||
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
|
||||
5d586NuHxeqt80YNMJDN0Q==
|
||||
=Y2MU
|
||||
-----END PGP PUBLIC KEY BLOCK-----
|
||||
2
scripts/setup/apt-repos/ksplice/noble.list
Normal file
2
scripts/setup/apt-repos/ksplice/noble.list
Normal file
@@ -0,0 +1,2 @@
|
||||
deb [signed-by=/etc/apt/keyrings/ksplice.asc] http://www.ksplice.com/apt noble ksplice
|
||||
deb-src [signed-by=/etc/apt/keyrings/ksplice.asc] http://www.ksplice.com/apt noble ksplice
|
||||
1
scripts/setup/apt-repos/teleport/jammy.list
Normal file
1
scripts/setup/apt-repos/teleport/jammy.list
Normal file
@@ -0,0 +1 @@
|
||||
deb [signed-by=/etc/apt/keyrings/teleport-pubkey.asc] https://apt.releases.teleport.dev/ubuntu jammy stable/v17
|
||||
1
scripts/setup/apt-repos/teleport/noble.list
Normal file
1
scripts/setup/apt-repos/teleport/noble.list
Normal file
@@ -0,0 +1 @@
|
||||
deb [signed-by=/etc/apt/keyrings/teleport-pubkey.asc] https://apt.releases.teleport.dev/ubuntu noble stable/v17
|
||||
52
scripts/setup/apt-repos/teleport/teleport-pubkey.asc
Normal file
52
scripts/setup/apt-repos/teleport/teleport-pubkey.asc
Normal file
@@ -0,0 +1,52 @@
|
||||
-----BEGIN PGP PUBLIC KEY BLOCK-----
|
||||
|
||||
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
|
||||
oq8y1SJMCrfC9Ub5q2/ijiJWUw==
|
||||
=+Ne5
|
||||
-----END PGP PUBLIC KEY BLOCK-----
|
||||
BIN
scripts/setup/apt-repos/zulip/apache-arrow-keyring.gpg
Normal file
BIN
scripts/setup/apt-repos/zulip/apache-arrow-keyring.gpg
Normal file
Binary file not shown.
5
scripts/setup/apt-repos/zulip/bookworm.list
Normal file
5
scripts/setup/apt-repos/zulip/bookworm.list
Normal file
@@ -0,0 +1,5 @@
|
||||
deb [signed-by=/etc/apt/keyrings/pgdg.asc] http://apt.postgresql.org/pub/repos/apt/ bookworm-pgdg main
|
||||
deb-src [signed-by=/etc/apt/keyrings/pgdg.asc] http://apt.postgresql.org/pub/repos/apt/ bookworm-pgdg main
|
||||
|
||||
deb http://deb.debian.org/debian bookworm-backports main
|
||||
deb-src http://deb.debian.org/debian bookworm-backports main
|
||||
50
scripts/setup/apt-repos/zulip/custom.sh
Executable file
50
scripts/setup/apt-repos/zulip/custom.sh
Executable file
@@ -0,0 +1,50 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
os_info="$(. /etc/os-release && printf '%s\n' "$ID" "$VERSION_ID" "$VERSION_CODENAME")"
|
||||
{
|
||||
read -r os_id
|
||||
read -r os_version_id
|
||||
read -r os_version_codename
|
||||
} <<<"$os_info"
|
||||
|
||||
if [ ! -e /usr/share/doc/groonga-apt-source/copyright ] && [ "$os_id $os_version_id" != "ubuntu 26.04" ]; then
|
||||
pgroonga_apt_sign_key=$(readlink -f "$LIST_PATH/pgroonga-packages.groonga.org.asc")
|
||||
|
||||
remove_pgroonga_apt_tmp_dir() {
|
||||
rm -rf "$pgroonga_apt_tmp_dir"
|
||||
}
|
||||
pgroonga_apt_tmp_dir=$(mktemp --directory)
|
||||
trap remove_pgroonga_apt_tmp_dir EXIT
|
||||
|
||||
{
|
||||
cd "$pgroonga_apt_tmp_dir" || exit 1
|
||||
tmp_gpg_home=.gnupg
|
||||
gpg --homedir="$tmp_gpg_home" --import "$pgroonga_apt_sign_key"
|
||||
# Find fingerprint of the first key.
|
||||
pgroonga_apt_sign_key_fingerprint=$(
|
||||
gpg --homedir="$tmp_gpg_home" --with-colons --list-keys \
|
||||
| grep '^fpr:' \
|
||||
| cut --delimiter=: --fields=10 \
|
||||
| head --lines=1
|
||||
)
|
||||
|
||||
groonga_apt_source_deb="groonga-apt-source-latest-$os_version_codename.deb"
|
||||
groonga_apt_source_deb_sign="$groonga_apt_source_deb.asc.$pgroonga_apt_sign_key_fingerprint"
|
||||
curl -fLO --retry 3 "https://packages.groonga.org/$os_id/$groonga_apt_source_deb"
|
||||
curl -fLO --retry 3 "https://packages.groonga.org/$os_id/$groonga_apt_source_deb_sign"
|
||||
gpg \
|
||||
--homedir="$tmp_gpg_home" \
|
||||
--verify \
|
||||
"$groonga_apt_source_deb_sign" \
|
||||
"$groonga_apt_source_deb"
|
||||
# To suppress the following warning by "apt-get install":
|
||||
# N: Download is performed unsandboxed as root as file
|
||||
# '.../groonga-apt-source-latest-$os_version_codename.deb' couldn't be
|
||||
# accessed by user '_apt'. - pkgAcquire::Run (13: Permission denied)
|
||||
chown _apt .
|
||||
|
||||
apt-get -y install "./$groonga_apt_source_deb"
|
||||
}
|
||||
touch "$STAMP_FILE"
|
||||
fi
|
||||
11
scripts/setup/apt-repos/zulip/jammy.list
Normal file
11
scripts/setup/apt-repos/zulip/jammy.list
Normal file
@@ -0,0 +1,11 @@
|
||||
deb [signed-by=/etc/apt/keyrings/pgdg.asc] http://apt.postgresql.org/pub/repos/apt/ jammy-pgdg main
|
||||
deb-src [signed-by=/etc/apt/keyrings/pgdg.asc] http://apt.postgresql.org/pub/repos/apt/ jammy-pgdg main
|
||||
|
||||
deb [signed-by=/etc/apt/keyrings/pgroonga-ppa.asc] http://ppa.launchpad.net/groonga/ppa/ubuntu jammy main
|
||||
deb-src [signed-by=/etc/apt/keyrings/pgroonga-ppa.asc] http://ppa.launchpad.net/groonga/ppa/ubuntu jammy main
|
||||
|
||||
deb [signed-by=/etc/apt/keyrings/strukturag.asc] https://ppa.launchpadcontent.net/strukturag/libheif/ubuntu/ jammy main
|
||||
deb-src [signed-by=/etc/apt/keyrings/strukturag.asc] https://ppa.launchpadcontent.net/strukturag/libheif/ubuntu/ jammy main
|
||||
|
||||
deb [signed-by=/etc/apt/keyrings/strukturag.asc] https://ppa.launchpadcontent.net/strukturag/libde265/ubuntu/ jammy main
|
||||
deb-src [signed-by=/etc/apt/keyrings/strukturag.asc] https://ppa.launchpadcontent.net/strukturag/libde265/ubuntu/ jammy main
|
||||
12
scripts/setup/apt-repos/zulip/noble.list
Normal file
12
scripts/setup/apt-repos/zulip/noble.list
Normal file
@@ -0,0 +1,12 @@
|
||||
deb [signed-by=/etc/apt/keyrings/pgdg.asc] http://apt.postgresql.org/pub/repos/apt/ noble-pgdg main
|
||||
deb-src [signed-by=/etc/apt/keyrings/pgdg.asc] http://apt.postgresql.org/pub/repos/apt/ noble-pgdg main
|
||||
|
||||
deb [signed-by=/etc/apt/keyrings/pgroonga-ppa.asc] http://ppa.launchpad.net/groonga/ppa/ubuntu noble main
|
||||
deb-src [signed-by=/etc/apt/keyrings/pgroonga-ppa.asc] http://ppa.launchpad.net/groonga/ppa/ubuntu noble main
|
||||
|
||||
deb [signed-by=/etc/apt/keyrings/strukturag.asc] https://ppa.launchpadcontent.net/strukturag/libheif/ubuntu/ noble main
|
||||
deb-src [signed-by=/etc/apt/keyrings/strukturag.asc] https://ppa.launchpadcontent.net/strukturag/libheif/ubuntu/ noble main
|
||||
|
||||
# libde265 in 24.04 is up-to-date, so the PPA doesn't provide 24.04 packages
|
||||
#deb [signed-by=/etc/apt/keyrings/strukturag.asc] https://ppa.launchpadcontent.net/strukturag/libde265/ubuntu/ noble main
|
||||
#deb-src [signed-by=/etc/apt/keyrings/strukturag.asc] https://ppa.launchpadcontent.net/strukturag/libde265/ubuntu/ noble main
|
||||
77
scripts/setup/apt-repos/zulip/pgdg.asc
Normal file
77
scripts/setup/apt-repos/zulip/pgdg.asc
Normal file
@@ -0,0 +1,77 @@
|
||||
-----BEGIN PGP PUBLIC KEY BLOCK-----
|
||||
|
||||
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
|
||||
=DA1T
|
||||
-----END PGP PUBLIC KEY BLOCK-----
|
||||
@@ -0,0 +1,52 @@
|
||||
-----BEGIN PGP PUBLIC KEY BLOCK-----
|
||||
|
||||
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
|
||||
6TYbUXXz7NBwM+kX8Uq9KpPXkHnP1D4TgEw7o5A=
|
||||
=7PCC
|
||||
-----END PGP PUBLIC KEY BLOCK-----
|
||||
27
scripts/setup/apt-repos/zulip/pgroonga-ppa.asc
Normal file
27
scripts/setup/apt-repos/zulip/pgroonga-ppa.asc
Normal file
@@ -0,0 +1,27 @@
|
||||
-----BEGIN PGP PUBLIC KEY BLOCK-----
|
||||
Version: SKS 1.1.5
|
||||
Comment: Hostname: keyserver.ubuntu.com
|
||||
|
||||
mQINBFMyaEABEADEeUoP5AmhJZLHujySJaLKd1EXpIxkope3l4f/3tkbPTvV+jkyYvOSX/ii
|
||||
NAHyVGPrg4UjJI+kDLru1glFoabBs+J4xk2Lql9DcHW0x7YPgBXKCuj2sg84Teb0KTnAkAFI
|
||||
gd8w2kzs6l9itQbtCWei0wH+pGpp9YJqEqJhqc3U51LqUoMmYjTd2WTaN+BNVZOE3ws4Pa4+
|
||||
JyikyVOkuj/8q+jRF8LHWmeb76SuycvZMyGjJBnIs65wmmSN6VpeHtQA3NzIAV2FKuirEEuW
|
||||
5j7fD9L8WNTXy++K6NTvcd2InXgEWMG3rSGvfE2CvZSxKc/b9/xVdaG0LDtTTWPTosNdluUA
|
||||
K4M91Rz7UEvC06SWIdxqL8LdLOy/6OYaJxgh1exrEadRPWAKr+Ak7LDMiYPHq80npQzMh8Hn
|
||||
oUbQTaQ7KDFD17BZ7HauXn8rRmG/vpdQvKzswRoc3+mroh5hA6otjKdmOWAaD5AmNNxcvR3O
|
||||
iNjDTnX804pjrjHzmTAAxrPKwPxbPzFUeNB4Dt4BzUdesdbbZYJw5ojcvt9qCfLU0DOZXWk4
|
||||
t1nHvIQvGoG8cE63x1buVHcwFXwxocMUdn4iFam9srBlDx1G8TMlaKITNHvoc//evBU0arz0
|
||||
12UMDb8TXAT5zOW1W6szFSt1ZFn0By9Ab+I69UCIgB2iknMUtwARAQABtBlMYXVuY2hwYWQg
|
||||
UFBBIGZvciBHcm9vbmdhiQI4BBMBAgAiBQJTMmhAAhsDBgsJCAcDAgYVCAIJCgsEFgIDAQIe
|
||||
AQIXgAAKCRAzWefOy2ShV5p5EACWQ5QBQV86SAhjJHfRe1MSYVZtrA3v+wwS52V/C9sV9xlc
|
||||
Sn2IGIpUQM2816fVMTM23pvj9lzuP3PpcHl0vc5/gqSliRPAzXawgs8Gl+wB9FcwnKPPQfyR
|
||||
CWflFg5mc37+5wM/fTagL7V5S+mBcDrAyMOr7AFgt03nTCwoSaNHatF+pBZRqpyWByM6sqD+
|
||||
IF2ChI4O3QTI5H6HkbQStEHLGuz8G6UX1MBRZ9LrRl0hUqdc2F/dcogrtV5NWkimXvMcTBYB
|
||||
9dTeuvj7eSma6JemqJAXfCARq54KjYwcZHapzczJe3Kgj1tSqD8O1Z8fsT16P2b0tUnDgNjX
|
||||
PB9UQGsP5NLK+vGnVBg5xJA5GqaN69lsUsvPaFFngmKmykQQNlDk7Q3yrAvMJWwRPQ1c8eMq
|
||||
kkrkKBal8Rq/EJaoaoBG6SFx2cSAD4GMxPEL7G3qJeNJPSLVSOP9j9nGIZ4SETeGIKCNVgVF
|
||||
16iXIxwflvgJP+/1qX3/Qin2YDYCeGvFmJiy5s0VFM1IdK+znrUu0apoCSYVia1D9NJGEdKf
|
||||
l7m/Odtxycq9VP1DKD/k4spWqsdpiBkJMch7XWfa7KF12bwV/tHP4KqJ02/xqhSqk+Ru4A9R
|
||||
LjQFWXs4o/Qwf5qF7FlGhpFridr0xdxtLVmK7bV4Hogldx1bKbkOpCL29BY8GA==
|
||||
=NjhU
|
||||
-----END PGP PUBLIC KEY BLOCK-----
|
||||
6
scripts/setup/apt-repos/zulip/resolute.list
Normal file
6
scripts/setup/apt-repos/zulip/resolute.list
Normal file
@@ -0,0 +1,6 @@
|
||||
deb [signed-by=/etc/apt/keyrings/pgdg.asc] http://apt.postgresql.org/pub/repos/apt/ resolute-pgdg main
|
||||
deb-src [signed-by=/etc/apt/keyrings/pgdg.asc] http://apt.postgresql.org/pub/repos/apt/ resolute-pgdg main
|
||||
|
||||
# Not available for Ubuntu 26.04 yet
|
||||
#deb [signed-by=/etc/apt/keyrings/pgroonga-ppa.asc] http://ppa.launchpad.net/groonga/ppa/ubuntu resolute main
|
||||
#deb-src [signed-by=/etc/apt/keyrings/pgroonga-ppa.asc] http://ppa.launchpad.net/groonga/ppa/ubuntu resolute main
|
||||
28
scripts/setup/apt-repos/zulip/strukturag.asc
Normal file
28
scripts/setup/apt-repos/zulip/strukturag.asc
Normal file
@@ -0,0 +1,28 @@
|
||||
-----BEGIN PGP PUBLIC KEY BLOCK-----
|
||||
|
||||
mQINBGY0lEwBEAD9qq6oOeRJvCi0fHNqJELvS8ykqfuXxWqn1B9DLt0VuTxgmaxG
|
||||
QNaG0B2pfrhoVDs+9wwfOuW0rtUtjWpccJjZaooTIEFSR4AKZAh3O4gUtvI7y95r
|
||||
q7+wWFct4RjbFyZOpmeinhIgnItTbkVjhjJvvQ66H4gUfNeJOoAE7CYgnMhZ1Sn8
|
||||
E6zxWJVLo0c5hXkkc4x4T1Aq02diO0Zg6T6HesxsCLeb5Sbjrca7ukUBGV3WFKAF
|
||||
QC22r8oCC4LiN5TX1WNQJjDSw0M2AY31AF+ebQvZGa1kcClWjqBCD7717+zbevPe
|
||||
/bx88Yr6eh+xuJ3ZJQ209phdeNq9RRkIDnap7TIVn21VoCQolH+adP9boxgG1iIz
|
||||
0gBx1KXBjRgV9owoYan4Mz9+c+5MMgJ975D+8GoRcNr1oycjOO/VVXw7ST9UiXqo
|
||||
TuOU2c1P8KmM3IUF41Igy5rd7MLy9uizKC/WY/lfLOH8pzT3LoWVmhMDJHooF9fq
|
||||
UC80GRIIaBM3f7sQH873zdqzaRPqtgJDxOAzLoZqUaFwLXuMfvYxhwMLWhe3b6Wp
|
||||
shCe3xifqZIHf9iZkqZ9b2TsstXervs0TlW9Xy8FT8RMuEzsX8DLNsUert+PzWpq
|
||||
C8Qse1AElMgSSxEsucWMbW5cnKj6ViEf10gy5VwlTgEgvt9KpZ2Icm43mwARAQAB
|
||||
tB1MYXVuY2hwYWQgUFBBIGZvciBzdHJ1a3R1ciBBR4kCTgQTAQoAOBYhBEfpK5wT
|
||||
yXuwgfX/wXF9tBYRYATJBQJmNJRMAhsDBQsJCAcCBhUKCQgLAgQWAgMBAh4BAheA
|
||||
AAoJEHF9tBYRYATJ/oMQAPVJWmzF+UKwkCA/kFeihXdA6OWl1BttFQpwTAAmxr9u
|
||||
SCg/w1ltIYqTfZcAjMUhY52oDix/NxzZZb/B4dxOkUM4gYmkbzq4wlT0QyrRgjNZ
|
||||
wflwdNjXGI9Ew9ta/3xvNE98LQWyV64t+Q8PihDD7VwOE/c2XVPvXQk8GWmsHJ48
|
||||
nGLaAPzn68kolv3XpkDB+ul07OLC7FXlADxWFOsobwula/zytFK2oyuQ1NiXoJ5I
|
||||
3+IA5sAp7O1mDOZ3m8RROfw56IbwHlNXAP8/REbqWEsXVNkyMf56snpsIr8FPlqY
|
||||
C74QbR/PHcb7vEdeWZgHmuIGNF37QPMx4XUGWjh/9NNi7AHGMa2cRsSF1xm1R99Q
|
||||
Zr1vmRPJM2z54gPq/JwY7WlcL6yEFMBL+WtNuiua5xxZ3rkMs956IuFS55zjrNeO
|
||||
MPmK77tdmFEui0GiTIyAJpoZIwp0AgYBdmP4/2Y6j6LN1sV+VkOzQz2CqDT87yO1
|
||||
BoN65jFuw61H3Y/+OBQMtoxamxs1m/Jjv0sEYf21nKgfukOTLecRWH1tXu0moTAf
|
||||
eEf0MOXzhH1teyfv/21haprwxJn0cG515oHB/JutFNKrXx3qtNH5QjRXIbz9EWAs
|
||||
u1mi4ERxSWAELxiJQWztCOhB5sI8Auq+Kk4uzbCutK6BQrUQORz2FGEJwEXy1f7g
|
||||
=76Dg
|
||||
-----END PGP PUBLIC KEY BLOCK-----
|
||||
2
scripts/setup/apt-repos/zulip/trixie.list
Normal file
2
scripts/setup/apt-repos/zulip/trixie.list
Normal file
@@ -0,0 +1,2 @@
|
||||
deb [signed-by=/etc/apt/keyrings/pgdg.asc] http://apt.postgresql.org/pub/repos/apt/ trixie-pgdg main
|
||||
deb-src [signed-by=/etc/apt/keyrings/pgdg.asc] http://apt.postgresql.org/pub/repos/apt/ trixie-pgdg main
|
||||
10
scripts/setup/apt-repos/zulip/zulip.pref
Normal file
10
scripts/setup/apt-repos/zulip/zulip.pref
Normal file
@@ -0,0 +1,10 @@
|
||||
# libarrow1600 is missing on Debian 12
|
||||
Package: libgroonga0
|
||||
Pin: version 14.0.3-1
|
||||
Pin-Priority: 499
|
||||
|
||||
|
||||
# On bookworm, pull libheif1 from backports
|
||||
Package: libheif1
|
||||
Pin: release n=bookworm-backports
|
||||
Pin-Priority: 500
|
||||
72
scripts/setup/compare-settings-to-template
Executable file
72
scripts/setup/compare-settings-to-template
Executable file
@@ -0,0 +1,72 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import difflib
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
BASE_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
sys.path.append(BASE_DIR)
|
||||
from scripts.lib.setup_path import setup_path
|
||||
|
||||
setup_path()
|
||||
|
||||
|
||||
current_path = "/etc/zulip/settings.py"
|
||||
if len(sys.argv) >= 2:
|
||||
current_path = sys.argv[1]
|
||||
|
||||
print(f"Reading current configuration from {current_path}...")
|
||||
with open(current_path) as f:
|
||||
current_contents = f.read()
|
||||
|
||||
import requests
|
||||
|
||||
print("Fetching old versions of setting templates from Github...")
|
||||
templ = {}
|
||||
url: str | None = "https://api.github.com/repos/zulip/zulip/tags?per_page=100"
|
||||
|
||||
while url is not None:
|
||||
resp = requests.get(url)
|
||||
if resp.status_code != 200:
|
||||
print(resp.content)
|
||||
sys.exit(1)
|
||||
|
||||
url = resp.links.get("next", {}).get("url")
|
||||
for tag in [t["name"] for t in resp.json()]:
|
||||
if not re.match(r"^\d+\.\d+(\.\d+)?$", tag):
|
||||
continue
|
||||
|
||||
filename = "zproject/prod_settings_template.py"
|
||||
if [int(x) for x in tag.split(".")] < [1, 4]:
|
||||
filename = "zproject/local_settings_template.py"
|
||||
|
||||
print(f" - {tag}")
|
||||
resp = requests.get(
|
||||
f"https://raw.githubusercontent.com/zulip/zulip/{tag}/{filename}",
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
templ[tag] = resp.content.decode()
|
||||
else:
|
||||
print("Failure: ")
|
||||
print(resp)
|
||||
|
||||
print("Computing minimal difference...")
|
||||
sequence_matchers = {}
|
||||
for version, contents in templ.items():
|
||||
sm = difflib.SequenceMatcher(None, current_contents, contents)
|
||||
sequence_matchers[version] = sm
|
||||
|
||||
likely = sorted(sequence_matchers.keys(), key=lambda v: sequence_matchers[v].ratio(), reverse=True)
|
||||
print(f"Most likely version: {likely[0]}")
|
||||
|
||||
|
||||
diff = difflib.unified_diff(
|
||||
templ[likely[0]].splitlines(keepends=True),
|
||||
current_contents.splitlines(keepends=True),
|
||||
fromfile=f"zulip-server-{version}/zproject/prod_settings_template.py",
|
||||
tofile=current_path,
|
||||
)
|
||||
|
||||
print()
|
||||
print("".join(diff), end="")
|
||||
30
scripts/setup/configure-rabbitmq
Executable file
30
scripts/setup/configure-rabbitmq
Executable file
@@ -0,0 +1,30 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Delete the "guest" default user and replace it with a Zulip user
|
||||
# with a real password
|
||||
set -eu
|
||||
|
||||
# If the RabbitMQ distribution cookie is insecure, reset it and
|
||||
# restart RabbitMQ.
|
||||
"$(dirname "$0")/generate-rabbitmq-cookie"
|
||||
|
||||
RABBITMQ_USERNAME=$("$(dirname "$0")/../get-django-setting" RABBITMQ_USERNAME)
|
||||
RABBITMQ_PASSWORD=$("$(dirname "$0")/../get-django-setting" RABBITMQ_PASSWORD)
|
||||
RABBITMQ_VHOST=$("$(dirname "$0")/../get-django-setting" RABBITMQ_VHOST)
|
||||
|
||||
tries=0
|
||||
while ((tries < 30)) && ! rabbitmqctl ping -q 2>/dev/null; do
|
||||
((tries++)) || echo "Waiting for RabbitMQ to start up..."
|
||||
sleep 1
|
||||
done
|
||||
rabbitmqctl await_startup
|
||||
|
||||
rabbitmqctl delete_user "$RABBITMQ_USERNAME" || true
|
||||
rabbitmqctl delete_user zulip || true
|
||||
rabbitmqctl delete_user guest || true
|
||||
rabbitmqctl add_user "$RABBITMQ_USERNAME" "$RABBITMQ_PASSWORD"
|
||||
rabbitmqctl set_user_tags "$RABBITMQ_USERNAME" administrator
|
||||
if ! rabbitmqctl list_vhosts --no-table-headers --quiet | grep -qx "$RABBITMQ_VHOST"; then
|
||||
rabbitmqctl add_vhost "$RABBITMQ_VHOST"
|
||||
fi
|
||||
rabbitmqctl set_permissions -p "$RABBITMQ_VHOST" "$RABBITMQ_USERNAME" '.*' '.*' '.*'
|
||||
61
scripts/setup/create-database
Executable file
61
scripts/setup/create-database
Executable file
@@ -0,0 +1,61 @@
|
||||
#!/usr/bin/env bash
|
||||
set -e
|
||||
|
||||
if [ "$EUID" -ne 0 ]; then
|
||||
echo "Error: This script must be run as root" >&2
|
||||
exit 1
|
||||
fi
|
||||
set -x
|
||||
|
||||
# What user should we use for connecting to the database
|
||||
POSTGRES_USER="${POSTGRES_USER:-postgres}"
|
||||
|
||||
# What database name and username to use when connecting to the database
|
||||
DATABASE_NAME=$(crudini --get /etc/zulip/zulip.conf postgresql database_name 2>/dev/null || echo zulip)
|
||||
DATABASE_USER=$(crudini --get /etc/zulip/zulip.conf postgresql database_user 2>/dev/null || echo zulip)
|
||||
|
||||
if [ "$(su "$POSTGRES_USER" -c "cd / && psql -v ON_ERROR_STOP=1 -Atc \"SELECT 1 FROM pg_database WHERE datname='$DATABASE_NAME';\"")" = "1" ]; then
|
||||
records="$(su "$POSTGRES_USER" -c "cd / && psql -v ON_ERROR_STOP=1 -Atc 'SELECT COUNT(*) FROM $DATABASE_NAME.zerver_message;' $DATABASE_USER" || echo 0)"
|
||||
if [ "$records" -gt 200 ]; then
|
||||
set +x
|
||||
echo "WARNING: This will delete your Zulip database which currently contains $records messages."
|
||||
read -p "Do you want to proceed? [y/N] " -r
|
||||
echo
|
||||
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||
exit 1
|
||||
fi
|
||||
set -x
|
||||
fi
|
||||
fi
|
||||
|
||||
# Shut down all services to ensure a quiescent state.
|
||||
if [ -e "/home/zulip/deployments" ]; then
|
||||
su zulip -c "/home/zulip/deployments/current/scripts/stop-server"
|
||||
fi
|
||||
|
||||
# Drop any open connections to any old database.
|
||||
# Send the script via stdin in case the postgres user lacks permission to read it.
|
||||
su -s /usr/bin/env - -- "$POSTGRES_USER" \
|
||||
bash -s - zulip zulip_base <"$(dirname "$0")/terminate-psql-sessions"
|
||||
|
||||
(
|
||||
cd / # Make sure the current working directory is readable by postgres
|
||||
su "$POSTGRES_USER" -c "psql -v ON_ERROR_STOP=1 -v dbname=$DATABASE_NAME -v dbuser=$DATABASE_USER -e"
|
||||
) <"$(dirname "$0")/create-db.sql"
|
||||
|
||||
# Set a postgres password if the postgres username is not "zulip".
|
||||
# When the username is zulip, we rely on running as the zulip system
|
||||
# user for authentication via postgres' peer authentication.
|
||||
if [ "$DATABASE_USER" != "zulip" ]; then
|
||||
PASSWORD=$(crudini --get /etc/zulip/zulip-secrets.conf secrets postgres_password)
|
||||
su "$POSTGRES_USER" -c "psql -v ON_ERROR_STOP=1 -e postgres" <<EOF
|
||||
ALTER ROLE $DATABASE_USER PASSWORD '$PASSWORD';
|
||||
EOF
|
||||
fi
|
||||
|
||||
# Clear memcached to avoid contamination from previous database state
|
||||
if [ -e "/home/zulip/deployments" ]; then
|
||||
/home/zulip/deployments/current/scripts/setup/flush-memcached
|
||||
fi
|
||||
|
||||
echo "Database created"
|
||||
20
scripts/setup/create-db.sql
Normal file
20
scripts/setup/create-db.sql
Normal file
@@ -0,0 +1,20 @@
|
||||
\set dbuser :dbuser
|
||||
SELECT CASE WHEN :'dbuser' = ':dbuser' THEN 'zulip' ELSE :'dbuser' END AS dbuser \gset
|
||||
\set dbname :dbname
|
||||
SELECT CASE WHEN :'dbname' = ':dbname' THEN 'zulip' ELSE :'dbname' END AS dbname \gset
|
||||
|
||||
\connect postgres
|
||||
DROP DATABASE IF EXISTS :"dbname";
|
||||
SELECT format($$BEGIN
|
||||
CREATE USER %I;
|
||||
EXCEPTION WHEN duplicate_object THEN
|
||||
RAISE NOTICE 'user already exists';
|
||||
END$$, :'dbuser') AS code \gset
|
||||
DO :'code';
|
||||
CREATE DATABASE :"dbname"
|
||||
OWNER=:dbuser
|
||||
ENCODING=UTF8
|
||||
LC_COLLATE='C.UTF-8'
|
||||
LC_CTYPE='C.UTF-8'
|
||||
TEMPLATE=template0;
|
||||
\connect :"dbname"
|
||||
2
scripts/setup/create-pgroonga.sql
Normal file
2
scripts/setup/create-pgroonga.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
\connect zulip
|
||||
CREATE EXTENSION pgroonga;
|
||||
28
scripts/setup/flush-memcached
Executable file
28
scripts/setup/flush-memcached
Executable file
@@ -0,0 +1,28 @@
|
||||
#!/usr/bin/env python3
|
||||
import os
|
||||
import sys
|
||||
|
||||
BASE_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "../..")
|
||||
sys.path.append(BASE_DIR)
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "zproject.settings")
|
||||
|
||||
from scripts.lib.setup_path import setup_path
|
||||
|
||||
setup_path()
|
||||
|
||||
import bmemcached
|
||||
from django.conf import settings
|
||||
|
||||
cache = settings.CACHES["default"]
|
||||
match cache["BACKEND"]:
|
||||
case "django.core.cache.backends.locmem.LocMemCache":
|
||||
pass
|
||||
case "zerver.lib.singleton_bmemcached.SingletonBMemcached":
|
||||
assert isinstance(cache["OPTIONS"], dict)
|
||||
client = bmemcached.Client((cache["LOCATION"],), **cache["OPTIONS"])
|
||||
try:
|
||||
client.flush_all()
|
||||
finally:
|
||||
client.disconnect_all()
|
||||
case _:
|
||||
raise ValueError(f"Unrecognized cache backend {cache['BACKEND']}")
|
||||
27
scripts/setup/generate-rabbitmq-cookie
Executable file
27
scripts/setup/generate-rabbitmq-cookie
Executable file
@@ -0,0 +1,27 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# rabbitmq sets an insecure "magic cookie" which is used for auth;
|
||||
# reset it to be longer.
|
||||
set -eu
|
||||
|
||||
cookiefile=/var/lib/rabbitmq/.erlang.cookie
|
||||
# If the RabbitMQ distribution cookie is insecure, reset it
|
||||
if [ ! -f "$cookiefile" ] || ! size="$(wc -c "$cookiefile")" || [ "${size%% *}" -le 20 ]; then
|
||||
running=0
|
||||
if service rabbitmq-server status >/dev/null; then
|
||||
running=1
|
||||
service rabbitmq-server stop
|
||||
fi
|
||||
|
||||
echo "Setting a more secure RabbitMQ distribution magic cookie"
|
||||
cookie="$(LC_ALL=C tr -dc '[:alnum:]' </dev/urandom | head -c255)"
|
||||
[ "${#cookie}" -eq 255 ] # make sure tr wasn’t OOM-killed
|
||||
tmpfile="$(mktemp "$cookiefile.XXXXXXXXXX")"
|
||||
chown rabbitmq: "$tmpfile"
|
||||
printf '%s' "$cookie" >"$tmpfile"
|
||||
mv "$tmpfile" "$cookiefile"
|
||||
|
||||
if [ "$running" == "1" ]; then
|
||||
service rabbitmq-server start
|
||||
fi
|
||||
fi
|
||||
108
scripts/setup/generate-self-signed-cert
Executable file
108
scripts/setup/generate-self-signed-cert
Executable file
@@ -0,0 +1,108 @@
|
||||
#!/usr/bin/env bash
|
||||
set -e
|
||||
|
||||
usage() {
|
||||
echo "usage: $0 [--force] [--exists-ok] EXTERNAL_HOST" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
args="$(getopt -o '' --long help,force,exists-ok -- "$@")"
|
||||
eval "set -- $args"
|
||||
while true; do
|
||||
case "$1" in
|
||||
--help) usage ;;
|
||||
--force)
|
||||
FORCE=1
|
||||
shift
|
||||
;;
|
||||
--exists-ok)
|
||||
EXISTS_OK=1
|
||||
shift
|
||||
;;
|
||||
--)
|
||||
shift
|
||||
break
|
||||
;;
|
||||
*) usage ;;
|
||||
esac
|
||||
done
|
||||
EXTERNAL_HOST="$1"
|
||||
|
||||
if [ -z "$EXTERNAL_HOST" ]; then
|
||||
usage
|
||||
fi
|
||||
|
||||
if [ "$EUID" -ne 0 ]; then
|
||||
echo "error: this script must be run as root" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
set -x
|
||||
|
||||
is_redhat=false
|
||||
if [ -e /etc/redhat-release ]; then
|
||||
is_redhat=true
|
||||
SSLDIR=/etc/pki/tls
|
||||
else
|
||||
SSLDIR=/etc/ssl
|
||||
fi
|
||||
KEYFILE=$SSLDIR/private/zulip.key
|
||||
CERTFILE=$SSLDIR/certs/zulip.combined-chain.crt
|
||||
|
||||
if [ -n "$EXISTS_OK" ] && [ -e "$KEYFILE" ] && [ -e "$CERTFILE" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ -z "$FORCE" ] && { [ -e "$KEYFILE" ] || [ -e "$CERTFILE" ]; }; then
|
||||
echo "$0: certificate and/or key already exists; use --force to overwrite." >&2
|
||||
exit 1
|
||||
fi
|
||||
rm -f "$KEYFILE" "$CERTFILE"
|
||||
|
||||
if [[ "$EXTERNAL_HOST" =~ ^(([0-9]+\.){3}[0-9]+)(:[0-9]+)?$ ]]; then
|
||||
subjectAltName="IP:${BASH_REMATCH[1]}" # IPv4 address
|
||||
elif [[ "$EXTERNAL_HOST" =~ ^\[([^][]*)\](:[0-9]+)?$ ]]; then
|
||||
subjectAltName="IP:${BASH_REMATCH[1]}" # IPv6 address
|
||||
elif [[ "$EXTERNAL_HOST" =~ ^([^:]+)(:[0-9]+)?$ ]]; then
|
||||
subjectAltName="DNS:${BASH_REMATCH[1]}"
|
||||
else
|
||||
echo "$0: invalid host $EXTERNAL_HOST" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
config="$(mktemp)" || exit 1
|
||||
trap 'rm -f "$config"' EXIT
|
||||
|
||||
cat >"$config" <<EOF
|
||||
# Based on /usr/share/ssl-cert/ssleay.cnf from Debian's \`ssl-cert\`
|
||||
# package, which is used for the system's snakeoil cert in /etc/ssl/.
|
||||
|
||||
[ req ]
|
||||
default_bits = 2048
|
||||
distinguished_name = req_distinguished_name
|
||||
prompt = no
|
||||
policy = policy_anything
|
||||
req_extensions = v3_req
|
||||
x509_extensions = v3_req
|
||||
|
||||
[ req_distinguished_name ]
|
||||
commonName = $EXTERNAL_HOST
|
||||
|
||||
[ v3_req ]
|
||||
basicConstraints = CA:FALSE
|
||||
subjectAltName = $subjectAltName
|
||||
EOF
|
||||
|
||||
if [ "$is_redhat" = true ]; then
|
||||
yum install -y openssl
|
||||
else
|
||||
apt-get install -y openssl
|
||||
fi
|
||||
|
||||
# Based on /usr/sbin/make-ssl-cert from Debian's `ssl-cert` package.
|
||||
openssl req -new -x509 \
|
||||
-config "$config" -days 3650 -nodes -sha256 \
|
||||
-out "$CERTFILE" -keyout "$KEYFILE"
|
||||
|
||||
chmod 644 "$CERTFILE"
|
||||
chmod 640 "$KEYFILE"
|
||||
228
scripts/setup/generate_secrets.py
Executable file
228
scripts/setup/generate_secrets.py
Executable file
@@ -0,0 +1,228 @@
|
||||
#!/usr/bin/env python3
|
||||
# This tools generates /etc/zulip/zulip-secrets.conf
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from contextlib import suppress
|
||||
|
||||
BASE_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
sys.path.append(BASE_DIR)
|
||||
from scripts.lib.setup_path import setup_path
|
||||
from scripts.lib.zulip_tools import get_config, get_config_file
|
||||
|
||||
setup_path()
|
||||
|
||||
os.environ["DISABLE_MANDATORY_SECRET_CHECK"] = "True"
|
||||
os.environ["DJANGO_SETTINGS_MODULE"] = "zproject.settings"
|
||||
|
||||
import argparse
|
||||
import configparser
|
||||
import uuid
|
||||
|
||||
from nacl.encoding import Base64Encoder
|
||||
from nacl.public import PrivateKey
|
||||
|
||||
os.chdir(os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
|
||||
# Standard, 64-bit tokens
|
||||
AUTOGENERATED_SETTINGS = [
|
||||
"avatar_salt",
|
||||
"rabbitmq_password",
|
||||
"shared_secret",
|
||||
]
|
||||
|
||||
|
||||
def random_string(cnt: int) -> str:
|
||||
# We do in-function imports so that we only do the expensive work
|
||||
# of importing cryptography modules when necessary.
|
||||
#
|
||||
# This helps optimize noop provision performance.
|
||||
from django.utils.crypto import get_random_string
|
||||
|
||||
return get_random_string(cnt)
|
||||
|
||||
|
||||
def random_token() -> str:
|
||||
# We do in-function imports so that we only do the expensive work
|
||||
# of importing cryptography modules when necessary.
|
||||
#
|
||||
# This helps optimize noop provision performance.
|
||||
import secrets
|
||||
|
||||
return secrets.token_hex(32)
|
||||
|
||||
|
||||
def generate_django_secretkey() -> str:
|
||||
"""Secret key generation taken from Django's startproject.py"""
|
||||
|
||||
# We do in-function imports so that we only do the expensive work
|
||||
# of importing cryptography modules when necessary.
|
||||
#
|
||||
# This helps optimize noop provision performance.
|
||||
from django.utils.crypto import get_random_string
|
||||
|
||||
chars = "abcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*(-_=+)"
|
||||
return get_random_string(50, chars)
|
||||
|
||||
|
||||
def get_old_conf(output_filename: str) -> dict[str, str]:
|
||||
if not os.path.exists(output_filename) or os.path.getsize(output_filename) == 0:
|
||||
return {}
|
||||
|
||||
secrets_file = configparser.RawConfigParser()
|
||||
secrets_file.read(output_filename)
|
||||
|
||||
return dict(secrets_file.items("secrets"))
|
||||
|
||||
|
||||
def generate_secrets(development: bool = False) -> None:
|
||||
if development:
|
||||
OUTPUT_SETTINGS_FILENAME = "zproject/dev-secrets.conf"
|
||||
else:
|
||||
OUTPUT_SETTINGS_FILENAME = "/etc/zulip/zulip-secrets.conf"
|
||||
current_conf = get_old_conf(OUTPUT_SETTINGS_FILENAME)
|
||||
|
||||
lines: list[str] = []
|
||||
if len(current_conf) == 0:
|
||||
lines = ["[secrets]\n"]
|
||||
|
||||
def need_secret(name: str) -> bool:
|
||||
return name not in current_conf
|
||||
|
||||
def add_secret(name: str, value: str) -> None:
|
||||
lines.append(f"{name} = {value}\n")
|
||||
current_conf[name] = value
|
||||
|
||||
for name in AUTOGENERATED_SETTINGS:
|
||||
if need_secret(name):
|
||||
add_secret(name, random_token())
|
||||
|
||||
# These secrets are exclusive to a Zulip development environment.
|
||||
# We use PostgreSQL peer authentication by default in production,
|
||||
# and initial_password_salt is used to generate passwords for the
|
||||
# test/development database users. See `manage.py
|
||||
# print_initial_password`.
|
||||
if development and need_secret("initial_password_salt"):
|
||||
add_secret("initial_password_salt", random_token())
|
||||
if development and need_secret("local_database_password"):
|
||||
add_secret("local_database_password", random_token())
|
||||
|
||||
# We only need a secret if the database username does not match
|
||||
# the OS username, as identd auth works in that case.
|
||||
if get_config(
|
||||
get_config_file(), "postgresql", "database_user", "zulip"
|
||||
) != "zulip" and need_secret("postgres_password"):
|
||||
add_secret("postgres_password", random_token())
|
||||
|
||||
# The core Django SECRET_KEY setting, used by Django internally to
|
||||
# secure sessions. If this gets changed, all users will be logged out.
|
||||
if need_secret("secret_key"):
|
||||
secret_key = generate_django_secretkey()
|
||||
add_secret("secret_key", secret_key)
|
||||
# To prevent Django ImproperlyConfigured error
|
||||
from zproject import settings
|
||||
|
||||
settings.SECRET_KEY = secret_key
|
||||
|
||||
# Secret key for the Camo HTTPS proxy.
|
||||
if need_secret("camo_key"):
|
||||
add_secret("camo_key", random_string(64))
|
||||
|
||||
# We enable Altcha in development
|
||||
if development and need_secret("altcha_hmac"):
|
||||
add_secret("altcha_hmac", random_token())
|
||||
|
||||
if not development:
|
||||
# The memcached_password and redis_password secrets are only
|
||||
# required/relevant in production.
|
||||
|
||||
# Password for authentication to memcached.
|
||||
if need_secret("memcached_password"):
|
||||
# We defer importing settings unless we need it, because
|
||||
# importing settings is expensive (mostly because of
|
||||
# django-auth-ldap) and we want the noop case to be fast.
|
||||
from zproject import settings
|
||||
|
||||
if settings.MEMCACHED_LOCATION == "127.0.0.1:11211":
|
||||
add_secret("memcached_password", random_token())
|
||||
|
||||
# Password for authentication to Redis.
|
||||
if need_secret("redis_password"):
|
||||
# We defer importing settings unless we need it, because
|
||||
# importing settings is expensive (mostly because of
|
||||
# django-auth-ldap) and we want the noop case to be fast.
|
||||
from zproject import settings
|
||||
|
||||
if settings.REDIS_HOST == "127.0.0.1":
|
||||
# To prevent Puppet from restarting Redis, which would lose
|
||||
# data because we configured Redis to disable persistence, set
|
||||
# the Redis password on the running server and edit the config
|
||||
# file directly.
|
||||
|
||||
import redis
|
||||
|
||||
from zerver.lib.redis_utils import get_redis_client
|
||||
|
||||
redis_password = random_token()
|
||||
|
||||
for filename in ["/etc/redis/zuli-redis.conf", "/etc/redis/zulip-redis.conf"]:
|
||||
if os.path.exists(filename):
|
||||
with open(filename, "a") as f:
|
||||
f.write(
|
||||
"# Set a Redis password based on zulip-secrets.conf\n"
|
||||
f"requirepass '{redis_password}'\n",
|
||||
)
|
||||
break
|
||||
|
||||
with suppress(redis.exceptions.ConnectionError):
|
||||
get_redis_client().config_set("requirepass", redis_password)
|
||||
|
||||
add_secret("redis_password", redis_password)
|
||||
|
||||
# Random id and secret used to identify this installation when
|
||||
# accessing the Zulip mobile push notifications service.
|
||||
# * zulip_org_key is generated using os.urandom().
|
||||
# * zulip_org_id only needs to be unique, so we use a UUID.
|
||||
if need_secret("zulip_org_key"):
|
||||
add_secret("zulip_org_key", random_string(64))
|
||||
if need_secret("zulip_org_id"):
|
||||
add_secret("zulip_org_id", str(uuid.uuid4()))
|
||||
|
||||
if development and need_secret("push_registration_encryption_keys"):
|
||||
# 'settings.ZILENCER_ENABLED' would be a better check than
|
||||
# 'development' for whether we need push bouncer secrets,
|
||||
# but we're trying to avoid importing settings.
|
||||
private_key = PrivateKey.generate()
|
||||
private_key_str = Base64Encoder.encode(bytes(private_key)).decode("utf-8")
|
||||
public_key_str = Base64Encoder.encode(bytes(private_key.public_key)).decode("utf-8")
|
||||
add_secret(
|
||||
"push_registration_encryption_keys", json.dumps({public_key_str: private_key_str})
|
||||
)
|
||||
|
||||
if len(lines) == 0:
|
||||
print("generate_secrets: No new secrets to generate.")
|
||||
return
|
||||
|
||||
with open(OUTPUT_SETTINGS_FILENAME, "a") as f:
|
||||
# Write a newline at the start, in case there was no newline at
|
||||
# the end of the file due to human editing.
|
||||
f.write("\n" + "".join(lines))
|
||||
|
||||
print(f"Generated new secrets in {OUTPUT_SETTINGS_FILENAME}.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser()
|
||||
group = parser.add_mutually_exclusive_group(required=True)
|
||||
group.add_argument(
|
||||
"--development", action="store_true", help="For setting up the developer env for zulip"
|
||||
)
|
||||
group.add_argument(
|
||||
"--production",
|
||||
action="store_false",
|
||||
dest="development",
|
||||
help="For setting up the production env for zulip",
|
||||
)
|
||||
results = parser.parse_args()
|
||||
|
||||
generate_secrets(results.development)
|
||||
60
scripts/setup/initialize-database
Executable file
60
scripts/setup/initialize-database
Executable file
@@ -0,0 +1,60 @@
|
||||
#!/usr/bin/env bash
|
||||
set -e
|
||||
|
||||
usage() {
|
||||
echo "usage: initialize-database [--quiet]" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
args="$(getopt -o '' --long help,quiet -- "$@")"
|
||||
eval "set -- $args"
|
||||
while true; do
|
||||
case "$1" in
|
||||
--help) usage ;;
|
||||
--quiet)
|
||||
QUIET=1
|
||||
shift
|
||||
;;
|
||||
--)
|
||||
shift
|
||||
break
|
||||
;;
|
||||
*) usage ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ "$#" -gt 0 ]; then
|
||||
usage
|
||||
fi
|
||||
|
||||
set -x
|
||||
|
||||
# Change to root directory of the checkout that we're running from
|
||||
THIS_DIR="$(dirname "$(readlink -f "$0")")"
|
||||
cd "$THIS_DIR/../.."
|
||||
|
||||
./manage.py migrate --noinput
|
||||
./manage.py createcachetable third_party_api_results
|
||||
|
||||
# Check if the supervisor socket exists. If not, it could be:
|
||||
#
|
||||
# A) A normal installation went bad (supervisor hasn't started)
|
||||
# B) We are in a Docker container and don't have supervisor running
|
||||
#
|
||||
# In either case, it doesn't make sense to restart supervisor jobs
|
||||
if [ -e "/var/run/supervisor.sock" ]; then
|
||||
./scripts/restart-server
|
||||
fi
|
||||
|
||||
set +x
|
||||
if [ -z "$QUIET" ]; then
|
||||
echo "Congratulations! You have successfully configured your Zulip database."
|
||||
echo "If you haven't already, you should configure email in /etc/zulip/settings.py."
|
||||
echo
|
||||
echo "Next, run as the zulip user (use 'su zulip' if needed):"
|
||||
echo
|
||||
echo " /home/zulip/deployments/current/manage.py generate_realm_creation_link"
|
||||
echo
|
||||
echo "This generates a secure, single-use link that you can use to set up "
|
||||
echo "a Zulip organization from the convenience of your web browser."
|
||||
fi
|
||||
27
scripts/setup/install
Executable file
27
scripts/setup/install
Executable file
@@ -0,0 +1,27 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Thin wrapper around the actual install script (scripts/lib/install).
|
||||
# The purpose of this wrapper is to log the full install script output
|
||||
# to /var/log/zulip/install.log for easy debugging.
|
||||
|
||||
set -e
|
||||
if [ "$EUID" -ne 0 ]; then
|
||||
echo "Error: The installation script must be run as root." >&2
|
||||
exit 1
|
||||
fi
|
||||
umask 022
|
||||
mkdir -p /var/log/zulip
|
||||
|
||||
"$(dirname "$(dirname "$0")")/lib/install" "$@" 2>&1 | tee -a /var/log/zulip/install.log
|
||||
failed=${PIPESTATUS[0]}
|
||||
|
||||
if [ "$failed" -ne 0 ]; then
|
||||
echo -e '\033[0;31m'
|
||||
echo "Zulip installation failed (exit code $failed)!"
|
||||
echo
|
||||
echo -n "The install process is designed to be idempotent, so you can retry "
|
||||
echo -n "after resolving whatever issue caused the failure (there should be a traceback above). "
|
||||
echo -n "A log of this installation is available in /var/log/zulip/install.log"
|
||||
echo -e '\033[0m'
|
||||
exit "$failed"
|
||||
fi
|
||||
25
scripts/setup/pgroonga-config
Executable file
25
scripts/setup/pgroonga-config
Executable file
@@ -0,0 +1,25 @@
|
||||
#!/usr/bin/env bash
|
||||
set -eux
|
||||
|
||||
dbversion=$(crudini --get /etc/zulip/zulip.conf postgresql version)
|
||||
dbname=$(crudini --get /etc/zulip/zulip.conf postgresql database_name 2>/dev/null || echo zulip)
|
||||
|
||||
if ! su postgres -c "psql -At -c 'SELECT datname FROM pg_database WHERE NOT datistemplate'" | grep -Fx "$dbname"; then
|
||||
echo "No database to install into!"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
sharedir="${1:-/usr/share/postgresql/$dbversion}"
|
||||
applied_file="$sharedir/pgroonga_setup.sql.applied"
|
||||
|
||||
installed_version=$(dpkg-query --show --showformat='${Version}' "postgresql-$dbversion-pgdg-pgroonga")
|
||||
|
||||
if [ ! -f "$applied_file" ]; then
|
||||
sql="CREATE EXTENSION PGROONGA"
|
||||
else
|
||||
sql="ALTER EXTENSION pgroonga UPDATE"
|
||||
fi
|
||||
|
||||
echo "$sql" | su postgres -c "psql -v ON_ERROR_STOP=1 $dbname"
|
||||
|
||||
echo "$installed_version" >"$applied_file"
|
||||
139
scripts/setup/reindex-textual-data
Executable file
139
scripts/setup/reindex-textual-data
Executable file
@@ -0,0 +1,139 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
sys.path.append(os.path.join(os.path.dirname(__file__), "../.."))
|
||||
from scripts.lib.setup_path import setup_path
|
||||
|
||||
setup_path()
|
||||
|
||||
import psycopg2
|
||||
import psycopg2.extensions
|
||||
from psycopg2.sql import SQL, Identifier
|
||||
|
||||
from scripts.lib.zulip_tools import su_to_zulip
|
||||
|
||||
su_to_zulip()
|
||||
os.environ["DJANGO_SETTINGS_MODULE"] = "zproject.settings"
|
||||
from django.conf import settings
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Reindex all text indexes, for glibc upgrades. This will take a write lock on every table, unless --concurrently is passed."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--concurrently", action="store_true", help="reindex concurrently, on Pg ≥ 11; takes longer"
|
||||
)
|
||||
parser.add_argument("--force", action="store_true", help="run the reindexing")
|
||||
options = parser.parse_args()
|
||||
|
||||
logging.Formatter.converter = time.gmtime
|
||||
logging.basicConfig(format="%(asctime)s %(levelname)s: %(message)s")
|
||||
logger = logging.getLogger("reindex-textual-data")
|
||||
logger.setLevel(logging.DEBUG)
|
||||
|
||||
pg_args = {}
|
||||
pg_args["host"] = settings.DATABASES["default"]["HOST"]
|
||||
pg_args["port"] = settings.DATABASES["default"].get("PORT")
|
||||
pg_args["password"] = settings.DATABASES["default"].get("PASSWORD")
|
||||
pg_args["user"] = settings.DATABASES["default"]["USER"]
|
||||
pg_args["dbname"] = settings.DATABASES["default"]["NAME"]
|
||||
pg_args["sslmode"] = settings.DATABASES["default"]["OPTIONS"].get("sslmode")
|
||||
pg_args["connect_timeout"] = "600"
|
||||
|
||||
# connection_factory=None lets mypy understand the return type
|
||||
conn = psycopg2.connect(connection_factory=None, **pg_args)
|
||||
conn.autocommit = True
|
||||
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT
|
||||
irel.relname AS index_name,
|
||||
trel.relname AS table_name,
|
||||
pg_size_pretty(pg_table_size(i.indrelid)) AS size
|
||||
FROM
|
||||
pg_index AS i
|
||||
JOIN pg_class AS trel ON trel.oid = i.indrelid
|
||||
JOIN pg_namespace AS tnsp ON trel.relnamespace = tnsp.oid
|
||||
JOIN pg_class AS irel ON irel.oid = i.indexrelid
|
||||
JOIN pg_attribute AS a ON a.attrelid = i.indexrelid
|
||||
WHERE tnsp.nspname = 'zulip'
|
||||
AND a.attcollation != 0
|
||||
GROUP BY 1, 2, i.indrelid
|
||||
ORDER BY pg_table_size(i.indrelid) ASC
|
||||
"""
|
||||
)
|
||||
reindex_targets = cursor.fetchall()
|
||||
|
||||
if not options.force:
|
||||
print("Would reindex the following indexes:")
|
||||
for index, _, _ in reindex_targets:
|
||||
print(f" {index}")
|
||||
print(
|
||||
"""
|
||||
|
||||
Re-run with --force to reindex these. Without --concurrently, these
|
||||
index rebuilds will TAKE A WRITE LOCK on their respective tables,
|
||||
which means that production traffic will be affected.
|
||||
|
||||
On PostgreSQL 11 and above, you can pass --concurrently, which will do
|
||||
the index rebuilds without taking such a lock. We recommend only using
|
||||
--concurrently if the Zulip server will be serving traffic while
|
||||
running this tool, because concurrent reindexing takes longer to
|
||||
complete even if the server is not running.
|
||||
"""
|
||||
)
|
||||
sys.exit(0)
|
||||
|
||||
had_failures = False
|
||||
for index_name, table_name, index_size in reindex_targets:
|
||||
if options.concurrently:
|
||||
command = SQL("REINDEX INDEX CONCURRENTLY {}").format(Identifier(index_name))
|
||||
else:
|
||||
command = SQL("REINDEX INDEX {}").format(Identifier(index_name))
|
||||
|
||||
logger.info("%s -- on %s, %s", command, table_name, index_size)
|
||||
try:
|
||||
cursor.execute(command)
|
||||
except psycopg2.OperationalError as e:
|
||||
logger.warning("Failed to reindex %s: %s", index_name, e)
|
||||
had_failures = True
|
||||
|
||||
if not had_failures:
|
||||
sys.exit(0)
|
||||
|
||||
print(
|
||||
"""
|
||||
=========================> REINDEXING FAILED <=========================
|
||||
|
||||
Reindexing failed on one or more indexes; this is generally caused
|
||||
by duplicate rows that had been incorrectly inserted because of
|
||||
corrupted database indexes. This happens, for example, when glibc was
|
||||
upgraded to 2.28 or higher, which has a different ordering of text
|
||||
("collation") than earlier versions. Because of the new ordering,
|
||||
entries in the indexes are not found (because they exist in a
|
||||
different place), which results in duplicates being created in the
|
||||
database for columns which should be unique.
|
||||
|
||||
There is no generalized tool to fix these duplicate rows, but in most cases
|
||||
the database can be repaired with some manual work. We are using
|
||||
this chat.zulip.org for support around such repairs:
|
||||
|
||||
https://chat.zulip.org/#narrow/channel/31-production-help/topic/database.20corruption
|
||||
|
||||
This tool has regenerated all database indexes that do not have duplicate rows.
|
||||
Once you have manually repaired duplicate rows, you can rerun this command
|
||||
to regenerate the rest.
|
||||
|
||||
It is reasonable to run your Zulip server to avoid downtime while you
|
||||
plan the manual repair work. Additional duplicate rows may be created
|
||||
for any corrupted index that has not yet been regenerated. But any new
|
||||
duplicate rows will be similar to existing duplicate rows that you
|
||||
already need to manually repair.
|
||||
"""
|
||||
)
|
||||
sys.exit(1)
|
||||
187
scripts/setup/restore-backup
Executable file
187
scripts/setup/restore-backup
Executable file
@@ -0,0 +1,187 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from typing import IO
|
||||
|
||||
BASE_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
sys.path.append(BASE_DIR)
|
||||
from scripts.lib.zulip_tools import assert_running_as_root, get_postgres_pwent, run, su_to_zulip
|
||||
|
||||
POSTGRES_PWENT = get_postgres_pwent()
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("tarball", help="Filename of input tarball")
|
||||
parser.add_argument(
|
||||
"--keep-settings", help="Do not overwrite local /etc/zulip/settings.py", action="store_true"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--keep-zulipconf", help="Do not overwrite local /etc/zulip/zulip.conf", action="store_true"
|
||||
)
|
||||
|
||||
|
||||
def restore_backup(tarball_file: IO[bytes], keep_settings: bool, keep_zulipconf: bool) -> None:
|
||||
assert_running_as_root()
|
||||
su_to_zulip(save_suid=True)
|
||||
|
||||
from scripts.lib.setup_path import setup_path
|
||||
|
||||
setup_path()
|
||||
|
||||
# First, we unpack the /etc/zulip configuration, so we know how
|
||||
# this server is supposed to be configured (and can import
|
||||
# /etc/zulip/settings.py via `from django.conf import settings`,
|
||||
# next). Ignore errors if zulip-backup/settings is not present
|
||||
# (E.g. because this is a development backup).
|
||||
tarball_file.seek(0, 0)
|
||||
excludes = []
|
||||
if keep_settings:
|
||||
excludes += ["--exclude=settings.py"]
|
||||
if keep_zulipconf:
|
||||
excludes += ["--exclude=zulip.conf"]
|
||||
run(
|
||||
[
|
||||
"tar",
|
||||
"--directory=/etc/zulip",
|
||||
"--keep-directory-symlink",
|
||||
*excludes,
|
||||
"--strip-components=2",
|
||||
"-xz",
|
||||
"zulip-backup/settings",
|
||||
],
|
||||
stdin=tarball_file,
|
||||
)
|
||||
|
||||
os.environ["DJANGO_SETTINGS_MODULE"] = "zproject.settings"
|
||||
from django.conf import settings
|
||||
|
||||
paths = [
|
||||
# zproject will only be present for development environment backups.
|
||||
("zproject", os.path.join(settings.DEPLOY_ROOT, "zproject")),
|
||||
]
|
||||
if settings.LOCAL_UPLOADS_DIR is not None:
|
||||
# We only need to restore LOCAL_UPLOADS_DIR if the system is
|
||||
# configured to locally host uploads.
|
||||
paths.append(("uploads", os.path.join(settings.DEPLOY_ROOT, settings.LOCAL_UPLOADS_DIR)))
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="zulip-restore-backup-") as tmp:
|
||||
uid = os.getuid()
|
||||
gid = os.getgid()
|
||||
os.setresuid(0, 0, 0)
|
||||
for name, path in paths:
|
||||
os.makedirs(path, exist_ok=True)
|
||||
os.chown(path, uid, gid)
|
||||
os.setresuid(uid, uid, 0)
|
||||
|
||||
assert not any("|" in name or "|" in path for name, path in paths)
|
||||
transform_args = [
|
||||
r"--transform=s|^zulip-backup/{}(/.*)?$|{}\1|x".format(
|
||||
re.escape(name),
|
||||
path.replace("\\", r"\\"),
|
||||
)
|
||||
for name, path in paths
|
||||
]
|
||||
|
||||
os.mkdir(os.path.join(tmp, "zulip-backup"))
|
||||
tarball_file.seek(0, 0)
|
||||
run(
|
||||
["tar", "-C", tmp, "--keep-directory-symlink", *transform_args, "-xPz"],
|
||||
stdin=tarball_file,
|
||||
)
|
||||
|
||||
# Now, extract the database backup, destroy the old
|
||||
# database, and create a new, empty database.
|
||||
db = settings.DATABASES["default"]
|
||||
assert isinstance(db["NAME"], str)
|
||||
db_dir = os.path.join(tmp, "zulip-backup", "database")
|
||||
os.setresuid(0, 0, 0)
|
||||
run(["chown", "-R", POSTGRES_PWENT.pw_name, "--", tmp])
|
||||
# Open the terminate-psql-sessions script while still running
|
||||
# as root, since the postgres user may lack permission to read
|
||||
# it -- e.g. because /home/zulip is not world-readable, which
|
||||
# is the default for home directories on Debian 13 and Ubuntu
|
||||
# 21.04 and newer.
|
||||
terminate_psql_sessions_fd = os.open(
|
||||
os.path.join(settings.DEPLOY_ROOT, "scripts", "setup", "terminate-psql-sessions"),
|
||||
os.O_RDONLY,
|
||||
)
|
||||
os.setresuid(POSTGRES_PWENT.pw_uid, POSTGRES_PWENT.pw_uid, 0)
|
||||
|
||||
postgresql_env = dict(os.environ)
|
||||
if db["HOST"] not in ["", "localhost"]:
|
||||
postgresql_env["PGHOST"] = db["HOST"]
|
||||
if "PORT" in db:
|
||||
postgresql_env["PGPORT"] = str(db["PORT"])
|
||||
postgresql_env["PGUSER"] = db["USER"]
|
||||
if "PASSWORD" in db:
|
||||
postgresql_env["PGPASSWORD"] = db["PASSWORD"]
|
||||
|
||||
run(
|
||||
["bash", "-s", "-", db["NAME"]],
|
||||
stdin=terminate_psql_sessions_fd,
|
||||
env=postgresql_env,
|
||||
)
|
||||
os.close(terminate_psql_sessions_fd)
|
||||
run(["dropdb", "--if-exists", "--", db["NAME"]], cwd="/", env=postgresql_env)
|
||||
run(
|
||||
["createdb", "--owner=zulip", "--template=template0", "--", db["NAME"]],
|
||||
cwd="/",
|
||||
env=postgresql_env,
|
||||
)
|
||||
os.setresuid(0, 0, 0)
|
||||
|
||||
if settings.PRODUCTION:
|
||||
# In case we are restoring a backup from an older Zulip
|
||||
# version, there may be new secrets to generate.
|
||||
run(
|
||||
[
|
||||
os.path.join(settings.DEPLOY_ROOT, "scripts", "setup", "generate_secrets.py"),
|
||||
"--production",
|
||||
]
|
||||
)
|
||||
|
||||
# If there is a local RabbitMQ, we need to reconfigure it
|
||||
# to ensure the RabbitMQ password matches the value in the
|
||||
# restored zulip-secrets.conf. We need to be careful to
|
||||
# only do this if RabbitMQ is configured to run locally on
|
||||
# the system.
|
||||
rabbitmq_host = subprocess.check_output(
|
||||
[
|
||||
os.path.join(settings.DEPLOY_ROOT, "scripts", "get-django-setting"),
|
||||
"RABBITMQ_HOST",
|
||||
],
|
||||
text=True,
|
||||
).strip()
|
||||
if rabbitmq_host in ["127.0.0.1", "::1", "localhost", "localhost6"]:
|
||||
run([os.path.join(settings.DEPLOY_ROOT, "scripts", "setup", "configure-rabbitmq")])
|
||||
|
||||
# In production, we also need to do a `zulip-puppet-apply`
|
||||
# in order to apply any configuration from
|
||||
# /etc/zulip/zulip.conf to this system, since it was
|
||||
# originally installed without the restored copy of that
|
||||
# file.
|
||||
run([os.path.join(settings.DEPLOY_ROOT, "scripts", "zulip-puppet-apply"), "-f"])
|
||||
|
||||
# Now, restore the database backup using pg_restore. This
|
||||
# needs to run after zulip-puppet-apply to ensure full-text
|
||||
# search extensions are available and installed.
|
||||
os.setresuid(POSTGRES_PWENT.pw_uid, POSTGRES_PWENT.pw_uid, 0)
|
||||
run(["pg_restore", "--dbname=" + db["NAME"], "--", db_dir], cwd="/", env=postgresql_env)
|
||||
os.setresuid(0, 0, 0)
|
||||
run(["chown", "-R", str(uid), "--", tmp])
|
||||
os.setresuid(uid, uid, 0)
|
||||
|
||||
if settings.PRODUCTION:
|
||||
run([os.path.join(settings.DEPLOY_ROOT, "scripts", "restart-server")])
|
||||
|
||||
run([os.path.join(settings.DEPLOY_ROOT, "scripts", "setup", "flush-memcached")])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
args = parser.parse_args()
|
||||
|
||||
with open(args.tarball, "rb") as tarball_file:
|
||||
restore_backup(tarball_file, args.keep_settings, args.keep_zulipconf)
|
||||
112
scripts/setup/setup-certbot
Executable file
112
scripts/setup/setup-certbot
Executable file
@@ -0,0 +1,112 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -e
|
||||
|
||||
usage() {
|
||||
cat <<EOF >&2
|
||||
Usage: $0 --email=admin@example.com [--method={webroot|standalone}] \
|
||||
hostname.example.com [another.example.com]
|
||||
EOF
|
||||
exit 1
|
||||
}
|
||||
|
||||
if [ "$EUID" -ne 0 ]; then
|
||||
echo "Error: This script must be run as root" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
method=webroot
|
||||
args="$(getopt -o '' --long help,email:,method:,agree-tos -n "$0" -- "$@")"
|
||||
eval "set -- $args"
|
||||
agree_tos=()
|
||||
while true; do
|
||||
case "$1" in
|
||||
--email)
|
||||
EMAIL="$2"
|
||||
shift
|
||||
shift
|
||||
;;
|
||||
--method)
|
||||
method="$2"
|
||||
shift
|
||||
shift
|
||||
;;
|
||||
--agree-tos)
|
||||
agree_tos=(--agree-tos)
|
||||
shift
|
||||
;;
|
||||
--help)
|
||||
show_help=1
|
||||
shift
|
||||
;;
|
||||
--)
|
||||
shift
|
||||
break
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Parse the remaining arguments as Subject Alternative Names to pass to certbot
|
||||
HOSTNAMES=()
|
||||
for arg; do
|
||||
HOSTNAMES+=(-d "$arg")
|
||||
done
|
||||
DOMAIN=$1
|
||||
|
||||
if [ -n "$show_help" ]; then
|
||||
usage
|
||||
fi
|
||||
|
||||
if [ -z "$DOMAIN" ] || [ -z "$EMAIL" ]; then
|
||||
usage
|
||||
fi
|
||||
|
||||
case "$method" in
|
||||
standalone)
|
||||
method_args=(--standalone --no-directory-hooks)
|
||||
;;
|
||||
webroot)
|
||||
method_args=(--webroot '--webroot-path=/var/lib/zulip/certbot-webroot/')
|
||||
;;
|
||||
*)
|
||||
usage
|
||||
;;
|
||||
esac
|
||||
|
||||
# If we aren't being run interactively, default to keeping the
|
||||
# existing certificate (rather than burning through a renewal)
|
||||
# If run interactively, certbot will prompt.
|
||||
default_keep=()
|
||||
if [ ! -t 0 ]; then
|
||||
default_keep=(--keep-until-expiring)
|
||||
fi
|
||||
|
||||
# We need to know _which_ domain is Zulip's, in the symlink deploy
|
||||
# hook, so we pass this down
|
||||
export ZULIP_DOMAIN="$DOMAIN"
|
||||
|
||||
# Certbot does not run deploy hooks on new certificates
|
||||
# (certbot/certbot#9978) so we will need to fake it if so
|
||||
if [ -d "/etc/letsencrypt/live/$DOMAIN/" ]; then
|
||||
needs_hooks=0
|
||||
else
|
||||
needs_hooks=1
|
||||
fi
|
||||
|
||||
certbot certonly "${method_args[@]}" \
|
||||
"${HOSTNAMES[@]}" -m "$EMAIL" \
|
||||
"${agree_tos[@]}" \
|
||||
"${default_keep[@]}" \
|
||||
--no-eff-email
|
||||
|
||||
# "certbot certonly" before version 3.2.0 does not run deploy hooks,
|
||||
# so we fake running them.
|
||||
if [ "$needs_hooks" = "1" ]; then
|
||||
export RENEWED_DOMAINS="$*"
|
||||
if [ "$method" == "webroot" ]; then
|
||||
for deploy_hook in /etc/letsencrypt/renewal-hooks/deploy/*; do
|
||||
"$deploy_hook"
|
||||
done
|
||||
fi
|
||||
fi
|
||||
echo "Certbot SSL certificate configuration succeeded."
|
||||
32
scripts/setup/sha256-file-to
Executable file
32
scripts/setup/sha256-file-to
Executable file
@@ -0,0 +1,32 @@
|
||||
#!/bin/sh
|
||||
|
||||
if [ "$#" -ne 3 ]; then
|
||||
echo "Usage:"
|
||||
echo " sha256-file-to SHA256 http://FETCH/FROM DST"
|
||||
echo
|
||||
echo "SHA256 is the sha256sum of the file fetched; the file is"
|
||||
echo "placed in DST."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
set -e
|
||||
set -x
|
||||
|
||||
SHA256="$1"
|
||||
URL="$2"
|
||||
DST="$3"
|
||||
|
||||
# Work in a tmpdir which we clean up at the end
|
||||
tmpdir="$(mktemp -d)"
|
||||
trap 'rm -r "$tmpdir"' EXIT
|
||||
cd "$tmpdir"
|
||||
|
||||
# Fetch to a predictable name, not whatever curl guesses from the URL
|
||||
LOCALFILE="output"
|
||||
curl -fL --retry 3 -o "$LOCALFILE" "$URL"
|
||||
|
||||
# Check the hash against what was passed in
|
||||
echo "$SHA256 $LOCALFILE" >"$LOCALFILE.sha256"
|
||||
sha256sum -c "$LOCALFILE.sha256"
|
||||
|
||||
mv "$LOCALFILE" "$DST"
|
||||
56
scripts/setup/sha256-tarball-to
Executable file
56
scripts/setup/sha256-tarball-to
Executable file
@@ -0,0 +1,56 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
if [ "$#" -lt 4 ]; then
|
||||
echo "Usage:"
|
||||
echo " sha256-tarball-to SHA256 http://FETCH/FROM.tar.gz SRC1 DST1 [SRC2 DST2 [...]]"
|
||||
echo
|
||||
echo "SHA256 is the sha256sum of the tarball fetched; each SRC (which may be a"
|
||||
echo "directory) is expected to be a relative path into the unpacked tarball,"
|
||||
echo "and each DST is the absolute path it should be moved to."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
set -e
|
||||
set -x
|
||||
|
||||
SHA256="$1"
|
||||
URL="$2"
|
||||
shift
|
||||
shift
|
||||
|
||||
# Work in a tmpdir which we clean up at the end
|
||||
tmpdir="$(mktemp -d)"
|
||||
trap 'rm -r "$tmpdir"' EXIT
|
||||
cd "$tmpdir"
|
||||
|
||||
# We support both .tar.gz and .zip files
|
||||
if [[ "$URL" == *.tar.gz ]] || [[ "$URL" == *.tgz ]]; then
|
||||
extension="tar.gz"
|
||||
elif [[ "$URL" == *.zip ]]; then
|
||||
extension="zip"
|
||||
else
|
||||
echo "Can't determine archive type from URL: $URL"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Fetch to a predictable name, not whatever curl guesses from the URL
|
||||
LOCALFILE="archive.$extension"
|
||||
curl -fL --retry 3 -o "$LOCALFILE" "$URL"
|
||||
|
||||
# Check the hash against what was passed in
|
||||
echo "$SHA256 $LOCALFILE" >"$LOCALFILE.sha256"
|
||||
sha256sum -c "$LOCALFILE.sha256"
|
||||
|
||||
if [[ "$extension" == "tar.gz" ]]; then
|
||||
tar xzf "$LOCALFILE"
|
||||
else
|
||||
unzip "$LOCALFILE"
|
||||
fi
|
||||
|
||||
# Take the rest of the arguments two-at-a-time, as source and
|
||||
# destination to move out of the unpacked tarball.
|
||||
while [ "$#" -gt 0 ]; do
|
||||
mv "$1" "$2"
|
||||
shift
|
||||
shift
|
||||
done
|
||||
17
scripts/setup/terminate-psql-sessions
Executable file
17
scripts/setup/terminate-psql-sessions
Executable file
@@ -0,0 +1,17 @@
|
||||
#!/usr/bin/env bash
|
||||
set -e
|
||||
|
||||
cd /
|
||||
|
||||
tables="$(printf "'%s'," "${@//\'/\'\'}")"
|
||||
tables="${tables%,}"
|
||||
|
||||
psql postgres -v ON_ERROR_STOP=1 <<EOF
|
||||
SELECT pg_terminate_backend(s.pid)
|
||||
FROM pg_stat_activity s, pg_roles r
|
||||
WHERE
|
||||
s.datname IN ($tables)
|
||||
AND r.rolname = CURRENT_USER
|
||||
AND (s.usename = r.rolname OR r.rolsuper = 't')
|
||||
AND s.pid <> pg_backend_pid();
|
||||
EOF
|
||||
149
scripts/setup/upgrade-postgresql
Executable file
149
scripts/setup/upgrade-postgresql
Executable file
@@ -0,0 +1,149 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
if [ "$EUID" -ne 0 ]; then
|
||||
echo "Error: This script must be run as root" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Force a known locale; some OS X terminals set an invalid LC_CTYPE
|
||||
# which crashes pg_upgradecluster
|
||||
export LC_ALL=C.UTF-8
|
||||
export LANG=C.UTF-8
|
||||
export LANGUAGE=C.UTF-8
|
||||
|
||||
LATEST_SUPPORTED_VERSION=18
|
||||
UPGRADE_TO=${1:-$LATEST_SUPPORTED_VERSION}
|
||||
UPGRADE_FROM=$(crudini --get /etc/zulip/zulip.conf postgresql version)
|
||||
ZULIP_PATH="$(dirname "$0")/../.."
|
||||
|
||||
if [ "$UPGRADE_TO" = "$UPGRADE_FROM" ]; then
|
||||
echo "Already running PostgreSQL $UPGRADE_TO!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$UPGRADE_TO" -lt "$UPGRADE_FROM" ]]; then
|
||||
echo "Refusing to downgrade PostgreSQL $UPGRADE_FROM to $UPGRADE_TO!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$UPGRADE_TO" -gt "$LATEST_SUPPORTED_VERSION" ]]; then
|
||||
echo "This version of Zulip does not support PostgreSQL $UPGRADE_TO."
|
||||
echo "You may need to upgrade Zulip before you can upgrade to PostgreSQL $UPGRADE_TO."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Verify that the version in /etc/zulip/zulip.conf is the version that
|
||||
# Django actually stores its data in. We can only do that if the
|
||||
# database server is on the same host as the application server.
|
||||
if [ -d /home/zulip/deployments/current ]; then
|
||||
DATA_IS_IN=$(
|
||||
su -s /usr/bin/env -- zulip \
|
||||
DJANGO_SETTINGS_MODULE=zproject.settings \
|
||||
uv run --directory=/home/zulip/deployments/current --no-sync \
|
||||
python -c 'from django.db import connection; print(connection.cursor().connection.server_version // 10000)'
|
||||
)
|
||||
|
||||
if [ "$UPGRADE_FROM" != "$DATA_IS_IN" ]; then
|
||||
cat <<EOF
|
||||
|
||||
/etc/zulip/zulip.conf claims that Zulip is running PostgreSQL
|
||||
$UPGRADE_FROM, but the server is connected to a PostgreSQL running
|
||||
version $DATA_IS_IN. Check the output from pg_lsclusters to verify
|
||||
which clusters are running, and update /etc/zulip/zulip.conf to match.
|
||||
|
||||
In general, this results from manually upgrading PostgreSQL; you
|
||||
should use this tool for all PostgreSQL upgrades.
|
||||
EOF
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
set -x
|
||||
|
||||
"$ZULIP_PATH"/scripts/lib/setup-apt-repo
|
||||
apt-get install -y "postgresql-$UPGRADE_TO" "postgresql-client-$UPGRADE_TO"
|
||||
drop_main_cluster="$(
|
||||
pg_lsclusters --json \
|
||||
| jq --arg pg_version "$UPGRADE_TO" -r \
|
||||
'.[] | select((.version|tostring == $ARGS.named.pg_version) and (.cluster == "main")) | .cluster'
|
||||
)"
|
||||
if [ -n "$drop_main_cluster" ]; then
|
||||
pg_dropcluster "$UPGRADE_TO" main --stop
|
||||
fi
|
||||
|
||||
(
|
||||
# Two-stage application of Puppet; we apply the bare-bones
|
||||
# PostgreSQL configuration first, so that FTS will be configured
|
||||
# prior to the pg_upgradecluster.
|
||||
TEMP_CONF_DIR=$(mktemp -d)
|
||||
cp /etc/zulip/zulip.conf "$TEMP_CONF_DIR"
|
||||
ZULIP_CONF="${TEMP_CONF_DIR}/zulip.conf"
|
||||
crudini --set "$ZULIP_CONF" postgresql version "$UPGRADE_TO"
|
||||
|
||||
if [ -f "/usr/share/postgresql/$UPGRADE_FROM/pgroonga_setup.sql.applied" ]; then
|
||||
# This file is intentionally blank, to trigger always running
|
||||
# `ALTER EXTENSION pgroonga UPDATE` and not `CREATE EXTENSION pgroonga`.
|
||||
touch "/usr/share/postgresql/$UPGRADE_TO/pgroonga_setup.sql.applied"
|
||||
fi
|
||||
|
||||
"$ZULIP_PATH"/scripts/zulip-puppet-apply -f --config "$ZULIP_CONF" --tags postgresql_upgrade
|
||||
rm -rf "$TEMP_CONF_DIR"
|
||||
)
|
||||
|
||||
# Capture the output so we know where the path to the post-upgrade scripts is
|
||||
UPGRADE_LOG=$(mktemp "/var/log/zulip/upgrade-postgresql-$UPGRADE_FROM-$UPGRADE_TO.XXXXXXXXX.log")
|
||||
pg_upgradecluster -v "$UPGRADE_TO" "$UPGRADE_FROM" main --method=upgrade --link --no-start | tee "$UPGRADE_LOG"
|
||||
SCRIPTS_PATH=$(grep -o "/var/log/postgresql/pg_upgradecluster-$UPGRADE_FROM-$UPGRADE_TO-main.*" "$UPGRADE_LOG" || true)
|
||||
|
||||
# If the upgrade completed successfully, lock in the new version in
|
||||
# our configuration immediately
|
||||
crudini --set /etc/zulip/zulip.conf postgresql version "$UPGRADE_TO"
|
||||
|
||||
# Make sure the new PostgreSQL is running
|
||||
pg_ctlcluster "$UPGRADE_TO" main start
|
||||
|
||||
# Update the statistics
|
||||
su postgres -c "/usr/lib/postgresql/$UPGRADE_TO/bin/vacuumdb --all --analyze-only --jobs 10"
|
||||
|
||||
# Update extensions
|
||||
if [ -n "$SCRIPTS_PATH" ] && [ -f "$SCRIPTS_PATH/update_extensions.sql" ]; then
|
||||
su postgres -c "psql --file $SCRIPTS_PATH/update_extensions.sql"
|
||||
rm "$SCRIPTS_PATH/update_extensions.sql"
|
||||
fi
|
||||
|
||||
# Start the database up cleanly
|
||||
"$ZULIP_PATH"/scripts/zulip-puppet-apply -f
|
||||
|
||||
# Drop the old data, binaries, and scripts
|
||||
pg_dropcluster "$UPGRADE_FROM" main
|
||||
apt remove -y "postgresql-$UPGRADE_FROM"
|
||||
if [ -n "$SCRIPTS_PATH" ] && [ -f "$SCRIPTS_PATH/delete_old_cluster.sh" ]; then
|
||||
su postgres -c "$SCRIPTS_PATH/delete_old_cluster.sh"
|
||||
rm "$SCRIPTS_PATH/delete_old_cluster.sh"
|
||||
fi
|
||||
|
||||
if [ -n "$SCRIPTS_PATH" ]; then
|
||||
if [ -n "$(ls -A "$SCRIPTS_PATH")" ]; then
|
||||
set +x
|
||||
echo
|
||||
echo
|
||||
echo ">>>>> pg_upgradecluster succeeded, but unexpected post-upgrade scripts"
|
||||
echo " were found:"
|
||||
ls -A1 "$SCRIPTS_PATH"
|
||||
else
|
||||
rmdir "$SCRIPTS_PATH"
|
||||
fi
|
||||
else
|
||||
set +x
|
||||
echo
|
||||
echo
|
||||
echo ">>>>> pg_upgradecluster succeeded, but post-upgrade scripts path could not"
|
||||
echo " be parsed out! Please read the pg_upgradecluster output to understand"
|
||||
echo " the current status of your cluster:"
|
||||
echo " $UPGRADE_LOG"
|
||||
echo " and report this bug with the PostgreSQL $UPGRADE_FROM -> $UPGRADE_TO upgrade to:"
|
||||
echo " https://github.com/zulip/zulip/issues"
|
||||
echo
|
||||
echo
|
||||
fi
|
||||
1
scripts/start-server
Symbolic link
1
scripts/start-server
Symbolic link
@@ -0,0 +1 @@
|
||||
restart-server
|
||||
83
scripts/stop-server
Executable file
83
scripts/stop-server
Executable file
@@ -0,0 +1,83 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import logging
|
||||
import os
|
||||
import pwd
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
sys.path.append(os.path.join(os.path.dirname(__file__), ".."))
|
||||
from scripts.lib.setup_path import setup_path
|
||||
|
||||
setup_path()
|
||||
|
||||
from scripts.lib.supervisor import list_supervisor_processes
|
||||
from scripts.lib.zulip_tools import (
|
||||
ENDC,
|
||||
OKGREEN,
|
||||
WARNING,
|
||||
has_application_server,
|
||||
has_process_fts_updates,
|
||||
su_to_zulip,
|
||||
)
|
||||
|
||||
# Parse arguments before any side effects, so `--help` prints usage
|
||||
# instead of stopping the server.
|
||||
argparse.ArgumentParser(
|
||||
description="Stop the Zulip server (Django, Tornado and workers).",
|
||||
).parse_args()
|
||||
|
||||
deploy_path = os.path.realpath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
os.chdir(deploy_path)
|
||||
|
||||
username = pwd.getpwuid(os.getuid()).pw_name
|
||||
if username == "root":
|
||||
su_to_zulip()
|
||||
elif username != "zulip":
|
||||
logging.error("Must be run as user 'zulip'.")
|
||||
sys.exit(1)
|
||||
|
||||
logging.Formatter.converter = time.gmtime
|
||||
logging.basicConfig(format="%(asctime)s stop-server: %(message)s", level=logging.INFO)
|
||||
|
||||
services = []
|
||||
|
||||
# Start with the least-critical services:
|
||||
if has_process_fts_updates():
|
||||
services.append("process-fts-updates")
|
||||
|
||||
if has_application_server():
|
||||
# Contrary to the order in (re)start-server, we stop django before the
|
||||
# workers, to increase the chance that we finish processing any work
|
||||
# that may have been enqueued by the Django, leaving the final state
|
||||
# closer to "empty." We stop Django before Tornado so it doesn't try
|
||||
# to make requests to make queues with a down'd Tornado.
|
||||
services.append("zulip-django")
|
||||
services.append("zulip-tornado:*")
|
||||
services.append("zulip-workers:*")
|
||||
services.append("zulip-tus")
|
||||
services.append("zulip-katex")
|
||||
services.append("zulip-email-server")
|
||||
if has_application_server(once=True):
|
||||
# These used to be included in "zulip-workers:*"; we may be
|
||||
# stopping an older version of Zulip, which has not applied
|
||||
# puppet to reload the new list of processes, but the
|
||||
# list_supervisor_processes below will filter them out if they
|
||||
# do not exist.
|
||||
services.append("zulip_deliver_scheduled_emails")
|
||||
services.append("zulip_deliver_scheduled_messages")
|
||||
|
||||
services = list_supervisor_processes(services, only_running=True)
|
||||
if services:
|
||||
subprocess.check_call(["supervisorctl", "stop", *services])
|
||||
|
||||
print()
|
||||
print(OKGREEN + "Zulip stopped successfully!" + ENDC)
|
||||
|
||||
using_sso = subprocess.check_output(["./scripts/get-django-setting", "USING_APACHE_SSO"])
|
||||
if using_sso.strip() == b"True":
|
||||
print()
|
||||
print(WARNING + "Apache2 needs to be shut down; as root, run:" + ENDC)
|
||||
print(" service apache2 stop")
|
||||
print()
|
||||
26
scripts/upgrade-zulip
Executable file
26
scripts/upgrade-zulip
Executable file
@@ -0,0 +1,26 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# This is a thin wrapper around the upgrade script (scripts/lib/upgrade-zulip).
|
||||
# This wrapper exists to log output to /var/log/zulip/upgrade.log for debugging.
|
||||
|
||||
set -e
|
||||
|
||||
if [ "$EUID" -ne 0 ]; then
|
||||
basename=$(basename "$0")
|
||||
echo "Error: $basename must be run as root." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
"$(dirname "$0")/lib/upgrade-zulip" "$@" 2>&1 | tee -a /var/log/zulip/upgrade.log
|
||||
failed=${PIPESTATUS[0]}
|
||||
|
||||
if [ "$failed" -ne 0 ]; then
|
||||
echo -e '\033[0;31m'
|
||||
echo "Zulip upgrade failed (exit code $failed)!"
|
||||
echo
|
||||
echo -n "The upgrade process is designed to be idempotent, so you can retry "
|
||||
echo -n "after resolving whatever issue caused the failure (there should be a traceback above). "
|
||||
echo -n "A log of this installation is available in /var/log/zulip/upgrade.log"
|
||||
echo -e '\033[0m'
|
||||
exit "$failed"
|
||||
fi
|
||||
26
scripts/upgrade-zulip-from-git
Executable file
26
scripts/upgrade-zulip-from-git
Executable file
@@ -0,0 +1,26 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# This is a thin wrapper around the upgrade-from-git script (scripts/lib/upgrade-zulip-from-git).
|
||||
# This wrapper exists to log output to /var/log/zulip/upgrade.log for debugging.
|
||||
|
||||
set -e
|
||||
|
||||
if [ "$EUID" -ne 0 ]; then
|
||||
basename=$(basename "$0")
|
||||
echo "Error: $basename must be run as root." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
"$(dirname "$0")/lib/upgrade-zulip-from-git" "$@" 2>&1 | tee -a /var/log/zulip/upgrade.log
|
||||
failed=${PIPESTATUS[0]}
|
||||
|
||||
if [ "$failed" -ne 0 ]; then
|
||||
echo -e '\033[0;31m'
|
||||
echo "Zulip upgrade failed (exit code $failed)!"
|
||||
echo
|
||||
echo -n "The upgrade process is designed to be idempotent, so you can retry "
|
||||
echo -n "after resolving whatever issue caused the failure (there should be a traceback above). "
|
||||
echo -n "A log of this installation is available in /var/log/zulip/upgrade.log"
|
||||
echo -e '\033[0m'
|
||||
exit "$failed"
|
||||
fi
|
||||
114
scripts/zulip-puppet-apply
Executable file
114
scripts/zulip-puppet-apply
Executable file
@@ -0,0 +1,114 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import configparser
|
||||
import os
|
||||
import platform
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
import yaml
|
||||
|
||||
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
sys.path.insert(0, BASE_DIR)
|
||||
|
||||
from scripts.lib.puppet_cache import setup_puppet_modules
|
||||
from scripts.lib.zulip_tools import assert_running_as_root
|
||||
|
||||
assert_running_as_root()
|
||||
|
||||
parser = argparse.ArgumentParser(description="Run Puppet")
|
||||
parser.add_argument(
|
||||
"--force", "-f", action="store_true", help="Do not prompt with proposed changes"
|
||||
)
|
||||
parser.add_argument("--noop", action="store_true", help="Do not apply the changes")
|
||||
parser.add_argument("--config", default="/etc/zulip/zulip.conf", help="Alternate zulip.conf path")
|
||||
args, extra_args = parser.parse_known_args()
|
||||
|
||||
config = configparser.RawConfigParser()
|
||||
config.read(args.config)
|
||||
|
||||
setup_puppet_modules()
|
||||
|
||||
distro_info = platform.freedesktop_os_release()
|
||||
puppet_config = """
|
||||
Exec { path => "/usr/sbin:/usr/bin:/sbin:/bin" }
|
||||
"""
|
||||
|
||||
for pclass in re.split(r"\s*,\s*", config.get("machine", "puppet_classes")):
|
||||
if " " in pclass:
|
||||
print(
|
||||
f"The `machine.puppet_classes` setting in {args.config} must be comma-separated, not space-separated!"
|
||||
)
|
||||
sys.exit(1)
|
||||
puppet_config += f"include {pclass}\n"
|
||||
|
||||
# We use the Puppet configuration from the same Zulip checkout as this script
|
||||
scripts_path = os.path.join(BASE_DIR, "scripts")
|
||||
puppet_module_path = os.path.join(BASE_DIR, "puppet")
|
||||
puppet_cmd = [
|
||||
"puppet",
|
||||
"apply",
|
||||
f"--modulepath={puppet_module_path}:/srv/zulip-puppet-cache/current",
|
||||
"-e",
|
||||
puppet_config,
|
||||
]
|
||||
if args.noop:
|
||||
puppet_cmd += ["--noop"]
|
||||
puppet_cmd += extra_args
|
||||
|
||||
# Set the scripts path to be a factor so it can be used by Puppet code
|
||||
puppet_env = os.environ.copy()
|
||||
puppet_env["FACTER_zulip_conf_path"] = args.config
|
||||
puppet_env["FACTER_zulip_scripts_path"] = scripts_path
|
||||
|
||||
|
||||
def noop_would_change(puppet_cmd: list[str]) -> bool:
|
||||
# --noop does not work with --detailed-exitcodes; see
|
||||
# https://tickets.puppetlabs.com/browse/PUP-686
|
||||
with tempfile.NamedTemporaryFile() as lastrun_file:
|
||||
try:
|
||||
subprocess.check_call(
|
||||
# puppet_cmd may already contain --noop, but it is safe to
|
||||
# supply twice
|
||||
[*puppet_cmd, "--noop", "--lastrunfile", lastrun_file.name],
|
||||
env=puppet_env,
|
||||
)
|
||||
except subprocess.CalledProcessError:
|
||||
sys.exit(2)
|
||||
|
||||
# Reopen the file since Puppet rewrote it with a new inode
|
||||
with open(lastrun_file.name) as lastrun:
|
||||
lastrun_data = yaml.safe_load(lastrun)
|
||||
resources = lastrun_data.get("resources", {})
|
||||
if resources.get("failed", 0) != 0:
|
||||
sys.exit(2)
|
||||
return resources.get("out_of_sync", 0) != 0
|
||||
|
||||
|
||||
if not args.noop and not args.force:
|
||||
if not noop_would_change([*puppet_cmd, "--show_diff"]):
|
||||
sys.exit(0)
|
||||
|
||||
do_apply = None
|
||||
while do_apply != "y":
|
||||
sys.stdout.write("Apply changes? [y/N] ")
|
||||
sys.stdout.flush()
|
||||
do_apply = sys.stdin.readline().strip().lower()
|
||||
if do_apply in ("", "n"):
|
||||
sys.exit(0)
|
||||
|
||||
if args.noop and args.force:
|
||||
if noop_would_change(puppet_cmd):
|
||||
sys.exit(1)
|
||||
else:
|
||||
sys.exit(0)
|
||||
|
||||
ret = subprocess.call([*puppet_cmd, "--detailed-exitcodes"], env=puppet_env)
|
||||
# ret = 0 => no changes, no errors
|
||||
# ret = 2 => changes, no errors
|
||||
# ret = 4 => no changes, yes errors
|
||||
# ret = 6 => changes, yes errors
|
||||
if ret not in (0, 2):
|
||||
sys.exit(2)
|
||||
Reference in New Issue
Block a user