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,
+	})
+}