feat(deploy): prisma migration history, /version, galactus standalone deploy
Closes the gap between "what tag did I deploy" and "what is actually running", and gives the schema a history that can be reasoned about across releases. Migrations - Baseline the existing schema as 0000_init (migrate diff --from-empty). The schema had only ever been applied with `prisma db push`, so no history existed and schema state was disconnected from app version. Existing databases must be baselined once with `migrate resolve --applied 0000_init`; the workflows print this remedy on P3005. - Run `prisma migrate deploy` as a deploy STEP, not the container CMD — as a CMD, N replicas would race each other applying the same migration. Version reporting - GET /version on the API reports the APP_VERSION / GIT_SHA / BUILD_DATE that build.yml already baked into both images but nothing ever read. - The web footer shows the web build and flags an api/web mismatch. The two cannot drift at build time (one matrix run) but can at deploy time. - Both deploy workflows now fail if the running API does not report the tag that was dispatched — a stack naming a tag is not proof of what is running. - scripts/set-version.mjs stamps every package.json, which had all sat at 0.1.0 while real releases shipped as v1.x. Pre-migrate backup - deploy/scripts/pre-migrate-backup.mjs dumps the database from INSIDE the still-running old API container over Portainer's Docker API, so the file lands in the volume the Operaciones restore screen reads. A dump taken on the CI runner would be unreachable by the only restore path we have. Verifies the artefact with `gzip -t` before letting the migration proceed. galactus - deploy/galactus/*.compose.yml: standalone-Docker ports of the Swarm stacks. Plain compose silently ignores `deploy:`, so restart_policy becomes `restart: unless-stopped` — without it nothing returns after a host reboot. - .gitea/workflows/deploy-galactus.yml drives endpoint 3 with its own secrets. Fixes - deploy.yml passed `endpoint_id` and `pull_image` to cssnr/portainer-stack-deploy-action, which has no such inputs (they are `endpoint` and `pull`). The endpoint was silently never set. docs/DEPLOY_AND_MIGRATIONS.md documents expand/contract as the rule for schema changes: Prisma has no down-migrations, so a code rollback never rolls the schema back, and restoring the replication master from a dump diverges every replica. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -6,4 +6,25 @@ export class AppController {
|
||||
health() {
|
||||
return { status: "ok" };
|
||||
}
|
||||
|
||||
/**
|
||||
* What is actually running. The three values are baked into the image at
|
||||
* build time by .gitea/workflows/build.yml (see docker/api.Dockerfile) and
|
||||
* are the only way to confirm a deploy — or a rollback — landed: the tag you
|
||||
* dispatched and the code inside the container can disagree if a stack was
|
||||
* applied without pulling, or if the app stack still names an older tag.
|
||||
*
|
||||
* Deliberately unauthenticated, same as /health: the deploy workflow has to
|
||||
* read it with no session, and it exposes nothing an attacker could not
|
||||
* already infer from the repo.
|
||||
*/
|
||||
@Get("version")
|
||||
version() {
|
||||
return {
|
||||
service: "api",
|
||||
version: process.env.APP_VERSION ?? "dev",
|
||||
gitSha: process.env.GIT_SHA ?? "unknown",
|
||||
buildDate: process.env.BUILD_DATE ?? "unknown",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,6 +162,38 @@ button {
|
||||
}
|
||||
}
|
||||
|
||||
/* Deployed-build line. Quiet by default — it only needs to be legible when
|
||||
someone is verifying a release or a rollback. */
|
||||
.shell-footer {
|
||||
max-width: var(--shell-max);
|
||||
margin: 0 auto;
|
||||
padding: 1rem 1.75rem 1.75rem;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 0.75rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--muted-2);
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
.shell-footer-build {
|
||||
font-family: var(--font-mono);
|
||||
font-variant-numeric: tabular-nums;
|
||||
cursor: help;
|
||||
}
|
||||
.shell-footer-warn {
|
||||
color: var(--negative);
|
||||
background: var(--negative-tint);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0.0625rem 0.375rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.shell-footer {
|
||||
padding: 1rem 1rem 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
font-family: var(--font-sans);
|
||||
text-transform: uppercase;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ReactNode } from "react";
|
||||
import "./globals.css";
|
||||
import { readBuildInfoFromEnv } from "@/lib/build-info";
|
||||
|
||||
export const metadata = {
|
||||
title: "Jorge Cuadros & Asociados — Plataforma",
|
||||
@@ -20,6 +21,9 @@ export default function RootLayout({ children }: { children: ReactNode }) {
|
||||
process.env.API_ORIGIN ??
|
||||
process.env.NEXT_PUBLIC_API_ORIGIN ??
|
||||
"http://localhost:3001";
|
||||
// Same reason as the API origin: read on the server per request so the built
|
||||
// image is not pinned to one build identity in its client bundle.
|
||||
const build = readBuildInfoFromEnv();
|
||||
|
||||
return (
|
||||
<html lang="es">
|
||||
@@ -27,7 +31,9 @@ export default function RootLayout({ children }: { children: ReactNode }) {
|
||||
{/* Must run before the app bundle so lib/api.ts sees it at import. */}
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `window.__API_ORIGIN__=${JSON.stringify(apiOrigin)};`,
|
||||
__html:
|
||||
`window.__API_ORIGIN__=${JSON.stringify(apiOrigin)};` +
|
||||
`window.__APP_BUILD__=${JSON.stringify(build)};`,
|
||||
}}
|
||||
/>
|
||||
{/* Text-size preference, applied before first paint so the page never
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { logout, me, updateUiScale } from "@/lib/api";
|
||||
import { getApiVersion, logout, me, updateUiScale } from "@/lib/api";
|
||||
import { shortSha, webBuildInfo } from "@/lib/build-info";
|
||||
import { AuthContext, can } from "@/lib/abilities";
|
||||
import { ROLE_LABEL } from "@/lib/labels";
|
||||
import {
|
||||
@@ -181,6 +182,52 @@ function NavMenu({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* What is deployed, from both halves. build.yml builds api + web in one matrix
|
||||
* run, so their versions cannot drift at build time — but they can at DEPLOY
|
||||
* time, if a stack is applied with only one image's tag moved. Showing both and
|
||||
* flagging a mismatch is the cheap check that catches a half-applied release.
|
||||
*/
|
||||
function BuildFooter() {
|
||||
const web = webBuildInfo();
|
||||
const [api, setApi] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
getApiVersion()
|
||||
.then((v) => {
|
||||
if (alive) setApi(v.version);
|
||||
})
|
||||
.catch(() => {
|
||||
// The shell already redirects to /login when the API is unreachable;
|
||||
// a missing version line is not worth a second error surface.
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const mismatch = api !== null && api !== web.version;
|
||||
|
||||
return (
|
||||
<footer className="shell-footer">
|
||||
<span>Jorge Cuadros & Asociados</span>
|
||||
<span
|
||||
className="shell-footer-build"
|
||||
title={`web ${web.version} (${shortSha(web.gitSha)}) — ${web.buildDate}`}
|
||||
>
|
||||
v{web.version}
|
||||
{api !== null && (mismatch ? ` · API v${api}` : "")}
|
||||
</span>
|
||||
{mismatch && (
|
||||
<span className="shell-footer-warn" role="status">
|
||||
versiones desincronizadas
|
||||
</span>
|
||||
)}
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
export function AppShell({ children }: { children: ReactNode }) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
@@ -381,6 +428,7 @@ export function AppShell({ children }: { children: ReactNode }) {
|
||||
)}
|
||||
</header>
|
||||
<main className="shell-main">{children}</main>
|
||||
<BuildFooter />
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -153,6 +153,18 @@ export function logout(): Promise<{ success: boolean }> {
|
||||
return apiFetch<{ success: boolean }>("/auth/logout", { method: "POST" });
|
||||
}
|
||||
|
||||
export interface ServiceVersion {
|
||||
service: string;
|
||||
version: string;
|
||||
gitSha: string;
|
||||
buildDate: string;
|
||||
}
|
||||
|
||||
/** What the API container reports it is running. Unauthenticated by design. */
|
||||
export function getApiVersion(): Promise<ServiceVersion> {
|
||||
return apiFetch<ServiceVersion>("/version");
|
||||
}
|
||||
|
||||
export function getStats(): Promise<CustomerStats> {
|
||||
return apiFetch<CustomerStats>("/customers/stats");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* The web image's own build identity.
|
||||
*
|
||||
* Same runtime-injection trick as API_ORIGIN (lib/api.ts): docker/web.Dockerfile
|
||||
* bakes APP_VERSION / GIT_SHA / BUILD_DATE as ENV, layout.tsx reads them on the
|
||||
* server per request and paints them into window.__APP_BUILD__. Reading
|
||||
* process.env directly from a client component would return undefined — Next
|
||||
* only inlines NEXT_PUBLIC_* into the browser bundle, and baking the version in
|
||||
* at build time is exactly what we are avoiding elsewhere.
|
||||
*/
|
||||
export interface BuildInfo {
|
||||
version: string;
|
||||
gitSha: string;
|
||||
buildDate: string;
|
||||
}
|
||||
|
||||
export const UNKNOWN_BUILD: BuildInfo = {
|
||||
version: "dev",
|
||||
gitSha: "unknown",
|
||||
buildDate: "unknown",
|
||||
};
|
||||
|
||||
/** Server-side read, used by layout.tsx to produce the injected payload. */
|
||||
export function readBuildInfoFromEnv(): BuildInfo {
|
||||
return {
|
||||
version: process.env.APP_VERSION ?? UNKNOWN_BUILD.version,
|
||||
gitSha: process.env.GIT_SHA ?? UNKNOWN_BUILD.gitSha,
|
||||
buildDate: process.env.BUILD_DATE ?? UNKNOWN_BUILD.buildDate,
|
||||
};
|
||||
}
|
||||
|
||||
/** Browser-side read of what layout.tsx injected. */
|
||||
export function webBuildInfo(): BuildInfo {
|
||||
if (typeof window === "undefined") return readBuildInfoFromEnv();
|
||||
const injected = (window as { __APP_BUILD__?: BuildInfo }).__APP_BUILD__;
|
||||
return injected ?? UNKNOWN_BUILD;
|
||||
}
|
||||
|
||||
/** First 7 chars, the length git itself abbreviates to. */
|
||||
export function shortSha(sha: string): string {
|
||||
return sha === "unknown" ? sha : sha.slice(0, 7);
|
||||
}
|
||||
Reference in New Issue
Block a user