summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--interface-definitions/container.xml.in18
-rw-r--r--python/vyos/configsession.py6
-rwxr-xr-xsmoketest/scripts/cli/test_container.py16
-rwxr-xr-xsmoketest/scripts/cli/test_service_https.py41
-rwxr-xr-xsmoketest/scripts/system/test_kernel_options.py17
-rwxr-xr-xsrc/conf_mode/container.py9
-rwxr-xr-xsrc/services/vyos-http-api-server46
7 files changed, 136 insertions, 17 deletions
diff --git a/interface-definitions/container.xml.in b/interface-definitions/container.xml.in
index 2296a3e9e..1ad7215e5 100644
--- a/interface-definitions/container.xml.in
+++ b/interface-definitions/container.xml.in
@@ -192,6 +192,24 @@
</leafNode>
</children>
</tagNode>
+ <leafNode name="cpu-quota">
+ <properties>
+ <help>This limits the number of CPU resources the container can use</help>
+ <valueHelp>
+ <format>u32:0</format>
+ <description>Unlimited</description>
+ </valueHelp>
+ <valueHelp>
+ <format>txt</format>
+ <description>Amount of CPU time the container can use in amount of cores (up to three decimals)</description>
+ </valueHelp>
+ <constraint>
+ <regex>(0|[1-9]\d*)(\.\d{1,3})?</regex>
+ </constraint>
+ <constraintErrorMessage>Container CPU limit must be a (decimal) number in range 0 to number of threads</constraintErrorMessage>
+ </properties>
+ <defaultValue>0</defaultValue>
+ </leafNode>
<leafNode name="memory">
<properties>
<help>Memory (RAM) available to this container</help>
diff --git a/python/vyos/configsession.py b/python/vyos/configsession.py
index ab7a631bb..beec6010b 100644
--- a/python/vyos/configsession.py
+++ b/python/vyos/configsession.py
@@ -34,6 +34,8 @@ INSTALL_IMAGE = ['/usr/libexec/vyos/op_mode/image_installer.py',
'--action', 'add', '--no-prompt', '--image-path']
REMOVE_IMAGE = ['/usr/libexec/vyos/op_mode/image_manager.py',
'--action', 'delete', '--no-prompt', '--image-name']
+SET_DEFAULT_IMAGE = ['/usr/libexec/vyos/op_mode/image_manager.py',
+ '--action', 'set', '--no-prompt', '--image-name']
GENERATE = ['/opt/vyatta/bin/vyatta-op-cmd-wrapper', 'generate']
SHOW = ['/opt/vyatta/bin/vyatta-op-cmd-wrapper', 'show']
RESET = ['/opt/vyatta/bin/vyatta-op-cmd-wrapper', 'reset']
@@ -235,6 +237,10 @@ class ConfigSession(object):
out = self.__run_command(REMOVE_IMAGE + [name])
return out
+ def set_default_image(self, name):
+ out = self.__run_command(SET_DEFAULT_IMAGE + [name])
+ return out
+
def generate(self, path):
out = self.__run_command(GENERATE + path)
return out
diff --git a/smoketest/scripts/cli/test_container.py b/smoketest/scripts/cli/test_container.py
index 3201883b8..90f821c60 100755
--- a/smoketest/scripts/cli/test_container.py
+++ b/smoketest/scripts/cli/test_container.py
@@ -91,6 +91,22 @@ class TestContainer(VyOSUnitTestSHIM.TestCase):
# Check for running process
self.assertEqual(process_named_running(PROCESS_NAME), pid)
+ def test_cpu_limit(self):
+ cont_name = 'c2'
+
+ self.cli_set(base_path + ['name', cont_name, 'allow-host-networks'])
+ self.cli_set(base_path + ['name', cont_name, 'image', cont_image])
+ self.cli_set(base_path + ['name', cont_name, 'cpu-quota', '1.25'])
+
+ self.cli_commit()
+
+ pid = 0
+ with open(PROCESS_PIDFILE.format(cont_name), 'r') as f:
+ pid = int(f.read())
+
+ # Check for running process
+ self.assertEqual(process_named_running(PROCESS_NAME), pid)
+
def test_ipv4_network(self):
prefix = '192.0.2.0/24'
base_name = 'ipv4'
diff --git a/smoketest/scripts/cli/test_service_https.py b/smoketest/scripts/cli/test_service_https.py
index f2a64627f..8a6386e4f 100755
--- a/smoketest/scripts/cli/test_service_https.py
+++ b/smoketest/scripts/cli/test_service_https.py
@@ -412,6 +412,47 @@ class TestHTTPSService(VyOSUnitTestSHIM.TestCase):
self.assertEqual(r.status_code, 200)
@ignore_warning(InsecureRequestWarning)
+ def test_api_image(self):
+ address = '127.0.0.1'
+ key = 'VyOS-key'
+ url = f'https://{address}/image'
+ headers = {}
+
+ self.cli_set(base_path + ['api', 'keys', 'id', 'key-01', 'key', key])
+ self.cli_commit()
+
+ payload = {
+ 'data': '{"op": "add"}',
+ 'key': f'{key}',
+ }
+ r = request('POST', url, verify=False, headers=headers, data=payload)
+ self.assertEqual(r.status_code, 400)
+ self.assertIn('Missing required field "url"', r.json().get('error'))
+
+ payload = {
+ 'data': '{"op": "delete"}',
+ 'key': f'{key}',
+ }
+ r = request('POST', url, verify=False, headers=headers, data=payload)
+ self.assertEqual(r.status_code, 400)
+ self.assertIn('Missing required field "name"', r.json().get('error'))
+
+ payload = {
+ 'data': '{"op": "set_default"}',
+ 'key': f'{key}',
+ }
+ r = request('POST', url, verify=False, headers=headers, data=payload)
+ self.assertEqual(r.status_code, 400)
+ self.assertIn('Missing required field "name"', r.json().get('error'))
+
+ payload = {
+ 'data': '{"op": "show"}',
+ 'key': f'{key}',
+ }
+ r = request('POST', url, verify=False, headers=headers, data=payload)
+ self.assertEqual(r.status_code, 200)
+
+ @ignore_warning(InsecureRequestWarning)
def test_api_config_file_load_http(self):
# Test load config from HTTP URL
address = '127.0.0.1'
diff --git a/smoketest/scripts/system/test_kernel_options.py b/smoketest/scripts/system/test_kernel_options.py
index 18922d93d..4666e98e7 100755
--- a/smoketest/scripts/system/test_kernel_options.py
+++ b/smoketest/scripts/system/test_kernel_options.py
@@ -111,5 +111,22 @@ class TestKernelModules(unittest.TestCase):
tmp = re.findall(f'{option}=(y|m)', self._config_data)
self.assertTrue(tmp)
+ def test_vfio(self):
+ options_to_check = [
+ 'CONFIG_VFIO', 'CONFIG_VFIO_GROUP', 'CONFIG_VFIO_CONTAINER',
+ 'CONFIG_VFIO_IOMMU_TYPE1', 'CONFIG_VFIO_NOIOMMU', 'CONFIG_VFIO_VIRQFD'
+ ]
+ for option in options_to_check:
+ tmp = re.findall(f'{option}=(y|m)', self._config_data)
+ self.assertTrue(tmp)
+
+ def test_container_cpu(self):
+ options_to_check = [
+ 'CONFIG_CGROUP_SCHED', 'CONFIG_CPUSETS', 'CONFIG_CGROUP_CPUACCT', 'CONFIG_CFS_BANDWIDTH'
+ ]
+ for option in options_to_check:
+ tmp = re.findall(f'{option}=(y|m)', self._config_data)
+ self.assertTrue(tmp)
+
if __name__ == '__main__':
unittest.main(verbosity=2)
diff --git a/src/conf_mode/container.py b/src/conf_mode/container.py
index 91a10e891..ca09dff9f 100755
--- a/src/conf_mode/container.py
+++ b/src/conf_mode/container.py
@@ -16,6 +16,7 @@
import os
+from decimal import Decimal
from hashlib import sha256
from ipaddress import ip_address
from ipaddress import ip_network
@@ -127,6 +128,11 @@ def verify(container):
f'locally. Please use "add container image {image}" to add it '\
f'to the system! Container "{name}" will not be started!')
+ if 'cpu_quota' in container_config:
+ cores = vyos.cpu.get_core_count()
+ if Decimal(container_config['cpu_quota']) > cores:
+ raise ConfigError(f'Cannot set limit to more cores than available "{name}"!')
+
if 'network' in container_config:
if len(container_config['network']) > 1:
raise ConfigError(f'Only one network can be specified for container "{name}"!')
@@ -257,6 +263,7 @@ def verify(container):
def generate_run_arguments(name, container_config):
image = container_config['image']
+ cpu_quota = container_config['cpu_quota']
memory = container_config['memory']
shared_memory = container_config['shared_memory']
restart = container_config['restart']
@@ -333,7 +340,7 @@ def generate_run_arguments(name, container_config):
if 'allow_host_pid' in container_config:
host_pid = '--pid host'
- container_base_cmd = f'--detach --interactive --tty --replace {capabilities} ' \
+ container_base_cmd = f'--detach --interactive --tty --replace {capabilities} --cpus {cpu_quota} ' \
f'--memory {memory}m --shm-size {shared_memory}m --memory-swap 0 --restart {restart} ' \
f'--name {name} {hostname} {device} {port} {volume} {env_opt} {label} {uid} {host_pid}'
diff --git a/src/services/vyos-http-api-server b/src/services/vyos-http-api-server
index ecbf6fcf9..7f5233c6b 100755
--- a/src/services/vyos-http-api-server
+++ b/src/services/vyos-http-api-server
@@ -23,16 +23,17 @@ import logging
import signal
import traceback
import threading
+from enum import Enum
from time import sleep
-from typing import List, Union, Callable, Dict
+from typing import List, Union, Callable, Dict, Self
from fastapi import FastAPI, Depends, Request, Response, HTTPException
from fastapi import BackgroundTasks
from fastapi.responses import HTMLResponse
from fastapi.exceptions import RequestValidationError
from fastapi.routing import APIRoute
-from pydantic import BaseModel, StrictStr, validator
+from pydantic import BaseModel, StrictStr, validator, model_validator
from starlette.middleware.cors import CORSMiddleware
from starlette.datastructures import FormData
from starlette.formparsers import FormParser, MultiPartParser
@@ -177,16 +178,35 @@ class ConfigFileModel(ApiModel):
}
}
+
+class ImageOp(str, Enum):
+ add = "add"
+ delete = "delete"
+ show = "show"
+ set_default = "set_default"
+
+
class ImageModel(ApiModel):
- op: StrictStr
+ op: ImageOp
url: StrictStr = None
name: StrictStr = None
+ @model_validator(mode='after')
+ def check_data(self) -> Self:
+ if self.op == 'add':
+ if not self.url:
+ raise ValueError("Missing required field \"url\"")
+ elif self.op in ['delete', 'set_default']:
+ if not self.name:
+ raise ValueError("Missing required field \"name\"")
+
+ return self
+
class Config:
schema_extra = {
"example": {
"key": "id_key",
- "op": "add | delete",
+ "op": "add | delete | show | set_default",
"url": "imagelocation",
"name": "imagename",
}
@@ -668,19 +688,13 @@ def image_op(data: ImageModel):
try:
if op == 'add':
- if data.url:
- url = data.url
- else:
- return error(400, "Missing required field \"url\"")
- res = session.install_image(url)
+ res = session.install_image(data.url)
elif op == 'delete':
- if data.name:
- name = data.name
- else:
- return error(400, "Missing required field \"name\"")
- res = session.remove_image(name)
- else:
- return error(400, f"'{op}' is not a valid operation")
+ res = session.remove_image(data.name)
+ elif op == 'show':
+ res = session.show(["system", "image"])
+ elif op == 'set_default':
+ res = session.set_default_image(data.name)
except ConfigSessionError as e:
return error(400, str(e))
except Exception as e: