Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d733a8f5d | ||
|
|
00d19c3008 | ||
|
|
bbeeffcb24 | ||
|
|
ed7689b9b0 | ||
|
|
faefd15490 | ||
|
|
e37f28ad65 | ||
|
|
cbeec6551c | ||
|
|
339b2b94d6 | ||
|
|
c9518e9c11 | ||
|
|
7ad4473e93 | ||
|
|
f9091d116a
|
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,124 +0,0 @@
|
||||
name: ci/packer - main/manual-build-golden-image
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
upload_s3:
|
||||
description: "Upload built image to specified static S3 Object Storage"
|
||||
type: boolean
|
||||
default: true
|
||||
|
||||
concurrency:
|
||||
group: ci-golden-image
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
validate-scripts:
|
||||
name: "Validate: Provisioner Scripts"
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.10"
|
||||
|
||||
- name: Install Developer Dependencies
|
||||
run: python3 -m pip install ruff==0.17.2 "PyYAML>=6.0,<7"
|
||||
|
||||
- name: Validate Shell Script Syntax
|
||||
run: |
|
||||
for script in ci/packer/scripts/*.sh ci/scripts/init-node/provisioning/seed.sh; do
|
||||
bash -n "$script"
|
||||
done
|
||||
make check
|
||||
|
||||
- name: Setup Packer
|
||||
uses: hashicorp/setup-packer@main
|
||||
with:
|
||||
version: "latest"
|
||||
|
||||
- name: Validate Packer Template
|
||||
run: |
|
||||
packer init ci/packer/apex-debian-13.pkr.hcl
|
||||
packer validate ci/packer/apex-debian-13.pkr.hcl
|
||||
|
||||
build-image:
|
||||
name: "Build: Golden Image"
|
||||
needs: validate-scripts
|
||||
runs-on: ubuntu-24.04
|
||||
outputs:
|
||||
image_name: ${{ steps.vars.outputs.image_name }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Compute Image Metadata
|
||||
id: vars
|
||||
run: |
|
||||
SHORT_SHA="$(echo ${{ github.sha }} | cut -c1-7)"
|
||||
IMAGE_NAME="com-ermnvldmr-apex-debian-13-amd64-${SHORT_SHA}.qcow2"
|
||||
echo "image_name=${IMAGE_NAME}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Install QEMU & Build Tools
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y qemu-system-x86 qemu-utils xorriso
|
||||
sudo chmod 666 /dev/kvm 2>/dev/null || true
|
||||
|
||||
- name: Setup Packer
|
||||
uses: hashicorp/setup-packer@main
|
||||
with:
|
||||
version: "latest"
|
||||
|
||||
- name: Build Debian 13 Image with Packer
|
||||
run: |
|
||||
packer init ci/packer/apex-debian-13.pkr.hcl
|
||||
packer build -var "vm_name=${{ steps.vars.outputs.image_name }}" -var "provision_revision=${{ github.sha }}" ci/packer/apex-debian-13.pkr.hcl
|
||||
|
||||
- name: Upload Image Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: golden-image
|
||||
path: output-qemu/${{ steps.vars.outputs.image_name }}
|
||||
retention-days: 2
|
||||
|
||||
upload-s3:
|
||||
name: "Upload: S3 Storage"
|
||||
needs: build-image
|
||||
if: inputs.upload_s3 == true
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Download Image Artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: golden-image
|
||||
path: output-qemu
|
||||
|
||||
- name: Upload to Static S3 (/dev/images/)
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.STATIC_AWS_ACCESS_KEY_ID || secrets.COM_ERMNVLDMR_STATIC_AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.STATIC_AWS_SECRET_ACCESS_KEY || secrets.COM_ERMNVLDMR_STATIC_AWS_SECRET_ACCESS_KEY }}
|
||||
AWS_DEFAULT_REGION: ${{ secrets.STATIC_AWS_REGION || secrets.COM_ERMNVLDMR_STATIC_S3_REGION || 'us-east-1' }}
|
||||
S3_ENDPOINT: ${{ secrets.STATIC_S3_ENDPOINT_URL || secrets.COM_ERMNVLDMR_STATIC_S3_ENDPOINT_URL }}
|
||||
S3_BUCKET: ${{ secrets.STATIC_S3_BUCKET || secrets.COM_ERMNVLDMR_STATIC_S3_BUCKET }}
|
||||
IMAGE_NAME: ${{ needs.build-image.outputs.image_name }}
|
||||
run: |
|
||||
if [ -n "${S3_BUCKET}" ] && [ -n "${AWS_ACCESS_KEY_ID}" ]; then
|
||||
ENDPOINT_FLAG=""
|
||||
if [ -n "${S3_ENDPOINT}" ]; then
|
||||
ENDPOINT_FLAG="--endpoint-url=${S3_ENDPOINT}"
|
||||
fi
|
||||
echo "Cleaning up older builds in dev/images/com-ermnvldmr-apex-debian-13-amd64-*.qcow2 to maintain quota..."
|
||||
aws ${ENDPOINT_FLAG} s3 rm "s3://${S3_BUCKET}/dev/images/" --recursive --exclude "*" --include "com-ermnvldmr-apex-debian-13-amd64-*.qcow2" || true
|
||||
echo "Uploading ${IMAGE_NAME}..."
|
||||
aws ${ENDPOINT_FLAG} s3 cp "output-qemu/${IMAGE_NAME}" "s3://${S3_BUCKET}/dev/images/${IMAGE_NAME}"
|
||||
echo "Golden image uploaded successfully to s3://${S3_BUCKET}/dev/images/${IMAGE_NAME}"
|
||||
else
|
||||
echo "S3 credentials not configured; skipping upload. Image artifact preserved in GitHub Actions run."
|
||||
fi
|
||||
@@ -16,3 +16,5 @@ __pycache__/
|
||||
*.env
|
||||
.env
|
||||
*.bak
|
||||
._*
|
||||
.DS_Store
|
||||
@@ -10,12 +10,12 @@ Local infrastructure operations for independently owned nodes, with a Python sta
|
||||
|
||||
- **Live Documentation**: <https://docs.ermnvldmr.com/en/apex/> (also available in [Russian](https://docs.ermnvldmr.com/ru/apex/))
|
||||
- **Getting Started Guide**: <https://docs.ermnvldmr.com/en/apex/getting-started/>
|
||||
- **Report an Issue**: <https://github.com/deytenit/apex.ermnvldmr.com/issues>
|
||||
- **Report an Issue**: <https://git.ermnvldmr.com/deytenit/apex.ermnvldmr.com/issues>
|
||||
- **License**: [BSD-2-Clause](#copyright--license)
|
||||
|
||||
```sh
|
||||
# Clone your node's repository (which pins apex as commons/)
|
||||
git clone --recurse-submodules git@github.com:you/selfhost.example.com.git
|
||||
git clone --recurse-submodules ssh://git@git.example.com:22222/infrastructure/selfhost.example.com.git
|
||||
cd selfhost.example.com && ./init.sh && source ~/.bashrc
|
||||
|
||||
# Configure node identity
|
||||
@@ -47,7 +47,7 @@ You should understand the following invariants before exploring the codebase:
|
||||
- **[`compositions/`](./compositions)**: The shared core Compose stack (`traefik`, `socket-proxy`, `alloy`, `adguard`, `xray`, `restic`).
|
||||
- **[`engine/`](./engine)**: Framework engine — action discovery, overlay resolution, context injection, and logging.
|
||||
- **[`etc/`](./etc)**: Shared static configuration, including Alloy.
|
||||
- **[`ci/`](./ci)**: Host image build recipes, cloud-init templates, and standalone Debian enrollment tooling.
|
||||
- **[`ci/`](./ci)**: Host image build recipes, cloud-init templates, and image enrollment tooling.
|
||||
- **[`assets/`](./assets)**: Repository branding assets (`logo.png`).
|
||||
|
||||
---
|
||||
@@ -104,18 +104,19 @@ Choose one output mode per invocation.
|
||||
|
||||
### Infrastructure Administrator
|
||||
|
||||
Bootstrapping clean Debian 13 hosts on bare metal or cloud providers without cloud-init support.
|
||||
Build Debian 13 amd64 golden images with Packer, then supply node identity,
|
||||
operator keys, and the node repository through
|
||||
[`ci/cloud-init/user-data.template.yaml`](./ci/cloud-init/user-data.template.yaml)
|
||||
at first boot. Keep filled user-data and repository credentials outside Git.
|
||||
|
||||
- **Initialization Tool**: A standalone workstation bootstrap tool targets clean Debian 13 amd64 installations using the same baseline as Packer.
|
||||
- **Template**: Copy and fill out [`ci/cloud-init/user-data.template.yaml`](./ci/cloud-init/user-data.template.yaml) with target identity, operator keys, and node repository.
|
||||
- **Run Enrollment**:
|
||||
```sh
|
||||
python3 ci/scripts/init-node.py root@server --user-data /private/path/location.user-data.yaml
|
||||
```
|
||||
- **Prerequisites**: Run from this commons checkout with Python 3.10+ and OpenSSH. The target must be Debian 13 amd64, reachable using a trusted SSH host key as root or an administrator with passwordless `sudo`. The node checkout must declare `APEX_ENROLLMENT_INTERFACE=1` and have a matching enrollment-aware `init.sh`. Use the supported template subset, not arbitrary cloud-config directives. Keep filled user-data and repository credentials outside Git; supply trusted repository host keys for SSH clones.
|
||||
- **Bootstrap Dependencies**: The target seed installs distro `python3-yaml` for user-data validation; this is separate from the standard-library APEX core. ZFS setup needs matching running-kernel headers/modules. A mismatch stops with a diagnosis; enrollment does not reboot automatically.
|
||||
- **Security Handover**: Safely switches SSH to key-only authentication on port 2222 (`adam@server`) protected by an automated rollback watchdog, activates UFW firewall rules, Docker iptables integration, and CrowdSec.
|
||||
- **Resuming**: Follow the emitted standalone resume command. After committed access handover, use `python3 ci/scripts/init-node.py adam@server --port 2222 --user-data /private/path/location.user-data.yaml`; before handover, use the bootstrap endpoint after pending rollback. An uncertain handover requires checking which endpoint is active. Preserve any `--identity-file` option. Core `apex` has no remote `init` action.
|
||||
```sh
|
||||
packer init ci/packer/apex-debian-13.pkr.hcl
|
||||
packer validate ci/packer/apex-debian-13.pkr.hcl
|
||||
packer build ci/packer/apex-debian-13.pkr.hcl
|
||||
```
|
||||
|
||||
Cloud-init runs the image's bundled enrollment helper. After enrollment, run
|
||||
`apex configure` and `apex compose up` on the node.
|
||||
|
||||
### Developer & Contributor
|
||||
|
||||
@@ -129,15 +130,15 @@ Adding shared actions or improving the engine.
|
||||
. .venv/bin/activate
|
||||
python3 -m pip install ruff==0.17.2 'PyYAML>=6.0,<7'
|
||||
```
|
||||
- **Verification**: `make check` runs lint and both the core and standalone provisioning suites. `make test` (or `bash tests/run.sh`) runs both suites; discovery limited to `tests/` omits provisioning. PyYAML is needed for the provisioning tests, not core execution. Environment integration tests use `docker compose` or standalone `docker-compose` with `config`; no daemon is needed.
|
||||
- **Verification**: `make check` runs lint and both the core and image provisioning suites. `make test` (or `bash tests/run.sh`) runs both suites; discovery limited to `tests/` omits provisioning. PyYAML is needed for the provisioning tests, not core execution. Environment integration tests use `docker compose` or standalone `docker-compose` with `config`; no daemon is needed.
|
||||
- **Linting**: Ruff checks correctness and security rules. Existing import-order, line-length, compact-statement, and annotation-style debt is explicitly deferred in `pyproject.toml` to avoid unrelated formatting changes. Without Ruff, `make lint` recursively checks Python compilation and fails on syntax errors; that fallback is not equivalent to Ruff. `make fmt` formats files and is a separate, deliberate operation.
|
||||
- **Packer**: Run `packer init ci/packer/apex-debian-13.pkr.hcl` and `packer validate ci/packer/apex-debian-13.pkr.hcl` for template validation. A VM build or live enrollment is a separate operation; unit tests and template validation do not demonstrate a deployed host.
|
||||
- **Packer**: Run `packer init ci/packer/apex-debian-13.pkr.hcl` and `packer validate ci/packer/apex-debian-13.pkr.hcl` for template validation. A VM build or first-boot enrollment is a separate operation; unit tests and template validation do not demonstrate a deployed host.
|
||||
|
||||
---
|
||||
|
||||
## Communication & Support
|
||||
|
||||
- **Issues**: <https://github.com/deytenit/apex.ermnvldmr.com/issues>
|
||||
- **Issues**: <https://git.ermnvldmr.com/deytenit/apex.ermnvldmr.com/issues>
|
||||
- **Email**: <personal@ermnvldmr.com>
|
||||
|
||||
## Copyright & License
|
||||
|
||||
+48
-3
@@ -1,5 +1,5 @@
|
||||
# actions/backup/run.py
|
||||
"""Label-driven restic backup via resticontainer (github.com/deytenit/resticontainer).
|
||||
"""Label-driven restic backup via resticontainer (git.ermnvldmr.com/deytenit/resticontainer).
|
||||
Runs the resticontainer core service one-shot: init-if-needed -> backup (discovers
|
||||
`restic.*`-labelled services, runs their pre-hooks/stops, snapshots the union of the
|
||||
resolved host paths in a single restic run) -> forget --prune -> Telegram. Every
|
||||
@@ -7,12 +7,44 @@ non-`backup` subcommand passes straight through to restic, so init/snapshots/for
|
||||
behave exactly as before."""
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import time
|
||||
from engine.descriptor import Meta, Arg
|
||||
|
||||
METADATA = Meta(summary="Run the resticontainer label-driven backup (init-if-needed, backup, forget --prune).",
|
||||
args=[Arg("telegram_bot_url", "Telegram Bot URL for notifications")])
|
||||
TITLE = "Restic Host Backup"
|
||||
|
||||
def write_backup_status(path, state, timestamp):
|
||||
path = Path(path)
|
||||
previous = json.loads(path.read_text()) if path.exists() else {}
|
||||
status = {"state": state, "updated_at": timestamp,
|
||||
"last_success": timestamp if state == "success" else previous.get("last_success", 0)}
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = path.with_name(path.name + f".{os.getpid()}.tmp")
|
||||
temporary.write_text(json.dumps(status) + "\n")
|
||||
temporary.chmod(0o644)
|
||||
temporary.replace(path)
|
||||
|
||||
def restore_containers(ctx, container_ids):
|
||||
"""Recover previously running backup targets, retrying namespace dependents."""
|
||||
pending = list(container_ids)
|
||||
if not pending:
|
||||
return
|
||||
for _ in range(len(pending) + 1):
|
||||
cp = ctx.sys.run(["docker", "inspect", *pending], capture=True)
|
||||
stopped = [c["Id"] for c in json.loads(cp.stdout) if not c["State"]["Running"]]
|
||||
if not stopped:
|
||||
return
|
||||
progress = False
|
||||
for container_id in stopped:
|
||||
cp = ctx.sys.run(["docker", "start", container_id], check=False, capture=True)
|
||||
progress = progress or cp.returncode == 0
|
||||
if not progress:
|
||||
raise RuntimeError("Backup left containers stopped: " + ", ".join(stopped))
|
||||
pending = stopped
|
||||
raise RuntimeError("Backup containers did not stay running: " + ", ".join(pending))
|
||||
|
||||
def run(ctx, args):
|
||||
log, node, url = ctx.log, ctx.node.name, args.telegram_bot_url
|
||||
core = ctx.paths.core
|
||||
@@ -42,6 +74,8 @@ def run(ctx, args):
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
status_path = Path("/var/lib/apex/backup-status.json")
|
||||
write_backup_status(status_path, "running", time.time())
|
||||
try:
|
||||
log.info("Checking restic repository availability...")
|
||||
if restic("snapshots", check=False).returncode == 0:
|
||||
@@ -50,8 +84,16 @@ def run(ctx, args):
|
||||
ctx.notify.error(TITLE, url, "restic init failed"); raise SystemExit(1)
|
||||
before = host_snapshot_ids()
|
||||
log.info("Backing up `restic.*`-labelled services (resticontainer discovers paths)...")
|
||||
if restic("backup", "--host", host, "--tag", tag, "--compression", "max",
|
||||
check=False).returncode != 0:
|
||||
targets = ctx.sys.run(["docker", "ps", "-q", "--no-trunc",
|
||||
"--filter", "label=restic.enable=true",
|
||||
"--filter", "label=restic.backup.stop=true"], capture=True).stdout.split()
|
||||
try:
|
||||
result = restic("backup", "--host", host, "--tag", tag, "--compression", "max",
|
||||
check=False)
|
||||
finally:
|
||||
# Resticontainer may start a network-namespace dependent before its owner.
|
||||
restore_containers(ctx, targets)
|
||||
if result.returncode != 0:
|
||||
ctx.notify.error(TITLE, url, "restic backup failed"); raise SystemExit(1)
|
||||
# Guard: the label-driven backup is a silent no-op if no service carries the
|
||||
# `restic.enable` label (resticontainer finds zero paths and skips restic).
|
||||
@@ -70,9 +112,12 @@ def run(ctx, args):
|
||||
log.success("Restic backup completed.")
|
||||
if not ctx.notify.success(TITLE, url, f"Restic backup + retention succeeded for {node}"):
|
||||
raise SystemExit(1) # bash exited 1 when the success telegram failed to send
|
||||
write_backup_status(status_path, "success", time.time())
|
||||
except SystemExit:
|
||||
write_backup_status(status_path, "failed", time.time())
|
||||
raise
|
||||
except Exception as e:
|
||||
write_backup_status(status_path, "failed", time.time())
|
||||
log.error(f"{type(e).__name__}: {e}")
|
||||
ctx.notify.error(TITLE, url, f"backup failed: {e}")
|
||||
raise SystemExit(1) from e
|
||||
@@ -0,0 +1,77 @@
|
||||
"""Deploy the opt-in native Alloy service using shared host/guest configs."""
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import tempfile
|
||||
|
||||
from engine.descriptor import Flag, Meta
|
||||
|
||||
METADATA = Meta(summary="Configure native Alloy from configs/alloy/config.json.",
|
||||
args=[Flag("--dry-run", "Validate the deployment plan without changes.")])
|
||||
|
||||
|
||||
def deployment(ctx):
|
||||
settings = Path(ctx.paths.configs) / "alloy/config.json"
|
||||
if not settings.exists():
|
||||
return None
|
||||
values = json.loads(settings.read_text())
|
||||
role = values.get("role")
|
||||
if role not in ("host", "guest"):
|
||||
raise ValueError("Alloy role must be host or guest")
|
||||
env_file = values.get("environment_file", "")
|
||||
if not re.fullmatch(r"/[A-Za-z0-9_./-]+", env_file):
|
||||
raise ValueError("Alloy environment_file must be an absolute path without whitespace")
|
||||
source = Path(ctx.paths.commons) / "etc/alloy" / (
|
||||
"native-host.alloy" if role == "host" else "native.alloy")
|
||||
config = source.read_text()
|
||||
extra = Path(ctx.paths.configs) / "alloy/extra.alloy"
|
||||
if extra.is_file():
|
||||
config += "\n" + extra.read_text()
|
||||
unit = ("[Service]\nEnvironmentFile=" + env_file + "\n"
|
||||
'ExecStart=\nExecStart=/usr/bin/alloy run --storage.path=/var/lib/alloy --server.http.listen-addr=127.0.0.1:12346 /etc/alloy/config.alloy\n'
|
||||
+ ("User=root\nGroup=root\n" if role == "guest" else "") +
|
||||
"SupplementaryGroups=systemd-journal" + (" docker" if role == "guest" else "") + "\n")
|
||||
return config, unit, env_file
|
||||
|
||||
|
||||
def run(ctx, args):
|
||||
plan = deployment(ctx)
|
||||
if plan is None:
|
||||
ctx.log.info("Native Alloy is not configured for this node; skipping.")
|
||||
return
|
||||
config, unit, env_file = plan
|
||||
if args.dry_run:
|
||||
ctx.log.info("Native Alloy deployment plan validated; no changes made.")
|
||||
return
|
||||
ctx.sys.sudo(["test", "-f", env_file])
|
||||
# Validate with the actual service environment without printing credentials.
|
||||
validator = '''import os, stat, subprocess, sys
|
||||
from pathlib import Path
|
||||
# Environment files deliberately use plain KEY=value, with no shell evaluation.
|
||||
secret = Path(sys.argv[1])
|
||||
info = secret.stat()
|
||||
if info.st_uid != 0 or stat.S_IMODE(info.st_mode) & 0o077:
|
||||
raise RuntimeError('Alloy environment file must be root-owned with mode 0600 or stricter')
|
||||
for line in secret.read_text().splitlines():
|
||||
if not line.strip() or line.lstrip().startswith('#'): continue
|
||||
key, value = line.split('=', 1)
|
||||
os.environ[key] = value
|
||||
subprocess.run(['alloy', 'validate', sys.argv[2]], check=True)
|
||||
'''
|
||||
with tempfile.TemporaryDirectory(prefix="apex-alloy-") as directory:
|
||||
config_path = Path(directory) / "config.alloy"
|
||||
unit_path = Path(directory) / "apex.conf"
|
||||
config_path.write_text(config)
|
||||
unit_path.write_text(unit)
|
||||
ctx.sys.sudo(["python3", "-c", validator, env_file, str(config_path)])
|
||||
ctx.sys.sudo(["install", "-d", "-m", "0755", "/etc/alloy",
|
||||
"/etc/systemd/system/alloy.service.d"])
|
||||
ctx.sys.sudo(["install", "-m", "0644", str(config_path), "/etc/alloy/config.alloy"])
|
||||
ctx.sys.sudo(["install", "-m", "0644", str(unit_path),
|
||||
"/etc/systemd/system/alloy.service.d/apex.conf"])
|
||||
ctx.sys.sudo(["systemctl", "unmask", "alloy.service"])
|
||||
ctx.sys.daemon_reload()
|
||||
ctx.sys.sudo(["systemctl", "enable", "alloy.service"])
|
||||
ctx.sys.sudo(["systemctl", "restart", "alloy.service"])
|
||||
ctx.sys.sudo(["systemctl", "is-active", "--quiet", "alloy.service"])
|
||||
ctx.log.success("Configured native Alloy.")
|
||||
+27
-32
@@ -1,64 +1,59 @@
|
||||
# actions/sync/repository.py
|
||||
"""Port of sync/repository — capture-up commit + rebase onto origin/main + force-push."""
|
||||
"""Commit node configuration and synchronize main directly with its remote."""
|
||||
import os
|
||||
from datetime import datetime
|
||||
from engine.descriptor import Meta, Arg
|
||||
from engine.identity import read_env
|
||||
|
||||
METADATA = Meta(summary="Capture-up: commit node changes, rebase onto origin/main, force-push sync/<node>.",
|
||||
args=[Arg("telegram_bot_url", "Telegram Bot URL for notifications")])
|
||||
TITLE = "Annual Remote Sync"
|
||||
METADATA = Meta(summary="Commit node changes, rebase onto origin/main, and push main.",
|
||||
args=[Arg("telegram_bot_url", "Optional notification URL", required=False)])
|
||||
TITLE = "Repository Sync"
|
||||
|
||||
|
||||
def run(ctx, args):
|
||||
log, node, url = ctx.log, ctx.node.name, args.telegram_bot_url
|
||||
log, node = ctx.log, ctx.node.name
|
||||
url = args.telegram_bot_url
|
||||
repo, commons = ctx.paths.repo_root, ctx.paths.commons
|
||||
ne = read_env(os.path.join(repo, "node.env")) # per-node bot identity (generic defaults)
|
||||
ne = read_env(os.path.join(repo, "node.env"))
|
||||
author = ne.get("APEX_GIT_AUTHOR_NAME", "apex [bot]")
|
||||
email = ne.get("APEX_GIT_AUTHOR_EMAIL", "apex@localhost")
|
||||
env = dict(os.environ,
|
||||
GIT_AUTHOR_NAME=author, GIT_AUTHOR_EMAIL=email,
|
||||
env = dict(os.environ, GIT_AUTHOR_NAME=author, GIT_AUTHOR_EMAIL=email,
|
||||
GIT_COMMITTER_NAME=author, GIT_COMMITTER_EMAIL=email)
|
||||
|
||||
def git(*a, check=True):
|
||||
return ctx.sys.run(["git", "-C", repo, *a], check=check, env=env)
|
||||
def git(*a, check=True, capture=False):
|
||||
return ctx.sys.run(["git", "-C", repo, *a], check=check, capture=capture, env=env)
|
||||
|
||||
try:
|
||||
branch = git("symbolic-ref", "--quiet", "--short", "HEAD", capture=True).stdout.strip()
|
||||
if branch != "main":
|
||||
raise RuntimeError("Repository sync requires main; switch branches explicitly first.")
|
||||
cp = ctx.sys.run(["git", "-C", commons, "status", "--porcelain"], capture=True)
|
||||
if (cp.stdout or "").strip():
|
||||
log.error(f"Commons submodule at {commons} has uncommitted changes. Aborting.")
|
||||
raise SystemExit(1)
|
||||
raise RuntimeError(f"Commons submodule at {commons} has uncommitted changes.")
|
||||
if not os.path.isdir(ctx.paths.compositions):
|
||||
# Without this, `git add -A` would stage a deleted node tree and an
|
||||
# unattended cron run would commit + force-push the deletion.
|
||||
log.error(f"Directory '{ctx.paths.compositions}' does not exist. Cannot sync.")
|
||||
raise SystemExit(1)
|
||||
branch = f"sync/{node}"
|
||||
ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
if ctx.sys.ok(["git", "-C", repo, "rev-parse", "--verify", branch]):
|
||||
git("checkout", branch)
|
||||
else:
|
||||
git("checkout", "-b", branch)
|
||||
raise RuntimeError(f"Directory '{ctx.paths.compositions}' does not exist.")
|
||||
expected = git("rev-parse", "HEAD:commons", capture=True).stdout.strip()
|
||||
actual = ctx.sys.run(["git", "-C", commons, "rev-parse", "HEAD"], capture=True).stdout.strip()
|
||||
if actual != expected:
|
||||
raise RuntimeError("Commons checkout differs from the committed pin; update the submodule before retrying.")
|
||||
git("add", "-A")
|
||||
git("submodule", "update", "--init", "--recursive")
|
||||
git("add", "commons")
|
||||
if git("diff", "--cached", "--quiet", check=False).returncode != 0:
|
||||
ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
git("commit", "-m", f"[{node}] Auto-commit: {ts}")
|
||||
else:
|
||||
log.info("No changes to commit.")
|
||||
git("fetch", "origin", "main")
|
||||
if git("rebase", "origin/main", check=False).returncode != 0:
|
||||
log.error("Rebase conflict — aborting rebase; manual intervention required.")
|
||||
git("rebase", "--abort", check=False)
|
||||
ctx.notify.error(TITLE, url, "Rebase conflict. Manual intervention required.")
|
||||
raise SystemExit(1)
|
||||
raise RuntimeError("Rebase conflict; local commits retained for manual resolution.")
|
||||
git("submodule", "update", "--init", "--recursive")
|
||||
git("push", "--force-with-lease", "origin", branch)
|
||||
log.success(f"Pushed {branch}.")
|
||||
if not ctx.notify.success(TITLE, url, f"Sync to '{branch}' completed."):
|
||||
git("push", "origin", "HEAD:refs/heads/main")
|
||||
log.success("Synchronized main.")
|
||||
if url and not ctx.notify.success(TITLE, url, "Sync to main completed."):
|
||||
raise SystemExit(1)
|
||||
except SystemExit:
|
||||
raise
|
||||
except Exception as e:
|
||||
log.error(f"{type(e).__name__}: {e}")
|
||||
ctx.notify.error(TITLE, url, f"Sync failed: {e}")
|
||||
if url:
|
||||
ctx.notify.error(TITLE, url, f"Sync failed: {e}")
|
||||
raise SystemExit(1) from e
|
||||
@@ -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"
|
||||
|
||||
@@ -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,65 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Initialize a plain Debian target server via SSH enrollment.
|
||||
|
||||
Standalone tool for hosts on providers that do not support cloud-init.
|
||||
Kept separate from the core local-only apex engine.
|
||||
"""
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
|
||||
_here = os.path.dirname(os.path.abspath(__file__))
|
||||
_repo_root = os.path.dirname(os.path.dirname(_here))
|
||||
_prov_dir = os.path.join(_here, "init-node")
|
||||
sys.path.insert(0, _prov_dir)
|
||||
|
||||
# The standalone package lives beside this launcher, outside the engine.
|
||||
from provisioning.transport import initialize # noqa: E402
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Initialize a plain Debian server through protected SSH enrollment.",
|
||||
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
|
||||
)
|
||||
parser.add_argument(
|
||||
"target",
|
||||
help="Bootstrap USER@HOST (root or an existing administrator with passwordless sudo)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--user-data",
|
||||
dest="user_data",
|
||||
help="Path to APEX cloud-config file (e.g., ci/cloud-init/user-data.template.yaml)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--port",
|
||||
dest="port",
|
||||
help="Bootstrap SSH port; otherwise uses standard SSH configuration",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--identity-file",
|
||||
dest="identity_file",
|
||||
help="SSH private identity file; otherwise uses SSH configuration/agent",
|
||||
)
|
||||
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
print(f"[init-node] Starting enrollment for {args.target}...")
|
||||
try:
|
||||
initialize(
|
||||
_repo_root,
|
||||
args.target,
|
||||
args.user_data,
|
||||
args.port,
|
||||
args.identity_file,
|
||||
progress=lambda msg: print(f"[init-node] {msg}"),
|
||||
)
|
||||
print(f"[init-node] Successfully initialized {args.target}.")
|
||||
return 0
|
||||
except Exception as e:
|
||||
sys.stderr.write(f"[init-node] Error: {e}\n")
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Deterministic provisioning payloads for golden-image builds."""
|
||||
import hashlib
|
||||
import io
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import tarfile
|
||||
|
||||
from .state import ProvisionError
|
||||
|
||||
|
||||
def _bundle_from_source(root):
|
||||
root = Path(root).resolve()
|
||||
package = root / 'provisioning'
|
||||
files = sorted(package.rglob('*.py'))
|
||||
seed = package / 'seed.sh'
|
||||
guard = package / 'profiles.sh'
|
||||
if not seed.is_file() or not (package / '__init__.py').is_file():
|
||||
raise ProvisionError('Provisioning package or seed is missing')
|
||||
files.append(seed)
|
||||
if guard.is_file():
|
||||
files.append(guard)
|
||||
output = io.BytesIO()
|
||||
with tarfile.open(fileobj=output, mode='w') as archive:
|
||||
for path in files:
|
||||
if (not path.is_file() or '__pycache__' in path.parts
|
||||
or any(part.is_symlink() for part in (path, *path.parents))):
|
||||
raise ProvisionError('Provisioning bundle contains an unsafe source path')
|
||||
data = path.read_bytes()
|
||||
info = tarfile.TarInfo(path.relative_to(root).as_posix())
|
||||
info.size, info.mode, info.mtime = len(data), (0o755 if path == seed else 0o644), 0
|
||||
info.uid = info.gid = 0
|
||||
archive.addfile(info, io.BytesIO(data))
|
||||
data = output.getvalue()
|
||||
return data, hashlib.sha256(data).hexdigest()
|
||||
|
||||
|
||||
def build_bundle(commons):
|
||||
root = Path(commons).resolve()
|
||||
data, digest = _bundle_from_source(root / 'ci/scripts/init-node')
|
||||
revision = 'unversioned-modified'
|
||||
if shutil.which('git'):
|
||||
result = subprocess.run(['git', '-C', str(root), 'rev-parse', 'HEAD'], capture_output=True, text=True, timeout=30)
|
||||
if result.returncode == 0 and re.fullmatch(r'[a-f0-9]{40,64}\n?', result.stdout):
|
||||
revision = result.stdout.strip()
|
||||
dirty = subprocess.run(['git', '-C', str(root), 'status', '--porcelain', '--', 'ci/scripts/init-node'], capture_output=True, text=True, timeout=30)
|
||||
if dirty.returncode or dirty.stdout.strip():
|
||||
revision += '-modified'
|
||||
return data, digest, revision
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Image-only installation and sanitization; never called by remote enrollment."""
|
||||
"""Image-only installation and sanitization; for golden-image builds."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
@@ -16,7 +16,7 @@ from .target import preflight, run
|
||||
|
||||
|
||||
def install():
|
||||
from .transport import _bundle_from_source
|
||||
from .bundle import _bundle_from_source
|
||||
source = Path(__file__).resolve().parents[1]
|
||||
data, digest = _bundle_from_source(source)
|
||||
destination = safe_path(Path('/usr/local/lib/apex-provisioner') / digest)
|
||||
|
||||
@@ -1,394 +0,0 @@
|
||||
"""Workstation SSH transport. User-data stays opaque until target validation."""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import io
|
||||
import ipaddress
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shlex
|
||||
import shutil
|
||||
import subprocess
|
||||
import tarfile
|
||||
import tempfile
|
||||
import time
|
||||
import uuid
|
||||
|
||||
|
||||
class TransportError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class HandoverUncertain(TransportError):
|
||||
pass
|
||||
|
||||
|
||||
# This runs before Python is guaranteed to exist on the Debian target. All
|
||||
# ancestors must be trusted; a root-owned sticky temporary directory is safe
|
||||
# only because root owns each managed child and exclusive mktemp creates it.
|
||||
REMOTE_PATH_GUARD = r'''
|
||||
set -eu
|
||||
umask 077
|
||||
apex_guard_dir() {
|
||||
apex_checked=$1
|
||||
while :; do
|
||||
test ! -L "$apex_checked" && test -d "$apex_checked" || exit 91
|
||||
test "$(stat -c %u -- "$apex_checked")" = 0 || exit 91
|
||||
apex_mode=$(stat -c %a -- "$apex_checked")
|
||||
test "$((0$apex_mode & 0022))" = 0 || test "$((0$apex_mode & 01000))" != 0 || exit 91
|
||||
test "$apex_checked" != / || break
|
||||
apex_checked=${apex_checked%/*}
|
||||
test -n "$apex_checked" || apex_checked=/
|
||||
done
|
||||
}
|
||||
apex_ensure_dir() {
|
||||
apex_new_dir=$1
|
||||
if test -e "$apex_new_dir" || test -L "$apex_new_dir"; then
|
||||
apex_guard_dir "$apex_new_dir"
|
||||
apex_mode=$(stat -c %a -- "$apex_new_dir")
|
||||
test "$((0$apex_mode & 0022))" = 0 || exit 91
|
||||
else
|
||||
apex_guard_dir "${apex_new_dir%/*}"
|
||||
mkdir -m "$2" -- "$apex_new_dir"
|
||||
apex_guard_dir "$apex_new_dir"
|
||||
fi
|
||||
}
|
||||
apex_guard_file() {
|
||||
apex_file=$1
|
||||
apex_guard_dir "${apex_file%/*}"
|
||||
apex_mode=$(stat -c %a -- "${apex_file%/*}")
|
||||
test "$((0$apex_mode & 0022))" = 0 || exit 91
|
||||
test ! -L "$apex_file" || exit 91
|
||||
if test -e "$apex_file"; then
|
||||
test -f "$apex_file" || exit 91
|
||||
test "$(stat -c %u -- "$apex_file")" = 0 || exit 91
|
||||
test "$(stat -c %h -- "$apex_file")" = 1 || exit 91
|
||||
apex_mode=$(stat -c %a -- "$apex_file")
|
||||
test "$((0$apex_mode & 0022))" = 0 || exit 91
|
||||
fi
|
||||
}
|
||||
apex_atomic_input() {
|
||||
apex_destination=$1
|
||||
apex_guard_file "$apex_destination"
|
||||
apex_temporary=$(mktemp "${apex_destination%/*}/.apex-write.XXXXXXXXXX")
|
||||
trap 'rm -f -- "$apex_temporary"' EXIT HUP INT TERM
|
||||
cat > "$apex_temporary"
|
||||
chmod 0600 "$apex_temporary"
|
||||
apex_guard_file "$apex_destination"
|
||||
mv -T -- "$apex_temporary" "$apex_destination"
|
||||
trap - EXIT HUP INT TERM
|
||||
}
|
||||
'''
|
||||
|
||||
|
||||
def remote_atomic_input(path):
|
||||
return REMOTE_PATH_GUARD + '\napex_atomic_input ' + shlex.quote(path)
|
||||
|
||||
|
||||
def remote_bundle_install(stage, installed, digest):
|
||||
return REMOTE_PATH_GUARD + '\n' + (
|
||||
'apex_guard_dir ' + shlex.quote(stage) + '; apex_guard_file ' + shlex.quote(stage + '/bundle.tar') + '; '
|
||||
'test "$(sha256sum ' + shlex.quote(stage + '/bundle.tar') + ' | cut -d " " -f 1)" = ' + digest + '; '
|
||||
'apex_ensure_dir /usr/local/lib/apex-provisioner 0755; '
|
||||
'if test -e ' + installed + ' || test -L ' + installed + '; then '
|
||||
'apex_guard_dir ' + installed + '; '
|
||||
'apex_bad=$(find ' + installed + ' -xdev \\( -type l -o ! -user root -o -perm /022 -o \\( ! -type d -a ! -type f \\) \\) -print -quit); test -z "$apex_bad"; '
|
||||
'else apex_install_tmp=$(mktemp -d /usr/local/lib/apex-provisioner/.install.XXXXXXXXXX); '
|
||||
'trap \'rm -rf -- "$apex_install_tmp"\' EXIT HUP INT TERM; '
|
||||
'tar --no-same-owner --same-permissions -xf ' + stage + '/bundle.tar -C "$apex_install_tmp"; '
|
||||
'mv -T -- "$apex_install_tmp" ' + installed + '; trap - EXIT HUP INT TERM; fi; '
|
||||
'tar --compare -f ' + stage + '/bundle.tar -C ' + installed)
|
||||
|
||||
|
||||
def local_file(value, name):
|
||||
if not value or any(ord(c) < 32 for c in str(value)):
|
||||
raise TransportError(f'{name} must name a readable regular file')
|
||||
try:
|
||||
path = Path(value).expanduser().resolve(strict=True)
|
||||
except (OSError, ValueError):
|
||||
raise TransportError(f'{name} must name a readable regular file') from None
|
||||
if not path.is_file() or not os.access(path, os.R_OK):
|
||||
raise TransportError(f'{name} must name a readable regular file')
|
||||
return path
|
||||
|
||||
|
||||
def destination(value, port=None):
|
||||
match = re.fullmatch(r'([A-Za-z_][A-Za-z0-9_.-]{0,31})@([A-Za-z0-9][A-Za-z0-9_.:-]*|\[[0-9a-fA-F:]+\])', value or '')
|
||||
if not match or '..' in match[2]:
|
||||
raise TransportError('Target must be USER@HOST without SSH options; use root or an existing administrator with sudo -n')
|
||||
hostname = match[2].strip('[]')
|
||||
if ':' in hostname:
|
||||
try:
|
||||
ipaddress.IPv6Address(hostname)
|
||||
except ValueError:
|
||||
raise TransportError('IPv6 targets must contain a valid address; pass ports separately') from None
|
||||
if port is not None and (not re.fullmatch(r'[0-9]{1,5}', str(port)) or not 1 <= int(port) <= 65535):
|
||||
raise TransportError('Port must be an integer from 1 through 65535')
|
||||
return match[1], hostname, int(port) if port is not None else None
|
||||
|
||||
|
||||
def _bundle_from_source(root):
|
||||
root = Path(root).resolve()
|
||||
package = root / 'provisioning'
|
||||
files = sorted(package.rglob('*.py'))
|
||||
seed = package / 'seed.sh'
|
||||
if not seed.is_file() or not (package / '__init__.py').is_file():
|
||||
raise TransportError('Provisioning package or seed is missing')
|
||||
files.append(seed)
|
||||
output = io.BytesIO()
|
||||
with tarfile.open(fileobj=output, mode='w') as archive:
|
||||
for path in files:
|
||||
if (not path.is_file() or '__pycache__' in path.parts
|
||||
or any(part.is_symlink() for part in (path, *path.parents))):
|
||||
raise TransportError('Provisioning bundle contains an unsafe source path')
|
||||
data = path.read_bytes()
|
||||
info = tarfile.TarInfo(path.relative_to(root).as_posix())
|
||||
info.size, info.mode, info.mtime = len(data), (0o755 if path == seed else 0o644), 0
|
||||
info.uid = info.gid = 0
|
||||
archive.addfile(info, io.BytesIO(data))
|
||||
data = output.getvalue()
|
||||
return data, hashlib.sha256(data).hexdigest()
|
||||
|
||||
|
||||
def build_bundle(commons):
|
||||
root = Path(commons).resolve()
|
||||
data, digest = _bundle_from_source(root / 'ci/scripts/init-node')
|
||||
revision = 'unversioned-modified'
|
||||
if shutil.which('git'):
|
||||
result = subprocess.run(['git', '-C', str(root), 'rev-parse', 'HEAD'], capture_output=True, text=True, timeout=30)
|
||||
if result.returncode == 0 and re.fullmatch(r'[a-f0-9]{40,64}\n?', result.stdout):
|
||||
revision = result.stdout.strip()
|
||||
dirty = subprocess.run(['git', '-C', str(root), 'status', '--porcelain', '--', 'ci/scripts/init-node', 'ci/scripts/init-node.py'], capture_output=True, text=True, timeout=30)
|
||||
if dirty.returncode or dirty.stdout.strip():
|
||||
revision += '-modified'
|
||||
return data, digest, revision
|
||||
|
||||
|
||||
class SSH:
|
||||
def __init__(self, target, port=None, identity_file=None, runner=subprocess.run):
|
||||
self.user, self.host, self.port = destination(target, port)
|
||||
self.identity = local_file(identity_file, 'Identity file') if identity_file else None
|
||||
self.runner = runner
|
||||
self.resolved_host = None
|
||||
self.resolved_port = None
|
||||
self.trust_file = None
|
||||
|
||||
def arguments(self, fresh=False):
|
||||
args = ['ssh', '-T', '-o', 'StrictHostKeyChecking=yes', '-o', 'ConnectTimeout=15',
|
||||
'-o', 'ServerAliveInterval=10', '-o', 'ServerAliveCountMax=3']
|
||||
if self.identity:
|
||||
args += ['-i', str(self.identity)]
|
||||
args += ['-l', 'adam' if fresh else self.user]
|
||||
if fresh:
|
||||
if not self.trust_file or not self.resolved_host:
|
||||
raise TransportError('Fresh access requires the verified bootstrap host identity')
|
||||
args += ['-p', '2222', '-o', 'ControlMaster=no', '-o', 'ControlPath=none',
|
||||
'-o', 'ControlPersist=no', '-o', 'BatchMode=yes',
|
||||
'-o', 'PreferredAuthentications=publickey', '-o', 'PasswordAuthentication=no',
|
||||
'-o', 'KbdInteractiveAuthentication=no', '-o', 'Hostname=' + self.resolved_host,
|
||||
'-o', 'HostKeyAlias=apex-init-verified-target',
|
||||
'-o', 'UserKnownHostsFile=' + str(self.trust_file),
|
||||
'-o', 'GlobalKnownHostsFile=/dev/null']
|
||||
elif self.port:
|
||||
args += ['-p', str(self.port)]
|
||||
return args
|
||||
|
||||
def resolve(self):
|
||||
result = self.runner(self.arguments() + ['-G', '--', self.host], capture_output=True, text=True, timeout=30)
|
||||
if result.returncode:
|
||||
raise TransportError('Unable to resolve SSH connection configuration')
|
||||
values = dict(line.split(' ', 1) for line in result.stdout.splitlines() if ' ' in line)
|
||||
hostname = values.get('hostname', '')
|
||||
port = values.get('port', '')
|
||||
destination(self.user + '@' + hostname, port)
|
||||
self.resolved_host, self.resolved_port = hostname, int(port)
|
||||
|
||||
def run(self, script, data=None, fresh=False, privileged=True, check=True, timeout=300):
|
||||
command = ['sh', '-c', script]
|
||||
if privileged and (fresh or self.user != 'root'):
|
||||
command = ['sudo', '-n', '--', *command]
|
||||
result = self.runner(self.arguments(fresh) + ['--', self.host, shlex.join(command)],
|
||||
input=data, capture_output=True, timeout=timeout)
|
||||
if check and result.returncode:
|
||||
# SSH and child diagnostics can contain user-data or credential-bearing URLs.
|
||||
raise TransportError('Remote operation failed; inspect the root-owned target status for a redacted diagnosis')
|
||||
return result
|
||||
|
||||
def fresh_login(self):
|
||||
self.run('test "$(id -u)" = 0', fresh=True)
|
||||
|
||||
|
||||
def protected_handover(ssh, invoke, token, port, client_address):
|
||||
result = invoke('begin', token, port, client_address, False)
|
||||
try:
|
||||
ssh.fresh_login()
|
||||
if result['status'] == 'committed':
|
||||
return
|
||||
if result['status'] != 'existing':
|
||||
invoke('finalize', token, None, None, True)
|
||||
ssh.fresh_login()
|
||||
try:
|
||||
invoke('commit', token, None, None, True)
|
||||
except Exception:
|
||||
try:
|
||||
state = invoke('status', token, None, None, True)
|
||||
if state.get('token') != token or state.get('status') not in ('committed', 'existing', 'transition', 'final', 'rolled_back', 'recovery_pending'):
|
||||
raise TransportError('Unexpected access transition state')
|
||||
except Exception:
|
||||
raise HandoverUncertain('Access commit outcome is unknown; verify the administrator and bootstrap endpoints before resuming') from None
|
||||
if state['status'] != 'committed':
|
||||
raise
|
||||
except HandoverUncertain:
|
||||
raise
|
||||
except Exception:
|
||||
try:
|
||||
invoke('rollback', token, None, None, True)
|
||||
except Exception:
|
||||
try:
|
||||
invoke('rollback', token, None, None, False)
|
||||
except Exception: # noqa: S110 — diagnostics may contain secrets.
|
||||
pass # Persistent timer is authoritative if both connections are gone.
|
||||
raise
|
||||
|
||||
|
||||
def initialize(commons, target, user_data, port=None, identity_file=None, progress=print):
|
||||
if not shutil.which('ssh'):
|
||||
raise TransportError('OpenSSH ssh is required on the workstation')
|
||||
source = local_file(user_data, 'User-data')
|
||||
if source.stat().st_size > 1024 * 1024:
|
||||
raise TransportError('User-data must be no larger than 1 MiB')
|
||||
ssh = SSH(target, port, identity_file)
|
||||
ssh.resolve()
|
||||
bundle, digest, revision = build_bundle(commons)
|
||||
run_id = uuid.uuid4().hex
|
||||
installed = '/usr/local/lib/apex-provisioner/' + digest
|
||||
stage = None
|
||||
launched = False
|
||||
finished = False
|
||||
access_committed = False
|
||||
access_uncertain = False
|
||||
cleanup_policy = '/etc/tmpfiles.d/apex-init-' + run_id + '.conf'
|
||||
with tempfile.TemporaryDirectory(prefix='apex-init-trust-') as local:
|
||||
try:
|
||||
preflight = ssh.run('set -eu; test "$(id -u)" = 0; . /etc/os-release; '
|
||||
'test "$ID" = debian; test "$VERSION_ID" = 13; '
|
||||
'test "$(dpkg --print-architecture)" = amd64; '
|
||||
'command -v systemd-run >/dev/null; '
|
||||
'printf "%s\\n" "${SSH_CONNECTION:-}"; '
|
||||
'cat /etc/ssh/ssh_host_*_key.pub')
|
||||
lines = preflight.stdout.decode().splitlines()
|
||||
connection = lines[0].split() if lines else []
|
||||
if len(connection) != 4:
|
||||
# sudo commonly clears SSH_CONNECTION; read it before elevation instead.
|
||||
raw = ssh.run('printf "%s\\n" "$SSH_CONNECTION"', privileged=False).stdout.decode().split()
|
||||
connection = raw
|
||||
if len(connection) != 4:
|
||||
raise TransportError('Unable to inspect the established SSH connection')
|
||||
keys = [line for line in lines[1:] if re.fullmatch(r'(ssh-[\w-]+|ecdsa-[\w-]+) [A-Za-z0-9+/=]+(?: .*?)?', line)]
|
||||
if not keys:
|
||||
raise TransportError('Unable to obtain public host keys through the verified connection')
|
||||
trust = Path(local) / 'known_hosts'
|
||||
trust.write_text(''.join('apex-init-verified-target,[apex-init-verified-target]:2222 ' + ' '.join(key.split()[:2]) + '\n' for key in keys))
|
||||
trust.chmod(0o600)
|
||||
ssh.trust_file = trust
|
||||
existing = ssh.run(REMOTE_PATH_GUARD + '\napex_ensure_dir /var/lib/apex-init 0700; '
|
||||
'apex_guard_file /var/lib/apex-init/remote.json; '
|
||||
'if test -f /var/lib/apex-init/remote.json; then cat /var/lib/apex-init/remote.json; fi').stdout
|
||||
if existing:
|
||||
prior = json.loads(existing)
|
||||
previous_unit = prior.get('unit', '')
|
||||
if not re.fullmatch(r'apex-init-[a-f0-9]{32}', previous_unit):
|
||||
raise TransportError('Existing remote coordinator state requires manual inspection')
|
||||
active = ssh.run(shlex.join(['systemctl', 'is-active', '--quiet', previous_unit]), check=False)
|
||||
if active.returncode == 0:
|
||||
raise TransportError('An enrollment unit is still running: ' + previous_unit + '; wait for it to settle before resuming')
|
||||
stage = ssh.run(REMOTE_PATH_GUARD + '\napex_guard_dir /var/tmp; mktemp -d /var/tmp/apex-init.XXXXXXXXXX').stdout.decode().strip()
|
||||
if not re.fullmatch(r'/var/tmp/apex-init\.[A-Za-z0-9]{10}', stage): # noqa: S108 — validates remote mktemp output.
|
||||
raise TransportError('Target returned an invalid staging path')
|
||||
# Hard reboot can bypass the shell trap; boot-time tmpfiles removes opaque inputs.
|
||||
cleanup = ('r ' + stage + '/user-data - - - -\n' +
|
||||
'r ' + stage + '/heartbeat - - - -\n').encode()
|
||||
ssh.run(REMOTE_PATH_GUARD + '\napex_ensure_dir /etc/tmpfiles.d 0755; apex_atomic_input ' + cleanup_policy, cleanup)
|
||||
ssh.run(remote_atomic_input(stage + '/bundle.tar'), bundle)
|
||||
ssh.run(remote_atomic_input(stage + '/user-data'), source.read_bytes())
|
||||
install = remote_bundle_install(stage, installed, digest)
|
||||
progress('Installing the versioned target runtime and validating enrollment input.')
|
||||
ssh.run(install)
|
||||
base = ['python3', '-m', 'provisioning.target']
|
||||
options = ['--user-data', stage + '/user-data', '--revision', revision, '--bundle-digest', digest]
|
||||
# State remains inspectable after disconnect; user-data is removed by the durable unit.
|
||||
enrollment = shlex.join(base + ['enroll'] + options + ['--heartbeat', stage + '/heartbeat'])
|
||||
validation = shlex.join(base + ['validate'] + options)
|
||||
access_preflight = shlex.join(['python3', '-m', 'provisioning.access', 'preflight'])
|
||||
seed = shlex.join(['bash', installed + '/provisioning/seed.sh'])
|
||||
wrapper = ('umask 077; cd ' + installed + '; '
|
||||
'trap ' + shlex.quote('rm -f ' + stage + '/user-data ' + stage + '/heartbeat ' + cleanup_policy) + ' EXIT; '
|
||||
'{ ' + seed + ' && ' + validation + ' && ' + access_preflight + ' && ' + enrollment + '; } >' + stage + '/runner.log 2>&1; rc=$?; '
|
||||
'printf "%s\\n" "$rc" >' + stage + '/result.new; mv ' + stage + '/result.new ' + stage + '/result; exit "$rc"')
|
||||
unit = 'apex-init-' + run_id
|
||||
record = json.dumps({'unit': unit, 'staging': stage, 'revision': revision, 'bundle_digest': digest}).encode()
|
||||
ssh.run(remote_atomic_input('/var/lib/apex-init/remote.json'), record)
|
||||
ssh.run('touch ' + stage + '/heartbeat; ' + shlex.join(['systemd-run', '--quiet', '--unit', unit,
|
||||
'--property=Type=exec', '--property=KillMode=process', '/bin/sh', '-c', wrapper]))
|
||||
launched = True
|
||||
progress('Enrollment is running under ' + unit + '; target staging: ' + stage)
|
||||
while True:
|
||||
status = ssh.run('if test -f ' + stage + '/result; then cat ' + stage + '/result; '
|
||||
'elif systemctl is-active --quiet ' + unit + '; then touch ' + stage + '/heartbeat; printf running; '
|
||||
'else printf stopped; fi').stdout.decode().strip()
|
||||
if status != 'running':
|
||||
finished = True
|
||||
if status != '0':
|
||||
raise TransportError('Target provisioning failed; inspect root-owned ' + stage +
|
||||
'/runner.log and /var/lib/apex-init/enrollment.json')
|
||||
break
|
||||
time.sleep(5)
|
||||
progress('Enrollment verified. Checking protected administrator SSH handover.')
|
||||
|
||||
def access(operation, token, original_port, address, fresh):
|
||||
if operation == 'status':
|
||||
result = ssh.run(REMOTE_PATH_GUARD + '\napex_guard_file /var/lib/apex-init/access/state.json; '
|
||||
'cat /var/lib/apex-init/access/state.json', fresh=True)
|
||||
return json.loads(result.stdout)
|
||||
args = ['python3', '-m', 'provisioning.access', operation, '--token', token]
|
||||
if original_port:
|
||||
args += ['--original-port', str(original_port), '--client-address', address]
|
||||
result = ssh.run('cd ' + installed + ' && ' + shlex.join(args), fresh=fresh)
|
||||
return json.loads(result.stdout)
|
||||
|
||||
protected_handover(ssh, access, run_id, int(connection[3]), connection[0])
|
||||
access_committed = True
|
||||
result = ssh.run('cd ' + installed + ' && ' + shlex.join(base + ['complete']), fresh=True)
|
||||
completed = json.loads(result.stdout)
|
||||
progress('Initialization and fresh administrator access verified; provisioner ' + revision + ', bundle ' + digest)
|
||||
repository = completed.get('stages', {}).get('repository', {}).get('result', {})
|
||||
if repository.get('commit'):
|
||||
progress('Repository ' + repository['commit'] + '; commons ' + repository.get('commons_commit', 'unknown'))
|
||||
return completed
|
||||
except HandoverUncertain:
|
||||
access_uncertain = True
|
||||
raise
|
||||
finally:
|
||||
if stage and (not launched or finished):
|
||||
for fresh in (True, False):
|
||||
try:
|
||||
ssh.run('rm -f ' + stage + '/user-data ' + stage + '/heartbeat ' + stage + '/bundle.tar ' + cleanup_policy, fresh=fresh)
|
||||
break
|
||||
except Exception: # noqa: S110 — cleanup tries both verified endpoints.
|
||||
pass
|
||||
if launched and not finished:
|
||||
progress('Coordinator stopped. The current target stage may finish; later stages stop after heartbeat expiry.')
|
||||
if access_uncertain:
|
||||
progress('Access commit outcome is unknown; verify which endpoint is active before resuming. Rollback may already be disabled.')
|
||||
endpoints = [('Administrator candidate', 'adam', 2222), ('Bootstrap candidate', ssh.user, ssh.resolved_port)]
|
||||
else:
|
||||
label = 'Safe resume' + ('' if access_committed else ' after any pending rollback')
|
||||
endpoints = [(label, 'adam' if access_committed else ssh.user, 2222 if access_committed else ssh.resolved_port)]
|
||||
for label, resume_user, resume_port in endpoints:
|
||||
resume = ['python3', 'ci/scripts/init-node.py', resume_user + '@' + ssh.host,
|
||||
'--port', str(resume_port), '--user-data', str(source)]
|
||||
if ssh.identity:
|
||||
resume += ['--identity-file', str(ssh.identity)]
|
||||
progress(label + ': ' + shlex.join(resume))
|
||||
@@ -11,7 +11,7 @@ from unittest.mock import patch
|
||||
|
||||
from provisioning import access, image
|
||||
from provisioning.image import cloud_enroll, sanitize_files
|
||||
from provisioning.transport import build_bundle
|
||||
from provisioning.bundle import build_bundle
|
||||
from provisioning.state import atomic_write, safe_path
|
||||
from provisioning.state import ProvisionError
|
||||
|
||||
|
||||
@@ -1,560 +0,0 @@
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from provisioning.transport import (
|
||||
SSH, TransportError, HandoverUncertain, build_bundle, destination, local_file, protected_handover,
|
||||
initialize,
|
||||
remote_atomic_input,
|
||||
)
|
||||
|
||||
|
||||
class TransportTests(unittest.TestCase):
|
||||
def test_durable_runner_checks_access_after_seed_before_enrollment(self):
|
||||
for compatible in (False, True):
|
||||
with self.subTest(compatible=compatible), tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
source = root / 'user-data'
|
||||
source.write_bytes(b'opaque input')
|
||||
stage = root / 'stage'
|
||||
stage.mkdir()
|
||||
installed = root / 'bundle'
|
||||
seed = installed / 'provisioning/seed.sh'
|
||||
seed.parent.mkdir(parents=True)
|
||||
seed.write_text('printf "seed\\n" >> "$APEX_TEST_EVENTS"\n')
|
||||
binaries = root / 'bin'
|
||||
binaries.mkdir()
|
||||
python = binaries / 'python3'
|
||||
python.write_text('#!' + sys.executable + '\n' + '''
|
||||
import os, sys
|
||||
module, operation = sys.argv[2:4]
|
||||
event = 'access-' + operation if module.endswith('.access') else operation
|
||||
with open(os.environ['APEX_TEST_EVENTS'], 'a') as stream:
|
||||
stream.write(event + '\\n')
|
||||
if event == 'access-preflight' and os.environ['APEX_TEST_COMPATIBLE'] == 'False':
|
||||
sys.exit(42)
|
||||
if event == 'enroll':
|
||||
sys.exit(43)
|
||||
''')
|
||||
python.chmod(0o755)
|
||||
events = root / 'events'
|
||||
environment = dict(os.environ, PATH=str(binaries) + os.pathsep + os.environ['PATH'],
|
||||
APEX_TEST_EVENTS=str(events), APEX_TEST_COMPATIBLE=str(compatible))
|
||||
class Remote:
|
||||
user, host, identity = 'root', 'example.com', None
|
||||
resolved_port = 22
|
||||
def __init__(self, *args):
|
||||
pass
|
||||
def resolve(self):
|
||||
pass
|
||||
def run(self, script, data=None, installed=installed, stage=stage,
|
||||
root=root, environment=environment, **kwargs):
|
||||
value = b''
|
||||
if 'ssh_host_' in script:
|
||||
value = b'192.0.2.1 50000 192.0.2.2 22\nssh-ed25519 AAAA fixture\n'
|
||||
elif 'mktemp -d /var/tmp/apex-init.' in script:
|
||||
value = b'/var/tmp/apex-init.0123456789\n'
|
||||
elif 'systemd-run --quiet' in script:
|
||||
arguments = shlex.split(script)
|
||||
wrapper = arguments[-1].replace('/usr/local/lib/apex-provisioner/' + 'a' * 64, str(installed))
|
||||
wrapper = wrapper.replace('/var/tmp/apex-init.0123456789', str(stage))
|
||||
wrapper = wrapper.replace('/etc/tmpfiles.d/', str(root) + '/')
|
||||
subprocess.run(['/bin/sh', '-c', wrapper], env=environment,
|
||||
capture_output=True, timeout=30)
|
||||
elif script.startswith('if test -f /var/tmp'):
|
||||
value = (stage / 'result').read_bytes()
|
||||
return subprocess.CompletedProcess([], 0, value, b'')
|
||||
with patch('provisioning.transport.SSH', Remote), \
|
||||
patch('provisioning.transport.build_bundle', return_value=(b'bundle', 'a' * 64, 'b' * 40)), \
|
||||
patch('provisioning.transport.shutil.which', return_value='/usr/bin/ssh'):
|
||||
with self.assertRaises(TransportError):
|
||||
initialize(root, 'root@example.com', source, progress=lambda message: None)
|
||||
expected = ['seed', 'validate', 'access-preflight']
|
||||
if compatible:
|
||||
expected.append('enroll')
|
||||
self.assertEqual(events.read_text().splitlines(), expected)
|
||||
self.assertEqual((stage / 'result').read_text().strip(), '43' if compatible else '42')
|
||||
|
||||
def test_rejects_options_shell_fragments_and_invalid_ports(self):
|
||||
for value in ['root@-oProxyCommand=id', 'root@host;id', '-oProxyCommand=id@host',
|
||||
'bob;id@host', 'bob name@host', 'bob\n@host', 'bob/other@host',
|
||||
'bob$(id)@host', '@host', '9bob@host', 'a' * 33 + '@host',
|
||||
'root@host\nfoo', 'host', 'debian@host -oProxyCommand=id']:
|
||||
with self.subTest(value=value), self.assertRaises(TransportError):
|
||||
destination(value)
|
||||
for port in ['0', '65536', '-1', '22;id', '2.5', '']:
|
||||
with self.subTest(port=port), self.assertRaises(TransportError):
|
||||
destination('root@example.com', port)
|
||||
self.assertEqual(destination('adam@node', 2222), ('adam', 'node', 2222))
|
||||
|
||||
def test_existing_bootstrap_accounts_are_accepted(self):
|
||||
for user in ['root', 'adam', 'debian', 'bob', 'cloud-admin', '_admin', 'Admin.1']:
|
||||
with self.subTest(user=user):
|
||||
self.assertEqual(destination(user + '@example.com'), (user, 'example.com', None))
|
||||
self.assertEqual(destination('debian@[2001:db8::1]', 22), ('debian', '2001:db8::1', 22))
|
||||
|
||||
def test_nonroot_bootstrap_uses_noninteractive_sudo_and_preserves_stdin(self):
|
||||
calls = []
|
||||
def runner(args, **kwargs):
|
||||
calls.append((args, kwargs))
|
||||
return subprocess.CompletedProcess(args, 0, b'root verified', b'')
|
||||
ssh = SSH('debian@host', runner=runner)
|
||||
self.assertEqual(ssh.run('id -u', b'opaque input').stdout, b'root verified')
|
||||
args, kwargs = calls[0]
|
||||
self.assertEqual(args[args.index('-l') + 1], 'debian')
|
||||
self.assertEqual(shlex.split(args[-1]), ['sudo', '-n', '--', 'sh', '-c', 'id -u'])
|
||||
self.assertEqual(kwargs['input'], b'opaque input')
|
||||
ssh.run('printf %s "$SSH_CONNECTION"', privileged=False)
|
||||
self.assertEqual(shlex.split(calls[1][0][-1])[:2], ['sh', '-c'])
|
||||
|
||||
def test_sudo_cleared_connection_environment_uses_unprivileged_fallback(self):
|
||||
class PreflightComplete(Exception):
|
||||
pass
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
source = root / 'user-data'
|
||||
source.write_bytes(b'opaque input')
|
||||
release = root / 'os-release'
|
||||
release.write_text('ID=debian\nVERSION_ID=13\n')
|
||||
systemd_run = root / 'systemd-run'
|
||||
systemd_run.write_text('#!/bin/sh\nexit 0\n')
|
||||
systemd_run.chmod(0o755)
|
||||
environment = dict(os.environ, PATH=str(root) + os.pathsep + os.environ['PATH'])
|
||||
environment.pop('SSH_CONNECTION', None)
|
||||
fallbacks = []
|
||||
def runner(args, **kwargs):
|
||||
if '-G' in args:
|
||||
return subprocess.CompletedProcess(args, 0, 'hostname 192.0.2.2\nport 2222\n', '')
|
||||
command = shlex.split(args[-1])
|
||||
script = command[-1]
|
||||
if 'ssh_host_' in script:
|
||||
self.assertEqual(command[:3], ['sudo', '-n', '--'])
|
||||
tools = ('id() { printf 0; }; dpkg() { printf amd64; }; '
|
||||
"cat() { printf 'ssh-ed25519 AAAA fixture\\n'; }; ")
|
||||
script = script.replace('/etc/os-release', shlex.quote(str(release)))
|
||||
result = subprocess.run(['sh', '-c', tools + script], capture_output=True, env=environment)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
return result
|
||||
if 'SSH_CONNECTION' in script:
|
||||
self.assertEqual(command[:2], ['sh', '-c'])
|
||||
fallbacks.append(script)
|
||||
return subprocess.run(command, capture_output=True,
|
||||
env=dict(environment, SSH_CONNECTION='192.0.2.1 50000 192.0.2.2 2222'))
|
||||
self.assertIn('/var/lib/apex-init/remote.json', script)
|
||||
self.assertIn('ssh-ed25519 AAAA', transport.trust_file.read_text())
|
||||
raise PreflightComplete
|
||||
transport = SSH('adam@example.com', runner=runner)
|
||||
with patch('provisioning.transport.SSH', return_value=transport), \
|
||||
patch('provisioning.transport.build_bundle', return_value=(b'bundle', 'a' * 64, 'b' * 40)), \
|
||||
patch('provisioning.transport.shutil.which', return_value='/usr/bin/ssh'):
|
||||
with self.assertRaises(PreflightComplete):
|
||||
initialize(root, 'adam@example.com', source, progress=lambda message: None)
|
||||
self.assertEqual(len(fallbacks), 1)
|
||||
|
||||
def test_remote_script_quoting_transports_literal_values(self):
|
||||
received = []
|
||||
def runner(args, **kwargs):
|
||||
command = shlex.split(args[-1])
|
||||
self.assertEqual(command[:2], ['sh', '-c'])
|
||||
result = subprocess.run(command, input=kwargs.get('input'), capture_output=True)
|
||||
received.append(result.stdout)
|
||||
return result
|
||||
value = "spaces '$HOME' `echo unsafe`; $(echo unsafe)"
|
||||
SSH('root@host', runner=runner).run('printf %s ' + shlex.quote(value))
|
||||
self.assertEqual(received, [value.encode()])
|
||||
|
||||
def test_secret_bytes_go_over_stdin_and_diagnostics_are_redacted(self):
|
||||
secret = b'private key material'
|
||||
def runner(args, **kwargs):
|
||||
self.assertEqual(kwargs['input'], secret)
|
||||
self.assertNotIn(secret.decode(), repr(args))
|
||||
return subprocess.CompletedProcess(args, 1, b'', secret)
|
||||
with self.assertRaises(TransportError) as caught:
|
||||
SSH('root@host', runner=runner).run('cat >/dev/null', secret)
|
||||
self.assertNotIn(secret.decode(), str(caught.exception))
|
||||
|
||||
def test_fresh_login_disables_mux_password_and_pins_verified_identity(self):
|
||||
calls = []
|
||||
def runner(args, **kwargs):
|
||||
calls.append(args)
|
||||
return subprocess.CompletedProcess(args, 0, b'', b'')
|
||||
ssh = SSH('root@original-alias', runner=runner)
|
||||
ssh.resolved_host = '192.0.2.42'
|
||||
ssh.trust_file = Path('/tmp/trusted')
|
||||
ssh.fresh_login()
|
||||
args = calls[0]
|
||||
self.assertIn('-T', args)
|
||||
for option in ('ControlMaster=no', 'ControlPath=none', 'BatchMode=yes',
|
||||
'PreferredAuthentications=publickey', 'StrictHostKeyChecking=yes',
|
||||
'Hostname=192.0.2.42', 'HostKeyAlias=apex-init-verified-target'):
|
||||
self.assertIn(option, args)
|
||||
self.assertEqual(args[args.index('-l') + 1], 'adam')
|
||||
self.assertEqual(args[args.index('-p') + 1], '2222')
|
||||
|
||||
def test_failed_first_fresh_login_rolls_back_without_finalizing(self):
|
||||
calls = []
|
||||
class FailedSSH:
|
||||
def fresh_login(self):
|
||||
raise TransportError('key rejected')
|
||||
def invoke(operation, *args):
|
||||
calls.append(operation)
|
||||
return {'status': 'transition'}
|
||||
with self.assertRaises(TransportError):
|
||||
protected_handover(FailedSSH(), invoke, 'a' * 32, 22, '192.0.2.1')
|
||||
self.assertEqual(calls, ['begin', 'rollback'])
|
||||
|
||||
def test_failed_second_fresh_login_never_commits(self):
|
||||
calls = []
|
||||
class SecondFailure:
|
||||
count = 0
|
||||
def fresh_login(self):
|
||||
self.count += 1
|
||||
if self.count == 2:
|
||||
raise TransportError('final policy rejected login')
|
||||
def invoke(operation, *args):
|
||||
calls.append(operation)
|
||||
return {'status': 'transition'}
|
||||
with self.assertRaises(TransportError):
|
||||
protected_handover(SecondFailure(), invoke, 'a' * 32, 22, '192.0.2.1')
|
||||
self.assertEqual(calls, ['begin', 'finalize', 'rollback'])
|
||||
|
||||
def test_two_successful_fresh_logins_precede_commit(self):
|
||||
calls = []
|
||||
class VerifiedSSH:
|
||||
def fresh_login(self):
|
||||
calls.append('fresh')
|
||||
def invoke(operation, *args):
|
||||
calls.append(operation)
|
||||
return {'status': 'transition'}
|
||||
protected_handover(VerifiedSSH(), invoke, 'a' * 32, 22, '192.0.2.1')
|
||||
self.assertEqual(calls, ['begin', 'fresh', 'finalize', 'fresh', 'commit'])
|
||||
|
||||
def test_lost_commit_reply_is_reconciled_without_rolling_back_committed_access(self):
|
||||
from provisioning.access import Transition
|
||||
from test_provision_access import FakeHost
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
host = FakeHost(Path(directory))
|
||||
transition = Transition(host, now=lambda: 1000, security=host.security)
|
||||
calls = []
|
||||
class VerifiedSSH:
|
||||
def fresh_login(self):
|
||||
pass
|
||||
def invoke(operation, token, port, address, fresh):
|
||||
calls.append(operation)
|
||||
if operation == 'status':
|
||||
self.assertTrue(fresh)
|
||||
return transition.load()
|
||||
result = transition.execute(operation, token, port, address)
|
||||
if operation == 'commit':
|
||||
raise TransportError('SSH reply lost')
|
||||
return result
|
||||
protected_handover(VerifiedSSH(), invoke, 'a' * 32, 22, '192.0.2.1')
|
||||
self.assertEqual(transition.load()['status'], 'committed')
|
||||
self.assertEqual(host.ports, [2222])
|
||||
self.assertFalse(host.armed)
|
||||
self.assertEqual(host.restored, 0)
|
||||
self.assertEqual(calls, ['begin', 'finalize', 'commit', 'status'])
|
||||
|
||||
def test_known_uncommitted_lost_reply_still_rolls_back(self):
|
||||
calls = []
|
||||
class VerifiedSSH:
|
||||
def fresh_login(self):
|
||||
pass
|
||||
def invoke(operation, *args):
|
||||
calls.append(operation)
|
||||
if operation == 'commit':
|
||||
raise TransportError('SSH failed before commit')
|
||||
return {'status': 'final' if operation == 'status' else 'transition', 'token': 'a' * 32}
|
||||
with self.assertRaises(TransportError):
|
||||
protected_handover(VerifiedSSH(), invoke, 'a' * 32, 22, '192.0.2.1')
|
||||
self.assertEqual(calls, ['begin', 'finalize', 'commit', 'status', 'rollback'])
|
||||
|
||||
def test_lost_commit_reply_reports_verified_or_uncertain_resume_endpoints(self):
|
||||
from provisioning.access import Transition
|
||||
from test_provision_access import FakeHost
|
||||
for unreachable in (False, True):
|
||||
with self.subTest(unreachable=unreachable), tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
source = root / 'user data $literal'
|
||||
source.write_bytes(b'opaque input')
|
||||
identity = root / 'identity file'
|
||||
identity.write_bytes(b'synthetic private identity')
|
||||
host = FakeHost(root)
|
||||
transition = Transition(host, now=lambda: 1000, security=host.security)
|
||||
messages, commands = [], []
|
||||
def runner(args, commands=commands, transition=transition,
|
||||
unreachable=unreachable, **kwargs):
|
||||
if '-G' in args:
|
||||
return subprocess.CompletedProcess(args, 0, 'hostname 192.0.2.2\nport 22\n', '')
|
||||
command = shlex.split(args[-1])
|
||||
script = command[-1]
|
||||
commands.append(script)
|
||||
value = b''
|
||||
if 'ssh_host_' in script:
|
||||
value = b'192.0.2.1 50000 192.0.2.2 22\nssh-ed25519 AAAA fixture\n'
|
||||
elif 'mktemp -d /var/tmp/apex-init.' in script:
|
||||
value = b'/var/tmp/apex-init.0123456789\n'
|
||||
elif script.startswith('if test -f /var/tmp'):
|
||||
value = b'0'
|
||||
elif script.startswith('cd ') and 'provisioning.access' in script:
|
||||
access_args = shlex.split(script)[3:]
|
||||
operation = access_args[3]
|
||||
token = access_args[access_args.index('--token') + 1]
|
||||
port = int(access_args[access_args.index('--original-port') + 1]) if '--original-port' in access_args else None
|
||||
address = access_args[access_args.index('--client-address') + 1] if port else None
|
||||
value = json.dumps(transition.execute(operation, token, port, address)).encode()
|
||||
if operation == 'commit':
|
||||
return subprocess.CompletedProcess(args, 255, b'', b'connection lost')
|
||||
elif 'cat /var/lib/apex-init/access/state.json' in script:
|
||||
self.assertEqual(args[args.index('-l') + 1], 'adam')
|
||||
self.assertEqual(args[args.index('-p') + 1], '2222')
|
||||
self.assertEqual(command[:3], ['sudo', '-n', '--'])
|
||||
if unreachable:
|
||||
return subprocess.CompletedProcess(args, 255, b'', b'connection lost')
|
||||
value = json.dumps(transition.load()).encode()
|
||||
elif 'provisioning.target complete' in script:
|
||||
value = b'{"status": "complete"}'
|
||||
return subprocess.CompletedProcess(args, 0, value, b'')
|
||||
def transport(*args):
|
||||
return SSH(*args, runner=runner)
|
||||
with patch('provisioning.transport.SSH', side_effect=transport), \
|
||||
patch('provisioning.transport.build_bundle', return_value=(b'bundle', 'a' * 64, 'b' * 40)), \
|
||||
patch('provisioning.transport.shutil.which', return_value='/usr/bin/ssh'):
|
||||
if unreachable:
|
||||
with self.assertRaises(HandoverUncertain):
|
||||
initialize(root, 'debian@example.com', source, identity_file=identity, progress=messages.append)
|
||||
self.assertTrue(any('outcome is unknown' in message for message in messages))
|
||||
self.assertIn('adam@example.com --port 2222', messages[-2])
|
||||
self.assertIn('debian@example.com --port 22', messages[-1])
|
||||
self.assertFalse(any('Safe resume' in message or 'pending rollback' in message for message in messages))
|
||||
else:
|
||||
self.assertEqual(initialize(root, 'debian@example.com', source, identity_file=identity, progress=messages.append), {'status': 'complete'})
|
||||
self.assertIn('Safe resume: python3 ci/scripts/init-node.py adam@example.com --port 2222', messages[-1])
|
||||
resume = shlex.split(messages[-1].split(': ', 1)[1])
|
||||
self.assertEqual(resume[:2], ['python3', 'ci/scripts/init-node.py'])
|
||||
self.assertEqual(resume[resume.index('--user-data') + 1], str(source.resolve()))
|
||||
self.assertEqual(resume[resume.index('--identity-file') + 1], str(identity.resolve()))
|
||||
self.assertEqual(transition.load()['status'], 'committed')
|
||||
self.assertFalse(host.armed)
|
||||
self.assertFalse(any('access rollback' in script for script in commands))
|
||||
|
||||
def test_bundle_excludes_repository_and_environment_secrets(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
(root / 'ci/scripts/init-node/provisioning').mkdir(parents=True)
|
||||
(root / 'ci/scripts/init-node/provisioning/__init__.py').write_text('')
|
||||
(root / 'ci/scripts/init-node/provisioning/seed.sh').write_text('#!/bin/sh\n')
|
||||
(root / '.env').write_text('private secret')
|
||||
(root / 'ci/scripts/init-node/provisioning/private.env').write_text('private secret')
|
||||
data, digest, revision = build_bundle(root)
|
||||
with tarfile.open(fileobj=io.BytesIO(data)) as archive:
|
||||
self.assertEqual(set(archive.getnames()), {'provisioning/__init__.py', 'provisioning/seed.sh'})
|
||||
self.assertNotIn(b'private secret', data)
|
||||
self.assertEqual(build_bundle(root)[1], digest)
|
||||
|
||||
def test_real_archive_runs_without_engine_or_site_packages(self):
|
||||
commons = Path(__file__).resolve().parents[4]
|
||||
data, digest, revision = build_bundle(commons)
|
||||
self.assertEqual(build_bundle(commons)[1], digest)
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
with tarfile.open(fileobj=io.BytesIO(data)) as archive:
|
||||
names = archive.getnames()
|
||||
self.assertTrue(names)
|
||||
self.assertTrue(all(name.startswith('provisioning/') for name in names))
|
||||
self.assertIn('provisioning/__init__.py', names)
|
||||
self.assertIn('provisioning/seed.sh', names)
|
||||
archive.extractall(directory, filter='data')
|
||||
environment = dict(os.environ)
|
||||
environment.pop('PYTHONPATH', None)
|
||||
for module in ('provisioning.target', 'provisioning.access', 'provisioning.image'):
|
||||
with self.subTest(module=module):
|
||||
result = subprocess.run([sys.executable, '-S', '-m', module, '--help'],
|
||||
cwd=directory, env=environment, capture_output=True,
|
||||
text=True, timeout=30)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertIn('usage:', result.stdout)
|
||||
|
||||
def test_bundle_rejects_symlinked_source(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
package = root / 'ci/scripts/init-node/provisioning'
|
||||
package.mkdir(parents=True)
|
||||
(package / '__init__.py').write_text('')
|
||||
(package / 'seed.sh').write_text('#!/bin/sh\n')
|
||||
outside = root / 'outside.py'
|
||||
outside.write_text('private = True\n')
|
||||
(package / 'target.py').symlink_to(outside)
|
||||
with self.assertRaisesRegex(TransportError, 'unsafe source path'):
|
||||
build_bundle(root)
|
||||
|
||||
def test_missing_input_is_a_safe_validation_error(self):
|
||||
with self.assertRaises(TransportError):
|
||||
local_file('/nonexistent/apex-user-data', 'User-data')
|
||||
|
||||
def test_standalone_help_runs_without_engine_or_yaml_imports(self):
|
||||
launcher = Path(__file__).resolve().parents[2] / 'init-node.py'
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
result = subprocess.run([sys.executable, '-S', str(launcher), '--help'],
|
||||
cwd=directory, capture_output=True, text=True, timeout=30)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertIn('--user-data', result.stdout)
|
||||
self.assertIn('--identity-file', result.stdout)
|
||||
|
||||
def test_dead_enrollment_unit_stops_polling_and_removes_input(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
source = root / 'user-data'
|
||||
source.write_bytes(b'opaque input')
|
||||
calls = []
|
||||
class Remote:
|
||||
user, host, identity = 'root', 'example.com', None
|
||||
def __init__(self, *args):
|
||||
self.resolved_port = 22
|
||||
def resolve(self):
|
||||
pass
|
||||
def run(self, script, data=None, **kwargs):
|
||||
calls.append(script)
|
||||
if 'ssh_host_' in script:
|
||||
value = b'192.0.2.1 50000 192.0.2.2 22\nssh-ed25519 AAAA fixture\n'
|
||||
elif 'mktemp -d /var/tmp/apex-init.' in script:
|
||||
value = b'/var/tmp/apex-init.0123456789\n'
|
||||
elif script.startswith('if test -f /var/tmp'):
|
||||
value = b'stopped'
|
||||
else:
|
||||
value = b''
|
||||
return subprocess.CompletedProcess([], 0, value, b'')
|
||||
messages = []
|
||||
with patch('provisioning.transport.SSH', Remote), \
|
||||
patch('provisioning.transport.build_bundle', return_value=(b'bundle', 'a' * 64, 'b' * 40)), \
|
||||
patch('provisioning.transport.shutil.which', return_value='/usr/bin/ssh'):
|
||||
with self.assertRaises(TransportError) as caught:
|
||||
initialize(root, 'root@example.com', source, progress=messages.append)
|
||||
self.assertIn('/runner.log', str(caught.exception))
|
||||
self.assertTrue(any(script.startswith('rm -f ') and '/user-data ' in script for script in calls))
|
||||
self.assertIn('root@example.com --port 22', messages[-1])
|
||||
|
||||
def test_completion_failure_keeps_administrator_resume_endpoint(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
source = root / 'user-data'
|
||||
source.write_bytes(b'opaque input')
|
||||
class Remote:
|
||||
user, host, identity = 'root', 'example.com', None
|
||||
def __init__(self, *args):
|
||||
self.resolved_port = 22
|
||||
def resolve(self):
|
||||
pass
|
||||
def run(self, script, data=None, **kwargs):
|
||||
if 'ssh_host_' in script:
|
||||
value = b'192.0.2.1 50000 192.0.2.2 22\nssh-ed25519 AAAA fixture\n'
|
||||
elif 'mktemp -d /var/tmp/apex-init.' in script:
|
||||
value = b'/var/tmp/apex-init.0123456789\n'
|
||||
elif script.startswith('if test -f /var/tmp'):
|
||||
value = b'0'
|
||||
elif 'provisioning.target complete' in script:
|
||||
raise TransportError('record failed')
|
||||
else:
|
||||
value = b''
|
||||
return subprocess.CompletedProcess([], 0, value, b'')
|
||||
messages = []
|
||||
with patch('provisioning.transport.SSH', Remote), \
|
||||
patch('provisioning.transport.build_bundle', return_value=(b'bundle', 'a' * 64, 'b' * 40)), \
|
||||
patch('provisioning.transport.protected_handover'), \
|
||||
patch('provisioning.transport.shutil.which', return_value='/usr/bin/ssh'):
|
||||
with self.assertRaises(TransportError):
|
||||
initialize(root, 'root@example.com', source, progress=messages.append)
|
||||
self.assertIn('adam@example.com --port 2222', messages[-1])
|
||||
|
||||
|
||||
class TargetPathTests(unittest.TestCase):
|
||||
"""Run the real shell guards with only Debian stat/mv translated on macOS."""
|
||||
def setUp(self):
|
||||
temporary = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(temporary.cleanup)
|
||||
self.root = Path(temporary.name).resolve()
|
||||
self.bin = self.root / 'bin'
|
||||
self.bin.mkdir()
|
||||
(self.bin / 'stat').write_text('#!' + sys.executable + '\n' + '''
|
||||
import os, stat, sys
|
||||
path = sys.argv[-1]
|
||||
info = os.lstat(path)
|
||||
field = sys.argv[sys.argv.index('-c') + 1]
|
||||
values = {'%a': format(stat.S_IMODE(info.st_mode), 'o'), '%h': str(info.st_nlink),
|
||||
'%u': '1000' if path == os.environ.get('TEST_NONROOT_PATH') else '0'}
|
||||
print(values[field])
|
||||
''')
|
||||
(self.bin / 'mv').write_text('#!' + sys.executable + '\nimport os, sys\nos.replace(sys.argv[-2], sys.argv[-1])\n')
|
||||
for command in self.bin.iterdir():
|
||||
command.chmod(0o755)
|
||||
self.environment = dict(os.environ, PATH=str(self.bin) + os.pathsep + os.environ['PATH'])
|
||||
|
||||
def write(self, path, data=b'new metadata'):
|
||||
return subprocess.run(['sh', '-c', remote_atomic_input(str(path))], input=data,
|
||||
capture_output=True, env=self.environment)
|
||||
|
||||
def test_atomic_write_ignores_predictable_old_temporary_symlink(self):
|
||||
destination = self.root / 'remote.json'
|
||||
victim = self.root / 'victim'
|
||||
victim.write_bytes(b'preserve me')
|
||||
destination.with_name('remote.json.new').symlink_to(victim)
|
||||
result = self.write(destination)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertEqual(destination.read_bytes(), b'new metadata')
|
||||
self.assertEqual(victim.read_bytes(), b'preserve me')
|
||||
self.assertEqual(destination.stat().st_mode & 0o777, 0o600)
|
||||
self.assertFalse(list(self.root.glob('.apex-write.*')))
|
||||
|
||||
def test_destination_symlink_is_rejected_without_touching_victim(self):
|
||||
victim = self.root / 'victim'
|
||||
victim.write_bytes(b'preserve me')
|
||||
destination = self.root / 'remote.json'
|
||||
destination.symlink_to(victim)
|
||||
result = self.write(destination)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertEqual(victim.read_bytes(), b'preserve me')
|
||||
|
||||
def test_redirected_parent_is_rejected_before_creating_files(self):
|
||||
victim = self.root / 'victim'
|
||||
victim.mkdir()
|
||||
redirected = self.root / 'apex-init'
|
||||
redirected.symlink_to(victim, target_is_directory=True)
|
||||
result = self.write(redirected / 'remote.json')
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertEqual(list(victim.iterdir()), [])
|
||||
|
||||
def test_unowned_parent_is_rejected_before_creating_files(self):
|
||||
state = self.root / 'apex-init'
|
||||
state.mkdir()
|
||||
self.environment['TEST_NONROOT_PATH'] = str(state)
|
||||
result = self.write(state / 'remote.json')
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertEqual(list(state.iterdir()), [])
|
||||
|
||||
def test_writable_ancestor_is_rejected_before_creating_files(self):
|
||||
state = self.root / 'apex-init'
|
||||
state.mkdir(mode=0o777)
|
||||
state.chmod(0o777)
|
||||
result = self.write(state / 'remote.json')
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertEqual(list(state.iterdir()), [])
|
||||
|
||||
def test_hardlinked_destination_is_rejected_without_touching_victim(self):
|
||||
victim = self.root / 'victim'
|
||||
victim.write_bytes(b'preserve me')
|
||||
destination = self.root / 'remote.json'
|
||||
os.link(victim, destination)
|
||||
result = self.write(destination)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertEqual(victim.read_bytes(), b'preserve me')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,10 +19,6 @@ select = ["E", "F", "W", "I", "B", "UP", "S"]
|
||||
# Subprocess arguments are constructed explicitly; no implicit shell execution.
|
||||
ignore = ["E401", "E501", "E702", "E741", "I001", "UP006", "UP035", "UP045", "S603", "S607"]
|
||||
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
# These tests use synthetic paths; no fixed temporary path is written.
|
||||
"ci/scripts/init-node/tests/test_provision_transport.py" = ["S108"]
|
||||
|
||||
[tool.mypy]
|
||||
python_version = "3.10"
|
||||
warn_return_any = true
|
||||
|
||||
@@ -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'])
|
||||
@@ -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,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()
|
||||
Reference in new issue
Block a user