Merge pull request #357 from runatlantis/basepath

Allow running Atlantis behind a path-based proxy
This commit is contained in:
Luke Kysow
2018-11-21 12:48:21 -06:00
committed by GitHub
8 changed files with 238 additions and 49 deletions

View File

@@ -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 {

View File

@@ -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

View File

@@ -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, "")
}

View File

@@ -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()
}

View File

@@ -10,18 +10,53 @@ import (
)
func TestRouter_GenerateLockURL(t *testing.T) {
queryParam := "queryparam"
routeName := "routename"
atlantisURL := "https://example.com"
underlyingRouter := mux.NewRouter()
underlyingRouter.HandleFunc("/lock", func(_ http.ResponseWriter, _ *http.Request) {}).Methods("GET").Queries(queryParam, "{queryparam}").Name(routeName)
router := &server.Router{
AtlantisURL: atlantisURL,
LockViewRouteIDQueryParam: queryParam,
LockViewRouteName: routeName,
Underlying: underlyingRouter,
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, "{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,
}
Equals(t, c.ExpURL, router.GenerateLockURL("lkysow/atlantis-example/./default"))
})
}
Equals(t, "https://example.com/lock?queryparam=myid", router.GenerateLockURL("myid"))
}

View File

@@ -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
}

View File

@@ -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)

View File

@@ -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(`
<meta name="description" content="">
<meta name="author" content="">
<meta name="viewport" content="width=device-width, initial-scale=1">
<script src="/static/js/jquery-3.2.1.min.js"></script>
<script src="{{ .CleanedBasePath }}/static/js/jquery-3.2.1.min.js"></script>
<script>
$(document).ready(function () {
$("p.js-discard-success").toggle(document.URL.indexOf("discard=true") !== -1);
@@ -61,15 +65,15 @@ var indexTemplate = template.Must(template.New("index.html.tmpl").Parse(`
$("p.js-discard-success").fadeOut('slow');
}, 5000); // <-- time in milliseconds
</script>
<link rel="stylesheet" href="/static/css/normalize.css">
<link rel="stylesheet" href="/static/css/skeleton.css">
<link rel="stylesheet" href="/static/css/custom.css">
<link rel="icon" type="image/png" href="/static/images/atlantis-icon.png">
<link rel="stylesheet" href="{{ .CleanedBasePath }}/static/css/normalize.css">
<link rel="stylesheet" href="{{ .CleanedBasePath }}/static/css/skeleton.css">
<link rel="stylesheet" href="{{ .CleanedBasePath }}/static/css/custom.css">
<link rel="icon" type="image/png" href="{{ .CleanedBasePath }}/static/images/atlantis-icon.png">
</head>
<body>
<div class="container">
<section class="header">
<a title="atlantis" href="/"><img src="/static/images/atlantis-icon.png"/></a>
<a title="atlantis" href="{{ .CleanedBasePath }}/"><img src="{{ .CleanedBasePath }}/static/images/atlantis-icon.png"/></a>
<p class="title-heading">atlantis</p>
<p class="js-discard-success"><strong>Plan discarded and unlocked!</strong></p>
</section>
@@ -82,8 +86,9 @@ var indexTemplate = template.Must(template.New("index.html.tmpl").Parse(`
<section>
<p class="title-heading small"><strong>Locks</strong></p>
{{ if .Locks }}
{{ $basePath := .CleanedBasePath }}
{{ range .Locks }}
<a href="{{.LockURL}}">
<a href="{{ $basePath }}{{.LockPath}}">
<div class="twelve columns button content lock-row">
<div class="list-title">{{.RepoFullName}} - <span class="heading-font-size">#{{.PullNum}}</span></div>
<div class="list-status"><code>Locked</code></div>
@@ -105,7 +110,6 @@ v{{ .AtlantisVersion }}
// LockDetailData holds the fields needed to display the lock detail view.
type LockDetailData struct {
UnlockURL string
LockKeyEncoded string
LockKey string
RepoOwner string
@@ -115,6 +119,10 @@ type LockDetailData struct {
Workspace string
Time time.Time
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 lockTemplate = template.Must(template.New("lock.html.tmpl").Parse(`
@@ -126,16 +134,16 @@ var lockTemplate = template.Must(template.New("lock.html.tmpl").Parse(`
<meta name="description" content="">
<meta name="author" content="">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="/static/css/normalize.css">
<link rel="stylesheet" href="/static/css/skeleton.css">
<link rel="stylesheet" href="/static/css/custom.css">
<link rel="icon" type="image/png" href="/static/images/atlantis-icon.png">
<script src="/static/js/jquery-3.2.1.min.js"></script>
<link rel="stylesheet" href="{{ .CleanedBasePath }}/static/css/normalize.css">
<link rel="stylesheet" href="{{ .CleanedBasePath }}/static/css/skeleton.css">
<link rel="stylesheet" href="{{ .CleanedBasePath }}/static/css/custom.css">
<link rel="icon" type="image/png" href="{{ .CleanedBasePath }}/static/images/atlantis-icon.png">
<script src="{{ .CleanedBasePath }}/static/js/jquery-3.2.1.min.js"></script>
</head>
<body>
<div class="container">
<section class="header">
<a title="atlantis" href="/"><img src="/static/images/atlantis-icon.png"/></a>
<a title="atlantis" href="{{ .CleanedBasePath }}/"><img src="{{ .CleanedBasePath }}/static/images/atlantis-icon.png"/></a>
<p class="title-heading">atlantis</p>
<p class="title-heading"><strong>{{.LockKey}}</strong> <code>Locked</code></p>
</section>
@@ -200,10 +208,10 @@ v{{ .AtlantisVersion }}
btnDiscard.click(function() {
$.ajax({
url: '/locks?id='+lockId,
url: '{{ .CleanedBasePath }}/locks?id='+lockId,
type: 'DELETE',
success: function(result) {
window.location.replace("/?discard=true");
window.location.replace("{{ .CleanedBasePath }}/?discard=true");
}
});
});