Skip to content

Commit 0dec53b

Browse files
committed
Use steve with embedded dashboard
This converts Rancher Desktop to use steve with embedded dashboard, instead of manually running a second server to handle the static assets. Since we already need to run a standalone forked steve, this makes it easier without significantly making anything more difficult. Note that the copy of steve we are using is already significantly outdated (as is the copy of dashboard), so we will need to update those at some point. Signed-off-by: Mark Yen <mark.yen@suse.com>
1 parent 08ebde8 commit 0dec53b

13 files changed

Lines changed: 75 additions & 473 deletions

File tree

.gitignore

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@
1313
/resources/linux/*
1414
!/resources/linux/rancher-desktop.desktop
1515
/resources/preload.js*
16-
/resources/rancher-dashboard/
1716
/resources/rdx-proxy.tar
1817
/resources/spin-operator*
1918
/resources/win32/

background.ts

Lines changed: 2 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -27,15 +27,14 @@ import { BackendState, CommandWorkerInterface, HttpCommandServer } from '@pkg/ma
2727
import SettingsValidator from '@pkg/main/commandServer/settingsValidator';
2828
import { ContainerExecHandler } from '@pkg/main/containerExec';
2929
import { HttpCredentialHelperServer } from '@pkg/main/credentialServer/httpCredentialHelperServer';
30-
import { DashboardServer } from '@pkg/main/dashboardServer';
3130
import { DeploymentProfileError, readDeploymentProfiles } from '@pkg/main/deploymentProfiles';
3231
import { DiagnosticsManager, DiagnosticsResultCollection } from '@pkg/main/diagnostics/diagnostics';
3332
import { ExtensionErrorCode, isExtensionError } from '@pkg/main/extensions';
3433
import { ImageEventHandler } from '@pkg/main/imageEvents';
3534
import { getIpcMainProxy } from '@pkg/main/ipcMain';
3635
import mainEvents from '@pkg/main/mainEvents';
3736
import buildApplicationMenu from '@pkg/main/mainmenu';
38-
import setupNetworking, { setSteveCertPort } from '@pkg/main/networking';
37+
import setupNetworking from '@pkg/main/networking';
3938
import { Snapshots } from '@pkg/main/snapshots/snapshots';
4039
import { Snapshot, SnapshotDialog } from '@pkg/main/snapshots/types';
4140
import { Tray } from '@pkg/main/tray';
@@ -229,8 +228,6 @@ Electron.app.whenReady().then(async() => {
229228

230229
await setupNetworking();
231230

232-
DashboardServer.getInstance().init();
233-
234231
try {
235232
deploymentProfiles = await readDeploymentProfiles();
236233
} catch (ex: any) {
@@ -1272,14 +1269,7 @@ function newK8sManager() {
12721269

12731270
if (enabledK8s) {
12741271
try {
1275-
const [stevePort] = await getAvailablePorts(1);
1276-
1277-
console.log(`Steve HTTPS port: ${ stevePort }`);
1278-
// Set the Steve HTTPS port for certificate checking before setting
1279-
// up Steve itself.
1280-
setSteveCertPort(stevePort);
1281-
await Steve.getInstance().start(stevePort);
1282-
DashboardServer.getInstance().setStevePort(stevePort); // recreate proxy middleware
1272+
await Steve.getInstance().start();
12831273
} catch (ex) {
12841274
console.error('Failed to start Steve:', ex);
12851275
}

e2e/extensions.e2e.spec.ts

Lines changed: 19 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
* An E2E test is required to have access to the web page context.
44
*/
55

6+
import http from 'http';
67
import os from 'os';
78
import path from 'path';
89

@@ -279,22 +280,6 @@ test.describe.serial('Extensions', () => {
279280
exitCodes: [0],
280281
}));
281282
});
282-
283-
test('bypass CORS', async() => {
284-
// This is the dashboard URL; it does not have CORS set up so it would
285-
// normally fail to fetch due to CORS reasons. However, this test case
286-
// checks that our CORS bypass is working.
287-
const url = 'http://127.0.0.1:6120/c/local/explorer/node';
288-
const script = `
289-
(async () => {
290-
const result = await fetch('${ url }');
291-
return Object.fromEntries(result.headers.entries());
292-
})()
293-
`;
294-
const result = await evalInView(script);
295-
296-
expect(result).toHaveProperty('content-type');
297-
});
298283
});
299284

