Skip to content

Commit

Permalink
Prefix URLs in templates with AtlantisURL
Browse files Browse the repository at this point in the history
Resolves runatlantis#213

Allows users to run Atlantis behind a shared reverse proxy, which is a fairly
common use case.
  • Loading branch information
jml committed Nov 6, 2018
1 parent d52b3fd commit 670131f
Show file tree
Hide file tree
Showing 9 changed files with 132 additions and 26 deletions.
2 changes: 1 addition & 1 deletion cmd/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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, e.g. https://example.com/basepath",
},
{
name: BitbucketUserFlag,
Expand Down
2 changes: 2 additions & 0 deletions server/locks_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -57,6 +58,7 @@ func (l *LocksController) GetLock(w http.ResponseWriter, r *http.Request) {
LockedBy: lock.Pull.Author,
Workspace: lock.Workspace,
AtlantisVersion: l.AtlantisVersion,
AtlantisURL: l.AtlantisURL,
}
l.LockDetailTemplate.Execute(w, viewData) // nolint: errcheck
}
Expand Down
4 changes: 2 additions & 2 deletions server/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,11 @@ type Router struct {
LockViewRouteIDQueryParam string
// AtlantisURL is the fully qualified URL (scheme included) that Atlantis is
// being served at, ex: https://example.com.
AtlantisURL string
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)
return fmt.Sprintf("%s%s", r.AtlantisURL.String(), path)
}
6 changes: 4 additions & 2 deletions server/router_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package server_test

