Skip to content

Commit ecf598a

Browse files
csweichelroboquat
authored andcommitted
[ws-manager-api] Introduce start cluster preference sets
1 parent ac618fa commit ecf598a

File tree

4 files changed

+252
-92
lines changed

4 files changed

+252
-92
lines changed

components/server/src/workspace/workspace-starter.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -176,12 +176,12 @@ export class WorkspaceStarter {
176176

177177
// tell the world we're starting this instance
178178
let resp: StartWorkspaceResponse.AsObject | undefined;
179-
let exceptInstallation: string[] = [];
180179
let lastInstallation = "";
181-
for (let i = 0; i < 5; i++) {
180+
const clusters = await this.clientProvider.getStartClusterSets(user, workspace, instance);
181+
for await (let cluster of clusters) {
182182
try {
183183
// getStartManager will throw an exception if there's no cluster available and hence exit the loop
184-
const { manager, installation } = await this.clientProvider.getStartManager(user, workspace, instance, exceptInstallation);
184+
const { manager, installation } = cluster;
185185
lastInstallation = installation;
186186

187187
instance.status.phase = "pending";
@@ -203,7 +203,6 @@ export class WorkspaceStarter {
203203
} catch (err: any) {
204204
if ('code' in err && err.code !== grpc.status.OK && lastInstallation !== "") {
205205
log.error({ instanceId: instance.id }, "cannot start workspace on cluster, might retry", err, { cluster: lastInstallation });
206-
exceptInstallation.push(lastInstallation);
207206
} else {
208207
throw err;
209208
}

components/ws-manager-api/typescript/src/client-provider.spec.ts

Lines changed: 60 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,12 @@ import "reflect-metadata";
99
import { Container } from 'inversify';
1010
import { suite, test } from "@testdeck/mocha";
1111
import * as chai from 'chai';
12-
import { WorkspaceManagerClientProvider } from "./client-provider";
12+
import { IWorkspaceClusterStartSet, WorkspaceManagerClientProvider } from "./client-provider";
1313
import { WorkspaceManagerClientProviderCompositeSource, WorkspaceManagerClientProviderSource } from "./client-provider-source";
1414
import { WorkspaceCluster, WorkspaceClusterWoTLS } from "@gitpod/gitpod-protocol/lib/workspace-cluster";
1515
import { User, Workspace, WorkspaceInstance } from "@gitpod/gitpod-protocol";
16+
import { PromisifiedWorkspaceManagerClient } from ".";
17+
import { Constraint, constraintNewWorkspaceCluster, ExtendedUser, intersect, invert } from "./constraints";
1618
const expect = chai.expect;
1719

1820
@suite
@@ -32,12 +34,7 @@ class TestClientProvider {
3234
{ name: "a2", govern: true, maxScore: 100, score: 50, state: "available", url: "", admissionConstraints: [] },
3335
{ name: "a3", govern: false, maxScore: 100, score: 50, state: "available", url: "", admissionConstraints: [] },
3436
{
35-
name: "con1", govern: true, maxScore: 100, score: 0, state: "available", url: "", admissionConstraints: [
36-
{ type: "has-feature-preview" },
37-
]
38-
},
39-
{
40-
name: "con2", govern: true, maxScore: 100, score: 50, state: "available", url: "", admissionConstraints: [
37+
name: "con1", govern: true, maxScore: 100, score: 50, state: "available", url: "", admissionConstraints: [
4138
{ type: "has-permission", permission: "new-workspace-cluster" },
4239
]
4340
},
@@ -50,28 +47,72 @@ class TestClientProvider {
5047
};
5148
}).inSingletonScope();
5249
this.provider = c.get(WorkspaceManagerClientProvider);
50+
51+
// we don't actually want to try and connect here
52+
this.provider.get = async (name: string, grpcOptions?: object): Promise<PromisifiedWorkspaceManagerClient> => { return {} as PromisifiedWorkspaceManagerClient };
5353
}
5454

5555
@test
56-
public async testGetStarterWorkspaceCluster() {
57-
this.expectInstallations(["a1", "a2", "a3"], await this.provider.getAvailableStartCluster({} as User, {} as Workspace, {} as WorkspaceInstance));
58-
this.expectInstallations(["a1", "a2", "a3", "con1"], await this.provider.getAvailableStartCluster({
59-
additionalData: {featurePreview: true}
60-
} as User, {} as Workspace, {} as WorkspaceInstance));
61-
this.expectInstallations(["a1", "a2", "a3", "con2"], await this.provider.getAvailableStartCluster({
56+
public async getStartClusterSets() {
57+
await this.expectInstallations(["a1", "a2", "a3"], await this.provider.getStartClusterSets({} as User, {} as Workspace, {} as WorkspaceInstance), "default case");
58+
this.expectInstallations(["a1", "a2", "a3", "con1"], await this.provider.getStartClusterSets({
6259
rolesOrPermissions: ["new-workspace-cluster"]
63-
} as User, {} as Workspace, {} as WorkspaceInstance));
60+
} as User, {} as Workspace, {} as WorkspaceInstance), "new workspace cluster");
61+
}
62+
63+
@test
64+
public async testInvert() {
65+
expect(materializeConstraint(invert(everything))).to.be.empty;
66+
expect(materializeConstraint(invert(nothing))).to.be.eql(materializeConstraint(everything));
67+
}
68+
69+
@test
70+
public testIntersect() {
71+
expect(materializeConstraint(intersect(everything, nothing)), "eveything U nothing == nothing").to.be.empty;
72+
expect(materializeConstraint(intersect(everything, everything, nothing)), "eveything U everything U nothing == nothing").to.be.empty;
73+
expect(materializeConstraint(intersect(everything, everything)), "eveything U everything == everything").to.be.eql(materializeConstraint(everything));
74+
75+
const something = (all: WorkspaceClusterWoTLS[], user: ExtendedUser, workspace: Workspace, instance: WorkspaceInstance) => all.filter(c => c.name === "a1");
76+
expect(materializeConstraint(intersect(something, nothing)), "something U nothing == nothing").to.be.empty;
77+
expect(materializeConstraint(intersect(everything, something)), "eveything U something == something").to.be.eql(materializeConstraint(something));
78+
expect(materializeConstraint(intersect(everything, something, nothing)), "everything U something U nothing == nothing").to.be.empty;
79+
80+
expect(materializeConstraint(intersect(everything, invert(nothing))), "everything U invert(nothing) == everything").to.be.eql(materializeConstraint(everything));
6481
}
6582

66-
public async getStartManager() {
67-
const { installation } = await this.provider.getStartManager({} as User, {} as Workspace, {} as WorkspaceInstance, ["a2", "a3"]);
68-
expect(installation).to.be.eql("a1");
83+
@test
84+
public testConstraintNewWorkspaceCluster() {
85+
const clusters: WorkspaceClusterWoTLS[] = [
86+
{name: "a1", admissionConstraints: [{ type: "has-permission", permission: "new-workspace-cluster" }]} as WorkspaceClusterWoTLS,
87+
{name: "b1" } as WorkspaceClusterWoTLS,
88+
]
89+
expect(constraintNewWorkspaceCluster(clusters, {} as ExtendedUser, {} as Workspace, {} as WorkspaceInstance).map(c => c.name)).to.be.empty;
90+
expect(constraintNewWorkspaceCluster(clusters, {rolesOrPermissions:["new-workspace-cluster"]} as ExtendedUser, {} as Workspace, {} as WorkspaceInstance).map(c => c.name)).to.be.eql(["a1"]);
6991
}
7092

71-
private expectInstallations(expected: string[], actual: WorkspaceClusterWoTLS[]) {
72-
expect(actual.map(e => e.name).sort()).to.be.eql(expected);
93+
private async expectInstallations(expected: string[], actual: IWorkspaceClusterStartSet, msg: string) {
94+
const a: string[] = [];
95+
for await (const c of actual) {
96+
a.push(c.installation);
97+
}
98+
99+
expect(a.sort(), msg).to.be.eql(expected);
73100
}
74101

75102
}
76103

104+
const everything = (all: WorkspaceClusterWoTLS[], user: ExtendedUser, workspace: Workspace, instance: WorkspaceInstance) => all;
105+
const nothing = (all: WorkspaceClusterWoTLS[], user: ExtendedUser, workspace: Workspace, instance: WorkspaceInstance) => [];
106+
107+
function materializeConstraint(c: Constraint): string[] {
108+
const cluster: WorkspaceClusterWoTLS[] = [
109+
{name: "a1"} as WorkspaceClusterWoTLS,
110+
{name: "a2"} as WorkspaceClusterWoTLS,
111+
{name: "a3"} as WorkspaceClusterWoTLS,
112+
{name: "a4"} as WorkspaceClusterWoTLS,
113+
];
114+
115+
return c(cluster, {} as ExtendedUser, {} as Workspace, {} as WorkspaceInstance).map(cluster => cluster.name);
116+
}
117+
77118
module.exports = new TestClientProvider()

components/ws-manager-api/typescript/src/client-provider.ts

Lines changed: 71 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -5,26 +5,19 @@
55
*/
66

77
import { createClientCallMetricsInterceptor, IClientCallMetrics } from "@gitpod/content-service/lib/client-call-metrics";
8-
import { Disposable, User, Workspace, WorkspaceInstance } from "@gitpod/gitpod-protocol";
8+
import { Disposable, Workspace, WorkspaceInstance } from "@gitpod/gitpod-protocol";
99
import { defaultGRPCOptions } from '@gitpod/gitpod-protocol/lib/util/grpc';
1010
import { log } from '@gitpod/gitpod-protocol/lib/util/logging';
1111
import { WorkspaceClusterWoTLS, WorkspaceManagerConnectionInfo } from '@gitpod/gitpod-protocol/lib/workspace-cluster';
1212
import * as grpc from "@grpc/grpc-js";
1313
import { inject, injectable, optional } from 'inversify';
1414
import { WorkspaceManagerClientProviderCompositeSource, WorkspaceManagerClientProviderSource } from "./client-provider-source";
15+
import { ExtendedUser, workspaceClusterSets } from "./constraints";
1516
import { WorkspaceManagerClient } from './core_grpc_pb';
1617
import { linearBackoffStrategy, PromisifiedWorkspaceManagerClient } from "./promisified-client";
1718

1819
export const IWorkspaceManagerClientCallMetrics = Symbol('IWorkspaceManagerClientCallMetrics')
1920

20-
/**
21-
* ExtendedUser adds additional attributes to a user which are helpful
22-
* during cluster selection.
23-
*/
24-
export interface ExtendedUser extends User {
25-
level?: string;
26-
}
27-
2821
@injectable()
2922
export class WorkspaceManagerClientProvider implements Disposable {
3023
@inject(WorkspaceManagerClientProviderCompositeSource)
@@ -39,41 +32,48 @@ export class WorkspaceManagerClientProvider implements Disposable {
3932
protected readonly connectionCache = new Map<string, WorkspaceManagerClient>();
4033

4134
/**
42-
* Throws an error if there is not WorkspaceManagerClient available.
35+
* getStartClusterSets produces a set of workspace clusters we can try to start a workspace in.
36+
* If starting a workspace fails in one cluster, the caller is expected to "pop" another cluster
37+
* of the list until the workspace start has succeeded or pop returns undefined.
4338
*
44-
* @returns The WorkspaceManagerClient that was chosen to start the next workspace with.
39+
* @param user user who wants to starts a workspace manager
40+
* @param workspace the workspace we want to start
41+
* @param instance the instance we want to start
42+
* @returns a set of workspace clusters we can start the workspace in
4543
*/
46-
public async getStartManager(user: ExtendedUser, workspace: Workspace, instance: WorkspaceInstance, exceptInstallations?: string[]): Promise<{ manager: PromisifiedWorkspaceManagerClient, installation: string }> {
47-
let availableCluster = await this.getAvailableStartCluster(user, workspace, instance);
48-
if (!!exceptInstallations) {
49-
availableCluster = availableCluster.filter(c => !exceptInstallations?.includes(c.name));
50-
}
51-
52-
let chosenCluster = chooseCluster(availableCluster.filter(admissionPreferencesFilter(user)))
53-
if (!chosenCluster) {
54-
// We did not choose a prefered cluster. That's ok, we'll try with the remaining set.
55-
chosenCluster = chooseCluster(availableCluster);
56-
}
57-
if (!chosenCluster) {
58-
// We still haven't choosen a cluster. That's a problem.
59-
throw new Error("no available workspace cluster to choose from!");
60-
}
44+
public async getStartClusterSets(user: ExtendedUser, workspace: Workspace, instance: WorkspaceInstance): Promise<IWorkspaceClusterStartSet> {
45+
const allClusters = await this.source.getAllWorkspaceClusters();
46+
const availableClusters = allClusters.filter(c => c.score >= 0 && c.state === "available");
6147

48+
const sets = workspaceClusterSets.map(constraints => {
49+
const r = constraints.constraint(availableClusters, user, workspace, instance);
50+
if (!r) {
51+
return;
52+
}
53+
return new ClusterSet(this, r);
54+
}).filter(s => s !== undefined) as ClusterSet[];
6255

63-
const grpcOptions: grpc.ClientOptions = {
64-
...defaultGRPCOptions,
65-
};
66-
const client = await this.get(chosenCluster.name, grpcOptions);
6756
return {
68-
manager: client,
69-
installation: chosenCluster.name,
70-
};
71-
}
72-
73-
public async getAvailableStartCluster(user: User, workspace: Workspace, instance: WorkspaceInstance): Promise<WorkspaceClusterWoTLS[]> {
74-
const allClusters = await this.source.getAllWorkspaceClusters();
75-
const availableClusters = allClusters.filter(c => c.score >= 0 && c.state === "available").filter(admissionConstraintsFilter(user, workspace, instance));
76-
return availableClusters;
57+
[Symbol.asyncIterator]: (): AsyncIterator<ClusterClientEntry> => {
58+
return {
59+
next: async (): Promise<IteratorResult<ClusterClientEntry>> => {
60+
while (true) {
61+
if (sets.length === 0) {
62+
return {done: true, value: undefined};
63+
}
64+
65+
let res = await sets[sets.length - 1].next();
66+
if (!!res.done) {
67+
sets.pop();
68+
continue;
69+
}
70+
71+
return res;
72+
}
73+
}
74+
}
75+
}
76+
}
7777
}
7878

7979
/**
@@ -143,7 +143,39 @@ export class WorkspaceManagerClientProvider implements Disposable {
143143
}
144144
}
145145

146+
export interface IWorkspaceClusterStartSet extends AsyncIterable<ClusterClientEntry> {}
146147

148+
export interface ClusterClientEntry { manager: PromisifiedWorkspaceManagerClient, installation: string }
149+
150+
/**
151+
* ClusterSet is an iterator
152+
*/
153+
class ClusterSet implements AsyncIterator<ClusterClientEntry> {
154+
protected usedCluster: string[] = [];
155+
constructor(protected readonly provider: WorkspaceManagerClientProvider, protected readonly cluster: WorkspaceClusterWoTLS[]) {}
156+
157+
public async next(): Promise<IteratorResult<ClusterClientEntry>> {
158+
const available = this.cluster.filter(c => !this.usedCluster.includes(c.name));
159+
const chosenCluster = chooseCluster(available);
160+
if (!chosenCluster) {
161+
// empty set
162+
return {done: true, value: undefined };
163+
}
164+
this.usedCluster.push(chosenCluster.name);
165+
166+
const grpcOptions: grpc.ClientOptions = {
167+
...defaultGRPCOptions,
168+
};
169+
const client = await this.provider.get(chosenCluster.name, grpcOptions);
170+
return {
171+
done: false,
172+
value: {
173+
manager: client,
174+
installation: chosenCluster.name,
175+
}
176+
};
177+
}
178+
}
147179

148180
/**
149181
*
@@ -175,33 +207,3 @@ function chooseCluster(availableCluster: WorkspaceClusterWoTLS[]): WorkspaceClus
175207
}
176208
return availableCluster[availableCluster.length - 1];
177209
}
178-
179-
function admissionConstraintsFilter(user: User, workspace: Workspace, instance: WorkspaceInstance): (c: WorkspaceClusterWoTLS) => boolean {
180-
return (c: WorkspaceClusterWoTLS) => {
181-
if (!c.admissionConstraints) {
182-
return true;
183-
}
184-
185-
return (c.admissionConstraints || []).every(con => {
186-
switch (con.type) {
187-
case "has-feature-preview":
188-
return !!user.additionalData && !!user.additionalData.featurePreview;
189-
case "has-permission":
190-
return user.rolesOrPermissions?.includes(con.permission);
191-
}
192-
});
193-
};
194-
}
195-
196-
function admissionPreferencesFilter(user: ExtendedUser): (c: WorkspaceClusterWoTLS) => boolean {
197-
return (c: WorkspaceClusterWoTLS) => {
198-
return (c.admissionPreferences || []).every(pref => {
199-
switch (pref.type) {
200-
case "user-level":
201-
return pref.level === user.level;
202-
default:
203-
return false;
204-
}
205-
})
206-
};
207-
}

0 commit comments

Comments
 (0)