Files
XC_VM/Makefile
T
Claude 26b0ec5a4d feat(cluster): re-enrol the fleet over SSH with cluster:reenrol
The plan's recovery path for a MAIN replaced without a DR bundle is
cluster:init, then re-enrol every node (section 6; Phase 2 listed a
fleet-wide cluster:reenrol --all as not built). Until now that meant
one server:enrol per node, each with its own credential file.

cluster:reenrol (--all [--state=...] | <id>...) --cred-file=<path>
[--dry-run] drives ServerEnrolCommand::enrol() node by node:

- Selection: --all takes the enrolled nodes in state enrolling or
  active; --state= names others. Revoked and quarantined nodes were the
  admin's decision, so they are only taken when asked for. Nodes named
  by id are taken whatever their state; a server that is not an
  enrolled load balancer is reported, not enrolled.
- The same requirements as server:enrol, per node: the SSH host key
  must match the credential file's "hostkey" for the node, else the
  one stored at its install. A node with neither is never contacted
  (no trust on first use). The node must run this release, and its
  keys must match their SAS.
- Credentials: one owner-only JSON .cred file in bin/install/, read
  and deleted before the first connection. Its top level ({"u","p",
  "port"}, the same shape as server:install's file) is every node's
  default, and "nodes" overrides it per server id. Unknown keys and
  bad ports or host keys refuse the file. The SSH port is the node's
  entry, else the port its install used (bin/install/<id>.json), else
  the file's default, else 22.
- Continue on error: each node's failure is reported with its reason
  and the run goes on. A licence refusal stops the run instead,
  because every later node would have its agent stopped only to be
  refused the same way. An unlicensed extension refuses before any
  node is touched.
- --dry-run contacts no node, changes nothing and keeps the credential
  file. It shows each node's user, address, port and host key, and
  which nodes cannot be attempted.
- Output: each node's log under its own header, then one line per
  node and the counts. The exit code is 0 only when every chosen node
  was (or would be) re-enrolled. A real run is audited as
  cluster.reenrol with the ids that succeeded and failed.

MAIN only: stripped from the LB archive and listed in
verify-lb-archive's SENSITIVE paths. ClusterReenrolCommandTest covers
it against FakeSshFleet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQHeypQpfAXTdCysmi7b6a
2026-09-26 16:27:25 +00:00

566 lines
26 KiB
Makefile

PROJECT_NAME := $(notdir $(CURDIR))
TIMESTAMP := $(shell date +%s)
TEMP_DIR := /tmp/XC_VM-$(TIMESTAMP)
MAIN_DIR = ./src
DIST_DIR = ./dist
CONFIG_DIR := ./lb_configs
TEMP_ARCHIVE_NAME := $(TIMESTAMP).tar.gz
MAIN_ARCHIVE_NAME := xc_vm.tar.gz
MAIN_ARCHIVE_INSTALLER := XC_VM.zip
LB_ARCHIVE_NAME := loadbalancer.tar.gz
# Latest published release tag — consumed only by generate_deleted_files to diff
# against the previous release. Uses `?=` (lazy) so plain builds (new/main/lb)
# never evaluate it and thus never hit the network; the GitHub API is queried
# only when generate_deleted_files actually expands LAST_TAG. Override on the CLI
# to skip the API call entirely (CI passes it this way):
# make generate_deleted_files LAST_TAG=v1.2.3
LAST_TAG ?= $(shell curl -s https://api.github.com/repos/Vateron-Media/XC_VM/releases/latest | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/')
HASH_FILE := hashes.md5
# Directories and files to exclude from archives
EXCLUDES := \
.git
# Directories to copy from MAIN to LB
# NOTE: Modules/ is intentionally excluded — all modules are MAIN-only
# (e.g. the ministra portal, whose ~50 MB of assets must not ship to LB nodes).
LB_DIRS := bin Cli config content Core Domain\
Infrastructure Public signals Streaming tmp vendor
# Former LB_DIRS entries that no longer exist under src/. Nothing is copied from
# them; lb_delete_files_list keeps their git deletions in the LB list, so an
# update still removes them from LBs installed by older releases.
LB_RETIRED_DIRS := resources www
# Root-level files to copy from MAIN to LB (not inside directories)
LB_ROOT_FILES := bootstrap.php console.php service update
# Directories to remove from LB (admin-only content)
LB_DIRS_TO_REMOVE := \
bin/install \
bin/redis \
bin/cluster_bus \
bin/nginx/conf/codes \
Domain/User \
Domain/Device \
Domain/Cluster \
Public/cluster \
Public/Controllers/Admin \
Public/Controllers/Player \
Public/Controllers/PlayerV2 \
Public/Controllers/Reseller \
Public/Views \
Public/assets \
Public/routes \
Core/Reference \
Core/Localization/lang
# Files to remove from LB
# Every LB_DIRS / LB_ROOT_FILES / LB_DIRS_TO_REMOVE / LB_FILES_TO_REMOVE entry must
# name a tracked path under src/ of the right kind (a directory in the *DIRS*
# lists, a file in the *FILES* lists), and nothing that lb_configs/nginx.conf
# executes may be stripped — tools/ci/verify-lb-archive.sh (make gates) fails on a
# STALE or WRONG-LIST entry or a MISSING routed script.
# The viewer-API controllers (Player/Enigma2/XPlugin/Epg/PlaylistApiController,
# BaseApiController) still ship: lb_configs/nginx.conf routes /api/player_api etc.
# to Public/index.php. They go together with those routes in a later phase (§3
# of the MAIN <-> LB API communication design).
LB_FILES_TO_REMOVE := \
Public/admin/api.php \
Public/admin/proxy_api.php \
Public/stream/auth.php \
Public/stream/probe.php \
Public/Controllers/Api/AdminApiController.php \
Public/Controllers/Api/AdminAPIWrapper.php \
Public/Controllers/Api/ActiveCodeApiController.php \
Public/Controllers/Api/ResellerRestApiController.php \
Public/Controllers/Api/ResellerAPIWrapper.php \
Infrastructure/ResellerApiDispatcher.php \
Infrastructure/ResellerTableRenderer.php \
config/rclone.conf \
Cli/migration_logic.php \
Cli/Commands/MigrateCommand.php \
Cli/Commands/DbMigrateCommand.php \
Cli/Commands/CacheHandlerCommand.php \
Cli/Commands/ServerInstallCommand.php \
Cli/Commands/ClusterInitCommand.php \
Cli/Commands/ClusterExportKeysCommand.php \
Cli/Commands/ClusterImportKeysCommand.php \
Cli/Commands/ClusterPassphrase.php \
Cli/Commands/ClusterDbAllowlistCommand.php \
Cli/Commands/ClusterEnrolCodeCommand.php \
Cli/Commands/ClusterEnrolApproveCommand.php \
Cli/Commands/ClusterPoolsCommand.php \
Cli/Commands/ClusterNginxCommand.php \
Cli/Commands/AgentBinaryCommand.php \
Cli/Commands/ServerEnrolCommand.php \
Cli/Commands/ClusterReenrolCommand.php \
Cli/Commands/SshChannel.php \
Cli/Commands/SshSession.php \
Cli/Commands/ServerSyncOpensslExtraCommand.php \
Cli/Commands/LbInstallFlow.php \
Cli/Commands/ProxyInstallFlow.php \
Cli/CronJobs/RootMysqlCronJob.php \
Cli/CronJobs/BackupsCronJob.php \
Cli/CronJobs/CacheEngineCronJob.php \
Cli/CronJobs/EpgCronJob.php \
Cli/CronJobs/UpdateCronJob.php \
Cli/CronJobs/ProvidersCronJob.php \
Cli/CronJobs/SeriesCronJob.php \
Domain/Epg/EPG.php \
Core/Enum/Theme.php \
Core/Enum/ResellerAction.php \
Core/Enum/ClientFilter.php \
bin/nginx/conf/gzip.conf \
bin/nginx/conf/cluster_locations.conf
# Stripped trees that lb_delete_files_list must not delete from installed LBs yet.
# Fresh LBs never get them, but LB code on routes the LB nginx still serves calls
# them: Public/stream/rtmp.php (the RTMP on_play auth) and the viewer-API
# controllers use Domain/User. An older LB that still carries it keeps it until
# those routes go (§3 of the MAIN <-> LB API communication design).
LB_KEEP_ON_UPDATE := Domain/User
EXCLUDE_ARGS := $(addprefix --exclude=,$(EXCLUDES))
.PHONY: new lb main lb_copy_files main_copy_files set_permissions create_archive \
lb_archive_move main_archive_move main_install_archive clean \
verify_no_lfs_pointers \
lb_delete_files_list generate_deleted_files \
phpstan phpstan-baseline cs cs-fix check-procedural-use verify-lb-archive check-lb-settings-keys gates \
check-vendor-prod-only dev-tools dev-clean rector rector-fix
# ─── Dev tooling ────────────────────────────────────────────────
# The committed src/vendor/ is PRODUCTION-ONLY (composer install --no-dev). The
# dev tools below (PHPStan, phpcs) are require-dev packages — install them
# into src/vendor/ once with `make dev-tools` before running phpstan / cs. CI runs
# the equivalent `composer install` step itself. They are never committed (the
# committed vendor stays prod-only — see tools/ci/check-vendor-prod-only.sh).
dev-tools:
@cd src && composer install --no-interaction
# Inverse of dev-tools: once you no longer need the checks, remove the installed
# dev libraries (PHPStan, phpcs + transitive deps) and restore the
# production-only vendor/. `composer install --no-dev` prunes vendor/ back to the
# committed prod set, so `git status` stays clean and no dev package can be staged.
dev-clean:
@cd src && composer install --no-dev --no-interaction
@# composer rewrites installed.php/.json with the root package's current git
@# reference (HEAD sha), so they churn after every commit even though the prod
@# package set is identical — restore the committed copies to keep vendor clean.
@git checkout -- src/vendor/composer/installed.php src/vendor/composer/installed.json 2>/dev/null || true
# ─── Static analysis (PHPStan) ──────────────────────────────────
PHPSTAN := src/vendor/bin/phpstan
# Run the analysis using build/phpstan.dist.neon.
phpstan:
@test -x "$(PHPSTAN)" || { echo "PHPStan not found — run 'make dev-tools' (composer install) first."; exit 1; }
@php "$(PHPSTAN)" analyse -c build/phpstan.dist.neon --memory-limit=2G
# Dead-code audit (on demand, NOT a CI gate): reports unused PUBLIC methods /
# properties / constants via tomasvotruba/unused-public. Expect false positives
# for dynamically-invoked code (routes, #[ListensTo], commands) — verify by hand.
phpstan-deadcode:
@test -x "$(PHPSTAN)" || { echo "PHPStan not found — run 'make dev-tools' (composer install) first."; exit 1; }
@php "$(PHPSTAN)" analyse -c build/phpstan-deadcode.neon --memory-limit=2G
# Freeze all current errors into build/phpstan-baseline.neon (run after a level bump).
phpstan-baseline:
@test -x "$(PHPSTAN)" || { echo "PHPStan not found — run 'make dev-tools' (composer install) first."; exit 1; }
@php "$(PHPSTAN)" analyse -c build/phpstan.dist.neon --memory-limit=2G --generate-baseline=build/phpstan-baseline.neon
# Regenerate tools/phpstan/constants.stub.php from the current src/ define()s.
# Run when runtime-defined constants change so the stub PHPStan reads stays
# accurate (constants.stub.php is a bootstrapFile in build/phpstan.dist.neon).
phpstan-stub:
@php tools/phpstan/gen-constants-stub.php > tools/phpstan/constants.stub.php
@echo "regenerated tools/phpstan/constants.stub.php ($$(grep -c 'define(' tools/phpstan/constants.stub.php) constants)"
# ─── Code style (PHP_CodeSniffer + Slevomat) ────────────────────
# Narrow ruleset — import/namespace hygiene only (no PSR-12 reformat). See
# build/phpcs.xml.dist. Replaced PHP-CS-Fixer because Slevomat's UnusedUses is
# precise (it does not treat a class name that merely appears in a PHPDoc
# DESCRIPTION as "used", unlike PHP-CS-Fixer's no_unused_imports).
PHPCS := src/vendor/bin/phpcs
PHPCBF := src/vendor/bin/phpcbf
# View templates (`<?`/`<?=`) are excluded in the ruleset; analysed class files
# use full `<?php`, so no short_open_tag handling is needed.
PHPCS_FLAGS := --standard=build/phpcs.xml.dist --cache=build/.phpcs-cache src
# Check only — non-zero exit on any violation. Used in CI.
cs:
@test -x "$(PHPCS)" || { echo "phpcs not found — run 'make dev-tools' (composer install) first."; exit 1; }
@php "$(PHPCS)" $(PHPCS_FLAGS)
# Apply fixes in place (phpcbf).
cs-fix:
@test -x "$(PHPCBF)" || { echo "phpcbf not found — run 'make dev-tools' (composer install) first."; exit 1; }
@php "$(PHPCBF)" $(PHPCS_FLAGS)
# ─── Automated refactoring (Rector) ─────────────────────────────
# Rector is a require-dev tool (like phpstan/phpcs); config in build/rector.php.
# See docs/en/guides/refactoring.md. Always review a `rector` dry-run diff before
# `rector-fix`, then re-run cs-fix + phpstan + tests on the result.
RECTOR := src/vendor/bin/rector
# Dry-run — report what would change, never write. Non-zero exit when changes
# are pending, so it doubles as a CI check.
#
# --clear-cache is not optional here: Rector's cache key does not track the rule
# set, so after editing build/rector.php a cached run reports "Rector is done!"
# and a newly enabled set looks like it changes nothing.
rector:
@test -x "$(RECTOR)" || { echo "Rector not found — run 'make dev-tools' (composer install) first."; exit 1; }
@php "$(RECTOR)" process -c build/rector.php --dry-run --clear-cache
# Apply changes in place.
rector-fix:
@test -x "$(RECTOR)" || { echo "Rector not found — run 'make dev-tools' (composer install) first."; exit 1; }
@php "$(RECTOR)" process -c build/rector.php
# ─── PSR-4 regression gates ─────────────────────────────────────
# Helper: print a variable's resolved value (consumed by CI gate scripts).
print-%:
@echo '$($*)'
# Procedural/view files must import every migrated class they use with a
# top-of-file `use` (PHP `use` is positional outside the top scope).
check-procedural-use:
@php -d short_open_tag=1 tools/ci/check_procedural_use.php
# LB archive must never contain privileged code (security blocker 1).
verify-lb-archive:
@bash tools/ci/verify-lb-archive.sh
# Assert the committed src/vendor/ is production-only (no require-dev packages).
# Dev tools (PHPStan, phpcs) are installed locally via `composer install`
# and must never be committed. Checks git-tracked files, so it is correct even
# in a CI job that has already run `composer install`.
check-vendor-prod-only:
@bash tools/ci/check-vendor-prod-only.sh
# Run every fast PSR-4 gate.
# The settings a node replica may carry: what the LB build reads, never a secret.
check-lb-settings-keys:
@bash tools/ci/lb-settings-keys.sh
gates: check-procedural-use verify-lb-archive check-vendor-prod-only check-lb-settings-keys
# ─── Admin E2E (Playwright) ─────────────────────────────────────
# Browser smoke tests for the admin panel. Run against a LIVE instance:
# set XC_E2E_BASE_URL / XC_E2E_USER / XC_E2E_PASS (see tests/e2e/README.md).
.PHONY: e2e e2e-ui e2e-install
e2e-install:
cd tests/e2e && npm ci && npx playwright install --with-deps chromium
e2e:
cd tests/e2e && npx playwright test
e2e-ui:
cd tests/e2e && npx playwright test --ui
# ─── Generate deleted_files.txt from git diff ───────────────────
# Usage: make generate_deleted_files [LAST_TAG=v1.2.3]
# Writes to src/migrations/deleted_files.txt for manual review.
generate_deleted_files:
@if [ -z "$(LAST_TAG)" ]; then \
echo "[ERROR] LAST_TAG is empty — cannot detect latest release."; \
echo " Pass it manually: make generate_deleted_files LAST_TAG=v1.2.3"; \
exit 1; \
fi
@if ! git rev-parse "$(LAST_TAG)" >/dev/null 2>&1; then \
echo "[ERROR] Tag '$(LAST_TAG)' not found locally. Run: git fetch --tags"; \
exit 1; \
fi
@echo "[INFO] Generating deleted_files.txt: $(LAST_TAG)..HEAD"
@{ \
echo '# Files to delete during update (relative to MAIN_HOME)'; \
echo '# Auto-generated from: git diff $(LAST_TAG)..HEAD'; \
echo '# One file per line. Lines starting with # are comments.'; \
echo '#'; \
git diff --name-status --no-renames "$(LAST_TAG)"..HEAD -- src/ \
| awk '$$1 == "D" { sub(/^src\//, "", $$2); print $$2 }'; \
} > "$(MAIN_DIR)/migrations/deleted_files.txt"
@rCount=$$(grep -cv '^#\|^$$' "$(MAIN_DIR)/migrations/deleted_files.txt" 2>/dev/null || echo 0); \
echo "[INFO] Generated $(MAIN_DIR)/migrations/deleted_files.txt ($$rCount files)"; \
if [ "$$rCount" -gt 0 ]; then \
echo "[INFO] Files to delete:"; \
grep -v '^#' "$(MAIN_DIR)/migrations/deleted_files.txt" | grep -v '^$$'; \
else \
echo "[INFO] No deleted files detected since $(LAST_TAG)"; \
fi
# ─── MAIN targets ────────────────────────────────────────────────
# Single archive: used for both clean install and update.
# The update script (src/update) filters out excluded dirs at runtime.
main: main_copy_files stamp_release_id set_permissions verify_no_lfs_pointers create_archive main_archive_move main_install_archive clean
# ─── LoadBalancer targets ────────────────────────────────────────
lb: lb_copy_files lb_delete_files_list stamp_release_id set_permissions verify_no_lfs_pointers create_archive lb_archive_move clean
lb_copy_files:
@echo "==> [LB] Creating distribution directory: $(DIST_DIR)"
@mkdir -p ${DIST_DIR}
@echo "==> [LB] Creating temporary directory: $(TEMP_DIR)"
@mkdir -p ${TEMP_DIR}
@echo "==> [LB] Copying tracked directories from $(MAIN_DIR)"
@for lb_item in $(LB_DIRS); do \
printf " → Scanning: %s\n" "$$lb_item"; \
git ls-files | grep "^src/$$lb_item/" | while read -r file; do \
rel=$${file#src/}; \
printf " → Copying: %s\n" "$$file"; \
mkdir -p "$(TEMP_DIR)/$$(dirname $$rel)"; \
cp "$$file" "$(TEMP_DIR)/$$rel"; \
done; \
done
@echo "==> [LB] Copying root files from $(MAIN_DIR)"
@for root_file in $(LB_ROOT_FILES); do \
if git ls-files --error-unmatch "src/$$root_file" >/dev/null 2>&1; then \
cp "$(MAIN_DIR)/$$root_file" "$(TEMP_DIR)/$$root_file"; \
else \
printf " ⚠ Not tracked: %s\n" "$$root_file"; \
fi; \
done
@echo "==> [LB] Removing excluded directories"
@for dir in $(LB_DIRS_TO_REMOVE); do \
echo " → Removing directory: $$dir"; \
rm -rf "$(TEMP_DIR)/$$dir"; \
done
@echo "==> [LB] Removing excluded files"
@for file in $(LB_FILES_TO_REMOVE); do \
echo " → Removing file: $$file"; \
rm -f "$(TEMP_DIR)/$$file"; \
done
@echo "==> [LB] Copying config files"
cp "$(CONFIG_DIR)/nginx.conf" $(TEMP_DIR)/bin/nginx/conf/nginx.conf
cp "$(CONFIG_DIR)/live.conf" $(TEMP_DIR)/bin/nginx_rtmp/conf/live.conf
@echo "Remove all .gitkeep files..."
@find $(TEMP_DIR) -name .gitkeep \
-not -path "*/.git/*" \
-delete
@echo "All files gitkeep deleted"
main_copy_files:
@echo "==> [MAIN] Creating distribution directory: $(DIST_DIR)"
mkdir -p ${DIST_DIR}
@echo "==> [MAIN] Creating temporary directory: $(TEMP_DIR)"
mkdir -p $(TEMP_DIR)
@echo "==> [MAIN] Copying tracked files from $(MAIN_DIR)"
@git ls-files src | while read -r file; do \
rel=$${file#src/}; \
# printf " → Copying: %s\n" "$$file"; \
mkdir -p "$(TEMP_DIR)/$$(dirname $$rel)"; \
cp "$$file" "$(TEMP_DIR)/$$rel"; \
done
@echo "Remove all .gitkeep files..."
@find $(TEMP_DIR) -name .gitkeep \
-not -path "*/.git/*" \
-delete
@echo "All files gitkeep deleted"
# Stamp a unique per-build watermark into the staged tree for provenance / leak
# tracing. Generated per build (NOT git-tracked, one file per archive). Runtime
# exposes it as the XC_VM_BUILD_ID constant (ConstantsInitializer reads
# RELEASE_ID from the deploy root); a source/dev checkout has no file -> "dev".
stamp_release_id:
@ver=$$(sed -nE "s/.*define\('XC_VM_VERSION', '([^']+)'\).*/\1/p" $(MAIN_DIR)/Core/Config/ConstantsInitializer.php | head -n1); \
sha=$$(git rev-parse --short=10 HEAD 2>/dev/null || echo nogit); \
rnd=$$(od -An -N8 -tx1 /dev/urandom | tr -d ' \n'); \
id="$${ver:-0}+$${sha}.$$(date -u +%Y%m%dT%H%M%SZ).$${rnd}"; \
printf '%s\n' "$$id" > "$(TEMP_DIR)/RELEASE_ID"; \
echo "==> [BUILD] Stamped RELEASE_ID: $$id"
# LB-scoped deletions applied on update. An update extracts the archive over the
# installed tree, and MigrationRunner::runFileCleanup() deletes only the files
# that migrations/deleted_files.txt lists. The list is therefore the LB-scoped
# part of the release's git deletions plus every file the LB build strips
# (LB_FILES_TO_REMOVE and the tracked files under LB_DIRS_TO_REMOVE), so a file
# newly stripped from the LB archive also leaves the LBs that already have it.
# Stripped paths are taken from the code trees only, minus LB_KEEP_ON_UPDATE:
# bin/, config/ and content/ hold runtime and per-server files that an update
# must never delete. tools/ci/verify-lb-archive.sh fails if the list names a
# file the LB archive ships (DELETES-SHIPPED).
lb_delete_files_list:
@echo "[INFO] Building the LB deleted files list (git deletions + LB strip lists)"
@mkdir -p "$(TEMP_DIR)/migrations"
@{ \
if [ -f "$(MAIN_DIR)/migrations/deleted_files.txt" ]; then \
grep -v '^#' "$(MAIN_DIR)/migrations/deleted_files.txt" | grep -v '^$$' \
| awk -v dirs="$(LB_DIRS) $(LB_RETIRED_DIRS)" -v files="$(LB_ROOT_FILES)" ' \
BEGIN { n=split(dirs,d," "); m=split(files,f," ") } \
{ ok=0; for(i=1;i<=n;i++) if(index($$0,d[i]"/")==1){ok=1;break} \
if(!ok) for(i=1;i<=m;i++) if($$0==f[i]){ok=1;break} \
if(ok) print }'; \
fi; \
{ \
for f in $(LB_FILES_TO_REMOVE); do echo "$$f"; done; \
for d in $(LB_DIRS_TO_REMOVE); do git ls-files -- "src/$$d" | sed 's#^src/##'; done; \
} | grep -E '^(Cli|Core|Domain|Infrastructure|Public|Streaming)/' \
| awk -v keep="$(LB_KEEP_ON_UPDATE)" ' \
BEGIN { n=split(keep,k," ") } \
{ for(i=1;i<=n;i++) if($$0==k[i] || index($$0,k[i]"/")==1) next; print }'; \
} | sort -u > "$(TEMP_DIR)/migrations/deleted_files.txt"
@if [ -s "$(TEMP_DIR)/migrations/deleted_files.txt" ]; then \
echo "[INFO] LB files to delete on update: $$(grep -c . "$(TEMP_DIR)/migrations/deleted_files.txt")"; \
else \
echo "[INFO] No LB-scoped deleted files found"; \
rm -f "$(TEMP_DIR)/migrations/deleted_files.txt"; \
rmdir "$(TEMP_DIR)/migrations" 2>/dev/null || true; \
fi
set_permissions:
@echo "==> Setting file and directory permissions"
# Global defaults: directories 755, regular files 644
@find $(TEMP_DIR) -type d -exec chmod 755 {} +
@find $(TEMP_DIR) -type f -exec chmod 644 {} +
# Restricted root directories (750)
@for d in backups bin config content signals; do \
chmod 0750 "$(TEMP_DIR)/$$d" 2>/dev/null || true; \
done
@chmod 0770 $(TEMP_DIR)/content/streams 2>/dev/null || true
# Executable scripts
@chmod 0750 $(TEMP_DIR)/service 2>/dev/null || true
@chmod 0750 $(TEMP_DIR)/update 2>/dev/null || true
@chmod 0750 $(TEMP_DIR)/bin/daemons.sh 2>/dev/null || true
@chmod 0755 $(TEMP_DIR)/bin/xc_fanout/run.sh 2>/dev/null || true
@chmod 0755 $(TEMP_DIR)/console.php 2>/dev/null || true
@chmod 0755 $(TEMP_DIR)/bin/guess 2>/dev/null || true
@chmod 0755 $(TEMP_DIR)/bin/yt-dlp 2>/dev/null || true
@chmod 0550 $(TEMP_DIR)/bin/network 2>/dev/null || true
@chmod 0550 $(TEMP_DIR)/bin/network.py 2>/dev/null || true
# FFmpeg executables
@find $(TEMP_DIR)/bin/ffmpeg_bin -type f \( -name 'ffmpeg' -o -name 'ffprobe' \) \
-exec chmod 0551 {} + 2>/dev/null || true
# Nginx binaries
@find $(TEMP_DIR)/bin/nginx -type d -exec chmod 750 {} + 2>/dev/null || true
@find $(TEMP_DIR)/bin/nginx -type f -exec chmod 550 {} + 2>/dev/null || true
@chmod 0755 $(TEMP_DIR)/bin/nginx/conf 2>/dev/null || true
@chmod 0600 $(TEMP_DIR)/bin/nginx/conf/server.key 2>/dev/null || true
@chmod 0750 $(TEMP_DIR)/bin/nginx_rtmp/sbin/nginx_rtmp 2>/dev/null || true
# PHP binaries
@find $(TEMP_DIR)/bin/php -type d -exec chmod 750 {} + 2>/dev/null || true
@find $(TEMP_DIR)/bin/php -type f -exec chmod 550 {} + 2>/dev/null || true
@for conf in 1.conf 2.conf 3.conf 4.conf; do \
chmod 0644 "$(TEMP_DIR)/bin/php/etc/$$conf" 2>/dev/null || true; \
done
@chmod 0551 $(TEMP_DIR)/bin/php/bin/php 2>/dev/null || true
@chmod 0551 $(TEMP_DIR)/bin/php/sbin/php-fpm 2>/dev/null || true
# Redis executable
@chmod 0755 $(TEMP_DIR)/bin/redis/redis-server 2>/dev/null || true
# Sensitive config files
@chmod 0640 $(TEMP_DIR)/config/modules.php 2>/dev/null || true
@chmod 0550 $(TEMP_DIR)/config/rclone.conf 2>/dev/null || true
# Fail the build if any staged file is still a Git LFS pointer instead of the
# real binary. This happens when the checkout did not materialise LFS objects
# (e.g. `actions/checkout` without `lfs: true`), which would otherwise ship
# 130-byte text stubs in place of ffmpeg, redis, yt-dlp, etc.
verify_no_lfs_pointers:
@echo "==> Verifying no Git LFS pointer files leaked into $(TEMP_DIR)"
@pointers=$$(grep -rlI '^version https://git-lfs.github.com/spec/v1' "$(TEMP_DIR)" 2>/dev/null || true); \
if [ -n "$$pointers" ]; then \
echo "ERROR: Git LFS pointer files found in the staged tree:"; \
echo "$$pointers" | sed 's|^$(TEMP_DIR)/| - |'; \
echo "The checkout did not fetch LFS objects. Run 'git lfs pull' or set 'lfs: true' on the CI checkout."; \
exit 1; \
fi; \
echo "OK: no LFS pointers staged"
create_archive:
@echo "==> Creating final archive: ${TEMP_ARCHIVE_NAME}"
@tar -czf ${DIST_DIR}/${TEMP_ARCHIVE_NAME} -C $(TEMP_DIR) .
lb_archive_move:
@echo "==> Moving LB archive to: ${DIST_DIR}/${LB_ARCHIVE_NAME}"
@rm -f ${DIST_DIR}/${LB_ARCHIVE_NAME}
@mv ${DIST_DIR}/${TEMP_ARCHIVE_NAME} ${DIST_DIR}/${LB_ARCHIVE_NAME}
md5sum "${DIST_DIR}/${LB_ARCHIVE_NAME}" | awk -v name="${LB_ARCHIVE_NAME}" '{print $$1, name}' >> "${DIST_DIR}/${HASH_FILE}"
main_archive_move:
@echo "==> Moving MAIN archive to: ${DIST_DIR}/${MAIN_ARCHIVE_NAME}"
@rm -f ${DIST_DIR}/${MAIN_ARCHIVE_NAME}
@mv ${DIST_DIR}/${TEMP_ARCHIVE_NAME} ${DIST_DIR}/${MAIN_ARCHIVE_NAME}
md5sum "${DIST_DIR}/${MAIN_ARCHIVE_NAME}" | awk -v name="${MAIN_ARCHIVE_NAME}" '{print $$1, name}' >> "${DIST_DIR}/${HASH_FILE}"
main_install_archive:
@echo "==> Creating installer archive: ${DIST_DIR}/${MAIN_ARCHIVE_INSTALLER}"
@rm -f ${DIST_DIR}/${MAIN_ARCHIVE_INSTALLER}
@zip -r ${DIST_DIR}/${MAIN_ARCHIVE_INSTALLER} install && zip -j ${DIST_DIR}/${MAIN_ARCHIVE_INSTALLER} ${DIST_DIR}/${MAIN_ARCHIVE_NAME}
md5sum "${DIST_DIR}/${MAIN_ARCHIVE_INSTALLER}" | awk -v name="${MAIN_ARCHIVE_INSTALLER}" '{print $$1, name}' >> "${DIST_DIR}/${HASH_FILE}"
clean:
@echo "==> Cleaning up temporary directory: $(TEMP_DIR)"
@rm -rf $(TEMP_DIR)
@echo "✅ Project build complete"
new:
@echo "==> Cleaning up distribution directory: $(DIST_DIR)"
@rm -rf $(DIST_DIR)
@echo "==> Creating distribution directory: $(DIST_DIR)"
@mkdir -p ${DIST_DIR}
# ─── Documentation (MkDocs Material + auto-translated ru) ────────────
# EDIT ENGLISH ONLY (docs/en). docs/ru is a GENERATED tree that is committed and
# refreshed by `make docs-translate` LOCALLY before a release — translation is
# deliberately kept out of CI (it is slow). CI (pages.yml) only builds the
# already-committed, already-translated tree. Never hand-edit docs/ru.
#
# make docs-venv # one-time: local venv (build + translation deps)
# make docs-serve # live preview at :8000 (builds committed en+ru)
# make docs-build # strict static build into ./build/site (what CI runs)
# make docs-translate # release step: (re)generate docs/ru from docs/en, then commit
#
# Translation engine via DOCS_TRANSLATE_PROVIDER (default: translators = free, no key):
# make docs-translate # free web engines (yandex/...)
# make docs-translate DOCS_TRANSLATE_PROVIDER=anthropic # needs ANTHROPIC_API_KEY
# make docs-translate DOCS_TRANSLATE_PROVIDER=noop # copy en (fast dry-run)
#
# Panel language files (src/Core/Localization/lang/*.ini) use the same script:
# make lang-translate # every <lang>.ini: translate keys missing vs
# # en.ini, drop keys en.ini no longer has
# make lang-translate LANG_TRANSLATE=ru
DOCS_VENV := build/docs-venv
DOCS_PY := $(DOCS_VENV)/bin/python
DOCS_TRANSLATE_PROVIDER ?= translators
LANG_TRANSLATE ?= all
.PHONY: docs-venv docs-translate docs-build docs-serve lang-translate
# The venv's mkdocs binary doubles as the install stamp (built once). Installs
# both the build toolchain and the (local-only) translation deps.
$(DOCS_VENV)/bin/mkdocs:
@python3 -m venv $(DOCS_VENV)
@$(DOCS_VENV)/bin/pip install -q --upgrade pip
@$(DOCS_VENV)/bin/pip install -q -r docs/requirements.txt -r tools/i18n/requirements.txt
docs-venv: $(DOCS_VENV)/bin/mkdocs
# Release-time step: regenerate the committed docs/ru from docs/en. Commit the
# result with the release. NOT part of docs-build (CI builds the committed tree).
docs-translate: $(DOCS_VENV)/bin/mkdocs
@DOCS_TRANSLATE_PROVIDER=$(DOCS_TRANSLATE_PROVIDER) $(DOCS_PY) -u tools/i18n/translate.py --lang ru
lang-translate: $(DOCS_VENV)/bin/mkdocs
@DOCS_TRANSLATE_PROVIDER=$(DOCS_TRANSLATE_PROVIDER) $(DOCS_PY) -u tools/i18n/translate.py --ini --lang $(LANG_TRANSLATE)
docs-build: $(DOCS_VENV)/bin/mkdocs
@$(DOCS_PY) -m mkdocs build --strict
docs-serve: $(DOCS_VENV)/bin/mkdocs
@$(DOCS_PY) -m mkdocs serve