diff --git a/coderd/templatebuilder/bases/quickstart/install-languages.sh.tftpl b/coderd/templatebuilder/bases/quickstart/install-languages.sh.tftpl index 6babbf6288..1c6a7dc7f2 100644 --- a/coderd/templatebuilder/bases/quickstart/install-languages.sh.tftpl +++ b/coderd/templatebuilder/bases/quickstart/install-languages.sh.tftpl @@ -85,7 +85,10 @@ if has_language java; then fi if has_language cpp; then - if command -v gcc >/dev/null 2>&1; then + # Guard on every tool the install below provides, not just gcc: the base + # image ships gcc but not cmake. + if command -v gcc >/dev/null 2>&1 && command -v g++ >/dev/null 2>&1 \ + && command -v make >/dev/null 2>&1 && command -v cmake >/dev/null 2>&1; then echo "C/C++: $(gcc --version | head -1)" else echo "Installing C/C++ toolchain..." diff --git a/coderd/templatebuilder/compose_test.go b/coderd/templatebuilder/compose_test.go index 03edde0c68..c3e10a2c7b 100644 --- a/coderd/templatebuilder/compose_test.go +++ b/coderd/templatebuilder/compose_test.go @@ -6,7 +6,12 @@ import ( "errors" "io" "io/fs" + "os" + "os/exec" + "path/filepath" "regexp" + "runtime" + "strings" "testing" "github.com/stretchr/testify/require" @@ -700,3 +705,70 @@ func extractTar(t *testing.T, data []byte) map[string]string { } return files } + +// TestQuickstartCppInstallGuard verifies the C/C++ branch of the quickstart +// install script installs the toolchain when only part of it is present. The +// base image ships gcc but not cmake, so guarding on gcc alone skipped the +// install and still reported success. +func TestQuickstartCppInstallGuard(t *testing.T) { + t.Parallel() + if runtime.GOOS == "windows" { + t.Skip("requires bash") + } + bash, err := exec.LookPath("bash") + require.NoError(t, err) + head, err := exec.LookPath("head") + require.NoError(t, err) + + fsys, err := templatebuilder.BaseTemplateFS("quickstart") + require.NoError(t, err) + raw, err := fs.ReadFile(fsys, "install-languages.sh.tftpl") + require.NoError(t, err) + // Render the template the way Terraform's templatefile would for the + // two constructs the script uses. + script := strings.ReplaceAll(string(raw), "${LANGUAGES}", "cpp") + script = strings.ReplaceAll(script, "$$", "$") + + // gcc is always present: the script echoes its version after installing. + tests := []struct { + name string + present []string + wantInstall bool + }{ + {name: "AllPresent", present: []string{"gcc", "g++", "make", "cmake"}, wantInstall: false}, + {name: "MissingCmake", present: []string{"gcc", "g++", "make"}, wantInstall: true}, + {name: "OnlyGcc", present: []string{"gcc"}, wantInstall: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + // PATH holds only stubs, so the host's real toolchain can't + // influence the result. + binDir := t.TempDir() + writeExecutable := func(name, content string) { + path := filepath.Join(binDir, name) + require.NoError(t, os.WriteFile(path, []byte(content), 0o600)) + require.NoError(t, os.Chmod(path, 0o700)) + } + require.NoError(t, os.Symlink(head, filepath.Join(binDir, "head"))) + stub := "#!" + bash + "\necho stub 1.0\n" + for _, tool := range tt.present { + writeExecutable(tool, stub) + } + // The sudo stub records its arguments and "installs" nothing. + logPath := filepath.Join(t.TempDir(), "sudo.log") + sudo := "#!" + bash + "\necho \"$@\" >> " + logPath + "\n" + writeExecutable("sudo", sudo) + + cmd := exec.Command(bash, "-c", script) + cmd.Env = []string{"PATH=" + binDir, "HOME=" + t.TempDir()} + out, err := cmd.CombinedOutput() + require.NoError(t, err, string(out)) + + logged, _ := os.ReadFile(logPath) + installed := strings.Contains(string(logged), "install") + require.Equal(t, tt.wantInstall, installed, "output: %s", out) + }) + } +}