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:
365
tools/droplets/create.py
Normal file
365
tools/droplets/create.py
Normal file
@@ -0,0 +1,365 @@
|
||||
# Creates a Zulip remote development environment droplet or
|
||||
# a production droplet in DigitalOcean.
|
||||
#
|
||||
# Particularly useful for sprints/hackathons, interns, and other
|
||||
# situation where one wants to quickly onboard new contributors.
|
||||
#
|
||||
# Requires python-digitalocean library:
|
||||
# https://github.com/koalalorenzo/python-digitalocean
|
||||
#
|
||||
# Also requires DigitalOcean team membership for Zulip and API token:
|
||||
# https://cloud.digitalocean.com/settings/api/tokens
|
||||
#
|
||||
# Copy conf.ini-template to conf.ini and populate with your API token.
|
||||
import argparse
|
||||
import configparser
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
import digitalocean
|
||||
import requests
|
||||
|
||||
parser = argparse.ArgumentParser(description="Create a Zulip development VM DigitalOcean droplet.")
|
||||
parser.add_argument(
|
||||
"username", help="GitHub username for whom you want to create a Zulip dev droplet"
|
||||
)
|
||||
parser.add_argument("--tags", nargs="+", default=[])
|
||||
parser.add_argument("-f", "--recreate", action="store_true")
|
||||
parser.add_argument("-s", "--subdomain")
|
||||
parser.add_argument("-p", "--production", action="store_true")
|
||||
parser.add_argument("-r", "--region", choices=("nyc3", "sfo3", "blr1", "fra1"), default="nyc3")
|
||||
|
||||
|
||||
def get_config() -> configparser.ConfigParser:
|
||||
config = configparser.ConfigParser()
|
||||
config.read(os.path.join(os.path.dirname(os.path.abspath(__file__)), "conf.ini"))
|
||||
return config
|
||||
|
||||
|
||||
def assert_github_user_exists(github_username: str) -> bool:
|
||||
print(f"Checking to see if GitHub user {github_username} exists...")
|
||||
user_api_url = f"https://api.github.com/users/{github_username}"
|
||||
try:
|
||||
response = urllib.request.urlopen(user_api_url)
|
||||
json.load(response)
|
||||
print("...user exists!")
|
||||
return True
|
||||
except urllib.error.HTTPError as err:
|
||||
print(err)
|
||||
print(f"Does the GitHub user {github_username} exist?")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def get_ssh_public_keys_from_github(github_username: str) -> list[str]:
|
||||
print("Checking to see that GitHub user has available public keys...")
|
||||
apiurl_keys = f"https://api.github.com/users/{github_username}/keys"
|
||||
try:
|
||||
response = urllib.request.urlopen(apiurl_keys)
|
||||
userkeys = json.load(response)
|
||||
if not userkeys:
|
||||
print(
|
||||
f"No keys found. Has user {github_username} added SSH keys to their GitHub account?"
|
||||
)
|
||||
sys.exit(1)
|
||||
print("...public keys found!")
|
||||
return [k["key"] for k in userkeys]
|
||||
except urllib.error.HTTPError as err:
|
||||
print(err)
|
||||
print(f"Has user {github_username} added SSH keys to their GitHub account?")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def assert_user_forked_zulip_server_repo(username: str) -> bool:
|
||||
print("Checking to see GitHub user has forked zulip/zulip...")
|
||||
apiurl_fork = f"https://api.github.com/repos/{username}/zulip"
|
||||
try:
|
||||
response = urllib.request.urlopen(apiurl_fork)
|
||||
json.load(response)
|
||||
print("...fork found!")
|
||||
return True
|
||||
except urllib.error.HTTPError as err:
|
||||
print(err)
|
||||
print(f"Has user {username} forked zulip/zulip?")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def assert_droplet_does_not_exist(my_token: str, droplet_name: str, recreate: bool) -> None:
|
||||
print(f"Checking to see if droplet {droplet_name} already exists...")
|
||||
manager = digitalocean.Manager(token=my_token)
|
||||
my_droplets = manager.get_all_droplets()
|
||||
for droplet in my_droplets:
|
||||
if droplet.name.lower() == droplet_name:
|
||||
if not recreate:
|
||||
print(
|
||||
f"Droplet {droplet_name} already exists. Pass --recreate if you "
|
||||
"need to recreate the droplet."
|
||||
)
|
||||
sys.exit(1)
|
||||
else:
|
||||
print(f"Deleting existing droplet {droplet_name}.")
|
||||
droplet.destroy()
|
||||
return
|
||||
print("...No droplet found...proceeding.")
|
||||
|
||||
|
||||
def generate_dev_droplet_user_data(username: str, subdomain: str, public_keys: list[str]) -> str:
|
||||
ssh_keys_string = "\n".join(public_keys)
|
||||
setup_root_ssh_keys = f"printf '{ssh_keys_string}' > /root/.ssh/authorized_keys"
|
||||
setup_zulipdev_ssh_keys = f"printf '{ssh_keys_string}' > /home/zulipdev/.ssh/authorized_keys"
|
||||
|
||||
# We pass the hostname as username.zulipdev.org to the DigitalOcean API.
|
||||
# But some droplets (eg on 18.04) are created with hostname set to just username.
|
||||
# So we fix the hostname using cloud-init.
|
||||
hostname_setup = f"hostnamectl set-hostname {subdomain}.zulipdev.org"
|
||||
|
||||
setup_repo = (
|
||||
"cd /home/zulipdev/{1} && "
|
||||
"git remote add origin https://github.com/{0}/{1}.git && "
|
||||
"git fetch origin && "
|
||||
"git clean -f"
|
||||
)
|
||||
|
||||
server_repo_setup = setup_repo.format(username, "zulip")
|
||||
python_api_repo_setup = setup_repo.format(username, "python-zulip-api")
|
||||
|
||||
erlang_cookie = secrets.token_hex(16)
|
||||
setup_erlang_cookie = (
|
||||
f"echo '{erlang_cookie}' > /var/lib/rabbitmq/.erlang.cookie && "
|
||||
"chown rabbitmq:rabbitmq /var/lib/rabbitmq/.erlang.cookie && "
|
||||
"service rabbitmq-server restart"
|
||||
)
|
||||
|
||||
cloudconf = f"""\
|
||||
#!/bin/bash
|
||||
|
||||
{setup_zulipdev_ssh_keys}
|
||||
{setup_root_ssh_keys}
|
||||
{setup_erlang_cookie}
|
||||
sed -i "s/PasswordAuthentication yes/PasswordAuthentication no/g" /etc/ssh/sshd_config
|
||||
service ssh restart
|
||||
{hostname_setup}
|
||||
su -c '{server_repo_setup}' zulipdev
|
||||
su -c '{python_api_repo_setup}' zulipdev
|
||||
su -c 'git config --global core.editor nano' zulipdev
|
||||
su -c 'git config --global pull.rebase true' zulipdev
|
||||
"""
|
||||
print("...returning cloud-config data.")
|
||||
return cloudconf
|
||||
|
||||
|
||||
def generate_prod_droplet_user_data(public_keys: list[str]) -> str:
|
||||
ssh_keys_string = "\n".join(public_keys)
|
||||
setup_root_ssh_keys = f"printf '{ssh_keys_string}' > /root/.ssh/authorized_keys"
|
||||
|
||||
cloudconf = f"""\
|
||||
#!/bin/bash
|
||||
|
||||
{setup_root_ssh_keys}
|
||||
passwd -d root
|
||||
sed -i "s/PasswordAuthentication yes/PasswordAuthentication no/g" /etc/ssh/sshd_config
|
||||
service ssh restart
|
||||
"""
|
||||
print("...returning cloud-config data.")
|
||||
return cloudconf
|
||||
|
||||
|
||||
def create_droplet(
|
||||
my_token: str,
|
||||
template_id: str,
|
||||
name: str,
|
||||
tags: list[str],
|
||||
user_data: str,
|
||||
region: str = "nyc3",
|
||||
) -> tuple[str, str]:
|
||||
droplet = digitalocean.Droplet(
|
||||
token=my_token,
|
||||
name=name,
|
||||
region=region,
|
||||
image=template_id,
|
||||
size_slug="s-2vcpu-4gb",
|
||||
user_data=user_data,
|
||||
tags=tags,
|
||||
backups=False,
|
||||
ipv6=True,
|
||||
)
|
||||
|
||||
print("Initiating droplet creation...")
|
||||
droplet.create()
|
||||
|
||||
incomplete = True
|
||||
while incomplete:
|
||||
actions = droplet.get_actions()
|
||||
for action in actions:
|
||||
action.load()
|
||||
print(f"...[{action.type}]: {action.status}")
|
||||
if action.type == "create" and action.status == "completed":
|
||||
incomplete = False
|
||||
break
|
||||
if incomplete:
|
||||
time.sleep(15)
|
||||
print("...droplet created!")
|
||||
droplet.load()
|
||||
print(f"...ip address for new droplet is: {droplet.ip_address}.")
|
||||
return (droplet.ip_address, droplet.ip_v6_address)
|
||||
|
||||
|
||||
def delete_existing_records(records: list[digitalocean.Record], record_name: str) -> None:
|
||||
count = 0
|
||||
for record in records:
|
||||
if (
|
||||
record.name == record_name
|
||||
and record.domain == "zulipdev.org"
|
||||
and record.type in ("AAAA", "A")
|
||||
):
|
||||
record.destroy()
|
||||
count += 1
|
||||
if count:
|
||||
print(f"Deleted {count} existing A / AAAA records for {record_name}.zulipdev.org.")
|
||||
|
||||
|
||||
def create_dns_record(my_token: str, record_name: str, ipv4: str, ipv6: str) -> None:
|
||||
domain = digitalocean.Domain(token=my_token, name="zulipdev.org")
|
||||
domain.load()
|
||||
records = domain.get_records()
|
||||
|
||||
delete_existing_records(records, record_name)
|
||||
wildcard_name = "*." + record_name
|
||||
delete_existing_records(records, wildcard_name)
|
||||
|
||||
print(f"Creating new A record for {record_name}.zulipdev.org that points to {ipv4}.")
|
||||
domain.create_new_domain_record(type="A", name=record_name, data=ipv4)
|
||||
print(f"Creating new A record for *.{record_name}.zulipdev.org that points to {ipv4}.")
|
||||
domain.create_new_domain_record(type="A", name=wildcard_name, data=ipv4)
|
||||
|
||||
print(f"Creating new AAAA record for {record_name}.zulipdev.org that points to {ipv6}.")
|
||||
domain.create_new_domain_record(type="AAAA", name=record_name, data=ipv6)
|
||||
print(f"Creating new AAAA record for *.{record_name}.zulipdev.org that points to {ipv6}.")
|
||||
domain.create_new_domain_record(type="AAAA", name=wildcard_name, data=ipv6)
|
||||
|
||||
|
||||
def print_dev_droplet_instructions(username: str, droplet_domain_name: str) -> None:
|
||||
print(
|
||||
f"""
|
||||
COMPLETE! Droplet for GitHub user {username} is available at {droplet_domain_name}.
|
||||
|
||||
Instructions for use are below. (copy and paste to the user)
|
||||
|
||||
------
|
||||
Your remote Zulip dev server has been created!
|
||||
|
||||
- Connect to your server by running
|
||||
`ssh zulipdev@{droplet_domain_name}` on the command line
|
||||
(Terminal for macOS and Linux, Bash for Git on Windows).
|
||||
- There is no password; your account is configured to use your SSH keys.
|
||||
- Once you log in, you should see `(zulip-server) ~$`.
|
||||
- To start the dev server, `cd zulip` and then run `./tools/run-dev`.
|
||||
- While the dev server is running, you can see the Zulip server in your browser at
|
||||
http://{droplet_domain_name}:9991.
|
||||
"""
|
||||
)
|
||||
|
||||
print(
|
||||
"See [Developing remotely](https://zulip.readthedocs.io/en/latest/development/remote.html) "
|
||||
"for tips on using the remote dev instance and "
|
||||
"[Git & GitHub guide](https://zulip.readthedocs.io/en/latest/git/index.html) "
|
||||
"to learn how to use Git with Zulip.\n"
|
||||
)
|
||||
print(
|
||||
"Note that this droplet will automatically be deleted after a month of inactivity. "
|
||||
"If you are leaving Zulip for more than a few weeks, we recommend pushing all of your "
|
||||
"active branches to GitHub."
|
||||
)
|
||||
print("------")
|
||||
|
||||
|
||||
def print_production_droplet_instructions(droplet_domain_name: str) -> None:
|
||||
print(
|
||||
f"""
|
||||
-----
|
||||
|
||||
Production droplet created successfully!
|
||||
|
||||
Connect to the server by running
|
||||
|
||||
ssh root@{droplet_domain_name}
|
||||
|
||||
-----
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def get_zulip_oneclick_app_slug(api_token: str) -> str:
|
||||
response = requests.get(
|
||||
"https://api.digitalocean.com/v2/1-clicks", headers={"Authorization": f"Bearer {api_token}"}
|
||||
).json()
|
||||
one_clicks = response["1_clicks"]
|
||||
|
||||
for one_click in one_clicks:
|
||||
if one_click["slug"].startswith("kandralabs"):
|
||||
return one_click["slug"]
|
||||
raise Exception("Unable to find Zulip One-click app slug")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
args = parser.parse_args()
|
||||
username = args.username.lower()
|
||||
if args.subdomain:
|
||||
subdomain = args.subdomain.lower()
|
||||
elif args.production:
|
||||
subdomain = f"{username}-prod"
|
||||
else:
|
||||
subdomain = username
|
||||
|
||||
if args.production:
|
||||
print(f"Creating production droplet for GitHub user {username}...")
|
||||
else:
|
||||
print(f"Creating Zulip developer environment for GitHub user {username}...")
|
||||
|
||||
config = get_config()
|
||||
api_token = config["digitalocean"]["api_token"]
|
||||
|
||||
assert_github_user_exists(github_username=username)
|
||||
|
||||
public_keys = get_ssh_public_keys_from_github(github_username=username)
|
||||
droplet_domain_name = f"{subdomain}.zulipdev.org"
|
||||
|
||||
if args.production:
|
||||
template_id = get_zulip_oneclick_app_slug(api_token)
|
||||
user_data = generate_prod_droplet_user_data(public_keys=public_keys)
|
||||
|
||||
else:
|
||||
assert_user_forked_zulip_server_repo(username=username)
|
||||
user_data = generate_dev_droplet_user_data(
|
||||
username=username, subdomain=subdomain, public_keys=public_keys
|
||||
)
|
||||
|
||||
# define id of image to create new droplets from; see:
|
||||
# curl -u <API_KEY>: "https://api.digitalocean.com/v2/snapshots | jq .
|
||||
template_id = "157280701"
|
||||
|
||||
assert_droplet_does_not_exist(
|
||||
my_token=api_token, droplet_name=droplet_domain_name, recreate=args.recreate
|
||||
)
|
||||
|
||||
(ipv4, ipv6) = create_droplet(
|
||||
my_token=api_token,
|
||||
template_id=template_id,
|
||||
name=droplet_domain_name,
|
||||
tags=[*args.tags, "dev"],
|
||||
user_data=user_data,
|
||||
region=args.region,
|
||||
)
|
||||
|
||||
create_dns_record(my_token=api_token, record_name=subdomain, ipv4=ipv4, ipv6=ipv6)
|
||||
|
||||
if args.production:
|
||||
print_production_droplet_instructions(droplet_domain_name=droplet_domain_name)
|
||||
else:
|
||||
print_dev_droplet_instructions(username=username, droplet_domain_name=droplet_domain_name)
|
||||
|
||||
sys.exit(1)
|
||||
Reference in New Issue
Block a user