Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 109 additions & 0 deletions .github/scripts/check_release_drift.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
#!/usr/bin/env node
/**
* Fails the build when the site's fallback release drifts from the release the
* package repository actually mirrors.
*
* The /packages page used to hardcode its versions, and they went stale: the
* page still advertised 0.114-0 (and 0.113-0 for the repository examples) after
* v0.116-0 had been published and mirrored. The page now reads
* out/packages/release-info.json at runtime, but it still needs a compiled-in
* fallback for the first paint and for the case where that fetch fails - so the
* fallback can drift in exactly the same way, just less visibly.
*
* This check closes that loop: whenever the deployment mirrors a release, the
* fallback baked into the bundle must name the same one.
*
* Skipped when release-info.json is absent, which is the normal case for a
* site-only build (BUILD_PACKAGES=false, or any fork without the packaging
* secrets). There is nothing to compare against then.
*/

const fs = require("node:fs");
const path = require("node:path");

const releaseInfoPath = path.join(process.cwd(), "out", "packages", "release-info.json");
const fallbackSourcePath = path.join(process.cwd(), "app", "lib", "releaseInfo.ts");

if (!fs.existsSync(releaseInfoPath)) {
console.log(`No ${path.relative(process.cwd(), releaseInfoPath)}; skipping release drift check.`);
process.exit(0);
}

const source = fs.readFileSync(fallbackSourcePath, "utf8");

/** Reads a string field out of the FALLBACK_RELEASE object literal. */
function fallbackField(field) {
const match = new RegExp(`${field}:\\s*"([^"]+)"`).exec(source);
return match ? match[1] : null;
}

const mirrored = JSON.parse(fs.readFileSync(releaseInfoPath, "utf8"));
const mirroredTag = mirrored.tag_name;
if (typeof mirroredTag !== "string" || mirroredTag.length === 0) {
console.error("release-info.json has no tag_name; cannot verify the site's fallback release.");
process.exit(1);
}

const fallbackTag = fallbackField("tagName");
if (fallbackTag !== mirroredTag) {
console.error(
[
"Release drift: the site's fallback release does not match the mirrored release.",
"",
` mirrored (out/packages/release-info.json): ${mirroredTag}`,
` fallback (app/lib/releaseInfo.ts): ${fallbackTag ?? "<not found>"}`,
"",
"Update FALLBACK_RELEASE in app/lib/releaseInfo.ts to the mirrored release.",
"It is what visitors see before the release feed loads, and permanently if",
"that fetch fails.",
].join("\n"),
);
process.exit(1);
}

// The version strings are derived from real asset filenames, so a mismatch here
// means the fallback would render install commands for packages the release
// does not contain.
const assetNames = Array.isArray(mirrored.assets)
? mirrored.assets.map((asset) => asset && asset.name).filter((name) => typeof name === "string")
: [];

const derived = [
{
field: "aptVersion",
pattern: /^ubuntu[\d.]+-postgresql-\d+-documentdb_([^_]+)_/,
},
{
field: "rpmVersion",
pattern: /^rhel\d+-postgresql\d+-documentdb-(.+)\.(?:x86_64|aarch64)\.rpm$/,
},
{
field: "metaVersion",
pattern: /^ubuntu[\d.]+-documentdb_([^_]+)_all\.deb$/,
},
];

let failed = false;
for (const { field, pattern } of derived) {
const actual = assetNames.map((name) => pattern.exec(name)).find((m) => m && m[1]);
if (!actual) {
// The release simply does not ship that package shape; the fallback keeps
// whatever it had, which is not drift.
continue;
}
const expected = actual[1];
const declared = fallbackField(field);
if (declared !== expected) {
console.error(
`Release drift: FALLBACK_RELEASE.${field} is "${declared}" but ${mirroredTag} ships "${expected}".`,
);
failed = true;
}
}

if (failed) {
console.error("\nUpdate FALLBACK_RELEASE in app/lib/releaseInfo.ts.");
process.exit(1);
}

