Skip to content

Commit 73d995f

Browse files
Private cloud support
- Add private cloud support
1 parent d001d66 commit 73d995f

File tree

9 files changed

+406
-17
lines changed

9 files changed

+406
-17
lines changed

README.md

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,8 @@ A locally-focused workflow (local development, local execution) with the CLI may
118118
- [`lean object-store properties`](#lean-object-store-properties)
119119
- [`lean object-store set`](#lean-object-store-set)
120120
- [`lean optimize`](#lean-optimize)
121+
- [`lean private-cloud start`](#lean-private-cloud-start)
122+
- [`lean private-cloud stop`](#lean-private-cloud-stop)
121123
- [`lean project-create`](#lean-project-create)
122124
- [`lean project-delete`](#lean-project-delete)
123125
- [`lean report`](#lean-report)
@@ -1816,6 +1818,50 @@ Options:
18161818

18171819
_See code: [lean/commands/optimize.py](lean/commands/optimize.py)_
18181820

1821+
### `lean private-cloud start`
1822+
1823+
Start a new private cloud
1824+
1825+
```
1826+
Usage: lean private-cloud start [OPTIONS]
1827+
1828+
Start a new private cloud
1829+
1830+
Options:
1831+
--master Run in master mode
1832+
--slave Run in slave mode
1833+
--token TEXT The master server token
1834+
--master-ip TEXT The master server ip address
1835+
--master-port INTEGER The master server port
1836+
--slave-ip TEXT The slave server ip address
1837+
--update Pull the latest image before starting
1838+
--no-update Do not update to the latest version
1839+
--compute TEXT Compute configuration to use
1840+
--extra-docker-config TEXT Extra docker configuration as a JSON string
1841+
--stop Stop any existing deployment
1842+
--lean-config FILE The Lean configuration file that should be used (defaults to the nearest lean.json)
1843+
--verbose Enable debug logging
1844+
--help Show this message and exit.
1845+
```
1846+
1847+
_See code: [lean/commands/private_cloud/start.py](lean/commands/private_cloud/start.py)_
1848+
1849+
### `lean private-cloud stop`
1850+
1851+
Stops a running private cloud
1852+
1853+
```
1854+
Usage: lean private-cloud stop [OPTIONS]
1855+
1856+
Stops a running private cloud
1857+
1858+
Options:
1859+
--verbose Enable debug logging
1860+
--help Show this message and exit.
1861+
```
1862+
1863+
_See code: [lean/commands/private_cloud/stop.py](lean/commands/private_cloud/stop.py)_
1864+
18191865
### `lean project-create`
18201866

18211867
Create a new project containing starter code.

lean/commands/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
from lean.commands.whoami import whoami
3434
from lean.commands.gui import gui
3535
from lean.commands.object_store import object_store
36+
from lean.commands.private_cloud import private_cloud
3637

3738
lean.add_command(config)
3839
lean.add_command(cloud)
@@ -55,3 +56,4 @@
5556
lean.add_command(logs)
5657
lean.add_command(gui)
5758
lean.add_command(object_store)
59+
lean.add_command(private_cloud)
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# QUANTCONNECT.COM - Democratizing Finance, Empowering Individuals.
2+
# Lean CLI v1.0. Copyright 2021 QuantConnect Corporation.
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License");
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
7+
#
8+
# Unless required by applicable law or agreed to in writing, software
9+
# distributed under the License is distributed on an "AS IS" BASIS,
10+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
# See the License for the specific language governing permissions and
12+
# limitations under the License.
13+
14+
from click import group
15+
16+
from lean.commands.private_cloud.start import start
17+
from lean.commands.private_cloud.stop import stop
18+
19+
20+
@group()
21+
def private_cloud() -> None:
22+
"""Interact with a QuantConnect private cloud."""
23+
# This method is intentionally empty
24+
# It is used as the command group for all `lean private-cloud <command>` commands
25+
pass
26+
27+
28+
private_cloud.add_command(start)
29+
private_cloud.add_command(stop)
Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
# QUANTCONNECT.COM - Democratizing Finance, Empowering Individuals.
2+
# Lean CLI v1.0. Copyright 2021 QuantConnect Corporation.
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License");
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
7+
#
8+
# Unless required by applicable law or agreed to in writing, software
9+
# distributed under the License is distributed on an "AS IS" BASIS,
10+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
# See the License for the specific language governing permissions and
12+
# limitations under the License.
13+
14+
from pathlib import Path
15+
from typing import Optional
16+
from json import loads
17+
18+
from click import command, option
19+
from docker.errors import APIError
20+
from docker.types import Mount
21+
22+
from lean.click import LeanCommand
23+
from lean.commands.private_cloud.stop import get_private_cloud_containers, stop_command
24+
from lean.container import container
25+
from lean.models.cli import cli_compute
26+
from lean.models.docker import DockerImage
27+
from lean.constants import COMPUTE_MASTER, COMPUTE_SLAVE, COMPUTE_MESSAGING
28+
29+
30+
def get_free_port():
31+
from socket import socket
32+
for i in range(0, 3):
33+
try:
34+
port = 32787 + i
35+
with socket() as s:
36+
s.bind(('', port))
37+
return port
38+
except:
39+
pass
40+
return 0
41+
42+
43+
def deploy(ip: str, port: int, token: str, slave: bool, update: bool, no_update: bool,
44+
image: str, lean_config: dict, extra_docker_config: str, counter: int = 0):
45+
logger = container.logger
46+
47+
compute_node_name = f"{COMPUTE_SLAVE}{counter}" if slave else COMPUTE_MASTER
48+
logger.info(f"Starting {compute_node_name}...")
49+
compute_directory = Path(f"~/.lean/compute/{compute_node_name}").expanduser()
50+
lean_config["node-name"] = compute_node_name
51+
run_options = container.lean_runner.get_basic_docker_config_without_algo(lean_config, None, True, None, None,
52+
None, compute_directory)
53+
run_options["mounts"].append(Mount(target="/QuantConnect/platform-services/airlock",
54+
source=str(compute_directory), type="bind"))
55+
run_options["mounts"].append(Mount(target="/var/run/docker.sock", source="/var/run/docker.sock",
56+
type="bind", read_only=True))
57+
docker_config_source = Path("~/.docker/config.json").expanduser()
58+
run_options["mounts"].append(Mount(target="/root/.docker/config.json", source=str(docker_config_source),
59+
type="bind", read_only=True))
60+
container.lean_runner.parse_extra_docker_config(run_options, loads(extra_docker_config))
61+
62+
if not slave:
63+
run_options["ports"]["9696"] = str(port)
64+
run_options["ports"]["9697"] = str(get_free_port())
65+
66+
root_directory = container.lean_config_manager.get_cli_root_directory()
67+
run_options["volumes"][str(root_directory)] = {"bind": "/LeanCLIWorkspace", "mode": "rw"}
68+
69+
run_options["remove"] = False
70+
run_options["name"] = compute_node_name
71+
run_options["environment"]["MODE"] = str('slave') if slave else str('master')
72+
run_options["environment"]["IP"] = str(ip)
73+
run_options["environment"]["PORT"] = str(port)
74+
run_options["environment"]["TOKEN"] = str(token)
75+
run_options["user"] = "root"
76+
run_options["restart_policy"] = {"Name": "always"}
77+
run_options["verify_stability"] = True
78+
79+
if not image:
80+
image = "quantconnect/platform-services:latest"
81+
docker_image = DockerImage.parse(image)
82+
container.update_manager.pull_docker_image_if_necessary(docker_image, update, no_update)
83+
try:
84+
container.docker_manager.run_image(image, **run_options)
85+
except APIError as error:
86+
msg = error.explanation
87+
if isinstance(msg, str) and any(m in msg.lower() for m in [
88+
"port is already allocated",
89+
"ports are not available"
90+
"an attempt was made to access a socket in a way forbidden by its access permissions"
91+
]):
92+
f"Port {port} is already in use, please specify a different port using --master-port <number>"
93+
raise error
94+
95+
96+
def get_ip_address():
97+
from socket import gethostname, gethostbyname
98+
hostname = gethostname()
99+
return gethostbyname(hostname + ".local")
100+
101+
102+
@command(cls=LeanCommand, requires_lean_config=True, requires_docker=True, help="Start a new private cloud")
103+
@option("--master", is_flag=True, default=False, help="Run in master mode")
104+
@option("--slave", is_flag=True, default=False, help="Run in slave mode")
105+
@option("--token", type=str, required=False, help="The master server token")
106+
@option("--master-ip", type=str, required=False, help="The master server ip address")
107+
@option("--master-port", type=int, required=False, default=0, help="The master server port")
108+
@option("--slave-ip", type=str, required=False, help="The slave server ip address")
109+
@option("--update", is_flag=True, default=False, help="Pull the latest image before starting")
110+
@option("--no-update", is_flag=True, default=False, help="Do not update to the latest version")
111+
@option("--compute", type=str, required=False, help="Compute configuration to use")
112+
@option("--extra-docker-config", type=str, default="{}", help="Extra docker configuration as a JSON string")
113+
@option("--image", type=str, hidden=True)
114+
@option("--stop", is_flag=True, default=False, help="Stop any existing deployment")
115+
def start(master: bool,
116+
slave: bool,
117+
token: str,
118+
master_ip: str,
119+
slave_ip: str,
120+
master_port: int,
121+
update: bool,
122+
no_update: bool,
123+
compute: Optional[str],
124+
extra_docker_config: Optional[str],
125+
image: Optional[str],
126+
stop: bool) -> None:
127+
logger = container.logger
128+
129+
if stop:
130+
stop_command()
131+
132+
if slave and master:
133+
raise RuntimeError(f"Can only provide one of '--master' or '--slave'")
134+
if not slave and not master:
135+
# just default to slave if none given
136+
slave = True
137+
138+
if not master_ip:
139+
master_ip = get_ip_address()
140+
logger.info(f"'--master-ip' was not provided using '{master_ip}'")
141+
142+
str_mode = 'slave' if slave else 'master'
143+
logger.info(f'Start running in {str_mode} mode')
144+
145+
if not compute:
146+
# configure
147+
compute = []
148+
for module in cli_compute:
149+
module.config_build({}, logger, True)
150+
compute_config = module.get_settings()
151+
compute.append(compute_config)
152+
else:
153+
compute = loads(compute)
154+
155+
if slave:
156+
if not token:
157+
raise RuntimeError(f"Master token is required when running as slave")
158+
if master_port == 0:
159+
raise RuntimeError(f"Master port is required when running as slave")
160+
else:
161+
if not token:
162+
from uuid import uuid4
163+
token = uuid4().hex
164+
165+
docker_container = get_private_cloud_containers()
166+
if any(docker_container):
167+
names = [node.name for node in docker_container if node.status == 'running']
168+
if master and (COMPUTE_MASTER in names or COMPUTE_MESSAGING in names):
169+
raise RuntimeError(f"Private cloud nodes already running detected: {names}")
170+
logger.info(f"Running nodes: {names}")
171+
172+
container.temp_manager.delete_temporary_directories_when_done = False
173+
lean_config = container.lean_config_manager.get_complete_lean_config(None, None, None)
174+
175+
if master:
176+
deploy(master_ip, master_port, token, False, update, no_update, image, lean_config, extra_docker_config)
177+
if master_port == 0:
178+
master_port = container.docker_manager.get_container_port(COMPUTE_MASTER, "9696/tcp")
179+
logger.info(f"Slaves can be added running: "
180+
f"lean private-cloud start --slave --master-ip {master_ip} --token \"{token}\" --master-port {master_port}")
181+
182+
compute_index = len(get_private_cloud_containers([COMPUTE_SLAVE]))
183+
if compute:
184+
logger.debug(f"Starting given compute configuration: {compute}")
185+
186+
if not slave_ip:
187+
logger.debug(f"'slave-ip' was not given will try to figure it out...")
188+
retry_count = 0
189+
while retry_count < 10:
190+
retry_count += 1
191+
try:
192+
from requests import get
193+
resp = get(f'http://{master_ip}:{master_port}', stream=True)
194+
slave_ip = resp.raw._connection.sock.getsockname()[0]
195+
break
196+
except Exception as e:
197+
from time import sleep
198+
sleep(1)
199+
pass
200+
lean_config["self-ip-address"] = slave_ip
201+
logger.info(f"Using ip address '{slave_ip}' as own")
202+
203+
for configuration in compute:
204+
lean_config["compute"] = configuration
205+
for i in range(compute_index, int(configuration["count"]) + compute_index):
206+
deploy(master_ip, master_port, token, True, update, no_update, image, lean_config, extra_docker_config, i)
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
# QUANTCONNECT.COM - Democratizing Finance, Empowering Individuals.
2+
# Lean CLI v1.0. Copyright 2021 QuantConnect Corporation.
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License");
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
7+
#
8+
# Unless required by applicable law or agreed to in writing, software
9+
# distributed under the License is distributed on an "AS IS" BASIS,
10+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
# See the License for the specific language governing permissions and
12+
# limitations under the License.
13+
14+
from click import command
15+
16+
from lean.click import LeanCommand
17+
from lean.constants import PRIVATE_CLOUD
18+
from lean.container import container
19+
20+
21+
def get_private_cloud_containers(container_filter: [] = None):
22+
result = []
23+
if not container_filter:
24+
container_filter = [PRIVATE_CLOUD]
25+
for name in container_filter:
26+
for docker_container in container.docker_manager.get_containers_by_name(name, starts_with=True):
27+
result.append(docker_container)
28+
return result
29+
30+
31+
def stop_command():
32+
logger = container.logger
33+
for docker_container in get_private_cloud_containers():
34+
logger.info(f'Stopping: {docker_container.name.lstrip("/")}')
35+
if docker_container:
36+
try:
37+
docker_container.kill()
38+
except:
39+
# might be restarting or not running
40+
pass
41+
try:
42+
docker_container.remove()
43+
except:
44+
# might be running with autoremove
45+
pass
46+
47+
48+
@command(cls=LeanCommand, requires_docker=True, help="Stops a running private cloud")
49+
def stop() -> None:
50+
stop_command()

0 commit comments

Comments
 (0)