mirror of
https://github.com/community-scripts/ProxmoxVE.git
synced 2026-11-11 13:53:12 +01:00
Compare commits
27
Commits
No files matched your search
Generated
+680
@@ -0,0 +1,680 @@
|
||||
name: PR script test
|
||||
|
||||
# Builds the ct/ and vm/ scripts a pull request touches on real Proxmox and Incus hosts and
|
||||
# reports the outcome on the PR, as a check run and as one comment kept up to
|
||||
# date.
|
||||
#
|
||||
# A self-hosted runner that executes pull request code as root is only
|
||||
# acceptable if a human decides what runs on it. So it starts on a push only
|
||||
# for trusted PRs: those the push-app-to-main app opens on a branch of this
|
||||
# repository when a script moves over from DevScripts (already reviewed and
|
||||
# tested there; only writers can push to such a branch). Those are built
|
||||
# automatically and approved when every script passes. Everything else waits
|
||||
# for someone with write access to comment `/test` (optionally followed by a
|
||||
# full SHA to pin, and the ct/ paths to build). Only a commit that existed when
|
||||
# the comment was written gets built: a push after it is never covered, not
|
||||
# even one that lands in the seconds before this job runs.
|
||||
#
|
||||
# gate ubuntu-latest who asked, which scripts, which commit
|
||||
# test proxmox-test / build + checks, one matrix job per script, platform
|
||||
# incus-test (pve or incus) and OS
|
||||
# report ubuntu-latest check run + PR comment
|
||||
#
|
||||
# The test job gets a read-only token and never sees a secret: the host it runs
|
||||
# on executes untrusted code and has to be treated as compromised.
|
||||
#
|
||||
# This file is identical in DevScripts and ProxmoxVE; github.repository decides
|
||||
# which URLs get rewritten and which container ID range is used.
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
# Trusted PRs only, see above. pull_request_target runs this file from main,
|
||||
# never the PR's copy of it.
|
||||
pull_request_target:
|
||||
types: [opened, reopened, synchronize, ready_for_review]
|
||||
paths:
|
||||
- "ct/**"
|
||||
- "install/**"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
pr:
|
||||
description: "Pull request number"
|
||||
required: true
|
||||
type: number
|
||||
sha:
|
||||
description: "Pin a commit (full 40-character SHA); empty = the latest, if nothing was pushed since"
|
||||
required: false
|
||||
type: string
|
||||
scripts:
|
||||
description: "Space-separated ct/ or vm/ paths (empty = everything the PR touches)"
|
||||
required: false
|
||||
type: string
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
gate:
|
||||
if: >-
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
(github.event_name == 'issue_comment' && startsWith(github.event.comment.body, '/test')) ||
|
||||
(github.event_name == 'pull_request_target' && !github.event.pull_request.draft &&
|
||||
github.event.pull_request.user.login == 'push-app-to-main[bot]' &&
|
||||
github.event.pull_request.head.repo.full_name == github.repository)
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: read # when the run was dispatched, for a /test without SHA
|
||||
checks: write
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
outputs:
|
||||
matrix: ${{ steps.plan.outputs.matrix }}
|
||||
pr: ${{ steps.plan.outputs.pr }}
|
||||
repo: ${{ steps.plan.outputs.repo }}
|
||||
sha: ${{ steps.plan.outputs.sha }}
|
||||
check_id: ${{ steps.plan.outputs.check_id }}
|
||||
actor: ${{ steps.plan.outputs.actor }}
|
||||
not_built: ${{ steps.plan.outputs.not_built }}
|
||||
trusted: ${{ steps.plan.outputs.trusted }}
|
||||
kind: ${{ steps.plan.outputs.kind }}
|
||||
steps:
|
||||
- id: plan
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
const MAX = 10;
|
||||
core.setOutput('matrix', '[]');
|
||||
|
||||
// Trusted: a PR the push-app-to-main app opened on a branch of this
|
||||
// repository, i.e. a script moving over from DevScripts.
|
||||
const TRUSTED_AUTHOR = 'push-app-to-main[bot]';
|
||||
const isTrusted = p => !!p && p.user && p.user.login === TRUSTED_AUTHOR && p.user.type === 'Bot' &&
|
||||
!!p.head.repo && p.head.repo.full_name === `${owner}/${repo}`;
|
||||
|
||||
let prNumber, requested = '';
|
||||
if (context.eventName === 'pull_request_target') {
|
||||
const p = context.payload.pull_request;
|
||||
if (!isTrusted(p)) return;
|
||||
prNumber = p.number;
|
||||
// Exactly the pushed commit; a later push starts its own run.
|
||||
requested = p.head.sha;
|
||||
} else if (context.eventName === 'workflow_dispatch') {
|
||||
prNumber = Number(context.payload.inputs.pr);
|
||||
requested = `${context.payload.inputs.sha || ''} ${context.payload.inputs.scripts || ''}`;
|
||||
} else {
|
||||
prNumber = context.payload.issue.number;
|
||||
const body = context.payload.comment.body.trim();
|
||||
if (!/^\/test(\s|$)/.test(body)) return;
|
||||
// author_association hides private org members, so ask for the permission itself.
|
||||
const user = context.payload.comment.user.login;
|
||||
const { data: perm } = await github.rest.repos.getCollaboratorPermissionLevel({ owner, repo, username: user });
|
||||
if (!['admin', 'maintain', 'write'].includes(perm.permission)) {
|
||||
core.notice(`${user} has ${perm.permission} access; /test needs write`);
|
||||
return;
|
||||
}
|
||||
await github.rest.reactions.createForIssueComment({
|
||||
owner, repo, comment_id: context.payload.comment.id, content: 'rocket',
|
||||
});
|
||||
requested = body.split('\n')[0].slice('/test'.length);
|
||||
}
|
||||
|
||||
// `/test <app>` in an issue (not a PR) builds that app from the
|
||||
// default branch as it is right now, e.g. to see whether a reported
|
||||
// error reproduces. No PR, so no check run, no review, no approval.
|
||||
const isIssue = context.eventName === 'issue_comment' && !context.payload.issue.pull_request;
|
||||
let pr = null, head, headSha;
|
||||
if (isIssue) {
|
||||
const { data: info } = await github.rest.repos.get({ owner, repo });
|
||||
const { data: branch } = await github.rest.repos.getBranch({ owner, repo, branch: info.default_branch });
|
||||
headSha = branch.commit.sha;
|
||||
head = { owner: { login: owner }, name: repo, full_name: `${owner}/${repo}` };
|
||||
} else {
|
||||
({ data: pr } = await github.rest.pulls.get({ owner, repo, pull_number: prNumber }));
|
||||
if (pr.state !== 'open' || !pr.head.repo) { core.notice('PR is closed or its fork is gone'); return; }
|
||||
head = pr.head.repo;
|
||||
headSha = pr.head.sha;
|
||||
if (context.eventName === 'pull_request_target' && headSha !== requested) {
|
||||
core.notice('Pushed to again since; the newer push has its own run');
|
||||
return;
|
||||
}
|
||||
}
|
||||
core.setOutput('kind', isIssue ? 'issue' : 'pr');
|
||||
core.setOutput('trusted', !isIssue && isTrusted(pr) ? 'true' : 'false');
|
||||
const MARK = '<!-- pr-script-test -->';
|
||||
const sticky = async body => {
|
||||
const comments = await github.paginate(github.rest.issues.listComments, { owner, repo, issue_number: prNumber });
|
||||
// Only our own comment: anyone can paste the marker into theirs.
|
||||
const mine = comments.find(c => c.body.includes(MARK) && c.user.type === 'Bot');
|
||||
if (mine) await github.rest.issues.updateComment({ owner, repo, comment_id: mine.id, body });
|
||||
else await github.rest.issues.createComment({ owner, repo, issue_number: prNumber, body });
|
||||
};
|
||||
|
||||
// Which commit the approval covers. Reading pr.head.sha alone would
|
||||
// build whatever was pushed between the comment and this job
|
||||
// starting, i.e. code nobody looked at. Two ways to be sure:
|
||||
//
|
||||
// /test <sha> that exact commit, and only while it is the head. A
|
||||
// full SHA: the author writes both commits, so a short
|
||||
// prefix is a quick birthday collision away.
|
||||
// /test the latest commit, if nothing reached the fork after
|
||||
// the comment. pushed_at is set by GitHub on every push
|
||||
// and read after the PR, so a push in between shows up.
|
||||
// Branches of this repository belong to people with
|
||||
// write access, who could build anything anyway.
|
||||
const tokens = requested.split(/\s+/).filter(Boolean);
|
||||
const isSha = t => /^[0-9a-f]{7,40}$/.test(t);
|
||||
const approved = isIssue ? [] : tokens.filter(isSha);
|
||||
let entries = isIssue ? [] : tokens.filter(t => !isSha(t));
|
||||
const refuse = async (why, retry) => {
|
||||
await sticky([MARK, '### Proxmox script test', '', `⚠️ ${why} Nothing was built.`, '', retry].join('\n'));
|
||||
core.notice(why);
|
||||
};
|
||||
if (isIssue) {
|
||||
// App names (bark-server) or ct/ and vm/ paths; the default branch
|
||||
// is trusted. A name without a ct/ script falls back to vm/ below.
|
||||
entries = tokens.map(t => /^(ct|vm)\/[A-Za-z0-9._-]+\.sh$/.test(t) ? t
|
||||
: /^[A-Za-z0-9._-]+$/.test(t) ? `ct/${t.replace(/\.sh$/, '')}.sh` : t);
|
||||
if (entries.length === 0) {
|
||||
await refuse('In an issue, `/test` needs to know which app.', 'Comment `/test <app>`, for example `/test bark-server` (the name of its `ct/` script).');
|
||||
return;
|
||||
}
|
||||
} else if (approved.length > 1) {
|
||||
await refuse('`/test` takes at most one commit.', 'Comment `/test` to build the latest commit.');
|
||||
return;
|
||||
}
|
||||
if (approved.length === 1 && approved[0] !== headSha) {
|
||||
await refuse(
|
||||
approved[0].length !== 40 && headSha.startsWith(approved[0])
|
||||
? 'A short SHA is not enough, it has to be all 40 characters.'
|
||||
: `\`${approved[0]}\` is not the head of this PR (any more).`,
|
||||
'Comment `/test` to build the latest commit.');
|
||||
return;
|
||||
}
|
||||
if (!isIssue && approved.length === 0 && head.full_name !== `${owner}/${repo}`) {
|
||||
const askedAt = context.eventName === 'workflow_dispatch'
|
||||
? (await github.rest.actions.getWorkflowRun({ owner, repo, run_id: context.runId })).data.created_at
|
||||
: context.payload.comment.created_at;
|
||||
const { data: fork } = await github.rest.repos.get({ owner: head.owner.login, repo: head.name });
|
||||
if (new Date(fork.pushed_at) >= new Date(askedAt)) {
|
||||
await refuse(
|
||||
`Something was pushed to ${head.full_name} after the \`/test\`, so it is not certain which commit was meant.`,
|
||||
'Comment `/test` again once you have looked at the latest commit.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (entries.some(e => !/^(ct|vm)\/[A-Za-z0-9._-]+\.sh$/.test(e))) {
|
||||
await refuse(
|
||||
isIssue ? 'Only app names (as in `ct/<app>.sh` or `vm/<name>.sh`) can be tested.' : 'Only `ct/<name>.sh` and `vm/<name>.sh` paths can be requested.',
|
||||
isIssue ? 'For example: `/test bark-server` or `/test debian-vm`' : 'For example: `/test ct/bark-server.sh`, or just `/test`.');
|
||||
return;
|
||||
}
|
||||
// Everything the PR changes that this test cannot build is named in
|
||||
// the comment, so a /test never ends in silence.
|
||||
const notBuilt = [];
|
||||
const code = s => '`' + String(s).replace(/[`\r\n]/g, '') + '`';
|
||||
if (entries.length === 0) {
|
||||
const files = await github.paginate(github.rest.pulls.listFiles, { owner, repo, pull_number: prNumber });
|
||||
const set = new Set();
|
||||
for (const f of files) {
|
||||
if (f.status === 'removed') continue;
|
||||
let m;
|
||||
if ((m = f.filename.match(/^install\/([A-Za-z0-9._-]+)-install\.sh$/))) set.add(`ct/${m[1]}.sh`);
|
||||
else if (/^(ct|vm)\/[A-Za-z0-9._-]+\.sh$/.test(f.filename)) set.add(f.filename);
|
||||
else if (/^(tools|turnkey)\/.*\.sh$/.test(f.filename)) notBuilt.push(`${code(f.filename)}: only \`ct/\` and \`vm/\` scripts are built`);
|
||||
}
|
||||
entries = [...set].sort();
|
||||
}
|
||||
|
||||
const include = [];
|
||||
const fetchScript = async p => {
|
||||
try {
|
||||
const res = await github.rest.repos.getContent({ owner: head.owner.login, repo: head.name, path: p, ref: headSha });
|
||||
return Buffer.from(res.data.content || '', 'base64').toString('utf8');
|
||||
} catch (e) { return null; }
|
||||
};
|
||||
for (let script of entries.slice(0, MAX)) {
|
||||
let text = await fetchScript(script);
|
||||
// /test debian-vm in an issue: there is no ct/debian-vm.sh, but a vm/ one.
|
||||
if (text === null && isIssue && script.startsWith('ct/')) {
|
||||
const vm = `vm/${script.slice(3)}`;
|
||||
if ((text = await fetchScript(vm)) !== null) script = vm;
|
||||
}
|
||||
if (text === null) {
|
||||
notBuilt.push(isIssue
|
||||
? (script.startsWith('ct/') ? `${code(script.slice(3, -3))}: neither \`ct/\` nor \`vm/\` has it on the default branch` : `${code(script)} does not exist on the default branch`)
|
||||
: `\`${script}\` does not exist in this PR (an \`install/\` script is built through the \`ct/\` script of the same name)`);
|
||||
continue;
|
||||
}
|
||||
const isVm = script.startsWith('vm/');
|
||||
// The test hosts are amd64; an ARM-only VM script refuses to run there.
|
||||
if (isVm && /^\s*vm_require_arch\s+arm64\b/m.test(text)) {
|
||||
notBuilt.push(`${code(script)}: ARM64 only, the test hosts are amd64`);
|
||||
continue;
|
||||
}
|
||||
// Scripts that ask for the OS get built once per OS.
|
||||
const oses = !isVm && /msg_menu\s+"Choose the container OS"/.test(text) ? ['debian', 'alpine'] : [''];
|
||||
const name = isVm ? `vm-${script.slice(3, -3)}` : script.slice(3, -3);
|
||||
// Every ct/ script on every platform: the same files run on Proxmox
|
||||
// and, through community-scripts/core's Incus backend, on Incus.
|
||||
// VMs are Proxmox only.
|
||||
const platforms = isVm ? ['pve'] : ['pve', 'incus'];
|
||||
for (const platform of platforms) {
|
||||
for (const os of oses) include.push({ script, os, platform, name: `${os ? `${name}-${os}` : name}-${platform}` });
|
||||
}
|
||||
}
|
||||
for (const script of entries.slice(MAX)) notBuilt.push(`\`${script}\`: at most ${MAX} scripts per run, name it with \`/test ${script}\``);
|
||||
if (include.length === 0) {
|
||||
await sticky([
|
||||
MARK, '### Proxmox script test', '',
|
||||
isIssue
|
||||
? 'ℹ️ Nothing to build:'
|
||||
: 'ℹ️ Nothing to build: this test builds `ct/` and `vm/` scripts (and changed `install/` scripts through their `ct/` script).', '',
|
||||
...(notBuilt.length ? notBuilt.map(n => `- ${n}`) : ['- this PR changes no `ct/`, `vm/` or `install/` script']),
|
||||
].join('\n'));
|
||||
core.notice('No ct/ or vm/ script to build');
|
||||
return;
|
||||
}
|
||||
|
||||
const runUrl = `${context.serverUrl}/${owner}/${repo}/actions/runs/${context.runId}`;
|
||||
// A check run belongs to a PR's commit; an issue has none.
|
||||
let checkId = '';
|
||||
if (!isIssue) {
|
||||
const { data: check } = await github.rest.checks.create({
|
||||
owner, repo, name: 'Proxmox script test', head_sha: headSha, status: 'in_progress',
|
||||
details_url: runUrl,
|
||||
output: { title: `Building ${include.length}`, summary: include.map(i => `- \`${i.script}\` ${[i.platform, i.os].filter(Boolean).join(' · ')}`).join('\n') },
|
||||
});
|
||||
checkId = String(check.id);
|
||||
}
|
||||
|
||||
core.setOutput('matrix', JSON.stringify(include));
|
||||
core.setOutput('pr', String(prNumber));
|
||||
core.setOutput('repo', head.full_name);
|
||||
core.setOutput('sha', headSha);
|
||||
core.setOutput('check_id', checkId);
|
||||
core.setOutput('not_built', JSON.stringify(notBuilt));
|
||||
// Who asked: the /test commenter, or whoever dispatched the run.
|
||||
core.setOutput('actor', context.actor);
|
||||
|
||||
await sticky([
|
||||
MARK, '### Proxmox script test', '',
|
||||
`⏳ Building ${include.length} at ${headSha.slice(0, 7)}${isIssue ? ' (default branch)' : ''} — [watch it live](${runUrl}) (open the \`test\` job)`, '',
|
||||
...include.map(i => `- \`${i.script}\` (${[i.platform, i.os].filter(Boolean).join(' · ')})`),
|
||||
...(notBuilt.length ? ['', '**Not built:**', ...notBuilt.map(n => `- ${n}`)] : []),
|
||||
].join('\n'));
|
||||
|
||||
test:
|
||||
needs: gate
|
||||
if: needs.gate.outputs.matrix != '[]' && needs.gate.outputs.matrix != ''
|
||||
# Only `proxmox-test`, never `self-hosted`: the runner is registered without
|
||||
# default labels so that no other workflow can be scheduled onto it.
|
||||
runs-on: ${{ matrix.platform == 'incus' && 'incus-test' || 'proxmox-test' }}
|
||||
permissions:
|
||||
contents: read
|
||||
timeout-minutes: 90 # install (45) + update (30) + checks
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include: ${{ fromJSON(needs.gate.outputs.matrix) }}
|
||||
name: test (${{ matrix.name }})
|
||||
steps:
|
||||
# The default branch, never the PR: the harness itself must be trusted.
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
sparse-checkout: .github/workflows/scripts
|
||||
persist-credentials: false
|
||||
|
||||
- name: Build and check ${{ matrix.script }}
|
||||
env:
|
||||
PR_REPO: ${{ needs.gate.outputs.repo }}
|
||||
PR_SHA: ${{ needs.gate.outputs.sha }}
|
||||
SCRIPT: ${{ matrix.script }}
|
||||
OS: ${{ matrix.os }}
|
||||
PLATFORM: ${{ matrix.platform }}
|
||||
UPSTREAM: ${{ github.repository }}
|
||||
CTID_BASE: ${{ github.repository == 'community-scripts/ProxmoxVE' && '9050' || '9000' }}
|
||||
# Only so release lookups are not rate limited: unauthenticated, all
|
||||
# test containers share the lab's one public IP and 60 calls an hour.
|
||||
# This job's token can read public contents and expires with the job.
|
||||
GH_API_TOKEN: ${{ github.token }}
|
||||
OUT_DIR: ${{ runner.temp }}/result
|
||||
run: bash .github/workflows/scripts/pve-run-script.sh
|
||||
|
||||
- if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: result-${{ matrix.name }}
|
||||
path: ${{ runner.temp }}/result
|
||||
if-no-files-found: ignore
|
||||
retention-days: 14
|
||||
|
||||
report:
|
||||
needs: [gate, test]
|
||||
if: always() && needs.gate.result == 'success' && needs.gate.outputs.matrix != '[]' && needs.gate.outputs.matrix != ''
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: read
|
||||
checks: write
|
||||
contents: write # the ci-screenshots branch only; main is protected
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/download-artifact@v5
|
||||
with:
|
||||
pattern: result-*
|
||||
path: results
|
||||
|
||||
- uses: actions/github-script@v8
|
||||
env:
|
||||
MATRIX: ${{ needs.gate.outputs.matrix }}
|
||||
PR: ${{ needs.gate.outputs.pr }}
|
||||
SHA: ${{ needs.gate.outputs.sha }}
|
||||
CHECK_ID: ${{ needs.gate.outputs.check_id }}
|
||||
TEST_RESULT: ${{ needs.test.result }}
|
||||
ACTOR: ${{ needs.gate.outputs.actor }}
|
||||
NOT_BUILT: ${{ needs.gate.outputs.not_built }}
|
||||
TRUSTED: ${{ needs.gate.outputs.trusted }}
|
||||
KIND: ${{ needs.gate.outputs.kind }}
|
||||
with:
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
const { owner, repo } = context.repo;
|
||||
const runUrl = `${context.serverUrl}/${owner}/${repo}/actions/runs/${context.runId}`;
|
||||
const matrix = JSON.parse(process.env.MATRIX);
|
||||
|
||||
// download-artifact unpacks a single artifact straight into results/
|
||||
// and several into results/<name>/, so look for every result.json
|
||||
// and match it by what it says it is.
|
||||
const path = require('path');
|
||||
const found = new Map();
|
||||
const walk = dir => {
|
||||
if (!fs.existsSync(dir)) return;
|
||||
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const p = path.join(dir, e.name);
|
||||
if (e.isDirectory()) walk(p);
|
||||
else if (e.name === 'result.json') {
|
||||
try {
|
||||
const r = JSON.parse(fs.readFileSync(p, 'utf8'));
|
||||
// Written on the host that ran the PR's code: anything but a
|
||||
// known status is an error, never something new.
|
||||
if (!['pass', 'fail', 'unsupported', 'error'].includes(r.status)) r.status = 'error';
|
||||
const log = path.join(dir, 'install.log');
|
||||
r.install_log = fs.existsSync(log) ? fs.readFileSync(log, 'utf8') : '';
|
||||
const shot = path.join(dir, 'screenshot.png');
|
||||
r.shot_file = fs.existsSync(shot) ? shot : '';
|
||||
if (!['updated', 'no-update', 'fail', 'error', ''].includes(r.update_status || '')) r.update_status = '';
|
||||
// Installed fine, but the update could not be tested (e.g. a
|
||||
// GitHub rate limit): not a pass, and not the PR's fault.
|
||||
if (r.status === 'pass' && r.update_status === 'error') { r.status = 'error'; r.step = `update ${r.update_step}`; }
|
||||
const ulog = path.join(dir, 'update.log');
|
||||
r.update_log = fs.existsSync(ulog) ? fs.readFileSync(ulog, 'utf8') : '';
|
||||
const ushot = path.join(dir, 'screenshot-update.png');
|
||||
r.shot_update_file = fs.existsSync(ushot) ? ushot : '';
|
||||
// Only the two platforms this workflow knows; an unknown one would
|
||||
// never match a matrix row anyway.
|
||||
if (!['pve', 'incus'].includes(r.platform)) r.platform = 'pve';
|
||||
found.set(`${r.script}|${r.platform}|${r.os}`, r);
|
||||
} catch (err) { core.warning(`unreadable ${p}: ${err.message}`); }
|
||||
}
|
||||
}
|
||||
};
|
||||
walk('results');
|
||||
|
||||
// A job that died before writing result.json still gets a row.
|
||||
const rows = matrix.map(m => ({ ...(found.get(`${m.script}|${m.platform || 'pve'}|${m.os || 'default'}`) || {
|
||||
script: m.script, os: m.os || 'default', platform: m.platform || 'pve',
|
||||
status: process.env.TEST_RESULT === 'cancelled' ? 'cancelled' : 'error',
|
||||
step: 'no result (runner or job failure)', duration: 0, log_tail: '', install_log: '', shot_file: '',
|
||||
update_status: '', update_log: '', shot_update_file: '',
|
||||
}), name: m.name }));
|
||||
// Where a row ran: "pve", "incus · alpine", or "VM" (Proxmox only).
|
||||
const isVm = r => r.script.startsWith('vm/');
|
||||
const where = r => isVm(r) ? 'VM' : r.os && r.os !== 'default' ? `${r.platform} · ${r.os}` : r.platform;
|
||||
|
||||
// Screenshots go to an orphan branch, because a comment can only show
|
||||
// an image that has a URL. One file per PR and script, overwritten on
|
||||
// every run; the link names the commit, so no cache shows an old one.
|
||||
// The PNG comes from the host that ran the PR's code: check it again.
|
||||
const BRANCH = 'ci-screenshots';
|
||||
const prNum = Number(process.env.PR);
|
||||
const isPng = buf => buf.length > 8 && buf.length < 3000000 &&
|
||||
buf.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]));
|
||||
const ensureBranch = async () => {
|
||||
try { await github.rest.git.getRef({ owner, repo, ref: `heads/${BRANCH}` }); return; }
|
||||
catch (e) { if (e.status !== 404) throw e; }
|
||||
const { data: blob } = await github.rest.git.createBlob({ owner, repo, encoding: 'utf-8',
|
||||
content: 'Screenshots taken by pr-script-test.yml for the PR comments. Not code; never merge this branch.\n' });
|
||||
const { data: tree } = await github.rest.git.createTree({ owner, repo, tree: [{ path: 'README.md', mode: '100644', type: 'blob', sha: blob.sha }] });
|
||||
const { data: commit } = await github.rest.git.createCommit({ owner, repo, message: 'Start ci-screenshots', tree: tree.sha, parents: [] });
|
||||
await github.rest.git.createRef({ owner, repo, ref: `refs/heads/${BRANCH}`, sha: commit.sha });
|
||||
};
|
||||
const uploadShot = async (r, local, suffix) => {
|
||||
const buf = fs.readFileSync(local);
|
||||
if (!isPng(buf)) { core.warning(`${r.script}: screenshot is not a plausible PNG, skipped`); return ''; }
|
||||
const file = `${process.env.KIND === 'issue' ? 'issue' : 'pr'}-${prNum}/${r.name}${suffix}.png`;
|
||||
for (let attempt = 1; attempt <= 3; attempt++) {
|
||||
try {
|
||||
await ensureBranch();
|
||||
let sha;
|
||||
try { sha = (await github.rest.repos.getContent({ owner, repo, path: file, ref: BRANCH })).data.sha; }
|
||||
catch (e) { if (e.status !== 404) throw e; }
|
||||
const { data } = await github.rest.repos.createOrUpdateFileContents({
|
||||
owner, repo, branch: BRANCH, path: file, sha, content: buf.toString('base64'),
|
||||
message: `Screenshot: ${r.script} (${where(r)})${suffix} for #${prNum} at ${process.env.SHA.slice(0, 7)}`,
|
||||
});
|
||||
return `https://raw.githubusercontent.com/${owner}/${repo}/${data.commit.sha}/${file}`;
|
||||
} catch (e) {
|
||||
// Two reports at once race on the branch head; try again.
|
||||
core.warning(`${r.script}: screenshot upload attempt ${attempt} failed: ${e.message}`);
|
||||
await new Promise(res => setTimeout(res, 2000 * attempt));
|
||||
}
|
||||
}
|
||||
return '';
|
||||
};
|
||||
for (const r of rows) {
|
||||
if (r.shot_file) r.shot_url = await uploadShot(r, r.shot_file, '');
|
||||
if (r.shot_update_file) r.shot_update_url = await uploadShot(r, r.shot_update_file, '-after-update');
|
||||
}
|
||||
|
||||
// unsupported: the script needs a CPU feature the test host lacks
|
||||
// (AVX2 and the like). Not the PR's fault, so not a failure.
|
||||
const icon = { pass: '✅', fail: '❌', error: '⚠️', cancelled: '⏹️', unsupported: '🖥️' };
|
||||
const dur = s => `${Math.floor(s / 60)}m${String(s % 60).padStart(2, '0')}s`;
|
||||
// Text from the build lands outside code blocks here: no table breaks,
|
||||
// no code spans, and no @mention of a stranger (zero-width space).
|
||||
const clean = s => String(s || '').replace(/[|\r\n]/g, ' ').replace(/`/g, "'").replace(/@/g, '@').slice(0, 200);
|
||||
const fence = s => String(s || '').replace(/~~~~/g, '~~~');
|
||||
|
||||
// fail: the app broke, the PR's business. error/cancelled: our test
|
||||
// setup did, not the PR's. unsupported: the host's CPU is too old.
|
||||
const count = s => rows.filter(r => r.status === s).length;
|
||||
const passed = count('pass');
|
||||
const untestable = count('unsupported');
|
||||
const broken = count('fail');
|
||||
const setupTrouble = count('error') + count('cancelled');
|
||||
const allPass = passed === rows.length;
|
||||
const cancelled = rows.every(r => r.status === 'cancelled');
|
||||
const of = `**${passed}/${rows.length} passed**`;
|
||||
const headline = broken
|
||||
? `❌ ${of}`
|
||||
: setupTrouble
|
||||
? `⚠️ ${of}, ${setupTrouble} could not be tested because of a problem with the test setup (not the PR)`
|
||||
: untestable
|
||||
? `🖥️ ${of}, ${untestable} need${untestable === 1 ? 's' : ''} a newer CPU than the test host has (nothing failed)`
|
||||
: `✅ ${of}`;
|
||||
|
||||
// Update: the installed app is set back a version and updated with
|
||||
// the PR's update_script. ➖ = it ran but had nothing to update.
|
||||
const updateIcon = { updated: '✅', 'no-update': '➖', fail: '❌', error: '⚠️' };
|
||||
const detail = r => {
|
||||
if (r.status !== 'pass') return clean(r.step);
|
||||
const ip = /^[0-9.]{7,15}$/.test(r.ip || '') ? r.ip : '';
|
||||
if (isVm(r) && !r.url) return ip ? `booted, got ${ip}` : 'booted, no DHCP lease (an installer ISO, or no network)';
|
||||
const reach = r.url ? `${clean(r.url)} → ${r.http_code}` : 'no URL to check';
|
||||
return r.update_status === 'no-update' ? `${reach}; update: nothing to update${r.update_step ? ` (${clean(r.update_step)})` : ''}` : reach;
|
||||
};
|
||||
const table = [
|
||||
'| Script | Platform | Install | Update | Duration | Detail |',
|
||||
'|---|---|---|---|---|---|',
|
||||
...rows.map(r => `| \`${r.script}\` | ${where(r)} | ${r.update_status ? '✅' : icon[r.status] || '❔'} | ${updateIcon[r.update_status] || '–'} | ${dur(r.duration || 0)} | ${detail(r)} |`),
|
||||
].join('\n');
|
||||
|
||||
// A comment holds 65536 characters. Each script gets an equal share
|
||||
// and a log that does not fit loses its beginning: the end is where
|
||||
// a failure shows.
|
||||
const budget = Math.floor(52000 / rows.length);
|
||||
const tail = (text, max) => {
|
||||
text = fence(text).trimEnd();
|
||||
if (text.length <= max) return text;
|
||||
const kept = text.slice(text.length - max);
|
||||
const cut = text.slice(0, text.length - max).split('\n').length;
|
||||
return `… ${cut} earlier lines cut, the full log is in the run artifacts\n${kept.slice(kept.indexOf('\n') + 1)}`;
|
||||
};
|
||||
const details = (summaryLine, text, max) => [
|
||||
`<details><summary>${summaryLine}</summary>`, '',
|
||||
'~~~~text', tail(text, max), '~~~~', '</details>',
|
||||
].join('\n');
|
||||
|
||||
const logs = rows.map(r => {
|
||||
const parts = [];
|
||||
const failed = r.status !== 'pass';
|
||||
// A VM script's own output is all there is: no install log inside.
|
||||
const buildShare = (failed || isVm(r)) && r.log_tail ? Math.min(r.log_tail.length, Math.floor(budget / 3)) : 0;
|
||||
if (buildShare) parts.push(details(`${icon[r.status] || '❔'} ${r.script} (${where(r)}) — build log`, r.log_tail, buildShare));
|
||||
if (isVm(r)) return parts.join('\n\n');
|
||||
// The update log gets up to a third of what is left, the install log the rest.
|
||||
const updateShare = r.update_log ? Math.min(r.update_log.length, Math.floor((budget - buildShare) / 3)) : 0;
|
||||
if (r.install_log) {
|
||||
const lines = r.install_log.trimEnd().split('\n').length;
|
||||
parts.push(details(`📄 ${r.script} (${where(r)}) — install log (${lines} lines)`, r.install_log, budget - buildShare - updateShare - 400));
|
||||
} else if (!buildShare) {
|
||||
parts.push(`<sub>${r.script} (${where(r)}): no install log was captured</sub>`);
|
||||
}
|
||||
if (updateShare) {
|
||||
const lines = r.update_log.trimEnd().split('\n').length;
|
||||
parts.push(details(`🔄 ${r.script} (${where(r)}) — update log (${lines} lines)`, r.update_log, updateShare));
|
||||
}
|
||||
return parts.join('\n\n');
|
||||
});
|
||||
|
||||
const shots = rows.filter(r => r.shot_url || r.shot_update_url).map(r => [
|
||||
`<details open><summary>📸 ${r.script} (${where(r)}) — ${r.url ? clean(r.url) : 'console'}${r.shot_url && r.shot_update_url ? ': after install / after update' : r.shot_update_url ? ': after update' : ''}</summary>`, '',
|
||||
[r.shot_url, r.shot_update_url].filter(Boolean)
|
||||
.map((u, i, all) => `<img src="${u}" width="${all.length > 1 ? 420 : 640}" alt="Screenshot of ${r.script}">`).join(' '),
|
||||
'', '</details>',
|
||||
].join('\n'));
|
||||
|
||||
const summary = [
|
||||
`${headline} at \`${process.env.SHA.slice(0, 7)}\``, '',
|
||||
table, '',
|
||||
...shots, '',
|
||||
...(() => { try { const n = JSON.parse(process.env.NOT_BUILT || '[]'); return n.length ? ['**Not built:**', ...n.map(x => `- ${x}`), ''] : []; } catch (e) { return []; } })(),
|
||||
...logs, '',
|
||||
`Full logs: [workflow run](${runUrl}) (artifacts \`result-*\`). Comment \`/test${process.env.KIND === 'issue' ? ' ' + [...new Set(rows.map(r => r.script.slice(3, -3)))].join(' ') : ''}\` to build again.`,
|
||||
].join('\n');
|
||||
|
||||
// Issues have no check run (there is no PR commit to attach it to).
|
||||
if (process.env.CHECK_ID) {
|
||||
await github.rest.checks.update({
|
||||
owner, repo, check_run_id: Number(process.env.CHECK_ID), status: 'completed',
|
||||
// Untestable here is "test it by hand", not a pass: action_required,
|
||||
// because a required check would count neutral as passed.
|
||||
conclusion: cancelled ? 'cancelled' : allPass ? 'success' : broken ? 'failure' : 'action_required',
|
||||
output: { title: `${passed}/${rows.length} passed`, summary: summary.slice(0, 65000) },
|
||||
});
|
||||
}
|
||||
|
||||
// The result pings whoever started the test. A mention added by
|
||||
// editing a comment does not reliably notify, so the old comment
|
||||
// goes and the result is posted as a new one, at the end of the
|
||||
// thread where it is looked for.
|
||||
// Whoever started the test and the PR's author, once each; no bots.
|
||||
const pr = Number(process.env.PR);
|
||||
const isIssue = process.env.KIND === 'issue';
|
||||
const { data: prInfo } = isIssue
|
||||
? await github.rest.issues.get({ owner, repo, issue_number: pr })
|
||||
: await github.rest.pulls.get({ owner, repo, pull_number: pr });
|
||||
const author = prInfo.user && prInfo.user.type === 'User' ? prInfo.user.login : '';
|
||||
const login = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/;
|
||||
const ping = [...new Set([process.env.ACTOR || '', author])].filter(l => login.test(l)).map(l => `@${l} `).join('');
|
||||
const MARK = '<!-- pr-script-test -->';
|
||||
const body = `${MARK}\n### Proxmox script test\n\n${ping}${summary}`.slice(0, 65000);
|
||||
const comments = await github.paginate(github.rest.issues.listComments, { owner, repo, issue_number: pr });
|
||||
for (const c of comments.filter(c => c.body.includes(MARK) && c.user.type === 'Bot')) {
|
||||
await github.rest.issues.deleteComment({ owner, repo, comment_id: c.id });
|
||||
}
|
||||
const { data: posted } = await github.rest.issues.createComment({ owner, repo, issue_number: pr, body });
|
||||
// An issue has nothing to review or approve: the comment is all.
|
||||
if (isIssue) return;
|
||||
|
||||
// A real failure requests changes on the tested commit. Untestable
|
||||
// (CPU), runner errors and cancelled runs are not the PR's fault and
|
||||
// leave the review state alone. Our previous "changes requested" is
|
||||
// always dismissed first: a pass lifts it, a new failure replaces it.
|
||||
const REVIEW_MARK = '<!-- pr-script-test-review -->';
|
||||
const failedRows = cancelled ? [] : rows.filter(r => r.status === 'fail');
|
||||
const sha7 = process.env.SHA.slice(0, 7);
|
||||
// Trusted PRs (scripts moving over from DevScripts) are approved when
|
||||
// every script passes -- not on untestable or setup trouble, which
|
||||
// means "not tested", not "good". That approval counts toward the
|
||||
// ruleset's required review; a push dismisses it as stale, and a
|
||||
// later run that does not fully pass withdraws it.
|
||||
// Anyone with write access can push to a migration branch, and the
|
||||
// approval counts toward the required review. So it only covers
|
||||
// what a migration contains: ct/ and install/ scripts. A PR that
|
||||
// also changes anything else (workflows, misc/, ...) is tested but
|
||||
// never approved.
|
||||
let approve = process.env.TRUSTED === 'true' && allPass;
|
||||
let noApproveWhy = '';
|
||||
if (approve) {
|
||||
const changed = await github.paginate(github.rest.pulls.listFiles, { owner, repo, pull_number: pr });
|
||||
const other = changed.map(f => f.filename).filter(n => !/^(ct|install)\/[A-Za-z0-9._-]+\.sh$/.test(n));
|
||||
if (other.length) {
|
||||
approve = false;
|
||||
noApproveWhy = `Not approved automatically: besides \`ct/\` and \`install/\` scripts this PR changes ${other.slice(0, 5).map(n => '`' + n.replace(/[`\r\n]/g, '') + '`').join(', ')}${other.length > 5 ? ', …' : ''}. That needs a human review.`;
|
||||
}
|
||||
}
|
||||
if (noApproveWhy) {
|
||||
await github.rest.issues.updateComment({ owner, repo, comment_id: posted.id, body: `${body}\n\n> ${noApproveWhy}`.slice(0, 65000) });
|
||||
}
|
||||
try {
|
||||
const reviews = await github.paginate(github.rest.pulls.listReviews, { owner, repo, pull_number: pr });
|
||||
const ours = state => reviews.filter(v => v.user && v.user.type === 'Bot' && v.state === state && (v.body || '').includes(REVIEW_MARK));
|
||||
for (const v of ours('CHANGES_REQUESTED')) {
|
||||
await github.rest.pulls.dismissReview({
|
||||
owner, repo, pull_number: pr, review_id: v.id,
|
||||
message: failedRows.length ? 'Replaced by a newer Proxmox script test.' : `Proxmox script test passed at ${sha7}.`,
|
||||
});
|
||||
}
|
||||
if (!approve && !cancelled) {
|
||||
for (const v of ours('APPROVED')) {
|
||||
await github.rest.pulls.dismissReview({
|
||||
owner, repo, pull_number: pr, review_id: v.id,
|
||||
message: `Withdrawn: the Proxmox script test at ${sha7} did not fully pass.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (approve && !ours('APPROVED').some(v => v.commit_id === process.env.SHA)) {
|
||||
await github.rest.pulls.createReview({
|
||||
owner, repo, pull_number: pr, commit_id: process.env.SHA, event: 'APPROVE',
|
||||
body: [
|
||||
REVIEW_MARK, `**Proxmox script test passed** at \`${sha7}\`: ${rows.map(r => `\`${r.script}\``).join(', ')}.`, '',
|
||||
`Script moved over from DevScripts, built and checked on a real Proxmox host. [Test result](${posted.html_url}).`,
|
||||
].join('\n'),
|
||||
});
|
||||
}
|
||||
if (failedRows.length) {
|
||||
await github.rest.pulls.createReview({
|
||||
owner, repo, pull_number: pr, commit_id: process.env.SHA, event: 'REQUEST_CHANGES',
|
||||
body: [
|
||||
REVIEW_MARK, `**Proxmox script test failed** at \`${sha7}\`:`, '',
|
||||
...failedRows.map(r => `- \`${r.script}\` (${where(r)}): ${clean(r.step)}`), '',
|
||||
`Logs and details: [test result](${posted.html_url}). Push a fix, then ask for \`/test\` again.`,
|
||||
].join('\n'),
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
core.warning(`Could not update the review state: ${e.message}`);
|
||||
}
|
||||
+193
@@ -0,0 +1,193 @@
|
||||
#!/usr/bin/env bash
|
||||
# One-time setup of an Incus test host behind pr-script-test.yml: the same
|
||||
# ct/ scripts as on Proxmox, built through community-scripts/core's Incus
|
||||
# backend. Safe to re-run. Run as root on a disposable Debian 13 machine (it
|
||||
# builds pull request code as root).
|
||||
#
|
||||
# incus-ci-host-setup.sh host only
|
||||
# incus-ci-host-setup.sh <owner/repo> <token> host + a runner for that repo
|
||||
#
|
||||
# The token is a runner *registration* token (valid one hour):
|
||||
# gh api -X POST repos/<owner/repo>/actions/runners/registration-token --jq .token
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
RUNNER_VERSION="${RUNNER_VERSION:-2.338.2}"
|
||||
SERVE_ROOT=/srv/ci
|
||||
SERVE_PORT=18080
|
||||
NET_ADDR=10.98.0.1
|
||||
NET_CIDR=10.98.0.0/24
|
||||
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
apt-get update -qq
|
||||
apt-get install -y -qq incus jq curl python3 iptables chromium fonts-dejavu fonts-noto-color-emoji >/dev/null
|
||||
|
||||
# Containers get a NATed bridge of their own (Incus runs DHCP and DNS on it)
|
||||
# and a plain directory pool on the root disk.
|
||||
if ! incus storage show default &>/dev/null; then
|
||||
incus admin init --preseed <<EOF
|
||||
networks:
|
||||
- name: incusbr0
|
||||
type: bridge
|
||||
config:
|
||||
ipv4.address: ${NET_ADDR}/24
|
||||
ipv4.nat: "true"
|
||||
ipv6.address: none
|
||||
storage_pools:
|
||||
- name: default
|
||||
driver: dir
|
||||
profiles:
|
||||
- name: default
|
||||
devices:
|
||||
root:
|
||||
path: /
|
||||
pool: default
|
||||
type: disk
|
||||
eth0:
|
||||
name: eth0
|
||||
network: incusbr0
|
||||
type: nic
|
||||
EOF
|
||||
fi
|
||||
|
||||
# Screenshots of the app under test are taken as this user, never as root.
|
||||
id ci-shot &>/dev/null || useradd --system --create-home --shell /usr/sbin/nologin ci-shot
|
||||
|
||||
# Same rules as on the Proxmox hosts: the screenshot browser may reach the
|
||||
# test containers, DNS and the public internet, nothing private, and not this
|
||||
# host's own address on the container network (the PR tree server).
|
||||
cat >/usr/local/sbin/ci-shot-firewall <<EOF
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
uid=\$(id -u ci-shot)
|
||||
for fw in iptables ip6tables; do
|
||||
\$fw -w -N CI_SHOT 2>/dev/null || \$fw -w -F CI_SHOT
|
||||
\$fw -w -C OUTPUT -m owner --uid-owner "\$uid" -j CI_SHOT 2>/dev/null ||
|
||||
\$fw -w -I OUTPUT -m owner --uid-owner "\$uid" -j CI_SHOT
|
||||
done
|
||||
iptables -w -A CI_SHOT -d ${NET_ADDR} -j REJECT
|
||||
iptables -w -A CI_SHOT -d ${NET_CIDR} -j ACCEPT
|
||||
# systemd-resolved's stub; it forwards to the lab resolver itself.
|
||||
iptables -w -A CI_SHOT -d 127.0.0.53 -p udp --dport 53 -j ACCEPT
|
||||
iptables -w -A CI_SHOT -d 127.0.0.53 -p tcp --dport 53 -j ACCEPT
|
||||
for ns in \$(awk '/^nameserver/ && \$2 !~ /:/ {print \$2}' /etc/resolv.conf /run/systemd/resolve/resolv.conf 2>/dev/null); do
|
||||
iptables -w -A CI_SHOT -d "\$ns" -p udp --dport 53 -j ACCEPT
|
||||
iptables -w -A CI_SHOT -d "\$ns" -p tcp --dport 53 -j ACCEPT
|
||||
done
|
||||
for net in 0.0.0.0/8 10.0.0.0/8 100.64.0.0/10 127.0.0.0/8 169.254.0.0/16 172.16.0.0/12 192.168.0.0/16 224.0.0.0/3; do
|
||||
iptables -w -A CI_SHOT -d "\$net" -j REJECT
|
||||
done
|
||||
for net in ::1/128 fc00::/7 fe80::/10 ff00::/8; do
|
||||
ip6tables -w -A CI_SHOT -d "\$net" -j REJECT
|
||||
done
|
||||
EOF
|
||||
chmod +x /usr/local/sbin/ci-shot-firewall
|
||||
cat >/etc/systemd/system/ci-shot-firewall.service <<'EOF'
|
||||
[Unit]
|
||||
Description=Keep the screenshot browser away from this host and the lab network
|
||||
After=network-online.target incus.service
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
RemainAfterExit=yes
|
||||
ExecStart=/usr/local/sbin/ci-shot-firewall
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
[[ -s /etc/timezone ]] || {
|
||||
timedatectl set-timezone Europe/Vienna
|
||||
echo Europe/Vienna >/etc/timezone
|
||||
}
|
||||
|
||||
# The rewritten PR trees, served to the engine and to the containers. Waits
|
||||
# for incusbr0, which only exists once Incus is up.
|
||||
mkdir -p "$SERVE_ROOT"
|
||||
cat >/etc/systemd/system/ci-serve.service <<EOF
|
||||
[Unit]
|
||||
Description=Serve pull request trees to script builds
|
||||
After=incus.service network-online.target
|
||||
Wants=incus.service
|
||||
|
||||
[Service]
|
||||
ExecStartPre=/bin/sh -c 'for i in \$(seq 1 60); do ip -4 addr show dev incusbr0 | grep -q ${NET_ADDR} && exit 0; incus network list >/dev/null 2>&1; sleep 1; done; exit 1'
|
||||
ExecStart=/usr/bin/python3 -m http.server ${SERVE_PORT} --bind ${NET_ADDR} --directory ${SERVE_ROOT}
|
||||
DynamicUser=yes
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
# Leftovers of a job that was killed before its cleanup trap ran.
|
||||
cat >/usr/local/sbin/ci-sweep <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
# Delete CI containers (ci-9000 .. ci-9099) that no running job holds, and stale trees.
|
||||
# A job holds its guest with /run/ci-jobs/<id>. A job killed outright (runner
|
||||
# restart, timeout) never removes it, so a lock older than any job can run
|
||||
# (90 minutes) holds nothing.
|
||||
held() { [[ -e "/run/ci-jobs/$1" && -z "$(find "/run/ci-jobs/$1" -mmin +180)" ]]; }
|
||||
for name in $(incus list -c n -f csv 2>/dev/null | grep -E '^ci-9[0-9]{3}$'); do
|
||||
held "$name" && continue
|
||||
incus delete "$name" --force &>/dev/null && echo "swept $name"
|
||||
done
|
||||
find /run/ci-jobs -maxdepth 1 -type f ! -name ".*" -mmin +180 -delete 2>/dev/null
|
||||
find /srv/ci -mindepth 1 -maxdepth 1 -type d -mmin +180 -exec rm -rf {} +
|
||||
find /usr/local/community-scripts/logs /var/log/community-scripts -type f -mtime +2 -delete 2>/dev/null
|
||||
find /opt/actions-runner/*/_diag -type f -mtime +14 -delete 2>/dev/null
|
||||
true
|
||||
EOF
|
||||
chmod +x /usr/local/sbin/ci-sweep
|
||||
cat >/etc/systemd/system/ci-sweep.service <<'EOF'
|
||||
[Unit]
|
||||
Description=Remove leftover CI containers
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/local/sbin/ci-sweep
|
||||
EOF
|
||||
cat >/etc/systemd/system/ci-sweep.timer <<'EOF'
|
||||
[Unit]
|
||||
Description=Remove leftover CI containers every 3 hours
|
||||
|
||||
[Timer]
|
||||
OnCalendar=0/3:30
|
||||
Persistent=true
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
EOF
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now ci-serve.service ci-sweep.timer ci-shot-firewall.service >/dev/null
|
||||
systemctl restart ci-serve.service ci-shot-firewall.service
|
||||
|
||||
# --- Runner (one per repository) ----------------------------------------------
|
||||
if [[ $# -eq 2 ]]; then
|
||||
repo="$1"
|
||||
token="$2"
|
||||
[[ "$repo" =~ ^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$ ]] || { echo "bad repo" >&2; exit 2; }
|
||||
dir="/opt/actions-runner/${repo#*/}"
|
||||
mkdir -p "$dir"
|
||||
cd "$dir"
|
||||
if [[ ! -x ./config.sh ]]; then
|
||||
curl -fsSL "https://github.com/actions/runner/releases/download/v${RUNNER_VERSION}/actions-runner-linux-x64-${RUNNER_VERSION}.tar.gz" | tar -xz
|
||||
./bin/installdependencies.sh >/dev/null
|
||||
fi
|
||||
if [[ -f .runner ]]; then
|
||||
./svc.sh stop >/dev/null 2>&1 || true
|
||||
./svc.sh uninstall >/dev/null 2>&1 || true
|
||||
RUNNER_ALLOW_RUNASROOT=1 ./config.sh remove --token "$token" >/dev/null 2>&1 || true
|
||||
fi
|
||||
# Only `incus-test`: see pve-ci-host-setup.sh for why there are no default labels.
|
||||
RUNNER_ALLOW_RUNASROOT=1 ./config.sh --unattended --replace --no-default-labels \
|
||||
--url "https://github.com/${repo}" --token "$token" \
|
||||
--name "$(hostname -s)-${repo#*/}" --labels incus-test --work _work
|
||||
./svc.sh install root >/dev/null
|
||||
./svc.sh start >/dev/null
|
||||
echo "runner for ${repo} registered"
|
||||
fi
|
||||
|
||||
echo "incus test host ready: ${SERVE_ROOT} on ${NET_ADDR}:${SERVE_PORT}, sweeper every 3h"
|
||||
+220
@@ -0,0 +1,220 @@
|
||||
#!/usr/bin/env bash
|
||||
# One-time setup of the Proxmox test host behind pr-script-test.yml.
|
||||
# Safe to re-run. Run as root on the test host (a disposable PVE, never a
|
||||
# production node: it builds pull request code as root).
|
||||
#
|
||||
# pve-ci-host-setup.sh host only
|
||||
# pve-ci-host-setup.sh <owner/repo> <token> host + a runner for that repo
|
||||
#
|
||||
# The token is a runner *registration* token (valid one hour):
|
||||
# gh api -X POST repos/<owner/repo>/actions/runners/registration-token --jq .token
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
RUNNER_VERSION="${RUNNER_VERSION:-2.338.2}"
|
||||
SERVE_ROOT=/srv/ci
|
||||
SERVE_PORT=18080
|
||||
UPLINK="${UPLINK:-vmbr0}"
|
||||
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
apt-get update -qq
|
||||
apt-get install -y -qq jq curl python3 dnsmasq iptables chromium fonts-dejavu fonts-noto-color-emoji >/dev/null
|
||||
|
||||
# Screenshots of the app under test are taken as this user, never as root.
|
||||
id ci-shot &>/dev/null || useradd --system --create-home --shell /usr/sbin/nologin ci-shot
|
||||
|
||||
# The page Chromium renders is served by code under test, and a redirect or an
|
||||
# <img> could point it at this host (the PVE UI on :8006) or the lab network;
|
||||
# the screenshot would then publish that on the PR. So ci-shot may reach the
|
||||
# test containers, DNS and the public internet (apps load fonts and scripts
|
||||
# from CDNs), and nothing private.
|
||||
cat >/usr/local/sbin/ci-shot-firewall <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
uid=$(id -u ci-shot)
|
||||
for fw in iptables ip6tables; do
|
||||
$fw -w -N CI_SHOT 2>/dev/null || $fw -w -F CI_SHOT
|
||||
$fw -w -C OUTPUT -m owner --uid-owner "$uid" -j CI_SHOT 2>/dev/null ||
|
||||
$fw -w -I OUTPUT -m owner --uid-owner "$uid" -j CI_SHOT
|
||||
done
|
||||
iptables -w -A CI_SHOT -d 10.99.0.1 -j REJECT
|
||||
iptables -w -A CI_SHOT -d 10.99.0.0/24 -j ACCEPT
|
||||
for ns in $(awk '/^nameserver/ && $2 !~ /:/ {print $2}' /etc/resolv.conf); do
|
||||
iptables -w -A CI_SHOT -d "$ns" -p udp --dport 53 -j ACCEPT
|
||||
iptables -w -A CI_SHOT -d "$ns" -p tcp --dport 53 -j ACCEPT
|
||||
done
|
||||
for net in 0.0.0.0/8 10.0.0.0/8 100.64.0.0/10 127.0.0.0/8 169.254.0.0/16 172.16.0.0/12 192.168.0.0/16 224.0.0.0/3; do
|
||||
iptables -w -A CI_SHOT -d "$net" -j REJECT
|
||||
done
|
||||
for net in ::1/128 fc00::/7 fe80::/10 ff00::/8; do
|
||||
ip6tables -w -A CI_SHOT -d "$net" -j REJECT
|
||||
done
|
||||
EOF
|
||||
chmod +x /usr/local/sbin/ci-shot-firewall
|
||||
cat >/etc/systemd/system/ci-shot-firewall.service <<'EOF'
|
||||
[Unit]
|
||||
Description=Keep the screenshot browser away from this host and the lab network
|
||||
After=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
RemainAfterExit=yes
|
||||
ExecStart=/usr/local/sbin/ci-shot-firewall
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
# Containers get their own NATed bridge instead of the lab network: no DHCP
|
||||
# lease surprises (a lab lease once handed a container the host's own address),
|
||||
# and an app listening on 8080 can never be confused with this host.
|
||||
cat >/etc/network/interfaces.d/ci-nat <<EOF
|
||||
auto vmbr1
|
||||
iface vmbr1 inet static
|
||||
address 10.99.0.1/24
|
||||
bridge-ports none
|
||||
bridge-stp off
|
||||
bridge-fd 0
|
||||
post-up sysctl -qw net.ipv4.ip_forward=1
|
||||
post-up iptables -t nat -C POSTROUTING -s 10.99.0.0/24 -o ${UPLINK} -j MASQUERADE 2>/dev/null || iptables -t nat -A POSTROUTING -s 10.99.0.0/24 -o ${UPLINK} -j MASQUERADE
|
||||
post-down iptables -t nat -D POSTROUTING -s 10.99.0.0/24 -o ${UPLINK} -j MASQUERADE || true
|
||||
EOF
|
||||
ifreload -a
|
||||
cat >/etc/dnsmasq.d/ci.conf <<'EOF'
|
||||
interface=vmbr1
|
||||
bind-interfaces
|
||||
except-interface=lo
|
||||
dhcp-range=10.99.0.100,10.99.0.250,255.255.255.0,2h
|
||||
dhcp-option=option:router,10.99.0.1
|
||||
dhcp-option=option:dns-server,10.99.0.1
|
||||
no-resolv
|
||||
EOF
|
||||
# Upstream = whatever this host resolves with. Debian's dnsmasq would otherwise
|
||||
# ask resolvconf, which has nothing to offer on a PVE host.
|
||||
awk '/^nameserver/ {print "server=" $2}' /etc/resolv.conf >>/etc/dnsmasq.d/ci.conf
|
||||
systemctl enable dnsmasq >/dev/null
|
||||
systemctl restart dnsmasq
|
||||
|
||||
# A PVE ISO install writes /etc/timezone; a cloud-image install does not, and
|
||||
# the engine's pct_timezone aborts without it.
|
||||
[[ -s /etc/timezone ]] || {
|
||||
timedatectl set-timezone Europe/Vienna
|
||||
echo Europe/Vienna >/etc/timezone
|
||||
}
|
||||
|
||||
# Containers and VM disks live on the root filesystem; there is no LVM in a
|
||||
# cloud-image PVE. Snippets hold the Cloud-Init configs some vm/ scripts write.
|
||||
pvesm set local --content iso,vztmpl,backup,rootdir,images,snippets
|
||||
|
||||
# Templates are fetched once here rather than on every first build.
|
||||
pveam update >/dev/null
|
||||
for t in $(pveam available --section system | awk '/_amd64\./ && /debian-13-standard|alpine-3\.2[0-9]-default|ubuntu-24\.04-standard/ {print $2}'); do
|
||||
pveam list local | grep -q "$t" || pveam download local "$t" >/dev/null
|
||||
done
|
||||
|
||||
# The rewritten PR trees, served to the engine and to the containers.
|
||||
mkdir -p "$SERVE_ROOT"
|
||||
cat >/etc/systemd/system/ci-serve.service <<EOF
|
||||
[Unit]
|
||||
Description=Serve pull request trees to script builds
|
||||
After=network-online.target
|
||||
|
||||
[Service]
|
||||
ExecStart=/usr/bin/python3 -m http.server ${SERVE_PORT} --bind 10.99.0.1 --directory ${SERVE_ROOT}
|
||||
DynamicUser=yes
|
||||
Restart=always
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
# Leftovers of a job that was killed before its cleanup trap ran.
|
||||
cat >/usr/local/sbin/ci-sweep <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
# Destroy CI containers (9000-9099) and VMs (9100-9199) that no running job
|
||||
# holds, and stale trees.
|
||||
# A job holds its guest with /run/ci-jobs/<id>. A job killed outright (runner
|
||||
# restart, timeout) never removes it, so a lock older than any job can run
|
||||
# (90 minutes) holds nothing.
|
||||
held() { [[ -e "/run/ci-jobs/$1" && -z "$(find "/run/ci-jobs/$1" -mmin +180)" ]]; }
|
||||
for id in $(pct list | awk 'NR>1 && $1>=9000 && $1<=9099 {print $1}'); do
|
||||
held "$id" && continue
|
||||
pct stop "$id" --skiplock 1 &>/dev/null || true
|
||||
pct destroy "$id" --purge 1 --force 1 &>/dev/null && echo "swept CT $id"
|
||||
done
|
||||
for id in $(qm list | awk 'NR>1 && $1>=9100 && $1<=9199 {print $1}'); do
|
||||
held "$id" && continue
|
||||
qm stop "$id" --skiplock 1 --timeout 30 &>/dev/null || true
|
||||
qm destroy "$id" --purge 1 --destroy-unreferenced-disks 1 --skiplock 1 &>/dev/null && echo "swept VM $id"
|
||||
done
|
||||
# VM images and ISOs are cached between builds; drop the ones no build used
|
||||
# for a week. Container templates (*.tar.*) stay.
|
||||
find /var/lib/vz/template/cache /var/lib/vz/template/iso -maxdepth 1 -type f ! -name '*.tar.*' -atime +7 -delete 2>/dev/null
|
||||
find /run/ci-jobs -maxdepth 1 -type f ! -name ".*" -mmin +180 -delete 2>/dev/null
|
||||
find /srv/ci -mindepth 1 -maxdepth 1 -type d -mmin +180 -exec rm -rf {} +
|
||||
# Engine logs that a killed job never got to remove.
|
||||
find /tmp -maxdepth 1 -type f \( -name 'create-lxc-*.log' -o -name 'pct_create_*.log' \
|
||||
-o -regex '/tmp/.*-9[0-9][0-9][0-9]-[0-9a-f]+\.log' \) -mmin +180 -delete
|
||||
find /var/log/community-scripts -type f -mtime +2 -delete 2>/dev/null
|
||||
find /opt/actions-runner/*/_diag -type f -mtime +14 -delete 2>/dev/null
|
||||
true
|
||||
EOF
|
||||
chmod +x /usr/local/sbin/ci-sweep
|
||||
cat >/etc/systemd/system/ci-sweep.service <<'EOF'
|
||||
[Unit]
|
||||
Description=Remove leftover CI containers
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/local/sbin/ci-sweep
|
||||
EOF
|
||||
cat >/etc/systemd/system/ci-sweep.timer <<'EOF'
|
||||
[Unit]
|
||||
Description=Remove leftover CI containers every 3 hours
|
||||
|
||||
[Timer]
|
||||
OnCalendar=0/3:30
|
||||
Persistent=true
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
EOF
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now ci-serve.service ci-sweep.timer ci-shot-firewall.service >/dev/null
|
||||
systemctl restart ci-serve.service ci-shot-firewall.service
|
||||
|
||||
# --- Runner (one per repository) ----------------------------------------------
|
||||
if [[ $# -eq 2 ]]; then
|
||||
repo="$1"
|
||||
token="$2"
|
||||
[[ "$repo" =~ ^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$ ]] || { echo "bad repo" >&2; exit 2; }
|
||||
dir="/opt/actions-runner/${repo#*/}"
|
||||
mkdir -p "$dir"
|
||||
cd "$dir"
|
||||
if [[ ! -x ./config.sh ]]; then
|
||||
curl -fsSL "https://github.com/actions/runner/releases/download/v${RUNNER_VERSION}/actions-runner-linux-x64-${RUNNER_VERSION}.tar.gz" | tar -xz
|
||||
./bin/installdependencies.sh >/dev/null
|
||||
fi
|
||||
# A registered runner is reconfigured from scratch: stop and remove the old
|
||||
# registration (the token works for removal too).
|
||||
if [[ -f .runner ]]; then
|
||||
./svc.sh stop >/dev/null 2>&1 || true
|
||||
./svc.sh uninstall >/dev/null 2>&1 || true
|
||||
RUNNER_ALLOW_RUNASROOT=1 ./config.sh remove --token "$token" >/dev/null 2>&1 || true
|
||||
fi
|
||||
# Root, because pct needs it; the host is disposable by design.
|
||||
# --no-default-labels: without it the runner also answers to `self-hosted`,
|
||||
# and any other workflow with `runs-on: self-hosted` (and its secrets) could
|
||||
# land on this host.
|
||||
# Named after this host: with --replace, a fixed name would make a second
|
||||
# test host take over the first one's registration instead of adding to it.
|
||||
RUNNER_ALLOW_RUNASROOT=1 ./config.sh --unattended --replace --no-default-labels \
|
||||
--url "https://github.com/${repo}" --token "$token" \
|
||||
--name "$(hostname -s)-${repo#*/}" --labels proxmox-test --work _work
|
||||
./svc.sh install root >/dev/null
|
||||
./svc.sh start >/dev/null
|
||||
echo "runner for ${repo} registered"
|
||||
fi
|
||||
|
||||
echo "test host ready: ${SERVE_ROOT} on :${SERVE_PORT}, sweeper every 3h"
|
||||
+627
@@ -0,0 +1,627 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build one ct/ or vm/ script from a pull request on this test host, check
|
||||
# that the container or VM really works, destroy it, and leave a result.json
|
||||
# behind. The vm/ part lives in pve-run-vm.sh, sourced from here.
|
||||
#
|
||||
# PLATFORM says what this host is: pve (Proxmox VE, pct) or incus. The ct/
|
||||
# scripts are the same for both; community-scripts/core picks its Incus
|
||||
# backend by itself when it finds incus instead of pveversion. Only the way
|
||||
# this harness reaches into the container differs (guest_* below).
|
||||
#
|
||||
# Runs on the self-hosted runner, which *is* the disposable test host. The
|
||||
# harness comes from the default branch; only the script under test comes from
|
||||
# the pull request, pinned to the commit a maintainer approved.
|
||||
#
|
||||
# The engine hardcodes raw.githubusercontent.com/<upstream>/main for every fetch
|
||||
# (ct -> build.func -> install script -> install.func -> tools.func). Pointing it
|
||||
# at a fork branch therefore means rewriting those URLs: the PR tree is unpacked
|
||||
# under SERVE_ROOT, every upstream main URL in it is replaced by this host's own
|
||||
# HTTP server, and the containers fetch from there. Engine changes in the PR are
|
||||
# tested along with the script, which is what a reviewer wants anyway.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
: "${PR_REPO:?}" "${PR_SHA:?}" "${SCRIPT:?}" "${UPSTREAM:?}" "${CTID_BASE:?}" "${OUT_DIR:?}"
|
||||
OS="${OS:-}"
|
||||
PLATFORM="${PLATFORM:-pve}"
|
||||
CTID_COUNT="${CTID_COUNT:-50}"
|
||||
SERVE_ROOT="${SERVE_ROOT:-/srv/ci}"
|
||||
# The containers' own network: vmbr1 + dnsmasq on Proxmox, incusbr0 on Incus.
|
||||
if [[ "$PLATFORM" == "incus" ]]; then
|
||||
SERVE_URL="${SERVE_URL:-http://10.98.0.1:18080}"
|
||||
BRIDGE="${BRIDGE:-incusbr0}"
|
||||
else
|
||||
SERVE_URL="${SERVE_URL:-http://10.99.0.1:18080}"
|
||||
BRIDGE="${BRIDGE:-vmbr1}"
|
||||
fi
|
||||
BUILD_TIMEOUT="${BUILD_TIMEOUT:-45m}"
|
||||
URL_WAIT="${URL_WAIT:-120}"
|
||||
|
||||
# Every value below reaches a path, a URL or a command line.
|
||||
[[ "$PR_REPO" =~ ^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$ ]] || { echo "bad PR_REPO" >&2; exit 2; }
|
||||
[[ "$UPSTREAM" =~ ^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$ ]] || { echo "bad UPSTREAM" >&2; exit 2; }
|
||||
[[ "$PR_SHA" =~ ^[0-9a-f]{40}$ ]] || { echo "bad PR_SHA" >&2; exit 2; }
|
||||
[[ "$SCRIPT" =~ ^(ct|vm)/[A-Za-z0-9._-]+\.sh$ ]] || { echo "bad SCRIPT" >&2; exit 2; }
|
||||
[[ -z "$OS" || "$OS" =~ ^(debian|ubuntu|alpine)$ ]] || { echo "bad OS" >&2; exit 2; }
|
||||
[[ "$PLATFORM" =~ ^(pve|incus)$ ]] || { echo "bad PLATFORM" >&2; exit 2; }
|
||||
[[ "$PLATFORM" == "pve" || "$SCRIPT" == ct/* ]] || { echo "vm/ scripts run on pve only" >&2; exit 2; }
|
||||
[[ "$CTID_BASE" =~ ^[0-9]+$ ]] || { echo "bad CTID_BASE" >&2; exit 2; }
|
||||
|
||||
mkdir -p "$OUT_DIR"
|
||||
RAW_LOG="$OUT_DIR/build.raw.log"
|
||||
LOG="$OUT_DIR/build.log"
|
||||
RESULT="$OUT_DIR/result.json"
|
||||
INSTALL_LOG_OUT="$OUT_DIR/install.log"
|
||||
UPDATE_RAW="$OUT_DIR/update.raw.log"
|
||||
UPDATE_LOG="$OUT_DIR/update.log"
|
||||
UPDATE_TIMEOUT="${UPDATE_TIMEOUT:-30m}"
|
||||
|
||||
kind="${SCRIPT%%/*}" # ct or vm
|
||||
app=$(basename "$SCRIPT" .sh)
|
||||
job="${GITHUB_RUN_ID:-local}-${GITHUB_RUN_ATTEMPT:-1}-${PLATFORM}-${kind}-${app}-${OS:-default}"
|
||||
tree="$SERVE_ROOT/$job"
|
||||
base="$SERVE_URL/$job"
|
||||
started=$(date +%s)
|
||||
|
||||
status="fail"
|
||||
step=""
|
||||
exit_code=""
|
||||
ctid="" # the guest's ID (pve: 9000, incus: ci-9000), container or VM
|
||||
|
||||
# The container, whatever the platform. ctid is set once a slot is reserved.
|
||||
guest_exec() {
|
||||
if [[ "$PLATFORM" == "incus" ]]; then incus exec "$ctid" -- "$@"; else pct exec "$ctid" -- "$@"; fi
|
||||
}
|
||||
guest_exists() {
|
||||
if [[ "$PLATFORM" == "incus" ]]; then incus info "$ctid" &>/dev/null; else pct status "$ctid" &>/dev/null; fi
|
||||
}
|
||||
guest_running() {
|
||||
if [[ "$PLATFORM" == "incus" ]]; then
|
||||
[[ "$(incus list "^${ctid}\$" -c s -f csv 2>/dev/null)" == "RUNNING" ]]
|
||||
else
|
||||
pct status "$ctid" 2>/dev/null | grep -q running
|
||||
fi
|
||||
}
|
||||
guest_destroy() {
|
||||
if [[ "$PLATFORM" == "incus" ]]; then
|
||||
incus delete "$ctid" --force &>/dev/null || true
|
||||
else
|
||||
pct stop "$ctid" --skiplock 1 &>/dev/null || true
|
||||
pct destroy "$ctid" --purge 1 --force 1 &>/dev/null || true
|
||||
fi
|
||||
}
|
||||
ct_ip=""
|
||||
url=""
|
||||
http_code=""
|
||||
failed_units=""
|
||||
# updated: a real update ran; no-update: update ran, nothing to update;
|
||||
# fail: the update broke; empty: not attempted (the install failed).
|
||||
update_status=""
|
||||
update_step=""
|
||||
|
||||
# Spinner frames: the engine redraws "⠋ Starting VM" a few times a second
|
||||
# without a tty too. An alternation, not a bracket: these are multibyte and
|
||||
# the runner's locale may be C.
|
||||
SPINNER_RE='^\s*(⠋|⠙|⠹|⠸|⠼|⠴|⠦|⠧|⠇|⠏)'
|
||||
|
||||
strip_ansi() {
|
||||
sed -E 's/\x1B\[[0-9;?]*[A-Za-z]//g; s/\x1B[()][A-Z0-9]//g; s/\r/\n/g' | grep -vE '^\s*$' | grep -vE "$SPINNER_RE"
|
||||
}
|
||||
|
||||
# The install script's own output: everything $STD hides from the build log.
|
||||
# Both engines write it inside the container as /root/.install-<session>.log;
|
||||
# dev_mode=keep makes sure a failed container is still there to read it from.
|
||||
# If it is gone anyway, the copy the engine leaves on the host is the fallback.
|
||||
collect_install_log() {
|
||||
[[ -n "$ctid" ]] || return 0
|
||||
# Exactly one build ran in this container, so the newest install log is ours.
|
||||
if guest_exists &&
|
||||
guest_exec sh -c 'cat "$(ls -t /root/.install-*.log 2>/dev/null | head -n1)"' 2>/dev/null |
|
||||
strip_ansi >"$INSTALL_LOG_OUT" && [[ -s "$INSTALL_LOG_OUT" ]]; then
|
||||
return 0
|
||||
fi
|
||||
[[ "$PLATFORM" == "pve" ]] || { rm -f "$INSTALL_LOG_OUT"; return 0; }
|
||||
# The engine names its host-side files after the session: pct_create_<ctid>_<date>_<session>.log
|
||||
local sid f
|
||||
sid=$(find /tmp -maxdepth 1 -name "pct_create_${ctid}_*.log" -newer "$RAW_LOG.start" -printf '%f\n' 2>/dev/null |
|
||||
sed -E 's/.*_([0-9a-f]+)\.log$/\1/' | head -n1)
|
||||
[[ -n "$sid" ]] || { rm -f "$INSTALL_LOG_OUT"; return 0; }
|
||||
for f in /tmp/*-"${ctid}"-"${sid}".log /var/log/community-scripts/install-"${sid}"-combined-*.log; do
|
||||
[[ -s "$f" ]] && { strip_ansi <"$f" >"$INSTALL_LOG_OUT"; return 0; }
|
||||
done
|
||||
rm -f "$INSTALL_LOG_OUT"
|
||||
}
|
||||
|
||||
# "Requires AVX2" and friends: the script may be fine, the test host's CPU is
|
||||
# not (Xeon E5-2650, Sandy Bridge: AVX, no AVX2). Shown as untestable rather
|
||||
# than as a failure of the PR -- but the log is written by the code under test,
|
||||
# so its word is not enough. Only the engine's error lines count, only when the
|
||||
# build or install itself failed, and the feature they name must really be
|
||||
# missing from this host's /proc/cpuinfo. Claiming a feature the host has
|
||||
# changes nothing.
|
||||
CPU_RE='(requir|need)[a-z]*[^.]{0,60}(AVX(2|-?512)?|SSE4[._]?2|x86-64-v[234])\b|\b(AVX(2|-?512)?|x86-64-v[234])\b[^.]{0,60}(not (supported|available)|does not have|is missing|unsupported)'
|
||||
|
||||
classify_cpu_failure() {
|
||||
local line feature flags
|
||||
line=$(strip_ansi <"$RAW_LOG" | grep -E '✖|\[ERROR\]' | grep -m1 -iE "$CPU_RE" |
|
||||
sed -E 's/^\s*(✖️?|\[[^]]*\]|\s)*//' | cut -c1-200)
|
||||
[[ -n "$line" ]] || return 0
|
||||
# The CPU flag that would have to be missing for the message to be true.
|
||||
case "${line,,}" in
|
||||
*avx-512* | *avx512* | *x86-64-v4*) feature=avx512f ;;
|
||||
*avx2* | *x86-64-v3*) feature=avx2 ;;
|
||||
*sse4.2* | *sse4_2* | *x86-64-v2*) feature=sse4_2 ;;
|
||||
*) return 0 ;;
|
||||
esac
|
||||
flags=" $(grep -m1 '^flags' /proc/cpuinfo | cut -d: -f2) "
|
||||
[[ "$flags" == *" ${feature} "* ]] && return 0
|
||||
status="unsupported"
|
||||
step="needs a newer CPU than the test host has (no ${feature}): ${line}"
|
||||
}
|
||||
|
||||
finish() {
|
||||
[[ -f "$RAW_LOG" ]] && strip_ansi <"$RAW_LOG" >"$LOG"
|
||||
[[ -f "$RAW_LOG" ]] && collect_install_log
|
||||
[[ -f "$UPDATE_RAW" ]] && strip_ansi <"$UPDATE_RAW" >"$UPDATE_LOG"
|
||||
rm -f "$UPDATE_RAW"
|
||||
jq -n \
|
||||
--arg script "$SCRIPT" --arg kind "$kind" --arg platform "$PLATFORM" --arg os "${OS:-default}" --arg status "$status" \
|
||||
--arg step "$step" --arg exit_code "$exit_code" --arg sha "$PR_SHA" \
|
||||
--arg ctid "$ctid" --arg url "$url" --arg http_code "$http_code" \
|
||||
--arg failed_units "$failed_units" --arg ip "$ct_ip" \
|
||||
--arg update_status "$update_status" --arg update_step "$update_step" \
|
||||
--argjson duration "$(($(date +%s) - started))" \
|
||||
--arg tail "$([[ -f "$LOG" ]] && tail -n 60 "$LOG")" \
|
||||
'{script: $script, kind: $kind, platform: $platform, os: $os, status: $status, step: $step, exit_code: $exit_code,
|
||||
sha: $sha, ctid: $ctid, ip: $ip, url: $url, http_code: $http_code,
|
||||
failed_units: $failed_units, update_status: $update_status, update_step: $update_step,
|
||||
duration: $duration, log_tail: $tail}' >"$RESULT"
|
||||
echo "::group::result"
|
||||
jq 'del(.log_tail)' "$RESULT"
|
||||
echo "::endgroup::"
|
||||
}
|
||||
|
||||
# --- Live output ------------------------------------------------------------------
|
||||
# The job log on GitHub streams while the job runs, so mirroring the build there
|
||||
# is all "live" takes. Two streams: the engine's own output, and the install
|
||||
# log inside the container, which holds everything $STD hides (apt, npm, ...).
|
||||
#
|
||||
# Both are written by the code under test. A line starting with `::` would be
|
||||
# read by the runner as a workflow command, so command processing is switched
|
||||
# off for as long as they run, with a token the build cannot know.
|
||||
stream_token=""
|
||||
log_pid=""
|
||||
install_pid=""
|
||||
|
||||
# Colours stay, cursor movement and spinner redraws go.
|
||||
live_filter() {
|
||||
sed -u -E 's/\r/\n/g; s/\x1B\[[0-9;?]*[ABCDEFGHJKSTfhilnsu]//g' | sed -u -E "/^\s*\$/d; /${SPINNER_RE}/d"
|
||||
}
|
||||
|
||||
start_streams() {
|
||||
stream_token=$(head -c 16 /dev/urandom | od -An -tx1 | tr -d ' \n')
|
||||
echo "::stop-commands::${stream_token}"
|
||||
tail -n +1 -F --pid="$build_pid" "$RAW_LOG" 2>/dev/null > >(live_filter) &
|
||||
log_pid=$!
|
||||
# A VM has no install log to follow: its script runs on this host only.
|
||||
[[ "$kind" == "ct" ]] || return 0
|
||||
# setsid: its own process group, so stop_streams can take down the
|
||||
# pct/incus exec + tail pair inside it in one go.
|
||||
setsid bash -c '
|
||||
gx() { if [ "$2" = incus ]; then incus exec "$1" -- "${@:3}"; else pct exec "$1" -- "${@:3}"; fi; }
|
||||
for _ in $(seq 1 900); do
|
||||
f=$(gx "$1" "$2" sh -c "ls -t /root/.install-*.log 2>/dev/null | head -n1" 2>/dev/null)
|
||||
if [ -n "$f" ]; then
|
||||
echo "──────── install log of CT $1 (live) ────────"
|
||||
if [ "$2" = incus ]; then exec incus exec "$1" -- tail -n +1 -F "$f"; fi
|
||||
exec pct exec "$1" -- tail -n +1 -F "$f"
|
||||
fi
|
||||
sleep 2
|
||||
done' _ "$ctid" "$PLATFORM" 2>/dev/null > >(live_filter | sed -u 's/^/ │ /') &
|
||||
install_pid=$!
|
||||
}
|
||||
|
||||
stop_streams() {
|
||||
[[ -n "$stream_token" ]] || return 0
|
||||
[[ -n "$install_pid" ]] && kill -TERM -- "-$install_pid" &>/dev/null
|
||||
[[ -n "$log_pid" ]] && kill -TERM "$log_pid" &>/dev/null
|
||||
wait "$log_pid" "$install_pid" &>/dev/null
|
||||
sleep 1 # let the filters flush
|
||||
install_pid=""
|
||||
log_pid=""
|
||||
echo "::${stream_token}::"
|
||||
stream_token=""
|
||||
}
|
||||
|
||||
build_pid=""
|
||||
update_pid=""
|
||||
cleanup() {
|
||||
stop_streams
|
||||
local pid
|
||||
for pid in "$build_pid" "$update_pid"; do
|
||||
[[ -n "$pid" ]] || continue
|
||||
pkill -TERM -P "$pid" &>/dev/null || true
|
||||
kill -TERM "$pid" &>/dev/null || true
|
||||
sleep 2
|
||||
done
|
||||
if [[ -n "$ctid" && "$kind" == "vm" ]]; then
|
||||
vm_cleanup
|
||||
elif [[ -n "$ctid" ]]; then
|
||||
guest_exists && guest_destroy
|
||||
rm -f "/run/ci-jobs/$ctid"
|
||||
# What the engine leaves on the host for this container and its session.
|
||||
local f sid
|
||||
[[ "$PLATFORM" == "pve" ]] && for f in /tmp/pct_create_"${ctid}"_*.log; do
|
||||
[[ -f "$f" && "$f" -nt "$RAW_LOG.start" ]] || continue
|
||||
sid=$(sed -E 's/.*_([0-9a-f]+)\.log$/\1/' <<<"$f")
|
||||
rm -f -- "$f" /tmp/create-lxc-"${sid}".log /tmp/*-"${ctid}"-"${sid}".log
|
||||
done
|
||||
fi
|
||||
rm -f "$RAW_LOG.start"
|
||||
rm -rf -- "$tree"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
# A cancelled job sends TERM; without this the EXIT trap would not run.
|
||||
trap 'exit 143' TERM
|
||||
trap 'exit 130' INT
|
||||
|
||||
# fail <reason> [fail|unsupported]: only the build/install branches pass the
|
||||
# second argument, after classify_cpu_failure has checked the host.
|
||||
#
|
||||
# The exit code says whether *this job* worked, not whether the app did. A
|
||||
# script that fails to build is a result: it is written down, reported on the
|
||||
# PR (check, comment, review) and the job exits 0 -- a red run per broken app
|
||||
# would only be notification noise. Only trouble with the test setup itself
|
||||
# (setup_error) fails the job, because that is ours to look at.
|
||||
fail() {
|
||||
step="$1"
|
||||
status="${2:-fail}"
|
||||
[[ "$status" == "unsupported" ]] || status="fail"
|
||||
finish
|
||||
echo "::warning title=${SCRIPT}::${step}"
|
||||
exit 0
|
||||
}
|
||||
|
||||
setup_error() {
|
||||
step="$1"
|
||||
status="error"
|
||||
finish
|
||||
echo "::error title=${SCRIPT}::test setup: ${step}"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- Reserve a container ID ---------------------------------------------------
|
||||
# A lock file per ID, not just "first free one", because the engine only creates
|
||||
# the container minutes later and two jobs would otherwise pick the same ID.
|
||||
# VMs take the range 100 above the containers' (DevScripts 9100-9149,
|
||||
# ProxmoxVE 9150-9199).
|
||||
mkdir -p /run/ci-jobs
|
||||
if [[ "$kind" == "vm" ]]; then
|
||||
source "$(dirname "$0")/pve-run-vm.sh"
|
||||
CTID_BASE=$((CTID_BASE + 100))
|
||||
vm_wait_turn
|
||||
fi
|
||||
exec 9>/run/ci-jobs/.lock
|
||||
flock 9
|
||||
# On Incus the ID is an instance name: var_ctid=ci-9000 names the container.
|
||||
for ((id = CTID_BASE; id < CTID_BASE + CTID_COUNT; id++)); do
|
||||
name=$id
|
||||
[[ "$PLATFORM" == "incus" ]] && name="ci-$id"
|
||||
[[ -e "/run/ci-jobs/$name" ]] && continue
|
||||
if [[ "$PLATFORM" == "incus" ]]; then
|
||||
incus info "$name" &>/dev/null && continue
|
||||
else
|
||||
pvesh get /cluster/nextid --vmid "$id" &>/dev/null || continue
|
||||
fi
|
||||
echo "$job" >"/run/ci-jobs/$name"
|
||||
ctid=$name
|
||||
break
|
||||
done
|
||||
flock -u 9
|
||||
exec 9>&-
|
||||
[[ -n "$ctid" ]] || setup_error "no free ${kind} ID in ${CTID_BASE}-$((CTID_BASE + CTID_COUNT - 1))"
|
||||
|
||||
# --- Fetch the approved commit and point it at ourselves ------------------------
|
||||
mkdir -p "$tree"
|
||||
curl -fsSL --retry 3 "https://codeload.github.com/${PR_REPO}/tar.gz/${PR_SHA}" |
|
||||
tar -xz -C "$tree" --strip-components=1 || setup_error "could not download ${PR_REPO}@${PR_SHA:0:7}"
|
||||
[[ -f "$tree/$SCRIPT" ]] || fail "${SCRIPT} does not exist at ${PR_SHA:0:7}"
|
||||
|
||||
upstream_re="https://raw\.githubusercontent\.com/${UPSTREAM//./\\.}/(refs/heads/)?main"
|
||||
grep -rlE "$upstream_re" "$tree" --include='*.sh' --include='*.func' |
|
||||
xargs -r sed -i -E "s#${upstream_re}#${base}#g"
|
||||
|
||||
if [[ "$kind" == "vm" ]]; then
|
||||
vm_build_and_check
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# --- Build ----------------------------------------------------------------------
|
||||
echo "Building ${SCRIPT} (${OS:-default OS}) on ${PLATFORM} as CT ${ctid} from ${PR_REPO}@${PR_SHA:0:7}"
|
||||
hostname_safe="ci-$(tr -c 'a-z0-9-\n' '-' <<<"${app,,}" | cut -c1-50)"
|
||||
build_env=(
|
||||
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
||||
HOME=/root TERM=dumb LANG=C.UTF-8
|
||||
mode=default MODE=default PHS_SILENT=1 DIAGNOSTICS=no
|
||||
var_ctid="$ctid" var_hostname="$hostname_safe" var_brg="$BRIDGE"
|
||||
"dev_mode=timing,keep" COMMUNITY_SCRIPTS_URL="$base"
|
||||
)
|
||||
if [[ "$PLATFORM" == "incus" ]]; then
|
||||
build_env+=(var_container_storage=default)
|
||||
else
|
||||
build_env+=(var_container_storage=local var_template_storage=local)
|
||||
fi
|
||||
touch "$RAW_LOG.start"
|
||||
[[ -n "$OS" ]] && build_env+=(var_os="$OS")
|
||||
# Both engines turn var_github_token into GITHUB_TOKEN inside the container.
|
||||
[[ -n "${GH_API_TOKEN:-}" ]] && build_env+=(var_github_token="$GH_API_TOKEN")
|
||||
|
||||
# --- Answers for the script's questions ------------------------------------------
|
||||
# Nobody types into CI, so anything the install asks has to be answered up front.
|
||||
# AGENTS.md rule 24: a script reads var_<x> first and prompts only when it is
|
||||
# unset (or uses prompt_input_required ... "var_x"). Every such variable gets a
|
||||
# CI value here. The names come from json/<app>.json app_vars (DevScripts) and
|
||||
# from the read/prompt_ lines of the install script (both repos). A bare `read`
|
||||
# that ignores its variable still fails, and the report says why.
|
||||
ENGINE_VARS=" var_ctid var_hostname var_os var_version var_cpu var_ram var_disk var_brg var_net var_container_storage var_template_storage var_unprivileged var_tags var_arm64 var_testurl var_verbose var_timezone var_http_proxy var_http_no_proxy var_appversion var_github_token "
|
||||
host_app=$(tr -c 'a-z0-9-\n' '-' <<<"${app,,}")
|
||||
ci_value() {
|
||||
case "${1,,}" in
|
||||
*pass* | *secret* | *token* | *key*) head -c 32 /dev/urandom | base64 | tr -dc 'A-Za-z0-9' | cut -c1-20 ;;
|
||||
*email* | *mail*) echo "ci@example.test" ;;
|
||||
*url* | *endpoint*) echo "https://ci-${host_app}.example.test" ;;
|
||||
# One name per variable: var_opencloud_collabora_fqdn ->
|
||||
# ci-opencloud-collabora.example.test, so several of them cannot collide.
|
||||
*host* | *domain* | *fqdn*)
|
||||
local part="${1#var_}"
|
||||
part=$(sed -E 's/_?(fqdn|hostname|host|domain)$//' <<<"${part,,}" | tr '_' '-')
|
||||
echo "ci-${part:-$host_app}.example.test"
|
||||
;;
|
||||
*user*) echo "ciadmin" ;;
|
||||
*) echo "ci-test" ;;
|
||||
esac
|
||||
}
|
||||
declare -A answers=()
|
||||
json="$tree/json/${app}.json"
|
||||
if [[ -f "$json" ]]; then
|
||||
while IFS=$'\t' read -r name default; do
|
||||
answers[$name]="$default"
|
||||
done < <(jq -r '(if type == "array" then .[0] else . end).app_vars // [] | .[] |
|
||||
select(.name | test("^var_[a-z0-9_]{1,40}$")) | [.name, (.default // "" | tostring)] | @tsv' "$json" 2>/dev/null)
|
||||
fi
|
||||
# Names built at run time from a loop, e.g. OpenCloud:
|
||||
# servers=("opencloud" "collabora" "wopi")
|
||||
# for server in "${servers[@]}"; do fqdn_var="var_opencloud_${server}_fqdn"
|
||||
# Each var_...${x}... template is expanded with the words of `for x in ...`
|
||||
# (or of the array that loop walks). Nothing is executed, only read.
|
||||
expand_templates() {
|
||||
local tpl x pre suf words arr w
|
||||
while read -r tpl; do
|
||||
x=$(sed -E 's/^[^$]*\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?.*$/\1/' <<<"$tpl")
|
||||
pre="${tpl%%\$*}"
|
||||
suf=$(sed -E 's/^[^$]*\$\{?[A-Za-z_][A-Za-z0-9_]*\}?//' <<<"$tpl")
|
||||
words=$(grep -m1 -oE "for ${x} in [^;]*" "$1" | sed -E "s/^for ${x} in //")
|
||||
if [[ "$words" =~ \$\{([A-Za-z_][A-Za-z0-9_]*)\[@\]\} ]]; then
|
||||
arr="${BASH_REMATCH[1]}"
|
||||
words=$(grep -m1 -oE "^\s*${arr}=\([^)]*\)" "$1" | sed -E "s/^\s*${arr}=\(//; s/\)$//")
|
||||
fi
|
||||
for w in $words; do
|
||||
w="${w//[\"\']/}"
|
||||
[[ "${pre}${w}${suf}" =~ ^var_[a-z0-9_]{1,40}$ ]] && echo "${pre}${w}${suf}"
|
||||
done
|
||||
done < <(grep -oE 'var_[a-z0-9_]*\$\{?[A-Za-z_][A-Za-z0-9_]*\}?[a-z0-9_]*' "$1" | sort -u)
|
||||
}
|
||||
|
||||
# The engine names the install script after APP (lower case, no spaces), which
|
||||
# need not match the ct/ file name: ct/bluesky-pds.sh -> blueskypds-install.sh.
|
||||
nsapp=$(grep -m1 -oE '^APP="[^"]+"' "$tree/$SCRIPT" | cut -d'"' -f2 | tr -d ' ' | tr '[:upper:]' '[:lower:]')
|
||||
for f in "$tree/install/${app}-install.sh" "$tree/install/${nsapp}-install.sh" "$tree/$SCRIPT"; do
|
||||
[[ -f "$f" ]] || continue
|
||||
while read -r name; do
|
||||
[[ -v "answers[$name]" ]] || answers[$name]=""
|
||||
done < <({
|
||||
grep -E '\bread\b|prompt_(input|input_required|confirm|select|password)\b' "$f" | grep -oE '\bvar_[a-z0-9_]{1,40}\b'
|
||||
expand_templates "$f"
|
||||
} | sort -u)
|
||||
done
|
||||
for name in "${!answers[@]}"; do
|
||||
[[ "$ENGINE_VARS" == *" $name "* ]] && continue
|
||||
value="${answers[$name]}"
|
||||
[[ -n "$value" ]] || value=$(ci_value "$name")
|
||||
build_env+=("$name=$value")
|
||||
case "${name,,}" in
|
||||
*pass* | *secret* | *token* | *key*) echo "Pre-answered ${name}=(random)" ;;
|
||||
*) echo "Pre-answered ${name}=${value}" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# In the background so that a TERM from a cancelled job is handled at once:
|
||||
# bash defers traps until a foreground command returns.
|
||||
env -i "${build_env[@]}" timeout --kill-after=1m "$BUILD_TIMEOUT" \
|
||||
bash -c 'bash -c "$(curl -fsSL "$1")"' _ "$base/$SCRIPT" </dev/null >"$RAW_LOG" 2>&1 &
|
||||
build_pid=$!
|
||||
start_streams
|
||||
wait "$build_pid"
|
||||
exit_code=$?
|
||||
build_pid=""
|
||||
stop_streams
|
||||
|
||||
if ((exit_code == 124 || exit_code == 137)); then
|
||||
fail "build timed out after ${BUILD_TIMEOUT}"
|
||||
fi
|
||||
# The engine marks the step that broke with a cross. Its closing "Installation
|
||||
# failed in container" line is generic, so the cross before it is the useful one.
|
||||
failure_line() {
|
||||
local line
|
||||
line=$(strip_ansi <"$RAW_LOG" | grep -E '✖|\[ERROR\]|error:' | grep -v 'Installation failed in container' |
|
||||
tail -n 1 | sed -E 's/^\s*(✖️?|\s)*//' | cut -c1-200)
|
||||
# A prompt nobody can answer: say what to do instead.
|
||||
# First, because the report keeps 200 characters.
|
||||
if [[ "$line" == *"read exited with status"* || "$line" == *"executing command read "* ]]; then
|
||||
local asked
|
||||
asked=$(grep -oE '\bvar_[a-z0-9_]{1,40}\b' <<<"$line" | tail -n 1)
|
||||
line="unanswerable prompt (AGENTS.md rule 24): ask only when ${asked:-var_<x>} is unset, or use prompt_input_required. ${line}"
|
||||
fi
|
||||
echo "$line"
|
||||
}
|
||||
if ((exit_code != 0)); then
|
||||
last=$(failure_line)
|
||||
step="build exited ${exit_code}${last:+: $last}"
|
||||
status="fail"
|
||||
classify_cpu_failure
|
||||
fail "$step" "$status"
|
||||
fi
|
||||
# With dev_mode=keep (needed to read the install log) the engine carries on
|
||||
# after a failed install and still prints "Completed Successfully", exit 0.
|
||||
install_exit=$(strip_ansi <"$RAW_LOG" | grep -oE 'Installation failed in container [0-9]+ \(exit code: [0-9]+\)' |
|
||||
grep -oE '[0-9]+\)$' | tr -d ')' | tail -n 1)
|
||||
if [[ -n "$install_exit" ]]; then
|
||||
last=$(failure_line)
|
||||
step="install script exited ${install_exit}${last:+: $last}"
|
||||
status="fail"
|
||||
classify_cpu_failure
|
||||
fail "$step" "$status"
|
||||
fi
|
||||
grep -qi 'completed successfully' "$RAW_LOG" || fail "build exited 0 but never reported success"
|
||||
|
||||
# --- Checks ---------------------------------------------------------------------
|
||||
guest_running || fail "container ${ctid} is not running"
|
||||
|
||||
ct_ip=$(guest_exec sh -c "ip -4 -o addr show dev eth0 | awk '{print \$4}' | cut -d/ -f1" 2>/dev/null | head -n1)
|
||||
|
||||
# What the app's page looks like, for the PR comment. Chromium runs as its own
|
||||
# unprivileged user with its sandbox on: the page is served by code under test.
|
||||
# ci-shot-firewall (pve-ci-host-setup.sh) keeps that user off this host and the
|
||||
# lab network; without it there is no screenshot rather than an unguarded one.
|
||||
# $1: file name in OUT_DIR (screenshot.png after install, -update after update).
|
||||
take_screenshot() {
|
||||
command -v chromium >/dev/null && id ci-shot &>/dev/null || return 0
|
||||
iptables -w -S CI_SHOT 2>/dev/null | grep -q -- '-d 10.0.0.0/8 .*REJECT' || return 0
|
||||
local dir
|
||||
dir=$(mktemp -d)
|
||||
chown ci-shot "$dir"
|
||||
runuser -u ci-shot -- timeout 60 chromium --headless=new --disable-gpu --hide-scrollbars \
|
||||
--ignore-certificate-errors --no-first-run --window-size=1280,800 --virtual-time-budget=10000 \
|
||||
--user-data-dir="$dir/profile" --screenshot="$dir/shot.png" "$url" &>/dev/null
|
||||
# Only a plausible PNG leaves this function.
|
||||
if [[ -s "$dir/shot.png" && $(stat -c %s "$dir/shot.png") -lt 3000000 ]] &&
|
||||
[[ "$(head -c 8 "$dir/shot.png" | od -An -tx1 | tr -d ' \n')" == "89504e470d0a1a0a" ]]; then
|
||||
cp "$dir/shot.png" "$OUT_DIR/${1:-screenshot.png}"
|
||||
fi
|
||||
rm -rf -- "$dir"
|
||||
}
|
||||
|
||||
# Is the app up? No failed services, and the URL the script tells the user to
|
||||
# open, e.g. http://${IP}:1337/admin, answers 200 (after redirects, so a jump
|
||||
# to /login is fine). A script that prints no URL has no web UI to check.
|
||||
# Run after the install and again after the update. Sets health_problem.
|
||||
pattern=$(grep -oE 'https?://\$\{IP\}(:[0-9]+)?(/[A-Za-z0-9._~/?=&%-]*)?' "$tree/$SCRIPT" | head -n1)
|
||||
[[ -n "$pattern" && -n "$ct_ip" ]] && url="${pattern/\$\{IP\}/$ct_ip}"
|
||||
health_problem=""
|
||||
check_health() {
|
||||
health_problem=""
|
||||
failed_units=""
|
||||
if guest_exec sh -c 'command -v systemctl' &>/dev/null; then
|
||||
sleep 10
|
||||
failed_units=$(guest_exec systemctl --failed --no-legend --plain 2>/dev/null | awk '{print $1}' | paste -sd, -)
|
||||
elif guest_exec sh -c 'command -v rc-status' &>/dev/null; then
|
||||
failed_units=$(guest_exec rc-status --crashed 2>/dev/null | awk '{print $1}' | paste -sd, -)
|
||||
fi
|
||||
[[ -z "$failed_units" ]] || { health_problem="failed services: ${failed_units}"; return 1; }
|
||||
[[ -n "$url" ]] || return 0
|
||||
local waited
|
||||
for ((waited = 0; waited < URL_WAIT; waited += 5)); do
|
||||
http_code=$(curl -sk -L --max-redirs 5 -o /dev/null -w '%{http_code}' --max-time 10 "$url" || true)
|
||||
[[ "$http_code" =~ ^2[0-9][0-9]$ ]] && break
|
||||
sleep 5
|
||||
done
|
||||
# Also when the check fails: an error page explains more than a status code.
|
||||
[[ "$http_code" =~ ^[1-5][0-9][0-9]$ ]] && take_screenshot "$1"
|
||||
[[ "$http_code" =~ ^2[0-9][0-9]$ ]] ||
|
||||
{ health_problem="${url} answered ${http_code/#000/nothing} instead of 200 within ${URL_WAIT}s"; return 1; }
|
||||
}
|
||||
|
||||
check_health screenshot.png || fail "$health_problem"
|
||||
|
||||
# --- Update -------------------------------------------------------------------------
|
||||
# A fresh install is already on the latest release, so a plain `update` would
|
||||
# only say "No update available" and never run update_script's real path. The
|
||||
# version files the install wrote (~/.<app>, one per fetch_and_deploy_gh_release)
|
||||
# are set back to 0.0.0-ci first; check_for_gh_release then sees an update and
|
||||
# runs it all. `update` is the /usr/bin/update the install generated: it pulls
|
||||
# ct/<app>.sh from the URL this container was built from, i.e. the PR's tree.
|
||||
UPDATE_FAKE="0.0.0-ci"
|
||||
update_fail() {
|
||||
update_status="fail"
|
||||
update_step="$1"
|
||||
fail "update: $1"
|
||||
}
|
||||
|
||||
version_files=$(guest_exec sh -c '
|
||||
for f in /root/.*; do
|
||||
[ -f "$f" ] || continue
|
||||
case "$f" in */.install-* | *.pending | *.bak | */.bash* | */.profile | */.hushlogin | */.viminfo | */.lesshst | */.wget-hsts | */.selected_editor) continue ;; esac
|
||||
[ "$(wc -l <"$f")" -le 1 ] && grep -qE "^v?[0-9]+(\.[0-9A-Za-z_+-]+)+$" "$f" && echo "$f"
|
||||
done' 2>/dev/null | grep -E '^/root/\.[A-Za-z0-9._-]+$')
|
||||
for f in $version_files; do
|
||||
guest_exec sh -c "echo '$UPDATE_FAKE' > '$f'; rm -f '$f.pending'" ||
|
||||
update_fail "could not reset $f"
|
||||
done
|
||||
[[ -n "$version_files" ]] && echo "Update test: set back $(tr '\n' ' ' <<<"$version_files")to ${UPDATE_FAKE}"
|
||||
|
||||
: >"$UPDATE_RAW"
|
||||
update_token=$(head -c 16 /dev/urandom | od -An -tx1 | tr -d ' \n')
|
||||
echo "──────── update test (live) ────────"
|
||||
echo "::stop-commands::${update_token}"
|
||||
# timeout runs a program, not a shell function, so spell out the exec here.
|
||||
if [[ "$PLATFORM" == "incus" ]]; then guest_cmd=(incus exec "$ctid" --); else guest_cmd=(pct exec "$ctid" --); fi
|
||||
timeout --kill-after=1m "$UPDATE_TIMEOUT" "${guest_cmd[@]}" env PHS_SILENT=1 mode=default MODE=default \
|
||||
TERM=dumb DIAGNOSTICS=no var_ignore_breaking_changes=yes ${GH_API_TOKEN:+GITHUB_TOKEN="$GH_API_TOKEN"} update </dev/null \
|
||||
> >(tee "$UPDATE_RAW" | live_filter) 2>&1 &
|
||||
update_pid=$!
|
||||
wait "$update_pid"
|
||||
update_rc=$?
|
||||
update_pid=""
|
||||
sleep 1
|
||||
echo "::${update_token}::"
|
||||
|
||||
update_failure_line() {
|
||||
strip_ansi <"$UPDATE_RAW" | grep -E '✖|\[ERROR\]|error:' | tail -n 1 | sed -E 's/^\s*(✖️?|\s)*//' | cut -c1-180
|
||||
}
|
||||
if ((update_rc == 124 || update_rc == 137)); then
|
||||
update_fail "timed out after ${UPDATE_TIMEOUT}"
|
||||
elif ((update_rc != 0)); then
|
||||
last=$(update_failure_line)
|
||||
update_fail "exited ${update_rc}${last:+: $last}"
|
||||
fi
|
||||
# The engine can report an error and still exit 0 (bark-server: "github: rate
|
||||
# limit reached", then "Cleaned"). A rate limit is ours, not the PR's: the
|
||||
# update could not be tested. Any other error line is a failed update.
|
||||
update_error=$(strip_ansi <"$UPDATE_RAW" | grep -E '✖|\[ERROR\]' | tail -n 1 | sed -E 's/^\s*(✖️?|\s)*//' | cut -c1-180)
|
||||
if [[ -n "$update_error" ]]; then
|
||||
if [[ "${update_error,,}" == *"rate limit"* ]]; then
|
||||
update_status="error"
|
||||
update_step="could not be tested: ${update_error}"
|
||||
status="pass"
|
||||
finish
|
||||
exit 0
|
||||
fi
|
||||
update_fail "$update_error"
|
||||
fi
|
||||
if strip_ansi <"$UPDATE_RAW" | grep -qi 'updated successfully'; then
|
||||
update_status="updated"
|
||||
for f in $version_files; do
|
||||
[[ "$(guest_exec cat "$f" 2>/dev/null)" != "$UPDATE_FAKE" ]] ||
|
||||
update_fail "reported success, but $f is still ${UPDATE_FAKE}"
|
||||
done
|
||||
else
|
||||
# Ran cleanly without updating anything: no version file to fake (apt/npm
|
||||
# based), or an update_script that decides differently. Not a failure.
|
||||
update_status="no-update"
|
||||
update_step=$(strip_ansi <"$UPDATE_RAW" | grep -iE 'no update|up to date|up-to-date|already' | tail -n 1 | sed -E 's/^\s*(✔️?|ℹ️?|\s)*//' | cut -c1-160)
|
||||
fi
|
||||
|
||||
check_health screenshot-update.png || update_fail "after the update: $health_problem"
|
||||
|
||||
status="pass"
|
||||
finish
|
||||
Generated
+140
@@ -0,0 +1,140 @@
|
||||
# shellcheck shell=bash
|
||||
# The vm/ half of pve-run-script.sh, sourced from it: same inputs, same
|
||||
# result.json, same live output. Only building and checking differ.
|
||||
#
|
||||
# A VM script runs entirely on this host (there is no install script inside
|
||||
# the guest to follow), unattended through the core engine's VM_UNATTENDED=1:
|
||||
# the script's defaults, with ID, name, bridge and storage set from here. The
|
||||
# guest then boots on vmbr1 like the containers do, so its address is whatever
|
||||
# this host's dnsmasq leased to its MAC; no guest agent needed.
|
||||
#
|
||||
# What passes: the script exits 0 and says "Completed successfully", the VM is
|
||||
# running and keeps running through the boot wait, and where the script prints
|
||||
# a URL built from the VM's address (http://${VM_IP}:9000) that URL answers 200.
|
||||
# An installer ISO or an image without DHCP gets no address; that is reported,
|
||||
# not failed. There is no update test: a VM has no `update`.
|
||||
|
||||
VM_BOOT_WAIT="${VM_BOOT_WAIT:-300}"
|
||||
VM_URL_WAIT="${VM_URL_WAIT:-600}"
|
||||
LEASES="${LEASES:-/var/lib/misc/dnsmasq.leases}"
|
||||
|
||||
# One VM build per host at a time: a VM takes 2-8 GB of RAM and a disk image,
|
||||
# where a container takes a fraction. Held until the job exits; the build is
|
||||
# started with this descriptor closed, so a QEMU process can never inherit it.
|
||||
vm_wait_turn() {
|
||||
exec 7>/run/ci-jobs/.vm-turn
|
||||
flock -n 7 && return 0
|
||||
echo "Another VM build is running on this host; waiting for it (one VM at a time)"
|
||||
flock -w 3600 7 || setup_error "waited an hour for this host's VM slot"
|
||||
}
|
||||
|
||||
vm_cleanup() {
|
||||
if qm status "$ctid" &>/dev/null; then
|
||||
qm stop "$ctid" --skiplock 1 --timeout 30 &>/dev/null || true
|
||||
qm destroy "$ctid" --purge 1 --destroy-unreferenced-disks 1 --skiplock 1 &>/dev/null || true
|
||||
fi
|
||||
rm -f "/run/ci-jobs/$ctid" "/run/ci-jobs/console-${ctid}.png"
|
||||
# Cloud-Init network snippets the script wrote for this VM.
|
||||
[[ -f "$RAW_LOG.start" ]] &&
|
||||
find /var/lib/vz/snippets -maxdepth 1 -type f -name "*${ctid}*" -newer "$RAW_LOG.start" -delete 2>/dev/null
|
||||
true
|
||||
}
|
||||
|
||||
# The VM's screen, straight from QEMU. Pixels only, but they come from the
|
||||
# guest, so the same checks as for a browser screenshot apply.
|
||||
vm_console_shot() {
|
||||
local f="/run/ci-jobs/console-${ctid}.png"
|
||||
rm -f "$f"
|
||||
printf 'screendump %s -f png\n' "$f" | timeout 30 qm monitor "$ctid" &>/dev/null
|
||||
if [[ -s "$f" && $(stat -c %s "$f") -lt 3000000 ]] &&
|
||||
[[ "$(head -c 8 "$f" | od -An -tx1 | tr -d ' \n')" == "89504e470d0a1a0a" ]]; then
|
||||
cp "$f" "$OUT_DIR/screenshot.png"
|
||||
fi
|
||||
rm -f "$f"
|
||||
}
|
||||
|
||||
vm_running() {
|
||||
qm status "$ctid" 2>/dev/null | grep -q 'status: running'
|
||||
}
|
||||
|
||||
vm_build_and_check() {
|
||||
local hn mac pattern waited last
|
||||
hn="ci-$(tr -c 'a-z0-9-\n' '-' <<<"${app,,}" | cut -c1-40)-${ctid}"
|
||||
echo "Building ${SCRIPT} as VM ${ctid} from ${PR_REPO}@${PR_SHA:0:7}"
|
||||
local vm_env=(
|
||||
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
||||
HOME=/root TERM=dumb LANG=C.UTF-8 DIAGNOSTICS=no
|
||||
VM_UNATTENDED=1 VM_VMID="$ctid" VM_HOSTNAME="$hn" VM_BRIDGE="$BRIDGE"
|
||||
VM_STORAGE=local VM_ISO_STORAGE=local VM_START=yes
|
||||
COMMUNITY_SCRIPTS_URL="$base"
|
||||
)
|
||||
# Release lookups (vm_latest_from_github and the like).
|
||||
[[ -n "${GH_API_TOKEN:-}" ]] && vm_env+=(GITHUB_TOKEN="$GH_API_TOKEN")
|
||||
touch "$RAW_LOG.start"
|
||||
|
||||
env -i "${vm_env[@]}" timeout --kill-after=1m "$BUILD_TIMEOUT" \
|
||||
bash -c 'bash -c "$(curl -fsSL "$1")"' _ "$base/$SCRIPT" </dev/null >"$RAW_LOG" 2>&1 7>&- &
|
||||
build_pid=$!
|
||||
start_streams
|
||||
wait "$build_pid"
|
||||
exit_code=$?
|
||||
build_pid=""
|
||||
stop_streams
|
||||
|
||||
((exit_code == 124 || exit_code == 137)) && fail "build timed out after ${BUILD_TIMEOUT}"
|
||||
if ((exit_code != 0)); then
|
||||
last=$(failure_line)
|
||||
step="script exited ${exit_code}${last:+: $last}"
|
||||
status="fail"
|
||||
classify_cpu_failure
|
||||
fail "$step" "$status"
|
||||
fi
|
||||
# vm_finish ends on a msg_ok of the script's choosing ("VM created. ..."),
|
||||
# older scripts on "Completed successfully". exit_script and friends leave
|
||||
# with 0 too, but on a cross.
|
||||
last=$(strip_ansi <"$RAW_LOG" | tail -n 1 | cut -c1-200)
|
||||
if ! grep -qi 'completed successfully' "$RAW_LOG" && ! [[ "$last" =~ ^[[:space:]]*✔ ]]; then
|
||||
fail "script exited 0 but never reported success${last:+ (last line: ${last})}"
|
||||
fi
|
||||
vm_running || fail "VM ${ctid} is not running after the script finished"
|
||||
|
||||
# The address dnsmasq handed to this VM's MAC. The VM must stay up while it
|
||||
# boots: one that powers itself off or crashes is a failure.
|
||||
mac=$(qm config "$ctid" 2>/dev/null | sed -nE 's/^net0:[^=]*=([0-9A-Fa-f:]{17}).*/\1/p' | tr 'A-F' 'a-f')
|
||||
echo "Waiting up to ${VM_BOOT_WAIT}s for VM ${ctid} (${mac:-no NIC}) to boot and get an address"
|
||||
for ((waited = 0; waited < VM_BOOT_WAIT; waited += 5)); do
|
||||
vm_running || fail "VM ${ctid} stopped by itself while booting"
|
||||
[[ -n "$mac" ]] && ct_ip=$(awk -v m="$mac" '$2 == m {print $3}' "$LEASES" 2>/dev/null | tail -n 1)
|
||||
[[ -n "$ct_ip" ]] && break
|
||||
sleep 5
|
||||
done
|
||||
[[ -n "$ct_ip" ]] && echo "VM ${ctid} got ${ct_ip}" || echo "VM ${ctid} got no DHCP lease within ${VM_BOOT_WAIT}s"
|
||||
|
||||
# Only a URL the script builds from the address it found; <VM-IP> in a
|
||||
# summary is a hint for later (after an installer ran), not a promise.
|
||||
# ${VM_IP}, $VM_IP and ${VM_IP:-<VM-IP>} all count.
|
||||
local ip_re='\$\{?VM_IP(:-[^}]*)?\}?'
|
||||
pattern=$(grep -oE "https?://${ip_re}(:[0-9]+)?(/[A-Za-z0-9._~/?=&%-]*)?" "$tree/$SCRIPT" | head -n1)
|
||||
if [[ -n "$pattern" ]]; then
|
||||
[[ -n "$ct_ip" ]] || fail "no IP address within ${VM_BOOT_WAIT}s, so ${pattern} could not be checked"
|
||||
url=$(sed -E "s#${ip_re}#${ct_ip}#" <<<"$pattern")
|
||||
for ((waited = 0; waited < VM_URL_WAIT; waited += 10)); do
|
||||
vm_running || fail "VM ${ctid} stopped by itself while its web UI was awaited"
|
||||
http_code=$(curl -sk -L --max-redirs 5 -o /dev/null -w '%{http_code}' --max-time 10 "$url" || true)
|
||||
[[ "$http_code" =~ ^2[0-9][0-9]$ ]] && break
|
||||
sleep 10
|
||||
done
|
||||
[[ "$http_code" =~ ^[1-5][0-9][0-9]$ ]] && take_screenshot screenshot.png
|
||||
else
|
||||
# Let the console settle on a login prompt rather than a boot message.
|
||||
sleep 20
|
||||
fi
|
||||
[[ -s "$OUT_DIR/screenshot.png" ]] || vm_console_shot
|
||||
if [[ -n "$url" && ! "$http_code" =~ ^2[0-9][0-9]$ ]]; then
|
||||
fail "${url} answered ${http_code/#000/nothing} instead of 200 within ${VM_URL_WAIT}s"
|
||||
fi
|
||||
vm_running || fail "VM ${ctid} stopped by itself"
|
||||
|
||||
status="pass"
|
||||
finish
|
||||
}
|
||||
@@ -581,6 +581,44 @@ Exercise vigilance regarding copycat or coat-tailing sites that seek to exploit
|
||||
|
||||
</details>
|
||||
|
||||
## 2026-11-11
|
||||
|
||||
### 🚀 Updated Scripts
|
||||
|
||||
- #### ✨ New Features
|
||||
|
||||
- Immich: bump to 3.4.0 [@MickLesk](https://github.com/MickLesk) ([#18600](https://github.com/community-scripts/ProxmoxVE/pull/18600))
|
||||
|
||||
- #### 💥 Breaking Changes
|
||||
|
||||
- Pocketbase: install and update from the new host (removed in github) [@MickLesk](https://github.com/MickLesk) ([#18609](https://github.com/community-scripts/ProxmoxVE/pull/18609))
|
||||
|
||||
### 💾 Core
|
||||
|
||||
- core: drop the SUDO_ variables an update through sudo carries [@MickLesk](https://github.com/MickLesk) ([core#229](https://github.com/community-scripts/core/pull/229))
|
||||
- feat: let setup_deb822_repo use a keyring already on the system [@MickLesk](https://github.com/MickLesk) ([core#227](https://github.com/community-scripts/core/pull/227))
|
||||
|
||||
## 2026-11-10
|
||||
|
||||
### 🚀 Updated Scripts
|
||||
|
||||
- #### 🐞 Bug Fixes
|
||||
|
||||
- writefreely: Force symbolic link update [@michelroegl-brunner](https://github.com/michelroegl-brunner) ([#18588](https://github.com/community-scripts/ProxmoxVE/pull/18588))
|
||||
- openproject: start memcached when IPv6 is disabled [@MickLesk](https://github.com/MickLesk) ([#18584](https://github.com/community-scripts/ProxmoxVE/pull/18584))
|
||||
- homeassistant.sh: Update pip install command for runlike [@Zajsoft](https://github.com/Zajsoft) ([#18583](https://github.com/community-scripts/ProxmoxVE/pull/18583))
|
||||
- Tunarr: fix install and update for the new release layout and latest ErsatzTV-ffmpeg [@chrisbenincasa](https://github.com/chrisbenincasa) ([#18577](https://github.com/community-scripts/ProxmoxVE/pull/18577))
|
||||
|
||||
### 💾 Core
|
||||
|
||||
- fix: keep a script's config file when a package installs it [@MickLesk](https://github.com/MickLesk) ([core#226](https://github.com/community-scripts/core/pull/226))
|
||||
- fix: flush the image before importing a VM disk [@michelroegl-brunner](https://github.com/michelroegl-brunner) ([core#223](https://github.com/community-scripts/core/pull/223))
|
||||
|
||||
### 📂 Github
|
||||
|
||||
- pr-script-test: pre-answer prompt variables built in a loop [@michelroegl-brunner](https://github.com/michelroegl-brunner) ([#18596](https://github.com/community-scripts/ProxmoxVE/pull/18596))
|
||||
- Build PR scripts on a Proxmox test host via /test [@michelroegl-brunner](https://github.com/michelroegl-brunner) ([#18594](https://github.com/community-scripts/ProxmoxVE/pull/18594))
|
||||
|
||||
## 2026-11-09
|
||||
|
||||
### 🚀 Updated Scripts
|
||||
|
||||
+1
-1
@@ -44,7 +44,7 @@ function update_script() {
|
||||
docker pull "${CONTAINER_IMAGE}"
|
||||
LATEST_IMAGE="$(docker inspect --format "{{.Id}}" --type image "${CONTAINER_IMAGE}")"
|
||||
if [[ "${RUNNING_IMAGE}" != "${LATEST_IMAGE}" ]]; then
|
||||
pip install -U runlike
|
||||
pip install -U runlike --break-system-packages
|
||||
echo "Updating ${container} image ${CONTAINER_IMAGE}"
|
||||
DOCKER_COMMAND="$(runlike --use-volume-id "${container}")"
|
||||
docker rm --force "${container}"
|
||||
|
||||
+26
-4
@@ -75,7 +75,7 @@ EOF
|
||||
BASE_DIR=${STAGING_DIR}/base-images
|
||||
SOURCE_DIR=${STAGING_DIR}/image-source
|
||||
cd /tmp
|
||||
RELEASE="v3.3.6"
|
||||
RELEASE="v3.4.0"
|
||||
if [[ -f ~/.intel_version ]]; then
|
||||
INTEL_SRC="https://raw.githubusercontent.com/immich-app/immich/${RELEASE}/machine-learning"
|
||||
curl -fsSL "${INTEL_SRC}/scripts/install-intel-runtime.sh" -o ./intel-runtime 2>/dev/null ||
|
||||
@@ -109,7 +109,7 @@ EOF
|
||||
libraries=("libjxl" "jpegli" "libheif" "libraw" "imagemagick" "libvips")
|
||||
cd "$BASE_DIR"
|
||||
msg_warn "Checking for updates to custom image-processing libraries (recompile time: 2-15min per library)"
|
||||
ensure_dependencies liblcms2-dev libjpeg62-turbo-dev libspng-dev libexif-dev libopenjp2-7-dev
|
||||
ensure_dependencies liblcms2-dev libjpeg62-turbo-dev libspng-dev libexif-dev libopenjp2-7-dev pigz
|
||||
$STD git pull
|
||||
for library in "${libraries[@]}"; do
|
||||
compile_"$library"
|
||||
@@ -234,7 +234,7 @@ EOF
|
||||
# plugins
|
||||
cd "$SRC_DIR"
|
||||
export MISE_TRUSTED_CONFIG_PATHS="$SRC_DIR"/mise.toml
|
||||
export MISE_DISABLE_TOOLS=github:jellyfin/jellyfin-ffmpeg,java,terragrunt,opentofu,npm:@openapitools/openapi-generator-cli
|
||||
export MISE_DISABLE_TOOLS=github:jellyfin/jellyfin-ffmpeg,java,terragrunt,opentofu,restic,npm:@openapitools/openapi-generator-cli
|
||||
export MISE_LOCKFILE_MODE=merge MISE_LOCKFILE_PLATFORMS="linux-$(arch_resolve x64 arm64)"
|
||||
mise_ok=0
|
||||
for i in 1 2 3; do
|
||||
@@ -251,7 +251,8 @@ EOF
|
||||
# extism-js ships as a bare gzip-compressed single binary (.gz) that
|
||||
# fetch_and_deploy_gh_release cannot deploy; fetch + gunzip it directly.
|
||||
EXTISM_ARCH="$(arch_resolve x86_64 aarch64)"
|
||||
curl_download /tmp/extism-js.gz "https://github.com/extism/js-pdk/releases/download/v1.6.0/extism-js-${EXTISM_ARCH}-linux-v1.6.0.gz"
|
||||
EXTISM_VERSION="$(grep -oP '"github:extism/js-pdk"\s*=\s*"\K[^"]+' "$SRC_DIR"/mise.toml)"
|
||||
curl_download /tmp/extism-js.gz "https://github.com/extism/js-pdk/releases/download/${EXTISM_VERSION}/extism-js-${EXTISM_ARCH}-linux-${EXTISM_VERSION}.gz"
|
||||
gunzip -f /tmp/extism-js.gz
|
||||
install -m 0755 /tmp/extism-js /usr/local/bin/extism-js
|
||||
rm -f /tmp/extism-js
|
||||
@@ -276,6 +277,27 @@ EOF
|
||||
cp ./packages/plugin-core/manifest.json "$PLUGIN_DIR"
|
||||
msg_ok "Updated Immich server, web, cli and plugins"
|
||||
|
||||
RESTIC_REF="$(grep -oP 'futo-org/restic:sha-\K[0-9a-f]+' "$SRC_DIR"/server/Dockerfile)"
|
||||
RESTIC_PROXY_REF="$(grep -oP 'futo-org/backups/restic-proxy:\K[^@]+' "$SRC_DIR"/server/Dockerfile)"
|
||||
if [[ "$(cat ~/.immich_restic 2>/dev/null)" != "${RESTIC_REF} ${RESTIC_PROXY_REF}" || ! -x /usr/local/bin/restic ]]; then
|
||||
FUTO_DIR=/opt/staging/futo
|
||||
rm -rf "$FUTO_DIR"
|
||||
$STD git clone --filter=blob:none --no-checkout https://github.com/futo-org/restic "$FUTO_DIR"/restic
|
||||
$STD git -C "$FUTO_DIR"/restic checkout "$RESTIC_REF"
|
||||
$STD git clone --depth 1 --filter=blob:none --sparse -b "$RESTIC_PROXY_REF" https://github.com/futo-org/backups "$FUTO_DIR"/backups
|
||||
$STD git -C "$FUTO_DIR"/backups sparse-checkout set packages/restic-proxy
|
||||
GO_VERSION="$(awk '/^go /{print $2}' "$FUTO_DIR"/backups/packages/restic-proxy/go.mod)" setup_go
|
||||
msg_info "Building restic for FUTO Backups"
|
||||
cd "$FUTO_DIR"/restic
|
||||
$STD env CGO_ENABLED=0 go build -o /usr/local/bin/restic ./cmd/restic
|
||||
cd "$FUTO_DIR"/backups/packages/restic-proxy
|
||||
$STD env CGO_ENABLED=0 go build -o /usr/local/bin/restic-proxy .
|
||||
cd "$SRC_DIR"
|
||||
rm -rf "$FUTO_DIR"
|
||||
echo "${RESTIC_REF} ${RESTIC_PROXY_REF}" >~/.immich_restic
|
||||
msg_ok "Built restic for FUTO Backups"
|
||||
fi
|
||||
|
||||
cd "$SRC_DIR"/machine-learning
|
||||
mkdir -p "$ML_DIR"
|
||||
# chown excluding upload dir contents (may be a mount with restricted permissions)
|
||||
|
||||
+7
-6
@@ -4,7 +4,7 @@ source "$_cs_boot" 2>/dev/null || source <(curl -fsSL "${COMMUNITY_SCRIPTS_CORE_
|
||||
# Copyright (c) 2021-2026 tteck
|
||||
# Author: tteck (tteckster)
|
||||
# License: MIT | https://github.com/community-scripts/ProxmoxVE/raw/main/LICENSE
|
||||
# Source: https://pocketbase.io/ | Github: https://github.com/pocketbase/pocketbase
|
||||
# Source: https://pocketbase.io/ | Repo: https://ganigeorgiev.com/pocketbase/pocketbase
|
||||
|
||||
APP="Pocketbase"
|
||||
var_tags="${var_tags:-database}"
|
||||
@@ -29,20 +29,21 @@ function update_script() {
|
||||
msg_error "No ${APP} Installation Found!"
|
||||
exit
|
||||
fi
|
||||
if check_for_gh_release "pocketbase" "pocketbase/pocketbase"; then
|
||||
RELEASE=$(curl_with_retry "https://ganigeorgiev.com/api/collections/releases/records?filter=channel.repository.name%3D%22pocketbase%22%20%26%26%20channel.name%3D%22default%22&sort=-semver&skipTotal=1&perPage=1" "-" | jq -r '.items[0].tag' | sed 's/^v//')
|
||||
if [[ -n "$RELEASE" && "$RELEASE" != "$(cat ~/.pocketbase 2>/dev/null)" ]]; then
|
||||
msg_info "Stopping Service"
|
||||
systemctl stop pocketbase
|
||||
msg_ok "Stopped Service"
|
||||
|
||||
msg_info "Updating ${APP}"
|
||||
/opt/pocketbase/pocketbase update
|
||||
echo "${CHECK_UPDATE_RELEASE}" >~/.pocketbase
|
||||
msg_ok "Updated ${APP}"
|
||||
fetch_and_deploy_from_url "https://ganigeorgiev.com/meta/pocketbase/pocketbase/download/v${RELEASE}/pocketbase_${RELEASE}_linux_$(arch_resolve).zip" "/opt/pocketbase"
|
||||
echo "${RELEASE}" >~/.pocketbase
|
||||
|
||||
msg_info "Starting Service"
|
||||
systemctl start pocketbase
|
||||
msg_ok "Started Service"
|
||||
msg_ok "Updated successfully!"
|
||||
else
|
||||
msg_ok "No update required. ${APP} is already at v${RELEASE}"
|
||||
fi
|
||||
exit
|
||||
}
|
||||
|
||||
+3
-6
@@ -37,8 +37,6 @@ function update_script() {
|
||||
create_backup /root/.local/share/tunarr
|
||||
|
||||
CLEAN_INSTALL=1 fetch_and_deploy_gh_release "tunarr" "chrisbenincasa/tunarr" "prebuild" "latest" "/opt/tunarr" "*linux-$(arch_resolve "x64" "arm64").tar.gz"
|
||||
cd /opt/tunarr
|
||||
mv tunarr* tunarr
|
||||
restore_backup
|
||||
|
||||
msg_info "Starting Service"
|
||||
@@ -52,13 +50,12 @@ function update_script() {
|
||||
systemctl stop tunarr
|
||||
msg_ok "Stopped Service"
|
||||
|
||||
CLEAN_INSTALL=1 fetch_and_deploy_gh_release "ersatztv-ffmpeg" "ErsatzTV/ErsatzTV-ffmpeg" "prebuild" "latest" "/opt/ErsatzTV-ffmpeg" "*-linux$(arch_resolve "64" "arm64")-gpl-7.1.tar.xz"
|
||||
CLEAN_INSTALL=1 fetch_and_deploy_gh_release "ersatztv-ffmpeg" "ErsatzTV/ErsatzTV-ffmpeg" "prebuild" "latest" "/opt/ErsatzTV-ffmpeg" "*-linux$(arch_resolve "64" "arm64")-gpl-*.tar.xz"
|
||||
|
||||
msg_info "Set ErsatzTV-ffmpeg links"
|
||||
chmod +x /opt/ErsatzTV-ffmpeg/bin/*
|
||||
ln -sf /opt/ErsatzTV-ffmpeg/bin/ffmpeg /usr/local/bin/ffmpeg
|
||||
ln -sf /opt/ErsatzTV-ffmpeg/bin/ffplay /usr/local/bin/ffplay
|
||||
ln -sf /opt/ErsatzTV-ffmpeg/bin/ffprobe /usr/local/bin/ffprobe
|
||||
ln -sf /opt/ErsatzTV-ffmpeg/bin/ffmpeg /usr/bin/ffmpeg
|
||||
ln -sf /opt/ErsatzTV-ffmpeg/bin/ffprobe /usr/bin/ffprobe
|
||||
msg_ok "ffmpeg links set"
|
||||
|
||||
msg_info "Starting Service"
|
||||
|
||||
+1
-1
@@ -45,7 +45,7 @@ function update_script() {
|
||||
msg_info "Running Post-Update Tasks"
|
||||
cd /opt/writefreely
|
||||
$STD ./writefreely db migrate
|
||||
ln -s /opt/writefreely/writefreely /usr/local/bin/writefreely
|
||||
ln -sf /opt/writefreely/writefreely /usr/local/bin/writefreely
|
||||
msg_ok "Ran Post-Update Tasks"
|
||||
|
||||
msg_info "Starting Services"
|
||||
|
||||
@@ -20,6 +20,9 @@ $STD apt install -y \
|
||||
git \
|
||||
memcached \
|
||||
libmemcached-tools
|
||||
# Debian's memcached also listens on ::1 and does not start when IPv6 is disabled
|
||||
sed -i '/^-l ::1$/d' /etc/memcached.conf
|
||||
systemctl restart memcached
|
||||
msg_ok "Installed Dependencies"
|
||||
|
||||
PYTHON_VERSION="3.13" setup_uv
|
||||
|
||||
@@ -23,7 +23,7 @@ if [[ "$(arch_resolve)" == "amd64" ]] && ! grep -qm1 sse4_2 /proc/cpuinfo; then
|
||||
exit 236
|
||||
fi
|
||||
|
||||
RELEASE="v3.3.6"
|
||||
RELEASE="v3.4.0"
|
||||
if lscpu | grep -q 'GenuineIntel'; then
|
||||
echo ""
|
||||
echo ""
|
||||
@@ -68,6 +68,7 @@ msg_info "Installing dependencies"
|
||||
$STD apt install --no-install-recommends -y \
|
||||
git \
|
||||
redis \
|
||||
pigz \
|
||||
autoconf \
|
||||
build-essential \
|
||||
python3-dev \
|
||||
@@ -415,7 +416,7 @@ cp -a web/build "$APP_DIR"/www
|
||||
cp LICENSE "$APP_DIR"
|
||||
cd "$SRC_DIR"
|
||||
export MISE_TRUSTED_CONFIG_PATHS="$SRC_DIR"/mise.toml
|
||||
export MISE_DISABLE_TOOLS=github:jellyfin/jellyfin-ffmpeg,java,terragrunt,opentofu,npm:@openapitools/openapi-generator-cli
|
||||
export MISE_DISABLE_TOOLS=github:jellyfin/jellyfin-ffmpeg,java,terragrunt,opentofu,restic,npm:@openapitools/openapi-generator-cli
|
||||
export MISE_LOCKFILE_MODE=merge MISE_LOCKFILE_PLATFORMS="linux-$(arch_resolve x64 arm64)"
|
||||
mise_ok=0
|
||||
for i in 1 2 3; do
|
||||
@@ -433,7 +434,8 @@ if ! command -v extism-js >/dev/null 2>&1; then
|
||||
# fetch_and_deploy_gh_release cannot deploy (singlefile leaves it compressed,
|
||||
# prebuild only handles zip/tar). Fetch + gunzip it directly.
|
||||
EXTISM_ARCH="$(arch_resolve x86_64 aarch64)"
|
||||
curl_download /tmp/extism-js.gz "https://github.com/extism/js-pdk/releases/download/v1.6.0/extism-js-${EXTISM_ARCH}-linux-v1.6.0.gz"
|
||||
EXTISM_VERSION="$(grep -oP '"github:extism/js-pdk"\s*=\s*"\K[^"]+' "$SRC_DIR"/mise.toml)"
|
||||
curl_download /tmp/extism-js.gz "https://github.com/extism/js-pdk/releases/download/${EXTISM_VERSION}/extism-js-${EXTISM_ARCH}-linux-${EXTISM_VERSION}.gz"
|
||||
gunzip -f /tmp/extism-js.gz
|
||||
install -m 0755 /tmp/extism-js /usr/local/bin/extism-js
|
||||
rm -f /tmp/extism-js
|
||||
@@ -445,6 +447,25 @@ cp -r ./packages/plugin-core/dist "$PLUGIN_DIR"/dist
|
||||
cp ./packages/plugin-core/manifest.json "$PLUGIN_DIR"
|
||||
msg_ok "Installed Immich Server, Web and Plugin Components"
|
||||
|
||||
RESTIC_REF="$(grep -oP 'futo-org/restic:sha-\K[0-9a-f]+' "$SRC_DIR"/server/Dockerfile)"
|
||||
RESTIC_PROXY_REF="$(grep -oP 'futo-org/backups/restic-proxy:\K[^@]+' "$SRC_DIR"/server/Dockerfile)"
|
||||
FUTO_DIR=/opt/staging/futo
|
||||
rm -rf "$FUTO_DIR"
|
||||
$STD git clone --filter=blob:none --no-checkout https://github.com/futo-org/restic "$FUTO_DIR"/restic
|
||||
$STD git -C "$FUTO_DIR"/restic checkout "$RESTIC_REF"
|
||||
$STD git clone --depth 1 --filter=blob:none --sparse -b "$RESTIC_PROXY_REF" https://github.com/futo-org/backups "$FUTO_DIR"/backups
|
||||
$STD git -C "$FUTO_DIR"/backups sparse-checkout set packages/restic-proxy
|
||||
GO_VERSION="$(awk '/^go /{print $2}' "$FUTO_DIR"/backups/packages/restic-proxy/go.mod)" setup_go
|
||||
msg_info "Building restic for FUTO Backups"
|
||||
cd "$FUTO_DIR"/restic
|
||||
$STD env CGO_ENABLED=0 go build -o /usr/local/bin/restic ./cmd/restic
|
||||
cd "$FUTO_DIR"/backups/packages/restic-proxy
|
||||
$STD env CGO_ENABLED=0 go build -o /usr/local/bin/restic-proxy .
|
||||
cd "$SRC_DIR"
|
||||
rm -rf "$FUTO_DIR"
|
||||
echo "${RESTIC_REF} ${RESTIC_PROXY_REF}" >~/.immich_restic
|
||||
msg_ok "Built restic for FUTO Backups"
|
||||
|
||||
cd "$SRC_DIR"/machine-learning
|
||||
$STD useradd -U -s /usr/sbin/nologin -r -M -d "$INSTALL_DIR" immich
|
||||
mkdir -p "$ML_DIR"
|
||||
|
||||
@@ -70,6 +70,9 @@ memcached/autoinstall install
|
||||
openproject/admin_email admin@example.net
|
||||
openproject/default_language en
|
||||
EOF
|
||||
# Debian's memcached also listens on ::1 and does not start when IPv6 is disabled
|
||||
$STD apt install -y memcached
|
||||
sed -i '/^-l ::1$/d' /etc/memcached.conf
|
||||
$STD sudo openproject configure
|
||||
systemctl stop openproject-web-1
|
||||
if ! grep -qF 'Environment=LD_PRELOAD=/usr/local/lib/libjemalloc.so.2' /etc/systemd/system/openproject-web-1.service; then
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# Copyright (c) 2021-2026 tteck
|
||||
# Author: tteck (tteckster)
|
||||
# License: MIT | https://github.com/community-scripts/ProxmoxVE/raw/main/LICENSE
|
||||
# Source: https://pocketbase.io/ | Github: https://github.com/pocketbase/pocketbase
|
||||
# Source: https://pocketbase.io/ | Repo: https://ganigeorgiev.com/pocketbase/pocketbase
|
||||
|
||||
source /dev/stdin <<<"$FUNCTIONS_FILE_PATH"
|
||||
color
|
||||
@@ -13,7 +13,9 @@ setting_up_container
|
||||
network_check
|
||||
update_os
|
||||
|
||||
fetch_and_deploy_gh_release "pocketbase" "pocketbase/pocketbase" "prebuild" "latest" "/opt/pocketbase" "pocketbase*linux_$(arch_resolve).zip"
|
||||
RELEASE=$(curl_with_retry "https://ganigeorgiev.com/api/collections/releases/records?filter=channel.repository.name%3D%22pocketbase%22%20%26%26%20channel.name%3D%22default%22&sort=-semver&skipTotal=1&perPage=1" "-" | jq -r '.items[0].tag' | sed 's/^v//')
|
||||
fetch_and_deploy_from_url "https://ganigeorgiev.com/meta/pocketbase/pocketbase/download/v${RELEASE}/pocketbase_${RELEASE}_linux_$(arch_resolve).zip" "/opt/pocketbase"
|
||||
echo "${RELEASE}" >~/.pocketbase
|
||||
|
||||
msg_info "Configuring Pocketbase"
|
||||
mkdir -p /opt/pocketbase/{pb_public,pb_migrations,pb_hooks}
|
||||
|
||||
@@ -16,14 +16,11 @@ update_os
|
||||
setup_hwaccel
|
||||
|
||||
fetch_and_deploy_gh_release "tunarr" "chrisbenincasa/tunarr" "prebuild" "latest" "/opt/tunarr" "*linux-$(arch_resolve "x64" "arm64").tar.gz"
|
||||
cd /opt/tunarr
|
||||
mv tunarr* tunarr
|
||||
fetch_and_deploy_gh_release "ersatztv-ffmpeg" "ErsatzTV/ErsatzTV-ffmpeg" "prebuild" "latest" "/opt/ErsatzTV-ffmpeg" "*-linux$(arch_resolve "64" "arm64")-gpl-7.1.tar.xz"
|
||||
fetch_and_deploy_gh_release "ersatztv-ffmpeg" "ErsatzTV/ErsatzTV-ffmpeg" "prebuild" "latest" "/opt/ErsatzTV-ffmpeg" "*-linux$(arch_resolve "64" "arm64")-gpl-*.tar.xz"
|
||||
|
||||
msg_info "Set ErsatzTV-ffmpeg links"
|
||||
chmod +x /opt/ErsatzTV-ffmpeg/bin/*
|
||||
ln -sf /opt/ErsatzTV-ffmpeg/bin/ffmpeg /usr/bin/ffmpeg
|
||||
ln -sf /opt/ErsatzTV-ffmpeg/bin/ffplay /usr/bin/ffplay
|
||||
ln -sf /opt/ErsatzTV-ffmpeg/bin/ffprobe /usr/bin/ffprobe
|
||||
msg_ok "ffmpeg links set"
|
||||
|
||||
|
||||
Reference in new issue
Block a user