1
0
Fork 0

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
master
q3k 2021-03-07 14:29:40 +00:00
parent be3adb9e09
commit 5e695e8f9b
13 changed files with 558 additions and 0 deletions

View File

@ -2,3 +2,5 @@ hscloud/hswaw
=============
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

View File

@ -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}",
)

28
hswaw/oodviewer/README.md Normal file
View File

@ -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.

117
hswaw/oodviewer/app.go Normal file
View File

@ -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
}

54
hswaw/oodviewer/main.go Normal file
View File

@ -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)
}
}

View File

@ -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 },
],
},
},
],
},
}
}

View File

@ -0,0 +1,40 @@
-----BEGIN PGP MESSAGE-----
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
hdsApEHzDw==
=nmyr
-----END PGP MESSAGE-----

View File

@ -0,0 +1 @@
*

View File

@ -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",
)

View File

@ -0,0 +1,8 @@
<html>
<head>
<title>oodviewer</title>
</head>
<body>
{{ template "body" . }}
</body>
</html>

View File

@ -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 }}

View File

@ -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 }}

138
hswaw/oodviewer/views.go Normal file
View File

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