Skip to content
Merged
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
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,21 @@ Add new entries to the top of the "Unreleased" section.

## Unreleased

### Added

- `devpod up --git-user-name` and `--git-user-email` set the git identity that the workspace's dev container
gets, and save it with the workspace. An empty value removes the saved one.

### Fixed

- The git identity that DevPod gives a dev container is the one git uses in the repository the workspace comes
from (its local folder, or the directory `devpod up` runs in), including the repository's config and matching
`include` and `includeIf` directives. DevPod used to read only `~/.gitconfig` without its includes, an issue
inherited from upstream, so a work identity kept in an `includeIf` block was ignored and workspaces committed
with the global (often personal) email. `devpod up` saves the repository's identity with the workspace, so
later connections from anywhere use the same one; outside a repository it saves nothing, and the container
gets the global identity (now with its includes) when it connects. A dev container that already has an
identity keeps it, including those of existing workspaces: use `devpod up --recreate` to give it the new one.
- `devpod up` of a Kubernetes workspace recreates the workspace's pod when it is dead (its phase is `Failed`,
`Succeeded` or `Unknown`, its dev container has terminated, or it is being deleted), keeping the workspace's
persistent volume claim. It used to reuse such a pod, for example after the node restarted, and then time out
Expand Down
1 change: 1 addition & 0 deletions cmd/build.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ func NewBuildCmd(flags *flags.GlobalFlags) *cobra.Command {
cmd.DevContainerPath,
sshConfigPath,
nil,
nil,
cmd.UID,
false,
log.Default,
Expand Down
16 changes: 16 additions & 0 deletions cmd/up.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,10 @@ type UpCmd struct {

SSHConfigPath string

GitUserName string
GitUserEmail string
GitUser workspace2.GitUserOptions

DotfilesSource string
DotfilesScript string
DotfilesScriptEnv []string // Key=Value to pass to install script
Expand Down Expand Up @@ -85,6 +89,15 @@ func NewUpCmd(f *flags.GlobalFlags) *cobra.Command {
ctx, cancel := WithSignals(cobraCmd.Context())
defer cancel()

// an empty directory makes DevPod use the global git identity
cmd.GitUser.Dir, _ = os.Getwd()
if cobraCmd.Flags().Changed("git-user-name") {
cmd.GitUser.Name = &cmd.GitUserName
}
if cobraCmd.Flags().Changed("git-user-email") {
cmd.GitUser.Email = &cmd.GitUserEmail
}

client, logger, err := cmd.prepareClient(ctx, devPodConfig, args)
if err != nil {
return fmt.Errorf("prepare workspace client: %w", err)
Expand Down Expand Up @@ -123,6 +136,8 @@ func NewUpCmd(f *flags.GlobalFlags) *cobra.Command {
upCmd.Flags().Var(&cmd.GitCloneStrategy, "git-clone-strategy", "The git clone strategy DevPod uses to checkout git based workspaces. Can be full (default), blobless, treeless or shallow")
upCmd.Flags().BoolVar(&cmd.GitCloneRecursiveSubmodules, "git-clone-recursive-submodules", false, "If true will clone git submodule repositories recursively")
upCmd.Flags().StringVar(&cmd.GitSSHSigningKey, "git-ssh-signing-key", "", "The ssh key to use when signing git commits. Used to explicitly setup DevPod's ssh signature forwarding with given key. Should be same format as value of `git config user.signingkey`")
upCmd.Flags().StringVar(&cmd.GitUserName, "git-user-name", "", "The git user.name to give the workspace's container, saved with the workspace. An empty value removes the saved one. Without one, DevPod saves the one git uses in the workspace's repository: its local folder, or the directory DevPod runs in")
upCmd.Flags().StringVar(&cmd.GitUserEmail, "git-user-email", "", "The git user.email to give the workspace's container, saved with the workspace. An empty value removes the saved one. Without one, DevPod saves the one git uses in the workspace's repository: its local folder, or the directory DevPod runs in")
upCmd.Flags().StringVar(&cmd.FallbackImage, "fallback-image", "", "The fallback image to use if no devcontainer configuration has been detected")
upCmd.Flags().BoolVar(&cmd.DisableDaemon, "disable-daemon", false, "If enabled, will not install a daemon into the target machine to track activity")
upCmd.Flags().StringVar(&cmd.Source, "source", "", "Optional source for the workspace. E.g. git:https://github.com/my-org/my-repo")
Expand Down Expand Up @@ -1095,6 +1110,7 @@ func (cmd *UpCmd) prepareClient(ctx context.Context, devPodConfig *config.Config
cmd.DevContainerPath,
cmd.SSHConfigPath,
source,
&cmd.GitUser,
cmd.UID,
true,
logger,
Expand Down
26 changes: 26 additions & 0 deletions docs/pages/developing-in-workspaces/credentials.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,32 @@ Both only work while you are connected to the workspace, because the credentials
workspaces that must keep working without you, such as ones running AI agents, see
[Run Workspaces Unattended](./unattended-workspaces.mdx).

## Git identity

DevPod gives the dev container your git `user.name` and `user.email`, unless the container user's `~/.gitconfig`
already has them (for example from the container's own start command). It uses the identity git uses in the
repository the workspace comes from: the workspace's local folder or, for other sources, the directory you run
`devpod up` from. That includes the repository's config and the `include` and `includeIf` directives that match
it, so running `devpod up` from inside a work repository gives the workspace your work identity.

`devpod up` saves that identity with the workspace, and running it again, from anywhere, doesn't change it. If
`devpod up` doesn't run in a repository (for example from the desktop app, for a git source), it saves nothing,
and the container gets your global identity, following the global config's includes, when it connects.

Git ignores a repository it doesn't trust, for example one on a network share or owned by another user, and then
DevPod can't use its identity. Add it to git's
[`safe.directory`](https://git-scm.com/docs/git-config#Documentation/git-config.txt-safedirectory) to fix that.

To set the identity explicitly, pass it to `devpod up`. An empty value removes the saved one, so that DevPod looks
it up again:

```
devpod up --git-user-email me+devpod@company.com --git-user-name "My Name" my-workspace
```

A dev container that already has an identity keeps it, and so do the dev containers of workspaces created before
DevPod saved the identity. Use `devpod up --recreate` to give the container the new one.

## Docker credentials

DevPod will make docker registry credentials available inside the dev container through a [docker credentials helper](https://docs.docker.com/engine/reference/commandline/login/#credential-helpers). This allows you to pull and push images from and to private registries from within the dev container.
Expand Down
28 changes: 22 additions & 6 deletions pkg/agent/tunnelserver/tunnelserver.go
Original file line number Diff line number Diff line change
Expand Up @@ -186,12 +186,7 @@ func (t *tunnelServer) DockerCredentials(ctx context.Context, message *tunnel.Me
}

func (t *tunnelServer) GitUser(ctx context.Context, empty *tunnel.Empty) (*tunnel.Message, error) {
gitUser, err := gitcredentials.GetUser("")
if err != nil {
return nil, err
}

out, err := json.Marshal(gitUser)
out, err := json.Marshal(t.gitUser())
if err != nil {
return nil, err
}
Expand All @@ -201,6 +196,27 @@ func (t *tunnelServer) GitUser(ctx context.Context, empty *tunnel.Empty) (*tunne
}, nil
}

// gitUser returns the identity `devpod up` saved with the workspace. A value it didn't save,
// because it wasn't given and the workspace doesn't come from a repository, is the global one.
func (t *tunnelServer) gitUser() *gitcredentials.GitUser {
gitUser := &gitcredentials.GitUser{}
if t.workspace != nil {
gitUser.Name = t.workspace.GitUser.Name
gitUser.Email = t.workspace.GitUser.Email
}
if gitUser.Name == "" || gitUser.Email == "" {
globalUser := gitcredentials.GetGlobalUser()
if gitUser.Name == "" {
gitUser.Name = globalUser.Name
}
if gitUser.Email == "" {
gitUser.Email = globalUser.Email
}
}

return gitUser
}

func (t *tunnelServer) GitCredentials(ctx context.Context, message *tunnel.Message) (*tunnel.Message, error) {
if !t.allowGitCredentials {
return nil, fmt.Errorf("git credentials forbidden")
Expand Down
50 changes: 50 additions & 0 deletions pkg/agent/tunnelserver/tunnelserver_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,14 @@ package tunnelserver

import (
"context"
"os"
"path/filepath"
"testing"

"github.com/loft-sh/devpod/pkg/agent/tunnel"
"github.com/loft-sh/devpod/pkg/log"
provider2 "github.com/loft-sh/devpod/pkg/provider"
"gotest.tools/assert"
)

// TestGitCredentials_NoWorkspace checks that a git credentials request to a
Expand All @@ -30,3 +34,49 @@ func TestGitCredentials_NoWorkspace(t *testing.T) {
// a panic is not.
_, _ = s.GitCredentials(context.Background(), request)
}

// TestGitUser checks that the container gets the identity saved with the
// workspace, and the global one for what the workspace doesn't have.
func TestGitUser(t *testing.T) {
home := t.TempDir()
globalConfig := filepath.Join(home, ".gitconfig")
err := os.WriteFile(globalConfig, []byte("[user]\n\tname = Global\n\temail = global@personal.com\n"), 0600)
assert.NilError(t, err)
t.Setenv("HOME", home)
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
t.Setenv("GIT_CONFIG_GLOBAL", globalConfig)
t.Setenv("GIT_CONFIG_NOSYSTEM", "1")

testCases := []struct {
name string
workspace *provider2.Workspace
expected string
}{
{
name: "saved identity",
workspace: &provider2.Workspace{GitUser: provider2.WorkspaceGitUser{Name: "Work", Email: "me@company.com"}},
expected: `{"name":"Work","email":"me@company.com"}`,
},
{
name: "partly saved identity",
workspace: &provider2.Workspace{GitUser: provider2.WorkspaceGitUser{Email: "me@company.com"}},
expected: `{"name":"Global","email":"me@company.com"}`,
},
{
name: "workspace without a saved identity",
workspace: &provider2.Workspace{},
expected: `{"name":"Global","email":"global@personal.com"}`,
},
{
name: "no workspace",
expected: `{"name":"Global","email":"global@personal.com"}`,
},
}

for _, testCase := range testCases {
s := New(log.Discard, WithWorkspace(testCase.workspace))
response, err := s.GitUser(context.Background(), &tunnel.Empty{})
assert.NilError(t, err, testCase.name)
assert.Equal(t, response.Message, testCase.expected, testCase.name)
}
}
91 changes: 55 additions & 36 deletions pkg/gitcredentials/gitcredentials.go
Original file line number Diff line number Diff line change
Expand Up @@ -145,59 +145,78 @@ func ToString(credentials *GitCredentials) string {
}

func SetUser(userName string, user *GitUser) error {
if user.Name != "" {
shellCommand := fmt.Sprintf(`git config --global user.name "%s"`, user.Name)
args := []string{}
if userName != "" {
args = append(args, "su", userName, "-c", shellCommand)
} else {
args = append(args, "sh", "-c", shellCommand)
}
if user.Name == "" && user.Email == "" {
return nil
}

out, err := exec.Command(args[0], args[1:]...).CombinedOutput()
if err != nil {
return fmt.Errorf("set user.name '%s': %w", strings.Join(args, " "), command.WrapCommandError(out, err))
}
gitConfigPath, err := getGlobalGitConfigPath(userName)
if err != nil {
return err
}

err = file.MkdirAll(userName, filepath.Dir(gitConfigPath), 0755)
if err != nil {
return err
}
if user.Email != "" {
shellCommand := fmt.Sprintf(`git config --global user.email "%s"`, user.Email)
args := []string{}
if userName != "" {
args = append(args, "su", userName, "-c", shellCommand)
} else {
args = append(args, "sh", "-c", shellCommand)

// run git without a shell, so that the values need no quoting
for _, setting := range [][2]string{{"user.name", user.Name}, {"user.email", user.Email}} {
if setting[1] == "" {
continue
}

out, err := exec.Command(args[0], args[1:]...).CombinedOutput()
out, err := exec.Command("git", "config", "--file", gitConfigPath, setting[0], setting[1]).CombinedOutput()
if err != nil {
return fmt.Errorf("set user.email '%s': %w", strings.Join(args, " "), command.WrapCommandError(out, err))
return fmt.Errorf("set %s: %w", setting[0], command.WrapCommandError(out, err))
}
}
return nil

return file.Chown(userName, gitConfigPath)
}

func GetUser(userName string) (*GitUser, error) {
gitUser := &GitUser{}
gitConfigPath, err := getGlobalGitConfigPath(userName)
if err != nil {
return &GitUser{}, fmt.Errorf("get git global dir for %s: %w", userName, err)
}

scopeArgs := []string{"config"}
if userName != "" {
p, err := getGlobalGitConfigPath(userName)
if err != nil {
return gitUser, fmt.Errorf("get git global dir for %s: %w", userName, err)
}
scopeArgs = append(scopeArgs, "--file", p)
} else {
scopeArgs = append(scopeArgs, "--global")
// --file names a single file, so git only follows its includes with --includes
return readUser([]string{"config", "--file", gitConfigPath, "--includes"}), nil
}

// GetRepositoryUser returns the git identity that git uses in the repository that dir is in. That
// includes the repository's config and the include and includeIf directives that match it. It
// fails if dir isn't in a repository that git can use, for example one that git doesn't trust
// because another user owns it (see git's safe.directory).
func GetRepositoryUser(dir string) (*GitUser, error) {
if dir == "" {
return nil, fmt.Errorf("no directory")
}

out, err := exec.Command("git", "-C", dir, "rev-parse", "--git-dir").CombinedOutput()
if err != nil {
return nil, command.WrapCommandError(out, err)
}

// we ignore the error here, because if email is empty we don't care
name, _ := exec.Command("git", append(scopeArgs[:], "user.name")...).Output()
return readUser([]string{"-C", dir, "config"}), nil
}

// GetGlobalUser returns the git identity of the global git config, following its includes.
func GetGlobalUser() *GitUser {
return readUser([]string{"config", "--global", "--includes"})
}

func readUser(configArgs []string) *GitUser {
gitUser := &GitUser{}

// we ignore the error here, because git fails if the value isn't set
name, _ := exec.Command("git", append(configArgs, "user.name")...).Output()
gitUser.Name = strings.TrimSpace(string(name))

email, _ := exec.Command("git", append(scopeArgs[:], "user.email")...).Output()
email, _ := exec.Command("git", append(configArgs, "user.email")...).Output()
gitUser.Email = strings.TrimSpace(string(email))

return gitUser, nil
return gitUser
}

func GetCredentials(requestObj *GitCredentials) (*GitCredentials, error) {
Expand Down
Loading
Loading