Skip to content

Commit a21363b

Browse files
committed
CI Channel Deploy
Signed-off-by: Mitch Gaffigan <mitch.gaffigan@comcast.net>
1 parent 4260fad commit a21363b

9 files changed

Lines changed: 563 additions & 171 deletions

File tree

ci/runner/Dockerfile

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
11
FROM docker:27-cli
22

3-
RUN apk add --no-cache bash curl python3
4-
RUN ln -sf /usr/bin/python3 /usr/bin/python
3+
RUN apk add --no-cache bash curl python3 py3-lxml
54

65
WORKDIR /app
7-
COPY run.py /app/run.py
6+
COPY *.py /app/
87

98
ENTRYPOINT ["python3", "/app/run.py"]

ci/runner/api.py

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import ssl
2+
import urllib.error
3+
import urllib.parse
4+
import urllib.request
5+
from http.cookiejar import CookieJar
6+
7+
REQUESTED_WITH_HEADER = "OpenIntegrationEngine-CI"
8+
9+
10+
class ApiClient:
11+
def __init__(self, base_url: str):
12+
self.base_url = base_url.rstrip("/")
13+
self.opener = build_opener()
14+
15+
def request(
16+
self,
17+
path: str,
18+
method: str = "GET",
19+
data: bytes | None = None,
20+
content_type: str | None = None,
21+
accept: str = "application/json",
22+
timeout: int = 15,
23+
) -> tuple[int, str]:
24+
headers = {
25+
"Accept": accept,
26+
"X-Requested-With": REQUESTED_WITH_HEADER,
27+
}
28+
if content_type is not None:
29+
headers["Content-Type"] = content_type
30+
31+
request = urllib.request.Request(
32+
f"{self.base_url}{path}",
33+
data=data,
34+
method=method,
35+
headers=headers,
36+
)
37+
38+
try:
39+
with self.opener.open(request, timeout=timeout) as response:
40+
body = response.read().decode("utf-8", errors="replace")
41+
return response.status, body
42+
except urllib.error.HTTPError as error:
43+
body = error.read().decode("utf-8", errors="replace")
44+
raise RuntimeError(f"HTTP {error.code} for {method} {path}: {body}") from error
45+
except urllib.error.URLError as error:
46+
raise RuntimeError(f"Request failed for {method} {path}: {error}") from error
47+
48+
def create_channel(self, channel_xml: bytes) -> None:
49+
self.request(
50+
"/api/channels/",
51+
method="POST",
52+
data=channel_xml,
53+
content_type="application/xml",
54+
accept="application/json",
55+
)
56+
57+
def deploy_channel(self, channel_id: str) -> None:
58+
self.request(f"/api/channels/{channel_id}/_deploy", method="POST")
59+
60+
def undeploy_channel(self, channel_id: str) -> None:
61+
self.request(f"/api/channels/{channel_id}/_undeploy", method="POST")
62+
63+
def remove_channel(self, channel_id: str) -> None:
64+
self.request(f"/api/channels/{channel_id}", method="DELETE")
65+
66+
67+
def build_opener() -> urllib.request.OpenerDirector:
68+
cookie_jar = CookieJar()
69+
ssl_context = ssl.create_default_context()
70+
ssl_context.check_hostname = False
71+
ssl_context.verify_mode = ssl.CERT_NONE
72+
https_handler = urllib.request.HTTPSHandler(context=ssl_context)
73+
return urllib.request.build_opener(https_handler, urllib.request.HTTPCookieProcessor(cookie_jar))
74+
75+
76+
def login_or_fail(base_url: str, username: str, password: str, timeout: int) -> ApiClient:
77+
client = ApiClient(base_url)
78+
payload = urllib.parse.urlencode({"username": username, "password": password}).encode("utf-8")
79+
status, body = client.request(
80+
"/api/users/_login",
81+
method="POST",
82+
data=payload,
83+
content_type="application/x-www-form-urlencoded",
84+
timeout=min(timeout, 15),
85+
)
86+
if status == 200 and "SUCCESS" in body:
87+
print("Authenticated successfully.", flush=True)
88+
return client
89+
raise RuntimeError(f"Unexpected login response: HTTP {status} body={body}")

