11 Commits
34 changed files with 255568 additions and 1206 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,124 +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: Setup Python
uses: actions/setup-python@v5
with:
python-version: "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
bash -n "$script"
done
make check
- 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
+2
View File
@@ -16,3 +16,5 @@ __pycache__/
*.env
.env
*.bak
._*
.DS_Store
+18 -17
View File
@@ -10,12 +10,12 @@ Local infrastructure operations for independently owned nodes, with a Python sta
- **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
@@ -47,7 +47,7 @@ You should understand the following invariants before exploring the codebase:
- **[`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)**: Shared static configuration, including Alloy.
- **[`ci/`](./ci)**: Host image build recipes, cloud-init templates, and standalone Debian enrollment tooling.
- **[`ci/`](./ci)**: Host image build recipes, cloud-init templates, and image enrollment tooling.
- **[`assets/`](./assets)**: Repository branding assets (`logo.png`).
---
@@ -104,18 +104,19 @@ 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
```
- **Prerequisites**: Run from this commons checkout with Python 3.10+ and OpenSSH. The target must be Debian 13 amd64, reachable using a trusted SSH host key as root or an administrator with passwordless `sudo`. The node checkout must declare `APEX_ENROLLMENT_INTERFACE=1` and have a matching enrollment-aware `init.sh`. Use the supported template subset, not arbitrary cloud-config directives. Keep filled user-data and repository credentials outside Git; supply trusted repository host keys for SSH clones.
- **Bootstrap Dependencies**: The target seed installs distro `python3-yaml` for user-data validation; this is separate from the standard-library APEX core. ZFS setup needs matching running-kernel headers/modules. A mismatch stops with a diagnosis; enrollment does not reboot automatically.
- **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**: Follow the emitted standalone resume command. After committed access handover, use `python3 ci/scripts/init-node.py adam@server --port 2222 --user-data /private/path/location.user-data.yaml`; before handover, use the bootstrap endpoint after pending rollback. An uncertain handover requires checking which endpoint is active. Preserve any `--identity-file` option. Core `apex` has no remote `init` action.
```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
@@ -129,15 +130,15 @@ Adding shared actions or improving the engine.
. .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 standalone 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.
- **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 live enrollment is a separate operation; unit tests and template validation do not demonstrate a deployed host.
- **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
+48 -3
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) from e
+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.")
+27 -32
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}")
if url:
ctx.notify.error(TITLE, url, f"Sync failed: {e}")
raise SystemExit(1) from e
+3 -2
View File
@@ -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"
+1
View File
@@ -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
-65
View File
@@ -1,65 +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)
# The standalone package lives beside this launcher, outside the engine.
from provisioning.transport import initialize # noqa: E402
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())
@@ -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
+2 -2
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,7 +16,7 @@ from .target import preflight, run
def install():
from .transport import _bundle_from_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)
@@ -1,394 +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 _bundle_from_source(root):
root = Path(root).resolve()
package = root / 'provisioning'
files = sorted(package.rglob('*.py'))
seed = package / 'seed.sh'
if not seed.is_file() or not (package / '__init__.py').is_file():
raise TransportError('Provisioning package or seed is missing')
files.append(seed)
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 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()
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', 'ci/scripts/init-node.py'], capture_output=True, text=True, timeout=30)
if dirty.returncode or dirty.stdout.strip():
revision += '-modified'
return data, digest, 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: # noqa: S110 — diagnostics may contain secrets.
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): # noqa: S108 — validates remote mktemp output.
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', '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', 'provisioning.access', 'preflight'])
seed = shlex.join(['bash', installed + '/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', '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: # noqa: S110 — cleanup tries both verified endpoints.
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 = ['python3', 'ci/scripts/init-node.py', resume_user + '@' + ssh.host,
'--port', str(resume_port), '--user-data', str(source)]
if ssh.identity:
resume += ['--identity-file', str(ssh.identity)]
progress(label + ': ' + shlex.join(resume))
@@ -11,7 +11,7 @@ from unittest.mock import patch
from provisioning import access, image
from provisioning.image import cloud_enroll, sanitize_files
from provisioning.transport import build_bundle
from provisioning.bundle import build_bundle
from provisioning.state import atomic_write, safe_path
from provisioning.state import ProvisionError
@@ -1,560 +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 provisioning.transport import (
SSH, TransportError, HandoverUncertain, build_bundle, destination, local_file, protected_handover,
initialize,
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 / '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, installed=installed, stage=stage,
root=root, environment=environment, **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('provisioning.transport.SSH', Remote), \
patch('provisioning.transport.build_bundle', return_value=(b'bundle', 'a' * 64, 'b' * 40)), \
patch('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('provisioning.transport.SSH', return_value=transport), \
patch('provisioning.transport.build_bundle', return_value=(b'bundle', 'a' * 64, 'b' * 40)), \
patch('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 provisioning.access import Transition
from 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 provisioning.access import Transition
from 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 $literal'
source.write_bytes(b'opaque input')
identity = root / 'identity file'
identity.write_bytes(b'synthetic private identity')
host = FakeHost(root)
transition = Transition(host, now=lambda: 1000, security=host.security)
messages, commands = [], []
def runner(args, commands=commands, transition=transition,
unreachable=unreachable, **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 '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 '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('provisioning.transport.SSH', side_effect=transport), \
patch('provisioning.transport.build_bundle', return_value=(b'bundle', 'a' * 64, 'b' * 40)), \
patch('provisioning.transport.shutil.which', return_value='/usr/bin/ssh'):
if unreachable:
with self.assertRaises(HandoverUncertain):
initialize(root, 'debian@example.com', source, identity_file=identity, 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, identity_file=identity, progress=messages.append), {'status': 'complete'})
self.assertIn('Safe resume: python3 ci/scripts/init-node.py adam@example.com --port 2222', messages[-1])
resume = shlex.split(messages[-1].split(': ', 1)[1])
self.assertEqual(resume[:2], ['python3', 'ci/scripts/init-node.py'])
self.assertEqual(resume[resume.index('--user-data') + 1], str(source.resolve()))
self.assertEqual(resume[resume.index('--identity-file') + 1], str(identity.resolve()))
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 / 'ci/scripts/init-node/provisioning').mkdir(parents=True)
(root / 'ci/scripts/init-node/provisioning/__init__.py').write_text('')
(root / 'ci/scripts/init-node/provisioning/seed.sh').write_text('#!/bin/sh\n')
(root / '.env').write_text('private secret')
(root / 'ci/scripts/init-node/provisioning/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()), {'provisioning/__init__.py', 'provisioning/seed.sh'})
self.assertNotIn(b'private secret', data)
self.assertEqual(build_bundle(root)[1], digest)
def test_real_archive_runs_without_engine_or_site_packages(self):
commons = Path(__file__).resolve().parents[4]
data, digest, revision = build_bundle(commons)
self.assertEqual(build_bundle(commons)[1], digest)
with tempfile.TemporaryDirectory() as directory:
with tarfile.open(fileobj=io.BytesIO(data)) as archive:
names = archive.getnames()
self.assertTrue(names)
self.assertTrue(all(name.startswith('provisioning/') for name in names))
self.assertIn('provisioning/__init__.py', names)
self.assertIn('provisioning/seed.sh', names)
archive.extractall(directory, filter='data')
environment = dict(os.environ)
environment.pop('PYTHONPATH', None)
for module in ('provisioning.target', 'provisioning.access', 'provisioning.image'):
with self.subTest(module=module):
result = subprocess.run([sys.executable, '-S', '-m', module, '--help'],
cwd=directory, env=environment, capture_output=True,
text=True, timeout=30)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn('usage:', result.stdout)
def test_bundle_rejects_symlinked_source(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
package = root / 'ci/scripts/init-node/provisioning'
package.mkdir(parents=True)
(package / '__init__.py').write_text('')
(package / 'seed.sh').write_text('#!/bin/sh\n')
outside = root / 'outside.py'
outside.write_text('private = True\n')
(package / 'target.py').symlink_to(outside)
with self.assertRaisesRegex(TransportError, 'unsafe source path'):
build_bundle(root)
def test_missing_input_is_a_safe_validation_error(self):
with self.assertRaises(TransportError):
local_file('/nonexistent/apex-user-data', 'User-data')
def test_standalone_help_runs_without_engine_or_yaml_imports(self):
launcher = Path(__file__).resolve().parents[2] / 'init-node.py'
with tempfile.TemporaryDirectory() as directory:
result = subprocess.run([sys.executable, '-S', str(launcher), '--help'],
cwd=directory, capture_output=True, text=True, timeout=30)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn('--user-data', result.stdout)
self.assertIn('--identity-file', result.stdout)
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('provisioning.transport.SSH', Remote), \
patch('provisioning.transport.build_bundle', return_value=(b'bundle', 'a' * 64, 'b' * 40)), \
patch('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 'provisioning.target complete' in script:
raise TransportError('record failed')
else:
value = b''
return subprocess.CompletedProcess([], 0, value, b'')
messages = []
with patch('provisioning.transport.SSH', Remote), \
patch('provisioning.transport.build_bundle', return_value=(b'bundle', 'a' * 64, 'b' * 40)), \
patch('provisioning.transport.protected_handover'), \
patch('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()
+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")
}
}
}
-4
View File
@@ -19,10 +19,6 @@ select = ["E", "F", "W", "I", "B", "UP", "S"]
# Subprocess arguments are constructed explicitly; no implicit shell execution.
ignore = ["E401", "E501", "E702", "E741", "I001", "UP006", "UP035", "UP045", "S603", "S607"]
[tool.ruff.lint.per-file-ignores]
# These tests use synthetic paths; no fixed temporary path is written.
"ci/scripts/init-node/tests/test_provision_transport.py" = ["S108"]
[tool.mypy]
python_version = "3.10"
warn_return_any = true
+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'])
+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()
+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()