app/hello: dummy server application (hello world)

Change-Id: I6690b9459325af5a76d1de679d56701eefdd195e
This commit is contained in:
mpl 2014-05-08 16:07:29 +02:00
parent 28ac303dc7
commit 21dda2b4ef
12 changed files with 578 additions and 10 deletions

97
app/hello/main.go Normal file
View File

@ -0,0 +1,97 @@
/*
Copyright 2014 The Camlistore Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
// The hello application serves as an example on how to make stand-alone
// server applications, interacting with a Camlistore server.
package main
import (
"flag"
"fmt"
"log"
"net/http"
"os"
"runtime"
"camlistore.org/pkg/app"
"camlistore.org/pkg/buildinfo"
"camlistore.org/pkg/webserver"
)
var (
flagVersion = flag.Bool("version", false, "show version")
)
// config is used to unmarshal the application configuration JSON
// that we get from Camlistore when we request it at $CAMLI_APP_CONFIG_URL.
type config struct {
Word string `json:"word,omitempty"` // Argument printed after "Hello " in the helloHandler response.
}
func appConfig() *config {
configURL := os.Getenv("CAMLI_APP_CONFIG_URL")
if configURL == "" {
log.Fatalf("Hello application needs a CAMLI_APP_CONFIG_URL env var")
}
cl, err := app.Client()
if err != nil {
log.Fatalf("could not get a client to fetch extra config: %v", err)
}
conf := &config{}
if err := cl.GetJSON(configURL, conf); err != nil {
log.Fatalf("could not get app config at %v: %v", configURL, err)
}
return conf
}
type helloHandler struct {
who string // who to say hello to.
}
func (h *helloHandler) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
rw.Header().Set("Content-Type", "text/plain; charset=utf-8")
rw.WriteHeader(200)
fmt.Fprintf(rw, "Hello %s\n", h.who)
}
func main() {
flag.Parse()
if *flagVersion {
fmt.Fprintf(os.Stderr, "hello version: %s\nGo version: %s (%s/%s)\n",
buildinfo.Version(), runtime.Version(), runtime.GOOS, runtime.GOARCH)
return
}
log.Printf("Starting hello version %s; Go %s (%s/%s)", buildinfo.Version(), runtime.Version(),
runtime.GOOS, runtime.GOARCH)
listenAddr, err := app.ListenAddress()
if err != nil {
log.Fatalf("Listen address: %v", err)
}
conf := appConfig()
ws := webserver.New()
ws.Handle("/", &helloHandler{who: conf.Word})
// TODO(mpl): handle status requests too. Camlistore will send an auth
// token in the extra config that should be used as the "password" for
// subsequent status requests.
if err := ws.Listen(listenAddr); err != nil {
log.Fatalf("Listen: %v", err)
}
ws.Serve()
}

View File

@ -17,6 +17,17 @@
}
},
"/hello/": {
"handler": "app",
"handlerArgs": {
"program": "hello",
"baseURL": "http://localhost:3178/",
"appConfig": {
"word": "world"
}
}
},
"/blog/": {
"enabled": ["_env", "${CAMLI_PUBLISH_ENABLED}"],
"handler": "publish",

24
doc/app-environment.text Normal file
View File

@ -0,0 +1,24 @@
Camlistore applications run with the following environment variables set:
CAMLI_APP_BASEURL (string):
URL prefix of the application's root, always ending in a trailing slash. Examples:
https://foo.org:3178/pub/
https://foo.org/pub/
http://192.168.0.1/
http://192.168.0.1:1234/
CAMLI_APP_CONFIG_URL (string):
URL containing JSON configuration for the app. The body of this URL comes from the
"appConfig" part of the config file.
CAMLI_AUTH (string):
Username and password (username:password) that the app should use to authenticate
over HTTP basic auth with the Camlistore server. Basic auth is unencrypted, hence
it should only be used with HTTPS or in a secure (local loopback) environment.
CAMLI_SERVER (string):
URL prefix of Camlistore's root, always ending in a trailing slash. Examples:
https://foo.org:3178/pub/
https://foo.org/pub/
http://192.168.0.1/
http://192.168.0.1:1234/

View File

@ -133,7 +133,7 @@ func main() {
// TODO(mpl): main is getting long. We could probably move all the mirroring
// dance to its own func.
// We copy all *.go files from camRoot's goDirs to buildSrcDir.
goDirs := []string{"cmd", "pkg", "dev", "server/camlistored", "third_party"}
goDirs := []string{"app", "cmd", "pkg", "dev", "server/camlistored", "third_party"}
if *onlysync {
goDirs = append(goDirs, "server/appengine", "config")
}
@ -168,6 +168,7 @@ func main() {
"camlistore.org/cmd/camput",
"camlistore.org/cmd/camtool",
"camlistore.org/server/camlistored",
"camlistore.org/app/hello",
}
switch *targets {
case "*":
@ -227,6 +228,7 @@ func main() {
if buildAll {
args = append(args,
"camlistore.org/app/...",
"camlistore.org/pkg/...",
"camlistore.org/server/...",
"camlistore.org/third_party/...",

75
pkg/app/app.go Normal file
View File

@ -0,0 +1,75 @@
/*
Copyright 2014 The Camlistore Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
// Package app provides helpers for server applications interacting
// with Camlistore.
package app
import (
"errors"
"fmt"
"net/http"
"os"
"strings"
"camlistore.org/pkg/auth"
"camlistore.org/pkg/client"
)
// Client returns a client from pkg/client, configured by environment variables
// for applications, and ready to be used to connect to the Camlistore server.
func Client() (*client.Client, error) {
server := os.Getenv("CAMLI_SERVER")
if server == "" {
return nil, errors.New("CAMLI_SERVER var not set")
}
authString := os.Getenv("CAMLI_AUTH")
if authString == "" {
return nil, errors.New("CAMLI_AUTH var not set")
}
userpass := strings.Split(authString, ":")
if len(userpass) != 2 {
return nil, fmt.Errorf("invalid auth string syntax. got %q, want \"username:password\"", authString)
}
cl := client.NewFromParams(server, auth.NewBasicAuth(userpass[0], userpass[1]))
cl.SetHTTPClient(&http.Client{
Transport: cl.TransportForConfig(nil),
})
return cl, nil
}
// ListenAddress returns the host:[port] network address, derived from the environment,
// that the application should listen on.
func ListenAddress() (string, error) {
baseURL := os.Getenv("CAMLI_APP_BASEURL")
if baseURL == "" {
return "", errors.New("CAMLI_APP_BASEURL is undefined")
}
defaultPort := "80"
noScheme := strings.TrimPrefix(baseURL, "http://")
if strings.HasPrefix(baseURL, "https://") {
noScheme = strings.TrimPrefix(baseURL, "https://")
defaultPort = "443"
}
hostPortPrefix := strings.SplitN(noScheme, "/", 2)
if len(hostPortPrefix) != 2 {
return "", fmt.Errorf("invalid CAMLI_APP_BASEURL: %q (no trailing slash?)", baseURL)
}
if !strings.Contains(hostPortPrefix[0], ":") {
return fmt.Sprintf("%s:%s", hostPortPrefix[0], defaultPort), nil
}
return hostPortPrefix[0], nil
}

117
pkg/app/app_test.go Normal file
View File

@ -0,0 +1,117 @@
/*
Copyright 2014 The Camlistore Authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package app
import (
"os"
"testing"
)
func TestListenAddress(t *testing.T) {
tests := []struct {
baseURL string
wantAddr string
wantErr bool
}{
{
baseURL: "http://foo.com/",
wantAddr: "foo.com:80",
},
{
baseURL: "https://foo.com/",
wantAddr: "foo.com:443",
},
{
baseURL: "http://foo.com:8080/",
wantAddr: "foo.com:8080",
},
{
baseURL: "https://foo.com:8080/",
wantAddr: "foo.com:8080",
},
{
baseURL: "http://foo.com:/",
wantAddr: "foo.com:",
},
{
baseURL: "https://foo.com:/",
wantAddr: "foo.com:",
},
{
baseURL: "http://foo.com/bar/",
wantAddr: "foo.com:80",
},
{
baseURL: "https://foo.com/bar/",
wantAddr: "foo.com:443",
},
{
baseURL: "http://foo.com:8080/bar/",
wantAddr: "foo.com:8080",
},
{
baseURL: "https://foo.com:8080/bar/",
wantAddr: "foo.com:8080",
},
{
baseURL: "http://foo.com:/bar/",
wantAddr: "foo.com:",
},
{
baseURL: "https://foo.com:/bar/",
wantAddr: "foo.com:",
},
{
baseURL: "",
wantErr: true,
},
{
baseURL: "http://foo.com",
wantErr: true,
},
}
for _, v := range tests {
os.Setenv("CAMLI_APP_BASEURL", v.baseURL)
got, err := ListenAddress()
if v.wantErr {
if err == nil {
t.Errorf("Wanted error for %v", v.baseURL)
}
continue
}
if err != nil {
t.Error(err)
continue
}
if got != v.wantAddr {
t.Errorf("got: %v, want: %v", got, v.wantAddr)
}
}
}

View File

@ -82,6 +82,7 @@ var authConstructor = map[string]AuthConfigParser{
"localhost": newLocalhostAuth,
"userpass": newUserPassAuth,
"devauth": newDevAuth,
"basic": newBasicAuth,
}
// RegisterAuth registers a new authentication scheme.
@ -111,7 +112,7 @@ func newDevAuth(pw string) (AuthMode, error) {
func newUserPassAuth(arg string) (AuthMode, error) {
pieces := strings.Split(arg, ":")
if len(pieces) < 2 {
return nil, fmt.Errorf("Wrong userpass auth string; needs to be \"userpass:user:password\"")
return nil, fmt.Errorf("Wrong userpass auth string; needs to be \"user:password\"")
}
username := pieces[0]
password := pieces[1]
@ -130,6 +131,23 @@ func newUserPassAuth(arg string) (AuthMode, error) {
return mode, nil
}
func newBasicAuth(arg string) (AuthMode, error) {
pieces := strings.Split(arg, ":")
if len(pieces) != 2 {
return nil, fmt.Errorf("invalid basic auth syntax. got %q, want \"username:password\"", arg)
}
return NewBasicAuth(pieces[0], pieces[1]), nil
}
// NewBasicAuth returns a UserPass Authmode, adequate to support HTTP
// basic authentication.
func NewBasicAuth(username, password string) AuthMode {
return &UserPass{
Username: username,
Password: password,
}
}
// ErrNoAuth is returned when there is no configured authentication.
var ErrNoAuth = errors.New("auth: no configured authentication")
@ -347,9 +365,15 @@ func ProcessRandom() string {
}
func genProcessRand() {
buf := make([]byte, 20)
processRand = RandToken(20)
}
// RandToken genererates (with crypto/rand.Read) and returns a token
// that is the hex version (2x size) of size bytes of randomness.
func RandToken(size int) string {
buf := make([]byte, size)
if n, err := rand.Read(buf); err != nil || n != len(buf) {
panic("failed to get random: " + err.Error())
}
processRand = fmt.Sprintf("%x", buf)
return fmt.Sprintf("%x", buf)
}

View File

@ -37,6 +37,9 @@ func TestFromConfig(t *testing.T) {
{in: "userpass:alice:secret:+localhost", want: &UserPass{Username: "alice", Password: "secret", OrLocalhost: true, VivifyPass: ""}},
{in: "userpass:alice:secret:+localhost:vivify=foo", want: &UserPass{Username: "alice", Password: "secret", OrLocalhost: true, VivifyPass: "foo"}},
{in: "devauth:port3179", want: &DevAuth{Password: "port3179", VivifyPass: "viviport3179"}},
{in: "basic:alice:secret", want: &Basic{Username: "alice", Password: "secret", OrLocalhost: true, VivifyPass: ""}},
{in: "basic:alice:secret:+localhost", wanterr: `invalid basic auth syntax. got "alice:secret:+localhost", want "username:password"`},
{in: "basic:alice:secret:+vivify=foo", wanterr: `invalid basic auth syntax. got "alice:secret:+vivify=foo", want "username:password"`},
}
for _, tt := range tests {
am, err := FromConfig(tt.in)

View File

@ -770,6 +770,21 @@ func (c *Client) doDiscovery() error {
return nil
}
// GetJSON sends a GET request to url, and unmarshals the returned
// JSON response into data. The URL's host must match the client's
// configured server.
func (c *Client) GetJSON(url string, data interface{}) error {
if !strings.HasPrefix(url, c.discoRoot()) {
return fmt.Errorf("wrong URL (%q) for this server", url)
}
hreq := c.newRequest("GET", url)
resp, err := c.expect2XX(hreq)
if err != nil {
return err
}
return httputil.DecodeJSON(resp, data)
}
func (c *Client) newRequest(method, url string, body ...io.Reader) *http.Request {
var bodyR io.Reader
if len(body) > 0 {

164
pkg/server/app/app.go Normal file
View File

@ -0,0 +1,164 @@
/*
Copyright 2014 The Camlistore Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
// Package app helps with configuring and starting server applications
// from Camlistore.
package app
import (
"fmt"
"log"
"net/http"
"net/http/httputil"
"net/url"
"os"
"os/exec"
"path/filepath"
"strings"
"camlistore.org/pkg/auth"
camhttputil "camlistore.org/pkg/httputil"
"camlistore.org/pkg/jsonconfig"
"camlistore.org/pkg/osutil"
)
// AppHandler acts as a reverse proxy for a server application started by
// Camlistore. It can also serve some extra JSON configuration to the app.
type AppHandler struct {
name string // Name of the app's program.
envVars map[string]string // Variables set in the app's process environment. See pkg/app/vars.txt.
auth auth.AuthMode // Used for basic HTTP authenticating against the app requests.
appConfig jsonconfig.Obj // Additional parameters the app can request, or nil.
proxy *httputil.ReverseProxy // For redirecting requests to the app.
}
func (a *AppHandler) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
if camhttputil.PathSuffix(req) == "config.json" {
if a.auth.AllowedAccess(req)&auth.OpGet == auth.OpGet {
camhttputil.ReturnJSON(rw, a.appConfig)
} else {
auth.SendUnauthorized(rw, req)
}
return
}
if a.proxy == nil {
http.Error(rw, "no proxy for the app", 500)
return
}
a.proxy.ServeHTTP(rw, req)
}
// New returns a configured AppHandler that Camlistore can use during server initialization
// as a handler that proxies request to an app. It is also used to start the app.
// The conf object has the following members, related to the vars described in doc/app-environment.text:
// "program", string, required. Name of the app's program.
// "baseURL", string, required. See CAMLI_APP_BASEURL.
// "server", string, optional, overrides the camliBaseURL argument. See CAMLI_SERVER.
// "appConfig", object, optional. Additional configuration that the app can request from Camlistore.
func New(conf jsonconfig.Obj, serverBaseURL string) (*AppHandler, error) {
name := conf.RequiredString("program")
server := conf.OptionalString("server", serverBaseURL)
if server == "" {
return nil, fmt.Errorf("could not initialize AppHandler for %q: Camlistore baseURL is unknown", name)
}
baseURL := conf.RequiredString("baseURL")
appConfig := conf.OptionalObject("appConfig")
// TODO(mpl): add an auth token in the extra config of the dev server config,
// that the hello app can use to setup a status handler than only responds
// to requests with that token.
if err := conf.Validate(); err != nil {
return nil, err
}
username, password := auth.RandToken(20), auth.RandToken(20)
camliAuth := username + ":" + password
basicAuth := auth.NewBasicAuth(username, password)
envVars := map[string]string{
"CAMLI_SERVER": server,
"CAMLI_AUTH": camliAuth,
"CAMLI_APP_BASEURL": baseURL,
}
if appConfig != nil {
appConfigURL := fmt.Sprintf("%s/%s/%s", strings.TrimSuffix(server, "/"), name, "config.json")
envVars["CAMLI_APP_CONFIG_URL"] = appConfigURL
}
proxyURL, err := url.Parse(baseURL)
if err != nil {
return nil, fmt.Errorf("could not parse baseURL %q: %v", baseURL, err)
}
return &AppHandler{
name: name,
envVars: envVars,
auth: basicAuth,
appConfig: appConfig,
proxy: httputil.NewSingleHostReverseProxy(proxyURL),
}, nil
}
func (a *AppHandler) Start() error {
name := a.name
if name == "" {
return fmt.Errorf("invalid app name: %q", name)
}
// first look for it in PATH
binPath, err := exec.LookPath(name)
if err != nil {
log.Printf("%q binary not found in PATH. now trying in the camlistore tree.", name)
// else try in the camlistore tree
binDir, err := osutil.GoPackagePath("camlistore.org/bin")
if err != nil {
return fmt.Errorf("bin dir in camlistore tree was not found: %v", err)
}
binPath = filepath.Join(binDir, name)
if _, err = os.Stat(binPath); err != nil {
return fmt.Errorf("could not find %v binary at %v: %v", name, binPath, err)
}
}
cmd := exec.Command(binPath)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
// TODO(mpl): extract Env methods from dev/devcam/env.go to a util pkg and use them here.
newVars := make(map[string]string, len(a.envVars))
for k, v := range a.envVars {
newVars[k+"="] = v
}
env := os.Environ()
for pos, oldkv := range env {
for k, newVal := range newVars {
if strings.HasPrefix(oldkv, k) {
env[pos] = k + newVal
delete(newVars, k)
break
}
}
}
for k, v := range newVars {
env = append(env, k+v)
}
cmd.Env = env
if err := cmd.Start(); err != nil {
return fmt.Errorf("could not start app %v: %v", name, err)
}
return nil
}
func (a *AppHandler) Name() string {
return a.name
}

View File

@ -42,6 +42,7 @@ import (
"camlistore.org/pkg/httputil"
"camlistore.org/pkg/index"
"camlistore.org/pkg/jsonconfig"
"camlistore.org/pkg/server/app"
"camlistore.org/pkg/types/serverconfig"
)
@ -333,10 +334,20 @@ func (hl *handlerLoader) setupHandler(prefix string) {
return
}
hh, err := blobserver.CreateHandler(h.htype, hl, h.conf)
if err != nil {
exitFailure("error instantiating handler for prefix %q, type %q: %v",
h.prefix, h.htype, err)
var hh http.Handler
if h.htype == "app" {
ap, err := app.New(h.conf, hl.baseURL+"/")
if err != nil {
exitFailure("error setting up app for prefix %q: %v", h.prefix, err)
}
hh = ap
} else {
var err error
hh, err = blobserver.CreateHandler(h.htype, hl, h.conf)
if err != nil {
exitFailure("error instantiating handler for prefix %q, type %q: %v",
h.prefix, h.htype, err)
}
}
hl.handler[prefix] = hh
@ -345,7 +356,7 @@ func (hl *handlerLoader) setupHandler(prefix string) {
wrappedHandler = unauthorizedHandler{}
} else {
wrappedHandler = &httputil.PrefixHandler{prefix, hh}
if handerTypeWantsAuth(h.htype) {
if handlerTypeWantsAuth(h.htype) {
wrappedHandler = auth.Handler{wrappedHandler}
}
}
@ -358,7 +369,7 @@ func (unauthorizedHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
}
func handerTypeWantsAuth(handlerType string) bool {
func handlerTypeWantsAuth(handlerType string) bool {
// TODO(bradfitz): ask the handler instead? This is a bit of a
// weird spot for this policy maybe?
switch handlerType {
@ -375,6 +386,10 @@ type Config struct {
jsonconfig.Obj
UIPath string // Not valid until after InstallHandlers
configPath string // Filesystem path
// apps is the list of server apps configured during InstallHandlers,
// and that should be started after camlistored has started serving.
apps []*app.AppHandler
}
// detectConfigChange returns an informative error if conf contains obsolete keys.
@ -527,7 +542,11 @@ func (config *Config) InstallHandlers(hi HandlerInstaller, baseURL string, reind
// Now that everything is setup, run any handlers' InitHandler
// methods.
// And register apps that will be started later.
for pfx, handler := range hl.handler {
if starter, ok := handler.(*app.AppHandler); ok {
config.apps = append(config.apps, starter)
}
if in, ok := handler.(blobserver.HandlerIniter); ok {
if err := in.InitHandler(hl); err != nil {
return nil, fmt.Errorf("Error calling InitHandler on %s: %v", pfx, err)
@ -544,6 +563,19 @@ func (config *Config) InstallHandlers(hi HandlerInstaller, baseURL string, reind
return multiCloser(hl.closers), nil
}
// StartApps starts all the server applications that were configured
// during InstallHandlers. It should only be called after camlistored
// has started serving, since these apps might request some configuration
// from Camlistore to finish initializing.
func (config *Config) StartApps() error {
for _, ap := range config.apps {
if err := ap.Start(); err != nil {
return fmt.Errorf("error starting app %v: %v", ap.Name(), err)
}
}
return nil
}
func mustCreate(path string) *os.File {
f, err := os.Create(path)
if err != nil {

View File

@ -395,6 +395,10 @@ func Main(up chan<- struct{}, down <-chan struct{}) {
osutil.DieOnParentDeath()
}
if err := config.StartApps(); err != nil {
exitf("StartApps: %v", err)
}
// Block forever, except during tests.
up <- struct{}{}
<-down