Skip to content

Commit 07f286a

Browse files
authored
Merge pull request #201 from mendix/mxbuild-externally
Run MxBuild outside CF Buildpack
2 parents 1564030 + 77d9248 commit 07f286a

18 files changed

+465
-135
lines changed

.github/workflows/test.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,13 @@ on:
1010

1111
jobs:
1212

13-
test-ubi8:
14-
name: Test with a ubi8 rootfs
13+
test-ubi9:
14+
name: Test with a ubi9 rootfs
1515
runs-on: ubuntu-latest
1616

1717
steps:
1818
# Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it
19-
- uses: actions/checkout@v3
19+
- uses: actions/checkout@v4
2020

2121
# Run the integration test script
2222
- name: Run integration tests

Dockerfile

Lines changed: 21 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,12 @@ COPY $BUILD_PATH /opt/mendix/build
2626
# Use nginx supplied by the base OS
2727
ENV NGINX_CUSTOM_BIN_PATH=/usr/sbin/nginx
2828

29+
# Set the user ID
30+
ARG USER_UID=1001
31+
32+
# Copy start scripts
33+
COPY scripts/startup.py scripts/vcap_application.json /opt/mendix/build/
34+
2935
# Each comment corresponds to the script line:
3036
# 1. Create cache directory and directory for dependencies which can be shared
3137
# 2. Set permissions for compilation scripts
@@ -35,44 +41,35 @@ ENV NGINX_CUSTOM_BIN_PATH=/usr/sbin/nginx
3541
# 6. Create symlink for java prefs used by CF buildpack
3642
# 7. Update ownership of /opt/mendix so that the app can run as a non-root user
3743
# 8. Update permissions of /opt/mendix so that the app can run as a non-root user
38-
RUN mkdir -p /tmp/buildcache /tmp/cf-deps /var/mendix/build /var/mendix/build/.local &&\
39-
chmod +rx /opt/mendix/buildpack/compilation.py /opt/mendix/buildpack/git /opt/mendix/buildpack/buildpack/stage.py &&\
44+
RUN mkdir -p /tmp/buildcache/bust /tmp/cf-deps /var/mendix/build /var/mendix/build/.local &&\
45+
chmod +rx /opt/mendix/buildpack/compilation.py /opt/mendix/buildpack/buildpack/stage.py /opt/mendix/build/startup.py &&\
4046
cd /opt/mendix/buildpack &&\
4147
./compilation.py /opt/mendix/build /tmp/buildcache /tmp/cf-deps 0 &&\
42-
rm -fr /tmp/buildcache /tmp/javasdk /tmp/opt /tmp/downloads /opt/mendix/buildpack/compilation.py /opt/mendix/buildpack/git &&\
48+
rm -fr /tmp/buildcache /tmp/javasdk /tmp/opt /tmp/downloads /opt/mendix/buildpack/compilation.py /var/mendix &&\
4349
ln -s /opt/mendix/.java /opt/mendix/build &&\
44-
chown -R ${USER_UID}:0 /opt/mendix /var/mendix &&\
45-
chmod -R g=u /opt/mendix /var/mendix
50+
chown -R ${USER_UID}:0 /opt/mendix &&\
51+
chmod -R g=u /opt/mendix
4652

4753
FROM ${ROOTFS_IMAGE}
4854
LABEL Author="Mendix Digital Ecosystems"
4955
LABEL maintainer="[email protected]"
5056

51-
# Set the user ID
52-
ARG USER_UID=1001
57+
# Install Ruby if Datadog is detected
58+
ARG DD_API_KEY
59+
RUN if [ ! -z "$DD_API_KEY" ] ; then\
60+
microdnf update -y && \
61+
microdnf install -y ruby && \
62+
microdnf clean all && rm -rf /var/cache/yum \
63+
; fi
64+
5365
# Set the home path
5466
ENV HOME=/opt/mendix/build
5567

