hswaw/oodviewer: init
This brings oodviewer into k0.
oodviewer started as a py2/flask script running on q3k's personal infra,
which is now being turned down.
This is a rewrite of that script into similarly mediocre Go, conforming
to the exact same mediocre JSON API and spartan HTML interface.
This also deploys it into k0 in the oodviewer-prod namespace. It's
already running, but the 'oodviewer.q3k.me' TTL has to expire before it
begins handling traffic.
Change-Id: Ieef1b0f8f0c60e6fa5dbe7701e0a07a4257f99ce
diff --git a/hswaw/README.md b/hswaw/README.md
index cb11d17..6e4ad2f 100644
--- a/hswaw/README.md
+++ b/hswaw/README.md
@@ -2,3 +2,5 @@
=============
Services and systems related to the Warsaw Hackerspace (ie. the physical place, not its cloud/ISP infrastructure).
+
+ - [oodviewer](oodviewer/), a spartan web interface to access our IRC bots' memory
diff --git a/hswaw/oodviewer/BUILD.bazel b/hswaw/oodviewer/BUILD.bazel
new file mode 100644
index 0000000..607780d
--- /dev/null
+++ b/hswaw/oodviewer/BUILD.bazel
@@ -0,0 +1,49 @@
+load("@io_bazel_rules_docker//container:container.bzl", "container_image", "container_layer", "container_push")
+load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library")
+
+go_library(
+ name = "go_default_library",
+ srcs = [
+ "app.go",
+ "main.go",
+ "views.go",
+ ],
+ importpath = "code.hackerspace.pl/hscloud/hswaw/oodviewer",
+ visibility = ["//visibility:private"],
+ deps = [
+ "//hswaw/oodviewer/templates:go_default_library",
+ "@com_github_golang_glog//:go_default_library",
+ "@com_github_lib_pq//:go_default_library",
+ ],
+)
+
+go_binary(
+ name = "oodviewer",
+ embed = [":go_default_library"],
+ visibility = ["//visibility:public"],
+)
+
+container_layer(
+ name = "layer_bin",
+ files = [
+ ":oodviewer",
+ ],
+ directory = "/hswaw/",
+)
+
+container_image(
+ name = "runtime",
+ base = "@prodimage-bionic//image",
+ layers = [
+ ":layer_bin",
+ ],
+)
+
+container_push(
+ name = "push",
+ image = ":runtime",
+ format = "Docker",
+ registry = "registry.k0.hswaw.net",
+ repository = "q3k/oodviewer",
+ tag = "{BUILD_TIMESTAMP}-{STABLE_GIT_COMMIT}",
+)
diff --git a/hswaw/oodviewer/README.md b/hswaw/oodviewer/README.md
new file mode 100644
index 0000000..b609c91
--- /dev/null
+++ b/hswaw/oodviewer/README.md
@@ -0,0 +1,28 @@
+Oodviewer
+=========
+
+Spartan web interface for the term database of our IRC bot (ood/oof/klacz).
+
+Go rewrite of a shitty old Python script that q3k wrote and hosted on his own infra. Now productionized!
+
+Building and Running
+--------------------
+
+ bazel build //hswaw/oodviewer
+ bazel run //hswaw/oodviewer -- -postgres 'postgres://ood:password@host/ood'
+
+Production deployment
+---------------------
+
+Runs on k0, connects to ood's database on boston. Serves from https://oodviewer.q3k.me/.
+
+To deploy:
+
+ bazel run //hswaw/oodviewer:push
+ # update //hswaw/oodviewer/prod.jsonnet with new image name
+ kubecfg update prod.jsonnet
+
+Development
+-----------
+
+Beg and borrow ood admins for psql credentials. Keep in mind that you will not be able to access the production database over the Internet - either develop on Boston or run a port forward over SSH.
diff --git a/hswaw/oodviewer/app.go b/hswaw/oodviewer/app.go
new file mode 100644
index 0000000..afe6bc8
--- /dev/null
+++ b/hswaw/oodviewer/app.go
@@ -0,0 +1,117 @@
+package main
+
+import (
+ "context"
+ "database/sql"
+ "fmt"
+ "time"
+
+ _ "github.com/lib/pq"
+)
+
+// app is the model of the oodviewer app.
+// The data modeled is a K/V map from string ('Term') to list of entries.
+type app struct {
+ db *sql.DB
+}
+
+// term represents a key in the K/V map of the model.
+type term struct {
+ // Name of the term, the 'K' of the K/V map.
+ Name string
+ // Count of entries (len(V) of the K/V map).
+ Entries uint64
+}
+
+// entry is an element contained under a term. A list of entries ([]entry) is
+// the 'V' of the K/V map.
+type entry struct {
+ Entry string `json:"entry"`
+ Added int64 `json:"added"`
+ Author string `json:"author"`
+}
+
+// newApp returns an instantiated app given a lib/pq postgres connection
+// string.
+func newApp(postgres string) (*app, error) {
+ db, err := sql.Open("postgres", flagPostgres)
+ if err != nil {
+ return nil, fmt.Errorf("Open: %v", err)
+ }
+
+ return &app{
+ db: db,
+ }, nil
+}
+
+// getTerms returns all terms stored in the database.
+func (a *app) getTerms(ctx context.Context) ([]term, error) {
+ rows, err := a.db.QueryContext(ctx, `
+ SELECT
+ _term._name,
+ count(_entry._text)
+ FROM
+ _term
+ LEFT JOIN _entry
+ ON
+ _entry._term_oid = _term._oid
+ GROUP BY _term._oid
+ ORDER BY _term._name
+ `)
+ if err != nil {
+ return nil, err
+ }
+ var res []term
+ for rows.Next() {
+ var name string
+ var count uint64
+ if err := rows.Scan(&name, &count); err != nil {
+ return nil, err
+ }
+ res = append(res, term{
+ Name: name,
+ Entries: count,
+ })
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return res, err
+}
+
+// getEntries returns all entries of a given term stored in the database.
+func (a *app) getEntries(ctx context.Context, name string) ([]entry, error) {
+ rows, err := a.db.QueryContext(ctx, `
+ SELECT
+ _entry._text,
+ _entry._added_at,
+ _entry._added_by
+ FROM
+ _term
+ LEFT JOIN _entry
+ ON _entry._term_oid = _term._oid
+ WHERE lower(_term._name) = lower($1)
+ ORDER BY _entry._added_at
+ `, name)
+ if err != nil {
+ return nil, err
+ }
+ var res []entry
+ for rows.Next() {
+ var text string
+ var added time.Time
+ var author string
+ if err := rows.Scan(&text, &added, &author); err != nil {
+ return nil, err
+ }
+ res = append(res, entry{
+ Entry: text,
+ Added: added.Unix(),
+ Author: author,
+ })
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return res, err
+}
diff --git a/hswaw/oodviewer/main.go b/hswaw/oodviewer/main.go
new file mode 100644
index 0000000..27fb2e0
--- /dev/null
+++ b/hswaw/oodviewer/main.go
@@ -0,0 +1,54 @@
+package main
+
+import (
+ "flag"
+ "fmt"
+ "math/rand"
+ "net/http"
+ "time"
+
+ "github.com/golang/glog"
+)
+
+var (
+ flagPostgres string
+ flagListen string
+)
+
+func init() {
+ flag.Set("logtostderr", "true")
+}
+
+func handleRobots(w http.ResponseWriter, r *http.Request) {
+ // Prevent indexing by any (honest) search engine.
+ fmt.Fprintf(w, "User-agent: *\nDisallow: /\n")
+}
+
+func main() {
+ flag.StringVar(&flagPostgres, "postgres", "", "Postgres connection string (see lib/pq docs)")
+ flag.StringVar(&flagListen, "listen", "127.0.0.1:8080", "Address to listen at for public HTTP traffic")
+ flag.Parse()
+
+ rand.Seed(time.Now().Unix())
+
+ a, err := newApp(flagPostgres)
+ if err != nil {
+ glog.Exitf("newApp: %v", err)
+ }
+
+ http.HandleFunc("/robots.txt", handleRobots)
+
+ http.HandleFunc("/terms.json", a.handleTermsJson)
+ http.HandleFunc("/term.json/", a.handleTermJson)
+ http.HandleFunc("/randomterm.json/", a.handleRandomTermJson)
+
+ http.HandleFunc("/terms", a.handleTerms)
+ http.HandleFunc("/", a.handleTerms)
+
+ http.HandleFunc("/term/", a.handleTerm)
+
+ glog.Infof("Listening at %q", flagListen)
+ if err := http.ListenAndServe(flagListen, nil); err != nil {
+ glog.Exit(err)
+ }
+}
diff --git a/hswaw/oodviewer/prod.jsonnet b/hswaw/oodviewer/prod.jsonnet
new file mode 100644
index 0000000..e06b368
--- /dev/null
+++ b/hswaw/oodviewer/prod.jsonnet
@@ -0,0 +1,85 @@
+// Production deployment of oodviewer.q3k.me.
+//
+// See README.md for more information.
+
+local kube = import "../../kube/kube.libsonnet";
+
+{
+ local top = self,
+ local cfg = self.cfg,
+ ns: kube.Namespace("oodviewer-prod"),
+
+ cfg:: {
+ dbUser: "ood",
+ dbPass: std.split(importstr "secrets/plain/postgres-pass", "\n")[0],
+ dbHost: "hackerspace.pl",
+ dbName: "ood",
+ postgresConnectionString: "postgres://%s:%s@%s/%s?sslmode=disable" % [cfg.dbUser, cfg.dbPass, cfg.dbHost, cfg.dbName],
+
+ image: "registry.k0.hswaw.net/q3k/oodviewer:315532800-937278cfb82e41dd2d2010cbd184834b3392116b",
+ domain: "oodviewer.q3k.me",
+ },
+
+ secret: top.ns.Contain(kube.Secret("oodviewer")) {
+ data_: {
+ "postgres": cfg.postgresConnectionString,
+ },
+ },
+
+ deploy: top.ns.Contain(kube.Deployment("oodviewer")) {
+ spec+: {
+ replicas: 3,
+ template+: {
+ spec+: {
+ containers_: {
+ default: kube.Container("default") {
+ image: cfg.image,
+ command: [
+ "/hswaw/oodviewer",
+ "-listen", "0.0.0.0:8080",
+ "-postgres", "$(POSTGRES)",
+ ],
+ env_: {
+ POSTGRES: kube.SecretKeyRef(top.secret, "postgres"),
+ },
+ resources: {
+ requests: { cpu: "0.01", memory: "64M" },
+ limits: { cpu: "1", memory: "256M" },
+ },
+ ports_: {
+ http: { containerPort: 8080 },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+
+ service: top.ns.Contain(kube.Service("oodviewer")) {
+ target_pod:: top.deploy.spec.template,
+ },
+
+ ingress: top.ns.Contain(kube.Ingress("oodviewer")) {
+ metadata+: {
+ annotations+: {
+ "kubernetes.io/tls-acme": "true",
+ "certmanager.k8s.io/cluster-issuer": "letsencrypt-prod",
+ "nginx.ingress.kubernetes.io/proxy-body-size": "0",
+ },
+ },
+ spec+: {
+ tls: [ { hosts: [ cfg.domain ], secretName: "oodviewer-tls" } ],
+ rules: [
+ {
+ host: cfg.domain,
+ http: {
+ paths: [
+ { path: "/", backend: top.service.name_port },
+ ],
+ },
+ },
+ ],
+ },
+ }
+}
diff --git a/hswaw/oodviewer/secrets/cipher/postgres-pass b/hswaw/oodviewer/secrets/cipher/postgres-pass
new file mode 100644
index 0000000..9342a72
--- /dev/null
+++ b/hswaw/oodviewer/secrets/cipher/postgres-pass
@@ -0,0 +1,40 @@
+-----BEGIN PGP MESSAGE-----
+
+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
+hdsApEHzDw==
+=nmyr
+-----END PGP MESSAGE-----
diff --git a/hswaw/oodviewer/secrets/plain/.gitignore b/hswaw/oodviewer/secrets/plain/.gitignore
new file mode 100644
index 0000000..72e8ffc
--- /dev/null
+++ b/hswaw/oodviewer/secrets/plain/.gitignore
@@ -0,0 +1 @@
+*
diff --git a/hswaw/oodviewer/templates/BUILD.bazel b/hswaw/oodviewer/templates/BUILD.bazel
new file mode 100644
index 0000000..be98820
--- /dev/null
+++ b/hswaw/oodviewer/templates/BUILD.bazel
@@ -0,0 +1,20 @@
+load("@io_bazel_rules_go//go:def.bzl", "go_library")
+load("@io_bazel_rules_go//extras:embed_data.bzl", "go_embed_data")
+
+go_embed_data(
+ name = "templates_data",
+ srcs = glob(["*.html"]),
+ package = "templates",
+ flatten = True,
+)
+
+go_library(
+ name = "go_default_library",
+ srcs = [
+ ":templates_data", # keep
+ ],
+ visibility = [
+ "//hswaw/oodviewer:__pkg__",
+ ],
+ importpath = "code.hackerspace.pl/hscloud/hswaw/oodviewer/templates",
+)
diff --git a/hswaw/oodviewer/templates/base.html b/hswaw/oodviewer/templates/base.html
new file mode 100644
index 0000000..7574bd9
--- /dev/null
+++ b/hswaw/oodviewer/templates/base.html
@@ -0,0 +1,8 @@
+<html>
+ <head>
+ <title>oodviewer</title>
+ </head>
+ <body>
+{{ template "body" . }}
+ </body>
+</html>
diff --git a/hswaw/oodviewer/templates/term.html b/hswaw/oodviewer/templates/term.html
new file mode 100644
index 0000000..990de29
--- /dev/null
+++ b/hswaw/oodviewer/templates/term.html
@@ -0,0 +1,8 @@
+{{ define "body" }}
+<h1>Entries for {{ .Name }}</h1>
+<ul>
+{{ range .Entries }}
+ <li>{{ .Entry }} <i>(added by {{ .Author }} on {{ .Added }})</i></li>
+{{ end }}
+</ul>
+{{ end }}
diff --git a/hswaw/oodviewer/templates/terms.html b/hswaw/oodviewer/templates/terms.html
new file mode 100644
index 0000000..c628e66
--- /dev/null
+++ b/hswaw/oodviewer/templates/terms.html
@@ -0,0 +1,8 @@
+{{ define "body" }}
+<h1>Available terms:</h1>
+<ul>
+{{ range .Terms }}
+ <li><a href="{{ .URL }}">{{ .Name }}</a> ({{ .Count }} entries)</li>
+{{ end }}
+</ul>
+{{ end }}
diff --git a/hswaw/oodviewer/views.go b/hswaw/oodviewer/views.go
new file mode 100644
index 0000000..5ed4b5e
--- /dev/null
+++ b/hswaw/oodviewer/views.go
@@ -0,0 +1,138 @@
+package main
+
+import (
+ "encoding/json"
+ "fmt"
+ "html/template"
+ "math/rand"
+ "net/http"
+ "net/url"
+ "strings"
+ "time"
+
+ "github.com/golang/glog"
+
+ "code.hackerspace.pl/hscloud/hswaw/oodviewer/templates"
+)
+
+var (
+ tplBase = template.Must(template.New("base").Parse(string(templates.Data["base.html"])))
+ tplTerm = template.Must(template.Must(tplBase.Clone()).Parse(string(templates.Data["term.html"])))
+ tplTerms = template.Must(template.Must(tplBase.Clone()).Parse(string(templates.Data["terms.html"])))
+)
+
+// handleTermsJson returns a JSON list of all terms.
+func (a *app) handleTermsJson(w http.ResponseWriter, r *http.Request) {
+ terms, err := a.getTerms(r.Context())
+ if err != nil {
+ glog.Errorf("getTerms: %v", err)
+ w.WriteHeader(500)
+ fmt.Fprintf(w, "internal error")
+ return
+ }
+ // Target API from old oodviewer, even if it's terrible.
+ var res [][]interface{}
+ for _, term := range terms {
+ res = append(res, []interface{}{
+ term.Name, term.Entries,
+ })
+ }
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(res)
+}
+
+// handleTerms renders a HTML page containing all terms.
+func (a *app) handleTerms(w http.ResponseWriter, r *http.Request) {
+ terms, err := a.getTerms(r.Context())
+ if err != nil {
+ glog.Errorf("getTerms: %v", err)
+ w.WriteHeader(500)
+ fmt.Fprintf(w, "internal error")
+ return
+ }
+
+ termsData := make([]struct {
+ URL string
+ Name string
+ Count uint64
+ }, len(terms))
+
+ for i, term := range terms {
+ termsData[i].URL = url.QueryEscape(term.Name)
+ termsData[i].Name = term.Name
+ termsData[i].Count = term.Entries
+ }
+
+ tplTerms.Execute(w, map[string]interface{}{
+ "Terms": termsData,
+ })
+}
+
+// handleTermJson returns a JSON list of all entries contained within a term.
+func (a *app) handleTermJson(w http.ResponseWriter, r *http.Request) {
+ parts := strings.Split(r.URL.Path, "/")
+ name := parts[len(parts)-1]
+
+ entries, err := a.getEntries(r.Context(), name)
+ if err != nil {
+ glog.Errorf("getEntries: %v", err)
+ w.WriteHeader(500)
+ fmt.Fprintf(w, "internal error")
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(entries)
+}
+
+// handleRandomTermJson returns a JSON serialized randomly chosen entry from a
+// given term.
+func (a *app) handleRandomTermJson(w http.ResponseWriter, r *http.Request) {
+ parts := strings.Split(r.URL.Path, "/")
+ name := parts[len(parts)-1]
+
+ entries, err := a.getEntries(r.Context(), name)
+ if err != nil {
+ glog.Errorf("getEntries: %v", err)
+ w.WriteHeader(500)
+ fmt.Fprintf(w, "internal error")
+ return
+ }
+ if len(entries) < 1 {
+ w.WriteHeader(404)
+ fmt.Fprintf(w, "no such entry")
+ return
+ }
+ entry := entries[rand.Intn(len(entries))]
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(entry)
+}
+
+// handleTerm renders an HTML page of all entries contained within a term.
+func (a *app) handleTerm(w http.ResponseWriter, r *http.Request) {
+ parts := strings.Split(r.URL.Path, "/")
+ name := parts[len(parts)-1]
+
+ entries, err := a.getEntries(r.Context(), name)
+ if err != nil {
+ glog.Errorf("getEntries: %v", err)
+ w.WriteHeader(500)
+ fmt.Fprintf(w, "internal error")
+ return
+ }
+
+ entriesData := make([]struct {
+ Entry string
+ Author string
+ Added string
+ }, len(entries))
+ for i, entry := range entries {
+ entriesData[i].Entry = entry.Entry
+ entriesData[i].Author = entry.Author
+ entriesData[i].Added = time.Unix(entry.Added, 0).String()
+ }
+
+ tplTerm.Execute(w, map[string]interface{}{
+ "Name": name,
+ "Entries": entriesData,
+ })
+}