diff options
author | Alessio Caiazza <acaiazza@gitlab.com> | 2019-11-29 18:37:23 +0300 |
---|---|---|
committer | Alessio Caiazza <acaiazza@gitlab.com> | 2019-11-29 18:37:23 +0300 |
commit | b0f9d9c6daab4fec25b9504c5cf0ff639dd1eba3 (patch) | |
tree | 95a047b4865736a4f23aeb0d732e2b4cc460574f | |
parent | 29b0a2d8ebc10ef9fa070d5634ab5d18938c935e (diff) | |
parent | 16e6e7e947fcc9235bdea9c72d0cbcc2dbd21bd0 (diff) |
Merge branch 'feature/gitlab-source-enum-domains' into 'master'
Add transitional GitLab domain source (no cache)
Closes #254
See merge request gitlab-org/gitlab-pages!201
32 files changed, 746 insertions, 236 deletions
diff --git a/acceptance_test.go b/acceptance_test.go index 44dbc90d..6f2e5fb4 100644 --- a/acceptance_test.go +++ b/acceptance_test.go @@ -16,6 +16,7 @@ import ( "time" "github.com/namsral/flag" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -432,6 +433,20 @@ func TestPageNotAvailableIfNotLoaded(t *testing.T) { require.Equal(t, http.StatusServiceUnavailable, rsp.StatusCode) } +func TestPageNotAvailableInDomainSource(t *testing.T) { + skipUnlessEnabled(t) + + brokenDomain := "GITLAB_NEW_SOURCE_BROKEN_DOMAIN=pages-broken-poc.gitlab.io" + teardown := RunPagesProcessWithEnvs(t, false, *pagesBinary, listeners, "", []string{brokenDomain}, "-pages-root=shared/invalid-pages") + defer teardown() + waitForRoundtrips(t, listeners, 5*time.Second) + + rsp, err := GetPageFromListener(t, httpListener, "pages-broken-poc.gitlab.io", "index.html") + require.NoError(t, err) + defer rsp.Body.Close() + require.Equal(t, http.StatusBadGateway, rsp.StatusCode) +} + func TestObscureMIMEType(t *testing.T) { skipUnlessEnabled(t) teardown := RunPagesProcessWithoutWait(t, *pagesBinary, listeners, "") @@ -1513,3 +1528,23 @@ func TestTLSVersions(t *testing.T) { }) } } + +func TestGitlabDomainsSource(t *testing.T) { + skipUnlessEnabled(t) + + source := NewGitlabDomainsSourceStub(t) + defer source.Close() + + newSourceDomains := "GITLAB_NEW_SOURCE_DOMAINS=new-source-test.gitlab.io,other-test.gitlab.io" + teardown := RunPagesProcessWithEnvs(t, true, *pagesBinary, listeners, "", []string{newSourceDomains}, "-gitlab-server", source.URL) + defer teardown() + + response, err := GetPageFromListener(t, httpListener, "new-source-test.gitlab.io", "/my/pages/project/") + require.NoError(t, err) + + defer response.Body.Close() + body, _ := ioutil.ReadAll(response.Body) + + assert.Equal(t, http.StatusOK, response.StatusCode) + assert.Equal(t, "New Pages GitLab Source TEST OK\n", string(body)) +} @@ -53,7 +53,7 @@ type theApp struct { } func (a *theApp) isReady() bool { - return a.domains.Ready() + return a.domains.IsReady() } func (a *theApp) ServeTLS(ch *tls.ClientHelloInfo) (*tls.Certificate, error) { @@ -61,7 +61,7 @@ func (a *theApp) ServeTLS(ch *tls.ClientHelloInfo) (*tls.Certificate, error) { return nil, nil } - if domain := a.domain(ch.ServerName); domain != nil { + if domain, _ := a.domain(ch.ServerName); domain != nil { tls, _ := domain.EnsureCertificate() return tls, nil } @@ -86,16 +86,18 @@ func (a *theApp) redirectToHTTPS(w http.ResponseWriter, r *http.Request, statusC http.Redirect(w, r, u.String(), statusCode) } -func (a *theApp) getHostAndDomain(r *http.Request) (host string, domain *domain.Domain) { +func (a *theApp) getHostAndDomain(r *http.Request) (string, *domain.Domain, error) { host, _, err := net.SplitHostPort(r.Host) if err != nil { host = r.Host } - return host, a.domain(host) + domain, err := a.domain(host) + + return host, domain, err } -func (a *theApp) domain(host string) *domain.Domain { +func (a *theApp) domain(host string) (*domain.Domain, error) { return a.domains.GetDomain(host) } @@ -163,7 +165,15 @@ func (a *theApp) tryAuxiliaryHandlers(w http.ResponseWriter, r *http.Request, ht // downstream middlewares to use func (a *theApp) routingMiddleware(handler http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - host, domain := a.getHostAndDomain(r) + // if we could not retrieve a domain from domains source we break the + // middleware chain and simply respond with 502 after logging this + host, domain, err := a.getHostAndDomain(r) + if err != nil { + log.WithError(err).Error("could not fetch domain information from a source") + + httperrors.Serve502(w) + return + } r = request.WithHostAndDomain(r, host, domain) @@ -289,7 +299,7 @@ func (a *theApp) proxyInitialMiddleware(handler http.Handler) http.Handler { } func (a *theApp) buildHandlerPipeline() (http.Handler, error) { - // Handlers should be applied in reverse order + // Handlers should be applied in a reverse order handler := a.serveFileOrNotFoundHandler() if !a.DisableCrossOriginRequests { handler = corsHandler.Handler(handler) @@ -348,7 +358,7 @@ func (a *theApp) Run() { a.listenMetricsFD(&wg, a.ListenMetrics) } - a.domains.Watch(a.Domain) + a.domains.Read(a.Domain) wg.Wait() } @@ -403,7 +413,7 @@ func (a *theApp) listenMetricsFD(wg *sync.WaitGroup, fd uintptr) { } func runApp(config appConfig) { - a := theApp{appConfig: config, domains: source.NewDomains()} + a := theApp{appConfig: config, domains: source.NewDomains(config)} err := logging.ConfigureLogging(a.LogFormat, a.LogVerbose) if err != nil { diff --git a/app_config.go b/app_config.go index f9be6545..639ece85 100644 --- a/app_config.go +++ b/app_config.go @@ -35,3 +35,13 @@ type appConfig struct { SentryEnvironment string CustomHeaders []string } + +// GitlabServerURL returns URL to a GitLab instance. +func (config appConfig) GitlabServerURL() string { + return config.GitLabServer +} + +// GitlabClientSecret returns GitLab server access token. +func (config appConfig) GitlabAPISecret() []byte { + return config.GitLabAPISecretKey +} @@ -23,9 +23,9 @@ require ( gitlab.com/gitlab-org/labkit v0.0.0-20190902063225-3253d7975ca7 gitlab.com/lupine/go-mimedb v0.0.0-20180307000149-e8af1d659877 golang.org/x/crypto v0.0.0-20190911031432-227b76d455e7 - golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac + golang.org/x/lint v0.0.0-20190930215403-16217165b5de golang.org/x/net v0.0.0-20190909003024-a7b16738d86b golang.org/x/sys v0.0.0-20190910064555-bbd175535a8b - golang.org/x/tools v0.0.0-20190917032747-2dc213d980bc + golang.org/x/tools v0.0.0-20191010201905-e5ffc44a6fee gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect ) @@ -141,8 +141,8 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk golang.org/x/crypto v0.0.0-20190911031432-227b76d455e7 h1:0hQKqeLdqlt5iIwVOBErRisrHJAN57yOiPRQItI20fU= golang.org/x/crypto v0.0.0-20190911031432-227b76d455e7/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/lint v0.0.0-20180702182130-06c8688daad7/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= -golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac h1:8R1esu+8QioDxo4E4mX6bFztO+dMTM49DNAaWfO5OeY= -golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190930215403-16217165b5de h1:5hukYrvBGR8/eNkX5mdUezrA6JiaEZDtJb9Ei+1LlBs= +golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -177,8 +177,8 @@ golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= -golang.org/x/tools v0.0.0-20190917032747-2dc213d980bc h1:AzQrNvr65FlhSjBpg0eVCY43QLsuOqtzWGtjcBqT6J8= -golang.org/x/tools v0.0.0-20190917032747-2dc213d980bc/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191010201905-e5ffc44a6fee h1:Cgj5oVkw7Gktu56MAiU0r1u0jyuT6jmtOzcAJwLj89c= +golang.org/x/tools v0.0.0-20191010201905-e5ffc44a6fee/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/appengine v1.1.0 h1:igQkv0AAhEIvTEpD5LIpAfav2eeVO9HBTjvKHVJPRSs= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= diff --git a/helpers_test.go b/helpers_test.go index ad7c65f1..b13fb18f 100644 --- a/helpers_test.go +++ b/helpers_test.go @@ -5,9 +5,11 @@ import ( "crypto/tls" "crypto/x509" "fmt" + "io" "io/ioutil" "net" "net/http" + "net/http/httptest" "os" "os/exec" "strings" @@ -145,6 +147,10 @@ func RunPagesProcessWithSSLCertFile(t *testing.T, pagesPath string, listeners [] return runPagesProcess(t, true, pagesPath, listeners, promPort, []string{"SSL_CERT_FILE=" + sslCertFile}, extraArgs...) } +func RunPagesProcessWithEnvs(t *testing.T, wait bool, pagesPath string, listeners []ListenSpec, promPort string, envs []string, extraArgs ...string) (teardown func()) { + return runPagesProcess(t, wait, pagesPath, listeners, promPort, envs, extraArgs...) +} + func RunPagesProcessWithAuth(t *testing.T, pagesPath string, listeners []ListenSpec, promPort string) (teardown func()) { return runPagesProcess(t, true, pagesPath, listeners, promPort, nil, "-auth-client-id=1", "-auth-client-secret=1", @@ -375,3 +381,20 @@ func waitForRoundtrips(t *testing.T, listeners []ListenSpec, timeout time.Durati require.Equal(t, len(listeners), nListening, "all listeners must be accepting TCP connections") } + +func NewGitlabDomainsSourceStub(t *testing.T) *httptest.Server { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + domain := r.URL.Query().Get("host") + + fixture, err := os.Open("shared/lookups/" + domain + ".json") + defer fixture.Close() + require.NoError(t, err) + + _, err = io.Copy(w, fixture) + require.NoError(t, err) + + t.Logf("GitLab domain %s source stub served lookup", domain) + }) + + return httptest.NewServer(handler) +} diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 2e8473b4..f30c7407 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -107,8 +107,7 @@ func (a *Auth) checkSession(w http.ResponseWriter, r *http.Request) (*sessions.S } // TryAuthenticate tries to authenticate user and fetch access token if request is a callback to auth -func (a *Auth) TryAuthenticate(w http.ResponseWriter, r *http.Request, domains *source.Domains) bool { - +func (a *Auth) TryAuthenticate(w http.ResponseWriter, r *http.Request, domains source.Source) bool { if a == nil { return false } @@ -199,17 +198,20 @@ func (a *Auth) checkAuthenticationResponse(session *sessions.Session, w http.Res http.Redirect(w, r, redirectURI, 302) } -func (a *Auth) domainAllowed(domain string, domains *source.Domains) bool { - domainConfigured := (domain == a.pagesDomain) || strings.HasSuffix("."+domain, a.pagesDomain) +func (a *Auth) domainAllowed(name string, domains source.Source) bool { + isConfigured := (name == a.pagesDomain) || strings.HasSuffix("."+name, a.pagesDomain) - if domainConfigured { + if isConfigured { return true } - return domains.HasDomain(domain) + domain, err := domains.GetDomain(name) + + // domain exists and there is no error + return (domain != nil && err == nil) } -func (a *Auth) handleProxyingAuth(session *sessions.Session, w http.ResponseWriter, r *http.Request, domains *source.Domains) bool { +func (a *Auth) handleProxyingAuth(session *sessions.Session, w http.ResponseWriter, r *http.Request, domains source.Source) bool { // If request is for authenticating via custom domain if shouldProxyAuth(r) { domain := r.URL.Query().Get("domain") diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go index c082cfdf..92e1e8c7 100644 --- a/internal/auth/auth_test.go +++ b/internal/auth/auth_test.go @@ -56,7 +56,7 @@ func TestTryAuthenticate(t *testing.T) { require.NoError(t, err) r := request.WithHTTPSFlag(&http.Request{URL: reqURL}, true) - require.Equal(t, false, auth.TryAuthenticate(result, r, source.NewDomains())) + require.Equal(t, false, auth.TryAuthenticate(result, r, source.NewMockSource())) } func TestTryAuthenticateWithError(t *testing.T) { @@ -67,7 +67,7 @@ func TestTryAuthenticateWithError(t *testing.T) { require.NoError(t, err) r := request.WithHTTPSFlag(&http.Request{URL: reqURL}, true) - require.Equal(t, true, auth.TryAuthenticate(result, r, source.NewDomains())) + require.Equal(t, true, auth.TryAuthenticate(result, r, source.NewMockSource())) require.Equal(t, 401, result.Code) } @@ -84,7 +84,7 @@ func TestTryAuthenticateWithCodeButInvalidState(t *testing.T) { session.Values["state"] = "state" session.Save(r, result) - require.Equal(t, true, auth.TryAuthenticate(result, r, source.NewDomains())) + require.Equal(t, true, auth.TryAuthenticate(result, r, source.NewMockSource())) require.Equal(t, 401, result.Code) } @@ -124,7 +124,7 @@ func testTryAuthenticateWithCodeAndState(t *testing.T, https bool) { }) result := httptest.NewRecorder() - require.Equal(t, true, auth.TryAuthenticate(result, r, source.NewDomains())) + require.Equal(t, true, auth.TryAuthenticate(result, r, source.NewMockSource())) require.Equal(t, 302, result.Code) require.Equal(t, "https://pages.gitlab-example.com/project/", result.Header().Get("Location")) require.Equal(t, 600, result.Result().Cookies()[0].MaxAge) diff --git a/internal/domain/domain.go b/internal/domain/domain.go index f7eba5ca..28eb3196 100644 --- a/internal/domain/domain.go +++ b/internal/domain/domain.go @@ -162,10 +162,8 @@ func (d *Domain) EnsureCertificate() (*tls.Certificate, error) { // ServeFileHTTP returns true if something was served, false if not. func (d *Domain) ServeFileHTTP(w http.ResponseWriter, r *http.Request) bool { if d.isUnconfigured() || !d.HasLookupPath(r) { - // TODO: this seems to be wrong: - // as we should rather return false, - // and fallback to `ServeNotFoundHTTP` - // to handle this case + // TODO: this seems to be wrong: as we should rather return false, and + // fallback to `ServeNotFoundHTTP` to handle this case httperrors.Serve404(w) return true } diff --git a/internal/serving/disk/reader.go b/internal/serving/disk/reader.go index b52b5cff..ce4f1d8b 100644 --- a/internal/serving/disk/reader.go +++ b/internal/serving/disk/reader.go @@ -28,6 +28,9 @@ func (reader *Reader) tryFile(h serving.Handler) error { if endsWithSlash(urlPath) { fullPath, err = reader.resolvePath(h.LookupPath.Path, h.SubPath, "index.html") } else { + // TODO why are we doing that? In tests it redirects to HTTPS. This seems wrong, + // issue about this: https://gitlab.com/gitlab-org/gitlab-pages/issues/273 + // Concat Host with URL.Path redirectPath := "//" + host + "/" redirectPath += strings.TrimPrefix(urlPath, "/") diff --git a/internal/serving/lookup_path.go b/internal/serving/lookup_path.go index ba6e8f7a..4360358b 100644 --- a/internal/serving/lookup_path.go +++ b/internal/serving/lookup_path.go @@ -2,9 +2,9 @@ package serving // LookupPath holds a domain project configuration needed to handle a request type LookupPath struct { - Location string - Path string - IsNamespaceProject bool + Prefix string // Project prefix, for example, /my/project in group.gitlab.io/my/project/index.html + Path string // Path is an internal and serving-specific location of a document + IsNamespaceProject bool // IsNamespaceProject is DEPRECATED, see https://gitlab.com/gitlab-org/gitlab-pages/issues/272 IsHTTPSOnly bool HasAccessControl bool ProjectID uint64 diff --git a/internal/source/config.go b/internal/source/config.go new file mode 100644 index 00000000..9cf87bc6 --- /dev/null +++ b/internal/source/config.go @@ -0,0 +1,7 @@ +package source + +import "gitlab.com/gitlab-org/gitlab-pages/internal/source/gitlab/client" + +// Config represents an interface that is configuration provider for client +// capable of comunicating with GitLab +type Config client.Config diff --git a/internal/source/disk/custom.go b/internal/source/disk/custom.go index 8a080f20..cc4f3f4c 100644 --- a/internal/source/disk/custom.go +++ b/internal/source/disk/custom.go @@ -12,10 +12,9 @@ type customProjectResolver struct { path string } -// TODO tests func (p *customProjectResolver) Resolve(r *http.Request) (*serving.LookupPath, string, error) { lookupPath := &serving.LookupPath{ - Location: "/", + Prefix: "/", Path: p.path, IsNamespaceProject: false, IsHTTPSOnly: p.config.HTTPSOnly, diff --git a/internal/source/disk/disk.go b/internal/source/disk/disk.go new file mode 100644 index 00000000..b79d222d --- /dev/null +++ b/internal/source/disk/disk.go @@ -0,0 +1,57 @@ +package disk + +import ( + "strings" + "sync" + "time" + + "gitlab.com/gitlab-org/gitlab-pages/internal/domain" +) + +// Disk struct represents a map of all domains supported by pages that are +// stored on a disk with corresponding `config.json`. +type Disk struct { + dm Map + lock *sync.RWMutex +} + +// New is a factory method for the Disk source. It is initializing a mutex. It +// should not initialize `dm` as we later check the readiness by comparing it +// with a nil value. +func New() *Disk { + return &Disk{ + lock: &sync.RWMutex{}, + } +} + +// GetDomain returns a domain from the domains map if it exists +func (d *Disk) GetDomain(host string) (*domain.Domain, error) { + host = strings.ToLower(host) + + d.lock.RLock() + defer d.lock.RUnlock() + + domain, _ := d.dm[host] + + return domain, nil +} + +// IsReady checks if the domains source is ready for work. The disk source is +// ready after traversing entire filesystem and reading all domains' +// configuration files. +func (d *Disk) IsReady() bool { + return d.dm != nil +} + +// Read starts the domain source, in this case it is reading domains from +// groups on disk concurrently. +func (d *Disk) Read(rootDomain string) { + go Watch(rootDomain, d.updateDomains, time.Second) +} + +func (d *Disk) updateDomains(dm Map) { + d.lock.Lock() + defer d.lock.Unlock() + + d.dm = dm +} diff --git a/internal/source/disk/group.go b/internal/source/disk/group.go index 59aedc73..7094e7a2 100644 --- a/internal/source/disk/group.go +++ b/internal/source/disk/group.go @@ -68,7 +68,6 @@ func (g *Group) getProjectConfigWithSubpath(r *http.Request) (*projectConfig, st // return the group project if it exists. if host := host.FromRequest(r); host != "" { if groupProject := g.projects[host]; groupProject != nil { - // TODOHERE: the location here should be "/", so we return "" return groupProject, "/", host, strings.Join(split[1:], "/") } } @@ -79,14 +78,14 @@ func (g *Group) getProjectConfigWithSubpath(r *http.Request) (*projectConfig, st // Resolve tries to find project and its config recursively for a given request // to a group domain func (g *Group) Resolve(r *http.Request) (*serving.LookupPath, string, error) { - projectConfig, location, projectPath, subPath := g.getProjectConfigWithSubpath(r) + projectConfig, prefix, projectPath, subPath := g.getProjectConfigWithSubpath(r) if projectConfig == nil { return nil, "", nil // it is not an error when project does not exist } lookupPath := &serving.LookupPath{ - Location: location, + Prefix: prefix, Path: filepath.Join(g.name, projectPath, "public"), IsNamespaceProject: projectConfig.NamespaceProject, IsHTTPSOnly: projectConfig.HTTPSOnly, diff --git a/internal/source/domains.go b/internal/source/domains.go index cd2d89c5..f47884ef 100644 --- a/internal/source/domains.go +++ b/internal/source/domains.go @@ -1,65 +1,79 @@ package source import ( + "errors" + "os" "strings" - "sync" - "time" "gitlab.com/gitlab-org/gitlab-pages/internal/domain" "gitlab.com/gitlab-org/gitlab-pages/internal/source/disk" + "gitlab.com/gitlab-org/gitlab-pages/internal/source/gitlab" ) +var newSourceDomains []string +var brokenSourceDomain string + +func init() { + testDomains := os.Getenv("GITLAB_NEW_SOURCE_DOMAINS") + if testDomains != "" { + newSourceDomains = strings.Split(testDomains, ",") + } + + brokenDomain := os.Getenv("GITLAB_NEW_SOURCE_BROKEN_DOMAIN") + if brokenDomain != "" { + brokenSourceDomain = brokenDomain + } +} + // Domains struct represents a map of all domains supported by pages. It is -// currently reading them from disk. +// currently using two sources during the transition to the new GitLab domains +// source. type Domains struct { - dm disk.Map - lock *sync.RWMutex + gitlab Source + disk *disk.Disk // legacy disk source } // NewDomains is a factory method for domains initializing a mutex. It should // not initialize `dm` as we later check the readiness by comparing it with a // nil value. -func NewDomains() *Domains { +func NewDomains(config Config) *Domains { return &Domains{ - lock: &sync.RWMutex{}, + disk: disk.New(), + gitlab: gitlab.New(config), } } -// GetDomain returns a domain from the domains map -func (d *Domains) GetDomain(host string) *domain.Domain { - host = strings.ToLower(host) - d.lock.RLock() - defer d.lock.RUnlock() - domain, _ := d.dm[host] - - return domain -} - -// HasDomain checks for presence of a domain in the domains map -func (d *Domains) HasDomain(host string) bool { - d.lock.RLock() - defer d.lock.RUnlock() - - host = strings.ToLower(host) - _, isPresent := d.dm[host] +// GetDomain retrieves a domain information from a source. We are using two +// sources here because it allows us to switch behavior and the domain source +// for some subset of domains, to test / PoC the new GitLab Domains Source that +// we plan to use to replace the disk source. +func (d *Domains) GetDomain(name string) (*domain.Domain, error) { + if name == brokenSourceDomain { + return nil, errors.New("broken test domain used") + } - return isPresent + return d.source(name).GetDomain(name) } -// Ready checks if the domains source is ready for work -func (d *Domains) Ready() bool { - return d.dm != nil +// Read starts the disk domain source. It is DEPRECATED, because we want to +// remove it entirely when disk source gets removed. +func (d *Domains) Read(rootDomain string) { + d.disk.Read(rootDomain) } -// Watch starts the domain source, in this case it is reading domains from -// groups on disk concurrently -func (d *Domains) Watch(rootDomain string) { - go disk.Watch(rootDomain, d.updateDomains, time.Second) +// IsReady checks if the disk domain source managed to traverse entire pages +// filesystem and is ready for use. It is DEPRECATED, because we want to remove +// it entirely when disk source gets removed. +func (d *Domains) IsReady() bool { + return d.disk.IsReady() } -func (d *Domains) updateDomains(dm disk.Map) { - d.lock.Lock() - defer d.lock.Unlock() +func (d *Domains) source(domain string) Source { + for _, name := range newSourceDomains { + if domain == name { + return d.gitlab + } + } - d.dm = dm + return d.disk } diff --git a/internal/source/domains_test.go b/internal/source/domains_test.go new file mode 100644 index 00000000..6854c359 --- /dev/null +++ b/internal/source/domains_test.go @@ -0,0 +1,63 @@ +package source + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "gitlab.com/gitlab-org/gitlab-pages/internal/domain" + "gitlab.com/gitlab-org/gitlab-pages/internal/source/disk" +) + +func TestHasDomain(t *testing.T) { + newSourceDomains = []string{"new-source-test.gitlab.io"} + brokenSourceDomain = "pages-broken-poc.gitlab.io" + + t.Run("when requesting a test domain", func(t *testing.T) { + testDomain := newSourceDomains[0] + + newSource := NewMockSource() + newSource.On("GetDomain", testDomain). + Return(&domain.Domain{Name: testDomain}, nil). + Once() + defer newSource.AssertExpectations(t) + + domains := &Domains{ + disk: disk.New(), + gitlab: newSource, + } + + domains.GetDomain(testDomain) + }) + + t.Run("when requesting a non-test domain", func(t *testing.T) { + newSource := NewMockSource() + defer newSource.AssertExpectations(t) + + domains := &Domains{ + disk: disk.New(), + gitlab: newSource, + } + + domain, err := domains.GetDomain("domain.test.io") + + require.NoError(t, err) + assert.Nil(t, domain) + }) + + t.Run("when requesting a broken test domain", func(t *testing.T) { + newSource := NewMockSource() + defer newSource.AssertExpectations(t) + + domains := &Domains{ + disk: disk.New(), + gitlab: newSource, + } + + domain, err := domains.GetDomain("pages-broken-poc.gitlab.io") + + assert.Nil(t, domain) + assert.EqualError(t, err, "broken test domain used") + }) +} diff --git a/internal/source/gitlab/response.go b/internal/source/gitlab/api/lookup_path.go index 20597362..b0407638 100644 --- a/internal/source/gitlab/response.go +++ b/internal/source/gitlab/api/lookup_path.go @@ -1,6 +1,6 @@ -package gitlab +package api -// LookupPath represents a lookup path for a GitLab Pages virtual domain +// LookupPath represents a lookup path for a virtual domain type LookupPath struct { ProjectID int `json:"project_id,omitempty"` AccessControl bool `json:"access_control,omitempty"` @@ -11,11 +11,3 @@ type LookupPath struct { Path string `json:"path,omitempty"` } } - -// VirtualDomain represents a GitLab Pages virtual domain -type VirtualDomain struct { - Certificate string `json:"certificate,omitempty"` - Key string `json:"key,omitempty"` - - LookupPaths []LookupPath `json:"lookup_paths"` -} diff --git a/internal/source/gitlab/api/virtual_domain.go b/internal/source/gitlab/api/virtual_domain.go new file mode 100644 index 00000000..200c06de --- /dev/null +++ b/internal/source/gitlab/api/virtual_domain.go @@ -0,0 +1,10 @@ +package api + +// VirtualDomain represents a GitLab Pages virtual domain that is being sent +// from GitLab API +type VirtualDomain struct { + Certificate string `json:"certificate,omitempty"` + Key string `json:"key,omitempty"` + + LookupPaths []LookupPath `json:"lookup_paths"` +} diff --git a/internal/source/gitlab/cache/cache.go b/internal/source/gitlab/cache/cache.go new file mode 100644 index 00000000..95c4c57b --- /dev/null +++ b/internal/source/gitlab/cache/cache.go @@ -0,0 +1,10 @@ +package cache + +// Cache is a short and long caching mechanism for GitLab source +type Cache struct { +} + +// New creates a new instance of Cache and sets default expiration +func New() *Cache { + return &Cache{} +} diff --git a/internal/source/gitlab/client.go b/internal/source/gitlab/client.go index 9dde7b43..5b6cd07c 100644 --- a/internal/source/gitlab/client.go +++ b/internal/source/gitlab/client.go @@ -1,142 +1,10 @@ package gitlab -import ( - "encoding/json" - "errors" - "net/http" - "net/url" - "time" +import "gitlab.com/gitlab-org/gitlab-pages/internal/source/gitlab/api" - jwt "github.com/dgrijalva/jwt-go" - - "gitlab.com/gitlab-org/gitlab-pages/internal/httptransport" -) - -// Client is a HTTP client to access Pages internal API -type Client struct { - secretKey []byte - baseURL *url.URL - httpClient *http.Client -} - -var ( - errUnknown = errors.New("Unknown") - errNoContent = errors.New("No Content") - errUnauthorized = errors.New("Unauthorized") - errNotFound = errors.New("Not Found") -) - -// NewClient initializes and returns new Client -// baseUrl is appConfig.GitLabServer -// secretKey is appConfig.GitLabAPISecretKey (not yet implemented) -func NewClient(baseURL string, secretKey []byte) (*Client, error) { - url, err := url.Parse(baseURL) - if err != nil { - return nil, err - } - - return &Client{ - secretKey: secretKey, - baseURL: url, - httpClient: &http.Client{ - Timeout: 5 * time.Second, - Transport: httptransport.Transport, - }, - }, nil -} - -// GetVirtualDomain returns VirtualDomain configuration for the given host -func (gc *Client) GetVirtualDomain(host string) (*VirtualDomain, error) { - params := map[string]string{"host": host} - - resp, err := gc.get("/api/v4/internal/pages", params) - if resp != nil { - defer resp.Body.Close() - } - - if err != nil { - return nil, err - } - - var domain VirtualDomain - err = json.NewDecoder(resp.Body).Decode(&domain) - if err != nil { - return nil, err - } - - return &domain, nil -} - -func (gc *Client) get(path string, params map[string]string) (*http.Response, error) { - endpoint, err := gc.endpoint(path, params) - if err != nil { - return nil, err - } - - req, err := gc.request("GET", endpoint) - if err != nil { - return nil, err - } - - resp, err := gc.httpClient.Do(req) - if err != nil { - return nil, err - } - - switch { - case resp.StatusCode == http.StatusOK: - return resp, nil - case resp.StatusCode == http.StatusNoContent: - return resp, errNoContent - case resp.StatusCode == http.StatusUnauthorized: - return resp, errUnauthorized - case resp.StatusCode == http.StatusNotFound: - return resp, errNotFound - default: - return resp, errUnknown - } -} - -func (gc *Client) endpoint(path string, params map[string]string) (*url.URL, error) { - endpoint, err := gc.baseURL.Parse(path) - if err != nil { - return nil, err - } - - values := url.Values{} - for key, value := range params { - values.Add(key, value) - } - endpoint.RawQuery = values.Encode() - - return endpoint, nil -} - -func (gc *Client) request(method string, endpoint *url.URL) (*http.Request, error) { - req, err := http.NewRequest("GET", endpoint.String(), nil) - if err != nil { - return nil, err - } - - token, err := gc.token() - if err != nil { - return nil, err - } - req.Header.Set("Gitlab-Pages-Api-Request", token) - - return req, nil -} - -func (gc *Client) token() (string, error) { - claims := jwt.StandardClaims{ - Issuer: "gitlab-pages", - ExpiresAt: time.Now().Add(5 * time.Second).Unix(), - } - - token, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(gc.secretKey) - if err != nil { - return "", err - } - - return token, nil +// Client interace represents a client capable of fetching a virtual domain +// from an external API +type Client interface { + // GetVirtualDomain retrieves a virtual domain from an external API + GetVirtualDomain(host string) (*api.VirtualDomain, error) } diff --git a/internal/source/gitlab/client/client.go b/internal/source/gitlab/client/client.go new file mode 100644 index 00000000..6c9327dc --- /dev/null +++ b/internal/source/gitlab/client/client.go @@ -0,0 +1,153 @@ +package client + +import ( + "encoding/json" + "errors" + "net/http" + "net/url" + "time" + + jwt "github.com/dgrijalva/jwt-go" + + "gitlab.com/gitlab-org/labkit/log" + + "gitlab.com/gitlab-org/gitlab-pages/internal/httptransport" + "gitlab.com/gitlab-org/gitlab-pages/internal/source/gitlab/api" +) + +// Client is a HTTP client to access Pages internal API +type Client struct { + secretKey []byte + baseURL *url.URL + httpClient *http.Client +} + +var ( + errUnknown = errors.New("Unknown") + errNoContent = errors.New("No Content") + errUnauthorized = errors.New("Unauthorized") + errNotFound = errors.New("Not Found") +) + +// TODO make these values configurable https://gitlab.com/gitlab-org/gitlab-pages/issues/274 +var tokenTimeout = 30 * time.Second +var connectionTimeout = 10 * time.Second + +// NewClient initializes and returns new Client baseUrl is +// appConfig.GitLabServer secretKey is appConfig.GitLabAPISecretKey +func NewClient(baseURL string, secretKey []byte) *Client { + url, err := url.Parse(baseURL) + if err != nil { + log.WithError(err).Fatal("could not parse GitLab server URL") + } + + return &Client{ + secretKey: secretKey, + baseURL: url, + httpClient: &http.Client{ + Timeout: connectionTimeout, + Transport: httptransport.Transport, + }, + } +} + +// NewFromConfig creates a new client from Config struct +func NewFromConfig(config Config) *Client { + return NewClient(config.GitlabServerURL(), config.GitlabAPISecret()) +} + +// GetVirtualDomain returns VirtualDomain configuration for the given host. It +// returns an error if non-nil `*api.VirtualDomain` can not be retuned. +func (gc *Client) GetVirtualDomain(host string) (*api.VirtualDomain, error) { + params := url.Values{} + params.Set("host", host) + + resp, err := gc.get("/api/v4/internal/pages", params) + if resp != nil { + defer resp.Body.Close() + } else { + return nil, errors.New("empty response returned") + } + + if err != nil { + return nil, err + } + + var domain api.VirtualDomain + err = json.NewDecoder(resp.Body).Decode(&domain) + if err != nil { + return nil, err + } + + return &domain, nil +} + +func (gc *Client) get(path string, params url.Values) (*http.Response, error) { + endpoint, err := gc.endpoint(path, params) + if err != nil { + return nil, err + } + + req, err := gc.request("GET", endpoint) + if err != nil { + return nil, err + } + + resp, err := gc.httpClient.Do(req) + if err != nil { + return nil, err + } + + switch { + case resp.StatusCode == http.StatusOK: + return resp, nil + case resp.StatusCode == http.StatusNoContent: + return resp, errNoContent + case resp.StatusCode == http.StatusUnauthorized: + return resp, errUnauthorized + case resp.StatusCode == http.StatusNotFound: + return resp, errNotFound + default: + return resp, errUnknown + } +} + +func (gc *Client) endpoint(path string, params url.Values) (*url.URL, error) { + endpoint, err := gc.baseURL.Parse(path) + if err != nil { + return nil, err + } + + endpoint.RawQuery = params.Encode() + + return endpoint, nil +} + +func (gc *Client) request(method string, endpoint *url.URL) (*http.Request, error) { + req, err := http.NewRequest("GET", endpoint.String(), nil) + if err != nil { + return nil, err + } + + token, err := gc.token() + if err != nil { + return nil, err + } + req.Header.Set("Gitlab-Pages-Api-Request", token) + + return req, nil +} + +func (gc *Client) token() (string, error) { + claims := jwt.StandardClaims{ + Issuer: "gitlab-pages", + ExpiresAt: time.Now().Add(tokenTimeout).Unix(), + } + + token, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(gc.secretKey) + if err != nil { + return "", err + } + + return token, nil +} diff --git a/internal/source/gitlab/client/client_stub.go b/internal/source/gitlab/client/client_stub.go new file mode 100644 index 00000000..6dc0af85 --- /dev/null +++ b/internal/source/gitlab/client/client_stub.go @@ -0,0 +1,27 @@ +package client + +import ( + "encoding/json" + "os" + + "gitlab.com/gitlab-org/gitlab-pages/internal/source/gitlab/api" +) + +// StubClient is a stubbed client used for testing +type StubClient struct { + File string +} + +// GetVirtualDomain reads a test fixture and unmarshalls it +func (c StubClient) GetVirtualDomain(host string) (*api.VirtualDomain, error) { + f, err := os.Open(c.File) + defer f.Close() + if err != nil { + return nil, err + } + + var domain api.VirtualDomain + err = json.NewDecoder(f).Decode(&domain) + + return &domain, err +} diff --git a/internal/source/gitlab/client_test.go b/internal/source/gitlab/client/client_test.go index 1d63a590..d689b687 100644 --- a/internal/source/gitlab/client_test.go +++ b/internal/source/gitlab/client/client_test.go @@ -1,4 +1,4 @@ -package gitlab +package client import ( "encoding/base64" @@ -16,17 +16,6 @@ var ( encodedSecret = "e41rcFh7XBA7sNABWVCe2AZvxMsy6QDtJ8S9Ql1UiN8=" // 32 bytes, base64 encoded ) -func TestNewValidBaseURL(t *testing.T) { - _, err := NewClient("https://gitlab.com", secretKey()) - require.NoError(t, err) -} - -func TestNewInvalidBaseURL(t *testing.T) { - client, err := NewClient("%", secretKey()) - require.Error(t, err) - require.Nil(t, client) -} - func TestGetVirtualDomainForErrorResponses(t *testing.T) { tests := map[int]string{ http.StatusNoContent: "No Content", @@ -46,7 +35,7 @@ func TestGetVirtualDomainForErrorResponses(t *testing.T) { server := httptest.NewServer(mux) defer server.Close() - client, _ := NewClient(server.URL, secretKey()) + client := NewClient(server.URL, secretKey()) actual, err := client.GetVirtualDomain("group.gitlab.io") @@ -89,7 +78,7 @@ func TestGetVirtualDomainAuthenticatedRequest(t *testing.T) { server := httptest.NewServer(mux) defer server.Close() - client, _ := NewClient(server.URL, secretKey()) + client := NewClient(server.URL, secretKey()) actual, err := client.GetVirtualDomain("group.gitlab.io") require.NoError(t, err) diff --git a/internal/source/gitlab/client/config.go b/internal/source/gitlab/client/config.go new file mode 100644 index 00000000..49c13a60 --- /dev/null +++ b/internal/source/gitlab/client/config.go @@ -0,0 +1,8 @@ +package client + +// Config represents an interface that is configuration provider for client +// capable of comunicating with GitLab +type Config interface { + GitlabServerURL() string + GitlabAPISecret() []byte +} diff --git a/internal/source/gitlab/client/testdata/test.gitlab.io.json b/internal/source/gitlab/client/testdata/test.gitlab.io.json new file mode 100644 index 00000000..923c7344 --- /dev/null +++ b/internal/source/gitlab/client/testdata/test.gitlab.io.json @@ -0,0 +1,36 @@ +{ + "certificate": "some--cert", + "key": "some--key", + "lookup_paths": [ + { + "access_control": false, + "https_only": true, + "prefix": "/my/pages/project", + "project_id": 123, + "source": { + "path": "/some/path/to/project/", + "type": "file" + } + }, + { + "access_control": false, + "https_only": true, + "prefix": "/my/second-project", + "project_id": 124, + "source": { + "path": "/some/path/to/project-2/", + "type": "file" + } + }, + { + "access_control": false, + "https_only": true, + "prefix": "/", + "project_id": 125, + "source": { + "path": "/some/path/to/project-3/", + "type": "file" + } + } + ] +} diff --git a/internal/source/gitlab/gitlab.go b/internal/source/gitlab/gitlab.go new file mode 100644 index 00000000..7cb88e42 --- /dev/null +++ b/internal/source/gitlab/gitlab.go @@ -0,0 +1,73 @@ +package gitlab + +import ( + "errors" + "net/http" + "path" + "strings" + + "gitlab.com/gitlab-org/gitlab-pages/internal/domain" + "gitlab.com/gitlab-org/gitlab-pages/internal/serving" + "gitlab.com/gitlab-org/gitlab-pages/internal/source/gitlab/cache" + "gitlab.com/gitlab-org/gitlab-pages/internal/source/gitlab/client" +) + +// Gitlab source represent a new domains configuration source. We fetch all the +// information about domains from GitLab instance. +type Gitlab struct { + client Client + cache *cache.Cache // WIP +} + +// New returns a new instance of gitlab domain source. +func New(config client.Config) *Gitlab { + return &Gitlab{client: client.NewFromConfig(config), cache: cache.New()} +} + +// GetDomain return a representation of a domain that we have fetched from +// GitLab +func (g *Gitlab) GetDomain(name string) (*domain.Domain, error) { + response, err := g.client.GetVirtualDomain(name) + if err != nil { + return nil, err + } + + domain := domain.Domain{ + Name: name, + CertificateCert: response.Certificate, + CertificateKey: response.Key, + Resolver: g, + } + + return &domain, nil +} + +// Resolve is supposed to get the serving lookup path based on the request from +// the GitLab source +func (g *Gitlab) Resolve(r *http.Request) (*serving.LookupPath, string, error) { + response, err := g.client.GetVirtualDomain(r.Host) + if err != nil { + return nil, "", err + } + + for _, lookup := range response.LookupPaths { + urlPath := path.Clean(r.URL.Path) + + if strings.HasPrefix(urlPath, lookup.Prefix) { + lookupPath := &serving.LookupPath{ + Prefix: lookup.Prefix, + Path: strings.TrimPrefix(lookup.Source.Path, "/"), + IsNamespaceProject: (lookup.Prefix == "/" && len(response.LookupPaths) > 1), + IsHTTPSOnly: lookup.HTTPSOnly, + HasAccessControl: lookup.AccessControl, + ProjectID: uint64(lookup.ProjectID), + } + + requestPath := strings.TrimPrefix(urlPath, lookup.Prefix) + + return lookupPath, strings.TrimPrefix(requestPath, "/"), nil + } + } + + return nil, "", errors.New("could not match lookup path") +} diff --git a/internal/source/gitlab/gitlab_test.go b/internal/source/gitlab/gitlab_test.go new file mode 100644 index 00000000..02751eea --- /dev/null +++ b/internal/source/gitlab/gitlab_test.go @@ -0,0 +1,75 @@ +package gitlab + +import ( + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "gitlab.com/gitlab-org/gitlab-pages/internal/source/gitlab/cache" + "gitlab.com/gitlab-org/gitlab-pages/internal/source/gitlab/client" +) + +func TestGetDomain(t *testing.T) { + t.Run("when the response if correct", func(t *testing.T) { + client := client.StubClient{File: "client/testdata/test.gitlab.io.json"} + source := Gitlab{client: client, cache: cache.New()} + + domain, err := source.GetDomain("test.gitlab.io") + require.NoError(t, err) + + assert.Equal(t, "test.gitlab.io", domain.Name) + }) + + t.Run("when the response is not valid", func(t *testing.T) { + client := client.StubClient{File: "/dev/null"} + source := Gitlab{client: client, cache: cache.New()} + + domain, err := source.GetDomain("test.gitlab.io") + + assert.NotNil(t, err) + assert.Nil(t, domain) + }) +} + +func TestResolve(t *testing.T) { + client := client.StubClient{File: "client/testdata/test.gitlab.io.json"} + source := Gitlab{client: client, cache: cache.New()} + + t.Run("when requesting a nested group project", func(t *testing.T) { + target := "https://test.gitlab.io:443/my/pages/project/path/index.html" + request := httptest.NewRequest("GET", target, nil) + + lookup, subpath, err := source.Resolve(request) + require.NoError(t, err) + + assert.Equal(t, "/my/pages/project", lookup.Prefix) + assert.Equal(t, "path/index.html", subpath) + assert.False(t, lookup.IsNamespaceProject) + }) + + t.Run("when request a nested group project", func(t *testing.T) { + target := "https://test.gitlab.io:443/path/to/index.html" + request := httptest.NewRequest("GET", target, nil) + + lookup, subpath, err := source.Resolve(request) + require.NoError(t, err) + + assert.Equal(t, "/", lookup.Prefix) + assert.Equal(t, "path/to/index.html", subpath) + assert.Equal(t, "some/path/to/project-3/", lookup.Path) + assert.True(t, lookup.IsNamespaceProject) + }) + + t.Run("when request path has not been sanitized", func(t *testing.T) { + target := "https://test.gitlab.io:443/something/../something/../my/pages/project/index.html" + request := httptest.NewRequest("GET", target, nil) + + lookup, subpath, err := source.Resolve(request) + require.NoError(t, err) + + assert.Equal(t, "/my/pages/project", lookup.Prefix) + assert.Equal(t, "index.html", subpath) + }) +} diff --git a/internal/source/source.go b/internal/source/source.go new file mode 100644 index 00000000..4b43b8f4 --- /dev/null +++ b/internal/source/source.go @@ -0,0 +1,8 @@ +package source + +import "gitlab.com/gitlab-org/gitlab-pages/internal/domain" + +// Source represents an abstract interface of a domains configuration source. +type Source interface { + GetDomain(string) (*domain.Domain, error) +} diff --git a/internal/source/source_mock.go b/internal/source/source_mock.go new file mode 100644 index 00000000..ee24d804 --- /dev/null +++ b/internal/source/source_mock.go @@ -0,0 +1,24 @@ +package source + +import ( + "github.com/stretchr/testify/mock" + + "gitlab.com/gitlab-org/gitlab-pages/internal/domain" +) + +// MockSource can be used for testing +type MockSource struct { + mock.Mock +} + +// GetDomain is a mocked function +func (m *MockSource) GetDomain(name string) (*domain.Domain, error) { + args := m.Called(name) + + return args.Get(0).(*domain.Domain), args.Error(1) +} + +// NewMockSource returns a new Source mock for testing +func NewMockSource() *MockSource { + return &MockSource{} +} diff --git a/shared/lookups/new-source-test.gitlab.io.json b/shared/lookups/new-source-test.gitlab.io.json new file mode 100644 index 00000000..0332b6c2 --- /dev/null +++ b/shared/lookups/new-source-test.gitlab.io.json @@ -0,0 +1,16 @@ +{ + "certificate": "", + "key": "", + "lookup_paths": [ + { + "access_control": false, + "https_only": false, + "prefix": "/my/pages/project", + "project_id": 123, + "source": { + "path": "/group/new-source-test.gitlab.io/public", + "type": "file" + } + } + ] +} diff --git a/shared/pages/group/new-source-test.gitlab.io/public/index.html b/shared/pages/group/new-source-test.gitlab.io/public/index.html new file mode 100644 index 00000000..00e11d66 --- /dev/null +++ b/shared/pages/group/new-source-test.gitlab.io/public/index.html @@ -0,0 +1 @@ +New Pages GitLab Source TEST OK |