feat(admin): module skeleton, config and two database connections

Adds the admin module: env config with fail-fast validation, two MongoDB
connections (its own vantage_admin database plus a narrow path into the
control plane), the boot sequence and the image.

Config refuses to start without a signing key, and both Mongo URIs must
name their database inline -- admin talks to two databases, so a bare
MONGO_DB would be ambiguous about which.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mrhid6
2026-07-25 18:58:44 +01:00
co-authored by Claude Opus 5
parent c4f1684304
commit 64eac6dbc7
8 changed files with 332 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
.env
*.lic
+30
View File
@@ -0,0 +1,30 @@
# Context is the repository root; admin depends on the shared module.
FROM golang:1.26-alpine AS builder
WORKDIR /src
COPY shared/go.mod shared/go.sum ./shared/
COPY admin/go.mod admin/go.sum ./admin/
RUN cd admin && go mod download
COPY shared/ ./shared/
COPY admin/ ./admin/
RUN cd admin && CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /out/admin ./cmd
RUN cd admin && CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /out/adminctl ./cmd/adminctl
FROM alpine:3.20 AS runner
RUN apk add --no-cache ca-certificates && \
addgroup --system --gid 1001 admin && \
adduser --system --uid 1001 --ingroup admin admin
COPY --from=builder /out/admin /usr/local/bin/admin
COPY --from=builder /out/adminctl /usr/local/bin/adminctl
USER admin
EXPOSE 8083
ENV PORT=8083
CMD ["/usr/local/bin/admin"]
+59
View File
@@ -0,0 +1,59 @@
package main
import (
"context"
"errors"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/joho/godotenv"
"github.com/mrhid6/vantage/admin/internal/config"
"github.com/mrhid6/vantage/admin/internal/db"
)
func main() {
godotenv.Load()
cfg, err := config.Load()
if err != nil {
log.Fatalf("configuration error: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
if err := db.Connect(ctx, cfg); err != nil {
cancel()
log.Fatalf("database: %v", err)
}
cancel()
log.Printf("connected: admin=%s control=%s", cfg.AdminDBName, cfg.ControlDBName)
srv := &http.Server{
Addr: cfg.Addr,
Handler: http.NotFoundHandler(), // replaced in Task 8
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 20 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 60 * time.Second,
}
go func() {
log.Printf("admin listening on %s", cfg.Addr)
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("server error: %v", err)
}
}()
stopCtx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
<-stopCtx.Done()
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 15*time.Second)
defer shutdownCancel()
_ = srv.Shutdown(shutdownCtx)
log.Println("admin stopped")
os.Exit(0)
}
+21
View File
@@ -0,0 +1,21 @@
module github.com/mrhid6/vantage/admin
go 1.26.4
require (
github.com/joho/godotenv v1.5.1
go.mongodb.org/mongo-driver/v2 v2.8.0
)
require (
github.com/klauspost/compress v1.17.6 // indirect
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
github.com/xdg-go/scram v1.2.0 // indirect
github.com/xdg-go/stringprep v1.0.4 // indirect
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
golang.org/x/crypto v0.54.0 // indirect
golang.org/x/sync v0.22.0 // indirect
golang.org/x/text v0.40.0 // indirect
)
replace github.com/mrhid6/vantage/shared => ../shared
+48
View File
@@ -0,0 +1,48 @@
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/klauspost/compress v1.17.6 h1:60eq2E/jlfwQXtvZEeBUYADs+BwKBWURIY+Gj2eRGjI=
github.com/klauspost/compress v1.17.6/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs=
github.com/xdg-go/scram v1.2.0/go.mod h1:3dlrS0iBaWKYVt2ZfA4cj48umJZ+cAEbR6/SjLA88I8=
github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8=
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.mongodb.org/mongo-driver/v2 v2.8.0 h1:CxWDGQYY8QQwNjAl/aq2sfWakdnWZynnqJ9F4DhHbP8=
go.mongodb.org/mongo-driver/v2 v2.8.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+110
View File
@@ -0,0 +1,110 @@
// Package config parses and validates admin's environment.
//
// Everything required is checked at boot and the process refuses to start
// without it. A licensing service that cannot sign is worse than one that is
// down, because it looks healthy.
package config
import (
"fmt"
"net/url"
"os"
"strings"
)
type Config struct {
AdminMongoURI string
AdminDBName string
ControlMongoURI string
ControlDBName string
RedisAddr string
SigningKey string
PublicURL string
AllowedOrigins []string
TrustProxy bool
Addr string
SMTPHost string
SMTPPort string
SMTPFrom string
SMTPUsername string
SMTPPassword string
}
// dbNameFromURI reads the database from a Mongo URI path.
//
// Both URIs must name their database inline rather than through a separate
// variable. Admin talks to two databases; a bare MONGO_DB would be ambiguous
// about which, and guessing wrong means writing licence fields into the wrong
// place.
func dbNameFromURI(raw, which string) (string, error) {
u, err := url.Parse(raw)
if err != nil {
return "", fmt.Errorf("%s is not a valid URI: %w", which, err)
}
name := strings.TrimPrefix(u.Path, "/")
if name == "" {
return "", fmt.Errorf("%s must name a database in its path, e.g. mongodb://host:27017/vantage_admin", which)
}
return name, nil
}
func Load() (Config, error) {
c := Config{
AdminMongoURI: os.Getenv("ADMIN_MONGO_URI"),
ControlMongoURI: os.Getenv("CONTROL_MONGO_URI"),
RedisAddr: os.Getenv("REDIS_ADDR"),
SigningKey: os.Getenv("LICENSE_SIGNING_KEY"),
PublicURL: strings.TrimSuffix(os.Getenv("PUBLIC_URL"), "/"),
TrustProxy: strings.EqualFold(os.Getenv("TRUST_PROXY"), "true"),
Addr: ":" + envOr("PORT", "8083"),
SMTPHost: os.Getenv("SMTP_HOST"),
SMTPPort: envOr("SMTP_PORT", "587"),
SMTPFrom: os.Getenv("SMTP_FROM"),
SMTPUsername: os.Getenv("SMTP_USERNAME"),
SMTPPassword: os.Getenv("SMTP_PASSWORD"),
}
var missing []string
for name, v := range map[string]string{
"ADMIN_MONGO_URI": c.AdminMongoURI,
"CONTROL_MONGO_URI": c.ControlMongoURI,
"REDIS_ADDR": c.RedisAddr,
"LICENSE_SIGNING_KEY": c.SigningKey,
"PUBLIC_URL": c.PublicURL,
"ADMIN_ORIGIN": os.Getenv("ADMIN_ORIGIN"),
} {
if v == "" {
missing = append(missing, name)
}
}
if len(missing) > 0 {
return Config{}, fmt.Errorf("missing required environment: %s", strings.Join(missing, ", "))
}
var err error
if c.AdminDBName, err = dbNameFromURI(c.AdminMongoURI, "ADMIN_MONGO_URI"); err != nil {
return Config{}, err
}
if c.ControlDBName, err = dbNameFromURI(c.ControlMongoURI, "CONTROL_MONGO_URI"); err != nil {
return Config{}, err
}
if c.AdminMongoURI == c.ControlMongoURI {
return Config{}, fmt.Errorf("ADMIN_MONGO_URI and CONTROL_MONGO_URI must not be the same database")
}
for _, o := range strings.Split(os.Getenv("ADMIN_ORIGIN"), ",") {
if o = strings.TrimSpace(o); o != "" {
c.AllowedOrigins = append(c.AllowedOrigins, o)
}
}
return c, nil
}
func envOr(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
+61
View File
@@ -0,0 +1,61 @@
// Package db holds admin's two MongoDB connections.
//
// Admin() is its own database and it owns every collection there. Control() is
// the control plane's database, and admin's access to it is deliberately narrow:
// it reads `instances` and `users`, and writes exactly three licence fields on
// `instances`. Nothing here should ever grow a write path to another collection.
package db
import (
"context"
"fmt"
"time"
"github.com/mrhid6/vantage/admin/internal/config"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
var (
adminDB *mongo.Database
controlDB *mongo.Database
)
func Connect(ctx context.Context, cfg config.Config) error {
ac, err := mongo.Connect(options.Client().ApplyURI(cfg.AdminMongoURI))
if err != nil {
return fmt.Errorf("connect admin mongo: %w", err)
}
if err := ac.Ping(ctx, nil); err != nil {
return fmt.Errorf("ping admin mongo: %w", err)
}
adminDB = ac.Database(cfg.AdminDBName)
cc, err := mongo.Connect(options.Client().ApplyURI(cfg.ControlMongoURI))
if err != nil {
return fmt.Errorf("connect control mongo: %w", err)
}
if err := cc.Ping(ctx, nil); err != nil {
return fmt.Errorf("ping control mongo: %w", err)
}
controlDB = cc.Database(cfg.ControlDBName)
// The control plane must already be deployed and migrated. Without the
// instances collection, injection would silently create it and write
// licence fields into a collection nothing reads.
names, err := controlDB.ListCollectionNames(ctx, map[string]any{"name": "instances"})
if err != nil {
return fmt.Errorf("inspect control database: %w", err)
}
if len(names) == 0 {
return fmt.Errorf("control database %q has no instances collection; deploy and migrate the control plane first", cfg.ControlDBName)
}
return nil
}
func Admin(name string) *mongo.Collection { return adminDB.Collection(name) }
func Control(name string) *mongo.Collection { return controlDB.Collection(name) }
func Ctx() (context.Context, context.CancelFunc) {
return context.WithTimeout(context.Background(), 10*time.Second)
}
+1
View File
@@ -1,6 +1,7 @@
go 1.26.4
use (
./admin
./server
./shared
./sitesvc