citadel-core/app/lib/manage.py

367 lines
15 KiB
Python
Raw Normal View History

2022-01-28 06:52:26 +00:00
# SPDX-FileCopyrightText: 2021-2022 Citadel and contributors
#
2022-01-21 20:37:48 +00:00
# SPDX-License-Identifier: GPL-3.0-or-later
import stat
2022-02-02 18:13:20 +00:00
import sys
import tempfile
import threading
from typing import List
from sys import argv
import os
import requests
import shutil
import json
import yaml
import subprocess
2022-02-02 08:51:13 +00:00
try:
import semver
except Exception:
print("Semver for python isn't installed")
print("On Debian/Ubuntu, you can install it using")
print(" sudo apt install -y python3-semver")
print("On other systems, please use")
print(" sudo pip3 install semver")
print("Continuing anyway, but some features won't be available,")
print("for example checking for app updates")
from lib.composegenerator.v1.generate import createComposeConfigFromV1
2022-02-06 18:20:49 +00:00
from lib.composegenerator.v2.generate import createComposeConfigFromV2
from lib.composegenerator.v3.generate import createComposeConfigFromV3
from lib.validate import findAndValidateApps
2021-11-05 10:40:35 +00:00
from lib.metadata import getAppRegistry
from lib.entropy import deriveEntropy
# For an array of threads, join them and wait for them to finish
def joinThreads(threads: List[threading.Thread]):
for thread in threads:
thread.join()
# The directory with this script
scriptDir = os.path.dirname(os.path.realpath(__file__))
nodeRoot = os.path.join(scriptDir, "..", "..")
appsDir = os.path.join(nodeRoot, "apps")
appSystemDir = os.path.join(nodeRoot, "app-system")
sourcesList = os.path.join(appSystemDir, "sources.list")
2022-02-23 15:04:38 +00:00
updateIgnore = os.path.join(appsDir, ".updateignore")
appDataDir = os.path.join(nodeRoot, "app-data")
userFile = os.path.join(nodeRoot, "db", "user.json")
legacyScript = os.path.join(nodeRoot, "scripts", "app")
# Returns a list of every argument after the second one in sys.argv joined into a string by spaces
def getArguments():
arguments = ""
for i in range(3, len(argv)):
arguments += argv[i] + " "
return arguments
def getAppYml(name):
2022-02-01 17:50:59 +00:00
with open(os.path.join(appsDir, "sourceMap.json"), "r") as f:
sourceMap = json.load(f)
if not name in sourceMap:
2022-02-09 19:19:29 +00:00
print("Warning: App {} is not in the source map".format(name))
sourceMap = {
name: {
"githubRepo": "runcitadel/core",
"branch": "v2"
}
}
2022-02-01 17:50:59 +00:00
url = 'https://raw.githubusercontent.com/{}/{}/apps/{}/app.yml'.format(sourceMap[name]["githubRepo"], sourceMap[name]["branch"], name)
response = requests.get(url)
if response.status_code == 200:
return response.text
else:
return False
def update(verbose: bool = False):
apps = findAndValidateApps(appsDir)
# The compose generation process updates the registry, so we need to get it set up with the basics before that
registry = getAppRegistry(apps, appsDir)
with open(os.path.join(appsDir, "registry.json"), "w") as f:
json.dump(registry["metadata"], f, sort_keys=True)
with open(os.path.join(appsDir, "ports.json"), "w") as f:
json.dump(registry["ports"], f, sort_keys=True)
print("Wrote registry to registry.json")
# Loop through the apps and generate valid compose files from them, then put these into the app dir
for app in apps:
composeFile = os.path.join(appsDir, app, "docker-compose.yml")
appYml = os.path.join(appsDir, app, "app.yml")
with open(composeFile, "w") as f:
appCompose = getApp(appYml, app)
2021-11-17 18:04:06 +00:00
if appCompose:
f.write(yaml.dump(appCompose, sort_keys=False))
if verbose:
print("Wrote " + app + " to " + composeFile)
print("Generated configuration successfully")
2022-02-01 17:50:59 +00:00
def download(app: str):
data = getAppYml(app)
if data:
with open(os.path.join(appsDir, app, "app.yml"), 'w') as f:
f.write(data)
else:
2022-02-01 17:50:59 +00:00
print("Warning: Could not download " + app)
def getUserData():
userData = {}
if os.path.isfile(userFile):
with open(userFile, "r") as f:
userData = json.load(f)
return userData
2022-02-02 08:51:13 +00:00
def checkUpdateAvailable(name: str) -> bool:
latestAppYml = yaml.safe_load(getAppYml(name))
with open(os.path.join(appsDir, name, "app.yml"), "r") as f:
originalAppYml = yaml.safe_load(f)
if not "metadata" in latestAppYml or not "version" in latestAppYml["metadata"] or not "metadata" in originalAppYml or not "version" in originalAppYml["metadata"]:
print("App {} is not valid".format(name))
return False
return semver.compare(latestAppYml["metadata"]["version"], originalAppYml["metadata"]["version"]) > 0
def getAvailableUpdates():
availableUpdates = []
apps = findAndValidateApps(appsDir)
for app in apps:
try:
if checkUpdateAvailable(app):
2022-02-02 18:13:20 +00:00
availableUpdates.append(app)
2022-02-02 08:51:13 +00:00
except Exception:
2022-02-02 18:13:20 +00:00
print("Warning: Can't check app {} yet".format(app), file=sys.stderr)
return availableUpdates
def startInstalled():
2022-02-02 08:51:13 +00:00
# If userfile doesn't exist, just do nothing
userData = {}
if os.path.isfile(userFile):
with open(userFile, "r") as f:
userData = json.load(f)
2021-11-17 18:04:06 +00:00
#threads = []
for app in userData["installedApps"]:
2022-02-19 18:10:17 +00:00
if not os.path.isdir(os.path.join(appsDir, app)):
2022-02-19 17:26:37 +00:00
print("Warning: App {} doesn't exist on Citadel".format(app))
continue
print("Starting app {}...".format(app))
# Run compose(args.app, "up --detach") asynchrounously for all apps, then exit(0) when all are finished
2021-11-12 19:51:32 +00:00
#thread = threading.Thread(target=compose, args=(app, "up --detach"))
#thread.start()
#threads.append(thread)
compose(app, "up --detach")
#joinThreads(threads)
def stopInstalled():
2022-02-02 08:51:13 +00:00
# If userfile doesn't exist, just do nothing
userData = {}
if os.path.isfile(userFile):
with open(userFile, "r") as f:
userData = json.load(f)
threads = []
for app in userData["installedApps"]:
2022-02-19 18:10:17 +00:00
if not os.path.isdir(os.path.join(appsDir, app)):
2022-02-19 17:26:37 +00:00
print("Warning: App {} doesn't exist on Citadel".format(app))
continue
print("Stopping app {}...".format(app))
# Run compose(args.app, "up --detach") asynchrounously for all apps, then exit(0) when all are finished
thread = threading.Thread(
target=compose, args=(app, "rm --force --stop"))
thread.start()
threads.append(thread)
joinThreads(threads)
# Loads an app.yml and converts it to a docker-compose.yml
def getApp(appFile: str, appId: str):
with open(appFile, 'r') as f:
app = yaml.safe_load(f)
if not "metadata" in app:
raise Exception("Error: Could not find metadata in " + appFile)
app["metadata"]["id"] = appId
2021-11-17 18:04:06 +00:00
if 'version' in app and str(app['version']) == "1":
print("Warning: App {} uses version 1 of the app.yml format, which is scheduled for removal in Citadel 0.1.0".format(appId))
return createComposeConfigFromV1(app, nodeRoot)
2022-02-06 18:20:49 +00:00
elif 'version' in app and str(app['version']) == "2":
print("Warning: App {} uses version 2 of the app.yml format, which is scheduled for removal in Citadel 0.2.0".format(appId))
2022-02-06 18:20:49 +00:00
return createComposeConfigFromV2(app, nodeRoot)
elif 'version' in app and str(app['version']) == "3":
return createComposeConfigFromV3(app, nodeRoot)
else:
raise Exception("Error: Unsupported version of app.yml")
def compose(app, arguments):
2022-02-19 18:10:17 +00:00
if not os.path.isdir(os.path.join(appsDir, app)):
2022-02-19 17:33:05 +00:00
print("Warning: App {} doesn't exist on Citadel".format(app))
return
# Runs a compose command in the app dir
# Before that, check if a docker-compose.yml exists in the app dir
composeFile = os.path.join(appsDir, app, "docker-compose.yml")
commonComposeFile = os.path.join(appSystemDir, "docker-compose.common.yml")
os.environ["APP_DOMAIN"] = subprocess.check_output(
"hostname -s 2>/dev/null || echo 'citadel'", shell=True).decode("utf-8").strip() + ".local"
os.environ["APP_HIDDEN_SERVICE"] = subprocess.check_output("cat {} 2>/dev/null || echo 'notyetset.onion'".format(
os.path.join(nodeRoot, "tor", "data", "app-{}/hostname".format(app))), shell=True).decode("utf-8").strip()
os.environ["APP_SEED"] = deriveEntropy("app-{}-seed".format(app))
# Allow more app seeds, with random numbers from 1-5 assigned in a loop
for i in range(1, 6):
os.environ["APP_SEED_{}".format(i)] = deriveEntropy("app-{}-seed{}".format(app, i))
os.environ["APP_DATA_DIR"] = os.path.join(appDataDir, app)
# Chown and chmod dataDir to have the owner 1000:1000 and the same permissions as appDir
subprocess.call("chown -R 1000:1000 {}".format(os.path.join(appDataDir, app)), shell=True)
2021-10-22 18:02:53 +00:00
try:
os.chmod(os.path.join(appDataDir, app), os.stat(os.path.join(appsDir, app)).st_mode)
except Exception:
pass
2022-01-02 09:10:57 +00:00
if app == "nextcloud":
subprocess.call("chown -R 33:33 {}".format(os.path.join(appDataDir, app, "data", "nextcloud")), shell=True)
subprocess.call("chmod -R 770 {}".format(os.path.join(appDataDir, app, "data", "nextcloud")), shell=True)
os.environ["BITCOIN_DATA_DIR"] = os.path.join(nodeRoot, "bitcoin")
os.environ["LND_DATA_DIR"] = os.path.join(nodeRoot, "lnd")
# List all hidden services for an app and put their hostname in the environment
hiddenServices: List[str] = getAppHiddenServices(app)
for service in hiddenServices:
appHiddenServiceFile = os.path.join(
nodeRoot, "tor", "data", "app-{}-{}/hostname".format(app, service))
os.environ["APP_HIDDEN_SERVICE_{}".format(service.upper().replace("-", "_"))] = subprocess.check_output("cat {} 2>/dev/null || echo 'notyetset.onion'".format(
2021-11-12 19:43:58 +00:00
appHiddenServiceFile), shell=True).decode("utf-8").strip()
if not os.path.isfile(composeFile):
print("Error: Could not find docker-compose.yml in " + app)
exit(1)
os.system(
"docker compose --env-file '{}' --project-name '{}' --file '{}' --file '{}' {}".format(
os.path.join(nodeRoot, ".env"), app, commonComposeFile, composeFile, arguments))
def remove_readonly(func, path, _):
os.chmod(path, stat.S_IWRITE)
func(path)
def deleteData(app: str):
dataDir = os.path.join(appDataDir, app)
try:
shutil.rmtree(dataDir, onerror=remove_readonly)
except FileNotFoundError:
pass
def createDataDir(app: str):
dataDir = os.path.join(appDataDir, app)
appDir = os.path.join(appsDir, app)
if os.path.isdir(dataDir):
deleteData(app)
2021-11-27 11:50:45 +00:00
# Recursively copy everything from appDir to dataDir while excluding .gitkeep files
shutil.copytree(appDir, dataDir, symlinks=False,
2021-11-27 11:50:45 +00:00
ignore=shutil.ignore_patterns(".gitkeep"))
# Chown and chmod dataDir to have the owner 1000:1000 and the same permissions as appDir
subprocess.call("chown -R 1000:1000 {}".format(os.path.join(appDataDir, app)), shell=True)
os.chmod(dataDir, os.stat(appDir).st_mode)
def setInstalled(app: str):
userData = getUserData()
if not "installedApps" in userData:
userData["installedApps"] = []
userData["installedApps"].append(app)
userData["installedApps"] = list(set(userData["installedApps"]))
with open(userFile, "w") as f:
json.dump(userData, f)
def setRemoved(app: str):
userData = getUserData()
if not "installedApps" in userData:
return
userData["installedApps"] = list(set(userData["installedApps"]))
userData["installedApps"].remove(app)
with open(userFile, "w") as f:
json.dump(userData, f)
def getAppHiddenServices(app: str):
torDir = os.path.join(nodeRoot, "tor", "data")
# List all subdirectories of torDir which start with app-${APP}-
# but return them without the app-${APP}- prefix
results = []
for subdir in os.listdir(torDir):
if subdir.startswith("app-{}-".format(app)):
results.append(subdir[len("app-{}-".format(app)):])
return results
# Parse the sources.list repo file, which contains a list of sources in the format
# <git-url> <branch>
# For every line, clone the repo to a temporary dir and checkout the branch
# Then, check that repos apps in the temporary dir/apps and for every app,
# overwrite the current app dir with the contents of the temporary dir/apps/app
# Also, keep a list of apps from every repo, a repo later in the file may not overwrite an app from a repo earlier in the file
def updateRepos():
# Get the list of repos
repos = []
2022-02-23 15:01:17 +00:00
ignoreApps = []
with open(sourcesList) as f:
repos = f.readlines()
2022-02-23 15:04:38 +00:00
try:
with open(updateIgnore) as f:
ignoreApps = f.readlines()
except: pass
# For each repo, clone the repo to a temporary dir, checkout the branch,
# and overwrite the current app dir with the contents of the temporary dir/apps/app
2022-02-23 15:01:17 +00:00
# Set this to ignoreApps. Normally, it keeps track of apps already installed from repos higher in the list,
# but apps specified in updateignore have the highest priority
2022-02-23 15:20:44 +00:00
alreadyInstalled = [s.strip() for s in ignoreApps]
2022-02-01 17:50:59 +00:00
# A map of apps to their source repo
sourceMap = {}
for repo in repos:
repo = repo.strip()
if repo == "":
continue
# Also ignore comments
if repo.startswith("#"):
continue
# Split the repo into the git url and the branch
repo = repo.split(" ")
if len(repo) != 2:
print("Error: Invalid repo format in " + sourcesList)
exit(1)
gitUrl = repo[0]
branch = repo[1]
# Clone the repo to a temporary dir
tempDir = tempfile.mkdtemp()
print("Cloning the repository")
# Git clone with a depth of 1 to avoid cloning the entire repo
2022-02-02 08:51:13 +00:00
# Don't print anything to stdout, as we don't want to see the git clone output
2021-12-08 18:18:25 +00:00
subprocess.run("git clone --depth 1 --branch {} {} {}".format(branch, gitUrl, tempDir), shell=True, stdout=subprocess.DEVNULL)
# Overwrite the current app dir with the contents of the temporary dir/apps/app
for app in os.listdir(os.path.join(tempDir, "apps")):
# if the app is already installed (or a simple file instead of a valid app), skip it
if app in alreadyInstalled or not os.path.isdir(os.path.join(tempDir, "apps", app)):
continue
2022-02-01 17:50:59 +00:00
if gitUrl.startswith("https://github.com"):
sourceMap[app] = {
"githubRepo": gitUrl.removeprefix("https://github.com/").removesuffix(".git").removesuffix("/"),
"branch": branch,
}
if os.path.isdir(os.path.join(appsDir, app)):
shutil.rmtree(os.path.join(appsDir, app), onerror=remove_readonly)
if os.path.isdir(os.path.join(tempDir, "apps", app)):
shutil.copytree(os.path.join(tempDir, "apps", app), os.path.join(appsDir, app),
symlinks=False, ignore=shutil.ignore_patterns(".gitignore"))
alreadyInstalled.append(app)
# Remove the temporary dir
shutil.rmtree(tempDir)
2022-02-01 17:50:59 +00:00
with open(os.path.join(appsDir, "sourceMap.json"), "w") as f:
json.dump(sourceMap, f)