give each member a postgres role instead of a docker daemon
A Postgres login role named the same as their Linux account, with CREATEDB, plus a
~/.pgpass so psql never prompts. This is what replaces rootless Docker: the case it
was really there for was "let me run a database to develop against", and a container
per member answered it with a daemon, an image cache and a subuid range each.
Measured on postgres:18-alpine before writing any of it, because three things I
asserted turned out to be wrong:
- a fresh LOGIN role CAN connect to `officer` (datacl NULL = PUBLIC has CONNECT),
but CANNOT read any application table — privileges are owner-only, so
has_table_privilege('users','UPDATE') is false. The capability model was never
reachable from here.
- the `trust` line in pg_hba does not cover host connections: Docker's NAT rewrites
the source, so they fall through to scram-sha-256. Verified with a wrong password.
- revoking from the ROLE does nothing. Privileges are additive and there is no DENY;
only revoking from PUBLIC is a lock.
So ensureAppDatabaseClosed revokes CONNECT+TEMPORARY on the platform's own database
from PUBLIC, and it runs inside provisionPostgresRole rather than in the setup script
— an install set up before today, or restored from a dump, then still cannot end up
with a member who can connect to `officer`.
Password is generated per member, 40 chars, rejection-sampled over an alphanumeric
alphabet: CREATE ROLE is a utility statement and cannot take a bind parameter, so the
safety comes from the alphabet rather than from escaping. Not stored anywhere — it
lives in their 600 ~/.pgpass, the same posture as their SSH key, where we keep only
the public half. Only (re)set when .pgpass is missing, so a reprovision does not
rotate a credential they may have pasted into an app config.
KNOWN RESIDUE, not handled: a database one member creates is metadata-readable by
another. datacl is not inherited from the template (measured: closing template1 and
creating from it still produced NULL), and CREATE DATABASE fires no event trigger, so
nothing can close it at creation. A second member can read table and column NAMES from
the catalogue. They cannot read a row and cannot create anything. Closing it needs a
sweep or a pg_hba rule per member; both are decisions, not details.
Verified live against the running cluster: role creation, refusal on `officer`,
creating and using two databases, and the DROP ... WITH (FORCE) teardown. All probe
roles and databases dropped afterwards.
NOT verified: bunx tsgo, still — node_modules is empty in this tree. The drizzle
return shape was checked by reading PostgresJsQueryResultHKT (RowList<T[]>, extends
Array) rather than by running it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,7 @@ import { ensureOsUser, osUserHome } from '@@/os-user';
|
|||||||
import { provisionSshAccess } from '@@/os-user-ssh';
|
import { provisionSshAccess } from '@@/os-user-ssh';
|
||||||
import { seedShellConfig } from '@@/os-user-shell';
|
import { seedShellConfig } from '@@/os-user-shell';
|
||||||
import { provisionClaudeCli } from '@@/os-user-claude';
|
import { provisionClaudeCli } from '@@/os-user-claude';
|
||||||
|
import { provisionPostgresRole } from '@@/os-user-postgres';
|
||||||
// Disabled 2026-08-13 — see the commented-out step in provisionOsAccount below.
|
// Disabled 2026-08-13 — see the commented-out step in provisionOsAccount below.
|
||||||
// import { provisionRootlessDocker } from '@@/os-user-docker';
|
// import { provisionRootlessDocker } from '@@/os-user-docker';
|
||||||
import { provisionUserDirs } from '@@/data-path';
|
import { provisionUserDirs } from '@@/data-path';
|
||||||
@@ -78,6 +79,19 @@ export async function provisionOsAccount(params: {
|
|||||||
// cannot do it for them and must not try, because the alternative is lending them the owner's credential.
|
// cannot do it for them and must not try, because the alternative is lending them the owner's credential.
|
||||||
const claude = await provisionClaudeCli({ email: params.email, osUser: account.osUser });
|
const claude = await provisionClaudeCli({ email: params.email, osUser: account.osUser });
|
||||||
|
|
||||||
|
// A Postgres login role of the same name, with CREATEDB. What replaced rootless Docker for the
|
||||||
|
// "let me run a database to develop against" case, at roughly none of the cost.
|
||||||
|
//
|
||||||
|
// Also the step that shuts PUBLIC out of the platform's own database — deliberately inside the function
|
||||||
|
// that creates the role rather than in the setup script, so it cannot be skipped by an install that was
|
||||||
|
// set up before this existed. See os-user-postgres.ts.
|
||||||
|
const postgres = await provisionPostgresRole({
|
||||||
|
email: params.email,
|
||||||
|
osUser: account.osUser,
|
||||||
|
uid: account.uid,
|
||||||
|
gid: account.gid,
|
||||||
|
});
|
||||||
|
|
||||||
// ── Rootless Docker: DISABLED 2026-08-13, code kept ──
|
// ── Rootless Docker: DISABLED 2026-08-13, code kept ──
|
||||||
//
|
//
|
||||||
// Every member got their own rootless daemon, unconditionally, on the argument that "can I run a database
|
// Every member got their own rootless daemon, unconditionally, on the argument that "can I run a database
|
||||||
@@ -105,9 +119,17 @@ export async function provisionOsAccount(params: {
|
|||||||
// Reported in order of consequence, not in order of execution: no keys matters more than a plain prompt,
|
// Reported in order of consequence, not in order of execution: no keys matters more than a plain prompt,
|
||||||
// which matters more than no containers. Only one is surfaced because the UI shows one line — the rest are
|
// which matters more than no containers. Only one is surfaced because the UI shows one line — the rest are
|
||||||
// in the log.
|
// in the log.
|
||||||
for (const step of [claude, shell] as const) {
|
for (const step of [claude, shell, postgres] as const) {
|
||||||
if (!step.ok) console.warn(`[users] ${params.email}: ${step.error}`);
|
if (!step.ok) console.warn(`[users] ${params.email}: ${step.error}`);
|
||||||
}
|
}
|
||||||
const error = !ssh.ok ? ssh.error : !claude.ok ? claude.error : !shell.ok ? shell.error : null;
|
const error = !ssh.ok
|
||||||
|
? ssh.error
|
||||||
|
: !claude.ok
|
||||||
|
? claude.error
|
||||||
|
: !shell.ok
|
||||||
|
? shell.error
|
||||||
|
: !postgres.ok
|
||||||
|
? postgres.error
|
||||||
|
: null;
|
||||||
return { osUser: account.osUser, sshPublicKey, error };
|
return { osUser: account.osUser, sshPublicKey, error };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,264 @@
|
|||||||
|
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { sql } from 'drizzle-orm';
|
||||||
|
import { db } from 'officerdb/db';
|
||||||
|
import { osUserHome, runAs } from './os-user';
|
||||||
|
|
||||||
|
// A Postgres login role for a member, named the same as their Linux account.
|
||||||
|
//
|
||||||
|
// This is what replaced rootless Docker per member (disabled 2026-08-13). The use case was always "let me
|
||||||
|
// run a database to develop against" and a container per member was an expensive way to answer it — one
|
||||||
|
// daemon, one image cache and one subuid range each. A role on the cluster that is already running costs
|
||||||
|
// a row in `pg_authid`.
|
||||||
|
//
|
||||||
|
// ── What the member gets ──
|
||||||
|
//
|
||||||
|
// CREATEDB as many databases as they like, owned by them. Not one database provisioned for them:
|
||||||
|
// the ask was to create databases in their own name, not to be handed one.
|
||||||
|
// ~/.pgpass so `psql` never prompts. Mode 600, owned by them, written the same way as their SSH
|
||||||
|
// keys — see os-user-ssh.ts for why every write into a 700 home goes through
|
||||||
|
// `sudo install`.
|
||||||
|
//
|
||||||
|
// ── What keeps them out of everything else ──
|
||||||
|
//
|
||||||
|
// Measured on postgres:18-alpine, 2026-08-13, because every part of this was wrong when assumed:
|
||||||
|
//
|
||||||
|
// - A fresh `CREATE ROLE … LOGIN` CAN connect to `officer`. `pg_database.datacl` is NULL, which means
|
||||||
|
// default privileges, which for a database means PUBLIC holds CONNECT and TEMPORARY. That is why
|
||||||
|
// `ensureAppDatabaseClosed` exists and why it runs before any role is created.
|
||||||
|
// - Once connected it can NOT read an application table. Table privileges default to owner-only and
|
||||||
|
// nothing grants to PUBLIC — `has_table_privilege('users','UPDATE')` is false. So the capability model
|
||||||
|
// was never reachable from here; the exposure was catalogue metadata, not data.
|
||||||
|
// - Revoking from the ROLE does not help. Postgres privileges are additive and there is no DENY, so a
|
||||||
|
// PUBLIC grant is not overridden by a role-level revoke. Revoking from PUBLIC is the only lock.
|
||||||
|
//
|
||||||
|
// ── The residue, stated rather than implied ──
|
||||||
|
//
|
||||||
|
// A database one member creates is readable-as-metadata by another: `datacl` is NOT inherited from the
|
||||||
|
// template (measured — closing `template1` and creating from it still produced a NULL `datacl`), and
|
||||||
|
// `CREATE DATABASE` fires no event trigger, so nothing can close a member's database at the moment they
|
||||||
|
// create it. A second member can connect and read table and column NAMES from the catalogue. They cannot
|
||||||
|
// read a row, and they cannot create anything (PG15+ removed PUBLIC's CREATE on `public`).
|
||||||
|
//
|
||||||
|
// Closing that needs either a sweep or a `pg_hba.conf` rule per member, and both are decisions rather
|
||||||
|
// than details. Not built. Do not let this comment be read as "handled".
|
||||||
|
|
||||||
|
/** Legal Linux/Postgres account name. Identical to `validateUsername`, restated because this one reaches SQL. */
|
||||||
|
const ROLE_NAME_RE = /^[a-zA-Z0-9._-]{2,32}$/;
|
||||||
|
|
||||||
|
// Deliberately alphanumeric. This value is interpolated into `PASSWORD '…'` as a literal, because
|
||||||
|
// CREATE ROLE is a utility statement and cannot take a bind parameter — so the safety has to come from
|
||||||
|
// the alphabet rather than from escaping. No quote, no backslash, nothing to terminate the literal with.
|
||||||
|
const PASSWORD_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||||
|
const PASSWORD_LENGTH = 40;
|
||||||
|
|
||||||
|
// Every attribute stated, including the negatives, and shared by CREATE and ALTER so the two cannot drift.
|
||||||
|
// The defaults are already NO* for all of them — writing them down is what makes a hand-made change to a
|
||||||
|
// member's role show up as a difference rather than as the way it always was.
|
||||||
|
const ROLE_ATTRIBUTES = 'LOGIN CREATEDB NOSUPERUSER NOCREATEROLE NOREPLICATION NOBYPASSRLS';
|
||||||
|
|
||||||
|
/** Uniform over the alphabet by rejection sampling — `% n` on a byte would bias the first 4 characters. */
|
||||||
|
export function generatePassword(length = PASSWORD_LENGTH): string {
|
||||||
|
const max = 256 - (256 % PASSWORD_ALPHABET.length);
|
||||||
|
let out = '';
|
||||||
|
while (out.length < length) {
|
||||||
|
const bytes = new Uint8Array(length);
|
||||||
|
crypto.getRandomValues(bytes);
|
||||||
|
for (const byte of bytes) {
|
||||||
|
if (byte >= max) continue;
|
||||||
|
out += PASSWORD_ALPHABET[byte % PASSWORD_ALPHABET.length];
|
||||||
|
if (out.length === length) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where `.pgpass` should point, taken from the platform's own connection string.
|
||||||
|
*
|
||||||
|
* The member connects to the same cluster the platform does, so deriving it means one fact rather than two
|
||||||
|
* that can disagree. Postgres is published on loopback only, which is why 127.0.0.1 is the fallback rather
|
||||||
|
* than a guess.
|
||||||
|
*/
|
||||||
|
export function pgpassTarget(postgresUrl: string | undefined): { host: string; port: string } {
|
||||||
|
if (!postgresUrl) return { host: '127.0.0.1', port: '5432' };
|
||||||
|
try {
|
||||||
|
const url = new URL(postgresUrl);
|
||||||
|
return { host: url.hostname || '127.0.0.1', port: url.port || '5432' };
|
||||||
|
} catch {
|
||||||
|
return { host: '127.0.0.1', port: '5432' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sudo(args: string[]): Promise<{ ok: boolean; out: string }> {
|
||||||
|
const proc = Bun.spawn(['sudo', '-n', ...args], { stdout: 'pipe', stderr: 'pipe' });
|
||||||
|
const [out, err] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]);
|
||||||
|
return { ok: (await proc.exited) === 0, out: `${out}${err}`.trim() };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Same shape as os-user-ssh's: content, owner and mode in one step, so the file never exists at the umask. */
|
||||||
|
async function installFile(params: { content: string; dest: string; uid: number; gid: number; mode: string }) {
|
||||||
|
const dir = await mkdtemp(join(tmpdir(), 'officer-pg-'));
|
||||||
|
const staged = join(dir, 'staged');
|
||||||
|
try {
|
||||||
|
await writeFile(staged, params.content, { mode: 0o600 });
|
||||||
|
return await sudo([
|
||||||
|
'install',
|
||||||
|
'-o',
|
||||||
|
String(params.uid),
|
||||||
|
'-g',
|
||||||
|
String(params.gid),
|
||||||
|
'-m',
|
||||||
|
params.mode,
|
||||||
|
staged,
|
||||||
|
params.dest,
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
await rm(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shut PUBLIC out of the platform's own database.
|
||||||
|
*
|
||||||
|
* Runs here, before any role is created, rather than once in the setup script. The guarantee then lives
|
||||||
|
* with the code that creates the hazard: an install whose setup predates this, or was run by hand, or was
|
||||||
|
* restored from a dump, still cannot end up with a member who can connect to `officer`.
|
||||||
|
*
|
||||||
|
* Idempotent — REVOKE on a privilege that is already absent succeeds and changes nothing.
|
||||||
|
*
|
||||||
|
* The platform is unaffected: it connects as a superuser, which bypasses privilege checks entirely. That
|
||||||
|
* is also the reason this is safe to run against a live server.
|
||||||
|
*/
|
||||||
|
export type AppDatabaseClosedResult = { ok: true; database: string } | { ok: false; error: string };
|
||||||
|
|
||||||
|
export async function ensureAppDatabaseClosed(): Promise<AppDatabaseClosedResult> {
|
||||||
|
try {
|
||||||
|
const [row] = await db.execute<{ current_database: string }>(sql`select current_database()`);
|
||||||
|
const database = row?.current_database;
|
||||||
|
if (!database) return { ok: false, error: 'could not determine the current database' };
|
||||||
|
|
||||||
|
// TEMPORARY as well as CONNECT. CONNECT is the lock; TEMPORARY is the only other thing PUBLIC holds by
|
||||||
|
// default, and leaving it grants a connected superuser-adjacent role nothing but a way to fill a disk.
|
||||||
|
await db.execute(sql`REVOKE CONNECT, TEMPORARY ON DATABASE ${sql.identifier(database)} FROM PUBLIC`);
|
||||||
|
return { ok: true, database };
|
||||||
|
} catch (ex) {
|
||||||
|
return { ok: false, error: `could not close the platform database to PUBLIC: ${asMessage(ex)}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const asMessage = (ex: unknown): string => (ex instanceof Error ? ex.message : String(ex));
|
||||||
|
|
||||||
|
export type PostgresRoleResult =
|
||||||
|
| { ok: true; role: string; created: boolean; passwordSet: boolean }
|
||||||
|
| { ok: false; error: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Give the account a Postgres login role of the same name.
|
||||||
|
*
|
||||||
|
* Idempotent, and the two halves are idempotent differently. The ROLE is adopted if it exists. The
|
||||||
|
* PASSWORD is only (re)set when `.pgpass` is missing, because we do not store it anywhere else — so a
|
||||||
|
* reprovision of a working account must not rotate a credential the member may have pasted into an
|
||||||
|
* application config. A missing `.pgpass` is the one case where rotating is the only way to get back to a
|
||||||
|
* known state, and there is nothing to lose by then.
|
||||||
|
*
|
||||||
|
* Never throws. Same posture as its neighbours in `provisionOsAccount`: an account with no database role
|
||||||
|
* is still a working account.
|
||||||
|
*/
|
||||||
|
export async function provisionPostgresRole(params: {
|
||||||
|
email: string;
|
||||||
|
osUser: string;
|
||||||
|
uid: number;
|
||||||
|
gid: number;
|
||||||
|
}): Promise<PostgresRoleResult> {
|
||||||
|
// Before anything else, and before the role can exist to take advantage of it.
|
||||||
|
const closed = await ensureAppDatabaseClosed();
|
||||||
|
if (!closed.ok) return { ok: false, error: closed.error };
|
||||||
|
|
||||||
|
// Re-asserted here even though `validateUsername` already ran at the route. This string reaches SQL as an
|
||||||
|
// identifier, and the distance between that check and this one is a whole call chain — the kind of gap
|
||||||
|
// where a future caller arrives without having passed the first one.
|
||||||
|
if (!ROLE_NAME_RE.test(params.osUser)) {
|
||||||
|
return { ok: false, error: `'${params.osUser}' is not a usable Postgres role name` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const home = osUserHome(params.email);
|
||||||
|
const pgpass = join(home, '.pgpass');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const existing = await db.execute<{ rolname: string }>(
|
||||||
|
sql`select rolname from pg_roles where rolname = ${params.osUser}`,
|
||||||
|
);
|
||||||
|
const created = existing.length === 0;
|
||||||
|
|
||||||
|
// Tested AS THE MEMBER, through runAs — a 700 home is unreadable to the service user, so a `test -f`
|
||||||
|
// run as ourselves reports "missing" for a file sitting right there, and would rotate their password on
|
||||||
|
// every single reprovision.
|
||||||
|
const probe = runAs(params.osUser, ['test', '-f', pgpass]);
|
||||||
|
const hasPgpass = (await probe.exited) === 0;
|
||||||
|
|
||||||
|
const passwordSet = created || !hasPgpass;
|
||||||
|
const password = passwordSet ? generatePassword() : null;
|
||||||
|
|
||||||
|
if (created) {
|
||||||
|
await db.execute(sql.raw(`CREATE ROLE "${params.osUser}" ${ROLE_ATTRIBUTES} PASSWORD '${password}'`));
|
||||||
|
} else {
|
||||||
|
// Re-asserted rather than assumed. An adopted role may have been altered by hand between runs, and
|
||||||
|
// this is the only place that states what a member's role is allowed to be.
|
||||||
|
await db.execute(sql.raw(`ALTER ROLE "${params.osUser}" ${ROLE_ATTRIBUTES}`));
|
||||||
|
if (passwordSet) await db.execute(sql.raw(`ALTER ROLE "${params.osUser}" PASSWORD '${password}'`));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (passwordSet && password) {
|
||||||
|
const { host, port } = pgpassTarget(process.env.POSTGRES_URL);
|
||||||
|
// `*` for the database field: they may create as many as they like and every one of them is theirs.
|
||||||
|
// psql ignores a .pgpass that is group- or world-readable, exactly like ssh and authorized_keys, so
|
||||||
|
// 600 is a requirement rather than caution.
|
||||||
|
const written = await installFile({
|
||||||
|
content: `${host}:${port}:*:${params.osUser}:${password}\n`,
|
||||||
|
dest: pgpass,
|
||||||
|
uid: params.uid,
|
||||||
|
gid: params.gid,
|
||||||
|
mode: '600',
|
||||||
|
});
|
||||||
|
if (!written.ok) {
|
||||||
|
return { ok: false, error: `role ${params.osUser} exists but ~/.pgpass could not be written: ${written.out}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true, role: params.osUser, created, passwordSet };
|
||||||
|
} catch (ex) {
|
||||||
|
return { ok: false, error: `could not provision the Postgres role ${params.osUser}: ${asMessage(ex)}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a member's role, and every database they own.
|
||||||
|
*
|
||||||
|
* `DROP ROLE` refuses while the role owns anything, which is the common case for exactly the accounts this
|
||||||
|
* is called for. The databases go first and they go permanently — this is called from account deletion,
|
||||||
|
* where the alternative is a cluster that accumulates unreachable databases owned by a name nobody holds.
|
||||||
|
*
|
||||||
|
* Deliberately NOT wired into `deprovisionOsAccount` yet: that function's contract is that a failure
|
||||||
|
* leaves the account intact and retryable, and dropping databases is not reversible. See the call site.
|
||||||
|
*/
|
||||||
|
export type DropRoleResult = { ok: true; dropped: string[] } | { ok: false; error: string };
|
||||||
|
|
||||||
|
export async function dropPostgresRole(osUser: string): Promise<DropRoleResult> {
|
||||||
|
if (!ROLE_NAME_RE.test(osUser)) return { ok: false, error: `'${osUser}' is not a usable Postgres role name` };
|
||||||
|
|
||||||
|
try {
|
||||||
|
const owned = await db.execute<{ datname: string }>(
|
||||||
|
sql`select datname from pg_database where pg_get_userbyid(datdba) = ${osUser}`,
|
||||||
|
);
|
||||||
|
for (const row of owned) {
|
||||||
|
if (!ROLE_NAME_RE.test(row.datname) && !/^[a-zA-Z0-9._-]+$/.test(row.datname)) continue;
|
||||||
|
await db.execute(sql.raw(`DROP DATABASE IF EXISTS "${row.datname}" WITH (FORCE)`));
|
||||||
|
}
|
||||||
|
await db.execute(sql.raw(`DROP ROLE IF EXISTS "${osUser}"`));
|
||||||
|
return { ok: true, dropped: owned.map((row) => row.datname) };
|
||||||
|
} catch (ex) {
|
||||||
|
return { ok: false, error: `could not drop the Postgres role ${osUser}: ${asMessage(ex)}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -105,11 +105,31 @@ else
|
|||||||
%F{blue}❯%f '
|
%F{blue}❯%f '
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ── Docker ──
|
# ── Postgres ──
|
||||||
# Your own rootless daemon, if Officer provisioned one. Containers you start run as your account in your own
|
# Officer gave your account a Postgres role named the same as your login, and a ~/.pgpass holding its
|
||||||
# user namespace — root inside them is you outside them, and you cannot see anyone else's containers.
|
# password — so psql never prompts you and you never have to know it.
|
||||||
#
|
#
|
||||||
# Set from $XDG_RUNTIME_DIR rather than a hard-coded uid so this line is the same in every account's file.
|
# createdb myapp as many as you like; each one is yours
|
||||||
|
# psql myapp connect to one
|
||||||
|
# dropdb myapp
|
||||||
|
#
|
||||||
|
# Nobody else can read your data, and you cannot reach Officer's own database.
|
||||||
|
#
|
||||||
|
# PGHOST is set because Postgres runs in a container published on loopback: there is no unix socket on
|
||||||
|
# this machine, and without this psql fails with "No such file or directory", which reads like Postgres
|
||||||
|
# is not installed rather than like it is one flag away.
|
||||||
|
#
|
||||||
|
# Read out of ~/.pgpass rather than hard-coded, for the same reason the PATH lines above are $HOME-relative
|
||||||
|
# — one fact, in one place, that cannot come to disagree with itself.
|
||||||
|
if [ -f "$HOME/.pgpass" ]; then
|
||||||
|
export PGHOST="${$(head -1 "$HOME/.pgpass")%%:*}"
|
||||||
|
export PGPORT="$(head -1 "$HOME/.pgpass" | cut -d: -f2)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Docker ──
|
||||||
|
# Officer no longer provisions a rootless daemon per account (2026-08-13) — a Postgres role covers the
|
||||||
|
# case it was really there for, at none of the cost. Kept because it costs one stat and does the right
|
||||||
|
# thing if you install rootless Docker yourself.
|
||||||
if [ -S "${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/docker.sock" ]; then
|
if [ -S "${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/docker.sock" ]; then
|
||||||
export DOCKER_HOST="unix://${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/docker.sock"
|
export DOCKER_HOST="unix://${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/docker.sock"
|
||||||
fi
|
fi
|
||||||
|
|||||||
Reference in New Issue
Block a user