Add scale command

Closes #9
This commit is contained in:
Ben Firshman 2014-01-16 17:58:53 +00:00
parent 8ed86ed551
commit 56c6efdfce
6 changed files with 125 additions and 0 deletions

View File

@ -233,6 +233,15 @@ For example:
Note that this will not start any services that the command's service links to. So if, for example, your one-off command talks to your database, you will need to run `fig up -d db` first. Note that this will not start any services that the command's service links to. So if, for example, your one-off command talks to your database, you will need to run `fig up -d db` first.
#### scale
Set number of containers to run for a service.
Numbers are specified in the form `service=num` as arguments.
For example:
$ fig scale web=2 worker=3
#### start #### start
Start existing containers for a service. Start existing containers for a service.

View File

@ -10,6 +10,7 @@ from inspect import getdoc
from .. import __version__ from .. import __version__
from ..project import NoSuchService from ..project import NoSuchService
from ..service import CannotBeScaledError
from .command import Command from .command import Command
from .formatter import Formatter from .formatter import Formatter
from .log_printer import LogPrinter from .log_printer import LogPrinter
@ -82,6 +83,7 @@ class TopLevelCommand(Command):
ps List containers ps List containers
rm Remove stopped containers rm Remove stopped containers
run Run a one-off command run Run a one-off command
scale Set number of containers for a service
start Start services start Start services
stop Stop services stop Stop services
up Create and start containers up Create and start containers
@ -220,6 +222,31 @@ class TopLevelCommand(Command):
service.start_container(container, ports=None) service.start_container(container, ports=None)
c.run() c.run()
def scale(self, options):
"""
Set number of containers to run for a service.
Numbers are specified in the form `service=num` as arguments.
For example:
$ fig scale web=2 worker=3
Usage: scale [SERVICE=NUM...]
"""
for s in options['SERVICE=NUM']:
if '=' not in s:
raise UserError('Arguments to scale should be in the form service=num')
service_name, num = s.split('=', 1)
try:
num = int(num)
except ValueError:
raise UserError('Number of containers for service "%s" is not a number' % service)
try:
self.project.get_service(service_name).scale(num)
except CannotBeScaledError:
raise UserError('Service "%s" cannot be scaled because it specifies a port on the host. If multiple containers for this service were created, the port would clash.\n\nRemove the ":" from the port definition in fig.yml so Docker can choose a random port for each container.' % service_name)
def start(self, options): def start(self, options):
""" """
Start existing containers. Start existing containers.

View File

@ -14,6 +14,10 @@ class BuildError(Exception):
pass pass
class CannotBeScaledError(Exception):
pass
class Service(object): class Service(object):
def __init__(self, name, client=None, project='default', links=[], **options): def __init__(self, name, client=None, project='default', links=[], **options):
if not re.match('^[a-zA-Z0-9]+$', name): if not re.match('^[a-zA-Z0-9]+$', name):
@ -56,6 +60,40 @@ class Service(object):
log.info("Killing %s..." % c.name) log.info("Killing %s..." % c.name)
c.kill(**options) c.kill(**options)
def scale(self, desired_num):
if not self.can_be_scaled():
raise CannotBeScaledError()
# Create enough containers
containers = self.containers(stopped=True)
while len(containers) < desired_num:
containers.append(self.create_container())
running_containers = []
stopped_containers = []
for c in containers:
if c.is_running:
running_containers.append(c)
else:
stopped_containers.append(c)
running_containers.sort(key=lambda c: c.number)
stopped_containers.sort(key=lambda c: c.number)
# Stop containers
while len(running_containers) > desired_num:
c = running_containers.pop()
log.info("Stopping %s..." % c.name)
c.stop(timeout=1)
stopped_containers.append(c)
# Start containers
while len(running_containers) < desired_num:
c = stopped_containers.pop(0)
log.info("Starting %s..." % c.name)
c.start()
running_containers.append(c)
def remove_stopped(self, **options): def remove_stopped(self, **options):
for c in self.containers(stopped=True): for c in self.containers(stopped=True):
if not c.is_running: if not c.is_running:
@ -231,6 +269,12 @@ class Service(object):
""" """
return '%s_%s' % (self.project, self.name) return '%s_%s' % (self.project, self.name)
def can_be_scaled(self):
for port in self.options.get('ports', []):
if ':' in str(port):
return False
return True
NAME_RE = re.compile(r'^([^_]+)_([^_]+)_(run_)?(\d+)$') NAME_RE = re.compile(r'^([^_]+)_([^_]+)_(run_)?(\d+)$')

View File

@ -13,3 +13,26 @@ class CLITestCase(unittest.TestCase):
def test_ps(self): def test_ps(self):
self.command.dispatch(['ps'], None) self.command.dispatch(['ps'], None)
def test_scale(self):
project = self.command.project
self.command.scale({'SERVICE=NUM': ['simple=1']})
self.assertEqual(len(project.get_service('simple').containers()), 1)
self.command.scale({'SERVICE=NUM': ['simple=3', 'another=2']})
self.assertEqual(len(project.get_service('simple').containers()), 3)
self.assertEqual(len(project.get_service('another').containers()), 2)
self.command.scale({'SERVICE=NUM': ['simple=1', 'another=1']})
self.assertEqual(len(project.get_service('simple').containers()), 1)
self.assertEqual(len(project.get_service('another').containers()), 1)
self.command.scale({'SERVICE=NUM': ['simple=1', 'another=1']})
self.assertEqual(len(project.get_service('simple').containers()), 1)
self.assertEqual(len(project.get_service('another').containers()), 1)
self.command.scale({'SERVICE=NUM': ['simple=0', 'another=0']})
self.assertEqual(len(project.get_service('simple').containers()), 0)
self.assertEqual(len(project.get_service('another').containers()), 0)

View File

@ -1,2 +1,6 @@
simple: simple:
image: ubuntu image: ubuntu
command: /bin/sleep 300
another:
image: ubuntu
command: /bin/sleep 300

View File

@ -1,6 +1,7 @@
from __future__ import unicode_literals from __future__ import unicode_literals
from __future__ import absolute_import from __future__ import absolute_import
from fig import Service from fig import Service
from fig.service import CannotBeScaledError
from .testcases import DockerClientTestCase from .testcases import DockerClientTestCase
@ -193,3 +194,20 @@ class ServiceTest(DockerClientTestCase):
self.assertIn('8000/tcp', container['HostConfig']['PortBindings']) self.assertIn('8000/tcp', container['HostConfig']['PortBindings'])
self.assertEqual(container['HostConfig']['PortBindings']['8000/tcp'][0]['HostPort'], '8001') self.assertEqual(container['HostConfig']['PortBindings']['8000/tcp'][0]['HostPort'], '8001')
def test_scale(self):
service = self.create_service('web')
service.scale(1)
self.assertEqual(len(service.containers()), 1)
service.scale(3)
self.assertEqual(len(service.containers()), 3)
service.scale(1)
self.assertEqual(len(service.containers()), 1)
service.scale(0)
self.assertEqual(len(service.containers()), 0)
def test_scale_on_service_that_cannot_be_scaled(self):
service = self.create_service('web', ports=['8000:8000'])
self.assertRaises(CannotBeScaledError, lambda: service.scale(1))