Page MenuHomePhorge

No OneTemporary

Size
214 KB
Referenced Files
None
Subscribers
None
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index d29705a..ee73527 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -1,65 +1,68 @@
+# This file is part of lilybuild.
+# SPDX-FileCopyrightText: 2025-2026 tusooa <tusooa@kazv.moe>
+# SPDX-License-Identifier: GPL-2.0-only
stages:
- unit-test
- build
default:
image: quay.io/podman/stable
.push:
script: &push
- if [ -n "${CI_COMMIT_REF_SLUG}" ]; then sudo -u podman podman push "$IMAGE"; fi
.unit-test:
stage: unit-test
artifacts:
reports:
coverage_report:
coverage_format: cobertura
path: coverage.xml
unit-test-master:
extends: ['.unit-test']
image: docker.io/buildbot/buildbot-master:v4.2.1
script:
- /buildbot_venv/bin/pip3 install jsonschema backports.tarfile coverage
- . /buildbot_venv/bin/activate
- ./lilybuild/run-tests.sh master
unit-test-worker:
extends: ['.unit-test']
image: alpine
script:
- apk add --no-cache python3 py3-virtualenv
- virtualenv --python=python3 /buildbot_venv
- /buildbot_venv/bin/pip3 install 'twisted[tls]' jsonschema backports.tarfile pycobertura coverage
- . /buildbot_venv/bin/activate
- ./lilybuild/run-tests.sh worker
.build:
stage: build
before_script:
- 'if [ -n "${CI_COMMIT_REF_SLUG}" ]; then sudo -u podman podman login -u "$REGISTRY_USER" --password-stdin "$REGISTRY" <<< "$REGISTRY_PASSWORD"; fi'
- IMAGE_PREFIX="$REGISTRY/infra/lilybuild"
- IMAGE_VER="${CI_COMMIT_REF_SLUG-none}"
build:master:
extends: .build
script:
- IMAGE="$IMAGE_PREFIX/buildbot-master:$IMAGE_VER"
- sudo -u podman ./build-master.sh -t "$IMAGE"
- *push
build:worker:
extends: .build
script:
- IMAGE="$IMAGE_PREFIX/buildbot-worker:$IMAGE_VER"
- sudo -u podman ./build-worker.sh -t "$IMAGE"
- *push
build:volume-helper:
extends: .build
script:
- IMAGE="$IMAGE_PREFIX/volume-helper:$IMAGE_VER"
- sudo -u podman ./build-volume-helper.sh -t "$IMAGE"
- *push
diff --git a/Containerfile.master b/Containerfile.master
index 81b96cc..6e9e783 100644
--- a/Containerfile.master
+++ b/Containerfile.master
@@ -1,8 +1,11 @@
+# This file is part of lilybuild.
+# SPDX-FileCopyrightText: 2025-2026 tusooa <tusooa@kazv.moe>
+# SPDX-License-Identifier: GPL-2.0-only
FROM docker.io/buildbot/buildbot-master:v4.2.1
RUN /buildbot_venv/bin/pip3 install jsonschema backports.tarfile
COPY --from=lilybuild . /usr/src/lilybuild
RUN /buildbot_venv/bin/pip3 install /usr/src/lilybuild
diff --git a/Containerfile.volume-helper b/Containerfile.volume-helper
index e593a76..4afb7aa 100644
--- a/Containerfile.volume-helper
+++ b/Containerfile.volume-helper
@@ -1,3 +1,7 @@
+# This file is part of lilybuild.
+# SPDX-FileCopyrightText: 2025-2026 tusooa <tusooa@kazv.moe>
+# SPDX-License-Identifier: GPL-2.0-only
+
FROM lscr.io/linuxserver/openssh-server:latest
RUN apk --no-cache add rsync
diff --git a/Containerfile.worker b/Containerfile.worker
index a96f87c..d0b356d 100644
--- a/Containerfile.worker
+++ b/Containerfile.worker
@@ -1,26 +1,31 @@
+# This file is part of lilybuild.
+# SPDX-FileCopyrightText: 2025-2026 tusooa <tusooa@kazv.moe>
+# SPDX-License-Identifier: GPL-2.0-only
+
+# Adapted from https://github.com/buildbot/buildbot/blob/master/worker/Dockerfile
FROM alpine
RUN apk add --no-cache podman podman-compose python3 \
py3-virtualenv dumb-init bash shadow git openssh rsync php php-curl \
&& virtualenv --python=python3 /buildbot_venv \
&& /buildbot_venv/bin/pip3 install 'twisted[tls]' pycobertura \
&& mkdir /buildbot \
&& useradd -ms /bin/bash buildbot \
&& mkdir -pv /tools \
&& cd /tools \
&& git clone https://github.com/phorgeit/arcanist.git \
&& cd arcanist \
&& git checkout stable
COPY . /usr/src/buildbot-worker
COPY docker/buildbot.tac /buildbot/buildbot.tac
RUN /buildbot_venv/bin/pip3 install /usr/src/buildbot-worker && \
chown -R buildbot /buildbot
USER buildbot
WORKDIR /buildbot
ENV PATH="/buildbot_venv/bin:/tools/arcanist/bin:$PATH"
CMD ["/usr/bin/dumb-init", "/buildbot_venv/bin/twistd", "--pidfile=", "-ny", "buildbot.tac"]
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..97ff50f
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,86 @@
+GNU GENERAL PUBLIC LICENSE
+
+Version 2, June 1991
+
+Copyright (C) 1989, 1991 Free Software Foundation, Inc.
+<https://fsf.org/>
+Everyone is permitted to copy and distribute verbatim copies
+of this license document, but changing it is not allowed.
+
+Preamble
+
+The licenses for most software are designed to take away your freedom to share and change it. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change free software--to make sure the software is free for all its users. This General Public License applies to most of the Free Software Foundation's software and to any other program whose authors commit to using it. (Some other Free Software Foundation software is covered by the GNU Lesser General Public License instead.) You can apply it to your programs, too.
+
+When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for this service if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs; and that you know you can do these things.
+
+To protect your rights, we need to make restrictions that forbid anyone to deny you these rights or to ask you to surrender the rights. These restrictions translate to certain responsibilities for you if you distribute copies of the software, or if you modify it.
+
+For example, if you distribute copies of such a program, whether gratis or for a fee, you must give the recipients all the rights that you have. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights.
+
+We protect your rights with two steps: (1) copyright the software, and (2) offer you this license which gives you legal permission to copy, distribute and/or modify the software.
+
+Also, for each author's protection and ours, we want to make certain that everyone understands that there is no warranty for this free software. If the software is modified by someone else and passed on, we want its recipients to know that what they have is not the original, so that any problems introduced by others will not reflect on the original authors' reputations.
+
+Finally, any free program is threatened constantly by software patents. We wish to avoid the danger that redistributors of a free program will individually obtain patent licenses, in effect making the program proprietary. To prevent this, we have made it clear that any patent must be licensed for everyone's free use or not licensed at all.
+
+The precise terms and conditions for copying, distribution and modification follow.
+TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
+
+0. This License applies to any program or other work which contains a notice placed by the copyright holder saying it may be distributed under the terms of this General Public License. The "Program", below, refers to any such program or work, and a "work based on the Program" means either the Program or any derivative work under copyright law: that is to say, a work containing the Program or a portion of it, either verbatim or with modifications and/or translated into another language. (Hereinafter, translation is included without limitation in the term "modification".) Each licensee is addressed as "you".
+
+Activities other than copying, distribution and modification are not covered by this License; they are outside its scope. The act of running the Program is not restricted, and the output from the Program is covered only if its contents constitute a work based on the Program (independent of having been made by running the Program). Whether that is true depends on what the Program does.
+
+1. You may copy and distribute verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice and disclaimer of warranty; keep intact all the notices that refer to this License and to the absence of any warranty; and give any other recipients of the Program a copy of this License along with the Program.
+
+You may charge a fee for the physical act of transferring a copy, and you may at your option offer warranty protection in exchange for a fee.
+
+2. You may modify your copy or copies of the Program or any portion of it, thus forming a work based on the Program, and copy and distribute such modifications or work under the terms of Section 1 above, provided that you also meet all of these conditions:
+
+ a) You must cause the modified files to carry prominent notices stating that you changed the files and the date of any change.
+ b) You must cause any work that you distribute or publish, that in whole or in part contains or is derived from the Program or any part thereof, to be licensed as a whole at no charge to all third parties under the terms of this License.
+ c) If the modified program normally reads commands interactively when run, you must cause it, when started running for such interactive use in the most ordinary way, to print or display an announcement including an appropriate copyright notice and a notice that there is no warranty (or else, saying that you provide a warranty) and that users may redistribute the program under these conditions, and telling the user how to view a copy of this License. (Exception: if the Program itself is interactive but does not normally print such an announcement, your work based on the Program is not required to print an announcement.)
+
+These requirements apply to the modified work as a whole. If identifiable sections of that work are not derived from the Program, and can be reasonably considered independent and separate works in themselves, then this License, and its terms, do not apply to those sections when you distribute them as separate works. But when you distribute the same sections as part of a whole which is a work based on the Program, the distribution of the whole must be on the terms of this License, whose permissions for other licensees extend to the entire whole, and thus to each and every part regardless of who wrote it.
+
+Thus, it is not the intent of this section to claim rights or contest your rights to work written entirely by you; rather, the intent is to exercise the right to control the distribution of derivative or collective works based on the Program.
+
+In addition, mere aggregation of another work not based on the Program with the Program (or with a work based on the Program) on a volume of a storage or distribution medium does not bring the other work under the scope of this License.
+
+3. You may copy and distribute the Program (or a work based on it, under Section 2) in object code or executable form under the terms of Sections 1 and 2 above provided that you also do one of the following:
+
+ a) Accompany it with the complete corresponding machine-readable source code, which must be distributed under the terms of Sections 1 and 2 above on a medium customarily used for software interchange; or,
+ b) Accompany it with a written offer, valid for at least three years, to give any third party, for a charge no more than your cost of physically performing source distribution, a complete machine-readable copy of the corresponding source code, to be distributed under the terms of Sections 1 and 2 above on a medium customarily used for software interchange; or,
+ c) Accompany it with the information you received as to the offer to distribute corresponding source code. (This alternative is allowed only for noncommercial distribution and only if you received the program in object code or executable form with such an offer, in accord with Subsection b above.)
+
+The source code for a work means the preferred form of the work for making modifications to it. For an executable work, complete source code means all the source code for all modules it contains, plus any associated interface definition files, plus the scripts used to control compilation and installation of the executable. However, as a special exception, the source code distributed need not include anything that is normally distributed (in either source or binary form) with the major components (compiler, kernel, and so on) of the operating system on which the executable runs, unless that component itself accompanies the executable.
+
+If distribution of executable or object code is made by offering access to copy from a designated place, then offering equivalent access to copy the source code from the same place counts as distribution of the source code, even though third parties are not compelled to copy the source along with the object code.
+
+4. You may not copy, modify, sublicense, or distribute the Program except as expressly provided under this License. Any attempt otherwise to copy, modify, sublicense or distribute the Program is void, and will automatically terminate your rights under this License. However, parties who have received copies, or rights, from you under this License will not have their licenses terminated so long as such parties remain in full compliance.
+
+5. You are not required to accept this License, since you have not signed it. However, nothing else grants you permission to modify or distribute the Program or its derivative works. These actions are prohibited by law if you do not accept this License. Therefore, by modifying or distributing the Program (or any work based on the Program), you indicate your acceptance of this License to do so, and all its terms and conditions for copying, distributing or modifying the Program or works based on it.
+
+6. Each time you redistribute the Program (or any work based on the Program), the recipient automatically receives a license from the original licensor to copy, distribute or modify the Program subject to these terms and conditions. You may not impose any further restrictions on the recipients' exercise of the rights granted herein. You are not responsible for enforcing compliance by third parties to this License.
+
+7. If, as a consequence of a court judgment or allegation of patent infringement or for any other reason (not limited to patent issues), conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot distribute so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not distribute the Program at all. For example, if a patent license would not permit royalty-free redistribution of the Program by all those who receive copies directly or indirectly through you, then the only way you could satisfy both it and this License would be to refrain entirely from distribution of the Program.
+
+If any portion of this section is held invalid or unenforceable under any particular circumstance, the balance of the section is intended to apply and the section as a whole is intended to apply in other circumstances.
+
+It is not the purpose of this section to induce you to infringe any patents or other property right claims or to contest validity of any such claims; this section has the sole purpose of protecting the integrity of the free software distribution system, which is implemented by public license practices. Many people have made generous contributions to the wide range of software distributed through that system in reliance on consistent application of that system; it is up to the author/donor to decide if he or she is willing to distribute software through any other system and a licensee cannot impose that choice.
+
+This section is intended to make thoroughly clear what is believed to be a consequence of the rest of this License.
+
+8. If the distribution and/or use of the Program is restricted in certain countries either by patents or by copyrighted interfaces, the original copyright holder who places the Program under this License may add an explicit geographical distribution limitation excluding those countries, so that distribution is permitted only in or among countries not thus excluded. In such case, this License incorporates the limitation as if written in the body of this License.
+
+9. The Free Software Foundation may publish revised and/or new versions of the General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns.
+
+Each version is given a distinguishing version number. If the Program specifies a version number of this License which applies to it and "any later version", you have the option of following the terms and conditions either of that version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of this License, you may choose any version ever published by the Free Software Foundation.
+
+10. If you wish to incorporate parts of the Program into other free programs whose distribution conditions are different, write to the author to ask for permission. For software which is copyrighted by the Free Software Foundation, write to the Free Software Foundation; we sometimes make exceptions for this. Our decision will be guided by the two goals of preserving the free status of all derivatives of our free software and of promoting the sharing and reuse of software generally.
+
+NO WARRANTY
+
+11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
+END OF TERMS AND CONDITIONS
diff --git a/README.md b/README.md
index 685be37..def45fb 100644
--- a/README.md
+++ b/README.md
@@ -1,131 +1,135 @@
# lilybuild
Runs pipeline jobs in containers.
Focus will be given to podman containers.
## Motivation
This originates as a trial to replace the current workflow in GitLab CI/CD on
[Lily Islands][https://lily-is.land]. Using GitLab is **unethical** because (1)
its official instance, gitlab.com, imposes a region lock by restricting users in
regions under the rule of the Peking government from using their account on
gitlab.com, and asking them migrate to another service provided by JiHu [^rd] [^gl-forum] [^gl-gl];
and (2) said company tries to threaten to sue GitLab CE users in order to ask them
to pay, despite the software is free/libre. [^sh]
[^rd]: https://www.reddit.com/r/gitlab/comments/1hj6ern/gitlab_can_no_longer_service_mainland_china_macao/
[^gl-forum]: https://forum.gitlab.com/t/question-about-the-gitlab-can-no-longer-service-in-mainland-china-macao-and-hong-kong/120085
[^gl-gl]: https://gitlab.com/gitlab-com/gl-infra/production-engineering/-/issues/25191
[^sh]: https://www.sohu.com/a/835281881_122066678
**If you are currently using GitLab, or are considering doing so, either
their SaaS version, or a self-hosted version, we strongly recommend that you think twice.**
Main objectives include:
- Parse GitLab CI file, and run them as buildbot builds, inside containers.
- Run CI from both a PR-style forge (forgejo) and a diff-style forge (phorge)
at the same time, and unifying them.
## Deployment
Only LilyBuildConfig is required. All others are optional.
### `lilybuild.config.LilyBuildConfig` (main entry point)
Requirements:
- Buildbot Workers, using image from `Containerfile.worker`
For the current moment:
- The `lilybuild` directory in this repository needs to be mounted to `/lilybuild`
inside the container
- A ssh key pair without password needs to be available in
`/secrets/lilybuild-volume-helper-key{,.pub}` inside the container
- An image built by `Containerfile.volume-helper` is available
under the name defined in `volume_helper_image` in `lilybuild/podman-helper`
- Repository urls (can be from any forge (of course, you can use GitLab
with it, but not using it can be a highly ethical choice.))
```
lbc = LilyBuildConfig(
c,
['workername1', 'workername2'],
# The ones below are only needed if you need status report for phorge OR need to use `arc patch` to fetch the source.
# Can be overriden by individual repos.
phorge_base_url='https://other.server.example/',
phorge_token=util.Secret('phorge-token'),
)
lbc.configure_factory_and_builder()
lbc.add_repo(
1, # repo id
'https://server.example/owner/repo', # canonical url
alternative_urls=['git@other.server.example:owner/repo'], # can be empty
do_poll=False, # set to True if you don't have a change hook
)
# add other repos
lbc.configure_pipeline_defs()
```
#### Builders
`LilyBuildConfig` creates 3 builders: `lilybuild`, `lilybuild-job`, `lilybuild-force`.
`lilybuild` runs a pipeline definition parsed from the CI file.
It triggers `lilybuild-job`, which runs a single job as defined by the CI file.
`lilybuild-force` allows you to run a pipeline on any defined repository by the
"force" button. You may want to restrict who can force build.
#### Phorge sources
If the build comes from a Phorge change hook (TODO: document how to setup such a
hook), it will try to fetch from the staging area defined in Phorge. If there
is no staging area, or the staging area does not contain that diff (for example,
because the user chooses to skip staging in `arc diff`, or the diff was
submitted on the web form, probably to prevent a security patch from leaking
too early), it will
checkout from the canonical repo url, then try to run `arc patch` to fetch
the source code.
If the build does not come from a Phorge change hook, it will fetch normally
via the canonical repo url.
#### Requiring approval
By default, if the source code is fetched via `arc patch`, an approval is
needed to run the pipeline (assuming anyone who can push to staging area
is trusted). The approval is done by rebuilding the build. (TODO: make this
configurable)
### `lilybuild.auth.ForgejoAuth`
An auth provider for forgejo.
Use:
```
# Allow users to login via forgejo
c['www']['auth'] = ForgejoAuth('https://forgejo.server.example/', CLIENT_ID, CLIENT_SECRET)
```
### `lilybuild.auth.ForgejoAuthz`
An authz provider that gives rights to people who have access to a forgejo
repository to control the builds in that repository (rebuild/stop).
Must use `ForgejoAuth` as the auth provider.
This class extends `buildbot.www.authz.authz.Authz` and accepts any arguments
accepted by it.
Use:
```
c['www']['authz'] = ForgejoAuthz(
base_url='https://forgejo.server.example/',
access_token=util.Secret('forgejo-authz-token'), # This needs to be a token of an **admin account** either of the forgejo server, or of all the registered repositories, and with `read:repository` access.
# any other options...
)
```
+
+## License
+
+This is licensed under the same one as buildbot, i.e. GPL v2.
diff --git a/build-master.sh b/build-master.sh
index 6e6f5bf..28cd717 100755
--- a/build-master.sh
+++ b/build-master.sh
@@ -1,3 +1,6 @@
#!/bin/sh
+# This file is part of lilybuild.
+# SPDX-FileCopyrightText: 2025-2026 tusooa <tusooa@kazv.moe>
+# SPDX-License-Identifier: GPL-2.0-only
podman build -f Containerfile.master --build-context=lilybuild=lilybuild master "$@"
diff --git a/build-volume-helper.sh b/build-volume-helper.sh
index bfa2d0c..484e49b 100755
--- a/build-volume-helper.sh
+++ b/build-volume-helper.sh
@@ -1,3 +1,6 @@
#!/bin/sh
+# This file is part of lilybuild.
+# SPDX-FileCopyrightText: 2025-2026 tusooa <tusooa@kazv.moe>
+# SPDX-License-Identifier: GPL-2.0-only
podman build -f Containerfile.volume-helper volume-helper "$@"
diff --git a/build-worker.sh b/build-worker.sh
index ce0587e..59197fe 100755
--- a/build-worker.sh
+++ b/build-worker.sh
@@ -1,3 +1,6 @@
#!/bin/sh
+# This file is part of lilybuild.
+# SPDX-FileCopyrightText: 2025-2026 tusooa <tusooa@kazv.moe>
+# SPDX-License-Identifier: GPL-2.0-only
podman build -f Containerfile.worker buildbot/worker "$@"
diff --git a/lilybuild/lilybuild/artifacts/__init__.py b/lilybuild/lilybuild/artifacts/__init__.py
index ace1953..c9a1c52 100644
--- a/lilybuild/lilybuild/artifacts/__init__.py
+++ b/lilybuild/lilybuild/artifacts/__init__.py
@@ -1,4 +1,7 @@
+# This file is part of lilybuild.
+# SPDX-FileCopyrightText: 2025-2026 tusooa <tusooa@kazv.moe>
+# SPDX-License-Identifier: GPL-2.0-only
from .application import ArtifactsApplication
ep = ArtifactsApplication()
diff --git a/lilybuild/lilybuild/artifacts/application.py b/lilybuild/lilybuild/artifacts/application.py
index bb25f9e..3c55220 100644
--- a/lilybuild/lilybuild/artifacts/application.py
+++ b/lilybuild/lilybuild/artifacts/application.py
@@ -1,12 +1,15 @@
+# This file is part of lilybuild.
+# SPDX-FileCopyrightText: 2025-2026 tusooa <tusooa@kazv.moe>
+# SPDX-License-Identifier: GPL-2.0-only
from buildbot.www.plugin import Application
from .resource import Api
class ArtifactsApplication(Application):
def __init__(self):
self.description = 'LilyBuild Artifacts support'
self.version = '0.0.0'
self.static_dir = ''
self.ui = True
self.api = Api(self)
self.resource = self.api.app.resource()
diff --git a/lilybuild/lilybuild/artifacts/authz_utils.py b/lilybuild/lilybuild/artifacts/authz_utils.py
index 21f3878..5b49447 100644
--- a/lilybuild/lilybuild/artifacts/authz_utils.py
+++ b/lilybuild/lilybuild/artifacts/authz_utils.py
@@ -1,24 +1,27 @@
+# This file is part of lilybuild.
+# SPDX-FileCopyrightText: 2025-2026 tusooa <tusooa@kazv.moe>
+# SPDX-License-Identifier: GPL-2.0-only
from twisted.internet import defer
from buildbot.www.authz.endpointmatchers import EndpointMatcherBase
class AnyArtifactEndpointMatcher(EndpointMatcherBase):
def __init__(self, role, defaultDeny=True):
super().__init__(role, defaultDeny)
def match(self, ep, action='get', options=None):
if isinstance(ep, tuple) and ep[0] == 'lilybuild_artifacts':
return defer.secceed(Match(self.master))
return defer.succeed(None)
class BuildArtifactEndpointMatcher(EndpointMatcherBase):
def __init__(self, role, defaultDeny=True):
super().__init__(role, defaultDeny)
@defer.inlineCallbacks
def match(self, ep, action='get', options=None):
if isinstance(ep, tuple) and ep[0] == 'lilybuild_artifacts' and ep[1] == 'builds':
build_id = ep[2]
build = yield self.master.data.get(('builds', build_id))
return Match(self.master, build=build)
return None
diff --git a/lilybuild/lilybuild/artifacts/resource.py b/lilybuild/lilybuild/artifacts/resource.py
index ea4abdf..3e72072 100644
--- a/lilybuild/lilybuild/artifacts/resource.py
+++ b/lilybuild/lilybuild/artifacts/resource.py
@@ -1,151 +1,154 @@
+# This file is part of lilybuild.
+# SPDX-FileCopyrightText: 2025-2026 tusooa <tusooa@kazv.moe>
+# SPDX-License-Identifier: GPL-2.0-only
import json
from klein import Klein
from twisted.internet import defer
from twisted.web.static import File
from buildbot.data.resultspec import ResultSpec, Property
import os
import re
POSSIBLE_ARTIFACT_TYPES = {
'archive': 'artifacts.tar',
'reports': 'reports.tar',
}
BAD_REQUEST = 'BAD_REQUEST'
NOT_FOUND = 'NOT_FOUND'
def validate_artifact_type(at):
return at in POSSIBLE_ARTIFACT_TYPES
REF_NAME_REGEX = re.compile(r'^[A-Za-z0-9_\-]+$')
def validate_ref_name(ref_name):
return re.match(REF_NAME_REGEX, ref_name)
class Api:
app = Klein()
def __init__(self, ep):
self.ep = ep
def lbc(self):
return self.ep.config['lbc']
def www(self):
return self.ep.master.www
@app.route('/builds/<int:build_id>/jobs/<int:job_index>/artifacts/<artifact_type>', methods=['GET'])
@defer.inlineCallbacks
def getArtifact(self, request, build_id, job_index, artifact_type):
if not validate_artifact_type(artifact_type):
request.setResponseCode(400)
request.setHeader('Content-Type', 'application/json')
return json.dumps({'error': BAD_REQUEST})
storage_dir = self.lbc().storage_dir
try:
artifact_file = yield self.get_artifact_location(request, build_id, job_index, artifact_type)
if os.path.exists(artifact_file):
return File(artifact_file)
else:
raise FileNotFoundError('Not Found')
except:
request.setResponseCode(404)
request.setHeader('Content-Type', 'application/json')
return json.dumps({'error': NOT_FOUND})
@defer.inlineCallbacks
def get_artifact_location(self, request, build_id, job_index, artifact_type, job_name=None):
props_to_get = [
'lilybuild_repo_id',
'lilybuild_source',
]
if job_index is None and job_name is None:
raise FileNotFoundError('Not Found')
if job_index is None and job_name is not None:
props_to_get.append('lilybuild_job_map')
result_spec = ResultSpec(
# see popProperties in ResultSpec
properties=[Property(b'property', 'eq', props_to_get)]
)
build = yield self.ep.master.data.get_with_resultspec(
('builds', build_id), result_spec
)
if not build:
raise FileNotFoundError('Not Found')
if 'lilybuild_repo_id' not in build['properties']:
raise FileNotFoundError('Not Found')
if 'lilybuild_source' not in build['properties']:
source = 'none'
else:
source = build['properties']['lilybuild_source'][0]
if job_index is None:
if 'lilybuild_job_map' not in build['properties']:
raise FileNotFoundError('Not Found')
job_map = build['properties']['lilybuild_job_map'][0]
if job_name not in job_map:
raise FileNotFoundError('Not Found')
job_index = job_map[job_name]
# Things not pushed to staging area is considered private
# and can only be seen by project members
if source == 'arc-patch':
# see ForgejoAuthz
yield self.www().assertUserAllowed(request, ('lilybuild_artifacts', 'builds', build_id, 'jobs', job_index, 'artifacts', artifact_type), 'get', {})
repo_id = build['properties']['lilybuild_repo_id'][0]
fn = POSSIBLE_ARTIFACT_TYPES[artifact_type]
res = os.path.join(
self.lbc().storage_dir,
'repos', str(repo_id),
'builds', str(build_id),
'jobs', str(job_index),
'artifacts', fn
)
return res
@app.route('/repos/<int:repo_id>/latest/<ref_name>/artifacts/<artifact_type>', methods=['GET'])
@defer.inlineCallbacks
def getLatestArtifact(self, request, repo_id, ref_name, artifact_type):
if not validate_ref_name(ref_name):
request.setResponseCode(400)
request.setHeader('Content-Type', 'application/json')
return json.dumps({'error': f'Bad ref_name format: {ref_name}'})
if not validate_artifact_type(artifact_type):
request.setResponseCode(400)
request.setHeader('Content-Type', 'application/json')
return json.dumps({'error': BAD_REQUEST})
is_good = True
if b'good' in request.args:
good = request.args[b'good'][0]
if good == b'1':
is_good = True
elif good == b'0':
is_good = False
else:
request.setResponseCode(400)
request.setHeader('Content-Type', 'application/json')
return json.dumps({'error': 'query `good` must be 0 or 1'})
if b'job' not in request.args:
request.setResponseCode(400)
request.setHeader('Content-Type', 'application/json')
return json.dumps({'error': 'query `job` missing'})
job_name = request.args[b'job'][0].decode()
link_name = os.path.join(
self.lbc().storage_dir,
'repos', str(repo_id),
'latest-good' if is_good else 'latest', ref_name
)
try:
target = os.readlink(link_name)
build_id = int(os.path.basename(target))
artifact_file = yield self.get_artifact_location(request, build_id, None, artifact_type, job_name)
if os.path.exists(artifact_file):
return File(artifact_file)
else:
raise FileNotFoundError('Not Found')
except:
request.setResponseCode(404)
request.setHeader('Content-Type', 'application/json')
return json.dumps({'error': NOT_FOUND})
diff --git a/lilybuild/lilybuild/auth.py b/lilybuild/lilybuild/auth.py
index 7cd8e1e..759653a 100644
--- a/lilybuild/lilybuild/auth.py
+++ b/lilybuild/lilybuild/auth.py
@@ -1,130 +1,133 @@
+# This file is part of lilybuild.
+# SPDX-FileCopyrightText: 2025-2026 tusooa <tusooa@kazv.moe>
+# SPDX-License-Identifier: GPL-2.0-only
from buildbot.plugins import *
from buildbot.www.oauth2 import OAuth2Auth
from buildbot.www.authz.roles import RolesFromBase, RolesFromOwner
from buildbot.www.authz.authz import Authz
from buildbot.process.properties import Properties
from buildbot.data.builds import BuildEndpoint
from twisted.internet import defer
from twisted.python import log
import requests
import txrequests
class ForgejoAuth(OAuth2Auth):
name = 'Forgejo'
faIcon = 'fa-git'
def __init__(self, instanceUri, clientId, clientSecret, **kwargs):
uri = instanceUri.rstrip('/')
self.authUri = f'{uri}/login/oauth/authorize'
self.tokenUri = f'{uri}/login/oauth/access_token'
self.resourceEndpoint = f'{uri}/api/v1'
super().__init__(clientId, clientSecret, **kwargs)
def getUserInfoFromOAuthClient(self, c):
user = self.get(c, '/user')
groups = self.get(c, '/user/orgs')
return {
'full_name': user['full_name'],
'username': user['login'],
'email': user['email'],
'avatar_url': user['avatar_url'],
'groups': [g['name'] for g in groups],
'is_admin': user['is_admin'],
}
class RolesFromGroups(RolesFromBase):
def __init__(self, prefix=''):
super().__init__()
self.prefix = prefix
def getRolesFromUser(self, userDetails):
roles = []
if 'groups' in userDetails:
for group in userDetails['groups']:
roles.append(self.prefix + group)
return roles
class RolesFromAttr(RolesFromBase):
def __init__(self, attr_name, role_name):
super().__init__()
self.attr_name = attr_name
self.role_name = role_name
def getRolesFromUser(self, userDetails):
if userDetails.get(self.attr_name):
return [self.role_name]
else:
return []
class ForgejoAuthz(Authz):
def __init__(self, base_url, access_token, **kwargs):
self.forgejo_base_url = base_url.rstrip('/') + '/'
self.forgejo_api_url = self.forgejo_base_url + 'api/v1'
self.forgejo_access_token = access_token
self.session = txrequests.Session()
super().__init__(**kwargs)
@defer.inlineCallbacks
def assertUserAllowed(self, ep, action, options, userDetails):
if isinstance(ep, tuple) and ep[0] == 'lilybuild_artifacts' and ep[1] == 'builds':
try:
build_id = ep[2]
ok = yield self.user_can_access_build_by_id(userDetails, build_id)
if ok:
return defer.succeed(None)
except Exception as e:
print('exception when checking artifact access', e)
else:
try:
(epobject, epdict) = self.master.data.getEndpoint(ep)
if isinstance(epobject, BuildEndpoint):
ok = yield self.user_can_access_build_by_id(userDetails, epdict['buildid'])
if ok:
return defer.succeed(None)
except Exception as e:
print('exception:', e)
finally:
pass
res = yield super().assertUserAllowed(ep, action, options, userDetails)
return res
@defer.inlineCallbacks
def user_can_access_build_by_id(self, userDetails, build_id):
build_props = yield self.master.data.get(('builds', build_id, 'properties'))
ok = yield self.user_can_access_build(userDetails, build_props)
return ok
@defer.inlineCallbacks
def user_can_access_build(self, userDetails, build_props):
if 'lilybuild_repo' not in build_props:
return False
repo = build_props['lilybuild_repo'][0]
if not repo.startswith(self.forgejo_base_url):
return False
repo_name = repo[len(self.forgejo_base_url):]
if repo_name.count('/') != 1:
return False
if 'username' not in userDetails:
return False
username = userDetails['username']
props = Properties()
props.master = self.master
access_token = yield props.render(self.forgejo_access_token)
# https://codeberg.org/forgejo/forgejo/issues/6837
# If I am not the repo owner, this works
resp = yield self.session.get(
f'{self.forgejo_api_url}/repos/{repo_name}/collaborators/{username}',
headers={'Authorization': f'token {access_token}'}
)
if resp.status_code < 300:
return True
# If I am the repo owner, this works
resp = yield self.session.get(
f'{self.forgejo_api_url}/repos/{repo_name}/collaborators/{username}/permission',
headers={'Authorization': f'token {access_token}'}
)
return resp.status_code < 300
diff --git a/lilybuild/lilybuild/ci_steps.py b/lilybuild/lilybuild/ci_steps.py
index 0c65af3..2ca7b9c 100644
--- a/lilybuild/lilybuild/ci_steps.py
+++ b/lilybuild/lilybuild/ci_steps.py
@@ -1,608 +1,611 @@
+# This file is part of lilybuild.
+# SPDX-FileCopyrightText: 2025-2026 tusooa <tusooa@kazv.moe>
+# SPDX-License-Identifier: GPL-2.0-only
from buildbot.plugins import *
from buildbot.process import buildstep, logobserver
from buildbot.interfaces import IRenderable
from twisted.internet import defer
from .ci_syntax import ci_file
from .ci_syntax import rules as ci_rules
from .helpers import (
rsync_rules_from_artifacts,
get_job_script,
normalize_image,
normalize_services,
ci_vars_to_env_file,
generate_metadata_from_job,
)
from .phorge import SendCoverageToPhorge
import re
import sys
import json
SAFETAR_EXEC = '/lilybuild/lilybuild/safetar.py'
COVERAGE_EXEC = '/lilybuild/lilybuild/coverage.py'
def on_success(step):
return step.build.results == util.SUCCESS
def on_always(_step):
return True
def fill_list(*args):
return list(args)
class RunCIJobStep(steps.BuildStep):
# 200 MiB
artifact_max_size = 200 * 1024 * 1024
default_image = 'alpine'
master_job_artifact_dir_pattern = '%(kw:st)s/repos/%(prop:lilybuild_repo_id)s/builds/%(prop:lilybuild_root_build_id)s/jobs/%(kw:job)s/artifacts'
artifact_file_name = 'artifacts.tar'
master_job_artifact_file_name_pattern = master_job_artifact_dir_pattern + '/' + artifact_file_name
reports_file_name = 'reports.tar'
master_reports_file_name_pattern = master_job_artifact_dir_pattern + '/' + reports_file_name
master_pages_dir_pattern = '%(kw:st)s/repos/%(prop:lilybuild_repo_id)s/pages'
phorge_coverage_file_name = 'coverage-phorge.json'
def __init__(
self,
lbc,
src_relative=None,
src_dir=None,
storage_dir=None,
repo_id=None,
result_relative=None,
result_dir=None,
artifact_stage_relative=None,
artifact_stage_dir=None,
job_prop=None,
artifact_link_base=None,
**kwargs):
self.lbc = lbc
self.src_relative = src_relative
self.src_dir = src_dir
self.work_root_dir = kwargs['workdir']
self.script_dir = 'script'
self.storage_dir = storage_dir
self.repo_id = repo_id
self.artifact_stage_relative = artifact_stage_relative
self.artifact_stage_dir = artifact_stage_dir
self.result_relative = result_relative
self.result_dir = result_dir
self.artifact_link_base = artifact_link_base
super().__init__(name='Run step', **kwargs)
def get_cur_repo_config(self):
return self.lbc.repos[self.getProperty('lilybuild_repo_id')]
@defer.inlineCallbacks
def run(self):
job_prop = self.getProperty('lilybuild_job_prop')
job = ci_file.CIJob.from_prop(job_prop)
job_index = self.getProperty('lilybuild_job_index')
variables = yield self.get_ci_variables(job)
should_run = True
if len(job.rules):
should_run = False
default_when = job.struct_raw.get('when', 'on_success')
for r in job.rules:
try:
when = r.get('when', default_when)
if when == 'never' or when == 'manual':
should_run_to_set = False
else:
should_run_to_set = True
rule_str = r.get('if')
if not rule_str:
# No condition == always true
# TODO: `changes` rule
should_run = should_run_to_set
break
res = ci_rules.evaluate_rule(ci_rules.parse_rule(rule_str), variables)
if not res:
continue
should_run = should_run_to_set
break
except SyntaxError:
self.addCompleteLog('error', f'Rule "{rule_str}" has syntax errors')
except:
pass
if should_run:
next_steps = self.job_to_steps(job, job_index, variables)
self.build.addStepsAfterCurrentStep(next_steps)
return util.SUCCESS
else:
self.addCompleteLog('info', 'Job skipped by a rule')
return util.SKIPPED
@defer.inlineCallbacks
def get_ci_variables(self, job):
res = {}
res.update(job.get_predefined_ci_variables())
res.update(self.getProperty('lilybuild_pipeline_vars'))
res['CI_JOB_IMAGE'] = job.image or self.default_image
res['CI_JOB_URL'] = yield self.build.getUrl()
res['CI_JOB_ID'] = self.build.buildid
res['CI_PROJECT_DIR'] = '/build'
try:
repo = self.lbc.repos[self.getProperty('lilybuild_repo_id')]
variables = yield repo['variables_getter'](self.build)
res_vars = {}
for var in variables:
value = variables[var]
if IRenderable.providedBy(value):
value = yield self.build.render(value)
res_vars[var] = value
res.update(res_vars)
except Exception as e:
self.addCompleteLog('exception', f'{e}')
return res
def get_upload_artifacts_jobs(self, short_name, artifact_type, artifact_name, base_dir, paths, exclude, master_pattern, job_index, doStepIf=on_success, has_pages=False):
archive_artifact_step = steps.ShellCommand(
name=f'Archive artifacts: {short_name}',
command=[
SAFETAR_EXEC,
],
initialStdin=json.dumps({
'op': 'create',
'archive_file': artifact_name,
'base_dir': base_dir,
'content': paths,
'items_to_exclude': exclude,
'compression': 'gz',
'limit_bytes': self.get_cur_repo_config()['artifact_uncompressed_limit'],
}),
workdir=self.work_root_dir,
doStepIf=doStepIf,
)
masterdest = util.Interpolate(
master_pattern,
st=self.storage_dir,
job=job_index,
doStepIf=doStepIf,
)
parent_build_id = self.getProperty('lilybuild_pipeline_vars')['CI_PIPELINE_ID']
artifact_url = f'{self.artifact_link_base}/plugins/lilybuild_artifacts/builds/{parent_build_id}/jobs/{job_index}/artifacts/{artifact_type}' if self.artifact_link_base else None
upload_artifact_step = steps.FileUpload(
workersrc=artifact_name,
maxsize=self.get_cur_repo_config()['artifact_compressed_limit'],
name=f'Upload artifacts: {short_name}',
masterdest=masterdest,
workdir=self.work_root_dir,
url=artifact_url,
doStepIf=doStepIf,
)
r = [archive_artifact_step, upload_artifact_step]
if has_pages:
r.append(steps.MasterShellCommand(
command=util.Transform(fill_list,
sys.executable,
'-m', 'lilybuild.pages',
util.Interpolate(
self.master_pages_dir_pattern,
st=self.storage_dir,
),
masterdest,
),
name='Deploy pages',
logEnviron=False,
doStepIf=doStepIf,
))
return r
def job_to_steps(self, job, job_index, variables):
script_name = self.script_dir + '/run.sh'
env_filename = self.script_dir + '/env'
metadata_filename = self.script_dir + '/metadata.json'
source_step = self.lbc.create_source_step()
script_step = steps.StringDownload(
get_job_script(job),
name='Set up script',
workerdest=script_name,
workdir=self.work_root_dir,
doStepIf=on_success,
)
env_step = steps.StringDownload(
ci_vars_to_env_file(variables),
name='Set up env file',
workerdest=env_filename,
workdir=self.work_root_dir,
doStepIf=on_success,
)
metadata_step = steps.StringDownload(
generate_metadata_from_job(
self.getProperty('lilybuild_repo_id'),
job,
variables,
),
name='Set up metadata file',
workerdest=metadata_filename,
workdir=self.work_root_dir,
doStepIf=on_success,
)
chmod_step = steps.ShellCommand(
name='Make script executable',
command=['chmod', '+x', script_name],
workdir=self.work_root_dir,
doStepIf=on_success,
)
artifact_steps = []
dep_job_indices = self.getProperty('lilybuild_dependency_job_indices')
if dep_job_indices:
for i in dep_job_indices:
# The steps may not run or may not have an artifact even if it runs
download_job = steps.FileDownload(
mastersrc=util.Interpolate(
self.master_job_artifact_file_name_pattern,
st=self.storage_dir,
job=i,
),
maxsize=self.get_cur_repo_config()['artifact_compressed_limit'],
name=f'Download artifacts from job #{i}',
workerdest=self.artifact_file_name,
workdir=self.work_root_dir,
doStepIf=on_success,
haltOnFailure=False,
flunkOnFailure=False,
# https://github.com/buildbot/buildbot/issues/3709
blocksize=256 * 1024,
)
unarchive_job = steps.ShellCommand(
name=f'Unarchive artifacts from job #{i}',
command=[
SAFETAR_EXEC,
],
initialStdin=json.dumps({
'op': 'extract',
'archive_file': self.artifact_file_name,
'target_dir': self.src_relative,
}),
workdir=self.work_root_dir,
doStepIf=on_success,
haltOnFailure=False,
flunkOnFailure=False,
)
artifact_steps += [download_job, unarchive_job]
run_step = steps.ShellCommand(
name='Run script in container',
command=[
'/lilybuild/podman-helper',
normalize_image(job.image or self.default_image),
self.src_relative,
self.script_dir,
self.result_relative,
normalize_services(job.services),
],
workdir=self.work_root_dir,
doStepIf=on_success,
# 2h timeout by default
# TODO support timeout by each job
timeout=60 * 60 * 2,
)
clean_script_step = steps.ShellCommand(
name='Clean script dir',
command=[
'rm',
'-rf',
self.script_dir,
],
workdir=self.work_root_dir,
alwaysRun=True,
)
steps_to_run = [source_step, script_step, chmod_step, env_step, metadata_step] + artifact_steps + [run_step, clean_script_step]
if 'paths' in job.artifacts:
steps_to_run += self.get_upload_artifacts_jobs(
'files',
'archive',
self.artifact_file_name,
self.result_relative,
job.artifacts.get('paths', []),
job.artifacts.get('exclude', []),
self.master_job_artifact_file_name_pattern,
job_index,
has_pages=job.is_pages()
)
if job.has_supported_coverage_report():
steps_to_run += [steps.ShellCommand(
name='Process reports',
command=[
COVERAGE_EXEC,
],
initialStdin=json.dumps({
'source_dir': self.src_relative,
'result_dir': self.result_relative,
'untrusted_coverage_file': job.artifacts['reports']['coverage_report']['path'],
'output_dir': self.artifact_stage_relative,
}),
workdir=self.work_root_dir,
flunkOnFailure=False,
doStepIf=on_always,
)] + self.get_upload_artifacts_jobs(
'reports',
'reports',
self.reports_file_name,
self.artifact_stage_relative,
['*'],
[],
self.master_reports_file_name_pattern,
job_index,
doStepIf=on_always
) + [SendCoverageToPhorge(
self.lbc,
self.artifact_stage_relative + '/' + self.phorge_coverage_file_name,
workdir=self.work_root_dir,
)]
clean_stage_dir_again_step = steps.ShellCommand(
name='Clean stage, result and artifact',
command=[
'rm',
'-rf',
self.result_relative,
self.artifact_file_name,
self.artifact_stage_relative,
self.reports_file_name,
],
workdir=self.work_root_dir,
alwaysRun=True,
)
steps_to_run.append(clean_stage_dir_again_step)
return steps_to_run
class TriggerMultipleJobsStep(steps.Trigger):
properties_to_keep = [
'branch',
'revision',
'repository',
'harbormaster_build_target_phid',
'harbormaster_variable_buildable.diff',
'harbormaster_variable_repository.staging.ref',
'harbormaster_variable_repository.staging.uri',
'harbormaster_variable_repository.uri',
'lilybuild_repo',
'lilybuild_repo_id',
'lilybuild_pipeline_vars',
]
def __init__(self, lbc, jobs_with_data, **kwargs):
self.lbc = lbc
self.jobs = jobs_with_data
super().__init__(schedulerNames=[self.lbc.triggerable_scheduler_name], **kwargs)
def getSchedulersAndProperties(self):
ret = []
common_properties = {
'lilybuild_root_build_id': self.build.buildid,
}
for prop in self.properties_to_keep:
if self.hasProperty(prop):
common_properties[prop] = self.getProperty(prop)
for (job, i, dep_job_indices) in self.jobs:
properties = common_properties.copy()
properties['lilybuild_job_prop'] = job.to_prop()
properties['lilybuild_job_index'] = i
properties['virtual_builder_name'] = 'lilybuild-job - ' + common_properties['lilybuild_repo'] + ' - ' + job.name
properties['lilybuild_dependency_job_indices'] = dep_job_indices
ret.append({
'sched_name': self.lbc.triggerable_scheduler_name,
'props_to_set': properties,
'unimportant': False,
})
return ret
class LatestMixin:
latest_build_dir_pattern = '%(kw:st)s/repos/%(prop:lilybuild_repo_id)s/latest'
latest_good_build_dir_pattern = '%(kw:st)s/repos/%(prop:lilybuild_repo_id)s/latest-good'
latest_name_pattern = '%(kw:st)s/repos/%(prop:lilybuild_repo_id)s/latest/%(kw:name)s'
latest_good_name_pattern = '%(kw:st)s/repos/%(prop:lilybuild_repo_id)s/latest-good/%(kw:name)s'
class EnsureLatestDirs(steps.MasterShellCommand, LatestMixin):
def __init__(self, lbc, storage_dir, **kwargs):
self.lbc = lbc
self.storage_dir = storage_dir
command = util.Transform(
fill_list,
'mkdir',
'-pv',
util.Interpolate(self.latest_build_dir_pattern, st=self.storage_dir),
util.Interpolate(self.latest_good_build_dir_pattern, st=self.storage_dir))
super().__init__(
name='Ensure latest dirs',
command=command,
logEnviron=False,
doStepIf=on_always,
**kwargs
)
class MarkLatest(steps.MasterShellCommand, LatestMixin):
def __init__(self, lbc, storage_dir, buildid, ref_name, is_good, **kwargs):
self.lbc = lbc
self.storage_dir = storage_dir
name_pattern = self.latest_good_name_pattern if is_good else self.latest_name_pattern
command = util.Transform(
fill_list,
'ln',
'-sfvn',
util.Interpolate('../builds/%(kw:buildid)s', buildid=buildid),
util.Interpolate(name_pattern, st=self.storage_dir, name=ref_name),
)
super().__init__(
name='Mark build as latest-good' if is_good else 'Mark build as latest',
command=command,
logEnviron=False,
doStepIf=on_success if is_good else on_always,
**kwargs
)
class AnalyzeCIFileCommand(buildstep.ShellMixin, steps.BuildStep):
ci_def_file = '.gitlab-ci.yml'
build_target_prop_name = 'harbormaster_build_target_phid'
def __init__(
self,
lbc,
src_relative=None,
src_dir=None,
storage_dir=None,
repo_id=None,
result_relative=None,
result_dir=None,
artifact_stage_relative=None,
artifact_stage_dir=None,
**kwargs):
kwargs['name'] = 'Analyze CI file'
kwargs['command'] = ['cat', self.ci_def_file]
self.lbc = lbc
self.src_relative = src_relative
self.src_dir = src_dir
self.work_root_dir = kwargs['workdir']
self.script_dir = 'script'
self.storage_dir = storage_dir
self.repo_id = repo_id
self.artifact_stage_relative = artifact_stage_relative
self.artifact_stage_dir = artifact_stage_dir
self.result_relative = result_relative
self.result_dir = result_dir
kwargs['workdir'] = self.src_dir
kwargs = self.setupShellMixin(kwargs)
super().__init__(**kwargs)
self.observer = logobserver.BufferLogObserver()
self.addLogObserver('stdio', self.observer)
def stage_to_step(self, stage_name, stage_jobs, job_name_to_index_map, ci_file):
jobs_with_data = []
for job in stage_jobs:
dep_job_names = [
jn
for jn in ci_file.get_jobs_to_pull_artifacts_from(job.name)
if ci_file.jobs[jn].has_artifacts_archive()
]
dep_job_indices = [job_name_to_index_map[jn] for jn in dep_job_names]
jobs_with_data.append((job, job_name_to_index_map[job.name], dep_job_indices))
trigger = TriggerMultipleJobsStep(
name=stage_name,
lbc=self.lbc,
jobs_with_data=jobs_with_data,
waitForFinish=True,
doStepIf=on_success,
)
return trigger
def get_steps_and_job_map(self, stdout):
f = ci_file.CIFile(stdout)
stages = f.get_grouped_jobs()
jobs = [job for (stage, js) in f.get_grouped_jobs() for job in js]
job_names = [job.name for job in jobs]
job_name_to_index_map = {}
for (i, j) in enumerate(jobs):
job_name_to_index_map[j.name] = i
steps = [self.stage_to_step(stage_name, stage_jobs, job_name_to_index_map, f) for (stage_name, stage_jobs) in stages]
print('steps:', steps)
return (steps, job_name_to_index_map)
def get_is_phorge(self):
return not not self.getProperty(self.build_target_prop_name)
def get_ref_and_type(self):
ref_type = 'branch'
ref = self.getProperty('branch')
if self.getProperty('category') == 'tag':
ref_type = 'tag'
if ref is not None:
m = re.match(r'^refs/(heads|tags)/(.+)$', ref)
if m:
ref = m.group(2)
return (ref, ref_type)
def get_cur_repo_config(self):
return self.lbc.repos[self.getProperty('lilybuild_repo_id')]
@defer.inlineCallbacks
def get_pipeline_ci_vars(self):
url = yield self.build.getUrl()
res = {
'CI_PIPELINE_ID': self.build.buildid,
'CI_PIPELINE_IID': self.build.buildid,
'CI_PIPELINE_URL': url,
'CI_PROJECT_ID': self.getProperty('lilybuild_repo_id'),
'CI_CONFIG_PATH': self.ci_def_file,
}
if not self.get_is_phorge():
res['CI_COMMIT_SHA'] = self.getProperty('got_revision')
res['CI_COMMIT_SHORT_SHA'] = res['CI_COMMIT_SHA'][:8]
(ref, ref_type) = self.get_ref_and_type()
res['CI_COMMIT_REF_NAME'] = ref
res['CI_COMMIT_REF_SLUG'] = ci_file.ci_slugify(ref)
res['CI_COMMIT_REF_PROTECTED'] = 'false'
if ref_type == 'tag':
res['CI_COMMIT_TAG'] = ref
elif ref_type == 'branch':
res['CI_COMMIT_BRANCH'] = ref
return res
@defer.inlineCallbacks
def run(self):
# run './build.sh --list-stages' to generate the list of stages
cmd = yield self.makeRemoteShellCommand()
yield self.runCommand(cmd)
# if the command passes extract the list of stages
result = cmd.results()
if result == util.SUCCESS:
pipeline_vars = yield self.get_pipeline_ci_vars()
self.setProperty('lilybuild_pipeline_vars', pipeline_vars, self.__class__.__name__)
# create a ShellCommand for each stage and add them to the build
(steps, job_map) = self.get_steps_and_job_map(self.observer.getStdout())
self.setProperty('lilybuild_job_map', job_map, self.__class__.__name__)
self.build.addStepsAfterCurrentStep(steps)
latest_branch_map = self.get_cur_repo_config()['artifact_latest_branch_map']
ref, _ref_type = self.get_ref_and_type()
if ref in latest_branch_map:
latest_name = latest_branch_map[ref]
self.build.addStepsAfterLastStep([
EnsureLatestDirs(lbc=self.lbc, storage_dir=self.storage_dir),
MarkLatest(
lbc=self.lbc,
storage_dir=self.storage_dir,
buildid=self.build.buildid,
ref_name=latest_name,
is_good=True,
),
MarkLatest(
lbc=self.lbc,
storage_dir=self.storage_dir,
buildid=self.build.buildid,
ref_name=latest_name,
is_good=False,
),
])
return result
diff --git a/lilybuild/lilybuild/ci_syntax/ci_file.py b/lilybuild/lilybuild/ci_syntax/ci_file.py
index 46c0f8a..5dd668a 100644
--- a/lilybuild/lilybuild/ci_syntax/ci_file.py
+++ b/lilybuild/lilybuild/ci_syntax/ci_file.py
@@ -1,281 +1,285 @@
+# This file is part of lilybuild.
+# SPDX-FileCopyrightText: 2025-2026 tusooa <tusooa@kazv.moe>
+# SPDX-License-Identifier: GPL-2.0-only
import yaml
import jsonschema
import json
import os
import re
# https://gitlab.com/gitlab-org/gitlab/-/blob/master/app/assets/javascripts/editor/schema/ci.json
+# Originally under MIT license
schema_file = os.path.join(os.path.dirname(__file__), 'ci.json')
schema = None
extend_limit = 11
class CIValidationError(Exception):
pass
def get_schema():
global schema
if schema is not None:
return schema
else:
with open(schema_file, 'r') as f:
schema = json.loads(f.read())
return schema
def toplevel_entries():
return get_schema()['properties']
def normalize_script(script):
if script is None:
return []
elif isinstance(script, str):
return [script]
else:
res = []
for l in script:
if isinstance(l, str):
res.append(l)
else:
res += l
return res
slug_re = re.compile('[^0-9a-z]')
def ci_slugify(s):
return re.sub(slug_re, '-', s.lower()[:63]).strip('-')
def get_job_extend_seq(job_name, all_jobs, depth=0):
# DFS traversal; child-first, self-last order
res = []
if job_name not in all_jobs:
raise CIValidationError(f'Job "{job_name}" does not exist, but occurs in `extends`.')
job_struct = all_jobs.get(job_name) or {}
parents = job_struct.get('extends', [])
if depth > extend_limit:
raise CIValidationError(f'`extends` depth is over the limit of {extend_limit}.')
if isinstance(parents, str):
parents = [parents]
for p in parents:
res += get_job_extend_seq(p, all_jobs, depth + 1)
res.append(job_name)
return res
depth_limit = 20
def merge_job_deep(res, parent, depth=0):
res_keys = set(res.keys())
if depth > depth_limit:
raise CIValidationError(f'depth is over the limit when merging job.')
for k in parent:
if k not in res_keys:
res[k] = parent[k]
elif isinstance(parent[k], dict) and isinstance(res[k], dict):
child = res[k]
# Necessary to avoid changing anything inside res[k] that was shallow-copied
res[k] = {}
merge_job_deep(res[k], child, depth + 1)
merge_job_deep(res[k], parent[k], depth + 1)
else:
# Presenting in both parent and child, and it's not a dict,
# so the child should take preference.
pass
def expand_job(job_name, all_jobs, defaults):
seq = get_job_extend_seq(job_name, all_jobs)
res = {}
for ancestor_name in reversed(seq):
merge_job_deep(res, all_jobs[ancestor_name])
merge_job_deep(res, defaults)
if 'extends' in res:
del res['extends']
return res
def get_ref_from_ref_cond(ref_cond):
# We don't support project name so ignore everything after @
pos = ref_cond.rfind('@')
if pos != -1:
ref_cond = ref_cond[:pos]
if ref_cond[0] == '/' and ref_cond[-1] == '/':
return '$CI_COMMIT_REF_NAME =~ ' + ref_cond
else:
return '$CI_COMMIT_REF_NAME == "' + ref_cond.replace('\\', '\\\\').replace('"', '\\"') + '"'
def get_alt_rules_from_job_struct(job_struct):
# Convert when/only/except to rules
when = None
if job_struct.get('when'):
when = job_struct.get('when')
positive_cond = []
if job_struct.get('only'):
for ref_cond in job_struct.get('only'):
positive_cond.append(get_ref_from_ref_cond(ref_cond))
negative_cond = []
if job_struct.get('except'):
for ref_cond in job_struct.get('except'):
negative_cond.append(get_ref_from_ref_cond(ref_cond))
rule_if = ''
if positive_cond:
rule_if += '(' + ' || '.join(positive_cond) + ')'
if negative_cond:
rule_if += ' && '
if negative_cond:
rule_if += '!(' + ' || '.join(negative_cond) + ')'
rule = {}
if when:
rule['when'] = when
if positive_cond or negative_cond:
rule['if'] = rule_if
if rule:
return [rule]
else:
return []
class CIJob:
def __init__(self, job_name, job_stage, job_struct):
self.name = job_name
self.stage = job_stage
self.struct_raw = job_struct
self.image = job_struct.get('image')
self.before_script = normalize_script(job_struct.get('before_script'))
self.script = normalize_script(job_struct.get('script'))
self.after_script = normalize_script(job_struct.get('after_script'))
self.artifacts = job_struct.get('artifacts') or {}
self.rules = job_struct.get('rules') or get_alt_rules_from_job_struct(job_struct)
self.dependencies = job_struct.get('dependencies')
self.services = job_struct.get('services') or []
def get_predefined_ci_variables(self):
vs = {
'CI': 'true',
'CI_JOB_NAME': self.name,
'CI_JOB_NAME_SLUG': ci_slugify(self.name),
'CI_JOB_STAGE': self.stage,
}
vs.update(self.struct_raw.get('variables', {}))
return vs
def has_artifacts_archive(self):
return self.artifacts and 'paths' in self.artifacts
def to_prop(self):
return {
'name': self.name,
'stage': self.stage,
'struct_raw': self.struct_raw,
}
@classmethod
def from_prop(cls, prop):
return cls(
prop['name'],
prop['stage'],
prop['struct_raw']
)
def is_pages(self):
return self.name == 'pages' or self.struct_raw.get('pages', False)
def has_supported_coverage_report(self):
return (
'reports' in self.artifacts
and self.artifacts['reports'].get('coverage_report')
and self.artifacts['reports']['coverage_report'].get('coverage_format') == 'cobertura'
and self.artifacts['reports']['coverage_report'].get('path')
)
OLD_TOPLEVEL_DEFAULTS = ['image', 'services', 'cache', 'before_script', 'after_script']
DEFAULT_STAGES = ['.pre', 'build', 'test', 'deploy', '.post']
DEFAULT_JOB_STAGE = 'test'
class CIFile:
'''
Class for parsing CI file.
'''
def __init__(self, file_content):
'''
Construct a CI File from its text content.
'''
f = yaml.safe_load(file_content)
if f is None:
self.stages = []
self.jobs = {}
return
jsonschema.validate(instance=f, schema=get_schema())
self.stages = f.get('stages', DEFAULT_STAGES)
self.jobs = {}
defaults = f.get('default', {})
if f.get('variables'):
defaults['variables'] = f['variables']
for kw in OLD_TOPLEVEL_DEFAULTS:
if kw not in defaults and kw in f:
defaults[kw] = f[kw]
all_jobs = {}
for job_name, job_struct in f.items():
# 'pages' is a special job
if job_name != 'pages' and job_name in toplevel_entries():
continue
all_jobs[job_name] = job_struct
for job_name in all_jobs:
# jobs starting with . will only be used for base jobs of other jobs,
# they themselves are not run
if job_name.startswith('.'):
continue
job_struct = expand_job(job_name, all_jobs, defaults)
job_stage = job_struct.get('stage', DEFAULT_JOB_STAGE)
if job_stage not in self.stages:
raise CIValidationError(f'Job "{job_name}": Stage "{job_stage}" is not specified in CI file')
self.jobs[job_name] = CIJob(job_name, job_stage, job_struct)
self.validate_logic()
def validate_logic(self):
for job_name in self.jobs:
self.validate_job(job_name)
def validate_job(self, job_name):
j = self.jobs[job_name]
my_stage_order = self.stage_order(job_name)
if j.dependencies is not None:
for d in j.dependencies:
try:
stage_order = self.stage_order(d)
except KeyError:
raise CIValidationError(f'Dependency "{d}" of job "{job_name}" does not exist')
if stage_order >= my_stage_order:
raise CIValidationError(f'Dependency "{d}" of job "{job_name}" is not before the job in stage')
def stage_order(self, jn):
return self.stages.index(self.jobs[jn].stage)
def get_grouped_jobs(self):
groups = {}
for job_name in self.jobs:
job = self.jobs[job_name]
if job.stage not in groups:
groups[job.stage] = []
groups[job.stage].append(job)
res = []
for stage in self.stages:
if stage in groups:
res.append((stage, groups[stage]))
return res
def get_jobs_to_pull_artifacts_from(self, job_name):
'''
Get a list of names of jobs of which the artifacts will be pulled
from for the job named `job_name`.
'''
dependencies = self.jobs[job_name].dependencies
if dependencies is None:
dependencies = []
cur_job_stage_order = self.stage_order(job_name)
for jn in self.jobs:
so = self.stage_order(jn)
if so < cur_job_stage_order:
dependencies.append(jn)
return dependencies
diff --git a/lilybuild/lilybuild/ci_syntax/rules.py b/lilybuild/lilybuild/ci_syntax/rules.py
index afcc55d..3e36b64 100644
--- a/lilybuild/lilybuild/ci_syntax/rules.py
+++ b/lilybuild/lilybuild/ci_syntax/rules.py
@@ -1,132 +1,135 @@
+# This file is part of lilybuild.
+# SPDX-FileCopyrightText: 2025-2026 tusooa <tusooa@kazv.moe>
+# SPDX-License-Identifier: GPL-2.0-only
import re
# XXX: Since we are stuck with Python 3.9, we can't disable backtrack yet.
# https://docs.python.org/3/library/re.html
patterns = {
'OP': re.compile(r'\s*(==|\|\||&&|=~|\!=|\!~|\!)'),
'PAREN_LEFT': re.compile(r'\s*\('),
'PAREN_RIGHT': re.compile(r'\s*\)'),
'STR_DOUBLE': re.compile(r'''\s*"((?:[^\\"]|\\.)*)"'''),
'STR_SINGLE': re.compile(r"""\s*'((?:[^\\']|\\.)*)'"""),
'REGEX': re.compile(r'''\s*/((?:[^\\/]|\\.)*)/'''),
'VAR': re.compile(r'\s*\$([A-Za-z_][A-Za-z0-9_]*)'),
'NULL': re.compile(r'\s*null'),
'END': re.compile(r'\s*$'),
}
ops = {
'==': (2, lambda a, b: a == b),
'!=': (2, lambda a, b: a != b),
'=~': (2, lambda a, b: not not re.search(b, a)),
'!~': (2, lambda a, b: not re.search(b, a)),
'!': (1, lambda a: not a),
'&&': (2, lambda a, b: a and b),
'||': (2, lambda a, b: a or b),
}
TERM_PRECEDENCE = 9
PAREN_PRECEDENCE = 8
def is_term(token):
return token[0] in ['STR_DOUBLE', 'STR_SINGLE', 'REGEX', 'VAR', 'NULL']
def get_precedence(token):
if is_term(token):
return TERM_PRECEDENCE
elif token[0] in ['PAREN_LEFT', 'PAREN_RIGHT']:
return PAREN_PRECEDENCE
elif token[0] == 'OP' and (token[1][0] in ['==', '!=', '=~', '!~']):
return 6
elif token[0] == 'OP' and (token[1][0] in ['!']):
return 5
elif token[0] == 'OP' and (token[1][0] in ['&&']):
return 4
elif token[0] == 'OP' and (token[1][0] in ['||']):
return 3
raise SyntaxError('Unknown token')
def tokenize_rule(rule_str):
pos = 0
tokenized = []
while not (len(tokenized) and tokenized[-1][0] == 'END'):
match = None
for t in patterns:
regex = patterns[t]
match = regex.match(rule_str, pos)
if match:
tokenized.append((t, match.groups()))
pos = match.end()
break
if not match:
raise SyntaxError(f'Bad rule "{rule_str}", at pos {pos}')
return tokenized[:-1]
def make_tree(tokenized):
# https://en.wikipedia.org/wiki/Shunting_yard_algorithm
stack = []
res = []
for t in tokenized:
if is_term(t):
res.append(t)
elif t[0] == 'OP':
if len(stack) and stack[-1][0] == 'OP' and get_precedence(stack[-1]) >= get_precedence(t):
res.append(stack.pop())
stack.append(t)
elif t[0] == 'PAREN_LEFT':
stack.append(t)
elif t[0] == 'PAREN_RIGHT':
while len(stack) and stack[-1][0] != 'PAREN_LEFT':
res.append(stack.pop())
if not len(stack):
raise SyntaxError('Mismatched parentheses')
stack.pop()
while len(stack):
t = stack.pop()
if t[0] == 'PAREN_LEFT':
raise SyntaxError('Mismatched parentheses')
res.append(t)
stack = []
for t in res:
if t[0] == 'OP':
opname = t[1][0]
arity = ops[opname][0]
if len(stack) < arity:
raise SyntaxError('Missing operands')
operands = tuple(reversed([stack.pop() for i in range(arity)]))
stack.append((t[0], (opname,) + operands))
else:
stack.append(t)
if len(stack) != 1:
raise SyntaxError('Too many operands')
return stack[0]
def parse_rule(rule_str):
return make_tree(tokenize_rule(rule_str))
backslash_re = re.compile(r'\\(.)')
def replace_backslash(match):
c = match.groups()[0]
return c
def replace_backslash_in_regex(match):
c = match.groups()[0]
if c == '/':
return c
# Treat everything except \/ as is
return '\\' + c
def evaluate_rule(expr, variables):
if expr[0] == 'VAR':
return variables.get(expr[1][0])
elif expr[0] == 'STR_DOUBLE' or expr[0] == 'STR_SINGLE':
return backslash_re.sub(replace_backslash, expr[1][0])
elif expr[0] == 'REGEX':
return backslash_re.sub(replace_backslash_in_regex, expr[1][0])
elif expr[0] == 'NULL':
return None
elif expr[0] == 'OP':
opname = expr[1][0]
operands = [evaluate_rule(o, variables) for o in expr[1][1:]]
return ops[opname][1](*operands)
raise SyntaxError(f'Cannot evaluate expression {expr}')
diff --git a/lilybuild/lilybuild/config.py b/lilybuild/lilybuild/config.py
index 9362130..f4f811e 100644
--- a/lilybuild/lilybuild/config.py
+++ b/lilybuild/lilybuild/config.py
@@ -1,268 +1,271 @@
+# This file is part of lilybuild.
+# SPDX-FileCopyrightText: 2025-2026 tusooa <tusooa@kazv.moe>
+# SPDX-License-Identifier: GPL-2.0-only
from buildbot.plugins import *
from buildbot.process import buildstep, logobserver
from twisted.internet import defer
from .ci_steps import RunCIJobStep, AnalyzeCIFileCommand, on_success
from .phorge import CheckoutSourceFromPhorge, SendArtifactLinkToPhorge, SendBuildStatusToPhorge, MaybeRequireApprovalForPhorge
from .helpers import normalize_base_url, phorge_token_to_arcrc
import yaml
def to_params(d):
res = []
for n in d:
res.append(util.FixedParameter(name=n, default=d[n]))
return res
work_root = util.Interpolate('repos/%(prop:lilybuild_repo_id)s')
src_relative = 'sources'
src_dir = util.Interpolate('repos/%(prop:lilybuild_repo_id)s/sources')
result_relative = 'result'
result_dir = util.Interpolate('repos/%(prop:lilybuild_repo_id)s/result')
artifact_stage_relative = 'stage'
artifact_stage_dir = util.Interpolate('repos/%(prop:lilybuild_repo_id)s/stage')
default_storage_dir = '/buildbot/storage'
report_phorge = 'phorge'
report_forgejo = 'forgejo'
default_artifact_uncompressed_limit = 200 * 1024 * 1024
default_artifact_compressed_limit = 200 * 1024 * 1024
class LilyBuildConfig:
builder_name = 'lilybuild'
builder_name_run_job = 'lilybuild-job'
builder_name_force = 'lilybuild-force'
main_builder_tag = 'lilybuild-pipeline'
triggerable_scheduler_name = 'lilybuild-triggerable'
force_triggerable_scheduler_name = 'lilybuild-force-triggerable'
def __init__(self, c, workernames, reports=None, phorge_base_url=None, phorge_token=None, ssh_priv_key=None, ssh_known_hosts=None, storage_dir=default_storage_dir, artifact_link_base=None, artifact_uncompressed_limit=None, artifact_compressed_limit=None):
self.c = c
self.poll_interval = 300
self.workernames = workernames
if reports is None:
reports = [report_phorge, report_forgejo]
self.reports = reports
self.repos = {}
self.repo_id_by_url = {}
self.branch_skip_re = '^phabricator/'
self.phorge_base_url = normalize_base_url(phorge_base_url)
self.phorge_token = phorge_token
self.ssh_priv_key = ssh_priv_key
self.ssh_known_hosts = ssh_known_hosts
self.storage_dir = storage_dir
self.artifact_link_base = normalize_base_url(artifact_link_base)
self.artifact_uncompressed_limit = artifact_uncompressed_limit or default_artifact_uncompressed_limit
self.artifact_compressed_limit = artifact_compressed_limit or default_artifact_compressed_limit
def configure_factory_and_builder(self):
self.add_lilybuild_builder()
self.add_lilybuild_job_builder()
def create_source_step(self):
return CheckoutSourceFromPhorge(
lbc=self,
repourl=util.Property('lilybuild_repo'),
mode='full',
workdir=src_dir,
submodules=True
)
def get_arcrc_for_repo(self, repo_id):
return util.Transform(
phorge_token_to_arcrc,
self.repos[repo_id]['phorge_base_url'],
self.repos[repo_id]['phorge_token'],
)
def get_phorge_base_url_for_repo(self, repo_id):
return self.repos[repo_id]['phorge_base_url']
def add_lilybuild_builder(self):
factory = util.BuildFactory()
factory.addStep(self.create_source_step())
if report_phorge in self.reports:
factory.addStep(MaybeRequireApprovalForPhorge(lbc=self, workdir=src_dir))
factory.addStep(SendArtifactLinkToPhorge(lbc=self, workdir=src_dir))
factory.addStep(AnalyzeCIFileCommand(
lbc=self,
workdir=work_root,
src_relative=src_relative,
src_dir=src_dir,
storage_dir=self.storage_dir,
artifact_stage_relative=artifact_stage_relative,
artifact_stage_dir=artifact_stage_dir,
result_relative=result_relative,
result_dir=result_dir,
doStepIf=on_success,
))
if report_phorge in self.reports:
factory.addStep(SendBuildStatusToPhorge(lbc=self, workdir=src_dir))
builder = util.BuilderConfig(
name=self.builder_name,
workernames=self.workernames,
factory=factory,
tags=[self.main_builder_tag],
)
self.c['builders'].append(builder)
def add_lilybuild_job_builder(self):
factory_job = util.BuildFactory()
factory_job.addStep(RunCIJobStep(
lbc=self,
workdir=work_root,
src_relative=src_relative,
src_dir=src_dir,
storage_dir=self.storage_dir,
artifact_stage_relative=artifact_stage_relative,
artifact_stage_dir=artifact_stage_dir,
result_relative=result_relative,
result_dir=result_dir,
artifact_link_base=self.artifact_link_base
))
builder_job = util.BuilderConfig(
name=self.builder_name_run_job,
workernames=self.workernames,
factory=factory_job,
# Tell buildbot to never collapse build requests for this one
# because otherwise triggering multiple jobs will cause it to only run one
# https://docs.buildbot.net/current/manual/configuration/builders.html#collapsing-build-requests
collapseRequests=False,
)
self.c['builders'].append(builder_job)
s = schedulers.Triggerable(self.triggerable_scheduler_name, builderNames=[self.builder_name_run_job])
self.c['schedulers'].append(s)
def record_url(self, repo_id, repo_url):
if repo_url in self.repo_id_by_url:
raise Exception(f'repo url "{repo_url}" already recorded')
self.repo_id_by_url[repo_url] = repo_id
def add_repo(
self,
repo_id,
repo_url,
do_poll=False,
alternative_urls=None,
phorge_base_url=None,
phorge_token=None,
# Function(build -> dict(str -> str | renderable<str>))
variables_getter=None,
artifact_compressed_limit=None,
artifact_uncompressed_limit=None,
artifact_latest_branch_map=None,
):
if not alternative_urls:
alternative_urls = []
self.repos[repo_id] = {
'repo_id': repo_id,
'repo_url': repo_url,
'do_poll': do_poll,
'alternative_urls': alternative_urls,
'phorge_base_url': normalize_base_url(phorge_base_url) or self.phorge_base_url,
'phorge_token': phorge_token or self.phorge_token,
'variables_getter': variables_getter or (lambda _build: {}),
'artifact_compressed_limit': artifact_compressed_limit or self.artifact_compressed_limit,
'artifact_uncompressed_limit': artifact_uncompressed_limit or self.artifact_uncompressed_limit,
'artifact_latest_branch_map': artifact_latest_branch_map or {},
}
self.record_url(repo_id, repo_url)
for u in alternative_urls:
self.record_url(repo_id, u)
def get_builder_name_for_repo(self, repo_def):
main_repo_url = repo_def['repo_url']
return f'lilybuild - {main_repo_url}'
def add_one_repo_def(self, repo_def):
print('Adding repo', repo_def)
repo_urls = [repo_def['repo_url']] + repo_def['alternative_urls']
repo_id = repo_def['repo_id']
do_poll = repo_def['do_poll']
main_repo_url = repo_def['repo_url']
properties = {
'lilybuild_repo': main_repo_url,
'lilybuild_repo_id': repo_id,
'virtual_builder_name': self.get_builder_name_for_repo(repo_def),
'virtual_builder_tags': [self.main_builder_tag],
}
self.c['schedulers'].append(schedulers.AnyBranchScheduler(
name=f'lilybuild-anybranch - {main_repo_url}',
change_filter=util.ChangeFilter(
repository=repo_urls,
),
treeStableTimer=None,
builderNames=[self.builder_name],
properties=properties))
if do_poll:
for repo in repo_urls:
print('Adding poller')
self.c['change_source'].append(changes.GitPoller(
repo,
workdir=f'gitpoller/{repo_id}', branches=True,
pollInterval=self.poll_interval))
def configure_pipeline_defs(self):
for repo_id in self.repos:
self.add_one_repo_def(self.repos[repo_id])
self.add_force_builder()
self.add_force_scheduler()
def add_force_builder(self):
def get_repo_id_by_url(url):
return self.repo_id_by_url[url]
factory_force = util.BuildFactory()
factory_force.addStep(steps.Trigger(
name='trigger lilybuild',
schedulerNames=[self.force_triggerable_scheduler_name],
waitForFinish=True,
set_properties={
'lilybuild_repo': util.Property('lilybuild_repo'),
'lilybuild_repo_id': util.Transform(get_repo_id_by_url, util.Property('lilybuild_repo')),
'virtual_builder_name': util.Interpolate('lilybuild - %(prop:lilybuild_repo)s'),
'virtual_builder_tags': [self.main_builder_tag],
}
))
builder_force = util.BuilderConfig(
name=self.builder_name_force,
workernames=self.workernames,
factory=factory_force,
)
self.c['builders'].append(builder_force)
# this is triggered by lilybuild-force, and it triggers lilybuild
# via its virtual builder
s = schedulers.Triggerable(
self.force_triggerable_scheduler_name,
builderNames=[self.builder_name],
)
self.c['schedulers'].append(s)
def add_force_scheduler(self):
repo_urls = []
for r in self.repos.values():
repo_urls.append(r['repo_url'])
repo_param = util.ChoiceStringParameter(
name='lilybuild_repo',
required=True,
strict=True,
choices=repo_urls,
)
self.c['schedulers'].append(schedulers.ForceScheduler(
name="force",
builderNames=[self.builder_name_force],
properties=[repo_param],
))
diff --git a/lilybuild/lilybuild/coverage.py b/lilybuild/lilybuild/coverage.py
index 4c87b1a..17d156c 100755
--- a/lilybuild/lilybuild/coverage.py
+++ b/lilybuild/lilybuild/coverage.py
@@ -1,75 +1,78 @@
#!/usr/bin/env python3
+# This file is part of lilybuild.
+# SPDX-FileCopyrightText: 2025-2026 tusooa <tusooa@kazv.moe>
+# SPDX-License-Identifier: GPL-2.0-only
import json
import sys
import os
import shutil
from pycobertura import Cobertura
from pycobertura.filesystem import DirectoryFileSystem
def is_covered(covered):
# old version returns True for covered and False for uncovered
# new version is 'hit' 'partial' and 'miss'
# https://github.com/aconrad/pycobertura/commit/866da18254c3eaf645719b11158daccf73acfe18
return covered == 'hit' or covered == 'partial' or covered is True
def convert_lines(cobertura, fs, filename):
statuses = cobertura.line_statuses(filename)
with fs.open(filename) as f:
lines = ['N' for _ in f]
for (line_num, covered) in statuses:
lines[line_num - 1] = 'C' if is_covered(covered) else 'U'
return ''.join(lines)
def get_file_full_path(base_dir, file_name):
res_abs = os.path.realpath(base_dir, strict=True)
file_abs = os.path.realpath(os.path.join(base_dir, file_name), strict=True)
if not file_abs.startswith(res_abs + os.sep):
raise RuntimeError(f'File is not in the base dir: {file_name}')
return file_abs
class SafeDirectoryFileSystem(DirectoryFileSystem):
def real_filename(self, filename):
return get_file_full_path(self.source_dir, filename)
def main(kwargs):
source_dir = kwargs['source_dir']
result_dir = kwargs['result_dir']
untrusted_coverage_file = kwargs['untrusted_coverage_file']
output_dir = kwargs['output_dir']
coverage_file = get_file_full_path(result_dir, untrusted_coverage_file)
fs = SafeDirectoryFileSystem(source_dir)
cobertura = Cobertura(coverage_file, fs)
files = cobertura.files()
result = {}
for f in files:
try:
result[f] = convert_lines(cobertura, fs, f)
except Exception as e:
print(f'Error while processing coverage for file "{f}". The file does not exist in the source repository, or cannot be read.', file=sys.stderr)
try:
os.mkdir(output_dir)
except FileExistsError:
pass
except:
print('Cannot create output dir.')
raise
shutil.copyfile(coverage_file, os.path.join(output_dir, 'coverage-cobertura.xml'))
with open(os.path.join(output_dir, 'coverage-phorge.json'), 'w') as f:
print(json.dumps(result), file=f)
if __name__ == '__main__':
if len(sys.argv) > 1:
a = json.loads(sys.argv[1])
else:
a = json.loads(sys.stdin.read())
try:
main(a)
except:
print('Error processing coverage file.')
sys.exit(1)
diff --git a/lilybuild/lilybuild/forgejo.py b/lilybuild/lilybuild/forgejo.py
index 101991d..fe49b42 100644
--- a/lilybuild/lilybuild/forgejo.py
+++ b/lilybuild/lilybuild/forgejo.py
@@ -1,22 +1,25 @@
+# This file is part of lilybuild.
+# SPDX-FileCopyrightText: 2025-2026 tusooa <tusooa@kazv.moe>
+# SPDX-License-Identifier: GPL-2.0-only
from twisted.internet import defer
from buildbot.www.hooks.github import GitHubEventHandler
PARENT_FIRST = 'PARENT_FIRST'
CHILD_FIRST = 'CHILD_FIRST'
# Forgejo webhooks return the commits in child-first order
# https://codeberg.org/forgejo/forgejo/issues/7737
# This helper class intercepts the payload and reverse the
# order of the commits.
class ForgejoEventHandler(GitHubEventHandler):
def __init__(self, secret, strict, commits_order=CHILD_FIRST, **kwargs):
self.commits_order = commits_order
super().__init__(secret, strict, **kwargs)
@defer.inlineCallbacks
def _get_payload(self, request):
payload = yield super()._get_payload(request)
if 'commits' in payload and self.commits_order == CHILD_FIRST:
payload['commits'].reverse()
return payload
diff --git a/lilybuild/lilybuild/helpers.py b/lilybuild/lilybuild/helpers.py
index 9c093c6..b46c77c 100644
--- a/lilybuild/lilybuild/helpers.py
+++ b/lilybuild/lilybuild/helpers.py
@@ -1,139 +1,142 @@
+# This file is part of lilybuild.
+# SPDX-FileCopyrightText: 2025-2026 tusooa <tusooa@kazv.moe>
+# SPDX-License-Identifier: GPL-2.0-only
import json
import shlex
import re
def normalize_path_for_rsync(path):
n = path
if n.startswith('./'):
n = n[2:]
if n.endswith('/'):
n = n[:-1]
return '/' + n
def rsync_rules_from_artifacts(artifacts):
paths = artifacts.get('paths', [])
# Include all dirs
rules = ['--include', '*/']
for p in paths:
normalized_path = normalize_path_for_rsync(p)
rules += [
# If path already has /** at the end, the second will actually do nothing,
# but it's fine to add it anyway. The directory itself will still
# be visited because of the --include */ option we add at the beginning.
'--include', normalized_path,
'--include', normalized_path + '/**',
]
# Exclude everything else
rules += ['--exclude', '*']
return rules
def normalize_base_url(base_url):
return base_url.rstrip('/') if base_url else None
def phorge_token_to_arcrc(normalized_base_url, token):
return json.dumps({
'hosts': {
normalized_base_url + '/api/': {
'token': token,
},
},
})
def ci_vars_to_env_file(v):
res = []
for name in v:
value = v[name]
if not isinstance(value, str):
value = str(value)
if '\n' not in value:
res.append(f'{name}={value}')
# Otherwise, ignore multiline variables because podman cannot pass it in env file
return '\n'.join(res)
DEFAULT_SCRIPT_HEADER = '''\
#!/bin/sh
set -e -x
cd /build
'''
def get_job_script(job):
return (
DEFAULT_SCRIPT_HEADER +
'\n\n'.join(job.before_script) + '\n\n' +
'\n\n'.join(job.script) +
'\n\nset +e\n\n' +
'\n\n'.join(job.after_script) +
'\n\nexit 0'
)
def normalize_image(image):
if isinstance(image, str):
return json.dumps({'name': image})
else:
return json.dumps(image)
def get_service_aliases_from_name(name):
# https://docs.gitlab.com/ci/services/#accessing-the-services
pos = name.find(':')
if pos != -1:
name = name[:pos]
primary = name.replace('/', '__')
secondary = name.replace('/', '-')
if primary == secondary:
return [primary]
else:
return [primary, secondary]
SERVICE_ALIAS_SEPARATOR = re.compile(r'[ ,]+')
def normalize_services(services):
res = []
for s in services:
so = s if isinstance(s, dict) else {'name': s}
normalized_service = {
'name': so['name'],
'aliases': SERVICE_ALIAS_SEPARATOR.split(so.get('alias')) if so.get('alias') else get_service_aliases_from_name(so['name']),
'entrypoint': so.get('entrypoint'),
'command': so.get('command'),
}
res.append(normalized_service)
return json.dumps(res)
VAR_REGEX = re.compile(r'\$([A-Za-z0-9_]+|\{[A-Za-z0-9_]+\})')
def expand_in_vars(value, vs):
def replacement(match):
varname = match.group(1)
if varname.startswith('{'):
varname = varname[1:-1]
return vs.get(varname, '')
return VAR_REGEX.sub(replacement, value)
def generate_metadata_from_job(repo_id, job, vs):
res = {
'repo_id': repo_id,
'caches': [],
'cache_last_invalidated_sec': 0,
'protected': False,
}
caches = job.struct_raw.get('cache') or []
if not isinstance(caches, list):
caches = [caches]
for cache_def in caches:
cache_key = cache_def.get('key')
if isinstance(cache_key, str):
cache_key = expand_in_vars(cache_key, vs)
paths = cache_def.get('paths') or []
res['caches'].append({
'key': cache_key,
'paths': paths,
'when': cache_def.get('when') or 'on_success',
'policy': cache_def.get('policy') or 'pull-push',
})
return json.dumps(res)
diff --git a/lilybuild/lilybuild/pages.py b/lilybuild/lilybuild/pages.py
index 4b01e02..bd07c98 100644
--- a/lilybuild/lilybuild/pages.py
+++ b/lilybuild/lilybuild/pages.py
@@ -1,84 +1,87 @@
+# This file is part of lilybuild.
+# SPDX-FileCopyrightText: 2025-2026 tusooa <tusooa@kazv.moe>
+# SPDX-License-Identifier: GPL-2.0-only
import os
import subprocess
import shutil
import sys
try:
import tarfile
tarfile.FilterError
except AttributeError:
import backports.tarfile as tarfile
'''
This module implements blue-green deployment for pages.
'''
FIRST = '0'
SECOND = '1'
CURRENT = 'cur'
def init_deployment(dir_name):
'''
Init the deployment at `dir_name` and return which directory we should
write into for the next deployment.
'''
os.makedirs(os.path.join(dir_name, FIRST), exist_ok=True)
os.makedirs(os.path.join(dir_name, SECOND), exist_ok=True)
link_file = os.path.join(dir_name, CURRENT)
try:
current = os.readlink(link_file)
except FileNotFoundError as e:
os.symlink(SECOND, link_file, target_is_directory=True)
current = SECOND
return FIRST if current == SECOND else SECOND
def switch_symlink(dir_name, target):
subprocess.run([
'ln',
'-sfvn',
'--',
target,
os.path.join(dir_name, CURRENT),
], check=True)
def empty_directory(dir_name):
shutil.rmtree(dir_name)
os.makedirs(dir_name, exist_ok=True)
class TarPagesFilter:
public_dir = 'public'
def __init__(
self,
limit_bytes=100*1024*1024 # 100 MiB
):
self.total_bytes_extracted = 0
self.limit_bytes = limit_bytes
def __call__(self, member, path):
filtered_member = tarfile.data_filter(member, path)
if not filtered_member:
return None
if self.total_bytes_extracted + filtered_member.size > self.limit_bytes:
self.total_bytes_extracted = self.limit_bytes + 1
raise RuntimeError('Limit exceeded')
name = os.path.normpath(filtered_member.name)
if not (name == self.public_dir or name.startswith(self.public_dir + '/')):
return None
self.total_bytes_extracted += filtered_member.size
return filtered_member
def extract_archive(dir_name, archive_file):
with tarfile.open(archive_file) as tf:
tf.errorlevel = 1
tf.extractall(dir_name, filter=TarPagesFilter())
def deploy_pages(dir_name, archive_file):
target = init_deployment(dir_name)
full_target = os.path.join(dir_name, target)
empty_directory(full_target)
extract_archive(full_target, archive_file)
switch_symlink(dir_name, target)
if __name__ == '__main__':
deploy_pages(sys.argv[1], sys.argv[2])
diff --git a/lilybuild/lilybuild/phorge.py b/lilybuild/lilybuild/phorge.py
index d4f4a03..e381c00 100644
--- a/lilybuild/lilybuild/phorge.py
+++ b/lilybuild/lilybuild/phorge.py
@@ -1,346 +1,349 @@
+# This file is part of lilybuild.
+# SPDX-FileCopyrightText: 2025-2026 tusooa <tusooa@kazv.moe>
+# SPDX-License-Identifier: GPL-2.0-only
import stat
import os
import json
from buildbot.plugins import *
from buildbot.process import buildstep, logobserver
from buildbot.interfaces import IRenderable
from buildbot.steps.download_secret_to_worker import DownloadSecretsToWorker, RemoveWorkerFileSecret
from buildbot.steps.worker import CompositeStepMixin
from buildbot.util import bytes2unicode
from twisted.internet import defer
from twisted.python import log
SEND_COVERAGE_EXEC = '/lilybuild/lilybuild/send_coverage.py'
class PhorgeMixin(CompositeStepMixin, buildstep.ShellMixin):
'''
Should be inherited by a BuildStep.
Required properties:
self.lbc: LilyBuildConfig
self.secret_dir: str
'''
staging_uri_prop_name = 'harbormaster_variable_repository.staging.uri'
staging_ref_prop_name = 'harbormaster_variable_repository.staging.ref'
diff_id_prop_name = 'harbormaster_variable_buildable.diff'
build_target_prop_name = 'harbormaster_build_target_phid'
arc_command = '/tools/arcanist/bin/arc'
def setup_phorge_mixin(self, kwargs):
shell_mixin_kwargs = {}
shell_mixin_inherit_args = ['workdir']
for a in shell_mixin_inherit_args:
if a in kwargs:
shell_mixin_kwargs[a] = kwargs[a]
self.setupShellMixin(shell_mixin_kwargs)
def get_is_phorge(self):
return not not self.getProperty(self.build_target_prop_name)
def get_secret_dir(self):
# Taken from buildbot.util.git
workerbuilddir = bytes2unicode(self.build.builder.config.workerbuilddir)
workdir = self.workdir.rstrip('/\\')
if os.path.isabs(workdir):
parent_path = os.path.dirname(workdir)
else:
assert self.worker is not None
parent_path = os.path.join(
self.worker.worker_basedir, os.path.dirname(workdir)
)
basename = f'.{workerbuilddir}.{os.path.basename(workdir)}.lilybuild-phorge'
secret_dir = os.path.join(parent_path, basename)
log.msg('Arc secret dir is', secret_dir)
return secret_dir
@defer.inlineCallbacks
def make_command(self, **kwargs):
cmd = yield self.makeRemoteShellCommand(
env={'HOME': self.get_secret_dir()},
**kwargs
)
return cmd
@defer.inlineCallbacks
def make_arc_command(self, args, **kwargs):
cmd = yield self.make_command(
command=[self.arc_command] + args,
**kwargs
)
return cmd
@defer.inlineCallbacks
def run_command_with_secret(self, **kwargs):
try:
res = yield self.download_arc_secret()
if res != util.SUCCESS:
return res
cmd = yield self.make_command(**kwargs)
yield self.runCommand(cmd)
return cmd.results()
except Exception as e:
raise e
finally:
yield self.remove_arc_secret()
@defer.inlineCallbacks
def run_arc_with_secret(self, args, **kwargs):
res = yield self.run_command_with_secret(
command=[self.arc_command] + args,
**kwargs
)
return res
@defer.inlineCallbacks
def download_arc_secret(self):
arcrc = self.lbc.get_arcrc_for_repo(self.getProperty('lilybuild_repo_id'))
if not arcrc:
self.addCompleteLog('error', 'arcrc secret is not specified for the repository')
return util.FAILURE
arcrc_content = yield self.build.render(arcrc)
path = os.path.join(self.get_secret_dir(), '.arcrc')
yield self.downloadFileContentToWorker(
path, arcrc_content, mode=stat.S_IRUSR | stat.S_IWUSR, workdir=self.workdir
)
# If failed, it will raise
return util.SUCCESS
@defer.inlineCallbacks
def remove_arc_secret(self):
path = self.get_secret_dir()
yield self.runRmdir(path, abandonOnFailure=False)
return util.SUCCESS
class CheckoutSourceFromPhorge(PhorgeMixin, steps.Git):
'''
Set a property `lilybuild_source`:
`non-phorge` if the source is not a Differential Diff,
`staging` if the source is checked out from staging area,
`arc-patch` if the source is checked out by `arc patch`, either because
there is no staging area defined, or because the staging area does not
have the relevant diffs.
'''
def __init__(self, lbc, **kwargs):
self.lbc = lbc
self.setup_phorge_mixin(kwargs)
super().__init__(
sshPrivateKey=lbc.ssh_priv_key,
sshKnownHosts=lbc.ssh_known_hosts,
**kwargs)
@defer.inlineCallbacks
def run_vc(self, branch, revision, patch):
def set_prop(k, v):
self.setProperty(k, v, self.__class__.__name__)
is_phorge = self.get_is_phorge()
if not is_phorge:
set_prop('lilybuild_source', 'non-phorge')
res = yield super().run_vc(branch, revision, patch)
return res
staging_uri = self.getProperty(self.staging_uri_prop_name)
staging_ref = self.getProperty(self.staging_ref_prop_name)
if staging_uri:
old_repourl = self.repourl
self.repourl = staging_uri
# Assume branch is the same as staging ref
try:
res = yield super().run_vc(branch, revision, patch)
if res == util.SUCCESS:
# If we can get the source from the staging area, then we are done
set_prop('lilybuild_source', 'staging')
return res
except:
pass
self.addCompleteLog('log', 'Cannot fetch source from staging area, trying to `arc patch`')
self.repourl = old_repourl
else:
self.addCompleteLog('log', 'No staging area defined, trying to `arc patch`')
res = yield super().run_vc(branch='HEAD', revision=None, patch=None)
if res != util.SUCCESS:
self.addCompleteLog('error', 'Cannot checkout repository head, unable to proceed')
return res
diff_id = self.getProperty(self.diff_id_prop_name)
if not diff_id:
self.addCompleteLog('error', 'Diff id is not present')
return util.FAILURE
res = yield self.run_arc_with_secret([
'patch',
'--diff', diff_id,
'--nobranch', '--nocommit',
])
set_prop('lilybuild_source', 'arc-patch')
return res
class SendArtifactLinkToPhorge(PhorgeMixin, steps.BuildStep):
def __init__(self, lbc, **kwargs):
self.lbc = lbc
self.setup_phorge_mixin(kwargs)
super().__init__(
name='Send artifact link to Phorge',
doStepIf=lambda step: step.get_is_phorge(),
alwaysRun=True,
**kwargs
)
@defer.inlineCallbacks
def run(self):
if self.getProperty('lilybuild_require_approval'):
# Do not send another artifact if this requires approval
return util.SKIPPED
build_url = yield self.build.getUrl()
data = {
'buildTargetPHID': self.getProperty(self.build_target_prop_name),
'artifactKey': f'lilybuild-{self.build.buildid}',
'artifactType': 'uri',
'artifactData': {
'uri': build_url,
'name': f'Buildbot build #{self.build.buildid}',
'ui.external': True,
},
}
encoded_data = json.dumps(data)
res = yield self.run_arc_with_secret([
'call-conduit',
'--',
'harbormaster.createartifact',
], initialStdin=encoded_data)
return res
class MaybeRequireApprovalForPhorge(PhorgeMixin, steps.BuildStep):
def __init__(self, lbc, sources_need_approval=None, **kwargs):
self.lbc = lbc
if sources_need_approval is None:
sources_need_approval = ['arc-patch']
self.sources_need_approval = sources_need_approval
self.setup_phorge_mixin(kwargs)
super().__init__(
name='Maybe require approval for phorge sources',
doStepIf=lambda step: step.get_is_phorge(),
alwaysRun=True,
**kwargs
)
@defer.inlineCallbacks
def run(self):
def set_prop(k, v):
self.setProperty(k, v, self.__class__.__name__)
if self.getProperty('lilybuild_source') not in self.sources_need_approval:
set_prop('lilybuild_require_approval', False)
return util.SUCCESS
build_data = yield self.master.data.get(('builds', self.build.buildid))
build_request_data = yield self.master.data.get(('buildrequests', build_data['buildrequestid']))
buildset_data = yield self.master.data.get(('buildsets', build_request_data['buildsetid']))
rebuilt_buildid = buildset_data['rebuilt_buildid']
if rebuilt_buildid is not None:
# This is rebuilt, because someone approved it earlier
set_prop('lilybuild_require_approval', False)
return util.SUCCESS
self.addCompleteLog('info', 'This build requires approval. To approve, rebuild this build.')
build_url = yield self.build.getUrl()
data = {
'buildTargetPHID': self.getProperty(self.build_target_prop_name),
'artifactKey': f'lilybuild-{self.build.buildid}-pending',
'artifactType': 'uri',
'artifactData': {
'uri': build_url,
'name': f'Requires approval',
'ui.external': True,
},
}
encoded_data = json.dumps(data)
res = yield self.run_arc_with_secret([
'call-conduit',
'--',
'harbormaster.createartifact',
], initialStdin=encoded_data)
set_prop('lilybuild_require_approval', True)
return util.FAILURE
class SendBuildStatusToPhorge(PhorgeMixin, steps.BuildStep):
def __init__(self, lbc, **kwargs):
self.lbc = lbc
self.setup_phorge_mixin(kwargs)
super().__init__(
name='Send build status to Phorge',
doStepIf=lambda step: step.get_is_phorge(),
alwaysRun=True,
**kwargs
)
@defer.inlineCallbacks
def run(self):
if self.getProperty('lilybuild_require_approval'):
# Do not send final build result if this is skipped, because
# otherwise we cannot update it later.
return util.SKIPPED
build_url = yield self.build.getUrl()
data = {
'receiver': self.getProperty(self.build_target_prop_name),
'type': 'pass' if self.build.results == util.SUCCESS else 'fail',
}
encoded_data = json.dumps(data)
res = yield self.run_arc_with_secret([
'call-conduit',
'--',
'harbormaster.sendmessage',
], initialStdin=encoded_data)
return res
class SendCoverageToPhorge(PhorgeMixin, steps.BuildStep):
def __init__(self, lbc, coverage_file, **kwargs):
self.lbc = lbc
self.coverage_file = coverage_file
self.setup_phorge_mixin(kwargs)
super().__init__(
name='Send coverage to Phorge',
doStepIf=lambda step: step.get_is_phorge(),
alwaysRun=True,
**kwargs
)
@defer.inlineCallbacks
def run(self):
phorge_url = yield self.build.render(self.lbc.get_phorge_base_url_for_repo(self.getProperty('lilybuild_repo_id')))
build_url = yield self.build.getUrl()
filename = yield self.build.render(self.coverage_file)
receiver = self.getProperty(self.build_target_prop_name)
job_name = self.getProperty('lilybuild_job_prop').get('name')
is_success = self.build.results == util.SUCCESS
data = {
'filename': filename,
'build_url': build_url,
'receiver': receiver,
'is_success': is_success,
'phorge_url': phorge_url,
'job_name': job_name,
}
encoded_data = json.dumps(data)
res = yield self.run_command_with_secret(command=[
SEND_COVERAGE_EXEC,
], initialStdin=encoded_data)
return res
diff --git a/lilybuild/lilybuild/podman_helper.py b/lilybuild/lilybuild/podman_helper.py
index ba12a47..17a69e5 100755
--- a/lilybuild/lilybuild/podman_helper.py
+++ b/lilybuild/lilybuild/podman_helper.py
@@ -1,541 +1,544 @@
#!/usr/bin/env python3
+# This file is part of lilybuild.
+# SPDX-FileCopyrightText: 2025-2026 tusooa <tusooa@kazv.moe>
+# SPDX-License-Identifier: GPL-2.0-only
import subprocess
import sys
import os
import json
import random
import traceback
import string
import time
import hashlib
import re
import tempfile
import lilybuild.safetar
col_info = '\x1b[1;34m[INFO]'
col_success = '\x1b[1;32m[SUCC]'
col_warn = '\x1b[1;33m<WARN>'
col_error = '\x1b[1;31m!ERROR!'
col_reset = '\x1b[0m'
any_spaces_re = re.compile(r'\s')
def pinfo(*args, **kwargs):
print(col_info, *args, col_reset, **kwargs)
sys.stdout.flush()
def perror(*args, **kwargs):
print(col_error, *args, col_reset, **kwargs)
sys.stdout.flush()
def pwarn(*args, **kwargs):
print(col_warn, *args, col_reset, **kwargs)
sys.stdout.flush()
def psuccess(*args, **kwargs):
print(col_success, *args, col_reset, **kwargs)
sys.stdout.flush()
def gen_random_id():
# https://stackoverflow.com/questions/2257441/random-string-generation-with-upper-case-letters-and-digits
return ''.join(random.SystemRandom().choice(string.ascii_lowercase + string.digits) for _ in range(10))
def image_to_podman_args(image):
name = image['name']
args = []
if 'entrypoint' in image:
# ci.json requires that the entrypoint is an array of strings
ep = json.dumps(image['entrypoint'])
args += ['--entrypoint', ep]
args += ['--', name]
return args
class PodmanHelper:
cache_storage_root_dir = '/cache'
cache_max_bytes = 10 * 1024 * 1024 * 1024
work_vol_mount_dir = '/build'
script_vol_mount_dir = '/script'
cache_tmp_dir = '/tmp/cache/'
script_name = script_vol_mount_dir + '/run.sh'
env_file_basename = 'env'
metadata_file_basename = 'metadata.json'
cur_cache_basename = 'cur'
volume_helper_image = os.environ.get('LILYBUILD_VOLUME_HELPER_IMAGE', 'r.lily-is.land/infra/lilybuild/volume-helper:servant')
key_file_pub = '/secrets/lilybuild-volume-helper-key.pub'
key_file_sub = '/secrets/lilybuild-volume-helper-key'
ssh_port = '2222'
ssh_command = f'ssh -p {ssh_port} -i {key_file_sub} -oStrictHostKeyChecking=no -oUserKnownHostsFile=/dev/null'
ssh_command_list = ['ssh', '-p', ssh_port, '-i', key_file_sub, '-oStrictHostKeyChecking=no', '-oUserKnownHostsFile=/dev/null']
worker_container_name = os.environ.get('HOSTNAME', '')
ssh_max_wait = 10
ssh_wait_interval_sec = 1
service_max_wait_sec = 60 * 5
service_wait_interval_sec = 10
container_run_timeout_sec = 60 * 60 * 2 # 2 hours by default
def __init__(self, **kwargs):
self.volumes_to_remove = []
self.helper_container_id = None
self.service_network_id = None
self.service_containers = []
self.metadata = {
'repo_id': None,
'caches': [],
'cache_last_invalidated_sec': 0,
'protected': False,
}
self.__dict__.update(kwargs)
# This calls podman which is hard to duplicate so we mock this function
# in tests instead
def verbose_run(self, *args, **kwargs):
print('run:', args, kwargs)
sys.stdout.flush()
return subprocess.run(*args, **kwargs)
def create_volume(self, t):
res = self.verbose_run([
'podman', 'volume', 'create',
'--label', 'lilybuild=' + t,
], check=True, capture_output=True, encoding='utf-8')
volname = res.stdout.strip()
self.volumes_to_remove.append(volname)
return volname
def clean_volumes(self):
self.verbose_run([
'podman', 'volume', 'rm', '-f', '--',
] + self.volumes_to_remove, capture_output=True)
def clean_helper_container(self):
self.verbose_run([
'podman', 'container', 'rm', '-f', '--', self.helper_container_id,
], capture_output=True)
def start_helper_service(self, work_volname, script_volname):
res = self.verbose_run([
'podman', 'container', 'inspect', '--', self.worker_container_name,
], check=True, capture_output=True, encoding='utf-8')
container_stat = json.loads(res.stdout)[0]
pod = container_stat.get('Pod')
networks = list(container_stat.get('NetworkSettings').get('Networks').keys())
alias = gen_random_id()
container_name = 'lilybuild-helper-' + alias
with open(self.key_file_pub) as f:
pub_key = f.readline().strip()
res = self.verbose_run([
'podman', 'run', '--rm', '-d', '--name', container_name,
f'--mount=type=volume,source={work_volname},destination={self.work_vol_mount_dir}',
f'--mount=type=volume,source={script_volname},destination={self.script_vol_mount_dir}',
f'--pod={pod}',
f'--net={networks[0]}',
f'--network-alias={alias}',
'--image-volume=ignore',
'--label', 'lilybuild=helper',
'-e', 'PUID=0',
'-e', 'PGID=0',
'-e', f'PUBLIC_KEY={pub_key}',
'-e', 'USER_NAME=helper',
'-e', 'SUDO_ACCESS=true',
'--',
self.volume_helper_image,
], check=True, capture_output=True, encoding='utf-8')
self.helper_container_id = container_name
self.helper_container_alias = alias
pinfo('Waiting for ssh service to be up...')
service_up = False
for i in range(self.ssh_max_wait):
chk = self.verbose_run(['nc', alias, self.ssh_port], input=b'', capture_output=True)
if chk.returncode == 0 and chk.stdout is not None and chk.stdout.startswith(b'SSH'):
service_up = True
break
else:
time.sleep(self.ssh_wait_interval_sec)
if not service_up:
raise RuntimeError('Service is still not up!')
psuccess('Service is up.')
return (container_name, alias)
def get_valid_caches(self, md):
using_caches = []
for c in md['caches']:
if c['policy'] != 'pull-push' and c['policy'] != 'pull':
continue
storage_dir = self.get_cache_storage_dir(md['repo_id'], c, protected=md['protected'])
pinfo('Cache storage dir is ', storage_dir)
if os.path.exists(storage_dir):
pinfo('Cache exists')
cur_cache_name = os.path.join(storage_dir, self.cur_cache_basename)
try:
stat_res = os.stat(cur_cache_name)
if stat_res.st_mtime > md['cache_last_invalidated_sec']:
pinfo('Cache not expired')
using_caches.append(cur_cache_name)
else:
pinfo('Cache expired')
except:
pinfo('Cache does not exist')
return using_caches
def import_caches(self, md, vol_mount_dir):
# Ensure we do not accidentally remove root
# although it should be pretty safe (it's a constant), but who knows
# Validate against spaces because anything passed after ssh is processed
# through a shell
if not (vol_mount_dir and
isinstance(vol_mount_dir, str) and
not any_spaces_re.search(vol_mount_dir)):
perror('vol_mount_dir cannot be empty and cannot contain spaces')
raise RuntimeError('vol_mount_dir cannot be empty and cannot contain spaces')
valid_caches = self.get_valid_caches(md)
cache_file = os.path.join(self.cache_tmp_dir, self.cur_cache_basename)
for c in valid_caches:
try:
pinfo('Uploading cache...')
self.verbose_run([
'rsync', '-a', '--delete',
'--rsh', self.ssh_command,
'--',
c,
f'helper@{self.helper_container_alias}:{self.cache_tmp_dir}',
], check=True)
pinfo('Extracting cache...')
self.verbose_run(self.ssh_command_list + [
f'helper@{self.helper_container_alias}',
'tar', '-xf', cache_file, '-C', vol_mount_dir,
], check=True)
except subprocess.CalledProcessError as e:
pwarn('Error when importing cache:', e)
# Cache is corrupt and should not be trusted
self.verbose_run(self.ssh_command_list + [
f'helper@{self.helper_container_alias}',
'rm', '-rf', '--', f'{vol_mount_dir}/*', f'{vol_mount_dir}/.*',
])
except:
pwarn('Other error occurred', sys.exception())
finally:
pinfo('Removing uploaded cache archive...')
self.verbose_run(self.ssh_command_list + [
f'helper@{self.helper_container_alias}',
'rm', '-f', '--', cache_file,
])
def save_caches(self, md, /, succeeded):
for c in md['caches']:
if c['policy'] != 'pull-push' and c['policy'] != 'push':
pinfo('Cache saving skipped because of policy')
continue
if (c['when'] == 'on_success' and not succeeded) or (c['when'] == 'on_failure' and succeeded):
pinfo('Cache saving skipped because mismatch in success status', c)
continue
storage_dir = self.get_cache_storage_dir(md['repo_id'], c, protected=md['protected'])
cache_file = os.path.join(storage_dir, self.cur_cache_basename)
try:
replaced = False
os.makedirs(storage_dir, exist_ok=True)
fd, fn = tempfile.mkstemp(dir=storage_dir)
os.close(fd)
lilybuild.safetar.create(
fn,
self.result_dir,
c['paths'],
self.cache_max_bytes,
items_to_exclude=None,
compression='gz',
)
os.replace(fn, cache_file)
replaced = True
except:
pwarn('Unable to create cache', sys.exception())
finally:
# Either the temp file is renamed, or it is not
if not replaced:
try:
os.remove(fn)
except:
pass
def import_volume(self, local_dir, vol_mount_dir):
# I'll just use the shell instead of pipe2+fork+exec+wait, much easier
self.verbose_run([
'rsync', '-a',
'--rsh', self.ssh_command,
f'{local_dir}/',
f'helper@{self.helper_container_alias}:{vol_mount_dir}',
], check=True)
def export_volume(self, local_dir, vol_mount_dir):
self.verbose_run([
'rsync', '-a', '--delete',
'--rsh', self.ssh_command,
f'helper@{self.helper_container_alias}:{vol_mount_dir}/',
local_dir,
], check=True)
def create_service_network(self):
res = self.verbose_run([
'podman', 'network', 'create', '--label', 'lilybuild=service-network'
], capture_output=True, check=True, encoding='utf-8')
self.service_network_id = res.stdout.strip()
return self.service_network_id
def maybe_clean_service_network(self):
if self.service_network_id is None:
return
res = self.verbose_run([
'podman', 'network', 'rm', '-f', '--', self.service_network_id
], capture_output=True, encoding='utf-8')
if res.returncode != 0:
perror('Cannot remove service network.')
def start_and_record_service_container(self, service):
image = service['name']
ep_args = []
if service['entrypoint']:
if isinstance(service['entrypoint'], str):
entrypoint = service['entrypoint']
else:
entrypoint = json.dumps(service['entrypoint'])
ep_args += [f'--entrypoint={entrypoint}']
cmd_args = []
if service['command']:
if isinstance(service['command'], str):
cmd_args += [service['command']]
else:
cmd_args += service['command']
res = self.verbose_run([
'podman', 'run', '-d', '--label', 'lilybuild=job-service',
f'--env-file={self.env_filename}',
f'--network={self.service_network_id}',
] + [
f'--network-alias={alias}' for alias in service['aliases']
] + ep_args + [
'--',
image,
] + cmd_args, check=True, capture_output=True, encoding='utf-8')
service_id = res.stdout.strip()
self.service_containers.append(service_id)
def ensure_service_containers_up(self):
waiting_container_ids = self.service_containers[:]
steady_deadline = time.monotonic() + self.service_max_wait_sec
pinfo('Waiting for service containers...')
while waiting_container_ids:
for cid in waiting_container_ids[:]:
res = self.verbose_run([
'podman', 'container', 'inspect', '--', cid
], check=True, capture_output=True, encoding='utf-8')
ins = json.loads(res.stdout)[0]
if ins.get('State', {}).get('Status') == 'running':
psuccess(f'Container {cid} is up')
waiting_container_ids.remove(cid)
if waiting_container_ids:
if time.monotonic() > steady_deadline:
perror('Containers are not yet up after deadline.')
raise TimeoutError('Service containers startup timeout')
pinfo('Some containers are not yet up. Waiting...')
time.sleep(self.service_wait_interval_sec)
psuccess('All service containers are up.')
def maybe_prune_service_containers(self):
container_ids = self.service_containers
if not container_ids:
return
stop_proc = self.verbose_run(['podman', 'container', 'stop', '--'] + container_ids)
if stop_proc.returncode != 0:
pwarn('Cannot stop container.')
# -v removes anonymous volumes associated with the container
rm_proc = self.verbose_run(['podman', 'container', 'rm', '-f', '-v', '--'] + container_ids)
def run_in_container(self, image, work_volname, script_volname):
timeout = self.container_run_timeout_sec
steady_deadline = time.monotonic() + timeout
network_args = []
if self.service_network_id:
network_args += [f'--network={self.service_network_id}']
start_process = self.verbose_run([
'podman', 'run', '-d',
f'--mount=type=volume,source={work_volname},destination={self.work_vol_mount_dir}',
f'--mount=type=volume,source={script_volname},destination={self.script_vol_mount_dir}',
f'--env-file={self.env_filename}',
] + network_args + image_to_podman_args(image) + [
self.script_name,
], capture_output=True, encoding='utf-8')
if start_process.returncode != 0:
perror('Cannot run container. Error message:')
print(start_process.stderr)
return start_process.returncode
container_id = start_process.stdout.strip()
steady_now = time.monotonic()
log_args = []
retcode = None
try:
while steady_deadline > steady_now:
log_process = self.verbose_run([
'podman', 'logs', '--follow'
] + log_args + ['--', container_id], timeout=steady_deadline - steady_now)
# Exited from `podman logs`: why? Is the container still running?
inspect_running = self.verbose_run([
'podman', 'container', 'inspect',
'--format', '{{.State.Status}}', '--', container_id,
], capture_output=True, encoding='utf-8', check=True)
if inspect_running.stdout.strip() == 'exited':
inspect_retcode = self.verbose_run([
'podman', 'container', 'inspect',
'--format', '{{.State.ExitCode}}', '--', container_id,
], capture_output=True, encoding='utf-8', check=True)
retcode = int(inspect_retcode.stdout.strip())
break
else:
pwarn('`podman logs` unexpectedly quits when the container is still running, resuming logs...')
log_args = ['--tail', '10']
steady_now = time.monotonic()
if retcode is None:
perror('Command timed out.')
retcode = 1
except subprocess.TimeoutExpired as e:
perror('Command timed out.')
retcode = 1
except subprocess.CalledProcessError as e:
perror('Cannot inspect container:', e)
except:
perror('Another exception happened:', sys.exception())
finally:
pinfo('Cleaning up container...')
stop_proc = self.verbose_run(['podman', 'container', 'stop', '--', container_id])
if stop_proc.returncode != 0:
pwarn('Cannot stop container.')
# -v removes anonymous volumes associated with the container
rm_proc = self.verbose_run(['podman', 'container', 'rm', '-f', '-v', '--', container_id])
pinfo('Cleaned.')
return retcode
def hash_cache_key(self, cache_key):
if not isinstance(cache_key, str):
cache_key = ''
m = hashlib.sha256()
m.update(cache_key.encode())
return m.hexdigest()
def get_cache_storage_dir(self, repo_id, cache_def, /, protected):
cache_key = cache_def.get('key', '')
hashed_key = self.hash_cache_key(cache_key)
return os.path.join(
self.cache_storage_root_dir,
'repos',
str(repo_id),
'protected' if protected else 'unprotected',
'cache-keys',
hashed_key,
)
def main(self, argv):
image = json.loads(argv[1])
self.work_dir = argv[2]
self.script_dir = argv[3]
self.result_dir = argv[4]
self.env_filename = os.path.join(self.script_dir, self.env_file_basename)
services = []
if len(argv) >= 6:
services = json.loads(argv[5])
metadata_filename = os.path.join(self.script_dir, self.metadata_file_basename)
if os.path.exists(metadata_filename):
pinfo('Parsing metadata...')
with open(metadata_filename) as f:
self.metadata = json.loads(f.read())
psuccess('Parsed.')
pinfo('Creating volumes...')
work_vol = self.create_volume('work')
script_vol = self.create_volume('script')
psuccess('Created.')
pinfo('Starting helper service...')
self.start_helper_service(work_vol, script_vol)
psuccess('Started...')
if services:
pinfo('Creating service network...')
self.create_service_network()
psuccess('Created.')
pinfo('Starting job-defined services...')
for service in services:
self.start_and_record_service_container(service)
pinfo('Waiting for job-defined services...')
self.ensure_service_containers_up()
if self.metadata['caches']:
pinfo('Importing caches...')
self.import_caches(self.metadata, self.work_vol_mount_dir)
psuccess('Imported.')
pinfo('Importing volumes...')
self.import_volume(self.work_dir, self.work_vol_mount_dir)
self.import_volume(self.script_dir, self.script_vol_mount_dir)
psuccess('Imported.')
pinfo('Running container...')
retcode = self.run_in_container(image, work_vol, script_vol)
succeeded = retcode == 0
pinfo(f'Returned {retcode}.')
if not succeeded:
perror('Job failed.')
else:
psuccess('Job succeeded.')
# We should collect the result regardless whether it succeeded
pinfo('Collecting build changes...')
self.export_volume(self.result_dir, self.work_vol_mount_dir)
psuccess('Collected.')
if self.metadata['caches']:
pinfo('Saving caches...')
self.save_caches(self.metadata, succeeded=succeeded)
psuccess('Saved.')
return retcode
def cleanup_all(self):
pinfo('Cleaning service containers...')
self.maybe_prune_service_containers()
psuccess('Cleaned.')
pinfo('Cleaning service network...')
self.maybe_clean_service_network()
psuccess('Cleaned.')
if self.helper_container_id:
pinfo('Cleaning helper container')
self.clean_helper_container()
psuccess('Cleaned.')
pinfo('Cleaning volumes...')
self.clean_volumes()
psuccess('Cleaned.')
if __name__ == '__main__':
retcode = 1
try:
ph = PodmanHelper()
retcode = ph.main(sys.argv)
except Exception as e:
perror('Error!', e)
print(traceback.format_exc())
raise
finally:
ph.cleanup_all()
sys.exit(retcode)
diff --git a/lilybuild/lilybuild/safetar.py b/lilybuild/lilybuild/safetar.py
index 902c4a4..29d24a9 100755
--- a/lilybuild/lilybuild/safetar.py
+++ b/lilybuild/lilybuild/safetar.py
@@ -1,244 +1,247 @@
#!/usr/bin/env python3
+# This file is part of lilybuild.
+# SPDX-FileCopyrightText: 2025-2026 tusooa <tusooa@kazv.moe>
+# SPDX-License-Identifier: GPL-2.0-only
import os
import glob
import re
try:
import tarfile
tarfile.FilterError
except AttributeError:
import backports.tarfile as tarfile
try:
glob_translate = glob.translate
except AttributeError:
# Taken from python 3.13 library
def py313_fnmatch_translate(pat, STAR, QUESTION_MARK):
res = []
add = res.append
i, n = 0, len(pat)
while i < n:
c = pat[i]
i = i+1
if c == '*':
# compress consecutive `*` into one
if (not res) or res[-1] is not STAR:
add(STAR)
elif c == '?':
add(QUESTION_MARK)
elif c == '[':
j = i
if j < n and pat[j] == '!':
j = j+1
if j < n and pat[j] == ']':
j = j+1
while j < n and pat[j] != ']':
j = j+1
if j >= n:
add('\\[')
else:
stuff = pat[i:j]
if '-' not in stuff:
stuff = stuff.replace('\\', r'\\')
else:
chunks = []
k = i+2 if pat[i] == '!' else i+1
while True:
k = pat.find('-', k, j)
if k < 0:
break
chunks.append(pat[i:k])
i = k+1
k = k+3
chunk = pat[i:j]
if chunk:
chunks.append(chunk)
else:
chunks[-1] += '-'
# Remove empty ranges -- invalid in RE.
for k in range(len(chunks)-1, 0, -1):
if chunks[k-1][-1] > chunks[k][0]:
chunks[k-1] = chunks[k-1][:-1] + chunks[k][1:]
del chunks[k]
# Escape backslashes and hyphens for set difference (--).
# Hyphens that create ranges shouldn't be escaped.
stuff = '-'.join(s.replace('\\', r'\\').replace('-', r'\-')
for s in chunks)
# Escape set operations (&&, ~~ and ||).
stuff = re.sub(r'([&~|])', r'\\\1', stuff)
i = j+1
if not stuff:
# Empty range: never match.
add('(?!)')
elif stuff == '!':
# Negated empty range: match any character.
add('.')
else:
if stuff[0] == '!':
stuff = '^' + stuff[1:]
elif stuff[0] in ('^', '['):
stuff = '\\' + stuff
add(f'[{stuff}]')
else:
add(re.escape(c))
assert i == n
return res
def py313_translate(pat, *, recursive=False, include_hidden=False, seps=None):
"""Translate a pathname with shell wildcards to a regular expression.
If `recursive` is true, the pattern segment '**' will match any number of
path segments.
If `include_hidden` is true, wildcards can match path segments beginning
with a dot ('.').
If a sequence of separator characters is given to `seps`, they will be
used to split the pattern into segments and match path separators. If not
given, os.path.sep and os.path.altsep (where available) are used.
"""
if not seps:
if os.path.altsep:
seps = (os.path.sep, os.path.altsep)
else:
seps = os.path.sep
escaped_seps = ''.join(map(re.escape, seps))
any_sep = f'[{escaped_seps}]' if len(seps) > 1 else escaped_seps
not_sep = f'[^{escaped_seps}]'
if include_hidden:
one_last_segment = f'{not_sep}+'
one_segment = f'{one_last_segment}{any_sep}'
any_segments = f'(?:.+{any_sep})?'
any_last_segments = '.*'
else:
one_last_segment = f'[^{escaped_seps}.]{not_sep}*'
one_segment = f'{one_last_segment}{any_sep}'
any_segments = f'(?:{one_segment})*'
any_last_segments = f'{any_segments}(?:{one_last_segment})?'
results = []
parts = re.split(any_sep, pat)
last_part_idx = len(parts) - 1
for idx, part in enumerate(parts):
if part == '*':
results.append(one_segment if idx < last_part_idx else one_last_segment)
elif recursive and part == '**':
if idx < last_part_idx:
if parts[idx + 1] != '**':
results.append(any_segments)
else:
results.append(any_last_segments)
else:
if part:
if not include_hidden and part[0] in '*?':
results.append(r'(?!\.)')
results.extend(py313_fnmatch_translate(part, f'{not_sep}*', not_sep))
if idx < last_part_idx:
results.append(any_sep)
res = ''.join(results)
return fr'(?s:{res})\Z'
glob_translate = py313_translate
def extract(target_dir, archive_file):
with tarfile.open(archive_file) as tf:
tf.errorlevel = 1
tf.extractall(target_dir, filter='data')
class ArchiveFilter:
def __init__(
self,
base_dir,
limit_bytes=None,
items_to_exclude=None
):
self.base_dir = base_dir
self.total_bytes_added = 0
self.limit_bytes = limit_bytes or 100*1024*1024 # 100 MiB
self.exclude_re = [re.compile(glob_translate(i, recursive=True, include_hidden=True)) for i in (items_to_exclude or [])]
def __call__(self, member):
member.name = os.path.relpath('/' + member.name, self.base_dir)
filtered_member = tarfile.data_filter(member, self.base_dir)
if not filtered_member:
return None
if self.total_bytes_added + member.size > self.limit_bytes:
self.total_bytes_added = self.limit_bytes + 1
raise RuntimeError('Limit exceeded')
name = member.name
for r in self.exclude_re:
if r.match(name):
return None
if member.isdir() and r.match(name + '/'):
return None
self.total_bytes_added += member.size
# Assume that data_filter does not do anything else to it besides rejecting
# Any remaining (permissions) will be stripped when the archive is extracted
# Directly using filtered_member will cause errors in further processing,
# as the data_filter seems intended only for extraction.
return member
def create(archive_file, base_dir, content, limit_bytes, items_to_exclude, compression=None):
base_dir = os.path.abspath(base_dir)
open_mode = 'w'
if compression == 'gz':
open_mode = 'w:gz'
try:
with tarfile.open(archive_file, open_mode) as tf:
tf.errorlevel = 1
archive_filter = ArchiveFilter(
base_dir,
limit_bytes,
items_to_exclude=items_to_exclude
)
for g in content:
# iglob is important because once we found one file, we
# add it, and if it does not pass the data filter,
# then we are done with the whole archive. Using glob
# will make it hang here, resulting in DoS.
for f in glob.iglob(g, root_dir=base_dir, recursive=True):
# Specifying 'xxx/**' as the glob will probably make
# this called multiple times on the parent and child
# dirs, so best to avoid it. However, we aren't
# good enough to sanitize this.
tf.add(os.path.join(base_dir, f), filter=archive_filter)
except tarfile.FilterError:
# To prevent exploiting '/**', '../../../**' globs etc., we
# cannot allow the filename to be exposed
raise RuntimeError('Did not pass the data_filter')
if __name__ == '__main__':
import sys
import json
if len(sys.argv) > 1:
a = json.loads(sys.argv[1])
else:
a = json.loads(sys.stdin.read())
op = a.get('op')
if op == 'create':
# Do not remove this try-catch, or it will print out the original
# FilterError, resulting in at least one file name being exposed.
# The file name in the filter error should not be exposed,
# or it allows the attacker to view arbitrary directory structure
# inside the whole worker.
try:
create(
a['archive_file'],
a['base_dir'],
a['content'],
a.get('limit_bytes'),
a.get('items_to_exclude'),
a.get('compression'),
)
except RuntimeError as e:
print('Cannot create archive:', e)
sys.exit(1)
elif op == 'extract':
# Does not need a try-catch, because only the things inside the archive
# or the target dir can be exposed.
extract(a['target_dir'], a['archive_file'])
else:
print('Unknown operation:', op)
sys.exit(1)
diff --git a/lilybuild/lilybuild/send_coverage.py b/lilybuild/lilybuild/send_coverage.py
index 7746ca2..4b42db6 100755
--- a/lilybuild/lilybuild/send_coverage.py
+++ b/lilybuild/lilybuild/send_coverage.py
@@ -1,43 +1,46 @@
#!/usr/bin/env python3
+# This file is part of lilybuild.
+# SPDX-FileCopyrightText: 2025-2026 tusooa <tusooa@kazv.moe>
+# SPDX-License-Identifier: GPL-2.0-only
import sys
import json
import subprocess
def main(args):
filename = args['filename']
build_url = args['build_url']
is_success = args['is_success']
receiver = args['receiver']
phorge_url = args['phorge_url']
job_name = args['job_name']
with open(filename) as f:
coverage = json.loads(f.read())
data = {
'receiver': receiver,
'type': 'work',
'unit': [{
'name': f'Coverage ({job_name})',
'result': 'pass' if is_success else 'fail',
'details': build_url,
'format': 'remarkup',
'coverage': coverage,
}],
}
subprocess.run([
'arc',
'--config',
'phabricator.uri=' + phorge_url,
'call-conduit',
'--',
'harbormaster.sendmessage',
], check=True, input=json.dumps(data), encoding='utf-8')
if __name__ == '__main__':
if len(sys.argv) > 1:
main(json.loads(sys.argv[1]))
else:
main(json.loads(sys.stdin.read()))
diff --git a/lilybuild/lilybuild/tests/ci_syntax/ci_file_test.py b/lilybuild/lilybuild/tests/ci_syntax/ci_file_test.py
index 504aa2f..0c0ab70 100644
--- a/lilybuild/lilybuild/tests/ci_syntax/ci_file_test.py
+++ b/lilybuild/lilybuild/tests/ci_syntax/ci_file_test.py
@@ -1,333 +1,336 @@
+# This file is part of lilybuild.
+# SPDX-FileCopyrightText: 2025-2026 tusooa <tusooa@kazv.moe>
+# SPDX-License-Identifier: GPL-2.0-only
import unittest
import yaml
from lilybuild.ci_syntax.ci_file import CIFile, CIValidationError, get_job_extend_seq, merge_job_deep
from lilybuild.tests.resources import get_res
class CIFileTest(unittest.TestCase):
def test_empty(self):
r = CIFile('')
self.assertEqual(r.stages, [])
self.assertEqual(r.jobs, {})
def test_invalid(self):
with self.assertRaises(yaml.composer.ComposerError) as m:
r = CIFile('*xxx')
def test_simple(self):
r = CIFile(get_res('simple'))
self.assertEqual(r.stages, ['build'])
self.assertEqual(list(r.jobs), ['build-a'])
self.assertEqual(r.jobs['build-a'].image, 'test')
self.assertEqual(r.jobs['build-a'].before_script, ['ls'])
self.assertEqual(r.jobs['build-a'].script, ['make', 'make install'])
self.assertEqual(r.jobs['build-a'].after_script, ['find', 'echo ok'])
def test_noscript(self):
r = CIFile(get_res('noscript'))
self.assertEqual(r.stages, ['build'])
self.assertEqual(list(r.jobs), ['build-a'])
self.assertEqual(r.jobs['build-a'].image, 'test')
self.assertEqual(r.jobs['build-a'].script, [])
def test_defaults(self):
r = CIFile(get_res('defaults'))
self.assertEqual(r.stages, ['build'])
self.assertEqual(list(r.jobs), ['build-a'])
self.assertEqual(r.jobs['build-a'].image, 'test')
self.assertEqual(r.jobs['build-a'].before_script, ['ls'])
self.assertEqual(r.jobs['build-a'].script, ['make', 'make install'])
self.assertEqual(r.jobs['build-a'].after_script, ['find', 'echo ok'])
def test_pages(self):
r = CIFile(get_res('pages'))
self.assertEqual(r.stages, ['test'])
self.assertEqual(list(r.jobs), ['pages'])
self.assertEqual(r.jobs['pages'].script, ['make docs'])
self.assertTrue(r.jobs['pages'].is_pages())
def test_pages_attr(self):
r = CIFile(get_res('pages_attr'))
self.assertFalse(r.jobs['not-pages'].is_pages())
self.assertTrue(r.jobs['is-pages'].is_pages())
def test_dotjobs(self):
r = CIFile(get_res('dotjobs'))
self.assertEqual(list(r.jobs), [])
def test_no_such_stage(self):
with self.assertRaises(CIValidationError) as m:
r = CIFile(get_res('no_such_stage'))
def test_default_stages(self):
r = CIFile(get_res('default_stages'))
self.assertEqual(r.jobs['a'].stage, 'build')
self.assertEqual(r.jobs['b'].stage, 'test')
self.assertEqual(r.jobs['c'].stage, 'deploy')
self.assertEqual(r.jobs['d'].stage, '.pre')
self.assertEqual(r.jobs['e'].stage, '.post')
self.assertEqual(r.jobs['f'].stage, 'test')
def test_group_jobs(self):
r = CIFile(get_res('group_jobs'))
groups = r.get_grouped_jobs()
self.assertEqual(len(groups), 2)
self.assertEqual(groups[0][0], 'build')
self.assertEqual(len(groups[0][1]), 2)
self.assertTrue(next(j for j in groups[0][1] if j.name == 'a'))
self.assertTrue(next(j for j in groups[0][1] if j.name == 'b'))
self.assertEqual(groups[1][0], 'test')
self.assertEqual(len(groups[1][1]), 2)
self.assertTrue(next(j for j in groups[1][1] if j.name == 'c'))
self.assertTrue(next(j for j in groups[1][1] if j.name == 'd'))
def test_dependencies(self):
r = CIFile(get_res('dependencies'))
self.assertEqual(r.get_jobs_to_pull_artifacts_from('a'), [])
self.assertEqual(r.get_jobs_to_pull_artifacts_from('a2'), [])
self.assertEqual(r.get_jobs_to_pull_artifacts_from('b'), ['a'])
self.assertEqual(set(r.get_jobs_to_pull_artifacts_from('b2')), set(['a', 'a2']))
def test_nonexistent_dep(self):
with self.assertRaises(CIValidationError) as m:
r = CIFile(get_res('nonexistent_dep'))
self.assertEqual(str(m.exception), 'Dependency "a3" of job "b" does not exist')
def test_late_dep(self):
with self.assertRaises(CIValidationError) as m:
r = CIFile(get_res('late_dep'))
self.assertEqual(str(m.exception), 'Dependency "a2" of job "a" is not before the job in stage')
def test_artifacts(self):
r = CIFile(get_res('has_artifacts_archive'))
self.assertEqual(r.jobs['has_archive'].artifacts, { 'paths': ['a'] })
self.assertEqual(r.jobs['no_archive'].artifacts, {})
self.assertEqual(r.jobs['no_artifacts'].artifacts, {})
def test_has_artifacts_archive(self):
r = CIFile(get_res('has_artifacts_archive'))
self.assertTrue(r.jobs['has_archive'].has_artifacts_archive())
self.assertFalse(r.jobs['no_archive'].has_artifacts_archive())
self.assertFalse(r.jobs['no_artifacts'].has_artifacts_archive())
def test_artifacts_reports(self):
r = CIFile(get_res('artifacts_reports'))
self.assertFalse(r.jobs['has_archive'].has_supported_coverage_report())
self.assertFalse(r.jobs['empty_reports'].has_supported_coverage_report())
self.assertTrue(r.jobs['yes_reports'].has_supported_coverage_report())
self.assertFalse(r.jobs['no_supported_reports'].has_supported_coverage_report())
def test_extends(self):
r = CIFile(get_res('extends'))
self.assertEqual(r.jobs['build-a'].struct_raw, {
'image': 'alpine',
'variables': { 'SECRET': 'abcdef', 'CC': 'gcc' },
'after_script': ['rm -r cache'],
'stage': 'build',
'before_script': ['apk add gcc'],
'script': ['make a', 'make install'],
})
self.assertEqual(r.jobs['build-b'].struct_raw, {
'image': 'alpine',
'variables': { 'SECRET': 'abcdef', 'CC': 'cc' },
'after_script': ['rm -r cache'],
'stage': 'build',
'before_script': ['apk add gcc'],
'script': ['make b', 'make install'],
})
self.assertEqual(r.jobs['test'].struct_raw, {
'image': 'tester',
'variables': { 'SECRET': 'abcdef' },
'after_script': ['rm -r cache'],
'stage': 'test',
'script': ['make test'],
})
self.assertEqual(r.jobs['container-amd64'].struct_raw, {
'image': 'podman',
'variables': { 'SECRET': 'abcdef', 'DOCKER': 'podman', 'PLATFORM': 'amd64', 'LOGIN': 'defghi' },
'after_script': ['rm -r cache'],
'stage': 'deploy',
'script': ['podman login', 'make docker'],
'tags': ['podman']
})
self.assertEqual(r.jobs['container-aarch64'].struct_raw, {
'image': 'podman',
'variables': { 'SECRET': 'abcdef', 'DOCKER': 'podman', 'PLATFORM': 'aarch64', 'LOGIN': 'defghi' },
'after_script': ['rm -r cache'],
'stage': 'deploy',
'script': ['podman login', 'make docker'],
'tags': ['podman']
})
self.assertEqual(r.jobs['appimage-amd64'].struct_raw, {
'image': 'alpine',
'variables': { 'SECRET': 'abcdef', 'PLATFORM': 'amd64', 'LOGIN': 'defghi' },
'after_script': ['rm -r cache'],
'stage': 'deploy',
'script': ['make appimage'],
'tags': ['amd64']
})
self.assertEqual(r.jobs['appimage-aarch64'].struct_raw, {
'image': 'alpine',
'variables': { 'SECRET': 'abcdef', 'PLATFORM': 'aarch64', 'LOGIN': 'defghi' },
'after_script': ['rm -r cache'],
'stage': 'deploy',
'script': ['make appimage'],
'tags': ['aarch64']
})
def test_variables(self):
r = CIFile(get_res('variables'))
self.assertEqual(r.jobs['build-a'].get_predefined_ci_variables()['foo'], 'bar')
def test_when_only_except(self):
r = CIFile(get_res('when-only-except'))
expected_if = r'''($CI_COMMIT_REF_NAME =~ /a/ || $CI_COMMIT_REF_NAME =~ /bb$/ || $CI_COMMIT_REF_NAME == "ccccccc" || $CI_COMMIT_REF_NAME == "ddd" || $CI_COMMIT_REF_NAME == "ff\\\\\"f" || $CI_COMMIT_REF_NAME == "ff\\'f" || $CI_COMMIT_REF_NAME == "ff\\\"") && !($CI_COMMIT_REF_NAME == "bbb" || $CI_COMMIT_REF_NAME =~ /^ba/ || $CI_COMMIT_REF_NAME =~ /ff\w/ || $CI_COMMIT_REF_NAME =~ /@@@/ || $CI_COMMIT_REF_NAME == "@@@")'''
self.assertEqual(r.jobs['a'].rules, [{
'when': 'manual',
'if': expected_if
}])
self.assertEqual(r.jobs['b'].rules, [{
'if': '($CI_COMMIT_REF_NAME == "servant")'
}])
self.assertEqual(r.jobs['c'].rules, [{
'if': '!($CI_COMMIT_REF_NAME == "servant")'
}])
self.assertEqual(r.jobs['d'].rules, [])
self.assertEqual(r.jobs['e'].rules, [{
'when': 'always'
}])
class GetJobExtendSeqTest(unittest.TestCase):
def test_no_extends(self):
all_jobs = {
'nothing': {}
}
self.assertEqual(get_job_extend_seq('nothing', all_jobs), ['nothing'])
def test_multi_extends(self):
all_jobs = {
'a': { 'extends': ['b', 'c'] },
'b': { 'extends': ['d', 'e'] },
'c': { 'extends': ['f', 'g'] },
'd': {},
'e': {},
'f': {},
'g': {},
}
self.assertEqual(get_job_extend_seq('a', all_jobs), ['d', 'e', 'b', 'f', 'g', 'c', 'a'])
def test_common_ancestor(self):
all_jobs = {
'a': { 'extends': ['b', 'c'] },
'b': { 'extends': ['d', 'e'] },
'c': { 'extends': ['e', 'd'] },
'd': {},
'e': {},
'f': {},
'g': {},
}
self.assertEqual(get_job_extend_seq('a', all_jobs), ['d', 'e', 'b', 'e', 'd', 'c', 'a'])
def test_self_cycle(self):
all_jobs = {
'a': { 'extends': ['a'] },
}
with self.assertRaises(CIValidationError) as m:
r = get_job_extend_seq('a', all_jobs)
self.assertTrue(str(m.exception).startswith('`extends` depth is over the limit of'))
def test_complex_cycle(self):
all_jobs = {
'a': { 'extends': ['b', 'c'] },
'b': { 'extends': ['d', 'c'] },
'c': { 'extends': ['d'] },
'd': { 'extends': ['a'] },
}
with self.assertRaises(CIValidationError) as m:
r = get_job_extend_seq('a', all_jobs)
self.assertTrue(str(m.exception).startswith('`extends` depth is over the limit of'))
def test_nonexistent_parent(self):
all_jobs = {
'a': { 'extends': ['b', 'c'] },
'b': { 'extends': ['d', 'c'] },
'c': { 'extends': ['d'] },
}
with self.assertRaises(CIValidationError) as m:
r = get_job_extend_seq('a', all_jobs)
self.assertEqual(str(m.exception), 'Job "d" does not exist, but occurs in `extends`.')
def test_null_parent(self):
all_jobs = {
'a': { 'extends': ['b', 'c'] },
'b': { 'extends': ['d', 'c'] },
'c': { 'extends': ['d'] },
'd': None,
}
self.assertEqual(get_job_extend_seq('a', all_jobs), ['d', 'd', 'c', 'b', 'd', 'c', 'a'])
class MergeJobTest(unittest.TestCase):
def test_simple(self):
parent = {
'a': {
'a/1': {
'a/1/1': '3',
'a/1/2': 4,
},
'a/2': {},
},
'b': 1,
}
child = {
'a': {
'a/1': {
'a/1/2': '6',
'a/1/3': 5,
},
'a/2': 'mew',
},
'c': 'test',
}
merge_job_deep(child, parent)
self.assertEqual(parent, {
'a': {
'a/1': {
'a/1/1': '3',
'a/1/2': 4,
},
'a/2': {},
},
'b': 1,
})
self.assertEqual(child, {
'a': {
'a/1': {
'a/1/1': '3',
'a/1/2': '6',
'a/1/3': 5,
},
'a/2': 'mew',
},
'b': 1,
'c': 'test',
})
def test_no_overflow(self):
a = {
'1': '2',
}
b = {
'2': '4',
'a': a,
}
a['a'] = b
res = {}
merge_job_deep(res, b)
with self.assertRaises(CIValidationError) as m:
merge_job_deep(res, a)
self.assertEqual(str(m.exception), 'depth is over the limit when merging job.')
if __name__ == '__main__':
unittest.main()
diff --git a/lilybuild/lilybuild/tests/ci_syntax/rules_test.py b/lilybuild/lilybuild/tests/ci_syntax/rules_test.py
index 9cf31bb..b792706 100644
--- a/lilybuild/lilybuild/tests/ci_syntax/rules_test.py
+++ b/lilybuild/lilybuild/tests/ci_syntax/rules_test.py
@@ -1,143 +1,146 @@
+# This file is part of lilybuild.
+# SPDX-FileCopyrightText: 2025-2026 tusooa <tusooa@kazv.moe>
+# SPDX-License-Identifier: GPL-2.0-only
import unittest
from lilybuild.ci_syntax.rules import tokenize_rule, make_tree, parse_rule, evaluate_rule
class RulesTest(unittest.TestCase):
def test_tokenize(self):
self.assertEqual(tokenize_rule("$VAR_FOO == ''"), [
('VAR', ('VAR_FOO',)),
('OP', ('==',)),
('STR_SINGLE', ('',)),
])
self.assertEqual(tokenize_rule("$VAR_FOO =~ /abc/"), [
('VAR', ('VAR_FOO',)),
('OP', ('=~',)),
('REGEX', ('abc',)),
])
self.assertEqual(tokenize_rule(r"$VAR_FOO =~ /ab\s\/c/"), [
('VAR', ('VAR_FOO',)),
('OP', ('=~',)),
('REGEX', ('ab\\s\\/c',)),
])
self.assertEqual(tokenize_rule(r'$VAR_FOO == "xxx\""'), [
('VAR', ('VAR_FOO',)),
('OP', ('==',)),
('STR_DOUBLE', ('xxx\\"',)),
])
self.assertEqual(tokenize_rule(r'!($VAR_FOO == "xxx\"")'), [
('OP', ('!',)),
('PAREN_LEFT', ()),
('VAR', ('VAR_FOO',)),
('OP', ('==',)),
('STR_DOUBLE', ('xxx\\"',)),
('PAREN_RIGHT', ()),
])
with self.assertRaises(SyntaxError) as m:
tokenize_rule(r'$VAR_FOO =/= "foo"')
with self.assertRaises(SyntaxError) as m:
tokenize_rule(r'$VAR_FOO > "foo"')
# bareword
with self.assertRaises(SyntaxError) as m:
tokenize_rule(r'$VAR_FOO == foo')
# unterminated strings
with self.assertRaises(SyntaxError) as m:
tokenize_rule(r'$VAR_FOO == "foo\"')
with self.assertRaises(SyntaxError) as m:
tokenize_rule(r'$VAR_FOO == "foo')
with self.assertRaises(SyntaxError) as m:
tokenize_rule(r'$VAR_FOO == "foo\\\"')
# bareword after regex
with self.assertRaises(SyntaxError) as m:
tokenize_rule(r"$VAR_FOO =~ /ab\s\/c/d")
def test_make_tree(self):
self.assertEqual(make_tree(tokenize_rule(r'($FOO)')), ('VAR', ('FOO',)))
self.assertEqual(make_tree(tokenize_rule(r'($FOO=="foo(") || "xxx"')), (
'OP', (
'||',
('OP', ('==', ('VAR', ('FOO',)), ('STR_DOUBLE', ('foo(',)))),
('STR_DOUBLE', ('xxx',)),
)
))
self.assertEqual(make_tree(tokenize_rule(r'$FOO=="foo("|| "xxx"')), (
'OP', (
'||',
('OP', ('==', ('VAR', ('FOO',)), ('STR_DOUBLE', ('foo(',)))),
('STR_DOUBLE', ('xxx',)),
)
))
self.assertEqual(make_tree(tokenize_rule(r'$FOO==("foo("|| "xxx")')), (
'OP', (
'==',
('VAR', ('FOO',)),
('OP', ('||', ('STR_DOUBLE', ('foo(',)), ('STR_DOUBLE', ('xxx',)))),
)
))
self.assertEqual(make_tree(tokenize_rule(r'!$FOO==("foo("|| "xxx")')), (
'OP', (
'!',
('OP', (
'==',
('VAR', ('FOO',)),
('OP', ('||', ('STR_DOUBLE', ('foo(',)), ('STR_DOUBLE', ('xxx',)))),
))
)
))
with self.assertRaises(SyntaxError) as m:
make_tree(tokenize_rule(r'())'))
self.assertEqual(str(m.exception), 'Mismatched parentheses')
with self.assertRaises(SyntaxError) as m:
make_tree(tokenize_rule(r'('))
self.assertEqual(str(m.exception), 'Mismatched parentheses')
with self.assertRaises(SyntaxError) as m:
make_tree(tokenize_rule(r'("("))'))
self.assertEqual(str(m.exception), 'Mismatched parentheses')
with self.assertRaises(SyntaxError) as m:
make_tree(tokenize_rule(r'$FOO== != "0"'))
self.assertEqual(str(m.exception), 'Missing operands')
with self.assertRaises(SyntaxError) as m:
make_tree(tokenize_rule(r'( == "1" && "0" || "0")'))
self.assertEqual(str(m.exception), 'Missing operands')
with self.assertRaises(SyntaxError) as m:
make_tree(tokenize_rule(r'$VAR( $FOO == "0")'))
self.assertEqual(str(m.exception), 'Too many operands')
def test_eval(self):
self.assertEqual(evaluate_rule(parse_rule("$VAR_FOO == ''"), {}), False)
self.assertEqual(evaluate_rule(parse_rule("$VAR_FOO == null"), {}), True)
self.assertEqual(evaluate_rule(parse_rule("$VAR_FOO == 'xx'"), {}), False)
self.assertEqual(evaluate_rule(parse_rule("$VAR_FOO == 'xx'"), { 'VAR_FOO': 'xx' }), True)
self.assertEqual(
evaluate_rule(parse_rule("$VAR_FOO == 'xx' && $BAR"), {
'VAR_FOO': 'xx',
'BAR': 'yyy',
}), 'yyy')
self.assertEqual(
evaluate_rule(parse_rule("$VAR_FOO == 'xx' && !$BAR"), {
'VAR_FOO': 'xx',
'BAR': 'yyy',
}), False)
self.assertEqual(
evaluate_rule(parse_rule("$VAR_FOO == 'xx' && $Z"), {
'VAR_FOO': 'xx',
'BAR': 'yyy',
}), None)
self.assertEqual(
evaluate_rule(parse_rule(r"$VAR_FOO == 'x\a\\\'x'"), {
'VAR_FOO': r"xa\'x",
}), True)
self.assertEqual(
evaluate_rule(parse_rule(r"$VAR_FOO =~ /ab\s\/c/"), {
'VAR_FOO': 'XXab /c/def',
}), True)
self.assertEqual(
evaluate_rule(parse_rule(r"$VAR_FOO =~ /ab\s\/c/"), {
'VAR_FOO': 'ab \\/c',
}), False)
self.assertEqual(
evaluate_rule(parse_rule(r"$VAR_FOO !~ /ab\s\/c/"), {
'VAR_FOO': 'ab \\/c',
}), True)
diff --git a/lilybuild/lilybuild/tests/coverage_test_worker.py b/lilybuild/lilybuild/tests/coverage_test_worker.py
index 60ca4c8..405659e 100644
--- a/lilybuild/lilybuild/tests/coverage_test_worker.py
+++ b/lilybuild/lilybuild/tests/coverage_test_worker.py
@@ -1,116 +1,119 @@
+# This file is part of lilybuild.
+# SPDX-FileCopyrightText: 2025-2026 tusooa <tusooa@kazv.moe>
+# SPDX-License-Identifier: GPL-2.0-only
import unittest
import tempfile
import os
import json
import lilybuild.coverage as lc
self_dir = os.path.dirname(__file__)
res_dir = os.path.join(self_dir, 'coverage_res')
def r(name):
return os.path.join(res_dir, name)
class CoverageTest(unittest.TestCase):
def test_simple(self):
with tempfile.TemporaryDirectory() as dir_name:
lc.main({
'source_dir': r('dummy.source1'),
'result_dir': r('dummy.result1'),
'untrusted_coverage_file': 'coverage.xml',
'output_dir': dir_name
})
self.assertTrue(os.path.exists(os.path.join(dir_name, 'coverage-cobertura.xml')))
self.assertTrue(os.path.exists(os.path.join(dir_name, 'coverage-phorge.json')))
with open(os.path.join(dir_name, 'coverage-phorge.json')) as f:
c = json.loads(f.read())
self.assertEqual(c, {
'dummy/__init__.py': '',
'dummy/dummy.py': 'CCNCUU',
'dummy/dummy2.py': 'CC',
'dummy/dummy4.py': 'UUNUUU',
})
def test_source_outofbounds(self):
with tempfile.TemporaryDirectory() as dir_name:
lc.main({
'source_dir': r('dummy.source2'),
'result_dir': r('dummy.result1'),
'untrusted_coverage_file': 'coverage.xml',
'output_dir': dir_name
})
self.assertTrue(os.path.exists(os.path.join(dir_name, 'coverage-cobertura.xml')))
self.assertTrue(os.path.exists(os.path.join(dir_name, 'coverage-phorge.json')))
with open(os.path.join(dir_name, 'coverage-phorge.json')) as f:
c = json.loads(f.read())
self.assertEqual(c, {
'dummy/__init__.py': '',
'dummy/dummy.py': 'CCNCUU',
'dummy/dummy2.py': 'CC',
})
def test_notfound(self):
with tempfile.TemporaryDirectory() as dir_name:
lc.main({
'source_dir': r('dummy.source1'),
'result_dir': r('dummy.result1'),
'untrusted_coverage_file': 'coverage-notfound.xml',
'output_dir': dir_name
})
self.assertTrue(os.path.exists(os.path.join(dir_name, 'coverage-cobertura.xml')))
self.assertTrue(os.path.exists(os.path.join(dir_name, 'coverage-phorge.json')))
with open(os.path.join(dir_name, 'coverage-phorge.json')) as f:
c = json.loads(f.read())
self.assertEqual(c, {
'dummy/__init__.py': '',
'dummy/dummy.py': 'CCNCUU',
'dummy/dummy2.py': 'CC',
})
def test_outofbounds(self):
with tempfile.TemporaryDirectory() as dir_name:
lc.main({
'source_dir': r('dummy.source1'),
'result_dir': r('dummy.result1'),
'untrusted_coverage_file': 'coverage-outofbounds.xml',
'output_dir': dir_name
})
self.assertTrue(os.path.exists(os.path.join(dir_name, 'coverage-cobertura.xml')))
self.assertTrue(os.path.exists(os.path.join(dir_name, 'coverage-phorge.json')))
with open(os.path.join(dir_name, 'coverage-phorge.json')) as f:
c = json.loads(f.read())
self.assertEqual(c, {
'dummy/__init__.py': '',
'dummy/dummy.py': 'CCNCUU',
})
def test_coverage_file_not_found(self):
with tempfile.TemporaryDirectory() as dir_name:
with self.assertRaises(RuntimeError):
lc.main({
'source_dir': r('dummy.source1'),
'result_dir': r('dummy.source1'),
'untrusted_coverage_file': '../dummy.result1/coverage.xml',
'output_dir': dir_name
})
with self.assertRaises(RuntimeError):
# A symlink to out-of-bounds file
lc.main({
'source_dir': r('dummy.source1'),
'result_dir': r('dummy.result2'),
'untrusted_coverage_file': 'coverage.xml',
'output_dir': dir_name
})
with self.assertRaises(FileNotFoundError):
lc.main({
'source_dir': r('dummy.source1'),
'result_dir': r('dummy.result1'),
'untrusted_coverage_file': 'nosuchfile.xml',
'output_dir': dir_name
})
diff --git a/lilybuild/lilybuild/tests/helpers_test.py b/lilybuild/lilybuild/tests/helpers_test.py
index b666942..49362e8 100644
--- a/lilybuild/lilybuild/tests/helpers_test.py
+++ b/lilybuild/lilybuild/tests/helpers_test.py
@@ -1,281 +1,284 @@
+# This file is part of lilybuild.
+# SPDX-FileCopyrightText: 2025-2026 tusooa <tusooa@kazv.moe>
+# SPDX-License-Identifier: GPL-2.0-only
import unittest
import json
from lilybuild.ci_syntax.ci_file import CIFile
from lilybuild.helpers import (
rsync_rules_from_artifacts,
normalize_base_url,
phorge_token_to_arcrc,
ci_vars_to_env_file,
get_job_script,
DEFAULT_SCRIPT_HEADER,
normalize_image,
get_service_aliases_from_name,
normalize_services,
expand_in_vars,
generate_metadata_from_job,
)
from lilybuild.tests.resources import get_res
class RsyncRulesTest(unittest.TestCase):
def test_empty(self):
self.assertEqual(
rsync_rules_from_artifacts({}),
['--include', '*/', '--exclude', '*']
)
def test_simple(self):
self.assertEqual(
rsync_rules_from_artifacts({'paths': ['public']}),
['--include', '*/',
'--include', '/public',
'--include', '/public/**',
'--exclude', '*']
)
def test_dotslash(self):
self.assertEqual(
rsync_rules_from_artifacts({'paths': ['./public/']}),
['--include', '*/',
'--include', '/public',
'--include', '/public/**',
'--exclude', '*']
)
def test_doublestar(self):
self.assertEqual(
rsync_rules_from_artifacts({'paths': ['./public/**']}),
['--include', '*/',
'--include', '/public/**',
'--include', '/public/**/**',
'--exclude', '*']
)
def test_doublestar_middle(self):
self.assertEqual(
rsync_rules_from_artifacts({'paths': ['./public/**/*.html']}),
['--include', '*/',
'--include', '/public/**/*.html',
'--include', '/public/**/*.html/**',
'--exclude', '*']
)
def test_dotdotslash(self):
# No exploit possible because rsync will not visit beyond the source root
self.assertEqual(
rsync_rules_from_artifacts({'paths': ['../etc/passwd']}),
['--include', '*/',
'--include', '/../etc/passwd',
'--include', '/../etc/passwd/**',
'--exclude', '*']
)
class PhorgeUtilsTest(unittest.TestCase):
def test_normalize_base_url(self):
self.assertEqual(normalize_base_url('https://iron.lily-is.land/'), 'https://iron.lily-is.land')
self.assertEqual(normalize_base_url('https://iron.lily-is.land'), 'https://iron.lily-is.land')
self.assertEqual(normalize_base_url(''), None)
def test_phorge_token_to_arcrc(self):
self.assertEqual(
json.loads(phorge_token_to_arcrc('https://iron.lily-is.land', 'some-token')),
{
'hosts': {
'https://iron.lily-is.land/api/': {
'token': 'some-token',
},
},
},
)
class CiVarsTest(unittest.TestCase):
def test_simple(self):
self.assertEqual(ci_vars_to_env_file({}), '')
self.assertEqual(ci_vars_to_env_file({'VAR': 'val'}), 'VAR=val')
self.assertEqual(ci_vars_to_env_file({'VAR': 'foo bar'}), "VAR=foo bar")
self.assertEqual(ci_vars_to_env_file({'VAR': '\nbar', 'MEW': 'abc def'}), "MEW=abc def")
self.assertEqual(ci_vars_to_env_file({'VAR': 12345}), "VAR=12345")
class GetJobScriptTest(unittest.TestCase):
def test_only_script(self):
r = CIFile(get_res('pages_attr'))
job_script = get_job_script(r.jobs['is-pages'])
self.assertEqual(job_script, f'''\
{DEFAULT_SCRIPT_HEADER}
make docs
set +e
exit 0''')
def test_before_and_after(self):
r = CIFile(get_res('defaults'))
job_script = get_job_script(r.jobs['build-a'])
self.assertEqual(job_script, f'''\
{DEFAULT_SCRIPT_HEADER}ls
make
make install
set +e
find
echo ok
exit 0''')
class NormalizeImageTest(unittest.TestCase):
def test_str(self):
res = normalize_image('alpine')
self.assertEqual(json.loads(res), {'name': 'alpine'})
def test_object(self):
orig = {'name': 'alpine', 'entrypoint': ['/docker-run', '/bin/bb']}
res = normalize_image(orig)
self.assertEqual(json.loads(res), orig)
class GetServiceAliasesFromNameTest(unittest.TestCase):
def test_simple(self):
self.assertEqual(
get_service_aliases_from_name('mewmew:abcdefg'),
['mewmew'])
self.assertEqual(
get_service_aliases_from_name('mewmew/a:abcdefg'),
['mewmew__a', 'mewmew-a'])
self.assertEqual(
get_service_aliases_from_name('mew-mew/a:abc-defg'),
['mew-mew__a', 'mew-mew-a'])
self.assertEqual(
get_service_aliases_from_name('a.example/mew-mew/a:abc-defg'),
['a.example__mew-mew__a', 'a.example-mew-mew-a'])
class NormalizeServicesTest(unittest.TestCase):
def test_simple(self):
self.assertEqual(
json.loads(normalize_services([
'mysql:latest',
'mysql:latest',
])),
[{ 'name': 'mysql:latest', 'aliases': ['mysql'], 'entrypoint': None, 'command': None },
{ 'name': 'mysql:latest', 'aliases': ['mysql'], 'entrypoint': None, 'command': None }],
)
def test_own_alias(self):
self.assertEqual(
json.loads(normalize_services([
{'name': 'mysql:latest', 'alias': 'a, b c'},
'mysql:latest',
])),
[{ 'name': 'mysql:latest', 'aliases': ['a', 'b', 'c'], 'entrypoint': None, 'command': None },
{ 'name': 'mysql:latest', 'aliases': ['mysql'], 'entrypoint': None, 'command': None }],
)
def test_entrypoint_command(self):
self.assertEqual(
json.loads(normalize_services([
{'name': 'mysql:latest', 'entrypoint': 'a', 'command': 'b c'},
])),
[{ 'name': 'mysql:latest', 'aliases': ['mysql'], 'entrypoint': 'a', 'command': 'b c' }]
)
self.assertEqual(
json.loads(normalize_services([
{'name': 'mysql:latest', 'entrypoint': ['a', 'b'], 'command': ['b c', 'c d']},
])),
[{ 'name': 'mysql:latest', 'aliases': ['mysql'], 'entrypoint': ['a', 'b'], 'command': ['b c', 'c d'] }]
)
class ExpandInVarsTest(unittest.TestCase):
def test_simple(self):
self.assertEqual(
expand_in_vars('a', {}),
'a'
)
self.assertEqual(
expand_in_vars('$abc', {'a': '1', 'abc': '2'}),
'2'
)
self.assertEqual(
expand_in_vars('$abc_$def-$g', {'abc': '1', 'def': '2'}),
'2-'
)
self.assertEqual(
expand_in_vars('$abc$def-$g', {'abc': '$def', 'def': '2'}),
'$def2-'
)
self.assertEqual(
expand_in_vars('${abc}def-$g', {'abc': '$def', 'def': '2'}),
'$defdef-'
)
self.assertEqual(
expand_in_vars('${ab$defc}', {'abc': '$def', 'def': '2'}),
'${ab}'
)
self.assertEqual(
expand_in_vars('${ab${def}c}', {'abc': '$def', 'def': '2'}),
'${ab2c}'
)
self.assertEqual(
expand_in_vars('${ab${def}c', {'abc': '$def', 'def': '2'}),
'${ab2c'
)
class GenerateMetadataFromJobTest(unittest.TestCase):
def test_simple(self):
r = CIFile(get_res('cache'))
self.assertEqual(
json.loads(generate_metadata_from_job(2, r.jobs['a'], {'CI_JOB_NAME': 'a'})),
{
'repo_id': 2,
'caches': [{
'key': 'test',
'paths': ['abc'],
'when': 'always',
'policy': 'pull',
}],
'cache_last_invalidated_sec': 0,
'protected': False,
}
)
self.assertEqual(
json.loads(generate_metadata_from_job(2, r.jobs['b'], {'CI_JOB_NAME': 'b'})),
{
'repo_id': 2,
'caches': [{
'key': 'test',
'paths': ['abc'],
'when': 'on_success',
'policy': 'pull-push',
}],
'cache_last_invalidated_sec': 0,
'protected': False,
}
)
self.assertEqual(
json.loads(generate_metadata_from_job(2, r.jobs['c'], {'CI_JOB_NAME': 'c'})),
{
'repo_id': 2,
'caches': [{
'key': 'c',
'paths': ['abc'],
'when': 'on_success',
'policy': 'pull-push',
}, {
'key': 'xxc',
'paths': ['def'],
'when': 'on_success',
'policy': 'pull-push',
}],
'cache_last_invalidated_sec': 0,
'protected': False,
}
)
if __name__ == '__main__':
unittest.main()
diff --git a/lilybuild/lilybuild/tests/pages_test.py b/lilybuild/lilybuild/tests/pages_test.py
index a40bbd5..17461bb 100644
--- a/lilybuild/lilybuild/tests/pages_test.py
+++ b/lilybuild/lilybuild/tests/pages_test.py
@@ -1,128 +1,131 @@
+# This file is part of lilybuild.
+# SPDX-FileCopyrightText: 2025-2026 tusooa <tusooa@kazv.moe>
+# SPDX-License-Identifier: GPL-2.0-only
import unittest
import tempfile
import os
import stat
import subprocess
from lilybuild.pages import (
FIRST, SECOND, CURRENT, init_deployment,
switch_symlink, extract_archive, deploy_pages,
)
try:
import tarfile
tarfile.FilterError
except AttributeError:
import backports.tarfile as tarfile
def is_dir(filename):
return stat.S_ISDIR(os.lstat(filename).st_mode)
def make_artifact_archive(root_dir):
os.makedirs(os.path.join(root_dir, 'public'))
with open(os.path.join(root_dir, 'public', 'a'), 'w') as f:
print('test', file=f)
os.makedirs(os.path.join(root_dir, 'other'))
with open(os.path.join(root_dir, 'other', 'a'), 'w') as f:
print('should not be there', file=f)
archive = os.path.join(root_dir, 'artifacts.tar')
with tarfile.open(archive, 'w') as f:
f.add(os.path.join(root_dir, 'public'), 'public')
f.add(os.path.join(root_dir, 'other'), 'other')
return archive
def make_artifact_archive2(root_dir):
os.makedirs(os.path.join(root_dir, 'public'))
with open(os.path.join(root_dir, 'public', 'b'), 'w') as f:
print('test b', file=f)
archive = os.path.join(root_dir, 'artifacts.tar')
with tarfile.open(archive, 'w') as f:
f.add(os.path.join(root_dir, 'public'), 'public')
return archive
def make_bad_artifact_archive(root_dir):
os.makedirs(os.path.join(root_dir, 'public'))
with open(os.path.join(root_dir, 'public', 'a'), 'w') as f:
print('test', file=f)
os.makedirs(os.path.join(root_dir, 'other'))
with open(os.path.join(root_dir, 'other', 'a'), 'w') as f:
print('should not be there', file=f)
archive = os.path.join(root_dir, 'artifacts.tar')
with tarfile.open(archive, 'w') as f:
f.add(os.path.join(root_dir, 'public'), 'public')
f.add(os.path.join(root_dir, 'other'), '../../../other')
return archive
class PagesTest(unittest.TestCase):
def test_init_deployment(self):
with tempfile.TemporaryDirectory() as dir_name:
res = init_deployment(dir_name)
self.assertEqual(res, FIRST)
self.assertTrue(is_dir(os.path.join(dir_name, FIRST)))
self.assertTrue(is_dir(os.path.join(dir_name, SECOND)))
self.assertEqual(os.readlink(os.path.join(dir_name, CURRENT)), SECOND)
res = init_deployment(dir_name)
self.assertEqual(res, FIRST)
self.assertEqual(os.readlink(os.path.join(dir_name, CURRENT)), SECOND)
def test_switch_symlink(self):
with tempfile.TemporaryDirectory() as dir_name:
res = init_deployment(dir_name)
switch_symlink(dir_name, res)
self.assertEqual(os.readlink(os.path.join(dir_name, CURRENT)), FIRST)
def test_extract_archive(self):
with tempfile.TemporaryDirectory() as root_dir:
archive_file = make_artifact_archive(root_dir)
dir_name = os.path.join(root_dir, 'deployment')
extract_archive(dir_name, archive_file)
self.assertTrue(is_dir(os.path.join(dir_name, 'public')))
self.assertTrue(os.path.exists(os.path.join(dir_name, 'public', 'a')))
self.assertFalse(os.path.exists(os.path.join(dir_name, 'other')))
def test_extract_bad_archive(self):
with tempfile.TemporaryDirectory() as root_dir:
archive_file = make_bad_artifact_archive(root_dir)
dir_name = os.path.join(root_dir, 'deployment')
with self.assertRaises(tarfile.FilterError):
extract_archive(dir_name, archive_file)
def test_deploy_pages(self):
with tempfile.TemporaryDirectory() as root_dir:
archive_file = make_artifact_archive(os.path.join(root_dir, 'a1'))
bad_archive = make_bad_artifact_archive(os.path.join(root_dir, 'bad'))
archive_file2 = make_artifact_archive2(os.path.join(root_dir, 'a2'))
dir_name = os.path.join(root_dir, 'deployment')
deploy_pages(dir_name, archive_file)
self.assertEqual(os.readlink(os.path.join(dir_name, CURRENT)), FIRST)
self.assertTrue(os.path.exists(os.path.join(dir_name, FIRST, 'public', 'a')))
with open(os.path.join(dir_name, FIRST, 'public', 'a')) as f:
self.assertEqual(f.read(), 'test\n')
# deploy the same thing again, it should go to SECOND
deploy_pages(dir_name, archive_file)
self.assertEqual(os.readlink(os.path.join(dir_name, CURRENT)), SECOND)
self.assertTrue(os.path.exists(os.path.join(dir_name, SECOND, 'public', 'a')))
with open(os.path.join(dir_name, SECOND, 'public', 'a')) as f:
self.assertEqual(f.read(), 'test\n')
# try to deploy the bad archive, it should not update the link
with self.assertRaises(tarfile.FilterError):
deploy_pages(dir_name, bad_archive)
self.assertEqual(os.readlink(os.path.join(dir_name, CURRENT)), SECOND)
# deploy something else, verify it cleans up all old files
deploy_pages(dir_name, archive_file2)
self.assertEqual(os.readlink(os.path.join(dir_name, CURRENT)), FIRST)
self.assertTrue(os.path.exists(os.path.join(dir_name, FIRST, 'public', 'b')))
self.assertFalse(os.path.exists(os.path.join(dir_name, FIRST, 'public', 'a')))
self.assertTrue(os.path.exists(os.path.join(dir_name, SECOND, 'public', 'a')))
self.assertFalse(os.path.exists(os.path.join(dir_name, SECOND, 'public', 'b')))
with open(os.path.join(dir_name, FIRST, 'public', 'b')) as f:
self.assertEqual(f.read(), 'test b\n')
if __name__ == '__main__':
unittest.main()
diff --git a/lilybuild/lilybuild/tests/podman_helper_test_worker.py b/lilybuild/lilybuild/tests/podman_helper_test_worker.py
index f4b29e7..02c62c2 100644
--- a/lilybuild/lilybuild/tests/podman_helper_test_worker.py
+++ b/lilybuild/lilybuild/tests/podman_helper_test_worker.py
@@ -1,481 +1,484 @@
+# This file is part of lilybuild.
+# SPDX-FileCopyrightText: 2025-2026 tusooa <tusooa@kazv.moe>
+# SPDX-License-Identifier: GPL-2.0-only
import unittest
from unittest.mock import Mock
import tempfile
import time
import os
import json
import subprocess
from dataclasses import dataclass
from contextlib import contextmanager
from lilybuild.podman_helper import PodmanHelper, image_to_podman_args
try:
import tarfile
tarfile.FilterError
except AttributeError:
import backports.tarfile as tarfile
@dataclass
class MockedCompletedProcess:
stdout: str | bytes | None = None
stderr: str | bytes | None = None
returncode: int = 0
def mocked(ph, mock=None):
ph.verbose_run = mock or Mock()
return ph
def make_cache_file(cache_file_name):
os.makedirs(os.path.dirname(cache_file_name), exist_ok=True)
with tempfile.TemporaryDirectory() as dir_name:
os.makedirs(os.path.join(dir_name, 'a'))
with open(os.path.join(dir_name, 'a', 'b'), 'w') as f:
print('bbb', file=f)
with tarfile.open(cache_file_name, 'w:gz') as f:
f.add(os.path.join(dir_name, 'a'), 'a')
return cache_file_name
class PodmanHelperTest(unittest.TestCase):
def test_get_cache_storage_dir(self):
ph = PodmanHelper(cache_storage_root_dir='/foo/cache')
self.assertTrue(
ph.get_cache_storage_dir(1, {
'key': 'foo',
}, protected=False)
.startswith('/foo/cache/repos/1/unprotected/cache-keys/')
)
self.assertTrue(
ph.get_cache_storage_dir(1, {
'key': 'bar',
}, protected=True)
.startswith('/foo/cache/repos/1/protected/cache-keys/')
)
def test_get_valid_caches(self):
md = {
'repo_id': 1,
'caches': [{
'key': 'bar',
'paths': ['a'],
'when': 'on_success',
'policy': 'pull-push',
}],
'cache_last_invalidated_sec': 0,
'protected': False,
}
# No cache directory
with tempfile.TemporaryDirectory() as dir_name:
ph = PodmanHelper(cache_storage_root_dir=dir_name)
self.assertEqual(ph.get_valid_caches(md), [])
# With cache directory, no cache file
with tempfile.TemporaryDirectory() as dir_name:
ph = PodmanHelper(cache_storage_root_dir=dir_name)
d1 = ph.get_cache_storage_dir(1, md['caches'][0], protected=False)
os.makedirs(d1)
self.assertEqual(ph.get_valid_caches(md), [])
# With cache directory, with cache file
with tempfile.TemporaryDirectory() as dir_name:
ph = PodmanHelper(cache_storage_root_dir=dir_name)
d1 = ph.get_cache_storage_dir(1, md['caches'][0], protected=False)
os.makedirs(d1)
cache_file = os.path.join(d1, 'cur')
with open(cache_file, 'w') as f:
print('', file=f)
self.assertEqual(ph.get_valid_caches(md), [cache_file])
# With cache directory, with expired cache file
with tempfile.TemporaryDirectory() as dir_name:
ph = PodmanHelper(cache_storage_root_dir=dir_name)
d1 = ph.get_cache_storage_dir(1, md['caches'][0], protected=False)
os.makedirs(d1)
cache_file = os.path.join(d1, 'cur')
with open(cache_file, 'w') as f:
print('', file=f)
md2 = md.copy()
md2['cache_last_invalidated_sec'] = time.time() + 1
self.assertEqual(ph.get_valid_caches(md2), [])
# With cache directory and cache file, but policy does not contain pull
with tempfile.TemporaryDirectory() as dir_name:
ph = PodmanHelper(cache_storage_root_dir=dir_name)
d1 = ph.get_cache_storage_dir(1, md['caches'][0], protected=False)
os.makedirs(d1)
cache_file = os.path.join(d1, 'cur')
with open(cache_file, 'w') as f:
print('', file=f)
md2 = md.copy()
md2['caches'] = [md['caches'][0].copy()]
md2['caches'][0]['policy'] = 'push'
self.assertEqual(ph.get_valid_caches(md2), [])
def test_import_caches(self):
md = {
'repo_id': 1,
'caches': [{
'key': 'bar',
'paths': ['a'],
'when': 'on_success',
'policy': 'pull-push',
}, {
'key': 'mew',
'paths': ['b'],
'when': 'on_success',
'policy': 'pull-push',
}],
'cache_last_invalidated_sec': 0,
'protected': False,
}
with tempfile.TemporaryDirectory() as dir_name:
ph = mocked(
PodmanHelper(cache_storage_root_dir=dir_name)
)
ph.helper_container_alias = 'helper-xxx'
with self.assertRaises(RuntimeError) as m:
ph.import_caches(md, '')
with tempfile.TemporaryDirectory() as dir_name:
ph = mocked(
PodmanHelper(cache_storage_root_dir=dir_name)
)
ph.helper_container_alias = 'helper-xxx'
d = ph.get_cache_storage_dir(md['repo_id'], md['caches'][0], protected=md['protected'])
cache_name = os.path.join(d, ph.cur_cache_basename)
make_cache_file(cache_name)
ph.import_caches(md, ph.work_vol_mount_dir)
# rsync -> tar -> rm archive
self.assertEqual(ph.verbose_run.call_count, 3)
self.assertTrue('rsync' in ph.verbose_run.call_args_list[0].args[0])
self.assertTrue('tar' in ph.verbose_run.call_args_list[1].args[0])
self.assertTrue('rm' in ph.verbose_run.call_args_list[2].args[0])
with tempfile.TemporaryDirectory() as dir_name:
def handle(run_args, **kwargs):
if 'tar' in run_args:
raise subprocess.CalledProcessError(returncode=1, cmd=run_args)
return MockedCompletedProcess()
ph = mocked(
PodmanHelper(cache_storage_root_dir=dir_name),
Mock(side_effect=handle),
)
ph.helper_container_alias = 'helper-xxx'
d = ph.get_cache_storage_dir(md['repo_id'], md['caches'][0], protected=md['protected'])
cache_name = os.path.join(d, ph.cur_cache_basename)
make_cache_file(cache_name)
ph.import_caches(md, ph.work_vol_mount_dir)
# rsync -> tar -> clean up extracted dir -> rm archive
self.assertEqual(ph.verbose_run.call_count, 4)
self.assertTrue('rsync' in ph.verbose_run.call_args_list[0].args[0])
self.assertTrue('tar' in ph.verbose_run.call_args_list[1].args[0])
self.assertTrue('rm' in ph.verbose_run.call_args_list[2].args[0])
self.assertTrue('rm' in ph.verbose_run.call_args_list[3].args[0])
def test_save_caches(self):
md = {
'repo_id': 1,
'caches': [{
'key': 'bar',
'paths': ['a'],
'when': 'on_success',
'policy': 'pull-push',
}],
'cache_last_invalidated_sec': 0,
'protected': False,
}
with tempfile.TemporaryDirectory() as dir_name:
cache_root = os.path.join(dir_name, 'cache')
result_dir = os.path.join(dir_name, 'result')
os.makedirs(os.path.join(result_dir, 'a'))
with open(os.path.join(result_dir, 'a', 'b'), 'w') as f:
print('mewmew', file=f)
ph = PodmanHelper(cache_storage_root_dir=cache_root, result_dir=result_dir)
ph.save_caches(md, succeeded=True)
cache_dir = ph.get_cache_storage_dir(md['repo_id'], md['caches'][0], protected=md['protected'])
cache_file = os.path.join(cache_dir, ph.cur_cache_basename)
self.assertTrue(os.path.exists(cache_file))
with tarfile.open(cache_file) as f:
f.getmember('a/b')
# did not succeed
with tempfile.TemporaryDirectory() as dir_name:
cache_root = os.path.join(dir_name, 'cache')
result_dir = os.path.join(dir_name, 'result')
ph = PodmanHelper(cache_storage_root_dir=cache_root, result_dir=result_dir)
ph.save_caches(md, succeeded=False)
cache_dir = ph.get_cache_storage_dir(md['repo_id'], md['caches'][0], protected=md['protected'])
cache_file = os.path.join(cache_dir, ph.cur_cache_basename)
self.assertFalse(os.path.exists(cache_file))
md2 = {
'repo_id': 1,
'caches': [{
'key': 'bar',
'paths': ['a'],
'when': 'on_success',
'policy': 'pull',
}],
'cache_last_invalidated_sec': 0,
'protected': False,
}
# policy does not contain push
with tempfile.TemporaryDirectory() as dir_name:
cache_root = os.path.join(dir_name, 'cache')
result_dir = os.path.join(dir_name, 'result')
ph = PodmanHelper(cache_storage_root_dir=cache_root, result_dir=result_dir)
ph.save_caches(md2, succeeded=True)
cache_dir = ph.get_cache_storage_dir(md2['repo_id'], md2['caches'][0], protected=md2['protected'])
cache_file = os.path.join(cache_dir, ph.cur_cache_basename)
self.assertFalse(os.path.exists(cache_file))
def test_create_and_clean_volume(self):
ph = mocked(
PodmanHelper(),
Mock(side_effect=[
MockedCompletedProcess(stdout='volume1\n'),
MockedCompletedProcess(stdout='volume2\n'),
MockedCompletedProcess(stdout='volume1\nvolume2\n'),
])
)
res = ph.create_volume('work')
self.assertEqual(res, 'volume1')
res = ph.create_volume('script')
self.assertEqual(res, 'volume2')
self.assertEqual(ph.volumes_to_remove, ['volume1', 'volume2'])
ph.clean_volumes()
self.assertEqual(
ph.verbose_run.call_args.args[0][-3:],
['--', 'volume1', 'volume2'],
)
def test_helper_service(self):
key_content = 'ssh-ed25519 somethingsomething a@example.com'
with tempfile.NamedTemporaryFile(mode='w+', encoding='utf-8') as key_pub:
print(key_content, file=key_pub)
key_pub.flush()
ph = mocked(
PodmanHelper(
worker_container_name='workerhostname',
key_file_pub=key_pub.name,
ssh_wait_interval_sec=0.001,
volume_helper_image='lilybuild-volume-helper',
),
Mock(side_effect=[
# podman inspect
MockedCompletedProcess(stdout=json.dumps([{
'Pod': 'pod0',
'NetworkSettings': {
'Networks': {
'network0': {
},
}
}
}])),
# podman run
MockedCompletedProcess(stdout='container0\n'),
# nc
MockedCompletedProcess(returncode=1),
# nc
MockedCompletedProcess(stdout=b'SSH 1.1.1\n'),
# podman container rm
MockedCompletedProcess(),
])
)
(cont_name, alias) = ph.start_helper_service('volume1', 'volume2')
self.assertEqual(
ph.verbose_run.call_args_list[0].args[0][-3:],
['inspect', '--', 'workerhostname'],
)
self.assertEqual(
ph.verbose_run.call_args_list[1].args[0][-2:],
['--', 'lilybuild-volume-helper'],
)
self.assertTrue(
'run' in ph.verbose_run.call_args_list[1].args[0]
)
self.assertTrue(ph.helper_container_id is not None)
ph.clean_helper_container()
self.assertEqual(
ph.verbose_run.call_args.args[0][-2:],
['--', cont_name],
)
def test_create_prune_service_containers(self):
env = '/path/to/script/env'
ph = mocked(
PodmanHelper(env_filename=env),
Mock(side_effect=
# network create
[MockedCompletedProcess(stdout='network0')]
# container run
+ [MockedCompletedProcess(stdout=f'container{i}') for i in range(4)]
# stop & rm
+ [MockedCompletedProcess(), MockedCompletedProcess()]
),
)
ph.create_service_network()
self.assertEqual(ph.service_network_id, 'network0')
# no entrypoint, no command
ph.start_and_record_service_container({
'name': 'service:latest',
'aliases': ['foo', 'bar'],
'command': None,
'entrypoint': None,
})
self.assertTrue(f'--env-file={env}' in ph.verbose_run.call_args.args[0])
self.assertTrue('--network=network0' in ph.verbose_run.call_args.args[0])
self.assertTrue('--network-alias=foo' in ph.verbose_run.call_args.args[0])
self.assertTrue('--network-alias=bar' in ph.verbose_run.call_args.args[0])
self.assertEqual(
ph.verbose_run.call_args.args[0][-2:],
['--', 'service:latest'],
)
# no entrypoint, with command
ph.start_and_record_service_container({
'name': 'service:latest',
'aliases': ['foo', 'bar'],
'command': ['abc', 'def'],
'entrypoint': None,
})
self.assertEqual(
ph.verbose_run.call_args.args[0][-4:],
['--', 'service:latest', 'abc', 'def'],
)
# entrypoint str
ph.start_and_record_service_container({
'name': 'service:latest',
'aliases': ['foo', 'bar'],
'command': ['abc', 'def'],
'entrypoint': '/bin/sh',
})
self.assertTrue('--entrypoint=/bin/sh', ph.verbose_run.call_args.args[0])
# entrypoint list
ph.start_and_record_service_container({
'name': 'service:latest',
'aliases': ['foo', 'bar'],
'command': ['abc', 'def'],
'entrypoint': ['/bin/sh', '-c'],
})
self.assertTrue('--entrypoint=["/bin/sh", "-c"]', ph.verbose_run.call_args.args[0])
self.assertEqual(ph.service_containers, ['container0', 'container1', 'container2', 'container3'])
ph.maybe_prune_service_containers()
self.assertEqual(
ph.verbose_run.call_args_list[-2].args[0][-5:],
['--', 'container0', 'container1', 'container2', 'container3'],
)
self.assertEqual(
ph.verbose_run.call_args_list[-1].args[0][-5:],
['--', 'container0', 'container1', 'container2', 'container3'],
)
def test_ensure_service_containers_up(self):
count = 0
def inspect_func(run_args, **kwargs):
nonlocal count
count += 1
cont_id = run_args[-1]
res = [{
'State': {
'Status': 'running',
}
}]
if cont_id == 'container0' and count < 5:
res[0]['State']['Status'] = 'starting'
return MockedCompletedProcess(stdout=json.dumps(res))
ph = mocked(
PodmanHelper(service_wait_interval_sec=0.001, service_max_wait_sec=1),
Mock(side_effect=inspect_func),
)
ph.service_containers += ['container0', 'container1']
ph.ensure_service_containers_up()
def test_ensure_service_containers_up_failed(self):
def inspect_func(run_args, **kwargs):
res = [{
'State': {
'Status': 'starting',
}
}]
return MockedCompletedProcess(stdout=json.dumps(res))
ph = mocked(
PodmanHelper(service_wait_interval_sec=0.5, service_max_wait_sec=1),
Mock(side_effect=inspect_func),
)
ph.service_containers += ['container0', 'container1']
with self.assertRaises(TimeoutError) as m:
ph.ensure_service_containers_up()
def test_image_to_podman_args(self):
self.assertEqual(
image_to_podman_args({'name': 'aaa'}),
['--', 'aaa'],
)
self.assertEqual(
image_to_podman_args({'name': 'aaa', 'entrypoint': ['mew', 'abc']}),
['--entrypoint', json.dumps(['mew', 'abc']), '--', 'aaa'],
)
def test_run_in_container(self):
def make_handle(rc):
def handle(run_args, **kwargs):
if run_args[1] == 'run':
return MockedCompletedProcess(stdout='container0\n')
if run_args[1] == 'logs':
return MockedCompletedProcess()
if run_args[2] == 'inspect' and '{{.State.Status}}' in run_args:
return MockedCompletedProcess(stdout='exited\n')
if run_args[2] == 'inspect' and '{{.State.ExitCode}}' in run_args:
return MockedCompletedProcess(stdout=f'{rc}\n')
if run_args[2] == 'stop' or run_args[2] == 'rm':
return MockedCompletedProcess()
raise RuntimeError(f'Unexpected command called: {run_args}')
return handle
ph = mocked(
PodmanHelper(container_run_timeout_sec=10, env_filename='/env'),
Mock(side_effect=make_handle(0)),
)
rc = ph.run_in_container({'name': 'foo:latest'}, 'work_vol', 'script_vol')
self.assertEqual(rc, 0)
ph = mocked(
PodmanHelper(container_run_timeout_sec=10, env_filename='/env'),
Mock(side_effect=make_handle(1)),
)
rc = ph.run_in_container({'name': 'foo:latest'}, 'work_vol', 'script_vol')
self.assertEqual(rc, 1)
def test_run_in_container_timeout(self):
def handle(run_args, **kwargs):
if run_args[1] == 'run':
return MockedCompletedProcess(stdout='container0\n')
if run_args[1] == 'logs':
raise subprocess.TimeoutExpired(run_args, 10)
if run_args[2] == 'stop' or run_args[2] == 'rm':
return MockedCompletedProcess()
raise RuntimeError(f'Unexpected command called: {run_args}')
ph = mocked(
PodmanHelper(container_run_timeout_sec=10, env_filename='/env'),
Mock(side_effect=handle),
)
rc = ph.run_in_container({'name': 'foo:latest'}, 'work_vol', 'script_vol')
self.assertEqual(rc, 1)
# check the container is cleaned up
self.assertTrue('stop' in ph.verbose_run.call_args_list[-2].args[0])
self.assertTrue('rm' in ph.verbose_run.call_args_list[-1].args[0])
def test_cleanup_all(self):
ph = mocked(PodmanHelper())
ph.cleanup_all()
if __name__ == '__main__':
unittest.main()
diff --git a/lilybuild/lilybuild/tests/resources.py b/lilybuild/lilybuild/tests/resources.py
index 21e9b0e..9d17f9f 100644
--- a/lilybuild/lilybuild/tests/resources.py
+++ b/lilybuild/lilybuild/tests/resources.py
@@ -1,9 +1,12 @@
+# This file is part of lilybuild.
+# SPDX-FileCopyrightText: 2025-2026 tusooa <tusooa@kazv.moe>
+# SPDX-License-Identifier: GPL-2.0-only
import os
self_dir = os.path.dirname(__file__)
res_dir = os.path.join(self_dir, 'ci_syntax', 'res')
def get_res(name):
with open(os.path.join(res_dir, name + '.yaml'), 'r') as f:
return f.read()
diff --git a/lilybuild/lilybuild/tests/safetar_test_worker.py b/lilybuild/lilybuild/tests/safetar_test_worker.py
index 68fa518..2d1ab73 100644
--- a/lilybuild/lilybuild/tests/safetar_test_worker.py
+++ b/lilybuild/lilybuild/tests/safetar_test_worker.py
@@ -1,223 +1,226 @@
+# This file is part of lilybuild.
+# SPDX-FileCopyrightText: 2025-2026 tusooa <tusooa@kazv.moe>
+# SPDX-License-Identifier: GPL-2.0-only
import unittest
import tempfile
import os
import stat
from lilybuild.safetar import (
create, extract
)
try:
import tarfile
tarfile.FilterError
except AttributeError:
import backports.tarfile as tarfile
def make_artifact_dir(root_dir):
os.makedirs(os.path.join(root_dir, 'public'))
with open(os.path.join(root_dir, 'public', 'a'), 'w') as f:
print('test', file=f)
os.makedirs(os.path.join(root_dir, 'other'))
with open(os.path.join(root_dir, 'other', 'a'), 'w') as f:
print('another test', file=f)
def make_artifact_dir_link(root_dir):
os.makedirs(os.path.join(root_dir, 'public'))
with open(os.path.join(root_dir, 'public', 'a'), 'w') as f:
print('test', file=f)
os.makedirs(os.path.join(root_dir, 'other'))
os.symlink('../public/a', os.path.join(root_dir, 'other', 'a'))
def make_bad_artifact_archive(root_dir):
os.makedirs(os.path.join(root_dir, 'public'))
with open(os.path.join(root_dir, 'public', 'a'), 'w') as f:
print('test', file=f)
os.makedirs(os.path.join(root_dir, 'other'))
with open(os.path.join(root_dir, 'other', 'a'), 'w') as f:
print('should not be there', file=f)
archive = os.path.join(root_dir, 'artifacts.tar')
with tarfile.open(archive, 'w') as f:
f.add(os.path.join(root_dir, 'public'), 'public')
f.add(os.path.join(root_dir, 'other'), '../../../other')
return archive
class SafetarTest(unittest.TestCase):
def test_create(self):
with tempfile.TemporaryDirectory() as dir_name:
make_artifact_dir(dir_name)
archive = os.path.join(dir_name, 'artifacts.tar')
create(archive, dir_name, ['public', 'other'], None, None)
with tarfile.open(archive, 'r') as f:
f.getmember('public/a')
f.getmember('other/a')
target = os.path.join(dir_name, 'extracts')
os.makedirs(target)
extract(target, archive)
def test_create_compression(self):
with tempfile.TemporaryDirectory() as dir_name:
make_artifact_dir(dir_name)
archive = os.path.join(dir_name, 'artifacts.tar')
create(archive, dir_name, ['public', 'other'], None, None, 'gz')
with tarfile.open(archive, 'r:gz') as f:
f.getmember('public/a')
f.getmember('other/a')
with tarfile.open(archive, 'r') as f:
f.getmember('public/a')
f.getmember('other/a')
target = os.path.join(dir_name, 'extracts')
os.makedirs(target)
extract(target, archive)
def test_create_glob(self):
with tempfile.TemporaryDirectory() as dir_name:
make_artifact_dir(dir_name)
archive = os.path.join(dir_name, 'artifacts.tar')
create(archive, dir_name, ['*'], None, None)
with tarfile.open(archive, 'r') as f:
f.getmember('public/a')
f.getmember('other/a')
with tempfile.TemporaryDirectory() as dir_name:
make_artifact_dir(dir_name)
archive = os.path.join(dir_name, 'artifacts.tar')
create(archive, dir_name, ['p*/a'], None, None)
with tarfile.open(archive, 'r') as f:
f.getmember('public/a')
with self.assertRaises(KeyError):
f.getmember('other')
with tempfile.TemporaryDirectory() as dir_name:
make_artifact_dir(dir_name)
archive = os.path.join(dir_name, 'artifacts.tar')
create(archive, dir_name, ['**/a'], None, None)
with tarfile.open(archive, 'r') as f:
f.getmember('public/a')
f.getmember('other/a')
def test_create_glob_not_exploitable(self):
# This tests for any traversal attack
with tempfile.TemporaryDirectory() as dir_name:
make_artifact_dir(dir_name)
archive = os.path.join(dir_name, 'artifacts.tar')
with self.assertRaises(RuntimeError):
create(archive, dir_name, ['/**'], None, None)
with tempfile.TemporaryDirectory() as dir_name:
make_artifact_dir(dir_name)
archive = os.path.join(dir_name, 'artifacts.tar')
with self.assertRaises(RuntimeError):
create(archive, os.path.join(dir_name, 'public'), ['../**'], None, None)
def test_create_out_of_scope(self):
with tempfile.TemporaryDirectory() as dir_name:
make_artifact_dir(dir_name)
archive = os.path.join(dir_name, 'artifacts.tar')
with self.assertRaises(RuntimeError):
create(archive, os.path.join(dir_name, 'public'), ['.', '../other'], None, None)
with self.assertRaises(RuntimeError):
create(archive, os.path.join(dir_name, 'public'), ['/home'], None, None)
def test_create_good_link(self):
with tempfile.TemporaryDirectory() as dir_name:
make_artifact_dir_link(dir_name)
archive = os.path.join(dir_name, 'artifacts.tar')
create(archive, dir_name, ['public', 'other'], None, None)
with tarfile.open(archive, 'r') as f:
f.getmember('public/a')
f.getmember('other/a')
def test_create_bad_link(self):
with tempfile.TemporaryDirectory() as dir_name:
make_artifact_dir_link(dir_name)
archive = os.path.join(dir_name, 'artifacts.tar')
with self.assertRaises(RuntimeError):
create(archive, os.path.join(dir_name, 'other'), ['.'], None, None)
def test_create_limited(self):
with tempfile.TemporaryDirectory() as dir_name:
make_artifact_dir(dir_name)
archive = os.path.join(dir_name, 'artifacts.tar')
with self.assertRaises(RuntimeError):
create(archive, dir_name, ['public', 'other'], 8, None)
def test_create_filtered(self):
with tempfile.TemporaryDirectory() as dir_name:
make_artifact_dir(dir_name)
archive = os.path.join(dir_name, 'artifacts.tar')
create(archive, dir_name, ['public', 'other'], None, ['other/a'])
with tarfile.open(archive, 'r') as f:
f.getmember('public/a')
f.getmember('other')
with self.assertRaises(KeyError):
f.getmember('other/a')
with tempfile.TemporaryDirectory() as dir_name:
make_artifact_dir(dir_name)
archive = os.path.join(dir_name, 'artifacts.tar')
create(archive, dir_name, ['public', 'other'], None, ['other'])
with tarfile.open(archive, 'r') as f:
f.getmember('public/a')
with self.assertRaises(KeyError):
f.getmember('other')
with self.assertRaises(KeyError):
f.getmember('other/a')
with tempfile.TemporaryDirectory() as dir_name:
make_artifact_dir(dir_name)
archive = os.path.join(dir_name, 'artifacts.tar')
create(archive, dir_name, ['public', 'other'], None, ['other/'])
with tarfile.open(archive, 'r') as f:
f.getmember('public/a')
with self.assertRaises(KeyError):
f.getmember('other')
with self.assertRaises(KeyError):
f.getmember('other/a')
with tempfile.TemporaryDirectory() as dir_name:
make_artifact_dir(dir_name)
archive = os.path.join(dir_name, 'artifacts.tar')
create(archive, dir_name, ['public', 'other'], None, ['other/a/'])
with tarfile.open(archive, 'r') as f:
f.getmember('public/a')
f.getmember('other')
f.getmember('other/a')
with tempfile.TemporaryDirectory() as dir_name:
make_artifact_dir(dir_name)
archive = os.path.join(dir_name, 'artifacts.tar')
create(archive, dir_name, ['public', 'other'], None, ['oth'])
with tarfile.open(archive, 'r') as f:
f.getmember('public/a')
f.getmember('other')
f.getmember('other/a')
with tempfile.TemporaryDirectory() as dir_name:
make_artifact_dir(dir_name)
archive = os.path.join(dir_name, 'artifacts.tar')
create(archive, dir_name, ['public', 'other'], None, ['**/a'])
with tarfile.open(archive, 'r') as f:
f.getmember('public')
f.getmember('other')
with self.assertRaises(KeyError):
f.getmember('public/a')
with self.assertRaises(KeyError):
f.getmember('other/a')
with tempfile.TemporaryDirectory() as dir_name:
make_artifact_dir(dir_name)
archive = os.path.join(dir_name, 'artifacts.tar')
create(archive, dir_name, ['public', 'other'], None, ['a'])
with tarfile.open(archive, 'r') as f:
f.getmember('public')
f.getmember('other')
f.getmember('public/a')
f.getmember('other/a')
def test_extract_bad(self):
with tempfile.TemporaryDirectory() as root_dir:
archive_file = make_bad_artifact_archive(root_dir)
with self.assertRaises(tarfile.FilterError):
extract(root_dir, archive_file)
if __name__ == '__main__':
unittest.main()

File Metadata

Mime Type
text/x-diff
Expires
Sat, Oct 10, 1:37 AM (1 d, 9 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
1784017
Default Alt Text
(214 KB)

Event Timeline