From c8059a2ea8b29447a8deebe18a65f202c250773d Mon Sep 17 00:00:00 2001 From: Jordan Jensen Date: Fri, 16 Jan 2026 18:05:57 -0800 Subject: [PATCH 1/2] feat: Validate entrypoint exists --- internal/clients/connect/capabilities.go | 35 ++++++++++++++++ internal/clients/connect/capabilities_test.go | 42 +++++++++++++++++++ internal/types/error.go | 1 + 3 files changed, 78 insertions(+) diff --git a/internal/clients/connect/capabilities.go b/internal/clients/connect/capabilities.go index 69545e1765..0bdc9d3659 100644 --- a/internal/clients/connect/capabilities.go +++ b/internal/clients/connect/capabilities.go @@ -59,6 +59,37 @@ func checkRequirementsFile(base util.AbsolutePath, cfg *config.Config) error { return nil } +const entrypointFileMissing = `entrypoint file '%s' does not exist` + +type entrypointErrDetails struct { + Entrypoint string `json:"entrypoint"` +} + +func checkEntrypoint(base util.AbsolutePath, cfg *config.Config) error { + // Module references (e.g., "app:myapp", "shiny.express.app:app_2e_py") + // are not file paths and should not be validated as files. + // Module references are currently constructed by reading the file so we + // already know the file exists at this point. + if strings.Contains(cfg.Entrypoint, ":") { + return nil + } + + entrypointPath := base.Join(cfg.Entrypoint) + exists, err := entrypointPath.Exists() + if err != nil { + return err + } + + if !exists { + return types.NewAgentError( + types.ErrorEntrypointNotFound, + fmt.Errorf(entrypointFileMissing, cfg.Entrypoint), + entrypointErrDetails{Entrypoint: cfg.Entrypoint}, + ) + } + return nil +} + func (c *ConnectClient) CheckCapabilities(base util.AbsolutePath, cfg *config.Config, contentID *types.ContentID, log logging.Logger) error { if contentID != nil && *contentID != "" { err := c.ValidateDeploymentTarget(*contentID, cfg, log) @@ -72,6 +103,10 @@ func (c *ConnectClient) CheckCapabilities(base util.AbsolutePath, cfg *config.Co return err } } + err := checkEntrypoint(base, cfg) + if err != nil { + return err + } settings, err := c.GetSettings(base, cfg, log) if err != nil { return err diff --git a/internal/clients/connect/capabilities_test.go b/internal/clients/connect/capabilities_test.go index 9128cf4695..4fe9a98e5e 100644 --- a/internal/clients/connect/capabilities_test.go +++ b/internal/clients/connect/capabilities_test.go @@ -11,6 +11,7 @@ import ( "github.com/posit-dev/publisher/internal/clients/connect/server_settings" "github.com/posit-dev/publisher/internal/config" "github.com/posit-dev/publisher/internal/contenttypes" + "github.com/posit-dev/publisher/internal/util" "github.com/posit-dev/publisher/internal/util/utiltest" ) @@ -307,3 +308,44 @@ func (s *CapabilitiesSuite) TestKubernetesGPULimits() { s.ErrorContains(a.checkConfig(makeGPURequest(5, 0)), "amd_gpu_limit value of 5 is higher than configured maximum of 1 on this server") s.ErrorContains(a.checkConfig(makeGPURequest(0, 5)), "nvidia_gpu_limit value of 5 is higher than configured maximum of 2 on this server") } + +func (s *CapabilitiesSuite) TestCheckEntrypointNotFound() { + base := util.NewAbsolutePath(s.T().TempDir(), nil) + cfg := &config.Config{ + Entrypoint: "nonexistent.py", + } + err := checkEntrypoint(base, cfg) + s.Error(err) + s.ErrorContains(err, "entrypoint file 'nonexistent.py' does not exist") +} + +func (s *CapabilitiesSuite) TestCheckEntrypointExists() { + base := util.NewAbsolutePath(s.T().TempDir(), nil) + // Create a temporary file to test against + entrypointFile := base.Join("app.py") + err := entrypointFile.WriteFile([]byte("# test app"), 0644) + s.NoError(err) + + cfg := &config.Config{ + Entrypoint: "app.py", + } + err = checkEntrypoint(base, cfg) + s.NoError(err) +} + +func (s *CapabilitiesSuite) TestCheckEntrypointModuleReference() { + base := util.NewAbsolutePath(s.T().TempDir(), nil) + // Module references like "app:myapp" or "shiny.express.app:app_2e_py" + // should not be validated as file paths + cfg := &config.Config{ + Entrypoint: "shiny.express.app:app_2e_py", + } + err := checkEntrypoint(base, cfg) + s.NoError(err) + + cfg2 := &config.Config{ + Entrypoint: "app:myapp", + } + err = checkEntrypoint(base, cfg2) + s.NoError(err) +} diff --git a/internal/types/error.go b/internal/types/error.go index 4d807ead40..3414b3f715 100644 --- a/internal/types/error.go +++ b/internal/types/error.go @@ -38,6 +38,7 @@ const ( ErrorDeviceAuthSlowDown ErrorCode = "deviceAuthSlowDown" ErrorDeviceAuthAccessDenied ErrorCode = "deviceAuthAccessDenied" ErrorDeviceAuthExpiredToken ErrorCode = "deviceAuthExpiredToken" + ErrorEntrypointNotFound ErrorCode = "entrypointNotFound" ) type EventableError interface { From 44ccd36e1e7c6563c4e4d1f685e92447b26e6572 Mon Sep 17 00:00:00 2001 From: Jordan Jensen Date: Fri, 16 Jan 2026 18:10:19 -0800 Subject: [PATCH 2/2] Add CHANGELOG --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c2d037f213..c53ecd6cc7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Deployments now fail early with a clear error if the entrypoint file does not exist. (#2964) - Log messages in the Publisher Logs panel are no longer duplicated when multiple deployments are made. (#3069) - Fix creating deployments of R Markdown documents that include parameters on Connect Cloud. (#3388) - The Publisher outputs (detailed logs) have been consolidated into one channel called "Posit Publisher". (#3168)