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