diff --git a/cmd/snot/serve.go b/cmd/snot/serve.go index 95a892a..aa1c389 100644 --- a/cmd/snot/serve.go +++ b/cmd/snot/serve.go @@ -40,15 +40,12 @@ func (c *ServeCmd) Run() error { } defer store.Close() - rkeys, err := state.LoadRkeyMap(cfg.StateDir) + db, err := state.Open(cfg.StateDir) if err != nil { return fmt.Errorf("state: %w", err) } - - dids, err := state.LoadRepoDids(cfg.StateDir) - if err != nil { - return fmt.Errorf("state (repodids): %w", err) - } + rkeys := db.Rkeys() + dids := db.RepoDids() scheme := "https" if cfg.Dev { diff --git a/default.nix b/default.nix index b54e6d3..2454c01 100644 --- a/default.nix +++ b/default.nix @@ -5,7 +5,7 @@ buildGoModule (finalAttrs: { src = ./.; - vendorHash = "sha256-mCjuJWTTQ6pRBO2hdYhDVxPLJ2s7BI25XbwAn3V2Klo="; + vendorHash = "sha256-uoHU3nySdQ/h1M9MWyrW/9h963byGV5AYvzW2xPkXyE="; subPackages = [ "cmd/snot" ]; diff --git a/go.mod b/go.mod index 168a035..8ffc73c 100644 --- a/go.mod +++ b/go.mod @@ -7,11 +7,15 @@ require ( github.com/bluekeyes/go-gitdiff v0.8.1 github.com/bluesky-social/indigo v0.0.0-20260220055544-bf41e2ee75ab github.com/cyphar/filepath-securejoin v0.6.1 + github.com/glebarez/sqlite v1.11.0 github.com/go-chi/chi/v5 v5.2.0 github.com/go-git/go-git/v5 v5.14.0 github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 github.com/jackc/pgx/v5 v5.10.0 - github.com/sethvargo/go-envconfig v1.3.0 + github.com/knadh/koanf/providers/confmap v1.0.0 + github.com/knadh/koanf/providers/env/v2 v2.0.0 + github.com/knadh/koanf/v2 v2.3.5 + gorm.io/gorm v1.31.1 tangled.org/core v0.0.0-20260612103734-ff6d47cfb983 ) @@ -56,6 +60,7 @@ require ( github.com/earthboundkid/versioninfo/v2 v2.24.1 // indirect github.com/emirpasic/gods v1.18.1 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/glebarez/go-sqlite v1.21.2 // indirect github.com/go-enry/go-enry/v2 v2.9.6 // indirect github.com/go-enry/go-oniguruma v1.2.1 // indirect github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect @@ -65,6 +70,7 @@ require ( github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-redis/cache/v9 v9.0.0 // indirect + github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/golang-jwt/jwt/v5 v5.3.0 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect @@ -95,9 +101,12 @@ require ( github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect github.com/kevinburke/ssh_config v1.2.0 // indirect github.com/klauspost/compress v1.18.0 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/knadh/koanf/maps v0.1.2 // indirect github.com/landlock-lsm/go-landlock v0.8.1 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect @@ -105,7 +114,9 @@ require ( github.com/mattn/go-sqlite3 v1.14.34 // indirect github.com/microcosm-cc/bluemonday v1.0.27 // indirect github.com/minio/sha256-simd v1.0.1 // indirect + github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/mr-tron/base58 v1.2.0 // indirect github.com/muesli/termenv v0.16.0 // indirect github.com/multiformats/go-base32 v0.1.0 // indirect @@ -125,9 +136,11 @@ require ( github.com/prometheus/common v0.67.5 // indirect github.com/prometheus/procfs v0.19.2 // indirect github.com/redis/go-redis/v9 v9.7.3 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/ryanuber/go-glob v1.0.0 // indirect github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect + github.com/sethvargo/go-envconfig v1.3.0 // indirect github.com/spaolacci/murmur3 v1.1.0 // indirect github.com/stretchr/testify v1.11.1 // indirect github.com/vmihailenco/go-tinylfu v0.2.2 // indirect @@ -164,4 +177,8 @@ require ( gopkg.in/yaml.v3 v3.0.1 // indirect kernel.org/pub/linux/libs/security/libcap/psx v1.2.77 // indirect lukechampine.com/blake3 v1.4.1 // indirect + modernc.org/libc v1.22.5 // indirect + modernc.org/mathutil v1.5.0 // indirect + modernc.org/memory v1.5.0 // indirect + modernc.org/sqlite v1.23.1 // indirect ) diff --git a/go.sum b/go.sum index b19a6a1..6421e3b 100644 --- a/go.sum +++ b/go.sum @@ -102,6 +102,10 @@ github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMo github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY= github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw= +github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo= +github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k= +github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw= +github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ= github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= github.com/go-chi/chi/v5 v5.2.0 h1:Aj1EtB0qR2Rdo2dG4O94RIU35w2lvQSj6BRA4+qwFL0= @@ -135,6 +139,8 @@ github.com/go-redis/cache/v9 v9.0.0/go.mod h1:cMwi1N8ASBOufbIvk7cdXe2PbPjK/WMRL9 github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= +github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= +github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/go-yaml/yaml v2.1.0+incompatible/go.mod h1:w2MrLa16VYP0jy6N7M5kHaCkaLENm+P+Tv+MfurjSw0= github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU= github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM= @@ -166,6 +172,8 @@ github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeN 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/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ= +github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= 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= @@ -240,6 +248,10 @@ github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0= github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= @@ -251,6 +263,14 @@ github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zt github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/knadh/koanf/maps v0.1.2 h1:RBfmAW5CnZT+PJ1CVc1QSJKf4Xu9kxfQgYVQSu8hpbo= +github.com/knadh/koanf/maps v0.1.2/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI= +github.com/knadh/koanf/providers/confmap v1.0.0 h1:mHKLJTE7iXEys6deO5p6olAiZdG5zwp8Aebir+/EaRE= +github.com/knadh/koanf/providers/confmap v1.0.0/go.mod h1:txHYHiI2hAtF0/0sCmcuol4IDcuQbKTybiB1nOcUo1A= +github.com/knadh/koanf/providers/env/v2 v2.0.0 h1:Ad5H3eun722u+FvchiIcEIJZsZ2M6oxCkgZfWN5B5KY= +github.com/knadh/koanf/providers/env/v2 v2.0.0/go.mod h1:1g01PE+Ve1gBfWNNw2wmULRP0tc8RJrjn5p2N/jNCIc= +github.com/knadh/koanf/v2 v2.3.5 h1:2dXJUYaKGm4SGYeoAtBviq9+02JZo/pxQ2ssOd60rJg= +github.com/knadh/koanf/v2 v2.3.5/go.mod h1:gRb40VRAbd4iJMYYD5IxZ6hfuopFcXBpc9bbQpZwo28= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -275,8 +295,12 @@ github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwX github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM= github.com/minio/sha256-simd v1.0.1/go.mod h1:Pz6AKMiUdngCLpeTL/RJY1M9rUuPMYujV5xJjtbRSN8= +github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= +github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= +github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o= github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= @@ -350,6 +374,9 @@ github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05Zp github.com/redis/go-redis/v9 v9.0.0-rc.4/go.mod h1:Vo3EsyWnicKnSKCA7HhgnvnyA74wOA69Cd2Meli5mmA= github.com/redis/go-redis/v9 v9.7.3 h1:YpPyAayJV+XErNsatSElgRZZVCwXX9QzkKYNvO7x0wM= github.com/redis/go-redis/v9 v9.7.3/go.mod h1:bGUrSggJ9X9GUmZpZNEOQKaANxSGgOEBRltRTZHSvrA= +github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +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/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= @@ -601,11 +628,21 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= +gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= kernel.org/pub/linux/libs/security/libcap/psx v1.2.77 h1:Z06sMOzc0GNCwp6efaVrIrz4ywGJ1v+DP0pjVkOfDuA= kernel.org/pub/linux/libs/security/libcap/psx v1.2.77/go.mod h1:+l6Ee2F59XiJ2I6WR5ObpC1utCQJZ/VLsEbQCD8RG24= lukechampine.com/blake3 v1.4.1 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg= lukechampine.com/blake3 v1.4.1/go.mod h1:QFosUxmjB8mnrWFSNwKmvxHpfY72bmD2tQ0kBMM3kwo= +modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE= +modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY= +modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ= +modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= +modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds= +modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU= +modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM= +modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk= tangled.org/core v0.0.0-20260612103734-ff6d47cfb983 h1:adp/YJS7cq6QI3xc3Ya/NB3KwWFFw0c/CxZvIDCTOvU= tangled.org/core v0.0.0-20260612103734-ff6d47cfb983/go.mod h1:DzlYk2UwbYk1I2CIn0x2wQAedlJLdatg3YKZpKHNWjU= tangled.sh/oppi.li/go-gitdiff v0.8.2 h1:pASJJNWaFn6EmEIUNNjHZQ3stRu6BqTO2YyjKvTcxIc= diff --git a/internal/config/config.go b/internal/config/config.go index 7370861..a403bf8 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -2,34 +2,118 @@ package config import ( "context" + "fmt" + "strconv" + "strings" - "github.com/sethvargo/go-envconfig" + "github.com/knadh/koanf/providers/confmap" + "github.com/knadh/koanf/providers/env/v2" + "github.com/knadh/koanf/v2" ) type Config struct { - Hostname string `env:"SNOT_HOSTNAME, required"` - ListenAddr string `env:"SNOT_LISTEN_ADDR, default=0.0.0.0:5555"` + Hostname string + ListenAddr string // OwnerDid identifies the knot's administrator alone (sh.tangled.owner, // knot registration). Repo ownership is governed by UserMap. - OwnerDid string `env:"SNOT_OWNER_DID, required"` + OwnerDid string // UserMap maps repo-owner DIDs to the Forgejo users whose repos they - // expose, e.g. SNOT_USER_MAP="did:plc:abc=isabel,did:plc:def=alice". - UserMap map[string]string `env:"SNOT_USER_MAP, required, separator=="` - - DbDsn string `env:"SNOT_DB_DSN, required"` - RepoRoot string `env:"SNOT_REPO_ROOT, required"` - PushRemote string `env:"SNOT_PUSH_REMOTE"` - StateDir string `env:"SNOT_STATE_DIR, default=/var/lib/snot"` - PlcUrl string `env:"SNOT_PLC_URL, default=https://plc.directory"` - Dev bool `env:"SNOT_DEV, default=false"` + // expose, parsed from SNOT_USER_MAP="did:plc:abc=isabel,did:plc:def=alice". + UserMap map[string]string + + DbDsn string + RepoRoot string + PushRemote string + StateDir string + PlcUrl string + Dev bool } +// Load reads configuration from SNOT_* environment variables. func Load(ctx context.Context) (*Config, error) { - var c Config - if err := envconfig.Process(ctx, &c); err != nil { + return loadWith(nil) +} + +// loadWith builds the config from the given environ source (nil = os.Environ), +// applying defaults first and SNOT_* env vars on top. +func loadWith(environ func() []string) (*Config, error) { + k := koanf.New(".") + + _ = k.Load(confmap.Provider(map[string]any{ + "listen_addr": "0.0.0.0:5555", + "state_dir": "/var/lib/snot", + "plc_url": "https://plc.directory", + "dev": "false", + }, "."), nil) + + err := k.Load(env.Provider(".", env.Opt{ + Prefix: "SNOT_", + EnvironFunc: environ, + TransformFunc: func(key, val string) (string, any) { + return strings.ToLower(strings.TrimPrefix(key, "SNOT_")), val + }, + }), nil) + if err != nil { + return nil, fmt.Errorf("loading env: %w", err) + } + + dev, _ := strconv.ParseBool(k.String("dev")) + cfg := &Config{ + Hostname: k.String("hostname"), + ListenAddr: k.String("listen_addr"), + OwnerDid: k.String("owner_did"), + UserMap: parseUserMap(k.String("user_map")), + DbDsn: k.String("db_dsn"), + RepoRoot: k.String("repo_root"), + PushRemote: k.String("push_remote"), + StateDir: k.String("state_dir"), + PlcUrl: k.String("plc_url"), + Dev: dev, + } + + if err := cfg.validate(); err != nil { return nil, err } - return &c, nil + return cfg, nil +} + +func parseUserMap(s string) map[string]string { + m := map[string]string{} + for _, pair := range strings.Split(s, ",") { + pair = strings.TrimSpace(pair) + if pair == "" { + continue + } + did, user, ok := strings.Cut(pair, "=") + if !ok { + continue + } + m[strings.TrimSpace(did)] = strings.TrimSpace(user) + } + return m +} + +func (c *Config) validate() error { + var missing []string + if c.Hostname == "" { + missing = append(missing, "SNOT_HOSTNAME") + } + if c.OwnerDid == "" { + missing = append(missing, "SNOT_OWNER_DID") + } + if len(c.UserMap) == 0 { + missing = append(missing, "SNOT_USER_MAP") + } + if c.DbDsn == "" { + missing = append(missing, "SNOT_DB_DSN") + } + if c.RepoRoot == "" { + missing = append(missing, "SNOT_REPO_ROOT") + } + if len(missing) > 0 { + return fmt.Errorf("missing required config: %s", strings.Join(missing, ", ")) + } + return nil } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index ff66542..03113b9 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -1,20 +1,16 @@ package config import ( - "context" "testing" - - "github.com/sethvargo/go-envconfig" ) func load(t *testing.T, env map[string]string) (*Config, error) { t.Helper() - var c Config - err := envconfig.ProcessWith(context.Background(), &envconfig.Config{ - Target: &c, - Lookuper: envconfig.MapLookuper(env), - }) - return &c, err + var environ []string + for k, v := range env { + environ = append(environ, k+"="+v) + } + return loadWith(func() []string { return environ }) } func TestLoadDefaults(t *testing.T) { @@ -42,6 +38,27 @@ func TestLoadDefaults(t *testing.T) { } } +func TestLoadOverrides(t *testing.T) { + c, err := load(t, map[string]string{ + "SNOT_HOSTNAME": "knot.example.com", + "SNOT_OWNER_DID": "did:plc:abc123", + "SNOT_USER_MAP": "did:plc:abc123=isabel", + "SNOT_DB_DSN": "postgres:///forgejo", + "SNOT_REPO_ROOT": "/repos", + "SNOT_LISTEN_ADDR": "127.0.0.1:9000", + "SNOT_DEV": "true", + }) + if err != nil { + t.Fatal(err) + } + if c.ListenAddr != "127.0.0.1:9000" { + t.Errorf("ListenAddr = %q", c.ListenAddr) + } + if !c.Dev { + t.Error("Dev should be true") + } +} + func TestLoadMissingRequired(t *testing.T) { if _, err := load(t, map[string]string{}); err == nil { t.Fatal("expected error for missing required vars") diff --git a/internal/state/migrate_test.go b/internal/state/migrate_test.go new file mode 100644 index 0000000..36dc620 --- /dev/null +++ b/internal/state/migrate_test.go @@ -0,0 +1,54 @@ +package state + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +func TestImportsLegacyJSON(t *testing.T) { + dir := t.TempDir() + + rkeys := map[string]string{"3l5tid": "demo"} + rb, _ := json.Marshal(rkeys) + if err := os.WriteFile(filepath.Join(dir, "rkeys.json"), rb, 0o600); err != nil { + t.Fatal(err) + } + + dids := map[string]RepoDidInfo{ + "did:plc:repo1": {User: "isabel", Repo: "demo", Key: []byte("plckey")}, + } + db, _ := json.Marshal(dids) + if err := os.WriteFile(filepath.Join(dir, "repodids.json"), db, 0o600); err != nil { + t.Fatal(err) + } + + d, err := Open(dir) + if err != nil { + t.Fatal(err) + } + + if got, ok := d.Rkeys().RepoByRkey("3l5tid"); !ok || got != "demo" { + t.Errorf("rkey not imported: %q %v", got, ok) + } + info, ok := d.RepoDids().Get("did:plc:repo1") + if !ok || info.Repo != "demo" || string(info.Key) != "plckey" { + t.Errorf("repodid not imported: %+v %v", info, ok) + } + + // Originals renamed to *.migrated (kept as backup, not re-imported). + for _, name := range []string{"rkeys.json", "repodids.json"} { + if _, err := os.Stat(filepath.Join(dir, name)); !os.IsNotExist(err) { + t.Errorf("%s should have been renamed away", name) + } + if _, err := os.Stat(filepath.Join(dir, name+".migrated")); err != nil { + t.Errorf("%s.migrated backup missing: %v", name, err) + } + } + + // Reopen: must not error or duplicate (table already populated, files gone). + if _, err := Open(dir); err != nil { + t.Fatalf("reopen after migration: %v", err) + } +} diff --git a/internal/state/repodids.go b/internal/state/repodids.go index c325883..5aeab5c 100644 --- a/internal/state/repodids.go +++ b/internal/state/repodids.go @@ -1,82 +1,50 @@ package state -import ( - "encoding/json" - "os" - "path/filepath" - "strings" - "sync" -) - -const repodidFile = "repodids.json" +import "gorm.io/gorm" // RepoDidInfo records a minted repo DID: which repo it names and the PLC // rotation/signing key that controls it. Losing the key means the DID's -// document can never be updated, so the state dir must be backed up. +// document can never be updated, so the state DB must be backed up. type RepoDidInfo struct { User string `json:"user"` // forgejo owner (lower) Repo string `json:"repo"` // repo name (lower) Key []byte `json:"key"` // raw private key bytes } +// RepoDids stores the per-repo did:plc identities the shim has minted. type RepoDids struct { - mu sync.Mutex - path string - m map[string]RepoDidInfo // keyed by DID + db *gorm.DB } -func LoadRepoDids(stateDir string) (*RepoDids, error) { - if err := os.MkdirAll(stateDir, 0o700); err != nil { - return nil, err - } - r := &RepoDids{ - path: filepath.Join(stateDir, repodidFile), - m: make(map[string]RepoDidInfo), - } - b, err := os.ReadFile(r.path) - if os.IsNotExist(err) { - return r, nil - } +// LoadRepoDids opens the state DB in dir and returns its repo-DID accessor. +func LoadRepoDids(dir string) (*RepoDids, error) { + db, err := Open(dir) if err != nil { return nil, err } - if err := json.Unmarshal(b, &r.m); err != nil { - return nil, err - } - return r, nil + return db.RepoDids(), nil } +// Put records (or overwrites) the info for a repo DID. func (r *RepoDids) Put(did string, info RepoDidInfo) error { - r.mu.Lock() - defer r.mu.Unlock() - r.m[did] = info - - b, err := json.MarshalIndent(r.m, "", " ") - if err != nil { - return err - } - tmp := r.path + ".tmp" - if err := os.WriteFile(tmp, b, 0o600); err != nil { - return err - } - return os.Rename(tmp, r.path) + return r.db.Save(&repoDidRow{Did: did, User: info.User, Repo: info.Repo, Key: info.Key}).Error } +// Get returns the info recorded for a repo DID. func (r *RepoDids) Get(did string) (RepoDidInfo, bool) { - r.mu.Lock() - defer r.mu.Unlock() - info, ok := r.m[did] - return info, ok + var row repoDidRow + if err := r.db.First(&row, "did = ?", did).Error; err != nil { + return RepoDidInfo{}, false + } + return RepoDidInfo{User: row.User, Repo: row.Repo, Key: row.Key}, true } // ByRepo returns the DID for the given (user, repo) pair (case-insensitive). func (r *RepoDids) ByRepo(user, repo string) (string, bool) { - r.mu.Lock() - defer r.mu.Unlock() - for did, info := range r.m { - if strings.EqualFold(info.User, user) && strings.EqualFold(info.Repo, repo) { - return did, true - } + var row repoDidRow + err := r.db.First(&row, "user = ? COLLATE NOCASE AND repo = ? COLLATE NOCASE", user, repo).Error + if err != nil { + return "", false } - return "", false + return row.Did, true } diff --git a/internal/state/repodids_test.go b/internal/state/repodids_test.go index d841db3..ebd1c9f 100644 --- a/internal/state/repodids_test.go +++ b/internal/state/repodids_test.go @@ -19,7 +19,7 @@ func TestRepoDidsRoundTrip(t *testing.T) { t.Fatal(err) } - // Reload from disk. + // Reopen against the same db file. r2, err := LoadRepoDids(dir) if err != nil { t.Fatal(err) @@ -57,7 +57,7 @@ func TestRepoDidsByRepo(t *testing.T) { } } -func TestRepoDidsFilePerms(t *testing.T) { +func TestDBFilePerms(t *testing.T) { dir := t.TempDir() r, err := LoadRepoDids(dir) if err != nil { @@ -67,9 +67,9 @@ func TestRepoDidsFilePerms(t *testing.T) { t.Fatal(err) } - info, err := os.Stat(filepath.Join(dir, repodidFile)) + info, err := os.Stat(filepath.Join(dir, dbFile)) if err != nil { - t.Fatalf("repodids.json not written: %v", err) + t.Fatalf("%s not written: %v", dbFile, err) } if info.Mode().Perm() != 0o600 { t.Errorf("file mode = %v, want 0600", info.Mode().Perm()) diff --git a/internal/state/rkeys.go b/internal/state/rkeys.go index ff9280d..cd3df2c 100644 --- a/internal/state/rkeys.go +++ b/internal/state/rkeys.go @@ -1,73 +1,40 @@ -// Package state persists the small amount of mutable state the shim owns: -// the mapping from sh.tangled.repo record rkeys to Forgejo repo names. package state -import ( - "encoding/json" - "os" - "path/filepath" - "sync" -) - -const rkeyFile = "rkeys.json" +import "gorm.io/gorm" +// RkeyMap maps sh.tangled.repo record rkeys to Forgejo repo names. type RkeyMap struct { - mu sync.Mutex - path string - m map[string]string // rkey -> repo lower_name + db *gorm.DB } -func LoadRkeyMap(stateDir string) (*RkeyMap, error) { - if err := os.MkdirAll(stateDir, 0o700); err != nil { - return nil, err - } - r := &RkeyMap{ - path: filepath.Join(stateDir, rkeyFile), - m: make(map[string]string), - } - b, err := os.ReadFile(r.path) - if os.IsNotExist(err) { - return r, nil - } +// LoadRkeyMap opens the state DB in dir and returns its rkey accessor. +func LoadRkeyMap(dir string) (*RkeyMap, error) { + db, err := Open(dir) if err != nil { return nil, err } - if err := json.Unmarshal(b, &r.m); err != nil { - return nil, err - } - return r, nil + return db.Rkeys(), nil } +// Put records (or overwrites) the repo name for an rkey. func (r *RkeyMap) Put(rkey, repoName string) error { - r.mu.Lock() - defer r.mu.Unlock() - r.m[rkey] = repoName - - b, err := json.MarshalIndent(r.m, "", " ") - if err != nil { - return err - } - tmp := r.path + ".tmp" - if err := os.WriteFile(tmp, b, 0o600); err != nil { - return err - } - return os.Rename(tmp, r.path) + return r.db.Save(&rkeyRow{Rkey: rkey, RepoName: repoName}).Error } +// RepoByRkey returns the repo name recorded for an rkey. func (r *RkeyMap) RepoByRkey(rkey string) (string, bool) { - r.mu.Lock() - defer r.mu.Unlock() - repo, ok := r.m[rkey] - return repo, ok + var row rkeyRow + if err := r.db.First(&row, "rkey = ?", rkey).Error; err != nil { + return "", false + } + return row.RepoName, true } +// RkeyByRepo returns an rkey recorded for a repo name. func (r *RkeyMap) RkeyByRepo(repoName string) (string, bool) { - r.mu.Lock() - defer r.mu.Unlock() - for rkey, repo := range r.m { - if repo == repoName { - return rkey, true - } + var row rkeyRow + if err := r.db.First(&row, "repo_name = ?", repoName).Error; err != nil { + return "", false } - return "", false + return row.Rkey, true } diff --git a/internal/state/rkeys_test.go b/internal/state/rkeys_test.go index 0014c19..cb44130 100644 --- a/internal/state/rkeys_test.go +++ b/internal/state/rkeys_test.go @@ -17,6 +17,7 @@ func TestRkeyMapPersists(t *testing.T) { t.Fatal(err) } + // Reopen against the same db file. m2, err := LoadRkeyMap(dir) if err != nil { t.Fatal(err) @@ -31,8 +32,8 @@ func TestRkeyMapPersists(t *testing.T) { t.Error("expected miss") } - if _, err := os.Stat(filepath.Join(dir, "rkeys.json")); err != nil { - t.Fatalf("rkeys.json not written: %v", err) + if _, err := os.Stat(filepath.Join(dir, dbFile)); err != nil { + t.Fatalf("%s not written: %v", dbFile, err) } } diff --git a/internal/state/state.go b/internal/state/state.go new file mode 100644 index 0000000..3cf01f8 --- /dev/null +++ b/internal/state/state.go @@ -0,0 +1,152 @@ +// Package state persists the small amount of mutable state the shim owns in a +// SQLite database (via GORM): the mapping from sh.tangled.repo record rkeys to +// Forgejo repo names, and the per-repo did:plc identities it has minted. +package state + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + + "github.com/glebarez/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +const dbFile = "snot.db" + +type rkeyRow struct { + Rkey string `gorm:"primaryKey"` + RepoName string `gorm:"index"` +} + +func (rkeyRow) TableName() string { return "rkeys" } + +type repoDidRow struct { + Did string `gorm:"primaryKey"` + User string `gorm:"index:idx_repodid_user_repo"` + Repo string `gorm:"index:idx_repodid_user_repo"` + Key []byte +} + +func (repoDidRow) TableName() string { return "repo_dids" } + +// DB owns snot's persistent SQLite store and hands out typed accessors. +type DB struct { + gorm *gorm.DB +} + +// Open opens (creating if absent) snot.db inside dir, runs migrations, and +// imports any legacy JSON state left by older versions. +func Open(dir string) (*DB, error) { + if err := os.MkdirAll(dir, 0o700); err != nil { + return nil, err + } + path := filepath.Join(dir, dbFile) + + gdb, err := gorm.Open(sqlite.Open(path), &gorm.Config{ + Logger: logger.Default.LogMode(logger.Silent), + }) + if err != nil { + return nil, err + } + if err := gdb.Exec("PRAGMA busy_timeout = 5000").Error; err != nil { + return nil, err + } + if err := gdb.AutoMigrate(&rkeyRow{}, &repoDidRow{}); err != nil { + return nil, err + } + // the db holds PLC rotation keys; keep it owner-only. + _ = os.Chmod(path, 0o600) + + db := &DB{gorm: gdb} + if err := db.importLegacyJSON(dir); err != nil { + return nil, fmt.Errorf("importing legacy json state: %w", err) + } + return db, nil +} + +// Rkeys returns the rkey↔repo accessor. +func (db *DB) Rkeys() *RkeyMap { return &RkeyMap{db: db.gorm} } + +// RepoDids returns the repo-DID accessor. +func (db *DB) RepoDids() *RepoDids { return &RepoDids{db: db.gorm} } + +// importLegacyJSON one-time-imports rkeys.json / repodids.json written by +// pre-SQLite versions. It only imports into an empty table, then renames the +// file to *.migrated so it is kept as a backup and never re-imported. +func (db *DB) importLegacyJSON(dir string) error { + if err := db.importRkeysJSON(filepath.Join(dir, "rkeys.json")); err != nil { + return err + } + return db.importRepoDidsJSON(filepath.Join(dir, "repodids.json")) +} + +func (db *DB) importRkeysJSON(path string) error { + b, ok, err := readLegacy(path) + if err != nil || !ok { + return err + } + var count int64 + if err := db.gorm.Model(&rkeyRow{}).Count(&count).Error; err != nil { + return err + } + if count > 0 { + return nil + } + var m map[string]string + if err := json.Unmarshal(b, &m); err != nil { + return err + } + rows := make([]rkeyRow, 0, len(m)) + for rkey, repo := range m { + rows = append(rows, rkeyRow{Rkey: rkey, RepoName: repo}) + } + if len(rows) > 0 { + if err := db.gorm.Create(&rows).Error; err != nil { + return err + } + } + return os.Rename(path, path+".migrated") +} + +func (db *DB) importRepoDidsJSON(path string) error { + b, ok, err := readLegacy(path) + if err != nil || !ok { + return err + } + var count int64 + if err := db.gorm.Model(&repoDidRow{}).Count(&count).Error; err != nil { + return err + } + if count > 0 { + return nil + } + var m map[string]RepoDidInfo + if err := json.Unmarshal(b, &m); err != nil { + return err + } + rows := make([]repoDidRow, 0, len(m)) + for did, info := range m { + rows = append(rows, repoDidRow{Did: did, User: info.User, Repo: info.Repo, Key: info.Key}) + } + if len(rows) > 0 { + if err := db.gorm.Create(&rows).Error; err != nil { + return err + } + } + return os.Rename(path, path+".migrated") +} + +func readLegacy(path string) ([]byte, bool, error) { + b, err := os.ReadFile(path) + if errors.Is(err, os.ErrNotExist) { + return nil, false, nil + } + if err != nil { + return nil, false, err + } + return b, true, nil +}