diff --git a/cmd/server.go b/cmd/server.go index 4d867fa52d..36bd28e504 100644 --- a/cmd/server.go +++ b/cmd/server.go @@ -74,7 +74,7 @@ const redTermEnd = "\033[39m" var stringFlags = []stringFlag{ { name: AtlantisURLFlag, - description: "URL that Atlantis can be reached at. Defaults to http://$(hostname):$port where $port is from --" + PortFlag + ".", + description: "URL that Atlantis can be reached at. Defaults to http://$(hostname):$port where $port is from --" + PortFlag + ". Supports a base path ex. https://example.com/basepath.", }, { name: BitbucketUserFlag, @@ -254,7 +254,7 @@ func (s *ServerCmd) Init() *cobra.Command { Short: "Start the atlantis server", Long: `Start the atlantis server and listen for webhook calls.`, SilenceErrors: true, - SilenceUsage: s.SilenceOutput, + SilenceUsage: true, PreRunE: s.withErrPrint(func(cmd *cobra.Command, args []string) error { return s.preRun() }), @@ -344,6 +344,7 @@ func (s *ServerCmd) run() error { server, err := s.ServerCreator.NewServer(userConfig, server.Config{ AllowForkPRsFlag: AllowForkPRsFlag, AllowRepoConfigFlag: AllowRepoConfigFlag, + AtlantisURLFlag: AtlantisURLFlag, AtlantisVersion: s.AtlantisVersion, }) if err != nil { diff --git a/server/locks_controller.go b/server/locks_controller.go index 8000e9984e..9c69abfa5a 100644 --- a/server/locks_controller.go +++ b/server/locks_controller.go @@ -16,6 +16,7 @@ import ( // LocksController handles all requests relating to Atlantis locks. type LocksController struct { AtlantisVersion string + AtlantisURL *url.URL Locker locking.Locker Logger *logging.SimpleLogger VCSClient vcs.ClientProxy @@ -57,8 +58,12 @@ func (l *LocksController) GetLock(w http.ResponseWriter, r *http.Request) { LockedBy: lock.Pull.Author, Workspace: lock.Workspace, AtlantisVersion: l.AtlantisVersion, + CleanedBasePath: l.AtlantisURL.Path, + } + err = l.LockDetailTemplate.Execute(w, viewData) + if err != nil { + l.Logger.Err(err.Error()) } - l.LockDetailTemplate.Execute(w, viewData) // nolint: errcheck } // DeleteLock handles deleting the lock at id and commenting back on the diff --git a/server/locks_controller_test.go b/server/locks_controller_test.go index ce6929e49e..7d76130aa9 100644 --- a/server/locks_controller_test.go +++ b/server/locks_controller_test.go @@ -5,6 +5,7 @@ import ( "errors" "net/http" "net/http/httptest" + "net/url" "reflect" "testing" @@ -18,6 +19,7 @@ import ( vcsmocks "github.com/runatlantis/atlantis/server/events/vcs/mocks" "github.com/runatlantis/atlantis/server/logging" sMocks "github.com/runatlantis/atlantis/server/mocks" + . "github.com/runatlantis/atlantis/testing" ) func AnyRepo() models.Repo { @@ -90,11 +92,14 @@ func TestGetLock_Success(t *testing.T) { Workspace: "workspace", }, nil) tmpl := sMocks.NewMockTemplateWriter() + atlantisURL, err := url.Parse("https://example.com/basepath") + Ok(t, err) lc := server.LocksController{ Logger: logging.NewNoopLogger(), Locker: l, LockDetailTemplate: tmpl, AtlantisVersion: "1300135", + AtlantisURL: atlantisURL, } req, _ := http.NewRequest("GET", "", bytes.NewBuffer(nil)) req = mux.SetURLVars(req, map[string]string{"id": "id"}) @@ -109,6 +114,7 @@ func TestGetLock_Success(t *testing.T) { LockedBy: "lkysow", Workspace: "workspace", AtlantisVersion: "1300135", + CleanedBasePath: "/basepath", }) responseContains(t, w, http.StatusOK, "") } diff --git a/server/router.go b/server/router.go index 4f4e2840da..18e1055949 100644 --- a/server/router.go +++ b/server/router.go @@ -1,7 +1,6 @@ package server import ( - "fmt" "net/url" "github.com/gorilla/mux" @@ -19,13 +18,18 @@ type Router struct { // LockViewRouteIDQueryParam is the query parameter needed to construct the // lock view: underlying.Get(LockViewRouteName).URL(LockViewRouteIDQueryParam, "my id"). LockViewRouteIDQueryParam string - // AtlantisURL is the fully qualified URL (scheme included) that Atlantis is - // being served at, ex: https://example.com. - AtlantisURL string + // AtlantisURL is the fully qualified URL that Atlantis is + // accessible from externally. + AtlantisURL *url.URL } // GenerateLockURL returns a fully qualified URL to view the lock at lockID. func (r *Router) GenerateLockURL(lockID string) string { - path, _ := r.Underlying.Get(r.LockViewRouteName).URL(r.LockViewRouteIDQueryParam, url.QueryEscape(lockID)) - return fmt.Sprintf("%s%s", r.AtlantisURL, path) + lockURL, _ := r.Underlying.Get(r.LockViewRouteName).URL(r.LockViewRouteIDQueryParam, url.QueryEscape(lockID)) + // At this point, lockURL will just be a path because r.Underlying isn't + // configured with host or scheme information. So to generate the fully + // qualified LockURL we just append the router's url to our base url. + // We're not doing anything fancy here with the actual url object because + // golang likes to double escape the lockURL path when using url.Parse(). + return r.AtlantisURL.String() + lockURL.String() } diff --git a/server/router_test.go b/server/router_test.go index 91bf9f3fc9..3a79d56404 100644 --- a/server/router_test.go +++ b/server/router_test.go @@ -10,18 +10,53 @@ import ( ) func TestRouter_GenerateLockURL(t *testing.T) { - queryParam := "queryparam" - routeName := "routename" - atlantisURL := "https://example.com" + cases := []struct { + AtlantisURL string + ExpURL string + }{ + { + "http://localhost:4141", + "http://localhost:4141/lock?id=lkysow%252Fatlantis-example%252F.%252Fdefault", + }, + { + "https://localhost:4141", + "https://localhost:4141/lock?id=lkysow%252Fatlantis-example%252F.%252Fdefault", + }, + { + "https://localhost:4141/", + "https://localhost:4141/lock?id=lkysow%252Fatlantis-example%252F.%252Fdefault", + }, + { + "https://example.com/basepath", + "https://example.com/basepath/lock?id=lkysow%252Fatlantis-example%252F.%252Fdefault", + }, + { + "https://example.com/basepath/", + "https://example.com/basepath/lock?id=lkysow%252Fatlantis-example%252F.%252Fdefault", + }, + { + "https://example.com/path/1/", + "https://example.com/path/1/lock?id=lkysow%252Fatlantis-example%252F.%252Fdefault", + }, + } + queryParam := "id" + routeName := "routename" underlyingRouter := mux.NewRouter() - underlyingRouter.HandleFunc("/lock", func(_ http.ResponseWriter, _ *http.Request) {}).Methods("GET").Queries(queryParam, "{queryparam}").Name(routeName) + underlyingRouter.HandleFunc("/lock", func(_ http.ResponseWriter, _ *http.Request) {}).Methods("GET").Queries(queryParam, "{id}").Name(routeName) + + for _, c := range cases { + t.Run(c.AtlantisURL, func(t *testing.T) { + atlantisURL, err := server.ParseAtlantisURL(c.AtlantisURL) + Ok(t, err) - router := &server.Router{ - AtlantisURL: atlantisURL, - LockViewRouteIDQueryParam: queryParam, - LockViewRouteName: routeName, - Underlying: underlyingRouter, + router := &server.Router{ + AtlantisURL: atlantisURL, + LockViewRouteIDQueryParam: queryParam, + LockViewRouteName: routeName, + Underlying: underlyingRouter, + } + Equals(t, c.ExpURL, router.GenerateLockURL("lkysow/atlantis-example/./default")) + }) } - Equals(t, "https://example.com/lock?queryparam=myid", router.GenerateLockURL("myid")) } diff --git a/server/server.go b/server/server.go index d938335077..c81f649cc9 100644 --- a/server/server.go +++ b/server/server.go @@ -64,6 +64,7 @@ const ( // Server runs the Atlantis web server. type Server struct { AtlantisVersion string + AtlantisURL *url.URL Router *mux.Router Port int CommandRunner *events.DefaultCommandRunner @@ -114,6 +115,7 @@ type UserConfig struct { type Config struct { AllowForkPRsFlag string AllowRepoConfigFlag string + AtlantisURLFlag string AtlantisVersion string } @@ -229,9 +231,14 @@ func NewServer(userConfig UserConfig, config Config) (*Server, error) { projectLocker := &events.DefaultProjectLocker{ Locker: lockingClient, } + parsedURL, err := ParseAtlantisURL(userConfig.AtlantisURL) + if err != nil { + return nil, errors.Wrapf(err, + "parsing --%s flag %q", config.AtlantisURLFlag, userConfig.AtlantisURL) + } underlyingRouter := mux.NewRouter() router := &Router{ - AtlantisURL: userConfig.AtlantisURL, + AtlantisURL: parsedURL, LockViewRouteIDQueryParam: LockViewRouteIDQueryParam, LockViewRouteName: LockViewRouteName, Underlying: underlyingRouter, @@ -309,6 +316,7 @@ func NewServer(userConfig UserConfig, config Config) (*Server, error) { } locksController := &LocksController{ AtlantisVersion: config.AtlantisVersion, + AtlantisURL: parsedURL, Locker: lockingClient, Logger: logger, VCSClient: vcsClient, @@ -334,6 +342,7 @@ func NewServer(userConfig UserConfig, config Config) (*Server, error) { } return &Server{ AtlantisVersion: config.AtlantisVersion, + AtlantisURL: parsedURL, Router: underlyingRouter, Port: userConfig.Port, CommandRunner: commandRunner, @@ -411,17 +420,22 @@ func (s *Server) Index(w http.ResponseWriter, _ *http.Request) { for id, v := range locks { lockURL, _ := s.Router.Get(LockViewRouteName).URL("id", url.QueryEscape(id)) lockResults = append(lockResults, LockIndexData{ - LockURL: lockURL.String(), + // NOTE: must use .String() instead of .Path because we need the + // query params as part of the lock URL. + LockPath: lockURL.String(), RepoFullName: v.Project.RepoFullName, PullNum: v.Pull.Num, Time: v.Time, }) } - // nolint: errcheck - s.IndexTemplate.Execute(w, IndexData{ + err = s.IndexTemplate.Execute(w, IndexData{ Locks: lockResults, AtlantisVersion: s.AtlantisVersion, + CleanedBasePath: s.AtlantisURL.Path, }) + if err != nil { + s.Logger.Err(err.Error()) + } } // Healthz returns the health check response. It always returns a 200 currently. @@ -439,3 +453,21 @@ func (s *Server) Healthz(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") w.Write(data) // nolint: errcheck } + +// ParseAtlantisURL parses the user-passed atlantis URL to ensure it is valid +// and we can use it in our templates. +// It removes any trailing slashes from the path so we can concatenate it +// with other paths without checking. +func ParseAtlantisURL(u string) (*url.URL, error) { + parsed, err := url.Parse(u) + if err != nil { + return nil, err + } + if !(parsed.Scheme == "http" || parsed.Scheme == "https") { + return nil, errors.New("http or https must be specified") + } + // We want the path to end without a trailing slash so we know how to + // use it in the rest of the program. + parsed.Path = strings.TrimSuffix(parsed.Path, "/") + return parsed, nil +} diff --git a/server/server_test.go b/server/server_test.go index 5e6e408e6c..951d1f6e1e 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -19,6 +19,7 @@ import ( "io/ioutil" "net/http" "net/http/httptest" + "net/url" "strings" "testing" "time" @@ -37,11 +38,24 @@ func TestNewServer(t *testing.T) { tmpDir, err := ioutil.TempDir("", "") Ok(t, err) _, err = server.NewServer(server.UserConfig{ - DataDir: tmpDir, + DataDir: tmpDir, + AtlantisURL: "http://example.com", }, server.Config{}) Ok(t, err) } +func TestNewServer_InvalidAtlantisURL(t *testing.T) { + tmpDir, err := ioutil.TempDir("", "") + Ok(t, err) + _, err = server.NewServer(server.UserConfig{ + DataDir: tmpDir, + AtlantisURL: "example.com", + }, server.Config{ + AtlantisURLFlag: "atlantis-url", + }) + ErrEquals(t, "parsing --atlantis-url flag \"example.com\": http or https must be specified", err) +} + func TestIndex_LockErr(t *testing.T) { t.Log("index should return a 503 if unable to list locks") RegisterMockTestingT(t) @@ -63,12 +77,12 @@ func TestIndex_Success(t *testing.T) { // These are the locks that we expect to be rendered. now := time.Now() locks := map[string]models.ProjectLock{ - "id1": { + "lkysow/atlantis-example/./default": { Pull: models.PullRequest{ Num: 9, }, Project: models.Project{ - RepoFullName: "owner/repo", + RepoFullName: "lkysow/atlantis-example", }, Time: now, }, @@ -78,12 +92,16 @@ func TestIndex_Success(t *testing.T) { r := mux.NewRouter() atlantisVersion := "0.3.1" // Need to create a lock route since the server expects this route to exist. - r.NewRoute().Path("").Name(server.LockViewRouteName) + r.NewRoute().Path("/lock"). + Queries("id", "{id}").Name(server.LockViewRouteName) + u, err := url.Parse("https://example.com") + Ok(t, err) s := server.Server{ Locker: l, IndexTemplate: it, Router: r, AtlantisVersion: atlantisVersion, + AtlantisURL: u, } req, _ := http.NewRequest("GET", "", bytes.NewBuffer(nil)) w := httptest.NewRecorder() @@ -91,8 +109,8 @@ func TestIndex_Success(t *testing.T) { it.VerifyWasCalledOnce().Execute(w, server.IndexData{ Locks: []server.LockIndexData{ { - LockURL: "", - RepoFullName: "owner/repo", + LockPath: "/lock?id=lkysow%252Fatlantis-example%252F.%252Fdefault", + RepoFullName: "lkysow/atlantis-example", PullNum: 9, Time: now, }, @@ -116,6 +134,86 @@ func TestHealthz(t *testing.T) { }`, string(body)) } +func TestParseAtlantisURL(t *testing.T) { + cases := []struct { + In string + ExpErr string + ExpURL string + }{ + // Valid URLs should work. + { + In: "https://example.com", + ExpURL: "https://example.com", + }, + { + In: "http://example.com", + ExpURL: "http://example.com", + }, + { + In: "http://example.com/", + ExpURL: "http://example.com", + }, + { + In: "http://example.com", + ExpURL: "http://example.com", + }, + { + In: "http://example.com:4141", + ExpURL: "http://example.com:4141", + }, + { + In: "http://example.com:4141/", + ExpURL: "http://example.com:4141", + }, + { + In: "http://example.com/baseurl", + ExpURL: "http://example.com/baseurl", + }, + { + In: "http://example.com/baseurl/", + ExpURL: "http://example.com/baseurl", + }, + { + In: "http://example.com/baseurl/test", + ExpURL: "http://example.com/baseurl/test", + }, + + // Must be valid URL. + { + In: "::", + ExpErr: "parse ::: missing protocol scheme", + }, + + // Must be absolute. + { + In: "/hi", + ExpErr: "http or https must be specified", + }, + + // Must have http or https scheme.. + { + In: "localhost/test", + ExpErr: "http or https must be specified", + }, + { + In: "httpl://localhost/test", + ExpErr: "http or https must be specified", + }, + } + + for _, c := range cases { + t.Run(c.In, func(t *testing.T) { + act, err := server.ParseAtlantisURL(c.In) + if c.ExpErr != "" { + ErrEquals(t, c.ExpErr, err) + } else { + Ok(t, err) + Equals(t, c.ExpURL, act.String()) + } + }) + } +} + func responseContains(t *testing.T, r *httptest.ResponseRecorder, status int, bodySubstr string) { t.Helper() body, err := ioutil.ReadAll(r.Result().Body) diff --git a/server/web_templates.go b/server/web_templates.go index de1fb8a7c9..17ac82d1b1 100644 --- a/server/web_templates.go +++ b/server/web_templates.go @@ -31,7 +31,7 @@ type TemplateWriter interface { // LockIndexData holds the fields needed to display the index view for locks. type LockIndexData struct { - LockURL string + LockPath string RepoFullName string PullNum int Time time.Time @@ -41,6 +41,10 @@ type LockIndexData struct { type IndexData struct { Locks []LockIndexData AtlantisVersion string + // CleanedBasePath is the path Atlantis is accessible at externally. If + // not using a path-based proxy, this will be an empty string. Never ends + // in a '/' (hence "cleaned"). + CleanedBasePath string } var indexTemplate = template.Must(template.New("index.html.tmpl").Parse(` @@ -52,7 +56,7 @@ var indexTemplate = template.Must(template.New("index.html.tmpl").Parse(` - + - - - - + + + +
atlantis
Plan discarded and unlocked!
Locks
{{ if .Locks }} + {{ $basePath := .CleanedBasePath }} {{ range .Locks }} - +