summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rwxr-xr-xcreate_service.sh163
-rw-r--r--deploy-phoneof.yml4
-rw-r--r--inventory4
-rw-r--r--roles/nameservers/templates/db.simponic.xyz.j21
-rw-r--r--roles/phoneof/tasks/main.yml22
-rw-r--r--roles/phoneof/templates/docker-compose.yml.j218
-rw-r--r--roles/webservers/files/ryo/http.phoneof.simponic.xyz.conf13
-rw-r--r--roles/webservers/files/ryo/https.phoneof.simponic.xyz.conf33
-rw-r--r--template/.dockerignore5
-rw-r--r--template/.drone.yml49
-rw-r--r--template/.gitignore3
-rw-r--r--template/.tool-versions1
-rw-r--r--template/Dockerfile13
-rw-r--r--template/README.md3
-rw-r--r--template/api/api.go91
-rw-r--r--template/api/api_test.go89
-rw-r--r--template/api/template/template.go73
-rw-r--r--template/api/types/types.go26
-rw-r--r--template/args/args.go82
-rw-r--r--template/database/conn.go17
-rw-r--r--template/database/migrate.go39
-rw-r--r--template/docker-compose.yml18
-rw-r--r--template/go.mod9
-rw-r--r--template/main.go63
-rw-r--r--template/scheduler/scheduler.go34
-rw-r--r--template/static/css/colors.css51
-rw-r--r--template/static/css/form.css42
-rw-r--r--template/static/css/styles.css13
-rw-r--r--template/static/css/table.css28
-rw-r--r--template/templates/404.html7
-rw-r--r--template/templates/base.html16
-rw-r--r--template/templates/base_empty.html3
-rw-r--r--template/templates/hello.html3
-rw-r--r--template/utils/random_id.go16
34 files changed, 1052 insertions, 0 deletions
diff --git a/create_service.sh b/create_service.sh
new file mode 100755
index 0000000..92bb4b1
--- /dev/null
+++ b/create_service.sh
@@ -0,0 +1,163 @@
+#!/bin/bash
+
+set -x
+set -e
+
+DNS_ENDPOINT="https://hatecomputers.club/dns"
+BIND_FILE="roles/nameservers/templates/db.simponic.xyz.j2"
+
+SERVICE_TITLE="phoneof simponic."
+SERVICE="phoneof"
+SERVICE_PORT="19191"
+SERVICE_REPO="git.simponic.xyz/simponic/$SERVICE"
+SERVICE_ORIGIN="git@git.simponic.xyz:simponic/$SERVICE"
+INTERNAL="no"
+SERVICE_HOST="ryo"
+PACKAGE_PATH="$HOME/git/simponic/$SERVICE"
+HATECOMPUTERS_API_KEY="$(pbaste)"
+
+
+function render_template() {
+ cp -r template $PACKAGE_PATH
+ ggrep -rlZ "{{ service }}" $PACKAGE_PATH | xargs -0 gsed -i "s/{{ service }}/$SERVICE/g"
+ ggrep -rlZ "{{ service_host }}" $PACKAGE_PATH | xargs -0 gsed -i "s/{{ service_host }}/$SERVICE_HOST/g"
+ ggrep -rlZ "{{ service_repo }}" $PACKAGE_PATH | xargs -0 gsed -i "s/{{ service_repo }}/$(echo $SERVICE_REPO | sed 's/\//\\\//g')/g"
+ ggrep -rlZ "{{ service_port }}" $PACKAGE_PATH | xargs -0 gsed -i "s/{{ service_port }}/$SERVICE_PORT/g"
+ ggrep -rlZ "{{ service_title }}" $PACKAGE_PATH | xargs -0 gsed -i "s/{{ service_title }}/$SERVICE_TITLE/g"
+}
+
+function test_and_commit_code() {
+ cd $PACKAGE_PATH
+
+ go get
+ go mod tidy
+ go build
+ go test -v ./...
+
+ echo "everything looks good, can you make a repo at https://$SERVICE_REPO (press enter when done)"
+ read
+ echo "cool. now, please sync it with drone (https://drone.internal.simponic.xyz/simponic/$SERVICE). (press enter when done)"
+ read
+
+ git init
+ git add .
+ git commit -m "initial commit by simponic-infra"
+ git checkout -B main
+ git remote add origin $SERVICE_ORIGIN
+ git push -u origin main
+ cd -
+}
+
+function add_dns_records() {
+ if [[ "$INTERNAL" = "yes" ]]; then
+ name="$SERVICE.internal.simponic.xyz."
+ content="$SERVICE_HOST.internal.simponic.xyz."
+ curl -H "Authorization: Bearer $HATECOMPUTERS_API_KEY" \
+ -F "type=CNAME&name=$name&content=$content.internal.simponic.xyz.&ttl=43200&internal=on" \
+ $DNS_ENDPOINT
+ else
+ name="$SERVICE.simponic.xyz."
+ content="$SERVICE_HOST.simponic.xyz."
+ gsed -i "s|;; CNAME Records|;; CNAME Records\n$name\t43200\tIN\tCNAME\t$content|" $BIND_FILE
+ fi
+}
+
+function add_nginx_config() {
+ endpoint="$SERVICE.simponic.xyz"
+ destination="roles/webservers/files/$SERVICE_HOST"
+ if [[ $INTERNAL = "yes" ]]; then
+ ednpoint="$SERVICE.internal.simponic.xyz"
+ destination="roles/private/files/$SERVICE_HOST"
+ else
+ mkdir -p $destination
+
+ echo "server {
+ listen 443 ssl;
+ server_name headscale.simponic.xyz;
+
+ ssl_certificate /etc/letsencrypt/live/$endpoint/fullchain.pem;
+ ssl_certificate_key /etc/letsencrypt/live/$endpoint/privkey.pem;
+ ssl_trusted_certificate /etc/letsencrypt/live/$endpoint/fullchain.pem;
+
+ ssl_session_cache shared:SSL:50m;
+ ssl_session_timeout 5m;
+ ssl_stapling on;
+ ssl_stapling_verify on;
+
+ ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
+ ssl_ciphers \"ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-SHA384:ECDHE-RSA-AES128-SHA256:ECDHE-RSA-AES256-SHA:ECDHE-RSA-AES128-SHA:DHE-RSA-AES256-SHA256:DHE-RSA-AES128-SHA256:DHE-RSA-AES256-SHA:DHE-RSA-AES128-SHA:ECDHE-RSA-DES-CBC3-SHA:EDH-RSA-DES-CBC3-SHA:AES256-GCM-SHA384:AES128-GCM-SHA256:AES256-SHA256:AES128-SHA256:AES256-SHA:AES128-SHA:DES-CBC3-SHA:HIGH:!aNULL:!eNULL:!EXPORT:!DES:!MD5:!PSK:!RC4\";
+
+ ssl_dhparam /etc/nginx/dhparams.pem;
+ ssl_prefer_server_ciphers on;
+
+ location / {
+ proxy_pass https://127.0.0.1:$SERVICE_PORT;
+ proxy_http_version 1.1;
+ proxy_set_header Upgrade \$http_upgrade;
+ proxy_set_header Connection \"upgrade\";
+ proxy_set_header Host \$server_name;
+ proxy_redirect http:// https://;
+ proxy_buffering off;
+ proxy_set_header X-Real-IP \$remote_addr;
+ proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto \$http_x_forwarded_proto;
+ add_header Strict-Transport-Security \"max-age=15552000; includeSubDomains\" always;
+ }
+}" > "$destination/https.$endpoint.conf"
+ echo "server {
+ listen 80;
+ server_name $endpoint;
+
+ location /.well-known/acme-challenge {
+ root /var/www/letsencrypt;
+ try_files \$uri \$uri/ =404;
+ }
+
+ location / {
+ rewrite ^ https://$endpoint\$request_uri? permanent;
+ }
+}" > "$destination/http.$endpoint.conf"
+ fi
+}
+
+function create_role() {
+ printf "\n[$SERVICE]\n$SERVICE_HOST ansible_user=root ansible_connection=ssh" >> inventory
+ mkdir -p roles/$SERVICE/tasks
+ mkdir -p roles/$SERVICE/templates
+ cp $PACKAGE_PATH/docker-compose.yml roles/$SERVICE/templates/docker-compose.yml.j2
+
+ echo "---
+- name: ensure $SERVICE docker/compose exist
+ file:
+ path: /etc/docker/compose/$SERVICE
+ state: directory
+ owner: root
+ group: root
+ mode: 0700
+
+- name: build $SERVICE docker-compose.yml.j2
+ template:
+ src: ../templates/docker-compose.yml.j2
+ dest: /etc/docker/compose/$SERVICE/docker-compose.yml
+ owner: root
+ group: root
+ mode: u=rw,g=r,o=r
+
+- name: daemon-reload and enable $SERVICE
+ ansible.builtin.systemd_service:
+ state: restarted
+ enabled: true
+ name: docker-compose@$SERVICE" > roles/$SERVICE/tasks/main.yml
+
+ echo "- name: deploy $SERVICE
+ hosts: $SERVICE
+ roles:
+ - $SERVICE" > deploy-$SERVICE.yml
+}
+
+render_template
+test_and_commit_code
+
+add_dns_records
+add_nginx_config
+create_role
diff --git a/deploy-phoneof.yml b/deploy-phoneof.yml
new file mode 100644
index 0000000..b1f0f6d
--- /dev/null
+++ b/deploy-phoneof.yml
@@ -0,0 +1,4 @@
+- name: deploy phoneof
+ hosts: phoneof
+ roles:
+ - phoneof
diff --git a/inventory b/inventory
index 5f9f6c1..05c0d23 100644
--- a/inventory
+++ b/inventory
@@ -21,6 +21,7 @@ raspberrypi ansible_user=root ansible_connection=ssh
[webservers]
levi ansible_user=root ansible_connection=ssh
nijika ansible_user=root ansible_connection=ssh
+ryo ansible_user=root ansible_connection=ssh
[nameservers]
ryo ansible_user=root ansible_connection=ssh
@@ -85,3 +86,6 @@ johan ansible_user=root ansible_connection=ssh
[uptime]
raspberrypi ansible_user=root ansible_connection=ssh
+
+[phoneof]
+ryo ansible_user=root ansible_connection=ssh
diff --git a/roles/nameservers/templates/db.simponic.xyz.j2 b/roles/nameservers/templates/db.simponic.xyz.j2
index d257346..8b6b429 100644
--- a/roles/nameservers/templates/db.simponic.xyz.j2
+++ b/roles/nameservers/templates/db.simponic.xyz.j2
@@ -27,6 +27,7 @@ simponic.xyz. 1 IN A 23.95.214.176
chesshbot.simponic.xyz. 1 IN A 129.123.76.14
;; CNAME Records
+phoneof.simponic.xyz. 43200 IN CNAME ryo.simponic.xyz.
secure.tunnel.simponic.xyz. 1 IN CNAME simponic.xyz.
tunnel.simponic.xyz. 1 IN CNAME simponic.xyz.
party.simponic.xyz. 1 IN CNAME simponic.xyz.
diff --git a/roles/phoneof/tasks/main.yml b/roles/phoneof/tasks/main.yml
new file mode 100644
index 0000000..082e87e
--- /dev/null
+++ b/roles/phoneof/tasks/main.yml
@@ -0,0 +1,22 @@
+---
+- name: ensure phoneof docker/compose exist
+ file:
+ path: /etc/docker/compose/phoneof
+ state: directory
+ owner: root
+ group: root
+ mode: 0700
+
+- name: build phoneof docker-compose.yml.j2
+ template:
+ src: ../templates/docker-compose.yml.j2
+ dest: /etc/docker/compose/phoneof/docker-compose.yml
+ owner: root
+ group: root
+ mode: u=rw,g=r,o=r
+
+- name: daemon-reload and enable phoneof
+ ansible.builtin.systemd_service:
+ state: restarted
+ enabled: true
+ name: docker-compose@phoneof
diff --git a/roles/phoneof/templates/docker-compose.yml.j2 b/roles/phoneof/templates/docker-compose.yml.j2
new file mode 100644
index 0000000..ca17e85
--- /dev/null
+++ b/roles/phoneof/templates/docker-compose.yml.j2
@@ -0,0 +1,18 @@
+version: "3"
+
+services:
+ api:
+ restart: always
+ image: git.simponic.xyz/simponic/phoneof
+ healthcheck:
+ test: ["CMD", "wget", "--spider", "http://localhost:8080/api/health"]
+ interval: 5s
+ timeout: 10s
+ retries: 5
+ env_file: .env
+ volumes:
+ - ./db:/app/db
+ - ./templates:/app/templates
+ - ./static:/app/static
+ ports:
+ - "127.0.0.1:19191:8080"
diff --git a/roles/webservers/files/ryo/http.phoneof.simponic.xyz.conf b/roles/webservers/files/ryo/http.phoneof.simponic.xyz.conf
new file mode 100644
index 0000000..c849a26
--- /dev/null
+++ b/roles/webservers/files/ryo/http.phoneof.simponic.xyz.conf
@@ -0,0 +1,13 @@
+server {
+ listen 80;
+ server_name phoneof.simponic.xyz;
+
+ location /.well-known/acme-challenge {
+ root /var/www/letsencrypt;
+ try_files $uri $uri/ =404;
+ }
+
+ location / {
+ rewrite ^ https://phoneof.simponic.xyz$request_uri? permanent;
+ }
+}
diff --git a/roles/webservers/files/ryo/https.phoneof.simponic.xyz.conf b/roles/webservers/files/ryo/https.phoneof.simponic.xyz.conf
new file mode 100644
index 0000000..2290c4a
--- /dev/null
+++ b/roles/webservers/files/ryo/https.phoneof.simponic.xyz.conf
@@ -0,0 +1,33 @@
+server {
+ listen 443 ssl;
+ server_name headscale.simponic.xyz;
+
+ ssl_certificate /etc/letsencrypt/live/phoneof.simponic.xyz/fullchain.pem;
+ ssl_certificate_key /etc/letsencrypt/live/phoneof.simponic.xyz/privkey.pem;
+ ssl_trusted_certificate /etc/letsencrypt/live/phoneof.simponic.xyz/fullchain.pem;
+
+ ssl_session_cache shared:SSL:50m;
+ ssl_session_timeout 5m;
+ ssl_stapling on;
+ ssl_stapling_verify on;
+
+ ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
+ ssl_ciphers "ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-SHA384:ECDHE-RSA-AES128-SHA256:ECDHE-RSA-AES256-SHA:ECDHE-RSA-AES128-SHA:DHE-RSA-AES256-SHA256:DHE-RSA-AES128-SHA256:DHE-RSA-AES256-SHA:DHE-RSA-AES128-SHA:ECDHE-RSA-DES-CBC3-SHA:EDH-RSA-DES-CBC3-SHA:AES256-GCM-SHA384:AES128-GCM-SHA256:AES256-SHA256:AES128-SHA256:AES256-SHA:AES128-SHA:DES-CBC3-SHA:HIGH:!aNULL:!eNULL:!EXPORT:!DES:!MD5:!PSK:!RC4";
+
+ ssl_dhparam /etc/nginx/dhparams.pem;
+ ssl_prefer_server_ciphers on;
+
+ location / {
+ proxy_pass https://127.0.0.1:19191;
+ proxy_http_version 1.1;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection "upgrade";
+ proxy_set_header Host $server_name;
+ proxy_redirect http:// https://;
+ proxy_buffering off;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
+ add_header Strict-Transport-Security "max-age=15552000; includeSubDomains" always;
+ }
+}
diff --git a/template/.dockerignore b/template/.dockerignore
new file mode 100644
index 0000000..7dddab3
--- /dev/null
+++ b/template/.dockerignore
@@ -0,0 +1,5 @@
+.env
+{{ service }}
+Dockerfile
+*.db
+.drone.yml
diff --git a/template/.drone.yml b/template/.drone.yml
new file mode 100644
index 0000000..92378f4
--- /dev/null
+++ b/template/.drone.yml
@@ -0,0 +1,49 @@
+---
+kind: pipeline
+type: docker
+name: build
+
+steps:
+ - name: run tests
+ image: golang
+ commands:
+ - go get
+ - go test -p 1 -v ./...
+
+trigger:
+ event:
+ - pull_request
+
+
+---
+kind: pipeline
+type: docker
+name: cicd
+
+steps:
+ - name: ci
+ image: plugins/docker
+ settings:
+ username:
+ from_secret: gitea_packpub_username
+ password:
+ from_secret: gitea_packpub_password
+ registry: git.simponic.xyz
+ repo: {{ service_repo }}
+ - name: ssh
+ image: appleboy/drone-ssh
+ settings:
+ host: {{ service_host }}.simponic.xyz
+ username: root
+ key:
+ from_secret: cd_ssh_key
+ port: 22
+ command_timeout: 2m
+ script:
+ - systemctl restart docker-compose@{{ service }}
+
+trigger:
+ branch:
+ - main
+ event:
+ - push
diff --git a/template/.gitignore b/template/.gitignore
new file mode 100644
index 0000000..059b6c1
--- /dev/null
+++ b/template/.gitignore
@@ -0,0 +1,3 @@
+*.env
+{{ service }}
+*.db
diff --git a/template/.tool-versions b/template/.tool-versions
new file mode 100644
index 0000000..db5d8ee
--- /dev/null
+++ b/template/.tool-versions
@@ -0,0 +1 @@
+golang 1.23.4
diff --git a/template/Dockerfile b/template/Dockerfile
new file mode 100644
index 0000000..87a2422
--- /dev/null
+++ b/template/Dockerfile
@@ -0,0 +1,13 @@
+FROM golang:1.23
+WORKDIR /app
+
+COPY go.mod go.sum ./
+RUN go mod download
+
+COPY . .
+
+RUN go build -o /app/{{ service }}
+
+EXPOSE 8080
+
+CMD ["/app/{{ service }}", "--server", "--migrate", "--port", "8080", "--template-path", "/app/templates", "--database-path", "/app/db/{{ service }}.db", "--static-path", "/app/static", "--scheduler"]
diff --git a/template/README.md b/template/README.md
new file mode 100644
index 0000000..c7477e4
--- /dev/null
+++ b/template/README.md
@@ -0,0 +1,3 @@
+## {{ service_title }}
+
+this is a simponic service for {{ service }}
diff --git a/template/api/api.go b/template/api/api.go
new file mode 100644
index 0000000..4d278da
--- /dev/null
+++ b/template/api/api.go
@@ -0,0 +1,91 @@
+package api
+
+import (
+ "database/sql"
+ "fmt"
+ "log"
+ "net/http"
+ "time"
+
+ "{{ service_repo }}/api/template"
+ "{{ service_repo }}/api/types"
+ "{{ service_repo }}/args"
+ "{{ service_repo }}/utils"
+)
+
+func LogRequestContinuation(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain {
+ return func(success types.Continuation, _failure types.Continuation) types.ContinuationChain {
+ context.Start = time.Now()
+ context.Id = utils.RandomId()
+
+ log.Println(req.Method, req.URL.Path, req.RemoteAddr, context.Id)
+ return success(context, req, resp)
+ }
+}
+
+func LogExecutionTimeContinuation(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain {
+ return func(success types.Continuation, _failure types.Continuation) types.ContinuationChain {
+ end := time.Now()
+ log.Println(context.Id, "took", end.Sub(context.Start))
+
+ return success(context, req, resp)
+ }
+}
+
+func HealthCheckContinuation(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain {
+ return func(success types.Continuation, _failure types.Continuation) types.ContinuationChain {
+ resp.WriteHeader(200)
+ resp.Write([]byte("healthy"))
+ return success(context, req, resp)
+ }
+}
+
+func FailurePassingContinuation(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain {
+ return func(_success types.Continuation, failure types.Continuation) types.ContinuationChain {
+ return failure(context, req, resp)
+ }
+}
+
+func IdContinuation(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain {
+ return func(success types.Continuation, _failure types.Continuation) types.ContinuationChain {
+ return success(context, req, resp)
+ }
+}
+
+func CacheControlMiddleware(next http.Handler, maxAge int) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ header := fmt.Sprintf("public, max-age=%d", maxAge)
+ w.Header().Set("Cache-Control", header)
+ next.ServeHTTP(w, r)
+ })
+}
+
+func MakeMux(argv *args.Arguments, dbConn *sql.DB) *http.ServeMux {
+ mux := http.NewServeMux()
+
+ staticFileServer := http.FileServer(http.Dir(argv.StaticPath))
+ mux.Handle("GET /static/", http.StripPrefix("/static/", CacheControlMiddleware(staticFileServer, 3600)))
+
+ makeRequestContext := func() *types.RequestContext {
+ return &types.RequestContext{
+ DBConn: dbConn,
+ Args: argv,
+ TemplateData: &map[string]interface{}{},
+ }
+ }
+
+ mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
+ requestContext := makeRequestContext()
+ LogRequestContinuation(requestContext, r, w)(HealthCheckContinuation, FailurePassingContinuation)(LogExecutionTimeContinuation, LogExecutionTimeContinuation)(IdContinuation, IdContinuation)
+ })
+
+ mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
+ requestContext := makeRequestContext()
+
+ (*requestContext.TemplateData)["Service"] = "{{ service }}"
+ templateFile := "hello.html"
+ LogRequestContinuation(requestContext, r, w)(template.TemplateContinuation(templateFile, true), FailurePassingContinuation)(LogExecutionTimeContinuation, LogExecutionTimeContinuation)(IdContinuation, IdContinuation)
+ })
+
+ return mux
+}
diff --git a/template/api/api_test.go b/template/api/api_test.go
new file mode 100644
index 0000000..9ad8f92
--- /dev/null
+++ b/template/api/api_test.go
@@ -0,0 +1,89 @@
+package api_test
+
+import (
+ "database/sql"
+ "io"
+ "net/http/httptest"
+ "os"
+ "strings"
+ "testing"
+
+ "{{ service_repo }}/api"
+ "{{ service_repo }}/args"
+ "{{ service_repo }}/database"
+ "{{ service_repo }}/utils"
+)
+
+func setup(t *testing.T) (*sql.DB, *httptest.Server) {
+ randomDb := utils.RandomId()
+
+ testDb := database.MakeConn(&randomDb)
+ database.Migrate(testDb)
+
+ arguments := &args.Arguments{
+ TemplatePath: "../templates",
+ StaticPath: "../static",
+ }
+
+ mux := api.MakeMux(arguments, testDb)
+ testServer := httptest.NewServer(mux)
+
+ t.Cleanup(func() {
+ testServer.Close()
+ testDb.Close()
+ os.Remove(randomDb)
+ })
+ return testDb, testServer
+}
+
+func assertResponseCode(t *testing.T, resp *httptest.ResponseRecorder, statusCode int) {
+ if resp.Code != statusCode {
+ t.Errorf("code is unexpected: %d, expected %d", resp.Code, statusCode)
+ }
+}
+
+func assertResponseBody(t *testing.T, resp *httptest.ResponseRecorder, body string) {
+ buf := new(strings.Builder)
+ _, err := io.Copy(buf, resp.Body)
+ if err != nil {
+ panic("could not read response body")
+ }
+ bodyStr := buf.String()
+ if bodyStr != body {
+ t.Errorf("body is unexpected: %s, expected %s", bodyStr, body)
+ }
+}
+
+func TestHealthcheck(t *testing.T) {
+ _, testServer := setup(t)
+
+ req := httptest.NewRequest("GET", "/health", nil)
+ resp := httptest.NewRecorder()
+ testServer.Config.Handler.ServeHTTP(resp, req)
+
+ assertResponseCode(t, resp, 200)
+ assertResponseBody(t, resp, "healthy")
+}
+
+func TestHello(t *testing.T) {
+ _, testServer := setup(t)
+
+ req := httptest.NewRequest("GET", "/", nil)
+ resp := httptest.NewRecorder()
+ testServer.Config.Handler.ServeHTTP(resp, req)
+
+ assertResponseCode(t, resp, 200)
+}
+
+func TestCachingStaticFiles(t *testing.T) {
+ _, testServer := setup(t)
+
+ req := httptest.NewRequest("GET", "/static/css/styles.css", nil)
+ resp := httptest.NewRecorder()
+ testServer.Config.Handler.ServeHTTP(resp, req)
+
+ assertResponseCode(t, resp, 200)
+ if resp.Header().Get("Cache-Control") != "public, max-age=3600" {
+ t.Errorf("client cache will live indefinitely for static files, which is probably not great! %s", resp.Header().Get("Cache-Control"))
+ }
+}
diff --git a/template/api/template/template.go b/template/api/template/template.go
new file mode 100644
index 0000000..9190f29
--- /dev/null
+++ b/template/api/template/template.go
@@ -0,0 +1,73 @@
+package template
+
+import (
+ "bytes"
+ "errors"
+ "html/template"
+ "log"
+ "net/http"
+ "os"
+
+ "{{ service_repo }}/api/types"
+)
+
+func renderTemplate(context *types.RequestContext, templateName string, showBaseHtml bool) (bytes.Buffer, error) {
+ templatePath := context.Args.TemplatePath
+ basePath := templatePath + "/base_empty.html"
+ if showBaseHtml {
+ basePath = templatePath + "/base.html"
+ }
+
+ templateLocation := templatePath + "/" + templateName
+ tmpl, err := template.New("").ParseFiles(templateLocation, basePath)
+ if err != nil {
+ return bytes.Buffer{}, err
+ }
+
+ dataPtr := context.TemplateData
+ if dataPtr == nil {
+ dataPtr = &map[string]interface{}{}
+ }
+
+ data := *dataPtr
+
+ var buffer bytes.Buffer
+ err = tmpl.ExecuteTemplate(&buffer, "base", data)
+
+ if err != nil {
+ return bytes.Buffer{}, err
+ }
+ return buffer, nil
+}
+
+func TemplateContinuation(path string, showBase bool) types.Continuation {
+ return func(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain {
+ return func(success types.Continuation, failure types.Continuation) types.ContinuationChain {
+ html, err := renderTemplate(context, path, true)
+ if errors.Is(err, os.ErrNotExist) {
+ resp.WriteHeader(404)
+ html, err = renderTemplate(context, "404.html", true)
+ if err != nil {
+ log.Println("error rendering 404 template", err)
+ resp.WriteHeader(500)
+ return failure(context, req, resp)
+ }
+
+ resp.Header().Set("Content-Type", "text/html")
+ resp.Write(html.Bytes())
+ return failure(context, req, resp)
+ }
+
+ if err != nil {
+ log.Println("error rendering template", err)
+ resp.WriteHeader(500)
+ resp.Write([]byte("error rendering template"))
+ return failure(context, req, resp)
+ }
+
+ resp.Header().Set("Content-Type", "text/html")
+ resp.Write(html.Bytes())
+ return success(context, req, resp)
+ }
+ }
+}
diff --git a/template/api/types/types.go b/template/api/types/types.go
new file mode 100644
index 0000000..d2a91a3
--- /dev/null
+++ b/template/api/types/types.go
@@ -0,0 +1,26 @@
+package types
+
+import (
+ "database/sql"
+ "net/http"
+ "time"
+
+ "{{ service_repo }}/args"
+)
+
+type RequestContext struct {
+ DBConn *sql.DB
+ Args *args.Arguments
+
+ Id string
+ Start time.Time
+
+ TemplateData *map[string]interface{}
+}
+
+type BannerMessages struct {
+ Messages []string
+}
+
+type Continuation func(*RequestContext, *http.Request, http.ResponseWriter) ContinuationChain
+type ContinuationChain func(Continuation, Continuation) ContinuationChain
diff --git a/template/args/args.go b/template/args/args.go
new file mode 100644
index 0000000..6e4aff1
--- /dev/null
+++ b/template/args/args.go
@@ -0,0 +1,82 @@
+package args
+
+import (
+ "flag"
+ "fmt"
+ "os"
+ "sync"
+)
+
+type Arguments struct {
+ DatabasePath string
+ TemplatePath string
+ StaticPath string
+
+ Migrate bool
+ Scheduler bool
+
+ Port int
+ Server bool
+}
+
+func isDirectory(path string) (bool, error) {
+ fileInfo, err := os.Stat(path)
+ if err != nil {
+ return false, err
+ }
+
+ return fileInfo.IsDir(), err
+}
+
+func validateArgs(args *Arguments) error {
+ templateIsDir, err := isDirectory(args.TemplatePath)
+ if err != nil || !templateIsDir {
+ return fmt.Errorf("template path is not an accessible directory %s", err)
+ }
+ staticPathIsDir, err := isDirectory(args.StaticPath)
+ if err != nil || !staticPathIsDir {
+ return fmt.Errorf("static path is not an accessible directory %s", err)
+ }
+ return nil
+}
+
+var lock = &sync.Mutex{}
+var args *Arguments
+
+func GetArgs() (*Arguments, error) {
+ lock.Lock()
+ defer lock.Unlock()
+
+ if args != nil {
+ return args, nil
+ }
+
+ databasePath := flag.String("database-path", "./{{ service }}.db", "Path to the SQLite database")
+
+ templatePath := flag.String("template-path", "./templates", "Path to the template directory")
+ staticPath := flag.String("static-path", "./static", "Path to the static directory")
+
+ scheduler := flag.Bool("scheduler", false, "Run scheduled jobs via cron")
+ migrate := flag.Bool("migrate", false, "Run the migrations")
+
+ port := flag.Int("port", 8080, "Port to listen on")
+ server := flag.Bool("server", false, "Run the server")
+
+ flag.Parse()
+
+ args = &Arguments{
+ DatabasePath: *databasePath,
+ TemplatePath: *templatePath,
+ StaticPath: *staticPath,
+ Port: *port,
+ Server: *server,
+ Migrate: *migrate,
+ Scheduler: *scheduler,
+ }
+ err := validateArgs(args)
+ if err != nil {
+ return nil, err
+ }
+
+ return args, nil
+}
diff --git a/template/database/conn.go b/template/database/conn.go
new file mode 100644
index 0000000..be27586
--- /dev/null
+++ b/template/database/conn.go
@@ -0,0 +1,17 @@
+package database
+
+import (
+ "database/sql"
+ _ "github.com/mattn/go-sqlite3"
+ "log"
+)
+
+func MakeConn(databasePath *string) *sql.DB {
+ log.Println("opening database at", *databasePath, "with foreign keys enabled")
+ dbConn, err := sql.Open("sqlite3", *databasePath+"?_foreign_keys=on")
+ if err != nil {
+ panic(err)
+ }
+
+ return dbConn
+}
diff --git a/template/database/migrate.go b/template/database/migrate.go
new file mode 100644
index 0000000..8b8712f
--- /dev/null
+++ b/template/database/migrate.go
@@ -0,0 +1,39 @@
+package database
+
+import (
+ "log"
+
+ "database/sql"
+
+ _ "github.com/mattn/go-sqlite3"
+)
+
+type Migrator func(*sql.DB) (*sql.DB, error)
+
+func DoNothing(dbConn *sql.DB) (*sql.DB, error) {
+ log.Println("doing nothing")
+
+ _, err := dbConn.Exec(`DO NOTHING;`)
+ if err != nil {
+ return dbConn, err
+ }
+
+ return dbConn, nil
+}
+
+func Migrate(dbConn *sql.DB) (*sql.DB, error) {
+ log.Println("migrating database")
+
+ migrations := []Migrator{
+ DoNothing,
+ }
+
+ for _, migration := range migrations {
+ dbConn, err := migration(dbConn)
+ if err != nil {
+ return dbConn, err
+ }
+ }
+
+ return dbConn, nil
+}
diff --git a/template/docker-compose.yml b/template/docker-compose.yml
new file mode 100644
index 0000000..655159d
--- /dev/null
+++ b/template/docker-compose.yml
@@ -0,0 +1,18 @@
+version: "3"
+
+services:
+ api:
+ restart: always
+ image: {{ service_repo }}
+ healthcheck:
+ test: ["CMD", "wget", "--spider", "http://localhost:8080/api/health"]
+ interval: 5s
+ timeout: 10s
+ retries: 5
+ env_file: .env
+ volumes:
+ - ./db:/app/db
+ - ./templates:/app/templates
+ - ./static:/app/static
+ ports:
+ - "127.0.0.1:{{ service_port }}:8080"
diff --git a/template/go.mod b/template/go.mod
new file mode 100644
index 0000000..006e357
--- /dev/null
+++ b/template/go.mod
@@ -0,0 +1,9 @@
+module {{ service_repo }}
+
+go 1.23.4
+
+require (
+ github.com/go-co-op/gocron/v2 v2.14.0
+ github.com/joho/godotenv v1.5.1
+ github.com/mattn/go-sqlite3 v1.14.24
+)
diff --git a/template/main.go b/template/main.go
new file mode 100644
index 0000000..6d2b657
--- /dev/null
+++ b/template/main.go
@@ -0,0 +1,63 @@
+package main
+
+import (
+ "fmt"
+ "log"
+ "net/http"
+
+ "{{ service_repo }}/api"
+ "{{ service_repo }}/args"
+ "{{ service_repo }}/database"
+ "{{ service_repo }}/scheduler"
+ "github.com/joho/godotenv"
+)
+
+func main() {
+ log.SetFlags(log.LstdFlags | log.Lshortfile)
+
+ err := godotenv.Load()
+ if err != nil {
+ log.Println("could not load .env file:", err)
+ }
+
+ argv, err := args.GetArgs()
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ dbConn := database.MakeConn(&argv.DatabasePath)
+ defer dbConn.Close()
+
+ if argv.Migrate {
+ _, err = database.Migrate(dbConn)
+ if err != nil {
+ log.Fatal(err)
+ }
+ log.Println("database migrated successfully")
+ }
+
+ if argv.Scheduler {
+ go func() {
+ scheduler.StartScheduler(dbConn, argv)
+ }()
+ }
+
+ if argv.Server {
+ mux := api.MakeMux(argv, dbConn)
+ log.Println("🚀🚀 {{ service }} API listening on port", argv.Port)
+ go func() {
+ server := &http.Server{
+ Addr: ":" + fmt.Sprint(argv.Port),
+ Handler: mux,
+ }
+ err = server.ListenAndServe()
+ if err != nil {
+ log.Fatal(err)
+ }
+ }()
+ }
+
+ if argv.Server || argv.Scheduler {
+ select {} // block forever
+ }
+}
diff --git a/template/scheduler/scheduler.go b/template/scheduler/scheduler.go
new file mode 100644
index 0000000..7b4487a
--- /dev/null
+++ b/template/scheduler/scheduler.go
@@ -0,0 +1,34 @@
+package scheduler
+
+import (
+ "database/sql"
+ "log"
+ "time"
+
+ "{{ service_repo }}/args"
+ "github.com/go-co-op/gocron/v2"
+)
+
+func StartScheduler(_dbConn *sql.DB, argv *args.Arguments) {
+ scheduler, err := gocron.NewScheduler()
+ if err != nil {
+ panic("could not create scheduler")
+ }
+
+ _, err = scheduler.NewJob(
+ gocron.DurationJob(
+ 24*time.Hour,
+ ),
+ gocron.NewTask(
+ func(msg string) {
+ log.Println(msg)
+ },
+ "it's a beautiful new day!",
+ ),
+ )
+ if err != nil {
+ panic("could not create job")
+ }
+
+ scheduler.Start()
+}
diff --git a/template/static/css/colors.css b/template/static/css/colors.css
new file mode 100644
index 0000000..46357d9
--- /dev/null
+++ b/template/static/css/colors.css
@@ -0,0 +1,51 @@
+:root {
+ --background-color-light: #f4e8e9;
+ --background-color-light-2: #f5e6f3;
+ --text-color-light: #333;
+ --confirm-color-light: #91d9bb;
+ --link-color-light: #d291bc;
+ --container-bg-light: #fff7f87a;
+ --border-color-light: #692fcc;
+ --error-color-light: #a83254;
+
+ --background-color-dark: #333;
+ --background-color-dark-2: #2c2c2c;
+ --text-color-dark: #f4e8e9;
+ --confirm-color-dark: #4d8f73;
+ --link-color-dark: #b86b77;
+ --container-bg-dark: #424242ea;
+ --border-color-dark: #956ade;
+ --error-color-dark: #851736;
+}
+
+[data-theme="DARK"] {
+ --background-color: var(--background-color-dark);
+ --background-color-2: var(--background-color-dark-2);
+ --text-color: var(--text-color-dark);
+ --link-color: var(--link-color-dark);
+ --container-bg: var(--container-bg-dark);
+ --border-color: var(--border-color-dark);
+ --error-color: var(--error-color-dark);
+ --confirm-color: var(--confirm-color-dark);
+}
+
+[data-theme="LIGHT"] {
+ --background-color: var(--background-color-light);
+ --background-color-2: var(--background-color-light-2);
+ --text-color: var(--text-color-light);
+ --link-color: var(--link-color-light);
+ --container-bg: var(--container-bg-light);
+ --border-color: var(--border-color-light);
+ --error-color: var(--error-color-light);
+ --confirm-color: var(--confirm-color-light);
+}
+
+.error {
+ background-color: var(--error-color);
+ padding: 1rem;
+}
+
+.success {
+ background-color: var(--confirm-color);
+ padding: 1rem;
+}
diff --git a/template/static/css/form.css b/template/static/css/form.css
new file mode 100644
index 0000000..7ccd8db
--- /dev/null
+++ b/template/static/css/form.css
@@ -0,0 +1,42 @@
+.form {
+ max-width: 600px;
+ padding: 1em;
+ background: var(--background-color-2);
+ border: 1px solid #ccc;
+}
+
+label {
+ display: block;
+ margin: 0 0 1em;
+ font-weight: bold;
+}
+
+input {
+ display: block;
+ width: 100%;
+ padding: 0.5em;
+ margin: 0 0 1em;
+ border: 1px solid var(--border-color);
+ background: var(--container-bg);
+}
+
+button,
+input[type="submit"] {
+ padding: 0.5em 1em;
+ background: var(--link-color);
+ color: var(--text-color);
+ border: 0;
+ cursor: pointer;
+}
+
+textarea {
+ display: block;
+ width: 100%;
+ padding: 0.5em;
+ margin: 0 0 1em;
+ border: 1px solid var(--border-color);
+ background: var(--container-bg);
+
+ resize: vertical;
+ min-height: 100px;
+}
diff --git a/template/static/css/styles.css b/template/static/css/styles.css
new file mode 100644
index 0000000..6252898
--- /dev/null
+++ b/template/static/css/styles.css
@@ -0,0 +1,13 @@
+@import "/static/css/colors.css";
+@import "/static/css/form.css";
+@import "/static/css/table.css";
+
+* {
+ box-sizing: border-box;
+ color: var(--text-color);
+}
+
+body {
+ font-family: "Roboto", sans-serif;
+ background-color: var(--background-color);
+}
diff --git a/template/static/css/table.css b/template/static/css/table.css
new file mode 100644
index 0000000..16da86d
--- /dev/null
+++ b/template/static/css/table.css
@@ -0,0 +1,28 @@
+@import "/static/css/colors.css";
+
+table {
+ width: auto;
+ border-collapse: collapse;
+ border: 1px solid var(--border-color);
+}
+
+th,
+td {
+ padding: 12px 20px;
+ text-align: center;
+ border-bottom: 1px solid var(--border-color);
+}
+
+th,
+thead {
+ background-color: var(--background-color-2);
+}
+
+tbody tr:nth-child(odd) {
+ background-color: var(--background-color);
+ color: var(--text-color);
+}
+
+tbody tr {
+ transition: background-color 0.3s ease;
+}
diff --git a/template/templates/404.html b/template/templates/404.html
new file mode 100644
index 0000000..5210bfb
--- /dev/null
+++ b/template/templates/404.html
@@ -0,0 +1,7 @@
+{{ define "content" }}
+<h1>page not found</h1>
+<p><em>but hey, at least you found our witty 404 page. that's something, right?</em></p>
+
+<p><a href="/">go back home</a></p>
+
+{{ end }}
diff --git a/template/templates/base.html b/template/templates/base.html
new file mode 100644
index 0000000..30a9c53
--- /dev/null
+++ b/template/templates/base.html
@@ -0,0 +1,16 @@
+{{ define "base" }}
+<!DOCTYPE html>
+<html>
+ <head>
+ <title>{{ service_title }}</title>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width, user-scalable=no, minimum-scale=1.0, maximum-scale=1.0">
+ <link rel="stylesheet" type="text/css" href="/static/css/styles.css">
+ </head>
+ <body data-theme="DARK">
+ <div id="content" class="container">
+ {{ template "content" . }}
+ </div>
+ </body>
+</html>
+{{ end }}
diff --git a/template/templates/base_empty.html b/template/templates/base_empty.html
new file mode 100644
index 0000000..6191ab9
--- /dev/null
+++ b/template/templates/base_empty.html
@@ -0,0 +1,3 @@
+{{ define "base" }}
+ {{ template "content" . }}
+{{ end }} \ No newline at end of file
diff --git a/template/templates/hello.html b/template/templates/hello.html
new file mode 100644
index 0000000..d2311f5
--- /dev/null
+++ b/template/templates/hello.html
@@ -0,0 +1,3 @@
+{{ define "content" }}
+hello from {{ .Service }}!
+{{ end }}
diff --git a/template/utils/random_id.go b/template/utils/random_id.go
new file mode 100644
index 0000000..1b03ec8
--- /dev/null
+++ b/template/utils/random_id.go
@@ -0,0 +1,16 @@
+package utils
+
+import (
+ "crypto/rand"
+ "fmt"
+)
+
+func RandomId() string {
+ id := make([]byte, 16)
+ _, err := rand.Read(id)
+ if err != nil {
+ panic(err)
+ }
+
+ return fmt.Sprintf("%x", id)
+}