Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
98f7aa8a2d | ||
|
|
898cf48c80 | ||
|
|
70fe425043 | ||
|
|
567b033c46 | ||
|
|
1934470d53 | ||
|
|
fdbe9fdb88 | ||
|
|
e082113640 | ||
|
|
860d483bad | ||
|
|
783ec83464 |
@@ -152,7 +152,97 @@ jobs:
|
||||
|
||||
# One push for both refs: a commit that lands without its tag builds
|
||||
# nothing and looks like a successful release.
|
||||
git push origin "HEAD:master" "refs/tags/v${VERSION}"
|
||||
#
|
||||
# The output is captured because a failing *post-receive* hook does not
|
||||
# fail the push: git prints `remote: error: ...`, updates both refs and
|
||||
# exits 0. That is how v1.0.3 was cut — the hook 500'd, so Gitea never
|
||||
# created the build run, and this step went green anyway.
|
||||
if ! git push origin "HEAD:master" "refs/tags/v${VERSION}" 2>push.log; then
|
||||
cat push.log
|
||||
echo "::error::Push failed. Nothing was released."
|
||||
exit 1
|
||||
fi
|
||||
cat push.log
|
||||
|
||||
if grep -q '^remote: error' push.log; then
|
||||
echo "::warning::The remote's post-receive hook errored. Both refs landed,"
|
||||
echo "::warning::but Gitea most likely created no workflow run for them."
|
||||
echo "::warning::The next step checks and dispatches build.yml if needed."
|
||||
fi
|
||||
|
||||
echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
||||
id: push
|
||||
|
||||
# Gitea creates workflow runs from the post-receive hook, so a hook error
|
||||
# silently costs you the build: the tag exists, no image is ever published,
|
||||
# and the failure only surfaces later as a 404 when deploy pulls the image.
|
||||
# Confirm the run exists; dispatch it if it does not; fail loudly if that
|
||||
# does not work either.
|
||||
- name: Verify build.yml started
|
||||
env:
|
||||
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||
VERSION: ${{ steps.ver.outputs.version }}
|
||||
SHA: ${{ steps.push.outputs.sha }}
|
||||
run: |
|
||||
node -e '
|
||||
const base = `${process.env.GITHUB_SERVER_URL}/api/v1/repos/${process.env.GITHUB_REPOSITORY}`;
|
||||
const headers = { Authorization: `token ${process.env.RELEASE_TOKEN}` };
|
||||
const sha = process.env.SHA;
|
||||
const tag = `v${process.env.VERSION}`;
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
const started = async () => {
|
||||
const res = await fetch(`${base}/actions/runs?limit=30`, { headers });
|
||||
if (!res.ok) throw new Error(`runs query failed: HTTP ${res.status}`);
|
||||
const body = await res.json();
|
||||
return (body.workflow_runs || []).some(
|
||||
(r) => r.head_sha === sha && String(r.path || "").includes("build.yml"),
|
||||
);
|
||||
};
|
||||
|
||||
// The hook fires synchronously with the push, so a run that is coming
|
||||
// is usually already there; the retries cover a busy instance.
|
||||
const poll = async (attempts) => {
|
||||
for (let i = 0; i < attempts; i++) {
|
||||
if (await started()) return true;
|
||||
await sleep(10_000);
|
||||
}
|
||||
return started();
|
||||
};
|
||||
|
||||
(async () => {
|
||||
if (await poll(3)) {
|
||||
console.log(`build.yml is running for ${sha}.`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`No build.yml run for ${sha}. Dispatching against ${tag}.`);
|
||||
const res = await fetch(
|
||||
`${base}/actions/workflows/build.yml/dispatches`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...headers, "Content-Type": "application/json" },
|
||||
// Must be the tag, not master: metadata-action only emits the
|
||||
// X.Y.Z and X.Y image tags when the ref is a semver tag. And
|
||||
// it must be the fully qualified ref — Gitea 404s on `v1.0.3`.
|
||||
body: JSON.stringify({ ref: `refs/tags/${tag}` }),
|
||||
},
|
||||
);
|
||||
if (!res.ok) console.log(`Dispatch returned HTTP ${res.status}.`);
|
||||
|
||||
if (await poll(3)) {
|
||||
console.log(`build.yml is running for ${sha}.`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`::error::${tag} is pushed but nothing is building it, and`);
|
||||
console.log(`::error::the dispatch did not take. Run "Build and Push Images"`);
|
||||
console.log(`::error::by hand with ref=${tag} (the tag, not master), then`);
|
||||
console.log(`::error::deploy. Check the Gitea server log for the`);
|
||||
console.log(`::error::post-receive error while you are at it.`);
|
||||
process.exit(1);
|
||||
})();
|
||||
'
|
||||
|
||||
- name: Summary
|
||||
env:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@jorgecuadros/api",
|
||||
"version": "1.0.2",
|
||||
"version": "1.0.5",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
|
||||
@@ -262,9 +262,26 @@ export class OpsService implements OnModuleInit {
|
||||
* deploy/scripts/pre-migrate-backup.mjs — the two write into the same volume
|
||||
* and both are listed as restore points by this same screen.
|
||||
*
|
||||
* --set-gtid-purged=OFF: the production server is the replication SOURCE with
|
||||
* GTID on, so without it every dump embeds SET @@GLOBAL.GTID_PURGED and is
|
||||
* unrestorable onto the very server it came from.
|
||||
* The dumper is probed at runtime rather than assumed. This command runs
|
||||
* inside the API image, whose `mysql-client` is Alpine's — i.e. MariaDB's —
|
||||
* where `mysqldump` is a deprecation-warning shim over `mariadb-dump` that
|
||||
* rejects --set-gtid-purged outright:
|
||||
* mysqldump: unknown variable 'set-gtid-purged=OFF'
|
||||
* which failed every backup, including the safety backups SYNC and REIMPORT
|
||||
* take first. MariaDB's dumper emits no GTID state unless asked (--gtid), so
|
||||
* there is nothing to suppress there; the flag is passed only when the dumper
|
||||
* on PATH advertises it, and the real binary is called directly only in the
|
||||
* MariaDB case (calling `mariadb-dump` whenever it merely exists would pick
|
||||
* it over a MySQL `mysqldump` earlier in PATH on a host carrying both).
|
||||
*
|
||||
* The probe is a command substitution, not `--help | grep -q`: PIPEFAIL is in
|
||||
* effect and grep closing the pipe early would make a supported flag look
|
||||
* unsupported.
|
||||
*
|
||||
* --set-gtid-purged=OFF (MySQL only): the production server is the
|
||||
* replication SOURCE with GTID on, so without it every dump embeds
|
||||
* SET @@GLOBAL.GTID_PURGED and is unrestorable onto the very server it came
|
||||
* from.
|
||||
*
|
||||
* The table-count assertion is not belt-and-braces: `gzip -t` passes on the
|
||||
* ~372-byte output of a mysqldump that died on its first statement, so a
|
||||
@@ -277,8 +294,12 @@ export class OpsService implements OnModuleInit {
|
||||
*/
|
||||
private dumpCommand(flags: string, db: string, out: string): string {
|
||||
return (
|
||||
`( mysqldump ${flags} --single-transaction --routines --triggers ` +
|
||||
`--no-tablespaces --set-gtid-purged=OFF ${db} | gzip -c > ${out} && ` +
|
||||
`DUMP=mysqldump; GTID=; ` +
|
||||
`case "$(mysqldump --help 2>/dev/null || true)" in ` +
|
||||
`*set-gtid-purged*) GTID=--set-gtid-purged=OFF;; ` +
|
||||
`*) command -v mariadb-dump >/dev/null 2>&1 && DUMP=mariadb-dump;; esac; ` +
|
||||
`( $DUMP ${flags} --single-transaction --routines --triggers ` +
|
||||
`--no-tablespaces $GTID ${db} | gzip -c > ${out} && ` +
|
||||
`gzip -t ${out} && ` +
|
||||
`TABLAS=$(gunzip -c ${out} | grep -c 'CREATE TABLE') && ` +
|
||||
`echo "tablas capturadas: $TABLAS" && ` +
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@jorgecuadros/web",
|
||||
"version": "1.0.2",
|
||||
"version": "1.0.5",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev -p 4500",
|
||||
|
||||
@@ -837,6 +837,46 @@ button {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
/* Upload progress (Operaciones ingest) */
|
||||
.upload-progress {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 4px 0 8px;
|
||||
}
|
||||
.progress-track {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--paper-2);
|
||||
}
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
border-radius: 999px;
|
||||
background: var(--brand-500);
|
||||
transition: width 0.2s linear;
|
||||
}
|
||||
.progress-indeterminate .progress-fill {
|
||||
width: 40% !important;
|
||||
animation: progress-slide 1.2s var(--ease-out-quart) infinite;
|
||||
}
|
||||
@keyframes progress-slide {
|
||||
0% {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(250%);
|
||||
}
|
||||
}
|
||||
.upload-progress-stats {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
background-position: -420px 0;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Fragment, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { useCan } from "@/lib/abilities";
|
||||
import {
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
startOpsJob,
|
||||
uploadIngest,
|
||||
} from "@/lib/api";
|
||||
import type { UploadProgress } from "@/lib/api";
|
||||
import type {
|
||||
BackupFile,
|
||||
IngestFile,
|
||||
@@ -56,6 +57,7 @@ function Operaciones() {
|
||||
const [confirm, setConfirm] = useState<ConfirmState>(null);
|
||||
const [confirmText, setConfirmText] = useState("");
|
||||
const [uploading, setUploading] = useState<string | null>(null);
|
||||
const [progress, setProgress] = useState<UploadProgress | null>(null);
|
||||
const [starting, setStarting] = useState(false);
|
||||
|
||||
const fileInputs = useRef<Record<string, HTMLInputElement | null>>({});
|
||||
@@ -119,14 +121,16 @@ function Operaciones() {
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
setUploading(name);
|
||||
setProgress(null);
|
||||
try {
|
||||
await uploadIngest(name, file);
|
||||
await uploadIngest(name, file, setProgress);
|
||||
setNotice(`${name} cargado.`);
|
||||
refreshLists();
|
||||
} catch (e) {
|
||||
setError((e as Error)?.message ?? "No se pudo cargar el archivo.");
|
||||
} finally {
|
||||
setUploading(null);
|
||||
setProgress(null);
|
||||
const input = fileInputs.current[name];
|
||||
if (input) input.value = "";
|
||||
}
|
||||
@@ -243,45 +247,55 @@ function Operaciones() {
|
||||
</thead>
|
||||
<tbody>
|
||||
{(ingest ?? []).map((f) => (
|
||||
<tr key={f.name}>
|
||||
<td className="mono">{f.name}</td>
|
||||
<td>
|
||||
<span className={`badge ${f.present ? "badge-positive" : "badge-negative"}`}>
|
||||
{f.present ? "Presente" : "Falta"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="num">{formatBytes(f.size)}</td>
|
||||
<td>{formatDateTime(f.modifiedAt)}</td>
|
||||
<td>
|
||||
<div className="row-actions">
|
||||
<input
|
||||
ref={(el) => {
|
||||
fileInputs.current[f.name] = el;
|
||||
}}
|
||||
type="file"
|
||||
style={{ display: "none" }}
|
||||
onChange={(e) => handleUpload(f.name, e.target.files?.[0])}
|
||||
/>
|
||||
<button
|
||||
className="btn btn-outline"
|
||||
type="button"
|
||||
disabled={uploading === f.name}
|
||||
onClick={() => fileInputs.current[f.name]?.click()}
|
||||
>
|
||||
{uploading === f.name ? "Cargando…" : f.present ? "Reemplazar" : "Cargar"}
|
||||
</button>
|
||||
{f.present && (
|
||||
<Fragment key={f.name}>
|
||||
<tr>
|
||||
<td className="mono">{f.name}</td>
|
||||
<td>
|
||||
<span className={`badge ${f.present ? "badge-positive" : "badge-negative"}`}>
|
||||
{f.present ? "Presente" : "Falta"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="num">{formatBytes(f.size)}</td>
|
||||
<td>{formatDateTime(f.modifiedAt)}</td>
|
||||
<td>
|
||||
<div className="row-actions">
|
||||
<input
|
||||
ref={(el) => {
|
||||
fileInputs.current[f.name] = el;
|
||||
}}
|
||||
type="file"
|
||||
style={{ display: "none" }}
|
||||
onChange={(e) => handleUpload(f.name, e.target.files?.[0])}
|
||||
/>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
className="btn btn-outline"
|
||||
type="button"
|
||||
onClick={() => handleDeleteIngest(f.name)}
|
||||
disabled={uploading === f.name}
|
||||
onClick={() => fileInputs.current[f.name]?.click()}
|
||||
>
|
||||
Eliminar
|
||||
{uploading === f.name ? "Cargando…" : f.present ? "Reemplazar" : "Cargar"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{f.present && (
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
type="button"
|
||||
disabled={uploading === f.name}
|
||||
onClick={() => handleDeleteIngest(f.name)}
|
||||
>
|
||||
Eliminar
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{uploading === f.name && (
|
||||
<tr>
|
||||
<td colSpan={5}>
|
||||
<UploadProgressBar progress={progress} />
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -496,6 +510,61 @@ function Operaciones() {
|
||||
);
|
||||
}
|
||||
|
||||
/** "1:05" / "0:09" — remaining time, coarse on purpose. */
|
||||
function formatEta(seconds: number): string {
|
||||
const s = Math.max(0, Math.round(seconds));
|
||||
if (s >= 3600) {
|
||||
const h = Math.floor(s / 3600);
|
||||
const m = Math.round((s % 3600) / 60);
|
||||
return `${h} h ${m} min`;
|
||||
}
|
||||
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Live upload readout. The bar tracks bytes handed to the network; once those
|
||||
* are all sent the server still has to write the file, so the tail of the
|
||||
* upload reads "Procesando…" instead of sitting at 100%.
|
||||
*/
|
||||
function UploadProgressBar({ progress }: { progress: UploadProgress | null }) {
|
||||
const pct = progress?.fraction != null ? Math.round(progress.fraction * 100) : null;
|
||||
return (
|
||||
<div className="upload-progress">
|
||||
<div
|
||||
className={`progress-track${pct === null ? " progress-indeterminate" : ""}`}
|
||||
role="progressbar"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={pct ?? undefined}
|
||||
>
|
||||
<div className="progress-fill" style={{ width: `${pct ?? 100}%` }} />
|
||||
</div>
|
||||
<div className="upload-progress-stats mono">
|
||||
{progress === null ? (
|
||||
"Preparando…"
|
||||
) : progress.finishing ? (
|
||||
`Procesando en el servidor… (${formatBytes(progress.total)} enviados)`
|
||||
) : (
|
||||
<>
|
||||
{pct !== null && <strong>{pct}%</strong>}
|
||||
{progress.total > 0 && (
|
||||
<span>
|
||||
{formatBytes(progress.loaded)} / {formatBytes(progress.total)}
|
||||
</span>
|
||||
)}
|
||||
{progress.bytesPerSecond > 0 && (
|
||||
<span>{formatBytes(progress.bytesPerSecond)}/s</span>
|
||||
)}
|
||||
{progress.secondsRemaining !== null && progress.bytesPerSecond > 0 && (
|
||||
<span>faltan {formatEta(progress.secondsRemaining)}</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OpTile({
|
||||
title,
|
||||
desc,
|
||||
|
||||
+98
-19
@@ -803,38 +803,117 @@ export function listIngest(): Promise<IngestFile[]> {
|
||||
return apiFetch<IngestFile[]>("/ops/ingest");
|
||||
}
|
||||
|
||||
/** Live upload stats reported to `uploadFile`'s `onProgress` callback. */
|
||||
export type UploadProgress = {
|
||||
loaded: number;
|
||||
/** 0 when the browser can't compute the request size. */
|
||||
total: number;
|
||||
/** 0..1, or null when `total` is unknown. */
|
||||
fraction: number | null;
|
||||
/** Smoothed transfer rate. */
|
||||
bytesPerSecond: number;
|
||||
/** null until a rate and a total are both known. */
|
||||
secondsRemaining: number | null;
|
||||
/** True once the bytes are sent and we're waiting on the server's reply. */
|
||||
finishing: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Multipart upload — not JSON, so it bypasses apiFetch's Content-Type. `path`
|
||||
* is API-relative (may include a query string); `filename` overrides the part
|
||||
* name sent to the server.
|
||||
* name sent to the server. Uses XHR rather than fetch because fetch has no way
|
||||
* to report request-body progress.
|
||||
*/
|
||||
export async function uploadFile(
|
||||
export function uploadFile(
|
||||
path: string,
|
||||
file: File,
|
||||
filename?: string,
|
||||
onProgress?: (p: UploadProgress) => void,
|
||||
): Promise<unknown> {
|
||||
const body = new FormData();
|
||||
body.append("file", file, filename ?? file.name);
|
||||
const res = await fetch(`${API_ORIGIN}${path}`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
body,
|
||||
});
|
||||
if (!res.ok) {
|
||||
let message = `Error ${res.status}`;
|
||||
try {
|
||||
const b = await res.json();
|
||||
if (b?.message) message = b.message;
|
||||
} catch {
|
||||
/* ignore */
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open("POST", `${API_ORIGIN}${path}`);
|
||||
xhr.withCredentials = true;
|
||||
|
||||
if (onProgress) {
|
||||
// Exponentially smoothed rate — raw per-chunk deltas jump around too
|
||||
// much to read.
|
||||
let lastAt = performance.now();
|
||||
let lastLoaded = 0;
|
||||
let rate = 0;
|
||||
|
||||
xhr.upload.onprogress = (e) => {
|
||||
const now = performance.now();
|
||||
const dt = (now - lastAt) / 1000;
|
||||
if (dt >= 0.15) {
|
||||
const sample = (e.loaded - lastLoaded) / dt;
|
||||
rate = rate === 0 ? sample : rate * 0.7 + sample * 0.3;
|
||||
lastAt = now;
|
||||
lastLoaded = e.loaded;
|
||||
}
|
||||
const total = e.lengthComputable ? e.total : 0;
|
||||
onProgress({
|
||||
loaded: e.loaded,
|
||||
total,
|
||||
fraction: total ? e.loaded / total : null,
|
||||
bytesPerSecond: rate,
|
||||
secondsRemaining:
|
||||
total && rate > 0 ? (total - e.loaded) / rate : null,
|
||||
finishing: false,
|
||||
});
|
||||
};
|
||||
// Bytes are out the door; the server still has to write the file.
|
||||
xhr.upload.onload = () => {
|
||||
onProgress({
|
||||
loaded: file.size,
|
||||
total: file.size,
|
||||
fraction: 1,
|
||||
bytesPerSecond: rate,
|
||||
secondsRemaining: 0,
|
||||
finishing: true,
|
||||
});
|
||||
};
|
||||
}
|
||||
throw new ApiError(res.status, message);
|
||||
}
|
||||
return res.status === 204 ? undefined : res.json().catch(() => undefined);
|
||||
|
||||
xhr.onload = () => {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = xhr.responseText ? JSON.parse(xhr.responseText) : undefined;
|
||||
} catch {
|
||||
parsed = undefined;
|
||||
}
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
resolve(parsed);
|
||||
return;
|
||||
}
|
||||
const message =
|
||||
(parsed as { message?: string } | undefined)?.message ??
|
||||
`Error ${xhr.status}`;
|
||||
reject(new ApiError(xhr.status, message));
|
||||
};
|
||||
xhr.onerror = () =>
|
||||
reject(new ApiError(0, "Fallo de red durante la carga."));
|
||||
xhr.onabort = () => reject(new ApiError(0, "Carga cancelada."));
|
||||
xhr.ontimeout = () => reject(new ApiError(0, "Tiempo de carga agotado."));
|
||||
|
||||
xhr.send(body);
|
||||
});
|
||||
}
|
||||
|
||||
export function uploadIngest(name: string, file: File): Promise<unknown> {
|
||||
return uploadFile(`/ops/ingest/${encodeURIComponent(name)}`, file, name);
|
||||
export function uploadIngest(
|
||||
name: string,
|
||||
file: File,
|
||||
onProgress?: (p: UploadProgress) => void,
|
||||
): Promise<unknown> {
|
||||
return uploadFile(
|
||||
`/ops/ingest/${encodeURIComponent(name)}`,
|
||||
file,
|
||||
name,
|
||||
onProgress,
|
||||
);
|
||||
}
|
||||
|
||||
export function deleteIngest(name: string): Promise<unknown> {
|
||||
|
||||
@@ -47,6 +47,13 @@ ENV NODE_ENV=production
|
||||
# That breaks the pre-migrate deploy backup AND the whole "Operaciones" admin
|
||||
# panel (backup, restore, sync, re-import all shell out to these binaries).
|
||||
#
|
||||
# mdbtools-utils, NOT mdbtools. Alpine splits the project: `mdbtools` is the
|
||||
# shared library only, and the command-line tools migration/extract.py shells out
|
||||
# to (`mdb-tables`, `mdb-export`) are in the -utils subpackage. Installing the
|
||||
# wrong one builds fine and fails at run time — the re-import in the "Operaciones"
|
||||
# panel dies with:
|
||||
# RuntimeError: mdbtools not found on PATH (need mdb-tables and mdb-export)
|
||||
#
|
||||
# tesseract-ocr + tesseract-ocr-data-spa + poppler-utils drive the statement
|
||||
# OCR intake (RECEIPT_CAPTURE_SPEC §2): poppler's `pdftoppm` rasterises each
|
||||
# scanned page and tesseract reads it, with the Spanish traineddata for the
|
||||
@@ -55,7 +62,7 @@ ENV NODE_ENV=production
|
||||
# dependency. If they are absent the API still boots — the statements module
|
||||
# reports itself unavailable and only that feature is disabled — but statement
|
||||
# ingest is the point of shipping them.
|
||||
RUN apk add --no-cache python3 mdbtools mysql-client mariadb-connector-c openssl \
|
||||
RUN apk add --no-cache python3 mdbtools-utils mysql-client mariadb-connector-c openssl \
|
||||
tesseract-ocr tesseract-ocr-data-spa poppler-utils \
|
||||
&& apk add --no-cache --virtual .pybuild python3-dev build-base \
|
||||
&& rm -rf /var/cache/apk/*
|
||||
|
||||
@@ -294,10 +294,19 @@ a warning, which is what local development wants.
|
||||
Two more things the panel's dumps now do, for the same reasons the pre-migrate
|
||||
backup does them (see `deploy/scripts/pre-migrate-backup.mjs`):
|
||||
|
||||
- **`--set-gtid-purged=OFF`.** galactus is the replication *source* with GTID
|
||||
on, so without this every dump embeds `SET @@GLOBAL.GTID_PURGED` and cannot be
|
||||
restored onto the server it came from — which is precisely what the restore
|
||||
screen exists to do.
|
||||
- **`--set-gtid-purged=OFF`, but only when the dumper supports it.** galactus is
|
||||
the replication *source* with GTID on, so on a MySQL client this flag is what
|
||||
keeps every dump from embedding `SET @@GLOBAL.GTID_PURGED` and becoming
|
||||
unrestorable onto the server it came from — which is precisely what the
|
||||
restore screen exists to do. The panel, however, dumps from *inside the API
|
||||
container*, where Alpine's `mysql-client` is MariaDB's: there `mysqldump` is a
|
||||
shim over `mariadb-dump`, the flag does not exist, and passing it failed every
|
||||
backup with `mysqldump: unknown variable 'set-gtid-purged=OFF'`. So the panel
|
||||
probes `mysqldump --help` and passes the flag only if it is advertised,
|
||||
invoking `mariadb-dump` directly otherwise (MariaDB writes no GTID state
|
||||
unless asked with `--gtid`, so there is nothing to suppress). The pre-migrate
|
||||
backup keeps the flag unconditionally — it runs in a real `mysql:8.4` image,
|
||||
not in the API container.
|
||||
- **`set -o pipefail` and a `CREATE TABLE` count.** `mysqldump | gzip` reports
|
||||
gzip's exit status, and a `mysqldump` that dies on its first statement still
|
||||
produces a ~372-byte perfectly valid archive that passes `gzip -t`. Without
|
||||
|
||||
@@ -33,7 +33,7 @@ from pathlib import Path
|
||||
import boto3
|
||||
from botocore.config import Config
|
||||
|
||||
from dbenv import connect, load_env
|
||||
from dbenv import connect, require, setting
|
||||
from extract import sanitize_column_name as san
|
||||
|
||||
csv.field_size_limit(300_000_000)
|
||||
@@ -102,12 +102,16 @@ def main():
|
||||
only = set(x.strip() for x in args.tables.split(",") if x.strip())
|
||||
sources = [s for s in SOURCES if not only or s["key"] in only]
|
||||
|
||||
env = load_env(args.env)
|
||||
# Process environment first, deploy/.env.<env> second, with the same
|
||||
# credential aliases the API uses — the "Operaciones" re-import runs this
|
||||
# inside the API container, which has S3_ENDPOINT / MINIO_ROOT_* injected
|
||||
# and no deploy/ directory at all.
|
||||
s3 = boto3.client(
|
||||
"s3", endpoint_url=env["S3_ENDPOINT"],
|
||||
aws_access_key_id=env["MINIO_ROOT_USER"], aws_secret_access_key=env["MINIO_ROOT_PASSWORD"],
|
||||
"s3", endpoint_url=require(args.env, "S3_ENDPOINT"),
|
||||
aws_access_key_id=require(args.env, "S3_ACCESS_KEY", "MINIO_ROOT_USER"),
|
||||
aws_secret_access_key=require(args.env, "S3_SECRET_KEY", "MINIO_ROOT_PASSWORD"),
|
||||
config=Config(signature_version="s3v4"), region_name="us-east-1")
|
||||
bucket = env["S3_BUCKET"]
|
||||
bucket = setting(args.env, "S3_BUCKET") or "jorgecuadros-documents"
|
||||
|
||||
conn = connect(args.env)
|
||||
cur = conn.cursor()
|
||||
|
||||
+31
-7
@@ -32,29 +32,53 @@ REPO = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def load_env(env: str) -> dict:
|
||||
"""deploy/.env.<env> parsed to a dict, or {} when the file is absent.
|
||||
|
||||
Absent is normal, not an error: the API container runs these scripts with
|
||||
DATABASE_URL / S3_* injected as real environment variables and ships no
|
||||
deploy/ directory. Use `setting()` / `require()` rather than this — they
|
||||
layer the process environment on top, which is what actually resolves."""
|
||||
f = REPO / "deploy" / f".env.{env}"
|
||||
if not f.exists():
|
||||
raise SystemExit(
|
||||
f"missing {f} — deploy the '{env}' DB stack and write its .env first "
|
||||
f"(see dbenv.py header)."
|
||||
)
|
||||
return {}
|
||||
out = {}
|
||||
for line in f.read_text().splitlines():
|
||||
line = line.strip()
|
||||
if line and not line.startswith("#") and "=" in line:
|
||||
k, v = line.split("=", 1)
|
||||
out[k] = v
|
||||
if "DATABASE_URL" not in out:
|
||||
raise SystemExit(f"{f} has no DATABASE_URL")
|
||||
return out
|
||||
|
||||
|
||||
def setting(env: str, *keys: str):
|
||||
"""First non-empty value for `keys`, process environment first, then
|
||||
deploy/.env.<env>. Several keys = fallback aliases (S3_ACCESS_KEY then
|
||||
MINIO_ROOT_USER, as apps/api/src/storage/storage.service.ts does)."""
|
||||
fromfile = load_env(env)
|
||||
for k in keys:
|
||||
v = os.environ.get(k) or fromfile.get(k)
|
||||
if v:
|
||||
return v
|
||||
return None
|
||||
|
||||
|
||||
def require(env: str, *keys: str) -> str:
|
||||
v = setting(env, *keys)
|
||||
if not v:
|
||||
raise SystemExit(
|
||||
f"missing {' / '.join(keys)} — set it in the environment, or deploy the "
|
||||
f"'{env}' stack and write {REPO / 'deploy' / f'.env.{env}'} "
|
||||
f"(see dbenv.py header)."
|
||||
)
|
||||
return v
|
||||
|
||||
|
||||
def database_url(env: str) -> str:
|
||||
"""Target DB URL. A DATABASE_URL in the process environment wins over
|
||||
deploy/.env.<env> — this is how the API container (which has its own
|
||||
DATABASE_URL and no deploy/.env files) drives a re-import against its own
|
||||
database."""
|
||||
return os.environ.get("DATABASE_URL") or load_env(env)["DATABASE_URL"]
|
||||
return require(env, "DATABASE_URL")
|
||||
|
||||
|
||||
def connect(env: str):
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "jorgecuadros-platform",
|
||||
"version": "1.0.2",
|
||||
"version": "1.0.5",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"apps/*",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@jorgecuadros/database",
|
||||
"version": "1.0.2",
|
||||
"version": "1.0.5",
|
||||
"private": true,
|
||||
"main": "generated/client/index.js",
|
||||
"types": "generated/client/index.d.ts",
|
||||
|
||||
Reference in New Issue
Block a user