Skip to content

Commit a8a67cc

Browse files
authored
[server] Reconnect to spicedb without waiting 2 mins (+ fail on missing config + client call metrics) (#18570)
1 parent b043f38 commit a8a67cc

File tree

8 files changed

+140
-54
lines changed

8 files changed

+140
-54
lines changed

components/server/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@
3434
"/src"
3535
],
3636
"dependencies": {
37-
"@authzed/authzed-node": "^0.10.0",
37+
"@authzed/authzed-node": "^0.12.1",
3838
"@bufbuild/connect": "^0.8.1",
3939
"@bufbuild/connect-express": "^0.8.1",
4040
"@gitbeaker/node": "^35.7.0",

components/server/src/authorization/authorizer.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,13 +24,14 @@ import {
2424
} from "./definitions";
2525
import { SpiceDBAuthorizer } from "./spicedb-authorizer";
2626
import { getExperimentsClientForBackend } from "@gitpod/gitpod-protocol/lib/experiments/configcat-server";
27+
import { log } from "@gitpod/gitpod-protocol/lib/util/logging";
2728

2829
export function createInitializingAuthorizer(spiceDbAuthorizer: SpiceDBAuthorizer): Authorizer {
2930
const target = new Authorizer(spiceDbAuthorizer);
3031
const initialized = (async () => {
3132
await target.addInstallationAdminRole(BUILTIN_INSTLLATION_ADMIN_USER_ID);
3233
await target.addUser(BUILTIN_INSTLLATION_ADMIN_USER_ID);
33-
})();
34+
})().catch((err) => log.error("Failed to initialize authorizer", err));
3435
return new Proxy(target, {
3536
get(target, propKey, receiver) {
3637
const originalMethod = target[propKey as keyof typeof target];
@@ -481,6 +482,7 @@ export class Authorizer {
481482
optionalSubjectId: relation.subject.object.objectId,
482483
},
483484
},
485+
optionalLimit: 0,
484486
});
485487
if (relationships.length === 0) {
486488
return undefined;
@@ -505,6 +507,7 @@ export class Authorizer {
505507
optionalSubjectId: relation.subject.object.objectId,
506508
},
507509
},
510+
optionalLimit: 0,
508511
});
509512
return relationships.map((r) => r.relationship!);
510513
}

components/server/src/authorization/spicedb-authorizer.ts

Lines changed: 27 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,14 @@ import { TrustedValue } from "@gitpod/gitpod-protocol/lib/util/scrubbing";
1111
import { getExperimentsClientForBackend } from "@gitpod/gitpod-protocol/lib/experiments/configcat-server";
1212
import { inject, injectable } from "inversify";
1313
import { observeSpicedbClientLatency, spicedbClientLatency } from "../prometheus-metrics";
14-
import { SpiceDBClient } from "./spicedb";
14+
import { SpiceDBClientProvider } from "./spicedb";
15+
import * as grpc from "@grpc/grpc-js";
1516

