diff --git a/internal/config/types.go b/internal/config/types.go index 6609cf4..500f14a 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -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"` diff --git a/internal/config/validation.go b/internal/config/validation.go index 9df4428..301c3a1 100644 --- a/internal/config/validation.go +++ b/internal/config/validation.go @@ -3,6 +3,7 @@ package config import ( "fmt" "math" + "path" "regexp" "strconv" "strings" @@ -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() @@ -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 } diff --git a/internal/config/validation_test.go b/internal/config/validation_test.go index 4c44399..6ba4753 100644 --- a/internal/config/validation_test.go +++ b/internal/config/validation_test.go @@ -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") +} diff --git a/internal/templates/template_files/dev/scripts/templated/startup.sh b/internal/templates/template_files/dev/scripts/templated/startup.sh index 48aa35b..baee8a2 100644 --- a/internal/templates/template_files/dev/scripts/templated/startup.sh +++ b/internal/templates/template_files/dev/scripts/templated/startup.sh @@ -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}} @@ -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}} @@ -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" @@ -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" @@ -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" @@ -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" @@ -229,4 +263,4 @@ echo "No Git repositories to clone" # === SSH SERVER LAUNCH === echo "Starting SSH server" -/usr/sbin/sshd -D \ No newline at end of file +/usr/sbin/sshd -D diff --git a/internal/templates/testdata/golden/startup-scripts.yaml b/internal/templates/testdata/golden/startup-scripts.yaml index c7eac05..bdc2e09 100644 --- a/internal/templates/testdata/golden/startup-scripts.yaml +++ b/internal/templates/testdata/golden/startup-scripts.yaml @@ -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" @@ -103,7 +114,7 @@ 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" @@ -111,6 +122,16 @@ data: 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" @@ -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" @@ -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" @@ -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: |