1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
package main

import (
	"fmt"
	"net/http"
	"os"
	"path/filepath"
	"time"
)

// =============================================================================
// hosty hello — example app + tree generator
// =============================================================================

func cmdHello(args []string) error {
	if len(args) == 0 {
		printHelloUsage()
		return fmt.Errorf("subcommand required")
	}
	switch args[0] {
	case "run-v1":
		return cmdHelloServeV1()
	case "run-v2":
		return cmdHelloServeV2()
	case "-h", "--help", "help":
		printHelloUsage()
		return nil
	default:
		fmt.Fprintf(os.Stderr, "hosty hello: unknown subcommand %q\n\n", args[0])
		printHelloUsage()
		os.Exit(1)
	}
	return nil
}

func printHelloUsage() {
	fmt.Print(`Usage: hosty hello <subcommand>

Subcommands:
  run-v1  Run the v0.1 HTTP server (visit counter only)
          Requires: HOSTY_DB, HOSTY_PORT
          Optional: HOSTY_FS (appends to visits.log, serves GET /log)
                    GREETING (response prefix, default: "Hello from hosty!")
  run-v2  Run the v0.2 HTTP server (visit counter + last_visited timestamp)
          Runs a DB migration on first start (adds last_visited column via PRAGMA user_version)
          Requires: HOSTY_DB, HOSTY_PORT
          Optional: HOSTY_FS (appends to visits.log, serves GET /log)
                    GREETING (response prefix, default: "Hello from hosty!")

To build a hosty-hello image:
  hosty pack -name hosty-hello -version 0.1 \
    -map /path/to/hosty:usr/bin/hosty \
    -ExecStart "/usr/bin/hosty hello run-v1" \
    -config "GREETING:Greeting message shown on GET /:optional,default=Hello from hosty!"
`)
}

// helloEnv reads and validates HOSTY_DB and HOSTY_PORT from the environment.
// HOSTY_FS is optional; fsDir is empty string if not set.
// GREETING is optional; defaults to "Hello from hosty!".
func helloEnv() (dbPath, port, fsDir, greeting string, err error) {
	dbPath = os.Getenv("HOSTY_DB")
	if dbPath == "" {
		return "", "", "", "", fmt.Errorf("HOSTY_DB environment variable not set")
	}
	port = os.Getenv("HOSTY_PORT")
	if port == "" {
		return "", "", "", "", fmt.Errorf("HOSTY_PORT environment variable not set")
	}
	fsDir = os.Getenv("HOSTY_FS")
	greeting = os.Getenv("GREETING")
	if greeting == "" {
		greeting = "Hello from hosty!"
	}
	return dbPath, port, fsDir, greeting, nil
}

// helloAppendVisit appends a line to <fsDir>/visits.log recording the visit
// count and current time. Silently does nothing if fsDir is empty.
func helloAppendVisit(fsDir string, count int) {
	if fsDir == "" {
		return
	}
	f, err := os.OpenFile(
		filepath.Join(fsDir, "visits.log"),
		os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644,
	)
	if err != nil {
		fmt.Fprintf(os.Stderr, "hosty hello: open visits.log: %v\n", err)
		return
	}
	defer f.Close()
	ts := time.Now().UTC().Format("2006-01-02 15:04:05")
	fmt.Fprintf(f, "visit #%d at %s\n", count, ts)
}

