Compare commits

..
Author SHA1 Message Date
Rögl-Brunner Michel 87d23aafec pr-script-test: pre-answer prompt variables built in a loop
OpenCloud reads var_opencloud_${server}_fqdn for each server in
servers=("opencloud" "collabora" "wopi"), so the scan for literal
var_ names found nothing and the build stopped at the first prompt
(#18586). Templates of the form var_...${x}... are now expanded with
the words of `for x in ...`, or of the array that loop walks; nothing
is executed. Hostname-like answers are derived from the variable name
(ci-opencloud-collabora.example.test), so several cannot collide.
2026-11-10 16:16:59 +01:00
community-scripts-pr-app[bot]andgithub-actions[bot] bd7f6f60bd Update CHANGELOG.md (#18595)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-11-10 15:05:16 +00:00
Michel Roegl-Brunner db30bcd960 Build PR scripts on a Proxmox test host via /test (#18594)
A maintainer comments `/test` on a pull request and a self-hosted
runner on a disposable Proxmox VE host builds every ct/ script the PR
touches (and the ct/ entry point of every changed install/ script),
checks the result and reports back on the PR.

- Gate: only users with write access; builds the latest commit only if
  nothing was pushed to the fork after the comment, or a pinned
  `/test <full sha>`.
- Build: the PR tree at that commit is served from the test host with
  every raw.githubusercontent.com/community-scripts/ProxmoxVE/main URL
  rewritten to it, so engine changes in the PR are tested too.
  Unattended (mode=default); prompts declared the AGENTS.md way are
  pre-answered with CI values.
- Checks: build and install succeed, container running, no failed
  units, the URL the script prints answers 200. Screenshot of that URL.
- Report: check run, one comment (results, screenshot, install log,
  mentions of the PR author and whoever asked), changes requested on a
  real failure, dismissed again on a pass. CPU-feature failures (no
  AVX2 on the test host) are marked untestable, not failed.
- The test job has a read-only token and no secrets; its runner carries
  only the proxmox-test label. Container IDs 9050-9099.

Same file as in DevScripts; github.repository selects the URLs and the
container ID range.
2026-11-10 16:04:32 +01:00
community-scripts-pr-app[bot]andgithub-actions[bot] cba9a593cf Update CHANGELOG.md (#18591)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-11-10 09:40:40 +00:00
community-scripts-pr-app[bot]andgithub-actions[bot] 9fa4751635 Update CHANGELOG.md (#18590)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-11-10 09:35:05 +00:00
Michel Roegl-Brunner aba63a4e3b Force symbolic link update for writefreely (#18588) 2026-11-10 10:34:34 +01:00
community-scripts-pr-app[bot]andgithub-actions[bot] 7b985d5ed8 Update CHANGELOG.md (#18589)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-11-10 09:28:16 +00:00
CanbiZ (MickLesk) aa219cff0b fix(openproject): start memcached when IPv6 is disabled (#18584)
Debian 13's memcached also listens on ::1 and cannot start in a container with IPv6 disabled, so openproject configure failed (#18579). adventurelog had the same memcached silently down. Both drop the ::1 listener.
2026-11-10 10:27:48 +01:00
community-scripts-pr-app[bot]andgithub-actions[bot] d367da8fcb Update CHANGELOG.md (#18585)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-11-10 09:08:55 +00:00
Zajsoft 2880722c71 Update pip install command for runlike (#18583) 2026-11-10 10:08:29 +01:00
community-scripts-pr-app[bot]andgithub-actions[bot] 641cc9ab1d Update CHANGELOG.md (#18582)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-11-10 08:08:26 +00:00
Christian BenincasaandClaude Opus 5.5 17cf76f6ee Tunarr: fix install and update for the new release layout and latest ErsatzTV-ffmpeg (#18577)
* Tunarr: drop binary rename for the new release folder layout

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Tunarr: track the latest ErsatzTV-ffmpeg and link ffmpeg into /usr/bin

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 09:07:56 +01:00
community-scripts-pr-app[bot]andgithub-actions[bot] c120fb6f9f Update CHANGELOG.md (#18575)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-11-09 16:27:30 +00:00
CanbiZ (MickLesk) bf3bd50926 security: remove hardcoded credentials in several scripts (#18572) 2026-11-09 17:27:03 +01:00
community-scripts-pr-app[bot]andgithub-actions[bot] cfeb555f8b Update CHANGELOG.md (#18571)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-11-09 13:41:43 +00:00
community-scripts-pr-app[bot]andgithub-actions[bot] af8f922610 Update CHANGELOG.md (#18570)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-11-09 13:19:39 +00:00
github-actions[bot] 08854f9b1b chore(ct): sync zipline defaults from PocketBase (#18569)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-11-09 14:19:13 +01:00
community-scripts-pr-app[bot]andgithub-actions[bot] 293a0a85c1 Update CHANGELOG.md (#18567)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-11-09 12:27:09 +00:00
bfba6d1f30 OpenObserve: record installed version, drop killall ExecStop (#18566)
update_script never wrote ~/.openobserve, so check_for_gh_release reported
an update on every run and reinstalled the release. Write the version after
extraction, as other ct scripts do, and overwrite instead of append at install.

The unit ExecStop=killall failed with 203/EXEC because psmisc is never
installed. OpenObserve treats SIGQUIT and SIGTERM alike, so drop ExecStop
and remove it from existing units during update.

Co-authored-by: BadFlo <7882184+BadFlo@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 13:26:43 +01:00
community-scripts-pr-app[bot]andgithub-actions[bot] 2166dfc210 Update CHANGELOG.md (#18565)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-11-09 12:08:44 +00:00
7a0faa4f94 OpenObserve: extract release tarball with --no-same-owner (#18564)
The release tarball stores the binary as uid/gid 1001. Extracted as root,
GNU tar keeps that owner, so /opt/openobserve/openobserve ended up owned by
a non-existent uid after install and every update.

Co-authored-by: BadFlo <7882184+BadFlo@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 13:08:19 +01:00
community-scripts-pr-app[bot]andgithub-actions[bot] f4c8bff028 Update CHANGELOG.md (#18563)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-11-09 11:40:50 +00:00
Michel Roegl-Brunner 81f14b0a02 Delete ct/coolify.sh (#18562) 2026-11-09 12:40:20 +01:00
community-scripts-pr-app[bot]andgithub-actions[bot] 19b6cbc34e Update CHANGELOG.md (#18560)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-11-09 09:17:10 +00:00
Ulaş Kalaycı eb2f583793 yuvomi: install from the attested release asset (#18554) 2026-11-09 10:16:37 +01:00
community-scripts-pr-app[bot]andgithub-actions[bot] 8d50af5c69 Update CHANGELOG.md (#18559)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-11-09 08:59:45 +00:00
github-actions[bot]andCanbiZ 2b63e9da14 cinephage: bump Node.js from 24 to 26 (#18374)
* cinephage: bump Node.js from 24 to 26

* Fix command to fetch Camoufox in install script

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: CanbiZ (MickLesk) <47820557+MickLesk@users.noreply.github.com>
2026-11-09 09:59:18 +01:00
community-scripts-pr-app[bot]andgithub-actions[bot] 0283df4573 Update CHANGELOG.md (#18558)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-11-09 08:46:47 +00:00
community-scripts-pr-app[bot]andgithub-actions[bot] d8087c2c0e Update CHANGELOG.md (#18557)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-11-09 08:42:33 +00:00
Tim MooreandClaude Opus 5.5 90d80949c1 Immich: download before stopping, and clear maintenance mode after an interrupted update (#18553)
* fix(immich): download before stopping, and clear maintenance mode after an interrupted update

setup_uv and the Immich release fetch ran after maintenance mode, the service stop and rm -rf app/*, so a failed download (e.g. a GitHub releases/download 504) left the container with no app at all. They now run first, while the old install is still serving.

The fetch writes the new version to ~/.immich, so the old version is put back until the update finishes and a failed run can still be retried.

Maintenance mode is now disabled whenever immich-admin exists at the end, not only when this run enabled it. Since #17798 a re-run after an interrupted update skips the enable, so it skipped the disable too and left the web UI on the maintenance page.

* fix(immich): trim comments to the AGENTS.md limit

* fix(immich): rely on the core pending marker, and only clear maintenance mode this run owns

fetch_and_deploy_release already keeps ~/.immich.pending until a clean exit and redoes the update while it exists, so the hand-rolled version swap is dropped; PREV_IMMICH now reads CS_PREVIOUS_VERSION so a redo sees the real old version.

Maintenance mode is disabled when this run enabled it, or when app/ was empty at the start (an interrupted run left it on), rather than whenever immich-admin exists.

* immich: drop comment per review

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 09:42:03 +01:00
CanbiZ (MickLesk) 2f0f6daee8 Update Node.js version to 26 in cinephage.sh 2026-11-09 09:40:29 +01:00
community-scripts-pr-app[bot]andgithub-actions[bot] cdae3b974f Update CHANGELOG.md (#18556)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-11-09 08:39:39 +00:00
mazeladoandMatt Daleo 50d1c199f5 IP-Tag: filter guest-agent IPs by CIDR before limiting to 3 (#18537)
Co-authored-by: Matt Daleo <12395090+mazelado@users.noreply.github.com>
2026-11-09 09:39:13 +01:00
github-actions[bot] f4e0f5508f cross-seed: bump Node.js from 24 to 26 (#17083)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-11-09 09:34:16 +01:00
community-scripts-pr-app[bot]andgithub-actions[bot] 492575699c Update CHANGELOG.md (#18555)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-11-09 08:16:26 +00:00
github-actions[bot]andCanbiZ 623d26a39d kan: bump Node.js from 20 to 24 (#18549)
* kan: bump Node.js from 20 to 24

* Set NODE_VERSION and NODE_MODULE before setup

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: CanbiZ (MickLesk) <47820557+MickLesk@users.noreply.github.com>
2026-11-09 09:16:02 +01:00
github-actions[bot]andCanbiZ 191ab7e861 myip: bump Node.js from 24 to 26 (#18550)
* myip: bump Node.js from 24 to 26

* Increase default RAM allocation from 1024MB to 2048MB

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: CanbiZ (MickLesk) <47820557+MickLesk@users.noreply.github.com>
2026-11-09 09:15:51 +01:00
53 changed files with 1406 additions and 211 deletions

No files matched your search

+475
View File
@@ -0,0 +1,475 @@
name: PR script test
# Builds the ct/ scripts a pull request touches on a real Proxmox host 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. Nothing here starts on a push:
# someone with write access comments `/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/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]
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/ paths (empty = everything the PR touches)"
required: false
type: string
permissions: {}
jobs:
gate:
if: >-
github.event_name == 'workflow_dispatch' ||
(github.event.issue.pull_request && startsWith(github.event.comment.body, '/test'))
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 }}
steps:
- id: plan
uses: actions/github-script@v8
with:
script: |
const { owner, repo } = context.repo;
const MAX = 10;
core.setOutput('matrix', '[]');
let prNumber, requested = '';
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);
}
const { 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; }
const head = pr.head.repo;
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 = tokens.filter(isSha);
let entries = 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 (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] !== pr.head.sha) {
await refuse(
approved[0].length !== 40 && pr.head.sha.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 (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\/[A-Za-z0-9._-]+\.sh$/.test(e))) {
core.setFailed(`Only ct/<name>.sh paths can be requested, got: ${requested}`);
return;
}
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\/[A-Za-z0-9._-]+\.sh$/.test(f.filename)) set.add(f.filename);
}
entries = [...set].sort();
}
const include = [];
for (const script of entries.slice(0, MAX)) {
let text;
try {
const res = await github.rest.repos.getContent({ owner: head.owner.login, repo: head.name, path: script, ref: pr.head.sha });
text = Buffer.from(res.data.content || '', 'base64').toString('utf8');
} catch (e) { continue; } // install script without a ct/ entry point
// Scripts that ask for the OS get built once per OS.
const oses = /msg_menu\s+"Choose the container OS"/.test(text) ? ['debian', 'alpine'] : [''];
const name = script.slice(3, -3);
for (const os of oses) include.push({ script, os, name: os ? `${name}-${os}` : name });
}
if (entries.length > MAX) core.warning(`Only the first ${MAX} of ${entries.length} scripts are built`);
if (include.length === 0) { core.notice('No ct/ script to build'); return; }
const runUrl = `${context.serverUrl}/${owner}/${repo}/actions/runs/${context.runId}`;
const { data: check } = await github.rest.checks.create({
owner, repo, name: 'Proxmox script test', head_sha: pr.head.sha, status: 'in_progress',
details_url: runUrl,
output: { title: `Building ${include.length} on Proxmox`, summary: include.map(i => `- \`${i.script}\` ${i.os}`).join('\n') },
});
core.setOutput('matrix', JSON.stringify(include));
core.setOutput('pr', String(prNumber));
core.setOutput('repo', head.full_name);
core.setOutput('sha', pr.head.sha);
core.setOutput('check_id', String(check.id));
// 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 ${pr.head.sha.slice(0, 7)} — [watch it live](${runUrl}) (open the \`test\` job)`, '',
...include.map(i => `- \`${i.script}\`${i.os ? ` (${i.os})` : ''}`),
].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: proxmox-test
permissions:
contents: read
timeout-minutes: 60
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 }}
UPSTREAM: ${{ github.repository }}
CTID_BASE: ${{ github.repository == 'community-scripts/ProxmoxVE' && '9050' || '9000' }}
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 }}
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 : '';
found.set(`${r.script}|${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.os || 'default'}`) || {
script: m.script, os: m.os || 'default',
status: process.env.TEST_RESULT === 'cancelled' ? 'cancelled' : 'error',
step: 'no result (runner or job failure)', duration: 0, log_tail: '', install_log: '', shot_file: '',
}), name: m.name }));
// 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 });
};
for (const r of rows.filter(r => r.shot_file)) {
const buf = fs.readFileSync(r.shot_file);
if (!isPng(buf)) { core.warning(`${r.script}: screenshot is not a plausible PNG, skipped`); continue; }
const file = `pr-${prNum}/${r.name}.png`;
for (let attempt = 1; attempt <= 3 && !r.shot_url; 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} (${r.os}) for #${prNum} at ${process.env.SHA.slice(0, 7)}`,
});
r.shot_url = `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));
}
}
}
// 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}`;
const table = [
'| Script | OS | Result | Duration | Detail |',
'|---|---|---|---|---|',
...rows.map(r => `| \`${r.script}\` | ${r.os} | ${icon[r.status] || '❔'} | ${dur(r.duration || 0)} | ${r.status === 'pass' ? (r.url ? `${clean(r.url)} → ${r.http_code}` : 'no URL to check') : clean(r.step)} |`),
].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';
const buildShare = failed && r.log_tail ? Math.min(r.log_tail.length, Math.floor(budget / 3)) : 0;
if (buildShare) parts.push(details(`${icon[r.status] || '❔'} ${r.script} (${r.os}) — build log`, r.log_tail, buildShare));
if (r.install_log) {
const lines = r.install_log.trimEnd().split('\n').length;
parts.push(details(`📄 ${r.script} (${r.os}) — install log (${lines} lines)`, r.install_log, budget - buildShare - 300));
} else if (!buildShare) {
parts.push(`<sub>${r.script} (${r.os}): no install log was captured</sub>`);
}
return parts.join('\n\n');
});
const shots = rows.filter(r => r.shot_url).map(r => [
`<details open><summary>📸 ${r.script} (${r.os}) — ${clean(r.url)}</summary>`, '',
`<img src="${r.shot_url}" width="640" alt="Screenshot of ${r.script}">`, '', '</details>',
].join('\n'));
const summary = [
`${headline} at \`${process.env.SHA.slice(0, 7)}\``, '',
table, '',
...shots, '',
...logs, '',
`Full logs: [workflow run](${runUrl}) (artifacts \`result-*\`). Comment \`/test\` to build again.`,
].join('\n');
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 { data: prInfo } = 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 });
// 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);
try {
const reviews = await github.paginate(github.rest.pulls.listReviews, { owner, repo, pull_number: pr });
for (const v of reviews.filter(v => v.user && v.user.type === 'Bot' && v.state === 'CHANGES_REQUESTED' && (v.body || '').includes(REVIEW_MARK))) {
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 (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}\` (${r.os}): ${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}`);
}
+203
View File
@@ -0,0 +1,203 @@
#!/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 live on the root filesystem; there is no LVM in a cloud-image PVE.
pvesm set local --content iso,vztmpl,backup,rootdir,images
# 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) that no running job holds, and stale trees.
for id in $(pct list | awk 'NR>1 && $1>=9000 && $1<=9099 {print $1}'); do
[[ -e "/run/ci-jobs/$id" ]] && continue
pct stop "$id" --skiplock 1 &>/dev/null || true
pct destroy "$id" --purge 1 --force 1 &>/dev/null && echo "swept CT $id"
done
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.
RUNNER_ALLOW_RUNASROOT=1 ./config.sh --unattended --replace --no-default-labels \
--url "https://github.com/${repo}" --token "$token" \
--name "pve-ci-${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"
+451
View File
@@ -0,0 +1,451 @@
#!/usr/bin/env bash
# Build one ct/ script from a pull request on this Proxmox host, check that the
# container really works, destroy it, and leave a result.json behind.
#
# 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:-}"
CTID_COUNT="${CTID_COUNT:-50}"
SERVE_ROOT="${SERVE_ROOT:-/srv/ci}"
SERVE_URL="${SERVE_URL:-http://10.99.0.1:18080}"
BRIDGE="${BRIDGE:-vmbr1}"
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/[A-Za-z0-9._-]+\.sh$ ]] || { echo "bad SCRIPT" >&2; exit 2; }
[[ -z "$OS" || "$OS" =~ ^(debian|ubuntu|alpine)$ ]] || { echo "bad OS" >&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"
app=$(basename "$SCRIPT" .sh)
job="${GITHUB_RUN_ID:-local}-${GITHUB_RUN_ATTEMPT:-1}-${app}-${OS:-default}"
tree="$SERVE_ROOT/$job"
base="$SERVE_URL/$job"
started=$(date +%s)
status="fail"
step=""
exit_code=""
ctid=""
ct_ip=""
url=""
http_code=""
failed_units=""
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*$'
}
# 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 pct status "$ctid" &>/dev/null &&
pct exec "$ctid" -- 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
# 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
jq -n \
--arg script "$SCRIPT" --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" \
--argjson duration "$(($(date +%s) - started))" \
--arg tail "$([[ -f "$LOG" ]] && tail -n 60 "$LOG")" \
'{script: $script, os: $os, status: $status, step: $step, exit_code: $exit_code,
sha: $sha, ctid: $ctid, url: $url, http_code: $http_code,
failed_units: $failed_units, 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'
}
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=$!
# setsid: its own process group, so stop_streams can take down the
# pct exec / tail pair inside it in one go.
setsid bash -c '
for _ in $(seq 1 900); do
f=$(pct exec "$1" -- 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) ────────"
exec pct exec "$1" -- tail -n +1 -F "$f"
fi
sleep 2
done' _ "$ctid" 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=""
cleanup() {
stop_streams
if [[ -n "$build_pid" ]]; then
pkill -TERM -P "$build_pid" &>/dev/null || true
kill -TERM "$build_pid" &>/dev/null || true
sleep 2
fi
if [[ -n "$ctid" ]]; then
if pct status "$ctid" &>/dev/null; then
pct stop "$ctid" --skiplock 1 &>/dev/null || true
pct destroy "$ctid" --purge 1 --force 1 &>/dev/null || true
fi
rm -f "/run/ci-jobs/$ctid"
# What the engine leaves on the host for this container and its session.
local f sid
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.
mkdir -p /run/ci-jobs
exec 9>/run/ci-jobs/.lock
flock 9
for ((id = CTID_BASE; id < CTID_BASE + CTID_COUNT; id++)); do
[[ -e "/run/ci-jobs/$id" ]] && continue
pvesh get /cluster/nextid --vmid "$id" &>/dev/null || continue
echo "$job" >"/run/ci-jobs/$id"
ctid=$id
break
done
flock -u 9
exec 9>&-
[[ -n "$ctid" ]] || setup_error "no free container 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"
# --- Build ----------------------------------------------------------------------
echo "Building ${SCRIPT} (${OS:-default OS}) 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_container_storage=local var_template_storage=local var_brg="$BRIDGE"
"dev_mode=timing,keep" COMMUNITY_SCRIPTS_URL="$base"
)
touch "$RAW_LOG.start"
[[ -n "$OS" ]] && build_env+=(var_os="$OS")
# --- 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 "
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 ---------------------------------------------------------------------
pct status "$ctid" 2>/dev/null | grep -q running || fail "container ${ctid} is not running"
ct_ip=$(pct exec "$ctid" -- sh -c "ip -4 -o addr show dev eth0 | awk '{print \$4}' | cut -d/ -f1" 2>/dev/null | head -n1)
if pct exec "$ctid" -- sh -c 'command -v systemctl' &>/dev/null; then
sleep 10
failed_units=$(pct exec "$ctid" -- systemctl --failed --no-legend --plain 2>/dev/null | awk '{print $1}' | paste -sd, -)
elif pct exec "$ctid" -- sh -c 'command -v rc-status' &>/dev/null; then
failed_units=$(pct exec "$ctid" -- rc-status --crashed 2>/dev/null | awk '{print $1}' | paste -sd, -)
fi
[[ -z "$failed_units" ]] || fail "failed services: ${failed_units}"
# 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.
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/screenshot.png"
fi
rm -rf -- "$dir"
}
# The URL the script tells the user to open, e.g. http://${IP}:1337/admin, has
# to answer 200 (after redirects, so a jump to /login is fine). A script that
# prints no URL has no web UI to check.
pattern=$(grep -oE 'https?://\$\{IP\}(:[0-9]+)?(/[A-Za-z0-9._~/?=&%-]*)?' "$tree/$SCRIPT" | head -n1)
if [[ -n "$pattern" && -n "$ct_ip" ]]; then
url="${pattern/\$\{IP\}/$ct_ip}"
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
[[ "$http_code" =~ ^2[0-9][0-9]$ ]] || fail "${url} answered ${http_code/#000/nothing} instead of 200 within ${URL_WAIT}s"
fi
status="pass"
finish
+53
View File
@@ -581,6 +581,59 @@ Exercise vigilance regarding copycat or coat-tailing sites that seek to exploit
</details>
## 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: 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
- 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
- #### 🐞 Bug Fixes
- zipline: Update RAM to 3GB [@github-actions[bot]](https://github.com/github-actions[bot]) ([#18569](https://github.com/community-scripts/ProxmoxVE/pull/18569))
- OpenObserve: record installed version, drop killall ExecStop [@BadFlo](https://github.com/BadFlo) ([#18566](https://github.com/community-scripts/ProxmoxVE/pull/18566))
- OpenObserve: extract release tarball with --no-same-owner [@BadFlo](https://github.com/BadFlo) ([#18564](https://github.com/community-scripts/ProxmoxVE/pull/18564))
- Immich: download before stopping, and clear maintenance mode after an interrupted update [@angusmaul](https://github.com/angusmaul) ([#18553](https://github.com/community-scripts/ProxmoxVE/pull/18553))
- cinephage: bump Node.js from 24 to 26 [@github-actions[bot]](https://github.com/github-actions[bot]) ([#18374](https://github.com/community-scripts/ProxmoxVE/pull/18374))
- kan: bump Node.js from 20 to 24 [@github-actions[bot]](https://github.com/github-actions[bot]) ([#18549](https://github.com/community-scripts/ProxmoxVE/pull/18549))
- myip: bump Node.js from 24 to 26 [@github-actions[bot]](https://github.com/github-actions[bot]) ([#18550](https://github.com/community-scripts/ProxmoxVE/pull/18550))
- #### 🔧 Refactor
- security: remove hardcoded credentials in several scripts [@MickLesk](https://github.com/MickLesk) ([#18572](https://github.com/community-scripts/ProxmoxVE/pull/18572))
- yuvomi: install from the attested release asset [@ulsklyc](https://github.com/ulsklyc) ([#18554](https://github.com/community-scripts/ProxmoxVE/pull/18554))
### 🗑️ Deleted Scripts
- coolify: delete old ct / its an addon [@michelroegl-brunner](https://github.com/michelroegl-brunner) ([#18562](https://github.com/community-scripts/ProxmoxVE/pull/18562))
### 💾 Core
- core: detect GPUs by their PCI class, not by text anywhere in the line [@MickLesk](https://github.com/MickLesk) ([core#221](https://github.com/community-scripts/core/pull/221))
- core: load an app's own settings from its .vars file [@MickLesk](https://github.com/MickLesk) ([core#216](https://github.com/community-scripts/core/pull/216))
### 🧰 Tools
- #### 🐞 Bug Fixes
- IP-Tag: filter guest-agent IPs by CIDR before limiting to 3 [@mazelado](https://github.com/mazelado) ([#18537](https://github.com/community-scripts/ProxmoxVE/pull/18537))
## 2026-11-08
### 🆕 New Scripts
+1 -1
View File
@@ -36,7 +36,7 @@ function update_script() {
systemctl stop cinephage
msg_ok "Stopped Cinephage"
NODE_VERSION="24" setup_nodejs
NODE_VERSION="26" setup_nodejs
CLEAN_INSTALL=1 fetch_and_deploy_gh_release "cinephage" "MoldyTaint/Cinephage" "tarball"
msg_info "Building Cinephage"
-73
View File
@@ -1,73 +0,0 @@
#!/usr/bin/env bash
_cs_boot="${COMMUNITY_SCRIPTS_CORE_DIR:-$(dirname "${BASH_SOURCE[0]}")/../../core}/core/build.func"
source "$_cs_boot" 2>/dev/null || source <(curl -fsSL "${COMMUNITY_SCRIPTS_CORE_URL:-https://raw.githubusercontent.com/community-scripts/core/main}/core/build.func")
# Copyright (c) 2021-2026 community-scripts ORG
# Author: MickLesk (CanbiZ)
# License: MIT | https://github.com/community-scripts/ProxmoxVE/raw/main/LICENSE
# Source: https://coolify.io/
APP="Coolify"
var_tags="${var_tags:-docker;paas}"
var_cpu="${var_cpu:-2}"
var_ram="${var_ram:-4096}"
var_disk="${var_disk:-30}"
var_os="${var_os:-debian}"
var_version="${var_version:-13}"
var_arm64="${var_arm64:-yes}"
var_unprivileged="${var_unprivileged:-1}"
header_info "$APP"
variables
color
catch_errors
ADDON_SCRIPT="https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/tools/addon/coolify.sh"
function update_script() {
header_info
check_container_storage
check_container_resources
if [[ ! -d /data/coolify ]]; then
msg_error "No ${APP} Installation Found!"
exit
fi
msg_warn "⚠️ ${APP} has been migrated to an addon script."
echo ""
echo -e "${INFO}${YW}This is a one-time migration. After this, you can update ${APP} anytime with:${CL}"
echo -e "${TAB}${TAB}${GN}update_coolify${CL} or ${GN}bash <(curl -fsSL ${ADDON_SCRIPT})${CL}"
echo ""
read -r -p "${TAB}Migrate update function now? [y/N]: " CONFIRM
if [[ ! "${CONFIRM,,}" =~ ^(y|yes)$ ]]; then
msg_warn "Migration skipped. The old update will continue to work for now."
msg_info "Updating ${APP} (legacy)"
$STD bash <(curl -fsSL https://cdn.coollabs.io/coolify/install.sh)
msg_ok "Updated ${APP}"
exit
fi
msg_info "Migrating update function"
TMP_UPDATE=$(mktemp)
cat <<'MIGRATION_EOF' >"$TMP_UPDATE"
bash -c "$(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/tools/addon/coolify.sh)"
MIGRATION_EOF
mv "$TMP_UPDATE" /usr/bin/update
chmod +x /usr/bin/update
ln -sf /usr/bin/update /usr/bin/update_coolify 2>/dev/null || true
msg_ok "Migration complete"
echo -e "${INFO}${YW}Running addon update${CL}"
type=update bash <(curl -fsSL "${ADDON_SCRIPT}")
exit
}
start
build_container
description
msg_ok "Completed successfully!\n"
echo -e "${CREATING}${GN}${APP} setup has been successfully initialized!${CL}"
echo -e "${INFO}${YW}Access it using the following URL:${CL}"
echo -e "${GATEWAY}${BGN}http://${IP}:8000${CL}"
+1 -1
View File
@@ -26,7 +26,7 @@ function update_script() {
check_container_storage
check_container_resources
NODE_VERSION="24" setup_nodejs
NODE_VERSION="26" setup_nodejs
ensure_dependencies build-essential
if command -v cross-seed &>/dev/null; then
+2 -27
View File
@@ -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}"
@@ -70,32 +70,7 @@ function update_script() {
exit
fi
if [ "$UPD" == "4" ]; then
msg_info "Installing FileBrowser"
RELEASE=$(curl -fsSL https://api.github.com/repos/filebrowser/filebrowser/releases/latest | grep -o '"tag_name": ".*"' | sed 's/"//g' | sed 's/tag_name: //g')
$STD curl -fsSL https://github.com/filebrowser/filebrowser/releases/download/v2.23.0/linux-$(arch_resolve)-filebrowser.tar.gz | tar -xzv -C /usr/local/bin
$STD filebrowser config init -a '0.0.0.0'
$STD filebrowser config set -a '0.0.0.0'
$STD filebrowser users add admin community-scripts.org --perm.admin
msg_ok "Installed FileBrowser"
msg_info "Creating Service"
service_path="/etc/systemd/system/filebrowser.service"
echo "[Unit]
Description=Filebrowser
After=network-online.target
[Service]
User=root
WorkingDirectory=/root/
ExecStart=/usr/local/bin/filebrowser -r /
[Install]
WantedBy=default.target" >$service_path
$STD systemctl enable --now filebrowser
msg_ok "Created Service"
msg_ok "Completed successfully!\n"
echo -e "FileBrowser should be reachable by going to the following URL.
${BL}http://$LOCAL_IP:8080${CL} admin|community-scripts.org\n"
bash -c "$(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/tools/addon/filebrowser.sh)"
exit
fi
}
+13 -11
View File
@@ -131,7 +131,18 @@ EOF
if check_for_gh_release "Immich" "immich-app/immich" "${RELEASE}" "each release is tested individually before the version is updated. Please do not open issues for this"; then
msg_warn "The update takes 5-15 minutes, do not interrupt it"
PREV_IMMICH="$(cat ~/.immich)"
PREV_IMMICH="${CS_PREVIOUS_VERSION:-$(cat ~/.immich)}"
INSTALL_DIR="/opt/${APP}"
UPLOAD_DIR="$(sed -n '/^IMMICH_MEDIA_LOCATION/s/[^=]*=//p' /opt/immich/.env)"
SRC_DIR="${INSTALL_DIR}/source"
APP_DIR="${INSTALL_DIR}/app"
PLUGIN_DIR="${APP_DIR}/plugins/immich-plugin-core"
ML_DIR="${APP_DIR}/machine-learning"
GEO_DIR="${INSTALL_DIR}/geodata"
setup_uv
CLEAN_INSTALL=1 fetch_and_deploy_gh_release "Immich" "immich-app/immich" "tarball" "${RELEASE}" "$SRC_DIR"
# An interrupted update leaves app/ empty, so there may be no immich-admin to call.
if [[ "$PREV_IMMICH" > "2.5.1" && -x /opt/immich/app/bin/immich-admin ]]; then
msg_info "Enabling Maintenance Mode"
@@ -141,6 +152,7 @@ EOF
$STD cd -
msg_ok "Enabled Maintenance Mode"
fi
[[ -x /opt/immich/app/bin/immich-admin ]] || export MAINT_MODE=1
msg_info "Stopping Services"
systemctl stop immich-web
systemctl stop immich-ml
@@ -164,14 +176,6 @@ EOF
fi
ensure_dependencies ccache gcc-13 g++-13
INSTALL_DIR="/opt/${APP}"
UPLOAD_DIR="$(sed -n '/^IMMICH_MEDIA_LOCATION/s/[^=]*=//p' /opt/immich/.env)"
SRC_DIR="${INSTALL_DIR}/source"
APP_DIR="${INSTALL_DIR}/app"
PLUGIN_DIR="${APP_DIR}/plugins/immich-plugin-core"
ML_DIR="${APP_DIR}/machine-learning"
GEO_DIR="${INSTALL_DIR}/geodata"
[[ -f "$ML_DIR"/ml_start.sh ]] && cp "$ML_DIR"/ml_start.sh "$INSTALL_DIR"
if grep -qs "set -a" "$APP_DIR"/bin/start.sh && grep -qs "warnings" "$APP_DIR"/bin/start.sh; then
cp "$APP_DIR"/bin/start.sh "$INSTALL_DIR"
@@ -193,8 +197,6 @@ EOF
rm -rf "${APP_DIR:?}"/*
)
setup_uv
CLEAN_INSTALL=1 fetch_and_deploy_gh_release "Immich" "immich-app/immich" "tarball" "${RELEASE}" "$SRC_DIR"
PNPM_VERSION="$(jq -r '.packageManager | split("@")[1] | split("+")[0]' ${SRC_DIR}/package.json)"
export COREPACK_ENABLE_DOWNLOAD_PROMPT=0
export CI=1
+2 -2
View File
@@ -32,14 +32,14 @@ function update_script() {
fi
if check_for_gh_tag "kan" "kanbn/kan"; then
NODE_VERSION="24" NODE_MODULE="pnpm" setup_nodejs
msg_info "Stopping Service"
systemctl stop kan
msg_ok "Stopped Service"
create_backup /opt/kan/.env
CLEAN_INSTALL=1 fetch_and_deploy_gh_tag "kan" "kanbn/kan" "latest"
restore_backup
msg_info "Building Application"
+2 -2
View File
@@ -9,7 +9,7 @@ source "$_cs_boot" 2>/dev/null || source <(curl -fsSL "${COMMUNITY_SCRIPTS_CORE_
APP="MyIP"
var_tags="${var_tags:-network}"
var_cpu="${var_cpu:-2}"
var_ram="${var_ram:-1024}"
var_ram="${var_ram:-2048}"
var_disk="${var_disk:-4}"
var_os="${var_os:-debian}"
var_version="${var_version:-13}"
@@ -30,7 +30,7 @@ function update_script() {
exit
fi
NODE_VERSION="24" setup_nodejs
NODE_VERSION="26" setup_nodejs
if check_for_gh_release "myip" "jason5ng32/MyIP"; then
msg_info "Stopping Services"
+6 -1
View File
@@ -32,9 +32,14 @@ function update_script() {
if check_for_gh_release "openobserve" "openobserve/openobserve"; then
msg_info "Updating OpenObserve"
if grep -q "^ExecStop=killall" /etc/systemd/system/openobserve.service; then
sed -i "/^ExecStop=killall/d" /etc/systemd/system/openobserve.service
systemctl daemon-reload
fi
systemctl stop openobserve
RELEASE=$(get_latest_github_release "openobserve/openobserve")
tar zxf <(curl -fsSL https://downloads.openobserve.ai/releases/openobserve/v$RELEASE/openobserve-v$RELEASE-linux-$(arch_resolve).tar.gz) -C /opt/openobserve
tar zxf <(curl -fsSL https://downloads.openobserve.ai/releases/openobserve/v$RELEASE/openobserve-v$RELEASE-linux-$(arch_resolve).tar.gz) -C /opt/openobserve --no-same-owner
echo "${RELEASE}" >~/.openobserve
systemctl start openobserve
msg_ok "Updated OpenObserve"
msg_ok "Updated successfully!"
+1 -27
View File
@@ -66,33 +66,7 @@ function update_script() {
exit
fi
if [ "$UPD" == "3" ]; then
msg_info "Installing FileBrowser"
$STD curl -fsSL https://raw.githubusercontent.com/filebrowser/get/master/get.sh | bash
$STD filebrowser config init -a '0.0.0.0'
$STD filebrowser config set -a '0.0.0.0'
$STD filebrowser users add admin community-scripts.org --perm.admin
msg_ok "Installed FileBrowser"
msg_info "Creating Service"
cat <<EOF >/etc/systemd/system/filebrowser.service
[Unit]
Description=Filebrowser
After=network-online.target
[Service]
User=root
WorkingDirectory=/root/
ExecStart=/usr/local/bin/filebrowser -r /
[Install]
WantedBy=default.target
EOF
systemctl enable -q --now filebrowser
msg_ok "Created Service"
msg_ok "Completed successfully!\n"
echo -e "FileBrowser should be reachable by going to the following URL.
${BL}http://$LOCAL_IP:8080${CL} admin|community-scripts.org\n"
bash -c "$(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/tools/addon/filebrowser.sh)"
exit
fi
if [ "$UPD" == "4" ]; then
+1 -1
View File
@@ -37,7 +37,7 @@ function update_script() {
msg_ok "Installed Archive Tools"
fi
NODE_VERSION="26" setup_nodejs
NODE_VERSION="24" setup_nodejs
# RomM 5.3.0 replaced rq-scheduler with `rq cron` and moved scans to their own queue
if grep -q rqscheduler /etc/systemd/system/romm-scheduler.service 2>/dev/null; then
+3 -6
View File
@@ -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
View File
@@ -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"
+1 -1
View File
@@ -38,7 +38,7 @@ function update_script() {
create_backup /opt/yuvomi/data /opt/yuvomi/.env
CLEAN_INSTALL=1 fetch_and_deploy_gh_release "yuvomi" "ulsklyc/yuvomi" "tarball"
CLEAN_INSTALL=1 fetch_and_deploy_gh_release "yuvomi" "ulsklyc/yuvomi" "prebuild" "latest" "/opt/yuvomi" "yuvomi-*-dist.tar.gz"
NODE_VERSION="24" setup_nodejs
msg_info "Installing Node.js Dependencies"
+1 -1
View File
@@ -9,7 +9,7 @@ source "$_cs_boot" 2>/dev/null || source <(curl -fsSL "${COMMUNITY_SCRIPTS_CORE_
APP="Zipline"
var_tags="${var_tags:-file;sharing}"
var_cpu="${var_cpu:-2}"
var_ram="${var_ram:-2048}"
var_ram="${var_ram:-3072}"
var_disk="${var_disk:-5}"
var_os="${var_os:-debian}"
var_version="${var_version:-13}"
+3
View File
@@ -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
+8 -2
View File
@@ -48,6 +48,7 @@ chown -R archivebox:archivebox /opt/archivebox/{data,.npm,.cache,.local}
chmod -R 755 /opt/archivebox/data
$STD uv pip install archivebox --system --break-system-packages
cd /opt/archivebox/data
ADMIN_PASS=$(random_password 20)
expect <<EOF
set timeout -1
log_user 0
@@ -62,13 +63,18 @@ expect "Email address"
send "\r"
expect "Password"
send "community-scripts.org\r"
send "${ADMIN_PASS}\r"
expect "Password (again)"
send "community-scripts.org\r"
send "${ADMIN_PASS}\r"
expect eof
EOF
{
echo "ArchiveBox Credentials"
echo "Username: archivebox"
echo "Password: ${ADMIN_PASS}"
} >~/archivebox.creds
msg_ok "Installed ArchiveBox"
msg_info "Creating Service"
+8 -1
View File
@@ -63,12 +63,19 @@ else
fi
echo "$AUTHELIA_URL" >/etc/authelia/.authelia_url
AUTHELIA_PASS=$(random_password 20)
AUTHELIA_HASH=$(authelia crypto hash generate argon2 --password "$AUTHELIA_PASS" | awk '/Digest:/{print $2}')
{
echo "Authelia Credentials"
echo "Username: authelia"
echo "Password: ${AUTHELIA_PASS}"
} >~/authelia.creds
cat <<EOF >/etc/authelia/users.yml
users:
authelia:
disabled: false
displayname: "Authelia Admin"
password: "\$argon2id\$v=19\$m=65536,t=3,p=4\$ZBopMzXrzhHXPEZxRDVT2w\$SxWm96DwhOsZyn34DLocwQEIb4kCDsk632PuiMdZnig"
password: "${AUTHELIA_HASH}"
groups: []
EOF
cat <<EOF >/etc/authelia/configuration.yml
+1 -1
View File
@@ -24,7 +24,7 @@ $STD apt install -y \
msg_ok "Installed Dependencies"
setup_ffmpeg
NODE_VERSION="24" setup_nodejs
NODE_VERSION="26" setup_nodejs
fetch_and_deploy_gh_release "cinephage" "MoldyTaint/Cinephage" "tarball"
msg_info "Building Cinephage"
+1 -1
View File
@@ -17,7 +17,7 @@ msg_info "Installing Dependencies"
$STD apt install -y build-essential
msg_ok "Installed Dependencies"
NODE_VERSION="24" setup_nodejs
NODE_VERSION="26" setup_nodejs
msg_info "Setup Cross-Seed"
$STD npm install cross-seed@latest -g
+1
View File
@@ -26,6 +26,7 @@ chown -R www-data:www-data /opt/firefly
chmod -R 775 /opt/firefly/storage
cd /opt/firefly
cp .env.example .env
sed -i -e "s|^APP_KEY=.*|APP_KEY=$(random_password 32)|" -e "s|^STATIC_CRON_TOKEN=.*|STATIC_CRON_TOKEN=$(random_password 32)|" /opt/firefly/.env
sed -i "s/DB_HOST=.*/DB_HOST=localhost/" /opt/firefly/.env
sed -i "s/DB_PASSWORD=.*/DB_PASSWORD=$MARIADB_DB_PASS/" /opt/firefly/.env
$STD composer install --no-dev --no-plugins --no-interaction
+7 -1
View File
@@ -33,7 +33,13 @@ msg_ok "Set up Python Backend"
msg_info "Configuring Homelable"
mkdir -p /opt/homelable/data
SECRET_KEY=$(openssl rand -hex 32)
BCRYPT_HASH=$(/opt/homelable/backend/.venv/bin/python -c "import bcrypt; print(bcrypt.hashpw(b'admin', bcrypt.gensalt()).decode())")
HOMELABLE_PASS=$(random_password 20)
BCRYPT_HASH=$(/opt/homelable/backend/.venv/bin/python -c "import bcrypt,sys; print(bcrypt.hashpw(sys.argv[1].encode(), bcrypt.gensalt()).decode())" "$HOMELABLE_PASS")
{
echo "Homelable Credentials"
echo "Username: admin"
echo "Password: ${HOMELABLE_PASS}"
} >~/homelable.creds
cat <<EOF >/opt/homelable/backend/.env
SECRET_KEY=${SECRET_KEY}
SQLITE_PATH=/opt/homelable/data/homelab.db
+1 -1
View File
@@ -21,7 +21,7 @@ msg_ok "Installed Dependencies"
PG_VERSION="16" setup_postgresql
PG_DB_NAME="kan" PG_DB_USER="kan" setup_postgresql_db
NODE_VERSION="20" NODE_MODULE="pnpm" setup_nodejs
NODE_VERSION="24" NODE_MODULE="pnpm" setup_nodejs
fetch_and_deploy_gh_tag "kan" "kanbn/kan" "latest"
+9 -1
View File
@@ -21,6 +21,7 @@ PG_DB_NAME="keycloak" PG_DB_USER="keycloak" setup_postgresql_db
fetch_and_deploy_gh_release "keycloak_app" "keycloak/keycloak" "prebuild" "latest" "/opt/keycloak" "keycloak-*.tar.gz"
msg_info "Creating Service"
ADMIN_PASS=$(random_password 20)
cat <<EOF >/etc/systemd/system/keycloak.service
[Unit]
Description=Keycloak Service
@@ -40,7 +41,7 @@ Environment="KC_DB_USERNAME=keycloak"
Environment="KC_DB_PASSWORD=$PG_DB_PASS"
Environment="KC_HTTP_ENABLED=true"
Environment="KC_BOOTSTRAP_ADMIN_USERNAME=tmpadm"
Environment="KC_BOOTSTRAP_ADMIN_PASSWORD=admin123"
Environment="KC_BOOTSTRAP_ADMIN_PASSWORD=${ADMIN_PASS}"
# Comment following line and uncomment the next 2 if working behind a reverse proxy
Environment="KC_HOSTNAME_STRICT=false"
#Environment="KC_HOSTNAME=keycloak.example.com"
@@ -48,6 +49,13 @@ Environment="KC_HOSTNAME_STRICT=false"
[Install]
WantedBy=multi-user.target
EOF
chmod 600 /etc/systemd/system/keycloak.service
{
echo "Keycloak Credentials"
echo "Temporary admin, replace it with a permanent one"
echo "Username: tmpadm"
echo "Password: ${ADMIN_PASS}"
} >~/keycloak.creds
systemctl enable -q --now keycloak
msg_ok "Created Service"
+8 -1
View File
@@ -40,6 +40,7 @@ cp .env.dist .env
sed -i "/^DATABASE_URL=.*/c\DATABASE_URL=mysql://kimai:$MARIADB_DB_PASS@127.0.0.1:3306/kimai_db?charset=utf8mb4&serverVersion=mariadb-$MYSQL_VERSION" /opt/kimai/.env
sed -i "s|^APP_SECRET=.*|APP_SECRET=$APP_SECRET|" /opt/kimai/.env
$STD bin/console kimai:install -n
ADMIN_PASS=$(random_password 20)
$STD expect <<EOF
set timeout -1
log_user 0
@@ -47,10 +48,16 @@ log_user 0
spawn bin/console kimai:user:create admin admin@community-scripts.org ROLE_SUPER_ADMIN
expect "Please enter the password:"
send "community-scripts.org\r"
send "${ADMIN_PASS}\r"
expect eof
EOF
{
echo "Kimai Credentials"
echo "Username: admin"
echo "Email: admin@community-scripts.org"
echo "Password: ${ADMIN_PASS}"
} >~/kimai.creds
$STD composer update --no-interaction
cat <<EOF >/opt/kimai/config/packages/local.yaml
kimai:
+7 -1
View File
@@ -33,7 +33,13 @@ $STD yarn config set ignore-engines true
$STD yarn install
$STD yarn run production
$STD php artisan key:generate
$STD php artisan setup:production --email=admin@community-scripts.org --password=community-scripts.org --force
ADMIN_PASS=$(random_password 20)
$STD php artisan setup:production --email=admin@community-scripts.org --password="${ADMIN_PASS}" --force
{
echo "Monica Credentials"
echo "Email: admin@community-scripts.org"
echo "Password: ${ADMIN_PASS}"
} >~/monica.creds
chown -R www-data:www-data /opt/monica
chmod -R 775 /opt/monica/storage
echo "* * * * * root php /opt/monica/artisan schedule:run >> /dev/null 2>&1" >>/etc/crontab
+1 -1
View File
@@ -13,7 +13,7 @@ setting_up_container
network_check
update_os
NODE_VERSION="24" setup_nodejs
NODE_VERSION="26" setup_nodejs
fetch_and_deploy_gh_release "myip" "jason5ng32/MyIP" "tarball"
msg_info "Configuring MyIP"
+7 -1
View File
@@ -63,7 +63,13 @@ setcap cap_net_raw+p /bin/ping
msg_ok "Built Nagios Plugins"
msg_info "Configuring Web Authentication"
$STD htpasswd -bc /usr/local/nagios/etc/htpasswd.users nagiosadmin nagiosadmin
NAGIOS_PASS=$(random_password 20)
$STD htpasswd -bc /usr/local/nagios/etc/htpasswd.users nagiosadmin "$NAGIOS_PASS"
{
echo "Nagios Credentials"
echo "Username: nagiosadmin"
echo "Password: ${NAGIOS_PASS}"
} >~/nagios.creds
chown root:www-data /usr/local/nagios/etc/htpasswd.users
chmod 640 /usr/local/nagios/etc/htpasswd.users
msg_ok "Configured Web Authentication"
+9 -2
View File
@@ -201,6 +201,13 @@ cat <<'EOF' >/etc/neko/openbox.xml
</openbox_config>
EOF
NEKO_ADMIN_PASS=$(random_password 20)
NEKO_USER_PASS=$(random_password 20)
{
echo "Neko Credentials"
echo "Admin password: ${NEKO_ADMIN_PASS}"
echo "User password: ${NEKO_USER_PASS}"
} >~/neko.creds
cat <<EOF >/etc/neko/neko.yaml
server:
bind: "0.0.0.0:8080"
@@ -219,8 +226,8 @@ desktop:
member:
provider: "multiuser"
multiuser:
admin_password: "admin"
user_password: "neko"
admin_password: "${NEKO_ADMIN_PASS}"
user_password: "${NEKO_USER_PASS}"
EOF
msg_ok "Set up Runtime"
+5 -2
View File
@@ -31,6 +31,7 @@ NODEBB_USER="nodebb"
NODEBB_PWD="$(random_password 13)"
MONGO_CONNECTION_STRING="mongodb://${NODEBB_USER}:${NODEBB_PWD}@localhost:27017/nodebb"
NODEBB_SECRET=$(uuidgen)
NODEBB_ADMIN_PASS="$(random_password 20)"
cat <<EOF >~/nodebb.creds
NodeBB-Credentials
Mongo Database User: $MONGO_ADMIN_USER
@@ -38,6 +39,8 @@ Mongo Database Password: $MONGO_ADMIN_PWD
NodeBB User: $NODEBB_USER
NodeBB Password: $NODEBB_PWD
NodeBB Secret: $NODEBB_SECRET
NodeBB Admin User: community-scripts
NodeBB Admin Password: $NODEBB_ADMIN_PASS
EOF
$STD mongosh <<EOF
@@ -97,10 +100,10 @@ expect "Administrator email address" {
send "admin@community-scripts.org\r"
}
expect "Password" {
send "community-scripts\r"
send "$NODEBB_ADMIN_PASS\r"
}
expect "Confirm Password" {
send "community-scripts\r"
send "$NODEBB_ADMIN_PASS\r"
}
expect eof
EOF
+7 -1
View File
@@ -57,11 +57,17 @@ done
ACME_EMAIL_INPUT="${var_npmplus_acme_email:-}"
[[ -n "$ACME_EMAIL_INPUT" ]] || read -r -p "${TAB3}Enter your ACME Email (optional, press ENTER to skip): " ACME_EMAIL_INPUT || true
ADMIN_PASS=$(random_password 20)
yq -i "
.services.npmplus.environment |=
(map(select(. != \"TZ=*\" and . != \"ACME_EMAIL=*\" and . != \"INITIAL_ADMIN_EMAIL=*\" and . != \"INITIAL_ADMIN_PASSWORD=*\")) +
[\"TZ=$TZ_INPUT\", \"ACME_EMAIL=$ACME_EMAIL_INPUT\", \"INITIAL_ADMIN_EMAIL=admin@local.com\", \"INITIAL_ADMIN_PASSWORD=community-scripts.org\"])
[\"TZ=$TZ_INPUT\", \"ACME_EMAIL=$ACME_EMAIL_INPUT\", \"INITIAL_ADMIN_EMAIL=admin@local.com\", \"INITIAL_ADMIN_PASSWORD=${ADMIN_PASS}\"])
" /opt/compose.yaml
{
echo "NPMplus Credentials"
echo "Email: admin@local.com"
echo "Password: ${ADMIN_PASS}"
} >~/npmplus.creds
msg_info "Building and Starting NPMplus (Patience)"
$STD docker compose up -d
+7 -1
View File
@@ -30,7 +30,13 @@ rm -rf /usr/lib/python3.*/EXTERNALLY-MANAGED
msg_ok "Setup Python3"
msg_info "Creating user octoprint"
useradd -m -s /bin/bash -p $(openssl passwd -1 octoprint) octoprint
OCTOPRINT_PASS=$(random_password 20)
useradd -m -s /bin/bash -p "$(openssl passwd -6 "$OCTOPRINT_PASS")" octoprint
{
echo "OctoPrint Linux user Credentials"
echo "Username: octoprint"
echo "Password: ${OCTOPRINT_PASS}"
} >~/octoprint.creds
usermod -aG sudo,tty,dialout octoprint
chown -R octoprint:octoprint /opt
echo "octoprint ALL=NOPASSWD: $(command -v systemctl) restart octoprint, $(command -v reboot), $(command -v poweroff)" >/etc/sudoers.d/octoprint
+2 -3
View File
@@ -16,7 +16,7 @@ update_os
msg_info "Installing OpenObserve"
mkdir -p /opt/openobserve/data
RELEASE=$(get_latest_github_release "openobserve/openobserve")
tar zxf <(curl -fsSL https://downloads.openobserve.ai/releases/openobserve/v$RELEASE/openobserve-v$RELEASE-linux-$(arch_resolve).tar.gz) -C /opt/openobserve
tar zxf <(curl -fsSL https://downloads.openobserve.ai/releases/openobserve/v$RELEASE/openobserve-v$RELEASE-linux-$(arch_resolve).tar.gz) -C /opt/openobserve --no-same-owner
ROOT_PASS="$(random_password 9)Aa1!"
cat <<EOF >/opt/openobserve/data/.env
@@ -25,7 +25,7 @@ ZO_ROOT_USER_PASSWORD = "${ROOT_PASS}"
ZO_DATA_DIR = "/opt/openobserve/data"
ZO_HTTP_PORT = "5080"
EOF
echo "${RELEASE}" >>~/.openobserve
echo "${RELEASE}" >~/.openobserve
msg_ok "Installed OpenObserve"
msg_info "Creating Service"
@@ -38,7 +38,6 @@ After=network.target
Type=simple
EnvironmentFile=/opt/openobserve/data/.env
ExecStart=/opt/openobserve/openobserve
ExecStop=killall -QUIT openobserve
Restart=on-failure
[Install]
+3
View File
@@ -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
+7 -1
View File
@@ -43,7 +43,13 @@ $STD php artisan migrate --force --seed
msg_ok "Set up database"
msg_info "Creating Admin User"
$STD php artisan app:user:create paymenter admin admin@paymenter.org paymenter 1 -q
PAYMENTER_PASS=$(random_password 20)
$STD php artisan app:user:create paymenter admin admin@paymenter.org "$PAYMENTER_PASS" 1 -q
{
echo "Paymenter Credentials"
echo "Email: admin@paymenter.org"
echo "Password: ${PAYMENTER_PASS}"
} >~/paymenter.creds
msg_ok "Created Admin User"
msg_info "Configuring Nginx"
+8 -2
View File
@@ -46,10 +46,16 @@ tar -xzf /tmp/libheif.tar.gz -C /usr/local
ldconfig
echo "${LIBHEIF_URL}" >~/.photoprism_libheif
chmod -R 755 /opt/photoprism/photos/originals
ADMIN_PASS=$(random_password 20)
{
echo "PhotoPrism Credentials"
echo "Username: admin"
echo "Password: ${ADMIN_PASS}"
} >~/photoprism.creds
cat <<EOF >/opt/photoprism/config/.env
# Authentication
PHOTOPRISM_ADMIN_USER='admin'
PHOTOPRISM_ADMIN_PASSWORD='changeme'
PHOTOPRISM_ADMIN_PASSWORD='${ADMIN_PASS}'
PHOTOPRISM_AUTH_MODE='password'
PHOTOPRISM_PUBLIC='false'
@@ -112,7 +118,7 @@ StoragePath: "/opt/photoprism/storage"
OriginalsPath: "/opt/photoprism/photos/originals"
ImportPath: "/media"
AdminUser: "admin"
AdminPassword: "changeme"
AdminPassword: "${ADMIN_PASS}"
AuthMode: "password"
DatabaseDriver: "sqlite"
HttpHost: "0.0.0.0"
+9 -1
View File
@@ -18,12 +18,20 @@ fetch_and_deploy_gh_release "qbittorrent" "userdocs/qbittorrent-nox-static" "sin
msg_info "Setup qBittorrent-nox"
mv /opt/qbittorrent/qbittorrent /opt/qbittorrent/qbittorrent-nox
mkdir -p ~/.config/qBittorrent/
QB_PASS=$(random_password 20)
QB_SALT=$(random_password 16)
QB_HASH="$(printf '%s' "$QB_SALT" | base64 -w0):$(openssl kdf -keylen 64 -kdfopt digest:SHA512 -kdfopt pass:"$QB_PASS" -kdfopt salt:"$QB_SALT" -kdfopt iter:100000 -binary PBKDF2 | base64 -w0)"
{
echo "qBittorrent Credentials"
echo "Username: admin"
echo "Password: ${QB_PASS}"
} >~/qbittorrent.creds
cat <<EOF >~/.config/qBittorrent/qBittorrent.conf
[LegalNotice]
Accepted=true
[Preferences]
WebUI\Password_PBKDF2="@ByteArray(amjeuVrF3xRbgzqWQmes5A==:XK3/Ra9jUmqUc4RwzCtrhrkQIcYczBl90DJw2rT8DFVTss4nxpoRhvyxhCf87ahVE3SzD8K9lyPdpyUCfmVsUg==)"
WebUI\Password_PBKDF2="@ByteArray(${QB_HASH})"
WebUI\Port=8090
WebUI\UseUPnP=false
WebUI\Username=admin
+7 -1
View File
@@ -43,9 +43,15 @@ $STD rabbitmqctl enable_feature_flag all
msg_ok "Enabled RabbitMQ Management Plugin"
msg_info "Creating User"
$STD rabbitmqctl add_user proxmox proxmox
RABBITMQ_PASS=$(random_password 20)
$STD rabbitmqctl add_user proxmox "$RABBITMQ_PASS"
$STD rabbitmqctl set_user_tags proxmox administrator
$STD rabbitmqctl set_permissions -p / proxmox ".*" ".*" ".*"
{
echo "RabbitMQ Credentials"
echo "Username: proxmox"
echo "Password: ${RABBITMQ_PASS}"
} >~/rabbitmq.creds
msg_ok "Created User"
motd_ssh
+1 -1
View File
@@ -76,7 +76,7 @@ function decodeBase64(r) {
export default { decodeBase64 };
EOF
msg_ok "Installed Angie with mod_zip and njs modules"
NODE_VERSION="26" setup_nodejs
NODE_VERSION="24" setup_nodejs
setup_mariadb
MARIADB_DB_NAME="romm" MARIADB_DB_USER="romm" setup_mariadb_db
+1 -1
View File
@@ -151,7 +151,7 @@ INVITE_EMAIL_TEMPLATE=/opt/signoz/templates/invitation_email_template.html
SIGNOZ_SQLSTORE_SQLITE_PATH=/var/lib/signoz/signoz.db
SIGNOZ_WEB_ENABLED=true
SIGNOZ_WEB_DIRECTORY=/opt/signoz/web
SIGNOZ_JWT_SECRET=secret
SIGNOZ_JWT_SECRET=$(openssl rand -hex 32)
SIGNOZ_ALERTMANAGER_PROVIDER=signoz
SIGNOZ_TELEMETRYSTORE_PROVIDER=clickhouse
SIGNOZ_TELEMETRYSTORE_CLICKHOUSE_DSN=tcp://localhost:9000?password=
+7 -1
View File
@@ -78,6 +78,12 @@ msg_info "Configuring SnapOtter"
mkdir -p /opt/snapotter_data/files
mkdir -p /tmp/snapotter-workspace
SNAPOTTER_PASS=$(random_password 20)
{
echo "SnapOtter Credentials"
echo "Username: admin"
echo "Password: ${SNAPOTTER_PASS}"
} >~/snapotter.creds
cat <<EOF >/opt/snapotter_data/.env
PORT=1349
NODE_ENV=production
@@ -92,7 +98,7 @@ FEATURE_MANIFEST_PATH=/opt/snapotter/docker/feature-manifest.json
U2NET_HOME=/opt/snapotter_data/ai/models/rembg
AUTH_ENABLED=true
DEFAULT_USERNAME=admin
DEFAULT_PASSWORD=admin
DEFAULT_PASSWORD=${SNAPOTTER_PASS}
LOG_LEVEL=info
TRUST_PROXY=true
FILE_MAX_AGE_HOURS=72
-4
View File
@@ -116,10 +116,6 @@ if [[ "$login_mode" == "true" ]]; then
# activate Login
DISABLE_ADDITIONAL_FEATURES=false
SECURITY_ENABLELOGIN=true
# login credentials
SECURITY_INITIALLOGIN_USERNAME=admin
SECURITY_INITIALLOGIN_PASSWORD=stirling
EOF
fi
msg_ok "Created Environment Variables"
+1 -4
View File
@@ -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"
+1 -1
View File
@@ -23,7 +23,7 @@ msg_ok "Installed Dependencies"
NODE_VERSION="24" setup_nodejs
fetch_and_deploy_gh_release "yuvomi" "ulsklyc/yuvomi" "tarball"
fetch_and_deploy_gh_release "yuvomi" "ulsklyc/yuvomi" "prebuild" "latest" "/opt/yuvomi" "yuvomi-*-dist.tar.gz"
msg_info "Installing Node.js Dependencies"
cd /opt/yuvomi
+7 -3
View File
@@ -85,12 +85,16 @@ function install() {
mkdir -p "${HOME}/.config/code-server/"
if [ "$preexisting_config" = false ]; then
CS_PASS=$(random_password 20)
cat <<EOF >"$config_path"
bind-addr: 0.0.0.0:8680
auth: none
password:
auth: password
password: ${CS_PASS}
cert: false
EOF
chmod 600 "$config_path"
echo -e "Coder Code Server\nPassword: ${CS_PASS}" >~/coder-code-server.creds
chmod 600 ~/coder-code-server.creds
fi
systemctl enable -q --now code-server@"$USER"
systemctl restart code-server@"$USER"
@@ -111,7 +115,7 @@ EOF
msg_ok "Created update script (/usr/local/bin/update_coder-code-server)"
echo -e "${APP} should be reachable by going to the following URL.
${BL}http://${IP}:8680${CL} \n"
${BL}http://${IP}:8680${CL} (password in ~/coder-code-server.creds)\n"
}
# ==============================================================================
+5 -3
View File
@@ -151,9 +151,9 @@ function install() {
else
read -rp "${TAB}Set admin username [admin]: " admin_user
admin_user=${admin_user:-admin}
read -rsp "${TAB}Set admin password [community-scripts.org]: " admin_pass
read -rsp "${TAB}Set admin password [random]: " admin_pass
echo ""
admin_pass=${admin_pass:-community-scripts.org}
admin_pass=${admin_pass:-$(random_password 20)}
msg_ok "Configured with admin user: ${admin_user}"
fi
@@ -291,8 +291,10 @@ UPDATEEOF
msg_ok "Storage: ${BL}${DATA_PATH}${CL}"
msg_ok "Config: ${BL}${CONF_PATH}${CL}"
if [[ -n "$admin_user" ]]; then
echo -e "Copyparty\nUsername: ${admin_user}\nPassword: ${admin_pass}" >~/copyparty.creds
chmod 600 ~/copyparty.creds
echo ""
msg_ok "Login: ${GN}${admin_user}${CL} / ${GN}${admin_pass}${CL}"
msg_ok "Login: ${GN}${admin_user}${CL} (password in ~/copyparty.creds)"
fi
}
+16 -2
View File
@@ -189,6 +189,8 @@ chown root:root /usr/local/community-scripts
chmod 755 /usr/local/community-scripts
msg_ok "Directory prepared"
ADMIN_PASS=$(random_password 20)
msg_warn "FileBrowser runs as root and shows the whole filesystem. Without authentication, anyone who reaches port ${PORT} can read and change every file."
echo -n "${TAB}Use No Authentication? (y/N): "
read -r noauth_prompt
@@ -234,9 +236,12 @@ server:
- neverWatchPath: "/lost+found"
auth:
adminUsername: admin
adminPassword: community-scripts.org
adminPassword: ${ADMIN_PASS}
EOF
msg_ok "Configured with default admin (admin / community-scripts.org)"
chmod 600 "$CONFIG_PATH"
echo -e "FileBrowser Quantum\nUsername: admin\nPassword: ${ADMIN_PASS}" >~/filebrowser-quantum.creds
chmod 600 ~/filebrowser-quantum.creds
msg_ok "Configured admin (credentials saved in ~/filebrowser-quantum.creds)"
fi
msg_info "Creating service"
@@ -276,4 +281,13 @@ EOF
fi
msg_ok "Service created successfully"
# auth.adminPassword resets the password on every start, so it stays only until the admin exists
if grep -q '^\s*adminPassword:' "$CONFIG_PATH"; then
for _ in {1..30}; do
[[ "$(curl -s -o /dev/null -w '%{http_code}' "http://127.0.0.1:${PORT}/health")" == "200" ]] && break
sleep 1
done
sed -i '/^\s*adminPassword:/d' "$CONFIG_PATH"
fi
msg_ok "${APP} is reachable at: ${BL}http://${IP}:${PORT}${CL}"
+9 -6
View File
@@ -122,9 +122,11 @@ if [[ "${install_prompt,,}" =~ ^(y|yes)$ ]]; then
chmod 755 /usr/local/community-scripts
touch "$DB_PATH"
chown root:root "$DB_PATH"
chmod 644 "$DB_PATH"
chmod 600 "$DB_PATH"
msg_ok "Directory created successfully"
ADMIN_PASS=$(random_password 20)
msg_warn "FileBrowser runs as root and shows the whole filesystem. Without authentication, anyone who reaches port ${PORT} can read and change every file."
read -r -p "Would you like to use No Authentication? (y/N): " auth_prompt
if [[ "${auth_prompt,,}" =~ ^(y|yes)$ ]]; then
msg_info "Configuring No Authentication"
@@ -133,16 +135,18 @@ if [[ "${install_prompt,,}" =~ ^(y|yes)$ ]]; then
$STD filebrowser config set -a '0.0.0.0' -p "$PORT" -d "$DB_PATH"
$STD filebrowser config set --auth.method=noauth --database "$DB_PATH"
if ! filebrowser users update 1 --perm.admin --database "$DB_PATH" &>/dev/null; then
$STD filebrowser users add admin community-scripts.org --perm.admin --database "$DB_PATH"
$STD filebrowser users add admin "$ADMIN_PASS" --perm.admin --database "$DB_PATH"
fi
msg_ok "No Authentication configured"
else
msg_info "Setting up default authentication"
msg_info "Setting up authentication"
cd /usr/local/community-scripts
$STD filebrowser config init -a '0.0.0.0' -p "$PORT" -d "$DB_PATH"
$STD filebrowser config set -a '0.0.0.0' -p "$PORT" -d "$DB_PATH"
$STD filebrowser users add admin community-scripts.org --perm.admin --database "$DB_PATH"
msg_ok "Default authentication configured (admin:community-scripts.org)"
$STD filebrowser users add admin "$ADMIN_PASS" --perm.admin --database "$DB_PATH"
echo -e "FileBrowser\nUsername: admin\nPassword: ${ADMIN_PASS}" >~/filebrowser.creds
chmod 600 ~/filebrowser.creds
msg_ok "Authentication configured (credentials saved in ~/filebrowser.creds)"
fi
msg_info "Creating service"
@@ -156,7 +160,6 @@ After=network-online.target
User=root
WorkingDirectory=/usr/local/community-scripts
ExecStartPre=/bin/touch /usr/local/community-scripts/filebrowser.db
ExecStartPre=/usr/local/bin/filebrowser config set -a "0.0.0.0" -p ${PORT} -d /usr/local/community-scripts/filebrowser.db
ExecStart=/usr/local/bin/filebrowser -r / -d /usr/local/community-scripts/filebrowser.db -p ${PORT}
Restart=always
+6 -1
View File
@@ -30,6 +30,7 @@ catch_errors
# Initialize all core functions (colors, formatting, icons, STD mode)
load_functions
confirm_not_pve_host
require_debian_like
header_info
@@ -49,9 +50,13 @@ msg_ok "Installed Prerequisites"
msg_info "Installing ${APP}"
fetch_and_deploy_gh_release "webmin" "webmin/webmin" "binary" "latest" "/opt/webmin" "webmin_*_all.deb"
/usr/share/webmin/changepass.pl /etc/webmin root root &>/dev/null
WEBMIN_PASS=$(random_password 20)
/usr/share/webmin/changepass.pl /etc/webmin root "$WEBMIN_PASS" &>/dev/null
echo -e "Webmin\nUsername: root\nPassword: ${WEBMIN_PASS}" >~/webmin.creds
chmod 600 ~/webmin.creds
msg_ok "Installed ${APP}"
get_lxc_ip
echo -e "Successfully Installed!! ${APP} should be reachable by going to ${BL}https://${LOCAL_IP}:10000${CL}"
echo -e "Login: root, password in ~/webmin.creds"
+9 -3
View File
@@ -588,11 +588,17 @@ get_vm_ips() {
fi
if [[ "$agent_enabled" == "1" ]] && command -v qm >/dev/null 2>&1; then
debug_log "vm $vmid: querying guest agent"
local qm_ips=$(timeout 5 qm guest cmd "$vmid" network-get-interfaces 2>/dev/null | grep -oE '([0-9]{1,3}\.){3}[0-9]{1,3}' | grep -v "127.0.0.1" | head -3)
local qm_ips=$(timeout 5 qm guest cmd "$vmid" network-get-interfaces 2>/dev/null | grep -oE '([0-9]{1,3}\.){3}[0-9]{1,3}' | grep -v "127.0.0.1")
local qm_kept=0
for qm_ip in $qm_ips; do
if [[ "$qm_ip" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]]; then
debug_log "vm $vmid: found IP $qm_ip via qm guest cmd"
ips+="$qm_ip "
if ip_in_cidrs "$qm_ip" "${CIDR_LIST[*]}"; then
debug_log "vm $vmid: found IP $qm_ip via qm guest cmd"
ips+="$qm_ip "
((++qm_kept >= 3)) && break
else
debug_log "vm $vmid: skipping IP $qm_ip via qm guest cmd (outside CIDR_LIST)"
fi
fi
done
else