Skip to content
987 changes: 23 additions & 964 deletions cmd/up.go

Large diffs are not rendered by default.

205 changes: 205 additions & 0 deletions pkg/dotfiles/dotfiles.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
package dotfiles

import (
"os"
"os/exec"
"slices"
"strings"

"github.com/sirupsen/logrus"
"github.com/skevetter/devpod/pkg/agent"
client2 "github.com/skevetter/devpod/pkg/client"
"github.com/skevetter/devpod/pkg/config"
config2 "github.com/skevetter/devpod/pkg/devcontainer/config"
devssh "github.com/skevetter/devpod/pkg/ssh"
"github.com/skevetter/log"
)

// SetupParams holds all parameters needed for dotfiles setup.
type SetupParams struct {
Source string
Script string
EnvFiles []string
EnvKeyValues []string
Client client2.BaseWorkspaceClient
DevPodConfig *config.Config
Log log.Logger
}

// Setup clones and installs dotfiles into the devcontainer.
func Setup(p SetupParams) error {
dotfilesRepo := p.DevPodConfig.ContextOption(config.ContextOptionDotfilesURL)
if p.Source != "" {
dotfilesRepo = p.Source
}

dotfilesScript := p.DevPodConfig.ContextOption(config.ContextOptionDotfilesScript)
if p.Script != "" {
dotfilesScript = p.Script
}

if dotfilesRepo == "" {
p.Log.Debug("No dotfiles repo specified, skipping")
return nil
}

p.Log.Infof("Dotfiles Git repository %s specified", dotfilesRepo)
p.Log.Debug("Cloning dotfiles into the devcontainer...")

dotCmd, err := buildDotCmd(buildDotCmdParams{
devPodConfig: p.DevPodConfig,
dotfilesRepo: dotfilesRepo,
dotfilesScript: dotfilesScript,
envFiles: p.EnvFiles,
envKeyValuePairs: p.EnvKeyValues,
client: p.Client,
log: p.Log,
})
if err != nil {
return err
}
if p.Log.GetLevel() == logrus.DebugLevel {
dotCmd.Args = append(dotCmd.Args, "--debug")
}

p.Log.Debugf("Running dotfiles setup command: %v", dotCmd.Args)

writer := p.Log.Writer(logrus.InfoLevel, false)

dotCmd.Stdout = writer
dotCmd.Stderr = writer

err = dotCmd.Run()
if err != nil {
return err
}

p.Log.Infof("Done setting up dotfiles into the devcontainer")

return nil
}

func buildDotCmdAgentArguments(
dotfilesRepo, dotfilesScript string,
strictHostKey, debug bool,
) []string {
agentArguments := []string{
"agent",
"workspace",
"install-dotfiles",
"--repository",
dotfilesRepo,
}

if strictHostKey {
agentArguments = append(agentArguments, "--strict-host-key-checking")
}

if debug {
agentArguments = append(agentArguments, "--debug")
}

if dotfilesScript != "" {
agentArguments = append(agentArguments, "--install-script", dotfilesScript)
}

return agentArguments
}

type buildDotCmdParams struct {
devPodConfig *config.Config
dotfilesRepo string
dotfilesScript string
envFiles []string
envKeyValuePairs []string
client client2.BaseWorkspaceClient
log log.Logger
}

func buildDotCmd(p buildDotCmdParams) (*exec.Cmd, error) {
sshCmd := []string{
"ssh",
"--agent-forwarding=true",
"--start-services=true",
}

envFilesKeyValuePairs, err := collectDotfilesScriptEnvKeyValuePairs(p.envFiles)
if err != nil {
return nil, err
}

// Collect file-based and CLI options env variables names (aka keys) and
// configure ssh env var passthrough with send-env
allEnvKeyValuesPairs := slices.Concat(envFilesKeyValuePairs, p.envKeyValuePairs)
allEnvKeys := extractKeysFromEnvKeyValuePairs(allEnvKeyValuesPairs)
for _, envKey := range allEnvKeys {
sshCmd = append(sshCmd, "--send-env", envKey)
}

remoteUser, err := devssh.GetUser(
p.client.WorkspaceConfig().ID,
p.client.WorkspaceConfig().SSHConfigPath,
p.client.WorkspaceConfig().SSHConfigIncludePath,
)
if err != nil {
remoteUser = "root"
}

strictHostKey := p.devPodConfig.ContextOption(
config.ContextOptionSSHStrictHostKeyChecking,
) == config.BoolTrue
debug := p.log.GetLevel() == logrus.DebugLevel
agentArguments := buildDotCmdAgentArguments(
p.dotfilesRepo, p.dotfilesScript, strictHostKey, debug,
)

if p.dotfilesScript != "" {
p.log.Infof("Dotfiles script %s specified", p.dotfilesScript)
}

sshCmd = append(sshCmd,
"--user",
remoteUser,
"--context",
p.client.Context(),
p.client.Workspace(),
"--log-output=raw",
"--command",
agent.ContainerDevPodHelperLocation+" "+strings.Join(agentArguments, " "),
)
execPath, err := os.Executable()
if err != nil {
return nil, err
}

dotCmd := exec.Command( //nolint:gosec
execPath,
sshCmd...,
)

dotCmd.Env = append(dotCmd.Environ(), allEnvKeyValuesPairs...)
return dotCmd, nil
}

