update databasing system with minimum API, schema parsing and FreeCAD
integration
This commit is contained in:
313
cmd/silo/main.go
Normal file
313
cmd/silo/main.go
Normal file
@@ -0,0 +1,313 @@
|
||||
// Command silo provides CLI access to the Silo item database.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/kindredsystems/silo/internal/config"
|
||||
"github.com/kindredsystems/silo/internal/db"
|
||||
"github.com/kindredsystems/silo/internal/partnum"
|
||||
"github.com/kindredsystems/silo/internal/schema"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 2 {
|
||||
printUsage()
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
cmd := os.Args[1]
|
||||
|
||||
switch cmd {
|
||||
case "help", "-h", "--help":
|
||||
printUsage()
|
||||
case "register":
|
||||
cmdRegister(ctx)
|
||||
case "list":
|
||||
cmdList(ctx)
|
||||
case "show":
|
||||
cmdShow(ctx)
|
||||
case "revisions":
|
||||
cmdRevisions(ctx)
|
||||
case "schemas":
|
||||
cmdSchemas(ctx)
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "Unknown command: %s\n", cmd)
|
||||
printUsage()
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func printUsage() {
|
||||
fmt.Println(`Silo - Item database CLI
|
||||
|
||||
Usage: silo <command> [options]
|
||||
|
||||
Commands:
|
||||
register Generate a new part number and create item
|
||||
list List items
|
||||
show Show item details
|
||||
revisions Show item revision history
|
||||
schemas List available schemas
|
||||
|
||||
Examples:
|
||||
silo register --schema kindred-rd --project PROTO --type AS
|
||||
silo list --type assembly
|
||||
silo show PROTO-AS-0001
|
||||
silo revisions PROTO-AS-0001`)
|
||||
}
|
||||
|
||||
func loadConfig() *config.Config {
|
||||
cfg, err := config.Load("config.yaml")
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error loading config: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
func connectDB(ctx context.Context, cfg *config.Config) *db.DB {
|
||||
database, err := db.Connect(ctx, db.Config{
|
||||
Host: cfg.Database.Host,
|
||||
Port: cfg.Database.Port,
|
||||
Name: cfg.Database.Name,
|
||||
User: cfg.Database.User,
|
||||
Password: cfg.Database.Password,
|
||||
SSLMode: cfg.Database.SSLMode,
|
||||
MaxConnections: cfg.Database.MaxConnections,
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error connecting to database: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
return database
|
||||
}
|
||||
|
||||
func cmdRegister(ctx context.Context) {
|
||||
// Parse flags
|
||||
var schemaName, project, partType, description string
|
||||
args := os.Args[2:]
|
||||
for i := 0; i < len(args); i++ {
|
||||
switch args[i] {
|
||||
case "--schema", "-s":
|
||||
i++
|
||||
schemaName = args[i]
|
||||
case "--project", "-p":
|
||||
i++
|
||||
project = args[i]
|
||||
case "--type", "-t":
|
||||
i++
|
||||
partType = args[i]
|
||||
case "--description", "-d":
|
||||
i++
|
||||
description = args[i]
|
||||
}
|
||||
}
|
||||
|
||||
if schemaName == "" || project == "" || partType == "" {
|
||||
fmt.Fprintln(os.Stderr, "Usage: silo register --schema NAME --project CODE --type TYPE [--description DESC]")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
cfg := loadConfig()
|
||||
database := connectDB(ctx, cfg)
|
||||
defer database.Close()
|
||||
|
||||
// Load schemas
|
||||
schemas, err := schema.LoadAll(cfg.Schemas.Directory)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error loading schemas: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Create generator with DB-backed sequence store
|
||||
seqStore := &dbSequenceStore{db: database, schemas: schemas}
|
||||
gen := partnum.NewGenerator(schemas, seqStore)
|
||||
|
||||
// Generate part number
|
||||
pn, err := gen.Generate(ctx, partnum.Input{
|
||||
SchemaName: schemaName,
|
||||
Values: map[string]string{
|
||||
"project": project,
|
||||
"part_type": partType,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error generating part number: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Create item in database
|
||||
repo := db.NewItemRepository(database)
|
||||
schemaRecord := schemas[schemaName]
|
||||
schemaID := getSchemaID(ctx, database, schemaRecord.Name)
|
||||
|
||||
item := &db.Item{
|
||||
PartNumber: pn,
|
||||
SchemaID: schemaID,
|
||||
ItemType: mapPartType(partType),
|
||||
Description: description,
|
||||
}
|
||||
|
||||
if err := repo.Create(ctx, item, map[string]any{
|
||||
"project": project,
|
||||
"part_type": partType,
|
||||
}); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error creating item: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Println(pn)
|
||||
}
|
||||
|
||||
func cmdList(ctx context.Context) {
|
||||
cfg := loadConfig()
|
||||
database := connectDB(ctx, cfg)
|
||||
defer database.Close()
|
||||
|
||||
var itemType, search string
|
||||
args := os.Args[2:]
|
||||
for i := 0; i < len(args); i++ {
|
||||
switch args[i] {
|
||||
case "--type", "-t":
|
||||
i++
|
||||
itemType = args[i]
|
||||
case "--search", "-s":
|
||||
i++
|
||||
search = args[i]
|
||||
}
|
||||
}
|
||||
|
||||
repo := db.NewItemRepository(database)
|
||||
items, err := repo.List(ctx, db.ListOptions{
|
||||
ItemType: itemType,
|
||||
Search: search,
|
||||
Limit: 100,
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error listing items: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
for _, item := range items {
|
||||
fmt.Printf("%s\t%s\t%s\n", item.PartNumber, item.ItemType, item.Description)
|
||||
}
|
||||
}
|
||||
|
||||
func cmdShow(ctx context.Context) {
|
||||
if len(os.Args) < 3 {
|
||||
fmt.Fprintln(os.Stderr, "Usage: silo show PART-NUMBER")
|
||||
os.Exit(1)
|
||||
}
|
||||
partNumber := os.Args[2]
|
||||
|
||||
cfg := loadConfig()
|
||||
database := connectDB(ctx, cfg)
|
||||
defer database.Close()
|
||||
|
||||
repo := db.NewItemRepository(database)
|
||||
item, err := repo.GetByPartNumber(ctx, partNumber)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if item == nil {
|
||||
fmt.Fprintf(os.Stderr, "Item not found: %s\n", partNumber)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
out, _ := json.MarshalIndent(item, "", " ")
|
||||
fmt.Println(string(out))
|
||||
}
|
||||
|
||||
func cmdRevisions(ctx context.Context) {
|
||||
if len(os.Args) < 3 {
|
||||
fmt.Fprintln(os.Stderr, "Usage: silo revisions PART-NUMBER")
|
||||
os.Exit(1)
|
||||
}
|
||||
partNumber := os.Args[2]
|
||||
|
||||
cfg := loadConfig()
|
||||
database := connectDB(ctx, cfg)
|
||||
defer database.Close()
|
||||
|
||||
repo := db.NewItemRepository(database)
|
||||
item, err := repo.GetByPartNumber(ctx, partNumber)
|
||||
if err != nil || item == nil {
|
||||
fmt.Fprintf(os.Stderr, "Item not found: %s\n", partNumber)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
revisions, err := repo.GetRevisions(ctx, item.ID)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
for _, rev := range revisions {
|
||||
comment := ""
|
||||
if rev.Comment != nil {
|
||||
comment = *rev.Comment
|
||||
}
|
||||
fmt.Printf("Rev %d\t%s\t%s\n", rev.RevisionNumber, rev.CreatedAt.Format("2006-01-02 15:04"), comment)
|
||||
}
|
||||
}
|
||||
|
||||
func cmdSchemas(ctx context.Context) {
|
||||
cfg := loadConfig()
|
||||
schemas, err := schema.LoadAll(cfg.Schemas.Directory)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error loading schemas: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
for name, s := range schemas {
|
||||
fmt.Printf("%s\tv%d\t%s\n", name, s.Version, s.Description)
|
||||
}
|
||||
}
|
||||
|
||||
// dbSequenceStore implements partnum.SequenceStore using the database.
|
||||
type dbSequenceStore struct {
|
||||
db *db.DB
|
||||
schemas map[string]*schema.Schema
|
||||
}
|
||||
|
||||
func (s *dbSequenceStore) NextValue(ctx context.Context, schemaName string, scope string) (int, error) {
|
||||
schemaID := getSchemaID(ctx, s.db, schemaName)
|
||||
if schemaID == nil {
|
||||
return 0, fmt.Errorf("schema not found: %s", schemaName)
|
||||
}
|
||||
return s.db.NextSequenceValue(ctx, *schemaID, scope)
|
||||
}
|
||||
|
||||
func getSchemaID(ctx context.Context, database *db.DB, name string) *string {
|
||||
var id string
|
||||
err := database.Pool().QueryRow(ctx,
|
||||
"SELECT id FROM schemas WHERE name = $1", name,
|
||||
).Scan(&id)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return &id
|
||||
}
|
||||
|
||||
func mapPartType(code string) string {
|
||||
types := map[string]string{
|
||||
"AS": "assembly",
|
||||
"PT": "part",
|
||||
"DW": "drawing",
|
||||
"DC": "document",
|
||||
"TB": "tooling",
|
||||
"PC": "purchased",
|
||||
"EL": "electrical",
|
||||
"SW": "software",
|
||||
}
|
||||
if t, ok := types[code]; ok {
|
||||
return t
|
||||
}
|
||||
return code
|
||||
}
|
||||
126
cmd/silod/main.go
Normal file
126
cmd/silod/main.go
Normal file
@@ -0,0 +1,126 @@
|
||||
// Command silod is the Silo HTTP API server.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/kindredsystems/silo/internal/api"
|
||||
"github.com/kindredsystems/silo/internal/config"
|
||||
"github.com/kindredsystems/silo/internal/db"
|
||||
"github.com/kindredsystems/silo/internal/schema"
|
||||
"github.com/kindredsystems/silo/internal/storage"
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Parse flags
|
||||
configPath := flag.String("config", "config.yaml", "Path to configuration file")
|
||||
flag.Parse()
|
||||
|
||||
// Setup logger
|
||||
logger := zerolog.New(os.Stdout).With().Timestamp().Logger()
|
||||
|
||||
// Load configuration
|
||||
cfg, err := config.Load(*configPath)
|
||||
if err != nil {
|
||||
logger.Fatal().Err(err).Msg("failed to load configuration")
|
||||
}
|
||||
|
||||
logger.Info().
|
||||
Str("host", cfg.Server.Host).
|
||||
Int("port", cfg.Server.Port).
|
||||
Str("database", cfg.Database.Host).
|
||||
Str("storage", cfg.Storage.Endpoint).
|
||||
Msg("starting silo server")
|
||||
|
||||
// Connect to database
|
||||
ctx := context.Background()
|
||||
database, err := db.Connect(ctx, db.Config{
|
||||
Host: cfg.Database.Host,
|
||||
Port: cfg.Database.Port,
|
||||
Name: cfg.Database.Name,
|
||||
User: cfg.Database.User,
|
||||
Password: cfg.Database.Password,
|
||||
SSLMode: cfg.Database.SSLMode,
|
||||
MaxConnections: cfg.Database.MaxConnections,
|
||||
})
|
||||
if err != nil {
|
||||
logger.Fatal().Err(err).Msg("failed to connect to database")
|
||||
}
|
||||
defer database.Close()
|
||||
logger.Info().Msg("connected to database")
|
||||
|
||||
// Connect to storage (optional - may be externally managed)
|
||||
var store *storage.Storage
|
||||
if cfg.Storage.Endpoint != "" {
|
||||
store, err = storage.Connect(ctx, storage.Config{
|
||||
Endpoint: cfg.Storage.Endpoint,
|
||||
AccessKey: cfg.Storage.AccessKey,
|
||||
SecretKey: cfg.Storage.SecretKey,
|
||||
Bucket: cfg.Storage.Bucket,
|
||||
UseSSL: cfg.Storage.UseSSL,
|
||||
Region: cfg.Storage.Region,
|
||||
})
|
||||
if err != nil {
|
||||
logger.Warn().Err(err).Msg("failed to connect to storage - file operations disabled")
|
||||
store = nil
|
||||
} else {
|
||||
logger.Info().Msg("connected to storage")
|
||||
}
|
||||
} else {
|
||||
logger.Info().Msg("storage not configured - file operations disabled")
|
||||
}
|
||||
|
||||
// Load schemas
|
||||
schemas, err := schema.LoadAll(cfg.Schemas.Directory)
|
||||
if err != nil {
|
||||
logger.Fatal().Err(err).Str("directory", cfg.Schemas.Directory).Msg("failed to load schemas")
|
||||
}
|
||||
logger.Info().Int("count", len(schemas)).Msg("loaded schemas")
|
||||
|
||||
// Create API server
|
||||
server := api.NewServer(logger, database, schemas, cfg.Schemas.Directory, store)
|
||||
router := api.NewRouter(server, logger)
|
||||
|
||||
// Create HTTP server
|
||||
addr := fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.Port)
|
||||
httpServer := &http.Server{
|
||||
Addr: addr,
|
||||
Handler: router,
|
||||
ReadTimeout: 15 * time.Second,
|
||||
WriteTimeout: 15 * time.Second,
|
||||
IdleTimeout: 60 * time.Second,
|
||||
}
|
||||
|
||||
// Start server in goroutine
|
||||
go func() {
|
||||
logger.Info().Str("addr", addr).Msg("listening")
|
||||
if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
logger.Fatal().Err(err).Msg("server error")
|
||||
}
|
||||
}()
|
||||
|
||||
// Wait for interrupt signal
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-quit
|
||||
|
||||
logger.Info().Msg("shutting down server")
|
||||
|
||||
// Graceful shutdown with timeout
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := httpServer.Shutdown(shutdownCtx); err != nil {
|
||||
logger.Fatal().Err(err).Msg("server forced to shutdown")
|
||||
}
|
||||
|
||||
logger.Info().Msg("server stopped")
|
||||
}
|
||||
Reference in New Issue
Block a user