ci/runner/channeltests.py

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
from dataclasses import dataclass
2+
import importlib
3+
from pathlib import Path
4+
import sys
5+
6+
from api import ApiClient
7+
8+
9+
@dataclass(frozen=True)
10+
class ChannelFixture:
11+
test_dir: Path
12+
channel_dir: Path
13+
channel_file: Path
14+
channel_id: str
15+
channel_name: str
16+
17+
18+
@dataclass(frozen=True)
19+
class ProvisionedChannel:
20+
fixture: ChannelFixture
21+
channel_id: str
22+
23+
24+
def resolve_tests_root(workspace: str, tests_root: str) -> Path:
25+
candidate = Path(tests_root)
26+
if candidate.is_absolute():
27+
return candidate
28+
return Path(workspace) / candidate
29+
30+
31+
def parse_channel_fixture(channel_file: Path) -> ChannelFixture:
32+
channel_id = parse_channel_id(channel_file)
33+
return ChannelFixture(
34+
test_dir=channel_file.parents[2],
35+
channel_dir=channel_file.parent,
36+
channel_file=channel_file,
37+
channel_id=channel_id,
38+
channel_name=channel_file.parent.name,
39+
)
40+
41+
42+
def parse_channel_id(channel_file: Path) -> str:
43+
etree = importlib.import_module("lxml.etree")
44+
document = etree.parse(str(channel_file))
45+
channel_id = document.getroot().findtext("id")
46+
if channel_id is None or not channel_id.strip():
47+
raise RuntimeError(f"Channel fixture is missing id: {channel_file}")
48+
return channel_id.strip()
49+
50+
51+
def test_runs_for_configuration(test_dir: Path, configuration: str) -> bool:
52+
configurations_file = test_dir / "configurations"
53+
if not configurations_file.exists():
54+
return True
55+
56+
configurations = [line.strip() for line in configurations_file.read_text(encoding="utf-8").splitlines() if line.strip()]
57+
return configuration in configurations
58+
59+
60+
def discover_channels(tests_root: Path, configuration: str) -> list[ChannelFixture]:
61+
if not tests_root.exists():
62+
return []
63+
64+
fixtures: list[ChannelFixture] = []
65+
for channel_file in sorted(tests_root.glob("**/channels/*/channel.xml")):
66+
fixture = parse_channel_fixture(channel_file)
67+
if test_runs_for_configuration(fixture.test_dir, configuration):
68+
fixtures.append(fixture)
69+
return fixtures
70+
71+
72+
def deploy_channel_tests(client: ApiClient, channel_fixtures: list[ChannelFixture]) -> list[ProvisionedChannel]:
73+
provisioned_channels: list[ProvisionedChannel] = []
74+
75+
for fixture in channel_fixtures:
76+
print(f"Creating channel {fixture.channel_name} from {fixture.channel_file}", flush=True)
77+
client.create_channel(fixture.channel_file.read_bytes())
78+
print(f"Deploying channel {fixture.channel_name} ({fixture.channel_id})", flush=True)
79+
client.deploy_channel(fixture.channel_id)
80+
provisioned_channels.append(ProvisionedChannel(fixture=fixture, channel_id=fixture.channel_id))
81+
82+
return provisioned_channels
83+
84+
85+
def cleanup_channel_tests(client: ApiClient, provisioned_channels: list[ProvisionedChannel]) -> None:
86+
for provisioned in reversed(provisioned_channels):
87+
safe_cleanup_channel("undeploy", undeploy_channel, client, provisioned)
88+
89+
for provisioned in reversed(provisioned_channels):
90+
safe_cleanup_channel("remove", remove_channel, client, provisioned)
91+
92+
93+
def undeploy_channel(client: ApiClient, provisioned: ProvisionedChannel) -> None:
94+
print(f"Undeploying channel {provisioned.fixture.channel_name} ({provisioned.channel_id})", flush=True)
95+
client.undeploy_channel(provisioned.channel_id)
96+
97+
98+
def remove_channel(client: ApiClient, provisioned: ProvisionedChannel) -> None:
99+
print(f"Removing channel {provisioned.fixture.channel_name} ({provisioned.channel_id})", flush=True)
100+
client.remove_channel(provisioned.channel_id)
101+
102+
103+
def safe_cleanup_channel(action: str, cleanup, client: ApiClient, provisioned: ProvisionedChannel) -> None:
104+
try:
105+
cleanup(client, provisioned)
106+
except Exception as error:
107+
print(f"Ignoring {action} failure for {provisioned.fixture.channel_name}: {error}", file=sys.stderr, flush=True)

