diff --git a/hswaw/README.md b/hswaw/README.md index cb11d17f..6e4ad2f3 100644 --- a/hswaw/README.md +++ b/hswaw/README.md @@ -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 diff --git a/hswaw/oodviewer/BUILD.bazel b/hswaw/oodviewer/BUILD.bazel new file mode 100644 index 00000000..607780d4 --- /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 00000000..b609c91a --- /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 00000000..afe6bc85 --- /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 00000000..27fb2e04 --- /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 00000000..e06b368c --- /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 00000000..9342a72c --- /dev/null +++ b/hswaw/oodviewer/secrets/cipher/postgres-pass @@ -0,0 +1,40 @@ +-----BEGIN PGP MESSAGE----- + +hQEMAzhuiT4RC8VbAQf/QF/42LLn1ZXWjnZdBKkjcDBuAn6UqiQwGA9+MomnlLEe +17Ut6H2yshcPuF8SfqISmuixTb/wzhsh8gXdiz7DYT8+kaUbJ44WouzZ0kp4Zdcw +7lvJT45CxorkHrb7u3fK1OOSr3wvEtGaGOJnDhHId2d20gGNRyGiM/O4Drx0sy/I +5HCNfquQbfmf0wsnpGlaEBTARP6vQTeZcAUMBIZdLsCG50E1OHNYbWnogUvKO4SS +e0VrOdcbJqgS/kecO7WIZGNw/mFrvMGpwUeAUu0UlPZVrtXrhUpJS/DpeV/ovrIH +W6vw5CKSJ9nxx+WNa4ii+nX6VihuA9Luzq1nvrfFeoUBDANcG2tp6fXqvgEIAJtJ +aEOCEJaXlXlD6Bmm6u6NEl7hPtQVQ6nylGMy28UT/CWBmiH7Om2ZVPXF+bc0IOos +7oelzj36QemjMqBPIQRUSy9ooitmBS0HFm42yfihggUzSDuIKzW4+q/3eq4Ny9G+ +8dYQAgOYwinHDxDNVc1CQKLnlhTFg1noXc+jP+V42PxFZsuz/5R3nqrsdyYoqPRE +FkvrCNT9vyyNTynaIhbYyFhKHn9ajfIZg2cHt/kb/gftEnpEoU/kfTmqOKeg5/bN +iVKWVmRVIHBlq3ERjO0E/4kLEXUvtl4gWokgbmca1b+YlySyQR4TcTzlOVZwXl9P +PDlb3paueVy9nywxe5CFAgwDodoT8VqRl4UBD/oD1VtbuoJRAbS6mKhyFxttGd/f +UpvrE6iZcMZxwqg6TQqgR/cEnE2FH8KXFqOf/xr3QTFbzBIn9a2WTAvDV6IOgxGj +V+wsV8fikdlTK0Qmpj5JUa79SZoGJrWUb1iQGYQPjdNrWyPoT8Vttfs/Mgqk8Q/q +jUhL0w7dVWlomrwnvSM4l4+jRHSIzyLViY4MTPLSHj1fx1n2TYiFccUnPsWMnU3C +mRTjbcRFQHMK5xaXA8DH9tMypXIQp3qoWZeIvikakJ3JMYsELTX7vMPFie21m04j +oq5u/1tj/f1c5W5cjmzOk9uQSgl5mRolzCjm0MhRnImEjBNGCfws1A3QlJHFG2Ep +Rh0uZrmHxDOSGBdDU4dadsO0gq/RVah/s3pKbf3kfEXDfK52lkgsBGC8quDbr8tx +qWybmOVWBGSSi0j1wJIFnGSeeOao99PPzkqgamgUsTKe65ZO/MQqSm9MwQehXgnx +fArvG8yfPKRtanJsQUrOGn3A0RXa7YE24XpwQfDb/FL9kddqyi/PcKGt+3rc/ZyQ +nLAWU7XK0r4YEsqCWB73PfvpKHar30f80kw6lJg6aUMe8BuAR0UmSgsSnfE458XJ +HHH3o0r/I0pf/Q8KmT1cPEbtkGGlwDG+qyJwBv/1+DZT8/t8l63z/Dn6mk1P/by+ +6EEj+IDeiTQWFuu8SYUCDAPiA8lOXOuz7wEP/0D2/qcDAcmtDQzxK1Xm5bhTwBzA +S314PU93e+nMAADe9DhhGn7AER7RCgqF9FcMJu80hZLfkQ6NXAt94fBwRpEMuMNZ +H5H+oQ0JskkA8bZi97Qn0R2jrS5rL0jRtyX26JE+QlqyalAIUB2WKDZNJpdhPKIZ +I5RTx8l9hoZp8lF/bDxpzcKLOETv0iU7J9QfzalV69/Mfj9crq8ZLtryB2vVhRzU +kWjO00I/ObCZYaskUiICtlQI2WEfADyZQt6/ZzerZqPjihfqwvSBiK3UbJVlRRg9 +pHI9JuQoYYGrUZ3OhR2FjxCcB2TsBKGYCrhpPGxwfyLfrr7K866Cq7cPzwe5HwWY +rRcNsywD9WcDotdkC/88JXbtlxnrmoMGxYVFIBUHRfBCOyzSAiDYVT1obaPVlboF +6bKA+TRr5MmGkd139PvyNEmlUrg/hmCD6gYJc/T4xEFykE+Su6ozxjvfBPupijBV +5jFdEgo2PojmO1EflrMsGBUnL9cly0onf4C40xjAVMGfpvbkJ7J/Fw5THx82j/wU +mH3n4AEPTf6LJqIrKWN+Z38VjRHPS1UAszzVt2XGJu7+xiPdIOAYq60gwfcmnpZR +m9qIOIPbKGYuHFP1+9i1avbYYConMisnz37LzsnUhiNYJFKkQcC3sqxffgcru8zB +01Le9nMKx8mLfMfz0lkBTSDX4AmMNnP5/1jiR5Yyr2xaajWtAg3fF/dkLWlWhKp9 +5k6iZCQ/IbnuGA2y7ipANuuERo1W00X+VwwMKO4MAoTkd0zh06jZEmCZoOwPR+X2 +hdsApEHzDw== +=nmyr +-----END PGP MESSAGE----- diff --git a/hswaw/oodviewer/secrets/plain/.gitignore b/hswaw/oodviewer/secrets/plain/.gitignore new file mode 100644 index 00000000..72e8ffc0 --- /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 00000000..be98820f --- /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 00000000..7574bd9f --- /dev/null +++ b/hswaw/oodviewer/templates/base.html @@ -0,0 +1,8 @@ + + + oodviewer + + +{{ template "body" . }} + + diff --git a/hswaw/oodviewer/templates/term.html b/hswaw/oodviewer/templates/term.html new file mode 100644 index 00000000..990de29b --- /dev/null +++ b/hswaw/oodviewer/templates/term.html @@ -0,0 +1,8 @@ +{{ define "body" }} +

Entries for {{ .Name }}

+ +{{ end }} diff --git a/hswaw/oodviewer/templates/terms.html b/hswaw/oodviewer/templates/terms.html new file mode 100644 index 00000000..c628e663 --- /dev/null +++ b/hswaw/oodviewer/templates/terms.html @@ -0,0 +1,8 @@ +{{ define "body" }} +

Available terms:

+ +{{ end }} diff --git a/hswaw/oodviewer/views.go b/hswaw/oodviewer/views.go new file mode 100644 index 00000000..5ed4b5ea --- /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, + }) +}