P1.1: Registry package with full CRUD and tests

SQLite schema (services, components, ports, volumes, cmd, events),
migrations, and complete CRUD operations. 7 tests covering:
idempotent migration, service CRUD, duplicate name rejection,
component CRUD with ports/volumes/cmd, composite PK enforcement,
cascade delete, and event insert/query/count/prune.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-26 11:18:35 -07:00
parent 3f23b14ef4
commit 6122123064
8 changed files with 963 additions and 4 deletions

View File

@@ -7,7 +7,7 @@
## Phase 1: Core Libraries ## Phase 1: Core Libraries
- [ ] **P1.1** Registry package (`internal/registry/`) - [x] **P1.1** Registry package (`internal/registry/`)
- [ ] **P1.2** Runtime package (`internal/runtime/`) - [ ] **P1.2** Runtime package (`internal/runtime/`)
- [ ] **P1.3** Service definition package (`internal/servicedef/`) - [ ] **P1.3** Service definition package (`internal/servicedef/`)
- [ ] **P1.4** Config package (`internal/config/`) - [ ] **P1.4** Config package (`internal/config/`)

11
go.mod
View File

@@ -6,13 +6,22 @@ require (
github.com/spf13/cobra v1.10.2 github.com/spf13/cobra v1.10.2
google.golang.org/grpc v1.79.3 google.golang.org/grpc v1.79.3
google.golang.org/protobuf v1.36.11 google.golang.org/protobuf v1.36.11
modernc.org/sqlite v1.47.0
) )
require ( require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/spf13/pflag v1.0.9 // indirect github.com/spf13/pflag v1.0.9 // indirect
golang.org/x/net v0.48.0 // indirect golang.org/x/net v0.48.0 // indirect
golang.org/x/sys v0.39.0 // indirect golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.32.0 // indirect golang.org/x/text v0.32.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
modernc.org/libc v1.70.0 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
) )

51
go.sum
View File

@@ -1,6 +1,8 @@
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
@@ -9,10 +11,20 @@ github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
@@ -31,12 +43,19 @@ go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5w
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww= google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww=
@@ -46,3 +65,31 @@ google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhH
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw=
modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw=
modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.47.0 h1:R1XyaNpoW4Et9yly+I2EeX7pBza/w+pmYee/0HJDyKk=
modernc.org/sqlite v1.47.0/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=

View File

