Skip to content
Merged
2 changes: 1 addition & 1 deletion internal/config/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ type BaseConfig struct {
InstallHomebrew bool `yaml:"installHomebrew,omitempty"`
ClearLocalPackages bool `yaml:"clearLocalPackages,omitempty"`
ClearVSCodeCache bool `yaml:"clearVSCodeCache,omitempty"`
PythonBinPath string `yaml:"pythonBinPath,omitempty" validate:"omitempty,min=1,filepath"`
PythonBinPath string `yaml:"pythonBinPath,omitempty" validate:"omitempty,min=1"`
HostName string `yaml:"hostName,omitempty" validate:"omitempty,min=1,hostname"`
EnableAuth bool `yaml:"enableAuth,omitempty"`
AuthURL string `yaml:"authURL,omitempty" validate:"omitempty,min=1,url"`
Expand Down
18 changes: 18 additions & 0 deletions internal/config/validation.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package config
import (
"fmt"
"math"
"path"
"regexp"
"strconv"
"strings"
Expand Down Expand Up @@ -193,6 +194,9 @@ func ValidateDevEnvConfig(config *DevEnvConfig) error {
if err := validate.Struct(config); err != nil {
return formatValidationError(err)
}
if err := validatePythonBinPathAbsolute(config.PythonBinPath); err != nil {
return err
}

// Require ≥1 SSH public key with valid format.
sshKeys, err := config.GetSSHKeys()
Expand Down Expand Up @@ -225,6 +229,20 @@ func ValidateBaseConfig(config *BaseConfig) error {
if err := validate.Struct(config); err != nil {
return formatValidationError(err)
}
if err := validatePythonBinPathAbsolute(config.PythonBinPath); err != nil {
return err
}
return nil
}

func validatePythonBinPathAbsolute(p string) error {
p = strings.TrimSpace(p)
if p == "" {
return nil
}
if !path.IsAbs(p) {
return fmt.Errorf("pythonBinPath must be an absolute path, got %q", p)
}
return nil
}

Expand Down
43 changes: 41 additions & 2 deletions internal/config/validation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -232,11 +232,50 @@ func TestValidateDevEnvConfig_ResourcesNonNegative(t *testing.T) {
}

//
// --- ValidateBaseConfig: no tag failures by default -------------------------
// --- ValidateBaseConfig ------------------------------------------------------
//

func TestValidateBaseConfig_Smoke(t *testing.T) {
// With no validation tags on BaseConfig itself, this should succeed.
// Zero-value BaseConfig should still pass baseline validation.
var bc BaseConfig
require.NoError(t, ValidateBaseConfig(&bc))
}

func TestValidateBaseConfig_DefaultsPass(t *testing.T) {
bc := NewBaseConfigWithDefaults()
require.NoError(t, ValidateBaseConfig(&bc))
}

func TestValidateBaseConfig_PythonBinPathMustBeAbsolute(t *testing.T) {
ok := &BaseConfig{PythonBinPath: "/opt/venv/bin"}
require.NoError(t, ValidateBaseConfig(ok))

bad := &BaseConfig{PythonBinPath: "usr/bin"}
err := ValidateBaseConfig(bad)
require.Error(t, err)
assert.Contains(t, err.Error(), "pythonBinPath")
assert.Contains(t, err.Error(), "absolute path")
}

func TestValidateDevEnvConfig_PythonBinPathMustBeAbsolute(t *testing.T) {
ok := &DevEnvConfig{
Name: "alice",
BaseConfig: BaseConfig{
PythonBinPath: "/opt/venv/bin",
SSHPublicKey: "ssh-ed25519 AAAAB3NzaC1lZDI1NTE5AAAA user@host",
},
}
require.NoError(t, ValidateDevEnvConfig(ok))

bad := &DevEnvConfig{
Name: "alice",
BaseConfig: BaseConfig{
PythonBinPath: "opt/venv/bin",
SSHPublicKey: "ssh-ed25519 AAAAB3NzaC1lZDI1NTE5AAAA user@host",
},
}
err := ValidateDevEnvConfig(bad)
require.Error(t, err)
assert.Contains(t, err.Error(), "pythonBinPath")
assert.Contains(t, err.Error(), "absolute path")
}
70 changes: 52 additions & 18 deletions internal/templates/template_files/dev/scripts/templated/startup.sh
Original file line number Diff line number Diff line change
Expand Up @@ -32,37 +32,48 @@ echo "Section 1: Environment and system setup complete"
echo "Setting up user: ${DEV_USERNAME}"

# Create/rename group with target GID
if id -g ${TARGET_GID} &>/dev/null; then
echo "Renaming group ${TARGET_GID} to ${DEV_USERNAME}"
groupmod -n ${DEV_USERNAME} $(id -gn ${TARGET_GID})
if GROUP_ENTRY="$(getent group "${TARGET_GID}")"; then
EXISTING_GROUP_NAME="${GROUP_ENTRY%%:*}"
if [ "${EXISTING_GROUP_NAME}" != "${DEV_USERNAME}" ]; then
echo "Renaming group ${EXISTING_GROUP_NAME} (GID: ${TARGET_GID}) to ${DEV_USERNAME}"
groupmod -n "${DEV_USERNAME}" "${EXISTING_GROUP_NAME}"
else
echo "Group ${DEV_USERNAME} already exists with GID ${TARGET_GID}"
fi
else
echo "Adding group ${DEV_USERNAME} with GID ${TARGET_GID}"
groupadd -g ${TARGET_GID} ${DEV_USERNAME}
groupadd -g "${TARGET_GID}" "${DEV_USERNAME}"
fi

# Create/rename user with target UID
if id -u ${TARGET_UID} &>/dev/null; then
echo "Renaming user ${TARGET_UID} to ${DEV_USERNAME}"
usermod -l ${DEV_USERNAME} -s /bin/bash -d /home/${DEV_USERNAME} -g ${TARGET_GID} $(id -un ${TARGET_UID})
if USER_ENTRY="$(getent passwd "${TARGET_UID}")"; then
EXISTING_USER_NAME="${USER_ENTRY%%:*}"
if [ "${EXISTING_USER_NAME}" != "${DEV_USERNAME}" ]; then
echo "Renaming user ${EXISTING_USER_NAME} (UID: ${TARGET_UID}) to ${DEV_USERNAME}"
usermod -l "${DEV_USERNAME}" -s /bin/bash -d "/home/${DEV_USERNAME}" -g "${TARGET_GID}" "${EXISTING_USER_NAME}"
else
echo "User ${DEV_USERNAME} already exists with UID ${TARGET_UID}; ensuring shell/home/group settings"
usermod -s /bin/bash -d "/home/${DEV_USERNAME}" -g "${TARGET_GID}" "${DEV_USERNAME}"
fi
else
echo "Adding user ${DEV_USERNAME} with UID ${TARGET_UID}"
useradd -u ${TARGET_UID} -m -s /bin/bash ${DEV_USERNAME}
useradd -u "${TARGET_UID}" -g "${TARGET_GID}" -m -s /bin/bash "${DEV_USERNAME}"
fi

# Ensure home directory exists and has correct ownership
mkdir -p "/home/${DEV_USERNAME}"
chown ${DEV_USERNAME}:${DEV_USERNAME} "/home/${DEV_USERNAME}"
chown "${DEV_USERNAME}:${DEV_USERNAME}" "/home/${DEV_USERNAME}"

echo "Section 2: User management complete"

# === ADMIN PRIVILEGES ===
{{- if .IsAdmin}}
echo "Setting up admin privileges for ${DEV_USERNAME}"
usermod -aG sudo ${DEV_USERNAME}
usermod -aG sudo "${DEV_USERNAME}"

# Configure sudo to not require password
echo "${DEV_USERNAME} ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/${DEV_USERNAME}
chmod 440 /etc/sudoers.d/${DEV_USERNAME}
echo "${DEV_USERNAME} ALL=(ALL) NOPASSWD:ALL" > "/etc/sudoers.d/${DEV_USERNAME}"
chmod 440 "/etc/sudoers.d/${DEV_USERNAME}"
{{- else}}
echo "User ${DEV_USERNAME} configured as non-admin"
{{- end}}
Expand All @@ -73,18 +84,25 @@ echo "Section 3: Admin privileges complete"
{{- if .InstallHomebrew}}
echo "Installing Homebrew for ${DEV_USERNAME}"

# Repair ownership on the mounted linuxbrew path before invoking the installer.
# This handles stale UID/GID ownership from previous runs while avoiding broad
# recursive mode changes across the entire Homebrew tree.
mkdir -p /home/linuxbrew /home/linuxbrew/.linuxbrew
chown -R "${DEV_USERNAME}:${DEV_USERNAME}" /home/linuxbrew
chmod u+rwx /home/linuxbrew /home/linuxbrew/.linuxbrew

# Create a specific sudoers file for Homebrew installation
echo "${DEV_USERNAME} ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/homebrew_install
chmod 440 /etc/sudoers.d/homebrew_install

# Install Homebrew as the dev user
sudo -u ${DEV_USERNAME} bash -c 'NONINTERACTIVE=1 /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"'
sudo -u "${DEV_USERNAME}" bash -c 'NONINTERACTIVE=1 /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"'

# Remove the temporary sudoers file
rm -f /etc/sudoers.d/homebrew_install

# Fix potential permissions issues
chown -R ${DEV_USERNAME}:${DEV_USERNAME} /home/${DEV_USERNAME}/.cache
chown -R "${DEV_USERNAME}:${DEV_USERNAME}" "/home/${DEV_USERNAME}/.cache"
{{- else}}
echo "Skipping Homebrew installation (disabled in config)"
{{- end}}
Expand Down Expand Up @@ -117,7 +135,7 @@ mkdir -p /home/${DEV_USERNAME}/.ssh
echo "{{.GetSSHKeysString}}" > /home/${DEV_USERNAME}/.ssh/authorized_keys
chmod 700 /home/${DEV_USERNAME}/.ssh
chmod 600 /home/${DEV_USERNAME}/.ssh/authorized_keys
chown -R ${DEV_USERNAME}:${DEV_USERNAME} /home/${DEV_USERNAME}/.ssh
chown -R "${DEV_USERNAME}:${DEV_USERNAME}" "/home/${DEV_USERNAME}/.ssh"

echo "Section 5: SSH server setup complete"

Expand All @@ -134,6 +152,16 @@ rm -rf /home/${DEV_USERNAME}/.cache/pip
rm -rf /home/${DEV_USERNAME}/.local/lib/python*/site-packages/*
{{- end}}

# Ensure default venv path exists before Python package installs.
# This keeps the default pythonBinPath (/opt/venv/bin) functional on images
# that don't pre-create /opt/venv.
if [ "${PYTHON_BIN_PATH}" = "/opt/venv/bin" ] && [ ! -x "${PYTHON_PATH}" ]; then
echo "Bootstrapping Python virtual environment at /opt/venv"
apt-get install -y python3-venv
python3 -m venv /opt/venv
chown -R "${DEV_USERNAME}:${DEV_USERNAME}" /opt/venv
fi

# Install common python packages from requirements.txt
if [ -f /scripts/requirements.txt ]; then
echo "Installing Python packages from requirements.txt"
Expand All @@ -153,6 +181,11 @@ sudo -u ${DEV_USERNAME} brew install{{range .Packages.Brew}} {{.}}{{end}}
echo "Section 6: Package installation complete"

# === USER ENVIRONMENT SETUP ===
# Repair ownership across persisted home content before running user-level setup.
# This prevents failures like ".bashrc: Permission denied" when stale files are
# carried over from previous runs with mismatched UID/GID ownership.
chown -R "${DEV_USERNAME}:${DEV_USERNAME}" "/home/${DEV_USERNAME}"

# Set up environment for the user
if [ -f /scripts/setup.sh ]; then
echo "Running user environment setup script"
Expand Down Expand Up @@ -181,8 +214,9 @@ rm -rf /home/${DEV_USERNAME}/.vscode-server/
{{- end}}


# Make sure .vscode-server directory is owned by ${DEV_USERNAME}
chown -R ${DEV_USERNAME}:${DEV_USERNAME} /home/${DEV_USERNAME}/.vscode-server
# Make sure .vscode-server directory exists and is owned by ${DEV_USERNAME}
mkdir -p /home/${DEV_USERNAME}/.vscode-server
chown -R "${DEV_USERNAME}:${DEV_USERNAME}" "/home/${DEV_USERNAME}/.vscode-server"

echo "Section 8: VSCode configuration complete"

Expand Down Expand Up @@ -229,4 +263,4 @@ echo "No Git repositories to clone"

# === SSH SERVER LAUNCH ===
echo "Starting SSH server"
/usr/sbin/sshd -D
/usr/sbin/sshd -D
58 changes: 43 additions & 15 deletions internal/templates/testdata/golden/startup-scripts.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -37,36 +37,47 @@ data:
echo "Setting up user: ${DEV_USERNAME}"

# Create/rename group with target GID
if id -g ${TARGET_GID} &>/dev/null; then
echo "Renaming group ${TARGET_GID} to ${DEV_USERNAME}"
groupmod -n ${DEV_USERNAME} $(id -gn ${TARGET_GID})
if GROUP_ENTRY="$(getent group "${TARGET_GID}")"; then
EXISTING_GROUP_NAME="${GROUP_ENTRY%%:*}"
if [ "${EXISTING_GROUP_NAME}" != "${DEV_USERNAME}" ]; then
echo "Renaming group ${EXISTING_GROUP_NAME} (GID: ${TARGET_GID}) to ${DEV_USERNAME}"
groupmod -n "${DEV_USERNAME}" "${EXISTING_GROUP_NAME}"
else
echo "Group ${DEV_USERNAME} already exists with GID ${TARGET_GID}"
fi
else
echo "Adding group ${DEV_USERNAME} with GID ${TARGET_GID}"
groupadd -g ${TARGET_GID} ${DEV_USERNAME}
groupadd -g "${TARGET_GID}" "${DEV_USERNAME}"
fi

# Create/rename user with target UID
if id -u ${TARGET_UID} &>/dev/null; then
echo "Renaming user ${TARGET_UID} to ${DEV_USERNAME}"
usermod -l ${DEV_USERNAME} -s /bin/bash -d /home/${DEV_USERNAME} -g ${TARGET_GID} $(id -un ${TARGET_UID})
if USER_ENTRY="$(getent passwd "${TARGET_UID}")"; then
EXISTING_USER_NAME="${USER_ENTRY%%:*}"
if [ "${EXISTING_USER_NAME}" != "${DEV_USERNAME}" ]; then
echo "Renaming user ${EXISTING_USER_NAME} (UID: ${TARGET_UID}) to ${DEV_USERNAME}"
usermod -l "${DEV_USERNAME}" -s /bin/bash -d "/home/${DEV_USERNAME}" -g "${TARGET_GID}" "${EXISTING_USER_NAME}"
else
echo "User ${DEV_USERNAME} already exists with UID ${TARGET_UID}; ensuring shell/home/group settings"
usermod -s /bin/bash -d "/home/${DEV_USERNAME}" -g "${TARGET_GID}" "${DEV_USERNAME}"
fi
else
echo "Adding user ${DEV_USERNAME} with UID ${TARGET_UID}"
useradd -u ${TARGET_UID} -m -s /bin/bash ${DEV_USERNAME}
useradd -u "${TARGET_UID}" -g "${TARGET_GID}" -m -s /bin/bash "${DEV_USERNAME}"
fi

# Ensure home directory exists and has correct ownership
mkdir -p "/home/${DEV_USERNAME}"
chown ${DEV_USERNAME}:${DEV_USERNAME} "/home/${DEV_USERNAME}"
chown "${DEV_USERNAME}:${DEV_USERNAME}" "/home/${DEV_USERNAME}"

echo "Section 2: User management complete"

# === ADMIN PRIVILEGES ===
echo "Setting up admin privileges for ${DEV_USERNAME}"
usermod -aG sudo ${DEV_USERNAME}
usermod -aG sudo "${DEV_USERNAME}"

# Configure sudo to not require password
echo "${DEV_USERNAME} ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/${DEV_USERNAME}
chmod 440 /etc/sudoers.d/${DEV_USERNAME}
echo "${DEV_USERNAME} ALL=(ALL) NOPASSWD:ALL" > "/etc/sudoers.d/${DEV_USERNAME}"
chmod 440 "/etc/sudoers.d/${DEV_USERNAME}"

echo "Section 3: Admin privileges complete"

Expand Down Expand Up @@ -103,14 +114,24 @@ data:
" > /home/${DEV_USERNAME}/.ssh/authorized_keys
chmod 700 /home/${DEV_USERNAME}/.ssh
chmod 600 /home/${DEV_USERNAME}/.ssh/authorized_keys
chown -R ${DEV_USERNAME}:${DEV_USERNAME} /home/${DEV_USERNAME}/.ssh
chown -R "${DEV_USERNAME}:${DEV_USERNAME}" "/home/${DEV_USERNAME}/.ssh"

echo "Section 5: SSH server setup complete"

# === PACKAGE INSTALLATION ===
echo "Installing APT packages: vim curl"
apt-get install -y vim curl

# Ensure default venv path exists before Python package installs.
# This keeps the default pythonBinPath (/opt/venv/bin) functional on images
# that don't pre-create /opt/venv.
if [ "${PYTHON_BIN_PATH}" = "/opt/venv/bin" ] && [ ! -x "${PYTHON_PATH}" ]; then
echo "Bootstrapping Python virtual environment at /opt/venv"
apt-get install -y python3-venv
python3 -m venv /opt/venv
chown -R "${DEV_USERNAME}:${DEV_USERNAME}" /opt/venv
fi

# Install common python packages from requirements.txt
if [ -f /scripts/requirements.txt ]; then
echo "Installing Python packages from requirements.txt"
Expand All @@ -122,6 +143,11 @@ data:
echo "Section 6: Package installation complete"

# === USER ENVIRONMENT SETUP ===
# Repair ownership across persisted home content before running user-level setup.
# This prevents failures like ".bashrc: Permission denied" when stale files are
# carried over from previous runs with mismatched UID/GID ownership.
chown -R "${DEV_USERNAME}:${DEV_USERNAME}" "/home/${DEV_USERNAME}"

# Set up environment for the user
if [ -f /scripts/setup.sh ]; then
echo "Running user environment setup script"
Expand All @@ -146,8 +172,9 @@ data:
# === VSCODE CONFIGURATION ===


# Make sure .vscode-server directory is owned by ${DEV_USERNAME}
chown -R ${DEV_USERNAME}:${DEV_USERNAME} /home/${DEV_USERNAME}/.vscode-server
# Make sure .vscode-server directory exists and is owned by ${DEV_USERNAME}
mkdir -p /home/${DEV_USERNAME}/.vscode-server
chown -R "${DEV_USERNAME}:${DEV_USERNAME}" "/home/${DEV_USERNAME}/.vscode-server"

echo "Section 8: VSCode configuration complete"

Expand All @@ -157,6 +184,7 @@ data:
# === SSH SERVER LAUNCH ===
echo "Starting SSH server"
/usr/sbin/sshd -D


# Static utility scripts - included as-is
run_with_git.sh: |
Expand Down