console.log(`Release fallback matches the mirrored release (${mirroredTag}).`);
3 changes: 3 additions & 0 deletions .github/workflows/continuous-deployment.yml
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,9 @@ jobs:
# downloading ~500 MB of PostgreSQL and PostGIS, which keeps the check to
# a few seconds while still catching the entire "package missing from the
# pool / unsatisfiable dependency" class.
- name: Verify the site's fallback release matches the mirrored release
if: steps.features.outputs.packages == 'true'
run: node .github/scripts/check_release_drift.js
- name: Smoke test the generated repository (dependency resolution)
if: steps.features.outputs.packages == 'true'
run: |
Expand Down
21 changes: 20 additions & 1 deletion PACKAGE-INSTALL.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,14 @@ mongosh 'mongodb://admin:<password>@127.0.0.1:10260/mydb?tls=true&tlsAllowInvali
--eval 'db.runCommand({ping: 1})'
```

If the password contains `@`, `:`, `/` or other reserved characters it must be percent-encoded
in the URI (`@` becomes `%40`). To avoid encoding entirely, pass the credentials as flags:

```bash
mongosh localhost:10260 -u admin -p --authenticationMechanism SCRAM-SHA-256 \
--tls --tlsAllowInvalidCertificates --eval 'db.runCommand({ping: 1})'
```

A first database and collection are created on first write:

```javascript
Expand Down Expand Up @@ -163,11 +171,22 @@ instead; re-run `documentdb-setup` to restart it.
**Remove or reset:**

```bash
# Stop the stack first — package removal deletes files but does not stop a
# running gateway. On systemd hosts:
sudo systemctl stop documentdb-local@18.target
# Without systemd the wizard started the gateway directly; kill that process.

sudo documentdb-setup --restore # detach the managed integration
sudo documentdb-local-reset --pg-version 18 --confirm-destroy # DESTROYS the data directory
sudo apt remove documentdb # or: sudo dnf remove documentdb

# Name the package you installed AND the extension: autoremove does not reap
# postgresql-18-documentdb, and `remove` would leave its config behind.
sudo apt purge --autoremove documentdb-18 postgresql-18-documentdb
sudo dnf remove documentdb-18 postgresql18-documentdb && sudo dnf autoremove
```

If you installed the `documentdb` meta package rather than `documentdb-18`, name that instead.

### What the packages are

| Package | Role |
Expand Down
52 changes: 50 additions & 2 deletions app/lib/packageInstall.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,12 +39,50 @@ const rpmMajorVersions: Record<RpmDistro, "8" | "9"> = {
rhel9: "9",
};

// Distributions where the repository serves the full v0.116-0 package set
// (`documentdb` meta, `documentdb-N`, `documentdb-common`, `documentdb-gateway`,
// `documentdb-postgresql-tools`) rather than the extension package alone.
// v0.116-0 ships Tier-1 only, so everywhere else still resolves the older
// extension-only release and must keep the `postgresql-N-documentdb` command.
export const aptFullStackDistros: readonly AptDistro[] = ["ubuntu24"];
export const rpmFullStackDistros: readonly RpmDistro[] = ["rhel9"];

// The stand-alone packages exist only for the majors the full stack was built
// for. PostgreSQL 16 resolves the older extension-only build even on a
// full-stack distribution, so it must not be offered the stand-alone command.
const fullStackPgVersions = ["17", "18"];

export function aptServesFullStack(
aptTarget: AptDistro,
aptPgVersion: AptPgVersion,
): boolean {
return (
aptFullStackDistros.includes(aptTarget) &&
fullStackPgVersions.includes(aptPgVersion)
);
}

export function rpmServesFullStack(
rpmTarget: RpmDistro,
rpmPgVersion: RpmPgVersion,
): boolean {
return (
rpmFullStackDistros.includes(rpmTarget) &&
fullStackPgVersions.includes(rpmPgVersion)
);
}

