From 98bdd542bde8dd5fc56b5014114936ef42c87508 Mon Sep 17 00:00:00 2001 From: spapa013 Date: Wed, 4 Mar 2026 23:54:14 +0000 Subject: [PATCH 01/11] fix(startup): make user/group provisioning idempotent by using getent UID/GID lookups and conditional rename/create logic for the dev account. --- .../dev/scripts/templated/startup.sh | 29 +++++++++++++------ 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/internal/templates/template_files/dev/scripts/templated/startup.sh b/internal/templates/template_files/dev/scripts/templated/startup.sh index 48aa35b..2f8f247 100644 --- a/internal/templates/template_files/dev/scripts/templated/startup.sh +++ b/internal/templates/template_files/dev/scripts/templated/startup.sh @@ -32,21 +32,32 @@ 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 @@ -229,4 +240,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 From 8200e63d56706c0495fd6384242c7541e741a2ab Mon Sep 17 00:00:00 2001 From: spapa013 Date: Thu, 5 Mar 2026 00:16:37 +0000 Subject: [PATCH 02/11] fix(startup): defensively set linuxbrew ownership before Homebrew install to prevent breakage from stale UID/GID ownership --- .../template_files/dev/scripts/templated/startup.sh | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/internal/templates/template_files/dev/scripts/templated/startup.sh b/internal/templates/template_files/dev/scripts/templated/startup.sh index 2f8f247..8c7dcea 100644 --- a/internal/templates/template_files/dev/scripts/templated/startup.sh +++ b/internal/templates/template_files/dev/scripts/templated/startup.sh @@ -84,6 +84,13 @@ 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 From 82b8ebdb789f3cddcd55e41c5df115a75375a117 Mon Sep 17 00:00:00 2001 From: spapa013 Date: Thu, 5 Mar 2026 00:18:59 +0000 Subject: [PATCH 03/11] fix(startup): ensure .vscode-server exists before chown to avoid startup failure on missing directory --- .../templates/template_files/dev/scripts/templated/startup.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/internal/templates/template_files/dev/scripts/templated/startup.sh b/internal/templates/template_files/dev/scripts/templated/startup.sh index 8c7dcea..125afb6 100644 --- a/internal/templates/template_files/dev/scripts/templated/startup.sh +++ b/internal/templates/template_files/dev/scripts/templated/startup.sh @@ -199,7 +199,8 @@ rm -rf /home/${DEV_USERNAME}/.vscode-server/ {{- end}} -# Make sure .vscode-server directory is owned by ${DEV_USERNAME} +# 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" From e9c9dba43ff08024426a58cfc4acc6bc0281a9e4 Mon Sep 17 00:00:00 2001 From: spapa013 Date: Thu, 5 Mar 2026 01:15:26 +0000 Subject: [PATCH 04/11] fix(config): require pythonBinPath to be absolute via explicit validation and remove conflicting filepath tag so valid absolute paths pass consistently --- internal/config/types.go | 2 +- internal/config/validation.go | 18 +++++++++++++ internal/config/validation_test.go | 43 ++++++++++++++++++++++++++++-- 3 files changed, 60 insertions(+), 3 deletions(-) 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") +} From b6edc5dd50535f280c89b320a1234a5b34f56541 Mon Sep 17 00:00:00 2001 From: spapa013 Date: Thu, 5 Mar 2026 01:22:14 +0000 Subject: [PATCH 05/11] chore(startup): harden account and ownership commands with consistent shell quoting for user/group and path arguments --- .../dev/scripts/templated/startup.sh | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/internal/templates/template_files/dev/scripts/templated/startup.sh b/internal/templates/template_files/dev/scripts/templated/startup.sh index 125afb6..f753617 100644 --- a/internal/templates/template_files/dev/scripts/templated/startup.sh +++ b/internal/templates/template_files/dev/scripts/templated/startup.sh @@ -62,18 +62,18 @@ 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}} @@ -96,13 +96,13 @@ 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}} @@ -135,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" @@ -201,7 +201,7 @@ rm -rf /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 +chown -R "${DEV_USERNAME}:${DEV_USERNAME}" "/home/${DEV_USERNAME}/.vscode-server" echo "Section 8: VSCode configuration complete" From 5691071b66c18dae03ed47139fbede39f479d8be Mon Sep 17 00:00:00 2001 From: spapa013 Date: Thu, 5 Mar 2026 01:50:26 +0000 Subject: [PATCH 06/11] fix(startup): bootstrap /opt/venv on demand when default pythonBinPath is used and venv python is missing --- .../template_files/dev/scripts/templated/startup.sh | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/internal/templates/template_files/dev/scripts/templated/startup.sh b/internal/templates/template_files/dev/scripts/templated/startup.sh index f753617..d9c3c74 100644 --- a/internal/templates/template_files/dev/scripts/templated/startup.sh +++ b/internal/templates/template_files/dev/scripts/templated/startup.sh @@ -152,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" From cc146b7cbcb3719f20ebcb22d569ca6e7cbd192d Mon Sep 17 00:00:00 2001 From: spapa013 Date: Thu, 5 Mar 2026 02:16:52 +0000 Subject: [PATCH 07/11] fix(startup): recursively repair persisted home ownership before user setup to prevent stale-dotfile permission failures (e.g. .bashrc) --- .../template_files/dev/scripts/templated/startup.sh | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/internal/templates/template_files/dev/scripts/templated/startup.sh b/internal/templates/template_files/dev/scripts/templated/startup.sh index d9c3c74..baee8a2 100644 --- a/internal/templates/template_files/dev/scripts/templated/startup.sh +++ b/internal/templates/template_files/dev/scripts/templated/startup.sh @@ -181,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" From db29fd856490ce27ded9ace83704fa90ecf706b5 Mon Sep 17 00:00:00 2001 From: spapa013 Date: Thu, 5 Mar 2026 19:05:18 +0000 Subject: [PATCH 08/11] fix: update golden template internal/scripts --- .../testdata/golden/startup-scripts.yaml | 58 ++++++++++++++----- 1 file changed, 43 insertions(+), 15 deletions(-) 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: | From f24cbf3ece0b289ec902e47efb01222ceba63bfe Mon Sep 17 00:00:00 2001 From: spapa013 Date: Thu, 5 Mar 2026 20:20:57 +0000 Subject: [PATCH 09/11] fix(config): validate volume mount paths syntactically instead of filepath --- internal/config/types.go | 4 +- internal/config/validation.go | 32 ++++++++++++++ internal/config/validation_test.go | 68 ++++++++++++++++++++++++++++++ 3 files changed, 102 insertions(+), 2 deletions(-) diff --git a/internal/config/types.go b/internal/config/types.go index 500f14a..7dbd14e 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -94,8 +94,8 @@ type ResourceConfig struct { // VolumeMount represents a volume mount configuration type VolumeMount struct { Name string `yaml:"name" validate:"required,min=1,max=63,alphanum"` - LocalPath string `yaml:"localPath" validate:"required,min=1,filepath"` - ContainerPath string `yaml:"containerPath" validate:"required,min=1,filepath"` + LocalPath string `yaml:"localPath" validate:"required,mount_path"` + ContainerPath string `yaml:"containerPath" validate:"required,mount_path"` } // RefreshConfig represents auto-refresh settings diff --git a/internal/config/validation.go b/internal/config/validation.go index 301c3a1..cabf662 100644 --- a/internal/config/validation.go +++ b/internal/config/validation.go @@ -57,6 +57,9 @@ func init() { if err := validate.RegisterValidation("k8s_memory", validateKubernetesMemory); err != nil { panic(fmt.Errorf("register validator k8s_memory: %w", err)) } + if err := validate.RegisterValidation("mount_path", validateMountPath); err != nil { + panic(fmt.Errorf("register validator mount_path: %w", err)) + } validate.RegisterStructValidation(validateGitRepo, GitRepo{}) } @@ -188,6 +191,33 @@ func validateKubernetesMemory(fl validator.FieldLevel) bool { } } +// validateMountPath implements the "mount_path" tag. +// It validates mount paths using syntax only (no filesystem checks). +func validateMountPath(fl validator.FieldLevel) bool { + p, ok := fl.Field().Interface().(string) + if !ok { + return false + } + + p = strings.TrimSpace(p) + if p == "" { + return false + } + + // Kubernetes-style mount paths are absolute, slash-separated paths. + if !path.IsAbs(p) { + return false + } + + // Reject NUL bytes; otherwise rely on lexical cleaning only. + if strings.ContainsRune(p, '\x00') { + return false + } + + clean := path.Clean(p) + return clean != "" && clean != "." +} + // ValidateDevEnvConfig runs tag-based validation and then applies // additional semantic checks that are easier to express in code. func ValidateDevEnvConfig(config *DevEnvConfig) error { @@ -282,6 +312,8 @@ func formatFieldError(fieldError validator.FieldError) string { return fmt.Sprintf("'%s' must be a valid URL, got '%v'", fieldName, value) case "filepath": return fmt.Sprintf("'%s' must be a valid file path, got '%v'", fieldName, value) + case "mount_path": + return fmt.Sprintf("'%s' must be a valid absolute mount path, got '%v'", fieldName, value) case "cron": return fmt.Sprintf("'%s' must be a valid cron expression, got '%v'", fieldName, value) diff --git a/internal/config/validation_test.go b/internal/config/validation_test.go index 6ba4753..08d5a4f 100644 --- a/internal/config/validation_test.go +++ b/internal/config/validation_test.go @@ -279,3 +279,71 @@ func TestValidateDevEnvConfig_PythonBinPathMustBeAbsolute(t *testing.T) { assert.Contains(t, err.Error(), "pythonBinPath") assert.Contains(t, err.Error(), "absolute path") } + +func TestValidator_MountPath(t *testing.T) { + type S struct { + Path string `validate:"mount_path"` + } + + cases := []struct { + name string + val string + ok bool + }{ + {name: "root mount dir", val: "/mnt", ok: true}, + {name: "mount subpath", val: "/mnt/data", ok: true}, + {name: "root", val: "/", ok: true}, + {name: "empty", val: "", ok: false}, + {name: "whitespace", val: " ", ok: false}, + {name: "relative path", val: "mnt/data", ok: false}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := validate.Struct(&S{Path: tc.val}) + if tc.ok { + require.NoError(t, err) + } else { + require.Error(t, err) + } + }) + } +} + +func TestValidateDevEnvConfig_VolumeMountPaths(t *testing.T) { + newCfg := func(localPath, containerPath string) *DevEnvConfig { + return &DevEnvConfig{ + Name: "alice", + BaseConfig: BaseConfig{ + SSHPublicKey: "ssh-ed25519 AAAAB3NzaC1lZDI1NTE5AAAA user@host", + Volumes: []VolumeMount{ + { + Name: "mnt", + LocalPath: localPath, + ContainerPath: containerPath, + }, + }, + }, + } + } + + t.Run("accepts root directory mounts", func(t *testing.T) { + require.NoError(t, ValidateDevEnvConfig(newCfg("/mnt", "/mnt"))) + }) + + t.Run("accepts mount subpaths", func(t *testing.T) { + require.NoError(t, ValidateDevEnvConfig(newCfg("/mnt/data", "/mnt/data"))) + }) + + t.Run("rejects empty localPath", func(t *testing.T) { + err := ValidateDevEnvConfig(newCfg("", "/mnt")) + require.Error(t, err) + assert.Contains(t, err.Error(), "LocalPath") + }) + + t.Run("rejects empty containerPath", func(t *testing.T) { + err := ValidateDevEnvConfig(newCfg("/mnt", "")) + require.Error(t, err) + assert.Contains(t, err.Error(), "ContainerPath") + }) +} From bd48064f03fa3b00aa76ffeff7c3c9952db07c32 Mon Sep 17 00:00:00 2001 From: spapa013 Date: Thu, 5 Mar 2026 21:36:05 +0000 Subject: [PATCH 10/11] fix(templates): :bug: set namespace on all namespaced manifests and update golden tests --- internal/templates/renderer_test.go | 6 +++-- .../dev/manifests/env-vars.tmpl | 1 + .../template_files/dev/manifests/ingress.tmpl | 3 ++- .../template_files/dev/manifests/service.tmpl | 4 ++- .../dev/manifests/startup-scripts.tmpl | 3 ++- .../dev/manifests/statefulset.tmpl | 3 ++- .../templates/testdata/golden/env-vars.yaml | 1 + .../templates/testdata/golden/ingress.yaml | 26 +++++++++++++++++++ .../templates/testdata/golden/service.yaml | 4 ++- .../testdata/golden/startup-scripts.yaml | 3 ++- .../testdata/golden/statefulset.yaml | 3 ++- 11 files changed, 48 insertions(+), 9 deletions(-) create mode 100644 internal/templates/testdata/golden/ingress.yaml diff --git a/internal/templates/renderer_test.go b/internal/templates/renderer_test.go index bcfe4c6..2d9053c 100644 --- a/internal/templates/renderer_test.go +++ b/internal/templates/renderer_test.go @@ -26,6 +26,7 @@ func TestRenderTemplate(t *testing.T) { }, UID: 2000, Image: "ubuntu:22.04", + Namespace: "devenv-test", Packages: config.PackageConfig{ Python: []string{"numpy", "pandas"}, APT: []string{"vim", "curl"}, @@ -57,7 +58,7 @@ func TestRenderTemplate(t *testing.T) { }, } - templates := []string{"statefulset", "service", "env-vars", "startup-scripts"} + templates := []string{"statefulset", "service", "env-vars", "startup-scripts", "ingress"} for _, templateName := range templates { t.Run(templateName, func(t *testing.T) { @@ -109,6 +110,7 @@ func TestRenderAll(t *testing.T) { Name: "minimal", BaseConfig: config.BaseConfig{ SSHPublicKey: "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC7... minimal@example.com", + Namespace: "devenv-test", }, SSHPort: 30002, } @@ -121,7 +123,7 @@ func TestRenderAll(t *testing.T) { require.NoError(t, err, "RenderAll should not return error") // Verify all expected files were created - expectedFiles := []string{"statefulset.yaml", "service.yaml", "env-vars.yaml", "startup-scripts.yaml"} + expectedFiles := []string{"statefulset.yaml", "service.yaml", "env-vars.yaml", "startup-scripts.yaml", "ingress.yaml"} for _, filename := range expectedFiles { filePath := filepath.Join(tempDir, filename) diff --git a/internal/templates/template_files/dev/manifests/env-vars.tmpl b/internal/templates/template_files/dev/manifests/env-vars.tmpl index 1597a53..cb79900 100644 --- a/internal/templates/template_files/dev/manifests/env-vars.tmpl +++ b/internal/templates/template_files/dev/manifests/env-vars.tmpl @@ -2,6 +2,7 @@ apiVersion: v1 kind: ConfigMap metadata: name: env-vars-{{.Name}} + namespace: {{.Namespace}} labels: app: devenv-{{.Name}} data: diff --git a/internal/templates/template_files/dev/manifests/ingress.tmpl b/internal/templates/template_files/dev/manifests/ingress.tmpl index 5f70fe1..951a580 100644 --- a/internal/templates/template_files/dev/manifests/ingress.tmpl +++ b/internal/templates/template_files/dev/manifests/ingress.tmpl @@ -2,6 +2,7 @@ apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: devenv-ingress-{{.Name}} + namespace: {{.Namespace}} annotations: nginx.ingress.kubernetes.io/force-ssl-redirect: "true" cert-manager.io/cluster-issuer: "letsencrypt" @@ -28,4 +29,4 @@ spec: tls: - hosts: - "*.{{.HostName}}" - secretName: http-{{.Name}}-tls \ No newline at end of file + secretName: http-{{.Name}}-tls diff --git a/internal/templates/template_files/dev/manifests/service.tmpl b/internal/templates/template_files/dev/manifests/service.tmpl index 9d5a10f..4073269 100644 --- a/internal/templates/template_files/dev/manifests/service.tmpl +++ b/internal/templates/template_files/dev/manifests/service.tmpl @@ -2,6 +2,7 @@ apiVersion: v1 kind: Service metadata: name: devenv-ssh-{{.Name}} + namespace: {{.Namespace}} labels: app: devenv-{{.Name}} service: ssh @@ -21,6 +22,7 @@ apiVersion: v1 kind: Service metadata: name: devenv-http-{{.Name}} + namespace: {{.Namespace}} labels: app: devenv-{{.Name}} service: http @@ -33,4 +35,4 @@ spec: port: {{.HTTPPort}} targetPort: {{.HTTPPort}} protocol: TCP -{{- end}} \ No newline at end of file +{{- end}} diff --git a/internal/templates/template_files/dev/manifests/startup-scripts.tmpl b/internal/templates/template_files/dev/manifests/startup-scripts.tmpl index 9f39413..5a33d03 100644 --- a/internal/templates/template_files/dev/manifests/startup-scripts.tmpl +++ b/internal/templates/template_files/dev/manifests/startup-scripts.tmpl @@ -2,6 +2,7 @@ apiVersion: v1 kind: ConfigMap metadata: name: startup-scripts-{{.Name}} + namespace: {{.Namespace}} labels: app: devenv-{{.Name}} data: @@ -19,4 +20,4 @@ data: # User setup script setup.sh: | - {{getTemplatedScript "user-setup.sh" . | indent 4}} \ No newline at end of file + {{getTemplatedScript "user-setup.sh" . | indent 4}} diff --git a/internal/templates/template_files/dev/manifests/statefulset.tmpl b/internal/templates/template_files/dev/manifests/statefulset.tmpl index 4183b71..6cae7cb 100644 --- a/internal/templates/template_files/dev/manifests/statefulset.tmpl +++ b/internal/templates/template_files/dev/manifests/statefulset.tmpl @@ -2,6 +2,7 @@ apiVersion: apps/v1 kind: StatefulSet metadata: name: devenv-{{.Name}} + namespace: {{.Namespace}} labels: app: devenv-{{.Name}} component: devenv @@ -127,4 +128,4 @@ spec: hostPath: path: {{.LocalPath}} type: DirectoryOrCreate - {{- end}} \ No newline at end of file + {{- end}} diff --git a/internal/templates/testdata/golden/env-vars.yaml b/internal/templates/testdata/golden/env-vars.yaml index 1d9015f..3764aa0 100644 --- a/internal/templates/testdata/golden/env-vars.yaml +++ b/internal/templates/testdata/golden/env-vars.yaml @@ -2,6 +2,7 @@ apiVersion: v1 kind: ConfigMap metadata: name: env-vars-testuser + namespace: devenv-test labels: app: devenv-testuser data: diff --git a/internal/templates/testdata/golden/ingress.yaml b/internal/templates/testdata/golden/ingress.yaml new file mode 100644 index 0000000..4d0c5ba --- /dev/null +++ b/internal/templates/testdata/golden/ingress.yaml @@ -0,0 +1,26 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: devenv-ingress-testuser + namespace: devenv-test + annotations: + nginx.ingress.kubernetes.io/force-ssl-redirect: "true" + cert-manager.io/cluster-issuer: "letsencrypt" + +spec: + ingressClassName: nginx + rules: + - host: testuser. + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: devenv-http-testuser + port: + name: http + tls: + - hosts: + - "*." + secretName: http-testuser-tls diff --git a/internal/templates/testdata/golden/service.yaml b/internal/templates/testdata/golden/service.yaml index 6453f86..9dd83e9 100644 --- a/internal/templates/testdata/golden/service.yaml +++ b/internal/templates/testdata/golden/service.yaml @@ -2,6 +2,7 @@ apiVersion: v1 kind: Service metadata: name: devenv-ssh-testuser + namespace: devenv-test labels: app: devenv-testuser service: ssh @@ -20,6 +21,7 @@ apiVersion: v1 kind: Service metadata: name: devenv-http-testuser + namespace: devenv-test labels: app: devenv-testuser service: http @@ -31,4 +33,4 @@ spec: - name: http port: 8080 targetPort: 8080 - protocol: TCP \ No newline at end of file + protocol: TCP diff --git a/internal/templates/testdata/golden/startup-scripts.yaml b/internal/templates/testdata/golden/startup-scripts.yaml index bdc2e09..250345c 100644 --- a/internal/templates/testdata/golden/startup-scripts.yaml +++ b/internal/templates/testdata/golden/startup-scripts.yaml @@ -2,6 +2,7 @@ apiVersion: v1 kind: ConfigMap metadata: name: startup-scripts-testuser + namespace: devenv-test labels: app: devenv-testuser data: @@ -355,4 +356,4 @@ data: git config --global user.name "${GIT_USER_NAME}" git config --global user.email "${GIT_USER_EMAIL}" - echo "User environment setup complete for testuser" \ No newline at end of file + echo "User environment setup complete for testuser" diff --git a/internal/templates/testdata/golden/statefulset.yaml b/internal/templates/testdata/golden/statefulset.yaml index e2ec0a7..33e7ae9 100644 --- a/internal/templates/testdata/golden/statefulset.yaml +++ b/internal/templates/testdata/golden/statefulset.yaml @@ -2,6 +2,7 @@ apiVersion: apps/v1 kind: StatefulSet metadata: name: devenv-testuser + namespace: devenv-test labels: app: devenv-testuser component: devenv @@ -106,4 +107,4 @@ spec: - name: config-volume hostPath: path: /mnt/config - type: DirectoryOrCreate \ No newline at end of file + type: DirectoryOrCreate From af9b0dd84cb66557e5e1a165bebefe6de8d13aeb Mon Sep 17 00:00:00 2001 From: spapa013 Date: Thu, 5 Mar 2026 22:25:08 +0000 Subject: [PATCH 11/11] fix(templates): :bug: add headless devenv-{{.Name}} governing Service to match StatefulSet serviceName while preserving existing SSH/HTTP services --- .../template_files/dev/manifests/service.tmpl | 18 ++++++++++++++++++ .../templates/testdata/golden/service.yaml | 18 ++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/internal/templates/template_files/dev/manifests/service.tmpl b/internal/templates/template_files/dev/manifests/service.tmpl index 4073269..1c821be 100644 --- a/internal/templates/template_files/dev/manifests/service.tmpl +++ b/internal/templates/template_files/dev/manifests/service.tmpl @@ -1,5 +1,23 @@ apiVersion: v1 kind: Service +metadata: + name: devenv-{{.Name}} + namespace: {{.Namespace}} + labels: + app: devenv-{{.Name}} + service: governing +spec: + clusterIP: None + selector: + app: devenv-{{.Name}} + ports: + - name: ssh + port: 22 + targetPort: 22 + protocol: TCP +--- +apiVersion: v1 +kind: Service metadata: name: devenv-ssh-{{.Name}} namespace: {{.Namespace}} diff --git a/internal/templates/testdata/golden/service.yaml b/internal/templates/testdata/golden/service.yaml index 9dd83e9..d7eff1a 100644 --- a/internal/templates/testdata/golden/service.yaml +++ b/internal/templates/testdata/golden/service.yaml @@ -1,5 +1,23 @@ apiVersion: v1 kind: Service +metadata: + name: devenv-testuser + namespace: devenv-test + labels: + app: devenv-testuser + service: governing +spec: + clusterIP: None + selector: + app: devenv-testuser + ports: + - name: ssh + port: 22 + targetPort: 22 + protocol: TCP +--- +apiVersion: v1 +kind: Service metadata: name: devenv-ssh-testuser namespace: devenv-test