A small IT asset inventory system built around a nested folder tree, matching the project brief: Go + html/template + Bootstrap 5 + vanilla JS, SQLite storage (WAL, FKs, migrations), embedded templates/static assets, single compiled binary. - AJAX-driven inventory tree: lazy-loaded folder expansion with per-user persisted expand state, drag-and-drop move for folders/assets, safe delete-with-relocate-or-cascade confirmation, search with breadcrumbs. - Assets: CRUD, duplicate, move, auto-generated IT-000001-style IDs, CSV import (with optional folder auto-create) and export. - Admin-defined item types with custom fields (text/long_text/number/date/ boolean/dropdown/url/ip_address). - Configurable statuses, session-based auth (bcrypt, secure cookies), admin/user roles, user management. - CLI: serve, version, database migrate, database backup; /healthz; graceful shutdown; Dockerfile/docker-compose/systemd unit; Go test suite.
206 lines
4.5 KiB
Go
206 lines
4.5 KiB
Go
// Command inventree runs the inventree IT asset inventory server, or one of
|
|
// its supporting subcommands (version, database migrate, database backup).
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/http"
|
|
"os"
|
|
"os/signal"
|
|
"path/filepath"
|
|
"syscall"
|
|
"time"
|
|
|
|
"inventree/internal/database"
|
|
"inventree/internal/web"
|
|
)
|
|
|
|
// version is set at build time via -ldflags "-X main.version=...".
|
|
var version = "dev"
|
|
|
|
func main() {
|
|
if err := run(os.Args[1:]); err != nil {
|
|
fmt.Fprintln(os.Stderr, "error:", err)
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
func run(args []string) error {
|
|
if len(args) == 0 {
|
|
return runServe(args)
|
|
}
|
|
switch args[0] {
|
|
case "serve":
|
|
return runServe(args[1:])
|
|
case "version":
|
|
fmt.Println("inventree", version)
|
|
return nil
|
|
case "database":
|
|
if len(args) < 2 {
|
|
return fmt.Errorf("usage: inventree database <migrate|backup>")
|
|
}
|
|
switch args[1] {
|
|
case "migrate":
|
|
return runDatabaseMigrate(args[2:])
|
|
case "backup":
|
|
return runDatabaseBackup(args[2:])
|
|
default:
|
|
return fmt.Errorf("unknown database subcommand %q", args[1])
|
|
}
|
|
case "help", "-h", "--help":
|
|
printUsage()
|
|
return nil
|
|
default:
|
|
printUsage()
|
|
return fmt.Errorf("unknown command %q", args[0])
|
|
}
|
|
}
|
|
|
|
func printUsage() {
|
|
fmt.Println(`inventree - a small IT asset inventory system
|
|
|
|
Usage:
|
|
inventree [serve] Start the web server (default command)
|
|
inventree version Print the version
|
|
inventree database migrate Apply pending database migrations
|
|
inventree database backup [path] Write a consistent backup snapshot
|
|
|
|
Flags for "serve":
|
|
-addr string listen address (default ":8080")
|
|
-db string path to the SQLite database file (default "data/inventree.db")
|
|
-secure-cookies mark the session cookie Secure (enable when serving over HTTPS)`)
|
|
}
|
|
|
|
func dbPathFlag(args []string) (dbPath string, rest []string) {
|
|
dbPath = "data/inventree.db"
|
|
for i := 0; i < len(args); i++ {
|
|
if args[i] == "-db" && i+1 < len(args) {
|
|
dbPath = args[i+1]
|
|
i++
|
|
continue
|
|
}
|
|
rest = append(rest, args[i])
|
|
}
|
|
return
|
|
}
|
|
|
|
func runDatabaseMigrate(args []string) error {
|
|
dbPath, _ := dbPathFlag(args)
|
|
if err := ensureDir(dbPath); err != nil {
|
|
return err
|
|
}
|
|
db, err := database.Open(dbPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer db.Close()
|
|
fmt.Println("migrations applied to", dbPath)
|
|
return nil
|
|
}
|
|
|
|
func runDatabaseBackup(args []string) error {
|
|
dbPath, rest := dbPathFlag(args)
|
|
dest := fmt.Sprintf("backup-%s.db", time.Now().Format("20060102-150405"))
|
|
if len(rest) > 0 {
|
|
dest = rest[0]
|
|
}
|
|
if err := ensureDir(dbPath); err != nil {
|
|
return err
|
|
}
|
|
db, err := database.Open(dbPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer db.Close()
|
|
|
|
if err := ensureDir(dest); err != nil {
|
|
return err
|
|
}
|
|
if _, err := db.Exec(`VACUUM INTO ?`, dest); err != nil {
|
|
return fmt.Errorf("backup failed: %w", err)
|
|
}
|
|
fmt.Println("backup written to", dest)
|
|
return nil
|
|
}
|
|
|
|
func ensureDir(path string) error {
|
|
dir := filepath.Dir(path)
|
|
if dir == "" || dir == "." {
|
|
return nil
|
|
}
|
|
return os.MkdirAll(dir, 0o755)
|
|
}
|
|
|
|
func runServe(args []string) error {
|
|
var (
|
|
addr = ":8080"
|
|
dbPath = "data/inventree.db"
|
|
secureCookies = false
|
|
)
|
|
for i := 0; i < len(args); i++ {
|
|
switch args[i] {
|
|
case "-addr":
|
|
if i+1 < len(args) {
|
|
addr = args[i+1]
|
|
i++
|
|
}
|
|
case "-db":
|
|
if i+1 < len(args) {
|
|
dbPath = args[i+1]
|
|
i++
|
|
}
|
|
case "-secure-cookies":
|
|
secureCookies = true
|
|
}
|
|
}
|
|
|
|
log := slog.New(slog.NewTextHandler(os.Stdout, nil))
|
|
|
|
if err := ensureDir(dbPath); err != nil {
|
|
return err
|
|
}
|
|
db, err := database.Open(dbPath)
|
|
if err != nil {
|
|
return fmt.Errorf("open database: %w", err)
|
|
}
|
|
defer db.Close()
|
|
|
|
app, err := web.NewApp(db, log, secureCookies)
|
|
if err != nil {
|
|
return fmt.Errorf("initialize app: %w", err)
|
|
}
|
|
|
|
stop := make(chan struct{})
|
|
app.StartSessionJanitor(stop)
|
|
defer close(stop)
|
|
|
|
srv := &http.Server{
|
|
Addr: addr,
|
|
Handler: app.Router(),
|
|
ReadHeaderTimeout: 10 * time.Second,
|
|
}
|
|
|
|
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
|
defer cancel()
|
|
|
|
go func() {
|
|
<-ctx.Done()
|
|
log.Info("shutting down")
|
|
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer shutdownCancel()
|
|
if err := srv.Shutdown(shutdownCtx); err != nil {
|
|
log.Error("graceful shutdown failed", "error", err)
|
|
}
|
|
}()
|
|
|
|
log.Info("inventree listening", "addr", addr, "db", dbPath)
|
|
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
|
return err
|
|
}
|
|
log.Info("server stopped")
|
|
return nil
|
|
}
|