Skip to content

Commit 0300ea8

Browse files
committed
infra: scope shared-cluster postgres resources by lane
Use lane-specific Postgres role and database names for non-prod stacks so dev deploys can share the managed cluster without colliding with prod objects or failing on reruns. Made-with: Cursor
1 parent 630b72a commit 0300ea8

10 files changed

Lines changed: 567 additions & 292 deletions

File tree

infra/README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,8 @@ Hostnames are derived in `infra/platform` from `platform:hostname` and `platform
123123

124124
`layer_1` and `layer_2` stack names are environment-specific when a lane has its own substrate (for example `dev` on the dedicated `dev-staging` node) and remain `layer_1` / `layer_2` on the production manager; confirm with `pulumi stack ls` per project before apply.
125125

126+
When multiple lanes share the same DigitalOcean Postgres cluster, non-prod stacks scope service roles and service databases by stack name (for example `grafana-dev` and `replicated-telegraf-telegraf-dev`) so reruns do not collide with prod. The bootstrap connection DB defaults to `sprocket_main` and can be overridden per stack with `postgres-management-database`.
127+
126128
## GitHub Actions
127129

128130
### Container image tags (CI)

infra/global/helpers/datastore/PostgresUser.ts

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import * as pulumi from "@pulumi/pulumi";
22
import * as postgres from "@pulumi/postgresql";
33
import {ServiceCredentials} from "../ServiceCredentials";
4+
import { EnsureSharedClusterRole, laneScopedPostgresName, usesLegacySharedClusterNames } from "./SharedClusterPostgres";
45

56

67
export interface PostgresUserArgs {
@@ -17,23 +18,34 @@ export class PostgresUser extends pulumi.ComponentResource {
1718
readonly password: pulumi.Output<string>;
1819

1920
private readonly credentials: ServiceCredentials;
20-
private readonly role: postgres.Role;
21+
private readonly role: EnsureSharedClusterRole | postgres.Role;
2122

2223
constructor(name: string, args: PostgresUserArgs, opts?: pulumi.ComponentResourceOptions) {
2324
super("sprocket:PostgresUser", name, {}, opts)
2425

26+
const scopedUsername = laneScopedPostgresName(args.username);
27+
2528
this.credentials = new ServiceCredentials(`${name}-pw`, {
26-
username: args.username,
29+
username: scopedUsername,
2730
}, {parent: this});
2831

2932
this.username = this.credentials.username;
3033
this.password = this.credentials.password;
3134

32-
this.role = new postgres.Role(`${name}-role`, {
33-
name: this.credentials.username,
34-
login: true,
35-
password: this.credentials.password,
35+
if (usesLegacySharedClusterNames()) {
36+
this.role = new postgres.Role(`${name}-role`, {
37+
name: this.credentials.username,
38+
login: true,
39+
password: this.credentials.password,
40+
replication: args.replication ?? false,
41+
}, {provider: args.providers.postgres, parent: this, import: args.importId});
42+
return;
43+
}
44+
45+
this.role = new EnsureSharedClusterRole(`${name}-role`, {
46+
roleName: this.credentials.username,
47+
rolePassword: this.credentials.password,
3648
replication: args.replication ?? false,
37-
}, {provider: args.providers.postgres, parent: this, import: args.importId});
49+
}, { parent: this });
3850
}
3951
}
Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
1+
import * as pulumi from "@pulumi/pulumi";
2+
import { Client } from "pg";
3+
4+
const LEGACY_SHARED_CLUSTER_STACKS = new Set(["layer_1", "layer_2", "prod"]);
5+
6+
type SharedClusterConnection = {
7+
host: string;
8+
port: number;
9+
username: string;
10+
password: string;
11+
database: string;
12+
};
13+
14+
type SharedClusterRoleInputs = SharedClusterConnection & {
15+
roleName: string;
16+
rolePassword: string;
17+
replication: boolean;
18+
};
19+
20+
type SharedClusterDatabaseInputs = SharedClusterConnection & {
21+
databaseName: string;
22+
ownerRole: string;
23+
};
24+
25+
export function usesLegacySharedClusterNames(stack = pulumi.getStack()): boolean {
26+
return LEGACY_SHARED_CLUSTER_STACKS.has(stack);
27+
}
28+
29+
export function laneScopedPostgresName(baseName: string, stack = pulumi.getStack()): string {
30+
if (usesLegacySharedClusterNames(stack)) {
31+
return baseName;
32+
}
33+
34+
return `${baseName}-${normalizeStackName(stack)}`;
35+
}
36+
37+
export function getPostgresManagementDatabaseName(config = new pulumi.Config()): string {
38+
return config.get("postgres-management-database")?.trim() || "sprocket_main";
39+
}
40+
41+
function normalizeStackName(stack: string): string {
42+
const normalized = stack
43+
.trim()
44+
.toLowerCase()
45+
.replace(/[^a-z0-9_-]+/g, "-")
46+
.replace(/^-+|-+$/g, "");
47+
48+
return normalized || "stack";
49+
}
50+
51+
function getSharedClusterConnectionInputs(config = new pulumi.Config()) {
52+
return {
53+
host: config.require("postgres-host"),
54+
port: config.requireNumber("postgres-port"),
55+
username: config.require("postgres-username"),
56+
password: config.requireSecret("postgres-password"),
57+
database: getPostgresManagementDatabaseName(config),
58+
};
59+
}
60+
61+
function quoteIdentifier(value: string): string {
62+
return `"${value.replace(/"/g, "\"\"")}"`;
63+
}
64+
65+
function quoteLiteral(value: string): string {
66+
return `'${value.replace(/'/g, "''")}'`;
67+
}
68+
69+
async function withClient<T>(connection: SharedClusterConnection, callback: (client: Client) => Promise<T>): Promise<T> {
70+
const client = new Client({
71+
host: connection.host,
72+
port: connection.port,
73+
user: connection.username,
74+
password: connection.password,
75+
database: connection.database,
76+
ssl: {
77+
rejectUnauthorized: false,
78+
},
79+
});
80+
81+
await client.connect();
82+
83+
try {
84+
return await callback(client);
85+
} finally {
86+
await client.end();
87+
}
88+
}
89+
90+
async function ensureRole(inputs: SharedClusterRoleInputs): Promise<void> {
91+
await withClient(inputs, async (client) => {
92+
const existingRole = await client.query("select 1 from pg_roles where rolname = $1", [inputs.roleName]);
93+
const replicationClause = inputs.replication ? "REPLICATION" : "NOREPLICATION";
94+
95+
if (existingRole.rowCount && existingRole.rowCount > 0) {
96+
await client.query(
97+
`ALTER ROLE ${quoteIdentifier(inputs.roleName)} WITH LOGIN ${replicationClause} PASSWORD ${quoteLiteral(inputs.rolePassword)}`,
98+
);
99+
return;
100+
}
101+
102+
await client.query(
103+
`CREATE ROLE ${quoteIdentifier(inputs.roleName)} WITH LOGIN ${replicationClause} PASSWORD ${quoteLiteral(inputs.rolePassword)}`,
104+
);
105+
});
106+
}
107+
108+
async function ensureDatabase(inputs: SharedClusterDatabaseInputs): Promise<void> {
109+
await withClient(inputs, async (client) => {
110+
const existingDatabase = await client.query("select 1 from pg_database where datname = $1", [inputs.databaseName]);
111+
112+
if (existingDatabase.rowCount && existingDatabase.rowCount > 0) {
113+
await client.query(
114+
`ALTER DATABASE ${quoteIdentifier(inputs.databaseName)} OWNER TO ${quoteIdentifier(inputs.ownerRole)}`,
115+
);
116+
return;
117+
}
118+
119+
await client.query(
120+
`CREATE DATABASE ${quoteIdentifier(inputs.databaseName)} OWNER ${quoteIdentifier(inputs.ownerRole)}`,
121+
);
122+
});
123+
}
124+
125+
class SharedClusterRoleProvider implements pulumi.dynamic.ResourceProvider {
126+
async create(inputs: SharedClusterRoleInputs) {
127+
await ensureRole(inputs);
128+
129+
return {
130+
id: inputs.roleName,
131+
outs: {
132+
...inputs,
133+
roleName: inputs.roleName,
134+
},
135+
};
136+
}
137+
138+
async diff(id: string, olds: SharedClusterRoleInputs, news: SharedClusterRoleInputs) {
139+
const replaces = olds.roleName !== news.roleName ? ["roleName"] : [];
140+
const changes = replaces.length > 0
141+
|| olds.rolePassword !== news.rolePassword
142+
|| olds.replication !== news.replication
143+
|| olds.host !== news.host
144+
|| olds.port !== news.port
145+
|| olds.username !== news.username
146+
|| olds.password !== news.password
147+
|| olds.database !== news.database;
148+
149+
return { changes, replaces };
150+
}
151+
152+
async update(id: string, olds: SharedClusterRoleInputs, news: SharedClusterRoleInputs) {
153+
await ensureRole(news);
154+
155+
return {
156+
outs: {
157+
...news,
158+
roleName: news.roleName,
159+
},
160+
};
161+
}
162+
163+
async delete(id: string, props: SharedClusterRoleInputs) {
164+
return;
165+
}
166+
}
167+
168+
class SharedClusterDatabaseProvider implements pulumi.dynamic.ResourceProvider {
169+
async create(inputs: SharedClusterDatabaseInputs) {
170+
await ensureDatabase(inputs);
171+
172+
return {
173+
id: inputs.databaseName,
174+
outs: {
175+
...inputs,
176+
databaseName: inputs.databaseName,
177+
},
178+
};
179+
}
180+
181+
async diff(id: string, olds: SharedClusterDatabaseInputs, news: SharedClusterDatabaseInputs) {
182+
const replaces = olds.databaseName !== news.databaseName ? ["databaseName"] : [];
183+
const changes = replaces.length > 0
184+
|| olds.ownerRole !== news.ownerRole
185+
|| olds.host !== news.host
186+
|| olds.port !== news.port
187+
|| olds.username !== news.username
188+
|| olds.password !== news.password
189+
|| olds.database !== news.database;
190+
191+
return { changes, replaces };
192+
}
193+
194+
async update(id: string, olds: SharedClusterDatabaseInputs, news: SharedClusterDatabaseInputs) {
195+
await ensureDatabase(news);
196+
197+
return {
198+
outs: {
199+
...news,
200+
databaseName: news.databaseName,
201+
},
202+
};
203+
}
204+
205+
async delete(id: string, props: SharedClusterDatabaseInputs) {
206+
return;
207+
}
208+
}
209+
210+
export interface EnsureSharedClusterRoleArgs {
211+
roleName: pulumi.Input<string>;
212+
rolePassword: pulumi.Input<string>;
213+
replication?: pulumi.Input<boolean>;
214+
}
215+
216+
export class EnsureSharedClusterRole extends pulumi.dynamic.Resource {
217+
readonly roleName!: pulumi.Output<string>;
218+
219+
constructor(name: string, args: EnsureSharedClusterRoleArgs, opts?: pulumi.CustomResourceOptions) {
220+
const connection = getSharedClusterConnectionInputs();
221+
222+
super(new SharedClusterRoleProvider(), name, {
223+
host: connection.host,
224+
port: connection.port,
225+
username: connection.username,
226+
password: connection.password,
227+
database: connection.database,
228+
roleName: args.roleName,
229+
rolePassword: args.rolePassword,
230+
replication: args.replication ?? false,
231+
}, {
232+
...opts,
233+
additionalSecretOutputs: ["password", "rolePassword"],
234+
});
235+
}
236+
}
237+
238+
export interface EnsureSharedClusterDatabaseArgs {
239+
databaseName: pulumi.Input<string>;
240+
ownerRole: pulumi.Input<string>;
241+
}
242+
243+
export class EnsureSharedClusterDatabase extends pulumi.dynamic.Resource {
244+
readonly databaseName!: pulumi.Output<string>;
245+
246+
constructor(name: string, args: EnsureSharedClusterDatabaseArgs, opts?: pulumi.CustomResourceOptions) {
247+
const connection = getSharedClusterConnectionInputs();
248+
249+
super(new SharedClusterDatabaseProvider(), name, {
250+
host: connection.host,
251+
port: connection.port,
252+
username: connection.username,
253+
password: connection.password,
254+
database: connection.database,
255+
databaseName: args.databaseName,
256+
ownerRole: args.ownerRole,
257+
}, {
258+
...opts,
259+
additionalSecretOutputs: ["password"],
260+
});
261+
}
262+
}