import (
"net/http"
"net/url"
"testing"

"github.com/gorilla/mux"
Expand All @@ -12,13 +13,14 @@ import (
func TestRouter_GenerateLockURL(t *testing.T) {
queryParam := "queryparam"
routeName := "routename"
atlantisURL := "https://example.com"
atlantisURL, err := url.Parse("https://example.com")
Ok(t, err)

underlyingRouter := mux.NewRouter()
underlyingRouter.HandleFunc("/lock", func(_ http.ResponseWriter, _ *http.Request) {}).Methods("GET").Queries(queryParam, "{queryparam}").Name(routeName)

router := &server.Router{
AtlantisURL: atlantisURL,
AtlantisURL: *atlantisURL,
LockViewRouteIDQueryParam: queryParam,
LockViewRouteName: routeName,
Underlying: underlyingRouter,
Expand Down
16 changes: 14 additions & 2 deletions server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -229,9 +230,17 @@ func NewServer(userConfig UserConfig, config Config) (*Server, error) {
projectLocker := &events.DefaultProjectLocker{
Locker: lockingClient,
}
atlantisURL, err := url.Parse(userConfig.AtlantisURL)
if err != nil {
return nil, errors.Wrap(err, "parsing atlantis URL")
}
atlantisURL, err = NormalizeBaseURL(atlantisURL)
if err != nil {
return nil, errors.Wrap(err, "normalizing atlantis URL")
}
underlyingRouter := mux.NewRouter()
router := &Router{
AtlantisURL: userConfig.AtlantisURL,
AtlantisURL: *atlantisURL,
LockViewRouteIDQueryParam: LockViewRouteIDQueryParam,
LockViewRouteName: LockViewRouteName,
Underlying: underlyingRouter,
Expand Down Expand Up @@ -309,6 +318,7 @@ func NewServer(userConfig UserConfig, config Config) (*Server, error) {
}
locksController := &LocksController{
AtlantisVersion: config.AtlantisVersion,
AtlantisURL: *atlantisURL,
Locker: lockingClient,
Logger: logger,
VCSClient: vcsClient,
Expand All @@ -334,6 +344,7 @@ func NewServer(userConfig UserConfig, config Config) (*Server, error) {
}
return &Server{
AtlantisVersion: config.AtlantisVersion,
AtlantisURL: *atlantisURL,
Router: underlyingRouter,
Port: userConfig.Port,
CommandRunner: commandRunner,
Expand Down Expand Up @@ -411,7 +422,7 @@ 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(),
LockURL: *lockURL,
RepoFullName: v.Project.RepoFullName,
PullNum: v.Pull.Num,
Time: v.Time,
Expand All @@ -421,6 +432,7 @@ func (s *Server) Index(w http.ResponseWriter, _ *http.Request) {
s.IndexTemplate.Execute(w, IndexData{
Locks: lockResults,
AtlantisVersion: s.AtlantisVersion,
AtlantisURL: s.AtlantisURL,
})
}

Expand Down
6 changes: 4 additions & 2 deletions server/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import (
"io/ioutil"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
Expand All @@ -37,7 +38,8 @@ 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)
}
Expand Down Expand Up @@ -91,7 +93,7 @@ func TestIndex_Success(t *testing.T) {
it.VerifyWasCalledOnce().Execute(w, server.IndexData{
Locks: []server.LockIndexData{
{
LockURL: "",
LockURL: url.URL{},
RepoFullName: "owner/repo",
PullNum: 9,
Time: now,
Expand Down
24 changes: 24 additions & 0 deletions server/url.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package server

import (
"fmt"
"net/url"
"strings"
)

// NormalizeBaseURL ensures the given URL is a valid base URL for Atlantis.
//
// URLs that are fundamentally invalid (e.g. "hi") will return an error.
// Otherwise, the returned URL will have no trailing slashes and be guaranteed
// to be suitable for use as a base URL.
func NormalizeBaseURL(u *url.URL) (*url.URL, error) {
if !u.IsAbs() {
return nil, fmt.Errorf("Base URLs must be absolute.")
}
if !(u.Scheme == "http" || u.Scheme == "https") {
return nil, fmt.Errorf("Base URLs must be HTTP or HTTPS.")
}
out := *u
out.Path = strings.TrimRight(out.Path, "/")
return &out, nil
}
62 changes: 62 additions & 0 deletions server/url_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package server_test

import (
"net/url"
"testing"

"github.com/runatlantis/atlantis/server"
. "github.com/runatlantis/atlantis/testing"
)

func TestNormalizeBaseURL_Valid(t *testing.T) {
t.Log("When given a valid base URL, NormalizeBaseURL returns such URLs unchanged.")
examples := []string{
"https://example.com",
"https://example.com/some/path",
"http://example.com:8080",
}
for _, example := range examples {
url, err := url.Parse(example)
Ok(t, err)
normalized, err := server.NormalizeBaseURL(url)
Ok(t, err)
Equals(t, url, normalized)
}
}

func TestNormalizeBaseURL_Relative(t *testing.T) {
t.Log("We do not allow relative URLs as base URLs.")
_, err := server.NormalizeBaseURL(&url.URL{Path: "hi"})
Assert(t, err != nil, "should be an error")
Equals(t, "Base URLs must be absolute.", err.Error())
}

func TestNormalizeBaseURL_NonHTTP(t *testing.T) {
t.Log("Base URLs must be http or https.")
_, err := server.NormalizeBaseURL(&url.URL{Scheme: "ftp", Host: "example", Path: "hi"})
Assert(t, err != nil, "should be an error")
Equals(t, "Base URLs must be HTTP or HTTPS.", err.Error())
}

func TestNormalizeBaseURL_TrailingSlashes(t *testing.T) {
t.Log("We strip off any trailing slashes from the base URL.")
examples := []struct {
input string
output string
}{
{"https://example.com/", "https://example.com"},
{"https://example.com/some/path/", "https://example.com/some/path"},
{"http://example.com:8080/", "http://example.com:8080"},
{"https://example.com//", "https://example.com"},
{"https://example.com/path///", "https://example.com/path"},
}
for _, example := range examples {
inputURL, err := url.Parse(example.input)
Ok(t, err)
outputURL, err := url.Parse(example.output)
Ok(t, err)
normalized, err := server.NormalizeBaseURL(inputURL)
Ok(t, err)
Equals(t, outputURL, normalized)
}
}
36 changes: 19 additions & 17 deletions server/web_templates.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ package server
import (
"html/template"
"io"
"net/url"
"time"
)

Expand All @@ -31,7 +32,7 @@ type TemplateWriter interface {

// LockIndexData holds the fields needed to display the index view for locks.
type LockIndexData struct {
LockURL string
LockURL url.URL
RepoFullName string
PullNum int
Time time.Time
Expand All @@ -41,6 +42,7 @@ type LockIndexData struct {
type IndexData struct {
Locks []LockIndexData
AtlantisVersion string
AtlantisURL url.URL
}

var indexTemplate = template.Must(template.New("index.html.tmpl").Parse(`
Expand All @@ -52,7 +54,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="{{ .AtlantisURL }}/static/js/jquery-3.2.1.min.js"></script>
<script>
$(document).ready(function () {
$("p.js-discard-success").toggle(document.URL.indexOf("discard=true") !== -1);
Expand All @@ -61,15 +63,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="{{ .AtlantisURL }}/static/css/normalize.css">
<link rel="stylesheet" href="{{ .AtlantisURL }}/static/css/skeleton.css">
<link rel="stylesheet" href="{{ .AtlantisURL }}/static/css/custom.css">
<link rel="icon" type="image/png" href="{{ .AtlantisURL }}/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="{{ .AtlantisURL }}"><img src="{{ .AtlantisURL }}/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>
Expand All @@ -83,7 +85,7 @@ var indexTemplate = template.Must(template.New("index.html.tmpl").Parse(`
<p class="title-heading small"><strong>Locks</strong></p>
{{ if .Locks }}
{{ range .Locks }}
<a href="{{.LockURL}}">
<a href="{{ .AtlantisURL }}{{.LockURL.Path}}">
<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>
Expand All @@ -105,7 +107,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
Expand All @@ -115,6 +116,7 @@ type LockDetailData struct {
Workspace string
Time time.Time
AtlantisVersion string
AtlantisURL url.URL
}

var lockTemplate = template.Must(template.New("lock.html.tmpl").Parse(`
Expand All @@ -126,16 +128,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="{{ .AtlantisURL }}/static/css/normalize.css">
<link rel="stylesheet" href="{{ .AtlantisURL }}/static/css/skeleton.css">
<link rel="stylesheet" href="{{ .AtlantisURL }}/static/css/custom.css">
<link rel="icon" type="image/png" href="{{ .AtlantisURL }}/static/images/atlantis-icon.png">
<script src="{{ .AtlantisURL }}/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="{{ .AtlantisURL }}"><img src="{{ .AtlantisURL }}/static/images/atlantis-icon.png"/></a>
<p class="title-heading">atlantis</p>
<p class="title-heading"><strong>{{.LockKey}}</strong> <code>Locked</code></p>
</section>
Expand Down Expand Up @@ -200,10 +202,10 @@ v{{ .AtlantisVersion }}
btnDiscard.click(function() {
$.ajax({
url: '/locks?id='+lockId,
url: '{{ .AtlantisURL }}/locks?id='+lockId,
type: 'DELETE',
success: function(result) {
window.location.replace("/?discard=true");
window.location.replace("{{ .AtlantisURL }}/?discard=true");
}
});
});
Expand Down

0 comments on commit 670131f

Please sign in to comment.