// cmdHelloServeV1 runs the v0.1 HTTP server: plain visit counter, no migrations.
func cmdHelloServeV1() error {
	dbPath, port, fsDir, greeting, err := helloEnv()
	if err != nil {
		return err
	}

	db, err := openDB(dbPath)
	if err != nil {
		return fmt.Errorf("opening db: %w", err)
	}
	defer db.Close()

	_, err = db.Exec(`
		CREATE TABLE IF NOT EXISTS visits (
			id    INTEGER PRIMARY KEY CHECK (id = 1),
			count INTEGER NOT NULL DEFAULT 0
		);
		INSERT OR IGNORE INTO visits (id, count) VALUES (1, 0);
	`)
	if err != nil {
		return fmt.Errorf("init visits table: %w", err)
	}

	mux := http.NewServeMux()
	mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(http.StatusOK)
		fmt.Fprint(w, "ok")
	})
	mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		var count int
		err := db.QueryRow(`
			UPDATE visits SET count = count + 1 WHERE id = 1
			RETURNING count
		`).Scan(&count)
		if err != nil {
			http.Error(w, fmt.Sprintf("db error: %v", err), http.StatusInternalServerError)
			return
		}
		helloAppendVisit(fsDir, count)
		w.Header().Set("Content-Type", "text/plain")
		fmt.Fprintf(w, "%s Visit #%d\n", greeting, count)
	})
	mux.HandleFunc("/log", func(w http.ResponseWriter, r *http.Request) {
		if fsDir == "" {
			http.Error(w, "HOSTY_FS not set", http.StatusNotFound)
			return
		}
		data, err := os.ReadFile(filepath.Join(fsDir, "visits.log"))
		if err != nil {
			http.Error(w, fmt.Sprintf("read log: %v", err), http.StatusInternalServerError)
			return
		}
		w.Header().Set("Content-Type", "text/plain")
		w.Write(data)
	})

	addr := ":" + port
	fmt.Fprintf(os.Stderr, "hosty hello v0.1: listening on %s\n", addr)
	return http.ListenAndServe(addr, mux)
}

// cmdHelloServeV2 runs the v0.2 HTTP server: visit counter with last_visited
// timestamp. Runs a DB migration on first start (schema version 0 → 1).
func cmdHelloServeV2() error {
	dbPath, port, fsDir, greeting, err := helloEnv()
	if err != nil {
		return err
	}

	db, err := openDB(dbPath)
	if err != nil {
		return fmt.Errorf("opening db: %w", err)
	}
	defer db.Close()

	// Create base table (idempotent, same as v0.1).
	_, err = db.Exec(`
		CREATE TABLE IF NOT EXISTS visits (
			id    INTEGER PRIMARY KEY CHECK (id = 1),
			count INTEGER NOT NULL DEFAULT 0
		);
		INSERT OR IGNORE INTO visits (id, count) VALUES (1, 0);
	`)
	if err != nil {
		return fmt.Errorf("init visits table: %w", err)
	}

	// Run pending migrations using PRAGMA user_version as the schema version.
	var schemaVersion int
	if err := db.QueryRow(`PRAGMA user_version`).Scan(&schemaVersion); err != nil {
		return fmt.Errorf("reading schema version: %w", err)
	}
	if schemaVersion < 1 {
		fmt.Fprintf(os.Stderr, "hosty hello: migrating schema 0 → 1 (adding last_visited column)\n")
		if _, err := db.Exec(`ALTER TABLE visits ADD COLUMN last_visited TEXT`); err != nil {
			return fmt.Errorf("migration 0→1: %w", err)
		}
		if _, err := db.Exec(`PRAGMA user_version = 1`); err != nil {
			return fmt.Errorf("setting schema version: %w", err)
		}
		fmt.Fprintf(os.Stderr, "hosty hello: migration complete, schema is now version 1\n")
	}

	mux := http.NewServeMux()
	mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(http.StatusOK)
		fmt.Fprint(w, "ok")
	})
	mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		var count int
		var lastVisited string
		err := db.QueryRow(`
			UPDATE visits SET count = count + 1, last_visited = datetime('now')
			WHERE id = 1
			RETURNING count, last_visited
		`).Scan(&count, &lastVisited)
		if err != nil {
			http.Error(w, fmt.Sprintf("db error: %v", err), http.StatusInternalServerError)
			return
		}
		helloAppendVisit(fsDir, count)
		w.Header().Set("Content-Type", "text/plain")
		fmt.Fprintf(w, "%s Visit #%d (last visited: %s)\n", greeting, count, lastVisited)
	})
	mux.HandleFunc("/log", func(w http.ResponseWriter, r *http.Request) {
		if fsDir == "" {
			http.Error(w, "HOSTY_FS not set", http.StatusNotFound)
			return
		}
		data, err := os.ReadFile(filepath.Join(fsDir, "visits.log"))
		if err != nil {
			http.Error(w, fmt.Sprintf("read log: %v", err), http.StatusInternalServerError)
			return
		}
		w.Header().Set("Content-Type", "text/plain")
		w.Write(data)
	})

	addr := ":" + port
	fmt.Fprintf(os.Stderr, "hosty hello v0.2: listening on %s\n", addr)
	return http.ListenAndServe(addr, mux)
}