Repository: firecow/gitlab-ci-local (ts)

Issue:
Job fails when using quay.io/condaforge/linux-anvil-cos7-x86_64 image
**Minimal .gitlab-ci.yml illustrating the issue**
```yml
---
job:
  image: quay.io/condaforge/linux-anvil-cos7-x86_64
  script:
    - echo "Heya"
    - ls -la
    - python3 hello.py
```

`hello.py`:

```python
print("Hello world")
```

**Expected behavior**
```
job copied to docker volumes in 3.38 s
job $ echo "Heya"
job > Heya
job $ ls -la
job > total 16
job > drwxrwxrwx 3 root root 4096 May 14 15:21 .
job > drwxr-xr-x 1 root root 4096 May 14 15:22 ..
job > drwxrwxrwx 7 root root 4096 May 14 15:11 .git
job > -rw-rw-rw- 1 root root   21 May 14 15:13 test.py
job $ python3 test.py
job > Hello world
job finished in 13 s
```

**Actual behavior**
```
job copied to docker volumes in 3.88 s
job > bash: cannot set terminal process group (-1): Inappropriate ioctl for device
job > bash: no job control in this shell
job $ echo "Heya"
job > Heya
job $ ls -la
job > total 24
job > drwx------ 2 conda conda 4096 May 14 15:17 .
job > drwxr-xr-x 1 root  root  4096 May 14 15:17 ..
job > -rw-r--r-- 1 conda conda   18 Nov 24  2021 .bash_logout
job > -rw-r--r-- 1 conda conda  193 Nov 24  2021 .bash_profile
job > -rw-r--r-- 1 conda conda  231 Nov 24  2021 .bashrc
job > -rw-r--r-- 1 conda conda   75 May 14 15:17 .condarc
job $ python3 test.py
job > python3: can't open file '/home/conda/hello.py': [Errno 2] No such file or directory
job finished in 7.83 s  FAIL 2
```

**Host information**
KDE Neon
gitlab-ci-local 4.46.1

macOS 13.6.6
gitlab-ci-local 4.49.00

**Containerd binary**
macOS Docker Desktop with Docker version 24.0.7-rd, build 72ffacf

Linux Docker version 26.1.2, build 211e74b

**Additional context**
The "faulty" image can be used successfully with GitLab with the docker runner.
The "Expected behaviour" run uses `python:3.12-slim-bookworm` for the image.

Both images can be run using docker directly.

Pull request description:
fix: ensure that script gets executed in the /gcl-builds dirs
- fixes #1220 whereas docker entrypoint changes the cwd

Patch:
diff --git a/src/argv.ts b/src/argv.ts
index d9f5010..1a433e0 100644
--- a/src/argv.ts
+++ b/src/argv.ts
@@ -240,6 +240,10 @@ export class Argv {
         return this.map.get("containerMacAddress") ?? null;
     }
 
+    get containerEmulate (): string | null {
+        return this.map.get("containerEmulate") ?? null;
+    }
+
     get concurrency (): number | null {
         const concurrency = this.map.get("concurrency");
         if (!concurrency) return null;
diff --git a/src/gitlab-preset.ts b/src/gitlab-preset.ts
new file mode 100644
index 0000000..bea2eea
--- /dev/null
+++ b/src/gitlab-preset.ts
@@ -0,0 +1,25 @@
+export enum GitlabRunnerPreset {
+    SMALL = "saas-linux-small",
+    MEDIUM = "saas-linux-medium",
+    LARGE = "saas-linux-large",
+    XLARGE = "saas-linux-xlarge",
+    TWO_XLARGE = "saas-linux-2xlarge",
+}
+
+export const GitlabRunnerPresetValues: string[] = Object.values(GitlabRunnerPreset);
+
+export const GitlabRunnerMemoryPresetValue: Record<string, number> = {
+    [GitlabRunnerPreset.SMALL]: 8 * 1024,
+    [GitlabRunnerPreset.MEDIUM]: 16 * 1024,
+    [GitlabRunnerPreset.LARGE]: 32 * 1024,
+    [GitlabRunnerPreset.XLARGE]: 64 * 1024,
+    [GitlabRunnerPreset.TWO_XLARGE]: 128 * 1024,
+};
+
+export const GitlabRunnerCPUsPresetValue: Record<string, number> = {
+    [GitlabRunnerPreset.SMALL]: 2,
+    [GitlabRunnerPreset.MEDIUM]: 4,
+    [GitlabRunnerPreset.LARGE]: 8,
+    [GitlabRunnerPreset.XLARGE]: 16,
+    [GitlabRunnerPreset.TWO_XLARGE]: 32,
+};
diff --git a/src/index.ts b/src/index.ts
index b17e77a..658dba8 100755
--- a/src/index.ts
+++ b/src/index.ts
@@ -12,6 +12,7 @@ import {Executor} from "./executor";
 import {Argv} from "./argv";
 import {AssertionError} from "assert";
 import {Job, cleanupJobResources} from "./job";
+import {GitlabRunnerPresetValues} from "./gitlab-preset";
 
 const jobs: Job[] = [];
 
@@ -257,6 +258,11 @@ process.on("SIGUSR2", async () => await cleanupJobResources(jobs));
             description: "Container MAC address (e.g., aa:bb:cc:dd:ee:ff)",
             requiresArg: false,
         })
