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:
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())
|
||||
Reference in New Issue
Block a user