5668
# Add the buildpack modules
5769
ENV PYTHONPATH "/opt/mendix/buildpack/lib/:/opt/mendix/buildpack/:/opt/mendix/buildpack/lib/python3.11/site-packages/"
5870

59-
# Copy start scripts
60-
COPY scripts/startup.py scripts/vcap_application.json /opt/mendix/build/
61-
62-
# Create vcap home directory for Datadog configuration
63-
RUN mkdir -p /home/vcap /opt/datadog-agent/run &&\
64-
chown -R ${USER_UID}:0 /home/vcap /opt/datadog-agent/run &&\
65-
chmod -R g=u /home/vcap /opt/datadog-agent/run
66-
67-
# Each comment corresponds to the script line:
68-
# 1. Make the startup script executable
69-
# 2. Update ownership of /opt/mendix so that the app can run as a non-root user
70-
# 3. Update permissions of /opt/mendix so that the app can run as a non-root user
71-
# 4. Ensure that running Java 8 as root will still be able to load offline licenses
72-
RUN chmod +rx /opt/mendix/build/startup.py &&\
73-
chown -R ${USER_UID}:0 /opt/mendix &&\
74-
chmod -R g=u /opt/mendix &&\
75-
ln -s /opt/mendix/.java /root
71+
# Set the user ID
72+
ARG USER_UID=1001
7673

7774
USER ${USER_UID}
7875

