19 Commits

Author SHA1 Message Date
1f4a4d2184 Revert "Use SELinux mount flag for secrets"
This reverts commit 874192568f.
2023-04-10 12:26:42 +03:00
08a453d643 Revert "Use more lenient SELinux mount flag for secrets"
This reverts commit 75de39c239.
2023-04-10 12:26:42 +03:00
75de39c239 Use more lenient SELinux mount flag for secrets
Signed-off-by: Henry Reed <60915078+henryreed@users.noreply.github.com>
2023-04-10 12:25:53 +03:00
874192568f Use SELinux mount flag for secrets
Signed-off-by: Henry Reed <github.69ofd@simplelogin.com>
2023-04-10 12:25:53 +03:00
0b853f29f4 Ignore access mode when merging volumes short syntax
The target path inside the container is treated as a key. Ref:
https://github.com/compose-spec/compose-spec/blob/master/spec.md#merging-service-definitions

Signed-off-by: Bhavin Gandhi <bhavin7392@gmail.com>
2023-04-10 12:25:05 +03:00
847f01a6c6 Add a docker-compose test file for uidmaps/gidmaps
Add a simple docker-compose.yml test to use the x-podman extension with
uidmaps and gitmaps
2023-04-10 12:22:25 +03:00
e511e6420f FIXES #228: Add support for uidmap and gidmap
Implement an x-podman extension on the level of the individual services
to handle `--uidmap` and `--gidmap`
2023-04-10 12:22:25 +03:00
a9723ec1cf Added a way to start containers with multiple ips and nets
Signed-off-by: KuhnChris <kuhnchris@kuhnchris.eu>
2023-04-10 12:16:54 +03:00
1cb608d8a7 allow project name to be fetched from dotenv
Look for project name in `self.environ` which includes both `os.environ`
and dotenv variables so that the project name can also be defined in an
environment file.

Signed-off-by: Kuan-Yi Li <kyli@abysm.org>
2023-04-10 12:13:23 +03:00
252f1d57a5 updating black formatting for podman-compose.py
Signed-off-by: Dixon Whitmire <dixonwh@gmail.com>
2023-04-10 12:12:18 +03:00
13856d2e9c updating black formatting
Signed-off-by: Dixon Whitmire <dixonwh@gmail.com>
2023-04-10 12:12:18 +03:00
8d8df0bc28 Adding basic support for --profile argument
Signed-off-by: Dixon Whitmire <dixonwh@gmail.com>
2023-04-10 12:12:18 +03:00
bc5f0123d9 add option to start podman in existing network namespace 2023-04-10 12:11:02 +03:00
9a08f85ffd FIXES #586: preserve exit code for podman-compose build
Signed-off-by: Roman Blanco <rblanco@redhat.com>
2023-04-10 12:10:16 +03:00
8625d7a4e8 add ipam-driver support
Signed-off-by: Benedikt Braunger <bb@emlix.com>
2023-04-10 12:02:47 +03:00
016c97fd1e Fixes #663 - Fixes linting/pylint errors
Signed-off-by: BugFest <bugfest.dev@pm.me>
2023-04-10 11:53:47 +03:00
2df11674c4 Fixes #661 - Fixes linting/flake8 errors
Signed-off-by: BugFest <bugfest.dev@pm.me>
2023-04-10 11:53:47 +03:00
5eff38e743 Fixes #659: fix permissions when installing OS packages for linting/black
Signed-off-by: BugFest <bugfest.dev@pm.me>
2023-04-10 11:28:07 +03:00
7f5ce26b1b start version 1.0.7 and default with pod enabled by default 2023-04-09 14:08:54 +03:00
18 changed files with 409 additions and 46 deletions

View File

@ -11,8 +11,8 @@ jobs:
- uses: actions/checkout@v3
- name: Install psf/black requirements
run: |
apt-get update
apt-get install -y python3 python3-venv
sudo apt-get update
sudo apt-get install -y python3 python3-venv
- uses: psf/black@stable
with:
options: "--check --verbose"

View File

@ -7,6 +7,6 @@ RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD [ "python", "-m", "App.web" ]
CMD [ "python", "-m", "app.web" ]
EXPOSE 8080

View File

