fix: cyrillic fonts, dev certs, page head, and ru-text typography (#67)
🤖 PR generated for branch `fix/cyrillic-fonts-dev-certs-and-page-head`.
### Changes Summary
- **Fonts**: Add Cyrillic glyph subsets for Lato, EB Garamond, and Fira
Code.
- **Dev**: Support remote Root CA and encrypted dev CA key in
`.env.dev`.
- **UI & Kits**: Add size variant to `Breadcrumbs` component and enlarge
`PageHead` breadcrumbs with tuned collapse scroll threshold.
- **ESLint**: Add `no-hanging-particles` i18n & markdown typography rule
with auto-fixer.
- **Content & i18n**: Apply `ru-text` editorial and typography polish
(em dashes, number ranges, «ё» restoration, non-breaking spaces) across
`services/www` and `services/docs`.
This commit is contained in:
1 parent
c6487dc258
commit
b3638f4d63
111 files changed
+2350
-1512
No files matched your search
@@ -2,6 +2,13 @@
|
||||
#/ public-key encryption for .env files /
|
||||
#/ [how it works](https://dotenvx.com/encryption) /
|
||||
#/----------------------------------------------------------/
|
||||
DOTENV_PUBLIC_KEY_DEV="02a9d0f6522086b04ac7763e008a6ad0273c58258715e613c977fd6c82a2030d92"
|
||||
|
||||
# .env.dev
|
||||
#/-------------------[DOTENV_PUBLIC_KEY]--------------------/
|
||||
#/ public-key encryption for .env files /
|
||||
#/ [how it works](https://dotenvx.com/encryption) /
|
||||
#/----------------------------------------------------------/
|
||||
DOTENV_PUBLIC_KEY="02a9d0f6522086b04ac7763e008a6ad0273c58258715e613c977fd6c82a2030d92"
|
||||
|
||||
# .env
|
||||
@@ -11,3 +18,4 @@ COM_ERMNVLDMR_STATIC_S3_ENDPOINT_URL="encrypted:BNkQrgsrxtz544vuJbgwWUh5cPjHG5wb
|
||||
COM_ERMNVLDMR_STATIC_AWS_ACCESS_KEY_ID="encrypted:BCsp8p4bp3cB+9grxHo634isRUaFGU3MOc1lr8ClSFRYLjqjqukyrbJCTZWV2Xdo8iQ33EylmbzUltbwzTWMQcjJSvIskesVLyV93q60o/Mu9L9jg/apwHuBgHv4zVuHstJ0zkHWgapbNTIlWD3RCm0ujeX/H1gBH3U="
|
||||
COM_ERMNVLDMR_STATIC_AWS_SECRET_ACCESS_KEY="encrypted:BM7aPliOyiPrqFPZMfqpn534Nu+Y0Nbh/YRoxv2KY6XAi2dNO0LSEf97DqSUQYdsnu9iibZmVNktF2K/D6mtHr9+gumF6KsmaLH65KE7c9ly7NdFQHL9I26wxRq1Sr3Zgrfs954J6oURSlBRKqJFdcTsBltuuupGuqJHyLS9+DE0jD+bl0Qg0lA="
|
||||
SOPS_AGE_KEY="encrypted:BKiJVxKYhiadu0kNCw8wVLYdaEWZreAVu2PaujxhvcXeCowCGjivEuQD/uhduAy/CwwDWyQkgQJ2FJMEKA47e7QDjS9TMdPCXO19/teR7LThgqTf6AbSM3sebln5zalgyplay4A/7cVytPXuOvVkaYyoh9CgzTVsPJKXRq5yMaDEx/9/91Yo8zUMF3z1vq3/preI5baK4/1xXewR3OwfZqnNGyd7LXkdqyT5"
|
||||
COM_ERMNVLDMR_DEV_ROOT_CA_KEY="encrypted:BP+GhCwutIMF2hSxIs92VNSoEAvfdhYRFSi5MUpFfCs+pNxJkZ7tFOAadMqSlPiLBcm9+A3yIn/04/oEtRNjXk/jlZQ2G21mt30sd89NARjJO+94ui6XbllW65E5/T+d8fDmRhz4c4OcNoKk70aXcdJqd1CJT7IlYQn++zQvB2el9cp9e7E22rvGtvum40iISEt/pVU84uHyQg6ZeCn+1czM+nLhPiWOAgveTz3EAgExlJW2de1ilDIuBCFXJy5+KvnCj3WNwRTAbIYgCwFnZMOUE5jYlhP6OZ2DQ3sWv7noG/Xaa/P+EomP9dwcamlAU32tRruyA8ZSp/ah9E8hFfmBqgKfSbRlWq3qGkq/F935DZfU/C+PjRcepS/eLZQ0mfD23B21VX2CjOhEkp5V+9I5L8XkXDjDg56JDRGvF/umpdqF/KpmtimqcgB0yDVY1Z3yT1U5IZ6Yf4WlqzhchdJXRWfo0bqK8Tk0b442/6AQgRP3n6DIrCjcpmr4jx+RJiejOr3xv3QtHGafDdkF2gO7DrYztdd6XpRjij0orBUiyYECgPsLHuwd+/2jXnxLVTavVTQ0Ddz0LLNjaYxqr8KK/B+0MeS/iuLXmACs8XCKwQmV8hPYQ0C/JXGiL4GOH0V70r6ghBxCDDMpJjPasCbiCBxSAMLkQ7ZmLbO0WVPPUTMAsZ8aKUy2MLe466zizXN640B4H6bkI7x/KC29sroEJWpUufD7f+t2tnVx/KLeAxAC6afuUCGi4ibFiwLHZjfimYKaYpuMcSZHQ64iONixyfUDIRNIOWQSiX1V5EBJgBDE0JVDnExmQYAIG5isYQEorC/7z6IJeZHc4NuiKDWuvd+jwwj8tN8PVAcACJKpQHpyUU20kvp4dYgBEeiQZpBfXWrnmOJZ0HgDHbvVxPW+SHTL/uoi/Tl9xdKe+z3Q/MoT8iYXggMPcFQIqVemQUTw9Tu0oNsSj3xueR9awJc2Le9aYbKTg54MAtcBnfKXN+3cYQkNVbqC5RfiAe2D3gfbT1OgApTUNz8n8v1dgAzTa+EB0i+w8nTsb3N/N4yAXMNSKdGeUl2rFRuQO7qlWorKc3pE+nBC2ex0sOMEUFYUXcyULaxsKM3ddakCAv9/jesnkrZl2gX7YDp8iqiFzh7l8jMAFHtFCMp3CSXlBAOpfQlGLY80qTSi1e8GrnV9Ow9t0DdS1wP7J6ES9DiNuEep5atHD+0MC6u0Dw0GtmsNnNRp77my/KI8ILhF4VBVBFQ5BiJDfdpAA3RNlANo2gK5ZCKPw4tXCYUknjYdwkqvUaIQCJcr2n+WkE2eCUCqjXTFr/KMvRByY6Fb1vAwcGoCWjP2g0Tliz3hcyh/72+umn05NO3cJSAfDEjfcYBUQZ/LCcy86G0EsHXP3NabgeHNXf/Z/VG7zWqQAPX0KaVrF9XC6I2FLkg1aNGWZfJGv4uohdIny7rWU7FWPaxy9GMk6x7xBh/Hk/5/rrGHCNGs+SegLglDs2+v4P56316k4kg//dmVl/vh3h0KC2P/1xiJZ10I91k7HqZLb2qdXToa4yOHB5Euu49UV/Qtv+cZU03fuT81sEv3YT819OdmwUZgkq0PT/Lrck+pbNDaUOakpgkGAbXy6SCvjM87jYN9Ia7druulWWO0pPi4T8daQ26ZQmZp57gdkpuh/He4kC5m1aWpT2s5STSbapChd9tFNjPhjCG1DZb7h0X8jg1VkxqutDDu17jOXrhElT1ejvYqYy9lIG1b1r7Jmjxo7LgGQuPMX6CIo/BLTsuNJyC5SfSCxEm56LvJgoNk2Vaeq6mLDOaKm2PNwPuYPnJw9xWBM0EetJBnsYMutPDMXhrB7x/8DKy5/hCsKICTfhlsboUHmbSfsikvGPYx7YOaOo8xFh4yAQ73T8hFX7kn2DBfRzGkJm8SQJRPj0Hjsf5mEFUt/OrieK/Jw10HKD6hDoEFmUDv5svC3nUY0FX0fTyi0jRAri+burrCQ0HXk/qj4Xs+WLiJgfQbprV2TwTIlzOdLb/jD8PeiElNzBxmOonRj76QKcbkOnPkCWCMFKapOJskxe0ASJVjYt142D0ndTa6F7oFu72ue5U+hyoToKeb7ONZilhF0+yL7akNfFKURUa/X+cEoewuSM7r8whWwZyG+M2cwGUTRMJiAC9wK7yE8MqdrEuQtILnCA0yRTPOh1Z9ifUY0asL1Tw9cbdGRW8svHH/oVHkWe3P+yepTNrn7xQlX/O1/p4FXRm0gSE3Fdnbli8VZdwVn2SET3WkPH9rJT2F7/hovfhx+lQzBQq9ZBpNLrWRdhXzCpiaC04o7q/eef+8dZwU62A1pd8uZ+0dI5T7lOVXoo03uF863YSOKX8ad2CiYUJ8gMvMA8FSWnsUbxiDaiXPj8kni5TLsvrkH/CKY/okG5SdRCF/XCcRcXP5dxdsS90tIWdd0xi/2wbalmn3CPQ2eVeDI3dP/jCRBkfiNtD70flhxVngZMZW+Rr1c0Cpo//fMCiV5ksDIBGin5AfOPmvSnPYWV5zcU3QEfl2F5fLXgDCbpaYjaOYjyRt9ZTqabSf1Ar4L9yQFBr4N9cNg4jKskvbha4BDmUL8J7xz/F/OXHFnEDV2YkMcpBfGM2XhKDevceOQcjrPkF5/e5LdwQeYhqgzzm6ur42OwHB2HBU7ulXn/LCk2eYCFa2D1f+2F3ExbwAvnDMhymdQdUniF/NzOfG/1L3yplFsqi270T/2gT1LdOQ/Fgp3FA81k/sMLbICcF/qCp/nS3x+iMZn9B+WLjUQvJy2PehXNcpXEvXWM/e1X4PWDUhwQi7+wjJwERX2W5SB1XjQa7n/osZqpOUG2ShssPNpS3mecaV+MTpRPVYTPFiDTIgkG7F/DSND0c2PDWsBLFus0h4jG70L94uDxVNiMJb/xq/7d2+7FMNPh9UJSK75XcKDhrRYJo3iU7mfl1U89vJB179ulGrdMpWd8v5DHWEDrn3NRBa0KWVYKpqeQ0195XXH1YlZjzK7615K/5ooOGUL42PlX2Hw1zxOJubsIl6LZt8V6wa6zJgywuVLsZb+kpjtHTHmVZyhY8IUnAn6Uj8erYXapvZ8rlhUpjvVuosn8gB9JM6vNct5dVn4e8Wbt6KnRcogHYUJT32QRSWsHCaZNEqwbX+dYaLx1FQZ5daYbXSWwiHhtAJRuYutbmWwWCRTVzyNNMh3sMAI1EcmMoOIX0DEhXYNe7daErR6vMyizQ+h5YKUYK4nUu270M0x/YKRHlxwK8WkEqWv8vfhZqzV0YGud1Hwd5JwWhIEZQ7cc1uPeYJJ5kAaIFqCffmwecS2gzVvOgDkNC5eENXSk7SzDpVgm8IcfZV47L17ni75tyJCS/kreODjap6VdpDNTyS1YJaaO2TMX/s4kWiEZ22Ma4CEv2eQq1uGazGamli+ZwExAtehw=="
|
||||
@@ -126,7 +126,7 @@ jobs:
|
||||
ENDPOINT_FLAG=""
|
||||
[ -n "${S3_ENDPOINT}" ] && ENDPOINT_FLAG="--endpoint-url=${S3_ENDPOINT}"
|
||||
echo "Cleaning up older images in dev/images/ to free bucket storage..."
|
||||
aws ${ENDPOINT_FLAG} s3 rm "s3://${S3_BUCKET}/dev/images/" --recursive || true
|
||||
aws ${ENDPOINT_FLAG} s3 rm "s3://${S3_BUCKET}/dev/images/" --recursive --exclude "*" --include "com-ermnvldmr-debian-12-k3s-*.qcow2" || true
|
||||
aws ${ENDPOINT_FLAG} s3 cp "output-qemu/${IMAGE_NAME}" "s3://${S3_BUCKET}/dev/images/${IMAGE_NAME}"
|
||||
echo "Golden image uploaded to s3://${S3_BUCKET}/dev/images/${IMAGE_NAME}"
|
||||
else
|
||||
|
||||
+139
-115
@@ -11,6 +11,7 @@ import {
|
||||
import { homedir, tmpdir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { createRequire } from 'node:module';
|
||||
|
||||
/**
|
||||
* Checks whether a hostname entry exists in the specified hosts file.
|
||||
@@ -113,6 +114,56 @@ export function findWorkspaceRoot(startDir = dirname(fileURLToPath(import.meta.u
|
||||
return process.cwd();
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the development Root CA private key from the environment or .env.dev.
|
||||
*
|
||||
* @param {string} workspaceRoot - Absolute path to workspace root
|
||||
* @returns {string | undefined} The PEM private key string, or undefined if unavailable
|
||||
*/
|
||||
export function loadDevRootCaKey(workspaceRoot = findWorkspaceRoot()) {
|
||||
if (process.env.COM_ERMNVLDMR_DEV_ROOT_CA_KEY) {
|
||||
const raw = process.env.COM_ERMNVLDMR_DEV_ROOT_CA_KEY;
|
||||
return raw.includes('\\n') ? raw.replace(/\\n/g, '\n') : raw;
|
||||
}
|
||||
|
||||
const envDevPath = join(workspaceRoot, '.env.dev');
|
||||
const envKeysPath = join(workspaceRoot, '.env.keys');
|
||||
|
||||
if (!existsSync(envDevPath)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const candidatePackages = [
|
||||
join(workspaceRoot, 'packages/cli/package.json'),
|
||||
join(workspaceRoot, 'package.json'),
|
||||
];
|
||||
|
||||
for (const pkgPath of candidatePackages) {
|
||||
if (!existsSync(pkgPath)) continue;
|
||||
try {
|
||||
const req = createRequire(pkgPath);
|
||||
const dotenvx = req('@dotenvx/dotenvx');
|
||||
if (dotenvx && typeof dotenvx.config === 'function') {
|
||||
dotenvx.config({ path: envDevPath, envKeysFile: envKeysPath });
|
||||
const key = process.env.COM_ERMNVLDMR_DEV_ROOT_CA_KEY;
|
||||
if (key) {
|
||||
return key.includes('\\n') ? key.replace(/\\n/g, '\n') : key;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Continue to next candidate package
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remote public URL for the Ermnvldmr development root CA certificate.
|
||||
*/
|
||||
export const REMOTE_ROOT_CA_URL =
|
||||
'https://static.ermnvldmr.com/ssl/certs/ErmnvldmrDevelopmentRootCA.pem';
|
||||
|
||||
/**
|
||||
* Ensures valid development certificates exist for localhost.ermnvldmr.com.
|
||||
*
|
||||
@@ -123,128 +174,101 @@ export function ensureDevCertificates(workspaceRoot = findWorkspaceRoot()) {
|
||||
const certsDir = join(workspaceRoot, '.certs');
|
||||
const certPath = join(certsDir, 'localhost.ermnvldmr.com.pem');
|
||||
const keyPath = join(certsDir, 'localhost.ermnvldmr.com-key.pem');
|
||||
const caCertPath = join(certsDir, 'ErmnvldmrDevelopmentRootCA.pem');
|
||||
const caKeyPath = join(certsDir, 'ErmnvldmrDevelopmentRootCA-key.pem');
|
||||
|
||||
ensureHostsEntry('localhost.ermnvldmr.com', '127.0.0.1');
|
||||
|
||||
if (existsSync(certPath) && existsSync(keyPath)) {
|
||||
return { certPath, keyPath };
|
||||
}
|
||||
const home = homedir();
|
||||
const globalCaDir = join(home, '.local/share/mkcert');
|
||||
const globalCaCert = join(globalCaDir, 'ErmnvldmrDevelopmentRootCA.pem');
|
||||
const globalCaKey = join(globalCaDir, 'ErmnvldmrDevelopmentRootCA-key.pem');
|
||||
|
||||
mkdirSync(certsDir, { recursive: true, mode: 0o700 });
|
||||
|
||||
const home = homedir();
|
||||
const hugoCaCert = join(home, '.local/share/mkcert/rootCA.pem');
|
||||
const hugoCaKey = join(home, '.local/share/mkcert/rootCA-key.pem');
|
||||
|
||||
let hasMkcertCli = false;
|
||||
try {
|
||||
execFileSync('mkcert', ['-help'], { stdio: 'ignore' });
|
||||
hasMkcertCli = true;
|
||||
} catch {
|
||||
hasMkcertCli = false;
|
||||
}
|
||||
|
||||
if (hasMkcertCli) {
|
||||
execFileSync(
|
||||
'mkcert',
|
||||
[
|
||||
'-cert-file',
|
||||
certPath,
|
||||
'-key-file',
|
||||
keyPath,
|
||||
'localhost.ermnvldmr.com',
|
||||
'*.localhost.ermnvldmr.com',
|
||||
'localhost',
|
||||
'127.0.0.1',
|
||||
'::1',
|
||||
],
|
||||
{ stdio: 'inherit' }
|
||||
);
|
||||
chmodSync(keyPath, 0o600);
|
||||
return { certPath, keyPath };
|
||||
}
|
||||
|
||||
if (existsSync(hugoCaCert) && existsSync(hugoCaKey)) {
|
||||
const tempDir = tmpdir();
|
||||
const tempCsr = join(tempDir, `ermnvldmr-dev-${Date.now()}.csr`);
|
||||
const tempExt = join(tempDir, `ermnvldmr-dev-${Date.now()}.cnf`);
|
||||
|
||||
try {
|
||||
writeFileSync(
|
||||
tempExt,
|
||||
'subjectAltName=DNS:localhost.ermnvldmr.com,DNS:*.localhost.ermnvldmr.com,DNS:localhost,IP:127.0.0.1,IP:::1\n'
|
||||
);
|
||||
execFileSync(
|
||||
'openssl',
|
||||
[
|
||||
'req',
|
||||
'-new',
|
||||
'-nodes',
|
||||
'-newkey',
|
||||
'rsa:2048',
|
||||
'-keyout',
|
||||
keyPath,
|
||||
'-out',
|
||||
tempCsr,
|
||||
'-subj',
|
||||
'/CN=localhost.ermnvldmr.com',
|
||||
'-batch',
|
||||
],
|
||||
{ stdio: 'pipe' }
|
||||
);
|
||||
execFileSync(
|
||||
'openssl',
|
||||
[
|
||||
'x509',
|
||||
'-req',
|
||||
'-in',
|
||||
tempCsr,
|
||||
'-CA',
|
||||
hugoCaCert,
|
||||
'-CAkey',
|
||||
hugoCaKey,
|
||||
'-CAcreateserial',
|
||||
'-out',
|
||||
certPath,
|
||||
'-days',
|
||||
'825',
|
||||
'-extfile',
|
||||
tempExt,
|
||||
],
|
||||
{ stdio: 'pipe' }
|
||||
);
|
||||
chmodSync(keyPath, 0o600);
|
||||
chmodSync(certPath, 0o644);
|
||||
} finally {
|
||||
rmSync(tempCsr, { force: true });
|
||||
rmSync(tempExt, { force: true });
|
||||
// 1. Sync CA cert from global cache if available, or download from remote static URL
|
||||
if (!existsSync(caCertPath)) {
|
||||
if (existsSync(globalCaCert)) {
|
||||
writeFileSync(caCertPath, readFileSync(globalCaCert));
|
||||
chmodSync(caCertPath, 0o644);
|
||||
} else {
|
||||
try {
|
||||
execFileSync('curl', ['-sSfL', REMOTE_ROOT_CA_URL, '-o', caCertPath], {
|
||||
stdio: 'ignore',
|
||||
});
|
||||
chmodSync(caCertPath, 0o644);
|
||||
try {
|
||||
mkdirSync(globalCaDir, { recursive: true });
|
||||
writeFileSync(globalCaCert, readFileSync(caCertPath));
|
||||
chmodSync(globalCaCert, 0o644);
|
||||
} catch {
|
||||
// Best-effort global cache
|
||||
}
|
||||
} catch {
|
||||
// Failed to download, will fall back to generation if key also needed
|
||||
}
|
||||
}
|
||||
return { certPath, keyPath };
|
||||
}
|
||||
|
||||
const caDir = join(home, '.local/share/mkcert');
|
||||
mkdirSync(caDir, { recursive: true });
|
||||
execFileSync(
|
||||
'openssl',
|
||||
[
|
||||
'req',
|
||||
'-x509',
|
||||
'-nodes',
|
||||
'-newkey',
|
||||
'rsa:3072',
|
||||
'-keyout',
|
||||
hugoCaKey,
|
||||
'-out',
|
||||
hugoCaCert,
|
||||
'-days',
|
||||
'3650',
|
||||
'-subj',
|
||||
'/CN=mkcert development CA/O=mkcert development CA',
|
||||
'-batch',
|
||||
],
|
||||
{ stdio: 'pipe' }
|
||||
);
|
||||
// 2. Sync CA key from global cache if available, or decrypt from .env.dev
|
||||
if (!existsSync(caKeyPath)) {
|
||||
if (existsSync(globalCaKey)) {
|
||||
writeFileSync(caKeyPath, readFileSync(globalCaKey));
|
||||
chmodSync(caKeyPath, 0o600);
|
||||
} else {
|
||||
const decryptedKey = loadDevRootCaKey(workspaceRoot);
|
||||
if (decryptedKey) {
|
||||
writeFileSync(caKeyPath, decryptedKey);
|
||||
chmodSync(caKeyPath, 0o600);
|
||||
try {
|
||||
mkdirSync(globalCaDir, { recursive: true });
|
||||
writeFileSync(globalCaKey, decryptedKey);
|
||||
chmodSync(globalCaKey, 0o600);
|
||||
} catch {
|
||||
// Best-effort global cache
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If server cert and CA cert already exist, return them
|
||||
if (existsSync(certPath) && existsSync(keyPath) && existsSync(caCertPath)) {
|
||||
return { certPath, keyPath, caCertPath };
|
||||
}
|
||||
|
||||
// Ensure ErmnvldmrDevelopmentRootCA exists
|
||||
if (!existsSync(caCertPath) || !existsSync(caKeyPath)) {
|
||||
mkdirSync(globalCaDir, { recursive: true });
|
||||
execFileSync(
|
||||
'openssl',
|
||||
[
|
||||
'req',
|
||||
'-x509',
|
||||
'-nodes',
|
||||
'-newkey',
|
||||
'rsa:3072',
|
||||
'-keyout',
|
||||
caKeyPath,
|
||||
'-out',
|
||||
caCertPath,
|
||||
'-days',
|
||||
'3650',
|
||||
'-subj',
|
||||
'/CN=Ermnvldmr Development Root CA/O=ermnvldmr.com',
|
||||
'-batch',
|
||||
],
|
||||
{ stdio: 'pipe' }
|
||||
);
|
||||
chmodSync(caKeyPath, 0o600);
|
||||
chmodSync(caCertPath, 0o644);
|
||||
|
||||
writeFileSync(globalCaCert, readFileSync(caCertPath));
|
||||
writeFileSync(globalCaKey, readFileSync(caKeyPath));
|
||||
chmodSync(globalCaKey, 0o600);
|
||||
chmodSync(globalCaCert, 0o644);
|
||||
}
|
||||
|
||||
// Generate server certificate signed by ErmnvldmrDevelopmentRootCA
|
||||
const tempDir = tmpdir();
|
||||
const tempCsr = join(tempDir, `ermnvldmr-dev-${Date.now()}.csr`);
|
||||
const tempExt = join(tempDir, `ermnvldmr-dev-${Date.now()}.cnf`);
|
||||
@@ -280,9 +304,9 @@ export function ensureDevCertificates(workspaceRoot = findWorkspaceRoot()) {
|
||||
'-in',
|
||||
tempCsr,
|
||||
'-CA',
|
||||
hugoCaCert,
|
||||
caCertPath,
|
||||
'-CAkey',
|
||||
hugoCaKey,
|
||||
caKeyPath,
|
||||
'-CAcreateserial',
|
||||
'-out',
|
||||
certPath,
|
||||
@@ -300,7 +324,7 @@ export function ensureDevCertificates(workspaceRoot = findWorkspaceRoot()) {
|
||||
rmSync(tempExt, { force: true });
|
||||
}
|
||||
|
||||
return { certPath, keyPath };
|
||||
return { certPath, keyPath, caCertPath };
|
||||
}
|
||||
|
||||
// Run if called directly
|
||||
|
||||
+4
-1
@@ -14,7 +14,10 @@ export default [
|
||||
// Additional ignores for monorepo
|
||||
{
|
||||
ignores: [
|
||||
'services/docs/**/*', // Hugo content, no linting needed
|
||||
'services/docs/public/**',
|
||||
'services/docs/resources/**',
|
||||
'services/docs/assets/**',
|
||||
'services/docs/archetypes/**',
|
||||
'**/node_modules/**',
|
||||
'**/dist/**',
|
||||
'**/build/**',
|
||||
|
||||
@@ -10,6 +10,9 @@
|
||||
"files": [
|
||||
"src"
|
||||
],
|
||||
"scripts": {
|
||||
"test": "node --test src/lib/rules/no-hanging-particles.test.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@eslint/js": "^9.39.2",
|
||||
"eslint-config-prettier": "^10.1.2",
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
// @ts-check
|
||||
import { i18nPlugin } from '../plugins/i18n.js';
|
||||
import { markdownParser } from '../parsers/markdown.js';
|
||||
|
||||
/**
|
||||
* i18n typography and validation ESLint configuration.
|
||||
*/
|
||||
export const i18nConfig = [
|
||||
{
|
||||
files: ['**/*.i18n.{ts,tsx,js,jsx,mjs}'],
|
||||
plugins: {
|
||||
i18n: i18nPlugin,
|
||||
},
|
||||
rules: {
|
||||
'i18n/no-hanging-particles': 'error',
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['**/*.{md,mdx}'],
|
||||
languageOptions: {
|
||||
parser: markdownParser,
|
||||
},
|
||||
plugins: {
|
||||
i18n: i18nPlugin,
|
||||
},
|
||||
rules: {
|
||||
'no-irregular-whitespace': 'off',
|
||||
'i18n/no-hanging-particles': 'error',
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -6,6 +6,7 @@ import { jsdocConfig } from './jsdoc.js';
|
||||
import { testingConfig } from './testing.js';
|
||||
import { toolingConfig } from './tooling.js';
|
||||
import { defineMonorepoRestrictedImports } from './monorepo.js';
|
||||
import { i18nConfig } from './i18n.js';
|
||||
|
||||
/**
|
||||
* Common ignores for the entire monorepo.
|
||||
@@ -38,6 +39,9 @@ export function defineBaseConfig(rootDir) {
|
||||
...jsdocConfig,
|
||||
...testingConfig,
|
||||
...toolingConfig,
|
||||
...i18nConfig,
|
||||
...defineMonorepoRestrictedImports(rootDir),
|
||||
];
|
||||
}
|
||||
|
||||
export { i18nConfig };
|
||||
@@ -1,6 +1,7 @@
|
||||
// @ts-check
|
||||
import path from 'node:path';
|
||||
import { defineBaseConfig } from './config/index.js';
|
||||
import { defineBaseConfig, i18nConfig } from './config/index.js';
|
||||
import { i18nPlugin } from './plugins/i18n.js';
|
||||
|
||||
// Resolve the monorepo root relative to this library's location
|
||||
const rootDir = path.resolve(import.meta.dirname, '../../../../');
|
||||
@@ -10,4 +11,4 @@ const rootDir = path.resolve(import.meta.dirname, '../../../../');
|
||||
*/
|
||||
export const baseConfig = defineBaseConfig(rootDir);
|
||||
|
||||
export { defineBaseConfig };
|
||||
export { defineBaseConfig, i18nConfig, i18nPlugin };
|
||||
@@ -0,0 +1,61 @@
|
||||
// @ts-check
|
||||
|
||||
/**
|
||||
* Calculates start and end line/column locations for a string offset.
|
||||
*
|
||||
* @param {string} code - Entire source code.
|
||||
* @returns {import('eslint').AST.SourceLocation} Full document SourceLocation.
|
||||
*/
|
||||
function getDocumentLocation(code) {
|
||||
const lines = code.split('\n');
|
||||
const lastLine = lines.length;
|
||||
const lastColumn = lines[lines.length - 1].length;
|
||||
|
||||
return {
|
||||
start: { line: 1, column: 0 },
|
||||
end: { line: lastLine, column: lastColumn },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom lightweight parser for Markdown and MDX documents.
|
||||
* Produces an AST that enables typography linting and autofixing on prose text.
|
||||
*/
|
||||
export const markdownParser = {
|
||||
meta: {
|
||||
name: 'eslint-parser-markdown-typography',
|
||||
version: '1.0.0',
|
||||
},
|
||||
parseForESLint(code, options = {}) {
|
||||
const filePath = options.filePath || '';
|
||||
const isRu = /(?:^|[\\/])ru(?:[\\/]|\.ru\.)/i.test(filePath);
|
||||
const isEn = /(?:^|[\\/])en(?:[\\/]|\.en\.)/i.test(filePath);
|
||||
const locale = isRu ? 'ru' : isEn ? 'en' : undefined;
|
||||
|
||||
const loc = getDocumentLocation(code);
|
||||
|
||||
return {
|
||||
ast: {
|
||||
type: 'Program',
|
||||
range: [0, code.length],
|
||||
loc,
|
||||
body: [
|
||||
{
|
||||
type: 'MarkdownDocument',
|
||||
range: [0, code.length],
|
||||
loc,
|
||||
value: code,
|
||||
locale,
|
||||
filePath,
|
||||
},
|
||||
],
|
||||
tokens: [],
|
||||
comments: [],
|
||||
},
|
||||
visitorKeys: {
|
||||
Program: ['body'],
|
||||
MarkdownDocument: [],
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
// @ts-check
|
||||
import { noHangingParticlesRule } from '../rules/no-hanging-particles.js';
|
||||
|
||||
/**
|
||||
* ESLint plugin providing i18n translation validation and typography rules.
|
||||
*/
|
||||
export const i18nPlugin = {
|
||||
meta: {
|
||||
name: 'eslint-plugin-i18n',
|
||||
version: '1.0.0',
|
||||
},
|
||||
rules: {
|
||||
'no-hanging-particles': noHangingParticlesRule,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,310 @@
|
||||
// @ts-check
|
||||
|
||||
const RU_PARTICLES = [
|
||||
'[а-яё]',
|
||||
'во', 'со', 'ко', 'до', 'по', 'на', 'из', 'за', 'от', 'об', 'не', 'ни', 'но', 'да', 'то', 'уж',
|
||||
'обо', 'под', 'над', 'при', 'про', 'для', 'без', 'или', 'как', 'что', 'еще', 'ещё', 'уже',
|
||||
'через', 'сквозь', 'перед', 'из-за', 'из-под',
|
||||
].join('|');
|
||||
|
||||
const EN_PARTICLES = [
|
||||
'a', 'an', 'the',
|
||||
'in', 'on', 'at', 'to', 'of', 'by', 'for', 'up', 'as', 'if', 'is', 'it', 'no',
|
||||
'and', 'or', 'nor', 'but', 'yet', 'so', 'with', 'into', 'from',
|
||||
].join('|');
|
||||
|
||||
const RU_TRAILING = ['ли', 'ль', 'же', 'ж', 'бы', 'б'].join('|');
|
||||
|
||||
const KNOWN_LOCALES = new Set(['en', 'ru']);
|
||||
const PLURAL_KEYS = new Set(['zero', 'one', 'two', 'few', 'many', 'other']);
|
||||
|
||||
/**
|
||||
* Applies non-breaking space typography rules to a translation string value.
|
||||
*
|
||||
* @param {string} input - The raw translation string.
|
||||
* @param {string} [locale] - The locale code ('en' | 'ru').
|
||||
* @returns {string} The transformed string with non-breaking spaces.
|
||||
*/
|
||||
export function applyTypography(input, locale) {
|
||||
let text = input.replaceAll(' ', '\u00A0');
|
||||
|
||||
/** @type {string[]} */
|
||||
const protectedTokens = [];
|
||||
text = text.replace(/\{[^}]+\}|<[^>]+>|\]\([^)]+\)/g, (match) => {
|
||||
const idx = protectedTokens.length;
|
||||
protectedTokens.push(match);
|
||||
return `__TYPO_PROT_${idx}__`;
|
||||
});
|
||||
|
||||
// Em-dash and en-dash (prepend non-breaking space)
|
||||
text = text.replace(/(?<=\S)[\t \u00A0]+([—–])[\t ]+/g, '\u00A0$1 ');
|
||||
|
||||
// Numbers & units/symbols
|
||||
text = text.replace(/(?<=\d)[\t ]+(?=[a-zA-Zа-яА-ЯёЁ%№$€₽]|__TYPO_PROT_)/g, '\u00A0');
|
||||
text = text.replace(/(?<=[№§$€₽])[\t ]+(?=\d|__TYPO_PROT_)/g, '\u00A0');
|
||||
|
||||
// Trailing particles (Russian)
|
||||
if (!locale || locale === 'ru') {
|
||||
const trailingRegex = new RegExp(`(?<=[а-яА-ЯёЁ\\w])\\s+(${RU_TRAILING})(?=[\\s\\u00A0.,!?:;»"')\\]]|$)`, 'gi');
|
||||
text = text.replace(trailingRegex, '\u00A0$1');
|
||||
}
|
||||
|
||||
// Leading particles (RU / EN)
|
||||
const particles = locale === 'en' ? EN_PARTICLES : locale === 'ru' ? RU_PARTICLES : `${RU_PARTICLES}|${EN_PARTICLES}`;
|
||||
const leadingRegex = new RegExp(`(?<=^|[\\s\\u00A0.,!?:;«"'(])(${particles})[ \\t]+(?=\\S)`, 'gi');
|
||||
text = text.replace(leadingRegex, '$1\u00A0');
|
||||
|
||||
// Restore protected tokens
|
||||
text = text.replace(/__TYPO_PROT_(\d+)__/g, (_, idx) => protectedTokens[Number(idx)]);
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Masks frontmatter non-title/description lines.
|
||||
*
|
||||
* @param {string} text - Raw document text.
|
||||
* @param {(str: string) => string} mask - Masking function.
|
||||
* @returns {string} Text with frontmatter metadata masked.
|
||||
*/
|
||||
function maskFrontmatter(text, mask) {
|
||||
if (!text.startsWith('---')) return text;
|
||||
const endIdx = text.indexOf('\n---', 3);
|
||||
if (endIdx === -1) return text;
|
||||
const fm = text.slice(0, endIdx + 4);
|
||||
const rest = text.slice(endIdx + 4);
|
||||
|
||||
const lines = fm.split('\n');
|
||||
const processedLines = lines.map((line, idx) => {
|
||||
if (idx === 0 || idx === lines.length - 1) return mask(line);
|
||||
const trimmed = line.trim();
|
||||
if (trimmed.startsWith('title:') || trimmed.startsWith('description:')) {
|
||||
return line;
|
||||
}
|
||||
return mask(line);
|
||||
});
|
||||
return processedLines.join('\n') + rest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies typography rules to a Markdown/MDX document, protecting code blocks,
|
||||
* inline code, shortcodes, link URLs, HTML tags, and frontmatter metadata.
|
||||
*
|
||||
* @param {string} content - Markdown document content.
|
||||
* @param {string} [locale] - The document locale ('en' | 'ru').
|
||||
* @returns {string} Transformed markdown content.
|
||||
*/
|
||||
export function applyMarkdownTypography(content, locale) {
|
||||
/** @type {string[]} */
|
||||
const protectedTokens = [];
|
||||
/**
|
||||
* @param {string} str
|
||||
* @returns {string}
|
||||
*/
|
||||
function mask(str) {
|
||||
const idx = protectedTokens.length;
|
||||
protectedTokens.push(str);
|
||||
return `__MD_PROT_${idx}__`;
|
||||
}
|
||||
|
||||
let text = maskFrontmatter(content, mask);
|
||||
|
||||
// Mask fenced code blocks ```...``` and ~~~...~~~
|
||||
text = text.replace(/```[\s\S]*?```|~~~[\s\S]*?~~~/g, mask);
|
||||
// Mask Hugo shortcodes {{< ... >}} or {{% ... %}}
|
||||
text = text.replace(/\{\{[<%][\s\S]*?[>%]\}\}/g, mask);
|
||||
// Mask inline code `...`
|
||||
text = text.replace(/`[^`\n]+`/g, mask);
|
||||
// Mask Markdown link targets ](...)
|
||||
text = text.replace(/\]\([^)]+\)/g, mask);
|
||||
// Mask HTML tags
|
||||
text = text.replace(/<[^>]+>/g, mask);
|
||||
|
||||
// Apply typography rules
|
||||
text = applyTypography(text, locale);
|
||||
|
||||
// Restore protected tokens
|
||||
text = text.replace(/__MD_PROT_(\d+)__/g, (_, idx) => protectedTokens[Number(idx)]);
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes a string into a valid JS string literal with specified quote.
|
||||
*
|
||||
* @param {string} str - String to escape and wrap.
|
||||
* @param {string} quote - Quote character (' or ").
|
||||
* @returns {string} Serialized literal.
|
||||
*/
|
||||
function serializeStringLiteral(str, quote) {
|
||||
const escaped = str
|
||||
.replace(/\\/g, '\\\\')
|
||||
.replace(/\r/g, '\\r')
|
||||
.replace(/\n/g, '\\n')
|
||||
.replace(/\t/g, '\\t')
|
||||
.replace(new RegExp(quote, 'g'), `\\${quote}`);
|
||||
return `${quote}${escaped}${quote}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts property name from a property node.
|
||||
*
|
||||
* @param {any} prop - Property AST node.
|
||||
* @returns {string | null} Property name or null.
|
||||
*/
|
||||
function getPropertyName(prop) {
|
||||
if (!prop || !prop.key) return null;
|
||||
if (prop.key.type === 'Identifier') return prop.key.name;
|
||||
if (prop.key.type === 'Literal') return String(prop.key.value);
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* ESLint rule module for enforcing non-breaking spaces in i18n translation values
|
||||
* and Markdown/MDX documentation.
|
||||
*
|
||||
* @type {import('eslint').Rule.RuleModule}
|
||||
*/
|
||||
export const noHangingParticlesRule = {
|
||||
meta: {
|
||||
type: 'layout',
|
||||
docs: {
|
||||
description: 'Enforce non-breaking spaces for hanging particles and numbers in i18n translation values and markdown documentation.',
|
||||
recommended: true,
|
||||
},
|
||||
fixable: 'whitespace',
|
||||
schema: [],
|
||||
messages: {
|
||||
hangingParticle: 'Translation value contains hanging particles or numbers that should be bound with a non-breaking space (\\u00A0).',
|
||||
},
|
||||
},
|
||||
create(context) {
|
||||
/**
|
||||
* Checks and fixes a single string literal node.
|
||||
*
|
||||
* @param {any} node - String Literal AST node.
|
||||
* @param {string} [locale] - The locale code if known.
|
||||
*/
|
||||
function checkStringLiteral(node, locale) {
|
||||
if (!node || typeof node.value !== 'string') return;
|
||||
const original = node.value;
|
||||
const formatted = applyTypography(original, locale);
|
||||
|
||||
if (original !== formatted) {
|
||||
context.report({
|
||||
node,
|
||||
messageId: 'hangingParticle',
|
||||
fix(fixer) {
|
||||
const raw = node.raw || '';
|
||||
const quote = raw.startsWith('"') ? '"' : "'";
|
||||
return fixer.replaceText(node, serializeStringLiteral(formatted, quote));
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Inspects a locale entry inside a translation item.
|
||||
*
|
||||
* @param {any} prop - Locale property node (e.g. en or ru).
|
||||
*/
|
||||
function processLocaleEntry(prop) {
|
||||
const locale = getPropertyName(prop);
|
||||
if (!locale || !KNOWN_LOCALES.has(locale)) return;
|
||||
|
||||
const valueNode = prop.value;
|
||||
if (!valueNode) return;
|
||||
|
||||
if (valueNode.type === 'Literal') {
|
||||
checkStringLiteral(valueNode, locale);
|
||||
} else if (valueNode.type === 'ObjectExpression' && Array.isArray(valueNode.properties)) {
|
||||
for (const pluralProp of valueNode.properties) {
|
||||
if (pluralProp.type === 'Property') {
|
||||
const pluralKey = getPropertyName(pluralProp);
|
||||
if (pluralKey && PLURAL_KEYS.has(pluralKey) && pluralProp.value?.type === 'Literal') {
|
||||
checkStringLiteral(pluralProp.value, locale);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes keyset object dictionary properties.
|
||||
*
|
||||
* @param {any} objectNode - Keyset ObjectExpression AST node.
|
||||
*/
|
||||
function processKeysetObject(objectNode) {
|
||||
if (!objectNode || !Array.isArray(objectNode.properties)) return;
|
||||
|
||||
for (const dictEntry of objectNode.properties) {
|
||||
if (
|
||||
dictEntry.type === 'Property' &&
|
||||
dictEntry.value &&
|
||||
dictEntry.value.type === 'ObjectExpression' &&
|
||||
Array.isArray(dictEntry.value.properties)
|
||||
) {
|
||||
for (const localeProp of dictEntry.value.properties) {
|
||||
if (localeProp.type === 'Property') {
|
||||
processLocaleEntry(localeProp);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
CallExpression(node) {
|
||||
if (
|
||||
node.callee &&
|
||||
node.callee.type === 'Identifier' &&
|
||||
node.callee.name === 'createKeyset' &&
|
||||
node.arguments.length > 0 &&
|
||||
node.arguments[0].type === 'ObjectExpression'
|
||||
) {
|
||||
processKeysetObject(node.arguments[0]);
|
||||
}
|
||||
},
|
||||
|
||||
MarkdownDocument(node) {
|
||||
if (!node || typeof node.value !== 'string') return;
|
||||
const original = node.value;
|
||||
const formatted = applyMarkdownTypography(original, node.locale);
|
||||
|
||||
if (original === formatted) return;
|
||||
|
||||
const origLines = original.split('\n');
|
||||
const formLines = formatted.split('\n');
|
||||
|
||||
let reported = false;
|
||||
for (let i = 0; i < origLines.length; i++) {
|
||||
const origLine = origLines[i];
|
||||
const formLine = formLines[i];
|
||||
if (origLine !== formLine) {
|
||||
let diffCol = 0;
|
||||
while (
|
||||
diffCol < origLine.length &&
|
||||
diffCol < formLine.length &&
|
||||
origLine[diffCol] === formLine[diffCol]
|
||||
) {
|
||||
diffCol++;
|
||||
}
|
||||
|
||||
const isFirst = !reported;
|
||||
reported = true;
|
||||
|
||||
context.report({
|
||||
node,
|
||||
loc: {
|
||||
start: { line: i + 1, column: diffCol },
|
||||
end: { line: i + 1, column: diffCol + 1 },
|
||||
},
|
||||
messageId: 'hangingParticle',
|
||||
fix: isFirst ? (fixer) => fixer.replaceText(node, formatted) : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,251 @@
|
||||
// @ts-check
|
||||
import { describe, it } from 'node:test';
|
||||
import { RuleTester } from 'eslint';
|
||||
import { noHangingParticlesRule } from './no-hanging-particles.js';
|
||||
import { markdownParser } from '../parsers/markdown.js';
|
||||
|
||||
// Configure RuleTester for node:test
|
||||
RuleTester.describe = describe;
|
||||
RuleTester.it = it;
|
||||
|
||||
const ruleTester = new RuleTester();
|
||||
|
||||
ruleTester.run('no-hanging-particles', noHangingParticlesRule, {
|
||||
valid: [
|
||||
{
|
||||
code: `
|
||||
createKeyset({
|
||||
'Yet capturing context': {
|
||||
en: 'Yet\\u00A0capturing context and\\u00A0architectural decisions',
|
||||
ru: 'Мои мысли и\\u00A0проекты',
|
||||
},
|
||||
});
|
||||
`,
|
||||
},
|
||||
{
|
||||
// Keys should not trigger errors even if they contain particles
|
||||
code: `
|
||||
createKeyset({
|
||||
'и иные проблемы': {
|
||||
en: 'and\\u00A0other issues',
|
||||
ru: 'и\\u00A0иные проблемы',
|
||||
},
|
||||
});
|
||||
`,
|
||||
},
|
||||
{
|
||||
// Manual {nbsp} token is allowed
|
||||
code: `
|
||||
createKeyset({
|
||||
manual: {
|
||||
en: 'Yet{nbsp}capturing',
|
||||
ru: 'и{nbsp}иные',
|
||||
},
|
||||
});
|
||||
`,
|
||||
},
|
||||
{
|
||||
// Not a createKeyset call
|
||||
code: `
|
||||
const regularObj = {
|
||||
en: 'Yet capturing without keyset',
|
||||
};
|
||||
`,
|
||||
},
|
||||
],
|
||||
invalid: [
|
||||
{
|
||||
// English leading particle
|
||||
code: `
|
||||
createKeyset({
|
||||
key: {
|
||||
en: 'Yet capturing context',
|
||||
ru: 'Фиксация контекста',
|
||||
},
|
||||
});
|
||||
`,
|
||||
output: `
|
||||
createKeyset({
|
||||
key: {
|
||||
en: 'Yet\u00A0capturing context',
|
||||
ru: 'Фиксация контекста',
|
||||
},
|
||||
});
|
||||
`,
|
||||
errors: [
|
||||
{
|
||||
messageId: 'hangingParticle',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
// Russian leading particle
|
||||
code: `
|
||||
createKeyset({
|
||||
'и иные проблемы': {
|
||||
en: 'and other issues',
|
||||
ru: 'и иные проблемы',
|
||||
},
|
||||
});
|
||||
`,
|
||||
output: `
|
||||
createKeyset({
|
||||
'и иные проблемы': {
|
||||
en: 'and\u00A0other issues',
|
||||
ru: 'и\u00A0иные проблемы',
|
||||
},
|
||||
});
|
||||
`,
|
||||
errors: [
|
||||
{ messageId: 'hangingParticle' },
|
||||
{ messageId: 'hangingParticle' },
|
||||
],
|
||||
},
|
||||
{
|
||||
// Interpolation placeholder & trailing particles & dashes & numbers
|
||||
code: `
|
||||
createKeyset({
|
||||
welcome: {
|
||||
en: 'Inspect the {repositoryLink} for 10 items — all good',
|
||||
ru: 'Загляните в {repositoryLink}, как же так — это 10 вещей в 2026 году',
|
||||
},
|
||||
});
|
||||
`,
|
||||
output: `
|
||||
createKeyset({
|
||||
welcome: {
|
||||
en: 'Inspect the\u00A0{repositoryLink} for\u00A010\u00A0items\u00A0— all good',
|
||||
ru: 'Загляните в\u00A0{repositoryLink}, как\u00A0же так\u00A0— это 10\u00A0вещей в\u00A02026\u00A0году',
|
||||
},
|
||||
});
|
||||
`,
|
||||
errors: [
|
||||
{ messageId: 'hangingParticle' },
|
||||
{ messageId: 'hangingParticle' },
|
||||
],
|
||||
},
|
||||
{
|
||||
// HTML entity replacement
|
||||
code: `
|
||||
createKeyset({
|
||||
test: {
|
||||
en: 'Text with entity',
|
||||
ru: 'Текст с сущностью',
|
||||
},
|
||||
});
|
||||
`,
|
||||
output: `
|
||||
createKeyset({
|
||||
test: {
|
||||
en: 'Text\u00A0with\u00A0entity',
|
||||
ru: 'Текст\u00A0с\u00A0сущностью',
|
||||
},
|
||||
});
|
||||
`,
|
||||
errors: [
|
||||
{ messageId: 'hangingParticle' },
|
||||
{ messageId: 'hangingParticle' },
|
||||
],
|
||||
},
|
||||
{
|
||||
// Plural forms
|
||||
code: `
|
||||
createKeyset({
|
||||
counter: {
|
||||
en: {
|
||||
one: '{count} item',
|
||||
other: '{count} items in stock',
|
||||
},
|
||||
ru: {
|
||||
one: '{count} штука в наличии',
|
||||
few: '{count} штуки в наличии',
|
||||
many: '{count} штук в наличии',
|
||||
other: '{count} штук в наличии',
|
||||
},
|
||||
},
|
||||
});
|
||||
`,
|
||||
output: `
|
||||
createKeyset({
|
||||
counter: {
|
||||
en: {
|
||||
one: '{count} item',
|
||||
other: '{count} items in\u00A0stock',
|
||||
},
|
||||
ru: {
|
||||
one: '{count} штука в\u00A0наличии',
|
||||
few: '{count} штуки в\u00A0наличии',
|
||||
many: '{count} штук в\u00A0наличии',
|
||||
other: '{count} штук в\u00A0наличии',
|
||||
},
|
||||
},
|
||||
});
|
||||
`,
|
||||
errors: [
|
||||
{ messageId: 'hangingParticle' },
|
||||
{ messageId: 'hangingParticle' },
|
||||
{ messageId: 'hangingParticle' },
|
||||
{ messageId: 'hangingParticle' },
|
||||
{ messageId: 'hangingParticle' },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const markdownRuleTester = new RuleTester({
|
||||
languageOptions: {
|
||||
parser: markdownParser,
|
||||
},
|
||||
});
|
||||
|
||||
markdownRuleTester.run('no-hanging-particles (markdown)', noHangingParticlesRule, {
|
||||
valid: [
|
||||
{
|
||||
code: '# Title\n\nYet\\u00A0capturing context\\u00A0— for\\u00A010\\u00A0items',
|
||||
filename: 'content/en/overview.md',
|
||||
},
|
||||
{
|
||||
// Code blocks should be protected
|
||||
code: '```ts\nconst in = 10;\n```',
|
||||
filename: 'content/en/guide.md',
|
||||
},
|
||||
{
|
||||
// Inline code and link targets should be protected
|
||||
code: 'Use `in` keyword here [link](https://example.com/in/path).',
|
||||
filename: 'content/en/guide.md',
|
||||
},
|
||||
{
|
||||
// Hugo shortcodes should be protected
|
||||
code: '{{< card link="apex" title="apex" >}}',
|
||||
filename: 'content/en/guide.md',
|
||||
},
|
||||
{
|
||||
// Frontmatter tags/date should be protected
|
||||
code: '---\ntitle: "Title"\ntags: [a, in, on]\n---\nBody text',
|
||||
filename: 'content/en/guide.md',
|
||||
},
|
||||
],
|
||||
invalid: [
|
||||
{
|
||||
code: '# Title\n\nYet capturing context — for 10 items',
|
||||
output: '# Title\n\nYet\u00A0capturing context\u00A0— for\u00A010\u00A0items',
|
||||
filename: 'content/en/overview.md',
|
||||
errors: [{ messageId: 'hangingParticle' }],
|
||||
},
|
||||
{
|
||||
code: 'Это руководство для заметок с нуля',
|
||||
output: 'Это руководство для\u00A0заметок с\u00A0нуля',
|
||||
filename: 'content/ru/overview.md',
|
||||
errors: [{ messageId: 'hangingParticle' }],
|
||||
},
|
||||
{
|
||||
code: '---\ntitle: "Руководство по развертыванию"\ntags: [a, in]\n---\nС нуля',
|
||||
output: '---\ntitle: "Руководство по\u00A0развертыванию"\ntags: [a, in]\n---\nС\u00A0нуля',
|
||||
filename: 'content/ru/guide.md',
|
||||
errors: [
|
||||
{ messageId: 'hangingParticle' },
|
||||
{ messageId: 'hangingParticle' },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -76,7 +76,7 @@ export function PageHead({
|
||||
|
||||
useEffect(() => {
|
||||
if (isCollapsible) {
|
||||
setIsCollapsed(scrollY > 200);
|
||||
setIsCollapsed(scrollY > 150);
|
||||
}
|
||||
}, [scrollY, isCollapsible]);
|
||||
|
||||
@@ -91,13 +91,13 @@ export function PageHead({
|
||||
<HStack align="center" className="min-h-[60px] py-2.5" gap={4} justify="between" wrap="wrap">
|
||||
<HStack align="center" className="flex-1" gap={4}>
|
||||
{addonLeft && <div className="flex-shrink-0 whitespace-nowrap">{addonLeft}</div>}
|
||||
{breadcrumbs && <div className="hidden sm:block">{breadcrumbs}</div>}
|
||||
{breadcrumbs && <div className="hidden sm:block text-label-l [&_ol]:text-label-l">{breadcrumbs}</div>}
|
||||
</HStack>
|
||||
{addonRight && <div className="flex-shrink-0">{addonRight}</div>}
|
||||
</HStack>
|
||||
</PageContainer>
|
||||
)}
|
||||
<PageContainer className={cn(hasTopBar ? 'pt-4 pb-8 sm:pt-6 sm:pb-12' : 'py-8 sm:py-12')}>
|
||||
<PageContainer className={cn(hasTopBar ? 'pt-2.5 pb-8 sm:pt-3.5 sm:pb-12' : 'pt-5 pb-8 sm:pt-9 sm:pb-12')}>
|
||||
<VStack gap={4}>
|
||||
<Header level={1}>{heading}</Header>
|
||||
{typeof caption === 'string' ? (
|
||||
@@ -117,7 +117,7 @@ export function PageHead({
|
||||
<HStack align="center" className="min-h-[60px] py-2.5" gap={4} justify="between" wrap="wrap">
|
||||
<HStack align="center" className="flex-1" gap={4}>
|
||||
{addonLeft && <div className="flex-shrink-0 whitespace-nowrap">{addonLeft}</div>}
|
||||
{breadcrumbs && <div className="hidden sm:block">{breadcrumbs}</div>}
|
||||
{breadcrumbs && <div className="hidden sm:block text-label-l [&_ol]:text-label-l">{breadcrumbs}</div>}
|
||||
<Header
|
||||
className={cn('min-w-0', breadcrumbs && 'sm:hidden')}
|
||||
level={4}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
ensureHostsEntry,
|
||||
findWorkspaceRoot,
|
||||
hasHostsEntry,
|
||||
loadDevRootCaKey,
|
||||
localDevServer,
|
||||
} from './server.js';
|
||||
|
||||
@@ -21,9 +22,45 @@ describe('server dev configuration', () => {
|
||||
const certs = ensureDevCertificates();
|
||||
expect(existsSync(certs.certPath)).toBe(true);
|
||||
expect(existsSync(certs.keyPath)).toBe(true);
|
||||
expect(certs.caCertPath).toBeDefined();
|
||||
if (certs.caCertPath) {
|
||||
expect(existsSync(certs.caCertPath)).toBe(true);
|
||||
expect(certs.caCertPath).toContain('ErmnvldmrDevelopmentRootCA.pem');
|
||||
}
|
||||
expect(certs.certPath).toContain('.certs/localhost.ermnvldmr.com.pem');
|
||||
});
|
||||
|
||||
it('loads dev root CA key from environment variable when present', () => {
|
||||
const prevKey = process.env.COM_ERMNVLDMR_DEV_ROOT_CA_KEY;
|
||||
try {
|
||||
process.env.COM_ERMNVLDMR_DEV_ROOT_CA_KEY = 'mock-key-value\\nwith-newline';
|
||||
const key = loadDevRootCaKey();
|
||||
expect(key).toBe('mock-key-value\nwith-newline');
|
||||
} finally {
|
||||
if (prevKey !== undefined) {
|
||||
process.env.COM_ERMNVLDMR_DEV_ROOT_CA_KEY = prevKey;
|
||||
} else {
|
||||
delete process.env.COM_ERMNVLDMR_DEV_ROOT_CA_KEY;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('loads dev root CA key from .env.dev when available', () => {
|
||||
const prevKey = process.env.COM_ERMNVLDMR_DEV_ROOT_CA_KEY;
|
||||
delete process.env.COM_ERMNVLDMR_DEV_ROOT_CA_KEY;
|
||||
try {
|
||||
const key = loadDevRootCaKey();
|
||||
expect(key).toBeDefined();
|
||||
if (key) {
|
||||
expect(key).toContain('BEGIN PRIVATE KEY');
|
||||
}
|
||||
} finally {
|
||||
if (prevKey !== undefined) {
|
||||
process.env.COM_ERMNVLDMR_DEV_ROOT_CA_KEY = prevKey;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('returns production configuration without https when NODE_ENV is production', () => {
|
||||
const prevEnv = process.env.NODE_ENV;
|
||||
try {
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from 'node:fs';
|
||||
import { homedir, tmpdir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { createRequire } from 'node:module';
|
||||
|
||||
import type { ServerConfig } from '@rsbuild/core';
|
||||
|
||||
@@ -39,6 +40,10 @@ export interface DevCertificates {
|
||||
* Absolute path to the PEM private key file.
|
||||
*/
|
||||
keyPath: string;
|
||||
/**
|
||||
* Absolute path to the PEM Root CA certificate file.
|
||||
*/
|
||||
caCertPath?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -146,11 +151,76 @@ export function findWorkspaceRoot(startDir: string = process.cwd()): string {
|
||||
return startDir;
|
||||
}
|
||||
|
||||
interface DotenvxModule {
|
||||
config: (options: { path?: string; envKeysFile?: string }) => void;
|
||||
}
|
||||
|
||||
function isDotenvxModule(val: unknown): val is DotenvxModule {
|
||||
return (
|
||||
typeof val === 'object' &&
|
||||
val !== null &&
|
||||
'config' in val &&
|
||||
typeof val.config === 'function'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the development Root CA private key from the environment or .env.dev.
|
||||
*
|
||||
* @param workspaceRoot - Absolute path to workspace root
|
||||
* @returns The PEM private key string, or undefined if unavailable
|
||||
*/
|
||||
export function loadDevRootCaKey(
|
||||
workspaceRoot: string = findWorkspaceRoot()
|
||||
): string | undefined {
|
||||
if (process.env.COM_ERMNVLDMR_DEV_ROOT_CA_KEY) {
|
||||
const raw = process.env.COM_ERMNVLDMR_DEV_ROOT_CA_KEY;
|
||||
return raw.includes('\\n') ? raw.replace(/\\n/g, '\n') : raw;
|
||||
}
|
||||
|
||||
const envDevPath = join(workspaceRoot, '.env.dev');
|
||||
const envKeysPath = join(workspaceRoot, '.env.keys');
|
||||
|
||||
if (!existsSync(envDevPath)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const candidatePackages = [
|
||||
join(workspaceRoot, 'packages/cli/package.json'),
|
||||
join(workspaceRoot, 'package.json'),
|
||||
];
|
||||
|
||||
for (const pkgPath of candidatePackages) {
|
||||
if (!existsSync(pkgPath)) continue;
|
||||
try {
|
||||
const req = createRequire(pkgPath);
|
||||
const dotenvx: unknown = req('@dotenvx/dotenvx');
|
||||
if (isDotenvxModule(dotenvx)) {
|
||||
dotenvx.config({ path: envDevPath, envKeysFile: envKeysPath });
|
||||
const key = process.env.COM_ERMNVLDMR_DEV_ROOT_CA_KEY;
|
||||
if (key) {
|
||||
return key.includes('\\n') ? key.replace(/\\n/g, '\n') : key;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Continue to next candidate package
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remote public URL for the Ermnvldmr development root CA certificate.
|
||||
*/
|
||||
export const REMOTE_ROOT_CA_URL =
|
||||
'https://static.ermnvldmr.com/ssl/certs/ErmnvldmrDevelopmentRootCA.pem';
|
||||
|
||||
/**
|
||||
* Ensures that valid TLS development certificates exist for localhost.ermnvldmr.com.
|
||||
*
|
||||
* Checks for certificates in the workspace `.certs` directory. If absent, generates
|
||||
* new certificates signed by either an existing mkcert/Hugo root CA or an openssl-generated CA.
|
||||
* Checks for certificates in the workspace `.certs` directory. If absent, downloads
|
||||
* or uses ErmnvldmrDevelopmentRootCA.pem and generates certificates signed by it.
|
||||
*
|
||||
* @param workspaceRoot - Absolute path to workspace root
|
||||
* @returns Paths to the certificate and private key files
|
||||
@@ -161,128 +231,101 @@ export function ensureDevCertificates(
|
||||
const certsDir = join(workspaceRoot, '.certs');
|
||||
const certPath = join(certsDir, 'localhost.ermnvldmr.com.pem');
|
||||
const keyPath = join(certsDir, 'localhost.ermnvldmr.com-key.pem');
|
||||
const caCertPath = join(certsDir, 'ErmnvldmrDevelopmentRootCA.pem');
|
||||
const caKeyPath = join(certsDir, 'ErmnvldmrDevelopmentRootCA-key.pem');
|
||||
|
||||
ensureHostsEntry('localhost.ermnvldmr.com', '127.0.0.1');
|
||||
|
||||
if (existsSync(certPath) && existsSync(keyPath)) {
|
||||
return { certPath, keyPath };
|
||||
}
|
||||
const home = homedir();
|
||||
const globalCaDir = join(home, '.local/share/mkcert');
|
||||
const globalCaCert = join(globalCaDir, 'ErmnvldmrDevelopmentRootCA.pem');
|
||||
const globalCaKey = join(globalCaDir, 'ErmnvldmrDevelopmentRootCA-key.pem');
|
||||
|
||||
mkdirSync(certsDir, { recursive: true, mode: 0o700 });
|
||||
|
||||
const home = homedir();
|
||||
const hugoCaCert = join(home, '.local/share/mkcert/rootCA.pem');
|
||||
const hugoCaKey = join(home, '.local/share/mkcert/rootCA-key.pem');
|
||||
|
||||
let hasMkcertCli = false;
|
||||
try {
|
||||
execFileSync('mkcert', ['-help'], { stdio: 'ignore' });
|
||||
hasMkcertCli = true;
|
||||
} catch {
|
||||
hasMkcertCli = false;
|
||||
}
|
||||
|
||||
if (hasMkcertCli) {
|
||||
execFileSync(
|
||||
'mkcert',
|
||||
[
|
||||
'-cert-file',
|
||||
certPath,
|
||||
'-key-file',
|
||||
keyPath,
|
||||
'localhost.ermnvldmr.com',
|
||||
'*.localhost.ermnvldmr.com',
|
||||
'localhost',
|
||||
'127.0.0.1',
|
||||
'::1',
|
||||
],
|
||||
{ stdio: 'inherit' }
|
||||
);
|
||||
chmodSync(keyPath, 0o600);
|
||||
return { certPath, keyPath };
|
||||
}
|
||||
|
||||
if (existsSync(hugoCaCert) && existsSync(hugoCaKey)) {
|
||||
const tempDir = tmpdir();
|
||||
const tempCsr = join(tempDir, `ermnvldmr-dev-${Date.now()}.csr`);
|
||||
const tempExt = join(tempDir, `ermnvldmr-dev-${Date.now()}.cnf`);
|
||||
|
||||
try {
|
||||
writeFileSync(
|
||||
tempExt,
|
||||
'subjectAltName=DNS:localhost.ermnvldmr.com,DNS:*.localhost.ermnvldmr.com,DNS:localhost,IP:127.0.0.1,IP:::1\n'
|
||||
);
|
||||
execFileSync(
|
||||
'openssl',
|
||||
[
|
||||
'req',
|
||||
'-new',
|
||||
'-nodes',
|
||||
'-newkey',
|
||||
'rsa:2048',
|
||||
'-keyout',
|
||||
keyPath,
|
||||
'-out',
|
||||
tempCsr,
|
||||
'-subj',
|
||||
'/CN=localhost.ermnvldmr.com',
|
||||
'-batch',
|
||||
],
|
||||
{ stdio: 'pipe' }
|
||||
);
|
||||
execFileSync(
|
||||
'openssl',
|
||||
[
|
||||
'x509',
|
||||
'-req',
|
||||
'-in',
|
||||
tempCsr,
|
||||
'-CA',
|
||||
hugoCaCert,
|
||||
'-CAkey',
|
||||
hugoCaKey,
|
||||
'-CAcreateserial',
|
||||
'-out',
|
||||
certPath,
|
||||
'-days',
|
||||
'825',
|
||||
'-extfile',
|
||||
tempExt,
|
||||
],
|
||||
{ stdio: 'pipe' }
|
||||
);
|
||||
chmodSync(keyPath, 0o600);
|
||||
chmodSync(certPath, 0o644);
|
||||
} finally {
|
||||
rmSync(tempCsr, { force: true });
|
||||
rmSync(tempExt, { force: true });
|
||||
// 1. Sync CA cert from global cache if available, or download from remote static URL
|
||||
if (!existsSync(caCertPath)) {
|
||||
if (existsSync(globalCaCert)) {
|
||||
writeFileSync(caCertPath, readFileSync(globalCaCert));
|
||||
chmodSync(caCertPath, 0o644);
|
||||
} else {
|
||||
try {
|
||||
execFileSync('curl', ['-sSfL', REMOTE_ROOT_CA_URL, '-o', caCertPath], {
|
||||
stdio: 'ignore',
|
||||
});
|
||||
chmodSync(caCertPath, 0o644);
|
||||
try {
|
||||
mkdirSync(globalCaDir, { recursive: true });
|
||||
writeFileSync(globalCaCert, readFileSync(caCertPath));
|
||||
chmodSync(globalCaCert, 0o644);
|
||||
} catch {
|
||||
// Best-effort global cache
|
||||
}
|
||||
} catch {
|
||||
// Failed to download, will fall back to generation if key also needed
|
||||
}
|
||||
}
|
||||
return { certPath, keyPath };
|
||||
}
|
||||
|
||||
const caDir = join(home, '.local/share/mkcert');
|
||||
mkdirSync(caDir, { recursive: true });
|
||||
execFileSync(
|
||||
'openssl',
|
||||
[
|
||||
'req',
|
||||
'-x509',
|
||||
'-nodes',
|
||||
'-newkey',
|
||||
'rsa:3072',
|
||||
'-keyout',
|
||||
hugoCaKey,
|
||||
'-out',
|
||||
hugoCaCert,
|
||||
'-days',
|
||||
'3650',
|
||||
'-subj',
|
||||
'/CN=mkcert development CA/O=mkcert development CA',
|
||||
'-batch',
|
||||
],
|
||||
{ stdio: 'pipe' }
|
||||
);
|
||||
// 2. Sync CA key from global cache if available, or decrypt from .env.dev
|
||||
if (!existsSync(caKeyPath)) {
|
||||
if (existsSync(globalCaKey)) {
|
||||
writeFileSync(caKeyPath, readFileSync(globalCaKey));
|
||||
chmodSync(caKeyPath, 0o600);
|
||||
} else {
|
||||
const decryptedKey = loadDevRootCaKey(workspaceRoot);
|
||||
if (decryptedKey) {
|
||||
writeFileSync(caKeyPath, decryptedKey);
|
||||
chmodSync(caKeyPath, 0o600);
|
||||
try {
|
||||
mkdirSync(globalCaDir, { recursive: true });
|
||||
writeFileSync(globalCaKey, decryptedKey);
|
||||
chmodSync(globalCaKey, 0o600);
|
||||
} catch {
|
||||
// Best-effort global cache
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If server cert and CA cert already exist, return them
|
||||
if (existsSync(certPath) && existsSync(keyPath) && existsSync(caCertPath)) {
|
||||
return { certPath, keyPath, caCertPath };
|
||||
}
|
||||
|
||||
// Ensure ErmnvldmrDevelopmentRootCA exists
|
||||
if (!existsSync(caCertPath) || !existsSync(caKeyPath)) {
|
||||
mkdirSync(globalCaDir, { recursive: true });
|
||||
execFileSync(
|
||||
'openssl',
|
||||
[
|
||||
'req',
|
||||
'-x509',
|
||||
'-nodes',
|
||||
'-newkey',
|
||||
'rsa:3072',
|
||||
'-keyout',
|
||||
caKeyPath,
|
||||
'-out',
|
||||
caCertPath,
|
||||
'-days',
|
||||
'3650',
|
||||
'-subj',
|
||||
'/CN=Ermnvldmr Development Root CA/O=ermnvldmr.com',
|
||||
'-batch',
|
||||
],
|
||||
{ stdio: 'pipe' }
|
||||
);
|
||||
chmodSync(caKeyPath, 0o600);
|
||||
chmodSync(caCertPath, 0o644);
|
||||
|
||||
writeFileSync(globalCaCert, readFileSync(caCertPath));
|
||||
writeFileSync(globalCaKey, readFileSync(caKeyPath));
|
||||
chmodSync(globalCaKey, 0o600);
|
||||
chmodSync(globalCaCert, 0o644);
|
||||
}
|
||||
|
||||
// Generate server certificate signed by ErmnvldmrDevelopmentRootCA
|
||||
const tempDir = tmpdir();
|
||||
const tempCsr = join(tempDir, `ermnvldmr-dev-${Date.now()}.csr`);
|
||||
const tempExt = join(tempDir, `ermnvldmr-dev-${Date.now()}.cnf`);
|
||||
@@ -318,9 +361,9 @@ export function ensureDevCertificates(
|
||||
'-in',
|
||||
tempCsr,
|
||||
'-CA',
|
||||
hugoCaCert,
|
||||
caCertPath,
|
||||
'-CAkey',
|
||||
hugoCaKey,
|
||||
caKeyPath,
|
||||
'-CAcreateserial',
|
||||
'-out',
|
||||
certPath,
|
||||
@@ -338,7 +381,7 @@ export function ensureDevCertificates(
|
||||
rmSync(tempExt, { force: true });
|
||||
}
|
||||
|
||||
return { certPath, keyPath };
|
||||
return { certPath, keyPath, caCertPath };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -76,3 +76,15 @@ export const WithBullets: Story = {
|
||||
</Breadcrumbs>
|
||||
),
|
||||
};
|
||||
|
||||
export const Large: Story = {
|
||||
render: () => (
|
||||
<Breadcrumbs size="l">
|
||||
<Breadcrumbs.Item href="/">Home</Breadcrumbs.Item>
|
||||
<Breadcrumbs.Separator variant="slash" />
|
||||
<Breadcrumbs.Item href="/articles">Articles</Breadcrumbs.Item>
|
||||
<Breadcrumbs.Separator variant="slash" />
|
||||
<Breadcrumbs.Item isCurrent>Deep Learning</Breadcrumbs.Item>
|
||||
</Breadcrumbs>
|
||||
),
|
||||
};
|
||||
@@ -36,6 +36,16 @@ describe('components/Breadcrumbs', () => {
|
||||
expect(ol.className).toContain('text-label-m');
|
||||
});
|
||||
|
||||
it('supports custom size variant', () => {
|
||||
render(
|
||||
<Breadcrumbs size="l">
|
||||
<Breadcrumbs.Item href="/test">Home</Breadcrumbs.Item>
|
||||
</Breadcrumbs>
|
||||
);
|
||||
const ol = screen.getByRole('list');
|
||||
expect(ol.className).toContain('text-label-l');
|
||||
});
|
||||
|
||||
it('supports isCurrent state', () => {
|
||||
render(
|
||||
<Breadcrumbs>
|
||||
|
||||
@@ -16,15 +16,44 @@ import type { ClassNameProps, TestIdProps } from '@ermnvldmr/stl';
|
||||
export interface BreadcrumbsProps extends HStackProps {
|
||||
/** Breadcrumb items and separators */
|
||||
children: React.ReactNode;
|
||||
/**
|
||||
* Typography size variant for the breadcrumbs trail.
|
||||
* @default 'm'
|
||||
*/
|
||||
size?: 's' | 'm' | 'l';
|
||||
}
|
||||
|
||||
const breadcrumbsVariants = cva('font-sans font-medium', {
|
||||
variants: {
|
||||
size: {
|
||||
s: 'text-label-s',
|
||||
m: 'text-label-m',
|
||||
l: 'text-label-l',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: 'm',
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Root component for hierarchical navigation.
|
||||
*/
|
||||
const BreadcrumbsRoot = memo(function Breadcrumbs({ children, className, ...props }: BreadcrumbsProps) {
|
||||
const BreadcrumbsRoot = memo(function Breadcrumbs({
|
||||
children,
|
||||
className,
|
||||
size = 'm',
|
||||
...props
|
||||
}: BreadcrumbsProps) {
|
||||
return (
|
||||
<nav aria-label="Breadcrumb" className={className}>
|
||||
<HStack align="center" as="ol" className="text-label-m font-sans font-medium" gap={2} {...props}>
|
||||
<nav aria-label="Breadcrumb">
|
||||
<HStack
|
||||
align="center"
|
||||
as="ol"
|
||||
className={cn(breadcrumbsVariants({ size }), className)}
|
||||
gap={2}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</HStack>
|
||||
</nav>
|
||||
@@ -126,7 +155,7 @@ const BreadcrumbsSeparator = memo(function BreadcrumbsSeparator({
|
||||
const content = {
|
||||
slash: '/',
|
||||
bull: '•',
|
||||
arrow: <ChevronRight className="size-4" />,
|
||||
arrow: <ChevronRight className="size-[1em]" />,
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -8,15 +8,15 @@ layout: hextra-home
|
||||
|
||||
<div class="hx:mb-6">
|
||||
{{< hextra/hero-headline >}}
|
||||
Documenting Decisions<br class="hx:sm:block hx:hidden" />Without the Actual Decisions
|
||||
Documenting Decisions<br class="hx:sm:block hx:hidden" />Without the Actual Decisions
|
||||
{{< /hextra/hero-headline >}}
|
||||
</div>
|
||||
|
||||
<div class="hx:mb-6">
|
||||
{{< hextra/hero-subtitle >}}
|
||||
I have neither the time nor the desire to manually craft every line of this half-baked novel. Yet capturing context and architectural decisions is essential, otherwise the number of entities comprehending what goes on here drops to zero. Consider this a structured neural knowledge dump for AI agents and rare human visitors.
|
||||
I have neither the time nor the desire to manually craft every line of this half-baked novel. Yet capturing context and architectural decisions is essential, otherwise the number of entities comprehending what goes on here drops to zero. Consider this a structured neural knowledge dump for AI agents and rare human visitors.
|
||||
<br /><br />
|
||||
If you ever feel like fixing anything (why though?) — any contribution here is guaranteed to be an improvement.
|
||||
If you ever feel like fixing anything (why though?) — any contribution here is guaranteed to be an improvement.
|
||||
{{< /hextra/hero-subtitle >}}
|
||||
</div>
|
||||
|
||||
@@ -46,7 +46,7 @@ If you ever feel like fixing anything (why though?) — any contributi
|
||||
<div class="hx:overflow-hidden hx:border hx:border-gray-200 hx:dark:border-neutral-800 hx:shadow-lg hx:bg-gray-50 hx:dark:bg-neutral-900/50" style="border-radius: 1.5rem; height: 100%; display: flex; flex-direction: column;">
|
||||
<img src="https://static.ermnvldmr.com/docs/images/home/dead-internet-debugging.webp" alt="Dead Internet Debugging" class="hx:w-full" style="flex: 1 1 auto; width: 100%; height: 100%; min-height: 340px; object-fit: cover; object-position: top; border-radius: 1.5rem 1.5rem 0 0;" />
|
||||
<div class="hx:p-3 hx:text-xs hx:text-gray-500 hx:dark:text-gray-400 hx:text-center hx:border-t hx:border-gray-200 hx:dark:border-neutral-800" style="flex-shrink: 0;">
|
||||
Image enhanced with Gemini 4 Argon · <a href="https://www.reddit.com/r/ProgrammerHumor/s/UVVd1CnFxH" target="_blank" rel="noreferrer" class="hx:underline hx:hover:text-primary-600">Original on Reddit</a>
|
||||
Image enhanced with Gemini 4 Argon · <a href="https://www.reddit.com/r/ProgrammerHumor/s/UVVd1CnFxH" target="_blank" rel="noreferrer" class="hx:underline hx:hover:text-primary-600">Original on Reddit</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
---
|
||||
title: Documentation of the apex fleet operations framework
|
||||
description: "Documentation for the apex fleet operations framework: a stdlib-python engine and a shared compose core for self-hosted nodes."
|
||||
title: Documentation of the apex fleet operations framework
|
||||
description: "Documentation for the apex fleet operations framework: a stdlib-python engine and a shared compose core for self-hosted nodes."
|
||||
weight: 1
|
||||
type: docs
|
||||
cascade:
|
||||
type: docs
|
||||
---
|
||||
|
||||
`apex` is the [commons](/apex/glossary#commons) of a fleet of self-hosted [nodes](/apex/glossary#node): a python3-stdlib [engine](/apex/glossary#engine) that runs grouped [actions](/apex/glossary#action) as `apex <group>/<action>`, paired with a shared, `${env}`-parameterized docker-compose core. Every node repository consumes the framework as the pinned `commons/` git submodule. apex succeeds the bash `root` commons framework.
|
||||
`apex` is the [commons](/apex/glossary#commons) of a fleet of self-hosted [nodes](/apex/glossary#node): a python3-stdlib [engine](/apex/glossary#engine) that runs grouped [actions](/apex/glossary#action) as `apex <group>/<action>`, paired with a shared, `${env}`-parameterized docker-compose core. Every node repository consumes the framework as the pinned `commons/` git submodule. apex succeeds the bash `root` commons framework.
|
||||
|
||||
The stack is deliberately small: `git` plus the Python 3 standard library. There is no `pip`, Node, Go, or CUE, and compose files are hand-authored — no code generation.
|
||||
The stack is deliberately small: `git` plus the Python 3 standard library. There is no `pip`, Node, Go, or CUE, and compose files are hand-authored — no code generation.
|
||||
|
||||
## Core features
|
||||
|
||||
- **Single entrypoint on the local checkout** — `apex` always acts on the checkout it runs from; there is no `<node>` argument to pass.
|
||||
- **Explicit identity from `node.env`** — [node identity](/apex/glossary#node-identity) is declared per node in `node.env` (`APEX_NODE_FQDN`, `APEX_NODE_HOST`, `APEX_SUBNET`); the framework assumes no particular domain, and the short node `name` is just the first label of the host.
|
||||
- **Four-pillar node repositories** — every node repository follows the same shape: the pinned `commons/` submodule, node-local [proprietaries](/apex/glossary#proprietaries), the node's [compositions](/apex/glossary#compositions), and its `node.env`.
|
||||
- **Proprietary overlay** — node-local proprietaries are [overlaid](/apex/glossary#overlay) over the shared actions; commons holds only what every node shares.
|
||||
- **Tiered storage with privilege isolation** — standardized storage [tiers](/apex/glossary#tier) with per-project privilege isolation.
|
||||
- **Shared core composition** — the [core composition](/apex/glossary#core-composition) (`compositions/apex/docker-compose.yml`) is `${env}`-parameterized and included in every node's compositions.
|
||||
- **Single entrypoint on the local checkout** — `apex` always acts on the checkout it runs from; there is no `<node>` argument to pass.
|
||||
- **Explicit identity from `node.env`** — [node identity](/apex/glossary#node-identity) is declared per node in `node.env` (`APEX_NODE_FQDN`, `APEX_NODE_HOST`, `APEX_SUBNET`); the framework assumes no particular domain, and the short node `name` is just the first label of the host.
|
||||
- **Four-pillar node repositories** — every node repository follows the same shape: the pinned `commons/` submodule, node-local [proprietaries](/apex/glossary#proprietaries), the node's [compositions](/apex/glossary#compositions), and its `node.env`.
|
||||
- **Proprietary overlay** — node-local proprietaries are [overlaid](/apex/glossary#overlay) over the shared actions; commons holds only what every node shares.
|
||||
- **Tiered storage with privilege isolation** — standardized storage [tiers](/apex/glossary#tier) with per-project privilege isolation.
|
||||
- **Shared core composition** — the [core composition](/apex/glossary#core-composition) (`compositions/apex/docker-compose.yml`) is `${env}`-parameterized and included in every node's compositions.
|
||||
|
||||
## Sections
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
---
|
||||
title: Concepts
|
||||
description: "Architecture, identity, actions, execution context, compositions, storage, addressing, and security."
|
||||
description: "Architecture, identity, actions, execution context, compositions, storage, addressing, and security."
|
||||
weight: 10
|
||||
---
|
||||
|
||||
Theoretical foundations of the apex fleet-operations framework: how a [node](/apex/glossary#node) declares its [identity](/apex/concepts/node-identity), how [actions](/apex/glossary#action) execute, and how a node organizes its services, storage, and addressing.
|
||||
Theoretical foundations of the apex fleet-operations framework: how a [node](/apex/glossary#node) declares its [identity](/apex/concepts/node-identity), how [actions](/apex/glossary#action) execute, and how a node organizes its services, storage, and addressing.
|
||||
|
||||
{{% cards %}}
|
||||
{{< card link="architecture" title="Architecture" icon="share" subtitle="The engine, commons, and configs layers and how they fit together." >}}
|
||||
@@ -21,5 +21,5 @@ Theoretical foundations of the apex fleet-operations framework: how a [node](/ap
|
||||
|
||||
**See also:**
|
||||
|
||||
- [Guides: Operating an apex fleet](/apex/guides)
|
||||
- [Reference: Commands and interfaces](/apex/reference)
|
||||
- [Guides: Operating an apex fleet](/apex/guides)
|
||||
- [Reference: Commands and interfaces](/apex/reference)
|
||||
@@ -1,19 +1,19 @@
|
||||
---
|
||||
title: "Actions and the overlay"
|
||||
description: "The action contract and how node-local proprietaries shadow commons actions."
|
||||
title: "Actions and the overlay"
|
||||
description: "The action contract and how node-local proprietaries shadow commons actions."
|
||||
weight: 13
|
||||
---
|
||||
|
||||
Every unit of automation in apex is an [action](/apex/glossary#action): a single Python module that the [engine](/apex/glossary#engine) dispatches by name. The [overlay](/apex/glossary#overlay) lets a node's [proprietaries](/apex/glossary#proprietaries) add, override, or disable [commons](/apex/glossary#commons) actions without forking them. This page defines the action contract, the declarative argument descriptors, and the resolution rules.
|
||||
Every unit of automation in apex is an [action](/apex/glossary#action): a single Python module that the [engine](/apex/glossary#engine) dispatches by name. The [overlay](/apex/glossary#overlay) lets a node's [proprietaries](/apex/glossary#proprietaries) add, override, or disable [commons](/apex/glossary#commons) actions without forking them. This page defines the action contract, the declarative argument descriptors, and the resolution rules.
|
||||
|
||||
## The action contract {#contract}
|
||||
## The action contract {#contract}
|
||||
|
||||
An action is one `.py` file under an `actions/` tree. The file path **is** the action name: `actions/<group>/<action>.py` is invoked as `apex <group>/<action> [args...]`. There is no registry to update — dropping a file into the tree publishes the action.
|
||||
An action is one `.py` file under an `actions/` tree. The file path **is** the action name: `actions/<group>/<action>.py` is invoked as `apex <group>/<action> [args...]`. There is no registry to update — dropping a file into the tree publishes the action.
|
||||
|
||||
The module must expose two things:
|
||||
The module must expose two things:
|
||||
|
||||
- **`METADATA = Meta(...)`** → the summary and argument descriptors that drive both the help text and parsing. The descriptor system succeeds the `action_metadata()` heredoc of the bash "root" commons framework, apex's predecessor.
|
||||
- **`run(ctx, args)`** → the entrypoint. `ctx` is the engine-built execution context; `args` is the parsed `argparse` namespace.
|
||||
- **`METADATA = Meta(...)`** → the summary and argument descriptors that drive both the help text and parsing. The descriptor system succeeds the `action_metadata()` heredoc of the bash "root" commons framework, apex's predecessor.
|
||||
- **`run(ctx, args)`** → the entrypoint. `ctx` is the engine-built execution context; `args` is the parsed `argparse` namespace.
|
||||
|
||||
```python {filename="actions/<group>/<action>.py"}
|
||||
from engine.descriptor import Meta, Arg, Flag
|
||||
@@ -31,36 +31,36 @@ def run(ctx, args):
|
||||
return 0
|
||||
```
|
||||
|
||||
The return value of `run` becomes the process exit code. A non-integer return (including `None`) is treated as success (`0`). An unhandled exception is logged with the context prefix and exits `1`.
|
||||
The return value of `run` becomes the process exit code. A non-integer return (including `None`) is treated as success (`0`). An unhandled exception is logged with the context prefix and exits `1`.
|
||||
|
||||
> [!NOTE]
|
||||
> `METADATA` is technically optional — a module without it is dispatched with an empty `Meta` and accepts no arguments — but every real action should declare it, since the listing and `--help` output are generated from it.
|
||||
> `METADATA` is technically optional — a module without it is dispatched with an empty `Meta` and accepts no arguments — but every real action should declare it, since the listing and `--help` output are generated from it.
|
||||
|
||||
## Declarative arguments {#arguments}
|
||||
|
||||
`Meta.args` is a list of descriptors. Each maps to exactly one `argparse` construct:
|
||||
`Meta.args` is a list of descriptors. Each maps to exactly one `argparse` construct:
|
||||
|
||||
| Descriptor | Maps to | Behavior |
|
||||
| Descriptor | Maps to | Behavior |
|
||||
| --- | --- | --- |
|
||||
| `Arg("name")` | positional argument | Required by default; `required=False` makes it an optional positional (`nargs="?"`, default `None`). |
|
||||
| `Opt("--name", default=...)` | value option | An optional flag that takes a value, with a declared default. |
|
||||
| `Arg("name")` | positional argument | Required by default; `required=False` makes it an optional positional (`nargs="?"`, default `None`). |
|
||||
| `Opt("--name", default=...)` | value option | An optional flag that takes a value, with a declared default. |
|
||||
| `Flag("--name")` | boolean flag | `store_true`; present means `True`. |
|
||||
| `Rest("name")` | remainder | Greedy passthrough of all remaining argv (`argparse.REMAINDER`). |
|
||||
| `Rest("name")` | remainder | Greedy passthrough of all remaining argv (`argparse.REMAINDER`). |
|
||||
|
||||
The engine builds the parser as `apex <group>/<action>` and parses the remaining argv before calling `run`. `--help` is handled at parse time. A usage error — unknown flag, missing positional — prints usage and exits `64` (`EX_USAGE`) instead of argparse's default `2`.
|
||||
The engine builds the parser as `apex <group>/<action>` and parses the remaining argv before calling `run`. `--help` is handled at parse time. A usage error — unknown flag, missing positional — prints usage and exits `64` (`EX_USAGE`) instead of argparse's default `2`.
|
||||
|
||||
## Listing without importing {#listing}
|
||||
|
||||
`apex` with no arguments (or `--help`) lists every available action. Listing never imports action modules: the engine statically scans each file with the `ast` module and extracts only the `METADATA` summary and any `DISABLED` constant. Action code therefore cannot run — or crash — at list time; a file that fails to parse is listed as `(unparseable)` rather than breaking the listing.
|
||||
`apex` with no arguments (or `--help`) lists every available action. Listing never imports action modules: the engine statically scans each file with the `ast` module and extracts only the `METADATA` summary and any `DISABLED` constant. Action code therefore cannot run — or crash — at list time; a file that fails to parse is listed as `(unparseable)` rather than breaking the listing.
|
||||
|
||||
Each entry is tagged with its overlay source:
|
||||
Each entry is tagged with its overlay source:
|
||||
|
||||
- no tag → a commons action,
|
||||
- `(local)` → exists only in proprietaries,
|
||||
- `(local override)` → a proprietaries action shadowing a commons one,
|
||||
- `[DISABLED: reason]` → the action declares `DISABLED`.
|
||||
- no tag → a commons action,
|
||||
- `(local)` → exists only in proprietaries,
|
||||
- `(local override)` → a proprietaries action shadowing a commons one,
|
||||
- `[DISABLED: reason]` → the action declares `DISABLED`.
|
||||
|
||||
## The overlay {#overlay}
|
||||
## The overlay {#overlay}
|
||||
|
||||
Action names are resolved across two trees, proprietaries first:
|
||||
|
||||
@@ -81,31 +81,31 @@ Action names are resolved across two trees, proprietaries first:
|
||||
{{< /filetree/folder >}}
|
||||
{{< /filetree/container >}}
|
||||
|
||||
The engine detects this layout by its own location: when it runs from a `commons/` directory whose parent contains `node.env`, the parent is the [node](/apex/glossary#node) repository root and `proprietaries/actions/` is the overlay tree. A standalone commons checkout is its own root and has no overlay layer. Inside a node repository, [node identity](/apex/glossary#node-identity) is resolved from the host FQDN before dispatch.
|
||||
The engine detects this layout by its own location: when it runs from a `commons/` directory whose parent contains `node.env`, the parent is the [node](/apex/glossary#node) repository root and `proprietaries/actions/` is the overlay tree. A standalone commons checkout is its own root and has no overlay layer. Inside a node repository, [node identity](/apex/glossary#node-identity) is resolved from the host FQDN before dispatch.
|
||||
|
||||
Resolution for a given name checks `proprietaries/actions/<name>.py` first and `commons/actions/<name>.py` second; exactly one module is loaded by path. An unknown name is an error (exit `1`). The overlay supports four moves:
|
||||
Resolution for a given name checks `proprietaries/actions/<name>.py` first and `commons/actions/<name>.py` second; exactly one module is loaded by path. An unknown name is an error (exit `1`). The overlay supports four moves:
|
||||
|
||||
- **Add** → a file that exists only in proprietaries becomes a node-local action, listed as `(local)`.
|
||||
- **Override** → a proprietaries file at the same relative path shadows the commons implementation entirely; the commons path is recorded on the resolution as `shadowed`.
|
||||
- **Wrap** → an overriding module can delegate to the implementation it shadows via `ctx.commons.run(args)`, adding node-local behavior before or after the commons logic instead of replacing it.
|
||||
- **Disable** → a module that assigns `DISABLED = "reason"` is refused at dispatch: the engine logs the reason and exits `1`. The listing shows `[DISABLED: reason]`. A bare `DISABLED = True` also disables, with the generic reason `disabled`; an empty string does not.
|
||||
- **Add** → a file that exists only in proprietaries becomes a node-local action, listed as `(local)`.
|
||||
- **Override** → a proprietaries file at the same relative path shadows the commons implementation entirely; the commons path is recorded on the resolution as `shadowed`.
|
||||
- **Wrap** → an overriding module can delegate to the implementation it shadows via `ctx.commons.run(args)`, adding node-local behavior before or after the commons logic instead of replacing it.
|
||||
- **Disable** → a module that assigns `DISABLED = "reason"` is refused at dispatch: the engine logs the reason and exits `1`. The listing shows `[DISABLED: reason]`. A bare `DISABLED = True` also disables, with the generic reason `disabled`; an empty string does not.
|
||||
|
||||
## Exit codes {#exit-codes}
|
||||
|
||||
Actions and the engine follow a fixed exit-code convention:
|
||||
Actions and the engine follow a fixed exit-code convention:
|
||||
|
||||
| Code | Meaning | Typical cause |
|
||||
| --- | --- | --- |
|
||||
| `0` | success | `run` returned `0` or a non-integer. |
|
||||
| `0` | success | `run` returned `0` or a non-integer. |
|
||||
| `1` | general error | Unknown action, disabled action, unhandled exception, non-integer `SystemExit`. |
|
||||
| `64` | usage error | Arguments rejected by the parser (`EX_USAGE`). |
|
||||
| `65` | data error | Malformed input data (`EX_DATAERR`), raised by engine libraries. |
|
||||
| `66` | missing input | A required input is absent (`EX_NOINPUT`), raised by engine libraries. |
|
||||
| `64` | usage error | Arguments rejected by the parser (`EX_USAGE`). |
|
||||
| `65` | data error | Malformed input data (`EX_DATAERR`), raised by engine libraries. |
|
||||
| `66` | missing input | A required input is absent (`EX_NOINPUT`), raised by engine libraries. |
|
||||
|
||||
---
|
||||
|
||||
**See also:**
|
||||
|
||||
- [Concept: Framework architecture](/apex/concepts/architecture)
|
||||
- [Concept: The execution context](/apex/concepts/execution-context)
|
||||
- [Concept: The execution context](/apex/concepts/execution-context)
|
||||
- [GitHub: apex.ermnvldmr.com](https://github.com/deytenit/apex.ermnvldmr.com)
|
||||
@@ -1,46 +1,46 @@
|
||||
---
|
||||
title: "Fleet addressing scheme"
|
||||
description: "Per-node /24 subnets and static anchor IPs inside the enclave space (default 198.18.0.0/16)."
|
||||
description: "Per-node /24 subnets and static anchor IPs inside the enclave space (default 198.18.0.0/16)."
|
||||
weight: 17
|
||||
---
|
||||
|
||||
Apex assigns every service a fixed, predictable IPv4 address inside a per-node subnet. Each node declares its own `/24`, service anchors are static addresses within it, and the whole scheme is enforced mechanically by the anchor-IP lint.
|
||||
Apex assigns every service a fixed, predictable IPv4 address inside a per-node subnet. Each node declares its own `/24`, service anchors are static addresses within it, and the whole scheme is enforced mechanically by the anchor-IP lint.
|
||||
|
||||
## Address space {#address-space}
|
||||
|
||||
The [enclave](/apex/glossary#enclave) space defaults to `198.18.0.0/16`, the benchmarking range set aside by [RFC 2544: Benchmarking Methodology for Network Interconnect Devices](https://datatracker.ietf.org/doc/html/rfc2544). This range is never routed on the public Internet and does not collide with the common private ranges (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`) that cloud providers and home networks already consume. The operator may override it via `APEX_ENCLAVE_SUBNET`.
|
||||
The [enclave](/apex/glossary#enclave) space defaults to `198.18.0.0/16`, the benchmarking range set aside by [RFC 2544: Benchmarking Methodology for Network Interconnect Devices](https://datatracker.ietf.org/doc/html/rfc2544). This range is never routed on the public Internet and does not collide with the common private ranges (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`) that cloud providers and home networks already consume. The operator may override it via `APEX_ENCLAVE_SUBNET`.
|
||||
|
||||
## Node subnets {#node-subnets}
|
||||
|
||||
Each [node](/apex/glossary#node) declares exactly one `/24` as `APEX_SUBNET` in its `node.env`, and that subnet is the `direct` network's IPAM range. The operator assigns it; conventionally it sits inside the enclave space (`198.18.0.0/16` by default, or whatever `APEX_ENCLAVE_SUBNET` is set to), but the framework imposes no arithmetic on the choice.
|
||||
Each [node](/apex/glossary#node) declares exactly one `/24` as `APEX_SUBNET` in its `node.env`, and that subnet is the `direct` network's IPAM range. The operator assigns it; conventionally it sits inside the enclave space (`198.18.0.0/16` by default, or whatever `APEX_ENCLAVE_SUBNET` is set to), but the framework imposes no arithmetic on the choice.
|
||||
|
||||
> [!NOTE]
|
||||
> As an optional operator convention, you may organize node `/24`s into contiguous blocks (for example, grouping related nodes) to keep allocations legible. This is a bookkeeping choice, not a framework rule.
|
||||
> As an optional operator convention, you may organize node `/24`s into contiguous blocks (for example, grouping related nodes) to keep allocations legible. This is a bookkeeping choice, not a framework rule.
|
||||
|
||||
## Allocation inside a node /24 {#node-allocation}
|
||||
## Allocation inside a node /24 {#node-allocation}
|
||||
|
||||
Within a node's `/24`, host addresses are partitioned by role:
|
||||
Within a node's `/24`, host addresses are partitioned by role:
|
||||
|
||||
| Range | Purpose |
|
||||
|---|---|
|
||||
| `.10`–`.19` | Reserved for [core composition](/apex/glossary#core-composition) anchors; `traefik` is always `.10` (via `APEX_TRAEFIK_DIRECT_IP`) |
|
||||
| `.20` and up | Service anchors, allocated per `<project>` |
|
||||
| `.10`–`.19` | Reserved for [core composition](/apex/glossary#core-composition) anchors; `traefik` is always `.10` (via `APEX_TRAEFIK_DIRECT_IP`) |
|
||||
| `.20` and up | Service anchors, allocated per `<project>` |
|
||||
|
||||
Every service anchor is a static `ipv4_address` on the direct network — a fixed [anchor IP](/apex/glossary#anchor-ip) rather than a dynamically assigned lease. Allocations are recorded in `compositions/ADDRESSING.md`, which serves as the human-readable register for the node's subnet.
|
||||
Every service anchor is a static `ipv4_address` on the direct network — a fixed [anchor IP](/apex/glossary#anchor-ip) rather than a dynamically assigned lease. Allocations are recorded in `compositions/ADDRESSING.md`, which serves as the human-readable register for the node's subnet.
|
||||
|
||||
## Enforcement {#enforcement}
|
||||
|
||||
The compose lint validates anchor IPs across all [compositions](/apex/glossary#compositions) on a node. For every `ipv4_address` found in `compositions/*/docker-compose.yml` it checks that the address is:
|
||||
The compose lint validates anchor IPs across all [compositions](/apex/glossary#compositions) on a node. For every `ipv4_address` found in `compositions/*/docker-compose.yml` it checks that the address is:
|
||||
|
||||
- **Well-formed** — a malformed literal is reported as a lint error, not a crash.
|
||||
- **Unique** — the same anchor IP may not appear in two projects; duplicates are reported with both offending projects.
|
||||
- **Inside the node subnet** — every anchor must fall within the node's declared `/24`.
|
||||
- **Well-formed** — a malformed literal is reported as a lint error, not a crash.
|
||||
- **Unique** — the same anchor IP may not appear in two projects; duplicates are reported with both offending projects.
|
||||
- **Inside the node subnet** — every anchor must fall within the node's declared `/24`.
|
||||
|
||||
Any violation fails the lint with exit code `1` (general error). Passing validation logs `Anchor IP validation passed.` The implementation lives in the `utils/lint-docker-compose` [action](/apex/glossary#action); see [GitHub: apex.ermnvldmr.com](https://github.com/deytenit/apex.ermnvldmr.com).
|
||||
Any violation fails the lint with exit code `1` (general error). Passing validation logs `Anchor IP validation passed.` The implementation lives in the `utils/lint-docker-compose` [action](/apex/glossary#action); see [GitHub: apex.ermnvldmr.com](https://github.com/deytenit/apex.ermnvldmr.com).
|
||||
|
||||
---
|
||||
|
||||
**See also:**
|
||||
|
||||
- [GitHub: apex.ermnvldmr.com](https://github.com/deytenit/apex.ermnvldmr.com)
|
||||
- [RFC 2544: Benchmarking Methodology for Network Interconnect Devices](https://datatracker.ietf.org/doc/html/rfc2544)
|
||||
- [RFC 2544: Benchmarking Methodology for Network Interconnect Devices](https://datatracker.ietf.org/doc/html/rfc2544)
|
||||
@@ -1,14 +1,14 @@
|
||||
---
|
||||
title: Framework architecture
|
||||
description: Four-pillar node repositories, the commons submodule, and the dispatch flow.
|
||||
description: Four-pillar node repositories, the commons submodule, and the dispatch flow.
|
||||
weight: 11
|
||||
---
|
||||
|
||||
apex splits fleet automation into one shared repository — the [commons](/apex/glossary#commons) — and one repository per [node](/apex/glossary#node). The commons carries everything every node shares: the [engine](/apex/glossary#engine), the shared [actions](/apex/glossary#action), and the [core composition](/apex/glossary#core-composition). A node repository carries everything that makes that node itself: its identity, host [configs](/apex/glossary#configs), service [compositions](/apex/glossary#compositions), and node-local [proprietaries](/apex/glossary#proprietaries).
|
||||
apex splits fleet automation into one shared repository — the [commons](/apex/glossary#commons) — and one repository per [node](/apex/glossary#node). The commons carries everything every node shares: the [engine](/apex/glossary#engine), the shared [actions](/apex/glossary#action), and the [core composition](/apex/glossary#core-composition). A node repository carries everything that makes that node itself: its identity, host [configs](/apex/glossary#configs), service [compositions](/apex/glossary#compositions), and node-local [proprietaries](/apex/glossary#proprietaries).
|
||||
|
||||
## The four pillars {#pillars}
|
||||
## The four pillars {#pillars}
|
||||
|
||||
Every node repository has the same shape:
|
||||
Every node repository has the same shape:
|
||||
|
||||
{{< filetree/container >}}
|
||||
{{< filetree/folder name="<node>.<your-domain>" state="open" >}}
|
||||
@@ -23,16 +23,16 @@ Every node repository has the same shape:
|
||||
|
||||
| Pillar | Content | Ownership |
|
||||
|---|---|---|
|
||||
| `commons/` | Git submodule → `apex.ermnvldmr.com`, pinned to a release tag | Shared, versioned |
|
||||
| `proprietaries/` | Node-local actions, libraries, and tool sources ([overlaid](/apex/glossary#overlay) over commons) | Node |
|
||||
| `commons/` | Git submodule → `apex.ermnvldmr.com`, pinned to a release tag | Shared, versioned |
|
||||
| `proprietaries/` | Node-local actions, libraries, and tool sources ([overlaid](/apex/glossary#overlay) over commons) | Node |
|
||||
| `configs/` | Host configuration sources: `ufw/`, `cron/crontab`, `systemd/`, `crowdsec/` | Node |
|
||||
| `compositions/` | One directory per project plus the `apex/` core wrapper and the addressing ledger | Node |
|
||||
| `compositions/` | One directory per project plus the `apex/` core wrapper and the addressing ledger | Node |
|
||||
|
||||
The submodule pin is the framework's version contract: a node runs exactly the engine and core composition recorded in its gitlink. Upgrading a node is a deliberate act — check out a newer tag inside `commons/` and commit the moved pointer.
|
||||
The submodule pin is the framework's version contract: a node runs exactly the engine and core composition recorded in its gitlink. Upgrading a node is a deliberate act — check out a newer tag inside `commons/` and commit the moved pointer.
|
||||
|
||||
## Dispatch flow {#dispatch}
|
||||
|
||||
The launcher `commons/apex` acts on the local checkout; there is no node argument anywhere.
|
||||
The launcher `commons/apex` acts on the local checkout; there is no node argument anywhere.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
@@ -47,32 +47,32 @@ flowchart TD
|
||||
H --> I["run(ctx, args)"]
|
||||
```
|
||||
|
||||
The engine detects the layout from its own location: running from a directory named `commons/` whose parent contains `node.env` means "node checkout" — the parent becomes the repository root and `proprietaries/actions/` becomes the overlay tree. Any other location is a standalone commons checkout acting as its own root.
|
||||
The engine detects the layout from its own location: running from a directory named `commons/` whose parent contains `node.env` means "node checkout" — the parent becomes the repository root and `proprietaries/actions/` becomes the overlay tree. Any other location is a standalone commons checkout acting as its own root.
|
||||
|
||||
Listing (`apex --help`) never imports action modules; summaries and disabled markers are extracted by static `ast` scanning. Dispatch loads exactly one module by path. Details in [Concept: Actions and the overlay](/apex/concepts/actions-and-overlay).
|
||||
Listing (`apex --help`) never imports action modules; summaries and disabled markers are extracted by static `ast` scanning. Dispatch loads exactly one module by path. Details in [Concept: Actions and the overlay](/apex/concepts/actions-and-overlay).
|
||||
|
||||
## The capture-up model {#capture-up}
|
||||
## The capture-up model {#capture-up}
|
||||
|
||||
Node repositories are ledgers, not just sources: the host itself records what it runs.
|
||||
Node repositories are ledgers, not just sources: the host itself records what it runs.
|
||||
|
||||
1. A scheduled `sync/repository` run on the host commits the working tree to a `sync/<node>` branch (including the commons submodule pointer), rebases it onto `origin/main`, and pushes with `--force-with-lease`.
|
||||
2. A scheduled CI job in the node repository merges `sync/<node>` back into `main` under a bot identity.
|
||||
1. A scheduled `sync/repository` run on the host commits the working tree to a `sync/<node>` branch (including the commons submodule pointer), rebases it onto `origin/main`, and pushes with `--force-with-lease`.
|
||||
2. A scheduled CI job in the node repository merges `sync/<node>` back into `main` under a bot identity.
|
||||
|
||||
This [capture-up](/apex/glossary#capture-up) loop means drift on a host becomes a commit — visible, diffable, and revertable — instead of silent divergence. The sync aborts rather than committing when the commons submodule is dirty or the `compositions/` tree is missing, so a broken checkout never becomes the recorded state.
|
||||
This [capture-up](/apex/glossary#capture-up) loop means drift on a host becomes a commit — visible, diffable, and revertable — instead of silent divergence. The sync aborts rather than committing when the commons submodule is dirty or the `compositions/` tree is missing, so a broken checkout never becomes the recorded state.
|
||||
|
||||
> [!NOTE]
|
||||
> The CI merge job checks out the repository *without* submodules: the commons submodule uses an SSH URL that the default CI token cannot fetch, and the merge only moves the gitlink pointer anyway.
|
||||
> The CI merge job checks out the repository *without* submodules: the commons submodule uses an SSH URL that the default CI token cannot fetch, and the merge only moves the gitlink pointer anyway.
|
||||
|
||||
## Design constraints {#constraints}
|
||||
|
||||
- **Stdlib only.** The engine runs on a stock Debian Python 3 — no `pip`, no third-party imports. The optional `vendor/` directory exists for pinned pure-Python code but ships empty.
|
||||
- **Hand-authored composes.** There is no manifest layer or code generation; compose files are the source of truth.
|
||||
- **Evidence over convention.** Anything enforceable is enforced by an action (anchor-IP lint, crontab rollback, marker-block self-healing) rather than by documentation.
|
||||
- **Stdlib only.** The engine runs on a stock Debian Python 3 — no `pip`, no third-party imports. The optional `vendor/` directory exists for pinned pure-Python code but ships empty.
|
||||
- **Hand-authored composes.** There is no manifest layer or code generation; compose files are the source of truth.
|
||||
- **Evidence over convention.** Anything enforceable is enforced by an action (anchor-IP lint, crontab rollback, marker-block self-healing) rather than by documentation.
|
||||
|
||||
---
|
||||
|
||||
**See also:**
|
||||
|
||||
- [Concept: Actions and the overlay](/apex/concepts/actions-and-overlay)
|
||||
- [Concept: Actions and the overlay](/apex/concepts/actions-and-overlay)
|
||||
- [Concept: Node identity resolution](/apex/concepts/node-identity)
|
||||
- [Guide: Build a node repository](/apex/guides/node-repository)
|
||||
- [Guide: Build a node repository](/apex/guides/node-repository)
|
||||
@@ -1,14 +1,14 @@
|
||||
---
|
||||
title: Compositions and the shared core
|
||||
description: The include-based core composition, profiles, networks, and env layering.
|
||||
title: Compositions and the shared core
|
||||
description: The include-based core composition, profiles, networks, and env layering.
|
||||
weight: 15
|
||||
---
|
||||
|
||||
A node's runtime is declared as [compositions](/apex/glossary#compositions): one docker-compose project per service under `compositions/<project>/`, plus the [core composition](/apex/glossary#core-composition) — the edge and infrastructure services every node needs. The core lives in the [commons](/apex/glossary#commons) and is consumed per node through a thin wrapper, so its definition is written once and versioned with the framework.
|
||||
A node's runtime is declared as [compositions](/apex/glossary#compositions): one docker-compose project per service under `compositions/<project>/`, plus the [core composition](/apex/glossary#core-composition) — the edge and infrastructure services every node needs. The core lives in the [commons](/apex/glossary#commons) and is consumed per node through a thin wrapper, so its definition is written once and versioned with the framework.
|
||||
|
||||
## The include wrapper {#include}
|
||||
## The include wrapper {#include}
|
||||
|
||||
Each node's `compositions/apex/docker-compose.yml` is three lines:
|
||||
Each node's `compositions/apex/docker-compose.yml` is three lines:
|
||||
|
||||
```yaml {filename="compositions/apex/docker-compose.yml"}
|
||||
name: apex
|
||||
@@ -16,9 +16,9 @@ include:
|
||||
- ../../commons/compositions/apex/docker-compose.yml
|
||||
```
|
||||
|
||||
The shared file is fully `${APEX_*}`-parameterized and contains no relative host paths — every mount resolves through variables the [engine](/apex/glossary#engine) exports at invocation, so the same file is correct on every node.
|
||||
The shared file is fully `${APEX_*}`-parameterized and contains no relative host paths — every mount resolves through variables the [engine](/apex/glossary#engine) exports at invocation, so the same file is correct on every node.
|
||||
|
||||
A structural outlier — a node that genuinely needs to change a core service, such as publishing an extra host port — layers an override through the include path-list form instead of editing the shared file:
|
||||
A structural outlier — a node that genuinely needs to change a core service, such as publishing an extra host port — layers an override through the include path-list form instead of editing the shared file:
|
||||
|
||||
```yaml {filename="compositions/apex/docker-compose.yml"}
|
||||
name: apex
|
||||
@@ -29,59 +29,59 @@ include:
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> The `include:` directive requires Docker Compose v2.20 or newer.
|
||||
> The `include:` directive requires Docker Compose v2.20 or newer.
|
||||
|
||||
## Core services and profiles {#core-services}
|
||||
## Core services and profiles {#core-services}
|
||||
|
||||
The core composition defines the fleet's shared service set. Always-on services:
|
||||
The core composition defines the fleet's shared service set. Always-on services:
|
||||
|
||||
- **traefik** — the edge reverse proxy and the anchor of the `direct` network (always `.10` in the node subnet). It terminates TLS via a DNS challenge, discovers backends through docker labels, and exposes its dashboard on the node FQDN behind an [enclave](/apex/glossary#enclave) IP allowlist.
|
||||
- **socket-proxy** — a filtered docker-socket proxy; traefik reads container state through it and never touches the socket directly.
|
||||
- **traefik** — the edge reverse proxy and the anchor of the `direct` network (always `.10` in the node subnet). It terminates TLS via a DNS challenge, discovers backends through docker labels, and exposes its dashboard on the node FQDN behind an [enclave](/apex/glossary#enclave) IP allowlist.
|
||||
- **socket-proxy** — a filtered docker-socket proxy; traefik reads container state through it and never touches the socket directly.
|
||||
|
||||
Opt-in services, gated by compose profiles a node enables in `COMPOSE_PROFILES` (set in `apex.env`):
|
||||
Opt-in services, gated by compose profiles a node enables in `COMPOSE_PROFILES` (set in `apex.env`):
|
||||
|
||||
| Profile | Service | Role |
|
||||
|---|---|---|
|
||||
| `xray` | xray | Host-network proxy edge for the enclave. |
|
||||
| `adguard` | adguard | DNS filtering, sharing traefik's network namespace; routed at `/adguard` on the node FQDN. |
|
||||
| `alloy` | alloy | Per-node observability agent (Grafana Alloy). Collects host + container metrics and journal + container logs and **pushes** them outbound to a central Prometheus and Loki (see [Concept: Security model](/apex/concepts/security-model#observability)). Its container `hostname` is set to `APEX_NODE_HOST`, and that value stamps the `node` and `instance` labels on every metric and log stream. |
|
||||
| `manual` | resticontainer | One-shot label-driven backup runner (`ghcr.io/deytenit/resticontainer`) — never starts with `up`; invoked by the `backup/run` action, which discovers per-service backup intent from `restic.*` compose labels. |
|
||||
| `xray` | xray | Host-network proxy edge for the enclave. |
|
||||
| `adguard` | adguard | DNS filtering, sharing traefik's network namespace; routed at `/adguard` on the node FQDN. |
|
||||
| `alloy` | alloy | Per-node observability agent (Grafana Alloy). Collects host + container metrics and journal + container logs and **pushes** them outbound to a central Prometheus and Loki (see [Concept: Security model](/apex/concepts/security-model#observability)). Its container `hostname` is set to `APEX_NODE_HOST`, and that value stamps the `node` and `instance` labels on every metric and log stream. |
|
||||
| `manual` | resticontainer | One-shot label-driven backup runner (`ghcr.io/deytenit/resticontainer`) — never starts with `up`; invoked by the `backup/run` action, which discovers per-service backup intent from `restic.*` compose labels. |
|
||||
|
||||
## Networks {#networks}
|
||||
|
||||
The core composition owns the three node networks; service compositions reference them by key with `external: true`.
|
||||
The core composition owns the three node networks; service compositions reference them by key with `external: true`.
|
||||
|
||||
| Network | Type | Purpose |
|
||||
|---|---|---|
|
||||
| `direct` | bridge, subnet = `APEX_SUBNET` | Static-[anchor-IP](/apex/glossary#anchor-ip) network; every project's anchor holds a fixed address here (see [Concept: Fleet addressing scheme](/apex/concepts/addressing)). |
|
||||
| `direct` | bridge, subnet = `APEX_SUBNET` | Static-[anchor-IP](/apex/glossary#anchor-ip) network; every project's anchor holds a fixed address here (see [Concept: Fleet addressing scheme](/apex/concepts/addressing)). |
|
||||
| `enclave` | bridge, `internal: true` | Reverse-proxy backend network; traefik reaches labeled services here. |
|
||||
| `socket` | bridge, `internal: true` | Carries only traefik ↔ socket-proxy traffic. |
|
||||
|
||||
Within a project, sibling containers typically join the anchor's network namespace (`network_mode: service:<anchor>`) instead of attaching to networks themselves.
|
||||
Within a project, sibling containers typically join the anchor's network namespace (`network_mode: service:<anchor>`) instead of attaching to networks themselves.
|
||||
|
||||
## Environment layering {#env-layering}
|
||||
|
||||
Values reach a composition from three layers, in increasing precedence:
|
||||
Values reach a composition from three layers, in increasing precedence:
|
||||
|
||||
1. **`.env`** — uncommitted secrets, a symlink into tier-1 storage (`APEX_RESTIC_*` credentials, `APEX_TRAEFIK_*` tokens, the UID/GID block).
|
||||
2. **`apex.env`** — committed non-secret scalars (`APEX_TRAEFIK_DIRECT_IP`, `APEX_RESTIC_REPOSITORY`, `COMPOSE_PROFILES`, optional `APEX_RESTIC_COMPRESSION`).
|
||||
3. **Engine exports** — the `APEX_*` identity and [tier](/apex/glossary#tier) paths from `ctx.vars()`, injected into the process environment by the orchestration actions and therefore winning over both files.
|
||||
1. **`.env`** — uncommitted secrets, a symlink into tier-1 storage (`APEX_RESTIC_*` credentials, `APEX_TRAEFIK_*` tokens, the UID/GID block).
|
||||
2. **`apex.env`** — committed non-secret scalars (`APEX_TRAEFIK_DIRECT_IP`, `APEX_RESTIC_REPOSITORY`, `COMPOSE_PROFILES`, optional `APEX_RESTIC_COMPRESSION`).
|
||||
3. **Engine exports** — the `APEX_*` identity and [tier](/apex/glossary#tier) paths from `ctx.vars()`, injected into the process environment by the orchestration actions and therefore winning over both files.
|
||||
|
||||
Critical variables carry `?error` markers in the shared file, so a missing secret fails composition config loudly instead of starting a misconfigured container.
|
||||
Critical variables carry `?error` markers in the shared file, so a missing secret fails composition config loudly instead of starting a misconfigured container.
|
||||
|
||||
## Orchestration {#orchestration}
|
||||
|
||||
`apex compose <action>` drives every composition on the node:
|
||||
`apex compose <action>` drives every composition on the node:
|
||||
|
||||
- **Order** — the core project first (it owns the networks), then every service composition in alphabetical order; `down` reverses the sequence.
|
||||
- **Env files** — for each project, `.env` and `apex.env` are passed as `--env-file` when present.
|
||||
- **`up`** implies `-d`; any extra arguments pass through to `docker compose`.
|
||||
- **`--dry-run`** prints each command with its working directory instead of executing — accepted anywhere on the command line.
|
||||
- **Order** — the core project first (it owns the networks), then every service composition in alphabetical order; `down` reverses the sequence.
|
||||
- **Env files** — for each project, `.env` and `apex.env` are passed as `--env-file` when present.
|
||||
- **`up`** implies `-d`; any extra arguments pass through to `docker compose`.
|
||||
- **`--dry-run`** prints each command with its working directory instead of executing — accepted anywhere on the command line.
|
||||
|
||||
---
|
||||
|
||||
**See also:**
|
||||
|
||||
- [Concept: Fleet addressing scheme](/apex/concepts/addressing)
|
||||
- [Guide: Add a service composition](/apex/guides/service-composition)
|
||||
- [Guide: Add a service composition](/apex/guides/service-composition)
|
||||
- [Reference: Environment variables](/apex/reference/environment)
|
||||
@@ -1,66 +1,66 @@
|
||||
---
|
||||
title: The execution context
|
||||
description: ctx — the single seam between an action and the host.
|
||||
title: The execution context
|
||||
description: ctx — the single seam between an action and the host.
|
||||
weight: 14
|
||||
---
|
||||
|
||||
Every [action](/apex/glossary#action) receives a `ctx` object as its first argument. It is the only sanctioned way to touch the host: logging, process execution, template rendering, file mutation, firewall and service management, and notifications all go through it. Actions therefore stay declarative, and behavior that must be consistent fleet-wide (log format, backup discipline, notification payloads) lives in one place — the [engine](/apex/glossary#engine).
|
||||
Every [action](/apex/glossary#action) receives a `ctx` object as its first argument. It is the only sanctioned way to touch the host: logging, process execution, template rendering, file mutation, firewall and service management, and notifications all go through it. Actions therefore stay declarative, and behavior that must be consistent fleet-wide (log format, backup discipline, notification payloads) lives in one place — the [engine](/apex/glossary#engine).
|
||||
|
||||
## Members {#members}
|
||||
|
||||
| Member | Purpose |
|
||||
|---|---|
|
||||
| `ctx.log` | Prefixed logging: `[<node>] [<action>] [LEVEL] message`; errors go to stderr, everything else to stdout. |
|
||||
| `ctx.sys` | Process execution (`run`, `sudo`, `ok`), systemd service helpers, and idempotent package installation. |
|
||||
| `ctx.tpl` | Template rendering over the `configs/` pillar with `string.Template.safe_substitute`. |
|
||||
| `ctx.host` | Idempotent host mutation: timestamped backups, atomic file writes, marker-block injection, crontab installation with rollback. |
|
||||
| `ctx.ufw` | Host and container firewall application (see [Concept: Security model](/apex/concepts/security-model)). |
|
||||
| `ctx.crowdsec` | CrowdSec installation, config deployment, and collection management. |
|
||||
| `ctx.systemd` | Rendering and enabling systemd units from `configs/systemd/`. |
|
||||
| `ctx.notify` | Telegram notifications; every method returns a boolean so actions can gate their exit code on delivery. |
|
||||
| `ctx.paths` | Resolved repository paths: `repo_root`, `commons`, `proprietaries`, `configs`, `compositions`, `core`, and the tier properties. |
|
||||
| `ctx.node` | The resolved [node identity](/apex/glossary#node-identity) record (`name`, `subnet`, `fqdn`, `host`); `None` in a standalone commons checkout. |
|
||||
| `ctx.commons` | Available only in an overriding proprietary action: `ctx.commons.run(args)` delegates to the shadowed commons implementation. |
|
||||
| `ctx.log` | Prefixed logging: `[<node>] [<action>] [LEVEL] message`; errors go to stderr, everything else to stdout. |
|
||||
| `ctx.sys` | Process execution (`run`, `sudo`, `ok`), systemd service helpers, and idempotent package installation. |
|
||||
| `ctx.tpl` | Template rendering over the `configs/` pillar with `string.Template.safe_substitute`. |
|
||||
| `ctx.host` | Idempotent host mutation: timestamped backups, atomic file writes, marker-block injection, crontab installation with rollback. |
|
||||
| `ctx.ufw` | Host and container firewall application (see [Concept: Security model](/apex/concepts/security-model)). |
|
||||
| `ctx.crowdsec` | CrowdSec installation, config deployment, and collection management. |
|
||||
| `ctx.systemd` | Rendering and enabling systemd units from `configs/systemd/`. |
|
||||
| `ctx.notify` | Telegram notifications; every method returns a boolean so actions can gate their exit code on delivery. |
|
||||
| `ctx.paths` | Resolved repository paths: `repo_root`, `commons`, `proprietaries`, `configs`, `compositions`, `core`, and the tier properties. |
|
||||
| `ctx.node` | The resolved [node identity](/apex/glossary#node-identity) record (`name`, `subnet`, `fqdn`, `host`); `None` in a standalone commons checkout. |
|
||||
| `ctx.commons` | Available only in an overriding proprietary action: `ctx.commons.run(args)` delegates to the shadowed commons implementation. |
|
||||
|
||||
Method-level signatures are listed in [Reference: Engine API](/apex/reference/engine-api).
|
||||
Method-level signatures are listed in [Reference: Engine API](/apex/reference/engine-api).
|
||||
|
||||
## Template semantics {#templates}
|
||||
|
||||
`ctx.tpl` renders files from `configs/` with Python's `string.Template.safe_substitute`:
|
||||
`ctx.tpl` renders files from `configs/` with Python's `string.Template.safe_substitute`:
|
||||
|
||||
- Known `$VAR` and `${VAR}` references are replaced.
|
||||
- **Unknown references stay literal.** A typo in a template survives into the rendered output where it is visible, instead of silently collapsing to an empty string.
|
||||
- Shell command substitution (`$(...)`) passes through untouched, which keeps crontab entries with embedded `$(cat ...)` intact.
|
||||
- Known `$VAR` and `${VAR}` references are replaced.
|
||||
- **Unknown references stay literal.** A typo in a template survives into the rendered output where it is visible, instead of silently collapsing to an empty string.
|
||||
- Shell command substitution (`$(...)`) passes through untouched, which keeps crontab entries with embedded `$(cat ...)` intact.
|
||||
|
||||
The substitution set is `ctx.vars()` — the full process environment plus the engine-provided `APEX_*` values below. Loops and conditionals never live in templates; they belong in the action.
|
||||
The substitution set is `ctx.vars()` — the full process environment plus the engine-provided `APEX_*` values below. Loops and conditionals never live in templates; they belong in the action.
|
||||
|
||||
## The variable export {#vars}
|
||||
## The variable export {#vars}
|
||||
|
||||
`ctx.vars()` returns the environment used for every template render, and its `APEX_*` subset is exported to `docker compose` by the orchestration actions. On a node it contains:
|
||||
`ctx.vars()` returns the environment used for every template render, and its `APEX_*` subset is exported to `docker compose` by the orchestration actions. On a node it contains:
|
||||
|
||||
| Variable | Value |
|
||||
|---|---|
|
||||
| `APEX_COMMONS` | Absolute path of the commons directory (the launcher lives at `$APEX_COMMONS/apex`). |
|
||||
| `APEX_REPO_ROOT` | Absolute path of the node repository root. |
|
||||
| `APEX_NODE_HOST`, `APEX_NODE_FQDN`, `APEX_SUBNET` | The resolved identity fields. |
|
||||
| `APEX_TIER1`…`APEX_TIER3` | The core project's per-[tier](/apex/glossary#tier) data directories. |
|
||||
| `APEX_TIER1_SHARED`…`APEX_TIER3_SHARED` | The shared areas inside each tier. |
|
||||
| `APEX_TIER_ROOT1`…`APEX_TIER_ROOT3` | The tier storage roots (node-level links). |
|
||||
| `APEX_COMMONS` | Absolute path of the commons directory (the launcher lives at `$APEX_COMMONS/apex`). |
|
||||
| `APEX_REPO_ROOT` | Absolute path of the node repository root. |
|
||||
| `APEX_NODE_HOST`, `APEX_NODE_FQDN`, `APEX_SUBNET` | The resolved identity fields. |
|
||||
| `APEX_TIER1`…`APEX_TIER3` | The core project's per-[tier](/apex/glossary#tier) data directories. |
|
||||
| `APEX_TIER1_SHARED`…`APEX_TIER3_SHARED` | The shared areas inside each tier. |
|
||||
| `APEX_TIER_ROOT1`…`APEX_TIER_ROOT3` | The tier storage roots (node-level links). |
|
||||
|
||||
The full origin/consumer matrix is in [Reference: Environment variables](/apex/reference/environment).
|
||||
The full origin/consumer matrix is in [Reference: Environment variables](/apex/reference/environment).
|
||||
|
||||
## Host mutation discipline {#host}
|
||||
|
||||
`ctx.host` encodes the framework's write rules:
|
||||
`ctx.host` encodes the framework's write rules:
|
||||
|
||||
- **Atomic writes.** `write_file` renders to a temporary file (mode `0644`, matching what a root shell redirect would create) and installs it with `mv` or `sudo cp`; overwriting an existing file preserves that file's mode.
|
||||
- **Timestamped backups.** When requested, the previous file is kept next to the target as `<name>.<epoch>.bak` before the write.
|
||||
- **Marker-block injection.** Managed blocks inside system files (for example `/etc/ufw/before.rules`) are delimited by start/end marker comments. Injection removes every prior managed block — including blocks left by predecessor tooling — before inserting the fresh one after its anchor line, so re-runs never accumulate.
|
||||
- **Crontab rollback.** `install_crontab` snapshots the current crontab, refuses an empty render (exit `65`), previews the result, and restores the snapshot if installation fails.
|
||||
- **Atomic writes.** `write_file` renders to a temporary file (mode `0644`, matching what a root shell redirect would create) and installs it with `mv` or `sudo cp`; overwriting an existing file preserves that file's mode.
|
||||
- **Timestamped backups.** When requested, the previous file is kept next to the target as `<name>.<epoch>.bak` before the write.
|
||||
- **Marker-block injection.** Managed blocks inside system files (for example `/etc/ufw/before.rules`) are delimited by start/end marker comments. Injection removes every prior managed block — including blocks left by predecessor tooling — before inserting the fresh one after its anchor line, so re-runs never accumulate.
|
||||
- **Crontab rollback.** `install_crontab` snapshots the current crontab, refuses an empty render (exit `65`), previews the result, and restores the snapshot if installation fails.
|
||||
|
||||
## Notifications {#notifications}
|
||||
|
||||
`ctx.notify` posts Telegram messages through the standard library (`urllib`). Payloads carry the instance name — the node's public host `APEX_NODE_HOST` — a status (`firing`/`resolved`), an escalation level, and the message truncated to 200 characters. Every method returns `True` only when the send succeeded; unattended actions treat a failed send of their final status message as a failure (exit `1`), so a broken notification channel is noticed instead of silently tolerated.
|
||||
`ctx.notify` posts Telegram messages through the standard library (`urllib`). Payloads carry the instance name — the node's public host `APEX_NODE_HOST` — a status (`firing`/`resolved`), an escalation level, and the message truncated to 200 characters. Every method returns `True` only when the send succeeded; unattended actions treat a failed send of their final status message as a failure (exit `1`), so a broken notification channel is noticed instead of silently tolerated.
|
||||
|
||||
---
|
||||
|
||||
@@ -68,4 +68,4 @@ The full origin/consumer matrix is in [Reference: Environment variables](/apex/r
|
||||
|
||||
- [Reference: Engine API](/apex/reference/engine-api)
|
||||
- [Reference: Environment variables](/apex/reference/environment)
|
||||
- [Concept: Actions and the overlay](/apex/concepts/actions-and-overlay)
|
||||
- [Concept: Actions and the overlay](/apex/concepts/actions-and-overlay)
|
||||
@@ -1,22 +1,22 @@
|
||||
---
|
||||
title: "Node identity resolution"
|
||||
description: "How a node learns who it is: node.env identity keys and the FQDN fallback."
|
||||
description: "How a node learns who it is: node.env identity keys and the FQDN fallback."
|
||||
weight: 12
|
||||
---
|
||||
|
||||
Every apex operation runs in the context of exactly one [node](/apex/glossary#node). The [engine](/apex/glossary#engine) derives that [node identity](/apex/glossary#node-identity) — node name, subnet, FQDN, and public host — from the `node.env` file at the repository root, with the OS hostname as a fallback for the FQDN only. The framework assumes no domain and no clustering: a node declares who it is explicitly. This resolution step succeeds the `root_require_node` mechanism of the bash "root" commons framework that apex replaces.
|
||||
Every apex operation runs in the context of exactly one [node](/apex/glossary#node). The [engine](/apex/glossary#engine) derives that [node identity](/apex/glossary#node-identity) — node name, subnet, FQDN, and public host — from the `node.env` file at the repository root, with the OS hostname as a fallback for the FQDN only. The framework assumes no domain and no clustering: a node declares who it is explicitly. This resolution step succeeds the `root_require_node` mechanism of the bash "root" commons framework that apex replaces.
|
||||
|
||||
## Identity sources {#sources}
|
||||
|
||||
### node.env
|
||||
|
||||
`node.env` is a plain `KEY=value` file (shell-sourceable) located at the repository root. When parsed, comments and blank lines are ignored, and surrounding single or double quotes around values are stripped.
|
||||
`node.env` is a plain `KEY=value` file (shell-sourceable) located at the repository root. When parsed, comments and blank lines are ignored, and surrounding single or double quotes around values are stripped.
|
||||
|
||||
It declares identity explicitly through these keys:
|
||||
It declares identity explicitly through these keys:
|
||||
|
||||
- `APEX_NODE_FQDN` — the node's real FQDN, used for traefik `Host()` routing. If unset, the engine falls back to the OS hostname (and warns).
|
||||
- `APEX_NODE_HOST` — the node's public host name, used for the backup `--host` and the observability/notify `instance`. Defaults to `APEX_NODE_FQDN`.
|
||||
- `APEX_SUBNET` — the node's `/24` for the `direct` network. **Required**; there is no other source for it (see [Concept: Fleet addressing scheme](/apex/concepts/addressing)).
|
||||
- `APEX_NODE_FQDN` — the node's real FQDN, used for traefik `Host()` routing. If unset, the engine falls back to the OS hostname (and warns).
|
||||
- `APEX_NODE_HOST` — the node's public host name, used for the backup `--host` and the observability/notify `instance`. Defaults to `APEX_NODE_FQDN`.
|
||||
- `APEX_SUBNET` — the node's `/24` for the `direct` network. **Required**; there is no other source for it (see [Concept: Fleet addressing scheme](/apex/concepts/addressing)).
|
||||
|
||||
```ini {filename="node.env"}
|
||||
APEX_NODE_FQDN=node1.example.com
|
||||
@@ -24,51 +24,51 @@ APEX_NODE_HOST=node1.example.com
|
||||
APEX_SUBNET=198.18.16.0/24
|
||||
```
|
||||
|
||||
### The OS hostname
|
||||
### The OS hostname
|
||||
|
||||
The OS hostname is consulted only as a fallback for `APEX_NODE_FQDN`. When `node.env` does not set `APEX_NODE_FQDN`, the engine uses the hostname and emits a warning. It is never parsed for a node name or any other coordinate.
|
||||
The OS hostname is consulted only as a fallback for `APEX_NODE_FQDN`. When `node.env` does not set `APEX_NODE_FQDN`, the engine uses the hostname and emits a warning. It is never parsed for a node name or any other coordinate.
|
||||
|
||||
## Resolution order {#resolution}
|
||||
|
||||
{{% steps %}}
|
||||
|
||||
### Resolve the FQDN
|
||||
### Resolve the FQDN
|
||||
|
||||
`fqdn` is `APEX_NODE_FQDN` from `node.env`, or the OS hostname when that key is unset. If neither yields a value, resolution fails (see [Failure modes](#failures)).
|
||||
`fqdn` is `APEX_NODE_FQDN` from `node.env`, or the OS hostname when that key is unset. If neither yields a value, resolution fails (see [Failure modes](#failures)).
|
||||
|
||||
### Resolve the public host
|
||||
### Resolve the public host
|
||||
|
||||
`host` is `APEX_NODE_HOST` from `node.env`, or `fqdn` when that key is unset.
|
||||
`host` is `APEX_NODE_HOST` from `node.env`, or `fqdn` when that key is unset.
|
||||
|
||||
### Derive the short name
|
||||
### Derive the short name
|
||||
|
||||
`name` is the **first label** of `host` (`host.split(".")[0]`) — for example, `node1` for `node1.example.com`. It is used for logs, the `sync/<node>` branch, and messages.
|
||||
`name` is the **first label** of `host` (`host.split(".")[0]`) — for example, `node1` for `node1.example.com`. It is used for logs, the `sync/<node>` branch, and messages.
|
||||
|
||||
### Read the subnet
|
||||
### Read the subnet
|
||||
|
||||
`subnet` is always read from `APEX_SUBNET` in `node.env`. It is required.
|
||||
`subnet` is always read from `APEX_SUBNET` in `node.env`. It is required.
|
||||
|
||||
{{% /steps %}}
|
||||
|
||||
The result is a `Node` record with four fields: `name`, `subnet`, `fqdn`, and `host`.
|
||||
The result is a `Node` record with four fields: `name`, `subnet`, `fqdn`, and `host`.
|
||||
|
||||
## Warnings {#warnings}
|
||||
|
||||
Identity resolution emits exactly one warning; it signals a recoverable inconsistency that should be fixed.
|
||||
Identity resolution emits exactly one warning; it signals a recoverable inconsistency that should be fixed.
|
||||
|
||||
| Condition | Warning message |
|
||||
|---|---|
|
||||
| `APEX_NODE_FQDN` is unset in `node.env` (the hostname is used instead) | `APEX_NODE_FQDN not set in node.env; using hostname '<fqdn>'.` |
|
||||
| `APEX_NODE_FQDN` is unset in `node.env` (the hostname is used instead) | `APEX_NODE_FQDN not set in node.env; using hostname '<fqdn>'.` |
|
||||
|
||||
## Failure modes {#failures}
|
||||
|
||||
Resolution refuses to guess. Two conditions are fatal, and both exit with code `66` — the engine's exit-code convention for missing input:
|
||||
Resolution refuses to guess. Two conditions are fatal, and both exit with code `66` — the engine's exit-code convention for missing input:
|
||||
|
||||
- **No FQDN at all.** `APEX_NODE_FQDN` is unset in `node.env` *and* the OS hostname is empty. The engine writes `identity: APEX_NODE_FQDN missing from node.env and no hostname` to stderr and exits.
|
||||
- **No subnet.** `APEX_SUBNET` is absent from `node.env` (including when the file itself is missing). The engine writes `identity: APEX_SUBNET missing from node.env` to stderr and exits.
|
||||
- **No FQDN at all.** `APEX_NODE_FQDN` is unset in `node.env` *and* the OS hostname is empty. The engine writes `identity: APEX_NODE_FQDN missing from node.env and no hostname` to stderr and exits.
|
||||
- **No subnet.** `APEX_SUBNET` is absent from `node.env` (including when the file itself is missing). The engine writes `identity: APEX_SUBNET missing from node.env` to stderr and exits.
|
||||
|
||||
> [!WARNING]
|
||||
> A valid `node.env` carrying `APEX_SUBNET` is required on every node. `APEX_NODE_FQDN` may be omitted only when the OS hostname supplies a usable FQDN.
|
||||
> A valid `node.env` carrying `APEX_SUBNET` is required on every node. `APEX_NODE_FQDN` may be omitted only when the OS hostname supplies a usable FQDN.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,53 +1,53 @@
|
||||
---
|
||||
title: Security model
|
||||
description: Privilege isolation, firewall management, intrusion detection, and secret handling.
|
||||
description: Privilege isolation, firewall management, intrusion detection, and secret handling.
|
||||
weight: 18
|
||||
---
|
||||
|
||||
apex hardens a [node](/apex/glossary#node) in independent layers: filesystem privilege isolation, a managed host firewall, intrusion detection, container-level containment, and a strict secrets boundary. Each layer is applied by an [action](/apex/glossary#action) and is therefore reproducible and re-runnable.
|
||||
apex hardens a [node](/apex/glossary#node) in independent layers: filesystem privilege isolation, a managed host firewall, intrusion detection, container-level containment, and a strict secrets boundary. Each layer is applied by an [action](/apex/glossary#action) and is therefore reproducible and re-runnable.
|
||||
|
||||
## Privilege isolation {#privilege}
|
||||
|
||||
Every project runs as its own locked `noroot-<project>` system user; shared storage is group-writable with the sticky bit so projects can exchange files without being able to delete each other's. The full mechanism — users, groups, and the ownership matrix — is described in [Concept: Tiered storage](/apex/concepts/tiered-storage).
|
||||
Every project runs as its own locked `noroot-<project>` system user; shared storage is group-writable with the sticky bit so projects can exchange files without being able to delete each other's. The full mechanism — users, groups, and the ownership matrix — is described in [Concept: Tiered storage](/apex/concepts/tiered-storage).
|
||||
|
||||
## Host firewall {#firewall}
|
||||
|
||||
`apex configure/ufw` applies the node's firewall from the `configs/ufw/` pillar in three parts:
|
||||
`apex configure/ufw` applies the node's firewall from the `configs/ufw/` pillar in three parts:
|
||||
|
||||
- **Host rules** (`configs/ufw/host/host.rules`) — plain `ufw` commands, one per line, applied in order.
|
||||
- **Kernel-level rule blocks** (`configs/ufw/host/before.rules`, `after.rules`) — injected into `/etc/ufw/before.rules` and `/etc/ufw/after.rules` as managed marker blocks. Injection is **self-healing**: every previous managed block — including blocks left by predecessor tooling — is removed before the fresh block is inserted after ufw's `# End required lines` anchor, and a timestamped `.bak` of the target is kept beside it.
|
||||
- **Container rules** (`configs/ufw/docker/*.rules`) — applied through `ufw-docker`, which translates them into rules that survive Docker's own iptables manipulation. `configure/base` installs the helper to `~/.local/bin/ufw-docker`.
|
||||
- **Host rules** (`configs/ufw/host/host.rules`) — plain `ufw` commands, one per line, applied in order.
|
||||
- **Kernel-level rule blocks** (`configs/ufw/host/before.rules`, `after.rules`) — injected into `/etc/ufw/before.rules` and `/etc/ufw/after.rules` as managed marker blocks. Injection is **self-healing**: every previous managed block — including blocks left by predecessor tooling — is removed before the fresh block is inserted after ufw's `# End required lines` anchor, and a timestamped `.bak` of the target is kept beside it.
|
||||
- **Container rules** (`configs/ufw/docker/*.rules`) — applied through `ufw-docker`, which translates them into rules that survive Docker's own iptables manipulation. `configure/base` installs the helper to `~/.local/bin/ufw-docker`.
|
||||
|
||||
Missing configuration is an error, not a silent skip: an absent `docker/` or `host/` directory exits `66` when its counterpart exists, and a missing `/etc/ufw` target file aborts the run. The action finishes with a reminder that `ufw` must be restarted to take effect — and that the SSH port must be allowed first.
|
||||
Missing configuration is an error, not a silent skip: an absent `docker/` or `host/` directory exits `66` when its counterpart exists, and a missing `/etc/ufw` target file aborts the run. The action finishes with a reminder that `ufw` must be restarted to take effect — and that the SSH port must be allowed first.
|
||||
|
||||
## Intrusion detection {#crowdsec}
|
||||
|
||||
`apex configure/crowdsec` installs CrowdSec through its official installer when `cscli` is absent, renders everything under `configs/crowdsec/` into `/etc/crowdsec/` (acquisition sources, parsers, bouncer configuration), and installs the `crowdsecurity/linux` and `crowdsecurity/traefik` collections. When the node's configs include a firewall-bouncer configuration, the matching bouncer package is installed as well, closing the loop from detection to blocking.
|
||||
`apex configure/crowdsec` installs CrowdSec through its official installer when `cscli` is absent, renders everything under `configs/crowdsec/` into `/etc/crowdsec/` (acquisition sources, parsers, bouncer configuration), and installs the `crowdsecurity/linux` and `crowdsecurity/traefik` collections. When the node's configs include a firewall-bouncer configuration, the matching bouncer package is installed as well, closing the loop from detection to blocking.
|
||||
|
||||
## Container containment {#containers}
|
||||
|
||||
The [core composition](/apex/glossary#core-composition) encodes container-level hardening:
|
||||
The [core composition](/apex/glossary#core-composition) encodes container-level hardening:
|
||||
|
||||
- The edge proxy never touches the Docker socket: it discovers backends through a **filtered socket proxy** that exposes read-only container/service state on an isolated network.
|
||||
- `enclave` and `socket` are `internal: true` networks — no egress, no host port exposure.
|
||||
- Core services set `no-new-privileges` and run under the project's noroot identity wherever the workload allows; capabilities are granted per service, not globally.
|
||||
- The edge proxy never touches the Docker socket: it discovers backends through a **filtered socket proxy** that exposes read-only container/service state on an isolated network.
|
||||
- `enclave` and `socket` are `internal: true` networks — no egress, no host port exposure.
|
||||
- Core services set `no-new-privileges` and run under the project's noroot identity wherever the workload allows; capabilities are granted per service, not globally.
|
||||
|
||||
## Observability {#observability}
|
||||
|
||||
Observability is an **outbound push**, not an exposed route. The optional `alloy` core service (Grafana Alloy, one agent per node) collects host metrics (a `node_exporter`-equivalent), container metrics (cadvisor), the systemd journal, and docker container logs, and pushes them to a **central** Prometheus and Loki that live on a designated node — the metrics via `prometheus.remote_write` to `APEX_PROM_PUSH_URL`, the logs via `loki.write` to `APEX_LOKI_PUSH_URL`. Both targets are authenticated with HTTP basic auth using `APEX_OBS_BASIC_AUTH_USER` / `APEX_OBS_BASIC_AUTH_PASS`. Because the push is outbound, Alloy attaches to the `direct` network (which has egress) rather than the `internal` `enclave`; there is no node-local log store and no `/loki` route to protect.
|
||||
Observability is an **outbound push**, not an exposed route. The optional `alloy` core service (Grafana Alloy, one agent per node) collects host metrics (a `node_exporter`-equivalent), container metrics (cadvisor), the systemd journal, and docker container logs, and pushes them to a **central** Prometheus and Loki that live on a designated node — the metrics via `prometheus.remote_write` to `APEX_PROM_PUSH_URL`, the logs via `loki.write` to `APEX_LOKI_PUSH_URL`. Both targets are authenticated with HTTP basic auth using `APEX_OBS_BASIC_AUTH_USER` / `APEX_OBS_BASIC_AUTH_PASS`. Because the push is outbound, Alloy attaches to the `direct` network (which has egress) rather than the `internal` `enclave`; there is no node-local log store and no `/loki` route to protect.
|
||||
|
||||
## Secrets boundary {#secrets}
|
||||
|
||||
- Secrets live only in per-project `.env` files, which are **symlinks into tier-1 storage** — outside the repository working tree and covered by `.gitignore` (`**/.env`).
|
||||
- Each node repository documents its expected keys in `SECRETS.md`; the file documents names, never values.
|
||||
- Committed configuration (`apex.env`) is restricted to non-secret scalars.
|
||||
- The shared composition marks critical secrets with `?error`, so a missing value fails fast at compose-config time.
|
||||
- [Capture-up](/apex/glossary#capture-up) commits are safe by construction: the ignored `.env` links and tier data never enter the git index.
|
||||
- Secrets live only in per-project `.env` files, which are **symlinks into tier-1 storage** — outside the repository working tree and covered by `.gitignore` (`**/.env`).
|
||||
- Each node repository documents its expected keys in `SECRETS.md`; the file documents names, never values.
|
||||
- Committed configuration (`apex.env`) is restricted to non-secret scalars.
|
||||
- The shared composition marks critical secrets with `?error`, so a missing value fails fast at compose-config time.
|
||||
- [Capture-up](/apex/glossary#capture-up) commits are safe by construction: the ignored `.env` links and tier data never enter the git index.
|
||||
|
||||
---
|
||||
|
||||
**See also:**
|
||||
|
||||
- [Concept: Tiered storage and privilege isolation](/apex/concepts/tiered-storage)
|
||||
- [Concept: Compositions and the shared core](/apex/concepts/compositions)
|
||||
- [Concept: Tiered storage and privilege isolation](/apex/concepts/tiered-storage)
|
||||
- [Concept: Compositions and the shared core](/apex/concepts/compositions)
|
||||
- [Reference: Actions](/apex/reference/actions)
|
||||
@@ -1,25 +1,25 @@
|
||||
---
|
||||
title: Tiered storage and privilege isolation
|
||||
description: The @tier link structure, shared storage, and per-project noroot users.
|
||||
title: Tiered storage and privilege isolation
|
||||
description: The @tier link structure, shared storage, and per-project noroot users.
|
||||
weight: 16
|
||||
---
|
||||
|
||||
Service data never lives inside a [node](/apex/glossary#node) repository. It lives in three storage [tiers](/apex/glossary#tier) — separate locations chosen per host — and reaches [compositions](/apex/glossary#compositions) through a standardized symlink structure. Ownership of that data is delegated to per-project system users, so no service container writes as root.
|
||||
Service data never lives inside a [node](/apex/glossary#node) repository. It lives in three storage [tiers](/apex/glossary#tier) — separate locations chosen per host — and reaches [compositions](/apex/glossary#compositions) through a standardized symlink structure. Ownership of that data is delegated to per-project system users, so no service container writes as root.
|
||||
|
||||
## The tier link structure {#links}
|
||||
## The tier link structure {#links}
|
||||
|
||||
`apex tiers/link <tier1> <tier2> <tier3>` builds two layers of links:
|
||||
`apex tiers/link <tier1> <tier2> <tier3>` builds two layers of links:
|
||||
|
||||
- **Node-level:** `compositions/@tierN` → the tier storage root. These are what the engine exposes as `APEX_TIER_ROOT1..3`.
|
||||
- **Per-project:** for every `compositions/<project>/`, the action creates `<tier-root>/<project>/` and links `compositions/<project>/@tierN` → `<tier-root>/<project>`. Inside each tier it also maintains `shared/` with a per-project subdirectory, and a relative `shared` link inside every project's tier directory pointing back at it.
|
||||
- **Node-level:** `compositions/@tierN` → the tier storage root. These are what the engine exposes as `APEX_TIER_ROOT1..3`.
|
||||
- **Per-project:** for every `compositions/<project>/`, the action creates `<tier-root>/<project>/` and links `compositions/<project>/@tierN` → `<tier-root>/<project>`. Inside each tier it also maintains `shared/` with a per-project subdirectory, and a relative `shared` link inside every project's tier directory pointing back at it.
|
||||
|
||||
Each project additionally gets `compositions/<project>/.env` → `<tier1-root>/<project>/.env` (created empty if missing) — the uncommitted secrets file described in [Concept: Compositions](/apex/concepts/compositions). The `@tierN` links and `.env` files are ignored by git; the repository stays pure source.
|
||||
Each project additionally gets `compositions/<project>/.env` → `<tier1-root>/<project>/.env` (created empty if missing) — the uncommitted secrets file described in [Concept: Compositions](/apex/concepts/compositions). The `@tierN` links and `.env` files are ignored by git; the repository stays pure source.
|
||||
|
||||
If a path that should be a symlink exists as a real file or directory, the action warns and asks for confirmation before removing it; declining (including non-interactive EOF) skips that link and continues.
|
||||
If a path that should be a symlink exists as a real file or directory, the action warns and asks for confirmation before removing it; declining (including non-interactive EOF) skips that link and continues.
|
||||
|
||||
## Users and groups {#users}
|
||||
## Users and groups {#users}
|
||||
|
||||
`apex tiers/useradd` provisions one locked system user per project — `noroot-<project>` (name sanitized, its own primary group) — plus the fleet-wide `noroot-shared` group every project user joins. It then injects the resolved IDs into the project's `.env` between marker comments, replacing any previous block on re-runs:
|
||||
`apex tiers/useradd` provisions one locked system user per project — `noroot-<project>` (name sanitized, its own primary group) — plus the fleet-wide `noroot-shared` group every project user joins. It then injects the resolved IDs into the project's `.env` between marker comments, replacing any previous block on re-runs:
|
||||
|
||||
```ini {filename="compositions/<project>/.env"}
|
||||
# START AUTO GENERATED APEX-NOROOT-USERS
|
||||
@@ -29,42 +29,42 @@ APEX_SHARED_GID=997
|
||||
# END AUTO GENERATED APEX-NOROOT-USERS
|
||||
```
|
||||
|
||||
Compositions consume these as `user: "${APEX_UID?error}:${APEX_GID?error}"` and `group_add: ["${APEX_SHARED_GID?error}"]`.
|
||||
Compositions consume these as `user: "${APEX_UID?error}:${APEX_GID?error}"` and `group_add: ["${APEX_SHARED_GID?error}"]`.
|
||||
|
||||
## The ownership matrix {#ownership}
|
||||
## The ownership matrix {#ownership}
|
||||
|
||||
`apex tiers/chown` enforces ownership across all three tiers. Without arguments it operates on the tier roots (`APEX_TIER_ROOT1..3`); explicit tier paths can override.
|
||||
`apex tiers/chown` enforces ownership across all three tiers. Without arguments it operates on the tier roots (`APEX_TIER_ROOT1..3`); explicit tier paths can override.
|
||||
|
||||
| Path | Ownership | Notes |
|
||||
|---|---|---|
|
||||
| `<tier-root>` | `root:root` | Directory inode only. |
|
||||
| `<tier-root>/shared` | `root:root` | Directory inode only — never recursive, so existing project data inside is untouched. |
|
||||
| `<tier-root>/shared` | `root:root` | Directory inode only — never recursive, so existing project data inside is untouched. |
|
||||
| `<tier-root>/node-infra` | `root:root` | Recursive. |
|
||||
| `<tier-root>/<project>` | `uid:gid` | Recursive; IDs read from the project `.env`. |
|
||||
| `<tier-root>/shared/<project>` | `uid:shared_gid` | Recursive, plus the sticky bit — collaborators can add files but only delete their own. |
|
||||
| `<tier-root>/<project>` | `uid:gid` | Recursive; IDs read from the project `.env`. |
|
||||
| `<tier-root>/shared/<project>` | `uid:shared_gid` | Recursive, plus the sticky bit — collaborators can add files but only delete their own. |
|
||||
|
||||
Projects with an incomplete `APEX_UID`/`APEX_GID`/`APEX_SHARED_GID` set are skipped with a warning pointing at `tiers/useradd`.
|
||||
Projects with an incomplete `APEX_UID`/`APEX_GID`/`APEX_SHARED_GID` set are skipped with a warning pointing at `tiers/useradd`.
|
||||
|
||||
## Engine tier variables {#variables}
|
||||
|
||||
| Variable | Resolves to |
|
||||
| Variable | Resolves to |
|
||||
|---|---|
|
||||
| `APEX_TIER_ROOT1..3` | The tier storage roots (`compositions/@tierN`, fully resolved). |
|
||||
| `APEX_TIER1..3` | The core project's data directories inside each tier (`compositions/apex/@tierN`). |
|
||||
| `APEX_TIER1..3_SHARED` | The shared area inside the core project's tiers. |
|
||||
| `APEX_TIER_ROOT1..3` | The tier storage roots (`compositions/@tierN`, fully resolved). |
|
||||
| `APEX_TIER1..3` | The core project's data directories inside each tier (`compositions/apex/@tierN`). |
|
||||
| `APEX_TIER1..3_SHARED` | The shared area inside the core project's tiers. |
|
||||
|
||||
The core composition mounts through these exclusively; the backup runner instead mounts the fleet data root `/srv` read-only and resolves each labeled service's container mounts back to their host paths.
|
||||
The core composition mounts through these exclusively; the backup runner instead mounts the fleet data root `/srv` read-only and resolves each labeled service's container mounts back to their host paths.
|
||||
|
||||
## Backups {#backups}
|
||||
|
||||
`apex backup/run <telegram-bot-url>` drives the core `resticontainer` service (profile `manual`, one-shot) end to end. Backup intent is **label-driven**: resticontainer discovers every service carrying `restic.enable=true`, reads its `restic.backup.paths` (and optional `restic.backup.stop` / `restic.hooks.*`), resolves those container paths to host paths, and snapshots their union in a single restic run.
|
||||
`apex backup/run <telegram-bot-url>` drives the core `resticontainer` service (profile `manual`, one-shot) end to end. Backup intent is **label-driven**: resticontainer discovers every service carrying `restic.enable=true`, reads its `restic.backup.paths` (and optional `restic.backup.stop` / `restic.hooks.*`), resolves those container paths to host paths, and snapshots their union in a single restic run.
|
||||
|
||||
1. Checks repository reachability with `snapshots`; initializes the repository if needed.
|
||||
2. Runs `backup` with `--host <APEX_NODE_HOST>` and `--tag biweekly` — resticontainer runs each labeled service's pre-hook, stops any container flagged `restic.backup.stop`, snapshots the resolved host paths, then restarts and runs post-hooks. The run refuses to report success if no new snapshot lands (a guard against zero labels being discovered).
|
||||
3. Applies retention with `forget --prune` (7 daily, 4 weekly, 12 monthly).
|
||||
4. Sends a Telegram status either way; a success whose notification cannot be delivered still exits `1` so schedulers notice.
|
||||
1. Checks repository reachability with `snapshots`; initializes the repository if needed.
|
||||
2. Runs `backup` with `--host <APEX_NODE_HOST>` and `--tag biweekly` — resticontainer runs each labeled service's pre-hook, stops any container flagged `restic.backup.stop`, snapshots the resolved host paths, then restarts and runs post-hooks. The run refuses to report success if no new snapshot lands (a guard against zero labels being discovered).
|
||||
3. Applies retention with `forget --prune` (7 daily, 4 weekly, 12 monthly).
|
||||
4. Sends a Telegram status either way; a success whose notification cannot be delivered still exits `1` so schedulers notice.
|
||||
|
||||
The repository location and credentials come entirely from `APEX_RESTIC_*` configuration (see [Reference: Environment variables](/apex/reference/environment)). Which data is captured is decided per service by its `restic.*` labels — for example the core services back up only their configuration (traefik dynamic rules, xray/adguard config), leaving reproducible tier-3 data such as caches and logs out of the set.
|
||||
The repository location and credentials come entirely from `APEX_RESTIC_*` configuration (see [Reference: Environment variables](/apex/reference/environment)). Which data is captured is decided per service by its `restic.*` labels — for example the core services back up only their configuration (traefik dynamic rules, xray/adguard config), leaving reproducible tier-3 data such as caches and logs out of the set.
|
||||
|
||||
---
|
||||
|
||||
@@ -72,4 +72,4 @@ The repository location and credentials come entirely from `APEX_RESTIC_*` confi
|
||||
|
||||
- [Concept: Security model](/apex/concepts/security-model)
|
||||
- [Reference: Actions](/apex/reference/actions)
|
||||
- [Guide: Add a service composition](/apex/guides/service-composition)
|
||||
- [Guide: Add a service composition](/apex/guides/service-composition)
|
||||
@@ -1,24 +1,24 @@
|
||||
---
|
||||
title: Getting started with an apex node
|
||||
description: Bootstrap a node repository and bring a host under apex management.
|
||||
title: Getting started with an apex node
|
||||
description: Bootstrap a node repository and bring a host under apex management.
|
||||
weight: 5
|
||||
---
|
||||
|
||||
This quick start takes a fresh host from a cloned [node](/apex/glossary#node) repository to a running service stack. It assumes the node repository already exists — to build one from scratch, see [Guide: Build a node repository](/apex/guides/node-repository).
|
||||
This quick start takes a fresh host from a cloned [node](/apex/glossary#node) repository to a running service stack. It assumes the node repository already exists — to build one from scratch, see [Guide: Build a node repository](/apex/guides/node-repository).
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- A Debian-based host. Any FQDN works — [node identity](/apex/glossary#node-identity) is declared explicitly in `node.env` (`APEX_NODE_FQDN`, `APEX_NODE_HOST`, `APEX_SUBNET`), not parsed from a required hostname shape.
|
||||
- `git` and Python 3 — the [engine](/apex/glossary#engine) uses the standard library only; `pip` is never required.
|
||||
- Docker with Compose v2.20 or newer (the [core composition](/apex/glossary#core-composition) is consumed through the `include:` directive).
|
||||
- A sudo-capable login user.
|
||||
- Three storage locations for the [tiers](/apex/glossary#tier) (for example, three mount points).
|
||||
- A Debian-based host. Any FQDN works — [node identity](/apex/glossary#node-identity) is declared explicitly in `node.env` (`APEX_NODE_FQDN`, `APEX_NODE_HOST`, `APEX_SUBNET`), not parsed from a required hostname shape.
|
||||
- `git` and Python 3 — the [engine](/apex/glossary#engine) uses the standard library only; `pip` is never required.
|
||||
- Docker with Compose v2.20 or newer (the [core composition](/apex/glossary#core-composition) is consumed through the `include:` directive).
|
||||
- A sudo-capable login user.
|
||||
- Three storage locations for the [tiers](/apex/glossary#tier) (for example, three mount points).
|
||||
|
||||
## Bootstrap
|
||||
|
||||
{{% steps %}}
|
||||
|
||||
### Clone the node repository
|
||||
### Clone the node repository
|
||||
|
||||
```bash
|
||||
git clone --recurse-submodules git@github.com:<owner>/<node-repo>.git
|
||||
@@ -27,7 +27,7 @@ cd <node-repo>
|
||||
source ~/.bashrc
|
||||
```
|
||||
|
||||
`init.sh` initializes the `commons/` submodule, points `core.hooksPath` at `commons/githooks`, marks the checkout `safe.directory`, and prepends `commons/` to `PATH` — the launcher is `commons/apex`.
|
||||
`init.sh` initializes the `commons/` submodule, points `core.hooksPath` at `commons/githooks`, marks the checkout `safe.directory`, and prepends `commons/` to `PATH` — the launcher is `commons/apex`.
|
||||
|
||||
### Verify action discovery
|
||||
|
||||
@@ -35,15 +35,15 @@ source ~/.bashrc
|
||||
apex --help
|
||||
```
|
||||
|
||||
The listing shows every [commons](/apex/glossary#commons) action plus any node-local [proprietaries](/apex/glossary#proprietaries) tagged `(local)`. When `node.env` has no `APEX_NODE_FQDN`, the engine warns and falls back to the OS hostname — harmless on a workstation, but set `APEX_NODE_FQDN` explicitly on the node itself. (`APEX_SUBNET` is required: if it is missing the engine exits `66`.)
|
||||
The listing shows every [commons](/apex/glossary#commons) action plus any node-local [proprietaries](/apex/glossary#proprietaries) tagged `(local)`. When `node.env` has no `APEX_NODE_FQDN`, the engine warns and falls back to the OS hostname — harmless on a workstation, but set `APEX_NODE_FQDN` explicitly on the node itself. (`APEX_SUBNET` is required: if it is missing the engine exits `66`.)
|
||||
|
||||
### Link the storage tiers
|
||||
### Link the storage tiers
|
||||
|
||||
```bash
|
||||
apex tiers/link <tier1-path> <tier2-path> <tier3-path>
|
||||
```
|
||||
|
||||
Creates the node-level `compositions/@tierN` links, the per-project tier directories and `@tierN` links, `shared/` areas, and each project's `.env` symlink into tier 1. Success ends with `@tier setup completed.`
|
||||
Creates the node-level `compositions/@tierN` links, the per-project tier directories and `@tierN` links, `shared/` areas, and each project's `.env` symlink into tier 1. Success ends with `@tier setup completed.`
|
||||
|
||||
### Provision project users
|
||||
|
||||
@@ -52,11 +52,11 @@ apex tiers/useradd
|
||||
apex tiers/chown
|
||||
```
|
||||
|
||||
`tiers/useradd` creates one `noroot-<project>` system user per composition plus the `noroot-shared` group, and injects `APEX_UID`/`APEX_GID`/`APEX_SHARED_GID` into each project `.env`. `tiers/chown` then applies the ownership matrix (see [Concept: Tiered storage](/apex/concepts/tiered-storage)) and ends with `Permissions applied.`
|
||||
`tiers/useradd` creates one `noroot-<project>` system user per composition plus the `noroot-shared` group, and injects `APEX_UID`/`APEX_GID`/`APEX_SHARED_GID` into each project `.env`. `tiers/chown` then applies the ownership matrix (see [Concept: Tiered storage](/apex/concepts/tiered-storage)) and ends with `Permissions applied.`
|
||||
|
||||
### Fill the core secrets
|
||||
### Fill the core secrets
|
||||
|
||||
Edit `compositions/apex/.env` (a symlink into tier-1 storage, never committed) with the keys documented in the node repository's `SECRETS.md`:
|
||||
Edit `compositions/apex/.env` (a symlink into tier-1 storage, never committed) with the keys documented in the node repository's `SECRETS.md`:
|
||||
|
||||
```ini {filename="compositions/apex/.env"}
|
||||
APEX_RESTIC_PASSWORD=...
|
||||
@@ -67,36 +67,36 @@ APEX_TRAEFIK_ACME_EMAIL=...
|
||||
APEX_OBS_BASIC_AUTH_PASS=... # only when the alloy observability profile is enabled
|
||||
```
|
||||
|
||||
`APEX_UID`/`APEX_GID`/`APEX_SHARED_GID` are already present — `tiers/useradd` maintains them between marker comments. Non-secret scalars (`APEX_RESTIC_REPOSITORY`, the push URLs, `COMPOSE_PROFILES`, `TZ`, …) live in the committed `compositions/apex/apex.env` instead — see [Guide: Build a node repository](/apex/guides/node-repository).
|
||||
`APEX_UID`/`APEX_GID`/`APEX_SHARED_GID` are already present — `tiers/useradd` maintains them between marker comments. Non-secret scalars (`APEX_RESTIC_REPOSITORY`, the push URLs, `COMPOSE_PROFILES`, `TZ`, …) live in the committed `compositions/apex/apex.env` instead — see [Guide: Build a node repository](/apex/guides/node-repository).
|
||||
|
||||
### Configure the host
|
||||
### Configure the host
|
||||
|
||||
```bash
|
||||
apex configure
|
||||
```
|
||||
|
||||
Runs every `configure/*` action in fixed order: `base`, `ufw`, `crowdsec`, `cron`, `systemd`, `routing`. Each step is also individually invocable; several accept `--dry-run`. See [Guide: Operate a node](/apex/guides/host-operations).
|
||||
Runs every `configure/*` action in fixed order: `base`, `ufw`, `crowdsec`, `cron`, `systemd`, `routing`. Each step is also individually invocable; several accept `--dry-run`. See [Guide: Operate a node](/apex/guides/host-operations).
|
||||
|
||||
### Bring the stack up
|
||||
### Bring the stack up
|
||||
|
||||
```bash
|
||||
apex compose up
|
||||
```
|
||||
|
||||
The core composition comes up first (it owns the networks), then every service composition in alphabetical order.
|
||||
The core composition comes up first (it owns the networks), then every service composition in alphabetical order.
|
||||
|
||||
{{% /steps %}}
|
||||
|
||||
## Validation
|
||||
|
||||
- `apex --help` lists actions without warnings on the live host.
|
||||
- `docker ps` shows the core containers (`apex-traefik`, `apex-socket-proxy`, plus any profile-enabled services) and each project's anchor.
|
||||
- `apex --help` lists actions without warnings on the live host.
|
||||
- `docker ps` shows the core containers (`apex-traefik`, `apex-socket-proxy`, plus any profile-enabled services) and each project's anchor.
|
||||
- `apex utils/lint-docker-compose` reports `Anchor IP validation passed.`
|
||||
|
||||
---
|
||||
|
||||
**See also:**
|
||||
|
||||
- [Guide: Build a node repository](/apex/guides/node-repository)
|
||||
- [Guide: Build a node repository](/apex/guides/node-repository)
|
||||
- [Concept: Framework architecture](/apex/concepts/architecture)
|
||||
- [GitHub: apex.ermnvldmr.com](https://github.com/deytenit/apex.ermnvldmr.com)
|
||||
@@ -1,66 +1,66 @@
|
||||
---
|
||||
title: Glossary
|
||||
description: Terminology used within the apex fleet operations framework.
|
||||
description: Terminology used within the apex fleet operations framework.
|
||||
weight: 100
|
||||
---
|
||||
|
||||
Terminology used within the apex fleet operations framework.
|
||||
Terminology used within the apex fleet operations framework.
|
||||
|
||||
## Action {#action}
|
||||
|
||||
**Action** - An atomic automation task: one Python module exposing `METADATA = Meta(...)` and `run(ctx, args)`, dispatched by its file path as `apex <group>/<action>`.
|
||||
**Action** - An atomic automation task: one Python module exposing `METADATA = Meta(...)` and `run(ctx, args)`, dispatched by its file path as `apex <group>/<action>`.
|
||||
|
||||
## Anchor IP {#anchor-ip}
|
||||
|
||||
**Anchor IP** - The static `ipv4_address` a project's main container holds on the `direct` network. Anchors make service addresses predictable and are validated by the compose lint.
|
||||
**Anchor IP** - The static `ipv4_address` a project's main container holds on the `direct` network. Anchors make service addresses predictable and are validated by the compose lint.
|
||||
|
||||
## Capture-up {#capture-up}
|
||||
|
||||
**Capture-up** - The flow that records a host's live state back into its repository: a scheduled commit to the `sync/<node>` branch, rebased onto `main` and folded back by CI. Drift becomes a diff instead of divergence.
|
||||
**Capture-up** - The flow that records a host's live state back into its repository: a scheduled commit to the `sync/<node>` branch, rebased onto `main` and folded back by CI. Drift becomes a diff instead of divergence.
|
||||
|
||||
## Commons {#commons}
|
||||
|
||||
**Commons** - The shared repository (`apex.ermnvldmr.com`) holding the engine, the shared actions, and the core composition. Every node consumes it as the pinned `commons/` git submodule.
|
||||
**Commons** - The shared repository (`apex.ermnvldmr.com`) holding the engine, the shared actions, and the core composition. Every node consumes it as the pinned `commons/` git submodule.
|
||||
|
||||
## Compositions {#compositions}
|
||||
|
||||
**Compositions** - The node-repository pillar declaring the node's runtime: one docker-compose project per service plus the core wrapper and the addressing ledger.
|
||||
**Compositions** - The node-repository pillar declaring the node's runtime: one docker-compose project per service plus the core wrapper and the addressing ledger.
|
||||
|
||||
## Configs {#configs}
|
||||
|
||||
**Configs** - The node-repository pillar holding host configuration sources (`ufw/`, `cron/`, `systemd/`, `crowdsec/`), rendered through the template layer by the `configure/*` actions.
|
||||
**Configs** - The node-repository pillar holding host configuration sources (`ufw/`, `cron/`, `systemd/`, `crowdsec/`), rendered through the template layer by the `configure/*` actions.
|
||||
|
||||
## Core composition {#core-composition}
|
||||
|
||||
**Core composition** - The shared, `${APEX_*}`-parameterized docker-compose project defining the fleet's common services (edge proxy, socket proxy, and the opt-in profile services). Defined once in the commons, included per node.
|
||||
**Core composition** - The shared, `${APEX_*}`-parameterized docker-compose project defining the fleet's common services (edge proxy, socket proxy, and the opt-in profile services). Defined once in the commons, included per node.
|
||||
|
||||
## Enclave {#enclave}
|
||||
|
||||
**Enclave** - The fleet's private address space, `198.18.0.0/16`, and by extension the internal reverse-proxy backend network named `enclave` in the core composition.
|
||||
**Enclave** - The fleet's private address space, `198.18.0.0/16`, and by extension the internal reverse-proxy backend network named `enclave` in the core composition.
|
||||
|
||||
## Engine {#engine}
|
||||
|
||||
**Engine** - The stdlib-Python runtime under `commons/engine/`: the launcher, identity resolution, the overlay, the execution context, and the `ctx.*` helper library.
|
||||
**Engine** - The stdlib-Python runtime under `commons/engine/`: the launcher, identity resolution, the overlay, the execution context, and the `ctx.*` helper library.
|
||||
|
||||
## Node {#node}
|
||||
|
||||
**Node** - A host managed by the framework, defined by exactly one node repository and identified by the FQDN it declares in `node.env` (for example, `node1.example.com`).
|
||||
**Node** - A host managed by the framework, defined by exactly one node repository and identified by the FQDN it declares in `node.env` (for example, `node1.example.com`).
|
||||
|
||||
## Node identity {#node-identity}
|
||||
|
||||
**Node identity** - The resolved record of who a node is — name, subnet, FQDN, public host — derived from `node.env`, with the OS hostname as a fallback for the FQDN.
|
||||
**Node identity** - The resolved record of who a node is — name, subnet, FQDN, public host — derived from `node.env`, with the OS hostname as a fallback for the FQDN.
|
||||
|
||||
## Overlay {#overlay}
|
||||
|
||||
**Overlay** - The resolution rule that lets a node's proprietaries add, override, wrap, or disable commons actions: `proprietaries/actions/` is consulted before `commons/actions/`.
|
||||
**Overlay** - The resolution rule that lets a node's proprietaries add, override, wrap, or disable commons actions: `proprietaries/actions/` is consulted before `commons/actions/`.
|
||||
|
||||
## Proprietaries {#proprietaries}
|
||||
|
||||
**Proprietaries** - The node-repository pillar for node-local code: actions, libraries, and tool sources that apply to exactly one node and therefore do not belong in the commons.
|
||||
**Proprietaries** - The node-repository pillar for node-local code: actions, libraries, and tool sources that apply to exactly one node and therefore do not belong in the commons.
|
||||
|
||||
## Tier {#tier}
|
||||
|
||||
**Tier** - One of the three standardized storage classes a node mounts (configuration, valuable data, reproducible data), reached from compositions through the `@tierN` symlink structure.
|
||||
**Tier** - One of the three standardized storage classes a node mounts (configuration, valuable data, reproducible data), reached from compositions through the `@tierN` symlink structure.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
---
|
||||
title: Guides
|
||||
description: Task-oriented guides for building node repositories, actions, and compositions.
|
||||
description: Task-oriented guides for building node repositories, actions, and compositions.
|
||||
weight: 20
|
||||
---
|
||||
|
||||
Task-oriented guides for day-to-day work with apex. Each guide solves one recurring problem: assembling a [node](/apex/glossary#node) repository, developing [actions](/apex/glossary#action) for the [engine](/apex/glossary#engine), defining service [compositions](/apex/glossary#compositions), and operating the underlying host.
|
||||
Task-oriented guides for day-to-day work with apex. Each guide solves one recurring problem: assembling a [node](/apex/glossary#node) repository, developing [actions](/apex/glossary#action) for the [engine](/apex/glossary#engine), defining service [compositions](/apex/glossary#compositions), and operating the underlying host.
|
||||
|
||||
## Available guides {#available-guides}
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
---
|
||||
title: Develop actions
|
||||
description: Write, override, and test engine actions in commons or proprietaries.
|
||||
description: Write, override, and test engine actions in commons or proprietaries.
|
||||
weight: 22
|
||||
---
|
||||
|
||||
**Context:** all automation ships as [actions](/apex/glossary#action) — one Python module each, dispatched by path. Shared behavior belongs in [commons](/apex/glossary#commons) `actions/`; anything node-specific belongs in that node's `proprietaries/actions/`. Both use the identical contract, and the launcher puts both trees on `sys.path`, so a proprietary action imports `engine.*` exactly like a commons one.
|
||||
**Context:** all automation ships as [actions](/apex/glossary#action) — one Python module each, dispatched by path. Shared behavior belongs in [commons](/apex/glossary#commons) `actions/`; anything node-specific belongs in that node's `proprietaries/actions/`. Both use the identical contract, and the launcher puts both trees on `sys.path`, so a proprietary action imports `engine.*` exactly like a commons one.
|
||||
|
||||
## A minimal action {#minimal}
|
||||
## A minimal action {#minimal}
|
||||
|
||||
```python {filename="proprietaries/actions/utils/example.py"}
|
||||
"""One-line module docstring describing the action."""
|
||||
@@ -29,14 +29,14 @@ def run(ctx, args):
|
||||
ctx.log.success("Config deployed.")
|
||||
```
|
||||
|
||||
The file's path is its name: the module above dispatches as `apex utils/example`. Return `0`/`None` for success, an integer for a specific exit code, or raise `SystemExit(code)`; the [exit-code convention](/apex/concepts/actions-and-overlay#exit-codes) is `64` usage, `65` data error, `66` missing input, `1` general.
|
||||
The file's path is its name: the module above dispatches as `apex utils/example`. Return `0`/`None` for success, an integer for a specific exit code, or raise `SystemExit(code)`; the [exit-code convention](/apex/concepts/actions-and-overlay#exit-codes) is `64` usage, `65` data error, `66` missing input, `1` general.
|
||||
|
||||
## Conventions for unattended actions {#unattended}
|
||||
## Conventions for unattended actions {#unattended}
|
||||
|
||||
Actions that run from cron follow two rules so failures page instead of vanishing:
|
||||
Actions that run from cron follow two rules so failures page instead of vanishing:
|
||||
|
||||
- **Wrap the body** in `try/except Exception` and send `ctx.notify.error(...)` before re-raising as `SystemExit(1)` — the Python equivalent of a shell ERR trap.
|
||||
- **Gate the exit code on the final notification**: `ctx.notify.*` returns `False` on delivery failure; a completed run whose status message never arrived should still exit `1`.
|
||||
- **Wrap the body** in `try/except Exception` and send `ctx.notify.error(...)` before re-raising as `SystemExit(1)` — the Python equivalent of a shell ERR trap.
|
||||
- **Gate the exit code on the final notification**: `ctx.notify.*` returns `False` on delivery failure; a completed run whose status message never arrived should still exit `1`.
|
||||
|
||||
```python
|
||||
try:
|
||||
@@ -54,7 +54,7 @@ Actions that run from cron follow two rules so failures page instead of vanishin
|
||||
|
||||
## Override, wrap, disable {#override}
|
||||
|
||||
A proprietary file at the same relative path as a commons action shadows it completely. Inside the shadowing module, `ctx.commons.run(args)` invokes the shadowed implementation — the wrap pattern:
|
||||
A proprietary file at the same relative path as a commons action shadows it completely. Inside the shadowing module, `ctx.commons.run(args)` invokes the shadowed implementation — the wrap pattern:
|
||||
|
||||
```python {filename="proprietaries/actions/sync/packages.py"}
|
||||
from engine.descriptor import Meta, Arg
|
||||
@@ -67,24 +67,24 @@ def run(ctx, args):
|
||||
return ctx.commons.run(args) # delegate to the commons implementation
|
||||
```
|
||||
|
||||
To retire an action on one node, assign `DISABLED = "reason"` at module top level: dispatch refuses it (exit `1`) and the listing shows `[DISABLED: reason]`.
|
||||
To retire an action on one node, assign `DISABLED = "reason"` at module top level: dispatch refuses it (exit `1`) and the listing shows `[DISABLED: reason]`.
|
||||
|
||||
## Testing {#testing}
|
||||
|
||||
The commons test suite is stdlib `unittest`:
|
||||
The commons test suite is stdlib `unittest`:
|
||||
|
||||
```bash
|
||||
./tests/run.sh # python3 -m unittest discover -s tests -p 'test_*.py' -v
|
||||
```
|
||||
|
||||
Engine changes must keep the suite green. A cheap smoke gate for any action — commons or proprietary — is its parser: `apex <name> --help` must exit `0`, which proves the module parses, its `METADATA` is well-formed, and discovery sees it.
|
||||
Engine changes must keep the suite green. A cheap smoke gate for any action — commons or proprietary — is its parser: `apex <name> --help` must exit `0`, which proves the module parses, its `METADATA` is well-formed, and discovery sees it.
|
||||
|
||||
The commons pre-commit hook (wired by `init.sh` via `core.hooksPath`) runs `apex utils/lint-docker-compose --hook` against staged compose files, so composition changes are linted at commit time.
|
||||
The commons pre-commit hook (wired by `init.sh` via `core.hooksPath`) runs `apex utils/lint-docker-compose --hook` against staged compose files, so composition changes are linted at commit time.
|
||||
|
||||
---
|
||||
|
||||
**See also:**
|
||||
|
||||
- [Concept: Actions and the overlay](/apex/concepts/actions-and-overlay)
|
||||
- [Concept: The execution context](/apex/concepts/execution-context)
|
||||
- [Concept: Actions and the overlay](/apex/concepts/actions-and-overlay)
|
||||
- [Concept: The execution context](/apex/concepts/execution-context)
|
||||
- [Reference: Engine API](/apex/reference/engine-api)
|
||||
@@ -1,25 +1,25 @@
|
||||
---
|
||||
title: Operate a node
|
||||
description: "Day-2 operations: configure, compose, backups, and repository sync."
|
||||
title: Operate a node
|
||||
description: "Day-2 operations: configure, compose, backups, and repository sync."
|
||||
weight: 24
|
||||
---
|
||||
|
||||
**Context:** once bootstrapped, a [node](/apex/glossary#node) is operated entirely through the [engine](/apex/glossary#engine). This guide covers the recurring operations and what each actually does on the host.
|
||||
**Context:** once bootstrapped, a [node](/apex/glossary#node) is operated entirely through the [engine](/apex/glossary#engine). This guide covers the recurring operations and what each actually does on the host.
|
||||
|
||||
## Host configuration {#configure}
|
||||
|
||||
`apex configure` runs the full configuration suite in a fixed order; each step is individually invocable when only one layer changed:
|
||||
`apex configure` runs the full configuration suite in a fixed order; each step is individually invocable when only one layer changed:
|
||||
|
||||
| Step | Effect |
|
||||
|---|---|
|
||||
| `configure/base` | Ensures a Debian base: package index update, essential packages (`curl`, `wget`, `git`, `rsyslog`), rsyslog running, `ufw` installed, and the `ufw-docker` helper at `~/.local/bin/ufw-docker`. |
|
||||
| `configure/ufw` | Applies host rules, injects the managed kernel-rule blocks, applies container rules via `ufw-docker`. Supports `--dry-run`. |
|
||||
| `configure/crowdsec` | Installs CrowdSec if absent, deploys `configs/crowdsec/` to `/etc/crowdsec/`, installs collections, and the firewall bouncer when configured. Supports `--dry-run`. |
|
||||
| `configure/cron` | Renders `configs/cron/crontab` through the template layer and installs it — with preview, empty-render refusal (exit `65`), and rollback on a failed install. Supports `--dry-run`. |
|
||||
| `configure/systemd` | Renders the top-level files of `configs/systemd/` into `/etc/systemd/system/`, then daemon-reloads and enables each `.service`. Supports `--dry-run`. |
|
||||
| `configure/routing` | Enables IP forwarding and installs the fwmark policy-routing rule and table used by the enclave edge; idempotent. |
|
||||
| `configure/base` | Ensures a Debian base: package index update, essential packages (`curl`, `wget`, `git`, `rsyslog`), rsyslog running, `ufw` installed, and the `ufw-docker` helper at `~/.local/bin/ufw-docker`. |
|
||||
| `configure/ufw` | Applies host rules, injects the managed kernel-rule blocks, applies container rules via `ufw-docker`. Supports `--dry-run`. |
|
||||
| `configure/crowdsec` | Installs CrowdSec if absent, deploys `configs/crowdsec/` to `/etc/crowdsec/`, installs collections, and the firewall bouncer when configured. Supports `--dry-run`. |
|
||||
| `configure/cron` | Renders `configs/cron/crontab` through the template layer and installs it — with preview, empty-render refusal (exit `65`), and rollback on a failed install. Supports `--dry-run`. |
|
||||
| `configure/systemd` | Renders the top-level files of `configs/systemd/` into `/etc/systemd/system/`, then daemon-reloads and enables each `.service`. Supports `--dry-run`. |
|
||||
| `configure/routing` | Enables IP forwarding and installs the fwmark policy-routing rule and table used by the enclave edge; idempotent. |
|
||||
|
||||
After `configure/ufw`, restart `ufw` deliberately (`sudo systemctl restart ufw`) — the action reminds you, and warns that the SSH port must already be allowed.
|
||||
After `configure/ufw`, restart `ufw` deliberately (`sudo systemctl restart ufw`) — the action reminds you, and warns that the SSH port must already be allowed.
|
||||
|
||||
## Composition lifecycle {#compose}
|
||||
|
||||
@@ -30,7 +30,7 @@ apex compose restart
|
||||
apex compose up --dry-run # print the commands only
|
||||
```
|
||||
|
||||
Extra arguments pass through to `docker compose` per project. Each project's `.env` and `apex.env` are supplied automatically when present.
|
||||
Extra arguments pass through to `docker compose` per project. Each project's `.env` and `apex.env` are supplied automatically when present.
|
||||
|
||||
## Backups {#backups}
|
||||
|
||||
@@ -38,7 +38,7 @@ Extra arguments pass through to `docker compose` per project. Each project's `.e
|
||||
apex backup/run "$(cat <path-to-telegram-url-file>)"
|
||||
```
|
||||
|
||||
Runs [resticontainer](https://github.com/deytenit/resticontainer) one-shot under the `manual` compose profile: init-if-needed, then a label-driven backup that discovers each service's intent from its `restic.*` compose labels (`restic.enable`, `restic.backup.paths`, …), snapshots the union of the resolved host paths in a single restic run (`--host` = `APEX_NODE_HOST`, `--compression max`), applies retention (`forget --prune`), and notifies. It refuses to report success if no `restic.*`-labelled service produced a new snapshot. The full flow is described in [Concept: Tiered storage](/apex/concepts/tiered-storage#backups). Schedule it from `configs/cron/crontab` so the run and its Telegram outcome are unattended.
|
||||
Runs [resticontainer](https://github.com/deytenit/resticontainer) one-shot under the `manual` compose profile: init-if-needed, then a label-driven backup that discovers each service's intent from its `restic.*` compose labels (`restic.enable`, `restic.backup.paths`, …), snapshots the union of the resolved host paths in a single restic run (`--host` = `APEX_NODE_HOST`, `--compression max`), applies retention (`forget --prune`), and notifies. It refuses to report success if no `restic.*`-labelled service produced a new snapshot. The full flow is described in [Concept: Tiered storage](/apex/concepts/tiered-storage#backups). Schedule it from `configs/cron/crontab` so the run and its Telegram outcome are unattended.
|
||||
|
||||
## Repository sync (capture-up) {#sync}
|
||||
|
||||
@@ -46,7 +46,7 @@ Runs [resticontainer](https://github.com/deytenit/resticontainer) one-shot under
|
||||
apex sync/repository <telegram-bot-url>
|
||||
```
|
||||
|
||||
Commits the live checkout to `sync/<node>`, rebases onto `origin/main`, and force-pushes with lease; CI folds the branch back into `main`. The action refuses to run — with a paged error — when the commons submodule is dirty or the `compositions/` tree is missing, and aborts cleanly on a rebase conflict (manual intervention required). See [Concept: Framework architecture](/apex/concepts/architecture#capture-up) for the model.
|
||||
Commits the live checkout to `sync/<node>`, rebases onto `origin/main`, and force-pushes with lease; CI folds the branch back into `main`. The action refuses to run — with a paged error — when the commons submodule is dirty or the `compositions/` tree is missing, and aborts cleanly on a rebase conflict (manual intervention required). See [Concept: Framework architecture](/apex/concepts/architecture#capture-up) for the model.
|
||||
|
||||
## Update awareness {#updates}
|
||||
|
||||
@@ -54,7 +54,7 @@ Commits the live checkout to `sync/<node>`, rebases onto `origin/main`, and forc
|
||||
apex sync/packages <telegram-bot-url>
|
||||
```
|
||||
|
||||
Reports pending apt upgrades and out-of-date container images (local digests compared against the registry via `skopeo`) in a single Telegram digest. It changes nothing on the host — it is a scheduled awareness check.
|
||||
Reports pending apt upgrades and out-of-date container images (local digests compared against the registry via `skopeo`) in a single Telegram digest. It changes nothing on the host — it is a scheduled awareness check.
|
||||
|
||||
## Certificate extraction {#certs}
|
||||
|
||||
@@ -62,12 +62,12 @@ Reports pending apt upgrades and out-of-date container images (local digests com
|
||||
apex utils/extract-traefik-certs <acme.json> <dest-dir> [telegram-bot-url]
|
||||
```
|
||||
|
||||
Dumps the edge proxy's ACME store into per-domain `fullchain.pem`/`privkey.pem` pairs (via a containerized dumper), restricts key permissions to `600`, and re-owns the output to the invoking user — for services that need raw PEM material instead of proxy-terminated TLS.
|
||||
Dumps the edge proxy's ACME store into per-domain `fullchain.pem`/`privkey.pem` pairs (via a containerized dumper), restricts key permissions to `600`, and re-owns the output to the invoking user — for services that need raw PEM material instead of proxy-terminated TLS.
|
||||
|
||||
---
|
||||
|
||||
**See also:**
|
||||
|
||||
- [Getting started with an apex node](/apex/getting-started)
|
||||
- [Getting started with an apex node](/apex/getting-started)
|
||||
- [Reference: Actions](/apex/reference/actions)
|
||||
- [Docs: Troubleshooting](/apex/troubleshooting)
|
||||
@@ -1,14 +1,14 @@
|
||||
---
|
||||
title: Build a node repository
|
||||
description: Assemble the four pillars, pin commons, and wire the capture-up flow.
|
||||
title: Build a node repository
|
||||
description: Assemble the four pillars, pin commons, and wire the capture-up flow.
|
||||
weight: 21
|
||||
---
|
||||
|
||||
**Context:** every [node](/apex/glossary#node) gets exactly one repository. Pick any repository name — [identity](/apex/glossary#node-identity) is declared explicitly in `node.env`, so the repo name is a convention, not a functional input. This guide assembles one from nothing.
|
||||
**Context:** every [node](/apex/glossary#node) gets exactly one repository. Pick any repository name — [identity](/apex/glossary#node-identity) is declared explicitly in `node.env`, so the repo name is a convention, not a functional input. This guide assembles one from nothing.
|
||||
|
||||
{{% steps %}}
|
||||
|
||||
### Initialize the repository and pin commons
|
||||
### Initialize the repository and pin commons
|
||||
|
||||
```bash
|
||||
git init <node-repo> && cd <node-repo>
|
||||
@@ -18,7 +18,7 @@ git add .gitmodules commons
|
||||
mkdir -p proprietaries/actions proprietaries/lib configs compositions/apex .github/workflows
|
||||
```
|
||||
|
||||
The gitlink pins the exact engine version the node runs. Upgrading later means checking out a newer tag inside `commons/` and committing the moved pointer.
|
||||
The gitlink pins the exact engine version the node runs. Upgrading later means checking out a newer tag inside `commons/` and committing the moved pointer.
|
||||
|
||||
### Declare identity
|
||||
|
||||
@@ -30,9 +30,9 @@ APEX_GIT_AUTHOR_NAME=apex [bot]
|
||||
APEX_GIT_AUTHOR_EMAIL=apex@localhost
|
||||
```
|
||||
|
||||
`node.env` declares the node's identity explicitly — no domain shape is assumed. `APEX_NODE_FQDN` is the real FQDN used for traefik `Host()` routing (fallback: the OS hostname, with a warning). `APEX_NODE_HOST` is the public host name used for the backup `--host` and the observability/notify `instance` label (default: `APEX_NODE_FQDN`); the short node `name` is its first label. `APEX_SUBNET` is the node's /24 for the `direct` network and is **required** — a missing value exits `66` (see [Concept: Fleet addressing scheme](/apex/concepts/addressing)). `APEX_GIT_AUTHOR_NAME`/`APEX_GIT_AUTHOR_EMAIL` are the capture-up bot identity (generic defaults if unset).
|
||||
`node.env` declares the node's identity explicitly — no domain shape is assumed. `APEX_NODE_FQDN` is the real FQDN used for traefik `Host()` routing (fallback: the OS hostname, with a warning). `APEX_NODE_HOST` is the public host name used for the backup `--host` and the observability/notify `instance` label (default: `APEX_NODE_FQDN`); the short node `name` is its first label. `APEX_SUBNET` is the node's /24 for the `direct` network and is **required** — a missing value exits `66` (see [Concept: Fleet addressing scheme](/apex/concepts/addressing)). `APEX_GIT_AUTHOR_NAME`/`APEX_GIT_AUTHOR_EMAIL` are the capture-up bot identity (generic defaults if unset).
|
||||
|
||||
### Write the core wrapper and its env files
|
||||
### Write the core wrapper and its env files
|
||||
|
||||
```yaml {filename="compositions/apex/docker-compose.yml"}
|
||||
name: apex
|
||||
@@ -55,9 +55,9 @@ APEX_OBS_BASIC_AUTH_USER=<node-user>
|
||||
# APEX_RESTIC_AWS_ENDPOINT= # e.g. an S3-compatible endpoint URL
|
||||
```
|
||||
|
||||
`apex.env` holds committed non-secret scalars only. `COMPOSE_PROFILES` selects which optional core services this node runs — `adguard`, `xray`, `alloy` (the observability push agent), and `manual` (one-shot backups) — and may be empty. Document the uncommitted secret keys in `compositions/apex/SECRETS.md` so an operator on a fresh host knows what to fill in `.env` (`APEX_RESTIC_PASSWORD`, the S3 access keys, `APEX_TRAEFIK_CF_DNS_API_TOKEN`, `APEX_TRAEFIK_ACME_EMAIL`, and `APEX_OBS_BASIC_AUTH_PASS` when observability is on).
|
||||
`apex.env` holds committed non-secret scalars only. `COMPOSE_PROFILES` selects which optional core services this node runs — `adguard`, `xray`, `alloy` (the observability push agent), and `manual` (one-shot backups) — and may be empty. Document the uncommitted secret keys in `compositions/apex/SECRETS.md` so an operator on a fresh host knows what to fill in `.env` (`APEX_RESTIC_PASSWORD`, the S3 access keys, `APEX_TRAEFIK_CF_DNS_API_TOKEN`, `APEX_TRAEFIK_ACME_EMAIL`, and `APEX_OBS_BASIC_AUTH_PASS` when observability is on).
|
||||
|
||||
### Ignore what never belongs in git
|
||||
### Ignore what never belongs in git
|
||||
|
||||
```gitignore {filename=".gitignore"}
|
||||
**/@tier1
|
||||
@@ -69,17 +69,17 @@ __pycache__/
|
||||
*.pyc
|
||||
```
|
||||
|
||||
### Add the bootstrap script
|
||||
### Add the bootstrap script
|
||||
|
||||
`init.sh` prepares a fresh clone on the host: `git submodule update --init --recursive`, `git config core.hooksPath commons/githooks`, `git config --global --add safe.directory <repo>`, and prepend `<repo>/commons` to `PATH` in the login shell so `apex` resolves. Mark it executable.
|
||||
`init.sh` prepares a fresh clone on the host: `git submodule update --init --recursive`, `git config core.hooksPath commons/githooks`, `git config --global --add safe.directory <repo>`, and prepend `<repo>/commons` to `PATH` in the login shell so `apex` resolves. Mark it executable.
|
||||
|
||||
### Start the addressing ledger
|
||||
### Start the addressing ledger
|
||||
|
||||
Create `compositions/ADDRESSING.md` with the node's subnet (`APEX_SUBNET`) and the reserved ranges (`.10`–`.19` core, `.20`+ services). Every [anchor IP](/apex/glossary#anchor-ip) allocated later is recorded here.
|
||||
Create `compositions/ADDRESSING.md` with the node's subnet (`APEX_SUBNET`) and the reserved ranges (`.10`–`.19` core, `.20`+ services). Every [anchor IP](/apex/glossary#anchor-ip) allocated later is recorded here.
|
||||
|
||||
### Wire capture-up CI
|
||||
|
||||
Add a scheduled workflow that merges `sync/<node>` into `main` when the branch exists, committing under a bot identity. Check out **without** submodules — the merge only moves the commons pointer, and an SSH submodule URL is not fetchable with the default CI token. See [Concept: Framework architecture](/apex/concepts/architecture) for the full [capture-up](/apex/glossary#capture-up) loop.
|
||||
Add a scheduled workflow that merges `sync/<node>` into `main` when the branch exists, committing under a bot identity. Check out **without** submodules — the merge only moves the commons pointer, and an SSH submodule URL is not fetchable with the default CI token. See [Concept: Framework architecture](/apex/concepts/architecture) for the full [capture-up](/apex/glossary#capture-up) loop.
|
||||
|
||||
### Validate
|
||||
|
||||
@@ -87,19 +87,19 @@ Add a scheduled workflow that merges `sync/<node>` into `main` when the branch e
|
||||
./commons/apex --help
|
||||
```
|
||||
|
||||
The listing must show every commons action (plus your proprietaries, once added). Off-host — before `node.env` is filled — expect the harmless warning that `APEX_NODE_FQDN` is unset (the engine falls back to the OS hostname); `APEX_SUBNET` must still be set or the engine exits `66`. Commit the assembled repository on `main`.
|
||||
The listing must show every commons action (plus your proprietaries, once added). Off-host — before `node.env` is filled — expect the harmless warning that `APEX_NODE_FQDN` is unset (the engine falls back to the OS hostname); `APEX_SUBNET` must still be set or the engine exits `66`. Commit the assembled repository on `main`.
|
||||
|
||||
{{% /steps %}}
|
||||
|
||||
## Notes
|
||||
|
||||
- The `proprietaries/` pillar may start empty (keep it present with `.gitkeep` files); the engine treats a missing overlay gracefully.
|
||||
- Migrate host config sources into `configs/` (`ufw/`, `cron/crontab`, `systemd/`, `crowdsec/`) as the node adopts each `configure/*` step — the actions consume `configs/` verbatim through the template layer.
|
||||
- The `proprietaries/` pillar may start empty (keep it present with `.gitkeep` files); the engine treats a missing overlay gracefully.
|
||||
- Migrate host config sources into `configs/` (`ufw/`, `cron/crontab`, `systemd/`, `crowdsec/`) as the node adopts each `configure/*` step — the actions consume `configs/` verbatim through the template layer.
|
||||
|
||||
---
|
||||
|
||||
**See also:**
|
||||
|
||||
- [Getting started with an apex node](/apex/getting-started)
|
||||
- [Getting started with an apex node](/apex/getting-started)
|
||||
- [Concept: Framework architecture](/apex/concepts/architecture)
|
||||
- [Guide: Add a service composition](/apex/guides/service-composition)
|
||||
- [Guide: Add a service composition](/apex/guides/service-composition)
|
||||
@@ -1,18 +1,18 @@
|
||||
---
|
||||
title: Add a service composition
|
||||
description: "Wire a new project into a node: anchor IP, networks, tiers, and lint."
|
||||
title: Add a service composition
|
||||
description: "Wire a new project into a node: anchor IP, networks, tiers, and lint."
|
||||
weight: 23
|
||||
---
|
||||
|
||||
**Context:** a project joins a [node](/apex/glossary#node) as one directory under `compositions/` containing a hand-authored compose file. The framework's contract for that file is small: a static [anchor IP](/apex/glossary#anchor-ip) on the `direct` network, external network references by key, and tier-relative data paths.
|
||||
**Context:** a project joins a [node](/apex/glossary#node) as one directory under `compositions/` containing a hand-authored compose file. The framework's contract for that file is small: a static [anchor IP](/apex/glossary#anchor-ip) on the `direct` network, external network references by key, and tier-relative data paths.
|
||||
|
||||
{{% steps %}}
|
||||
|
||||
### Allocate the anchor address
|
||||
### Allocate the anchor address
|
||||
|
||||
Pick the next free address at `.20` or above in the node's /24 and record it in `compositions/ADDRESSING.md`. Addresses `.10`–`.19` are reserved for the [core composition](/apex/glossary#core-composition) (see [Concept: Fleet addressing scheme](/apex/concepts/addressing)).
|
||||
Pick the next free address at `.20` or above in the node's /24 and record it in `compositions/ADDRESSING.md`. Addresses `.10`–`.19` are reserved for the [core composition](/apex/glossary#core-composition) (see [Concept: Fleet addressing scheme](/apex/concepts/addressing)).
|
||||
|
||||
### Author the compose file
|
||||
### Author the compose file
|
||||
|
||||
```yaml {filename="compositions/<project>/docker-compose.yml"}
|
||||
name: <project>
|
||||
@@ -50,16 +50,16 @@ networks:
|
||||
external: true
|
||||
```
|
||||
|
||||
Conventions at work:
|
||||
Conventions at work:
|
||||
|
||||
- **One anchor per project** holds the static `ipv4_address` on `direct`; sidecars share its network namespace via `network_mode: service:<anchor>`.
|
||||
- **Networks are referenced by key** (`direct`, `enclave`, `socket`) with `external: true` — the core composition owns their definitions.
|
||||
- **`enclave` membership plus traefik labels** publishes the service through the edge proxy; the proxy discovers it via the socket proxy, never via published ports.
|
||||
- **Data paths are tier-relative** (`./@tierN/...`) and resolve through the symlinks created by `tiers/link`.
|
||||
- **Backup is opt-in via `restic.*` labels.** `apex backup/run` drives [resticontainer](https://github.com/deytenit/resticontainer), which discovers what to snapshot from compose labels: `restic.enable=true` marks the service, `restic.backup.paths=<container-path>` names the mounts to back up (resticontainer resolves them to host paths). A service with no `restic.*` labels is simply not backed up. Back up config only where the data is regenerable.
|
||||
- The `${APEX_UID...}` trio comes from the project `.env` maintained by `tiers/useradd`.
|
||||
- **One anchor per project** holds the static `ipv4_address` on `direct`; sidecars share its network namespace via `network_mode: service:<anchor>`.
|
||||
- **Networks are referenced by key** (`direct`, `enclave`, `socket`) with `external: true` — the core composition owns their definitions.
|
||||
- **`enclave` membership plus traefik labels** publishes the service through the edge proxy; the proxy discovers it via the socket proxy, never via published ports.
|
||||
- **Data paths are tier-relative** (`./@tierN/...`) and resolve through the symlinks created by `tiers/link`.
|
||||
- **Backup is opt-in via `restic.*` labels.** `apex backup/run` drives [resticontainer](https://github.com/deytenit/resticontainer), which discovers what to snapshot from compose labels: `restic.enable=true` marks the service, `restic.backup.paths=<container-path>` names the mounts to back up (resticontainer resolves them to host paths). A service with no `restic.*` labels is simply not backed up. Back up config only where the data is regenerable.
|
||||
- The `${APEX_UID...}` trio comes from the project `.env` maintained by `tiers/useradd`.
|
||||
|
||||
### Provision storage and identity
|
||||
### Provision storage and identity
|
||||
|
||||
```bash
|
||||
apex tiers/link <tier1> <tier2> <tier3>
|
||||
@@ -67,7 +67,7 @@ apex tiers/useradd
|
||||
apex tiers/chown
|
||||
```
|
||||
|
||||
Re-running the trio is idempotent; it picks up the new project, creates its tier directories and `.env`, provisions its `noroot-<project>` user, and applies the ownership matrix.
|
||||
Re-running the trio is idempotent; it picks up the new project, creates its tier directories and `.env`, provisions its `noroot-<project>` user, and applies the ownership matrix.
|
||||
|
||||
### Lint
|
||||
|
||||
@@ -75,7 +75,7 @@ Re-running the trio is idempotent; it picks up the new project, creates its tier
|
||||
apex utils/lint-docker-compose
|
||||
```
|
||||
|
||||
Validates every anchor IP (well-formed, unique, inside the node subnet) and runs the compose linter over the repository. The pre-commit hook enforces the same lint on staged compose files.
|
||||
Validates every anchor IP (well-formed, unique, inside the node subnet) and runs the compose linter over the repository. The pre-commit hook enforces the same lint on staged compose files.
|
||||
|
||||
### Deploy
|
||||
|
||||
@@ -83,19 +83,19 @@ Validates every anchor IP (well-formed, unique, inside the node subnet) and runs
|
||||
apex compose up
|
||||
```
|
||||
|
||||
The core composition is already up; the new project starts in its alphabetical slot. Verify with `docker ps` and, if the service is proxied, through its route on the edge.
|
||||
The core composition is already up; the new project starts in its alphabetical slot. Verify with `docker ps` and, if the service is proxied, through its route on the edge.
|
||||
|
||||
{{% /steps %}}
|
||||
|
||||
## Notes
|
||||
|
||||
- Service-internal secrets (for example a database password) live in the project's own `.env` in tier-1 storage, alongside the auto-injected UID block.
|
||||
- Never define `direct`/`enclave`/`socket` inside a project file — a second definition would race the core composition for network ownership.
|
||||
- Service-internal secrets (for example a database password) live in the project's own `.env` in tier-1 storage, alongside the auto-injected UID block.
|
||||
- Never define `direct`/`enclave`/`socket` inside a project file — a second definition would race the core composition for network ownership.
|
||||
|
||||
---
|
||||
|
||||
**See also:**
|
||||
|
||||
- [Concept: Compositions and the shared core](/apex/concepts/compositions)
|
||||
- [Concept: Tiered storage and privilege isolation](/apex/concepts/tiered-storage)
|
||||
- [Concept: Compositions and the shared core](/apex/concepts/compositions)
|
||||
- [Concept: Tiered storage and privilege isolation](/apex/concepts/tiered-storage)
|
||||
- [Concept: Fleet addressing scheme](/apex/concepts/addressing)
|
||||
@@ -1,10 +1,10 @@
|
||||
---
|
||||
title: Reference
|
||||
description: CLI, actions, environment variables, and the engine API.
|
||||
description: CLI, actions, environment variables, and the engine API.
|
||||
weight: 30
|
||||
---
|
||||
|
||||
Technical reference for the apex fleet-operations framework surface: the command-line interface, the built-in [actions](/apex/glossary#action), the environment contract they consume, and the Python [engine](/apex/glossary#engine) API.
|
||||
Technical reference for the apex fleet-operations framework surface: the command-line interface, the built-in [actions](/apex/glossary#action), the environment contract they consume, and the Python [engine](/apex/glossary#engine) API.
|
||||
|
||||
{{% cards %}}
|
||||
{{< card link="cli" title="CLI reference" icon="terminal" subtitle="Command-line invocation, subcommands, and exit codes." >}}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
---
|
||||
title: Actions reference
|
||||
description: "Every commons action: arguments and behavior."
|
||||
description: "Every commons action: arguments and behavior."
|
||||
weight: 32
|
||||
---
|
||||
|
||||
The sixteen [commons](/apex/glossary#commons) actions, by group. Arguments are shown as the parser accepts them; optional arguments are bracketed. Exit codes follow the [engine convention](/apex/reference/cli#exit-codes).
|
||||
The sixteen [commons](/apex/glossary#commons) actions, by group. Arguments are shown as the parser accepts them; optional arguments are bracketed. Exit codes follow the [engine convention](/apex/reference/cli#exit-codes).
|
||||
|
||||
## configure {#configure}
|
||||
|
||||
@@ -12,43 +12,43 @@ The sixteen [commons](/apex/glossary#commons) actions, by group. Arguments are s
|
||||
|
||||
`apex configure [--dry-run]`
|
||||
|
||||
Runs the whole suite in fixed order — `base`, `ufw`, `crowdsec`, `cron`, `systemd`, `routing` — and stops at the first failing step, propagating its exit code. `--dry-run` is forwarded to the steps that support it.
|
||||
Runs the whole suite in fixed order — `base`, `ufw`, `crowdsec`, `cron`, `systemd`, `routing` — and stops at the first failing step, propagating its exit code. `--dry-run` is forwarded to the steps that support it.
|
||||
|
||||
### configure/base
|
||||
|
||||
`apex configure/base`
|
||||
|
||||
Debian-only guard (exit `1` elsewhere). Updates the package index, installs the essential packages (`curl`, `wget`, `git`, `rsyslog`), ensures rsyslog is running, installs `ufw` when absent, and downloads the `ufw-docker` helper to `~/.local/bin/ufw-docker` (skipped when already present).
|
||||
Debian-only guard (exit `1` elsewhere). Updates the package index, installs the essential packages (`curl`, `wget`, `git`, `rsyslog`), ensures rsyslog is running, installs `ufw` when absent, and downloads the `ufw-docker` helper to `~/.local/bin/ufw-docker` (skipped when already present).
|
||||
|
||||
### configure/ufw
|
||||
|
||||
`apex configure/ufw [--dry-run]`
|
||||
|
||||
Applies the node firewall from `configs/ufw/`: `host/host.rules` through plain `ufw`, `host/before.rules`/`after.rules` as self-healing managed blocks inside `/etc/ufw/`, and `docker/*.rules` through `ufw-docker`. Exits `1` when the config root is missing, `66` when exactly one of `docker/`/`host/` is absent, `1` when a `/etc/ufw` target file does not exist. Requires `configure/base` first (checks `ufw` and `ufw-docker`). Finishes with a restart reminder.
|
||||
Applies the node firewall from `configs/ufw/`: `host/host.rules` through plain `ufw`, `host/before.rules`/`after.rules` as self-healing managed blocks inside `/etc/ufw/`, and `docker/*.rules` through `ufw-docker`. Exits `1` when the config root is missing, `66` when exactly one of `docker/`/`host/` is absent, `1` when a `/etc/ufw` target file does not exist. Requires `configure/base` first (checks `ufw` and `ufw-docker`). Finishes with a restart reminder.
|
||||
|
||||
### configure/crowdsec
|
||||
|
||||
`apex configure/crowdsec [--dry-run]`
|
||||
|
||||
Installs CrowdSec via the official script when `cscli` is absent, renders `configs/crowdsec/` into `/etc/crowdsec/` (backing up overwritten files), installs the `crowdsecurity/linux` and `crowdsecurity/traefik` collections, and installs the iptables firewall bouncer when `configs/crowdsec/bouncers/crowdsec-firewall-bouncer.yaml.local` exists and the package is missing. `--dry-run` renders without writing and skips install steps.
|
||||
Installs CrowdSec via the official script when `cscli` is absent, renders `configs/crowdsec/` into `/etc/crowdsec/` (backing up overwritten files), installs the `crowdsecurity/linux` and `crowdsecurity/traefik` collections, and installs the iptables firewall bouncer when `configs/crowdsec/bouncers/crowdsec-firewall-bouncer.yaml.local` exists and the package is missing. `--dry-run` renders without writing and skips install steps.
|
||||
|
||||
### configure/cron
|
||||
|
||||
`apex configure/cron [--dry-run]`
|
||||
|
||||
Renders `configs/cron/crontab` with `ctx.vars()` and installs it via `crontab -`. Missing source file: exit `1`. Empty render: exit `65`, previous crontab kept. Failed install: previous crontab restored, exit `1`. The rendered result is always printed for review.
|
||||
Renders `configs/cron/crontab` with `ctx.vars()` and installs it via `crontab -`. Missing source file: exit `1`. Empty render: exit `65`, previous crontab kept. Failed install: previous crontab restored, exit `1`. The rendered result is always printed for review.
|
||||
|
||||
### configure/systemd
|
||||
|
||||
`apex configure/systemd [--dry-run]`
|
||||
|
||||
Renders every top-level, non-hidden file of `configs/systemd/` into `/etc/systemd/system/<name>`; for each `.service` unit it daemon-reloads and enables `--now`. Missing config directory: exit `1`.
|
||||
Renders every top-level, non-hidden file of `configs/systemd/` into `/etc/systemd/system/<name>`; for each `.service` unit it daemon-reloads and enables `--now`. Missing config directory: exit `1`.
|
||||
|
||||
### configure/routing
|
||||
|
||||
`apex configure/routing`
|
||||
|
||||
Enables `net.ipv4.ip_forward`, installs the fwmark `0x1` → routing-table-100 policy rule and the local default route in that table. Both checks are idempotent — re-runs change nothing.
|
||||
Enables `net.ipv4.ip_forward`, installs the fwmark `0x1` → routing-table-100 policy rule and the local default route in that table. Both checks are idempotent — re-runs change nothing.
|
||||
|
||||
## sync {#sync}
|
||||
|
||||
@@ -56,13 +56,13 @@ Enables `net.ipv4.ip_forward`, installs the fwmark `0x1` → routing-table-100 p
|
||||
|
||||
`apex sync/repository <telegram_bot_url>`
|
||||
|
||||
The [capture-up](/apex/glossary#capture-up) commit: switches to (or creates) `sync/<node>`, stages the whole tree plus the refreshed commons submodule pointer, commits under a bot identity when anything changed, rebases onto `origin/main`, updates submodules, and pushes with `--force-with-lease`. Aborts (exit `1`) when the commons submodule is dirty or `compositions/` is missing; on a rebase conflict it aborts the rebase and pages via Telegram. Success and every failure path notify; a failed success-notification also exits `1`.
|
||||
The [capture-up](/apex/glossary#capture-up) commit: switches to (or creates) `sync/<node>`, stages the whole tree plus the refreshed commons submodule pointer, commits under a bot identity when anything changed, rebases onto `origin/main`, updates submodules, and pushes with `--force-with-lease`. Aborts (exit `1`) when the commons submodule is dirty or `compositions/` is missing; on a rebase conflict it aborts the rebase and pages via Telegram. Success and every failure path notify; a failed success-notification also exits `1`.
|
||||
|
||||
### sync/packages
|
||||
|
||||
`apex sync/packages <telegram_bot_url>`
|
||||
|
||||
Debian-only. Collects pending apt upgrades (`--just-print`) and out-of-date running container images (local RepoDigest vs the registry digest via `skopeo`), then sends one Telegram digest. Read-only with respect to packages and images. Unhandled failures page via Telegram and exit `1`; so does a failed digest delivery.
|
||||
Debian-only. Collects pending apt upgrades (`--just-print`) and out-of-date running container images (local RepoDigest vs the registry digest via `skopeo`), then sends one Telegram digest. Read-only with respect to packages and images. Unhandled failures page via Telegram and exit `1`; so does a failed digest delivery.
|
||||
|
||||
## backup {#backup}
|
||||
|
||||
@@ -70,7 +70,7 @@ Debian-only. Collects pending apt upgrades (`--just-print`) and out-of-date runn
|
||||
|
||||
`apex backup/run <telegram_bot_url>`
|
||||
|
||||
Drives the label-driven `resticontainer` core service one-shot (profile `manual`): repository reachability check with init-if-needed, then a single `backup` in which resticontainer discovers every service carrying `restic.*` compose labels, runs their pre-hooks, stops any container flagged `restic.backup.stop`, resolves the labeled container mounts to host paths, and snapshots the union in one restic run. The snapshot is stamped `--host <APEX_NODE_HOST>`, `--tag biweekly`, `--compression max`. A guard then proves a fresh snapshot for this host actually landed — if no `restic.*` labels were discovered the backup is a silent no-op, so it refuses to report success. Retention follows via `forget --prune` (7 daily / 4 weekly / 12 monthly). Each restic failure pages and exits `1`; success pages too, and a failed success-notification exits `1`.
|
||||
Drives the label-driven `resticontainer` core service one-shot (profile `manual`): repository reachability check with init-if-needed, then a single `backup` in which resticontainer discovers every service carrying `restic.*` compose labels, runs their pre-hooks, stops any container flagged `restic.backup.stop`, resolves the labeled container mounts to host paths, and snapshots the union in one restic run. The snapshot is stamped `--host <APEX_NODE_HOST>`, `--tag biweekly`, `--compression max`. A guard then proves a fresh snapshot for this host actually landed — if no `restic.*` labels were discovered the backup is a silent no-op, so it refuses to report success. Retention follows via `forget --prune` (7 daily / 4 weekly / 12 monthly). Each restic failure pages and exits `1`; success pages too, and a failed success-notification exits `1`.
|
||||
|
||||
## tiers {#tiers}
|
||||
|
||||
@@ -78,19 +78,19 @@ Drives the label-driven `resticontainer` core service one-shot (profile `manual`
|
||||
|
||||
`apex tiers/link <tier1_path> <tier2_path> <tier3_path>`
|
||||
|
||||
Builds the whole [tier](/apex/glossary#tier) symlink structure: node-level `compositions/@tierN` links, per-project tier directories, `shared/` areas with back-links, per-project `@tierN` links, and the `.env` symlink into tier 1 (target created when missing). Existing non-symlink paths prompt for confirmation; declining skips that link.
|
||||
Builds the whole [tier](/apex/glossary#tier) symlink structure: node-level `compositions/@tierN` links, per-project tier directories, `shared/` areas with back-links, per-project `@tierN` links, and the `.env` symlink into tier 1 (target created when missing). Existing non-symlink paths prompt for confirmation; declining skips that link.
|
||||
|
||||
### tiers/useradd
|
||||
|
||||
`apex tiers/useradd`
|
||||
|
||||
Ensures the `noroot-shared` group, then one locked `noroot-<project>` system user per composition project (own primary group, no home, `nologin` shell, member of the shared group), and injects `APEX_UID`/`APEX_GID`/`APEX_SHARED_GID` into each project `.env` between managed markers.
|
||||
Ensures the `noroot-shared` group, then one locked `noroot-<project>` system user per composition project (own primary group, no home, `nologin` shell, member of the shared group), and injects `APEX_UID`/`APEX_GID`/`APEX_SHARED_GID` into each project `.env` between managed markers.
|
||||
|
||||
### tiers/chown
|
||||
|
||||
`apex tiers/chown [tier1_path] [tier2_path] [tier3_path]`
|
||||
|
||||
Applies the ownership matrix (see [Concept: Tiered storage](/apex/concepts/tiered-storage#ownership)) across the tier roots — defaults are the node-level tier links. Requires `noroot-shared` to exist (exit `1` otherwise); projects with incomplete `.env` identity are skipped with a warning.
|
||||
Applies the ownership matrix (see [Concept: Tiered storage](/apex/concepts/tiered-storage#ownership)) across the tier roots — defaults are the node-level tier links. Requires `noroot-shared` to exist (exit `1` otherwise); projects with incomplete `.env` identity are skipped with a warning.
|
||||
|
||||
## utils {#utils}
|
||||
|
||||
@@ -98,13 +98,13 @@ Applies the ownership matrix (see [Concept: Tiered storage](/apex/concepts/tiere
|
||||
|
||||
`apex utils/extract-traefik-certs <source_acme> <dest_dir> [telegram_bot_url]`
|
||||
|
||||
Extracts the ACME store into per-domain `fullchain.pem`/`privkey.pem` via a containerized dumper, then `chmod 600` on the keys and re-owns the output tree to the invoking user. Missing source: exit `1`. Failures inside the extraction page via Telegram when a URL was given.
|
||||
Extracts the ACME store into per-domain `fullchain.pem`/`privkey.pem` via a containerized dumper, then `chmod 600` on the keys and re-owns the output tree to the invoking user. Missing source: exit `1`. Failures inside the extraction page via Telegram when a URL was given.
|
||||
|
||||
### utils/lint-docker-compose
|
||||
|
||||
`apex utils/lint-docker-compose [--hook]`
|
||||
|
||||
Two passes: (1) [anchor-IP](/apex/glossary#anchor-ip) validation over `compositions/*/docker-compose.yml` — well-formed, unique, inside the node subnet; (2) the `dclint` compose linter with `--fix`, run in a container over the repository (tier links, `.env`, and the commons submodule excluded). `--hook` restricts pass 2 to staged compose files and re-stages what the linter fixed. The exit code reflects both passes.
|
||||
Two passes: (1) [anchor-IP](/apex/glossary#anchor-ip) validation over `compositions/*/docker-compose.yml` — well-formed, unique, inside the node subnet; (2) the `dclint` compose linter with `--fix`, run in a container over the repository (tier links, `.env`, and the commons submodule excluded). `--hook` restricts pass 2 to staged compose files and re-stages what the linter fixed. The exit code reflects both passes.
|
||||
|
||||
## compose {#compose}
|
||||
|
||||
@@ -112,12 +112,12 @@ Two passes: (1) [anchor-IP](/apex/glossary#anchor-ip) validation over `compositi
|
||||
|
||||
`apex compose <action> [--dry-run] [extra...]`
|
||||
|
||||
Orchestrates `docker compose <action>` across the node: the core project first, then services alphabetically (reversed for `down`). `up` implies `-d`; `.env`/`apex.env` are passed per project when present; extra arguments pass through; `--dry-run` (accepted in any position) prints commands instead of executing.
|
||||
Orchestrates `docker compose <action>` across the node: the core project first, then services alphabetically (reversed for `down`). `up` implies `-d`; `.env`/`apex.env` are passed per project when present; extra arguments pass through; `--dry-run` (accepted in any position) prints commands instead of executing.
|
||||
|
||||
---
|
||||
|
||||
**See also:**
|
||||
|
||||
- [Reference: CLI](/apex/reference/cli)
|
||||
- [Guide: Operate a node](/apex/guides/host-operations)
|
||||
- [Guide: Operate a node](/apex/guides/host-operations)
|
||||
- [GitHub: apex.ermnvldmr.com](https://github.com/deytenit/apex.ermnvldmr.com)
|
||||
@@ -1,56 +1,56 @@
|
||||
---
|
||||
title: CLI reference
|
||||
description: "The apex launcher: listing, dispatch, help, and exit codes."
|
||||
description: "The apex launcher: listing, dispatch, help, and exit codes."
|
||||
weight: 31
|
||||
---
|
||||
|
||||
The launcher is a single stdlib-Python script, `commons/apex` (on `PATH` after `init.sh`). Everything it does targets the local checkout — no node argument exists.
|
||||
The launcher is a single stdlib-Python script, `commons/apex` (on `PATH` after `init.sh`). Everything it does targets the local checkout — no node argument exists.
|
||||
|
||||
## Invocation forms {#invocation}
|
||||
|
||||
| Command | Behavior |
|
||||
|---|---|
|
||||
| `apex` or `apex --help` | List every available [action](/apex/glossary#action) with its summary; exit `0`. |
|
||||
| `apex` or `apex --help` | List every available [action](/apex/glossary#action) with its summary; exit `0`. |
|
||||
| `apex <group>/<action> [args...]` | Dispatch one action. |
|
||||
| `apex <group>/<action> --help` | Print the action's argparse help generated from its `METADATA`; exit `0`. |
|
||||
| `apex <group>/<action> --help` | Print the action's argparse help generated from its `METADATA`; exit `0`. |
|
||||
|
||||
The listing tags each entry with its [overlay](/apex/glossary#overlay) source — no tag (commons), `(local)`, `(local override)` — and appends `[DISABLED: reason]` where a module declares `DISABLED`. Listing uses static `ast` scanning, so no action code executes.
|
||||
The listing tags each entry with its [overlay](/apex/glossary#overlay) source — no tag (commons), `(local)`, `(local override)` — and appends `[DISABLED: reason]` where a module declares `DISABLED`. Listing uses static `ast` scanning, so no action code executes.
|
||||
|
||||
## Layout and path resolution {#layout}
|
||||
## Layout and path resolution {#layout}
|
||||
|
||||
At startup the launcher decides which of two layouts it is in:
|
||||
At startup the launcher decides which of two layouts it is in:
|
||||
|
||||
- **Node checkout** — the launcher lives in a directory named `commons/` whose parent contains `node.env`. The parent is the repository root; `proprietaries/actions/` is the overlay tree; [node identity](/apex/glossary#node-identity) is resolved before dispatch, and any identity warnings are logged first.
|
||||
- **Standalone commons** — any other location. The commons directory is its own root, there is no overlay, and `ctx.node` is `None` (log lines carry the `global` prefix).
|
||||
- **Node checkout** — the launcher lives in a directory named `commons/` whose parent contains `node.env`. The parent is the repository root; `proprietaries/actions/` is the overlay tree; [node identity](/apex/glossary#node-identity) is resolved before dispatch, and any identity warnings are logged first.
|
||||
- **Standalone commons** — any other location. The commons directory is its own root, there is no overlay, and `ctx.node` is `None` (log lines carry the `global` prefix).
|
||||
|
||||
`sys.path` is assembled as: the commons directory (makes `engine` importable), then `commons/vendor/` when present, then `../proprietaries/` in a node checkout (makes proprietary `lib/` code importable).
|
||||
`sys.path` is assembled as: the commons directory (makes `engine` importable), then `commons/vendor/` when present, then `../proprietaries/` in a node checkout (makes proprietary `lib/` code importable).
|
||||
|
||||
## Exit codes {#exit-codes}
|
||||
|
||||
| Code | Source |
|
||||
|---|---|
|
||||
| `0` | Action returned `0` or a non-integer; `--help` paths. |
|
||||
| `1` | Unknown action; disabled action; unhandled exception (logged as `Type: message` with the context prefix); non-integer `SystemExit`. |
|
||||
| `64` | Usage error from argument parsing (`EX_USAGE`). |
|
||||
| `65` | Data error raised by engine libraries (`EX_DATAERR`) — for example an empty rendered crontab. |
|
||||
| `66` | Missing input (`EX_NOINPUT`) — for example missing identity fields or an absent ufw config directory. |
|
||||
| `0` | Action returned `0` or a non-integer; `--help` paths. |
|
||||
| `1` | Unknown action; disabled action; unhandled exception (logged as `Type: message` with the context prefix); non-integer `SystemExit`. |
|
||||
| `64` | Usage error from argument parsing (`EX_USAGE`). |
|
||||
| `65` | Data error raised by engine libraries (`EX_DATAERR`) — for example an empty rendered crontab. |
|
||||
| `66` | Missing input (`EX_NOINPUT`) — for example missing identity fields or an absent ufw config directory. |
|
||||
|
||||
Any other code is whatever the action itself returned or raised.
|
||||
Any other code is whatever the action itself returned or raised.
|
||||
|
||||
## Log format {#log-format}
|
||||
|
||||
All engine and action output follows one line shape:
|
||||
All engine and action output follows one line shape:
|
||||
|
||||
```text
|
||||
[<node>] [<group>/<action>] [LEVEL] message
|
||||
```
|
||||
|
||||
`LEVEL` is `INFO`, `WARN`, `ERROR`, or `SUCCESS`; `ERROR` lines go to stderr, everything else to stdout — cron-friendly for both capture and alerting.
|
||||
`LEVEL` is `INFO`, `WARN`, `ERROR`, or `SUCCESS`; `ERROR` lines go to stderr, everything else to stdout — cron-friendly for both capture and alerting.
|
||||
|
||||
---
|
||||
|
||||
**See also:**
|
||||
|
||||
- [Concept: Actions and the overlay](/apex/concepts/actions-and-overlay)
|
||||
- [Concept: Actions and the overlay](/apex/concepts/actions-and-overlay)
|
||||
- [Reference: Actions](/apex/reference/actions)
|
||||
- [Reference: Engine API](/apex/reference/engine-api)
|
||||
@@ -1,10 +1,10 @@
|
||||
---
|
||||
title: Engine API reference
|
||||
description: The descriptor and ctx APIs available to action authors.
|
||||
description: The descriptor and ctx APIs available to action authors.
|
||||
weight: 34
|
||||
---
|
||||
|
||||
The surface an [action](/apex/glossary#action) author programs against: the argument descriptors in `engine.descriptor` and the members of the execution context. Conceptual background in [Concept: The execution context](/apex/concepts/execution-context).
|
||||
The surface an [action](/apex/glossary#action) author programs against: the argument descriptors in `engine.descriptor` and the members of the execution context. Conceptual background in [Concept: The execution context](/apex/concepts/execution-context).
|
||||
|
||||
## Descriptors {#descriptors}
|
||||
|
||||
@@ -14,20 +14,20 @@ from engine.descriptor import Meta, Arg, Opt, Flag, Rest
|
||||
|
||||
| Class | Signature | argparse mapping |
|
||||
|---|---|---|
|
||||
| `Meta` | `Meta(summary, args=[])` | Drives both the listing summary and the parser. |
|
||||
| `Meta` | `Meta(summary, args=[])` | Drives both the listing summary and the parser. |
|
||||
| `Arg` | `Arg(name, help="", required=True)` | Positional; `required=False` → `nargs="?"`, default `None`. |
|
||||
| `Opt` | `Opt(name, help="", default=None)` | Value option (`--name VALUE`) with a default. |
|
||||
| `Opt` | `Opt(name, help="", default=None)` | Value option (`--name VALUE`) with a default. |
|
||||
| `Flag` | `Flag(name, help="")` | Boolean `store_true` flag. |
|
||||
| `Rest` | `Rest(name, help="")` | Greedy remainder (`argparse.REMAINDER`). |
|
||||
|
||||
Usage errors exit `64` instead of argparse's default `2`.
|
||||
Usage errors exit `64` instead of argparse's default `2`.
|
||||
|
||||
> [!WARNING]
|
||||
> `Rest` captures *everything* after the first positional — including flags. A `Flag` declared next to a `Rest` is only recognized *before* the positional; an action that must accept its flag anywhere has to strip it out of the remainder itself (the `compose` action does exactly this for `--dry-run`).
|
||||
> `Rest` captures *everything* after the first positional — including flags. A `Flag` declared next to a `Rest` is only recognized *before* the positional; an action that must accept its flag anywhere has to strip it out of the remainder itself (the `compose` action does exactly this for `--dry-run`).
|
||||
|
||||
## ctx.log {#log}
|
||||
|
||||
`info(msg)` · `warn(msg)` · `success(msg)` → stdout; `error(msg)` → stderr. All lines carry the `[<node>] [<action>] [LEVEL]` prefix.
|
||||
`info(msg)` · `warn(msg)` · `success(msg)` → stdout; `error(msg)` → stderr. All lines carry the `[<node>] [<action>] [LEVEL]` prefix.
|
||||
|
||||
## ctx.sys {#sys}
|
||||
|
||||
@@ -41,27 +41,27 @@ Usage errors exit `64` instead of argparse's default `2`.
|
||||
| `ensure_running(name)` | `enable --now` when inactive. |
|
||||
| `restart(name)` / `reload(name, dry_run=False)` / `daemon_reload()` | Service lifecycle. |
|
||||
| `wait_for(name, timeout=30, interval=2)` | Poll until active. |
|
||||
| `install_packages(packages)` | For each package: skip when its binary is on `PATH`, else `apt-get update -y` + `apt-get install -y`. |
|
||||
| `install_packages(packages)` | For each package: skip when its binary is on `PATH`, else `apt-get update -y` + `apt-get install -y`. |
|
||||
|
||||
## ctx.tpl {#tpl}
|
||||
|
||||
| Method | Behavior |
|
||||
|---|---|
|
||||
| `render(path, vars)` | Render one file (relative paths resolve against `configs/`); `safe_substitute` semantics — unknown variables stay literal. |
|
||||
| `render_dir(rel_dir, vars)` | Render every file under a `configs/` subtree; returns `{relative_path: content}`. |
|
||||
| `render(path, vars)` | Render one file (relative paths resolve against `configs/`); `safe_substitute` semantics — unknown variables stay literal. |
|
||||
| `render_dir(rel_dir, vars)` | Render every file under a `configs/` subtree; returns `{relative_path: content}`. |
|
||||
|
||||
## ctx.host {#host}
|
||||
|
||||
| Method | Behavior |
|
||||
|---|---|
|
||||
| `backup(path, sudo=False)` | Copy the file to `<path>.<epoch>.bak`; returns the backup path or `None`. |
|
||||
| `write_file(path, content, backup=True, dry_run=False, sudo=False)` | Atomic install via temp file (mode `0644`); optional prior backup; `sudo` variant preserves an existing target's mode. |
|
||||
| `inject_block(target, content, start, end, anchor=None, old_pairs=(), dry_run=False, sudo=True)` | Managed marker-block replacement; removes current and legacy blocks, inserts after the first anchor line; `False` when the anchor is missing; missing target is an error. |
|
||||
| `install_crontab(text, dry_run=False)` | Snapshot, refuse empty render (`SystemExit(65)`), preview, install via `crontab -`, restore snapshot on failure (`SystemExit(1)`). |
|
||||
| `backup(path, sudo=False)` | Copy the file to `<path>.<epoch>.bak`; returns the backup path or `None`. |
|
||||
| `write_file(path, content, backup=True, dry_run=False, sudo=False)` | Atomic install via temp file (mode `0644`); optional prior backup; `sudo` variant preserves an existing target's mode. |
|
||||
| `inject_block(target, content, start, end, anchor=None, old_pairs=(), dry_run=False, sudo=True)` | Managed marker-block replacement; removes current and legacy blocks, inserts after the first anchor line; `False` when the anchor is missing; missing target is an error. |
|
||||
| `install_crontab(text, dry_run=False)` | Snapshot, refuse empty render (`SystemExit(65)`), preview, install via `crontab -`, restore snapshot on failure (`SystemExit(1)`). |
|
||||
|
||||
## ctx.ufw {#ufw}
|
||||
|
||||
`apply(rules_dir, config_dir=None, dry_run=False)` — the full firewall pass: directory guards (exit `66`), tool guards (`ufw`, `ufw-docker`), host rules, managed-block injection into `/etc/ufw/before.rules` and `after.rules`, container rules from `*.rules` files.
|
||||
`apply(rules_dir, config_dir=None, dry_run=False)` — the full firewall pass: directory guards (exit `66`), tool guards (`ufw`, `ufw-docker`), host rules, managed-block injection into `/etc/ufw/before.rules` and `after.rules`, container rules from `*.rules` files.
|
||||
|
||||
## ctx.crowdsec {#crowdsec}
|
||||
|
||||
@@ -69,20 +69,20 @@ Usage errors exit `64` instead of argparse's default `2`.
|
||||
|
||||
## ctx.systemd {#systemd}
|
||||
|
||||
`deploy_units(configs_subdir, vars, dry_run=False)` — render the subtree's top-level non-hidden files into `/etc/systemd/system/`, then daemon-reload and `enable --now` each `.service`.
|
||||
`deploy_units(configs_subdir, vars, dry_run=False)` — render the subtree's top-level non-hidden files into `/etc/systemd/system/`, then daemon-reload and `enable --now` each `.service`.
|
||||
|
||||
## ctx.notify {#notify}
|
||||
|
||||
`telegram(title, bot_url, message, node=None, level="INFO")` plus the shorthands `success` / `error` / `info` / `warn`. All return `bool` — `True` only when the message was delivered; an empty `bot_url` logs a warning and returns `False`. The alert `instance` defaults to the node's public host (`APEX_NODE_HOST`) — no domain or node prefix is applied.
|
||||
`telegram(title, bot_url, message, node=None, level="INFO")` plus the shorthands `success` / `error` / `info` / `warn`. All return `bool` — `True` only when the message was delivered; an empty `bot_url` logs a warning and returns `False`. The alert `instance` defaults to the node's public host (`APEX_NODE_HOST`) — no domain or node prefix is applied.
|
||||
|
||||
## ctx.paths and ctx.node {#paths}
|
||||
## ctx.paths and ctx.node {#paths}
|
||||
|
||||
`ctx.paths`: `repo_root`, `commons`, `proprietaries`, `configs`, `compositions`, `core`, `tier1..3`, `stier1..3`, `root_tier1..3`. `ctx.node`: the identity record (`name`, `subnet`, `fqdn`, `host`) or `None` in a standalone commons checkout. `ctx.vars()`: the template/compose environment (see [Reference: Environment variables](/apex/reference/environment)). `ctx.commons.run(args)`: delegate to the shadowed commons action — only valid in an overriding proprietary module.
|
||||
`ctx.paths`: `repo_root`, `commons`, `proprietaries`, `configs`, `compositions`, `core`, `tier1..3`, `stier1..3`, `root_tier1..3`. `ctx.node`: the identity record (`name`, `subnet`, `fqdn`, `host`) or `None` in a standalone commons checkout. `ctx.vars()`: the template/compose environment (see [Reference: Environment variables](/apex/reference/environment)). `ctx.commons.run(args)`: delegate to the shadowed commons action — only valid in an overriding proprietary module.
|
||||
|
||||
---
|
||||
|
||||
**See also:**
|
||||
|
||||
- [Concept: The execution context](/apex/concepts/execution-context)
|
||||
- [Concept: The execution context](/apex/concepts/execution-context)
|
||||
- [Guide: Develop actions](/apex/guides/action-development)
|
||||
- [GitHub: apex.ermnvldmr.com](https://github.com/deytenit/apex.ermnvldmr.com)
|
||||
@@ -1,78 +1,78 @@
|
||||
---
|
||||
title: Environment variables reference
|
||||
description: Every APEX_* variable, where it is set, and who consumes it.
|
||||
description: Every APEX_* variable, where it is set, and who consumes it.
|
||||
weight: 33
|
||||
---
|
||||
|
||||
The `APEX_*` namespace is the framework's entire configuration surface. Variables originate in one of four layers; later layers win when the orchestration actions assemble the process environment.
|
||||
The `APEX_*` namespace is the framework's entire configuration surface. Variables originate in one of four layers; later layers win when the orchestration actions assemble the process environment.
|
||||
|
||||
## node.env — identity {#node-env}
|
||||
## node.env — identity {#node-env}
|
||||
|
||||
Committed at the repository root; read by [node identity](/apex/glossary#node-identity) resolution. The framework is domain-agnostic: a node declares its own FQDN and public host name — there is no cluster concept and no required FQDN shape.
|
||||
Committed at the repository root; read by [node identity](/apex/glossary#node-identity) resolution. The framework is domain-agnostic: a node declares its own FQDN and public host name — there is no cluster concept and no required FQDN shape.
|
||||
|
||||
| Variable | Meaning | Notes |
|
||||
|---|---|---|
|
||||
| `APEX_NODE_FQDN` | The node's real FQDN (Traefik `Host()` routing) | Falls back to the OS hostname (with a warning) when unset. |
|
||||
| `APEX_NODE_HOST` | The node's public host name (backup `--host`, obs/notify `instance`) | Defaults to `APEX_NODE_FQDN`. Its first label becomes the short `name`. |
|
||||
| `APEX_SUBNET` | The node's /24 for the `direct` network | Required, only source of the subnet. Missing = exit `66`. |
|
||||
| `APEX_GIT_AUTHOR_NAME` | Bot identity for capture-up commits | Generic default (`apex [bot]`). |
|
||||
| `APEX_GIT_AUTHOR_EMAIL` | Bot identity email for capture-up commits | Generic default (`apex@localhost`). |
|
||||
| `APEX_NODE_FQDN` | The node's real FQDN (Traefik `Host()` routing) | Falls back to the OS hostname (with a warning) when unset. |
|
||||
| `APEX_NODE_HOST` | The node's public host name (backup `--host`, obs/notify `instance`) | Defaults to `APEX_NODE_FQDN`. Its first label becomes the short `name`. |
|
||||
| `APEX_SUBNET` | The node's /24 for the `direct` network | Required, only source of the subnet. Missing = exit `66`. |
|
||||
| `APEX_GIT_AUTHOR_NAME` | Bot identity for capture-up commits | Generic default (`apex [bot]`). |
|
||||
| `APEX_GIT_AUTHOR_EMAIL` | Bot identity email for capture-up commits | Generic default (`apex@localhost`). |
|
||||
|
||||
## apex.env — committed scalars {#apex-env}
|
||||
## apex.env — committed scalars {#apex-env}
|
||||
|
||||
Committed at `compositions/apex/`; non-secret by definition.
|
||||
Committed at `compositions/apex/`; non-secret by definition.
|
||||
|
||||
| Variable | Meaning | Notes |
|
||||
|---|---|---|
|
||||
| `APEX_TRAEFIK_DIRECT_IP` | The edge proxy's [anchor IP](/apex/glossary#anchor-ip) in the node subnet | Required by the core composition (`?error`). |
|
||||
| `APEX_TRAEFIK_DIRECT_IP` | The edge proxy's [anchor IP](/apex/glossary#anchor-ip) in the node subnet | Required by the core composition (`?error`). |
|
||||
| `APEX_RESTIC_REPOSITORY` | Backup repository locator | Required (`?error`). |
|
||||
| `APEX_RESTIC_COMPRESSION` | Restic compression level | Optional; defaults to `max` (fleet policy — `backup/run` also forces `--compression max`). |
|
||||
| `APEX_RESTIC_AWS_REGION` | S3 backend region | Optional; defaults to `us-east-1`. |
|
||||
| `APEX_RESTIC_AWS_ENDPOINT` | S3 backend endpoint URL | Optional; defaults to empty (AWS S3). Set for any non-AWS S3-compatible store. |
|
||||
| `APEX_LOKI_PUSH_URL` | Loki ingest URL for the [Alloy](/apex/concepts/architecture) agent (`loki.write`) | Required by the `alloy` profile (`?error`). |
|
||||
| `APEX_PROM_PUSH_URL` | Prometheus `remote_write` URL for the Alloy agent | Required by the `alloy` profile (`?error`). |
|
||||
| `APEX_OBS_BASIC_AUTH_USER` | Basic-auth user for the observability push targets | Required by the `alloy` profile (`?error`). |
|
||||
| `COMPOSE_PROFILES` | Opt-in core profiles (`adguard`, `xray`, `alloy`) | May be empty; `manual` is activated by `backup/run`, not here. |
|
||||
| `TZ` | Container timezone | Optional; defaults to `Etc/UTC`. |
|
||||
| `APEX_RESTIC_COMPRESSION` | Restic compression level | Optional; defaults to `max` (fleet policy — `backup/run` also forces `--compression max`). |
|
||||
| `APEX_RESTIC_AWS_REGION` | S3 backend region | Optional; defaults to `us-east-1`. |
|
||||
| `APEX_RESTIC_AWS_ENDPOINT` | S3 backend endpoint URL | Optional; defaults to empty (AWS S3). Set for any non-AWS S3-compatible store. |
|
||||
| `APEX_LOKI_PUSH_URL` | Loki ingest URL for the [Alloy](/apex/concepts/architecture) agent (`loki.write`) | Required by the `alloy` profile (`?error`). |
|
||||
| `APEX_PROM_PUSH_URL` | Prometheus `remote_write` URL for the Alloy agent | Required by the `alloy` profile (`?error`). |
|
||||
| `APEX_OBS_BASIC_AUTH_USER` | Basic-auth user for the observability push targets | Required by the `alloy` profile (`?error`). |
|
||||
| `COMPOSE_PROFILES` | Opt-in core profiles (`adguard`, `xray`, `alloy`) | May be empty; `manual` is activated by `backup/run`, not here. |
|
||||
| `TZ` | Container timezone | Optional; defaults to `Etc/UTC`. |
|
||||
|
||||
## .env — secrets {#secrets}
|
||||
## .env — secrets {#secrets}
|
||||
|
||||
Uncommitted; a symlink into tier-1 storage. Keys are documented per node in `SECRETS.md`.
|
||||
Uncommitted; a symlink into tier-1 storage. Keys are documented per node in `SECRETS.md`.
|
||||
|
||||
| Variable | Meaning | Notes |
|
||||
|---|---|---|
|
||||
| `APEX_UID`, `APEX_GID`, `APEX_SHARED_GID` | The project's noroot identity | Auto-injected by `tiers/useradd`; required (`?error`). |
|
||||
| `APEX_UID`, `APEX_GID`, `APEX_SHARED_GID` | The project's noroot identity | Auto-injected by `tiers/useradd`; required (`?error`). |
|
||||
| `APEX_RESTIC_PASSWORD` | Backup repository password | Required (`?error`). |
|
||||
| `APEX_RESTIC_AWS_ACCESS_KEY_ID`, `APEX_RESTIC_AWS_SECRET_ACCESS_KEY` | Object-storage credentials | Required (`?error`). |
|
||||
| `APEX_TRAEFIK_CF_DNS_API_TOKEN` | DNS-challenge token for ACME | Required (`?error`). |
|
||||
| `APEX_TRAEFIK_CF_DNS_API_TOKEN` | DNS-challenge token for ACME | Required (`?error`). |
|
||||
| `APEX_TRAEFIK_ACME_EMAIL` | ACME account email | Required (`?error`). |
|
||||
| `APEX_OBS_BASIC_AUTH_PASS` | Basic-auth password for the observability push targets | Required by the `alloy` profile (`?error`). |
|
||||
| `APEX_LOKI_BASIC_AUTH` | htpasswd users for the central Loki/Prometheus ingest | Not core-required. Only on a node that itself fronts the central observability ingest. |
|
||||
| `APEX_OBS_BASIC_AUTH_PASS` | Basic-auth password for the observability push targets | Required by the `alloy` profile (`?error`). |
|
||||
| `APEX_LOKI_BASIC_AUTH` | htpasswd users for the central Loki/Prometheus ingest | Not core-required. Only on a node that itself fronts the central observability ingest. |
|
||||
|
||||
## Engine exports — ctx.vars() {#engine}
|
||||
## Engine exports — ctx.vars() {#engine}
|
||||
|
||||
Computed by the [engine](/apex/glossary#engine) at invocation; exported to `docker compose` by the orchestration actions and available to every template render. Never set these by hand.
|
||||
Computed by the [engine](/apex/glossary#engine) at invocation; exported to `docker compose` by the orchestration actions and available to every template render. Never set these by hand.
|
||||
|
||||
| Variable | Value |
|
||||
|---|---|
|
||||
| `APEX_NODE_HOST` | The node's public host name (`node.host`); backup `--host` and obs/notify `instance`. |
|
||||
| `APEX_NODE_FQDN` | The node's real FQDN (`node.fqdn`); used for edge routing rules. |
|
||||
| `APEX_NODE_HOST` | The node's public host name (`node.host`); backup `--host` and obs/notify `instance`. |
|
||||
| `APEX_NODE_FQDN` | The node's real FQDN (`node.fqdn`); used for edge routing rules. |
|
||||
| `APEX_SUBNET` | Node subnet. |
|
||||
| `APEX_COMMONS` | Absolute commons directory (`$APEX_COMMONS/apex` is the launcher — crontabs and systemd units use it). |
|
||||
| `APEX_COMMONS` | Absolute commons directory (`$APEX_COMMONS/apex` is the launcher — crontabs and systemd units use it). |
|
||||
| `APEX_REPO_ROOT` | Absolute node repository root. |
|
||||
| `APEX_TIER1`, `APEX_TIER2`, `APEX_TIER3` | Core project data directory per [tier](/apex/glossary#tier). |
|
||||
| `APEX_TIER1_SHARED`, `APEX_TIER2_SHARED`, `APEX_TIER3_SHARED` | Shared area per tier. |
|
||||
| `APEX_TIER_ROOT1`, `APEX_TIER_ROOT2`, `APEX_TIER_ROOT3` | Tier storage roots. |
|
||||
|
||||
> [!NOTE]
|
||||
> Variables marked `?error` above are enforced by the shared core composition: `docker compose` refuses to evaluate the model while they are unset, which turns a forgotten secret into an immediate, explicit failure.
|
||||
> Variables marked `?error` above are enforced by the shared core composition: `docker compose` refuses to evaluate the model while they are unset, which turns a forgotten secret into an immediate, explicit failure.
|
||||
|
||||
Several core-composition variables use `${VAR:-default}` instead, so they are optional and overridable per node: `TZ` (`Etc/UTC`), `APEX_RESTIC_AWS_REGION` (`us-east-1`), `APEX_RESTIC_AWS_ENDPOINT` (empty), `APEX_RESTIC_COMPRESSION` (`max`), and `APEX_ENCLAVE_SUBNET` (`198.18.0.0/16`, the ipallowlist source range for the Traefik dashboard/adguard routes).
|
||||
Several core-composition variables use `${VAR:-default}` instead, so they are optional and overridable per node: `TZ` (`Etc/UTC`), `APEX_RESTIC_AWS_REGION` (`us-east-1`), `APEX_RESTIC_AWS_ENDPOINT` (empty), `APEX_RESTIC_COMPRESSION` (`max`), and `APEX_ENCLAVE_SUBNET` (`198.18.0.0/16`, the ipallowlist source range for the Traefik dashboard/adguard routes).
|
||||
|
||||
---
|
||||
|
||||
**See also:**
|
||||
|
||||
- [Concept: The execution context](/apex/concepts/execution-context)
|
||||
- [Concept: Compositions and the shared core](/apex/concepts/compositions)
|
||||
- [Concept: The execution context](/apex/concepts/execution-context)
|
||||
- [Concept: Compositions and the shared core](/apex/concepts/compositions)
|
||||
- [Concept: Node identity resolution](/apex/concepts/node-identity)
|
||||
@@ -1,79 +1,79 @@
|
||||
---
|
||||
title: Troubleshooting
|
||||
description: Symptoms, diagnosis, and fixes for common apex failure modes.
|
||||
description: Symptoms, diagnosis, and fixes for common apex failure modes.
|
||||
weight: 40
|
||||
---
|
||||
|
||||
Failure modes the [engine](/apex/glossary#engine) reports, with the exact log lines it emits.
|
||||
Failure modes the [engine](/apex/glossary#engine) reports, with the exact log lines it emits.
|
||||
|
||||
## APEX_NODE_FQDN falls back to the hostname {#fqdn-fallback}
|
||||
## APEX_NODE_FQDN falls back to the hostname {#fqdn-fallback}
|
||||
|
||||
**Symptoms:** every action logs `APEX_NODE_FQDN not set in node.env; using hostname '<fqdn>'.`
|
||||
|
||||
**Diagnosis:** `node.env` does not set `APEX_NODE_FQDN`, so the engine derived the FQDN from the OS hostname. On a node whose hostname is the intended FQDN this is harmless; otherwise traefik `Host()` routing will use the wrong name.
|
||||
**Diagnosis:** `node.env` does not set `APEX_NODE_FQDN`, so the engine derived the FQDN from the OS hostname. On a node whose hostname is the intended FQDN this is harmless; otherwise traefik `Host()` routing will use the wrong name.
|
||||
|
||||
**Solution:** set `APEX_NODE_FQDN` explicitly in `node.env` at the repository root (for example `APEX_NODE_FQDN=node1.example.com`).
|
||||
**Solution:** set `APEX_NODE_FQDN` explicitly in `node.env` at the repository root (for example `APEX_NODE_FQDN=node1.example.com`).
|
||||
|
||||
## Identity exits 66 {#identity-66}
|
||||
## Identity exits 66 {#identity-66}
|
||||
|
||||
**Symptoms:** `identity: APEX_NODE_FQDN missing from node.env and no hostname` or `identity: APEX_SUBNET missing from node.env`, exit `66`.
|
||||
**Symptoms:** `identity: APEX_NODE_FQDN missing from node.env and no hostname` or `identity: APEX_SUBNET missing from node.env`, exit `66`.
|
||||
|
||||
**Diagnosis:** a required identity field cannot be resolved. The first line means neither `APEX_NODE_FQDN` nor an OS hostname yields an FQDN; the second means `APEX_SUBNET` is absent. The engine refuses to guess.
|
||||
**Diagnosis:** a required identity field cannot be resolved. The first line means neither `APEX_NODE_FQDN` nor an OS hostname yields an FQDN; the second means `APEX_SUBNET` is absent. The engine refuses to guess.
|
||||
|
||||
**Solution:** create or complete `node.env` at the repository root — set `APEX_SUBNET`, and set `APEX_NODE_FQDN` (or ensure the OS hostname is usable).
|
||||
**Solution:** create or complete `node.env` at the repository root — set `APEX_SUBNET`, and set `APEX_NODE_FQDN` (or ensure the OS hostname is usable).
|
||||
|
||||
## configure/ufw exits 66 {#ufw-66}
|
||||
## configure/ufw exits 66 {#ufw-66}
|
||||
|
||||
**Symptoms:** `Rules directory does not exist: ...` or `Config directory does not exist: ...`, exit `66`.
|
||||
**Symptoms:** `Rules directory does not exist: ...` or `Config directory does not exist: ...`, exit `66`.
|
||||
|
||||
**Diagnosis:** exactly one of `configs/ufw/docker/` or `configs/ufw/host/` is missing — a broken config layout rather than an intentionally rule-less node.
|
||||
**Diagnosis:** exactly one of `configs/ufw/docker/` or `configs/ufw/host/` is missing — a broken config layout rather than an intentionally rule-less node.
|
||||
|
||||
**Solution:** restore the missing directory (empty is acceptable: an empty `docker/` warns `No .rules files found ... nothing to apply.` and continues).
|
||||
**Solution:** restore the missing directory (empty is acceptable: an empty `docker/` warns `No .rules files found ... nothing to apply.` and continues).
|
||||
|
||||
## ufw tooling missing {#ufw-tools}
|
||||
|
||||
**Symptoms:** `ufw not installed/working via sudo. Run configure/base first.` or `ufw-docker not found at ~/.local/bin/ufw-docker. Run configure/base first.`, exit `1`.
|
||||
**Symptoms:** `ufw not installed/working via sudo. Run configure/base first.` or `ufw-docker not found at ~/.local/bin/ufw-docker. Run configure/base first.`, exit `1`.
|
||||
|
||||
**Diagnosis:** the firewall layer's prerequisites are absent.
|
||||
**Diagnosis:** the firewall layer's prerequisites are absent.
|
||||
|
||||
**Solution:** run `apex configure/base`, then retry `apex configure/ufw`.
|
||||
|
||||
## Crontab install refused or rolled back {#cron}
|
||||
## Crontab install refused or rolled back {#cron}
|
||||
|
||||
**Symptoms:** `Rendered crontab is empty; aborting (previous kept).` (exit `65`) or `crontab install failed; restoring previous.` (exit `1`).
|
||||
**Symptoms:** `Rendered crontab is empty; aborting (previous kept).` (exit `65`) or `crontab install failed; restoring previous.` (exit `1`).
|
||||
|
||||
**Diagnosis:** the rendered `configs/cron/crontab` was empty (template/variable problem — remember unknown `$VARS` stay literal, they do not blank the file) or `crontab` rejected the syntax.
|
||||
**Diagnosis:** the rendered `configs/cron/crontab` was empty (template/variable problem — remember unknown `$VARS` stay literal, they do not blank the file) or `crontab` rejected the syntax.
|
||||
|
||||
**Solution:** inspect the printed preview, fix the template, re-run `apex configure/cron`. The previous crontab is intact in both cases.
|
||||
**Solution:** inspect the printed preview, fix the template, re-run `apex configure/cron`. The previous crontab is intact in both cases.
|
||||
|
||||
## sync/repository aborts {#sync-aborts}
|
||||
|
||||
**Symptoms:** `Commons submodule at <path> has uncommitted changes. Aborting.` or `Directory '<repo>/compositions' does not exist. Cannot sync.`, exit `1`.
|
||||
**Symptoms:** `Commons submodule at <path> has uncommitted changes. Aborting.` or `Directory '<repo>/compositions' does not exist. Cannot sync.`, exit `1`.
|
||||
|
||||
**Diagnosis:** the [capture-up](/apex/glossary#capture-up) commit refuses to record a broken checkout — a dirty pinned submodule or a missing [compositions](/apex/glossary#compositions) tree.
|
||||
**Diagnosis:** the [capture-up](/apex/glossary#capture-up) commit refuses to record a broken checkout — a dirty pinned submodule or a missing [compositions](/apex/glossary#compositions) tree.
|
||||
|
||||
**Solution:** for a dirty submodule, `git -C commons checkout -- .` (or commit an intentional pin move); for a missing tree, restore the checkout before the next scheduled run.
|
||||
**Solution:** for a dirty submodule, `git -C commons checkout -- .` (or commit an intentional pin move); for a missing tree, restore the checkout before the next scheduled run.
|
||||
|
||||
## Telegram not delivered {#telegram}
|
||||
|
||||
**Symptoms:** `Telegram Bot URL not provided. Skipping notification.` or `Failed to send Telegram notification: <error>` — and notify-gated actions (`backup/run`, `sync/repository`, `sync/packages`) exit `1` even though their work succeeded.
|
||||
**Symptoms:** `Telegram Bot URL not provided. Skipping notification.` or `Failed to send Telegram notification: <error>` — and notify-gated actions (`backup/run`, `sync/repository`, `sync/packages`) exit `1` even though their work succeeded.
|
||||
|
||||
**Diagnosis:** the bot URL argument was empty, or the send failed (network, proxy, revoked token). The non-zero exit is deliberate: an unattended run whose status cannot be reported is treated as failed.
|
||||
**Diagnosis:** the bot URL argument was empty, or the send failed (network, proxy, revoked token). The non-zero exit is deliberate: an unattended run whose status cannot be reported is treated as failed.
|
||||
|
||||
**Solution:** verify the URL source file the cron line reads, network/proxy reachability, and the bot token; re-run manually to confirm delivery.
|
||||
**Solution:** verify the URL source file the cron line reads, network/proxy reachability, and the bot token; re-run manually to confirm delivery.
|
||||
|
||||
## docker compose rejects the core wrapper {#compose-include}
|
||||
## docker compose rejects the core wrapper {#compose-include}
|
||||
|
||||
**Symptoms:** `docker compose` errors on the `include` keyword when evaluating `compositions/apex/docker-compose.yml`.
|
||||
**Symptoms:** `docker compose` errors on the `include` keyword when evaluating `compositions/apex/docker-compose.yml`.
|
||||
|
||||
**Diagnosis:** the [core composition](/apex/glossary#core-composition) is consumed through `include:`, which requires Docker Compose v2.20+.
|
||||
**Diagnosis:** the [core composition](/apex/glossary#core-composition) is consumed through `include:`, which requires Docker Compose v2.20+.
|
||||
|
||||
**Solution:** upgrade the Compose plugin (`docker compose version` to verify).
|
||||
**Solution:** upgrade the Compose plugin (`docker compose version` to verify).
|
||||
|
||||
---
|
||||
|
||||
**See also:**
|
||||
|
||||
- [Reference: CLI](/apex/reference/cli)
|
||||
- [Guide: Operate a node](/apex/guides/host-operations)
|
||||
- [Guide: Operate a node](/apex/guides/host-operations)
|
||||
- [GitHub: apex.ermnvldmr.com](https://github.com/deytenit/apex.ermnvldmr.com)
|
||||
@@ -6,11 +6,11 @@ cascade:
|
||||
type: docs
|
||||
---
|
||||
|
||||
**Quetza** is a modular Discord bot framework built with TypeScript and Discord.js. It transforms feature development from core modification into independent module addition.
|
||||
**Quetza** is a modular Discord bot framework built with TypeScript and Discord.js. It transforms feature development from core modification into independent module addition.
|
||||
|
||||
## Project Vision
|
||||
|
||||
The framework addresses the fundamental challenge of extensible bot design: providing a scalable architecture where functionality can be added without increasing system complexity or coupling.
|
||||
The framework addresses the fundamental challenge of extensible bot design: providing a scalable architecture where functionality can be added without increasing system complexity or coupling.
|
||||
|
||||
## Documentation Sections
|
||||
|
||||
@@ -25,6 +25,6 @@ The framework addresses the fundamental challenge of extensible bot design: prov
|
||||
## Key Features
|
||||
|
||||
- **Modular Architecture:** Features are plugins, not core modifications.
|
||||
- **TypeScript First:** Built for type safety and developer experience.
|
||||
- **TypeScript First:** Built for type safety and developer experience.
|
||||
- **Zero-Config Discovery:** Filesystem-based module registration.
|
||||
- **State Management:** Built-in patterns for guild-scoped services.
|
||||
- **State Management:** Built-in patterns for guild-scoped services.
|
||||
@@ -3,7 +3,7 @@ title: Concepts
|
||||
weight: 4
|
||||
---
|
||||
|
||||
**Quetza** concepts explain the _Why_ and _How_ behind the framework's design. These documents cover the underlying philosophy and architectural decisions.
|
||||
**Quetza** concepts explain the _Why_ and _How_ behind the framework's design. These documents cover the underlying philosophy and architectural decisions.
|
||||
|
||||
## Key Topics
|
||||
|
||||
|
||||
@@ -3,38 +3,38 @@ title: Architecture & Philosophy
|
||||
weight: 1
|
||||
---
|
||||
|
||||
**Goal:** Understand the core design principles that drive Quetza's modularity.
|
||||
**Goal:** Understand the core design principles that drive Quetza's modularity.
|
||||
|
||||
## Design Philosophies
|
||||
|
||||
### Convention Over Configuration
|
||||
|
||||
Quetza minimizes boilerplate by relying on strict folder structures.
|
||||
Quetza minimizes boilerplate by relying on strict folder structures.
|
||||
|
||||
- **No central registry:** The filesystem _is_ the registry.
|
||||
- **Result:** You don't update a config file to add a module; you just create the folder.
|
||||
- **No central registry:** The filesystem _is_ the registry.
|
||||
- **Result:** You don't update a config file to add a module; you just create the folder.
|
||||
|
||||
### Composition Over Inheritance
|
||||
|
||||
Modules are composed of capabilities, not inherited from a base class.
|
||||
Modules are composed of capabilities, not inherited from a base class.
|
||||
|
||||
- **Flexibility:** Modules can use any internal architecture (Functional, OOP) as long as they export the required interfaces.
|
||||
- **Flexibility:** Modules can use any internal architecture (Functional, OOP) as long as they export the required interfaces.
|
||||
|
||||
### Isolation Over Integration
|
||||
|
||||
Modules cannot directly invoke each other.
|
||||
|
||||
- **Why:** Prevents tight coupling.
|
||||
- **Benefit:** You can delete any module without breaking the rest of the bot.
|
||||
- **Benefit:** You can delete any module without breaking the rest of the bot.
|
||||
|
||||
## Core Entities
|
||||
|
||||
| Entity | Role | Lifecycle |
|
||||
| :------------- | :----------------------------- | :---------------------------- |
|
||||
| **Client** | Service Locator & Orchestrator | Singleton. Runs the show. |
|
||||
| **Module** | Namespace for features | Loaded at startup. |
|
||||
| **Client** | Service Locator & Orchestrator | Singleton. Runs the show. |
|
||||
| **Module** | Namespace for features | Loaded at startup. |
|
||||
| **Command** | User Interaction Endpoint | Stateless. Request/Response. |
|
||||
| **Event** | Reactive Observer | Subscribed at startup. |
|
||||
| **Event** | Reactive Observer | Subscribed at startup. |
|
||||
| **Controller** | State Manager | Persists across interactions. |
|
||||
|
||||
---
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
---
|
||||
title: The Module System
|
||||
title: The Module System
|
||||
weight: 2
|
||||
---
|
||||
|
||||
**Goal:** Define the vocabulary and mechanics of Quetza's primary abstraction unit.
|
||||
**Goal:** Define the vocabulary and mechanics of Quetza's primary abstraction unit.
|
||||
|
||||
## System Vocabulary
|
||||
|
||||
### Module Declaration
|
||||
|
||||
The `module.ts` file acts as the manifest. It is the entry point for discovery.
|
||||
The `module.ts` file acts as the manifest. It is the entry point for discovery.
|
||||
|
||||
```typescript
|
||||
export const name = 'my-module'; // ID
|
||||
@@ -18,36 +18,36 @@ export const description = '...'; // Doc
|
||||
|
||||
### Command Registration
|
||||
|
||||
Occurs during the `ready` event.
|
||||
Occurs during the `ready` event.
|
||||
|
||||
1. Quetza scans all `commands/` folders.
|
||||
2. Compiles `ApplicationCommandData`.
|
||||
3. PUTs the payload to Discord's API.
|
||||
3. PUTs the payload to Discord's API.
|
||||
|
||||
### Controller Injection
|
||||
|
||||
State is injected, not imported.
|
||||
State is injected, not imported.
|
||||
|
||||
```typescript
|
||||
// The framework calls this:
|
||||
execute(client, interaction, controller);
|
||||
```
|
||||
|
||||
- **Why?** Allows the framework to manage the controller's lifecycle (singleton per module).
|
||||
- **Why?** Allows the framework to manage the controller's lifecycle (singleton per module).
|
||||
|
||||
## State Management Philosophy
|
||||
|
||||
### Ephemeral by Default
|
||||
### Ephemeral by Default
|
||||
|
||||
Quetza provides **no built-in database**.
|
||||
|
||||
- **Memory:** Controllers store state in JS `Map` or `Set` objects.
|
||||
- **Restart:** All state is lost when the process exits.
|
||||
- **Persistence:** Must be implemented manually (e.g., SQLite, Redis) inside the Controller.
|
||||
- **Memory:** Controllers store state in JS `Map` or `Set` objects.
|
||||
- **Restart:** All state is lost when the process exits.
|
||||
- **Persistence:** Must be implemented manually (e.g., SQLite, Redis) inside the Controller.
|
||||
|
||||
### Scope as Key
|
||||
### Scope as Key
|
||||
|
||||
State must be keyed by its boundary.
|
||||
State must be keyed by its boundary.
|
||||
|
||||
- `Map<GuildID, ...>` - Server specific (Most common).
|
||||
- `Map<UserID, ...>` - User specific (DM bots).
|
||||
@@ -58,7 +58,7 @@ State must be keyed by its boundary.
|
||||
|
||||
- **Validation:** Check permissions/inputs immediately.
|
||||
- **User-Facing:** "You don't have permission" (Not "Error: 403").
|
||||
- **Isolation:** A crash in `/ping` catches itself and doesn't kill the bot.
|
||||
- **Isolation:** A crash in `/ping` catches itself and doesn't kill the bot.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -3,60 +3,60 @@ title: Getting Started
|
||||
weight: 1
|
||||
---
|
||||
|
||||
**Quetza** is a modular Discord bot framework built with TypeScript that enables developers to build feature-rich Discord applications through self-contained, composable modules.
|
||||
**Quetza** is a modular Discord bot framework built with TypeScript that enables developers to build feature-rich Discord applications through self-contained, composable modules.
|
||||
|
||||
## What is Quetza?
|
||||
## What is Quetza?
|
||||
|
||||
Quetza provides an architectural foundation that transforms feature development from core modification into module addition. It solves the problem of rigid bot design by inverting dependency structures—the core knows nothing about modules, yet modules have full access to capabilities through defined contracts.
|
||||
Quetza provides an architectural foundation that transforms feature development from core modification into module addition. It solves the problem of rigid bot design by inverting dependency structures—the core knows nothing about modules, yet modules have full access to capabilities through defined contracts.
|
||||
|
||||
## The Core Philosophy
|
||||
## The Core Philosophy
|
||||
|
||||
### The Problem
|
||||
### The Problem
|
||||
|
||||
Traditional monolithic bots suffer from brittleness. Adding features requires modifying core logic, intertwining dependencies, and managing growing codebases that are difficult to test or change.
|
||||
Traditional monolithic bots suffer from brittleness. Adding features requires modifying core logic, intertwining dependencies, and managing growing codebases that are difficult to test or change.
|
||||
|
||||
### The Solution
|
||||
### The Solution
|
||||
|
||||
Quetza treats modules as plugins.
|
||||
Quetza treats modules as plugins.
|
||||
|
||||
- **Zero-Touch Extension:** Add features by creating a folder. No core code modification required.
|
||||
- **Zero-Touch Extension:** Add features by creating a folder. No core code modification required.
|
||||
- **Isolation:** Modules operate independently.
|
||||
- **Composition:** Enable/disable features by simply adding or removing module folders.
|
||||
- **Composition:** Enable/disable features by simply adding or removing module folders.
|
||||
|
||||
## High-Level Architecture
|
||||
|
||||
Quetza consists of three primary layers:
|
||||
Quetza consists of three primary layers:
|
||||
|
||||
1. **Foundation Layer**: The `Discord.js` client extended with module-aware registries (Commands, Events, Modules). It acts as a service locator.
|
||||
2. **Module Layer**: Self-contained feature packages. Each module exposes its own commands and events via a standardized file structure.
|
||||
1. **Foundation Layer**: The `Discord.js` client extended with module-aware registries (Commands, Events, Modules). It acts as a service locator.
|
||||
2. **Module Layer**: Self-contained feature packages. Each module exposes its own commands and events via a standardized file structure.
|
||||
3. **Service Layer**: Optional **Controllers** that manage stateful business logic (e.g., audio players, conversation context) across guilds.
|
||||
|
||||
## Key Concepts
|
||||
|
||||
### Mental Model
|
||||
|
||||
To work effectively with Quetza, adopt this framework:
|
||||
To work effectively with Quetza, adopt this framework:
|
||||
|
||||
- **Modules are Plugins:** Independent folders discovered at runtime.
|
||||
- **Modules are Plugins:** Independent folders discovered at runtime.
|
||||
- **Commands are Endpoints:** Stateless handlers acting like REST API endpoints.
|
||||
- **Events are Observers:** Reactive logic subscribing to Discord lifecycle events.
|
||||
- **Controllers are Services:** Stateful managers bridging the gap between stateless commands and persistent behavior.
|
||||
- **Events are Observers:** Reactive logic subscribing to Discord lifecycle events.
|
||||
- **Controllers are Services:** Stateful managers bridging the gap between stateless commands and persistent behavior.
|
||||
|
||||
### System Boundaries
|
||||
|
||||
Understanding constraints is critical for design:
|
||||
Understanding constraints is critical for design:
|
||||
|
||||
- **No Persistence:** In-memory state is lost on restart. External storage is required for persistence.
|
||||
- **No Persistence:** In-memory state is lost on restart. External storage is required for persistence.
|
||||
- **No Inter-Module Communication:** Modules cannot directly invoke each other, ensuring loose coupling.
|
||||
- **No Dynamic Loading:** Modules are loaded only at startup.
|
||||
- **No Dynamic Loading:** Modules are loaded only at startup.
|
||||
|
||||
## Architectural Invariants
|
||||
|
||||
Quetza trades flexibility for predictability through strict conventions:
|
||||
Quetza trades flexibility for predictability through strict conventions:
|
||||
|
||||
- **Convention-Based Discovery:** Modules _must_ follow the directory structure (`module.ts`, `commands/`, `events/`) to be loaded.
|
||||
- **Single-Phase Initialization:** All loading happens before the Discord connection is established.
|
||||
- **Interface Compliance:** All commands and events must export specific interfaces to be registered.
|
||||
- **Convention-Based Discovery:** Modules _must_ follow the directory structure (`module.ts`, `commands/`, `events/`) to be loaded.
|
||||
- **Single-Phase Initialization:** All loading happens before the Discord connection is established.
|
||||
- **Interface Compliance:** All commands and events must export specific interfaces to be registered.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -3,15 +3,15 @@ title: Guides
|
||||
weight: 2
|
||||
---
|
||||
|
||||
**Quetza** guides provide step-by-step instructions for common tasks and workflows. These documents focus on practical application of the framework's concepts.
|
||||
**Quetza** guides provide step-by-step instructions for common tasks and workflows. These documents focus on practical application of the framework's concepts.
|
||||
|
||||
## Core Workflows
|
||||
|
||||
The development and operation of Quetza bots revolve around three primary workflows:
|
||||
The development and operation of Quetza bots revolve around three primary workflows:
|
||||
|
||||
1. **Module Development:** Creating new features by adding self-contained modules.
|
||||
2. **Deployment & Operations:** Running the bot in various environments (Docker, Source).
|
||||
3. **Lifecycle Management:** Maintaining modules from creation to deprecation.
|
||||
1. **Module Development:** Creating new features by adding self-contained modules.
|
||||
2. **Deployment & Operations:** Running the bot in various environments (Docker, Source).
|
||||
3. **Lifecycle Management:** Maintaining modules from creation to deprecation.
|
||||
|
||||
## Available Guides
|
||||
|
||||
|
||||
@@ -3,26 +3,26 @@ title: Conversational AI Integration
|
||||
weight: 4
|
||||
---
|
||||
|
||||
**Goal:** Integrate Large Language Models (LLMs) while managing context and Discord's timeout constraints.
|
||||
**Goal:** Integrate Large Language Models (LLMs) while managing context and Discord's timeout constraints.
|
||||
|
||||
## 1. Context Management Strategy
|
||||
|
||||
Conversations require history to be coherent.
|
||||
Conversations require history to be coherent.
|
||||
|
||||
- **Scoping:** `Map<GuildID, Map<UserID, Message[]>>`
|
||||
- **Guild Key:** Prevents cross-server leaks.
|
||||
- **User Key:** Prevents cross-user confusion in public channels.
|
||||
- **Lifecycle:** Implement a rolling window (e.g., last 50 messages) to prevent context bloat.
|
||||
- **User Key:** Prevents cross-user confusion in public channels.
|
||||
- **Lifecycle:** Implement a rolling window (e.g., last 50 messages) to prevent context bloat.
|
||||
|
||||
## 2. Response Streaming
|
||||
|
||||
LLMs are slow; Discord is fast (3s timeout).
|
||||
LLMs are slow; Discord is fast (3s timeout).
|
||||
|
||||
{{% steps %}}
|
||||
|
||||
### Step 1: Defer Interaction
|
||||
|
||||
Immediately call `interaction.deferReply()`. This buys you 15 minutes.
|
||||
Immediately call `interaction.deferReply()`. This buys you 15 minutes.
|
||||
|
||||
### Step 2: Invoke LLM
|
||||
|
||||
@@ -53,4 +53,4 @@ export async function execute(interaction, controller) {
|
||||
|
||||
> [!WARNING]
|
||||
> **API Costs:**
|
||||
> AI APIs are often billed by token. Implement strict user-based rate limits in your Controller to prevent abuse.
|
||||
> AI APIs are often billed by token. Implement strict user-based rate limits in your Controller to prevent abuse.
|
||||
@@ -3,31 +3,31 @@ title: Deployment & Operations
|
||||
weight: 2
|
||||
---
|
||||
|
||||
**Goal:** Configure, deploy, and monitor a Quetza bot instance.
|
||||
**Goal:** Configure, deploy, and monitor a Quetza bot instance.
|
||||
|
||||
## 1. Environment Preparation
|
||||
|
||||
Quetza relies on environment variables for configuration. **Never** commit these to version control.
|
||||
Quetza relies on environment variables for configuration. **Never** commit these to version control.
|
||||
|
||||
| Variable | Description | Required |
|
||||
| :-------------- | :-------------------------------------------- | :------- |
|
||||
| `DISCORD_TOKEN` | Bot token from Discord Developer Portal | **Yes** |
|
||||
| `CLIENT_ID` | Application ID for slash command registration | **Yes** |
|
||||
| `LLAMA_API_KEY` | Required if using the AI module | No |
|
||||
| `DISCORD_TOKEN` | Bot token from Discord Developer Portal | **Yes** |
|
||||
| `CLIENT_ID` | Application ID for slash command registration | **Yes** |
|
||||
| `LLAMA_API_KEY` | Required if using the AI module | No |
|
||||
|
||||
## 2. Deployment Strategies
|
||||
|
||||
{{< tabs >}}
|
||||
{{< tab name="Docker (Recommended)" >}}
|
||||
**Docker** ensures a consistent runtime environment.
|
||||
**Docker** ensures a consistent runtime environment.
|
||||
|
||||
1. **Build** the image:
|
||||
1. **Build** the image:
|
||||
|
||||
```bash
|
||||
docker build -t quetza-bot .
|
||||
```
|
||||
|
||||
2. **Run** the container:
|
||||
2. **Run** the container:
|
||||
`bash
|
||||
docker run -d \
|
||||
--env-file .env \
|
||||
@@ -36,7 +36,7 @@ docker run -d \
|
||||
`
|
||||
{{< /tab >}}
|
||||
{{< tab name="Source" >}}
|
||||
**Source** deployment is useful for development.
|
||||
**Source** deployment is useful for development.
|
||||
|
||||
3. **Install** dependencies:
|
||||
|
||||
@@ -44,13 +44,13 @@ docker run -d \
|
||||
pnpm install
|
||||
```
|
||||
|
||||
4. **Build** the project:
|
||||
4. **Build** the project:
|
||||
|
||||
```bash
|
||||
pnpm build
|
||||
```
|
||||
|
||||
5. **Start** the bot:
|
||||
5. **Start** the bot:
|
||||
`bash
|
||||
pnpm start
|
||||
`
|
||||
@@ -59,15 +59,15 @@ pnpm start
|
||||
|
||||
## 3. Operational Monitoring
|
||||
|
||||
Once running, use these mechanisms to check health:
|
||||
Once running, use these mechanisms to check health:
|
||||
|
||||
- **Logs:** Check standard output (Docker logs) for initialization errors.
|
||||
- **`/ping` Command:** Verifies network latency and API responsiveness.
|
||||
- **`/modules` Command:** Lists all loaded modules to confirm feature availability.
|
||||
- **Logs:** Check standard output (Docker logs) for initialization errors.
|
||||
- **`/ping` Command:** Verifies network latency and API responsiveness.
|
||||
- **`/modules` Command:** Lists all loaded modules to confirm feature availability.
|
||||
|
||||
> [!WARNING]
|
||||
> **Scaling Constraint:**
|
||||
> Quetza follows a single-instance-per-token model. Do not run multiple instances with the same token, as this will cause WebSocket conflicts.
|
||||
> Quetza follows a single-instance-per-token model. Do not run multiple instances with the same token, as this will cause WebSocket conflicts.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -3,19 +3,19 @@ title: Module Development Workflow
|
||||
weight: 1
|
||||
---
|
||||
|
||||
**Goal:** Create a new feature for the Quetza bot by implementing a self-contained module.
|
||||
**Goal:** Create a new feature for the Quetza bot by implementing a self-contained module.
|
||||
|
||||
## 1. Conceptualization Phase
|
||||
|
||||
Before writing code, determine the module's architecture:
|
||||
Before writing code, determine the module's architecture:
|
||||
|
||||
- **Simple Command:** Needs only a `commands/` folder (e.g., utility commands).
|
||||
- **Reactive Logic:** Needs `events/` to listen to Discord lifecycles (e.g., welcome messages).
|
||||
- **Stateful Feature:** Needs a **Controller** to manage state across guilds (e.g., music player).
|
||||
- **Simple Command:** Needs only a `commands/` folder (e.g., utility commands).
|
||||
- **Reactive Logic:** Needs `events/` to listen to Discord lifecycles (e.g., welcome messages).
|
||||
- **Stateful Feature:** Needs a **Controller** to manage state across guilds (e.g., music player).
|
||||
|
||||
## 2. Structure Establishment
|
||||
|
||||
Create the module directory structure. The presence of these files triggers automatic discovery.
|
||||
Create the module directory structure. The presence of these files triggers automatic discovery.
|
||||
|
||||
{{< filetree/container >}}
|
||||
{{< filetree/folder name="src" state="open" >}}
|
||||
@@ -34,9 +34,9 @@ Create the module directory structure. The presence of these files triggers auto
|
||||
|
||||
{{% steps %}}
|
||||
|
||||
### Step 1: Create the Manifest
|
||||
### Step 1: Create the Manifest
|
||||
|
||||
Create `module.ts` to declare the module.
|
||||
Create `module.ts` to declare the module.
|
||||
|
||||
```typescript
|
||||
// src/modules/my-feature/module.ts
|
||||
@@ -47,7 +47,7 @@ export const description = 'Adds cool feature capabilities';
|
||||
|
||||
### Step 2: Implement Commands
|
||||
|
||||
Create command files in `commands/`. Each file exports data and an executor.
|
||||
Create command files in `commands/`. Each file exports data and an executor.
|
||||
|
||||
```typescript
|
||||
// src/modules/my-feature/commands/ping.ts
|
||||
@@ -62,7 +62,7 @@ export async function execute(interaction) {
|
||||
|
||||
### Step 3: Implement Events (Optional)
|
||||
|
||||
Create event handlers in `events/` to observe Discord state.
|
||||
Create event handlers in `events/` to observe Discord state.
|
||||
|
||||
```typescript
|
||||
// src/modules/my-feature/events/ready.ts
|
||||
@@ -78,14 +78,14 @@ export function execute(client) {
|
||||
|
||||
### Step 4: Define Controller (Optional)
|
||||
|
||||
For stateful logic, implement a controller in `lib/` and export it in `module.ts`.
|
||||
For stateful logic, implement a controller in `lib/` and export it in `module.ts`.
|
||||
|
||||
### Step 5: Validate
|
||||
|
||||
Restart the bot.
|
||||
Restart the bot.
|
||||
|
||||
1. Check logs for "Loaded module: my-feature".
|
||||
2. Verify the command `/ping` appears in Discord.
|
||||
1. Check logs for "Loaded module: my-feature".
|
||||
2. Verify the command `/ping` appears in Discord.
|
||||
|
||||
{{% /steps %}}
|
||||
|
||||
|
||||
@@ -3,37 +3,37 @@ title: Interactive Voice Features
|
||||
weight: 3
|
||||
---
|
||||
|
||||
**Goal:** Implement sophisticated voice interactions that respect guild boundaries and manage ephemeral resources.
|
||||
**Goal:** Implement sophisticated voice interactions that respect guild boundaries and manage ephemeral resources.
|
||||
|
||||
## 1. Resource Scoping Strategy
|
||||
|
||||
Voice features **MUST** be scoped to the Guild.
|
||||
Voice features **MUST** be scoped to the Guild.
|
||||
|
||||
- **Pattern:** The Controller maintains a `Map<GuildID, Player>`.
|
||||
- **Pattern:** The Controller maintains a `Map<GuildID, Player>`.
|
||||
- **Behavior:**
|
||||
- **Join:** Create a Player for `Guild A` if none exists.
|
||||
- **Leave:** Destroy the Player and release connections.
|
||||
- **Isolation:** Commands in `Guild B` never affect `Guild A`.
|
||||
- **Join:** Create a Player for `Guild A` if none exists.
|
||||
- **Leave:** Destroy the Player and release connections.
|
||||
- **Isolation:** Commands in `Guild B` never affect `Guild A`.
|
||||
|
||||
## 2. Command Orchestration
|
||||
|
||||
Voice features require a suite of commands coordinated via the Controller.
|
||||
Voice features require a suite of commands coordinated via the Controller.
|
||||
|
||||
| Command | Action | Controller Logic |
|
||||
| :------ | :----------------- | :---------------------------------- |
|
||||
| `/join` | Connect to channel | `createPlayer(guildId, channelId)` |
|
||||
| `/play` | Add track to queue | `getPlayer(guildId).enqueue(track)` |
|
||||
| `/join` | Connect to channel | `createPlayer(guildId, channelId)` |
|
||||
| `/play` | Add track to queue | `getPlayer(guildId).enqueue(track)` |
|
||||
| `/stop` | Disconnect | `destroyPlayer(guildId)` |
|
||||
|
||||
## 3. State Persistence
|
||||
|
||||
Audio sessions are ephemeral.
|
||||
|
||||
- **Transient State:** Active audio stream (Lost on disconnect).
|
||||
- **Durable State:** Queue/Playlist (Persisted in Controller until restart).
|
||||
- **Transient State:** Active audio stream (Lost on disconnect).
|
||||
- **Durable State:** Queue/Playlist (Persisted in Controller until restart).
|
||||
|
||||
> [!TIP]
|
||||
> **Resilience:** Separate the _Queue_ logic from the _Voice Connection_ logic. This allows the bot to reconnect without losing the user's playlist.
|
||||
> **Resilience:** Separate the _Queue_ logic from the _Voice Connection_ logic. This allows the bot to reconnect without losing the user's playlist.
|
||||
|
||||
## 4. Implementation Sketch
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ title: Troubleshooting
|
||||
weight: 5
|
||||
---
|
||||
|
||||
**Quetza** troubleshooting focuses on diagnosing structural and conceptual issues. Because of its convention-based nature, most errors are silent (missing files) rather than noisy (stack traces).
|
||||
**Quetza** troubleshooting focuses on diagnosing structural and conceptual issues. Because of its convention-based nature, most errors are silent (missing files) rather than noisy (stack traces).
|
||||
|
||||
## Common Issue Categories
|
||||
|
||||
|
||||
@@ -3,39 +3,39 @@ title: Diagnostics Guide
|
||||
weight: 1
|
||||
---
|
||||
|
||||
**Goal:** Identify and fix common pitfalls in Quetza development.
|
||||
**Goal:** Identify and fix common pitfalls in Quetza development.
|
||||
|
||||
## 1. Module Discovery Failures
|
||||
|
||||
**Symptom:** The bot starts, but my module is ignored.
|
||||
**Cause:** Violation of filesystem conventions.
|
||||
**Symptom:** The bot starts, but my module is ignored.
|
||||
**Cause:** Violation of filesystem conventions.
|
||||
|
||||
{{% details title="Checklist: Directory Structure" closed="false" %}}
|
||||
|
||||
- [ ] Does `src/modules/<name>/` exist?
|
||||
- [ ] Does `module.ts` export `name`?
|
||||
- [ ] Are commands in `commands/` (not `cmds/`)?
|
||||
- [ ] Did you run `pnpm build`? (Quetza runs from `dist/`).
|
||||
- [ ] Are commands in `commands/` (not `cmds/`)?
|
||||
- [ ] Did you run `pnpm build`? (Quetza runs from `dist/`).
|
||||
{{% /details %}}
|
||||
|
||||
## 2. Command Registration Issues
|
||||
|
||||
**Symptom:** `/command` does not appear in Discord.
|
||||
**Symptom:** `/command` does not appear in Discord.
|
||||
|
||||
### Global vs. Test Guild Registration
|
||||
|
||||
- **Global:** Commands pushed to the application globally can take up to 1 hour to propagate and are subject to stricter rate limits.
|
||||
- **Test Guild:** Quetza automatically pushes commands to a specific guild in development mode for instant updates.
|
||||
- **Requirement:** Ensure `NODE_ENV=development` and `DEV_GUILD_ID` is correctly set in your `.env`.
|
||||
- **Global:** Commands pushed to the application globally can take up to 1 hour to propagate and are subject to stricter rate limits.
|
||||
- **Test Guild:** Quetza automatically pushes commands to a specific guild in development mode for instant updates.
|
||||
- **Requirement:** Ensure `NODE_ENV=development` and `DEV_GUILD_ID` is correctly set in your `.env`.
|
||||
|
||||
### Rate Limits
|
||||
|
||||
Frequent updates to global commands may trigger Discord's rate limits. Use a test guild for iterative development.
|
||||
Frequent updates to global commands may trigger Discord's rate limits. Use a test guild for iterative development.
|
||||
|
||||
## 3. Interaction Failures
|
||||
|
||||
**Symptom:** "The application did not respond".
|
||||
**Cause:** Command took > 3 seconds without deferral.
|
||||
**Symptom:** "The application did not respond".
|
||||
**Cause:** Command took > 3 seconds without deferral.
|
||||
|
||||
**Solution:**
|
||||
|
||||
@@ -53,21 +53,21 @@ await interaction.editReply('Done');
|
||||
## 4. State Management Leaks
|
||||
|
||||
**Symptom:** Bot memory usage grows; cross-server weirdness.
|
||||
**Cause:** Storing state globally instead of per-guild.
|
||||
**Cause:** Storing state globally instead of per-guild.
|
||||
|
||||
**Diagnostic:**
|
||||
Check your Controller.
|
||||
|
||||
- ❌ `private player: AudioPlayer;` (Shared by everyone!)
|
||||
- ✅ `private players = new Map<string, AudioPlayer>();` (Scoped by ID).
|
||||
- ❌ `private player: AudioPlayer;` (Shared by everyone!)
|
||||
- ✅ `private players = new Map<string, AudioPlayer>();` (Scoped by ID).
|
||||
|
||||
## 5. Event Handling
|
||||
|
||||
**Symptom:** Events fire multiple times or not at all.
|
||||
**Symptom:** Events fire multiple times or not at all.
|
||||
|
||||
- **Multiple:** Events are additive. Did you register the same handler in two modules?
|
||||
- **None:** Did you export `name` and `execute` correctly?
|
||||
- **Multiple:** Events are additive. Did you register the same handler in two modules?
|
||||
- **None:** Did you export `name` and `execute` correctly?
|
||||
|
||||
> [!TIP]
|
||||
> **Debug Mode:**
|
||||
> Enable verbose logging to see exactly which modules and commands are loaded at startup.
|
||||
> Enable verbose logging to see exactly which modules and commands are loaded at startup.
|
||||
@@ -8,15 +8,15 @@ layout: hextra-home
|
||||
|
||||
<div class="hx:mb-6">
|
||||
{{< hextra/hero-headline >}}
|
||||
Документация решений<br class="hx:sm:block hx:hidden" />без решений как таковых
|
||||
Документация решений<br class="hx:sm:block hx:hidden" />без решений как таковых
|
||||
{{< /hextra/hero-headline >}}
|
||||
</div>
|
||||
|
||||
<div class="hx:mb-6">
|
||||
{{< hextra/hero-subtitle >}}
|
||||
У меня нет ни времени, ни желания вручную выписывать каждую строчку этого недоромана. Однако фиксировать контекст и принятые решения нужно, иначе число сущностей, понимающих происходящее, упадёт до нуля. Считайте это структурированной нейробазой для агентов и редких живых гостей.
|
||||
У меня нет ни времени, ни желания вручную выписывать каждую строчку этого недоромана. Однако фиксировать контекст и принятые решения нужно, иначе число сущностей, понимающих происходящее, упадёт до нуля. Считайте это структурированной нейробазой для агентов и редких живых гостей.
|
||||
<br /><br />
|
||||
А если вдруг захотите что-то исправить (зачем?) — любой вклад здесь пойдёт на пользу.
|
||||
А если вдруг захотите что-то исправить (зачем?) — любой вклад здесь пойдёт на пользу.
|
||||
{{< /hextra/hero-subtitle >}}
|
||||
</div>
|
||||
|
||||
@@ -46,7 +46,7 @@ layout: hextra-home
|
||||
<div class="hx:overflow-hidden hx:border hx:border-gray-200 hx:dark:border-neutral-800 hx:shadow-lg hx:bg-gray-50 hx:dark:bg-neutral-900/50" style="border-radius: 1.5rem; height: 100%; display: flex; flex-direction: column;">
|
||||
<img src="https://static.ermnvldmr.com/docs/images/home/dead-internet-debugging.webp" alt="Dead Internet Debugging" class="hx:w-full" style="flex: 1 1 auto; width: 100%; height: 100%; min-height: 340px; object-fit: cover; object-position: top; border-radius: 1.5rem 1.5rem 0 0;" />
|
||||
<div class="hx:p-3 hx:text-xs hx:text-gray-500 hx:dark:text-gray-400 hx:text-center hx:border-t hx:border-gray-200 hx:dark:border-neutral-800" style="flex-shrink: 0;">
|
||||
Изображение улучшено с помощью Gemini 4 Argon · <a href="https://www.reddit.com/r/ProgrammerHumor/s/UVVd1CnFxH" target="_blank" rel="noreferrer" class="hx:underline hx:hover:text-primary-600">Оригинал на Reddit</a>
|
||||
Изображение улучшено с помощью Gemini 4 Argon · <a href="https://www.reddit.com/r/ProgrammerHumor/s/UVVd1CnFxH" target="_blank" rel="noreferrer" class="hx:underline hx:hover:text-primary-600">Оригинал на Reddit</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -16,7 +16,7 @@ cascade:
|
||||
- **Единая точка входа в локальном чекауте** — `apex` всегда выполняется в том чекауте, из которого запущен; аргумент `<node>` передавать не нужно.
|
||||
- **Явная идентичность из `node.env`** — [идентичность узла](/apex/glossary#node-identity) задаётся в `node.env` (`APEX_NODE_FQDN`, `APEX_NODE_HOST`, `APEX_SUBNET`); фреймворк не привязан к конкретному домену, а короткое имя `name` узла — просто первая метка хоста.
|
||||
- **Репозитории узлов из четырёх опор** — каждый репозиторий узла устроен одинаково: зафиксированный субмодуль `commons/`, локальные [проприетарные модули узла](/apex/glossary#proprietaries), его [композиции](/apex/glossary#compositions) и `node.env`.
|
||||
- **Проприетарный оверлей** — локальные проприетарные модули узла [накладываются](/apex/glossary#overlay) поверх общих действий; commons содержит только то, что объединяет все узлы.
|
||||
- **Проприетарный оверлей** — локальные проприетарные модули узла [накладываются](/apex/glossary#overlay) поверх общих действий; commons содержит только то, что объединяет все узлы.
|
||||
- **Многоуровневое хранилище с изоляцией привилегий** — стандартизированные [уровни](/apex/glossary#tier) хранилища с изоляцией прав на уровне каждого проекта.
|
||||
- **Общая базовая композиция** — [базовая композиция](/apex/glossary#core-composition) (`compositions/apex/docker-compose.yml`) параметризуется через `${env}` и включается в композиции каждого узла.
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ description: "Архитектура, идентичность, действия
|
||||
weight: 10
|
||||
---
|
||||
|
||||
Теоретические основы фреймворка операций флота apex: как [узел](/apex/glossary#node) объявляет свою [идентичность](/apex/concepts/node-identity), как выполняются [действия](/apex/glossary#action) и как узел организует свои сервисы, хранилище и адресацию.
|
||||
Теоретические основы фреймворка операций флота apex: как [узел](/apex/glossary#node) объявляет свою [идентичность](/apex/concepts/node-identity), как выполняются [действия](/apex/glossary#action) и как узел организует свои сервисы, хранилище и адресацию.
|
||||
|
||||
{{% cards %}}
|
||||
{{< card link="architecture" title="Архитектура" icon="share" subtitle="Слои движка, commons и configs: как они устроены и сочетаются друг с другом." >}}
|
||||
@@ -22,4 +22,4 @@ weight: 10
|
||||
**См. также:**
|
||||
|
||||
- [Руководства: Эксплуатация флота apex](/apex/guides)
|
||||
- [Справочник: Команды и интерфейсы](/apex/reference)
|
||||
- [Справочник: Команды и интерфейсы](/apex/reference)
|
||||
@@ -1,18 +1,18 @@
|
||||
---
|
||||
title: "Действия и оверлей"
|
||||
title: "Действия и оверлей"
|
||||
description: "Контракт действия и затенение действий commons локальными проприетариями узла."
|
||||
weight: 13
|
||||
---
|
||||
|
||||
Каждая единица автоматизации в apex — это [действие](/apex/glossary#action): один Python-модуль, который [движок](/apex/glossary#engine) диспетчеризует по имени. [Оверлей](/apex/glossary#overlay) позволяет [проприетариям](/apex/glossary#proprietaries) узла добавлять, переопределять или отключать действия [«commons»](/apex/glossary#commons) без создания форков. На этой странице описаны контракт действия, декларативные дескрипторы аргументов и правила разрешения.
|
||||
Каждая единица автоматизации в apex — это [действие](/apex/glossary#action): один Python-модуль, который [движок](/apex/glossary#engine) диспетчеризует по имени. [Оверлей](/apex/glossary#overlay) позволяет [проприетариям](/apex/glossary#proprietaries) узла добавлять, переопределять или отключать действия [«commons»](/apex/glossary#commons) без создания форков. На этой странице описаны контракт действия, декларативные дескрипторы аргументов и правила разрешения.
|
||||
|
||||
## Контракт действия {#contract}
|
||||
|
||||
Действие — это отдельный файл `.py` в дереве `actions/`. Путь к файлу служит именем действия: `actions/<group>/<action>.py` вызывается как `apex <group>/<action> [args...]`. Обновлять реестр не нужно: появление файла в дереве сразу публикует действие.
|
||||
Действие — это отдельный файл `.py` в дереве `actions/`. Путь к файлу служит именем действия: `actions/<group>/<action>.py` вызывается как `apex <group>/<action> [args...]`. Обновлять реестр не нужно: появление файла в дереве сразу публикует действие.
|
||||
|
||||
Модуль должен предоставлять две вещи:
|
||||
|
||||
- **`METADATA = Meta(...)`** → краткое описание и дескрипторы аргументов, управляющие как текстом справки, так и парсингом. Система дескрипторов пришла на смену heredoc-блоку `action_metadata()` bash-фреймворка «root» commons, предшественника apex.
|
||||
- **`METADATA = Meta(...)`** → краткое описание и дескрипторы аргументов, управляющие как текстом справки, так и парсингом. Система дескрипторов пришла на смену heredoc-блоку `action_metadata()` bash-фреймворка «root» commons, предшественника apex.
|
||||
- **`run(ctx, args)`** → точка входа. `ctx` — сформированный движком контекст выполнения; `args` — распарсенное пространство имён `argparse`.
|
||||
|
||||
```python {filename="actions/<group>/<action>.py"}
|
||||
@@ -31,27 +31,27 @@ def run(ctx, args):
|
||||
return 0
|
||||
```
|
||||
|
||||
Возвращаемое значение `run` становится кодом завершения процесса. Нецелочисленный результат (включая `None`) трактуется как успешное завершение (`0`). Необработанное исключение логируется с префиксом контекста, после чего процесс завершается с кодом `1`.
|
||||
Возвращаемое значение `run` становится кодом завершения процесса. Нецелочисленный результат (включая `None`) трактуется как успешное завершение (`0`). Необработанное исключение логируется с префиксом контекста, после чего процесс завершается с кодом `1`.
|
||||
|
||||
> [!NOTE]
|
||||
> Технически `METADATA` необязательна: модуль без неё диспетчеризуется с пустым `Meta` и не принимает аргументов. Однако в реальных действиях её объявлять необходимо, поскольку вывод перечисления и справка `--help` генерируются именно из неё.
|
||||
> Технически `METADATA` необязательна: модуль без неё диспетчеризуется с пустым `Meta` и не принимает аргументов. Однако в реальных действиях её объявлять необходимо, поскольку вывод перечисления и справка `--help` генерируются именно из неё.
|
||||
|
||||
## Декларативные аргументы {#arguments}
|
||||
|
||||
`Meta.args` — это список дескрипторов. Каждый отображается ровно на одну конструкцию `argparse`:
|
||||
|
||||
| Дескриптор | Отображается на | Поведение |
|
||||
| Дескриптор | Отображается на | Поведение |
|
||||
| --- | --- | --- |
|
||||
| `Arg("name")` | позиционный аргумент | Обязателен по умолчанию; `required=False` делает его опциональным позиционным (`nargs="?"`, по умолчанию `None`). |
|
||||
| `Opt("--name", default=...)` | опция со значением | Опциональный флаг, принимающий значение, с объявленным значением по умолчанию. |
|
||||
| `Flag("--name")` | булев флаг | `store_true`; наличие означает `True`. |
|
||||
| `Rest("name")` | остаток | Жадная сквозная передача всех оставшихся аргументов командной строки (`argparse.REMAINDER`). |
|
||||
|
||||
Движок строит парсер как `apex <group>/<action>` и разбирает оставшиеся аргументы перед вызовом `run`. Флаг `--help` обрабатывается на этапе парсинга. При ошибке использования (неизвестный флаг, отсутствующий позиционный аргумент) выводится справка по синтаксису (usage), а процесс завершается с кодом `64` (`EX_USAGE`) вместо стандартного для argparse кода `2`.
|
||||
Движок строит парсер как `apex <group>/<action>` и разбирает оставшиеся аргументы перед вызовом `run`. Флаг `--help` обрабатывается на этапе парсинга. При ошибке использования (неизвестный флаг, отсутствующий позиционный аргумент) выводится справка по синтаксису (usage), а процесс завершается с кодом `64` (`EX_USAGE`) вместо стандартного для argparse кода `2`.
|
||||
|
||||
## Перечисление без импорта {#listing}
|
||||
## Перечисление без импорта {#listing}
|
||||
|
||||
Команда `apex` без аргументов (или с флагом `--help`) выводит список всех доступных действий. Перечисление никогда не импортирует модули действий: движок статически сканирует каждый файл с помощью модуля `ast` и извлекает только краткое описание `METADATA` и любую константу `DISABLED`. Поэтому код действия не выполняется и не может вызвать сбой во время перечисления: файл, который не удалось распарсить, помечается как `(unparseable)`, не нарушая работу всей команды.
|
||||
Команда `apex` без аргументов (или с флагом `--help`) выводит список всех доступных действий. Перечисление никогда не импортирует модули действий: движок статически сканирует каждый файл с помощью модуля `ast` и извлекает только краткое описание `METADATA` и любую константу `DISABLED`. Поэтому код действия не выполняется и не может вызвать сбой во время перечисления: файл, который не удалось распарсить, помечается как `(unparseable)`, не нарушая работу всей команды.
|
||||
|
||||
Каждая запись помечается своим источником оверлея:
|
||||
|
||||
@@ -81,14 +81,14 @@ def run(ctx, args):
|
||||
{{< /filetree/folder >}}
|
||||
{{< /filetree/container >}}
|
||||
|
||||
Движок определяет структуру размещения по своему расположению на диске: если запуск происходит из каталога `commons/`, а в родительском каталоге присутствует `node.env`, родительский каталог считается корнем репозитория [узла](/apex/glossary#node), а `proprietaries/actions/` — деревом оверлея. Автономный checkout commons сам выступает в качестве корня и не имеет слоя оверлея. Внутри репозитория узла [идентичность узла](/apex/glossary#node-identity) разрешается из FQDN хоста перед диспетчеризацией.
|
||||
Движок определяет структуру размещения по своему расположению на диске: если запуск происходит из каталога `commons/`, а в родительском каталоге присутствует `node.env`, родительский каталог считается корнем репозитория [узла](/apex/glossary#node), а `proprietaries/actions/` — деревом оверлея. Автономный checkout commons сам выступает в качестве корня и не имеет слоя оверлея. Внутри репозитория узла [идентичность узла](/apex/glossary#node-identity) разрешается из FQDN хоста перед диспетчеризацией.
|
||||
|
||||
При разрешении имени движок сначала проверяет `proprietaries/actions/<name>.py`, а затем `commons/actions/<name>.py`; по найденному пути загружается ровно один модуль. Неизвестное имя считается ошибкой (код завершения `1`). Оверлей поддерживает четыре сценария:
|
||||
При разрешении имени движок сначала проверяет `proprietaries/actions/<name>.py`, а затем `commons/actions/<name>.py`; по найденному пути загружается ровно один модуль. Неизвестное имя считается ошибкой (код завершения `1`). Оверлей поддерживает четыре сценария:
|
||||
|
||||
- **Добавление (Add)** → файл, существующий только в проприетариях, становится локальным действием узла и отображается в списке как `(local)`.
|
||||
- **Переопределение (Override)** → файл проприетариев по тому же относительному пути полностью затеняет реализацию commons; путь commons помечается при разрешении как `shadowed`.
|
||||
- **Обёртывание (Wrap)** → переопределяющий модуль может делегировать вызов затенённой реализации через `ctx.commons.run(args)`, дополняя поведение commons локальной логикой узла до или после её вызова.
|
||||
- **Отключение (Disable)** → модуль с объявлением `DISABLED = "reason"` отклоняется при диспетчеризации: движок логирует причину и завершается с кодом `1`. При перечислении выводится `[DISABLED: reason]`. Простое значение `DISABLED = True` также отключает действие со стандартной причиной `disabled`; пустая строка действие не отключает.
|
||||
- **Добавление (Add)** → файл, существующий только в проприетариях, становится локальным действием узла и отображается в списке как `(local)`.
|
||||
- **Переопределение (Override)** → файл проприетариев по тому же относительному пути полностью затеняет реализацию commons; путь commons помечается при разрешении как `shadowed`.
|
||||
- **Обёртывание (Wrap)** → переопределяющий модуль может делегировать вызов затенённой реализации через `ctx.commons.run(args)`, дополняя поведение commons локальной логикой узла до или после её вызова.
|
||||
- **Отключение (Disable)** → модуль с объявлением `DISABLED = "reason"` отклоняется при диспетчеризации: движок логирует причину и завершается с кодом `1`. При перечислении выводится `[DISABLED: reason]`. Простое значение `DISABLED = True` также отключает действие со стандартной причиной `disabled`; пустая строка действие не отключает.
|
||||
|
||||
## Коды завершения {#exit-codes}
|
||||
|
||||
@@ -96,7 +96,7 @@ def run(ctx, args):
|
||||
|
||||
| Код | Значение | Типичная причина |
|
||||
| --- | --- | --- |
|
||||
| `0` | успех | Функция `run` вернула `0` или нецелочисленное значение. |
|
||||
| `0` | успех | Функция `run` вернула `0` или нецелочисленное значение. |
|
||||
| `1` | общая ошибка | Неизвестное действие, отключённое действие, необработанное исключение, нецелочисленный `SystemExit`. |
|
||||
| `64` | ошибка использования | Аргументы отклонены парсером (`EX_USAGE`). |
|
||||
| `65` | ошибка данных | Некорректные входные данные (`EX_DATAERR`), выбрасывается библиотеками движка. |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: "Схема адресации флота"
|
||||
description: "Подсети /24 для каждого узла и статические якорные IP внутри пространства анклава (по умолчанию 198.18.0.0/16)."
|
||||
description: "Подсети /24 для каждого узла и статические якорные IP внутри пространства анклава (по умолчанию 198.18.0.0/16)."
|
||||
weight: 17
|
||||
---
|
||||
|
||||
@@ -8,11 +8,11 @@ weight: 17
|
||||
|
||||
## Адресное пространство {#address-space}
|
||||
|
||||
Пространство [анклава](/apex/glossary#enclave) по умолчанию равно `198.18.0.0/16` — диапазону для бенчмаркинга, выделенному [RFC 2544: Benchmarking Methodology for Network Interconnect Devices](https://datatracker.ietf.org/doc/html/rfc2544). Этот диапазон никогда не маршрутизируется в публичном интернете и не конфликтует с распространёнными частными диапазонами (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`), которые уже используют облачные провайдеры и домашние сети. Оператор может переопределить его через переменную `APEX_ENCLAVE_SUBNET`.
|
||||
Пространство [анклава](/apex/glossary#enclave) по умолчанию равно `198.18.0.0/16` — диапазону для бенчмаркинга, выделенному [RFC 2544: Benchmarking Methodology for Network Interconnect Devices](https://datatracker.ietf.org/doc/html/rfc2544). Этот диапазон никогда не маршрутизируется в публичном интернете и не конфликтует с распространёнными частными диапазонами (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`), которые уже используют облачные провайдеры и домашние сети. Оператор может переопределить его через переменную `APEX_ENCLAVE_SUBNET`.
|
||||
|
||||
## Подсети узлов {#node-subnets}
|
||||
|
||||
Каждый [узел](/apex/glossary#node) объявляет ровно один диапазон `/24` как `APEX_SUBNET` в своём файле `node.env`. Эта подсеть служит пулом IPAM сети `direct`. Оператор назначает её вручную: по соглашению она располагается внутри пространства анклава (`198.18.0.0/16` по умолчанию или в подсети из `APEX_ENCLAVE_SUBNET`), однако фреймворк не накладывает строгих ограничений на выбор адресов.
|
||||
Каждый [узел](/apex/glossary#node) объявляет ровно один диапазон `/24` как `APEX_SUBNET` в своём файле `node.env`. Эта подсеть служит пулом IPAM сети `direct`. Оператор назначает её вручную: по соглашению она располагается внутри пространства анклава (`198.18.0.0/16` по умолчанию или в подсети из `APEX_ENCLAVE_SUBNET`), однако фреймворк не накладывает строгих ограничений на выбор адресов.
|
||||
|
||||
> [!NOTE]
|
||||
> В качестве соглашения оператор может группировать подсети `/24` связанных узлов в непрерывные блоки, чтобы сохранять наглядность адресного плана. Это вопрос внутренней организации, а не жёсткое требование фреймворка.
|
||||
@@ -23,20 +23,20 @@ weight: 17
|
||||
|
||||
| Диапазон | Назначение |
|
||||
|---|---|
|
||||
| `.10`–`.19` | Зарезервировано для якорей [базовой композиции](/apex/glossary#core-composition); сервис `traefik` всегда занимает адрес `.10` (через `APEX_TRAEFIK_DIRECT_IP`) |
|
||||
| `.10`–`.19` | Зарезервировано для якорей [базовой композиции](/apex/glossary#core-composition); сервис `traefik` всегда занимает адрес `.10` (через `APEX_TRAEFIK_DIRECT_IP`) |
|
||||
| `.20` и выше | Якоря сервисов, распределяемые по проектам (`<project>`) |
|
||||
|
||||
Каждый якорь сервиса представляет собой статический `ipv4_address` в сети direct — фиксированный [якорный IP](/apex/glossary#anchor-ip), а не динамическую аренду (lease). Назначенные адреса фиксируются в `compositions/ADDRESSING.md`, который служит человекочитаемым реестром подсети узла.
|
||||
|
||||
## Автоматическая проверка {#enforcement}
|
||||
|
||||
Линтер compose проверяет якорные IP-адреса во всех [композициях](/apex/glossary#compositions) узла. Для каждого значения `ipv4_address`, найденного в файлах `compositions/*/docker-compose.yml`, проверяются следующие условия:
|
||||
Линтер compose проверяет якорные IP-адреса во всех [композициях](/apex/glossary#compositions) узла. Для каждого значения `ipv4_address`, найденного в файлах `compositions/*/docker-compose.yml`, проверяются следующие условия:
|
||||
|
||||
- **Корректный формат** — о некорректном литерале линтер сообщает как об ошибке, не падая аварийно.
|
||||
- **Уникальность** — один и тот же якорный IP не может использоваться в двух разных проектах; при обнаружении дубликатов выводятся имена обоих конфликтующих проектов.
|
||||
- **Корректный формат** — о некорректном литерале линтер сообщает как об ошибке, не падая аварийно.
|
||||
- **Уникальность** — один и тот же якорный IP не может использоваться в двух разных проектах; при обнаружении дубликатов выводятся имена обоих конфликтующих проектов.
|
||||
- **Принадлежность подсети узла** — каждый якорь должен входить в объявленную подсеть `/24` узла.
|
||||
|
||||
При любом нарушении линтер завершается с ошибкой и кодом `1`. При успешной проверке в лог выводится сообщение `Anchor IP validation passed.` Логика проверки реализована в [действии](/apex/glossary#action) `utils/lint-docker-compose`; см. [GitHub: apex.ermnvldmr.com](https://github.com/deytenit/apex.ermnvldmr.com).
|
||||
При любом нарушении линтер завершается с ошибкой и кодом `1`. При успешной проверке в лог выводится сообщение `Anchor IP validation passed.` Логика проверки реализована в [действии](/apex/glossary#action) `utils/lint-docker-compose`; см. [GitHub: apex.ermnvldmr.com](https://github.com/deytenit/apex.ermnvldmr.com).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ description: Четыре опоры репозиториев узла, субм
|
||||
weight: 11
|
||||
---
|
||||
|
||||
Фреймворк apex делит автоматизацию флота между одним общим репозиторием ([«commons»](/apex/glossary#commons)) и отдельными репозиториями для каждого [узла](/apex/glossary#node). В commons сосредоточено всё общее для флота: [движок](/apex/glossary#engine), стандартные [действия](/apex/glossary#action) и [ядровая композиция](/apex/glossary#core-composition). Репозиторий узла содержит специфичные для хоста данные: его идентичность, хостовые [конфигурации](/apex/glossary#configs), сервисные [композиции](/apex/glossary#compositions) и локальные [проприетарии](/apex/glossary#proprietaries).
|
||||
Фреймворк apex делит автоматизацию флота между одним общим репозиторием ([«commons»](/apex/glossary#commons)) и отдельными репозиториями для каждого [узла](/apex/glossary#node). В commons сосредоточено всё общее для флота: [движок](/apex/glossary#engine), стандартные [действия](/apex/glossary#action) и [ядровая композиция](/apex/glossary#core-composition). Репозиторий узла содержит специфичные для хоста данные: его идентичность, хостовые [конфигурации](/apex/glossary#configs), сервисные [композиции](/apex/glossary#compositions) и локальные [проприетарии](/apex/glossary#proprietaries).
|
||||
|
||||
## Четыре опоры {#pillars}
|
||||
|
||||
@@ -47,32 +47,32 @@ flowchart TD
|
||||
H --> I["run(ctx, args)"]
|
||||
```
|
||||
|
||||
Движок определяет режим работы по своему расположению на диске. Если он запущен из каталога `commons/`, а в родительском каталоге присутствует `node.env`, то родительский каталог принимается за корень репозитория узла, а `proprietaries/actions/` становится деревом оверлея. При любом другом расположении commons считается автономным чекаутом и выступает собственным корнем.
|
||||
Движок определяет режим работы по своему расположению на диске. Если он запущен из каталога `commons/`, а в родительском каталоге присутствует `node.env`, то родительский каталог принимается за корень репозитория узла, а `proprietaries/actions/` становится деревом оверлея. При любом другом расположении commons считается автономным чекаутом и выступает собственным корнем.
|
||||
|
||||
Команда перечисления (`apex --help`) никогда не импортирует модули действий: краткие описания и маркеры отключения извлекаются статически с помощью модуля `ast`. При диспетчеризации загружается ровно один модуль по вычисленному пути. Подробнее — в разделе [Концепция: Действия и оверлей](/apex/concepts/actions-and-overlay).
|
||||
Команда перечисления (`apex --help`) никогда не импортирует модули действий: краткие описания и маркеры отключения извлекаются статически с помощью модуля `ast`. При диспетчеризации загружается ровно один модуль по вычисленному пути. Подробнее — в разделе [Концепция: Действия и оверлей](/apex/concepts/actions-and-overlay).
|
||||
|
||||
## Модель «capture-up» {#capture-up}
|
||||
|
||||
Репозитории узлов служат реестрами состояния, а не просто источниками конфигурации: хост сам фиксирует то, что выполняет на практике.
|
||||
Репозитории узлов служат реестрами состояния, а не просто источниками конфигурации: хост сам фиксирует то, что выполняет на практике.
|
||||
|
||||
1. Регулярная задача `sync/repository` на хосте коммитит рабочее дерево в ветку `sync/<node>` (включая указатель субмодуля commons), делает rebase поверх `origin/main` и выполняет push с флагом `--force-with-lease`.
|
||||
2. Запланированное CI-задание в репозитории узла вливает ветку `sync/<node>` обратно в `main` от имени бота.
|
||||
|
||||
Благодаря циклу [«capture-up»](/apex/glossary#capture-up) любой дрейф конфигурации на хосте превращается в коммит: изменение наглядно отображается в diff и легко откатывается, исключая незаметные расхождения. Если субмодуль commons имеет незакоммиченные изменения («грязный») или каталог `compositions/` отсутствует, синхронизация прерывается без коммита, поэтому повреждённое состояние чекаута никогда не попадёт в историю.
|
||||
Благодаря циклу [«capture-up»](/apex/glossary#capture-up) любой дрейф конфигурации на хосте превращается в коммит: изменение наглядно отображается в diff и легко откатывается, исключая незаметные расхождения. Если субмодуль commons имеет незакоммиченные изменения («грязный») или каталог `compositions/` отсутствует, синхронизация прерывается без коммита, поэтому повреждённое состояние чекаута никогда не попадёт в историю.
|
||||
|
||||
> [!NOTE]
|
||||
> CI-задание слияния клонирует репозиторий *без* субмодулей: субмодуль commons подключён по SSH-URL, недоступному стандартному токену CI, а самому слиянию требуется лишь обновить указатель gitlink.
|
||||
|
||||
## Проектные ограничения {#constraints}
|
||||
|
||||
- **Только стандартная библиотека (stdlib).** Движок работает на штатном Python 3 из Debian — без `pip` и сторонних зависимостей. Каталог `vendor/` зарезервирован для зафиксированного чистого Python-кода, но по умолчанию поставляется пустым.
|
||||
- **Ручные compose-файлы.** Никаких промежуточных манифестов или кодогенерации: файлы compose служат единственным источником истины.
|
||||
- **Автоматическая проверка вместо соглашений.** Всё, что можно проверить или применить автоматически, выполняется действием (проверка якорных IP, откат crontab, самовосстановление блоков-маркеров), а не остаётся на совести документации.
|
||||
- **Только стандартная библиотека (stdlib).** Движок работает на штатном Python 3 из Debian — без `pip` и сторонних зависимостей. Каталог `vendor/` зарезервирован для зафиксированного чистого Python-кода, но по умолчанию поставляется пустым.
|
||||
- **Ручные compose-файлы.** Никаких промежуточных манифестов или кодогенерации: файлы compose служат единственным источником истины.
|
||||
- **Автоматическая проверка вместо соглашений.** Всё, что можно проверить или применить автоматически, выполняется действием (проверка якорных IP, откат crontab, самовосстановление блоков-маркеров), а не остаётся на совести документации.
|
||||
|
||||
---
|
||||
|
||||
**См. также:**
|
||||
|
||||
- [Концепция: Действия и оверлей](/apex/concepts/actions-and-overlay)
|
||||
- [Концепция: Действия и оверлей](/apex/concepts/actions-and-overlay)
|
||||
- [Концепция: Разрешение идентичности узла](/apex/concepts/node-identity)
|
||||
- [Руководство: Сборка репозитория узла](/apex/guides/node-repository)
|
||||
@@ -4,7 +4,7 @@ description: Ядровая композиция на основе include, п
|
||||
weight: 15
|
||||
---
|
||||
|
||||
Среда выполнения узла описывается в виде [композиций](/apex/glossary#compositions): по одному docker-compose-проекту на сервис в каталоге `compositions/<project>/`, плюс [ядровая композиция](/apex/glossary#core-composition) — граничные и инфраструктурные сервисы, необходимые каждому узлу. Ядро находится в [«commons»](/apex/glossary#commons) и подключается каждым узлом через тонкую обёртку. Благодаря этому его манифест пишется один раз и версионируется вместе с фреймворком.
|
||||
Среда выполнения узла описывается в виде [композиций](/apex/glossary#compositions): по одному docker-compose-проекту на сервис в каталоге `compositions/<project>/`, плюс [ядровая композиция](/apex/glossary#core-composition) — граничные и инфраструктурные сервисы, необходимые каждому узлу. Ядро находится в [«commons»](/apex/glossary#commons) и подключается каждым узлом через тонкую обёртку. Благодаря этому его манифест пишется один раз и версионируется вместе с фреймворком.
|
||||
|
||||
## Обёртка include {#include}
|
||||
|
||||
@@ -16,9 +16,9 @@ include:
|
||||
- ../../commons/compositions/apex/docker-compose.yml
|
||||
```
|
||||
|
||||
Общий файл полностью параметризован переменными `${APEX_*}` и не содержит относительных путей хоста: все точки монтирования разрешаются через переменные, экспортируемые [движком](/apex/glossary#engine) при вызове, поэтому один и тот же файл корректно работает на каждом узле.
|
||||
Общий файл полностью параметризован переменными `${APEX_*}` и не содержит относительных путей хоста: все точки монтирования разрешаются через переменные, экспортируемые [движком](/apex/glossary#engine) при вызове, поэтому один и тот же файл корректно работает на каждом узле.
|
||||
|
||||
Если узлу требуется изменить ядровой сервис (например, опубликовать дополнительный порт хоста), переопределение накладывается через список путей в директиве `include:` без редактирования общего файла:
|
||||
Если узлу требуется изменить ядровой сервис (например, опубликовать дополнительный порт хоста), переопределение накладывается через список путей в директиве `include:` без редактирования общего файла:
|
||||
|
||||
```yaml {filename="compositions/apex/docker-compose.yml"}
|
||||
name: apex
|
||||
@@ -29,14 +29,14 @@ include:
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> Директива `include:` требует Docker Compose версии v2.20 или новее.
|
||||
> Директива `include:` требует Docker Compose версии v2.20 или новее.
|
||||
|
||||
## Ядровые сервисы и профили {#core-services}
|
||||
## Ядровые сервисы и профили {#core-services}
|
||||
|
||||
Ядровая композиция определяет общий набор сервисов флота. Всегда включённые сервисы:
|
||||
|
||||
- **traefik** — граничный обратный прокси и якорь сети `direct` (всегда занимает `.10` в подсети узла). Он терминирует TLS через DNS-челлендж, обнаруживает бэкенды по docker-меткам и открывает панель управления на FQDN узла, ограничивая доступ списком разрешённых IP-адресов [анклава](/apex/glossary#enclave).
|
||||
- **socket-proxy** — отфильтрованный прокси для docker-сокета: traefik получает состояние контейнеров через него и никогда не обращается к сокету напрямую.
|
||||
- **traefik** — граничный обратный прокси и якорь сети `direct` (всегда занимает `.10` в подсети узла). Он терминирует TLS через DNS-челлендж, обнаруживает бэкенды по docker-меткам и открывает панель управления на FQDN узла, ограничивая доступ списком разрешённых IP-адресов [анклава](/apex/glossary#enclave).
|
||||
- **socket-proxy** — отфильтрованный прокси для docker-сокета: traefik получает состояние контейнеров через него и никогда не обращается к сокету напрямую.
|
||||
|
||||
Опциональные сервисы управляются compose-профилями, которые узел активирует в переменной `COMPOSE_PROFILES` (задаётся в `apex.env`):
|
||||
|
||||
@@ -49,12 +49,12 @@ include:
|
||||
|
||||
## Сети {#networks}
|
||||
|
||||
Ядровая композиция создаёт три сети узла, а сервисные композиции подключаются к ним через директиву `external: true`:
|
||||
Ядровая композиция создаёт три сети узла, а сервисные композиции подключаются к ним через директиву `external: true`:
|
||||
|
||||
| Сеть | Тип | Назначение |
|
||||
|---|---|---|
|
||||
| `direct` | bridge, подсеть = `APEX_SUBNET` | Сеть со статическими [якорными IP](/apex/glossary#anchor-ip); якорь каждого проекта удерживает здесь фиксированный адрес (см. [Концепция: Схема адресации флота](/apex/concepts/addressing)). |
|
||||
| `enclave` | bridge, `internal: true` | Бэкенд-сеть обратного прокси: через неё traefik направляет трафик к целевым сервисам. |
|
||||
| `enclave` | bridge, `internal: true` | Бэкенд-сеть обратного прокси: через неё traefik направляет трафик к целевым сервисам. |
|
||||
| `socket` | bridge, `internal: true` | Изолирует трафик между traefik и socket-proxy. |
|
||||
|
||||
Внутри проекта зависимые контейнеры обычно используют сетевое пространство имён якоря (`network_mode: service:<anchor>`) вместо прямого подключения к сетям.
|
||||
@@ -65,7 +65,7 @@ include:
|
||||
|
||||
1. **`.env`** — незакоммиченные секреты, символьная ссылка на хранилище уровня 1 (учётные данные `APEX_RESTIC_*`, токены `APEX_TRAEFIK_*`, блок UID/GID).
|
||||
2. **`apex.env`** — закоммиченные несекретные параметры (`APEX_TRAEFIK_DIRECT_IP`, `APEX_RESTIC_REPOSITORY`, `COMPOSE_PROFILES`, опциональный `APEX_RESTIC_COMPRESSION`).
|
||||
3. **Экспорт движка** — переменные идентичности `APEX_*` и пути [уровней](/apex/glossary#tier) из `ctx.vars()`. Они внедряются в окружение процесса действиями оркестрации и потому имеют наивысший приоритет над обоими файлами.
|
||||
3. **Экспорт движка** — переменные идентичности `APEX_*` и пути [уровней](/apex/glossary#tier) из `ctx.vars()`. Они внедряются в окружение процесса действиями оркестрации и потому имеют наивысший приоритет над обоими файлами.
|
||||
|
||||
Критические переменные снабжены суффиксом `?error` в общем файле: если секрет не задан, загрузка конфигурации композиции завершается явной ошибкой, предотвращая запуск некорректно настроенного контейнера.
|
||||
|
||||
@@ -74,9 +74,9 @@ include:
|
||||
Команда `apex compose <action>` управляет всеми композициями на узле:
|
||||
|
||||
- **Порядок:** сначала ядровой проект (он владеет сетями), затем остальные сервисные композиции по алфавиту; команда `down` выполняет действия в обратном порядке.
|
||||
- **Env-файлы:** для каждого проекта существующие файлы `.env` и `apex.env` передаются через флаг `--env-file`.
|
||||
- **Env-файлы:** для каждого проекта существующие файлы `.env` и `apex.env` передаются через флаг `--env-file`.
|
||||
- **Команда `up`:** по умолчанию подразумевает флаг `-d`; любые дополнительные аргументы передаются напрямую в `docker compose`.
|
||||
- **Флаг `--dry-run`:** выводит каждую команду с её рабочим каталогом без выполнения; поддерживается в любом месте командной строки.
|
||||
- **Флаг `--dry-run`:** выводит каждую команду с её рабочим каталогом без выполнения; поддерживается в любом месте командной строки.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ description: ctx — единый интерфейс взаимодействи
|
||||
weight: 14
|
||||
---
|
||||
|
||||
Каждое [действие](/apex/glossary#action) получает объект `ctx` первым аргументом. Это единственный штатный способ взаимодействия с хостом: логирование, запуск процессов, рендеринг шаблонов, правка файлов, настройка межсетевого экрана и сервисов, а также отправка уведомлений — всё выполняется через него. Благодаря этому действия остаются компактными и декларативными, а общие для всего флота правила (формат логов, регламент резервного копирования, структура уведомлений) сосредоточены в одном месте — [движке](/apex/glossary#engine).
|
||||
Каждое [действие](/apex/glossary#action) получает объект `ctx` первым аргументом. Это единственный штатный способ взаимодействия с хостом: логирование, запуск процессов, рендеринг шаблонов, правка файлов, настройка межсетевого экрана и сервисов, а также отправка уведомлений — всё выполняется через него. Благодаря этому действия остаются компактными и декларативными, а общие для всего флота правила (формат логов, регламент резервного копирования, структура уведомлений) сосредоточены в одном месте — [движке](/apex/glossary#engine).
|
||||
|
||||
## Члены {#members}
|
||||
|
||||
@@ -30,20 +30,20 @@ weight: 14
|
||||
|
||||
- Известные переменные `$VAR` и `${VAR}` подставляются.
|
||||
- **Неизвестные переменные сохраняются буквально:** опечатка в имени переменной останется в готовом файле на виду, а не превратится незаметно в пустую строку.
|
||||
- Подстановки команд shell (`$(cmd)`) не затрагиваются, что сохраняет целостность строк crontab со встроенными командами вида `$(cat ...)`.
|
||||
- Подстановки команд shell (`$(cmd)`) не затрагиваются, что сохраняет целостность строк crontab со встроенными командами вида `$(cat ...)`.
|
||||
|
||||
Подстановка выполняется по словарю `ctx.vars()`: это полное окружение процесса плюс переменные `APEX_*`, предоставляемые движком (перечислены ниже). В шаблонах нет циклов и условий — вся логика ветвления находится в действиях.
|
||||
|
||||
## Экспорт переменных {#vars}
|
||||
|
||||
Функция `ctx.vars()` возвращает словарь окружения, используемый при каждом рендеринге шаблонов. Подмножество переменных `APEX_*` также передаётся в `docker compose` действиями оркестрации. На узле оно включает:
|
||||
Функция `ctx.vars()` возвращает словарь окружения, используемый при каждом рендеринге шаблонов. Подмножество переменных `APEX_*` также передаётся в `docker compose` действиями оркестрации. На узле оно включает:
|
||||
|
||||
| Переменная | Значение |
|
||||
|---|---|
|
||||
| `APEX_COMMONS` | Абсолютный путь к каталогу commons (лаунчер расположен по пути `$APEX_COMMONS/apex`). |
|
||||
| `APEX_REPO_ROOT` | Абсолютный путь к корню репозитория узла. |
|
||||
| `APEX_NODE_HOST`, `APEX_NODE_FQDN`, `APEX_SUBNET` | Разрешённые поля идентичности узла. |
|
||||
| `APEX_TIER1`…`APEX_TIER3` | Каталоги данных базового проекта для каждого [уровня](/apex/glossary#tier). |
|
||||
| `APEX_TIER1`…`APEX_TIER3` | Каталоги данных базового проекта для каждого [уровня](/apex/glossary#tier). |
|
||||
| `APEX_TIER1_SHARED`…`APEX_TIER3_SHARED` | Общие области внутри каждого уровня. |
|
||||
| `APEX_TIER_ROOT1`…`APEX_TIER_ROOT3` | Корневые каталоги хранилищ уровней (ссылки уровня узла). |
|
||||
|
||||
@@ -53,14 +53,14 @@ weight: 14
|
||||
|
||||
Объект `ctx.host` реализует строгие правила записи:
|
||||
|
||||
- **Атомарная запись:** метод `write_file` сначала формирует временный файл (права `0644`, аналогичные перенаправлению вывода из-под root) и перемещает его на место через `mv` или `sudo cp`. При перезаписи существующего файла его исходные права сохраняются.
|
||||
- **Резервные копии с временной меткой:** по запросу перед записью создаётся копия старого файла рядом с целевым: `<name>.<epoch>.bak`.
|
||||
- **Внедрение блоков-маркеров:** изменения внутри системных файлов (например, `/etc/ufw/before.rules`) обрамляются комментариями-маркерами начала и конца. Перед вставкой нового блока после якорной строки движок удаляет все предыдущие управляемые блоки (включая оставшиеся от старого инструментария), предотвращая накопление дубликатов при повторных запусках.
|
||||
- **Откат crontab:** метод `install_crontab` делает снимок текущего crontab, отклоняет пустой результат рендеринга (код выхода `65`), показывает diff изменений и автоматически восстанавливает снимок при ошибке установки.
|
||||
- **Атомарная запись:** метод `write_file` сначала формирует временный файл (права `0644`, аналогичные перенаправлению вывода из-под root) и перемещает его на место через `mv` или `sudo cp`. При перезаписи существующего файла его исходные права сохраняются.
|
||||
- **Резервные копии с временной меткой:** по запросу перед записью создаётся копия старого файла рядом с целевым: `<name>.<epoch>.bak`.
|
||||
- **Внедрение блоков-маркеров:** изменения внутри системных файлов (например, `/etc/ufw/before.rules`) обрамляются комментариями-маркерами начала и конца. Перед вставкой нового блока после якорной строки движок удаляет все предыдущие управляемые блоки (включая оставшиеся от старого инструментария), предотвращая накопление дубликатов при повторных запусках.
|
||||
- **Откат crontab:** метод `install_crontab` делает снимок текущего crontab, отклоняет пустой результат рендеринга (код выхода `65`), показывает diff изменений и автоматически восстанавливает снимок при ошибке установки.
|
||||
|
||||
## Уведомления {#notifications}
|
||||
|
||||
Модуль `ctx.notify` отправляет сообщения в Telegram средствами стандартной библиотеки (`urllib`). Полезная нагрузка содержит имя хоста (`APEX_NODE_HOST`), статус (`firing`/`resolved`), уровень важности и текст сообщения, обрезанный до 200 символов. Методы возвращают `True` только при успешной доставке; автоматические действия трактуют сбой отправки итогового статуса как критическую ошибку (код завершения `1`), поэтому проблемы со связью сразу становятся заметны.
|
||||
Модуль `ctx.notify` отправляет сообщения в Telegram средствами стандартной библиотеки (`urllib`). Полезная нагрузка содержит имя хоста (`APEX_NODE_HOST`), статус (`firing`/`resolved`), уровень важности и текст сообщения, обрезанный до 200 символов. Методы возвращают `True` только при успешной доставке; автоматические действия трактуют сбой отправки итогового статуса как критическую ошибку (код завершения `1`), поэтому проблемы со связью сразу становятся заметны.
|
||||
|
||||
---
|
||||
|
||||
@@ -68,4 +68,4 @@ weight: 14
|
||||
|
||||
- [Справочник: API движка](/apex/reference/engine-api)
|
||||
- [Справочник: Переменные окружения](/apex/reference/environment)
|
||||
- [Концепция: Действия и оверлей](/apex/concepts/actions-and-overlay)
|
||||
- [Концепция: Действия и оверлей](/apex/concepts/actions-and-overlay)
|
||||
@@ -1,22 +1,22 @@
|
||||
---
|
||||
title: "Разрешение идентичности узла"
|
||||
description: "Как узел определяет собственную идентичность: параметры node.env и резервный FQDN."
|
||||
description: "Как узел определяет собственную идентичность: параметры node.env и резервный FQDN."
|
||||
weight: 12
|
||||
---
|
||||
|
||||
Каждая операция apex выполняется в контексте строго одного [узла](/apex/glossary#node). [Движок](/apex/glossary#engine) извлекает [идентичность узла](/apex/glossary#node-identity) — имя, подсеть, FQDN и публичный адрес — из файла `node.env` в корне репозитория. Системное имя хоста используется исключительно как резервный вариант для FQDN. Фреймворк не делает предположений о домене или наличии кластера: узел заявляет о себе явно. Этот механизм пришёл на смену функции `root_require_node` bash-фреймворка «root» commons, развитием которого стал apex.
|
||||
Каждая операция apex выполняется в контексте строго одного [узла](/apex/glossary#node). [Движок](/apex/glossary#engine) извлекает [идентичность узла](/apex/glossary#node-identity) — имя, подсеть, FQDN и публичный адрес — из файла `node.env` в корне репозитория. Системное имя хоста используется исключительно как резервный вариант для FQDN. Фреймворк не делает предположений о домене или наличии кластера: узел заявляет о себе явно. Этот механизм пришёл на смену функции `root_require_node` bash-фреймворка «root» commons, развитием которого стал apex.
|
||||
|
||||
## Источники идентичности {#sources}
|
||||
|
||||
### node.env
|
||||
|
||||
Файл `node.env` — это простой конфигурационный файл формата `KEY=value` (совместимый с командой `source` в shell), расположенный в корне репозитория. При разборе комментарии и пустые строки игнорируются, а одинарные или двойные кавычки вокруг значений отбрасываются.
|
||||
Файл `node.env` — это простой конфигурационный файл формата `KEY=value` (совместимый с командой `source` в shell), расположенный в корне репозитория. При разборе комментарии и пустые строки игнорируются, а одинарные или двойные кавычки вокруг значений отбрасываются.
|
||||
|
||||
Идентичность объявляется явно с помощью следующих ключей:
|
||||
|
||||
- `APEX_NODE_FQDN` — канонический FQDN узла для правила `Host()` в traefik. Если ключ не задан, движок берёт имя хоста из ОС и выводит предупреждение.
|
||||
- `APEX_NODE_HOST` — публичное имя хоста узла, используемое для флага `--host` при резервном копировании и метки `instance` в системе мониторинга и уведомлений. По умолчанию совпадает с `APEX_NODE_FQDN`.
|
||||
- `APEX_SUBNET` — подсеть `/24` узла для сети `direct`. Параметр **обязателен**: альтернативных источников для него нет (см. [Концепция: Схема адресации флота](/apex/concepts/addressing)).
|
||||
- `APEX_NODE_FQDN` — канонический FQDN узла для правила `Host()` в traefik. Если ключ не задан, движок берёт имя хоста из ОС и выводит предупреждение.
|
||||
- `APEX_NODE_HOST` — публичное имя хоста узла, используемое для флага `--host` при резервном копировании и метки `instance` в системе мониторинга и уведомлений. По умолчанию совпадает с `APEX_NODE_FQDN`.
|
||||
- `APEX_SUBNET` — подсеть `/24` узла для сети `direct`. Параметр **обязателен**: альтернативных источников для него нет (см. [Концепция: Схема адресации флота](/apex/concepts/addressing)).
|
||||
|
||||
```ini {filename="node.env"}
|
||||
APEX_NODE_FQDN=node1.example.com
|
||||
@@ -26,7 +26,7 @@ APEX_SUBNET=198.18.16.0/24
|
||||
|
||||
### Имя хоста ОС
|
||||
|
||||
Имя хоста операционной системы используется только как резервный вариант для `APEX_NODE_FQDN`. Если в `node.env` значение `APEX_NODE_FQDN` не указано, движок берёт имя хоста ОС и выводит предупреждение. Это значение никогда не парсится для вычисления короткого имени узла или других параметров.
|
||||
Имя хоста операционной системы используется только как резервный вариант для `APEX_NODE_FQDN`. Если в `node.env` значение `APEX_NODE_FQDN` не указано, движок берёт имя хоста ОС и выводит предупреждение. Это значение никогда не парсится для вычисления короткого имени узла или других параметров.
|
||||
|
||||
## Порядок разрешения {#resolution}
|
||||
|
||||
@@ -42,7 +42,7 @@ APEX_SUBNET=198.18.16.0/24
|
||||
|
||||
### Вычисление короткого имени
|
||||
|
||||
Значение `name` — это **первая часть** имени `host` (`host.split(".")[0]`), например `node1` для `node1.example.com`. Оно используется в логах, имени ветки `sync/<node>` и уведомлениях.
|
||||
Значение `name` — это **первая часть** имени `host` (`host.split(".")[0]`), например `node1` для `node1.example.com`. Оно используется в логах, имени ветки `sync/<node>` и уведомлениях.
|
||||
|
||||
### Получение подсети
|
||||
|
||||
@@ -54,7 +54,7 @@ APEX_SUBNET=198.18.16.0/24
|
||||
|
||||
## Предупреждения {#warnings}
|
||||
|
||||
При разрешении идентичности может выводиться одно штатное предупреждение: оно указывает на неполную конфигурацию, которую стоит исправить.
|
||||
При разрешении идентичности может выводиться одно штатное предупреждение: оно указывает на неполную конфигурацию, которую стоит исправить.
|
||||
|
||||
| Условие | Сообщение предупреждения |
|
||||
|---|---|
|
||||
@@ -62,7 +62,7 @@ APEX_SUBNET=198.18.16.0/24
|
||||
|
||||
## Режимы сбоев {#failures}
|
||||
|
||||
Движок никогда не пытается угадывать параметры наугад. Две ситуации считаются критическими и приводят к завершению с кодом `66` (соглашение движка о коде выхода при отсутствии обязательного ввода):
|
||||
Движок никогда не пытается угадывать параметры наугад. Две ситуации считаются критическими и приводят к завершению с кодом `66` (соглашение движка о коде выхода при отсутствии обязательного ввода):
|
||||
|
||||
- **FQDN не определён:** ключ `APEX_NODE_FQDN` отсутствует в `node.env`, *а также* не задано имя хоста в ОС. Движок выводит в stderr сообщение `identity: APEX_NODE_FQDN missing from node.env and no hostname` и завершает работу.
|
||||
- **Подсеть не задана:** ключ `APEX_SUBNET` отсутствует в `node.env` (либо сам файл отсутствует). Движок выводит в stderr сообщение `identity: APEX_SUBNET missing from node.env` и завершает работу.
|
||||
|
||||
@@ -4,50 +4,50 @@ description: Изоляция привилегий, управление меж
|
||||
weight: 18
|
||||
---
|
||||
|
||||
Фреймворк apex защищает [узел](/apex/glossary#node) на нескольких независимых уровнях: изоляция привилегий в файловой системе, управляемый межсетевой экран хоста, обнаружение вторжений, разграничение прав в контейнерах и строгая изоляция секретов. Каждый уровень настраивается отдельным [действием](/apex/glossary#action), что гарантирует воспроизводимость и идемпотентность конфигурации.
|
||||
Фреймворк apex защищает [узел](/apex/glossary#node) на нескольких независимых уровнях: изоляция привилегий в файловой системе, управляемый межсетевой экран хоста, обнаружение вторжений, разграничение прав в контейнерах и строгая изоляция секретов. Каждый уровень настраивается отдельным [действием](/apex/glossary#action), что гарантирует воспроизводимость и идемпотентность конфигурации.
|
||||
|
||||
## Изоляция привилегий {#privilege}
|
||||
|
||||
Каждый проект работает под изолированным системным пользователем `noroot-<project>` без права входа. Общее хранилище открыто на запись группе с установленным sticky-битом: проекты могут обмениваться файлами, но не могут удалять чужие данные. Подробное устройство механизма (пользователи, группы и матрица прав) описано в разделе [Концепция: Многоуровневое хранилище](/apex/concepts/tiered-storage).
|
||||
Каждый проект работает под изолированным системным пользователем `noroot-<project>` без права входа. Общее хранилище открыто на запись группе с установленным sticky-битом: проекты могут обмениваться файлами, но не могут удалять чужие данные. Подробное устройство механизма (пользователи, группы и матрица прав) описано в разделе [Концепция: Многоуровневое хранилище](/apex/concepts/tiered-storage).
|
||||
|
||||
## Межсетевой экран хоста {#firewall}
|
||||
|
||||
Команда `apex configure/ufw` настраивает межсетевой экран узла на основе каталога `configs/ufw/` по трём направлениям:
|
||||
|
||||
- **Правила хоста** (`configs/ufw/host/host.rules`) — список команд `ufw` (по одной на строку), применяемых последовательно.
|
||||
- **Правила уровня ядра** (`configs/ufw/host/before.rules`, `after.rules`) — внедряются в `/etc/ufw/before.rules` и `/etc/ufw/after.rules` в виде управляемых блоков-маркеров. Процесс внедрения **самовосстанавливающийся**: любые ранее добавленные блоки (включая оставшиеся от предыдущих утилит) удаляются перед вставкой актуального блока после якорной строки `# End required lines`. Рядом с изменяемым файлом сохраняется резервная копия `.bak` с временной меткой.
|
||||
- **Правила для контейнеров** (`configs/ufw/docker/*.rules`) — применяются через утилиту `ufw-docker`, которая формирует правила iptables, устойчивые к перезапускам и манипуляциям самого Docker. Команда `configure/base` устанавливает этот инструмент в `~/.local/bin/ufw-docker`.
|
||||
- **Правила уровня ядра** (`configs/ufw/host/before.rules`, `after.rules`) — внедряются в `/etc/ufw/before.rules` и `/etc/ufw/after.rules` в виде управляемых блоков-маркеров. Процесс внедрения **самовосстанавливающийся**: любые ранее добавленные блоки (включая оставшиеся от предыдущих утилит) удаляются перед вставкой актуального блока после якорной строки `# End required lines`. Рядом с изменяемым файлом сохраняется резервная копия `.bak` с временной меткой.
|
||||
- **Правила для контейнеров** (`configs/ufw/docker/*.rules`) — применяются через утилиту `ufw-docker`, которая формирует правила iptables, устойчивые к перезапускам и манипуляциям самого Docker. Команда `configure/base` устанавливает этот инструмент в `~/.local/bin/ufw-docker`.
|
||||
|
||||
Отсутствующая конфигурация считается ошибкой, а не поводом для тихого пропуска: отсутствие каталогов `docker/` или `host/` (при наличии парного каталога) завершает работу с кодом `66`, а отсутствие целевого системного файла в `/etc/ufw` немедленно прерывает выполнение. В конце действие напоминает о необходимости перезапустить `ufw` для применения изменений и проверить доступность порта SSH.
|
||||
Отсутствующая конфигурация считается ошибкой, а не поводом для тихого пропуска: отсутствие каталогов `docker/` или `host/` (при наличии парного каталога) завершает работу с кодом `66`, а отсутствие целевого системного файла в `/etc/ufw` немедленно прерывает выполнение. В конце действие напоминает о необходимости перезапустить `ufw` для применения изменений и проверить доступность порта SSH.
|
||||
|
||||
## Обнаружение вторжений {#crowdsec}
|
||||
|
||||
Команда `apex configure/crowdsec` автоматически устанавливает CrowdSec через официальный инсталлятор (если утилита `cscli` отсутствует), генерирует конфигурацию из `configs/crowdsec/` в `/etc/crowdsec/` (источники логов, парсеры, параметры bouncer) и устанавливает коллекции `crowdsecurity/linux` и `crowdsecurity/traefik`. Если конфигурация узла предусматривает firewall-bouncer, соответствующий пакет bouncer устанавливается автоматически, связывая обнаружение атак с немедленной блокировкой на уровне сети.
|
||||
Команда `apex configure/crowdsec` автоматически устанавливает CrowdSec через официальный инсталлятор (если утилита `cscli` отсутствует), генерирует конфигурацию из `configs/crowdsec/` в `/etc/crowdsec/` (источники логов, парсеры, параметры bouncer) и устанавливает коллекции `crowdsecurity/linux` и `crowdsecurity/traefik`. Если конфигурация узла предусматривает firewall-bouncer, соответствующий пакет bouncer устанавливается автоматически, связывая обнаружение атак с немедленной блокировкой на уровне сети.
|
||||
|
||||
## Ограничение на уровне контейнеров {#containers}
|
||||
## Ограничение на уровне контейнеров {#containers}
|
||||
|
||||
[Ядровая композиция](/apex/glossary#core-composition) задаёт строгие ограничения для контейнеров:
|
||||
[Ядровая композиция](/apex/glossary#core-composition) задаёт строгие ограничения для контейнеров:
|
||||
|
||||
- Граничный прокси изолирован от сокета Docker: он получает данные о сервисах через **отфильтрованный socket-proxy**, который отдаёт состояние контейнеров только для чтения в замкнутой сети.
|
||||
- Граничный прокси изолирован от сокета Docker: он получает данные о сервисах через **отфильтрованный socket-proxy**, который отдаёт состояние контейнеров только для чтения в замкнутой сети.
|
||||
- Сети `enclave` и `socket` объявлены с параметром `internal: true`: у них нет прямого доступа в интернет и опубликованных портов хоста.
|
||||
- Ядровые сервисы запускаются с опцией `no-new-privileges` под noroot-пользователем везде, где позволяет рабочая нагрузка; Linux capabilities выдаются точечно каждому сервису, а не глобально.
|
||||
- Ядровые сервисы запускаются с опцией `no-new-privileges` под noroot-пользователем везде, где позволяет рабочая нагрузка; Linux capabilities выдаются точечно каждому сервису, а не глобально.
|
||||
|
||||
## Наблюдаемость {#observability}
|
||||
|
||||
Система мониторинга построена по модели **исходящей отправки (push)**, а не входящих опросов. Опциональный сервис `alloy` (Grafana Alloy, один агент на узел) собирает метрики хоста (аналог `node_exporter`), метрики контейнеров (cadvisor), системный журнал systemd journal и логи docker-контейнеров, после чего передаёт их в **центральные** хранилища Prometheus и Loki на выделенном узле. Метрики отправляются через `prometheus.remote_write` на адрес `APEX_PROM_PUSH_URL`, логи — через `loki.write` на `APEX_LOKI_PUSH_URL`. Обе конечные точки защищены базовой аутентификацией HTTP basic auth (`APEX_OBS_BASIC_AUTH_USER` / `APEX_OBS_BASIC_AUTH_PASS`). Поскольку трафик отправляется наружу, Alloy подключён к сети `direct` с доступом в интернет, а не к закрытой сети `enclave`. На самих узлах логи не накапливаются, и открывать внешний доступ к эндпоинтам сервисов не требуется.
|
||||
Система мониторинга построена по модели **исходящей отправки (push)**, а не входящих опросов. Опциональный сервис `alloy` (Grafana Alloy, один агент на узел) собирает метрики хоста (аналог `node_exporter`), метрики контейнеров (cadvisor), системный журнал systemd journal и логи docker-контейнеров, после чего передаёт их в **центральные** хранилища Prometheus и Loki на выделенном узле. Метрики отправляются через `prometheus.remote_write` на адрес `APEX_PROM_PUSH_URL`, логи — через `loki.write` на `APEX_LOKI_PUSH_URL`. Обе конечные точки защищены базовой аутентификацией HTTP basic auth (`APEX_OBS_BASIC_AUTH_USER` / `APEX_OBS_BASIC_AUTH_PASS`). Поскольку трафик отправляется наружу, Alloy подключён к сети `direct` с доступом в интернет, а не к закрытой сети `enclave`. На самих узлах логи не накапливаются, и открывать внешний доступ к эндпоинтам сервисов не требуется.
|
||||
|
||||
## Изоляция секретов {#secrets}
|
||||
|
||||
- Секреты хранятся исключительно в файлах `.env` каждого проекта. Эти файлы представляют собой **символьные ссылки на хранилище уровня 1**, расположены вне рабочего дерева git и внесены в `.gitignore` (`**/.env`).
|
||||
- Для каждого узла перечень обязательных переменных задокументирован в файле `SECRETS.md`, причём указываются только имена ключей, но ни в коем случае не их значения.
|
||||
- Для каждого узла перечень обязательных переменных задокументирован в файле `SECRETS.md`, причём указываются только имена ключей, но ни в коем случае не их значения.
|
||||
- Версионируемый файл `apex.env` содержит только несекретные параметры.
|
||||
- В ядровых compose-файлах критически важные секреты снабжены маркером `?error`: при отсутствии переменной запуск немедленно останавливается с ошибкой ещё на этапе разбора конфигурации.
|
||||
- В ядровых compose-файлах критически важные секреты снабжены маркером `?error`: при отсутствии переменной запуск немедленно останавливается с ошибкой ещё на этапе разбора конфигурации.
|
||||
- Механизм [«capture-up»](/apex/glossary#capture-up) безопасен конструктивно: игнорируемые файлы `.env` и данные уровней хранилища никогда не попадают в git-индекс.
|
||||
|
||||
---
|
||||
|
||||
**См. также:**
|
||||
|
||||
- [Концепция: Многоуровневое хранилище и изоляция привилегий](/apex/concepts/tiered-storage)
|
||||
- [Концепция: Композиции и общее ядро](/apex/concepts/compositions)
|
||||
- [Концепция: Многоуровневое хранилище и изоляция привилегий](/apex/concepts/tiered-storage)
|
||||
- [Концепция: Композиции и общее ядро](/apex/concepts/compositions)
|
||||
- [Справочник: Действия](/apex/reference/actions)
|
||||
@@ -1,25 +1,25 @@
|
||||
---
|
||||
title: Многоуровневое хранилище и изоляция привилегий
|
||||
description: Структура ссылок @tier, разделяемое хранилище и локальные noroot-пользователи для проектов.
|
||||
title: Многоуровневое хранилище и изоляция привилегий
|
||||
description: Структура ссылок @tier, разделяемое хранилище и локальные noroot-пользователи для проектов.
|
||||
weight: 16
|
||||
---
|
||||
|
||||
Данные сервисов никогда не хранятся внутри репозитория [узла](/apex/glossary#node). Они распределяются по трём [уровням](/apex/glossary#tier) хранилища — отдельным каталогам, настраиваемым индивидуально для каждого хоста, — и подключаются к [композициям](/apex/glossary#compositions) через стандартизированную структуру символьных ссылок. Права на эти каталоги делегируются системным пользователям проектов, благодаря чему ни один контейнер не ведёт запись от имени root.
|
||||
Данные сервисов никогда не хранятся внутри репозитория [узла](/apex/glossary#node). Они распределяются по трём [уровням](/apex/glossary#tier) хранилища — отдельным каталогам, настраиваемым индивидуально для каждого хоста, — и подключаются к [композициям](/apex/glossary#compositions) через стандартизированную структуру символьных ссылок. Права на эти каталоги делегируются системным пользователям проектов, благодаря чему ни один контейнер не ведёт запись от имени root.
|
||||
|
||||
## Структура ссылок уровней {#links}
|
||||
|
||||
Команда `apex tiers/link <tier1> <tier2> <tier3>` формирует два уровня символьных ссылок:
|
||||
|
||||
- **На уровне узла:** `compositions/@tierN` → корень хранилища соответствующего уровня. Именно эти пути движок предоставляет в переменных `APEX_TIER_ROOT1..3`.
|
||||
- **Для каждого проекта:** в каталоге `compositions/<project>/` создаётся путь `<tier-root>/<project>/`, на который указывает ссылка `compositions/<project>/@tierN`. Внутри каждого уровня поддерживается общий каталог `shared/` с поддиректорией для каждого проекта, а внутри каталога уровня проекта создаётся относительная ссылка `shared`, ведущая обратно в общую зону.
|
||||
- **Для каждого проекта:** в каталоге `compositions/<project>/` создаётся путь `<tier-root>/<project>/`, на который указывает ссылка `compositions/<project>/@tierN`. Внутри каждого уровня поддерживается общий каталог `shared/` с поддиректорией для каждого проекта, а внутри каталога уровня проекта создаётся относительная ссылка `shared`, ведущая обратно в общую зону.
|
||||
|
||||
Каждый проект дополнительно получает символьную ссылку `compositions/<project>/.env` → `<tier1-root>/<project>/.env` (создаётся пустым, если файл отсутствует). В нём хранятся незакоммиченные секреты (см. [Концепция: Композиции и общее ядро](/apex/concepts/compositions)). Ссылки `@tierN` и файлы `.env` исключены из git через `.gitignore`, благодаря чему репозиторий остаётся чистым источником кода.
|
||||
Каждый проект дополнительно получает символьную ссылку `compositions/<project>/.env` → `<tier1-root>/<project>/.env` (создаётся пустым, если файл отсутствует). В нём хранятся незакоммиченные секреты (см. [Концепция: Композиции и общее ядро](/apex/concepts/compositions)). Ссылки `@tierN` и файлы `.env` исключены из git через `.gitignore`, благодаря чему репозиторий остаётся чистым источником кода.
|
||||
|
||||
Если по пути предполагаемой символьной ссылки уже существует обычный файл или каталог, действие предупреждает об этом и запрашивает подтверждение перед удалением. При отказе (включая неинтерактивный EOF) создание ссылки пропускается, а выполнение продолжается дальше.
|
||||
Если по пути предполагаемой символьной ссылки уже существует обычный файл или каталог, действие предупреждает об этом и запрашивает подтверждение перед удалением. При отказе (включая неинтерактивный EOF) создание ссылки пропускается, а выполнение продолжается дальше.
|
||||
|
||||
## Пользователи и группы {#users}
|
||||
## Пользователи и группы {#users}
|
||||
|
||||
Команда `apex tiers/useradd` создаёт по одному заблокированному системному пользователю на проект — `noroot-<project>` (с санитизацией имени и собственной первичной группой), а также общефлотовую группу `noroot-shared`, в которую входят все пользователи проектов. Затем сгенерированные идентификаторы прописываются в файл `.env` соответствующего проекта между комментариями-маркерами, перезаписывая старый блок при повторных запусках:
|
||||
Команда `apex tiers/useradd` создаёт по одному заблокированному системному пользователю на проект — `noroot-<project>` (с санитизацией имени и собственной первичной группой), а также общефлотовую группу `noroot-shared`, в которую входят все пользователи проектов. Затем сгенерированные идентификаторы прописываются в файл `.env` соответствующего проекта между комментариями-маркерами, перезаписывая старый блок при повторных запусках:
|
||||
|
||||
```ini {filename="compositions/<project>/.env"}
|
||||
# START AUTO GENERATED APEX-NOROOT-USERS
|
||||
@@ -29,42 +29,42 @@ APEX_SHARED_GID=997
|
||||
# END AUTO GENERATED APEX-NOROOT-USERS
|
||||
```
|
||||
|
||||
Композиции подключают эти идентификаторы через переменные `user: "${APEX_UID?error}:${APEX_GID?error}"` и `group_add: ["${APEX_SHARED_GID?error}"]`.
|
||||
Композиции подключают эти идентификаторы через переменные `user: "${APEX_UID?error}:${APEX_GID?error}"` и `group_add: ["${APEX_SHARED_GID?error}"]`.
|
||||
|
||||
## Матрица владения {#ownership}
|
||||
|
||||
Действие `apex tiers/chown` выставляет корректные права по всем трём уровням хранилища. Без аргументов оно обрабатывает корни уровней (`APEX_TIER_ROOT1..3`); при необходимости можно указать конкретные пути вручную.
|
||||
Действие `apex tiers/chown` выставляет корректные права по всем трём уровням хранилища. Без аргументов оно обрабатывает корни уровней (`APEX_TIER_ROOT1..3`); при необходимости можно указать конкретные пути вручную.
|
||||
|
||||
| Путь | Владение | Примечания |
|
||||
|---|---|---|
|
||||
| `<tier-root>` | `root:root` | Только inode каталога. |
|
||||
| `<tier-root>/shared` | `root:root` | Только inode каталога (без рекурсии, чтобы не затронуть пользовательские данные проектов). |
|
||||
| `<tier-root>/shared` | `root:root` | Только inode каталога (без рекурсии, чтобы не затронуть пользовательские данные проектов). |
|
||||
| `<tier-root>/node-infra` | `root:root` | Рекурсивно. |
|
||||
| `<tier-root>/<project>` | `uid:gid` | Рекурсивно; идентификаторы читаются из `.env` проекта. |
|
||||
| `<tier-root>/shared/<project>` | `uid:shared_gid` | Рекурсивно, с установкой sticky-бита: участники могут добавлять файлы, но удалять — только собственные. |
|
||||
|
||||
Проекты с неполным набором переменных `APEX_UID`, `APEX_GID` или `APEX_SHARED_GID` пропускаются с предупреждением и рекомендацией выполнить `tiers/useradd`.
|
||||
Проекты с неполным набором переменных `APEX_UID`, `APEX_GID` или `APEX_SHARED_GID` пропускаются с предупреждением и рекомендацией выполнить `tiers/useradd`.
|
||||
|
||||
## Переменные уровней движка {#variables}
|
||||
|
||||
| Переменная | Разрешается в |
|
||||
| Переменная | Разрешается в |
|
||||
|---|---|
|
||||
| `APEX_TIER_ROOT1..3` | Корни хранилищ уровней (`compositions/@tierN`, абсолютные пути). |
|
||||
| `APEX_TIER1..3` | Каталоги данных ядрового проекта на каждом уровне (`compositions/apex/@tierN`). |
|
||||
| `APEX_TIER1..3_SHARED` | Общие области внутри уровней ядрового проекта. |
|
||||
|
||||
Ядровая композиция монтирует каталоги исключительно через эти переменные. Контейнер резервного копирования, напротив, монтирует общий корень данных флота `/srv` в режиме «только для чтения» и сопоставляет контейнерные пути каждого сервиса с их реальными путями на хосте.
|
||||
Ядровая композиция монтирует каталоги исключительно через эти переменные. Контейнер резервного копирования, напротив, монтирует общий корень данных флота `/srv` в режиме «только для чтения» и сопоставляет контейнерные пути каждого сервиса с их реальными путями на хосте.
|
||||
|
||||
## Резервное копирование {#backups}
|
||||
|
||||
Команда `apex backup/run <telegram-bot-url>` полностью берёт на себя управление сервисом `resticontainer` (профиль `manual`, одноразовый запуск). Резервное копирование **управляется метками**: resticontainer находит все контейнеры с меткой `restic.enable=true`, считывает список путей `restic.backup.paths` (и необязательные параметры `restic.backup.stop` / `restic.hooks.*`), транслирует пути контейнеров в пути хоста и создаёт единый снимок restic.
|
||||
|
||||
1. Проверяет доступность репозитория с помощью команды `snapshots`; при необходимости автоматически инициализирует его.
|
||||
2. Запускает команду `backup` с параметрами `--host <APEX_NODE_HOST>` и `--tag biweekly`. При этом resticontainer выполняет pre-hook сервиса, временно останавливает контейнеры с меткой `restic.backup.stop`, делает снимок хостовых путей, перезапускает остановленные контейнеры и запускает post-hooks. Запуск не засчитывается как успешный, если в репозитории не появился новый снимок (защита от ложного успеха при отсутствии помеченных сервисов).
|
||||
1. Проверяет доступность репозитория с помощью команды `snapshots`; при необходимости автоматически инициализирует его.
|
||||
2. Запускает команду `backup` с параметрами `--host <APEX_NODE_HOST>` и `--tag biweekly`. При этом resticontainer выполняет pre-hook сервиса, временно останавливает контейнеры с меткой `restic.backup.stop`, делает снимок хостовых путей, перезапускает остановленные контейнеры и запускает post-hooks. Запуск не засчитывается как успешный, если в репозитории не появился новый снимок (защита от ложного успеха при отсутствии помеченных сервисов).
|
||||
3. Применяет политику хранения с помощью `forget --prune` (хранятся 7 ежедневных, 4 еженедельных и 12 ежемесячных снимков).
|
||||
4. Отправляет статус выполнения в Telegram. Если уведомление не удалось доставить, операция завершается с кодом ошибки `1` даже при успешном бэкапе, чтобы сбой в канале оповещения не остался незамеченным.
|
||||
4. Отправляет статус выполнения в Telegram. Если уведомление не удалось доставить, операция завершается с кодом ошибки `1` даже при успешном бэкапе, чтобы сбой в канале оповещения не остался незамеченным.
|
||||
|
||||
Параметры подключения и учётные данные репозитория считываются из конфигурации `APEX_RESTIC_*` (см. [Справочник: Переменные окружения](/apex/reference/environment)). Состав сохраняемых данных определяется метками каждого отдельного сервиса. Например, для ядровых сервисов копируются только файлы конфигурации (динамические правила traefik, настройки xray и adguard), тогда как легко восстановимые данные уровня 3 (кэши и журналы) в снимок не включаются.
|
||||
Параметры подключения и учётные данные репозитория считываются из конфигурации `APEX_RESTIC_*` (см. [Справочник: Переменные окружения](/apex/reference/environment)). Состав сохраняемых данных определяется метками каждого отдельного сервиса. Например, для ядровых сервисов копируются только файлы конфигурации (динамические правила traefik, настройки xray и adguard), тогда как легко восстановимые данные уровня 3 (кэши и журналы) в снимок не включаются.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -4,13 +4,13 @@ description: Начальная настройка репозитория узл
|
||||
weight: 5
|
||||
---
|
||||
|
||||
В этом руководстве показано, как настроить новый хост: от клонирования репозитория [узла](/apex/glossary#node) до запуска рабочего стека сервисов. Предполагается, что репозиторий узла уже создан; если его нужно собрать с нуля, см. [Руководство: Сборка репозитория узла](/apex/guides/node-repository).
|
||||
В этом руководстве показано, как настроить новый хост: от клонирования репозитория [узла](/apex/glossary#node) до запуска рабочего стека сервисов. Предполагается, что репозиторий узла уже создан; если его нужно собрать с нуля, см. [Руководство: Сборка репозитория узла](/apex/guides/node-repository).
|
||||
|
||||
## Предварительные требования
|
||||
|
||||
- Хост на базе Debian. Подойдёт любой FQDN: [идентичность узла](/apex/glossary#node-identity) задаётся явно в `node.env` (`APEX_NODE_FQDN`, `APEX_NODE_HOST`, `APEX_SUBNET`), а не выводится из имени хоста.
|
||||
- Хост на базе Debian. Подойдёт любой FQDN: [идентичность узла](/apex/glossary#node-identity) задаётся явно в `node.env` (`APEX_NODE_FQDN`, `APEX_NODE_HOST`, `APEX_SUBNET`), а не выводится из имени хоста.
|
||||
- `git` и Python 3 — [движок](/apex/glossary#engine) использует только стандартную библиотеку, `pip` не нужен.
|
||||
- Docker с Compose версии 2.20 или новее ([базовая композиция](/apex/glossary#core-composition) подключается через директиву `include:`).
|
||||
- Docker с Compose версии 2.20 или новее ([базовая композиция](/apex/glossary#core-composition) подключается через директиву `include:`).
|
||||
- Учётная запись с правами sudo для входа на хост.
|
||||
- Три хранилища под [уровни](/apex/glossary#tier) (например, три точки монтирования).
|
||||
|
||||
@@ -35,7 +35,7 @@ source ~/.bashrc
|
||||
apex --help
|
||||
```
|
||||
|
||||
В списке отображается каждое действие [commons](/apex/glossary#commons) плюс любые локальные [проприетарные модули](/apex/glossary#proprietaries) узла с меткой `(local)`. Если в `node.env` не задан `APEX_NODE_FQDN`, движок выводит предупреждение и использует имя хоста ОС. На рабочей станции это безобидно, но на самом узле задайте `APEX_NODE_FQDN` явно. Параметр `APEX_SUBNET` обязателен: без него движок завершит работу с кодом `66`.
|
||||
В списке отображается каждое действие [commons](/apex/glossary#commons) плюс любые локальные [проприетарные модули](/apex/glossary#proprietaries) узла с меткой `(local)`. Если в `node.env` не задан `APEX_NODE_FQDN`, движок выводит предупреждение и использует имя хоста ОС. На рабочей станции это безобидно, но на самом узле задайте `APEX_NODE_FQDN` явно. Параметр `APEX_SUBNET` обязателен: без него движок завершит работу с кодом `66`.
|
||||
|
||||
### Свяжите уровни хранилища
|
||||
|
||||
@@ -67,7 +67,7 @@ APEX_TRAEFIK_ACME_EMAIL=...
|
||||
APEX_OBS_BASIC_AUTH_PASS=... # только когда включён профиль наблюдаемости alloy
|
||||
```
|
||||
|
||||
Переменные `APEX_UID`, `APEX_GID` и `APEX_SHARED_GID` уже добавлены: `tiers/useradd` сохраняет их между специальными комментариями-маркерами. Несекретные скалярные значения (`APEX_RESTIC_REPOSITORY`, push-URL, `COMPOSE_PROFILES`, `TZ` и др.) хранятся в отслеживаемом файле `compositions/apex/apex.env` (см. [Руководство: Сборка репозитория узла](/apex/guides/node-repository)).
|
||||
Переменные `APEX_UID`, `APEX_GID` и `APEX_SHARED_GID` уже добавлены: `tiers/useradd` сохраняет их между специальными комментариями-маркерами. Несекретные скалярные значения (`APEX_RESTIC_REPOSITORY`, push-URL, `COMPOSE_PROFILES`, `TZ` и др.) хранятся в отслеживаемом файле `compositions/apex/apex.env` (см. [Руководство: Сборка репозитория узла](/apex/guides/node-repository)).
|
||||
|
||||
### Настройте хост
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ weight: 100
|
||||
|
||||
## Capture-up {#capture-up}
|
||||
|
||||
**Capture-up** — механизм фиксации состояния хоста в репозитории: регулярный коммит в ветку `sync/<node>`, который перебазируется на `main` и сливается средствами CI. В результате любой дрейф конфигурации становится наглядным diff, а не скрытым расхождением.
|
||||
**Capture-up** — механизм фиксации состояния хоста в репозитории: регулярный коммит в ветку `sync/<node>`, который перебазируется на `main` и сливается средствами CI. В результате любой дрейф конфигурации становится наглядным diff, а не скрытым расхождением.
|
||||
|
||||
## Commons {#commons}
|
||||
|
||||
@@ -56,11 +56,11 @@ weight: 100
|
||||
|
||||
## Proprietaries {#proprietaries}
|
||||
|
||||
**Proprietaries (проприетарные модули)** — каталог репозитория для локального кода конкретного узла: действий, библиотек и инструментов, которые нужны только этому узлу и не входят в commons.
|
||||
**Proprietaries (проприетарные модули)** — каталог репозитория для локального кода конкретного узла: действий, библиотек и инструментов, которые нужны только этому узлу и не входят в commons.
|
||||
|
||||
## Tier {#tier}
|
||||
|
||||
**Tier (уровень)** — один из трёх стандартных классов хранилища узла (конфигурация, ценные данные, воспроизводимые данные), доступный сервисам через символьные ссылки вида `@tierN`.
|
||||
**Tier (уровень)** — один из трёх стандартных классов хранилища узла (конфигурация, ценные данные, воспроизводимые данные), доступный сервисам через символьные ссылки вида `@tierN`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
---
|
||||
title: Руководства
|
||||
description: Практические руководства по сборке репозиториев узлов, разработке действий и настройке композиций.
|
||||
description: Практические руководства по сборке репозиториев узлов, разработке действий и настройке композиций.
|
||||
weight: 20
|
||||
---
|
||||
|
||||
Практические руководства для повседневной работы с apex. Каждое руководство решает одну прикладную задачу: сборку репозитория [узла](/apex/glossary#node), разработку [действий](/apex/glossary#action) для [движка](/apex/glossary#engine), определение [композиций](/apex/glossary#compositions) сервисов и эксплуатацию базового хоста.
|
||||
Практические руководства для повседневной работы с apex. Каждое руководство решает одну прикладную задачу: сборку репозитория [узла](/apex/glossary#node), разработку [действий](/apex/glossary#action) для [движка](/apex/glossary#engine), определение [композиций](/apex/glossary#compositions) сервисов и эксплуатацию базового хоста.
|
||||
|
||||
## Доступные руководства {#available-guides}
|
||||
|
||||
@@ -17,7 +17,7 @@ weight: 20
|
||||
|
||||
---
|
||||
|
||||
**См. также:**
|
||||
**См. также:**
|
||||
|
||||
- [Docs: Getting started](/apex/getting-started)
|
||||
- [Docs: Concepts](/apex/concepts)
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
---
|
||||
title: Разработка действий
|
||||
description: Создавайте, переопределяйте и тестируйте действия движка в commons или proprietaries.
|
||||
description: Создавайте, переопределяйте и тестируйте действия движка в commons или proprietaries.
|
||||
weight: 22
|
||||
---
|
||||
|
||||
**Контекст:** вся автоматизация устроена в виде [действий](/apex/glossary#action) — по одному Python-модулю на каждое действие, диспетчеризуемых по пути к файлу. Общий код находится в каталоге [commons](/apex/glossary#commons) `actions/` («commons» — общий репозиторий), а специфика конкретного узла — в его `proprietaries/actions/`. Оба дерева используют единый контракт, а запускатель добавляет оба каталога в `sys.path`. Поэтому локальное действие импортирует `engine.*` точно так же, как и действие из commons.
|
||||
**Контекст:** вся автоматизация устроена в виде [действий](/apex/glossary#action) — по одному Python-модулю на каждое действие, диспетчеризуемых по пути к файлу. Общий код находится в каталоге [commons](/apex/glossary#commons) `actions/` («commons» — общий репозиторий), а специфика конкретного узла — в его `proprietaries/actions/`. Оба дерева используют единый контракт, а запускатель добавляет оба каталога в `sys.path`. Поэтому локальное действие импортирует `engine.*` точно так же, как и действие из commons.
|
||||
|
||||
## Минимальное действие {#minimal}
|
||||
|
||||
@@ -29,14 +29,14 @@ def run(ctx, args):
|
||||
ctx.log.success("Конфигурация развёрнута.")
|
||||
```
|
||||
|
||||
Путь к файлу задаёт имя действия: модуль выше вызывается как `apex utils/example`. Верните `0` или `None` при успехе, целое число — для конкретного кода выхода, либо выбросьте `SystemExit(code)`. [Соглашение о кодах выхода](/apex/concepts/actions-and-overlay#exit-codes): `64` — ошибка использования, `65` — ошибка данных, `66` — отсутствие входных данных, `1` — общая ошибка.
|
||||
Путь к файлу задаёт имя действия: модуль выше вызывается как `apex utils/example`. Верните `0` или `None` при успехе, целое число — для конкретного кода выхода, либо выбросьте `SystemExit(code)`. [Соглашение о кодах выхода](/apex/concepts/actions-and-overlay#exit-codes): `64` — ошибка использования, `65` — ошибка данных, `66` — отсутствие входных данных, `1` — общая ошибка.
|
||||
|
||||
## Соглашения для фоновых действий {#unattended}
|
||||
## Соглашения для фоновых действий {#unattended}
|
||||
|
||||
Действия, которые запускаются по cron без надзора оператора, следуют двум правилам, чтобы сбои отправляли оповещение, а не исчезали бесследно:
|
||||
Действия, которые запускаются по cron без надзора оператора, следуют двум правилам, чтобы сбои отправляли оповещение, а не исчезали бесследно:
|
||||
|
||||
- **Оберните тело** в `try/except Exception` и отправьте `ctx.notify.error(...)` перед тем как выбросить `SystemExit(1)` — это Python-аналог перехвата `ERR` в shell.
|
||||
- **Привяжите код выхода к итоговому уведомлению**: методы `ctx.notify.*` возвращают `False`, если доставить сообщение не удалось. Запуск, чьё статусное сообщение не дошло, всё равно должен завершиться с кодом 1.
|
||||
- **Оберните тело** в `try/except Exception` и отправьте `ctx.notify.error(...)` перед тем как выбросить `SystemExit(1)` — это Python-аналог перехвата `ERR` в shell.
|
||||
- **Привяжите код выхода к итоговому уведомлению**: методы `ctx.notify.*` возвращают `False`, если доставить сообщение не удалось. Запуск, чьё статусное сообщение не дошло, всё равно должен завершиться с кодом 1.
|
||||
|
||||
```python
|
||||
try:
|
||||
@@ -54,7 +54,7 @@ def run(ctx, args):
|
||||
|
||||
## Переопределение, обёртывание, отключение {#override}
|
||||
|
||||
Проприетарный файл с тем же относительным путём, что и действие из commons, полностью перекрывает его. Внутри переопределяющего модуля вызов `ctx.commons.run(args)` обращается к исходной реализации из commons — по паттерну обёртки:
|
||||
Проприетарный файл с тем же относительным путём, что и действие из commons, полностью перекрывает его. Внутри переопределяющего модуля вызов `ctx.commons.run(args)` обращается к исходной реализации из commons — по паттерну обёртки:
|
||||
|
||||
```python {filename="proprietaries/actions/sync/packages.py"}
|
||||
from engine.descriptor import Meta, Arg
|
||||
@@ -67,23 +67,23 @@ def run(ctx, args):
|
||||
return ctx.commons.run(args) # делегирование реализации из commons
|
||||
```
|
||||
|
||||
Чтобы отключить действие на конкретном узле, укажите `DISABLED = "reason"` на верхнем уровне модуля. Диспетчер отклонит запуск такого действия с кодом выхода 1, а в общем списке появится отметка `[DISABLED: reason]`.
|
||||
Чтобы отключить действие на конкретном узле, укажите `DISABLED = "reason"` на верхнем уровне модуля. Диспетчер отклонит запуск такого действия с кодом выхода 1, а в общем списке появится отметка `[DISABLED: reason]`.
|
||||
|
||||
## Тестирование {#testing}
|
||||
|
||||
Набор тестов commons построен на стандартном модуле `unittest`:
|
||||
Набор тестов commons построен на стандартном модуле `unittest`:
|
||||
|
||||
```bash
|
||||
./tests/run.sh # python3 -m unittest discover -s tests -p 'test_*.py' -v
|
||||
```
|
||||
|
||||
Изменения в движке должны сохранять тесты зелёными. Быстрая smoke-проверка для любого действия (как из commons, так и проприетарного) — запуск со справкой: команда `apex <name> --help` должна завершаться с кодом 0. Это подтверждает, что модуль синтаксически корректен, объект `METADATA` сформирован без ошибок, а механизм обнаружения находит действие.
|
||||
Изменения в движке должны сохранять тесты зелёными. Быстрая smoke-проверка для любого действия (как из commons, так и проприетарного) — запуск со справкой: команда `apex <name> --help` должна завершаться с кодом 0. Это подтверждает, что модуль синтаксически корректен, объект `METADATA` сформирован без ошибок, а механизм обнаружения находит действие.
|
||||
|
||||
Хук pre-commit в commons (скрипт `init.sh` подключает его через `core.hooksPath`) запускает `apex utils/lint-docker-compose --hook` для подготовленных (staged) compose-файлов, поэтому ошибки в композициях отлавливаются ещё на этапе коммита.
|
||||
Хук pre-commit в commons (скрипт `init.sh` подключает его через `core.hooksPath`) запускает `apex utils/lint-docker-compose --hook` для подготовленных (staged) compose-файлов, поэтому ошибки в композициях отлавливаются ещё на этапе коммита.
|
||||
|
||||
---
|
||||
|
||||
**См. также:**
|
||||
**См. также:**
|
||||
|
||||
- [Concept: Actions and the overlay](/apex/concepts/actions-and-overlay)
|
||||
- [Concept: The execution context](/apex/concepts/execution-context)
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
---
|
||||
title: Эксплуатация узла
|
||||
description: "Операции day-2: конфигурирование, композиции, резервные копии и синхронизация репозитория."
|
||||
description: "Операции day-2: конфигурирование, композиции, резервные копии и синхронизация репозитория."
|
||||
weight: 24
|
||||
---
|
||||
|
||||
**Контекст:** после первоначальной настройки [узел](/apex/glossary#node) управляется исключительно через [движок](/apex/glossary#engine). В этом руководстве описаны регулярные операции и их фактические действия на хосте.
|
||||
**Контекст:** после первоначальной настройки [узел](/apex/glossary#node) управляется исключительно через [движок](/apex/glossary#engine). В этом руководстве описаны регулярные операции и их фактические действия на хосте.
|
||||
|
||||
## Конфигурирование хоста {#configure}
|
||||
|
||||
Команда `apex configure` выполняет все шаги настройки в строгой последовательности. Если изменился только один слой, нужный шаг можно вызвать отдельно:
|
||||
Команда `apex configure` выполняет все шаги настройки в строгой последовательности. Если изменился только один слой, нужный шаг можно вызвать отдельно:
|
||||
|
||||
| Шаг | Эффект |
|
||||
|---|---|
|
||||
| `configure/base` | Готовит базовую систему Debian: обновляет списки пакетов, ставит базовые утилиты (`curl`, `wget`, `git`, `rsyslog`), запускает службу rsyslog, устанавливает `ufw` и вспомогательный скрипт `ufw-docker` в `~/.local/bin/ufw-docker`. |
|
||||
| `configure/ufw` | Применяет правила хоста, настраивает цепочки правил ядра и пробрасывает правила для контейнеров через `ufw-docker`. Поддерживает флаг `--dry-run`. |
|
||||
| `configure/crowdsec` | Устанавливает CrowdSec (если он ещё не поставлен), копирует `configs/crowdsec/` в `/etc/crowdsec/`, ставит коллекции и firewall bouncer (если включён в настройках). Поддерживает флаг `--dry-run`. |
|
||||
| `configure/cron` | Рендерит `configs/cron/crontab` через шаблонизатор и устанавливает расписание. Предусмотрены предпросмотр, аварийная остановка при пустом результате (код выхода 65) и откат при ошибке установки. Поддерживает флаг `--dry-run`. |
|
||||
| `configure/systemd` | Рендерит файлы первого уровня из `configs/systemd/` в `/etc/systemd/system/`, перезагружает конфигурацию systemd (daemon-reload) и активирует каждый юнит `.service`. Поддерживает флаг `--dry-run`. |
|
||||
| `configure/routing` | Включает пересылку IP-пакетов (forwarding), задаёт правило и таблицу маршрутизации (policy-routing) по метке fwmark для границы анклава. Шаг идемпотентен. |
|
||||
| `configure/base` | Готовит базовую систему Debian: обновляет списки пакетов, ставит базовые утилиты (`curl`, `wget`, `git`, `rsyslog`), запускает службу rsyslog, устанавливает `ufw` и вспомогательный скрипт `ufw-docker` в `~/.local/bin/ufw-docker`. |
|
||||
| `configure/ufw` | Применяет правила хоста, настраивает цепочки правил ядра и пробрасывает правила для контейнеров через `ufw-docker`. Поддерживает флаг `--dry-run`. |
|
||||
| `configure/crowdsec` | Устанавливает CrowdSec (если он ещё не поставлен), копирует `configs/crowdsec/` в `/etc/crowdsec/`, ставит коллекции и firewall bouncer (если включён в настройках). Поддерживает флаг `--dry-run`. |
|
||||
| `configure/cron` | Рендерит `configs/cron/crontab` через шаблонизатор и устанавливает расписание. Предусмотрены предпросмотр, аварийная остановка при пустом результате (код выхода 65) и откат при ошибке установки. Поддерживает флаг `--dry-run`. |
|
||||
| `configure/systemd` | Рендерит файлы первого уровня из `configs/systemd/` в `/etc/systemd/system/`, перезагружает конфигурацию systemd (daemon-reload) и активирует каждый юнит `.service`. Поддерживает флаг `--dry-run`. |
|
||||
| `configure/routing` | Включает пересылку IP-пакетов (forwarding), задаёт правило и таблицу маршрутизации (policy-routing) по метке fwmark для границы анклава. Шаг идемпотентен. |
|
||||
|
||||
После `configure/ufw` обязательно перезапустите службу: `sudo systemctl restart ufw`. Действие выведет напоминание и проверит, что порт SSH остаётся открытым.
|
||||
После `configure/ufw` обязательно перезапустите службу: `sudo systemctl restart ufw`. Действие выведет напоминание и проверит, что порт SSH остаётся открытым.
|
||||
|
||||
## Жизненный цикл композиции {#compose}
|
||||
|
||||
@@ -30,7 +30,7 @@ apex compose restart
|
||||
apex compose up --dry-run # только напечатать команды
|
||||
```
|
||||
|
||||
Все дополнительные аргументы движок передаёт напрямую в `docker compose` каждого проекта. Файлы `.env` и `apex.env` проектных каталогов подставляются автоматически при их наличии.
|
||||
Все дополнительные аргументы движок передаёт напрямую в `docker compose` каждого проекта. Файлы `.env` и `apex.env` проектных каталогов подставляются автоматически при их наличии.
|
||||
|
||||
## Резервные копии {#backups}
|
||||
|
||||
@@ -38,14 +38,14 @@ apex compose up --dry-run # только напечатать команды
|
||||
apex backup/run "$(cat <path-to-telegram-url-file>)"
|
||||
```
|
||||
|
||||
Команда запускает [resticontainer](https://github.com/deytenit/resticontainer) в режиме one-shot под профилем compose `manual`. Процесс проходит следующие этапы:
|
||||
Команда запускает [resticontainer](https://github.com/deytenit/resticontainer) в режиме one-shot под профилем compose `manual`. Процесс проходит следующие этапы:
|
||||
|
||||
1. Инициализирует репозиторий (если он ещё не создан).
|
||||
2. Считывает compose-метки `restic.*` (`restic.enable`, `restic.backup.paths` и т. д.), чтобы определить, какие сервисы и пути нужно сохранять.
|
||||
3. Создаёт единый снимок restic для всех выбранных путей хоста (`--host` = `APEX_NODE_HOST`, `--compression max`).
|
||||
4. Применяет политику хранения (`forget --prune`) и отправляет уведомление.
|
||||
1. Инициализирует репозиторий (если он ещё не создан).
|
||||
2. Считывает compose-метки `restic.*` (`restic.enable`, `restic.backup.paths` и т. д.), чтобы определить, какие сервисы и пути нужно сохранять.
|
||||
3. Создаёт единый снимок restic для всех выбранных путей хоста (`--host` = `APEX_NODE_HOST`, `--compression max`).
|
||||
4. Применяет политику хранения (`forget --prune`) и отправляет уведомление.
|
||||
|
||||
Если ни один сервис с меткой `restic.*` не сформировал нового снимка, действие завершается с ошибкой. Полный процесс описан в разделе [Concept: Tiered storage](/apex/concepts/tiered-storage#backups). Чтобы резервное копирование и отправка отчётов в Telegram выполнялись автономно, добавьте команду в `configs/cron/crontab`.
|
||||
Если ни один сервис с меткой `restic.*` не сформировал нового снимка, действие завершается с ошибкой. Полный процесс описан в разделе [Concept: Tiered storage](/apex/concepts/tiered-storage#backups). Чтобы резервное копирование и отправка отчётов в Telegram выполнялись автономно, добавьте команду в `configs/cron/crontab`.
|
||||
|
||||
## Синхронизация репозитория (capture-up) {#sync}
|
||||
|
||||
@@ -53,9 +53,9 @@ apex backup/run "$(cat <path-to-telegram-url-file>)"
|
||||
apex sync/repository <telegram-bot-url>
|
||||
```
|
||||
|
||||
Действие фиксирует изменения рабочей копии в ветке `sync/<node>`, выполняет rebase на `origin/main` и отправляет ветку через force-push с lease. Затем CI автоматически вливает изменения обратно в `main`. Этот механизм (capture-up) сохраняет актуальное состояние узла в репозиторий.
|
||||
Действие фиксирует изменения рабочей копии в ветке `sync/<node>`, выполняет rebase на `origin/main` и отправляет ветку через force-push с lease. Затем CI автоматически вливает изменения обратно в `main`. Этот механизм (capture-up) сохраняет актуальное состояние узла в репозиторий.
|
||||
|
||||
Действие прерывается с подробным сообщением, если подмодуль commons содержит незакоммиченные изменения или каталог `compositions/` отсутствует. При конфликтах rebase выполнение останавливается для ручного вмешательства. Подробнее архитектурная модель описана в разделе [Concept: Framework architecture](/apex/concepts/architecture#capture-up).
|
||||
Действие прерывается с подробным сообщением, если подмодуль commons содержит незакоммиченные изменения или каталог `compositions/` отсутствует. При конфликтах rebase выполнение останавливается для ручного вмешательства. Подробнее архитектурная модель описана в разделе [Concept: Framework architecture](/apex/concepts/architecture#capture-up).
|
||||
|
||||
## Мониторинг обновлений {#updates}
|
||||
|
||||
@@ -63,7 +63,7 @@ apex sync/repository <telegram-bot-url>
|
||||
apex sync/packages <telegram-bot-url>
|
||||
```
|
||||
|
||||
Формирует единую сводку в Telegram о доступных обновлениях пакетов apt и устаревших образах контейнеров (локальные дайджесты сравниваются с реестром через `skopeo`). Действие ничего не меняет на хосте — это исключительно плановая проверка.
|
||||
Формирует единую сводку в Telegram о доступных обновлениях пакетов apt и устаревших образах контейнеров (локальные дайджесты сравниваются с реестром через `skopeo`). Действие ничего не меняет на хосте — это исключительно плановая проверка.
|
||||
|
||||
## Извлечение сертификатов {#certs}
|
||||
|
||||
@@ -71,11 +71,11 @@ apex sync/packages <telegram-bot-url>
|
||||
apex utils/extract-traefik-certs <acme.json> <dest-dir> [telegram-bot-url]
|
||||
```
|
||||
|
||||
Экспортирует сертификаты из ACME-хранилища граничного прокси в пары `fullchain.pem` и `privkey.pem` для каждого домена (с помощью утилиты в контейнере). Скрипт ограничивает права доступа к ключам до `600` и передаёт владение файлами запустившему пользователю. Это нужно сервисам, которым требуются исходные PEM-файлы вместо готового TLS-соединения от прокси.
|
||||
Экспортирует сертификаты из ACME-хранилища граничного прокси в пары `fullchain.pem` и `privkey.pem` для каждого домена (с помощью утилиты в контейнере). Скрипт ограничивает права доступа к ключам до `600` и передаёт владение файлами запустившему пользователю. Это нужно сервисам, которым требуются исходные PEM-файлы вместо готового TLS-соединения от прокси.
|
||||
|
||||
---
|
||||
|
||||
**См. также:**
|
||||
**См. также:**
|
||||
|
||||
- [Getting started with an apex node](/apex/getting-started)
|
||||
- [Reference: Actions](/apex/reference/actions)
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
---
|
||||
title: Сборка репозитория узла
|
||||
description: Соберите четыре опоры, зафиксируйте commons и подключите поток capture-up.
|
||||
description: Соберите четыре опоры, зафиксируйте commons и подключите поток capture-up.
|
||||
weight: 21
|
||||
---
|
||||
|
||||
**Контекст:** у каждого [узла](/apex/glossary#node) — ровно один репозиторий. Название репозитория может быть любым: [идентичность](/apex/glossary#node-identity) задаётся явно в файле `node.env`, поэтому имя репозитория служит лишь удобным обозначением. В этом руководстве мы пошагово соберём такой репозиторий с нуля.
|
||||
**Контекст:** у каждого [узла](/apex/glossary#node) — ровно один репозиторий. Название репозитория может быть любым: [идентичность](/apex/glossary#node-identity) задаётся явно в файле `node.env`, поэтому имя репозитория служит лишь удобным обозначением. В этом руководстве мы пошагово соберём такой репозиторий с нуля.
|
||||
|
||||
{{% steps %}}
|
||||
|
||||
### Инициализируйте репозиторий и зафиксируйте commons
|
||||
### Инициализируйте репозиторий и зафиксируйте commons
|
||||
|
||||
```bash
|
||||
git init <node-repo> && cd <node-repo>
|
||||
@@ -18,7 +18,7 @@ git add .gitmodules commons
|
||||
mkdir -p proprietaries/actions proprietaries/lib configs compositions/apex .github/workflows
|
||||
```
|
||||
|
||||
Ссылка gitlink фиксирует точную версию движка, на которой работает узел. Чтобы обновить движок в будущем, достаточно переключиться на новый тег внутри каталога `commons/` и закоммитить смещение указателя.
|
||||
Ссылка gitlink фиксирует точную версию движка, на которой работает узел. Чтобы обновить движок в будущем, достаточно переключиться на новый тег внутри каталога `commons/` и закоммитить смещение указателя.
|
||||
|
||||
### Объявите идентичность
|
||||
|
||||
@@ -30,14 +30,14 @@ APEX_GIT_AUTHOR_NAME=apex [bot]
|
||||
APEX_GIT_AUTHOR_EMAIL=apex@localhost
|
||||
```
|
||||
|
||||
Файл `node.env` явно задаёт параметры идентификации узла (доменная зона не подразумевается заранее):
|
||||
Файл `node.env` явно задаёт параметры идентификации узла (доменная зона не подразумевается заранее):
|
||||
|
||||
- `APEX_NODE_FQDN` — FQDN хоста для маршрутизации Traefik по правилу `Host()` (если не указан, движок выведет предупреждение и возьмёт имя хоста из ОС).
|
||||
- `APEX_NODE_HOST` — публичный адрес хоста для параметра `--host` резервного копирования и метки `instance` в системе мониторинга и уведомлениях (по умолчанию совпадает с `APEX_NODE_FQDN`). Первая часть FQDN до точки используется как короткое имя (`name`) узла.
|
||||
- `APEX_SUBNET` — выделенная подсеть /24 для внутренней сети `direct`. Параметр **обязателен**: без него запуск завершится с кодом 66 (см. [Concept: Fleet addressing scheme](/apex/concepts/addressing)).
|
||||
- `APEX_GIT_AUTHOR_NAME` и `APEX_GIT_AUTHOR_EMAIL` — имя и почта сервисного бота capture-up (если не заданы, применяются значения по умолчанию).
|
||||
- `APEX_NODE_FQDN` — FQDN хоста для маршрутизации Traefik по правилу `Host()` (если не указан, движок выведет предупреждение и возьмёт имя хоста из ОС).
|
||||
- `APEX_NODE_HOST` — публичный адрес хоста для параметра `--host` резервного копирования и метки `instance` в системе мониторинга и уведомлениях (по умолчанию совпадает с `APEX_NODE_FQDN`). Первая часть FQDN до точки используется как короткое имя (`name`) узла.
|
||||
- `APEX_SUBNET` — выделенная подсеть /24 для внутренней сети `direct`. Параметр **обязателен**: без него запуск завершится с кодом 66 (см. [Concept: Fleet addressing scheme](/apex/concepts/addressing)).
|
||||
- `APEX_GIT_AUTHOR_NAME` и `APEX_GIT_AUTHOR_EMAIL` — имя и почта сервисного бота capture-up (если не заданы, применяются значения по умолчанию).
|
||||
|
||||
### Напишите базовую обёртку и файлы переменных окружения
|
||||
### Напишите базовую обёртку и файлы переменных окружения
|
||||
|
||||
```yaml {filename="compositions/apex/docker-compose.yml"}
|
||||
name: apex
|
||||
@@ -60,11 +60,11 @@ APEX_OBS_BASIC_AUTH_USER=<node-user>
|
||||
# APEX_RESTIC_AWS_ENDPOINT= # напр., URL S3-совместимого эндпоинта
|
||||
```
|
||||
|
||||
Файл `apex.env` хранит только публичные параметры конфигурации, которые безопасно коммитить в репозиторий. Переменная `COMPOSE_PROFILES` определяет набор опциональных базовых сервисов: `adguard`, `xray`, `alloy` (агент сбора метрик и логов) и `manual` (разовые запуски бэкапа); список может быть пустым.
|
||||
Файл `apex.env` хранит только публичные параметры конфигурации, которые безопасно коммитить в репозиторий. Переменная `COMPOSE_PROFILES` определяет набор опциональных базовых сервисов: `adguard`, `xray`, `alloy` (агент сбора метрик и логов) и `manual` (разовые запуски бэкапа); список может быть пустым.
|
||||
|
||||
Все секреты, которые нельзя хранить в Git, задокументируйте в `compositions/apex/SECRETS.md`. Это поможет при развёртывании нового узла заполнить локальный файл `.env`: пароль `APEX_RESTIC_PASSWORD`, ключи доступа к S3, токен `APEX_TRAEFIK_CF_DNS_API_TOKEN`, почту `APEX_TRAEFIK_ACME_EMAIL`, а также `APEX_OBS_BASIC_AUTH_PASS` (если включён мониторинг).
|
||||
Все секреты, которые нельзя хранить в Git, задокументируйте в `compositions/apex/SECRETS.md`. Это поможет при развёртывании нового узла заполнить локальный файл `.env`: пароль `APEX_RESTIC_PASSWORD`, ключи доступа к S3, токен `APEX_TRAEFIK_CF_DNS_API_TOKEN`, почту `APEX_TRAEFIK_ACME_EMAIL`, а также `APEX_OBS_BASIC_AUTH_PASS` (если включён мониторинг).
|
||||
|
||||
### Настройте правила игнорирования для Git
|
||||
### Настройте правила игнорирования для Git
|
||||
|
||||
```gitignore {filename=".gitignore"}
|
||||
**/@tier1
|
||||
@@ -78,15 +78,15 @@ __pycache__/
|
||||
|
||||
### Добавьте bootstrap-скрипт
|
||||
|
||||
Скрипт `init.sh` подготавливает свежий клон репозитория на хосте: выполняет `git submodule update --init --recursive`, настраивает хуки `git config core.hooksPath commons/githooks`, добавляет репозиторий в доверенные (`git config --global --add safe.directory <repo>`) и прописывает `<repo>/commons` в переменную `PATH` командной оболочки, чтобы утилита `apex` была доступна глобально. Не забудьте выставить флаг исполняемого файла: `chmod +x init.sh`.
|
||||
Скрипт `init.sh` подготавливает свежий клон репозитория на хосте: выполняет `git submodule update --init --recursive`, настраивает хуки `git config core.hooksPath commons/githooks`, добавляет репозиторий в доверенные (`git config --global --add safe.directory <repo>`) и прописывает `<repo>/commons` в переменную `PATH` командной оболочки, чтобы утилита `apex` была доступна глобально. Не забудьте выставить флаг исполняемого файла: `chmod +x init.sh`.
|
||||
|
||||
### Заведите реестр адресации
|
||||
|
||||
Создайте файл `compositions/ADDRESSING.md` с подсетью узла (`APEX_SUBNET`) и схемой распределения адресов: диапазоны `.10`–`.19` зарезервированы за базовыми сервисами (core), а `.20` и выше выделяются под проекты. Все назначаемые в будущем [якорные IP](/apex/glossary#anchor-ip) фиксируются в этом документе.
|
||||
Создайте файл `compositions/ADDRESSING.md` с подсетью узла (`APEX_SUBNET`) и схемой распределения адресов: диапазоны `.10`–`.19` зарезервированы за базовыми сервисами (core), а `.20` и выше выделяются под проекты. Все назначаемые в будущем [якорные IP](/apex/glossary#anchor-ip) фиксируются в этом документе.
|
||||
|
||||
### Подключите CI capture-up
|
||||
|
||||
Настройте периодический CI-workflow, который автоматически вливает ветку `sync/<node>` в `main` (если ветка существует) с авторством бота. Шаг checkout должен выполняться **без** загрузки подмодулей: слияние только сдвигает указатель `commons`, а SSH-ссылка на подмодуль недоступна по стандартному CI-токену. Полный цикл описан в разделе [Concept: Framework architecture](/apex/concepts/architecture) ([capture-up](/apex/glossary#capture-up)).
|
||||
Настройте периодический CI-workflow, который автоматически вливает ветку `sync/<node>` в `main` (если ветка существует) с авторством бота. Шаг checkout должен выполняться **без** загрузки подмодулей: слияние только сдвигает указатель `commons`, а SSH-ссылка на подмодуль недоступна по стандартному CI-токену. Полный цикл описан в разделе [Concept: Framework architecture](/apex/concepts/architecture) ([capture-up](/apex/glossary#capture-up)).
|
||||
|
||||
### Проверьте работу
|
||||
|
||||
@@ -94,18 +94,18 @@ __pycache__/
|
||||
./commons/apex --help
|
||||
```
|
||||
|
||||
В списке должны появиться все базовые действия из commons, а позже — и локальные действия из proprietaries. При запуске вне хоста (до заполнения `node.env`) появится предупреждение о том, что `APEX_NODE_FQDN` не задан: в этом случае движок подставит системное имя хоста. При этом переменная `APEX_SUBNET` обязательно должна быть указана, иначе выполнение завершится с кодом 66. Закоммитьте подготовленный репозиторий в ветку `main`.
|
||||
В списке должны появиться все базовые действия из commons, а позже — и локальные действия из proprietaries. При запуске вне хоста (до заполнения `node.env`) появится предупреждение о том, что `APEX_NODE_FQDN` не задан: в этом случае движок подставит системное имя хоста. При этом переменная `APEX_SUBNET` обязательно должна быть указана, иначе выполнение завершится с кодом 66. Закоммитьте подготовленный репозиторий в ветку `main`.
|
||||
|
||||
{{% /steps %}}
|
||||
|
||||
## Заметки
|
||||
|
||||
- Каталог `proprietaries/` изначально может быть пустым (сохраните структуру с помощью файлов `.gitkeep`): движок корректно работает без пользовательских переопределений.
|
||||
- Переносите конфигурационные файлы хоста в `configs/` (`ufw/`, `cron/crontab`, `systemd/`, `crowdsec/`) по мере подключения шагов `configure/*`. Действия считывают шаблоны из `configs/` напрямую.
|
||||
- Каталог `proprietaries/` изначально может быть пустым (сохраните структуру с помощью файлов `.gitkeep`): движок корректно работает без пользовательских переопределений.
|
||||
- Переносите конфигурационные файлы хоста в `configs/` (`ufw/`, `cron/crontab`, `systemd/`, `crowdsec/`) по мере подключения шагов `configure/*`. Действия считывают шаблоны из `configs/` напрямую.
|
||||
|
||||
---
|
||||
|
||||
**См. также:**
|
||||
**См. также:**
|
||||
|
||||
- [Getting started with an apex node](/apex/getting-started)
|
||||
- [Concept: Framework architecture](/apex/concepts/architecture)
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
---
|
||||
title: Добавление композиции сервисов
|
||||
description: "Подключите новый проект к узлу: якорный IP, сети, уровни и линтинг."
|
||||
description: "Подключите новый проект к узлу: якорный IP, сети, уровни и линтинг."
|
||||
weight: 23
|
||||
---
|
||||
|
||||
**Контекст:** проект подключается к [узлу](/apex/glossary#node) в виде отдельного каталога в `compositions/` со своим compose-файлом. Требования фреймворка к этому файлу минимальны: статический [якорный IP](/apex/glossary#anchor-ip) в сети `direct`, подключение к внешним сетям по имени и пути к данным относительно уровней хранения.
|
||||
**Контекст:** проект подключается к [узлу](/apex/glossary#node) в виде отдельного каталога в `compositions/` со своим compose-файлом. Требования фреймворка к этому файлу минимальны: статический [якорный IP](/apex/glossary#anchor-ip) в сети `direct`, подключение к внешним сетям по имени и пути к данным относительно уровней хранения.
|
||||
|
||||
{{% steps %}}
|
||||
|
||||
### Выделите якорный адрес
|
||||
|
||||
Выберите следующий свободный адрес `.20` или выше в подсети узла и зафиксируйте его в `compositions/ADDRESSING.md`. Диапазон `.10`–`.19` зарезервирован для [базовой композиции](/apex/glossary#core-composition) (см. [Concept: Fleet addressing scheme](/apex/concepts/addressing)).
|
||||
Выберите следующий свободный адрес `.20` или выше в подсети узла и зафиксируйте его в `compositions/ADDRESSING.md`. Диапазон `.10`–`.19` зарезервирован для [базовой композиции](/apex/glossary#core-composition) (см. [Concept: Fleet addressing scheme](/apex/concepts/addressing)).
|
||||
|
||||
### Напишите файл compose
|
||||
|
||||
@@ -52,14 +52,14 @@ networks:
|
||||
|
||||
Применяемые соглашения:
|
||||
|
||||
- **Один якорь на проект**: контейнер-якорь получает постоянный `ipv4_address` в сети `direct`, а вспомогательные sidecar-контейнеры используют его сетевой стек через `network_mode: service:<anchor>`.
|
||||
- **Внешние сети подключаются по имени**: для сетей `direct`, `enclave` и `socket` указывается `external: true`, поскольку они объявляются в базовой композиции.
|
||||
- **Публикация через `enclave` и метки Traefik**: подключение к сети `enclave` вместе с метками маршрутизации открывает сервис через граничный прокси. Прокси связывается с ним через socket-прокси, без проброса портов наружу.
|
||||
- **Пути к данным привязаны к уровням**: пути вида `./@tierN/...` разрешаются через символические ссылки, которые создаёт действие `tiers/link`.
|
||||
- **Резервное копирование настраивается метками `restic.*`**: действие `apex backup/run` запускает утилиту [resticontainer](https://github.com/deytenit/resticontainer). Сервис помечается меткой `restic.enable=true`, а пути монтирования для бэкапа перечисляются в `restic.backup.paths=<container-path>` (resticontainer сопоставляет их с путями на хосте). Сервисы без этих меток игнорируются. Сохраняйте только восстановимые данные и конфигурацию.
|
||||
- **Идентификаторы UID и GID**: переменные `${APEX_UID...}` берутся из файла `.env` проекта, который формирует действие `tiers/useradd`.
|
||||
- **Один якорь на проект**: контейнер-якорь получает постоянный `ipv4_address` в сети `direct`, а вспомогательные sidecar-контейнеры используют его сетевой стек через `network_mode: service:<anchor>`.
|
||||
- **Внешние сети подключаются по имени**: для сетей `direct`, `enclave` и `socket` указывается `external: true`, поскольку они объявляются в базовой композиции.
|
||||
- **Публикация через `enclave` и метки Traefik**: подключение к сети `enclave` вместе с метками маршрутизации открывает сервис через граничный прокси. Прокси связывается с ним через socket-прокси, без проброса портов наружу.
|
||||
- **Пути к данным привязаны к уровням**: пути вида `./@tierN/...` разрешаются через символические ссылки, которые создаёт действие `tiers/link`.
|
||||
- **Резервное копирование настраивается метками `restic.*`**: действие `apex backup/run` запускает утилиту [resticontainer](https://github.com/deytenit/resticontainer). Сервис помечается меткой `restic.enable=true`, а пути монтирования для бэкапа перечисляются в `restic.backup.paths=<container-path>` (resticontainer сопоставляет их с путями на хосте). Сервисы без этих меток игнорируются. Сохраняйте только восстановимые данные и конфигурацию.
|
||||
- **Идентификаторы UID и GID**: переменные `${APEX_UID...}` берутся из файла `.env` проекта, который формирует действие `tiers/useradd`.
|
||||
|
||||
### Настройте хранилище и права доступа
|
||||
### Настройте хранилище и права доступа
|
||||
|
||||
```bash
|
||||
apex tiers/link <tier1> <tier2> <tier3>
|
||||
@@ -67,7 +67,7 @@ apex tiers/useradd
|
||||
apex tiers/chown
|
||||
```
|
||||
|
||||
Повторный запуск этих команд идемпотентен: движок находит новый проект, создаёт для него каталоги хранения и файл `.env`, заводит системного пользователя `noroot-<project>` и выставляет права доступа.
|
||||
Повторный запуск этих команд идемпотентен: движок находит новый проект, создаёт для него каталоги хранения и файл `.env`, заводит системного пользователя `noroot-<project>` и выставляет права доступа.
|
||||
|
||||
### Проверьте конфигурацию
|
||||
|
||||
@@ -75,7 +75,7 @@ apex tiers/chown
|
||||
apex utils/lint-docker-compose
|
||||
```
|
||||
|
||||
Действие проверяет корректность, уникальность и принадлежность подсети для каждого якорного IP, после чего запускает проверку compose-файлов по всему репозиторию. Тот же линтинг автоматически выполняется хуком pre-commit для всех подготовленных к коммиту файлов.
|
||||
Действие проверяет корректность, уникальность и принадлежность подсети для каждого якорного IP, после чего запускает проверку compose-файлов по всему репозиторию. Тот же линтинг автоматически выполняется хуком pre-commit для всех подготовленных к коммиту файлов.
|
||||
|
||||
### Разверните сервисы
|
||||
|
||||
@@ -83,18 +83,18 @@ apex utils/lint-docker-compose
|
||||
apex compose up
|
||||
```
|
||||
|
||||
Базовая композиция уже работает, поэтому новый проект запустится в порядке своей очереди по алфавиту. Проверьте статус контейнеров через `docker ps`, а доступность маршрута — через граничный прокси.
|
||||
Базовая композиция уже работает, поэтому новый проект запустится в порядке своей очереди по алфавиту. Проверьте статус контейнеров через `docker ps`, а доступность маршрута — через граничный прокси.
|
||||
|
||||
{{% /steps %}}
|
||||
|
||||
## Заметки
|
||||
|
||||
- Секретные данные проекта (например, пароль к базе данных) хранятся в собственном `.env` на уровне 1 (@tier1), рядом с автоматически сгенерированным блоком UID.
|
||||
- Не объявляйте сети `direct`, `enclave` и `socket` заново внутри файла проекта: дублирование определений приведёт к конфликту с базовой композицией за управление сетью.
|
||||
- Секретные данные проекта (например, пароль к базе данных) хранятся в собственном `.env` на уровне 1 (@tier1), рядом с автоматически сгенерированным блоком UID.
|
||||
- Не объявляйте сети `direct`, `enclave` и `socket` заново внутри файла проекта: дублирование определений приведёт к конфликту с базовой композицией за управление сетью.
|
||||
|
||||
---
|
||||
|
||||
**См. также:**
|
||||
**См. также:**
|
||||
|
||||
- [Concept: Compositions and the shared core](/apex/concepts/compositions)
|
||||
- [Concept: Tiered storage and privilege isolation](/apex/concepts/tiered-storage)
|
||||
|
||||
@@ -18,37 +18,37 @@ weight: 32
|
||||
|
||||
`apex configure/base`
|
||||
|
||||
Проверяет, что операционная система — Debian (иначе завершается с кодом `1`). Обновляет индекс пакетов, устанавливает необходимые пакеты (`curl`, `wget`, `git`, `rsyslog`), проверяет статус службы `rsyslog`, устанавливает `ufw`, если его нет, и загружает вспомогательную утилиту `ufw-docker` в `~/.local/bin/ufw-docker` (если утилита уже есть, загрузка пропускается).
|
||||
Проверяет, что операционная система — Debian (иначе завершается с кодом `1`). Обновляет индекс пакетов, устанавливает необходимые пакеты (`curl`, `wget`, `git`, `rsyslog`), проверяет статус службы `rsyslog`, устанавливает `ufw`, если его нет, и загружает вспомогательную утилиту `ufw-docker` в `~/.local/bin/ufw-docker` (если утилита уже есть, загрузка пропускается).
|
||||
|
||||
### configure/ufw
|
||||
|
||||
`apex configure/ufw [--dry-run]`
|
||||
|
||||
Применяет правила межсетевого экрана узла из `configs/ufw/`: `host/host.rules` через штатный `ufw`, `host/before.rules` и `after.rules` как самовосстанавливающиеся блоки внутри `/etc/ufw/`, а `docker/*.rules` — через `ufw-docker`. Завершается с кодом `1`, если отсутствует корень конфигурации, с кодом `66` — если отсутствует ровно один из каталогов `docker/` или `host/`, и с кодом `1` — если целевой файл в `/etc/ufw` не существует. Требует предварительного выполнения `configure/base` (проверяет наличие `ufw` и `ufw-docker`). В конце выводит напоминание о необходимости перезагрузки правил.
|
||||
Применяет правила межсетевого экрана узла из `configs/ufw/`: `host/host.rules` через штатный `ufw`, `host/before.rules` и `after.rules` как самовосстанавливающиеся блоки внутри `/etc/ufw/`, а `docker/*.rules` — через `ufw-docker`. Завершается с кодом `1`, если отсутствует корень конфигурации, с кодом `66` — если отсутствует ровно один из каталогов `docker/` или `host/`, и с кодом `1` — если целевой файл в `/etc/ufw` не существует. Требует предварительного выполнения `configure/base` (проверяет наличие `ufw` и `ufw-docker`). В конце выводит напоминание о необходимости перезагрузки правил.
|
||||
|
||||
### configure/crowdsec
|
||||
|
||||
`apex configure/crowdsec [--dry-run]`
|
||||
|
||||
Устанавливает CrowdSec через официальный скрипт, если `cscli` отсутствует. Рендерит шаблоны из `configs/crowdsec/` в `/etc/crowdsec/` (создавая резервные копии перезаписываемых файлов), устанавливает коллекции `crowdsecurity/linux` и `crowdsecurity/traefik`, а также устанавливает firewall-баунсер iptables, если файл `configs/crowdsec/bouncers/crowdsec-firewall-bouncer.yaml.local` существует, а пакет ещё не установлен. Флаг `--dry-run` выполняет рендеринг без записи на диск и пропускает установку.
|
||||
Устанавливает CrowdSec через официальный скрипт, если `cscli` отсутствует. Рендерит шаблоны из `configs/crowdsec/` в `/etc/crowdsec/` (создавая резервные копии перезаписываемых файлов), устанавливает коллекции `crowdsecurity/linux` и `crowdsecurity/traefik`, а также устанавливает firewall-баунсер iptables, если файл `configs/crowdsec/bouncers/crowdsec-firewall-bouncer.yaml.local` существует, а пакет ещё не установлен. Флаг `--dry-run` выполняет рендеринг без записи на диск и пропускает установку.
|
||||
|
||||
### configure/cron
|
||||
|
||||
`apex configure/cron [--dry-run]`
|
||||
|
||||
Рендерит `configs/cron/crontab` с помощью `ctx.vars()` и устанавливает полученное расписание через `crontab -`. Если исходный файл отсутствует, действие завершается с кодом `1`. Если результат рендеринга пуст, возвращается код `65`, а предыдущий crontab сохраняется. При сбое установки восстанавливается прежний crontab и возвращается код `1`. Результат рендеринга всегда выводится для проверки.
|
||||
Рендерит `configs/cron/crontab` с помощью `ctx.vars()` и устанавливает полученное расписание через `crontab -`. Если исходный файл отсутствует, действие завершается с кодом `1`. Если результат рендеринга пуст, возвращается код `65`, а предыдущий crontab сохраняется. При сбое установки восстанавливается прежний crontab и возвращается код `1`. Результат рендеринга всегда выводится для проверки.
|
||||
|
||||
### configure/systemd
|
||||
|
||||
`apex configure/systemd [--dry-run]`
|
||||
|
||||
Рендерит каждый открытый файл (кроме скрытых) верхнего уровня из `configs/systemd/` в `/etc/systemd/system/<name>`. Для каждого сервиса `.service` выполняет daemon-reload и активирует его командой с флагом `--now`. Если каталог конфигурации отсутствует, завершается с кодом `1`.
|
||||
Рендерит каждый открытый файл (кроме скрытых) верхнего уровня из `configs/systemd/` в `/etc/systemd/system/<name>`. Для каждого сервиса `.service` выполняет daemon-reload и активирует его командой с флагом `--now`. Если каталог конфигурации отсутствует, завершается с кодом `1`.
|
||||
|
||||
### configure/routing
|
||||
|
||||
`apex configure/routing`
|
||||
|
||||
Включает `net.ipv4.ip_forward`, настраивает правило политики fwmark `0x1` → routing-table-100 и локальный маршрут по умолчанию в этой таблице. Обе операции идемпотентны: повторные запуски ничего не меняют.
|
||||
Включает `net.ipv4.ip_forward`, настраивает правило политики fwmark `0x1` → routing-table-100 и локальный маршрут по умолчанию в этой таблице. Обе операции идемпотентны: повторные запуски ничего не меняют.
|
||||
|
||||
## sync {#sync}
|
||||
|
||||
@@ -56,13 +56,13 @@ weight: 32
|
||||
|
||||
`apex sync/repository <telegram_bot_url>`
|
||||
|
||||
Коммит [«capture-up»](/apex/glossary#capture-up) (фиксация локальных изменений): переключается на ветку `sync/<node>` (или создаёт её), индексирует всё дерево вместе с обновлённым указателем на подмодуль commons, создаёт коммит от имени бота (если есть изменения), выполняет rebase на `origin/main`, обновляет подмодули и отправляет изменения командой с флагом `--force-with-lease`. Прерывается с кодом `1`, если подмодуль commons содержит незакоммиченные правки или отсутствует каталог `compositions/`. При конфликте во время rebase операция отменяется и отправляется оповещение в Telegram. Об успехе и каждом сбое отправляется уведомление; сбой при отправке уведомления об успехе также завершает команду с кодом `1`.
|
||||
Коммит [«capture-up»](/apex/glossary#capture-up) (фиксация локальных изменений): переключается на ветку `sync/<node>` (или создаёт её), индексирует всё дерево вместе с обновлённым указателем на подмодуль commons, создаёт коммит от имени бота (если есть изменения), выполняет rebase на `origin/main`, обновляет подмодули и отправляет изменения командой с флагом `--force-with-lease`. Прерывается с кодом `1`, если подмодуль commons содержит незакоммиченные правки или отсутствует каталог `compositions/`. При конфликте во время rebase операция отменяется и отправляется оповещение в Telegram. Об успехе и каждом сбое отправляется уведомление; сбой при отправке уведомления об успехе также завершает команду с кодом `1`.
|
||||
|
||||
### sync/packages
|
||||
|
||||
`apex sync/packages <telegram_bot_url>`
|
||||
|
||||
Только для Debian. Собирает список доступных обновлений apt (`--just-print`) и устаревших образов запущенных контейнеров (сравнивая локальный RepoDigest с дайджестом в реестре через `skopeo`), после чего отправляет единую сводку в Telegram. Работает исключительно в режиме чтения: пакеты и образы не изменяются. При необработанных сбоях отправляет оповещение в Telegram и завершается с кодом `1`; так же действие ведёт себя и при ошибке доставки сводки.
|
||||
Только для Debian. Собирает список доступных обновлений apt (`--just-print`) и устаревших образов запущенных контейнеров (сравнивая локальный RepoDigest с дайджестом в реестре через `skopeo`), после чего отправляет единую сводку в Telegram. Работает исключительно в режиме чтения: пакеты и образы не изменяются. При необработанных сбоях отправляет оповещение в Telegram и завершается с кодом `1`; так же действие ведёт себя и при ошибке доставки сводки.
|
||||
|
||||
## backup {#backup}
|
||||
|
||||
@@ -70,7 +70,7 @@ weight: 32
|
||||
|
||||
`apex backup/run <telegram_bot_url>`
|
||||
|
||||
Управляет однократным запуском основного сервиса `resticontainer`, работающего по меткам (профиль `manual`). Проверяет доступность репозитория (при необходимости выполняет инициализацию), затем запускает процедуру `backup`. При этом resticontainer находит каждый сервис с метками compose вида `restic.*`, вызывает их предварительные хуки, останавливает контейнеры с меткой `restic.backup.stop`, сопоставляет пути монтирования контейнеров с путями на хосте и создаёт единый снимок за один вызов restic. Снимок помечается флагами `--host <APEX_NODE_HOST>`, `--tag biweekly`, `--compression max`. Затем защитная проверка удостоверяется, что свежий снимок для этого хоста действительно создан: если метки `restic.*` не были найдены, резервное копирование завершилось бы вхолостую, поэтому действие откажется сообщать об успехе. Следом применяется политика хранения `forget --prune` (7 daily / 4 weekly / 12 monthly). При любом сбое restic отправляет оповещение и завершается с кодом `1`. При успешном завершении также отправляется оповещение, а ошибка его доставки приводит к выходу с кодом `1`.
|
||||
Управляет однократным запуском основного сервиса `resticontainer`, работающего по меткам (профиль `manual`). Проверяет доступность репозитория (при необходимости выполняет инициализацию), затем запускает процедуру `backup`. При этом resticontainer находит каждый сервис с метками compose вида `restic.*`, вызывает их предварительные хуки, останавливает контейнеры с меткой `restic.backup.stop`, сопоставляет пути монтирования контейнеров с путями на хосте и создаёт единый снимок за один вызов restic. Снимок помечается флагами `--host <APEX_NODE_HOST>`, `--tag biweekly`, `--compression max`. Затем защитная проверка удостоверяется, что свежий снимок для этого хоста действительно создан: если метки `restic.*` не были найдены, резервное копирование завершилось бы вхолостую, поэтому действие откажется сообщать об успехе. Следом применяется политика хранения `forget --prune` (7 daily / 4 weekly / 12 monthly). При любом сбое restic отправляет оповещение и завершается с кодом `1`. При успешном завершении также отправляется оповещение, а ошибка его доставки приводит к выходу с кодом `1`.
|
||||
|
||||
## tiers {#tiers}
|
||||
|
||||
@@ -78,13 +78,13 @@ weight: 32
|
||||
|
||||
`apex tiers/link <tier1_path> <tier2_path> <tier3_path>`
|
||||
|
||||
Создаёт полную структуру символьных ссылок для [уровней хранения](/apex/glossary#tier): ссылки уровня узла `compositions/@tierN`, каталоги уровней для каждого проекта, общие области `shared/` с обратными ссылками, проектные ссылки `@tierN` и символьную ссылку `.env` на первый уровень (если файл назначения отсутствует, он создаётся). Если существующий путь не является символьной ссылкой, действие запрашивает подтверждение; отказ пропускает создание ссылки.
|
||||
Создаёт полную структуру символьных ссылок для [уровней хранения](/apex/glossary#tier): ссылки уровня узла `compositions/@tierN`, каталоги уровней для каждого проекта, общие области `shared/` с обратными ссылками, проектные ссылки `@tierN` и символьную ссылку `.env` на первый уровень (если файл назначения отсутствует, он создаётся). Если существующий путь не является символьной ссылкой, действие запрашивает подтверждение; отказ пропускает создание ссылки.
|
||||
|
||||
### tiers/useradd
|
||||
|
||||
`apex tiers/useradd`
|
||||
|
||||
Проверяет наличие группы `noroot-shared`, затем создаёт по одному заблокированному системному пользователю `noroot-<project>` для каждого проекта композиций (собственная основная группа, без домашнего каталога, оболочка `nologin`, участник общей группы) и прописывает переменные `APEX_UID`/`APEX_GID`/`APEX_SHARED_GID` в `.env` каждого проекта между служебными маркерами.
|
||||
Проверяет наличие группы `noroot-shared`, затем создаёт по одному заблокированному системному пользователю `noroot-<project>` для каждого проекта композиций (собственная основная группа, без домашнего каталога, оболочка `nologin`, участник общей группы) и прописывает переменные `APEX_UID`/`APEX_GID`/`APEX_SHARED_GID` в `.env` каждого проекта между служебными маркерами.
|
||||
|
||||
### tiers/chown
|
||||
|
||||
@@ -98,7 +98,7 @@ weight: 32
|
||||
|
||||
`apex utils/extract-traefik-certs <source_acme> <dest_dir> [telegram_bot_url]`
|
||||
|
||||
Извлекает сертификаты из хранилища ACME в файлы `fullchain.pem`/`privkey.pem` для каждого домена с помощью контейнера-дампера, выставляет права `chmod 600` на ключи и передаёт права владения выходным каталогом вызывающему пользователю. Если исходный файл отсутствует, действие завершается с кодом `1`. При ошибках извлечения отправляет оповещение в Telegram (если указан URL).
|
||||
Извлекает сертификаты из хранилища ACME в файлы `fullchain.pem`/`privkey.pem` для каждого домена с помощью контейнера-дампера, выставляет права `chmod 600` на ключи и передаёт права владения выходным каталогом вызывающему пользователю. Если исходный файл отсутствует, действие завершается с кодом `1`. При ошибках извлечения отправляет оповещение в Telegram (если указан URL).
|
||||
|
||||
### utils/lint-docker-compose
|
||||
|
||||
@@ -112,7 +112,7 @@ weight: 32
|
||||
|
||||
`apex compose <action> [--dry-run] [extra...]`
|
||||
|
||||
Оркеструет запуск `docker compose <action>` по всему узлу: сначала основной проект, затем сервисы в алфавитном порядке (в обратном — для команды `down`). Команда `up` по умолчанию использует ключ `-d`; файлы `.env` и `apex.env` подключаются для каждого проекта при их наличии; дополнительные аргументы пробрасываются дальше; флаг `--dry-run` (принимается в любой позиции) выводит сформированные команды без их выполнения.
|
||||
Оркеструет запуск `docker compose <action>` по всему узлу: сначала основной проект, затем сервисы в алфавитном порядке (в обратном — для команды `down`). Команда `up` по умолчанию использует ключ `-d`; файлы `.env` и `apex.env` подключаются для каждого проекта при их наличии; дополнительные аргументы пробрасываются дальше; флаг `--dry-run` (принимается в любой позиции) выводит сформированные команды без их выполнения.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -4,38 +4,38 @@ description: "Лаунчер apex: перечисление, диспетчер
|
||||
weight: 31
|
||||
---
|
||||
|
||||
Лаунчер — это скрипт на Python из стандартной библиотеки, `commons/apex` (попадает в `PATH` после выполнения `init.sh`). Все операции выполняются над локальным checkout: аргумента узла не существует.
|
||||
Лаунчер — это скрипт на Python из стандартной библиотеки, `commons/apex` (попадает в `PATH` после выполнения `init.sh`). Все операции выполняются над локальным checkout: аргумента узла не существует.
|
||||
|
||||
## Формы вызова {#invocation}
|
||||
|
||||
| Команда | Поведение |
|
||||
|---|---|
|
||||
| `apex` или `apex --help` | Перечислить каждое доступное [действие](/apex/glossary#action) с его кратким описанием; выход с кодом `0`. |
|
||||
| `apex` или `apex --help` | Перечислить каждое доступное [действие](/apex/glossary#action) с его кратким описанием; выход с кодом `0`. |
|
||||
| `apex <group>/<action> [args...]` | Выполнить одно действие. |
|
||||
| `apex <group>/<action> --help` | Вывести справку argparse для действия, сгенерированную из `METADATA`; выход с кодом `0`. |
|
||||
| `apex <group>/<action> --help` | Вывести справку argparse для действия, сгенерированную из `METADATA`; выход с кодом `0`. |
|
||||
|
||||
Перечисление помечает каждую запись источником [оверлея](/apex/glossary#overlay) — без метки (commons), `(local)`, `(local override)` — и добавляет `[DISABLED: reason]`, если модуль объявляет `DISABLED`. Перечисление использует статическое сканирование через `ast`, поэтому код самих действий не выполняется.
|
||||
Перечисление помечает каждую запись источником [оверлея](/apex/glossary#overlay) — без метки (commons), `(local)`, `(local override)` — и добавляет `[DISABLED: reason]`, если модуль объявляет `DISABLED`. Перечисление использует статическое сканирование через `ast`, поэтому код самих действий не выполняется.
|
||||
|
||||
## Раскладка и разрешение путей {#layout}
|
||||
|
||||
При запуске лаунчер определяет, в какой из двух раскладок он находится:
|
||||
При запуске лаунчер определяет, в какой из двух раскладок он находится:
|
||||
|
||||
- **Checkout узла** — лаунчер находится в каталоге `commons/`, родительский каталог которого содержит `node.env`. Родительский каталог служит корнем репозитория; `proprietaries/actions/` — дерево оверлея; [идентичность узла](/apex/glossary#node-identity) разрешается перед диспетчеризацией, а любые предупреждения об идентичности выводятся первыми в логе.
|
||||
- **Checkout узла** — лаунчер находится в каталоге `commons/`, родительский каталог которого содержит `node.env`. Родительский каталог служит корнем репозитория; `proprietaries/actions/` — дерево оверлея; [идентичность узла](/apex/glossary#node-identity) разрешается перед диспетчеризацией, а любые предупреждения об идентичности выводятся первыми в логе.
|
||||
- **Автономный commons** — любое другое расположение. Каталог commons служит собственным корнем, оверлей отсутствует, а `ctx.node` равен `None` (строки лога содержат префикс `global`).
|
||||
|
||||
`sys.path` формируется следующим образом: каталог commons (позволяет импортировать `engine`), затем `commons/vendor/` при наличии и `../proprietaries/` в checkout узла (позволяет импортировать проприетарный код из `lib/`).
|
||||
`sys.path` формируется следующим образом: каталог commons (позволяет импортировать `engine`), затем `commons/vendor/` при наличии и `../proprietaries/` в checkout узла (позволяет импортировать проприетарный код из `lib/`).
|
||||
|
||||
## Коды возврата {#exit-codes}
|
||||
|
||||
| Код | Источник |
|
||||
|---|---|
|
||||
| `0` | Действие вернуло `0` или нецелое значение; вызовы `--help`. |
|
||||
| `1` | Неизвестное действие; отключённое действие; необработанное исключение (выводится в лог как `Type: message` с префиксом контекста); нецелый `SystemExit`. |
|
||||
| `64` | Ошибка использования при разборе аргументов (`EX_USAGE`). |
|
||||
| `0` | Действие вернуло `0` или нецелое значение; вызовы `--help`. |
|
||||
| `1` | Неизвестное действие; отключённое действие; необработанное исключение (выводится в лог как `Type: message` с префиксом контекста); нецелый `SystemExit`. |
|
||||
| `64` | Ошибка использования при разборе аргументов (`EX_USAGE`). |
|
||||
| `65` | Ошибка данных, вызванная библиотеками движка (`EX_DATAERR`) — например, пустой результат рендеринга crontab. |
|
||||
| `66` | Неполный или отсутствующий ввод (`EX_NOINPUT`) — например, не заданы поля идентичности или отсутствует каталог конфигурации ufw. |
|
||||
| `66` | Неполный или отсутствующий ввод (`EX_NOINPUT`) — например, не заданы поля идентичности или отсутствует каталог конфигурации ufw. |
|
||||
|
||||
Любой другой код — это значение, которое вернуло или вызвало само действие.
|
||||
Любой другой код — это значение, которое вернуло или вызвало само действие.
|
||||
|
||||
## Формат лога {#log-format}
|
||||
|
||||
@@ -45,7 +45,7 @@ weight: 31
|
||||
[<node>] [<group>/<action>] [LEVEL] message
|
||||
```
|
||||
|
||||
`LEVEL` — это `INFO`, `WARN`, `ERROR` или `SUCCESS`. Строки `ERROR` выводятся в stderr, а остальные — в stdout. Такой формат удобен для cron: упрощает сбор логов и отправку оповещений.
|
||||
`LEVEL` — это `INFO`, `WARN`, `ERROR` или `SUCCESS`. Строки `ERROR` выводятся в stderr, а остальные — в stdout. Такой формат удобен для cron: упрощает сбор логов и отправку оповещений.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ description: API дескрипторов и ctx, доступные автор
|
||||
weight: 34
|
||||
---
|
||||
|
||||
Инструменты для разработки [действий](/apex/glossary#action): дескрипторы аргументов в `engine.descriptor` и методы контекста выполнения. Концептуальные основы описаны в разделе [Концепция: Контекст выполнения](/apex/concepts/execution-context).
|
||||
Инструменты для разработки [действий](/apex/glossary#action): дескрипторы аргументов в `engine.descriptor` и методы контекста выполнения. Концептуальные основы описаны в разделе [Концепция: Контекст выполнения](/apex/concepts/execution-context).
|
||||
|
||||
## Дескрипторы {#descriptors}
|
||||
|
||||
@@ -14,16 +14,16 @@ from engine.descriptor import Meta, Arg, Opt, Flag, Rest
|
||||
|
||||
| Класс | Сигнатура | Сопоставление с argparse |
|
||||
|---|---|---|
|
||||
| `Meta` | `Meta(summary, args=[])` | Управляет как кратким описанием в перечислении, так и парсером. |
|
||||
| `Meta` | `Meta(summary, args=[])` | Управляет как кратким описанием в перечислении, так и парсером. |
|
||||
| `Arg` | `Arg(name, help="", required=True)` | Позиционный аргумент; `required=False` → `nargs="?"`, по умолчанию `None`. |
|
||||
| `Opt` | `Opt(name, help="", default=None)` | Параметр со значением (`--name VALUE`) и значением по умолчанию. |
|
||||
| `Flag` | `Flag(name, help="")` | Логический флаг `store_true`. |
|
||||
| `Rest` | `Rest(name, help="")` | Жадный остаток аргументов (`argparse.REMAINDER`). |
|
||||
|
||||
Ошибки использования приводят к выходу с кодом `64` вместо стандартного для argparse кода `2`.
|
||||
Ошибки использования приводят к выходу с кодом `64` вместо стандартного для argparse кода `2`.
|
||||
|
||||
> [!WARNING]
|
||||
> `Rest` захватывает *всё* после первого позиционного аргумента — включая флаги. `Flag`, объявленный рядом с `Rest`, распознаётся только *перед* позиционным аргументом; действие, которое должно принимать свой флаг в любом месте, обязано само извлечь его из остатка (действие `compose` делает именно так для `--dry-run`).
|
||||
> `Rest` захватывает *всё* после первого позиционного аргумента — включая флаги. `Flag`, объявленный рядом с `Rest`, распознаётся только *перед* позиционным аргументом; действие, которое должно принимать свой флаг в любом месте, обязано само извлечь его из остатка (действие `compose` делает именно так для `--dry-run`).
|
||||
|
||||
## ctx.log {#log}
|
||||
|
||||
@@ -33,31 +33,31 @@ from engine.descriptor import Meta, Arg, Opt, Flag, Rest
|
||||
|
||||
| Метод | Поведение |
|
||||
|---|---|
|
||||
| `run(cmd, check=True, capture=False, cwd=None, env=None, input=None)` | Обёртка над `subprocess.run`, возвращающая `CompletedProcess`; `capture=True` собирает текстовые потоки stdout/stderr. |
|
||||
| `run(cmd, check=True, capture=False, cwd=None, env=None, input=None)` | Обёртка над `subprocess.run`, возвращающая `CompletedProcess`; `capture=True` собирает текстовые потоки stdout/stderr. |
|
||||
| `sudo(cmd, **kw)` | `run(["sudo", *cmd])`. |
|
||||
| `ok(cmd, **kw)` | `True` тогда и только тогда, когда код возврата `0`; никогда не вызывает исключений. |
|
||||
| `service_exists(name)` / `service_is_active(name)` / `service_is_enabled(name)` | Запросы к юнитам systemd. |
|
||||
| `start_and_enable(name)` | Запуск и включение при необходимости; возвращает итоговое состояние активности. |
|
||||
| `start_and_enable(name)` | Запуск и включение при необходимости; возвращает итоговое состояние активности. |
|
||||
| `ensure_running(name)` | Выполняет `enable --now`, если сервис неактивен. |
|
||||
| `restart(name)` / `reload(name, dry_run=False)` / `daemon_reload()` | Управление жизненным циклом сервиса. |
|
||||
| `wait_for(name, timeout=30, interval=2)` | Опрашивает статус, пока сервис не станет активным. |
|
||||
| `install_packages(packages)` | Для каждого пакета: пропускает, если бинарный файл уже есть в `PATH`, иначе выполняет `apt-get update -y` и `apt-get install -y`. |
|
||||
| `install_packages(packages)` | Для каждого пакета: пропускает, если бинарный файл уже есть в `PATH`, иначе выполняет `apt-get update -y` и `apt-get install -y`. |
|
||||
|
||||
## ctx.tpl {#tpl}
|
||||
|
||||
| Метод | Поведение |
|
||||
|---|---|
|
||||
| `render(path, vars)` | Рендерит один файл (относительные пути отсчитываются от `configs/`); семантика `safe_substitute` — неизвестные переменные сохраняются как есть. |
|
||||
| `render(path, vars)` | Рендерит один файл (относительные пути отсчитываются от `configs/`); семантика `safe_substitute` — неизвестные переменные сохраняются как есть. |
|
||||
| `render_dir(rel_dir, vars)` | Рендерит каждый файл в поддереве `configs/`; возвращает `{relative_path: content}`. |
|
||||
|
||||
## ctx.host {#host}
|
||||
|
||||
| Метод | Поведение |
|
||||
|---|---|
|
||||
| `backup(path, sudo=False)` | Копирует файл в `<path>.<epoch>.bak`; возвращает путь к резервной копии или `None`. |
|
||||
| `write_file(path, content, backup=True, dry_run=False, sudo=False)` | Атомарная запись через временный файл (права `0644`); опциональное создание резервной копии; при `sudo=True` сохраняются права целевого файла. |
|
||||
| `backup(path, sudo=False)` | Копирует файл в `<path>.<epoch>.bak`; возвращает путь к резервной копии или `None`. |
|
||||
| `write_file(path, content, backup=True, dry_run=False, sudo=False)` | Атомарная запись через временный файл (права `0644`); опциональное создание резервной копии; при `sudo=True` сохраняются права целевого файла. |
|
||||
| `inject_block(target, content, start, end, anchor=None, old_pairs=(), dry_run=False, sudo=True)` | Заменяет управляемый блок по маркерам: удаляет текущие и устаревшие блоки, вставляет новый блок после первой строки-якоря. Возвращает `False`, если якорь отсутствует. Если целевой файл не найден, завершается с ошибкой. |
|
||||
| `install_crontab(text, dry_run=False)` | Создаёт резервную копию crontab, завершается с ошибкой при пустом результате рендеринга (`SystemExit(65)`), выводит предпросмотр, устанавливает crontab через `crontab -` и восстанавливает прежнюю версию при сбое (`SystemExit(1)`). |
|
||||
| `install_crontab(text, dry_run=False)` | Создаёт резервную копию crontab, завершается с ошибкой при пустом результате рендеринга (`SystemExit(65)`), выводит предпросмотр, устанавливает crontab через `crontab -` и восстанавливает прежнюю версию при сбое (`SystemExit(1)`). |
|
||||
|
||||
## ctx.ufw {#ufw}
|
||||
|
||||
@@ -69,15 +69,15 @@ from engine.descriptor import Meta, Arg, Opt, Flag, Rest
|
||||
|
||||
## ctx.systemd {#systemd}
|
||||
|
||||
`deploy_units(configs_subdir, vars, dry_run=False)` — рендерит файлы верхнего уровня (не скрытые) из подкаталога в `/etc/systemd/system/`, затем выполняет daemon-reload и `enable --now` для каждого сервиса `.service`.
|
||||
`deploy_units(configs_subdir, vars, dry_run=False)` — рендерит файлы верхнего уровня (не скрытые) из подкаталога в `/etc/systemd/system/`, затем выполняет daemon-reload и `enable --now` для каждого сервиса `.service`.
|
||||
|
||||
## ctx.notify {#notify}
|
||||
|
||||
`telegram(title, bot_url, message, node=None, level="INFO")` плюс вспомогательные методы `success` / `error` / `info` / `warn`. Все методы возвращают `bool`: `True` — только если сообщение доставлено. При пустом `bot_url` выводится предупреждение в лог и возвращается `False`. Поле `instance` в оповещении по умолчанию содержит публичный хост узла (`APEX_NODE_HOST`), префикс домена или узла к нему не добавляется.
|
||||
`telegram(title, bot_url, message, node=None, level="INFO")` плюс вспомогательные методы `success` / `error` / `info` / `warn`. Все методы возвращают `bool`: `True` — только если сообщение доставлено. При пустом `bot_url` выводится предупреждение в лог и возвращается `False`. Поле `instance` в оповещении по умолчанию содержит публичный хост узла (`APEX_NODE_HOST`), префикс домена или узла к нему не добавляется.
|
||||
|
||||
## ctx.paths и ctx.node {#paths}
|
||||
|
||||
`ctx.paths`: `repo_root`, `commons`, `proprietaries`, `configs`, `compositions`, `core`, `tier1..3`, `stier1..3`, `root_tier1..3`. `ctx.node`: запись идентичности (`name`, `subnet`, `fqdn`, `host`) или `None` в checkout автономного commons. `ctx.vars()`: окружение шаблонов/compose (см. [Справочник: Переменные окружения](/apex/reference/environment)). `ctx.commons.run(args)`: делегирование затенённому действию commons — допустимо только в переопределяющем проприетарном модуле.
|
||||
`ctx.paths`: `repo_root`, `commons`, `proprietaries`, `configs`, `compositions`, `core`, `tier1..3`, `stier1..3`, `root_tier1..3`. `ctx.node`: запись идентичности (`name`, `subnet`, `fqdn`, `host`) или `None` в checkout автономного commons. `ctx.vars()`: окружение шаблонов/compose (см. [Справочник: Переменные окружения](/apex/reference/environment)). `ctx.commons.run(args)`: делегирование затенённому действию commons — допустимо только в переопределяющем проприетарном модуле.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -4,70 +4,70 @@ description: "Переменные окружения APEX_*: источники
|
||||
weight: 33
|
||||
---
|
||||
|
||||
Пространство имён `APEX_*` охватывает всю конфигурацию фреймворка. Переменные формируются на четырёх уровнях: при сборке окружения процесса действиями оркестрации значения из более поздних слоёв переопределяют предыдущие.
|
||||
Пространство имён `APEX_*` охватывает всю конфигурацию фреймворка. Переменные формируются на четырёх уровнях: при сборке окружения процесса действиями оркестрации значения из более поздних слоёв переопределяют предыдущие.
|
||||
|
||||
## node.env — идентичность {#node-env}
|
||||
## node.env — идентичность {#node-env}
|
||||
|
||||
Закоммичен в корне репозитория; считывается при разрешении [идентичности узла](/apex/glossary#node-identity). Фреймворк не привязан к доменной структуре: узел объявляет собственный FQDN и публичное имя хоста. Концепция кластера отсутствует, а формат FQDN ничем не ограничен.
|
||||
Закоммичен в корне репозитория; считывается при разрешении [идентичности узла](/apex/glossary#node-identity). Фреймворк не привязан к доменной структуре: узел объявляет собственный FQDN и публичное имя хоста. Концепция кластера отсутствует, а формат FQDN ничем не ограничен.
|
||||
|
||||
| Переменная | Значение | Примечания |
|
||||
|---|---|---|
|
||||
| `APEX_NODE_FQDN` | Действительный FQDN узла (маршрутизация Traefik `Host()`) | Если не задан, используется имя хоста ОС (с выводом предупреждения). |
|
||||
| `APEX_NODE_HOST` | Публичное имя хоста узла (резервное копирование `--host`, `instance` для наблюдаемости/уведомлений) | По умолчанию совпадает с `APEX_NODE_FQDN`. Первая часть доменного имени используется как короткое имя `name`. |
|
||||
| `APEX_SUBNET` | Сеть /24 узла для сети `direct` | Обязательна, единственный источник подсети. Если не задана, запуск завершается с кодом `66`. |
|
||||
| `APEX_GIT_AUTHOR_NAME` | Имя автора для коммитов capture-up | По умолчанию — `apex [bot]`. |
|
||||
| `APEX_GIT_AUTHOR_EMAIL` | Email автора для коммитов capture-up | По умолчанию — `apex@localhost`. |
|
||||
| `APEX_NODE_HOST` | Публичное имя хоста узла (резервное копирование `--host`, `instance` для наблюдаемости/уведомлений) | По умолчанию совпадает с `APEX_NODE_FQDN`. Первая часть доменного имени используется как короткое имя `name`. |
|
||||
| `APEX_SUBNET` | Сеть /24 узла для сети `direct` | Обязательна, единственный источник подсети. Если не задана, запуск завершается с кодом `66`. |
|
||||
| `APEX_GIT_AUTHOR_NAME` | Имя автора для коммитов capture-up | По умолчанию — `apex [bot]`. |
|
||||
| `APEX_GIT_AUTHOR_EMAIL` | Email автора для коммитов capture-up | По умолчанию — `apex@localhost`. |
|
||||
|
||||
## apex.env — закоммиченные скаляры {#apex-env}
|
||||
## apex.env — закоммиченные скаляры {#apex-env}
|
||||
|
||||
Закоммичен в `compositions/apex/`; не содержит секретных данных.
|
||||
|
||||
| Переменная | Значение | Примечания |
|
||||
|---|---|---|
|
||||
| `APEX_TRAEFIK_DIRECT_IP` | [Якорный IP](/apex/glossary#anchor-ip) краевого прокси в подсети узла | Обязательна для основной композиции (`?error`). |
|
||||
| `APEX_TRAEFIK_DIRECT_IP` | [Якорный IP](/apex/glossary#anchor-ip) краевого прокси в подсети узла | Обязательна для основной композиции (`?error`). |
|
||||
| `APEX_RESTIC_REPOSITORY` | Локатор репозитория резервных копий | Обязательна (`?error`). |
|
||||
| `APEX_RESTIC_COMPRESSION` | Уровень сжатия restic | Опциональна; по умолчанию `max` (стандарт флота: `backup/run` также принудительно передаёт `--compression max`). |
|
||||
| `APEX_RESTIC_AWS_REGION` | Регион бэкенда S3 | Опциональна; по умолчанию `us-east-1`. |
|
||||
| `APEX_RESTIC_AWS_ENDPOINT` | URL эндпоинта бэкенда S3 | Опциональна; по умолчанию не задан (AWS S3). Задаётся для любого S3-совместимого хранилища сторонних провайдеров. |
|
||||
| `APEX_LOKI_PUSH_URL` | URL приёма Loki для агента [Alloy](/apex/concepts/architecture) (`loki.write`) | Требуется для профиля `alloy` (`?error`). |
|
||||
| `APEX_PROM_PUSH_URL` | URL `remote_write` Prometheus для агента Alloy | Требуется для профиля `alloy` (`?error`). |
|
||||
| `APEX_OBS_BASIC_AUTH_USER` | Пользователь basic-auth для отправки метрик наблюдаемости | Требуется для профиля `alloy` (`?error`). |
|
||||
| `COMPOSE_PROFILES` | Подключаемые основные профили (`adguard`, `xray`, `alloy`) | Может быть пустой; профиль `manual` активируется через `backup/run`, а не здесь. |
|
||||
| `APEX_RESTIC_AWS_ENDPOINT` | URL эндпоинта бэкенда S3 | Опциональна; по умолчанию не задан (AWS S3). Задаётся для любого S3-совместимого хранилища сторонних провайдеров. |
|
||||
| `APEX_LOKI_PUSH_URL` | URL приёма Loki для агента [Alloy](/apex/concepts/architecture) (`loki.write`) | Требуется для профиля `alloy` (`?error`). |
|
||||
| `APEX_PROM_PUSH_URL` | URL `remote_write` Prometheus для агента Alloy | Требуется для профиля `alloy` (`?error`). |
|
||||
| `APEX_OBS_BASIC_AUTH_USER` | Пользователь basic-auth для отправки метрик наблюдаемости | Требуется для профиля `alloy` (`?error`). |
|
||||
| `COMPOSE_PROFILES` | Подключаемые основные профили (`adguard`, `xray`, `alloy`) | Может быть пустой; профиль `manual` активируется через `backup/run`, а не здесь. |
|
||||
| `TZ` | Часовой пояс контейнера | Опциональна; по умолчанию `Etc/UTC`. |
|
||||
|
||||
## .env — секреты {#secrets}
|
||||
## .env — секреты {#secrets}
|
||||
|
||||
Не коммитится в репозиторий; представляет собой символьную ссылку в хранилище первого уровня. Список ключей каждого узла задокументирован в `SECRETS.md`.
|
||||
|
||||
| Переменная | Значение | Примечания |
|
||||
|---|---|---|
|
||||
| `APEX_UID`, `APEX_GID`, `APEX_SHARED_GID` | Идентификаторы пользователя noroot для проекта | Внедряются автоматически через `tiers/useradd`; обязательны (`?error`). |
|
||||
| `APEX_UID`, `APEX_GID`, `APEX_SHARED_GID` | Идентификаторы пользователя noroot для проекта | Внедряются автоматически через `tiers/useradd`; обязательны (`?error`). |
|
||||
| `APEX_RESTIC_PASSWORD` | Пароль репозитория резервных копий | Обязательна (`?error`). |
|
||||
| `APEX_RESTIC_AWS_ACCESS_KEY_ID`, `APEX_RESTIC_AWS_SECRET_ACCESS_KEY` | Учётные данные объектного хранилища | Обязательны (`?error`). |
|
||||
| `APEX_TRAEFIK_CF_DNS_API_TOKEN` | Токен DNS-challenge для ACME | Обязательна (`?error`). |
|
||||
| `APEX_TRAEFIK_CF_DNS_API_TOKEN` | Токен DNS-challenge для ACME | Обязательна (`?error`). |
|
||||
| `APEX_TRAEFIK_ACME_EMAIL` | Email учётной записи ACME | Обязательна (`?error`). |
|
||||
| `APEX_OBS_BASIC_AUTH_PASS` | Пароль basic-auth для отправки метрик и логов наблюдаемости | Требуется для профиля `alloy` (`?error`). |
|
||||
| `APEX_LOKI_BASIC_AUTH` | Пользователи htpasswd для центрального шлюза Loki/Prometheus | Не требуется для основной композиции. Задаётся только на узле, принимающем данные наблюдаемости. |
|
||||
| `APEX_OBS_BASIC_AUTH_PASS` | Пароль basic-auth для отправки метрик и логов наблюдаемости | Требуется для профиля `alloy` (`?error`). |
|
||||
| `APEX_LOKI_BASIC_AUTH` | Пользователи htpasswd для центрального шлюза Loki/Prometheus | Не требуется для основной композиции. Задаётся только на узле, принимающем данные наблюдаемости. |
|
||||
|
||||
## Экспорты движка — ctx.vars() {#engine}
|
||||
## Экспорты движка — ctx.vars() {#engine}
|
||||
|
||||
Вычисляются [движком](/apex/glossary#engine) при запуске; экспортируются в `docker compose` действиями оркестрации и доступны при рендеринге любого шаблона. Не задавайте их вручную.
|
||||
Вычисляются [движком](/apex/glossary#engine) при запуске; экспортируются в `docker compose` действиями оркестрации и доступны при рендеринге любого шаблона. Не задавайте их вручную.
|
||||
|
||||
| Переменная | Значение |
|
||||
|---|---|
|
||||
| `APEX_NODE_HOST` | Публичное имя хоста узла (`node.host`); используется для `--host` в резервном копировании и как `instance` для наблюдаемости и уведомлений. |
|
||||
| `APEX_NODE_FQDN` | Действительный FQDN узла (`node.fqdn`); используется для правил краевой маршрутизации. |
|
||||
| `APEX_NODE_HOST` | Публичное имя хоста узла (`node.host`); используется для `--host` в резервном копировании и как `instance` для наблюдаемости и уведомлений. |
|
||||
| `APEX_NODE_FQDN` | Действительный FQDN узла (`node.fqdn`); используется для правил краевой маршрутизации. |
|
||||
| `APEX_SUBNET` | Подсеть узла. |
|
||||
| `APEX_COMMONS` | Абсолютный путь к каталогу commons (`$APEX_COMMONS/apex` — лаунчер, используемый в crontab и юнитах systemd). |
|
||||
| `APEX_REPO_ROOT` | Абсолютный путь к корню репозитория узла. |
|
||||
| `APEX_TIER1`, `APEX_TIER2`, `APEX_TIER3` | Каталог данных основного проекта для каждого [уровня хранения](/apex/glossary#tier). |
|
||||
| `APEX_TIER1_SHARED`, `APEX_TIER2_SHARED`, `APEX_TIER3_SHARED` | Общая область для каждого уровня. |
|
||||
| `APEX_TIER1`, `APEX_TIER2`, `APEX_TIER3` | Каталог данных основного проекта для каждого [уровня хранения](/apex/glossary#tier). |
|
||||
| `APEX_TIER1_SHARED`, `APEX_TIER2_SHARED`, `APEX_TIER3_SHARED` | Общая область для каждого уровня. |
|
||||
| `APEX_TIER_ROOT1`, `APEX_TIER_ROOT2`, `APEX_TIER_ROOT3` | Корневые каталоги уровней хранения. |
|
||||
|
||||
> [!NOTE]
|
||||
> Для переменных, отмеченных выше как `?error`, проверка выполняется на уровне общей основной композиции: `docker compose` прерывает разбор конфигурации, если хотя бы одна из них не задана. Благодаря этому пропущенный секрет приводит к немедленной и понятной ошибке.
|
||||
> Для переменных, отмеченных выше как `?error`, проверка выполняется на уровне общей основной композиции: `docker compose` прерывает разбор конфигурации, если хотя бы одна из них не задана. Благодаря этому пропущенный секрет приводит к немедленной и понятной ошибке.
|
||||
|
||||
Ряд переменных основной композиции использует конструкцию `${VAR:-default}`, поэтому их указывать необязательно, а значения по умолчанию можно переопределить для конкретного узла: `TZ` (`Etc/UTC`), `APEX_RESTIC_AWS_REGION` (`us-east-1`), `APEX_RESTIC_AWS_ENDPOINT` (пусто), `APEX_RESTIC_COMPRESSION` (`max`) и `APEX_ENCLAVE_SUBNET` (`198.18.0.0/16` — разрешённый диапазон IP-адресов в ipallowlist для маршрутов к панелям Traefik и AdGuard).
|
||||
Ряд переменных основной композиции использует конструкцию `${VAR:-default}`, поэтому их указывать необязательно, а значения по умолчанию можно переопределить для конкретного узла: `TZ` (`Etc/UTC`), `APEX_RESTIC_AWS_REGION` (`us-east-1`), `APEX_RESTIC_AWS_ENDPOINT` (пусто), `APEX_RESTIC_COMPRESSION` (`max`) и `APEX_ENCLAVE_SUBNET` (`198.18.0.0/16` — разрешённый диапазон IP-адресов в ipallowlist для маршрутов к панелям Traefik и AdGuard).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -14,19 +14,19 @@ weight: 40
|
||||
|
||||
**Решение:** явно укажите `APEX_NODE_FQDN` в файле `node.env` в корне репозитория (например, `APEX_NODE_FQDN=node1.example.com`).
|
||||
|
||||
## Разрешение идентичности завершается с кодом 66 {#identity-66}
|
||||
## Разрешение идентичности завершается с кодом 66 {#identity-66}
|
||||
|
||||
**Симптомы:** `identity: APEX_NODE_FQDN missing from node.env and no hostname` или `identity: APEX_SUBNET missing from node.env`, код выхода `66`.
|
||||
|
||||
**Диагностика:** не удалось определить обязательный параметр идентичности. Первое сообщение указывает, что ни в `node.env`, ни в имени хоста ОС нет FQDN; второе — что не задана подсеть `APEX_SUBNET`. Движок не пытается угадывать эти значения.
|
||||
**Диагностика:** не удалось определить обязательный параметр идентичности. Первое сообщение указывает, что ни в `node.env`, ни в имени хоста ОС нет FQDN; второе — что не задана подсеть `APEX_SUBNET`. Движок не пытается угадывать эти значения.
|
||||
|
||||
**Решение:** укажите нужные переменные в файле `node.env` в корне репозитория: задайте `APEX_SUBNET` и `APEX_NODE_FQDN` (либо настройте корректное имя хоста в ОС).
|
||||
|
||||
## configure/ufw завершается с кодом 66 {#ufw-66}
|
||||
## configure/ufw завершается с кодом 66 {#ufw-66}
|
||||
|
||||
**Симптомы:** `Rules directory does not exist: ...` или `Config directory does not exist: ...`, код выхода `66`.
|
||||
|
||||
**Диагностика:** отсутствует одна из директорий: `configs/ufw/docker/` либо `configs/ufw/host/`. Это свидетельствует о нарушении структуры каталогов, а не об осознанном отключении правил.
|
||||
**Диагностика:** отсутствует одна из директорий: `configs/ufw/docker/` либо `configs/ufw/host/`. Это свидетельствует о нарушении структуры каталогов, а не об осознанном отключении правил.
|
||||
|
||||
**Решение:** создайте недостающую директорию (она может быть пустой: в этом случае `docker/` лишь выведет предупреждение `No .rules files found ... nothing to apply.` и продолжит выполнение).
|
||||
|
||||
@@ -38,11 +38,11 @@ weight: 40
|
||||
|
||||
**Решение:** запустите `apex configure/base`, затем повторите вызов `apex configure/ufw`.
|
||||
|
||||
## Установка crontab отклонена или откачена {#cron}
|
||||
## Установка crontab отклонена или откачена {#cron}
|
||||
|
||||
**Симптомы:** `Rendered crontab is empty; aborting (previous kept).` (код выхода `65`) или `crontab install failed; restoring previous.` (код выхода `1`).
|
||||
|
||||
**Диагностика:** сгенерированный файл `configs/cron/crontab` оказался пустым (ошибка в шаблоне или переменных: неизвестные `$VARS` остаются как есть и не очищают файл) либо утилита `crontab` отклонила синтаксис.
|
||||
**Диагностика:** сгенерированный файл `configs/cron/crontab` оказался пустым (ошибка в шаблоне или переменных: неизвестные `$VARS` остаются как есть и не очищают файл) либо утилита `crontab` отклонила синтаксис.
|
||||
|
||||
**Решение:** проверьте вывод предпросмотра, исправьте шаблон и запустите `apex configure/cron` заново. В обоих случаях предыдущая версия crontab остаётся без изменений.
|
||||
|
||||
@@ -54,7 +54,7 @@ weight: 40
|
||||
|
||||
**Решение:** при наличии изменений в субмодуле сбросьте их командой `git -C commons checkout -- .` (либо зафиксируйте намеренное обновление субмодуля в коммите). Если отсутствует дерево каталогов, восстановите чекаут до следующего планового запуска.
|
||||
|
||||
## Telegram не доставлен {#telegram}
|
||||
## Telegram не доставлен {#telegram}
|
||||
|
||||
**Симптомы:** в логе появляется `Telegram Bot URL not provided. Skipping notification.` или `Failed to send Telegram notification: <error>`. При этом действия, отправляющие уведомления (`backup/run`, `sync/repository`, `sync/packages`), завершаются с кодом `1`, даже если основная задача выполнена.
|
||||
|
||||
@@ -66,7 +66,7 @@ weight: 40
|
||||
|
||||
**Симптомы:** команда `docker compose` сообщает об ошибке на директиве `include` при обработке `compositions/apex/docker-compose.yml`.
|
||||
|
||||
**Диагностика:** [базовая композиция](/apex/glossary#core-composition) подключается через директиву `include:`, для которой требуется Docker Compose версии 2.20 или новее.
|
||||
**Диагностика:** [базовая композиция](/apex/glossary#core-composition) подключается через директиву `include:`, для которой требуется Docker Compose версии 2.20 или новее.
|
||||
|
||||
**Решение:** обновите плагин Compose (проверить текущую версию можно командой `docker compose version`).
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: Quetza Bot
|
||||
description: Модульный фреймворк для Discord-ботов.
|
||||
description: Модульный фреймворк для Discord-ботов.
|
||||
weight: 1
|
||||
type: docs
|
||||
cascade:
|
||||
@@ -13,9 +13,9 @@ cascade:
|
||||
|
||||
## Основные возможности
|
||||
|
||||
- **Обнаружение модулей по соглашениям:** создайте папку, и Quetza автоматически загрузит её как модуль.
|
||||
- **Изоляция контроллеров:** управление состоянием происходит на уровне сервера (guild), что исключает утечки данных между серверами.
|
||||
- **Декларативные команды:** определение слэш-команд Discord через типизированный интерфейс.
|
||||
- **Обнаружение модулей по соглашениям:** создайте папку, и Quetza автоматически загрузит её как модуль.
|
||||
- **Изоляция контроллеров:** управление состоянием происходит на уровне сервера (guild), что исключает утечки данных между серверами.
|
||||
- **Декларативные команды:** определение слэш-команд Discord через типизированный интерфейс.
|
||||
- **Реактивные события:** подписка на любые события Discord.js внутри модулей.
|
||||
|
||||
## Разделы
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
title: Архитектура и философия
|
||||
title: Архитектура и философия
|
||||
weight: 1
|
||||
---
|
||||
|
||||
@@ -16,9 +16,9 @@ Quetza сокращает шаблонный код за счёт строго
|
||||
|
||||
### Композиция важнее наследования (Composition over Inheritance)
|
||||
|
||||
Модули собираются из набора возможностей (capabilities), а не наследуются от базового класса.
|
||||
Модули собираются из набора возможностей (capabilities), а не наследуются от базового класса.
|
||||
|
||||
- **Гибкость:** внутри модуля можно использовать любую архитектуру (функциональную или ООП), если модуль экспортирует обязательные интерфейсы.
|
||||
- **Гибкость:** внутри модуля можно использовать любую архитектуру (функциональную или ООП), если модуль экспортирует обязательные интерфейсы.
|
||||
|
||||
### Изоляция важнее интеграции (Isolation over Integration)
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ weight: 2
|
||||
|
||||
### Объявление модуля (Module Declaration)
|
||||
|
||||
Файл `module.ts` служит манифестом и точкой входа при поиске модулей.
|
||||
Файл `module.ts` служит манифестом и точкой входа при поиске модулей.
|
||||
|
||||
```typescript
|
||||
export const name = 'my-module'; // ID
|
||||
@@ -18,7 +18,7 @@ export const description = '...'; // Документация
|
||||
|
||||
### Регистрация команд (Command Registration)
|
||||
|
||||
Выполняется при наступлении события `ready`:
|
||||
Выполняется при наступлении события `ready`:
|
||||
|
||||
1. Quetza сканирует каталоги `commands/`.
|
||||
2. Формирует структуру `ApplicationCommandData`.
|
||||
@@ -26,7 +26,7 @@ export const description = '...'; // Документация
|
||||
|
||||
### Внедрение контроллера (Controller Injection)
|
||||
|
||||
Экземпляр контроллера передаётся аргументом при вызове (injected), а не импортируется модулем напрямую:
|
||||
Экземпляр контроллера передаётся аргументом при вызове (injected), а не импортируется модулем напрямую:
|
||||
|
||||
```typescript
|
||||
// Фреймворк вызывает это:
|
||||
@@ -37,15 +37,15 @@ execute(client, interaction, controller);
|
||||
|
||||
## Управление состоянием
|
||||
|
||||
### Эфемерность по умолчанию
|
||||
### Эфемерность по умолчанию
|
||||
|
||||
В Quetza **нет встроенной базы данных**.
|
||||
|
||||
- **Память:** контроллеры сохраняют данные в стандартных JavaScript-коллекциях `Map` или `Set`.
|
||||
- **Перезапуск:** при остановке процесса всё состояние в памяти сбрасывается.
|
||||
- **Постоянное хранение:** если данные нужно сохранять между запусками, подключите внешнюю СУБД (например, SQLite или Redis) внутри контроллера.
|
||||
- **Память:** контроллеры сохраняют данные в стандартных JavaScript-коллекциях `Map` или `Set`.
|
||||
- **Перезапуск:** при остановке процесса всё состояние в памяти сбрасывается.
|
||||
- **Постоянное хранение:** если данные нужно сохранять между запусками, подключите внешнюю СУБД (например, SQLite или Redis) внутри контроллера.
|
||||
|
||||
### Область видимости как ключ
|
||||
### Область видимости как ключ
|
||||
|
||||
Ключи состояния должны соответствовать области его видимости:
|
||||
|
||||
@@ -58,7 +58,7 @@ execute(client, interaction, controller);
|
||||
|
||||
- **Валидация:** проверяйте права и входные параметры сразу.
|
||||
- **Сообщения для пользователя:** «У вас нет прав» вместо сухого кода вроде «Ошибка: 403».
|
||||
- **Изоляция:** сбой внутри `/ping` перехватывается локально и не роняет процесс бота.
|
||||
- **Изоляция:** сбой внутри `/ping` перехватывается локально и не роняет процесс бота.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -5,9 +5,9 @@ weight: 1
|
||||
|
||||
**Quetza** — модульный фреймворк на TypeScript для Discord-ботов. Он позволяет собирать ботов из самодостаточных независимых модулей.
|
||||
|
||||
## Что такое Quetza?
|
||||
## Что такое Quetza?
|
||||
|
||||
Quetza даёт архитектурный каркас, где разработка новой функциональности сводится к добавлению модулей, а не правке ядра. Это решает проблему жёсткой архитектуры за счёт инверсии зависимостей: ядро ничего не знает о модулях, а модули взаимодействуют с системой через типизированные контракты.
|
||||
Quetza даёт архитектурный каркас, где разработка новой функциональности сводится к добавлению модулей, а не правке ядра. Это решает проблему жёсткой архитектуры за счёт инверсии зависимостей: ядро ничего не знает о модулях, а модули взаимодействуют с системой через типизированные контракты.
|
||||
|
||||
## Основная философия
|
||||
|
||||
@@ -17,25 +17,25 @@ Quetza даёт архитектурный каркас, где разработ
|
||||
|
||||
### Решение
|
||||
|
||||
Quetza работает с модулями как с плагинами:
|
||||
Quetza работает с модулями как с плагинами:
|
||||
|
||||
- **Расширение без правки ядра:** добавьте каталог с модулем, и функция подключена. Менять исходный код ядра не нужно.
|
||||
- **Расширение без правки ядра:** добавьте каталог с модулем, и функция подключена. Менять исходный код ядра не нужно.
|
||||
- **Изоляция:** модули выполняются независимо друг от друга.
|
||||
- **Композиция:** подключайте и отключайте возможности, добавляя или удаляя каталоги модулей.
|
||||
- **Композиция:** подключайте и отключайте возможности, добавляя или удаляя каталоги модулей.
|
||||
|
||||
## Высокоуровневая архитектура
|
||||
|
||||
Quetza состоит из трёх уровней:
|
||||
|
||||
1. **Базовый уровень (Foundation Layer):** клиент `Discord.js`, расширенный реестрами команд, событий и модулей. Выступает в роли сервис-локатора.
|
||||
2. **Модульный уровень (Module Layer):** изолированные наборы функций. Модуль объявляет собственные команды и события через стандартизированную структуру файлов.
|
||||
3. **Сервисный уровень (Service Layer):** контроллеры для управления состоянием бизнес-логики (например, аудиоплеером или контекстом диалога) на разных серверах (guilds).
|
||||
2. **Модульный уровень (Module Layer):** изолированные наборы функций. Модуль объявляет собственные команды и события через стандартизированную структуру файлов.
|
||||
3. **Сервисный уровень (Service Layer):** контроллеры для управления состоянием бизнес-логики (например, аудиоплеером или контекстом диалога) на разных серверах (guilds).
|
||||
|
||||
## Ключевые концепции
|
||||
|
||||
### Ментальная модель
|
||||
|
||||
При работе с Quetza полезна следующая аналогия:
|
||||
При работе с Quetza полезна следующая аналогия:
|
||||
|
||||
- **Модули — плагины:** независимые каталоги, обнаруживаемые во время запуска.
|
||||
- **Команды — эндпоинты:** обработчики без состояния, работающие аналогично конечным точкам REST API.
|
||||
@@ -44,11 +44,11 @@ Quetza состоит из трёх уровней:
|
||||
|
||||
### Границы системы
|
||||
|
||||
Ограничения, которые важно учитывать при проектировании:
|
||||
Ограничения, которые важно учитывать при проектировании:
|
||||
|
||||
- **Нет встроенного постоянного хранилища (persistence):** состояние в памяти сбрасывается при перезапуске. Для долговременного хранения нужна внешняя база данных.
|
||||
- **Нет межмодульного взаимодействия:** модули не вызывают друг друга напрямую, что гарантирует слабую связанность.
|
||||
- **Нет динамической перезагрузки:** модули загружаются один раз при старте процесса.
|
||||
- **Нет встроенного постоянного хранилища (persistence):** состояние в памяти сбрасывается при перезапуске. Для долговременного хранения нужна внешняя база данных.
|
||||
- **Нет межмодульного взаимодействия:** модули не вызывают друг друга напрямую, что гарантирует слабую связанность.
|
||||
- **Нет динамической перезагрузки:** модули загружаются один раз при старте процесса.
|
||||
|
||||
## Архитектурные инварианты
|
||||
|
||||
|
||||
@@ -53,4 +53,4 @@ export async function execute(interaction, controller) {
|
||||
|
||||
> [!WARNING]
|
||||
> **Расходы на API:**
|
||||
> Провайдеры ИИ обычно тарифицируют запросы по токенам. Настройте в контроллере строгие лимиты запросов (rate limits) для пользователей, чтобы избежать лишних расходов.
|
||||
> Провайдеры ИИ обычно тарифицируют запросы по токенам. Настройте в контроллере строгие лимиты запросов (rate limits) для пользователей, чтобы избежать лишних расходов.
|
||||
@@ -7,13 +7,13 @@ weight: 2
|
||||
|
||||
## 1. Подготовка окружения
|
||||
|
||||
Quetza настраивается через переменные окружения. **Никогда** не сохраняйте их в систему контроля версий.
|
||||
Quetza настраивается через переменные окружения. **Никогда** не сохраняйте их в систему контроля версий.
|
||||
|
||||
| Переменная | Описание | Обязательно |
|
||||
| :--- | :--- | :--- |
|
||||
| `DISCORD_TOKEN` | Токен бота из Discord Developer Portal | **Да** |
|
||||
| `CLIENT_ID` | Идентификатор приложения для регистрации слэш-команд | **Да** |
|
||||
| `LLAMA_API_KEY` | Требуется для модуля ИИ | Нет |
|
||||
| `CLIENT_ID` | Идентификатор приложения для регистрации слэш-команд | **Да** |
|
||||
| `LLAMA_API_KEY` | Требуется для модуля ИИ | Нет |
|
||||
|
||||
## 2. Способы развёртывания
|
||||
|
||||
@@ -36,7 +36,7 @@ Quetza настраивается через переменные окружен
|
||||
```
|
||||
{{< /tab >}}
|
||||
{{< tab name="Из исходного кода" >}}
|
||||
Развёртывание **из исходного кода** подходит для разработки и отладки.
|
||||
Развёртывание **из исходного кода** подходит для разработки и отладки.
|
||||
|
||||
1. **Установка** зависимостей:
|
||||
|
||||
|
||||
@@ -7,11 +7,11 @@ weight: 1
|
||||
|
||||
## 1. Проектирование модуля
|
||||
|
||||
Перед написанием кода выберите подходящую структуру модуля:
|
||||
Перед написанием кода выберите подходящую структуру модуля:
|
||||
|
||||
- **Простая команда:** достаточно каталога `commands/` (например, для служебных команд).
|
||||
- **Реактивная логика:** нужен каталог `events/` для обработки событий Discord (например, приветственных сообщений).
|
||||
- **Логика с состоянием:** понадобится контроллер (`Controller`) для управления состоянием между серверами (например, для музыкального плеера).
|
||||
- **Простая команда:** достаточно каталога `commands/` (например, для служебных команд).
|
||||
- **Реактивная логика:** нужен каталог `events/` для обработки событий Discord (например, приветственных сообщений).
|
||||
- **Логика с состоянием:** понадобится контроллер (`Controller`) для управления состоянием между серверами (например, для музыкального плеера).
|
||||
|
||||
## 2. Структура каталогов
|
||||
|
||||
@@ -85,7 +85,7 @@ export function execute(client) {
|
||||
Перезапустите бота:
|
||||
|
||||
1. Найдите в логах строку «Loaded module: my-feature».
|
||||
2. Убедитесь, что команда `/ping` появилась и работает в Discord.
|
||||
2. Убедитесь, что команда `/ping` появилась и работает в Discord.
|
||||
|
||||
{{% /steps %}}
|
||||
|
||||
@@ -93,7 +93,7 @@ export function execute(client) {
|
||||
|
||||
| Шаблон | Назначение | Состав |
|
||||
| :--- | :--- | :--- |
|
||||
| **Простая команда** | Утилиты без сохранения состояния (`/ping`, бросок кубика) | Только `commands/` |
|
||||
| **Простая команда** | Утилиты без сохранения состояния (`/ping`, бросок кубика) | Только `commands/` |
|
||||
| **Сервис с состоянием** | Музыка, ролевые игры, экономика | `commands/` + `lib/Controller` |
|
||||
| **Событийная логика** | Автомодерация, логирование | Только `events/` |
|
||||
| **Гибридный модуль** | Сложные сценарии (например, ИИ-чат) | Все компоненты |
|
||||
|
||||
@@ -11,7 +11,7 @@ weight: 3
|
||||
|
||||
- **Архитектурный шаблон:** контроллер хранит соответствие `Map<GuildID, Player>`.
|
||||
- **Поведение:**
|
||||
- **Подключение (Join):** создать `Player` для сервера A, если он ещё не существует.
|
||||
- **Подключение (Join):** создать `Player` для сервера A, если он ещё не существует.
|
||||
- **Отключение (Leave):** удалить `Player` и освободить соединение.
|
||||
- **Изоляция:** команды на сервере B никак не влияют на сервер A.
|
||||
|
||||
@@ -29,11 +29,11 @@ weight: 3
|
||||
|
||||
Аудиосессии по своей природе эфемерны:
|
||||
|
||||
- **Временное состояние:** активный аудиопоток (сбрасывается при отключении).
|
||||
- **Долговременное состояние:** очередь воспроизведения или плейлист (хранится в контроллере до перезапуска бота).
|
||||
- **Временное состояние:** активный аудиопоток (сбрасывается при отключении).
|
||||
- **Долговременное состояние:** очередь воспроизведения или плейлист (хранится в контроллере до перезапуска бота).
|
||||
|
||||
> [!TIP]
|
||||
> **Надёжность:** разделяйте логику очереди воспроизведения и логику голосового соединения. Тогда бот сможет переподключиться без потери пользовательского плейлиста.
|
||||
> **Надёжность:** разделяйте логику очереди воспроизведения и логику голосового соединения. Тогда бот сможет переподключиться без потери пользовательского плейлиста.
|
||||
|
||||
## 4. Пример реализации
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ title: Устранение неполадок
|
||||
weight: 5
|
||||
---
|
||||
|
||||
Диагностика неполадок в **Quetza** в основном касается структуры файлов и концептуальных ошибок. Поскольку фреймворк опирается на соглашения (convention-based), многие сбои происходят «молча» — например, модуль не загружается из-за неверного пути к файлу, не вызывая явных ошибок и трассировок стека.
|
||||
Диагностика неполадок в **Quetza** в основном касается структуры файлов и концептуальных ошибок. Поскольку фреймворк опирается на соглашения (convention-based), многие сбои происходят «молча» — например, модуль не загружается из-за неверного пути к файлу, не вызывая явных ошибок и трассировок стека.
|
||||
|
||||
## Основные категории проблем
|
||||
|
||||
|
||||
@@ -3,34 +3,34 @@ title: Диагностика
|
||||
weight: 1
|
||||
---
|
||||
|
||||
**Цель:** выявить и устранить частые ошибки при разработке под Quetza.
|
||||
**Цель:** выявить и устранить частые ошибки при разработке под Quetza.
|
||||
|
||||
## 1. Сбои при обнаружении модулей
|
||||
## 1. Сбои при обнаружении модулей
|
||||
|
||||
**Симптом:** бот запускается, но модуль не подхватывается.
|
||||
**Причина:** нарушена структура файлов или соглашения об именах.
|
||||
**Симптом:** бот запускается, но модуль не подхватывается.
|
||||
**Причина:** нарушена структура файлов или соглашения об именах.
|
||||
|
||||
{{% details title="Чек-лист: Структура директорий" closed="false" %}}
|
||||
|
||||
- [ ] Каталог модуля существует: `src/modules/<name>/`.
|
||||
- [ ] Файл `module.ts` экспортирует переменную `name`.
|
||||
- [ ] Каталог команд называется именно `commands/` (а не `cmds/`).
|
||||
- [ ] Каталог команд называется именно `commands/` (а не `cmds/`).
|
||||
- [ ] Выполнена сборка `pnpm build` (Quetza работает из `dist/`).
|
||||
{{% /details %}}
|
||||
|
||||
## 2. Проблемы с регистрацией команд
|
||||
## 2. Проблемы с регистрацией команд
|
||||
|
||||
**Симптом:** команда `/command` не появляется в Discord.
|
||||
|
||||
### Глобальная регистрация и регистрация на тестовом сервере
|
||||
### Глобальная регистрация и регистрация на тестовом сервере
|
||||
|
||||
- **Глобальная регистрация:** команды, зарегистрированные глобально, могут обновляться до 1 часа и подпадают под жёсткие лимиты Discord по частоте запросов (rate limits).
|
||||
- **Тестовый сервер (guild):** в режиме разработки Quetza публикует команды на указанном сервере мгновенно.
|
||||
- **Настройка:** проверьте, что в `.env` установлены переменные `NODE_ENV=development` и `DEV_GUILD_ID`.
|
||||
- **Настройка:** проверьте, что в `.env` установлены переменные `NODE_ENV=development` и `DEV_GUILD_ID`.
|
||||
|
||||
### Лимиты частоты запросов (Rate Limits)
|
||||
|
||||
Частая повторная публикация глобальных команд приводит к превышению лимитов Discord. Для итеративной разработки всегда используйте тестовый сервер.
|
||||
Частая повторная публикация глобальных команд приводит к превышению лимитов Discord. Для итеративной разработки всегда используйте тестовый сервер.
|
||||
|
||||
## 3. Ошибки взаимодействия (Interaction Failures)
|
||||
|
||||
@@ -53,7 +53,7 @@ await interaction.editReply('Готово');
|
||||
## 4. Утечки состояния
|
||||
|
||||
**Симптом:** потребление памяти растёт, данные перемешиваются между разными серверами.
|
||||
**Причина:** состояние хранится глобально, а не изолированно для каждого сервера.
|
||||
**Причина:** состояние хранится глобально, а не изолированно для каждого сервера.
|
||||
|
||||
**Диагностика:**
|
||||
Проверьте код контроллера:
|
||||
@@ -63,10 +63,10 @@ await interaction.editReply('Готово');
|
||||
|
||||
## 5. Обработка событий
|
||||
|
||||
**Симптом:** обработчик срабатывает несколько раз или не вызывается вовсе.
|
||||
**Симптом:** обработчик срабатывает несколько раз или не вызывается вовсе.
|
||||
|
||||
- **Срабатывает несколько раз:** слушатели событий суммируются. Проверьте, не зарегистрирован ли один и тот же обработчик в двух разных модулях.
|
||||
- **Не срабатывает:** убедитесь, что обработчик корректно экспортирует `name` и `execute`.
|
||||
- **Срабатывает несколько раз:** слушатели событий суммируются. Проверьте, не зарегистрирован ли один и тот же обработчик в двух разных модулях.
|
||||
- **Не срабатывает:** убедитесь, что обработчик корректно экспортирует `name` и `execute`.
|
||||
|
||||
> [!TIP]
|
||||
> **Режим отладки (Debug Mode):**
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"dev": "hugo server --buildDrafts --buildExpired --baseURL https://localhost.ermnvldmr.com:1313/ --tlsCertFile ../../.certs/localhost.ermnvldmr.com.pem --tlsKeyFile ../../.certs/localhost.ermnvldmr.com-key.pem",
|
||||
"build": "hugo --gc --minify",
|
||||
"clean": "rm -rf public resources",
|
||||
"lint": "echo 'No linting for Hugo site'",
|
||||
"lint": "eslint content --max-warnings 0",
|
||||
"typecheck": "echo 'No typecheck for Hugo site'"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,36 +1,36 @@
|
||||
---
|
||||
title: 'Joplin Web Deployment Guide'
|
||||
description: 'Joplin Server & Web App deployment from scratch'
|
||||
description: 'Joplin Server & Web App deployment from scratch'
|
||||
createdDate: 2026-03-20
|
||||
tags: ['self-hosting', 'guides', 'joplin']
|
||||
---
|
||||
|
||||
This guide describes how to deploy a private note-taking infrastructure using the Joplin Server and its web client.
|
||||
This guide describes how to deploy a private note-taking infrastructure using the Joplin Server and its web client.
|
||||
|
||||
## Why I Chose Joplin?
|
||||
|
||||
I have been using Joplin for a long time, and it has become my primary note-taking tool. There are several reasons why I chose it over other alternatives:
|
||||
I have been using Joplin for a long time, and it has become my primary note-taking tool. There are several reasons why I chose it over other alternatives:
|
||||
|
||||
- **Local-First**: Notes are stored locally on your device, ensuring fast access and full ownership (especially when the internet is inaccessible).
|
||||
- **Markdown Format**: Joplin uses Markdown for all notes, making it incredibly easy to export, import, or move your data to another platform if needed.
|
||||
- **Easy Sharing**: The ability to share notes via the web makes collaboration or public sharing straightforward.
|
||||
- **Backups**: Joplin provides robust backup options, giving me peace of mind that my data is safe.
|
||||
- **Local-First**: Notes are stored locally on your device, ensuring fast access and full ownership (especially when the internet is inaccessible).
|
||||
- **Markdown Format**: Joplin uses Markdown for all notes, making it incredibly easy to export, import, or move your data to another platform if needed.
|
||||
- **Easy Sharing**: The ability to share notes via the web makes collaboration or public sharing straightforward.
|
||||
- **Backups**: Joplin provides robust backup options, giving me peace of mind that my data is safe.
|
||||
|
||||
One thing that was missing for me was the ability to quickly add or view notes when I don't have access to my primary devices (for example, at work). This article provides the solution: a self-hosted web client.
|
||||
One thing that was missing for me was the ability to quickly add or view notes when I don't have access to my primary devices (for example, at work). This article provides the solution: a self-hosted web client.
|
||||
|
||||
## Technical Stack Overview
|
||||
|
||||
1. **Joplin Server**: The synchronization server. It handles the Joplin sync protocol, account logic, and note synchronization across devices.
|
||||
2. **Postgres**: The database. Essential for production performance.
|
||||
3. **Joplin Web App**: A browser-based client built from the Joplin mobile app source code using React Native for Web.
|
||||
1. **Joplin Server**: The synchronization server. It handles the Joplin sync protocol, account logic, and note synchronization across devices.
|
||||
2. **Postgres**: The database. Essential for production performance.
|
||||
3. **Joplin Web App**: A browser-based client built from the Joplin mobile app source code using React Native for Web.
|
||||
|
||||
## 1. The Deployment Stack
|
||||
## 1. The Deployment Stack
|
||||
|
||||
This configuration is generic, uses **Docker Volumes**, and provides a minimal setup for deploying the full stack.
|
||||
This configuration is generic, uses **Docker Volumes**, and provides a minimal setup for deploying the full stack.
|
||||
|
||||
### Main docker-compose.yml
|
||||
|
||||
This compose file is a minimal configuration to run the Joplin Server and the web app. It assumes you understand how to set up a reverse proxy, or better yet, already have your own Joplin setup.
|
||||
This compose file is a minimal configuration to run the Joplin Server and the web app. It assumes you understand how to set up a reverse proxy, or better yet, already have your own Joplin setup.
|
||||
|
||||
```yaml
|
||||
x-environment: &shared-environment
|
||||
@@ -84,9 +84,9 @@ volumes:
|
||||
|
||||
### Dockerfile
|
||||
|
||||
Create an `app` directory and place this `Dockerfile` inside it to build the web client. We build our own image since the original authors don't provide one.
|
||||
Create an `app` directory and place this `Dockerfile` inside it to build the web client. We build our own image since the original authors don't provide one.
|
||||
|
||||
While there are repositories with pre-built images, it's easy enough to just create our own.
|
||||
While there are repositories with pre-built images, it's easy enough to just create our own.
|
||||
|
||||
```dockerfile
|
||||
# Stage 1: Build
|
||||
@@ -128,9 +128,9 @@ CMD ["nginx", "-g", "daemon off;"]
|
||||
|
||||
### Nginx configuration
|
||||
|
||||
Place this `nginx.conf` alongside the `Dockerfile` in the `app` directory. Joplin is an SPA, meaning it doesn't matter where it's deployed since all navigation is handled internally without location dependency. So it's alright to just deploy it on a path that doesn't overlap with the main server, such as `/app`.
|
||||
Place this `nginx.conf` alongside the `Dockerfile` in the `app` directory. Joplin is an SPA, meaning it doesn't matter where it's deployed since all navigation is handled internally without location dependency. So it's alright to just deploy it on a path that doesn't overlap with the main server, such as `/app`.
|
||||
|
||||
> This is a pretty minimal configuration; it assumes your reverse proxy is good enough to cover all questionable items, but you can expand it as you like.
|
||||
> This is a pretty minimal configuration; it assumes your reverse proxy is good enough to cover all questionable items, but you can expand it as you like.
|
||||
|
||||
```nginx
|
||||
server {
|
||||
@@ -161,7 +161,7 @@ server {
|
||||
|
||||
## 2. Configuration
|
||||
|
||||
Create a `.env` file in the same directory as your `docker-compose.yml`. **Do not skip the secret generation step below.**
|
||||
Create a `.env` file in the same directory as your `docker-compose.yml`. **Do not skip the secret generation step below.**
|
||||
|
||||
```bash
|
||||
# --- Database Credentials ---
|
||||
@@ -171,7 +171,7 @@ SHARED_PASSWORD=generate_this_with_openssl
|
||||
|
||||
### Generating Secrets
|
||||
|
||||
Use `openssl` to easily generate secure, random strings for your `.env` entries:
|
||||
Use `openssl` to easily generate secure, random strings for your `.env` entries:
|
||||
|
||||
```bash
|
||||
# Generate SHARED_PASSWORD
|
||||
@@ -180,7 +180,7 @@ openssl rand -hex 32
|
||||
|
||||
## 3. Deployment
|
||||
|
||||
Start your stack by running the following command:
|
||||
Start your stack by running the following command:
|
||||
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
@@ -188,33 +188,33 @@ docker compose up -d --build
|
||||
|
||||
## 4. Initial Server Setup
|
||||
|
||||
Before configuring the web app, you must initialize your Joplin Server:
|
||||
Before configuring the web app, you must initialize your Joplin Server:
|
||||
|
||||
Follow the official guide: [Joplin Server README](https://github.com/laurent22/joplin/blob/dev/packages/server/README.md)
|
||||
Follow the official guide: [Joplin Server README](https://github.com/laurent22/joplin/blob/dev/packages/server/README.md)
|
||||
|
||||
Essentially, you need to ensure that basic synchronization via the app is functional.
|
||||
Essentially, you need to ensure that basic synchronization via the app is functional.
|
||||
|
||||
## 5. Web App Showcase & Setup
|
||||
|
||||
Once the web app is running at `https://example.com/app/`, you will be greeted by it.
|
||||
Once the web app is running at `https://example.com/app/`, you will be greeted by it.
|
||||
|
||||
### About the Web App
|
||||
### About the Web App
|
||||
|
||||

|
||||
|
||||
It is basically the same mobile Joplin app, but in-browser. It leverages the LocalStorage API for data handling. Everything is still local-first, so after logging in, it will take some time to synchronize all remote notes.
|
||||
It is basically the same mobile Joplin app, but in-browser. It leverages the LocalStorage API for data handling. Everything is still local-first, so after logging in, it will take some time to synchronize all remote notes.
|
||||
|
||||
But most importantly: _The Joplin web interface allows you to view and edit your notes directly from the browser._
|
||||
But most importantly: _The Joplin web interface allows you to view and edit your notes directly from the browser._
|
||||
|
||||
> It is said that it's only guaranteed to work on Chrome due to the required API compatibility; however, I have had great success with Firefox as well. Browsers are catching up quickly.
|
||||
> It is said that it's only guaranteed to work on Chrome due to the required API compatibility; however, I have had great success with Firefox as well. Browsers are catching up quickly.
|
||||
|
||||
### Authentication Setup
|
||||
|
||||
To connect to your self-hosted Joplin Server, go to **Settings > Synchronization** and configure the following parameters:
|
||||
To connect to your self-hosted Joplin Server, go to **Settings > Synchronization** and configure the following parameters:
|
||||
|
||||
- **Sync Target**: Joplin Server
|
||||
- **Joplin Server URL**: `https://example.com`
|
||||
- **Joplin Server Email**: `your-email@example.com` (the one you just created/configured)
|
||||
- **Joplin Server Email**: `your-email@example.com` (the one you just created/configured)
|
||||
- **Joplin Server Password**: `your-password`
|
||||
|
||||

|
||||
|
||||
@@ -1,36 +1,36 @@
|
||||
---
|
||||
title: 'Руководство по развертыванию Joplin Web'
|
||||
description: 'Развертывание Joplin Server и веб-приложения с нуля'
|
||||
title: 'Руководство по развёртыванию Joplin Web'
|
||||
description: 'Развёртывание Joplin Server и веб-приложения с нуля'
|
||||
createdDate: 2026-03-20
|
||||
tags: ['self-hosting', 'guides', 'joplin']
|
||||
---
|
||||
|
||||
Это руководство описывает, как развернуть приватную инфраструктуру для заметок, используя Joplin Server и его веб-клиент.
|
||||
В этом руководстве разберём, как развернуть приватную инфраструктуру для заметок на базе Joplin Server и веб-клиента.
|
||||
|
||||
## Почему я выбрал Joplin?
|
||||
## Почему я выбрал Joplin?
|
||||
|
||||
Я давно пользуюсь Joplin, и он стал моим основным инструментом для ведения заметок. Есть несколько причин, по которым я выбрал его среди других альтернатив:
|
||||
Я давно пользуюсь Joplin как основным приложением для заметок. Вот почему я выбрал именно его:
|
||||
|
||||
- **Local-First**: Заметки хранятся локально на вашем устройстве, что обеспечивает быстрый доступ и полное владение данными (особенно когда интернет недоступен).
|
||||
- **Формат Markdown**: Joplin использует Markdown для всех заметок, что позволяет невероятно легко экспортировать, импортировать или переносить данные на другую платформу при необходимости.
|
||||
- **Удобный обмен**: Возможность делиться заметками через веб делает совместную работу или публичный доступ простыми.
|
||||
- **Резервное копирование**: Joplin предоставляет надежные варианты резервного копирования, давая мне уверенность в сохранности моих данных.
|
||||
- **Local-First**: заметки хранятся локально на устройстве. Доступ к ним мгновенный, а данные остаются под полным контролем даже без интернета.
|
||||
- **Формат Markdown**: все заметки сохраняются в Markdown, поэтому их легко экспортировать, импортировать или перенести на другую платформу.
|
||||
- **Удобный обмен**: заметками можно делиться через веб для совместной работы или публичного доступа.
|
||||
- **Резервное копирование**: в Joplin встроены надёжные механизмы бэкапа, поэтому за сохранность данных можно не беспокоиться.
|
||||
|
||||
Одной вещи мне не хватало — возможности быстро добавить или просмотреть заметки, когда у меня нет доступа к моим основным устройствам (например, на работе). Эта статья предлагает решение: самостоятельно размещенный (self-hosted) веб-клиент.
|
||||
Мне не хватало лишь одного — возможности быстро открыть или записать заметку без доступа к основным устройствам (например, с рабочего компьютера). Решение — собственный (self-hosted) веб-клиент.
|
||||
|
||||
## Обзор технологического стека
|
||||
|
||||
1. **Joplin Server**: Сервер синхронизации. Он обрабатывает протокол синхронизации Joplin, логику учетных записей и синхронизацию заметок между устройствами.
|
||||
2. **Postgres**: База данных. Необходима для производительности в рабочей среде.
|
||||
3. **Joplin Web App**: Браузерный клиент, созданный из исходного кода мобильного приложения Joplin с использованием React Native for Web.
|
||||
1. **Joplin Server**: сервер синхронизации. Отвечает за протокол обмена данными, учётные записи и синхронизацию заметок между устройствами.
|
||||
2. **Postgres**: база данных. Обеспечивает высокую производительность и стабильность в рабочей среде.
|
||||
3. **Joplin Web App**: браузерный клиент, собранный из исходного кода мобильного приложения Joplin с помощью React Native for Web.
|
||||
|
||||
## 1. Стек развертывания
|
||||
## 1. Стек развёртывания
|
||||
|
||||
Эта конфигурация является универсальной, использует **Docker Volumes** и предоставляет минимальную настройку для развертывания полного стека.
|
||||
Конфигурация универсальна, использует **Docker Volumes** и содержит минимум параметров для развёртывания полного стека.
|
||||
|
||||
### Основной docker-compose.yml
|
||||
|
||||
Этот compose-файл представляет собой минимальную конфигурацию для запуска Joplin Server и веб-приложения. Предполагается, что вы понимаете, как настроить обратный прокси-сервер, или, что еще лучше, у вас уже есть собственная установка Joplin.
|
||||
В этом compose-файле собрана минимальная конфигурация для запуска Joplin Server и веб-приложения. Предполагается, что вы умеете настраивать обратный прокси-сервер или у вас уже развёрнут Joplin Server.
|
||||
|
||||
```yaml
|
||||
x-environment: &shared-environment
|
||||
@@ -84,9 +84,9 @@ volumes:
|
||||
|
||||
### Dockerfile
|
||||
|
||||
Создайте директорию `app` и поместите в нее этот `Dockerfile` для сборки веб-клиента. Мы собираем собственный образ, так как авторы оригинала его не предоставляют.
|
||||
Создайте директорию `app` и поместите в неё `Dockerfile` для сборки веб-клиента. Собираем собственный образ, потому что разработчики Joplin пока не выпускают официальный контейнер.
|
||||
|
||||
Хотя существуют репозитории с готовыми образами, создать свой достаточно просто.
|
||||
В сети встречаются сторонние образы, но собрать собственный надёжнее и проще:
|
||||
|
||||
```dockerfile
|
||||
# Stage 1: Build
|
||||
@@ -128,9 +128,9 @@ CMD ["nginx", "-g", "daemon off;"]
|
||||
|
||||
### Конфигурация Nginx
|
||||
|
||||
Поместите этот `nginx.conf` рядом с `Dockerfile` в директории `app`. Joplin — это SPA (Single Page Application), а значит, не имеет значения, где оно развернуто, так как вся навигация обрабатывается внутренне без зависимости от местоположения. Поэтому вполне нормально развернуть его по пути, который не пересекается с основным сервером, например `/app`.
|
||||
Поместите `nginx.conf` рядом с `Dockerfile` в директорию `app`. Joplin Web — это SPA (Single Page Application): маршрутизация выполняется внутри браузера, поэтому клиент можно развернуть по любому пути, не конфликтующему с сервером синхронизации, например `/app`.
|
||||
|
||||
> Это довольно минималистичная конфигурация; предполагается, что ваш обратный прокси достаточно хорош, чтобы закрыть все спорные моменты, но вы можете расширить ее по своему усмотрению.
|
||||
> Это базовая конфигурация: предполагается, что TLS и защиту периметра берёт на себя обратный прокси. При необходимости правила Nginx можно дополнить.
|
||||
|
||||
```nginx
|
||||
server {
|
||||
@@ -161,7 +161,7 @@ server {
|
||||
|
||||
## 2. Конфигурация
|
||||
|
||||
Создайте файл `.env` в той же директории, что и `docker-compose.yml`. **Не пропускайте шаг генерации секретов ниже.**
|
||||
Создайте файл `.env` рядом с `docker-compose.yml`. **Обязательно сгенерируйте надёжный пароль перед запуском.**
|
||||
|
||||
```bash
|
||||
# --- Учетные данные базы данных ---
|
||||
@@ -171,16 +171,16 @@ SHARED_PASSWORD=generate_this_with_openssl
|
||||
|
||||
### Генерация секретов
|
||||
|
||||
Используйте `openssl`, чтобы легко создать безопасные случайные строки для записей в вашем `.env`:
|
||||
Сгенерируйте пароль с помощью `openssl`:
|
||||
|
||||
```bash
|
||||
# Генерация SHARED_PASSWORD
|
||||
openssl rand -hex 32
|
||||
```
|
||||
|
||||
## 3. Развертывание
|
||||
## 3. Развёртывание
|
||||
|
||||
Запустите стек, выполнив следующую команду:
|
||||
Запустите контейнеры:
|
||||
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
@@ -188,38 +188,34 @@ docker compose up -d --build
|
||||
|
||||
## 4. Начальная настройка сервера
|
||||
|
||||
Перед настройкой веб-приложения необходимо инициализировать ваш Joplin Server:
|
||||
Перед настройкой веб-клиента инициализируйте Joplin Server по [официальной инструкции](https://github.com/laurent22/joplin/blob/dev/packages/server/README.md): создайте учётную запись администратора и проверьте, что базовая синхронизация работает через десктопное или мобильное приложение.
|
||||
|
||||
Следуйте официальному руководству: [Joplin Server README](https://github.com/laurent22/joplin/blob/dev/packages/server/README.md)
|
||||
## 5. Демонстрация и настройка веб-приложения
|
||||
|
||||
По сути, вам нужно убедиться, что базовая синхронизация через приложение работает.
|
||||
После запуска откройте веб-клиент по адресу `https://example.com/app/`.
|
||||
|
||||
## 5. Демонстрация и настройка веб-приложения
|
||||
|
||||
Как только веб-приложение запустится по адресу `https://example.com/app/`, оно поприветствует вас.
|
||||
|
||||
### О веб-приложении
|
||||
### О веб-приложении
|
||||
|
||||

|
||||
|
||||
По сути, это то же самое мобильное приложение Joplin, но в браузере. Оно использует LocalStorage API для обработки данных. Все по-прежнему работает по принципу local-first, поэтому после входа в систему потребуется время для синхронизации всех удаленных заметок.
|
||||
Это интерфейс мобильного приложения Joplin, скомпилированный для браузера. Данные сохраняются в LocalStorage: приложение работает по принципу local-first, поэтому при первом входе потребуется время, чтобы скачать заметки с сервера.
|
||||
|
||||
Но самое главное: _Веб-интерфейс Joplin позволяет просматривать и редактировать заметки прямо из браузера._
|
||||
Главная польза интерфейса — возможность просматривать и редактировать заметки прямо из браузера на любом устройстве без установки клиента.
|
||||
|
||||
> Говорят, что работа гарантирована только в Chrome из-за требований к совместимости API; однако я добился отличных результатов и в Firefox. Браузеры быстро догоняют друг друга.
|
||||
> Разработчики заявляют гарантированную работу только в Chrome из-за требований к Web API, но клиент стабильно работает и в Firefox.
|
||||
|
||||
### Настройка аутентификации
|
||||
|
||||
Чтобы подключиться к вашему self-hosted серверу Joplin, перейдите в **Settings > Synchronization** и настройте следующие параметры:
|
||||
Чтобы подключиться к своему серверу Joplin, откройте **Settings > Synchronization** и укажите параметры подключения:
|
||||
|
||||
- **Sync Target**: Joplin Server
|
||||
- **Joplin Server URL**: `https://example.com`
|
||||
- **Joplin Server Email**: `your-email@example.com` (тот, который вы только что создали/настроили)
|
||||
- **Joplin Server Email**: `your-email@example.com` (созданный на предыдущем шаге)
|
||||
- **Joplin Server Password**: `your-password`
|
||||
|
||||

|
||||
|
||||
_Наслаждайтесь!_
|
||||
Теперь синхронизированные заметки доступны прямо в браузере.
|
||||
|
||||
## Ресурсы
|
||||
|
||||
|
||||
@@ -1,43 +1,43 @@
|
||||
---
|
||||
title: 'Matrix Server Deployment Guide'
|
||||
description: 'Understanding and deploying Synapse, Coturn, LiveKit SFU, and Native Matrix RTC'
|
||||
description: 'Understanding and deploying Synapse, Coturn, LiveKit SFU, and Native Matrix RTC'
|
||||
createdDate: 2026-02-15
|
||||
tags: ['self-hosting', 'guides', 'matrix']
|
||||
---
|
||||
|
||||
Deploying a Matrix communications stack involves more than running a single web server. Matrix is an open, federated protocol for real-time, decentralized, end-to-end encrypted communication. While basic messaging requires only a homeserver and a database, modern voice and video capabilities—ranging from peer-to-peer 1:1 calls to multi-party group video conferences—require dedicated media relays and authentication bridges.
|
||||
Deploying a Matrix communications stack involves more than running a single web server. Matrix is an open, federated protocol for real-time, decentralized, end-to-end encrypted communication. While basic messaging requires only a homeserver and a database, modern voice and video capabilities—ranging from peer-to-peer 1:1 calls to multi-party group video conferences—require dedicated media relays and authentication bridges.
|
||||
|
||||
This article walks through the inner workings and deployment of a production-ready Matrix stack utilizing **Synapse**, **PostgreSQL**, **Coturn**, **LiveKit SFU**, and **lk-jwt-service**.
|
||||
This article walks through the inner workings and deployment of a production-ready Matrix stack utilizing **Synapse**, **PostgreSQL**, **Coturn**, **LiveKit SFU**, and **lk-jwt-service**.
|
||||
|
||||
---
|
||||
|
||||
## Technical Stack Architecture
|
||||
|
||||
To understand how a Matrix communications deployment functions, we must look at how each of its five core components handles data:
|
||||
To understand how a Matrix communications deployment functions, we must look at how each of its five core components handles data:
|
||||
|
||||
1. **Synapse**: The primary homeserver engine. It manages user authentication, room state, message synchronization, end-to-end encryption (E2EE) key distribution, and federation with other homeservers across the global Matrix network.
|
||||
2. **PostgreSQL**: The relational database powering Synapse. While Synapse supports SQLite for quick local experimentation, PostgreSQL is mandatory for production to handle concurrent database transactions, index room state, and maintain message history efficiently.
|
||||
3. **Coturn (STUN/TURN)**: The NAT traversal relay. When two users initiate a 1:1 call, their devices attempt a direct peer-to-peer WebRTC connection. If firewalls or symmetric NATs block direct traffic, Coturn acts as a relay server, forwarding encrypted audio and video packets between peers over UDP or TCP.
|
||||
4. **LiveKit SFU**: A high-performance Selective Forwarding Unit. Peer-to-peer meshes become bandwidth-prohibitive in group calls with three or more participants. LiveKit receives media streams from each speaker and forwards them to all other room members, reducing client upstream bandwidth.
|
||||
5. **lk-jwt-service**: The authentication bridge for Element Call and Element X Native Matrix RTC (governed by MSC4143 and MSC4186). When a user joins a group call in Element X, `lk-jwt-service` verifies their Matrix session with Synapse and issues a short-lived JSON Web Token (JWT), allowing them to authenticate with LiveKit automatically.
|
||||
6. **Reverse Proxy (Traefik / Nginx)**: The edge gateway. It terminates TLS certificates, handles WebSocket protocol upgrades for LiveKit signaling (`/livekit`), injects CORS headers for web clients, and serves Matrix client and federation auto-discovery JSON payloads (`.well-known`).
|
||||
1. **Synapse**: The primary homeserver engine. It manages user authentication, room state, message synchronization, end-to-end encryption (E2EE) key distribution, and federation with other homeservers across the global Matrix network.
|
||||
2. **PostgreSQL**: The relational database powering Synapse. While Synapse supports SQLite for quick local experimentation, PostgreSQL is mandatory for production to handle concurrent database transactions, index room state, and maintain message history efficiently.
|
||||
3. **Coturn (STUN/TURN)**: The NAT traversal relay. When two users initiate a 1:1 call, their devices attempt a direct peer-to-peer WebRTC connection. If firewalls or symmetric NATs block direct traffic, Coturn acts as a relay server, forwarding encrypted audio and video packets between peers over UDP or TCP.
|
||||
4. **LiveKit SFU**: A high-performance Selective Forwarding Unit. Peer-to-peer meshes become bandwidth-prohibitive in group calls with three or more participants. LiveKit receives media streams from each speaker and forwards them to all other room members, reducing client upstream bandwidth.
|
||||
5. **lk-jwt-service**: The authentication bridge for Element Call and Element X Native Matrix RTC (governed by MSC4143 and MSC4186). When a user joins a group call in Element X, `lk-jwt-service` verifies their Matrix session with Synapse and issues a short-lived JSON Web Token (JWT), allowing them to authenticate with LiveKit automatically.
|
||||
6. **Reverse Proxy (Traefik / Nginx)**: The edge gateway. It terminates TLS certificates, handles WebSocket protocol upgrades for LiveKit signaling (`/livekit`), injects CORS headers for web clients, and serves Matrix client and federation auto-discovery JSON payloads (`.well-known`).
|
||||
|
||||
---
|
||||
|
||||
## 1. The Docker Infrastructure (docker-compose.yml)
|
||||
## 1. The Docker Infrastructure (docker-compose.yml)
|
||||
|
||||
Organizing these services into containerized units ensures isolated environments, consistent startup ordering, and clean network mapping.
|
||||
Organizing these services into containerized units ensures isolated environments, consistent startup ordering, and clean network mapping.
|
||||
|
||||
In our setup, PostgreSQL includes a healthcheck (`pg_isready`) so that Synapse waits until database initialization is complete before attempting schema migrations. Services like Coturn, LiveKit, and `lk-jwt-service` run in network namespace mode (`network_mode: "service:synapse"`) or dedicated container networks, simplifying loopback communication while binding necessary host WebRTC and STUN/TURN ports directly.
|
||||
In our setup, PostgreSQL includes a healthcheck (`pg_isready`) so that Synapse waits until database initialization is complete before attempting schema migrations. Services like Coturn, LiveKit, and `lk-jwt-service` run in network namespace mode (`network_mode: "service:synapse"`) or dedicated container networks, simplifying loopback communication while binding necessary host WebRTC and STUN/TURN ports directly.
|
||||
|
||||
Notice that specific port ranges are opened for media traffic:
|
||||
- **Port 3478 & 5349 (TCP/UDP)**: Coturn STUN and TURN signaling.
|
||||
Notice that specific port ranges are opened for media traffic:
|
||||
- **Port 3478 & 5349 (TCP/UDP)**: Coturn STUN and TURN signaling.
|
||||
- **Port Range 49152-49172 (UDP)**: Coturn media relay channels.
|
||||
- **Port 7880 (TCP)**: LiveKit HTTP signaling and WebSockets.
|
||||
- **Port 7880 (TCP)**: LiveKit HTTP signaling and WebSockets.
|
||||
- **Port 7881 (TCP)**: LiveKit WebRTC TCP fallback.
|
||||
- **Port Range 50000-50020 (UDP)**: LiveKit SFU media channels.
|
||||
|
||||
The configuration below finalizes the infrastructure definition:
|
||||
The configuration below finalizes the infrastructure definition:
|
||||
|
||||
```yaml
|
||||
networks:
|
||||
@@ -142,15 +142,15 @@ services:
|
||||
|
||||
## 2. Homeserver Configuration (homeserver.yaml)
|
||||
|
||||
Synapse reads its operational rules from `homeserver.yaml`. Understanding key sections of this file prevents common administrative issues:
|
||||
Synapse reads its operational rules from `homeserver.yaml`. Understanding key sections of this file prevents common administrative issues:
|
||||
|
||||
- **Server Identity (`server_name`)**: This string defines your server's authority and user ID domain suffix (e.g., `@user:matrix.example.com`). Changing this later breaks existing user IDs and room history.
|
||||
- **Database Connection (`database`)**: Connects Synapse to PostgreSQL using the `psycopg2` driver. Setting `cp_min` and `cp_max` manages connection pooling efficiently under load.
|
||||
- **Media Retention (`media_retention`)**: Matrix room members frequently share images and files. Setting `remote_media_lifetime` to 14 days automatically purges cached media from federated servers, protecting your disk space while preserving local media (`local_media_lifetime: 365d`).
|
||||
- **VoIP Integration (`turn_uris` & `turn_shared_secret`)**: Informs Synapse of your Coturn installation. When two users place a call, Synapse uses `turn_shared_secret` to generate ephemeral, time-limited credentials, allowing clients to authenticate with Coturn securely.
|
||||
- **Native Matrix RTC (MSC4143 / MSC4186)**: Modern clients like Element X and Element Call use Matrix Specification Proposals MSC4143 (RTC Foci) and MSC4186 (LiveKit Call Authentication). Adding `extra_well_known_client_content` tells Matrix clients that group call authentication is handled by your `lk-jwt-service` endpoint (`https://matrix.example.com/livekit/jwt`).
|
||||
- **Server Identity (`server_name`)**: This string defines your server's authority and user ID domain suffix (e.g., `@user:matrix.example.com`). Changing this later breaks existing user IDs and room history.
|
||||
- **Database Connection (`database`)**: Connects Synapse to PostgreSQL using the `psycopg2` driver. Setting `cp_min` and `cp_max` manages connection pooling efficiently under load.
|
||||
- **Media Retention (`media_retention`)**: Matrix room members frequently share images and files. Setting `remote_media_lifetime` to 14 days automatically purges cached media from federated servers, protecting your disk space while preserving local media (`local_media_lifetime: 365d`).
|
||||
- **VoIP Integration (`turn_uris` & `turn_shared_secret`)**: Informs Synapse of your Coturn installation. When two users place a call, Synapse uses `turn_shared_secret` to generate ephemeral, time-limited credentials, allowing clients to authenticate with Coturn securely.
|
||||
- **Native Matrix RTC (MSC4143 / MSC4186)**: Modern clients like Element X and Element Call use Matrix Specification Proposals MSC4143 (RTC Foci) and MSC4186 (LiveKit Call Authentication). Adding `extra_well_known_client_content` tells Matrix clients that group call authentication is handled by your `lk-jwt-service` endpoint (`https://matrix.example.com/livekit/jwt`).
|
||||
|
||||
The complete `homeserver.yaml` configuration is structured as follows:
|
||||
The complete `homeserver.yaml` configuration is structured as follows:
|
||||
|
||||
```yaml
|
||||
# Server Identity
|
||||
@@ -227,14 +227,14 @@ report_stats: false
|
||||
|
||||
## 3. LiveKit SFU Configuration (livekit.yaml)
|
||||
|
||||
LiveKit powers group calls by receiving publisher streams and distributing them to subscribers. Key directives in `livekit.yaml` control media routing:
|
||||
LiveKit powers group calls by receiving publisher streams and distributing them to subscribers. Key directives in `livekit.yaml` control media routing:
|
||||
|
||||
- **`bind_addresses` & `port`**: Sets LiveKit to listen for HTTP signaling and WebSocket connections on port `7880`.
|
||||
- **`rtc.use_external_ip: true`**: Crucial for cloud server environments behind NAT. Instructs LiveKit to discover and advertise the host node's public IP address (`--node-ip`) to connecting WebRTC clients.
|
||||
- **`rtc.port_range_start` & `rtc.port_range_end`**: Restricts dynamic UDP WebRTC media allocation to ports `50000-50020`, keeping firewall policy simple.
|
||||
- **`room.auto_create: true`**: Allows rooms to be dynamically allocated whenever `lk-jwt-service` issues a valid access token.
|
||||
- **`bind_addresses` & `port`**: Sets LiveKit to listen for HTTP signaling and WebSocket connections on port `7880`.
|
||||
- **`rtc.use_external_ip: true`**: Crucial for cloud server environments behind NAT. Instructs LiveKit to discover and advertise the host node's public IP address (`--node-ip`) to connecting WebRTC clients.
|
||||
- **`rtc.port_range_start` & `rtc.port_range_end`**: Restricts dynamic UDP WebRTC media allocation to ports `50000-50020`, keeping firewall policy simple.
|
||||
- **`room.auto_create: true`**: Allows rooms to be dynamically allocated whenever `lk-jwt-service` issues a valid access token.
|
||||
|
||||
Here is the resulting `livekit.yaml`:
|
||||
Here is the resulting `livekit.yaml`:
|
||||
|
||||
```yaml
|
||||
port: 7880
|
||||
@@ -258,16 +258,16 @@ logging:
|
||||
|
||||
## 4. Coturn Relay Configuration (turnserver.conf)
|
||||
|
||||
Coturn handles fallback media relaying for 1:1 calls. When configuring `turnserver.conf`, several settings ensure reliability and security:
|
||||
Coturn handles fallback media relaying for 1:1 calls. When configuring `turnserver.conf`, several settings ensure reliability and security:
|
||||
|
||||
- **`listening-port=3478`**: Standard port for STUN/TURN requests over UDP and TCP.
|
||||
- **`external-ip`**: Tells Coturn its public IP address so it can communicate valid relay candidates to remote peers.
|
||||
- **`min-port` & `max-port`**: Limits dynamic UDP relay allocation to `49152-49172`.
|
||||
- **`use-auth-secret` & `static-auth-secret`**: Enables secret-based authentication matching `turn_shared_secret` in `homeserver.yaml`.
|
||||
- **`realm`**: The domain name associated with your TURN server, required for HMAC token verification.
|
||||
- **`no-multicast-peers`**: Prevents malicious users from abusing the TURN relay to probe internal private network addresses.
|
||||
- **`listening-port=3478`**: Standard port for STUN/TURN requests over UDP and TCP.
|
||||
- **`external-ip`**: Tells Coturn its public IP address so it can communicate valid relay candidates to remote peers.
|
||||
- **`min-port` & `max-port`**: Limits dynamic UDP relay allocation to `49152-49172`.
|
||||
- **`use-auth-secret` & `static-auth-secret`**: Enables secret-based authentication matching `turn_shared_secret` in `homeserver.yaml`.
|
||||
- **`realm`**: The domain name associated with your TURN server, required for HMAC token verification.
|
||||
- **`no-multicast-peers`**: Prevents malicious users from abusing the TURN relay to probe internal private network addresses.
|
||||
|
||||
Below is the complete `turnserver.conf` file:
|
||||
Below is the complete `turnserver.conf` file:
|
||||
|
||||
```conf
|
||||
listening-port=3478
|
||||
@@ -295,11 +295,11 @@ no-multicast-peers
|
||||
|
||||
## 5. Environment Setup (.env)
|
||||
|
||||
Storing credentials in environment variables keeps secret values out of configuration files and version control.
|
||||
Storing credentials in environment variables keeps secret values out of configuration files and version control.
|
||||
|
||||
Secrets such as `DB_PASSWORD`, `LK_SECRET`, and `TURN_SECRET` should be cryptographically random strings. We use `openssl` to generate secure 64-character hex strings.
|
||||
Secrets such as `DB_PASSWORD`, `LK_SECRET`, and `TURN_SECRET` should be cryptographically random strings. We use `openssl` to generate secure 64-character hex strings.
|
||||
|
||||
Here is the `.env` template file:
|
||||
Here is the `.env` template file:
|
||||
|
||||
```bash
|
||||
# Infrastructure
|
||||
@@ -318,7 +318,7 @@ LK_SECRET=YOUR_LIVEKIT_SECRET
|
||||
TURN_SECRET=YOUR_TURN_SHARED_SECRET
|
||||
```
|
||||
|
||||
Generating secrets with OpenSSL:
|
||||
Generating secrets with OpenSSL:
|
||||
|
||||
```bash
|
||||
# Generate DB_PASSWORD, LK_SECRET, and TURN_SECRET
|
||||
@@ -327,15 +327,15 @@ openssl rand -hex 32
|
||||
|
||||
---
|
||||
|
||||
## 6. Reverse Proxy and Client Discovery (.well-known)
|
||||
## 6. Reverse Proxy and Client Discovery (.well-known)
|
||||
|
||||
A reverse proxy routes external HTTPS traffic to internal services and satisfies protocol requirements for web clients:
|
||||
A reverse proxy routes external HTTPS traffic to internal services and satisfies protocol requirements for web clients:
|
||||
|
||||
1. **CORS Headers**: Web clients like Element Web or Element Call make cross-origin API requests. The proxy must return appropriate `Access-Control-Allow-*` headers.
|
||||
2. **WebSocket Proxying (`/livekit/`)**: LiveKit signaling requires persistent WebSocket connections (`Upgrade: $http_upgrade` and `Connection: "Upgrade"`).
|
||||
3. **Auto-Discovery (`/.well-known/matrix/client` & `/.well-known/matrix/server`)**: Matrix clients inspect these URIs upon user login to locate the homeserver API base URL and LiveKit JWT service without asking users to type custom port numbers or subdomains.
|
||||
1. **CORS Headers**: Web clients like Element Web or Element Call make cross-origin API requests. The proxy must return appropriate `Access-Control-Allow-*` headers.
|
||||
2. **WebSocket Proxying (`/livekit/`)**: LiveKit signaling requires persistent WebSocket connections (`Upgrade: $http_upgrade` and `Connection: "Upgrade"`).
|
||||
3. **Auto-Discovery (`/.well-known/matrix/client` & `/.well-known/matrix/server`)**: Matrix clients inspect these URIs upon user login to locate the homeserver API base URL and LiveKit JWT service without asking users to type custom port numbers or subdomains.
|
||||
|
||||
The following **Nginx** configuration puts all routing rules into practice:
|
||||
The following **Nginx** configuration puts all routing rules into practice:
|
||||
|
||||
```nginx
|
||||
server {
|
||||
@@ -396,13 +396,13 @@ server {
|
||||
|
||||
---
|
||||
|
||||
## 7. Account Provisioning and Maintenance
|
||||
## 7. Account Provisioning and Maintenance
|
||||
|
||||
With public registration disabled (`enable_registration: false`), accounts are provisioned via Synapse's CLI tool.
|
||||
With public registration disabled (`enable_registration: false`), accounts are provisioned via Synapse's CLI tool.
|
||||
|
||||
### Admin User Registration
|
||||
|
||||
Run `register_new_matrix_user` inside the Synapse container to create your initial administrator account:
|
||||
Run `register_new_matrix_user` inside the Synapse container to create your initial administrator account:
|
||||
|
||||
```bash
|
||||
docker exec -it matrix-synapse register_new_matrix_user \
|
||||
@@ -412,7 +412,7 @@ docker exec -it matrix-synapse register_new_matrix_user \
|
||||
|
||||
### Transactional Email Configuration (SMTP)
|
||||
|
||||
To enable email verification and password reset functionality, append the `email` configuration block to `homeserver.yaml`:
|
||||
To enable email verification and password reset functionality, append the `email` configuration block to `homeserver.yaml`:
|
||||
|
||||
```yaml
|
||||
email:
|
||||
|
||||
Loaded 100 of 111 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user