ci/runner/compose.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import subprocess
2+
import uuid
3+
from pathlib import Path
4+
5+
6+
def sanitize_project_name(name: str) -> str:
7+
filtered = "".join(character if character.isalnum() else "-" for character in name.lower())
8+
filtered = filtered.strip("-") or "oie-ci"
9+
return f"oie-ci-{filtered}-{uuid.uuid4().hex[:8]}"
10+
11+
12+
def run_command(command: list[str], env: dict[str, str]) -> subprocess.CompletedProcess:
13+
print(f"+ {' '.join(command)}", flush=True)
14+
return subprocess.run(command, env=env, check=False)
15+
16+
17+
def compose_up(compose_file: Path, project_name: str, env: dict[str, str], timeout: int) -> None:
18+
command = [
19+
"docker",
20+
"compose",
21+
"-f",
22+
str(compose_file),
23+
"-p",
24+
project_name,
25+
"up",
26+
"-d",
27+
"--wait",
28+
"--wait-timeout",
29+
str(timeout),
30+
]
31+
result = run_command(command, env)
32+
if result.returncode != 0:
33+
raise RuntimeError("docker compose up failed")
34+
35+
36+
def compose_down(compose_file: Path, project_name: str, env: dict[str, str]) -> None:
37+
command = [
38+
"docker",
39+
"compose",
40+
"-f",
41+
str(compose_file),
42+
"-p",
43+
project_name,
44+
"down",
45+
"-v",
46+
"--remove-orphans",
47+
]
48+
result = run_command(command, env)
49+
if result.returncode != 0:
50+
raise RuntimeError("docker compose down failed")

