diff --git a/build/wix/main.wxs b/build/wix/main.wxs
index 05c790894dd..2b3ceb9574d 100644
--- a/build/wix/main.wxs
+++ b/build/wix/main.wxs
@@ -132,12 +132,18 @@
Type="string"
KeyPath="yes"
/>
-
-
+
+
@@ -151,12 +157,18 @@
Type="string"
KeyPath="yes"
/>
-
-
+
+
diff --git a/pkg/rancher-desktop/backend/containerClient/mobyClient.ts b/pkg/rancher-desktop/backend/containerClient/mobyClient.ts
index 37b054036c8..713bd0e6248 100644
--- a/pkg/rancher-desktop/backend/containerClient/mobyClient.ts
+++ b/pkg/rancher-desktop/backend/containerClient/mobyClient.ts
@@ -470,8 +470,11 @@ export class MobyClient implements ContainerEngineClient {
runClient(args: string[], stdio: 'pipe', options?: runClientOptions): Promise<{ stdout: string; stderr: string; }>;
runClient(args: string[], stdio: 'stream', options?: runClientOptions): ReadableProcess;
runClient(args: string[], stdio?: 'ignore' | 'pipe' | 'stream' | Log, options?: runClientOptions) {
- const binDir = path.join(paths.resources, process.platform, 'bin');
- const executable = path.resolve(binDir, options?.executable ?? this.executable);
+ const executableName = options?.executable ?? this.executable;
+ const isCLIPlugin = /^docker-(?!credential-)/.test(executableName);
+ const binType = isCLIPlugin ? 'docker-cli-plugins' : 'bin';
+ const binDir = path.join(paths.resources, process.platform, binType);
+ const executable = path.resolve(binDir, executableName);
const opts = _.merge({}, options ?? {}, {
env: {
DOCKER_HOST: this.endpoint,
diff --git a/pkg/rancher-desktop/integrations/__tests__/unixIntegrationManager.spec.ts b/pkg/rancher-desktop/integrations/__tests__/unixIntegrationManager.spec.ts
index 8a8a695a37f..18e97de3789 100644
--- a/pkg/rancher-desktop/integrations/__tests__/unixIntegrationManager.spec.ts
+++ b/pkg/rancher-desktop/integrations/__tests__/unixIntegrationManager.spec.ts
@@ -8,29 +8,26 @@ const INTEGRATION_DIR_NAME = 'integrationDir';
const TMPDIR_PREFIX = 'rdtest-';
const describeUnix = os.platform() === 'win32' ? describe.skip : describe;
-const resourcesDir = path.join('resources', os.platform(), 'bin');
+const binDir = path.join('resources', os.platform(), 'bin');
+const dockerCLIPluginSource = path.join('resources', os.platform(), 'docker-cli-plugins');
let testDir: string;
// Creates integration directory and docker CLI plugin directory with
// relevant symlinks in them. Useful for testing removal parts
// of UnixIntegrationManager.
-async function createTestSymlinks(resourcesDirectory: string, integrationDirectory: string, dockerCliPluginDirectory: string): Promise {
+async function createTestSymlinks(integrationDirectory: string, dockerCLIPluginDest: string): Promise {
await fs.promises.mkdir(integrationDirectory, { recursive: true, mode: 0o755 });
- await fs.promises.mkdir(dockerCliPluginDirectory, { recursive: true, mode: 0o755 });
+ await fs.promises.mkdir(dockerCLIPluginDest, { recursive: true, mode: 0o755 });
- const kubectlSrcPath = path.join(resourcesDirectory, 'kubectl');
+ const kubectlSrcPath = path.join(binDir, 'kubectl');
const kubectlDstPath = path.join(integrationDirectory, 'kubectl');
await fs.promises.symlink(kubectlSrcPath, kubectlDstPath);
- const composeSrcPath = path.join(resourcesDirectory, 'docker-compose');
- const composeDstPath = path.join(integrationDirectory, 'docker-compose');
+ const composeSrcPath = path.join(dockerCLIPluginSource, 'docker-compose');
+ const composeDstPath = path.join(dockerCLIPluginDest, 'docker-compose');
await fs.promises.symlink(composeSrcPath, composeDstPath);
-
- const composeCliDstPath = path.join(dockerCliPluginDirectory, 'docker-compose');
-
- await fs.promises.symlink(composeDstPath, composeCliDstPath);
}
beforeEach(async() => {
@@ -45,39 +42,42 @@ afterEach(async() => {
describeUnix('UnixIntegrationManager', () => {
let integrationDir: string;
- let dockerCliPluginDir: string;
+ let dockerCLIPluginDest: string;
let integrationManager: UnixIntegrationManager;
beforeEach(() => {
integrationDir = path.join(testDir, INTEGRATION_DIR_NAME);
- dockerCliPluginDir = path.join(testDir, 'dockerCliPluginDir');
- integrationManager = new UnixIntegrationManager(
- resourcesDir, integrationDir, dockerCliPluginDir);
+ dockerCLIPluginDest = path.join(testDir, 'dockerCliPluginDir');
+ integrationManager = new UnixIntegrationManager({
+ binDir, integrationDir, dockerCLIPluginSource, dockerCLIPluginDest,
+ });
});
describe('enforce', () => {
test('should create dirs and symlinks properly', async() => {
await integrationManager.enforce();
- for (const name of await fs.promises.readdir(resourcesDir)) {
+ for (const name of await fs.promises.readdir(binDir)) {
const integrationPath = path.join(integrationDir, name);
- const expectedValue = path.join(resourcesDir, name);
+ const expectedValue = path.join(binDir, name);
await expect(fs.promises.readlink(integrationPath, 'utf8')).resolves.toEqual(expectedValue);
}
- for (const name of await integrationManager.getDockerCliPluginNames()) {
- const pluginPath = path.join(dockerCliPluginDir, name);
- const expectedValue = path.join(integrationDir, name);
+ for (const name of await fs.promises.readdir(dockerCLIPluginSource)) {
+ const binPath = path.join(integrationDir, name);
+ const pluginPath = path.join(dockerCLIPluginDest, name);
+ const expectedValue = path.join(dockerCLIPluginSource, name);
await expect(fs.promises.readlink(pluginPath, 'utf8')).resolves.toEqual(expectedValue);
+ await expect(fs.promises.readlink(binPath, 'utf8')).resolves.toEqual(expectedValue);
}
});
test('should not overwrite an existing docker CLI plugin that is a regular file', async() => {
// create existing plugin
- const existingPluginPath = path.join(dockerCliPluginDir, 'docker-compose');
+ const existingPluginPath = path.join(dockerCLIPluginDest, 'docker-compose');
const existingPluginContents = 'meaningless contents';
- await fs.promises.mkdir(dockerCliPluginDir, { mode: 0o755 });
+ await fs.promises.mkdir(dockerCLIPluginDest, { mode: 0o755 });
await fs.promises.writeFile(existingPluginPath, existingPluginContents);
await integrationManager.enforce();
@@ -88,11 +88,11 @@ describeUnix('UnixIntegrationManager', () => {
});
test('should update an existing docker CLI plugin that is a dangling symlink', async() => {
- const existingPluginPath = path.join(dockerCliPluginDir, 'docker-compose');
+ const existingPluginPath = path.join(dockerCLIPluginDest, 'docker-compose');
const nonExistentPath = '/somepaththatshouldnevereverexist';
- const expectedTarget = path.join(integrationDir, 'docker-compose');
+ const expectedTarget = path.join(dockerCLIPluginSource, 'docker-compose');
- await fs.promises.mkdir(dockerCliPluginDir, { mode: 0o755 });
+ await fs.promises.mkdir(dockerCLIPluginDest, { mode: 0o755 });
await fs.promises.symlink(nonExistentPath, existingPluginPath);
await integrationManager.enforce();
@@ -102,13 +102,13 @@ describeUnix('UnixIntegrationManager', () => {
expect(newTarget).toEqual(expectedTarget);
});
- test('should update an existing docker CLI plugin whose target is resources directory', async() => {
- const existingPluginPath = path.join(dockerCliPluginDir, 'docker-compose');
- const resourcesPath = path.join(resourcesDir, 'docker-compose');
- const expectedTarget = path.join(integrationDir, 'docker-compose');
+ test('should update an existing docker CLI plugin whose target is integrations directory', async() => {
+ const existingPluginPath = path.join(dockerCLIPluginDest, 'docker-compose');
+ const integrationsPath = path.join(integrationDir, 'docker-compose');
+ const expectedTarget = path.join(dockerCLIPluginSource, 'docker-compose');
- await fs.promises.mkdir(dockerCliPluginDir, { mode: 0o755 });
- await fs.promises.symlink(resourcesPath, existingPluginPath);
+ await fs.promises.mkdir(dockerCLIPluginDest, { mode: 0o755 });
+ await fs.promises.symlink(integrationsPath, existingPluginPath);
await integrationManager.enforce();
@@ -120,11 +120,11 @@ describeUnix('UnixIntegrationManager', () => {
test('should be idempotent', async() => {
await integrationManager.enforce();
const intDirAfterFirstCall = await fs.promises.readdir(integrationDir);
- const dockerCliDirAfterFirstCall = await fs.promises.readdir(dockerCliPluginDir);
+ const dockerCliDirAfterFirstCall = await fs.promises.readdir(dockerCLIPluginDest);
await integrationManager.enforce();
const intDirAfterSecondCall = await fs.promises.readdir(integrationDir);
- const dockerCliDirAfterSecondCall = await fs.promises.readdir(dockerCliPluginDir);
+ const dockerCliDirAfterSecondCall = await fs.promises.readdir(dockerCLIPluginDest);
expect(intDirAfterFirstCall).toEqual(intDirAfterSecondCall);
expect(dockerCliDirAfterFirstCall).toEqual(dockerCliDirAfterSecondCall);
@@ -132,7 +132,7 @@ describeUnix('UnixIntegrationManager', () => {
test('should convert a regular file in integration directory to correct symlink', async() => {
const integrationPath = path.join(integrationDir, 'kubectl');
- const expectedTarget = path.join(resourcesDir, 'kubectl');
+ const expectedTarget = path.join(binDir, 'kubectl');
await fs.promises.mkdir(integrationDir);
await fs.promises.writeFile(integrationPath, 'contents', 'utf-8');
@@ -143,7 +143,7 @@ describeUnix('UnixIntegrationManager', () => {
test('should fix an incorrect symlink in integration directory', async() => {
const integrationPath = path.join(integrationDir, 'kubectl');
const originalTargetPath = path.join(testDir, 'kubectl');
- const expectedTarget = path.join(resourcesDir, 'kubectl');
+ const expectedTarget = path.join(binDir, 'kubectl');
await fs.promises.mkdir(integrationDir);
await fs.promises.writeFile(originalTargetPath, 'contents', 'utf-8');
@@ -155,7 +155,7 @@ describeUnix('UnixIntegrationManager', () => {
test('should fix a dangling symlink in integration directory', async() => {
const integrationPath = path.join(integrationDir, 'kubectl');
const originalTargetPath = path.join(testDir, 'kubectl');
- const expectedTarget = path.join(resourcesDir, 'kubectl');
+ const expectedTarget = path.join(binDir, 'kubectl');
await fs.promises.mkdir(integrationDir);
await fs.promises.symlink(originalTargetPath, integrationPath);
@@ -173,10 +173,10 @@ describeUnix('UnixIntegrationManager', () => {
});
test('should not modify a docker plugin that does not have a counterpart in resources directory', async() => {
- const dockerCliPluginPath = path.join(dockerCliPluginDir, 'nameThatShouldNeverBeInResourcesDir');
+ const dockerCliPluginPath = path.join(dockerCLIPluginDest, 'nameThatShouldNeverBeInResourcesDir');
const content = 'content';
- await fs.promises.mkdir(dockerCliPluginDir);
+ await fs.promises.mkdir(dockerCLIPluginDest);
await fs.promises.writeFile(dockerCliPluginPath, content, 'utf-8');
await integrationManager.enforce();
await expect(fs.promises.readFile(dockerCliPluginPath, 'utf-8')).resolves.toEqual(content);
@@ -185,19 +185,19 @@ describeUnix('UnixIntegrationManager', () => {
describe('remove', () => {
test('should remove symlinks and dirs properly', async() => {
- await createTestSymlinks(resourcesDir, integrationDir, dockerCliPluginDir);
+ await createTestSymlinks(integrationDir, dockerCLIPluginDest);
await integrationManager.remove();
await expect(fs.promises.readdir(integrationDir)).rejects.toThrow();
- await expect(fs.promises.readdir(dockerCliPluginDir)).resolves.toEqual([]);
+ await expect(fs.promises.readdir(dockerCLIPluginDest)).resolves.toEqual([]);
});
test('should not remove an existing docker CLI plugin that is a regular file', async() => {
// create existing plugin
- const existingPluginPath = path.join(dockerCliPluginDir, 'docker-compose');
+ const existingPluginPath = path.join(dockerCLIPluginDest, 'docker-compose');
const existingPluginContents = 'meaningless contents';
- await fs.promises.mkdir(dockerCliPluginDir, { mode: 0o755 });
+ await fs.promises.mkdir(dockerCLIPluginDest, { mode: 0o755 });
await fs.promises.writeFile(existingPluginPath, existingPluginContents);
await integrationManager.remove();
@@ -208,11 +208,11 @@ describeUnix('UnixIntegrationManager', () => {
});
test('should not remove an existing docker CLI plugin that is not an expected symlink', async() => {
- const dockerCliPluginPath = path.join(dockerCliPluginDir, 'docker-compose');
+ const dockerCliPluginPath = path.join(dockerCLIPluginDest, 'docker-compose');
const existingTarget = path.join(testDir, 'docker-compose');
const existingPluginContents = 'meaningless contents';
- await fs.promises.mkdir(dockerCliPluginDir, { mode: 0o755 });
+ await fs.promises.mkdir(dockerCLIPluginDest, { mode: 0o755 });
await fs.promises.writeFile(existingTarget, existingPluginContents);
await fs.promises.symlink(existingTarget, dockerCliPluginPath);
@@ -222,10 +222,10 @@ describeUnix('UnixIntegrationManager', () => {
});
test('should remove an existing docker CLI plugin that is a dangling symlink', async() => {
- const dockerCliPluginPath = path.join(dockerCliPluginDir, 'docker-compose');
+ const dockerCliPluginPath = path.join(dockerCLIPluginDest, 'docker-compose');
const existingTarget = path.join(testDir, 'docker-compose');
- await fs.promises.mkdir(dockerCliPluginDir, { mode: 0o755 });
+ await fs.promises.mkdir(dockerCLIPluginDest, { mode: 0o755 });
await fs.promises.symlink(existingTarget, dockerCliPluginPath);
await integrationManager.remove();
@@ -238,7 +238,7 @@ describeUnix('UnixIntegrationManager', () => {
const testDirAfterFirstCall = await fs.promises.readdir(testDir);
expect(testDirAfterFirstCall).not.toContain(INTEGRATION_DIR_NAME);
- const dockerCliDirAfterFirstCall = await fs.promises.readdir(dockerCliPluginDir);
+ const dockerCliDirAfterFirstCall = await fs.promises.readdir(dockerCLIPluginDest);
expect(dockerCliDirAfterFirstCall).toEqual([]);
@@ -246,7 +246,7 @@ describeUnix('UnixIntegrationManager', () => {
const testDirAfterSecondCall = await fs.promises.readdir(testDir);
expect(testDirAfterSecondCall).not.toContain(INTEGRATION_DIR_NAME);
- const dockerCliDirAfterSecondCall = await fs.promises.readdir(dockerCliPluginDir);
+ const dockerCliDirAfterSecondCall = await fs.promises.readdir(dockerCLIPluginDest);
expect(dockerCliDirAfterFirstCall).toEqual(dockerCliDirAfterSecondCall);
});
@@ -254,11 +254,11 @@ describeUnix('UnixIntegrationManager', () => {
describe('removeSymlinksOnly', () => {
test('should remove symlinks but not integration directory', async() => {
- await createTestSymlinks(resourcesDir, integrationDir, dockerCliPluginDir);
+ await createTestSymlinks(integrationDir, dockerCLIPluginDest);
await integrationManager.removeSymlinksOnly();
await expect(fs.promises.readdir(integrationDir)).resolves.toEqual([]);
- await expect(fs.promises.readdir(dockerCliPluginDir)).resolves.toEqual([]);
+ await expect(fs.promises.readdir(dockerCLIPluginDest)).resolves.toEqual([]);
});
});
@@ -267,12 +267,12 @@ describeUnix('UnixIntegrationManager', () => {
const credHelper = 'docker-credential-pass';
beforeEach(async() => {
- await fs.promises.mkdir(dockerCliPluginDir, { recursive: true, mode: 0o755 });
- dstPath = path.join(dockerCliPluginDir, credHelper);
+ await fs.promises.mkdir(dockerCLIPluginDest, { recursive: true, mode: 0o755 });
+ dstPath = path.join(dockerCLIPluginDest, credHelper);
});
test("should return true when the symlink's target matches the integration directory", async() => {
- const resourcesPath = path.join(resourcesDir, credHelper);
+ const resourcesPath = path.join(dockerCLIPluginSource, credHelper);
const srcPath = path.join(integrationDir, credHelper);
// create symlink in integration dir, otherwise it is dangling
@@ -284,7 +284,7 @@ describeUnix('UnixIntegrationManager', () => {
});
test("should return true when the symlink's target matches the resources directory", async() => {
- const srcPath = path.join(resourcesDir, credHelper);
+ const srcPath = path.join(dockerCLIPluginSource, credHelper);
await fs.promises.symlink(srcPath, dstPath);
expect(integrationManager['weOwnDockerCliFile'](dstPath)).resolves.toEqual(true);
@@ -315,7 +315,7 @@ describeUnix('UnixIntegrationManager', () => {
});
describeUnix('ensureSymlink', () => {
- const srcPath = path.join(resourcesDir, 'kubectl');
+ const srcPath = path.join(dockerCLIPluginSource, 'kubectl');
let dstPath: string;
beforeEach(() => {
diff --git a/pkg/rancher-desktop/integrations/integrationManager.ts b/pkg/rancher-desktop/integrations/integrationManager.ts
index c2ed4815a9d..750eee7aef1 100644
--- a/pkg/rancher-desktop/integrations/integrationManager.ts
+++ b/pkg/rancher-desktop/integrations/integrationManager.ts
@@ -42,14 +42,16 @@ export interface IntegrationManager {
export function getIntegrationManager(): IntegrationManager {
const platform = os.platform();
- const resourcesBinDir = path.join(paths.resources, platform, 'bin');
- const dockerCliPluginDir = path.join(os.homedir(), '.docker', 'cli-plugins');
+ const binDir = path.join(paths.resources, platform, 'bin');
+ const dockerCLIPluginSource = path.join(paths.resources, platform, 'docker-cli-plugins');
+ const dockerCLIPluginDest = path.join(os.homedir(), '.docker', 'cli-plugins');
switch (platform) {
case 'linux':
- return new UnixIntegrationManager(resourcesBinDir, paths.integration, dockerCliPluginDir);
case 'darwin':
- return new UnixIntegrationManager(resourcesBinDir, paths.integration, dockerCliPluginDir);
+ return new UnixIntegrationManager({
+ binDir, integrationDir: paths.integration, dockerCLIPluginSource, dockerCLIPluginDest,
+ });
case 'win32':
return WindowsIntegrationManager.getInstance();
default:
diff --git a/pkg/rancher-desktop/integrations/unixIntegrationManager.ts b/pkg/rancher-desktop/integrations/unixIntegrationManager.ts
index 43925abcf16..0dc96b041b5 100644
--- a/pkg/rancher-desktop/integrations/unixIntegrationManager.ts
+++ b/pkg/rancher-desktop/integrations/unixIntegrationManager.ts
@@ -3,6 +3,20 @@ import os from 'os';
import path from 'path';
import { IntegrationManager } from '@pkg/integrations/integrationManager';
+import Logging from '@pkg/utils/logging';
+
+type UnixIntegrationManagerOptions = {
+ /** Directory containing tools shipped with Rancher Desktop. */
+ binDir: string;
+ /** Directory to place tools the user can use. */
+ integrationDir: string;
+ /** Directory containing docker CLI plugins shipped with Rancher Desktop. */
+ dockerCLIPluginSource: string;
+ /** Directory to place docker CLI plugins for with the docker CLI. */
+ dockerCLIPluginDest: string;
+};
+
+const console = Logging.integrations;
/**
* Manages integrations for Unix-like operating systems. Integrations take
@@ -10,20 +24,18 @@ import { IntegrationManager } from '@pkg/integrations/integrationManager';
* directories: the "integrations directory", which should be in the user's path
* somehow, and the "docker CLI plugins directory", which is the directory that
* docker looks in for CLI plugins.
- * @param resourcesDir The directory in which UnixIntegrationManager expects to find
- * all integrations.
- * @param integrationDir The directory that symlinks are placed in.
- * @param dockerCliPluginDir The directory that docker CLI plugin symlinks are placed in.
*/
export default class UnixIntegrationManager implements IntegrationManager {
- protected resourcesDir: string;
+ protected binDir: string;
protected integrationDir: string;
- protected dockerCliPluginDir: string;
-
- constructor(resourcesDir: string, integrationDir: string, dockerCliPluginDir: string) {
- this.resourcesDir = resourcesDir;
- this.integrationDir = integrationDir;
- this.dockerCliPluginDir = dockerCliPluginDir;
+ protected dockerCLIPluginSource: string;
+ protected dockerCLIPluginDest: string;
+
+ constructor(options: UnixIntegrationManagerOptions) {
+ this.binDir = options.binDir;
+ this.integrationDir = options.integrationDir;
+ this.dockerCLIPluginSource = options.dockerCLIPluginSource;
+ this.dockerCLIPluginDest = options.dockerCLIPluginDest;
}
// Idempotently installs directories and symlinks onto the system.
@@ -50,14 +62,6 @@ export default class UnixIntegrationManager implements IntegrationManager {
await this.ensureIntegrationSymlinks(false);
}
- // gets the names of the integrations that we want to symlink into the
- // docker CLI plugin directory. They should all be of the form "docker-*".
- async getDockerCliPluginNames(): Promise {
- return (await fs.promises.readdir(this.resourcesDir)).filter((name) => {
- return name.startsWith('docker-') && !name.startsWith('docker-credential-');
- });
- }
-
protected async ensureIntegrationDir(desiredPresent: boolean): Promise {
if (desiredPresent) {
await fs.promises.mkdir(this.integrationDir, { recursive: true, mode: 0o755 });
@@ -66,13 +70,23 @@ export default class UnixIntegrationManager implements IntegrationManager {
}
}
+ /**
+ * Set up the symbolic links in the integration directory. This will include
+ * both files from `binDir` as well as `dockerCLIPluginSource`; this is needed
+ * in case users try to run `docker-compose` instead of `docker compose`.
+ */
protected async ensureIntegrationSymlinks(desiredPresent: boolean): Promise {
- const validIntegrationNames = await fs.promises.readdir(this.resourcesDir);
+ const RDIntegration = 'rancher-desktop';
+ const sourceDirs = [this.binDir, this.dockerCLIPluginSource];
+ const validIntegrations = Object.fromEntries((await Promise.all(sourceDirs.map(async(d) => {
+ return (await fs.promises.readdir(d)).map(f => [f, d] as const);
+ }))).flat(1));
let currentIntegrationNames: string[] = [];
// integration directory may or may not be present; handle error if not
try {
currentIntegrationNames = await fs.promises.readdir(this.integrationDir);
+ currentIntegrationNames = currentIntegrationNames.filter(v => v !== RDIntegration);
} catch (error: any) {
if (error.code !== 'ENOENT') {
throw error;
@@ -81,14 +95,14 @@ export default class UnixIntegrationManager implements IntegrationManager {
// remove current integrations that are not valid
await Promise.all(currentIntegrationNames.map(async(name) => {
- if (!validIntegrationNames.includes(name)) {
+ if (!(name in validIntegrations)) {
await fs.promises.rm(path.join(this.integrationDir, name), { force: true });
}
}));
// create or remove the integrations
- for (const name of validIntegrationNames) {
- const resourcesPath = path.join(this.resourcesDir, name);
+ for (const [name, dir] of Object.entries(validIntegrations)) {
+ const resourcesPath = path.join(dir, name);
const integrationPath = path.join(this.integrationDir, name);
if (desiredPresent) {
@@ -101,7 +115,7 @@ export default class UnixIntegrationManager implements IntegrationManager {
// manage the special rancher-desktop integration; this symlink
// exists so that rdctl can find the path to the AppImage
// that Rancher Desktop is running from
- const rancherDesktopPath = path.join(this.integrationDir, 'rancher-desktop');
+ const rancherDesktopPath = path.join(this.integrationDir, RDIntegration);
const appImagePath = process.env['APPIMAGE'];
if (desiredPresent && appImagePath) {
@@ -113,24 +127,27 @@ export default class UnixIntegrationManager implements IntegrationManager {
protected async ensureDockerCliSymlinks(desiredPresent: boolean): Promise {
// ensure the docker plugin path exists
- await fs.promises.mkdir(this.dockerCliPluginDir, { recursive: true, mode: 0o755 });
+ await fs.promises.mkdir(this.dockerCLIPluginDest, { recursive: true, mode: 0o755 });
// get a list of docker plugins
- const pluginNames = await this.getDockerCliPluginNames();
+ const pluginNames = await fs.promises.readdir(this.dockerCLIPluginSource);
// create or remove the plugin links
for (const name of pluginNames) {
- const integrationPath = path.join(this.integrationDir, name);
- const dockerCliPluginPath = path.join(this.dockerCliPluginDir, name);
+ const sourcePath = path.join(this.dockerCLIPluginSource, name);
+ const destPath = path.join(this.dockerCLIPluginDest, name);
- if (!await this.weOwnDockerCliFile(dockerCliPluginPath)) {
+ if (!await this.weOwnDockerCliFile(destPath)) {
+ console.debug(`Skipping ${ destPath } - we don't own it`);
continue;
}
+ console.debug(`Will update ${ destPath }`);
+
if (desiredPresent) {
- await ensureSymlink(integrationPath, dockerCliPluginPath);
+ await ensureSymlink(sourcePath, destPath);
} else {
- await fs.promises.rm(dockerCliPluginPath, { force: true });
+ await fs.promises.rm(destPath, { force: true });
}
}
}
@@ -149,9 +166,13 @@ export default class UnixIntegrationManager implements IntegrationManager {
} catch (error: any) {
if (error.code === 'ENOENT') {
// symlink doesn't exist, so create it
+ console.debug(`Symlink ${ filePath } does not exist, will create.`);
+
return true;
} else if (error.code === 'EINVAL') {
// not a symlink
+ console.debug(`${ filePath } is not a symlink, will ignore.`);
+
return false;
}
throw error;
@@ -162,18 +183,26 @@ export default class UnixIntegrationManager implements IntegrationManager {
} catch (error: any) {
if (error.code === 'ENOENT') {
// symlink is dangling
+ console.debug(`Symlink ${ filePath } links to dangling ${ linkedTo }, will replace.`);
+
return true;
}
}
if (path.dirname(linkedTo).endsWith(this.integrationDir)) {
+ console.debug(`Symlink ${ filePath } links to ${ linkedTo } which is in ${ this.integrationDir }, will replace`);
+
return true;
}
- if (path.dirname(linkedTo).endsWith(path.join('resources', os.platform(), 'bin'))) {
+ if (path.dirname(linkedTo).endsWith(path.join('resources', os.platform(), 'docker-cli-plugins'))) {
+ console.debug(`Symlink ${ filePath } links to ${ linkedTo }, will replace`);
+
return true;
}
+ console.debug(`Symlink ${ filePath } links to unknown path ${ linkedTo }, will ignore.`);
+
return false;
}
}
@@ -203,6 +232,7 @@ export async function ensureSymlink(srcPath: string, dstPath: string): Promise this.syncDistro(distro.name, kubeconfigPath)),
]);
} catch (ex) {
@@ -397,76 +395,59 @@ export default class WindowsIntegrationManager implements IntegrationManager {
}
}
- protected async syncHostDockerPlugins() {
- const pluginNames = await this.getHostDockerCliPluginNames();
-
- await Promise.all(pluginNames.map(name => this.syncHostDockerPlugin(name)));
- }
-
- protected async getWslDockerCliPluginNames(): Promise {
- const resourcesBinDir = path.join(paths.resources, 'linux', 'bin');
-
- return (await fs.promises.readdir(resourcesBinDir)).filter((name) => {
- return name.startsWith('docker-') && !name.startsWith('docker-credential-');
- });
- }
-
- protected async getHostDockerCliPluginNames(): Promise {
- const resourcesBinDir = path.join(paths.resources, os.platform(), 'bin');
-
- const pluginNamesWithExe = (await fs.promises.readdir(resourcesBinDir)).filter((name) => {
- return name.startsWith('docker-') && !name.startsWith('docker-credential-');
- });
+ protected async syncHostDockerPluginConfig() {
+ const configPath = path.join(os.homedir(), '.docker', 'config.json');
+ let config: { cliPluginsExtraDirs?: string[] } = {};
- return pluginNamesWithExe.map(pluginName => pluginName.replace(/\.exe$/, ''));
- }
-
- protected async syncHostDockerPlugin(pluginName: string) {
- const homeDir = findHomeDir();
+ try {
+ config = JSON.parse(await fs.promises.readFile(configPath, 'utf-8'));
+ } catch (ex) {
+ if (ex && typeof ex === 'object' && 'code' in ex && ex.code === 'ENOENT') {
+ // If the file does not exist, create it.
+ } else {
+ console.error(`Could not set up docker plugins:`, ex);
- if (!homeDir) {
- throw new Error("Can't find home directory");
+ return;
+ }
}
- const cliDir = path.join(homeDir, '.docker', 'cli-plugins');
- const srcPath = executable(pluginName as any); // It's an executable in `bin`
- const cliPath = path.join(cliDir, path.basename(srcPath));
- console.debug(`Syncing host ${ pluginName }: ${ srcPath } -> ${ cliPath }`);
- await fs.promises.mkdir(cliDir, { recursive: true });
- try {
- await fs.promises.copyFile(
- srcPath, cliPath,
- fs.constants.COPYFILE_EXCL | fs.constants.COPYFILE_FICLONE);
- } catch (error: any) {
- if (error?.code !== 'EEXIST') {
- console.error(`Failed to copy file ${ srcPath } to ${ cliPath }`, error);
- }
+ // All of the docker plugins are in the `docker-cli-plugins` directory.
+ const binDir = path.join(paths.resources, process.platform, 'docker-cli-plugins');
+
+ if (config.cliPluginsExtraDirs?.includes(binDir)) {
+ // If it's already configured, no need to do so again.
+ return;
}
- }
- protected async syncDistroDockerPlugins(distro: string, state: boolean): Promise {
- const names = await this.getWslDockerCliPluginNames();
+ config.cliPluginsExtraDirs ??= [];
+ config.cliPluginsExtraDirs.push(binDir);
- await Promise.all(names.map(name => this.syncDistroDockerPlugin(distro, name, state)));
+ await fs.promises.writeFile(configPath, JSON.stringify(config), 'utf-8');
}
/**
- * syncDistroDockerPlugin ensures that a plugin is accessible in the given distro.
- * @param distro The distribution to manage.
- * @param pluginName The plugin to validate.
- * @param state Whether the plugin should be exposed.
- * @note this function must not throw.
+ * syncDistroDockerPlugins sets up docker CLI configuration in WSL distros to
+ * use the plugins shipped with Rancher Desktop.
+ * @param distro The distribution to update.
+ * @param state Whether the plugins should be enabled.
*/
- protected async syncDistroDockerPlugin(distro: string, pluginName: string, state: boolean) {
+ protected async syncDistroDockerPlugins(distro: string, state: boolean): Promise {
try {
+ const binDir = await this.getLinuxToolPath(distro,
+ path.join(paths.resources, 'linux', 'bin'));
const srcPath = await this.getLinuxToolPath(distro,
- path.join(paths.resources, 'linux', 'bin', pluginName));
+ path.join(paths.resources, 'linux', 'docker-cli-plugins'));
const wslHelper = await this.getLinuxToolPath(distro, executable('wsl-helper-linux'));
+ const args = ['wsl', 'integration', 'docker-plugin',
+ `--plugin-dir=${ srcPath }`, `--bin-dir=${ binDir }`, `--state=${ state }`];
+
+ if (this.settings.application?.debug) {
+ args.push('--verbose');
+ }
- console.debug(`Syncing docker plugin ${ pluginName } for distribution ${ distro }: ${ state }`);
- await this.execCommand({ distro }, wslHelper, 'wsl', 'integration', 'docker-plugin', `--plugin=${ srcPath }`, `--state=${ state }`);
+ await this.execCommand({ distro }, wslHelper, ...args);
} catch (error) {
- console.error(`Failed to sync ${ distro } docker plugin ${ pluginName }: ${ error }`.trim());
+ console.error(`Failed to set up ${ distro } docker plugins: ${ error }`.trim());
}
}
diff --git a/scripts/dependencies/tools.ts b/scripts/dependencies/tools.ts
index b6106995246..0ee6b290342 100644
--- a/scripts/dependencies/tools.ts
+++ b/scripts/dependencies/tools.ts
@@ -212,7 +212,7 @@ export class DockerBuildx implements Dependency, GitHubDependency {
const baseURL = `https://github.com/${ this.githubOwner }/${ this.githubRepo }/releases/download/v${ context.versions.dockerBuildx }`;
const executableName = exeName(context, `buildx-v${ context.versions.dockerBuildx }.${ context.goPlatform }-${ arch }`);
const dockerBuildxURL = `${ baseURL }/${ executableName }`;
- const dockerBuildxPath = path.join(context.binDir, exeName(context, 'docker-buildx'));
+ const dockerBuildxPath = path.join(context.dockerPluginsDir, exeName(context, 'docker-buildx'));
const options: DownloadOptions = {};
// No checksums available on the docker/buildx site for darwin builds
@@ -246,7 +246,7 @@ export class DockerCompose implements Dependency, GitHubDependency {
const arch = context.isM1 ? 'aarch64' : 'x86_64';
const executableName = exeName(context, `docker-compose-${ context.goPlatform }-${ arch }`);
const url = `${ baseUrl }/${ executableName }`;
- const destPath = path.join(context.binDir, exeName(context, 'docker-compose'));
+ const destPath = path.join(context.dockerPluginsDir, exeName(context, 'docker-compose'));
const expectedChecksum = await findChecksum(`${ url }.sha256`, executableName);
await download(url, destPath, { expectedChecksum });
diff --git a/scripts/lib/dependencies.ts b/scripts/lib/dependencies.ts
index 3a12fc2d286..6455d8c57dc 100644
--- a/scripts/lib/dependencies.ts
+++ b/scripts/lib/dependencies.ts
@@ -22,6 +22,8 @@ export type DownloadContext = {
binDir: string;
// internalDir is for binaries that RD will execute behind the scenes
internalDir: string;
+ // dockerPluginsDir is for docker CLI plugins.
+ dockerPluginsDir: string;
};
export type AlpineLimaISOVersion = {
diff --git a/scripts/postinstall.ts b/scripts/postinstall.ts
index 0dcc5b20a43..9f452b6cf47 100644
--- a/scripts/postinstall.ts
+++ b/scripts/postinstall.ts
@@ -167,26 +167,26 @@ async function runScripts(): Promise {
if (platform === 'linux' || platform === 'darwin') {
// download things that go on unix host
- const hostDownloadContext = buildDownloadContextFor(platform, depVersions);
+ const hostDownloadContext = await buildDownloadContextFor(platform, depVersions);
for (const dependency of [...userTouchedDependencies, ...unixDependencies, ...hostDependencies]) {
dependencies.push({ dependency, context: hostDownloadContext });
}
// download things that go inside Lima VM
- const vmDownloadContext = buildDownloadContextFor('linux', depVersions);
+ const vmDownloadContext = await buildDownloadContextFor('linux', depVersions);
dependencies.push(...vmDependencies.map(dependency => ({ dependency, context: vmDownloadContext })));
} else if (platform === 'win32') {
// download things for windows
- const hostDownloadContext = buildDownloadContextFor('win32', depVersions);
+ const hostDownloadContext = await buildDownloadContextFor('win32', depVersions);
for (const dependency of [...userTouchedDependencies, ...windowsDependencies, ...hostDependencies]) {
dependencies.push({ dependency, context: hostDownloadContext });
}
// download things that go inside WSL distro
- const vmDownloadContext = buildDownloadContextFor('wsl', depVersions);
+ const vmDownloadContext = await buildDownloadContextFor('wsl', depVersions);
for (const dependency of [...userTouchedDependencies, ...wslDependencies, ...vmDependencies]) {
dependencies.push({ dependency, context: vmDownloadContext });
@@ -196,7 +196,7 @@ async function runScripts(): Promise {
await downloadDependencies(dependencies);
}
-function buildDownloadContextFor(rawPlatform: DependencyPlatform, depVersions: DependencyVersions): DownloadContext {
+async function buildDownloadContextFor(rawPlatform: DependencyPlatform, depVersions: DependencyVersions): Promise {
const platform = rawPlatform === 'wsl' ? 'linux' : rawPlatform;
const resourcesDir = path.join(process.cwd(), 'resources');
const downloadContext: DownloadContext = {
@@ -208,10 +208,12 @@ function buildDownloadContextFor(rawPlatform: DependencyPlatform, depVersions: D
resourcesDir,
binDir: path.join(resourcesDir, platform, 'bin'),
internalDir: path.join(resourcesDir, platform, 'internal'),
+ dockerPluginsDir: path.join(resourcesDir, platform, 'docker-cli-plugins'),
};
- fs.mkdirSync(downloadContext.binDir, { recursive: true });
- fs.mkdirSync(downloadContext.internalDir, { recursive: true });
+ const dirsToCreate = ['binDir', 'internalDir', 'dockerPluginsDir'] as const;
+
+ await Promise.all(dirsToCreate.map(d => fs.promises.mkdir(downloadContext[d], { recursive: true })));
return downloadContext;
}
diff --git a/src/go/wsl-helper/cmd/wsl_integration_docker_plugin_linux.go b/src/go/wsl-helper/cmd/wsl_integration_docker_plugin_linux.go
index 833714ee7c6..2dbdb0055bb 100644
--- a/src/go/wsl-helper/cmd/wsl_integration_docker_plugin_linux.go
+++ b/src/go/wsl-helper/cmd/wsl_integration_docker_plugin_linux.go
@@ -17,6 +17,9 @@ limitations under the License.
package cmd
import (
+ "fmt"
+ "os"
+
"github.com/rancher-sandbox/rancher-desktop/src/go/wsl-helper/pkg/integration"
"github.com/sirupsen/logrus"
"github.com/spf13/cobra"
@@ -33,9 +36,18 @@ var wslIntegrationDockerPluginCmd = &cobra.Command{
cmd.SilenceUsage = true
state := wslIntegrationDockerPluginViper.GetBool("state")
- pluginPath := wslIntegrationDockerPluginViper.GetString("plugin")
+ pluginDir := wslIntegrationDockerPluginViper.GetString("plugin-dir")
+ binDir := wslIntegrationDockerPluginViper.GetString("bin-dir")
+ homeDir, err := os.UserHomeDir()
+ if err != nil {
+ return fmt.Errorf("failed to locate home directory: %w", err)
+ }
+
+ if err := integration.SetupPluginDirConfig(homeDir, pluginDir, state); err != nil {
+ return err
+ }
- if err := integration.DockerPlugin(pluginPath, state); err != nil {
+ if err := integration.RemoveObsoletePluginSymlinks(homeDir, binDir); err != nil {
return err
}
@@ -44,9 +56,10 @@ var wslIntegrationDockerPluginCmd = &cobra.Command{
}
func init() {
- wslIntegrationDockerPluginCmd.Flags().String("plugin", "", "Full path to plugin")
+ wslIntegrationDockerPluginCmd.Flags().String("plugin-dir", "", "Full path to plugin directory")
+ wslIntegrationDockerPluginCmd.Flags().String("bin-dir", "", "Full path to bin directory to clean up deprecated links")
wslIntegrationDockerPluginCmd.Flags().Bool("state", false, "Desired state")
- if err := wslIntegrationDockerPluginCmd.MarkFlagRequired("plugin"); err != nil {
+ if err := wslIntegrationDockerPluginCmd.MarkFlagRequired("plugin-dir"); err != nil {
logrus.WithError(err).Fatal("Failed to set up flags")
}
wslIntegrationDockerPluginViper.AutomaticEnv()
diff --git a/src/go/wsl-helper/pkg/integration/docker_plugin_linux.go b/src/go/wsl-helper/pkg/integration/docker_plugin_linux.go
index acaf5b2dbc5..fd9d04ad480 100644
--- a/src/go/wsl-helper/pkg/integration/docker_plugin_linux.go
+++ b/src/go/wsl-helper/pkg/integration/docker_plugin_linux.go
@@ -1,5 +1,5 @@
/*
-Copyright © 2023 SUSE LLC
+Copyright © 2024 SUSE LLC
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -17,47 +17,126 @@ limitations under the License.
package integration
import (
+ "encoding/json"
"errors"
"fmt"
"os"
+ "path"
"path/filepath"
+ "slices"
+
+ "github.com/sirupsen/logrus"
)
-// DockerPlugin manages a specific docker plugin (given in pluginPath), either
-// enabling it or disabling it in the WSL distribution the process is running in.
-func DockerPlugin(pluginPath string, enabled bool) error {
- homeDir, err := os.UserHomeDir()
- if err != nil {
- return fmt.Errorf("could not get home directory: %w", err)
- }
- pluginDir := filepath.Join(homeDir, ".docker", "cli-plugins")
- if err = os.MkdirAll(pluginDir, 0o755); err != nil {
- return fmt.Errorf("failed to create docker plugins directory: %w", err)
+const (
+ pluginDirsKey = "cliPluginsExtraDirs"
+)
+
+// SetupPluginDirConfig configures docker CLI to load plugins from the directory
+// given.
+func SetupPluginDirConfig(homeDir, pluginPath string, enabled bool) error {
+ configPath := filepath.Join(homeDir, ".docker", "config.json")
+ config := make(map[string]any)
+
+ configBytes, err := os.ReadFile(configPath)
+ if errors.Is(err, os.ErrNotExist) {
+ // If the config file does not exist, start with empty map.
+ if !enabled {
+ return nil
+ }
+ } else if err != nil {
+ return fmt.Errorf("could not read docker CLI configuration: %w", err)
+ } else {
+ if err = json.Unmarshal(configBytes, &config); err != nil {
+ return fmt.Errorf("could not parse docker CLI configuration: %w", err)
+ }
}
- destPath := filepath.Join(pluginDir, filepath.Base(pluginPath))
-
- if enabled {
- if _, err := os.Readlink(destPath); err == nil {
- if _, err := os.Stat(destPath); errors.Is(err, os.ErrNotExist) {
- // The destination is a dangling symlink
- if err = os.Remove(destPath); err != nil {
- return fmt.Errorf("could not remove dangling symlink %q: %w", destPath, err)
+
+ var dirs []string
+
+ if dirsRaw, ok := config[pluginDirsKey]; ok {
+ if dirsAny, ok := dirsRaw.([]any); ok {
+ for _, item := range dirsAny {
+ if dir, ok := item.(string); ok {
+ dirs = append(dirs, dir)
+ } else {
+ return fmt.Errorf("failed to update docker CLI configuration: %q has non-string item %v", pluginDirsKey, item)
}
}
+ } else {
+ return fmt.Errorf("failed to update docker CLI configuration: %q is not a string array", pluginDirsKey)
}
-
- if err = os.Symlink(pluginPath, destPath); err != nil {
- // ErrExist is fine, that means there's a user-created file there.
- if !errors.Is(err, os.ErrExist) {
- return fmt.Errorf("failed to create symlink %q: %w", destPath, err)
+ index := slices.Index(dirs, pluginPath)
+ if enabled {
+ if index >= 0 {
+ // Config file already contains the plugin path; nothing to do.
+ return nil
}
+ dirs = append([]string{pluginPath}, dirs...)
+ } else {
+ if index < 0 {
+ // Config does not contain the plugin path; nothing to do.
+ return nil
+ }
+ dirs = slices.Delete(dirs, index, index+1)
}
} else {
- link, err := os.Readlink(destPath)
- if err == nil && link == pluginPath {
- if err = os.Remove(destPath); err != nil {
- return fmt.Errorf("failed to remove link %q: %w", destPath, err)
- }
+ if !enabled {
+ // The key does not exist, and we don't want it; nothing to do.
+ return nil
+ }
+ // The key does not exist; add it.
+ dirs = []string{pluginPath}
+ }
+ if len(dirs) > 0 {
+ config[pluginDirsKey] = dirs
+ } else {
+ delete(config, pluginDirsKey)
+ }
+
+ if configBytes, err = json.Marshal(config); err != nil {
+ return fmt.Errorf("failed to serialize updated docker CLI configuration: %w", err)
+ }
+
+ if err = os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil {
+ return fmt.Errorf("failed to update docker CLI configuration: could not create parent: %w", err)
+ }
+
+ if err = os.WriteFile(configPath, configBytes, 0o644); err != nil {
+ return fmt.Errorf("failed to update docker CLI configuration: %w", err)
+ }
+
+ return nil
+}
+
+// RemoveObsoletePluginSymlinks removes symlinks in the docker CLI plugin
+// directory which are children of the given directory.
+func RemoveObsoletePluginSymlinks(homeDir, binPath string) error {
+ pluginDir := path.Join(homeDir, ".docker", "cli-plugins")
+ entries, err := os.ReadDir(pluginDir)
+ if err != nil {
+ if errors.Is(err, os.ErrNotExist) {
+ // If the plugin directory does not exist, there is nothing to do.
+ logrus.Debugf("Docker CLI plugins directory %q does not exist", pluginDir)
+ return nil
+ }
+ return fmt.Errorf("failed to enumerate docker CLI plugins: %w", err)
+ }
+ for _, entry := range entries {
+ if entry.Type()&os.ModeSymlink != os.ModeSymlink {
+ // entry is not a symlink; ignore it.
+ logrus.Debugf("Plugin %q is not a symlink", entry.Name())
+ continue
+ }
+ entryPath := path.Join(pluginDir, entry.Name())
+ target, err := os.Readlink(entryPath)
+ if err != nil {
+ logrus.Debugf("Error reading plugin symlink %q: %v", entryPath, err)
+ } else if filepath.Dir(target) == binPath {
+ // Remove the symlink, ignoring any errors.
+ _ = os.Remove(entryPath)
+ } else {
+ logrus.Debugf("Plugin symlink %q does not start with %q", target, binPath)
}
}
diff --git a/src/go/wsl-helper/pkg/integration/docker_plugin_linux_test.go b/src/go/wsl-helper/pkg/integration/docker_plugin_linux_test.go
index d3711eab3c7..9ffa4af9da7 100644
--- a/src/go/wsl-helper/pkg/integration/docker_plugin_linux_test.go
+++ b/src/go/wsl-helper/pkg/integration/docker_plugin_linux_test.go
@@ -1,5 +1,5 @@
/*
-Copyright © 2023 SUSE LLC
+Copyright © 2024 SUSE LLC
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -17,7 +17,9 @@ limitations under the License.
package integration_test
import (
+ "encoding/json"
"os"
+ "path"
"path/filepath"
"testing"
@@ -27,93 +29,191 @@ import (
"github.com/rancher-sandbox/rancher-desktop/src/go/wsl-helper/pkg/integration"
)
-func TestDockerPlugin(t *testing.T) {
- t.Run("create symlink", func(t *testing.T) {
+func TestSetupPluginDirConfig(t *testing.T) {
+ t.Parallel()
+ t.Run("create config file", func(t *testing.T) {
homeDir := t.TempDir()
- pluginDir := t.TempDir()
- pluginPath := filepath.Join(pluginDir, "docker-something")
- destPath := filepath.Join(homeDir, ".docker", "cli-plugins", "docker-something")
- t.Setenv("HOME", homeDir)
-
- require.NoError(t, integration.DockerPlugin(pluginPath, true))
- link, err := os.Readlink(destPath)
- if assert.NoError(t, err, "error reading created symlink") {
- assert.Equal(t, pluginPath, link)
- }
+ pluginPath := t.TempDir()
+
+ assert.NoError(t, integration.SetupPluginDirConfig(homeDir, pluginPath, true))
+
+ bytes, err := os.ReadFile(path.Join(homeDir, ".docker", "config.json"))
+ require.NoError(t, err, "error reading docker CLI config")
+ var config map[string]any
+ require.NoError(t, json.Unmarshal(bytes, &config))
+
+ value := config["cliPluginsExtraDirs"]
+ require.Contains(t, config, "cliPluginsExtraDirs")
+ require.Contains(t, value, pluginPath, "did not contain plugin path")
+ })
+ t.Run("update config file", func(t *testing.T) {
+ homeDir := t.TempDir()
+ pluginPath := t.TempDir()
+ configPath := path.Join(homeDir, ".docker", "config.json")
+ require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
+ existingContents := []byte(`{"credsStore": "nothing"}`)
+ require.NoError(t, os.WriteFile(configPath, existingContents, 0o644))
+
+ require.NoError(t, integration.SetupPluginDirConfig(homeDir, pluginPath, true))
+
+ bytes, err := os.ReadFile(path.Join(homeDir, ".docker", "config.json"))
+ require.NoError(t, err, "error reading docker CLI config")
+ var config map[string]any
+ require.NoError(t, json.Unmarshal(bytes, &config))
+
+ assert.Subset(t, config, map[string]any{"credsStore": "nothing"})
+ assert.Subset(t, config, map[string]any{"cliPluginsExtraDirs": []any{pluginPath}})
+ })
+ t.Run("do not add multiple instances", func(t *testing.T) {
+ homeDir := t.TempDir()
+ pluginPath := t.TempDir()
+ configPath := path.Join(homeDir, ".docker", "config.json")
+ require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
+
+ expected := []any{"1", pluginPath, "2"}
+ config := map[string]any{"cliPluginsExtraDirs": expected}
+ existingContents, err := json.Marshal(config)
+ require.NoError(t, err)
+ require.NoError(t, os.WriteFile(configPath, existingContents, 0o644))
+ config = make(map[string]any)
+
+ require.NoError(t, integration.SetupPluginDirConfig(homeDir, pluginPath, true))
+
+ bytes, err := os.ReadFile(configPath)
+ require.NoError(t, err, "error reading docker CLI config")
+ require.NoError(t, json.Unmarshal(bytes, &config))
+
+ assert.Subset(t, config, map[string]any{"cliPluginsExtraDirs": expected})
+ })
+ t.Run("remove existing instances", func(t *testing.T) {
+ homeDir := t.TempDir()
+ pluginPath := t.TempDir()
+ configPath := path.Join(homeDir, ".docker", "config.json")
+ require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
+
+ config := map[string]any{"cliPluginsExtraDirs": []any{"1", pluginPath, "2"}}
+ existingContents, err := json.Marshal(config)
+ require.NoError(t, err)
+ require.NoError(t, os.WriteFile(configPath, existingContents, 0o644))
+ config = make(map[string]any)
+
+ require.NoError(t, integration.SetupPluginDirConfig(homeDir, pluginPath, false))
+
+ bytes, err := os.ReadFile(configPath)
+ require.NoError(t, err, "error reading docker CLI config")
+ require.NoError(t, json.Unmarshal(bytes, &config))
+
+ assert.Subset(t, config, map[string]any{"cliPluginsExtraDirs": []any{"1", "2"}})
+ })
+ t.Run("do not modify invalid file", func(t *testing.T) {
+ t.Parallel()
+ t.Run("file is not JSON", func(t *testing.T) {
+ homeDir := t.TempDir()
+ pluginPath := t.TempDir()
+ configPath := path.Join(homeDir, ".docker", "config.json")
+ require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
+ existingContents := []byte(`this is not JSON`)
+ require.NoError(t, os.WriteFile(configPath, existingContents, 0o644))
+
+ assert.Error(t, integration.SetupPluginDirConfig(homeDir, pluginPath, true))
+
+ bytes, err := os.ReadFile(configPath)
+ require.NoError(t, err, "error reading docker CLI config")
+ assert.Equal(t, existingContents, bytes, "docker CLI config was changed")
+ })
+ t.Run("file contains invalid plugin dirs", func(t *testing.T) {
+ homeDir := t.TempDir()
+ pluginPath := t.TempDir()
+ configPath := path.Join(homeDir, ".docker", "config.json")
+ require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
+
+ config := map[string]any{"cliPluginsExtraDirs": 500}
+ existingContents, err := json.MarshalIndent(config, " \t ", " \n\r ")
+ require.NoError(t, err)
+ require.NoError(t, os.WriteFile(configPath, existingContents, 0o644))
+
+ require.Error(t, integration.SetupPluginDirConfig(homeDir, pluginPath, false))
+
+ bytes, err := os.ReadFile(configPath)
+ require.NoError(t, err, "error reading docker CLI config")
+ // Since we should not have modified the file at all, the file should
+ // still be byte-identical.
+ assert.Equal(t, existingContents, bytes, "docker CLI config was modified")
+ })
+ t.Run("file contains non-string plugin dirs items", func(t *testing.T) {})
+ homeDir := t.TempDir()
+ pluginPath := t.TempDir()
+ configPath := path.Join(homeDir, ".docker", "config.json")
+ require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
+
+ items := []any{1, true, map[string]any{"hello": "world"}}
+ config := map[string]any{"cliPluginsExtraDirs": items}
+ existingContents, err := json.MarshalIndent(config, " \t ", " \n\r ")
+ require.NoError(t, err)
+ require.NoError(t, os.WriteFile(configPath, existingContents, 0o644))
+
+ require.Error(t, integration.SetupPluginDirConfig(homeDir, pluginPath, false))
+
+ bytes, err := os.ReadFile(configPath)
+ require.NoError(t, err, "error reading docker CLI config")
+ // Since we should not have modified the file at all, the file should
+ // still be byte-identical.
+ assert.Equal(t, existingContents, bytes, "docker CLI config was modified")
})
- t.Run("remove dangling symlink", func(t *testing.T) {
+}
+
+func TestRemoveObsoletePluginSymlinks(t *testing.T) {
+ t.Run("plugin directory does not exist", func(t *testing.T) {
homeDir := t.TempDir()
- pluginDir := t.TempDir()
- pluginPath := filepath.Join(pluginDir, "docker-something")
- destPath := filepath.Join(homeDir, ".docker", "cli-plugins", "docker-something")
- t.Setenv("HOME", homeDir)
-
- require.NoError(t, os.MkdirAll(filepath.Dir(destPath), 0o755))
- require.NoError(t, os.Symlink(filepath.Join(pluginDir, "missing"), destPath))
- require.NoError(t, integration.DockerPlugin(pluginPath, true))
- link, err := os.Readlink(destPath)
- if assert.NoError(t, err, "error reading created symlink") {
- assert.Equal(t, pluginPath, link)
- }
+ binPath := t.TempDir()
+ assert.NoError(t, integration.RemoveObsoletePluginSymlinks(homeDir, binPath))
})
- t.Run("leave existing symlink", func(t *testing.T) {
- executable, err := os.Executable()
- require.NoError(t, err, "failed to locate executable")
+ t.Run("leaves non-symlink plugins", func(t *testing.T) {
homeDir := t.TempDir()
- pluginDir := t.TempDir()
- pluginPath := filepath.Join(pluginDir, "docker-something")
- destPath := filepath.Join(homeDir, ".docker", "cli-plugins", "docker-something")
- t.Setenv("HOME", homeDir)
-
- require.NoError(t, os.MkdirAll(filepath.Dir(destPath), 0o755))
- require.NoError(t, os.Symlink(executable, destPath))
- require.NoError(t, integration.DockerPlugin(pluginPath, true))
- link, err := os.Readlink(destPath)
- if assert.NoError(t, err, "error reading created symlink") {
- assert.Equal(t, executable, link)
- }
+ binPath := t.TempDir()
+ pluginDir := path.Join(homeDir, ".docker", "cli-plugins")
+ assert.NoError(t, os.MkdirAll(pluginDir, 0o755))
+ pluginPath := path.Join(pluginDir, "docker-plugin")
+ assert.NoError(t, os.WriteFile(pluginPath, []byte{}, 0o755))
+ assert.NoError(t, integration.RemoveObsoletePluginSymlinks(homeDir, binPath))
+ contents, err := os.ReadFile(pluginPath)
+ assert.NoError(t, err)
+ assert.Empty(t, contents)
})
- t.Run("leave existing file", func(t *testing.T) {
+ t.Run("leaves foreign symlinks", func(t *testing.T) {
homeDir := t.TempDir()
- pluginDir := t.TempDir()
- pluginPath := filepath.Join(pluginDir, "docker-something")
- destPath := filepath.Join(homeDir, ".docker", "cli-plugins", "docker-something")
- t.Setenv("HOME", homeDir)
-
- require.NoError(t, os.MkdirAll(filepath.Dir(destPath), 0o755))
- require.NoError(t, os.WriteFile(destPath, []byte("hello"), 0o644))
- require.NoError(t, integration.DockerPlugin(pluginPath, true))
- buf, err := os.ReadFile(destPath)
- if assert.NoError(t, err, "failed to read destination file") {
- assert.Equal(t, []byte("hello"), buf)
- }
+ binPath := t.TempDir()
+ pluginDir := path.Join(homeDir, ".docker", "cli-plugins")
+ assert.NoError(t, os.MkdirAll(pluginDir, 0o755))
+ pluginPath := path.Join(pluginDir, "docker-plugin")
+ assert.NoError(t, os.Symlink("/usr/bin/true", pluginPath))
+ assert.NoError(t, integration.RemoveObsoletePluginSymlinks(homeDir, binPath))
+ symlinkTarget, err := os.Readlink(pluginPath)
+ assert.NoError(t, err)
+ assert.Equal(t, "/usr/bin/true", symlinkTarget)
})
- t.Run("remove correct symlink", func(t *testing.T) {
+ t.Run("leaves self-referential symlinks", func(t *testing.T) {
homeDir := t.TempDir()
- pluginDir := t.TempDir()
- pluginPath := filepath.Join(pluginDir, "docker-something")
- destPath := filepath.Join(homeDir, ".docker", "cli-plugins", "docker-something")
- t.Setenv("HOME", homeDir)
-
- require.NoError(t, os.MkdirAll(filepath.Dir(destPath), 0o755))
- require.NoError(t, os.Symlink(pluginPath, destPath))
- require.NoError(t, integration.DockerPlugin(pluginPath, false))
- _, err := os.Lstat(destPath)
- assert.ErrorIs(t, err, os.ErrNotExist, "symlink was not removed")
+ binPath := t.TempDir()
+ pluginDir := path.Join(homeDir, ".docker", "cli-plugins")
+ assert.NoError(t, os.MkdirAll(pluginDir, 0o755))
+ pluginPath := path.Join(pluginDir, "docker-plugin")
+ assert.NoError(t, os.Symlink(pluginPath, pluginPath))
+ assert.NoError(t, integration.RemoveObsoletePluginSymlinks(homeDir, binPath))
+ symlinkTarget, err := os.Readlink(pluginPath)
+ assert.NoError(t, err)
+ assert.Equal(t, pluginPath, symlinkTarget)
})
- t.Run("do not remove incorrect symlink", func(t *testing.T) {
+ t.Run("removes symlinks", func(t *testing.T) {
homeDir := t.TempDir()
- pluginDir := t.TempDir()
- pluginPath := filepath.Join(pluginDir, "docker-something")
- destPath := filepath.Join(homeDir, ".docker", "cli-plugins", "docker-something")
- t.Setenv("HOME", homeDir)
-
- require.NoError(t, os.MkdirAll(filepath.Dir(destPath), 0o755))
- require.NoError(t, os.Symlink(destPath, destPath))
- require.NoError(t, integration.DockerPlugin(pluginPath, false))
- result, err := os.Readlink(destPath)
- if assert.NoError(t, err, "error reading symlink") {
- assert.Equal(t, destPath, result, "unexpected symlink contents")
- }
+ binPath := t.TempDir()
+ pluginDir := path.Join(homeDir, ".docker", "cli-plugins")
+ assert.NoError(t, os.MkdirAll(pluginDir, 0o755))
+ pluginPath := path.Join(pluginDir, "docker-plugin")
+ targetPath := path.Join(binPath, "does-not-exist")
+ assert.NoError(t, os.Symlink(targetPath, pluginPath))
+ assert.NoError(t, integration.RemoveObsoletePluginSymlinks(homeDir, binPath))
+ _, err := os.Readlink(pluginPath)
+ assert.ErrorIs(t, err, os.ErrNotExist)
})
}