From db6769c7d9508b1ea633c72d3fb8a815867774b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gabriel=20N=C3=BCtzi?= Date: Tue, 20 Feb 2024 20:11:56 +0100 Subject: [PATCH] feat: Support `podman` container manager :anchor: (#145) - Add `podman` container manager. Essentially the same as `docker` except we mount with `--userns=keep-id:uid=1000,gid=1000`. - Multiple managers can be specified with `git hooks config container-manager-types --set --global "docker, podman`. Update-Info: Container manager `podman` is now supported. See Readme. --- .circleci/config.yml | 1 + README.md | 60 ++++++++-- docs/cli/git_hooks_config.md | 1 + ...it_hooks_config_container-manager-types.md | 29 +++++ docs/dialog/dialog.md | 17 ++- docs/dialog/dialog_entry.md | 14 +-- docs/dialog/dialog_file-save.md | 10 +- docs/dialog/dialog_file-selection.md | 9 +- docs/dialog/dialog_message.md | 13 ++- docs/dialog/dialog_notify.md | 4 +- docs/dialog/dialog_options.md | 17 +-- githooks/cmd/config/config.go | 56 +++++++++ githooks/container/executable.go | 35 ++++-- githooks/container/manager-container_test.go | 110 ++++++++++++++++++ githooks/container/manager-docker.go | 49 +++++--- githooks/container/manager-docker_test.go | 91 +-------------- githooks/container/manager-podman.go | 75 ++++++++++++ githooks/container/manager-podman_test.go | 19 +++ githooks/container/manager.go | 34 ++++-- tests/exec-testsuite.sh | 7 +- tests/test-testsuite-podman.sh | 86 ++++++++++++++ 21 files changed, 561 insertions(+), 176 deletions(-) create mode 100644 docs/cli/git_hooks_config_container-manager-types.md create mode 100644 githooks/container/manager-container_test.go create mode 100644 githooks/container/manager-podman.go create mode 100644 githooks/container/manager-podman_test.go create mode 100755 tests/test-testsuite-podman.sh diff --git a/.circleci/config.yml b/.circleci/config.yml index 77925a07..69bf587a 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -46,6 +46,7 @@ workflows: "test-corehookspath", "test-whitespace", "test-testsuite", + "test-testsuite-podman", "test-rules", ] filters: &filters diff --git a/README.md b/README.md index 5ee009d5..2c44ab6e 100644 --- a/README.md +++ b/README.md @@ -700,20 +700,27 @@ the missing Git LFS hooks will be installed too. ## Running Hooks in Containers -You can run hooks containerized over a container manager such as `docker` -(others such as `podman` etc. are not yet implemented). This relieves the -maintainer of a Githooks shared repo from dealing with _"It works on my -machine!"_ +You can run hooks containerized over a container manager such as `docker`. This +relieves the maintainer of a Githooks shared repo from dealing with _"It works +on my machine!"_ To enable containerized hook runs set the Git config variable either locally or globally with ```shell -git hooks config enable-containerized-hooks [--global] --set +git hooks config enable-containerized-hooks [--global] --set true ``` -to `true` or use the environment variable -`GITHOOKS_CONTAINERIZED_HOOKS_ENABLED=true`. +or use the environment variable `GITHOOKS_CONTAINERIZED_HOOKS_ENABLED=true`. + +Optionally set the container manager (default is `docker`) like + +```shell +git hooks config container-manager-types [--global] --set "podman,docker" +``` + +The container manager types can be a list from \[`docker`, `podman`\] where the +first valid one is used to run the hooks. Running a hook in a container is achieved by specifying the image reference (image name) inside a [hook run configuration](#hook-run-configuration), e.g. @@ -731,7 +738,7 @@ image: reference: "my-shellcheck:1.2.0" ``` -which will launch the command `./myscript/checkit.sh` in a docker container +which will launch the command `./myscript/checkit.sh` in a container `my-shellcheck:1.2.0`. The current Git repository where this hook is launched is mounted as the current working directory and the relative path `./myscript/checkit.sh` will be mangled to a path in the mounted read-only @@ -743,11 +750,40 @@ have access to the same environment variables as on your host system. All Githooks [environment variables](#environment-variables) are forwarded however to the container run. -Running commands in containers which modify files on writable volumes has some -caveats and quirks with permissions which are host system dependent. Hongli Lai -summarized these troubles in a +**Note:** The images you run must be `rootless` (contain a `USER` statement) and +this user must have user/group id `1000` (Todo: We can loosen this requirement +if really needed). See the +[example](https://github.com/gabyx/Githooks-Shell/blob/main/githooks/container/Dockerfile). + +### Podman Manager (rootless) + +**This manager is strongly preferred due to better security and less hassle with +volume mounts.** + +The containers are run with the following flags to `podman`: + +- `--userns=keep-id:uid=1000,gid=1000`: + [_User namespace mapping_](https://docs.podman.io/en/v4.4/markdown/options/userns.container.html). + Maps the user/group id of the user running Githooks (your host user) to the + container user/group id `1000`. This means a host user with user/group id e.g. + 4000 will be seen inside the container as user/group id 1000. This also works + for all volume mounts which will have `1000:1000` permission inside the + container. + +### Docker Manager + +The containers are run with the following flags to `docker`: + +- `--user::`: The container is run as the same user id and group id as + the user which runs Githooks (your host user). See the note below why this is + the case. + +**Note:** Running commands in containers which modify files on writable volumes +has some caveats and quirks with permissions which are host system dependent. +Hongli Lai summarized these troubles in a [very good article](https://www.fullstaq.com/knowledge-hub/blogs/docker-and-the-host-filesystem-owner-matching-problem). -Long story short, **you should use +Long story short if the images are run with the `docker` manager, **you should +use [`MatchHostFsOwner`](https://github.com/FooBarWidget/matchhostfsowner/releases)** which counter acts these permission problems neatly by installing [this into your hook's sidecar container](https://github.com/gabyx/Githooks-Shell/blob/main/githooks/container/Dockerfile#L29). diff --git a/docs/cli/git_hooks_config.md b/docs/cli/git_hooks_config.md index 211784d1..8b6f5dad 100644 --- a/docs/cli/git_hooks_config.md +++ b/docs/cli/git_hooks_config.md @@ -21,6 +21,7 @@ git hooks config * [git hooks](git_hooks.md) - Githooks CLI application * [git hooks config clone-branch](git_hooks_config_clone-branch.md) - Changes the Githooks clone url used for any update. * [git hooks config clone-url](git_hooks_config_clone-url.md) - Changes the Githooks clone url used for any update. +* [git hooks config container-manager-types](git_hooks_config_container-manager-types.md) - Set container manger types to use (see `enable-containerized-hooks`). * [git hooks config delete-detected-lfs-hooks](git_hooks_config_delete-detected-lfs-hooks.md) - Change the behavior for detected LFS hooks during install. * [git hooks config disable](git_hooks_config_disable.md) - Disables Githooks in the current repository or globally. * [git hooks config disable-shared-hooks-update](git_hooks_config_disable-shared-hooks-update.md) - Disable/enable automatic updates of shared hooks. diff --git a/docs/cli/git_hooks_config_container-manager-types.md b/docs/cli/git_hooks_config_container-manager-types.md new file mode 100644 index 00000000..78b884a4 --- /dev/null +++ b/docs/cli/git_hooks_config_container-manager-types.md @@ -0,0 +1,29 @@ +## git hooks config container-manager-types + +Set container manger types to use (see `enable-containerized-hooks`). + +### Synopsis + +Set container manager types to use where the first valid one is taken and used. +If unset `docker` is used. + +``` +git hooks config container-manager-types [flags] +``` + +### Options + +``` + --print Print the setting. + --set Set the setting. + --reset Reset the setting. + --local Use the local Git configuration (default). + --global Use the global Git configuration. + -h, --help help for container-manager-types +``` + +### SEE ALSO + +* [git hooks config](git_hooks_config.md) - Manages various Githooks configuration. + +###### Auto generated by spf13/cobra diff --git a/docs/dialog/dialog.md b/docs/dialog/dialog.md index 98a5b7a0..b5775132 100644 --- a/docs/dialog/dialog.md +++ b/docs/dialog/dialog.md @@ -22,12 +22,11 @@ dialog ### SEE ALSO -- [dialog entry](dialog_entry.md) - Shows a entry dialog. -- [dialog file-save](dialog_file-save.md) - Shows a file save dialog. -- [dialog file-selection](dialog_file-selection.md) - Shows a file selection - dialog. -- [dialog message](dialog_message.md) - Shows a message dialog. -- [dialog notify](dialog_notify.md) - Shows a notification. -- [dialog options](dialog_options.md) - Shows a options selection dialog. - -###### Auto generated by spf13/cobra +* [dialog entry](dialog_entry.md) - Shows a entry dialog. +* [dialog file-save](dialog_file-save.md) - Shows a file save dialog. +* [dialog file-selection](dialog_file-selection.md) - Shows a file selection dialog. +* [dialog message](dialog_message.md) - Shows a message dialog. +* [dialog notify](dialog_notify.md) - Shows a notification. +* [dialog options](dialog_options.md) - Shows a options selection dialog. + +###### Auto generated by spf13/cobra diff --git a/docs/dialog/dialog_entry.md b/docs/dialog/dialog_entry.md index 6ffe7d36..0574bf03 100644 --- a/docs/dialog/dialog_entry.md +++ b/docs/dialog/dialog_entry.md @@ -4,16 +4,16 @@ Shows a entry dialog. ### Synopsis -Shows a entry dialog similar to `zenity`. Currently extra buttons are not -supported on all platforms. Unix/Windows supports multiple extra buttons, MacOS -does not. +Shows a entry dialog similar to `zenity`. +Currently extra buttons are not supported on all platforms. +Unix/Windows supports multiple extra buttons, MacOS does not. # Exit Codes: - `0` : User pressed `Ok`. - `1` : User pressed `Cancel` or closed the dialog. -- `2` : The user pressed an extra button. The output contains the index of that - button. +- `2` : The user pressed an extra button. + The output contains the index of that button. ``` dialog entry @@ -54,6 +54,6 @@ dialog entry ### SEE ALSO -- [dialog](dialog.md) - Githooks dialog application similar to `zenity`. +* [dialog](dialog.md) - Githooks dialog application similar to `zenity`. -###### Auto generated by spf13/cobra +###### Auto generated by spf13/cobra diff --git a/docs/dialog/dialog_file-save.md b/docs/dialog/dialog_file-save.md index 7c469002..46640194 100644 --- a/docs/dialog/dialog_file-save.md +++ b/docs/dialog/dialog_file-save.md @@ -5,11 +5,11 @@ Shows a file save dialog. ### Synopsis Shows a file save dialog similar to `zenity`. - # Exit Codes: -- `0` : User pressed `Ok`. The output contains the selected paths separated by - `--separator`. All paths use forward slashes on any platform. +- `0` : User pressed `Ok`. The output contains the selected paths + separated by `--separator`. All paths use forward slashes + on any platform. - `1` : User pressed `Cancel` or closed the dialog. - `5` : The dialog was closed due to timeout. @@ -48,6 +48,6 @@ dialog file-save ### SEE ALSO -- [dialog](dialog.md) - Githooks dialog application similar to `zenity`. +* [dialog](dialog.md) - Githooks dialog application similar to `zenity`. -###### Auto generated by spf13/cobra +###### Auto generated by spf13/cobra diff --git a/docs/dialog/dialog_file-selection.md b/docs/dialog/dialog_file-selection.md index 66bbf083..b510aca9 100644 --- a/docs/dialog/dialog_file-selection.md +++ b/docs/dialog/dialog_file-selection.md @@ -8,8 +8,9 @@ Shows a file selection dialog similar to `zenity`. # Exit Codes: -- `0` : User pressed `Ok`. The output contains the selected paths separated by - `--separator`. All paths use forward slashes on any platform. +- `0` : User pressed `Ok`. The output contains the selected paths + separated by `--separator`. All paths use forward slashes + on any platform. - `1` : User pressed `Cancel` or closed the dialog. - `5` : The dialog was closed due to timeout. @@ -47,6 +48,6 @@ dialog file-selection ### SEE ALSO -- [dialog](dialog.md) - Githooks dialog application similar to `zenity`. +* [dialog](dialog.md) - Githooks dialog application similar to `zenity`. -###### Auto generated by spf13/cobra +###### Auto generated by spf13/cobra diff --git a/docs/dialog/dialog_message.md b/docs/dialog/dialog_message.md index e7af2a0e..2ef9d47e 100644 --- a/docs/dialog/dialog_message.md +++ b/docs/dialog/dialog_message.md @@ -6,15 +6,16 @@ Shows a message dialog. Shows a message dialog similar to `zenity`. -Currently only one extra button is supported on all platforms. Only Unix -supports multiple extra buttons. Use `options` to have more choices. +Currently only one extra button is supported on all platforms. +Only Unix supports multiple extra buttons. +Use `options` to have more choices. # Exit Codes: - `0` : User pressed `Ok`. - `1` : User pressed `Cancel` or closed the dialog. -- `2` : The user pressed an extra button. The output contains the index of that - button. +- `2` : The user pressed an extra button. + The output contains the index of that button. ``` dialog message @@ -54,6 +55,6 @@ dialog message ### SEE ALSO -- [dialog](dialog.md) - Githooks dialog application similar to `zenity`. +* [dialog](dialog.md) - Githooks dialog application similar to `zenity`. -###### Auto generated by spf13/cobra +###### Auto generated by spf13/cobra diff --git a/docs/dialog/dialog_notify.md b/docs/dialog/dialog_notify.md index 68c40ca5..3226903c 100644 --- a/docs/dialog/dialog_notify.md +++ b/docs/dialog/dialog_notify.md @@ -38,6 +38,6 @@ dialog notify ### SEE ALSO -- [dialog](dialog.md) - Githooks dialog application similar to `zenity`. +* [dialog](dialog.md) - Githooks dialog application similar to `zenity`. -###### Auto generated by spf13/cobra +###### Auto generated by spf13/cobra diff --git a/docs/dialog/dialog_options.md b/docs/dialog/dialog_options.md index 3aeeec7c..546492c8 100644 --- a/docs/dialog/dialog_options.md +++ b/docs/dialog/dialog_options.md @@ -6,17 +6,18 @@ Shows a options selection dialog. Shows a list selection dialog similar to `zenity`. -Extra buttons are only supported on Unix and Windows. If not using `--multiple` -you can also use the button style options with `--style 1` which uses buttons -instead of a listbox. +Extra buttons are only supported on Unix and Windows. +If not using `--multiple` you can also use the +button style options with `--style 1` which uses buttons instead +of a listbox. # Exit Codes: - `0` : `Ok` was pressed. The output contains the indices of the selected items - separated by `--separator`. + separated by `--separator`. - `1` : `Cancel` was pressed or the dialog was closed. -- `2` : The user pressed an extra button. The output contains the index of that - button on the first line. +- `2` : The user pressed an extra button. + The output contains the index of that button on the first line. - `5` : The dialog was closed due to timeout. ``` @@ -61,6 +62,6 @@ dialog options ### SEE ALSO -- [dialog](dialog.md) - Githooks dialog application similar to `zenity`. +* [dialog](dialog.md) - Githooks dialog application similar to `zenity`. -###### Auto generated by spf13/cobra +###### Auto generated by spf13/cobra diff --git a/githooks/cmd/config/config.go b/githooks/cmd/config/config.go index 05942b78..7375b9cc 100644 --- a/githooks/cmd/config/config.go +++ b/githooks/cmd/config/config.go @@ -216,6 +216,34 @@ func runSearchDir(ctx *ccm.CmdContext, opts *SetOptions) { } } +func runContainerManagerTypes(ctx *ccm.CmdContext, opts *SetOptions, gitOpts *GitOptions) { + opt := hooks.GitCKContainerManager + localOrGlobal := "locally" // nolint: goconst + if gitOpts.Global { + localOrGlobal = "globally" // nolint: goconst + } + + scope := wrapToGitScope(ctx.Log, gitOpts) + switch { + case opts.Set: + val := strings.Join(opts.Values, ",") + err := ctx.GitX.SetConfig(opt, val, scope) + ctx.Log.AssertNoErrorPanicF(err, "Could not set Git config '%s'.", opt) + ctx.Log.InfoF("Container manager types is set to '%s' %s.", val, localOrGlobal) + + case opts.Reset: + err := ctx.GitX.UnsetConfig(opt, scope) + ctx.Log.AssertNoErrorPanicF(err, "Could not unset Git config '%s'.", opt) + ctx.Log.InfoF("Container manager types is unset %s.", localOrGlobal) + + case opts.Print: + conf := ctx.GitX.GetConfig(opt, scope) + ctx.Log.InfoF("Container manager types is set to '%s' %s.", conf, localOrGlobal) + default: + cm.Panic("Wrong arguments.") + } +} + func runContainerizedHooksEnable(ctx *ccm.CmdContext, opts *SetOptions, gitOpts *GitOptions) { opt := hooks.GitCKContainerizedHooksEnabled localOrGlobal := "locally" // nolint: goconst @@ -725,6 +753,33 @@ func configContainerizedHooksEnabledCmd( configCmd.AddCommand(ccm.SetCommandDefaults(ctx.Log, enableCmd)) } +func configContainerManagerTypesCmd( + ctx *ccm.CmdContext, + configCmd *cobra.Command, + setOpts *SetOptions, + gitOpts *GitOptions) { + + enableCmd := &cobra.Command{ + Use: "container-manager-types [flags]", + Short: "Set container manger types to use (see 'enable-containerized-hooks').", + Long: `Set container manager types to use where the first valid one is taken and used. +If unset 'docker' is used.`, + Run: func(cmd *cobra.Command, args []string) { + if !gitOpts.Local && !gitOpts.Global { + gitOpts.Local = true + } + + runContainerManagerTypes(ctx, setOpts, gitOpts) + }} + + optsPSR := createOptionMap(true, false, true) + + configSetOptions(enableCmd, setOpts, &optsPSR, ctx.Log, 1, 2) // nolint: gomnd + enableCmd.Flags().BoolVar(&gitOpts.Local, "local", false, "Use the local Git configuration (default).") + enableCmd.Flags().BoolVar(&gitOpts.Global, "global", false, "Use the global Git configuration.") + configCmd.AddCommand(ccm.SetCommandDefaults(ctx.Log, enableCmd)) +} + func configSearchDirCmd(ctx *ccm.CmdContext, configCmd *cobra.Command, setOpts *SetOptions) { searchDirCmd := &cobra.Command{ @@ -1076,6 +1131,7 @@ func NewCmd(ctx *ccm.CmdContext) *cobra.Command { configCloneBranchCmd(ctx, configCmd, &setOpts) configContainerizedHooksEnabledCmd(ctx, configCmd, &setOpts, &gitOpts) + configContainerManagerTypesCmd(ctx, configCmd, &setOpts, &gitOpts) configSharedCmd(ctx, configCmd, &setOpts, &gitOpts) configDisableSharedHooksUpdate(ctx, configCmd, &setOpts, &gitOpts) diff --git a/githooks/container/executable.go b/githooks/container/executable.go index a2d7b2b8..771f51a8 100644 --- a/githooks/container/executable.go +++ b/githooks/container/executable.go @@ -39,7 +39,8 @@ func (e *ContainerizedExecutable) GetEnvironment() []string { // ApplyEnvironmentToArgs applies all environment variables `env` to the arguments of // the call to be able to forward them into the container. func (e *ContainerizedExecutable) ApplyEnvironmentToArgs(env []string) { - if e.containerType == ContainerManagerTypeV.Docker { + if e.containerType == ContainerManagerTypeV.Docker || + e.containerType == ContainerManagerTypeV.Podman { for i := range env { e.ArgsEnv = append(e.ArgsEnv, "-e", env[i]) } @@ -48,25 +49,35 @@ func (e *ContainerizedExecutable) ApplyEnvironmentToArgs(env []string) { } } +const dindMsg = "Note: If you are inside a container ALREADY and want\n" + + "to run hooks containerized (docker-in-docker) you can ONLY do\n" + + "this by specifying host-machine paths (or a container volume) \n" + + "for two locations:\n\n" + + " - path (or container volume) and relative base path pointing to the \n" + + " workspace repository on the host machine where Githooks runs in,\n\n" + + " - path (or container volume) pointing to the shared hooks \n" + + " location on the host machine, e.g `~/.githooks/shared`.\n\n" + + "Check the Githooks manual for instructions on docker-in-docker." + // GetExitCodeHelp gets help for any non-zero exit code if needed. func (e *ContainerizedExecutable) ResolveExitCode(exitCode int) string { if e.containerType == ContainerManagerTypeV.Docker { switch exitCode { case 125: // nolint: gomnd - return "The docker daemon reported an error.\n" + - "Note: If you are inside a container ALREADY and want\n" + - "to run hooks containerized (docker-in-docker) you can ONLY do\n" + - "this by specifying host-machine paths (or a container volume) \n" + - "for two locations:\n\n" + - " - path (or container volume) and relative base path pointing to the \n" + - " workspace repository on the host machine where Githooks runs in,\n\n" + - " - path (or container volume) pointing to the shared hooks \n" + - " location on the host machine, e.g `~/.githooks/shared`.\n\n" + - "Check the Githooks manual for instructions on docker-in-docker." + return "The docker daemon reported an error.\n" + dindMsg case 126: // nolint: gomnd return "Docker command could not be invoked (permission problem?)." case 127: // nolint: gomnd - return "Command could not be found." + return "Command inside container could not be found." + } + } else if e.containerType == ContainerManagerTypeV.Podman { + switch exitCode { + case 125: // nolint: gomnd + return "The podman reported an error.\n" + dindMsg + case 126: // nolint: gomnd + return "Podman command could not be invoked (permission problem?)." + case 127: // nolint: gomnd + return "Command inside container could not be found." } } diff --git a/githooks/container/manager-container_test.go b/githooks/container/manager-container_test.go new file mode 100644 index 00000000..c3cda20b --- /dev/null +++ b/githooks/container/manager-container_test.go @@ -0,0 +1,110 @@ +//go:build test_docker || test_podman + +package container + +import ( + "io" + "os" + "testing" + + cm "github.com/gabyx/githooks/githooks/common" + "github.com/stretchr/testify/assert" +) + +func testDockerManager(t *testing.T, cmd string) { + + mgr, err := NewManager(cmd) + if !assert.Nil(t, err, "%s", err) { + assert.FailNow(t, "Cannot continue.") + } + + err = mgr.ImagePull("alpine:latest") + assert.Nil(t, err, "Could not pull image: %s", err) + + err = mgr.ImagePull("alpine:latests") + assert.NotNil(t, err, "Pull image should have failed: %s", err) + + err = mgr.ImageTag("alpine:latest", "alpine:mine") + assert.Nil(t, err, "Tagging image should not have failed: %s", err) + + exists, err := mgr.ImageExists("alpine:latest") + assert.Nil(t, err) + assert.True(t, exists) + + exists, err = mgr.ImageExists("alpine:mine") + assert.Nil(t, err) + assert.True(t, exists) + + exists, err = mgr.ImageExists("alpine:latests") + assert.Nil(t, err) + assert.False(t, exists) + + err = mgr.ImageRemove("alpine:latest") + assert.Nil(t, err) +} + +func testDockerManagerBuild(t *testing.T, cmd string) { + mgr, err := NewManager(cmd) + if !assert.Nil(t, err, "%s", err) { + assert.FailNow(t, "Cannot continue.") + } + + file, err := os.CreateTemp("", "") + assert.Nil(t, err) + defer os.Remove(file.Name()) + dockerfile := ` +FROM alpine:latest as stage1 + +FROM stage1 as stage2 +RUN apk add bash +` + _, _ = io.WriteString(file, dockerfile) + file.Close() + + log, err := cm.CreateLogContext(false) + assert.Nil(t, err) + + exists, err := mgr.ImageExists("alpine:mine-special") + assert.Nil(t, err) + assert.False(t, exists) + + _, err = mgr.ImageBuild(log, file.Name(), ".", "stage2", "alpine:mine-special") + assert.Nil(t, err, "Build failed: '%s'", err) + + exists, err = mgr.ImageExists("alpine:mine-special") + assert.Nil(t, err) + assert.True(t, exists) + + err = mgr.ImageRemove("alpine:mine-special") + assert.Nil(t, err) +} + +func testDockerManagerBuildFail(t *testing.T, cmd string) { + + mgr, err := NewManager(cmd) + if !assert.Nil(t, err, "%s", err) { + assert.FailNow(t, "Cannot continue.") + } + + file, err := os.CreateTemp("", "") + assert.Nil(t, err) + defer os.Remove(file.Name()) + dockerfile := ` +FROM alpine:latest as stage1 + +FROM stage1 as stage2 +RUN apk add bashhhh +` + _, _ = io.WriteString(file, dockerfile) + file.Close() + + log, err := cm.CreateLogContext(false) + assert.Nil(t, err) + + _, err = mgr.ImageBuild(log, file.Name(), ".", "stage2", "alpine:mine-special") + assert.NotNil(t, err, "Build failed: '%s'", err) + + exists, err := mgr.ImageExists("alpine:mine-special") + assert.Nil(t, err) + assert.False(t, exists) +} diff --git a/githooks/container/manager-docker.go b/githooks/container/manager-docker.go index 561f6522..df2ff21b 100644 --- a/githooks/container/manager-docker.go +++ b/githooks/container/manager-docker.go @@ -23,6 +23,9 @@ type ManagerDocker struct { uid string gid string + + // Only used to wrap podman into this structure as well. + mgrType ContainerManagerType } // ImagePull pulls an image with reference `ref`. @@ -89,9 +92,9 @@ func (m *ManagerDocker) NewHookRunExec( cm.DebugAssert(filepath.IsAbs(workspaceDir), "Workspace dir must be an absolute path.") cm.DebugAssert(filepath.IsAbs(workspaceHookDir), "Workspace hook dir must be an abs path.") - containerExec := ContainerizedExecutable{containerType: ContainerManagerTypeV.Docker} + containerExec := ContainerizedExecutable{containerType: m.mgrType} - containerExec.Cmd = dockerCmd + containerExec.Cmd = m.cmdCtx.GetBaseCmd() // Mount: Working directory. // The repository where the hook runs. @@ -176,12 +179,23 @@ func (m *ManagerDocker) NewHookRunExec( strs.Fmt("%v:%v:ro", mntWSSharedSrc, mntWSSharedDest)) // Set the mount for the shared directory. } - if runtime.GOOS != cm.WindowsOsName && - runtime.GOOS != "darwin" { - // On non win/mac, execute as the user/group from the host. - containerExec.ArgsPre = append(containerExec.ArgsPre, - "--user", - strs.Fmt("%v:%v", m.uid, m.gid)) + if m.mgrType == ContainerManagerTypeV.Docker { + if runtime.GOOS != cm.WindowsOsName && + runtime.GOOS != "darwin" { + // On non win/mac, execute as the user/group from the host. + // This will make all volume mounts have the same user/group + // in the container. + // The entrypoint https://github.com/FooBarWidget/matchhostfsowner + // will take care to adjust a specified container user + // to the one running. + containerExec.ArgsPre = append(containerExec.ArgsPre, + "--user", + strs.Fmt("%v:%v", m.uid, m.gid)) + } + } else if m.mgrType == ContainerManagerTypeV.Podman { + // With rootless podman its much easier to make the volumes + // match the host user which launch this Githook. + containerExec.ArgsPre = append(containerExec.ArgsPre, "--userns=keep-id:uid=1000,gid=1000") } // Set env. variable denoting we are running over a container. @@ -206,11 +220,7 @@ func IsDockerAvailable() bool { return err == nil } - -func NewManagerDocker() (mgr IManager, err error) { - if !IsDockerAvailable() { - return nil, &ManagerNotAvailableError{dockerCmd} - } +func newManagerDocker(cmd string, mgrType ContainerManagerType) (mgr *ManagerDocker, err error) { var uid, gid string @@ -228,8 +238,17 @@ func NewManagerDocker() (mgr IManager, err error) { gid = usr.Gid } - cmdCtx := cm.NewCommandCtxBuilder().SetBaseCmd(dockerCmd).EnableCaptureError().Build() - mgr = &ManagerDocker{cmdCtx: cmdCtx, uid: uid, gid: gid} + cmdCtx := cm.NewCommandCtxBuilder().SetBaseCmd(cmd).EnableCaptureError().Build() + mgr = &ManagerDocker{cmdCtx: cmdCtx, uid: uid, gid: gid, mgrType: mgrType} return } + +// NewManagerDocker return a new mangers for Docker images. +func NewManagerDocker() (mgr IManager, err error) { + if !IsDockerAvailable() { + return nil, &ManagerNotAvailableError{dockerCmd} + } + + return newManagerDocker(dockerCmd, ContainerManagerTypeV.Docker) +} diff --git a/githooks/container/manager-docker_test.go b/githooks/container/manager-docker_test.go index d9876970..07aa525f 100644 --- a/githooks/container/manager-docker_test.go +++ b/githooks/container/manager-docker_test.go @@ -1,100 +1,19 @@ +//go:build test_docker && !test_podman + package container import ( - "io" - "os" "testing" - - cm "github.com/gabyx/githooks/githooks/common" - "github.com/stretchr/testify/assert" ) func TestDockerManager(t *testing.T) { - mgr, err := NewManager("docker") - assert.Nil(t, err) - - err = mgr.ImagePull("alpine:latest") - assert.Nil(t, err, "Could not pull image: %s", err) - - err = mgr.ImagePull("alpine:latests") - assert.NotNil(t, err, "Pull image should have failed: %s", err) - - err = mgr.ImageTag("alpine:latest", "alpine:mine") - assert.Nil(t, err, "Tagging image should not have failed: %s", err) - - exists, err := mgr.ImageExists("alpine:latest") - assert.Nil(t, err) - assert.True(t, exists) - - exists, err = mgr.ImageExists("alpine:mine") - assert.Nil(t, err) - assert.True(t, exists) - - exists, err = mgr.ImageExists("alpine:latests") - assert.Nil(t, err) - assert.False(t, exists) - - err = mgr.ImageRemove("alpine:latest") - assert.Nil(t, err) + testDockerManager(t, "docker") } func TestDockerManagerBuild(t *testing.T) { - mgr, err := NewManager("docker") - assert.Nil(t, err) - - file, err := os.CreateTemp("", "") - assert.Nil(t, err) - defer os.Remove(file.Name()) - dockerfile := ` -FROM alpine:latest as stage1 - -FROM stage1 as stage2 -RUN apk add bash -` - _, _ = io.WriteString(file, dockerfile) - file.Close() - - log, err := cm.CreateLogContext(false) - assert.Nil(t, err) - - exists, err := mgr.ImageExists("alpine:mine-special") - assert.Nil(t, err) - assert.False(t, exists) - - _, err = mgr.ImageBuild(log, file.Name(), ".", "stage2", "alpine:mine-special") - assert.Nil(t, err, "Build failed: '%s'", err) - - exists, err = mgr.ImageExists("alpine:mine-special") - assert.Nil(t, err) - assert.True(t, exists) - - err = mgr.ImageRemove("alpine:mine-special") - assert.Nil(t, err) + testDockerManagerBuild(t, "docker") } func TestDockerManagerBuildFail(t *testing.T) { - mgr, err := NewManager("docker") - assert.Nil(t, err) - - file, err := os.CreateTemp("", "") - assert.Nil(t, err) - defer os.Remove(file.Name()) - dockerfile := ` -FROM alpine:latest as stage1 - -FROM stage1 as stage2 -RUN apk add bashhhh -` - _, _ = io.WriteString(file, dockerfile) - file.Close() - - log, err := cm.CreateLogContext(false) - assert.Nil(t, err) - - _, err = mgr.ImageBuild(log, file.Name(), ".", "stage2", "alpine:mine-special") - assert.NotNil(t, err, "Build failed: '%s'", err) - - exists, err := mgr.ImageExists("alpine:mine-special") - assert.Nil(t, err) - assert.False(t, exists) + testDockerManagerBuildFail(t, "docker") } diff --git a/githooks/container/manager-podman.go b/githooks/container/manager-podman.go new file mode 100644 index 00000000..d9180141 --- /dev/null +++ b/githooks/container/manager-podman.go @@ -0,0 +1,75 @@ +package container + +import ( + "os/exec" + + cm "github.com/gabyx/githooks/githooks/common" +) + +const ( + podmanCmd = "podman" +) + +type ManagerPodman struct { + docker ManagerDocker +} + +// ImagePull pulls an image with reference `ref`. +func (m *ManagerPodman) ImagePull(ref string) (err error) { + return m.docker.ImagePull(ref) +} + +// ImageTag tags an image with reference `refSrc` to reference `refTarget`. +func (m *ManagerPodman) ImageTag(refSrc string, refTarget string) (err error) { + return m.docker.ImageTag(refSrc, refTarget) +} + +// ImageBuild builds the stage `stage` +// of an image from `dockerfile` in context path `context` and tags +// it with reference `ref`. +func (m *ManagerPodman) ImageBuild( + log cm.ILogContext, + dockerfile string, + context string, + stage string, + ref string) (string, error) { + return m.docker.ImageBuild(log, dockerfile, context, stage, ref) +} + +// ImageExists checks if the image with reference `ref` exists. +func (m *ManagerPodman) ImageExists(ref string) (exists bool, err error) { + return m.docker.ImageExists(ref) +} + +// ImageRemove removes an image with reference `ref`. +func (m *ManagerPodman) ImageRemove(ref string) (err error) { + return m.docker.ImageRemove(ref) +} + +// NewHookRunExec runs a hook over a container. +func (m *ManagerPodman) NewHookRunExec( + ref string, + workspaceDir string, + workspaceHookDir string, + hookExec cm.IExecutable, + attachStdIn bool, + allocateTTY bool, +) (cm.IExecutable, error) { + return m.docker.NewHookRunExec(ref, workspaceDir, workspaceHookDir, hookExec, attachStdIn, allocateTTY) +} + +// IsPodmanAvailable returns if podman is available. +func IsPodmanAvailable() bool { + _, err := exec.LookPath(podmanCmd) + + return err == nil +} + +// NewManagerPodman returns a manger to manage images with podman. +func NewManagerPodman() (IManager, error) { + if !IsPodmanAvailable() { + return nil, &ManagerNotAvailableError{podmanCmd} + } + + return newManagerDocker(podmanCmd, ContainerManagerTypeV.Podman) +} diff --git a/githooks/container/manager-podman_test.go b/githooks/container/manager-podman_test.go new file mode 100644 index 00000000..6f5512fe --- /dev/null +++ b/githooks/container/manager-podman_test.go @@ -0,0 +1,19 @@ +//go:build !test_docker && test_podman + +package container + +import ( + "testing" +) + +func TestPodmanManager(t *testing.T) { + testDockerManager(t, "podman") +} + +func TestPodmanManagerBuild(t *testing.T) { + testDockerManagerBuild(t, "podman") +} + +func TestPodmanManagerBuildFail(t *testing.T) { + testDockerManagerBuildFail(t, "podman") +} diff --git a/githooks/container/manager.go b/githooks/container/manager.go index f6fea86e..4ba05165 100644 --- a/githooks/container/manager.go +++ b/githooks/container/manager.go @@ -1,6 +1,8 @@ package container import ( + "strings" + cm "github.com/gabyx/githooks/githooks/common" strs "github.com/gabyx/githooks/githooks/strings" ) @@ -20,7 +22,7 @@ const EnvVariableContainerRun = "GITHOOKS_CONTAINER_RUN" type ContainerManagerType int type containerManagerType struct { Docker ContainerManagerType - Podman ContainerManagerType // Not yet supported. + Podman ContainerManagerType } // ContainerManagerTypeV enumerates all container managers supported so far. @@ -52,20 +54,36 @@ type IManager interface { // NewManager creates a container manager of type `manager`. // If empty `docker` is taken. -// Currently only `docker` is supported. +// Can be a comma-separated string e.g. `podman,docker` to try +// to use the one which first can be constructed. +// Currently only `docker` and `podman` is supported. func NewManager(manager string) (mgr IManager, err error) { if strs.IsEmpty(manager) { manager = "docker" } - switch manager { - case "docker": - mgr, err = NewManagerDocker() - default: - return nil, cm.ErrorF("Container manager '%s' not supported.", manager) + mgrs := strings.Split(manager, ",") + + var e error + for _, manager := range mgrs { + switch strings.TrimSpace(manager) { + case "docker": + mgr, e = NewManagerDocker() + case "podman": + mgr, e = NewManagerPodman() + default: + e = cm.ErrorF("Container manager '%s' not supported.", manager) + } + + // If we could construct it, immediately return it. + if e == nil { + return mgr, nil + } + + err = cm.CombineErrors(err, e) } - return + return nil, cm.CombineErrors(err, cm.Error("Container manager could not be validated.")) } diff --git a/tests/exec-testsuite.sh b/tests/exec-testsuite.sh index 8f8165d1..489b7648 100755 --- a/tests/exec-testsuite.sh +++ b/tests/exec-testsuite.sh @@ -20,13 +20,16 @@ export CGO_ENABLED=0 go mod vendor go generate -mod vendor ./... +regex="${1:-".*"}" +tags="${2:-"test_docker"}" + if [ -d /cover ]; then - if ! go test ./... -test.coverprofile /cover/tests.cov -covermode=count -coverpkg ./...; then + if ! go test -tags "$tags" -run "$regex" ./... -test.coverprofile /cover/tests.cov -covermode=count -coverpkg ./...; then echo "! Go testsuite reported errors." >&2 exit 1 fi else - if ! go test -v ./...; then + if ! go test -tags "$tags" -run "$regex" -v ./...; then echo "! Go testsuite reported errors." >&2 exit 1 fi diff --git a/tests/test-testsuite-podman.sh b/tests/test-testsuite-podman.sh new file mode 100755 index 00000000..f87166f0 --- /dev/null +++ b/tests/test-testsuite-podman.sh @@ -0,0 +1,86 @@ +#!/usr/bin/env bash + +set -e +set -u + +function cleanUp() { + # shellcheck disable=SC2317 + docker rmi "githooks:testsuite" &>/dev/null || true +} + +trap cleanUp EXIT + +cat < /etc/subuid && \ + echo "test-user:100000:65536" > /etc/subgid + +RUN temp=\$(mktemp) && \ + sed -E 's/rc_cgroup_mode=.*/rc_cgroup_mode="unified"/g' /etc/rc.conf >"\$temp" && \ + mv "\$temp" /etc/rc.conf + +RUN rc-service cgroups start || true +RUN rc-update add cgroups + +RUN mkdir -p "/home/test-user/.config/containers" && \ + mkdir -p "/home/test-user/.local/share/containers" + +RUN (echo '[containers]' && \ + echo 'netns="host"' && \ + echo 'userns="host"' && \ + echo 'ipcns="host"' && \ + echo 'utsns="host"' && \ + echo 'cgroupns="host"' && \ + echo 'cgroups="disabled"' && \ + echo 'log_driver = "k8s-file"' && \ + echo '[engine]' && \ + echo 'cgroup_manager = "cgroupfs"' && \ + echo 'events_logger="file"' && \ + echo 'runtime="crun"') >/etc/containers/containers.conf + +RUN (echo "[containers]" && \ + echo "volumes = [" && \ + echo " \"/proc:/proc\"," && \ + echo "]" && \ + echo "default_sysctls = []") >"/home/test-user/.config/containers/containers.conf" + +RUN (echo "[storage]" && \ + echo "driver = \"overlay\"" && \ + echo "[storage.options.overlay]" && \ + echo "mount_program = \"\$(which fuse-overlayfs)\"") >"/home/test-user/.config/containers/storage.conf" + +RUN chown -R "test-user:test-user" "/home/test-user/.config/containers" && \ + chown -R "test-user:test-user" "/home/test-user/.local/share/containers" + +USER "test-user" + +# CVE https://github.blog/2022-10-18-git-security-vulnerabilities-announced/#cve-2022-39253 +RUN git config --global protocol.file.allow always +# Git refuses to do stuff in this mounted directory. +RUN git config --global safe.directory /githooks + +ENV DOCKER_RUNNING=true +EOF + +if ! docker run --privileged --rm -it \ + -v "$(pwd)":/githooks \ + -v "/var/run/docker.sock:/var/run/docker.sock" \ + -w /githooks githooks:testsuite \ + tests/exec-testsuite.sh ".*Podman.*" "test_podman"; then + + echo "! Check rules had failures." + exit 1 +fi + +exit 0