ci/runner/main.py

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import argparse
2+
import json
3+
import os
4+
import sys
5+
from pathlib import Path
6+
7+
from api import login_or_fail
8+
from channeltests import (
9+
ChannelFixture,
10+
cleanup_channel_tests,
11+
deploy_channel_tests,
12+
discover_channels,
13+
resolve_tests_root,
14+
)
15+
from compose import compose_down, compose_up, sanitize_project_name
16+
17+
DEFAULT_TIMEOUT_SECONDS = 600
18+
DEFAULT_BASE_URL = "https://host.docker.internal:8443"
19+
DEFAULT_USERNAME = "admin"
20+
DEFAULT_PASSWORD = "admin"
21+
DEFAULT_TESTS_ROOT = "ci/tests"
22+
23+
24+
def parse_args() -> argparse.Namespace:
25+
parser = argparse.ArgumentParser(description="Boot and tear down an OIE docker-compose test configuration.")
26+
parser.add_argument("--workspace", default="/workspace", help="Workspace root containing ci/configurations.")
27+
parser.add_argument("--configuration", help="Configuration name mapped to ci/configurations/<name>.compose.yml.")
28+
parser.add_argument("--compose-file", help="Explicit compose file path. Overrides --configuration.")
29+
parser.add_argument("--server-image", required=True, help="Server image tag to inject into the compose environment.")
30+
parser.add_argument("--base-url", default=DEFAULT_BASE_URL, help="Base URL used for readiness and login checks.")
31+
parser.add_argument("--username", default=DEFAULT_USERNAME, help="REST username.")
32+
parser.add_argument("--password", default=DEFAULT_PASSWORD, help="REST password.")
33+
parser.add_argument("--tests-root", default=DEFAULT_TESTS_ROOT, help="Root directory containing test fixtures.")
34+
parser.add_argument("--timeout", type=int, default=DEFAULT_TIMEOUT_SECONDS, help="Timeout in seconds for compose wait and login readiness.")
35+
parser.add_argument("--keep-alive", action="store_true", help="Leave the compose stack running after a successful check.")
36+
return parser.parse_args()
37+
38+
39+
def resolve_compose_file(args: argparse.Namespace) -> Path:
40+
if args.compose_file:
41+
compose_file = Path(args.compose_file)
42+
if not compose_file.is_absolute():
43+
compose_file = Path(args.workspace) / compose_file
44+
return compose_file
45+
46+
if not args.configuration:
47+
raise ValueError("Either --configuration or --compose-file must be provided.")
48+
49+
return Path(args.workspace) / "ci" / "configurations" / f"{args.configuration}.compose.yml"
50+
51+
52+
def build_run_summary(
53+
compose_file: Path,
54+
config_name: str,
55+
project_name: str,
56+
server_image: str,
57+
tests_root: Path,
58+
channel_fixtures: list[ChannelFixture],
59+
base_url: str,
60+
keep_alive: bool,
61+
) -> dict[str, object]:
62+
return {
63+
"compose_file": str(compose_file),
64+
"configuration": config_name,
65+
"project_name": project_name,
66+
"server_image": server_image,
67+
"tests_root": str(tests_root),
68+
"channels": [str(fixture.channel_file.relative_to(tests_root)) for fixture in channel_fixtures],
69+
"base_url": base_url,
70+
"keep_alive": keep_alive,
71+
}
72+
73+
74+
def main() -> int:
75+
args = parse_args()
76+
compose_file = resolve_compose_file(args)
77+
tests_root = resolve_tests_root(args.workspace, args.tests_root)
78+
if not compose_file.exists():
79+
raise FileNotFoundError(f"Compose file not found: {compose_file}")
80+
81+
config_name = args.configuration or compose_file.stem.replace(".compose", "")
82+
channel_fixtures = discover_channels(tests_root, config_name)
83+
project_name = sanitize_project_name(config_name)
84+
env = os.environ.copy()
85+
env["OIE_IMAGE"] = args.server_image
86+
87+
print(
88+
json.dumps(
89+
build_run_summary(
90+
compose_file,
91+
config_name,
92+
project_name,
93+
args.server_image,
94+
tests_root,
95+
channel_fixtures,
96+
args.base_url,
97+
args.keep_alive,
98+
),
99+
indent=2,
100+
),
101+
flush=True,
102+
)
103+
104+
compose_attempted = False
105+
client = None
106+
provisioned_channels = []
107+
teardown_error = None
108+
try:
109+
compose_attempted = True
110+
compose_up(compose_file, project_name, env, args.timeout)
111+
client = login_or_fail(args.base_url, args.username, args.password, args.timeout)
112+
provisioned_channels = deploy_channel_tests(client, channel_fixtures)
113+
114+
print(f"Configuration boot completed. Deployed {len(provisioned_channels)} channel(s).", flush=True)
115+
return 0
116+
finally:
117+
if client is not None:
118+
cleanup_channel_tests(client, provisioned_channels)
119+
120+
if compose_attempted and not args.keep_alive:
121+
try:
122+
compose_down(compose_file, project_name, env)
123+
except Exception as error:
124+
print(f"Teardown failed: {error}", file=sys.stderr, flush=True)
125+
teardown_error = error
126+
127+
if teardown_error is not None:
128+
raise RuntimeError("Compose teardown failed") from teardown_error

0 commit comments

Comments
 (0)