@@ -0,0 +1,276 @@
package registry
import (
"database/sql"
"fmt"
"time"
)
// Component represents a component in the registry.
type Component struct {
Name string
Service string
Image string
Network string
UserSpec string
Restart string
DesiredState string
ObservedState string
Version string
Ports []string
Volumes []string
Cmd []string
CreatedAt time.Time
UpdatedAt time.Time
}
// CreateComponent creates a new component in the registry.
func CreateComponent(db *sql.DB, c *Component) error {
tx, err := db.Begin()
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback() //nolint:errcheck
_, err = tx.Exec(`
INSERT INTO components (name, service, image, network, user_spec, restart, desired_state, observed_state, version)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
c.Name, c.Service, c.Image, c.Network, c.UserSpec, c.Restart,
c.DesiredState, c.ObservedState, c.Version,
)
if err != nil {
return fmt.Errorf("insert component %q/%q: %w", c.Service, c.Name, err)
}
if err := setPorts(tx, c.Service, c.Name, c.Ports); err != nil {
return err
}
if err := setVolumes(tx, c.Service, c.Name, c.Volumes); err != nil {
return err
}
if err := setCmd(tx, c.Service, c.Name, c.Cmd); err != nil {
return err
}
return tx.Commit()
}
// GetComponent retrieves a component by service and name.
func GetComponent(db *sql.DB, service, name string) (*Component, error) {
c := &Component{}
var createdAt, updatedAt string
err := db.QueryRow(`
SELECT name, service, image, network, user_spec, restart,
desired_state, observed_state, version, created_at, updated_at
FROM components WHERE service = ? AND name = ?`,
service, name,
).Scan(&c.Name, &c.Service, &c.Image, &c.Network, &c.UserSpec, &c.Restart,
&c.DesiredState, &c.ObservedState, &c.Version, &createdAt, &updatedAt)
if err != nil {
return nil, fmt.Errorf("get component %q/%q: %w", service, name, err)
}
c.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
c.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updatedAt)
c.Ports, err = getPorts(db, service, name)
if err != nil {
return nil, err
}
c.Volumes, err = getVolumes(db, service, name)
if err != nil {
return nil, err
}
c.Cmd, err = getCmd(db, service, name)
if err != nil {
return nil, err
}
return c, nil
}
// ListComponents returns all components for a service.
func ListComponents(db *sql.DB, service string) ([]Component, error) {
rows, err := db.Query(`
SELECT name, service, image, network, user_spec, restart,
desired_state, observed_state, version, created_at, updated_at
FROM components WHERE service = ? ORDER BY name`,
service,
)
if err != nil {
return nil, fmt.Errorf("list components for %q: %w", service, err)
}
defer func() { _ = rows.Close() }()
var components []Component
for rows.Next() {
var c Component
var createdAt, updatedAt string
if err := rows.Scan(&c.Name, &c.Service, &c.Image, &c.Network, &c.UserSpec, &c.Restart,
&c.DesiredState, &c.ObservedState, &c.Version, &createdAt, &updatedAt); err != nil {
return nil, fmt.Errorf("scan component: %w", err)
}
c.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
c.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updatedAt)
c.Ports, _ = getPorts(db, c.Service, c.Name)
c.Volumes, _ = getVolumes(db, c.Service, c.Name)
c.Cmd, _ = getCmd(db, c.Service, c.Name)
components = append(components, c)
}
return components, rows.Err()
}
// UpdateComponentState updates desired and/or observed state.
func UpdateComponentState(db *sql.DB, service, name, desiredState, observedState string) error {
res, err := db.Exec(`
UPDATE components
SET desired_state = CASE WHEN ? = '' THEN desired_state ELSE ? END,
observed_state = CASE WHEN ? = '' THEN observed_state ELSE ? END,
updated_at = datetime('now')
WHERE service = ? AND name = ?`,
desiredState, desiredState, observedState, observedState, service, name,
)
if err != nil {
return fmt.Errorf("update state %q/%q: %w", service, name, err)
}
n, _ := res.RowsAffected()
if n == 0 {
return fmt.Errorf("update state %q/%q: %w", service, name, sql.ErrNoRows)
}
return nil
}
// UpdateComponentSpec updates the full spec for an existing component.
func UpdateComponentSpec(db *sql.DB, c *Component) error {
tx, err := db.Begin()
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback() //nolint:errcheck
_, err = tx.Exec(`
UPDATE components
SET image = ?, network = ?, user_spec = ?, restart = ?, version = ?, updated_at = datetime('now')
WHERE service = ? AND name = ?`,
c.Image, c.Network, c.UserSpec, c.Restart, c.Version, c.Service, c.Name,
)
if err != nil {
return fmt.Errorf("update component %q/%q: %w", c.Service, c.Name, err)
}
if err := setPorts(tx, c.Service, c.Name, c.Ports); err != nil {
return err
}
if err := setVolumes(tx, c.Service, c.Name, c.Volumes); err != nil {
return err
}
if err := setCmd(tx, c.Service, c.Name, c.Cmd); err != nil {
return err
}
return tx.Commit()
}
// DeleteComponent removes a component.
func DeleteComponent(db *sql.DB, service, name string) error {
res, err := db.Exec("DELETE FROM components WHERE service = ? AND name = ?", service, name)
if err != nil {
return fmt.Errorf("delete component %q/%q: %w", service, name, err)
}
n, _ := res.RowsAffected()
if n == 0 {
return fmt.Errorf("delete component %q/%q: %w", service, name, sql.ErrNoRows)
}
return nil
}
// helper: set port mappings (delete + re-insert)
func setPorts(tx *sql.Tx, service, component string, ports []string) error {
if _, err := tx.Exec("DELETE FROM component_ports WHERE service = ? AND component = ?", service, component); err != nil {
return fmt.Errorf("clear ports %q/%q: %w", service, component, err)
}
for _, p := range ports {
if _, err := tx.Exec("INSERT INTO component_ports (service, component, mapping) VALUES (?, ?, ?)", service, component, p); err != nil {
return fmt.Errorf("insert port %q/%q %q: %w", service, component, p, err)
}
}
return nil
}
func getPorts(db *sql.DB, service, component string) ([]string, error) {
rows, err := db.Query("SELECT mapping FROM component_ports WHERE service = ? AND component = ? ORDER BY mapping", service, component)
if err != nil {
return nil, fmt.Errorf("get ports %q/%q: %w", service, component, err)
}
defer func() { _ = rows.Close() }()
var ports []string
for rows.Next() {
var p string
if err := rows.Scan(&p); err != nil {
return nil, err
}
ports = append(ports, p)
}
return ports, rows.Err()
}
// helper: set volume mappings
func setVolumes(tx *sql.Tx, service, component string, volumes []string) error {
if _, err := tx.Exec("DELETE FROM component_volumes WHERE service = ? AND component = ?", service, component); err != nil {
return fmt.Errorf("clear volumes %q/%q: %w", service, component, err)
}
for _, v := range volumes {
if _, err := tx.Exec("INSERT INTO component_volumes (service, component, mapping) VALUES (?, ?, ?)", service, component, v); err != nil {
return fmt.Errorf("insert volume %q/%q %q: %w", service, component, v, err)
}
}
return nil
}
func getVolumes(db *sql.DB, service, component string) ([]string, error) {
rows, err := db.Query("SELECT mapping FROM component_volumes WHERE service = ? AND component = ? ORDER BY mapping", service, component)
if err != nil {
return nil, fmt.Errorf("get volumes %q/%q: %w", service, component, err)
}
defer func() { _ = rows.Close() }()
var volumes []string
for rows.Next() {
var v string
if err := rows.Scan(&v); err != nil {
return nil, err
}
volumes = append(volumes, v)
}
return volumes, rows.Err()
}
// helper: set command args
func setCmd(tx *sql.Tx, service, component string, cmd []string) error {
if _, err := tx.Exec("DELETE FROM component_cmd WHERE service = ? AND component = ?", service, component); err != nil {
return fmt.Errorf("clear cmd %q/%q: %w", service, component, err)
}
for i, arg := range cmd {
if _, err := tx.Exec("INSERT INTO component_cmd (service, component, position, arg) VALUES (?, ?, ?, ?)", service, component, i, arg); err != nil {
return fmt.Errorf("insert cmd %q/%q [%d]: %w", service, component, i, err)
}
}
return nil
}
func getCmd(db *sql.DB, service, component string) ([]string, error) {
rows, err := db.Query("SELECT arg FROM component_cmd WHERE service = ? AND component = ? ORDER BY position", service, component)
if err != nil {
return nil, fmt.Errorf("get cmd %q/%q: %w", service, component, err)
}
defer func() { _ = rows.Close() }()
var cmd []string
for rows.Next() {
var arg string
if err := rows.Scan(&arg); err != nil {
return nil, err
}
cmd = append(cmd, arg)
}
return cmd, rows.Err()
}

130
internal/registry/db.go Normal file
View File

@@ -0,0 +1,130 @@
package registry
import (
"database/sql"
"fmt"
_ "modernc.org/sqlite"
)
// Open opens the registry database at the given path and runs migrations.
func Open(path string) (*sql.DB, error) {
db, err := sql.Open("sqlite", path)
if err != nil {
return nil, fmt.Errorf("open database: %w", err)
}
for _, pragma := range []string{
"PRAGMA journal_mode = WAL",
"PRAGMA foreign_keys = ON",
"PRAGMA busy_timeout = 5000",
} {
if _, err := db.Exec(pragma); err != nil {
_ = db.Close()
return nil, fmt.Errorf("exec %q: %w", pragma, err)
}
}
if err := migrate(db); err != nil {
_ = db.Close()
return nil, fmt.Errorf("migrate: %w", err)
}
return db, nil
}
func migrate(db *sql.DB) error {
_, err := db.Exec(`
CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY,
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
);
`)
if err != nil {
return fmt.Errorf("create migrations table: %w", err)
}
for i, m := range migrations {
version := i + 1
var count int
if err := db.QueryRow("SELECT COUNT(*) FROM schema_migrations WHERE version = ?", version).Scan(&count); err != nil {
return fmt.Errorf("check migration %d: %w", version, err)
}
if count > 0 {
continue
}
if _, err := db.Exec(m); err != nil {
return fmt.Errorf("run migration %d: %w", version, err)
}
if _, err := db.Exec("INSERT INTO schema_migrations (version) VALUES (?)", version); err != nil {
return fmt.Errorf("record migration %d: %w", version, err)
}
}
return nil
}
var migrations = []string{
// Migration 1: initial schema
`
CREATE TABLE IF NOT EXISTS services (
name TEXT PRIMARY KEY,
active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS components (
name TEXT NOT NULL,
service TEXT NOT NULL REFERENCES services(name) ON DELETE CASCADE,
image TEXT NOT NULL,
network TEXT NOT NULL DEFAULT 'bridge',
user_spec TEXT NOT NULL DEFAULT '',
restart TEXT NOT NULL DEFAULT 'unless-stopped',
desired_state TEXT NOT NULL DEFAULT 'running',
observed_state TEXT NOT NULL DEFAULT 'unknown',
version TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (service, name)
);
CREATE TABLE IF NOT EXISTS component_ports (
service TEXT NOT NULL,
component TEXT NOT NULL,
mapping TEXT NOT NULL,
PRIMARY KEY (service, component, mapping),
FOREIGN KEY (service, component) REFERENCES components(service, name) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS component_volumes (
service TEXT NOT NULL,
component TEXT NOT NULL,
mapping TEXT NOT NULL,
PRIMARY KEY (service, component, mapping),
FOREIGN KEY (service, component) REFERENCES components(service, name) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS component_cmd (
service TEXT NOT NULL,
component TEXT NOT NULL,
position INTEGER NOT NULL,
arg TEXT NOT NULL,
PRIMARY KEY (service, component, position),
FOREIGN KEY (service, component) REFERENCES components(service, name) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
service TEXT NOT NULL,
component TEXT NOT NULL,
prev_state TEXT NOT NULL,
new_state TEXT NOT NULL,
timestamp TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_events_component_time
ON events(service, component, timestamp);
`,
}

View File

@@ -0,0 +1,96 @@
package registry
import (
"database/sql"
"fmt"
"time"
)
// Event represents a state-change event.
type Event struct {
ID int64
Service string
Component string
PrevState string
NewState string
Timestamp time.Time
}
// InsertEvent records a state transition.
func InsertEvent(db *sql.DB, service, component, prevState, newState string) error {
_, err := db.Exec(
"INSERT INTO events (service, component, prev_state, new_state) VALUES (?, ?, ?, ?)",
service, component, prevState, newState,
)
if err != nil {
return fmt.Errorf("insert event %q/%q: %w", service, component, err)
}
return nil
}
// QueryEvents returns events for a service/component within a time window.
// If service is empty, returns events for all services.
// If component is empty, returns events for all components in the service.
func QueryEvents(db *sql.DB, service, component string, since time.Time, limit int) ([]Event, error) {
query := "SELECT id, service, component, prev_state, new_state, timestamp FROM events WHERE timestamp > ?"
args := []any{since.UTC().Format("2006-01-02 15:04:05")}
if service != "" {
query += " AND service = ?"
args = append(args, service)
}
if component != "" {
query += " AND component = ?"
args = append(args, component)
}
query += " ORDER BY timestamp DESC"
if limit > 0 {
query += " LIMIT ?"
args = append(args, limit)
}
rows, err := db.Query(query, args...)
if err != nil {
return nil, fmt.Errorf("query events: %w", err)
}
defer func() { _ = rows.Close() }()
var events []Event
for rows.Next() {
var e Event
var ts string
if err := rows.Scan(&e.ID, &e.Service, &e.Component, &e.PrevState, &e.NewState, &ts); err != nil {
return nil, fmt.Errorf("scan event: %w", err)
}
e.Timestamp, _ = time.Parse("2006-01-02 15:04:05", ts)
events = append(events, e)
}
return events, rows.Err()
}
// CountEvents counts events for a component within a time window matching
// a specific new_state. Used for flap detection.
func CountEvents(db *sql.DB, service, component string, since time.Time) (int, error) {
var count int
err := db.QueryRow(
"SELECT COUNT(*) FROM events WHERE service = ? AND component = ? AND timestamp > ?",
service, component, since.UTC().Format("2006-01-02 15:04:05"),
).Scan(&count)
if err != nil {
return 0, fmt.Errorf("count events %q/%q: %w", service, component, err)
}
return count, nil
}
// PruneEvents deletes events older than the given time.
func PruneEvents(db *sql.DB, before time.Time) (int64, error) {
res, err := db.Exec(
"DELETE FROM events WHERE timestamp < ?",
before.UTC().Format("2006-01-02 15:04:05"),
)
if err != nil {
return 0, fmt.Errorf("prune events: %w", err)
}
return res.RowsAffected()
}

View File

@@ -0,0 +1,307 @@
package registry
import (
"database/sql"
"errors"
"path/filepath"
"testing"
"time"
)
func openTestDB(t *testing.T) *sql.DB {
t.Helper()
db, err := Open(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatalf("open db: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
return db
}
func TestMigrationIdempotent(t *testing.T) {
path := filepath.Join(t.TempDir(), "test.db")
db1, err := Open(path)
if err != nil {
t.Fatalf("first open: %v", err)
}
_ = db1.Close()
db2, err := Open(path)
if err != nil {
t.Fatalf("second open: %v", err)
}
_ = db2.Close()
}
func TestServiceCRUD(t *testing.T) {
db := openTestDB(t)
// Create
if err := CreateService(db, "metacrypt", true); err != nil {
t.Fatalf("create: %v", err)
}
// Get
s, err := GetService(db, "metacrypt")
if err != nil {
t.Fatalf("get: %v", err)
}
if s.Name != "metacrypt" || !s.Active {
t.Fatalf("got %+v", s)
}
// List
services, err := ListServices(db)
if err != nil {
t.Fatalf("list: %v", err)
}
if len(services) != 1 || services[0].Name != "metacrypt" {
t.Fatalf("list: got %d services", len(services))
}
// Update active
if err := UpdateServiceActive(db, "metacrypt", false); err != nil {
t.Fatalf("update: %v", err)
}
s, _ = GetService(db, "metacrypt")
if s.Active {
t.Fatal("expected inactive")
}
// Delete
if err := DeleteService(db, "metacrypt"); err != nil {
t.Fatalf("delete: %v", err)
}
_, err = GetService(db, "metacrypt")
if !errors.Is(err, sql.ErrNoRows) {
t.Fatalf("expected ErrNoRows after delete, got: %v", err)
}
}
func TestServiceDuplicateName(t *testing.T) {
db := openTestDB(t)
if err := CreateService(db, "metacrypt", true); err != nil {
t.Fatalf("first create: %v", err)
}
if err := CreateService(db, "metacrypt", true); err == nil {
t.Fatal("expected error on duplicate name")
}
}
func TestComponentCRUD(t *testing.T) {
db := openTestDB(t)
if err := CreateService(db, "metacrypt", true); err != nil {
t.Fatalf("create service: %v", err)
}
// Create component
c := &Component{
Name: "api",
Service: "metacrypt",
Image: "mcr.svc.mcp.metacircular.net:8443/metacrypt:v1.0.0",
Network: "docker_default",
UserSpec: "0:0",
Restart: "unless-stopped",
DesiredState: "running",
ObservedState: "unknown",
Ports: []string{"127.0.0.1:18443:8443", "127.0.0.1:19443:9443"},
Volumes: []string{"/srv/metacrypt:/srv/metacrypt"},
Cmd: nil,
}
if err := CreateComponent(db, c); err != nil {
t.Fatalf("create component: %v", err)
}
// Get
got, err := GetComponent(db, "metacrypt", "api")
if err != nil {
t.Fatalf("get: %v", err)
}
if got.Image != c.Image {
t.Fatalf("image: got %q, want %q", got.Image, c.Image)
}
if len(got.Ports) != 2 {
t.Fatalf("ports: got %d, want 2", len(got.Ports))
}
if len(got.Volumes) != 1 {
t.Fatalf("volumes: got %d, want 1", len(got.Volumes))
}
// Create a second component
c2 := &Component{
Name: "web",
Service: "metacrypt",
Image: "mcr.svc.mcp.metacircular.net:8443/metacrypt-web:v1.0.0",
Network: "docker_default",
Restart: "unless-stopped",
DesiredState: "running",
ObservedState: "unknown",
Ports: []string{"127.0.0.1:18080:8080"},
Volumes: []string{"/srv/metacrypt:/srv/metacrypt"},
Cmd: []string{"server", "--config", "/srv/metacrypt/metacrypt.toml"},
}
if err := CreateComponent(db, c2); err != nil {
t.Fatalf("create web: %v", err)
}
// List
components, err := ListComponents(db, "metacrypt")
if err != nil {
t.Fatalf("list: %v", err)
}
if len(components) != 2 {
t.Fatalf("list: got %d, want 2", len(components))
}
// Verify cmd
got2, _ := GetComponent(db, "metacrypt", "web")
if len(got2.Cmd) != 3 || got2.Cmd[0] != "server" {
t.Fatalf("cmd: got %v", got2.Cmd)
}
// Update state
if err := UpdateComponentState(db, "metacrypt", "api", "", "running"); err != nil {
t.Fatalf("update state: %v", err)
}
got, _ = GetComponent(db, "metacrypt", "api")
if got.ObservedState != "running" {
t.Fatalf("observed: got %q, want running", got.ObservedState)
}
if got.DesiredState != "running" {
t.Fatalf("desired should be unchanged: got %q", got.DesiredState)
}
// Update spec
c.Image = "mcr.svc.mcp.metacircular.net:8443/metacrypt:v2.0.0"
c.Version = "v2.0.0"
c.Ports = []string{"127.0.0.1:18443:8443"}
if err := UpdateComponentSpec(db, c); err != nil {
t.Fatalf("update spec: %v", err)
}
got, _ = GetComponent(db, "metacrypt", "api")
if got.Image != c.Image {
t.Fatalf("updated image: got %q", got.Image)
}
if len(got.Ports) != 1 {
t.Fatalf("updated ports: got %d, want 1", len(got.Ports))
}
// Delete component
if err := DeleteComponent(db, "metacrypt", "web"); err != nil {
t.Fatalf("delete: %v", err)
}
components, _ = ListComponents(db, "metacrypt")
if len(components) != 1 {
t.Fatalf("after delete: got %d, want 1", len(components))
}
}
func TestComponentCompositePK(t *testing.T) {
db := openTestDB(t)
if err := CreateService(db, "metacrypt", true); err != nil {
t.Fatalf("create service: %v", err)
}
c := &Component{Name: "api", Service: "metacrypt", Image: "img:v1", Restart: "unless-stopped", DesiredState: "running", ObservedState: "unknown"}
if err := CreateComponent(db, c); err != nil {
t.Fatalf("first create: %v", err)
}
if err := CreateComponent(db, c); err == nil {
t.Fatal("expected error on duplicate (service, name)")
}
}
func TestCascadeDelete(t *testing.T) {
db := openTestDB(t)
if err := CreateService(db, "metacrypt", true); err != nil {
t.Fatalf("create service: %v", err)
}
c := &Component{
Name: "api", Service: "metacrypt", Image: "img:v1",
Restart: "unless-stopped", DesiredState: "running", ObservedState: "unknown",
Ports: []string{"8443:8443"}, Volumes: []string{"/srv/metacrypt:/srv/metacrypt"},
}
if err := CreateComponent(db, c); err != nil {
t.Fatalf("create component: %v", err)
}
// Delete service should cascade to components, ports, volumes
if err := DeleteService(db, "metacrypt"); err != nil {
t.Fatalf("delete service: %v", err)
}
components, _ := ListComponents(db, "metacrypt")
if len(components) != 0 {
t.Fatalf("components should be empty after cascade, got %d", len(components))
}
}
func TestEvents(t *testing.T) {
db := openTestDB(t)
// Insert events
if err := InsertEvent(db, "metacrypt", "api", "unknown", "running"); err != nil {
t.Fatalf("insert: %v", err)
}
if err := InsertEvent(db, "metacrypt", "api", "running", "exited"); err != nil {
t.Fatalf("insert: %v", err)
}
if err := InsertEvent(db, "mcr", "api", "unknown", "running"); err != nil {
t.Fatalf("insert: %v", err)
}
// Query all recent
events, err := QueryEvents(db, "", "", time.Now().Add(-1*time.Hour), 0)
if err != nil {
t.Fatalf("query: %v", err)
}
if len(events) != 3 {
t.Fatalf("got %d events, want 3", len(events))
}
// Query by service
events, _ = QueryEvents(db, "metacrypt", "", time.Now().Add(-1*time.Hour), 0)
if len(events) != 2 {
t.Fatalf("by service: got %d, want 2", len(events))
}
// Query by component
events, _ = QueryEvents(db, "metacrypt", "api", time.Now().Add(-1*time.Hour), 0)
if len(events) != 2 {
t.Fatalf("by component: got %d, want 2", len(events))
}
// Query with limit
events, _ = QueryEvents(db, "", "", time.Now().Add(-1*time.Hour), 1)
if len(events) != 1 {
t.Fatalf("with limit: got %d, want 1", len(events))
}
// Count
count, err := CountEvents(db, "metacrypt", "api", time.Now().Add(-1*time.Hour))
if err != nil {
t.Fatalf("count: %v", err)
}
if count != 2 {
t.Fatalf("count: got %d, want 2", count)
}
// Prune (prune nothing since events are recent)
pruned, err := PruneEvents(db, time.Now().Add(-1*time.Hour))
if err != nil {
t.Fatalf("prune: %v", err)
}
if pruned != 0 {
t.Fatalf("pruned: got %d, want 0", pruned)
}
// Prune everything
pruned, err = PruneEvents(db, time.Now().Add(1*time.Hour))
if err != nil {
t.Fatalf("prune all: %v", err)
}
if pruned != 3 {
t.Fatalf("pruned all: got %d, want 3", pruned)
}
}

View File

@@ -0,0 +1,94 @@
package registry
import (
"database/sql"
"fmt"
"time"
)
// Service represents a service in the registry.
type Service struct {
Name string
Active bool
CreatedAt time.Time
UpdatedAt time.Time
}
// CreateService creates a new service in the registry.
func CreateService(db *sql.DB, name string, active bool) error {
_, err := db.Exec(
"INSERT INTO services (name, active) VALUES (?, ?)",
name, active,
)
if err != nil {
return fmt.Errorf("create service %q: %w", name, err)
}
return nil
}
// GetService retrieves a service by name.
func GetService(db *sql.DB, name string) (*Service, error) {
s := &Service{}
var createdAt, updatedAt string
err := db.QueryRow(
"SELECT name, active, created_at, updated_at FROM services WHERE name = ?",
name,
).Scan(&s.Name, &s.Active, &createdAt, &updatedAt)
if err != nil {
return nil, fmt.Errorf("get service %q: %w", name, err)
}
s.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
s.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updatedAt)
return s, nil
}
// ListServices returns all services.
func ListServices(db *sql.DB) ([]Service, error) {
rows, err := db.Query("SELECT name, active, created_at, updated_at FROM services ORDER BY name")
if err != nil {
return nil, fmt.Errorf("list services: %w", err)
}
defer func() { _ = rows.Close() }()
var services []Service
for rows.Next() {
var s Service
var createdAt, updatedAt string
if err := rows.Scan(&s.Name, &s.Active, &createdAt, &updatedAt); err != nil {
return nil, fmt.Errorf("scan service: %w", err)
}
s.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
s.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updatedAt)
services = append(services, s)
}
return services, rows.Err()
}
// UpdateServiceActive updates a service's active flag.
func UpdateServiceActive(db *sql.DB, name string, active bool) error {
res, err := db.Exec(
"UPDATE services SET active = ?, updated_at = datetime('now') WHERE name = ?",
active, name,
)
if err != nil {
return fmt.Errorf("update service %q: %w", name, err)
}
n, _ := res.RowsAffected()
if n == 0 {
return fmt.Errorf("update service %q: %w", name, sql.ErrNoRows)
}
return nil
}
// DeleteService removes a service and all its components (via CASCADE).
func DeleteService(db *sql.DB, name string) error {
res, err := db.Exec("DELETE FROM services WHERE name = ?", name)
if err != nil {
return fmt.Errorf("delete service %q: %w", name, err)
}
n, _ := res.RowsAffected()
if n == 0 {
return fmt.Errorf("delete service %q: %w", name, sql.ErrNoRows)
}
return nil
}