fix(manager): refactor REST API to make it modular

This modifies the startup and initial handling of the REST API to make
it modular, remove the init() methods, and remove reliance on global
vars.
pull/363/head
Matt Butcher 9 years ago
parent b4c8e3c79d
commit 87d360afda

@ -56,7 +56,7 @@ container: all
.PHONY: test-unit
test-unit:
@echo Running tests...
go test -v $(GO_PKGS)
go test -race -v $(GO_PKGS)
.PHONY: test-flake8
test-flake8:

@ -18,7 +18,7 @@ package main
import (
"encoding/json"
"flag"
"errors"
"fmt"
"io"
"io/ioutil"
@ -32,11 +32,11 @@ import (
"github.com/ghodss/yaml"
"github.com/gorilla/mux"
"github.com/kubernetes/deployment-manager/cmd/manager/manager"
"github.com/kubernetes/deployment-manager/cmd/manager/repository"
"github.com/kubernetes/deployment-manager/cmd/manager/repository/persistent"
"github.com/kubernetes/deployment-manager/cmd/manager/repository/transient"
"github.com/kubernetes/deployment-manager/cmd/manager/router"
"github.com/kubernetes/deployment-manager/pkg/common"
"github.com/kubernetes/deployment-manager/pkg/registry"
"github.com/kubernetes/deployment-manager/pkg/util"
@ -65,57 +65,84 @@ var deployments = []Route{
{"GetCredential", "/credentials/{credential}", "GET", getCredentialHandlerFunc, ""},
}
var (
maxLength = flag.Int64("maxLength", 1024, "The maximum length (KB) of a template.")
expanderName = flag.String("expander", "expandybird-service", "The DNS name of the expander service.")
expanderURL = flag.String("expanderURL", "", "The URL for the expander service.")
deployerName = flag.String("deployer", "resourcifier-service", "The DNS name of the deployer service.")
deployerURL = flag.String("deployerURL", "", "The URL for the deployer service.")
credentialFile = flag.String("credentialFile", "", "Local file to use for credentials.")
credentialSecrets = flag.Bool("credentialSecrets", true, "Use secrets for credentials.")
mongoName = flag.String("mongoName", "mongodb", "The DNS name of the mongodb service.")
mongoPort = flag.String("mongoPort", "27017", "The port of the mongodb service.")
mongoAddress = flag.String("mongoAddress", "mongodb:27017", "The address of the mongodb service.")
)
// Deprecated. Use Context.Manager instead.
var backend manager.Manager
func init() {
if !flag.Parsed() {
flag.Parse()
// Route defines a routing table entry to be registered with gorilla/mux.
//
// Route is deprecated. Use router.Routes instead.
type Route struct {
Name string
Path string
Methods string
HandlerFunc http.HandlerFunc
Type string
}
func registerRoutes(c *router.Context) router.Routes {
re := regexp.MustCompile("{[a-z]+}")
r := router.NewRoutes()
r.Add("GET /healthz", healthz)
// TODO: Replace these routes with updated ones.
for _, d := range deployments {
path := fmt.Sprintf("%s %s", d.Methods, re.ReplaceAllString(d.Path, "*"))
fmt.Printf("\t%s\n", path)
r.Add(path, func(w http.ResponseWriter, r *http.Request, c *router.Context) error {
d.HandlerFunc(w, r)
return nil
})
}
return r
}
func healthz(w http.ResponseWriter, r *http.Request, c *router.Context) error {
c.Log("manager: healthz checkpoint")
// TODO: This should check the availability of the repository, and fail if it
// cannot connect.
fmt.Fprintln(w, "OK")
return nil
}
routes = append(routes, deployments...)
func setupDependencies(c *router.Context) error {
var credentialProvider common.CredentialProvider
if *credentialFile != "" {
if *credentialSecrets {
panic(fmt.Errorf("Both credentialFile and credentialSecrets are set"))
if c.Config.CredentialFile != "" {
if c.Config.CredentialSecrets {
return errors.New("Both credentialFile and credentialSecrets are set")
}
var err error
credentialProvider, err = registry.NewFilebasedCredentialProvider(*credentialFile)
credentialProvider, err = registry.NewFilebasedCredentialProvider(c.Config.CredentialFile)
if err != nil {
panic(fmt.Errorf("cannot create credential provider %s: %s", *credentialFile, err))
return fmt.Errorf("cannot create credential provider %s: %s", c.Config.CredentialFile, err)
}
} else if *credentialSecrets {
credentialProvider = registry.NewSecretsCredentialProvider()
} else {
credentialProvider = registry.NewInmemCredentialProvider()
}
backend = newManager(credentialProvider)
c.CredentialProvider = credentialProvider
c.Manager = newManager(c)
// FIXME: As soon as we can, we need to get rid of this.
backend = c.Manager
return nil
}
const expanderPort = "8080"
const deployerPort = "8080"
func newManager(cp common.CredentialProvider) manager.Manager {
func newManager(c *router.Context) manager.Manager {
cfg := c.Config
service := registry.NewInmemRegistryService()
registryProvider := registry.NewDefaultRegistryProvider(cp, service)
registryProvider := registry.NewDefaultRegistryProvider(c.CredentialProvider, service)
resolver := manager.NewTypeResolver(registryProvider, util.DefaultHTTPClient())
expander := manager.NewExpander(getServiceURL(*expanderURL, *expanderName, expanderPort), resolver)
deployer := manager.NewDeployer(getServiceURL(*deployerURL, *deployerName, deployerPort))
address := strings.TrimPrefix(getServiceURL(*mongoAddress, *mongoName, *mongoPort), "http://")
expander := manager.NewExpander(getServiceURL(cfg.ExpanderURL, cfg.ExpanderName, expanderPort), resolver)
deployer := manager.NewDeployer(getServiceURL(cfg.DeployerURL, cfg.DeployerName, deployerPort))
address := strings.TrimPrefix(getServiceURL(cfg.MongoAddress, cfg.MongoName, cfg.MongoPort), "http://")
repository := createRepository(address)
return manager.NewManager(expander, deployer, repository, registryProvider, service, cp)
return manager.NewManager(expander, deployer, repository, registryProvider, service, c.CredentialProvider)
}
func createRepository(address string) repository.Repository {

@ -17,75 +17,64 @@ limitations under the License.
package main
import (
"github.com/kubernetes/deployment-manager/pkg/util"
"github.com/kubernetes/deployment-manager/cmd/manager/router"
"github.com/kubernetes/deployment-manager/pkg/version"
"flag"
"fmt"
"log"
"net/http"
"os"
)
"github.com/gorilla/handlers"
"github.com/gorilla/mux"
var (
port = flag.Int("port", 8080, "The port to listen on")
maxLength = flag.Int64("maxLength", 1024, "The maximum length (KB) of a template.")
expanderName = flag.String("expander", "expandybird-service", "The DNS name of the expander service.")
expanderURL = flag.String("expanderURL", "", "The URL for the expander service.")
deployerName = flag.String("deployer", "resourcifier-service", "The DNS name of the deployer service.")
deployerURL = flag.String("deployerURL", "", "The URL for the deployer service.")
credentialFile = flag.String("credentialFile", "", "Local file to use for credentials.")
credentialSecrets = flag.Bool("credentialSecrets", true, "Use secrets for credentials.")
mongoName = flag.String("mongoName", "mongodb", "The DNS name of the mongodb service.")
mongoPort = flag.String("mongoPort", "27017", "The port of the mongodb service.")
mongoAddress = flag.String("mongoAddress", "mongodb:27017", "The address of the mongodb service.")
)
// Route defines a routing table entry to be registered with gorilla/mux.
type Route struct {
Name string
Path string
Methods string
HandlerFunc http.HandlerFunc
Type string
func main() {
// Set up dependencies
c := &router.Context{
Config: parseFlags(),
}
var routes = []Route{
{"HealthCheck", "/healthz", "GET", healthCheckHandlerFunc, ""},
if err := setupDependencies(c); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
// port to listen on
var port = flag.Int("port", 8080, "The port to listen on")
// Set up routes
routes := registerRoutes(c)
func init() {
if !flag.Parsed() {
flag.Parse()
// Now create a server.
c.Log("Starting Manager %s on %s", version.Version, c.Config.Address)
if err := http.ListenAndServe(c.Config.Address, router.NewHandler(c, routes)); err != nil {
c.Err("Server exited with error %s", err)
os.Exit(1)
}
}
func main() {
if !flag.Parsed() {
func parseFlags() *router.Config {
flag.Parse()
return &router.Config{
Address: fmt.Sprintf(":%d", *port),
MaxTemplateLength: *maxLength,
ExpanderName: *expanderName,
ExpanderURL: *expanderURL,
DeployerName: *deployerName,
DeployerURL: *deployerURL,
CredentialFile: *credentialFile,
CredentialSecrets: *credentialSecrets,
MongoName: *mongoName,
MongoPort: *mongoPort,
MongoAddress: *mongoAddress,
}
router := mux.NewRouter()
router.StrictSlash(true)
for _, route := range routes {
handler := http.Handler(http.HandlerFunc(route.HandlerFunc))
switch route.Type {
case "JSON":
handler = handlers.ContentTypeHandler(handler, "application/json")
case "":
break
default:
log.Fatalf("invalid route type: %v", route.Type)
}
r := router.NewRoute()
r.Name(route.Name).
Path(route.Path).
Methods(route.Methods).
Handler(handler)
}
address := fmt.Sprintf(":%d", *port)
handler := handlers.CombinedLoggingHandler(os.Stderr, router)
log.Printf("Version: %s", version.Version)
log.Printf("Listening on port %d...", *port)
log.Fatal(http.ListenAndServe(address, handler))
}
func healthCheckHandlerFunc(w http.ResponseWriter, r *http.Request) {
handler := "manager: get health"
util.LogHandlerEntry(handler, r)
util.LogHandlerExitWithText(handler, w, "OK", http.StatusOK)
}

@ -0,0 +1,121 @@
/*
Copyright 2016 The Kubernetes Authors All rights reserved.
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 router
import (
"encoding/json"
"errors"
"fmt"
"mime"
"net/http"
"reflect"
"strings"
"github.com/ghodss/yaml"
)
type Encoder interface {
// Encode encoders a given response
//
// When an encoder fails, it logs any necessary data and then responds to
// the client.
Encode(http.ResponseWriter, *http.Request, *Context, interface{})
}
// AcceptEncodder uses the accept headers on a request to determine the response type.
//
// It supports the following encodings:
// - application/json: passed to encoding/json.Marshal
// - text/yaml: passed to gopkg.in/yaml.v2.Marshal
// - text/plain: passed to fmt.Sprintf("%V")
type AcceptEncoder struct {
DefaultEncoding string
}
// Encode encodeds the given interface to the first available type in the Accept header.
func (e *AcceptEncoder) Encode(w http.ResponseWriter, r *http.Request, c *Context, out interface{}) {
a := r.Header.Get("accept")
fn := encoders[e.DefaultEncoding]
mt := e.DefaultEncoding
if a != "" {
mt, fn = e.parseAccept(a)
}
data, err := fn(out)
if err != nil {
Fatal(w, r, "Could not marshal data: %s", err)
return
}
w.Header().Add("content-type", mt)
w.Write(data)
}
// parseAccept parses the value of an Accept: header and returns the best match.
//
// This returns the matched MIME type and the Marshal function.
func (e *AcceptEncoder) parseAccept(h string) (string, Marshaler) {
keys := strings.Split(h, ",")
for _, k := range keys {
mt, _, err := mime.ParseMediaType(k)
if err != nil {
continue
}
if enc, ok := encoders[mt]; ok {
return mt, enc
}
}
return e.DefaultEncoding, encoders[e.DefaultEncoding]
}
type Marshaler func(interface{}) ([]byte, error)
var encoders = map[string]Marshaler{
"application/json": json.Marshal,
"text/yaml": yaml.Marshal,
"application/x-yaml": yaml.Marshal,
"text/plain": textMarshal,
}
var ErrUnsupportedKind = errors.New("unsupported kind")
// textMarshal marshals v into a text representation ONLY IN NARROW CASES.
//
// An error will have its Error() method called.
// A fmt.Stringer will have its String() method called.
// Scalar types will be marshaled with fmt.Sprintf("%v").
//
// This will only marshal scalar types for securoty reasons (namely, we don't
// want the possibility of forcing exposure of non-exported data or ptr
// addresses, etc.)
func textMarshal(v interface{}) ([]byte, error) {
switch s := v.(type) {
case error:
return []byte(s.Error()), nil
case fmt.Stringer:
return []byte(s.String()), nil
}
// Error on kinds we don't support.
val := reflect.Indirect(reflect.ValueOf(v))
switch val.Kind() {
case reflect.Invalid, reflect.Array, reflect.Chan, reflect.Func, reflect.Interface,
reflect.Map, reflect.Ptr, reflect.Slice, reflect.Struct, reflect.UnsafePointer:
return []byte{}, ErrUnsupportedKind
}
return []byte(fmt.Sprintf("%v", v)), nil
}

@ -0,0 +1,110 @@
/*
Copyright 2016 The Kubernetes Authors All rights reserved.
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 router
import (
"encoding/json"
"errors"
"io/ioutil"
"net/http"
"testing"
)
var _ Encoder = &AcceptEncoder{}
func TestParseAccept(t *testing.T) {
e := &AcceptEncoder{
DefaultEncoding: "application/json",
}
tests := map[string]string{
"": e.DefaultEncoding,
"*/*": e.DefaultEncoding,
// To stay true to spec, this _should_ be an error. But our thought
// on this case is that we'd rather send a default format.
"audio/*; q=0.2, audio/basic": e.DefaultEncoding,
"text/html; q=0.8, text/yaml,application/json": "text/yaml",
"application/x-yaml; foo=bar": "application/x-yaml",
"text/monkey, TEXT/YAML ; zoom=zoom ": "text/yaml",
}
for in, expects := range tests {
mt, enc := e.parseAccept(in)
if mt != expects {
t.Errorf("Expected %q, got %q", expects, mt)
continue
}
_, err := enc([]string{"hello", "world"})
if err != nil {
t.Fatalf("Failed to marshal: %s", err)
}
}
}
func TestTextMarshal(t *testing.T) {
tests := map[string]interface{}{
"foo": "foo",
"5": 5,
"stinky cheese": errors.New("stinky cheese"),
}
for expect, in := range tests {
if o, err := textMarshal(in); err != nil || string(o) != expect {
t.Errorf("Expected %q, got %q", expect, o)
}
}
if _, err := textMarshal(struct{ foo int }{5}); err != ErrUnsupportedKind {
t.Fatalf("Expected unsupported kind, got %v", err)
}
}
func TestAcceptEncoder(t *testing.T) {
c := &Context{
Encoder: &AcceptEncoder{DefaultEncoding: "application/json"},
}
fn := func(w http.ResponseWriter, r *http.Request, c *Context) error {
c.Encoder.Encode(w, r, c, []string{"hello", "world"})
return nil
}
s := httpHarness(c, "GET /", fn)
defer s.Close()
res, err := http.Get(s.URL)
if err != nil {
t.Fatal(err)
}
if res.StatusCode != 200 {
t.Fatalf("Unexpected response code %d", res.StatusCode)
}
if mt := res.Header.Get("content-type"); mt != "application/json" {
t.Errorf("Unexpected content type: %q", mt)
}
data, err := ioutil.ReadAll(res.Body)
res.Body.Close()
if err != nil {
t.Fatalf("Failed to read response body: %s", err)
}
out := []string{}
if err := json.Unmarshal(data, &out); err != nil {
t.Fatalf("Failed to unmarshal JSON: %s", err)
}
if out[0] != "hello" {
t.Fatalf("Unexpected JSON data in slot 0: %s", out[0])
}
}

@ -0,0 +1,203 @@
/*
Copyright 2016 The Kubernetes Authors All rights reserved.
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 router is an HTTP router.
This router provides appropriate dependency injection/encapsulation for the
HTTP routing layer. This removes the requirement to set global variables for
resources like database handles.
This library does not replace the default HTTP mux because there is no need.
Instead, it implements an HTTP handler.
It then defines a handler function that is given a context as well as a
request and response.
*/
package router
import (
"fmt"
"net/http"
"os"
"github.com/Masterminds/httputil"
"github.com/kubernetes/deployment-manager/cmd/manager/manager"
"github.com/kubernetes/deployment-manager/pkg/common"
)
const LogAccess = "Access: %s %s"
// Config holds the global configuration parameters passed into the router.
//
// Config is used concurrently. Once a config is created, it should be treated
// as immutable.
type Config struct {
// Address is the host and port (:8080)
Address string
// MaxTemplateLength is the maximum length of a template.
MaxTemplateLength int64
// ExpanderName is the DNS name of the expansion service.
ExpanderName string
// ExpanderURL is the expander service's URL.
ExpanderURL string
// DeployerName is the deployer's DNS name
DeployerName string
// DeployerURL is the deployer's URL
DeployerURL string
// CredentialFile is the file to the credentials.
CredentialFile string
// CredentialSecrets tells the service to use a secrets file instead.
CredentialSecrets bool
// MongoName is the DNS name of the mongo server.
MongoName string
// MongoPort is the port for the MongoDB protocol on the mongo server.
// It is a string for historical reasons.
MongoPort string
// MongoAddress is the name and port.
MongoAddress string
}
// Context contains dependencies that are passed to each handler function.
//
// Context carries typed information, often scoped to interfaces, so that the
// caller's contract with the service is known at compile time.
//
// Members of the context must be concurrency safe.
type Context struct {
Config *Config
// Manager is a deployment-manager/manager/manager.Manager
Manager manager.Manager
Encoder Encoder
CredentialProvider common.CredentialProvider
}
func (c *Context) Log(msg string, v ...interface{}) {
// FIXME: This should be configurable via the context.
fmt.Fprintf(os.Stdout, msg+"\n", v...)
}
func (c *Context) Err(msg string, v ...interface{}) {
// FIXME: This should be configurable via the context.
fmt.Fprintf(os.Stderr, msg+"\n", v...)
}
// NotFound writes a 404 error to the client and logs an error.
func NotFound(w http.ResponseWriter, r *http.Request) {
// TODO: Log this.
w.WriteHeader(http.StatusNotFound)
fmt.Fprintln(w, "File Not Found")
}
// Fatal writes a 500 response to the client and logs the message.
//
// Additional arguments are past into the the formatter as params to msg.
func Fatal(w http.ResponseWriter, r *http.Request, msg string, v ...interface{}) {
// TODO: Log this.
w.WriteHeader(http.StatusInternalServerError)
fmt.Fprintln(w, "Internal Server Error")
}
// HandlerFunc responds to an individual HTTP request.
//
// Returned errors will be captured, logged, and returned as HTTP 500 errors.
type HandlerFunc func(w http.ResponseWriter, r *http.Request, c *Context) error
// Handler implements an http.Handler.
//
// This is the top level route handler.
type Handler struct {
c *Context
resolver *httputil.Resolver
routes Routes
}
// Create a new Handler.
//
// Routes cannot be modified after construction. The order that the route
// names are returned by Routes.Paths() determines the lookup order.
func NewHandler(c *Context, r Routes) *Handler {
paths := make([]string, r.Len())
i := 0
for _, k := range r.Paths() {
paths[i] = k
i++
}
return &Handler{
c: c,
resolver: httputil.NewResolver(paths),
routes: r,
}
}
// ServeHTTP serves an HTTP request.
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
h.c.Log(LogAccess, r.Method, r.URL)
route, err := h.resolver.Resolve(r)
if err != nil {
NotFound(w, r)
return
}
fn, ok := h.routes.Get(route)
if !ok {
Fatal(w, r, "route %s missing", route)
}
if err := fn(w, r, h.c); err != nil {
Fatal(w, r, err.Error())
}
}
// Routes defines a container for route-to-function mapping.
type Routes interface {
Add(string, HandlerFunc)
Get(string) (HandlerFunc, bool)
Len() int
Paths() []string
}
// NewRoutes creates a default implementation of a Routes.
//
// The ordering of routes is nonderministic.
func NewRoutes() Routes {
return routeMap{}
}
type routeMap map[string]HandlerFunc
func (r routeMap) Add(name string, fn HandlerFunc) {
r[name] = fn
}
func (r routeMap) Get(name string) (HandlerFunc, bool) {
f, ok := r[name]
return f, ok
}
func (r routeMap) Len() int {
return len(r)
}
func (r routeMap) Paths() []string {
b := make([]string, len(r))
i := 0
for k := range r {
b[i] = k
i++
}
return b
}

@ -0,0 +1,72 @@
/*
Copyright 2016 The Kubernetes Authors All rights reserved.
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 router
import (
"fmt"
"io/ioutil"
"net/http"
"net/http/httptest"
"testing"
)
// Canary
var v Routes = routeMap{}
func TestHandler(t *testing.T) {
c := &Context{}
r := NewRoutes()
r.Add("GET /", func(w http.ResponseWriter, r *http.Request, c *Context) error {
fmt.Fprintln(w, "hello")
return nil
})
r.Add("POST /", func(w http.ResponseWriter, r *http.Request, c *Context) error {
fmt.Fprintln(w, "goodbye")
return nil
})
h := NewHandler(c, r)
s := httptest.NewServer(h)
defer s.Close()
res, err := http.Get(s.URL)
if err != nil {
t.Fatal(err)
}
data, err := ioutil.ReadAll(res.Body)
res.Body.Close()
if err != nil {
t.Fatal(err)
}
if "hello\n" != string(data) {
t.Errorf("Expected 'hello', got %q", data)
}
}
// httpHarness is a simple test server fixture.
// Simple fixture for standing up a test server with a single route.
//
// You must Close() the returned server.
func httpHarness(c *Context, route string, fn HandlerFunc) *httptest.Server {
r := NewRoutes()
r.Add(route, fn)
h := NewHandler(c, r)
return httptest.NewServer(h)
}

8
glide.lock generated

@ -1,5 +1,5 @@
hash: d9beab9a799ac8dd0d76c4f7a3a32753d44833dd3527b3caa8e786865ea26816
updated: 2016-03-04T09:54:13.155442463-07:00
hash: f34e830b237fcba5202f84c55b21764226b7e8c201f7622e9639c7a628e33b0b
updated: 2016-03-10T15:03:34.650809572-07:00
imports:
- name: github.com/aokoli/goutils
version: 9c37978a95bd5c709a15883b6242714ea6709e64
@ -25,10 +25,12 @@ imports:
version: 8f2758070a82adb7a3ad6b223a0b91878f32d400
- name: github.com/gorilla/mux
version: 26a6070f849969ba72b72256e9f14cf519751690
- name: github.com/Masterminds/httputil
version: e9b977e9cf16f9d339573e18f0f1f7ce5d3f419a
- name: github.com/Masterminds/semver
version: c4f7ef0702f269161a60489ccbbc9f1241ad1265
- name: github.com/Masterminds/sprig
version: fd057ca403105755181f84645696d705a58852dd
version: 0199893f008a87287bf2b4e3e390e66bb074c659
- name: golang.org/x/net
version: 04b9de9b512f58addf28c9853d50ebef61c3953e
subpackages:

@ -14,3 +14,4 @@ import:
- package: github.com/gorilla/mux
- package: gopkg.in/yaml.v2
- package: github.com/Masterminds/sprig
- package: github.com/Masterminds/httputil

Loading…
Cancel
Save