Compare commits

...

11 Commits

Author SHA1 Message Date
Valentin Lab db9009e81e dev to remove 5 years ago
Valentin Lab 1d7d049da0 added doc 5 years ago
Valentin Lab f3c23c7673 fix: support BSD also for standard macosx compatibility 5 years ago
Valentin Lab 612a81a4ad fix: compatibility with bash v5 5 years ago
Valentin Lab 9d9bfaa7ef fix: support of Ctrl-C 5 years ago
Valentin Lab 3dd7521e46 new: dev: show loaded config files !minor 5 years ago
Valentin Lab 9b6c1f8613 fix: support running in directory having name containing uppercase 5 years ago
Valentin Lab 371496e901 new: add ``SHOW_ENV`` and ``SHOW_CONFIG_LOCATIONS`` 5 years ago
Valentin Lab 41609925d2 chg: dev: clean color definition !minor 5 years ago
Valentin Lab 72167e0f7f chg: remove requirement on ``sha256sum`` 5 years ago
Valentin Lab fc3a1ff17e fix: in WSL, make compose file accessible 5 years ago
  1. 6
      Dockerfile
  2. 240
      README.org
  3. 212
      bin/compose
  4. 12
      bin/compose-core

6
Dockerfile

@ -118,4 +118,8 @@ RUN apk add openssh-client
## install compose
COPY ./bin/ /usr/local/bin/
ENTRYPOINT ["/usr/local/bin/compose-core"]
## Fixes Ctrl-C handling:
## see https://github.com/moby/moby/issues/2838#issuecomment-402491110
RUN apk add --no-cache tini
# Tini is now available at /sbin/tini
ENTRYPOINT ["/sbin/tini", "--", "/usr/local/bin/compose-core"]

240
README.org

