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) }) }