@ -1,5 +1,7 @@
# pylint: disable=import-error
# pylint: disable=unused-import
import os
import asyncio
import asyncio # noqa: F401
import aioredis
from aiohttp import web
@ -14,13 +16,13 @@ routes = web.RouteTableDef()
@routes.get("/")
async def hello(request):
async def hello(request): # pylint: disable=unused-argument
counter = await redis.incr("mycounter")
return web.Response(text=f"counter={counter}")
@routes.get("/hello.json")
async def hello_json(request):
async def hello_json(request): # pylint: disable=unused-argument
counter = await redis.incr("mycounter")
data = {"counter": counter}
return web.json_response(data)

View File

@ -30,7 +30,7 @@ import shlex
try:
from shlex import quote as cmd_quote
except ImportError:
from pipes import quote as cmd_quote
from pipes import quote as cmd_quote # pylint: disable=deprecated-module
# import fnmatch
# fnmatch.fnmatchcase(env, "*_HOST")
@ -38,16 +38,32 @@ except ImportError:
import yaml
from dotenv import dotenv_values
__version__ = "1.0.6"
__version__ = "1.0.7"
script = os.path.realpath(sys.argv[0])
# helper functions
is_str = lambda s: isinstance(s, str)
is_dict = lambda d: isinstance(d, dict)
is_list = lambda l: not is_str(l) and not is_dict(l) and hasattr(l, "__iter__")
def is_str(string_object):
return isinstance(string_object, str)
def is_dict(dict_object):
return isinstance(dict_object, dict)
def is_list(list_object):
return (
not is_str(list_object)
and not is_dict(list_object)
and hasattr(list_object, "__iter__")
)
# identity filter
filteri = lambda a: filter(lambda i: i, a)
def filteri(a):
return filter(lambda i: i, a)
def try_int(i, fallback=None):
@ -730,7 +746,7 @@ def assert_cnt_nets(compose, cnt):
"--label",
f"com.docker.compose.project={proj_name}",
]
# TODO: add more options here, like driver, internal, ..etc
# TODO: add more options here, like dns, ipv6, etc.
labels = net_desc.get("labels", None) or []
for item in norm_as_list(labels):
args.extend(["--label", item])
@ -742,15 +758,17 @@ def assert_cnt_nets(compose, cnt):
driver_opts = net_desc.get("driver_opts", None) or {}
for key, value in driver_opts.items():
args.extend(("--opt", f"{key}={value}"))
ipam_config_ls = (net_desc.get("ipam", None) or {}).get(
"config", None
) or []
ipam = (net_desc.get("ipam", None) or {})
ipam_driver = ipam.get("driver", None)
if ipam_driver:
args.extend(("--ipam-driver", ipam_driver))
ipam_config_ls = ipam.get("config", None) or []
if is_dict(ipam_config_ls):
ipam_config_ls = [ipam_config_ls]
for ipam in ipam_config_ls:
subnet = ipam.get("subnet", None)
ip_range = ipam.get("ip_range", None)
gateway = ipam.get("gateway", None)
for ipam_config in ipam_config_ls:
subnet = ipam_config.get("subnet", None)
ip_range = ipam_config.get("ip_range", None)
gateway = ipam_config.get("gateway", None)
if subnet:
args.extend(("--subnet", subnet))
if ip_range:
@ -777,6 +795,8 @@ def get_net_args(compose, cnt):
net_args.extend(["--network", net])
elif net.startswith("slirp4netns:"):
net_args.extend(["--network", net])
elif net.startswith("ns:"):
net_args.extend(["--network", net])
elif net.startswith("service:"):
other_srv = net.split(":", 1)[1].strip()
other_cnt = compose.container_names_by_service[other_srv][0]
@ -797,15 +817,23 @@ def get_net_args(compose, cnt):
cnt_nets = cnt.get("networks", None)
aliases = [service_name]
# NOTE: from podman manpage:
# NOTE: A container will only have access to aliases on the first network that it joins. This is a limitation that will be removed in a later release.
# NOTE: A container will only have access to aliases on the first network
# that it joins. This is a limitation that will be removed in a later
# release.
ip = None
ip6 = None
ip_assignments = 0
if cnt_nets and is_dict(cnt_nets):
prioritized_cnt_nets = []
# cnt_nets is {net_key: net_value, ...}
for net_key, net_value in cnt_nets.items():
net_value = net_value or {}
aliases.extend(norm_as_list(net_value.get("aliases", None)))
if net_value.get("ipv4_address", None) != None:
ip_assignments = ip_assignments + 1
if net_value.get("ipv6_address", None) != None:
ip_assignments = ip_assignments + 1
if not ip:
ip = net_value.get("ipv4_address", None)
if not ip6:
@ -832,12 +860,35 @@ def get_net_args(compose, cnt):
)
net_names.append(net_name)
net_names_str = ",".join(net_names)
if is_bridge:
net_args.extend(["--net", net_names_str, "--network-alias", ",".join(aliases)])
if ip:
net_args.append(f"--ip={ip}")
if ip6:
net_args.append(f"--ip6={ip6}")
if ip_assignments > 1:
multipleNets = cnt.get("networks", None)
multipleNetNames = multipleNets.keys()
for net_ in multipleNetNames:
net_desc = nets[net_] or {}
is_ext = net_desc.get("external", None)
ext_desc = is_ext if is_dict(is_ext) else {}
default_net_name = net_ if is_ext else f"{proj_name}_{net_}"
net_name = (
ext_desc.get("name", None) or net_desc.get("name", None) or default_net_name
)
ipv4 = multipleNets[net_].get("ipv4_address",None)
ipv6 = multipleNets[net_].get("ipv6_address",None)
if ipv4 is not None and ipv6 is not None:
net_args.extend(["--network", f"{net_name}:ip={ipv4},ip={ipv6}"])
elif ipv4 is None and ipv6 is not None:
net_args.extend(["--network", f"{net_name}:ip={ipv6}"])
elif ipv6 is None and ipv4 is not None:
net_args.extend(["--network", f"{net_name}:ip={ipv4}"])
else:
if is_bridge:
net_args.extend(["--net", net_names_str, "--network-alias", ",".join(aliases)])
if ip:
net_args.append(f"--ip={ip}")
if ip6:
net_args.append(f"--ip6={ip6}")
return net_args
@ -1019,6 +1070,14 @@ def container_to_args(compose, cnt, detached=True):
if "retries" in healthcheck:
podman_args.extend(["--healthcheck-retries", str(healthcheck["retries"])])
# handle podman extension
x_podman = cnt.get("x-podman", None)
if x_podman is not None:
for uidmap in x_podman.get("uidmaps", []):
podman_args.extend(["--uidmap", uidmap])
for gidmap in x_podman.get("gidmaps", []):
podman_args.extend(["--gidmap", gidmap])
podman_args.append(cnt["image"]) # command, ..etc.
command = cnt.get("command", None)
if command is not None:
@ -1127,7 +1186,11 @@ class Podman:
log(" ".join([str(i) for i in cmd_ls]))
if self.dry_run:
return None
# subprocess.Popen(args, bufsize = 0, executable = None, stdin = None, stdout = None, stderr = None, preexec_fn = None, close_fds = False, shell = False, cwd = None, env = None, universal_newlines = False, startupinfo = None, creationflags = 0)
# subprocess.Popen(
# args, bufsize = 0, executable = None, stdin = None, stdout = None, stderr = None, preexec_fn = None,
# close_fds = False, shell = False, cwd = None, env = None, universal_newlines = False, startupinfo = None,
# creationflags = 0
# )
if log_formatter is not None:
# Pipe podman process output through log_formatter (which can add colored prefix)
p = subprocess.Popen(
@ -1252,11 +1315,11 @@ def rec_merge_one(target, source):
if is_list(value2):
if key == "volumes":
# clean duplicate mount targets
pts = {v.split(":", 1)[1] for v in value2 if ":" in v}
pts = {v.split(":", 2)[1] for v in value2 if ":" in v}
del_ls = [
ix
for (ix, v) in enumerate(value)
if ":" in v and v.split(":", 1)[1] in pts
if ":" in v and v.split(":", 2)[1] in pts
]
for ix in reversed(del_ls):
del value[ix]
@ -1497,6 +1560,10 @@ class PodmanCompose:
# log(filename, json.dumps(content, indent = 2))
content = rec_subs(content, self.environ)
rec_merge(compose, content)
resolved_services = self._resolve_profiles(
compose.get("services", {}), set(args.profile)
)
compose["services"] = resolved_services
self.merged_yaml = yaml.safe_dump(compose)
merged_json_b = json.dumps(compose, separators=(",", ":")).encode("utf-8")
self.yaml_hash = hashlib.sha256(merged_json_b).hexdigest()
@ -1511,7 +1578,7 @@ class PodmanCompose:
if project_name is None:
# More strict then actually needed for simplicity: podman requires [a-zA-Z0-9][a-zA-Z0-9_.-]*
project_name = (
os.environ.get("COMPOSE_PROJECT_NAME", None) or dir_basename.lower()
self.environ.get("COMPOSE_PROJECT_NAME", None) or dir_basename.lower()
)
project_name = norm_re.sub("", project_name)
if not project_name:
@ -1526,6 +1593,8 @@ class PodmanCompose:
if services is None:
services = {}
log("WARNING: No services defined")
# include services with no profile defined or the selected profiles
services = self._resolve_profiles(services, set(args.profile))
# NOTE: maybe add "extends.service" to _deps at this stage
flat_deps(services, with_extends=True)
@ -1640,6 +1709,30 @@ class PodmanCompose:
self.containers = containers
self.container_by_name = {c["name"]: c for c in containers}
def _resolve_profiles(self, defined_services, requested_profiles=None):
"""
Returns a service dictionary (key = service name, value = service config) compatible with the requested_profiles
list.
The returned service dictionary contains all services which do not include/reference a profile in addition to
services that match the requested_profiles.
:param defined_services: The service dictionary
:param requested_profiles: The profiles requested using the --profile arg.
"""
if requested_profiles is None:
requested_profiles = set()
services = {}
for name, config in defined_services.items():
service_profiles = set(config.get("profiles", []))
if not service_profiles or requested_profiles.intersection(
service_profiles
):
services[name] = config
return services
def _parse_args(self):
parser = argparse.ArgumentParser(formatter_class=argparse.RawTextHelpFormatter)
self._init_global_parser(parser)
@ -1667,7 +1760,7 @@ class PodmanCompose:
help="pod creation",
metavar="in_pod",
type=bool,
default=False,
default=True,
)
parser.add_argument(
"--pod-args",
@ -1691,6 +1784,13 @@ class PodmanCompose:
action="append",
default=[],
)
parser.add_argument(
"--profile",
help="Specify a profile to enable",
metavar="profile",
action="append",
default=[],
)
parser.add_argument(
"-p",
"--project-name",
@ -1799,7 +1899,7 @@ def is_local(container: dict) -> bool:
* has a build section and is not prefixed
"""
return (
not "/" in container["image"]
"/" not in container["image"]
if "build" in container
else container["image"].startswith("localhost/")
)
@ -1978,7 +2078,8 @@ def build_one(compose, args, cnt):
)
)
build_args.append(ctx)
compose.podman.run([], "build", build_args, sleep=0)
status = compose.podman.run([], "build", build_args, sleep=0)
return status
@cmd_run(podman_compose, "build", "build stack images")
@ -1988,10 +2089,12 @@ def compose_build(compose, args):
compose.assert_services(args.services)
for service in args.services:
cnt = compose.container_by_name[container_names_by_service[service][0]]
build_one(compose, args, cnt)
p = build_one(compose, args, cnt)
exit(p.returncode)
else:
for cnt in compose.containers:
build_one(compose, args, cnt)
p = build_one(compose, args, cnt)
exit(p.returncode)
def create_pods(compose, args): # pylint: disable=unused-argument
@ -2522,7 +2625,8 @@ def compose_up_parse(parser):
"-d",
"--detach",
action="store_true",
help="Detached mode: Run container in the background, print new container name. Incompatible with --abort-on-container-exit.",
help="Detached mode: Run container in the background, print new container name. \
Incompatible with --abort-on-container-exit.",
)
parser.add_argument(
"--no-color", action="store_true", help="Produce monochrome output."
@ -2573,7 +2677,8 @@ def compose_up_parse(parser):
"--timeout",
type=int,
default=None,
help="Use this timeout in seconds for container shutdown when attached or when containers are already running. (default: 10)",
help="Use this timeout in seconds for container shutdown when attached or when containers are already running. \
(default: 10)",
)
parser.add_argument(
"-V",

View File

@ -1,3 +1,4 @@
# pylint: disable=redefined-outer-name
import pytest
from podman_compose import parse_short_mount

View File

@ -3,3 +3,7 @@ universal = 1
[metadata]
version = attr: podman_compose.__version__
[flake8]
# The GitHub editor is 127 chars wide
max-line-length=127

View File

@ -2,14 +2,16 @@ import os
from setuptools import setup
try:
readme = open(os.path.join(os.path.dirname(__file__), "README.md")).read()
except:
readme = ""
README = open(
os.path.join(os.path.dirname(__file__), "README.md"), encoding="utf-8"
).read()
except: # noqa: E722 # pylint: disable=bare-except
README = ""
setup(
name="podman-compose",
description="A script to run docker-compose.yml using podman",
long_description=readme,
long_description=README,
long_description_content_type="text/markdown",
classifiers=[
"Programming Language :: Python",

25
tests/conftest.py Normal file
View File

@ -0,0 +1,25 @@
"""conftest.py
Defines global pytest fixtures available to all tests.
"""
import pytest
from pathlib import Path
import os
@pytest.fixture
def base_path():
"""Returns the base path for the project"""
return Path(__file__).parent.parent
@pytest.fixture
def test_path(base_path):
"""Returns the path to the tests directory"""
return os.path.join(base_path, "tests")
@pytest.fixture
def podman_compose_path(base_path):
"""Returns the path to the podman compose script"""
return os.path.join(base_path, "podman_compose.py")

View File

@ -0,0 +1,24 @@
version: "3"
services:
default-service:
image: busybox
command: ["/bin/busybox", "httpd", "-f", "-h", "/etc/", "-p", "8000"]
tmpfs:
- /run
- /tmp
service-1:
image: busybox
command: ["/bin/busybox", "httpd", "-f", "-h", "/etc/", "-p", "8000"]
tmpfs:
- /run
- /tmp
profiles:
- profile-1
service-2:
image: busybox
command: ["/bin/busybox", "httpd", "-f", "-h", "/etc/", "-p", "8000"]
tmpfs:
- /run
- /tmp
profiles:
- profile-2

View File

@ -46,16 +46,16 @@ def test_podman_compose_extends_w_file_subdir():
"docker.io/library/busybox",
]
out, err, returncode = capture(command_up)
out, _, returncode = capture(command_up)
assert 0 == returncode
# check container was created and exists
out, err, returncode = capture(command_check_container)
out, _, returncode = capture(command_check_container)
assert 0 == returncode
assert out == b'"localhost/subdir_test:me"\n'
out, err, returncode = capture(command_down)
out, _, returncode = capture(command_down)
# cleanup test image(tags)
assert 0 == returncode
# check container did not exists anymore
out, err, returncode = capture(command_check_container)
out, _, returncode = capture(command_check_container)
assert 0 == returncode
assert out == b""

View File

@ -0,0 +1,77 @@
"""
test_podman_compose_config.py
Tests the podman-compose config command which is used to return defined compose services.
"""
import pytest
import os
from test_podman_compose import capture
@pytest.fixture
def profile_compose_file(test_path):
""" "Returns the path to the `profile` compose file used for this test module"""
return os.path.join(test_path, "profile", "docker-compose.yml")
def test_config_no_profiles(podman_compose_path, profile_compose_file):
"""
Tests podman-compose config command without profile enablement.
:param podman_compose_path: The fixture used to specify the path to the podman compose file.
:param profile_compose_file: The fixtued used to specify the path to the "profile" compose used in the test.
"""
config_cmd = ["python3", podman_compose_path, "-f", profile_compose_file, "config"]
out, err, return_code = capture(config_cmd)
assert return_code == 0
string_output = out.decode("utf-8")
assert "default-service" in string_output
assert "service-1" not in string_output
assert "service-2" not in string_output
@pytest.mark.parametrize(
"profiles, expected_services",
[
(
["--profile", "profile-1", "config"],
{"default-service": True, "service-1": True, "service-2": False},
),
(
["--profile", "profile-2", "config"],
{"default-service": True, "service-1": False, "service-2": True},
),
(
["--profile", "profile-1", "--profile", "profile-2", "config"],
{"default-service": True, "service-1": True, "service-2": True},
),
],
)
def test_config_profiles(
podman_compose_path, profile_compose_file, profiles, expected_services
):
"""
Tests podman-compose
:param podman_compose_path: The fixture used to specify the path to the podman compose file.
:param profile_compose_file: The fixtued used to specify the path to the "profile" compose used in the test.
:param profiles: The enabled profiles for the parameterized test.
:param expected_services: Dictionary used to model the expected "enabled" services in the profile.
Key = service name, Value = True if the service is enabled, otherwise False.
"""
config_cmd = ["python3", podman_compose_path, "-f", profile_compose_file]
config_cmd.extend(profiles)
out, err, return_code = capture(config_cmd)
assert return_code == 0
actual_output = out.decode("utf-8")
assert len(expected_services) == 3
actual_services = {}
for service, expected_check in expected_services.items():
actual_services[service] = service in actual_output
assert expected_services == actual_services

View File

@ -0,0 +1,88 @@
"""
test_podman_compose_up_down.py
Tests the podman compose up and down commands used to create and remove services.
"""
import pytest
import os
from test_podman_compose import capture
@pytest.fixture
def profile_compose_file(test_path):
""" "Returns the path to the `profile` compose file used for this test module"""
return os.path.join(test_path, "profile", "docker-compose.yml")
@pytest.fixture(autouse=True)
def teardown(podman_compose_path, profile_compose_file):
"""
Ensures that the services within the "profile compose file" are removed between each test case.
:param podman_compose_path: The path to the podman compose script.
:param profile_compose_file: The path to the compose file used for this test module.
"""
# run the test case
yield
down_cmd = [
"python3",
podman_compose_path,
"--profile",
"profile-1",
"--profile",
"profile-2",
"-f",
profile_compose_file,
"down",
]
capture(down_cmd)
@pytest.mark.parametrize(
"profiles, expected_services",
[
(
["--profile", "profile-1", "up", "-d"],
{"default-service": True, "service-1": True, "service-2": False},
),
(
["--profile", "profile-2", "up", "-d"],
{"default-service": True, "service-1": False, "service-2": True},
),
(
["--profile", "profile-1", "--profile", "profile-2", "up", "-d"],
{"default-service": True, "service-1": True, "service-2": True},
),
],
)
def test_up(podman_compose_path, profile_compose_file, profiles, expected_services):
up_cmd = [
"python3",
podman_compose_path,
"-f",
profile_compose_file,
]
up_cmd.extend(profiles)
out, err, return_code = capture(up_cmd)
assert return_code == 0
check_cmd = [
"podman",
"container",
"ps",
"--format",
'"{{.Names}}"',
]
out, err, return_code = capture(check_cmd)
assert return_code == 0
assert len(expected_services) == 3
actual_output = out.decode("utf-8")
actual_services = {}
for service, expected_check in expected_services.items():
actual_services[service] = service in actual_output
assert expected_services == actual_services

View File

@ -0,0 +1,15 @@
version: "3.7"
services:
touch:
image: busybox
command: 'touch /mnt/test'
volumes:
- ./:/mnt
user: 999:999
x-podman:
uidmaps:
- "0:1:1"
- "999:0:1"
gidmaps:
- "0:1:1"
- "999:0:1"

View File

@ -0,0 +1,7 @@
version: "3"
services:
web:
volumes:
- ./override.txt:/var/www/html/index.html:ro,z
- ./override.txt:/var/www/html/index2.html:z
- ./override.txt:/var/www/html/index3.html

View File

@ -0,0 +1,11 @@
version: "3"
services:
web:
image: busybox
command: ["/bin/busybox", "httpd", "-f", "-h", "/var/www/html", "-p", "8080"]
ports:
- 8080:8080
volumes:
- ./index.txt:/var/www/html/index.html:ro,z
- ./index.txt:/var/www/html/index2.html
- ./index.txt:/var/www/html/index3.html:ro

View File

@ -0,0 +1 @@
The file from docker-compose.yaml

View File

@ -0,0 +1 @@
The file from docker-compose.override.yaml