mirror of
https://github.com/ggogel/seafile-containerized.git
synced 2024-11-16 17:05:32 +00:00
Initial work on running seafile with docker.
This commit is contained in:
parent
f1fd42ef07
commit
7814d43b12
3
.gitignore
vendored
3
.gitignore
vendored
|
@ -3,3 +3,6 @@
|
||||||
*.swp
|
*.swp
|
||||||
.DS_Store
|
.DS_Store
|
||||||
*.pyc
|
*.pyc
|
||||||
|
|
||||||
|
containers/
|
||||||
|
shared/
|
||||||
|
|
|
@ -9,4 +9,4 @@ install:
|
||||||
- echo "Nothing to install"
|
- echo "Nothing to install"
|
||||||
|
|
||||||
script:
|
script:
|
||||||
- cd base && docker build -t seafile/base .
|
- cd image && make base && make
|
||||||
|
|
0
containers/.gitkeep
Normal file
0
containers/.gitkeep
Normal file
9
image/Makefile
Normal file
9
image/Makefile
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
version=6.0.5
|
||||||
|
|
||||||
|
all:
|
||||||
|
cd seafile && docker build -t seafileorg/server:$(version) .
|
||||||
|
|
||||||
|
base:
|
||||||
|
cd base && docker build -t seafileorg/base:16.04 .
|
||||||
|
|
||||||
|
.PHONY: base
|
16
image/base/Dockerfile
Normal file
16
image/base/Dockerfile
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
# lastet phusion baseimage as of 2016.11, based on ubuntu 16.04
|
||||||
|
FROM phusion/baseimage:0.9.19
|
||||||
|
|
||||||
|
ENV UPDATED_AT 20161110
|
||||||
|
|
||||||
|
RUN apt-get update -qq && apt-get -qq -y install python2.7-dev memcached python-pip \
|
||||||
|
python-setuptools python-imaging python-mysqldb python-memcache python-ldap \
|
||||||
|
python-urllib3 sqlite3 nginx \
|
||||||
|
vim htop net-tools psmisc git wget curl
|
||||||
|
|
||||||
|
RUN pip install -U wheel && pip install click termcolor prettytable colorlog
|
||||||
|
|
||||||
|
RUN mkdir /etc/service/memcached
|
||||||
|
ADD memcached.sh /etc/service/memcached/run
|
||||||
|
|
||||||
|
CMD ["/sbin/my_init", "--", "bash", "-l"]
|
4
image/base/memcached.sh
Executable file
4
image/base/memcached.sh
Executable file
|
@ -0,0 +1,4 @@
|
||||||
|
#!/bin/sh
|
||||||
|
# `/sbin/setuser memcache` runs the given command as the user `memcache`.
|
||||||
|
# If you omit that part, the command will be run as root.
|
||||||
|
exec /sbin/setuser memcache /usr/bin/memcached >>/var/log/memcached.log 2>&1
|
13
image/seafile/Dockerfile
Normal file
13
image/seafile/Dockerfile
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
FROM seafileorg/base:16.04
|
||||||
|
WORKDIR /opt/seafile
|
||||||
|
|
||||||
|
ENV SEAFILE_VERSION=6.0.5
|
||||||
|
|
||||||
|
RUN mkdir -p /opt/seafile/ && \
|
||||||
|
curl -sSL -o - https://bintray.com/artifact/download/seafile-org/seafile/seafile-server_6.0.5_x86-64.tar.gz \
|
||||||
|
| tar xzf - -C /opt/seafile/
|
||||||
|
|
||||||
|
RUN mkdir -p /etc/my_init.d
|
||||||
|
ADD create_data_links.sh /etc/my_init.d/create_data_links.sh
|
||||||
|
|
||||||
|
CMD ["/sbin/my_init", "--", "bash", "-l"]
|
24
image/seafile/create_data_links.sh
Executable file
24
image/seafile/create_data_links.sh
Executable file
|
@ -0,0 +1,24 @@
|
||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
set -e
|
||||||
|
set -o pipefail
|
||||||
|
|
||||||
|
dirs=(
|
||||||
|
conf
|
||||||
|
ccnet
|
||||||
|
logs
|
||||||
|
seafile-data
|
||||||
|
seahub-data
|
||||||
|
seahub.db
|
||||||
|
)
|
||||||
|
|
||||||
|
for d in ${dirs[*]}; do
|
||||||
|
src=/shared/$d
|
||||||
|
if [[ -e $src ]]; then
|
||||||
|
ln -sf $src /opt/seafile/
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
ln -sf /opt/seafile/seafile-server-${SEAFILE_VERSION} /opt/seafile/seafile-server-latest
|
||||||
|
|
||||||
|
# TODO: create avatars link
|
78
launcher
Executable file
78
launcher
Executable file
|
@ -0,0 +1,78 @@
|
||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
set -e
|
||||||
|
set -o pipefail
|
||||||
|
|
||||||
|
version=6.0.5
|
||||||
|
image=seafileorg/server:$version
|
||||||
|
topdir=$(cd $(dirname $0); pwd -P)
|
||||||
|
sharedir=$topdir/shared
|
||||||
|
|
||||||
|
cd $topdir
|
||||||
|
|
||||||
|
dbg() {
|
||||||
|
if [[ $debug == "true" ]]; then
|
||||||
|
echo "dbg: $1"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
err_and_quit () {
|
||||||
|
printf "\n\n\033[33mError: %s\033[m\n\n" "$1"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
set_volumes() {
|
||||||
|
local mounts seahub_db
|
||||||
|
|
||||||
|
seahub_db=$sharedir/seahub.db
|
||||||
|
if [[ ! -e $seahub_db ]]; then
|
||||||
|
touch $seahub_db
|
||||||
|
fi
|
||||||
|
|
||||||
|
local bash_history=$sharedir/.bash_history
|
||||||
|
if [[ ! -e $bash_history ]]; then
|
||||||
|
touch $bash_history
|
||||||
|
fi
|
||||||
|
|
||||||
|
mounts=(
|
||||||
|
$sharedir:/shared
|
||||||
|
$topdir/containers:/containers:ro
|
||||||
|
$topdir/scripts:/scripts:ro
|
||||||
|
$bash_history:/root/.bash_history
|
||||||
|
)
|
||||||
|
volumes=""
|
||||||
|
local m
|
||||||
|
for m in ${mounts[*]}; do
|
||||||
|
volumes="$volumes -v $m"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
bootstrap() {
|
||||||
|
set_volumes
|
||||||
|
docker run --rm -it $volumes $image /scripts/bootstrap.py
|
||||||
|
}
|
||||||
|
|
||||||
|
start() {
|
||||||
|
set_volumes
|
||||||
|
docker run --rm -it $volumes $image # scripts/start.py
|
||||||
|
}
|
||||||
|
|
||||||
|
enter() {
|
||||||
|
err_and_quit "Not implemented yet"
|
||||||
|
}
|
||||||
|
|
||||||
|
function main {
|
||||||
|
local action
|
||||||
|
while [[ $# -gt 0 ]]
|
||||||
|
do
|
||||||
|
case "$1" in
|
||||||
|
bootstrap|start|enter) action=$1 ; shift 1 ;;
|
||||||
|
--debug) debug=true ; shift 1 ;;
|
||||||
|
--dummy) dummy=$2 ; shift 2 ;;
|
||||||
|
*) err_and_quit "Argument error. Please see help." ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
"$action"
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
47
scripts/bootstrap.py
Executable file
47
scripts/bootstrap.py
Executable file
|
@ -0,0 +1,47 @@
|
||||||
|
#!/usr/bin/env python
|
||||||
|
#coding: UTF-8
|
||||||
|
|
||||||
|
"""
|
||||||
|
This script calls the appropriate seafile init scripts (e.g.
|
||||||
|
setup-seafile.sh or setup-seafile-mysql.sh. It's supposed to run inside the
|
||||||
|
container.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from ConfigParser import ConfigParser
|
||||||
|
import os
|
||||||
|
from os.path import abspath, basename, exists, dirname, join, isdir
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from utils import call, get_install_dir, get_script
|
||||||
|
|
||||||
|
installdir = get_install_dir()
|
||||||
|
topdir = dirname(installdir)
|
||||||
|
|
||||||
|
_config = None
|
||||||
|
|
||||||
|
def get_conf(key):
|
||||||
|
global _config
|
||||||
|
if _config is None:
|
||||||
|
_config = ConfigParser()
|
||||||
|
_config.read("/containers/bootstrap.conf")
|
||||||
|
return _config.get("server", key)
|
||||||
|
|
||||||
|
def main():
|
||||||
|
env = {
|
||||||
|
'SERVER_NAME': 'seafile',
|
||||||
|
'SERVER_IP': get_conf("server.hostname"),
|
||||||
|
}
|
||||||
|
call('{} auto'.format(get_script('setup-seafile.sh')), env=env)
|
||||||
|
for fn in ('conf', 'ccnet', 'seafile-data', 'seahub-data', 'seahub.db'):
|
||||||
|
src = join(topdir, fn)
|
||||||
|
dst = join('/shared', fn)
|
||||||
|
if exists(dst):
|
||||||
|
if isdir(dst):
|
||||||
|
shutil.rmtree(dst)
|
||||||
|
else:
|
||||||
|
os.unlink(dst)
|
||||||
|
shutil.move(src, '/shared')
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
8
scripts/config.py
Normal file
8
scripts/config.py
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
#!/usr/bin/env python
|
||||||
|
#coding: UTF-8
|
||||||
|
|
||||||
|
def main():
|
||||||
|
pass
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
0
scripts/start.py
Normal file
0
scripts/start.py
Normal file
206
scripts/utils/__init__.py
Normal file
206
scripts/utils/__init__.py
Normal file
|
@ -0,0 +1,206 @@
|
||||||
|
# coding: UTF-8
|
||||||
|
|
||||||
|
from __future__ import print_function
|
||||||
|
import os
|
||||||
|
import platform
|
||||||
|
import sys
|
||||||
|
import termcolor
|
||||||
|
import subprocess
|
||||||
|
import logging
|
||||||
|
import logging.config
|
||||||
|
import click
|
||||||
|
import colorlog
|
||||||
|
from os.path import abspath, basename, exists, dirname, join, isdir, expanduser
|
||||||
|
from contextlib import contextmanager
|
||||||
|
|
||||||
|
logger = logging.getLogger('.utils')
|
||||||
|
|
||||||
|
def eprint(*a, **kw):
|
||||||
|
kw['file'] = sys.stderr
|
||||||
|
print(*a, **kw)
|
||||||
|
|
||||||
|
def identity(msg, *a, **kw):
|
||||||
|
return msg
|
||||||
|
|
||||||
|
colored = identity if not os.isatty(sys.stdin.fileno()) else termcolor.colored
|
||||||
|
red = lambda s: colored(s, 'red')
|
||||||
|
green = lambda s: colored(s, 'green')
|
||||||
|
|
||||||
|
def underlined(msg):
|
||||||
|
return '\x1b[4m{}\x1b[0m'.format(msg)
|
||||||
|
|
||||||
|
def sudo(*a, **kw):
|
||||||
|
call('sudo ' + a[0], *a[1:], **kw)
|
||||||
|
|
||||||
|
def _find_flag(args, *opts, **kw):
|
||||||
|
is_flag = kw.get('is_flag', False)
|
||||||
|
if is_flag:
|
||||||
|
return any([opt in args for opt in opts])
|
||||||
|
else:
|
||||||
|
for opt in opts:
|
||||||
|
try:
|
||||||
|
return args[args.index(opt) + 1]
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def call(*a, **kw):
|
||||||
|
dry_run = kw.pop('dry_run', False)
|
||||||
|
quiet = kw.pop('quiet', False)
|
||||||
|
cwd = kw.get('cwd', os.getcwd())
|
||||||
|
reduct_args = kw.pop('reduct_args', [])
|
||||||
|
if not quiet:
|
||||||
|
toprint = a[0]
|
||||||
|
args = [x.strip('"') for x in a[0].split() if '=' not in x]
|
||||||
|
for arg in reduct_args:
|
||||||
|
value = _find_flag(args, arg)
|
||||||
|
toprint = toprint.replace(value, '{}**reducted**'.format(value[:3]))
|
||||||
|
eprint('calling: ', green(toprint))
|
||||||
|
eprint('cwd: ', green(cwd))
|
||||||
|
kw.setdefault('shell', True)
|
||||||
|
if not dry_run:
|
||||||
|
return subprocess.Popen(*a, **kw).wait()
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def cd(path):
|
||||||
|
path = expanduser(path)
|
||||||
|
olddir = os.getcwd()
|
||||||
|
os.chdir(path)
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
os.chdir(olddir)
|
||||||
|
|
||||||
|
def must_makedir(p):
|
||||||
|
p = expanduser(p)
|
||||||
|
if not exists(p):
|
||||||
|
logger.info('created folder %s', p)
|
||||||
|
os.makedirs(p)
|
||||||
|
else:
|
||||||
|
logger.debug('folder %s already exists', p)
|
||||||
|
|
||||||
|
def setup_colorlog():
|
||||||
|
logging.config.dictConfig({
|
||||||
|
'version': 1,
|
||||||
|
'disable_existing_loggers': False,
|
||||||
|
'formatters': {
|
||||||
|
'standard': {
|
||||||
|
'format': '%(asctime)s [%(levelname)s] %(name)s: %(message)s'
|
||||||
|
},
|
||||||
|
'colored': {
|
||||||
|
'()': 'colorlog.ColoredFormatter',
|
||||||
|
'format': "%(log_color)s[%(asctime)s]%(reset)s %(blue)s%(message)s",
|
||||||
|
'datefmt': '%m/%d/%Y %H:%M:%S',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'handlers': {
|
||||||
|
'default': {
|
||||||
|
'level': 'INFO',
|
||||||
|
'formatter': 'colored',
|
||||||
|
'class': 'logging.StreamHandler',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'loggers': {
|
||||||
|
'': {
|
||||||
|
'handlers': ['default'],
|
||||||
|
'level': 'INFO',
|
||||||
|
'propagate': True
|
||||||
|
},
|
||||||
|
'django.request': {
|
||||||
|
'handlers': ['default'],
|
||||||
|
'level': 'WARN',
|
||||||
|
'propagate': False
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
logging.getLogger('requests.packages.urllib3.connectionpool').setLevel(
|
||||||
|
logging.WARNING)
|
||||||
|
|
||||||
|
|
||||||
|
def setup_logging(level=logging.INFO):
|
||||||
|
kw = {
|
||||||
|
'format': '[%(asctime)s][%(module)s]: %(message)s',
|
||||||
|
'datefmt': '%m/%d/%Y %H:%M:%S',
|
||||||
|
'level': level,
|
||||||
|
'stream': sys.stdout
|
||||||
|
}
|
||||||
|
|
||||||
|
logging.basicConfig(**kw)
|
||||||
|
logging.getLogger('requests.packages.urllib3.connectionpool').setLevel(
|
||||||
|
logging.WARNING)
|
||||||
|
|
||||||
|
def get_process_cmd(pid, env=False):
|
||||||
|
env = 'e' if env else ''
|
||||||
|
try:
|
||||||
|
return subprocess.check_output('ps {} -o command {}'.format(env, pid),
|
||||||
|
shell=True).strip().splitlines()[1]
|
||||||
|
# except Exception, e:
|
||||||
|
# print(e)
|
||||||
|
except:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_match_pids(pattern):
|
||||||
|
pgrep_output = subprocess.check_output(
|
||||||
|
'pgrep -f "{}" || true'.format(pattern),
|
||||||
|
shell=True).strip()
|
||||||
|
return [int(pid) for pid in pgrep_output.splitlines()]
|
||||||
|
|
||||||
|
def ask_for_confirm(msg):
|
||||||
|
confirm = click.prompt(msg, default='Y')
|
||||||
|
return confirm.lower() in ('y', 'yes')
|
||||||
|
|
||||||
|
def confirm_command_to_run(cmd):
|
||||||
|
if ask_for_confirm('Run the command: {} ?'.format(green(cmd))):
|
||||||
|
call(cmd)
|
||||||
|
else:
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
def git_current_commit():
|
||||||
|
return get_command_output('git rev-parse --short HEAD').strip()
|
||||||
|
|
||||||
|
def get_command_output(cmd):
|
||||||
|
shell = not isinstance(cmd, list)
|
||||||
|
return subprocess.check_output(cmd, shell=shell)
|
||||||
|
|
||||||
|
def ask_yes_or_no(msg, prompt='', default=None):
|
||||||
|
print('\n' + msg + '\n')
|
||||||
|
while True:
|
||||||
|
answer = raw_input(prompt + ' [yes/no] ').lower()
|
||||||
|
if not answer:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if answer not in ('yes', 'no', 'y', 'n'):
|
||||||
|
continue
|
||||||
|
|
||||||
|
if answer in ('yes', 'y'):
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def git_branch_exists(branch):
|
||||||
|
return call('git rev-parse --short --verify {}'.format(branch)) == 0
|
||||||
|
|
||||||
|
def to_unicode(s):
|
||||||
|
if isinstance(s, str):
|
||||||
|
return s.decode('utf-8')
|
||||||
|
else:
|
||||||
|
return s
|
||||||
|
|
||||||
|
def to_utf8(s):
|
||||||
|
if isinstance(s, unicode):
|
||||||
|
return s.encode('utf-8')
|
||||||
|
else:
|
||||||
|
return s
|
||||||
|
|
||||||
|
def git_commit_time(refspec):
|
||||||
|
return int(get_command_output('git log -1 --format="%ct" {}'.format(
|
||||||
|
refspec)).strip())
|
||||||
|
|
||||||
|
def get_seafile_version():
|
||||||
|
return os.environ['SEAFILE_VERSION']
|
||||||
|
|
||||||
|
def get_install_dir():
|
||||||
|
return join('/opt/seafile/seafile-server-{}'.format(get_seafile_version()))
|
||||||
|
|
||||||
|
def get_script(script):
|
||||||
|
return join(get_install_dir(), script)
|
107
seafile-server-setup
Executable file
107
seafile-server-setup
Executable file
|
@ -0,0 +1,107 @@
|
||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
set -e
|
||||||
|
set -o pipefail
|
||||||
|
|
||||||
|
err_and_quit () {
|
||||||
|
printf "\n\n\033[33mError occured during setup. \nPlease fix possible issues and run the script again.\033[m\n\n"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
on_ctrl_c_pressed () {
|
||||||
|
printf "\n\n\033[33mYou have pressed Ctrl-C. Setup is interrupted.\033[m\n\n"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# clean newly created ccnet/seafile configs when exit on SIGINT
|
||||||
|
trap on_ctrl_c_pressed 2
|
||||||
|
|
||||||
|
read_yes_no () {
|
||||||
|
printf "[yes|no] "
|
||||||
|
read yesno
|
||||||
|
while [[ "$yesno" != "yes" && "$yesno" != "no" ]]
|
||||||
|
do
|
||||||
|
printf "please answer [yes|no] "
|
||||||
|
read yesno
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ "$yesno" == "no" ]]; then
|
||||||
|
return 1
|
||||||
|
else
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
ask_question () {
|
||||||
|
local question default key
|
||||||
|
question=$1
|
||||||
|
default=$2
|
||||||
|
key=$3
|
||||||
|
printf "$question"
|
||||||
|
printf "\n"
|
||||||
|
if [[ "$default" != "" && "$default" != "nodefault" ]]; then
|
||||||
|
printf "[default: $default] "
|
||||||
|
elif [[ "$key" != "" ]]; then
|
||||||
|
printf "[$key]: "
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
get_server_name () {
|
||||||
|
local question="Host name for your seafile server?" default="seafile.example.com"
|
||||||
|
ask_question "$question" "seafile.example.com"
|
||||||
|
read server_name
|
||||||
|
if [[ "$server_name" == "" ]]; then
|
||||||
|
server_name=$default
|
||||||
|
elif [[ ! $server_name =~ ^[a-zA-Z0-9_-.]+$ ]]; then
|
||||||
|
printf "\n\033[33m${server_name}\033[m is not a valid name.\n"
|
||||||
|
get_server_name
|
||||||
|
fi
|
||||||
|
echo
|
||||||
|
}
|
||||||
|
|
||||||
|
# echo "Please specify the email address and password for the seahub administrator."
|
||||||
|
# echo "You can use them to login as admin on your seahub website."
|
||||||
|
# echo
|
||||||
|
|
||||||
|
get_admin_email () {
|
||||||
|
local question="Admin email address for your seafile server?" default="me@example.com"
|
||||||
|
ask_question "$question" "$default"
|
||||||
|
read admin_email
|
||||||
|
if [[ "$admin_email" == "" ]]; then
|
||||||
|
admin_email=$default
|
||||||
|
elif [[ ! $admin_email =~ ^.+@.*\..+$ ]]; then
|
||||||
|
echo "$admin_email is not a valid email address"
|
||||||
|
get_admin_email
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
get_admin_passwd () {
|
||||||
|
local question="Admin password for your seafile server?"
|
||||||
|
ask_question "$question" "nodefault" "seahub admin password"
|
||||||
|
read -s admin_passwd
|
||||||
|
echo
|
||||||
|
question="Please enter the password again:"
|
||||||
|
ask_question "$question" "nodefault" "seahub admin password again"
|
||||||
|
read -s admin_passwd_again
|
||||||
|
echo
|
||||||
|
if [[ "$admin_passwd" != "$admin_passwd_again" ]]; then
|
||||||
|
printf "\033[33mThe passwords didn't match.\033[m"
|
||||||
|
get_admin_passwd
|
||||||
|
elif [[ "$admin_passwd" == "" ]]; then
|
||||||
|
echo "Password cannot be empty."
|
||||||
|
get_admin_passwd
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
get_server_name
|
||||||
|
get_admin_email
|
||||||
|
get_admin_passwd
|
||||||
|
|
||||||
|
cat >containers/bootstrap.conf<<EOF
|
||||||
|
[server]
|
||||||
|
server.hostname = $server_name
|
||||||
|
admin.email = $admin_email
|
||||||
|
admin.password = $admin_passwd
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# ./launcher bootstrap && ./launcher start
|
0
shared/logs/.gitkeep
Normal file
0
shared/logs/.gitkeep
Normal file
Loading…
Reference in a new issue