diff --git a/.github/workflows/self-host-apps-discovery-catalogs.yml b/.github/workflows/self-host-apps-discovery-catalogs.yml deleted file mode 100644 index eca959944..000000000 --- a/.github/workflows/self-host-apps-discovery-catalogs.yml +++ /dev/null @@ -1,48 +0,0 @@ -name: Discover self-host apps from install catalogs - -on: - workflow_dispatch: - schedule: - - cron: "17 4 * * 0" - -permissions: - contents: read - -concurrency: - group: self-host-apps-discovery-catalogs - cancel-in-progress: false - -jobs: - discover: - runs-on: ubuntu-latest - timeout-minutes: 45 - env: - DATABASE_URL: ${{ secrets.DATABASE_POOLER_URL }} - GITHUB_TOKEN: ${{ secrets.STACK127_GITHUB_TOKEN || github.token }} - - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: 24 - cache: npm - - run: npm ci - - name: Discover platform catalogs - run: >- - npm run self-host-apps:discover-app-catalogs -- - --catalog all - --limit 5000 - --docker-limit 500 - --docker-page-size 100 - --out scripts/self-host-apps/batches/${{ github.run_id }}-catalog-discovery.json - --write - - name: Classify exact duplicates and review matches - if: always() - run: npm run self-host-apps:dedupe-candidates -- --limit 30000 - - name: Upload discovery report - if: always() - uses: actions/upload-artifact@v4 - with: - name: self-host-app-catalog-discovery-${{ github.run_id }} - path: scripts/self-host-apps/batches/${{ github.run_id }}-catalog-discovery.json - if-no-files-found: ignore diff --git a/.github/workflows/self-host-apps-discovery-directories.yml b/.github/workflows/self-host-apps-discovery-directories.yml deleted file mode 100644 index ccd657376..000000000 --- a/.github/workflows/self-host-apps-discovery-directories.yml +++ /dev/null @@ -1,31 +0,0 @@ -name: Discover self-host apps from directories - -on: - workflow_dispatch: - schedule: - - cron: "12 4 1 * *" - -permissions: - contents: read - -concurrency: - group: self-host-apps-discovery-directories - cancel-in-progress: false - -jobs: - discover: - runs-on: ubuntu-latest - timeout-minutes: 30 - env: - DATABASE_URL: ${{ secrets.DATABASE_POOLER_URL }} - - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: 24 - cache: npm - - run: npm ci - - run: npm run self-host-apps:discover-selfhostedworld -- --limit 5000 --write - - run: npm run self-host-apps:discover-openalternative -- --limit 5000 --write - - run: npm run self-host-apps:dedupe-candidates -- --limit 20000 diff --git a/.github/workflows/self-host-apps-discovery-github.yml b/.github/workflows/self-host-apps-discovery-github.yml deleted file mode 100644 index 22ed807ae..000000000 --- a/.github/workflows/self-host-apps-discovery-github.yml +++ /dev/null @@ -1,36 +0,0 @@ -name: Discover self-host apps from GitHub - -on: - workflow_dispatch: - schedule: - - cron: "24 2 * * *" - -permissions: - contents: read - -concurrency: - group: self-host-apps-discovery-github - cancel-in-progress: false - -jobs: - discover: - runs-on: ubuntu-latest - timeout-minutes: 20 - env: - DATABASE_URL: ${{ secrets.DATABASE_POOLER_URL }} - GITHUB_TOKEN: ${{ secrets.STACK127_GITHUB_TOKEN || github.token }} - - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: 24 - cache: npm - - run: npm ci - - name: Discover GitHub topic candidates - run: | - for topic in self-hosted homelab local-ai home-automation ai-agent; do - npm run self-host-apps:discover-github-topic -- --topic "$topic" --limit 100 --write - done - - name: Classify exact duplicates and review matches - run: npm run self-host-apps:dedupe-candidates -- --limit 10000 diff --git a/.github/workflows/self-host-apps-discovery-lists.yml b/.github/workflows/self-host-apps-discovery-lists.yml deleted file mode 100644 index 7f555aed0..000000000 --- a/.github/workflows/self-host-apps-discovery-lists.yml +++ /dev/null @@ -1,31 +0,0 @@ -name: Discover self-host apps from maintained lists - -on: - workflow_dispatch: - schedule: - - cron: "41 3 * * 0" - -permissions: - contents: read - -concurrency: - group: self-host-apps-discovery-lists - cancel-in-progress: false - -jobs: - discover: - runs-on: ubuntu-latest - timeout-minutes: 20 - env: - DATABASE_URL: ${{ secrets.DATABASE_POOLER_URL }} - - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: 24 - cache: npm - - run: npm ci - - run: npm run self-host-apps:discover-awesome-selfhosted -- --limit 5000 --write - - run: npm run self-host-apps:discover-selfhst -- --limit 5000 --write - - run: npm run self-host-apps:dedupe-candidates -- --limit 10000 diff --git a/.github/workflows/self-host-apps-refresh-github.yml b/.github/workflows/self-host-apps-refresh-github.yml deleted file mode 100644 index 84fc22f2c..000000000 --- a/.github/workflows/self-host-apps-refresh-github.yml +++ /dev/null @@ -1,74 +0,0 @@ -name: Refresh self-host and CLI app GitHub facts - -on: - schedule: - - cron: "0 */12 * * *" - workflow_dispatch: - inputs: - limit: - description: "Maximum unique repositories to refresh. Use 0 with all=true." - default: "500" - required: false - all: - description: "Refresh every matching repository instead of using limit." - default: false - required: false - type: boolean - dry_run: - description: "Run without writing database updates." - default: false - required: false - type: boolean - -permissions: - contents: read - -concurrency: - group: self-host-apps-refresh-github - cancel-in-progress: false - -jobs: - refresh: - runs-on: ubuntu-latest - timeout-minutes: 20 - - env: - DATABASE_URL: ${{ secrets.DATABASE_POOLER_URL }} - GITHUB_TOKEN: ${{ secrets.STACK127_GITHUB_TOKEN || github.token }} - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup Node - uses: actions/setup-node@v4 - with: - node-version: 24 - cache: npm - - - name: Install dependencies - run: npm ci - - - name: Refresh GitHub facts - run: | - args=( - "--published-only" - "--batch-size" "40" - ) - - if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then - if [ "${{ inputs.all }}" = "true" ]; then - args+=("--all") - else - args+=("--limit" "${{ inputs.limit || '500' }}") - fi - - if [ "${{ inputs.dry_run }}" = "true" ]; then - args+=("--dry-run") - fi - else - args+=("--limit" "500") - args+=("--max-age-hours" "12") - fi - - npm run self-host-apps:refresh-github -- "${args[@]}" diff --git a/.github/workflows/self-host-apps-refresh-source-hosts.yml b/.github/workflows/self-host-apps-refresh-source-hosts.yml deleted file mode 100644 index 4ff29d0d6..000000000 --- a/.github/workflows/self-host-apps-refresh-source-hosts.yml +++ /dev/null @@ -1,35 +0,0 @@ -name: Refresh GitLab and Codeberg facts - -on: - workflow_dispatch: - schedule: - - cron: "36 5 * * *" - -permissions: - contents: read - -concurrency: - group: self-host-apps-refresh-source-hosts - cancel-in-progress: false - -jobs: - refresh: - runs-on: ubuntu-latest - timeout-minutes: 30 - env: - DATABASE_URL: ${{ secrets.DATABASE_POOLER_URL }} - GITLAB_TOKEN: ${{ secrets.STACK127_GITLAB_TOKEN }} - CODEBERG_TOKEN: ${{ secrets.STACK127_CODEBERG_TOKEN }} - - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: 24 - cache: npm - - run: npm ci - - name: Refresh GitLab facts - run: npm run self-host-apps:refresh-gitlab -- --published-only --limit 500 --max-age-hours 24 - - name: Refresh Codeberg facts - if: always() - run: npm run self-host-apps:refresh-codeberg -- --published-only --limit 500 --max-age-hours 24 diff --git a/.github/workflows/self-host-apps-refresh-technical.yml b/.github/workflows/self-host-apps-refresh-technical.yml deleted file mode 100644 index aa8b8be77..000000000 --- a/.github/workflows/self-host-apps-refresh-technical.yml +++ /dev/null @@ -1,59 +0,0 @@ -name: Collect self-host app technical evidence - -on: - workflow_dispatch: - inputs: - refresh_all: - description: "Check every published self-host app" - default: true - required: false - type: boolean - schedule: - - cron: "23 5 * * 1" - -permissions: - contents: read - -concurrency: - group: self-host-apps-refresh-technical - cancel-in-progress: false - -jobs: - refresh: - runs-on: ubuntu-latest - timeout-minutes: 60 - env: - DATABASE_URL: ${{ secrets.DATABASE_POOLER_URL }} - GITHUB_TOKEN: ${{ secrets.STACK127_GITHUB_TOKEN || github.token }} - - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: 24 - cache: npm - - run: npm ci - - name: Collect primary technical evidence - run: >- - npm run self-host-apps:refresh-primary-technical-facts -- - ${{ (github.event_name == 'schedule' || inputs.refresh_all == true) && '--refresh-all' || '' }} - --concurrency 16 - --max-failure-percent 5 - --out scripts/self-host-apps/batches/${{ github.run_id }}-technical-refresh.json - - name: Inspect install destinations - # Keep dry-run: the builder infers registry links/support levels and replaces - # stored options; these changes require review before applying. - if: always() - run: npm run self-host-apps:refresh-install-options -- --limit 5000 - - name: Run publication quality gate - if: always() - run: npm run self-host-apps:qa-gate -- --limit 5000 --out scripts/self-host-apps/batches/${{ github.run_id }}-technical-qa-gate.json - - name: Upload technical reports - if: always() - uses: actions/upload-artifact@v4 - with: - name: self-host-app-technical-refresh-${{ github.run_id }} - path: | - scripts/self-host-apps/batches/${{ github.run_id }}-technical-refresh.json - scripts/self-host-apps/batches/${{ github.run_id }}-technical-qa-gate.json - if-no-files-found: ignore diff --git a/content_docs/self-host-apps/data-workflow.md b/content_docs/self-host-apps/data-workflow.md index 4ff30acca..e210cd23e 100644 --- a/content_docs/self-host-apps/data-workflow.md +++ b/content_docs/self-host-apps/data-workflow.md @@ -83,7 +83,7 @@ npm run self-host-apps:refresh-install-options -- --limit 5000 --apply Docker links point to a registry or install page. Dockerfiles, Compose files, repositories, and READMEs are not Docker install buttons. Appliance links are emitted only when a reviewed catalog or package source supplies them. -GitHub Actions collects primary-source evidence and inspects install destinations weekly without applying changes. The catalogue discovery job also runs weekly. Reports remain review inputs. Apply corrections only after reading the cited sources, using the guarded field-correction workflow documented in `scripts/stack127/directory-quality/README.md`. A failed source or unreadable app stays pending; it must not become a negative factual claim or receive a new verification date. +The OVH cron runner collects primary-source evidence and inspects install destinations weekly without applying changes. The catalogue discovery job also runs weekly. Reports remain review inputs. Apply corrections only after reading the cited sources, using the guarded field-correction workflow documented in `scripts/stack127/directory-quality/README.md`. A failed source or unreadable app stays pending; it must not become a negative factual claim or receive a new verification date. ## QA @@ -115,4 +115,6 @@ The local-to-production mirror is exceptional and guarded: npm run self-host-apps:sync-local-to-prod -- --confirm-reviewed-push --dry-run ``` -Pass `--draft-missing` only when the reviewed local mirror is intentionally authoritative for publication. Without that flag, the script never drafts production rows. Normal enrichment writes directly to the selected database. GitHub Actions and VPS logs are the run history. No import-run table is needed. +Pass `--draft-missing` only when the reviewed local mirror is intentionally authoritative for publication. Without that flag, the script never drafts production rows. Normal enrichment writes directly to the selected database. OVH job logs are the run history. No import-run table is needed. + +Scheduled discovery and refresh run on OVH. See [the cron runner guide](../../scripts/self-host-apps/ovh/README.md) for schedules, catch-up, credentials, logs, reports, and installation. diff --git a/content_docs/self-host-apps/schema.md b/content_docs/self-host-apps/schema.md index d33b07e05..9780ad5d7 100644 --- a/content_docs/self-host-apps/schema.md +++ b/content_docs/self-host-apps/schema.md @@ -25,7 +25,7 @@ Categories come from `self_host_app_categories` through `self_host_app_category_ - `github_projects`: shared mutable GitHub facts. `self_host_apps.github_project_id` links an app when it has a GitHub repository. - `self_host_app_candidates`: the durable discovery and review queue. Candidate rows are never public. -Discovery and refresh logs live in GitHub Actions or VPS job logs. The old import-run, raw-source, field-source, and curated-override tables are removed by the streamline migration. +Discovery and refresh logs live in OVH job logs. The old import-run, raw-source, field-source, and curated-override tables are removed by the streamline migration. ## Self-hosting classification @@ -66,3 +66,5 @@ Exact canonical source-code and homepage matches can be deduped automatically. N ## Rendering There are no per-app routes. The server loads the compact filter index in bounded batches, then fetches complete records only for the 30 apps on the current results page. This keeps the cards server-rendered without making one large database read or transferring every detail field for thousands of apps on each request. + +Scheduled discovery and refresh run on OVH. See [the cron runner guide](../../scripts/self-host-apps/ovh/README.md) for schedules, catch-up, credentials, logs, reports, and installation. diff --git a/scripts/self-host-apps/discover-app-catalogs.mjs b/scripts/self-host-apps/discover-app-catalogs.mjs index ee3cf1c06..42c26762d 100644 --- a/scripts/self-host-apps/discover-app-catalogs.mjs +++ b/scripts/self-host-apps/discover-app-catalogs.mjs @@ -1,6 +1,6 @@ #!/usr/bin/env node -import { mkdir, writeFile } from "node:fs/promises"; +import { mkdir, rename, writeFile } from "node:fs/promises"; import path from "node:path"; import { parseArgs, numberArg } from "./lib/args.mjs"; import { canonicalSourceCodeUrl, normalizeCandidate, storeCandidates } from "./lib/candidates.mjs"; @@ -12,6 +12,7 @@ import { parseYunoHostAppsToml, } from "./lib/catalog-adapters.mjs"; import { withClient } from "./lib/db.mjs"; +import { request } from "./lib/request.mjs"; import { pathToFileURL } from "node:url"; const catalogs = { @@ -56,48 +57,23 @@ const catalogs = { }, }; -async function wait(ms) { - await new Promise((resolve) => setTimeout(resolve, ms)); -} - -async function fetchResponse(url, options = {}) { - let lastError; - for (let attempt = 0; attempt < 3; attempt += 1) { - let response; - try { - response = await fetch(url, { ...options, signal: AbortSignal.timeout(60_000) }); - } catch (error) { - lastError = error; - if (attempt === 2) throw error; - await wait(500 * (2 ** attempt)); - continue; - } - if (response.ok) return response; - const retryable = response.status === 408 || response.status === 425 || response.status === 429 || response.status >= 500; - lastError = new Error(`Failed to fetch ${url}: ${response.status}`); - if (!retryable || attempt === 2) throw lastError; - await wait(500 * (2 ** attempt)); - } - throw lastError ?? new Error(`Failed to fetch ${url}`); -} - export async function fetchJson(url) { const isGithubApi = new URL(url).origin === "https://api.github.com"; - const response = await fetchResponse(url, { + // Never send GitHub bearer credentials to Docker Hub (it rejects them with 401). + return request(url, { + ...(isGithubApi ? { redirectOrigin: "https://api.github.com" } : { redirect: "follow" }), headers: { Accept: "application/vnd.github+json, application/json", "User-Agent": "stack127-self-host-app-catalog-discovery/1.0", ...(isGithubApi && process.env.GITHUB_TOKEN ? { Authorization: `Bearer ${process.env.GITHUB_TOKEN}` } : {}), }, - }); - return response.json(); + }, response => response.json()); } async function fetchText(url) { - const response = await fetchResponse(url, { + return request(url, { headers: { "User-Agent": "stack127-self-host-app-catalog-discovery/1.0" }, }); - return response.text(); } function firstUrl(values, predicate) { @@ -342,7 +318,8 @@ async function main() { if (args.out) { await mkdir(path.dirname(args.out), { recursive: true }); - await writeFile(args.out, `${JSON.stringify(report, null, 2)}\n`); + await writeFile(`${args.out}.tmp`, `${JSON.stringify(report, null, 2)}\n`); + await rename(`${args.out}.tmp`, args.out); } console.log(JSON.stringify(report, null, 2)); if (Object.keys(report.errors).length > 0) process.exitCode = 1; diff --git a/scripts/self-host-apps/discover-selfhst.mjs b/scripts/self-host-apps/discover-selfhst.mjs index ab528de9c..b544fcaab 100644 --- a/scripts/self-host-apps/discover-selfhst.mjs +++ b/scripts/self-host-apps/discover-selfhst.mjs @@ -6,15 +6,13 @@ import { parseArgs, numberArg } from "./lib/args.mjs"; import { normalizeCandidate, storeCandidates } from "./lib/candidates.mjs"; import { withClient } from "./lib/db.mjs"; +import { request } from "./lib/request.mjs"; + const softwareUrl = "https://selfhst.github.io/cdn/directory/software.json"; const tagsUrl = "https://selfhst.github.io/cdn/directory/tags.json"; async function fetchJson(url) { - const response = await fetch(url, { - headers: { "User-Agent": "stack127-self-host-apps-discovery/1.0" }, - }); - if (!response.ok) throw new Error(`Failed to fetch ${url}: ${response.status}`); - return response.json(); + return request(url, { headers: { "User-Agent": "stack127-self-host-apps-discovery/1.0" } }, response => response.json()); } function sourceRow(row, tagNames) { diff --git a/scripts/self-host-apps/lib/candidates.mjs b/scripts/self-host-apps/lib/candidates.mjs index 501c62d31..5b7423c53 100644 --- a/scripts/self-host-apps/lib/candidates.mjs +++ b/scripts/self-host-apps/lib/candidates.mjs @@ -244,17 +244,14 @@ export async function upsertCandidate(client, candidate) { export async function storeCandidates(client, candidates, { source, sourceUrl, notes } = {}) { let inserted = 0; let updated = 0; - let errors = 0; - for (const candidate of candidates) { try { if (await upsertCandidate(client, candidate)) inserted += 1; else updated += 1; } catch (error) { - errors += 1; - console.warn(`warning ${candidate.slug}: ${error.message}`); + throw new Error(`Candidate write failed (${source}/${candidate.source_key}): ${error.message}`, { cause: error }); } } - return { source, source_url: sourceUrl ?? null, notes: notes ?? null, discovered: candidates.length, inserted, updated, errors }; + return { source, source_url: sourceUrl ?? null, notes: notes ?? null, discovered: candidates.length, inserted, updated, errors: 0 }; } diff --git a/scripts/self-host-apps/lib/github.mjs b/scripts/self-host-apps/lib/github.mjs index fd362b7e8..f14357db4 100644 --- a/scripts/self-host-apps/lib/github.mjs +++ b/scripts/self-host-apps/lib/github.mjs @@ -1,3 +1,4 @@ +import { request } from "./request.mjs"; import { execFileSync } from "node:child_process"; const githubApiBase = "https://api.github.com"; @@ -14,6 +15,7 @@ function githubToken() { cachedToken = execFileSync("gh", ["auth", "token"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], + timeout: 10_000, }).trim(); } catch { cachedToken = ""; @@ -50,9 +52,7 @@ export async function githubJson(pathName, searchParams) { const token = githubToken(); if (token) headers.Authorization = `Bearer ${token}`; - const response = await fetch(url, { headers }); - if (!response.ok) throw new Error(`GitHub ${response.status}: ${url}`); - return response.json(); + return request(url, { headers, redirectOrigin: githubApiBase }, response => response.json()); } export async function searchTopic(topic, { limit = 30 } = {}) { diff --git a/scripts/self-host-apps/lib/http.mjs b/scripts/self-host-apps/lib/http.mjs index 52c2be6ed..d5b2107cd 100644 --- a/scripts/self-host-apps/lib/http.mjs +++ b/scripts/self-host-apps/lib/http.mjs @@ -1,16 +1,10 @@ -export async function fetchText(url, { accept = "text/html,application/xhtml+xml" } = {}) { - const response = await fetch(url, { - headers: { - Accept: accept, - "User-Agent": "stack127-self-host-app-discovery/1.0", - }, - }); - - if (!response.ok) { - throw new Error(`Source fetch failed ${response.status}: ${url}`); - } +import { request } from "./request.mjs"; - return response.text(); +export async function fetchText(url, { accept = "text/html,application/xhtml+xml" } = {}) { + return request(url, { headers: { + Accept: accept, + "User-Agent": "stack127-self-host-app-discovery/1.0", + } }); } export async function mapConcurrent(values, concurrency, callback) { diff --git a/scripts/self-host-apps/lib/primary-source.mjs b/scripts/self-host-apps/lib/primary-source.mjs new file mode 100644 index 000000000..6f688eef0 --- /dev/null +++ b/scripts/self-host-apps/lib/primary-source.mjs @@ -0,0 +1,39 @@ +import { request } from './request.mjs'; + +let nextDockerRequest = 0; + +export async function readPrimarySource(value) { + let url = value; + const parsed = new URL(value); + const docker = ['hub.docker.com', 'registry.hub.docker.com'].includes(parsed.hostname) + && parsed.pathname.match(/^\/r\/([^/]+)\/([^/]+)\/?$/); + if (docker) { + // The HTML frontend can block automation. This is the same publisher's README, + // supplied by Docker Hub's public repository API; no registry search inference. + url = `https://hub.docker.com/v2/repositories/${docker[1]}/${docker[2]}/`; + } else if (parsed.hostname === 'github.com') { + const parts = parsed.pathname.split('/').filter(Boolean); + if (parts[2] === 'blob' && parts.length >= 5) { + url = `https://raw.githubusercontent.com/${parts[0]}/${parts[1]}/${parts.slice(3).join('/')}`; + } + } + if (docker) { + // Docker Hub advertises 180 requests/minute. Leave headroom for retries and + // other jobs on this VPS instead of bursting all 16 evidence workers at it. + const slot = Math.max(Date.now(), nextDockerRequest); + nextDockerRequest = slot + 500; + await new Promise(resolve => setTimeout(resolve, slot - Date.now())); + } + return request(url, { + headers: { 'User-Agent': 'stack127-self-host-primary-facts/1.0' }, + redirect: 'follow', + }, async response => { + if (docker) { + const repository = await response.json(); + const text = repository.full_description || repository.description; + if (typeof text !== 'string' || !text.trim()) throw Object.assign(new Error(`Docker Hub README is empty: ${url}`), { retryable: false }); + return { text: text.slice(0, 500_000), url }; + } + return { text: (await response.text()).slice(0, 500_000), url: response.url }; + }); +} diff --git a/scripts/self-host-apps/lib/report-output.mjs b/scripts/self-host-apps/lib/report-output.mjs new file mode 100644 index 000000000..acf7e0f3a --- /dev/null +++ b/scripts/self-host-apps/lib/report-output.mjs @@ -0,0 +1,32 @@ +import { mkdirSync, renameSync, writeFileSync } from 'node:fs'; +import { dirname } from 'node:path'; + +// Detailed evidence belongs in artifacts. Console summaries have a fixed size, +// even when an upstream error contains a complete response body. +export function boundedSummary(value, reportPath) { + function compact(item, depth = 0) { + if (typeof item === 'string') return item.replace(/[\r\n]+/g, ' ').slice(0, 300); + if (!item || typeof item !== 'object') return item; + if (depth >= 3) return '[details in report]'; + if (Array.isArray(item)) return item.slice(0, 5).map(entry => compact(entry, depth + 1)); + return Object.fromEntries(Object.entries(item).slice(0, 30).map(([key, entry]) => [key.slice(0, 80), compact(entry, depth + 1)])); + } + const text = JSON.stringify({ ...compact(value), report_path: reportPath ? String(reportPath).slice(0, 500) : null }, null, 2); + return text.length <= 12000 ? text : `${text.slice(0, 11500)}\n[summary capped; see report file]`; +} + +export function reportOutput(report, out, summary) { + if (out) { + mkdirSync(dirname(out), { recursive: true }); + writeFileSync(`${out}.tmp`, `${JSON.stringify(report, null, 2)}\n`); + renameSync(`${out}.tmp`, out); + } + console.log(boundedSummary(summary, out)); +} + +export function technicalReportOutput(report, out) { + reportOutput(report, out, { + selected: report.selected, passed: report.passed, failed_count: report.failed_count, + facts: report.facts, failures: report.failed.map(({ slug, error }) => ({ slug, error })), + }); +} diff --git a/scripts/self-host-apps/lib/request.mjs b/scripts/self-host-apps/lib/request.mjs new file mode 100644 index 000000000..1f6914756 --- /dev/null +++ b/scripts/self-host-apps/lib/request.mjs @@ -0,0 +1,40 @@ +// Bound the response body as well as headers, and retry only transient failures. +export async function request(url, options = {}, read = response => response.text()) { + const { redirectOrigin, ...fetchOptions } = options; + const redirectError = message => Object.assign(new Error(message), { retryable: false }); + for (let attempt = 0; ; attempt++) { + let retryAfter = 0; + try { + // One deadline includes the full redirect chain and response body. + const signal = AbortSignal.timeout(45_000); + let target = new URL(url); + let response; + for (let redirects = 0; ; redirects++) { + if (redirectOrigin && (target.origin !== redirectOrigin || target.username || target.password)) { + throw redirectError(`Refusing redirect outside ${redirectOrigin}`); + } + response = await fetch(target.href, { ...fetchOptions, ...(redirectOrigin ? { redirect: "manual" } : {}), signal }); + if (!redirectOrigin || ![301, 302, 303, 307, 308].includes(response.status)) break; + const location = response.headers.get("location"); + await response.body?.cancel(); + if (!location) throw redirectError("Redirect response is missing Location"); + if (redirects >= 5) throw redirectError("Too many API redirects (maximum 5)"); + try { target = new URL(location, target); } + catch { throw redirectError("Invalid API redirect Location"); } + } + if (!response.ok) { + retryAfter = Math.min(60_000, Math.max(0, Number(response.headers.get("retry-after")) * 1000 || Date.parse(response.headers.get("retry-after")) - Date.now() || (response.status === 429 ? 30_000 : 0))); + const error = Object.assign(new Error(`HTTP ${response.status}: ${url}`), { + retryable: [408, 425, 429].includes(response.status) || response.status >= 500, + status: response.status, + }); + await response.body?.cancel(); + throw error; + } + return await read(response); + } catch (error) { + if (error.retryable === false || attempt >= 2) throw error; + await new Promise(resolve => setTimeout(resolve, Math.max(retryAfter, 500 * 2 ** attempt))); + } + } +} diff --git a/scripts/self-host-apps/lib/sources.mjs b/scripts/self-host-apps/lib/sources.mjs index 086eb4130..c9fd6a7dc 100644 --- a/scripts/self-host-apps/lib/sources.mjs +++ b/scripts/self-host-apps/lib/sources.mjs @@ -1,13 +1,11 @@ import { readFile, writeFile } from "node:fs/promises"; +import { request } from "./request.mjs"; + export async function readSource({ file, url }) { if (file) return readFile(file, "utf8"); if (url) { - const response = await fetch(url, { - headers: { "User-Agent": "stack127-self-host-apps" }, - }); - if (!response.ok) throw new Error(`Source fetch failed ${response.status}: ${url}`); - return response.text(); + return request(url, { headers: { "User-Agent": "stack127-self-host-apps" } }); } throw new Error("Pass --file or --url."); } diff --git a/scripts/self-host-apps/ovh/README.md b/scripts/self-host-apps/ovh/README.md new file mode 100644 index 000000000..507034882 --- /dev/null +++ b/scripts/self-host-apps/ovh/README.md @@ -0,0 +1,223 @@ +# Self-host app cron jobs on OVH + +These seven data jobs and one catch-up job run as `ubuntu` on OVH. GitHub no +longer schedules them. Cron is already enabled on the server. No new daemon or timer is required. The wrapper +uses a transient systemd resource scope through ubuntu's enabled user manager +(`loginctl enable-linger ubuntu`) to cap the complete process tree at 2 GB RAM, +no swap, and one CPU. It also sets `nice -n 10`, a 1.5 GB V8 heap ceiling, a 16 GB +virtual address ceiling (V8 reserves about 10 GB), and a 20/30/45/60-minute timeout +matching each former workflow, including checkout update and lock waiting. The +resource scope also has the same RuntimeMaxSec limit, so children remain bounded +even if the outer supervisor is killed. The env launcher passes the checkout +lock descriptor to the step shell, so killing the launcher cannot unlock a +still-running child. + +## Code and credentials + +`/srv/stack127-self-host` is a separate, clean clone of +`https://github.com/stack127labs/stack127.git`. Every nominal run and hourly +catch-up fetches `origin/main` and merges with `--ff-only` before running the updated due module. +Unmarked local edits, diverged history, unpublished commits and unreachable +remotes fail visibly. + +Cron calls a reviewed copy of `run-job.sh` installed outside that checkout at +`/srv/stack127-data/self-host-apps/run-job.sh`. Git cannot damage this supervisor +while updating job code. The supervisor runs the updated checkout's step wrapper; +a syntax error there is logged and the next hourly fetch can bring its repair. +After fetching, catch-up compares the installed supervisor with the repo copy. +A mismatch fails catch-up with a clear instruction to perform the reviewed, +syntax-checked reinstall by atomic rename below. Data jobs continue to run; +fetching still works so a later update can bring a repair. Do not point cron +back into the Git checkout. + +Under the shared checkout lock, each update atomically records its starting +commit in both fields of `update-in-progress` before fetching. Before merging, +it atomically replaces the second field with the intended target commit. After +a crash, a remaining journal or Git lock triggers recovery. If any Git process owned by ubuntu is +still alive, recovery fails and retries next hour. Otherwise it logs changed +paths and stale lock names, removes those locks, and resets to the target commit. +This includes new files already created by an interrupted merge. The journal +stays until reset succeeds and the checkout is clean, then the updater fetches +and fast-forwards again. Older single-field journals reset to their recorded +commit; an old lock without a journal resets to HEAD. Recovery can be interrupted +and repeated. This clone is automation-owned: do not edit it by hand. During recovery, tracked edits and untracked files in +the way of tracked paths may be overwritten by `git reset --hard`; names, never +contents, are logged first. Other untracked files are kept and block the update. +Ordinary edits without a recovery journal or stale Git lock are never reset. + +`npm ci --no-audit --no-fund` runs only if the lockfile hash changes or +node_modules is absent. Its success marker lives outside the checkout. An +interrupted install has no new marker and is retried. A per-job nonblocking +`flock -E 76` prevents overlap. A shared checkout lock covers update and execution, +so another job cannot change code or dependencies mid-run. `/srv/stack127` and +its ad-network jobs are untouched. The revision is recorded in the job log. + +Infisical `stack127 /vps/self-host-apps` writes +`/srv/stack127-data/self-host-apps.env` through the Cockpit sync. The wrapper +requires a regular file, mode 600, owned by the job user. Values are parsed by +Node, never evaluated as shell code. Credentials: + +- `DATABASE_URL`: production `stack127_app` on `127.0.0.1:5432/postgres`, + `sslmode=disable`; use the same connection as launch-directory metrics. +- `GITHUB_TOKEN`: GitHub discovery, catalogs, facts and technical jobs. Copy an + existing authorised Infisical credential; no interactive `gh` login is needed. +- `GITLAB_TOKEN`, `CODEBERG_TOKEN`: optional, for source-host refresh. Without them + the job reads the public APIs anonymously, as it always did on GitHub Actions. + Add them only if rate limits start to fail runs. Never fill them with placeholders. + +GitHub API reads follow at most five redirects within `https://api.github.com`. +A redirect to another origin, HTTP, or a URL containing credentials fails before +any request goes there. Renamed and transferred repositories remain readable. + +Docker Hub needs no token. Sending a GitHub bearer to Docker Hub caused the old +401 failures. The catalog fetcher sends it only to `api.github.com`. Transient +HTTP/network failures retry three times with backoff; a permanent error fails +immediately and stays in the catalog report. Other catalogs still run, followed +by dedupe even on failure. No failed catalog is silently treated as success. + +## Schedule and catch-up + +| Job suffix (`self-host-…`) | Former intended IST time | New nominal UTC time | Whole-run limit | +| --- | --- | --- | --- | +| discovery-github | Daily 07:54 | Daily 07:02 | 20 min | +| discovery-lists | Sunday 09:11 | Sunday 08:10 | 20 min | +| discovery-catalogs | Sunday 09:47 | Sunday 09:14 | 45 min | +| discovery-directories | 1st 09:42 | 1st 10:18 | 30 min | +| refresh-github | Daily 05:30, 17:30 | Daily 07:06, 19:06 | 20 min | +| refresh-source-hosts | Daily 11:06 | Daily 11:22 | 30 min | +| refresh-technical | Monday 10:53 | Monday 12:26 | 60 min | + +Each data job has its own cron line at the nominal times above, with `--due`. +The dashboard therefore sees the real schedule and can flag overdue work. +`self-host-catch-up` has its own line and log at minute 30 each hour, excluding +hours 05 and 06. It updates the checkout, checks supervisor drift, releases the +checkout lock, then calls the seven installed job wrappers in order with `--due`. +Each wrapper updates and rechecks due status under the shared lock, so a nominal +run racing catch-up cannot repeat a completed period. It compares the latest +nominal occurrence against the last successful start time. First install is due +immediately. Missed periods catch up once; failures retry next hour. Success +markers are renamed atomically only after all required steps succeed. + +A completed-period check exits 0 without changing the data job log or success +marker. Checkout output is buffered until work starts, then streamed to the job +log with its start line. Setup or update failures flush that output with failure +start/end lines even when no steps could run. Catch-up always logs its own +start/end lines, including on drift or checkout failure. A failed child also +fails catch-up and names that child's log. No-op checks never appear as green +data runs on the dashboard. + +Catch-up and its children wait at most 30 seconds for the shared checkout lock; +a busy lock exits 76 for retry next hour. A per-job overlap is also exit 76 and +does not add a data-job run. Nominal jobs wait until ten seconds before their whole-run deadline. If they +never acquire the checkout lock, they log a skipped run (76), not a failure. +A timeout after acquiring the lock remains a failure. +Catch-up runs due jobs sequentially; its whole-run limit is 240 minutes, covering +the seven job limits (225 minutes total) and bounded lock waits. Its own per-job +lock prevents overlapping catch-up sweeps. All cron starts avoid :35 to :50 and +the 05:35 to 06:45 release window. + +GitHub facts still select the oldest attempts with `--max-age-hours 12`; source +hosts still use `--max-age-hours 24`. Discovery upserts candidates by source/key +and dedupe is repeatable. Install-options inspection remains dry-run; this job +never writes inferred editorial technical facts. + +## Install after merge + +The lead installs cron only after the reviewed code reaches main. First ensure +`DATABASE_URL` and `GITHUB_TOKEN` exist and the Infisical sync succeeds. +`GITLAB_TOKEN` and `CODEBERG_TOKEN` are optional. If the clone does not +exist, create it as ubuntu (sudo may be needed just for the directory): + +```sh +sudo install -d -o ubuntu -g ubuntu -m 755 /srv/stack127-self-host +git clone --depth 1 https://github.com/stack127labs/stack127.git /srv/stack127-self-host +``` + +On OVH as ubuntu: + +```sh +cd /srv/stack127-self-host +git fetch origin main +git merge --ff-only origin/main +systemctl is-enabled cron +loginctl show-user ubuntu -p Linger +systemctl --user is-active default.target +``` + +Install the reviewed supervisor as ubuntu, outside the checkout. Check syntax +before atomically replacing it. Re-run this step after reviewed supervisor changes: + +```sh +install -d -m 700 /srv/stack127-data/self-host-apps +install -d -m 755 /srv/stack127-data/logs +install -m 700 scripts/self-host-apps/ovh/run-job.sh /srv/stack127-data/self-host-apps/run-job.sh.new +bash -n /srv/stack127-data/self-host-apps/run-job.sh.new +mv /srv/stack127-data/self-host-apps/run-job.sh.new /srv/stack127-data/self-host-apps/run-job.sh +cmp scripts/self-host-apps/ovh/run-job.sh /srv/stack127-data/self-host-apps/run-job.sh +``` + +Run one job by hand to check the update and dependencies: + +```sh +/bin/bash /srv/stack127-data/self-host-apps/run-job.sh self-host-refresh-github +``` + +Append the block once, preserving all existing entries. This command refuses to +add a second copy. Review the saved crontab before installing: + +```sh +crontab -l > /srv/stack127-data/self-host-apps/crontab.before +if ! grep -q '^# BEGIN stack127 self-host apps' /srv/stack127-data/self-host-apps/crontab.before; then + cat /srv/stack127-data/self-host-apps/crontab.before /srv/stack127-self-host/scripts/self-host-apps/ovh/crontab.txt > /srv/stack127-data/self-host-apps/crontab.new + crontab /srv/stack127-data/self-host-apps/crontab.new +fi +crontab -l +``` + +## Operate + +Use the same command without `--due` to force one job. Logs are +`/srv/stack127-data/logs/.log`; the dashboard reads the UTC `== … start …` +and `== … end … exit …` lines. Exit 76 means the job or checkout lock is busy; +catch-up logs a shared-lock skip. Nominal shared-lock deadlines also log a skip. +Per-job overlaps stay silent for every job, so they cannot interrupt the active +run’s marker pair. Catch-up children that find a busy shared lock stay silent. +Other nonzero exits are failures, including checkout, env, dependency and timeout +errors. Failure traps are installed before argument checks and setup. Cron also +redirects stderr to the job log, so even a damaged installed supervisor's Bash +startup or syntax error is visible. A broken updated step wrapper produces the +normal failure start/end lines from the separate installed supervisor. +Candidate storage stops at the first database write error, so a schema change +cannot produce a warning-only successful discovery run. A supervisor killed with +SIGKILL cannot write its end line; cron still +retries because no success marker is written. OS locks release when the last +child exits; the timeout kills the run's process group after 30 seconds of grace. + +Technical evidence, QA and install-options steps print bounded counts, report +paths and at most five failure examples. Full evidence stays in JSON files; +install-options remains dry-run. Step stdout/stderr is capped at 128 KiB in the +job log, keeping the first part and the last 16 KiB, so a late failure reason +survives; the full step output is saved as `-console.log` in the job's +report folder, and the command exit status is preserved. Dependency output and +checkout update output each have a 32 KiB cap with the same head-and-tail rule. Keep other setup/catch-up output +small: each run must stay well below the dashboard's 250,000-byte tail. Dedupe +and discovery writes print counts; catalog and source-host errors and per-row +refresh messages are also covered by the step cap. Markdown job summaries +remain complete files and never enter stdout. + +Catalog and technical JSON reports and Markdown summaries are dated under +`/srv/stack127-data/self-host-apps/reports//`. Report writers rename temporary +files atomically. Each run deletes only that job's reports older than 60 days. +Do not delete another job's files or use these evidence reports to apply facts +without source review. Technical failures now include attempted URLs and HTTP +errors, so dead sources can be distinguished from temporary network failures. +Docker Hub publisher READMEs use its public repository API, paced at two requests +per second. The collector avoids registry requests when a GitHub README or +Compose file is already readable. HTTP 429 respects Retry-After (capped at 60 +seconds), or waits 30 seconds before retrying. It never treats throttling as a +negative factual claim. + +For pre-merge validation only, a manual `--validation /absolute/checkout` uses a +working-tree copy, skips its Git update, and clearly labels the log. Cron never +uses this flag. Delete that temporary validation checkout after testing. No env +file belongs inside either checkout. diff --git a/scripts/self-host-apps/ovh/crontab.txt b/scripts/self-host-apps/ovh/crontab.txt new file mode 100644 index 000000000..82db962ae --- /dev/null +++ b/scripts/self-host-apps/ovh/crontab.txt @@ -0,0 +1,14 @@ +# BEGIN stack127 self-host apps (append to ubuntu's crontab; keep every existing line). +# Install the reviewed supervisor outside the Git checkout first; see README.md. +# Server clock: UTC. Nominal schedules stay visible to the dashboard. +# --due makes both nominal and catch-up runs skip completed periods silently. +# No starts at :35-:50 or during the 05:35-06:45 static release window. +2 7 * * * /bin/bash /srv/stack127-data/self-host-apps/run-job.sh self-host-discovery-github --due >> /srv/stack127-data/logs/self-host-discovery-github.log 2>&1 +6 7,19 * * * /bin/bash /srv/stack127-data/self-host-apps/run-job.sh self-host-refresh-github --due >> /srv/stack127-data/logs/self-host-refresh-github.log 2>&1 +10 8 * * 0 /bin/bash /srv/stack127-data/self-host-apps/run-job.sh self-host-discovery-lists --due >> /srv/stack127-data/logs/self-host-discovery-lists.log 2>&1 +14 9 * * 0 /bin/bash /srv/stack127-data/self-host-apps/run-job.sh self-host-discovery-catalogs --due >> /srv/stack127-data/logs/self-host-discovery-catalogs.log 2>&1 +18 10 1 * * /bin/bash /srv/stack127-data/self-host-apps/run-job.sh self-host-discovery-directories --due >> /srv/stack127-data/logs/self-host-discovery-directories.log 2>&1 +22 11 * * * /bin/bash /srv/stack127-data/self-host-apps/run-job.sh self-host-refresh-source-hosts --due >> /srv/stack127-data/logs/self-host-refresh-source-hosts.log 2>&1 +26 12 * * 1 /bin/bash /srv/stack127-data/self-host-apps/run-job.sh self-host-refresh-technical --due >> /srv/stack127-data/logs/self-host-refresh-technical.log 2>&1 +30 0-4,7-23 * * * /bin/bash /srv/stack127-data/self-host-apps/run-job.sh self-host-catch-up >> /srv/stack127-data/logs/self-host-catch-up.log 2>&1 +# END stack127 self-host apps diff --git a/scripts/self-host-apps/ovh/due.mjs b/scripts/self-host-apps/ovh/due.mjs new file mode 100644 index 000000000..a477682af --- /dev/null +++ b/scripts/self-host-apps/ovh/due.mjs @@ -0,0 +1,29 @@ +// Latest scheduled occurrence, rather than a wall-clock equality: cron can catch up after downtime. +export function scheduledAt(job, now = new Date()) { + const date = new Date(now); + date.setUTCSeconds(0, 0); + const schedules = { + 'self-host-discovery-github': [7, 2], + 'self-host-refresh-github': [7, 6], + 'self-host-discovery-lists': [8, 10, 0], + 'self-host-discovery-catalogs': [9, 14, 0], + 'self-host-discovery-directories': [10, 18, 'month'], + 'self-host-refresh-source-hosts': [11, 22], + 'self-host-refresh-technical': [12, 26, 1], + }; + const schedule = schedules[job]; + if (!schedule) throw new Error(`Unknown job: ${job}`); + const [hour, minute, day] = schedule; + date.setUTCHours(hour, minute, 0, 0); + if (day === 'month') { + date.setUTCDate(1); + if (date > now) date.setUTCMonth(date.getUTCMonth() - 1); + } else if (typeof day === 'number') { + date.setUTCDate(date.getUTCDate() - (date.getUTCDay() - day + 7) % 7); + if (date > now) date.setUTCDate(date.getUTCDate() - 7); + } else if (job === 'self-host-refresh-github') { + if (now.getUTCHours() >= 19) date.setUTCHours(19); + if (date > now) date.setUTCHours(date.getUTCHours() - 12); + } else if (date > now) date.setUTCDate(date.getUTCDate() - 1); + return date.getTime(); +} diff --git a/scripts/self-host-apps/ovh/run-job.sh b/scripts/self-host-apps/ovh/run-job.sh new file mode 100755 index 000000000..56c9e1944 --- /dev/null +++ b/scripts/self-host-apps/ovh/run-job.sh @@ -0,0 +1,351 @@ +#!/bin/bash +# Job name = dashboard identity and log name. Install as ubuntu; never run as root. +set -euo pipefail +export HOME=/home/ubuntu PATH=/usr/local/bin:/usr/bin:/bin +export GIT_TERMINAL_PROMPT=0 GIT_SSH_COMMAND='ssh -o BatchMode=yes -o ConnectTimeout=15' +export XDG_RUNTIME_DIR="/run/user/$(id -u)" +export DBUS_SESSION_BUS_ADDRESS="unix:path=$XDG_RUNTIME_DIR/bus" +export NODE_OPTIONS=--max-old-space-size=1536 +umask 077 +root=/srv/stack127-data/self-host-apps +checkout=/srv/stack127-self-host +job_env=/srv/stack127-data/self-host-apps.env +logs=/srv/stack127-data/logs + +# Keep the start and the end of the output, so a late failure reason survives the +# cap. Drain everything so writers never get EPIPE and pipefail keeps the exit code. +# An optional second argument saves the full output to a report file. +# Together with setup/install caps, a run stays well below the dashboard's 250 KB tail. +bounded_output() { + node -e ' + const fs = require("node:fs"); + const limit = Number(process.argv[1]), full = process.argv[2]; + const tailLimit = Math.min(16384, Math.floor(limit / 2)); + let headLeft = limit - tailLimit, tail = Buffer.alloc(0), dropped = 0, done = false; + const fullFd = full ? fs.openSync(full, "a", 0o600) : null; + process.stdin.on("data", chunk => { + if (fullFd !== null) fs.writeSync(fullFd, chunk); + if (headLeft > 0) { + const part = chunk.subarray(0, headLeft); + fs.writeSync(1, part); + headLeft -= part.length; + chunk = chunk.subarray(part.length); + } + if (!chunk.length) return; + tail = Buffer.concat([tail, chunk]); + if (tail.length > tailLimit) { dropped += tail.length - tailLimit; tail = tail.subarray(tail.length - tailLimit); } + }); + const finish = () => { + if (done) return; + done = true; + if (dropped) fs.writeSync(1, "\n[console output capped: " + dropped + " bytes omitted" + (full ? "; full output in " + full : "") + "]\n"); + fs.writeSync(1, tail); + }; + process.stdin.on("end", finish); + for (const signal of ["SIGTERM", "SIGINT", "SIGHUP"]) process.on(signal, () => { finish(); process.exit(143); }); + ' "$1" "${2:-}" +} + +steps() { + cd "$checkout" + # Every normal step stops the job on failure. Explicit rc collection preserves if: always(). + local rc=0 + case "$job" in + self-host-discovery-github) + for topic in self-hosted homelab local-ai home-automation ai-agent; do + npm run self-host-apps:discover-github-topic -- --topic "$topic" --limit 100 --write + done + npm run self-host-apps:dedupe-candidates -- --limit 10000 ;; + self-host-discovery-lists) + npm run self-host-apps:discover-awesome-selfhosted -- --limit 5000 --write + npm run self-host-apps:discover-selfhst -- --limit 5000 --write + npm run self-host-apps:dedupe-candidates -- --limit 10000 ;; + self-host-discovery-catalogs) + npm run self-host-apps:discover-app-catalogs -- --catalog all --limit 5000 --docker-limit 500 --docker-page-size 100 --out "$report_dir/$run_id-catalog-discovery.json" --write || rc=$? + npm run self-host-apps:dedupe-candidates -- --limit 30000 || rc=$? ;; + self-host-discovery-directories) + npm run self-host-apps:discover-selfhostedworld -- --limit 5000 --write + npm run self-host-apps:discover-openalternative -- --limit 5000 --write + npm run self-host-apps:dedupe-candidates -- --limit 20000 ;; + self-host-refresh-github) + npm run self-host-apps:refresh-github -- --published-only --batch-size 40 --limit 500 --max-age-hours 12 ;; + self-host-refresh-source-hosts) + npm run self-host-apps:refresh-gitlab -- --published-only --limit 500 --max-age-hours 24 || rc=$? + npm run self-host-apps:refresh-codeberg -- --published-only --limit 500 --max-age-hours 24 || rc=$? ;; + self-host-refresh-technical) + npm run self-host-apps:refresh-primary-technical-facts -- --refresh-all --concurrency 16 --max-failure-percent 5 --out "$report_dir/$run_id-technical-refresh.json" || rc=$? + # Deliberately dry-run (default); never add --apply here. + npm run self-host-apps:refresh-install-options -- --limit 5000 --out "$report_dir/$run_id-install-options.json" || rc=$? + npm run self-host-apps:qa-gate -- --limit 5000 --out "$report_dir/$run_id-technical-qa-gate.json" || rc=$? ;; + esac + return "$rc" +} + +# This checkout belongs only to this automation. A journal permits repair only of +# an interrupted update; ordinary dirty files and unpublished commits still fail. +update_checkout() { + local journal="$root/update-in-progress" base target lock lock_count=0 git_rc=0 + local git_dir + git_dir=$(git rev-parse --absolute-git-dir) + local locks=() + while IFS= read -r -d '' lock; do locks+=("$lock"); lock_count=$((lock_count + 1)); done < <(find "$git_dir" -type f -name '*.lock' -print0) + if [[ -f "$journal" || "$lock_count" -gt 0 ]]; then + # All our Git children inherit the checkout flock. Also refuse recovery while + # any Git owned by this user is alive, including a manual Git command. + pgrep -u "$(id -u)" -x git >/dev/null || git_rc=$? + if [[ "$git_rc" != 1 ]]; then + echo 'ERROR: Git is still running or its process list is unavailable; refusing checkout recovery' + return 1 + fi + target=$(git rev-parse HEAD) + if [[ -f "$journal" ]]; then + read -r base target < "$journal" + # Older supervisors recorded only the base. New journals record both the + # base and target before merge can create paths absent from the base. + target=${target:-$base} + [[ "$base" =~ ^[0-9a-f]{40,64}$ && "$target" =~ ^[0-9a-f]{40,64}$ ]] && + git cat-file -e "$target^{commit}" && git merge-base --is-ancestor "$base" "$target" || { + echo 'ERROR: invalid checkout recovery journal'; return 1; + } + fi + echo "RECOVERY: interrupted checkout update; completing tracked files at $target" + if [[ "$lock_count" -gt 0 ]]; then + for lock in "${locks[@]}"; do + echo "RECOVERY: removing stale Git lock ${lock#"$git_dir/"}" + rm -- "$lock" + done + fi + # Log names/status only, never file contents or credentials. Like any hard + # reset, this may overwrite untracked files in the way of tracked paths. + git status --short --untracked-files=normal + git reset --hard "$target" + [[ -z "$(git status --porcelain --untracked-files=normal)" ]] || { + echo 'ERROR: checkout recovery left local files; journal retained for operator review'; return 1; + } + rm -f "$journal" + fi + [[ -z "$(git status --porcelain --untracked-files=normal)" ]] || { + echo 'ERROR: self-host checkout is dirty without an interrupted update; refusing to reset local edits'; return 1; + } + base=$(git rev-parse HEAD) + printf '%s %s\n' "$base" "$base" > "$journal.tmp" + mv "$journal.tmp" "$journal" + git -c http.lowSpeedLimit=1024 -c http.lowSpeedTime=30 fetch --no-tags origin main + # Refuse unpublished history instead of discarding it during recovery. + if ! git merge-base --is-ancestor HEAD origin/main; then + rm -f "$journal" + echo 'ERROR: self-host checkout is ahead of or diverged from main' + return 1 + fi + target=$(git rev-parse origin/main) + printf '%s %s\n' "$base" "$target" > "$journal.tmp" + mv "$journal.tmp" "$journal" + git merge --ff-only "$target" + [[ "$(git rev-parse HEAD)" = "$target" && -z "$(git status --porcelain --untracked-files=normal)" ]] || { + echo 'ERROR: self-host checkout did not reach main'; return 1; + } + rm -f "$journal" + echo "revision $(git rev-parse HEAD)" +} + +check_due() { + # Only updated code decides what is due. A broken module is repaired by the + # next fetch, before it can stop the next due check. + node --input-type=module - "$job" "$root/state/$job.success" "$checkout/scripts/self-host-apps/ovh/due.mjs" <<'JS' +import { readFileSync } from 'node:fs'; +import { pathToFileURL } from 'node:url'; +const [job, state, modulePath] = process.argv.slice(2); +const { scheduledAt } = await import(pathToFileURL(modulePath)); +let last = 0; +try { last = Number(readFileSync(state, 'utf8')); } catch (error) { if (error.code !== 'ENOENT') throw error; } +process.exit(last >= scheduledAt(job) ? 3 : 0); +JS +} + +# Buffer checkout/due-check output until actual work begins. A no-op discards +# it; failures flush it with failure markers. Job output streams live after this. +begin_job_log() { + : > "$pending_log.started" + echo "== $(date -u +%FT%TZ) start $job" >> "$log_file" + bounded_output 32768 < "$pending_log" >> "$log_file" + printf '\n' >> "$log_file" + exec >> "$log_file" 2>&1 +} + +finish_run() { + local rc=$1 + trap - EXIT + set +e + exec >> "$log_file" 2>&1 + if [[ -n "$pending_log" && -f "$pending_log.started" ]]; then + printf '\n== %s end %s exit %s\n' "$(date -u +%FT%TZ)" "$job" "$rc" + elif [[ "$rc" != 0 && "$rc" != 76 ]]; then + echo "== $outer_started start $job" + [[ -z "$pending_log" ]] || bounded_output 32768 < "$pending_log" + echo "ERROR: $job failed before job steps (exit $rc); see preceding error" + printf '\n== %s end %s exit %s\n' "$(date -u +%FT%TZ)" "$job" "$rc" + fi + if [[ -n "$pending_log" ]]; then rm -f "$pending_log" "$pending_log.started" "$pending_log.locked" "$pending_log.waiting"; fi + exit "$rc" +} + +catch_up() { + # Fetch/repair first, then check the stable supervisor. Drift must never stop + # normal data jobs or prevent a later fetch from bringing a repair. + if ! cmp -s "$self" "$checkout/scripts/self-host-apps/ovh/run-job.sh"; then + echo 'ERROR: installed self-host supervisor differs from the checkout; perform the reviewed atomic reinstall in scripts/self-host-apps/ovh/README.md' + return 1 + fi + # Child wrappers take their own shared lock. Do not inherit ours and deadlock. + exec 8>&- + local next next_rc rc=0 + for next in self-host-discovery-github self-host-refresh-github self-host-discovery-lists self-host-discovery-catalogs self-host-discovery-directories self-host-refresh-source-hosts self-host-refresh-technical; do + local args=("$next" --due --catch-up) + [[ "$validation" = 0 ]] || args+=(--validation "$checkout") + next_rc=0 + /bin/bash "$self" "${args[@]}" || next_rc=$? + if [[ "$next_rc" = 76 ]]; then + echo "Skipped $next: job or checkout lock busy; retry next hour" + [[ "$rc" != 0 ]] || rc=76 + elif [[ "$next_rc" != 0 ]]; then + echo "ERROR: catch-up $next failed (exit $next_rc); see $logs/$next.log" + rc=1 + fi + done + return "$rc" +} + +execute() { + # One shared lock covers update AND execution, so another job cannot change imported code + # or node_modules mid-run. The outer timeout bounds the lock wait as well. + exec 8>"$root/checkout.lock" + if [[ -n "${pending_log:-}" ]]; then : > "$pending_log.waiting"; fi + if [[ "$job" = self-host-catch-up || "$catchup_child" = 1 ]]; then + flock -w 30 -E 76 8 || { echo 'Checkout lock busy; retry next hour'; return 76; } + else + # Leave time for the skip to reach the outer supervisor before its deadline. + flock -w "$((minutes * 60 - 10))" -E 76 8 || { + begin_job_log + echo "Skipped: checkout lock busy; no job steps started; retry next schedule" + return 76 + } + fi + if [[ -n "${pending_log:-}" ]]; then : > "$pending_log.locked"; fi + cd "$checkout" + if [[ "$validation" = 0 ]]; then + # Capped like other output, so a verbose merge or recovery cannot push this + # run's start marker out of the dashboard's log tail (catch-up logs live). + update_checkout 2>&1 | bounded_output 32768 + else + echo "VALIDATION: working-tree code in $checkout; main update disabled for this manual run" + fi + if [[ "$job" = self-host-catch-up ]]; then catch_up; return; fi + if [[ "$due" = 1 ]]; then + local due_rc=0 + check_due || due_rc=$? + if [[ "$due_rc" = 3 ]]; then return 0; fi + [[ "$due_rc" = 0 ]] || { echo 'ERROR: due check failed'; exit "$due_rc"; } + fi + begin_job_log + if [[ "$validation" = 0 ]]; then + local fingerprint + fingerprint=$(sha256sum package-lock.json | cut -d' ' -f1) + if [[ ! -d node_modules || ! -f "$root/dependencies.sha256" || "$(cat "$root/dependencies.sha256")" != "$fingerprint" ]]; then + rm -f "$root/dependencies.sha256" + npm ci --no-audit --no-fund 2>&1 | bounded_output 32768 + printf '%s\n' "$fingerprint" > "$root/dependencies.sha256.tmp" + mv "$root/dependencies.sha256.tmp" "$root/dependencies.sha256" + fi + fi + [[ -f "$job_env" && ! -L "$job_env" && "$(stat -c '%a' "$job_env")" = 600 && "$(stat -c '%u' "$job_env")" = "$(id -u)" ]] || { + echo 'ERROR: self-host env must exist, belong to the job user, and have mode 600'; exit 1; + } + # Use Node's dotenv parser; never evaluate secret values as shell code. + export GITHUB_STEP_SUMMARY="$report_dir/$run_id-summary.md" + unset DATABASE_URL GITHUB_TOKEN GITLAB_TOKEN CODEBERG_TOKEN + local started + started=$(date +%s%3N) + # Run the updated step definitions. Syntax/launcher failures reach the stable + # supervisor's log and EXIT trap; they cannot prevent the next checkout update. + node --env-file="$job_env" scripts/self-host-apps/ovh/validate-env.mjs "$job" "$checkout/scripts/self-host-apps/ovh/run-job.sh" "$checkout" "$run_id" 2>&1 | bounded_output 131072 "$report_dir/$run_id-console.log" + printf '%s\n' "$started" > "$root/state/$job.success.tmp" + mv "$root/state/$job.success.tmp" "$root/state/$job.success" +} + +main() { + local phase=outer due=0 validation=0 catchup_child=0 pending_log= outer_started + if [[ "${1:-}" = --execute || "${1:-}" = --steps ]]; then phase=${1#--}; shift; fi + job=${1:-}; shift || true + local minutes + case "$job" in + self-host-discovery-github|self-host-discovery-lists|self-host-refresh-github) minutes=20 ;; + self-host-discovery-catalogs) minutes=45 ;; + self-host-discovery-directories|self-host-refresh-source-hosts) minutes=30 ;; + self-host-refresh-technical) minutes=60 ;; + # Sum of the seven data-job limits is 225 minutes, plus bounded lock waits. + self-host-catch-up) minutes=240 ;; + *) echo 'Usage: run-job.sh self-host-{discovery-{github,lists,catalogs,directories},refresh-{github,source-hosts,technical},catch-up} [--due | --validation CHECKOUT]' >&2; exit 2 ;; + esac + log_file="$logs/$job.log" + if [[ "$phase" = outer ]]; then + # Cron also redirects stderr here, covering a damaged installed script before + # Bash can reach this trap. Install this supervisor outside the Git checkout. + mkdir -p "$logs" + exec >>"$log_file" 2>&1 + outer_started=$(date -u +%FT%TZ) + trap 'finish_run "$?"' EXIT + trap 'exit 143' TERM + trap 'exit 130' INT + # Serialize argument/setup failures too; hold until finish_run's final marker. + mkdir -p "$root" + exec 9>"$root/$job.lock" + flock -n -E 76 9 || exit 76 + fi + self=$(realpath "$0") + if [[ "$phase" = steps ]]; then + checkout=$1; run_id=$2 + else + while [[ $# -gt 0 ]]; do + case "$1" in + --due) due=1; shift ;; + --catch-up) catchup_child=1; shift ;; + --pending-log) pending_log=$2; shift 2 ;; + --validation) checkout=$(realpath "$2"); validation=1; shift 2 ;; + --run-id) run_id=$2; shift 2 ;; + *) echo "Unknown argument: $1" >&2; exit 2 ;; + esac + done + fi + report_dir="$root/reports/$job" + if [[ "$phase" = steps ]]; then steps; return; fi + mkdir -p "$report_dir" "$root/state" "$logs" + run_id=${run_id:-$(date -u +%Y-%m-%dT%H-%M-%SZ)-$$} + if [[ "$phase" = execute ]]; then execute; return; fi + pending_log=$(mktemp "$report_dir/.pending-log-XXXXXX") + exec >> "$pending_log" 2>&1 + if [[ "$job" = self-host-catch-up ]]; then begin_job_log; fi + # 16 GB virtual address space (V8 reserves about 10 GB); real V8 heap capped at 1.5 GB. + # A transient resource scope caps the whole process tree at 2 GB and one CPU. + # Cron owns the schedule; no systemd timer or persistent service is installed. + ulimit -v 16777216 + ulimit -c 0 + local args=(--execute "$job" --run-id "$run_id" --pending-log "$pending_log") rc=0 + [[ "$due" = 0 ]] || args+=(--due) + [[ "$catchup_child" = 0 ]] || args+=(--catch-up) + [[ "$validation" = 0 ]] || args+=(--validation "$checkout") + nice -n 10 timeout --signal=TERM --kill-after=30s "${minutes}m" systemd-run --user --scope --quiet -p MemoryMax=2G -p MemorySwapMax=0 -p CPUQuota=100% -p RuntimeMaxSec="${minutes}min" /bin/bash "$self" "${args[@]}" || rc=$? + if [[ "$rc" = 124 && -f "$pending_log.waiting" && ! -f "$pending_log.locked" ]]; then + rc=76 + [[ -f "$pending_log.started" ]] || begin_job_log + echo 'Skipped: checkout lock wait reached the run deadline; no job steps started' + fi + if [[ -f "$pending_log.started" ]]; then exec >> "$log_file" 2>&1; fi + if [[ "$rc" = 76 ]]; then + echo 'Job or checkout lock busy; retry next schedule' + elif [[ "$rc" != 0 ]]; then + echo "ERROR: $job failed (exit $rc); see preceding step or update error" + fi + find "$report_dir" -type f -mtime +60 -delete + exit "$rc" +} +if [[ "${BASH_SOURCE[0]}" = "$0" ]]; then main "$@"; fi diff --git a/scripts/self-host-apps/ovh/validate-env.mjs b/scripts/self-host-apps/ovh/validate-env.mjs new file mode 100644 index 000000000..bdde68426 --- /dev/null +++ b/scripts/self-host-apps/ovh/validate-env.mjs @@ -0,0 +1,14 @@ +// Runs only after Node has parsed the private env file. Never print a credential. +import { spawn } from 'node:child_process'; +const [job, wrapper, checkout, runId] = process.argv.slice(2); +function fail(message) { console.error(`ERROR: ${message}`); process.exit(1); } +let database; +try { database = new URL(process.env.DATABASE_URL); } catch { fail('DATABASE_URL is missing or invalid'); } +if (database.hostname !== '127.0.0.1' || database.username !== 'stack127_app' || database.pathname !== '/postgres' || (database.port || '5432') !== '5432' || database.searchParams.get('sslmode') !== 'disable') fail('DATABASE_URL must use the loopback production app connection with sslmode=disable'); +if (['self-host-discovery-github', 'self-host-discovery-catalogs', 'self-host-refresh-github', 'self-host-refresh-technical'].includes(job) && !process.env.GITHUB_TOKEN?.trim()) fail('GITHUB_TOKEN is missing'); +// GITLAB_TOKEN and CODEBERG_TOKEN are optional: both APIs answer public reads without them. +// Descriptor 8 holds the checkout flock. The step shell must retain it if this launcher dies. +const child = spawn('/bin/bash', [wrapper, '--steps', job, checkout, runId], { stdio: ['inherit', 'inherit', 'inherit', 'ignore', 'ignore', 'ignore', 'ignore', 'ignore', 8] }); +for (const signal of ['SIGTERM', 'SIGINT']) process.on(signal, () => child.kill(signal)); +child.on('error', () => fail('Could not start job steps')); +child.on('exit', (code, signal) => process.exit(code ?? (signal === 'SIGKILL' ? 137 : 143))); diff --git a/scripts/self-host-apps/qa-gate.mjs b/scripts/self-host-apps/qa-gate.mjs index 22d64e0d8..8ebf5a260 100644 --- a/scripts/self-host-apps/qa-gate.mjs +++ b/scripts/self-host-apps/qa-gate.mjs @@ -1,7 +1,6 @@ #!/usr/bin/env node -import { writeFileSync, mkdirSync } from "node:fs"; -import { dirname } from "node:path"; +import { reportOutput } from "./lib/report-output.mjs"; import { parseArgs, numberArg } from "./lib/args.mjs"; import { withClient } from "./lib/db.mjs"; import { validateSummary } from "./lib/summary-rules.mjs"; @@ -166,12 +165,12 @@ async function main() { })), }; - if (args.out) { - mkdirSync(dirname(args.out), { recursive: true }); - writeFileSync(args.out, `${JSON.stringify(report, null, 2)}\n`); - } - - console.log(JSON.stringify(report, null, 2)); + reportOutput(report, args.out, { + published: report.published, blocked_count: report.blocked_count, + excluded_published_count: report.excluded_published_count, + blocker_counts: report.blocker_counts, + failures: report.blocked_rows.map(({ slug, blockers }) => ({ slug, blockers })), + }); jobSummary("Publication QA", { Published: report.published, Blocked: report.blocked_count, "Excluded published": report.excluded_published_count }); } diff --git a/scripts/self-host-apps/refresh-install-options.mjs b/scripts/self-host-apps/refresh-install-options.mjs index 80a2779fc..2c69cdf3a 100644 --- a/scripts/self-host-apps/refresh-install-options.mjs +++ b/scripts/self-host-apps/refresh-install-options.mjs @@ -5,6 +5,7 @@ import { parseArgs, numberArg } from "./lib/args.mjs"; import { withClient } from "./lib/db.mjs"; import { buildInstallOptions } from "./lib/install-options.mjs"; import { installMethods, installOptionsToRow } from "./lib/streamlined-storage.mjs"; +import { reportOutput } from "./lib/report-output.mjs"; import { jobSummary } from "./lib/job-summary.mjs"; function rowValues(row, options, checkedAt) { @@ -113,7 +114,8 @@ async function main() { }; }, args.environment ? databaseOptions(args) : args["database-url"]); - console.log(JSON.stringify(report, null, 2)); + const { sample: _sample, ...counts } = report; + reportOutput(report, args.out, counts); jobSummary("Install destinations", { Mode: report.mode, Apps: report.apps, "Apps with options": report.apps_with_options, Options: report.options }); } diff --git a/scripts/self-host-apps/refresh-primary-technical-facts.mjs b/scripts/self-host-apps/refresh-primary-technical-facts.mjs index 0ad55d631..1048cb053 100644 --- a/scripts/self-host-apps/refresh-primary-technical-facts.mjs +++ b/scripts/self-host-apps/refresh-primary-technical-facts.mjs @@ -1,6 +1,7 @@ #!/usr/bin/env node -import { writeFile } from "node:fs/promises"; +import { technicalReportOutput } from "./lib/report-output.mjs"; +import { readPrimarySource as fetchText } from "./lib/primary-source.mjs"; import { parseArgs } from "./lib/args.mjs"; import { parseComposeFacts } from "./lib/catalog-facts.mjs"; import { withClient } from "./lib/db.mjs"; @@ -41,46 +42,26 @@ function githubRepository(value) { } } -async function fetchText(url) { - const response = await fetch(url, { - headers: { "User-Agent": "stack127-self-host-primary-facts/1.0" }, - redirect: "follow", - signal: AbortSignal.timeout(45_000), - }); - if (!response.ok) throw new Error(`HTTP ${response.status}`); - return { text: (await response.text()).slice(0, 500_000), url: response.url }; -} - -function rawGithubUrl(value) { - try { - const url = new URL(value); - const parts = url.pathname.split("/").filter(Boolean); - const blobIndex = parts.indexOf("blob"); - if (url.hostname !== "github.com" || blobIndex !== 2 || parts.length < 5) return value; - return `https://raw.githubusercontent.com/${parts[0]}/${parts[1]}/${parts.slice(blobIndex + 1).join("/")}`; - } catch { - return value; - } -} - -async function firstGithubFile(repository, paths) { +async function firstGithubFile(repository, paths, errors) { for (const file of paths) { const url = `https://raw.githubusercontent.com/${repository.owner}/${repository.repo}/HEAD/${file}`; try { const result = await fetchText(url); return { ...result, file }; - } catch { + } catch (error) { + errors.push({ url, error: error.message }); // Conventional official paths are checked in order. } } return null; } -async function firstOfficialUrl(urls) { +async function firstOfficialUrl(urls, errors) { for (const url of unique(urls)) { try { return await fetchText(url); - } catch { + } catch (error) { + errors.push({ url, error: error.message }); // Try the next official URL before reporting an unreviewable row. } } @@ -89,6 +70,7 @@ async function firstOfficialUrl(urls) { async function collect(app) { const repository = githubRepository(app.source_code_url); + const errors = []; let readme = null; let compose = null; let dockerPage = null; @@ -96,15 +78,20 @@ async function collect(app) { let checkedUrl = app.install_docs_url ?? app.docs_url ?? app.docker_url ?? app.source_code_url ?? app.website_url; if (repository) { - [readme, compose, dockerPage] = await Promise.all([ - firstGithubFile(repository, readmePaths), + [readme, compose] = await Promise.all([ + firstGithubFile(repository, readmePaths, errors), app.compose_url - ? fetchText(rawGithubUrl(app.compose_url)).catch(() => firstGithubFile(repository, composePaths)) - : firstGithubFile(repository, composePaths), - app.docker_url && app.docker_url !== app.compose_url - ? fetchText(rawGithubUrl(app.docker_url)).catch(() => null) - : null, + ? fetchText(app.compose_url).catch(() => firstGithubFile(repository, composePaths, errors)) + : firstGithubFile(repository, composePaths, errors), ]); + // A readable README/Compose is already primary evidence. Avoid thousands of + // redundant registry-page requests that throttle Docker-only apps later. + if (!readme && !compose && app.docker_url && app.docker_url !== app.compose_url) { + dockerPage = await fetchText(app.docker_url).catch(error => { + errors.push({ url: app.docker_url, error: error.message }); + return null; + }); + } if (!readme && !compose && !dockerPage) { fallback = await firstOfficialUrl([ app.install_docs_url, @@ -112,7 +99,7 @@ async function collect(app) { app.docker_url, app.website_url, app.source_code_url, - ]); + ], errors); } checkedUrl = readme?.url ?? compose?.url ?? dockerPage?.url ?? fallback?.url ?? checkedUrl; } else { @@ -123,12 +110,12 @@ async function collect(app) { app.compose_url, app.website_url, app.source_code_url, - ]); + ], errors); readme = fallback; checkedUrl = fallback?.url ?? checkedUrl; } - if (!readme && !compose && !dockerPage && !fallback) throw new Error("No official primary source could be read"); + if (!readme && !compose && !dockerPage && !fallback) throw Object.assign(new Error("No official primary source could be read"), { sources: errors }); const primaryText = [readme?.text, dockerPage?.text, fallback?.text].filter(Boolean).join("\n"); const requirements = parsePublishedRequirements(primaryText); const readmeContainers = readmeContainerSupport(primaryText); @@ -199,11 +186,11 @@ async function main() { try { return { ok: true, ...(await collect(app)) }; } catch (error) { - return { ok: false, app, error: error.message }; + return { ok: false, app, error: error.message, sources: error.sources ?? [] }; } }); const passed = collected.filter((record) => record.ok); - const failed = collected.filter((record) => !record.ok).map((record) => ({ slug: record.app.slug, error: record.error })); + const failed = collected.filter((record) => !record.ok).map((record) => ({ slug: record.app.slug, error: record.error, sources: record.sources })); const factCounts = {}; for (const record of passed) { @@ -227,8 +214,7 @@ async function main() { .map((record) => ({ slug: record.app.slug, source_url: record.composeUrl ?? record.checkedUrl, image: record.facts.dockerImage })), records: passed.map((record) => ({ slug: record.app.slug, review_status: "pending", checked_url: record.checkedUrl, readme_url: record.readmeUrl, compose_url: record.composeUrl, candidate_facts: record.facts })), }; - if (args.out) await writeFile(args.out, `${JSON.stringify(report, null, 2)}\n`); - console.log(JSON.stringify(report, null, 2)); + technicalReportOutput(report, args.out); jobSummary("Technical evidence (review required; no facts written)", { Selected: rows.length, Readable: passed.length, Unreadable: failed.length, "Allowed unreadable percentage": maxFailurePercent }, failed); if (failed.length) console.warn(`Unreadable primary sources: ${failed.length}/${rows.length}; see the report and job summary.`); if (failed.length && (passed.length === 0 || failed.length / rows.length * 100 > maxFailurePercent)) process.exitCode = 1; diff --git a/scripts/self-host-apps/refresh-source-host.mjs b/scripts/self-host-apps/refresh-source-host.mjs index 55c9a63e5..89562c480 100644 --- a/scripts/self-host-apps/refresh-source-host.mjs +++ b/scripts/self-host-apps/refresh-source-host.mjs @@ -1,4 +1,5 @@ #!/usr/bin/env node +import { boundedSummary } from "./lib/report-output.mjs"; import { parseArgs, numberArg } from "./lib/args.mjs"; import { withClient } from "./lib/db.mjs"; @@ -240,7 +241,7 @@ export async function refreshSourceHost(client, args) { } const report = { host, dry_run: Boolean(args["dry-run"]), selected: rows.length, refreshed, failed: failures.length, failures }; - console.log(JSON.stringify(report, null, 2)); + console.log(boundedSummary(report)); jobSummary(`${host} facts`, { Selected: rows.length, Refreshed: refreshed, Failed: failures.length, "Allowed failure percentage": 20, "Dry run": Boolean(args["dry-run"]) }, failures); return report; } diff --git a/skills/self-host-apps/discovery/SKILL.md b/skills/self-host-apps/discovery/SKILL.md index f6e063e47..8ca74e107 100644 --- a/skills/self-host-apps/discovery/SKILL.md +++ b/skills/self-host-apps/discovery/SKILL.md @@ -14,6 +14,7 @@ Discovery writes staging evidence only. Official app sources and the enrichment 1. Read `content_docs/self-host-apps/data-workflow.md` and `schema.md`. 2. Run the relevant `self-host-apps:discover-*` command with `--write`. Use `self-host-apps:discover-app-catalogs` for the official TrueNAS and Umbrel catalogs; these sources include commercial and closed-source apps that open-source lists omit. + Scheduled discovery runs on OVH through `scripts/self-host-apps/ovh/run-job.sh`; see its `README.md` for schedules, catch-up, credentials, logs and dated reports. 3. Confirm the command report and candidate count. Run history is kept in the job log, not a database import-run table. 4. Dry-run, then apply dedupe: `npm run self-host-apps:dedupe-candidates -- --dry-run` diff --git a/stack127_docs/automation-monitor.md b/stack127_docs/automation-monitor.md index cac5b7201..a13fc49f6 100644 --- a/stack127_docs/automation-monitor.md +++ b/stack127_docs/automation-monitor.md @@ -29,8 +29,9 @@ A job with no entry still shows up, marked "No description yet". ## What the dashboard can't see - On OVH, the `ubuntu` account can't read Docker or the system journal. So containers aren't listed, and timer logs are empty. -- Ad network jobs report through `/srv/stack127-data/logs/.log`. The dashboard reads the `==