personal/q3k: door^Wship stuck

Change-Id: I189fc13971d46790634804c3fa1b54e2c4788273
diff --git a/personal/q3k/mirko.jsonnet b/personal/q3k/mirko.jsonnet
new file mode 100644
index 0000000..db7750e
--- /dev/null
+++ b/personal/q3k/mirko.jsonnet
@@ -0,0 +1,49 @@
+local mirko = import "../../kube/mirko.libsonnet";
+
+{
+    local top = self,
+    shipstuck:: {
+        cfg:: {
+            image: "registry.k0.hswaw.net/q3k/shipstuck:315532800-a7282b5aa2952e5eb66a1c3ecf7cdafef8335aba",
+            domain: error "domain must be set",
+        },
+        component(cfg, env): mirko.Component(env, "shipstuck") {
+            local shipstuck = self,
+            cfg+: {
+                image: cfg.image,
+                container: shipstuck.GoContainer("main", "/personal/q3k/shipstuck") {
+                    command+: [
+                        "-public_address", "0.0.0.0:8080",
+                    ],
+                },
+                ports+: {
+                    publicHTTP: {
+                        public: {
+                            port: 8080,
+                            dns: cfg.domain,
+                        },
+                    },
+                },
+            },
+        },
+    },
+
+    env(name):: mirko.Environment(name) {
+        local env = self,
+        local cfg = self.cfg,
+        cfg+: {
+            shipstuck: top.shipstuck.cfg,
+        },
+        components: {
+            shipstuck: top.shipstuck.component(cfg.shipstuck, env),
+        },
+    },
+
+    prod: top.env("personal-q3k") {
+        cfg+: {
+            shipstuck+: {
+                domain: "shipstuck.q3k.org",
+            },
+        },
+    },
+}
diff --git a/personal/q3k/shipstuck/BUILD.bazel b/personal/q3k/shipstuck/BUILD.bazel
new file mode 100644
index 0000000..08e77f6
--- /dev/null
+++ b/personal/q3k/shipstuck/BUILD.bazel
@@ -0,0 +1,47 @@
+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 = ["main.go"],
+    importpath = "code.hackerspace.pl/hscloud/personal/q3k/shipstuck",
+    visibility = ["//visibility:private"],
+    deps = [
+        "//go/mirko:go_default_library",
+        "//personal/q3k/shipstuck/proto:go_default_library",
+        "@com_github_golang_glog//:go_default_library",
+        "@com_github_grpc_ecosystem_grpc_gateway//runtime:go_default_library",
+    ],
+)
+
+go_binary(
+    name = "shipstuck",
+    embed = [":go_default_library"],
+    visibility = ["//visibility:public"],
+)
+
+container_layer(
+    name = "layer_bin",
+    files = [
+        ":shipstuck",
+    ],
+    directory = "/personal/q3k/",
+)
+
+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/shipstuck",
+    tag = "{BUILD_TIMESTAMP}-{STABLE_GIT_COMMIT}",
+)
+
diff --git a/personal/q3k/shipstuck/main.go b/personal/q3k/shipstuck/main.go
new file mode 100644
index 0000000..0224c3d
--- /dev/null
+++ b/personal/q3k/shipstuck/main.go
@@ -0,0 +1,191 @@
+package main
+
+import (
+	"context"
+	"encoding/json"
+	"flag"
+	"fmt"
+	"net/http"
+	"sync"
+	"time"
+
+	"code.hackerspace.pl/hscloud/go/mirko"
+	"github.com/golang/glog"
+	"github.com/grpc-ecosystem/grpc-gateway/runtime"
+
+	pb "code.hackerspace.pl/hscloud/personal/q3k/shipstuck/proto"
+)
+
+type vessel struct {
+	// No idea what these fields are, but they seem to be related to
+	// latitude/longitude. Use these to detect the stuckness of the ship.
+	GT int64 `json:"gt"`
+	DW int64 `json:"dw"`
+}
+
+// get retrieves the current status of the ship - returns true if stack, false
+// otherwise.
+func get(ctx context.Context) (bool, error) {
+	// Sorry vesselfinder, if you made it easy to set up an account I would
+	// gladly pay for the API instead of doing this. Limiting requests to once
+	// every 15 minutes though, that seems fair enough.
+	req, err := http.NewRequestWithContext(ctx, "GET", "https://www.vesselfinder.com/api/pub/click/353136000", nil)
+	if err != nil {
+		return false, fmt.Errorf("NewRequestWithContext: %w", err)
+	}
+	req.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64; rv:86.0) Gecko/20100101 Firefox/86.0")
+
+	res, err := http.DefaultClient.Do(req)
+	if err != nil {
+		return false, fmt.Errorf("Do: %w", err)
+	}
+
+	defer res.Body.Close()
+
+	v := &vessel{}
+	err = json.NewDecoder(res.Body).Decode(v)
+	if err != nil {
+		return false, fmt.Errorf("Decode: %w", err)
+	}
+
+	if v.GT == 219079 && v.DW == 199489 {
+		return true, nil
+	} else {
+		glog.Infof("Freed! %+v", v)
+		return false, nil
+	}
+}
+
+type shipState string
+
+const (
+	shipStateUnknown shipState = "UNKNOWN"
+	shipStateStuck   shipState = "STUCK"
+	shipStateFreed   shipState = "FREED"
+)
+
+type service struct {
+	lastStateMu   sync.RWMutex
+	lastState     shipState
+	lastStateTime time.Time
+}
+
+func (s *service) worker(ctx context.Context) {
+	update := func() {
+		state := shipStateUnknown
+		// shitty back off, good enough.
+		retries := 10
+		for {
+			stuck, err := get(ctx)
+			if err != nil {
+				glog.Warningf("get: %v", err)
+				if retries > 0 {
+					time.Sleep(60 * time.Second)
+					retries -= 1
+				} else {
+					glog.Errorf("giving up on get")
+					break
+				}
+			} else {
+				if stuck {
+					state = shipStateStuck
+				} else {
+					state = shipStateFreed
+				}
+				break
+			}
+		}
+
+		glog.Infof("New state: %v", state)
+		s.lastStateMu.Lock()
+		s.lastState = state
+		s.lastStateTime = time.Now()
+		s.lastStateMu.Unlock()
+	}
+
+	update()
+	ticker := time.NewTicker(15 * 60 * time.Second)
+	for {
+		select {
+		case <-ctx.Done():
+			return
+		case <-ticker.C:
+			update()
+		}
+	}
+}
+
+func timeMust(t time.Time, err error) time.Time {
+	if err != nil {
+		panic(err)
+	}
+	return t
+}
+
+var (
+	timeStuck = timeMust(time.Parse(
+		"At 15:04 Eastern European Time (MST) on 2 January 2006",
+		"At 07:40 Eastern European Time (UTC) on 23 March 2021",
+	))
+)
+
+func (s *service) Status(ctx context.Context, req *pb.StatusRequest) (*pb.StatusResponse, error) {
+	s.lastStateMu.RLock()
+	state := s.lastState
+	lastChecked := s.lastStateTime
+	s.lastStateMu.RUnlock()
+
+	res := &pb.StatusResponse{
+		LastChecked: lastChecked.UnixNano(),
+	}
+	switch state {
+	case shipStateUnknown:
+		res.Current = pb.StatusResponse_STUCKNESS_UNKNOWN
+	case shipStateStuck:
+		res.Current = pb.StatusResponse_STUCKNESS_STUCK
+		res.Elapsed = time.Since(timeStuck).Nanoseconds()
+	case shipStateFreed:
+		res.Current = pb.StatusResponse_STUCKNESS_FREE
+	}
+
+	return res, nil
+}
+
+var (
+	flagPublicAddress string
+)
+
+func main() {
+	flag.StringVar(&flagPublicAddress, "public_address", "127.0.0.1:8080", "Public HTTP/JSON listen address")
+	flag.Parse()
+	m := mirko.New()
+	if err := m.Listen(); err != nil {
+		glog.Exitf("Listen(): %v", err)
+	}
+
+	s := &service{}
+	pb.RegisterShipStuckServer(m.GRPC(), s)
+
+	publicMux := runtime.NewServeMux()
+	publicSrv := http.Server{
+		Addr:    flagPublicAddress,
+		Handler: publicMux,
+	}
+	go func() {
+		glog.Infof("REST listening on %s", flagPublicAddress)
+		if err := publicSrv.ListenAndServe(); err != nil {
+			glog.Exitf("public ListenAndServe: %v", err)
+		}
+	}()
+	if err := pb.RegisterShipStuckHandlerServer(m.Context(), publicMux, s); err != nil {
+		glog.Exitf("RegisterShipStuckHandlerSerever: %v", err)
+	}
+
+	go s.worker(m.Context())
+
+	if err := m.Serve(); err != nil {
+		glog.Exitf("Serve(): %v", err)
+	}
+
+	<-m.Done()
+}
diff --git a/personal/q3k/shipstuck/proto/BUILD.bazel b/personal/q3k/shipstuck/proto/BUILD.bazel
new file mode 100644
index 0000000..c1fba40
--- /dev/null
+++ b/personal/q3k/shipstuck/proto/BUILD.bazel
@@ -0,0 +1,31 @@
+load("@rules_proto//proto:defs.bzl", "proto_library")
+load("@io_bazel_rules_go//go:def.bzl", "go_library")
+load("@io_bazel_rules_go//proto:def.bzl", "go_proto_library")
+
+proto_library(
+    name = "proto_proto",
+    srcs = ["shipstuck.proto"],
+    visibility = ["//visibility:public"],
+    deps = ["@go_googleapis//google/api:annotations_proto"],
+)
+
+go_proto_library(
+    name = "proto_go_proto",
+    compilers = [
+        "@com_github_grpc_ecosystem_grpc_gateway//protoc-gen-grpc-gateway:go_gen_grpc_gateway",  # keep
+        "@io_bazel_rules_go//proto:go_grpc",
+    ],
+    importpath = "code.hackerspace.pl/hscloud/personal/q3k/shipstuck/proto",
+    proto = ":proto_proto",
+    visibility = ["//visibility:public"],
+    deps = [
+        "@go_googleapis//google/api:annotations_go_proto",
+    ],
+)
+
+go_library(
+    name = "go_default_library",
+    embed = [":proto_go_proto"],
+    importpath = "code.hackerspace.pl/hscloud/personal/q3k/shipstuck/proto",
+    visibility = ["//visibility:public"],
+)
diff --git a/personal/q3k/shipstuck/proto/shipstuck.proto b/personal/q3k/shipstuck/proto/shipstuck.proto
new file mode 100644
index 0000000..f0836e1
--- /dev/null
+++ b/personal/q3k/shipstuck/proto/shipstuck.proto
@@ -0,0 +1,30 @@
+syntax = "proto3";
+package proto;
+option go_package = "code.hackerspace.pl/hscloud/personal/q3k/shipstuck/proto";
+
+import "google/api/annotations.proto";
+
+service ShipStuck {
+    rpc Status(StatusRequest) returns (StatusResponse) {
+        option (google.api.http) = {
+          get: "/v1/shipstuck/status"
+        };
+    };
+}
+
+message StatusRequest {
+}
+
+message StatusResponse {
+    // Timestamp (nanos from epoch) of last check.
+    int64 last_checked = 1;
+    enum Stuckness {
+        STUCKNESS_INVALID = 0;
+        STUCKNESS_STUCK = 1;
+        STUCKNESS_FREE = 2;
+        STUCKNESS_UNKNOWN = 3;
+    };
+    Stuckness current = 2;
+    // If STUCK, how many nanoseconds have elapsed since the whoopsie?
+    int64 elapsed = 3;
+}