infra/global/package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,11 @@
2222
"@types/axios": "^0.14.0",
2323
"@types/yaml": "^1.9.7",
2424
"axios": "^0.27.1",
25+
"pg": "^8.20.0",
2526
"remove": "^0.1.5",
2627
"yaml": "^2.0.1"
28+
},
29+
"devDependencies": {
30+
"@types/pg": "^8.20.0"
2731
}
2832
}

infra/global/providers/SprocketPostgresProvider.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import * as pulumi from "@pulumi/pulumi"
22
import * as postgres from "@pulumi/postgresql";
3+
import { getPostgresManagementDatabaseName } from "../helpers/datastore/SharedClusterPostgres";
34

45
export interface SprocketPostgresProviderArgs extends Omit<postgres.ProviderArgs, "username" | "password" | "host" | "sslmode" | "port"> {
56
}
@@ -18,6 +19,7 @@ export class SprocketPostgresProvider extends postgres.Provider {
1819
const password = config.require('postgres-password');
1920
const host = config.require('postgres-host');
2021
const port = config.requireNumber('postgres-port');
22+
const database = getPostgresManagementDatabaseName(config);
2123

2224
super("SprocketPostgresProvider", {
2325
...args,
@@ -26,7 +28,7 @@ export class SprocketPostgresProvider extends postgres.Provider {
2628
host: host,
2729
sslmode: 'require',
2830
port: port,
29-
database: 'sprocket_main',
31+
database: database,
3032
superuser: false
3133
}, opts);
3234

0 commit comments

Comments
 (0)