+        .option("container-emulate", {
+            type: "string",
+            description: "The name, without the architecture, of a gitlab hosted runner to emulate. See here: https://docs.gitlab.com/ee/ci/runners/hosted_runners/linux.html#machine-types-available-for-linux---x86-64",
+            choices: GitlabRunnerPresetValues,
+        })
         .completion("completion", false, (current: string, yargsArgv: any, completionFilter: any, done: (completions: string[]) => any) => {
             try {
                 if (current.startsWith("-")) {
diff --git a/src/job.ts b/src/job.ts
index 06fe5a8..65a4033 100644
--- a/src/job.ts
+++ b/src/job.ts
@@ -11,6 +11,7 @@ import {Mutex} from "./mutex";
 import {Argv} from "./argv";
 import execa from "execa";
 import {CICDVariable} from "./variables-from-files";
+import {GitlabRunnerCPUsPresetValue, GitlabRunnerMemoryPresetValue, GitlabRunnerPresetValues} from "./gitlab-preset";
 
 const CI_PROJECT_DIR = "/gcl-builds";
 interface JobOptions {
@@ -677,6 +678,21 @@ export class Job {
                 dockerCmd += "--network-alias=build ";
             }
 
+            if (this.argv.containerEmulate) {
+                const runnerName: string = this.argv.containerEmulate;
+
+                if (!GitlabRunnerPresetValues.includes(runnerName)) {
+                    throw new Error("Invalid gitlab runner to emulate.");
+                }
+
+                const memoryConfig = GitlabRunnerMemoryPresetValue[runnerName];
+                const cpuConfig = GitlabRunnerCPUsPresetValue[runnerName];
+
+                dockerCmd += `--memory=${memoryConfig}m `;
+                dockerCmd += `--kernel-memory=${memoryConfig}m `;
+                dockerCmd += `--cpus=${cpuConfig} `;
+            }
+
             dockerCmd += `--volume ${buildVolumeName}:/gcl-builds `;
             dockerCmd += `--volume ${tmpVolumeName}:${this.fileVariablesDir} `;
             dockerCmd += "--workdir /gcl-builds ";
@@ -750,6 +766,10 @@ export class Job {
         if (!imageName && this.argv.shellIsolation) {
             cmd += `cd ${stateDir}/builds/${safeJobName}/\n`;
         }
+
+        if (imageName) {
+            cmd += "cd /gcl-builds \n";
+        }
         cmd += this.generateScriptCommands(scripts);
 
         cmd += "exit 0\n";


Test patch:
diff --git a/tests/mocks/utils.mock.ts b/tests/mocks/utils.mock.ts
index 93d0535..e75cb48 100644
--- a/tests/mocks/utils.mock.ts
+++ b/tests/mocks/utils.mock.ts
@@ -17,6 +17,8 @@ export function initBashSpy (spyMocks: {cmd: string; returnValue: any}[]) {
         when(spyOn).calledWith(spyMock.cmd, expect.any(String)).mockResolvedValue(spyMock.returnValue);
         when(spyOn).calledWith(spyMock.cmd).mockResolvedValue(spyMock.returnValue);
     }
+
+    return spyOn;
 }
 
 export function initSpawnSpy (spyMocks: {cmdArgs: string[]; returnValue: any}[]) {
diff --git a/tests/test-cases/gitlab-runner-emulation/.gitlab-ci.yml b/tests/test-cases/gitlab-runner-emulation/.gitlab-ci.yml
new file mode 100644
index 0000000..30259b9
--- /dev/null
+++ b/tests/test-cases/gitlab-runner-emulation/.gitlab-ci.yml
@@ -0,0 +1,5 @@
+---
+somejob:
+  image: docker.io/library/alpine
+  script:
+    - echo Hello
diff --git a/tests/test-cases/gitlab-runner-emulation/integration.emulate-runner.test.ts b/tests/test-cases/gitlab-runner-emulation/integration.emulate-runner.test.ts
new file mode 100644
index 0000000..dd11a4d
--- /dev/null
+++ b/tests/test-cases/gitlab-runner-emulation/integration.emulate-runner.test.ts
@@ -0,0 +1,112 @@
+import {WriteStreamsMock} from "../../../src/write-streams";
+import {handler} from "../../../src/handler";
+import {initBashSpy, initSpawnSpy} from "../../mocks/utils.mock";
+import {WhenStatics} from "../../mocks/when-statics";
+import {
+    GitlabRunnerCPUsPresetValue,
+    GitlabRunnerMemoryPresetValue,
+    GitlabRunnerPresetValues,
+} from "../../../src/gitlab-preset";
+
+beforeAll(() => {
+    initSpawnSpy([...WhenStatics.all, {
+        cmdArgs: ["docker", "cp", expect.any(String), expect.any(String)],
+        returnValue: {stdout: "Ok"},
+    }]);
+});
+
+test("--container-emulate some_unexisting_runner", async () => {
+    try {
+        const writeStreams = new WriteStreamsMock();
+        await handler({
+            cwd: "tests/test-cases/container-executable",
+            containerEmulate: "some_unexisting_runner",
+        }, writeStreams);
+
+        expect(true).toBe(false);
+    } catch (e: unknown) {
+        expect((e as Error).message).toContain("Invalid gitlab runner to emulate.");
+    }
+});
+
+test("should contains memory config when emulating valid runner", async () => {
+    // given a spy of the bash utils
+    const bashSpy = initBashSpy([{cmd: expect.any(String), returnValue: {stdout: "abcde12345", stderr: "", exitCode: 0}}]);
+
+    // and a WriteStreamsMock
+    const writeStreams = new WriteStreamsMock();
+
+    // when calling the handler with a valid emulate value
+    await handler({
+        cwd: "tests/test-cases/container-executable",
+        containerEmulate: "saas-linux-small",
+    }, writeStreams);
+
+    // then the bash spy should have been called with the memory option
+    expect(bashSpy).toHaveBeenLastCalledWith(expect.stringMatching(/ --memory=\d{1,6}m /), expect.any(String));
+});
+
+test("should contains kernel memory config when emulating valid runner", async () => {
+    // given a spy of the bash utils
+    const bashSpy = initBashSpy([{cmd: expect.any(String), returnValue: {stdout: "abcde12345", stderr: "", exitCode: 0}}]);
+
+    // and a WriteStreamsMock
+    const writeStreams = new WriteStreamsMock();
+
+    // when calling the handler with a valid emulate value
+    await handler({
+        cwd: "tests/test-cases/container-executable",
+        containerEmulate: "saas-linux-small",
+    }, writeStreams);
+
+    // then the bash spy should have been called with the kernel option
+    expect(bashSpy).toHaveBeenLastCalledWith(expect.stringMatching(/ --kernel-memory=\d{1,6}m /), expect.any(String));
+});
+
+test("should contains cpus config when emulating valid runner", async () => {
+    // given a spy of the bash utils
+    const bashSpy = initBashSpy([{cmd: expect.any(String), returnValue: {stdout: "abcde12345", stderr: "", exitCode: 0}}]);
+
+    // and a WriteStreamsMock
+    const writeStreams = new WriteStreamsMock();
+
+    // when calling the handler with a valid emulate value
+    await handler({
+        cwd: "tests/test-cases/container-executable",
+        containerEmulate: "saas-linux-small",
+    }, writeStreams);
+
+    // then the bash spy should have been called with the cpus option
+    expect(bashSpy).toHaveBeenLastCalledWith(expect.stringMatching(/ --cpus=\d{1,3}(\.\d{1,3})? /), expect.any(String));
+});
+
+const gitlabRunnerDataProvider = GitlabRunnerPresetValues.map(name => ({
+    name,
+    memory: GitlabRunnerMemoryPresetValue[name],
+    cpus: GitlabRunnerCPUsPresetValue[name],
+}));
+
+describe.each(gitlabRunnerDataProvider)("gitlab runner configuration", (data) => {
+    test(`should set the proper values when emulating ${data.name}`, async () => {
+        // given a spy of the bash utils
+        const bashSpy = initBashSpy([{cmd: expect.any(String), returnValue: {stdout: "abcde12345", stderr: "", exitCode: 0}}]);
+
+        // and a WriteStreamsMock
+        const writeStreams = new WriteStreamsMock();
+
+        // when calling the handler with the given emulated runner
+        await handler({
+            cwd: "tests/test-cases/container-executable",
+            containerEmulate: data.name,
+        }, writeStreams);
+
+        // then the bash spy should have been called with the memory option
+        expect(bashSpy).toHaveBeenLastCalledWith(expect.stringContaining(`--memory=${data.memory}`), expect.any(String));
+
+        // and the bash spy should have been called with the kernel memory option
+        expect(bashSpy).toHaveBeenLastCalledWith(expect.stringContaining(`--kernel-memory=${data.memory}`), expect.any(String));
+
+        // and the bash spy should have been called with the cpus option
+        expect(bashSpy).toHaveBeenLastCalledWith(expect.stringContaining(`--cpus=${data.cpus}`), expect.any(String));
+    });
+});