diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6fb19aa..9b5279a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,9 +43,9 @@ jobs: go mod tidy git diff --exit-code -- go.mod go.sum - - name: Verify generated SQL code + - name: Verify generated code run: | - make generate/sqlc + make generate git diff --exit-code - name: Lint Go diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b3d5791..733b579 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -54,7 +54,7 @@ jobs: - name: Verify generated and module files run: | go mod tidy - make generate/sqlc + make generate git diff --exit-code - name: Lint Go diff --git a/Makefile b/Makefile index 6850609..8128878 100644 --- a/Makefile +++ b/Makefile @@ -11,7 +11,7 @@ SQLC ?= $(CURDIR)/../../cache/bin/sqlc-$(SQLC_VERSION) COMPOSE_FILES = --env-file deploy/.env -f deploy/docker-compose.yml -f deploy/docker-compose.dev.yml GO_PACKAGES = ./api/... ./cmd/... ./internal/... ./pkg/... ./migrations/... -.PHONY: build test integration-test vet lint frontend-build frontend-i18n frontend-lint generate generate/sqlc compose-dev-up compose-dev-down e2e-live check +.PHONY: build test integration-test vet lint frontend-build frontend-i18n frontend-lint generate generate/sqlc generate/wire compose-dev-up compose-dev-down e2e-live check build: GOCACHE=$(GOCACHE) GOMODCACHE=$(GOMODCACHE) GOFLAGS=$(GOFLAGS) $(GO) build ./cmd/mooncode @@ -20,7 +20,7 @@ test: GOCACHE=$(GOCACHE) GOMODCACHE=$(GOMODCACHE) GOFLAGS=$(GOFLAGS) $(GO) test $(GO_PACKAGES) integration-test: - GOCACHE=$(GOCACHE) GOMODCACHE=$(GOMODCACHE) GOFLAGS=$(GOFLAGS) $(GO) test -tags=integration -count=1 ./internal/identity/data ./internal/repository/data ./internal/analysis/data ./internal/channel/data ./internal/retention/data ./internal/platform/audit ./internal/workflow/data ./pkg/gitrepo + GOCACHE=$(GOCACHE) GOMODCACHE=$(GOMODCACHE) GOFLAGS=$(GOFLAGS) $(GO) test -tags=integration -count=1 ./internal/data/identity ./internal/data/repository ./internal/data/analysis ./internal/data/channel ./internal/data/retention ./internal/data/audit ./internal/data/workflow ./pkg/gitrepo vet: GOCACHE=$(GOCACHE) GOMODCACHE=$(GOMODCACHE) GOFLAGS=$(GOFLAGS) $(GO) vet $(GO_PACKAGES) @@ -37,7 +37,7 @@ frontend-lint: frontend-i18n: $(NPM) --prefix frontend run check:i18n -generate: generate/sqlc +generate: generate/sqlc generate/wire generate/sqlc: @if [ -x "$(SQLC)" ]; then \ @@ -46,6 +46,9 @@ generate/sqlc: GOCACHE=$(GOCACHE) GOMODCACHE=$(GOMODCACHE) GOPATH=$(GO_TOOL_PATH) $(GO) run github.com/sqlc-dev/sqlc/cmd/sqlc@$(SQLC_VERSION) generate; \ fi +generate/wire: + GOCACHE=$(GOCACHE) GOMODCACHE=$(GOMODCACHE) GOPATH=$(GO_TOOL_PATH) GOFLAGS=$(GOFLAGS) $(GO) tool wire ./internal/app + compose-dev-up: $(DOCKER) compose $(COMPOSE_FILES) up -d --build --wait diff --git a/README.md b/README.md index 1e53635..18ac3fa 100644 --- a/README.md +++ b/README.md @@ -107,7 +107,7 @@ make compose-dev-down ```bash npm ci --prefix frontend -make generate/sqlc +make generate make test make vet make frontend-i18n frontend-lint frontend-build @@ -142,12 +142,14 @@ go run ./cmd/mooncode version ```text cmd/mooncode/ Cobra 命令入口 -internal//biz/ 业务模型与用例 -internal//data/ PostgreSQL/sqlc 实现 -internal//workflow/ Hatchet Task Runner -internal/channel/runtime/ 长连接生命周期、策略与入站编排 -internal/platform/ HTTP、认证、数据库、Hatchet、加密装配 -pkg/ 可被第三方独立使用的接口与默认实现 +internal/app/ 进程生命周期与 Wire 顶层装配 +internal/conf/ 配置加载与校验 +internal/server/ HTTP 协议适配与认证中间件 +internal/service/ 面向传输层的应用服务门面 +internal/biz/ 业务模型、用例与工作流规则 +internal/data/ PostgreSQL/sqlc 与外部数据适配 +internal/worker/ Hatchet、调度器与长连接运行时 +pkg/ 可被第三方独立使用的接口、Options 与默认实现 migrations/ 唯一初始 PostgreSQL migration sql/queries/ sqlc 查询定义 frontend/ React 前端 diff --git a/README_EN.md b/README_EN.md index 2642604..01fe56b 100644 --- a/README_EN.md +++ b/README_EN.md @@ -107,7 +107,7 @@ Source worktrees belong under the workspace root's `worktree/` directory. Cross- ```bash npm ci --prefix frontend -make generate/sqlc +make generate make test make vet make frontend-i18n frontend-lint frontend-build @@ -142,12 +142,14 @@ go run ./cmd/mooncode version ```text cmd/mooncode/ Cobra entry point -internal//biz/ Business models and use cases -internal//data/ PostgreSQL/sqlc implementations -internal//workflow/ Hatchet task runners -internal/channel/runtime/ Connection lifecycle, policy, and inbound orchestration -internal/platform/ HTTP, auth, database, Hatchet, and encryption assembly -pkg/ Independently reusable interfaces and defaults +internal/app/ Process lifecycle and top-level Wire assembly +internal/conf/ Configuration loading and validation +internal/server/ HTTP protocol adapters and auth middleware +internal/service/ Transport-facing application service facades +internal/biz/ Business models, use cases, and workflow rules +internal/data/ PostgreSQL/sqlc and external data adapters +internal/worker/ Hatchet, dispatchers, and connection runtimes +pkg/ Independently reusable interfaces, options, and defaults migrations/ The single initial PostgreSQL migration sql/queries/ sqlc query definitions frontend/ React frontend diff --git a/api/openapi/spec_test.go b/api/openapi/spec_test.go index ab165f9..7666d7f 100644 --- a/api/openapi/spec_test.go +++ b/api/openapi/spec_test.go @@ -22,6 +22,26 @@ func TestDocumentIsValidOpenAPI(t *testing.T) { } } +func TestPublishedOperationInventory(t *testing.T) { + document, err := Document() + if err != nil { + t.Fatal(err) + } + specification, err := openapi3.NewLoader().LoadFromData(document) + if err != nil { + t.Fatal(err) + } + + paths := specification.Paths.Map() + operations := 0 + for _, item := range paths { + operations += len(item.Operations()) + } + if len(paths) != 44 || operations != 54 { + t.Fatalf("published operation inventory = %d paths/%d operations, want 44/54", len(paths), operations) + } +} + func TestAnalysisReportUsesClosedCodeScaleResult(t *testing.T) { document, err := Document() if err != nil { diff --git a/deploy/backend.Dockerfile b/deploy/backend.Dockerfile index effea47..d892366 100644 --- a/deploy/backend.Dockerfile +++ b/deploy/backend.Dockerfile @@ -17,7 +17,7 @@ COPY migrations ./migrations COPY pkg ./pkg RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -buildvcs=false \ - -ldflags="-s -w -X github.com/fuchencong/mooncode/internal/platform/buildinfo.Version=${VERSION} -X github.com/fuchencong/mooncode/internal/platform/buildinfo.Commit=${COMMIT} -X github.com/fuchencong/mooncode/internal/platform/buildinfo.BuildTime=${BUILD_TIME}" \ + -ldflags="-s -w -X github.com/fuchencong/mooncode/internal/buildinfo.Version=${VERSION} -X github.com/fuchencong/mooncode/internal/buildinfo.Commit=${COMMIT} -X github.com/fuchencong/mooncode/internal/buildinfo.BuildTime=${BUILD_TIME}" \ -o /out/mooncode ./cmd/mooncode RUN mkdir /tmp/scc \ diff --git a/go.mod b/go.mod index 8af2566..ce356e4 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/getkin/kin-openapi v0.144.0 github.com/gin-gonic/gin v1.10.1 github.com/google/uuid v1.6.0 + github.com/google/wire v0.7.0 github.com/hatchet-dev/hatchet v0.98.9 github.com/jackc/pgx/v5 v5.9.2 github.com/larksuite/oapi-sdk-go/v3 v3.9.4 @@ -44,6 +45,7 @@ require ( github.com/goccy/go-json v0.10.5 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/google/cel-go v0.29.0 // indirect + github.com/google/subcommands v1.2.0 // indirect github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect @@ -69,6 +71,7 @@ require ( github.com/oasdiff/yaml3 v0.0.14 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pkg/errors v0.9.1 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/robfig/cron/v3 v3.0.1 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/rs/zerolog v1.35.1 // indirect @@ -93,15 +96,19 @@ require ( go.opentelemetry.io/proto/otlp v1.10.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/arch v0.8.0 // indirect - golang.org/x/crypto v0.52.0 // indirect + golang.org/x/crypto v0.53.0 // indirect golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect - golang.org/x/net v0.55.0 // indirect + golang.org/x/mod v0.37.0 // indirect + golang.org/x/net v0.56.0 // indirect golang.org/x/sync v0.21.0 // indirect - golang.org/x/sys v0.45.0 // indirect + golang.org/x/sys v0.46.0 // indirect golang.org/x/text v0.39.0 // indirect + golang.org/x/tools v0.47.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529 // indirect google.golang.org/grpc v1.82.1 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) + +tool github.com/google/wire/cmd/wire diff --git a/go.sum b/go.sum index 33603ee..78e3df3 100644 --- a/go.sum +++ b/go.sum @@ -84,8 +84,12 @@ github.com/google/cel-go v0.29.0/go.mod h1:X0bD6iVNR8pkROSOoHVdgTkzmRcosof7WQqCD 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/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE= +github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk= 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/wire v0.7.0 h1:JxUKI6+CVBgCO2WToKy/nQk0sS+amI9z9EjVmdaocj4= +github.com/google/wire v0.7.0/go.mod h1:n6YbUQD9cPKTnHXEBN2DXlOp/mVADhVErcMFb0v3J18= github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= @@ -247,18 +251,20 @@ golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= -golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= +golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= +golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM= golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= -golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -268,8 +274,8 @@ golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5h golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= -golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= 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.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus= @@ -278,6 +284,8 @@ golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGm golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= +golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/internal/app/migrate.go b/internal/app/migrate.go new file mode 100644 index 0000000..cdf4714 --- /dev/null +++ b/internal/app/migrate.go @@ -0,0 +1,20 @@ +package app + +import ( + "context" + + "github.com/fuchencong/mooncode/internal/data" +) + +// Migrator applies MoonCode's embedded database migrations. +type Migrator struct { + data *data.Data +} + +func NewMigrator(data *data.Data) *Migrator { + return &Migrator{data: data} +} + +func (m *Migrator) Run(ctx context.Context) error { + return m.data.Migrate(ctx) +} diff --git a/internal/app/option.go b/internal/app/option.go new file mode 100644 index 0000000..0586dcc --- /dev/null +++ b/internal/app/option.go @@ -0,0 +1,132 @@ +package app + +import ( + "errors" + "time" +) + +const ( + defaultReadHeaderTimeout = 10 * time.Second + defaultReadTimeout = 30 * time.Second + defaultWriteTimeout = 30 * time.Second + defaultIdleTimeout = 2 * time.Minute + defaultShutdownTimeout = 15 * time.Second + defaultWorkerName = "mooncode-worker" + defaultWorkerSlots = 20 +) + +type serverOptions struct { + address string + readHeaderTimeout time.Duration + readTimeout time.Duration + writeTimeout time.Duration + idleTimeout time.Duration + shutdownTimeout time.Duration +} + +// ServerOption configures the HTTP process lifecycle. +type ServerOption func(*serverOptions) + +func WithServerAddress(address string) ServerOption { + return func(options *serverOptions) { options.address = address } +} + +func WithServerTimeouts(readHeader, read, write, idle time.Duration) ServerOption { + return func(options *serverOptions) { + options.readHeaderTimeout = readHeader + options.readTimeout = read + options.writeTimeout = write + options.idleTimeout = idle + } +} + +func WithServerShutdownTimeout(timeout time.Duration) ServerOption { + return func(options *serverOptions) { options.shutdownTimeout = timeout } +} + +func defaultServerOptions() serverOptions { + return serverOptions{ + readHeaderTimeout: defaultReadHeaderTimeout, + readTimeout: defaultReadTimeout, + writeTimeout: defaultWriteTimeout, + idleTimeout: defaultIdleTimeout, + shutdownTimeout: defaultShutdownTimeout, + } +} + +func (o serverOptions) validate() error { + if o.address == "" { + return errors.New("server address is required") + } + if o.readHeaderTimeout <= 0 || o.readTimeout <= 0 || o.writeTimeout <= 0 || o.idleTimeout <= 0 { + return errors.New("server timeouts must be positive") + } + if o.shutdownTimeout <= 0 { + return errors.New("server shutdown timeout must be positive") + } + + return nil +} + +type workerOptions struct { + name string + slots int + metricsAddress string + worktreeMaxAge time.Duration + janitorInterval time.Duration + metricsReadTimeout time.Duration + metricsWriteTimeout time.Duration + metricsIdleTimeout time.Duration + metricsShutdownLimit time.Duration +} + +// WorkerOption configures worker process scheduling and lifecycle behavior. +type WorkerOption func(*workerOptions) + +func WithWorkerIdentity(name string, slots int) WorkerOption { + return func(options *workerOptions) { + options.name = name + options.slots = slots + } +} + +func WithWorkerMetricsAddress(address string) WorkerOption { + return func(options *workerOptions) { options.metricsAddress = address } +} + +func WithWorktreeRetention(maxAge, interval time.Duration) WorkerOption { + return func(options *workerOptions) { + options.worktreeMaxAge = maxAge + options.janitorInterval = interval + } +} + +func defaultWorkerOptions() workerOptions { + return workerOptions{ + name: defaultWorkerName, + slots: defaultWorkerSlots, + worktreeMaxAge: 24 * time.Hour, + janitorInterval: time.Hour, + metricsReadTimeout: 10 * time.Second, + metricsWriteTimeout: 10 * time.Second, + metricsIdleTimeout: time.Minute, + metricsShutdownLimit: 10 * time.Second, + } +} + +func (o workerOptions) validate() error { + if o.name == "" { + return errors.New("worker name is required") + } + if o.slots <= 0 { + return errors.New("worker slots must be positive") + } + if o.metricsAddress == "" { + return errors.New("worker metrics address is required") + } + if o.worktreeMaxAge <= 0 || o.janitorInterval <= 0 { + return errors.New("worktree retention durations must be positive") + } + + return nil +} diff --git a/internal/app/provider.go b/internal/app/provider.go new file mode 100644 index 0000000..63827bf --- /dev/null +++ b/internal/app/provider.go @@ -0,0 +1,107 @@ +package app + +import ( + "time" + + "github.com/fuchencong/mooncode/internal/biz" + "github.com/fuchencong/mooncode/internal/conf" + "github.com/fuchencong/mooncode/internal/data" + "github.com/fuchencong/mooncode/internal/server" + "github.com/fuchencong/mooncode/internal/worker" + "github.com/fuchencong/mooncode/pkg/analyzer/scc" + channelpkg "github.com/fuchencong/mooncode/pkg/channel" + "github.com/fuchencong/mooncode/pkg/channel/dingtalk" + "github.com/fuchencong/mooncode/pkg/channel/feishu" + "github.com/fuchencong/mooncode/pkg/gitrepo" +) + +func provideDataOptions(cfg conf.Config) []data.Option { + return []data.Option{ + data.WithPostgres(cfg.Database.URL, cfg.Database.MaxConnections), + data.WithMigrateOnStart(cfg.Database.MigrateOnStart), + data.WithSecretKey(cfg.Secrets.Key, cfg.Secrets.KeyVersion), + data.WithRepositoryLimit(cfg.Repository.MaxPerWorkspace), + data.WithAnalysisLimit(cfg.Analysis.MaxConcurrentPerWorkspace), + } +} + +func provideMigrationDataOptions(cfg conf.Config) []data.Option { + options := provideDataOptions(cfg) + + return append(options, data.WithMigrateOnStart(false)) +} + +func provideBizOptions(cfg conf.Config) []biz.Option { + return []biz.Option{ + biz.WithIdentityIssuer(cfg.Auth.Issuer), + biz.WithAnalyzer(scc.Version, cfg.Analysis.Timeout), + biz.WithApplicationURL(cfg.Auth.AppURL), + } +} + +func provideGitManager(cfg conf.Config) (*gitrepo.DefaultManager, error) { + return gitrepo.NewManager( + cfg.Repository.Root, + gitrepo.WithMaxMirrorBytes(cfg.Repository.MaxMirrorBytes), + ) +} + +func provideChannelFactories(feishuFactory *feishu.Factory, dingTalkFactory *dingtalk.Factory) []channelpkg.Factory { + return []channelpkg.Factory{feishuFactory, dingTalkFactory} +} + +func provideFeishuFactory() *feishu.Factory { + return feishu.New() +} + +func provideDingTalkFactory() *dingtalk.Factory { + return dingtalk.New() +} + +func provideAnalyzer(cfg conf.Config) *scc.Default { + return scc.New( + scc.WithBinary(cfg.Analysis.SCCPath), + scc.WithMaxOutputBytes(cfg.Analysis.MaxOutputBytes), + ) +} + +func provideServerComponentOptions(cfg conf.Config) []server.Option { + return []server.Option{ + server.WithAuthentication( + cfg.Auth.LogoutURL, + cfg.Auth.RegistrationMode, + cfg.Auth.TermsVersion, + cfg.Auth.PrivacyVersion, + cfg.Auth.TrustedProxyCIDRs, + cfg.Auth.CSRFKey, + ), + server.WithHTTP(cfg.HTTP.MaxBodyBytes, cfg.HTTP.RequestTimeout), + } +} + +func provideWorkerComponentOptions(cfg conf.Config) []worker.Option { + return []worker.Option{ + worker.WithHatchet(cfg.Hatchet.Token, cfg.Hatchet.Address, cfg.Hatchet.Namespace), + } +} + +func provideServerOptions(cfg conf.Config) []ServerOption { + return []ServerOption{ + WithServerAddress(cfg.HTTP.Address), + WithServerTimeouts( + defaultReadHeaderTimeout, + defaultReadTimeout, + defaultWriteTimeout, + defaultIdleTimeout, + ), + WithServerShutdownTimeout(defaultShutdownTimeout), + } +} + +func provideWorkerOptions(cfg conf.Config) []WorkerOption { + return []WorkerOption{ + WithWorkerIdentity(defaultWorkerName, defaultWorkerSlots), + WithWorkerMetricsAddress(cfg.Metrics.WorkerAddress), + WithWorktreeRetention(cfg.Repository.WorktreeMaxAge, time.Hour), + } +} diff --git a/internal/app/server.go b/internal/app/server.go new file mode 100644 index 0000000..0db09cb --- /dev/null +++ b/internal/app/server.go @@ -0,0 +1,93 @@ +// Package app assembles and owns MoonCode process-level resources. +package app + +import ( + "context" + "errors" + "net/http" + + "github.com/fuchencong/mooncode/internal/server" + "github.com/fuchencong/mooncode/internal/worker" +) + +// Server coordinates the HTTP server and inbound channel runtime. +type Server struct { + http *http.Server + runtime worker.ChannelRuntime + + options serverOptions +} + +func NewServer(handler *server.Server, runtime worker.ChannelRuntime, opts ...ServerOption) (*Server, error) { + if handler == nil || runtime == nil { + return nil, errors.New("server application dependencies must not be nil") + } + + options := defaultServerOptions() + for _, option := range opts { + option(&options) + } + if err := options.validate(); err != nil { + return nil, err + } + + return &Server{ + http: &http.Server{ + Addr: options.address, + Handler: handler.Handler(), + ReadHeaderTimeout: options.readHeaderTimeout, + ReadTimeout: options.readTimeout, + WriteTimeout: options.writeTimeout, + IdleTimeout: options.idleTimeout, + }, + runtime: runtime, + options: options, + }, nil +} + +// Run blocks until cancellation or an HTTP listener failure. +func (a *Server) Run(ctx context.Context) error { + runtimeContext, stopRuntime := context.WithCancel(ctx) + defer stopRuntime() + + runtimeDone := make(chan struct{}) + go func() { + defer close(runtimeDone) + _ = a.runtime.Run(runtimeContext) + }() + + errorsChannel := make(chan error, 1) + go func() { errorsChannel <- a.http.ListenAndServe() }() + + select { + case <-ctx.Done(): + shutdown, cancel := context.WithTimeout(context.Background(), a.options.shutdownTimeout) + defer cancel() + + if err := a.http.Shutdown(shutdown); err != nil { + return err + } + + select { + case <-runtimeDone: + return nil + case <-shutdown.Done(): + return shutdown.Err() + } + case err := <-errorsChannel: + stopRuntime() + + shutdown, cancel := context.WithTimeout(context.Background(), a.options.shutdownTimeout) + defer cancel() + + select { + case <-runtimeDone: + case <-shutdown.Done(): + } + if errors.Is(err, http.ErrServerClosed) { + return nil + } + + return err + } +} diff --git a/internal/app/wire.go b/internal/app/wire.go new file mode 100644 index 0000000..a5fc4ad --- /dev/null +++ b/internal/app/wire.go @@ -0,0 +1,82 @@ +//go:build wireinject + +package app + +import ( + "context" + "log/slog" + + "github.com/fuchencong/mooncode/internal/biz" + "github.com/fuchencong/mooncode/internal/conf" + "github.com/fuchencong/mooncode/internal/data" + "github.com/fuchencong/mooncode/internal/server" + "github.com/fuchencong/mooncode/internal/service" + "github.com/fuchencong/mooncode/internal/worker" + "github.com/fuchencong/mooncode/pkg/analyzer" + "github.com/fuchencong/mooncode/pkg/analyzer/scc" + "github.com/fuchencong/mooncode/pkg/gitrepo" + "github.com/google/wire" +) + +var infrastructureProviderSet = wire.NewSet( + provideGitManager, + provideAnalyzer, + provideFeishuFactory, + provideDingTalkFactory, + provideChannelFactories, + wire.Bind(new(gitrepo.Manager), new(*gitrepo.DefaultManager)), + wire.Bind(new(gitrepo.WorktreeJanitor), new(*gitrepo.DefaultManager)), + wire.Bind(new(analyzer.Analyzer), new(*scc.Default)), +) + +var dataProviderSet = wire.NewSet( + provideDataOptions, + data.ProviderSet, + wire.Bind(new(server.Readiness), new(*data.Readiness)), +) + +var bizProviderSet = wire.NewSet( + provideBizOptions, + biz.ProviderSet, +) + +var workerProviderSet = wire.NewSet( + provideWorkerComponentOptions, + worker.ProviderSet, +) + +var observabilityProviderSet = wire.NewSet( + server.MetricsProviderSet, + wire.Bind(new(biz.RetentionMetrics), new(*server.Metrics)), + wire.Bind(new(worker.TaskMetrics), new(*server.Metrics)), +) + +var serverProviderSet = wire.NewSet( + service.ProviderSet, + provideServerComponentOptions, + server.ProviderSet, + wire.Bind(new(biz.RetentionMetrics), new(*server.Metrics)), + wire.Bind(new(worker.TaskMetrics), new(*server.Metrics)), +) + +var processProviderSet = wire.NewSet( + infrastructureProviderSet, + dataProviderSet, + bizProviderSet, + workerProviderSet, +) + +// InitializeServer assembles the HTTP process and its cleanup function. +func InitializeServer(context.Context, conf.Config, *slog.Logger) (*Server, func(), error) { + panic(wire.Build(processProviderSet, serverProviderSet, provideServerOptions, NewServer)) +} + +// InitializeWorker assembles the background worker and its cleanup function. +func InitializeWorker(context.Context, conf.Config, *slog.Logger) (*Worker, func(), error) { + panic(wire.Build(processProviderSet, observabilityProviderSet, provideWorkerOptions, NewWorker)) +} + +// InitializeMigrator assembles the standalone migration command. +func InitializeMigrator(context.Context, conf.Config) (*Migrator, func(), error) { + panic(wire.Build(provideMigrationDataOptions, data.New, NewMigrator)) +} diff --git a/internal/app/wire_gen.go b/internal/app/wire_gen.go new file mode 100644 index 0000000..64de36a --- /dev/null +++ b/internal/app/wire_gen.go @@ -0,0 +1,223 @@ +// Code generated by Wire. DO NOT EDIT. + +//go:generate go run -mod=mod github.com/google/wire/cmd/wire +//go:build !wireinject +// +build !wireinject + +package app + +import ( + "context" + "github.com/fuchencong/mooncode/internal/biz" + "github.com/fuchencong/mooncode/internal/conf" + "github.com/fuchencong/mooncode/internal/data" + "github.com/fuchencong/mooncode/internal/server" + "github.com/fuchencong/mooncode/internal/service" + "github.com/fuchencong/mooncode/internal/service/analysis" + "github.com/fuchencong/mooncode/internal/service/channel" + "github.com/fuchencong/mooncode/internal/service/command" + "github.com/fuchencong/mooncode/internal/service/identity" + "github.com/fuchencong/mooncode/internal/service/repository" + "github.com/fuchencong/mooncode/internal/worker" + "github.com/fuchencong/mooncode/pkg/analyzer" + "github.com/fuchencong/mooncode/pkg/analyzer/scc" + "github.com/fuchencong/mooncode/pkg/gitrepo" + "github.com/google/wire" + "log/slog" +) + +// Injectors from wire.go: + +// InitializeServer assembles the HTTP process and its cleanup function. +func InitializeServer(contextContext context.Context, config conf.Config, logger *slog.Logger) (*Server, func(), error) { + v := provideDataOptions(config) + dataData, cleanup, err := data.New(contextContext, v...) + if err != nil { + return nil, nil, err + } + store := data.NewIdentityStore(dataData) + v2 := provideBizOptions(config) + settings, err := biz.NewSettings(v2...) + if err != nil { + cleanup() + return nil, nil, err + } + service := biz.NewIdentityService(store, settings) + identityService := identity.WrapService(service) + aesgcm, err := data.NewCipher(dataData) + if err != nil { + cleanup() + return nil, nil, err + } + validator := data.NewProviderValidator(dataData) + providerService := biz.NewProviderService(store, aesgcm, validator) + identityProviderService := identity.WrapProviderService(providerService) + defaultManager, err := provideGitManager(config) + if err != nil { + cleanup() + return nil, nil, err + } + repositoryStore := data.NewRepositoryStore(dataData, defaultManager) + runner := biz.NewRepositoryRunner(repositoryStore, service, providerService, defaultManager) + analysisStore := data.NewAnalysisStore(dataData) + sccDefault := provideAnalyzer(config) + workflowRunner := biz.NewAnalysisRunner(analysisStore, service, defaultManager, sccDefault, settings) + channelStore := data.NewChannelStore(dataData) + factory := provideFeishuFactory() + dingtalkFactory := provideDingTalkFactory() + v3 := provideChannelFactories(factory, dingtalkFactory) + runner2 := biz.NewNotificationRunner(channelStore, aesgcm, v3) + retentionStore := data.NewRetentionStore(dataData) + scheduler := biz.NewRetentionScheduler(retentionStore) + metrics := server.NewMetrics() + runner3 := biz.NewRetentionRunner(retentionStore, defaultManager, metrics) + v4 := provideWorkerComponentOptions(config) + v5, err := worker.NewSettings(v4...) + if err != nil { + cleanup() + return nil, nil, err + } + client, err := worker.NewHatchetClient(runner, workflowRunner, runner2, scheduler, runner3, metrics, v5) + if err != nil { + cleanup() + return nil, nil, err + } + repositoryService := biz.NewRepositoryService(repositoryStore, service, providerService, client, defaultManager) + service2 := repository.New(repositoryService) + analysisService := biz.NewAnalysisService(analysisStore, service, repositoryStore, client, settings) + service3 := analysis.New(analysisService) + channelService := biz.NewChannelService(channelStore, service, aesgcm) + service4 := channel.New(channelService) + commandService := biz.NewCommandService(channelStore, service, repositoryService, analysisService, settings) + service5 := command.New(commandService) + v6 := provideServerComponentOptions(config) + v7 := server.NewSettings(v6...) + csrf := server.NewCSRF(v7) + readiness := data.NewReadiness(dataData) + serverServer, err := server.NewConfigured(identityService, identityProviderService, service2, service3, service4, service5, csrf, readiness, metrics, logger, v7) + if err != nil { + cleanup() + return nil, nil, err + } + manager := worker.NewChannelRuntime(channelStore, aesgcm, v3, commandService, logger) + v8 := provideServerOptions(config) + appServer, err := NewServer(serverServer, manager, v8...) + if err != nil { + cleanup() + return nil, nil, err + } + return appServer, func() { + cleanup() + }, nil +} + +// InitializeWorker assembles the background worker and its cleanup function. +func InitializeWorker(contextContext context.Context, config conf.Config, logger *slog.Logger) (*Worker, func(), error) { + v := provideDataOptions(config) + dataData, cleanup, err := data.New(contextContext, v...) + if err != nil { + return nil, nil, err + } + store := data.NewWorkflowStore(dataData) + defaultManager, err := provideGitManager(config) + if err != nil { + cleanup() + return nil, nil, err + } + repositoryStore := data.NewRepositoryStore(dataData, defaultManager) + identityStore := data.NewIdentityStore(dataData) + v2 := provideBizOptions(config) + settings, err := biz.NewSettings(v2...) + if err != nil { + cleanup() + return nil, nil, err + } + service := biz.NewIdentityService(identityStore, settings) + aesgcm, err := data.NewCipher(dataData) + if err != nil { + cleanup() + return nil, nil, err + } + validator := data.NewProviderValidator(dataData) + providerService := biz.NewProviderService(identityStore, aesgcm, validator) + runner := biz.NewRepositoryRunner(repositoryStore, service, providerService, defaultManager) + analysisStore := data.NewAnalysisStore(dataData) + sccDefault := provideAnalyzer(config) + workflowRunner := biz.NewAnalysisRunner(analysisStore, service, defaultManager, sccDefault, settings) + channelStore := data.NewChannelStore(dataData) + factory := provideFeishuFactory() + dingtalkFactory := provideDingTalkFactory() + v3 := provideChannelFactories(factory, dingtalkFactory) + runner2 := biz.NewNotificationRunner(channelStore, aesgcm, v3) + retentionStore := data.NewRetentionStore(dataData) + scheduler := biz.NewRetentionScheduler(retentionStore) + metrics := server.NewMetrics() + runner3 := biz.NewRetentionRunner(retentionStore, defaultManager, metrics) + v4 := provideWorkerComponentOptions(config) + v5, err := worker.NewSettings(v4...) + if err != nil { + cleanup() + return nil, nil, err + } + client, err := worker.NewHatchetClient(runner, workflowRunner, runner2, scheduler, runner3, metrics, v5) + if err != nil { + cleanup() + return nil, nil, err + } + dispatcher := worker.NewDispatcher(store, client, logger) + v6 := provideWorkerOptions(config) + appWorker, err := NewWorker(dispatcher, client, defaultManager, metrics, v6...) + if err != nil { + cleanup() + return nil, nil, err + } + return appWorker, func() { + cleanup() + }, nil +} + +// InitializeMigrator assembles the standalone migration command. +func InitializeMigrator(contextContext context.Context, config conf.Config) (*Migrator, func(), error) { + v := provideMigrationDataOptions(config) + dataData, cleanup, err := data.New(contextContext, v...) + if err != nil { + return nil, nil, err + } + migrator := NewMigrator(dataData) + return migrator, func() { + cleanup() + }, nil +} + +// wire.go: + +var infrastructureProviderSet = wire.NewSet( + provideGitManager, + provideAnalyzer, + provideFeishuFactory, + provideDingTalkFactory, + provideChannelFactories, wire.Bind(new(gitrepo.Manager), new(*gitrepo.DefaultManager)), wire.Bind(new(gitrepo.WorktreeJanitor), new(*gitrepo.DefaultManager)), wire.Bind(new(analyzer.Analyzer), new(*scc.Default)), +) + +var dataProviderSet = wire.NewSet( + provideDataOptions, data.ProviderSet, wire.Bind(new(server.Readiness), new(*data.Readiness)), +) + +var bizProviderSet = wire.NewSet( + provideBizOptions, biz.ProviderSet, +) + +var workerProviderSet = wire.NewSet( + provideWorkerComponentOptions, worker.ProviderSet, +) + +var observabilityProviderSet = wire.NewSet(server.MetricsProviderSet, wire.Bind(new(biz.RetentionMetrics), new(*server.Metrics)), wire.Bind(new(worker.TaskMetrics), new(*server.Metrics))) + +var serverProviderSet = wire.NewSet(service.ProviderSet, provideServerComponentOptions, server.ProviderSet, wire.Bind(new(biz.RetentionMetrics), new(*server.Metrics)), wire.Bind(new(worker.TaskMetrics), new(*server.Metrics))) + +var processProviderSet = wire.NewSet( + infrastructureProviderSet, + dataProviderSet, + bizProviderSet, + workerProviderSet, +) diff --git a/internal/app/worker.go b/internal/app/worker.go new file mode 100644 index 0000000..4d2ba95 --- /dev/null +++ b/internal/app/worker.go @@ -0,0 +1,207 @@ +package app + +import ( + "context" + "errors" + "fmt" + "net/http" + "time" + + "github.com/fuchencong/mooncode/internal/server" + "github.com/fuchencong/mooncode/internal/worker" + "github.com/fuchencong/mooncode/pkg/gitrepo" +) + +// Worker owns the background process schedulers and observability endpoint. +type Worker struct { + dispatcher worker.Dispatcher + hatchet worker.Client + janitor gitrepo.WorktreeJanitor + metrics *server.Metrics + + options workerOptions +} + +func NewWorker( + dispatcher worker.Dispatcher, + hatchet worker.Client, + janitor gitrepo.WorktreeJanitor, + metrics *server.Metrics, + opts ...WorkerOption, +) (*Worker, error) { + if dispatcher == nil || hatchet == nil || janitor == nil || metrics == nil { + return nil, errors.New("worker application dependencies must not be nil") + } + + options := defaultWorkerOptions() + for _, option := range opts { + option(&options) + } + if err := options.validate(); err != nil { + return nil, err + } + + return &Worker{ + dispatcher: dispatcher, + hatchet: hatchet, + janitor: janitor, + metrics: metrics, + options: options, + }, nil +} + +// Run starts all worker processes and stops their peers on the first failure. +func (a *Worker) Run(ctx context.Context) error { + return runWorkerProcesses( + ctx, + a.dispatcher.Run, + func(workerContext context.Context) error { + return a.hatchet.StartWorker(workerContext, a.options.name, a.options.slots) + }, + namedWorkerProcess{ + name: "worker metrics server", + run: func(metricsContext context.Context) error { + return a.serveMetrics(metricsContext) + }, + }, + namedWorkerProcess{ + name: "worktree janitor", + run: a.runWorktreeJanitor, + }, + ) +} + +func (a *Worker) runWorktreeJanitor(ctx context.Context) error { + return runWorktreeJanitorEvery( + ctx, + a.janitor, + a.options.worktreeMaxAge, + a.options.janitorInterval, + ) +} + +func runWorktreeJanitor(ctx context.Context, janitor gitrepo.WorktreeJanitor, maxAge time.Duration) error { + return runWorktreeJanitorEvery(ctx, janitor, maxAge, time.Hour) +} + +func runWorktreeJanitorEvery( + ctx context.Context, + janitor gitrepo.WorktreeJanitor, + maxAge time.Duration, + interval time.Duration, +) error { + cleanup := func() error { + if _, err := janitor.CleanupStaleWorktrees(ctx, maxAge); err != nil { + return fmt.Errorf("clean stale analysis worktrees: %w", err) + } + + return nil + } + if err := cleanup(); err != nil { + return err + } + + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return nil + case <-ticker.C: + if err := cleanup(); err != nil { + return err + } + } + } +} + +type workerProcess func(context.Context) error + +type namedWorkerProcess struct { + name string + run workerProcess +} + +type workerProcessResult struct { + name string + err error +} + +func runWorkerProcesses(ctx context.Context, dispatcher, worker workerProcess, additional ...namedWorkerProcess) error { + processContext, cancel := context.WithCancel(ctx) + defer cancel() + + processes := append([]namedWorkerProcess{ + {name: "workflow dispatcher", run: dispatcher}, + {name: "Hatchet worker", run: worker}, + }, additional...) + results := make(chan workerProcessResult, len(processes)) + for _, process := range processes { + go func(process namedWorkerProcess) { + results <- workerProcessResult{name: process.name, err: process.run(processContext)} + }(process) + } + + first := <-results + cancel() + completed := []workerProcessResult{first} + for range len(processes) - 1 { + completed = append(completed, <-results) + } + + return workerProcessError(ctx, completed) +} + +func workerProcessError(parent context.Context, results []workerProcessResult) error { + var processErrors []error + for index, result := range results { + if result.err == nil { + continue + } + if parent.Err() != nil && errors.Is(result.err, parent.Err()) { + continue + } + if index > 0 && errors.Is(result.err, context.Canceled) { + continue + } + + processErrors = append(processErrors, fmt.Errorf("%s: %w", result.name, result.err)) + } + + return errors.Join(processErrors...) +} + +func (a *Worker) serveMetrics(ctx context.Context) error { + router := http.NewServeMux() + router.Handle("/metrics", a.metrics.Handler()) + router.HandleFunc("/healthz", func(writer http.ResponseWriter, _ *http.Request) { + writer.Header().Set("Cache-Control", "no-store") + writer.WriteHeader(http.StatusNoContent) + }) + server := &http.Server{ + Addr: a.options.metricsAddress, + Handler: router, + ReadTimeout: a.options.metricsReadTimeout, + WriteTimeout: a.options.metricsWriteTimeout, + IdleTimeout: a.options.metricsIdleTimeout, + } + errorsChannel := make(chan error, 1) + go func() { errorsChannel <- server.ListenAndServe() }() + + select { + case <-ctx.Done(): + shutdown, cancel := context.WithTimeout(context.Background(), a.options.metricsShutdownLimit) + defer cancel() + if err := server.Shutdown(shutdown); err != nil { + return err + } + + return nil + case err := <-errorsChannel: + if errors.Is(err, http.ErrServerClosed) { + return nil + } + + return err + } +} diff --git a/internal/app/worker_test.go b/internal/app/worker_test.go new file mode 100644 index 0000000..0ef1a2a --- /dev/null +++ b/internal/app/worker_test.go @@ -0,0 +1,178 @@ +package app + +import ( + "context" + "errors" + "strings" + "testing" + "time" +) + +type worktreeJanitor struct { + calls int + maxAge time.Duration + err error +} + +func (j *worktreeJanitor) CleanupStaleWorktrees(_ context.Context, maxAge time.Duration) (int, error) { + j.calls++ + j.maxAge = maxAge + + return 0, j.err +} + +func TestWorktreeJanitorRunsAtStartupAndStopsWithContext(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + janitor := &worktreeJanitor{} + + if err := runWorktreeJanitor(ctx, janitor, 24*time.Hour); err != nil { + t.Fatal(err) + } + if janitor.calls != 1 || janitor.maxAge != 24*time.Hour { + t.Fatalf("janitor startup = (%d,%s)", janitor.calls, janitor.maxAge) + } +} + +func TestWorktreeJanitorReturnsCleanupFailure(t *testing.T) { + want := errors.New("worktree scan failed") + err := runWorktreeJanitor(context.Background(), &worktreeJanitor{err: want}, time.Hour) + if !errors.Is(err, want) { + t.Fatalf("runWorktreeJanitor() error = %v, want %v", err, want) + } +} + +func TestRunWorkerProcessesReturnsDispatcherFailure(t *testing.T) { + want := errors.New("outbox unavailable") + workerStopped := make(chan struct{}) + + err := runWorkerProcesses( + context.Background(), + func(context.Context) error { return want }, + func(ctx context.Context) error { + <-ctx.Done() + close(workerStopped) + + return ctx.Err() + }, + ) + + if !errors.Is(err, want) || !strings.Contains(err.Error(), "workflow dispatcher") { + t.Fatalf("runWorkerProcesses() error = %v, want dispatcher failure", err) + } + select { + case <-workerStopped: + default: + t.Fatal("Hatchet worker was not stopped") + } +} + +func TestRunWorkerProcessesReturnsWorkerFailure(t *testing.T) { + want := errors.New("worker registration failed") + dispatcherStopped := make(chan struct{}) + + err := runWorkerProcesses( + context.Background(), + func(ctx context.Context) error { + <-ctx.Done() + close(dispatcherStopped) + + return nil + }, + func(context.Context) error { return want }, + ) + + if !errors.Is(err, want) || !strings.Contains(err.Error(), "Hatchet worker") { + t.Fatalf("runWorkerProcesses() error = %v, want Hatchet worker failure", err) + } + select { + case <-dispatcherStopped: + default: + t.Fatal("workflow dispatcher was not stopped") + } +} + +func TestRunWorkerProcessesReturnsMetricsFailure(t *testing.T) { + want := errors.New("metrics listener failed") + stopped := make(chan string, 2) + wait := func(name string) workerProcess { + return func(ctx context.Context) error { + <-ctx.Done() + stopped <- name + + return ctx.Err() + } + } + + err := runWorkerProcesses( + context.Background(), + wait("dispatcher"), + wait("worker"), + namedWorkerProcess{name: "worker metrics server", run: func(context.Context) error { return want }}, + ) + if !errors.Is(err, want) || !strings.Contains(err.Error(), "worker metrics server") { + t.Fatalf("runWorkerProcesses() error = %v, want metrics failure", err) + } + if errors.Is(err, context.Canceled) { + t.Fatalf("runWorkerProcesses() error = %v, want peer cancellations to be ignored", err) + } + for range 2 { + select { + case <-stopped: + case <-time.After(time.Second): + t.Fatal("a peer process was not stopped") + } + } +} + +func TestRunWorkerProcessesDoesNotHideUnexpectedCancellation(t *testing.T) { + err := runWorkerProcesses( + context.Background(), + func(context.Context) error { return context.Canceled }, + func(ctx context.Context) error { + <-ctx.Done() + + return ctx.Err() + }, + ) + + if !errors.Is(err, context.Canceled) || !strings.Contains(err.Error(), "workflow dispatcher") { + t.Fatalf("runWorkerProcesses() error = %v, want unexpected dispatcher cancellation", err) + } +} + +func TestRunWorkerProcessesStopsGracefully(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + dispatcherStarted := make(chan struct{}) + workerStarted := make(chan struct{}) + process := func(started chan<- struct{}) workerProcess { + return func(ctx context.Context) error { + close(started) + <-ctx.Done() + + return ctx.Err() + } + } + done := make(chan error, 1) + go func() { + done <- runWorkerProcesses(ctx, process(dispatcherStarted), process(workerStarted)) + }() + + for _, started := range []<-chan struct{}{dispatcherStarted, workerStarted} { + select { + case <-started: + case <-time.After(time.Second): + t.Fatal("worker process did not start") + } + } + cancel() + + select { + case err := <-done: + if err != nil { + t.Fatalf("runWorkerProcesses() error = %v, want nil", err) + } + case <-time.After(time.Second): + t.Fatal("worker processes did not stop") + } +} diff --git a/internal/architecture/dependencies_test.go b/internal/architecture/dependencies_test.go new file mode 100644 index 0000000..215866a --- /dev/null +++ b/internal/architecture/dependencies_test.go @@ -0,0 +1,59 @@ +package architecture_test + +import ( + "go/parser" + "go/token" + "io/fs" + "path/filepath" + "runtime" + "strconv" + "strings" + "testing" +) + +func TestProductionDependencyDirection(t *testing.T) { + _, filename, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("resolve architecture test location") + } + internalRoot := filepath.Clean(filepath.Join(filepath.Dir(filename), "..")) + rules := map[string][]string{ + "biz": {"internal/app", "internal/conf", "internal/data", "internal/server", "internal/service", "internal/worker", "gin-gonic/gin", "spf13/viper", "jackc/pgx", "analyzer/scc"}, + "service": {"internal/app", "internal/conf", "internal/data", "internal/server", "internal/worker", "gin-gonic/gin", "spf13/viper", "jackc/pgx"}, + "data": {"internal/app", "internal/conf", "internal/server", "internal/service", "internal/worker", "gin-gonic/gin", "spf13/viper"}, + "server": {"internal/app", "internal/conf", "internal/data", "internal/worker", "spf13/viper", "jackc/pgx"}, + "worker": {"internal/app", "internal/conf", "internal/data", "internal/server", "internal/service", "gin-gonic/gin", "spf13/viper", "jackc/pgx"}, + } + + for layer, forbidden := range rules { + layerRoot := filepath.Join(internalRoot, layer) + err := filepath.WalkDir(layerRoot, func(path string, entry fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".go") || strings.HasSuffix(entry.Name(), "_test.go") { + return nil + } + file, parseErr := parser.ParseFile(token.NewFileSet(), path, nil, parser.ImportsOnly) + if parseErr != nil { + return parseErr + } + for _, imported := range file.Imports { + pathValue, unquoteErr := strconv.Unquote(imported.Path.Value) + if unquoteErr != nil { + return unquoteErr + } + for _, prefix := range forbidden { + if strings.Contains(pathValue, prefix) { + t.Errorf("%s imports forbidden dependency %s", path, pathValue) + } + } + } + + return nil + }) + if err != nil { + t.Fatalf("inspect %s layer: %v", layer, err) + } + } +} diff --git a/internal/analysis/biz/failure.go b/internal/biz/analysis/failure.go similarity index 84% rename from internal/analysis/biz/failure.go rename to internal/biz/analysis/failure.go index 82b5e04..5bc0ab1 100644 --- a/internal/analysis/biz/failure.go +++ b/internal/biz/analysis/failure.go @@ -1,4 +1,4 @@ -package biz +package analysis type Failure struct { Stage string diff --git a/internal/analysis/biz/idempotency.go b/internal/biz/analysis/idempotency.go similarity index 97% rename from internal/analysis/biz/idempotency.go rename to internal/biz/analysis/idempotency.go index f5cf1ea..f3dedb1 100644 --- a/internal/analysis/biz/idempotency.go +++ b/internal/biz/analysis/idempotency.go @@ -1,4 +1,4 @@ -package biz +package analysis import ( "crypto/sha256" diff --git a/internal/analysis/biz/idempotency_test.go b/internal/biz/analysis/idempotency_test.go similarity index 99% rename from internal/analysis/biz/idempotency_test.go rename to internal/biz/analysis/idempotency_test.go index a9442bc..36286fb 100644 --- a/internal/analysis/biz/idempotency_test.go +++ b/internal/biz/analysis/idempotency_test.go @@ -1,4 +1,4 @@ -package biz +package analysis import ( "testing" diff --git a/internal/analysis/biz/model.go b/internal/biz/analysis/model.go similarity index 99% rename from internal/analysis/biz/model.go rename to internal/biz/analysis/model.go index 703fe29..38894cf 100644 --- a/internal/analysis/biz/model.go +++ b/internal/biz/analysis/model.go @@ -1,4 +1,4 @@ -package biz +package analysis import ( "encoding/json" diff --git a/internal/analysis/biz/profile.go b/internal/biz/analysis/profile.go similarity index 96% rename from internal/analysis/biz/profile.go rename to internal/biz/analysis/profile.go index 2a915cc..03b2a68 100644 --- a/internal/analysis/biz/profile.go +++ b/internal/biz/analysis/profile.go @@ -1,4 +1,4 @@ -package biz +package analysis import ( "bytes" @@ -8,9 +8,9 @@ import ( "strings" "time" - "github.com/fuchencong/mooncode/internal/platform/auth" - "github.com/fuchencong/mooncode/internal/platform/fault" - "github.com/fuchencong/mooncode/internal/platform/pagination" + "github.com/fuchencong/mooncode/internal/biz/auth" + "github.com/fuchencong/mooncode/internal/biz/fault" + "github.com/fuchencong/mooncode/internal/biz/pagination" "github.com/google/uuid" ) diff --git a/internal/analysis/biz/profile_test.go b/internal/biz/analysis/profile_test.go similarity index 98% rename from internal/analysis/biz/profile_test.go rename to internal/biz/analysis/profile_test.go index 9bdbbc6..e1a06c6 100644 --- a/internal/analysis/biz/profile_test.go +++ b/internal/biz/analysis/profile_test.go @@ -1,4 +1,4 @@ -package biz +package analysis import ( "encoding/json" diff --git a/internal/analysis/biz/service.go b/internal/biz/analysis/service.go similarity index 89% rename from internal/analysis/biz/service.go rename to internal/biz/analysis/service.go index f5dfb4a..4ddf91c 100644 --- a/internal/analysis/biz/service.go +++ b/internal/biz/analysis/service.go @@ -1,15 +1,14 @@ -package biz +package analysis import ( "context" "fmt" - identity "github.com/fuchencong/mooncode/internal/identity/biz" - "github.com/fuchencong/mooncode/internal/platform/auth" - "github.com/fuchencong/mooncode/internal/platform/fault" - "github.com/fuchencong/mooncode/internal/platform/pagination" - repository "github.com/fuchencong/mooncode/internal/repository/biz" - "github.com/fuchencong/mooncode/pkg/analyzer/scc" + "github.com/fuchencong/mooncode/internal/biz/auth" + "github.com/fuchencong/mooncode/internal/biz/fault" + identity "github.com/fuchencong/mooncode/internal/biz/identity" + "github.com/fuchencong/mooncode/internal/biz/pagination" + repository "github.com/fuchencong/mooncode/internal/biz/repository" "github.com/google/uuid" ) @@ -26,10 +25,14 @@ type Service struct { authorizer Authorizer repositories Repositories canceller Canceller + analyzer string } -func NewService(store Store, authorizer Authorizer, repositories Repositories, canceller Canceller) *Service { - return &Service{store: store, authorizer: authorizer, repositories: repositories, canceller: canceller} +func NewService(store Store, authorizer Authorizer, repositories Repositories, canceller Canceller, analyzerVersion string) *Service { + return &Service{ + store: store, authorizer: authorizer, repositories: repositories, + canceller: canceller, analyzer: analyzerVersion, + } } type CreateInput struct { @@ -96,8 +99,8 @@ func (s *Service) create(ctx context.Context, actor auth.Actor, workspaceID, rep ID: uuid.New(), WorkspaceID: workspaceID, RepositoryID: repositoryID, SnapshotID: snapshot.ID, CommitSHA: snapshot.CommitSHA, RequestedBy: actor.UserID, DimensionKey: frozen.DimensionKey, ProfileID: frozen.ID, ProfileVersion: frozen.Version, - ProfileSnapshot: append([]byte(nil), frozen.Snapshot...), AnalyzerVersion: scc.Version, - IdempotencyKey: idempotencyKey(workspaceID, repositoryID, snapshot.ID, frozen.ID, frozen.DimensionKey, frozen.Version, scc.Version, frozen.Snapshot), + ProfileSnapshot: append([]byte(nil), frozen.Snapshot...), AnalyzerVersion: s.analyzer, + IdempotencyKey: idempotencyKey(workspaceID, repositoryID, snapshot.ID, frozen.ID, frozen.DimensionKey, frozen.Version, s.analyzer, frozen.Snapshot), Attempt: attempt, RerunOf: rerunOf, Status: "queued", Stage: "queued", } run, err = s.store.Create(ctx, run) diff --git a/internal/analysis/biz/service_test.go b/internal/biz/analysis/service_test.go similarity index 93% rename from internal/analysis/biz/service_test.go rename to internal/biz/analysis/service_test.go index 7fecf86..316a52a 100644 --- a/internal/analysis/biz/service_test.go +++ b/internal/biz/analysis/service_test.go @@ -1,4 +1,4 @@ -package biz +package analysis import ( "context" @@ -6,11 +6,11 @@ import ( "errors" "testing" - identity "github.com/fuchencong/mooncode/internal/identity/biz" - "github.com/fuchencong/mooncode/internal/platform/auth" - "github.com/fuchencong/mooncode/internal/platform/fault" - "github.com/fuchencong/mooncode/internal/platform/pagination" - repository "github.com/fuchencong/mooncode/internal/repository/biz" + "github.com/fuchencong/mooncode/internal/biz/auth" + "github.com/fuchencong/mooncode/internal/biz/fault" + identity "github.com/fuchencong/mooncode/internal/biz/identity" + "github.com/fuchencong/mooncode/internal/biz/pagination" + repository "github.com/fuchencong/mooncode/internal/biz/repository" "github.com/fuchencong/mooncode/pkg/analyzer/scc" "github.com/google/uuid" ) @@ -106,7 +106,7 @@ func (r analysisRepositories) Snapshot(context.Context, uuid.UUID, uuid.UUID, uu func TestRetryAuthorizesWorkspaceBeforeLoadingRun(t *testing.T) { want := errors.New("workspace permission denied") store := &analysisStore{} - service := NewService(store, analysisAuthorizer{err: want}, nil, analysisCanceller{}) + service := NewService(store, analysisAuthorizer{err: want}, nil, analysisCanceller{}, scc.Version) _, err := service.Retry(context.Background(), auth.Actor{UserID: uuid.New()}, uuid.New(), uuid.New()) if !errors.Is(err, want) { @@ -120,7 +120,7 @@ func TestRetryAuthorizesWorkspaceBeforeLoadingRun(t *testing.T) { func TestCancelUsesWorkflowIDReturnedByAtomicCancellation(t *testing.T) { store := &analysisStore{cancelWorkflowRunID: "current-hatchet-run"} var cancelled string - service := NewService(store, analysisAuthorizer{}, nil, analysisCanceller{called: &cancelled}) + service := NewService(store, analysisAuthorizer{}, nil, analysisCanceller{called: &cancelled}, scc.Version) if err := service.Cancel(context.Background(), auth.Actor{UserID: uuid.New()}, uuid.New(), uuid.New()); err != nil { t.Fatal(err) @@ -138,7 +138,7 @@ func TestCreateFreezesSelectedSnapshotAndCommit(t *testing.T) { repository: repository.Repository{ID: repositoryID, WorkspaceID: workspaceID, Status: "ready"}, snapshot: repository.Snapshot{ID: snapshotID, RepositoryID: repositoryID, CommitSHA: "0123456789012345678901234567890123456789", SourceState: "available"}, } - service := NewService(store, analysisAuthorizer{}, repositories, analysisCanceller{}) + service := NewService(store, analysisAuthorizer{}, repositories, analysisCanceller{}, scc.Version) run, err := service.Create(context.Background(), auth.Actor{UserID: uuid.New()}, workspaceID, repositoryID, CreateInput{SnapshotID: snapshotID, ProfileID: profileID}) if err != nil { @@ -166,7 +166,7 @@ func TestRetryKeepsOriginalSnapshot(t *testing.T) { repository: repository.Repository{ID: repositoryID, WorkspaceID: workspaceID, Status: "ready"}, snapshot: repository.Snapshot{ID: snapshotID, RepositoryID: repositoryID, CommitSHA: commitSHA, SourceState: "available"}, } - service := NewService(store, analysisAuthorizer{}, repositories, analysisCanceller{}) + service := NewService(store, analysisAuthorizer{}, repositories, analysisCanceller{}, scc.Version) run, err := service.Retry(context.Background(), auth.Actor{UserID: uuid.New()}, workspaceID, uuid.New()) if err != nil { @@ -188,7 +188,7 @@ func TestRetryKeepsOriginalSnapshot(t *testing.T) { func TestRetryRejectsNonRetryableFailure(t *testing.T) { store := &analysisStore{getRun: Run{Status: "failed", Retryable: false}} - service := NewService(store, analysisAuthorizer{}, nil, analysisCanceller{}) + service := NewService(store, analysisAuthorizer{}, nil, analysisCanceller{}, scc.Version) _, err := service.Retry(context.Background(), auth.Actor{UserID: uuid.New()}, uuid.New(), uuid.New()) problem, ok := fault.From(err) diff --git a/internal/analysis/biz/store.go b/internal/biz/analysis/store.go similarity index 94% rename from internal/analysis/biz/store.go rename to internal/biz/analysis/store.go index b9f7320..0932770 100644 --- a/internal/analysis/biz/store.go +++ b/internal/biz/analysis/store.go @@ -1,10 +1,10 @@ -package biz +package analysis import ( "context" "encoding/json" - "github.com/fuchencong/mooncode/internal/platform/pagination" + "github.com/fuchencong/mooncode/internal/biz/pagination" "github.com/google/uuid" ) diff --git a/internal/analysis/workflow/failure.go b/internal/biz/analysis/workflow/failure.go similarity index 88% rename from internal/analysis/workflow/failure.go rename to internal/biz/analysis/workflow/failure.go index 2b6269f..1b72bca 100644 --- a/internal/analysis/workflow/failure.go +++ b/internal/biz/analysis/workflow/failure.go @@ -3,8 +3,8 @@ package workflow import ( "errors" - analysis "github.com/fuchencong/mooncode/internal/analysis/biz" - workflowbiz "github.com/fuchencong/mooncode/internal/workflow/biz" + analysis "github.com/fuchencong/mooncode/internal/biz/analysis" + workflowbiz "github.com/fuchencong/mooncode/internal/biz/workflow" ) const ( diff --git a/internal/analysis/workflow/runner.go b/internal/biz/analysis/workflow/runner.go similarity index 94% rename from internal/analysis/workflow/runner.go rename to internal/biz/analysis/workflow/runner.go index a88d456..a50b91d 100644 --- a/internal/analysis/workflow/runner.go +++ b/internal/biz/analysis/workflow/runner.go @@ -8,13 +8,13 @@ import ( "runtime" "time" - analysis "github.com/fuchencong/mooncode/internal/analysis/biz" - identity "github.com/fuchencong/mooncode/internal/identity/biz" - "github.com/fuchencong/mooncode/internal/platform/auth" + analysis "github.com/fuchencong/mooncode/internal/biz/analysis" + "github.com/fuchencong/mooncode/internal/biz/auth" + "github.com/fuchencong/mooncode/internal/biz/fault" + identity "github.com/fuchencong/mooncode/internal/biz/identity" "github.com/fuchencong/mooncode/pkg/analyzer" "github.com/fuchencong/mooncode/pkg/gitrepo" "github.com/google/uuid" - "github.com/jackc/pgx/v5" ) type Authorizer interface { @@ -36,7 +36,7 @@ func NewRunner(store analysis.Store, authorizer Authorizer, git gitrepo.Manager, func (r *Runner) Execute(ctx context.Context, runID uuid.UUID) error { work, err := r.store.LoadWork(ctx, runID) if err != nil { - if errors.Is(err, pgx.ErrNoRows) { + if fault.IsKind(err, fault.NotFound) { return nil } @@ -44,7 +44,7 @@ func (r *Runner) Execute(ctx context.Context, runID uuid.UUID) error { } if work.Run.Status == "queued" { if _, err = r.store.Start(ctx, runID); err != nil { - if errors.Is(err, pgx.ErrNoRows) { + if fault.IsKind(err, fault.NotFound) { return nil } @@ -107,7 +107,7 @@ func (r *Runner) Execute(ctx context.Context, runID uuid.UUID) error { Environment: runtime.Version() + "/" + runtime.GOOS + "/" + runtime.GOARCH, } _, err = r.store.Finish(ctx, runID, result.Data, result.RawArtifact, execution) - if errors.Is(err, pgx.ErrNoRows) { + if fault.IsKind(err, fault.NotFound) { return nil } if err != nil { diff --git a/internal/analysis/workflow/runner_test.go b/internal/biz/analysis/workflow/runner_test.go similarity index 98% rename from internal/analysis/workflow/runner_test.go rename to internal/biz/analysis/workflow/runner_test.go index fd32cd1..91b8b81 100644 --- a/internal/analysis/workflow/runner_test.go +++ b/internal/biz/analysis/workflow/runner_test.go @@ -8,9 +8,9 @@ import ( "testing" "time" - analysis "github.com/fuchencong/mooncode/internal/analysis/biz" - identity "github.com/fuchencong/mooncode/internal/identity/biz" - "github.com/fuchencong/mooncode/internal/platform/auth" + analysis "github.com/fuchencong/mooncode/internal/biz/analysis" + "github.com/fuchencong/mooncode/internal/biz/auth" + identity "github.com/fuchencong/mooncode/internal/biz/identity" "github.com/fuchencong/mooncode/pkg/analyzer" "github.com/fuchencong/mooncode/pkg/analyzer/scc" "github.com/fuchencong/mooncode/pkg/gitrepo" diff --git a/internal/platform/auth/actor.go b/internal/biz/auth/actor.go similarity index 85% rename from internal/platform/auth/actor.go rename to internal/biz/auth/actor.go index 51f4aaf..37f8e92 100644 --- a/internal/platform/auth/actor.go +++ b/internal/biz/auth/actor.go @@ -1,3 +1,4 @@ +// Package auth defines transport-independent authenticated identity values. package auth import ( diff --git a/internal/biz/auth/identity.go b/internal/biz/auth/identity.go new file mode 100644 index 0000000..a700fad --- /dev/null +++ b/internal/biz/auth/identity.go @@ -0,0 +1,8 @@ +package auth + +type Identity struct { + Subject string + DisplayName string + Username string + Email string +} diff --git a/internal/channel/command/model.go b/internal/biz/channel/command/model.go similarity index 100% rename from internal/channel/command/model.go rename to internal/biz/channel/command/model.go diff --git a/internal/channel/command/options.go b/internal/biz/channel/command/options.go similarity index 100% rename from internal/channel/command/options.go rename to internal/biz/channel/command/options.go diff --git a/internal/channel/command/service.go b/internal/biz/channel/command/service.go similarity index 96% rename from internal/channel/command/service.go rename to internal/biz/channel/command/service.go index 5a2bb05..fcf2942 100644 --- a/internal/channel/command/service.go +++ b/internal/biz/channel/command/service.go @@ -13,12 +13,12 @@ import ( "strings" "time" - analysis "github.com/fuchencong/mooncode/internal/analysis/biz" - identity "github.com/fuchencong/mooncode/internal/identity/biz" - "github.com/fuchencong/mooncode/internal/platform/auth" - "github.com/fuchencong/mooncode/internal/platform/fault" - "github.com/fuchencong/mooncode/internal/platform/pagination" - repository "github.com/fuchencong/mooncode/internal/repository/biz" + analysis "github.com/fuchencong/mooncode/internal/biz/analysis" + "github.com/fuchencong/mooncode/internal/biz/auth" + "github.com/fuchencong/mooncode/internal/biz/fault" + identity "github.com/fuchencong/mooncode/internal/biz/identity" + "github.com/fuchencong/mooncode/internal/biz/pagination" + repository "github.com/fuchencong/mooncode/internal/biz/repository" channelpkg "github.com/fuchencong/mooncode/pkg/channel" "github.com/google/uuid" ) diff --git a/internal/channel/command/service_test.go b/internal/biz/channel/command/service_test.go similarity index 96% rename from internal/channel/command/service_test.go rename to internal/biz/channel/command/service_test.go index 048cd4d..6af68dc 100644 --- a/internal/channel/command/service_test.go +++ b/internal/biz/channel/command/service_test.go @@ -9,11 +9,11 @@ import ( "testing" "time" - analysis "github.com/fuchencong/mooncode/internal/analysis/biz" - identity "github.com/fuchencong/mooncode/internal/identity/biz" - "github.com/fuchencong/mooncode/internal/platform/auth" - "github.com/fuchencong/mooncode/internal/platform/pagination" - repository "github.com/fuchencong/mooncode/internal/repository/biz" + analysis "github.com/fuchencong/mooncode/internal/biz/analysis" + "github.com/fuchencong/mooncode/internal/biz/auth" + identity "github.com/fuchencong/mooncode/internal/biz/identity" + "github.com/fuchencong/mooncode/internal/biz/pagination" + repository "github.com/fuchencong/mooncode/internal/biz/repository" channelpkg "github.com/fuchencong/mooncode/pkg/channel" "github.com/google/uuid" ) diff --git a/internal/channel/biz/config.go b/internal/biz/channel/config.go similarity index 97% rename from internal/channel/biz/config.go rename to internal/biz/channel/config.go index eabb556..2b72624 100644 --- a/internal/channel/biz/config.go +++ b/internal/biz/channel/config.go @@ -1,11 +1,11 @@ -package biz +package channel import ( "encoding/json" "fmt" "strings" - "github.com/fuchencong/mooncode/internal/platform/fault" + "github.com/fuchencong/mooncode/internal/biz/fault" ) type configuration struct { diff --git a/internal/channel/biz/config_test.go b/internal/biz/channel/config_test.go similarity index 99% rename from internal/channel/biz/config_test.go rename to internal/biz/channel/config_test.go index 966e854..d979967 100644 --- a/internal/channel/biz/config_test.go +++ b/internal/biz/channel/config_test.go @@ -1,4 +1,4 @@ -package biz +package channel import ( "encoding/json" diff --git a/internal/channel/biz/model.go b/internal/biz/channel/model.go similarity index 99% rename from internal/channel/biz/model.go rename to internal/biz/channel/model.go index 1a65e2e..2b3910e 100644 --- a/internal/channel/biz/model.go +++ b/internal/biz/channel/model.go @@ -1,4 +1,4 @@ -package biz +package channel import ( "encoding/json" diff --git a/internal/biz/channel/runtime.go b/internal/biz/channel/runtime.go new file mode 100644 index 0000000..b00a575 --- /dev/null +++ b/internal/biz/channel/runtime.go @@ -0,0 +1,28 @@ +package channel + +import ( + "context" + "encoding/json" + + channelpkg "github.com/fuchencong/mooncode/pkg/channel" + "github.com/google/uuid" +) + +type RuntimeStore interface { + ListRuntimeChannels(context.Context) ([]RuntimeConfiguration, error) + SaveInbound(context.Context, RuntimeConfiguration, channelpkg.InboundMessage) (bool, error) + MarkRuntimeConnected(context.Context, uuid.UUID, int64) error + MarkRuntimeError(context.Context, uuid.UUID, int64, string) error +} + +// RuntimeConfiguration is the immutable channel state consumed by connection workers. +type RuntimeConfiguration struct { + ID uuid.UUID + WorkspaceID uuid.UUID + Type string + Version int64 + Ciphertext []byte + Nonce []byte + KeyVersion int + Config json.RawMessage +} diff --git a/internal/channel/biz/service.go b/internal/biz/channel/service.go similarity index 95% rename from internal/channel/biz/service.go rename to internal/biz/channel/service.go index 3c5b599..b432e9b 100644 --- a/internal/channel/biz/service.go +++ b/internal/biz/channel/service.go @@ -1,15 +1,15 @@ -package biz +package channel import ( "context" "encoding/json" "strings" - identity "github.com/fuchencong/mooncode/internal/identity/biz" - "github.com/fuchencong/mooncode/internal/platform/auth" - "github.com/fuchencong/mooncode/internal/platform/fault" - "github.com/fuchencong/mooncode/internal/platform/pagination" - "github.com/fuchencong/mooncode/internal/platform/secret" + "github.com/fuchencong/mooncode/internal/biz/auth" + "github.com/fuchencong/mooncode/internal/biz/fault" + identity "github.com/fuchencong/mooncode/internal/biz/identity" + "github.com/fuchencong/mooncode/internal/biz/pagination" + "github.com/fuchencong/mooncode/internal/biz/secret" "github.com/google/uuid" ) diff --git a/internal/channel/biz/service_test.go b/internal/biz/channel/service_test.go similarity index 96% rename from internal/channel/biz/service_test.go rename to internal/biz/channel/service_test.go index cebbe21..51b742a 100644 --- a/internal/channel/biz/service_test.go +++ b/internal/biz/channel/service_test.go @@ -1,12 +1,12 @@ -package biz +package channel import ( "context" "encoding/json" "testing" - identity "github.com/fuchencong/mooncode/internal/identity/biz" - "github.com/fuchencong/mooncode/internal/platform/auth" + "github.com/fuchencong/mooncode/internal/biz/auth" + identity "github.com/fuchencong/mooncode/internal/biz/identity" "github.com/google/uuid" ) diff --git a/internal/channel/biz/subscription.go b/internal/biz/channel/subscription.go similarity index 92% rename from internal/channel/biz/subscription.go rename to internal/biz/channel/subscription.go index e07f145..8000ac2 100644 --- a/internal/channel/biz/subscription.go +++ b/internal/biz/channel/subscription.go @@ -1,9 +1,9 @@ -package biz +package channel import ( "sort" - "github.com/fuchencong/mooncode/internal/platform/fault" + "github.com/fuchencong/mooncode/internal/biz/fault" ) const ( diff --git a/internal/channel/workflow/model.go b/internal/biz/channel/workflow/model.go similarity index 100% rename from internal/channel/workflow/model.go rename to internal/biz/channel/workflow/model.go diff --git a/internal/channel/workflow/runner.go b/internal/biz/channel/workflow/runner.go similarity index 93% rename from internal/channel/workflow/runner.go rename to internal/biz/channel/workflow/runner.go index 9de49dc..6e971f9 100644 --- a/internal/channel/workflow/runner.go +++ b/internal/biz/channel/workflow/runner.go @@ -3,15 +3,14 @@ package workflow import ( "context" "encoding/json" - "errors" "fmt" "strings" - channelbiz "github.com/fuchencong/mooncode/internal/channel/biz" - workflowbiz "github.com/fuchencong/mooncode/internal/workflow/biz" + channelbiz "github.com/fuchencong/mooncode/internal/biz/channel" + "github.com/fuchencong/mooncode/internal/biz/fault" + workflowbiz "github.com/fuchencong/mooncode/internal/biz/workflow" channelpkg "github.com/fuchencong/mooncode/pkg/channel" "github.com/google/uuid" - "github.com/jackc/pgx/v5" ) type Store interface { @@ -97,7 +96,7 @@ func (r *Runner) Execute(ctx context.Context, notificationID uuid.UUID) error { } func (r *Runner) MarkFailed(ctx context.Context, notificationID uuid.UUID, cause error) error { - if _, err := r.store.LoadDelivery(ctx, notificationID); errors.Is(err, pgx.ErrNoRows) { + if _, err := r.store.LoadDelivery(ctx, notificationID); fault.IsKind(err, fault.NotFound) { return nil } else if err != nil { return err diff --git a/internal/channel/workflow/runner_test.go b/internal/biz/channel/workflow/runner_test.go similarity index 100% rename from internal/channel/workflow/runner_test.go rename to internal/biz/channel/workflow/runner_test.go diff --git a/internal/platform/fault/fault.go b/internal/biz/fault/fault.go similarity index 89% rename from internal/platform/fault/fault.go rename to internal/biz/fault/fault.go index 770303c..84760f5 100644 --- a/internal/platform/fault/fault.go +++ b/internal/biz/fault/fault.go @@ -57,3 +57,9 @@ func From(err error) (*Error, bool) { return result, ok } + +func IsKind(err error, kind Kind) bool { + problem, ok := From(err) + + return ok && problem.Kind() == kind +} diff --git a/internal/platform/fault/fault_test.go b/internal/biz/fault/fault_test.go similarity index 100% rename from internal/platform/fault/fault_test.go rename to internal/biz/fault/fault_test.go diff --git a/internal/identity/biz/model.go b/internal/biz/identity/model.go similarity index 98% rename from internal/identity/biz/model.go rename to internal/biz/identity/model.go index 6020824..fd06ded 100644 --- a/internal/identity/biz/model.go +++ b/internal/biz/identity/model.go @@ -1,4 +1,4 @@ -package biz +package identity import ( "time" diff --git a/internal/identity/biz/provider.go b/internal/biz/identity/provider.go similarity index 78% rename from internal/identity/biz/provider.go rename to internal/biz/identity/provider.go index c226b4d..59c0bbc 100644 --- a/internal/identity/biz/provider.go +++ b/internal/biz/identity/provider.go @@ -1,16 +1,13 @@ -package biz +package identity import ( "context" - "encoding/json" - "fmt" - "net/http" "strings" "time" - "github.com/fuchencong/mooncode/internal/platform/fault" - "github.com/fuchencong/mooncode/internal/platform/pagination" - "github.com/fuchencong/mooncode/internal/platform/secret" + "github.com/fuchencong/mooncode/internal/biz/fault" + "github.com/fuchencong/mooncode/internal/biz/pagination" + "github.com/fuchencong/mooncode/internal/biz/secret" "github.com/google/uuid" ) @@ -197,63 +194,3 @@ func normalizeProviderInput(providerType, baseURL, token string) (string, string return providerType, baseURL, token, nil } - -type HTTPProviderValidator struct{ client *http.Client } - -func NewHTTPProviderValidator(client *http.Client) *HTTPProviderValidator { - if client == nil { - client = &http.Client{Timeout: 15 * time.Second} - } - return &HTTPProviderValidator{client: client} -} - -func (v *HTTPProviderValidator) Validate(ctx context.Context, providerType, baseURL, token string) (ProviderProfile, error) { - endpoint := strings.TrimRight(baseURL, "/") + "/api/v4/user" - if providerType == "github" { - if baseURL == "https://github.com" { - endpoint = "https://api.github.com/user" - } else { - endpoint = strings.TrimRight(baseURL, "/") + "/api/v3/user" - } - } - request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) - if err != nil { - return ProviderProfile{}, err - } - request.Header.Set("Accept", "application/json") - request.Header.Set("Authorization", "Bearer "+token) - request.Header.Set("User-Agent", "MoonCode") - response, err := v.client.Do(request) - if err != nil { - return ProviderProfile{}, err - } - defer func() { _ = response.Body.Close() }() - if response.StatusCode != http.StatusOK { - return ProviderProfile{}, fmt.Errorf("provider returned HTTP %d", response.StatusCode) - } - var body struct { - ID any `json:"id"` - Login string `json:"login"` - Username string `json:"username"` - Name string `json:"name"` - } - if err := json.NewDecoder(response.Body).Decode(&body); err != nil { - return ProviderProfile{}, err - } - login := body.Login - if login == "" { - login = body.Username - } - scopesHeader := response.Header.Get("X-OAuth-Scopes") - if providerType == "gitlab" { - scopesHeader = response.Header.Get("X-OAuth-Scopes") - } - var scopes []string - for _, scope := range strings.Split(scopesHeader, ",") { - if value := strings.TrimSpace(scope); value != "" { - scopes = append(scopes, value) - } - } - - return ProviderProfile{AccountID: fmt.Sprint(body.ID), Login: login, Name: body.Name, Scopes: scopes}, nil -} diff --git a/internal/identity/biz/provider_test.go b/internal/biz/identity/provider_test.go similarity index 98% rename from internal/identity/biz/provider_test.go rename to internal/biz/identity/provider_test.go index de9475b..d0d2cb1 100644 --- a/internal/identity/biz/provider_test.go +++ b/internal/biz/identity/provider_test.go @@ -1,4 +1,4 @@ -package biz +package identity import "testing" diff --git a/internal/identity/biz/service.go b/internal/biz/identity/service.go similarity index 97% rename from internal/identity/biz/service.go rename to internal/biz/identity/service.go index 126511b..df00518 100644 --- a/internal/identity/biz/service.go +++ b/internal/biz/identity/service.go @@ -1,4 +1,4 @@ -package biz +package identity import ( "context" @@ -10,9 +10,9 @@ import ( "strings" "time" - "github.com/fuchencong/mooncode/internal/platform/auth" - "github.com/fuchencong/mooncode/internal/platform/fault" - "github.com/fuchencong/mooncode/internal/platform/pagination" + "github.com/fuchencong/mooncode/internal/biz/auth" + "github.com/fuchencong/mooncode/internal/biz/fault" + "github.com/fuchencong/mooncode/internal/biz/pagination" "github.com/google/uuid" ) diff --git a/internal/identity/biz/store.go b/internal/biz/identity/store.go similarity index 89% rename from internal/identity/biz/store.go rename to internal/biz/identity/store.go index 40d53c7..36927ee 100644 --- a/internal/identity/biz/store.go +++ b/internal/biz/identity/store.go @@ -1,11 +1,11 @@ -package biz +package identity import ( "context" "time" - "github.com/fuchencong/mooncode/internal/platform/auth" - "github.com/fuchencong/mooncode/internal/platform/pagination" + "github.com/fuchencong/mooncode/internal/biz/auth" + "github.com/fuchencong/mooncode/internal/biz/pagination" "github.com/google/uuid" ) diff --git a/internal/biz/option.go b/internal/biz/option.go new file mode 100644 index 0000000..9a04c46 --- /dev/null +++ b/internal/biz/option.go @@ -0,0 +1,50 @@ +package biz + +import ( + "errors" + "strings" + "time" +) + +type Settings struct { + identityIssuer string + analyzerVersion string + analysisTimeout time.Duration + appURL string +} + +// Option projects process configuration into business construction settings. +type Option func(*Settings) + +func WithIdentityIssuer(issuer string) Option { + return func(settings *Settings) { settings.identityIssuer = issuer } +} + +func WithAnalyzer(version string, timeout time.Duration) Option { + return func(settings *Settings) { + settings.analyzerVersion = version + settings.analysisTimeout = timeout + } +} + +func WithApplicationURL(url string) Option { + return func(settings *Settings) { settings.appURL = url } +} + +func NewSettings(options ...Option) (*Settings, error) { + settings := &Settings{} + for _, option := range options { + option(settings) + } + if strings.TrimSpace(settings.identityIssuer) == "" { + return nil, errors.New("business identity issuer is required") + } + if strings.TrimSpace(settings.analyzerVersion) == "" || settings.analysisTimeout <= 0 { + return nil, errors.New("business analyzer version and timeout are required") + } + if strings.TrimSpace(settings.appURL) == "" { + return nil, errors.New("business application URL is required") + } + + return settings, nil +} diff --git a/internal/biz/option_test.go b/internal/biz/option_test.go new file mode 100644 index 0000000..870eedd --- /dev/null +++ b/internal/biz/option_test.go @@ -0,0 +1,25 @@ +package biz + +import ( + "testing" + "time" +) + +func TestSettingsValidation(t *testing.T) { + valid := []Option{ + WithIdentityIssuer("issuer"), + WithAnalyzer("scc", time.Minute), + WithApplicationURL("https://mooncode.example"), + } + if _, err := NewSettings(valid...); err != nil { + t.Fatalf("valid settings: %v", err) + } + + for index := range valid { + options := append([]Option(nil), valid[:index]...) + options = append(options, valid[index+1:]...) + if _, err := NewSettings(options...); err == nil { + t.Fatalf("missing option %d was accepted", index) + } + } +} diff --git a/internal/platform/pagination/pagination.go b/internal/biz/pagination/pagination.go similarity index 100% rename from internal/platform/pagination/pagination.go rename to internal/biz/pagination/pagination.go diff --git a/internal/platform/pagination/pagination_test.go b/internal/biz/pagination/pagination_test.go similarity index 100% rename from internal/platform/pagination/pagination_test.go rename to internal/biz/pagination/pagination_test.go diff --git a/internal/biz/provider.go b/internal/biz/provider.go new file mode 100644 index 0000000..5a5107c --- /dev/null +++ b/internal/biz/provider.go @@ -0,0 +1,128 @@ +package biz + +import ( + analysis "github.com/fuchencong/mooncode/internal/biz/analysis" + analysisworkflow "github.com/fuchencong/mooncode/internal/biz/analysis/workflow" + channel "github.com/fuchencong/mooncode/internal/biz/channel" + channelcommand "github.com/fuchencong/mooncode/internal/biz/channel/command" + channelworkflow "github.com/fuchencong/mooncode/internal/biz/channel/workflow" + identity "github.com/fuchencong/mooncode/internal/biz/identity" + repository "github.com/fuchencong/mooncode/internal/biz/repository" + repositoryworkflow "github.com/fuchencong/mooncode/internal/biz/repository/workflow" + retention "github.com/fuchencong/mooncode/internal/biz/retention" + retentionworkflow "github.com/fuchencong/mooncode/internal/biz/retention/workflow" + "github.com/fuchencong/mooncode/internal/biz/secret" + "github.com/fuchencong/mooncode/pkg/analyzer" + channelpkg "github.com/fuchencong/mooncode/pkg/channel" + "github.com/fuchencong/mooncode/pkg/gitrepo" + "github.com/google/wire" +) + +type RetentionMetrics = retentionworkflow.Metrics +type RepositoryCanceller = repository.Canceller +type AnalysisCanceller = analysis.Canceller + +// ProviderSet contains MoonCode business use cases and workflow rules. +var ProviderSet = wire.NewSet( + NewSettings, + NewIdentityService, + NewProviderService, + NewRepositoryRunner, + NewAnalysisRunner, + NewNotificationRunner, + NewRetentionScheduler, + NewRetentionRunner, + NewRepositoryService, + NewAnalysisService, + NewChannelService, + NewCommandService, +) + +func NewIdentityService(store identity.Store, settings *Settings) *identity.Service { + return identity.NewService(store, settings.identityIssuer) +} + +func NewProviderService( + store identity.ProviderStore, + cipher secret.Cipher, + validator identity.ProviderValidator, +) *identity.ProviderService { + return identity.NewProviderService(store, cipher, validator) +} + +func NewRepositoryRunner( + store repository.Store, + identityService *identity.Service, + providerService *identity.ProviderService, + git gitrepo.Manager, +) *repositoryworkflow.Runner { + return repositoryworkflow.NewRunner(store, identityService, providerService, git) +} + +func NewAnalysisRunner( + store analysis.Store, + identityService *identity.Service, + git gitrepo.Manager, + engine analyzer.Analyzer, + settings *Settings, +) *analysisworkflow.Runner { + return analysisworkflow.NewRunner(store, identityService, git, engine, settings.analysisTimeout) +} + +func NewNotificationRunner( + store channelworkflow.Store, + cipher secret.Cipher, + factories []channelpkg.Factory, +) *channelworkflow.Runner { + return channelworkflow.NewRunner(store, cipher, factories...) +} + +func NewRetentionScheduler(store retention.Store) *retentionworkflow.Scheduler { + return retentionworkflow.NewScheduler(store) +} + +func NewRetentionRunner( + store retention.Store, + git gitrepo.Manager, + metrics retentionworkflow.Metrics, +) *retentionworkflow.Runner { + return retentionworkflow.NewRunner(store, git, retentionworkflow.WithMetrics(metrics)) +} + +func NewRepositoryService( + store repository.Store, + identityService *identity.Service, + providerService *identity.ProviderService, + canceller repository.Canceller, + git gitrepo.Manager, +) *repository.Service { + return repository.NewService(store, identityService, providerService, canceller, git) +} + +func NewAnalysisService( + store analysis.Store, + identityService *identity.Service, + repositories analysis.Repositories, + canceller analysis.Canceller, + settings *Settings, +) *analysis.Service { + return analysis.NewService(store, identityService, repositories, canceller, settings.analyzerVersion) +} + +func NewChannelService( + store channel.Store, + identityService *identity.Service, + cipher secret.Cipher, +) *channel.Service { + return channel.NewService(store, identityService, cipher) +} + +func NewCommandService( + store channelcommand.Store, + identityService *identity.Service, + repositories *repository.Service, + analyses *analysis.Service, + settings *Settings, +) *channelcommand.Service { + return channelcommand.NewService(store, identityService, repositories, analyses, settings.appURL) +} diff --git a/internal/repository/biz/failure.go b/internal/biz/repository/failure.go similarity index 75% rename from internal/repository/biz/failure.go rename to internal/biz/repository/failure.go index e093481..d434c88 100644 --- a/internal/repository/biz/failure.go +++ b/internal/biz/repository/failure.go @@ -1,4 +1,4 @@ -package biz +package repository type Failure struct { Code string diff --git a/internal/repository/biz/model.go b/internal/biz/repository/model.go similarity index 99% rename from internal/repository/biz/model.go rename to internal/biz/repository/model.go index c2f06cf..7f0b6c6 100644 --- a/internal/repository/biz/model.go +++ b/internal/biz/repository/model.go @@ -1,4 +1,4 @@ -package biz +package repository import ( "time" diff --git a/internal/repository/biz/service.go b/internal/biz/repository/service.go similarity index 97% rename from internal/repository/biz/service.go rename to internal/biz/repository/service.go index 8a63511..de65505 100644 --- a/internal/repository/biz/service.go +++ b/internal/biz/repository/service.go @@ -1,13 +1,13 @@ -package biz +package repository import ( "context" "strings" - identity "github.com/fuchencong/mooncode/internal/identity/biz" - "github.com/fuchencong/mooncode/internal/platform/auth" - "github.com/fuchencong/mooncode/internal/platform/fault" - "github.com/fuchencong/mooncode/internal/platform/pagination" + "github.com/fuchencong/mooncode/internal/biz/auth" + "github.com/fuchencong/mooncode/internal/biz/fault" + identity "github.com/fuchencong/mooncode/internal/biz/identity" + "github.com/fuchencong/mooncode/internal/biz/pagination" "github.com/fuchencong/mooncode/pkg/gitrepo" "github.com/google/uuid" ) diff --git a/internal/repository/biz/service_test.go b/internal/biz/repository/service_test.go similarity index 98% rename from internal/repository/biz/service_test.go rename to internal/biz/repository/service_test.go index 952685c..58a91a6 100644 --- a/internal/repository/biz/service_test.go +++ b/internal/biz/repository/service_test.go @@ -1,13 +1,13 @@ -package biz +package repository import ( "context" "errors" "testing" - identity "github.com/fuchencong/mooncode/internal/identity/biz" - "github.com/fuchencong/mooncode/internal/platform/auth" - "github.com/fuchencong/mooncode/internal/platform/pagination" + "github.com/fuchencong/mooncode/internal/biz/auth" + identity "github.com/fuchencong/mooncode/internal/biz/identity" + "github.com/fuchencong/mooncode/internal/biz/pagination" "github.com/fuchencong/mooncode/pkg/gitrepo" "github.com/google/uuid" ) diff --git a/internal/repository/biz/store.go b/internal/biz/repository/store.go similarity index 95% rename from internal/repository/biz/store.go rename to internal/biz/repository/store.go index 349b3ef..fddb1a5 100644 --- a/internal/repository/biz/store.go +++ b/internal/biz/repository/store.go @@ -1,9 +1,9 @@ -package biz +package repository import ( "context" - "github.com/fuchencong/mooncode/internal/platform/pagination" + "github.com/fuchencong/mooncode/internal/biz/pagination" "github.com/fuchencong/mooncode/pkg/gitrepo" "github.com/google/uuid" ) diff --git a/internal/repository/workflow/runner.go b/internal/biz/repository/workflow/runner.go similarity index 91% rename from internal/repository/workflow/runner.go rename to internal/biz/repository/workflow/runner.go index 323e492..a0c8169 100644 --- a/internal/repository/workflow/runner.go +++ b/internal/biz/repository/workflow/runner.go @@ -7,14 +7,13 @@ import ( "strings" "time" - identity "github.com/fuchencong/mooncode/internal/identity/biz" - "github.com/fuchencong/mooncode/internal/platform/auth" - "github.com/fuchencong/mooncode/internal/platform/fault" - repository "github.com/fuchencong/mooncode/internal/repository/biz" - workflowbiz "github.com/fuchencong/mooncode/internal/workflow/biz" + "github.com/fuchencong/mooncode/internal/biz/auth" + "github.com/fuchencong/mooncode/internal/biz/fault" + identity "github.com/fuchencong/mooncode/internal/biz/identity" + repository "github.com/fuchencong/mooncode/internal/biz/repository" + workflowbiz "github.com/fuchencong/mooncode/internal/biz/workflow" "github.com/fuchencong/mooncode/pkg/gitrepo" "github.com/google/uuid" - "github.com/jackc/pgx/v5" ) type Authorizer interface { @@ -77,7 +76,7 @@ func (r *Runner) Execute(ctx context.Context, operationID uuid.UUID) error { if credentialErr != nil { cause := fmt.Errorf("load current user's provider credential: %w", credentialErr) problem, isProblem := fault.From(credentialErr) - if errors.Is(credentialErr, pgx.ErrNoRows) || (isProblem && (problem.Code() == "provider.credential_stale" || problem.Code() == "provider.connection_unavailable")) { + if fault.IsKind(credentialErr, fault.NotFound) || (isProblem && (problem.Code() == "provider.credential_stale" || problem.Code() == "provider.connection_unavailable")) { return workflowbiz.Permanent(cause) } @@ -144,7 +143,7 @@ func (r *Runner) reconcileSnapshot(work repository.WorkItem, snapshotID uuid.UUI return nil } - if !errors.Is(err, pgx.ErrNoRows) { + if !fault.IsKind(err, fault.NotFound) { return completionErr } if err := r.git.ReleaseSnapshot(reconcileContext, work.Repository.ID, snapshotID, snapshot.CommitSHA); err != nil { @@ -156,7 +155,7 @@ func (r *Runner) reconcileSnapshot(work repository.WorkItem, snapshotID uuid.UUI func (r *Runner) MarkFailed(ctx context.Context, operationID uuid.UUID, cause error) error { work, err := r.store.GetWorkItem(ctx, operationID) - if errors.Is(err, pgx.ErrNoRows) { + if fault.IsKind(err, fault.NotFound) { return nil } if err != nil { diff --git a/internal/repository/workflow/runner_test.go b/internal/biz/repository/workflow/runner_test.go similarity index 98% rename from internal/repository/workflow/runner_test.go rename to internal/biz/repository/workflow/runner_test.go index ac39bd2..cc29271 100644 --- a/internal/repository/workflow/runner_test.go +++ b/internal/biz/repository/workflow/runner_test.go @@ -5,14 +5,13 @@ import ( "errors" "testing" - identity "github.com/fuchencong/mooncode/internal/identity/biz" - "github.com/fuchencong/mooncode/internal/platform/auth" - "github.com/fuchencong/mooncode/internal/platform/fault" - repository "github.com/fuchencong/mooncode/internal/repository/biz" - workflowbiz "github.com/fuchencong/mooncode/internal/workflow/biz" + "github.com/fuchencong/mooncode/internal/biz/auth" + "github.com/fuchencong/mooncode/internal/biz/fault" + identity "github.com/fuchencong/mooncode/internal/biz/identity" + repository "github.com/fuchencong/mooncode/internal/biz/repository" + workflowbiz "github.com/fuchencong/mooncode/internal/biz/workflow" "github.com/fuchencong/mooncode/pkg/gitrepo" "github.com/google/uuid" - "github.com/jackc/pgx/v5" ) type repositoryWorkflowStore struct { @@ -307,7 +306,7 @@ func TestRunnerReleasesSnapshotPinWhenDatabaseCompletionDidNotCommit(t *testing. completionErr := errors.New("complete repository operation") store := &repositoryWorkflowStore{ completeErr: completionErr, - snapshotLookupErr: pgx.ErrNoRows, + snapshotLookupErr: fault.New(fault.NotFound, "repository.snapshot_not_found", "Repository snapshot not found"), work: repository.WorkItem{ Repository: repository.Repository{ID: uuid.New(), WorkspaceID: uuid.New(), ConfigVersion: 1}, Operation: repository.Operation{ diff --git a/internal/retention/biz/model.go b/internal/biz/retention/model.go similarity index 96% rename from internal/retention/biz/model.go rename to internal/biz/retention/model.go index 3c8a4be..342ce2f 100644 --- a/internal/retention/biz/model.go +++ b/internal/biz/retention/model.go @@ -1,4 +1,4 @@ -package biz +package retention import ( "time" diff --git a/internal/retention/biz/store.go b/internal/biz/retention/store.go similarity index 97% rename from internal/retention/biz/store.go rename to internal/biz/retention/store.go index 6a6e756..77f3458 100644 --- a/internal/retention/biz/store.go +++ b/internal/biz/retention/store.go @@ -1,4 +1,4 @@ -package biz +package retention import ( "context" diff --git a/internal/retention/workflow/options.go b/internal/biz/retention/workflow/options.go similarity index 100% rename from internal/retention/workflow/options.go rename to internal/biz/retention/workflow/options.go diff --git a/internal/retention/workflow/runner.go b/internal/biz/retention/workflow/runner.go similarity index 91% rename from internal/retention/workflow/runner.go rename to internal/biz/retention/workflow/runner.go index b710e8c..03fcc57 100644 --- a/internal/retention/workflow/runner.go +++ b/internal/biz/retention/workflow/runner.go @@ -4,11 +4,11 @@ import ( "context" "errors" - retention "github.com/fuchencong/mooncode/internal/retention/biz" - workflowbiz "github.com/fuchencong/mooncode/internal/workflow/biz" + "github.com/fuchencong/mooncode/internal/biz/fault" + retention "github.com/fuchencong/mooncode/internal/biz/retention" + workflowbiz "github.com/fuchencong/mooncode/internal/biz/workflow" "github.com/fuchencong/mooncode/pkg/gitrepo" "github.com/google/uuid" - "github.com/jackc/pgx/v5" ) type Runner struct { @@ -29,7 +29,7 @@ func NewRunner(store retention.Store, git gitrepo.Manager, options ...RunnerOpti func (r *Runner) Execute(ctx context.Context, cleanupID uuid.UUID) error { cleanup, err := r.store.GetCleanup(ctx, cleanupID) - if errors.Is(err, pgx.ErrNoRows) { + if fault.IsKind(err, fault.NotFound) { return nil } if err != nil { diff --git a/internal/retention/workflow/runner_test.go b/internal/biz/retention/workflow/runner_test.go similarity index 98% rename from internal/retention/workflow/runner_test.go rename to internal/biz/retention/workflow/runner_test.go index 7dbf4cf..83c1c58 100644 --- a/internal/retention/workflow/runner_test.go +++ b/internal/biz/retention/workflow/runner_test.go @@ -5,7 +5,7 @@ import ( "errors" "testing" - retention "github.com/fuchencong/mooncode/internal/retention/biz" + retention "github.com/fuchencong/mooncode/internal/biz/retention" "github.com/fuchencong/mooncode/pkg/gitrepo" "github.com/google/uuid" ) diff --git a/internal/retention/workflow/scheduler.go b/internal/biz/retention/workflow/scheduler.go similarity index 86% rename from internal/retention/workflow/scheduler.go rename to internal/biz/retention/workflow/scheduler.go index 734c21d..7a8e57e 100644 --- a/internal/retention/workflow/scheduler.go +++ b/internal/biz/retention/workflow/scheduler.go @@ -3,7 +3,7 @@ package workflow import ( "context" - retention "github.com/fuchencong/mooncode/internal/retention/biz" + retention "github.com/fuchencong/mooncode/internal/biz/retention" ) type Scheduler struct { diff --git a/internal/biz/secret/cipher.go b/internal/biz/secret/cipher.go new file mode 100644 index 0000000..45d591a --- /dev/null +++ b/internal/biz/secret/cipher.go @@ -0,0 +1,7 @@ +// Package secret defines the encryption capability required by business use cases. +package secret + +type Cipher interface { + Encrypt([]byte) (ciphertext, nonce []byte, keyVersion int, err error) + Decrypt(ciphertext, nonce []byte, keyVersion int) ([]byte, error) +} diff --git a/internal/biz/workflow/contract_test.go b/internal/biz/workflow/contract_test.go new file mode 100644 index 0000000..855e193 --- /dev/null +++ b/internal/biz/workflow/contract_test.go @@ -0,0 +1,39 @@ +package workflow + +import ( + "encoding/json" + "testing" + + "github.com/google/uuid" +) + +func TestWorkflowProtocolContract(t *testing.T) { + workflows := map[string]string{ + "repository": WorkflowRepositoryOperation, + "analysis": WorkflowAnalysisRun, + "notification": WorkflowNotificationDelivery, + "retention": WorkflowRetentionCleanup, + } + want := map[string]string{ + "repository": "repository-operation", + "analysis": "analysis-run", + "notification": "notification-delivery", + "retention": "retention-cleanup", + } + for name, value := range workflows { + if value != want[name] { + t.Fatalf("%s workflow name = %q, want %q", name, value, want[name]) + } + } + + id := uuid.MustParse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa") + repositoryID := uuid.MustParse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb") + encoded, err := json.Marshal(Payload{AggregateID: id, RepositoryID: repositoryID}) + if err != nil { + t.Fatal(err) + } + const expected = `{"aggregateId":"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa","repositoryId":"bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb","channelId":"00000000-0000-0000-0000-000000000000","workspaceId":"00000000-0000-0000-0000-000000000000"}` + if string(encoded) != expected { + t.Fatalf("workflow payload = %s, want %s", encoded, expected) + } +} diff --git a/internal/workflow/biz/failure.go b/internal/biz/workflow/failure.go similarity index 96% rename from internal/workflow/biz/failure.go rename to internal/biz/workflow/failure.go index 67e79ed..03b42a4 100644 --- a/internal/workflow/biz/failure.go +++ b/internal/biz/workflow/failure.go @@ -1,4 +1,4 @@ -package biz +package workflow import "errors" diff --git a/internal/workflow/biz/failure_test.go b/internal/biz/workflow/failure_test.go similarity index 95% rename from internal/workflow/biz/failure_test.go rename to internal/biz/workflow/failure_test.go index e4a5b32..91a52c8 100644 --- a/internal/workflow/biz/failure_test.go +++ b/internal/biz/workflow/failure_test.go @@ -1,4 +1,4 @@ -package biz +package workflow import ( "errors" diff --git a/internal/workflow/biz/model.go b/internal/biz/workflow/model.go similarity index 98% rename from internal/workflow/biz/model.go rename to internal/biz/workflow/model.go index 8c2cae5..bbd1d9d 100644 --- a/internal/workflow/biz/model.go +++ b/internal/biz/workflow/model.go @@ -1,4 +1,4 @@ -package biz +package workflow import ( "encoding/json" diff --git a/internal/biz/workflow/store.go b/internal/biz/workflow/store.go new file mode 100644 index 0000000..99c6459 --- /dev/null +++ b/internal/biz/workflow/store.go @@ -0,0 +1,15 @@ +package workflow + +import ( + "context" + "time" + + "github.com/google/uuid" +) + +type DispatchStore interface { + Claim(context.Context, int32, time.Duration) ([]Dispatch, error) + IsPending(context.Context, uuid.UUID) (bool, error) + Complete(context.Context, Dispatch, string) error + Delay(context.Context, uuid.UUID, time.Time, string) error +} diff --git a/internal/platform/buildinfo/buildinfo.go b/internal/buildinfo/buildinfo.go similarity index 100% rename from internal/platform/buildinfo/buildinfo.go rename to internal/buildinfo/buildinfo.go diff --git a/internal/cli/app.go b/internal/cli/app.go deleted file mode 100644 index 5b575dd..0000000 --- a/internal/cli/app.go +++ /dev/null @@ -1,115 +0,0 @@ -package cli - -import ( - "context" - "fmt" - - analysisbiz "github.com/fuchencong/mooncode/internal/analysis/biz" - analysisdata "github.com/fuchencong/mooncode/internal/analysis/data" - analysisworkflow "github.com/fuchencong/mooncode/internal/analysis/workflow" - channelbiz "github.com/fuchencong/mooncode/internal/channel/biz" - channelcommand "github.com/fuchencong/mooncode/internal/channel/command" - channeldata "github.com/fuchencong/mooncode/internal/channel/data" - channelruntime "github.com/fuchencong/mooncode/internal/channel/runtime" - channelworkflow "github.com/fuchencong/mooncode/internal/channel/workflow" - identitybiz "github.com/fuchencong/mooncode/internal/identity/biz" - identitydata "github.com/fuchencong/mooncode/internal/identity/data" - "github.com/fuchencong/mooncode/internal/platform/auth" - "github.com/fuchencong/mooncode/internal/platform/config" - "github.com/fuchencong/mooncode/internal/platform/database" - platformhatchet "github.com/fuchencong/mooncode/internal/platform/hatchet" - "github.com/fuchencong/mooncode/internal/platform/httpserver" - "github.com/fuchencong/mooncode/internal/platform/secret" - repositorybiz "github.com/fuchencong/mooncode/internal/repository/biz" - repositorydata "github.com/fuchencong/mooncode/internal/repository/data" - repositoryworkflow "github.com/fuchencong/mooncode/internal/repository/workflow" - retentiondata "github.com/fuchencong/mooncode/internal/retention/data" - retentionworkflow "github.com/fuchencong/mooncode/internal/retention/workflow" - workflow "github.com/fuchencong/mooncode/internal/workflow" - workflowdata "github.com/fuchencong/mooncode/internal/workflow/data" - "github.com/fuchencong/mooncode/pkg/analyzer/scc" - channelpkg "github.com/fuchencong/mooncode/pkg/channel" - "github.com/fuchencong/mooncode/pkg/channel/dingtalk" - "github.com/fuchencong/mooncode/pkg/channel/feishu" - "github.com/fuchencong/mooncode/pkg/gitrepo" - "github.com/jackc/pgx/v5/pgxpool" -) - -type app struct { - config config.Config - pool *pgxpool.Pool - server *httpserver.Server - hatchet *platformhatchet.Client - dispatcher *workflow.Dispatcher - runtime *channelruntime.Manager - janitor gitrepo.WorktreeJanitor - metrics *httpserver.Metrics -} - -func build(ctx context.Context, cfg config.Config) (*app, error) { - pool, err := database.Open(ctx, cfg.Database.URL, cfg.Database.MaxConnections) - if err != nil { - return nil, err - } - fail := func(err error) (*app, error) { pool.Close(); return nil, err } - if cfg.Database.MigrateOnStart { - if err := database.Migrate(ctx, pool); err != nil { - return fail(err) - } - } - cipher, err := secret.NewAESGCM(cfg.Secrets.Key, cfg.Secrets.KeyVersion) - if err != nil { - return fail(err) - } - git, err := gitrepo.NewManager(cfg.Repository.Root, gitrepo.WithMaxMirrorBytes(cfg.Repository.MaxMirrorBytes)) - if err != nil { - return fail(err) - } - identityStore := identitydata.NewStore(pool) - identityService := identitybiz.NewService(identityStore, cfg.Auth.Issuer) - providerService := identitybiz.NewProviderService(identityStore, cipher, identitybiz.NewHTTPProviderValidator(nil)) - repositoryStore := repositorydata.NewStore(pool, git, repositorydata.WithMaxRepositoriesPerWorkspace(cfg.Repository.MaxPerWorkspace)) - analysisStore := analysisdata.NewStore(pool, analysisdata.WithMaxConcurrentRunsPerWorkspace(cfg.Analysis.MaxConcurrentPerWorkspace)) - channelStore := channeldata.NewStore(pool) - retentionStore := retentiondata.NewStore(pool) - metrics := httpserver.NewMetrics() - feishuFactory := feishu.New() - dingTalkFactory := dingtalk.New() - repositoryRunner := repositoryworkflow.NewRunner(repositoryStore, identityService, providerService, git) - analysisRunner := analysisworkflow.NewRunner(analysisStore, identityService, git, scc.New(scc.WithBinary(cfg.Analysis.SCCPath), scc.WithMaxOutputBytes(cfg.Analysis.MaxOutputBytes)), cfg.Analysis.Timeout) - notificationRunner := channelworkflow.NewRunner(channelStore, cipher, feishuFactory, dingTalkFactory) - retentionScheduler := retentionworkflow.NewScheduler(retentionStore) - retentionRunner := retentionworkflow.NewRunner(retentionStore, git, retentionworkflow.WithMetrics(metrics)) - hatchetClient, err := platformhatchet.New( - cfg.Hatchet.Token, cfg.Hatchet.Address, cfg.Hatchet.Namespace, - repositoryRunner, analysisRunner, notificationRunner, retentionScheduler, retentionRunner, - platformhatchet.WithMetrics(metrics), - ) - if err != nil { - return fail(fmt.Errorf("create Hatchet client: %w", err)) - } - repositoryService := repositorybiz.NewService(repositoryStore, identityService, providerService, hatchetClient, git) - analysisService := analysisbiz.NewService(analysisStore, identityService, repositoryStore, hatchetClient) - channelService := channelbiz.NewService(channelStore, identityService, cipher) - commandService := channelcommand.NewService(channelStore, identityService, repositoryService, analysisService, cfg.Auth.AppURL) - server, err := httpserver.New(httpserver.Config{ - LogoutURL: cfg.Auth.LogoutURL, - RegistrationMode: cfg.Auth.RegistrationMode, TermsVersion: cfg.Auth.TermsVersion, PrivacyVersion: cfg.Auth.PrivacyVersion, - TrustedProxyCIDRs: cfg.Auth.TrustedProxyCIDRs, - MaxBodyBytes: cfg.HTTP.MaxBodyBytes, RequestTimeout: cfg.HTTP.RequestTimeout, - }, identityService, providerService, repositoryService, analysisService, channelService, commandService, auth.NewCSRF(cfg.Auth.CSRFKey), - httpserver.WithReadiness(database.NewReadiness(pool)), httpserver.WithMetrics(metrics), - ) - if err != nil { - return fail(fmt.Errorf("create HTTP server: %w", err)) - } - dispatcher := workflow.NewDispatcher(workflowdata.NewStore(pool), hatchetClient) - runtime := channelruntime.NewManager(channelStore, cipher, []channelpkg.Factory{feishuFactory, dingTalkFactory}, channelruntime.WithCommandHandler(commandService)) - - return &app{ - config: cfg, pool: pool, server: server, hatchet: hatchetClient, - dispatcher: dispatcher, runtime: runtime, janitor: git, metrics: metrics, - }, nil -} - -func (a *app) close() { a.pool.Close() } diff --git a/internal/cli/root.go b/internal/cli/root.go index fcc09d2..a291e1e 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -2,231 +2,96 @@ package cli import ( "context" - "errors" - "fmt" - "net/http" + "log/slog" "os/signal" "syscall" - "time" - "github.com/fuchencong/mooncode/internal/platform/config" - "github.com/fuchencong/mooncode/internal/platform/database" - "github.com/fuchencong/mooncode/pkg/gitrepo" + "github.com/fuchencong/mooncode/internal/app" + "github.com/fuchencong/mooncode/internal/conf" "github.com/spf13/cobra" ) func NewRoot() *cobra.Command { var configPath string - root := &cobra.Command{Use: "mooncode", Short: "MoonCode code quality analysis SaaS"} + + root := &cobra.Command{ + Use: "mooncode", + Short: "MoonCode code quality analysis SaaS", + } root.PersistentFlags().StringVar(&configPath, "config", "", "configuration file") - root.AddCommand(&cobra.Command{Use: "serve", Short: "Run the MoonCode HTTP API", RunE: func(command *cobra.Command, _ []string) error { return runServer(command.Context(), configPath) }}) - root.AddCommand(&cobra.Command{Use: "worker", Short: "Run the MoonCode Hatchet worker", RunE: func(command *cobra.Command, _ []string) error { return runWorker(command.Context(), configPath) }}) + root.AddCommand(&cobra.Command{ + Use: "serve", + Short: "Run the MoonCode HTTP API", + RunE: func(command *cobra.Command, _ []string) error { + return runServer(command.Context(), configPath) + }, + }) + root.AddCommand(&cobra.Command{ + Use: "worker", + Short: "Run the MoonCode Hatchet worker", + RunE: func(command *cobra.Command, _ []string) error { + return runWorker(command.Context(), configPath) + }, + }) root.AddCommand(newVersionCommand()) - root.AddCommand(&cobra.Command{Use: "migrate", Short: "Apply MoonCode database migrations", RunE: func(command *cobra.Command, _ []string) error { - cfg, err := config.Load(configPath) - if err != nil { - return err - } - pool, err := database.Open(command.Context(), cfg.Database.URL, cfg.Database.MaxConnections) - if err != nil { - return err - } - defer pool.Close() - return database.Migrate(command.Context(), pool) - }}) + root.AddCommand(&cobra.Command{ + Use: "migrate", + Short: "Apply MoonCode database migrations", + RunE: func(command *cobra.Command, _ []string) error { + return runMigrations(command.Context(), configPath) + }, + }) + return root } func runServer(parent context.Context, path string) error { - cfg, err := config.Load(path) + cfg, err := conf.Load(path) if err != nil { return err } + ctx, stop := signal.NotifyContext(parent, syscall.SIGINT, syscall.SIGTERM) defer stop() - application, err := build(ctx, cfg) + + application, cleanup, err := app.InitializeServer(ctx, cfg, slog.Default()) if err != nil { return err } - defer application.close() - server := &http.Server{Addr: cfg.HTTP.Address, Handler: application.server.Handler(), ReadHeaderTimeout: 10 * time.Second, ReadTimeout: 30 * time.Second, WriteTimeout: 30 * time.Second, IdleTimeout: 2 * time.Minute} - runtimeDone := make(chan struct{}) - go func() { - defer close(runtimeDone) - _ = application.runtime.Run(ctx) - }() - errorsChannel := make(chan error, 1) - go func() { errorsChannel <- server.ListenAndServe() }() - select { - case <-ctx.Done(): - shutdown, cancel := context.WithTimeout(context.Background(), 15*time.Second) - defer cancel() - if err := server.Shutdown(shutdown); err != nil { - return err - } - select { - case <-runtimeDone: - return nil - case <-shutdown.Done(): - return shutdown.Err() - } - case err := <-errorsChannel: - stop() - shutdown, cancel := context.WithTimeout(context.Background(), 15*time.Second) - defer cancel() - select { - case <-runtimeDone: - case <-shutdown.Done(): - } - if errors.Is(err, http.ErrServerClosed) { - return nil - } - return err - } + defer cleanup() + + return application.Run(ctx) } + func runWorker(parent context.Context, path string) error { - cfg, err := config.Load(path) + cfg, err := conf.Load(path) if err != nil { return err } + ctx, stop := signal.NotifyContext(parent, syscall.SIGINT, syscall.SIGTERM) defer stop() - application, err := build(ctx, cfg) - if err != nil { - return err - } - defer application.close() - return runWorkerProcesses( - ctx, - application.dispatcher.Run, - func(workerContext context.Context) error { - return application.hatchet.StartWorker(workerContext, "mooncode-worker", 20) - }, - namedWorkerProcess{ - name: "worker metrics server", - run: func(metricsContext context.Context) error { - return serveWorkerMetrics(metricsContext, cfg.Metrics.WorkerAddress, application.metrics.Handler()) - }, - }, - namedWorkerProcess{ - name: "worktree janitor", - run: func(janitorContext context.Context) error { - return runWorktreeJanitor(janitorContext, application.janitor, cfg.Repository.WorktreeMaxAge) - }, - }, - ) -} - -func runWorktreeJanitor(ctx context.Context, janitor gitrepo.WorktreeJanitor, maxAge time.Duration) error { - cleanup := func() error { - if _, err := janitor.CleanupStaleWorktrees(ctx, maxAge); err != nil { - return fmt.Errorf("clean stale analysis worktrees: %w", err) - } - return nil - } - if err := cleanup(); err != nil { + application, cleanup, err := app.InitializeWorker(ctx, cfg, slog.Default()) + if err != nil { return err } + defer cleanup() - ticker := time.NewTicker(time.Hour) - defer ticker.Stop() - for { - select { - case <-ctx.Done(): - return nil - case <-ticker.C: - if err := cleanup(); err != nil { - return err - } - } - } -} - -type workerProcess func(context.Context) error - -type namedWorkerProcess struct { - name string - run workerProcess + return application.Run(ctx) } -type workerProcessResult struct { - name string - err error -} - -func runWorkerProcesses(ctx context.Context, dispatcher, worker workerProcess, additional ...namedWorkerProcess) error { - processContext, cancel := context.WithCancel(ctx) - defer cancel() - - processes := append([]namedWorkerProcess{ - {name: "workflow dispatcher", run: dispatcher}, - {name: "Hatchet worker", run: worker}, - }, additional...) - results := make(chan workerProcessResult, len(processes)) - for _, process := range processes { - go func(process namedWorkerProcess) { - results <- workerProcessResult{name: process.name, err: process.run(processContext)} - }(process) - } - - first := <-results - cancel() - completed := []workerProcessResult{first} - for range len(processes) - 1 { - completed = append(completed, <-results) - } - - return workerProcessError(ctx, completed) -} - -func workerProcessError(parent context.Context, results []workerProcessResult) error { - var processErrors []error - for index, result := range results { - if result.err == nil { - continue - } - if parent.Err() != nil && errors.Is(result.err, parent.Err()) { - continue - } - if index > 0 && errors.Is(result.err, context.Canceled) { - continue - } - - processErrors = append(processErrors, fmt.Errorf("%s: %w", result.name, result.err)) - } - - return errors.Join(processErrors...) -} - -func serveWorkerMetrics(ctx context.Context, address string, metrics http.Handler) error { - router := http.NewServeMux() - router.Handle("/metrics", metrics) - router.HandleFunc("/healthz", func(writer http.ResponseWriter, _ *http.Request) { - writer.Header().Set("Cache-Control", "no-store") - writer.WriteHeader(http.StatusNoContent) - }) - server := &http.Server{ - Addr: address, Handler: router, ReadHeaderTimeout: 5 * time.Second, - ReadTimeout: 10 * time.Second, WriteTimeout: 10 * time.Second, IdleTimeout: time.Minute, +func runMigrations(ctx context.Context, path string) error { + cfg, err := conf.Load(path) + if err != nil { + return err } - errorsChannel := make(chan error, 1) - go func() { errorsChannel <- server.ListenAndServe() }() - - select { - case <-ctx.Done(): - shutdown, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - if err := server.Shutdown(shutdown); err != nil { - return err - } - - return nil - case err := <-errorsChannel: - if errors.Is(err, http.ErrServerClosed) { - return nil - } + migrator, cleanup, err := app.InitializeMigrator(ctx, cfg) + if err != nil { return err } + defer cleanup() + + return migrator.Run(ctx) } diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go index 179db96..a291b8f 100644 --- a/internal/cli/root_test.go +++ b/internal/cli/root_test.go @@ -1,178 +1,23 @@ package cli import ( - "context" - "errors" - "strings" + "slices" "testing" - "time" ) -type worktreeJanitor struct { - calls int - maxAge time.Duration - err error -} - -func (j *worktreeJanitor) CleanupStaleWorktrees(_ context.Context, maxAge time.Duration) (int, error) { - j.calls++ - j.maxAge = maxAge - - return 0, j.err -} - -func TestWorktreeJanitorRunsAtStartupAndStopsWithContext(t *testing.T) { - ctx, cancel := context.WithCancel(context.Background()) - cancel() - janitor := &worktreeJanitor{} - - if err := runWorktreeJanitor(ctx, janitor, 24*time.Hour); err != nil { - t.Fatal(err) - } - if janitor.calls != 1 || janitor.maxAge != 24*time.Hour { - t.Fatalf("janitor startup = (%d,%s)", janitor.calls, janitor.maxAge) - } -} - -func TestWorktreeJanitorReturnsCleanupFailure(t *testing.T) { - want := errors.New("worktree scan failed") - err := runWorktreeJanitor(context.Background(), &worktreeJanitor{err: want}, time.Hour) - if !errors.Is(err, want) { - t.Fatalf("runWorktreeJanitor() error = %v, want %v", err, want) - } -} - -func TestRunWorkerProcessesReturnsDispatcherFailure(t *testing.T) { - want := errors.New("outbox unavailable") - workerStopped := make(chan struct{}) - - err := runWorkerProcesses( - context.Background(), - func(context.Context) error { return want }, - func(ctx context.Context) error { - <-ctx.Done() - close(workerStopped) - - return ctx.Err() - }, - ) - - if !errors.Is(err, want) || !strings.Contains(err.Error(), "workflow dispatcher") { - t.Fatalf("runWorkerProcesses() error = %v, want dispatcher failure", err) - } - select { - case <-workerStopped: - default: - t.Fatal("Hatchet worker was not stopped") - } -} - -func TestRunWorkerProcessesReturnsWorkerFailure(t *testing.T) { - want := errors.New("worker registration failed") - dispatcherStopped := make(chan struct{}) - - err := runWorkerProcesses( - context.Background(), - func(ctx context.Context) error { - <-ctx.Done() - close(dispatcherStopped) - - return nil - }, - func(context.Context) error { return want }, - ) - - if !errors.Is(err, want) || !strings.Contains(err.Error(), "Hatchet worker") { - t.Fatalf("runWorkerProcesses() error = %v, want Hatchet worker failure", err) - } - select { - case <-dispatcherStopped: - default: - t.Fatal("workflow dispatcher was not stopped") - } -} - -func TestRunWorkerProcessesReturnsMetricsFailure(t *testing.T) { - want := errors.New("metrics listener failed") - stopped := make(chan string, 2) - wait := func(name string) workerProcess { - return func(ctx context.Context) error { - <-ctx.Done() - stopped <- name - - return ctx.Err() - } - } +func TestRootCommandContract(t *testing.T) { + root := NewRoot() - err := runWorkerProcesses( - context.Background(), - wait("dispatcher"), - wait("worker"), - namedWorkerProcess{name: "worker metrics server", run: func(context.Context) error { return want }}, - ) - if !errors.Is(err, want) || !strings.Contains(err.Error(), "worker metrics server") { - t.Fatalf("runWorkerProcesses() error = %v, want metrics failure", err) - } - if errors.Is(err, context.Canceled) { - t.Fatalf("runWorkerProcesses() error = %v, want peer cancellations to be ignored", err) + var names []string + for _, command := range root.Commands() { + names = append(names, command.Name()) } - for range 2 { - select { - case <-stopped: - case <-time.After(time.Second): - t.Fatal("a peer process was not stopped") + for _, expected := range []string{"migrate", "serve", "version", "worker"} { + if !slices.Contains(names, expected) { + t.Fatalf("root command is missing %q; commands=%v", expected, names) } } -} - -func TestRunWorkerProcessesDoesNotHideUnexpectedCancellation(t *testing.T) { - err := runWorkerProcesses( - context.Background(), - func(context.Context) error { return context.Canceled }, - func(ctx context.Context) error { - <-ctx.Done() - - return ctx.Err() - }, - ) - - if !errors.Is(err, context.Canceled) || !strings.Contains(err.Error(), "workflow dispatcher") { - t.Fatalf("runWorkerProcesses() error = %v, want unexpected dispatcher cancellation", err) - } -} - -func TestRunWorkerProcessesStopsGracefully(t *testing.T) { - ctx, cancel := context.WithCancel(context.Background()) - dispatcherStarted := make(chan struct{}) - workerStarted := make(chan struct{}) - process := func(started chan<- struct{}) workerProcess { - return func(ctx context.Context) error { - close(started) - <-ctx.Done() - - return ctx.Err() - } - } - done := make(chan error, 1) - go func() { - done <- runWorkerProcesses(ctx, process(dispatcherStarted), process(workerStarted)) - }() - - for _, started := range []<-chan struct{}{dispatcherStarted, workerStarted} { - select { - case <-started: - case <-time.After(time.Second): - t.Fatal("worker process did not start") - } - } - cancel() - - select { - case err := <-done: - if err != nil { - t.Fatalf("runWorkerProcesses() error = %v, want nil", err) - } - case <-time.After(time.Second): - t.Fatal("worker processes did not stop") + if root.PersistentFlags().Lookup("config") == nil { + t.Fatal("root command is missing --config") } } diff --git a/internal/cli/version.go b/internal/cli/version.go index 717036a..268c689 100644 --- a/internal/cli/version.go +++ b/internal/cli/version.go @@ -3,7 +3,7 @@ package cli import ( "fmt" - "github.com/fuchencong/mooncode/internal/platform/buildinfo" + "github.com/fuchencong/mooncode/internal/buildinfo" "github.com/spf13/cobra" ) diff --git a/internal/cli/version_test.go b/internal/cli/version_test.go index 7af110c..4505035 100644 --- a/internal/cli/version_test.go +++ b/internal/cli/version_test.go @@ -4,7 +4,7 @@ import ( "bytes" "testing" - "github.com/fuchencong/mooncode/internal/platform/buildinfo" + "github.com/fuchencong/mooncode/internal/buildinfo" ) func TestVersionCommandReportsBuildIdentity(t *testing.T) { diff --git a/internal/platform/config/config.go b/internal/conf/conf.go similarity index 99% rename from internal/platform/config/config.go rename to internal/conf/conf.go index 12a5de2..54065e5 100644 --- a/internal/platform/config/config.go +++ b/internal/conf/conf.go @@ -1,4 +1,5 @@ -package config +// Package conf owns MoonCode's external configuration boundary. +package conf import ( "encoding/base64" diff --git a/internal/platform/config/config_test.go b/internal/conf/conf_test.go similarity index 99% rename from internal/platform/config/config_test.go rename to internal/conf/conf_test.go index 4fb69a0..362d55f 100644 --- a/internal/platform/config/config_test.go +++ b/internal/conf/conf_test.go @@ -1,4 +1,4 @@ -package config +package conf import ( "encoding/base64" diff --git a/internal/analysis/data/audit_integration_test.go b/internal/data/analysis/audit_integration_test.go similarity index 96% rename from internal/analysis/data/audit_integration_test.go rename to internal/data/analysis/audit_integration_test.go index 0a57473..e9a7abc 100644 --- a/internal/analysis/data/audit_integration_test.go +++ b/internal/data/analysis/audit_integration_test.go @@ -1,6 +1,6 @@ //go:build integration -package data +package analysis import ( "context" @@ -8,8 +8,8 @@ import ( "strings" "testing" - analysis "github.com/fuchencong/mooncode/internal/analysis/biz" - "github.com/fuchencong/mooncode/internal/platform/audit" + analysis "github.com/fuchencong/mooncode/internal/biz/analysis" + "github.com/fuchencong/mooncode/internal/data/audit" "github.com/fuchencong/mooncode/internal/testutil/testdb" "github.com/google/uuid" ) diff --git a/internal/analysis/data/failure_integration_test.go b/internal/data/analysis/failure_integration_test.go similarity index 98% rename from internal/analysis/data/failure_integration_test.go rename to internal/data/analysis/failure_integration_test.go index 076ab05..dfbe91b 100644 --- a/internal/analysis/data/failure_integration_test.go +++ b/internal/data/analysis/failure_integration_test.go @@ -1,6 +1,6 @@ //go:build integration -package data +package analysis import ( "context" @@ -10,7 +10,7 @@ import ( "strings" "testing" - analysis "github.com/fuchencong/mooncode/internal/analysis/biz" + analysis "github.com/fuchencong/mooncode/internal/biz/analysis" "github.com/fuchencong/mooncode/internal/testutil/testdb" "github.com/google/uuid" ) diff --git a/internal/analysis/data/notification_integration_test.go b/internal/data/analysis/notification_integration_test.go similarity index 99% rename from internal/analysis/data/notification_integration_test.go rename to internal/data/analysis/notification_integration_test.go index 7a09b0e..bf021a6 100644 --- a/internal/analysis/data/notification_integration_test.go +++ b/internal/data/analysis/notification_integration_test.go @@ -1,6 +1,6 @@ //go:build integration -package data +package analysis import ( "context" @@ -11,7 +11,7 @@ import ( "testing" "time" - analysis "github.com/fuchencong/mooncode/internal/analysis/biz" + analysis "github.com/fuchencong/mooncode/internal/biz/analysis" "github.com/fuchencong/mooncode/internal/testutil/testdb" "github.com/google/uuid" ) diff --git a/internal/analysis/data/options.go b/internal/data/analysis/options.go similarity index 91% rename from internal/analysis/data/options.go rename to internal/data/analysis/options.go index 6a16753..b99e3cf 100644 --- a/internal/analysis/data/options.go +++ b/internal/data/analysis/options.go @@ -1,4 +1,4 @@ -package data +package analysis type Option func(*Store) diff --git a/internal/analysis/data/profile_integration_test.go b/internal/data/analysis/profile_integration_test.go similarity index 94% rename from internal/analysis/data/profile_integration_test.go rename to internal/data/analysis/profile_integration_test.go index 6437cb7..d584b0f 100644 --- a/internal/analysis/data/profile_integration_test.go +++ b/internal/data/analysis/profile_integration_test.go @@ -1,6 +1,6 @@ //go:build integration -package data +package analysis import ( "context" @@ -8,9 +8,9 @@ import ( "os" "testing" - analysis "github.com/fuchencong/mooncode/internal/analysis/biz" - "github.com/fuchencong/mooncode/internal/platform/audit" - "github.com/fuchencong/mooncode/internal/platform/fault" + analysis "github.com/fuchencong/mooncode/internal/biz/analysis" + "github.com/fuchencong/mooncode/internal/biz/fault" + "github.com/fuchencong/mooncode/internal/data/audit" "github.com/fuchencong/mooncode/internal/testutil/testdb" "github.com/google/uuid" ) diff --git a/internal/analysis/data/profile_store.go b/internal/data/analysis/profile_store.go similarity index 95% rename from internal/analysis/data/profile_store.go rename to internal/data/analysis/profile_store.go index 8cd2bf5..eb25a35 100644 --- a/internal/analysis/data/profile_store.go +++ b/internal/data/analysis/profile_store.go @@ -1,4 +1,4 @@ -package data +package analysis import ( "context" @@ -6,12 +6,12 @@ import ( "errors" "time" - analysis "github.com/fuchencong/mooncode/internal/analysis/biz" - "github.com/fuchencong/mooncode/internal/data/pagecursor" - "github.com/fuchencong/mooncode/internal/data/sqlc" - "github.com/fuchencong/mooncode/internal/platform/audit" - "github.com/fuchencong/mooncode/internal/platform/fault" - "github.com/fuchencong/mooncode/internal/platform/pagination" + analysis "github.com/fuchencong/mooncode/internal/biz/analysis" + "github.com/fuchencong/mooncode/internal/biz/fault" + "github.com/fuchencong/mooncode/internal/biz/pagination" + "github.com/fuchencong/mooncode/internal/data/audit" + "github.com/fuchencong/mooncode/internal/data/postgres/pagecursor" + "github.com/fuchencong/mooncode/internal/data/postgres/sqlc" "github.com/google/uuid" "github.com/jackc/pgx/v5" ) diff --git a/internal/analysis/data/quota_integration_test.go b/internal/data/analysis/quota_integration_test.go similarity index 96% rename from internal/analysis/data/quota_integration_test.go rename to internal/data/analysis/quota_integration_test.go index f45dd6b..a62ff16 100644 --- a/internal/analysis/data/quota_integration_test.go +++ b/internal/data/analysis/quota_integration_test.go @@ -1,6 +1,6 @@ //go:build integration -package data +package analysis import ( "context" @@ -8,8 +8,8 @@ import ( "strings" "testing" - analysis "github.com/fuchencong/mooncode/internal/analysis/biz" - "github.com/fuchencong/mooncode/internal/platform/fault" + analysis "github.com/fuchencong/mooncode/internal/biz/analysis" + "github.com/fuchencong/mooncode/internal/biz/fault" "github.com/fuchencong/mooncode/internal/testutil/testdb" "github.com/fuchencong/mooncode/pkg/analyzer/scc" "github.com/google/uuid" diff --git a/internal/analysis/data/store.go b/internal/data/analysis/store.go similarity index 92% rename from internal/analysis/data/store.go rename to internal/data/analysis/store.go index b74d843..d2e534b 100644 --- a/internal/analysis/data/store.go +++ b/internal/data/analysis/store.go @@ -1,4 +1,4 @@ -package data +package analysis import ( "context" @@ -6,14 +6,15 @@ import ( "errors" "time" - analysis "github.com/fuchencong/mooncode/internal/analysis/biz" - channel "github.com/fuchencong/mooncode/internal/channel/biz" - "github.com/fuchencong/mooncode/internal/data/pagecursor" - "github.com/fuchencong/mooncode/internal/data/sqlc" - "github.com/fuchencong/mooncode/internal/platform/audit" - "github.com/fuchencong/mooncode/internal/platform/fault" - "github.com/fuchencong/mooncode/internal/platform/pagination" - workflow "github.com/fuchencong/mooncode/internal/workflow/biz" + analysis "github.com/fuchencong/mooncode/internal/biz/analysis" + channel "github.com/fuchencong/mooncode/internal/biz/channel" + "github.com/fuchencong/mooncode/internal/biz/fault" + "github.com/fuchencong/mooncode/internal/biz/pagination" + workflow "github.com/fuchencong/mooncode/internal/biz/workflow" + "github.com/fuchencong/mooncode/internal/data/audit" + "github.com/fuchencong/mooncode/internal/data/postgres" + "github.com/fuchencong/mooncode/internal/data/postgres/pagecursor" + "github.com/fuchencong/mooncode/internal/data/postgres/sqlc" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" @@ -121,7 +122,7 @@ func (s *Store) Create(ctx context.Context, run analysis.Run) (analysis.Run, err func (s *Store) Get(ctx context.Context, workspaceID, id uuid.UUID) (analysis.Run, error) { row, err := s.queries.GetAnalysisRun(ctx, sqlc.GetAnalysisRunParams{ID: id, WorkspaceID: workspaceID}) if err != nil { - return analysis.Run{}, err + return analysis.Run{}, postgres.NotFound(err, "analysis.run_not_found", "Analysis run not found") } return s.attachReport(ctx, mapRun(row)) @@ -155,7 +156,7 @@ func (s *Store) List(ctx context.Context, workspaceID, repositoryID uuid.UUID, p func (s *Store) LoadWork(ctx context.Context, id uuid.UUID) (analysis.WorkItem, error) { row, err := s.queries.GetAnalysisRunWork(ctx, id) - return analysis.WorkItem{Run: mapRun(row), CommitSHA: row.CommitSha}, err + return analysis.WorkItem{Run: mapRun(row), CommitSHA: row.CommitSha}, postgres.NotFound(err, "analysis.run_not_found", "Analysis run not found") } func (s *Store) Start(ctx context.Context, id uuid.UUID) (analysis.Run, error) { @@ -167,7 +168,7 @@ func (s *Store) Start(ctx context.Context, id uuid.UUID) (analysis.Run, error) { q := s.queries.WithTx(tx) row, err := q.StartAnalysisRun(ctx, id) if err != nil { - return analysis.Run{}, err + return analysis.Run{}, postgres.NotFound(err, "analysis.run_not_found", "Analysis run not found") } if err = enqueueAnalysisNotifications(ctx, q, row, channel.EventAnalysisStarted); err != nil { return analysis.Run{}, err @@ -192,7 +193,7 @@ func (s *Store) Finish(ctx context.Context, id uuid.UUID, result, rawArtifact js q := s.queries.WithTx(tx) work, err := q.GetAnalysisRunWork(ctx, id) if err != nil { - return analysis.Run{}, err + return analysis.Run{}, postgres.NotFound(err, "analysis.run_not_found", "Analysis run not found") } if !work.StartedAt.Valid { return analysis.Run{}, errors.New("analysis run has no start time") @@ -318,7 +319,7 @@ func (s *Store) Cancel(ctx context.Context, workspaceID, id uuid.UUID) (string, func (s *Store) Report(ctx context.Context, workspaceID, id uuid.UUID) (analysis.Report, error) { row, err := s.queries.GetAnalysisReport(ctx, sqlc.GetAnalysisReportParams{ID: id, WorkspaceID: workspaceID}) - return mapReport(row), err + return mapReport(row), postgres.NotFound(err, "analysis.report_not_found", "Analysis report not found") } func (s *Store) attachReport(ctx context.Context, run analysis.Run) (analysis.Run, error) { diff --git a/internal/platform/audit/audit_integration_test.go b/internal/data/audit/audit_integration_test.go similarity index 95% rename from internal/platform/audit/audit_integration_test.go rename to internal/data/audit/audit_integration_test.go index 2928069..c0fb498 100644 --- a/internal/platform/audit/audit_integration_test.go +++ b/internal/data/audit/audit_integration_test.go @@ -7,7 +7,7 @@ import ( "os" "testing" - "github.com/fuchencong/mooncode/internal/data/sqlc" + "github.com/fuchencong/mooncode/internal/data/postgres/sqlc" "github.com/fuchencong/mooncode/internal/testutil/testdb" "github.com/google/uuid" ) diff --git a/internal/platform/audit/metadata.go b/internal/data/audit/metadata.go similarity index 100% rename from internal/platform/audit/metadata.go rename to internal/data/audit/metadata.go diff --git a/internal/platform/audit/model.go b/internal/data/audit/model.go similarity index 100% rename from internal/platform/audit/model.go rename to internal/data/audit/model.go diff --git a/internal/platform/audit/writer.go b/internal/data/audit/writer.go similarity index 94% rename from internal/platform/audit/writer.go rename to internal/data/audit/writer.go index cd7d3e2..d5511bc 100644 --- a/internal/platform/audit/writer.go +++ b/internal/data/audit/writer.go @@ -5,7 +5,7 @@ import ( "fmt" "strings" - "github.com/fuchencong/mooncode/internal/data/sqlc" + "github.com/fuchencong/mooncode/internal/data/postgres/sqlc" "github.com/google/uuid" ) diff --git a/internal/platform/audit/writer_test.go b/internal/data/audit/writer_test.go similarity index 97% rename from internal/platform/audit/writer_test.go rename to internal/data/audit/writer_test.go index 3ee442e..a4be27b 100644 --- a/internal/platform/audit/writer_test.go +++ b/internal/data/audit/writer_test.go @@ -6,7 +6,7 @@ import ( "strings" "testing" - "github.com/fuchencong/mooncode/internal/data/sqlc" + "github.com/fuchencong/mooncode/internal/data/postgres/sqlc" "github.com/google/uuid" ) diff --git a/internal/channel/data/audit_integration_test.go b/internal/data/channel/audit_integration_test.go similarity index 98% rename from internal/channel/data/audit_integration_test.go rename to internal/data/channel/audit_integration_test.go index e2e7d64..bef0fe2 100644 --- a/internal/channel/data/audit_integration_test.go +++ b/internal/data/channel/audit_integration_test.go @@ -1,6 +1,6 @@ //go:build integration -package data +package channel import ( "context" @@ -10,7 +10,7 @@ import ( "strings" "testing" - "github.com/fuchencong/mooncode/internal/platform/audit" + "github.com/fuchencong/mooncode/internal/data/audit" "github.com/fuchencong/mooncode/internal/testutil/testdb" "github.com/google/uuid" ) diff --git a/internal/channel/data/command_store.go b/internal/data/channel/command_store.go similarity index 96% rename from internal/channel/data/command_store.go rename to internal/data/channel/command_store.go index 8458895..cff3b62 100644 --- a/internal/channel/data/command_store.go +++ b/internal/data/channel/command_store.go @@ -1,11 +1,11 @@ -package data +package channel import ( "context" "errors" - channelcommand "github.com/fuchencong/mooncode/internal/channel/command" - "github.com/fuchencong/mooncode/internal/data/sqlc" + channelcommand "github.com/fuchencong/mooncode/internal/biz/channel/command" + "github.com/fuchencong/mooncode/internal/data/postgres/sqlc" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" diff --git a/internal/channel/data/command_store_integration_test.go b/internal/data/channel/command_store_integration_test.go similarity index 98% rename from internal/channel/data/command_store_integration_test.go rename to internal/data/channel/command_store_integration_test.go index 402bbe2..a2a9e63 100644 --- a/internal/channel/data/command_store_integration_test.go +++ b/internal/data/channel/command_store_integration_test.go @@ -1,6 +1,6 @@ //go:build integration -package data +package channel import ( "bytes" @@ -11,7 +11,7 @@ import ( "testing" "time" - channelcommand "github.com/fuchencong/mooncode/internal/channel/command" + channelcommand "github.com/fuchencong/mooncode/internal/biz/channel/command" "github.com/fuchencong/mooncode/internal/testutil/testdb" "github.com/google/uuid" ) diff --git a/internal/channel/data/delivery_store.go b/internal/data/channel/delivery_store.go similarity index 86% rename from internal/channel/data/delivery_store.go rename to internal/data/channel/delivery_store.go index 148b4e3..3dacab6 100644 --- a/internal/channel/data/delivery_store.go +++ b/internal/data/channel/delivery_store.go @@ -1,10 +1,11 @@ -package data +package channel import ( "context" - channelworkflow "github.com/fuchencong/mooncode/internal/channel/workflow" - "github.com/fuchencong/mooncode/internal/data/sqlc" + channelworkflow "github.com/fuchencong/mooncode/internal/biz/channel/workflow" + "github.com/fuchencong/mooncode/internal/data/postgres" + "github.com/fuchencong/mooncode/internal/data/postgres/sqlc" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgtype" ) @@ -28,7 +29,7 @@ WHERE n.id=$1`, notificationID).Scan( ) item.KeyVersion = int(keyVersion.Int32) - return item, err + return item, postgres.NotFound(err, "channel.notification_not_found", "Notification delivery not found") } func (s *Store) StartDelivery(ctx context.Context, notificationID uuid.UUID) error { diff --git a/internal/channel/data/runtime_integration_test.go b/internal/data/channel/runtime_integration_test.go similarity index 95% rename from internal/channel/data/runtime_integration_test.go rename to internal/data/channel/runtime_integration_test.go index 2c13d39..92aa1d6 100644 --- a/internal/channel/data/runtime_integration_test.go +++ b/internal/data/channel/runtime_integration_test.go @@ -1,6 +1,6 @@ //go:build integration -package data +package channel import ( "context" @@ -9,7 +9,7 @@ import ( "testing" "time" - channelruntime "github.com/fuchencong/mooncode/internal/channel/runtime" + channelbiz "github.com/fuchencong/mooncode/internal/biz/channel" "github.com/fuchencong/mooncode/internal/testutil/testdb" channelpkg "github.com/fuchencong/mooncode/pkg/channel" "github.com/google/uuid" @@ -42,7 +42,7 @@ VALUES ($1,$2,'feishu','runtime',true,'starting',$3,$4,1,$5)`, channelID, worksp if err != nil { t.Fatal(err) } - var runtimeConfig channelruntime.Configuration + var runtimeConfig channelbiz.RuntimeConfiguration for _, item := range configurations { if item.ID == channelID { runtimeConfig = item diff --git a/internal/channel/data/runtime_store.go b/internal/data/channel/runtime_store.go similarity index 87% rename from internal/channel/data/runtime_store.go rename to internal/data/channel/runtime_store.go index f6b764c..89ae5f8 100644 --- a/internal/channel/data/runtime_store.go +++ b/internal/data/channel/runtime_store.go @@ -1,4 +1,4 @@ -package data +package channel import ( "context" @@ -6,22 +6,22 @@ import ( "errors" "fmt" - channelruntime "github.com/fuchencong/mooncode/internal/channel/runtime" - "github.com/fuchencong/mooncode/internal/data/sqlc" + channelbiz "github.com/fuchencong/mooncode/internal/biz/channel" + "github.com/fuchencong/mooncode/internal/data/postgres/sqlc" channelpkg "github.com/fuchencong/mooncode/pkg/channel" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" ) -func (s *Store) ListRuntimeChannels(ctx context.Context) ([]channelruntime.Configuration, error) { +func (s *Store) ListRuntimeChannels(ctx context.Context) ([]channelbiz.RuntimeConfiguration, error) { rows, err := s.queries.ListRuntimeChannels(ctx) if err != nil { return nil, err } - items := make([]channelruntime.Configuration, 0, len(rows)) + items := make([]channelbiz.RuntimeConfiguration, 0, len(rows)) for _, row := range rows { - items = append(items, channelruntime.Configuration{ + items = append(items, channelbiz.RuntimeConfiguration{ ID: row.ID, WorkspaceID: row.WorkspaceID, Type: row.Type, Version: row.ConfigVersion, Ciphertext: row.SecretCiphertext, Nonce: row.SecretNonce, KeyVersion: int(row.KeyVersion.Int32), Config: row.Config, }) @@ -30,7 +30,7 @@ func (s *Store) ListRuntimeChannels(ctx context.Context) ([]channelruntime.Confi return items, nil } -func (s *Store) SaveInbound(ctx context.Context, configuration channelruntime.Configuration, message channelpkg.InboundMessage) (bool, error) { +func (s *Store) SaveInbound(ctx context.Context, configuration channelbiz.RuntimeConfiguration, message channelpkg.InboundMessage) (bool, error) { tx, err := s.pool.Begin(ctx) if err != nil { return false, err diff --git a/internal/channel/data/store.go b/internal/data/channel/store.go similarity index 93% rename from internal/channel/data/store.go rename to internal/data/channel/store.go index 1253574..4f5291c 100644 --- a/internal/channel/data/store.go +++ b/internal/data/channel/store.go @@ -1,15 +1,16 @@ -package data +package channel import ( "context" "encoding/json" "time" - channel "github.com/fuchencong/mooncode/internal/channel/biz" - "github.com/fuchencong/mooncode/internal/data/pagecursor" - "github.com/fuchencong/mooncode/internal/data/sqlc" - "github.com/fuchencong/mooncode/internal/platform/audit" - "github.com/fuchencong/mooncode/internal/platform/pagination" + channel "github.com/fuchencong/mooncode/internal/biz/channel" + "github.com/fuchencong/mooncode/internal/biz/pagination" + "github.com/fuchencong/mooncode/internal/data/audit" + "github.com/fuchencong/mooncode/internal/data/postgres" + "github.com/fuchencong/mooncode/internal/data/postgres/pagecursor" + "github.com/fuchencong/mooncode/internal/data/postgres/sqlc" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgxpool" @@ -75,7 +76,7 @@ func (s *Store) Create(ctx context.Context, workspaceID, actorID uuid.UUID, kind func (s *Store) Get(ctx context.Context, workspaceID, id uuid.UUID) (channel.Channel, error) { row, err := s.queries.GetChannel(ctx, sqlc.GetChannelParams{ID: id, WorkspaceID: workspaceID}) if err != nil { - return channel.Channel{}, err + return channel.Channel{}, postgres.NotFound(err, "channel.not_found", "Channel not found") } events, err := channelSubscriptionEvents(ctx, s.queries, workspaceID, id) item := mapChannel(row) @@ -92,7 +93,7 @@ func (s *Store) Update(ctx context.Context, workspaceID, actorID, id uuid.UUID, q := s.queries.WithTx(tx) row, err := q.UpdateChannel(ctx, sqlc.UpdateChannelParams{ID: id, WorkspaceID: workspaceID, Name: name, Config: config, ConfigVersion: configVersion}) if err != nil { - return channel.Channel{}, err + return channel.Channel{}, postgres.NotFound(err, "channel.not_found", "Channel not found") } if err = replaceChannelSubscriptions(ctx, q, workspaceID, id, notificationEvents); err != nil { return channel.Channel{}, err @@ -121,7 +122,7 @@ func (s *Store) RotateCredential(ctx context.Context, workspaceID, actorID, id u KeyVersion: pgtype.Int4{Int32: int32(keyVersion), Valid: true}, ConfigVersion: configVersion, }) if err != nil { - return channel.Channel{}, err + return channel.Channel{}, postgres.NotFound(err, "channel.not_found", "Channel not found") } events, err := channelSubscriptionEvents(ctx, q, workspaceID, id) if err != nil { @@ -147,7 +148,7 @@ func (s *Store) Enable(ctx context.Context, workspaceID, actorID, id uuid.UUID, q := s.queries.WithTx(tx) row, err := q.SetChannelEnabled(ctx, sqlc.SetChannelEnabledParams{ID: id, WorkspaceID: workspaceID, Enabled: enabled}) if err != nil { - return channel.Channel{}, err + return channel.Channel{}, postgres.NotFound(err, "channel.not_found", "Channel not found") } events, err := channelSubscriptionEvents(ctx, q, workspaceID, id) if err != nil { diff --git a/internal/data/data.go b/internal/data/data.go new file mode 100644 index 0000000..62a40e1 --- /dev/null +++ b/internal/data/data.go @@ -0,0 +1,117 @@ +// Package data owns MoonCode's process data resources and repository adapters. +package data + +import ( + "context" + "errors" + "net/http" + + analysisdata "github.com/fuchencong/mooncode/internal/data/analysis" + channeldata "github.com/fuchencong/mooncode/internal/data/channel" + identitydata "github.com/fuchencong/mooncode/internal/data/identity" + "github.com/fuchencong/mooncode/internal/data/postgres" + providerdata "github.com/fuchencong/mooncode/internal/data/provider" + repositorydata "github.com/fuchencong/mooncode/internal/data/repository" + retentiondata "github.com/fuchencong/mooncode/internal/data/retention" + secretdata "github.com/fuchencong/mooncode/internal/data/secret" + workflowdata "github.com/fuchencong/mooncode/internal/data/workflow" + "github.com/fuchencong/mooncode/pkg/gitrepo" + "github.com/jackc/pgx/v5/pgxpool" +) + +type Data struct { + pool *pgxpool.Pool + + options options +} + +type Readiness = postgres.Readiness + +func New(ctx context.Context, opts ...Option) (*Data, func(), error) { + options := defaultOptions() + for _, option := range opts { + option(&options) + } + if options.postgresURL == "" { + return nil, nil, errors.New("data PostgreSQL URL is required") + } + if options.maxConnections <= 0 { + return nil, nil, errors.New("data maximum connections must be positive") + } + if len(options.secretKey) == 0 || options.secretKeyVersion <= 0 { + return nil, nil, errors.New("data secret key and version are required") + } + if options.maxRepositoriesPerWorkspace <= 0 || options.maxConcurrentRunsPerWorkspace <= 0 { + return nil, nil, errors.New("data workspace limits must be positive") + } + if options.providerRequestTimeout <= 0 { + return nil, nil, errors.New("provider request timeout must be positive") + } + + pool, err := postgres.Open(ctx, options.postgresURL, options.maxConnections) + if err != nil { + return nil, nil, err + } + if options.migrateOnStart { + if err := postgres.Migrate(ctx, pool); err != nil { + pool.Close() + + return nil, nil, err + } + } + + data := &Data{pool: pool, options: options} + cleanup := func() { + clear(data.options.secretKey) + data.pool.Close() + } + + return data, cleanup, nil +} + +func (d *Data) Migrate(ctx context.Context) error { + return postgres.Migrate(ctx, d.pool) +} + +func NewReadiness(data *Data) *postgres.Readiness { + return postgres.NewReadiness(data.pool) +} + +func NewCipher(data *Data) (*secretdata.AESGCM, error) { + return secretdata.NewAESGCM(data.options.secretKey, data.options.secretKeyVersion) +} + +func NewProviderValidator(data *Data) *providerdata.Validator { + return providerdata.New(providerdata.WithHTTPClient(&http.Client{Timeout: data.options.providerRequestTimeout})) +} + +func NewIdentityStore(data *Data) *identitydata.Store { + return identitydata.NewStore(data.pool) +} + +func NewRepositoryStore(data *Data, git *gitrepo.DefaultManager) *repositorydata.Store { + return repositorydata.NewStore( + data.pool, + git, + repositorydata.WithMaxRepositoriesPerWorkspace(data.options.maxRepositoriesPerWorkspace), + ) +} + +func NewAnalysisStore(data *Data) *analysisdata.Store { + return analysisdata.NewStore( + data.pool, + analysisdata.WithMaxConcurrentRunsPerWorkspace(data.options.maxConcurrentRunsPerWorkspace), + ) +} + +func NewChannelStore(data *Data) *channeldata.Store { + return channeldata.NewStore(data.pool) +} + +func NewRetentionStore(data *Data) *retentiondata.Store { + return retentiondata.NewStore(data.pool) +} + +func NewWorkflowStore(data *Data) *workflowdata.Store { + return workflowdata.NewStore(data.pool) +} diff --git a/internal/identity/data/membership_integration_test.go b/internal/data/identity/membership_integration_test.go similarity index 99% rename from internal/identity/data/membership_integration_test.go rename to internal/data/identity/membership_integration_test.go index 8ed0aa1..4eb303c 100644 --- a/internal/identity/data/membership_integration_test.go +++ b/internal/data/identity/membership_integration_test.go @@ -1,6 +1,6 @@ //go:build integration -package data +package identity import ( "context" diff --git a/internal/identity/data/provider_integration_test.go b/internal/data/identity/provider_integration_test.go similarity index 94% rename from internal/identity/data/provider_integration_test.go rename to internal/data/identity/provider_integration_test.go index db7a79d..304a474 100644 --- a/internal/identity/data/provider_integration_test.go +++ b/internal/data/identity/provider_integration_test.go @@ -1,6 +1,6 @@ //go:build integration -package data +package identity import ( "context" @@ -9,11 +9,11 @@ import ( "strings" "testing" - identity "github.com/fuchencong/mooncode/internal/identity/biz" - "github.com/fuchencong/mooncode/internal/platform/audit" - "github.com/fuchencong/mooncode/internal/platform/auth" - "github.com/fuchencong/mooncode/internal/platform/pagination" - "github.com/fuchencong/mooncode/internal/platform/secret" + "github.com/fuchencong/mooncode/internal/biz/auth" + identity "github.com/fuchencong/mooncode/internal/biz/identity" + "github.com/fuchencong/mooncode/internal/biz/pagination" + "github.com/fuchencong/mooncode/internal/data/audit" + "github.com/fuchencong/mooncode/internal/data/secret" "github.com/fuchencong/mooncode/internal/testutil/testdb" "github.com/google/uuid" "github.com/jackc/pgx/v5" diff --git a/internal/identity/data/provider_store.go b/internal/data/identity/provider_store.go similarity index 87% rename from internal/identity/data/provider_store.go rename to internal/data/identity/provider_store.go index b0e1e85..7bbf2df 100644 --- a/internal/identity/data/provider_store.go +++ b/internal/data/identity/provider_store.go @@ -1,15 +1,16 @@ -package data +package identity import ( "context" "time" - "github.com/fuchencong/mooncode/internal/data/pagecursor" - "github.com/fuchencong/mooncode/internal/data/sqlc" - identity "github.com/fuchencong/mooncode/internal/identity/biz" - "github.com/fuchencong/mooncode/internal/platform/audit" - "github.com/fuchencong/mooncode/internal/platform/fault" - "github.com/fuchencong/mooncode/internal/platform/pagination" + "github.com/fuchencong/mooncode/internal/biz/fault" + identity "github.com/fuchencong/mooncode/internal/biz/identity" + "github.com/fuchencong/mooncode/internal/biz/pagination" + "github.com/fuchencong/mooncode/internal/data/audit" + "github.com/fuchencong/mooncode/internal/data/postgres" + "github.com/fuchencong/mooncode/internal/data/postgres/pagecursor" + "github.com/fuchencong/mooncode/internal/data/postgres/sqlc" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgtype" ) @@ -79,7 +80,7 @@ func (s *Store) CreateProviderConnection(ctx context.Context, userID uuid.UUID, func (s *Store) GetProviderSecret(ctx context.Context, userID, connectionID uuid.UUID) (identity.ProviderConnection, []byte, []byte, int, error) { row, err := s.queries.GetProviderConnection(ctx, sqlc.GetProviderConnectionParams{ID: connectionID, UserID: userID}) - return mapProvider(row), row.TokenCiphertext, row.TokenNonce, int(row.KeyVersion), err + return mapProvider(row), row.TokenCiphertext, row.TokenNonce, int(row.KeyVersion), postgres.NotFound(err, "provider.connection_not_found", "Provider connection not found") } func (s *Store) MarkProviderConnectionUsed(ctx context.Context, userID, connectionID uuid.UUID) error { @@ -100,11 +101,11 @@ func (s *Store) ReplaceProviderCredential(ctx context.Context, userID, connectio q := s.queries.WithTx(tx) _, err = q.ReplaceProviderCredential(ctx, sqlc.ReplaceProviderCredentialParams{ID: connectionID, UserID: userID, TokenCiphertext: ciphertext, TokenNonce: nonce, KeyVersion: int32(keyVersion)}) if err != nil { - return identity.ProviderConnection{}, err + return identity.ProviderConnection{}, postgres.NotFound(err, "provider.connection_not_found", "Provider connection not found") } row, err := q.SetProviderConnectionValidation(ctx, validationParams(connectionID, userID, profile, "active", "")) if err != nil { - return identity.ProviderConnection{}, err + return identity.ProviderConnection{}, postgres.NotFound(err, "provider.connection_not_found", "Provider connection not found") } if err = audit.Record(ctx, q, audit.Event{ ActorUserID: userID, @@ -125,7 +126,7 @@ func (s *Store) ReplaceProviderCredential(ctx context.Context, userID, connectio func (s *Store) SetProviderValidation(ctx context.Context, userID, connectionID uuid.UUID, profile identity.ProviderProfile, status, code string) (identity.ProviderConnection, error) { row, err := s.queries.SetProviderConnectionValidation(ctx, validationParams(connectionID, userID, profile, status, code)) - return mapProvider(row), err + return mapProvider(row), postgres.NotFound(err, "provider.connection_not_found", "Provider connection not found") } func (s *Store) SetDefaultProviderConnection(ctx context.Context, userID, connectionID uuid.UUID) (identity.ProviderConnection, error) { @@ -137,14 +138,14 @@ func (s *Store) SetDefaultProviderConnection(ctx context.Context, userID, connec q := s.queries.WithTx(tx) connection, err := q.GetProviderConnection(ctx, sqlc.GetProviderConnectionParams{ID: connectionID, UserID: userID}) if err != nil { - return identity.ProviderConnection{}, err + return identity.ProviderConnection{}, postgres.NotFound(err, "provider.connection_not_found", "Provider connection not found") } if err = q.ClearDefaultProviderConnections(ctx, sqlc.ClearDefaultProviderConnectionsParams{UserID: userID, ProviderType: connection.ProviderType}); err != nil { return identity.ProviderConnection{}, err } connection, err = q.SetDefaultProviderConnection(ctx, sqlc.SetDefaultProviderConnectionParams{ID: connectionID, UserID: userID}) if err != nil { - return identity.ProviderConnection{}, err + return identity.ProviderConnection{}, postgres.NotFound(err, "provider.connection_not_found", "Provider connection not found") } if err = audit.Record(ctx, q, audit.Event{ ActorUserID: userID, diff --git a/internal/identity/data/store.go b/internal/data/identity/store.go similarity index 90% rename from internal/identity/data/store.go rename to internal/data/identity/store.go index 2f0da42..5de86e0 100644 --- a/internal/identity/data/store.go +++ b/internal/data/identity/store.go @@ -1,4 +1,4 @@ -package data +package identity import ( "context" @@ -7,13 +7,14 @@ import ( "strings" "time" - analysis "github.com/fuchencong/mooncode/internal/analysis/biz" - "github.com/fuchencong/mooncode/internal/data/pagecursor" - "github.com/fuchencong/mooncode/internal/data/sqlc" - identity "github.com/fuchencong/mooncode/internal/identity/biz" - "github.com/fuchencong/mooncode/internal/platform/auth" - "github.com/fuchencong/mooncode/internal/platform/fault" - "github.com/fuchencong/mooncode/internal/platform/pagination" + analysis "github.com/fuchencong/mooncode/internal/biz/analysis" + "github.com/fuchencong/mooncode/internal/biz/auth" + "github.com/fuchencong/mooncode/internal/biz/fault" + identity "github.com/fuchencong/mooncode/internal/biz/identity" + "github.com/fuchencong/mooncode/internal/biz/pagination" + "github.com/fuchencong/mooncode/internal/data/postgres" + "github.com/fuchencong/mooncode/internal/data/postgres/pagecursor" + "github.com/fuchencong/mooncode/internal/data/postgres/sqlc" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" @@ -69,12 +70,12 @@ func (s *Store) ActivateUser(ctx context.Context, id uuid.UUID) (identity.User, user, err = s.queries.GetUser(ctx, id) } - return mapUser(user), err + return mapUser(user), postgres.NotFound(err, "identity.user_not_found", "User not found") } func (s *Store) GetUser(ctx context.Context, id uuid.UUID) (identity.User, error) { user, err := s.queries.GetUser(ctx, id) - return mapUser(user), err + return mapUser(user), postgres.NotFound(err, "identity.user_not_found", "User not found") } func (s *Store) ListWorkspaces(ctx context.Context, userID uuid.UUID) ([]identity.Workspace, error) { @@ -131,7 +132,7 @@ func (s *Store) CreateWorkspace(ctx context.Context, userID uuid.UUID, name, slu func (s *Store) GetMembership(ctx context.Context, workspaceID, userID uuid.UUID) (identity.Membership, error) { row, err := s.queries.GetWorkspaceMembership(ctx, sqlc.GetWorkspaceMembershipParams{ID: workspaceID, UserID: userID}) - return identity.Membership{Workspace: identity.Workspace{ID: row.ID, Name: row.Name, Slug: row.Slug, ReportRetentionDays: row.ReportRetentionDays, CreatedBy: row.CreatedBy, Role: row.Role}, Role: row.Role}, err + return identity.Membership{Workspace: identity.Workspace{ID: row.ID, Name: row.Name, Slug: row.Slug, ReportRetentionDays: row.ReportRetentionDays, CreatedBy: row.CreatedBy, Role: row.Role}, Role: row.Role}, postgres.NotFound(err, "workspace.membership_not_found", "Workspace membership not found") } func (s *Store) UpdateWorkspace(ctx context.Context, id uuid.UUID, update identity.WorkspaceUpdate) (identity.Workspace, error) { @@ -143,7 +144,7 @@ func (s *Store) UpdateWorkspace(ctx context.Context, id uuid.UUID, update identi params.ReportRetentionDays = pgtype.Int4{Int32: *update.ReportRetentionDays, Valid: true} } row, err := s.queries.UpdateWorkspace(ctx, params) - return mapWorkspace(row), err + return mapWorkspace(row), postgres.NotFound(err, "workspace.not_found", "Workspace not found") } func (s *Store) ListMembers(ctx context.Context, workspaceID uuid.UUID, page pagination.Request) (pagination.Page[identity.Member], error) { @@ -212,7 +213,7 @@ func (s *Store) AcceptInvitation(ctx context.Context, userID uuid.UUID, hash []b q := s.queries.WithTx(tx) invitation, err := q.GetInvitationByTokenHash(ctx, hash) if err != nil { - return identity.Workspace{}, err + return identity.Workspace{}, postgres.NotFound(err, "workspace.invitation_not_found", "Invitation not found") } if !strings.EqualFold(invitation.Email, email) { return identity.Workspace{}, fault.New(fault.Forbidden, "workspace.invitation_recipient_mismatch", "Invitation does not belong to the authenticated user") diff --git a/internal/data/option.go b/internal/data/option.go new file mode 100644 index 0000000..9fd4a28 --- /dev/null +++ b/internal/data/option.go @@ -0,0 +1,51 @@ +package data + +import "time" + +type options struct { + postgresURL string + maxConnections int32 + migrateOnStart bool + secretKey []byte + secretKeyVersion int + maxRepositoriesPerWorkspace int64 + maxConcurrentRunsPerWorkspace int64 + providerRequestTimeout time.Duration +} + +// Option configures process data resources and adapters. +type Option func(*options) + +func WithPostgres(url string, maxConnections int32) Option { + return func(options *options) { + options.postgresURL = url + options.maxConnections = maxConnections + } +} + +func WithMigrateOnStart(enabled bool) Option { + return func(options *options) { options.migrateOnStart = enabled } +} + +func WithSecretKey(key []byte, version int) Option { + return func(options *options) { + options.secretKey = append([]byte(nil), key...) + options.secretKeyVersion = version + } +} + +func WithRepositoryLimit(maximum int64) Option { + return func(options *options) { options.maxRepositoriesPerWorkspace = maximum } +} + +func WithAnalysisLimit(maximum int64) Option { + return func(options *options) { options.maxConcurrentRunsPerWorkspace = maximum } +} + +func WithProviderRequestTimeout(timeout time.Duration) Option { + return func(options *options) { options.providerRequestTimeout = timeout } +} + +func defaultOptions() options { + return options{providerRequestTimeout: 15 * time.Second} +} diff --git a/internal/data/option_test.go b/internal/data/option_test.go new file mode 100644 index 0000000..cca0a75 --- /dev/null +++ b/internal/data/option_test.go @@ -0,0 +1,23 @@ +package data + +import ( + "testing" + "time" +) + +func TestDefaultOptionsUseBoundedProviderTimeout(t *testing.T) { + options := defaultOptions() + if options.providerRequestTimeout != 15*time.Second { + t.Fatalf("provider timeout = %s", options.providerRequestTimeout) + } +} + +func TestSecretOptionCopiesKey(t *testing.T) { + key := []byte("secret") + options := defaultOptions() + WithSecretKey(key, 2)(&options) + key[0] = 'X' + if string(options.secretKey) != "secret" || options.secretKeyVersion != 2 { + t.Fatalf("secret option retained caller storage: %q version %d", options.secretKey, options.secretKeyVersion) + } +} diff --git a/internal/platform/database/database.go b/internal/data/postgres/database.go similarity index 97% rename from internal/platform/database/database.go rename to internal/data/postgres/database.go index 86f269b..2d86c0d 100644 --- a/internal/platform/database/database.go +++ b/internal/data/postgres/database.go @@ -1,4 +1,4 @@ -package database +package postgres import ( "context" diff --git a/internal/data/postgres/errors.go b/internal/data/postgres/errors.go new file mode 100644 index 0000000..32b0e44 --- /dev/null +++ b/internal/data/postgres/errors.go @@ -0,0 +1,17 @@ +package postgres + +import ( + "errors" + + "github.com/fuchencong/mooncode/internal/biz/fault" + "github.com/jackc/pgx/v5" +) + +// NotFound converts the driver's empty-row signal into a domain-classified error. +func NotFound(err error, code, message string) error { + if errors.Is(err, pgx.ErrNoRows) { + return fault.Wrap(fault.NotFound, code, message, err) + } + + return err +} diff --git a/internal/platform/database/migrate.go b/internal/data/postgres/migrate.go similarity index 99% rename from internal/platform/database/migrate.go rename to internal/data/postgres/migrate.go index d3d709a..1383166 100644 --- a/internal/platform/database/migrate.go +++ b/internal/data/postgres/migrate.go @@ -1,4 +1,4 @@ -package database +package postgres import ( "context" diff --git a/internal/data/pagecursor/pagecursor.go b/internal/data/postgres/pagecursor/pagecursor.go similarity index 85% rename from internal/data/pagecursor/pagecursor.go rename to internal/data/postgres/pagecursor/pagecursor.go index a93a765..bdfa5ab 100644 --- a/internal/data/pagecursor/pagecursor.go +++ b/internal/data/postgres/pagecursor/pagecursor.go @@ -1,7 +1,7 @@ package pagecursor import ( - "github.com/fuchencong/mooncode/internal/platform/pagination" + "github.com/fuchencong/mooncode/internal/biz/pagination" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgtype" ) diff --git a/internal/platform/database/readiness.go b/internal/data/postgres/readiness.go similarity index 98% rename from internal/platform/database/readiness.go rename to internal/data/postgres/readiness.go index 5fb5039..9f5c0fe 100644 --- a/internal/platform/database/readiness.go +++ b/internal/data/postgres/readiness.go @@ -1,4 +1,4 @@ -package database +package postgres import ( "context" diff --git a/internal/platform/database/readiness_test.go b/internal/data/postgres/readiness_test.go similarity index 99% rename from internal/platform/database/readiness_test.go rename to internal/data/postgres/readiness_test.go index 1918830..03b3e6e 100644 --- a/internal/platform/database/readiness_test.go +++ b/internal/data/postgres/readiness_test.go @@ -1,4 +1,4 @@ -package database +package postgres import ( "context" diff --git a/internal/data/sqlc/analysis.sql.go b/internal/data/postgres/sqlc/analysis.sql.go similarity index 100% rename from internal/data/sqlc/analysis.sql.go rename to internal/data/postgres/sqlc/analysis.sql.go diff --git a/internal/data/sqlc/audit.sql.go b/internal/data/postgres/sqlc/audit.sql.go similarity index 100% rename from internal/data/sqlc/audit.sql.go rename to internal/data/postgres/sqlc/audit.sql.go diff --git a/internal/data/sqlc/channel_commands.sql.go b/internal/data/postgres/sqlc/channel_commands.sql.go similarity index 100% rename from internal/data/sqlc/channel_commands.sql.go rename to internal/data/postgres/sqlc/channel_commands.sql.go diff --git a/internal/data/sqlc/channels.sql.go b/internal/data/postgres/sqlc/channels.sql.go similarity index 100% rename from internal/data/sqlc/channels.sql.go rename to internal/data/postgres/sqlc/channels.sql.go diff --git a/internal/data/sqlc/db.go b/internal/data/postgres/sqlc/db.go similarity index 100% rename from internal/data/sqlc/db.go rename to internal/data/postgres/sqlc/db.go diff --git a/internal/data/sqlc/identity.sql.go b/internal/data/postgres/sqlc/identity.sql.go similarity index 100% rename from internal/data/sqlc/identity.sql.go rename to internal/data/postgres/sqlc/identity.sql.go diff --git a/internal/data/sqlc/models.go b/internal/data/postgres/sqlc/models.go similarity index 100% rename from internal/data/sqlc/models.go rename to internal/data/postgres/sqlc/models.go diff --git a/internal/data/sqlc/notifications.sql.go b/internal/data/postgres/sqlc/notifications.sql.go similarity index 100% rename from internal/data/sqlc/notifications.sql.go rename to internal/data/postgres/sqlc/notifications.sql.go diff --git a/internal/data/sqlc/providers.sql.go b/internal/data/postgres/sqlc/providers.sql.go similarity index 100% rename from internal/data/sqlc/providers.sql.go rename to internal/data/postgres/sqlc/providers.sql.go diff --git a/internal/data/sqlc/querier.go b/internal/data/postgres/sqlc/querier.go similarity index 100% rename from internal/data/sqlc/querier.go rename to internal/data/postgres/sqlc/querier.go diff --git a/internal/data/sqlc/repositories.sql.go b/internal/data/postgres/sqlc/repositories.sql.go similarity index 100% rename from internal/data/sqlc/repositories.sql.go rename to internal/data/postgres/sqlc/repositories.sql.go diff --git a/internal/data/sqlc/retention.sql.go b/internal/data/postgres/sqlc/retention.sql.go similarity index 100% rename from internal/data/sqlc/retention.sql.go rename to internal/data/postgres/sqlc/retention.sql.go diff --git a/internal/data/sqlc/workflows.sql.go b/internal/data/postgres/sqlc/workflows.sql.go similarity index 100% rename from internal/data/sqlc/workflows.sql.go rename to internal/data/postgres/sqlc/workflows.sql.go diff --git a/internal/data/provider.go b/internal/data/provider.go new file mode 100644 index 0000000..ea2eaf0 --- /dev/null +++ b/internal/data/provider.go @@ -0,0 +1,49 @@ +package data + +import ( + analysis "github.com/fuchencong/mooncode/internal/biz/analysis" + channel "github.com/fuchencong/mooncode/internal/biz/channel" + channelcommand "github.com/fuchencong/mooncode/internal/biz/channel/command" + channelworkflow "github.com/fuchencong/mooncode/internal/biz/channel/workflow" + identity "github.com/fuchencong/mooncode/internal/biz/identity" + repository "github.com/fuchencong/mooncode/internal/biz/repository" + retention "github.com/fuchencong/mooncode/internal/biz/retention" + secret "github.com/fuchencong/mooncode/internal/biz/secret" + workflow "github.com/fuchencong/mooncode/internal/biz/workflow" + analysisdata "github.com/fuchencong/mooncode/internal/data/analysis" + channeldata "github.com/fuchencong/mooncode/internal/data/channel" + identitydata "github.com/fuchencong/mooncode/internal/data/identity" + providerdata "github.com/fuchencong/mooncode/internal/data/provider" + repositorydata "github.com/fuchencong/mooncode/internal/data/repository" + retentiondata "github.com/fuchencong/mooncode/internal/data/retention" + secretdata "github.com/fuchencong/mooncode/internal/data/secret" + workflowdata "github.com/fuchencong/mooncode/internal/data/workflow" + "github.com/google/wire" +) + +// ProviderSet contains the data resource and all domain repository adapters. +var ProviderSet = wire.NewSet( + New, + NewReadiness, + NewCipher, + NewProviderValidator, + NewIdentityStore, + NewRepositoryStore, + NewAnalysisStore, + NewChannelStore, + NewRetentionStore, + NewWorkflowStore, + wire.Bind(new(identity.Store), new(*identitydata.Store)), + wire.Bind(new(identity.ProviderStore), new(*identitydata.Store)), + wire.Bind(new(identity.ProviderValidator), new(*providerdata.Validator)), + wire.Bind(new(repository.Store), new(*repositorydata.Store)), + wire.Bind(new(analysis.Repositories), new(*repositorydata.Store)), + wire.Bind(new(analysis.Store), new(*analysisdata.Store)), + wire.Bind(new(channel.Store), new(*channeldata.Store)), + wire.Bind(new(channel.RuntimeStore), new(*channeldata.Store)), + wire.Bind(new(channelcommand.Store), new(*channeldata.Store)), + wire.Bind(new(channelworkflow.Store), new(*channeldata.Store)), + wire.Bind(new(retention.Store), new(*retentiondata.Store)), + wire.Bind(new(workflow.DispatchStore), new(*workflowdata.Store)), + wire.Bind(new(secret.Cipher), new(*secretdata.AESGCM)), +) diff --git a/internal/data/provider/validator.go b/internal/data/provider/validator.go new file mode 100644 index 0000000..896174b --- /dev/null +++ b/internal/data/provider/validator.go @@ -0,0 +1,92 @@ +// Package provider contains outbound source-control provider adapters. +package provider + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + "time" + + identity "github.com/fuchencong/mooncode/internal/biz/identity" +) + +type Validator struct { + client *http.Client +} + +type Option func(*Validator) + +func WithHTTPClient(client *http.Client) Option { + return func(validator *Validator) { + if client != nil { + validator.client = client + } + } +} + +func New(options ...Option) *Validator { + validator := &Validator{client: &http.Client{Timeout: 15 * time.Second}} + for _, option := range options { + option(validator) + } + + return validator +} + +func (v *Validator) Validate(ctx context.Context, providerType, baseURL, token string) (identity.ProviderProfile, error) { + endpoint := strings.TrimRight(baseURL, "/") + "/api/v4/user" + if providerType == "github" { + if baseURL == "https://github.com" { + endpoint = "https://api.github.com/user" + } else { + endpoint = strings.TrimRight(baseURL, "/") + "/api/v3/user" + } + } + + request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return identity.ProviderProfile{}, err + } + request.Header.Set("Accept", "application/json") + request.Header.Set("Authorization", "Bearer "+token) + request.Header.Set("User-Agent", "MoonCode") + + response, err := v.client.Do(request) + if err != nil { + return identity.ProviderProfile{}, err + } + defer func() { _ = response.Body.Close() }() + if response.StatusCode != http.StatusOK { + return identity.ProviderProfile{}, fmt.Errorf("provider returned HTTP %d", response.StatusCode) + } + + var body struct { + ID any `json:"id"` + Login string `json:"login"` + Username string `json:"username"` + Name string `json:"name"` + } + if err := json.NewDecoder(response.Body).Decode(&body); err != nil { + return identity.ProviderProfile{}, err + } + login := body.Login + if login == "" { + login = body.Username + } + + var scopes []string + for _, scope := range strings.Split(response.Header.Get("X-OAuth-Scopes"), ",") { + if value := strings.TrimSpace(scope); value != "" { + scopes = append(scopes, value) + } + } + + return identity.ProviderProfile{ + AccountID: fmt.Sprint(body.ID), + Login: login, + Name: body.Name, + Scopes: scopes, + }, nil +} diff --git a/internal/repository/data/options.go b/internal/data/repository/options.go similarity index 90% rename from internal/repository/data/options.go rename to internal/data/repository/options.go index 598f7d6..a9eefec 100644 --- a/internal/repository/data/options.go +++ b/internal/data/repository/options.go @@ -1,4 +1,4 @@ -package data +package repository type Option func(*Store) diff --git a/internal/repository/data/quota_integration_test.go b/internal/data/repository/quota_integration_test.go similarity index 93% rename from internal/repository/data/quota_integration_test.go rename to internal/data/repository/quota_integration_test.go index b8e5b0b..f2b774f 100644 --- a/internal/repository/data/quota_integration_test.go +++ b/internal/data/repository/quota_integration_test.go @@ -1,14 +1,14 @@ //go:build integration -package data +package repository import ( "context" "os" "testing" - "github.com/fuchencong/mooncode/internal/platform/fault" - repository "github.com/fuchencong/mooncode/internal/repository/biz" + "github.com/fuchencong/mooncode/internal/biz/fault" + repository "github.com/fuchencong/mooncode/internal/biz/repository" "github.com/fuchencong/mooncode/internal/testutil/testdb" "github.com/fuchencong/mooncode/pkg/gitrepo" "github.com/google/uuid" diff --git a/internal/repository/data/store.go b/internal/data/repository/store.go similarity index 95% rename from internal/repository/data/store.go rename to internal/data/repository/store.go index 37a25a9..b4ef10f 100644 --- a/internal/repository/data/store.go +++ b/internal/data/repository/store.go @@ -1,17 +1,18 @@ -package data +package repository import ( "context" "errors" "time" - "github.com/fuchencong/mooncode/internal/data/pagecursor" - "github.com/fuchencong/mooncode/internal/data/sqlc" - "github.com/fuchencong/mooncode/internal/platform/audit" - "github.com/fuchencong/mooncode/internal/platform/fault" - "github.com/fuchencong/mooncode/internal/platform/pagination" - repository "github.com/fuchencong/mooncode/internal/repository/biz" - workflow "github.com/fuchencong/mooncode/internal/workflow/biz" + "github.com/fuchencong/mooncode/internal/biz/fault" + "github.com/fuchencong/mooncode/internal/biz/pagination" + repository "github.com/fuchencong/mooncode/internal/biz/repository" + workflow "github.com/fuchencong/mooncode/internal/biz/workflow" + "github.com/fuchencong/mooncode/internal/data/audit" + "github.com/fuchencong/mooncode/internal/data/postgres" + "github.com/fuchencong/mooncode/internal/data/postgres/pagecursor" + "github.com/fuchencong/mooncode/internal/data/postgres/sqlc" "github.com/fuchencong/mooncode/pkg/gitrepo" "github.com/google/uuid" "github.com/jackc/pgx/v5" @@ -87,7 +88,7 @@ func (s *Store) Create(ctx context.Context, item repository.Repository, operatio func (s *Store) Get(ctx context.Context, workspaceID, id uuid.UUID) (repository.Repository, error) { row, err := s.queries.GetRepository(ctx, sqlc.GetRepositoryParams{ID: id, WorkspaceID: workspaceID}) if err != nil { - return repository.Repository{}, err + return repository.Repository{}, postgres.NotFound(err, "repository.not_found", "Repository not found") } item := mapRepository(row) if row.CurrentSnapshotID.Valid { @@ -138,7 +139,7 @@ func (s *Store) List(ctx context.Context, workspaceID uuid.UUID, page pagination func (s *Store) Update(ctx context.Context, workspaceID, id uuid.UUID, name string) (repository.Repository, error) { row, err := s.queries.UpdateRepository(ctx, sqlc.UpdateRepositoryParams{ID: id, WorkspaceID: workspaceID, Name: name}) - return mapRepository(row), err + return mapRepository(row), postgres.NotFound(err, "repository.not_found", "Repository not found") } func (s *Store) UpdateWithOperation(ctx context.Context, workspaceID, id uuid.UUID, name string, operation repository.Operation) (repository.Repository, repository.Operation, error) { @@ -200,7 +201,7 @@ func (s *Store) Archive(ctx context.Context, workspaceID, id uuid.UUID, archived row, err = s.queries.RestoreRepository(ctx, sqlc.RestoreRepositoryParams{ID: id, WorkspaceID: workspaceID}) } - return mapRepository(row), err + return mapRepository(row), postgres.NotFound(err, "repository.not_found", "Repository not found") } func (s *Store) RequestDeletion(ctx context.Context, workspaceID, id uuid.UUID, operation repository.Operation) (repository.Repository, repository.Operation, error) { @@ -240,7 +241,7 @@ func (s *Store) RequestDeletion(ctx context.Context, workspaceID, id uuid.UUID, func (s *Store) Delete(ctx context.Context, workspaceID, id uuid.UUID) error { count, err := s.queries.DeleteRepository(ctx, sqlc.DeleteRepositoryParams{ID: id, WorkspaceID: workspaceID}) if err == nil && count == 0 { - return pgx.ErrNoRows + return postgres.NotFound(pgx.ErrNoRows, "repository.not_found", "Repository not found") } return err } @@ -306,7 +307,7 @@ func (s *Store) ListOperations(ctx context.Context, repositoryID uuid.UUID, page func (s *Store) Snapshot(ctx context.Context, workspaceID, repositoryID, snapshotID uuid.UUID) (repository.Snapshot, error) { row, err := s.queries.GetWorkspaceSnapshot(ctx, sqlc.GetWorkspaceSnapshotParams{ID: snapshotID, RepositoryID: repositoryID, WorkspaceID: workspaceID}) - return mapSnapshot(row), err + return mapSnapshot(row), postgres.NotFound(err, "repository.snapshot_not_found", "Repository snapshot not found") } func (s *Store) Snapshots(ctx context.Context, workspaceID, repositoryID uuid.UUID, page pagination.Request) (pagination.Page[repository.Snapshot], error) { @@ -334,14 +335,14 @@ func (s *Store) Snapshots(ctx context.Context, workspaceID, repositoryID uuid.UU func (s *Store) GetWorkItem(ctx context.Context, operationID uuid.UUID) (repository.WorkItem, error) { op, err := s.queries.GetRepositoryOperation(ctx, operationID) if err != nil { - return repository.WorkItem{}, err + return repository.WorkItem{}, postgres.NotFound(err, "repository.operation_not_found", "Repository operation not found") } var row sqlc.Repository err = s.pool.QueryRow(ctx, `SELECT id,workspace_id,provider_type,name,remote_url,normalized_url,configured_ref,config_version,git_path,status,current_snapshot_id,mirror_size_bytes,last_sync_at,last_error_code,last_error_message,archived_at,deleted_at,created_by,created_at,updated_at FROM repositories WHERE id=$1`, op.RepositoryID).Scan( &row.ID, &row.WorkspaceID, &row.ProviderType, &row.Name, &row.RemoteUrl, &row.NormalizedUrl, &row.ConfiguredRef, &row.ConfigVersion, &row.GitPath, &row.Status, &row.CurrentSnapshotID, &row.MirrorSizeBytes, &row.LastSyncAt, &row.LastErrorCode, &row.LastErrorMessage, &row.ArchivedAt, &row.DeletedAt, &row.CreatedBy, &row.CreatedAt, &row.UpdatedAt, ) if err != nil { - return repository.WorkItem{}, err + return repository.WorkItem{}, postgres.NotFound(err, "repository.not_found", "Repository not found") } item := mapRepository(row) if row.CurrentSnapshotID.Valid { @@ -358,7 +359,7 @@ func (s *Store) GetWorkItem(ctx context.Context, operationID uuid.UUID) (reposit func (s *Store) StartOperation(ctx context.Context, id uuid.UUID) (repository.Operation, error) { row, err := s.queries.StartRepositoryOperation(ctx, id) - return mapOperation(row), err + return mapOperation(row), postgres.NotFound(err, "repository.operation_not_found", "Repository operation not found") } func (s *Store) CompleteSync(ctx context.Context, work repository.WorkItem, snapshotID uuid.UUID, result gitrepo.Snapshot) (repository.Operation, error) { diff --git a/internal/repository/data/store_integration_test.go b/internal/data/repository/store_integration_test.go similarity index 98% rename from internal/repository/data/store_integration_test.go rename to internal/data/repository/store_integration_test.go index b1fd0da..1424c26 100644 --- a/internal/repository/data/store_integration_test.go +++ b/internal/data/repository/store_integration_test.go @@ -1,15 +1,15 @@ //go:build integration -package data +package repository import ( "context" "os" "testing" - "github.com/fuchencong/mooncode/internal/platform/audit" - "github.com/fuchencong/mooncode/internal/platform/pagination" - repository "github.com/fuchencong/mooncode/internal/repository/biz" + "github.com/fuchencong/mooncode/internal/biz/pagination" + repository "github.com/fuchencong/mooncode/internal/biz/repository" + "github.com/fuchencong/mooncode/internal/data/audit" "github.com/fuchencong/mooncode/internal/testutil/testdb" "github.com/fuchencong/mooncode/pkg/gitrepo" "github.com/google/uuid" diff --git a/internal/retention/data/store.go b/internal/data/retention/store.go similarity index 95% rename from internal/retention/data/store.go rename to internal/data/retention/store.go index 3672ab0..8be32c8 100644 --- a/internal/retention/data/store.go +++ b/internal/data/retention/store.go @@ -1,13 +1,14 @@ -package data +package retention import ( "context" "errors" - "github.com/fuchencong/mooncode/internal/data/sqlc" - "github.com/fuchencong/mooncode/internal/platform/audit" - retention "github.com/fuchencong/mooncode/internal/retention/biz" - workflow "github.com/fuchencong/mooncode/internal/workflow/biz" + retention "github.com/fuchencong/mooncode/internal/biz/retention" + workflow "github.com/fuchencong/mooncode/internal/biz/workflow" + "github.com/fuchencong/mooncode/internal/data/audit" + "github.com/fuchencong/mooncode/internal/data/postgres" + "github.com/fuchencong/mooncode/internal/data/postgres/sqlc" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgxpool" @@ -80,7 +81,7 @@ func (s *Store) ScheduleDue(ctx context.Context, limit int32) (int, error) { func (s *Store) GetCleanup(ctx context.Context, id uuid.UUID) (retention.Cleanup, error) { row, err := s.queries.GetRetentionCleanup(ctx, id) - return mapCleanup(row), err + return mapCleanup(row), postgres.NotFound(err, "retention.cleanup_not_found", "Retention cleanup not found") } func (s *Store) StartCleanup(ctx context.Context, id uuid.UUID) (retention.Cleanup, error) { diff --git a/internal/retention/data/store_integration_test.go b/internal/data/retention/store_integration_test.go similarity index 97% rename from internal/retention/data/store_integration_test.go rename to internal/data/retention/store_integration_test.go index a876b46..8221138 100644 --- a/internal/retention/data/store_integration_test.go +++ b/internal/data/retention/store_integration_test.go @@ -1,6 +1,6 @@ //go:build integration -package data +package retention import ( "context" @@ -11,10 +11,10 @@ import ( "testing" "time" - analysis "github.com/fuchencong/mooncode/internal/analysis/biz" - analysisdata "github.com/fuchencong/mooncode/internal/analysis/data" - "github.com/fuchencong/mooncode/internal/platform/fault" - retention "github.com/fuchencong/mooncode/internal/retention/biz" + analysis "github.com/fuchencong/mooncode/internal/biz/analysis" + "github.com/fuchencong/mooncode/internal/biz/fault" + retention "github.com/fuchencong/mooncode/internal/biz/retention" + analysisdata "github.com/fuchencong/mooncode/internal/data/analysis" "github.com/fuchencong/mooncode/internal/testutil/testdb" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgxpool" diff --git a/internal/platform/secret/cipher.go b/internal/data/secret/cipher.go similarity index 88% rename from internal/platform/secret/cipher.go rename to internal/data/secret/cipher.go index e15d308..464ddcf 100644 --- a/internal/platform/secret/cipher.go +++ b/internal/data/secret/cipher.go @@ -8,11 +8,6 @@ import ( "io" ) -type Cipher interface { - Encrypt(plaintext []byte) (ciphertext, nonce []byte, keyVersion int, err error) - Decrypt(ciphertext, nonce []byte, keyVersion int) ([]byte, error) -} - type Option func(*AESGCM) type AESGCM struct { diff --git a/internal/platform/secret/cipher_test.go b/internal/data/secret/cipher_test.go similarity index 100% rename from internal/platform/secret/cipher_test.go rename to internal/data/secret/cipher_test.go diff --git a/internal/workflow/data/store.go b/internal/data/workflow/store.go similarity index 95% rename from internal/workflow/data/store.go rename to internal/data/workflow/store.go index 0826812..c40496f 100644 --- a/internal/workflow/data/store.go +++ b/internal/data/workflow/store.go @@ -1,12 +1,12 @@ -package data +package workflow import ( "context" "fmt" "time" - "github.com/fuchencong/mooncode/internal/data/sqlc" - workflow "github.com/fuchencong/mooncode/internal/workflow/biz" + workflow "github.com/fuchencong/mooncode/internal/biz/workflow" + "github.com/fuchencong/mooncode/internal/data/postgres/sqlc" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgxpool" diff --git a/internal/workflow/data/store_integration_test.go b/internal/data/workflow/store_integration_test.go similarity index 97% rename from internal/workflow/data/store_integration_test.go rename to internal/data/workflow/store_integration_test.go index e31ff73..ba1f2bf 100644 --- a/internal/workflow/data/store_integration_test.go +++ b/internal/data/workflow/store_integration_test.go @@ -1,6 +1,6 @@ //go:build integration -package data +package workflow import ( "context" @@ -8,9 +8,9 @@ import ( "testing" "time" - "github.com/fuchencong/mooncode/internal/data/sqlc" + workflow "github.com/fuchencong/mooncode/internal/biz/workflow" + "github.com/fuchencong/mooncode/internal/data/postgres/sqlc" "github.com/fuchencong/mooncode/internal/testutil/testdb" - workflow "github.com/fuchencong/mooncode/internal/workflow/biz" "github.com/google/uuid" ) diff --git a/internal/platform/httpserver/options.go b/internal/platform/httpserver/options.go deleted file mode 100644 index 845d9f7..0000000 --- a/internal/platform/httpserver/options.go +++ /dev/null @@ -1,34 +0,0 @@ -package httpserver - -import ( - "context" - "log/slog" -) - -type Readiness interface { - Check(context.Context) error -} - -type Option func(*Server) - -func WithReadiness(readiness Readiness) Option { - return func(server *Server) { - server.readiness = readiness - } -} - -func WithLogger(logger *slog.Logger) Option { - return func(server *Server) { - if logger != nil { - server.logger = logger - } - } -} - -func WithMetrics(metrics RequestMetrics) Option { - return func(server *Server) { - if metrics != nil { - server.metrics = metrics - } - } -} diff --git a/internal/platform/httpserver/accesslog.go b/internal/server/accesslog.go similarity index 89% rename from internal/platform/httpserver/accesslog.go rename to internal/server/accesslog.go index 0667abd..3412573 100644 --- a/internal/platform/httpserver/accesslog.go +++ b/internal/server/accesslog.go @@ -1,10 +1,10 @@ -package httpserver +package server import ( "log/slog" "time" - "github.com/fuchencong/mooncode/internal/platform/auth" + "github.com/fuchencong/mooncode/internal/server/auth" "github.com/gin-gonic/gin" ) diff --git a/internal/platform/httpserver/analysis.go b/internal/server/analysis.go similarity index 96% rename from internal/platform/httpserver/analysis.go rename to internal/server/analysis.go index c7461ae..2fdf88d 100644 --- a/internal/platform/httpserver/analysis.go +++ b/internal/server/analysis.go @@ -1,10 +1,10 @@ -package httpserver +package server import ( "net/http" - analysis "github.com/fuchencong/mooncode/internal/analysis/biz" - "github.com/fuchencong/mooncode/internal/platform/fault" + "github.com/fuchencong/mooncode/internal/biz/fault" + analysis "github.com/fuchencong/mooncode/internal/service/analysis" "github.com/gin-gonic/gin" "github.com/google/uuid" ) diff --git a/internal/platform/httpserver/analysis_profiles.go b/internal/server/analysis_profiles.go similarity index 99% rename from internal/platform/httpserver/analysis_profiles.go rename to internal/server/analysis_profiles.go index 26b026a..d84848a 100644 --- a/internal/platform/httpserver/analysis_profiles.go +++ b/internal/server/analysis_profiles.go @@ -1,4 +1,4 @@ -package httpserver +package server import ( "encoding/json" diff --git a/internal/server/auth/actor.go b/internal/server/auth/actor.go new file mode 100644 index 0000000..13cc341 --- /dev/null +++ b/internal/server/auth/actor.go @@ -0,0 +1,17 @@ +package auth + +import ( + "context" + + bizauth "github.com/fuchencong/mooncode/internal/biz/auth" +) + +type Actor = bizauth.Actor + +func WithActor(ctx context.Context, actor Actor) context.Context { + return bizauth.WithActor(ctx, actor) +} + +func ActorFrom(ctx context.Context) (Actor, bool) { + return bizauth.ActorFrom(ctx) +} diff --git a/internal/platform/auth/csrf.go b/internal/server/auth/csrf.go similarity index 100% rename from internal/platform/auth/csrf.go rename to internal/server/auth/csrf.go diff --git a/internal/platform/auth/headers.go b/internal/server/auth/headers.go similarity index 86% rename from internal/platform/auth/headers.go rename to internal/server/auth/headers.go index 04382ab..84db1a0 100644 --- a/internal/platform/auth/headers.go +++ b/internal/server/auth/headers.go @@ -4,14 +4,11 @@ import ( "errors" "net/http" "strings" + + bizauth "github.com/fuchencong/mooncode/internal/biz/auth" ) -type Identity struct { - Subject string - DisplayName string - Username string - Email string -} +type Identity = bizauth.Identity func IdentityFromHeaders(header http.Header) (Identity, error) { identity := Identity{ diff --git a/internal/platform/auth/headers_test.go b/internal/server/auth/headers_test.go similarity index 100% rename from internal/platform/auth/headers_test.go rename to internal/server/auth/headers_test.go diff --git a/internal/platform/auth/middleware.go b/internal/server/auth/middleware.go similarity index 100% rename from internal/platform/auth/middleware.go rename to internal/server/auth/middleware.go diff --git a/internal/platform/auth/proxy.go b/internal/server/auth/proxy.go similarity index 100% rename from internal/platform/auth/proxy.go rename to internal/server/auth/proxy.go diff --git a/internal/platform/auth/proxy_test.go b/internal/server/auth/proxy_test.go similarity index 100% rename from internal/platform/auth/proxy_test.go rename to internal/server/auth/proxy_test.go diff --git a/internal/platform/auth/request_id.go b/internal/server/auth/request_id.go similarity index 100% rename from internal/platform/auth/request_id.go rename to internal/server/auth/request_id.go diff --git a/internal/platform/httpserver/channels.go b/internal/server/channels.go similarity index 98% rename from internal/platform/httpserver/channels.go rename to internal/server/channels.go index 6d0a25b..c032e17 100644 --- a/internal/platform/httpserver/channels.go +++ b/internal/server/channels.go @@ -1,10 +1,10 @@ -package httpserver +package server import ( "encoding/json" "net/http" - "github.com/fuchencong/mooncode/internal/platform/fault" + "github.com/fuchencong/mooncode/internal/biz/fault" "github.com/gin-gonic/gin" "github.com/google/uuid" ) diff --git a/internal/platform/httpserver/channels_test.go b/internal/server/channels_test.go similarity index 95% rename from internal/platform/httpserver/channels_test.go rename to internal/server/channels_test.go index c8d9540..267a54b 100644 --- a/internal/platform/httpserver/channels_test.go +++ b/internal/server/channels_test.go @@ -1,4 +1,4 @@ -package httpserver +package server import ( "testing" diff --git a/internal/platform/httpserver/helpers.go b/internal/server/helpers.go similarity index 81% rename from internal/platform/httpserver/helpers.go rename to internal/server/helpers.go index f566a69..c4c983b 100644 --- a/internal/platform/httpserver/helpers.go +++ b/internal/server/helpers.go @@ -1,20 +1,22 @@ -package httpserver +package server import ( "context" "errors" "net/http" - identity "github.com/fuchencong/mooncode/internal/identity/biz" - "github.com/fuchencong/mooncode/internal/platform/auth" - "github.com/fuchencong/mooncode/internal/platform/fault" - "github.com/fuchencong/mooncode/internal/platform/pagination" + "github.com/fuchencong/mooncode/internal/biz/fault" + "github.com/fuchencong/mooncode/internal/biz/pagination" + "github.com/fuchencong/mooncode/internal/server/auth" + identity "github.com/fuchencong/mooncode/internal/service/identity" "github.com/gin-gonic/gin" "github.com/google/uuid" - "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgconn" ) +type sqlStateError interface { + SQLState() string +} + func actor(c *gin.Context) (auth.Actor, bool) { return auth.ActorFrom(c.Request.Context()) } func parameterID(c *gin.Context, name string) (uuid.UUID, bool) { value, err := uuid.Parse(c.Param(name)) @@ -69,15 +71,13 @@ func failure(c *gin.Context, err error) { status = http.StatusServiceUnavailable } } else { - var postgres *pgconn.PgError + var databaseError sqlStateError switch { case errors.Is(err, context.DeadlineExceeded): status, code, message = http.StatusGatewayTimeout, "request.timeout", "Request timed out" case errors.Is(err, identity.ErrForbidden): status, code, message = http.StatusForbidden, "workspace.forbidden", "Workspace permission denied" - case errors.Is(err, pgx.ErrNoRows): - status, code, message = http.StatusNotFound, "resource.not_found", "Resource not found" - case errors.As(err, &postgres) && postgres.Code == "23505": + case errors.As(err, &databaseError) && databaseError.SQLState() == "23505": status, code, message = http.StatusConflict, "resource.conflict", "Resource already exists" } } diff --git a/internal/platform/httpserver/identity.go b/internal/server/identity.go similarity index 97% rename from internal/platform/httpserver/identity.go rename to internal/server/identity.go index 8b0a45c..c3912ea 100644 --- a/internal/platform/httpserver/identity.go +++ b/internal/server/identity.go @@ -1,10 +1,10 @@ -package httpserver +package server import ( "net/http" - biz "github.com/fuchencong/mooncode/internal/identity/biz" - "github.com/fuchencong/mooncode/internal/platform/fault" + "github.com/fuchencong/mooncode/internal/biz/fault" + biz "github.com/fuchencong/mooncode/internal/service/identity" "github.com/gin-gonic/gin" "github.com/google/uuid" ) diff --git a/internal/platform/httpserver/identity_rbac_test.go b/internal/server/identity_rbac_test.go similarity index 98% rename from internal/platform/httpserver/identity_rbac_test.go rename to internal/server/identity_rbac_test.go index 1eae16c..c6f35ba 100644 --- a/internal/platform/httpserver/identity_rbac_test.go +++ b/internal/server/identity_rbac_test.go @@ -1,4 +1,4 @@ -package httpserver +package server import ( "bytes" @@ -10,9 +10,9 @@ import ( "testing" "time" - identity "github.com/fuchencong/mooncode/internal/identity/biz" - "github.com/fuchencong/mooncode/internal/platform/auth" - "github.com/fuchencong/mooncode/internal/platform/pagination" + "github.com/fuchencong/mooncode/internal/biz/pagination" + "github.com/fuchencong/mooncode/internal/server/auth" + identity "github.com/fuchencong/mooncode/internal/service/identity" "github.com/google/uuid" ) diff --git a/internal/platform/httpserver/metrics.go b/internal/server/metrics.go similarity index 99% rename from internal/platform/httpserver/metrics.go rename to internal/server/metrics.go index ed4028b..87d5dcb 100644 --- a/internal/platform/httpserver/metrics.go +++ b/internal/server/metrics.go @@ -1,4 +1,4 @@ -package httpserver +package server import ( "fmt" diff --git a/internal/platform/httpserver/middleware.go b/internal/server/middleware.go similarity index 94% rename from internal/platform/httpserver/middleware.go rename to internal/server/middleware.go index 39b8223..c244b52 100644 --- a/internal/platform/httpserver/middleware.go +++ b/internal/server/middleware.go @@ -1,4 +1,4 @@ -package httpserver +package server import ( "context" @@ -7,7 +7,7 @@ import ( "github.com/gin-gonic/gin" - "github.com/fuchencong/mooncode/internal/platform/auth" + "github.com/fuchencong/mooncode/internal/server/auth" ) func requireActiveAccount() gin.HandlerFunc { diff --git a/internal/platform/httpserver/observability_test.go b/internal/server/observability_test.go similarity index 97% rename from internal/platform/httpserver/observability_test.go rename to internal/server/observability_test.go index 9547c34..47c2e8f 100644 --- a/internal/platform/httpserver/observability_test.go +++ b/internal/server/observability_test.go @@ -1,4 +1,4 @@ -package httpserver +package server import ( "bytes" @@ -9,7 +9,7 @@ import ( "testing" "time" - "github.com/fuchencong/mooncode/internal/platform/auth" + "github.com/fuchencong/mooncode/internal/server/auth" "github.com/gin-gonic/gin" ) diff --git a/internal/platform/httpserver/openapi_routes_test.go b/internal/server/openapi_routes_test.go similarity index 93% rename from internal/platform/httpserver/openapi_routes_test.go rename to internal/server/openapi_routes_test.go index 29a9992..4c8419e 100644 --- a/internal/platform/httpserver/openapi_routes_test.go +++ b/internal/server/openapi_routes_test.go @@ -1,4 +1,4 @@ -package httpserver +package server import ( "fmt" @@ -7,8 +7,8 @@ import ( "testing" openapidoc "github.com/fuchencong/mooncode/api/openapi" - channelcommand "github.com/fuchencong/mooncode/internal/channel/command" - "github.com/fuchencong/mooncode/internal/platform/auth" + "github.com/fuchencong/mooncode/internal/server/auth" + channelcommand "github.com/fuchencong/mooncode/internal/service/command" "github.com/getkin/kin-openapi/openapi3" "github.com/gin-gonic/gin" ) diff --git a/internal/server/options.go b/internal/server/options.go new file mode 100644 index 0000000..1d171ef --- /dev/null +++ b/internal/server/options.go @@ -0,0 +1,81 @@ +package server + +import ( + "context" + "log/slog" + "time" + + "github.com/fuchencong/mooncode/internal/server/auth" +) + +type Readiness interface { + Check(context.Context) error +} + +type settings struct { + config Config + csrf *auth.CSRF + csrfKey []byte + readiness Readiness + logger *slog.Logger + metrics RequestMetrics +} + +// Settings is the validated immutable HTTP adapter configuration. +type Settings = settings + +// Option configures the HTTP adapter. +type Option func(*settings) + +func WithAuthentication(logoutURL, registrationMode, termsVersion, privacyVersion string, trustedProxyCIDRs []string, csrfKey []byte) Option { + return func(settings *settings) { + settings.config.LogoutURL = logoutURL + settings.config.RegistrationMode = registrationMode + settings.config.TermsVersion = termsVersion + settings.config.PrivacyVersion = privacyVersion + settings.config.TrustedProxyCIDRs = append([]string(nil), trustedProxyCIDRs...) + settings.csrfKey = append([]byte(nil), csrfKey...) + } +} + +func WithHTTP(maxBodyBytes int64, requestTimeout time.Duration) Option { + return func(settings *settings) { + settings.config.MaxBodyBytes = maxBodyBytes + settings.config.RequestTimeout = requestTimeout + } +} + +func WithReadiness(readiness Readiness) Option { + return func(settings *settings) { + settings.readiness = readiness + } +} + +func WithLogger(logger *slog.Logger) Option { + return func(settings *settings) { + if logger != nil { + settings.logger = logger + } + } +} + +func WithMetrics(metrics RequestMetrics) Option { + return func(settings *settings) { + if metrics != nil { + settings.metrics = metrics + } + } +} + +func NewSettings(options ...Option) *Settings { + settings := &Settings{} + for _, option := range options { + option(settings) + } + + return settings +} + +func NewCSRF(settings *Settings) *auth.CSRF { + return auth.NewCSRF(settings.csrfKey) +} diff --git a/internal/server/options_test.go b/internal/server/options_test.go new file mode 100644 index 0000000..156c434 --- /dev/null +++ b/internal/server/options_test.go @@ -0,0 +1,24 @@ +package server + +import ( + "testing" + "time" +) + +func TestSettingsCopyAuthenticationInputs(t *testing.T) { + proxies := []string{"127.0.0.1/32"} + key := []byte("csrf-key") + settings := NewSettings( + WithAuthentication("/logout", "open", "terms-v1", "privacy-v1", proxies, key), + WithHTTP(2048, 12*time.Second), + ) + proxies[0] = "0.0.0.0/0" + key[0] = 'X' + + if settings.config.TrustedProxyCIDRs[0] != "127.0.0.1/32" || string(settings.csrfKey) != "csrf-key" { + t.Fatal("server settings retained caller-owned authentication storage") + } + if settings.config.MaxBodyBytes != 2048 || settings.config.RequestTimeout != 12*time.Second { + t.Fatalf("HTTP settings = %+v", settings.config) + } +} diff --git a/internal/server/provider_set.go b/internal/server/provider_set.go new file mode 100644 index 0000000..3580a35 --- /dev/null +++ b/internal/server/provider_set.go @@ -0,0 +1,9 @@ +package server + +import "github.com/google/wire" + +// MetricsProviderSet contains the process-wide metrics registry. +var MetricsProviderSet = wire.NewSet(NewMetrics) + +// ProviderSet contains the HTTP protocol adapter and its observability resources. +var ProviderSet = wire.NewSet(NewSettings, NewCSRF, MetricsProviderSet, NewConfigured) diff --git a/internal/platform/httpserver/providers.go b/internal/server/providers.go similarity index 99% rename from internal/platform/httpserver/providers.go rename to internal/server/providers.go index c268b4b..20dc489 100644 --- a/internal/platform/httpserver/providers.go +++ b/internal/server/providers.go @@ -1,4 +1,4 @@ -package httpserver +package server import ( "net/http" diff --git a/internal/platform/httpserver/repositories.go b/internal/server/repositories.go similarity index 98% rename from internal/platform/httpserver/repositories.go rename to internal/server/repositories.go index d399718..52458cb 100644 --- a/internal/platform/httpserver/repositories.go +++ b/internal/server/repositories.go @@ -1,9 +1,9 @@ -package httpserver +package server import ( "net/http" - repository "github.com/fuchencong/mooncode/internal/repository/biz" + repository "github.com/fuchencong/mooncode/internal/service/repository" "github.com/gin-gonic/gin" "github.com/google/uuid" ) diff --git a/internal/platform/httpserver/retention_metrics.go b/internal/server/retention_metrics.go similarity index 98% rename from internal/platform/httpserver/retention_metrics.go rename to internal/server/retention_metrics.go index a140c31..7812a6f 100644 --- a/internal/platform/httpserver/retention_metrics.go +++ b/internal/server/retention_metrics.go @@ -1,4 +1,4 @@ -package httpserver +package server import ( "fmt" diff --git a/internal/platform/httpserver/server.go b/internal/server/server.go similarity index 60% rename from internal/platform/httpserver/server.go rename to internal/server/server.go index fd1812d..8139119 100644 --- a/internal/platform/httpserver/server.go +++ b/internal/server/server.go @@ -1,4 +1,4 @@ -package httpserver +package server import ( "log/slog" @@ -6,12 +6,12 @@ import ( "time" openapidoc "github.com/fuchencong/mooncode/api/openapi" - analysis "github.com/fuchencong/mooncode/internal/analysis/biz" - channel "github.com/fuchencong/mooncode/internal/channel/biz" - channelcommand "github.com/fuchencong/mooncode/internal/channel/command" - identity "github.com/fuchencong/mooncode/internal/identity/biz" - "github.com/fuchencong/mooncode/internal/platform/auth" - repository "github.com/fuchencong/mooncode/internal/repository/biz" + "github.com/fuchencong/mooncode/internal/server/auth" + analysis "github.com/fuchencong/mooncode/internal/service/analysis" + channel "github.com/fuchencong/mooncode/internal/service/channel" + channelcommand "github.com/fuchencong/mooncode/internal/service/command" + identity "github.com/fuchencong/mooncode/internal/service/identity" + repository "github.com/fuchencong/mooncode/internal/service/repository" "github.com/gin-gonic/gin" ) @@ -41,6 +41,37 @@ type Server struct { } func New(config Config, identityService *identity.Service, providers *identity.ProviderService, repositories *repository.Service, analyses *analysis.Service, channels *channel.Service, commands *channelcommand.Service, csrf *auth.CSRF, options ...Option) (*Server, error) { + settings := NewSettings(options...) + settings.config = config + settings.csrf = csrf + + return newServer(identityService, providers, repositories, analyses, channels, commands, settings) +} + +// NewConfigured constructs the HTTP adapter from injected process resources. +func NewConfigured( + identityService *identity.Service, + providers *identity.ProviderService, + repositories *repository.Service, + analyses *analysis.Service, + channels *channel.Service, + commands *channelcommand.Service, + csrf *auth.CSRF, + readiness Readiness, + metrics *Metrics, + logger *slog.Logger, + settings *Settings, +) (*Server, error) { + settings.csrf = csrf + settings.readiness = readiness + settings.metrics = metrics + settings.logger = logger + + return newServer(identityService, providers, repositories, analyses, channels, commands, settings) +} + +func newServer(identityService *identity.Service, providers *identity.ProviderService, repositories *repository.Service, analyses *analysis.Service, channels *channel.Service, commands *channelcommand.Service, settings *Settings) (*Server, error) { + config := settings.config if config.MaxBodyBytes <= 0 { config.MaxBodyBytes = 1 << 20 } @@ -51,9 +82,12 @@ func New(config Config, identityService *identity.Service, providers *identity.P if err != nil { return nil, err } - server := &Server{config: config, identity: identityService, providers: providers, repositories: repositories, analyses: analyses, channels: channels, commands: commands, csrf: csrf, logger: slog.Default(), metrics: NewMetrics()} - for _, option := range options { - option(server) + server := &Server{config: config, identity: identityService, providers: providers, repositories: repositories, analyses: analyses, channels: channels, commands: commands, csrf: settings.csrf, readiness: settings.readiness, logger: settings.logger, metrics: settings.metrics} + if server.logger == nil { + server.logger = slog.Default() + } + if server.metrics == nil { + server.metrics = NewMetrics() } router := gin.New() if err := router.SetTrustedProxies(config.TrustedProxyCIDRs); err != nil { @@ -65,7 +99,7 @@ func New(config Config, identityService *identity.Service, providers *identity.P router.GET("/metrics", gin.WrapH(server.metrics.Handler())) router.GET("/api/openapi.yaml", server.openAPI) api := router.Group("/api/v1") - api.Use(auth.Require(identityService, trusted), csrf.Middleware()) + api.Use(auth.Require(identityService, trusted), server.csrf.Middleware()) active := api.Group("") active.Use(requireActiveAccount()) server.registerIdentity(api, active) diff --git a/internal/platform/httpserver/server_test.go b/internal/server/server_test.go similarity index 98% rename from internal/platform/httpserver/server_test.go rename to internal/server/server_test.go index c521dc1..2977f69 100644 --- a/internal/platform/httpserver/server_test.go +++ b/internal/server/server_test.go @@ -1,4 +1,4 @@ -package httpserver +package server import ( "context" @@ -9,7 +9,7 @@ import ( "testing" "time" - "github.com/fuchencong/mooncode/internal/platform/auth" + "github.com/fuchencong/mooncode/internal/server/auth" "github.com/gin-gonic/gin" ) diff --git a/internal/platform/httpserver/task_metrics.go b/internal/server/task_metrics.go similarity index 99% rename from internal/platform/httpserver/task_metrics.go rename to internal/server/task_metrics.go index 8d2c8ca..4259af2 100644 --- a/internal/platform/httpserver/task_metrics.go +++ b/internal/server/task_metrics.go @@ -1,4 +1,4 @@ -package httpserver +package server import ( "fmt" diff --git a/internal/service/analysis/analysis.go b/internal/service/analysis/analysis.go new file mode 100644 index 0000000..d0932b8 --- /dev/null +++ b/internal/service/analysis/analysis.go @@ -0,0 +1,23 @@ +// Package analysis adapts analysis business use cases for transports. +package analysis + +import bizanalysis "github.com/fuchencong/mooncode/internal/biz/analysis" + +type ( + CreateInput = bizanalysis.CreateInput + Run = bizanalysis.Run + Report = bizanalysis.Report + Profile = bizanalysis.Profile +) + +type Service struct { + *bizanalysis.Service +} + +func New(service *bizanalysis.Service) *Service { + if service == nil { + return nil + } + + return &Service{Service: service} +} diff --git a/internal/service/channel/channel.go b/internal/service/channel/channel.go new file mode 100644 index 0000000..3c17b89 --- /dev/null +++ b/internal/service/channel/channel.go @@ -0,0 +1,16 @@ +// Package channel adapts channel business use cases for transports. +package channel + +import bizchannel "github.com/fuchencong/mooncode/internal/biz/channel" + +type Service struct { + *bizchannel.Service +} + +func New(service *bizchannel.Service) *Service { + if service == nil { + return nil + } + + return &Service{Service: service} +} diff --git a/internal/service/command/command.go b/internal/service/command/command.go new file mode 100644 index 0000000..645f929 --- /dev/null +++ b/internal/service/command/command.go @@ -0,0 +1,16 @@ +// Package command adapts inbound channel command use cases for HTTP transport. +package command + +import bizcommand "github.com/fuchencong/mooncode/internal/biz/channel/command" + +type Service struct { + *bizcommand.Service +} + +func New(service *bizcommand.Service) *Service { + if service == nil { + return nil + } + + return &Service{Service: service} +} diff --git a/internal/service/identity/identity.go b/internal/service/identity/identity.go new file mode 100644 index 0000000..6200364 --- /dev/null +++ b/internal/service/identity/identity.go @@ -0,0 +1,48 @@ +// Package identity adapts identity business use cases for transport servers. +package identity + +import bizidentity "github.com/fuchencong/mooncode/internal/biz/identity" + +type ( + Store = bizidentity.Store + Option = bizidentity.Option + User = bizidentity.User + Workspace = bizidentity.Workspace + WorkspaceUpdate = bizidentity.WorkspaceUpdate + Member = bizidentity.Member + Invitation = bizidentity.Invitation + Membership = bizidentity.Membership + ProviderConnection = bizidentity.ProviderConnection +) + +var ErrForbidden = bizidentity.ErrForbidden + +// Service exposes identity use cases to transport packages without leaking +// construction details into the server. +type Service struct { + *bizidentity.Service +} + +func NewService(store Store, issuer string, options ...Option) *Service { + return WrapService(bizidentity.NewService(store, issuer, options...)) +} + +func WrapService(service *bizidentity.Service) *Service { + if service == nil { + return nil + } + + return &Service{Service: service} +} + +type ProviderService struct { + *bizidentity.ProviderService +} + +func WrapProviderService(service *bizidentity.ProviderService) *ProviderService { + if service == nil { + return nil + } + + return &ProviderService{ProviderService: service} +} diff --git a/internal/service/provider.go b/internal/service/provider.go new file mode 100644 index 0000000..fc49b4b --- /dev/null +++ b/internal/service/provider.go @@ -0,0 +1,20 @@ +package service + +import ( + analysisservice "github.com/fuchencong/mooncode/internal/service/analysis" + channelservice "github.com/fuchencong/mooncode/internal/service/channel" + commandservice "github.com/fuchencong/mooncode/internal/service/command" + identityservice "github.com/fuchencong/mooncode/internal/service/identity" + repositoryservice "github.com/fuchencong/mooncode/internal/service/repository" + "github.com/google/wire" +) + +// ProviderSet contains transport-facing application services. +var ProviderSet = wire.NewSet( + identityservice.WrapService, + identityservice.WrapProviderService, + repositoryservice.New, + analysisservice.New, + channelservice.New, + commandservice.New, +) diff --git a/internal/service/repository/repository.go b/internal/service/repository/repository.go new file mode 100644 index 0000000..f3f5661 --- /dev/null +++ b/internal/service/repository/repository.go @@ -0,0 +1,24 @@ +// Package repository adapts repository business use cases for transports. +package repository + +import bizrepository "github.com/fuchencong/mooncode/internal/biz/repository" + +type ( + CreateInput = bizrepository.CreateInput + UpdateInput = bizrepository.UpdateInput + Repository = bizrepository.Repository + Operation = bizrepository.Operation + Snapshot = bizrepository.Snapshot +) + +type Service struct { + *bizrepository.Service +} + +func New(service *bizrepository.Service) *Service { + if service == nil { + return nil + } + + return &Service{Service: service} +} diff --git a/internal/testutil/testdb/testdb.go b/internal/testutil/testdb/testdb.go index 5dc9abe..2da8b2d 100644 --- a/internal/testutil/testdb/testdb.go +++ b/internal/testutil/testdb/testdb.go @@ -7,7 +7,7 @@ import ( "context" "testing" - "github.com/fuchencong/mooncode/internal/platform/database" + "github.com/fuchencong/mooncode/internal/data/postgres" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" @@ -19,7 +19,7 @@ func Open(t testing.TB, databaseURL string) *pgxpool.Pool { t.Helper() ctx := context.Background() - admin, err := database.Open(ctx, databaseURL, 2) + admin, err := postgres.Open(ctx, databaseURL, 2) if err != nil { t.Fatal(err) } @@ -49,7 +49,7 @@ func Open(t testing.TB, databaseURL string) *pgxpool.Pool { admin.Close() t.Fatal(err) } - if err := database.Migrate(ctx, pool); err != nil { + if err := postgres.Migrate(ctx, pool); err != nil { pool.Close() admin.Close() t.Fatal(err) diff --git a/internal/channel/runtime/manager.go b/internal/worker/channelruntime/manager.go similarity index 88% rename from internal/channel/runtime/manager.go rename to internal/worker/channelruntime/manager.go index 11f0e5a..13740f4 100644 --- a/internal/channel/runtime/manager.go +++ b/internal/worker/channelruntime/manager.go @@ -8,19 +8,13 @@ import ( "strings" "time" + channelbiz "github.com/fuchencong/mooncode/internal/biz/channel" "github.com/fuchencong/mooncode/pkg/channel" "github.com/google/uuid" ) const defaultPollInterval = 5 * time.Second -type Store interface { - ListRuntimeChannels(context.Context) ([]Configuration, error) - SaveInbound(context.Context, Configuration, channel.InboundMessage) (bool, error) - MarkRuntimeConnected(context.Context, uuid.UUID, int64) error - MarkRuntimeError(context.Context, uuid.UUID, int64, string) error -} - type CommandHandler interface { Handle(context.Context, uuid.UUID, uuid.UUID, int64, channel.InboundMessage) (string, error) } @@ -30,7 +24,7 @@ type Decryptor interface { } type Manager struct { - store Store + store channelbiz.RuntimeStore decryptor Decryptor factories map[string]channel.Factory pollInterval time.Duration @@ -45,12 +39,12 @@ type activeConnection struct { } type startResult struct { - configuration Configuration + configuration channelbiz.RuntimeConfiguration connection channel.Connection err error } -func NewManager(store Store, decryptor Decryptor, factories []channel.Factory, options ...Option) *Manager { +func NewManager(store channelbiz.RuntimeStore, decryptor Decryptor, factories []channel.Factory, options ...Option) *Manager { registered := make(map[string]channel.Factory, len(factories)) for _, factory := range factories { registered[factory.Type()] = factory @@ -93,7 +87,7 @@ func (m *Manager) reconcile(ctx context.Context) error { if err != nil { return fmt.Errorf("list runtime channels: %w", err) } - desired := make(map[uuid.UUID]Configuration, len(configurations)) + desired := make(map[uuid.UUID]channelbiz.RuntimeConfiguration, len(configurations)) for _, item := range configurations { desired[item.ID] = item } @@ -109,7 +103,7 @@ func (m *Manager) reconcile(ctx context.Context) error { delete(m.active, id) } - candidates := make([]Configuration, 0, len(configurations)) + candidates := make([]channelbiz.RuntimeConfiguration, 0, len(configurations)) for _, item := range configurations { if _, running := m.active[item.ID]; !running { candidates = append(candidates, item) @@ -117,7 +111,7 @@ func (m *Manager) reconcile(ctx context.Context) error { } results := make(chan startResult, len(candidates)) for _, item := range candidates { - go func(configuration Configuration) { + go func(configuration channelbiz.RuntimeConfiguration) { connection, startError := m.start(ctx, configuration) results <- startResult{configuration: configuration, connection: connection, err: startError} }(item) @@ -138,7 +132,7 @@ func (m *Manager) reconcile(ctx context.Context) error { return nil } -func (m *Manager) start(ctx context.Context, item Configuration) (channel.Connection, error) { +func (m *Manager) start(ctx context.Context, item channelbiz.RuntimeConfiguration) (channel.Connection, error) { factory, ok := m.factories[item.Type] if !ok { return nil, fmt.Errorf("channel type %q is not registered", item.Type) @@ -213,7 +207,7 @@ func rawSenderID(canonical string) string { return canonical } -func (m *Manager) recordError(ctx context.Context, item Configuration, cause error) { +func (m *Manager) recordError(ctx context.Context, item channelbiz.RuntimeConfiguration, cause error) { message := strings.TrimSpace(cause.Error()) if len(message) > 1000 { message = message[:1000] diff --git a/internal/channel/runtime/manager_test.go b/internal/worker/channelruntime/manager_test.go similarity index 90% rename from internal/channel/runtime/manager_test.go rename to internal/worker/channelruntime/manager_test.go index 9a22d82..90fcb1a 100644 --- a/internal/channel/runtime/manager_test.go +++ b/internal/worker/channelruntime/manager_test.go @@ -8,27 +8,28 @@ import ( "testing" "time" + channelbiz "github.com/fuchencong/mooncode/internal/biz/channel" "github.com/fuchencong/mooncode/pkg/channel" "github.com/google/uuid" ) type runtimeStore struct { mu sync.Mutex - configurations []Configuration + configurations []channelbiz.RuntimeConfiguration connected []int64 errors []string messages []channel.InboundMessage seen map[string]struct{} } -func (s *runtimeStore) ListRuntimeChannels(context.Context) ([]Configuration, error) { +func (s *runtimeStore) ListRuntimeChannels(context.Context) ([]channelbiz.RuntimeConfiguration, error) { s.mu.Lock() defer s.mu.Unlock() - return append([]Configuration(nil), s.configurations...), nil + return append([]channelbiz.RuntimeConfiguration(nil), s.configurations...), nil } -func (s *runtimeStore) SaveInbound(_ context.Context, _ Configuration, message channel.InboundMessage) (bool, error) { +func (s *runtimeStore) SaveInbound(_ context.Context, _ channelbiz.RuntimeConfiguration, message channel.InboundMessage) (bool, error) { s.mu.Lock() defer s.mu.Unlock() if s.seen == nil { @@ -116,7 +117,7 @@ func (c *runtimeConnection) Close() error { func TestManagerReconcilesLifecycleAndInboundIdempotency(t *testing.T) { channelID := uuid.New() - store := &runtimeStore{configurations: []Configuration{{ + store := &runtimeStore{configurations: []channelbiz.RuntimeConfiguration{{ ID: channelID, WorkspaceID: uuid.New(), Type: "feishu", Version: 1, Config: json.RawMessage(`{"values":{"app_id":"app","receive_id":"chat"},"senderAllowList":["allowed"],"groupPolicy":{"requireMention":true,"prefix":"/moon"}}`), }}} @@ -153,7 +154,7 @@ func TestManagerReconcilesLifecycleAndInboundIdempotency(t *testing.T) { func TestManagerIsolatesConnectionFailure(t *testing.T) { failingFactory := &runtimeFactory{startError: errors.New("handshake failed")} - store := &runtimeStore{configurations: []Configuration{{ + store := &runtimeStore{configurations: []channelbiz.RuntimeConfiguration{{ ID: uuid.New(), WorkspaceID: uuid.New(), Type: "feishu", Version: 1, Config: json.RawMessage(`{"values":{"app_id":"app","receive_id":"chat"},"senderAllowList":["*"]}`), }}} @@ -181,7 +182,7 @@ func (c *runtimeCommands) Handle(_ context.Context, workspaceID, channelID uuid. func TestManagerHandlesInsertedMessageAndRepliesToConversation(t *testing.T) { channelID, workspaceID := uuid.New(), uuid.New() - store := &runtimeStore{configurations: []Configuration{{ + store := &runtimeStore{configurations: []channelbiz.RuntimeConfiguration{{ ID: channelID, WorkspaceID: workspaceID, Type: "feishu", Version: 1, Config: json.RawMessage(`{"values":{"app_id":"app","receive_id":"chat"},"senderAllowList":["*"]}`), }}} diff --git a/internal/channel/runtime/model.go b/internal/worker/channelruntime/model.go similarity index 55% rename from internal/channel/runtime/model.go rename to internal/worker/channelruntime/model.go index c855dea..716bfa7 100644 --- a/internal/channel/runtime/model.go +++ b/internal/worker/channelruntime/model.go @@ -1,22 +1,5 @@ package runtime -import ( - "encoding/json" - - "github.com/google/uuid" -) - -type Configuration struct { - ID uuid.UUID - WorkspaceID uuid.UUID - Type string - Version int64 - Ciphertext []byte - Nonce []byte - KeyVersion int - Config json.RawMessage -} - type configuration struct { Values map[string]any `json:"values"` SenderAllowList []string `json:"senderAllowList"` diff --git a/internal/channel/runtime/options.go b/internal/worker/channelruntime/options.go similarity index 100% rename from internal/channel/runtime/options.go rename to internal/worker/channelruntime/options.go diff --git a/internal/channel/runtime/policy.go b/internal/worker/channelruntime/policy.go similarity index 100% rename from internal/channel/runtime/policy.go rename to internal/worker/channelruntime/policy.go diff --git a/internal/channel/runtime/policy_test.go b/internal/worker/channelruntime/policy_test.go similarity index 100% rename from internal/channel/runtime/policy_test.go rename to internal/worker/channelruntime/policy_test.go diff --git a/internal/workflow/dispatcher.go b/internal/worker/dispatcher/dispatcher.go similarity index 90% rename from internal/workflow/dispatcher.go rename to internal/worker/dispatcher/dispatcher.go index 62c6f5b..6994870 100644 --- a/internal/workflow/dispatcher.go +++ b/internal/worker/dispatcher/dispatcher.go @@ -1,4 +1,4 @@ -package workflow +package dispatcher import ( "context" @@ -8,17 +8,10 @@ import ( "log/slog" "time" - biz "github.com/fuchencong/mooncode/internal/workflow/biz" + biz "github.com/fuchencong/mooncode/internal/biz/workflow" "github.com/google/uuid" ) -type Store interface { - Claim(context.Context, int32, time.Duration) ([]biz.Dispatch, error) - IsPending(context.Context, uuid.UUID) (bool, error) - Complete(context.Context, biz.Dispatch, string) error - Delay(context.Context, uuid.UUID, time.Time, string) error -} - type Launcher interface { DispatchRepository(context.Context, uuid.UUID, uuid.UUID) (string, error) DispatchAnalysis(context.Context, uuid.UUID, uuid.UUID) (string, error) @@ -28,7 +21,7 @@ type Launcher interface { } type Dispatcher struct { - store Store + store biz.DispatchStore launcher Launcher pollInterval time.Duration lease time.Duration @@ -37,7 +30,7 @@ type Dispatcher struct { logger *slog.Logger } -func NewDispatcher(store Store, launcher Launcher, options ...Option) *Dispatcher { +func NewDispatcher(store biz.DispatchStore, launcher Launcher, options ...Option) *Dispatcher { dispatcher := &Dispatcher{ store: store, launcher: launcher, pollInterval: time.Second, lease: time.Minute, batchSize: 20, diff --git a/internal/workflow/dispatcher_test.go b/internal/worker/dispatcher/dispatcher_test.go similarity index 98% rename from internal/workflow/dispatcher_test.go rename to internal/worker/dispatcher/dispatcher_test.go index d7748b9..c561b8d 100644 --- a/internal/workflow/dispatcher_test.go +++ b/internal/worker/dispatcher/dispatcher_test.go @@ -1,4 +1,4 @@ -package workflow +package dispatcher import ( "context" @@ -6,7 +6,7 @@ import ( "testing" "time" - biz "github.com/fuchencong/mooncode/internal/workflow/biz" + biz "github.com/fuchencong/mooncode/internal/biz/workflow" "github.com/google/uuid" ) diff --git a/internal/workflow/options.go b/internal/worker/dispatcher/options.go similarity index 97% rename from internal/workflow/options.go rename to internal/worker/dispatcher/options.go index a94e3ac..9e76d35 100644 --- a/internal/workflow/options.go +++ b/internal/worker/dispatcher/options.go @@ -1,4 +1,4 @@ -package workflow +package dispatcher import ( "log/slog" diff --git a/internal/platform/hatchet/client.go b/internal/worker/hatchet/client.go similarity index 97% rename from internal/platform/hatchet/client.go rename to internal/worker/hatchet/client.go index 94e244f..1ad77b0 100644 --- a/internal/platform/hatchet/client.go +++ b/internal/worker/hatchet/client.go @@ -9,10 +9,10 @@ import ( "strings" "time" - analysisworkflow "github.com/fuchencong/mooncode/internal/analysis/workflow" - channelworkflow "github.com/fuchencong/mooncode/internal/channel/workflow" - repositoryworkflow "github.com/fuchencong/mooncode/internal/repository/workflow" - retentionworkflow "github.com/fuchencong/mooncode/internal/retention/workflow" + analysisworkflow "github.com/fuchencong/mooncode/internal/biz/analysis/workflow" + channelworkflow "github.com/fuchencong/mooncode/internal/biz/channel/workflow" + repositoryworkflow "github.com/fuchencong/mooncode/internal/biz/repository/workflow" + retentionworkflow "github.com/fuchencong/mooncode/internal/biz/retention/workflow" "github.com/google/uuid" legacyclient "github.com/hatchet-dev/hatchet/pkg/client" //nolint:staticcheck // Hatchet v0.98.9's new SDK constructor still exposes these configuration options. "github.com/hatchet-dev/hatchet/pkg/client/rest" diff --git a/internal/platform/hatchet/failure.go b/internal/worker/hatchet/failure.go similarity index 84% rename from internal/platform/hatchet/failure.go rename to internal/worker/hatchet/failure.go index 8d6d842..f28f007 100644 --- a/internal/platform/hatchet/failure.go +++ b/internal/worker/hatchet/failure.go @@ -6,10 +6,9 @@ import ( "fmt" "time" - "github.com/fuchencong/mooncode/internal/platform/fault" - workflowbiz "github.com/fuchencong/mooncode/internal/workflow/biz" + "github.com/fuchencong/mooncode/internal/biz/fault" + workflowbiz "github.com/fuchencong/mooncode/internal/biz/workflow" "github.com/hatchet-dev/hatchet/pkg/worker" //nolint:staticcheck // Hatchet v0.98.9's new SDK documents this type for non-retryable task errors. - "github.com/jackc/pgx/v5" ) const failureProjectionTimeout = 15 * time.Second @@ -38,7 +37,7 @@ func isPermanentTaskError(err error) bool { if err == nil || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { return false } - if workflowbiz.IsPermanent(err) || errors.Is(err, pgx.ErrNoRows) { + if workflowbiz.IsPermanent(err) { return true } problem, ok := fault.From(err) diff --git a/internal/platform/hatchet/failure_test.go b/internal/worker/hatchet/failure_test.go similarity index 94% rename from internal/platform/hatchet/failure_test.go rename to internal/worker/hatchet/failure_test.go index 8811c0e..661bec7 100644 --- a/internal/platform/hatchet/failure_test.go +++ b/internal/worker/hatchet/failure_test.go @@ -5,8 +5,8 @@ import ( "errors" "testing" - "github.com/fuchencong/mooncode/internal/platform/fault" - workflowbiz "github.com/fuchencong/mooncode/internal/workflow/biz" + "github.com/fuchencong/mooncode/internal/biz/fault" + workflowbiz "github.com/fuchencong/mooncode/internal/biz/workflow" "github.com/hatchet-dev/hatchet/pkg/worker" //nolint:staticcheck // Verify the marker required by Hatchet's new SDK retry protocol. ) diff --git a/internal/platform/hatchet/options.go b/internal/worker/hatchet/options.go similarity index 100% rename from internal/platform/hatchet/options.go rename to internal/worker/hatchet/options.go diff --git a/internal/platform/hatchet/options_test.go b/internal/worker/hatchet/options_test.go similarity index 100% rename from internal/platform/hatchet/options_test.go rename to internal/worker/hatchet/options_test.go diff --git a/internal/worker/option.go b/internal/worker/option.go new file mode 100644 index 0000000..e0af6f6 --- /dev/null +++ b/internal/worker/option.go @@ -0,0 +1,41 @@ +package worker + +import ( + "errors" + "strings" +) + +type settings struct { + hatchetToken string + hatchetAddress string + hatchetNamespace string +} + +// Option configures background infrastructure adapters. +type Option func(*settings) + +func WithHatchet(token, address, namespace string) Option { + return func(settings *settings) { + settings.hatchetToken = token + settings.hatchetAddress = address + settings.hatchetNamespace = namespace + } +} + +func NewSettings(options ...Option) (*Settings, error) { + settings := &Settings{} + for _, option := range options { + option(settings) + } + if strings.TrimSpace(settings.hatchetToken) == "" { + return nil, errors.New("worker Hatchet token is required") + } + if strings.TrimSpace(settings.hatchetAddress) == "" { + return nil, errors.New("worker Hatchet address is required") + } + + return settings, nil +} + +// Settings is the validated immutable worker adapter configuration. +type Settings = settings diff --git a/internal/worker/option_test.go b/internal/worker/option_test.go new file mode 100644 index 0000000..1c55a0a --- /dev/null +++ b/internal/worker/option_test.go @@ -0,0 +1,30 @@ +package worker + +import "testing" + +func TestSettingsRequireHatchetConnection(t *testing.T) { + for _, test := range []struct { + name string + options []Option + }{ + {name: "missing all"}, + {name: "missing token", options: []Option{WithHatchet("", "localhost:7070", "")}}, + {name: "missing address", options: []Option{WithHatchet("token", "", "")}}, + } { + t.Run(test.name, func(t *testing.T) { + if _, err := NewSettings(test.options...); err == nil { + t.Fatal("NewSettings() error = nil") + } + }) + } +} + +func TestSettingsPreserveHatchetNamespace(t *testing.T) { + settings, err := NewSettings(WithHatchet("token", "localhost:7070", "mooncode")) + if err != nil { + t.Fatal(err) + } + if settings.hatchetNamespace != "mooncode" { + t.Fatalf("namespace = %q", settings.hatchetNamespace) + } +} diff --git a/internal/worker/provider.go b/internal/worker/provider.go new file mode 100644 index 0000000..be300f0 --- /dev/null +++ b/internal/worker/provider.go @@ -0,0 +1,105 @@ +// Package worker assembles background protocol adapters and schedulers. +package worker + +import ( + "context" + "fmt" + "log/slog" + + "github.com/fuchencong/mooncode/internal/biz" + analysisworkflow "github.com/fuchencong/mooncode/internal/biz/analysis/workflow" + channel "github.com/fuchencong/mooncode/internal/biz/channel" + channelcommand "github.com/fuchencong/mooncode/internal/biz/channel/command" + channelworkflow "github.com/fuchencong/mooncode/internal/biz/channel/workflow" + repositoryworkflow "github.com/fuchencong/mooncode/internal/biz/repository/workflow" + retentionworkflow "github.com/fuchencong/mooncode/internal/biz/retention/workflow" + "github.com/fuchencong/mooncode/internal/biz/secret" + workflow "github.com/fuchencong/mooncode/internal/biz/workflow" + channelruntime "github.com/fuchencong/mooncode/internal/worker/channelruntime" + "github.com/fuchencong/mooncode/internal/worker/dispatcher" + "github.com/fuchencong/mooncode/internal/worker/hatchet" + channelpkg "github.com/fuchencong/mooncode/pkg/channel" + "github.com/google/wire" +) + +type TaskMetrics = hatchet.TaskMetrics + +// Client owns the external background worker lifecycle. +type Client interface { + StartWorker(context.Context, string, int) error +} + +// Dispatcher runs the durable workflow dispatch loop. +type Dispatcher interface { + Run(context.Context) error +} + +// ChannelRuntime owns inbound channel connections. +type ChannelRuntime interface { + Run(context.Context) error +} + +// ProviderSet contains background protocol adapters and process schedulers. +var ProviderSet = wire.NewSet( + NewSettings, + NewHatchetClient, + NewDispatcher, + NewChannelRuntime, + wire.Bind(new(biz.RepositoryCanceller), new(*hatchet.Client)), + wire.Bind(new(biz.AnalysisCanceller), new(*hatchet.Client)), + wire.Bind(new(dispatcher.Launcher), new(*hatchet.Client)), + wire.Bind(new(Client), new(*hatchet.Client)), + wire.Bind(new(Dispatcher), new(*dispatcher.Dispatcher)), + wire.Bind(new(ChannelRuntime), new(*channelruntime.Manager)), +) + +func NewHatchetClient( + repositoryRunner *repositoryworkflow.Runner, + analysisRunner *analysisworkflow.Runner, + notificationRunner *channelworkflow.Runner, + retentionScheduler *retentionworkflow.Scheduler, + retentionRunner *retentionworkflow.Runner, + metrics hatchet.TaskMetrics, + settings *Settings, +) (*hatchet.Client, error) { + client, err := hatchet.New( + settings.hatchetToken, + settings.hatchetAddress, + settings.hatchetNamespace, + repositoryRunner, + analysisRunner, + notificationRunner, + retentionScheduler, + retentionRunner, + hatchet.WithMetrics(metrics), + ) + if err != nil { + return nil, fmt.Errorf("create Hatchet client: %w", err) + } + + return client, nil +} + +func NewDispatcher( + store workflow.DispatchStore, + launcher dispatcher.Launcher, + logger *slog.Logger, +) *dispatcher.Dispatcher { + return dispatcher.NewDispatcher(store, launcher, dispatcher.WithLogger(logger)) +} + +func NewChannelRuntime( + store channel.RuntimeStore, + cipher secret.Cipher, + factories []channelpkg.Factory, + commands *channelcommand.Service, + logger *slog.Logger, +) *channelruntime.Manager { + return channelruntime.NewManager( + store, + cipher, + factories, + channelruntime.WithCommandHandler(commands), + channelruntime.WithLogger(logger), + ) +} diff --git a/sqlc.yaml b/sqlc.yaml index df310ec..1876000 100644 --- a/sqlc.yaml +++ b/sqlc.yaml @@ -6,7 +6,7 @@ sql: gen: go: package: sqlc - out: internal/data/sqlc + out: internal/data/postgres/sqlc sql_package: pgx/v5 emit_interface: true emit_json_tags: true