Compare commits

...

5 Commits

Author SHA1 Message Date
Aaron Dewes
61f5f9f1e0
Citadel 0.0.7 (#76)
Co-authored-by: nolim1t - f6287b82CC84bcbd <nolim1t@users.noreply.github.com>
Co-authored-by: Aaron Dewes <aaron.dewes@protonmail.com>
Co-authored-by: Philipp Walter <philippwalter@pm.me>
2022-08-11 13:50:09 +02:00
Philipp Walter
07aa73b59e
feat: add Citadel CLI (#33)
add commands for:
- Show status for all services
- Run a command inside a (app) container
- Switch branch / update channel
- Switch Bitcoin/Electrum/Lightning implementation
- app commands
- debug command
- Fix update / backup / starting stuck
- Edit node configs (Bitcoin Core, LND)
- Edit app configs (Nextcloud etc.)
- Memory usage / System info
- Start
- Stop
- Restart
- Reboot
- Shutdown
- List all installed apps & services
- Logs
- Version
- Help
2022-07-15 13:26:23 +02:00
Aaron Dewes
a8d224f597
Citadel 0.0.6 (#59)
Co-authored-by: nolim1t - f6287b82CC84bcbd <nolim1t@users.noreply.github.com>
2022-06-24 08:45:07 +02:00
Aaron Dewes
85f9886137
Citadel 0.0.5 (#45)
Co-authored-by: Philipp Walter <philippwalter@pm.me>
2022-06-11 18:35:47 +02:00
Aaron Dewes
66c0ca350b
Fix app generation for the Samourai app (#44)
This also only loads the schema file for validation once instead for every app, which should improve the performance of the app validation
2022-06-04 13:50:37 -04:00
35 changed files with 970 additions and 158 deletions

7
.gitignore vendored
View File

@ -22,10 +22,9 @@ statuses/*
app-data
apps
electrs/*
fulcrumx/*
fulcrum/*
nginx/*
redis/*
events/signals/*
docker-compose.override.yml
# Commit these empty directories
@ -36,7 +35,6 @@ docker-compose.override.yml
!db/citadel-seed
db/citadel-seed/*
!db/citadel-seed/.gitkeep
!events/signals/.gitkeep
!lnd/.gitkeep
!logs/.gitkeep
!tor/data/.gitkeep
@ -45,8 +43,7 @@ db/citadel-seed/*
!db/.gitkeep
!nginx/.gitkeep
!redis/.gitkeep
!fulcrumx/.gitkeep
!events/signals/.gitkeep
!fulcrum/.gitkeep
!**/*.license
services/installed.json

View File

@ -47,7 +47,7 @@ def getContainerHiddenService(
if isinstance(container.hiddenServicePorts, int):
return getHiddenServiceString(
"{} {}".format(metadata.name, container.name),
metadata.id,
metadata.id if isMainContainer else "{}-{}".format(metadata.id, container.name),
container.hiddenServicePorts,
containerIp,
container.hiddenServicePorts,
@ -55,7 +55,7 @@ def getContainerHiddenService(
elif isinstance(container.hiddenServicePorts, list):
return getHiddenServiceMultiPort(
"{} {}".format(metadata.name, container.name),
metadata.id,
metadata.id if isMainContainer else "{}-{}".format(metadata.id, container.name),
containerIp,
container.hiddenServicePorts,
)
@ -77,14 +77,14 @@ def getContainerHiddenService(
else:
additionalHiddenServices[key] = value
for key, value in additionalHiddenServices.items():
otherHiddenServices += "\n"
if isinstance(value, int):
otherHiddenServices += "# {} {} {} Hidden Service\nHiddenServiceDir /var/lib/tor/app-{}-{}\n".format(
metadata.name, container.name, key, metadata.id, container.name
metadata.name, container.name, key, metadata.id, key
)
otherHiddenServices += "HiddenServicePort {} {}:{}".format(
value, containerIp, value
)
otherHiddenServices += "\n"
elif isinstance(value, list):
otherHiddenServices += getHiddenServiceMultiPort(
"{} {}".format(metadata.name, key), "{}-{}".format(metadata.id, key), containerIp, value

View File

@ -85,11 +85,12 @@ def createComposeConfigFromV3(app: dict, nodeRoot: str):
newApp = configureIps(newApp, networkingFile, envFile)
# This is validated earlier
for container in newApp.containers:
container.ports = container.requiredPorts
for tcpPort in container.requiredPorts:
container.ports.append("{}:{}".format(tcpPort, tcpPort))
del container.requiredPorts
for container in newApp.containers:
for udpPort in container.requiredUdpPorts:
container.ports.append("{}/udp".format(udpPort))
container.ports.append("{}:{}/udp".format(udpPort, udpPort))
del container.requiredUdpPorts
newApp = configureMainPort(newApp, nodeRoot)
newApp = configureHiddenServices(newApp, nodeRoot)

View File

@ -77,6 +77,8 @@ def configureMainPort(app: AppStage2, nodeRoot: str) -> AppStage3:
# If it doesn't contain a :, it's the port itself
if mainPort == False:
mainPort = mainContainer.ports[0]
if mainPort.find(":") != -1:
mainPort = mainPort.split(":")[1]
else:
mainContainer.ports = [portToAppend]

View File

@ -118,26 +118,6 @@ def getUserData():
userData = json.load(f)
return userData
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):
availableUpdates.append(app)
except Exception:
print("Warning: Can't check app {} yet".format(app), file=sys.stderr)
return availableUpdates
def startInstalled():
# If userfile doesn't exist, just do nothing
userData = {}
@ -364,3 +344,59 @@ def updateRepos():
shutil.rmtree(tempDir)
with open(os.path.join(appsDir, "sourceMap.json"), "w") as f:
json.dump(sourceMap, f)
def getAvailableUpdates():
availableUpdates = {}
repos = []
ignoreApps = []
with open(sourcesList) as f:
repos = f.readlines()
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
# 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
alreadyDefined = [s.strip() for s in ignoreApps]
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, file=sys.stderr)
exit(1)
gitUrl = repo[0]
branch = repo[1]
# Clone the repo to a temporary dir
tempDir = tempfile.mkdtemp()
# Git clone with a depth of 1 to avoid cloning the entire repo
# Don't print anything to stdout, as we don't want to see the git clone output
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")):
try:
# if the app is already installed (or a simple file instead of a valid app), skip it
if app in alreadyDefined or not os.path.isdir(os.path.join(tempDir, "apps", app)):
continue
with open(os.path.join(appsDir, app, "app.yml"), "r") as f:
originalAppYml = yaml.safe_load(f)
with open(os.path.join(tempDir, "apps", app, "app.yml"), "r") as f:
latestAppYml = yaml.safe_load(f)
if semver.compare(latestAppYml["metadata"]["version"], originalAppYml["metadata"]["version"]) > 0:
availableUpdates[app] = {
"updateFrom": originalAppYml["metadata"]["version"],
"updateTo": latestAppYml["metadata"]["version"]
}
except Exception:
print("Warning: Can't check app {} (yet)".format(app), file=sys.stderr)
# Remove the temporary dir
shutil.rmtree(tempDir)
return availableUpdates

View File

@ -7,18 +7,18 @@ import yaml
from jsonschema import validate
import yaml
scriptDir = os.path.join(os.path.dirname(os.path.realpath(__file__)), "..")
with open(os.path.join(scriptDir, 'app-standard-v1.yml'), 'r') as f:
schemaVersion1 = yaml.safe_load(f)
with open(os.path.join(scriptDir, 'app-standard-v2.yml'), 'r') as f:
schemaVersion2 = yaml.safe_load(f)
with open(os.path.join(scriptDir, 'app-standard-v3.yml'), 'r') as f:
schemaVersion3 = yaml.safe_load(f)
# Validates app data
# Returns true if valid, false otherwise
scriptDir = os.path.join(os.path.dirname(os.path.realpath(__file__)), "..")
def validateApp(app: dict):
with open(os.path.join(scriptDir, 'app-standard-v1.yml'), 'r') as f:
schemaVersion1 = yaml.safe_load(f)
with open(os.path.join(scriptDir, 'app-standard-v2.yml'), 'r') as f:
schemaVersion2 = yaml.safe_load(f)
with open(os.path.join(scriptDir, 'app-standard-v3.yml'), 'r') as f:
schemaVersion3 = yaml.safe_load(f)
if 'version' in app and str(app['version']) == "1":
try:
validate(app, schemaVersion1)

1
bin/citadel Symbolic link
View File

@ -0,0 +1 @@
../cli/citadel

View File

@ -13,7 +13,7 @@ CITADEL_ROOT="$(readlink -f $(dirname "${BASH_SOURCE[0]}")/..)"
result=$(docker compose \
--file "${CITADEL_ROOT}/docker-compose.yml" \
--env-file "${CITADEL_ROOT}/.env" \
exec lnd lncli "$@")
exec lightning lncli "$@")
# We need to echo with quotes to preserve output formatting
echo "$result"

487
cli/citadel Executable file
View File

@ -0,0 +1,487 @@
#!/usr/bin/env bash
# SPDX-FileCopyrightText: 2022 Citadel and contributors
#
# SPDX-License-Identifier: GPL-3.0-or-later
set -euo pipefail
CITADEL_ROOT="$(readlink -f $(dirname "${BASH_SOURCE[0]}")/..)"
CLI_NAME="$(basename $0)"
CLI_VERSION="0.0.1"
CLI_DIR="$(dirname "$(readlink -f "$0")")"
SERVICE_NAME="citadel-startup"
EDITOR="${EDITOR:-micro}"
source $CLI_DIR/utils/functions.sh
source $CLI_DIR/utils/multiselect.sh
source $CLI_DIR/utils/spinner.sh
source $CLI_DIR/utils/helpers.sh
if [ -z ${1+x} ]; then
command=""
else
command="$1"
fi
# Check Citadel Status
if [[ "$command" = "status" ]]; then
POSITIONAL_ARGS=()
long=false
while [[ $# -gt 0 ]]; do
case $1 in
-l | --long)
long=true
shift
;;
-* | --*)
echo "Unknown option $1"
exit 1
;;
*)
POSITIONAL_ARGS+=("$1")
shift
;;
esac
done
set -- "${POSITIONAL_ARGS[@]}"
free -m | awk 'NR==2{printf "Memory Usage: %s/%sMB (%.2f%%)\n", $3,$2,$3*100/$2 }'
df -h | awk '$NF=="/"{printf "Disk Usage: %d/%dGB (%s)\n", $3,$2,$5}'
top -bn1 | grep load | awk '{printf "CPU Load: %.2f\n", $(NF-2)}'
echo
if $long; then
docker container ls --format "table {{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}"
else
docker container ls --format "table {{.Names}}\t{{.Status}}"
fi
if [[ $(pgrep -f karen) ]]; then
printf "\nKaren is listening.\n"
else
printf "\nERROR: Karen is not listening.\n"
fi
exit
fi
# Update Citadel
if [[ "$command" = "update" ]]; then
POSITIONAL_ARGS=()
branch=$(get_update_channel)
force=false
while [[ $# -gt 0 ]]; do
case $1 in
-b | --branch)
branch="$2"
shift # past argument
shift # past value
;;
--force)
force=true
shift # past argument
;;
-* | --*)
echo "Unknown option $1"
exit 1
;;
*)
POSITIONAL_ARGS+=("$1") # save positional arg
shift # past argument
;;
esac
done
set -- "${POSITIONAL_ARGS[@]}" # restore positional parameters
if $force; then
sudo rm -f $CITADEL_ROOT/statuses/update-in-progress
fi
sudo $CITADEL_ROOT/scripts/update/update --repo runcitadel/core#$branch
exit
fi
# Start Citadel
if [[ "$command" = "start" ]]; then
if $(is_managed_by_systemd); then
if $(is_service_active); then
echo 'Citadel is already running.'
else
sudo systemctl start citadel-startup
fi
else
sudo $CITADEL_ROOT/scripts/start
fi
exit
fi
# Stop Citadel
if [[ "$command" = "stop" ]]; then
active=$(is_service_active)
if $(is_managed_by_systemd) && $active; then
if $(is_service_active); then
sudo systemctl stop citadel-startup
else
echo 'Citadel is not running.'
fi
else
sudo $CITADEL_ROOT/scripts/stop
fi
exit
fi
# Restart Citadel
if [[ "$command" = "restart" ]]; then
shift
# TODO: enable restarting services
if [ ! -z ${1+x} ]; then
echo "Too many arguments."
echo "Usage: \`$CLI_NAME $command\`"
exit 1
fi
if $(is_managed_by_systemd); then
sudo systemctl restart $SERVICE_NAME
else
sudo $CITADEL_ROOT/scripts/stop
sudo $CITADEL_ROOT/scripts/start
fi
exit
fi
# Reboot the system
if [[ "$command" = "reboot" ]]; then
$CLI_NAME stop || true
sudo reboot
exit
fi
# Shutdown the system
if [[ "$command" = "shutdown" ]]; then
$CLI_NAME stop || true
sudo shutdown now
exit
fi
# List all installed services apps
if [[ "$command" = "list" ]]; then
echo 'karen'
docker ps --format "{{.Names}}"
exit
fi
# Run a command inside a container
if [[ "$command" = "run" ]]; then
shift
if [ -z ${1+x} ]; then
echo "Specify an app or service."
echo "Usage: \`$CLI_NAME $command <service> \"<command>\"\`"
exit 1
fi
if [ -z ${2+x} ]; then
echo "Specify a command to run."
echo "Usage: \`$CLI_NAME $command <service> \"<command>\"\`"
exit 1
fi
docker exec -t $1 sh -c "$2" || {
echo "To see all installed services & apps use \`$CLI_NAME list\`"
echo "Usage: \`$CLI_NAME $command <service> \"<command>\"\`"
exit 1
}
exit
fi
# Configure Citadel
if [[ "$command" = "set" ]]; then
shift
if [ -z ${1+x} ]; then
echo "Missing subcommand."
echo "Usage: \`$CLI_NAME $command <subcommand>\`"
exit 1
else
subcommand="$1"
fi
# Switch update channel
if [[ "$subcommand" = "update-channel" ]]; then
if [ -z ${2+x} ]; then
echo "Specify an update channel to switch to."
echo "Usage: \`$CLI_NAME $subcommand <stable|beta|c-lightning>\`"
exit 1
fi
case $2 in "stable" | "beta" | "c-lightning")
# continue
;;
*)
echo "Not a valid update channel: \"$2\""
exit 1
;;
esac
sudo $CITADEL_ROOT/scripts/set-update-channel $2
$CLI_NAME update
exit
fi
# Switch Bitcoin/Electrum implementation
if [[ "$subcommand" = "implementation" ]] || [[ "$subcommand" = "impl" ]]; then
shift
sudo $CITADEL_ROOT/services/manage.py set $@
$CLI_NAME restart
exit
fi
# Switch Bitcoin network
if [[ "$subcommand" = "network" ]]; then
shift
if [ -z ${1+x} ]; then
echo "Specify a network to switch to."
echo "Usage: \`$CLI_NAME $subcommand <mainnet|signet|testnet|regtest>\`"
else
case $1 in
"mainnet" | "testnet" | "signet" | "regtest")
sudo $CITADEL_ROOT/scripts/stop
sudo OVERWRITE_NETWORK=$1 $CITADEL_ROOT/scripts/configure
sudo $CITADEL_ROOT/scripts/start
;;
*)
echo "Not a valid value for network"
exit 1
;;
esac
fi
exit
fi
echo "\"$subcommand\" is not a valid subcommand."
exit 1
fi
# App commands
if [[ "$command" = "app" ]]; then
shift
sudo $CITADEL_ROOT/scripts/app $@
exit
fi
# Edit common app configuration files
if [[ "$command" = "configure" ]]; then
if [ -z ${2+x} ]; then
echo "Specify an app or service to configure."
echo "Usage: \`$CLI_NAME $command <service>\`"
exit 1
fi
POSITIONAL_ARGS=()
persist=false
while [[ $# -gt 0 ]]; do
case $1 in
--persist)
persist=true
shift # past argument
;;
-* | --*)
echo "Unknown option $1"
exit 1
;;
*)
POSITIONAL_ARGS+=("$1") # save positional arg
shift # past argument
;;
esac
done
set -- "${POSITIONAL_ARGS[@]}" # restore positional parameters
# These service and app configs are already persisted
# TODO: add more apps
if [[ "$2" = "nextcloud" ]]; then
edit_file --priviledged $CITADEL_ROOT/app-data/nextcloud/data/nextcloud/config/config.php
prompt_apply_config nextcloud-web-1 false
exit
fi
if [[ "$2" = "nginx" ]]; then
edit_file $CITADEL_ROOT/nginx/nginx.conf
prompt_apply_config nginx false
exit
fi
if $persist; then
echo "NOTE: As of now persisted config changes will not be kept when updating Citadel."
else
echo "NOTE: Some changes to this configuration file may be overwritten the next time you start Citadel."
echo "To persist the changes run the command again with \`$CLI_NAME configure $2 --persist\`"
fi
read -p "Continue? [Y/n] " should_continue
echo
if [[ $should_continue =~ [Nn]$ ]]; then
exit
fi
# Service and app configs below may be overwritten
# TODO: check which implementation is running
# and do "bitcoin" / "lightning" / "electrum"
if [[ "$2" = "bitcoin" ]]; then
if $persist; then
edit_file $CITADEL_ROOT/templates/bitcoin-sample.conf
prompt_apply_config bitcoin true
else
edit_file $CITADEL_ROOT/bitcoin/bitcoin.conf
prompt_apply_config bitcoin false
fi
exit
fi
if [[ "$2" = "lnd" ]]; then
if $persist; then
edit_file $CITADEL_ROOT/templates/lnd-sample.conf
prompt_apply_config lightning true
else
edit_file $CITADEL_ROOT/lnd/lnd.conf
prompt_apply_config lightning false
fi
exit
fi
if [[ "$2" = "electrs" ]]; then
edit_file $CITADEL_ROOT/templates/electrs-sample.toml
prompt_apply_config electrum true
exit
fi
if [[ "$2" = "fulcrum" ]]; then
if $persist; then
edit_file $CITADEL_ROOT/templates/fulcrum-sample.conf
prompt_apply_config electrum true
else
edit_file $CITADEL_ROOT/fulcrum/fulcrum.conf
prompt_apply_config electrum false
fi
exit
fi
echo "No service or app \"$2\" not found."
exit 1
fi
# Show logs for apps & services
if [[ "$command" = "logs" ]]; then
shift
POSITIONAL_ARGS=()
follow=false
while [[ $# -gt 0 ]]; do
case $1 in
-f | --follow)
follow=true
shift # past argument
;;
-n | --tail)
number_of_lines="$2"
shift # past argument
shift # past value
;;
-* | --*)
echo "Unknown option $1"
exit 1
;;
*)
POSITIONAL_ARGS+=("$1") # save positional arg
shift # past argument
;;
esac
done
set -- "${POSITIONAL_ARGS[@]}" # restore positional parameters
# Set default number_of_lines if not set by user
if [ -z ${number_of_lines+x} ]; then
if [[ ${#POSITIONAL_ARGS[@]} == 0 ]] || [[ ${#POSITIONAL_ARGS[@]} == 1 ]]; then
number_of_lines=40
else
number_of_lines=10
fi
fi
if [ -z ${1+x} ] || [[ "$1" = "karen" ]]; then
if [[ ${#POSITIONAL_ARGS[@]} == 2 ]]; then
echo "Karen logs cannot be viewed together with other services."
echo "Usage: \`$CLI_NAME $command karen\`"
exit 1
fi
tail $($follow && echo "-f") -n $number_of_lines $CITADEL_ROOT/logs/karen.log
exit
fi
if [[ ${#POSITIONAL_ARGS[@]} == 1 ]]; then
docker logs $($follow && echo "-f") --tail $number_of_lines $@ || {
echo "To see all installed services & apps use \`$CLI_NAME list\`"
echo "Usage: \`$CLI_NAME $command <service>\`"
exit 1
}
else
# TODO: can only show logs for services in docker-compose.yml
docker compose logs $($follow && echo "-f") --tail $number_of_lines $@ || {
echo "To see all installed services & apps use \`$CLI_NAME list\`"
echo "Usage: \`$CLI_NAME $command <service>\`"
exit 1
}
fi
exit
fi
# Debug Citadel
if [[ "$command" = "debug" ]]; then
shift
sudo $CITADEL_ROOT/scripts/debug $@
exit
fi
# Show version information for this CLI
if [[ "$command" = "--version" ]] || [[ "$command" = "-v" ]]; then
citadel_version=$(jq -r '.version' $CITADEL_ROOT/info.json)
echo "Citadel v$citadel_version"
echo "citadel-cli v$CLI_VERSION"
exit
fi
# Show usage information for this CLI
if [[ "$command" = "--help" ]] || [[ "$command" = "-h" ]]; then
show_help
exit
fi
# If we get here it means no valid command was supplied
# Show help and exit
show_help
exit

111
cli/utils/functions.sh Normal file
View File

@ -0,0 +1,111 @@
#!/usr/bin/env bash
set -euo pipefail
show_help() {
cat <<EOF
${CLI_NAME}-cli v${CLI_VERSION}
Manage your Citadel.
Usage: ${CLI_NAME} <command> [options]
Flags:
-h, --help Show this help message
-v, --version Show version information for this CLI
Commands:
status Check the status of all services
start Start the Citadel service
stop Stop the Citadel service safely
restart Restart the Citadel service
reboot Reboot the system
shutdown Shutdown the system
update Update Citadel
list List all installed services apps
run <service> "<command>" Run a command inside a container
set <command> Switch between Bitcoin & Lightning implementations
app <command> Install, update or restart apps
configure <service> Edit service & app configuration files
logs <service> Show logs for an app or service
debug View logs for troubleshooting
EOF
}
is_managed_by_systemd() {
if systemctl --all --type service | grep -q "$SERVICE_NAME"; then
echo true
else
echo false
fi
}
is_service_active() {
service_status=$(systemctl is-active $SERVICE_NAME)
if [[ "$service_status" = "active" ]]; then
echo true
else
echo false
fi
}
edit_file() {
if [[ $1 = "--priviledged" ]]; then
echo "Editing this file requires elevated priviledges."
if ! sudo test -f $2; then
echo "File not found."
exit 1
fi
if sudo test -w $2; then
sudo $EDITOR $2
else
echo "File not writable."
fi
else
if ! test -f $1; then
echo "File not found."
exit 1
fi
if test -w $1; then
$EDITOR $1
else
echo "File not writable."
fi
fi
}
get_update_channel() {
update_channel_line=$(cat $CITADEL_ROOT/.env | grep UPDATE_CHANNEL)
update_channel=(${update_channel_line//=/ })
if [ -z ${update_channel[1]+x} ]; then
# fall back to stable
echo "stable"
else
echo ${update_channel[1]}
fi
}
prompt_apply_config() {
service=$1
persisted=$2
read -p "Do you want to apply the changes now? [y/N] " should_restart
echo
if [[ $should_restart =~ [Yy]$ ]]; then
if $persisted; then
sudo $CITADEL_ROOT/scripts/configure
fi
printf "\nRestarting service \"$service\"...\n"
docker restart $service
echo "Done."
else
if $persisted; then
echo "To apply the changes, restart Citadel by running \`$CLI_NAME restart\`."
else
echo "To apply the changes, restart service \"$service\" by running \`docker restart $service\`."
fi
fi
}

11
cli/utils/helpers.sh Normal file
View File

@ -0,0 +1,11 @@
#!/usr/bin/env bash
set -euo pipefail
trim() {
local var="$*"
# remove leading whitespace characters
var="${var#"${var%%[![:space:]]*}"}"
# remove trailing whitespace characters
var="${var%"${var##*[![:space:]]}"}"
printf '%s' "$var"
}

123
cli/utils/multiselect.sh Normal file
View File

@ -0,0 +1,123 @@
#!/usr/bin/env bash
set -euo pipefail
function multiselect() {
# little helpers for terminal print control and key input
ESC=$(printf "\033")
cursor_blink_on() { printf "$ESC[?25h"; }
cursor_blink_off() { printf "$ESC[?25l"; }
cursor_to() { printf "$ESC[$1;${2:-1}H"; }
print_inactive() { printf "$2 $1 "; }
print_active() { printf "$2 $ESC[7m $1 $ESC[27m"; }
get_cursor_row() {
IFS=';' read -sdR -p $'\E[6n' ROW COL
echo ${ROW#*[}
}
local return_value=$1
local -n options=$2
local -n defaults=$3
local selected=()
for ((i = 0; i < ${#options[@]}; i++)); do
if [[ ${defaults[i]} = "true" ]]; then
selected+=("true")
else
selected+=("false")
fi
printf "\n"
done
# determine current screen position for overwriting the options
local lastrow=$(get_cursor_row)
local startrow=$(($lastrow - ${#options[@]}))
# ensure cursor and input echoing back on upon a ctrl+c during read -s
trap "cursor_blink_on; stty echo; printf '\n'; exit" 2
cursor_blink_off
key_input() {
local key
IFS= read -rsn1 key 2>/dev/null >&2
if [[ $key = "" ]]; then echo enter; fi
if [[ $key = $'\x20' ]]; then echo space; fi
if [[ $key = "k" ]]; then echo up; fi
if [[ $key = "j" ]]; then echo down; fi
if [[ $key = $'\x1b' ]]; then
read -rsn2 key
if [[ $key = [A || $key = k ]]; then echo up; fi
if [[ $key = [B || $key = j ]]; then echo down; fi
fi
}
toggle_option() {
local option=$1
if [[ ${selected[option]} == true ]]; then
selected[option]=false
else
selected[option]=true
fi
}
print_options() {
# print options by overwriting the last lines
local idx=0
for option in "${options[@]}"; do
local prefix="[ ]"
if [[ ${selected[idx]} == true ]]; then
prefix="[\e[38;5;46m✔\e[0m]"
fi
cursor_to $(($startrow + $idx))
if [ $idx -eq $1 ]; then
print_active "$option" "$prefix"
else
print_inactive "$option" "$prefix"
fi
idx=$((idx + 1))
done
}
get_result() {
local result=()
for ((i = 0; i < ${#options[@]}; i++)); do
if ${selected[i]}; then
result+=(${options[i]})
fi
done
echo "${result[@]}"
}
local active=0
while true; do
print_options $active
# user key control
case $(key_input) in
space) toggle_option $active ;;
enter)
print_options -1
break
;;
up)
active=$((active - 1))
if [ $active -lt 0 ]; then active=$((${#options[@]} - 1)); fi
;;
down)
active=$((active + 1))
if [ $active -ge ${#options[@]} ]; then active=0; fi
;;
esac
done
# cursor position back to normal
cursor_to $lastrow
printf "\n"
cursor_blink_on
eval $return_value='("$(get_result)")'
}

87
cli/utils/spinner.sh Normal file
View File

@ -0,0 +1,87 @@
#!/bin/bash
# Author: Tasos Latsas
# spinner.sh
#
# Display an awesome 'spinner' while running your long shell commands
#
# Do *NOT* call _spinner function directly.
# Use {start,stop}_spinner wrapper functions
# usage:
# 1. source this script in your's
# 2. start the spinner:
# start_spinner [display-message-here]
# 3. run your command
# 4. stop the spinner:
# stop_spinner [your command's exit status]
#
# Also see: test.sh
function _spinner() {
# $1 start/stop
#
# on start: $2 display message
# on stop : $2 process exit status
# $3 spinner function pid (supplied from stop_spinner)
local on_success="DONE"
local on_fail="FAIL"
local white="\e[1;37m"
local green="\e[1;32m"
local red="\e[1;31m"
local nc="\e[0m"
case $1 in
start)
# display message with some space
echo -ne "${2} "
# start spinner
i=1
sp='\|/-'
delay=${SPINNER_DELAY:-0.15}
while :; do
printf "\b${sp:i++%${#sp}:1}"
sleep $delay
done
;;
stop)
if [[ -z ${3} ]]; then
echo "spinner is not running.."
exit 1
fi
kill $3 >/dev/null 2>&1
# inform the user upon success or failure
echo -en "\b["
if [[ $2 -eq 0 ]]; then
echo -en "${green}${on_success}${nc}"
else
echo -en "${red}${on_fail}${nc}"
fi
echo -e "]"
;;
*)
echo "invalid argument, try {start/stop}"
exit 1
;;
esac
}
function start_spinner() {
# $1 : msg to display
_spinner "start" "${1}" &
# set global spinner pid
_sp_pid=$!
disown
}
function stop_spinner() {
# $1 : command exit status
_spinner "stop" $1 $_sp_pid
unset _sp_pid
}

View File

@ -2,7 +2,7 @@ version: '3.8'
services:
tor:
container_name: tor
image: lncm/tor:0.4.7.7@sha256:3c4ae833d2fefbea7d960f833a1e89fc9b2069a6e5f360109b5ddc9334ac0227
image: lncm/tor:0.4.7.8@sha256:aab30ebb496aa25934d6096951d8b200347c3c3ce5db3493695229efa2601f7b
user: toruser
restart: on-failure
volumes:
@ -15,7 +15,7 @@ services:
ipv4_address: $TOR_PROXY_IP
app-tor:
container_name: app-tor
image: lncm/tor:0.4.7.7@sha256:3c4ae833d2fefbea7d960f833a1e89fc9b2069a6e5f360109b5ddc9334ac0227
image: lncm/tor:0.4.7.8@sha256:aab30ebb496aa25934d6096951d8b200347c3c3ce5db3493695229efa2601f7b
user: toruser
restart: on-failure
volumes:
@ -26,7 +26,7 @@ services:
ipv4_address: $APPS_TOR_IP
app-2-tor:
container_name: app-2-tor
image: lncm/tor:0.4.7.7@sha256:3c4ae833d2fefbea7d960f833a1e89fc9b2069a6e5f360109b5ddc9334ac0227
image: lncm/tor:0.4.7.8@sha256:aab30ebb496aa25934d6096951d8b200347c3c3ce5db3493695229efa2601f7b
user: toruser
restart: on-failure
volumes:
@ -37,7 +37,7 @@ services:
ipv4_address: $APPS_2_TOR_IP
app-3-tor:
container_name: app-3-tor
image: lncm/tor:0.4.7.7@sha256:3c4ae833d2fefbea7d960f833a1e89fc9b2069a6e5f360109b5ddc9334ac0227
image: lncm/tor:0.4.7.8@sha256:aab30ebb496aa25934d6096951d8b200347c3c3ce5db3493695229efa2601f7b
user: toruser
restart: on-failure
volumes:
@ -65,7 +65,7 @@ services:
ipv4_address: $NGINX_IP
bitcoin:
container_name: bitcoin
image: nolim1t/bitcoinknots:v22.0.knots20211108@sha256:a475da2b2ecda55fcc65ea23e1a36c58b2c10549f1c3d3bb3c31c7dda1127354
image: ghcr.io/runcitadel/bitcoinknots:main@sha256:5fbee0f6f0d09d42aacc11c373ffe6162210c42ce21e6eba294e547e3ad80219
depends_on:
- tor
volumes:
@ -79,7 +79,7 @@ services:
ipv4_address: $BITCOIN_IP
lightning:
container_name: lightning
image: lightninglabs/lnd:v0.14.3-beta@sha256:6a2234b0aad4caed3d993736816b198d6228f32c59b27ba2218d5ebf516ae905
image: lightninglabs/lnd:v0.15.0-beta@sha256:d227a9db0727ff56020c8d6604c8c369757123d238ab6ce679579c2dd0d0d259
user: 1000:1000
depends_on:
- tor
@ -100,7 +100,7 @@ services:
ipv4_address: $LND_IP
dashboard:
container_name: dashboard
image: ghcr.io/runcitadel/dashboard:v0.0.12@sha256:102a44b938765be8224c63785949b90bf796670f01aef4e282d58a4c26a42595
image: ghcr.io/runcitadel/dashboard:v0.0.15@sha256:a2cf5ad79367fb083db0f61e5a296aafee655c99af0c228680644c248ec674a5
restart: on-failure
stop_grace_period: 1m30s
networks:
@ -108,7 +108,7 @@ services:
ipv4_address: $DASHBOARD_IP
manager:
container_name: manager
image: ghcr.io/runcitadel/manager:v0.0.13@sha256:3ced2643e12253fea46abb48a69dcd999d69d3d86ed9956ae0298d4f6be4f06d
image: ghcr.io/runcitadel/manager:v0.0.15@sha256:9fb5a86d9e40a04f93d5b6110d43a0f9a5c4ad6311a843b5442290013196a5ce
depends_on:
- tor
- redis
@ -120,7 +120,6 @@ services:
volumes:
- ${PWD}/info.json:/info.json
- ${PWD}/db:/db
- ${PWD}/events/signals:/signals
- ${PWD}/events:/events
- ${PWD}/apps:/apps
- ${PWD}/lnd:/lnd:ro
@ -163,7 +162,7 @@ services:
ipv4_address: $MANAGER_IP
middleware:
container_name: middleware
image: ghcr.io/runcitadel/middleware:v0.0.10@sha256:afd6e2b6f5ba27cde32f6f6d630ddc6dd46d1072871f7834d7424d0554d0f53d
image: ghcr.io/runcitadel/middleware:v0.0.11@sha256:e472da8cbfa67d9a9dbf321334fe65cdf20a0f9b6d6bab33fdf07210f54e7002
depends_on:
- manager
- bitcoin

View File

@ -6,4 +6,4 @@
CITADEL_ROOT="$(readlink -f $(dirname "${BASH_SOURCE[0]}")/../..)"
"${CITADEL_ROOT}/scripts/set-update-channel" beta
"${CITADEL_ROOT}/scripts/set-update-channel" "${1}"

View File

@ -1,9 +0,0 @@
#!/usr/bin/env bash
# SPDX-FileCopyrightText: 2021-2022 Citadel and contributors
#
# SPDX-License-Identifier: GPL-3.0-or-later
CITADEL_ROOT="$(readlink -f $(dirname "${BASH_SOURCE[0]}")/../..)"
"${CITADEL_ROOT}/scripts/set-update-channel" stable

View File

View File

@ -1,6 +1,6 @@
{
"version": "0.0.4",
"name": "Citadel 0.0.4",
"version": "0.0.7",
"name": "Citadel 0.0.7",
"requires": ">=0.0.1",
"notes": "This update fixes multiple bugs in the 0.0.3 release."
"notes": "While we are busy with the next huge update, you may need to wait longer for updates. This update updates Bitcoin Knots and LND to their latest versions to ensure apps can utilize their latest features. In addition, this update includes the Citadel CLI. More information on that will be published soon."
}

View File

@ -1,6 +1,7 @@
#!/usr/bin/env bash
# SPDX-FileCopyrightText: 2020 Umbrel. https://getumbrel.com
# SPDX-FileCopyrightText: 2022 Citadel and contributors. https://runcitadel.space
#
# SPDX-License-Identifier: GPL-3.0-or-later
@ -98,39 +99,19 @@ tar \
# --verbose \
# --gzip
# Check if a file exists on Firebase by checking if
# "https://firebasestorage.googleapis.com/v0/b/citadel-user-backups.appspot.com/o/backups%2F<THE_FILE_NAME>?alt=media"
# returns a 200 response code.
#
check_if_exists() {
curl -s -o /dev/null -w "%{http_code}" \
-X GET \
"https://firebasestorage.googleapis.com/v0/b/citadel-user-backups.appspot.com/o/backups%2F${1}?alt=media"
}
# Upload a file to Firebase Cloud Storage by uloading its name + a random ID.
# Before uploading, we need to check if a file with the same name already exists, and if it does, change the random ID.
# For example, if we want to upload a file named "<THE_FILE_NAME>" with the content AA, we'd use this command to upload:
# curl -X POST "https://firebasestorage.googleapis.com/v0/b/citadel-user-backups.appspot.com/o/backups%2F<THE_FILE_NAME>?alt=media" -d "AA" -H "Content-Type: text/plain"
# To check if a file exists, we can check if this endpoint returns an error 404:
# curl "https://firebasestorage.googleapis.com/v0/b/citadel-user-backups.appspot.com/o/backups%2F<THE_FILE_NAME>?alt=media"
# To download a file, we can the same endpoint
upload_file() {
local file_to_send="${1}"
local file_name="${2}"
# A random ID to avoid collisions
local random_id="$(tr -dc A-Za-z0-9 </dev/urandom | head -c 60 ; echo '')"
# Check if the file already exists
# While a file with the same name exists, we'll try to upload it with a different ID
while [[ $(check_if_exists "${file_name}-${random_id}") == "200" ]]; do
random_id="$(tr -dc A-Za-z0-9 </dev/urandom | head -c 60 ; echo '')"
done
# Upload the file
local backup_id="${2}"
local upload_data=$(jq --null-input \
--arg name "$backup_id" \
--arg data "$(base64 $file_to_send)" \
'{"name": $name, "data": $data}')
curl -X POST \
"https://firebasestorage.googleapis.com/v0/b/citadel-user-backups.appspot.com/o/backups%2F${file_name}-${random_id}?alt=media" \
-d @"${file_to_send}" \
-H "Content-Type: application/octet-stream" \
> /dev/null
"https://account.runcitadel.space/api/upload" \
-d "${upload_data}" \
-H "Content-Type: application/json" \
--socks5 "localhost:${TOR_PROXY_PORT}" \
> /dev/null
}
if [[ $BITCOIN_NETWORK == "testnet" ]]; then

View File

@ -39,7 +39,7 @@ main () {
echo "Sleeping for ${delay} seconds..."
sleep $delay
echo "Triggering decoy backup..."
touch "${CITADEL_ROOT}/events/signals/backup"
"${CITADEL_ROOT}/scripts/backup/backup"
done
}

View File

@ -48,10 +48,10 @@ monitor_file () {
sleep 1
done
echo "$file_path created! Triggering backup..."
touch "${CITADEL_ROOT}/events/signals/backup"
"${CITADEL_ROOT}/scripts/backup/backup"
fi
fswatch -0 --event Updated $file_path | xargs -0 -n 1 -I {} touch "${CITADEL_ROOT}/events/signals/backup"
fswatch -0 --event Updated $file_path | xargs -0 -n 1 -I {} "${CITADEL_ROOT}/scripts/backup/backup"
}
if [[ ! -d "${CITADEL_ROOT}" ]]; then

2
scripts/configure vendored
View File

@ -334,7 +334,7 @@ templates_to_build = {
"./templates/bitcoin-sample.conf": "./bitcoin/bitcoin.conf",
"./templates/.env-sample": "./.env",
"./templates/electrs-sample.toml": "./electrs/electrs.toml",
"./templates/fulcrumx-sample.conf": "./fulcrumx/fulcrumx.conf",
"./templates/fulcrum-sample.conf": "./fulcrum/fulcrum.conf",
"./templates/nginx-sample.conf": "./nginx/nginx.conf"
}

View File

@ -21,7 +21,17 @@ function get_memory_usage() {
function mem_usage_to_percent() {
local mem_usage="$1"
local total_mem="$(free -m | awk 'NR==2 {print $2}')"
echo "$(awk "BEGIN {printf \"%.1f\", $mem_usage / $total_mem * 100}")"
echo "$(awk "BEGIN {printf \"%.1f\", ${mem_usage/,/.} / ${total_mem/,/.} * 100}")"
}
function app_mem_usage() {
# For every container of the app, get the mem usage, save it, and at the end, print the total mem usage of the app
local mem_usage=0
for container in $(get_app_containers "$1"); do
# Use awk to add, it supports floating point numbers
mem_usage=$(awk "BEGIN {printf \"%.2f\", $mem_usage + $(get_memory_usage "$container")}")
done
echo "${1}: $mem_usage%"
}
get_total_used_mem_raw() {
@ -35,7 +45,7 @@ get_total_used_mem() {
# To get the containers of the app, list every container whose name starts with the name of the app
get_app_containers () {
local app_name="$1"
"${CITADEL_ROOT}/scripts/app" compose "${app_name}" ps | awk '{print $1}' | grep -v 'Name\|-----'
"${CITADEL_ROOT}/scripts/app" compose "${app_name}" ps | awk '{print $1}' | grep -v 'NAME'
}
# Get the memory usage of the whole system, excluding docker containers
@ -48,22 +58,17 @@ get_system_memory_usage() {
}
main() {
echo "total: $(get_total_used_mem)%"&
echo "total: $(get_total_used_mem)%"
echo "system: $(get_system_memory_usage)%"
for service in bitcoin lightning electrum tor; do
echo "${service}: $(get_memory_usage "$service")%" &
done
for app in $("${CITADEL_ROOT}/scripts/app" ls-installed); do
# For every container of the app, get the mem usage, save it, and at the end, print the total mem usage of the app
local mem_usage=0
for container in $(get_app_containers "$app"); do
# Use awk to add, it supports floating point numbers
mem_usage=$(awk "BEGIN {printf \"%.2f\", $mem_usage + $(get_memory_usage "$container")}")
done
wait
echo "${app}: $mem_usage%"
app_mem_usage "${app}" &
done
echo "system: $(get_system_memory_usage)%"
wait
}
echo "Calculating memory usage..."
echo "This may take a while, please wait..."
main | sort --key 2 --numeric-sort --reverse

View File

@ -90,11 +90,11 @@ echo
echo "Starting status monitors..."
echo
pkill -f ./scripts/status-monitor || true
./scripts/status-monitor memory 60 &>> "${CITADEL_LOGS}/status-monitor.log" &
./scripts/status-monitor memory 300 &>> "${CITADEL_LOGS}/status-monitor.log" &
./scripts/status-monitor storage 60 &>> "${CITADEL_LOGS}/status-monitor.log" &
./scripts/status-monitor temperature 15 &>> "${CITADEL_LOGS}/status-monitor.log" &
./scripts/status-monitor uptime 15 &>> "${CITADEL_LOGS}/status-monitor.log" &
./scripts/status-monitor app-updates 300 &>> "${CITADEL_LOGS}/status-monitor.log" &
./scripts/status-monitor app-updates 600 &>> "${CITADEL_LOGS}/status-monitor.log" &
echo "Starting backup monitor..."
echo

View File

@ -4,6 +4,9 @@
#
# SPDX-License-Identifier: GPL-3.0-or-later
# To prevent tools we use from outputting in another language
LANG=C
CITADEL_ROOT="$(readlink -f $(dirname "${BASH_SOURCE[0]}")/../..)"
memory_total_bytes() {
@ -23,7 +26,7 @@ get_app_memory_use() {
local app_memory=0
local app_containers=$("${CITADEL_ROOT}/app/app-manager.py" compose "${app}" ps | awk '{print $1}' | grep -v 'Name\|-----')
local app_containers=$("${CITADEL_ROOT}/scripts/app" compose "${app}" ps | awk '{print $1}' | grep -v 'NAME')
for container in $app_containers; do
local container_memory=$(get_container_memory_use "${container}")
app_memory=$(awk "BEGIN {print ${app_memory}+${container_memory}}")
@ -43,7 +46,7 @@ used=$(memory_used_bytes)
json=$(echo $json | jq --arg used "${used}" '. + {used: $used|tonumber}')
cumulative_app_memory="0"
for app in $( "${CITADEL_ROOT}/app/app-manager.py" ls-installed); do
for app in $( "${CITADEL_ROOT}/scripts/app" ls-installed); do
app_memory=$(get_app_memory_use "${app}")
cumulative_app_memory=$(($cumulative_app_memory+$app_memory))
json=$(echo $json | jq --arg app "${app}" --arg app_memory "${app_memory}" '.breakdown |= .+ [{id: $app, used: $app_memory|tonumber}]')

View File

@ -5,7 +5,6 @@ lnd/*
statuses
tor/*
electrs/*
events/signals
logs/*
app-data/*
apps/networking.json
@ -13,4 +12,4 @@ nginx/*
services/installed.yml
apps/sourceMap.json
apps/.updateignore
fulcrumx/*
fulcrum/*

View File

@ -1,18 +0,0 @@
# SPDX-FileCopyrightText: 2021-2022 Citadel and contributors
#
# SPDX-License-Identifier: GPL-3.0-or-later
bitcoin:
container_name: bitcoin
image: ghcr.io/runcitadel/bitcoin-custom:main@sha256:d0af506f8dc92a434e845305ac4252d0601b699c4b3bc4443073a0a2e237f3a0
depends_on:
- tor
volumes:
- ${PWD}/bitcoin:/data/.bitcoin
restart: on-failure
stop_grace_period: 1m
ports:
- $BITCOIN_P2P_PORT:$BITCOIN_P2P_PORT
networks:
default:
ipv4_address: $BITCOIN_IP

View File

@ -4,7 +4,7 @@
bitcoin:
container_name: bitcoin
image: lncm/bitcoind:v22.0@sha256:37a1adb29b3abc9f972f0d981f45e41e5fca2e22816a023faa9fdc0084aa4507
image: lncm/bitcoind:v23.0@sha256:57317c90d89156a30327fe1b8e51b836e0fd1a8ba13721eb2e75e6b35a570e26
depends_on:
- tor
volumes:

View File

@ -4,7 +4,7 @@
bitcoin:
container_name: bitcoin
image: nolim1t/bitcoinknots:v22.0.knots20211108@sha256:a475da2b2ecda55fcc65ea23e1a36c58b2c10549f1c3d3bb3c31c7dda1127354
image: ghcr.io/runcitadel/bitcoinknots:main@sha256:5fbee0f6f0d09d42aacc11c373ffe6162210c42ce21e6eba294e547e3ad80219
depends_on:
- tor
volumes:

View File

@ -4,12 +4,12 @@
electrum:
container_name: electrum
image: ghcr.io/runcitadel/fulcrumx:latest@sha256:a74abdfe8397f02482faed6bd828477c452df071129f66ad6596d0ab8d29cf39
image: cculianu/fulcrum:latest@sha256:c0543f8b8a5bf6b0c447d8525d6b4360a6c07532f7741f19cc2c179968e71848
working_dir: /data
volumes:
- ${PWD}/bitcoin:/bitcoin:ro
- ${PWD}/fulcrumx:/data
command: /usr/bin/FulcrumX /data/fulcrumx.conf
- ${PWD}/fulcrum:/data
command: /usr/bin/Fulcrum /data/fulcrum.conf
restart: on-failure
stop_grace_period: 5m
ports:

View File

@ -1,19 +0,0 @@
# SPDX-FileCopyrightText: 2021-2022 Citadel and contributors
#
# SPDX-License-Identifier: GPL-3.0-or-later
electrum:
container_name: electrum
image: ghcr.io/runcitadel/fulcrumx:latest@sha256:557a54a5652b475b01c59eb6cffee3568f18b74d875c4cc125e5ac190c4b0706
working_dir: /data
volumes:
- ${PWD}/bitcoin:/bitcoin:ro
- ${PWD}/fulcrumx:/data
command: /usr/bin/FulcrumX /data/fulcrumx.conf
restart: on-failure
stop_grace_period: 5m
ports:
- "$ELECTRUM_PORT:$ELECTRUM_PORT"
networks:
default:
ipv4_address: $ELECTRUM_IP

View File

@ -1,6 +1,6 @@
lightning:
container_name: lightning
image: lightninglabs/lnd:v0.14.3-beta@sha256:6a2234b0aad4caed3d993736816b198d6228f32c59b27ba2218d5ebf516ae905
image: lightninglabs/lnd:v0.15.0-beta@sha256:d227a9db0727ff56020c8d6604c8c369757123d238ab6ce679579c2dd0d0d259
user: 1000:1000
depends_on:
- tor

View File

@ -31,6 +31,21 @@ args = parser.parse_args()
# Function to install a service
# To install it, read the service's YAML file (nodeRoot/services/name.yml) and add it to the main compose file (nodeRoot/docker-compose.yml)
def setService(name, implementation):
# Get all available services
services = next(os.walk(os.path.join(nodeRoot, "services")))[1]
if not name in services:
print("\"{}\" is not a valid service.".format(name))
exit(1)
# Get all available implementations
implementations = next(os.walk(os.path.join(nodeRoot, "services", name)), (None, None, []))[2]
implementations = [x.split('.')[0] for x in implementations]
if not implementation in implementations:
print("\"{}\" is not a valid implementation.".format(implementation))
exit(1)
# Read the YAML file
with open(os.path.join(nodeRoot, "services", name, implementation + ".yml"), 'r') as stream:
service = yaml.safe_load(stream)