300285
test.describe('ddClient.docker', () => {
@@ -423,13 +408,26 @@ test.describe.serial('Extensions', () => {
423408
});
424409
});
425410
test('can fetch from external sources', async() => {
426-
const url = 'http://127.0.0.1:6120/c/local/explorer/node'; // dashboard
411+
const contents = 'Hello from E2E tests';
412+
const server = http.createServer((req, res) => {
413+
// This server does not include CORS headers; it would fail without
414+
// CORS bypass.
415+
res.writeHead(200, { 'Content-Type': 'text/plain' });
416+
res.end(contents);
417+
});
418+
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve));
419+
try {
420+
const port = (server.address() as any).port;
421+
const url = `http://127.0.0.1:${ port }/`;
427422

428-
await retry(async() => {
429-
const result = evalInView(`ddClient.extension.vm.service.get("${ url }")`);
423+
await retry(async() => {
424+
const result = evalInView(`ddClient.extension.vm.service.get("${ url }")`);
430425

431-
await expect(result).resolves.toContain('<title>Rancher</title>');
432-
});
426+
await expect(result).resolves.toContain(contents);
427+
});
428+
} finally {
429+
server.close();
430+
}
433431
});
434432
test.describe('can post values', () => {
435433
test('with string body', async() => {

pkg/rancher-desktop/backend/steve.ts

Lines changed: 47 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { setTimeout } from 'timers/promises';
66
import Electron from 'electron';
77

88
import K3sHelper from '@pkg/backend/k3sHelper';
9+
import Latch from '@pkg/utils/latch';
910
import Logging from '@pkg/utils/logging';
1011
import paths from '@pkg/utils/paths';
1112

@@ -16,10 +17,10 @@ const console = Logging.steve;
1617
*/
1718
export class Steve {
1819
private static instance: Steve;
19-
private process!: ChildProcess;
20+
private process: ChildProcess | undefined;
2021

2122
private isRunning: boolean;
22-
private httpsPort = 0;
23+
#port = 0;
2324

2425
private constructor() {
2526
this.isRunning = false;
@@ -40,19 +41,17 @@ export class Steve {
4041
/**
4142
* @description Starts the Steve API if one is not already running.
4243
* Returns only after Steve is ready to accept connections.
43-
* @param httpsPort The HTTPS port for Steve to listen on.
44+
* @returns The port Steve is listening on.
4445
*/
45-
public async start(httpsPort: number) {
46+
public async start(): Promise<number> {
4647
const { pid } = this.process || { };
4748

4849
if (this.isRunning && pid) {
4950
console.debug(`Steve is already running with pid: ${ pid }`);
5051

51-
return;
52+
return this.#port;
5253
}
5354

54-
this.httpsPort = httpsPort;
55-
5655
const osSpecificName = /^win/i.test(os.platform()) ? 'steve.exe' : 'steve';
5756
const stevePath = path.join(paths.resources, os.platform(), 'internal', osSpecificName);
5857
const env = Object.assign({}, process.env);
@@ -62,33 +61,37 @@ export class Steve {
6261
} catch {
6362
// do nothing
6463
}
64+
console.debug(`Starting Steve with KUBECONFIG=${ env.KUBECONFIG }`);
6565
this.process = spawn(
6666
stevePath,
6767
[
6868
'--context',
6969
'rancher-desktop',
70-
'--ui-path',
71-
path.join(paths.resources, 'rancher-dashboard'),
7270
'--offline',
7371
'true',
74-
'--https-listen-port',
75-
String(httpsPort),
76-
'--http-listen-port',
77-
'0', // Disable HTTP support; it does not work correctly anyway.
7872
],
79-
{ env },
73+
{ env, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true },
8074
);
8175

8276
const { stdout, stderr } = this.process;
77+
let portBuffer = '';
78+
const portLatch = Latch<number>();
8379

8480
if (!stdout || !stderr) {
8581
console.error('Unable to get child process...');
8682

87-
return;
83+
throw new Error(`Failed to start Steve: could not get output`);
8884
}
8985

86+
console.debug('Waiting for Steve to output port...');
9087
stdout.on('data', (data: any) => {
91-
console.log(`stdout: ${ data }`);
88+
portBuffer += data.toString();
89+
});
90+
stdout.on('end', () => {
91+
const port = parseInt(portBuffer, 10);
92+
if (port) {
93+
portLatch.resolve(port);
94+
}
9295
});
9396

9497
stderr.on('data', (data: any) => {
@@ -101,23 +104,32 @@ export class Steve {
101104
});
102105

103106
await new Promise<void>((resolve, reject) => {
104-
this.process.once('spawn', () => {
107+
this.process?.once('spawn', () => {
105108
this.isRunning = true;
106-
console.debug(`Spawned child pid: ${ this.process.pid }`);
109+
console.debug(`Spawned child pid: ${ this.process?.pid }`);
107110
resolve();
108111
});
109-
this.process.once('error', (err) => {
112+
this.process?.once('error', (err) => {
110113
reject(new Error(`Failed to spawn Steve: ${ err.message }`, { cause: err }));
111114
});
115+
setTimeout(10_000).then(() => reject(new Error('Timed out waiting for Steve to start')));
112116
});
117+
this.#port = await portLatch;
118+
console.debug(`Steve is listening on port: ${ this.#port }`);
113119

114-
await this.waitForReady();
120+
await this.waitForReady(this.#port);
121+
122+
return this.#port;
123+
}
124+
125+
public get port() {
126+
return this.#port;
115127
}
116128

117129
/**
118130
* Wait for Steve to be ready to serve API requests.
119131
*/
120-
private async waitForReady(): Promise<void> {
132+
private async waitForReady(port: number): Promise<void> {
121133
const maxAttempts = 60;
122134
const delayMs = 500;
123135

@@ -126,7 +138,7 @@ export class Steve {
126138
throw new Error('Steve process exited before becoming ready');
127139
}
128140

129-
if (await this.isPortReady()) {
141+
if (await this.isPortReady(port)) {
130142
console.debug(`Steve is ready after ${ attempt } / ${ maxAttempts } attempt(s)`);
131143

132144
return;
@@ -146,55 +158,17 @@ export class Steve {
146158
* we probe a core resource endpoint that returns 404 until the
147159
* schema controller has registered it.
148160
*/
149-
private isPortReady(): Promise<boolean> {
150-
// Steve's HTTP port just redirects to HTTPS, so we might as well go to the
151-
// HTTPS port directly. We will need to ignore certificate errors; however,
152-
// neither the NodeJS stack nor Electron.net.request() would pass through
153-
// the `Electron.app.on('certificate-error', ...)` handler, so we cannot use
154-
// the normal certificate handling for this health check. Instead, we
155-
// create a temporary session with a certificate verify proc that ignores
156-
// errors, and use that session for the health check request.
157-
return new Promise((resolve) => {
158-
const session = Electron.session.fromPartition('steve-healthcheck', { cache: false });
159-
160-
session.setCertificateVerifyProc((request, callback) => {
161-
if (request.hostname === '127.0.0.1') {
162-
// We do not have any more information to narrow down the certificate;
163-
// given that we're doing this in a private partition, it should be
164-
// safe to allow all localhost certificates. In particular, we do not
165-
// get access to the port number, and all the Steve certificates have
166-
// generic fields (e.g. subject).
167-
callback(0);
168-
} else {
169-
// Unexpected request; not sure how this could happen in a new session,
170-
// but we can at least pretend to do the right thing.
171-
callback(-3); // Use Chromium's default verification.
172-
}
173-
});
174-
175-
const req = Electron.net.request({
176-
protocol: 'https:',
177-
hostname: '127.0.0.1',
178-
port: this.httpsPort,
179-
path: '/v1/namespaces',
180-
method: 'GET',
181-
redirect: 'error',
182-
session,
183-
});
184-
185-
req.on('response', (res) => resolve(res.statusCode === 200));
186-
req.on('error', () => resolve(false));
187-
// Timeout if we don't get a response in a reasonable time.
188-
setTimeout(1_000).then(() => {
189-
try {
190-
req.abort();
191-
} catch {
192-
// ignore
193-
}
194-
resolve(false);
195-
});
196-
req.end();
197-
});
161+
private async isPortReady(port: number): Promise<boolean> {
162+
try {
163+
// Set up a short time out, so we don't wait too long.
164+
const signal = AbortSignal.timeout(1_000);
165+
const resp = await Electron.net.fetch(
166+
`http://127.0.0.1:${ port }/v1/namespaces`,
167+
{ redirect: 'error', signal });
168+
return resp.ok;
169+
} catch {
170+
return false;
171+
}
198172
}
199173

200174
/**
@@ -205,6 +179,7 @@ export class Steve {
205179
return;
206180
}
207181

208-
this.process.kill('SIGINT');
182+
this.process?.kill('SIGINT');
183+
this.#port = 0;
209184
}
210185
}

0 commit comments

Comments
 (0)