Compare commits

...

5 Commits

6 changed files with 229 additions and 69 deletions

@ -2,8 +2,6 @@
_The minimal, declarative service catalog._ _The minimal, declarative service catalog._
Pronounced "MC" as in the master of ceremonies.
## Background ## Background
In the last three jobs I've worked at, it's always been a hassle trying to locate the various dashboards, documentation, In the last three jobs I've worked at, it's always been a hassle trying to locate the various dashboards, documentation,
@ -60,11 +58,38 @@ func main() {
Once you've built your catalog, you can easily run a landing page by executing the catalog file. Once you've built your catalog, you can easily run a landing page by executing the catalog file.
``` ```sh
$ go run ./catalog.go $ go run ./catalog.go
``` ```
This starts a web server for you to interact with on `localhost:8080`. If `:8080` is already in use, you can configure This starts a web server for you to interact with on `localhost:8080`. If `:8080` is already in use, you can configure
the bind address be passing the `-bind_address` flag with the desired host and port. the bind address by passing the `-bind_address` flag with the desired host and port.
![Screenshot](screenshot.png) <p align="center">
<img src="screenshot.png" alt="Screenshot" width="72%"/>
</p>
### Exporting your catalog
Instead of needing to compile a binary or host your catalog using `go run`, you can export your catalog to HTML or JSON.
This makes it easy to drop into existing self-host platforms or leverage with other popular systems.
```sh
$ go run ./catalog.go -output html > index.html
$ go run ./catalog.go -output json > catalog.json
```
### Protecting your catalog using oauth-proxy
Regardless of how you host your catalog, you'll likely want to protect access to it. An easy way to do this is using the
[oauth-proxy][] project. This project provides common OAuth2 client functionality to any project, making it easy to
require authentication in order to access a system / project.
<!-- TODO: write up guide and link to it from here -->
Until I have more of a concrete guide, you can follow my setup [here](https://github.com/mjpitz/mjpitz/blob/main/infra/helm/catalog/values.yaml).
A simple analogy to this deployment would be a docker compose file with two services, one for the oauth-proxy and the
other for the catalog (bound to 127.0.0.1). Using the new `-output` functionality, this deployment could definitely
be simplified.
[oauth-proxy]: https://oauth2-proxy.github.io/oauth2-proxy

@ -14,7 +14,8 @@
} }
div.catalog { div.catalog {
max-width: 800px; max-width: 1600px;
min-width: 1200px;
margin: 0 auto; margin: 0 auto;
} }
@ -31,14 +32,14 @@
min-width: 50%; min-width: 50%;
} }
div.col-60 { div.col-65 {
max-width: 60%; max-width: 65%;
min-width: 60%; min-width: 65%;
} }
div.col-40 { div.col-35 {
max-width: 40%; max-width: 35%;
min-width: 40%; min-width: 35%;
} }
div.catalog h1 { div.catalog h1 {
@ -52,7 +53,7 @@
border: 1px solid #aaa; border: 1px solid #aaa;
padding: 20px 26px; padding: 20px 26px;
border-radius: 5px; border-radius: 5px;
margin-top: 18px; margin: 9px 18px;
} }
div.catalog div.service img.logo { div.catalog div.service img.logo {
@ -81,11 +82,12 @@
<body> <body>
<div class="catalog"> <div class="catalog">
<h1>Service Catalog</h1> {{- range $i, $service := .Services }}
{{- range $service := .Services }} {{- if $i | mod 2 | eq 0 }}<div class="row">{{- end }}
<div class="col col-50">
<div class="service"> <div class="service">
<div class="row"> <div class="row">
<div class="col col-60" style="padding-right: 10px"> <div class="col col-65" style="padding-right: 10px">
<div> <div>
{{- if $service.LogoURL }} {{- if $service.LogoURL }}
<img class="logo" src="{{ $service.LogoURL }}" /> <img class="logo" src="{{ $service.LogoURL }}" />
@ -102,15 +104,15 @@
<p class="description">{{ $service.Description }}</p> <p class="description">{{ $service.Description }}</p>
{{- end }} {{- end }}
{{- range $key, $value := $service.Metadata }} {{- range $kv := $service.Metadata }}
<div class="metadata row"> <div class="metadata row">
<div class="col-50"><b>{{ $key }}</b></div> <div class="col col-35"><b>{{ $kv.Key }}</b></div>
<div class="col-50">{{ $value }}</div> <div class="col col-65">{{ $kv.Value }}</div>
</div> </div>
{{- end }} {{- end }}
</div> </div>
<div class="col col-40"> <div class="col col-35">
{{- range $group := $service.LinkGroups }} {{- range $group := $service.LinkGroups }}
<div class="link-group"> <div class="link-group">
<h3 class="label">{{ $group.Label }}</h3> <h3 class="label">{{ $group.Label }}</h3>
@ -126,6 +128,8 @@
</div> </div>
</div> </div>
</div> </div>
</div>
{{- if $i | mod 2 | eq 1 }}</div>{{- end }}
{{- end }} {{- end }}
</div> </div>
</body> </body>

@ -6,10 +6,13 @@ package catalog
import ( import (
"bytes" "bytes"
_ "embed" _ "embed"
"encoding/json"
"flag" "flag"
"html/template" "html/template"
"io"
"log" "log"
"net/http" "net/http"
"os"
"time" "time"
"github.com/mjpitz/emc/catalog/service" "github.com/mjpitz/emc/catalog/service"
@ -18,6 +21,11 @@ import (
//go:embed index.html.tpl //go:embed index.html.tpl
var catalog string var catalog string
var funcs = map[string]any{
"mod": func(mod, v int) int { return v % mod },
"eq": func(exp, act int) bool { return exp == act },
}
// Spec defines the requirements for hosting a catalog. // Spec defines the requirements for hosting a catalog.
type Spec struct { type Spec struct {
Services []service.Spec Services []service.Spec
@ -36,30 +44,44 @@ func Service(label string, options ...service.Option) Option {
// Serve provides command line functionality for running the service catalog. // Serve provides command line functionality for running the service catalog.
func Serve(options ...Option) { func Serve(options ...Option) {
addr := flag.String("bind_address", "127.0.0.1:8080", "the address the service should bind to when serving content") addr := flag.String("bind_address", "127.0.0.1:8080", "the address the service should bind to when serving content")
output := flag.String("output", "", "set to output the catalog to stdout (valid html, json)")
flag.Parse() flag.Parse()
start := time.Now() start := time.Now()
t := template.Must(template.New("catalog").Parse(catalog))
spec := Spec{} spec := Spec{}
for _, opt := range options { for _, opt := range options {
opt(&spec) opt(&spec)
} }
html := bytes.NewBuffer(nil) buffer := bytes.NewBuffer(nil)
err := t.Execute(html, spec) var err error
if err != nil {
panic(err) switch {
case output == nil || *output == "" || *output == "html":
err = template.Must(template.New("catalog").Funcs(funcs).Parse(catalog)).Execute(buffer, spec)
case *output == "json":
err = json.NewEncoder(buffer).Encode(spec)
} }
if err != nil {
log.Fatal("failed to render output", err)
}
if output != nil && *output != "" {
_, err = io.Copy(os.Stdout, bytes.NewReader(buffer.Bytes()))
if err != nil {
log.Fatal("failed to write buffer to stdout", err)
}
} else {
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
http.ServeContent(w, r, "", start, bytes.NewReader(html.Bytes())) http.ServeContent(w, r, "", start, bytes.NewReader(buffer.Bytes()))
}) })
log.Printf("serving on %s\n", *addr) log.Printf("serving on %s\n", *addr)
err = http.ListenAndServe(*addr, http.DefaultServeMux) err = http.ListenAndServe(*addr, http.DefaultServeMux)
if err != nil { if err != nil {
panic(err) log.Fatal("failed to serve content", err)
}
} }
} }

@ -11,7 +11,6 @@ import (
func New(label string, options ...Option) Spec { func New(label string, options ...Option) Spec {
spec := Spec{ spec := Spec{
Label: label, Label: label,
Metadata: make(map[string]string),
} }
for _, opt := range options { for _, opt := range options {
@ -24,13 +23,19 @@ func New(label string, options ...Option) Spec {
// Option defines an optional component of the spec. // Option defines an optional component of the spec.
type Option func(spec *Spec) type Option func(spec *Spec)
// KV defines a metadata entry.
type KV struct {
Key string
Value string
}
// Spec defines the elements needed to render a service. // Spec defines the elements needed to render a service.
type Spec struct { type Spec struct {
Label string Label string
LogoURL string LogoURL string
Description string Description string
URL string URL string
Metadata map[string]string Metadata []KV
LinkGroups []linkgroup.Spec LinkGroups []linkgroup.Spec
} }
@ -56,9 +61,19 @@ func URL(url string) Option {
} }
// Metadata allows additional metadata to be attached to a service. // Metadata allows additional metadata to be attached to a service.
func Metadata(key, value string) Option { func Metadata(kvs ...string) Option {
// ensure even number of parameters
if len(kvs)%2 > 0 {
kvs = append(kvs, "")
}
return func(spec *Spec) { return func(spec *Spec) {
spec.Metadata[key] = value for i := 0; i < len(kvs); i += 2 {
spec.Metadata = append(spec.Metadata, KV{
Key: kvs[i],
Value: kvs[i+1],
})
}
} }
} }

94
examples/catalog.go Normal file

@ -0,0 +1,94 @@
package main
import (
"github.com/mjpitz/emc/catalog"
"github.com/mjpitz/emc/catalog/linkgroup"
"github.com/mjpitz/emc/catalog/service"
)
func main() {
catalog.Serve(
catalog.Service(
"CI/CD",
service.LogoURL("https://th.bing.com/th/id/OIP.wd0WnO0MF56eQ23LR8XzRAAAAA?pid=ImgDet&rs=1"),
service.URL("https://deploy.example.com"),
service.Description("Continuous integration and delivery platform."),
service.Metadata(
"Key1", "Key1Value1",
"Key1", "Key1Value2",
"Key2", "Key2Value1",
),
service.LinkGroup(
"Dashboards",
linkgroup.Link("System", "#"),
linkgroup.Link("Queue", "#"),
),
service.LinkGroup(
"Documentation",
linkgroup.Link("Developing", "#"),
linkgroup.Link("Releasing", "#"),
linkgroup.Link("Contributing", "#"),
),
),
catalog.Service(
"Version Control",
service.LogoURL(""),
service.URL("https://code.example.com"),
service.Description("System source code."),
service.Metadata(
"Key1", "Key1Value1",
"Key1", "Key1Value2",
"Key2", "Key2Value1",
),
service.LinkGroup(
"Dashboards",
linkgroup.Link("System", "#"),
linkgroup.Link("Database", "#"),
linkgroup.Link("Queue", "#"),
),
service.LinkGroup(
"Documentation",
linkgroup.Link("Developing", "#"),
linkgroup.Link("Releasing", "#"),
),
),
catalog.Service(
"Monitoring",
service.Metadata(
"Key1", "Key1Value1",
"Key1", "Key1Value2",
"Key2", "Key2Value1",
),
service.LinkGroup(
"Dashboards",
linkgroup.Link("System", "#"),
linkgroup.Link("Database", "#"),
linkgroup.Link("Queue", "#"),
),
service.LinkGroup(
"Documentation",
linkgroup.Link("Developing", "#"),
linkgroup.Link("Releasing", "#"),
),
),
catalog.Service(
"Alerting",
service.Metadata(
"Key1", "Key1Value1",
"Key1", "Key1Value2",
"Key2", "Key2Value1",
),
service.LinkGroup(
"Dashboards",
linkgroup.Link("System", "#"),
linkgroup.Link("Database", "#"),
linkgroup.Link("Queue", "#"),
),
service.LinkGroup(
"Documentation",
linkgroup.Link("Developing", "#"),
linkgroup.Link("Releasing", "#"),
),
),
)
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 KiB

After

Width:  |  Height:  |  Size: 383 KiB