export function buildAptInstallCommand(
aptTarget: AptDistro,
aptArch: AptArch,
aptPgVersion: AptPgVersion,
): string {
const pgdgSuite = aptPgdgSuites[aptTarget];
// `documentdb-N` pulls the whole stack (extension + gateway + tools +
// documentdb-common) and owns the systemd lifecycle for that major.
const installTarget = aptServesFullStack(aptTarget, aptPgVersion)
? `documentdb-${aptPgVersion}`
: `postgresql-${aptPgVersion}-documentdb`;

return `sudo apt update && \\
sudo apt install -y curl ca-certificates gnupg && \\
Expand All @@ -53,7 +91,7 @@ echo "deb [signed-by=/usr/share/keyrings/postgresql.gpg] https://apt.postgresql.
curl -fsSL https://documentdb.io/documentdb-archive-keyring.gpg | sudo gpg --dearmor --yes -o /usr/share/keyrings/documentdb-archive-keyring.gpg && \\
echo "deb [arch=${aptArch} signed-by=/usr/share/keyrings/documentdb-archive-keyring.gpg] https://documentdb.io/deb stable ${aptTarget}" | sudo tee /etc/apt/sources.list.d/documentdb.list >/dev/null && \\
sudo apt update && \\
sudo apt install -y postgresql-${aptPgVersion}-documentdb`;
sudo apt install -y ${installTarget}`;
}

export function buildRpmInstallCommand(
Expand All @@ -62,6 +100,9 @@ export function buildRpmInstallCommand(
rpmPgVersion: RpmPgVersion,
): string {
const rhelMajorVersion = rpmMajorVersions[rpmTarget];
const installTarget = rpmServesFullStack(rpmTarget, rpmPgVersion)
? `documentdb-${rpmPgVersion}`
: `postgresql${rpmPgVersion}-documentdb`;

return `sudo dnf install -y https://dl.fedoraproject.org/pub/epel/epel-release-latest-${rhelMajorVersion}.noarch.rpm && \\
sudo dnf install -y https://download.postgresql.org/pub/repos/yum/reporpms/EL-${rhelMajorVersion}-${rpmArch}/pgdg-redhat-repo-latest.noarch.rpm && \\
Expand All @@ -78,5 +119,12 @@ printf '%s\\n' \\
'enabled=1' \\
'gpgcheck=1' \\
'gpgkey=https://documentdb.io/documentdb-archive-keyring.gpg' | sudo tee /etc/yum.repos.d/documentdb.repo >/dev/null && \\
sudo dnf install -y postgresql${rpmPgVersion}-documentdb`;
sudo dnf install -y ${installTarget}`;
}

// Shown after a full-stack install: the packages ship a wizard that creates the
// PostgreSQL instance, installs the extensions and starts the gateway, so the
// install command alone does not leave a reachable endpoint.
export function buildSetupCommand(): string {
return `sudo documentdb-setup --admin-user admin`;
}
142 changes: 142 additions & 0 deletions app/lib/releaseInfo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
"use client";

import { useEffect, useState } from "react";

// The site publishes out/packages/release-info.json on every deployment, built
// from the GitHub release the package repository actually mirrors. It is the
// only authoritative statement of "what version is on documentdb.io", so the
// UI derives its version strings from it rather than repeating them.
//
// Before this module the versions were hardcoded in the page, and they drifted:
// the page still advertised 0.114-0 (and 0.113-0 for the repository examples)
// after v0.116-0 had been published and mirrored.

export type ReleaseInfo = {
/** Git tag of the mirrored release, e.g. "v0.116-0". */
tagName: string;
/** Extension package version on DEB, e.g. "0.116-0". */
aptVersion: string;
/** Extension package version on RPM, e.g. "0.116.0-1.el9". */
rpmVersion: string;
/** Version of every non-extension package, e.g. "0.116.0". */
metaVersion: string;
releaseUrl: string;
assetNames: readonly string[];
};