@ -0,0 +1,240 @@
# include-in-agenda
#+PROPERTY: Effort_ALL 0 0:30 1:00 2:00 0.5d 1d 1.5d 2d 3d 4d 5d
#+PROPERTY: Max_effort_ALL 0 0:30 1:00 2:00 0.5d 1d 1.5d 2d 3d 4d 5d
#+PROPERTY: header-args:python :var filename=(buffer-file-name)
#+PROPERTY: header-args:sh :var filename=(buffer-file-name)
#+TODO: TODO WIP BLOCKED | DONE CANCELED
#+LATEX_HEADER: \usepackage[margin=0.5in]{geometry}
#+LaTeX_CLASS: article
#+OPTIONS: H:8 ^:nil prop:("Effort" "Max_effort") tags:not-in-toc
#+COLUMNS: %50ITEM %Effort(Min Effort) %Max_effort(Max Effort)
#+begin_LaTeX
\hypersetup{
linkcolor=blue,
pdfborder={0 0 0 0}
}
#+end_LaTeX
#+TITLE: 0k-compose
#+LATEX: \pagebreak
0k-compose defines and run multi-container setups with Docker.
It is a wrapper around =docker-compose= that aims at offering:
- very simple (short) service description
- completely orchestrate deployment of a solution
- deployed solution is reproducible and identical
#+LATEX: \pagebreak
#+LATEX: \pagebreak
* Installation
** Requirement
You'll need:
- =bash= >= 4.3
- =docker= >= 17.06
Expect to use command line and edit some files with your favorite
editor.
On MacOSX, you'll need to install homebrew
https://www.topbug.net/blog/2013/04/14/install-and-use-gnu-command-line-tools-in-mac-os-x/
** Installation
*** Compose binary
You can download this bash script and set it executable:
https://git.0k.io/0k-compose.git/plain/bin/compose
**** Linux/MacOSX
For instance, you could
#+BEGIN_SRC shell
cd /tmp &&
wget https://git.0k.io/0k-compose.git/plain/bin/compose -O compose &&
chmod +x compose &&
mv compose /usr/local/bin/
#+END_SRC
**** Windows WSL
We need the real compose to be out of the WSL root and in the
Docker available path, so this should work:
You'll need administrative permission, for that just right click on
the installed Linux environment such as Ubuntu and select “Run as
Administrator”.
#+BEGIN_SRC shell
## We make sure we have access to ``cmd.exe``
CMD=$(type -p cmd.exe >/dev/null) || {
for p in {/mnt,}/c/WINDOWS/SYSTEM32; do
if [ -x "$p"/cmd.exe ]; then
CMD="$p"/cmd.exe
fi
done
if [ -z "$CMD" ]; then
echo "cmd.exe is not found in \$PATH." \
"And could not find it in standard directories." >&2
return 1
fi
}
win_env() { "$CMD" /c "<nul set /p=%${1}%" 2>/dev/null; }
wsl_path_env() { wslpath "$(win_env "${1}")"; }
## Actual installation code:
PROGRAM_PATH="$(wsl_path_env ProgramFiles)"/Compose &&
mkdir -p "$PROGRAM_PATH/bin" &&
cd /tmp &&
wget "https://git.0k.io/0k-compose.git/plain/bin/compose?h=dev" -O compose &&
chmod +x compose &&
mv compose "$PROGRAM_PATH"/bin/compose &&
mkdir -p ~/.local/bin &&
ln -sf "$PROGRAM_PATH"/bin/compose ~/.local/bin/
cd "$OLDPWD"
#+END_SRC
*** Test
You should be able to run:
#+BEGIN_SRC shell
compose --version
#+END_SRC
*** Charms
You should get some charms to mode forward. Charms are sort of
packages that know how to setup services.
**** Linux/MacOSX
If you are installing this for you user account in Linux or MacOSX:
#+BEGIN_SRC shell
cd ~ &&
git clone https://git.0k.io/0k-charms.git .charm-store
#+END_SRC
If you want to install them for general usage and intend
to run =compose= with root access:
#+BEGIN_SRC shell
cd /srv &&
git clone https://git.0k.io/0k-charms.git charm-store
#+END_SRC
**** Windows WSL
#+BEGIN_SRC shell
win_env() { cmd.exe /c "<nul set /p=%${1}%" 2>/dev/null; }
wsl_path_env() { wslpath "$(win_env "${1}")"; }
PROGRAM_PATH="$(wsl_path_env ProgramFiles)"/Compose &&
cd "$PROGRAM_PATH" &&
git clone https://git.0k.io/0k-charms.git charm-store
#+END_SRC
** Usage
Create a basic =compose.yml=, for instance:
#+BEGIN_SRC yaml
odoo:
#+END_SRC
And then run:
#+BEGIN_SRC sh
compose up
#+END_SRC
** Configuration
=compose= needs to store various things on your host:
- the =datastore= is the data of all services that will be run by
=compose= . This datastore can be saved and restored or moved around
between hosts to revive the exact same services at the same point
in time. It contains any data the service will produce : it can be
work data, logs, parameters if the service allows for some configuration.
On linux servers, when you are installing this with =root=
permissions, it is traditionally stored in =/srv/datastore=. On
local user installs you could tuck it in ``~/.compose/datastore``.
- a state information directory for =compose= to store various
internal running information: compose is not a daemon running in the
back so it is storing state information in this directory. It is
basically configuration values for services (for instance: user and
password access between services, internal URL so that services
can talk to each others... and any configuration that can be generated
automatically, like configurations files in the service's format...)
- a cache directory for =compose=, which is just a place to store
intermediate values of computationaly expensive code. The content of
this directory can be entirely wiped out and is cleaned by compose
regularly to avoid growing too much.
It'll need to get a read access also to:
- a =charms-store=, a directory tree filled with charms. A charm is a directory conforming
to charm rules (most importantly, it should hold a file named =metadata.yml=). A charm
describe how to spawn a service. We have charms for =postgres=, =rocketchat=, =apache=, ...
When launching =compose=, it takes also a =compose.yml= to manage
services to deploy that can be specified with the =-f MYCOMPOSEFILE=
optional argument, or will be found automatially in current directory
or parents of it.
* Feature
compose run
- much smaller and functional ``compose.yml`` file
- all boiler-plate code is considered technical details
and are moved in ``charms``
- common compose-level API for same intents
- launching a 'frontend', whether it is 'ngnix' or 'apache'
is just one word to change in ``compose.yml``.
- explicitely describe the relations between services and
manage their dependencies.
- be a good mannered command-line layer that don't remove any
feature from ``docker-compose``.
- allow complex setups and behavior out of ``docker-compose`` reach
through:
- =init= hooks that are run on the host before launching
- relation hooks that triggers only when connecting 2 services
- it enforce a clear separation of responsabilities
** Good mannered command line layer
We want =compose= to be used as a drop-in replacement where ``docker-compose``
can be used. With the following exception:
- its own service file is named =compose.yml=.
- some =compose='s service are subordinates
*** Keeping command-line intact
compose run

212
bin/compose

@ -15,18 +15,43 @@
## From kal-shlib
##
ANSI_ESC=$'\e['
_sed_compat_load() {
if get_path sed >/dev/null; then
if sed --version >/dev/null 2>&1; then ## GNU
sed_compat() { sed -r "$@"; }
sed_compat_i() { sed -r -i "$@"; }
else ## BSD
sed_compat() { sed -E "$@"; }
sed_compat_i() { sed -E -i "" "$@"; }
fi
else
## Look for ``gsed``
if (get_path gsed && gsed --version) >/dev/null 2>&1; then
sed_compat() { gsed -r "$@"; }
sed_compat_i() { gsed -r -i "$@"; }
else
die "$exname: required GNU or BSD sed not found"
fi
fi
}
md5_compat() {
if get_path md5sum >/dev/null; then
md5_compat() { md5sum | cut -c -32; }
## BSD / GNU sed compatibility layer
sed_compat() { _sed_compat_load; sed_compat "$@"; }
hash_get() {
if get_path sha256sum >/dev/null; then
hash_get() { local x; x=$(sha256sum) || return 1; echo "${x:0:32}"; }
elif get_path md5sum >/dev/null; then
hash_get() { local x; x=$(md5sum) || return 1; echo "${x:0:32}"; }
elif get_path md5 >/dev/null; then
md5_compat() { md5; }
hash_get() { md5; }
else
die "$exname: required GNU or BSD date not found"
err "required GNU md5sum or BSD md5 not found"
return 1
fi
md5_compat
hash_get
}
@ -71,22 +96,9 @@ ansi_color() {
fi
fi
if [ "$choice" != "no" ]; then
SET_COL_CHAR="${ANSI_ESC}${COL_CHAR}G"
SET_COL_STATUS="${ANSI_ESC}${COL_STATUS}G"
SET_COL_INFO="${ANSI_ESC}${COL_INFO}G"
SET_COL_ELT="${ANSI_ESC}${COL_ELT}G"
SET_BEGINCOL="${ANSI_ESC}0G"
ANSI_ESC=$'\e['
UP="${ANSI_ESC}1A"
DOWN="${ANSI_ESC}1B"
LEFT="${ANSI_ESC}1D"
RIGHT="${ANSI_ESC}1C"
SAVE="${ANSI_ESC}7"
RESTORE="${ANSI_ESC}8"
if [ "$choice" != "no" ]; then
NORMAL="${ANSI_ESC}0m"
@ -108,23 +120,8 @@ ansi_color() {
DARKCYAN="${ANSI_ESC}0;36m"
DARKWHITE="${ANSI_ESC}0;37m"
SUCCESS=$GREEN
WARNING=$YELLOW
FAILURE=$RED
NOOP=$BLUE
ON=$SUCCESS
OFF=$FAILURE
ERROR=$FAILURE
else
SET_COL_CHAR=
SET_COL_STATUS=
SET_COL_INFO=
SET_COL_ELT=
SET_BEGINCOL=
NORMAL=
RED=
GREEN=
@ -140,24 +137,15 @@ ansi_color() {
DARKBLUE=
DARKPINK=
DARKCYAN=
SUCCESS=
WARNING=
FAILURE=
NOOP=
ON=
OFF=
ERROR=
DARKWHITE=
fi
ansi_color="$choice"
export SET_COL_CHAR SET_COL_STATUS SET_COL_INFO SET_COL_ELT \
SET_BEGINCOL UP DOWN LEFT RIGHT SAVE RESTORE NORMAL \
GRAY RED GREEN YELLOW BLUE PINK CYAN WHITE DARKGRAY \
export NORMAL GRAY RED GREEN YELLOW BLUE PINK CYAN WHITE DARKGRAY \
DARKRED DARKGREEN DARKYELLOW DARKBLUE DARKPINK DARKCYAN \
SUCCESS WARNING FAILURE NOOP ON OFF ERROR ansi_color
DARKWHITE SUCCESS WARNING FAILURE NOOP ON OFF ERROR ansi_color
}
@ -218,9 +206,9 @@ get_os() {
uname_output="$(uname -s)"
case "${uname_output}" in
Linux*)
if [[ "$(< /proc/version)" == *@(Microsoft|WSL)* ]]; then
if [[ "$(< /proc/version)" =~ "@(Microsoft|WSL)" ]]; then
e wsl
# elif [[ "$(< /proc/version)" == *"@(microsoft|WSL)"* ]]; then
# elif [[ "$(< /proc/version)" =~ "@(microsoft|WSL)" ]]; then
# e wsl2
else
e linux
@ -408,14 +396,14 @@ get_hash_image() {
p0 ""
[ -n "$override" ] && cat "$override"
true
} | md5_compat
} | hash_get
return "${PIPESTATUS[0]}"
}
get_compose_file_opt() {
local hash_bin="$1" override="$2" \
cache_file="$COMPOSE_LAUNCHER_CACHE/$FUNCNAME.cache.$(p0 "$@" | md5_compat)"
cache_file="$COMPOSE_LAUNCHER_CACHE/$FUNCNAME.cache.$(p0 "$@" | hash_get)"
if [ -e "$cache_file" ]; then
cat "$cache_file" &&
touch "$cache_file" || return 1
@ -454,7 +442,7 @@ get_compose_file_opt() {
replace_compose_file_opt() {
local hash_bin="$1" override="$2" \
cache_file="$COMPOSE_LAUNCHER_CACHE/$FUNCNAME.cache.$(p0 "$@" | md5_compat)"
cache_file="$COMPOSE_LAUNCHER_CACHE/$FUNCNAME.cache.$(p0 "$@" | hash_get)"
if [ -e "$cache_file" ]; then
cat "$cache_file" &&
touch "$cache_file" || return 1
@ -519,14 +507,14 @@ get_compose_opts_list() {
grep -E -m 1 "^\S*\$" -B 10000 |
head -n -1 |
grep -E "^\s+-" |
sed -r 's/\s+((((-[a-zA-Z]|--[a-zA-Z0-9-]+)( [A-Z=]+|=[^ ]+)?)(, )?)+)\s+.*$/\1/g' |
sed_compat 's/\s+((((-[a-zA-Z]|--[a-zA-Z0-9-]+)( [A-Z=]+|=[^ ]+)?)(, )?)+)\s+.*$/\1/g' |
tee "$cache_file" || return 1
}
multi_opts_filter() {
grep -E "$_MULTIOPTION_REGEX_LINE_FILTER" |
sed -r "s/^($_MULTIOPTION_REGEX)(\s|=).*$/\1/g" |
sed_compat "s/^($_MULTIOPTION_REGEX)(\s|=).*$/\1/g" |
tr ',' "\n" | nspc
}
@ -565,7 +553,7 @@ get_compose_single_opts_list() {
get_volume_opt() {
local cache_file="$COMPOSE_LAUNCHER_CACHE/$FUNCNAME.cache.$(p0 "$@" | md5_compat)"
local cache_file="$COMPOSE_LAUNCHER_CACHE/$FUNCNAME.cache.$(p0 "$@" | hash_get)"
if [ -e "$cache_file" ]; then
cat "$cache_file" &&
touch "$cache_file" || return 1
@ -789,6 +777,7 @@ mk_docker_run_options() {
for cfgfile in "${compose_config_files[@]}"; do
[ -e "$cfgfile" ] || continue
docker_run_opts+=("-v" "$cfgfile:$cfgfile:ro")
debug "Loading config file '$cfgfile'."
. "$cfgfile"
done
else
@ -800,7 +789,7 @@ mk_docker_run_options() {
## get TZ value and prepare TZ_PATH
TZ=$(get_tz) || return 1
mkdir -p "${TZ_PATH}"
TZ_PATH="${TZ_PATH}/$(e "$TZ" | sha256sum | cut -c 1-8)" || return 1
TZ_PATH="${TZ_PATH}/$(e "$TZ" | hash_get | cut -c 1-8)" || return 1
[ -e "$TZ_PATH" ] || e "$TZ" > "$TZ_PATH"
## CACHE/DATA DIRS
@ -826,26 +815,6 @@ mk_docker_run_options() {
)
check_no_links_subdirs "$CHARM_STORE"/* || return 1
## DEFAULT_COMPOSE_FILE
if [ "${DEFAULT_COMPOSE_FILE+x}" ]; then
DEFAULT_COMPOSE_FILE=$(realpath "$DEFAULT_COMPOSE_FILE")
dirname=$(dirname "$DEFAULT_COMPOSE_FILE")/
if [ -e "${DEFAULT_COMPOSE_FILE}" ]; then
docker_run_opts+=("-v" "$dirname:$dirname:ro")
fi
fi
## COMPOSE_YML_FILE
if [ "${COMPOSE_YML_FILE+x}" ]; then
if [ -e "${COMPOSE_YML_FILE}" ]; then
docker_run_opts+=(
"-v" "$COMPOSE_YML_FILE:/tmp/compose.yml:ro"
"-e" "COMPOSE_YML_FILE=/tmp/compose.yml"
"-e" "HOST_COMPOSE_YML_FILE=/tmp/compose.yml"
)
fi
fi
## DATASTORE and CONFIGSTORE
docker_run_opts+=(
@ -897,7 +866,9 @@ mk_docker_run_options() {
get_compose_file_opt "$COMPOSE_LAUNCHER_HASH" "$COMPOSE_LAUNCHER_BIN_OVERRIDE" \
"$@") || return 1
if [ -z "$ARG_COMPOSE_FILE" ]; then
compose_file="${ARG_COMPOSE_FILE:-$COMPOSE_YML_FILE}"
if [ -z "$compose_file" ]; then
## Find a compose.yml in parents
debug "No config file specified on command line arguments"
debug "Looking for 'compose.yml' in self and parents.."
@ -915,8 +886,6 @@ mk_docker_run_options() {
else
debug " .. not found."
fi
else
compose_file="$ARG_COMPOSE_FILE"
fi
if [ -n "$compose_file" ]; then
@ -924,11 +893,23 @@ mk_docker_run_options() {
die "Specified compose file '$compose_file' not found."
fi
compose_file="$(realpath "$compose_file")"
if [ "$OS" == "wsl" ]; then
## Docker host is not same linux than WSL, so
## access to root files are not the same.
##YYYvlab, check on cp where is the base
dst="$COMPOSE_LAUNCHER_CACHE/compose.$(hash_get < "$compose_file").yml"
cp "$compose_file" "$dst"
## docker host start with /c/... whereas WSL could start with /mnt/c/...
local="$dst"
else
local="$compose_file"
fi
parent_dir="${compose_file%/*}"
docker_path=/var/lib/compose/root/${parent_dir##*/}/${compose_file##*/}
docker_run_opts+=(
"-e" "COMPOSE_YML_FILE=${compose_file##*/}"
"-v" "${compose_file}:${docker_path}:ro"
"-v" "${local}:${docker_path}:ro"
"-w" "${docker_path%/*}"
)
else
@ -941,12 +922,11 @@ mk_docker_run_options() {
clean_unused_sessions
filename=$(mktemp -p /tmp/ -t launch_opts-XXXXXXXXXXXXXXXX)
p0 "${docker_run_opts[@]}" > "$filename"
sha=$(sha256sum "$filename")
sha=${sha:0:64}
src="$SESSION_DIR/$UID-$sha"
dest="/var/lib/compose/sessions/$UID-$sha"
hash=$(hash_get < "$filename") || return 1
src="$SESSION_DIR/$UID-$hash"
dest="/var/lib/compose/sessions/$UID-$hash"
additional_docker_run_opts=(
"-v" "$SESSION_DIR/$UID-$sha:$dest:ro"
"-v" "$SESSION_DIR/$UID-$hash:$dest:ro"
"-e" "COMPOSE_LAUNCHER_OPTS=$dest"
"-e" "COMPOSE_LAUNCHER_BIN=$COMPOSE_LAUNCHER_BIN"
)
@ -961,24 +941,10 @@ mk_docker_run_options() {
mkdir -p "$SESSION_DIR" || return 1
mv -f "$filename" "$src" || return 1
if [ -n "$DEBUG" ]; then
echo "${WHITE}Environment:${NORMAL}"
echo " COMPOSE_DOCKER_IMAGE: $COMPOSE_DOCKER_IMAGE"
echo " CHARM_STORE: $CHARM_STORE"
echo " DATASTORE: $DATASTORE"
echo " CONFIGSTORE: $CONFIGSTORE"
echo " COMPOSE_VAR: $COMPOSE_VAR"
echo " COMPOSE_CACHE: $COMPOSE_CACHE"
echo " COMPOSE_LAUNCHER_CACHE: $COMPOSE_LAUNCHER_CACHE"
echo " SESSION_DIR: $SESSION_DIR"
echo " TZ_PATH: $TZ_PATH"
fi >&2
}
run() {
local os docker_run_opts
load_env() {
docker_run_opts=()
if [ -z "$COMPOSE_LAUNCHER_OPTS" ]; then
mk_docker_run_options "$@" || return 1
@ -995,8 +961,29 @@ run() {
fi
done < <(cat "$COMPOSE_LAUNCHER_OPTS")
fi
}
set_os
show_env() {
echo "${WHITE}Environment:${NORMAL}"
echo " COMPOSE_DOCKER_IMAGE: $COMPOSE_DOCKER_IMAGE"
echo " CHARM_STORE: $CHARM_STORE"
echo " DATASTORE: $DATASTORE"
echo " CONFIGSTORE: $CONFIGSTORE"
echo " COMPOSE_VAR: $COMPOSE_VAR"
echo " COMPOSE_CACHE: $COMPOSE_CACHE"
echo " COMPOSE_LAUNCHER_CACHE: $COMPOSE_LAUNCHER_CACHE"
echo " SESSION_DIR: $SESSION_DIR"
echo " TZ_PATH: $TZ_PATH"
}
run() {
local os docker_run_opts
load_env "$@" || return 1
[ -n "$DEBUG" ] && show_env
set_os || return 1
if [ -n "$ARG_COMPOSE_FILE" ]; then
array_read-0 cmd_args < \
@ -1012,7 +999,7 @@ run() {
if [ -n "$DEBUG" ] || [ -n "$DRY_RUN" ]; then
debug "${WHITE}Launching:${NORMAL}"
echo "docker run --rm \\"
pretty_print "${docker_run_opts[@]}" | sed -r 's/^/ /g;s/([^\])$/\1\\\n/g'
pretty_print "${docker_run_opts[@]}" | sed_compat 's/^/ /g;s/([^\])$/\1\\\n/g'
if [ -z "$ENTER" ]; then
echo " ${COMPOSE_DOCKER_IMAGE} \\"
echo " " "$@"
@ -1020,7 +1007,7 @@ run() {
echo " --entrypoint bash \\"
echo " ${COMPOSE_DOCKER_IMAGE}"
fi
fi | { if [ -n "$DEBUG" ]; then sed -r 's/^/ /g'; else cat; fi } >&2
fi | { if [ -n "$DEBUG" ]; then sed_compat 's/^/ /g'; else cat; fi } >&2
if [ -z "$DRY_RUN" ]; then
debug "${WHITE}Execution:${NORMAL}"
if [ -z "$ENTER" ]; then
@ -1046,4 +1033,19 @@ depends docker cat readlink sed realpath tee sed grep tail
ansi_color "${ansi_color:-tty}"
if [ "$SHOW_ENV" ]; then
load_env || return 1
show_env
exit 0
fi
if [ "$SHOW_CONFIG_LOCATIONS" ]; then
set_os || return 1
echo "compose will read these files if existing in the given order:"
for loc in "${compose_config_files[@]}"; do
echo " - $loc"
done
exit 0
fi
run "$@"

12
bin/compose-core

@ -3045,15 +3045,19 @@ export -f _save
get_default_project_name() {
if [ "$DEFAULT_PROJECT_NAME" ]; then
local normalized_path compose_yml_location name
if [ -n "$DEFAULT_PROJECT_NAME" ]; then
echo "$DEFAULT_PROJECT_NAME"
return 0
fi
compose_yml_location="$(get_compose_yml_location)" || return 1
if [ "$compose_yml_location" ]; then
if [ -n "$compose_yml_location" ]; then
if normalized_path=$(readlink -f "$compose_yml_location"); then
name="$(basename "$(dirname "$normalized_path")")"
echo "${name%%-deploy}"
## XXX:
name="${normalized_path%/*}" ## dirname
name="${name##*/}" ## basename
name="${name%%-deploy}" ## remove any '-deploy'
name="${name,,}" ## lowercase
return 0
fi
fi

Loading…
Cancel
Save