tools/gostatic: init

This adds Bazel/hscloud integration to gostatic, via gostatic_tarball.

A sample is provided in //tools/gostatic/example, it can be built using:

    bazel build //tools/gostatic/example

The resulting tarball can then be extracted and viewed in a web
browser.

Change-Id: Idf8d4a8e0ee3a5ae07f7449a25909478c2d8b105
diff --git a/tools/gostatic/BUILD b/tools/gostatic/BUILD
new file mode 100644
index 0000000..13a250d
--- /dev/null
+++ b/tools/gostatic/BUILD
@@ -0,0 +1,8 @@
+load("//bzl:rules.bzl", "copy_go_binary")
+
+copy_go_binary(
+    name = "gostatic",
+    src = "@com_github_piranha_gostatic//:gostatic",
+    visibility = ["//visibility:public"],
+)
+
diff --git a/tools/gostatic/README.md b/tools/gostatic/README.md
new file mode 100644
index 0000000..6c0f17c
--- /dev/null
+++ b/tools/gostatic/README.md
@@ -0,0 +1,43 @@
+gostatic site generator
+=======================
+
+This implements support for [gostatic](https://github.com/piranha/gostatic), a static site generator, inside hscloud.
+
+Creating a gostatic site
+------------------------
+
+To get started, copy over the skeleton from //tools/gostatic/example into a new directory.
+
+    mkdir -p personal/foo
+    cp -rv tools/gostatic/example personal/foo/mysite
+
+You can also build your own `gostatic_tarball` from scratch if you are familiar enough with gostatic.
+
+You can then then build a tarball of your site by running:
+
+    bazel build //personal/foo/mysite
+
+Your site will be built and tarred up into `bazel-bin/personal/foo/mysite/mysite.tar`. You can then use this to populate a container in `docker_rules`.
+
+TODO(q3k): add a target that starts up a simple web server for testing the rendered site.
+
+Configuring a gostatic site
+---------------------------
+
+Configuration is done via the `gostatic_tarball` rule. This mostly generates an upstream gostatic [configuration file](https://github.com/piranha/gostatic#configuration) - please refer to that file for more information.
+
+| Field | Description | Example |
+|-------|-------------|---------|
+| `templates` | List of template sources/targets. This is used to populate the TEMPLATES config option. | `[ ":site.tmpl" ]` |
+| `source_dir` | BUILDfile-relative source directory containing site sources. This is used to populate the SOURCE config option. All files given in `srcs` must be contained within this directory. | `"src"` |
+| `srcs` | List of template sources/targets. This is what will be available to gostatic during compilation. | `[ "src/blog/first.md" ]` |
+| `extra_config` | Rest of the gostatic config, ie. rules. | |
+
+Running gostatic-the-tool
+-------------------------
+
+If you want to run plain gostatic for some odd reason, it's available under:
+
+    bazel run //tools/gostatic
+
+TODO(q3k): allow running this against a `gostatic_tarball`'s config.
diff --git a/tools/gostatic/example/BUILD b/tools/gostatic/example/BUILD
new file mode 100644
index 0000000..cb54fec
--- /dev/null
+++ b/tools/gostatic/example/BUILD
@@ -0,0 +1,45 @@
+load("//tools/gostatic:rules.bzl", "gostatic_tarball")
+
+gostatic_tarball(
+    name = "example",
+    templates = [
+        "site.tmpl",
+    ],
+    source_dir = "src",
+    extra_config = """
+TITLE = Example Site
+URL = https://example.com
+AUTHOR = Your Name
+
+blog/*.md:
+	config
+	ext .html
+	directorify
+	tags tags/*.tag
+	markdown
+	template post
+	template page
+
+*.tag: blog/*.md
+	ext .html
+	directorify
+	template tag
+	markdown
+	template page
+
+blog.atom: blog/*.md
+	inner-template
+
+index.html: blog/*.md
+	config
+	inner-template
+	template page
+
+    """,
+    srcs = [
+        "src/blog/first.md",
+        "src/static/style.css",
+        "src/blog.atom",
+        "src/index.html",
+    ],
+)
diff --git a/tools/gostatic/example/site.tmpl b/tools/gostatic/example/site.tmpl
new file mode 100644
index 0000000..8878ab2
--- /dev/null
+++ b/tools/gostatic/example/site.tmpl
@@ -0,0 +1,50 @@
+{{ define "header" }}<!doctype html>
+<html>
+<head>
+  <meta charset="utf-8">
+  <meta name="author" content="{{ html .Site.Other.Author }}">
+  <link rel="alternate" type="application/atom+xml" title="{{ html .Site.Other.Title }} feed" href="{{ .Rel "blog.atom" }}">
+  <title>{{ .Site.Other.Title }}{{ if .Title }}: {{ .Title }}{{ end }}</title>
+  <link href="//netdna.bootstrapcdn.com/bootstrap/3.0.0/css/bootstrap.min.css" rel="stylesheet">
+  <link rel="stylesheet" type="text/css" href="{{ .Rel "static/style.css" }}">
+</head>
+<body>
+{{ end }}
+
+{{ define "footer" }}
+</body>
+</html>
+{{ end }}
+
+{{define "date"}}
+<time datetime="{{ .Format "2006-01-02T15:04:05Z07:00" }}">
+  {{ .Format "2006, January 02" }}
+</time>
+{{end}}
+
+{{ define "page" }}{{ template "header" . }}
+  {{ .Content }}
+{{ template "footer" . }}{{ end }}
+
+{{ define "post" }}
+<article>
+  <header>
+    <h1>{{ .Title }}</h1>
+    <div class="info">
+      {{ template "date" .Date }} &mdash;
+      {{ range $i, $t := .Tags }}{{if $i}},{{end}}
+      <a href="/tags/{{ $t }}/">{{ $t }}</a>{{ end }}
+    </div>
+  </header>
+  <section>
+  {{ .Content }}
+  </section>
+</article>
+{{ end }}
+
+{{define "tag"}}
+# Pages tagged with {{ .Title }}
+{{ range .Site.Pages.WithTag .Title }}
+- [{{ .Title }}](../../{{ .Url }})
+{{ end }}
+{{ end }}
diff --git a/tools/gostatic/example/src/blog.atom b/tools/gostatic/example/src/blog.atom
new file mode 100644
index 0000000..3af3d96
--- /dev/null
+++ b/tools/gostatic/example/src/blog.atom
@@ -0,0 +1,32 @@
+<?xml version="1.0" encoding="utf-8"?>
+<feed xmlns="http://www.w3.org/2005/Atom" xmlns:thr="http://purl.org/syndication/thread/1.0">
+  <id>{{ .Site.Other.Url }}</id>
+  <title>{{ .Site.Other.Title }}</title>
+  {{ with .Site.Pages.Children "blog/" }}
+  <updated>{{ .First.Date.Format "2006-01-02T15:04:05Z07:00" }}</updated>
+  {{ end }}
+  <author><name>{{ .Site.Other.Author }}</name></author>
+  <link href="{{ .Site.Other.Url }}" rel="alternate"></link>
+  <generator uri="https://github.com/piranha/gostatic">gostatic</generator>
+
+{{ with .Site.Pages.Children "blog/" }}
+{{ range .Slice 0 5 }}
+<entry>
+  <id>{{ .Url }}</id>
+  <author><name>{{ or .Other.Author .Site.Other.Author }}</name></author>
+  <title type="html">{{ html .Title }}</title>
+  <published>{{ .Date.Format "2006-01-02T15:04:05Z07:00" }}</published>
+  {{ range .Tags }}
+  <category term="{{ . }}"></category>
+  {{ end }}
+  <link href="{{ .Site.Other.Url }}/{{ .Url }}" rel="alternate"></link>
+  <content type="html">
+    {{/* .Process runs here in case only feed changed */}}
+    {{ with cut "<section>" "</section>" .Process.Content }}
+      {{ html . }}
+    {{ end }}
+  </content>
+</entry>
+{{ end }}
+{{ end }}
+</feed>
diff --git a/tools/gostatic/example/src/blog/first.md b/tools/gostatic/example/src/blog/first.md
new file mode 100644
index 0000000..64812c6
--- /dev/null
+++ b/tools/gostatic/example/src/blog/first.md
@@ -0,0 +1,5 @@
+title: First Post
+date: 2012-12-12
+tags: blog
+----
+My first post with [gostatic](https://github.com/piranha/gostatic).
diff --git a/tools/gostatic/example/src/index.html b/tools/gostatic/example/src/index.html
new file mode 100644
index 0000000..56853dd
--- /dev/null
+++ b/tools/gostatic/example/src/index.html
@@ -0,0 +1,9 @@
+title: Main Page
+----
+<ul class="post-list">
+{{ range .Site.Pages.Children "blog/" }}
+  <li>
+    {{ template "date" .Date }} - <a href="{{ $.Rel .Url }}">{{ .Title }}</a>
+  </li>
+{{ end }}
+</ul>
diff --git a/tools/gostatic/example/src/static/style.css b/tools/gostatic/example/src/static/style.css
new file mode 100644
index 0000000..9f89f00
--- /dev/null
+++ b/tools/gostatic/example/src/static/style.css
@@ -0,0 +1 @@
+/* put your style rules here */
diff --git a/tools/gostatic/rules.bzl b/tools/gostatic/rules.bzl
new file mode 100644
index 0000000..e0326f9
--- /dev/null
+++ b/tools/gostatic/rules.bzl
@@ -0,0 +1,84 @@
+def _gostatic_tarball_impl(ctx):
+    out = ctx.actions.declare_directory("out")
+    tarball_name = ctx.attr.name + ".tar"
+    tarball = ctx.actions.declare_file(tarball_name)
+
+    config_name = ctx.attr.name + ".config"
+    config = ctx.actions.declare_file(config_name)
+
+    # Build path to root of sources, based on source_dir
+    # and location of the instantiating BUILDfile
+    # (source_dir is defined as relative to the BUILD file).
+    source_dir = '/'.join(ctx.build_file_path.split('/')[:-1]) + '/' + ctx.attr.source_dir
+
+    # Relative path to go up from generated config to build
+    # root. This is because gostatic is magical and really
+    # wants the config to be alongside the source.
+    up = "../" * (config.path.count("/"))
+    templates = " ".join([up + f.path for f in ctx.files.templates])
+    config_lines = [
+        "OUTPUT = {}".format(up + out.path),
+        "TEMPLATES = {}".format(templates),
+        "SOURCE = {}".format(up + source_dir),
+    ]
+    config_content = "\n".join(config_lines)
+    config_content += ctx.attr.extra_config
+
+    ctx.actions.write(config, config_content)
+
+    ctx.actions.run(
+        outputs = [out],
+        inputs = [config] + ctx.files.templates + ctx.files.srcs,
+        executable = ctx.file._gostatic,
+        arguments = [config.path],
+    )
+    ctx.actions.run(
+        outputs = [tarball],
+        inputs = [out],
+        executable = ctx.file._tarify,
+        arguments = [
+            "-site", out.path,
+            "-tarball", tarball.path,
+        ],
+    )
+
+    return [DefaultInfo(files=depset([tarball]))]
+
+gostatic_tarball = rule(
+    implementation = _gostatic_tarball_impl,
+    attrs = {
+        "extra_config": attr.string(
+            mandatory = True,
+            doc = """
+                Gostatic configuration (rules, etc). Do not specify OUTPUT, TEMPLATES
+                or SOURCES - these are automatically generated.
+            """,
+        ),
+        "source_dir": attr.string(
+            mandatory = True,
+            doc = "Root of site sources. Relative to BUILDfile.",
+        ),
+        "srcs": attr.label_list(
+            allow_files = True,
+            doc = "Site sources, all must be contained within source_dir"
+        ),
+        "templates": attr.label_list(
+            allow_files = True,
+            doc = "Templates to use (passed to TEMPLATES in gostatic config).",
+        ),
+        "_gostatic": attr.label(
+            default = Label("//tools/gostatic"),
+            allow_single_file = True,
+            executable = True,
+            cfg = "exec",
+            doc = "Path to gostatic binary.",
+        ),
+        "_tarify": attr.label(
+            default = Label("//tools/gostatic/tarify"),
+            allow_single_file = True,
+            executable = True,
+            cfg = "exec",
+            doc = "Path to tarify binary.",
+        ),
+    },
+)
diff --git a/tools/gostatic/tarify/BUILD.bazel b/tools/gostatic/tarify/BUILD.bazel
new file mode 100644
index 0000000..7bc841b
--- /dev/null
+++ b/tools/gostatic/tarify/BUILD.bazel
@@ -0,0 +1,15 @@
+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/tools/gostatic/tarify",
+    visibility = ["//visibility:private"],
+    deps = ["@com_github_golang_glog//:go_default_library"],
+)
+
+go_binary(
+    name = "tarify",
+    embed = [":go_default_library"],
+    visibility = ["//visibility:public"],
+)
diff --git a/tools/gostatic/tarify/main.go b/tools/gostatic/tarify/main.go
new file mode 100644
index 0000000..f292289
--- /dev/null
+++ b/tools/gostatic/tarify/main.go
@@ -0,0 +1,119 @@
+package main
+
+// tarify implements a minimal, self-contained, hermetic tarball builder.
+// It is currently used with gostatic to take a non-hermetic directory and
+// turn it into a hermetic tarball via a glob.
+//
+// For more information about tree artifacts and hermeticity, see:
+// https://jmmv.dev/2019/12/bazel-dynamic-execution-tree-artifacts.html
+
+import (
+	"archive/tar"
+	"flag"
+	"fmt"
+	"io"
+	"os"
+	"path/filepath"
+	"sort"
+	"strings"
+
+	"github.com/golang/glog"
+)
+
+var (
+	flagSite    string
+	flagTarball string
+)
+
+func init() {
+	flag.Set("logtostderr", "true")
+}
+
+func main() {
+	flag.StringVar(&flagSite, "site", "", "Site sources")
+	flag.StringVar(&flagTarball, "tarball", "", "Output tarball")
+	flag.Parse()
+
+	if flagSite == "" {
+		glog.Exitf("-site must be set")
+	}
+	if flagTarball == "" {
+		glog.Exitf("-tarball must be set")
+	}
+
+	f, err := os.Create(flagTarball)
+	if err != nil {
+		glog.Exitf("Create(%q): %v", flagTarball, err)
+	}
+	defer f.Close()
+	w := tar.NewWriter(f)
+	defer w.Close()
+
+	flagSite = strings.TrimSuffix(flagSite, "/")
+
+	// First retrieve all files and sort. This is required for idempotency.
+	elems := []struct {
+		path string
+		info os.FileInfo
+	}{}
+	err = filepath.Walk(flagSite, func(inPath string, _ os.FileInfo, err error) error {
+		// We don't use the given fileinfo, as we want to deref symlinks.
+		info, err := os.Stat(inPath)
+		if err != nil {
+			return fmt.Errorf("Stat: %w", err)
+		}
+		elems = append(elems, struct {
+			path string
+			info os.FileInfo
+		}{inPath, info})
+		return nil
+	})
+	if err != nil {
+		glog.Exitf("Walk(%q, _): %v", flagSite, err)
+	}
+	sort.Slice(elems, func(i, j int) bool { return elems[i].path < elems[j].path })
+
+	// Now that we have a sorted list, tar 'em up.
+	for _, elem := range elems {
+		inPath := elem.path
+		info := elem.info
+
+		outPath := strings.TrimPrefix(strings.TrimPrefix(inPath, flagSite), "/")
+		if outPath == "" {
+			continue
+		}
+		if info.IsDir() {
+			glog.Infof("D %s", outPath)
+			if err := w.WriteHeader(&tar.Header{
+				Typeflag: tar.TypeDir,
+				Name:     outPath,
+				Mode:     0755,
+			}); err != nil {
+				glog.Exitf("Writing directory header for %q failed: %v", inPath, err)
+			}
+		} else {
+			glog.Infof("F %s", outPath)
+			if err := w.WriteHeader(&tar.Header{
+				Typeflag: tar.TypeReg,
+				Name:     outPath,
+				Mode:     0644,
+				// TODO(q3k): this can race (TOCTOU Stat/Open, resulting in "archive/tar: write Too long")
+				// No idea, how to handle this better though without reading the entire file into memory,
+				// or trying to do filesystem locks? Besides, in practical use with Bazel this will never
+				// happen.
+				Size: info.Size(),
+			}); err != nil {
+				glog.Exitf("Writing file header for %q failed: %v", inPath, err)
+			}
+			r, err := os.Open(inPath)
+			if err != nil {
+				glog.Exitf("Open(%q): %v", inPath, err)
+			}
+			defer r.Close()
+			if _, err := io.Copy(w, r); err != nil {
+				glog.Exitf("Copy(%q): %v", inPath, err)
+			}
+
+		}
+	}
+}