From 1e7552609d7709f332452fc6b9a23aaee429ea65 Mon Sep 17 00:00:00 2001 From: David Wahler Date: Thu, 14 Jul 2022 03:04:19 +0000 Subject: [PATCH 1/4] expose SetupComplete() method on agent.agent --- agent/agent.go | 32 ++++++++++++++++++++++++++------ agent/agent_test.go | 34 +++++++++++++++------------------- 2 files changed, 41 insertions(+), 25 deletions(-) diff --git a/agent/agent.go b/agent/agent.go index d596511dd1522..66adb5ed40ba4 100644 --- a/agent/agent.go +++ b/agent/agent.go @@ -45,6 +45,11 @@ const ( ProtocolDial = "dial" ) +type Agent interface { + io.Closer + SetupComplete() bool +} + type Options struct { EnableWireguard bool UploadWireguardKeys UploadWireguardKeys @@ -72,7 +77,7 @@ type Dialer func(ctx context.Context, logger slog.Logger) (Metadata, *peerbroker type UploadWireguardKeys func(ctx context.Context, keys WireguardPublicKeys) error type ListenWireguardPeers func(ctx context.Context, logger slog.Logger) (<-chan peerwg.Handshake, func(), error) -func New(dialer Dialer, options *Options) io.Closer { +func New(dialer Dialer, options *Options) Agent { if options == nil { options = &Options{} } @@ -109,8 +114,10 @@ type agent struct { envVars map[string]string // metadata is atomic because values can change after reconnection. - metadata atomic.Value - startupScript atomic.Bool + metadata atomic.Value + // tracks whether or not we have started/completed initial setup, including any startup script + setupStarted atomic.Bool + setupComplete atomic.Bool sshServer *ssh.Server enableWireguard bool @@ -147,15 +154,16 @@ func (a *agent) run(ctx context.Context) { } a.metadata.Store(metadata) - if a.startupScript.CAS(false, true) { + if a.setupStarted.CAS(false, true) { // The startup script has not ran yet! go func() { - err := a.runStartupScript(ctx, metadata.StartupScript) + defer a.setupComplete.Store(true) + err := a.performInitialSetup(ctx, &metadata) if errors.Is(err, context.Canceled) { return } if err != nil { - a.logger.Warn(ctx, "agent script failed", slog.Error(err)) + a.logger.Warn(ctx, "initial setup failed", slog.Error(err)) } }() } @@ -184,6 +192,14 @@ func (a *agent) run(ctx context.Context) { } } +func (a *agent) performInitialSetup(ctx context.Context, metadata *Metadata) error { + err := a.runStartupScript(ctx, metadata.StartupScript) + if err != nil { + return xerrors.Errorf("agent script failed: %w", err) + } + return nil +} + func (a *agent) runStartupScript(ctx context.Context, script string) error { if script == "" { return nil @@ -755,6 +771,10 @@ func (a *agent) Close() error { return nil } +func (a *agent) SetupComplete() bool { + return a.setupComplete.Load() +} + type reconnectingPTY struct { activeConnsMutex sync.Mutex activeConns map[string]net.Conn diff --git a/agent/agent_test.go b/agent/agent_test.go index 22ee920c2c8f7..392bb659b4959 100644 --- a/agent/agent_test.go +++ b/agent/agent_test.go @@ -212,23 +212,7 @@ func TestAgent(t *testing.T) { StartupScript: fmt.Sprintf("echo %s > %s", content, tempPath), }, 0) - var gotContent string - require.Eventually(t, func() bool { - content, err := os.ReadFile(tempPath) - if err != nil { - return false - } - if len(content) == 0 { - return false - } - if runtime.GOOS == "windows" { - // Windows uses UTF16! 🪟🪟🪟 - content, _, err = transform.Bytes(unicode.UTF16(unicode.LittleEndian, unicode.UseBOM).NewDecoder(), content) - require.NoError(t, err) - } - gotContent = string(content) - return true - }, 15*time.Second, 100*time.Millisecond) + gotContent := readFileContents(t, tempPath) require.Equal(t, content, strings.TrimSpace(gotContent)) }) @@ -436,7 +420,7 @@ func setupSSHSession(t *testing.T, options agent.Metadata) *ssh.Session { func setupAgent(t *testing.T, metadata agent.Metadata, ptyTimeout time.Duration) *agent.Conn { client, server := provisionersdk.TransportPipe() - closer := agent.New(func(ctx context.Context, logger slog.Logger) (agent.Metadata, *peerbroker.Listener, error) { + a := agent.New(func(ctx context.Context, logger slog.Logger) (agent.Metadata, *peerbroker.Listener, error) { listener, err := peerbroker.Listen(server, nil) return metadata, listener, err }, &agent.Options{ @@ -446,7 +430,7 @@ func setupAgent(t *testing.T, metadata agent.Metadata, ptyTimeout time.Duration) t.Cleanup(func() { _ = client.Close() _ = server.Close() - _ = closer.Close() + _ = a.Close() }) api := proto.NewDRPCPeerBrokerClient(provisionersdk.Conn(client)) stream, err := api.NegotiateConnection(context.Background()) @@ -458,6 +442,7 @@ func setupAgent(t *testing.T, metadata agent.Metadata, ptyTimeout time.Duration) t.Cleanup(func() { _ = conn.Close() }) + require.Eventually(t, a.SetupComplete, 10*time.Second, 100*time.Millisecond) return &agent.Conn{ Negotiator: api, @@ -495,3 +480,14 @@ func assertWritePayload(t *testing.T, w io.Writer, payload []byte) { assert.NoError(t, err, "write payload") assert.Equal(t, len(payload), n, "payload length does not match") } + +func readFileContents(t *testing.T, path string) string { + content, err := os.ReadFile(path) + require.NoError(t, err) + if runtime.GOOS == "windows" { + // Windows uses UTF16! 🪟🪟🪟 + content, _, err = transform.Bytes(unicode.UTF16(unicode.LittleEndian, unicode.UseBOM).NewDecoder(), content) + require.NoError(t, err) + } + return string(content) +} From 5a2ea9bc3ca8d0f94d7fbec68877090084ffc0a5 Mon Sep 17 00:00:00 2001 From: David Wahler Date: Thu, 14 Jul 2022 03:08:16 +0000 Subject: [PATCH 2/4] provide default git configuration in .gitconfig instead of environment variables --- agent/agent.go | 21 +++++++++----- agent/agent_test.go | 19 +++++++++++++ agent/gitconfig.go | 59 +++++++++++++++++++++++++++++++++++++++ coderd/workspaceagents.go | 1 + 4 files changed, 93 insertions(+), 7 deletions(-) create mode 100644 agent/gitconfig.go diff --git a/agent/agent.go b/agent/agent.go index 66adb5ed40ba4..3439561265c07 100644 --- a/agent/agent.go +++ b/agent/agent.go @@ -66,6 +66,7 @@ type Metadata struct { EnvironmentVariables map[string]string `json:"environment_variables"` StartupScript string `json:"startup_script"` Directory string `json:"directory"` + GitConfigPath string `json:"git_config_path"` } type WireguardPublicKeys struct { @@ -193,7 +194,19 @@ func (a *agent) run(ctx context.Context) { } func (a *agent) performInitialSetup(ctx context.Context, metadata *Metadata) error { - err := a.runStartupScript(ctx, metadata.StartupScript) + err := setupGitconfig(ctx, metadata.GitConfigPath, map[string]string{ + "user.name": metadata.OwnerUsername, + "user.email": metadata.OwnerEmail, + }) + if errors.Is(err, context.Canceled) { + return err + } + if err != nil { + // failure to set up gitconfig shouldn't prevent the startup script from running + a.logger.Warn(ctx, "git autoconfiguration failed", slog.Error(err)) + } + + err = a.runStartupScript(ctx, metadata.StartupScript) if err != nil { return xerrors.Errorf("agent script failed: %w", err) } @@ -402,12 +415,6 @@ func (a *agent) createCommand(ctx context.Context, rawCommand string, env []stri // If using backslashes, it's unable to find the executable. unixExecutablePath := strings.ReplaceAll(executablePath, "\\", "/") cmd.Env = append(cmd.Env, fmt.Sprintf(`GIT_SSH_COMMAND=%s gitssh --`, unixExecutablePath)) - // These prevent the user from having to specify _anything_ to successfully commit. - // Both author and committer must be set! - cmd.Env = append(cmd.Env, fmt.Sprintf(`GIT_AUTHOR_EMAIL=%s`, metadata.OwnerEmail)) - cmd.Env = append(cmd.Env, fmt.Sprintf(`GIT_COMMITTER_EMAIL=%s`, metadata.OwnerEmail)) - cmd.Env = append(cmd.Env, fmt.Sprintf(`GIT_AUTHOR_NAME=%s`, metadata.OwnerUsername)) - cmd.Env = append(cmd.Env, fmt.Sprintf(`GIT_COMMITTER_NAME=%s`, metadata.OwnerUsername)) // Load environment variables passed via the agent. // These should override all variables we manually specify. diff --git a/agent/agent_test.go b/agent/agent_test.go index 392bb659b4959..64999852c5a36 100644 --- a/agent/agent_test.go +++ b/agent/agent_test.go @@ -216,6 +216,25 @@ func TestAgent(t *testing.T) { require.Equal(t, content, strings.TrimSpace(gotContent)) }) + t.Run("GitAutoconfig", func(t *testing.T) { + t.Parallel() + configPath := filepath.Join(os.TempDir(), "gitconfig") + + initialContent := "[user]\nemail = elmo@example.com\n" + err := os.WriteFile(configPath, []byte(initialContent), 0600) + require.NoError(t, err) + + setupAgent(t, agent.Metadata{ + OwnerUsername: "Kermit the Frog", + OwnerEmail: "kermit@example.com", + GitConfigPath: configPath, + }, 0) + + gotContent := readFileContents(t, configPath) + require.Contains(t, gotContent, "name = Kermit the Frog") + require.Contains(t, gotContent, "email = elmo@example.com") + }) + t.Run("ReconnectingPTY", func(t *testing.T) { t.Parallel() if runtime.GOOS == "windows" { diff --git a/agent/gitconfig.go b/agent/gitconfig.go new file mode 100644 index 0000000000000..7a5b055f7eda4 --- /dev/null +++ b/agent/gitconfig.go @@ -0,0 +1,59 @@ +package agent + +import ( + "context" + "os/exec" + "os/user" + "strings" + + "golang.org/x/xerrors" +) + +var errNoGitAvailable = xerrors.New("Git does not seem to be installed") + +func setupGitconfig(ctx context.Context, configPath string, params map[string]string) error { + if configPath == "" { + return nil + } + if strings.HasPrefix(configPath, "~/") { + currentUser, err := user.Current() + if err != nil { + return xerrors.Errorf("get current user: %w", err) + } + configPath = currentUser.HomeDir + "/" + configPath[2:] + } + + cmd := exec.CommandContext(ctx, "git", "--version") + err := cmd.Run() + if err != nil { + return errNoGitAvailable + } + + for name, value := range params { + err = setGitConfigIfUnset(ctx, configPath, name, value) + if err != nil { + return err + } + } + return nil +} + +func setGitConfigIfUnset(ctx context.Context, configPath, name, value string) error { + cmd := exec.CommandContext(ctx, "git", "config", "--file", configPath, "--get", name) + err := cmd.Run() + if err == nil { + // an exit status of 0 means the value exists, so there's nothing to do + return nil + } + // an exit status of 1 means the value is unset + if cmd.ProcessState.ExitCode() != 1 { + return xerrors.Errorf("getting %s: %w", name, err) + } + + cmd = exec.CommandContext(ctx, "git", "config", "--file", configPath, "--add", name, value) + _, err = cmd.Output() + if err != nil { + return xerrors.Errorf("setting %s=%s: %w", name, value, err) + } + return nil +} diff --git a/coderd/workspaceagents.go b/coderd/workspaceagents.go index 42b965fd64b67..3371f39258524 100644 --- a/coderd/workspaceagents.go +++ b/coderd/workspaceagents.go @@ -178,6 +178,7 @@ func (api *API) workspaceAgentMetadata(rw http.ResponseWriter, r *http.Request) EnvironmentVariables: apiAgent.EnvironmentVariables, StartupScript: apiAgent.StartupScript, Directory: apiAgent.Directory, + GitConfigPath: "~/.gitconfig", }) } From 1fe721a631ce0094c6a4f72202cb758cf6fd00f1 Mon Sep 17 00:00:00 2001 From: David Wahler Date: Thu, 14 Jul 2022 15:14:50 +0000 Subject: [PATCH 3/4] don't try to interpret .gitconfig as UTF-16 on Window --- agent/agent_test.go | 24 +++++++++++------------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/agent/agent_test.go b/agent/agent_test.go index 64999852c5a36..d6114a02a38cb 100644 --- a/agent/agent_test.go +++ b/agent/agent_test.go @@ -212,7 +212,14 @@ func TestAgent(t *testing.T) { StartupScript: fmt.Sprintf("echo %s > %s", content, tempPath), }, 0) - gotContent := readFileContents(t, tempPath) + gotContentBytes, err := os.ReadFile(tempPath) + require.NoError(t, err) + if runtime.GOOS == "windows" { + // Windows uses UTF16! 🪟🪟🪟 + gotContentBytes, _, err = transform.Bytes(unicode.UTF16(unicode.LittleEndian, unicode.UseBOM).NewDecoder(), gotContentBytes) + require.NoError(t, err) + } + gotContent := string(gotContentBytes) require.Equal(t, content, strings.TrimSpace(gotContent)) }) @@ -230,7 +237,9 @@ func TestAgent(t *testing.T) { GitConfigPath: configPath, }, 0) - gotContent := readFileContents(t, configPath) + gotContentBytes, err := os.ReadFile(configPath) + require.NoError(t, err) + gotContent := string(gotContentBytes) require.Contains(t, gotContent, "name = Kermit the Frog") require.Contains(t, gotContent, "email = elmo@example.com") }) @@ -499,14 +508,3 @@ func assertWritePayload(t *testing.T, w io.Writer, payload []byte) { assert.NoError(t, err, "write payload") assert.Equal(t, len(payload), n, "payload length does not match") } - -func readFileContents(t *testing.T, path string) string { - content, err := os.ReadFile(path) - require.NoError(t, err) - if runtime.GOOS == "windows" { - // Windows uses UTF16! 🪟🪟🪟 - content, _, err = transform.Bytes(unicode.UTF16(unicode.LittleEndian, unicode.UseBOM).NewDecoder(), content) - require.NoError(t, err) - } - return string(content) -} From a3a6ce5ad9ef951ed553307b33d4cc5a45f665a6 Mon Sep 17 00:00:00 2001 From: David Wahler Date: Thu, 14 Jul 2022 15:32:24 +0000 Subject: [PATCH 4/4] use filepath.Join instead of string concatenation --- agent/gitconfig.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/agent/gitconfig.go b/agent/gitconfig.go index 7a5b055f7eda4..443fb4d4de5f1 100644 --- a/agent/gitconfig.go +++ b/agent/gitconfig.go @@ -4,6 +4,7 @@ import ( "context" "os/exec" "os/user" + "path/filepath" "strings" "golang.org/x/xerrors" @@ -20,7 +21,7 @@ func setupGitconfig(ctx context.Context, configPath string, params map[string]st if err != nil { return xerrors.Errorf("get current user: %w", err) } - configPath = currentUser.HomeDir + "/" + configPath[2:] + configPath = filepath.Join(currentUser.HomeDir, configPath[2:]) } cmd := exec.CommandContext(ctx, "git", "--version")