func extractKeysFromEnvKeyValuePairs(envKeyValuePairs []string) []string {
keys := []string{}
for _, env := range envKeyValuePairs {
keyValue := strings.SplitN(env, "=", 2)
if len(keyValue) == 2 {
keys = append(keys, keyValue[0])
}
}
return keys
}

func collectDotfilesScriptEnvKeyValuePairs(envFiles []string) ([]string, error) {
keyValues := []string{}
for _, file := range envFiles {
envFromFile, err := config2.ParseKeyValueFile(file)
if err != nil {
return nil, err
}
keyValues = append(keyValues, envFromFile...)
}
return keyValues, nil
}
121 changes: 121 additions & 0 deletions pkg/dotfiles/dotfiles_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
package dotfiles

import (
"testing"

"github.com/stretchr/testify/assert"
)

func TestExtractKeysFromEnvKeyValuePairs(t *testing.T) {
tests := []struct {
name string
input []string
expected []string
}{
{
name: "empty input",
input: []string{},
expected: []string{},
},
{
name: "single key-value pair",
input: []string{"FOO=bar"},
expected: []string{"FOO"},
},
{
name: "multiple key-value pairs",
input: []string{"FOO=bar", "BAZ=qux"},
expected: []string{"FOO", "BAZ"},
},
{
name: "value contains equals sign",
input: []string{"FOO=bar=baz"},
expected: []string{"FOO"},
},
{
name: "entry without equals sign is skipped",
input: []string{"NOEQ", "FOO=bar"},
expected: []string{"FOO"},
},
{
name: "empty value",
input: []string{"FOO="},
expected: []string{"FOO"},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := extractKeysFromEnvKeyValuePairs(tt.input)
assert.Equal(t, tt.expected, result)
})
}
}

func TestCollectDotfilesScriptEnvKeyValuePairs(t *testing.T) {
t.Run("empty file list", func(t *testing.T) {
result, err := collectDotfilesScriptEnvKeyValuePairs([]string{})
assert.NoError(t, err)
assert.Equal(t, []string{}, result)
})

t.Run("nonexistent file returns error", func(t *testing.T) {
_, err := collectDotfilesScriptEnvKeyValuePairs([]string{"/nonexistent/file"})
assert.Error(t, err)
})
}

func TestBuildDotCmdAgentArguments(t *testing.T) {
tests := []struct {
name string
dotfilesRepo string
dotfilesScript string
strictHostKey bool
debug bool
expected []string
}{
{
name: "basic repo only",
dotfilesRepo: "https://github.com/user/dotfiles",
expected: []string{
"agent", "workspace", "install-dotfiles",
"--repository", "https://github.com/user/dotfiles",
},
},
{
name: "with script",
dotfilesRepo: "https://github.com/user/dotfiles",
dotfilesScript: "install.sh",
expected: []string{
"agent", "workspace", "install-dotfiles",
"--repository", "https://github.com/user/dotfiles",
"--install-script", "install.sh",
},
},
{
name: "all options enabled",
dotfilesRepo: "https://github.com/user/dotfiles",
dotfilesScript: "setup.sh",
strictHostKey: true,
debug: true,
expected: []string{
"agent", "workspace", "install-dotfiles",
"--repository", "https://github.com/user/dotfiles",
"--strict-host-key-checking", "--debug",
"--install-script", "setup.sh",
},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := buildDotCmdAgentArguments(
tt.dotfilesRepo,
tt.dotfilesScript,
tt.strictHostKey,
tt.debug,
)
assert.Equal(t, tt.expected, result)
})
}
}
59 changes: 59 additions & 0 deletions pkg/gpg/forward.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package gpg

import (
"os"
"os/exec"

client2 "github.com/skevetter/devpod/pkg/client"
devssh "github.com/skevetter/devpod/pkg/ssh"
"github.com/skevetter/log"
)

// ForwardAgent starts a background SSH connection that forwards the local GPG agent.
func ForwardAgent(client client2.BaseWorkspaceClient, logger log.Logger) error {
logger.Debug("gpg forwarding enabled, performing immediately")

execPath, err := os.Executable()
if err != nil {
return err
}

remoteUser, err := devssh.GetUser(
client.WorkspaceConfig().ID,
client.WorkspaceConfig().SSHConfigPath,
client.WorkspaceConfig().SSHConfigIncludePath,
)
if err != nil {
remoteUser = "root"
}

logger.Info("forwarding gpg-agent")

args := buildForwardArgs(remoteUser, client.Context(), client.Workspace())

go func() {
//nolint:gosec // execPath comes from os.Executable()
err = exec.Command(execPath, args...).Run()
if err != nil {
logger.Error("failure in forwarding gpg-agent")
}
}()

return nil
}

func buildForwardArgs(user, context, workspace string) []string {
return []string{
"ssh",
"--gpg-agent-forwarding=true",
"--agent-forwarding=true",
"--start-services=true",
"--user",
user,
"--context",
context,
workspace,
"--log-output=raw",
"--command", "sleep infinity",
}
}
Loading