Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -16,21 +16,30 @@
* SPDX-License-Identifier: Apache-2.0
***********************************************************************/

import type { ChildProcess } from 'node:child_process';
import { spawn } from 'node:child_process';
import type { PathLike } from 'node:fs';
import { existsSync } from 'node:fs';
import { resolve } from 'node:path';

import { afterEach, beforeEach, expect, test, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';

import { PodmanSocketLinuxFinder } from './podman-linux-finder';

vi.mock(import('node:fs'));
vi.mock(import('node:child_process'));

let originalXdgRuntimeDir: string | undefined;
let originalFlatpakId: string | undefined;

beforeEach(() => {
vi.resetAllMocks();
originalXdgRuntimeDir = process.env.XDG_RUNTIME_DIR;
originalFlatpakId = process.env.FLATPAK_ID;
vi.mocked(spawn).mockReturnValue({
on: vi.fn(),
kill: vi.fn(),
} as unknown as ChildProcess);
});

afterEach(() => {
Expand All @@ -39,20 +48,14 @@ afterEach(() => {
} else {
delete process.env.XDG_RUNTIME_DIR;
}
if (originalFlatpakId !== undefined) {
process.env.FLATPAK_ID = originalFlatpakId;
} else {
delete process.env.FLATPAK_ID;
}
});

test('findPaths returns empty array when no sockets exist', async () => {
const finder = new PodmanSocketLinuxFinder();

process.env.XDG_RUNTIME_DIR = '/run/user/1000';
vi.mocked(existsSync).mockReturnValue(false);

const result = await finder.findPaths();

expect(result).toEqual([]);
});

test('findPaths returns rootless socket when it exists', async () => {
test('findPaths returns rootless socket when it already exists without spawning', async () => {
const finder = new PodmanSocketLinuxFinder();

process.env.XDG_RUNTIME_DIR = '/run/user/1000';
Expand All @@ -65,19 +68,7 @@ test('findPaths returns rootless socket when it exists', async () => {

expect(result).toContain(expectedSocket);
expect(result).not.toContain('/run/podman/podman.sock');
});

test('findPaths returns rootful socket when it exists', async () => {
const finder = new PodmanSocketLinuxFinder();

delete process.env.XDG_RUNTIME_DIR;
vi.mocked(existsSync).mockImplementation((path: PathLike) => {
return String(path) === '/run/podman/podman.sock';
});

const result = await finder.findPaths();

expect(result).toEqual(['/run/podman/podman.sock']);
expect(spawn).not.toHaveBeenCalled();
});

test('findPaths returns both sockets when both exist', async () => {
Expand All @@ -94,17 +85,6 @@ test('findPaths returns both sockets when both exist', async () => {
expect(result).toContain('/run/podman/podman.sock');
});

test('findPaths returns empty array when XDG_RUNTIME_DIR is not set and rootful socket does not exist', async () => {
const finder = new PodmanSocketLinuxFinder();

delete process.env.XDG_RUNTIME_DIR;
vi.mocked(existsSync).mockReturnValue(false);

const result = await finder.findPaths();

expect(result).toEqual([]);
});

test('findPaths falls back to /run/user/$UID when XDG_RUNTIME_DIR is unset', async () => {
const finder = new PodmanSocketLinuxFinder();

Expand All @@ -122,3 +102,158 @@ test('findPaths falls back to /run/user/$UID when XDG_RUNTIME_DIR is unset', asy
expect(result).toContain(expectedSocket);
}
});

test('dispose is safe to call when no process was spawned', () => {
const finder = new PodmanSocketLinuxFinder();

expect(() => finder.dispose()).not.toThrow();
});

describe('with fake timers', () => {
beforeEach(() => {
vi.useFakeTimers();
});

afterEach(() => {
vi.useRealTimers();
});

test('findPaths returns empty array when no sockets exist and podman service fails to start', async () => {
const finder = new PodmanSocketLinuxFinder();

process.env.XDG_RUNTIME_DIR = '/run/user/1000';
vi.mocked(existsSync).mockReturnValue(false);

const resultPromise = finder.findPaths();
await vi.advanceTimersByTimeAsync(
PodmanSocketLinuxFinder.SOCKET_WAIT_ATTEMPTS * PodmanSocketLinuxFinder.SOCKET_WAIT_INTERVAL_MS,
);
const result = await resultPromise;

expect(result).toEqual([]);
expect(spawn).toHaveBeenCalledWith('podman', ['system', 'service', '--time=0'], { stdio: 'ignore' });
});

test('findPaths returns rootful socket when rootless is unavailable', async () => {
const finder = new PodmanSocketLinuxFinder();

delete process.env.XDG_RUNTIME_DIR;
const uid = process.getuid?.();
const rootlessSocket = uid !== undefined ? resolve(`/run/user/${uid}`, 'podman/podman.sock') : undefined;

vi.mocked(existsSync).mockImplementation((path: PathLike) => {
if (String(path) === '/run/podman/podman.sock') return true;
if (rootlessSocket && String(path) === rootlessSocket) return false;
return false;
});

const resultPromise = finder.findPaths();
await vi.advanceTimersByTimeAsync(
PodmanSocketLinuxFinder.SOCKET_WAIT_ATTEMPTS * PodmanSocketLinuxFinder.SOCKET_WAIT_INTERVAL_MS,
);
const result = await resultPromise;

expect(result).toEqual(['/run/podman/podman.sock']);
});

test('findPaths returns empty array when XDG_RUNTIME_DIR is not set and rootful socket does not exist', async () => {
const finder = new PodmanSocketLinuxFinder();

delete process.env.XDG_RUNTIME_DIR;
vi.mocked(existsSync).mockReturnValue(false);

const resultPromise = finder.findPaths();
await vi.advanceTimersByTimeAsync(
PodmanSocketLinuxFinder.SOCKET_WAIT_ATTEMPTS * PodmanSocketLinuxFinder.SOCKET_WAIT_INTERVAL_MS,
);
const result = await resultPromise;

expect(result).toEqual([]);
});

test('findPaths spawns podman service when rootless socket does not exist and returns socket after it appears', async () => {
const finder = new PodmanSocketLinuxFinder();

process.env.XDG_RUNTIME_DIR = '/run/user/1000';
const expectedSocket = resolve('/run/user/1000', 'podman/podman.sock');

let callCount = 0;
vi.mocked(existsSync).mockImplementation((path: PathLike) => {
if (String(path) === expectedSocket) {
callCount++;
return callCount >= 3;
}
return false;
});

const resultPromise = finder.findPaths();
await vi.advanceTimersByTimeAsync(
PodmanSocketLinuxFinder.SOCKET_WAIT_ATTEMPTS * PodmanSocketLinuxFinder.SOCKET_WAIT_INTERVAL_MS,
);
const result = await resultPromise;

expect(spawn).toHaveBeenCalledWith('podman', ['system', 'service', '--time=0'], { stdio: 'ignore' });
expect(result).toContain(expectedSocket);
});

test('findPaths uses flatpak-spawn when FLATPAK_ID is set', async () => {
const finder = new PodmanSocketLinuxFinder();

process.env.XDG_RUNTIME_DIR = '/run/user/1000';
process.env.FLATPAK_ID = 'com.example.app';
vi.mocked(existsSync).mockReturnValue(false);

const resultPromise = finder.findPaths();
await vi.advanceTimersByTimeAsync(
PodmanSocketLinuxFinder.SOCKET_WAIT_ATTEMPTS * PodmanSocketLinuxFinder.SOCKET_WAIT_INTERVAL_MS,
);
await resultPromise;

expect(spawn).toHaveBeenCalledWith('flatpak-spawn', ['--host', 'podman', 'system', 'service', '--time=0'], {
stdio: 'ignore',
});
});

test('dispose kills the spawned podman process', async () => {
const finder = new PodmanSocketLinuxFinder();
const killMock = vi.fn();
vi.mocked(spawn).mockReturnValue({
on: vi.fn(),
kill: killMock,
} as unknown as ChildProcess);

process.env.XDG_RUNTIME_DIR = '/run/user/1000';
vi.mocked(existsSync).mockReturnValue(false);

const resultPromise = finder.findPaths();
await vi.advanceTimersByTimeAsync(
PodmanSocketLinuxFinder.SOCKET_WAIT_ATTEMPTS * PodmanSocketLinuxFinder.SOCKET_WAIT_INTERVAL_MS,
);
await resultPromise;

finder.dispose();

expect(killMock).toHaveBeenCalled();
});

test('findPaths does not spawn a second process if one is already running', async () => {
const finder = new PodmanSocketLinuxFinder();

process.env.XDG_RUNTIME_DIR = '/run/user/1000';
vi.mocked(existsSync).mockReturnValue(false);

const resultPromise1 = finder.findPaths();
await vi.advanceTimersByTimeAsync(
PodmanSocketLinuxFinder.SOCKET_WAIT_ATTEMPTS * PodmanSocketLinuxFinder.SOCKET_WAIT_INTERVAL_MS,
);
await resultPromise1;

const resultPromise2 = finder.findPaths();
await vi.advanceTimersByTimeAsync(
PodmanSocketLinuxFinder.SOCKET_WAIT_ATTEMPTS * PodmanSocketLinuxFinder.SOCKET_WAIT_INTERVAL_MS,
);
await resultPromise2;

expect(spawn).toHaveBeenCalledTimes(1);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,22 @@
* SPDX-License-Identifier: Apache-2.0
***********************************************************************/

import type { ChildProcess } from 'node:child_process';
import { spawn } from 'node:child_process';
import { existsSync } from 'node:fs';
import { resolve } from 'node:path';

import { injectable } from 'inversify';
import { injectable, preDestroy } from 'inversify';

import type { SocketFinder } from '/@/api/socket-finder';

@injectable()
export class PodmanSocketLinuxFinder implements SocketFinder {
static readonly SOCKET_WAIT_ATTEMPTS = 50;
static readonly SOCKET_WAIT_INTERVAL_MS = 100;

#podmanProcess: ChildProcess | undefined;

async findPaths(): Promise<string[]> {
const paths: string[] = [];

Expand All @@ -34,6 +41,9 @@ export class PodmanSocketLinuxFinder implements SocketFinder {
const xdgRuntimeDir = process.env.XDG_RUNTIME_DIR ?? (uid !== undefined ? `/run/user/${uid}` : undefined);
if (xdgRuntimeDir) {
const rootlessSocket = resolve(xdgRuntimeDir, 'podman/podman.sock');
if (!existsSync(rootlessSocket)) {
await this.startPodmanService(rootlessSocket);
}
if (existsSync(rootlessSocket)) {
paths.push(rootlessSocket);
}
Expand All @@ -47,4 +57,42 @@ export class PodmanSocketLinuxFinder implements SocketFinder {

return paths;
}

protected async startPodmanService(socketPath: string): Promise<void> {
if (this.#podmanProcess) {
return;
}

let command = 'podman';
let args = ['system', 'service', '--time=0'];
if (process.env.FLATPAK_ID) {
command = 'flatpak-spawn';
args = ['--host', 'podman', ...args];

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

praise: should use exec from the extension API

}

this.#podmanProcess = spawn(command, args, { stdio: 'ignore' });
this.#podmanProcess.on('error', err => {
console.error('Failed to start podman system service:', err);
this.#podmanProcess = undefined;
});

for (let i = 0; i < PodmanSocketLinuxFinder.SOCKET_WAIT_ATTEMPTS; i++) {
if (existsSync(socketPath)) {
return;
}
await new Promise(resolve => setTimeout(resolve, PodmanSocketLinuxFinder.SOCKET_WAIT_INTERVAL_MS));
}

console.error(
`Could not find the socket at ${socketPath} after ${(PodmanSocketLinuxFinder.SOCKET_WAIT_ATTEMPTS * PodmanSocketLinuxFinder.SOCKET_WAIT_INTERVAL_MS) / 1000}s. The command podman system service --time=0 did not work to start the podman socket.`,
);
}

@preDestroy()
dispose(): void {
if (this.#podmanProcess) {
this.#podmanProcess.kill();
this.#podmanProcess = undefined;
}
}
}
Loading