1617
@injectable()
1718
export class SpiceDBAuthorizer {
1819
constructor(
19-
@inject(SpiceDBClient)
20-
private client: SpiceDBClient,
20+
@inject(SpiceDBClientProvider)
21+
private readonly clientProvider: SpiceDBClientProvider,
2122
) {}
2223

2324
async check(
@@ -26,10 +27,6 @@ export class SpiceDBAuthorizer {
2627
userId: string;
2728
},
2829
): Promise<boolean> {
29-
if (!this.client) {
30-
return true;
31-
}
32-
3330
const featureEnabled = await getExperimentsClientForBackend().getValueAsync("centralizedPermissions", false, {
3431
user: {
3532
id: experimentsFields.userId,
@@ -42,7 +39,8 @@ export class SpiceDBAuthorizer {
4239
const timer = spicedbClientLatency.startTimer();
4340
let error: Error | undefined;
4441
try {
45-
const response = await this.client.checkPermission(req);
42+
const client = this.clientProvider.getClient();
43+
const response = await client.checkPermission(req, this.callOptions);
4644
const permitted = response.permissionship === v1.CheckPermissionResponse_Permissionship.HAS_PERMISSION;
4745

4846
return permitted;
@@ -58,17 +56,15 @@ export class SpiceDBAuthorizer {
5856
}
5957

6058
async writeRelationships(...updates: v1.RelationshipUpdate[]): Promise<v1.WriteRelationshipsResponse | undefined> {
61-
if (!this.client) {
62-
return undefined;
63-
}
64-
6559
const timer = spicedbClientLatency.startTimer();
6660
let error: Error | undefined;
6761
try {
68-
const response = await this.client.writeRelationships(
62+
const client = this.clientProvider.getClient();
63+
const response = await client.writeRelationships(
6964
v1.WriteRelationshipsRequest.create({
7065
updates,
7166
}),
67+
this.callOptions,
7268
);
7369
log.info("[spicedb] Successfully wrote relationships.", { response, updates });
7470

@@ -82,17 +78,14 @@ export class SpiceDBAuthorizer {
8278
}
8379

8480
async deleteRelationships(req: v1.DeleteRelationshipsRequest): Promise<v1.ReadRelationshipsResponse[]> {
85-
if (!this.client) {
86-
return [];
87-
}
88-
8981
const timer = spicedbClientLatency.startTimer();
9082
let error: Error | undefined;
9183
try {
92-
const existing = await this.client.readRelationships(v1.ReadRelationshipsRequest.create(req));
84+
const client = this.clientProvider.getClient();
85+
const existing = await client.readRelationships(v1.ReadRelationshipsRequest.create(req), this.callOptions);
9386
if (existing.length > 0) {
94-
const response = await this.client.deleteRelationships(req);
95-
const after = await this.client.readRelationships(v1.ReadRelationshipsRequest.create(req));
87+
const response = await client.deleteRelationships(req, this.callOptions);
88+
const after = await client.readRelationships(v1.ReadRelationshipsRequest.create(req), this.callOptions);
9689
if (after.length > 0) {
9790
log.error("[spicedb] Failed to delete relationships.", { existing, after, request: req });
9891
}
@@ -115,9 +108,21 @@ export class SpiceDBAuthorizer {
115108
}
116109

117110
async readRelationships(req: v1.ReadRelationshipsRequest): Promise<v1.ReadRelationshipsResponse[]> {
118-
if (!this.client) {
111+
const client = this.clientProvider.getClient();
112+
if (!client) {
119113
return [];
120114
}
121-
return this.client.readRelationships(req);
115+
return client.readRelationships(req, this.callOptions);
116+
}
117+
118+
/**
119+
* permission_service.grpc-client.d.ts has all methods overloaded with this pattern:
120+
* - xyzRelationships(input: Request, metadata?: grpc.Metadata | grpc.CallOptions, options?: grpc.CallOptions): grpc.ClientReadableStream<ReadRelationshipsResponse>;
121+
* But the promisified client somehow does not have the same overloads. Thus we convince it here that options may be passed as 2nd argument.
122+
*/
123+
private get callOptions(): grpc.Metadata {
124+
return (<grpc.CallOptions>{
125+
deadline: Date.now() + 8000,
126+
}) as any as grpc.Metadata;
122127
}
123128
}

components/server/src/authorization/spicedb.ts

Lines changed: 79 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,24 @@
55
*/
66

77
import { v1 } from "@authzed/authzed-node";
8+
import { IClientCallMetrics, createClientCallMetricsInterceptor } from "@gitpod/gitpod-protocol/lib/util/grpc";
89
import { log } from "@gitpod/gitpod-protocol/lib/util/logging";
10+
import * as grpc from "@grpc/grpc-js";
911

10-
export const SpiceDBClient = Symbol("SpiceDBClient");
11-
export type SpiceDBClient = v1.ZedPromiseClientInterface | undefined;
12+
export const SpiceDBClientProvider = Symbol("SpiceDBClientProvider");
1213

13-
export function spicedbClientFromEnv(): SpiceDBClient {
14+
export interface SpiceDBClientConfig {
15+
address: string;
16+
token: string;
17+
}
18+
19+
export type SpiceDBClient = v1.ZedPromiseClientInterface;
20+
type Client = v1.ZedClientInterface & grpc.Client;
21+
export interface SpiceDBClientProvider {
22+
getClient(): SpiceDBClient;
23+
}
24+
25+
export function spiceDBConfigFromEnv(): SpiceDBClientConfig | undefined {
1426
const token = process.env["SPICEDB_PRESHARED_KEY"];
1527
if (!token) {
1628
log.error("[spicedb] No preshared key configured.");
@@ -22,6 +34,69 @@ export function spicedbClientFromEnv(): SpiceDBClient {
2234
log.error("[spicedb] No service address configured.");
2335
return undefined;
2436
}
37+
return {
38+
address,
39+
token,
40+
};
41+
}
42+
43+
function spicedbClientFromConfig(config: SpiceDBClientConfig): Client {
44+
const clientOptions: grpc.ClientOptions = {
45+
"grpc.client_idle_timeout_ms": 10000, // this ensures a connection is not stuck in the "READY" state for too long. Required for the reconnect logic below.
46+
"grpc.max_reconnect_backoff_ms": 5000, // default: 12000
47+
};
48+
49+
return v1.NewClient(
50+
config.token,
51+
config.address,
52+
v1.ClientSecurity.INSECURE_PLAINTEXT_CREDENTIALS,
53+
undefined,
54+
clientOptions,
55+
) as Client;
56+
}
57+
58+
export class CachingSpiceDBClientProvider implements SpiceDBClientProvider {
59+
private client: Client | undefined;
60+
61+
private readonly interceptors: grpc.Interceptor[] = [];
62+
63+
constructor(
64+
private readonly _clientConfig: SpiceDBClientConfig,
65+
private readonly clientCallMetrics?: IClientCallMetrics | undefined,
66+
) {
67+
if (this.clientCallMetrics) {
68+
this.interceptors.push(createClientCallMetricsInterceptor(this.clientCallMetrics));
69+
}
70+
}
71+
72+
getClient(): SpiceDBClient {
73+
let client = this.client;
74+
if (!client) {
75+
client = spicedbClientFromConfig(this.clientConfig);
76+
} else if (client.getChannel().getConnectivityState(true) !== grpc.connectivityState.READY) {
77+
// (gpl): We need this check and explicit re-connect because we observe a ~120s connection timeout without it.
78+
// It's not entirely clear where that timeout comes from, but likely from the underlying transport, that is not exposed by grpc/grpc-js
79+
client.close();
80+
81+
log.warn("[spicedb] Lost connection to SpiceDB - reconnecting...");
2582

26-
return v1.NewClient(token, address, v1.ClientSecurity.INSECURE_PLAINTEXT_CREDENTIALS).promises;
83+
client = spicedbClientFromConfig(this.clientConfig);
84+
}
85+
this.client = client;
86+
87+
return client.promises;
88+
}
89+
90+
protected get clientConfig() {
91+
const config = this._clientConfig;
92+
if (this.interceptors) {
93+
return {
94+
...config,
95+
options: {
96+
interceptors: [...this.interceptors],
97+
},
98+
};
99+
}
100+
return config;
101+
}
27102
}

components/server/src/container-module.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ import { AuthJWT, SignInJWT } from "./auth/jwt";
4646
import { LoginCompletionHandler } from "./auth/login-completion-handler";
4747
import { VerificationService } from "./auth/verification-service";
4848
import { Authorizer, createInitializingAuthorizer } from "./authorization/authorizer";
49-
import { SpiceDBClient, spicedbClientFromEnv } from "./authorization/spicedb";
49+
import { CachingSpiceDBClientProvider, SpiceDBClientProvider, spiceDBConfigFromEnv } from "./authorization/spicedb";
5050
import { BillingModes } from "./billing/billing-mode";
5151
import { EntitlementService, EntitlementServiceImpl } from "./billing/entitlement-service";
5252
import { EntitlementServiceUBP } from "./billing/entitlement-service-ubp";
@@ -302,8 +302,15 @@ export const productionContainerModule = new ContainerModule(
302302
bind(IamSessionApp).toSelf().inSingletonScope();
303303

304304
// Authorization & Perms
305-
bind(SpiceDBClient)
306-
.toDynamicValue(() => spicedbClientFromEnv())
305+
bind(SpiceDBClientProvider)
306+
.toDynamicValue((ctx) => {
307+
const config = spiceDBConfigFromEnv();
308+
if (!config) {
309+
throw new Error("[spicedb] Missing configuration expected in env vars!");
310+
}
311+
const clientCallMetrics = ctx.container.get<IClientCallMetrics>(IClientCallMetrics);
312+
return new CachingSpiceDBClientProvider(config, clientCallMetrics);
313+
})
307314
.inSingletonScope();
308315
bind(SpiceDBAuthorizer).toSelf().inSingletonScope();
309316
bind(Authorizer)

components/server/src/test/service-testing-container-module.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
*/
66

77
import * as grpc from "@grpc/grpc-js";
8-
import { v1 } from "@authzed/authzed-node";
98
import { IAnalyticsWriter, NullAnalyticsWriter } from "@gitpod/gitpod-protocol/lib/analytics";
109
import { IDEServiceClient, IDEServiceDefinition } from "@gitpod/ide-service-api/lib/ide.pb";
1110
import { UsageServiceDefinition } from "@gitpod/usage-api/lib/usage/v1/usage.pb";
@@ -14,7 +13,7 @@ import { v4 } from "uuid";
1413
import { AuthProviderParams } from "../auth/auth-provider";
1514
import { HostContextProvider, HostContextProviderFactory } from "../auth/host-context-provider";
1615
import { HostContextProviderImpl } from "../auth/host-context-provider-impl";
17-
import { SpiceDBClient } from "../authorization/spicedb";
16+
import { CachingSpiceDBClientProvider, SpiceDBClientProvider } from "../authorization/spicedb";
1817
import { Config } from "../config";
1918
import { StorageClient } from "../storage/storage-client";
2019
import { testContainer } from "@gitpod/gitpod-db/lib";
@@ -189,10 +188,13 @@ const mockApplyingContainerModule = new ContainerModule((bind, unbound, isbound,
189188
}))
190189
.inSingletonScope();
191190

192-
rebind(SpiceDBClient)
191+
rebind(SpiceDBClientProvider)
193192
.toDynamicValue(() => {
194-
const token = v4();
195-
return v1.NewClient(token, "localhost:50051", v1.ClientSecurity.INSECURE_PLAINTEXT_CREDENTIALS).promises;
193+
const config = {
194+
token: v4(),
195+
address: "localhost:50051",
196+
};
197+
return new CachingSpiceDBClientProvider(config);
196198
})
197199
.inSingletonScope();
198200
});

install/installer/pkg/components/spicedb/deployment.go

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -155,10 +155,12 @@ func deployment(ctx *common.RenderContext) ([]runtime.Object, error) {
155155
},
156156
},
157157
InitialDelaySeconds: 5,
158-
PeriodSeconds: 30,
159-
FailureThreshold: 5,
160-
SuccessThreshold: 1,
161-
TimeoutSeconds: 3,
158+
// try again every 10 seconds
159+
PeriodSeconds: 10,
160+
// fail after 6 * 10 + 5 = 65 seconds
161+
FailureThreshold: 6,
162+
SuccessThreshold: 1,
163+
TimeoutSeconds: 3,
162164
},
163165
VolumeMounts: []v1.VolumeMount{
164166
bootstrapVolumeMount,

yarn.lock

Lines changed: 6 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -29,12 +29,12 @@
2929
jsonpointer "^5.0.0"
3030
leven "^3.1.0"
3131

32-
"@authzed/authzed-node@^0.10.0":
33-
version "0.10.0"
34-
resolved "https://registry.yarnpkg.com/@authzed/authzed-node/-/authzed-node-0.10.0.tgz#623e4911fde221bb526e7f2e9ca335d9f3b9072d"
35-
integrity sha512-TnAnatcU5dHvyGqrWoZzPNaO1opPpVU1y7P5LrJsV2j54y0xvx/OFhYtfeguMxHSz2kpbdCuIvIKJuB8WFbRRA==
32+
"@authzed/authzed-node@^0.12.1":
33+
version "0.12.1"
34+
resolved "https://registry.yarnpkg.com/@authzed/authzed-node/-/authzed-node-0.12.1.tgz#0c28395a64f9b1ecf33faf67259e32a9a3bce300"
35+
integrity sha512-BVHLaPfiHQw1Vz+199m9i4xltT3YyFhqVHtkYPIQ28q8a7iJpnXmFRZIWuTMJcxJI01wtAxJYFuRJq3ktFe6qw==
3636
dependencies:
37-
"@grpc/grpc-js" "^1.2.8"
37+
"@grpc/grpc-js" "^1.8.3"
3838
"@protobuf-ts/runtime" "^2.8.1"
3939
"@protobuf-ts/runtime-rpc" "^2.8.1"
4040
google-protobuf "^3.15.3"
@@ -2222,22 +2222,14 @@
22222222
qs "^6.10.1"
22232223
xcase "^2.0.1"
22242224

2225-
2225+
"@grpc/[email protected]", "@grpc/grpc-js@^1.8.3":
22262226
version "1.8.8"
22272227
resolved "https://registry.yarnpkg.com/@grpc/grpc-js/-/grpc-js-1.8.8.tgz#a7c6765d0302f47ba67c0ce3cb79718d6b028248"
22282228
integrity sha512-4gfDqMLXTrorvYTKA1jL22zLvVwiHJ73t6Re1OHwdCFRjdGTDOVtSJuaWhtHaivyeDGg0LeCkmU77MTKoV3wPA==
22292229
dependencies:
22302230
"@grpc/proto-loader" "^0.7.0"
22312231
"@types/node" ">=12.12.47"
22322232

2233-
"@grpc/grpc-js@^1.2.8":
2234-
version "1.8.7"
2235-
resolved "https://registry.yarnpkg.com/@grpc/grpc-js/-/grpc-js-1.8.7.tgz#2154fc0134462ad45f4134e8b54682a25ed05956"
2236-
integrity sha512-dRAWjRFN1Zy9mzPNLkFFIWT8T6C9euwluzCHZUKuhC+Bk3MayNPcpgDRyG+sg+n2sitEUySKxUynirVpu9ItKw==
2237-
dependencies:
2238-
"@grpc/proto-loader" "^0.7.0"
2239-
"@types/node" ">=12.12.47"
2240-
22412233
"@grpc/grpc-js@^1.6.1":
22422234
version "1.7.0"
22432235
resolved "https://registry.yarnpkg.com/@grpc/grpc-js/-/grpc-js-1.7.0.tgz#5a96bdbe51cce23faa38a4db6e43595a5c584849"

0 commit comments

Comments
 (0)