13 Commits
71 changed files with 256878 additions and 1564 deletions

No files matched your search

+22
View File
@@ -0,0 +1,22 @@
The MIT License (MIT)
Copyright (c) 2018 GitHub, Inc. and contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
@@ -0,0 +1,8 @@
{
"name": "ci-action-gitea-download-artifact",
"source": "https://github.com/christopherhx/gitea-download-artifact.git",
"requested_ref": "v4",
"commit": "75635f32b4c1c41c4b3d64e8f85210112ed4c9c7",
"path": "/private/tmp/gitea-migration-20261105/mirrors/ci-action-gitea-download-artifact.git",
"destination": "https://git.ermnvldmr.com/deytenit/ci-action-gitea-download-artifact"
}
@@ -0,0 +1,40 @@
name: 'Download a Build Artifact'
description: 'Download a build artifact that was previously uploaded in the workflow by the upload-artifact action'
author: 'GitHub'
inputs:
name:
description: 'Name of the artifact to download. If unspecified, all artifacts for the run are downloaded.'
required: false
path:
description: 'Destination path. Supports basic tilde expansion. Defaults to $GITHUB_WORKSPACE'
required: false
pattern:
description: 'A glob pattern matching the artifacts that should be downloaded. Ignored if name is specified.'
required: false
merge-multiple:
description: 'When multiple artifacts are matched, this changes the behavior of the destination directories.
If true, the downloaded artifacts will be in the same directory specified by path.
If false, the downloaded artifacts will be extracted into individual named directories within the specified path.'
required: false
default: 'false'
github-token:
description: 'The GitHub token used to authenticate with the GitHub API.
This is required when downloading artifacts from a different repository or from a different workflow run.
If this is not specified, the action will attempt to download artifacts from the current repository and the current workflow run.'
required: false
repository:
description: 'The repository owner and the repository name joined together by "/".
If github-token is specified, this is the repository that artifacts will be downloaded from.'
required: false
default: ${{ github.repository }}
run-id:
description: 'The id of the workflow run where the desired download artifact was uploaded from.
If github-token is specified, this is the run that artifacts will be downloaded from.'
required: false
default: ${{ github.run_id }}
outputs:
download-path:
description: 'Path of artifact download'
runs:
using: 'node20'
main: 'dist/index.js'
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,47 @@
{
"name": "download-artifact",
"version": "4.0.1",
"description": "Download an Actions Artifact from a workflow run",
"main": "dist/index.js",
"scripts": {
"build": "tsc",
"release": "ncc build src/download-artifact.ts && git add -f dist/",
"check-all": "concurrently \"npm:format-check\" \"npm:lint\" \"npm:build\"",
"format": "prettier --write **/*.ts",
"format-check": "prettier --check **/*.ts",
"lint": "eslint **/*.ts"
},
"repository": {
"type": "git",
"url": "git+https://github.com/actions/download-artifact.git"
},
"keywords": [
"Actions",
"GitHub",
"Artifacts",
"Download"
],
"author": "GitHub",
"license": "MIT",
"bugs": {
"url": "https://github.com/actions/download-artifact/issues"
},
"homepage": "https://github.com/actions/download-artifact#readme",
"dependencies": {
"@actions/artifact": "^2.0.1",
"@actions/core": "^1.10.0",
"@actions/github": "^5.1.1",
"minimatch": "^9.0.3"
},
"devDependencies": {
"@types/node": "^12.12.6",
"@typescript-eslint/eslint-plugin": "^6.14.0",
"@vercel/ncc": "^0.33.4",
"concurrently": "^5.2.0",
"eslint": "^8.55.0",
"eslint-plugin-github": "^4.10.1",
"eslint-plugin-prettier": "^5.0.1",
"prettier": "^3.1.1",
"typescript": "^5.3.3"
}
}
+22
View File
@@ -0,0 +1,22 @@
The MIT License (MIT)
Copyright (c) 2018 GitHub, Inc. and contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
@@ -0,0 +1,8 @@
{
"name": "ci-action-gitea-upload-artifact",
"source": "https://github.com/christopherhx/gitea-upload-artifact.git",
"requested_ref": "v4",
"commit": "81f940d004763f986ba3582c007fd842dd5cb0d7",
"path": "/private/tmp/gitea-migration-20261105/mirrors/ci-action-gitea-upload-artifact.git",
"destination": "https://git.ermnvldmr.com/deytenit/ci-action-gitea-upload-artifact"
}
+61
View File
@@ -0,0 +1,61 @@
name: 'Upload a Build Artifact'
description: 'Upload a build artifact that can be used by subsequent workflow steps'
author: 'GitHub'
inputs:
name:
description: 'Artifact name'
default: 'artifact'
path:
description: 'A file, directory or wildcard pattern that describes what to upload'
required: true
if-no-files-found:
description: >
The desired behavior if no files are found using the provided path.
Available Options:
warn: Output a warning but do not fail the action
error: Fail the action with an error message
ignore: Do not output any warnings or errors, the action does not fail
default: 'warn'
retention-days:
description: >
Duration after which artifact will expire in days. 0 means using default retention.
Minimum 1 day.
Maximum 90 days unless changed from the repository settings page.
compression-level:
description: >
The level of compression for Zlib to be applied to the artifact archive.
The value can range from 0 to 9:
- 0: No compression
- 1: Best speed
- 6: Default compression (same as GNU Gzip)
- 9: Best compression
Higher levels will result in better compression, but will take longer to complete.
For large files that are not easily compressed, a value of 0 is recommended for significantly faster uploads.
default: '6'
overwrite:
description: >
If true, an artifact with a matching name will be deleted before a new one is uploaded.
If false, the action will fail if an artifact for the given name already exists.
Does not fail if the artifact does not exist.
default: 'false'
outputs:
artifact-id:
description: >
A unique identifier for the artifact that was just uploaded. Empty if the artifact upload failed.
This ID can be used as input to other APIs to download, delete or get more information about an artifact: https://docs.github.com/en/rest/actions/artifacts
artifact-url:
description: >
A download URL for the artifact that was just uploaded. Empty if the artifact upload failed.
This download URL only works for requests Authenticated with GitHub. Anonymous downloads will be prompted to first login.
If an anonymous download URL is needed than a short time restricted URL can be generated using the download artifact API: https://docs.github.com/en/rest/actions/artifacts#download-an-artifact
This URL will be valid for as long as the artifact exists and the workflow run and repository exists. Once an artifact has expired this URL will no longer work.
Common uses cases for such a download URL can be adding download links to artifacts in descriptions or comments on pull requests or issues.
runs:
using: 'node20'
main: 'dist/index.js'
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,54 @@
{
"name": "upload-artifact",
"version": "4.2.0",
"description": "Upload an Actions Artifact in a workflow run",
"main": "dist/index.js",
"scripts": {
"build": "tsc",
"release": "ncc build src/upload-artifact.ts && git add -f dist/index.js",
"check-all": "concurrently \"npm:format-check\" \"npm:lint\" \"npm:test\" \"npm:build\"",
"format": "prettier --write **/*.ts",
"format-check": "prettier --check **/*.ts",
"lint": "eslint **/*.ts",
"test": "jest --testTimeout 10000"
},
"repository": {
"type": "git",
"url": "git+https://github.com/actions/upload-artifact.git"
},
"keywords": [
"Actions",
"GitHub",
"Artifacts",
"Upload"
],
"author": "GitHub",
"license": "MIT",
"bugs": {
"url": "https://github.com/actions/upload-artifact/issues"
},
"homepage": "https://github.com/actions/upload-artifact#readme",
"dependencies": {
"@actions/artifact": "^2.1.0",
"@actions/core": "^1.10.0",
"@actions/github": "^6.0.0",
"@actions/glob": "^0.3.0",
"@actions/io": "^1.1.2"
},
"devDependencies": {
"@types/jest": "^29.2.5",
"@types/node": "^18.11.18",
"@typescript-eslint/parser": "^5.48.0",
"@vercel/ncc": "^0.36.0",
"concurrently": "^7.6.0",
"eslint": "^8.31.0",
"eslint-plugin-github": "^4.6.0",
"eslint-plugin-jest": "^27.2.0",
"glob": "^8.0.3",
"jest": "^29.3.1",
"jest-circus": "^29.3.1",
"prettier": "^2.8.1",
"ts-jest": "^29.0.3",
"typescript": "^4.9.4"
}
}
@@ -0,0 +1,179 @@
name: ci/packer - main/manual-build-golden-image
'on':
workflow_dispatch:
inputs:
upload_s3:
description: Upload built image to specified static S3 Object Storage
type: boolean
default: true
concurrency:
group: ci-golden-image
cancel-in-progress: false
permissions:
contents: read
jobs:
validate-scripts:
name: 'Validate: Provisioner Scripts'
runs-on: apex-debian13
steps:
- name: Checkout repository
run: "set -euo pipefail\ncase \"$CHECKOUT_REF\" in -*) exit 1;; esac\nGITHUB_SERVER_URL=${GITHUB_SERVER_URL%/}\n\
case \"$GITHUB_SERVER_URL\" in https://git.ermnvldmr.com) ;; *) exit 1;; esac\n\
AUTH=$(printf 'deytenit:%s' \"$CHECKOUT_TOKEN\" | base64 -w0)\nHEADER=\"http.${GITHUB_SERVER_URL}/.extraheader=AUTHORIZATION:\
\ basic $AUTH\"\ngit init \"$GITHUB_WORKSPACE\"\ncd \"$GITHUB_WORKSPACE\"\n\
git remote add origin \"$GITHUB_SERVER_URL/$GITHUB_REPOSITORY.git\"\ngit -c\
\ \"$HEADER\" fetch --tags origin '+refs/heads/*:refs/remotes/origin/*'\n\
git -c \"$HEADER\" fetch origin \"$CHECKOUT_REF\"\ngit checkout --detach FETCH_HEAD\n\
test \"$(git rev-parse HEAD)\" = \"$GITHUB_SHA\"\nif [ \"$CHECKOUT_SUBMODULES\"\
\ = recursive ]; then\n key_file=$(mktemp \"${RUNNER_TEMP:-/tmp}/hextra-key.XXXXXX\"\
)\n trap 'rm -f \"$key_file\"' EXIT\n umask 077\n printf '%s\\n'\
\ \"$HEXTRA_READ_KEY\" > \"$key_file\"\n chmod 600 \"$key_file\"\n export\
\ GIT_SSH_COMMAND=\"ssh -T -i $key_file -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes\
\ -o HostKeyAlgorithms=ssh-ed25519 -o UserKnownHostsFile=$GITHUB_WORKSPACE/.gitea/known_hosts\"\
\n git submodule update --init --recursive\nfi\nif [ \"$PERSIST_CREDENTIALS\"\
\ = true ]; then\n git config \"http.${GITHUB_SERVER_URL}/.extraheader\"\
\ \"AUTHORIZATION: basic $AUTH\"\nfi\n"
env:
CHECKOUT_REF: ${{ github.sha }}
CHECKOUT_SUBMODULES: 'false'
CHECKOUT_TOKEN: ${{ github.token }}
PERSIST_CREDENTIALS: 'false'
HEXTRA_READ_KEY: ''
- name: Setup Python
run: python3 -c "import sys; assert sys.version_info >= (3, 10)"
- name: Install Developer Dependencies
run: python3 -m pip install ruff==0.17.2 "PyYAML>=6.0,<7"
- name: Validate Shell Script Syntax
run: "for script in ci/packer/scripts/*.sh ci/scripts/init-node/provisioning/seed.sh;\
\ do\n bash -n \"$script\"\ndone\nmake check\n"
- name: Setup Packer
run: 'packer version
packer plugins installed | grep qemu'
- name: Validate Packer Template
run: 'packer plugins installed | grep qemu
packer validate ci/packer/apex-debian-13.pkr.hcl
'
build-image:
name: 'Build: Golden Image'
needs: validate-scripts
runs-on: apex-debian13
outputs:
image_name: ${{ steps.vars.outputs.image_name }}
steps:
- name: Checkout repository
run: "set -euo pipefail\ncase \"$CHECKOUT_REF\" in -*) exit 1;; esac\nGITHUB_SERVER_URL=${GITHUB_SERVER_URL%/}\n\
case \"$GITHUB_SERVER_URL\" in https://git.ermnvldmr.com) ;; *) exit 1;; esac\n\
AUTH=$(printf 'deytenit:%s' \"$CHECKOUT_TOKEN\" | base64 -w0)\nHEADER=\"http.${GITHUB_SERVER_URL}/.extraheader=AUTHORIZATION:\
\ basic $AUTH\"\ngit init \"$GITHUB_WORKSPACE\"\ncd \"$GITHUB_WORKSPACE\"\n\
git remote add origin \"$GITHUB_SERVER_URL/$GITHUB_REPOSITORY.git\"\ngit -c\
\ \"$HEADER\" fetch --tags origin '+refs/heads/*:refs/remotes/origin/*'\n\
git -c \"$HEADER\" fetch origin \"$CHECKOUT_REF\"\ngit checkout --detach FETCH_HEAD\n\
test \"$(git rev-parse HEAD)\" = \"$GITHUB_SHA\"\nif [ \"$CHECKOUT_SUBMODULES\"\
\ = recursive ]; then\n key_file=$(mktemp \"${RUNNER_TEMP:-/tmp}/hextra-key.XXXXXX\"\
)\n trap 'rm -f \"$key_file\"' EXIT\n umask 077\n printf '%s\\n'\
\ \"$HEXTRA_READ_KEY\" > \"$key_file\"\n chmod 600 \"$key_file\"\n export\
\ GIT_SSH_COMMAND=\"ssh -T -i $key_file -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes\
\ -o HostKeyAlgorithms=ssh-ed25519 -o UserKnownHostsFile=$GITHUB_WORKSPACE/.gitea/known_hosts\"\
\n git submodule update --init --recursive\nfi\nif [ \"$PERSIST_CREDENTIALS\"\
\ = true ]; then\n git config \"http.${GITHUB_SERVER_URL}/.extraheader\"\
\ \"AUTHORIZATION: basic $AUTH\"\nfi\n"
env:
CHECKOUT_REF: ${{ github.sha }}
CHECKOUT_SUBMODULES: 'false'
CHECKOUT_TOKEN: ${{ github.token }}
PERSIST_CREDENTIALS: 'false'
HEXTRA_READ_KEY: ''
- name: Compute Image Metadata
id: vars
run: 'SHORT_SHA="$(echo ${{ github.sha }} | cut -c1-7)"
IMAGE_NAME="com-ermnvldmr-apex-debian-13-amd64-${SHORT_SHA}.qcow2"
echo "image_name=${IMAGE_NAME}" >> "$GITHUB_OUTPUT"
'
- name: Install QEMU & Build Tools
run: 'sudo apt-get update
sudo apt-get install -y qemu-system-x86 qemu-utils xorriso
sudo chmod 666 /dev/kvm 2>/dev/null || true
'
- name: Setup Packer
run: 'packer version
packer plugins installed | grep qemu'
- name: Build Debian 13 Image with Packer
run: 'packer plugins installed | grep qemu
packer build -var "vm_name=${{ steps.vars.outputs.image_name }}" -var "provision_revision=${{
github.sha }}" ci/packer/apex-debian-13.pkr.hcl
'
- name: Upload Image Artifact
uses: ./.gitea/actions/upload-artifact
with:
name: golden-image
path: output-qemu/${{ steps.vars.outputs.image_name }}
retention-days: 30
upload-s3:
name: 'Upload: S3 Storage'
needs: build-image
if: inputs.upload_s3 == true
runs-on: apex-debian13
steps:
- run: "set -euo pipefail\ncase \"$CHECKOUT_REF\" in -*) exit 1;; esac\nGITHUB_SERVER_URL=${GITHUB_SERVER_URL%/}\n\
case \"$GITHUB_SERVER_URL\" in https://git.ermnvldmr.com) ;; *) exit 1;; esac\n\
AUTH=$(printf 'deytenit:%s' \"$CHECKOUT_TOKEN\" | base64 -w0)\nHEADER=\"http.${GITHUB_SERVER_URL}/.extraheader=AUTHORIZATION:\
\ basic $AUTH\"\ngit init \"$GITHUB_WORKSPACE\"\ncd \"$GITHUB_WORKSPACE\"\n\
git remote add origin \"$GITHUB_SERVER_URL/$GITHUB_REPOSITORY.git\"\ngit -c\
\ \"$HEADER\" fetch --tags origin '+refs/heads/*:refs/remotes/origin/*'\n\
git -c \"$HEADER\" fetch origin \"$CHECKOUT_REF\"\ngit checkout --detach FETCH_HEAD\n\
test \"$(git rev-parse HEAD)\" = \"$GITHUB_SHA\"\nif [ \"$CHECKOUT_SUBMODULES\"\
\ = recursive ]; then\n key_file=$(mktemp \"${RUNNER_TEMP:-/tmp}/hextra-key.XXXXXX\"\
)\n trap 'rm -f \"$key_file\"' EXIT\n umask 077\n printf '%s\\n'\
\ \"$HEXTRA_READ_KEY\" > \"$key_file\"\n chmod 600 \"$key_file\"\n export\
\ GIT_SSH_COMMAND=\"ssh -T -i $key_file -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes\
\ -o HostKeyAlgorithms=ssh-ed25519 -o UserKnownHostsFile=$GITHUB_WORKSPACE/.gitea/known_hosts\"\
\n git submodule update --init --recursive\nfi\nif [ \"$PERSIST_CREDENTIALS\"\
\ = true ]; then\n git config \"http.${GITHUB_SERVER_URL}/.extraheader\"\
\ \"AUTHORIZATION: basic $AUTH\"\nfi\n"
env:
CHECKOUT_REF: ${{ github.sha }}
CHECKOUT_SUBMODULES: 'false'
CHECKOUT_TOKEN: ${{ github.token }}
PERSIST_CREDENTIALS: 'false'
HEXTRA_READ_KEY: ''
- name: Download Image Artifact
uses: ./.gitea/actions/download-artifact
with:
name: golden-image
path: output-qemu
- name: Upload to Static S3 (/dev/images/)
env:
AWS_ACCESS_KEY_ID: ${{ secrets.STATIC_AWS_ACCESS_KEY_ID || secrets.COM_ERMNVLDMR_STATIC_AWS_ACCESS_KEY_ID
}}
AWS_SECRET_ACCESS_KEY: ${{ secrets.STATIC_AWS_SECRET_ACCESS_KEY || secrets.COM_ERMNVLDMR_STATIC_AWS_SECRET_ACCESS_KEY
}}
AWS_DEFAULT_REGION: ${{ secrets.STATIC_AWS_REGION || secrets.COM_ERMNVLDMR_STATIC_S3_REGION
|| 'auto' }}
S3_ENDPOINT: ${{ secrets.STATIC_S3_ENDPOINT_URL || secrets.COM_ERMNVLDMR_STATIC_S3_ENDPOINT_URL
}}
S3_BUCKET: ${{ secrets.STATIC_S3_BUCKET || secrets.COM_ERMNVLDMR_STATIC_S3_BUCKET
}}
IMAGE_NAME: ${{ needs.build-image.outputs.image_name }}
run: "if [ -n \"${S3_BUCKET}\" ] && [ -n \"${AWS_ACCESS_KEY_ID}\" ]; then\n\
\ ENDPOINT_FLAG=\"\"\n if [ -n \"${S3_ENDPOINT}\" ]; then\n ENDPOINT_FLAG=\"\
--endpoint-url=${S3_ENDPOINT}\"\n fi\n echo \"Cleaning up older builds in\
\ dev/images/com-ermnvldmr-apex-debian-13-amd64-*.qcow2 to maintain quota...\"\
\n aws ${ENDPOINT_FLAG} s3 rm \"s3://${S3_BUCKET}/dev/images/\" --recursive\
\ --exclude \"*\" --include \"com-ermnvldmr-apex-debian-13-amd64-*.qcow2\"\
\ || true\n echo \"Uploading ${IMAGE_NAME}...\"\n aws ${ENDPOINT_FLAG} s3\
\ cp \"output-qemu/${IMAGE_NAME}\" \"s3://${S3_BUCKET}/dev/images/${IMAGE_NAME}\"\
\n echo \"Golden image uploaded successfully to s3://${S3_BUCKET}/dev/images/${IMAGE_NAME}\"\
\nelse\n echo \"S3 credentials not configured; skipping upload. Image artifact\
\ preserved in GitHub Actions run.\"\nfi\n"
@@ -1,116 +0,0 @@
name: ci/packer - main/manual-build-golden-image
on:
workflow_dispatch:
inputs:
upload_s3:
description: "Upload built image to specified static S3 Object Storage"
type: boolean
default: true
concurrency:
group: ci-golden-image
cancel-in-progress: false
permissions:
contents: read
jobs:
validate-scripts:
name: "Validate: Provisioner Scripts"
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Validate Shell Script Syntax
run: |
for script in ci/packer/scripts/*.sh engine/provisioning/seed.sh; do
bash -n "$script"
done
bash tests/run.sh
- name: Setup Packer
uses: hashicorp/setup-packer@main
with:
version: "latest"
- name: Validate Packer Template
run: |
packer init ci/packer/apex-debian-13.pkr.hcl
packer validate ci/packer/apex-debian-13.pkr.hcl
build-image:
name: "Build: Golden Image"
needs: validate-scripts
runs-on: ubuntu-24.04
outputs:
image_name: ${{ steps.vars.outputs.image_name }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Compute Image Metadata
id: vars
run: |
SHORT_SHA="$(echo ${{ github.sha }} | cut -c1-7)"
IMAGE_NAME="com-ermnvldmr-apex-debian-13-amd64-${SHORT_SHA}.qcow2"
echo "image_name=${IMAGE_NAME}" >> "$GITHUB_OUTPUT"
- name: Install QEMU & Build Tools
run: |
sudo apt-get update
sudo apt-get install -y qemu-system-x86 qemu-utils xorriso
sudo chmod 666 /dev/kvm 2>/dev/null || true
- name: Setup Packer
uses: hashicorp/setup-packer@main
with:
version: "latest"
- name: Build Debian 13 Image with Packer
run: |
packer init ci/packer/apex-debian-13.pkr.hcl
packer build -var "vm_name=${{ steps.vars.outputs.image_name }}" -var "provision_revision=${{ github.sha }}" ci/packer/apex-debian-13.pkr.hcl
- name: Upload Image Artifact
uses: actions/upload-artifact@v4
with:
name: golden-image
path: output-qemu/${{ steps.vars.outputs.image_name }}
retention-days: 2
upload-s3:
name: "Upload: S3 Storage"
needs: build-image
if: inputs.upload_s3 == true
runs-on: ubuntu-latest
steps:
- name: Download Image Artifact
uses: actions/download-artifact@v4
with:
name: golden-image
path: output-qemu
- name: Upload to Static S3 (/dev/images/)
env:
AWS_ACCESS_KEY_ID: ${{ secrets.STATIC_AWS_ACCESS_KEY_ID || secrets.COM_ERMNVLDMR_STATIC_AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.STATIC_AWS_SECRET_ACCESS_KEY || secrets.COM_ERMNVLDMR_STATIC_AWS_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: ${{ secrets.STATIC_AWS_REGION || secrets.COM_ERMNVLDMR_STATIC_S3_REGION || 'us-east-1' }}
S3_ENDPOINT: ${{ secrets.STATIC_S3_ENDPOINT_URL || secrets.COM_ERMNVLDMR_STATIC_S3_ENDPOINT_URL }}
S3_BUCKET: ${{ secrets.STATIC_S3_BUCKET || secrets.COM_ERMNVLDMR_STATIC_S3_BUCKET }}
IMAGE_NAME: ${{ needs.build-image.outputs.image_name }}
run: |
if [ -n "${S3_BUCKET}" ] && [ -n "${AWS_ACCESS_KEY_ID}" ]; then
ENDPOINT_FLAG=""
if [ -n "${S3_ENDPOINT}" ]; then
ENDPOINT_FLAG="--endpoint-url=${S3_ENDPOINT}"
fi
echo "Cleaning up older builds in dev/images/com-ermnvldmr-apex-debian-13-amd64-*.qcow2 to maintain quota..."
aws ${ENDPOINT_FLAG} s3 rm "s3://${S3_BUCKET}/dev/images/" --recursive --exclude "*" --include "com-ermnvldmr-apex-debian-13-amd64-*.qcow2" || true
echo "Uploading ${IMAGE_NAME}..."
aws ${ENDPOINT_FLAG} s3 cp "output-qemu/${IMAGE_NAME}" "s3://${S3_BUCKET}/dev/images/${IMAGE_NAME}"
echo "Golden image uploaded successfully to s3://${S3_BUCKET}/dev/images/${IMAGE_NAME}"
else
echo "S3 credentials not configured; skipping upload. Image artifact preserved in GitHub Actions run."
fi
+3
View File
@@ -9,9 +9,12 @@
__pycache__/
*.pyc
.pytest_cache/
.venv/
# Runtime / secrets (never in commons)
*.log
*.env
.env
*.bak
._*
.DS_Store
+2 -2
View File
@@ -1,13 +1,13 @@
.PHONY: test lint fmt check
test:
python3 -m unittest discover -s tests
bash tests/run.sh
lint:
@if command -v ruff >/dev/null 2>&1; then \
ruff check .; \
else \
python3 -m py_compile engine/**/*.py actions/**/*.py tests/*.py; \
python3 -m compileall -q engine actions tests ci/scripts && \
echo "ruff not installed in PATH; verified python bytecode compilation."; \
fi
+68 -20
View File
@@ -4,25 +4,25 @@
# [apex.ermnvldmr.com](https://apex.ermnvldmr.com)
The boring, standard-library fleet operations framework powering self-hosted infrastructure across independent nodes.
Local infrastructure operations for independently owned nodes, with a Python standard-library core.
## Quick Start
- **Live Documentation**: <https://docs.ermnvldmr.com/en/apex/> (also available in [Russian](https://docs.ermnvldmr.com/ru/apex/))
- **Getting Started Guide**: <https://docs.ermnvldmr.com/en/apex/getting-started/>
- **Report an Issue**: <https://github.com/deytenit/apex.ermnvldmr.com/issues>
- **Report an Issue**: <https://git.ermnvldmr.com/deytenit/apex.ermnvldmr.com/issues>
- **License**: [BSD-2-Clause](#copyright--license)
```sh
# Clone your node's repository (which pins apex as commons/)
git clone --recurse-submodules git@github.com:you/selfhost.example.com.git
git clone --recurse-submodules ssh://git@git.example.com:22222/infrastructure/selfhost.example.com.git
cd selfhost.example.com && ./init.sh && source ~/.bashrc
# Configure node identity
cat > node.env <<'EOF'
APEX_NODE_FQDN=selfhost.example.com
APEX_NODE_HOST=selfhost.example.com
APEX_SUBNET=198.18.16.0/24
APEX_SUBNET=198.18.0.0/24
EOF
# Configure host baseline and bring up compositions
@@ -37,7 +37,7 @@ You should understand the following invariants before exploring the codebase:
### Core Principles
- **No Control Plane**: No central orchestrator, agent daemon, or cluster controller. Commands act locally on the node you run them on.
- **Zero Build Step & Stdlib-Only**: Pure Python 3 standard library and Git. No `pip` dependencies, Node, Go, or code-generation steps.
- **Core Runtime**: Python 3.10 or newer and Git, with no `pip` dependencies or build step. Standalone host provisioning and workstation checks have separate requirements below.
- **Node-Owned Repositories**: Each machine is an independent Git repository pinning `apex` as a `commons/` submodule. It declares its own identity, compositions, and local overrides.
- **Overlay Architecture**: Node repositories can extend or override any shared action by placing an executable in `proprietaries/actions/`.
@@ -46,8 +46,8 @@ You should understand the following invariants before exploring the codebase:
- **[`actions/`](./actions)**: Built-in engine actions (`compose.py`, `configure/`, `sync/`, `tiers/`, `backup/`, `utils/`).
- **[`compositions/`](./compositions)**: The shared core Compose stack (`traefik`, `socket-proxy`, `alloy`, `adguard`, `xray`, `restic`).
- **[`engine/`](./engine)**: Framework engine — action discovery, overlay resolution, context injection, and logging.
- **[`etc/`](./etc)**: Static service configurations, daemon templates, and helper scripts (Alloy configs, Happ generator).
- **[`ci/`](./ci)**: Host image build recipes (Packer for Debian 13) and cloud-init bootstrap templates.
- **[`etc/`](./etc)**: Shared static configuration, including Alloy.
- **[`ci/`](./ci)**: Host image build recipes, cloud-init templates, and image enrollment tooling.
- **[`assets/`](./assets)**: Repository branding assets (`logo.png`).
---
@@ -63,34 +63,82 @@ Running and maintaining services on an existing host.
- Update services: `apex compose pull && apex compose up`
- Run backups: `apex backup/run`
- Sync repository state: `apex sync/repository`
- **Replication**: For multi-node high availability patterns and contracts, consult the [Composition Replication Documentation](https://docs.ermnvldmr.com/en/apex/concepts/replication/).
Target a composition explicitly when operating on one application:
```sh
apex compose -p apex restart traefik
apex compose -p app up -d
apex compose --dry-run -p apex logs --tail 20 xray
```
Wrapper options precede the action; remaining arguments go to Docker Compose.
The positional forms `apex compose restart apex traefik` and `apex compose up -d apex`
remain supported. Use `-p` when service names or option values could be mistaken
for composition names. Without a target, APEX visits core first, then other
compositions alphabetically; `down` reverses that order.
Replace `app` with a composition defined in your repository. Use targeted `up -d`
after changing a container definition; `restart` retains the existing definition.
For direct Docker Compose use in the core composition:
```sh
apex env --sync
cd compositions/apex
docker compose config --quiet
docker compose restart traefik
```
Sync preserves existing `.env` credentials and UID/GID entries, including tier
symlinks. It updates a managed block containing `apex.env` settings (including
profiles) followed by synthesized node/path values, and writes with mode `0600`.
Resync after changing `apex.env`, node identity, or tier paths. Shell environment
values still take precedence in Docker Compose. Do not edit the generated block.
`eval "$(apex env)"` exports synthesized APEX values to the current shell;
`apex env --dotenv` prints dotenv syntax, and `apex env --write generated.env`
writes those values to a separate file. These three modes do not merge the
composition's secrets or `apex.env`; use `--sync` for the complete core bridge.
Choose one output mode per invocation.
### Infrastructure Administrator
Bootstrapping clean Debian 13 hosts on bare metal or cloud providers without cloud-init support.
Build Debian 13 amd64 golden images with Packer, then supply node identity,
operator keys, and the node repository through
[`ci/cloud-init/user-data.template.yaml`](./ci/cloud-init/user-data.template.yaml)
at first boot. Keep filled user-data and repository credentials outside Git.
- **Initialization Tool**: A standalone workstation bootstrap tool targets clean Debian 13 amd64 installations using the same baseline as Packer.
- **Template**: Copy and fill out [`ci/cloud-init/user-data.template.yaml`](./ci/cloud-init/user-data.template.yaml) with target identity, operator keys, and node repository.
- **Run Enrollment**:
```sh
python3 ci/scripts/init-node.py root@server --user-data /private/path/location.user-data.yaml
```
- **Security Handover**: Safely switches SSH to key-only authentication on port 2222 (`adam@server`) protected by an automated rollback watchdog, activates UFW firewall rules, Docker iptables integration, and CrowdSec.
- **Resuming**: Post-enrollment commands connect via `adam@server --port 2222`.
```sh
packer init ci/packer/apex-debian-13.pkr.hcl
packer validate ci/packer/apex-debian-13.pkr.hcl
packer build ci/packer/apex-debian-13.pkr.hcl
```
Cloud-init runs the image's bundled enrollment helper. After enrollment, run
`apex configure` and `apex compose up` on the node.
### Developer & Contributor
Adding shared actions or improving the engine.
- **Conventions**: Maintain strict Python standard library compliance. No external runtime packages.
- **Testing**: Run test suite via `python3 -m unittest discover -s tests`.
- **Linting**: Keep code formatted and clean according to PEP 8 standards.
- **Public documentation**: Describe supported interfaces using synthetic examples. Keep deployment inventories, internal plans, operational runbooks, and host-specific configuration in their owning private repositories.
- **Setup**:
```sh
python3 -m venv .venv
. .venv/bin/activate
python3 -m pip install ruff==0.17.2 'PyYAML>=6.0,<7'
```
- **Verification**: `make check` runs lint and both the core and image provisioning suites. `make test` (or `bash tests/run.sh`) runs both suites; discovery limited to `tests/` omits provisioning. PyYAML is needed for the provisioning tests, not core execution. Environment integration tests use `docker compose` or standalone `docker-compose` with `config`; no daemon is needed.
- **Linting**: Ruff checks correctness and security rules. Existing import-order, line-length, compact-statement, and annotation-style debt is explicitly deferred in `pyproject.toml` to avoid unrelated formatting changes. Without Ruff, `make lint` recursively checks Python compilation and fails on syntax errors; that fallback is not equivalent to Ruff. `make fmt` formats files and is a separate, deliberate operation.
- **Packer**: Run `packer init ci/packer/apex-debian-13.pkr.hcl` and `packer validate ci/packer/apex-debian-13.pkr.hcl` for template validation. A VM build or first-boot enrollment is a separate operation; unit tests and template validation do not demonstrate a deployed host.
---
## Communication & Support
- **Issues**: <https://github.com/deytenit/apex.ermnvldmr.com/issues>
- **Issues**: <https://git.ermnvldmr.com/deytenit/apex.ermnvldmr.com/issues>
- **Email**: <personal@ermnvldmr.com>
## Copyright & License
+49 -4
View File
@@ -1,5 +1,5 @@
# actions/backup/run.py
"""Label-driven restic backup via resticontainer (github.com/deytenit/resticontainer).
"""Label-driven restic backup via resticontainer (git.ermnvldmr.com/deytenit/resticontainer).
Runs the resticontainer core service one-shot: init-if-needed -> backup (discovers
`restic.*`-labelled services, runs their pre-hooks/stops, snapshots the union of the
resolved host paths in a single restic run) -> forget --prune -> Telegram. Every
@@ -7,12 +7,44 @@ non-`backup` subcommand passes straight through to restic, so init/snapshots/for
behave exactly as before."""
import json
import os
from pathlib import Path
import time
from engine.descriptor import Meta, Arg
METADATA = Meta(summary="Run the resticontainer label-driven backup (init-if-needed, backup, forget --prune).",
args=[Arg("telegram_bot_url", "Telegram Bot URL for notifications")])
TITLE = "Restic Host Backup"
def write_backup_status(path, state, timestamp):
path = Path(path)
previous = json.loads(path.read_text()) if path.exists() else {}
status = {"state": state, "updated_at": timestamp,
"last_success": timestamp if state == "success" else previous.get("last_success", 0)}
path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_name(path.name + f".{os.getpid()}.tmp")
temporary.write_text(json.dumps(status) + "\n")
temporary.chmod(0o644)
temporary.replace(path)
def restore_containers(ctx, container_ids):
"""Recover previously running backup targets, retrying namespace dependents."""
pending = list(container_ids)
if not pending:
return
for _ in range(len(pending) + 1):
cp = ctx.sys.run(["docker", "inspect", *pending], capture=True)
stopped = [c["Id"] for c in json.loads(cp.stdout) if not c["State"]["Running"]]
if not stopped:
return
progress = False
for container_id in stopped:
cp = ctx.sys.run(["docker", "start", container_id], check=False, capture=True)
progress = progress or cp.returncode == 0
if not progress:
raise RuntimeError("Backup left containers stopped: " + ", ".join(stopped))
pending = stopped
raise RuntimeError("Backup containers did not stay running: " + ", ".join(pending))
def run(ctx, args):
log, node, url = ctx.log, ctx.node.name, args.telegram_bot_url
core = ctx.paths.core
@@ -42,6 +74,8 @@ def run(ctx, args):
except (ValueError, TypeError):
return None
status_path = Path("/var/lib/apex/backup-status.json")
write_backup_status(status_path, "running", time.time())
try:
log.info("Checking restic repository availability...")
if restic("snapshots", check=False).returncode == 0:
@@ -50,8 +84,16 @@ def run(ctx, args):
ctx.notify.error(TITLE, url, "restic init failed"); raise SystemExit(1)
before = host_snapshot_ids()
log.info("Backing up `restic.*`-labelled services (resticontainer discovers paths)...")
if restic("backup", "--host", host, "--tag", tag, "--compression", "max",
check=False).returncode != 0:
targets = ctx.sys.run(["docker", "ps", "-q", "--no-trunc",
"--filter", "label=restic.enable=true",
"--filter", "label=restic.backup.stop=true"], capture=True).stdout.split()
try:
result = restic("backup", "--host", host, "--tag", tag, "--compression", "max",
check=False)
finally:
# Resticontainer may start a network-namespace dependent before its owner.
restore_containers(ctx, targets)
if result.returncode != 0:
ctx.notify.error(TITLE, url, "restic backup failed"); raise SystemExit(1)
# Guard: the label-driven backup is a silent no-op if no service carries the
# `restic.enable` label (resticontainer finds zero paths and skips restic).
@@ -70,9 +112,12 @@ def run(ctx, args):
log.success("Restic backup completed.")
if not ctx.notify.success(TITLE, url, f"Restic backup + retention succeeded for {node}"):
raise SystemExit(1) # bash exited 1 when the success telegram failed to send
write_backup_status(status_path, "success", time.time())
except SystemExit:
write_backup_status(status_path, "failed", time.time())
raise
except Exception as e:
write_backup_status(status_path, "failed", time.time())
log.error(f"{type(e).__name__}: {e}")
ctx.notify.error(TITLE, url, f"backup failed: {e}")
raise SystemExit(1)
raise SystemExit(1) from e
+35 -42
View File
@@ -2,40 +2,39 @@
"""Orchestrate docker compose across the node.
Supports targeting all compositions in sequence (default: apex core first, services next;
reverse on down) or targeting a specific composition (e.g. `apex compose restart apex`
or `apex compose up -d sso`). Exports derived APEX_* vars and layers --env-file (.env secrets + apex.env).
reverse on down) or targeting one with `apex compose -p apex restart traefik`.
Wrapper options precede the action. Exports derived APEX_* vars and layers env files.
"""
import os
from engine.descriptor import Arg, Flag, Meta, Opt, Rest
import shlex
from engine.descriptor import Flag, Meta, Opt, Rest
METADATA = Meta(
summary="Orchestrate docker compose across the node or on a specific composition.",
args=[
Opt("-p", "Target composition name or 'all'.", default=None),
Flag("--dry-run", "Print commands only."),
Arg("action", "up | down | restart | ps | logs | ..."),
Rest("extra", "Extra args (target composition name, service names, or docker compose flags)."),
Rest("command", "ACTION [COMPOSE_ARGS...]; wrapper options must precede ACTION."),
],
)
def _clean_xray_sockets(ctx):
"""Unlink stale host-side Xray sockets in /dev/shm to prevent REALITY panic crash loops."""
shm = os.path.join(ctx.paths.stier3, "apex", "xray", "shm")
if os.path.isdir(shm):
for f in os.listdir(shm):
if f.endswith(".sock"):
try:
os.unlink(os.path.join(shm, f))
ctx.log.info(f"Cleaned stale Xray socket: {f}")
except OSError as e:
ctx.log.warn(f"Failed to unlink {f}: {e}")
def run(ctx, args):
log, comp, action = ctx.log, ctx.paths.compositions, args.action
extra = [a for a in (args.extra or []) if a != "--dry-run"]
dry_run = args.dry_run or len(extra) != len(args.extra or [])
log, comp = ctx.log, ctx.paths.compositions
command = list(args.command)
if command and command[0] == "--":
command.pop(0)
if not command:
log.error("ACTION is required (up, down, restart, logs, ...).")
raise SystemExit(64)
action, extra = command[0], command[1:]
dry_run = args.dry_run
before_separator = extra[:extra.index("--")] if "--" in extra else extra
if any(arg in ("-p", "--dry-run") or arg.startswith("-p=")
or arg.startswith("--dry-run=") for arg in before_separator):
log.error("Wrapper options -p and --dry-run must precede ACTION; use -- for passthrough.")
raise SystemExit(64)
env = dict(os.environ)
env.update({k: v for k, v in ctx.vars().items() if k.startswith("APEX_")})
@@ -49,31 +48,25 @@ def run(ctx, args):
]
target = getattr(args, "p", None)
if not target and extra:
first = extra[0]
if first == "all":
target = "all"
extra = extra[1:]
elif first in available:
target = first
extra = extra[1:]
elif not first.startswith("-"):
target = first
extra = extra[1:]
elif extra[-1] in available and len(extra) > 1:
target = extra[-1]
extra = extra[:-1]
if target is None and extra:
if extra[0] in ["all", *available]:
target, extra = extra[0], extra[1:]
elif action == "up" and len(extra) >= 2 and extra[0] == "-d":
# Preserve the documented `up -d apex` spelling without scanning flag values.
if extra[1] in ["all", *available]:
if len(extra) != 2:
log.error("Use -p COMPOSITION before up when adding service arguments.")
raise SystemExit(64)
target, extra = extra[1], ["-d"]
if not target:
if target is None:
target = "all"
if target != "all" and target not in available:
log.error(f"Unknown composition '{target}'. Available: {', '.join(sorted(available)) if available else 'none'}")
names = ", ".join(sorted(available)) if available else "none"
log.error(f"Unknown composition '{target}'. Available: {names}")
raise SystemExit(1)
if action in ("up", "restart", "start") and target in ("all", "apex") and not dry_run:
_clean_xray_sockets(ctx)
def sequence():
if target != "all":
return [os.path.join(comp, target)]
@@ -89,13 +82,13 @@ def run(ctx, args):
if os.path.isfile(os.path.join(dirpath, ef)):
base += ["--env-file", ef]
if action == "up":
up_flags = [] if "-d" in extra else ["-d"]
up_flags = [] if any(flag in extra for flag in ("-d", "--detach")) else ["-d"]
cmd = base + ["up"] + up_flags + extra
else:
cmd = base + [action] + extra
if dry_run:
log.info(f"[DRY-RUN] (cd {dirpath} && {' '.join(cmd)})")
log.info(f"[DRY-RUN] (cd {shlex.quote(dirpath)} && {shlex.join(cmd)})")
else:
log.info(f"{action} {name}...")
ctx.sys.run(cmd, cwd=dirpath, env=env, check=True)
+2 -2
View File
@@ -1,5 +1,5 @@
# actions/configure.py
"""Umbrella: run every configure/* action in a stable order (SP2 §3.1)."""
"""Run every configure/* action in a stable order."""
import os
from engine.descriptor import Meta, Flag, build_parser
from engine import overlay
@@ -21,7 +21,7 @@ def run(ctx, args):
mod = overlay.load_module(r.path, "apex_umbrella_" + name.replace("/", "_"))
if getattr(mod, "DISABLED", None):
ctx.log.warn(f"Skipping disabled {name}"); continue
meta = getattr(mod, "METADATA")
meta = mod.METADATA
has_dry = any(getattr(a, "name", None) == "--dry-run" for a in meta.args)
argv = ["--dry-run"] if (args.dry_run and has_dry) else []
sub_args = build_parser(meta, f"apex {name}").parse_args(argv)
+77
View File
@@ -0,0 +1,77 @@
"""Deploy the opt-in native Alloy service using shared host/guest configs."""
import json
from pathlib import Path
import re
import tempfile
from engine.descriptor import Flag, Meta
METADATA = Meta(summary="Configure native Alloy from configs/alloy/config.json.",
args=[Flag("--dry-run", "Validate the deployment plan without changes.")])
def deployment(ctx):
settings = Path(ctx.paths.configs) / "alloy/config.json"
if not settings.exists():
return None
values = json.loads(settings.read_text())
role = values.get("role")
if role not in ("host", "guest"):
raise ValueError("Alloy role must be host or guest")
env_file = values.get("environment_file", "")
if not re.fullmatch(r"/[A-Za-z0-9_./-]+", env_file):
raise ValueError("Alloy environment_file must be an absolute path without whitespace")
source = Path(ctx.paths.commons) / "etc/alloy" / (
"native-host.alloy" if role == "host" else "native.alloy")
config = source.read_text()
extra = Path(ctx.paths.configs) / "alloy/extra.alloy"
if extra.is_file():
config += "\n" + extra.read_text()
unit = ("[Service]\nEnvironmentFile=" + env_file + "\n"
'ExecStart=\nExecStart=/usr/bin/alloy run --storage.path=/var/lib/alloy --server.http.listen-addr=127.0.0.1:12346 /etc/alloy/config.alloy\n'
+ ("User=root\nGroup=root\n" if role == "guest" else "") +
"SupplementaryGroups=systemd-journal" + (" docker" if role == "guest" else "") + "\n")
return config, unit, env_file
def run(ctx, args):
plan = deployment(ctx)
if plan is None:
ctx.log.info("Native Alloy is not configured for this node; skipping.")
return
config, unit, env_file = plan
if args.dry_run:
ctx.log.info("Native Alloy deployment plan validated; no changes made.")
return
ctx.sys.sudo(["test", "-f", env_file])
# Validate with the actual service environment without printing credentials.
validator = '''import os, stat, subprocess, sys
from pathlib import Path
# Environment files deliberately use plain KEY=value, with no shell evaluation.
secret = Path(sys.argv[1])
info = secret.stat()
if info.st_uid != 0 or stat.S_IMODE(info.st_mode) & 0o077:
raise RuntimeError('Alloy environment file must be root-owned with mode 0600 or stricter')
for line in secret.read_text().splitlines():
if not line.strip() or line.lstrip().startswith('#'): continue
key, value = line.split('=', 1)
os.environ[key] = value
subprocess.run(['alloy', 'validate', sys.argv[2]], check=True)
'''
with tempfile.TemporaryDirectory(prefix="apex-alloy-") as directory:
config_path = Path(directory) / "config.alloy"
unit_path = Path(directory) / "apex.conf"
config_path.write_text(config)
unit_path.write_text(unit)
ctx.sys.sudo(["python3", "-c", validator, env_file, str(config_path)])
ctx.sys.sudo(["install", "-d", "-m", "0755", "/etc/alloy",
"/etc/systemd/system/alloy.service.d"])
ctx.sys.sudo(["install", "-m", "0644", str(config_path), "/etc/alloy/config.alloy"])
ctx.sys.sudo(["install", "-m", "0644", str(unit_path),
"/etc/systemd/system/alloy.service.d/apex.conf"])
ctx.sys.sudo(["systemctl", "unmask", "alloy.service"])
ctx.sys.daemon_reload()
ctx.sys.sudo(["systemctl", "enable", "alloy.service"])
ctx.sys.sudo(["systemctl", "restart", "alloy.service"])
ctx.sys.sudo(["systemctl", "is-active", "--quiet", "alloy.service"])
ctx.log.success("Configured native Alloy.")
+78 -36
View File
@@ -1,13 +1,85 @@
# actions/configure/base.py
"""Port of configure/base — base packages + ufw + ufw-docker helper."""
import hashlib
import os
import shutil
import tempfile
from pathlib import Path
import re
import stat
import urllib.request
from engine.descriptor import Meta
METADATA = Meta(summary="Configure base system packages (curl, wget, git, rsyslog, ufw, ufw-docker).")
UFW_DOCKER_URL = "https://github.com/chaifeng/ufw-docker/raw/master/ufw-docker"
UFW_DOCKER_REVISION = "020a8699f95592561f254d8d4ad1bb40d401dfc7"
UFW_DOCKER_SHA256 = "643e56b080567c567b4aa28650196849b2a2da5dd0473fd3e5216b0886035ab0"
UFW_DOCKER_URL = (
"https://raw.githubusercontent.com/chaifeng/ufw-docker/"
+ UFW_DOCKER_REVISION + "/ufw-docker"
)
UFW_DOCKER_PATH = Path("/usr/local/bin/ufw-docker")
BASE_PACKAGES = ("curl", "wget", "git", "rsyslog", "ufw")
def _install_missing(s, packages):
missing = []
for package in packages:
result = s.run(["dpkg-query", "-W", "-f=${Status}", package],
check=False, capture=True)
if result.returncode or result.stdout.split()[1:] != ["ok", "installed"]:
missing.append(package)
if not missing:
return
apt = ["env", "LC_ALL=C", "DEBIAN_FRONTEND=noninteractive", "apt-get",
"-o", "DPkg::Lock::Timeout=60", "-o", "Acquire::Retries=2",
"-o", "Acquire::http::Timeout=30", "-o", "Acquire::https::Timeout=30"]
s.sudo([*apt, "update", "--error-on=any"])
command = [*apt, "install", "-y", "--no-install-recommends", "--no-upgrade",
"--no-remove", *missing]
plan = s.sudo([*command, "--simulate"], capture=True)
if any(line.startswith("Remv ") or re.match(r"^Inst \S+ \[", line)
for line in plan.stdout.splitlines()):
raise RuntimeError("Missing packages require upgrades/removals; reconcile packages first.")
s.sudo(command)
def _check_helper_path(path):
for parent in reversed(path.parents):
info = parent.lstat()
if (not stat.S_ISDIR(info.st_mode) or info.st_uid != 0 or
info.st_mode & 0o022):
raise RuntimeError(f"Unsafe ufw-docker ancestor: {parent}")
try:
info = path.lstat()
except FileNotFoundError:
return False
if (not stat.S_ISREG(info.st_mode) or info.st_uid != 0 or info.st_gid != 0 or
info.st_mode & 0o022 or info.st_nlink != 1):
raise RuntimeError(f"Unsafe ufw-docker target: {path}")
return True
def _install_helper(ctx):
path = UFW_DOCKER_PATH
exists = _check_helper_path(path)
payload = path.read_bytes() if exists else None
if payload is None or hashlib.sha256(payload).hexdigest() != UFW_DOCKER_SHA256:
ctx.log.info(f"Downloading pinned ufw-docker helper to {path}...")
# The immutable HTTPS URL is authenticated by the digest below.
with urllib.request.urlopen(UFW_DOCKER_URL, timeout=30) as response: # noqa: S310
payload = response.read(1024 * 1024 + 1)
if len(payload) > 1024 * 1024 or hashlib.sha256(payload).hexdigest() != UFW_DOCKER_SHA256:
raise RuntimeError("ufw-docker checksum mismatch; refusing installation.")
_check_helper_path(path)
ctx.host.write_file(str(path), payload.decode("utf-8"), mode="0755",
backup=False, sudo=True)
elif stat.S_IMODE(path.stat().st_mode) != 0o755:
ctx.host.write_file(str(path), payload.decode("utf-8"), mode="0755",
backup=False, sudo=True)
if (not _check_helper_path(path) or
hashlib.sha256(path.read_bytes()).hexdigest() != UFW_DOCKER_SHA256 or
stat.S_IMODE(path.stat().st_mode) != 0o755):
raise RuntimeError("ufw-docker verification failed; refusing execution.")
ctx.log.info("Ensuring ufw-docker routing rules are installed in UFW...")
ctx.sys.sudo([str(path), "install"])
def run(ctx, args):
@@ -16,14 +88,8 @@ def run(ctx, args):
if not os.path.isfile("/etc/debian_version"):
log.error("Requires a Debian-based system (Debian/Ubuntu).")
raise SystemExit(1)
needed = [p for p in ["curl", "wget", "git", "rsyslog"] if not shutil.which(p)]
if needed:
log.info("Updating package lists...")
s.sudo(["apt-get", "update", "-y"])
log.info(f"Installing missing essential packages: {', '.join(needed)}...")
s.sudo(["apt-get", "install", "-y", "-o", "DPkg::Lock::Timeout=60", *needed])
else:
log.info("Essential packages already installed.")
_check_helper_path(UFW_DOCKER_PATH)
_install_missing(s, BASE_PACKAGES)
s.ensure_running("rsyslog")
# Configure APT to discard downloaded archives
@@ -61,29 +127,5 @@ def run(ctx, args):
s.sudo(["tee", journal_conf], input=journal_content)
s.sudo(["systemctl", "restart", "systemd-journald"])
if not shutil.which("ufw"):
log.info("Installing UFW...")
s.sudo(["apt-get", "install", "-y", "-o", "DPkg::Lock::Timeout=60", "ufw"])
else:
log.info("UFW already installed.")
target_bin = "/usr/local/bin/ufw-docker"
if not (os.path.isfile(target_bin) and os.access(target_bin, os.X_OK)):
log.info("Downloading ufw-docker helper to /usr/local/bin/ufw-docker...")
with tempfile.NamedTemporaryFile(delete=False) as tf:
tmp_name = tf.name
try:
urllib.request.urlretrieve(UFW_DOCKER_URL, tmp_name)
s.sudo(["cp", tmp_name, target_bin])
s.sudo(["chown", "root:root", target_bin])
s.sudo(["chmod", "0755", target_bin])
log.success(f"ufw-docker installed to {target_bin}.")
finally:
if os.path.exists(tmp_name):
os.unlink(tmp_name)
else:
log.info(f"ufw-docker already exists at {target_bin}.")
log.info("Ensuring ufw-docker routing rules are installed in UFW...")
s.sudo([target_bin, "install"])
_install_helper(ctx)
log.success("Base system initialization completed.")
+1 -1
View File
@@ -18,7 +18,7 @@ def run(ctx, args):
ZoneInfo(zone)
except (ValueError, ZoneInfoNotFoundError):
ctx.log.error("configs/timezone must name an installed IANA timezone.")
raise SystemExit(1)
raise SystemExit(1) from None
current = ctx.sys.run(["timedatectl", "show", "--property=Timezone", "--value"], capture=True).stdout.strip()
if current == zone:
ctx.log.info(f"Timezone already set to {zone}.")
+66 -38
View File
@@ -1,54 +1,82 @@
# actions/env.py
"""Export or synchronize synthesized APEX_* environment variables.
Outputs shell export statements (for `eval $(apex env)`) or writes a .env file
for direct compatibility with bare `docker compose` commands.
"""
"""Export synthesized APEX values or bridge a composition's environment for Compose."""
import os
import shlex
from engine.descriptor import Flag, Meta, Opt
METADATA = Meta(
summary="Export or sync synthesized APEX_* variables for shell or docker compose.",
args=[
Flag("--export", "Format as shell export statements (default)."),
Flag("--dotenv", "Format as raw KEY=VAL lines without 'export'."),
Opt("--write", "Write variables to the specified file path.", default=None),
Flag("--sync", "Sync variables into compositions/apex/.env for bare docker compose."),
Flag("--dotenv", "Print quoted generated APEX_* dotenv values only."),
Opt("--write", "Write a standalone generated dotenv file (0600).", default=None),
Flag("--sync", "Preserve user .env text and refresh the core Compose managed block."),
],
)
_START = "# BEGIN APEX MANAGED ENV"
_END = "# END APEX MANAGED ENV"
def _dotenv_value(value):
"""Quote a literal using Compose dotenv escapes, including interpolation dollars."""
value = str(value)
for old, new in (("\\", "\\\\"), ('"', '\\"'), ("$", "$$"),
("\r", "\\r"), ("\n", "\\n"), ("\t", "\\t")):
value = value.replace(old, new)
return f'"{value}"'
def _read_optional(path):
try:
with open(path, encoding="utf-8", newline="") as source:
return source.read()
except FileNotFoundError:
return ""
def _sync_body(existing, public, generated):
if any(marker in public or marker in generated for marker in (_START, _END)):
raise ValueError("Managed environment marker collides with source content.")
lines = existing.splitlines(keepends=True)
markers = [(index, line.rstrip("\r\n")) for index, line in enumerate(lines)
if _START in line or _END in line]
if markers:
if (len(markers) != 2 or markers[0][1] != _START or markers[1][1] != _END):
raise ValueError("Malformed or duplicate managed environment markers; .env unchanged.")
start, end = markers[0][0], markers[1][0]
existing = "".join(lines[:start] + lines[end + 1:])
# Keep user bytes in order, but place managed values last to preserve precedence.
separator = "" if not existing or existing.endswith("\n") else "\n"
public_separator = "" if not public or public.endswith("\n") else "\n"
return (existing + separator + _START + "\n" + public + public_separator
+ generated + _END + "\n")
def run(ctx, args):
log = ctx.log
vars_dict = {k: v for k, v in ctx.vars().items() if k.startswith("APEX_")}
target_file = None
modes = (args.export, args.dotenv, args.write is not None, args.sync)
if sum(bool(mode) for mode in modes) > 1:
ctx.log.error("Choose only one of --export, --dotenv, --write, or --sync.")
raise SystemExit(64)
variables = {k: v for k, v in ctx.vars().items() if k.startswith("APEX_")}
generated = "".join(f"{key}={_dotenv_value(value)}\n"
for key, value in sorted(variables.items()))
target = args.write
if args.sync:
target_file = os.path.join(ctx.paths.compositions, "apex", ".env")
elif args.write:
target_file = args.write
if target_file:
content = [
"# Generated by apex env. Do not edit directly.",
"# Contains synthesized node and path scalars for docker compose interpolation.",
]
for k in sorted(vars_dict.keys()):
content.append(f"{k}={vars_dict[k]}")
content.append("")
body = "\n".join(content)
os.makedirs(os.path.dirname(os.path.abspath(target_file)), exist_ok=True)
ctx.host.write_file(target_file, body, mode="0600")
log.success(f"Synced {len(vars_dict)} variables to {target_file}.")
return 0
if args.dotenv:
for k in sorted(vars_dict.keys()):
print(f"{k}={vars_dict[k]}")
core = os.path.join(ctx.paths.compositions, "apex")
target = os.path.join(core, ".env")
try:
generated = _sync_body(_read_optional(target),
_read_optional(os.path.join(core, "apex.env")), generated)
except ValueError as error:
ctx.log.error(str(error))
raise SystemExit(65) from error
if target is not None:
ctx.host.write_file(target, generated, mode="0600")
ctx.log.success(f"Wrote {len(variables)} synthesized variables to {target}.")
elif args.dotenv:
print(generated, end="")
else:
for k in sorted(vars_dict.keys()):
print(f"export {k}={shlex.quote(str(vars_dict[k]))}")
for key, value in sorted(variables.items()):
print(f"export {key}={shlex.quote(str(value))}")
return 0
+1 -1
View File
@@ -220,7 +220,7 @@ def run(ctx, args):
except Exception as e: # bash ERR trap parity: unhandled failures page via Telegram
log.error(f"{type(e).__name__}: {e}")
ctx.notify.error(TITLE, url, f"Critical error: {e}")
raise SystemExit(1)
raise SystemExit(1) from e
def _check(ctx, args, log, node, url, s):
if not os.path.isfile("/etc/debian_version"):
+28 -33
View File
@@ -1,64 +1,59 @@
# actions/sync/repository.py
"""Port of sync/repository — capture-up commit + rebase onto origin/main + force-push."""
"""Commit node configuration and synchronize main directly with its remote."""
import os
from datetime import datetime
from engine.descriptor import Meta, Arg
from engine.identity import read_env
METADATA = Meta(summary="Capture-up: commit node changes, rebase onto origin/main, force-push sync/<node>.",
args=[Arg("telegram_bot_url", "Telegram Bot URL for notifications")])
TITLE = "Annual Remote Sync"
METADATA = Meta(summary="Commit node changes, rebase onto origin/main, and push main.",
args=[Arg("telegram_bot_url", "Optional notification URL", required=False)])
TITLE = "Repository Sync"
def run(ctx, args):
log, node, url = ctx.log, ctx.node.name, args.telegram_bot_url
log, node = ctx.log, ctx.node.name
url = args.telegram_bot_url
repo, commons = ctx.paths.repo_root, ctx.paths.commons
ne = read_env(os.path.join(repo, "node.env")) # per-node bot identity (generic defaults)
ne = read_env(os.path.join(repo, "node.env"))
author = ne.get("APEX_GIT_AUTHOR_NAME", "apex [bot]")
email = ne.get("APEX_GIT_AUTHOR_EMAIL", "apex@localhost")
env = dict(os.environ,
GIT_AUTHOR_NAME=author, GIT_AUTHOR_EMAIL=email,
env = dict(os.environ, GIT_AUTHOR_NAME=author, GIT_AUTHOR_EMAIL=email,
GIT_COMMITTER_NAME=author, GIT_COMMITTER_EMAIL=email)
def git(*a, check=True):
return ctx.sys.run(["git", "-C", repo, *a], check=check, env=env)
def git(*a, check=True, capture=False):
return ctx.sys.run(["git", "-C", repo, *a], check=check, capture=capture, env=env)
try:
branch = git("symbolic-ref", "--quiet", "--short", "HEAD", capture=True).stdout.strip()
if branch != "main":
raise RuntimeError("Repository sync requires main; switch branches explicitly first.")
cp = ctx.sys.run(["git", "-C", commons, "status", "--porcelain"], capture=True)
if (cp.stdout or "").strip():
log.error(f"Commons submodule at {commons} has uncommitted changes. Aborting.")
raise SystemExit(1)
raise RuntimeError(f"Commons submodule at {commons} has uncommitted changes.")
if not os.path.isdir(ctx.paths.compositions):
# Without this, `git add -A` would stage a deleted node tree and an
# unattended cron run would commit + force-push the deletion.
log.error(f"Directory '{ctx.paths.compositions}' does not exist. Cannot sync.")
raise SystemExit(1)
branch = f"sync/{node}"
ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
if ctx.sys.ok(["git", "-C", repo, "rev-parse", "--verify", branch]):
git("checkout", branch)
else:
git("checkout", "-b", branch)
raise RuntimeError(f"Directory '{ctx.paths.compositions}' does not exist.")
expected = git("rev-parse", "HEAD:commons", capture=True).stdout.strip()
actual = ctx.sys.run(["git", "-C", commons, "rev-parse", "HEAD"], capture=True).stdout.strip()
if actual != expected:
raise RuntimeError("Commons checkout differs from the committed pin; update the submodule before retrying.")
git("add", "-A")
git("submodule", "update", "--init", "--recursive")
git("add", "commons")
if git("diff", "--cached", "--quiet", check=False).returncode != 0:
ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
git("commit", "-m", f"[{node}] Auto-commit: {ts}")
else:
log.info("No changes to commit.")
git("fetch", "origin", "main")
if git("rebase", "origin/main", check=False).returncode != 0:
log.error("Rebase conflict — aborting rebase; manual intervention required.")
git("rebase", "--abort", check=False)
ctx.notify.error(TITLE, url, "Rebase conflict. Manual intervention required.")
raise SystemExit(1)
raise RuntimeError("Rebase conflict; local commits retained for manual resolution.")
git("submodule", "update", "--init", "--recursive")
git("push", "--force-with-lease", "origin", branch)
log.success(f"Pushed {branch}.")
if not ctx.notify.success(TITLE, url, f"Sync to '{branch}' completed."):
git("push", "origin", "HEAD:refs/heads/main")
log.success("Synchronized main.")
if url and not ctx.notify.success(TITLE, url, "Sync to main completed."):
raise SystemExit(1)
except SystemExit:
raise
except Exception as e:
log.error(f"{type(e).__name__}: {e}")
ctx.notify.error(TITLE, url, f"Sync failed: {e}")
raise SystemExit(1)
if url:
ctx.notify.error(TITLE, url, f"Sync failed: {e}")
raise SystemExit(1) from e
+1 -1
View File
@@ -33,4 +33,4 @@ def run(ctx, args):
ctx.notify.success(TITLE, url, f"Certificates extracted for {node}")
except Exception as e:
log.error(str(e)); ctx.notify.error(TITLE, url, f"cert extraction failed: {e}")
raise SystemExit(1)
raise SystemExit(1) from e
+5 -4
View File
@@ -1,7 +1,7 @@
# actions/utils/lint-docker-compose.py
"""Port of utils/lint-docker-compose (dclint via docker) + stdlib anchor-IP validation:
"""Run dclint through Docker and validate anchor IPs:
every ipv4_address in compositions/*/docker-compose.yml must be unique and inside the
node's /24 (SP3 §6.2). No new deps — ipaddress + re are stdlib."""
node's /24."""
import ipaddress, os, re, shutil
from engine.descriptor import Meta, Flag
@@ -12,8 +12,6 @@ def run(ctx, args):
log, s = ctx.log, ctx.sys
repo = ctx.paths.repo_root
rc_ip = _validate_anchor_ips(ctx)
if not shutil.which("docker"):
log.error("docker is not installed or not in PATH."); return rc_ip or 1
files_string, targets = "", ["."]
if args.hook:
@@ -25,6 +23,9 @@ def run(ctx, args):
log.info("No staged docker-compose files to lint."); return rc_ip
targets = [f"/workdir/{f}" for f in files_string.splitlines()]
if not shutil.which("docker"):
log.error("docker is not installed or not in PATH."); return rc_ip or 1
log.info("Running dclint --fix (via docker)...")
env_args = []
for k in ("HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy", "NO_PROXY", "no_proxy"):
+2 -2
View File
@@ -1,5 +1,5 @@
#cloud-config
# Use this same file for cloud-init or: python3 ci/scripts/init-node.py root@HOST --user-data FILE
# Supply this file to cloud-init when booting the golden image.
# Replace the hostname, repository and public key. Keep filled files outside Git.
# SSH repositories additionally require approved deferred id_ed25519 (0600)
# and known_hosts (0644) writes under /home/adam/.ssh, owned by adam:adam.
@@ -19,7 +19,7 @@ write_files:
permissions: '0600'
content: |
APEX_REPO_NAME="node1.example.com"
APEX_REPO_URL="https://github.com/you/node1.example.com.git"
APEX_REPO_URL="https://git.example.com/infrastructure/node1.example.com.git"
APEX_TIER1_MODE="directory"
APEX_TIER1_DEVICE=""
APEX_TIER2_MODE="directory"
+4 -3
View File
@@ -61,7 +61,7 @@ build {
sources = ["source.qemu.apex_debian"]
provisioner "file" {
source = "engine"
source = "ci/scripts/init-node/provisioning"
destination = "/tmp/"
}
@@ -69,8 +69,8 @@ build {
execute_command = "sudo -n sh -c '{{ .Vars }} {{ .Path }}'"
environment_vars = ["APEX_PROVISION_REVISION=${var.provision_revision}"]
inline = [
"bash /tmp/engine/provisioning/seed.sh",
"cd /tmp && python3 -m engine.provisioning.image install"
"bash /tmp/provisioning/seed.sh",
"cd /tmp && python3 -m provisioning.image install"
]
}
@@ -79,6 +79,7 @@ build {
scripts = [
"ci/packer/scripts/01-base-system.sh",
"ci/packer/scripts/02-storage-docker.sh",
"ci/packer/scripts/02-alloy-package.sh",
"ci/packer/scripts/03-bootstrap-helper.sh",
"ci/packer/scripts/04-cleanup.sh"
]
+18
View File
@@ -0,0 +1,18 @@
#!/usr/bin/env bash
set -euo pipefail
# Service configuration and credentials are deployed later by apex configure/alloy.
alloy_version=1.21.3
alloy_sha256=dbbd705de8ab37dec0cbd6361a3fc7e754d6b2854b57ce672a83e6366199ba73
[[ "$(dpkg --print-architecture)" == amd64 ]]
alloy_deb=$(mktemp --suffix=.deb)
trap 'rm -f "$alloy_deb"' EXIT
curl --fail --location --retry 3 --output "$alloy_deb" \
"https://github.com/grafana/alloy/releases/download/v${alloy_version}/alloy-${alloy_version}-1.amd64.deb"
printf '%s %s\n' "$alloy_sha256" "$alloy_deb" | sha256sum --check --status
systemctl mask alloy.service
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends "$alloy_deb"
systemctl disable --now alloy.service
systemctl unmask alloy.service
! systemctl is-active --quiet alloy.service
! systemctl is-enabled --quiet alloy.service
-64
View File
@@ -1,64 +0,0 @@
#!/usr/bin/env python3
"""Initialize a plain Debian target server via SSH enrollment.
Standalone tool for hosts on providers that do not support cloud-init.
Kept separate from the core local-only apex engine.
"""
import argparse
import os
import sys
_here = os.path.dirname(os.path.abspath(__file__))
_repo_root = os.path.dirname(os.path.dirname(_here))
_prov_dir = os.path.join(_here, "init-node")
sys.path.insert(0, _prov_dir)
from provisioning.transport import initialize
def main(argv=None):
parser = argparse.ArgumentParser(
description="Initialize a plain Debian server through protected SSH enrollment.",
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
)
parser.add_argument(
"target",
help="Bootstrap USER@HOST (root or an existing administrator with passwordless sudo)",
)
parser.add_argument(
"--user-data",
dest="user_data",
help="Path to APEX cloud-config file (e.g., ci/cloud-init/user-data.template.yaml)",
)
parser.add_argument(
"--port",
dest="port",
help="Bootstrap SSH port; otherwise uses standard SSH configuration",
)
parser.add_argument(
"--identity-file",
dest="identity_file",
help="SSH private identity file; otherwise uses SSH configuration/agent",
)
args = parser.parse_args(argv)
print(f"[init-node] Starting enrollment for {args.target}...")
try:
initialize(
_repo_root,
args.target,
args.user_data,
args.port,
args.identity_file,
progress=lambda msg: print(f"[init-node] {msg}"),
)
print(f"[init-node] Successfully initialized {args.target}.")
return 0
except Exception as e:
sys.stderr.write(f"[init-node] Error: {e}\n")
return 1
if __name__ == "__main__":
raise SystemExit(main())
+2 -2
View File
@@ -319,12 +319,12 @@ class Host:
self.command(['ufw', '--force', 'delete', 'allow', '2222/tcp', 'comment', 'apex-init-access'])
def arm(self):
bundle = Path(__file__).resolve().parents[2]
bundle = Path(__file__).resolve().parents[1]
if not re.fullmatch(r'/usr/local/lib/apex-provisioner/[a-f0-9]{64}', str(bundle)):
raise AccessError('Access runner must execute from the installed root-owned bundle')
service = ('[Unit]\nDescription=APEX pending access rollback\nAfter=local-fs.target\n'
'[Service]\nType=oneshot\nWorkingDirectory=' + str(bundle) + '\n'
'ExecStart=/usr/bin/python3 -m engine.provisioning.access tick\n')
'ExecStart=/usr/bin/python3 -m provisioning.access tick\n')
timer = ('[Unit]\nDescription=APEX access rollback deadline and boot recovery\n'
'[Timer]\nOnBootSec=5s\nOnUnitActiveSec=10s\nAccuracySec=1s\n'
'[Install]\nWantedBy=timers.target\n')
@@ -325,7 +325,8 @@ def configure_sources():
key = Path(f"/etc/apt/keyrings/apex-{name}.asc")
if not key.exists():
try:
with urllib.request.urlopen(key_url, timeout=30) as response:
# Both repository key URLs above are fixed HTTPS endpoints.
with urllib.request.urlopen(key_url, timeout=30) as response: # noqa: S310
data = response.read()
except OSError as exc:
raise BaselineError(f"Cannot download {name} repository signing key.") from exc
@@ -0,0 +1,51 @@
"""Deterministic provisioning payloads for golden-image builds."""
import hashlib
import io
from pathlib import Path
import re
import shutil
import subprocess
import tarfile
from .state import ProvisionError
def _bundle_from_source(root):
root = Path(root).resolve()
package = root / 'provisioning'
files = sorted(package.rglob('*.py'))
seed = package / 'seed.sh'
guard = package / 'profiles.sh'
if not seed.is_file() or not (package / '__init__.py').is_file():
raise ProvisionError('Provisioning package or seed is missing')
files.append(seed)
if guard.is_file():
files.append(guard)
output = io.BytesIO()
with tarfile.open(fileobj=output, mode='w') as archive:
for path in files:
if (not path.is_file() or '__pycache__' in path.parts
or any(part.is_symlink() for part in (path, *path.parents))):
raise ProvisionError('Provisioning bundle contains an unsafe source path')
data = path.read_bytes()
info = tarfile.TarInfo(path.relative_to(root).as_posix())
info.size, info.mode, info.mtime = len(data), (0o755 if path == seed else 0o644), 0
info.uid = info.gid = 0
archive.addfile(info, io.BytesIO(data))
data = output.getvalue()
return data, hashlib.sha256(data).hexdigest()
def build_bundle(commons):
root = Path(commons).resolve()
data, digest = _bundle_from_source(root / 'ci/scripts/init-node')
revision = 'unversioned-modified'
if shutil.which('git'):
result = subprocess.run(['git', '-C', str(root), 'rev-parse', 'HEAD'], capture_output=True, text=True, timeout=30)
if result.returncode == 0 and re.fullmatch(r'[a-f0-9]{40,64}\n?', result.stdout):
revision = result.stdout.strip()
dirty = subprocess.run(['git', '-C', str(root), 'status', '--porcelain', '--', 'ci/scripts/init-node'], capture_output=True, text=True, timeout=30)
if dirty.returncode or dirty.stdout.strip():
revision += '-modified'
return data, digest, revision
+6 -6
View File
@@ -1,4 +1,4 @@
"""Image-only installation and sanitization; never called by remote enrollment."""
"""Image-only installation and sanitization; for golden-image builds."""
from __future__ import annotations
import argparse
@@ -16,9 +16,9 @@ from .target import preflight, run
def install():
from .transport import build_bundle
source = Path(__file__).resolve().parents[2]
data, digest, _ = build_bundle(source)
from .bundle import _bundle_from_source
source = Path(__file__).resolve().parents[1]
data, digest = _bundle_from_source(source)
destination = safe_path(Path('/usr/local/lib/apex-provisioner') / digest)
destination.mkdir(mode=0o755, parents=True, exist_ok=False)
with tarfile.open(fileobj=io.BytesIO(data)) as archive:
@@ -29,7 +29,7 @@ def install():
atomic_write('/etc/apex/provisioner.json', json.dumps(metadata) + '\n')
for executable, operation in (('apex-provision-image', ''), ('apex-bootstrap', 'cloud-enroll ')):
content = ('#!/bin/sh\nset -eu\ncd ' + str(destination) + '\n'
'exec /usr/bin/python3 -m engine.provisioning.image ' + operation + '"$@"\n')
'exec /usr/bin/python3 -m provisioning.image ' + operation + '"$@"\n')
atomic_write('/usr/local/bin/' + executable, content, 0o755)
return metadata
@@ -84,7 +84,7 @@ def sanitize_files(root=Path('/')):
dbus.unlink()
dbus.parent.mkdir(parents=True, exist_ok=True)
dbus.symlink_to('/etc/machine-id')
for name in ('var/lib/apex-init', 'tmp/engine', 'var/lib/cloud'):
for name in ('var/lib/apex-init', 'tmp/provisioning', 'var/lib/cloud'):
path = root / name
if path.is_symlink():
path.unlink()
@@ -45,7 +45,7 @@ def _runtime_rule(line):
if len(tokens) % 2:
raise SecurityError('Unsupported runtime firewall rule')
pairs = []
for key, value in zip(tokens[::2], tokens[1::2]):
for key, value in zip(tokens[::2], tokens[1::2], strict=True):
if key in ('-s', '-d'):
value = str(ipaddress.ip_network(value, strict=False))
elif key == '--ctstate':
@@ -77,7 +77,7 @@ class Security:
if not active:
output = self.host.command(['dpkg-query', '-W', '-f=${Conffiles}', 'ufw']).stdout
conffiles = dict(re.findall(r'^\s*(/\S+)\s+([a-f0-9]{32})(?:\s|$)', output, re.M))
pristine = (hashlib.md5(base64.b64decode(files['/etc/default/ufw']['data'])).hexdigest()
pristine = (hashlib.md5(base64.b64decode(files['/etc/default/ufw']['data']), usedforsecurity=False).hexdigest()
== conffiles.get('/etc/default/ufw'))
manifest = dict((name, digest) for digest, name in re.findall(
r'^([a-f0-9]{32})\s+(\S+)\s*$', self._text('/var/lib/dpkg/info/ufw.md5sums'), re.M))
@@ -86,7 +86,7 @@ class Security:
filename = name.rsplit('/', 1)[1]
template = '/usr/share/ufw/' + ('' if filename == 'ufw.conf' else 'iptables/') + filename
data = self._path(template).read_bytes()
pristine = pristine and (hashlib.md5(data).hexdigest() == manifest.get(template.lstrip('/'))
pristine = pristine and (hashlib.md5(data, usedforsecurity=False).hexdigest() == manifest.get(template.lstrip('/'))
and data == base64.b64decode(files[name]['data']))
if not pristine:
raise SecurityError('Inactive UFW is not provably pristine; reconcile its existing policy first')
@@ -111,7 +111,7 @@ class Security:
output = self.host.command(['dpkg-query', '-W', '-f=${Conffiles}',
'crowdsec-firewall-bouncer-iptables']).stdout
conffiles = dict(re.findall(r'^\s*(/\S+)\s+([a-f0-9]{32})(?:\s|$)', output, re.M))
return hashlib.md5(self._path(path).read_bytes()).hexdigest() == conffiles.get(path)
return hashlib.md5(self._path(path).read_bytes(), usedforsecurity=False).hexdigest() == conffiles.get(path)
def _path(self, name):
path = self.host.path(name)
@@ -1,385 +0,0 @@
"""Workstation SSH transport. User-data stays opaque until target validation."""
from __future__ import annotations
import hashlib
import io
import ipaddress
import json
import os
from pathlib import Path
import re
import shlex
import shutil
import subprocess
import tarfile
import tempfile
import time
import uuid
class TransportError(RuntimeError):
pass
class HandoverUncertain(TransportError):
pass
# This runs before Python is guaranteed to exist on the Debian target. All
# ancestors must be trusted; a root-owned sticky temporary directory is safe
# only because root owns each managed child and exclusive mktemp creates it.
REMOTE_PATH_GUARD = r'''
set -eu
umask 077
apex_guard_dir() {
apex_checked=$1
while :; do
test ! -L "$apex_checked" && test -d "$apex_checked" || exit 91
test "$(stat -c %u -- "$apex_checked")" = 0 || exit 91
apex_mode=$(stat -c %a -- "$apex_checked")
test "$((0$apex_mode & 0022))" = 0 || test "$((0$apex_mode & 01000))" != 0 || exit 91
test "$apex_checked" != / || break
apex_checked=${apex_checked%/*}
test -n "$apex_checked" || apex_checked=/
done
}
apex_ensure_dir() {
apex_new_dir=$1
if test -e "$apex_new_dir" || test -L "$apex_new_dir"; then
apex_guard_dir "$apex_new_dir"
apex_mode=$(stat -c %a -- "$apex_new_dir")
test "$((0$apex_mode & 0022))" = 0 || exit 91
else
apex_guard_dir "${apex_new_dir%/*}"
mkdir -m "$2" -- "$apex_new_dir"
apex_guard_dir "$apex_new_dir"
fi
}
apex_guard_file() {
apex_file=$1
apex_guard_dir "${apex_file%/*}"
apex_mode=$(stat -c %a -- "${apex_file%/*}")
test "$((0$apex_mode & 0022))" = 0 || exit 91
test ! -L "$apex_file" || exit 91
if test -e "$apex_file"; then
test -f "$apex_file" || exit 91
test "$(stat -c %u -- "$apex_file")" = 0 || exit 91
test "$(stat -c %h -- "$apex_file")" = 1 || exit 91
apex_mode=$(stat -c %a -- "$apex_file")
test "$((0$apex_mode & 0022))" = 0 || exit 91
fi
}
apex_atomic_input() {
apex_destination=$1
apex_guard_file "$apex_destination"
apex_temporary=$(mktemp "${apex_destination%/*}/.apex-write.XXXXXXXXXX")
trap 'rm -f -- "$apex_temporary"' EXIT HUP INT TERM
cat > "$apex_temporary"
chmod 0600 "$apex_temporary"
apex_guard_file "$apex_destination"
mv -T -- "$apex_temporary" "$apex_destination"
trap - EXIT HUP INT TERM
}
'''
def remote_atomic_input(path):
return REMOTE_PATH_GUARD + '\napex_atomic_input ' + shlex.quote(path)
def remote_bundle_install(stage, installed, digest):
return REMOTE_PATH_GUARD + '\n' + (
'apex_guard_dir ' + shlex.quote(stage) + '; apex_guard_file ' + shlex.quote(stage + '/bundle.tar') + '; '
'test "$(sha256sum ' + shlex.quote(stage + '/bundle.tar') + ' | cut -d " " -f 1)" = ' + digest + '; '
'apex_ensure_dir /usr/local/lib/apex-provisioner 0755; '
'if test -e ' + installed + ' || test -L ' + installed + '; then '
'apex_guard_dir ' + installed + '; '
'apex_bad=$(find ' + installed + ' -xdev \\( -type l -o ! -user root -o -perm /022 -o \\( ! -type d -a ! -type f \\) \\) -print -quit); test -z "$apex_bad"; '
'else apex_install_tmp=$(mktemp -d /usr/local/lib/apex-provisioner/.install.XXXXXXXXXX); '
'trap \'rm -rf -- "$apex_install_tmp"\' EXIT HUP INT TERM; '
'tar --no-same-owner --same-permissions -xf ' + stage + '/bundle.tar -C "$apex_install_tmp"; '
'mv -T -- "$apex_install_tmp" ' + installed + '; trap - EXIT HUP INT TERM; fi; '
'tar --compare -f ' + stage + '/bundle.tar -C ' + installed)
def local_file(value, name):
if not value or any(ord(c) < 32 for c in str(value)):
raise TransportError(f'{name} must name a readable regular file')
try:
path = Path(value).expanduser().resolve(strict=True)
except (OSError, ValueError):
raise TransportError(f'{name} must name a readable regular file') from None
if not path.is_file() or not os.access(path, os.R_OK):
raise TransportError(f'{name} must name a readable regular file')
return path
def destination(value, port=None):
match = re.fullmatch(r'([A-Za-z_][A-Za-z0-9_.-]{0,31})@([A-Za-z0-9][A-Za-z0-9_.:-]*|\[[0-9a-fA-F:]+\])', value or '')
if not match or '..' in match[2]:
raise TransportError('Target must be USER@HOST without SSH options; use root or an existing administrator with sudo -n')
hostname = match[2].strip('[]')
if ':' in hostname:
try:
ipaddress.IPv6Address(hostname)
except ValueError:
raise TransportError('IPv6 targets must contain a valid address; pass ports separately') from None
if port is not None and (not re.fullmatch(r'[0-9]{1,5}', str(port)) or not 1 <= int(port) <= 65535):
raise TransportError('Port must be an integer from 1 through 65535')
return match[1], hostname, int(port) if port is not None else None
def build_bundle(commons):
root = Path(commons)
files = sorted((root / 'engine').rglob('*.py'))
seed = root / 'engine/provisioning/seed.sh'
if not seed.is_file():
raise TransportError('Provisioning seed is missing')
files.append(seed)
output = io.BytesIO()
with tarfile.open(fileobj=output, mode='w') as archive:
for path in files:
if path.is_symlink() or '__pycache__' in path.parts:
raise TransportError('Provisioning bundle contains an unsafe source path')
data = path.read_bytes()
info = tarfile.TarInfo(path.relative_to(root).as_posix())
info.size, info.mode, info.mtime = len(data), (0o755 if path == seed else 0o644), 0
info.uid = info.gid = 0
archive.addfile(info, io.BytesIO(data))
data = output.getvalue()
revision = 'unversioned-modified'
if shutil.which('git'):
result = subprocess.run(['git', '-C', str(root), 'rev-parse', 'HEAD'], capture_output=True, text=True, timeout=30)
if result.returncode == 0 and re.fullmatch(r'[a-f0-9]{40,64}\n?', result.stdout):
revision = result.stdout.strip()
dirty = subprocess.run(['git', '-C', str(root), 'status', '--porcelain', '--', 'engine'], capture_output=True, text=True, timeout=30)
if dirty.returncode or dirty.stdout.strip():
revision += '-modified'
return data, hashlib.sha256(data).hexdigest(), revision
class SSH:
def __init__(self, target, port=None, identity_file=None, runner=subprocess.run):
self.user, self.host, self.port = destination(target, port)
self.identity = local_file(identity_file, 'Identity file') if identity_file else None
self.runner = runner
self.resolved_host = None
self.resolved_port = None
self.trust_file = None
def arguments(self, fresh=False):
args = ['ssh', '-T', '-o', 'StrictHostKeyChecking=yes', '-o', 'ConnectTimeout=15',
'-o', 'ServerAliveInterval=10', '-o', 'ServerAliveCountMax=3']
if self.identity:
args += ['-i', str(self.identity)]
args += ['-l', 'adam' if fresh else self.user]
if fresh:
if not self.trust_file or not self.resolved_host:
raise TransportError('Fresh access requires the verified bootstrap host identity')
args += ['-p', '2222', '-o', 'ControlMaster=no', '-o', 'ControlPath=none',
'-o', 'ControlPersist=no', '-o', 'BatchMode=yes',
'-o', 'PreferredAuthentications=publickey', '-o', 'PasswordAuthentication=no',
'-o', 'KbdInteractiveAuthentication=no', '-o', 'Hostname=' + self.resolved_host,
'-o', 'HostKeyAlias=apex-init-verified-target',
'-o', 'UserKnownHostsFile=' + str(self.trust_file),
'-o', 'GlobalKnownHostsFile=/dev/null']
elif self.port:
args += ['-p', str(self.port)]
return args
def resolve(self):
result = self.runner(self.arguments() + ['-G', '--', self.host], capture_output=True, text=True, timeout=30)
if result.returncode:
raise TransportError('Unable to resolve SSH connection configuration')
values = dict(line.split(' ', 1) for line in result.stdout.splitlines() if ' ' in line)
hostname = values.get('hostname', '')
port = values.get('port', '')
destination(self.user + '@' + hostname, port)
self.resolved_host, self.resolved_port = hostname, int(port)
def run(self, script, data=None, fresh=False, privileged=True, check=True, timeout=300):
command = ['sh', '-c', script]
if privileged and (fresh or self.user != 'root'):
command = ['sudo', '-n', '--', *command]
result = self.runner(self.arguments(fresh) + ['--', self.host, shlex.join(command)],
input=data, capture_output=True, timeout=timeout)
if check and result.returncode:
# SSH and child diagnostics can contain user-data or credential-bearing URLs.
raise TransportError('Remote operation failed; inspect the root-owned target status for a redacted diagnosis')
return result
def fresh_login(self):
self.run('test "$(id -u)" = 0', fresh=True)
def protected_handover(ssh, invoke, token, port, client_address):
result = invoke('begin', token, port, client_address, False)
try:
ssh.fresh_login()
if result['status'] == 'committed':
return
if result['status'] != 'existing':
invoke('finalize', token, None, None, True)
ssh.fresh_login()
try:
invoke('commit', token, None, None, True)
except Exception:
try:
state = invoke('status', token, None, None, True)
if state.get('token') != token or state.get('status') not in ('committed', 'existing', 'transition', 'final', 'rolled_back', 'recovery_pending'):
raise TransportError('Unexpected access transition state')
except Exception:
raise HandoverUncertain('Access commit outcome is unknown; verify the administrator and bootstrap endpoints before resuming') from None
if state['status'] != 'committed':
raise
except HandoverUncertain:
raise
except Exception:
try:
invoke('rollback', token, None, None, True)
except Exception:
try:
invoke('rollback', token, None, None, False)
except Exception:
pass # Persistent timer is authoritative if both connections are gone.
raise
def initialize(commons, target, user_data, port=None, identity_file=None, progress=print):
if not shutil.which('ssh'):
raise TransportError('OpenSSH ssh is required on the workstation')
source = local_file(user_data, 'User-data')
if source.stat().st_size > 1024 * 1024:
raise TransportError('User-data must be no larger than 1 MiB')
ssh = SSH(target, port, identity_file)
ssh.resolve()
bundle, digest, revision = build_bundle(commons)
run_id = uuid.uuid4().hex
installed = '/usr/local/lib/apex-provisioner/' + digest
stage = None
launched = False
finished = False
access_committed = False
access_uncertain = False
cleanup_policy = '/etc/tmpfiles.d/apex-init-' + run_id + '.conf'
with tempfile.TemporaryDirectory(prefix='apex-init-trust-') as local:
try:
preflight = ssh.run('set -eu; test "$(id -u)" = 0; . /etc/os-release; '
'test "$ID" = debian; test "$VERSION_ID" = 13; '
'test "$(dpkg --print-architecture)" = amd64; '
'command -v systemd-run >/dev/null; '
'printf "%s\\n" "${SSH_CONNECTION:-}"; '
'cat /etc/ssh/ssh_host_*_key.pub')
lines = preflight.stdout.decode().splitlines()
connection = lines[0].split() if lines else []
if len(connection) != 4:
# sudo commonly clears SSH_CONNECTION; read it before elevation instead.
raw = ssh.run('printf "%s\\n" "$SSH_CONNECTION"', privileged=False).stdout.decode().split()
connection = raw
if len(connection) != 4:
raise TransportError('Unable to inspect the established SSH connection')
keys = [line for line in lines[1:] if re.fullmatch(r'(ssh-[\w-]+|ecdsa-[\w-]+) [A-Za-z0-9+/=]+(?: .*?)?', line)]
if not keys:
raise TransportError('Unable to obtain public host keys through the verified connection')
trust = Path(local) / 'known_hosts'
trust.write_text(''.join('apex-init-verified-target,[apex-init-verified-target]:2222 ' + ' '.join(key.split()[:2]) + '\n' for key in keys))
trust.chmod(0o600)
ssh.trust_file = trust
existing = ssh.run(REMOTE_PATH_GUARD + '\napex_ensure_dir /var/lib/apex-init 0700; '
'apex_guard_file /var/lib/apex-init/remote.json; '
'if test -f /var/lib/apex-init/remote.json; then cat /var/lib/apex-init/remote.json; fi').stdout
if existing:
prior = json.loads(existing)
previous_unit = prior.get('unit', '')
if not re.fullmatch(r'apex-init-[a-f0-9]{32}', previous_unit):
raise TransportError('Existing remote coordinator state requires manual inspection')
active = ssh.run(shlex.join(['systemctl', 'is-active', '--quiet', previous_unit]), check=False)
if active.returncode == 0:
raise TransportError('An enrollment unit is still running: ' + previous_unit + '; wait for it to settle before resuming')
stage = ssh.run(REMOTE_PATH_GUARD + '\napex_guard_dir /var/tmp; mktemp -d /var/tmp/apex-init.XXXXXXXXXX').stdout.decode().strip()
if not re.fullmatch(r'/var/tmp/apex-init\.[A-Za-z0-9]{10}', stage):
raise TransportError('Target returned an invalid staging path')
# Hard reboot can bypass the shell trap; boot-time tmpfiles removes opaque inputs.
cleanup = ('r ' + stage + '/user-data - - - -\n' +
'r ' + stage + '/heartbeat - - - -\n').encode()
ssh.run(REMOTE_PATH_GUARD + '\napex_ensure_dir /etc/tmpfiles.d 0755; apex_atomic_input ' + cleanup_policy, cleanup)
ssh.run(remote_atomic_input(stage + '/bundle.tar'), bundle)
ssh.run(remote_atomic_input(stage + '/user-data'), source.read_bytes())
install = remote_bundle_install(stage, installed, digest)
progress('Installing the versioned target runtime and validating enrollment input.')
ssh.run(install)
base = ['python3', '-m', 'engine.provisioning.target']
options = ['--user-data', stage + '/user-data', '--revision', revision, '--bundle-digest', digest]
# State remains inspectable after disconnect; user-data is removed by the durable unit.
enrollment = shlex.join(base + ['enroll'] + options + ['--heartbeat', stage + '/heartbeat'])
validation = shlex.join(base + ['validate'] + options)
access_preflight = shlex.join(['python3', '-m', 'engine.provisioning.access', 'preflight'])
seed = shlex.join(['bash', installed + '/engine/provisioning/seed.sh'])
wrapper = ('umask 077; cd ' + installed + '; '
'trap ' + shlex.quote('rm -f ' + stage + '/user-data ' + stage + '/heartbeat ' + cleanup_policy) + ' EXIT; '
'{ ' + seed + ' && ' + validation + ' && ' + access_preflight + ' && ' + enrollment + '; } >' + stage + '/runner.log 2>&1; rc=$?; '
'printf "%s\\n" "$rc" >' + stage + '/result.new; mv ' + stage + '/result.new ' + stage + '/result; exit "$rc"')
unit = 'apex-init-' + run_id
record = json.dumps({'unit': unit, 'staging': stage, 'revision': revision, 'bundle_digest': digest}).encode()
ssh.run(remote_atomic_input('/var/lib/apex-init/remote.json'), record)
ssh.run('touch ' + stage + '/heartbeat; ' + shlex.join(['systemd-run', '--quiet', '--unit', unit,
'--property=Type=exec', '--property=KillMode=process', '/bin/sh', '-c', wrapper]))
launched = True
progress('Enrollment is running under ' + unit + '; target staging: ' + stage)
while True:
status = ssh.run('if test -f ' + stage + '/result; then cat ' + stage + '/result; '
'elif systemctl is-active --quiet ' + unit + '; then touch ' + stage + '/heartbeat; printf running; '
'else printf stopped; fi').stdout.decode().strip()
if status != 'running':
finished = True
if status != '0':
raise TransportError('Target provisioning failed; inspect root-owned ' + stage +
'/runner.log and /var/lib/apex-init/enrollment.json')
break
time.sleep(5)
progress('Enrollment verified. Checking protected administrator SSH handover.')
def access(operation, token, original_port, address, fresh):
if operation == 'status':
result = ssh.run(REMOTE_PATH_GUARD + '\napex_guard_file /var/lib/apex-init/access/state.json; '
'cat /var/lib/apex-init/access/state.json', fresh=True)
return json.loads(result.stdout)
args = ['python3', '-m', 'engine.provisioning.access', operation, '--token', token]
if original_port:
args += ['--original-port', str(original_port), '--client-address', address]
result = ssh.run('cd ' + installed + ' && ' + shlex.join(args), fresh=fresh)
return json.loads(result.stdout)
protected_handover(ssh, access, run_id, int(connection[3]), connection[0])
access_committed = True
result = ssh.run('cd ' + installed + ' && ' + shlex.join(base + ['complete']), fresh=True)
completed = json.loads(result.stdout)
progress('Initialization and fresh administrator access verified; provisioner ' + revision + ', bundle ' + digest)
repository = completed.get('stages', {}).get('repository', {}).get('result', {})
if repository.get('commit'):
progress('Repository ' + repository['commit'] + '; commons ' + repository.get('commons_commit', 'unknown'))
return completed
except HandoverUncertain:
access_uncertain = True
raise
finally:
if stage and (not launched or finished):
for fresh in (True, False):
try:
ssh.run('rm -f ' + stage + '/user-data ' + stage + '/heartbeat ' + stage + '/bundle.tar ' + cleanup_policy, fresh=fresh)
break
except Exception:
pass
if launched and not finished:
progress('Coordinator stopped. The current target stage may finish; later stages stop after heartbeat expiry.')
if access_uncertain:
progress('Access commit outcome is unknown; verify which endpoint is active before resuming. Rollback may already be disabled.')
endpoints = [('Administrator candidate', 'adam', 2222), ('Bootstrap candidate', ssh.user, ssh.resolved_port)]
else:
label = 'Safe resume' + ('' if access_committed else ' after any pending rollback')
endpoints = [(label, 'adam' if access_committed else ssh.user, 2222 if access_committed else ssh.resolved_port)]
for label, resume_user, resume_port in endpoints:
resume = './apex init ' + resume_user + '@' + ssh.host + ' --port ' + str(resume_port) + ' --user-data ' + shlex.quote(str(source))
if ssh.identity:
resume += ' --identity-file ' + shlex.quote(str(ssh.identity))
progress(label + ': ' + resume)
@@ -23,7 +23,8 @@ def decode_user_data(content):
if len(content) > 1024 * 1024:
raise ContractError('user-data exceeds the supported size')
try:
document = yaml.load(content, Loader=Loader)
# Loader extends SafeLoader only to reject aliases and duplicate keys.
document = yaml.load(content, Loader=Loader) # noqa: S506
except (yaml.YAMLError, UnicodeError, RecursionError):
raise ContractError('invalid or unsupported YAML user-data') from None
return normalize_user_data(document)
@@ -1,4 +1,4 @@
from engine.provisioning.security import SecurityError
from provisioning.security import SecurityError
class SecurityBoundary:
@@ -7,7 +7,7 @@ import tempfile
import unittest
from unittest.mock import patch
from engine.provisioning.access import AccessError, Host, Transition, atomic, main
from provisioning.access import AccessError, Host, Transition, atomic, main
from provision_fakes import SecurityBoundary
@@ -122,7 +122,7 @@ class AccessTests(unittest.TestCase):
self.assertEqual(self.host.restored, 1)
def test_watchdog_restores_access_while_enrollment_lock_is_held(self):
from engine.provisioning.state import exclusive_lock
from provisioning.state import exclusive_lock
self.begin()
self.clock += 301
with exclusive_lock(self.host.path('/var/lib/apex-init/init.lock')):
@@ -297,8 +297,8 @@ class LivePolicyTests(unittest.TestCase):
return self.runner(args, **kwargs)
self.host.runner = unseeded
before = sorted(self.root.rglob('*'))
with patch('engine.provisioning.access.Host', return_value=self.host), \
patch('engine.provisioning.access.os.geteuid', return_value=0):
with patch('provisioning.access.Host', return_value=self.host), \
patch('provisioning.access.os.geteuid', return_value=0):
self.assertEqual(main(['preflight']), 0)
self.assertEqual(sorted(self.root.rglob('*')), before)
self.assertIn(['/usr/sbin/sshd', '-t'], self.calls)
@@ -309,7 +309,7 @@ class LivePolicyTests(unittest.TestCase):
self.cmdline()
for failure in ('socket', 'invocation'):
with self.subTest(failure=failure):
def incompatible(args, **kwargs):
def incompatible(args, failure=failure, **kwargs):
if failure == 'socket' and args == ['systemctl', 'is-enabled', '--quiet', 'ssh.socket']:
return subprocess.CompletedProcess(args, 0, '', '')
return self.runner(args, **kwargs)
@@ -2,14 +2,13 @@
import hashlib
import importlib.util
import io
import json
from pathlib import Path
import subprocess
import tempfile
import unittest
from unittest.mock import patch
from engine.provisioning import baseline
from provisioning import baseline
class Host:
@@ -1,13 +1,13 @@
import copy
import unittest
from engine.provisioning.contract import ContractError, normalize_user_data, parse_bootstrap_env
from provisioning.contract import ContractError, normalize_user_data, parse_bootstrap_env
def example():
return {
'preserve_hostname': False,
'hostname': 'metis.a1.example.com',
'hostname': 'node1.a1.example.com',
'users': [{
'name': 'adam',
'sudo': 'ALL=(ALL) NOPASSWD:ALL',
@@ -19,8 +19,8 @@ def example():
'path': '/etc/apex/bootstrap.env',
'owner': 'root:root',
'permissions': '0600',
'content': 'APEX_REPO_NAME="metis.example.com"\n'
'APEX_REPO_URL="https://example.com/team/metis.git"\n',
'content': 'APEX_REPO_NAME="node1.example.com"\n'
'APEX_REPO_URL="https://example.com/team/node1.git"\n',
}],
'runcmd': ['/usr/local/bin/apex-bootstrap'],
}
@@ -34,7 +34,7 @@ def add_env(doc, text):
class TestBootstrapAssignments(unittest.TestCase):
def test_literal_quotes_comments_and_empty_values(self):
values = parse_bootstrap_env(
'# enrollment\nAPEX_REPO_NAME=metis.example.com\n'
'# enrollment\nAPEX_REPO_NAME=node1.example.com\n'
"APEX_REPO_REF='release-1'\nAPEX_TIER1_DEVICE=\n")
self.assertEqual(values['APEX_REPO_REF'], 'release-1')
self.assertEqual(values['APEX_TIER1_DEVICE'], '')
@@ -76,8 +76,8 @@ class TestBootstrapAssignments(unittest.TestCase):
class TestUserDataContract(unittest.TestCase):
def test_public_repository_and_directory_defaults(self):
spec = normalize_user_data(example())
self.assertEqual(spec.hostname, 'metis.a1.example.com')
self.assertEqual(spec.repository.name, 'metis.example.com')
self.assertEqual(spec.hostname, 'node1.a1.example.com')
self.assertEqual(spec.repository.name, 'node1.example.com')
self.assertIsNone(spec.repository.ref)
self.assertEqual([tier.mode for tier in spec.tiers], ['directory'] * 3)
self.assertEqual(spec.tiers[0].number, 1)
@@ -173,9 +173,9 @@ class TestUserDataContract(unittest.TestCase):
def test_repository_paths_and_embedded_passwords(self):
for name, url in [
('../escape', 'https://example.com/repo.git'),
('metis', 'https://user:SECRET_SENTINEL@example.com/repo.git'),
('metis', 'file:///etc/shadow'),
('metis', '-uSECRET_SENTINEL'),
('node1', 'https://user:SECRET_SENTINEL@example.com/repo.git'),
('node1', 'file:///etc/shadow'),
('node1', '-uSECRET_SENTINEL'),
]:
doc = example()
doc['write_files'][0]['content'] = (
@@ -1,17 +1,90 @@
import io
import os
from pathlib import Path
import shlex
import subprocess
import sys
import tarfile
import tempfile
import unittest
from unittest.mock import patch
from engine.provisioning.image import cloud_enroll, sanitize_files
from engine.provisioning.state import ProvisionError
from provisioning import access, image
from provisioning.image import cloud_enroll, sanitize_files
from provisioning.bundle import build_bundle
from provisioning.state import atomic_write, safe_path
from provisioning.state import ProvisionError
class ImageTests(unittest.TestCase):
def test_uploaded_package_installs_runnable_wrappers_and_watchdog(self):
commons = Path(__file__).resolve().parents[4]
data, digest, _ = build_bundle(commons)
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary).resolve()
upload = root / 'tmp'
upload.mkdir()
with tarfile.open(fileobj=io.BytesIO(data)) as archive:
archive.extractall(upload, filter='data')
def mapped(path):
return safe_path(root / str(path).lstrip('/'))
def write(path, content, mode=0o600):
return atomic_write(mapped(path), content, mode)
with patch.object(image, '__file__', str(upload / 'provisioning/image.py')), \
patch.object(image, 'safe_path', side_effect=mapped), \
patch.object(image, 'atomic_write', side_effect=write):
metadata = image.install()
installed = root / 'usr/local/lib/apex-provisioner' / digest
self.assertEqual(metadata['path'], str(installed))
self.assertTrue((installed / 'provisioning/seed.sh').is_file())
environment = dict(os.environ)
environment.pop('PYTHONPATH', None)
for executable in ('apex-provision-image', 'apex-bootstrap'):
wrapper = root / 'usr/local/bin' / executable
self.assertEqual(wrapper.stat().st_mode & 0o777, 0o755)
script = wrapper.read_text().replace('/usr/bin/python3', shlex.quote(sys.executable) + ' -S')
result = subprocess.run(['/bin/sh', '-c', script, executable, '--help'],
cwd=root, env=environment, capture_output=True,
text=True, timeout=30)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn('usage:', result.stdout)
commands = []
def runner(args, **kwargs):
commands.append(args)
return subprocess.CompletedProcess(args, 0, '', '')
canonical = '/usr/local/lib/apex-provisioner/' + digest
with patch.object(access, '__file__', canonical + '/provisioning/access.py'):
access.Host(root, runner=runner).arm()
service = (root / 'etc/systemd/system/apex-access-rollback.service').read_text()
fields = dict(line.split('=', 1) for line in service.splitlines() if '=' in line)
self.assertEqual(fields['WorkingDirectory'], canonical)
argv = shlex.split(fields['ExecStart'])
self.assertEqual(argv[-1], 'tick')
result = subprocess.run([sys.executable, '-S', *argv[1:-1], '--help'],
cwd=installed, env=environment, capture_output=True,
text=True, timeout=30)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn(['systemctl', 'enable', '--now', 'apex-access-rollback.timer'], commands)
sanitize_files(root)
self.assertFalse((upload / 'provisioning').exists())
self.assertTrue((installed / 'provisioning/image.py').is_file())
def test_watchdog_rejects_uninstalled_package_before_writing_units(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary).resolve()
with self.assertRaisesRegex(access.AccessError, 'installed root-owned bundle'):
access.Host(root).arm()
self.assertEqual(list(root.iterdir()), [])
def test_incompatible_image_stops_before_any_enrollment(self):
for metadata in ('{}', '{"enrollment_interface":999}'):
with self.subTest(metadata=metadata), patch.object(Path, 'read_text', return_value=metadata), \
patch('engine.provisioning.target.enroll') as enroll:
patch('provisioning.target.enroll') as enroll:
with self.assertRaisesRegex(ProvisionError, 'image enrollment interface'):
cloud_enroll()
enroll.assert_not_called()
@@ -23,7 +96,8 @@ class ImageTests(unittest.TestCase):
'root/.ssh/authorized_keys', 'home/debian/.ssh/authorized_keys',
'etc/machine-id', 'var/lib/dbus/machine-id',
'var/lib/apex-init/enrollment.json', 'var/lib/cloud/instance/user-data.txt',
'usr/local/lib/apex-provisioner/digest/engine/provisioning/target.py'):
'usr/local/lib/apex-provisioner/digest/provisioning/target.py',
'tmp/provisioning/image.py'):
path = root / relative
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text('build-specific')
@@ -33,5 +107,6 @@ class ImageTests(unittest.TestCase):
self.assertFalse((root / 'root/.ssh/authorized_keys').exists())
self.assertFalse((root / 'var/lib/apex-init').exists())
self.assertFalse((root / 'var/lib/cloud').exists())
self.assertTrue((root / 'usr/local/lib/apex-provisioner/digest/engine/provisioning/target.py').is_file())
self.assertFalse((root / 'tmp/provisioning').exists())
self.assertTrue((root / 'usr/local/lib/apex-provisioner/digest/provisioning/target.py').is_file())
self.assertTrue((root / 'etc/systemd/system/ssh.service.d/10-generate-host-keys.conf').is_file())
@@ -6,9 +6,9 @@ import tempfile
import unittest
from unittest.mock import patch
from engine.provisioning.contract import Enrollment, Repository
from engine.provisioning.repository import reconcile_repository
from engine.provisioning.state import ProvisionError
from provisioning.contract import Enrollment, Repository
from provisioning.repository import reconcile_repository
from provisioning.state import ProvisionError
class RepositoryTests(unittest.TestCase):
@@ -32,7 +32,7 @@ class RepositoryTests(unittest.TestCase):
(self.commons / 'etc/.vimrc').write_text('set number\n')
self.git(['add', '.'], self.commons)
self.git(['commit', '-qm', 'initial'], self.commons)
(self.upstream / 'node.env').write_text('APEX_NODE_FQDN=metis.example.com\nAPEX_ENROLLMENT_INTERFACE=1\n')
(self.upstream / 'node.env').write_text('APEX_NODE_FQDN=node1.example.com\nAPEX_ENROLLMENT_INTERFACE=1\n')
(self.upstream / 'init.sh').write_text(
'#!/bin/sh\nset -eu\ngit config core.hooksPath commons/githooks\n'
'printf \'export PATH="%s/commons:$PATH"\\n\' "$PWD" > "$HOME/.bashrc"\n'
@@ -40,14 +40,14 @@ class RepositoryTests(unittest.TestCase):
self.git(['-c', 'protocol.file.allow=always', 'submodule', 'add', str(self.commons), 'commons'], self.upstream)
self.git(['add', '.'], self.upstream)
self.git(['commit', '-qm', 'initial'], self.upstream)
self.spec = Enrollment('metis.example.com', Repository('metis', str(self.upstream), None), (), (), ())
self.spec = Enrollment('node1.example.com', Repository('node1', str(self.upstream), None), (), (), ())
self.state = {}
self.saved = []
self.account = patch('engine.provisioning.repository.pwd.getpwnam',
self.account = patch('provisioning.repository.pwd.getpwnam',
return_value=SimpleNamespace(pw_uid=os.getuid(), pw_gid=os.getgid(), pw_dir=str(self.home)))
self.account.start()
self.addCleanup(self.account.stop)
self.runner = patch('engine.provisioning.repository.as_adam', side_effect=self.local_run)
self.runner = patch('provisioning.repository.as_adam', side_effect=self.local_run)
self.runner.start()
self.addCleanup(self.runner.stop)
@@ -71,19 +71,19 @@ class RepositoryTests(unittest.TestCase):
self.git(['commit', '-qm', 'advance'], self.upstream)
second = self.enroll()
self.assertEqual(first['commit'], second['commit'])
self.assertFalse((self.srv / 'metis' / 'new').exists())
self.assertFalse((self.srv / 'node1' / 'new').exists())
self.assertTrue(self.state['repository']['hook_complete'])
def test_local_changes_are_preserved_and_stop_initialization(self):
self.enroll()
path = self.srv / 'metis' / 'node.env'
path = self.srv / 'node1' / 'node.env'
path.write_text('local changes')
with self.assertRaisesRegex(ProvisionError, 'local changes'):
self.enroll()
self.assertEqual(path.read_text(), 'local changes')
def test_unrecorded_checkout_is_not_adopted(self):
(self.srv / 'metis').mkdir()
(self.srv / 'node1').mkdir()
with self.assertRaisesRegex(ProvisionError, 'adoption'):
self.enroll()
@@ -98,10 +98,10 @@ class RepositoryTests(unittest.TestCase):
def test_missing_or_unsupported_interface_stops_before_hook(self):
for version in ('', 'APEX_ENROLLMENT_INTERFACE=999\n'):
with self.subTest(version=version):
(self.upstream / 'node.env').write_text('APEX_NODE_FQDN=metis.example.com\n' + version)
(self.upstream / 'node.env').write_text('APEX_NODE_FQDN=node1.example.com\n' + version)
self.git(['add', '.'], self.upstream)
self.git(['commit', '-qm', 'unsupported interface'], self.upstream)
self.spec = Enrollment('metis.example.com', Repository('missing-' + str(len(version)), str(self.upstream), None), (), (), ())
self.spec = Enrollment('node1.example.com', Repository('missing-' + str(len(version)), str(self.upstream), None), (), (), ())
self.state = {}
with self.assertRaisesRegex(ProvisionError, 'enrollment interface'):
self.enroll()
@@ -110,7 +110,7 @@ class RepositoryTests(unittest.TestCase):
def test_retry_checks_hook_state_instead_of_completion_marker(self):
self.enroll()
self.git(['config', 'core.hooksPath', 'wrong-path'], self.srv / 'metis')
self.git(['config', 'core.hooksPath', 'wrong-path'], self.srv / 'node1')
with self.assertRaisesRegex(ProvisionError, 'hook configuration'):
self.enroll()
@@ -10,8 +10,8 @@ import tempfile
import unittest
from unittest.mock import patch
from engine.provisioning.access import Host
from engine.provisioning import security
from provisioning.access import Host
from provisioning import security
# Pinned helper default output, kept independent of the adapter's validation code.
@@ -97,7 +97,7 @@ class SecurityHost(Host):
path = self.path(name)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content)
self.conffiles = {'/etc/default/ufw': hashlib.md5(contents['/etc/default/ufw'].encode()).hexdigest()}
self.conffiles = {'/etc/default/ufw': hashlib.md5(contents['/etc/default/ufw'].encode(), usedforsecurity=False).hexdigest()}
self.bouncer_conffiles = {}
checksums = []
for name, content in contents.items():
@@ -108,7 +108,7 @@ class SecurityHost(Host):
path = self.path(template)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content)
checksums.append(hashlib.md5(content.encode()).hexdigest() + ' ' + template.lstrip('/'))
checksums.append(hashlib.md5(content.encode(), usedforsecurity=False).hexdigest() + ' ' + template.lstrip('/'))
manifest = self.path('/var/lib/dpkg/info/ufw.md5sums')
manifest.parent.mkdir(parents=True, exist_ok=True)
manifest.write_text('\n'.join(checksums) + '\n')
@@ -275,7 +275,7 @@ class SecurityTests(unittest.TestCase):
path = self.host.path(name)
path.parent.mkdir(parents=True)
path.write_text('[Service]\nExecStart=/usr/bin/crowdsec-firewall-bouncer\n')
self.host.bouncer_conffiles[name] = hashlib.md5(path.read_bytes()).hexdigest()
self.host.bouncer_conffiles[name] = hashlib.md5(path.read_bytes(), usedforsecurity=False).hexdigest()
self.host.services['crowdsec-firewall-bouncer']['FragmentPath'] = name
state = self.prepared()
self.adapter.activate(state, self.persist)
@@ -535,7 +535,7 @@ class SecurityTests(unittest.TestCase):
def test_disabled_ipv6_runtime_is_preserved_during_helper_cleanup(self):
path = self.host.path('/etc/default/ufw')
path.write_text(path.read_text().replace('IPV6=yes', 'IPV6=no'))
self.host.conffiles['/etc/default/ufw'] = hashlib.md5(path.read_bytes()).hexdigest()
self.host.conffiles['/etc/default/ufw'] = hashlib.md5(path.read_bytes(), usedforsecurity=False).hexdigest()
before = copy.deepcopy(self.host.runtime_chains)
state = self.prepared()
self.adapter.activate(state, self.persist)
@@ -828,7 +828,7 @@ class SecurityTests(unittest.TestCase):
def test_ipv6_peer_requires_ipv6_firewall_support(self):
path = self.host.path('/etc/default/ufw')
path.write_text(path.read_text().replace('IPV6=yes', 'IPV6=no'))
self.host.conffiles['/etc/default/ufw'] = hashlib.md5(path.read_bytes()).hexdigest()
self.host.conffiles['/etc/default/ufw'] = hashlib.md5(path.read_bytes(), usedforsecurity=False).hexdigest()
with self.assertRaises(security.SecurityError):
self.adapter.snapshot(self.adapter.preflight(), 22, '2001:db8::1')
@@ -2,8 +2,8 @@ import tempfile
import unittest
from pathlib import Path
from engine.provisioning.access import AccessError, Transition
from engine.provisioning.security import SecurityError
from provisioning.access import AccessError, Transition
from provisioning.security import SecurityError
from test_provision_access import FakeHost, TOKEN
@@ -4,7 +4,7 @@ from pathlib import Path
import tempfile
import unittest
from engine.provisioning.state import (Journal, ProvisionError, atomic_write,
from provisioning.state import (Journal, ProvisionError, atomic_write,
check_heartbeat, exclusive_lock)
@@ -37,12 +37,12 @@ class StateTests(unittest.TestCase):
def test_failed_stage_is_retryable_and_success_is_reverified(self):
path = self.root / 'journal'
record = Journal(path, {'hostname': 'metis'}, 'rev', 'digest')
record = Journal(path, {'hostname': 'node1'}, 'rev', 'digest')
with self.assertRaises(RuntimeError):
record.stage('package', lambda: (_ for _ in ()).throw(RuntimeError('SECRET')))
self.assertNotIn('SECRET', path.read_text())
self.assertEqual(json.loads(path.read_text())['failed_stage'], 'package')
record = Journal(path, {'hostname': 'metis'}, 'rev', 'digest')
record = Journal(path, {'hostname': 'node1'}, 'rev', 'digest')
results = iter(({'version': 'one'}, {'version': 'two'}))
record.stage('package', lambda: next(results))
record.stage('package', lambda: next(results))
@@ -50,21 +50,21 @@ class StateTests(unittest.TestCase):
def test_identity_or_bundle_change_cannot_retarget_partial_run(self):
path = self.root / 'journal'
Journal(path, {'hostname': 'metis'}, 'rev', 'digest').save()
for identity, digest in (({'hostname': 'other'}, 'digest'), ({'hostname': 'metis'}, 'other')):
Journal(path, {'hostname': 'node1'}, 'rev', 'digest').save()
for identity, digest in (({'hostname': 'other'}, 'digest'), ({'hostname': 'node1'}, 'other')):
with self.assertRaises(ProvisionError):
Journal(path, identity, 'rev', digest)
def test_unsupported_interface_cannot_resume_and_revisions_survive_retry(self):
path = self.root / 'journal'
Journal(path, {'hostname': 'metis'}, 'source-rev', 'digest').save()
resumed = Journal(path, {'hostname': 'metis'}, 'source-rev', 'digest')
Journal(path, {'hostname': 'node1'}, 'source-rev', 'digest').save()
resumed = Journal(path, {'hostname': 'node1'}, 'source-rev', 'digest')
self.assertEqual(resumed.data['baseline_revision'], 'source-rev')
for version in (None, 999):
resumed.data['enrollment_interface'] = version
resumed.save()
with self.assertRaisesRegex(ProvisionError, 'enrollment interface'):
Journal(path, {'hostname': 'metis'}, 'source-rev', 'digest')
Journal(path, {'hostname': 'node1'}, 'source-rev', 'digest')
def test_expired_heartbeat_prevents_next_operation(self):
beat = self.root / 'heartbeat'
@@ -8,9 +8,9 @@ from types import SimpleNamespace
import unittest
from unittest.mock import patch
from engine.provisioning.contract import Enrollment, Repository, Tier
from engine.provisioning import storage
from engine.provisioning.storage import StorageError, _persist_swap, reconcile_storage
from provisioning.contract import Enrollment, Repository, Tier
from provisioning import storage
from provisioning.storage import StorageError, _persist_swap, reconcile_storage
def enrollment(*tiers):
@@ -49,13 +49,13 @@ class TestStorage(unittest.TestCase):
self.create_hook = None
for name, value in [('SRV', self.srv), ('SWAPFILE', self.swap),
('FSTAB', self.fstab), ('MEMINFO', self.meminfo), ('SWAPS', self.swaps)]:
patcher = patch('engine.provisioning.storage.' + name, value, create=True)
patcher = patch('provisioning.storage.' + name, value, create=True)
patcher.start()
self.addCleanup(patcher.stop)
patcher = patch('engine.provisioning.storage.subprocess.run', side_effect=self.run_command)
patcher = patch('provisioning.storage.subprocess.run', side_effect=self.run_command)
patcher.start()
self.addCleanup(patcher.stop)
patcher = patch('engine.provisioning.storage._device_number', side_effect=self.device_number)
patcher = patch('provisioning.storage._device_number', side_effect=self.device_number)
patcher.start()
self.addCleanup(patcher.stop)
@@ -365,7 +365,7 @@ class TestStorage(unittest.TestCase):
def test_swap_skip_reports_low_space_and_unsupported_filesystems(self):
self.set_active_swaps([])
with patch('engine.provisioning.storage.os.statvfs', return_value=SimpleNamespace(f_bavail=1, f_frsize=4096)):
with patch('provisioning.storage.os.statvfs', return_value=SimpleNamespace(f_bavail=1, f_frsize=4096)):
result = reconcile_storage(enrollment(), self.state, self.save)
self.assertIn('insufficient', result['swap']['reason'])
self.mounts[0]['fstype'] = 'btrfs'
@@ -375,9 +375,9 @@ class TestStorage(unittest.TestCase):
def test_swap_sizes_allocation_and_records_verified_result(self):
self.set_active_swaps([])
with patch('engine.provisioning.storage.os.statvfs', return_value=SimpleNamespace(f_bavail=16, f_frsize=1024 ** 3)), \
patch('engine.provisioning.storage.os.posix_fallocate', create=True) as allocate, \
patch('engine.provisioning.storage._persist_swap') as persist:
with patch('provisioning.storage.os.statvfs', return_value=SimpleNamespace(f_bavail=16, f_frsize=1024 ** 3)), \
patch('provisioning.storage.os.posix_fallocate', create=True) as allocate, \
patch('provisioning.storage._persist_swap') as persist:
result = reconcile_storage(enrollment(), self.state, self.save)
self.assertEqual(allocate.call_args.args[1:], (0, 4 * 1024 ** 3))
self.assertEqual(result['swap'], {'status': 'complete', 'size': 4 * 1024 ** 3})
@@ -401,9 +401,9 @@ class TestStorage(unittest.TestCase):
def test_unobserved_swap_activation_is_not_persisted_or_reported_complete(self):
self.set_active_swaps([])
self.activate_swap = False
with patch('engine.provisioning.storage.os.statvfs', return_value=SimpleNamespace(f_bavail=16, f_frsize=1024 ** 3)), \
patch('engine.provisioning.storage.os.posix_fallocate', create=True), \
patch('engine.provisioning.storage._persist_swap') as persist:
with patch('provisioning.storage.os.statvfs', return_value=SimpleNamespace(f_bavail=16, f_frsize=1024 ** 3)), \
patch('provisioning.storage.os.posix_fallocate', create=True), \
patch('provisioning.storage._persist_swap') as persist:
result = reconcile_storage(enrollment(), self.state, self.save)
self.assertEqual(result['swap']['status'], 'skipped')
self.assertIn('activation did not become observable', result['swap']['reason'])
@@ -421,9 +421,9 @@ class TestStorage(unittest.TestCase):
def test_small_memory_swap_is_two_gib(self):
self.set_active_swaps([])
self.meminfo.write_text('MemTotal: 4194304 kB\n')
with patch('engine.provisioning.storage.os.statvfs', return_value=SimpleNamespace(f_bavail=16, f_frsize=1024 ** 3)), \
patch('engine.provisioning.storage.os.posix_fallocate', create=True) as allocate, \
patch('engine.provisioning.storage._persist_swap'):
with patch('provisioning.storage.os.statvfs', return_value=SimpleNamespace(f_bavail=16, f_frsize=1024 ** 3)), \
patch('provisioning.storage.os.posix_fallocate', create=True) as allocate, \
patch('provisioning.storage._persist_swap'):
result = reconcile_storage(enrollment(), self.state, self.save)
self.assertEqual(allocate.call_args.args[1:], (0, 2 * 1024 ** 3))
self.assertEqual(result['swap']['size'], 2 * 1024 ** 3)
@@ -439,8 +439,8 @@ class TestStorage(unittest.TestCase):
return os.stat_result(fields)
return info
with patch('engine.provisioning.storage.Path.lstat', root_owned), \
patch('engine.provisioning.storage.os.fchown'):
with patch('provisioning.storage.Path.lstat', root_owned), \
patch('provisioning.storage.os.fchown'):
_persist_swap()
_persist_swap()
self.assertEqual(self.fstab.read_text(), '# existing entries\n' +
@@ -8,10 +8,10 @@ import tempfile
import unittest
from unittest.mock import patch
from engine.provisioning import target
from engine.provisioning.contract import normalize_user_data
from engine.provisioning.state import ProvisionError, atomic_write, safe_path
from tests.test_provision_contract import example
from provisioning import target
from provisioning.contract import normalize_user_data
from provisioning.state import ProvisionError, atomic_write, safe_path
from test_provision_contract import example
class TargetTests(unittest.TestCase):
@@ -1,517 +0,0 @@
import io
import json
import os
from pathlib import Path
import shlex
import subprocess
import sys
import tarfile
import tempfile
import unittest
from unittest.mock import patch
from engine.provisioning.transport import (
SSH, TransportError, HandoverUncertain, build_bundle, destination, local_file, protected_handover,
initialize,
REMOTE_PATH_GUARD, remote_atomic_input,
)
class TransportTests(unittest.TestCase):
def test_durable_runner_checks_access_after_seed_before_enrollment(self):
for compatible in (False, True):
with self.subTest(compatible=compatible), tempfile.TemporaryDirectory() as directory:
root = Path(directory)
source = root / 'user-data'
source.write_bytes(b'opaque input')
stage = root / 'stage'
stage.mkdir()
installed = root / 'bundle'
seed = installed / 'engine/provisioning/seed.sh'
seed.parent.mkdir(parents=True)
seed.write_text('printf "seed\\n" >> "$APEX_TEST_EVENTS"\n')
binaries = root / 'bin'
binaries.mkdir()
python = binaries / 'python3'
python.write_text('#!' + sys.executable + '\n' + '''
import os, sys
module, operation = sys.argv[2:4]
event = 'access-' + operation if module.endswith('.access') else operation
with open(os.environ['APEX_TEST_EVENTS'], 'a') as stream:
stream.write(event + '\\n')
if event == 'access-preflight' and os.environ['APEX_TEST_COMPATIBLE'] == 'False':
sys.exit(42)
if event == 'enroll':
sys.exit(43)
''')
python.chmod(0o755)
events = root / 'events'
environment = dict(os.environ, PATH=str(binaries) + os.pathsep + os.environ['PATH'],
APEX_TEST_EVENTS=str(events), APEX_TEST_COMPATIBLE=str(compatible))
class Remote:
user, host, identity = 'root', 'example.com', None
resolved_port = 22
def __init__(self, *args):
pass
def resolve(self):
pass
def run(self, script, data=None, **kwargs):
value = b''
if 'ssh_host_' in script:
value = b'192.0.2.1 50000 192.0.2.2 22\nssh-ed25519 AAAA fixture\n'
elif 'mktemp -d /var/tmp/apex-init.' in script:
value = b'/var/tmp/apex-init.0123456789\n'
elif 'systemd-run --quiet' in script:
arguments = shlex.split(script)
wrapper = arguments[-1].replace('/usr/local/lib/apex-provisioner/' + 'a' * 64, str(installed))
wrapper = wrapper.replace('/var/tmp/apex-init.0123456789', str(stage))
wrapper = wrapper.replace('/etc/tmpfiles.d/', str(root) + '/')
subprocess.run(['/bin/sh', '-c', wrapper], env=environment,
capture_output=True, timeout=30)
elif script.startswith('if test -f /var/tmp'):
value = (stage / 'result').read_bytes()
return subprocess.CompletedProcess([], 0, value, b'')
with patch('engine.provisioning.transport.SSH', Remote), \
patch('engine.provisioning.transport.build_bundle', return_value=(b'bundle', 'a' * 64, 'b' * 40)), \
patch('engine.provisioning.transport.shutil.which', return_value='/usr/bin/ssh'):
with self.assertRaises(TransportError):
initialize(root, 'root@example.com', source, progress=lambda message: None)
expected = ['seed', 'validate', 'access-preflight']
if compatible:
expected.append('enroll')
self.assertEqual(events.read_text().splitlines(), expected)
self.assertEqual((stage / 'result').read_text().strip(), '43' if compatible else '42')
def test_rejects_options_shell_fragments_and_invalid_ports(self):
for value in ['root@-oProxyCommand=id', 'root@host;id', '-oProxyCommand=id@host',
'bob;id@host', 'bob name@host', 'bob\n@host', 'bob/other@host',
'bob$(id)@host', '@host', '9bob@host', 'a' * 33 + '@host',
'root@host\nfoo', 'host', 'debian@host -oProxyCommand=id']:
with self.subTest(value=value), self.assertRaises(TransportError):
destination(value)
for port in ['0', '65536', '-1', '22;id', '2.5', '']:
with self.subTest(port=port), self.assertRaises(TransportError):
destination('root@example.com', port)
self.assertEqual(destination('adam@node', 2222), ('adam', 'node', 2222))
def test_existing_bootstrap_accounts_are_accepted(self):
for user in ['root', 'adam', 'debian', 'bob', 'cloud-admin', '_admin', 'Admin.1']:
with self.subTest(user=user):
self.assertEqual(destination(user + '@example.com'), (user, 'example.com', None))
self.assertEqual(destination('debian@[2001:db8::1]', 22), ('debian', '2001:db8::1', 22))
def test_nonroot_bootstrap_uses_noninteractive_sudo_and_preserves_stdin(self):
calls = []
def runner(args, **kwargs):
calls.append((args, kwargs))
return subprocess.CompletedProcess(args, 0, b'root verified', b'')
ssh = SSH('debian@host', runner=runner)
self.assertEqual(ssh.run('id -u', b'opaque input').stdout, b'root verified')
args, kwargs = calls[0]
self.assertEqual(args[args.index('-l') + 1], 'debian')
self.assertEqual(shlex.split(args[-1]), ['sudo', '-n', '--', 'sh', '-c', 'id -u'])
self.assertEqual(kwargs['input'], b'opaque input')
ssh.run('printf %s "$SSH_CONNECTION"', privileged=False)
self.assertEqual(shlex.split(calls[1][0][-1])[:2], ['sh', '-c'])
def test_sudo_cleared_connection_environment_uses_unprivileged_fallback(self):
class PreflightComplete(Exception):
pass
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
source = root / 'user-data'
source.write_bytes(b'opaque input')
release = root / 'os-release'
release.write_text('ID=debian\nVERSION_ID=13\n')
systemd_run = root / 'systemd-run'
systemd_run.write_text('#!/bin/sh\nexit 0\n')
systemd_run.chmod(0o755)
environment = dict(os.environ, PATH=str(root) + os.pathsep + os.environ['PATH'])
environment.pop('SSH_CONNECTION', None)
fallbacks = []
def runner(args, **kwargs):
if '-G' in args:
return subprocess.CompletedProcess(args, 0, 'hostname 192.0.2.2\nport 2222\n', '')
command = shlex.split(args[-1])
script = command[-1]
if 'ssh_host_' in script:
self.assertEqual(command[:3], ['sudo', '-n', '--'])
tools = ('id() { printf 0; }; dpkg() { printf amd64; }; '
"cat() { printf 'ssh-ed25519 AAAA fixture\\n'; }; ")
script = script.replace('/etc/os-release', shlex.quote(str(release)))
result = subprocess.run(['sh', '-c', tools + script], capture_output=True, env=environment)
self.assertEqual(result.returncode, 0, result.stderr)
return result
if 'SSH_CONNECTION' in script:
self.assertEqual(command[:2], ['sh', '-c'])
fallbacks.append(script)
return subprocess.run(command, capture_output=True,
env=dict(environment, SSH_CONNECTION='192.0.2.1 50000 192.0.2.2 2222'))
self.assertIn('/var/lib/apex-init/remote.json', script)
self.assertIn('ssh-ed25519 AAAA', transport.trust_file.read_text())
raise PreflightComplete
transport = SSH('adam@example.com', runner=runner)
with patch('engine.provisioning.transport.SSH', return_value=transport), \
patch('engine.provisioning.transport.build_bundle', return_value=(b'bundle', 'a' * 64, 'b' * 40)), \
patch('engine.provisioning.transport.shutil.which', return_value='/usr/bin/ssh'):
with self.assertRaises(PreflightComplete):
initialize(root, 'adam@example.com', source, progress=lambda message: None)
self.assertEqual(len(fallbacks), 1)
def test_remote_script_quoting_transports_literal_values(self):
received = []
def runner(args, **kwargs):
command = shlex.split(args[-1])
self.assertEqual(command[:2], ['sh', '-c'])
result = subprocess.run(command, input=kwargs.get('input'), capture_output=True)
received.append(result.stdout)
return result
value = "spaces '$HOME' `echo unsafe`; $(echo unsafe)"
SSH('root@host', runner=runner).run('printf %s ' + shlex.quote(value))
self.assertEqual(received, [value.encode()])
def test_secret_bytes_go_over_stdin_and_diagnostics_are_redacted(self):
secret = b'private key material'
def runner(args, **kwargs):
self.assertEqual(kwargs['input'], secret)
self.assertNotIn(secret.decode(), repr(args))
return subprocess.CompletedProcess(args, 1, b'', secret)
with self.assertRaises(TransportError) as caught:
SSH('root@host', runner=runner).run('cat >/dev/null', secret)
self.assertNotIn(secret.decode(), str(caught.exception))
def test_fresh_login_disables_mux_password_and_pins_verified_identity(self):
calls = []
def runner(args, **kwargs):
calls.append(args)
return subprocess.CompletedProcess(args, 0, b'', b'')
ssh = SSH('root@original-alias', runner=runner)
ssh.resolved_host = '192.0.2.42'
ssh.trust_file = Path('/tmp/trusted')
ssh.fresh_login()
args = calls[0]
self.assertIn('-T', args)
for option in ('ControlMaster=no', 'ControlPath=none', 'BatchMode=yes',
'PreferredAuthentications=publickey', 'StrictHostKeyChecking=yes',
'Hostname=192.0.2.42', 'HostKeyAlias=apex-init-verified-target'):
self.assertIn(option, args)
self.assertEqual(args[args.index('-l') + 1], 'adam')
self.assertEqual(args[args.index('-p') + 1], '2222')
def test_failed_first_fresh_login_rolls_back_without_finalizing(self):
calls = []
class FailedSSH:
def fresh_login(self):
raise TransportError('key rejected')
def invoke(operation, *args):
calls.append(operation)
return {'status': 'transition'}
with self.assertRaises(TransportError):
protected_handover(FailedSSH(), invoke, 'a' * 32, 22, '192.0.2.1')
self.assertEqual(calls, ['begin', 'rollback'])
def test_failed_second_fresh_login_never_commits(self):
calls = []
class SecondFailure:
count = 0
def fresh_login(self):
self.count += 1
if self.count == 2:
raise TransportError('final policy rejected login')
def invoke(operation, *args):
calls.append(operation)
return {'status': 'transition'}
with self.assertRaises(TransportError):
protected_handover(SecondFailure(), invoke, 'a' * 32, 22, '192.0.2.1')
self.assertEqual(calls, ['begin', 'finalize', 'rollback'])
def test_two_successful_fresh_logins_precede_commit(self):
calls = []
class VerifiedSSH:
def fresh_login(self):
calls.append('fresh')
def invoke(operation, *args):
calls.append(operation)
return {'status': 'transition'}
protected_handover(VerifiedSSH(), invoke, 'a' * 32, 22, '192.0.2.1')
self.assertEqual(calls, ['begin', 'fresh', 'finalize', 'fresh', 'commit'])
def test_lost_commit_reply_is_reconciled_without_rolling_back_committed_access(self):
from engine.provisioning.access import Transition
from tests.test_provision_access import FakeHost
with tempfile.TemporaryDirectory() as directory:
host = FakeHost(Path(directory))
transition = Transition(host, now=lambda: 1000, security=host.security)
calls = []
class VerifiedSSH:
def fresh_login(self):
pass
def invoke(operation, token, port, address, fresh):
calls.append(operation)
if operation == 'status':
self.assertTrue(fresh)
return transition.load()
result = transition.execute(operation, token, port, address)
if operation == 'commit':
raise TransportError('SSH reply lost')
return result
protected_handover(VerifiedSSH(), invoke, 'a' * 32, 22, '192.0.2.1')
self.assertEqual(transition.load()['status'], 'committed')
self.assertEqual(host.ports, [2222])
self.assertFalse(host.armed)
self.assertEqual(host.restored, 0)
self.assertEqual(calls, ['begin', 'finalize', 'commit', 'status'])
def test_known_uncommitted_lost_reply_still_rolls_back(self):
calls = []
class VerifiedSSH:
def fresh_login(self):
pass
def invoke(operation, *args):
calls.append(operation)
if operation == 'commit':
raise TransportError('SSH failed before commit')
return {'status': 'final' if operation == 'status' else 'transition', 'token': 'a' * 32}
with self.assertRaises(TransportError):
protected_handover(VerifiedSSH(), invoke, 'a' * 32, 22, '192.0.2.1')
self.assertEqual(calls, ['begin', 'finalize', 'commit', 'status', 'rollback'])
def test_lost_commit_reply_reports_verified_or_uncertain_resume_endpoints(self):
from engine.provisioning.access import Transition
from tests.test_provision_access import FakeHost
for unreachable in (False, True):
with self.subTest(unreachable=unreachable), tempfile.TemporaryDirectory() as directory:
root = Path(directory)
source = root / 'user-data'
source.write_bytes(b'opaque input')
host = FakeHost(root)
transition = Transition(host, now=lambda: 1000, security=host.security)
messages, commands = [], []
def runner(args, **kwargs):
if '-G' in args:
return subprocess.CompletedProcess(args, 0, 'hostname 192.0.2.2\nport 22\n', '')
command = shlex.split(args[-1])
script = command[-1]
commands.append(script)
value = b''
if 'ssh_host_' in script:
value = b'192.0.2.1 50000 192.0.2.2 22\nssh-ed25519 AAAA fixture\n'
elif 'mktemp -d /var/tmp/apex-init.' in script:
value = b'/var/tmp/apex-init.0123456789\n'
elif script.startswith('if test -f /var/tmp'):
value = b'0'
elif script.startswith('cd ') and 'engine.provisioning.access' in script:
access_args = shlex.split(script)[3:]
operation = access_args[3]
token = access_args[access_args.index('--token') + 1]
port = int(access_args[access_args.index('--original-port') + 1]) if '--original-port' in access_args else None
address = access_args[access_args.index('--client-address') + 1] if port else None
value = json.dumps(transition.execute(operation, token, port, address)).encode()
if operation == 'commit':
return subprocess.CompletedProcess(args, 255, b'', b'connection lost')
elif 'cat /var/lib/apex-init/access/state.json' in script:
self.assertEqual(args[args.index('-l') + 1], 'adam')
self.assertEqual(args[args.index('-p') + 1], '2222')
self.assertEqual(command[:3], ['sudo', '-n', '--'])
if unreachable:
return subprocess.CompletedProcess(args, 255, b'', b'connection lost')
value = json.dumps(transition.load()).encode()
elif 'engine.provisioning.target complete' in script:
value = b'{"status": "complete"}'
return subprocess.CompletedProcess(args, 0, value, b'')
def transport(*args):
return SSH(*args, runner=runner)
with patch('engine.provisioning.transport.SSH', side_effect=transport), \
patch('engine.provisioning.transport.build_bundle', return_value=(b'bundle', 'a' * 64, 'b' * 40)), \
patch('engine.provisioning.transport.shutil.which', return_value='/usr/bin/ssh'):
if unreachable:
with self.assertRaises(HandoverUncertain):
initialize(root, 'debian@example.com', source, progress=messages.append)
self.assertTrue(any('outcome is unknown' in message for message in messages))
self.assertIn('adam@example.com --port 2222', messages[-2])
self.assertIn('debian@example.com --port 22', messages[-1])
self.assertFalse(any('Safe resume' in message or 'pending rollback' in message for message in messages))
else:
self.assertEqual(initialize(root, 'debian@example.com', source, progress=messages.append), {'status': 'complete'})
self.assertIn('Safe resume: ./apex init adam@example.com --port 2222', messages[-1])
self.assertEqual(transition.load()['status'], 'committed')
self.assertFalse(host.armed)
self.assertFalse(any('access rollback' in script for script in commands))
def test_bundle_excludes_repository_and_environment_secrets(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
(root / 'engine/provisioning').mkdir(parents=True)
(root / 'engine/__init__.py').write_text('')
(root / 'engine/provisioning/seed.sh').write_text('#!/bin/sh\n')
(root / '.env').write_text('private secret')
(root / 'engine/private.env').write_text('private secret')
data, digest, revision = build_bundle(root)
with tarfile.open(fileobj=io.BytesIO(data)) as archive:
self.assertEqual(set(archive.getnames()), {'engine/__init__.py', 'engine/provisioning/seed.sh'})
self.assertNotIn(b'private secret', data)
self.assertEqual(build_bundle(root)[1], digest)
def test_missing_input_is_a_safe_validation_error(self):
with self.assertRaises(TransportError):
local_file('/nonexistent/apex-user-data', 'User-data')
def test_init_from_node_checkout_does_not_resolve_local_identity(self):
from engine import cli
root = Path(__file__).resolve().parents[1]
with patch.object(cli, '_resolve_layout', return_value=(str(root), str(root), None, True)), \
patch.object(cli, 'resolve', side_effect=AssertionError('local identity used')):
with self.assertRaises(SystemExit) as caught:
cli.main(['init', '--help'])
self.assertEqual(caught.exception.code, 0)
def test_dead_enrollment_unit_stops_polling_and_removes_input(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
source = root / 'user-data'
source.write_bytes(b'opaque input')
calls = []
class Remote:
user, host, identity = 'root', 'example.com', None
def __init__(self, *args):
self.resolved_port = 22
def resolve(self):
pass
def run(self, script, data=None, **kwargs):
calls.append(script)
if 'ssh_host_' in script:
value = b'192.0.2.1 50000 192.0.2.2 22\nssh-ed25519 AAAA fixture\n'
elif 'mktemp -d /var/tmp/apex-init.' in script:
value = b'/var/tmp/apex-init.0123456789\n'
elif script.startswith('if test -f /var/tmp'):
value = b'stopped'
else:
value = b''
return subprocess.CompletedProcess([], 0, value, b'')
messages = []
with patch('engine.provisioning.transport.SSH', Remote), \
patch('engine.provisioning.transport.build_bundle', return_value=(b'bundle', 'a' * 64, 'b' * 40)), \
patch('engine.provisioning.transport.shutil.which', return_value='/usr/bin/ssh'):
with self.assertRaises(TransportError) as caught:
initialize(root, 'root@example.com', source, progress=messages.append)
self.assertIn('/runner.log', str(caught.exception))
self.assertTrue(any(script.startswith('rm -f ') and '/user-data ' in script for script in calls))
self.assertIn('root@example.com --port 22', messages[-1])
def test_completion_failure_keeps_administrator_resume_endpoint(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
source = root / 'user-data'
source.write_bytes(b'opaque input')
class Remote:
user, host, identity = 'root', 'example.com', None
def __init__(self, *args):
self.resolved_port = 22
def resolve(self):
pass
def run(self, script, data=None, **kwargs):
if 'ssh_host_' in script:
value = b'192.0.2.1 50000 192.0.2.2 22\nssh-ed25519 AAAA fixture\n'
elif 'mktemp -d /var/tmp/apex-init.' in script:
value = b'/var/tmp/apex-init.0123456789\n'
elif script.startswith('if test -f /var/tmp'):
value = b'0'
elif 'engine.provisioning.target complete' in script:
raise TransportError('record failed')
else:
value = b''
return subprocess.CompletedProcess([], 0, value, b'')
messages = []
with patch('engine.provisioning.transport.SSH', Remote), \
patch('engine.provisioning.transport.build_bundle', return_value=(b'bundle', 'a' * 64, 'b' * 40)), \
patch('engine.provisioning.transport.protected_handover'), \
patch('engine.provisioning.transport.shutil.which', return_value='/usr/bin/ssh'):
with self.assertRaises(TransportError):
initialize(root, 'root@example.com', source, progress=messages.append)
self.assertIn('adam@example.com --port 2222', messages[-1])
class TargetPathTests(unittest.TestCase):
"""Run the real shell guards with only Debian stat/mv translated on macOS."""
def setUp(self):
temporary = tempfile.TemporaryDirectory()
self.addCleanup(temporary.cleanup)
self.root = Path(temporary.name).resolve()
self.bin = self.root / 'bin'
self.bin.mkdir()
(self.bin / 'stat').write_text('#!' + sys.executable + '\n' + '''
import os, stat, sys
path = sys.argv[-1]
info = os.lstat(path)
field = sys.argv[sys.argv.index('-c') + 1]
values = {'%a': format(stat.S_IMODE(info.st_mode), 'o'), '%h': str(info.st_nlink),
'%u': '1000' if path == os.environ.get('TEST_NONROOT_PATH') else '0'}
print(values[field])
''')
(self.bin / 'mv').write_text('#!' + sys.executable + '\nimport os, sys\nos.replace(sys.argv[-2], sys.argv[-1])\n')
for command in self.bin.iterdir():
command.chmod(0o755)
self.environment = dict(os.environ, PATH=str(self.bin) + os.pathsep + os.environ['PATH'])
def write(self, path, data=b'new metadata'):
return subprocess.run(['sh', '-c', remote_atomic_input(str(path))], input=data,
capture_output=True, env=self.environment)
def test_atomic_write_ignores_predictable_old_temporary_symlink(self):
destination = self.root / 'remote.json'
victim = self.root / 'victim'
victim.write_bytes(b'preserve me')
destination.with_name('remote.json.new').symlink_to(victim)
result = self.write(destination)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(destination.read_bytes(), b'new metadata')
self.assertEqual(victim.read_bytes(), b'preserve me')
self.assertEqual(destination.stat().st_mode & 0o777, 0o600)
self.assertFalse(list(self.root.glob('.apex-write.*')))
def test_destination_symlink_is_rejected_without_touching_victim(self):
victim = self.root / 'victim'
victim.write_bytes(b'preserve me')
destination = self.root / 'remote.json'
destination.symlink_to(victim)
result = self.write(destination)
self.assertNotEqual(result.returncode, 0)
self.assertEqual(victim.read_bytes(), b'preserve me')
def test_redirected_parent_is_rejected_before_creating_files(self):
victim = self.root / 'victim'
victim.mkdir()
redirected = self.root / 'apex-init'
redirected.symlink_to(victim, target_is_directory=True)
result = self.write(redirected / 'remote.json')
self.assertNotEqual(result.returncode, 0)
self.assertEqual(list(victim.iterdir()), [])
def test_unowned_parent_is_rejected_before_creating_files(self):
state = self.root / 'apex-init'
state.mkdir()
self.environment['TEST_NONROOT_PATH'] = str(state)
result = self.write(state / 'remote.json')
self.assertNotEqual(result.returncode, 0)
self.assertEqual(list(state.iterdir()), [])
def test_writable_ancestor_is_rejected_before_creating_files(self):
state = self.root / 'apex-init'
state.mkdir(mode=0o777)
state.chmod(0o777)
result = self.write(state / 'remote.json')
self.assertNotEqual(result.returncode, 0)
self.assertEqual(list(state.iterdir()), [])
def test_hardlinked_destination_is_rejected_without_touching_victim(self):
victim = self.root / 'victim'
victim.write_bytes(b'preserve me')
destination = self.root / 'remote.json'
os.link(victim, destination)
result = self.write(destination)
self.assertNotEqual(result.returncode, 0)
self.assertEqual(victim.read_bytes(), b'preserve me')
if __name__ == '__main__':
unittest.main()
@@ -1,8 +1,8 @@
import importlib.util
import unittest
from engine.provisioning.contract import ContractError
from engine.provisioning.userdata import decode_user_data
from provisioning.contract import ContractError
from provisioning.userdata import decode_user_data
@unittest.skipUnless(importlib.util.find_spec('yaml'), 'target-only python3-yaml is unavailable')
@@ -21,9 +21,9 @@ class UserDataTests(unittest.TestCase):
def test_normal_supported_document(self):
import yaml
from tests.test_provision_contract import example
from test_provision_contract import example
spec = decode_user_data(yaml.safe_dump(example()).encode())
self.assertEqual(spec.hostname, 'metis.a1.example.com')
self.assertEqual(spec.hostname, 'node1.a1.example.com')
def test_yaml_boolean_mapping_key_rejected(self):
with self.assertRaisesRegex(ContractError, 'unique strings'):
+9 -15
View File
@@ -1,5 +1,5 @@
# commons/compositions/apex/docker-compose.yml
# The shared apex CORE composition. Consumed by each node via include: (SP3).
# The shared apex CORE composition. Available to node compositions through include:.
# FULLY ${env}-parameterized; NO './' host paths (relative paths in an included file
# resolve against THIS dir — always use absolute ${APEX_*} vars). The engine exports
# APEX_TIER*/APEX_TIER*_SHARED/APEX_TIER_ROOT*/APEX_SUBNET/APEX_NODE_FQDN at invoke.
@@ -18,7 +18,7 @@ services:
network_mode: "service:traefik"
restart: unless-stopped
labels:
# backup (resticontainer, core/all-nodes): adguard config only (work data regenerable)
# backup (resticontainer): adguard config only (work data regenerable)
- "restic.enable=true"
- "restic.backup.paths=/opt/adguardhome/conf"
cap_add:
@@ -30,12 +30,8 @@ services:
- no-new-privileges:true
# --- optional: observability agent (profile-gated) ---
# Grafana Alloy: one agent per node. Collects host metrics (node_exporter-
# equivalent) + container metrics (cadvisor) and PUSHES them via remote_write;
# collects systemd-journal + docker logs and PUSHES them to Loki. All targets
# are OUTBOUND to a central Prometheus + Loki (hosted on whichever node runs them), so
# Alloy needs egress (direct, not enclave). Replaces the old store-only `loki`
# service (which had no feeder). Config is a committed, env-parameterized asset.
# Collect host/container metrics and logs for configured remote endpoints.
# Alloy requires outbound access to those endpoints through the direct network.
alloy:
image: grafana/alloy:v1.20.0
container_name: apex-alloy
@@ -86,8 +82,7 @@ services:
# container mounts to host paths (via the /srv mount), snapshots the union
# in a single restic run, then restarts + runs post-hooks. Every other subcommand
# (init/snapshots/forget/…) passes straight through to restic. Needs the docker
# socket (list/inspect/exec/stop/start) and /srv read-only (the fleet's data root)
# — a privileged manual one-shot, same trust surface as the old restic core.
# socket (list/inspect/exec/stop/start) and read-only access to /srv.
resticontainer:
image: ghcr.io/deytenit/resticontainer:0.19.1@sha256:85457ee665b1c6c8438ac3494c286d267169ee023857da3a605a6fe16dab3d82
container_name: apex-resticontainer
@@ -112,9 +107,8 @@ services:
# for any other S3-compatible store (Yandex, Backblaze B2, MinIO, …).
AWS_REGION: "${APEX_RESTIC_AWS_REGION:-us-east-1}"
AWS_ENDPOINT_URL: "${APEX_RESTIC_AWS_ENDPOINT:-}"
# max compression is fleet policy (backup/run also forces --compression max on
# the CLI; needs repo format v2, which a fresh restic init creates). Per-node
# override via APEX_RESTIC_COMPRESSION only if ever needed.
# Compression requires repository format v2. The default is max;
# override it with APEX_RESTIC_COMPRESSION for direct container use.
RESTIC_COMPRESSION: "${APEX_RESTIC_COMPRESSION:-max}"
RESTIC_CACHE_DIR: /var/restic-cache
network_mode: "host"
@@ -195,7 +189,7 @@ services:
- "traefik.http.middlewares.apex-adguard-strip-prefix.stripprefix.prefixes=/adguard"
- "traefik.http.routers.apex-adguard.service=apex-adguard"
- "traefik.http.services.apex-adguard.loadbalancer.server.port=3000"
# backup (resticontainer, core/all-nodes): dynamic routing rules only
# backup (resticontainer): dynamic routing rules only
# (letsencrypt certs are regenerable via ACME/Cloudflare DNS-01)
- "restic.enable=true"
- "restic.backup.paths=/etc/traefik/dynamic"
@@ -218,7 +212,7 @@ services:
command: [ "-c", "/etc/xray/config.jsonc" ]
restart: unless-stopped
labels:
# backup (resticontainer, core/all-nodes): xray config only (logs + shm are ephemeral)
# backup (resticontainer): xray config only (logs + shm are ephemeral)
- "restic.enable=true"
- "restic.backup.paths=/etc/xray/config.jsonc"
user: "0:0"
+1 -1
View File
@@ -1,5 +1,5 @@
# engine/context.py
"""Context (ctx) — the single seam between an action and the world (SP2 §2.1)."""
"""Context and filesystem paths available to actions."""
from __future__ import annotations
import os
+66 -22
View File
@@ -6,10 +6,10 @@ Ports root_template_crontab (install + rollback) and the ufw block-injection log
"""
from __future__ import annotations
import inspect
import json
import os
import shutil
import subprocess
import tempfile
import time
from typing import List, Optional, Sequence, Tuple
@@ -55,11 +55,73 @@ def apply_block(text: str, content: str, start: str, end: str,
return "\n".join(result) + "\n", inserted
def _atomic_write(path, content, mode):
"""Self-contained so sudo executes the same operation without importing user code."""
import os
import stat
import tempfile
target = os.path.realpath(path)
parent = os.path.dirname(target)
try:
previous = os.stat(target)
except FileNotFoundError:
previous = None
if previous is not None and not stat.S_ISREG(previous.st_mode):
raise ValueError(f"Not a regular file: {path}")
target_mode = (int(mode, 8) if mode is not None else
stat.S_IMODE(previous.st_mode) if previous is not None else 0o644)
os.makedirs(parent, exist_ok=True)
fd, temporary = tempfile.mkstemp(prefix=".apex-", dir=parent)
try:
with os.fdopen(fd, "w", encoding="utf-8", newline="") as stream:
stream.write(content)
stream.flush()
if previous is not None:
current = os.fstat(stream.fileno())
if (current.st_uid, current.st_gid) != (previous.st_uid, previous.st_gid):
os.fchown(stream.fileno(), previous.st_uid, previous.st_gid)
os.fchmod(stream.fileno(), target_mode)
os.fsync(stream.fileno())
os.replace(temporary, target)
finally:
if os.path.exists(temporary):
os.unlink(temporary)
def _atomic_backup(path, destination):
import os
import shutil
import tempfile
destination = os.path.abspath(destination)
fd, temporary = tempfile.mkstemp(prefix=".apex-", dir=os.path.dirname(destination))
os.close(fd)
try:
shutil.copyfile(path, temporary)
previous, current = os.stat(path), os.stat(temporary)
if (current.st_uid, current.st_gid) != (previous.st_uid, previous.st_gid):
os.chown(temporary, previous.st_uid, previous.st_gid)
shutil.copystat(path, temporary)
os.replace(temporary, destination)
finally:
if os.path.exists(temporary):
os.unlink(temporary)
class Host:
def __init__(self, log, sys_):
self.log = log
self.sys = sys_
def _file_operation(self, operation, args, sudo):
if sudo:
script = (inspect.getsource(operation) + "\nimport json, sys\n" +
f"{operation.__name__}(*json.load(sys.stdin))\n")
self.sys.sudo(["python3", "-I", "-c", script], input=json.dumps(args))
else:
operation(*args)
# --- read/write with sudo awareness ---
def _read(self, path: str, sudo: bool = False) -> str:
if sudo:
@@ -74,10 +136,7 @@ class Host:
if not exists:
return None
bkp = f"{path}.{int(time.time())}.bak"
if sudo:
self.sys.sudo(["cp", "-a", path, bkp])
else:
shutil.copy2(path, bkp)
self._file_operation(_atomic_backup, [path, bkp], sudo)
self.log.info(f"Backed up {path} -> {bkp}")
return bkp
@@ -88,22 +147,7 @@ class Host:
return
if backup:
self.backup(path, sudo=sudo)
fd, tmp_name = tempfile.mkstemp(prefix="apex-")
try:
with os.fdopen(fd, "w", encoding="utf-8") as tmp:
tmp.write(content)
target_mode = mode or "0644"
if sudo:
self.sys.sudo(["mkdir", "-p", os.path.dirname(path)])
self.sys.sudo(["cp", tmp_name, path])
self.sys.sudo(["chmod", target_mode, path])
else:
os.makedirs(os.path.dirname(path), exist_ok=True)
shutil.copyfile(tmp_name, path)
os.chmod(path, int(target_mode, 8))
finally:
if os.path.exists(tmp_name):
os.unlink(tmp_name)
self._file_operation(_atomic_write, [path, content, mode], sudo)
self.log.success(f"Wrote {path}")
def inject_block(self, target: str, content: str, start: str, end: str,
+1 -1
View File
@@ -73,7 +73,7 @@ class Notify:
data = urllib.parse.urlencode({"parse_mode": "HTML", "text": text}).encode()
self.log.info(f"Sending Telegram notification ({level}): {title}")
try:
with urllib.request.urlopen(urllib.request.Request(bot_url, data=data),
with urllib.request.urlopen(urllib.request.Request(bot_url, data=data), # noqa: S310 -- HTTPS validated above.
timeout=NOTIFY_TIMEOUT) as r:
r.read()
return True
+3 -4
View File
@@ -1,9 +1,8 @@
# engine/lib/tpl.py
"""ctx.tpl — stdlib string.Template rendering (envsubst successor, SP2 decision #3).
"""ctx.tpl — stdlib string.Template rendering.
safe_substitute replaces known $VAR/${VAR} and leaves unknown ones literal (deliberate
delta: envsubst emptied them — literal survivors surface typos instead of silently
blanking). Shell $(...) command-substitution survives untouched.
safe_substitute replaces known $VAR/${VAR} and leaves unknown ones literal.
Shell $(...) command-substitution survives untouched.
Loops/conditionals live in the python action, never in the template file.
"""
from __future__ import annotations
+3 -4
View File
@@ -1,9 +1,8 @@
# engine/lib/ufw.py
"""ctx.ufw — ufw + ufw-docker rule application with self-healing marker migration.
"""Apply ufw and ufw-docker rules.
Injects the APEX marker block into before/after.rules, deleting BOTH the old ROOT
and the new APEX blocks first (so re-running after cutover migrates the marker in
place — SP2 §6). Docker rules go through ufw-docker; host rules through plain ufw.
Replace existing supported marker blocks before inserting current rules.
Docker rules go through ufw-docker; host rules through plain ufw.
"""
from __future__ import annotations
+6 -15
View File
@@ -1,13 +1,6 @@
// ============================================================================
// APEX fleet observability agent — per-node Grafana Alloy config (shared).
// Committed, env-parameterized: every node runs this SAME file; per-node values
// come from sys.env(...) (APEX_NODE_HOST, push URLs, obs basic-auth creds).
// Verified against grafana.com/docs/alloy/latest (Alloy v1.17.1).
// ============================================================================
// Model: PUSH. Metrics -> remote_write to a central Prometheus; logs -> loki.write
// to a central Loki. Push endpoints + basic-auth are per-node env
// (APEX_PROM_PUSH_URL, APEX_LOKI_PUSH_URL, APEX_OBS_BASIC_AUTH_USER/PASS).
// ============================================================================
// Optional shared Grafana Alloy configuration.
// Metrics and logs use the endpoints and credentials supplied through
// APEX_PROM_PUSH_URL, APEX_LOKI_PUSH_URL, and APEX_OBS_BASIC_AUTH_USER/PASS.
// ----------------------------------------------------------------------------
// METRICS
@@ -81,10 +74,8 @@ prometheus.scrape "containers" {
scrape_interval = "30s"
}
// CrowdSec agent metrics. CrowdSec runs on the host (not a container) and exposes Prometheus
// metrics on :6060; nodes that run it bind that endpoint on 0.0.0.0 (the host firewall keeps it
// off the public interface) so Alloy can reach it via the docker gateway. Nodes that don't run
// CrowdSec simply report this target down.
// The CrowdSec scrape requires a reachable host metrics endpoint on port 6060.
// If no endpoint is configured, this target reports down.
prometheus.scrape "crowdsec" {
targets = [{
__address__ = "host.docker.internal:6060",
@@ -96,7 +87,7 @@ prometheus.scrape "crowdsec" {
scrape_interval = "30s"
}
// Push metrics to the central Prometheus (needs --web.enable-remote-write-receiver).
// The configured Prometheus-compatible endpoint must accept remote writes.
prometheus.remote_write "default" {
endpoint {
url = sys.env("APEX_PROM_PUSH_URL")
+102
View File
@@ -0,0 +1,102 @@
// Shared native Grafana Alloy configuration for the physical host.
// Metrics and logs use the endpoints and credentials supplied through
// APEX_PROM_PUSH_URL, APEX_LOKI_PUSH_URL, and APEX_OBS_BASIC_AUTH_USER/PASS.
// ----------------------------------------------------------------------------
// METRICS
// ----------------------------------------------------------------------------
// Native filesystem views; physical capacity is collected on the Proxmox host.
prometheus.exporter.unix "node" {
enable_collectors = ["textfile"]
textfile {
directory = "/var/lib/alloy/textfile"
}
procfs_path = "/proc"
sysfs_path = "/sys"
rootfs_path = "/"
}
// Container metrics (cadvisor-equivalent) via the docker socket.
// Stamp a static `node` label onto each exporter's targets.
discovery.relabel "node_metrics" {
targets = prometheus.exporter.unix.node.targets
rule {
action = "replace"
target_label = "node"
replacement = sys.env("APEX_NODE_HOST")
}
// Stamp a stable `instance` (the node's public host) instead of the default,
// which is the Alloy container id/address.
rule {
action = "replace"
target_label = "instance"
replacement = sys.env("APEX_NODE_HOST")
}
}
prometheus.scrape "node" {
targets = discovery.relabel.node_metrics.output
forward_to = [prometheus.remote_write.default.receiver]
scrape_interval = "30s"
}
// The configured Prometheus-compatible endpoint must accept remote writes.
prometheus.remote_write "default" {
endpoint {
url = sys.env("APEX_PROM_PUSH_URL")
basic_auth {
username = sys.env("APEX_OBS_BASIC_AUTH_USER")
password = sys.env("APEX_OBS_BASIC_AUTH_PASS")
}
queue_config {
capacity = 5000
max_shards = 10
min_shards = 1
max_samples_per_send = 1000
batch_send_deadline = "5s"
min_backoff = "100ms"
max_backoff = "10s"
}
}
wal {
truncate_frequency = "30m"
min_keepalive_time = "5m"
max_keepalive_time = "30m"
}
}
// ----------------------------------------------------------------------------
// LOGS
// ----------------------------------------------------------------------------
// systemd journal (host services, ufw, sshd, crowdsec, etc.).
loki.source.journal "systemd" {
path = "/var/log/journal"
max_age = "12h"
labels = {
node = sys.env("APEX_NODE_HOST"),
job = "systemd-journal",
}
forward_to = [loki.write.default.receiver]
}
// All docker containers' stdout/stderr.
// Push logs to the central Loki.
loki.write "default" {
endpoint {
url = sys.env("APEX_LOKI_PUSH_URL")
basic_auth {
username = sys.env("APEX_OBS_BASIC_AUTH_USER")
password = sys.env("APEX_OBS_BASIC_AUTH_PASS")
}
}
}
+128
View File
@@ -0,0 +1,128 @@
// Shared native Grafana Alloy configuration for Docker guests.
// Metrics and logs use the endpoints and credentials supplied through
// APEX_PROM_PUSH_URL, APEX_LOKI_PUSH_URL, and APEX_OBS_BASIC_AUTH_USER/PASS.
// ----------------------------------------------------------------------------
// METRICS
// ----------------------------------------------------------------------------
// Native filesystem views; physical capacity is collected on the Proxmox host.
// Container metrics (cadvisor-equivalent) via the docker socket.
prometheus.exporter.cadvisor "containers" {
docker_host = "unix:///var/run/docker.sock"
store_container_labels = false
allowlisted_container_labels = [
"com.docker.compose.project",
"com.docker.compose.service",
]
docker_only = true
enabled_metrics = [
"cpu",
"cpuLoad",
"diskIO",
"memory",
"network",
"oom_event",
]
}
// Stamp a static `node` label onto each exporter's targets.
discovery.relabel "container_metrics" {
targets = prometheus.exporter.cadvisor.containers.targets
rule {
action = "replace"
target_label = "node"
replacement = sys.env("APEX_NODE_HOST")
}
rule {
action = "replace"
target_label = "instance"
replacement = sys.env("APEX_NODE_HOST")
}
}
prometheus.scrape "containers" {
targets = discovery.relabel.container_metrics.output
forward_to = [prometheus.remote_write.default.receiver]
scrape_interval = "30s"
}
// The configured Prometheus-compatible endpoint must accept remote writes.
prometheus.remote_write "default" {
endpoint {
url = sys.env("APEX_PROM_PUSH_URL")
basic_auth {
username = sys.env("APEX_OBS_BASIC_AUTH_USER")
password = sys.env("APEX_OBS_BASIC_AUTH_PASS")
}
queue_config {
capacity = 5000
max_shards = 10
min_shards = 1
max_samples_per_send = 1000
batch_send_deadline = "5s"
min_backoff = "100ms"
max_backoff = "10s"
}
}
wal {
truncate_frequency = "30m"
min_keepalive_time = "5m"
max_keepalive_time = "30m"
}
}
// ----------------------------------------------------------------------------
// LOGS
// ----------------------------------------------------------------------------
// systemd journal (host services, ufw, sshd, crowdsec, etc.).
loki.source.journal "systemd" {
path = "/var/log/journal"
max_age = "12h"
labels = {
node = sys.env("APEX_NODE_HOST"),
job = "systemd-journal",
}
forward_to = [loki.write.default.receiver]
}
// All docker containers' stdout/stderr.
discovery.docker "containers" {
host = "unix:///var/run/docker.sock"
}
discovery.relabel "container_logs" {
targets = discovery.docker.containers.targets
rule {
action = "replace"
source_labels = ["__meta_docker_container_name"]
regex = "/?(.*)"
target_label = "container"
}
rule {
action = "replace"
target_label = "node"
replacement = sys.env("APEX_NODE_HOST")
}
}
loki.source.docker "containers" {
host = "unix:///var/run/docker.sock"
targets = discovery.relabel.container_logs.output
relabel_rules = discovery.relabel.container_logs.rules
forward_to = [loki.write.default.receiver]
}
// Push logs to the central Loki.
loki.write "default" {
endpoint {
url = sys.env("APEX_LOKI_PUSH_URL")
basic_auth {
username = sys.env("APEX_OBS_BASIC_AUTH_USER")
password = sys.env("APEX_OBS_BASIC_AUTH_PASS")
}
}
}
+6 -1
View File
@@ -6,13 +6,18 @@ readme = "README.md"
license = { text = "BSD-2-Clause" }
requires-python = ">=3.10"
[project.optional-dependencies]
dev = ["ruff==0.17.2", "PyYAML>=6.0,<7"]
[tool.ruff]
line-length = 100
target-version = "py310"
[tool.ruff.lint]
select = ["E", "F", "W", "I", "B", "UP", "S"]
ignore = ["S603", "S607"] # Subprocess invocations are mediated via ctx.sys
# Existing formatting and typing modernization are outside correctness checks.
# Subprocess arguments are constructed explicitly; no implicit shell execution.
ignore = ["E401", "E501", "E702", "E741", "I001", "UP006", "UP035", "UP045", "S603", "S607"]
[tool.mypy]
python_version = "3.10"
+3 -2
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env bash
# Run the full engine test suite (stdlib unittest — no pytest).
# Run the core and standalone provisioning test suites (stdlib unittest — no pytest).
set -euo pipefail
cd "$(dirname "$0")/.."
exec python3 -m unittest discover -s tests -p 'test_*.py' -v
python3 -m unittest discover -s tests -p 'test_*.py' -v
PYTHONPATH=ci/scripts/init-node${PYTHONPATH:+:$PYTHONPATH} python3 -m unittest discover -s ci/scripts/init-node/tests -p 'test_*.py' -v
+49
View File
@@ -0,0 +1,49 @@
import json
from pathlib import Path
from types import SimpleNamespace
import unittest
import tempfile
from unittest.mock import Mock
from engine import overlay
backup = overlay.load_module(str(Path(__file__).resolve().parents[1] / 'actions/backup/run.py'),
'test_backup_action')
class BackupRecovery(unittest.TestCase):
def test_failed_attempt_preserves_last_success_and_records_new_failure(self):
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / 'status.json'
backup.write_backup_status(path, 'success', 100)
backup.write_backup_status(path, 'running', 200)
backup.write_backup_status(path, 'failed', 210)
status = json.loads(path.read_text())
self.assertEqual(status['last_success'], 100)
self.assertEqual(status['state'], 'failed')
self.assertEqual(status['updated_at'], 210)
def test_recovers_namespace_owner_before_dependent_without_starting_other_containers(self):
running = {'komf': False, 'komga': False, 'intentionally-stopped': False}
def docker(command, **kwargs):
if command[1] == 'inspect':
return SimpleNamespace(returncode=0, stdout=json.dumps([
{'Id': name, 'State': {'Running': running[name]}} for name in command[2:]]))
name = command[2]
if name == 'komf' and not running['komga']:
return SimpleNamespace(returncode=1)
running[name] = True
return SimpleNamespace(returncode=0)
ctx = SimpleNamespace(sys=SimpleNamespace(run=docker), log=Mock())
backup.restore_containers(ctx, ['komf', 'komga'])
self.assertTrue(running['komf'])
self.assertTrue(running['komga'])
self.assertFalse(running['intentionally-stopped'])
def test_unrecoverable_container_prevents_success(self):
def docker(command, **kwargs):
return SimpleNamespace(returncode=0 if command[1] == 'inspect' else 1,
stdout='[{"Id":"broken","State":{"Running":false}}]')
ctx = SimpleNamespace(sys=SimpleNamespace(run=docker), log=Mock())
with self.assertRaisesRegex(RuntimeError, 'broken'):
backup.restore_containers(ctx, ['broken'])
+95 -91
View File
@@ -1,119 +1,123 @@
# tests/test_compose.py
import contextlib
import io
import os
import tempfile
import unittest
from pathlib import Path
from unittest.mock import MagicMock
from actions import compose
from engine.descriptor import build_parser
class TestCompose(unittest.TestCase):
def setUp(self):
self.tmpdir = tempfile.TemporaryDirectory()
self.addCleanup(self.tmpdir.cleanup)
self.comp_dir = os.path.join(self.tmpdir.name, "compositions")
os.makedirs(self.comp_dir)
# Create compositions: apex, sso, arr
for name in ("apex", "sso", "arr"):
d = os.path.join(self.comp_dir, name)
os.makedirs(d)
with open(os.path.join(d, "docker-compose.yml"), "w") as f:
f.write(f"name: {name}\n")
self.stier3 = os.path.join(self.tmpdir.name, "tier3_shared")
os.makedirs(os.path.join(self.stier3, "apex", "xray", "shm"))
self.comp_dir = Path(self.tmpdir.name) / 'compositions'
for name in ('apex', 'sso', 'arr'):
directory = self.comp_dir / name
directory.mkdir(parents=True)
(directory / 'docker-compose.yml').write_text(f'name: {name}\n')
self.ctx = MagicMock()
self.ctx.paths.compositions = self.comp_dir
self.ctx.paths.stier3 = self.stier3
self.ctx.vars.return_value = {
"APEX_SUBNET": "198.18.16.0/24",
"APEX_NODE_HOST": "test-host",
}
self.ctx.sys.run = MagicMock()
self.ctx.paths.compositions = str(self.comp_dir)
self.ctx.paths.stier3 = self.tmpdir.name
self.ctx.vars.return_value = {'APEX_NODE_HOST': 'test-host', 'OTHER': 'ignored'}
def test_sequence_all_by_default(self):
args = MagicMock(action="restart", p=None, dry_run=False, extra=[])
def run_compose(self, *argv):
args = build_parser(compose.METADATA, 'apex compose').parse_args(argv)
compose.run(self.ctx, args)
# Should execute on apex, arr, sso (apex core first, others sorted)
calls = self.ctx.sys.run.call_args_list
self.assertEqual(len(calls), 3)
visited = [os.path.basename(c.kwargs["cwd"]) for c in calls]
self.assertEqual(visited, ["apex", "arr", "sso"])
def visited(self):
return [os.path.basename(call.kwargs['cwd'])
for call in self.ctx.sys.run.call_args_list]
def test_down_reverse_sequence(self):
args = MagicMock(action="down", p=None, dry_run=False, extra=[])
compose.run(self.ctx, args)
def test_sequence_all_and_down_reverse(self):
for argv, expected in [(('restart',), ['apex', 'arr', 'sso']),
(('-p', 'all', 'up'), ['apex', 'arr', 'sso']),
(('down', 'all'), ['sso', 'arr', 'apex'])]:
with self.subTest(argv=argv):
self.ctx.sys.run.reset_mock()
self.run_compose(*argv)
self.assertEqual(self.visited(), expected)
calls = self.ctx.sys.run.call_args_list
self.assertEqual(len(calls), 3)
visited = [os.path.basename(c.kwargs["cwd"]) for c in calls]
# Reverse on down: sso, arr, apex
self.assertEqual(visited, ["sso", "arr", "apex"])
def test_explicit_target_preserves_service_named_like_composition(self):
self.run_compose('-p', 'apex', 'restart', 'sso')
self.assertEqual(self.visited(), ['apex'])
call = self.ctx.sys.run.call_args
self.assertEqual(call.args[0], ['docker', 'compose', 'restart', 'sso'])
self.assertEqual(call.kwargs['env']['APEX_NODE_HOST'], 'test-host')
self.assertTrue(call.kwargs['check'])
def test_target_single_composition_positional(self):
args = MagicMock(action="restart", p=None, dry_run=False, extra=["apex"])
compose.run(self.ctx, args)
def test_immediate_positional_composition_compatibility(self):
self.run_compose('restart', 'apex', 'traefik')
self.assertEqual(self.visited(), ['apex'])
self.assertEqual(self.ctx.sys.run.call_args.args[0],
['docker', 'compose', 'restart', 'traefik'])
calls = self.ctx.sys.run.call_args_list
self.assertEqual(len(calls), 1)
self.assertEqual(os.path.basename(calls[0].kwargs["cwd"]), "apex")
self.assertEqual(calls[0].args[0], ["docker", "compose", "restart"])
def test_up_detached_compatibility_and_default_detached(self):
for argv in [('up', '-d', 'apex'), ('-p', 'apex', 'up'),
('up', 'apex', '-d')]:
with self.subTest(argv=argv):
self.ctx.sys.run.reset_mock()
self.run_compose(*argv)
self.assertEqual(self.visited(), ['apex'])
self.assertEqual(self.ctx.sys.run.call_args.args[0],
['docker', 'compose', 'up', '-d'])
def test_target_single_composition_flag(self):
args = MagicMock(action="restart", p="sso", dry_run=False, extra=[])
compose.run(self.ctx, args)
def test_flag_values_and_unknown_services_do_not_retarget(self):
for argv in [('logs', '--tail', 'sso'), ('restart', 'traefik'),
('up', '--scale', 'sso'), ('logs', '--', 'apex')]:
with self.subTest(argv=argv):
self.ctx.sys.run.reset_mock()
self.run_compose(*argv)
self.assertEqual(self.visited(), ['apex', 'arr', 'sso'])
prefix = ['docker', 'compose', argv[0]]
if argv[0] == 'up':
prefix += ['-d']
self.assertEqual(self.ctx.sys.run.call_args.args[0], prefix + list(argv[1:]))
calls = self.ctx.sys.run.call_args_list
self.assertEqual(len(calls), 1)
self.assertEqual(os.path.basename(calls[0].kwargs["cwd"]), "sso")
def test_invalid_wrapper_options_fail_before_execution(self):
for argv, code in [(('-p', 'unknown', 'up'), 1), (('-p', '', 'up'), 1),
(('-p',), 64),
(('up', '-p', 'apex'), 64),
(('up', '--dry-run'), 64), ((), 64),
(('up', '-d', 'apex', 'traefik'), 64)]:
with self.subTest(argv=argv), contextlib.redirect_stdout(io.StringIO()), \
contextlib.redirect_stderr(io.StringIO()):
with self.assertRaises(SystemExit) as error:
self.run_compose(*argv)
self.assertEqual(error.exception.code, code)
self.ctx.sys.run.assert_not_called()
def test_target_with_service_passthrough(self):
args = MagicMock(action="restart", p=None, dry_run=False, extra=["apex", "traefik"])
compose.run(self.ctx, args)
def test_separator_preserves_wrapper_looking_passthrough_tokens(self):
self.run_compose('-p', 'apex', 'exec', '--', 'probe', '--dry-run', '-p', 'sso')
self.assertEqual(self.ctx.sys.run.call_args.args[0],
['docker', 'compose', 'exec', '--', 'probe', '--dry-run', '-p', 'sso'])
calls = self.ctx.sys.run.call_args_list
self.assertEqual(len(calls), 1)
self.assertEqual(os.path.basename(calls[0].kwargs["cwd"]), "apex")
self.assertEqual(calls[0].args[0], ["docker", "compose", "restart", "traefik"])
def test_env_files_layer_before_action(self):
for filename in ('.env', 'apex.env'):
(self.comp_dir / 'apex' / filename).write_text('PORT=1234\n')
self.run_compose('-p', 'apex', 'config')
self.assertEqual(self.ctx.sys.run.call_args.args[0],
['docker', 'compose', '--env-file', '.env', '--env-file',
'apex.env', 'config'])
def test_target_up_detached_order(self):
args = MagicMock(action="up", p=None, dry_run=False, extra=["-d", "sso"])
compose.run(self.ctx, args)
calls = self.ctx.sys.run.call_args_list
self.assertEqual(len(calls), 1)
self.assertEqual(os.path.basename(calls[0].kwargs["cwd"]), "sso")
self.assertEqual(calls[0].args[0], ["docker", "compose", "up", "-d"])
def test_dry_run_skips_execution(self):
args = MagicMock(action="restart", p=None, dry_run=True, extra=["apex"])
compose.run(self.ctx, args)
self.ctx.sys.run.assert_not_called()
self.ctx.log.info.assert_any_call(
f"[DRY-RUN] (cd {os.path.join(self.comp_dir, 'apex')} && docker compose restart)"
)
def test_unknown_target_raises_system_exit(self):
args = MagicMock(action="restart", p=None, dry_run=False, extra=["nonexistent"])
with self.assertRaises(SystemExit) as cm:
compose.run(self.ctx, args)
self.assertEqual(cm.exception.code, 1)
def test_xray_socket_cleanup_on_apex_restart(self):
shm = os.path.join(self.stier3, "apex", "xray", "shm")
sock_file = os.path.join(shm, "stale.sock")
with open(sock_file, "w") as f:
f.write("")
args = MagicMock(action="restart", p=None, dry_run=False, extra=["apex"])
compose.run(self.ctx, args)
self.assertFalse(os.path.exists(sock_file))
def test_socket_survives_restart_up_and_dry_run(self):
shm = Path(self.tmpdir.name) / 'apex' / 'xray' / 'shm'
shm.mkdir(parents=True)
path = shm / 'live.sock'
path.write_bytes(b"socket path must survive")
for argv in [('restart', 'apex'), ('-p', 'apex', 'restart', 'traefik'),
('up',), ('--dry-run', '-p', 'apex', 'logs', '--tail', '20', 'xray')]:
with self.subTest(argv=argv):
self.ctx.sys.run.reset_mock()
self.run_compose(*argv)
self.assertTrue(path.exists())
if argv[0] == '--dry-run':
self.ctx.sys.run.assert_not_called()
self.assertIn('logs --tail 20 xray', self.ctx.log.info.call_args.args[0])
if __name__ == "__main__":
if __name__ == '__main__':
unittest.main()
+37
View File
@@ -0,0 +1,37 @@
import importlib.util
from pathlib import Path
import subprocess
import tempfile
from types import SimpleNamespace
import unittest
from unittest.mock import Mock, patch
from engine.lib.sys import Sys
spec = importlib.util.spec_from_file_location('compose_lint', Path(__file__).resolve().parents[1] / 'actions/utils/lint-docker-compose.py')
lint = importlib.util.module_from_spec(spec)
spec.loader.exec_module(lint)
class HookTests(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.repo = Path(self.tmp.name)
subprocess.run(['git', 'init', '-q', str(self.repo)], check=True)
self.log = Mock()
self.ctx = SimpleNamespace(log=self.log, sys=Sys(self.log), node=None,
paths=SimpleNamespace(repo_root=str(self.repo), compositions=str(self.repo / 'compositions')))
def stage(self, name, content):
(self.repo / name).write_text(content)
subprocess.run(['git', '-C', str(self.repo), 'add', name], check=True)
def test_documentation_commit_does_not_require_docker(self):
self.stage('README.md', 'Documentation\n')
with patch.object(lint.shutil, 'which', return_value=None):
self.assertEqual(lint.run(self.ctx, SimpleNamespace(hook=True)), 0)
def test_compose_change_still_requires_docker(self):
self.stage('docker-compose.yml', 'services: {}\n')
with patch.object(lint.shutil, 'which', return_value=None):
self.assertNotEqual(lint.run(self.ctx, SimpleNamespace(hook=True)), 0)
+52
View File
@@ -0,0 +1,52 @@
"""Native Alloy opt-in and validation boundaries."""
import json
from pathlib import Path
import tempfile
from types import SimpleNamespace
import unittest
from unittest.mock import Mock
from engine import overlay
ROOT = Path(__file__).resolve().parents[1]
alloy = overlay.load_module(str(ROOT / 'actions/configure/alloy.py'), 'test_alloy_action')
class NativeAlloy(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.configs = Path(self.temp.name)
self.ctx = SimpleNamespace(paths=SimpleNamespace(configs=str(self.configs), commons=str(ROOT)),
sys=Mock(), log=Mock())
def configure(self, **settings):
(self.configs / 'alloy').mkdir(exist_ok=True)
(self.configs / 'alloy/config.json').write_text(json.dumps(settings))
def test_unconfigured_node_is_untouched(self):
alloy.run(self.ctx, SimpleNamespace(dry_run=False))
self.ctx.sys.sudo.assert_not_called()
def test_invalid_role_or_environment_path_cannot_change_service(self):
for role, path in [('unknown', '/etc/alloy/secrets'), ('host', '/etc/alloy/invalid\nUser=root')]:
self.configure(role=role, environment_file=path)
with self.assertRaises(ValueError):
alloy.run(self.ctx, SimpleNamespace(dry_run=False))
self.ctx.sys.sudo.assert_not_called()
def test_dry_run_never_installs_or_starts(self):
self.configure(role='guest', environment_file='/etc/alloy/secrets')
alloy.run(self.ctx, SimpleNamespace(dry_run=True))
self.ctx.sys.sudo.assert_not_called()
def test_failed_config_validation_leaves_existing_service_untouched(self):
self.configure(role='host', environment_file='/etc/alloy/secrets')
def execute(command):
if command[0] == 'python3':
raise RuntimeError('invalid Alloy configuration')
self.assertEqual(command, ['test', '-f', '/etc/alloy/secrets'])
self.ctx.sys.sudo.side_effect = execute
with self.assertRaisesRegex(RuntimeError, 'invalid Alloy'):
alloy.run(self.ctx, SimpleNamespace(dry_run=False))
self.ctx.sys.daemon_reload.assert_not_called()
+281
View File
@@ -0,0 +1,281 @@
"""Local baseline policy: fake only package, network, and root privilege boundaries."""
import hashlib
import io
import json
import os
from pathlib import Path
import stat
import subprocess
import sys
import tempfile
from types import SimpleNamespace
import unittest
from unittest.mock import Mock, patch
from engine import overlay
from engine.lib.host import Host
from engine.lib.sys import Sys
base = overlay.load_module(
str(Path(__file__).resolve().parents[1] / "actions/configure/base.py"),
"apex_test_configure_base",
)
class TestBasePackages(unittest.TestCase):
def setUp(self):
self.system = Mock()
self.installed = set(base.BASE_PACKAGES)
self.system.run.side_effect = self.query
self.system.sudo.return_value = subprocess.CompletedProcess([], 0, stdout="")
def query(self, command, **kwargs):
self.assertEqual(command[:3], ["dpkg-query", "-W", "-f=${Status}"])
status = "install ok installed" if command[-1] in self.installed else "unknown ok not-installed"
return subprocess.CompletedProcess(command, 0, stdout=status)
def test_installed_rsyslog_and_ufw_do_not_require_executable_or_apt_update(self):
base._install_missing(self.system, base.BASE_PACKAGES)
self.assertEqual({call.args[0][-1] for call in self.system.run.call_args_list},
{"curl", "wget", "git", "rsyslog", "ufw"})
self.system.sudo.assert_not_called()
def test_only_missing_packages_are_installed_after_safe_simulation(self):
self.installed.remove("ufw")
self.system.sudo.return_value = subprocess.CompletedProcess(
[], 0, stdout="Inst ufw (0.36.2 Debian:13 [all])\nConf ufw (0.36.2 Debian:13 [all])\n",
)
base._install_missing(self.system, base.BASE_PACKAGES)
commands = [call.args[0] for call in self.system.sudo.call_args_list]
self.assertEqual(len(commands), 3)
self.assertIn("update", commands[0])
self.assertEqual(commands[1], [*commands[2], "--simulate"])
install = commands[2]
self.assertEqual(install[install.index("install"):], [
"install", "-y", "--no-install-recommends", "--no-upgrade", "--no-remove", "ufw",
])
def test_privileged_apt_environment_survives_sudo_filtering(self):
execute = subprocess.run
operations = []
def filtered_sudo(command, **kwargs):
if command[0] == "dpkg-query":
return subprocess.CompletedProcess(command, 1, stdout="")
self.assertEqual(command[0], "sudo")
incoming = kwargs.get("env") or os.environ
filtered = {"PATH": os.defpath}
if "LC_ALL" in incoming:
filtered["LC_ALL"] = incoming["LC_ALL"]
apt_index = command.index("apt-get")
probe = execute([
*command[1:apt_index], sys.executable, "-I", "-c",
"import json, os; print(json.dumps([os.getenv('LC_ALL'), "
"os.getenv('DEBIAN_FRONTEND')]))",
], env=filtered, check=True, text=True, capture_output=True)
self.assertEqual(json.loads(probe.stdout), ["C", "noninteractive"])
operations.append(command[apt_index:])
plan = "Inst ufw (0.36.2 Debian:13 [all])\n" if "--simulate" in command else ""
return subprocess.CompletedProcess(command, 0, stdout=plan)
with patch("engine.lib.sys.subprocess.run", side_effect=filtered_sudo):
base._install_missing(Sys(Mock()), ["ufw"])
self.assertEqual(len(operations), 3)
self.assertIn("update", operations[0])
self.assertIn("--simulate", operations[1])
self.assertIn("install", operations[2])
self.assertNotIn("--simulate", operations[2])
def test_dependency_upgrades_and_removals_abort_before_installation(self):
self.installed.remove("curl")
for plan in ("Inst libcurl4 [7.0] (8.0 Debian:13 [amd64])\n",
"Remv libcurl4 [7.0]\n"):
with self.subTest(plan=plan):
self.system.sudo.reset_mock()
self.system.sudo.return_value = subprocess.CompletedProcess([], 0, stdout=plan)
with self.assertRaisesRegex(RuntimeError, "upgrades/removals"):
base._install_missing(self.system, base.BASE_PACKAGES)
self.assertEqual(self.system.sudo.call_count, 2)
def test_failed_package_query_is_not_treated_as_installed(self):
self.system.run.return_value = subprocess.CompletedProcess([], 1, stdout="")
self.system.run.side_effect = None
base._install_missing(self.system, ["rsyslog"])
self.assertEqual(self.system.sudo.call_args.args[0][-1], "rsyslog")
class TestPinnedHelper(unittest.TestCase):
def setUp(self):
temporary = tempfile.TemporaryDirectory()
self.addCleanup(temporary.cleanup)
self.root = Path(temporary.name).resolve()
self.path = self.root / "bin" / "ufw-docker"
self.path.parent.mkdir()
self.payload = b"#!/bin/sh\nprintf 'verified fixture\\n'\n"
self.wrong_owner = None
self.wrong_group = None
self.system = Mock()
self.system.sudo.side_effect = self.sudo
self.ctx = SimpleNamespace(log=Mock(), sys=self.system)
self.ctx.host = Host(self.ctx.log, self.system)
real_lstat = Path.lstat
def root_lstat(path, *args, **kwargs):
info = list(real_lstat(path, *args, **kwargs))
info[4] = 1001 if path == self.wrong_owner else 0
info[5] = 1001 if path == self.wrong_group else 0
if path not in (self.path, self.path.parent, self.root):
info[0] &= ~0o022
return os.stat_result(info)
# Model root-owned ancestry without sudo/chown; content, modes and links stay real.
self.addCleanup(patch.stopall)
patch.object(Path, "lstat", autospec=True, side_effect=root_lstat).start()
patch.object(base, "UFW_DOCKER_PATH", self.path).start()
patch.object(base, "UFW_DOCKER_SHA256", hashlib.sha256(self.payload).hexdigest()).start()
self.download = patch.object(base.urllib.request, "urlopen").start()
self.download.return_value = io.BytesIO(self.payload)
def sudo(self, command, **kwargs):
if command[:3] == ["python3", "-I", "-c"]:
return subprocess.run([sys.executable, *command[1:]], check=True, text=True, **kwargs)
self.assertEqual(command, [str(self.path), "install"])
self.assertEqual(self.path.read_bytes(), self.payload)
self.assertEqual(stat.S_IMODE(self.path.stat().st_mode), 0o755)
return subprocess.CompletedProcess(command, 0)
def write_helper(self, payload=None, mode=0o755):
self.path.write_bytes(self.payload if payload is None else payload)
self.path.chmod(mode)
def test_verified_existing_helper_executes_without_download(self):
self.write_helper()
base._install_helper(self.ctx)
self.download.assert_not_called()
self.assertEqual(self.system.sudo.call_args.args[0], [str(self.path), "install"])
self.assertEqual(self.system.sudo.call_count, 1)
def test_wrong_executable_content_is_replaced_only_with_verified_download(self):
self.write_helper(b"#!/bin/sh\necho wrong\n")
base._install_helper(self.ctx)
self.assertEqual(self.path.read_bytes(), self.payload)
self.assertEqual(self.system.sudo.call_count, 2)
self.download.assert_called_once_with(base.UFW_DOCKER_URL, timeout=30)
def test_missing_helper_is_published_with_executable_mode(self):
base._install_helper(self.ctx)
self.assertEqual(self.path.read_bytes(), self.payload)
self.assertEqual(stat.S_IMODE(self.path.stat().st_mode), 0o755)
self.assertEqual(list(self.path.parent.iterdir()), [self.path])
def test_verified_nonexecutable_content_is_reused_and_mode_repaired(self):
self.write_helper(mode=0o600)
base._install_helper(self.ctx)
self.download.assert_not_called()
self.assertEqual(stat.S_IMODE(self.path.stat().st_mode), 0o755)
self.assertEqual(self.system.sudo.call_count, 2)
def test_bad_checksum_never_publishes_or_executes(self):
self.download.return_value = io.BytesIO(b"not the pinned helper")
with self.assertRaisesRegex(RuntimeError, "checksum mismatch"):
base._install_helper(self.ctx)
self.system.sudo.assert_not_called()
self.assertFalse(self.path.exists())
def test_bad_download_preserves_existing_wrong_helper_without_executing_it(self):
self.write_helper(b"old unknown executable")
self.download.return_value = io.BytesIO(b"bad download")
with self.assertRaisesRegex(RuntimeError, "checksum mismatch"):
base._install_helper(self.ctx)
self.system.sudo.assert_not_called()
self.assertEqual(self.path.read_bytes(), b"old unknown executable")
def assert_rejected_before_network_or_sudo(self):
with self.assertRaisesRegex(RuntimeError, "Unsafe ufw-docker"):
base._install_helper(self.ctx)
self.download.assert_not_called()
self.system.sudo.assert_not_called()
def test_symlink_target_is_rejected(self):
target = self.root / "untrusted"
target.write_bytes(self.payload)
self.path.symlink_to(target)
self.assert_rejected_before_network_or_sudo()
def test_dangling_symlink_target_is_rejected(self):
self.path.symlink_to(self.root / "missing")
self.assert_rejected_before_network_or_sudo()
def test_symlink_ancestor_is_rejected(self):
self.path.parent.rmdir()
target = self.root / "real-bin"
target.mkdir()
self.path.parent.symlink_to(target)
self.assert_rejected_before_network_or_sudo()
def test_writable_ancestor_is_rejected(self):
self.path.parent.chmod(0o777)
self.assert_rejected_before_network_or_sudo()
def test_unsafe_helper_preflight_precedes_package_network_or_mutation(self):
self.path.parent.chmod(0o777)
with patch.object(base.os.path, "isfile", return_value=True):
with self.assertRaisesRegex(RuntimeError, "Unsafe ufw-docker"):
base.run(self.ctx, None)
self.download.assert_not_called()
self.system.run.assert_not_called()
self.system.sudo.assert_not_called()
def test_untrusted_owner_ancestor_is_rejected(self):
self.wrong_owner = self.path.parent
self.assert_rejected_before_network_or_sudo()
def test_untrusted_file_owner_is_rejected(self):
self.write_helper()
self.wrong_owner = self.path
self.assert_rejected_before_network_or_sudo()
def test_untrusted_file_group_is_rejected(self):
self.write_helper()
self.wrong_group = self.path
self.assert_rejected_before_network_or_sudo()
def test_writable_helper_is_rejected(self):
self.write_helper(mode=0o775)
self.assert_rejected_before_network_or_sudo()
def test_hardlinked_helper_is_rejected(self):
self.write_helper()
os.link(self.path, self.root / "other-link")
self.assert_rejected_before_network_or_sudo()
def test_nonregular_helper_is_rejected(self):
self.path.mkdir()
self.assert_rejected_before_network_or_sudo()
def test_path_changed_during_download_is_rejected_before_publication(self):
def download(*args, **kwargs):
self.path.symlink_to(self.root / "untrusted")
return io.BytesIO(self.payload)
self.download.side_effect = download
with self.assertRaisesRegex(RuntimeError, "Unsafe ufw-docker"):
base._install_helper(self.ctx)
self.system.sudo.assert_not_called()
def test_changed_content_after_publication_is_not_executed(self):
real_sudo = self.sudo
def tamper(command, **kwargs):
result = real_sudo(command, **kwargs)
self.path.write_bytes(b"changed after publication")
return result
self.system.sudo.side_effect = tamper
with self.assertRaisesRegex(RuntimeError, "verification failed"):
base._install_helper(self.ctx)
self.assertEqual(self.system.sudo.call_count, 1)
if __name__ == "__main__":
unittest.main()
+156 -42
View File
@@ -1,68 +1,182 @@
# tests/test_env.py
import contextlib
import io
import json
import os
import sys
import shutil
import stat
import subprocess
import tempfile
import unittest
from pathlib import Path
from unittest.mock import MagicMock
from actions import env
from engine.descriptor import build_parser
from engine.lib.host import Host
class TestEnv(unittest.TestCase):
def setUp(self):
self.tmpdir = tempfile.TemporaryDirectory()
self.addCleanup(self.tmpdir.cleanup)
self.comp_dir = os.path.join(self.tmpdir.name, "compositions")
os.makedirs(os.path.join(self.comp_dir, "apex"))
self.core = Path(self.tmpdir.name) / 'compositions' / 'apex'
self.core.mkdir(parents=True)
self.ctx = MagicMock()
self.ctx.paths.compositions = self.comp_dir
self.ctx.paths.compositions = str(self.core.parent)
self.ctx.vars.return_value = {
"APEX_SUBNET": "198.18.16.0/24",
"APEX_NODE_HOST": "test-host",
"OTHER_VAR": "ignore_me",
'APEX_SUBNET': '198.18.0.0/24',
'APEX_NODE_HOST': 'test-host',
'OTHER_VAR': 'ignore_me',
}
self.ctx.host = Host(self.ctx.log, self.ctx.sys)
def test_export_format_stdout(self):
args = MagicMock(sync=False, write=None, dotenv=False)
captured = io.StringIO()
old_stdout = sys.stdout
try:
sys.stdout = captured
def run_env(self, *argv):
args = build_parser(env.METADATA, 'apex env').parse_args(argv)
with contextlib.redirect_stdout(io.StringIO()) as out:
env.run(self.ctx, args)
finally:
sys.stdout = old_stdout
return out.getvalue()
out = captured.getvalue()
self.assertIn("export APEX_NODE_HOST=test-host", out)
self.assertIn("export APEX_SUBNET=198.18.16.0/24", out)
self.assertNotIn("OTHER_VAR", out)
def test_shell_exports_round_trip_literal_values(self):
value = "a$b # 'quote' \"double\" \\backslash\r\nnext"
self.ctx.vars.return_value['APEX_NODE_HOST'] = value
exports = self.run_env('--export')
self.assertEqual(self.run_env(), exports)
script = exports + '\nprintf %s "$APEX_NODE_HOST"'
result = subprocess.run(['/bin/sh', '-c', script], capture_output=True, check=True,
timeout=30, env={})
self.assertEqual(result.stdout.decode(), value)
self.assertNotIn('OTHER_VAR', script)
def test_dotenv_format_stdout(self):
args = MagicMock(sync=False, write=None, dotenv=True)
captured = io.StringIO()
old_stdout = sys.stdout
try:
sys.stdout = captured
env.run(self.ctx, args)
finally:
sys.stdout = old_stdout
def test_dotenv_does_not_disclose_other_values_or_existing_secrets(self):
(self.core / '.env').write_text('SECRET=synthetic-private\n')
output = self.run_env('--dotenv')
self.assertIn('APEX_NODE_HOST=', output)
self.assertNotIn('export ', output)
self.assertNotIn('SECRET', output)
self.assertNotIn('OTHER_VAR', output)
out = captured.getvalue()
self.assertIn("APEX_NODE_HOST=test-host", out)
self.assertNotIn("export", out)
def test_repeat_sync_preserves_credentials_bytes_and_updates_managed_values(self):
original = b'# user credentials\r\nTOKEN=\'a$#"\\literal\'\r\nUID=1000\r\nGID=1000'
target = self.core / '.env'
target.write_bytes(original)
target.chmod(0o600)
(self.core / 'apex.env').write_bytes(b'COMPOSE_PROFILES=core\r\nPORT=1234\r\n')
self.run_env('--sync')
first = target.read_bytes()
self.assertTrue(first.startswith(original + b'\n'))
self.assertIn(b'COMPOSE_PROFILES=core\r\nPORT=1234\r\n', first)
self.run_env('--sync')
self.assertEqual(first, target.read_bytes())
self.ctx.vars.return_value['APEX_NODE_HOST'] = 'new-host'
self.run_env('--sync')
self.assertTrue(target.read_bytes().startswith(original + b'\n'))
self.assertNotIn(b'test-host', target.read_bytes())
self.assertEqual(stat.S_IMODE(target.stat().st_mode), 0o600)
def test_sync_to_file(self):
target = os.path.join(self.comp_dir, "apex", ".env")
args = MagicMock(sync=True, write=None, dotenv=False)
env.run(self.ctx, args)
def test_sync_preserves_symlink_and_private_target(self):
target = Path(self.tmpdir.name) / 'private.env'
target.write_text('TOKEN=synthetic\n')
link = self.core / '.env'
link.symlink_to(target)
self.run_env('--sync')
self.assertTrue(link.is_symlink())
self.assertIn('TOKEN=synthetic', target.read_text())
self.assertIn('APEX_NODE_HOST=', target.read_text())
self.assertEqual(stat.S_IMODE(target.stat().st_mode), 0o600)
self.ctx.host.write_file.assert_called_once()
call_args = self.ctx.host.write_file.call_args
self.assertEqual(call_args[0][0], target)
self.assertIn("APEX_SUBNET=198.18.16.0/24", call_args[0][1])
def test_write_basename_is_standalone_and_private(self):
previous = os.getcwd()
self.addCleanup(os.chdir, previous)
os.chdir(self.tmpdir.name)
Path('generated.env').write_text('OLD=removed\n')
self.run_env('--write', 'generated.env')
content = Path('generated.env').read_text()
self.assertIn('APEX_NODE_HOST=', content)
self.assertNotIn('OLD=', content)
self.assertNotIn('BEGIN', content)
self.assertEqual(stat.S_IMODE(Path('generated.env').stat().st_mode), 0o600)
def test_incompatible_modes_fail_without_writing(self):
for argv in [('--sync', '--dotenv'), ('--sync', '--write', 'file'),
('--export', '--dotenv'), ('--export', '--write', 'file')]:
with self.subTest(argv=argv):
with self.assertRaises(SystemExit) as error:
self.run_env(*argv)
self.assertEqual(error.exception.code, 64)
self.assertFalse((self.core / '.env').exists())
def test_uncertain_managed_markers_fail_without_changing_file(self):
start = '# BEGIN APEX MANAGED ENV'
end = '# END APEX MANAGED ENV'
for content in [start+'\n', end+'\n', end+'\n'+start+'\n',
start+'\n'+start+'\n'+end+'\n',
start+'\n'+end+'\n'+start+'\n'+end+'\n',
'TOKEN="'+start+'"\n']:
target = self.core / '.env'
target.write_text(content)
with self.subTest(content=content):
with self.assertRaises(SystemExit) as error:
self.run_env('--sync')
self.assertEqual(error.exception.code, 65)
self.assertEqual(target.read_text(), content)
def test_source_marker_collision_fails_without_writing(self):
(self.core / 'apex.env').write_text('# BEGIN APEX MANAGED ENV\n')
with self.assertRaises(SystemExit) as error:
self.run_env('--sync')
self.assertEqual(error.exception.code, 65)
self.assertFalse((self.core / '.env').exists())
(self.core / 'apex.env').unlink()
self.ctx.vars.return_value['APEX_NODE_HOST'] = '# END APEX MANAGED ENV'
with self.assertRaises(SystemExit) as error:
self.run_env('--sync')
self.assertEqual(error.exception.code, 65)
self.assertFalse((self.core / '.env').exists())
def test_bare_compose_interprets_literals_precedence_and_profiles(self):
binary = shutil.which('docker-compose')
if binary is None:
self.skipTest('Standalone docker-compose required for daemon-free config check')
values = {
'APEX_LITERAL': "a$b ${UNSET} $$ # 'quote' \"double\" \\backslash",
'APEX_TRAILING': 'last\\',
'APEX_LINES': 'one\r\ntwo\nthree\tfour',
'APEX_EMPTY': '',
'APEX_ESCAPES': "\\n \\\\ \\' \\\"",
'APEX_NODE_HOST': 'synthesized',
}
self.ctx.vars.return_value = values
(self.core / '.env').write_text('UID=1000\nGID=1001\nPORT=1\nAPEX_NODE_HOST=old\n'
"TOKEN='literal$token#hash'\n")
(self.core / 'apex.env').write_text('PORT=2\nAPEX_NODE_HOST=public\n'
'COMPOSE_PROFILES=chosen\n')
keys = [*values, 'UID', 'GID', 'PORT', 'TOKEN']
(self.core / 'docker-compose.yml').write_text(
'services:\n probe:\n image: busybox\n profiles: [chosen]\n'
' environment:\n' + ''.join(f' {key}: ${{{key}}}\n' for key in keys))
self.run_env('--sync')
result = subprocess.run([binary, 'config', '--format', 'json'], cwd=self.core,
capture_output=True, check=True, timeout=30, env={})
actual = json.loads(result.stdout)['services']['probe']['environment']
expected = {**values, 'UID': '1000', 'GID': '1001', 'PORT': '2',
'TOKEN': 'literal$token#hash'}
# Compose re-escapes literal dollars when serializing a reusable configuration.
self.assertEqual(actual, {k: v.replace('$', '$$') for k, v in expected.items()})
result = subprocess.run([binary, 'config', '--environment'], cwd=self.core,
capture_output=True, check=True, timeout=30, env={})
for key, value in {**expected, 'COMPOSE_PROFILES': 'chosen'}.items():
self.assertIn(f'{key}={value}\n'.encode(), result.stdout)
with (self.core / '.env').open('a') as target:
target.write('# later user addition\nPORT=999\n')
self.run_env('--sync')
self.assertIn('# later user addition\nPORT=999\n', (self.core / '.env').read_text())
result = subprocess.run([binary, 'config', '--format', 'json'], cwd=self.core,
capture_output=True, check=True, timeout=30, env={})
self.assertEqual(json.loads(result.stdout)['services']['probe']['environment']['PORT'], '2')
result = subprocess.run([binary, 'config', '--services'], cwd=self.core,
capture_output=True, check=True, timeout=30, env={})
self.assertEqual(result.stdout.strip(), b'probe')
if __name__ == "__main__":
if __name__ == '__main__':
unittest.main()
+195 -1
View File
@@ -1,6 +1,200 @@
# tests/test_host.py
import inspect
import os
from pathlib import Path
import stat
import subprocess
import sys
import tempfile
import unittest
from engine.lib.host import apply_block
from unittest.mock import Mock, patch
from engine.lib.host import Host, apply_block
def _check_backup_chmod(chmod, expected_group):
import os
import stat
def checked(path, mode, *args, **kwargs):
result = chmod(path, mode, *args, **kwargs)
info = os.stat(path)
with open(path, "rb") as stream:
content = stream.read()
if content and stat.S_IMODE(info.st_mode) & 0o077 and info.st_gid != expected_group:
raise AssertionError("Backup content exposed to the inherited staging group")
return result
return checked
class TestHostWrites(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.root = Path(self.temp.name).resolve()
self.log = Mock()
self.host = Host(self.log, Mock())
def test_existing_private_modes_and_owner_survive_replacement(self):
for mode in (0o600, 0o640):
with self.subTest(mode=mode):
path = self.root / "secret"
path.write_text("old")
path.chmod(mode)
previous = path.stat()
self.host.write_file(str(path), "new", backup=False)
self.assertEqual(path.read_text(), "new")
self.assertEqual(stat.S_IMODE(path.stat().st_mode), mode)
self.assertEqual((path.stat().st_uid, path.stat().st_gid),
(previous.st_uid, previous.st_gid))
def test_new_basename_defaults_to_0644(self):
previous = Path.cwd()
try:
os.chdir(self.root)
self.host.write_file("settings", "value", backup=False)
finally:
os.chdir(previous)
path = self.root / "settings"
self.assertEqual(path.read_text(), "value")
self.assertEqual(stat.S_IMODE(path.stat().st_mode), 0o644)
def test_existing_supplementary_group_survives_replacement(self):
path = self.root / "group-owned"
path.write_text("old")
groups = set(os.getgroups()) - {path.stat().st_gid}
if not groups:
self.skipTest("No alternate supplementary group available")
group = next(iter(groups))
try:
os.chown(path, -1, group)
except PermissionError:
self.skipTest("Filesystem does not permit changing to a supplementary group")
self.host.write_file(str(path), "new", backup=False)
self.assertEqual(path.read_text(), "new")
self.assertEqual(path.stat().st_gid, group)
def test_explicit_mode_is_applied_before_atomic_publication(self):
path = self.root / "secret"
path.write_text("old")
replace = os.replace
published = []
def observe(source, destination):
self.assertEqual(path.read_text(), "old")
self.assertEqual(Path(source).parent, self.root)
self.assertEqual(stat.S_IMODE(os.stat(source).st_mode), 0o600)
self.assertEqual(Path(source).read_text(), "new secret")
published.append(destination)
replace(source, destination)
with patch("os.replace", side_effect=observe):
self.host.write_file(str(path), "new secret", mode="0600", backup=False)
self.assertEqual(len(published), 1)
self.assertEqual(path.read_text(), "new secret")
def test_symlink_remains_and_target_receives_new_content(self):
target = self.root / "tier" / "secret"
target.parent.mkdir()
target.write_text("old")
target.chmod(0o600)
link = self.root / ".env"
link.symlink_to("tier/secret")
self.host.write_file(str(link), "new", backup=False)
self.assertTrue(link.is_symlink())
self.assertEqual(os.readlink(link), "tier/secret")
self.assertEqual(target.read_text(), "new")
self.assertEqual(stat.S_IMODE(target.stat().st_mode), 0o600)
def test_dry_run_does_not_create_parents_or_backups(self):
self.host.write_file(str(self.root / "missing" / "secret"), "new", dry_run=True)
self.assertEqual(list(self.root.iterdir()), [])
def test_backup_preserves_old_private_content(self):
path = self.root / "secret"
path.write_text("old")
path.chmod(0o600)
self.host.write_file(str(path), "new")
backups = list(self.root.glob("secret.*.bak"))
self.assertEqual(len(backups), 1)
self.assertEqual(backups[0].read_text(), "old")
self.assertEqual(stat.S_IMODE(backups[0].stat().st_mode), 0o600)
self.assertEqual(path.read_text(), "new")
def test_backup_sets_owner_before_exposing_group_readable_content(self):
path = self.root / "group-secret"
path.write_text("synthetic group-only secret")
groups = set(os.getgroups()) - {path.stat().st_gid}
if not groups:
self.skipTest("No alternate supplementary group available")
group = next(iter(groups))
try:
os.chown(path, -1, group)
except PermissionError:
self.skipTest("Filesystem does not permit changing to a supplementary group")
path.chmod(0o640)
os.utime(path, (1600000000, 1600000000))
def local_sudo(command, **kwargs):
self.assertEqual(command[:3], ["python3", "-I", "-c"])
probe = ("import os\n" + inspect.getsource(_check_backup_chmod) +
f"\nos.chmod = _check_backup_chmod(os.chmod, {group})\n")
result = subprocess.run([sys.executable, "-I", "-c", probe + command[3]],
check=False, text=True, capture_output=True, **kwargs)
self.assertEqual(result.returncode, 0, result.stderr)
return result
self.host.sys.ok.return_value = True
self.host.sys.sudo.side_effect = local_sudo
for sudo in (False, True):
with self.subTest(sudo=sudo):
with patch("os.chmod", side_effect=_check_backup_chmod(os.chmod, group)):
backup = Path(self.host.backup(str(path), sudo=sudo))
self.assertEqual(backup.read_bytes(), path.read_bytes())
self.assertEqual(backup.stat().st_gid, group)
self.assertEqual(stat.S_IMODE(backup.stat().st_mode), 0o640)
self.assertEqual(backup.stat().st_mtime_ns, path.stat().st_mtime_ns)
backup.unlink()
def test_publication_failure_keeps_original_and_cleans_staging(self):
path = self.root / "secret"
path.write_text("old")
with patch("os.replace", side_effect=OSError("publication failed")):
with self.assertRaisesRegex(OSError, "publication failed"):
self.host.write_file(str(path), "new", backup=False)
self.assertEqual(path.read_text(), "old")
self.assertEqual(list(self.root.iterdir()), [path])
self.log.success.assert_not_called()
def test_write_failure_keeps_original_and_cleans_staging(self):
path = self.root / "secret"
path.write_text("old")
with patch("os.fsync", side_effect=OSError("disk full")):
with self.assertRaisesRegex(OSError, "disk full"):
self.host.write_file(str(path), "new", backup=False)
self.assertEqual(path.read_text(), "old")
self.assertEqual(list(self.root.iterdir()), [path])
self.log.success.assert_not_called()
def test_sudo_runs_same_writer_without_copying_over_live_target(self):
target = self.root / "secret"
target.write_text("old")
target.chmod(0o640)
link = self.root / ".env"
link.symlink_to("secret")
def local_sudo(command, **kwargs):
self.assertEqual(command[:3], ["python3", "-I", "-c"])
return subprocess.run([sys.executable, *command[1:]], check=True,
text=True, **kwargs)
self.host.sys.sudo.side_effect = local_sudo
self.host.write_file(str(link), "new", sudo=True, backup=False)
self.assertTrue(link.is_symlink())
self.assertEqual(target.read_text(), "new")
self.assertEqual(stat.S_IMODE(target.stat().st_mode), 0o640)
self.assertEqual(set(self.root.iterdir()), {link, target})
class TestApplyBlock(unittest.TestCase):
def test_insert_after_anchor(self):
+1 -1
View File
@@ -1,6 +1,6 @@
# tests/test_identity.py
import os, tempfile, unittest
from engine.identity import resolve, Node
from engine.identity import resolve
class TestIdentity(unittest.TestCase):
def _envfile(self, text):
+133
View File
@@ -0,0 +1,133 @@
import os
import subprocess
import tempfile
import unittest
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import Mock
from actions.sync import repository
from engine.lib.sys import Sys
class RepositorySyncTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.root = Path(self.temp.name)
self.remote = self.root / "remote.git"
self.repo = self.root / "node"
self.env = dict(os.environ, GIT_AUTHOR_NAME="Test", GIT_AUTHOR_EMAIL="test@example.invalid",
GIT_COMMITTER_NAME="Test", GIT_COMMITTER_EMAIL="test@example.invalid",
GIT_ALLOW_PROTOCOL="file")
self.git("init", "--bare", str(self.remote))
self.git("clone", str(self.remote), str(self.repo))
self.git("checkout", "-b", "main", cwd=self.repo)
(self.repo / "compositions").mkdir()
(self.repo / "compositions/service").write_text("base\n")
self.commons_source = self.root / "shared"
self.git("init", str(self.commons_source))
(self.commons_source / "file").write_text("shared\n")
self.git("add", ".", cwd=self.commons_source)
self.git("commit", "-m", "shared", cwd=self.commons_source)
self.git("submodule", "add", str(self.commons_source), "commons", cwd=self.repo)
self.git("add", ".", cwd=self.repo)
self.git("commit", "-m", "base", cwd=self.repo)
self.git("push", "origin", "main", cwd=self.repo)
self.log = Mock()
class Commands(Sys):
def run(inner, cmd, **kw):
kw["env"] = dict(self.env, **kw.get("env", {}))
kw["env"]["GIT_ALLOW_PROTOCOL"] = "file"
return super().run(cmd, **kw)
self.ctx = SimpleNamespace(log=self.log, sys=Commands(self.log), notify=Mock(),
node=SimpleNamespace(name="node"),
paths=SimpleNamespace(repo_root=str(self.repo), commons=str(self.repo / "commons"),
compositions=str(self.repo / "compositions")))
self.args = SimpleNamespace(telegram_bot_url=None)
def git(self, *args, cwd=None):
return subprocess.check_output(["git", *args], cwd=cwd, env=self.env, stderr=subprocess.PIPE, text=True).strip()
def sync(self):
repository.run(self.ctx, self.args)
def test_local_change_publishes_main_without_sync_branch_or_notification(self):
(self.repo / "compositions/service").write_text("changed\n")
self.sync()
self.assertEqual(self.git("--git-dir=" + str(self.remote), "show", "main:compositions/service"), "changed")
self.assertEqual(self.git("--git-dir=" + str(self.remote), "for-each-ref", "--format=%(refname)", "refs/heads"), "refs/heads/main")
self.ctx.notify.success.assert_not_called()
self.ctx.notify.error.assert_not_called()
def test_noop_keeps_head(self):
before = self.git("rev-parse", "HEAD", cwd=self.repo)
self.sync()
self.assertEqual(before, self.git("rev-parse", "HEAD", cwd=self.repo))
def test_other_branch_does_not_stage_changes(self):
self.git("checkout", "-b", "work", cwd=self.repo)
(self.repo / "compositions/service").write_text("changed\n")
with self.assertRaises(SystemExit):
self.sync()
self.assertEqual(self.git("diff", "--cached", cwd=self.repo), "")
def test_dirty_commons_blocks_parent_commit(self):
before = self.git("rev-parse", "HEAD", cwd=self.repo)
(self.repo / "commons/file").write_text("dirty\n")
with self.assertRaises(SystemExit):
self.sync()
self.assertEqual(before, self.git("rev-parse", "HEAD", cwd=self.repo))
def test_stale_submodule_after_failed_update_cannot_rollback_pin(self):
old = self.git("rev-parse", "HEAD", cwd=self.repo / "commons")
(self.commons_source / "file").write_text("new shared\n")
self.git("add", ".", cwd=self.commons_source)
self.git("commit", "-m", "new shared", cwd=self.commons_source)
new = self.git("rev-parse", "HEAD", cwd=self.commons_source)
self.git("-C", str(self.repo / "commons"), "fetch", "origin")
self.git("update-index", "--cacheinfo", "160000," + new + ",commons", cwd=self.repo)
self.git("commit", "-m", "advance pin", cwd=self.repo)
before = self.git("rev-parse", "HEAD", cwd=self.repo)
with self.assertRaises(SystemExit):
self.sync()
self.assertEqual(before, self.git("rev-parse", "HEAD", cwd=self.repo))
self.assertEqual(old, self.git("rev-parse", "HEAD", cwd=self.repo / "commons"))
self.git("submodule", "update", "--init", cwd=self.repo)
self.sync()
self.assertEqual(new, self.git("--git-dir=" + str(self.remote), "rev-parse", "main:commons"))
def test_concurrent_main_advance_rejects_push_without_overwriting(self):
original = self.ctx.sys.run
other = self.root / "other"
self.git("clone", "--branch", "main", str(self.remote), str(other))
def race(cmd, **kw):
if "push" in cmd:
(other / "compositions/service").write_text("concurrent\n")
self.git("add", ".", cwd=other)
self.git("commit", "-m", "concurrent", cwd=other)
self.git("push", "origin", "main", cwd=other)
return original(cmd, **kw)
self.ctx.sys.run = race
(self.repo / "compositions/service").write_text("local\n")
with self.assertRaises(SystemExit):
self.sync()
self.assertEqual("concurrent", self.git("--git-dir=" + str(self.remote), "show", "main:compositions/service"))
self.assertEqual("local", self.git("show", "HEAD:compositions/service", cwd=self.repo))
def test_conflict_preserves_local_commit_and_remote(self):
other = self.root / "other"
self.git("clone", "--branch", "main", str(self.remote), str(other))
(other / "compositions/service").write_text("remote\n")
self.git("add", ".", cwd=other)
self.git("commit", "-m", "remote", cwd=other)
self.git("push", "origin", "main", cwd=other)
(self.repo / "compositions/service").write_text("local\n")
with self.assertRaises(SystemExit):
self.sync()
self.assertEqual(self.git("show", "HEAD:compositions/service", cwd=self.repo), "local")
self.assertEqual(self.git("--git-dir=" + str(self.remote), "show", "main:compositions/service"), "remote")
self.assertFalse((self.repo / ".git/rebase-merge").exists())
if __name__ == "__main__":
unittest.main()
+74
View File
@@ -0,0 +1,74 @@
"""Exercise verification entrypoints in disposable source trees."""
import os
from pathlib import Path
import shutil
import subprocess
import sys
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[1]
class ToolingTests(unittest.TestCase):
def test_lint_fallback_recurses_and_propagates_each_root_failure(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
shutil.copyfile(ROOT / 'Makefile', root / 'Makefile')
binaries = root / 'bin'
binaries.mkdir()
make = shutil.which('make')
self.assertIsNotNone(make, 'make is required for the declared verification workflow')
(binaries / 'make').symlink_to(make)
(binaries / 'python3').symlink_to(sys.executable)
environment = dict(os.environ, PATH=str(binaries))
sources = []
for name in ('engine', 'actions', 'tests', 'ci/scripts'):
source = root / name / 'nested' / 'case.py'
source.parent.mkdir(parents=True)
source.write_text('value = 1\n')
sources.append(source)
for source in sources:
with self.subTest(root=source.parent.parent):
invalid = source.with_name('invalid.py')
invalid.write_text('def broken(:\n')
result = subprocess.run([str(binaries / 'make'), 'lint'], cwd=root,
env=environment, capture_output=True, text=True,
timeout=30)
self.assertNotEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertNotIn('verified python bytecode compilation', result.stdout)
invalid.unlink()
result = subprocess.run([str(binaries / 'make'), 'lint'], cwd=root,
env=environment, capture_output=True, text=True, timeout=30)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertIn('verified python bytecode compilation', result.stdout)
def test_suite_runner_discovers_both_suites_and_propagates_failures(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
suites = (root / 'tests', root / 'ci/scripts/init-node/tests')
for suite in suites:
suite.mkdir(parents=True)
shutil.copyfile(ROOT / 'tests/run.sh', suites[0] / 'run.sh')
binaries = root / 'bin'
binaries.mkdir()
(binaries / 'python3').symlink_to(sys.executable)
environment = dict(os.environ, PATH=str(binaries) + os.pathsep + os.environ['PATH'])
for failure in (None, 0, 1):
with self.subTest(failing_suite=failure):
for index, suite in enumerate(suites):
(suite / 'test_case.py').write_text(
'import unittest\nclass Case(unittest.TestCase):\n'
' def test_suite_' + str(index) + '(self):\n'
' self.assertTrue(' + repr(index != failure) + ')\n')
result = subprocess.run(['/bin/bash', 'tests/run.sh'], cwd=root,
env=environment, capture_output=True, text=True,
timeout=30)
if failure is None:
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn('test_suite_0', result.stderr)
self.assertIn('test_suite_1', result.stderr)
else:
self.assertNotEqual(result.returncode, 0, result.stderr)
self.assertIn('FAIL: test_suite_' + str(failure), result.stderr)