From bc315512c38e31b054d643d4d81397139202cb03 Mon Sep 17 00:00:00 2001 From: fuchencong <9530753+fuchencong@users.noreply.github.com> Date: Thu, 6 Aug 2026 15:00:12 +0800 Subject: [PATCH] refactor: reorganize backend with wire injection Adopt layered app, server, service, biz, data, and worker packages. Add Wire provider sets and functional options, preserve external contracts, and enforce dependency direction with architecture tests. --- .github/workflows/ci.yml | 4 +- .github/workflows/release.yml | 2 +- Makefile | 9 +- README.md | 16 +- README_EN.md | 16 +- api/openapi/spec_test.go | 20 ++ deploy/backend.Dockerfile | 2 +- go.mod | 13 +- go.sum | 20 +- internal/app/migrate.go | 20 ++ internal/app/option.go | 132 ++++++++++ internal/app/provider.go | 107 ++++++++ internal/app/server.go | 93 +++++++ internal/app/wire.go | 82 ++++++ internal/app/wire_gen.go | 223 ++++++++++++++++ internal/app/worker.go | 207 +++++++++++++++ internal/app/worker_test.go | 178 +++++++++++++ internal/architecture/dependencies_test.go | 59 +++++ .../{analysis/biz => biz/analysis}/failure.go | 2 +- .../biz => biz/analysis}/idempotency.go | 2 +- .../biz => biz/analysis}/idempotency_test.go | 2 +- .../{analysis/biz => biz/analysis}/model.go | 2 +- .../{analysis/biz => biz/analysis}/profile.go | 8 +- .../biz => biz/analysis}/profile_test.go | 2 +- .../{analysis/biz => biz/analysis}/service.go | 25 +- .../biz => biz/analysis}/service_test.go | 22 +- .../{analysis/biz => biz/analysis}/store.go | 4 +- .../{ => biz}/analysis/workflow/failure.go | 4 +- .../{ => biz}/analysis/workflow/runner.go | 14 +- .../analysis/workflow/runner_test.go | 6 +- internal/{platform => biz}/auth/actor.go | 1 + internal/biz/auth/identity.go | 8 + internal/{ => biz}/channel/command/model.go | 0 internal/{ => biz}/channel/command/options.go | 0 internal/{ => biz}/channel/command/service.go | 12 +- .../{ => biz}/channel/command/service_test.go | 10 +- .../{channel/biz => biz/channel}/config.go | 4 +- .../biz => biz/channel}/config_test.go | 2 +- .../{channel/biz => biz/channel}/model.go | 2 +- internal/biz/channel/runtime.go | 28 ++ .../{channel/biz => biz/channel}/service.go | 12 +- .../biz => biz/channel}/service_test.go | 6 +- .../biz => biz/channel}/subscription.go | 4 +- internal/{ => biz}/channel/workflow/model.go | 0 internal/{ => biz}/channel/workflow/runner.go | 9 +- .../{ => biz}/channel/workflow/runner_test.go | 0 internal/{platform => biz}/fault/fault.go | 6 + .../{platform => biz}/fault/fault_test.go | 0 .../{identity/biz => biz/identity}/model.go | 2 +- .../biz => biz/identity}/provider.go | 71 +----- .../biz => biz/identity}/provider_test.go | 2 +- .../{identity/biz => biz/identity}/service.go | 8 +- .../{identity/biz => biz/identity}/store.go | 6 +- internal/biz/option.go | 50 ++++ internal/biz/option_test.go | 25 ++ .../pagination/pagination.go | 0 .../pagination/pagination_test.go | 0 internal/biz/provider.go | 128 ++++++++++ .../biz => biz/repository}/failure.go | 2 +- .../biz => biz/repository}/model.go | 2 +- .../biz => biz/repository}/service.go | 10 +- .../biz => biz/repository}/service_test.go | 8 +- .../biz => biz/repository}/store.go | 4 +- .../{ => biz}/repository/workflow/runner.go | 17 +- .../repository/workflow/runner_test.go | 13 +- .../{retention/biz => biz/retention}/model.go | 2 +- .../{retention/biz => biz/retention}/store.go | 2 +- .../{ => biz}/retention/workflow/options.go | 0 .../{ => biz}/retention/workflow/runner.go | 8 +- .../retention/workflow/runner_test.go | 2 +- .../{ => biz}/retention/workflow/scheduler.go | 2 +- internal/biz/secret/cipher.go | 7 + internal/biz/workflow/contract_test.go | 39 +++ .../{workflow/biz => biz/workflow}/failure.go | 2 +- .../biz => biz/workflow}/failure_test.go | 2 +- .../{workflow/biz => biz/workflow}/model.go | 2 +- internal/biz/workflow/store.go | 15 ++ .../{platform => }/buildinfo/buildinfo.go | 0 internal/cli/app.go | 115 --------- internal/cli/root.go | 241 ++++-------------- internal/cli/root_test.go | 177 +------------ internal/cli/version.go | 2 +- internal/cli/version_test.go | 2 +- .../config/config.go => conf/conf.go} | 3 +- .../config_test.go => conf/conf_test.go} | 2 +- .../analysis}/audit_integration_test.go | 6 +- .../analysis}/failure_integration_test.go | 4 +- .../notification_integration_test.go | 4 +- .../data => data/analysis}/options.go | 2 +- .../analysis}/profile_integration_test.go | 8 +- .../data => data/analysis}/profile_store.go | 14 +- .../analysis}/quota_integration_test.go | 6 +- .../{analysis/data => data/analysis}/store.go | 29 ++- .../audit/audit_integration_test.go | 2 +- internal/{platform => data}/audit/metadata.go | 0 internal/{platform => data}/audit/model.go | 0 internal/{platform => data}/audit/writer.go | 2 +- .../{platform => data}/audit/writer_test.go | 2 +- .../channel}/audit_integration_test.go | 4 +- .../data => data/channel}/command_store.go | 6 +- .../command_store_integration_test.go | 4 +- .../data => data/channel}/delivery_store.go | 9 +- .../channel}/runtime_integration_test.go | 6 +- .../data => data/channel}/runtime_store.go | 14 +- .../{channel/data => data/channel}/store.go | 21 +- internal/data/data.go | 117 +++++++++ .../identity}/membership_integration_test.go | 2 +- .../identity}/provider_integration_test.go | 12 +- .../data => data/identity}/provider_store.go | 27 +- .../{identity/data => data/identity}/store.go | 27 +- internal/data/option.go | 51 ++++ internal/data/option_test.go | 23 ++ .../database => data/postgres}/database.go | 2 +- internal/data/postgres/errors.go | 17 ++ .../database => data/postgres}/migrate.go | 2 +- .../{ => postgres}/pagecursor/pagecursor.go | 2 +- .../database => data/postgres}/readiness.go | 2 +- .../postgres}/readiness_test.go | 2 +- .../data/{ => postgres}/sqlc/analysis.sql.go | 0 .../data/{ => postgres}/sqlc/audit.sql.go | 0 .../sqlc/channel_commands.sql.go | 0 .../data/{ => postgres}/sqlc/channels.sql.go | 0 internal/data/{ => postgres}/sqlc/db.go | 0 .../data/{ => postgres}/sqlc/identity.sql.go | 0 internal/data/{ => postgres}/sqlc/models.go | 0 .../{ => postgres}/sqlc/notifications.sql.go | 0 .../data/{ => postgres}/sqlc/providers.sql.go | 0 internal/data/{ => postgres}/sqlc/querier.go | 0 .../{ => postgres}/sqlc/repositories.sql.go | 0 .../data/{ => postgres}/sqlc/retention.sql.go | 0 .../data/{ => postgres}/sqlc/workflows.sql.go | 0 internal/data/provider.go | 49 ++++ internal/data/provider/validator.go | 92 +++++++ .../data => data/repository}/options.go | 2 +- .../repository}/quota_integration_test.go | 6 +- .../data => data/repository}/store.go | 33 +-- .../repository}/store_integration_test.go | 8 +- .../data => data/retention}/store.go | 13 +- .../retention}/store_integration_test.go | 10 +- internal/{platform => data}/secret/cipher.go | 5 - .../{platform => data}/secret/cipher_test.go | 0 .../{workflow/data => data/workflow}/store.go | 6 +- .../workflow}/store_integration_test.go | 6 +- internal/platform/httpserver/options.go | 34 --- .../httpserver => server}/accesslog.go | 4 +- .../httpserver => server}/analysis.go | 6 +- .../analysis_profiles.go | 2 +- internal/server/auth/actor.go | 17 ++ internal/{platform => server}/auth/csrf.go | 0 internal/{platform => server}/auth/headers.go | 9 +- .../{platform => server}/auth/headers_test.go | 0 .../{platform => server}/auth/middleware.go | 0 internal/{platform => server}/auth/proxy.go | 0 .../{platform => server}/auth/proxy_test.go | 0 .../{platform => server}/auth/request_id.go | 0 .../httpserver => server}/channels.go | 4 +- .../httpserver => server}/channels_test.go | 2 +- .../httpserver => server}/helpers.go | 22 +- .../httpserver => server}/identity.go | 6 +- .../identity_rbac_test.go | 8 +- .../httpserver => server}/metrics.go | 2 +- .../httpserver => server}/middleware.go | 4 +- .../observability_test.go | 4 +- .../openapi_routes_test.go | 6 +- internal/server/options.go | 81 ++++++ internal/server/options_test.go | 24 ++ internal/server/provider_set.go | 9 + .../httpserver => server}/providers.go | 2 +- .../httpserver => server}/repositories.go | 4 +- .../retention_metrics.go | 2 +- .../{platform/httpserver => server}/server.go | 56 +++- .../httpserver => server}/server_test.go | 4 +- .../httpserver => server}/task_metrics.go | 2 +- internal/service/analysis/analysis.go | 23 ++ internal/service/channel/channel.go | 16 ++ internal/service/command/command.go | 16 ++ internal/service/identity/identity.go | 48 ++++ internal/service/provider.go | 20 ++ internal/service/repository/repository.go | 24 ++ internal/testutil/testdb/testdb.go | 6 +- .../channelruntime}/manager.go | 24 +- .../channelruntime}/manager_test.go | 15 +- .../channelruntime}/model.go | 17 -- .../channelruntime}/options.go | 0 .../channelruntime}/policy.go | 0 .../channelruntime}/policy_test.go | 0 .../dispatcher}/dispatcher.go | 15 +- .../dispatcher}/dispatcher_test.go | 4 +- .../dispatcher}/options.go | 2 +- .../{platform => worker}/hatchet/client.go | 8 +- .../{platform => worker}/hatchet/failure.go | 7 +- .../hatchet/failure_test.go | 4 +- .../{platform => worker}/hatchet/options.go | 0 .../hatchet/options_test.go | 0 internal/worker/option.go | 41 +++ internal/worker/option_test.go | 30 +++ internal/worker/provider.go | 105 ++++++++ sqlc.yaml | 2 +- 198 files changed, 2764 insertions(+), 1009 deletions(-) create mode 100644 internal/app/migrate.go create mode 100644 internal/app/option.go create mode 100644 internal/app/provider.go create mode 100644 internal/app/server.go create mode 100644 internal/app/wire.go create mode 100644 internal/app/wire_gen.go create mode 100644 internal/app/worker.go create mode 100644 internal/app/worker_test.go create mode 100644 internal/architecture/dependencies_test.go rename internal/{analysis/biz => biz/analysis}/failure.go (84%) rename internal/{analysis/biz => biz/analysis}/idempotency.go (97%) rename internal/{analysis/biz => biz/analysis}/idempotency_test.go (99%) rename internal/{analysis/biz => biz/analysis}/model.go (99%) rename internal/{analysis/biz => biz/analysis}/profile.go (96%) rename internal/{analysis/biz => biz/analysis}/profile_test.go (98%) rename internal/{analysis/biz => biz/analysis}/service.go (89%) rename internal/{analysis/biz => biz/analysis}/service_test.go (93%) rename internal/{analysis/biz => biz/analysis}/store.go (94%) rename internal/{ => biz}/analysis/workflow/failure.go (88%) rename internal/{ => biz}/analysis/workflow/runner.go (94%) rename internal/{ => biz}/analysis/workflow/runner_test.go (98%) rename internal/{platform => biz}/auth/actor.go (85%) create mode 100644 internal/biz/auth/identity.go rename internal/{ => biz}/channel/command/model.go (100%) rename internal/{ => biz}/channel/command/options.go (100%) rename internal/{ => biz}/channel/command/service.go (96%) rename internal/{ => biz}/channel/command/service_test.go (96%) rename internal/{channel/biz => biz/channel}/config.go (97%) rename internal/{channel/biz => biz/channel}/config_test.go (99%) rename internal/{channel/biz => biz/channel}/model.go (99%) create mode 100644 internal/biz/channel/runtime.go rename internal/{channel/biz => biz/channel}/service.go (95%) rename internal/{channel/biz => biz/channel}/service_test.go (96%) rename internal/{channel/biz => biz/channel}/subscription.go (92%) rename internal/{ => biz}/channel/workflow/model.go (100%) rename internal/{ => biz}/channel/workflow/runner.go (93%) rename internal/{ => biz}/channel/workflow/runner_test.go (100%) rename internal/{platform => biz}/fault/fault.go (89%) rename internal/{platform => biz}/fault/fault_test.go (100%) rename internal/{identity/biz => biz/identity}/model.go (98%) rename internal/{identity/biz => biz/identity}/provider.go (78%) rename internal/{identity/biz => biz/identity}/provider_test.go (98%) rename internal/{identity/biz => biz/identity}/service.go (97%) rename internal/{identity/biz => biz/identity}/store.go (89%) create mode 100644 internal/biz/option.go create mode 100644 internal/biz/option_test.go rename internal/{platform => biz}/pagination/pagination.go (100%) rename internal/{platform => biz}/pagination/pagination_test.go (100%) create mode 100644 internal/biz/provider.go rename internal/{repository/biz => biz/repository}/failure.go (75%) rename internal/{repository/biz => biz/repository}/model.go (99%) rename internal/{repository/biz => biz/repository}/service.go (97%) rename internal/{repository/biz => biz/repository}/service_test.go (98%) rename internal/{repository/biz => biz/repository}/store.go (95%) rename internal/{ => biz}/repository/workflow/runner.go (91%) rename internal/{ => biz}/repository/workflow/runner_test.go (98%) rename internal/{retention/biz => biz/retention}/model.go (96%) rename internal/{retention/biz => biz/retention}/store.go (97%) rename internal/{ => biz}/retention/workflow/options.go (100%) rename internal/{ => biz}/retention/workflow/runner.go (91%) rename internal/{ => biz}/retention/workflow/runner_test.go (98%) rename internal/{ => biz}/retention/workflow/scheduler.go (86%) create mode 100644 internal/biz/secret/cipher.go create mode 100644 internal/biz/workflow/contract_test.go rename internal/{workflow/biz => biz/workflow}/failure.go (96%) rename internal/{workflow/biz => biz/workflow}/failure_test.go (95%) rename internal/{workflow/biz => biz/workflow}/model.go (98%) create mode 100644 internal/biz/workflow/store.go rename internal/{platform => }/buildinfo/buildinfo.go (100%) delete mode 100644 internal/cli/app.go rename internal/{platform/config/config.go => conf/conf.go} (99%) rename internal/{platform/config/config_test.go => conf/conf_test.go} (99%) rename internal/{analysis/data => data/analysis}/audit_integration_test.go (96%) rename internal/{analysis/data => data/analysis}/failure_integration_test.go (98%) rename internal/{analysis/data => data/analysis}/notification_integration_test.go (99%) rename internal/{analysis/data => data/analysis}/options.go (91%) rename internal/{analysis/data => data/analysis}/profile_integration_test.go (94%) rename internal/{analysis/data => data/analysis}/profile_store.go (95%) rename internal/{analysis/data => data/analysis}/quota_integration_test.go (96%) rename internal/{analysis/data => data/analysis}/store.go (92%) rename internal/{platform => data}/audit/audit_integration_test.go (95%) rename internal/{platform => data}/audit/metadata.go (100%) rename internal/{platform => data}/audit/model.go (100%) rename internal/{platform => data}/audit/writer.go (94%) rename internal/{platform => data}/audit/writer_test.go (97%) rename internal/{channel/data => data/channel}/audit_integration_test.go (98%) rename internal/{channel/data => data/channel}/command_store.go (96%) rename internal/{channel/data => data/channel}/command_store_integration_test.go (98%) rename internal/{channel/data => data/channel}/delivery_store.go (86%) rename internal/{channel/data => data/channel}/runtime_integration_test.go (95%) rename internal/{channel/data => data/channel}/runtime_store.go (87%) rename internal/{channel/data => data/channel}/store.go (93%) create mode 100644 internal/data/data.go rename internal/{identity/data => data/identity}/membership_integration_test.go (99%) rename internal/{identity/data => data/identity}/provider_integration_test.go (94%) rename internal/{identity/data => data/identity}/provider_store.go (87%) rename internal/{identity/data => data/identity}/store.go (90%) create mode 100644 internal/data/option.go create mode 100644 internal/data/option_test.go rename internal/{platform/database => data/postgres}/database.go (97%) create mode 100644 internal/data/postgres/errors.go rename internal/{platform/database => data/postgres}/migrate.go (99%) rename internal/data/{ => postgres}/pagecursor/pagecursor.go (85%) rename internal/{platform/database => data/postgres}/readiness.go (98%) rename internal/{platform/database => data/postgres}/readiness_test.go (99%) rename internal/data/{ => postgres}/sqlc/analysis.sql.go (100%) rename internal/data/{ => postgres}/sqlc/audit.sql.go (100%) rename internal/data/{ => postgres}/sqlc/channel_commands.sql.go (100%) rename internal/data/{ => postgres}/sqlc/channels.sql.go (100%) rename internal/data/{ => postgres}/sqlc/db.go (100%) rename internal/data/{ => postgres}/sqlc/identity.sql.go (100%) rename internal/data/{ => postgres}/sqlc/models.go (100%) rename internal/data/{ => postgres}/sqlc/notifications.sql.go (100%) rename internal/data/{ => postgres}/sqlc/providers.sql.go (100%) rename internal/data/{ => postgres}/sqlc/querier.go (100%) rename internal/data/{ => postgres}/sqlc/repositories.sql.go (100%) rename internal/data/{ => postgres}/sqlc/retention.sql.go (100%) rename internal/data/{ => postgres}/sqlc/workflows.sql.go (100%) create mode 100644 internal/data/provider.go create mode 100644 internal/data/provider/validator.go rename internal/{repository/data => data/repository}/options.go (90%) rename internal/{repository/data => data/repository}/quota_integration_test.go (93%) rename internal/{repository/data => data/repository}/store.go (95%) rename internal/{repository/data => data/repository}/store_integration_test.go (98%) rename internal/{retention/data => data/retention}/store.go (95%) rename internal/{retention/data => data/retention}/store_integration_test.go (97%) rename internal/{platform => data}/secret/cipher.go (88%) rename internal/{platform => data}/secret/cipher_test.go (100%) rename internal/{workflow/data => data/workflow}/store.go (95%) rename internal/{workflow/data => data/workflow}/store_integration_test.go (97%) delete mode 100644 internal/platform/httpserver/options.go rename internal/{platform/httpserver => server}/accesslog.go (89%) rename internal/{platform/httpserver => server}/analysis.go (96%) rename internal/{platform/httpserver => server}/analysis_profiles.go (99%) create mode 100644 internal/server/auth/actor.go rename internal/{platform => server}/auth/csrf.go (100%) rename internal/{platform => server}/auth/headers.go (86%) rename internal/{platform => server}/auth/headers_test.go (100%) rename internal/{platform => server}/auth/middleware.go (100%) rename internal/{platform => server}/auth/proxy.go (100%) rename internal/{platform => server}/auth/proxy_test.go (100%) rename internal/{platform => server}/auth/request_id.go (100%) rename internal/{platform/httpserver => server}/channels.go (98%) rename internal/{platform/httpserver => server}/channels_test.go (95%) rename internal/{platform/httpserver => server}/helpers.go (81%) rename internal/{platform/httpserver => server}/identity.go (97%) rename internal/{platform/httpserver => server}/identity_rbac_test.go (98%) rename internal/{platform/httpserver => server}/metrics.go (99%) rename internal/{platform/httpserver => server}/middleware.go (94%) rename internal/{platform/httpserver => server}/observability_test.go (97%) rename internal/{platform/httpserver => server}/openapi_routes_test.go (93%) create mode 100644 internal/server/options.go create mode 100644 internal/server/options_test.go create mode 100644 internal/server/provider_set.go rename internal/{platform/httpserver => server}/providers.go (99%) rename internal/{platform/httpserver => server}/repositories.go (98%) rename internal/{platform/httpserver => server}/retention_metrics.go (98%) rename internal/{platform/httpserver => server}/server.go (60%) rename internal/{platform/httpserver => server}/server_test.go (98%) rename internal/{platform/httpserver => server}/task_metrics.go (99%) create mode 100644 internal/service/analysis/analysis.go create mode 100644 internal/service/channel/channel.go create mode 100644 internal/service/command/command.go create mode 100644 internal/service/identity/identity.go create mode 100644 internal/service/provider.go create mode 100644 internal/service/repository/repository.go rename internal/{channel/runtime => worker/channelruntime}/manager.go (88%) rename internal/{channel/runtime => worker/channelruntime}/manager_test.go (90%) rename internal/{channel/runtime => worker/channelruntime}/model.go (55%) rename internal/{channel/runtime => worker/channelruntime}/options.go (100%) rename internal/{channel/runtime => worker/channelruntime}/policy.go (100%) rename internal/{channel/runtime => worker/channelruntime}/policy_test.go (100%) rename internal/{workflow => worker/dispatcher}/dispatcher.go (90%) rename internal/{workflow => worker/dispatcher}/dispatcher_test.go (98%) rename internal/{workflow => worker/dispatcher}/options.go (97%) rename internal/{platform => worker}/hatchet/client.go (97%) rename internal/{platform => worker}/hatchet/failure.go (84%) rename internal/{platform => worker}/hatchet/failure_test.go (94%) rename internal/{platform => worker}/hatchet/options.go (100%) rename internal/{platform => worker}/hatchet/options_test.go (100%) create mode 100644 internal/worker/option.go create mode 100644 internal/worker/option_test.go create mode 100644 internal/worker/provider.go 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