summaryrefslogtreecommitdiffstats
path: root/src/cephadm/box
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-21 11:54:28 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-21 11:54:28 +0000
commite6918187568dbd01842d8d1d2c808ce16a894239 (patch)
tree64f88b554b444a49f656b6c656111a145cbbaa28 /src/cephadm/box
parentInitial commit. (diff)
downloadceph-e6918187568dbd01842d8d1d2c808ce16a894239.tar.xz
ceph-e6918187568dbd01842d8d1d2c808ce16a894239.zip
Adding upstream version 18.2.2.upstream/18.2.2
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'src/cephadm/box')
-rw-r--r--src/cephadm/box/DockerfileDocker33
-rw-r--r--src/cephadm/box/DockerfilePodman64
-rw-r--r--src/cephadm/box/__init__.py0
-rwxr-xr-xsrc/cephadm/box/box.py414
-rw-r--r--src/cephadm/box/daemon.json3
-rw-r--r--src/cephadm/box/docker-compose-docker.yml39
-rw-r--r--src/cephadm/box/docker-compose.cgroup1.yml10
-rw-r--r--src/cephadm/box/docker/ceph/.bashrc0
-rw-r--r--src/cephadm/box/docker/ceph/Dockerfile3
-rw-r--r--src/cephadm/box/docker/ceph/locale.conf2
-rw-r--r--src/cephadm/box/host.py120
-rw-r--r--src/cephadm/box/osd.py157
-rw-r--r--src/cephadm/box/util.py421
13 files changed, 1266 insertions, 0 deletions
diff --git a/src/cephadm/box/DockerfileDocker b/src/cephadm/box/DockerfileDocker
new file mode 100644
index 000000000..f64b48e4c
--- /dev/null
+++ b/src/cephadm/box/DockerfileDocker
@@ -0,0 +1,33 @@
+# https://developers.redhat.com/blog/2014/05/05/running-systemd-within-docker-container/
+FROM centos:8 as centos-systemd
+ENV container docker
+ENV CEPHADM_PATH=/usr/local/sbin/cephadm
+
+# Centos met EOL and the content of the CentOS 8 repos has been moved to vault.centos.org
+RUN sed -i 's/mirrorlist/#mirrorlist/g' /etc/yum.repos.d/CentOS-Linux-*
+RUN sed -i 's|#baseurl=http://mirror.centos.org|baseurl=https://vault.centos.org|g' /etc/yum.repos.d/CentOS-Linux-*
+
+RUN dnf -y install chrony firewalld lvm2 \
+ openssh-server openssh-clients python3 \
+ yum-utils sudo which && dnf clean all
+
+RUN systemctl enable chronyd firewalld sshd
+
+
+FROM centos-systemd as centos-systemd-docker
+# To cache cephadm images
+RUN yum-config-manager --add-repo https://download.docker.com/linux/centos/docker-ce.repo
+RUN dnf -y install docker-ce && \
+ dnf clean all && systemctl enable docker
+
+# ssh utilities
+RUN dnf install epel-release -y && dnf makecache && dnf install sshpass -y
+RUN touch /.box_container # empty file to check if inside a container
+
+EXPOSE 8443
+EXPOSE 22
+
+FROM centos-systemd-docker
+WORKDIR /root
+
+CMD [ "/usr/sbin/init" ]
diff --git a/src/cephadm/box/DockerfilePodman b/src/cephadm/box/DockerfilePodman
new file mode 100644
index 000000000..115c3c730
--- /dev/null
+++ b/src/cephadm/box/DockerfilePodman
@@ -0,0 +1,64 @@
+# stable/Dockerfile
+#
+# Build a Podman container image from the latest
+# stable version of Podman on the Fedoras Updates System.
+# https://bodhi.fedoraproject.org/updates/?search=podman
+# This image can be used to create a secured container
+# that runs safely with privileges within the container.
+#
+FROM fedora:34
+
+ENV CEPHADM_PATH=/usr/local/sbin/cephadm
+RUN ln -s /ceph/src/cephadm/cephadm.py $CEPHADM_PATH # NOTE: assume path of ceph volume
+
+# Don't include container-selinux and remove
+# directories used by yum that are just taking
+# up space.
+RUN dnf -y update; rpm --restore shadow-utils 2>/dev/null; \
+yum -y install podman fuse-overlayfs --exclude container-selinux; \
+rm -rf /var/cache /var/log/dnf* /var/log/yum.*
+
+RUN dnf install which firewalld chrony procps systemd openssh openssh-server openssh-clients sshpass lvm2 -y
+
+ADD https://raw.githubusercontent.com/containers/podman/main/contrib/podmanimage/stable/containers.conf /etc/containers/containers.conf
+ADD https://raw.githubusercontent.com/containers/podman/main/contrib/podmanimage/stable/podman-containers.conf /root/.config/containers/containers.conf
+
+RUN mkdir -p /root/.local/share/containers; # chown podman:podman -R /home/podman
+
+# Note VOLUME options must always happen after the chown call above
+# RUN commands can not modify existing volumes
+VOLUME /var/lib/containers
+VOLUME /root/.local/share/containers
+
+# chmod containers.conf and adjust storage.conf to enable Fuse storage.
+RUN chmod 644 /etc/containers/containers.conf; sed -i -e 's|^#mount_program|mount_program|g' -e '/additionalimage.*/a "/var/lib/shared",' -e 's|^mountopt[[:space:]]*=.*$|mountopt = "nodev,fsync=0"|g' /etc/containers/storage.conf
+RUN mkdir -p /var/lib/shared/overlay-images /var/lib/shared/overlay-layers /var/lib/shared/vfs-images /var/lib/shared/vfs-layers; touch /var/lib/shared/overlay-images/images.lock; touch /var/lib/shared/overlay-layers/layers.lock; touch /var/lib/shared/vfs-images/images.lock; touch /var/lib/shared/vfs-layers/layers.lock
+
+RUN echo 'root:root' | chpasswd
+
+RUN dnf install -y adjtimex # adjtimex syscall doesn't exist in fedora 35+ therefore we have to install it manually
+ # so chronyd works
+RUN dnf install -y strace sysstat # debugging tools
+RUN dnf -y install hostname iproute udev
+ENV _CONTAINERS_USERNS_CONFIGURED=""
+
+RUN useradd podman; \
+echo podman:0:5000 > /etc/subuid; \
+echo podman:0:5000 > /etc/subgid; \
+echo root:0:65535 > /etc/subuid; \
+echo root:0:65535 > /etc/subgid;
+
+VOLUME /home/podman/.local/share/containers
+
+ADD https://raw.githubusercontent.com/containers/libpod/master/contrib/podmanimage/stable/containers.conf /etc/containers/containers.conf
+ADD https://raw.githubusercontent.com/containers/libpod/master/contrib/podmanimage/stable/podman-containers.conf /home/podman/.config/containers/containers.conf
+
+RUN chown podman:podman -R /home/podman
+
+RUN echo 'podman:podman' | chpasswd
+RUN touch /.box_container # empty file to check if inside a container
+
+EXPOSE 8443
+EXPOSE 22
+
+ENTRYPOINT ["/usr/sbin/init"]
diff --git a/src/cephadm/box/__init__.py b/src/cephadm/box/__init__.py
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/src/cephadm/box/__init__.py
diff --git a/src/cephadm/box/box.py b/src/cephadm/box/box.py
new file mode 100755
index 000000000..db2f24233
--- /dev/null
+++ b/src/cephadm/box/box.py
@@ -0,0 +1,414 @@
+#!/bin/python3
+import argparse
+import os
+import stat
+import json
+import sys
+import host
+import osd
+from multiprocessing import Process, Pool
+from util import (
+ BoxType,
+ Config,
+ Target,
+ ensure_inside_container,
+ ensure_outside_container,
+ get_boxes_container_info,
+ run_cephadm_shell_command,
+ run_dc_shell_command,
+ run_dc_shell_commands,
+ get_container_engine,
+ run_shell_command,
+ run_shell_commands,
+ ContainerEngine,
+ DockerEngine,
+ PodmanEngine,
+ colored,
+ engine,
+ engine_compose,
+ Colors,
+ get_seed_name
+)
+
+CEPH_IMAGE = 'quay.ceph.io/ceph-ci/ceph:main'
+BOX_IMAGE = 'cephadm-box:latest'
+
+# NOTE: this image tar is a trickeroo so cephadm won't pull the image everytime
+# we deploy a cluster. Keep in mind that you'll be responsible for pulling the
+# image yourself with `./box.py -v cluster setup`
+CEPH_IMAGE_TAR = 'docker/ceph/image/quay.ceph.image.tar'
+CEPH_ROOT = '../../../'
+DASHBOARD_PATH = '../../../src/pybind/mgr/dashboard/frontend/'
+
+root_error_msg = """
+WARNING WARNING WARNING WARNING WARNING WARNING WARNING WARNING
+sudo with this script can kill your computer, try again without sudo
+if you value your time.
+"""
+
+def remove_ceph_image_tar():
+ if os.path.exists(CEPH_IMAGE_TAR):
+ os.remove(CEPH_IMAGE_TAR)
+
+
+def cleanup_box() -> None:
+ osd.cleanup_osds()
+ remove_ceph_image_tar()
+
+
+def image_exists(image_name: str):
+ # extract_tag
+ assert image_name.find(':')
+ image_name, tag = image_name.split(':')
+ engine = get_container_engine()
+ images = engine.run('image ls').split('\n')
+ IMAGE_NAME = 0
+ TAG = 1
+ for image in images:
+ image = image.split()
+ print(image)
+ print(image_name, tag)
+ if image[IMAGE_NAME] == image_name and image[TAG] == tag:
+ return True
+ return False
+
+
+def get_ceph_image():
+ print('Getting ceph image')
+ engine = get_container_engine()
+ engine.run(f'pull {CEPH_IMAGE}')
+ # update
+ engine.run(f'build -t {CEPH_IMAGE} docker/ceph')
+ if not os.path.exists('docker/ceph/image'):
+ os.mkdir('docker/ceph/image')
+
+ remove_ceph_image_tar()
+
+ engine.run(f'save {CEPH_IMAGE} -o {CEPH_IMAGE_TAR}')
+ run_shell_command(f'chmod 777 {CEPH_IMAGE_TAR}')
+ print('Ceph image added')
+
+
+def get_box_image():
+ print('Getting box image')
+ engine = get_container_engine()
+ engine.run(f'build -t cephadm-box -f {engine.dockerfile} .')
+ print('Box image added')
+
+def check_dashboard():
+ if not os.path.exists(os.path.join(CEPH_ROOT, 'dist')):
+ print(colored('Missing build in dashboard', Colors.WARNING))
+
+def check_cgroups():
+ if not os.path.exists('/sys/fs/cgroup/cgroup.controllers'):
+ print(colored('cgroups v1 is not supported', Colors.FAIL))
+ print('Enable cgroups v2 please')
+ sys.exit(666)
+
+def check_selinux():
+ selinux = run_shell_command('getenforce')
+ if 'Disabled' not in selinux:
+ print(colored('selinux should be disabled, please disable it if you '
+ 'don\'t want unexpected behaviour.', Colors.WARNING))
+def dashboard_setup():
+ command = f'cd {DASHBOARD_PATH} && npm install'
+ run_shell_command(command)
+ command = f'cd {DASHBOARD_PATH} && npm run build'
+ run_shell_command(command)
+
+class Cluster(Target):
+ _help = 'Manage docker cephadm boxes'
+ actions = ['bootstrap', 'start', 'down', 'list', 'bash', 'setup', 'cleanup']
+
+ def set_args(self):
+ self.parser.add_argument(
+ 'action', choices=Cluster.actions, help='Action to perform on the box'
+ )
+ self.parser.add_argument('--osds', type=int, default=3, help='Number of osds')
+
+ self.parser.add_argument('--hosts', type=int, default=1, help='Number of hosts')
+ self.parser.add_argument('--skip-deploy-osds', action='store_true', help='skip deploy osd')
+ self.parser.add_argument('--skip-create-loop', action='store_true', help='skip create loopback device')
+ self.parser.add_argument('--skip-monitoring-stack', action='store_true', help='skip monitoring stack')
+ self.parser.add_argument('--skip-dashboard', action='store_true', help='skip dashboard')
+ self.parser.add_argument('--expanded', action='store_true', help='deploy 3 hosts and 3 osds')
+ self.parser.add_argument('--jobs', type=int, help='Number of jobs scheduled in parallel')
+
+ @ensure_outside_container
+ def setup(self):
+ check_cgroups()
+ check_selinux()
+
+ targets = [
+ get_ceph_image,
+ get_box_image,
+ dashboard_setup
+ ]
+ results = []
+ jobs = Config.get('jobs')
+ if jobs:
+ jobs = int(jobs)
+ else:
+ jobs = None
+ pool = Pool(jobs)
+ for target in targets:
+ results.append(pool.apply_async(target))
+
+ for result in results:
+ result.wait()
+
+
+ @ensure_outside_container
+ def cleanup(self):
+ cleanup_box()
+
+ @ensure_inside_container
+ def bootstrap(self):
+ print('Running bootstrap on seed')
+ cephadm_path = str(os.environ.get('CEPHADM_PATH'))
+
+ engine = get_container_engine()
+ if isinstance(engine, DockerEngine):
+ engine.restart()
+ st = os.stat(cephadm_path)
+ os.chmod(cephadm_path, st.st_mode | stat.S_IEXEC)
+
+ engine.run('load < /cephadm/box/docker/ceph/image/quay.ceph.image.tar')
+ # cephadm guid error because it sometimes tries to use quay.ceph.io/ceph-ci/ceph:<none>
+ # instead of main branch's tag
+ run_shell_command('export CEPH_SOURCE_FOLDER=/ceph')
+ run_shell_command('export CEPHADM_IMAGE=quay.ceph.io/ceph-ci/ceph:main')
+ run_shell_command(
+ 'echo "export CEPHADM_IMAGE=quay.ceph.io/ceph-ci/ceph:main" >> ~/.bashrc'
+ )
+
+ extra_args = []
+
+ extra_args.append('--skip-pull')
+
+ # cephadm prints in warning, let's redirect it to the output so shell_command doesn't
+ # complain
+ extra_args.append('2>&0')
+
+ extra_args = ' '.join(extra_args)
+ skip_monitoring_stack = (
+ '--skip-monitoring-stack' if Config.get('skip-monitoring-stack') else ''
+ )
+ skip_dashboard = '--skip-dashboard' if Config.get('skip-dashboard') else ''
+
+ fsid = Config.get('fsid')
+ config_folder = str(Config.get('config_folder'))
+ config = str(Config.get('config'))
+ keyring = str(Config.get('keyring'))
+ if not os.path.exists(config_folder):
+ os.mkdir(config_folder)
+
+ cephadm_bootstrap_command = (
+ '$CEPHADM_PATH --verbose bootstrap '
+ '--mon-ip "$(hostname -i)" '
+ '--allow-fqdn-hostname '
+ '--initial-dashboard-password admin '
+ '--dashboard-password-noupdate '
+ '--shared_ceph_folder /ceph '
+ '--allow-overwrite '
+ f'--output-config {config} '
+ f'--output-keyring {keyring} '
+ f'--output-config {config} '
+ f'--fsid "{fsid}" '
+ '--log-to-file '
+ f'{skip_dashboard} '
+ f'{skip_monitoring_stack} '
+ f'{extra_args} '
+ )
+
+ print('Running cephadm bootstrap...')
+ run_shell_command(cephadm_bootstrap_command, expect_exit_code=120)
+ print('Cephadm bootstrap complete')
+
+ run_shell_command('sudo vgchange --refresh')
+ run_shell_command('cephadm ls')
+ run_shell_command('ln -s /ceph/src/cephadm/box/box.py /usr/bin/box')
+
+ run_cephadm_shell_command('ceph -s')
+
+ print('Bootstrap completed!')
+
+ @ensure_outside_container
+ def start(self):
+ check_cgroups()
+ check_selinux()
+ osds = int(Config.get('osds'))
+ hosts = int(Config.get('hosts'))
+ engine = get_container_engine()
+
+ # ensure boxes don't exist
+ self.down()
+
+ # podman is ran without sudo
+ if isinstance(engine, PodmanEngine):
+ I_am = run_shell_command('whoami')
+ if 'root' in I_am:
+ print(root_error_msg)
+ sys.exit(1)
+
+ print('Checking docker images')
+ if not image_exists(CEPH_IMAGE):
+ get_ceph_image()
+ if not image_exists(BOX_IMAGE):
+ get_box_image()
+
+ used_loop = ""
+ if not Config.get('skip_create_loop'):
+ print('Creating OSD devices...')
+ used_loop = osd.create_loopback_devices(osds)
+ print(f'Added {osds} logical volumes in a loopback device')
+
+ print('Starting containers')
+
+ engine.up(hosts)
+
+ containers = engine.get_containers()
+ seed = engine.get_seed()
+ # Umounting somehow brings back the contents of the host /sys/dev/block.
+ # On startup /sys/dev/block is empty. After umount, we can see symlinks again
+ # so that lsblk is able to run as expected
+ run_dc_shell_command('umount /sys/dev/block', seed)
+
+ run_shell_command('sudo sysctl net.ipv4.conf.all.forwarding=1')
+ run_shell_command('sudo iptables -P FORWARD ACCEPT')
+
+ # don't update clock with chronyd / setup chronyd on all boxes
+ chronyd_setup = """
+ sed 's/$OPTIONS/-x/g' /usr/lib/systemd/system/chronyd.service -i
+ systemctl daemon-reload
+ systemctl start chronyd
+ systemctl status --no-pager chronyd
+ """
+ for container in containers:
+ print(colored('Got container:', Colors.OKCYAN), str(container))
+ for container in containers:
+ run_dc_shell_commands(chronyd_setup, container)
+
+ print('Seting up host ssh servers')
+ for container in containers:
+ print(colored('Setting up ssh server for:', Colors.OKCYAN), str(container))
+ host._setup_ssh(container)
+
+ verbose = '-v' if Config.get('verbose') else ''
+ skip_deploy = '--skip-deploy-osds' if Config.get('skip-deploy-osds') else ''
+ skip_monitoring_stack = (
+ '--skip-monitoring-stack' if Config.get('skip-monitoring-stack') else ''
+ )
+ skip_dashboard = '--skip-dashboard' if Config.get('skip-dashboard') else ''
+ box_bootstrap_command = (
+ f'/cephadm/box/box.py {verbose} --engine {engine.command} cluster bootstrap '
+ f'--osds {osds} '
+ f'--hosts {hosts} '
+ f'{skip_deploy} '
+ f'{skip_dashboard} '
+ f'{skip_monitoring_stack} '
+ )
+ print(box_bootstrap_command)
+ run_dc_shell_command(box_bootstrap_command, seed)
+
+ expanded = Config.get('expanded')
+ if expanded:
+ info = get_boxes_container_info()
+ ips = info['ips']
+ hostnames = info['hostnames']
+ print(ips)
+ if hosts > 0:
+ host._copy_cluster_ssh_key(ips)
+ host._add_hosts(ips, hostnames)
+ if not Config.get('skip-deploy-osds'):
+ print('Deploying osds... This could take up to minutes')
+ osd.deploy_osds(osds)
+ print('Osds deployed')
+
+
+ dashboard_ip = 'localhost'
+ info = get_boxes_container_info(with_seed=True)
+ if isinstance(engine, DockerEngine):
+ for i in range(info['size']):
+ if get_seed_name() in info['container_names'][i]:
+ dashboard_ip = info["ips"][i]
+ print(colored(f'dashboard available at https://{dashboard_ip}:8443', Colors.OKGREEN))
+
+ print('Bootstrap finished successfully')
+
+ @ensure_outside_container
+ def down(self):
+ engine = get_container_engine()
+ if isinstance(engine, PodmanEngine):
+ containers = json.loads(engine.run('container ls --format json'))
+ for container in containers:
+ for name in container['Names']:
+ if name.startswith('box_hosts_'):
+ engine.run(f'container kill {name}')
+ engine.run(f'container rm {name}')
+ pods = json.loads(engine.run('pod ls --format json'))
+ for pod in pods:
+ if 'Name' in pod and pod['Name'].startswith('box_pod_host'):
+ name = pod['Name']
+ engine.run(f'pod kill {name}')
+ engine.run(f'pod rm {name}')
+ else:
+ run_shell_command(f'{engine_compose()} -f {Config.get("docker_yaml")} down')
+ print('Successfully killed all boxes')
+
+ @ensure_outside_container
+ def list(self):
+ info = get_boxes_container_info(with_seed=True)
+ for i in range(info['size']):
+ ip = info['ips'][i]
+ name = info['container_names'][i]
+ hostname = info['hostnames'][i]
+ print(f'{name} \t{ip} \t{hostname}')
+
+ @ensure_outside_container
+ def bash(self):
+ # we need verbose to see the prompt after running shell command
+ Config.set('verbose', True)
+ print('Seed bash')
+ engine = get_container_engine()
+ engine.run(f'exec -it {engine.seed_name} bash')
+
+
+targets = {
+ 'cluster': Cluster,
+ 'osd': osd.Osd,
+ 'host': host.Host,
+}
+
+
+def main():
+ parser = argparse.ArgumentParser()
+ parser.add_argument(
+ '-v', action='store_true', dest='verbose', help='be more verbose'
+ )
+ parser.add_argument(
+ '--engine', type=str, default='podman',
+ dest='engine', help='choose engine between "docker" and "podman"'
+ )
+
+ subparsers = parser.add_subparsers()
+ target_instances = {}
+ for name, target in targets.items():
+ target_instances[name] = target(None, subparsers)
+
+ for count, arg in enumerate(sys.argv, 1):
+ if arg in targets:
+ instance = target_instances[arg]
+ if hasattr(instance, 'main'):
+ instance.argv = sys.argv[count:]
+ instance.set_args()
+ args = parser.parse_args()
+ Config.add_args(vars(args))
+ instance.main()
+ sys.exit(0)
+
+ parser.print_help()
+
+
+if __name__ == '__main__':
+ main()
diff --git a/src/cephadm/box/daemon.json b/src/cephadm/box/daemon.json
new file mode 100644
index 000000000..5cfcaa87f
--- /dev/null
+++ b/src/cephadm/box/daemon.json
@@ -0,0 +1,3 @@
+{
+ "storage-driver": "fuse-overlayfs"
+}
diff --git a/src/cephadm/box/docker-compose-docker.yml b/src/cephadm/box/docker-compose-docker.yml
new file mode 100644
index 000000000..fdecf6677
--- /dev/null
+++ b/src/cephadm/box/docker-compose-docker.yml
@@ -0,0 +1,39 @@
+version: "2.4"
+services:
+ cephadm-host-base:
+ build:
+ context: .
+ environment:
+ - CEPH_BRANCH=master
+ image: cephadm-box
+ privileged: true
+ stop_signal: RTMIN+3
+ volumes:
+ - ../../../:/ceph
+ - ..:/cephadm
+ - ./daemon.json:/etc/docker/daemon.json
+ # dangerous, maybe just map the loopback
+ # https://stackoverflow.com/questions/36880565/why-dont-my-udev-rules-work-inside-of-a-running-docker-container
+ - /dev:/dev
+ networks:
+ - public
+ mem_limit: "20g"
+ scale: -1
+ seed:
+ extends:
+ service: cephadm-host-base
+ ports:
+ - "3000:3000"
+ - "8443:8443"
+ - "9095:9095"
+ scale: 1
+ hosts:
+ extends:
+ service: cephadm-host-base
+ scale: 3
+
+
+volumes:
+ var-lib-docker:
+networks:
+ public:
diff --git a/src/cephadm/box/docker-compose.cgroup1.yml b/src/cephadm/box/docker-compose.cgroup1.yml
new file mode 100644
index 000000000..ea23dec1e
--- /dev/null
+++ b/src/cephadm/box/docker-compose.cgroup1.yml
@@ -0,0 +1,10 @@
+version: "2.4"
+
+# If cgroups v2 is disabled then add cgroup fs
+services:
+ seed:
+ volumes:
+ - "/sys/fs/cgroup:/sys/fs/cgroup:ro"
+ hosts:
+ volumes:
+ - "/sys/fs/cgroup:/sys/fs/cgroup:ro"
diff --git a/src/cephadm/box/docker/ceph/.bashrc b/src/cephadm/box/docker/ceph/.bashrc
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/src/cephadm/box/docker/ceph/.bashrc
diff --git a/src/cephadm/box/docker/ceph/Dockerfile b/src/cephadm/box/docker/ceph/Dockerfile
new file mode 100644
index 000000000..b950750e9
--- /dev/null
+++ b/src/cephadm/box/docker/ceph/Dockerfile
@@ -0,0 +1,3 @@
+FROM quay.ceph.io/ceph-ci/ceph:main
+RUN pip3 install packaging
+EXPOSE 8443
diff --git a/src/cephadm/box/docker/ceph/locale.conf b/src/cephadm/box/docker/ceph/locale.conf
new file mode 100644
index 000000000..00d76c8cd
--- /dev/null
+++ b/src/cephadm/box/docker/ceph/locale.conf
@@ -0,0 +1,2 @@
+LANG="en_US.UTF-8"
+LC_ALL="en_US.UTF-8"
diff --git a/src/cephadm/box/host.py b/src/cephadm/box/host.py
new file mode 100644
index 000000000..aae16d07f
--- /dev/null
+++ b/src/cephadm/box/host.py
@@ -0,0 +1,120 @@
+import os
+from typing import List, Union
+
+from util import (
+ Config,
+ HostContainer,
+ Target,
+ get_boxes_container_info,
+ get_container_engine,
+ inside_container,
+ run_cephadm_shell_command,
+ run_dc_shell_command,
+ run_shell_command,
+ engine,
+ BoxType
+)
+
+
+def _setup_ssh(container: HostContainer):
+ if inside_container():
+ if not os.path.exists('/root/.ssh/known_hosts'):
+ run_shell_command('echo "y" | ssh-keygen -b 2048 -t rsa -f /root/.ssh/id_rsa -q -N ""',
+ expect_error=True)
+
+ run_shell_command('echo "root:root" | chpasswd')
+ with open('/etc/ssh/sshd_config', 'a+') as f:
+ f.write('PermitRootLogin yes\n')
+ f.write('PasswordAuthentication yes\n')
+ f.flush()
+ run_shell_command('systemctl restart sshd')
+ else:
+ print('Redirecting to _setup_ssh to container')
+ verbose = '-v' if Config.get('verbose') else ''
+ run_dc_shell_command(
+ f'/cephadm/box/box.py {verbose} --engine {engine()} host setup_ssh {container.name}',
+ container
+ )
+
+
+def _add_hosts(ips: Union[List[str], str], hostnames: Union[List[str], str]):
+ if inside_container():
+ assert len(ips) == len(hostnames)
+ for i in range(len(ips)):
+ run_cephadm_shell_command(f'ceph orch host add {hostnames[i]} {ips[i]}')
+ else:
+ print('Redirecting to _add_hosts to container')
+ verbose = '-v' if Config.get('verbose') else ''
+ print(ips)
+ ips = ' '.join(ips)
+ ips = f'{ips}'
+ hostnames = ' '.join(hostnames)
+ hostnames = f'{hostnames}'
+ seed = get_container_engine().get_seed()
+ run_dc_shell_command(
+ f'/cephadm/box/box.py {verbose} --engine {engine()} host add_hosts {seed.name} --ips {ips} --hostnames {hostnames}',
+ seed
+ )
+
+
+def _copy_cluster_ssh_key(ips: Union[List[str], str]):
+ if inside_container():
+ local_ip = run_shell_command('hostname -i')
+ for ip in ips:
+ if ip != local_ip:
+ run_shell_command(
+ (
+ 'sshpass -p "root" ssh-copy-id -f '
+ f'-o StrictHostKeyChecking=no -i /etc/ceph/ceph.pub "root@{ip}"'
+ )
+ )
+
+ else:
+ print('Redirecting to _copy_cluster_ssh to container')
+ verbose = '-v' if Config.get('verbose') else ''
+ print(ips)
+ ips = ' '.join(ips)
+ ips = f'{ips}'
+ # assume we only have one seed
+ seed = get_container_engine().get_seed()
+ run_dc_shell_command(
+ f'/cephadm/box/box.py {verbose} --engine {engine()} host copy_cluster_ssh_key {seed.name} --ips {ips}',
+ seed
+ )
+
+
+class Host(Target):
+ _help = 'Run seed/host related commands'
+ actions = ['setup_ssh', 'copy_cluster_ssh_key', 'add_hosts']
+
+ def set_args(self):
+ self.parser.add_argument('action', choices=Host.actions)
+ self.parser.add_argument(
+ 'container_name',
+ type=str,
+ help='box_{type}_{index}. In docker, type can be seed or hosts. In podman only hosts.'
+ )
+ self.parser.add_argument('--ips', nargs='*', help='List of host ips')
+ self.parser.add_argument(
+ '--hostnames', nargs='*', help='List of hostnames ips(relative to ip list)'
+ )
+
+ def setup_ssh(self):
+ container_name = Config.get('container_name')
+ engine = get_container_engine()
+ _setup_ssh(engine.get_container(container_name))
+
+ def add_hosts(self):
+ ips = Config.get('ips')
+ if not ips:
+ ips = get_boxes_container_info()['ips']
+ hostnames = Config.get('hostnames')
+ if not hostnames:
+ hostnames = get_boxes_container_info()['hostnames']
+ _add_hosts(ips, hostnames)
+
+ def copy_cluster_ssh_key(self):
+ ips = Config.get('ips')
+ if not ips:
+ ips = get_boxes_container_info()['ips']
+ _copy_cluster_ssh_key(ips)
diff --git a/src/cephadm/box/osd.py b/src/cephadm/box/osd.py
new file mode 100644
index 000000000..827a4de36
--- /dev/null
+++ b/src/cephadm/box/osd.py
@@ -0,0 +1,157 @@
+import json
+import os
+import time
+import re
+from typing import Dict
+
+from util import (
+ BoxType,
+ Config,
+ Target,
+ ensure_inside_container,
+ ensure_outside_container,
+ get_orch_hosts,
+ run_cephadm_shell_command,
+ run_dc_shell_command,
+ get_container_engine,
+ run_shell_command,
+)
+
+DEVICES_FILE="./devices.json"
+
+def remove_loop_img() -> None:
+ loop_image = Config.get('loop_img')
+ if os.path.exists(loop_image):
+ os.remove(loop_image)
+
+def create_loopback_devices(osds: int) -> Dict[int, Dict[str, str]]:
+ assert osds
+ cleanup_osds()
+ osd_devs = dict()
+
+ for i in range(osds):
+ img_name = f'osd{i}'
+ loop_dev = create_loopback_device(img_name)
+ osd_devs[i] = dict(img_name=img_name, device=loop_dev)
+ with open(DEVICES_FILE, 'w') as dev_file:
+ dev_file.write(json.dumps(osd_devs))
+ return osd_devs
+
+def create_loopback_device(img_name, size_gb=5):
+ loop_img_dir = Config.get('loop_img_dir')
+ run_shell_command(f'mkdir -p {loop_img_dir}')
+ loop_img = os.path.join(loop_img_dir, img_name)
+ run_shell_command(f'rm -f {loop_img}')
+ run_shell_command(f'dd if=/dev/zero of={loop_img} bs=1 count=0 seek={size_gb}G')
+ loop_dev = run_shell_command(f'sudo losetup -f')
+ if not os.path.exists(loop_dev):
+ dev_minor = re.match(r'\/dev\/[^\d]+(\d+)', loop_dev).groups()[0]
+ run_shell_command(f'sudo mknod -m777 {loop_dev} b 7 {dev_minor}')
+ run_shell_command(f'sudo chown {os.getuid()}:{os.getgid()} {loop_dev}')
+ if os.path.ismount(loop_dev):
+ os.umount(loop_dev)
+ run_shell_command(f'sudo losetup {loop_dev} {loop_img}')
+ run_shell_command(f'sudo chown {os.getuid()}:{os.getgid()} {loop_dev}')
+ return loop_dev
+
+
+def get_lvm_osd_data(data: str) -> Dict[str, str]:
+ osd_lvm_info = run_cephadm_shell_command(f'ceph-volume lvm list {data}')
+ osd_data = {}
+ for line in osd_lvm_info.split('\n'):
+ line = line.strip()
+ if not line:
+ continue
+ line = line.split()
+ if line[0].startswith('===') or line[0].startswith('[block]'):
+ continue
+ # "block device" key -> "block_device"
+ key = '_'.join(line[:-1])
+ osd_data[key] = line[-1]
+ return osd_data
+
+def load_osd_devices():
+ if not os.path.exists(DEVICES_FILE):
+ return dict()
+ with open(DEVICES_FILE) as dev_file:
+ devs = json.loads(dev_file.read())
+ return devs
+
+
+@ensure_inside_container
+def deploy_osd(data: str, hostname: str) -> bool:
+ out = run_cephadm_shell_command(f'ceph orch daemon add osd {hostname}:{data} raw')
+ return 'Created osd(s)' in out
+
+
+def cleanup_osds() -> None:
+ loop_img_dir = Config.get('loop_img_dir')
+ osd_devs = load_osd_devices()
+ for osd in osd_devs.values():
+ device = osd['device']
+ if 'loop' in device:
+ loop_img = os.path.join(loop_img_dir, osd['img_name'])
+ run_shell_command(f'sudo losetup -d {device}', expect_error=True)
+ if os.path.exists(loop_img):
+ os.remove(loop_img)
+ run_shell_command(f'rm -rf {loop_img_dir}')
+
+
+def deploy_osds(count: int):
+ osd_devs = load_osd_devices()
+ hosts = get_orch_hosts()
+ host_index = 0
+ seed = get_container_engine().get_seed()
+ v = '-v' if Config.get('verbose') else ''
+ for osd in osd_devs.values():
+ deployed = False
+ while not deployed:
+ print(hosts)
+ hostname = hosts[host_index]['hostname']
+ deployed = run_dc_shell_command(
+ f'/cephadm/box/box.py {v} osd deploy --data {osd["device"]} --hostname {hostname}',
+ seed
+ )
+ deployed = 'created osd' in deployed.lower() or 'already created?' in deployed.lower()
+ print('Waiting 5 seconds to re-run deploy osd...')
+ time.sleep(5)
+ host_index = (host_index + 1) % len(hosts)
+
+
+class Osd(Target):
+ _help = """
+ Deploy osds and create needed block devices with loopback devices:
+ Actions:
+ - deploy: Deploy an osd given a block device
+ - create_loop: Create needed loopback devices and block devices in logical volumes
+ for a number of osds.
+ - destroy: Remove all osds and the underlying loopback devices.
+ """
+ actions = ['deploy', 'create_loop', 'destroy']
+
+ def set_args(self):
+ self.parser.add_argument('action', choices=Osd.actions)
+ self.parser.add_argument('--data', type=str, help='path to a block device')
+ self.parser.add_argument('--hostname', type=str, help='host to deploy osd')
+ self.parser.add_argument('--osds', type=int, default=0, help='number of osds')
+
+ def deploy(self):
+ data = Config.get('data')
+ hostname = Config.get('hostname')
+ if not hostname:
+ # assume this host
+ hostname = run_shell_command('hostname')
+ if not data:
+ deploy_osds(Config.get('osds'))
+ else:
+ deploy_osd(data, hostname)
+
+ @ensure_outside_container
+ def create_loop(self):
+ osds = Config.get('osds')
+ create_loopback_devices(int(osds))
+ print('Successfully created loopback devices')
+
+ @ensure_outside_container
+ def destroy(self):
+ cleanup_osds()
diff --git a/src/cephadm/box/util.py b/src/cephadm/box/util.py
new file mode 100644
index 000000000..7dcf883f8
--- /dev/null
+++ b/src/cephadm/box/util.py
@@ -0,0 +1,421 @@
+import json
+import os
+import subprocess
+import sys
+import copy
+from abc import ABCMeta, abstractmethod
+from enum import Enum
+from typing import Any, Callable, Dict, List
+
+class Colors:
+ HEADER = '\033[95m'
+ OKBLUE = '\033[94m'
+ OKCYAN = '\033[96m'
+ OKGREEN = '\033[92m'
+ WARNING = '\033[93m'
+ FAIL = '\033[91m'
+ ENDC = '\033[0m'
+ BOLD = '\033[1m'
+ UNDERLINE = '\033[4m'
+
+class Config:
+ args = {
+ 'fsid': '00000000-0000-0000-0000-0000deadbeef',
+ 'config_folder': '/etc/ceph/',
+ 'config': '/etc/ceph/ceph.conf',
+ 'keyring': '/etc/ceph/ceph.keyring',
+ 'loop_img': 'loop-images/loop.img',
+ 'engine': 'podman',
+ 'docker_yaml': 'docker-compose-docker.yml',
+ 'docker_v1_yaml': 'docker-compose.cgroup1.yml',
+ 'podman_yaml': 'docker-compose-podman.yml',
+ 'loop_img_dir': 'loop-images',
+ }
+
+ @staticmethod
+ def set(key, value):
+ Config.args[key] = value
+
+ @staticmethod
+ def get(key):
+ if key in Config.args:
+ return Config.args[key]
+ return None
+
+ @staticmethod
+ def add_args(args: Dict[str, str]) -> None:
+ Config.args.update(args)
+
+class Target:
+ def __init__(self, argv, subparsers):
+ self.argv = argv
+ self.parser = subparsers.add_parser(
+ self.__class__.__name__.lower(), help=self.__class__._help
+ )
+
+ def set_args(self):
+ """
+ adding the required arguments of the target should go here, example:
+ self.parser.add_argument(..)
+ """
+ raise NotImplementedError()
+
+ def main(self):
+ """
+ A target will be setup by first calling this main function
+ where the parser is initialized.
+ """
+ args = self.parser.parse_args(self.argv)
+ Config.add_args(vars(args))
+ function = getattr(self, args.action)
+ function()
+
+
+def ensure_outside_container(func) -> Callable:
+ def wrapper(*args, **kwargs):
+ if not inside_container():
+ return func(*args, **kwargs)
+ else:
+ raise RuntimeError('This command should be ran outside a container')
+
+ return wrapper
+
+
+def ensure_inside_container(func) -> bool:
+ def wrapper(*args, **kwargs):
+ if inside_container():
+ return func(*args, **kwargs)
+ else:
+ raise RuntimeError('This command should be ran inside a container')
+
+ return wrapper
+
+
+def colored(msg, color: Colors):
+ return color + msg + Colors.ENDC
+
+class BoxType(str, Enum):
+ SEED = 'seed'
+ HOST = 'host'
+
+class HostContainer:
+ def __init__(self, _name, _type) -> None:
+ self._name: str = _name
+ self._type: BoxType = _type
+
+ @property
+ def name(self) -> str:
+ return self._name
+
+ @property
+ def type(self) -> BoxType:
+ return self._type
+ def __str__(self) -> str:
+ return f'{self.name} {self.type}'
+
+def run_shell_command(command: str, expect_error=False, verbose=True, expect_exit_code=0) -> str:
+ if Config.get('verbose'):
+ print(f'{colored("Running command", Colors.HEADER)}: {colored(command, Colors.OKBLUE)}')
+
+ process = subprocess.Popen(
+ command, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE
+ )
+
+ out = ''
+ err = ''
+ # let's read when output comes so it is in real time
+ while True:
+ # TODO: improve performance of this part, I think this part is a problem
+ pout = process.stdout.read(1).decode('latin1')
+ if pout == '' and process.poll() is not None:
+ break
+ if pout:
+ if Config.get('verbose') and verbose:
+ sys.stdout.write(pout)
+ sys.stdout.flush()
+ out += pout
+
+ process.wait()
+
+ err += process.stderr.read().decode('latin1').strip()
+ out = out.strip()
+
+ if process.returncode != 0 and not expect_error and process.returncode != expect_exit_code:
+ err = colored(err, Colors.FAIL);
+
+ raise RuntimeError(f'Failed command: {command}\n{err}\nexit code: {process.returncode}')
+ sys.exit(1)
+ return out
+
+
+def run_dc_shell_commands(commands: str, container: HostContainer, expect_error=False) -> str:
+ for command in commands.split('\n'):
+ command = command.strip()
+ if not command:
+ continue
+ run_dc_shell_command(command.strip(), container, expect_error=expect_error)
+
+def run_shell_commands(commands: str, expect_error=False) -> str:
+ for command in commands.split('\n'):
+ command = command.strip()
+ if not command:
+ continue
+ run_shell_command(command, expect_error=expect_error)
+
+@ensure_inside_container
+def run_cephadm_shell_command(command: str, expect_error=False) -> str:
+ config = Config.get('config')
+ keyring = Config.get('keyring')
+ fsid = Config.get('fsid')
+
+ with_cephadm_image = 'CEPHADM_IMAGE=quay.ceph.io/ceph-ci/ceph:main'
+ out = run_shell_command(
+ f'{with_cephadm_image} cephadm --verbose shell --fsid {fsid} --config {config} --keyring {keyring} -- {command}',
+ expect_error,
+ )
+ return out
+
+
+def run_dc_shell_command(
+ command: str, container: HostContainer, expect_error=False
+) -> str:
+ out = get_container_engine().run_exec(container, command, expect_error=expect_error)
+ return out
+
+def inside_container() -> bool:
+ return os.path.exists('/.box_container')
+
+def get_container_id(container_name: str):
+ return run_shell_command(f"{engine()} ps | \grep " + container_name + " | awk '{ print $1 }'")
+
+def engine():
+ return Config.get('engine')
+
+def engine_compose():
+ return f'{engine()}-compose'
+
+def get_seed_name():
+ if engine() == 'docker':
+ return 'seed'
+ elif engine() == 'podman':
+ return 'box_hosts_0'
+ else:
+ print(f'unkown engine {engine()}')
+ sys.exit(1)
+
+
+@ensure_outside_container
+def get_boxes_container_info(with_seed: bool = False) -> Dict[str, Any]:
+ # NOTE: this could be cached
+ ips_query = engine() + " inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}} %tab% {{.Name}} %tab% {{.Config.Hostname}}' $("+ engine() + " ps -aq) --format json"
+ containers = json.loads(run_shell_command(ips_query, verbose=False))
+ # FIXME: if things get more complex a class representing a container info might be useful,
+ # for now representing data this way is faster.
+ info = {'size': 0, 'ips': [], 'container_names': [], 'hostnames': []}
+ for container in containers:
+ # Most commands use hosts only
+ name = container['Name']
+ if name.startswith('box_hosts'):
+ if not with_seed and name == get_seed_name():
+ continue
+ info['size'] += 1
+ print(container['NetworkSettings'])
+ if 'Networks' in container['NetworkSettings']:
+ info['ips'].append(container['NetworkSettings']['Networks']['box_network']['IPAddress'])
+ else:
+ info['ips'].append('n/a')
+ info['container_names'].append(name)
+ info['hostnames'].append(container['Config']['Hostname'])
+ return info
+
+
+def get_orch_hosts():
+ if inside_container():
+ orch_host_ls_out = run_cephadm_shell_command('ceph orch host ls --format json')
+ else:
+ orch_host_ls_out = run_dc_shell_command(f'cephadm shell --keyring /etc/ceph/ceph.keyring --config /etc/ceph/ceph.conf -- ceph orch host ls --format json',
+ get_container_engine().get_seed())
+ sp = orch_host_ls_out.split('\n')
+ orch_host_ls_out = sp[len(sp) - 1]
+ hosts = json.loads(orch_host_ls_out)
+ return hosts
+
+
+class ContainerEngine(metaclass=ABCMeta):
+ @property
+ @abstractmethod
+ def command(self) -> str: pass
+
+ @property
+ @abstractmethod
+ def seed_name(self) -> str: pass
+
+ @property
+ @abstractmethod
+ def dockerfile(self) -> str: pass
+
+ @property
+ def host_name_prefix(self) -> str:
+ return 'box_hosts_'
+
+ @abstractmethod
+ def up(self, hosts: int): pass
+
+ def run_exec(self, container: HostContainer, command: str, expect_error: bool = False):
+ return run_shell_command(' '.join([self.command, 'exec', container.name, command]),
+ expect_error=expect_error)
+
+ def run(self, engine_command: str, expect_error: bool = False):
+ return run_shell_command(' '.join([self.command, engine_command]), expect_error=expect_error)
+
+ def get_containers(self) -> List[HostContainer]:
+ ps_out = json.loads(run_shell_command('podman ps --format json'))
+ containers = []
+ for container in ps_out:
+ if not container['Names']:
+ raise RuntimeError(f'Container {container} missing name')
+ name = container['Names'][0]
+ if name == self.seed_name:
+ containers.append(HostContainer(name, BoxType.SEED))
+ elif name.startswith(self.host_name_prefix):
+ containers.append(HostContainer(name, BoxType.HOST))
+ return containers
+
+ def get_seed(self) -> HostContainer:
+ for container in self.get_containers():
+ if container.type == BoxType.SEED:
+ return container
+ raise RuntimeError('Missing seed container')
+
+ def get_container(self, container_name: str):
+ containers = self.get_containers()
+ for container in containers:
+ if container.name == container_name:
+ return container
+ return None
+
+
+ def restart(self):
+ pass
+
+
+class DockerEngine(ContainerEngine):
+ command = 'docker'
+ seed_name = 'seed'
+ dockerfile = 'DockerfileDocker'
+
+ def restart(self):
+ run_shell_command('systemctl restart docker')
+
+ def up(self, hosts: int):
+ dcflags = f'-f {Config.get("docker_yaml")}'
+ if not os.path.exists('/sys/fs/cgroup/cgroup.controllers'):
+ dcflags += f' -f {Config.get("docker_v1_yaml")}'
+ run_shell_command(f'{engine_compose()} {dcflags} up --scale hosts={hosts} -d')
+
+class PodmanEngine(ContainerEngine):
+ command = 'podman'
+ seed_name = 'box_hosts_0'
+ dockerfile = 'DockerfilePodman'
+
+ CAPS = [
+ "SYS_ADMIN",
+ "NET_ADMIN",
+ "SYS_TIME",
+ "SYS_RAWIO",
+ "MKNOD",
+ "NET_RAW",
+ "SETUID",
+ "SETGID",
+ "CHOWN",
+ "SYS_PTRACE",
+ "SYS_TTY_CONFIG",
+ "CAP_AUDIT_WRITE",
+ "CAP_AUDIT_CONTROL",
+ ]
+
+ VOLUMES = [
+ '../../../:/ceph:z',
+ '../:/cephadm:z',
+ '/run/udev:/run/udev',
+ '/sys/dev/block:/sys/dev/block',
+ '/sys/fs/cgroup:/sys/fs/cgroup:ro',
+ '/dev/fuse:/dev/fuse',
+ '/dev/disk:/dev/disk',
+ '/sys/devices/virtual/block:/sys/devices/virtual/block',
+ '/sys/block:/dev/block',
+ '/dev/mapper:/dev/mapper',
+ '/dev/mapper/control:/dev/mapper/control',
+ ]
+
+ TMPFS = ['/run', '/tmp']
+
+ # FIXME: right now we are assuming every service will be exposed through the seed, but this is far
+ # from the truth. Services can be deployed on different hosts so we need a system to manage this.
+ SEED_PORTS = [
+ 8443, # dashboard
+ 3000, # grafana
+ 9093, # alertmanager
+ 9095 # prometheus
+ ]
+
+
+ def setup_podman_env(self, hosts: int = 1, osd_devs={}):
+ network_name = 'box_network'
+ networks = run_shell_command('podman network ls')
+ if network_name not in networks:
+ run_shell_command(f'podman network create -d bridge {network_name}')
+
+ args = [
+ '--group-add', 'keep-groups',
+ '--device', '/dev/fuse' ,
+ '-it' ,
+ '-d',
+ '-e', 'CEPH_BRANCH=main',
+ '--stop-signal', 'RTMIN+3'
+ ]
+
+ for cap in self.CAPS:
+ args.append('--cap-add')
+ args.append(cap)
+
+ for volume in self.VOLUMES:
+ args.append('-v')
+ args.append(volume)
+
+ for tmp in self.TMPFS:
+ args.append('--tmpfs')
+ args.append(tmp)
+
+
+ for osd_dev in osd_devs.values():
+ device = osd_dev["device"]
+ args.append('--device')
+ args.append(f'{device}:{device}')
+
+
+ for host in range(hosts+1): # 0 will be the seed
+ options = copy.copy(args)
+ options.append('--name')
+ options.append(f'box_hosts_{host}')
+ options.append('--network')
+ options.append(f'{network_name}')
+ if host == 0:
+ for port in self.SEED_PORTS:
+ options.append('-p')
+ options.append(f'{port}:{port}')
+
+ options.append('cephadm-box')
+ options = ' '.join(options)
+
+ run_shell_command(f'podman run {options}')
+
+ def up(self, hosts: int):
+ import osd
+ self.setup_podman_env(hosts=hosts, osd_devs=osd.load_osd_devices())
+
+def get_container_engine() -> ContainerEngine:
+ if engine() == 'docker':
+ return DockerEngine()
+ else:
+ return PodmanEngine()