// Used until the fetch resolves, and permanently if it fails. A stale-but-valid
// page is much better than a blank one, so this is a real release rather than a
// placeholder. Keep it in step with the newest release; the drift check in CI
// fails the build when it falls behind release-info.json.
export const FALLBACK_RELEASE: ReleaseInfo = {
tagName: "v0.116-0",
aptVersion: "0.116-0",
rpmVersion: "0.116.0-1.el9",
metaVersion: "0.116.0",
releaseUrl: "https://github.com/documentdb/documentdb/releases/tag/v0.116-0",
assetNames: [],
};

type RawReleaseInfo = {
tag_name?: unknown;
html_url?: unknown;
assets?: unknown;
};

function assetNamesOf(raw: RawReleaseInfo): string[] {
if (!Array.isArray(raw.assets)) {
return [];
}
return raw.assets
.map((asset) =>
asset && typeof asset === "object" && typeof (asset as { name?: unknown }).name === "string"
? (asset as { name: string }).name
: null,
)
.filter((name): name is string => name !== null);
}

function firstMatch(names: readonly string[], pattern: RegExp): string | null {
for (const name of names) {
const match = pattern.exec(name);
if (match?.[1]) {
return match[1];
}
}
return null;
}

/**
* Derives the display versions from a release-info.json payload.
*
* Each field falls back independently: a release that stops shipping one
* package shape must not blank out the versions that are still present.
*/
export function parseReleaseInfo(payload: unknown): ReleaseInfo {
if (!payload || typeof payload !== "object") {
return FALLBACK_RELEASE;
}
const raw = payload as RawReleaseInfo;
const names = assetNamesOf(raw);

const tagName = typeof raw.tag_name === "string" ? raw.tag_name : FALLBACK_RELEASE.tagName;
const releaseUrl =
typeof raw.html_url === "string"
? raw.html_url
: `https://github.com/documentdb/documentdb/releases/tag/${tagName}`;

// The extension keeps the control-file form (0.116-0) on DEB, while RPM
// splits it into Version/Release and renders 0.116.0-1.el9. Everything else
// uses the flat dotted form. Read all three off real filenames so the page
// cannot claim a shape the release does not contain.
const aptVersion =
firstMatch(names, /^ubuntu[\d.]+-postgresql-\d+-documentdb_([^_]+)_/) ??
firstMatch(names, /^deb\d+-postgresql-\d+-documentdb_([^_]+)_/) ??
FALLBACK_RELEASE.aptVersion;

const rpmVersion =
firstMatch(names, /^rhel\d+-postgresql\d+-documentdb-(.+)\.(?:x86_64|aarch64)\.rpm$/) ??
FALLBACK_RELEASE.rpmVersion;

const metaVersion =
firstMatch(names, /^ubuntu[\d.]+-documentdb_([^_]+)_all\.deb$/) ??
firstMatch(names, /^documentdb-(\d+\.\d+\.\d+)-\d+\.noarch\.rpm$/) ??
FALLBACK_RELEASE.metaVersion;

return { tagName, aptVersion, rpmVersion, metaVersion, releaseUrl, assetNames: names };
}

/**
* Reads the mirrored release description published alongside the packages.
*
* Returns the fallback synchronously so the first paint is always correct-ish,
* then swaps in the live values. The site is a static export, so this has to
* happen in the browser; NEXT_PUBLIC_BASE_PATH is the one base-path value Next
* keeps in the client bundle.
*/
export function useReleaseInfo(): ReleaseInfo {
const [release, setRelease] = useState<ReleaseInfo>(FALLBACK_RELEASE);

useEffect(() => {
let cancelled = false;
const basePath = process.env.NEXT_PUBLIC_BASE_PATH ?? "";

fetch(`${basePath}/packages/release-info.json`)
.then((response) => (response.ok ? response.json() : Promise.reject(response.status)))
.then((payload) => {
if (!cancelled) {
setRelease(parseReleaseInfo(payload));
}
})
.catch(() => {
// Keep the fallback: an unreachable or malformed feed must not empty
// the install commands the page exists to show.
});

return () => {
cancelled = true;
};
}, []);

return release;
}
Loading