Skip to content

Commit ac11cf7

Browse files
authored
Merge pull request rancher-sandbox#9745 from mook-as/moby-container-snapshotter
Allow users to select moby storage driver
2 parents 287f50e + 16ccfb0 commit ac11cf7

File tree

16 files changed

+391
-86
lines changed

16 files changed

+391
-86
lines changed

e2e/backend.e2e.spec.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ test.describe.serial('KubernetesBackend', () => {
2121

2222
test.beforeAll(async({ colorScheme }, testInfo) => {
2323
[electronApp, page] = await startSlowerDesktop(testInfo, {
24+
kubernetes: {
25+
enabled: false,
26+
},
2427
virtualMachine: {
2528
mount: {
2629
type: MountType.REVERSE_SSHFS,

pkg/rancher-desktop/assets/specs/command-api.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -617,6 +617,10 @@ components:
617617
x-rd-usage: allowed image names
618618
items:
619619
type: string
620+
mobyStorageDriver:
621+
type: string
622+
enum: [classic, snapshotter, auto]
623+
x-rd-usage: override Moby storage driver selection
620624
virtualMachine:
621625
type: object
622626
properties:
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
/** @jest-environment node */
2+
3+
import _ from 'lodash';
4+
5+
import type { VMExecutor } from '@pkg/backend/backend';
6+
import type BackendHelperType from '@pkg/backend/backendHelper';
7+
import mockModules from '@pkg/utils/testUtils/mockModules';
8+
9+
const modules = mockModules({
10+
electron: undefined,
11+
});
12+
13+
describe('BackendHelper', () => {
14+
let BackendHelper: typeof BackendHelperType;
15+
beforeAll(async() => {
16+
BackendHelper = (await import('@pkg/backend/backendHelper')).default;
17+
});
18+
19+
describe('configureMobyStorage', () => {
20+
const snapshotterDir = '/var/lib/docker/containerd/daemon/io.containerd.snapshotter.v1.overlayfs/snapshots/';
21+
const classicDir = '/var/lib/docker/image/overlay2/imagedb/content/sha256/'; // no-spell-check
22+
const DOCKER_DAEMON_JSON = '/etc/docker/daemon.json';
23+
24+
interface Options {
25+
hasSnapshotter: boolean;
26+
hasClassic: boolean;
27+
useWASM: boolean;
28+
storageDriver: 'classic' | 'snapshotter' | 'auto';
29+
}
30+
31+
class mockExecutor implements Partial<VMExecutor> {
32+
readonly options: Omit<Options, 'useWASM' | 'storageDriver'> & { existingConfig?: string; };
33+
readonly backend = 'mock';
34+
result: any;
35+
36+
constructor(options: typeof this.options) {
37+
this.options = options;
38+
}
39+
40+
execCommand(...command: string[]): Promise<void>;
41+
execCommand(options: unknown, ...command: string[]): Promise<void>;
42+
execCommand(options: unknown, ...command: string[]): Promise<string>;
43+
execCommand(options?: unknown, ...command: string[]): Promise<void> | Promise<string> {
44+
if (typeof options === 'string') {
45+
command.unshift(options);
46+
}
47+
switch (command[0]) {
48+
case '/usr/bin/find':
49+
expect(options).toHaveProperty('capture', true);
50+
if (command.includes(snapshotterDir)) {
51+
return Promise.resolve(this.options.hasSnapshotter ? 'some text\n' : '\n');
52+
}
53+
if (command.includes(classicDir)) {
54+
return Promise.resolve(this.options.hasClassic ? 'not empty\n' : '\n');
55+
}
56+
break;
57+
case 'mkdir':
58+
return Promise.resolve();
59+
}
60+
throw new Error(`Unexpected command: ${ JSON.stringify(command) }`);
61+
}
62+
63+
readFile(filePath: string): Promise<string> {
64+
if (filePath === DOCKER_DAEMON_JSON) {
65+
if (this.options.existingConfig) {
66+
return Promise.resolve(this.options.existingConfig);
67+
}
68+
return Promise.reject<string>(new Error('file does not exist'));
69+
}
70+
throw new Error(`Unexpected readFile: ${ filePath }`);
71+
}
72+
73+
writeFile(filePath: string, fileContents: string): Promise<void> {
74+
expect(filePath).toEqual(DOCKER_DAEMON_JSON);
75+
const config = JSON.parse(fileContents);
76+
77+
this.result = config;
78+
expect(config).toHaveProperty('features.containerd-snapshotter');
79+
return Promise.resolve();
80+
}
81+
}
82+
83+
async function runTest(options: Options): Promise<boolean> {
84+
const vmx = new mockExecutor(options);
85+
86+
await BackendHelper.configureMobyStorage(
87+
vmx as unknown as VMExecutor,
88+
options.storageDriver,
89+
options.useWASM);
90+
91+
expect(vmx.result).toHaveProperty('features.containerd-snapshotter');
92+
93+
return vmx.result.features['containerd-snapshotter'] ?? false;
94+
}
95+
96+
function generateCases(alwaysUseWASM: boolean) {
97+
const cases: Omit<Options, 'storageDriver'>[] = [];
98+
const bools = [true, false];
99+
100+
for (const hasSnapshotter of bools) {
101+
for (const hasClassic of bools) {
102+
if (!alwaysUseWASM) {
103+
for (const useWASM of bools) {
104+
cases.push({ hasSnapshotter, hasClassic, useWASM });
105+
}
106+
} else {
107+
cases.push({ hasSnapshotter, hasClassic, useWASM: true });
108+
}
109+
}
110+
}
111+
112+
return cases;
113+
}
114+
115+
it.concurrent.each(generateCases(false))(
116+
'should use classic storage driver when specified (snapshotter:$hasSnapshotter classic:$hasClassic wasm:$useWASM)', async(options) => {
117+
await expect(runTest({ ...options, storageDriver: 'classic' })).resolves.toBeFalsy();
118+
});
119+
120+
it.concurrent.each(generateCases(false))(
121+
'should use snapshotter storage driver when specified (snapshotter:$hasSnapshotter classic:$hasClassic wasm:$useWASM)', async(options) => {
122+
await expect(runTest({ ...options, storageDriver: 'snapshotter' })).resolves.toBeTruthy();
123+
});
124+
125+
it.concurrent.each(generateCases(true))(
126+
'should choose storage driver based on WASM configuration when set to auto (snapshotter:$hasSnapshotter classic:$hasClassic)', async(options) => {
127+
await expect(runTest({ ...options, useWASM: true, storageDriver: 'auto' })).resolves.toBeTruthy();
128+
});
129+
130+
it.concurrent.each`
131+
hasSnapshotter | hasClassic | expected
132+
${ true } | ${ true } | ${ true }
133+
${ true } | ${ false } | ${ true }
134+
${ false } | ${ true } | ${ false }
135+
${ false } | ${ false } | ${ true }
136+
`('should choose storage driver based on existing usage when set to auto and WASM disabled (snapshotter:$hasSnapshotter classic:$hasClassic)', async(options) => {
137+
await expect(runTest({ ...options, useWASM: false, storageDriver: 'auto' })).resolves.toBe(options.expected);
138+
});
139+
140+
it('should preserve existing docker daemon settings', async() => {
141+
const existingConfig = {
142+
hello: 'world',
143+
};
144+
145+
const vmx = new mockExecutor({
146+
hasSnapshotter: false,
147+
hasClassic: true,
148+
existingConfig: JSON.stringify(existingConfig),
149+
});
150+
151+
await BackendHelper.configureMobyStorage(
152+
vmx as unknown as VMExecutor,
153+
'auto',
154+
false);
155+
156+
expect(vmx.result).toHaveProperty('features.containerd-snapshotter');
157+
expect(vmx.result).toHaveProperty('hello', 'world');
158+
});
159+
});
160+
});

pkg/rancher-desktop/backend/backendHelper.ts

Lines changed: 47 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -373,20 +373,61 @@ export default class BackendHelper {
373373
* Configure the Moby containerd-snapshotter feature if WASM support is
374374
* requested, or if we have not previously run the daemon.
375375
*/
376-
static async writeMobyConfig(vmx: VMExecutor, configureWASM: boolean) {
376+
static async configureMobyStorage(vmx: VMExecutor, storageDriver: 'classic' | 'snapshotter' | 'auto', configureWASM: boolean) {
377+
// Due to issues with a botched migration, we will need to provide more logic
378+
// to determine whether to use the containerd-snapshotter backend for moby
379+
// storage. See https://github.com/rancher-sandbox/rancher-desktop/issues/9732
380+
// for more details.
381+
382+
// If this directory is not empty, we assume that there is data in the containerd snapshotter.
383+
const snapshotterDir = '/var/lib/docker/containerd/daemon/io.containerd.snapshotter.v1.overlayfs/snapshots/';
384+
// If this directory is not empty, we assume that there is data in the classic storage.
385+
const classicDir = '/var/lib/docker/image/overlay2/imagedb/content/sha256/'; // no-spell-check
386+
387+
let useSnapshotter: boolean | undefined;
388+
389+
// Check if a directory (in the VM) has any subdirectories or files.
390+
async function dirHasChildren(dir: string): Promise<boolean> {
391+
try {
392+
const stdout = await vmx.execCommand(
393+
{ root: true, expectFailure: true, capture: true },
394+
'/usr/bin/find', dir, '-maxdepth', '0', '-not', '-empty');
395+
396+
return stdout.trim().length > 0;
397+
} catch {
398+
// Directory does not exist.
399+
return false;
400+
}
401+
}
402+
403+
// If `storageDriver` is explicitly set, use that setting.
404+
if (storageDriver !== 'auto') {
405+
useSnapshotter = (storageDriver === 'snapshotter');
406+
} else if (configureWASM) {
407+
// WASM requires the containerd snapshotter.
408+
useSnapshotter = true;
409+
} else {
410+
// If there is data in the containerd snapshotter store, use it.
411+
if (await dirHasChildren(snapshotterDir)) {
412+
useSnapshotter = true;
413+
}
414+
}
415+
if (useSnapshotter === undefined) {
416+
// If there is no data in the classic storage, use containerd snapshotter.
417+
useSnapshotter = !(await dirHasChildren(classicDir));
418+
}
419+
377420
let config: Record<string, any>;
378-
let found = false;
379421

380422
try {
381423
config = JSON.parse(await vmx.readFile(DOCKER_DAEMON_JSON));
382-
found = true;
383424
} catch (err: any) {
384425
await vmx.execCommand({ root: true }, 'mkdir', '-p', path.dirname(DOCKER_DAEMON_JSON));
385426
config = {};
386427
}
387428
config['min-api-version'] = '1.41';
388429
config['features'] ??= {};
389-
config['features']['containerd-snapshotter'] ||= configureWASM || !found;
430+
config['features']['containerd-snapshotter'] = useSnapshotter;
390431

391432
if (config['features']['containerd-snapshotter']) {
392433
// If we are using the containerd snapshotter, create /var/lib/docker/image
@@ -396,9 +437,9 @@ export default class BackendHelper {
396437
await vmx.writeFile(DOCKER_DAEMON_JSON, jsonStringifyWithWhiteSpace(config), 0o644);
397438
}
398439

399-
static async configureContainerEngine(vmx: VMExecutor, configureWASM: boolean) {
440+
static async configureContainerEngine(vmx: VMExecutor, configureWASM: boolean, mobyStorageDriver: 'classic' | 'snapshotter' | 'auto') {
400441
await BackendHelper.installContainerdShims(vmx, configureWASM);
401442
await BackendHelper.writeContainerdConfig(vmx, configureWASM);
402-
await BackendHelper.writeMobyConfig(vmx, configureWASM);
443+
await BackendHelper.configureMobyStorage(vmx, mobyStorageDriver, configureWASM);
403444
}
404445
}

pkg/rancher-desktop/backend/k3sHelper.ts

Lines changed: 52 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { Architecture, VMExecutor } from './backend';
2121
import * as K8s from '@pkg/backend/k8s';
2222
import { KubeClient } from '@pkg/backend/kube/client';
2323
import { loadFromString, exportConfig } from '@pkg/backend/kubeconfig';
24+
import { ContainerEngine } from '@pkg/config/settings';
2425
import mainEvents from '@pkg/main/mainEvents';
2526
import { isUnixError } from '@pkg/typings/unix.interface';
2627
import DownloadProgressListener from '@pkg/utils/DownloadProgressListener';
@@ -32,7 +33,7 @@ import paths from '@pkg/utils/paths';
3233
import { executable } from '@pkg/utils/resources';
3334
import safeRename from '@pkg/utils/safeRename';
3435
import { jsonStringifyWithWhiteSpace } from '@pkg/utils/stringify';
35-
import { defined, RecursivePartial, RecursiveTypes } from '@pkg/utils/typeUtils';
36+
import { defined, RecursivePartial, RecursiveReadonly, RecursiveTypes } from '@pkg/utils/typeUtils';
3637
import { showMessageBox } from '@pkg/window';
3738

3839
import type Electron from 'electron';
@@ -77,9 +78,17 @@ interface cacheData {
7778
* RequiresRestartSeverityChecker is a function that will be used to determine
7879
* whether a given settings change will require a reset (i.e. deleting user
7980
* workloads).
81+
* @param currentValue The current value of the setting.
82+
* @param desiredValue The desired value of the setting.
83+
* @param allSettings The full merged settings object.
84+
* @returns 'restart' if a restart is required, 'reset' if a reset is required,
85+
* or false if no restart is required.
8086
*/
81-
type RequiresRestartSeverityChecker<K extends keyof RecursiveTypes<K8s.BackendSettings>> =
82-
(currentValue: RecursiveTypes<K8s.BackendSettings>[K], desiredValue: RecursiveTypes<K8s.BackendSettings>[K]) => 'restart' | 'reset';
87+
type RequiresRestartSeverityChecker<K extends keyof RecursiveTypes<K8s.BackendSettings>> = (
88+
currentValue: RecursiveTypes<K8s.BackendSettings>[K],
89+
desiredValue: RecursiveTypes<K8s.BackendSettings>[K],
90+
allSettings: RecursiveReadonly<K8s.BackendSettings>,
91+
) => 'restart' | 'reset' | false;
8392

8493
/**
8594
* RequiresRestartCheckers defines a mapping of settings (in dot-separated form)
@@ -1199,6 +1208,36 @@ export default class K3sHelper extends events.EventEmitter {
11991208
): K8s.RestartReasons {
12001209
const results: K8s.RestartReasons = {};
12011210
const NotFound = Symbol('not-found');
1211+
const mergedSettings = _.merge({}, currentSettings, desiredSettings);
1212+
1213+
function restartIfKubernetesEnabled() {
1214+
return mergedSettings.kubernetes.enabled ? 'restart' : false;
1215+
}
1216+
1217+
/**
1218+
* defaultRestartReasonCheckers contains the restart reason checkers shared
1219+
* between backends.
1220+
*/
1221+
const defaultRestartReasonCheckers: RequiresRestartCheckers = {
1222+
'containerEngine.mobyStorageDriver': (current, desired, allSettings) => {
1223+
// We only need to restart if running moby.
1224+
return allSettings.containerEngine.name === ContainerEngine.MOBY ? 'restart' : false;
1225+
},
1226+
'kubernetes.version': (current, desired, allSettings) => {
1227+
if (!allSettings.kubernetes.enabled) {
1228+
return false;
1229+
}
1230+
return semver.gt(current || '0.0.0', desired) ? 'reset' : 'restart';
1231+
},
1232+
'containerEngine.allowedImages.enabled': undefined,
1233+
'containerEngine.name': undefined,
1234+
'experimental.containerEngine.webAssembly.enabled': undefined,
1235+
'experimental.kubernetes.options.spinkube': undefined,
1236+
'kubernetes.enabled': undefined,
1237+
'kubernetes.options.flannel': restartIfKubernetesEnabled,
1238+
'kubernetes.options.traefik': restartIfKubernetesEnabled,
1239+
'kubernetes.port': restartIfKubernetesEnabled,
1240+
};
12021241

12031242
/**
12041243
* Check the given settings against the last-applied settings to see if we
@@ -1217,13 +1256,19 @@ export default class K3sHelper extends events.EventEmitter {
12171256
return;
12181257
}
12191258
if (!_.isEqual(current, desired)) {
1220-
results[key] = {
1221-
current, desired, severity: checker ? checker(current, desired) : 'restart',
1222-
};
1259+
const severity = checker ? checker(current, desired, mergedSettings) : 'restart';
1260+
1261+
if (severity) {
1262+
results[key] = { current, desired, severity };
1263+
}
12231264
}
12241265
}
12251266

1226-
for (const [key, checker] of Object.entries(checkers)) {
1267+
for (const [key, checker] of Object.entries({ ...defaultRestartReasonCheckers, ...checkers })) {
1268+
if (checker === null) {
1269+
// The custom checker wants to delete a default checker.
1270+
continue;
1271+
}
12271272
// We need the casts here because TypeScript can't match up the key with
12281273
// its corresponding checker.
12291274
cmp(key as any, checker as any);

pkg/rancher-desktop/backend/kube/lima.ts

Lines changed: 1 addition & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -429,22 +429,7 @@ export default class LimaKubernetesBackend extends events.EventEmitter implement
429429
currentConfig,
430430
desiredConfig,
431431
{
432-
'kubernetes.version': (current: string, desired: string) => {
433-
if (semver.gt(current || '0.0.0', desired)) {
434-
return 'reset';
435-
}
436-
437-
return 'restart';
438-
},
439-
'application.adminAccess': undefined,
440-
'containerEngine.allowedImages.enabled': undefined,
441-
'containerEngine.name': undefined,
442-
'experimental.containerEngine.webAssembly.enabled': undefined,
443-
'experimental.kubernetes.options.spinkube': undefined,
444-
'kubernetes.port': undefined,
445-
'kubernetes.enabled': undefined,
446-
'kubernetes.options.traefik': undefined,
447-
'kubernetes.options.flannel': undefined,
432+
'application.adminAccess': undefined,
448433
},
449434
extra,
450435
);

0 commit comments

Comments
 (0)