README.md

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ This project is a goto reference for the following scenarios :
4242
* Docker 20.10 (Installation [here](https://docs.docker.com/engine/installation/))
4343
* Earlier Docker versions are no longer compatible because they don't support multistage builds.
4444
To use Docker versions below 20.10, download an earlier Mendix Docker Buildpack release, such as [v2.3.2](https://github.com/mendix/docker-mendix-buildpack/releases/tag/v2.3.2)
45+
* Python 3
4546
* For preparing, a local installation of `curl`
4647
* For local testing, make sure you can run the [docker-compose command](https://docs.docker.com/compose/install/)
4748
* A Mendix app based on Mendix 8 or a later version
@@ -86,7 +87,35 @@ When building the the `rootfs-builder.dockerfile` file, you can provide the foll
8687
- **CF_BUILDPACK_URL** specifies the URL where the CF buildpack should be downloaded from (for example, a local mirror). Defaults to `https://github.com/mendix/cf-mendix-buildpack/releases/download/${CF_BUILDPACK}/cf-mendix-buildpack.zip`. Specifying **CF_BUILDPACK_URL** will override the version from **CF_BUILDPACK**.
8788
- **BUILDPACK_XTRACE** can be used to enable CF Buildpack [debug logging](https://github.com/mendix/cf-mendix-buildpack#logging-and-debugging). Set this variable to `true` to enable debug logging.
8889

89-
### Compile an app
90+
### Compile an MDA
91+
92+
If your app is a source MPK file, an MPR project directory or a compressed MDA file, it needs to be converted or compiled into a format supported by CF Buildpack - an extracted MDA file.
93+
94+
This feature is available in Docker Buildpack version v5.1.0 and later, and is intended to allow building Mendix 10 apps in custom CI/CD pipelines.
95+
96+
To do this, run:
97+
98+
```shell
99+
./build.py --source <path-to-source> --destination <destination-dir> build-mda-dir
100+
```
101+
102+
where:
103+
104+
- **--source** is the path to the project source, such as a project directory (with a source MPR project) or an MPK file.
105+
- **--destination** is a path to an empty directory where the script should output the build result. This directory will contain
106+
* a compiled, extracted MDA file - in a subdirectory called `project`.
107+
* a copy of the `Dockerfile` and the `scripts` directory.
108+
- **--artifacts-repository** - an optional repository to cache MxBuild and Mono build images, for example `quay.io/example/mxbuild-artifacts`. By enabling this option, the `build.py` script will try to use a prebuilt image from this repository if available.
109+
110+
After the `build.py` script completes, you can proceed with building the app image by running the following command (see next section for more details):
111+
112+
```shell
113+
docker build --tag mendix/mendix-buildpack:v1.2 <destination-dir>
114+
```
115+
116+
where `<destination-dir>` is the same as used when calling `build.py`.
117+
118+
### Build an image from an MDA
90119

91120
Before running the container, it is necessary to build the image with your application. This buildpack contains Dockerfile with a script that will compile your application using [cf-mendix-buildpack](https://github.com/mendix/cf-mendix-buildpack/).
92121

build.py

Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,250 @@
1+
#!/usr/bin/env python3
2+
3+
import argparse
4+
import pathlib
5+
import os
6+
import tempfile
7+
import json
8+
import sqlite3
9+
import zipfile
10+
import atexit
11+
import shutil
12+
import subprocess
13+
import sys
14+
import selectors
15+
import logging
16+
import platform
17+
18+
logging.basicConfig(
19+
level=logging.INFO,
20+
stream=sys.stdout
21+
)
22+
23+
def find_default_file(source, ext):
24+
if os.path.isfile(source):
25+
return source if source.name.endswith(ext) else None
26+
files = [x for x in os.listdir(source) if x.endswith(ext)]
27+
if len(files) == 1:
28+
return os.path.join(source, files[0])
29+
if len(files) > 1:
30+
raise Exception(f"More than one {ext} file found, can not continue")
31+
return None
32+
33+
def get_metadata_value(source_dir):
34+
file_name = os.path.join(source_dir, 'model', 'metadata.json')
35+
try:
36+
with open(file_name) as file_handle:
37+
return json.loads(file_handle.read())
38+
except IOError:
39+
return None
40+
41+
def extract_zip(mda_file):
42+
temp_dir = tempfile.TemporaryDirectory(prefix='mendix-docker-buildpack')
43+
with zipfile.ZipFile(mda_file) as zip_file:
44+
zip_file.extractall(temp_dir.name)
45+
return temp_dir
46+
47+
BUILDER_PROCESS = None
48+
def stop_processes():
49+
if BUILDER_PROCESS is not None:
50+
proc = BUILDER_PROCESS
51+
proc.terminate()
52+
proc.communicate()
53+
proc.wait()
54+
55+
def container_call(args):
56+
build_executables = ['podman', 'docker']
57+
build_executable = None
58+
logger_stdout = None
59+
logger_stderr = None
60+
for builder in build_executables:
61+
build_executable = shutil.which(builder)
62+
if build_executable is not None:
63+
logger_stderr = logging.getLogger(builder + '-stderr')
64+
logger_stdout = logging.getLogger(builder + '-stdout')
65+
break
66+
if build_executable is None:
67+
raise Exception('Cannot find Podman or Docker executable')
68+
proc = subprocess.Popen([build_executable] + args, stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True)
69+
BUILDER_PROCESS = proc
70+
71+
sel = selectors.DefaultSelector()
72+
sel.register(proc.stdout, selectors.EVENT_READ)
73+
sel.register(proc.stderr, selectors.EVENT_READ)
74+
75+
last_line_stdout = None
76+
last_line_stderr = None
77+
stdout_open, stderr_open = True, True
78+
while stdout_open or stderr_open:
79+
for key, _ in sel.select():
80+
data = key.fileobj.readline()
81+
if data == '':
82+
if key.fileobj is proc.stdout:
83+
stdout_open = False
84+
elif key.fileobj is proc.stderr:
85+
stderr_open = False
86+
continue
87+
data = data.rstrip()
88+
if key.fileobj is proc.stdout:
89+
last_line_stdout = data
90+
logger_stdout.info(data)
91+
elif key.fileobj is proc.stderr:
92+
last_line_stderr = data
93+
# stderr is mostly used for progress notifications, not errors
94+
logger_stderr.info(data)
95+
96+
sel.close()
97+
BUILDER_PROCESS = None
98+
if proc.wait() != 0:
99+
raise Exception(f"Builder returned with error: {last_line_stderr}")
100+
return last_line_stdout
101+
102+
def pull_image(image_url):
103+
try:
104+
container_call(['image', 'pull', image_url])
105+
return image_url
106+
except:
107+
return None
108+
109+
def delete_container(container_id):
110+
try:
111+
container_call(['container', 'rm', '--force', container_id])
112+
except Exception as e:
113+
logging.warning('Failed to delete container {}: {}'.format(container_id, e))
114+
115+
def build_mpr_builder(mx_version, dotnet, artifacts_repository=None):
116+
builder_image_tag = f"mxbuild-{mx_version}-{dotnet}-{platform.machine()}"
117+
builder_image_url = None
118+
if artifacts_repository is not None:
119+
builder_image_url = f"{artifacts_repository}:{builder_image_tag}"
120+
image_url = pull_image(builder_image_url)
121+
if image_url is not None:
122+
return image_url
123+
else:
124+
builder_image_url = f"mendix-buildpack:{builder_image_tag}"
125+
126+
prefix = ''
127+
if platform.machine() == 'arm64' and dotnet == 'dotnet':
128+
prefix = 'arm64-'
129+
130+
mxbuild_filename = f"{prefix}mxbuild-{mx_version}.tar.gz"
131+
mxbuild_url = f"https://download.mendix.com/runtimes/{mxbuild_filename}"
132+
133+
build_args = ['--build-arg', f"MXBUILD_DOWNLOAD_URL={mxbuild_url}",
134+
'--file', os.path.join('mxbuild', f"{dotnet}.dockerfile"),
135+
'--tag', builder_image_url]
136+
137+
container_call(['image', 'build'] + build_args + ['mxbuild'])
138+
if artifacts_repository is not None:
139+
try:
140+
container_call(['image', 'push', builder_image_url])
141+
except Exception as e:
142+
logging.warning('Failed to push mxbuild into artifacts repository: {}; continuing with the build'.format(e))
143+
return builder_image_url
144+
145+
def get_git_commit(source_dir):
146+
git_head = os.path.join(source_dir, '.git', 'HEAD')
147+
if not os.path.isfile(git_head):
148+
raise Exception('Project source doesn\'t contain git metadata')
149+
with open(git_head) as git_head:
150+
git_head_line = git_head.readline().split()
151+
if len(git_head_line) == 1:
152+
# Detached commit
153+
return git_head_line[0]
154+
if len(git_head_line) > 2:
155+
raise Exception(f"Unsupported Git HEAD format {git_head_line}")
156+
git_branch = git_head_line[1].split('/')
157+
git_branch_file = os.path.join(*([source_dir, '.git'] + git_branch))
158+
if not os.path.isfile(git_branch_file):
159+
raise Exception('Git branch file doesn\'t exist')
160+
with open(git_branch_file) as git_branch_file:
161+
return git_branch_file.readline()
162+
163+
164+
def build_mpr(source_dir, mpr_file, destination, artifacts_repository=None):
165+
cursor = sqlite3.connect(mpr_file).cursor()
166+
cursor.execute("SELECT _ProductVersion FROM _MetaData LIMIT 1")
167+
mx_version = cursor.fetchone()[0]
168+
mx_version_value = parse_version(mx_version)
169+
logging.debug('Detected Mendix version {}'.format('.'.join(map(str,mx_version_value))))
170+
dotnet = 'dotnet' if mx_version_value >= (10, 0, 0, 0) else 'mono'
171+
builder_image = build_mpr_builder(mx_version, dotnet, artifacts_repository)
172+
model_version = None
173+
try:
174+
model_version = get_git_commit(source_dir)
175+
except Exception as e:
176+
model_version = 'unversioned'
177+
logging.warning('Cannot determine git commit ({}), will set model version to unversioned'.format(e))
178+
container_id = container_call(['container', 'create', builder_image, os.path.basename(mpr_file), model_version])
179+
atexit.register(delete_container, container_id)
180+
container_call(['container', 'cp', os.path.abspath(source_dir)+'/.', f"{container_id}:/workdir/project"])
181+
build_result = container_call(['start', '--attach', '--interactive', container_id])
182+
183+
temp_dir = tempfile.TemporaryDirectory(prefix='mendix-docker-buildpack')
184+
container_call(['container', 'cp', f"{container_id}:/workdir/output.mda", temp_dir.name])
185+
with zipfile.ZipFile(os.path.join(temp_dir.name, 'output.mda')) as zip_file:
186+
zip_file.extractall(destination)
187+
188+
def parse_version(version):
189+
return tuple([ int(n) for n in version.split('.') ])
190+
191+
def prepare_destination(destination_path):
192+
with os.scandir(destination_path) as entries:
193+
for entry in entries:
194+
if entry.is_dir() and not entry.is_symlink():
195+
shutil.rmtree(entry.path)
196+
else:
197+
os.remove(entry.path)
198+
project_path = os.path.join(destination_path, 'project')
199+
os.mkdir(project_path, 0o755)
200+
shutil.copytree('scripts', os.path.join(destination_path, 'scripts'))
201+
shutil.copyfile('Dockerfile', os.path.join(destination_path, 'Dockerfile'))
202+
return project_path
203+
204+
def prepare_mda(source_path, destination_path, artifacts_repository=None):
205+
destination_path = prepare_destination(destination_path)
206+
mpk_file = find_default_file(source_path, '.mpk')
207+
extracted_dir = None
208+
if mpk_file is not None:
209+
extracted_dir = extract_zip(mpk_file)
210+
source_path = extracted_dir.name
211+
mpr_file = find_default_file(source_path, '.mpr')
212+
if mpr_file is not None:
213+
source_path = os.path.abspath(os.path.join(mpr_file, os.pardir))
214+
return build_mpr(source_path, mpr_file, destination_path, artifacts_repository)
215+
mda_file = find_default_file(source_path, '.mda')
216+
if mda_file is not None:
217+
with zipfile.ZipFile(mda_file) as zip_file:
218+
zip_file.extractall(destination_path)
219+
elif os.path.isdir(source_path):
220+
shutil.copytree(source_path, destination_path, dirs_exist_ok=True)
221+
extracted_mda_file = get_metadata_value(destination_path)
222+
if extracted_mda_file is not None:
223+
return destination_path
224+
else:
225+
raise Exception('No supported files found in source path')
226+
227+
def build_image(mda_dir):
228+
# TODO: build the full image, or just copy MDA into destination?
229+
mda_path = mda_dir.name if isinstance(mda_dir, tempfile.TemporaryDirectory) else mda_dir
230+
mda_metadata = get_metadata_value(mda_path)
231+
mx_version = mda_metadata['RuntimeVersion']
232+
java_version = mda_metadata.get('JavaVersion', 11)
233+
logging.debug("Detected Mendix {} Java {}".format(mx_version, java_version))
234+
235+
if __name__ == '__main__':
236+
parser = argparse.ArgumentParser(description='Build a Mendix app')
237+
parser.add_argument('--source', metavar='source', required=True, nargs='?', type=pathlib.Path, help='Path to source Mendix app (MDA file, MPK file, MPR directory or extracted MDA directory)')
238+
parser.add_argument('--destination', metavar='destination', required=True, nargs='?', type=pathlib.Path, help='Destination for MDA')
239+
parser.add_argument('--artifacts-repository', required=False, nargs='?', metavar='artifacts_repository', type=str, help='Repository to use for caching build images')
240+
parser.add_argument('action', metavar='action', choices=['build-mda-dir'], help='Action to perform')
241+
242+
args = parser.parse_args()
243+
244+
atexit.register(stop_processes)
245+
try:
246+
prepare_mda(args.source, args.destination, args.artifacts_repository)
247+
except KeyboardInterrupt:
248+
stop_processes()
249+
raise
250+
# build_image(args.destination)

0 commit comments

Comments
 (0)