From 91242df3c767ed0679e4f0f5159b5c5999b1f845 Mon Sep 17 00:00:00 2001 From: v-byte-cpu <65545655+v-byte-cpu@users.noreply.github.com> Date: Sat, 5 Sep 2026 21:12:19 +0400 Subject: [PATCH] feat: introduce manifest-driven project control plane Add the devctl CLI for initializing, validating, inspecting, synchronizing, linting, and generating reproducible Go projects from manifests. Include contract snapshots, project scaffolding, runtime configuration, examples, documentation, tests, and CI workflows. --- .commitlintrc.yml | 5 + .dockerignore | 10 + .github/scripts/compute_release_version.py | 163 ++ .github/scripts/tests/__init__.py | 0 .../tests/test_compute_release_version.py | 151 ++ .github/workflows/ci.yml | 152 ++ .github/workflows/commitlint.yml | 51 + .github/workflows/preview-release.yml | 68 + .github/workflows/publish-image.yml | 315 ++++ .github/workflows/release.yml | 246 +++ .gitignore | 4 + .golangci.yml | 79 + .mise.toml | 42 + AGENTS.md | 9 + CONTEXT.md | 134 ++ Dockerfile | 26 + README.md | 90 + cliff.toml | 40 + cmd/devctl/internal/add/add.go | 23 + cmd/devctl/internal/add/add_test.go | 44 + cmd/devctl/internal/add/db.go | 115 ++ cmd/devctl/internal/add/grpc_client.go | 112 ++ cmd/devctl/internal/add/http_client.go | 113 ++ cmd/devctl/internal/add/kafka.go | 150 ++ cmd/devctl/internal/add/mocks/db.go | 81 + cmd/devctl/internal/add/mocks/http_client.go | 81 + cmd/devctl/internal/add/mocks/source.go | 81 + cmd/devctl/internal/add/result.go | 42 + cmd/devctl/internal/add/result_test.go | 68 + cmd/devctl/internal/add/source.go | 122 ++ cmd/devctl/internal/add/storage.go | 146 ++ cmd/devctl/internal/app/app.go | 47 + cmd/devctl/internal/app/app_test.go | 37 + cmd/devctl/internal/command/options.go | 79 + cmd/devctl/internal/command/options_test.go | 52 + cmd/devctl/internal/command/reporter.go | 150 ++ cmd/devctl/internal/command/reporter_test.go | 99 + cmd/devctl/internal/command/runtime.go | 20 + .../internal/command/validation_issue.go | 45 + cmd/devctl/internal/docgen/main.go | 90 + cmd/devctl/internal/docgen/main_test.go | 73 + cmd/devctl/internal/enable/capability.go | 117 ++ cmd/devctl/internal/enable/enable.go | 24 + cmd/devctl/internal/enable/enable_test.go | 78 + cmd/devctl/internal/enable/health.go | 8 + cmd/devctl/internal/enable/http.go | 8 + cmd/devctl/internal/enable/logging.go | 8 + .../internal/enable/mocks/capability.go | 81 + cmd/devctl/internal/enable/pprof.go | 8 + cmd/devctl/internal/enable/telemetry.go | 8 + cmd/devctl/internal/gen/config.go | 8 + cmd/devctl/internal/gen/gen.go | 169 ++ cmd/devctl/internal/gen/gen_test.go | 78 + cmd/devctl/internal/gen/http.go | 8 + cmd/devctl/internal/gen/mocks/gen.go | 81 + cmd/devctl/internal/init/init.go | 16 + cmd/devctl/internal/init/init_test.go | 135 ++ cmd/devctl/internal/init/manifest.go | 127 ++ cmd/devctl/internal/init/mocks/manifest.go | 81 + cmd/devctl/internal/init/mocks/scaffold.go | 81 + cmd/devctl/internal/init/scaffold.go | 119 ++ cmd/devctl/internal/inspect/inspect.go | 207 ++ cmd/devctl/internal/inspect/inspect_test.go | 101 + cmd/devctl/internal/inspect/mocks/inspect.go | 81 + cmd/devctl/internal/lint/http.go | 8 + cmd/devctl/internal/lint/lint.go | 173 ++ cmd/devctl/internal/lint/lint_test.go | 93 + cmd/devctl/internal/lint/mocks/lint.go | 81 + cmd/devctl/internal/sync/http.go | 8 + cmd/devctl/internal/sync/mocks/sync.go | 81 + cmd/devctl/internal/sync/sync.go | 158 ++ cmd/devctl/internal/sync/sync_test.go | 144 ++ .../internal/validate/mocks/validate.go | 81 + cmd/devctl/internal/validate/validate.go | 113 ++ cmd/devctl/internal/validate/validate_test.go | 156 ++ cmd/devctl/main.go | 29 + cmd/devctl/main_test.go | 1658 +++++++++++++++++ ...al-project-model-and-command-boundaries.md | 61 + ...-contract-sources-exports-and-snapshots.md | 77 + .../0003-managed-artifacts-and-publication.md | 71 + .../0004-project-owned-generator-toolchain.md | 50 + docs/adr/0005-canonical-runtime-config.md | 61 + .../adr/0006-machine-readable-cli-contract.md | 46 + ...ation-composition-and-runtime-scenarios.md | 81 + ...ative-clickhouse-runtime-and-migrations.md | 52 + docs/agents/domain.md | 53 + docs/agents/issue-tracker.md | 29 + docs/development.md | 57 + docs/user-guide/README.md | 28 + docs/user-guide/concepts-and-ownership.md | 64 + docs/user-guide/contracts-and-generation.md | 79 + docs/user-guide/generated-project.md | 60 + docs/user-guide/getting-started.md | 305 +++ docs/user-guide/project-workflow.md | 72 + docs/user-guide/recipes.md | 125 ++ docs/user-guide/reference/commands.md | 843 +++++++++ docs/user-guide/reference/manifest/README.md | 51 + .../reference/manifest/components.md | 252 +++ .../manifest/project-and-language.md | 115 ++ .../reference/manifest/sources-and-exports.md | 70 + .../user-guide/reference/output-and-errors.md | 80 + docs/user-guide/troubleshooting.md | 80 + e2e/e2e_test.go | 184 ++ examples/orders-api/.env.example | 13 + examples/orders-api/.golangci.yml | 76 + examples/orders-api/.mise.toml | 50 + examples/orders-api/README.md | 30 + examples/orders-api/api/openapi/swagger.yaml | 89 + .../orders-api/cmd/orders-api/internal/api.go | 27 + examples/orders-api/cmd/orders-api/main.go | 29 + examples/orders-api/devctl.yaml | 72 + examples/orders-api/gen/config/config.gen.go | 56 + .../orders-api/gen/serverhttp/server.gen.go | 386 ++++ examples/orders-api/go.mod | 85 + examples/orders-api/go.sum | 372 ++++ .../orders-api/internal/deps/application.go | 52 + .../orders-api/internal/deps/config.gen.go | 23 + .../orders-api/internal/deps/container.gen.go | 97 + .../orders-api/internal/deps/runtime.gen.go | 131 ++ .../internal/deps/storage_primary.gen.go | 109 ++ .../internal/orders/handler_test.go | 49 + examples/orders-api/internal/orders/orders.go | 115 ++ .../migrations/primary/postgres/.gitkeep | 0 .../postgres/000001_create_orders.down.sql | 1 + .../postgres/000001_create_orders.up.sql | 6 + examples/orders-api/tools/oapi/server.yaml | 6 + go.mod | 58 + go.sum | 247 +++ internal/client/bufgen/client.go | 106 ++ internal/client/bufgen/client_test.go | 40 + internal/client/bufgen/runner_test.go | 75 + internal/client/generator/client.go | 65 + internal/client/generator/client_test.go | 96 + internal/client/generator/mocks/client.go | 82 + internal/client/git/client.go | 73 + internal/client/git/client_test.go | 52 + internal/client/http/client.go | 151 ++ internal/client/http/client_test.go | 148 ++ internal/client/http/policy_test.go | 66 + internal/client/jsonschema/client.go | 102 + internal/client/jsonschema/client_test.go | 127 ++ internal/client/jsonschema/runner_test.go | 67 + internal/client/oapicodegen/client.go | 77 + internal/client/oapicodegen/client_test.go | 41 + internal/client/oapicodegen/runner_test.go | 57 + internal/client/toolrun/runner.go | 42 + internal/client/toolrun/runner_test.go | 85 + internal/client/toolrun/temporary.go | 25 + internal/client/toolrun/temporary_test.go | 78 + internal/client/toolsafety/safety.go | 157 ++ internal/client/toolsafety/safety_test.go | 165 ++ internal/deps/clients.go | 124 ++ internal/deps/container.go | 93 + internal/deps/init_scaffold.go | 1 + internal/deps/logger.go | 15 + internal/deps/repositories.go | 84 + internal/deps/resolve.go | 16 + internal/deps/scaffold_repositories.go | 24 + internal/deps/scaffold_services.go | 30 + internal/deps/services.go | 192 ++ internal/deps/target_input.go | 54 + internal/deps/validate.go | 1 + internal/deps/validate_test.go | 118 ++ internal/domain/artifact/tree.go | 36 + internal/domain/contract/metadata.go | 271 +++ internal/domain/contract/snapshot.go | 46 + internal/domain/failure/category.go | 41 + internal/domain/failure/category_test.go | 41 + internal/domain/generate/command.go | 44 + internal/domain/generate/errors.go | 47 + internal/domain/generate/errors_test.go | 20 + internal/domain/lint/command.go | 36 + internal/domain/lint/errors.go | 46 + internal/domain/lint/errors_test.go | 20 + internal/domain/materialize/contract.go | 27 + internal/domain/materialize/errors.go | 143 ++ internal/domain/materialize/errors_test.go | 39 + internal/domain/project/command.go | 117 ++ internal/domain/project/errors.go | 104 ++ internal/domain/project/errors_test.go | 20 + internal/domain/project/export.go | 40 + internal/domain/project/export_test.go | 39 + internal/domain/project/manifest.go | 277 +++ internal/domain/project/query.go | 171 ++ internal/domain/project/query_test.go | 14 + internal/domain/project/runtime_config.go | 497 +++++ .../domain/project/runtime_config_test.go | 112 ++ internal/domain/project/source.go | 29 + internal/domain/project/target.go | 403 ++++ internal/domain/project/target_selection.go | 80 + internal/domain/project/target_test.go | 242 +++ internal/domain/project/validation.go | 512 +++++ .../domain/project/validation_policy_test.go | 174 ++ internal/domain/project/validation_test.go | 36 + internal/domain/scaffold/command.go | 27 + internal/domain/scaffold/errors.go | 52 + internal/domain/scaffold/errors_test.go | 20 + internal/domain/sync/command.go | 36 + internal/domain/sync/errors.go | 54 + internal/domain/sync/errors_test.go | 20 + internal/platform/jsonschema/jsonschema.go | 22 + .../platform/jsonschema/jsonschema_test.go | 27 + internal/platform/openapi/openapi.go | 152 ++ internal/platform/openapi/openapi_test.go | 35 + internal/repository/manifest/decode.go | 162 ++ internal/repository/manifest/document.go | 280 +++ internal/repository/manifest/mapper.go | 407 ++++ internal/repository/manifest/repository.go | 109 ++ .../repository/manifest/repository_test.go | 268 +++ internal/repository/workspace/contracts.go | 160 ++ internal/repository/workspace/repository.go | 441 +++++ .../repository/workspace/repository_test.go | 201 ++ .../filesystem_integration_test.go | 160 ++ internal/service/contractsnapshot/loader.go | 126 ++ .../service/contractsnapshot/loader_test.go | 187 ++ .../service/contractsnapshot/mocks/loader.go | 120 ++ internal/service/generate/config.go | 21 + internal/service/generate/mocks/service.go | 311 ++++ internal/service/generate/publish.go | 42 + internal/service/generate/service.go | 137 ++ internal/service/generate/service_test.go | 943 ++++++++++ internal/service/generate/targets.go | 60 + .../generate/testdata/config_logging.golden | 25 + internal/service/lint/mocks/service.go | 348 ++++ internal/service/lint/service.go | 306 +++ internal/service/lint/service_test.go | 432 +++++ internal/service/materialize/closure.go | 96 + internal/service/materialize/devctl.go | 215 +++ internal/service/materialize/git.go | 49 + internal/service/materialize/local.go | 36 + internal/service/materialize/mocks/ports.go | 310 +++ internal/service/materialize/ports.go | 38 + internal/service/materialize/proto.go | 123 ++ internal/service/materialize/service.go | 54 + internal/service/materialize/service_test.go | 66 + internal/service/materialize/snapshot.go | 75 + internal/service/materialize/strategy_test.go | 1019 ++++++++++ internal/service/materialize/url.go | 237 +++ internal/service/project/errors.go | 24 + internal/service/project/init.go | 89 + internal/service/project/inspect.go | 140 ++ internal/service/project/inspect_env.go | 18 + internal/service/project/load.go | 101 + internal/service/project/mocks/service.go | 348 ++++ internal/service/project/mutation.go | 848 +++++++++ internal/service/project/mutation_db_test.go | 48 + internal/service/project/service.go | 63 + internal/service/project/service_test.go | 702 +++++++ internal/service/project/validation.go | 35 + .../service/project/validation_db_test.go | 129 ++ .../service/project/validation_grpc_test.go | 226 +++ .../service/project/validation_http_test.go | 43 + .../service/project/validation_kafka_test.go | 77 + .../project/validation_source_export_test.go | 164 ++ .../project/validation_storage_test.go | 77 + .../project/validation_test_helpers_test.go | 24 + internal/service/projectreadiness/checker.go | 396 ++++ .../service/projectreadiness/checker_test.go | 617 ++++++ .../service/projectreadiness/mocks/checker.go | 158 ++ internal/service/runtimeconfig/render.go | 122 ++ internal/service/runtimeconfig/render_test.go | 46 + .../runtimeconfig/templates/config.go.gotmpl | 29 + internal/service/scaffold/integration_test.go | 216 +++ internal/service/scaffold/mocks/ports.go | 262 +++ internal/service/scaffold/planner.go | 471 +++++ .../scaffold/planner_characterization_test.go | 177 ++ internal/service/scaffold/planner_test.go | 746 ++++++++ internal/service/scaffold/ports.go | 29 + internal/service/scaffold/projection.go | 627 +++++++ internal/service/scaffold/render.go | 118 ++ internal/service/scaffold/render_config.go | 49 + internal/service/scaffold/render_container.go | 38 + internal/service/scaffold/render_runtime.go | 22 + internal/service/scaffold/render_storage.go | 29 + internal/service/scaffold/service.go | 94 + .../scaffold/service_preflight_test.go | 312 ++++ internal/service/scaffold/service_test.go | 41 + .../service/scaffold/templates/api.go.gotmpl | 23 + .../scaffold/templates/application.go.gotmpl | 46 + .../scaffold/templates/buf-go.gen.yaml | 10 + .../scaffold/templates/buf.yaml.gotmpl | 11 + .../scaffold/templates/config.go.gotmpl | 21 + .../scaffold/templates/consumer.go.gotmpl | 23 + .../scaffold/templates/container.go.gotmpl | 144 ++ .../service/scaffold/templates/go.mod.gotmpl | 21 + .../service/scaffold/templates/golangci.yml | 76 + .../service/scaffold/templates/grpc.go.gotmpl | 6 + .../scaffold/templates/grpc_clients.go.gotmpl | 23 + .../scaffold/templates/http_clients.go.gotmpl | 28 + .../scaffold/templates/kafka_broker.go.gotmpl | 5 + .../kafka_consumer_binding.go.gotmpl | 35 + .../templates/kafka_consumers.go.gotmpl | 61 + .../templates/kafka_handler.go.gotmpl | 20 + .../templates/kafka_producers.go.gotmpl | 19 + .../service/scaffold/templates/main.go.gotmpl | 48 + internal/service/scaffold/templates/mise.toml | 57 + .../scaffold/templates/oapi-client.yaml | 4 + .../scaffold/templates/oapi-server.yaml | 6 + .../service/scaffold/templates/openapi.yaml | 5 + .../templates/project-readme.md.gotmpl | 30 + .../scaffold/templates/redis.go.gotmpl | 18 + .../scaffold/templates/runtime.go.gotmpl | 243 +++ .../service/scaffold/templates/s3.go.gotmpl | 65 + .../scaffold/templates/storage.go.gotmpl | 133 ++ .../scaffold/testdata/full/.env.example | 44 + .../scaffold/testdata/full/.golangci.yml | 76 + .../service/scaffold/testdata/full/.mise.toml | 52 + .../service/scaffold/testdata/full/README.md | 30 + .../testdata/full/api/openapi/swagger.yaml | 5 + .../service/scaffold/testdata/full/buf.yaml | 11 + .../full/cmd/sample-api/internal/api.go | 27 + .../full/cmd/sample-api/internal/consumer.go | 27 + .../testdata/full/cmd/sample-api/main.go | 40 + .../scaffold/testdata/full/data/.gitkeep | 0 .../testdata/full/gen/config/config.gen.go | 115 ++ .../service/scaffold/testdata/full/go.mod | 45 + .../full/internal/deps/application.go | 66 + .../testdata/full/internal/deps/config.gen.go | 23 + .../full/internal/deps/consumer_audit.go | 35 + .../full/internal/deps/consumer_invoice.go | 35 + .../full/internal/deps/container.gen.go | 141 ++ .../testdata/full/internal/deps/grpc.gen.go | 8 + .../full/internal/deps/grpc_clients.gen.go | 23 + .../full/internal/deps/http_clients.gen.go | 30 + .../full/internal/deps/kafka_broker.gen.go | 7 + .../full/internal/deps/kafka_consumers.gen.go | 79 + .../full/internal/deps/kafka_producers.gen.go | 21 + .../testdata/full/internal/deps/redis.gen.go | 18 + .../full/internal/deps/runtime.gen.go | 161 ++ .../testdata/full/internal/deps/s3.gen.go | 51 + .../internal/deps/storage_analytics.gen.go | 45 + .../full/internal/deps/storage_primary.gen.go | 152 ++ .../transport/consumerkafka/audit/handler.go | 20 + .../consumerkafka/invoice/handler.go | 20 + .../migrations/analytics/clickhouse/.gitkeep | 0 .../testdata/full/tools/buf/grpc.gen.yaml | 10 + .../testdata/full/tools/buf/kafka.gen.yaml | 10 + .../full/tools/oapi/clients.catalog.yaml | 4 + .../testdata/full/tools/oapi/server.yaml | 6 + .../scaffold/testdata/minimal/.env.example | 0 .../scaffold/testdata/minimal/.golangci.yml | 76 + .../scaffold/testdata/minimal/.mise.toml | 26 + .../scaffold/testdata/minimal/README.md | 30 + .../testdata/minimal/cmd/sample/main.go | 34 + .../testdata/minimal/gen/config/config.gen.go | 20 + .../service/scaffold/testdata/minimal/go.mod | 9 + .../minimal/internal/deps/application.go | 15 + .../minimal/internal/deps/config.gen.go | 23 + .../minimal/internal/deps/container.gen.go | 3 + internal/service/sync/mocks/sync_service.go | 326 ++++ internal/service/sync/prune.go | 79 + internal/service/sync/service.go | 168 ++ internal/service/sync/service_test.go | 425 +++++ internal/service/sync/targets.go | 22 + .../service/targetinput/mocks/resolver.go | 144 ++ internal/service/targetinput/resolver.go | 61 + internal/service/targetinput/resolver_test.go | 225 +++ internal/testutil/testexec/testexec.go | 18 + 358 files changed, 39634 insertions(+) create mode 100644 .commitlintrc.yml create mode 100644 .dockerignore create mode 100644 .github/scripts/compute_release_version.py create mode 100644 .github/scripts/tests/__init__.py create mode 100644 .github/scripts/tests/test_compute_release_version.py create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/commitlint.yml create mode 100644 .github/workflows/preview-release.yml create mode 100644 .github/workflows/publish-image.yml create mode 100644 .github/workflows/release.yml create mode 100644 .gitignore create mode 100644 .golangci.yml create mode 100644 .mise.toml create mode 100644 AGENTS.md create mode 100644 CONTEXT.md create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 cliff.toml create mode 100644 cmd/devctl/internal/add/add.go create mode 100644 cmd/devctl/internal/add/add_test.go create mode 100644 cmd/devctl/internal/add/db.go create mode 100644 cmd/devctl/internal/add/grpc_client.go create mode 100644 cmd/devctl/internal/add/http_client.go create mode 100644 cmd/devctl/internal/add/kafka.go create mode 100644 cmd/devctl/internal/add/mocks/db.go create mode 100644 cmd/devctl/internal/add/mocks/http_client.go create mode 100644 cmd/devctl/internal/add/mocks/source.go create mode 100644 cmd/devctl/internal/add/result.go create mode 100644 cmd/devctl/internal/add/result_test.go create mode 100644 cmd/devctl/internal/add/source.go create mode 100644 cmd/devctl/internal/add/storage.go create mode 100644 cmd/devctl/internal/app/app.go create mode 100644 cmd/devctl/internal/app/app_test.go create mode 100644 cmd/devctl/internal/command/options.go create mode 100644 cmd/devctl/internal/command/options_test.go create mode 100644 cmd/devctl/internal/command/reporter.go create mode 100644 cmd/devctl/internal/command/reporter_test.go create mode 100644 cmd/devctl/internal/command/runtime.go create mode 100644 cmd/devctl/internal/command/validation_issue.go create mode 100644 cmd/devctl/internal/docgen/main.go create mode 100644 cmd/devctl/internal/docgen/main_test.go create mode 100644 cmd/devctl/internal/enable/capability.go create mode 100644 cmd/devctl/internal/enable/enable.go create mode 100644 cmd/devctl/internal/enable/enable_test.go create mode 100644 cmd/devctl/internal/enable/health.go create mode 100644 cmd/devctl/internal/enable/http.go create mode 100644 cmd/devctl/internal/enable/logging.go create mode 100644 cmd/devctl/internal/enable/mocks/capability.go create mode 100644 cmd/devctl/internal/enable/pprof.go create mode 100644 cmd/devctl/internal/enable/telemetry.go create mode 100644 cmd/devctl/internal/gen/config.go create mode 100644 cmd/devctl/internal/gen/gen.go create mode 100644 cmd/devctl/internal/gen/gen_test.go create mode 100644 cmd/devctl/internal/gen/http.go create mode 100644 cmd/devctl/internal/gen/mocks/gen.go create mode 100644 cmd/devctl/internal/init/init.go create mode 100644 cmd/devctl/internal/init/init_test.go create mode 100644 cmd/devctl/internal/init/manifest.go create mode 100644 cmd/devctl/internal/init/mocks/manifest.go create mode 100644 cmd/devctl/internal/init/mocks/scaffold.go create mode 100644 cmd/devctl/internal/init/scaffold.go create mode 100644 cmd/devctl/internal/inspect/inspect.go create mode 100644 cmd/devctl/internal/inspect/inspect_test.go create mode 100644 cmd/devctl/internal/inspect/mocks/inspect.go create mode 100644 cmd/devctl/internal/lint/http.go create mode 100644 cmd/devctl/internal/lint/lint.go create mode 100644 cmd/devctl/internal/lint/lint_test.go create mode 100644 cmd/devctl/internal/lint/mocks/lint.go create mode 100644 cmd/devctl/internal/sync/http.go create mode 100644 cmd/devctl/internal/sync/mocks/sync.go create mode 100644 cmd/devctl/internal/sync/sync.go create mode 100644 cmd/devctl/internal/sync/sync_test.go create mode 100644 cmd/devctl/internal/validate/mocks/validate.go create mode 100644 cmd/devctl/internal/validate/validate.go create mode 100644 cmd/devctl/internal/validate/validate_test.go create mode 100644 cmd/devctl/main.go create mode 100644 cmd/devctl/main_test.go create mode 100644 docs/adr/0001-canonical-project-model-and-command-boundaries.md create mode 100644 docs/adr/0002-contract-sources-exports-and-snapshots.md create mode 100644 docs/adr/0003-managed-artifacts-and-publication.md create mode 100644 docs/adr/0004-project-owned-generator-toolchain.md create mode 100644 docs/adr/0005-canonical-runtime-config.md create mode 100644 docs/adr/0006-machine-readable-cli-contract.md create mode 100644 docs/adr/0007-explicit-application-composition-and-runtime-scenarios.md create mode 100644 docs/adr/0008-native-clickhouse-runtime-and-migrations.md create mode 100644 docs/agents/domain.md create mode 100644 docs/agents/issue-tracker.md create mode 100644 docs/development.md create mode 100644 docs/user-guide/README.md create mode 100644 docs/user-guide/concepts-and-ownership.md create mode 100644 docs/user-guide/contracts-and-generation.md create mode 100644 docs/user-guide/generated-project.md create mode 100644 docs/user-guide/getting-started.md create mode 100644 docs/user-guide/project-workflow.md create mode 100644 docs/user-guide/recipes.md create mode 100644 docs/user-guide/reference/commands.md create mode 100644 docs/user-guide/reference/manifest/README.md create mode 100644 docs/user-guide/reference/manifest/components.md create mode 100644 docs/user-guide/reference/manifest/project-and-language.md create mode 100644 docs/user-guide/reference/manifest/sources-and-exports.md create mode 100644 docs/user-guide/reference/output-and-errors.md create mode 100644 docs/user-guide/troubleshooting.md create mode 100644 e2e/e2e_test.go create mode 100644 examples/orders-api/.env.example create mode 100644 examples/orders-api/.golangci.yml create mode 100644 examples/orders-api/.mise.toml create mode 100644 examples/orders-api/README.md create mode 100644 examples/orders-api/api/openapi/swagger.yaml create mode 100644 examples/orders-api/cmd/orders-api/internal/api.go create mode 100644 examples/orders-api/cmd/orders-api/main.go create mode 100644 examples/orders-api/devctl.yaml create mode 100644 examples/orders-api/gen/config/config.gen.go create mode 100644 examples/orders-api/gen/serverhttp/server.gen.go create mode 100644 examples/orders-api/go.mod create mode 100644 examples/orders-api/go.sum create mode 100644 examples/orders-api/internal/deps/application.go create mode 100644 examples/orders-api/internal/deps/config.gen.go create mode 100644 examples/orders-api/internal/deps/container.gen.go create mode 100644 examples/orders-api/internal/deps/runtime.gen.go create mode 100644 examples/orders-api/internal/deps/storage_primary.gen.go create mode 100644 examples/orders-api/internal/orders/handler_test.go create mode 100644 examples/orders-api/internal/orders/orders.go create mode 100644 examples/orders-api/migrations/primary/postgres/.gitkeep create mode 100644 examples/orders-api/migrations/primary/postgres/000001_create_orders.down.sql create mode 100644 examples/orders-api/migrations/primary/postgres/000001_create_orders.up.sql create mode 100644 examples/orders-api/tools/oapi/server.yaml create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/client/bufgen/client.go create mode 100644 internal/client/bufgen/client_test.go create mode 100644 internal/client/bufgen/runner_test.go create mode 100644 internal/client/generator/client.go create mode 100644 internal/client/generator/client_test.go create mode 100644 internal/client/generator/mocks/client.go create mode 100644 internal/client/git/client.go create mode 100644 internal/client/git/client_test.go create mode 100644 internal/client/http/client.go create mode 100644 internal/client/http/client_test.go create mode 100644 internal/client/http/policy_test.go create mode 100644 internal/client/jsonschema/client.go create mode 100644 internal/client/jsonschema/client_test.go create mode 100644 internal/client/jsonschema/runner_test.go create mode 100644 internal/client/oapicodegen/client.go create mode 100644 internal/client/oapicodegen/client_test.go create mode 100644 internal/client/oapicodegen/runner_test.go create mode 100644 internal/client/toolrun/runner.go create mode 100644 internal/client/toolrun/runner_test.go create mode 100644 internal/client/toolrun/temporary.go create mode 100644 internal/client/toolrun/temporary_test.go create mode 100644 internal/client/toolsafety/safety.go create mode 100644 internal/client/toolsafety/safety_test.go create mode 100644 internal/deps/clients.go create mode 100644 internal/deps/container.go create mode 100644 internal/deps/init_scaffold.go create mode 100644 internal/deps/logger.go create mode 100644 internal/deps/repositories.go create mode 100644 internal/deps/resolve.go create mode 100644 internal/deps/scaffold_repositories.go create mode 100644 internal/deps/scaffold_services.go create mode 100644 internal/deps/services.go create mode 100644 internal/deps/target_input.go create mode 100644 internal/deps/validate.go create mode 100644 internal/deps/validate_test.go create mode 100644 internal/domain/artifact/tree.go create mode 100644 internal/domain/contract/metadata.go create mode 100644 internal/domain/contract/snapshot.go create mode 100644 internal/domain/failure/category.go create mode 100644 internal/domain/failure/category_test.go create mode 100644 internal/domain/generate/command.go create mode 100644 internal/domain/generate/errors.go create mode 100644 internal/domain/generate/errors_test.go create mode 100644 internal/domain/lint/command.go create mode 100644 internal/domain/lint/errors.go create mode 100644 internal/domain/lint/errors_test.go create mode 100644 internal/domain/materialize/contract.go create mode 100644 internal/domain/materialize/errors.go create mode 100644 internal/domain/materialize/errors_test.go create mode 100644 internal/domain/project/command.go create mode 100644 internal/domain/project/errors.go create mode 100644 internal/domain/project/errors_test.go create mode 100644 internal/domain/project/export.go create mode 100644 internal/domain/project/export_test.go create mode 100644 internal/domain/project/manifest.go create mode 100644 internal/domain/project/query.go create mode 100644 internal/domain/project/query_test.go create mode 100644 internal/domain/project/runtime_config.go create mode 100644 internal/domain/project/runtime_config_test.go create mode 100644 internal/domain/project/source.go create mode 100644 internal/domain/project/target.go create mode 100644 internal/domain/project/target_selection.go create mode 100644 internal/domain/project/target_test.go create mode 100644 internal/domain/project/validation.go create mode 100644 internal/domain/project/validation_policy_test.go create mode 100644 internal/domain/project/validation_test.go create mode 100644 internal/domain/scaffold/command.go create mode 100644 internal/domain/scaffold/errors.go create mode 100644 internal/domain/scaffold/errors_test.go create mode 100644 internal/domain/sync/command.go create mode 100644 internal/domain/sync/errors.go create mode 100644 internal/domain/sync/errors_test.go create mode 100644 internal/platform/jsonschema/jsonschema.go create mode 100644 internal/platform/jsonschema/jsonschema_test.go create mode 100644 internal/platform/openapi/openapi.go create mode 100644 internal/platform/openapi/openapi_test.go create mode 100644 internal/repository/manifest/decode.go create mode 100644 internal/repository/manifest/document.go create mode 100644 internal/repository/manifest/mapper.go create mode 100644 internal/repository/manifest/repository.go create mode 100644 internal/repository/manifest/repository_test.go create mode 100644 internal/repository/workspace/contracts.go create mode 100644 internal/repository/workspace/repository.go create mode 100644 internal/repository/workspace/repository_test.go create mode 100644 internal/service/contractsnapshot/filesystem_integration_test.go create mode 100644 internal/service/contractsnapshot/loader.go create mode 100644 internal/service/contractsnapshot/loader_test.go create mode 100644 internal/service/contractsnapshot/mocks/loader.go create mode 100644 internal/service/generate/config.go create mode 100644 internal/service/generate/mocks/service.go create mode 100644 internal/service/generate/publish.go create mode 100644 internal/service/generate/service.go create mode 100644 internal/service/generate/service_test.go create mode 100644 internal/service/generate/targets.go create mode 100644 internal/service/generate/testdata/config_logging.golden create mode 100644 internal/service/lint/mocks/service.go create mode 100644 internal/service/lint/service.go create mode 100644 internal/service/lint/service_test.go create mode 100644 internal/service/materialize/closure.go create mode 100644 internal/service/materialize/devctl.go create mode 100644 internal/service/materialize/git.go create mode 100644 internal/service/materialize/local.go create mode 100644 internal/service/materialize/mocks/ports.go create mode 100644 internal/service/materialize/ports.go create mode 100644 internal/service/materialize/proto.go create mode 100644 internal/service/materialize/service.go create mode 100644 internal/service/materialize/service_test.go create mode 100644 internal/service/materialize/snapshot.go create mode 100644 internal/service/materialize/strategy_test.go create mode 100644 internal/service/materialize/url.go create mode 100644 internal/service/project/errors.go create mode 100644 internal/service/project/init.go create mode 100644 internal/service/project/inspect.go create mode 100644 internal/service/project/inspect_env.go create mode 100644 internal/service/project/load.go create mode 100644 internal/service/project/mocks/service.go create mode 100644 internal/service/project/mutation.go create mode 100644 internal/service/project/mutation_db_test.go create mode 100644 internal/service/project/service.go create mode 100644 internal/service/project/service_test.go create mode 100644 internal/service/project/validation.go create mode 100644 internal/service/project/validation_db_test.go create mode 100644 internal/service/project/validation_grpc_test.go create mode 100644 internal/service/project/validation_http_test.go create mode 100644 internal/service/project/validation_kafka_test.go create mode 100644 internal/service/project/validation_source_export_test.go create mode 100644 internal/service/project/validation_storage_test.go create mode 100644 internal/service/project/validation_test_helpers_test.go create mode 100644 internal/service/projectreadiness/checker.go create mode 100644 internal/service/projectreadiness/checker_test.go create mode 100644 internal/service/projectreadiness/mocks/checker.go create mode 100644 internal/service/runtimeconfig/render.go create mode 100644 internal/service/runtimeconfig/render_test.go create mode 100644 internal/service/runtimeconfig/templates/config.go.gotmpl create mode 100644 internal/service/scaffold/integration_test.go create mode 100644 internal/service/scaffold/mocks/ports.go create mode 100644 internal/service/scaffold/planner.go create mode 100644 internal/service/scaffold/planner_characterization_test.go create mode 100644 internal/service/scaffold/planner_test.go create mode 100644 internal/service/scaffold/ports.go create mode 100644 internal/service/scaffold/projection.go create mode 100644 internal/service/scaffold/render.go create mode 100644 internal/service/scaffold/render_config.go create mode 100644 internal/service/scaffold/render_container.go create mode 100644 internal/service/scaffold/render_runtime.go create mode 100644 internal/service/scaffold/render_storage.go create mode 100644 internal/service/scaffold/service.go create mode 100644 internal/service/scaffold/service_preflight_test.go create mode 100644 internal/service/scaffold/service_test.go create mode 100644 internal/service/scaffold/templates/api.go.gotmpl create mode 100644 internal/service/scaffold/templates/application.go.gotmpl create mode 100644 internal/service/scaffold/templates/buf-go.gen.yaml create mode 100644 internal/service/scaffold/templates/buf.yaml.gotmpl create mode 100644 internal/service/scaffold/templates/config.go.gotmpl create mode 100644 internal/service/scaffold/templates/consumer.go.gotmpl create mode 100644 internal/service/scaffold/templates/container.go.gotmpl create mode 100644 internal/service/scaffold/templates/go.mod.gotmpl create mode 100644 internal/service/scaffold/templates/golangci.yml create mode 100644 internal/service/scaffold/templates/grpc.go.gotmpl create mode 100644 internal/service/scaffold/templates/grpc_clients.go.gotmpl create mode 100644 internal/service/scaffold/templates/http_clients.go.gotmpl create mode 100644 internal/service/scaffold/templates/kafka_broker.go.gotmpl create mode 100644 internal/service/scaffold/templates/kafka_consumer_binding.go.gotmpl create mode 100644 internal/service/scaffold/templates/kafka_consumers.go.gotmpl create mode 100644 internal/service/scaffold/templates/kafka_handler.go.gotmpl create mode 100644 internal/service/scaffold/templates/kafka_producers.go.gotmpl create mode 100644 internal/service/scaffold/templates/main.go.gotmpl create mode 100644 internal/service/scaffold/templates/mise.toml create mode 100644 internal/service/scaffold/templates/oapi-client.yaml create mode 100644 internal/service/scaffold/templates/oapi-server.yaml create mode 100644 internal/service/scaffold/templates/openapi.yaml create mode 100644 internal/service/scaffold/templates/project-readme.md.gotmpl create mode 100644 internal/service/scaffold/templates/redis.go.gotmpl create mode 100644 internal/service/scaffold/templates/runtime.go.gotmpl create mode 100644 internal/service/scaffold/templates/s3.go.gotmpl create mode 100644 internal/service/scaffold/templates/storage.go.gotmpl create mode 100644 internal/service/scaffold/testdata/full/.env.example create mode 100644 internal/service/scaffold/testdata/full/.golangci.yml create mode 100644 internal/service/scaffold/testdata/full/.mise.toml create mode 100644 internal/service/scaffold/testdata/full/README.md create mode 100644 internal/service/scaffold/testdata/full/api/openapi/swagger.yaml create mode 100644 internal/service/scaffold/testdata/full/buf.yaml create mode 100644 internal/service/scaffold/testdata/full/cmd/sample-api/internal/api.go create mode 100644 internal/service/scaffold/testdata/full/cmd/sample-api/internal/consumer.go create mode 100644 internal/service/scaffold/testdata/full/cmd/sample-api/main.go create mode 100644 internal/service/scaffold/testdata/full/data/.gitkeep create mode 100644 internal/service/scaffold/testdata/full/gen/config/config.gen.go create mode 100644 internal/service/scaffold/testdata/full/go.mod create mode 100644 internal/service/scaffold/testdata/full/internal/deps/application.go create mode 100644 internal/service/scaffold/testdata/full/internal/deps/config.gen.go create mode 100644 internal/service/scaffold/testdata/full/internal/deps/consumer_audit.go create mode 100644 internal/service/scaffold/testdata/full/internal/deps/consumer_invoice.go create mode 100644 internal/service/scaffold/testdata/full/internal/deps/container.gen.go create mode 100644 internal/service/scaffold/testdata/full/internal/deps/grpc.gen.go create mode 100644 internal/service/scaffold/testdata/full/internal/deps/grpc_clients.gen.go create mode 100644 internal/service/scaffold/testdata/full/internal/deps/http_clients.gen.go create mode 100644 internal/service/scaffold/testdata/full/internal/deps/kafka_broker.gen.go create mode 100644 internal/service/scaffold/testdata/full/internal/deps/kafka_consumers.gen.go create mode 100644 internal/service/scaffold/testdata/full/internal/deps/kafka_producers.gen.go create mode 100644 internal/service/scaffold/testdata/full/internal/deps/redis.gen.go create mode 100644 internal/service/scaffold/testdata/full/internal/deps/runtime.gen.go create mode 100644 internal/service/scaffold/testdata/full/internal/deps/s3.gen.go create mode 100644 internal/service/scaffold/testdata/full/internal/deps/storage_analytics.gen.go create mode 100644 internal/service/scaffold/testdata/full/internal/deps/storage_primary.gen.go create mode 100644 internal/service/scaffold/testdata/full/internal/transport/consumerkafka/audit/handler.go create mode 100644 internal/service/scaffold/testdata/full/internal/transport/consumerkafka/invoice/handler.go create mode 100644 internal/service/scaffold/testdata/full/migrations/analytics/clickhouse/.gitkeep create mode 100644 internal/service/scaffold/testdata/full/tools/buf/grpc.gen.yaml create mode 100644 internal/service/scaffold/testdata/full/tools/buf/kafka.gen.yaml create mode 100644 internal/service/scaffold/testdata/full/tools/oapi/clients.catalog.yaml create mode 100644 internal/service/scaffold/testdata/full/tools/oapi/server.yaml create mode 100644 internal/service/scaffold/testdata/minimal/.env.example create mode 100644 internal/service/scaffold/testdata/minimal/.golangci.yml create mode 100644 internal/service/scaffold/testdata/minimal/.mise.toml create mode 100644 internal/service/scaffold/testdata/minimal/README.md create mode 100644 internal/service/scaffold/testdata/minimal/cmd/sample/main.go create mode 100644 internal/service/scaffold/testdata/minimal/gen/config/config.gen.go create mode 100644 internal/service/scaffold/testdata/minimal/go.mod create mode 100644 internal/service/scaffold/testdata/minimal/internal/deps/application.go create mode 100644 internal/service/scaffold/testdata/minimal/internal/deps/config.gen.go create mode 100644 internal/service/scaffold/testdata/minimal/internal/deps/container.gen.go create mode 100644 internal/service/sync/mocks/sync_service.go create mode 100644 internal/service/sync/prune.go create mode 100644 internal/service/sync/service.go create mode 100644 internal/service/sync/service_test.go create mode 100644 internal/service/sync/targets.go create mode 100644 internal/service/targetinput/mocks/resolver.go create mode 100644 internal/service/targetinput/resolver.go create mode 100644 internal/service/targetinput/resolver_test.go create mode 100644 internal/testutil/testexec/testexec.go diff --git a/.commitlintrc.yml b/.commitlintrc.yml new file mode 100644 index 0000000..8bc6ddb --- /dev/null +++ b/.commitlintrc.yml @@ -0,0 +1,5 @@ +extends: + - '@commitlint/config-conventional' + +rules: + header-max-length: [0, 'always', 100] diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..a5ba463 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +.git +.github +.idea +.scratch +dist +Dockerfile +.dockerignore +devctl +sx +devctl-skills diff --git a/.github/scripts/compute_release_version.py b/.github/scripts/compute_release_version.py new file mode 100644 index 0000000..a849f6f --- /dev/null +++ b/.github/scripts/compute_release_version.py @@ -0,0 +1,163 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import json +import re +import subprocess +import sys +from pathlib import Path +from typing import Literal + +REPO_ROOT = Path(__file__).resolve().parents[2] +RC_IGNORE_TAGS = r"^v[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+$" +RC_VERSION_RE = re.compile(r"^(\d+\.\d+\.\d+)-rc\.(\d+)$") +ReleaseKind = Literal["stable", "rc"] + + +def bumped_version(release_kind: ReleaseKind) -> str: + try: + raw_version = run_command(*build_git_cliff_args(release_kind)) + except subprocess.CalledProcessError as exc: + stderr = (exc.stderr or "").strip() + if "No releases found" in stderr: + return "0.1.0" + raise RuntimeError(stderr or "git-cliff failed while computing the next version.") from exc + + return raw_version + + +def build_git_cliff_args(release_kind: ReleaseKind) -> list[str]: + args = ["git-cliff"] + if release_kind == "stable": + args.extend(["--ignore-tags", RC_IGNORE_TAGS]) + args.append("--bumped-version") + return args + + +def build_git_cliff_context_args(release_kind: ReleaseKind) -> list[str]: + args = ["git-cliff", "--unreleased", "--bump", "--context"] + if release_kind == "stable": + args.extend(["--ignore-tags", RC_IGNORE_TAGS]) + return args + + +def run_command(*args: str) -> str: + completed = subprocess.run( + args, + cwd=REPO_ROOT, + check=True, + text=True, + capture_output=True, + ) + return completed.stdout.strip() + + +def normalize_version(raw_version: str) -> str: + version = raw_version.strip() + if version.startswith("v"): + version = version[1:] + return version + + +def compute_next_tag(raw_version: str, release_kind: ReleaseKind) -> str: + version = normalize_version(raw_version) + if not version: + raise RuntimeError("git-cliff did not return a version.") + + if release_kind == "stable": + if RC_VERSION_RE.fullmatch(version): + raise RuntimeError("git-cliff returned a prerelease version for a stable release.") + return f"v{version}" + + if RC_VERSION_RE.fullmatch(version): + return f"v{version}" + return f"v{version}-rc.1" + + +def release_commit_count(release_kind: ReleaseKind) -> int: + try: + raw_context = run_command(*build_git_cliff_context_args(release_kind)) + except subprocess.CalledProcessError as exc: + stderr = (exc.stderr or "").strip() + raise RuntimeError(stderr or "git-cliff failed while checking for new commits.") from exc + + try: + context = json.loads(raw_context) + except json.JSONDecodeError as exc: + raise RuntimeError("git-cliff returned invalid JSON while checking for new commits.") from exc + + if not context: + return 0 + + statistics = context[0].get("statistics", {}) + return int(statistics.get("commit_count", 0)) + + +def ensure_new_commits(release_kind: ReleaseKind) -> None: + if release_commit_count(release_kind) == 0: + raise RuntimeError("nothing to release") + + +def tag_exists(tag: str) -> bool: + completed = subprocess.run( + ["git", "ls-remote", "--exit-code", "--tags", "origin", f"refs/tags/{tag}"], + cwd=REPO_ROOT, + text=True, + capture_output=True, + ) + if completed.returncode == 0: + return True + if completed.returncode == 2: + return False + + stderr = (completed.stderr or completed.stdout or "").strip() + raise RuntimeError(stderr or f"git failed while checking whether {tag} exists in origin.") + + +def ensure_tag_absent(tag: str) -> None: + if tag_exists(tag): + raise RuntimeError(f"tag {tag} already exists in origin") + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="compute_release_version.py", + description="Compute the next release tag for stable or rc workflows.", + ) + parser.add_argument( + "--release-kind", + required=True, + choices=("stable", "rc"), + help="Release line to compute the next tag for.", + ) + parser.add_argument( + "--require-new-commits", + action="store_true", + help="Fail if there are no unreleased commits for the selected release line.", + ) + parser.add_argument( + "--require-absent-tag", + action="store_true", + help="Fail if the computed release tag already exists in origin.", + ) + return parser + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + try: + if args.require_new_commits: + ensure_new_commits(args.release_kind) + next_tag = compute_next_tag(bumped_version(args.release_kind), args.release_kind) + if args.require_absent_tag: + ensure_tag_absent(next_tag) + print(next_tag) + except (RuntimeError, subprocess.CalledProcessError) as exc: + print(str(exc), file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/scripts/tests/__init__.py b/.github/scripts/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/.github/scripts/tests/test_compute_release_version.py b/.github/scripts/tests/test_compute_release_version.py new file mode 100644 index 0000000..167696e --- /dev/null +++ b/.github/scripts/tests/test_compute_release_version.py @@ -0,0 +1,151 @@ +from __future__ import annotations + +import io +import subprocess +import sys +import unittest +from contextlib import redirect_stderr +from pathlib import Path +from unittest.mock import patch + + +SCRIPT_ROOT = Path(__file__).resolve().parents[1] +if str(SCRIPT_ROOT) not in sys.path: + sys.path.insert(0, str(SCRIPT_ROOT)) + +import compute_release_version as release_version + + +class ComputeReleaseVersionTests(unittest.TestCase): + def test_normalize_version_strips_leading_v(self) -> None: + self.assertEqual(release_version.normalize_version("v1.2.3"), "1.2.3") + self.assertEqual(release_version.normalize_version("1.2.3"), "1.2.3") + + def test_build_git_cliff_args_for_stable_adds_ignore_tags(self) -> None: + self.assertEqual( + release_version.build_git_cliff_args("stable"), + ["git-cliff", "--ignore-tags", release_version.RC_IGNORE_TAGS, "--bumped-version"], + ) + + def test_build_git_cliff_args_for_rc_omits_ignore_tags(self) -> None: + self.assertEqual(release_version.build_git_cliff_args("rc"), ["git-cliff", "--bumped-version"]) + + def test_build_git_cliff_context_args_for_stable_adds_ignore_tags(self) -> None: + self.assertEqual( + release_version.build_git_cliff_context_args("stable"), + [ + "git-cliff", + "--unreleased", + "--bump", + "--context", + "--ignore-tags", + release_version.RC_IGNORE_TAGS, + ], + ) + + def test_build_git_cliff_context_args_for_rc_omits_ignore_tags(self) -> None: + self.assertEqual( + release_version.build_git_cliff_context_args("rc"), + ["git-cliff", "--unreleased", "--bump", "--context"], + ) + + def test_bumped_version_returns_raw_git_cliff_output(self) -> None: + with patch.object(release_version, "run_command", return_value="v1.2.4"): + self.assertEqual(release_version.bumped_version("stable"), "v1.2.4") + + def test_bumped_version_falls_back_without_releases(self) -> None: + error = subprocess.CalledProcessError( + returncode=1, + cmd=("git-cliff", "--bumped-version"), + stderr="No releases found, using 0.1.0 as the next version.", + ) + + with patch.object(release_version, "run_command", side_effect=error): + self.assertEqual(release_version.bumped_version("stable"), "0.1.0") + self.assertEqual(release_version.bumped_version("rc"), "0.1.0") + + def test_release_commit_count_parses_json_context(self) -> None: + with patch.object( + release_version, + "run_command", + return_value='[{"statistics":{"commit_count":2}}]', + ): + self.assertEqual(release_version.release_commit_count("stable"), 2) + + def test_release_commit_count_handles_empty_context(self) -> None: + with patch.object(release_version, "run_command", return_value="[]"): + self.assertEqual(release_version.release_commit_count("rc"), 0) + + def test_ensure_new_commits_raises_for_empty_release(self) -> None: + with patch.object(release_version, "release_commit_count", return_value=0): + with self.assertRaisesRegex(RuntimeError, "nothing to release"): + release_version.ensure_new_commits("stable") + + def test_compute_next_tag_for_stable_prefixes_version(self) -> None: + self.assertEqual(release_version.compute_next_tag("v1.2.4", "stable"), "v1.2.4") + + def test_compute_next_tag_for_rc_keeps_existing_rc_suffix(self) -> None: + self.assertEqual(release_version.compute_next_tag("v1.2.4-rc.2", "rc"), "v1.2.4-rc.2") + + def test_compute_next_tag_for_rc_adds_first_suffix_for_plain_version(self) -> None: + self.assertEqual(release_version.compute_next_tag("v1.2.4", "rc"), "v1.2.4-rc.1") + + def test_compute_next_tag_for_stable_rejects_prerelease_version(self) -> None: + with self.assertRaisesRegex(RuntimeError, "prerelease version"): + release_version.compute_next_tag("v1.2.4-rc.2", "stable") + + def test_compute_next_tag_for_empty_version_rejects_empty_output(self) -> None: + with self.assertRaisesRegex(RuntimeError, "did not return a version"): + release_version.compute_next_tag(" ", "stable") + + def test_tag_exists_returns_true_when_origin_has_tag(self) -> None: + completed = subprocess.CompletedProcess(args=(), returncode=0, stdout="ref", stderr="") + with patch.object(release_version.subprocess, "run", return_value=completed): + self.assertTrue(release_version.tag_exists("v1.2.3")) + + def test_tag_exists_returns_false_when_origin_has_no_tag(self) -> None: + completed = subprocess.CompletedProcess(args=(), returncode=2, stdout="", stderr="") + with patch.object(release_version.subprocess, "run", return_value=completed): + self.assertFalse(release_version.tag_exists("v1.2.3")) + + def test_tag_exists_raises_for_git_errors(self) -> None: + completed = subprocess.CompletedProcess(args=(), returncode=128, stdout="", stderr="network failed") + with patch.object(release_version.subprocess, "run", return_value=completed): + with self.assertRaisesRegex(RuntimeError, "network failed"): + release_version.tag_exists("v1.2.3") + + def test_ensure_tag_absent_raises_for_existing_tag(self) -> None: + with patch.object(release_version, "tag_exists", return_value=True): + with self.assertRaisesRegex(RuntimeError, "tag v1.2.3 already exists in origin"): + release_version.ensure_tag_absent("v1.2.3") + + def test_main_fails_on_empty_release_when_required(self) -> None: + stderr = io.StringIO() + with ( + patch.object(release_version, "ensure_new_commits", side_effect=RuntimeError("nothing to release")), + patch.object(release_version, "bumped_version") as bumped_version, + redirect_stderr(stderr), + ): + self.assertEqual( + release_version.main(["--release-kind", "stable", "--require-new-commits"]), + 1, + ) + bumped_version.assert_not_called() + self.assertEqual(stderr.getvalue().strip(), "nothing to release") + + def test_main_fails_on_existing_tag_when_required(self) -> None: + stderr = io.StringIO() + with ( + patch.object(release_version, "bumped_version", return_value="v1.2.4"), + patch.object(release_version, "ensure_tag_absent", side_effect=RuntimeError("tag v1.2.4 already exists in origin")), + redirect_stderr(stderr), + ): + self.assertEqual( + release_version.main(["--release-kind", "stable", "--require-absent-tag"]), + 1, + ) + self.assertEqual(stderr.getvalue().strip(), "tag v1.2.4 already exists in origin") + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..40a5222 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,152 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +env: + GOWORK: "off" + +jobs: + changes: + runs-on: ubuntu-latest + outputs: + quality_ci: ${{ steps.filter.outputs.quality_ci }} + docker_ci: ${{ steps.filter.outputs.docker_ci }} + steps: + - name: Checkout repository + uses: actions/checkout@v6 + - name: Detect changed paths + id: filter + uses: dorny/paths-filter@v4 + with: + filters: | + quality_ci: + - '**/*.go' + - 'go.mod' + - 'go.sum' + - '.golangci.yml' + - '.mise.toml' + - 'README.md' + - 'CONTEXT.md' + - 'docs/**' + - 'examples/**' + - 'e2e/**' + - 'internal/service/scaffold/templates/**' + - '.github/workflows/**' + - '.github/scripts/**' + docker_ci: + - 'go.mod' + - 'go.sum' + - 'Dockerfile' + - '.dockerignore' + - '.github/workflows/ci.yml' + - '.github/workflows/publish-image.yml' + + linux: + name: quality (Linux) + needs: changes + if: needs.changes.outputs.quality_ci == 'true' + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v6 + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version-file: go.mod + cache: true + cache-dependency-path: go.sum + - name: Set up project tools + uses: jdx/mise-action@v4 + with: + cache: true + install: true + github_token: ${{ github.token }} + - name: Cache golangci-lint analysis + uses: actions/cache@v5 + with: + path: ~/.cache/golangci-lint + key: golangci-lint-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('.mise.toml', '.golangci.yml', 'go.mod', 'go.sum') }} + restore-keys: | + golangci-lint-${{ runner.os }}-${{ runner.arch }}- + - name: Run full check + run: mise run check + + - name: Build macOS binaries + env: + CGO_ENABLED: "0" + GOOS: darwin + run: | + set -euo pipefail + for goarch in amd64 arm64; do + GOARCH="${goarch}" GOWORK=off go build \ + -trimpath \ + -o "${RUNNER_TEMP}/devctl-darwin-${goarch}" \ + ./cmd/devctl + done + + docker: + name: docker (linux/amd64) + needs: changes + if: needs.changes.outputs.docker_ci == 'true' + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v6 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + - name: Build Docker image + uses: docker/build-push-action@v7 + with: + context: . + platforms: linux/amd64 + load: true + push: false + tags: devctl:ci + build-args: | + VERSION=ci + COMMIT=${{ github.sha }} + cache-from: type=gha,scope=docker-amd64 + cache-to: type=gha,scope=docker-amd64,mode=max + provenance: false + - name: Verify image metadata + env: + EXPECTED_COMMIT: ${{ github.sha }} + run: | + set -euo pipefail + version_output="$(docker run --rm --platform linux/amd64 devctl:ci --version)" + grep -Fqx "devctl version ci" <<< "${version_output}" + grep -Fqx "commit: ${EXPECTED_COMMIT}" <<< "${version_output}" + - name: Verify project runner tools + run: | + set -euo pipefail + docker run --rm --platform linux/amd64 --entrypoint sh devctl:ci -ceu ' + test -x /devctl + command -v devctl + go version + git --version + node --version | grep -E "^v24\\." + quicktype --version + ' + + release-helper: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v6 + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.11" + - name: Run release helper tests + run: python3 -B -m unittest discover -s .github/scripts/tests -t .github/scripts diff --git a/.github/workflows/commitlint.yml b/.github/workflows/commitlint.yml new file mode 100644 index 0000000..039a8b2 --- /dev/null +++ b/.github/workflows/commitlint.yml @@ -0,0 +1,51 @@ +name: Commit checks + +on: + push: + branches: [main] + pull_request: + branches: [main] + +concurrency: + group: commitlint-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + commitlint: + if: >- + (github.event_name == 'pull_request' && github.event.pull_request.user.login != 'dependabot[bot]') + || (github.event_name == 'push' && github.event.head_commit.author.name != 'dependabot[bot]') + runs-on: ubuntu-latest + + steps: + - name: Enforce single-commit PRs + if: github.event_name == 'pull_request' + env: + PR_COMMIT_COUNT: ${{ github.event.pull_request.commits }} + run: test "$PR_COMMIT_COUNT" -eq 1 + + - name: Checkout repository + uses: actions/checkout@v6 + with: + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} + + - name: Set up Node + uses: actions/setup-node@v6 + with: + node-version: "22" + + - name: Install commitlint + run: npm install --no-save --no-package-lock @commitlint/cli @commitlint/config-conventional + + - name: Validate PR title + if: github.event_name == 'pull_request' + env: + PR_TITLE: ${{ github.event.pull_request.title }} + run: printf '%s\n' "$PR_TITLE" | npx commitlint --verbose + + - name: Validate last commit + run: npx commitlint --last --verbose + diff --git a/.github/workflows/preview-release.yml b/.github/workflows/preview-release.yml new file mode 100644 index 0000000..9f6996c --- /dev/null +++ b/.github/workflows/preview-release.yml @@ -0,0 +1,68 @@ +name: preview-release + +on: + workflow_dispatch: + inputs: + release_kind: + description: "Release line to preview." + required: true + default: stable + type: choice + options: + - stable + - rc + +permissions: + contents: read + +jobs: + preview: + runs-on: ubuntu-latest + env: + RELEASE_KIND: ${{ inputs.release_kind }} + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + ref: ${{ github.event.repository.default_branch }} + fetch-depth: 100 + fetch-tags: true + + - name: Install git-cliff + uses: taiki-e/install-action@git-cliff + + - name: Compute release tag + id: compute_release_version + shell: bash + run: | + set -euo pipefail + + next_tag="$(python3 .github/scripts/compute_release_version.py --release-kind "${RELEASE_KIND}")" + echo "next_tag=${next_tag}" >> "$GITHUB_OUTPUT" + + - name: Generate release preview + shell: bash + env: + NEXT_TAG: ${{ steps.compute_release_version.outputs.next_tag }} + run: | + set -euo pipefail + + ignore_rc_tags='^v[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+$' + notes_path="${RUNNER_TEMP}/release-notes.md" + if [[ "${RELEASE_KIND}" == "stable" ]]; then + git-cliff --ignore-tags "${ignore_rc_tags}" --unreleased --bump --tag "${NEXT_TAG}" --output "${notes_path}" + else + git-cliff --unreleased --bump --tag "${NEXT_TAG}" --output "${notes_path}" + fi + + cat >> "$GITHUB_STEP_SUMMARY" <> "$GITHUB_STEP_SUMMARY" + diff --git a/.github/workflows/publish-image.yml b/.github/workflows/publish-image.yml new file mode 100644 index 0000000..7e7192c --- /dev/null +++ b/.github/workflows/publish-image.yml @@ -0,0 +1,315 @@ +name: publish-image + +on: + workflow_dispatch: + inputs: + release_tag: + description: "Published release tag to publish to GHCR." + required: true + type: string + +permissions: {} + +concurrency: + group: publish-image-${{ inputs.release_tag }} + cancel-in-progress: false + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + publish: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + attestations: write + id-token: write + artifact-metadata: write + defaults: + run: + shell: bash + + steps: + - name: Validate published release + id: release + env: + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_TAG: ${{ inputs.release_tag }} + WORKFLOW_REF_NAME: ${{ github.ref_name }} + run: | + set -euo pipefail + + if [[ "${WORKFLOW_REF_NAME}" != "${DEFAULT_BRANCH}" ]]; then + echo "run this workflow from the default branch: ${DEFAULT_BRANCH}" >&2 + exit 1 + fi + + if [[ ! "${RELEASE_TAG}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-rc\.[0-9]+)?$ ]]; then + echo "unsupported release tag: ${RELEASE_TAG}" >&2 + exit 1 + fi + + release_json="$(gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${RELEASE_TAG}")" + draft="$(jq -r '.draft' <<< "${release_json}")" + prerelease="$(jq -r '.prerelease' <<< "${release_json}")" + published_at="$(jq -r '.published_at' <<< "${release_json}")" + + if [[ "${draft}" != "false" || -z "${published_at}" || "${published_at}" == "null" ]]; then + echo "release ${RELEASE_TAG} is not published" >&2 + exit 1 + fi + + expected_prerelease=false + if [[ "${RELEASE_TAG}" =~ -rc\.[0-9]+$ ]]; then + expected_prerelease=true + fi + if [[ "${prerelease}" != "${expected_prerelease}" ]]; then + echo "release prerelease flag does not match tag ${RELEASE_TAG}" >&2 + exit 1 + fi + + echo "version=${RELEASE_TAG#v}" >> "${GITHUB_OUTPUT}" + echo "prerelease=${prerelease}" >> "${GITHUB_OUTPUT}" + + - name: Checkout release tag + uses: actions/checkout@v6 + with: + ref: refs/tags/${{ inputs.release_tag }} + fetch-depth: 1 + + - name: Validate release source + id: source + run: | + set -euo pipefail + + if ! grep -Eq '^ARG[[:space:]]+VERSION(=|[[:space:]]|$)' Dockerfile || \ + ! grep -Eq '^ARG[[:space:]]+COMMIT(=|[[:space:]]|$)' Dockerfile; then + echo "release Dockerfile does not support VERSION and COMMIT build arguments" >&2 + exit 1 + fi + + echo "commit=$(git rev-parse HEAD)" >> "${GITHUB_OUTPUT}" + + - name: Set up QEMU + uses: docker/setup-qemu-action@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Log in to GHCR + uses: docker/login-action@v4 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Generate Docker metadata + id: meta + uses: docker/metadata-action@v6 + env: + DOCKER_METADATA_ANNOTATIONS_LEVELS: manifest,index + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + flavor: latest=false + tags: | + type=semver,pattern={{version}},value=${{ inputs.release_tag }} + type=raw,value=latest,enable=${{ steps.release.outputs.prerelease == 'false' }} + labels: | + org.opencontainers.image.source=https://github.com/${{ github.repository }} + org.opencontainers.image.description=Manifest-driven Go project control plane + org.opencontainers.image.licenses=MIT + org.opencontainers.image.version=${{ steps.release.outputs.version }} + org.opencontainers.image.revision=${{ steps.source.outputs.commit }} + annotations: | + org.opencontainers.image.source=https://github.com/${{ github.repository }} + org.opencontainers.image.description=Manifest-driven Go project control plane + org.opencontainers.image.licenses=MIT + org.opencontainers.image.version=${{ steps.release.outputs.version }} + org.opencontainers.image.revision=${{ steps.source.outputs.commit }} + + - name: Inspect existing version tag + id: existing + env: + EXPECTED_COMMIT: ${{ steps.source.outputs.commit }} + EXPECTED_VERSION: ${{ steps.release.outputs.version }} + run: | + set -euo pipefail + + image="${REGISTRY}/${IMAGE_NAME}:${EXPECTED_VERSION}" + set +e + manifest_json="$(docker buildx imagetools inspect "${image}" --format '{{json .Manifest}}' 2>&1)" + inspect_status=$? + set -e + + if (( inspect_status != 0 )); then + if grep -Eiq 'manifest unknown|name unknown|not found|does not exist' <<< "${manifest_json}"; then + echo "exists=false" >> "${GITHUB_OUTPUT}" + exit 0 + fi + echo "${manifest_json}" >&2 + exit "${inspect_status}" + fi + + digest="$(jq -r '.digest // ""' <<< "${manifest_json}")" + actual_version="$(jq -r '.annotations["org.opencontainers.image.version"] // ""' <<< "${manifest_json}")" + actual_commit="$(jq -r '.annotations["org.opencontainers.image.revision"] // ""' <<< "${manifest_json}")" + + if [[ ! "${digest}" =~ ^sha256:[0-9a-f]{64}$ ]]; then + echo "existing image has an invalid digest: ${digest}" >&2 + exit 1 + fi + if [[ "${actual_version}" != "${EXPECTED_VERSION}" || "${actual_commit}" != "${EXPECTED_COMMIT}" ]]; then + echo "existing image metadata does not match release source" >&2 + exit 1 + fi + if ! jq -e 'any(.manifests[]?; .platform.os == "linux" and .platform.architecture == "amd64")' \ + <<< "${manifest_json}" >/dev/null; then + echo "existing image is missing linux/amd64" >&2 + exit 1 + fi + if ! jq -e 'any(.manifests[]?; .platform.os == "linux" and .platform.architecture == "arm64")' \ + <<< "${manifest_json}" >/dev/null; then + echo "existing image is missing linux/arm64" >&2 + exit 1 + fi + + echo "exists=true" >> "${GITHUB_OUTPUT}" + echo "digest=${digest}" >> "${GITHUB_OUTPUT}" + + - name: Build and push Docker image + if: steps.existing.outputs.exists != 'true' + id: push + uses: docker/build-push-action@v7 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + annotations: ${{ steps.meta.outputs.annotations }} + build-args: | + VERSION=${{ steps.release.outputs.version }} + COMMIT=${{ steps.source.outputs.commit }} + cache-from: type=gha,scope=docker-release + cache-to: type=gha,scope=docker-release,mode=max + provenance: mode=max + + - name: Resolve image digest + id: image + env: + BUILT_DIGEST: ${{ steps.push.outputs.digest }} + EXISTING_DIGEST: ${{ steps.existing.outputs.digest }} + IMAGE_EXISTS: ${{ steps.existing.outputs.exists }} + run: | + set -euo pipefail + + if [[ "${IMAGE_EXISTS}" == "true" ]]; then + digest="${EXISTING_DIGEST}" + reused=true + else + digest="${BUILT_DIGEST}" + reused=false + fi + if [[ ! "${digest}" =~ ^sha256:[0-9a-f]{64}$ ]]; then + echo "image digest is invalid: ${digest}" >&2 + exit 1 + fi + + echo "digest=${digest}" >> "${GITHUB_OUTPUT}" + echo "reused=${reused}" >> "${GITHUB_OUTPUT}" + + - name: Update latest tag + if: steps.release.outputs.prerelease == 'false' && steps.image.outputs.reused == 'true' + env: + DIGEST: ${{ steps.image.outputs.digest }} + run: | + set -euo pipefail + + docker buildx imagetools create \ + --tag "${REGISTRY}/${IMAGE_NAME}:latest" \ + "${REGISTRY}/${IMAGE_NAME}@${DIGEST}" + + - name: Verify latest tag + if: steps.release.outputs.prerelease == 'false' + env: + EXPECTED_DIGEST: ${{ steps.image.outputs.digest }} + run: | + set -euo pipefail + + latest_manifest="$(docker buildx imagetools inspect \ + "${REGISTRY}/${IMAGE_NAME}:latest" --format '{{json .Manifest}}')" + latest_digest="$(jq -r '.digest // ""' <<< "${latest_manifest}")" + if [[ "${latest_digest}" != "${EXPECTED_DIGEST}" ]]; then + echo "latest points to ${latest_digest}, expected ${EXPECTED_DIGEST}" >&2 + exit 1 + fi + + - name: Verify published image + env: + IMAGE: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.release.outputs.version }} + EXPECTED_VERSION: ${{ steps.release.outputs.version }} + EXPECTED_COMMIT: ${{ steps.source.outputs.commit }} + run: | + set -euo pipefail + + version_output="$(docker run --rm --pull always --platform linux/amd64 "${IMAGE}" --version)" + grep -Fqx "devctl version ${EXPECTED_VERSION}" <<< "${version_output}" + grep -Fqx "commit: ${EXPECTED_COMMIT}" <<< "${version_output}" + + - name: Check GitHub attestation + id: attestation + env: + DIGEST: ${{ steps.image.outputs.digest }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + + count="$(gh api \ + "repos/${GITHUB_REPOSITORY}/attestations/${DIGEST}?predicate_type=provenance&per_page=1" \ + --jq '.attestations | length')" + if [[ "${count}" == "0" ]]; then + echo "exists=false" >> "${GITHUB_OUTPUT}" + else + echo "exists=true" >> "${GITHUB_OUTPUT}" + fi + + - name: Attest Docker image + if: steps.attestation.outputs.exists != 'true' + uses: actions/attest@v4 + with: + subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + subject-digest: ${{ steps.image.outputs.digest }} + push-to-registry: true + + - name: Verify GitHub attestation + env: + DIGEST: ${{ steps.image.outputs.digest }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + + gh attestation verify \ + "oci://${REGISTRY}/${IMAGE_NAME}@${DIGEST}" \ + --repo "${GITHUB_REPOSITORY}" + + - name: Summarize Docker image + env: + DIGEST: ${{ steps.image.outputs.digest }} + REUSED: ${{ steps.image.outputs.reused }} + TAGS: ${{ steps.meta.outputs.tags }} + run: | + set -euo pipefail + + { + echo "# Docker Image" + echo + echo "- Digest: \`${DIGEST}\`" + echo "- Existing digest reused: \`${REUSED}\`" + echo "- Tags:" + while IFS= read -r tag; do + echo " - \`${tag}\`" + done <<< "${TAGS}" + } >> "${GITHUB_STEP_SUMMARY}" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..b1eb459 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,246 @@ +name: release + +on: + workflow_dispatch: + inputs: + release_kind: + description: "Release line to publish." + required: true + default: stable + type: choice + options: + - stable + - rc + +permissions: + contents: write + +concurrency: + group: release-pipeline + cancel-in-progress: false + +jobs: + release: + runs-on: ubuntu-latest + outputs: + next_tag: ${{ steps.compute_release_version.outputs.next_tag }} + release_name: ${{ steps.compute_release_version.outputs.release_name }} + prerelease: ${{ steps.release_flags.outputs.prerelease }} + make_latest: ${{ steps.release_flags.outputs.make_latest }} + env: + RELEASE_KIND: ${{ inputs.release_kind }} + defaults: + run: + shell: bash + steps: + - name: Checkout default branch + uses: actions/checkout@v6 + with: + ref: ${{ github.event.repository.default_branch }} + fetch-depth: 100 + fetch-tags: true + - name: Install git-cliff + uses: taiki-e/install-action@git-cliff + - name: Compute release tag + id: compute_release_version + run: | + set -euo pipefail + next_tag="$( + python3 .github/scripts/compute_release_version.py \ + --release-kind "${RELEASE_KIND}" \ + --require-new-commits \ + --require-absent-tag + )" + echo "next_tag=${next_tag}" >> "$GITHUB_OUTPUT" + echo "release_name=${next_tag#v}" >> "$GITHUB_OUTPUT" + - name: Create annotated tag + env: + NEXT_TAG: ${{ steps.compute_release_version.outputs.next_tag }} + RELEASE_NAME: ${{ steps.compute_release_version.outputs.release_name }} + run: | + set -euo pipefail + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git tag -a "${NEXT_TAG}" -m "Release ${RELEASE_NAME}" + git push origin "${NEXT_TAG}" + commit_sha="$(git rev-parse HEAD)" + cat >> "$GITHUB_STEP_SUMMARY" <> "$GITHUB_OUTPUT" + echo "make_latest=false" >> "$GITHUB_OUTPUT" + else + echo "prerelease=false" >> "$GITHUB_OUTPUT" + echo "make_latest=true" >> "$GITHUB_OUTPUT" + fi + + linux-artifacts: + name: Linux artifact + needs: release + runs-on: ubuntu-latest + container: golang:1.26-alpine + env: + GOARCH: amd64 + GOFLAGS: -buildvcs=false + GOWORK: off + NEXT_TAG: ${{ needs.release.outputs.next_tag }} + RELEASE_NAME: ${{ needs.release.outputs.release_name }} + defaults: + run: + shell: bash + steps: + - name: Install shell dependencies + shell: sh + run: apk add --no-cache bash ca-certificates git tar + - name: Checkout release tag + uses: actions/checkout@v6 + with: + ref: ${{ needs.release.outputs.next_tag }} + fetch-depth: 1 + - name: Mark repository as safe for git + shell: sh + run: git config --global --add safe.directory "$GITHUB_WORKSPACE" + - name: Build Linux archive + id: package + run: | + set -euo pipefail + commit_sha="$(git rev-parse HEAD)" + archive="devctl_${RELEASE_NAME}_linux_${GOARCH}.tar.gz" + mkdir -p dist/linux + CGO_ENABLED=0 go build \ + -trimpath \ + -ldflags "-s -w -X main.version=${RELEASE_NAME} -X main.commit=${commit_sha}" \ + -o dist/linux/devctl ./cmd/devctl + cp README.md LICENSE dist/linux/ + tar -C dist/linux -czf "${archive}" devctl README.md LICENSE + echo "archive=${archive}" >> "$GITHUB_OUTPUT" + - name: Upload Linux archive + uses: actions/upload-artifact@v7 + with: + name: release-linux + path: ${{ steps.package.outputs.archive }} + retention-days: 1 + if-no-files-found: error + + macos-artifacts: + name: macOS artifacts (${{ matrix.goarch }}) + needs: release + runs-on: ${{ matrix.runner }} + env: + GOARCH: ${{ matrix.goarch }} + GOFLAGS: -buildvcs=false + GOWORK: off + NEXT_TAG: ${{ needs.release.outputs.next_tag }} + RELEASE_NAME: ${{ needs.release.outputs.release_name }} + strategy: + fail-fast: false + matrix: + include: + - runner: macos-26-intel + goarch: amd64 + - runner: macos-26 + goarch: arm64 + steps: + - name: Checkout release tag + uses: actions/checkout@v6 + with: + ref: ${{ needs.release.outputs.next_tag }} + fetch-depth: 1 + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version-file: go.mod + cache: true + cache-dependency-path: go.sum + - name: Build macOS archive + id: package + run: | + set -euo pipefail + commit_sha="$(git rev-parse HEAD)" + archive="devctl_${RELEASE_NAME}_darwin_${GOARCH}.tar.gz" + mkdir -p dist/macos + CGO_ENABLED=0 go build \ + -trimpath \ + -ldflags "-s -w -X main.version=${RELEASE_NAME} -X main.commit=${commit_sha}" \ + -o dist/macos/devctl ./cmd/devctl + cp README.md LICENSE dist/macos/ + tar -C dist/macos -czf "${archive}" devctl README.md LICENSE + echo "archive=${archive}" >> "$GITHUB_OUTPUT" + - name: Upload macOS archive + uses: actions/upload-artifact@v7 + with: + name: release-macos-${{ matrix.goarch }} + path: ${{ steps.package.outputs.archive }} + retention-days: 1 + if-no-files-found: error + + publish-release: + name: Publish release + needs: + - release + - linux-artifacts + - macos-artifacts + runs-on: ubuntu-latest + env: + NEXT_TAG: ${{ needs.release.outputs.next_tag }} + RELEASE_NAME: ${{ needs.release.outputs.release_name }} + PRERELEASE: ${{ needs.release.outputs.prerelease }} + MAKE_LATEST: ${{ needs.release.outputs.make_latest }} + NOTES_PATH: ${{ github.workspace }}/release-notes.md + defaults: + run: + shell: bash + steps: + - name: Checkout release tag + uses: actions/checkout@v6 + with: + ref: ${{ needs.release.outputs.next_tag }} + fetch-depth: 100 + fetch-tags: true + - name: Install git-cliff + uses: taiki-e/install-action@git-cliff + - name: Generate release notes + run: | + set -euo pipefail + ignore_rc_tags='^v[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+$' + if [[ "${NEXT_TAG}" =~ -rc\.[0-9]+$ ]]; then + git-cliff --current --output "${NOTES_PATH}" + else + git-cliff --current --ignore-tags "${ignore_rc_tags}" --output "${NOTES_PATH}" + fi + { + echo "# Release Notes" + echo + cat "${NOTES_PATH}" + } >> "$GITHUB_STEP_SUMMARY" + - name: Download release artifacts + uses: actions/download-artifact@v8 + with: + pattern: release-* + path: release-assets + merge-multiple: true + - name: Publish GitHub Release + uses: softprops/action-gh-release@v3 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + tag_name: ${{ env.NEXT_TAG }} + name: ${{ env.RELEASE_NAME }} + body_path: ${{ env.NOTES_PATH }} + prerelease: ${{ env.PRERELEASE }} + make_latest: ${{ env.MAKE_LATEST }} + working_directory: release-assets + draft: true + files: | + *.tar.gz diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2e7e200 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.devctl-tmp/ +.scratch/ +dist/ +.idea/ diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..8f9457e --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,79 @@ +version: "2" + +run: + relative-path-mode: gomod + tests: true + modules-download-mode: readonly + +linters: + default: none + enable: + - asasalint + - bidichk + - bodyclose + - containedctx + - contextcheck + - durationcheck + - errcheck + - errchkjson + - errname + - errorlint + - exhaustive + - fatcontext + - forcetypeassert + - gocheckcompilerdirectives + - gochecknoinits + - gocognit + - govet + - inamedparam + - ineffassign + - loggercheck + - makezero + - musttag + - nilerr + - nilnesserr + - noctx + - nolintlint + - nosprintfhostport + - paralleltest + - predeclared + - reassign + - recvcheck + - revive + - rowserrcheck + - sqlclosecheck + - staticcheck + - testifylint + - thelper + - tparallel + - unconvert + - unused + - usetesting + - wastedassign + - wrapcheck + settings: + gocognit: + min-complexity: 20 + nolintlint: + allow-unused: false + require-explanation: true + require-specific: true + paralleltest: + ignore-missing: false + ignore-missing-subtests: false + check-cleanup: true + revive: + rules: + - name: argument-limit + arguments: [4] + - name: function-result-limit + arguments: [3] + wrapcheck: + ignore-sig-regexps: + # ReportError already returns a delivery-owned terminal error. Wrapping it + # would make the error visible to urfave/cli and duplicate the log event. + - 'ErrorReporter\)\.ReportError\(' + +formatters: + enable: + - gofmt diff --git a/.mise.toml b/.mise.toml new file mode 100644 index 0000000..3c8fa1d --- /dev/null +++ b/.mise.toml @@ -0,0 +1,42 @@ +[tools] +go = "1.26.0" +golangci-lint = "2.12.2" +node = "24" +"npm:quicktype" = "26.0.0" + +[tasks.fmt] +run = "golangci-lint fmt" + +[tasks."fmt:check"] +run = "golangci-lint fmt --diff" + +[tasks.lint] +run = "golangci-lint run" + +[tasks.test] +run = "GOWORK=off go test ./..." + +[tasks."test:race"] +run = "GOWORK=off go test -race ./..." + +[tasks.build] +run = "GOWORK=off go build -trimpath -o \"${TMPDIR:-/tmp}/devctl-check\" ./cmd/devctl" + +[tasks."mod:check"] +run = "GOWORK=off go mod tidy -diff" + +[tasks.e2e] +run = "GOWORK=off go test -tags=e2e ./e2e -v" + +[tasks.check] +depends = ["fmt:check", "lint", "test:race", "build", "mod:check", "docs:check", "example:orders:check", "e2e"] + +[tasks."docs:generate"] +run = "GOWORK=off go run ./cmd/devctl/internal/docgen" + +[tasks."docs:check"] +run = "GOWORK=off go run ./cmd/devctl/internal/docgen --check" + +[tasks."example:orders:check"] +dir = "examples/orders-api" +run = "GOWORK=off go test ./..." diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..bad2f35 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,9 @@ +## Agent skills + +### Issue tracker + +Issues and specs are tracked as local Markdown files under `.scratch/`. See `docs/agents/issue-tracker.md`. + +### Domain docs + +This repo uses a single-context domain docs layout. See `docs/agents/domain.md`. diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..c73643b --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,134 @@ +# Devctl ubiquitous language + +Devctl is a non-interactive control plane for defining, validating, and +materializing reproducible Go projects. These are the canonical terms used by +manifests, commands, diagnostics, and architecture documents. + +## Project model + +**Project**: +A repository rooted at a selected Manifest and managed through Devctl +workflows. + +**Manifest**: +The canonical desired-state document that names a Project and declares its +Components, Resources, paths, and generator policy. +_Avoid_: configuration file, when referring to the complete Project model + +**Component**: +An application-facing area such as HTTP, gRPC, Kafka, logging, or telemetry. +A Component can contain Capabilities and related Resources. + +**Capability**: +Enableable runtime behavior such as an HTTP server, health endpoint, or +telemetry. A Capability may have a Runtime Start Policy. + +**Resource**: +A named infrastructure dependency declared by a Project, such as a database, +Redis connection, S3 bucket, or client. + +**Connection**: +A named access configuration for an external system. A Connection can expose +one or more Variants. + +**Variant**: +One concrete backend form of a logical Resource, such as SQLite, PostgreSQL, +or ClickHouse for a database connection. + +## Effective project model + +**Target**: +A stable, addressable effective unit on which `sync`, `lint`, or `gen` can +operate, such as `http-client:payments`. A Target ID is its CLI address. + +**Target Catalog**: +The immutable, deterministically ordered projection of one Manifest into +effective Targets. It is the canonical source of Target identities, inputs, +outputs, references, and operation capabilities. + +**Logical Input**: +The Manifest-derived Contract selection recorded by a Target before external +state is materialized. + +**Resolved Input**: +The concrete Contract entrypoint or module root selected from valid committed +Snapshot Metadata. + +**Readiness**: +The Project state required to execute its declared toolchain, including local +files, native generator configs, module tools, and task declarations. + +## Contracts + +**Contract**: +A stable description of an API or event surface: OpenAPI, Proto, JSON Schema, +or a raw Kafka message convention. + +**Source**: +A named origin and containment root for Contracts. A Source is local, +URL-based, Git-based, or another Devctl Project. + +**Contract Reference**: +A Component's selection of a Contract from a Source, either by a relative +Entrypoint or by a named Export. + +**Export**: +A named Contract surface published by one Devctl Project for consumption by +another. + +**Contract Snapshot**: +An exact, self-contained Contract closure selected from a Source, including +its Entrypoint, Module Root, files, and Snapshot Metadata when applicable. + +**Entrypoint**: +The contained file through which a file-based Contract is interpreted. +_Avoid_: first file, main file + +**Module Root**: +The contained directory that defines a multi-file Contract module, especially +a Proto module. +_Avoid_: entrypoint, when the Contract is selected as a module + +**Snapshot Metadata**: +Committed, machine-readable facts required to interpret a Managed External +Contract without rediscovering its structure or contacting its supplier. + +**Managed External Contract**: +A Contract Snapshot published by `sync` into a Project-owned namespace and +committed for review. Offline workflows consume this committed state. + +## Ownership and runtime + +**Managed Output**: +A file or complete tree whose contents belong entirely to Devctl and may be +atomically replaced or pruned by its owning workflow. + +**Scaffold Seed**: +A file created once by scaffold and owned by the user afterwards, such as an +application entrypoint, provider binding, or handler. +_Avoid_: create-only artifact, in user-facing language + +**Provider Binding**: +User-owned composition code that selects concrete application behavior or +adapts generated infrastructure to application types. + +**Canonical DI Key**: +A stable, namespaced identifier for one named runtime dependency or runner. +_Avoid_: ad hoc string key + +**Scenario**: +An executable application mode that owns a composed dependency graph, its +selected runtime work, and orderly shutdown. + +**Runtime Config**: +The immutable, typed catalog of effective environment-backed settings derived +from the Manifest. Migration-only environment is not application Runtime +Config. + +**Runtime Start Policy**: +An optional environment-backed gate for a Capability. It distinguishes +construction of a runtime dependency from starting its background work. + +**Migration Target**: +The migration path and migration-only environment belonging to one SQLite, +PostgreSQL, or ClickHouse database Variant. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..3a9ef96 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,26 @@ +FROM golang:1.26-alpine AS builder + +ARG VERSION=dev +ARG COMMIT + +WORKDIR /src +COPY . . +RUN CGO_ENABLED=0 go build \ + -buildvcs=false \ + -trimpath \ + -ldflags "-s -w -X main.version=${VERSION} -X main.commit=${COMMIT}" \ + -o /devctl ./cmd/devctl + +FROM node:24-alpine + +RUN apk add --no-cache ca-certificates git \ + && npm install --global --no-update-notifier --no-fund quicktype@26.0.0 \ + && npm cache clean --force + +COPY --from=builder /usr/local/go /usr/local/go +COPY --from=builder /devctl /devctl +RUN ln -s /devctl /usr/local/bin/devctl + +ENV PATH="/usr/local/go/bin:/go/bin:/usr/local/bin:${PATH}" + +ENTRYPOINT ["/devctl"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..5c45a44 --- /dev/null +++ b/README.md @@ -0,0 +1,90 @@ +# Devctl + +`devctl` is a non-interactive CLI for defining, validating, and materializing +reproducible Go projects. A checked-in `devctl.yaml` Manifest describes the +Project, its runtime Capabilities and Resources, its API and event Contracts, +and the project-owned tools used to generate code. + +Devctl keeps each workflow explicit: changing the Manifest does not install +tools or rewrite application code, and `sync`, `lint`, and `gen` never invoke +one another implicitly. + +## Requirements + +- Go 1.26 +- Git for `git` and `devctl` sources +- [Mise](https://mise.jdx.dev/) for scaffolded toolchains and quality tasks +- Docker for the optional local PostgreSQL walkthrough or the published Devctl image + +## Install + +Install the latest published Go module: + +```sh +go install github.com/devctllabs/devctl/cmd/devctl@latest +devctl --version +``` + +## Start a Project + +```sh +mkdir orders-api +cd orders-api + +devctl init manifest \ + --lang go \ + --preset http-service \ + --name orders-api \ + --module example.com/orders-api + +devctl add db primary --kind postgres +devctl init scaffold +mise install +go mod tidy +devctl validate +``` + +The published container image is available at +`ghcr.io/devctllabs/devctl`. Stable releases provide both a SemVer tag and +`latest`; release candidates provide only their version tag: + +```sh +docker run --rm ghcr.io/devctllabs/devctl:latest --version +``` + +The image includes Devctl, Go, Git, Node, and quicktype so a mounted Project +can use Devctl's `sync`, `lint`, and `gen` workflows. Project-declared Go tools +remain owned by that Project and are resolved from its `go.mod`. + +Continue with the [HTTP and PostgreSQL getting started +guide](docs/user-guide/getting-started.md) to define an Orders API, apply a +migration, implement the generated server interface, and make a real database +round trip. + +The normal Project lifecycle is: + +```text +init or mutate Manifest -> scaffold -> validate -> sync -> lint -> gen +``` + +Only run the steps required by a change. For example, a local server Contract +does not need `sync`, while a changed external Contract does. + +## Documentation + +- [User guide](docs/user-guide/README.md) +- [Command reference](docs/user-guide/reference/commands.md) +- [Manifest reference](docs/user-guide/reference/manifest/README.md) +- [Output and error contract](docs/user-guide/reference/output-and-errors.md) +- [Development guide](docs/development.md) + +Release artifacts are published as `devctl___.tar.gz` for +Linux amd64 and macOS amd64/arm64. Releases use stable and RC lines and are +created as draft GitHub Releases for manual publication. + +The documentation on `main` describes the current source tree. For a released +binary, read the documentation at the matching Git tag. + +## License + +Apache-2.0. See [LICENSE](LICENSE). diff --git a/cliff.toml b/cliff.toml new file mode 100644 index 0000000..6e436d3 --- /dev/null +++ b/cliff.toml @@ -0,0 +1,40 @@ +# https://git-cliff.org/docs/configuration + +[changelog] + +body = """ +{% for group, commits in commits | group_by(attribute="group") %} +## {{ group | striptags | trim }} + +{% for commit in commits -%} +- {% if commit.scope %}*({{ commit.scope }})* {% endif %}{% if commit.breaking %}[**breaking**] {% endif %}{{ commit.message | upper_first }} +{% endfor -%} + +{% endfor %} +""" + +trim = true + +[bump] +initial_tag = "v0.1.0" +breaking_always_bump_major = false + +[git] +conventional_commits = true +tag_pattern = "^v[0-9]+\.[0-9]+\.[0-9]+(?:-rc\.[0-9]+)?$" + +commit_parsers = [ + { message = "^feat", group = "New Features" }, + { message = "^fix", group = "Bug Fixes" }, + { message = "^docs", group = "Documentation" }, + { message = "^refactor", group = "Refactoring" }, + { message = "^perf", group = "Performance" }, + { message = "^ci", group = "CI" }, + { message = "^test", group = "Tests" }, + { message = "^(build|chore)", group = "Chores" }, + { message = ".*", group = "Other" }, +] + +filter_unconventional = true +sort_commits = "oldest" +topo_order = false diff --git a/cmd/devctl/internal/add/add.go b/cmd/devctl/internal/add/add.go new file mode 100644 index 0000000..73023ec --- /dev/null +++ b/cmd/devctl/internal/add/add.go @@ -0,0 +1,23 @@ +package add + +import "github.com/urfave/cli/v3" + +// NewCmd constructs the namespace for project resource additions. +func NewCmd() *cli.Command { + return &cli.Command{ + Name: "add", + Usage: "Add a named Project resource", + Description: "Add or update a named Source, client, Kafka endpoint, database Variant, Redis Connection, S3 Connection, or S3 bucket in the Manifest. This command changes only devctl.yaml.", + Commands: []*cli.Command{ + newDBCmd(dbCmdOpts{}, buildDB), + newSourceCmd(sourceCmdOpts{}, buildSource), + newHTTPClientCmd(httpClientCmdOpts{}, buildHTTPClient), + newGRPCClientCmd(grpcClientCmdOpts{}, buildGRPCClient), + newKafkaConsumerCmd(kafkaCmdOpts{}, buildKafka), + newKafkaProducerCmd(kafkaCmdOpts{}, buildKafka), + newRedisCmd(storageCmdOpts{}, buildStorage), + newS3ConnectionCmd(storageCmdOpts{}, buildStorage), + newS3Cmd(storageCmdOpts{}, buildStorage), + }, + } +} diff --git a/cmd/devctl/internal/add/add_test.go b/cmd/devctl/internal/add/add_test.go new file mode 100644 index 0000000..7b99e34 --- /dev/null +++ b/cmd/devctl/internal/add/add_test.go @@ -0,0 +1,44 @@ +package add + +import ( + "bytes" + "context" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestAddSourceWritesManifestChangeJSON(t *testing.T) { + t.Parallel() + manifestPath := writeManifest(t) + command := newSourceCmd(sourceCmdOpts{}, buildSource) + var stdout bytes.Buffer + command.Writer = &stdout + + err := command.Run(context.Background(), []string{ + "source", "catalog", "--file", manifestPath, "--json", "--type", "local", "--path", "api/catalog.yaml", + }) + + require.NoError(t, err) + require.Contains(t, stdout.String(), `"msg":"project resource addition completed"`) + require.Contains(t, stdout.String(), `"command":"source"`) + require.Contains(t, stdout.String(), `"data":{"manifest":"`+manifestPath+`","change":"updated"}`) +} + +func writeManifest(t *testing.T) string { + t.Helper() + path := filepath.Join(t.TempDir(), "devctl.yaml") + require.NoError(t, os.WriteFile(path, []byte(`version: 1 +project: {name: sample, language: go} +env: {} +paths: {} +sources: {} +exports: {} +components: {} +languages: + go: {module: example.test/sample} +`), 0o644)) + return path +} diff --git a/cmd/devctl/internal/add/db.go b/cmd/devctl/internal/add/db.go new file mode 100644 index 0000000..5833830 --- /dev/null +++ b/cmd/devctl/internal/add/db.go @@ -0,0 +1,115 @@ +package add + +import ( + "context" + "errors" + "fmt" + + commandruntime "github.com/devctllabs/devctl/cmd/devctl/internal/command" + "github.com/devctllabs/devctl/internal/deps" + projectdomain "github.com/devctllabs/devctl/internal/domain/project" + "github.com/urfave/cli/v3" + "go.uber.org/zap" +) + +//go:generate go tool mockgen -destination mocks/db.go -package mocks -typed . databaseAdder + +type databaseAdder interface { + // AddDB adds or updates one named database variant. + AddDB(ctx context.Context, command projectdomain.AddDBCommand) (projectdomain.ManifestResult, error) +} + +// dbRuntime contains the application port and cleanup hook used by Action. +type dbRuntime struct { + adder databaseAdder + shutdown func(context.Context) error +} + +// dbBuilder isolates dependency construction from database mutation behavior. +type dbBuilder func(context.Context, *zap.Logger) (dbRuntime, error) + +// dbCmd owns parsed database options and the runtime factory. +type dbCmd struct { + opts dbCmdOpts + buildRuntime dbBuilder +} + +// dbCmdOpts receives the positional name, common flags, and database settings. +type dbCmdOpts struct { + commandruntime.CommonCmdOpts + Name string + Kind string + Default bool + NoMigrations bool + MigrationsPath string + Force bool +} + +// newDBCmd constructs the executable add db leaf. +func newDBCmd(opts dbCmdOpts, build dbBuilder) *cli.Command { + cmd := &dbCmd{opts: opts, buildRuntime: build} + noMigrationsFlag := &cli.BoolFlag{Name: "no-migrations", Usage: "do not declare a migration target for this Variant", Destination: &cmd.opts.NoMigrations} + migrationsPathFlag := &cli.StringFlag{Name: "migrations-path", Usage: "override the project-relative migration `path`", Destination: &cmd.opts.MigrationsPath} + flags := append(cmd.opts.CommonFlags(), + &cli.StringFlag{Name: "kind", Usage: "select `sqlite`, `postgres`, or `clickhouse`", Destination: &cmd.opts.Kind}, + &cli.BoolFlag{Name: "default", Usage: "make this Variant the Connection default", Destination: &cmd.opts.Default}, + &cli.BoolFlag{Name: "force", Usage: "replace an existing Variant with the same identity", Destination: &cmd.opts.Force}, + ) + return &cli.Command{ + Name: "db", + Usage: "Add a database variant", + Description: "Add a SQLite, PostgreSQL, or ClickHouse Variant to a named database Connection. A migration target is declared by default; Devctl never writes SQL or applies migrations.", + UsageText: "devctl add db --kind ", + UseShortOptionHandling: true, + Arguments: []cli.Argument{&cli.StringArg{ + Name: "database-name", UsageText: "", Destination: &cmd.opts.Name, + }}, + Flags: flags, + MutuallyExclusiveFlags: []cli.MutuallyExclusiveFlags{{Flags: [][]cli.Flag{{noMigrationsFlag}, {migrationsPathFlag}}}}, + OnUsageError: func(_ context.Context, command *cli.Command, err error, _ bool) error { + reporter := commandruntime.NewErrorReporter(cmd.opts.NewStderrLogger(command), cmd.opts.Verbose) + return reporter.ReportError(cli.Exit(err, 2)) + }, + Action: cmd.Action, + } +} + +// Action adds one named database variant to the selected manifest. +func (cmd *dbCmd) Action(ctx context.Context, command *cli.Command) error { + stdout := cmd.opts.NewStdoutLogger(command) + stderr := cmd.opts.NewStderrLogger(command) + reporter := commandruntime.NewErrorReporter(stderr, cmd.opts.Verbose) + if cmd.opts.Name == "" { + return reporter.ReportError(cli.Exit("database name is required", 2)) + } + if cmd.opts.Kind == "" { + return reporter.ReportError(cli.Exit("--kind is required", 2)) + } + runtime, err := cmd.buildRuntime(ctx, stderr) + if err != nil { + return reporter.ReportError(err) + } + result, operationErr := runtime.adder.AddDB(ctx, projectdomain.AddDBCommand{ + ManifestPath: cmd.opts.ManifestPath, Name: cmd.opts.Name, Kind: cmd.opts.Kind, + Default: cmd.opts.Default, NoMigrations: cmd.opts.NoMigrations, + MigrationsPath: cmd.opts.MigrationsPath, Force: cmd.opts.Force, + }) + return finishManifestAddition(ctx, manifestAddition{ + stdout: stdout, reporter: reporter, shutdown: runtime.shutdown, + result: result, operationErr: operationErr, + }) +} + +// buildDB constructs and resolves the lazy dependencies owned by add db. +func buildDB(ctx context.Context, logger *zap.Logger) (dbRuntime, error) { + container, err := deps.New(logger) + if err != nil { + return dbRuntime{}, fmt.Errorf("deps.New: %w", err) + } + service, err := container.ProjectService() + if err != nil { + shutdownErr := commandruntime.Shutdown(ctx, container) + return dbRuntime{}, errors.Join(fmt.Errorf("container.ProjectService: %w", err), shutdownErr) + } + return dbRuntime{adder: service, shutdown: container.Shutdown}, nil +} diff --git a/cmd/devctl/internal/add/grpc_client.go b/cmd/devctl/internal/add/grpc_client.go new file mode 100644 index 0000000..3f3e0bc --- /dev/null +++ b/cmd/devctl/internal/add/grpc_client.go @@ -0,0 +1,112 @@ +package add + +import ( + "context" + "errors" + "fmt" + + commandruntime "github.com/devctllabs/devctl/cmd/devctl/internal/command" + "github.com/devctllabs/devctl/internal/deps" + projectdomain "github.com/devctllabs/devctl/internal/domain/project" + "github.com/urfave/cli/v3" + "go.uber.org/zap" +) + +type grpcClientAdder interface { + // AddGRPCClient adds or updates the gRPC client described by command. + AddGRPCClient(ctx context.Context, command projectdomain.AddGRPCClientCommand) (projectdomain.ManifestResult, error) +} + +type grpcClientRuntime struct { + adder grpcClientAdder + shutdown func(context.Context) error +} + +type grpcClientBuilder func(context.Context, *zap.Logger) (grpcClientRuntime, error) + +type grpcClientCmdOpts struct { + commandruntime.CommonCmdOpts + Name string + Source string + Export string + Path string + ProtoRoot string + BufGenConfig string + AddrEnv string + Force bool +} + +type grpcClientCmd struct { + opts grpcClientCmdOpts + buildRuntime grpcClientBuilder +} + +func newGRPCClientCmd(opts grpcClientCmdOpts, build grpcClientBuilder) *cli.Command { + cmd := &grpcClientCmd{opts: opts, buildRuntime: build} + flags := append(cmd.opts.CommonFlags(), + &cli.StringFlag{Name: "source", Usage: "select the named contract `source`", Destination: &cmd.opts.Source}, + &cli.StringFlag{Name: "export", Usage: "select a named Export from a Devctl Source", Destination: &cmd.opts.Export}, + &cli.StringFlag{Name: "path", Usage: "select the Contract path from a non-Devctl Source", Destination: &cmd.opts.Path}, + &cli.StringFlag{Name: "proto-root", Usage: "set the Source-relative Proto `module-root`", Destination: &cmd.opts.ProtoRoot}, + &cli.StringFlag{Name: "buf-gen-config", Usage: "use the project-owned generator `config-path`", Destination: &cmd.opts.BufGenConfig}, + &cli.StringFlag{Name: "addr-env", Usage: "override the generated runtime address environment `key`", Destination: &cmd.opts.AddrEnv}, + &cli.BoolFlag{Name: "force", Usage: "replace an existing gRPC client with the same name", Destination: &cmd.opts.Force}, + ) + return &cli.Command{ + Name: "grpc-client", + Usage: "Add a gRPC client", + Description: "Declare a named Proto client Target. Use --path with ordinary Sources or --export with a Devctl Source; custom generator configs remain user-owned.", + UsageText: "devctl add grpc-client --source (--path | --export )", + Arguments: []cli.Argument{&cli.StringArg{ + Name: "grpc-client-name", UsageText: "", Destination: &cmd.opts.Name, + }}, + Flags: flags, + Action: cmd.Action, + } +} + +func (cmd *grpcClientCmd) Action(ctx context.Context, command *cli.Command) error { + stdout := cmd.opts.NewStdoutLogger(command) + stderr := cmd.opts.NewStderrLogger(command) + reporter := commandruntime.NewErrorReporter(stderr, cmd.opts.Verbose) + if cmd.opts.Name == "" { + return reporter.ReportError(cli.Exit("gRPC client name is required", 2)) + } + if cmd.opts.Source == "" { + return reporter.ReportError(cli.Exit("--source is required", 2)) + } + runtime, err := cmd.buildRuntime(ctx, stderr) + if err != nil { + return reporter.ReportError(err) + } + result, operationErr := runtime.adder.AddGRPCClient(ctx, projectdomain.AddGRPCClientCommand{ + ManifestPath: cmd.opts.ManifestPath, + Name: cmd.opts.Name, + Source: cmd.opts.Source, + Export: cmd.opts.Export, + Path: cmd.opts.Path, + ProtoRoot: cmd.opts.ProtoRoot, + BufGenConfig: cmd.opts.BufGenConfig, + AddrEnv: cmd.opts.AddrEnv, + Force: cmd.opts.Force, + }) + return finishManifestAddition(ctx, manifestAddition{ + stdout: stdout, reporter: reporter, shutdown: runtime.shutdown, + result: result, operationErr: operationErr, + }) +} + +func buildGRPCClient(ctx context.Context, logger *zap.Logger) (grpcClientRuntime, error) { + container, err := deps.New(logger) + if err != nil { + return grpcClientRuntime{}, fmt.Errorf("deps.New: %w", err) + } + service, err := container.ProjectService() + if err != nil { + return grpcClientRuntime{}, errors.Join( + fmt.Errorf("container.ProjectService: %w", err), + commandruntime.Shutdown(ctx, container), + ) + } + return grpcClientRuntime{adder: service, shutdown: container.Shutdown}, nil +} diff --git a/cmd/devctl/internal/add/http_client.go b/cmd/devctl/internal/add/http_client.go new file mode 100644 index 0000000..c370679 --- /dev/null +++ b/cmd/devctl/internal/add/http_client.go @@ -0,0 +1,113 @@ +package add + +import ( + "context" + "errors" + "fmt" + + commandruntime "github.com/devctllabs/devctl/cmd/devctl/internal/command" + "github.com/devctllabs/devctl/internal/deps" + projectdomain "github.com/devctllabs/devctl/internal/domain/project" + "github.com/urfave/cli/v3" + "go.uber.org/zap" +) + +//go:generate go tool mockgen -destination mocks/http_client.go -package mocks -typed . httpClientAdder + +type httpClientAdder interface { + // AddHTTPClient adds or updates one named generated HTTP client. + AddHTTPClient(ctx context.Context, command projectdomain.AddHTTPClientCommand) (projectdomain.ManifestResult, error) +} + +// httpClientRuntime contains the application port and cleanup hook used by Action. +type httpClientRuntime struct { + adder httpClientAdder + shutdown func(context.Context) error +} + +// httpClientBuilder isolates dependency construction from HTTP client mutation behavior. +type httpClientBuilder func(context.Context, *zap.Logger) (httpClientRuntime, error) + +// httpClientCmd owns parsed client options and the runtime factory. +type httpClientCmd struct { + opts httpClientCmdOpts + buildRuntime httpClientBuilder +} + +// httpClientCmdOpts receives the positional name, common flags, and contract selection. +type httpClientCmdOpts struct { + commandruntime.CommonCmdOpts + Name string + Source string + Export string + Path string + BaseURLEnv string + Force bool +} + +// newHTTPClientCmd constructs the executable add http-client leaf. +func newHTTPClientCmd(opts httpClientCmdOpts, build httpClientBuilder) *cli.Command { + cmd := &httpClientCmd{opts: opts, buildRuntime: build} + flags := append(cmd.opts.CommonFlags(), + &cli.StringFlag{Name: "source", Usage: "select the named contract `source`", Destination: &cmd.opts.Source}, + &cli.StringFlag{Name: "export", Usage: "select a named Export from a Devctl Source", Destination: &cmd.opts.Export}, + &cli.StringFlag{Name: "path", Usage: "select an OpenAPI Entrypoint from a non-Devctl Source", Destination: &cmd.opts.Path}, + &cli.StringFlag{Name: "base-url-env", Usage: "override the generated runtime base URL environment `key`", Destination: &cmd.opts.BaseURLEnv}, + &cli.BoolFlag{Name: "force", Usage: "replace an existing HTTP client with the same name", Destination: &cmd.opts.Force}, + ) + return &cli.Command{ + Name: "http-client", + Usage: "Add an HTTP client", + Description: "Declare a named OpenAPI client Target. Use --path with ordinary Sources or --export with a Devctl Source.", + UsageText: "devctl add http-client --source (--path | --export )", + UseShortOptionHandling: true, + Arguments: []cli.Argument{&cli.StringArg{ + Name: "http-client-name", UsageText: "", Destination: &cmd.opts.Name, + }}, + Flags: flags, + OnUsageError: func(_ context.Context, command *cli.Command, err error, _ bool) error { + reporter := commandruntime.NewErrorReporter(cmd.opts.NewStderrLogger(command), cmd.opts.Verbose) + return reporter.ReportError(cli.Exit(err, 2)) + }, + Action: cmd.Action, + } +} + +// Action adds one named HTTP client to the selected manifest. +func (cmd *httpClientCmd) Action(ctx context.Context, command *cli.Command) error { + stdout := cmd.opts.NewStdoutLogger(command) + stderr := cmd.opts.NewStderrLogger(command) + reporter := commandruntime.NewErrorReporter(stderr, cmd.opts.Verbose) + if cmd.opts.Name == "" { + return reporter.ReportError(cli.Exit("HTTP client name is required", 2)) + } + if cmd.opts.Source == "" { + return reporter.ReportError(cli.Exit("--source is required", 2)) + } + runtime, err := cmd.buildRuntime(ctx, stderr) + if err != nil { + return reporter.ReportError(err) + } + result, operationErr := runtime.adder.AddHTTPClient(ctx, projectdomain.AddHTTPClientCommand{ + ManifestPath: cmd.opts.ManifestPath, Name: cmd.opts.Name, Source: cmd.opts.Source, + Export: cmd.opts.Export, Path: cmd.opts.Path, BaseURLEnv: cmd.opts.BaseURLEnv, Force: cmd.opts.Force, + }) + return finishManifestAddition(ctx, manifestAddition{ + stdout: stdout, reporter: reporter, shutdown: runtime.shutdown, + result: result, operationErr: operationErr, + }) +} + +// buildHTTPClient constructs and resolves the lazy dependencies owned by add http-client. +func buildHTTPClient(ctx context.Context, logger *zap.Logger) (httpClientRuntime, error) { + container, err := deps.New(logger) + if err != nil { + return httpClientRuntime{}, fmt.Errorf("deps.New: %w", err) + } + service, err := container.ProjectService() + if err != nil { + shutdownErr := commandruntime.Shutdown(ctx, container) + return httpClientRuntime{}, errors.Join(fmt.Errorf("container.ProjectService: %w", err), shutdownErr) + } + return httpClientRuntime{adder: service, shutdown: container.Shutdown}, nil +} diff --git a/cmd/devctl/internal/add/kafka.go b/cmd/devctl/internal/add/kafka.go new file mode 100644 index 0000000..d216e94 --- /dev/null +++ b/cmd/devctl/internal/add/kafka.go @@ -0,0 +1,150 @@ +package add + +import ( + "context" + "errors" + "fmt" + + commandruntime "github.com/devctllabs/devctl/cmd/devctl/internal/command" + "github.com/devctllabs/devctl/internal/deps" + projectdomain "github.com/devctllabs/devctl/internal/domain/project" + "github.com/urfave/cli/v3" + "go.uber.org/zap" +) + +type kafkaAdder interface { + // AddKafkaConsumer adds or updates the Kafka consumer described by command. + AddKafkaConsumer(ctx context.Context, command projectdomain.AddKafkaConsumerCommand) (projectdomain.ManifestResult, error) + // AddKafkaProducer adds or updates the Kafka producer described by command. + AddKafkaProducer(ctx context.Context, command projectdomain.AddKafkaProducerCommand) (projectdomain.ManifestResult, error) +} + +type kafkaRuntime struct { + adder kafkaAdder + shutdown func(context.Context) error +} + +type kafkaBuilder func(context.Context, *zap.Logger) (kafkaRuntime, error) + +type kafkaCmdOpts struct { + commandruntime.CommonCmdOpts + Name string + Topic string + Source string + Export string + Path string + Format string + ProtoRoot string + Message string + Encoding string + GroupEnv string + TopicEnv string + Always bool + Force bool +} + +type kafkaCmd struct { + opts kafkaCmdOpts + consumer bool + buildRuntime kafkaBuilder +} + +func newKafkaConsumerCmd(opts kafkaCmdOpts, build kafkaBuilder) *cli.Command { + return newKafkaCmd("kafka-consumer", true, opts, build) +} +func newKafkaProducerCmd(opts kafkaCmdOpts, build kafkaBuilder) *cli.Command { + return newKafkaCmd("kafka-producer", false, opts, build) +} + +func newKafkaCmd(name string, consumer bool, opts kafkaCmdOpts, build kafkaBuilder) *cli.Command { + cmd := &kafkaCmd{opts: opts, consumer: consumer, buildRuntime: build} + flags := append(cmd.opts.CommonFlags(), + &cli.StringFlag{Name: "topic", Usage: "set the Kafka `topic`", Destination: &cmd.opts.Topic}, + &cli.StringFlag{Name: "source", Usage: "select the named contract `source`", Destination: &cmd.opts.Source}, + &cli.StringFlag{Name: "export", Usage: "select a named Export from a Devctl Source", Destination: &cmd.opts.Export}, + &cli.StringFlag{Name: "path", Usage: "select the schema Entrypoint from a non-Devctl Source", Destination: &cmd.opts.Path}, + &cli.StringFlag{Name: "format", Usage: "select `raw`, `json`, or `proto`", Destination: &cmd.opts.Format}, + &cli.StringFlag{Name: "proto-root", Usage: "set the Source-relative Proto `module-root`", Destination: &cmd.opts.ProtoRoot}, + &cli.StringFlag{Name: "message", Usage: "select the fully-qualified Proto `message`", Destination: &cmd.opts.Message}, + &cli.StringFlag{Name: "encoding", Usage: "select Proto `binary` or `json` encoding", Destination: &cmd.opts.Encoding}, + ) + if consumer { + flags = append(flags, + &cli.StringFlag{Name: "group-env", Usage: "override the consumer group environment `key`", Destination: &cmd.opts.GroupEnv}, + &cli.BoolFlag{Name: "always", Usage: "omit the Runtime Start Policy so the consumer is always enabled", Destination: &cmd.opts.Always}, + ) + } else { + flags = append(flags, &cli.StringFlag{Name: "topic-env", Usage: "override the producer topic environment `key`", Destination: &cmd.opts.TopicEnv}) + } + flags = append(flags, &cli.BoolFlag{Name: "force", Usage: "replace an existing Kafka endpoint with the same name", Destination: &cmd.opts.Force}) + return &cli.Command{ + Name: name, + Usage: "Add a Kafka endpoint", + Description: "Declare a named Kafka endpoint and its raw, JSON Schema, or Proto Contract. Schema-backed endpoints use --path for ordinary Sources or --export for Devctl Sources.", + UsageText: "devctl add " + name + " <" + name + "-name> --topic --format [contract flags]", + Arguments: []cli.Argument{&cli.StringArg{ + Name: name + "-name", UsageText: "<" + name + "-name>", Destination: &cmd.opts.Name, + }}, + Flags: flags, + Action: cmd.Action, + } +} + +func (cmd *kafkaCmd) Action(ctx context.Context, command *cli.Command) error { + stdout := cmd.opts.NewStdoutLogger(command) + stderr := cmd.opts.NewStderrLogger(command) + reporter := commandruntime.NewErrorReporter(stderr, cmd.opts.Verbose) + if cmd.opts.Name == "" { + return reporter.ReportError(cli.Exit("Kafka endpoint name is required", 2)) + } + if cmd.opts.Topic == "" { + return reporter.ReportError(cli.Exit("--topic is required", 2)) + } + runtime, err := cmd.buildRuntime(ctx, stderr) + if err != nil { + return reporter.ReportError(err) + } + result, operationErr := cmd.add(ctx, runtime.adder) + return finishManifestAddition(ctx, manifestAddition{stdout: stdout, reporter: reporter, shutdown: runtime.shutdown, result: result, operationErr: operationErr}) +} + +func (cmd *kafkaCmd) add(ctx context.Context, adder kafkaAdder) (projectdomain.ManifestResult, error) { + if cmd.consumer { + result, err := adder.AddKafkaConsumer(ctx, projectdomain.AddKafkaConsumerCommand{ + ManifestPath: cmd.opts.ManifestPath, Name: cmd.opts.Name, Topic: cmd.opts.Topic, + Source: cmd.opts.Source, Export: cmd.opts.Export, Path: cmd.opts.Path, + Format: cmd.opts.Format, ProtoRoot: cmd.opts.ProtoRoot, Message: cmd.opts.Message, + Encoding: cmd.opts.Encoding, GroupEnv: cmd.opts.GroupEnv, + Always: cmd.opts.Always, Force: cmd.opts.Force, + }) + if err != nil { + return result, fmt.Errorf("adder.AddKafkaConsumer: %w", err) + } + return result, nil + } + result, err := adder.AddKafkaProducer(ctx, projectdomain.AddKafkaProducerCommand{ + ManifestPath: cmd.opts.ManifestPath, Name: cmd.opts.Name, Topic: cmd.opts.Topic, + Source: cmd.opts.Source, Export: cmd.opts.Export, Path: cmd.opts.Path, + Format: cmd.opts.Format, ProtoRoot: cmd.opts.ProtoRoot, Message: cmd.opts.Message, + Encoding: cmd.opts.Encoding, TopicEnv: cmd.opts.TopicEnv, Force: cmd.opts.Force, + }) + if err != nil { + return result, fmt.Errorf("adder.AddKafkaProducer: %w", err) + } + return result, nil +} + +func buildKafka(ctx context.Context, logger *zap.Logger) (kafkaRuntime, error) { + container, err := deps.New(logger) + if err != nil { + return kafkaRuntime{}, fmt.Errorf("deps.New: %w", err) + } + service, err := container.ProjectService() + if err != nil { + return kafkaRuntime{}, errors.Join( + fmt.Errorf("container.ProjectService: %w", err), + commandruntime.Shutdown(ctx, container), + ) + } + return kafkaRuntime{adder: service, shutdown: container.Shutdown}, nil +} diff --git a/cmd/devctl/internal/add/mocks/db.go b/cmd/devctl/internal/add/mocks/db.go new file mode 100644 index 0000000..e6d4d2f --- /dev/null +++ b/cmd/devctl/internal/add/mocks/db.go @@ -0,0 +1,81 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/devctllabs/devctl/cmd/devctl/internal/add (interfaces: databaseAdder) +// +// Generated by this command: +// +// mockgen -destination mocks/db.go -package mocks -typed . databaseAdder +// + +// Package mocks is a generated GoMock package. +package mocks + +import ( + context "context" + reflect "reflect" + + project "github.com/devctllabs/devctl/internal/domain/project" + gomock "go.uber.org/mock/gomock" +) + +// MockdatabaseAdder is a mock of databaseAdder interface. +type MockdatabaseAdder struct { + ctrl *gomock.Controller + recorder *MockdatabaseAdderMockRecorder + isgomock struct{} +} + +// MockdatabaseAdderMockRecorder is the mock recorder for MockdatabaseAdder. +type MockdatabaseAdderMockRecorder struct { + mock *MockdatabaseAdder +} + +// NewMockdatabaseAdder creates a new mock instance. +func NewMockdatabaseAdder(ctrl *gomock.Controller) *MockdatabaseAdder { + mock := &MockdatabaseAdder{ctrl: ctrl} + mock.recorder = &MockdatabaseAdderMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockdatabaseAdder) EXPECT() *MockdatabaseAdderMockRecorder { + return m.recorder +} + +// AddDB mocks base method. +func (m *MockdatabaseAdder) AddDB(ctx context.Context, command project.AddDBCommand) (project.ManifestResult, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "AddDB", ctx, command) + ret0, _ := ret[0].(project.ManifestResult) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// AddDB indicates an expected call of AddDB. +func (mr *MockdatabaseAdderMockRecorder) AddDB(ctx, command any) *MockdatabaseAdderAddDBCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddDB", reflect.TypeOf((*MockdatabaseAdder)(nil).AddDB), ctx, command) + return &MockdatabaseAdderAddDBCall{Call: call} +} + +// MockdatabaseAdderAddDBCall wrap *gomock.Call +type MockdatabaseAdderAddDBCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockdatabaseAdderAddDBCall) Return(arg0 project.ManifestResult, arg1 error) *MockdatabaseAdderAddDBCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockdatabaseAdderAddDBCall) Do(f func(context.Context, project.AddDBCommand) (project.ManifestResult, error)) *MockdatabaseAdderAddDBCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockdatabaseAdderAddDBCall) DoAndReturn(f func(context.Context, project.AddDBCommand) (project.ManifestResult, error)) *MockdatabaseAdderAddDBCall { + c.Call = c.Call.DoAndReturn(f) + return c +} diff --git a/cmd/devctl/internal/add/mocks/http_client.go b/cmd/devctl/internal/add/mocks/http_client.go new file mode 100644 index 0000000..135d797 --- /dev/null +++ b/cmd/devctl/internal/add/mocks/http_client.go @@ -0,0 +1,81 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/devctllabs/devctl/cmd/devctl/internal/add (interfaces: httpClientAdder) +// +// Generated by this command: +// +// mockgen -destination mocks/http_client.go -package mocks -typed . httpClientAdder +// + +// Package mocks is a generated GoMock package. +package mocks + +import ( + context "context" + reflect "reflect" + + project "github.com/devctllabs/devctl/internal/domain/project" + gomock "go.uber.org/mock/gomock" +) + +// MockhttpClientAdder is a mock of httpClientAdder interface. +type MockhttpClientAdder struct { + ctrl *gomock.Controller + recorder *MockhttpClientAdderMockRecorder + isgomock struct{} +} + +// MockhttpClientAdderMockRecorder is the mock recorder for MockhttpClientAdder. +type MockhttpClientAdderMockRecorder struct { + mock *MockhttpClientAdder +} + +// NewMockhttpClientAdder creates a new mock instance. +func NewMockhttpClientAdder(ctrl *gomock.Controller) *MockhttpClientAdder { + mock := &MockhttpClientAdder{ctrl: ctrl} + mock.recorder = &MockhttpClientAdderMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockhttpClientAdder) EXPECT() *MockhttpClientAdderMockRecorder { + return m.recorder +} + +// AddHTTPClient mocks base method. +func (m *MockhttpClientAdder) AddHTTPClient(ctx context.Context, command project.AddHTTPClientCommand) (project.ManifestResult, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "AddHTTPClient", ctx, command) + ret0, _ := ret[0].(project.ManifestResult) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// AddHTTPClient indicates an expected call of AddHTTPClient. +func (mr *MockhttpClientAdderMockRecorder) AddHTTPClient(ctx, command any) *MockhttpClientAdderAddHTTPClientCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddHTTPClient", reflect.TypeOf((*MockhttpClientAdder)(nil).AddHTTPClient), ctx, command) + return &MockhttpClientAdderAddHTTPClientCall{Call: call} +} + +// MockhttpClientAdderAddHTTPClientCall wrap *gomock.Call +type MockhttpClientAdderAddHTTPClientCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockhttpClientAdderAddHTTPClientCall) Return(arg0 project.ManifestResult, arg1 error) *MockhttpClientAdderAddHTTPClientCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockhttpClientAdderAddHTTPClientCall) Do(f func(context.Context, project.AddHTTPClientCommand) (project.ManifestResult, error)) *MockhttpClientAdderAddHTTPClientCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockhttpClientAdderAddHTTPClientCall) DoAndReturn(f func(context.Context, project.AddHTTPClientCommand) (project.ManifestResult, error)) *MockhttpClientAdderAddHTTPClientCall { + c.Call = c.Call.DoAndReturn(f) + return c +} diff --git a/cmd/devctl/internal/add/mocks/source.go b/cmd/devctl/internal/add/mocks/source.go new file mode 100644 index 0000000..512ce26 --- /dev/null +++ b/cmd/devctl/internal/add/mocks/source.go @@ -0,0 +1,81 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/devctllabs/devctl/cmd/devctl/internal/add (interfaces: sourceAdder) +// +// Generated by this command: +// +// mockgen -destination mocks/source.go -package mocks -typed . sourceAdder +// + +// Package mocks is a generated GoMock package. +package mocks + +import ( + context "context" + reflect "reflect" + + project "github.com/devctllabs/devctl/internal/domain/project" + gomock "go.uber.org/mock/gomock" +) + +// MocksourceAdder is a mock of sourceAdder interface. +type MocksourceAdder struct { + ctrl *gomock.Controller + recorder *MocksourceAdderMockRecorder + isgomock struct{} +} + +// MocksourceAdderMockRecorder is the mock recorder for MocksourceAdder. +type MocksourceAdderMockRecorder struct { + mock *MocksourceAdder +} + +// NewMocksourceAdder creates a new mock instance. +func NewMocksourceAdder(ctrl *gomock.Controller) *MocksourceAdder { + mock := &MocksourceAdder{ctrl: ctrl} + mock.recorder = &MocksourceAdderMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MocksourceAdder) EXPECT() *MocksourceAdderMockRecorder { + return m.recorder +} + +// AddSource mocks base method. +func (m *MocksourceAdder) AddSource(ctx context.Context, command project.AddSourceCommand) (project.ManifestResult, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "AddSource", ctx, command) + ret0, _ := ret[0].(project.ManifestResult) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// AddSource indicates an expected call of AddSource. +func (mr *MocksourceAdderMockRecorder) AddSource(ctx, command any) *MocksourceAdderAddSourceCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddSource", reflect.TypeOf((*MocksourceAdder)(nil).AddSource), ctx, command) + return &MocksourceAdderAddSourceCall{Call: call} +} + +// MocksourceAdderAddSourceCall wrap *gomock.Call +type MocksourceAdderAddSourceCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MocksourceAdderAddSourceCall) Return(arg0 project.ManifestResult, arg1 error) *MocksourceAdderAddSourceCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MocksourceAdderAddSourceCall) Do(f func(context.Context, project.AddSourceCommand) (project.ManifestResult, error)) *MocksourceAdderAddSourceCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MocksourceAdderAddSourceCall) DoAndReturn(f func(context.Context, project.AddSourceCommand) (project.ManifestResult, error)) *MocksourceAdderAddSourceCall { + c.Call = c.Call.DoAndReturn(f) + return c +} diff --git a/cmd/devctl/internal/add/result.go b/cmd/devctl/internal/add/result.go new file mode 100644 index 0000000..131deef --- /dev/null +++ b/cmd/devctl/internal/add/result.go @@ -0,0 +1,42 @@ +package add + +import ( + "context" + "errors" + "time" + + commandruntime "github.com/devctllabs/devctl/cmd/devctl/internal/command" + projectdomain "github.com/devctllabs/devctl/internal/domain/project" + "github.com/devctllabs/go-libs/lifecycle" + "go.uber.org/zap" +) + +// manifestResultDTO is the stable payload shared by manifest resource additions. +type manifestResultDTO struct { + Manifest string `json:"manifest"` + Change string `json:"change"` +} + +// manifestAddition carries one mutation outcome through the shared shutdown boundary. +type manifestAddition struct { + stdout *zap.Logger + reporter *commandruntime.ErrorReporter + shutdown func(context.Context) error + result projectdomain.ManifestResult + operationErr error +} + +// finishManifestAddition joins operation and shutdown outcomes before emitting one final event. +func finishManifestAddition(ctx context.Context, addition manifestAddition) error { + shutdownErr := lifecycle.Shutdown(ctx, 5*time.Second, addition.shutdown) + dto := manifestResultDTO{Manifest: addition.result.Manifest, Change: string(addition.result.Change)} + var options []commandruntime.ErrorOption + if addition.result.Change != "" { + options = append(options, commandruntime.WithPartialResult(dto)) + } + if finalErr := errors.Join(addition.operationErr, shutdownErr); finalErr != nil { + return addition.reporter.ReportError(finalErr, options...) + } + addition.stdout.Info("project resource addition completed", zap.Any("data", dto)) + return nil +} diff --git a/cmd/devctl/internal/add/result_test.go b/cmd/devctl/internal/add/result_test.go new file mode 100644 index 0000000..0896cd7 --- /dev/null +++ b/cmd/devctl/internal/add/result_test.go @@ -0,0 +1,68 @@ +package add + +import ( + "context" + "errors" + "testing" + + commandruntime "github.com/devctllabs/devctl/cmd/devctl/internal/command" + projectdomain "github.com/devctllabs/devctl/internal/domain/project" + "github.com/stretchr/testify/require" + "go.uber.org/zap" + "go.uber.org/zap/zaptest/observer" +) + +func TestFinishManifestAdditionReportsJoinedFailuresWithKnownDataOnly(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + result projectdomain.ManifestResult + operationErr error + shutdownErr error + expectPartial bool + }{ + { + name: "operation and shutdown with known manifest", + result: projectdomain.ManifestResult{Manifest: "/project/devctl.yaml", Change: projectdomain.ChangeUpdated}, + operationErr: errors.New("operation failed"), shutdownErr: errors.New("shutdown failed"), + expectPartial: true, + }, + { + name: "operation with unknown manifest", + operationErr: errors.New("operation failed"), + expectPartial: false, + }, + { + name: "shutdown with unknown manifest", + shutdownErr: errors.New("shutdown failed"), + expectPartial: false, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + core, observed := observer.New(zap.DebugLevel) + reporter := commandruntime.NewErrorReporter(zap.New(core), false) + err := finishManifestAddition(context.Background(), manifestAddition{ + stdout: zap.NewNop(), reporter: reporter, result: test.result, + operationErr: test.operationErr, + shutdown: func(context.Context) error { return test.shutdownErr }, + }) + + if test.operationErr != nil { + require.ErrorIs(t, err, test.operationErr) + } + if test.shutdownErr != nil { + require.ErrorIs(t, err, test.shutdownErr) + } + require.Len(t, observed.All(), 1) + context := observed.All()[0].ContextMap() + require.NotContains(t, context, "data") + details, hasDetails := context["details"].(map[string]any) + _, hasPartial := details["partial_result"] + require.Equal(t, test.expectPartial, hasDetails && hasPartial) + }) + } +} diff --git a/cmd/devctl/internal/add/source.go b/cmd/devctl/internal/add/source.go new file mode 100644 index 0000000..ccff89b --- /dev/null +++ b/cmd/devctl/internal/add/source.go @@ -0,0 +1,122 @@ +package add + +import ( + "context" + "errors" + "fmt" + + commandruntime "github.com/devctllabs/devctl/cmd/devctl/internal/command" + "github.com/devctllabs/devctl/internal/deps" + projectdomain "github.com/devctllabs/devctl/internal/domain/project" + "github.com/urfave/cli/v3" + "go.uber.org/zap" +) + +//go:generate go tool mockgen -destination mocks/source.go -package mocks -typed . sourceAdder + +type sourceAdder interface { + // AddSource adds or updates one named contract source. + AddSource(ctx context.Context, command projectdomain.AddSourceCommand) (projectdomain.ManifestResult, error) +} + +// sourceRuntime contains the application port and cleanup hook used by Action. +type sourceRuntime struct { + adder sourceAdder + shutdown func(context.Context) error +} + +// sourceBuilder isolates dependency construction from source mutation behavior. +type sourceBuilder func(context.Context, *zap.Logger) (sourceRuntime, error) + +// sourceCmd owns parsed source options and the runtime factory. +type sourceCmd struct { + opts sourceCmdOpts + buildRuntime sourceBuilder +} + +// sourceCmdOpts receives the positional name, common flags, and source location settings. +type sourceCmdOpts struct { + commandruntime.CommonCmdOpts + Name string + Type string + Path string + URL string + Filename string + AllowInsecureHTTP bool + Repo string + Ref string + BufConfig string + Force bool +} + +// newSourceCmd constructs the executable add source leaf. +func newSourceCmd(opts sourceCmdOpts, build sourceBuilder) *cli.Command { + cmd := &sourceCmd{opts: opts, buildRuntime: build} + flags := append(cmd.opts.CommonFlags(), + &cli.StringFlag{Name: "type", Usage: "select `local`, `url`, `git`, or `devctl`", Destination: &cmd.opts.Type}, + &cli.StringFlag{Name: "path", Usage: "set the project-relative local or Git containment `path`", Destination: &cmd.opts.Path}, + &cli.StringFlag{Name: "url", Usage: "set the base `URL` for a URL Source", Destination: &cmd.opts.URL}, + &cli.StringFlag{Name: "filename", Usage: "store a single URL document under `filename`", Destination: &cmd.opts.Filename}, + &cli.BoolFlag{Name: "allow-insecure-http", Usage: "allow an http URL instead of requiring https", Destination: &cmd.opts.AllowInsecureHTTP}, + &cli.StringFlag{Name: "repo", Usage: "set the Git or Devctl repository `location`", Destination: &cmd.opts.Repo}, + &cli.StringFlag{Name: "ref", Usage: "select the immutable or reviewable repository `ref`", Destination: &cmd.opts.Ref}, + &cli.StringFlag{Name: "buf-config", Usage: "select the Source-relative supplier `buf-config`", Destination: &cmd.opts.BufConfig}, + &cli.BoolFlag{Name: "force", Usage: "replace an existing Source with the same name", Destination: &cmd.opts.Force}, + ) + return &cli.Command{ + Name: "source", + Usage: "Add a contract source", + Description: "Declare a bounded origin for Contracts. Type-specific flags select a local directory, URL closure, Git checkout, or another Devctl Project.", + UsageText: "devctl add source --type [type-specific flags]", + UseShortOptionHandling: true, + Arguments: []cli.Argument{&cli.StringArg{ + Name: "source-name", UsageText: "", Destination: &cmd.opts.Name, + }}, + Flags: flags, + OnUsageError: func(_ context.Context, command *cli.Command, err error, _ bool) error { + reporter := commandruntime.NewErrorReporter(cmd.opts.NewStderrLogger(command), cmd.opts.Verbose) + return reporter.ReportError(cli.Exit(err, 2)) + }, + Action: cmd.Action, + } +} + +// Action adds one named contract source to the selected manifest. +func (cmd *sourceCmd) Action(ctx context.Context, command *cli.Command) error { + stdout := cmd.opts.NewStdoutLogger(command) + stderr := cmd.opts.NewStderrLogger(command) + reporter := commandruntime.NewErrorReporter(stderr, cmd.opts.Verbose) + if cmd.opts.Name == "" { + return reporter.ReportError(cli.Exit("source name is required", 2)) + } + if cmd.opts.Type == "" { + return reporter.ReportError(cli.Exit("--type is required", 2)) + } + runtime, err := cmd.buildRuntime(ctx, stderr) + if err != nil { + return reporter.ReportError(err) + } + result, operationErr := runtime.adder.AddSource(ctx, projectdomain.AddSourceCommand{ + ManifestPath: cmd.opts.ManifestPath, Name: cmd.opts.Name, Type: cmd.opts.Type, + Path: cmd.opts.Path, URL: cmd.opts.URL, Filename: cmd.opts.Filename, + AllowInsecureHTTP: cmd.opts.AllowInsecureHTTP, Repo: cmd.opts.Repo, Ref: cmd.opts.Ref, BufConfig: cmd.opts.BufConfig, Force: cmd.opts.Force, + }) + return finishManifestAddition(ctx, manifestAddition{ + stdout: stdout, reporter: reporter, shutdown: runtime.shutdown, + result: result, operationErr: operationErr, + }) +} + +// buildSource constructs and resolves the lazy dependencies owned by add source. +func buildSource(ctx context.Context, logger *zap.Logger) (sourceRuntime, error) { + container, err := deps.New(logger) + if err != nil { + return sourceRuntime{}, fmt.Errorf("deps.New: %w", err) + } + service, err := container.ProjectService() + if err != nil { + shutdownErr := commandruntime.Shutdown(ctx, container) + return sourceRuntime{}, errors.Join(fmt.Errorf("container.ProjectService: %w", err), shutdownErr) + } + return sourceRuntime{adder: service, shutdown: container.Shutdown}, nil +} diff --git a/cmd/devctl/internal/add/storage.go b/cmd/devctl/internal/add/storage.go new file mode 100644 index 0000000..c8a7e70 --- /dev/null +++ b/cmd/devctl/internal/add/storage.go @@ -0,0 +1,146 @@ +package add + +import ( + "context" + "errors" + "fmt" + + commandruntime "github.com/devctllabs/devctl/cmd/devctl/internal/command" + "github.com/devctllabs/devctl/internal/deps" + projectdomain "github.com/devctllabs/devctl/internal/domain/project" + "github.com/urfave/cli/v3" + "go.uber.org/zap" +) + +type storageAdder interface { + // AddRedis adds or updates the Redis connection described by command. + AddRedis(ctx context.Context, command projectdomain.AddRedisCommand) (projectdomain.ManifestResult, error) + // AddS3Connection adds or updates the S3 connection described by command. + AddS3Connection(ctx context.Context, command projectdomain.AddS3ConnectionCommand) (projectdomain.ManifestResult, error) + // AddS3 adds or updates the S3 bucket described by command. + AddS3(ctx context.Context, command projectdomain.AddS3Command) (projectdomain.ManifestResult, error) +} + +type storageRuntime struct { + adder storageAdder + shutdown func(context.Context) error +} + +type storageBuilder func(context.Context, *zap.Logger) (storageRuntime, error) + +type storageCmdOpts struct { + commandruntime.CommonCmdOpts + Name string + AddrEnv string + AddrDefault string + Connection string + Credentials string + Force bool +} + +type storageCmd struct { + kind string + opts storageCmdOpts + buildRuntime storageBuilder +} + +func newRedisCmd(opts storageCmdOpts, build storageBuilder) *cli.Command { + cmd := &storageCmd{kind: "redis", opts: opts, buildRuntime: build} + flags := append(cmd.opts.CommonFlags(), + &cli.StringFlag{Name: "addr-env", Usage: "override the generated Redis address environment `key`", Destination: &cmd.opts.AddrEnv}, + &cli.StringFlag{Name: "addr-default", Usage: "override the local Redis `address` default", Destination: &cmd.opts.AddrDefault}, + &cli.BoolFlag{Name: "force", Usage: "replace an existing Redis Connection with the same name", Destination: &cmd.opts.Force}, + ) + return newStorageLeaf(cmd, flags) +} + +func newS3ConnectionCmd(opts storageCmdOpts, build storageBuilder) *cli.Command { + cmd := &storageCmd{kind: "s3-connection", opts: opts, buildRuntime: build} + flags := append(cmd.opts.CommonFlags(), + &cli.StringFlag{Name: "credentials", Usage: "select `ambient` or `static` credentials", Destination: &cmd.opts.Credentials}, + &cli.BoolFlag{Name: "force", Usage: "replace an existing S3 Connection with the same name", Destination: &cmd.opts.Force}, + ) + return newStorageLeaf(cmd, flags) +} + +func newS3Cmd(opts storageCmdOpts, build storageBuilder) *cli.Command { + cmd := &storageCmd{kind: "s3", opts: opts, buildRuntime: build} + flags := append(cmd.opts.CommonFlags(), + &cli.StringFlag{Name: "connection", Usage: "attach the bucket to the named S3 `connection`", Destination: &cmd.opts.Connection}, + &cli.BoolFlag{Name: "force", Usage: "replace an existing S3 bucket with the same name", Destination: &cmd.opts.Force}, + ) + return newStorageLeaf(cmd, flags) +} + +func newStorageLeaf(cmd *storageCmd, flags []cli.Flag) *cli.Command { + descriptions := map[string]string{ + "redis": "Declare a named Redis Connection with an environment-backed address and a local default.", + "s3-connection": "Declare a named S3 Connection and choose ambient or static credentials.", + "s3": "Declare a named S3 bucket attached to an existing Connection, or create the canonical local Connection when omitted.", + } + return &cli.Command{ + Name: cmd.kind, + Usage: "Add a " + cmd.kind + " resource", + Description: descriptions[cmd.kind], + UsageText: "devctl add " + cmd.kind + " <" + cmd.kind + "-name> [options]", + Arguments: []cli.Argument{&cli.StringArg{ + Name: cmd.kind + "-name", UsageText: "<" + cmd.kind + "-name>", Destination: &cmd.opts.Name, + }}, + Flags: flags, + OnUsageError: func(_ context.Context, command *cli.Command, err error, _ bool) error { + reporter := commandruntime.NewErrorReporter(cmd.opts.NewStderrLogger(command), cmd.opts.Verbose) + return reporter.ReportError(cli.Exit(err, 2)) + }, + Action: cmd.Action, + } +} + +func (cmd *storageCmd) Action(ctx context.Context, command *cli.Command) error { + stdout := cmd.opts.NewStdoutLogger(command) + stderr := cmd.opts.NewStderrLogger(command) + reporter := commandruntime.NewErrorReporter(stderr, cmd.opts.Verbose) + if cmd.opts.Name == "" { + return reporter.ReportError(cli.Exit(cmd.kind+" name is required", 2)) + } + runtime, err := cmd.buildRuntime(ctx, stderr) + if err != nil { + return reporter.ReportError(err) + } + result, operationErr := cmd.add(ctx, runtime.adder) + return finishManifestAddition(ctx, manifestAddition{ + stdout: stdout, reporter: reporter, shutdown: runtime.shutdown, + result: result, operationErr: operationErr, + }) +} + +func (cmd *storageCmd) add(ctx context.Context, adder storageAdder) (projectdomain.ManifestResult, error) { + var result projectdomain.ManifestResult + var err error + operation := "AddS3" + switch cmd.kind { + case "redis": + operation = "AddRedis" + result, err = adder.AddRedis(ctx, projectdomain.AddRedisCommand{ManifestPath: cmd.opts.ManifestPath, Name: cmd.opts.Name, AddrEnv: cmd.opts.AddrEnv, AddrDefault: cmd.opts.AddrDefault, Force: cmd.opts.Force}) + case "s3-connection": + operation = "AddS3Connection" + result, err = adder.AddS3Connection(ctx, projectdomain.AddS3ConnectionCommand{ManifestPath: cmd.opts.ManifestPath, Name: cmd.opts.Name, Credentials: cmd.opts.Credentials, Force: cmd.opts.Force}) + default: + result, err = adder.AddS3(ctx, projectdomain.AddS3Command{ManifestPath: cmd.opts.ManifestPath, Name: cmd.opts.Name, Connection: cmd.opts.Connection, Force: cmd.opts.Force}) + } + if err != nil { + return result, fmt.Errorf("adder.%s: %w", operation, err) + } + return result, nil +} + +func buildStorage(ctx context.Context, logger *zap.Logger) (storageRuntime, error) { + container, err := deps.New(logger) + if err != nil { + return storageRuntime{}, fmt.Errorf("deps.New: %w", err) + } + service, err := container.ProjectService() + if err != nil { + return storageRuntime{}, errors.Join(fmt.Errorf("container.ProjectService: %w", err), commandruntime.Shutdown(ctx, container)) + } + return storageRuntime{adder: service, shutdown: container.Shutdown}, nil +} diff --git a/cmd/devctl/internal/app/app.go b/cmd/devctl/internal/app/app.go new file mode 100644 index 0000000..baf2ba5 --- /dev/null +++ b/cmd/devctl/internal/app/app.go @@ -0,0 +1,47 @@ +// Package app constructs the complete devctl command tree. +package app + +import ( + "context" + + addcmd "github.com/devctllabs/devctl/cmd/devctl/internal/add" + enablecmd "github.com/devctllabs/devctl/cmd/devctl/internal/enable" + gencmd "github.com/devctllabs/devctl/cmd/devctl/internal/gen" + initcmd "github.com/devctllabs/devctl/cmd/devctl/internal/init" + inspectcmd "github.com/devctllabs/devctl/cmd/devctl/internal/inspect" + lintcmd "github.com/devctllabs/devctl/cmd/devctl/internal/lint" + synccmd "github.com/devctllabs/devctl/cmd/devctl/internal/sync" + validatecmd "github.com/devctllabs/devctl/cmd/devctl/internal/validate" + "github.com/urfave/cli/v3" +) + +// New constructs the root command shared by the executable and documentation generator. +func New(releaseVersion, releaseCommit string) *cli.Command { + return &cli.Command{ + Name: "devctl", + Usage: "Manage Devctl Go projects", + Description: "Devctl defines, validates, and materializes reproducible Go projects from a devctl.yaml manifest. Commands are non-interactive and keep manifest mutation, synchronization, linting, scaffolding, and generation explicit.", + Version: buildVersion(releaseVersion, releaseCommit), + Commands: []*cli.Command{ + initcmd.NewCmd(), + validatecmd.NewCmd(), + inspectcmd.NewCmd(), + enablecmd.NewCmd(), + addcmd.NewCmd(), + synccmd.NewCmd(), + gencmd.NewCmd(), + lintcmd.NewCmd(), + }, + ExitErrHandler: func(context.Context, *cli.Command, error) {}, + OnUsageError: func(_ context.Context, _ *cli.Command, err error, _ bool) error { + return cli.Exit(err, 2) + }, + } +} + +func buildVersion(releaseVersion, releaseCommit string) string { + if releaseCommit == "" { + return releaseVersion + } + return releaseVersion + "\ncommit: " + releaseCommit +} diff --git a/cmd/devctl/internal/app/app_test.go b/cmd/devctl/internal/app/app_test.go new file mode 100644 index 0000000..9e4022b --- /dev/null +++ b/cmd/devctl/internal/app/app_test.go @@ -0,0 +1,37 @@ +package app_test + +import ( + "testing" + + devctlapp "github.com/devctllabs/devctl/cmd/devctl/internal/app" + "github.com/stretchr/testify/require" + "github.com/urfave/cli/v3" +) + +func TestCommandTreeHasDocumentationMetadata(t *testing.T) { + t.Parallel() + + root := devctlapp.New("test", "") + walkCommands(t, root, func(command *cli.Command) { + require.NotEmpty(t, command.Usage, "%s has no usage", command.Path()) + require.NotEmpty(t, command.Description, "%s has no description", command.Path()) + + for _, flag := range command.VisibleFlags() { + documented, ok := flag.(cli.DocGenerationFlag) + require.True(t, ok, "%s flag %v cannot be documented", command.Path(), flag.Names()) + require.NotEmpty(t, documented.GetUsage(), "%s flag %v has no usage", command.Path(), flag.Names()) + } + + for _, argument := range command.Arguments { + require.NotEmpty(t, argument.Usage(), "%s has an undocumented argument", command.Path()) + } + }) +} + +func walkCommands(t *testing.T, command *cli.Command, check func(*cli.Command)) { + t.Helper() + check(command) + for _, child := range command.Commands { + walkCommands(t, child, check) + } +} diff --git a/cmd/devctl/internal/command/options.go b/cmd/devctl/internal/command/options.go new file mode 100644 index 0000000..b4499ee --- /dev/null +++ b/cmd/devctl/internal/command/options.go @@ -0,0 +1,79 @@ +package command + +import ( + "io" + "os" + "strings" + + loglib "github.com/devctllabs/go-libs/log" + "github.com/urfave/cli/v3" + "go.uber.org/zap" + "go.uber.org/zap/zapcore" +) + +// CommonCmdOpts contains the delivery options shared by every executable leaf. +type CommonCmdOpts struct { + // ManifestPath selects a manifest explicitly; an empty value enables project discovery. + ManifestPath string + // JSON selects JSON events instead of the default console encoding. + JSON bool + // Verbose enables debug diagnostics and raw error causes. + Verbose bool +} + +// CommonFlags binds shared flags directly to the owning leaf's options. +func (o *CommonCmdOpts) CommonFlags() []cli.Flag { + return []cli.Flag{ + &cli.StringFlag{Name: "file", Usage: "use `path` as the Manifest instead of discovering devctl.yaml", Destination: &o.ManifestPath}, + &cli.BoolFlag{Name: "json", Usage: "emit compact JSONL events instead of text", Destination: &o.JSON}, + &cli.BoolFlag{Name: "verbose", Usage: "include debug diagnostics and raw causes on stderr", Destination: &o.Verbose}, + } +} + +// NewStdoutLogger creates the leaf result logger using the selected encoding. +func (o *CommonCmdOpts) NewStdoutLogger(command *cli.Command) *zap.Logger { + return o.newLogger(command, outputWriter(command), zapcore.InfoLevel) +} + +// NewStderrLogger creates the leaf diagnostic logger using the selected encoding and verbosity. +func (o *CommonCmdOpts) NewStderrLogger(command *cli.Command) *zap.Logger { + level := zapcore.WarnLevel + if o.Verbose { + level = zapcore.DebugLevel + } + return o.newLogger(command, errorWriter(command), level) +} + +func (o *CommonCmdOpts) newLogger(command *cli.Command, writer io.Writer, level zapcore.Level) *zap.Logger { + encoding := loglib.EncodingConsole + if o.JSON { + encoding = loglib.EncodingJSON + } + logger := loglib.New(level, false, loglib.WithEncoding(encoding), loglib.WithOutput(writer)) + if name := leafCommandName(command); name != "" { + logger = logger.With(zap.String("command", name)) + } + return logger +} + +func leafCommandName(command *cli.Command) string { + path := command.Path() + if len(path) > 0 && path[0] == "devctl" { + path = path[1:] + } + return strings.Join(path, " ") +} + +func outputWriter(command *cli.Command) io.Writer { + if command.Writer != nil { + return command.Writer + } + return os.Stdout +} + +func errorWriter(command *cli.Command) io.Writer { + if command.ErrWriter != nil { + return command.ErrWriter + } + return os.Stderr +} diff --git a/cmd/devctl/internal/command/options_test.go b/cmd/devctl/internal/command/options_test.go new file mode 100644 index 0000000..78edd19 --- /dev/null +++ b/cmd/devctl/internal/command/options_test.go @@ -0,0 +1,52 @@ +package command + +import ( + "bytes" + "context" + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" + "github.com/urfave/cli/v3" + "go.uber.org/zap" +) + +func TestCommonCmdOptsBuildsCommandLoggers(t *testing.T) { + t.Parallel() + + var opts CommonCmdOpts + var stdout bytes.Buffer + var stderr bytes.Buffer + leaf := &cli.Command{ + Name: "validate", + Flags: opts.CommonFlags(), + Action: func(_ context.Context, command *cli.Command) error { + opts.NewStdoutLogger(command).Info("completed", zap.String("stream", "stdout")) + opts.NewStderrLogger(command).Debug("diagnostic", zap.String("stream", "stderr")) + return nil + }, + } + root := &cli.Command{Name: "devctl", Commands: []*cli.Command{leaf}, Writer: &stdout, ErrWriter: &stderr} + + require.NoError(t, root.Run(context.Background(), []string{"devctl", "validate", "--json", "--verbose", "--file", "custom.yaml"})) + require.Equal(t, "custom.yaml", opts.ManifestPath) + require.True(t, opts.JSON) + require.True(t, opts.Verbose) + requireJSONLog(t, stdout.Bytes(), logExpectation{level: "info", command: "validate", message: "completed"}) + requireJSONLog(t, stderr.Bytes(), logExpectation{level: "debug", command: "validate", message: "diagnostic"}) +} + +type logExpectation struct { + level string + command string + message string +} + +func requireJSONLog(t *testing.T, data []byte, expected logExpectation) { + t.Helper() + var event map[string]any + require.NoError(t, json.Unmarshal(data, &event)) + require.Equal(t, expected.level, event["level"]) + require.Equal(t, expected.command, event["command"]) + require.Equal(t, expected.message, event["msg"]) +} diff --git a/cmd/devctl/internal/command/reporter.go b/cmd/devctl/internal/command/reporter.go new file mode 100644 index 0000000..9736458 --- /dev/null +++ b/cmd/devctl/internal/command/reporter.go @@ -0,0 +1,150 @@ +package command + +import ( + "context" + "errors" + + "github.com/devctllabs/devctl/internal/domain/contract" + "github.com/devctllabs/devctl/internal/domain/failure" + projectdomain "github.com/devctllabs/devctl/internal/domain/project" + "github.com/urfave/cli/v3" + "go.uber.org/zap" +) + +const urfaveHelpExitCode = 3 + +// ErrorOption adds caller-approved facts to an error event. +type ErrorOption func(*errorReport) + +// WithPartialResult attaches caller-approved completed work to recovery details. +func WithPartialResult(result any) ErrorOption { + return func(report *errorReport) { + report.addDetail("partial_result", result) + } +} + +// ErrorReporter owns safe CLI error presentation and process exit classification. +type ErrorReporter struct { + logger *zap.Logger + verbose bool +} + +// NewErrorReporter creates a reporter that writes final errors through logger. +func NewErrorReporter(logger *zap.Logger, verbose bool) *ErrorReporter { + return &ErrorReporter{logger: logger, verbose: verbose} +} + +// ReportError writes one final safe error event and returns a silent error with the same cause. +func (r *ErrorReporter) ReportError(err error, options ...ErrorOption) error { + report := classifyError(err) + for _, option := range options { + if option != nil { + option(&report) + } + } + if err.Error() != "" { + fields := []zap.Field{zap.String("code", report.code), zap.Int("exit_code", report.exitCode)} + if len(report.details) > 0 { + fields = append(fields, zap.Any("details", report.details)) + } + if r.verbose { + fields = append(fields, zap.Error(err)) + } + r.logger.Error(report.message, fields...) + } + return &reportedError{cause: err, exitCode: report.exitCode} +} + +type errorReport struct { + message string + code string + exitCode int + details map[string]any +} + +func (r *errorReport) addDetail(key string, value any) { + if r.details == nil { + r.details = make(map[string]any) + } + r.details[key] = value +} + +// classifyError maps domain and CLI errors into one safe presentation record. +func classifyError(err error) errorReport { + exitCode := errorExitCode(err) + if exitCode == 2 { + return errorReport{message: err.Error(), code: "usage", exitCode: exitCode} + } + if exitCode == 130 { + return errorReport{message: "operation was cancelled", code: "cancelled", exitCode: exitCode} + } + + code, message := executionErrorMessage(err) + report := errorReport{message: message, code: code, exitCode: exitCode} + var invalidManifest *projectdomain.InvalidManifestError + if errors.As(err, &invalidManifest) { + report.addDetail("path", invalidManifest.Path) + report.addDetail("issues", ValidationIssueDTOs(invalidManifest.Issues)) + } + var metadataErr *contract.SnapshotMetadataError + if errors.As(err, &metadataErr) { + report.addDetail("type", "snapshot_metadata_invalid") + report.addDetail("field", metadataErr.Field) + report.addDetail("reason", string(metadataErr.Reason)) + report.addDetail("hint", metadataErr.Hint) + } + return report +} + +// errorExitCode normalizes framework and cancellation errors to the public CLI contract. +func errorExitCode(err error) int { + if errors.Is(err, context.Canceled) { + return 130 + } + var exitCoder cli.ExitCoder + if !errors.As(err, &exitCoder) { + return 1 + } + if exitCoder.ExitCode() == urfaveHelpExitCode { + return 2 + } + return exitCoder.ExitCode() +} + +// ExitCode maps a command error to the process status used by devctl. +func ExitCode(err error) int { + return errorExitCode(err) +} + +// executionErrorMessage maps domain failure categories to stable public codes and messages. +func executionErrorMessage(err error) (string, string) { + switch failure.CategoryOf(err) { + case failure.Cancelled: + return "cancelled", "operation was cancelled" + case failure.Unavailable: + return "unavailable", "required dependency is unavailable" + case failure.InvalidInput: + return "invalid_input", "input is invalid" + case failure.NotFound: + return "not_found", "requested resource was not found" + case failure.Conflict: + return "conflict", "operation conflicts with existing state" + case failure.Unsupported: + return "unsupported", "requested operation is unsupported" + case failure.Internal: + return "internal", "internal error" + } + return "internal", "internal error" +} + +// reportedError suppresses framework rendering while retaining cause and process status. +type reportedError struct { + cause error + exitCode int +} + +func (e *reportedError) Error() string { return "" } + +func (e *reportedError) Unwrap() error { return e.cause } + +func (e *reportedError) ExitCode() int { return e.exitCode } diff --git a/cmd/devctl/internal/command/reporter_test.go b/cmd/devctl/internal/command/reporter_test.go new file mode 100644 index 0000000..ebfba4b --- /dev/null +++ b/cmd/devctl/internal/command/reporter_test.go @@ -0,0 +1,99 @@ +package command + +import ( + "bytes" + "encoding/json" + "io/fs" + "testing" + + "github.com/devctllabs/devctl/internal/domain/contract" + projectdomain "github.com/devctllabs/devctl/internal/domain/project" + "github.com/stretchr/testify/require" + "github.com/urfave/cli/v3" +) + +func TestErrorReporterWritesSafeEventAndReturnsSilentCause(t *testing.T) { + t.Parallel() + + var stderr bytes.Buffer + command := &cli.Command{Name: "validate", ErrWriter: &stderr} + opts := CommonCmdOpts{JSON: true} + logger := opts.NewStderrLogger(command) + reporter := NewErrorReporter(logger, false) + cause := &projectdomain.OperationError{ + Operation: projectdomain.OperationLoadManifest, + Path: "/private/project/devctl.yaml", + Kind: projectdomain.FailureNotFound, + Cause: fs.ErrNotExist, + } + + err := reporter.ReportError(cause, WithPartialResult(map[string]int{"completed": 2})) + + var exitCoder cli.ExitCoder + require.ErrorAs(t, err, &exitCoder) + require.Equal(t, 1, exitCoder.ExitCode()) + require.Empty(t, err.Error()) + require.ErrorIs(t, err, fs.ErrNotExist) + var event map[string]any + require.NoError(t, json.Unmarshal(stderr.Bytes(), &event)) + require.Equal(t, "requested resource was not found", event["msg"]) + require.Equal(t, "not_found", event["code"]) + require.EqualValues(t, 1, event["exit_code"]) + require.NotContains(t, event, "data") + require.Equal(t, map[string]any{ + "partial_result": map[string]any{"completed": float64(2)}, + }, event["details"]) + require.NotContains(t, event, "error") + require.NotContains(t, stderr.String(), cause.Path) +} + +func TestErrorReporterExposesSafeSnapshotRefreshDetails(t *testing.T) { + t.Parallel() + + var stderr bytes.Buffer + command := &cli.Command{Name: "gen", ErrWriter: &stderr} + opts := CommonCmdOpts{JSON: true} + logger := opts.NewStderrLogger(command) + reporter := NewErrorReporter(logger, false) + cause := &contract.SnapshotMetadataError{ + Field: "entrypoint", Reason: contract.MetadataRequired, Hint: "devctl sync", + } + + err := reporter.ReportError(cause, WithPartialResult(map[string]int{"completed": 1})) + + require.Error(t, err) + var event map[string]any + require.NoError(t, json.Unmarshal(stderr.Bytes(), &event)) + require.Equal(t, "invalid_input", event["code"]) + require.Equal(t, map[string]any{ + "type": "snapshot_metadata_invalid", "field": "entrypoint", + "reason": "required", "hint": "devctl sync", + "partial_result": map[string]any{"completed": float64(1)}, + }, event["details"]) + require.NotContains(t, event, "data") +} + +func TestErrorReporterMergesManifestIssuesWithPartialResult(t *testing.T) { + t.Parallel() + + var stderr bytes.Buffer + command := &cli.Command{Name: "sync", ErrWriter: &stderr} + opts := CommonCmdOpts{JSON: true} + reporter := NewErrorReporter(opts.NewStderrLogger(command), false) + cause := &projectdomain.InvalidManifestError{ + Path: "devctl.yaml", + Issues: []projectdomain.Issue{{Code: projectdomain.IssueCode("source_not_found"), Field: "sources.catalog"}}, + } + + err := reporter.ReportError(cause, WithPartialResult(map[string]int{"completed": 1})) + + require.Error(t, err) + var event map[string]any + require.NoError(t, json.Unmarshal(stderr.Bytes(), &event)) + require.NotContains(t, event, "data") + details, ok := event["details"].(map[string]any) + require.True(t, ok) + require.Equal(t, "devctl.yaml", details["path"]) + require.Equal(t, map[string]any{"completed": float64(1)}, details["partial_result"]) + require.Equal(t, []any{map[string]any{"code": "source_not_found", "field": "sources.catalog"}}, details["issues"]) +} diff --git a/cmd/devctl/internal/command/runtime.go b/cmd/devctl/internal/command/runtime.go new file mode 100644 index 0000000..661bf60 --- /dev/null +++ b/cmd/devctl/internal/command/runtime.go @@ -0,0 +1,20 @@ +package command + +import ( + "context" + "fmt" + "time" + + "github.com/devctllabs/devctl/internal/deps" + "github.com/devctllabs/go-libs/lifecycle" +) + +const shutdownTimeout = 5 * time.Second + +// Shutdown closes resources with the common fresh bounded context convention. +func Shutdown(ctx context.Context, container *deps.Container) error { + if err := lifecycle.Shutdown(ctx, shutdownTimeout, container.Shutdown); err != nil { + return fmt.Errorf("lifecycle.Shutdown: %w", err) + } + return nil +} diff --git a/cmd/devctl/internal/command/validation_issue.go b/cmd/devctl/internal/command/validation_issue.go new file mode 100644 index 0000000..126c89b --- /dev/null +++ b/cmd/devctl/internal/command/validation_issue.go @@ -0,0 +1,45 @@ +package command + +import projectdomain "github.com/devctllabs/devctl/internal/domain/project" + +// ValidationIssueDTO is the safe CLI representation of one project validation issue. +type ValidationIssueDTO struct { + // Code identifies the stable validation rule that failed. + Code string `json:"code"` + // Path identifies the affected project file when available. + Path string `json:"path,omitempty"` + // Line and Column are one-based source coordinates; zero means unavailable. + Line int `json:"line,omitempty"` + Column int `json:"column,omitempty"` + // Field identifies the affected manifest field when available. + Field string `json:"field,omitempty"` + // Parameters contains rule-specific, presentation-safe facts. + Parameters *ValidationParametersDTO `json:"parameters,omitempty"` +} + +// ValidationParametersDTO carries issue-specific facts safe for CLI output. +type ValidationParametersDTO struct { + Expected string `json:"expected,omitempty"` + Actual string `json:"actual,omitempty"` + Value string `json:"value,omitempty"` +} + +// ValidationIssueDTOs maps presentation-neutral issues to their shared CLI form. +func ValidationIssueDTOs(values []projectdomain.Issue) []ValidationIssueDTO { + issues := make([]ValidationIssueDTO, 0, len(values)) + for _, issue := range values { + var parameters *ValidationParametersDTO + if issue.Parameters != nil { + parameters = &ValidationParametersDTO{ + Expected: issue.Parameters.Expected, + Actual: issue.Parameters.Actual, + Value: issue.Parameters.Value, + } + } + issues = append(issues, ValidationIssueDTO{ + Code: string(issue.Code), Path: issue.Path, Line: issue.Line, Column: issue.Column, + Field: issue.Field, Parameters: parameters, + }) + } + return issues +} diff --git a/cmd/devctl/internal/docgen/main.go b/cmd/devctl/internal/docgen/main.go new file mode 100644 index 0000000..e47bdf8 --- /dev/null +++ b/cmd/devctl/internal/docgen/main.go @@ -0,0 +1,90 @@ +package main + +import ( + "bytes" + "context" + "errors" + "flag" + "fmt" + "io/fs" + "os" + + devctlapp "github.com/devctllabs/devctl/cmd/devctl/internal/app" + devfs "github.com/devctllabs/go-libs/filesystem" + docs "github.com/urfave/cli-docs/v3" +) + +const referencePath = "docs/user-guide/reference/commands.md" + +func main() { + check := flag.Bool("check", false, "verify that the committed command reference is current") + flag.Parse() + + content, err := renderReference() + if err == nil { + if *check { + err = checkReference(".", referencePath, content) + } else { + err = writeReference(".", referencePath, content) + } + } + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func renderReference() ([]byte, error) { + root := devctlapp.New("", "") + markdown, err := docs.ToTabularMarkdown(root, "devctl") + if err != nil { + return nil, fmt.Errorf("generate command reference: %w", err) + } + + const header = "# Command reference\n\n\n\n" + return []byte(header + markdown), nil +} + +func checkReference(root, name string, expected []byte) error { + disk, err := devfs.Open(root) + if err != nil { + return fmt.Errorf("filesystem.Open: %w", err) + } + actual, readErr := fs.ReadFile(disk, name) + if readErr != nil { + readErr = fmt.Errorf("filesystem.ReadFile: %w", readErr) + } + closeErr := disk.Close() + if closeErr != nil { + closeErr = fmt.Errorf("filesystem.Close: %w", closeErr) + } + if err := errors.Join(readErr, closeErr); err != nil { + return fmt.Errorf("read command reference: %w", err) + } + if !bytes.Equal(actual, expected) { + return errors.New("command reference is out of date; run `mise run docs:generate`") + } + return nil +} + +func writeReference(root, name string, content []byte) error { + disk, err := devfs.Open(root) + if err != nil { + return fmt.Errorf("filesystem.Open: %w", err) + } + _, publishErr := disk.PublishFile(context.Background(), name, devfs.File{ + Content: content, + Mode: 0o644, + }) + if publishErr != nil { + publishErr = fmt.Errorf("filesystem.PublishFile: %w", publishErr) + } + closeErr := disk.Close() + if closeErr != nil { + closeErr = fmt.Errorf("filesystem.Close: %w", closeErr) + } + if err := errors.Join(publishErr, closeErr); err != nil { + return fmt.Errorf("publish command reference: %w", err) + } + return nil +} diff --git a/cmd/devctl/internal/docgen/main_test.go b/cmd/devctl/internal/docgen/main_test.go new file mode 100644 index 0000000..d56de04 --- /dev/null +++ b/cmd/devctl/internal/docgen/main_test.go @@ -0,0 +1,73 @@ +package main + +import ( + "io/fs" + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestRenderReferenceIsDeterministicAndVersionless(t *testing.T) { + t.Parallel() + + first, err := renderReference() + require.NoError(t, err) + second, err := renderReference() + require.NoError(t, err) + require.Equal(t, first, second) + + text := string(first) + require.Contains(t, text, "# Command reference") + require.Contains(t, text, "### `init` command") + require.Contains(t, text, "### `init manifest` subcommand") + require.Contains(t, text, "--preset") + require.Contains(t, text, "http-service") + require.NotContains(t, text, "commit:") + require.NotContains(t, text, "Version:") +} + +func TestCheckReferenceDetectsDrift(t *testing.T) { + t.Parallel() + + root := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(root, "commands.md"), []byte("current\n"), 0o644)) + require.NoError(t, checkReference(root, "commands.md", []byte("current\n"))) + require.ErrorContains(t, checkReference(root, "commands.md", []byte("expected\n")), "command reference is out of date") +} + +func TestWriteReferenceCreatesParentDirectories(t *testing.T) { + t.Parallel() + + root := t.TempDir() + require.NoError(t, writeReference(root, "docs/reference/commands.md", []byte("generated\n"))) + + actual, err := os.ReadFile(filepath.Join(root, "docs/reference/commands.md")) + require.NoError(t, err) + require.Equal(t, []byte("generated\n"), actual) +} + +func TestWriteReferenceRejectsSymlinkTarget(t *testing.T) { + t.Parallel() + if runtime.GOOS == "windows" { + t.Skip("symlink policy requires symlink support") + } + + root := t.TempDir() + original := filepath.Join(t.TempDir(), "commands.md") + require.NoError(t, os.WriteFile(original, []byte("original\n"), 0o644)) + target := filepath.Join(root, "commands.md") + require.NoError(t, os.Symlink(original, target)) + + err := writeReference(root, "commands.md", []byte("replacement\n")) + + require.ErrorIs(t, err, fs.ErrInvalid) + linkTarget, readLinkErr := os.Readlink(target) + require.NoError(t, readLinkErr) + require.Equal(t, original, linkTarget) + actual, readErr := os.ReadFile(original) + require.NoError(t, readErr) + require.Equal(t, []byte("original\n"), actual) +} diff --git a/cmd/devctl/internal/enable/capability.go b/cmd/devctl/internal/enable/capability.go new file mode 100644 index 0000000..790f5b1 --- /dev/null +++ b/cmd/devctl/internal/enable/capability.go @@ -0,0 +1,117 @@ +package enable + +import ( + "context" + "errors" + "fmt" + "time" + + commandruntime "github.com/devctllabs/devctl/cmd/devctl/internal/command" + "github.com/devctllabs/devctl/internal/deps" + projectdomain "github.com/devctllabs/devctl/internal/domain/project" + "github.com/devctllabs/go-libs/lifecycle" + "github.com/urfave/cli/v3" + "go.uber.org/zap" +) + +//go:generate go tool mockgen -destination mocks/capability.go -package mocks -typed . capabilityEnabler + +type capabilityEnabler interface { + // Enable adds or updates one project capability configuration. + Enable(ctx context.Context, command projectdomain.EnableCommand) (projectdomain.ManifestResult, error) +} + +// capabilityRuntime contains the application port and cleanup hook used by Action. +type capabilityRuntime struct { + enabler capabilityEnabler + shutdown func(context.Context) error +} + +// capabilityBuilder isolates dependency construction from capability mutation behavior. +type capabilityBuilder func(context.Context, *zap.Logger) (capabilityRuntime, error) + +// capabilityCmd owns parsed options, the selected capability, and its runtime factory. +type capabilityCmd struct { + opts capabilityCmdOpts + capability string + buildRuntime capabilityBuilder +} + +// capabilityCmdOpts receives common flags and capability mutation policies. +type capabilityCmdOpts struct { + commandruntime.CommonCmdOpts + Always bool + Force bool +} + +// newCapabilityCmd constructs one executable leaf for a fixed capability name. +func newCapabilityCmd(name string, opts capabilityCmdOpts, build capabilityBuilder) *cli.Command { + cmd := &capabilityCmd{opts: opts, capability: name, buildRuntime: build} + flags := append(cmd.opts.CommonFlags(), + &cli.BoolFlag{Name: "always", Usage: "omit the Runtime Start Policy so the Capability always starts", Destination: &cmd.opts.Always}, + &cli.BoolFlag{Name: "force", Usage: "replace an existing Capability declaration", Destination: &cmd.opts.Force}, + ) + return &cli.Command{ + Name: name, + Usage: "Enable " + name, + Description: "Add the " + name + " Capability and its canonical defaults to the Manifest. Run init scaffold and gen explicitly when the resulting Project files need to be refreshed.", + UsageText: "devctl enable " + name + " [--always] [--force]", + Flags: flags, + OnUsageError: func(_ context.Context, command *cli.Command, err error, _ bool) error { + reporter := commandruntime.NewErrorReporter(cmd.opts.NewStderrLogger(command), cmd.opts.Verbose) + return reporter.ReportError(cli.Exit(err, 2)) + }, + Action: cmd.Action, + } +} + +// Action enables one project capability and emits the resulting manifest change. +func (cmd *capabilityCmd) Action(ctx context.Context, command *cli.Command) error { + stdout := cmd.opts.NewStdoutLogger(command) + stderr := cmd.opts.NewStderrLogger(command) + reporter := commandruntime.NewErrorReporter(stderr, cmd.opts.Verbose) + if command.Args().Len() != 0 { + return reporter.ReportError(cli.Exit("enable accepts no positional arguments", 2)) + } + runtime, err := cmd.buildRuntime(ctx, stderr) + if err != nil { + return reporter.ReportError(err) + } + result, operationErr := runtime.enabler.Enable(ctx, projectdomain.EnableCommand{ + ManifestPath: cmd.opts.ManifestPath, + Capability: cmd.capability, + Always: cmd.opts.Always, + Force: cmd.opts.Force, + }) + shutdownErr := lifecycle.Shutdown(ctx, 5*time.Second, runtime.shutdown) + dto := manifestResultDTO{Manifest: result.Manifest, Change: string(result.Change)} + var options []commandruntime.ErrorOption + if result.Change != "" { + options = append(options, commandruntime.WithPartialResult(dto)) + } + if finalErr := errors.Join(operationErr, shutdownErr); finalErr != nil { + return reporter.ReportError(finalErr, options...) + } + stdout.Info("capability enablement completed", zap.Any("data", dto)) + return nil +} + +// manifestResultDTO is the stable capability mutation payload. +type manifestResultDTO struct { + Manifest string `json:"manifest"` + Change string `json:"change"` +} + +// buildCapability constructs and resolves the lazy dependencies owned by enable leaves. +func buildCapability(ctx context.Context, logger *zap.Logger) (capabilityRuntime, error) { + container, err := deps.New(logger) + if err != nil { + return capabilityRuntime{}, fmt.Errorf("deps.New: %w", err) + } + service, err := container.ProjectService() + if err != nil { + shutdownErr := commandruntime.Shutdown(ctx, container) + return capabilityRuntime{}, errors.Join(fmt.Errorf("container.ProjectService: %w", err), shutdownErr) + } + return capabilityRuntime{enabler: service, shutdown: container.Shutdown}, nil +} diff --git a/cmd/devctl/internal/enable/enable.go b/cmd/devctl/internal/enable/enable.go new file mode 100644 index 0000000..40c5181 --- /dev/null +++ b/cmd/devctl/internal/enable/enable.go @@ -0,0 +1,24 @@ +package enable + +import "github.com/urfave/cli/v3" + +// NewCmd constructs the namespace for project capability commands. +func NewCmd() *cli.Command { + return &cli.Command{ + Name: "enable", + Usage: "Enable a project capability", + Description: "Add or update one supported Capability in the Manifest. This command changes only devctl.yaml and does not refresh scaffold files or generated code.", + Commands: []*cli.Command{ + newHTTPCmd(capabilityCmdOpts{}, buildCapability), + newGRPCCmd(capabilityCmdOpts{}, buildCapability), + newLoggingCmd(capabilityCmdOpts{}, buildCapability), + newHealthCmd(capabilityCmdOpts{}, buildCapability), + newTelemetryCmd(capabilityCmdOpts{}, buildCapability), + newPprofCmd(capabilityCmdOpts{}, buildCapability), + }, + } +} + +func newGRPCCmd(opts capabilityCmdOpts, build capabilityBuilder) *cli.Command { + return newCapabilityCmd("grpc", opts, build) +} diff --git a/cmd/devctl/internal/enable/enable_test.go b/cmd/devctl/internal/enable/enable_test.go new file mode 100644 index 0000000..ed35681 --- /dev/null +++ b/cmd/devctl/internal/enable/enable_test.go @@ -0,0 +1,78 @@ +package enable + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "os" + "path/filepath" + "testing" + + "github.com/devctllabs/devctl/cmd/devctl/internal/enable/mocks" + projectdomain "github.com/devctllabs/devctl/internal/domain/project" + "github.com/stretchr/testify/require" + "github.com/urfave/cli/v3" + "go.uber.org/mock/gomock" + "go.uber.org/zap" +) + +func TestEnableWritesManifestChangeJSON(t *testing.T) { + t.Parallel() + manifestPath := writeManifest(t) + command := newLoggingCmd(capabilityCmdOpts{}, buildCapability) + var stdout bytes.Buffer + command.Writer = &stdout + + err := command.Run(context.Background(), []string{"logging", "--file", manifestPath, "--json"}) + + require.NoError(t, err) + require.Contains(t, stdout.String(), `"msg":"capability enablement completed"`) + require.Contains(t, stdout.String(), `"command":"logging"`) + require.Contains(t, stdout.String(), `"data":{"manifest":"`+manifestPath+`","change":"updated"}`) +} + +func TestEnableReportsOperationAndShutdownFailuresWithManifestData(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + enabler := mocks.NewMockcapabilityEnabler(ctrl) + operationErr := errors.New("enable failed") + shutdownErr := errors.New("shutdown failed") + enabler.EXPECT().Enable(gomock.Any(), projectdomain.EnableCommand{Capability: "logging"}).Return( + projectdomain.ManifestResult{Manifest: "/project/devctl.yaml", Change: projectdomain.ChangeUpdated}, operationErr, + ) + command := newLoggingCmd(capabilityCmdOpts{}, func(context.Context, *zap.Logger) (capabilityRuntime, error) { + return capabilityRuntime{enabler: enabler, shutdown: func(context.Context) error { return shutdownErr }}, nil + }) + command.ExitErrHandler = func(context.Context, *cli.Command, error) {} + var stderr bytes.Buffer + command.ErrWriter = &stderr + + err := command.Run(context.Background(), []string{"logging", "--json"}) + + require.ErrorIs(t, err, operationErr) + require.ErrorIs(t, err, shutdownErr) + var event map[string]any + require.NoError(t, json.Unmarshal(stderr.Bytes(), &event)) + require.NotContains(t, event, "data") + details, ok := event["details"].(map[string]any) + require.True(t, ok) + require.Equal(t, map[string]any{"manifest": "/project/devctl.yaml", "change": "updated"}, details["partial_result"]) +} + +func writeManifest(t *testing.T) string { + t.Helper() + path := filepath.Join(t.TempDir(), "devctl.yaml") + require.NoError(t, os.WriteFile(path, []byte(`version: 1 +project: {name: sample, language: go} +env: {} +paths: {} +sources: {} +exports: {} +components: {} +languages: + go: {module: example.test/sample} +`), 0o644)) + return path +} diff --git a/cmd/devctl/internal/enable/health.go b/cmd/devctl/internal/enable/health.go new file mode 100644 index 0000000..1291373 --- /dev/null +++ b/cmd/devctl/internal/enable/health.go @@ -0,0 +1,8 @@ +package enable + +import "github.com/urfave/cli/v3" + +// newHealthCmd constructs the health capability leaf. +func newHealthCmd(opts capabilityCmdOpts, build capabilityBuilder) *cli.Command { + return newCapabilityCmd("health", opts, build) +} diff --git a/cmd/devctl/internal/enable/http.go b/cmd/devctl/internal/enable/http.go new file mode 100644 index 0000000..51b86ec --- /dev/null +++ b/cmd/devctl/internal/enable/http.go @@ -0,0 +1,8 @@ +package enable + +import "github.com/urfave/cli/v3" + +// newHTTPCmd constructs the HTTP capability leaf. +func newHTTPCmd(opts capabilityCmdOpts, build capabilityBuilder) *cli.Command { + return newCapabilityCmd("http", opts, build) +} diff --git a/cmd/devctl/internal/enable/logging.go b/cmd/devctl/internal/enable/logging.go new file mode 100644 index 0000000..fdfc5de --- /dev/null +++ b/cmd/devctl/internal/enable/logging.go @@ -0,0 +1,8 @@ +package enable + +import "github.com/urfave/cli/v3" + +// newLoggingCmd constructs the logging capability leaf. +func newLoggingCmd(opts capabilityCmdOpts, build capabilityBuilder) *cli.Command { + return newCapabilityCmd("logging", opts, build) +} diff --git a/cmd/devctl/internal/enable/mocks/capability.go b/cmd/devctl/internal/enable/mocks/capability.go new file mode 100644 index 0000000..de69704 --- /dev/null +++ b/cmd/devctl/internal/enable/mocks/capability.go @@ -0,0 +1,81 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/devctllabs/devctl/cmd/devctl/internal/enable (interfaces: capabilityEnabler) +// +// Generated by this command: +// +// mockgen -destination mocks/capability.go -package mocks -typed . capabilityEnabler +// + +// Package mocks is a generated GoMock package. +package mocks + +import ( + context "context" + reflect "reflect" + + project "github.com/devctllabs/devctl/internal/domain/project" + gomock "go.uber.org/mock/gomock" +) + +// MockcapabilityEnabler is a mock of capabilityEnabler interface. +type MockcapabilityEnabler struct { + ctrl *gomock.Controller + recorder *MockcapabilityEnablerMockRecorder + isgomock struct{} +} + +// MockcapabilityEnablerMockRecorder is the mock recorder for MockcapabilityEnabler. +type MockcapabilityEnablerMockRecorder struct { + mock *MockcapabilityEnabler +} + +// NewMockcapabilityEnabler creates a new mock instance. +func NewMockcapabilityEnabler(ctrl *gomock.Controller) *MockcapabilityEnabler { + mock := &MockcapabilityEnabler{ctrl: ctrl} + mock.recorder = &MockcapabilityEnablerMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockcapabilityEnabler) EXPECT() *MockcapabilityEnablerMockRecorder { + return m.recorder +} + +// Enable mocks base method. +func (m *MockcapabilityEnabler) Enable(ctx context.Context, command project.EnableCommand) (project.ManifestResult, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Enable", ctx, command) + ret0, _ := ret[0].(project.ManifestResult) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Enable indicates an expected call of Enable. +func (mr *MockcapabilityEnablerMockRecorder) Enable(ctx, command any) *MockcapabilityEnablerEnableCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Enable", reflect.TypeOf((*MockcapabilityEnabler)(nil).Enable), ctx, command) + return &MockcapabilityEnablerEnableCall{Call: call} +} + +// MockcapabilityEnablerEnableCall wrap *gomock.Call +type MockcapabilityEnablerEnableCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockcapabilityEnablerEnableCall) Return(arg0 project.ManifestResult, arg1 error) *MockcapabilityEnablerEnableCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockcapabilityEnablerEnableCall) Do(f func(context.Context, project.EnableCommand) (project.ManifestResult, error)) *MockcapabilityEnablerEnableCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockcapabilityEnablerEnableCall) DoAndReturn(f func(context.Context, project.EnableCommand) (project.ManifestResult, error)) *MockcapabilityEnablerEnableCall { + c.Call = c.Call.DoAndReturn(f) + return c +} diff --git a/cmd/devctl/internal/enable/pprof.go b/cmd/devctl/internal/enable/pprof.go new file mode 100644 index 0000000..20509b7 --- /dev/null +++ b/cmd/devctl/internal/enable/pprof.go @@ -0,0 +1,8 @@ +package enable + +import "github.com/urfave/cli/v3" + +// newPprofCmd constructs the pprof capability leaf. +func newPprofCmd(opts capabilityCmdOpts, build capabilityBuilder) *cli.Command { + return newCapabilityCmd("pprof", opts, build) +} diff --git a/cmd/devctl/internal/enable/telemetry.go b/cmd/devctl/internal/enable/telemetry.go new file mode 100644 index 0000000..9a66cbd --- /dev/null +++ b/cmd/devctl/internal/enable/telemetry.go @@ -0,0 +1,8 @@ +package enable + +import "github.com/urfave/cli/v3" + +// newTelemetryCmd constructs the telemetry capability leaf. +func newTelemetryCmd(opts capabilityCmdOpts, build capabilityBuilder) *cli.Command { + return newCapabilityCmd("telemetry", opts, build) +} diff --git a/cmd/devctl/internal/gen/config.go b/cmd/devctl/internal/gen/config.go new file mode 100644 index 0000000..a93e128 --- /dev/null +++ b/cmd/devctl/internal/gen/config.go @@ -0,0 +1,8 @@ +package gen + +import "github.com/urfave/cli/v3" + +// newGenConfigCmd constructs the config-only generation leaf. +func newGenConfigCmd(opts genCmdOpts, build genBuilder) *cli.Command { + return newGenLeaf(genLeafSpec{name: "config", family: "config"}, opts, build) +} diff --git a/cmd/devctl/internal/gen/gen.go b/cmd/devctl/internal/gen/gen.go new file mode 100644 index 0000000..43d2f8c --- /dev/null +++ b/cmd/devctl/internal/gen/gen.go @@ -0,0 +1,169 @@ +package gen + +import ( + "context" + "errors" + "fmt" + "time" + + commandruntime "github.com/devctllabs/devctl/cmd/devctl/internal/command" + "github.com/devctllabs/devctl/internal/deps" + generatedomain "github.com/devctllabs/devctl/internal/domain/generate" + "github.com/devctllabs/go-libs/lifecycle" + "github.com/urfave/cli/v3" + "go.uber.org/zap" +) + +//go:generate go tool mockgen -destination mocks/gen.go -package mocks -typed . generator + +type generator interface { + // Generate returns completed target facts even when a later generation step fails. + Generate(ctx context.Context, command generatedomain.Command) (generatedomain.Result, error) +} + +// genRuntime contains the application port and cleanup hook used by Action. +type genRuntime struct { + generator generator + shutdown func(context.Context) error +} + +// genBuilder isolates dependency construction from generation behavior. +type genBuilder func(ctx context.Context, logger *zap.Logger) (genRuntime, error) + +// genCmd owns parsed options, a generation family selector, and its runtime factory. +type genCmd struct { + opts genCmdOpts + family string + buildRuntime genBuilder +} + +// genCmdOpts receives common flags plus generation target selection. +type genCmdOpts struct { + commandruntime.CommonCmdOpts + Target string + DryRun bool +} + +// genLeafSpec identifies the command node and the generation scope it selects. +type genLeafSpec struct { + name string + family string + allowTarget bool +} + +// NewCmd constructs the gen command tree. +func NewCmd() *cli.Command { + return newGenCmd(genCmdOpts{}, buildGen) +} + +func newGenCmd(opts genCmdOpts, build genBuilder) *cli.Command { + command := newGenLeaf(genLeafSpec{name: "gen", allowTarget: true}, opts, build) + command.Commands = []*cli.Command{newGenConfigCmd(genCmdOpts{}, build), newGenHTTPCmd(genCmdOpts{}, build), newGenLeaf(genLeafSpec{name: "grpc", family: "grpc", allowTarget: true}, genCmdOpts{}, build), newGenLeaf(genLeafSpec{name: "kafka", family: "kafka", allowTarget: true}, genCmdOpts{}, build)} + return command +} + +// newGenLeaf constructs one executable generation node for the selected family. +func newGenLeaf(spec genLeafSpec, opts genCmdOpts, build genBuilder) *cli.Command { + cmd := &genCmd{opts: opts, family: spec.family, buildRuntime: build} + usageText := "devctl gen" + if spec.name != "gen" { + usageText += " " + spec.name + } + if spec.allowTarget { + usageText += " [--target ]" + } + usageText += " [--dry-run]" + flags := append(cmd.opts.CommonFlags(), &cli.BoolFlag{Name: "dry-run", Destination: &cmd.opts.DryRun, Usage: "preview Managed Outputs without running generators or writing files"}) + if spec.allowTarget { + flags = append(flags, &cli.StringFlag{Name: "target", Destination: &cmd.opts.Target, Usage: "select one exact generation Target `id`"}) + } + return &cli.Command{ + Name: spec.name, + Usage: "Generate Managed Outputs", + Description: genDescription(spec.family), + UsageText: usageText, + Flags: flags, + OnUsageError: func(_ context.Context, command *cli.Command, err error, _ bool) error { + reporter := commandruntime.NewErrorReporter(cmd.opts.NewStderrLogger(command), cmd.opts.Verbose) + return reporter.ReportError(cli.Exit(err, 2)) + }, + Action: cmd.Action, + } +} + +func genDescription(family string) string { + if family == "" { + return "Run the Project-owned generators for every supported Target and atomically publish each Target's Managed Output. Generation never synchronizes or lints implicitly." + } + return "Run the Project-owned generators for " + family + " Targets and atomically publish their Managed Outputs without synchronizing or linting implicitly." +} + +// Action generates the selected managed outputs and emits the completed or partial result. +func (cmd *genCmd) Action(ctx context.Context, command *cli.Command) error { + stdout := cmd.opts.NewStdoutLogger(command) + stderr := cmd.opts.NewStderrLogger(command) + reporter := commandruntime.NewErrorReporter(stderr, cmd.opts.Verbose) + if command.Args().Len() != 0 { + return reporter.ReportError(cli.Exit("gen accepts no positional arguments", 2)) + } + runtime, err := cmd.buildRuntime(ctx, stderr) + if err != nil { + return reporter.ReportError(err) + } + result, operationErr := runtime.generator.Generate(ctx, generatedomain.Command{ + ManifestPath: cmd.opts.ManifestPath, + Family: cmd.family, + Target: cmd.opts.Target, + DryRun: cmd.opts.DryRun, + }) + shutdownErr := lifecycle.Shutdown(ctx, 5*time.Second, runtime.shutdown) + dto := generateDTO(result) + if finalErr := errors.Join(operationErr, shutdownErr); finalErr != nil { + var options []commandruntime.ErrorOption + if !result.DryRun && (len(result.Targets) > 0 || len(result.Changes) > 0) { + options = append(options, commandruntime.WithPartialResult(dto)) + } + return reporter.ReportError(finalErr, options...) + } + stdout.Info("managed output generation completed", zap.Any("data", dto)) + return nil +} + +// changeDTO is one stable managed-file change fact. +type changeDTO struct { + Target string `json:"target"` + Path string `json:"path"` + Action string `json:"action"` +} + +// resultDTO contains every completed generation target and file change. +type resultDTO struct { + Targets []string `json:"targets"` + Changes []changeDTO `json:"changes"` + DryRun bool `json:"dry_run"` +} + +// generateDTO converts the domain result into the stable CLI payload. +func generateDTO(result generatedomain.Result) resultDTO { + targets := make([]string, len(result.Targets)) + copy(targets, result.Targets) + changes := make([]changeDTO, 0, len(result.Changes)) + for _, change := range result.Changes { + changes = append(changes, changeDTO{Target: change.Target, Path: change.Path, Action: string(change.Action)}) + } + return resultDTO{Targets: targets, Changes: changes, DryRun: result.DryRun} +} + +// buildGen constructs and resolves the lazy dependencies owned by gen. +func buildGen(ctx context.Context, logger *zap.Logger) (genRuntime, error) { + container, err := deps.New(logger) + if err != nil { + return genRuntime{}, fmt.Errorf("deps.New: %w", err) + } + service, err := container.GenService() + if err != nil { + shutdownErr := commandruntime.Shutdown(ctx, container) + return genRuntime{}, errors.Join(fmt.Errorf("container.GenService: %w", err), shutdownErr) + } + return genRuntime{generator: service, shutdown: container.Shutdown}, nil +} diff --git a/cmd/devctl/internal/gen/gen_test.go b/cmd/devctl/internal/gen/gen_test.go new file mode 100644 index 0000000..e448a3a --- /dev/null +++ b/cmd/devctl/internal/gen/gen_test.go @@ -0,0 +1,78 @@ +package gen + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "testing" + + "github.com/devctllabs/devctl/cmd/devctl/internal/gen/mocks" + generatedomain "github.com/devctllabs/devctl/internal/domain/generate" + "github.com/stretchr/testify/require" + "github.com/urfave/cli/v3" + "go.uber.org/mock/gomock" + "go.uber.org/zap" +) + +func TestGenCommandUsesGenService(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + generator := mocks.NewMockgenerator(ctrl) + generator.EXPECT().Generate(gomock.Any(), generatedomain.Command{ + ManifestPath: "custom.yaml", + Target: "config", + DryRun: true, + }).Return(generatedomain.Result{ + Targets: []string{"config"}, + Changes: []generatedomain.Change{{Target: "config", Path: "gen/config/config.gen.go", Action: generatedomain.ChangePlannedPublish}}, + DryRun: true, + }, nil) + command := newGenCmd(genCmdOpts{}, func(_ context.Context, logger *zap.Logger) (genRuntime, error) { + require.NotNil(t, logger) + return genRuntime{generator: generator, shutdown: func(context.Context) error { return nil }}, nil + }) + var stdout bytes.Buffer + command.Writer = &stdout + + err := command.Run(context.Background(), []string{ + "gen", "--file", "custom.yaml", "--json", "--verbose", "--target", "config", "--dry-run", + }) + + require.NoError(t, err) + require.Contains(t, stdout.String(), `"msg":"managed output generation completed"`) + require.Contains(t, stdout.String(), `"command":"gen"`) + require.Contains(t, stdout.String(), `"data":{"targets":["config"],"changes":[{"target":"config","path":"gen/config/config.gen.go","action":"planned_publish"}],"dry_run":true}`) +} + +func TestGenReportsOperationAndShutdownFailuresWithPartialData(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + generator := mocks.NewMockgenerator(ctrl) + operationErr := errors.New("generation failed") + shutdownErr := errors.New("shutdown failed") + generator.EXPECT().Generate(gomock.Any(), generatedomain.Command{}).Return( + generatedomain.Result{Targets: []string{"config"}}, operationErr, + ) + command := newGenCmd(genCmdOpts{}, func(context.Context, *zap.Logger) (genRuntime, error) { + return genRuntime{generator: generator, shutdown: func(context.Context) error { return shutdownErr }}, nil + }) + command.ExitErrHandler = func(context.Context, *cli.Command, error) {} + var stderr bytes.Buffer + command.ErrWriter = &stderr + + err := command.Run(context.Background(), []string{"gen", "--json"}) + + require.ErrorIs(t, err, operationErr) + require.ErrorIs(t, err, shutdownErr) + var event map[string]any + require.NoError(t, json.Unmarshal(stderr.Bytes(), &event)) + require.NotContains(t, event, "data") + details, ok := event["details"].(map[string]any) + require.True(t, ok) + require.Equal(t, map[string]any{ + "targets": []any{"config"}, "changes": []any{}, "dry_run": false, + }, details["partial_result"]) +} diff --git a/cmd/devctl/internal/gen/http.go b/cmd/devctl/internal/gen/http.go new file mode 100644 index 0000000..ce7d6c4 --- /dev/null +++ b/cmd/devctl/internal/gen/http.go @@ -0,0 +1,8 @@ +package gen + +import "github.com/urfave/cli/v3" + +// newGenHTTPCmd constructs the HTTP generation leaf. +func newGenHTTPCmd(opts genCmdOpts, build genBuilder) *cli.Command { + return newGenLeaf(genLeafSpec{name: "http", family: "http", allowTarget: true}, opts, build) +} diff --git a/cmd/devctl/internal/gen/mocks/gen.go b/cmd/devctl/internal/gen/mocks/gen.go new file mode 100644 index 0000000..a4fe601 --- /dev/null +++ b/cmd/devctl/internal/gen/mocks/gen.go @@ -0,0 +1,81 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/devctllabs/devctl/cmd/devctl/internal/gen (interfaces: generator) +// +// Generated by this command: +// +// mockgen -destination mocks/gen.go -package mocks -typed . generator +// + +// Package mocks is a generated GoMock package. +package mocks + +import ( + context "context" + reflect "reflect" + + generate "github.com/devctllabs/devctl/internal/domain/generate" + gomock "go.uber.org/mock/gomock" +) + +// Mockgenerator is a mock of generator interface. +type Mockgenerator struct { + ctrl *gomock.Controller + recorder *MockgeneratorMockRecorder + isgomock struct{} +} + +// MockgeneratorMockRecorder is the mock recorder for Mockgenerator. +type MockgeneratorMockRecorder struct { + mock *Mockgenerator +} + +// NewMockgenerator creates a new mock instance. +func NewMockgenerator(ctrl *gomock.Controller) *Mockgenerator { + mock := &Mockgenerator{ctrl: ctrl} + mock.recorder = &MockgeneratorMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *Mockgenerator) EXPECT() *MockgeneratorMockRecorder { + return m.recorder +} + +// Generate mocks base method. +func (m *Mockgenerator) Generate(ctx context.Context, command generate.Command) (generate.Result, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Generate", ctx, command) + ret0, _ := ret[0].(generate.Result) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Generate indicates an expected call of Generate. +func (mr *MockgeneratorMockRecorder) Generate(ctx, command any) *MockgeneratorGenerateCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Generate", reflect.TypeOf((*Mockgenerator)(nil).Generate), ctx, command) + return &MockgeneratorGenerateCall{Call: call} +} + +// MockgeneratorGenerateCall wrap *gomock.Call +type MockgeneratorGenerateCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockgeneratorGenerateCall) Return(arg0 generate.Result, arg1 error) *MockgeneratorGenerateCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockgeneratorGenerateCall) Do(f func(context.Context, generate.Command) (generate.Result, error)) *MockgeneratorGenerateCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockgeneratorGenerateCall) DoAndReturn(f func(context.Context, generate.Command) (generate.Result, error)) *MockgeneratorGenerateCall { + c.Call = c.Call.DoAndReturn(f) + return c +} diff --git a/cmd/devctl/internal/init/init.go b/cmd/devctl/internal/init/init.go new file mode 100644 index 0000000..072a490 --- /dev/null +++ b/cmd/devctl/internal/init/init.go @@ -0,0 +1,16 @@ +package initcmd + +import "github.com/urfave/cli/v3" + +// NewCmd constructs the namespace for project initialization commands. +func NewCmd() *cli.Command { + return &cli.Command{ + Name: "init", + Usage: "Initialize a Devctl project", + Description: "Create the canonical Manifest or materialize the Go project foundation declared by an existing Manifest. Initialization steps are explicit and never run one another implicitly.", + Commands: []*cli.Command{ + newManifestCmd(manifestCmdOpts{}, buildManifest), + newScaffoldCmd(scaffoldCmdOpts{}, buildScaffold), + }, + } +} diff --git a/cmd/devctl/internal/init/init_test.go b/cmd/devctl/internal/init/init_test.go new file mode 100644 index 0000000..9468a50 --- /dev/null +++ b/cmd/devctl/internal/init/init_test.go @@ -0,0 +1,135 @@ +package initcmd + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "os" + "path/filepath" + "testing" + + "github.com/devctllabs/devctl/cmd/devctl/internal/init/mocks" + projectdomain "github.com/devctllabs/devctl/internal/domain/project" + scaffolddomain "github.com/devctllabs/devctl/internal/domain/scaffold" + "github.com/stretchr/testify/require" + "github.com/urfave/cli/v3" + "go.uber.org/mock/gomock" + "go.uber.org/zap" +) + +func TestInitManifestWritesCommandSpecificJSON(t *testing.T) { + t.Parallel() + destination := filepath.Join(t.TempDir(), "devctl.yaml") + command := newManifestCmd(manifestCmdOpts{}, buildManifest) + var stdout bytes.Buffer + command.Writer = &stdout + + err := command.Run(context.Background(), []string{ + "manifest", "--file", destination, "--json", + "--lang", "go", "--preset", "cli", "--name", "sample", "--module", "example.test/sample", + }) + + require.NoError(t, err) + require.Contains(t, stdout.String(), `"msg":"manifest initialization completed"`) + require.Contains(t, stdout.String(), `"command":"manifest"`) + require.Contains(t, stdout.String(), `"data":{"manifest":"`+destination+`","change":"created"}`) + _, err = os.Stat(destination) + require.NoError(t, err) +} + +func TestInitManifestForceReplacesDifferentRegularFile(t *testing.T) { + t.Parallel() + destination := filepath.Join(t.TempDir(), "devctl.yaml") + require.NoError(t, os.WriteFile(destination, []byte("different\n"), 0o644)) + command := newManifestCmd(manifestCmdOpts{}, buildManifest) + var stdout bytes.Buffer + command.Writer = &stdout + + err := command.Run(context.Background(), []string{ + "manifest", "--file", destination, "--json", "--force", + "--lang", "go", "--preset", "cli", "--name", "sample", "--module", "example.test/sample", + }) + + require.NoError(t, err) + require.Contains(t, stdout.String(), `"data":{"manifest":"`+destination+`","change":"updated"}`) +} + +func TestInitManifestReportsOperationAndShutdownFailuresWithManifestData(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + initializer := mocks.NewMockmanifestInitializer(ctrl) + operationErr := errors.New("initialization failed") + shutdownErr := errors.New("shutdown failed") + initializer.EXPECT().InitManifest(gomock.Any(), projectdomain.InitManifestCommand{ + Language: "go", Preset: "cli", Name: "sample", Module: "example.test/sample", + }).Return(projectdomain.ManifestResult{Manifest: "/project/devctl.yaml", Change: projectdomain.ChangeUpdated}, operationErr) + command := newManifestCmd(manifestCmdOpts{}, func(context.Context, *zap.Logger) (manifestRuntime, error) { + return manifestRuntime{initializer: initializer, shutdown: func(context.Context) error { return shutdownErr }}, nil + }) + command.ExitErrHandler = func(context.Context, *cli.Command, error) {} + var stderr bytes.Buffer + command.ErrWriter = &stderr + + err := command.Run(context.Background(), []string{ + "manifest", "--json", "--lang", "go", "--preset", "cli", "--name", "sample", "--module", "example.test/sample", + }) + + require.ErrorIs(t, err, operationErr) + require.ErrorIs(t, err, shutdownErr) + var event map[string]any + require.NoError(t, json.Unmarshal(stderr.Bytes(), &event)) + require.NotContains(t, event, "data") + details, ok := event["details"].(map[string]any) + require.True(t, ok) + require.Equal(t, map[string]any{"manifest": "/project/devctl.yaml", "change": "updated"}, details["partial_result"]) +} + +func TestInitScaffoldReportsOperationAndShutdownFailuresWithPartialData(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + scaffolder := mocks.NewMockscaffolder(ctrl) + operationErr := errors.New("scaffold failed") + shutdownErr := errors.New("shutdown failed") + scaffolder.EXPECT().Scaffold(gomock.Any(), scaffolddomain.Command{}).Return(scaffolddomain.Result{ + Files: []scaffolddomain.FileChange{{Path: "go.mod", Action: scaffolddomain.FileCreated}}, + }, operationErr) + command := newScaffoldCmd(scaffoldCmdOpts{}, func(context.Context, *zap.Logger) (scaffoldRuntime, error) { + return scaffoldRuntime{scaffolder: scaffolder, shutdown: func(context.Context) error { return shutdownErr }}, nil + }) + command.ExitErrHandler = func(context.Context, *cli.Command, error) {} + var stderr bytes.Buffer + command.ErrWriter = &stderr + + err := command.Run(context.Background(), []string{"scaffold", "--json"}) + + require.ErrorIs(t, err, operationErr) + require.ErrorIs(t, err, shutdownErr) + var event map[string]any + require.NoError(t, json.Unmarshal(stderr.Bytes(), &event)) + require.NotContains(t, event, "data") + details, ok := event["details"].(map[string]any) + require.True(t, ok) + require.Equal(t, map[string]any{"files": []any{map[string]any{"path": "go.mod", "action": "created"}}}, details["partial_result"]) +} + +func TestInitScaffoldDoesNotExposeForceFlag(t *testing.T) { + t.Parallel() + + command := newScaffoldCmd(scaffoldCmdOpts{}, func(context.Context, *zap.Logger) (scaffoldRuntime, error) { + require.FailNow(t, "runtime must not be built for an unknown flag") + return scaffoldRuntime{}, nil + }) + command.ExitErrHandler = func(context.Context, *cli.Command, error) {} + var stderr bytes.Buffer + command.ErrWriter = &stderr + + err := command.Run(context.Background(), []string{"scaffold", "--force"}) + + require.Error(t, err) + var exitCoder cli.ExitCoder + require.ErrorAs(t, err, &exitCoder) + require.Equal(t, 2, exitCoder.ExitCode()) +} diff --git a/cmd/devctl/internal/init/manifest.go b/cmd/devctl/internal/init/manifest.go new file mode 100644 index 0000000..982726e --- /dev/null +++ b/cmd/devctl/internal/init/manifest.go @@ -0,0 +1,127 @@ +package initcmd + +import ( + "context" + "errors" + "fmt" + "time" + + commandruntime "github.com/devctllabs/devctl/cmd/devctl/internal/command" + "github.com/devctllabs/devctl/internal/deps" + projectdomain "github.com/devctllabs/devctl/internal/domain/project" + "github.com/devctllabs/go-libs/lifecycle" + "github.com/urfave/cli/v3" + "go.uber.org/zap" +) + +//go:generate go tool mockgen -destination mocks/manifest.go -package mocks -typed . manifestInitializer + +type manifestInitializer interface { + // InitManifest creates or updates the canonical project manifest. + InitManifest(ctx context.Context, command projectdomain.InitManifestCommand) (projectdomain.ManifestResult, error) +} + +// manifestRuntime contains the application port and cleanup hook used by Action. +type manifestRuntime struct { + initializer manifestInitializer + shutdown func(context.Context) error +} + +// manifestBuilder isolates dependency construction from manifest initialization. +type manifestBuilder func(context.Context, *zap.Logger) (manifestRuntime, error) + +// manifestCmd owns parsed options and the runtime factory for one invocation. +type manifestCmd struct { + opts manifestCmdOpts + buildRuntime manifestBuilder +} + +// manifestCmdOpts receives common flags plus the manifest identity and overwrite policy. +type manifestCmdOpts struct { + commandruntime.CommonCmdOpts + Language string + Preset string + Name string + Module string + Force bool +} + +// newManifestCmd constructs the executable init manifest leaf. +func newManifestCmd(opts manifestCmdOpts, build manifestBuilder) *cli.Command { + cmd := &manifestCmd{opts: opts, buildRuntime: build} + flags := append(cmd.opts.CommonFlags(), + &cli.StringFlag{Name: "lang", Usage: "set the project language; supported value: `go`", Destination: &cmd.opts.Language}, + &cli.StringFlag{Name: "preset", Usage: "seed the Manifest from `cli` or `http-service`", Destination: &cmd.opts.Preset}, + &cli.StringFlag{Name: "name", Usage: "set the kebab-case `project-name`", Destination: &cmd.opts.Name}, + &cli.StringFlag{Name: "module", Usage: "set the Go `module-path`", Destination: &cmd.opts.Module}, + &cli.BoolFlag{Name: "force", Usage: "replace an existing Manifest instead of returning a conflict", Destination: &cmd.opts.Force}, + ) + return &cli.Command{ + Name: "manifest", + Usage: "Create devctl.yaml", + Description: "Create a complete v1 Manifest from a supported preset. This command writes only the Manifest; it does not scaffold files, install tools, synchronize Contracts, lint, or generate code.", + UsageText: "devctl init manifest --lang go --preset --name --module ", + Flags: flags, + OnUsageError: func(_ context.Context, command *cli.Command, err error, _ bool) error { + reporter := commandruntime.NewErrorReporter(cmd.opts.NewStderrLogger(command), cmd.opts.Verbose) + return reporter.ReportError(cli.Exit(err, 2)) + }, + Action: cmd.Action, + } +} + +// Action creates the selected project manifest and emits the resulting change. +func (cmd *manifestCmd) Action(ctx context.Context, command *cli.Command) error { + stdout := cmd.opts.NewStdoutLogger(command) + stderr := cmd.opts.NewStderrLogger(command) + reporter := commandruntime.NewErrorReporter(stderr, cmd.opts.Verbose) + if command.Args().Len() != 0 { + return reporter.ReportError(cli.Exit("init manifest accepts no positional arguments", 2)) + } + if cmd.opts.Language == "" || cmd.opts.Preset == "" || cmd.opts.Name == "" || cmd.opts.Module == "" { + return reporter.ReportError(cli.Exit("--lang, --preset, --name, and --module are required", 2)) + } + runtime, err := cmd.buildRuntime(ctx, stderr) + if err != nil { + return reporter.ReportError(err) + } + result, operationErr := runtime.initializer.InitManifest(ctx, projectdomain.InitManifestCommand{ + Destination: cmd.opts.ManifestPath, + Language: cmd.opts.Language, + Preset: cmd.opts.Preset, + Name: cmd.opts.Name, + Module: cmd.opts.Module, + Force: cmd.opts.Force, + }) + shutdownErr := lifecycle.Shutdown(ctx, 5*time.Second, runtime.shutdown) + dto := manifestResultDTO{Manifest: result.Manifest, Change: string(result.Change)} + var options []commandruntime.ErrorOption + if result.Change != "" { + options = append(options, commandruntime.WithPartialResult(dto)) + } + if finalErr := errors.Join(operationErr, shutdownErr); finalErr != nil { + return reporter.ReportError(finalErr, options...) + } + stdout.Info("manifest initialization completed", zap.Any("data", dto)) + return nil +} + +// manifestResultDTO is the stable manifest mutation payload. +type manifestResultDTO struct { + Manifest string `json:"manifest"` + Change string `json:"change"` +} + +// buildManifest constructs and resolves the lazy dependencies owned by init manifest. +func buildManifest(ctx context.Context, logger *zap.Logger) (manifestRuntime, error) { + container, err := deps.New(logger) + if err != nil { + return manifestRuntime{}, fmt.Errorf("deps.New: %w", err) + } + service, err := container.ProjectService() + if err != nil { + shutdownErr := commandruntime.Shutdown(ctx, container) + return manifestRuntime{}, errors.Join(fmt.Errorf("container.ProjectService: %w", err), shutdownErr) + } + return manifestRuntime{initializer: service, shutdown: container.Shutdown}, nil +} diff --git a/cmd/devctl/internal/init/mocks/manifest.go b/cmd/devctl/internal/init/mocks/manifest.go new file mode 100644 index 0000000..632be26 --- /dev/null +++ b/cmd/devctl/internal/init/mocks/manifest.go @@ -0,0 +1,81 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/devctllabs/devctl/cmd/devctl/internal/init (interfaces: manifestInitializer) +// +// Generated by this command: +// +// mockgen -destination mocks/manifest.go -package mocks -typed . manifestInitializer +// + +// Package mocks is a generated GoMock package. +package mocks + +import ( + context "context" + reflect "reflect" + + project "github.com/devctllabs/devctl/internal/domain/project" + gomock "go.uber.org/mock/gomock" +) + +// MockmanifestInitializer is a mock of manifestInitializer interface. +type MockmanifestInitializer struct { + ctrl *gomock.Controller + recorder *MockmanifestInitializerMockRecorder + isgomock struct{} +} + +// MockmanifestInitializerMockRecorder is the mock recorder for MockmanifestInitializer. +type MockmanifestInitializerMockRecorder struct { + mock *MockmanifestInitializer +} + +// NewMockmanifestInitializer creates a new mock instance. +func NewMockmanifestInitializer(ctrl *gomock.Controller) *MockmanifestInitializer { + mock := &MockmanifestInitializer{ctrl: ctrl} + mock.recorder = &MockmanifestInitializerMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockmanifestInitializer) EXPECT() *MockmanifestInitializerMockRecorder { + return m.recorder +} + +// InitManifest mocks base method. +func (m *MockmanifestInitializer) InitManifest(ctx context.Context, command project.InitManifestCommand) (project.ManifestResult, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "InitManifest", ctx, command) + ret0, _ := ret[0].(project.ManifestResult) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// InitManifest indicates an expected call of InitManifest. +func (mr *MockmanifestInitializerMockRecorder) InitManifest(ctx, command any) *MockmanifestInitializerInitManifestCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InitManifest", reflect.TypeOf((*MockmanifestInitializer)(nil).InitManifest), ctx, command) + return &MockmanifestInitializerInitManifestCall{Call: call} +} + +// MockmanifestInitializerInitManifestCall wrap *gomock.Call +type MockmanifestInitializerInitManifestCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockmanifestInitializerInitManifestCall) Return(arg0 project.ManifestResult, arg1 error) *MockmanifestInitializerInitManifestCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockmanifestInitializerInitManifestCall) Do(f func(context.Context, project.InitManifestCommand) (project.ManifestResult, error)) *MockmanifestInitializerInitManifestCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockmanifestInitializerInitManifestCall) DoAndReturn(f func(context.Context, project.InitManifestCommand) (project.ManifestResult, error)) *MockmanifestInitializerInitManifestCall { + c.Call = c.Call.DoAndReturn(f) + return c +} diff --git a/cmd/devctl/internal/init/mocks/scaffold.go b/cmd/devctl/internal/init/mocks/scaffold.go new file mode 100644 index 0000000..7b62c61 --- /dev/null +++ b/cmd/devctl/internal/init/mocks/scaffold.go @@ -0,0 +1,81 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/devctllabs/devctl/cmd/devctl/internal/init (interfaces: scaffolder) +// +// Generated by this command: +// +// mockgen -destination mocks/scaffold.go -package mocks -typed . scaffolder +// + +// Package mocks is a generated GoMock package. +package mocks + +import ( + context "context" + reflect "reflect" + + scaffold "github.com/devctllabs/devctl/internal/domain/scaffold" + gomock "go.uber.org/mock/gomock" +) + +// Mockscaffolder is a mock of scaffolder interface. +type Mockscaffolder struct { + ctrl *gomock.Controller + recorder *MockscaffolderMockRecorder + isgomock struct{} +} + +// MockscaffolderMockRecorder is the mock recorder for Mockscaffolder. +type MockscaffolderMockRecorder struct { + mock *Mockscaffolder +} + +// NewMockscaffolder creates a new mock instance. +func NewMockscaffolder(ctrl *gomock.Controller) *Mockscaffolder { + mock := &Mockscaffolder{ctrl: ctrl} + mock.recorder = &MockscaffolderMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *Mockscaffolder) EXPECT() *MockscaffolderMockRecorder { + return m.recorder +} + +// Scaffold mocks base method. +func (m *Mockscaffolder) Scaffold(ctx context.Context, command scaffold.Command) (scaffold.Result, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Scaffold", ctx, command) + ret0, _ := ret[0].(scaffold.Result) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Scaffold indicates an expected call of Scaffold. +func (mr *MockscaffolderMockRecorder) Scaffold(ctx, command any) *MockscaffolderScaffoldCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Scaffold", reflect.TypeOf((*Mockscaffolder)(nil).Scaffold), ctx, command) + return &MockscaffolderScaffoldCall{Call: call} +} + +// MockscaffolderScaffoldCall wrap *gomock.Call +type MockscaffolderScaffoldCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockscaffolderScaffoldCall) Return(arg0 scaffold.Result, arg1 error) *MockscaffolderScaffoldCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockscaffolderScaffoldCall) Do(f func(context.Context, scaffold.Command) (scaffold.Result, error)) *MockscaffolderScaffoldCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockscaffolderScaffoldCall) DoAndReturn(f func(context.Context, scaffold.Command) (scaffold.Result, error)) *MockscaffolderScaffoldCall { + c.Call = c.Call.DoAndReturn(f) + return c +} diff --git a/cmd/devctl/internal/init/scaffold.go b/cmd/devctl/internal/init/scaffold.go new file mode 100644 index 0000000..8cd60d0 --- /dev/null +++ b/cmd/devctl/internal/init/scaffold.go @@ -0,0 +1,119 @@ +package initcmd + +import ( + "context" + "errors" + "fmt" + "time" + + commandruntime "github.com/devctllabs/devctl/cmd/devctl/internal/command" + "github.com/devctllabs/devctl/internal/deps" + scaffolddomain "github.com/devctllabs/devctl/internal/domain/scaffold" + "github.com/devctllabs/go-libs/lifecycle" + "github.com/urfave/cli/v3" + "go.uber.org/zap" +) + +//go:generate go tool mockgen -destination mocks/scaffold.go -package mocks -typed . scaffolder + +type scaffolder interface { + // Scaffold creates or refreshes the generated project foundation. + Scaffold(ctx context.Context, command scaffolddomain.Command) (scaffolddomain.Result, error) +} + +// scaffoldRuntime contains the application port and cleanup hook used by Action. +type scaffoldRuntime struct { + scaffolder scaffolder + shutdown func(context.Context) error +} + +// scaffoldBuilder isolates dependency construction from scaffold behavior. +type scaffoldBuilder func(context.Context, *zap.Logger) (scaffoldRuntime, error) + +// scaffoldCmd owns parsed options and the runtime factory for one invocation. +type scaffoldCmd struct { + opts scaffoldCmdOpts + buildRuntime scaffoldBuilder +} + +// scaffoldCmdOpts receives common scaffold flags. +type scaffoldCmdOpts struct { + commandruntime.CommonCmdOpts +} + +// newScaffoldCmd constructs the executable init scaffold leaf. +func newScaffoldCmd(opts scaffoldCmdOpts, build scaffoldBuilder) *cli.Command { + cmd := &scaffoldCmd{opts: opts, buildRuntime: build} + return &cli.Command{ + Name: "scaffold", + Usage: "Create or refresh the Go project foundation", + Description: "Publish Devctl-managed project files and create missing Scaffold Seeds. Managed Outputs may be replaced; existing user-owned Seeds are never deliberately overwritten or deleted.", + UsageText: "devctl init scaffold [--file ]", + Flags: cmd.opts.CommonFlags(), + OnUsageError: func(_ context.Context, command *cli.Command, err error, _ bool) error { + reporter := commandruntime.NewErrorReporter(cmd.opts.NewStderrLogger(command), cmd.opts.Verbose) + return reporter.ReportError(cli.Exit(err, 2)) + }, + Action: cmd.Action, + } +} + +// Action scaffolds the selected project and emits every resulting file change. +func (cmd *scaffoldCmd) Action(ctx context.Context, command *cli.Command) error { + stdout := cmd.opts.NewStdoutLogger(command) + stderr := cmd.opts.NewStderrLogger(command) + reporter := commandruntime.NewErrorReporter(stderr, cmd.opts.Verbose) + if command.Args().Len() != 0 { + return reporter.ReportError(cli.Exit("init scaffold accepts no positional arguments", 2)) + } + runtime, err := cmd.buildRuntime(ctx, stderr) + if err != nil { + return reporter.ReportError(err) + } + result, operationErr := runtime.scaffolder.Scaffold(ctx, scaffolddomain.Command{ManifestPath: cmd.opts.ManifestPath}) + shutdownErr := lifecycle.Shutdown(ctx, 5*time.Second, runtime.shutdown) + dto := scaffoldDTO(result) + if finalErr := errors.Join(operationErr, shutdownErr); finalErr != nil { + var options []commandruntime.ErrorOption + if len(result.Files) > 0 { + options = append(options, commandruntime.WithPartialResult(dto)) + } + return reporter.ReportError(finalErr, options...) + } + stdout.Info("project scaffolding completed", zap.Any("data", dto)) + return nil +} + +// scaffoldFileDTO is one stable generated-file change fact. +type scaffoldFileDTO struct { + Path string `json:"path"` + Action string `json:"action"` +} + +// scaffoldResultDTO contains every file considered by the scaffold operation. +type scaffoldResultDTO struct { + Files []scaffoldFileDTO `json:"files"` +} + +// scaffoldDTO converts the domain result into the stable CLI payload. +func scaffoldDTO(result scaffolddomain.Result) scaffoldResultDTO { + files := make([]scaffoldFileDTO, 0, len(result.Files)) + for _, file := range result.Files { + files = append(files, scaffoldFileDTO{Path: file.Path, Action: string(file.Action)}) + } + return scaffoldResultDTO{Files: files} +} + +// buildScaffold constructs and resolves the lazy dependencies owned by init scaffold. +func buildScaffold(ctx context.Context, logger *zap.Logger) (scaffoldRuntime, error) { + container, err := deps.New(logger) + if err != nil { + return scaffoldRuntime{}, fmt.Errorf("deps.New: %w", err) + } + service, err := container.ScaffoldService() + if err != nil { + shutdownErr := commandruntime.Shutdown(ctx, container) + return scaffoldRuntime{}, errors.Join(fmt.Errorf("container.ScaffoldService: %w", err), shutdownErr) + } + return scaffoldRuntime{scaffolder: service, shutdown: container.Shutdown}, nil +} diff --git a/cmd/devctl/internal/inspect/inspect.go b/cmd/devctl/internal/inspect/inspect.go new file mode 100644 index 0000000..9ace52f --- /dev/null +++ b/cmd/devctl/internal/inspect/inspect.go @@ -0,0 +1,207 @@ +package inspect + +import ( + "context" + "errors" + "fmt" + + commandruntime "github.com/devctllabs/devctl/cmd/devctl/internal/command" + "github.com/devctllabs/devctl/internal/deps" + projectdomain "github.com/devctllabs/devctl/internal/domain/project" + "github.com/urfave/cli/v3" + "go.uber.org/zap" +) + +//go:generate go tool mockgen -destination mocks/inspect.go -package mocks -typed . projectInspector + +type projectInspector interface { + // Inspect returns the effective project view selected by query. + Inspect(ctx context.Context, query projectdomain.InspectQuery) (projectdomain.InspectResult, error) +} + +// inspectRuntime contains the application port and cleanup hook used by Action. +type inspectRuntime struct { + inspector projectInspector + shutdown func(context.Context) error +} + +// inspectBuilder isolates dependency construction from command behavior. +type inspectBuilder func(context.Context, *zap.Logger) (inspectRuntime, error) + +// inspectCmd owns parsed options and the runtime factory for one invocation. +type inspectCmd struct { + opts inspectCmdOpts + buildRuntime inspectBuilder +} + +// inspectCmdOpts receives the common leaf flags bound by urfave/cli. +type inspectCmdOpts struct { + commandruntime.CommonCmdOpts +} + +// inspectProjectDTO is the stable effective-project payload emitted by inspect. +type inspectProjectDTO struct { + Root string `json:"root"` + ManifestPath string `json:"manifest_path"` + Name string `json:"name"` + Language string `json:"language"` + Module string `json:"module"` + EnvPrefix string `json:"env_prefix"` + Paths inspectPathsDTO `json:"paths"` + Targets []inspectTargetDTO `json:"targets"` + Env []inspectEnvDTO `json:"env"` + Resources inspectResourcesDTO `json:"resources"` +} + +type inspectTargetDTO struct { + ID string `json:"id"` + Family string `json:"family"` + Format string `json:"format"` + Input string `json:"input,omitempty"` + ResolvedInput string `json:"resolved_input,omitempty"` + Config string `json:"config,omitempty"` + Output string `json:"output,omitempty"` +} + +type inspectEnvDTO struct { + Key string `json:"key"` + Type string `json:"type"` + Default any `json:"default,omitempty"` + Secret bool `json:"secret,omitempty"` +} + +type inspectResourcesDTO struct { + DBConnections []string `json:"db_connections"` + RedisConnections []string `json:"redis_connections"` + S3Connections []string `json:"s3_connections"` + S3Buckets []string `json:"s3_buckets"` + Migrations []inspectMigrationDTO `json:"migrations"` +} + +type inspectMigrationDTO struct { + Connection string `json:"connection"` + Variant string `json:"variant"` + Kind string `json:"kind"` + Path string `json:"path"` + DatabaseEnv string `json:"database_env"` +} + +// inspectPathsDTO contains effective project-relative output locations. +type inspectPathsDTO struct { + ExternalContracts string `json:"external_contracts"` + ConfigOut string `json:"config_out"` + ServerOut string `json:"server_out"` + ClientOut string `json:"client_out"` +} + +// inspectResultDTO wraps the effective project under the public data schema. +type inspectResultDTO struct { + Project inspectProjectDTO `json:"project"` +} + +// NewCmd constructs the inspect command. +func NewCmd() *cli.Command { + return newInspectCmd(inspectCmdOpts{}, buildInspect) +} + +func newInspectCmd(opts inspectCmdOpts, build inspectBuilder) *cli.Command { + cmd := &inspectCmd{opts: opts, buildRuntime: build} + return &cli.Command{ + Name: "inspect", + Usage: "Inspect effective Project configuration", + Description: "Show the selected Project root, effective paths, Target Catalog, Runtime Config, Resources, and resolved Contract inputs without requiring every external Snapshot to be ready.", + UsageText: "devctl inspect [--file ]", + Flags: cmd.opts.CommonFlags(), + OnUsageError: func(_ context.Context, command *cli.Command, err error, _ bool) error { + reporter := commandruntime.NewErrorReporter(cmd.opts.NewStderrLogger(command), cmd.opts.Verbose) + return reporter.ReportError(cli.Exit(err, 2)) + }, + Action: cmd.Action, + } +} + +// Action inspects the selected project and emits its effective configuration. +func (cmd *inspectCmd) Action(ctx context.Context, command *cli.Command) error { + stdout := cmd.opts.NewStdoutLogger(command) + stderr := cmd.opts.NewStderrLogger(command) + reporter := commandruntime.NewErrorReporter(stderr, cmd.opts.Verbose) + if command.Args().Len() != 0 { + return reporter.ReportError(cli.Exit("inspect accepts no positional arguments", 2)) + } + runtime, err := cmd.buildRuntime(ctx, stderr) + if err != nil { + return reporter.ReportError(err) + } + result, inspectErr := runtime.inspector.Inspect(ctx, projectdomain.InspectQuery{ManifestPath: cmd.opts.ManifestPath}) + shutdownErr := runtime.shutdown(ctx) + dto := inspectDTO(result) + var options []commandruntime.ErrorOption + if inspectErr == nil { + options = append(options, commandruntime.WithPartialResult(dto)) + } + if finalErr := errors.Join(inspectErr, shutdownErr); finalErr != nil { + return reporter.ReportError(finalErr, options...) + } + stdout.Info("project inspection completed", zap.Any("data", dto)) + return nil +} + +// inspectDTO converts a domain inspection into the stable CLI payload. +func inspectDTO(result projectdomain.InspectResult) inspectResultDTO { + project := result.Project + targets := make([]inspectTargetDTO, len(project.Targets)) + for index, target := range project.Targets { + targets[index] = inspectTargetDTO{ + ID: target.ID, Family: target.Family, Format: target.Format, + Input: target.Input, ResolvedInput: target.ResolvedInput, + Config: target.Config, Output: target.Output, + } + } + env := make([]inspectEnvDTO, len(project.Env)) + for index, entry := range project.Env { + env[index] = inspectEnvDTO{Key: entry.Key, Type: entry.Type, Default: entry.Default, Secret: entry.Secret} + if entry.Secret { + env[index].Default = nil + } + } + migrations := make([]inspectMigrationDTO, len(project.Resources.Migrations)) + for index, migration := range project.Resources.Migrations { + migrations[index] = inspectMigrationDTO{ + Connection: migration.Connection, Variant: migration.Variant, Kind: migration.Kind, + Path: migration.Path, DatabaseEnv: migration.DatabaseEnv, + } + } + return inspectResultDTO{Project: inspectProjectDTO{ + Root: project.Root, ManifestPath: project.ManifestPath, Name: project.Name, + Language: project.Language, Module: project.Module, EnvPrefix: project.EnvPrefix, + Paths: inspectPathsDTO{ + ExternalContracts: project.Paths.ExternalContracts, ConfigOut: project.Paths.ConfigOut, + ServerOut: project.Paths.ServerOut, ClientOut: project.Paths.ClientOut, + }, + Targets: targets, Env: env, + Resources: inspectResourcesDTO{ + DBConnections: project.Resources.DBConnections, RedisConnections: project.Resources.RedisConnections, + S3Connections: project.Resources.S3Connections, S3Buckets: project.Resources.S3Buckets, + Migrations: migrations, + }, + }} +} + +// buildInspect constructs and resolves the lazy dependencies owned by inspect. +func buildInspect(ctx context.Context, logger *zap.Logger) (inspectRuntime, error) { + container, err := deps.New(logger) + if err != nil { + return inspectRuntime{}, fmt.Errorf("deps.New: %w", err) + } + service, err := container.ProjectService() + if err != nil { + shutdownErr := commandruntime.Shutdown(ctx, container) + return inspectRuntime{}, errors.Join(fmt.Errorf("container.ProjectService: %w", err), shutdownErr) + } + return inspectRuntime{ + inspector: service, + shutdown: func(shutdownCtx context.Context) error { + return commandruntime.Shutdown(shutdownCtx, container) + }, + }, nil +} diff --git a/cmd/devctl/internal/inspect/inspect_test.go b/cmd/devctl/internal/inspect/inspect_test.go new file mode 100644 index 0000000..1d926f8 --- /dev/null +++ b/cmd/devctl/internal/inspect/inspect_test.go @@ -0,0 +1,101 @@ +package inspect + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "testing" + + "github.com/devctllabs/devctl/cmd/devctl/internal/inspect/mocks" + projectdomain "github.com/devctllabs/devctl/internal/domain/project" + "github.com/stretchr/testify/require" + "github.com/urfave/cli/v3" + "go.uber.org/mock/gomock" + "go.uber.org/zap" +) + +func TestInspectActionEmitsEffectiveProjectEvent(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + inspector := mocks.NewMockprojectInspector(ctrl) + inspector.EXPECT().Inspect(gomock.Any(), projectdomain.InspectQuery{ManifestPath: "custom.yaml"}).Return(projectdomain.InspectResult{ + Project: projectdomain.Inspection{ + Root: "/work", ManifestPath: "/work/devctl.yaml", Name: "sample", Language: "go", + Module: "example.test/sample", EnvPrefix: "SAMPLE_", Targets: []projectdomain.InspectionTarget{{ + ID: "api", Family: "http", Format: "openapi", Input: "api/external/http/client/api", + ResolvedInput: "api/external/http/client/api/openapi.yaml", Config: "tools/oapi/api.yaml", + }}, + Paths: projectdomain.Paths{ExternalContracts: "api/external", ConfigOut: "gen/config", ServerOut: "gen/serverhttp", ClientOut: "gen/clienthttp"}, + }, + }, nil) + command := newInspectCmd(inspectCmdOpts{}, func(context.Context, *zap.Logger) (inspectRuntime, error) { + return inspectRuntime{inspector: inspector, shutdown: func(context.Context) error { return nil }}, nil + }) + var stdout bytes.Buffer + command.Writer = &stdout + + err := command.Run(context.Background(), []string{"inspect", "--file", "custom.yaml", "--json"}) + + require.NoError(t, err) + require.Contains(t, stdout.String(), `"msg":"project inspection completed"`) + require.Contains(t, stdout.String(), `"command":"inspect"`) + require.Contains(t, stdout.String(), `"data":{"project":{"root":"/work"`) + require.Contains(t, stdout.String(), `"resolved_input":"api/external/http/client/api/openapi.yaml"`) + require.Contains(t, stdout.String(), `"config":"tools/oapi/api.yaml"`) + require.NotContains(t, stdout.String(), "next_steps") + require.NotContains(t, stdout.String(), `"status"`) +} + +func TestInspectActionReportsServiceErrorOnce(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + inspector := mocks.NewMockprojectInspector(ctrl) + cause := errors.New("inspect failed") + shutdownErr := errors.New("shutdown failed") + inspector.EXPECT().Inspect(gomock.Any(), projectdomain.InspectQuery{}).Return(projectdomain.InspectResult{}, cause) + command := newInspectCmd(inspectCmdOpts{}, func(context.Context, *zap.Logger) (inspectRuntime, error) { + return inspectRuntime{inspector: inspector, shutdown: func(context.Context) error { return shutdownErr }}, nil + }) + command.ExitErrHandler = func(context.Context, *cli.Command, error) {} + var stderr bytes.Buffer + command.ErrWriter = &stderr + + err := command.Run(context.Background(), []string{"inspect", "--json"}) + + require.ErrorIs(t, err, cause) + require.ErrorIs(t, err, shutdownErr) + require.Empty(t, err.Error()) + require.Equal(t, 1, bytes.Count(stderr.Bytes(), []byte(`"level":"error"`))) + require.NotContains(t, stderr.String(), `"data"`) +} + +func TestInspectActionReportsCompletedInspectionAfterShutdownFailure(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + inspector := mocks.NewMockprojectInspector(ctrl) + shutdownErr := errors.New("shutdown failed") + inspector.EXPECT().Inspect(gomock.Any(), projectdomain.InspectQuery{}).Return(projectdomain.InspectResult{ + Project: projectdomain.Inspection{Name: "sample"}, + }, nil) + command := newInspectCmd(inspectCmdOpts{}, func(context.Context, *zap.Logger) (inspectRuntime, error) { + return inspectRuntime{inspector: inspector, shutdown: func(context.Context) error { return shutdownErr }}, nil + }) + command.ExitErrHandler = func(context.Context, *cli.Command, error) {} + var stderr bytes.Buffer + command.ErrWriter = &stderr + + err := command.Run(context.Background(), []string{"inspect", "--json"}) + + require.ErrorIs(t, err, shutdownErr) + var event map[string]any + require.NoError(t, json.Unmarshal(stderr.Bytes(), &event)) + require.NotContains(t, event, "data") + details, ok := event["details"].(map[string]any) + require.True(t, ok) + partial, ok := details["partial_result"].(map[string]any) + require.True(t, ok) + project, ok := partial["project"].(map[string]any) + require.True(t, ok) + require.Equal(t, "sample", project["name"]) +} diff --git a/cmd/devctl/internal/inspect/mocks/inspect.go b/cmd/devctl/internal/inspect/mocks/inspect.go new file mode 100644 index 0000000..be96666 --- /dev/null +++ b/cmd/devctl/internal/inspect/mocks/inspect.go @@ -0,0 +1,81 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/devctllabs/devctl/cmd/devctl/internal/inspect (interfaces: projectInspector) +// +// Generated by this command: +// +// mockgen -destination mocks/inspect.go -package mocks -typed . projectInspector +// + +// Package mocks is a generated GoMock package. +package mocks + +import ( + context "context" + reflect "reflect" + + project "github.com/devctllabs/devctl/internal/domain/project" + gomock "go.uber.org/mock/gomock" +) + +// MockprojectInspector is a mock of projectInspector interface. +type MockprojectInspector struct { + ctrl *gomock.Controller + recorder *MockprojectInspectorMockRecorder + isgomock struct{} +} + +// MockprojectInspectorMockRecorder is the mock recorder for MockprojectInspector. +type MockprojectInspectorMockRecorder struct { + mock *MockprojectInspector +} + +// NewMockprojectInspector creates a new mock instance. +func NewMockprojectInspector(ctrl *gomock.Controller) *MockprojectInspector { + mock := &MockprojectInspector{ctrl: ctrl} + mock.recorder = &MockprojectInspectorMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockprojectInspector) EXPECT() *MockprojectInspectorMockRecorder { + return m.recorder +} + +// Inspect mocks base method. +func (m *MockprojectInspector) Inspect(ctx context.Context, query project.InspectQuery) (project.InspectResult, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Inspect", ctx, query) + ret0, _ := ret[0].(project.InspectResult) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Inspect indicates an expected call of Inspect. +func (mr *MockprojectInspectorMockRecorder) Inspect(ctx, query any) *MockprojectInspectorInspectCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Inspect", reflect.TypeOf((*MockprojectInspector)(nil).Inspect), ctx, query) + return &MockprojectInspectorInspectCall{Call: call} +} + +// MockprojectInspectorInspectCall wrap *gomock.Call +type MockprojectInspectorInspectCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockprojectInspectorInspectCall) Return(arg0 project.InspectResult, arg1 error) *MockprojectInspectorInspectCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockprojectInspectorInspectCall) Do(f func(context.Context, project.InspectQuery) (project.InspectResult, error)) *MockprojectInspectorInspectCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockprojectInspectorInspectCall) DoAndReturn(f func(context.Context, project.InspectQuery) (project.InspectResult, error)) *MockprojectInspectorInspectCall { + c.Call = c.Call.DoAndReturn(f) + return c +} diff --git a/cmd/devctl/internal/lint/http.go b/cmd/devctl/internal/lint/http.go new file mode 100644 index 0000000..0abb0ad --- /dev/null +++ b/cmd/devctl/internal/lint/http.go @@ -0,0 +1,8 @@ +package lint + +import "github.com/urfave/cli/v3" + +// newLintHTTPCmd constructs the HTTP-only lint leaf. +func newLintHTTPCmd(opts lintCmdOpts, build lintBuilder) *cli.Command { + return newLintLeaf("http", "http", opts, build) +} diff --git a/cmd/devctl/internal/lint/lint.go b/cmd/devctl/internal/lint/lint.go new file mode 100644 index 0000000..21c982b --- /dev/null +++ b/cmd/devctl/internal/lint/lint.go @@ -0,0 +1,173 @@ +package lint + +import ( + "context" + "errors" + "fmt" + "time" + + commandruntime "github.com/devctllabs/devctl/cmd/devctl/internal/command" + "github.com/devctllabs/devctl/internal/deps" + lintdomain "github.com/devctllabs/devctl/internal/domain/lint" + "github.com/devctllabs/go-libs/lifecycle" + "github.com/urfave/cli/v3" + "go.uber.org/zap" +) + +//go:generate go tool mockgen -destination mocks/lint.go -package mocks -typed . linter + +type linter interface { + // Lint returns collected findings even when a later contract cannot be processed. + Lint(ctx context.Context, command lintdomain.Command) (lintdomain.Result, error) +} + +// lintRuntime contains the application port and cleanup hook used by Action. +type lintRuntime struct { + linter linter + shutdown func(context.Context) error +} + +// lintBuilder isolates dependency construction from lint behavior. +type lintBuilder func(ctx context.Context, logger *zap.Logger) (lintRuntime, error) + +// lintCmd owns parsed options, a contract family selector, and its runtime factory. +type lintCmd struct { + opts lintCmdOpts + family string + buildRuntime lintBuilder +} + +// lintCmdOpts receives the common leaf flags bound by urfave/cli. +type lintCmdOpts struct { + commandruntime.CommonCmdOpts +} + +// NewCmd constructs the lint command tree. +func NewCmd() *cli.Command { + return newLintCmd(lintCmdOpts{}, buildLint) +} + +func newLintCmd(opts lintCmdOpts, build lintBuilder) *cli.Command { + command := newLintLeaf("lint", "", opts, build) + command.Commands = []*cli.Command{newLintHTTPCmd(lintCmdOpts{}, build), newLintLeaf("grpc", "grpc", lintCmdOpts{}, build), newLintLeaf("kafka", "kafka", lintCmdOpts{}, build)} + return command +} + +// newLintLeaf constructs one executable lint node for the selected contract family. +func newLintLeaf(name, family string, opts lintCmdOpts, build lintBuilder) *cli.Command { + cmd := &lintCmd{opts: opts, family: family, buildRuntime: build} + usageText := "devctl lint" + if family != "" { + usageText += " " + name + } + return &cli.Command{ + Name: name, + Usage: "Lint Project Contracts", + Description: lintDescription(family), + UsageText: usageText + " [--file ]", + Flags: cmd.opts.CommonFlags(), + OnUsageError: func(_ context.Context, command *cli.Command, err error, _ bool) error { + reporter := commandruntime.NewErrorReporter(cmd.opts.NewStderrLogger(command), cmd.opts.Verbose) + return reporter.ReportError(cli.Exit(err, 2)) + }, + Action: cmd.Action, + } +} + +func lintDescription(family string) string { + if family == "" { + return "Lint every supported Contract using committed local inputs. Findings are normal results and exit with status 1 without becoming execution errors." + } + return "Lint committed " + family + " Contracts without synchronizing or generating code. Findings are normal results and exit with status 1." +} + +// Action lints selected contracts and emits all collected findings. +func (cmd *lintCmd) Action(ctx context.Context, command *cli.Command) error { + stdout := cmd.opts.NewStdoutLogger(command) + stderr := cmd.opts.NewStderrLogger(command) + reporter := commandruntime.NewErrorReporter(stderr, cmd.opts.Verbose) + if command.Args().Len() != 0 { + return reporter.ReportError(cli.Exit("lint accepts no positional arguments", 2)) + } + runtime, err := cmd.buildRuntime(ctx, stderr) + if err != nil { + return reporter.ReportError(err) + } + result, operationErr := runtime.linter.Lint(ctx, lintdomain.Command{ManifestPath: cmd.opts.ManifestPath, Family: cmd.family}) + shutdownErr := lifecycle.Shutdown(ctx, 5*time.Second, runtime.shutdown) + dto := lintDTO(result) + if finalErr := errors.Join(operationErr, shutdownErr); finalErr != nil { + var options []commandruntime.ErrorOption + if len(result.Contracts) > 0 { + options = append(options, commandruntime.WithPartialResult(dto)) + } + return reporter.ReportError(finalErr, options...) + } + stdout.Info("contract lint completed", zap.Any("data", dto)) + if !result.Valid { + return cli.Exit("", 1) + } + return nil +} + +// issueDTO is one stable contract lint finding. +type issueDTO struct { + Code string `json:"code"` + Target string `json:"target,omitempty"` + Path string `json:"path,omitempty"` + Line int `json:"line,omitempty"` + Column int `json:"column,omitempty"` + Field string `json:"field,omitempty"` + Parameters *issueParametersDTO `json:"parameters,omitempty"` +} + +// issueParametersDTO carries finding-specific facts safe for CLI output. +type issueParametersDTO struct { + OperationID string `json:"operation_id,omitempty"` + Location string `json:"location,omitempty"` + Type string `json:"type,omitempty"` + Subtype string `json:"subtype,omitempty"` + SpecPath string `json:"spec_path,omitempty"` +} + +// resultDTO contains all contracts and findings inspected by one lint run. +type resultDTO struct { + Valid bool `json:"valid"` + Contracts []string `json:"contracts"` + Issues []issueDTO `json:"issues"` +} + +// lintDTO converts the domain result into the stable CLI payload. +func lintDTO(result lintdomain.Result) resultDTO { + contracts := make([]string, len(result.Contracts)) + copy(contracts, result.Contracts) + issues := make([]issueDTO, 0, len(result.Issues)) + for _, issue := range result.Issues { + var parameters *issueParametersDTO + if issue.Parameters != nil { + parameters = &issueParametersDTO{ + OperationID: issue.Parameters.OperationID, Location: issue.Parameters.Location, + Type: issue.Parameters.Type, Subtype: issue.Parameters.Subtype, SpecPath: issue.Parameters.SpecPath, + } + } + issues = append(issues, issueDTO{ + Code: issue.Code, Target: issue.Target, Path: issue.Path, Line: issue.Line, + Column: issue.Column, Field: issue.Field, Parameters: parameters, + }) + } + return resultDTO{Valid: result.Valid, Contracts: contracts, Issues: issues} +} + +// buildLint constructs and resolves the lazy dependencies owned by lint. +func buildLint(ctx context.Context, logger *zap.Logger) (lintRuntime, error) { + container, err := deps.New(logger) + if err != nil { + return lintRuntime{}, fmt.Errorf("deps.New: %w", err) + } + service, err := container.LintService() + if err != nil { + shutdownErr := commandruntime.Shutdown(ctx, container) + return lintRuntime{}, errors.Join(fmt.Errorf("container.LintService: %w", err), shutdownErr) + } + return lintRuntime{linter: service, shutdown: container.Shutdown}, nil +} diff --git a/cmd/devctl/internal/lint/lint_test.go b/cmd/devctl/internal/lint/lint_test.go new file mode 100644 index 0000000..2e48f2c --- /dev/null +++ b/cmd/devctl/internal/lint/lint_test.go @@ -0,0 +1,93 @@ +package lint + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "testing" + + "github.com/devctllabs/devctl/cmd/devctl/internal/lint/mocks" + lintdomain "github.com/devctllabs/devctl/internal/domain/lint" + "github.com/stretchr/testify/require" + "github.com/urfave/cli/v3" + "go.uber.org/mock/gomock" + "go.uber.org/zap" +) + +func TestLintCommandUsesLintService(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + linter := mocks.NewMocklinter(ctrl) + linter.EXPECT().Lint(gomock.Any(), lintdomain.Command{ManifestPath: "custom.yaml"}).Return(lintdomain.Result{Valid: true, Contracts: []string{"http-server"}, Issues: []lintdomain.Issue{}}, nil) + command := newLintCmd(lintCmdOpts{}, func(_ context.Context, logger *zap.Logger) (lintRuntime, error) { + require.NotNil(t, logger) + return lintRuntime{linter: linter, shutdown: func(context.Context) error { return nil }}, nil + }) + var stdout bytes.Buffer + command.Writer = &stdout + + err := command.Run(context.Background(), []string{"lint", "--file", "custom.yaml", "--json", "--verbose"}) + + require.NoError(t, err) + require.Contains(t, stdout.String(), `"msg":"contract lint completed"`) + require.Contains(t, stdout.String(), `"command":"lint"`) + require.Contains(t, stdout.String(), `"data":{"valid":true,"contracts":["http-server"],"issues":[]}`) +} + +func TestLintActionEmitsInvalidResultAndExitsOneWithoutErrorEvent(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + linter := mocks.NewMocklinter(ctrl) + linter.EXPECT().Lint(gomock.Any(), lintdomain.Command{}).Return(lintdomain.Result{ + Valid: false, Contracts: []string{"http-server"}, Issues: []lintdomain.Issue{{Code: "operation_id_missing"}}, + }, nil) + command := newLintCmd(lintCmdOpts{}, func(context.Context, *zap.Logger) (lintRuntime, error) { + return lintRuntime{linter: linter, shutdown: func(context.Context) error { return nil }}, nil + }) + command.ExitErrHandler = func(context.Context, *cli.Command, error) {} + var stdout bytes.Buffer + var stderr bytes.Buffer + command.Writer = &stdout + command.ErrWriter = &stderr + + err := command.Run(context.Background(), []string{"lint", "--json"}) + + var exitCoder cli.ExitCoder + require.ErrorAs(t, err, &exitCoder) + require.Equal(t, 1, exitCoder.ExitCode()) + require.Contains(t, stdout.String(), `"data":{"valid":false`) + require.Empty(t, stderr.String()) +} + +func TestLintReportsOperationAndShutdownFailuresWithPartialData(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + linter := mocks.NewMocklinter(ctrl) + operationErr := errors.New("lint failed") + shutdownErr := errors.New("shutdown failed") + linter.EXPECT().Lint(gomock.Any(), lintdomain.Command{}).Return( + lintdomain.Result{Contracts: []string{"http-server"}}, operationErr, + ) + command := newLintCmd(lintCmdOpts{}, func(context.Context, *zap.Logger) (lintRuntime, error) { + return lintRuntime{linter: linter, shutdown: func(context.Context) error { return shutdownErr }}, nil + }) + command.ExitErrHandler = func(context.Context, *cli.Command, error) {} + var stderr bytes.Buffer + command.ErrWriter = &stderr + + err := command.Run(context.Background(), []string{"lint", "--json"}) + + require.ErrorIs(t, err, operationErr) + require.ErrorIs(t, err, shutdownErr) + var event map[string]any + require.NoError(t, json.Unmarshal(stderr.Bytes(), &event)) + require.NotContains(t, event, "data") + details, ok := event["details"].(map[string]any) + require.True(t, ok) + require.Equal(t, map[string]any{ + "valid": false, "contracts": []any{"http-server"}, "issues": []any{}, + }, details["partial_result"]) +} diff --git a/cmd/devctl/internal/lint/mocks/lint.go b/cmd/devctl/internal/lint/mocks/lint.go new file mode 100644 index 0000000..16a13a8 --- /dev/null +++ b/cmd/devctl/internal/lint/mocks/lint.go @@ -0,0 +1,81 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/devctllabs/devctl/cmd/devctl/internal/lint (interfaces: linter) +// +// Generated by this command: +// +// mockgen -destination mocks/lint.go -package mocks -typed . linter +// + +// Package mocks is a generated GoMock package. +package mocks + +import ( + context "context" + reflect "reflect" + + lint "github.com/devctllabs/devctl/internal/domain/lint" + gomock "go.uber.org/mock/gomock" +) + +// Mocklinter is a mock of linter interface. +type Mocklinter struct { + ctrl *gomock.Controller + recorder *MocklinterMockRecorder + isgomock struct{} +} + +// MocklinterMockRecorder is the mock recorder for Mocklinter. +type MocklinterMockRecorder struct { + mock *Mocklinter +} + +// NewMocklinter creates a new mock instance. +func NewMocklinter(ctrl *gomock.Controller) *Mocklinter { + mock := &Mocklinter{ctrl: ctrl} + mock.recorder = &MocklinterMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *Mocklinter) EXPECT() *MocklinterMockRecorder { + return m.recorder +} + +// Lint mocks base method. +func (m *Mocklinter) Lint(ctx context.Context, command lint.Command) (lint.Result, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Lint", ctx, command) + ret0, _ := ret[0].(lint.Result) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Lint indicates an expected call of Lint. +func (mr *MocklinterMockRecorder) Lint(ctx, command any) *MocklinterLintCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Lint", reflect.TypeOf((*Mocklinter)(nil).Lint), ctx, command) + return &MocklinterLintCall{Call: call} +} + +// MocklinterLintCall wrap *gomock.Call +type MocklinterLintCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MocklinterLintCall) Return(arg0 lint.Result, arg1 error) *MocklinterLintCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MocklinterLintCall) Do(f func(context.Context, lint.Command) (lint.Result, error)) *MocklinterLintCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MocklinterLintCall) DoAndReturn(f func(context.Context, lint.Command) (lint.Result, error)) *MocklinterLintCall { + c.Call = c.Call.DoAndReturn(f) + return c +} diff --git a/cmd/devctl/internal/sync/http.go b/cmd/devctl/internal/sync/http.go new file mode 100644 index 0000000..cac09c0 --- /dev/null +++ b/cmd/devctl/internal/sync/http.go @@ -0,0 +1,8 @@ +package sync + +import "github.com/urfave/cli/v3" + +// newSyncHTTPCmd constructs the HTTP-only synchronization leaf. +func newSyncHTTPCmd(opts syncCmdOpts, build syncBuilder) *cli.Command { + return newSyncLeaf("http", "http", opts, build) +} diff --git a/cmd/devctl/internal/sync/mocks/sync.go b/cmd/devctl/internal/sync/mocks/sync.go new file mode 100644 index 0000000..47d24b0 --- /dev/null +++ b/cmd/devctl/internal/sync/mocks/sync.go @@ -0,0 +1,81 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/devctllabs/devctl/cmd/devctl/internal/sync (interfaces: syncer) +// +// Generated by this command: +// +// mockgen -destination mocks/sync.go -package mocks -typed . syncer +// + +// Package mocks is a generated GoMock package. +package mocks + +import ( + context "context" + reflect "reflect" + + sync "github.com/devctllabs/devctl/internal/domain/sync" + gomock "go.uber.org/mock/gomock" +) + +// Mocksyncer is a mock of syncer interface. +type Mocksyncer struct { + ctrl *gomock.Controller + recorder *MocksyncerMockRecorder + isgomock struct{} +} + +// MocksyncerMockRecorder is the mock recorder for Mocksyncer. +type MocksyncerMockRecorder struct { + mock *Mocksyncer +} + +// NewMocksyncer creates a new mock instance. +func NewMocksyncer(ctrl *gomock.Controller) *Mocksyncer { + mock := &Mocksyncer{ctrl: ctrl} + mock.recorder = &MocksyncerMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *Mocksyncer) EXPECT() *MocksyncerMockRecorder { + return m.recorder +} + +// Sync mocks base method. +func (m *Mocksyncer) Sync(ctx context.Context, command sync.Command) (sync.Result, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Sync", ctx, command) + ret0, _ := ret[0].(sync.Result) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Sync indicates an expected call of Sync. +func (mr *MocksyncerMockRecorder) Sync(ctx, command any) *MocksyncerSyncCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Sync", reflect.TypeOf((*Mocksyncer)(nil).Sync), ctx, command) + return &MocksyncerSyncCall{Call: call} +} + +// MocksyncerSyncCall wrap *gomock.Call +type MocksyncerSyncCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MocksyncerSyncCall) Return(arg0 sync.Result, arg1 error) *MocksyncerSyncCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MocksyncerSyncCall) Do(f func(context.Context, sync.Command) (sync.Result, error)) *MocksyncerSyncCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MocksyncerSyncCall) DoAndReturn(f func(context.Context, sync.Command) (sync.Result, error)) *MocksyncerSyncCall { + c.Call = c.Call.DoAndReturn(f) + return c +} diff --git a/cmd/devctl/internal/sync/sync.go b/cmd/devctl/internal/sync/sync.go new file mode 100644 index 0000000..1653cc3 --- /dev/null +++ b/cmd/devctl/internal/sync/sync.go @@ -0,0 +1,158 @@ +package sync + +import ( + "context" + "errors" + "fmt" + "time" + + commandruntime "github.com/devctllabs/devctl/cmd/devctl/internal/command" + "github.com/devctllabs/devctl/internal/deps" + syncdomain "github.com/devctllabs/devctl/internal/domain/sync" + "github.com/devctllabs/go-libs/lifecycle" + "github.com/urfave/cli/v3" + "go.uber.org/zap" +) + +//go:generate go tool mockgen -destination mocks/sync.go -package mocks -typed . syncer + +type syncer interface { + // Sync returns completed target facts even when a later target fails. + Sync(ctx context.Context, command syncdomain.Command) (syncdomain.Result, error) +} + +// syncRuntime contains the application port and cleanup hook used by Action. +type syncRuntime struct { + syncer syncer + shutdown func(context.Context) error +} + +// syncBuilder isolates dependency construction from synchronization behavior. +type syncBuilder func(ctx context.Context, logger *zap.Logger) (syncRuntime, error) + +// syncCmd owns parsed options, a contract family selector, and its runtime factory. +type syncCmd struct { + opts syncCmdOpts + family string + buildRuntime syncBuilder +} + +// syncCmdOpts receives common flags plus synchronization target selection. +type syncCmdOpts struct { + commandruntime.CommonCmdOpts + Target string + DryRun bool +} + +// NewCmd constructs the sync command tree. +func NewCmd() *cli.Command { + return newSyncCmd(syncCmdOpts{}, buildSync) +} + +func newSyncCmd(opts syncCmdOpts, build syncBuilder) *cli.Command { + command := newSyncLeaf("sync", "", opts, build) + command.Commands = []*cli.Command{newSyncHTTPCmd(syncCmdOpts{}, build), newSyncLeaf("grpc", "grpc", syncCmdOpts{}, build), newSyncLeaf("kafka", "kafka", syncCmdOpts{}, build)} + return command +} + +// newSyncLeaf constructs one executable sync node for the selected contract family. +func newSyncLeaf(name, family string, opts syncCmdOpts, build syncBuilder) *cli.Command { + cmd := &syncCmd{opts: opts, family: family, buildRuntime: build} + usageText := "devctl sync" + if family != "" { + usageText += " " + name + } + flags := append(cmd.opts.CommonFlags(), + &cli.StringFlag{Name: "target", Destination: &cmd.opts.Target, Usage: "select one exact Target `id`, such as http-client:billing"}, + &cli.BoolFlag{Name: "dry-run", Destination: &cmd.opts.DryRun, Usage: "preview publication and pruning without network access or writes"}, + ) + return &cli.Command{ + Name: name, + Usage: "Synchronize external Contracts", + Description: syncDescription(family), + UsageText: usageText + " [--target ] [--dry-run]", + Flags: flags, + OnUsageError: func(_ context.Context, command *cli.Command, err error, _ bool) error { + reporter := commandruntime.NewErrorReporter(cmd.opts.NewStderrLogger(command), cmd.opts.Verbose) + return reporter.ReportError(cli.Exit(err, 2)) + }, + Action: cmd.Action, + } +} + +func syncDescription(family string) string { + if family == "" { + return "Materialize every supported external Contract Snapshot into Project-owned paths. Full synchronization may prune stale Target directories; use --dry-run to preview changes." + } + return "Materialize external " + family + " Contract Snapshots. Family synchronization may prune stale Target directories; an explicit --target never prunes sibling Targets." +} + +// Action synchronizes the selected contracts and emits the completed or partial result. +func (cmd *syncCmd) Action(ctx context.Context, command *cli.Command) error { + stdout := cmd.opts.NewStdoutLogger(command) + stderr := cmd.opts.NewStderrLogger(command) + reporter := commandruntime.NewErrorReporter(stderr, cmd.opts.Verbose) + if command.Args().Len() != 0 { + return reporter.ReportError(cli.Exit("sync accepts no positional arguments", 2)) + } + runtime, err := cmd.buildRuntime(ctx, stderr) + if err != nil { + return reporter.ReportError(err) + } + result, operationErr := runtime.syncer.Sync(ctx, syncdomain.Command{ + ManifestPath: cmd.opts.ManifestPath, + Family: cmd.family, + Target: cmd.opts.Target, + DryRun: cmd.opts.DryRun, + }) + shutdownErr := lifecycle.Shutdown(ctx, 5*time.Second, runtime.shutdown) + dto := syncDTO(result) + if finalErr := errors.Join(operationErr, shutdownErr); finalErr != nil { + var options []commandruntime.ErrorOption + if !result.DryRun && (len(result.Targets) > 0 || len(result.Changes) > 0) { + options = append(options, commandruntime.WithPartialResult(dto)) + } + return reporter.ReportError(finalErr, options...) + } + stdout.Info("contract synchronization completed", zap.Any("data", dto)) + return nil +} + +// syncChangeDTO is one stable contract publication fact. +type syncChangeDTO struct { + Target string `json:"target"` + Path string `json:"path"` + Action string `json:"action"` +} + +// syncResultDTO contains all completed targets and changes, including dry runs. +type syncResultDTO struct { + Targets []string `json:"targets"` + Changes []syncChangeDTO `json:"changes"` + DryRun bool `json:"dry_run"` +} + +// syncDTO converts the domain result into the stable CLI payload. +func syncDTO(result syncdomain.Result) syncResultDTO { + targets := make([]string, len(result.Targets)) + copy(targets, result.Targets) + changes := make([]syncChangeDTO, 0, len(result.Changes)) + for _, change := range result.Changes { + changes = append(changes, syncChangeDTO{Target: change.Target, Path: change.Path, Action: string(change.Action)}) + } + return syncResultDTO{Targets: targets, Changes: changes, DryRun: result.DryRun} +} + +// buildSync constructs and resolves the lazy dependencies owned by sync. +func buildSync(ctx context.Context, logger *zap.Logger) (syncRuntime, error) { + container, err := deps.New(logger) + if err != nil { + return syncRuntime{}, fmt.Errorf("deps.New: %w", err) + } + service, err := container.SyncService() + if err != nil { + shutdownErr := commandruntime.Shutdown(ctx, container) + return syncRuntime{}, errors.Join(fmt.Errorf("container.SyncService: %w", err), shutdownErr) + } + return syncRuntime{syncer: service, shutdown: container.Shutdown}, nil +} diff --git a/cmd/devctl/internal/sync/sync_test.go b/cmd/devctl/internal/sync/sync_test.go new file mode 100644 index 0000000..b8bfd2d --- /dev/null +++ b/cmd/devctl/internal/sync/sync_test.go @@ -0,0 +1,144 @@ +package sync + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "testing" + + "github.com/devctllabs/devctl/cmd/devctl/internal/sync/mocks" + materializedomain "github.com/devctllabs/devctl/internal/domain/materialize" + syncdomain "github.com/devctllabs/devctl/internal/domain/sync" + "github.com/stretchr/testify/require" + "github.com/urfave/cli/v3" + "go.uber.org/mock/gomock" + "go.uber.org/zap" +) + +func TestSyncCommandBuildsAndRunsOnlySyncService(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + syncer := mocks.NewMocksyncer(ctrl) + syncer.EXPECT().Sync(gomock.Any(), syncdomain.Command{ + ManifestPath: "custom.yaml", + Target: "http-client:remote", + DryRun: true, + }).Return(syncdomain.Result{ + Targets: []string{"http-client:remote"}, + Changes: []syncdomain.Change{{Target: "http-client:remote", Path: "api/external/clienthttp/remote", Action: syncdomain.ChangePlannedPublish}}, + DryRun: true, + }, nil) + command := newSyncCmd(syncCmdOpts{}, func(_ context.Context, logger *zap.Logger) (syncRuntime, error) { + require.NotNil(t, logger) + return syncRuntime{syncer: syncer, shutdown: func(context.Context) error { return nil }}, nil + }) + var stdout bytes.Buffer + command.Writer = &stdout + + err := command.Run(context.Background(), []string{ + "sync", "--file", "custom.yaml", "--json", "--verbose", "--target", "http-client:remote", "--dry-run", + }) + + require.NoError(t, err) + require.Contains(t, stdout.String(), `"msg":"contract synchronization completed"`) + require.Contains(t, stdout.String(), `"command":"sync"`) + require.Contains(t, stdout.String(), `"data":{"targets":["http-client:remote"],"changes":[{"target":"http-client:remote","path":"api/external/clienthttp/remote","action":"planned_publish"}],"dry_run":true}`) +} + +func TestSyncReportsOperationAndShutdownFailuresWithPartialData(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + syncer := mocks.NewMocksyncer(ctrl) + operationErr := errors.New("sync failed") + shutdownErr := errors.New("shutdown failed") + syncer.EXPECT().Sync(gomock.Any(), syncdomain.Command{}).Return( + syncdomain.Result{Targets: []string{"http-client:catalog"}}, operationErr, + ) + command := newSyncCmd(syncCmdOpts{}, func(context.Context, *zap.Logger) (syncRuntime, error) { + return syncRuntime{syncer: syncer, shutdown: func(context.Context) error { return shutdownErr }}, nil + }) + command.ExitErrHandler = func(context.Context, *cli.Command, error) {} + var stderr bytes.Buffer + command.ErrWriter = &stderr + + err := command.Run(context.Background(), []string{"sync", "--json"}) + + require.ErrorIs(t, err, operationErr) + require.ErrorIs(t, err, shutdownErr) + var event map[string]any + require.NoError(t, json.Unmarshal(stderr.Bytes(), &event)) + require.NotContains(t, event, "data") + require.Equal(t, map[string]any{ + "partial_result": map[string]any{ + "targets": []any{"http-client:catalog"}, "changes": []any{}, "dry_run": false, + }, + }, event["details"]) +} + +func TestSyncDryRunFailureOmitsPlannedPartialResult(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + syncer := mocks.NewMocksyncer(ctrl) + cause := errors.New("planning failed") + syncer.EXPECT().Sync(gomock.Any(), syncdomain.Command{DryRun: true}).Return( + syncdomain.Result{Targets: []string{"config"}, DryRun: true}, cause, + ) + command := newSyncCmd(syncCmdOpts{}, func(context.Context, *zap.Logger) (syncRuntime, error) { + return syncRuntime{syncer: syncer, shutdown: func(context.Context) error { return nil }}, nil + }) + command.ExitErrHandler = func(context.Context, *cli.Command, error) {} + var stderr bytes.Buffer + command.ErrWriter = &stderr + + err := command.Run(context.Background(), []string{"sync", "--json", "--dry-run"}) + + require.ErrorIs(t, err, cause) + var event map[string]any + require.NoError(t, json.Unmarshal(stderr.Bytes(), &event)) + require.NotContains(t, event, "data") + require.NotContains(t, event, "details") +} + +func TestSyncReportsMaterializationCategoriesWithoutRawCause(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + kind materializedomain.FailureKind + code string + }{ + {name: "invalid input", kind: materializedomain.FailureInvalid, code: "invalid_input"}, + {name: "not found", kind: materializedomain.FailureNotFound, code: "not_found"}, + {name: "unsupported", kind: materializedomain.FailureUnsupported, code: "unsupported"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + syncer := mocks.NewMocksyncer(ctrl) + cause := &materializedomain.OperationError{ + Operation: materializedomain.OperationBuildSnapshot, Kind: test.kind, + } + syncer.EXPECT().Sync(gomock.Any(), syncdomain.Command{}).Return(syncdomain.Result{}, cause) + command := newSyncCmd(syncCmdOpts{}, func(context.Context, *zap.Logger) (syncRuntime, error) { + return syncRuntime{syncer: syncer, shutdown: func(context.Context) error { return nil }}, nil + }) + command.ExitErrHandler = func(context.Context, *cli.Command, error) {} + var stderr bytes.Buffer + command.ErrWriter = &stderr + + err := command.Run(context.Background(), []string{"sync", "--json"}) + + require.ErrorIs(t, err, cause) + var event map[string]any + require.NoError(t, json.Unmarshal(stderr.Bytes(), &event)) + require.Equal(t, test.code, event["code"]) + require.NotContains(t, event, "error") + require.NotContains(t, stderr.String(), string(materializedomain.OperationBuildSnapshot)) + }) + } +} diff --git a/cmd/devctl/internal/validate/mocks/validate.go b/cmd/devctl/internal/validate/mocks/validate.go new file mode 100644 index 0000000..2913223 --- /dev/null +++ b/cmd/devctl/internal/validate/mocks/validate.go @@ -0,0 +1,81 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/devctllabs/devctl/cmd/devctl/internal/validate (interfaces: projectValidator) +// +// Generated by this command: +// +// mockgen -destination mocks/validate.go -package mocks -typed . projectValidator +// + +// Package mocks is a generated GoMock package. +package mocks + +import ( + context "context" + reflect "reflect" + + project "github.com/devctllabs/devctl/internal/domain/project" + gomock "go.uber.org/mock/gomock" +) + +// MockprojectValidator is a mock of projectValidator interface. +type MockprojectValidator struct { + ctrl *gomock.Controller + recorder *MockprojectValidatorMockRecorder + isgomock struct{} +} + +// MockprojectValidatorMockRecorder is the mock recorder for MockprojectValidator. +type MockprojectValidatorMockRecorder struct { + mock *MockprojectValidator +} + +// NewMockprojectValidator creates a new mock instance. +func NewMockprojectValidator(ctrl *gomock.Controller) *MockprojectValidator { + mock := &MockprojectValidator{ctrl: ctrl} + mock.recorder = &MockprojectValidatorMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockprojectValidator) EXPECT() *MockprojectValidatorMockRecorder { + return m.recorder +} + +// Validate mocks base method. +func (m *MockprojectValidator) Validate(ctx context.Context, query project.ValidateQuery) (project.ValidationResult, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Validate", ctx, query) + ret0, _ := ret[0].(project.ValidationResult) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Validate indicates an expected call of Validate. +func (mr *MockprojectValidatorMockRecorder) Validate(ctx, query any) *MockprojectValidatorValidateCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Validate", reflect.TypeOf((*MockprojectValidator)(nil).Validate), ctx, query) + return &MockprojectValidatorValidateCall{Call: call} +} + +// MockprojectValidatorValidateCall wrap *gomock.Call +type MockprojectValidatorValidateCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockprojectValidatorValidateCall) Return(arg0 project.ValidationResult, arg1 error) *MockprojectValidatorValidateCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockprojectValidatorValidateCall) Do(f func(context.Context, project.ValidateQuery) (project.ValidationResult, error)) *MockprojectValidatorValidateCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockprojectValidatorValidateCall) DoAndReturn(f func(context.Context, project.ValidateQuery) (project.ValidationResult, error)) *MockprojectValidatorValidateCall { + c.Call = c.Call.DoAndReturn(f) + return c +} diff --git a/cmd/devctl/internal/validate/validate.go b/cmd/devctl/internal/validate/validate.go new file mode 100644 index 0000000..0b8f84d --- /dev/null +++ b/cmd/devctl/internal/validate/validate.go @@ -0,0 +1,113 @@ +package validate + +import ( + "context" + "errors" + "fmt" + "time" + + commandruntime "github.com/devctllabs/devctl/cmd/devctl/internal/command" + "github.com/devctllabs/devctl/internal/deps" + projectdomain "github.com/devctllabs/devctl/internal/domain/project" + "github.com/devctllabs/go-libs/lifecycle" + "github.com/urfave/cli/v3" + "go.uber.org/zap" +) + +//go:generate go tool mockgen -destination mocks/validate.go -package mocks -typed . projectValidator + +type projectValidator interface { + // Validate returns all available project issues; invalid project data is not an execution error. + Validate(ctx context.Context, query projectdomain.ValidateQuery) (projectdomain.ValidationResult, error) +} + +// validateRuntime contains only the behavior and cleanup hook needed after command construction. +type validateRuntime struct { + validator projectValidator + shutdown func(context.Context) error +} + +// validateBuilder owns dependency construction so Action remains testable without a container. +type validateBuilder func(ctx context.Context, logger *zap.Logger) (validateRuntime, error) + +// validateCmd owns parsed options and the runtime factory for one invocation. +type validateCmd struct { + opts validateCmdOpts + buildRuntime validateBuilder +} + +// validateCmdOpts receives the common leaf flags bound by urfave/cli. +type validateCmdOpts struct { + commandruntime.CommonCmdOpts +} + +// NewCmd constructs the validate command. +func NewCmd() *cli.Command { return newValidateCmd(validateCmdOpts{}, buildValidate) } + +func newValidateCmd(opts validateCmdOpts, build validateBuilder) *cli.Command { + cmd := &validateCmd{opts: opts, buildRuntime: build} + return &cli.Command{ + Name: "validate", + Usage: "Validate the selected Project", + Description: "Check Manifest structure, semantic validity, references, safe paths, and Project Readiness. Validation findings are normal results and exit with status 1 when any issue is present.", + UsageText: "devctl validate [--file ]", + Flags: cmd.opts.CommonFlags(), + OnUsageError: func(_ context.Context, command *cli.Command, err error, _ bool) error { + reporter := commandruntime.NewErrorReporter(cmd.opts.NewStderrLogger(command), cmd.opts.Verbose) + return reporter.ReportError(cli.Exit(err, 2)) + }, + Action: cmd.Action, + } +} + +// Action validates the selected project and emits its complete result as one event. +func (cmd *validateCmd) Action(ctx context.Context, command *cli.Command) error { + stdout := cmd.opts.NewStdoutLogger(command) + stderr := cmd.opts.NewStderrLogger(command) + reporter := commandruntime.NewErrorReporter(stderr, cmd.opts.Verbose) + if command.Args().Len() != 0 { + return reporter.ReportError(cli.Exit("validate accepts no positional arguments", 2)) + } + runtime, err := cmd.buildRuntime(ctx, stderr) + if err != nil { + return reporter.ReportError(err) + } + result, validateErr := runtime.validator.Validate(ctx, projectdomain.ValidateQuery{ManifestPath: cmd.opts.ManifestPath}) + shutdownErr := lifecycle.Shutdown(ctx, 5*time.Second, runtime.shutdown) + dto := validationDTO(result) + var options []commandruntime.ErrorOption + if validateErr == nil { + options = append(options, commandruntime.WithPartialResult(dto)) + } + if finalErr := errors.Join(validateErr, shutdownErr); finalErr != nil { + return reporter.ReportError(finalErr, options...) + } + stdout.Info("project validation completed", zap.Any("data", dto)) + if !result.IsValid() { + return cli.Exit("", 1) + } + return nil +} + +type validationResultDTO struct { + Valid bool `json:"valid"` + Issues []commandruntime.ValidationIssueDTO `json:"issues"` +} + +// validationDTO converts the domain result into the stable CLI payload. +func validationDTO(result projectdomain.ValidationResult) validationResultDTO { + return validationResultDTO{Valid: result.IsValid(), Issues: commandruntime.ValidationIssueDTOs(result.Issues)} +} + +// buildValidate constructs and resolves the lazy dependencies owned by validate. +func buildValidate(ctx context.Context, logger *zap.Logger) (validateRuntime, error) { + container, err := deps.New(logger) + if err != nil { + return validateRuntime{}, fmt.Errorf("deps.New: %w", err) + } + service, err := container.ProjectService() + if err != nil { + return validateRuntime{}, errors.Join(err, lifecycle.Shutdown(ctx, 5*time.Second, container.Shutdown)) + } + return validateRuntime{validator: service, shutdown: container.Shutdown}, nil +} diff --git a/cmd/devctl/internal/validate/validate_test.go b/cmd/devctl/internal/validate/validate_test.go new file mode 100644 index 0000000..b0323f8 --- /dev/null +++ b/cmd/devctl/internal/validate/validate_test.go @@ -0,0 +1,156 @@ +package validate + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io/fs" + "testing" + + "github.com/devctllabs/devctl/cmd/devctl/internal/validate/mocks" + projectdomain "github.com/devctllabs/devctl/internal/domain/project" + "github.com/stretchr/testify/require" + "github.com/urfave/cli/v3" + "go.uber.org/mock/gomock" + "go.uber.org/zap" +) + +func TestValidateWritesCommandSpecificJSON(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + validator := mocks.NewMockprojectValidator(ctrl) + validator.EXPECT().Validate(gomock.Any(), projectdomain.ValidateQuery{ManifestPath: "custom.yaml"}).Return(projectdomain.ValidationResult{Issues: []projectdomain.Issue{}}, nil) + built := false + command := newValidateCmd(validateCmdOpts{}, func(_ context.Context, logger *zap.Logger) (validateRuntime, error) { + built = true + require.NotNil(t, logger) + return validateRuntime{validator: validator, shutdown: func(context.Context) error { return nil }}, nil + }) + var stdout bytes.Buffer + command.Writer = &stdout + + err := command.Run(context.Background(), []string{"validate", "--file", "custom.yaml", "--json"}) + + require.NoError(t, err) + require.True(t, built) + require.Contains(t, stdout.String(), `"msg":"project validation completed"`) + require.Contains(t, stdout.String(), `"command":"validate"`) + require.Contains(t, stdout.String(), `"data":{"valid":true,"issues":[]}`) +} + +func TestValidateReturnsSafeExecutionError(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + validator := mocks.NewMockprojectValidator(ctrl) + validator.EXPECT().Validate(gomock.Any(), projectdomain.ValidateQuery{}).Return( + projectdomain.ValidationResult{}, fmt.Errorf("repository.LoadDocument: %w", &projectdomain.OperationError{ + Operation: projectdomain.OperationLoadManifest, + Path: "/private/work/devctl.yaml", + Kind: projectdomain.FailureNotFound, + Cause: fs.ErrNotExist, + }), + ) + command := newValidateCmd(validateCmdOpts{}, func(context.Context, *zap.Logger) (validateRuntime, error) { + return validateRuntime{validator: validator, shutdown: func(context.Context) error { return nil }}, nil + }) + command.ExitErrHandler = func(context.Context, *cli.Command, error) {} + var stdout bytes.Buffer + var stderr bytes.Buffer + command.Writer = &stdout + command.ErrWriter = &stderr + + err := command.Run(context.Background(), []string{"validate", "--json"}) + + var exitCoder cli.ExitCoder + require.ErrorAs(t, err, &exitCoder) + require.Equal(t, 1, exitCoder.ExitCode()) + require.Empty(t, err.Error()) + require.ErrorIs(t, err, fs.ErrNotExist) + require.Empty(t, stdout.String()) + require.Contains(t, stderr.String(), `"msg":"requested resource was not found"`) + require.Contains(t, stderr.String(), `"code":"not_found"`) + require.NotContains(t, stderr.String(), "/private/work/devctl.yaml") +} + +func TestValidateReportsOperationAndShutdownFailures(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + validator := mocks.NewMockprojectValidator(ctrl) + operationErr := errors.New("validation failed") + shutdownErr := errors.New("shutdown failed") + validator.EXPECT().Validate(gomock.Any(), projectdomain.ValidateQuery{}).Return(projectdomain.ValidationResult{}, operationErr) + command := newValidateCmd(validateCmdOpts{}, func(context.Context, *zap.Logger) (validateRuntime, error) { + return validateRuntime{validator: validator, shutdown: func(context.Context) error { return shutdownErr }}, nil + }) + command.ExitErrHandler = func(context.Context, *cli.Command, error) {} + var stderr bytes.Buffer + command.ErrWriter = &stderr + + err := command.Run(context.Background(), []string{"validate", "--json"}) + + require.ErrorIs(t, err, operationErr) + require.ErrorIs(t, err, shutdownErr) + require.Equal(t, 1, bytes.Count(stderr.Bytes(), []byte(`"level":"error"`))) + require.NotContains(t, stderr.String(), `"data"`) +} + +func TestValidateReportsCompletedValidationAfterShutdownFailure(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + validator := mocks.NewMockprojectValidator(ctrl) + shutdownErr := errors.New("shutdown failed") + validator.EXPECT().Validate(gomock.Any(), projectdomain.ValidateQuery{}).Return( + projectdomain.ValidationResult{Issues: []projectdomain.Issue{}}, nil, + ) + command := newValidateCmd(validateCmdOpts{}, func(context.Context, *zap.Logger) (validateRuntime, error) { + return validateRuntime{validator: validator, shutdown: func(context.Context) error { return shutdownErr }}, nil + }) + command.ExitErrHandler = func(context.Context, *cli.Command, error) {} + var stderr bytes.Buffer + command.ErrWriter = &stderr + + err := command.Run(context.Background(), []string{"validate", "--json"}) + + require.ErrorIs(t, err, shutdownErr) + var event map[string]any + require.NoError(t, json.Unmarshal(stderr.Bytes(), &event)) + require.NotContains(t, event, "data") + details, ok := event["details"].(map[string]any) + require.True(t, ok) + require.Equal(t, map[string]any{"valid": true, "issues": []any{}}, details["partial_result"]) +} + +func TestValidateWritesIssuesAndExitsOne(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + validator := mocks.NewMockprojectValidator(ctrl) + validator.EXPECT().Validate(gomock.Any(), projectdomain.ValidateQuery{}).Return(projectdomain.ValidationResult{ + Issues: []projectdomain.Issue{{ + Code: "name_invalid", Path: "devctl.yaml", Line: 3, Column: 9, Field: "project.name", + }}, + }, nil) + command := newValidateCmd(validateCmdOpts{}, func(context.Context, *zap.Logger) (validateRuntime, error) { + return validateRuntime{validator: validator, shutdown: func(context.Context) error { return nil }}, nil + }) + command.ExitErrHandler = func(context.Context, *cli.Command, error) {} + var stdout bytes.Buffer + var stderr bytes.Buffer + command.Writer = &stdout + command.ErrWriter = &stderr + + err := command.Run(context.Background(), []string{"validate", "--json"}) + + var exitCoder cli.ExitCoder + require.ErrorAs(t, err, &exitCoder) + require.Equal(t, 1, exitCoder.ExitCode()) + require.Contains(t, stdout.String(), `"msg":"project validation completed"`) + require.Contains(t, stdout.String(), `"data":{"valid":false,"issues":[{"code":"name_invalid","path":"devctl.yaml","line":3,"column":9,"field":"project.name"}]}`) + require.Empty(t, stderr.String()) +} diff --git a/cmd/devctl/main.go b/cmd/devctl/main.go new file mode 100644 index 0000000..18269a9 --- /dev/null +++ b/cmd/devctl/main.go @@ -0,0 +1,29 @@ +package main + +import ( + "context" + "os" + "os/signal" + "syscall" + + devctlapp "github.com/devctllabs/devctl/cmd/devctl/internal/app" + commandruntime "github.com/devctllabs/devctl/cmd/devctl/internal/command" +) + +var ( + version = "dev" + commit = "" +) + +func main() { + ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer cancel() + + root := devctlapp.New(version, commit) + + if err := root.Run(ctx, os.Args); err != nil { + rootOpts := commandruntime.CommonCmdOpts{} + reporter := commandruntime.NewErrorReporter(rootOpts.NewStderrLogger(root), false) + os.Exit(commandruntime.ExitCode(reporter.ReportError(err))) + } +} diff --git a/cmd/devctl/main_test.go b/cmd/devctl/main_test.go new file mode 100644 index 0000000..74694c2 --- /dev/null +++ b/cmd/devctl/main_test.go @@ -0,0 +1,1658 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/BurntSushi/toml" + "github.com/devctllabs/devctl/internal/testutil/testexec" + "github.com/stretchr/testify/require" + "golang.org/x/mod/modfile" + "gopkg.in/yaml.v3" +) + +var testCLIBinary string + +func TestMain(m *testing.M) { + testRoot, err := os.MkdirTemp("", "devctl-test-") + if err != nil { + fmt.Fprintf(os.Stderr, "create test directory: %v\n", err) + os.Exit(1) + } + + binary := filepath.Join(testRoot, "devctl") + build := exec.CommandContext(context.Background(), "go", "build", "-o", binary, ".") + output, err := build.CombinedOutput() + if err != nil { + fmt.Fprintf(os.Stderr, "build test CLI: %v\n%s", err, output) + _ = os.RemoveAll(testRoot) + os.Exit(1) + } + testCLIBinary = binary + + exitCode := m.Run() + if err := os.RemoveAll(testRoot); err != nil { + fmt.Fprintf(os.Stderr, "remove test directory: %v\n", err) + if exitCode == 0 { + exitCode = 1 + } + } + os.Exit(exitCode) +} + +func TestRootHelp(t *testing.T) { + t.Parallel() + binary := buildCLI(t) + stdout := runCLI(t, binary, "--help") + for _, commandName := range []string{"init", "validate", "inspect", "enable", "add", "sync", "gen", "lint"} { + require.Contains(t, stdout, commandName) + } + require.NotContains(t, stdout, "--file") + require.NotContains(t, stdout, "--json") + require.NotContains(t, stdout, "--verbose") + require.NotContains(t, stdout, "--format") +} + +func TestCanonicalCommandFamiliesExposePlannedLeaves(t *testing.T) { + t.Parallel() + binary := buildCLI(t) + + tests := []struct { + args []string + leaves []string + }{ + {args: []string{"enable", "--help"}, leaves: []string{"grpc"}}, + {args: []string{"add", "--help"}, leaves: []string{"grpc-client", "kafka-consumer", "kafka-producer", "redis", "s3-connection", "s3"}}, + {args: []string{"sync", "--help"}, leaves: []string{"grpc", "kafka"}}, + {args: []string{"gen", "--help"}, leaves: []string{"grpc", "kafka"}}, + {args: []string{"lint", "--help"}, leaves: []string{"grpc", "kafka"}}, + } + + for _, test := range tests { + output := runCLI(t, binary, test.args...) + for _, leaf := range test.leaves { + require.Contains(t, output, leaf, "%v", test.args) + } + } +} + +func TestInitScaffoldCreatesPinnedBufTooling(t *testing.T) { + t.Parallel() + binary := buildCLI(t) + root := t.TempDir() + manifestPath := filepath.Join(root, "devctl.yaml") + require.NoError(t, os.WriteFile(manifestPath, []byte(`version: 1 +project: {name: sample, language: go} +env: {} +paths: {} +sources: {} +exports: {} +components: + grpc: + server: {proto_root: api/proto, buf_config: buf.yaml} +languages: + go: + module: example.test/sample + generators: + grpc: {out: gen/grpc, buf_gen_config: tools/buf/grpc.gen.yaml} +`), 0o644)) + + runCLI(t, binary, "init", "scaffold", "--file", manifestPath, "--json") + + goModBytes, err := os.ReadFile(filepath.Join(root, "go.mod")) + require.NoError(t, err) + goMod, err := modfile.Parse("go.mod", goModBytes, nil) + require.NoError(t, err) + tools := make([]string, 0, len(goMod.Tool)) + for _, tool := range goMod.Tool { + tools = append(tools, tool.Path) + } + require.ElementsMatch(t, []string{ + "github.com/bufbuild/buf/cmd/buf", + "google.golang.org/protobuf/cmd/protoc-gen-go", + "google.golang.org/grpc/cmd/protoc-gen-go-grpc", + }, tools) + requiredVersions := map[string]string{} + for _, dependency := range goMod.Require { + requiredVersions[dependency.Mod.Path] = dependency.Mod.Version + } + require.Equal(t, "v1.72.0", requiredVersions["github.com/bufbuild/buf"]) + require.Equal(t, "v1.36.12", requiredVersions["google.golang.org/protobuf"]) + require.Equal(t, "v1.6.2", requiredVersions["google.golang.org/grpc/cmd/protoc-gen-go-grpc"]) + + bufModuleBytes, err := os.ReadFile(filepath.Join(root, "buf.yaml")) + require.NoError(t, err) + var bufModule struct { + Version string `yaml:"version"` + Modules []struct { + Path string `yaml:"path"` + } `yaml:"modules"` + Lint struct { + Use []string `yaml:"use"` + Except []string `yaml:"except"` + } `yaml:"lint"` + } + require.NoError(t, yaml.Unmarshal(bufModuleBytes, &bufModule)) + require.Equal(t, "v2", bufModule.Version) + require.Equal(t, "api/proto", bufModule.Modules[0].Path) + require.Equal(t, []string{"STANDARD"}, bufModule.Lint.Use) + require.Equal(t, []string{"FILE_LOWER_SNAKE_CASE"}, bufModule.Lint.Except) + + bufGenerateBytes, err := os.ReadFile(filepath.Join(root, "tools/buf/grpc.gen.yaml")) + require.NoError(t, err) + var bufGenerate struct { + Version string `yaml:"version"` + Plugins []struct { + Local []string `yaml:"local"` + Out string `yaml:"out"` + } `yaml:"plugins"` + } + require.NoError(t, yaml.Unmarshal(bufGenerateBytes, &bufGenerate)) + require.Equal(t, "v2", bufGenerate.Version) + require.Equal(t, [][]string{ + {"go", "tool", "protoc-gen-go"}, + {"go", "tool", "protoc-gen-go-grpc"}, + }, [][]string{bufGenerate.Plugins[0].Local, bufGenerate.Plugins[1].Local}) + require.Equal(t, ".", bufGenerate.Plugins[0].Out) + require.Equal(t, ".", bufGenerate.Plugins[1].Out) +} + +func TestEnableGRPCWritesCanonicalManifest(t *testing.T) { + t.Parallel() + binary := buildCLI(t) + manifestPath := filepath.Join(t.TempDir(), "devctl.yaml") + require.NoError(t, os.WriteFile(manifestPath, []byte(`version: 1 +project: {name: sample, language: go} +env: {} +paths: {} +sources: {} +exports: {} +components: {} +languages: + go: {module: example.test/sample} +`), 0o644)) + + runCLI(t, binary, "enable", "grpc", "--file", manifestPath, "--json") + + manifest := readTestManifest(t, manifestPath) + require.NotNil(t, manifest.Components.GRPC) + require.Equal(t, "api/proto/grpc", manifest.Components.GRPC.Server.ProtoRoot) + require.Equal(t, "buf.yaml", manifest.Components.GRPC.Server.BufConfig) + require.Equal(t, "GRPC_SERVER_ENABLED", manifest.Components.GRPC.Server.Start.Env) + require.True(t, *manifest.Components.GRPC.Server.Start.Default) + require.Equal(t, testGenerator{Out: "gen/grpc", BufGenConfig: "tools/buf/grpc.gen.yaml"}, *manifest.Languages.Go.Generators.GRPC) +} + +func TestEnableGRPCAlwaysOmitsStartPolicy(t *testing.T) { + t.Parallel() + binary := buildCLI(t) + manifestPath := filepath.Join(t.TempDir(), "devctl.yaml") + require.NoError(t, os.WriteFile(manifestPath, []byte(`version: 1 +project: {name: sample, language: go} +env: {} +paths: {} +sources: {} +exports: {} +components: {} +languages: + go: {module: example.test/sample} +`), 0o644)) + + runCLI(t, binary, "enable", "grpc", "--file", manifestPath, "--always") + + manifest := readTestManifest(t, manifestPath) + require.NotNil(t, manifest.Components.GRPC) + require.Nil(t, manifest.Components.GRPC.Server.Start) +} + +func TestAddGRPCClientWritesCanonicalManifest(t *testing.T) { + t.Parallel() + binary := buildCLI(t) + manifestPath := filepath.Join(t.TempDir(), "devctl.yaml") + require.NoError(t, os.WriteFile(manifestPath, []byte(`version: 1 +project: {name: sample, language: go} +env: {} +paths: {external_contracts: api/external} +sources: + contracts: {type: local, path: api/contracts} +exports: {} +components: {} +languages: + go: {module: example.test/sample} +`), 0o644)) + + runCLI(t, binary, "add", "grpc-client", "billing", "--file", manifestPath, + "--source", "contracts", "--path", "billing", "--proto-root", "proto", + "--buf-gen-config", "tools/buf/billing.gen.yaml", "--addr-env", "BILLING_GRPC_ADDR") + + manifest := readTestManifest(t, manifestPath) + require.Equal(t, []testGRPCClient{{ + Name: "billing", Source: "contracts", Path: "billing", ProtoRoot: "proto", + BufGenConfig: "tools/buf/billing.gen.yaml", AddrEnv: "BILLING_GRPC_ADDR", + }}, manifest.Components.GRPC.Clients) + require.Equal(t, testGenerator{Out: "gen/grpc", BufGenConfig: "tools/buf/grpc.gen.yaml"}, *manifest.Languages.Go.Generators.GRPC) +} + +func TestAddKafkaEndpointsWritesConsumerAndProducerPolicies(t *testing.T) { + t.Parallel() + binary := buildCLI(t) + manifestPath := filepath.Join(t.TempDir(), "devctl.yaml") + require.NoError(t, os.WriteFile(manifestPath, []byte(`version: 1 +project: {name: sample, language: go} +env: {} +paths: {} +sources: {} +exports: {} +components: {} +languages: + go: {module: example.test/sample} +`), 0o644)) + + runCLI(t, binary, "add", "kafka-consumer", "billing", "--file", manifestPath, + "--topic", "billing.events", "--format", "raw", "--group-env", "BILLING_GROUP") + runCLI(t, binary, "add", "kafka-producer", "audit", "--file", manifestPath, + "--topic", "audit.events", "--format", "raw", "--topic-env", "AUDIT_TOPIC") + + manifest := readTestManifest(t, manifestPath) + require.Equal(t, []testKafkaConsumer{{ + Name: "billing", Topic: "billing.events", GroupEnv: "BILLING_GROUP", + Start: &testStart{Env: "KAFKA_BILLING_CONSUMER_ENABLED", Default: boolPointer(false)}, + Contract: testKafkaContract{Format: "raw"}, + }}, manifest.Components.Kafka.Consumers) + require.Equal(t, []testKafkaProducer{{ + Name: "audit", Topic: "audit.events", TopicEnv: "AUDIT_TOPIC", + Contract: testKafkaContract{Format: "raw"}, + }}, manifest.Components.Kafka.Producers) +} + +func TestAddSourcePersistsNestedBufConfig(t *testing.T) { + t.Parallel() + binary := buildCLI(t) + manifestPath := filepath.Join(t.TempDir(), "devctl.yaml") + require.NoError(t, os.WriteFile(manifestPath, []byte(`version: 1 +project: {name: sample, language: go} +env: {} +paths: {} +sources: {} +exports: {} +components: {} +languages: + go: {module: example.test/sample} +`), 0o644)) + + runCLI(t, binary, "add", "source", "events", "--file", manifestPath, + "--type", "local", "--path", "api/events", "--buf-config", "buf.yaml") + + manifest := readTestManifest(t, manifestPath) + require.Equal(t, "buf.yaml", manifest.Sources["events"].Proto.BufConfig) +} + +func TestValidateAcceptsLocalSourceDirectory(t *testing.T) { + t.Parallel() + binary := buildCLI(t) + root := t.TempDir() + manifestPath := filepath.Join(root, "devctl.yaml") + require.NoError(t, os.MkdirAll(filepath.Join(root, "api", "events"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(root, "go.mod"), []byte("module example.test/sample\n"), 0o644)) + require.NoError(t, os.WriteFile(manifestPath, []byte(`version: 1 +project: {name: sample, language: go} +env: {} +paths: {} +sources: + events: {type: local, path: api/events} +exports: {} +components: {} +languages: + go: {module: example.test/sample} +`), 0o644)) + + runCLI(t, binary, "validate", "--file", manifestPath) +} + +func TestAddKafkaProtoEndpointConfiguresBufGeneration(t *testing.T) { + t.Parallel() + binary := buildCLI(t) + manifestPath := filepath.Join(t.TempDir(), "devctl.yaml") + require.NoError(t, os.WriteFile(manifestPath, []byte(`version: 1 +project: {name: sample, language: go} +env: {} +paths: {} +sources: + events: + type: local + path: api/events + proto: {buf_config: buf.yaml} +exports: {} +components: {} +languages: + go: {module: example.test/sample} +`), 0o644)) + + runCLI(t, binary, "add", "kafka-producer", "invoice", "--file", manifestPath, + "--topic", "invoice.events", "--format", "proto", "--source", "events", + "--path", "proto/invoice.proto", "--proto-root", "proto", "--message", "acme.invoice.v1.Invoice", + "--encoding", "binary") + + manifest := readTestManifest(t, manifestPath) + require.Equal(t, testKafkaContract{ + Source: "events", Path: "proto/invoice.proto", Format: "proto", ProtoRoot: "proto", + Message: "acme.invoice.v1.Invoice", Encoding: "binary", + }, manifest.Components.Kafka.Producers[0].Contract) + require.Equal(t, testGenerator{Out: "gen/kafka", BufGenConfig: "tools/buf/kafka.gen.yaml"}, *manifest.Languages.Go.Generators.Kafka) +} + +func TestAddStorageResourcesWritesSafeDefaults(t *testing.T) { + t.Parallel() + binary := buildCLI(t) + manifestPath := filepath.Join(t.TempDir(), "devctl.yaml") + require.NoError(t, os.WriteFile(manifestPath, []byte(`version: 1 +project: {name: sample, language: go} +env: {} +paths: {} +sources: {} +exports: {} +components: {} +languages: + go: {module: example.test/sample} +`), 0o644)) + + runCLI(t, binary, "add", "redis", "cache", "--file", manifestPath) + runCLI(t, binary, "add", "s3", "media", "--file", manifestPath) + runCLI(t, binary, "add", "db", "analytics", "--file", manifestPath, "--kind", "clickhouse") + + manifest := readTestManifest(t, manifestPath) + require.Equal(t, []testRedisConnection{{Name: "cache", AddrEnv: "REDIS_CACHE_ADDR", AddrDefault: "localhost:6379"}}, manifest.Components.Redis.Connections) + require.Equal(t, []testS3Connection{{Name: "default", Credentials: "static", Endpoint: "http://localhost:9000", Region: "us-east-1", PathStyle: true}}, manifest.Components.S3.Connections) + require.Equal(t, []testS3Bucket{{Name: "media", Connection: "default", Bucket: "media-local"}}, manifest.Components.S3.Buckets) + require.Equal(t, "clickhouse", manifest.Components.DB.Connections[0].Variants[0].Kind) + require.Equal(t, "clickhouse://localhost:9000/default", manifest.Components.DB.Connections[0].Variants[0].DSNDefault) +} + +func TestAddSQLiteDatabaseConfiguresMigrations(t *testing.T) { + t.Parallel() + binary := buildCLI(t) + manifestPath := filepath.Join(t.TempDir(), "devctl.yaml") + require.NoError(t, os.WriteFile(manifestPath, []byte(`version: 1 +project: {name: sample, language: go} +env: {} +paths: {} +sources: {} +exports: {} +components: {} +languages: + go: {module: example.test/sample} +`), 0o644)) + + runCLI(t, binary, "add", "db", "primary", "--file", manifestPath, "--kind", "sqlite") + + manifest := readTestManifest(t, manifestPath) + require.Equal(t, []testDBVariant{{ + Kind: "sqlite", + DSNDefault: "file:./data/primary.db?_foreign_keys=on", + Migrations: &testDBMigrations{ + Path: "migrations/primary/sqlite", + DatabaseEnv: "DB_PRIMARY_SQLITE_MIGRATIONS_URL", + DatabaseDefault: "sqlite://./data/primary.db?_pragma=foreign_keys%281%29", + }, + }}, manifest.Components.DB.Connections[0].Variants) +} + +func TestAddDatabaseSupportsMigrationOverrideAndOptOut(t *testing.T) { + t.Parallel() + binary := buildCLI(t) + manifestPath := filepath.Join(t.TempDir(), "devctl.yaml") + require.NoError(t, os.WriteFile(manifestPath, []byte(`version: 1 +project: {name: sample, language: go} +env: {} +paths: {} +sources: {} +exports: {} +components: {} +languages: + go: {module: example.test/sample} +`), 0o644)) + + runCLI(t, binary, "add", "db", "archive", "--file", manifestPath, "--kind", "postgres", "--migrations-path", "db/archive") + runCLI(t, binary, "add", "db", "scratch", "--file", manifestPath, "--kind", "sqlite", "--no-migrations") + + manifest := readTestManifest(t, manifestPath) + require.Equal(t, "archive", manifest.Components.DB.Connections[0].Name) + require.Equal(t, &testDBMigrations{Path: "db/archive", DatabaseEnv: "DB_ARCHIVE_POSTGRES_MIGRATIONS_URL"}, manifest.Components.DB.Connections[0].Variants[0].Migrations) + require.Equal(t, "scratch", manifest.Components.DB.Connections[1].Name) + require.Nil(t, manifest.Components.DB.Connections[1].Variants[0].Migrations) +} + +func TestAddRejectsUnsafeMigrationAndRedisOptions(t *testing.T) { + t.Parallel() + binary := buildCLI(t) + manifestPath := filepath.Join(t.TempDir(), "devctl.yaml") + require.NoError(t, os.WriteFile(manifestPath, []byte(`version: 1 +project: {name: sample, language: go} +env: {} +paths: {} +sources: {} +exports: {} +components: {} +languages: + go: {module: example.test/sample} +`), 0o644)) + + tests := []struct { + args []string + exitCode int + }{ + {[]string{"add", "db", "primary", "--file", manifestPath, "--kind", "sqlite", "--no-migrations", "--migrations-path", "migrations/primary/sqlite", "--json"}, 2}, + {[]string{"add", "db", "primary", "--file", manifestPath, "--kind", "sqlite", "--migrations-path", "../outside", "--json"}, 1}, + {[]string{"add", "db", "analytics", "--file", manifestPath, "--kind", "clickhouse", "--migrations-path", "../outside", "--json"}, 1}, + {[]string{"add", "redis", "cache", "--file", manifestPath, "--addr-default", "redis://user:secret@localhost:6379/0", "--json"}, 1}, + {[]string{"add", "redis", "cache", "--file", manifestPath, "--json", "--default"}, 2}, + } + for _, test := range tests { + _, exitCode := runCLIError(t, binary, test.args...) + require.Equal(t, test.exitCode, exitCode, test.args) + } +} + +func TestInitScaffoldCreatesMigrationToolingWithoutOwningSQL(t *testing.T) { + t.Parallel() + binary := buildCLI(t) + root := t.TempDir() + manifestPath := filepath.Join(root, "devctl.yaml") + require.NoError(t, os.WriteFile(manifestPath, []byte(`version: 1 +project: {name: sample, language: go} +env: {} +paths: {} +sources: {} +exports: {} +components: + db: + connections: + - name: primary + default: sqlite + variants: + - name: sqlite + kind: sqlite + dsn_env: DB_PRIMARY_SQLITE_DSN + dsn_default: file:./data/primary.db?_foreign_keys=on + migrations: + path: migrations/primary/sqlite + database_env: DB_PRIMARY_SQLITE_MIGRATIONS_URL + database_default: sqlite://./data/primary.db?_pragma=foreign_keys%281%29 +languages: + go: {module: example.test/sample} +`), 0o644)) + + runCLI(t, binary, "init", "scaffold", "--file", manifestPath) + + require.FileExists(t, filepath.Join(root, "migrations/primary/sqlite/.gitkeep")) + var mise struct { + Tasks map[string]struct { + Run string `toml:"run"` + } `toml:"tasks"` + } + _, err := toml.DecodeFile(filepath.Join(root, ".mise.toml"), &mise) + require.NoError(t, err) + require.Contains(t, mise.Tasks, "migrate:primary:sqlite:create") + require.Contains(t, mise.Tasks, "migrate:primary:sqlite:up") + require.Contains(t, mise.Tasks, "migrate:primary:sqlite:down") + goMod, err := os.ReadFile(filepath.Join(root, "go.mod")) + require.NoError(t, err) + require.NotContains(t, string(goMod), "golang-migrate") + + migrationPath := filepath.Join(root, "migrations/primary/sqlite/20260830010000_create_users.up.sql") + require.NoError(t, os.WriteFile(migrationPath, []byte("CREATE TABLE users (id INTEGER PRIMARY KEY);\n"), 0o644)) + runCLI(t, binary, "init", "scaffold", "--file", manifestPath) + content, err := os.ReadFile(migrationPath) + require.NoError(t, err) + require.Equal(t, "CREATE TABLE users (id INTEGER PRIMARY KEY);\n", string(content)) + runCLI(t, binary, "validate", "--file", manifestPath) +} + +func TestAddedClickHouseConnectionPassesValidation(t *testing.T) { + t.Parallel() + binary := buildCLI(t) + root := t.TempDir() + manifestPath := filepath.Join(root, "devctl.yaml") + require.NoError(t, os.WriteFile(filepath.Join(root, "go.mod"), []byte("module example.test/sample\n\ngo 1.25\n"), 0o644)) + require.NoError(t, os.WriteFile(manifestPath, []byte(`version: 1 +project: {name: sample, language: go} +env: {} +paths: {} +sources: {} +exports: {} +components: {} +languages: + go: {module: example.test/sample} +`), 0o644)) + + runCLI(t, binary, "add", "db", "analytics", "--kind", "clickhouse", "--file", manifestPath) + runCLI(t, binary, "init", "scaffold", "--file", manifestPath) + output := runCLI(t, binary, "validate", "--file", manifestPath, "--json") + var event struct { + Data struct { + Valid bool `json:"valid"` + Issues []any `json:"issues"` + } `json:"data"` + } + require.NoError(t, json.Unmarshal([]byte(output), &event)) + require.True(t, event.Data.Valid) + require.Empty(t, event.Data.Issues) +} + +func TestValidateRejectsGRPCClientWithUnknownSource(t *testing.T) { + t.Parallel() + binary := buildCLI(t) + root := t.TempDir() + manifestPath := filepath.Join(root, "devctl.yaml") + require.NoError(t, os.WriteFile(filepath.Join(root, "go.mod"), []byte("module example.test/sample\n\ngo 1.25\n"), 0o644)) + require.NoError(t, os.WriteFile(manifestPath, []byte(`version: 1 +project: {name: sample, language: go} +env: {} +paths: {} +sources: {} +exports: {} +components: + grpc: + clients: + - {name: billing, source: missing, path: proto/billing, proto_root: proto} +languages: + go: {module: example.test/sample} +`), 0o644)) + + entry, exitCode := runCLIError(t, binary, "validate", "--file", manifestPath, "--json") + + require.Equal(t, 1, exitCode) + data, ok := entry["data"].(map[string]any) + require.True(t, ok) + issues, ok := data["issues"].([]any) + require.True(t, ok) + require.Len(t, issues, 1) + issue, ok := issues[0].(map[string]any) + require.True(t, ok) + require.Equal(t, "source_not_found", issue["code"]) + require.Equal(t, "components.grpc.clients.billing.source", issue["field"]) +} + +func TestValidateRejectsGRPCClientWithInvalidContractSelection(t *testing.T) { + t.Parallel() + binary := buildCLI(t) + root := t.TempDir() + manifestPath := filepath.Join(root, "devctl.yaml") + require.NoError(t, os.MkdirAll(filepath.Join(root, "tools", "buf"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(root, "go.mod"), []byte("module example.test/sample\n\ngo 1.25\n\ntool github.com/bufbuild/buf/cmd/buf\n"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(root, "tools", "buf", "grpc.gen.yaml"), []byte("version: v2\n"), 0o644)) + require.NoError(t, os.WriteFile(manifestPath, []byte(`version: 1 +project: {name: sample, language: go} +env: {} +paths: {} +sources: + contracts: {type: git, repo: example/contracts, ref: v1} +exports: {} +components: + grpc: + clients: + - {name: billing, source: contracts, export: billing} +languages: + go: {module: example.test/sample} +`), 0o644)) + + entry, exitCode := runCLIError(t, binary, "validate", "--file", manifestPath, "--json") + + require.Equal(t, 1, exitCode) + data, ok := entry["data"].(map[string]any) + require.True(t, ok) + issues, ok := data["issues"].([]any) + require.True(t, ok) + require.Len(t, issues, 1) + issue, ok := issues[0].(map[string]any) + require.True(t, ok) + require.Equal(t, "grpc_client_invalid", issue["code"]) + require.Equal(t, "components.grpc.clients.billing", issue["field"]) +} + +func TestValidateRejectsS3BucketWithUnknownConnection(t *testing.T) { + t.Parallel() + binary := buildCLI(t) + root := t.TempDir() + manifestPath := filepath.Join(root, "devctl.yaml") + require.NoError(t, os.WriteFile(manifestPath, []byte(`version: 1 +project: {name: sample, language: go} +env: {} +paths: {} +sources: {} +exports: {} +components: + s3: + connections: + - {name: default, credentials: static} + buckets: + - {name: media, connection: archive, bucket: media-local} +languages: + go: {module: example.test/sample} +`), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(root, "go.mod"), []byte("module example.test/sample\n\ngo 1.25\n"), 0o644)) + + entry, exitCode := runCLIError(t, binary, "validate", "--file", manifestPath, "--json") + + require.Equal(t, 1, exitCode) + data, ok := entry["data"].(map[string]any) + require.True(t, ok) + require.Equal(t, false, data["valid"]) + issues, ok := data["issues"].([]any) + require.True(t, ok) + require.Len(t, issues, 1) + issue, ok := issues[0].(map[string]any) + require.True(t, ok) + require.Equal(t, "s3_connection_not_found", issue["code"]) + require.Equal(t, "components.s3.buckets.media.connection", issue["field"]) +} + +func TestValidateRejectsKafkaContractWithUnknownSource(t *testing.T) { + t.Parallel() + binary := buildCLI(t) + root := t.TempDir() + manifestPath := filepath.Join(root, "devctl.yaml") + require.NoError(t, os.WriteFile(manifestPath, []byte(`version: 1 +project: {name: sample, language: go} +env: {} +paths: {} +sources: {} +exports: {} +components: + kafka: + consumers: + - name: billing + topic: billing_service.invoice.events.v1 + contract: + source: missing + path: invoice.proto + format: proto + message: acme.invoice.v1.Invoice + encoding: binary +languages: + go: {module: example.test/sample} +`), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(root, "go.mod"), []byte("module example.test/sample\n\ngo 1.25\n"), 0o644)) + + entry, exitCode := runCLIError(t, binary, "validate", "--file", manifestPath, "--json") + + require.Equal(t, 1, exitCode) + data, ok := entry["data"].(map[string]any) + require.True(t, ok) + issues, ok := data["issues"].([]any) + require.True(t, ok) + require.Len(t, issues, 1) + issue, ok := issues[0].(map[string]any) + require.True(t, ok) + require.Equal(t, "source_not_found", issue["code"]) + require.Equal(t, "components.kafka.consumers.billing.contract.source", issue["field"]) +} + +func TestValidateReportsMissingGRPCGeneratorConfig(t *testing.T) { + t.Parallel() + binary := buildCLI(t) + root := t.TempDir() + manifestPath := filepath.Join(root, "devctl.yaml") + require.NoError(t, os.WriteFile(manifestPath, []byte(`version: 1 +project: {name: sample, language: go} +env: {} +paths: {} +sources: {} +exports: {} +components: + grpc: + server: {proto_root: api/proto, buf_config: buf.yaml} +languages: + go: + module: example.test/sample + generators: + grpc: {out: gen/grpc, buf_gen_config: tools/buf/grpc.gen.yaml} +`), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(root, "go.mod"), []byte("module example.test/sample\n\ngo 1.25\n\ntool github.com/bufbuild/buf/cmd/buf\n"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(root, "buf.yaml"), []byte("version: v2\nmodules:\n - path: api/proto\n"), 0o644)) + + entry, exitCode := runCLIError(t, binary, "validate", "--file", manifestPath, "--json") + + require.Equal(t, 1, exitCode) + data, ok := entry["data"].(map[string]any) + require.True(t, ok) + issues, ok := data["issues"].([]any) + require.True(t, ok) + require.Len(t, issues, 1) + issue, ok := issues[0].(map[string]any) + require.True(t, ok) + require.Equal(t, "tool_config_missing", issue["code"]) + require.Equal(t, "tools/buf/grpc.gen.yaml", issue["field"]) +} + +func TestInspectReportsGRPCAndKafkaTargets(t *testing.T) { + t.Parallel() + binary := buildCLI(t) + root := t.TempDir() + manifestPath := filepath.Join(root, "devctl.yaml") + require.NoError(t, os.WriteFile(manifestPath, []byte(`version: 1 +project: {name: sample, language: go} +env: + custom: + - group: app + vars: + - {key: API_TOKEN, type: string, default: do-not-leak, secret: true} +paths: {} +sources: + contracts: {type: local, path: api/contracts} +exports: {} +components: + grpc: + server: {proto_root: api/proto, buf_config: buf.yaml} + clients: + - {name: billing, source: contracts, path: proto/billing, proto_root: proto} + kafka: + producers: + - name: audit + topic: audit_service.audit.events.v1 + contract: {format: raw} +languages: + go: {module: example.test/sample} +`), 0o644)) + + output := runCLI(t, binary, "inspect", "--file", manifestPath, "--json") + var event struct { + Data struct { + Project struct { + Targets []struct { + ID string `json:"id"` + Family string `json:"family"` + Format string `json:"format"` + Input string `json:"input"` + Config string `json:"config"` + Output string `json:"output"` + } `json:"targets"` + Env []struct { + Key string `json:"key"` + Secret bool `json:"secret"` + Default *any `json:"default"` + } `json:"env"` + } `json:"project"` + } `json:"data"` + } + require.NoError(t, json.Unmarshal([]byte(output), &event)) + require.Equal(t, "config", event.Data.Project.Targets[0].ID) + require.Equal(t, "gen/config", event.Data.Project.Targets[0].Output) + require.Equal(t, "grpc-client:billing", event.Data.Project.Targets[1].ID) + require.Equal(t, "grpc", event.Data.Project.Targets[1].Family) + require.Equal(t, "proto", event.Data.Project.Targets[1].Format) + require.Equal(t, "api/contracts/proto", event.Data.Project.Targets[1].Input) + require.Equal(t, "tools/buf/grpc.gen.yaml", event.Data.Project.Targets[1].Config) + require.Equal(t, "gen/grpc/client/billing", event.Data.Project.Targets[1].Output) + require.Equal(t, "grpc-server", event.Data.Project.Targets[2].ID) + require.Equal(t, "kafka-producer:audit", event.Data.Project.Targets[3].ID) + require.Equal(t, "raw", event.Data.Project.Targets[3].Format) + require.Equal(t, "SAMPLE_API_TOKEN", event.Data.Project.Env[0].Key) + require.True(t, event.Data.Project.Env[0].Secret) + require.Nil(t, event.Data.Project.Env[0].Default) +} + +func TestInspectAddsResolvedInputFromCommittedSnapshotMetadata(t *testing.T) { + t.Parallel() + binary := buildCLI(t) + root := t.TempDir() + manifestPath := filepath.Join(root, "devctl.yaml") + require.NoError(t, os.WriteFile(manifestPath, []byte(`version: 1 +project: {name: sample, language: go} +env: {} +paths: {external_contracts: api/external} +sources: + upstream: {type: devctl, repo: example/contracts, ref: v1} +exports: {} +components: + kafka: + consumers: + - name: audit + topic: audit_service.audit.events.v1 + contract: {format: json, source: upstream, export: audit} +languages: + go: {module: example.test/sample} +`), 0o644)) + targetRoot := filepath.Join(root, "api/external/kafka/consumer/audit") + require.NoError(t, os.MkdirAll(filepath.Join(targetRoot, "schemas"), 0o755)) + require.NoError(t, os.WriteFile( + filepath.Join(targetRoot, ".devctl-contract.json"), + []byte(`{"kind":"kafka","topic":"audit_service.audit.events.v1","format":"json","entrypoint":"schemas/event.json"}`), + 0o644, + )) + require.NoError(t, os.WriteFile( + filepath.Join(targetRoot, "schemas/event.json"), []byte(`{"title":"AuditEvent","type":"object"}`), 0o644, + )) + + output := runCLI(t, binary, "inspect", "--file", manifestPath, "--json") + var event struct { + Data struct { + Project struct { + Targets []struct { + ID string `json:"id"` + Input string `json:"input"` + ResolvedInput string `json:"resolved_input"` + } `json:"targets"` + } `json:"project"` + } `json:"data"` + } + require.NoError(t, json.Unmarshal([]byte(output), &event)) + require.Equal(t, "kafka-consumer:audit", event.Data.Project.Targets[1].ID) + require.Equal(t, "api/external/kafka/consumer/audit", event.Data.Project.Targets[1].Input) + require.Equal(t, "api/external/kafka/consumer/audit/schemas/event.json", event.Data.Project.Targets[1].ResolvedInput) +} + +func TestSyncGRPCDryRunReportsPlannedClientPublication(t *testing.T) { + t.Parallel() + binary := buildCLI(t) + manifestPath := filepath.Join(t.TempDir(), "devctl.yaml") + require.NoError(t, os.WriteFile(manifestPath, []byte(`version: 1 +project: {name: sample, language: go} +env: {} +paths: {external_contracts: api/external} +sources: + billing: {type: url, url: https://example.test/billing.proto} +exports: {} +components: + grpc: + clients: + - {name: billing, source: billing, path: billing.proto, proto_root: .} +languages: + go: {module: example.test/sample} +`), 0o644)) + + output := runCLI(t, binary, "sync", "grpc", "--file", manifestPath, "--dry-run", "--json") + var event struct { + Data struct { + Targets []string `json:"targets"` + Changes []struct { + Target string `json:"target"` + Path string `json:"path"` + Action string `json:"action"` + } `json:"changes"` + } `json:"data"` + } + require.NoError(t, json.Unmarshal([]byte(output), &event)) + require.Equal(t, []string{"grpc-client:billing"}, event.Data.Targets) + require.Equal(t, "grpc-client:billing", event.Data.Changes[0].Target) + require.Equal(t, "api/external/grpc/client/billing", event.Data.Changes[0].Path) + require.Equal(t, "planned_publish", event.Data.Changes[0].Action) +} + +func TestSyncKafkaDryRunReportsPlannedProducerPublication(t *testing.T) { + t.Parallel() + binary := buildCLI(t) + manifestPath := filepath.Join(t.TempDir(), "devctl.yaml") + require.NoError(t, os.WriteFile(manifestPath, []byte(`version: 1 +project: {name: sample, language: go} +env: {} +paths: {external_contracts: api/external} +sources: + events: {type: url, url: https://example.test/events.proto} +exports: {} +components: + kafka: + producers: + - name: invoice + topic: invoice.events + contract: {source: events, path: events.proto, format: proto, message: acme.Invoice, encoding: binary} +languages: + go: {module: example.test/sample} +`), 0o644)) + + output := runCLI(t, binary, "sync", "kafka", "--file", manifestPath, "--dry-run", "--json") + var event struct { + Data struct { + Targets []string `json:"targets"` + Changes []struct { + Target string `json:"target"` + Path string `json:"path"` + } `json:"changes"` + } `json:"data"` + } + require.NoError(t, json.Unmarshal([]byte(output), &event)) + require.Equal(t, []string{"kafka-producer:invoice"}, event.Data.Targets) + require.Equal(t, "api/external/kafka/producer/invoice", event.Data.Changes[0].Path) +} + +func TestGenGRPCDryRunReportsServerAndClientOutputs(t *testing.T) { + t.Parallel() + binary := buildCLI(t) + manifestPath := filepath.Join(t.TempDir(), "devctl.yaml") + require.NoError(t, os.WriteFile(manifestPath, []byte(`version: 1 +project: {name: sample, language: go} +env: {} +paths: {external_contracts: api/external} +sources: + billing: {type: local, path: api/contracts} +exports: {} +components: + grpc: + server: {proto_root: api/proto, buf_config: buf.yaml} + clients: + - {name: billing, source: billing, path: billing, proto_root: proto} +languages: + go: + module: example.test/sample + generators: + grpc: {out: gen/grpc, buf_gen_config: tools/buf/grpc.gen.yaml} +`), 0o644)) + + output := runCLI(t, binary, "gen", "grpc", "--file", manifestPath, "--dry-run", "--json") + var event struct { + Data struct { + Targets []string `json:"targets"` + Changes []struct { + Target string `json:"target"` + Path string `json:"path"` + Action string `json:"action"` + } `json:"changes"` + } `json:"data"` + } + require.NoError(t, json.Unmarshal([]byte(output), &event)) + require.Equal(t, []string{"grpc-server", "grpc-client:billing"}, event.Data.Targets) + require.Equal(t, []struct { + Target string `json:"target"` + Path string `json:"path"` + Action string `json:"action"` + }{ + {Target: "grpc-server", Path: "gen/grpc/server", Action: "planned_publish"}, + {Target: "grpc-client:billing", Path: "gen/grpc/client/billing", Action: "planned_publish"}, + }, event.Data.Changes) +} + +func TestGenKafkaDryRunReportsConsumerAndProducerOutputs(t *testing.T) { + t.Parallel() + binary := buildCLI(t) + manifestPath := filepath.Join(t.TempDir(), "devctl.yaml") + require.NoError(t, os.WriteFile(manifestPath, []byte(`version: 1 +project: {name: sample, language: go} +env: {} +paths: {external_contracts: api/external} +sources: + events: {type: local, path: api/events} +exports: {} +components: + kafka: + consumers: + - name: invoices + topic: invoice_service.invoice.events.v1 + contract: {source: events, path: invoice.proto, format: proto, proto_root: ., message: acme.Invoice, encoding: binary} + producers: + - name: audit + topic: audit_service.audit.events.v1 + contract: {source: events, path: audit.proto, format: proto, proto_root: ., message: acme.Audit, encoding: binary} +languages: + go: + module: example.test/sample + generators: + kafka: {out: gen/kafka, buf_gen_config: tools/buf/kafka.gen.yaml} +`), 0o644)) + + output := runCLI(t, binary, "gen", "kafka", "--file", manifestPath, "--dry-run", "--json") + var event struct { + Data struct { + Targets []string `json:"targets"` + Changes []struct { + Target string `json:"target"` + Path string `json:"path"` + Action string `json:"action"` + } `json:"changes"` + } `json:"data"` + } + require.NoError(t, json.Unmarshal([]byte(output), &event)) + require.Equal(t, []string{"kafka-consumer:invoices", "kafka-producer:audit"}, event.Data.Targets) + require.Equal(t, []struct { + Target string `json:"target"` + Path string `json:"path"` + Action string `json:"action"` + }{ + {Target: "kafka-consumer:invoices", Path: "gen/kafka/consumer/invoices", Action: "planned_publish"}, + {Target: "kafka-producer:audit", Path: "gen/kafka/producer/audit", Action: "planned_publish"}, + }, event.Data.Changes) +} + +func TestGenGRPCPublishesBufOutput(t *testing.T) { + binary := buildCLI(t) + root := t.TempDir() + manifestPath := filepath.Join(root, "devctl.yaml") + require.NoError(t, os.WriteFile(manifestPath, []byte(`version: 1 +project: {name: sample, language: go} +env: {} +paths: {} +sources: {} +exports: {} +components: + grpc: + server: {proto_root: api/proto, buf_config: buf.yaml} +languages: + go: + module: example.test/sample + generators: + grpc: {out: gen/grpc, buf_gen_config: tools/buf/grpc.gen.yaml} +`), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(root, "go.mod"), []byte("module example.test/sample\n\ngo 1.25\n\ntool github.com/bufbuild/buf/cmd/buf\n"), 0o644)) + require.NoError(t, os.MkdirAll(filepath.Join(root, "api/proto/acme/v1"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(root, "api/proto/acme/v1/service.proto"), []byte("syntax = \"proto3\";\npackage acme.v1;\n"), 0o644)) + require.NoError(t, os.MkdirAll(filepath.Join(root, "tools/buf"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(root, "tools/buf/grpc.gen.yaml"), []byte("version: v2\nplugins: []\n"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(root, "buf.yaml"), []byte("version: v2\nmodules:\n - path: api/proto\n"), 0o644)) + t.Setenv("PATH", testexec.StubPathCommand(t, "go", `#!/bin/sh +set -eu +test "$1" = tool +test "$2" = buf +test "$3" = generate +test "$4" = api/proto +test "$5" = --template +test "$6" = tools/buf/grpc.gen.yaml +test "$7" = --output +mkdir -p "$8/acme/v1" +printf '// Code generated by protoc-gen-go. DO NOT EDIT.\npackage acmev1\n' > "$8/acme/v1/service.pb.go" +`)) + + output := runCLI(t, binary, "gen", "grpc", "--target", "grpc-server", "--file", manifestPath, "--json") + var event struct { + Data struct { + Targets []string `json:"targets"` + Changes []struct { + Path string `json:"path"` + Action string `json:"action"` + } `json:"changes"` + } `json:"data"` + } + require.NoError(t, json.Unmarshal([]byte(output), &event)) + require.Equal(t, []string{"grpc-server"}, event.Data.Targets) + require.Equal(t, "gen/grpc/server/acme/v1/service.pb.go", event.Data.Changes[0].Path) + require.Equal(t, "created", event.Data.Changes[0].Action) + require.FileExists(t, filepath.Join(root, "gen/grpc/server/acme/v1/service.pb.go")) +} + +func TestGenGRPCClientUsesProtoRootAndSelectedPath(t *testing.T) { + binary := buildCLI(t) + root := t.TempDir() + manifestPath := filepath.Join(root, "devctl.yaml") + require.NoError(t, os.WriteFile(manifestPath, []byte(`version: 1 +project: {name: sample, language: go} +env: {} +paths: {} +sources: + contracts: {type: local, path: api/contracts} +exports: {} +components: + grpc: + clients: + - name: billing + source: contracts + path: proto/acme/billing/v1 + proto_root: proto + buf_gen_config: tools/buf/billing.gen.yaml +languages: + go: + module: example.test/sample + generators: + grpc: {out: gen/grpc, buf_gen_config: tools/buf/grpc.gen.yaml} +`), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(root, "go.mod"), []byte("module example.test/sample\n\ngo 1.25\n\ntool github.com/bufbuild/buf/cmd/buf\n"), 0o644)) + require.NoError(t, os.MkdirAll(filepath.Join(root, "api/contracts/proto/acme/billing/v1"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(root, "api/contracts/proto/acme/billing/v1/billing.proto"), []byte("syntax = \"proto3\";\npackage acme.billing.v1;\n"), 0o644)) + require.NoError(t, os.MkdirAll(filepath.Join(root, "tools/buf"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(root, "tools/buf/billing.gen.yaml"), []byte("version: v2\nplugins: []\n"), 0o644)) + t.Setenv("PATH", testexec.StubPathCommand(t, "go", `#!/bin/sh +set -eu +test "$1" = tool +test "$2" = buf +test "$3" = generate +test "$4" = api/contracts/proto +test "$5" = --template +test "$6" = tools/buf/billing.gen.yaml +test "$7" = --path +test "$8" = acme/billing/v1 +test "$9" = --output +mkdir -p "${10}/acme/billing/v1" +printf '// Code generated by protoc-gen-go. DO NOT EDIT.\npackage billingv1\n' > "${10}/acme/billing/v1/billing.pb.go" +`)) + + runCLI(t, binary, "gen", "grpc", "--target", "grpc-client:billing", "--file", manifestPath, "--json") + + require.FileExists(t, filepath.Join(root, "gen/grpc/client/billing/acme/billing/v1/billing.pb.go")) +} + +func TestGenKafkaProducerUsesProtoContractSelection(t *testing.T) { + binary := buildCLI(t) + root := t.TempDir() + manifestPath := filepath.Join(root, "devctl.yaml") + require.NoError(t, os.WriteFile(manifestPath, []byte(`version: 1 +project: {name: sample, language: go} +env: {} +paths: {} +sources: + events: {type: local, path: api/events} +exports: {} +components: + kafka: + producers: + - name: invoice + topic: invoice_service.invoice.events.v1 + contract: + source: events + path: proto/invoice_service.invoice.events.v1.proto + format: proto + proto_root: proto + message: acme.invoice.v1.Invoice + encoding: binary +languages: + go: + module: example.test/sample + generators: + kafka: {out: gen/kafka, buf_gen_config: tools/buf/kafka.gen.yaml} +`), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(root, "go.mod"), []byte("module example.test/sample\n\ngo 1.25\n\ntool github.com/bufbuild/buf/cmd/buf\n"), 0o644)) + require.NoError(t, os.MkdirAll(filepath.Join(root, "api/events/proto"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(root, "api/events/proto/invoice_service.invoice.events.v1.proto"), []byte("syntax = \"proto3\";\npackage acme.invoice.v1;\n"), 0o644)) + require.NoError(t, os.MkdirAll(filepath.Join(root, "tools/buf"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(root, "tools/buf/kafka.gen.yaml"), []byte("version: v2\nplugins: []\n"), 0o644)) + t.Setenv("PATH", testexec.StubPathCommand(t, "go", `#!/bin/sh +set -eu +test "$1" = tool +test "$2" = buf +test "$3" = generate +test "$4" = api/events/proto +test "$5" = --template +test "$6" = tools/buf/kafka.gen.yaml +test "$7" = --path +test "$8" = invoice_service.invoice.events.v1.proto +test "$9" = --output +mkdir -p "${10}/acme/invoice/v1" +printf '// Code generated by protoc-gen-go. DO NOT EDIT.\npackage invoicev1\n' > "${10}/acme/invoice/v1/invoice.pb.go" +`)) + + runCLI(t, binary, "gen", "kafka", "--target", "kafka-producer:invoice", "--file", manifestPath, "--json") + + require.FileExists(t, filepath.Join(root, "gen/kafka/producer/invoice/acme/invoice/v1/invoice.pb.go")) +} + +func TestLintGRPCPreservesCompletedFindingWhenBufIsUnavailable(t *testing.T) { + t.Parallel() + binary := buildCLI(t) + root := t.TempDir() + manifestPath := filepath.Join(root, "devctl.yaml") + require.NoError(t, os.WriteFile(manifestPath, []byte(`version: 1 +project: {name: sample, language: go} +env: {} +paths: {} +sources: {} +exports: {} +components: + grpc: + server: {proto_root: api/proto, buf_config: buf.yaml} +languages: + go: {module: example.test/sample} +`), 0o644)) + require.NoError(t, os.MkdirAll(filepath.Join(root, "api/proto/acme/v1"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(root, "api/proto/acme/v1/service.proto"), []byte("syntax = \"proto3\";\npackage acme.v1;\nservice BillingService {}\n"), 0o644)) + + entry, exitCode := runCLIError(t, binary, "lint", "grpc", "--file", manifestPath, "--json") + + require.Equal(t, 1, exitCode) + require.Equal(t, "unavailable", entry["code"]) + require.NotContains(t, entry, "data") + details, ok := entry["details"].(map[string]any) + require.True(t, ok) + partial, ok := details["partial_result"].(map[string]any) + require.True(t, ok) + require.Equal(t, false, partial["valid"]) + require.Equal(t, []any{"grpc-server"}, partial["contracts"]) + issues, ok := partial["issues"].([]any) + require.True(t, ok) + require.Len(t, issues, 1) + issue, ok := issues[0].(map[string]any) + require.True(t, ok) + require.Equal(t, "proto_filename", issue["code"]) + require.Equal(t, "grpc-server", issue["target"]) + require.Equal(t, "api/proto/acme/v1/service.proto", issue["path"]) +} + +func TestLintKafkaReportsInvalidTopicName(t *testing.T) { + t.Parallel() + binary := buildCLI(t) + root := t.TempDir() + manifestPath := filepath.Join(root, "devctl.yaml") + require.NoError(t, os.WriteFile(manifestPath, []byte(`version: 1 +project: {name: sample, language: go} +env: {} +paths: {} +sources: {} +exports: {} +components: + kafka: + producers: + - name: audit + topic: audit.events + contract: {format: raw} +languages: + go: {module: example.test/sample} +`), 0o644)) + + entry, exitCode := runCLIError(t, binary, "lint", "kafka", "--file", manifestPath, "--json") + + require.Equal(t, 1, exitCode) + data, ok := entry["data"].(map[string]any) + require.True(t, ok) + require.Equal(t, false, data["valid"]) + require.Equal(t, []any{"kafka-producer:audit"}, data["contracts"]) + issues, ok := data["issues"].([]any) + require.True(t, ok) + require.Len(t, issues, 1) + issue, ok := issues[0].(map[string]any) + require.True(t, ok) + require.Equal(t, "kafka_topic", issue["code"]) + require.Equal(t, "kafka-producer:audit", issue["target"]) +} + +func TestLintKafkaReportsMismatchedLocalSchemaFilename(t *testing.T) { + t.Parallel() + binary := buildCLI(t) + root := t.TempDir() + manifestPath := filepath.Join(root, "devctl.yaml") + require.NoError(t, os.WriteFile(manifestPath, []byte(`version: 1 +project: {name: sample, language: go} +env: {} +paths: {} +sources: + events: {type: local, path: api/events} +exports: {} +components: + kafka: + producers: + - name: audit + topic: audit_service.audit.created.v1 + contract: + source: events + path: wrong_name.json + format: json +languages: + go: {module: example.test/sample} +`), 0o644)) + require.NoError(t, os.MkdirAll(filepath.Join(root, "api/events"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(root, "api/events/wrong_name.json"), []byte(`{"title":"AuditEvent","type":"object"}`), 0o644)) + + entry, exitCode := runCLIError(t, binary, "lint", "kafka", "--file", manifestPath, "--json") + + require.Equal(t, 1, exitCode) + data, ok := entry["data"].(map[string]any) + require.True(t, ok) + require.Equal(t, false, data["valid"]) + require.Equal(t, []any{"kafka-producer:audit"}, data["contracts"]) + issues, ok := data["issues"].([]any) + require.True(t, ok) + require.Len(t, issues, 1) + issue, ok := issues[0].(map[string]any) + require.True(t, ok) + require.Equal(t, "kafka_schema_filename", issue["code"]) + require.Equal(t, "kafka-producer:audit", issue["target"]) + require.Equal(t, "wrong_name.json", issue["path"]) +} + +func TestLintKafkaReportsMissingJSONSchemaTitle(t *testing.T) { + t.Parallel() + binary := buildCLI(t) + root := t.TempDir() + manifestPath := filepath.Join(root, "devctl.yaml") + require.NoError(t, os.WriteFile(manifestPath, []byte(`version: 1 +project: {name: sample, language: go} +env: {} +paths: {} +sources: + events: {type: local, path: api/events} +exports: {} +components: + kafka: + producers: + - name: audit + topic: audit_service.audit.created.v1 + contract: + source: events + path: audit_service.audit.created.v1.json + format: json +languages: + go: {module: example.test/sample} +`), 0o644)) + require.NoError(t, os.MkdirAll(filepath.Join(root, "api/events"), 0o755)) + schemaPath := filepath.Join(root, "api/events/audit_service.audit.created.v1.json") + require.NoError(t, os.WriteFile(schemaPath, []byte(`{"type":"object"}`), 0o644)) + + entry, exitCode := runCLIError(t, binary, "lint", "kafka", "--file", manifestPath, "--json") + + require.Equal(t, 1, exitCode) + data, ok := entry["data"].(map[string]any) + require.True(t, ok) + issues, ok := data["issues"].([]any) + require.True(t, ok) + require.Len(t, issues, 1) + issue, ok := issues[0].(map[string]any) + require.True(t, ok) + require.Equal(t, "json_schema_title", issue["code"]) + require.Equal(t, "kafka-producer:audit", issue["target"]) + require.Equal(t, schemaPath, issue["path"]) + require.Equal(t, "title", issue["field"]) +} + +func TestLintWithoutFamilyIncludesKafkaContracts(t *testing.T) { + t.Parallel() + binary := buildCLI(t) + root := t.TempDir() + manifestPath := filepath.Join(root, "devctl.yaml") + require.NoError(t, os.WriteFile(manifestPath, []byte(`version: 1 +project: {name: sample, language: go} +env: {} +paths: {} +sources: {} +exports: {} +components: + kafka: + consumers: + - name: audit + topic: audit.events + contract: {format: raw} +languages: + go: {module: example.test/sample} +`), 0o644)) + + entry, exitCode := runCLIError(t, binary, "lint", "--file", manifestPath, "--json") + + require.Equal(t, 1, exitCode) + data, ok := entry["data"].(map[string]any) + require.True(t, ok) + require.Equal(t, []any{"kafka-consumer:audit"}, data["contracts"]) + issues, ok := data["issues"].([]any) + require.True(t, ok) + require.Len(t, issues, 1) + issue, ok := issues[0].(map[string]any) + require.True(t, ok) + require.Equal(t, "kafka_topic", issue["code"]) +} + +type testManifest struct { + Sources map[string]testSource `yaml:"sources"` + Components struct { + GRPC *testGRPC `yaml:"grpc"` + Kafka *testKafka `yaml:"kafka"` + Redis *testRedis `yaml:"redis"` + S3 *testS3 `yaml:"s3"` + DB *testDB `yaml:"db"` + } `yaml:"components"` + Languages struct { + Go struct { + Generators struct { + GRPC *testGenerator `yaml:"grpc"` + Kafka *testGenerator `yaml:"kafka"` + } `yaml:"generators"` + } `yaml:"go"` + } `yaml:"languages"` +} + +type testSource struct { + Proto struct { + BufConfig string `yaml:"buf_config"` + } `yaml:"proto"` +} +type testGenerator struct { + Out string `yaml:"out"` + BufGenConfig string `yaml:"buf_gen_config"` +} +type testStart struct { + Env string `yaml:"env"` + Default *bool `yaml:"default"` +} +type testGRPC struct { + Server *testGRPCServer `yaml:"server"` + Clients []testGRPCClient `yaml:"clients"` +} +type testGRPCServer struct { + ProtoRoot string `yaml:"proto_root"` + BufConfig string `yaml:"buf_config"` + Start *testStart `yaml:"start"` +} +type testGRPCClient struct { + Name string `yaml:"name"` + Source string `yaml:"source"` + Path string `yaml:"path"` + ProtoRoot string `yaml:"proto_root"` + BufGenConfig string `yaml:"buf_gen_config"` + AddrEnv string `yaml:"addr_env"` +} +type testKafka struct { + Consumers []testKafkaConsumer `yaml:"consumers"` + Producers []testKafkaProducer `yaml:"producers"` +} +type testKafkaContract struct { + Source string `yaml:"source"` + Path string `yaml:"path"` + Format string `yaml:"format"` + ProtoRoot string `yaml:"proto_root"` + Message string `yaml:"message"` + Encoding string `yaml:"encoding"` +} +type testKafkaConsumer struct { + Name string `yaml:"name"` + Topic string `yaml:"topic"` + GroupEnv string `yaml:"group_env"` + Start *testStart `yaml:"start"` + Contract testKafkaContract `yaml:"contract"` +} +type testKafkaProducer struct { + Name string `yaml:"name"` + Topic string `yaml:"topic"` + TopicEnv string `yaml:"topic_env"` + Contract testKafkaContract `yaml:"contract"` +} +type testRedis struct { + Connections []testRedisConnection `yaml:"connections"` +} +type testRedisConnection struct { + Name string `yaml:"name"` + AddrEnv string `yaml:"addr_env"` + AddrDefault string `yaml:"addr_default"` +} +type testS3 struct { + Connections []testS3Connection `yaml:"connections"` + Buckets []testS3Bucket `yaml:"buckets"` +} +type testS3Connection struct { + Name string `yaml:"name"` + Credentials string `yaml:"credentials"` + Endpoint string `yaml:"endpoint"` + Region string `yaml:"region"` + PathStyle bool `yaml:"path_style"` +} +type testS3Bucket struct { + Name string `yaml:"name"` + Connection string `yaml:"connection"` + Bucket string `yaml:"bucket"` +} +type testDB struct { + Connections []testDBConnection `yaml:"connections"` +} +type testDBConnection struct { + Name string `yaml:"name"` + Variants []testDBVariant `yaml:"variants"` +} +type testDBVariant struct { + Kind string `yaml:"kind"` + DSNDefault string `yaml:"dsn_default"` + Migrations *testDBMigrations `yaml:"migrations"` +} +type testDBMigrations struct { + Path string `yaml:"path"` + DatabaseEnv string `yaml:"database_env"` + DatabaseDefault string `yaml:"database_default"` +} + +func readTestManifest(t *testing.T, path string) testManifest { + t.Helper() + data, err := os.ReadFile(path) + require.NoError(t, err) + var manifest testManifest + require.NoError(t, yaml.Unmarshal(data, &manifest)) + return manifest +} + +func boolPointer(value bool) *bool { return &value } + +func TestLeafHelpOwnsCommonFlags(t *testing.T) { + t.Parallel() + stdout := runCLI(t, buildCLI(t), "validate", "--help") + + require.Contains(t, stdout, "--file") + require.Contains(t, stdout, "--json") + require.Contains(t, stdout, "--verbose") + require.NotContains(t, stdout, "--format") +} + +func TestValidateEmitsJSONResultEvent(t *testing.T) { + t.Parallel() + binary := buildCLI(t) + manifestPath := filepath.Join(t.TempDir(), "devctl.yaml") + require.NoError(t, os.WriteFile(filepath.Join(filepath.Dir(manifestPath), "go.mod"), []byte("module example.test/sample\n\ngo 1.25\n"), 0o644)) + require.NoError(t, os.WriteFile(manifestPath, []byte(`version: 1 +project: {name: sample, language: go} +env: {} +paths: {} +sources: {} +exports: {} +components: {} +languages: + go: {module: example.test/sample} +`), 0o644)) + + command := exec.CommandContext(context.Background(), binary, "validate", "--file", manifestPath, "--json") + var stdout bytes.Buffer + var stderr bytes.Buffer + command.Stdout = &stdout + command.Stderr = &stderr + + require.NoError(t, command.Run(), "stdout:\n%s\nstderr:\n%s", stdout.String(), stderr.String()) + require.Empty(t, stderr.String()) + var event struct { + Level string `json:"level"` + Message string `json:"msg"` + Command string `json:"command"` + Data struct { + Valid bool `json:"valid"` + Issues []any `json:"issues"` + } `json:"data"` + } + require.NoError(t, json.Unmarshal(stdout.Bytes(), &event)) + require.Equal(t, "info", event.Level) + require.Equal(t, "project validation completed", event.Message) + require.Equal(t, "validate", event.Command) + require.True(t, event.Data.Valid) + require.Empty(t, event.Data.Issues) +} + +func TestUsageErrorsExitTwo(t *testing.T) { + t.Parallel() + + binary := buildCLI(t) + for _, args := range [][]string{{"--bad"}, {"--format", "json", "validate"}, {"--json", "validate"}, {"unknown"}, {"inspect", "extra"}} { + command := exec.CommandContext(context.Background(), binary, args...) + err := command.Run() + var exitError *exec.ExitError + require.ErrorAs(t, err, &exitError, "args: %v", args) + require.Equal(t, 2, exitError.ExitCode(), "args: %v", args) + } +} + +func TestErrorsUseSelectedLogEncoding(t *testing.T) { + t.Parallel() + binary := buildCLI(t) + + t.Run("json usage", func(t *testing.T) { + t.Parallel() + entry, exitCode := runCLIError(t, binary, "validate", "--json", "extra") + + require.Equal(t, 2, exitCode) + require.Equal(t, "error", entry["level"]) + require.Equal(t, "usage", entry["code"]) + require.EqualValues(t, 2, entry["exit_code"]) + require.NotEmpty(t, entry["msg"]) + require.NotContains(t, entry, "logger") + require.NotContains(t, entry, "error") + }) + + t.Run("console usage", func(t *testing.T) { + t.Parallel() + command := exec.CommandContext(context.Background(), binary, "unknown") + output, err := command.CombinedOutput() + var exitError *exec.ExitError + require.ErrorAs(t, err, &exitError) + require.Equal(t, 2, exitError.ExitCode()) + require.Contains(t, string(output), "\terror\t") + require.Contains(t, string(output), `"code": "usage"`) + }) +} + +func TestExecutionErrorHidesRawCauseUnlessVerbose(t *testing.T) { + t.Parallel() + binary := buildCLI(t) + manifestPath := filepath.Join(t.TempDir(), "missing.yaml") + + safeEntry, exitCode := runCLIError(t, binary, "validate", "--json", "--file", manifestPath) + require.Equal(t, 1, exitCode) + require.Equal(t, "not_found", safeEntry["code"]) + require.EqualValues(t, 1, safeEntry["exit_code"]) + require.Equal(t, "requested resource was not found", safeEntry["msg"]) + require.NotContains(t, safeEntry, "error") + require.NotContains(t, safeEntry, "data") + require.NotContains(t, safeEntry, "details") + require.NotContains(t, stringJSON(t, safeEntry), manifestPath) + + verboseEntry, exitCode := runCLIError(t, binary, "validate", "--json", "--verbose", "--file", manifestPath) + require.Equal(t, 1, exitCode) + require.Contains(t, verboseEntry["error"], "readManifestFile") + require.Contains(t, verboseEntry["error"], filepath.Base(manifestPath)) +} + +func buildCLI(t *testing.T) string { + t.Helper() + if testCLIBinary == "" { + t.Fatal("test CLI binary was not initialized") + } + return testCLIBinary +} + +func runCLI(t *testing.T, binary string, args ...string) string { + t.Helper() + command := exec.CommandContext(context.Background(), binary, args...) + output, err := command.CombinedOutput() + require.NoError(t, err, "output:\n%s", output) + return string(output) +} + +func runCLIError(t *testing.T, binary string, args ...string) (map[string]any, int) { + t.Helper() + command := exec.CommandContext(context.Background(), binary, args...) + output, err := command.CombinedOutput() + var exitError *exec.ExitError + require.ErrorAs(t, err, &exitError) + + lines := strings.Split(strings.TrimSpace(string(output)), "\n") + require.NotEmpty(t, lines) + var entry map[string]any + require.NoError(t, json.Unmarshal([]byte(lines[len(lines)-1]), &entry), "output:\n%s", output) + return entry, exitError.ExitCode() +} + +func stringJSON(t *testing.T, value any) string { + t.Helper() + data, err := json.Marshal(value) + require.NoError(t, err) + return string(data) +} diff --git a/docs/adr/0001-canonical-project-model-and-command-boundaries.md b/docs/adr/0001-canonical-project-model-and-command-boundaries.md new file mode 100644 index 0000000..b7fa523 --- /dev/null +++ b/docs/adr/0001-canonical-project-model-and-command-boundaries.md @@ -0,0 +1,61 @@ +# ADR 0001: Use one canonical Project model and explicit command boundaries + +- Status: accepted +- Date: 2026-09-03 + +## Context + +Project bootstrap, manifest mutation, contract acquisition, linting, code +generation, and scaffolding have different side effects and readiness needs. +Allowing each workflow to interpret the Manifest independently would duplicate +defaults and paths, while implicit chaining would make a command's filesystem +and network effects difficult to predict. + +## Decision + +The v1 CLI has exactly eight top-level commands: `init`, `validate`, `inspect`, +`enable`, `add`, `sync`, `lint`, and `gen`. Initialization is explicit through +`init manifest` and `init scaffold`. Manifest mutations do not install tools, +run generators, or change handwritten Go. `sync`, `lint`, and `gen` never +invoke one another implicitly. + +`devctl.yaml` is the canonical desired-state Manifest. The Project service is +the only gateway for manifest discovery, decoding, semantic validation, +inspection, and mutation. Structural decoding issues, semantic validity, and +Project Readiness are distinct. `validate` checks all three; other workflows +load the semantically valid Project and check only their own prerequisites. + +The project domain projects the Manifest into one immutable, total, and +deterministically ID-sorted Target Catalog. The catalog owns effective Target +IDs, families, roles, references, source locations, Logical Inputs, outputs, +defaults, and supported operations. A valid committed snapshot may add an +optional Resolved Input for inspection without changing the Logical Input. +Missing committed metadata does not make `inspect` fail. + +Workflows select and execute catalog Targets instead of deriving these facts +again. Unknown families are `invalid_input`; a known family with no applicable +Targets succeeds with an empty result; an unknown explicit Target ID is +`not_found`; and an existing Target that lacks the requested operation is +`unsupported`. Local sync is a supported no-op. Raw Kafka Targets do not claim +sync support. + +The catalog remains sorted by Target ID for stable inspection and selection. +Generation separately executes `config`, HTTP server/client, gRPC +server/client, then Kafka consumer/producer Targets so dependency-sensitive +work remains explicit. + +The dependency direction is `cmd -> service -> domain`. Delivery code invokes +one service root per executable command. Consumer-owned ports isolate +repositories, external tools, and protocol analyzers, while `internal/deps` is +the production composition root. + +## Consequences + +Adding a Component or Target requires one domain projection change followed by +the relevant inspect, sync, lint, generation, and scaffold adapters. Invalid +references remain visible to validation instead of disappearing from a partial +catalog. Readiness failures remain attributable to the workflow that actually +needs the missing file or tool. + +We reject command aliases with hidden orchestration, workflow-local copies of +Target defaults, and selectors whose meaning changes by workflow. diff --git a/docs/adr/0002-contract-sources-exports-and-snapshots.md b/docs/adr/0002-contract-sources-exports-and-snapshots.md new file mode 100644 index 0000000..f645fa6 --- /dev/null +++ b/docs/adr/0002-contract-sources-exports-and-snapshots.md @@ -0,0 +1,77 @@ +# ADR 0002: Materialize stable Contract Snapshots from bounded Sources + +- Status: accepted +- Date: 2026-09-03 + +## Context + +Contracts can originate locally, at a URL, in Git, or as a named Export from +another Devctl Project. Generation and linting must remain reproducible and +must not silently depend on changing external suppliers. Cross-project Kafka +and gRPC contracts also need enough committed information to be interpreted +without filesystem discovery. + +## Decision + +A Source is a containment root. A Contract Reference selects a relative +Entrypoint or a named Export, and materialization produces a Snapshot with +`ModuleRoot`, `Entrypoint`, `Files`, and `Metadata`. OpenAPI and JSON Schema +Snapshots have an Entrypoint. A gRPC Export is module-only: it has a Module +Root and no Entrypoint. A Proto-backed Kafka Snapshot may have both, with the +Entrypoint contained by the Module Root. + +Local Exports are exact projections of effective Project surfaces. An OpenAPI +Export uses the effective HTTP server OpenAPI path. A gRPC Export uses the +effective gRPC server Proto root. A Kafka producer Export names an existing +producer and inherits that producer's topic and format. A Devctl Source always +addresses the upstream checkout root, forbids `path`, and selects a named +Export. Upstream Manifest loading is structural-only; the selected Export is +validated at materialization time. + +For a URL Source, `source.url` is the fetch base while the selected Target path +is the virtual committed Entrypoint. Only relative `$ref` values are followed; +absolute references are ignored even when same-origin. A relative reference is +resolved for fetching from the current document URL and for storage from the +current virtual path. Query participates in fetch identity, fragments do not, +and two distinct query identities that map to the same virtual path are an +invalid collision. Query values are redacted from diagnostics. + +Every URL request and redirect must retain the initial scheme, host, and +effective port. Credentials and scheme changes are forbidden. One Snapshot is +limited to 64 documents, 64 MiB aggregate, and 32 MiB per response; Devctl +keeps no persistent URL cache. Policy, reference, limit, and collision failures +map to `invalid_input`; HTTP 404 and 410 map to `not_found`; other non-success +HTTP responses and network, DNS, TLS, or timeout failures map to `unavailable`. + +Proto Snapshots carry the effective upstream Buf config declared by +`components.grpc.server.buf_config`, defaulting to `buf.yaml`, plus an adjacent +`buf.lock` when present. No discovery heuristic selects alternate Buf files. + +Every Devctl-sourced gRPC or Kafka Managed External Contract has a root +`.devctl-contract.json` sidecar whose relative paths are interpreted from that +root. gRPC metadata records `kind: grpc`, `format: proto`, `module_root`, and +`buf_config`. Kafka metadata records `kind`, `topic`, and `format`; JSON adds +`entrypoint`, Proto adds `entrypoint`, `module_root`, and `buf_config`, and raw +Kafka adds no file fields. `buf.lock` is a Snapshot file, not a metadata field. +Local Sources have no sidecar and local sync is a no-op. + +Downstream lint, generation, inspection resolution, and re-export consume the +committed metadata and never rediscover a schema or contact the original +supplier. Missing or invalid required metadata is a stale Snapshot, not an +invalid Contract. It returns `invalid_input` with typed reason +`snapshot_metadata_invalid`, the offending field and reason, and a hint to run +`devctl sync`. Legacy Devctl-sourced gRPC and non-raw Kafka snapshots without +complete metadata are handled the same way. `inspect` remains tolerant and +simply omits Resolved Input until committed metadata is valid. + +## Consequences + +Contract updates become visible repository changes and builds remain stable +when suppliers are unavailable. Same-origin URL closures support multi-file +specifications without becoming a general web crawler. Existing cross-project +snapshots must be refreshed before downstream operations rely on the new +metadata. + +We reject single-document URL semantics, absolute-reference crawling, +implicit network access from lint or generation, arbitrary gRPC first-file +entrypoints, and schema discovery by file count. diff --git a/docs/adr/0003-managed-artifacts-and-publication.md b/docs/adr/0003-managed-artifacts-and-publication.md new file mode 100644 index 0000000..f4b24c3 --- /dev/null +++ b/docs/adr/0003-managed-artifacts-and-publication.md @@ -0,0 +1,71 @@ +# ADR 0003: Separate Managed Outputs from Scaffold Seeds + +- Status: accepted +- Date: 2026-09-03 + +## Context + +Sync, generation, and scaffold all publish files, but they do not have the +same ownership contract. Treating user-edited seeds as generated output risks +data loss, while treating managed trees as shared directories prevents safe +replacement and stale cleanup. Multi-Target workflows also need precise and +race-free change reporting. + +## Decision + +A Managed Output is a file or complete Target tree owned by one Devctl +workflow. It is rendered or materialized in temporary storage and atomically +published at the Target boundary. The publication adapter returns a structured +result that distinguishes `created`, `updated`, and `unchanged` as part of the +same operation; workflows do not infer this with a read-before-publish race. +This race-free publication guarantee applies to Managed Outputs only. + +Targets execute sequentially and deterministically. A later failure does not +roll back completed Targets and returns their partial result. Generation +replaces only each selected Target tree and removes stale files inside that +tree; it never prunes unrelated Targets. The external-contract namespace +belongs entirely to `sync`; a full family sync may prune stale Target children, +while targeted sync never prunes unrelated Targets. + +Observed changes are `created`, `updated`, `unchanged`, and `removed`. +Side-effect-free preview reports `planned_publish` and `planned_remove`. +Previewing removal uses a read-only repository operation with the same path, +symlink, and file-type validation as actual publication. Dry-run performs no +network acquisition, generator execution, publication, or pruning. + +A Scaffold Seed is created once and is thereafter user-owned. Scaffold first +preflights its complete artifact plan. For each Seed it then checks the current +entry, leaves an existing regular file unchanged, rejects symlinks and +non-regular entries, and uses ordinary file publication only when the path is +absent. Scaffold never deliberately overwrites or deletes an existing Seed, +including during refresh. Managed Outputs may be refreshed independently. + +The Seed check and publication are not a cross-process atomic +create-if-absent operation. An external writer can create the same path in the +narrow interval between them and have its file replaced. We accept this rare +race as a deliberate simplicity tradeoff rather than add a separate filesystem +primitive. Consequently `init scaffold` has no `--force` option; +`init manifest --force` remains a separate whole-manifest replacement command +that rebuilds the Manifest from its arguments or preset rather than merging. + +This is a pre-v1 ownership cutover. Old scaffold filenames receive no +compatibility shim or automatic migration; users review and move existing +custom code explicitly. + +## Consequences + +Users can predict which files may be replaced and review dry-run cleanup before +it occurs. A workflow can leave a valid partially updated Project after a late +failure, so callers must inspect the returned changes before retrying. Every +scaffold plan must classify each artifact as managed or create-once. + +Callers that require a hard cross-process create-only guarantee need a +different explicit publication operation; Scaffold does not claim that +guarantee for Seeds. + +We reject workflow-wide rollback, read-before-publish change detection for +Managed Outputs, automatic deletion or intentional overwrite of Scaffold +Seeds, and shared managed directories containing arbitrary user files. We also +reject adding a dedicated atomic create-only filesystem primitive for Scaffold +Seeds while the practical risk remains limited to the accepted external-writer +race. diff --git a/docs/adr/0004-project-owned-generator-toolchain.md b/docs/adr/0004-project-owned-generator-toolchain.md new file mode 100644 index 0000000..7ef1281 --- /dev/null +++ b/docs/adr/0004-project-owned-generator-toolchain.md @@ -0,0 +1,50 @@ +# ADR 0004: Keep generator toolchains project-owned + +- Status: accepted +- Date: 2026-09-03 + +## Context + +Checked-in generated code must be reproducible from a standalone Project. A +Devctl-bundled or globally installed generator can drift independently of the +Project and makes upgrades invisible in code review. Native generator configs +also have different ownership depending on whether the canonical path or an +explicit override is selected. + +## Decision + +The consuming Project owns generator versions and native configuration. Go +generators are pinned to exact published module versions with `go.mod` tool +directives. Mise owns exact executable runtime and task versions that are not +Go tools, including Node 24, quicktype 26.0.0, and golang-migrate 4.19.1 when +their capabilities are used. Generator objects in the Manifest do not expose a +competing `tool` selector. + +Devctl invokes tools through the Project environment, writes into temporary +storage, validates expected output, and hands publication to the owning +workflow. Canonical shared native generator configs are Managed Outputs. An +explicit custom config path is user-owned: it must already exist, validation +checks it, generation consumes it, and scaffold never creates, overwrites, or +deletes it. Every distinct gRPC Target config is planned and validated. + +Proto input ownership is split deliberately. A supplier's Contract Snapshot +contains its declared Buf config and adjacent lock file; the consuming Project +owns its `*.gen.yaml` generation config. Buf paths must remain contained +project-relative regular files and each file is included exactly once. + +Quicktype 26.0.0 is the Kafka JSON Schema generator. Every input Schema has a +non-empty root `title`, which owns the generated top-level type name. The Go +output includes serialization helpers and marks non-required fields with +`omitempty`. Devctl remains Go-only in v1; choosing quicktype does not add a +multi-language Manifest surface or a `go:generate` workflow. + +## Consequences + +A fresh checkout can reconstruct its toolchain from versioned Project files. +Generator upgrades are explicit dependency changes and may update checked-in +Managed Outputs. Devctl reports missing Project tools with installation +guidance rather than silently substituting bundled versions. + +We reject global PATH lookup as the version policy, generated custom override +files, generator-specific version fields in the Manifest, and heuristic Buf +config discovery. diff --git a/docs/adr/0005-canonical-runtime-config.md b/docs/adr/0005-canonical-runtime-config.md new file mode 100644 index 0000000..08d11f7 --- /dev/null +++ b/docs/adr/0005-canonical-runtime-config.md @@ -0,0 +1,61 @@ +# ADR 0005: Use one canonical Runtime Config catalog + +- Status: accepted +- Date: 2026-09-03 + +## Context + +Config generation, scaffold templates, and `inspect` previously derived +environment keys, defaults, types, and field names independently. Their +policies diverged, so a Managed Output refresh could generate Go code that did +not match the runtime consumers created by scaffold. + +## Decision + +The project domain owns an immutable, key-sorted Runtime Config catalog derived +from one Manifest. It is the sole policy owner for effective prefixes, keys, +types, defaults, secret markers, semantic Go field paths, and the runtime, +example, and inspect projections. Conflicting declarations and colliding Go +field paths are typed domain errors reported by `validate` as +`runtime_config_conflict`. + +Every Go Project has an implicit `config` Target. Its default Managed Output is +`gen/config/config.gen.go`; `languages.go.generators.config.out` only overrides +that location. Newly initialized Manifests omit the redundant default block. +`devctl gen config` and `devctl init scaffold` use the same renderer for one +grouped generated `Config` type and the root `.env.example`. Scaffold emits an +adapter that imports the generated type rather than maintaining a second +schema. `inspect` projects its environment list from the same catalog. + +No `start` block means a Capability is always on and has no environment toggle. +A present `start` block creates a toggle; if it omits `default`, its effective +default is `false`. Construction and startup are separate: disabled gateable +Capabilities may be constructed but do not run. A selected disabled Kafka +consumer is rejected before dependency resolution, so its client is not +constructed. Ecosystem-standard OpenTelemetry keys retain the `OTEL_*` prefix. + +Each Kafka consumer receives runtime fields for enabled state, group, topic, +batch maximum size, batch flush interval, retry maximum attempts, retry maximum +elapsed time, retry initial and maximum delays, rebalance and drain timeouts, +and shutdown timeout. The Manifest topic is the runtime default and may be +overridden through environment. `CommitRetry` inherits the main retry policy; +`OnReject` is fixed to `RejectStop`; rarer library knobs remain available only +through user-owned custom environment and explicit composition until they +become Manifest features. + +Migration database environment belongs only to example and inspect +projections. It is absent from application Runtime Config, and the migration +URL is distinct from the runtime database connection. Secret fields never +carry defaults: generated tags omit them, `.env.example` leaves them blank, +and inspect reports only the secret marker. + +## Consequences + +Generated field paths are semantic and grouped, such as `cfg.HTTP.Address`, +`cfg.Telemetry.ServiceVersion`, and `cfg.DBPrimary.PostgresDSN`. Projects using +the former flat scaffold config regenerate scaffold and config together. +Adding environment-backed behavior requires one catalog policy and projection +tests. + +We reject separate scaffold and generation config builders, hidden default +topic changes, and compatibility flat fields with no independent lifetime. diff --git a/docs/adr/0006-machine-readable-cli-contract.md b/docs/adr/0006-machine-readable-cli-contract.md new file mode 100644 index 0000000..ed87516 --- /dev/null +++ b/docs/adr/0006-machine-readable-cli-contract.md @@ -0,0 +1,46 @@ +# ADR 0006: Use structured events as the machine-readable CLI contract + +- Status: accepted +- Date: 2026-09-03 + +## Context + +Automation needs deterministic result and failure records while humans need +safe diagnostics. Success already uses structured log events, but late +failures currently place partial results in the same `data` field as success, +and adapters can collapse specific source failures into broader categories. + +## Decision + +`--json` emits compact JSONL. A successful command emits exactly one structured +event on stdout with `level`, `ts`, `msg`, and its command-specific payload in +`data`. It does not emit a bare result object. Diagnostic events may precede +the final event only under `--verbose`. + +Usage, execution, and cancellation failures emit one final event on stderr +with stable `code` and `exit_code` fields. Safe scalar context belongs in +`details`. A late workflow failure places the completed operation result in +`details.partial_result`; the reporter safely merges it with existing details, +top-level error `data` is removed, and stdout remains empty. This pre-v1 wire +change has no compatibility shim. Raw causes and external tool output appear +only under `--verbose`. + +Invalid `validate` and `lint` findings are normal results: the structured event +is written to stdout, stderr is empty, and the process exits `1`. Execution +errors also exit `1`, usage/help exits `2`, cancellation exits `130`, and +success exits `0`. + +Public failure codes remain `usage`, `invalid_input`, `not_found`, `conflict`, +`unavailable`, `unsupported`, `cancelled`, and `internal`. Adapters add safe +operation, Target, Source, field, and path context without replacing a more +specific underlying category. Partial results are attached only after actual +progress, never merely after planning or validation. + +## Consequences + +Callers can distinguish successful data from recovery information and branch +on stable failure categories without parsing messages. CLI DTO, reporter, and +end-to-end tests must change together. + +We reject bare success objects, mixed stdout error payloads, top-level error +`data`, and reducing every source acquisition failure to `unavailable`. diff --git a/docs/adr/0007-explicit-application-composition-and-runtime-scenarios.md b/docs/adr/0007-explicit-application-composition-and-runtime-scenarios.md new file mode 100644 index 0000000..ffdc42e --- /dev/null +++ b/docs/adr/0007-explicit-application-composition-and-runtime-scenarios.md @@ -0,0 +1,81 @@ +# ADR 0007: Use explicit application composition and runtime Scenarios + +- Status: accepted +- Date: 2026-09-03 + +## Context + +A generated dependency registry would keep scaffold refresh automatic, but it +would hide application composition, require AST-aware mutation or runtime +registration, and blur ownership between generated infrastructure and user +behavior. API and Kafka processes also need different root lifecycles without +constructing every runnable branch eagerly. + +## Decision + +All Devctl-owned Go scaffold files use the `*.gen.go` suffix and the standard +generated-file header. User-owned files use ordinary `.go` names and are +Scaffold Seeds. Managed `provideX` functions register infrastructure. A +create-once `provideApplication` function calls them explicitly and is the +user-owned composition list. Later refreshes may add new per-component Seeds +but never edit that list; the user adds each new provider call deliberately. +Missing registration fails normally during dependency resolution. Devctl does +not analyze Go syntax or maintain a runtime plugin registry. + +Named registrations use unexported, namespaced Canonical DI Keys such as +`db-connection:analytics`, `kafka-consumer:audit`, +`kafka-producer:audit`, `http-client:billing`, and +`grpc-client:billing`. The constants remain internal to the composition +package. Each Kafka consumer has a create-once provider binding that selects +its message type, decoder, handler, and retry policy; `provideApplication` +calls that binding. + +Managed scenario code defines `Scenario`, which owns a dependency graph and +selected runner and provides `Run(ctx)` and `Shutdown(ctx)`. `NewAPI(ctx)` +resolves an unnamed API Runtime whose lifecycle tasks contain HTTP, gRPC, +health, and pprof work. `NewConsumer(ctx, name)` validates the Manifest-derived +consumer name and enabled toggle before resolution, calls `provideApplication`, +and resolves only the selected runner by its Canonical DI Key. Providers are +registered eagerly but constructed lazily. Selecting a disabled consumer is a +typed scaffolded-application error and process exit 1, not a Devctl CLI public +error category. + +Application routes and services are registered through typed `HTTPRegistrar` +and `GRPCRegistrar` interfaces. Managed runtime code resolves these registrars +and applies them to the Echo and gRPC servers. Outbound clients expose raw +transport handles and base addresses: HTTP exposes `*http.Client` and its base +URL, while gRPC exposes `*grpc.ClientConn`. Application code constructs the +generated OpenAPI clients and Proto stubs it needs. + +Database Variants share one named logical provider that switches on effective +config and opens only the selected backend. A logical Connection cannot mix a +ClickHouse Variant with transactional Variants. Kafka producers are ordinary +`*kafka.Producer[[]byte]`; topic config is injected into application types +that need it rather than hidden behind a generated wrapper. + +Kafka consumer infrastructure uses a managed generic provider that resolves a +named `kafka.ConsumerConfig`, `kafka.Decoder[T]`, +`kafka.BatchHandler[T]`, and `retry.Policy`, then registers the named runner. +Per-consumer managed config maps generated Runtime Config and registers the +default exponential policy. Managed helpers cover raw bytes, JSON, and Proto; +Proto support and its dependency are conditional. Raw decoding is zero-copy +because the handler contract forbids retaining a batch. Schema-backed +consumers still default to `[]byte`; the user opts into actual generated JSON +or Proto types in the Provider Binding. Buf output receives no Devctl facade or +type-alias file. + +The initial raw handler Seed has no infrastructure dependencies and returns a +permanent `ErrNotImplemented`, making an unimplemented consumer fail closed +instead of acknowledging messages silently. + +## Consequences + +Composition remains readable Go and user intent survives scaffold refresh. +Adding a Component requires adding its explicit Provider Binding call, and the +generated documentation must include that checklist. Scenario resolution +constructs only the selected runnable branch while still allowing shared +infrastructure providers. + +We reject AST mutation of user code, implicit runtime registries, exported DI +key constants, generated wrappers around generated clients, and no-op default +handlers. diff --git a/docs/adr/0008-native-clickhouse-runtime-and-migrations.md b/docs/adr/0008-native-clickhouse-runtime-and-migrations.md new file mode 100644 index 0000000..3c6ee93 --- /dev/null +++ b/docs/adr/0008-native-clickhouse-runtime-and-migrations.md @@ -0,0 +1,52 @@ +# ADR 0008: Use the native ClickHouse runtime and separate migrations + +- Status: accepted +- Date: 2026-09-03 + +## Context + +ClickHouse's native Go API provides capabilities and operational semantics that +do not fit the transactional `database/sql` abstraction used by SQLite and +PostgreSQL. Migration tooling, however, already integrates through a separate +driver and migration URL. + +## Decision + +ClickHouse runtime connections use +`github.com/ClickHouse/clickhouse-go/v2` v2.48.0 and expose the native +`driver.Conn`. Construction parses the DSN, opens the connection, performs an +eager ping, and registers owned close behavior. The connection is named with +the same `db-connection:` convention as other database Resources, and a +narrow health checker delegates to `Ping`. ClickHouse does not receive the +transaction manager, read/write endpoint split, or generated repository +getter used for transactional databases; application repositories resolve the +named native connection and define their own narrow interfaces. + +ClickHouse migrations remain supported through golang-migrate's ClickHouse +database driver and a migration-only secret URL. They retain the same Project +surface as other databases: a default migration directory, an explicit path +override, and `--no-migrations`. Runtime native-driver config and migration +driver config are independent. + +ClickHouse migrations are non-transactional. Documentation recommends a +migration DSN with `x-multi-statement=true` when a file contains multiple +statements, but Devctl neither appends nor enforces that parameter. The known +semicolon-splitting limitations and the possibility of partial application and +manual recovery must be documented. + +## Consequences + +Runtime code retains ClickHouse-native behavior instead of presenting a false +transactional abstraction. Applications that support several database kinds +must keep ClickHouse separate from transactional Variants within one logical +Connection. Migration operators explicitly own multi-statement and recovery +trade-offs. + +We reject `database/sql` as the ClickHouse runtime contract, generated +transaction helpers for ClickHouse, and silent DSN mutation. + +## References + +- [clickhouse-go README](https://github.com/ClickHouse/clickhouse-go) +- [Native driver.Conn API](https://pkg.go.dev/github.com/ClickHouse/clickhouse-go/v2@v2.48.0/lib/driver#Conn) +- [golang-migrate ClickHouse driver](https://github.com/golang-migrate/migrate/blob/master/database/clickhouse/README.md) diff --git a/docs/agents/domain.md b/docs/agents/domain.md new file mode 100644 index 0000000..4f148b9 --- /dev/null +++ b/docs/agents/domain.md @@ -0,0 +1,53 @@ +# Domain Docs + +How the engineering skills should consume this repo's domain documentation when exploring the codebase. + +## Before exploring, read these + +- **`CONTEXT.md`** at the repo root, or +- **`CONTEXT-MAP.md`** at the repo root if it exists: it points at one `CONTEXT.md` per context. Read each one relevant to the topic. +- **`docs/adr/`**: read ADRs that touch the area you're about to work in. In multi-context repos, also check `src//docs/adr/` for context-scoped decisions. + +If any of these files don't exist, **proceed silently**. Don't flag their absence; don't suggest creating them upfront. The `/domain-modeling` skill (reached via `/grill-with-docs` and `/improve-codebase-architecture`) creates them lazily when terms or decisions actually get resolved. + +## File structure + +Single-context repo (most repos): + +``` +/ +├── CONTEXT.md +├── docs/ +│ └── adr/ +│ ├── 0001-event-sourced-orders.md +│ └── 0002-postgres-for-write-model.md +└── src/ +``` + +Multi-context repo (presence of `CONTEXT-MAP.md` at the root): + +``` +/ +├── CONTEXT-MAP.md +├── docs/ +│ └── adr/ ← system-wide decisions +└── src/ + ├── ordering/ + │ ├── CONTEXT.md + │ └── docs/adr/ ← context-specific decisions + └── billing/ + ├── CONTEXT.md + └── docs/adr/ +``` + +## Use the glossary's vocabulary + +When your output names a domain concept (in an issue title, a refactor proposal, a hypothesis, a test name), use the term as defined in `CONTEXT.md`. Don't drift to synonyms the glossary explicitly avoids. + +If the concept you need isn't in the glossary yet, that's a signal: either you're inventing language the project doesn't use (reconsider) or there's a real gap (note it for `/domain-modeling`). + +## Flag ADR conflicts + +If your output contradicts an existing ADR, surface it explicitly rather than silently overriding: + +> _Contradicts ADR-0007 (event-sourced orders), but worth reopening because…_ diff --git a/docs/agents/issue-tracker.md b/docs/agents/issue-tracker.md new file mode 100644 index 0000000..658e570 --- /dev/null +++ b/docs/agents/issue-tracker.md @@ -0,0 +1,29 @@ +# Issue tracker: Local Markdown + +Issues and specs for this repo live as markdown files in `.scratch/`. + +## Conventions + +- One feature per directory: `.scratch//` +- The spec is `.scratch//spec.md` +- Implementation issues are one file per ticket at `.scratch//issues/-.md`, numbered from `01`, never a single combined tickets file +- Comments and conversation history append to the bottom of the file under a `## Comments` heading + +## When a skill says "publish to the issue tracker" + +Create a new file under `.scratch//` (creating the directory if needed). + +## When a skill says "fetch the relevant ticket" + +Read the file at the referenced path. The user will normally pass the path or the issue number directly. + +## Wayfinding operations + +Used by `/wayfinder`. The **map** is a file with one **child** file per ticket. + +- **Map**: `.scratch//map.md` (the Notes / Decisions-so-far / Fog body). +- **Child ticket**: `.scratch//issues/NN-.md`, numbered from `01`, with the question in the body. A `Type:` line records the ticket type (`research`/`prototype`/`grilling`/`task`); a `Status:` line records `claimed`/`resolved`. +- **Blocking**: a `Blocked by: NN, NN` line near the top. A ticket is unblocked when every file it lists is `resolved`. +- **Frontier**: scan `.scratch//issues/` for files that are open, unblocked, and unclaimed; first by number wins. +- **Claim**: set `Status: claimed` and save before any work. +- **Resolve**: append the answer under an `## Answer` heading, set `Status: resolved`, then append a context pointer (gist + link) to the map's Decisions-so-far in `map.md`. diff --git a/docs/development.md b/docs/development.md new file mode 100644 index 0000000..f24c20b --- /dev/null +++ b/docs/development.md @@ -0,0 +1,57 @@ +# Developing Devctl + +This guide is for contributors to the Devctl repository. Project authors using +the CLI should start with the [user guide](user-guide/README.md). + +## Local checks + +Install the repository-pinned tools once, or again after `.mise.toml` changes: + +```sh +mise install +``` + +Run the complete local CI contract through Mise: + +```sh +mise run check +``` + +`check` runs formatting, golangci-lint (including `govet`), dependency-graph +hygiene, race-enabled Go tests, build, generated documentation, the Orders API +example, and e2e workflows. The ordinary `mise run test` task remains +available for faster non-race iteration. + +The pull request CI runs this complete check on Linux and cross-builds the +Devctl binary for macOS amd64 and arm64 with CGO disabled. Native macOS jobs +are intentionally not part of the regular CI because the repository has no +platform-specific implementation. The release workflow still builds and +publishes native macOS release archives for both architectures. + +Regenerate the CLI reference after changing visible command metadata: + +```sh +mise run docs:generate +``` + +## Working with sibling modules + +For local work across sibling `devctl` and `go-libs` checkouts, create a Go +workspace outside either repository, for example in their parent directory: + +```sh +go work init ./devctl ./go-libs/... +``` + +Use that workspace only for interactive development. Do not commit `go.work` +or add `replace` directives to the released module. Before submitting, rerun +the checks above with `GOWORK=off` so they exercise the dependency graph used +by CI and release builds. + +## Ownership boundaries + +Devctl uses `github.com/devctllabs/go-libs/filesystem` for rooted file +operations, `go-libs/log` for structured logging, and `go-libs/di` for command +scenario graphs. Atomic file publication, Git, process execution, and external +tool boundaries remain owned by Devctl. Completed Target or scaffold changes +are not rolled back after a later failure. diff --git a/docs/user-guide/README.md b/docs/user-guide/README.md new file mode 100644 index 0000000..2d720cd --- /dev/null +++ b/docs/user-guide/README.md @@ -0,0 +1,28 @@ +# Devctl user guide + +Devctl turns one checked-in `devctl.yaml` Manifest into a reproducible Go +Project. This guide is organized around work you need to complete rather than +the internal packages that implement it. + +## Start here + +1. [Build an HTTP API with PostgreSQL](getting-started.md). +2. Learn the [core concepts and ownership rules](concepts-and-ownership.md). +3. Use the [Project workflow](project-workflow.md) when changing a Manifest. +4. Follow the [Contract and generation workflow](contracts-and-generation.md) + when an API or event schema changes. + +## Continue by task + +- [Work inside the generated Project](generated-project.md) +- [Add databases, storage, clients, and event endpoints](recipes.md) +- [Diagnose and recover from failures](troubleshooting.md) + +## Reference + +- [Commands and flags](reference/commands.md) +- [Manifest v1](reference/manifest/README.md) +- [JSONL, result shapes, errors, and exit codes](reference/output-and-errors.md) + +Documentation on `main` follows the current source tree. When using a released +binary, select the matching Git tag before reading these files. diff --git a/docs/user-guide/concepts-and-ownership.md b/docs/user-guide/concepts-and-ownership.md new file mode 100644 index 0000000..0bdb8b1 --- /dev/null +++ b/docs/user-guide/concepts-and-ownership.md @@ -0,0 +1,64 @@ +# Concepts and ownership + +## Project and Manifest + +A **Project** is the repository rooted at the selected Manifest. The +**Manifest** is the canonical desired-state document, normally `devctl.yaml`. +It declares Components, Resources, Sources, paths, Runtime Config, and the Go +generator policy. + +When `--file` is absent, Devctl searches upward from the current directory for +`devctl.yaml`. Use `--file ` when you need an explicit Manifest. + +## Components, Capabilities, and Resources + +A **Component** is an application-facing area such as HTTP, gRPC, Kafka, +logging, or telemetry. A **Capability** is enableable runtime behavior inside a +Component. A **Resource** is a named infrastructure dependency such as a +database, Redis Connection, or S3 bucket. + +`enable` and `add` mutate only the Manifest. After a structural change, run +`init scaffold` to materialize the corresponding Project foundation and `gen` +when generated code is required. + +## Contracts, Sources, and Targets + +A **Source** is a bounded origin for Contracts. A Contract Reference selects an +Entrypoint or named Export from that Source. External Contracts become +committed **Contract Snapshots** after `sync`, so later lint and generation can +run offline and produce reviewable changes. + +A **Target** is the effective unit addressed by `sync`, `lint`, or `gen`, for +example `http-client:billing`. `inspect` shows the complete, deterministically +ordered Target Catalog and the inputs, outputs, and operations of each Target. + +## Managed Outputs and Scaffold Seeds + +Every scaffolded path has one owner: + +- A **Managed Output** belongs entirely to Devctl. Its owning workflow may + atomically replace the file or Target tree and remove stale files inside it. +- A **Scaffold Seed** is created once and belongs to you afterwards. Devctl + does not deliberately overwrite or delete an existing Seed. + +Devctl-owned Go scaffold files end in `*.gen.go` and carry a generated-file +header. Ordinary `.go` files, application entrypoints, Provider Bindings, +handlers, and the scaffolded README are Seeds. + +`init scaffold` has no `--force`. It refreshes Managed Outputs, creates missing +Seeds, and rejects unsafe symlinks or non-regular entries. Adding a Component +may create a new Provider Binding, but you must add its `provideX` call to +`internal/deps/application.go` yourself. + +## Explicit workflows + +Devctl deliberately avoids hidden orchestration: + +- `init manifest`, `enable`, and `add` do not install tools, scaffold files, or + generate code. +- `sync`, `lint`, and `gen` never invoke one another. +- A later Target failure does not roll back Targets that already completed. +- `--dry-run` plans from committed local state without network acquisition, + generator execution, publication, or pruning. + +This makes command side effects predictable and repository diffs reviewable. diff --git a/docs/user-guide/contracts-and-generation.md b/docs/user-guide/contracts-and-generation.md new file mode 100644 index 0000000..f0a794f --- /dev/null +++ b/docs/user-guide/contracts-and-generation.md @@ -0,0 +1,79 @@ +# Contracts and generation + +## Choose a Source + +Declare where a Contract originates, then reference it from a client or Kafka +endpoint. Source containment prevents references from escaping the selected +root. + +```sh +devctl add source contracts --type local --path api/contracts +devctl add http-client billing \ + --source contracts \ + --path billing/openapi.yaml +``` + +Ordinary Sources select a relative `--path`. A Devctl Source selects a named +upstream `--export` instead. See [recipes](recipes.md) for the Source matrix. + +## Synchronize external state + +```sh +devctl sync +devctl sync http --target http-client:billing +``` + +`sync` materializes external Contract closures into Project-owned paths. +Review and commit those Snapshots. Local Sources are supported no-ops. + +A full family sync may remove stale Target children. Targeted sync publishes +only the selected Target and never prunes another Target. Use `--dry-run` to +preview publication and pruning without network access or writes. + +## Lint committed inputs + +```sh +devctl lint +devctl lint grpc +``` + +Linting reads local Contracts or committed external Snapshots. It never +contacts the supplier and never runs generation. Findings are normal results: +stdout contains the complete result, stderr remains empty, and the process +exits `1` when invalid. + +## Generate Managed Outputs + +```sh +devctl gen +devctl gen http --target http-client:billing +``` + +Generation invokes versions and native configuration owned by the Project. It +publishes one Target atomically, removes stale files only inside that Target +tree, and never prunes siblings. Execution order is config, HTTP, gRPC, then +Kafka. + +After a generator changes Go imports, finish with: + +```sh +go mod tidy +mise run check +``` + +## Supplier and consumer Buf files + +For Proto, the supplier's `buf.yaml` and adjacent `buf.lock` travel with the +Contract Snapshot. The consuming Project owns `*.gen.yaml`. Canonical consumer +configs are Managed Outputs; an explicitly selected `buf_gen_config` is +user-owned and must already exist before validation or generation. + +## When to run each command + +| Change | Scaffold | Sync | Lint | Gen | +|---|:---:|:---:|:---:|:---:| +| Manifest-only default | maybe | no | no | maybe | +| New Component or Resource | yes | if external | if Contract | if generated output | +| Local server Contract | no | no | yes | yes | +| External Contract revision | no | yes | yes | yes | +| Generator configuration | maybe | no | no | yes | diff --git a/docs/user-guide/generated-project.md b/docs/user-guide/generated-project.md new file mode 100644 index 0000000..3c7c7c2 --- /dev/null +++ b/docs/user-guide/generated-project.md @@ -0,0 +1,60 @@ +# Working in a generated Project + +## Refresh safely + +Run `devctl init scaffold` whenever the Manifest gains a Component, Resource, +or generator configuration. Review the reported `created`, `updated`, and +`unchanged` paths. + +Managed files may change on every refresh. Scaffold Seeds are yours after +creation. Do not put handwritten files inside a Target directory owned by +`sync` or `gen`, because publication may replace that complete tree. + +## Compose application behavior + +`internal/deps/application.go` is the user-owned composition list. Generated +`provideX` functions register infrastructure; call the providers your +application needs from `provideApplication`. Refresh may add new binding Seeds +but does not edit this list. + +HTTP and gRPC behavior is registered through `HTTPRegistrar` and +`GRPCRegistrar`. Generated outbound providers expose raw HTTP transports/base +URLs or gRPC connections; application code constructs the generated client or +stub it needs. + +## Run Scenarios + +The generated `Scenario` owns one lazy dependency graph and orderly shutdown: + +- `deps.NewAPI(ctx)` resolves the API runtime and its HTTP, gRPC, health, and + pprof work. +- `deps.NewConsumer(ctx, name)` validates the selected Kafka consumer and + resolves only that runner. + +Unknown or disabled consumers fail before their Kafka client is constructed. +The initial Kafka handler Seed returns a permanent not-implemented error so an +unfinished consumer cannot acknowledge messages silently. + +## Configure runtime behavior + +Generated Runtime Config is derived from the Manifest. `.env.example`, +`inspect`, generated config, and scaffold consumers share the same catalog. + +No `start` block means a Capability is always on. A present `start` block +creates an environment toggle; when its `default` is omitted, the effective +default is `false`. Secret entries never receive rendered defaults. + +## Manage migrations + +For each database Variant with migrations, scaffold creates the directory and +pinned Mise tasks. Devctl does not write SQL or apply migrations. + +```sh +mise run migrate:primary:postgres:create create_orders +mise run migrate:primary:postgres:up +mise run migrate:primary:postgres:down +``` + +Runtime DSNs and migration URLs are independent. ClickHouse migrations are +non-transactional; a failed multi-statement migration may require manual +inspection and recovery before forcing a version. diff --git a/docs/user-guide/getting-started.md b/docs/user-guide/getting-started.md new file mode 100644 index 0000000..70ee4ee --- /dev/null +++ b/docs/user-guide/getting-started.md @@ -0,0 +1,305 @@ +# Build an Orders API with PostgreSQL + +This walkthrough starts with an empty directory and ends with a running HTTP +API that writes to PostgreSQL. It exercises the complete local Devctl workflow: +Manifest creation, scaffolding, Contract linting, generation, a migration, and +user-owned application code. + +The finished result is checked in at +[`examples/orders-api`](../../examples/orders-api/README.md). + +## Prerequisites + +Install Go 1.26, Git, Mise, and Docker. Then install Devctl: + +```sh +go install github.com/devctllabs/devctl/cmd/devctl@latest +devctl --version +``` + +## 1. Create the Project + +```sh +mkdir orders-api +cd orders-api + +devctl init manifest \ + --lang go \ + --preset http-service \ + --name orders-api \ + --module example.com/orders-api + +devctl add db primary --kind postgres +devctl init scaffold +mise install +go mod tidy +``` + +At this point `devctl.yaml` is the desired state, files ending in `*.gen.go` +belong to Devctl, and ordinary `.go` files belong to you. The database command +also added PostgreSQL configuration and migration tasks to the Project. + +## 2. Define the HTTP Contract + +Replace `api/openapi/swagger.yaml` with this OpenAPI 3.1 Contract: + +```yaml +openapi: 3.1.0 +info: + title: Orders API + version: 1.0.0 +paths: + /orders: + post: + operationId: createOrder + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CreateOrder" + responses: + "201": + description: Order created + content: + application/json: + schema: + $ref: "#/components/schemas/Order" + /orders/{id}: + get: + operationId: getOrder + parameters: + - name: id + in: path + required: true + schema: + type: integer + format: int64 + minimum: 1 + responses: + "200": + description: Order found + content: + application/json: + schema: + $ref: "#/components/schemas/Order" + "404": + description: Order not found + content: + application/json: + schema: + $ref: "#/components/schemas/Problem" +components: + schemas: + CreateOrder: + type: object + additionalProperties: false + required: [customer_name, total_cents] + properties: + customer_name: + type: string + minLength: 1 + total_cents: + type: integer + format: int64 + minimum: 0 + Order: + type: object + additionalProperties: false + required: [id, customer_name, total_cents, created_at] + properties: + id: + type: integer + format: int64 + customer_name: + type: string + total_cents: + type: integer + format: int64 + created_at: + type: string + format: date-time + Problem: + type: object + additionalProperties: false + required: [message] + properties: + message: + type: string +``` + +Lint the Contract and generate the strict Echo server interface: + +```sh +devctl lint +devctl gen +go mod tidy +``` + +`devctl lint` validates the Contract but does not generate code. `devctl gen` +publishes the generated server to `gen/serverhttp/server.gen.go`. + +## 3. Create the database migration + +Create a timestamped migration pair: + +```sh +mise run migrate:primary:postgres:create create_orders +``` + +Put this in the new `.up.sql` file: + +```sql +CREATE TABLE orders ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + customer_name text NOT NULL, + total_cents bigint NOT NULL CHECK (total_cents >= 0), + created_at timestamptz NOT NULL DEFAULT now() +); +``` + +Put this in the matching `.down.sql` file: + +```sql +DROP TABLE orders; +``` + +## 4. Implement the user-owned code + +Create an `internal/orders` package that contains: + +- an `Order` domain value and a small `Store` interface; +- a PostgreSQL store using `*postgresdb.Endpoint`; +- a handler implementing `serverhttp.StrictServerInterface`; +- a mapping from the domain value to the generated API type. + +The complete implementation is +[`internal/orders/orders.go`](../../examples/orders-api/internal/orders/orders.go). +Its important boundary is deliberately small: + +```go +type Store interface { + Create(ctx context.Context, customerName string, totalCents int64) (Order, error) + Get(ctx context.Context, id int64) (Order, error) +} + +type Handler struct { + store Store +} +``` + +Then edit the user-owned `internal/deps/application.go`. Resolve the reader and +writer endpoints published by the generated database provider, construct the +store and handler, and register the strict server: + +```go +reader, err := di.ResolveNamed[*postgresdb.Endpoint]( + resolver, + storagePrimaryConnectionName+".reader", +) +// handle err +writer, err := di.ResolveNamed[*postgresdb.Endpoint]( + resolver, + storagePrimaryConnectionName+".writer", +) +// handle err + +store := orders.NewPostgresStore(reader, writer) +app := &application{orders: orders.NewHandler(store)} +``` + +```go +func (a *application) RegisterHTTP(server *echo.Echo) { + strict := serverhttp.NewStrictHandler(a.orders, nil) + serverhttp.RegisterHandlers(server, strict) +} +``` + +See the complete +[`application.go`](../../examples/orders-api/internal/deps/application.go) and +the handler tests in +[`handler_test.go`](../../examples/orders-api/internal/orders/handler_test.go). + +Verify that the Project compiles before starting infrastructure: + +```sh +go test ./... +``` + +## 5. Start PostgreSQL and apply the migration + +Start an isolated PostgreSQL container: + +```sh +docker run --rm --detach \ + --name devctl-orders-postgres \ + --publish 5432:5432 \ + --env POSTGRES_USER=orders \ + --env POSTGRES_PASSWORD=orders \ + --env POSTGRES_DB=orders \ + postgres:18.6-alpine3.23 +``` + +Wait until it is ready: + +```sh +docker exec devctl-orders-postgres pg_isready -U orders -d orders +``` + +Configure both the runtime pool and the migration tool, then migrate: + +```sh +export ORDERS_API_DB_PRIMARY_KIND=postgres +export ORDERS_API_DB_PRIMARY_POSTGRES_DSN='postgres://orders:orders@127.0.0.1:5432/orders?sslmode=disable' +export ORDERS_API_DB_PRIMARY_POSTGRES_MIGRATIONS_URL="$ORDERS_API_DB_PRIMARY_POSTGRES_DSN" + +mise run migrate:primary:postgres:up +``` + +The generated `.env.example` lists every runtime key. Secrets have empty +values and must be supplied through the environment or your secret manager. + +## 6. Run and call the API + +Start the API in one terminal: + +```sh +go run ./cmd/orders-api api +``` + +Create an order from another terminal: + +```sh +curl --fail-with-body \ + --request POST \ + --header 'Content-Type: application/json' \ + --data '{"customer_name":"Ada","total_cents":1250}' \ + http://127.0.0.1:8080/orders +``` + +The response has status `201` and contains the assigned ID: + +```json +{"created_at":"2026-09-04T19:30:00Z","customer_name":"Ada","id":1,"total_cents":1250} +``` + +Use that ID to read the row back: + +```sh +curl --fail-with-body http://127.0.0.1:8080/orders/1 +``` + +Stop the API with Ctrl-C, then stop PostgreSQL: + +```sh +docker stop devctl-orders-postgres +``` + +## What to do next + +- Read [concepts and ownership](concepts-and-ownership.md) before editing + generated files. +- Follow the [Project workflow](project-workflow.md) when adding Components. +- Use [contracts and generation](contracts-and-generation.md) for local and + external Contract changes. +- Use the [recipes](recipes.md) to add more Resources and clients. diff --git a/docs/user-guide/project-workflow.md b/docs/user-guide/project-workflow.md new file mode 100644 index 0000000..adc5cd4 --- /dev/null +++ b/docs/user-guide/project-workflow.md @@ -0,0 +1,72 @@ +# Project workflow + +## Create a Manifest + +Choose the minimal CLI application or an HTTP service foundation: + +```sh +devctl init manifest \ + --lang go \ + --preset http-service \ + --name billing-api \ + --module example.com/billing-api +``` + +The command creates only `devctl.yaml`. `--force` replaces the complete +Manifest from the supplied arguments; it does not merge with the old file. + +## Change desired state + +Use `enable` for runtime Capabilities and `add` for named Resources, Sources, +clients, and Kafka endpoints: + +```sh +devctl enable grpc +devctl add db primary --kind postgres +devctl add redis cache +``` + +Mutation commands preserve a valid existing declaration unless `--force` is +given. They never install dependencies or change handwritten Go. + +## Materialize the foundation + +```sh +devctl init scaffold +mise install +go mod tidy +``` + +Run scaffold again after adding Components or Resources. Review created files, +then register new user-owned Provider Bindings in +`internal/deps/application.go`. See [generated Project](generated-project.md) +for the ownership contract. + +## Validate and inspect + +```sh +devctl validate +devctl inspect +``` + +`validate` checks three distinct layers: YAML/schema structure, semantic +validity, and Project Readiness. Findings are returned as a normal result and +produce exit status `1`. + +`inspect` shows the effective Project rather than repeating raw YAML. Use it to +find effective paths, Target IDs, Runtime Config keys and defaults, Resources, +and resolved Contract inputs. Missing or stale external Snapshot Metadata does +not make inspection fail; `resolved_input` is simply absent until `sync` +publishes valid metadata. + +## Preview destructive publication + +Before generation or a full external synchronization, inspect the plan: + +```sh +devctl gen --dry-run +devctl sync --dry-run +``` + +Dry runs report `planned_publish` and, for full synchronization, possible +`planned_remove` actions. A targeted sync never removes sibling Target trees. diff --git a/docs/user-guide/recipes.md b/docs/user-guide/recipes.md new file mode 100644 index 0000000..e0387c1 --- /dev/null +++ b/docs/user-guide/recipes.md @@ -0,0 +1,125 @@ +# Recipes + +These recipes show the Manifest mutation step. Follow each with `init +scaffold`, `validate`, `sync`, `lint`, or `gen` only when the changed surface +requires it. + +## Databases + +```sh +devctl add db primary --kind sqlite +devctl add db primary --kind postgres --default +devctl add db analytics --kind clickhouse +``` + +Migration targets default to `migrations//`. Override the +path with `--migrations-path`, or opt out with `--no-migrations`. + +A Connection with several Variants must name a default. ClickHouse cannot be +mixed with SQLite or PostgreSQL Variants in the same logical Connection because +its native runtime is not transactional. + +## Redis + +```sh +devctl add redis cache +devctl add redis sessions \ + --addr-env SESSIONS_REDIS_ADDR \ + --addr-default localhost:6380 +``` + +The default address is `localhost:6379`. Addresses may be `host:port` or a +`redis`/`rediss` URL without embedded credentials. + +## S3 + +```sh +devctl add s3-connection assets --credentials ambient +devctl add s3 uploads --connection assets +``` + +Credential modes are `ambient` and `static`. When `add s3` omits +`--connection`, Devctl creates the canonical local static Connection if +needed. + +## Contract Sources + +```sh +# Project-local containment root +devctl add source contracts --type local --path api/contracts + +# HTTPS closure rooted at a URL +devctl add source public-api \ + --type url \ + --url https://contracts.example.com/ \ + --filename openapi.yaml + +# Repository checkout +devctl add source shared \ + --type git \ + --repo https://github.com/acme/contracts.git \ + --ref v1.4.0 \ + --path services + +# Named Exports from another Devctl Project +devctl add source platform \ + --type devctl \ + --repo https://github.com/acme/platform-contracts.git \ + --ref v1.4.0 +``` + +URL Sources require HTTPS unless `--allow-insecure-http` is explicitly set. +Git Sources may select a containment path. Devctl Sources forbid `path` and +are consumed through named Exports. + +## HTTP and gRPC clients + +```sh +devctl add http-client billing \ + --source contracts \ + --path billing/openapi.yaml + +devctl add grpc-client ledger \ + --source platform \ + --export ledger-grpc +``` + +Use `--path` for local, URL, and Git Sources. Use `--export` for a Devctl +Source. A custom `--buf-gen-config` is user-owned and must exist before +validation. + +## Kafka endpoints + +```sh +devctl add kafka-consumer audit \ + --topic orders.audit.v1 \ + --format raw + +devctl add kafka-producer created \ + --topic orders.created.v1 \ + --format json \ + --source contracts \ + --path orders-created.schema.json + +devctl add kafka-consumer billing \ + --topic billing.events.v1 \ + --format proto \ + --source platform \ + --export billing-events \ + --message acme.billing.v1.Event \ + --encoding binary +``` + +`raw` has no Contract files and supports inspect/lint but not sync or code +generation. JSON Schema roots need a non-empty `title`. Proto encoding is +`binary` or `json`. + +## Targeted previews + +```sh +devctl inspect +devctl sync http --target http-client:billing --dry-run +devctl gen grpc --target grpc-client:ledger --dry-run +``` + +Copy Target IDs from `inspect`; do not derive them from output paths. diff --git a/docs/user-guide/reference/commands.md b/docs/user-guide/reference/commands.md new file mode 100644 index 0000000..dccc192 --- /dev/null +++ b/docs/user-guide/reference/commands.md @@ -0,0 +1,843 @@ +# Command reference + + + +## CLI interface - devctl + +Devctl defines, validates, and materializes reproducible Go projects from a devctl.yaml manifest. Commands are non-interactive and keep manifest mutation, synchronization, linting, scaffolding, and generation explicit. + +Manage Devctl Go projects. + +Usage: + +```bash +$ devctl [COMMAND] [COMMAND FLAGS] [ARGUMENTS...] +``` + +### `init` command + +Initialize a Devctl project. + +Create the canonical Manifest or materialize the Go project foundation declared by an existing Manifest. Initialization steps are explicit and never run one another implicitly. + +Usage: + +```bash +$ devctl [GLOBAL FLAGS] init [ARGUMENTS...] +``` + +### `init manifest` subcommand + +Create devctl.yaml. + +> devctl init manifest --lang go --preset --name --module + +Create a complete v1 Manifest from a supported preset. This command writes only the Manifest; it does not scaffold files, install tools, synchronize Contracts, lint, or generate code. + +Usage: + +```bash +$ devctl [GLOBAL FLAGS] init manifest [COMMAND FLAGS] [ARGUMENTS...] +``` + +The following flags are supported: + +| Name | Description | Type | Default value | Environment variables | +|----------------|---------------------------------------------------------------|--------|:-------------:|:---------------------:| +| `--file="…"` | use `path` as the Manifest instead of discovering devctl.yaml | string | | *none* | +| `--json` | emit compact JSONL events instead of text | bool | `false` | *none* | +| `--verbose` | include debug diagnostics and raw causes on stderr | bool | `false` | *none* | +| `--lang="…"` | set the project language; supported value: `go` | string | | *none* | +| `--preset="…"` | seed the Manifest from `cli` or `http-service` | string | | *none* | +| `--name="…"` | set the kebab-case `project-name` | string | | *none* | +| `--module="…"` | set the Go `module-path` | string | | *none* | +| `--force` | replace an existing Manifest instead of returning a conflict | bool | `false` | *none* | + +### `init scaffold` subcommand + +Create or refresh the Go project foundation. + +> devctl init scaffold [--file ] + +Publish Devctl-managed project files and create missing Scaffold Seeds. Managed Outputs may be replaced; existing user-owned Seeds are never deliberately overwritten or deleted. + +Usage: + +```bash +$ devctl [GLOBAL FLAGS] init scaffold [COMMAND FLAGS] [ARGUMENTS...] +``` + +The following flags are supported: + +| Name | Description | Type | Default value | Environment variables | +|--------------|---------------------------------------------------------------|--------|:-------------:|:---------------------:| +| `--file="…"` | use `path` as the Manifest instead of discovering devctl.yaml | string | | *none* | +| `--json` | emit compact JSONL events instead of text | bool | `false` | *none* | +| `--verbose` | include debug diagnostics and raw causes on stderr | bool | `false` | *none* | + +### `validate` command + +Validate the selected Project. + +> devctl validate [--file ] + +Check Manifest structure, semantic validity, references, safe paths, and Project Readiness. Validation findings are normal results and exit with status 1 when any issue is present. + +Usage: + +```bash +$ devctl [GLOBAL FLAGS] validate [COMMAND FLAGS] [ARGUMENTS...] +``` + +The following flags are supported: + +| Name | Description | Type | Default value | Environment variables | +|--------------|---------------------------------------------------------------|--------|:-------------:|:---------------------:| +| `--file="…"` | use `path` as the Manifest instead of discovering devctl.yaml | string | | *none* | +| `--json` | emit compact JSONL events instead of text | bool | `false` | *none* | +| `--verbose` | include debug diagnostics and raw causes on stderr | bool | `false` | *none* | + +### `inspect` command + +Inspect effective Project configuration. + +> devctl inspect [--file ] + +Show the selected Project root, effective paths, Target Catalog, Runtime Config, Resources, and resolved Contract inputs without requiring every external Snapshot to be ready. + +Usage: + +```bash +$ devctl [GLOBAL FLAGS] inspect [COMMAND FLAGS] [ARGUMENTS...] +``` + +The following flags are supported: + +| Name | Description | Type | Default value | Environment variables | +|--------------|---------------------------------------------------------------|--------|:-------------:|:---------------------:| +| `--file="…"` | use `path` as the Manifest instead of discovering devctl.yaml | string | | *none* | +| `--json` | emit compact JSONL events instead of text | bool | `false` | *none* | +| `--verbose` | include debug diagnostics and raw causes on stderr | bool | `false` | *none* | + +### `enable` command + +Enable a project capability. + +Add or update one supported Capability in the Manifest. This command changes only devctl.yaml and does not refresh scaffold files or generated code. + +Usage: + +```bash +$ devctl [GLOBAL FLAGS] enable [ARGUMENTS...] +``` + +### `enable http` subcommand + +Enable http. + +> devctl enable http [--always] [--force] + +Add the http Capability and its canonical defaults to the Manifest. Run init scaffold and gen explicitly when the resulting Project files need to be refreshed. + +Usage: + +```bash +$ devctl [GLOBAL FLAGS] enable http [COMMAND FLAGS] [ARGUMENTS...] +``` + +The following flags are supported: + +| Name | Description | Type | Default value | Environment variables | +|--------------|---------------------------------------------------------------|--------|:-------------:|:---------------------:| +| `--file="…"` | use `path` as the Manifest instead of discovering devctl.yaml | string | | *none* | +| `--json` | emit compact JSONL events instead of text | bool | `false` | *none* | +| `--verbose` | include debug diagnostics and raw causes on stderr | bool | `false` | *none* | +| `--always` | omit the Runtime Start Policy so the Capability always starts | bool | `false` | *none* | +| `--force` | replace an existing Capability declaration | bool | `false` | *none* | + +### `enable grpc` subcommand + +Enable grpc. + +> devctl enable grpc [--always] [--force] + +Add the grpc Capability and its canonical defaults to the Manifest. Run init scaffold and gen explicitly when the resulting Project files need to be refreshed. + +Usage: + +```bash +$ devctl [GLOBAL FLAGS] enable grpc [COMMAND FLAGS] [ARGUMENTS...] +``` + +The following flags are supported: + +| Name | Description | Type | Default value | Environment variables | +|--------------|---------------------------------------------------------------|--------|:-------------:|:---------------------:| +| `--file="…"` | use `path` as the Manifest instead of discovering devctl.yaml | string | | *none* | +| `--json` | emit compact JSONL events instead of text | bool | `false` | *none* | +| `--verbose` | include debug diagnostics and raw causes on stderr | bool | `false` | *none* | +| `--always` | omit the Runtime Start Policy so the Capability always starts | bool | `false` | *none* | +| `--force` | replace an existing Capability declaration | bool | `false` | *none* | + +### `enable logging` subcommand + +Enable logging. + +> devctl enable logging [--always] [--force] + +Add the logging Capability and its canonical defaults to the Manifest. Run init scaffold and gen explicitly when the resulting Project files need to be refreshed. + +Usage: + +```bash +$ devctl [GLOBAL FLAGS] enable logging [COMMAND FLAGS] [ARGUMENTS...] +``` + +The following flags are supported: + +| Name | Description | Type | Default value | Environment variables | +|--------------|---------------------------------------------------------------|--------|:-------------:|:---------------------:| +| `--file="…"` | use `path` as the Manifest instead of discovering devctl.yaml | string | | *none* | +| `--json` | emit compact JSONL events instead of text | bool | `false` | *none* | +| `--verbose` | include debug diagnostics and raw causes on stderr | bool | `false` | *none* | +| `--always` | omit the Runtime Start Policy so the Capability always starts | bool | `false` | *none* | +| `--force` | replace an existing Capability declaration | bool | `false` | *none* | + +### `enable health` subcommand + +Enable health. + +> devctl enable health [--always] [--force] + +Add the health Capability and its canonical defaults to the Manifest. Run init scaffold and gen explicitly when the resulting Project files need to be refreshed. + +Usage: + +```bash +$ devctl [GLOBAL FLAGS] enable health [COMMAND FLAGS] [ARGUMENTS...] +``` + +The following flags are supported: + +| Name | Description | Type | Default value | Environment variables | +|--------------|---------------------------------------------------------------|--------|:-------------:|:---------------------:| +| `--file="…"` | use `path` as the Manifest instead of discovering devctl.yaml | string | | *none* | +| `--json` | emit compact JSONL events instead of text | bool | `false` | *none* | +| `--verbose` | include debug diagnostics and raw causes on stderr | bool | `false` | *none* | +| `--always` | omit the Runtime Start Policy so the Capability always starts | bool | `false` | *none* | +| `--force` | replace an existing Capability declaration | bool | `false` | *none* | + +### `enable telemetry` subcommand + +Enable telemetry. + +> devctl enable telemetry [--always] [--force] + +Add the telemetry Capability and its canonical defaults to the Manifest. Run init scaffold and gen explicitly when the resulting Project files need to be refreshed. + +Usage: + +```bash +$ devctl [GLOBAL FLAGS] enable telemetry [COMMAND FLAGS] [ARGUMENTS...] +``` + +The following flags are supported: + +| Name | Description | Type | Default value | Environment variables | +|--------------|---------------------------------------------------------------|--------|:-------------:|:---------------------:| +| `--file="…"` | use `path` as the Manifest instead of discovering devctl.yaml | string | | *none* | +| `--json` | emit compact JSONL events instead of text | bool | `false` | *none* | +| `--verbose` | include debug diagnostics and raw causes on stderr | bool | `false` | *none* | +| `--always` | omit the Runtime Start Policy so the Capability always starts | bool | `false` | *none* | +| `--force` | replace an existing Capability declaration | bool | `false` | *none* | + +### `enable pprof` subcommand + +Enable pprof. + +> devctl enable pprof [--always] [--force] + +Add the pprof Capability and its canonical defaults to the Manifest. Run init scaffold and gen explicitly when the resulting Project files need to be refreshed. + +Usage: + +```bash +$ devctl [GLOBAL FLAGS] enable pprof [COMMAND FLAGS] [ARGUMENTS...] +``` + +The following flags are supported: + +| Name | Description | Type | Default value | Environment variables | +|--------------|---------------------------------------------------------------|--------|:-------------:|:---------------------:| +| `--file="…"` | use `path` as the Manifest instead of discovering devctl.yaml | string | | *none* | +| `--json` | emit compact JSONL events instead of text | bool | `false` | *none* | +| `--verbose` | include debug diagnostics and raw causes on stderr | bool | `false` | *none* | +| `--always` | omit the Runtime Start Policy so the Capability always starts | bool | `false` | *none* | +| `--force` | replace an existing Capability declaration | bool | `false` | *none* | + +### `add` command + +Add a named Project resource. + +Add or update a named Source, client, Kafka endpoint, database Variant, Redis Connection, S3 Connection, or S3 bucket in the Manifest. This command changes only devctl.yaml. + +Usage: + +```bash +$ devctl [GLOBAL FLAGS] add [ARGUMENTS...] +``` + +### `add db` subcommand + +Add a database variant. + +> devctl add db --kind + +Add a SQLite, PostgreSQL, or ClickHouse Variant to a named database Connection. A migration target is declared by default; Devctl never writes SQL or applies migrations. + +Usage: + +```bash +$ devctl [GLOBAL FLAGS] add db [COMMAND FLAGS] [ARGUMENTS...] +``` + +The following flags are supported: + +| Name | Description | Type | Default value | Environment variables | +|-------------------------|---------------------------------------------------------------|--------|:-------------:|:---------------------:| +| `--file="…"` | use `path` as the Manifest instead of discovering devctl.yaml | string | | *none* | +| `--json` | emit compact JSONL events instead of text | bool | `false` | *none* | +| `--verbose` | include debug diagnostics and raw causes on stderr | bool | `false` | *none* | +| `--kind="…"` | select `sqlite`, `postgres`, or `clickhouse` | string | | *none* | +| `--default` | make this Variant the Connection default | bool | `false` | *none* | +| `--force` | replace an existing Variant with the same identity | bool | `false` | *none* | +| `--no-migrations` | do not declare a migration target for this Variant | bool | `false` | *none* | +| `--migrations-path="…"` | override the project-relative migration `path` | string | | *none* | + +### `add source` subcommand + +Add a contract source. + +> devctl add source --type [type-specific flags] + +Declare a bounded origin for Contracts. Type-specific flags select a local directory, URL closure, Git checkout, or another Devctl Project. + +Usage: + +```bash +$ devctl [GLOBAL FLAGS] add source [COMMAND FLAGS] [ARGUMENTS...] +``` + +The following flags are supported: + +| Name | Description | Type | Default value | Environment variables | +|-------------------------|---------------------------------------------------------------|--------|:-------------:|:---------------------:| +| `--file="…"` | use `path` as the Manifest instead of discovering devctl.yaml | string | | *none* | +| `--json` | emit compact JSONL events instead of text | bool | `false` | *none* | +| `--verbose` | include debug diagnostics and raw causes on stderr | bool | `false` | *none* | +| `--type="…"` | select `local`, `url`, `git`, or `devctl` | string | | *none* | +| `--path="…"` | set the project-relative local or Git containment `path` | string | | *none* | +| `--url="…"` | set the base `URL` for a URL Source | string | | *none* | +| `--filename="…"` | store a single URL document under `filename` | string | | *none* | +| `--allow-insecure-http` | allow an http URL instead of requiring https | bool | `false` | *none* | +| `--repo="…"` | set the Git or Devctl repository `location` | string | | *none* | +| `--ref="…"` | select the immutable or reviewable repository `ref` | string | | *none* | +| `--buf-config="…"` | select the Source-relative supplier `buf-config` | string | | *none* | +| `--force` | replace an existing Source with the same name | bool | `false` | *none* | + +### `add http-client` subcommand + +Add an HTTP client. + +> devctl add http-client --source (--path | --export ) + +Declare a named OpenAPI client Target. Use --path with ordinary Sources or --export with a Devctl Source. + +Usage: + +```bash +$ devctl [GLOBAL FLAGS] add http-client [COMMAND FLAGS] [ARGUMENTS...] +``` + +The following flags are supported: + +| Name | Description | Type | Default value | Environment variables | +|----------------------|---------------------------------------------------------------|--------|:-------------:|:---------------------:| +| `--file="…"` | use `path` as the Manifest instead of discovering devctl.yaml | string | | *none* | +| `--json` | emit compact JSONL events instead of text | bool | `false` | *none* | +| `--verbose` | include debug diagnostics and raw causes on stderr | bool | `false` | *none* | +| `--source="…"` | select the named contract `source` | string | | *none* | +| `--export="…"` | select a named Export from a Devctl Source | string | | *none* | +| `--path="…"` | select an OpenAPI Entrypoint from a non-Devctl Source | string | | *none* | +| `--base-url-env="…"` | override the generated runtime base URL environment `key` | string | | *none* | +| `--force` | replace an existing HTTP client with the same name | bool | `false` | *none* | + +### `add grpc-client` subcommand + +Add a gRPC client. + +> devctl add grpc-client --source (--path | --export ) + +Declare a named Proto client Target. Use --path with ordinary Sources or --export with a Devctl Source; custom generator configs remain user-owned. + +Usage: + +```bash +$ devctl [GLOBAL FLAGS] add grpc-client [COMMAND FLAGS] [ARGUMENTS...] +``` + +The following flags are supported: + +| Name | Description | Type | Default value | Environment variables | +|------------------------|---------------------------------------------------------------|--------|:-------------:|:---------------------:| +| `--file="…"` | use `path` as the Manifest instead of discovering devctl.yaml | string | | *none* | +| `--json` | emit compact JSONL events instead of text | bool | `false` | *none* | +| `--verbose` | include debug diagnostics and raw causes on stderr | bool | `false` | *none* | +| `--source="…"` | select the named contract `source` | string | | *none* | +| `--export="…"` | select a named Export from a Devctl Source | string | | *none* | +| `--path="…"` | select the Contract path from a non-Devctl Source | string | | *none* | +| `--proto-root="…"` | set the Source-relative Proto `module-root` | string | | *none* | +| `--buf-gen-config="…"` | use the project-owned generator `config-path` | string | | *none* | +| `--addr-env="…"` | override the generated runtime address environment `key` | string | | *none* | +| `--force` | replace an existing gRPC client with the same name | bool | `false` | *none* | + +### `add kafka-consumer` subcommand + +Add a Kafka endpoint. + +> devctl add kafka-consumer --topic --format [contract flags] + +Declare a named Kafka endpoint and its raw, JSON Schema, or Proto Contract. Schema-backed endpoints use --path for ordinary Sources or --export for Devctl Sources. + +Usage: + +```bash +$ devctl [GLOBAL FLAGS] add kafka-consumer [COMMAND FLAGS] [ARGUMENTS...] +``` + +The following flags are supported: + +| Name | Description | Type | Default value | Environment variables | +|--------------------|-----------------------------------------------------------------|--------|:-------------:|:---------------------:| +| `--file="…"` | use `path` as the Manifest instead of discovering devctl.yaml | string | | *none* | +| `--json` | emit compact JSONL events instead of text | bool | `false` | *none* | +| `--verbose` | include debug diagnostics and raw causes on stderr | bool | `false` | *none* | +| `--topic="…"` | set the Kafka `topic` | string | | *none* | +| `--source="…"` | select the named contract `source` | string | | *none* | +| `--export="…"` | select a named Export from a Devctl Source | string | | *none* | +| `--path="…"` | select the schema Entrypoint from a non-Devctl Source | string | | *none* | +| `--format="…"` | select `raw`, `json`, or `proto` | string | | *none* | +| `--proto-root="…"` | set the Source-relative Proto `module-root` | string | | *none* | +| `--message="…"` | select the fully-qualified Proto `message` | string | | *none* | +| `--encoding="…"` | select Proto `binary` or `json` encoding | string | | *none* | +| `--group-env="…"` | override the consumer group environment `key` | string | | *none* | +| `--always` | omit the Runtime Start Policy so the consumer is always enabled | bool | `false` | *none* | +| `--force` | replace an existing Kafka endpoint with the same name | bool | `false` | *none* | + +### `add kafka-producer` subcommand + +Add a Kafka endpoint. + +> devctl add kafka-producer --topic --format [contract flags] + +Declare a named Kafka endpoint and its raw, JSON Schema, or Proto Contract. Schema-backed endpoints use --path for ordinary Sources or --export for Devctl Sources. + +Usage: + +```bash +$ devctl [GLOBAL FLAGS] add kafka-producer [COMMAND FLAGS] [ARGUMENTS...] +``` + +The following flags are supported: + +| Name | Description | Type | Default value | Environment variables | +|--------------------|---------------------------------------------------------------|--------|:-------------:|:---------------------:| +| `--file="…"` | use `path` as the Manifest instead of discovering devctl.yaml | string | | *none* | +| `--json` | emit compact JSONL events instead of text | bool | `false` | *none* | +| `--verbose` | include debug diagnostics and raw causes on stderr | bool | `false` | *none* | +| `--topic="…"` | set the Kafka `topic` | string | | *none* | +| `--source="…"` | select the named contract `source` | string | | *none* | +| `--export="…"` | select a named Export from a Devctl Source | string | | *none* | +| `--path="…"` | select the schema Entrypoint from a non-Devctl Source | string | | *none* | +| `--format="…"` | select `raw`, `json`, or `proto` | string | | *none* | +| `--proto-root="…"` | set the Source-relative Proto `module-root` | string | | *none* | +| `--message="…"` | select the fully-qualified Proto `message` | string | | *none* | +| `--encoding="…"` | select Proto `binary` or `json` encoding | string | | *none* | +| `--topic-env="…"` | override the producer topic environment `key` | string | | *none* | +| `--force` | replace an existing Kafka endpoint with the same name | bool | `false` | *none* | + +### `add redis` subcommand + +Add a redis resource. + +> devctl add redis [options] + +Declare a named Redis Connection with an environment-backed address and a local default. + +Usage: + +```bash +$ devctl [GLOBAL FLAGS] add redis [COMMAND FLAGS] [ARGUMENTS...] +``` + +The following flags are supported: + +| Name | Description | Type | Default value | Environment variables | +|----------------------|---------------------------------------------------------------|--------|:-------------:|:---------------------:| +| `--file="…"` | use `path` as the Manifest instead of discovering devctl.yaml | string | | *none* | +| `--json` | emit compact JSONL events instead of text | bool | `false` | *none* | +| `--verbose` | include debug diagnostics and raw causes on stderr | bool | `false` | *none* | +| `--addr-env="…"` | override the generated Redis address environment `key` | string | | *none* | +| `--addr-default="…"` | override the local Redis `address` default | string | | *none* | +| `--force` | replace an existing Redis Connection with the same name | bool | `false` | *none* | + +### `add s3-connection` subcommand + +Add a s3-connection resource. + +> devctl add s3-connection [options] + +Declare a named S3 Connection and choose ambient or static credentials. + +Usage: + +```bash +$ devctl [GLOBAL FLAGS] add s3-connection [COMMAND FLAGS] [ARGUMENTS...] +``` + +The following flags are supported: + +| Name | Description | Type | Default value | Environment variables | +|---------------------|---------------------------------------------------------------|--------|:-------------:|:---------------------:| +| `--file="…"` | use `path` as the Manifest instead of discovering devctl.yaml | string | | *none* | +| `--json` | emit compact JSONL events instead of text | bool | `false` | *none* | +| `--verbose` | include debug diagnostics and raw causes on stderr | bool | `false` | *none* | +| `--credentials="…"` | select `ambient` or `static` credentials | string | | *none* | +| `--force` | replace an existing S3 Connection with the same name | bool | `false` | *none* | + +### `add s3` subcommand + +Add a s3 resource. + +> devctl add s3 [options] + +Declare a named S3 bucket attached to an existing Connection, or create the canonical local Connection when omitted. + +Usage: + +```bash +$ devctl [GLOBAL FLAGS] add s3 [COMMAND FLAGS] [ARGUMENTS...] +``` + +The following flags are supported: + +| Name | Description | Type | Default value | Environment variables | +|--------------------|---------------------------------------------------------------|--------|:-------------:|:---------------------:| +| `--file="…"` | use `path` as the Manifest instead of discovering devctl.yaml | string | | *none* | +| `--json` | emit compact JSONL events instead of text | bool | `false` | *none* | +| `--verbose` | include debug diagnostics and raw causes on stderr | bool | `false` | *none* | +| `--connection="…"` | attach the bucket to the named S3 `connection` | string | | *none* | +| `--force` | replace an existing S3 bucket with the same name | bool | `false` | *none* | + +### `sync` command + +Synchronize external Contracts. + +> devctl sync [--target ] [--dry-run] + +Materialize every supported external Contract Snapshot into Project-owned paths. Full synchronization may prune stale Target directories; use --dry-run to preview changes. + +Usage: + +```bash +$ devctl [GLOBAL FLAGS] sync [COMMAND FLAGS] [ARGUMENTS...] +``` + +The following flags are supported: + +| Name | Description | Type | Default value | Environment variables | +|----------------|------------------------------------------------------------------|--------|:-------------:|:---------------------:| +| `--file="…"` | use `path` as the Manifest instead of discovering devctl.yaml | string | | *none* | +| `--json` | emit compact JSONL events instead of text | bool | `false` | *none* | +| `--verbose` | include debug diagnostics and raw causes on stderr | bool | `false` | *none* | +| `--target="…"` | select one exact Target `id`, such as http-client:billing | string | | *none* | +| `--dry-run` | preview publication and pruning without network access or writes | bool | `false` | *none* | + +### `sync http` subcommand + +Synchronize external Contracts. + +> devctl sync http [--target ] [--dry-run] + +Materialize external http Contract Snapshots. Family synchronization may prune stale Target directories; an explicit --target never prunes sibling Targets. + +Usage: + +```bash +$ devctl [GLOBAL FLAGS] sync http [COMMAND FLAGS] [ARGUMENTS...] +``` + +The following flags are supported: + +| Name | Description | Type | Default value | Environment variables | +|----------------|------------------------------------------------------------------|--------|:-------------:|:---------------------:| +| `--file="…"` | use `path` as the Manifest instead of discovering devctl.yaml | string | | *none* | +| `--json` | emit compact JSONL events instead of text | bool | `false` | *none* | +| `--verbose` | include debug diagnostics and raw causes on stderr | bool | `false` | *none* | +| `--target="…"` | select one exact Target `id`, such as http-client:billing | string | | *none* | +| `--dry-run` | preview publication and pruning without network access or writes | bool | `false` | *none* | + +### `sync grpc` subcommand + +Synchronize external Contracts. + +> devctl sync grpc [--target ] [--dry-run] + +Materialize external grpc Contract Snapshots. Family synchronization may prune stale Target directories; an explicit --target never prunes sibling Targets. + +Usage: + +```bash +$ devctl [GLOBAL FLAGS] sync grpc [COMMAND FLAGS] [ARGUMENTS...] +``` + +The following flags are supported: + +| Name | Description | Type | Default value | Environment variables | +|----------------|------------------------------------------------------------------|--------|:-------------:|:---------------------:| +| `--file="…"` | use `path` as the Manifest instead of discovering devctl.yaml | string | | *none* | +| `--json` | emit compact JSONL events instead of text | bool | `false` | *none* | +| `--verbose` | include debug diagnostics and raw causes on stderr | bool | `false` | *none* | +| `--target="…"` | select one exact Target `id`, such as http-client:billing | string | | *none* | +| `--dry-run` | preview publication and pruning without network access or writes | bool | `false` | *none* | + +### `sync kafka` subcommand + +Synchronize external Contracts. + +> devctl sync kafka [--target ] [--dry-run] + +Materialize external kafka Contract Snapshots. Family synchronization may prune stale Target directories; an explicit --target never prunes sibling Targets. + +Usage: + +```bash +$ devctl [GLOBAL FLAGS] sync kafka [COMMAND FLAGS] [ARGUMENTS...] +``` + +The following flags are supported: + +| Name | Description | Type | Default value | Environment variables | +|----------------|------------------------------------------------------------------|--------|:-------------:|:---------------------:| +| `--file="…"` | use `path` as the Manifest instead of discovering devctl.yaml | string | | *none* | +| `--json` | emit compact JSONL events instead of text | bool | `false` | *none* | +| `--verbose` | include debug diagnostics and raw causes on stderr | bool | `false` | *none* | +| `--target="…"` | select one exact Target `id`, such as http-client:billing | string | | *none* | +| `--dry-run` | preview publication and pruning without network access or writes | bool | `false` | *none* | + +### `gen` command + +Generate Managed Outputs. + +> devctl gen [--target ] [--dry-run] + +Run the Project-owned generators for every supported Target and atomically publish each Target's Managed Output. Generation never synchronizes or lints implicitly. + +Usage: + +```bash +$ devctl [GLOBAL FLAGS] gen [COMMAND FLAGS] [ARGUMENTS...] +``` + +The following flags are supported: + +| Name | Description | Type | Default value | Environment variables | +|----------------|---------------------------------------------------------------------|--------|:-------------:|:---------------------:| +| `--file="…"` | use `path` as the Manifest instead of discovering devctl.yaml | string | | *none* | +| `--json` | emit compact JSONL events instead of text | bool | `false` | *none* | +| `--verbose` | include debug diagnostics and raw causes on stderr | bool | `false` | *none* | +| `--dry-run` | preview Managed Outputs without running generators or writing files | bool | `false` | *none* | +| `--target="…"` | select one exact generation Target `id` | string | | *none* | + +### `gen config` subcommand + +Generate Managed Outputs. + +> devctl gen config [--dry-run] + +Run the Project-owned generators for config Targets and atomically publish their Managed Outputs without synchronizing or linting implicitly. + +Usage: + +```bash +$ devctl [GLOBAL FLAGS] gen config [COMMAND FLAGS] [ARGUMENTS...] +``` + +The following flags are supported: + +| Name | Description | Type | Default value | Environment variables | +|--------------|---------------------------------------------------------------------|--------|:-------------:|:---------------------:| +| `--file="…"` | use `path` as the Manifest instead of discovering devctl.yaml | string | | *none* | +| `--json` | emit compact JSONL events instead of text | bool | `false` | *none* | +| `--verbose` | include debug diagnostics and raw causes on stderr | bool | `false` | *none* | +| `--dry-run` | preview Managed Outputs without running generators or writing files | bool | `false` | *none* | + +### `gen http` subcommand + +Generate Managed Outputs. + +> devctl gen http [--target ] [--dry-run] + +Run the Project-owned generators for http Targets and atomically publish their Managed Outputs without synchronizing or linting implicitly. + +Usage: + +```bash +$ devctl [GLOBAL FLAGS] gen http [COMMAND FLAGS] [ARGUMENTS...] +``` + +The following flags are supported: + +| Name | Description | Type | Default value | Environment variables | +|----------------|---------------------------------------------------------------------|--------|:-------------:|:---------------------:| +| `--file="…"` | use `path` as the Manifest instead of discovering devctl.yaml | string | | *none* | +| `--json` | emit compact JSONL events instead of text | bool | `false` | *none* | +| `--verbose` | include debug diagnostics and raw causes on stderr | bool | `false` | *none* | +| `--dry-run` | preview Managed Outputs without running generators or writing files | bool | `false` | *none* | +| `--target="…"` | select one exact generation Target `id` | string | | *none* | + +### `gen grpc` subcommand + +Generate Managed Outputs. + +> devctl gen grpc [--target ] [--dry-run] + +Run the Project-owned generators for grpc Targets and atomically publish their Managed Outputs without synchronizing or linting implicitly. + +Usage: + +```bash +$ devctl [GLOBAL FLAGS] gen grpc [COMMAND FLAGS] [ARGUMENTS...] +``` + +The following flags are supported: + +| Name | Description | Type | Default value | Environment variables | +|----------------|---------------------------------------------------------------------|--------|:-------------:|:---------------------:| +| `--file="…"` | use `path` as the Manifest instead of discovering devctl.yaml | string | | *none* | +| `--json` | emit compact JSONL events instead of text | bool | `false` | *none* | +| `--verbose` | include debug diagnostics and raw causes on stderr | bool | `false` | *none* | +| `--dry-run` | preview Managed Outputs without running generators or writing files | bool | `false` | *none* | +| `--target="…"` | select one exact generation Target `id` | string | | *none* | + +### `gen kafka` subcommand + +Generate Managed Outputs. + +> devctl gen kafka [--target ] [--dry-run] + +Run the Project-owned generators for kafka Targets and atomically publish their Managed Outputs without synchronizing or linting implicitly. + +Usage: + +```bash +$ devctl [GLOBAL FLAGS] gen kafka [COMMAND FLAGS] [ARGUMENTS...] +``` + +The following flags are supported: + +| Name | Description | Type | Default value | Environment variables | +|----------------|---------------------------------------------------------------------|--------|:-------------:|:---------------------:| +| `--file="…"` | use `path` as the Manifest instead of discovering devctl.yaml | string | | *none* | +| `--json` | emit compact JSONL events instead of text | bool | `false` | *none* | +| `--verbose` | include debug diagnostics and raw causes on stderr | bool | `false` | *none* | +| `--dry-run` | preview Managed Outputs without running generators or writing files | bool | `false` | *none* | +| `--target="…"` | select one exact generation Target `id` | string | | *none* | + +### `lint` command + +Lint Project Contracts. + +> devctl lint [--file ] + +Lint every supported Contract using committed local inputs. Findings are normal results and exit with status 1 without becoming execution errors. + +Usage: + +```bash +$ devctl [GLOBAL FLAGS] lint [COMMAND FLAGS] [ARGUMENTS...] +``` + +The following flags are supported: + +| Name | Description | Type | Default value | Environment variables | +|--------------|---------------------------------------------------------------|--------|:-------------:|:---------------------:| +| `--file="…"` | use `path` as the Manifest instead of discovering devctl.yaml | string | | *none* | +| `--json` | emit compact JSONL events instead of text | bool | `false` | *none* | +| `--verbose` | include debug diagnostics and raw causes on stderr | bool | `false` | *none* | + +### `lint http` subcommand + +Lint Project Contracts. + +> devctl lint http [--file ] + +Lint committed http Contracts without synchronizing or generating code. Findings are normal results and exit with status 1. + +Usage: + +```bash +$ devctl [GLOBAL FLAGS] lint http [COMMAND FLAGS] [ARGUMENTS...] +``` + +The following flags are supported: + +| Name | Description | Type | Default value | Environment variables | +|--------------|---------------------------------------------------------------|--------|:-------------:|:---------------------:| +| `--file="…"` | use `path` as the Manifest instead of discovering devctl.yaml | string | | *none* | +| `--json` | emit compact JSONL events instead of text | bool | `false` | *none* | +| `--verbose` | include debug diagnostics and raw causes on stderr | bool | `false` | *none* | + +### `lint grpc` subcommand + +Lint Project Contracts. + +> devctl lint grpc [--file ] + +Lint committed grpc Contracts without synchronizing or generating code. Findings are normal results and exit with status 1. + +Usage: + +```bash +$ devctl [GLOBAL FLAGS] lint grpc [COMMAND FLAGS] [ARGUMENTS...] +``` + +The following flags are supported: + +| Name | Description | Type | Default value | Environment variables | +|--------------|---------------------------------------------------------------|--------|:-------------:|:---------------------:| +| `--file="…"` | use `path` as the Manifest instead of discovering devctl.yaml | string | | *none* | +| `--json` | emit compact JSONL events instead of text | bool | `false` | *none* | +| `--verbose` | include debug diagnostics and raw causes on stderr | bool | `false` | *none* | + +### `lint kafka` subcommand + +Lint Project Contracts. + +> devctl lint kafka [--file ] + +Lint committed kafka Contracts without synchronizing or generating code. Findings are normal results and exit with status 1. + +Usage: + +```bash +$ devctl [GLOBAL FLAGS] lint kafka [COMMAND FLAGS] [ARGUMENTS...] +``` + +The following flags are supported: + +| Name | Description | Type | Default value | Environment variables | +|--------------|---------------------------------------------------------------|--------|:-------------:|:---------------------:| +| `--file="…"` | use `path` as the Manifest instead of discovering devctl.yaml | string | | *none* | +| `--json` | emit compact JSONL events instead of text | bool | `false` | *none* | +| `--verbose` | include debug diagnostics and raw causes on stderr | bool | `false` | *none* | diff --git a/docs/user-guide/reference/manifest/README.md b/docs/user-guide/reference/manifest/README.md new file mode 100644 index 0000000..e8f5e19 --- /dev/null +++ b/docs/user-guide/reference/manifest/README.md @@ -0,0 +1,51 @@ +# Manifest v1 reference + +`devctl.yaml` is the canonical desired-state document for a Project. The root +must be a YAML mapping with these fields: + +```yaml +version: 1 +project: {} +env: {} +paths: {} +sources: {} +exports: {} +components: {} +languages: + go: {} +``` + +| Field | Required | Description | +|---|:---:|---| +| `version` | yes | Manifest format; only `1` is supported | +| `project` | yes | Project identity | +| `env` | yes | Global Runtime Config declarations | +| `paths` | yes | Project-owned path overrides | +| `sources` | yes | Named Contract Sources | +| `exports` | yes | Named surfaces supplied to other Projects | +| `components` | yes | Runtime Components, Capabilities, and Resources | +| `languages.go` | yes | Go module and generator policy | + +Unknown and duplicate fields are rejected. YAML scalar, sequence, and mapping +types are checked before semantic validation. Required values, references, +safe relative paths, conflicts, and Project Readiness are checked separately by +`devctl validate`. + +Continue with: + +- [Project, environment, paths, and Go tooling](project-and-language.md) +- [Sources and Exports](sources-and-exports.md) +- [Components and Resources](components.md) + +## Path rules + +Manifest paths are project-relative and must remain contained by the Project. +Absolute paths, traversal through `..`, symlinks at managed boundaries, and +non-regular files where files are required are rejected. Managed paths must not +overlap in ways that let one workflow replace another workflow's output. + +## Defaults + +Omitted optional fields decode to zero values. Effective defaults are applied +by the Project model and may therefore appear in `inspect` even when absent +from YAML. This reference lists effective defaults next to the owning field. diff --git a/docs/user-guide/reference/manifest/components.md b/docs/user-guide/reference/manifest/components.md new file mode 100644 index 0000000..7854118 --- /dev/null +++ b/docs/user-guide/reference/manifest/components.md @@ -0,0 +1,252 @@ +# Components and Resources + +## Shared environment and start policy + +Components may declare system and custom Runtime Config entries: + +```yaml +env: + system: + - key: HTTP_ADDR + type: string + default: :8080 + custom: + - key: HEADER_LIMIT + type: int + default: 50 +``` + +Both lists use `key`, optional `type`, optional `default`, and optional +`secret`. See [environment reference](project-and-language.md#env). + +Runnable Capabilities use this shape: + +```yaml +start: + env: HTTP_SERVER_ENABLED + default: true +``` + +`start.env` is required when `start` exists. An absent `start` means always on. +When `start` exists and `default` is omitted, the effective default is `false`. + +## HTTP + +```yaml +components: + http: + server: + openapi: api/openapi/swagger.yaml + start: {env: HTTP_SERVER_ENABLED, default: true} + clients: + - name: billing + source: contracts + path: billing/openapi.yaml + base_url_env: BILLING_BASE_URL + oapi_config: tools/oapi/clients.billing.yaml + env: + system: + - {key: HTTP_ADDR, type: string, default: ":8080"} +``` + +| Field | Required | Description/default | +|---|:---:|---| +| `server.openapi` | no | Server Entrypoint; `api/openapi/swagger.yaml` | +| `server.start` | no | HTTP Runtime Start Policy | +| `clients[].name` | yes | Unique client name | +| `clients[].source` | yes | Existing Source | +| `clients[].path` | conditional | Entrypoint for non-Devctl Sources | +| `clients[].export` | conditional | Named Export for a Devctl Source | +| `clients[].base_url_env` | no | Runtime base URL key | +| `clients[].oapi_config` | no | Per-client generator config | +| `env` | no | Component Runtime Config | + +`path` and `export` are alternatives selected by Source type. + +## gRPC + +```yaml +components: + grpc: + server: + proto_root: api/proto/grpc + buf_config: buf.yaml + start: {env: GRPC_SERVER_ENABLED, default: true} + clients: + - name: ledger + source: shared + export: ledger-grpc + proto_root: api/proto/ledger + buf_gen_config: tools/buf/ledger.gen.yaml + addr_env: LEDGER_GRPC_ADDR + env: {} +``` + +| Field | Required | Description/default | +|---|:---:|---| +| `server.proto_root` | no | Proto Module Root; `api/proto/grpc` | +| `server.buf_config` | no | Supplier Buf config; `buf.yaml` | +| `server.start` | no | gRPC Runtime Start Policy | +| `clients[].name` | yes | Unique client name | +| `clients[].source` | yes | Existing Source | +| `clients[].path` | conditional | Contract path for non-Devctl Sources | +| `clients[].export` | conditional | Named Export for a Devctl Source | +| `clients[].proto_root` | no | Module Root containing the selected path | +| `clients[].buf_gen_config` | no | Consumer generator config | +| `clients[].addr_env` | no | Runtime server address key | +| `env` | no | Component Runtime Config | + +Supplier `buf_config`/`buf.lock` and consumer `buf_gen_config` have different +owners. An explicit consumer config is a user-owned file. + +## Kafka + +```yaml +components: + kafka: + consumers: + - name: audit + topic: orders.audit.v1 + group_env: KAFKA_AUDIT_GROUP + start: {env: KAFKA_AUDIT_CONSUMER_ENABLED, default: false} + contract: {format: raw} + producers: + - name: created + topic: orders.created.v1 + topic_env: KAFKA_CREATED_TOPIC + contract: + source: contracts + path: orders-created.schema.json + format: json + env: {} +``` + +Consumers require `name` and `topic`; `group_env` and `start` are optional. +Producers require `name` and `topic`; `topic_env` is optional. Both use the +same `contract` shape: + +| Field | Required | Description | +|---|:---:|---| +| `format` | yes | `raw`, `json`, or `proto` | +| `source` | schema-backed | Existing Source | +| `path` | conditional | Entrypoint for non-Devctl Sources | +| `export` | conditional | Kafka Export for a Devctl Source | +| `proto_root` | Proto only | Module Root containing `path` | +| `message` | Proto only | Fully-qualified message name | +| `encoding` | Proto only | `binary` or `json` | + +Raw endpoints have no Source, path, Export, or Proto fields. JSON Schema roots +need a non-empty `title`, which owns the generated Go type name. Kafka Runtime +Config also derives brokers, batching, retry, rebalance, drain, and shutdown +settings from each effective endpoint. + +## Databases + +```yaml +components: + db: + connections: + - name: primary + default: postgres + kind_env: DB_PRIMARY_KIND + variants: + - name: postgres + kind: postgres + dsn_env: DB_PRIMARY_POSTGRES_DSN + secret: true + migrations: + path: migrations/primary/postgres + database_env: DB_PRIMARY_POSTGRES_MIGRATIONS_URL + env: {} +``` + +| Field | Required | Description | +|---|:---:|---| +| `connections[].name` | yes | Unique logical Connection name | +| `connections[].default` | multiple Variants | Name of the default Variant | +| `connections[].kind_env` | no | Runtime Variant selector key | +| `connections[].variants` | yes | One or more Variants | +| `variants[].name` | yes | Unique name within the Connection | +| `variants[].kind` | yes | `sqlite`, `postgres`, or `clickhouse` | +| `variants[].dsn_env` | no | Runtime DSN key | +| `variants[].dsn_default` | no | Runtime DSN default | +| `variants[].secret` | no | Redacts/suppresses the DSN default | +| `variants[].migrations` | no | Migration target | +| `migrations.path` | yes | Project-relative migration directory | +| `migrations.database_env` | yes | Migration-only database URL key | +| `migrations.database_default` | no | Migration URL default | +| `env` | no | Component Runtime Config | + +A single-Variant Connection may omit `default`; multiple Variants require an +existing default. Migration URL schemes must match the database kind. +ClickHouse cannot share a Connection with transactional Variants. + +## Redis + +```yaml +components: + redis: + connections: + - name: cache + addr_env: REDIS_CACHE_ADDR + addr_default: localhost:6379 + env: {} +``` + +Connections require `name`; `addr_env` and `addr_default` are optional. The +`add redis` defaults are `REDIS__ADDR` and `localhost:6379`. Credential- +bearing Redis URLs are rejected. + +## S3 + +```yaml +components: + s3: + connections: + - name: assets + credentials: static + endpoint: http://localhost:9000 + region: us-east-1 + path_style: true + access_key_env: S3_ASSETS_ACCESS_KEY + secret_key_env: S3_ASSETS_SECRET_KEY + buckets: + - name: uploads + connection: assets + bucket: uploads + env: {} +``` + +| Field | Required | Description | +|---|:---:|---| +| `connections[].name` | yes | Unique Connection name | +| `connections[].credentials` | no | `ambient` or `static` | +| `connections[].endpoint` | no | Custom service endpoint | +| `connections[].region` | no | AWS region | +| `connections[].path_style` | no | Use path-style bucket addressing | +| `connections[].access_key_env` | static | Access key environment name | +| `connections[].secret_key_env` | static | Secret key environment name | +| `buckets[].name` | yes | Unique logical bucket name | +| `buckets[].connection` | yes | Existing S3 Connection | +| `buckets[].bucket` | no | Physical bucket name | +| `env` | no | Component Runtime Config | + +## Logging, health, and telemetry + +```yaml +components: + logging: + env: {} + health: + server: + start: {env: HEALTH_SERVER_ENABLED, default: true} + env: {} + telemetry: + start: {env: TELEMETRY_ENABLED, default: false} + env: {} +``` + +`logging` contains only `env`. `health.server.start` and `telemetry.start` use +the shared Runtime Start Policy. The HTTP service preset also supplies +`HEALTH_ADDR=:8081`; telemetry uses the ecosystem-standard `OTEL_*` keys in +the effective Runtime Config catalog. diff --git a/docs/user-guide/reference/manifest/project-and-language.md b/docs/user-guide/reference/manifest/project-and-language.md new file mode 100644 index 0000000..0145df2 --- /dev/null +++ b/docs/user-guide/reference/manifest/project-and-language.md @@ -0,0 +1,115 @@ +# Project, environment, paths, and Go tooling + +## `project` + +```yaml +project: + name: orders-api + language: go +``` + +| Field | Required | Values | +|---|:---:|---| +| `project.name` | yes | Kebab-case Project name | +| `project.language` | yes | `go` | + +The name owns the default Runtime Config prefix. `orders-api` becomes +`ORDERS_API_`. + +## `env` + +```yaml +env: + prefix: ORDERS_ + custom: + - group: Payments + vars: + - key: PAYMENTS_TIMEOUT + type: duration + default: 5s + - key: PAYMENTS_TOKEN + type: string + secret: true +``` + +| Field | Required | Description | +|---|:---:|---| +| `env.prefix` | no | Overrides the Project-derived prefix | +| `env.custom[].group` | yes | Semantic group used in generated Go fields | +| `env.custom[].vars` | yes | Environment variables in the group | +| `vars[].key` | yes | Environment key before effective prefixing | +| `vars[].type` | no | `string` (default), `bool`, `int`, or `duration` | +| `vars[].default` | no | Typed runtime default | +| `vars[].secret` | no | Marks a secret and suppresses rendered defaults | + +Component `env.system` and `env.custom` entries use the same variable shape, +without the outer `group` field. Conflicting effective keys or generated Go +field paths produce `runtime_config_conflict`. + +Keys beginning with `OTEL_` retain that ecosystem prefix instead of receiving +the Project prefix. Secret values never carry defaults in generated config, +`.env.example`, or `inspect`. + +## `paths` + +```yaml +paths: + external_contracts: api/external +``` + +`paths.external_contracts` is optional and defaults to `api/external`. The +complete subtree belongs to `sync` and contains one child tree per external +Target. + +## `languages.go` + +```yaml +languages: + go: + module: example.com/orders-api + generators: + config: + out: gen/config/config.gen.go + http: + oapi_config: tools/oapi/server.yaml + server_out: gen/serverhttp + client_out: gen/clienthttp + grpc: + out: gen/grpc + buf_gen_config: tools/buf/grpc.gen.yaml + kafka: + out: gen/kafka + buf_gen_config: tools/buf/kafka.gen.yaml + components: + pprof: + server: + start: + env: PPROF_ENABLED + default: false + env: + system: + - key: PPROF_ADDR + type: string + default: 127.0.0.1:6060 +``` + +| Field | Required | Effective default | +|---|:---:|---| +| `languages.go.module` | yes | none | +| `generators.config.out` | no | `gen/config/config.gen.go` | +| `generators.http.oapi_config` | no | `tools/oapi/server.yaml` | +| `generators.http.server_out` | no | `gen/serverhttp` | +| `generators.http.client_out` | no | `gen/clienthttp` | +| `generators.grpc.out` | no | `gen/grpc` | +| `generators.grpc.buf_gen_config` | no | `tools/buf/grpc.gen.yaml` | +| `generators.kafka.out` | no | `gen/kafka` | +| `generators.kafka.buf_gen_config` | no | `tools/buf/kafka.gen.yaml` | + +Every Go Project has an implicit config Target even if `generators.config` is +omitted. Canonical native generator configs are Managed Outputs. An explicitly +selected alternate config is user-owned, must already exist, and is never +created or replaced by scaffold. + +`languages.go.components.pprof` uses the same `start` and component `env` +shapes described in [Components](components.md). The HTTP service preset uses +`127.0.0.1:6060` and a disabled-by-default `PPROF_ENABLED` toggle. diff --git a/docs/user-guide/reference/manifest/sources-and-exports.md b/docs/user-guide/reference/manifest/sources-and-exports.md new file mode 100644 index 0000000..975a12f --- /dev/null +++ b/docs/user-guide/reference/manifest/sources-and-exports.md @@ -0,0 +1,70 @@ +# Sources and Exports + +## `sources` + +Sources are named map entries: + +```yaml +sources: + contracts: + type: local + path: api/contracts +``` + +Every Source has `type`. Other fields are type-specific: + +| Type | Required | Optional | Forbidden or ignored | +|---|---|---|---| +| `local` | `path` | `proto.buf_config` | URL and repository fields | +| `url` | `url` | `filename`, `allow_insecure_http` | `path`, repository fields | +| `git` | `repo`, `ref` | `path`, `proto.buf_config` | URL fields | +| `devctl` | `repo`, `ref` | none | `path`, URL fields; consumers use `export` | + +### Source fields + +| Field | Description | +|---|---| +| `type` | `local`, `url`, `git`, or `devctl` | +| `path` | Relative containment root for local or Git content | +| `url` | Initial fetch URL for a URL closure | +| `filename` | Virtual committed filename for a single URL document | +| `allow_insecure_http` | Allows `http`; HTTPS is required by default | +| `repo` | Git clone location or upstream Devctl checkout | +| `ref` | Reviewable repository revision | +| `proto.buf_config` | Source-relative supplier Buf config | + +Credential-bearing URLs are rejected. URL references may fetch only relative +same-origin documents and are limited to 64 documents, 64 MiB total, and 32 +MiB per response. Query participates in fetch identity but is redacted from +diagnostics; fragments do not participate. Absolute references are ignored. + +For Proto Snapshots, the effective supplier Buf config and adjacent `buf.lock` +travel with the Contract. They are separate from consumer-owned `*.gen.yaml`. + +## `exports` + +Exports publish exact Project surfaces to a downstream Devctl Source: + +```yaml +exports: + public-api: + kind: openapi + path: api/openapi/swagger.yaml + billing-grpc: + kind: grpc + path: api/proto/grpc + order-events: + kind: kafka + producer: orders +``` + +| Kind | Required | Meaning | +|---|---|---| +| `openapi` | `path` | Must equal the effective HTTP server Entrypoint | +| `grpc` | `path` | Must equal the effective gRPC server Module Root | +| `kafka` | `producer` | Names an existing producer and inherits topic/format | + +`producer` is forbidden for OpenAPI/gRPC. `path` is forbidden for Kafka. +Local Project validation checks every Export. When a downstream Project syncs +one Export from a Devctl Source, only that selected upstream surface must be +materializable; unrelated upstream Readiness is not required. diff --git a/docs/user-guide/reference/output-and-errors.md b/docs/user-guide/reference/output-and-errors.md new file mode 100644 index 0000000..cb415e8 --- /dev/null +++ b/docs/user-guide/reference/output-and-errors.md @@ -0,0 +1,80 @@ +# Output and errors + +Every executable leaf accepts `--json`; text output is the default. +`--verbose` independently enables debug diagnostics and raw causes. + +## JSONL success + +JSON mode emits compact JSON Lines, not a bare result object. A successful +command emits exactly one final info event on stdout: + +```json +{"level":"info","ts":0,"msg":"project validation completed","command":"validate","data":{"valid":true,"issues":[]}} +``` + +Diagnostic events may precede the final event only with `--verbose`. + +## Findings + +Invalid `validate` and `lint` findings are normal results. The complete event +is written to stdout, stderr is empty, and the process exits `1`. Do not treat +every exit status `1` as an execution-error envelope; inspect stdout first for +these two commands. + +## Errors + +Usage, execution, and cancellation failures emit one final event on stderr. +stdout is empty for execution failures. + +```json +{"level":"error","ts":0,"msg":"internal error","code":"internal","exit_code":1,"details":{"partial_result":{"targets":["config"],"changes":[],"dry_run":false}}} +``` + +Safe scalar context appears in `details`. Raw causes and external tool output +appear only with `--verbose`. Error events never carry a top-level `data` +field. + +## Exit codes + +| Exit code | Meaning | +|---:|---| +| `0` | Success | +| `1` | Validation/lint findings or execution failure | +| `2` | Invalid CLI usage or help failure | +| `130` | Cancellation | + +## Stable error codes + +| Code | Meaning | +|---|---| +| `usage` | Invalid command, argument, or flag usage | +| `invalid_input` | Input is understood but invalid for the operation | +| `not_found` | Requested Project object, Target, Source, or file is absent | +| `conflict` | Existing state prevents the requested change | +| `unavailable` | External source or dependency cannot be reached | +| `unsupported` | The selected object does not support the operation | +| `cancelled` | Context or process cancellation | +| `internal` | Unexpected failure without a safer public category | + +## Command result payloads + +The command-specific payload is under `data` on success and may appear under +`details.partial_result` after real partial progress. + +| Commands | Payload shape | +|---|---| +| `init manifest`, `enable`, `add` | `{manifest, change}` | +| `init scaffold` | `{files: [{path, action}]}` | +| `validate` | `{valid, issues}` | +| `inspect` | `{project: {...}}` | +| `lint` | `{valid, contracts, issues}` | +| `sync`, `gen` | `{targets, changes, dry_run}` | + +Publication actions are `created`, `updated`, `unchanged`, and `removed`. +Static previews use `planned_publish` and `planned_remove`. + +## Partial results + +A partial result appears only when work completed before a later execution or +shutdown failure. It is recovery information, not success output. Review it +before retrying because already-published Targets are not rolled back. diff --git a/docs/user-guide/troubleshooting.md b/docs/user-guide/troubleshooting.md new file mode 100644 index 0000000..fda638d --- /dev/null +++ b/docs/user-guide/troubleshooting.md @@ -0,0 +1,80 @@ +# Troubleshooting + +Start with verbose diagnostics and an effective Project view: + +```sh +devctl validate --verbose +devctl inspect --json +``` + +## Manifest not found + +Without `--file`, Devctl searches upward from the current directory for +`devctl.yaml`. Run from inside the Project or pass an explicit path. + +## Invalid Manifest + +`validate` distinguishes malformed YAML, unknown or duplicate fields, semantic +conflicts, invalid references, unsafe paths, and missing Readiness. Fix every +reported issue before retrying the workflow that needs that state. + +Unknown fields are rejected; they are not ignored for forward compatibility. +Manifest `version` must currently be `1` and the supported language is `go`. + +## Missing Project tools or configs + +Run scaffold and install the Project-owned toolchain: + +```sh +devctl init scaffold +mise install +go mod download all +go mod tidy +devctl validate +``` + +An explicit custom generator config is user-owned. Scaffold will not create or +replace it; create the file at the configured path or return to the canonical +managed config. + +## Stale Snapshot Metadata + +Devctl-sourced gRPC and schema-backed Kafka Snapshots need valid root +`.devctl-contract.json` metadata. Missing, unsafe, or inconsistent metadata +returns `invalid_input` with reason `snapshot_metadata_invalid`. + +While the supplier is available, run the suggested targeted or full `devctl +sync`, review the refreshed Snapshot, and commit it. `inspect` remains usable +before the refresh but omits `resolved_input`. + +## Target selection failures + +- Unknown family: `invalid_input`. +- Known family with no applicable Targets: successful empty result. +- Unknown explicit Target ID: `not_found`. +- Existing Target without the requested operation: `unsupported`. + +Local Contract Targets support sync as a no-op. Raw Kafka Targets do not +support sync or generation. + +## A command failed after making progress + +Targets execute sequentially. A later failure does not roll back completed +Targets. In JSON mode, inspect `details.partial_result` on the final stderr +event, review the listed changes, correct the cause, and retry. Planning-only +failures do not contain a partial result. + +## Dry-run differs from execution + +Dry-run is intentionally static: it performs no network acquisition, external +tool execution, publication, or pruning. It can show intended Target paths and +stale removal candidates, but it cannot promise that a later network request +or generator will succeed. + +## ClickHouse migration failed partway + +ClickHouse migrations are non-transactional. For files containing several +statements, use a migration DSN with `x-multi-statement=true`. Driver statement +splitting can still leave a partial apply. Inspect the database, complete or +revert the intended changes manually, and only then force the migration +version. Devctl does not alter the DSN or perform recovery. diff --git a/e2e/e2e_test.go b/e2e/e2e_test.go new file mode 100644 index 0000000..e2b50ed --- /dev/null +++ b/e2e/e2e_test.go @@ -0,0 +1,184 @@ +//go:build e2e + +package e2e_test + +import ( + "bytes" + "context" + "net" + "net/http" + "os" + "os/exec" + "path/filepath" + "syscall" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestPublicHTTPServiceWorkflow(t *testing.T) { + t.Parallel() + + repositoryRoot, err := filepath.Abs("..") + require.NoError(t, err) + workspace := t.TempDir() + binDir := filepath.Join(workspace, "bin") + require.NoError(t, os.MkdirAll(binDir, 0o755)) + binary := filepath.Join(binDir, "devctl") + run(t, repositoryRoot, nil, "go", "build", "-o", binary, "./cmd/devctl") + + project := filepath.Join(workspace, "project") + require.NoError(t, os.MkdirAll(project, 0o755)) + manifest := filepath.Join(project, "devctl.yaml") + projectEnv := append(os.Environ(), "PATH="+binDir+string(os.PathListSeparator)+os.Getenv("PATH")) + run(t, project, projectEnv, binary, "init", "manifest", "--file", manifest, "--lang", "go", "--preset", "http-service", "--name", "fixture-api", "--module", "example.test/fixture-api") + run(t, project, projectEnv, binary, "init", "scaffold", "--file", manifest) + run(t, project, projectEnv, "go", "mod", "tidy") + run(t, project, projectEnv, binary, "sync") + run(t, project, projectEnv, binary, "lint") + run(t, project, projectEnv, binary, "gen") + application := filepath.Join(binDir, "fixture-api") + run(t, project, projectEnv, "go", "build", "-o", application, "./cmd/fixture-api") + httpAddress := availableAddress(t) + runtimeCommand := exec.Command(application, "api") + runtimeCommand.Dir = project + runtimeCommand.Env = append(projectEnv, "FIXTURE_API_HTTP_ADDR="+httpAddress, "FIXTURE_API_HEALTH_SERVER_ENABLED=false") + var runtimeOutput bytes.Buffer + runtimeCommand.Stdout = &runtimeOutput + runtimeCommand.Stderr = &runtimeOutput + require.NoError(t, runtimeCommand.Start()) + t.Cleanup(func() { _ = runtimeCommand.Process.Kill() }) + waitForHTTP(t, httpAddress) + require.NoError(t, runtimeCommand.Process.Signal(syscall.SIGTERM)) + require.NoError(t, runtimeCommand.Wait(), runtimeOutput.String()) + + run(t, project, projectEnv, "go", "test", "./...") + + run(t, project, projectEnv, "git", "init", "--quiet") + run(t, project, projectEnv, "git", "add", ".") + run(t, project, projectEnv, "git", "-c", "user.name=Devctl E2E", "-c", "user.email=devctl@example.test", "commit", "--quiet", "-m", "fixture") + run(t, project, projectEnv, binary, "sync") + run(t, project, projectEnv, binary, "gen") + run(t, project, projectEnv, "git", "diff", "--exit-code") +} + +func TestKafkaJSONQuicktypeWorkflow(t *testing.T) { + t.Parallel() + + repositoryRoot, err := filepath.Abs("..") + require.NoError(t, err) + workspace := t.TempDir() + binDir := filepath.Join(workspace, "bin") + require.NoError(t, os.MkdirAll(binDir, 0o755)) + binary := filepath.Join(binDir, "devctl") + run(t, repositoryRoot, nil, "go", "build", "-o", binary, "./cmd/devctl") + + project := filepath.Join(workspace, "project") + require.NoError(t, os.MkdirAll(project, 0o755)) + manifest := filepath.Join(project, "devctl.yaml") + projectEnv := append(os.Environ(), "PATH="+binDir+string(os.PathListSeparator)+os.Getenv("PATH")) + run(t, project, projectEnv, binary, "init", "manifest", "--file", manifest, "--lang", "go", "--preset", "cli", "--name", "fixture-events", "--module", "example.test/fixture-events") + run(t, project, projectEnv, binary, "add", "source", "contracts", "--file", manifest, "--type", "local", "--path", "api/contracts") + run(t, project, projectEnv, binary, "add", "kafka-consumer", "audit", "--file", manifest, + "--topic", "fixture_events.audit.created.v1", "--source", "contracts", "--format", "json", + "--path", "fixture_events.audit.created.v1.json") + writeFile(t, project, "api/contracts/fixture_events.audit.created.v1.json", `{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "AuditEvent", + "type": "object", + "additionalProperties": false, + "properties": { + "id": {"type": "string"}, + "note": {"type": ["string", "null"]}, + "payload": {"oneOf": [{"type": "string"}, {"type": "integer"}]} + }, + "required": ["id", "payload"] +} +`) + run(t, project, projectEnv, binary, "init", "scaffold", "--file", manifest) + run(t, project, projectEnv, binary, "validate", "--file", manifest) + run(t, project, projectEnv, binary, "lint", "kafka", "--file", manifest) + run(t, project, projectEnv, binary, "gen", "kafka", "--file", manifest) + + generatedPath := filepath.Join(project, "gen/kafka/consumer/audit/schema.gen.go") + generated, err := os.ReadFile(generatedPath) + require.NoError(t, err) + require.Contains(t, string(generated), "func UnmarshalAuditEvent") + require.Contains(t, string(generated), `json:"note,omitempty"`) + writeFile(t, project, "cmd/roundtrip/main.go", `package main + +import ( + "bytes" + + audit "example.test/fixture-events/gen/kafka/consumer/audit" +) + +func main() { + event, err := audit.UnmarshalAuditEvent([]byte("{\"id\":\"1\",\"payload\":\"created\"}")) + if err != nil { + panic(err) + } + encoded, err := event.Marshal() + if err != nil { + panic(err) + } + if !bytes.Contains(encoded, []byte("\"payload\":\"created\"")) { + panic(string(encoded)) + } +} +`) + run(t, project, projectEnv, "go", "mod", "tidy") + run(t, project, projectEnv, "go", "test", "./gen/kafka/consumer/audit") + run(t, project, projectEnv, "go", "run", "./cmd/roundtrip") + run(t, project, projectEnv, binary, "gen", "kafka", "--file", manifest) + regenerated, err := os.ReadFile(generatedPath) + require.NoError(t, err) + require.Equal(t, generated, regenerated) +} + +func writeFile(t *testing.T, root, relative, content string) { + t.Helper() + + filename := filepath.Join(root, filepath.FromSlash(relative)) + require.NoError(t, os.MkdirAll(filepath.Dir(filename), 0o755)) + require.NoError(t, os.WriteFile(filename, []byte(content), 0o644)) +} + +func availableAddress(t *testing.T) string { + t.Helper() + listener, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + address := listener.Addr().String() + require.NoError(t, listener.Close()) + return address +} + +func waitForHTTP(t *testing.T, address string) { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + client := &http.Client{Timeout: 250 * time.Millisecond} + for ctx.Err() == nil { + request, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://"+address+"/", nil) + require.NoError(t, err) + response, err := client.Do(request) + if err == nil { + require.NoError(t, response.Body.Close()) + return + } + time.Sleep(25 * time.Millisecond) + } + require.FailNow(t, "HTTP server did not become ready", "address: %s", address) +} + +func run(t *testing.T, directory string, environment []string, name string, args ...string) { + t.Helper() + command := exec.Command(name, args...) + command.Dir = directory + if environment != nil { + command.Env = environment + } + output, err := command.CombinedOutput() + require.NoError(t, err, "%s %v failed:\n%s", name, args, output) +} diff --git a/examples/orders-api/.env.example b/examples/orders-api/.env.example new file mode 100644 index 0000000..f0b2157 --- /dev/null +++ b/examples/orders-api/.env.example @@ -0,0 +1,13 @@ +ORDERS_API_DB_PRIMARY_KIND=postgres +ORDERS_API_DB_PRIMARY_POSTGRES_DSN= +ORDERS_API_DB_PRIMARY_POSTGRES_MIGRATIONS_URL= +ORDERS_API_DEPLOYMENT_ENVIRONMENT=development +ORDERS_API_HEALTH_ADDR=:8081 +ORDERS_API_HEALTH_SERVER_ENABLED=true +ORDERS_API_HTTP_ADDR=:8080 +ORDERS_API_HTTP_SERVER_ENABLED=true +ORDERS_API_LOG_LEVEL=info +ORDERS_API_PPROF_ADDR=127.0.0.1:6060 +ORDERS_API_PPROF_ENABLED=false +ORDERS_API_SERVICE_VERSION=dev +ORDERS_API_TELEMETRY_ENABLED=false diff --git a/examples/orders-api/.golangci.yml b/examples/orders-api/.golangci.yml new file mode 100644 index 0000000..a01c843 --- /dev/null +++ b/examples/orders-api/.golangci.yml @@ -0,0 +1,76 @@ +version: "2" +run: + relative-path-mode: gomod + tests: true + modules-download-mode: readonly +linters: + default: none + enable: + - asasalint + - bidichk + - bodyclose + - containedctx + - contextcheck + - durationcheck + - errcheck + - errchkjson + - errname + - errorlint + - exhaustive + - fatcontext + - forcetypeassert + - gocheckcompilerdirectives + - gochecknoinits + - gocognit + - govet + - inamedparam + - ineffassign + - loggercheck + - makezero + - musttag + - nilerr + - nilnesserr + - noctx + - nolintlint + - nosprintfhostport + - paralleltest + - predeclared + - reassign + - recvcheck + - revive + - rowserrcheck + - sqlclosecheck + - staticcheck + - testifylint + - thelper + - tparallel + - unconvert + - unused + - usetesting + - wastedassign + - wrapcheck + settings: + gocognit: + min-complexity: 20 + nolintlint: + allow-unused: false + require-explanation: true + require-specific: true + paralleltest: + ignore-missing: false + ignore-missing-subtests: false + check-cleanup: true + revive: + rules: + - name: argument-limit + arguments: [4] + - name: function-result-limit + arguments: [3] + exclusions: + generated: strict + paths: ["^gen/"] +formatters: + enable: [gofmt] + exclusions: + generated: strict + paths: ["^gen/"] diff --git a/examples/orders-api/.mise.toml b/examples/orders-api/.mise.toml new file mode 100644 index 0000000..621d406 --- /dev/null +++ b/examples/orders-api/.mise.toml @@ -0,0 +1,50 @@ +[tools] +go = "1.26.0" +golangci-lint = "2.12.2" +"go:github.com/golang-migrate/migrate/v4/cmd/migrate" = { version = "v4.19.1", tags = ["postgres"] } + +[tasks.fmt] +run = "golangci-lint fmt" +[tasks."fmt:check"] +run = "golangci-lint fmt --diff" +[tasks."lint:contracts"] +run = "devctl lint" +[tasks."lint:go"] +run = "golangci-lint run" +[tasks.lint] +depends = ["lint:contracts", "lint:go"] +[tasks.test] +run = "go test ./..." +[tasks.gen] +run = "devctl gen" +[tasks."gen:http"] +run = "devctl gen http" +[tasks."gen:grpc"] +run = "devctl gen grpc" +[tasks."gen:kafka"] +run = "devctl gen kafka" +[tasks.check] +depends = ["fmt:check", "lint", "test"] + +[tasks."migrate:primary:postgres:create"] +description = "Create timestamped migration files in migrations/primary/postgres" +usage = 'arg "" help="Migration name"' +run = ''' +migrate create -ext sql -dir "migrations/primary/postgres" -format "20060102150405" "${usage_name?}" +''' + +[tasks."migrate:primary:postgres:up"] +description = "Apply migrations from migrations/primary/postgres" +run = ''' +database_url="${ORDERS_API_DB_PRIMARY_POSTGRES_MIGRATIONS_URL:?set ORDERS_API_DB_PRIMARY_POSTGRES_MIGRATIONS_URL}" +migrate -path "migrations/primary/postgres" -database "$database_url" up +''' + +[tasks."migrate:primary:postgres:down"] +description = "Roll back migrations from migrations/primary/postgres" +usage = 'arg "[steps]" default="1" help="Number of migrations"' +confirm = "Roll back migrate:primary:postgres migrations?" +run = ''' +database_url="${ORDERS_API_DB_PRIMARY_POSTGRES_MIGRATIONS_URL:?set ORDERS_API_DB_PRIMARY_POSTGRES_MIGRATIONS_URL}" +migrate -path "migrations/primary/postgres" -database "$database_url" down "${usage_steps?}" +''' diff --git a/examples/orders-api/README.md b/examples/orders-api/README.md new file mode 100644 index 0000000..06b9f40 --- /dev/null +++ b/examples/orders-api/README.md @@ -0,0 +1,30 @@ +# orders-api + +This project foundation is scaffolded by Devctl. + +## Bootstrap + +```sh +mise install +go mod download all +go mod tidy +devctl lint +devctl gen +go mod tidy +mise run check +``` + +Run the API with `go run ./cmd/orders-api api`. + +## Updating the foundation + +- Run `devctl sync` after changing remote sources. +- Run `devctl init scaffold` after changing components in `devctl.yaml`. +- Run `devctl gen` after changing API or schema contracts. + +Devctl replaces files ending in `*.gen.go`. Ordinary `.go` files and this +README are created once, so application code and local notes are preserved. + +When a component adds a provider seed, review it and call the provider from +`internal/deps/application.go`. That file is the user-owned composition root; +Devctl does not rewrite its provider list. diff --git a/examples/orders-api/api/openapi/swagger.yaml b/examples/orders-api/api/openapi/swagger.yaml new file mode 100644 index 0000000..b2b56b7 --- /dev/null +++ b/examples/orders-api/api/openapi/swagger.yaml @@ -0,0 +1,89 @@ +openapi: 3.1.0 +info: + title: Orders API + version: 1.0.0 +paths: + /orders: + post: + operationId: createOrder + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CreateOrder" + responses: + "201": + description: Order created + content: + application/json: + schema: + $ref: "#/components/schemas/Order" + /orders/{id}: + get: + operationId: getOrder + parameters: + - name: id + in: path + required: true + schema: + type: integer + format: int64 + minimum: 1 + responses: + "200": + description: Order found + content: + application/json: + schema: + $ref: "#/components/schemas/Order" + "404": + description: Order not found + content: + application/json: + schema: + $ref: "#/components/schemas/Problem" +components: + schemas: + CreateOrder: + type: object + additionalProperties: false + required: + - customer_name + - total_cents + properties: + customer_name: + type: string + minLength: 1 + total_cents: + type: integer + format: int64 + minimum: 0 + Order: + type: object + additionalProperties: false + required: + - id + - customer_name + - total_cents + - created_at + properties: + id: + type: integer + format: int64 + customer_name: + type: string + total_cents: + type: integer + format: int64 + created_at: + type: string + format: date-time + Problem: + type: object + additionalProperties: false + required: + - message + properties: + message: + type: string diff --git a/examples/orders-api/cmd/orders-api/internal/api.go b/examples/orders-api/cmd/orders-api/internal/api.go new file mode 100644 index 0000000..554686e --- /dev/null +++ b/examples/orders-api/cmd/orders-api/internal/api.go @@ -0,0 +1,27 @@ +package internal + +import ( + "context" + "fmt" + + "example.com/orders-api/internal/deps" + "github.com/urfave/cli/v3" +) + +// NewCmdAPI constructs the API server command. +func NewCmdAPI() *cli.Command { + return &cli.Command{ + Name: "api", + Usage: "Run API servers", + Action: func(ctx context.Context, _ *cli.Command) error { + scenario, err := deps.NewAPI(ctx) + if err != nil { + return fmt.Errorf("deps.NewAPI: %w", err) + } + if err := scenario.Run(ctx); err != nil { + return fmt.Errorf("scenario.Run: %w", err) + } + return nil + }, + } +} diff --git a/examples/orders-api/cmd/orders-api/main.go b/examples/orders-api/cmd/orders-api/main.go new file mode 100644 index 0000000..7bb2ba2 --- /dev/null +++ b/examples/orders-api/cmd/orders-api/main.go @@ -0,0 +1,29 @@ +package main + +import ( + "context" + "fmt" + "os" + "os/signal" + "syscall" + + "github.com/urfave/cli/v3" + + appcmd "example.com/orders-api/cmd/orders-api/internal" +) + +func main() { + ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer cancel() + root := &cli.Command{ + Name: "orders-api", + Usage: "Run orders-api", + Commands: []*cli.Command{ + appcmd.NewCmdAPI(), + }, + } + if err := root.Run(ctx, os.Args); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} diff --git a/examples/orders-api/devctl.yaml b/examples/orders-api/devctl.yaml new file mode 100644 index 0000000..e119aeb --- /dev/null +++ b/examples/orders-api/devctl.yaml @@ -0,0 +1,72 @@ +version: 1 +project: + name: orders-api + language: go +env: {} +paths: + external_contracts: api/external +sources: {} +exports: {} +components: + http: + server: + openapi: api/openapi/swagger.yaml + start: + env: HTTP_SERVER_ENABLED + default: true + env: + system: + - key: HTTP_ADDR + type: string + default: :8080 + logging: + env: + system: + - key: LOG_LEVEL + type: string + default: info + health: + server: + start: + env: HEALTH_SERVER_ENABLED + default: true + env: + system: + - key: HEALTH_ADDR + type: string + default: :8081 + telemetry: + start: + env: TELEMETRY_ENABLED + default: false + db: + connections: + - name: primary + default: postgres + variants: + - name: postgres + kind: postgres + dsn_env: DB_PRIMARY_POSTGRES_DSN + secret: true + migrations: + path: migrations/primary/postgres + database_env: DB_PRIMARY_POSTGRES_MIGRATIONS_URL +languages: + go: + module: example.com/orders-api + generators: + http: + oapi_config: tools/oapi/server.yaml + server_out: gen/serverhttp + client_out: gen/clienthttp + components: + pprof: + server: + start: + env: PPROF_ENABLED + default: false + env: + system: + - key: PPROF_ADDR + type: string + default: 127.0.0.1:6060 diff --git a/examples/orders-api/gen/config/config.gen.go b/examples/orders-api/gen/config/config.gen.go new file mode 100644 index 0000000..379f8f6 --- /dev/null +++ b/examples/orders-api/gen/config/config.gen.go @@ -0,0 +1,56 @@ +// Code generated by devctl. DO NOT EDIT. + +package config + +import ( + "fmt" + "time" +) + +type Config struct { + DBPrimary DBPrimaryConfig + HTTP HTTPConfig + Health HealthConfig + Logging LoggingConfig + Pprof PprofConfig + Telemetry TelemetryConfig +} + +type DBPrimaryConfig struct { + Kind string `env:"ORDERS_API_DB_PRIMARY_KIND" default:"postgres"` + PostgresDSN string `env:"ORDERS_API_DB_PRIMARY_POSTGRES_DSN"` +} + +type HTTPConfig struct { + Address string `env:"ORDERS_API_HTTP_ADDR" default:":8080"` + Enabled bool `env:"ORDERS_API_HTTP_SERVER_ENABLED" default:"true"` +} + +type HealthConfig struct { + Address string `env:"ORDERS_API_HEALTH_ADDR" default:":8081"` + Enabled bool `env:"ORDERS_API_HEALTH_SERVER_ENABLED" default:"true"` +} + +type LoggingConfig struct { + Level string `env:"ORDERS_API_LOG_LEVEL" default:"info"` +} + +type PprofConfig struct { + Address string `env:"ORDERS_API_PPROF_ADDR" default:"127.0.0.1:6060"` + Enabled bool `env:"ORDERS_API_PPROF_ENABLED" default:"false"` +} + +type TelemetryConfig struct { + DeploymentEnvironment string `env:"ORDERS_API_DEPLOYMENT_ENVIRONMENT" default:"development"` + Enabled bool `env:"ORDERS_API_TELEMETRY_ENABLED" default:"false"` + ServiceVersion string `env:"ORDERS_API_SERVICE_VERSION" default:"dev"` +} + +func (c *Config) Validate() error { + if c == nil { + return fmt.Errorf("config is nil") + } + return nil +} + +var _ time.Duration diff --git a/examples/orders-api/gen/serverhttp/server.gen.go b/examples/orders-api/gen/serverhttp/server.gen.go new file mode 100644 index 0000000..faf2083 --- /dev/null +++ b/examples/orders-api/gen/serverhttp/server.gen.go @@ -0,0 +1,386 @@ +// Package serverhttp provides primitives to interact with the openapi HTTP API. +// +// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.8.0 DO NOT EDIT. +package serverhttp + +import ( + "bytes" + "compress/flate" + "context" + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "net/url" + "path" + "strings" + "time" + + "github.com/getkin/kin-openapi/openapi3" + "github.com/labstack/echo/v5" + "github.com/oapi-codegen/runtime" +) + +// CreateOrder defines model for CreateOrder. +type CreateOrder struct { + CustomerName string `json:"customer_name"` + TotalCents int64 `json:"total_cents"` +} + +// Order defines model for Order. +type Order struct { + CreatedAt time.Time `json:"created_at"` + CustomerName string `json:"customer_name"` + Id int64 `json:"id"` + TotalCents int64 `json:"total_cents"` +} + +// Problem defines model for Problem. +type Problem struct { + Message string `json:"message"` +} + +// CreateOrderJSONRequestBody defines body for CreateOrder for application/json ContentType. +type CreateOrderJSONRequestBody = CreateOrder + +// ServerInterface represents all server handlers. +type ServerInterface interface { + + // (POST /orders) + CreateOrder(ctx *echo.Context) error + + // (GET /orders/{id}) + GetOrder(ctx *echo.Context, id int64) error +} + +// ServerInterfaceWrapper converts echo contexts to parameters. +type ServerInterfaceWrapper struct { + Handler ServerInterface +} + +// CreateOrder converts echo context to params. +func (w *ServerInterfaceWrapper) CreateOrder(ctx *echo.Context) error { + var err error + + // Invoke the callback with all the unmarshaled arguments + err = w.Handler.CreateOrder(ctx) + return err +} + +// GetOrder converts echo context to params. +func (w *ServerInterfaceWrapper) GetOrder(ctx *echo.Context) error { + var err error + // ------------- Path parameter "id" ------------- + var id int64 + + err = runtime.BindStyledParameterWithOptions("simple", "id", ctx.Param("id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "integer", Format: "int64", ValueIsUnescaped: ctx.Request().URL.RawPath == ""}) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter id: %s", err)) + } + + // Invoke the callback with all the unmarshaled arguments + err = w.Handler.GetOrder(ctx, id) + return err +} + +// This is a simple interface which specifies echo.Route addition functions which +// are present on both echo.Echo and echo.Group, since we want to allow using +// either of them for path registration +type EchoRouter interface { + CONNECT(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) echo.RouteInfo + DELETE(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) echo.RouteInfo + GET(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) echo.RouteInfo + HEAD(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) echo.RouteInfo + OPTIONS(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) echo.RouteInfo + PATCH(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) echo.RouteInfo + POST(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) echo.RouteInfo + PUT(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) echo.RouteInfo + TRACE(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) echo.RouteInfo +} + +// RegisterHandlersOptions configures RegisterHandlersWithOptions. +type RegisterHandlersOptions struct { + // BaseURL is prepended to every registered path so the API can be served + // under a prefix. + BaseURL string + // OperationMiddlewares lets the caller attach per-operation middleware at + // registration time. The map key is the OpenAPI `operationId` value as it + // appears in the spec (the raw, un-normalized form). Operations that have + // no entry are registered with no extra middleware. A nil map disables + // per-operation middleware entirely. + OperationMiddlewares map[string][]echo.MiddlewareFunc +} + +// RegisterHandlers adds each server route to the EchoRouter. +func RegisterHandlers(router EchoRouter, si ServerInterface) { + RegisterHandlersWithOptions(router, si, RegisterHandlersOptions{}) +} + +// RegisterHandlersWithBaseURL registers handlers and prepends BaseURL to the +// paths so the API can be served under a prefix. +func RegisterHandlersWithBaseURL(router EchoRouter, si ServerInterface, baseURL string) { + RegisterHandlersWithOptions(router, si, RegisterHandlersOptions{BaseURL: baseURL}) +} + +// RegisterHandlersWithOptions registers handlers using the supplied options, +// including any per-operation middleware. +func RegisterHandlersWithOptions(router EchoRouter, si ServerInterface, options RegisterHandlersOptions) { + + wrapper := ServerInterfaceWrapper{ + Handler: si, + } + + router.POST(options.BaseURL+"/orders", wrapper.CreateOrder, options.OperationMiddlewares["createOrder"]...) + router.GET(options.BaseURL+"/orders/:id", wrapper.GetOrder, options.OperationMiddlewares["getOrder"]...) + +} + +type CreateOrderRequestObject struct { + Body *CreateOrderJSONRequestBody +} + +type CreateOrderResponseObject interface { + VisitCreateOrderResponse(w http.ResponseWriter) error +} + +type CreateOrder201JSONResponse Order + +func (response CreateOrder201JSONResponse) VisitCreateOrderResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response); err != nil { + return err + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(201) + _, err := buf.WriteTo(w) + return err +} + +type GetOrderRequestObject struct { + Id int64 `json:"id"` +} + +type GetOrderResponseObject interface { + VisitGetOrderResponse(w http.ResponseWriter) error +} + +type GetOrder200JSONResponse Order + +func (response GetOrder200JSONResponse) VisitGetOrderResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response); err != nil { + return err + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + _, err := buf.WriteTo(w) + return err +} + +type GetOrder404JSONResponse Problem + +func (response GetOrder404JSONResponse) VisitGetOrderResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response); err != nil { + return err + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(404) + _, err := buf.WriteTo(w) + return err +} + +// StrictServerInterface represents all server handlers. +type StrictServerInterface interface { + + // (POST /orders) + CreateOrder(ctx context.Context, request CreateOrderRequestObject) (CreateOrderResponseObject, error) + + // (GET /orders/{id}) + GetOrder(ctx context.Context, request GetOrderRequestObject) (GetOrderResponseObject, error) +} + +type StrictHandlerFunc func(ctx *echo.Context, request any) (any, error) +type StrictMiddlewareFunc func(f StrictHandlerFunc, operationID string) StrictHandlerFunc + +func NewStrictHandler(ssi StrictServerInterface, middlewares []StrictMiddlewareFunc) ServerInterface { + return &strictHandler{ssi: ssi, middlewares: middlewares} +} + +type strictHandler struct { + ssi StrictServerInterface + middlewares []StrictMiddlewareFunc +} + +// CreateOrder operation middleware +func (sh *strictHandler) CreateOrder(ctx *echo.Context) error { + var request CreateOrderRequestObject + + var body CreateOrderJSONRequestBody + var err error + if _, ok := ctx.Echo().Binder.(*echo.DefaultBinder); ok { + // Bind only the request body, so that path and query parameters + // are not also bound into the body struct. + err = echo.BindBody(ctx, &body) + } else { + // A custom binder is installed on the Echo instance; defer to it + // entirely, since echo.Binder does not expose body-only binding. + err = ctx.Bind(&body) + } + if err != nil { + return err + } + request.Body = &body + + handler := func(ctx *echo.Context, request interface{}) (interface{}, error) { + return sh.ssi.CreateOrder(ctx.Request().Context(), request.(CreateOrderRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "CreateOrder") + } + + response, err := handler(ctx, request) + + if err != nil { + return err + } else if validResponse, ok := response.(CreateOrderResponseObject); ok { + return validResponse.VisitCreateOrderResponse(ctx.Response()) + } else if response != nil { + return fmt.Errorf("unexpected response type: %T", response) + } + return nil +} + +// GetOrder operation middleware +func (sh *strictHandler) GetOrder(ctx *echo.Context, id int64) error { + var request GetOrderRequestObject + + request.Id = id + + handler := func(ctx *echo.Context, request interface{}) (interface{}, error) { + return sh.ssi.GetOrder(ctx.Request().Context(), request.(GetOrderRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "GetOrder") + } + + response, err := handler(ctx, request) + + if err != nil { + return err + } else if validResponse, ok := response.(GetOrderResponseObject); ok { + return validResponse.VisitGetOrderResponse(ctx.Response()) + } else if response != nil { + return fmt.Errorf("unexpected response type: %T", response) + } + return nil +} + +// Base64 encoded, compressed with deflate, json marshaled OpenAPI spec. +// Stored as a slice of fixed-width chunks rather than one concatenated +// const string: with thousands of chunks the chained `+` fold is several +// times slower for the Go compiler than parsing a slice literal. +var swaggerSpec = []string{ + "vJTRb9MwEMb/leng0TQpVDz4DXhAk5Do+zRVXnxtb4p9xr4gTVH+d2RnGWmbMYEm+uQ6Pn/f98tdemjY", + "BfboJYHuITVHdKYsv0Q0gt+jxZj/GmtJiL1pt5EDRiFMoPemTaggzLZ6aLok7DDuvHGYNxz5b+gPcgS9", + "ViAPAUFDkkj+AIMCYTHtrpk87Dk6I6CBvHzcgMrl5DoHun4qJi94wAjDoCDij44iWtA3Z9KnV98+VfPd", + "PTaSpf8pXgFjd9njzK01gu+ERtnziBdILk6QXcx+nvdlWi8QIgvqj5jUPOASs23kuxbdX1JzmJI5LGU/", + "MzgdvJTOJ8nvudxB0uZn5QWmq0/ba1DwE2Mi9qBhvapXdTbLAb0JBBo+rNarGhQEI8fiqOJSm5eBU3mZ", + "2bDJga4t6JMJGD1iks9sH0oXsBf0pcqE0FJT6qr7lPWnQcqrtxH3oOFN9XvSqscxq+YKwykIiR2WjRTY", + "p5Hh+3r9atIzUYupiRRkRFceXD32QD4wqAlV1ZMd8sUHXMD1FWViFUw0DqXQvemB8r2ZOygYB2Bsw9O0", + "aub8+W/AeqHDby841f+L0547b3OjberNq2lOE/asqmeZlMvvVwAAAP//", +} + +// decodeSpec returns the embedded OpenAPI spec as raw JSON bytes, +// after base64-decoding and flate-decompressing the embedded blob. +func decodeSpec() ([]byte, error) { + encoded := strings.Join(swaggerSpec, "") + compressed, err := base64.StdEncoding.DecodeString(encoded) + if err != nil { + return nil, fmt.Errorf("error base64 decoding spec: %w", err) + } + zr := flate.NewReader(bytes.NewReader(compressed)) + var buf bytes.Buffer + if _, err := buf.ReadFrom(zr); err != nil { + return nil, fmt.Errorf("read flate: %w", err) + } + if err := zr.Close(); err != nil { + return nil, fmt.Errorf("close flate reader: %w", err) + } + + return buf.Bytes(), nil +} + +var rawSpec = decodeSpecCached() + +// a naive cache of the decoded OpenAPI spec +func decodeSpecCached() func() ([]byte, error) { + data, err := decodeSpec() + return func() ([]byte, error) { + return data, err + } +} + +// Constructs a synthetic filesystem for resolving external references when loading openapi specifications. +func PathToRawSpec(pathToFile string) map[string]func() ([]byte, error) { + res := make(map[string]func() ([]byte, error)) + if len(pathToFile) > 0 { + res[pathToFile] = rawSpec + } + + return res +} + +// GetSpec returns the OpenAPI specification corresponding to the generated +// code in this file. External references in the spec are resolved through +// PathToRawSpec; externally-referenced files must be embedded in their +// corresponding Go packages (via the import-mapping feature). URL-based +// external refs are not supported. +func GetSpec() (swagger *openapi3.T, err error) { + resolvePath := PathToRawSpec("") + + loader := openapi3.NewLoader() + loader.IsExternalRefsAllowed = true + loader.ReadFromURIFunc = func(loader *openapi3.Loader, url *url.URL) ([]byte, error) { + pathToFile := url.String() + pathToFile = path.Clean(pathToFile) + getSpec, ok := resolvePath[pathToFile] + if !ok { + err1 := fmt.Errorf("path not found: %s", pathToFile) + return nil, err1 + } + return getSpec() + } + var specData []byte + specData, err = rawSpec() + if err != nil { + return + } + swagger, err = loader.LoadFromData(specData) + if err != nil { + return + } + return +} + +// GetSpecJSON returns the raw JSON bytes of the embedded OpenAPI +// specification: decompressed but not unmarshaled. External references +// are not resolved here; the bytes are the spec exactly as embedded by +// codegen. The result is cached at package init time, so repeated calls +// are cheap. +func GetSpecJSON() ([]byte, error) { + return rawSpec() +} + +// GetSwagger returns the OpenAPI specification corresponding to the +// generated code in this file. +// +// Deprecated: GetSwagger predates kin-openapi renaming openapi3.Swagger +// to openapi3.T. Use [GetSpec] instead. This wrapper is retained for +// backwards compatibility. +func GetSwagger() (*openapi3.T, error) { + return GetSpec() +} diff --git a/examples/orders-api/go.mod b/examples/orders-api/go.mod new file mode 100644 index 0000000..ed98664 --- /dev/null +++ b/examples/orders-api/go.mod @@ -0,0 +1,85 @@ +module example.com/orders-api + +go 1.26.0 + +require ( + github.com/devctllabs/go-libs/config v0.1.0 + github.com/devctllabs/go-libs/debugserver v0.1.0 + github.com/devctllabs/go-libs/di v0.1.0 + github.com/devctllabs/go-libs/health v0.1.0 + github.com/devctllabs/go-libs/healthserver v0.1.0 + github.com/devctllabs/go-libs/lifecycle v0.2.0 + github.com/devctllabs/go-libs/log v0.2.0 + github.com/devctllabs/go-libs/postgresdb v0.2.0 + github.com/devctllabs/go-libs/telemetry v0.1.0 + github.com/devctllabs/go-libs/txmanager v0.1.0 + github.com/getkin/kin-openapi v0.142.0 + github.com/jackc/pgx/v5 v5.10.0 + github.com/labstack/echo/v5 v5.3.1 + github.com/oapi-codegen/runtime v1.6.0 + github.com/stretchr/testify v1.11.1 + github.com/urfave/cli/v3 v3.10.1 + go.uber.org/zap v1.28.0 +) + +tool github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen + +require github.com/oapi-codegen/oapi-codegen/v2 v2.8.0 // indirect + +require ( + github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect + github.com/caarlos0/env/v11 v11.4.1 // indirect + github.com/cenkalti/backoff/v5 v5.0.3 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/creasty/defaults v1.8.0 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 // indirect + github.com/exaring/otelpgx v0.11.1 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-logr/zapr v1.3.0 // indirect + github.com/go-openapi/jsonpointer v0.23.1 // indirect + github.com/go-openapi/swag/jsonname v0.26.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/joho/godotenv v1.5.1 // indirect + github.com/oasdiff/yaml v0.1.1 // indirect + github.com/oasdiff/yaml3 v0.0.14 // indirect + github.com/pelletier/go-toml/v2 v2.4.3 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/samber/do/v2 v2.1.0 // indirect + github.com/samber/go-type-to-string v1.8.0 // indirect + github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect + github.com/speakeasy-api/jsonpath v0.6.3 // indirect + github.com/speakeasy-api/openapi v1.24.0 // indirect + github.com/vmware-labs/yaml-jsonpath v0.3.2 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/instrumentation/runtime v0.69.0 // indirect + go.opentelemetry.io/otel v1.44.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.44.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.44.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0 // indirect + go.opentelemetry.io/otel/metric v1.44.0 // indirect + go.opentelemetry.io/otel/sdk v1.44.0 // indirect + go.opentelemetry.io/otel/sdk/metric v1.44.0 // indirect + go.opentelemetry.io/otel/trace v1.44.0 // indirect + go.opentelemetry.io/proto/otlp v1.10.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.yaml.in/yaml/v3 v3.0.5 // indirect + golang.org/x/mod v0.38.0 // indirect + golang.org/x/net v0.57.0 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.40.0 // indirect + golang.org/x/tools v0.48.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect + google.golang.org/grpc v1.81.1 // indirect + google.golang.org/protobuf v1.36.11 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/examples/orders-api/go.sum b/examples/orders-api/go.sum new file mode 100644 index 0000000..9698b8f --- /dev/null +++ b/examples/orders-api/go.sum @@ -0,0 +1,372 @@ +dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= +dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= +github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ= +github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= +github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= +github.com/caarlos0/env/v11 v11.4.1 h1:fYwH0sWEsBSMPG7t4e/PEfTFzrWrpjyygXyUnWiSwEw= +github.com/caarlos0/env/v11 v11.4.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= +github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= +github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= +github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= +github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= +github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= +github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= +github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= +github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= +github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= +github.com/creasty/defaults v1.8.0 h1:z27FJxCAa0JKt3utc0sCImAEb+spPucmKoOdLHvHYKk= +github.com/creasty/defaults v1.8.0/go.mod h1:iGzKe6pbEHnpMPtfDXZEr0NVxWnPTjb1bbDy08fPzYM= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/devctllabs/go-libs/config v0.1.0 h1:jCQF7MS1ZwYNtxtZPsE1fApqdM63BW3YODXe7EJm5ek= +github.com/devctllabs/go-libs/config v0.1.0/go.mod h1:ukrS7MSVLYC2eZCPiU9B/f4slQtTDU+uxWlt7MEX4iA= +github.com/devctllabs/go-libs/debugserver v0.1.0 h1:3QCLJqJBgiIldazi5gwt6L4/Pa+ry+MnSHriDB+fYcE= +github.com/devctllabs/go-libs/debugserver v0.1.0/go.mod h1:RwHLtXQNGBMTLeZBWRrat/1gijO64E0mbs40KMlW/Ss= +github.com/devctllabs/go-libs/di v0.1.0 h1:pPhlKmdYyvKVYr/5eV51Ea9HcfqsqWCs3GZKG6jFGW8= +github.com/devctllabs/go-libs/di v0.1.0/go.mod h1:kr9mSKElEmsDHgKsZbDGJhSCdmlcluT81PjbCvcOmbY= +github.com/devctllabs/go-libs/health v0.1.0 h1:pYlxHqx90HSWZ1S64h6gVajorDdmNnQWNa0jW8HdafE= +github.com/devctllabs/go-libs/health v0.1.0/go.mod h1:n9PLAIiXIM7p5DNtc5LNY0xaZNuywevsnqnM6mUO+LU= +github.com/devctllabs/go-libs/healthserver v0.1.0 h1:bnQA/jJFsrSbuqaa9CE1LP3/oOudMLexHlNbV2sYG70= +github.com/devctllabs/go-libs/healthserver v0.1.0/go.mod h1:H6IXWM5QF68aaNPBnjdzIrvZZbZHimzUsQRWl+NVaUI= +github.com/devctllabs/go-libs/lifecycle v0.2.0 h1:vafo21o5tjrU3Qf4tVa0a2A6a8NQPSR4blpHiTQU3O4= +github.com/devctllabs/go-libs/lifecycle v0.2.0/go.mod h1:/m8kzixQx7IhAi3jx7hFfICu96VxBKNuQxHd+tY6VIY= +github.com/devctllabs/go-libs/log v0.2.0 h1:RICLkubslpX8CGSw/m+i2FxrRo64PzhE7hVFQXlwReM= +github.com/devctllabs/go-libs/log v0.2.0/go.mod h1:KfCsyQUkit4D2kounIhY6AauWB/Lo5SMiHZZXaTtMJ4= +github.com/devctllabs/go-libs/postgresdb v0.2.0 h1:4QhmPbVOnQXiy4qBmmBxHH0k8veqf8mNUmjT1z8dcas= +github.com/devctllabs/go-libs/postgresdb v0.2.0/go.mod h1:q0cD+nVZz/S+CTzVVAJIx1XSnuIqtXbDcapd3SHhdPs= +github.com/devctllabs/go-libs/telemetry v0.1.0 h1:hJF0DtkyPLldeMuBx3/ue2zhVw0my1i8tYBpUPMEma4= +github.com/devctllabs/go-libs/telemetry v0.1.0/go.mod h1:4HdPyn83FwbZuasGTa0Og8+aBDiEDc1QfD3T7EGB90E= +github.com/devctllabs/go-libs/txmanager v0.1.0 h1:UEiZ1vDeM+e4umlBynka5k+Go56N4N1EpIZwZ7nnhsY= +github.com/devctllabs/go-libs/txmanager v0.1.0/go.mod h1:yDtccFPCuXFEFLVnMpJDJhPn4hFeQ4o8UHnq7HoQwDI= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo= +github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c= +github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/dprotaso/go-yit v0.0.0-20191028211022-135eb7262960/go.mod h1:9HQzr9D/0PGwMEbC3d5AB7oi67+h4TsQqItC1GVYG58= +github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 h1:PRxIJD8XjimM5aTknUK9w6DHLDox2r2M3DI4i2pnd3w= +github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936/go.mod h1:ttYvX5qlB+mlV1okblJqcSMtR4c52UKxDiX9GRBS8+Q= +github.com/ebitengine/purego v0.10.1 h1:dewVBCBT2GaMu1SrNTYxQhgQBethzfhiwvZiLGP/qyY= +github.com/ebitengine/purego v0.10.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/exaring/otelpgx v0.11.1 h1:pE79fIg/qh/Lpu00kvswFC5dKfqyJJhMJ4Y4N3w5Lj4= +github.com/exaring/otelpgx v0.11.1/go.mod h1:3OojrUKhhy3lTbYIMBijP3YjMey/jo14eHAW5cXcUdk= +github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc= +github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= +github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= +github.com/georgysavva/scany/v2 v2.1.4 h1:nrzHEJ4oQVRoiKmocRqA1IyGOmM/GQOEsg9UjMR5Ip4= +github.com/georgysavva/scany/v2 v2.1.4/go.mod h1:fqp9yHZzM/PFVa3/rYEC57VmDx+KDch0LoqrJzkvtos= +github.com/getkin/kin-openapi v0.142.0 h1:izj0vBdFprMhitfzaX8sTqztsEQyvwhssBoB6n8NO7w= +github.com/getkin/kin-openapi v0.142.0/go.mod h1:3BH9M9XDe/y9M5DSvEocVYAYq1w0qrhJHjC/vZi0AaY= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= +github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg= +github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= +github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= +github.com/go-openapi/jsonpointer v0.23.1 h1:1HBACs7XIwR2RcmItfdSFlALhGbe6S92p0ry4d1GWg4= +github.com/go-openapi/jsonpointer v0.23.1/go.mod h1:iWRmZTrGn7XwYhtPt/fvdSFj1OfNBngqRT2UG3BxSqY= +github.com/go-openapi/swag/jsonname v0.26.0 h1:gV1NFX9M8avo0YSpmWogqfQISigCmpaiNci8cGECU5w= +github.com/go-openapi/swag/jsonname v0.26.0/go.mod h1:urBBR8bZNoDYGr653ynhIx+gTeIz0ARZxHkAPktJK2M= +github.com/go-openapi/testify/v2 v2.4.2 h1:tiByHpvE9uHrrKjOszax7ZvKB7QOgizBWGBLuq0ePx4= +github.com/go-openapi/testify/v2 v2.4.2/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw= +github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0= +github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE= +github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= +github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/labstack/echo/v5 v5.3.1 h1:75maCxkQVGualckLc/5s/ihgpH1a1Dc6AuGWNVNs6bw= +github.com/labstack/echo/v5 v5.3.1/go.mod h1:4iEGNQiPPZnkfYpNR/L6fINd3NLiGWUD5+eBotFALas= +github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e h1:Q6MvJtQK/iRcRtzAscm/zF23XxJlbECiGPyRicsX+Ak= +github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= +github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= +github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8= +github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU= +github.com/moby/moby/api v1.55.0 h1:2/sexvQyqIWS8pRSCFddBfpW2qE7vR7FCL+vN8pxwMc= +github.com/moby/moby/api v1.55.0/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs= +github.com/moby/moby/client v0.5.0 h1:5XhyPk2fuOWf6RlSFa3MkIIgDZkF25xToXW8Q/BH7cc= +github.com/moby/moby/client v0.5.0/go.mod h1:rcVpF8ncl9vo5gaIBdol6CnbEtSj1uxMvEV/UrykF/s= +github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U= +github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= +github.com/moby/sys/sequential v0.7.0 h1:ASQNGNROJSuOO6LL6bPHbKvuZu6NU8P4ldPWk31zj/8= +github.com/moby/sys/sequential v0.7.0/go.mod h1:NfSTAp6V3fw4tmkD62PEcOKeZKquXT8VKCkf7aVR79o= +github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs= +github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= +github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g= +github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= +github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= +github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= +github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= +github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= +github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= +github.com/oapi-codegen/nullable v1.1.0 h1:eAh8JVc5430VtYVnq00Hrbpag9PFRGWLjxR1/3KntMs= +github.com/oapi-codegen/nullable v1.1.0/go.mod h1:KUZ3vUzkmEKY90ksAmit2+5juDIhIZhfDl+0PwOQlFY= +github.com/oapi-codegen/oapi-codegen/v2 v2.8.0 h1:s4hxMxuqtR8jPzXkBTtFwY/SBuj3gEAYikmbBSdtLMM= +github.com/oapi-codegen/oapi-codegen/v2 v2.8.0/go.mod h1:yae2TI9IYB5vxQ35gFrpXh9L5H1eJv4MAUK1jumGMTo= +github.com/oapi-codegen/runtime v1.6.0 h1:7Xx+GlueD6nRuyKoCPzL434Jfi3BetbiJOrzCHp/VPU= +github.com/oapi-codegen/runtime v1.6.0/go.mod h1:GwV7hC2hviaMzj+ITfHVRESK5J2W/GefVwIND/bMGvU= +github.com/oasdiff/yaml v0.1.1 h1:6nHx+pn9gBRM6YpBlFZFQGCCd1nuvqOBtTD3KKTgGxY= +github.com/oasdiff/yaml v0.1.1/go.mod h1:EYJNoyktvWMJ0Hmhx+6qTaqMOsalUaRGT8Sj1hNcegU= +github.com/oasdiff/yaml3 v0.0.14 h1:aLJee3hxBK2H5wdXd9iPcIXb93Nty1Ge0pT171eHtkw= +github.com/oasdiff/yaml3 v0.0.14/go.mod h1:csto2xfDjYccdUn/yw/bPjj/cYTdp6HtFA0J4TWG+gg= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.10.2/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= +github.com/onsi/ginkgo v1.16.4 h1:29JGrr5oVBm5ulCWet69zQkzWipVXIol6ygQUe/EzNc= +github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0= +github.com/onsi/ginkgo/v2 v2.1.3/go.mod h1:vw5CSIxN1JObi/U8gcbwft7ZxR2dgaR70JSE3/PpL4c= +github.com/onsi/gomega v1.7.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= +github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= +github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= +github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY= +github.com/onsi/gomega v1.19.0 h1:4ieX6qQjPP/BfC3mpsAtIGGlxTWPeA3Inl/7DtXw1tw= +github.com/onsi/gomega v1.19.0/go.mod h1:LY+I3pBVzYsTBU1AnDwOSxaYi9WoWiqgwooUqq9yPro= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY= +github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/samber/do/v2 v2.1.0 h1:lqCHn05XvY3VqwxvZDQPSkH+jIGWSVHUrSVLEbPOopo= +github.com/samber/do/v2 v2.1.0/go.mod h1:wJBoiaZcUZyGuraOhfz15b517ZMogGs+U03DvnqvT6Q= +github.com/samber/go-type-to-string v1.8.0 h1:5z6tDTjtXxkIAoAuHAZYMYR8mkBZjVgeSH7jcSLqc8w= +github.com/samber/go-type-to-string v1.8.0/go.mod h1:jpU77vIDoIxkahknKDoEx9C8bQ1ADnh2sotZ8I4QqBU= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= +github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0= +github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= +github.com/shirou/gopsutil/v4 v4.26.6 h1:Mzr/npDtQC/xpeEuQKHZt8Zo9CmPvhTj8nkR8w5TLDs= +github.com/shirou/gopsutil/v4 v4.26.6/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= +github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= +github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= +github.com/speakeasy-api/jsonpath v0.6.3 h1:c+QPwzAOdrWvzycuc9HFsIZcxKIaWcNpC+xhOW9rJxU= +github.com/speakeasy-api/jsonpath v0.6.3/go.mod h1:2cXloNuQ+RSXi5HTRaeBh7JEmjRXTiaKpFTdZiL7URI= +github.com/speakeasy-api/openapi v1.24.0 h1:opoD27rupX7zBVPq1HkIGLeMOzNNA7JalhYP8q34i04= +github.com/speakeasy-api/openapi v1.24.0/go.mod h1:g3+dIMe0AYgbbGvnlQZqesmjAVWSm9BmsjLevnefQrg= +github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/testcontainers/testcontainers-go v0.44.0 h1:/Fwh6HY1mIikhnm9e7HwoxGycx0lzRAE0f5VQpjFxzI= +github.com/testcontainers/testcontainers-go v0.44.0/go.mod h1:IcnwQrYTO86xHXu5bvMaBH7ATlbS3Qn1M1QWW3c66rE= +github.com/testcontainers/testcontainers-go/modules/postgres v0.44.0 h1:8fdv/9y3JMxjQ+ULAcOG8RtgeNu5t9XF9LolSXDuTwM= +github.com/testcontainers/testcontainers-go/modules/postgres v0.44.0/go.mod h1:CFr2LncGYokw+OKjXcr8ARCKG1SaC2UEnGxFBovE86g= +github.com/tklauser/go-sysconf v0.4.0 h1:7H0uAN+7RkwWRaxhYXDLqa5V3LPrJeV8wmD9dRUgPQU= +github.com/tklauser/go-sysconf v0.4.0/go.mod h1:8mTNWyog7H+MpKijp4VmKJAd2bbYQ2zuUwkYRbUArPI= +github.com/tklauser/numcpus v0.12.0 h1:NR85qdvHA9pFse3x3weVZ0r0ST8R6l5RHbZrlRaqob4= +github.com/tklauser/numcpus v0.12.0/go.mod h1:ABHeXzJnr/qqwguhClkZKT1/8VABcYrsyUiUGobwWJg= +github.com/urfave/cli/v3 v3.10.1 h1:7Kx9H50hrHbRbyxgO1KP6/BcbiGRz0uYh5YyQ30JEEY= +github.com/urfave/cli/v3 v3.10.1/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso= +github.com/vmware-labs/yaml-jsonpath v0.3.2 h1:/5QKeCBGdsInyDCyVNLbXyilb61MXGi9NP674f9Hobk= +github.com/vmware-labs/yaml-jsonpath v0.3.2/go.mod h1:U6whw1z03QyqgWdgXxvVnQ90zN1BWz5V+51Ewf8k+rQ= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI= +go.opentelemetry.io/contrib/instrumentation/runtime v0.69.0 h1:MtkMsuRo3zEXTTMALfyrszwCDZTkB6wolyPjbwFAdq0= +go.opentelemetry.io/contrib/instrumentation/runtime v0.69.0/go.mod h1:FYTxnpsm+UPD0erZNq20GvnM8T2YQHiHtT2vokdpoac= +go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= +go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.44.0 h1:SUplec5dp06reu1zaXmOXdvqH398taqrDXqUl99jxSc= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.44.0/go.mod h1:ho2g4N+ane+swq5I/VBkKWnRDY4kUINH3FuqyZqX/Ug= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.44.0 h1:RuynHbfU8JUEw7DyONgkVYg2SVtsoF28y0LGIr69jgA= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.44.0/go.mod h1:qZF+/lBs71APw8mlnEZcqZHMzqrYrsFiJOv83lX1OGo= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 h1:4YsVu3B8+3qtWYYrsUYgn0OG78pN0rnNPRGX4SbokQI= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0/go.mod h1:+wnlSn0mD1ADVMe3v9Z/WIaiz6q6gL2J/ejaAmdmv80= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0 h1:qazEJlUOQzhCpzQpFETGby7EdqjI1wsd0W+6Gg1SCTU= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0/go.mod h1:fOD2Yefuxixkx3ahVNf0O/PERb6r4OlbxfATVnYvzCo= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0 h1:lgh3PiVrRUWMLOVSkQicxzZll5NjF1r+AtsX1XRIHw0= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0/go.mod h1:5Cnhth3m/AgOeTgE3ex12pPmiu/gGtZit03kSzx9X7s= +go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= +go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= +go.opentelemetry.io/otel/metric/x v0.66.0 h1:YkCrx1zLOChi9ZcZ6euupOcsgzbVlec7D/xoEU1+cTA= +go.opentelemetry.io/otel/metric/x v0.66.0/go.mod h1:d1+BDj9t96do0/1LoU1ayfCv79ZgNE41qbhBvnMOBZk= +go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= +go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= +go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= +go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= +go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= +go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= +go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g= +go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo= +go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= +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.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= +golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +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-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk= +golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= +golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= +golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= +golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= +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= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8= +google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ= +google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20191026110619-0b21df46bc1d/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/examples/orders-api/internal/deps/application.go b/examples/orders-api/internal/deps/application.go new file mode 100644 index 0000000..7d28db1 --- /dev/null +++ b/examples/orders-api/internal/deps/application.go @@ -0,0 +1,52 @@ +package deps + +import ( + "context" + "fmt" + + "example.com/orders-api/gen/serverhttp" + "example.com/orders-api/internal/orders" + "github.com/devctllabs/go-libs/di" + "github.com/devctllabs/go-libs/postgresdb" + "github.com/labstack/echo/v5" +) + +// application is the user-owned composition root. Add application dependencies here. +type application struct { + orders *orders.Handler +} + +func (a *application) RegisterHTTP(server *echo.Echo) { + serverhttp.RegisterHandlers(server, serverhttp.NewStrictHandler(a.orders, nil)) +} + +// provideApplication is created once. Add newly scaffolded provider calls manually. +func provideApplication(ctx context.Context, graph *di.Container, cfg *Config) error { + if err := di.Provide[HTTPRegistrar](graph, func(resolver di.Resolver) (HTTPRegistrar, error) { + reader, err := di.ResolveNamed[*postgresdb.Endpoint](resolver, storagePrimaryConnectionName+".reader") + if err != nil { + return nil, fmt.Errorf("resolve primary reader: %w", err) + } + writer, err := di.ResolveNamed[*postgresdb.Endpoint](resolver, storagePrimaryConnectionName+".writer") + if err != nil { + return nil, fmt.Errorf("resolve primary writer: %w", err) + } + store := orders.NewPostgresStore(reader, writer) + return &application{orders: orders.NewHandler(store)}, nil + }); err != nil { + return fmt.Errorf("di.Provide HTTPRegistrar: %w", err) + } + if err := provideLogging(ctx, graph, cfg); err != nil { + return fmt.Errorf("provideLogging: %w", err) + } + if err := provideTelemetry(ctx, graph, cfg); err != nil { + return fmt.Errorf("provideTelemetry: %w", err) + } + if err := provideStoragePrimary(ctx, graph, cfg); err != nil { + return fmt.Errorf("provideStoragePrimary: %w", err) + } + if err := provideRuntime(ctx, graph, cfg); err != nil { + return fmt.Errorf("provideRuntime: %w", err) + } + return nil +} diff --git a/examples/orders-api/internal/deps/config.gen.go b/examples/orders-api/internal/deps/config.gen.go new file mode 100644 index 0000000..2b7e455 --- /dev/null +++ b/examples/orders-api/internal/deps/config.gen.go @@ -0,0 +1,23 @@ +// Code generated by devctl. DO NOT EDIT. + +package deps + +import ( + "context" + "fmt" + + generatedconfig "example.com/orders-api/gen/config" + configlib "github.com/devctllabs/go-libs/config" +) + +// Config is the canonical generated runtime configuration. +type Config = generatedconfig.Config + +func loadConfig(ctx context.Context) (*Config, error) { + var cfg Config + loader := configlib.Chain(configlib.Defaults(), configlib.OSEnv()) + if err := loader.Load(ctx, &cfg); err != nil { + return nil, fmt.Errorf("loader.Load: %w", err) + } + return &cfg, nil +} diff --git a/examples/orders-api/internal/deps/container.gen.go b/examples/orders-api/internal/deps/container.gen.go new file mode 100644 index 0000000..7d30c66 --- /dev/null +++ b/examples/orders-api/internal/deps/container.gen.go @@ -0,0 +1,97 @@ +// Code generated by devctl. DO NOT EDIT. + +package deps + +import ( + "context" + "errors" + "fmt" + "github.com/devctllabs/go-libs/di" + "github.com/devctllabs/go-libs/lifecycle" + loglib "github.com/devctllabs/go-libs/log" + telemetrylib "github.com/devctllabs/go-libs/telemetry" + "go.uber.org/zap" + "go.uber.org/zap/zapcore" + "time" +) + +type dbChecker interface{ Check(context.Context) error } +type scenarioRunner interface{ Run(context.Context) error } + +// Scenario owns one lazily resolved runnable branch and its dependency graph. +type Scenario struct { + graph *di.Container + tasks []lifecycle.Task +} + +// Run coordinates the selected branch until cancellation or failure. +func (s *Scenario) Run(ctx context.Context) error { + return lifecycle.Run(ctx, lifecycle.Config{ + ShutdownTimeout: 30 * time.Second, + Shutdown: s.Shutdown, + Tasks: s.tasks, + }) +} + +// Shutdown closes only resources constructed by this Scenario. +func (s *Scenario) Shutdown(ctx context.Context) error { + if s == nil || s.graph == nil { + return nil + } + if err := s.graph.Shutdown(ctx); err != nil { + return fmt.Errorf("graph.Shutdown: %w", err) + } + return nil +} + +func newScenarioGraph(ctx context.Context) (*di.Container, *Config, error) { + cfg, err := loadConfig(ctx) + if err != nil { + return nil, nil, fmt.Errorf("loadConfig: %w", err) + } + graph := di.New() + if err := di.ProvideValue(graph, cfg); err != nil { + return nil, nil, fmt.Errorf("di.ProvideValue: %w", err) + } + if err := provideApplication(ctx, graph, cfg); err != nil { + shutdownErr := graph.Shutdown(context.WithoutCancel(ctx)) + return nil, nil, errors.Join(fmt.Errorf("provideApplication: %w", err), shutdownErr) + } + return graph, cfg, nil +} +func provideLogging(_ context.Context, graph *di.Container, cfg *Config) error { + return di.ProvideResource(graph, func(di.Resolver) (*zap.Logger, error) { + level := zapcore.InfoLevel + if err := level.Set(cfg.Logging.Level); err != nil { + return nil, fmt.Errorf("logging level: %w", err) + } + return loglib.New(level, false).Named("orders-api"), nil + }, func(_ context.Context, value *zap.Logger) error { _ = value.Sync(); return nil }) +} +func provideTelemetry(ctx context.Context, graph *di.Container, cfg *Config) error { + return di.ProvideResource(graph, func(di.Resolver) (*telemetrylib.Runtime, error) { + value, err := telemetrylib.Open(ctx, telemetrylib.Config{ + Enabled: cfg.Telemetry.Enabled, + ServiceName: "orders-api", + ServiceVersion: cfg.Telemetry.ServiceVersion, + DeploymentEnvironment: cfg.Telemetry.DeploymentEnvironment, + }) + if err != nil { + return nil, fmt.Errorf("telemetrylib.Open: %w", err) + } + return value, nil + }, func(ctx context.Context, value *telemetrylib.Runtime) error { return value.Shutdown(ctx) }) +} + +// NewAPI resolves only the API Runtime branch. +func NewAPI(ctx context.Context) (*Scenario, error) { + graph, _, err := newScenarioGraph(ctx) + if err != nil { + return nil, err + } + runtime, err := di.Resolve[*Runtime](graph) + if err != nil { + return nil, errors.Join(fmt.Errorf("di.Resolve Runtime: %w", err), graph.Shutdown(context.WithoutCancel(ctx))) + } + return &Scenario{graph: graph, tasks: runtime.Tasks()}, nil +} diff --git a/examples/orders-api/internal/deps/runtime.gen.go b/examples/orders-api/internal/deps/runtime.gen.go new file mode 100644 index 0000000..c0a721e --- /dev/null +++ b/examples/orders-api/internal/deps/runtime.gen.go @@ -0,0 +1,131 @@ +// Code generated by devctl. DO NOT EDIT. + +package deps + +import ( + "context" + "errors" + "fmt" + + debugserverlib "github.com/devctllabs/go-libs/debugserver" + "github.com/devctllabs/go-libs/di" + healthlib "github.com/devctllabs/go-libs/health" + healthserverlib "github.com/devctllabs/go-libs/healthserver" + "github.com/devctllabs/go-libs/lifecycle" + "github.com/labstack/echo/v5" + "net/http" + "time" +) + +// HTTPRegistrar is implemented by user-owned application composition. +type HTTPRegistrar interface{ RegisterHTTP(*echo.Echo) } + +// Runtime owns the optional long-lived components selected by configuration. +type Runtime struct { + http *http.Server + httpEnabled bool + health *healthserverlib.Server + healthEnabled bool + pprof *debugserverlib.Server + pprofEnabled bool +} + +func newRuntime(resolver di.Resolver, cfg *Config) (*Runtime, error) { + httpRegistrar, err := di.Resolve[HTTPRegistrar](resolver) + if err != nil { + return nil, fmt.Errorf("di.Resolve HTTPRegistrar: %w", err) + } + options := make([]healthlib.Option, 0, 1) + { + checker, err := di.ResolveNamed[dbChecker](resolver, "db-connection:primary") + if err != nil { + return nil, fmt.Errorf("di.ResolveNamed: %w", err) + } + options = append(options, healthlib.NonCritical("db.primary", checker)) + } + return NewRuntime(cfg, httpRegistrar, options...) +} + +func NewRuntime(cfg *Config, httpRegistrar HTTPRegistrar, options ...healthlib.Option) (*Runtime, error) { + if cfg == nil { + return nil, errors.New("config is nil") + } + runtime := &Runtime{} + httpRouter := echo.New() + httpRegistrar.RegisterHTTP(httpRouter) + runtime.http = &http.Server{Addr: cfg.HTTP.Address, Handler: httpRouter, ReadHeaderTimeout: 2 * time.Second, IdleTimeout: 30 * time.Second} + runtime.httpEnabled = cfg.HTTP.Enabled + probes, err := healthlib.New(options...) + if err != nil { + return nil, fmt.Errorf("healthlib.New: %w", err) + } + runtime.health, err = healthserverlib.NewServer(probes, healthserverlib.WithAddress(cfg.Health.Address)) + if err != nil { + return nil, fmt.Errorf("healthserverlib.NewServer: %w", err) + } + runtime.healthEnabled = cfg.Health.Enabled + pprofServer, err := debugserverlib.NewServer(debugserverlib.WithAddress(cfg.Pprof.Address)) + if err != nil { + return nil, fmt.Errorf("debugserverlib.NewServer: %w", err) + } + runtime.pprof = pprofServer + runtime.pprofEnabled = cfg.Pprof.Enabled + return runtime, nil +} + +func provideRuntime(_ context.Context, graph *di.Container, cfg *Config) error { + return di.ProvideResource(graph, func(resolver di.Resolver) (*Runtime, error) { + return newRuntime(resolver, cfg) + }, func(ctx context.Context, value *Runtime) error { return value.Shutdown(ctx) }) +} + +// Tasks returns enabled runtime roots in deterministic startup order. +func (r *Runtime) Tasks() []lifecycle.Task { + tasks := make([]lifecycle.Task, 0, 3) + if r.httpEnabled { + tasks = append(tasks, lifecycle.Task{Name: "http", Run: func(context.Context) error { + if err := r.http.ListenAndServe(); err != nil { + return fmt.Errorf("r.http.ListenAndServe: %w", err) + } + return nil + }}) + } + if r.healthEnabled { + tasks = append(tasks, lifecycle.Task{Name: "health", Run: func(context.Context) error { + if err := r.health.ListenAndServe(); err != nil { + return fmt.Errorf("r.health.ListenAndServe: %w", err) + } + return nil + }}) + } + if r.pprofEnabled { + tasks = append(tasks, lifecycle.Task{Name: "pprof", Run: func(context.Context) error { + if err := r.pprof.ListenAndServe(); err != nil { + return fmt.Errorf("r.pprof.ListenAndServe: %w", err) + } + return nil + }}) + } + return tasks +} + +// Shutdown stops every constructed runtime component and joins cleanup failures. +func (r *Runtime) Shutdown(ctx context.Context) error { + var shutdownErrors []error + if r.http != nil { + if err := r.http.Shutdown(ctx); err != nil { + shutdownErrors = append(shutdownErrors, fmt.Errorf("r.http.Shutdown: %w", err)) + } + } + if r.health != nil { + if err := r.health.Shutdown(ctx); err != nil { + shutdownErrors = append(shutdownErrors, fmt.Errorf("r.health.Shutdown: %w", err)) + } + } + if r.pprof != nil { + if err := r.pprof.Shutdown(ctx); err != nil { + shutdownErrors = append(shutdownErrors, fmt.Errorf("r.pprof.Shutdown: %w", err)) + } + } + return errors.Join(shutdownErrors...) +} diff --git a/examples/orders-api/internal/deps/storage_primary.gen.go b/examples/orders-api/internal/deps/storage_primary.gen.go new file mode 100644 index 0000000..d0567e6 --- /dev/null +++ b/examples/orders-api/internal/deps/storage_primary.gen.go @@ -0,0 +1,109 @@ +// Code generated by devctl. DO NOT EDIT. + +package deps + +import ( + "context" + "errors" + "fmt" + + "github.com/devctllabs/go-libs/di" + postgresdb "github.com/devctllabs/go-libs/postgresdb" + telemetrylib "github.com/devctllabs/go-libs/telemetry" + "github.com/devctllabs/go-libs/txmanager" +) + +const storagePrimaryConnectionName = "db-connection:primary" + +type storagePrimary struct { + postgres *postgresdb.DB +} + +func provideStoragePrimary(ctx context.Context, graph *di.Container, cfg *Config) error { + if err := di.ProvideNamedResource[*storagePrimary](graph, storagePrimaryConnectionName, func(resolver di.Resolver) (*storagePrimary, error) { + return openStoragePrimary(ctx, resolver, cfg) + }, func(_ context.Context, value *storagePrimary) error { return value.close() }); err != nil { + return fmt.Errorf("di.ProvideNamedResource: %w", err) + } + if err := di.ProvideNamed[*postgresdb.Endpoint](graph, storagePrimaryConnectionName+".reader", func(resolver di.Resolver) (*postgresdb.Endpoint, error) { + storage, err := di.ResolveNamed[*storagePrimary](resolver, storagePrimaryConnectionName) + if err != nil { + return nil, fmt.Errorf("di.ResolveNamed: %w", err) + } + if storage.postgres == nil { + return nil, fmt.Errorf("storage primary does not use postgres") + } + return storage.postgres.Reader(), nil + }); err != nil { + return fmt.Errorf("di.ProvideNamed reader: %w", err) + } + if err := di.ProvideNamed[*postgresdb.Endpoint](graph, storagePrimaryConnectionName+".writer", func(resolver di.Resolver) (*postgresdb.Endpoint, error) { + storage, err := di.ResolveNamed[*storagePrimary](resolver, storagePrimaryConnectionName) + if err != nil { + return nil, fmt.Errorf("di.ResolveNamed: %w", err) + } + if storage.postgres == nil { + return nil, fmt.Errorf("storage primary does not use postgres") + } + return storage.postgres.Writer(), nil + }); err != nil { + return fmt.Errorf("di.ProvideNamed writer: %w", err) + } + if err := di.ProvideNamed[txmanager.Managers](graph, storagePrimaryConnectionName, func(resolver di.Resolver) (txmanager.Managers, error) { + storage, err := di.ResolveNamed[*storagePrimary](resolver, storagePrimaryConnectionName) + if err != nil { + return nil, fmt.Errorf("di.ResolveNamed: %w", err) + } + return storage.managers(), nil + }); err != nil { + return fmt.Errorf("di.ProvideNamed tx managers: %w", err) + } + return di.ProvideNamed[dbChecker](graph, storagePrimaryConnectionName, func(resolver di.Resolver) (dbChecker, error) { + storage, err := di.ResolveNamed[*storagePrimary](resolver, storagePrimaryConnectionName) + if err != nil { + return nil, fmt.Errorf("di.ResolveNamed: %w", err) + } + return storage.checker(), nil + }) +} + +func openStoragePrimary(ctx context.Context, resolver di.Resolver, cfg *Config) (*storagePrimary, error) { + telemetryRuntime, err := di.Resolve[*telemetrylib.Runtime](resolver) + if err != nil { + return nil, fmt.Errorf("di.Resolve: %w", err) + } + switch cfg.DBPrimary.Kind { + case "postgres": + db, err := postgresdb.Open(ctx, postgresdb.Config{Writer: postgresdb.EndpointConfig{DSN: cfg.DBPrimary.PostgresDSN}, Telemetry: postgresdb.Telemetry{TracerProvider: telemetryRuntime.TracerProvider(), MeterProvider: telemetryRuntime.MeterProvider()}}) + if err != nil { + return nil, fmt.Errorf("postgresdb.Open: %w", err) + } + return &storagePrimary{postgres: db}, nil + default: + return nil, fmt.Errorf("unsupported primary database kind %q", cfg.DBPrimary.Kind) + } +} + +func (s *storagePrimary) managers() txmanager.Managers { + if s.postgres != nil { + return s.postgres.TxManagers() + } + return nil +} + +func (s *storagePrimary) checker() dbChecker { + if s.postgres != nil { + return s.postgres.Writer() + } + return nil +} + +func (s *storagePrimary) close() error { + var closeErrors []error + if s.postgres != nil { + if err := s.postgres.Close(); err != nil { + closeErrors = append(closeErrors, fmt.Errorf("close postgres: %w", err)) + } + } + return errors.Join(closeErrors...) +} diff --git a/examples/orders-api/internal/orders/handler_test.go b/examples/orders-api/internal/orders/handler_test.go new file mode 100644 index 0000000..01e9ddd --- /dev/null +++ b/examples/orders-api/internal/orders/handler_test.go @@ -0,0 +1,49 @@ +package orders + +import ( + "context" + "testing" + "time" + + "example.com/orders-api/gen/serverhttp" + "github.com/stretchr/testify/require" +) + +type stubStore struct { + created Order + got Order + err error +} + +func (s *stubStore) Create(context.Context, string, int64) (Order, error) { + return s.created, s.err +} + +func (s *stubStore) Get(context.Context, int64) (Order, error) { + return s.got, s.err +} + +func TestHandlerCreatesOrder(t *testing.T) { + t.Parallel() + + want := Order{ID: 7, CustomerName: "Ada", TotalCents: 1250, CreatedAt: time.Unix(1, 0).UTC()} + handler := NewHandler(&stubStore{created: want}) + + response, err := handler.CreateOrder(t.Context(), serverhttp.CreateOrderRequestObject{ + Body: &serverhttp.CreateOrder{CustomerName: "Ada", TotalCents: 1250}, + }) + + require.NoError(t, err) + require.Equal(t, serverhttp.CreateOrder201JSONResponse(toAPIOrder(want)), response) +} + +func TestHandlerReturnsNotFound(t *testing.T) { + t.Parallel() + + handler := NewHandler(&stubStore{err: ErrNotFound}) + + response, err := handler.GetOrder(t.Context(), serverhttp.GetOrderRequestObject{Id: 404}) + + require.NoError(t, err) + require.Equal(t, serverhttp.GetOrder404JSONResponse{Message: "order not found"}, response) +} diff --git a/examples/orders-api/internal/orders/orders.go b/examples/orders-api/internal/orders/orders.go new file mode 100644 index 0000000..94f6607 --- /dev/null +++ b/examples/orders-api/internal/orders/orders.go @@ -0,0 +1,115 @@ +package orders + +import ( + "context" + "errors" + "fmt" + "time" + + "example.com/orders-api/gen/serverhttp" + "github.com/devctllabs/go-libs/postgresdb" + "github.com/jackc/pgx/v5" +) + +var ErrNotFound = errors.New("order not found") + +type Order struct { + ID int64 + CustomerName string + TotalCents int64 + CreatedAt time.Time +} + +type Store interface { + Create(ctx context.Context, customerName string, totalCents int64) (Order, error) + Get(ctx context.Context, id int64) (Order, error) +} + +type PostgresStore struct { + reader *postgresdb.Endpoint + writer *postgresdb.Endpoint +} + +func NewPostgresStore(reader, writer *postgresdb.Endpoint) *PostgresStore { + return &PostgresStore{reader: reader, writer: writer} +} + +func (s *PostgresStore) Create(ctx context.Context, customerName string, totalCents int64) (Order, error) { + const query = ` + INSERT INTO orders (customer_name, total_cents) + VALUES ($1, $2) + RETURNING id, customer_name, total_cents, created_at` + + var order Order + err := s.writer.QueryRow(ctx, query, customerName, totalCents).Scan( + &order.ID, + &order.CustomerName, + &order.TotalCents, + &order.CreatedAt, + ) + if err != nil { + return Order{}, fmt.Errorf("insert order: %w", err) + } + return order, nil +} + +func (s *PostgresStore) Get(ctx context.Context, id int64) (Order, error) { + const query = ` + SELECT id, customer_name, total_cents, created_at + FROM orders + WHERE id = $1` + + var order Order + err := s.reader.QueryRow(ctx, query, id).Scan( + &order.ID, + &order.CustomerName, + &order.TotalCents, + &order.CreatedAt, + ) + if errors.Is(err, pgx.ErrNoRows) { + return Order{}, ErrNotFound + } + if err != nil { + return Order{}, fmt.Errorf("select order: %w", err) + } + return order, nil +} + +type Handler struct { + store Store +} + +func NewHandler(store Store) *Handler { + return &Handler{store: store} +} + +func (h *Handler) CreateOrder(ctx context.Context, request serverhttp.CreateOrderRequestObject) (serverhttp.CreateOrderResponseObject, error) { + if request.Body == nil { + return nil, errors.New("create order body is required") + } + order, err := h.store.Create(ctx, request.Body.CustomerName, request.Body.TotalCents) + if err != nil { + return nil, err + } + return serverhttp.CreateOrder201JSONResponse(toAPIOrder(order)), nil +} + +func (h *Handler) GetOrder(ctx context.Context, request serverhttp.GetOrderRequestObject) (serverhttp.GetOrderResponseObject, error) { + order, err := h.store.Get(ctx, request.Id) + if errors.Is(err, ErrNotFound) { + return serverhttp.GetOrder404JSONResponse{Message: "order not found"}, nil + } + if err != nil { + return nil, err + } + return serverhttp.GetOrder200JSONResponse(toAPIOrder(order)), nil +} + +func toAPIOrder(order Order) serverhttp.Order { + return serverhttp.Order{ + Id: order.ID, + CustomerName: order.CustomerName, + TotalCents: order.TotalCents, + CreatedAt: order.CreatedAt, + } +} diff --git a/examples/orders-api/migrations/primary/postgres/.gitkeep b/examples/orders-api/migrations/primary/postgres/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/examples/orders-api/migrations/primary/postgres/000001_create_orders.down.sql b/examples/orders-api/migrations/primary/postgres/000001_create_orders.down.sql new file mode 100644 index 0000000..1ba41c1 --- /dev/null +++ b/examples/orders-api/migrations/primary/postgres/000001_create_orders.down.sql @@ -0,0 +1 @@ +DROP TABLE orders; diff --git a/examples/orders-api/migrations/primary/postgres/000001_create_orders.up.sql b/examples/orders-api/migrations/primary/postgres/000001_create_orders.up.sql new file mode 100644 index 0000000..cc63f74 --- /dev/null +++ b/examples/orders-api/migrations/primary/postgres/000001_create_orders.up.sql @@ -0,0 +1,6 @@ +CREATE TABLE orders ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + customer_name text NOT NULL, + total_cents bigint NOT NULL CHECK (total_cents >= 0), + created_at timestamptz NOT NULL DEFAULT now() +); diff --git a/examples/orders-api/tools/oapi/server.yaml b/examples/orders-api/tools/oapi/server.yaml new file mode 100644 index 0000000..45c9a22 --- /dev/null +++ b/examples/orders-api/tools/oapi/server.yaml @@ -0,0 +1,6 @@ +package: serverhttp +generate: + models: true + echo5-server: true + strict-server: true + embedded-spec: true diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..2a3787b --- /dev/null +++ b/go.mod @@ -0,0 +1,58 @@ +module github.com/devctllabs/devctl + +go 1.26.0 + +require ( + github.com/BurntSushi/toml v1.6.0 + github.com/devctllabs/go-libs/di v0.1.0 + github.com/devctllabs/go-libs/filesystem v0.2.0 + github.com/devctllabs/go-libs/lifecycle v0.2.0 + github.com/devctllabs/go-libs/log v0.2.0 + github.com/pb33f/libopenapi v0.38.6 + github.com/pb33f/libopenapi-validator v0.14.0 + github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 + github.com/stretchr/testify v1.11.1 + github.com/urfave/cli-docs/v3 v3.1.0 + github.com/urfave/cli/v3 v3.10.1 + go.uber.org/mock v0.6.0 + go.uber.org/zap v1.28.0 + golang.org/x/mod v0.38.0 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/bahlo/generic-list-go v0.2.0 // indirect + github.com/basgys/goxml2json v1.1.1-0.20231018121955-e66ee54ceaad // indirect + github.com/buger/jsonparser v1.1.2 // indirect + github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 // indirect + github.com/getkin/kin-openapi v0.142.0 // indirect + github.com/go-openapi/jsonpointer v0.23.2 // indirect + github.com/go-openapi/swag/jsonname v0.26.1 // indirect + github.com/google/go-cmp v0.7.0 // indirect + github.com/oapi-codegen/oapi-codegen/v2 v2.8.0 // indirect + github.com/oasdiff/yaml v0.1.1 // indirect + github.com/oasdiff/yaml3 v0.0.14 // indirect + github.com/pb33f/jsonpath v0.8.2 // indirect + github.com/pb33f/ordered-map/v2 v2.3.1 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/russross/blackfriday/v2 v2.1.0 // indirect + github.com/samber/do/v2 v2.1.0 // indirect + github.com/samber/go-type-to-string v1.8.0 // indirect + github.com/speakeasy-api/jsonpath v0.6.3 // indirect + github.com/speakeasy-api/openapi v1.24.0 // indirect + github.com/vmware-labs/yaml-jsonpath v0.3.2 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + go.yaml.in/yaml/v4 v4.0.0-rc.6 // indirect + golang.org/x/net v0.57.0 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/text v0.40.0 // indirect + golang.org/x/tools v0.48.0 // indirect +) + +tool ( + github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen + go.uber.org/mock/mockgen +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..2ede17b --- /dev/null +++ b/go.sum @@ -0,0 +1,247 @@ +github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= +github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= +github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= +github.com/basgys/goxml2json v1.1.1-0.20231018121955-e66ee54ceaad h1:3swAvbzgfaI6nKuDDU7BiKfZRdF+h2ZwKgMHd8Ha4t8= +github.com/basgys/goxml2json v1.1.1-0.20231018121955-e66ee54ceaad/go.mod h1:9+nBLYNWkvPcq9ep0owWUsPTLgL9ZXTsZWcCSVGGLJ0= +github.com/bitly/go-simplejson v0.5.1 h1:xgwPbetQScXt1gh9BmoJ6j9JMr3TElvuIyjR8pgdoow= +github.com/bitly/go-simplejson v0.5.1/go.mod h1:YOPVLzCfwK14b4Sff3oP1AmGhI9T9Vsg84etUnlyp+Q= +github.com/buger/jsonparser v1.1.2 h1:frqHqw7otoVbk5M8LlE/L7HTnIq2v9RX6EJ48i9AxJk= +github.com/buger/jsonparser v1.1.2/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= +github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= +github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= +github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= +github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w= +github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/devctllabs/go-libs/di v0.1.0 h1:pPhlKmdYyvKVYr/5eV51Ea9HcfqsqWCs3GZKG6jFGW8= +github.com/devctllabs/go-libs/di v0.1.0/go.mod h1:kr9mSKElEmsDHgKsZbDGJhSCdmlcluT81PjbCvcOmbY= +github.com/devctllabs/go-libs/filesystem v0.2.0 h1:PdPKAoFy90RoH0+8qOSvJnyr8ASj0L1RiFngBe/2bns= +github.com/devctllabs/go-libs/filesystem v0.2.0/go.mod h1:XjGXmwFAmeRMknEchaEtj5jxQ1wwtJj2xPiwEZB2Yz4= +github.com/devctllabs/go-libs/lifecycle v0.2.0 h1:vafo21o5tjrU3Qf4tVa0a2A6a8NQPSR4blpHiTQU3O4= +github.com/devctllabs/go-libs/lifecycle v0.2.0/go.mod h1:/m8kzixQx7IhAi3jx7hFfICu96VxBKNuQxHd+tY6VIY= +github.com/devctllabs/go-libs/log v0.2.0 h1:RICLkubslpX8CGSw/m+i2FxrRo64PzhE7hVFQXlwReM= +github.com/devctllabs/go-libs/log v0.2.0/go.mod h1:KfCsyQUkit4D2kounIhY6AauWB/Lo5SMiHZZXaTtMJ4= +github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8= +github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/dprotaso/go-yit v0.0.0-20191028211022-135eb7262960/go.mod h1:9HQzr9D/0PGwMEbC3d5AB7oi67+h4TsQqItC1GVYG58= +github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 h1:PRxIJD8XjimM5aTknUK9w6DHLDox2r2M3DI4i2pnd3w= +github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936/go.mod h1:ttYvX5qlB+mlV1okblJqcSMtR4c52UKxDiX9GRBS8+Q= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= +github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= +github.com/getkin/kin-openapi v0.142.0 h1:izj0vBdFprMhitfzaX8sTqztsEQyvwhssBoB6n8NO7w= +github.com/getkin/kin-openapi v0.142.0/go.mod h1:3BH9M9XDe/y9M5DSvEocVYAYq1w0qrhJHjC/vZi0AaY= +github.com/go-openapi/jsonpointer v0.23.2 h1:DK7R/3zAt4xTytxNkw7jARGPFI7rkaSsii58n8X45x0= +github.com/go-openapi/jsonpointer v0.23.2/go.mod h1:noUOckXtq7b4bVkqw0sbHKieq9uEZRN7p6EF/dalc4w= +github.com/go-openapi/swag/jsonname v0.26.1 h1:VReupaV6WxlAsCn0e4DUfgV6bPmINnPpyJDLqSfNPcE= +github.com/go-openapi/swag/jsonname v0.26.1/go.mod h1:OvdW6BoWoj33pTfi7x9vFrgmT+fk7aw0BRwvCE0YOuc= +github.com/go-openapi/testify/v2 v2.6.0 h1:5PKH2HE7YJ/LuRPQGvSxBRlFXNQhSetBLlGAgUEu3ug= +github.com/go-openapi/testify/v2 v2.6.0/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw= +github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= +github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= +github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= +github.com/oapi-codegen/oapi-codegen/v2 v2.8.0 h1:s4hxMxuqtR8jPzXkBTtFwY/SBuj3gEAYikmbBSdtLMM= +github.com/oapi-codegen/oapi-codegen/v2 v2.8.0/go.mod h1:yae2TI9IYB5vxQ35gFrpXh9L5H1eJv4MAUK1jumGMTo= +github.com/oasdiff/yaml v0.1.1 h1:6nHx+pn9gBRM6YpBlFZFQGCCd1nuvqOBtTD3KKTgGxY= +github.com/oasdiff/yaml v0.1.1/go.mod h1:EYJNoyktvWMJ0Hmhx+6qTaqMOsalUaRGT8Sj1hNcegU= +github.com/oasdiff/yaml3 v0.0.14 h1:aLJee3hxBK2H5wdXd9iPcIXb93Nty1Ge0pT171eHtkw= +github.com/oasdiff/yaml3 v0.0.14/go.mod h1:csto2xfDjYccdUn/yw/bPjj/cYTdp6HtFA0J4TWG+gg= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.10.2/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= +github.com/onsi/ginkgo v1.16.4 h1:29JGrr5oVBm5ulCWet69zQkzWipVXIol6ygQUe/EzNc= +github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0= +github.com/onsi/ginkgo/v2 v2.1.3/go.mod h1:vw5CSIxN1JObi/U8gcbwft7ZxR2dgaR70JSE3/PpL4c= +github.com/onsi/gomega v1.7.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= +github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= +github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= +github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY= +github.com/onsi/gomega v1.19.0 h1:4ieX6qQjPP/BfC3mpsAtIGGlxTWPeA3Inl/7DtXw1tw= +github.com/onsi/gomega v1.19.0/go.mod h1:LY+I3pBVzYsTBU1AnDwOSxaYi9WoWiqgwooUqq9yPro= +github.com/pb33f/jsonpath v0.8.2 h1:Ou4C7zjYClBm97dfZjDCjdZGusJoynv/vrtiEKNfj2Y= +github.com/pb33f/jsonpath v0.8.2/go.mod h1:zBV5LJW4OQOPatmQE2QdKpGQJvhDTlE5IEj6ASaRNTo= +github.com/pb33f/libopenapi v0.38.6 h1:rnLshOSCSLx3JmN/MnftyYcdN1FfaeRkbaBE/ZHdIXg= +github.com/pb33f/libopenapi v0.38.6/go.mod h1:8yHl64vr+ICrnzSgiwJmZ54heRqCqrhI/JL1ge+CPIY= +github.com/pb33f/libopenapi-validator v0.14.0 h1:K9cdv1kL6cZdQmSbzitkVcWCJKyRmmSZryZqgUqIv+Y= +github.com/pb33f/libopenapi-validator v0.14.0/go.mod h1:EU6yVajX6rWwUAC4OWfsVPPwAZ7k29fruoZnMnzdKjI= +github.com/pb33f/ordered-map/v2 v2.3.1 h1:5319HDO0aw4DA4gzi+zv4FXU9UlSs3xGZ40wcP1nBjY= +github.com/pb33f/ordered-map/v2 v2.3.1/go.mod h1:qxFQgd0PkVUtOMCkTapqotNgzRhMPL7VvaHKbd1HnmQ= +github.com/pb33f/testify v0.1.0 h1:g48/HDU/jn2COspS4nM0scptxiKTJ4DnbX/4ehK6IZ8= +github.com/pb33f/testify v0.1.0/go.mod h1:nq283P/jJ8hXMmdhAqfj7BJIz0y+6IOHj9q0044rKt4= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/samber/do/v2 v2.1.0 h1:lqCHn05XvY3VqwxvZDQPSkH+jIGWSVHUrSVLEbPOopo= +github.com/samber/do/v2 v2.1.0/go.mod h1:wJBoiaZcUZyGuraOhfz15b517ZMogGs+U03DvnqvT6Q= +github.com/samber/go-type-to-string v1.8.0 h1:5z6tDTjtXxkIAoAuHAZYMYR8mkBZjVgeSH7jcSLqc8w= +github.com/samber/go-type-to-string v1.8.0/go.mod h1:jpU77vIDoIxkahknKDoEx9C8bQ1ADnh2sotZ8I4QqBU= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= +github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0= +github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= +github.com/speakeasy-api/jsonpath v0.6.3 h1:c+QPwzAOdrWvzycuc9HFsIZcxKIaWcNpC+xhOW9rJxU= +github.com/speakeasy-api/jsonpath v0.6.3/go.mod h1:2cXloNuQ+RSXi5HTRaeBh7JEmjRXTiaKpFTdZiL7URI= +github.com/speakeasy-api/openapi v1.24.0 h1:opoD27rupX7zBVPq1HkIGLeMOzNNA7JalhYP8q34i04= +github.com/speakeasy-api/openapi v1.24.0/go.mod h1:g3+dIMe0AYgbbGvnlQZqesmjAVWSm9BmsjLevnefQrg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/urfave/cli-docs/v3 v3.1.0 h1:Sa5xm19IpE5gpm6tZzXdfjdFxn67PnEsE4dpXF7vsKw= +github.com/urfave/cli-docs/v3 v3.1.0/go.mod h1:59d+5Hz1h6GSGJ10cvcEkbIe3j233t4XDqI72UIx7to= +github.com/urfave/cli/v3 v3.10.1 h1:7Kx9H50hrHbRbyxgO1KP6/BcbiGRz0uYh5YyQ30JEEY= +github.com/urfave/cli/v3 v3.10.1/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso= +github.com/vmware-labs/yaml-jsonpath v0.3.2 h1:/5QKeCBGdsInyDCyVNLbXyilb61MXGi9NP674f9Hobk= +github.com/vmware-labs/yaml-jsonpath v0.3.2/go.mod h1:U6whw1z03QyqgWdgXxvVnQ90zN1BWz5V+51Ewf8k+rQ= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo= +go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +go.yaml.in/yaml/v4 v4.0.0-rc.6 h1:1h7H1ohdUh93/FyE4YaDa1Zh64K6VVbjF4K6WUxMtH4= +go.yaml.in/yaml/v4 v4.0.0-rc.6/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0= +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.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.11.0/go.mod h1:xgJhtzW8F9jGdVFWZESrid1U1bjeNy4zgy5cRr/CIio= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= +golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +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-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk= +golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.13.0/go.mod h1:zEVYFnQC7m/vmpQFELhcD1EWkZlX69l4oqgmer6hfKA= +golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= +golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.10.0/go.mod h1:lpqdcUyK/oCiQxvxVrppt5ggO2KCZ5QblwqPnfZ6d5o= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= +golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= +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= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20191026110619-0b21df46bc1d/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/client/bufgen/client.go b/internal/client/bufgen/client.go new file mode 100644 index 0000000..33f70e0 --- /dev/null +++ b/internal/client/bufgen/client.go @@ -0,0 +1,106 @@ +package bufgen + +import ( + "context" + "fmt" + + "github.com/devctllabs/devctl/internal/client/toolrun" + "github.com/devctllabs/devctl/internal/client/toolsafety" + generatedomain "github.com/devctllabs/devctl/internal/domain/generate" + projectdomain "github.com/devctllabs/devctl/internal/domain/project" + "golang.org/x/mod/modfile" +) + +// Client runs the project-declared Buf tool in a temporary output workspace. +type Client struct { + runner toolrun.Runner +} + +func New(runner toolrun.Runner) *Client { return &Client{runner: runner} } + +// Generate returns unpublished generated Proto output. +func (c *Client) Generate( + ctx context.Context, + project projectdomain.Project, + target projectdomain.Target, +) (generatedomain.Output, error) { + if err := ctx.Err(); err != nil { + return generatedomain.Output{}, fmt.Errorf("ctx.Err: %w", err) + } + if target.Family != "grpc" && target.Family != "kafka" { + return generatedomain.Output{}, fmt.Errorf("unsupported generation target kind %q", target.Family) + } + if err := toolsafety.RequireDirectory(project.Root, target.Input); err != nil { + return generatedomain.Output{}, fmt.Errorf("toolsafety.RequireDirectory input: %w", err) + } + if _, err := toolsafety.ReadRegularFile(project.Root, target.Config); err != nil { + return generatedomain.Output{}, fmt.Errorf("toolsafety.ReadRegularFile config: %w", err) + } + if err := requireBufTool(project.Root); err != nil { + return generatedomain.Output{}, err + } + + output, err := toolrun.WithTemporaryOutput(ctx, "devctl-buf-generate-", func(temporary string) (generatedomain.Output, error) { + arguments := []string{"tool", "buf", "generate", target.Input, "--template", target.Config} + for _, selectedPath := range target.Paths { + arguments = append(arguments, "--path", selectedPath) + } + arguments = append(arguments, "--output", temporary) + if err := c.runner.Run(ctx, toolrun.Command{Name: "go", Args: arguments, Dir: project.Root}); err != nil { + return generatedomain.Output{}, fmt.Errorf("runner.Run: %w", err) + } + tree, err := toolsafety.ReadRegularTree(temporary, ".") + if err != nil { + return generatedomain.Output{}, fmt.Errorf("toolsafety.ReadRegularTree: %w", err) + } + if len(tree.Files) == 0 { + return generatedomain.Output{}, fmt.Errorf("buf produced no generated files") + } + return generatedomain.Output{Directory: tree}, nil + }) + if err != nil { + return output, fmt.Errorf("toolrun.WithTemporaryOutput: %w", err) + } + return output, nil +} + +// Lint checks one Proto-backed target with the project-declared Buf tool. +func (c *Client) Lint(ctx context.Context, project projectdomain.Project, target projectdomain.Target) error { + if err := ctx.Err(); err != nil { + return fmt.Errorf("ctx.Err: %w", err) + } + if target.Family != "grpc" && target.Family != "kafka" { + return fmt.Errorf("unsupported lint target kind %q", target.Family) + } + if err := toolsafety.RequireDirectory(project.Root, target.Input); err != nil { + return fmt.Errorf("toolsafety.RequireDirectory input: %w", err) + } + if err := requireBufTool(project.Root); err != nil { + return err + } + arguments := []string{"tool", "buf", "lint", target.Input} + for _, selectedPath := range target.Paths { + arguments = append(arguments, "--path", selectedPath) + } + if err := c.runner.Run(ctx, toolrun.Command{Name: "go", Args: arguments, Dir: project.Root}); err != nil { + return fmt.Errorf("runner.Run: %w", err) + } + return nil +} + +func requireBufTool(root string) error { + goMod, err := toolsafety.ReadRegularFile(root, "go.mod") + if err != nil { + return fmt.Errorf("toolsafety.ReadRegularFile go.mod: %w", err) + } + parsed, err := modfile.Parse("go.mod", goMod.Content, nil) + if err != nil { + return fmt.Errorf("modfile.Parse: %w", err) + } + for _, tool := range parsed.Tool { + if tool.Path == "github.com/bufbuild/buf/cmd/buf" { + return nil + } + } + return fmt.Errorf("go.mod does not declare the Buf tool") +} diff --git a/internal/client/bufgen/client_test.go b/internal/client/bufgen/client_test.go new file mode 100644 index 0000000..629cd3d --- /dev/null +++ b/internal/client/bufgen/client_test.go @@ -0,0 +1,40 @@ +package bufgen_test + +import ( + "context" + "errors" + "os" + "path/filepath" + "testing" + + bufgen "github.com/devctllabs/devctl/internal/client/bufgen" + "github.com/devctllabs/devctl/internal/client/toolrun" + projectdomain "github.com/devctllabs/devctl/internal/domain/project" + "github.com/stretchr/testify/require" +) + +func TestClientPreservesBufRunnerFailure(t *testing.T) { + t.Parallel() + + root := t.TempDir() + writeFixture(t, root, "go.mod", "module example.test/service\n\ngo 1.25\n\ntool github.com/bufbuild/buf/cmd/buf\n") + writeFixture(t, root, "api/proto/acme/v1/service.proto", "syntax = \"proto3\";\npackage acme.v1;\n") + writeFixture(t, root, "tools/buf/grpc.gen.yaml", "version: v2\nplugins: []\n") + primary := errors.New("buf process failed") + runner := &recordingRunner{run: func(toolrun.Command) error { return primary }} + target := projectdomain.Target{ + ID: "grpc-server", Family: "grpc", Input: "api/proto", Config: "tools/buf/grpc.gen.yaml", + } + + _, err := bufgen.New(runner).Generate(context.Background(), projectdomain.Project{Root: root}, target) + + require.ErrorIs(t, err, primary) + require.ErrorContains(t, err, "runner.Run") +} + +func writeFixture(t *testing.T, root, relative, content string) { + t.Helper() + filename := filepath.Join(root, filepath.FromSlash(relative)) + require.NoError(t, os.MkdirAll(filepath.Dir(filename), 0o755)) + require.NoError(t, os.WriteFile(filename, []byte(content), 0o644)) +} diff --git a/internal/client/bufgen/runner_test.go b/internal/client/bufgen/runner_test.go new file mode 100644 index 0000000..8afe52c --- /dev/null +++ b/internal/client/bufgen/runner_test.go @@ -0,0 +1,75 @@ +package bufgen_test + +import ( + "context" + "testing" + + bufgen "github.com/devctllabs/devctl/internal/client/bufgen" + "github.com/devctllabs/devctl/internal/client/toolrun" + "github.com/devctllabs/devctl/internal/domain/artifact" + projectdomain "github.com/devctllabs/devctl/internal/domain/project" + "github.com/stretchr/testify/require" +) + +func TestClientDelegatesBufGenerationToRunner(t *testing.T) { + t.Parallel() + + root := t.TempDir() + writeFixture(t, root, "go.mod", "module example.test/service\n\ngo 1.25\n\ntool github.com/bufbuild/buf/cmd/buf\n") + writeFixture(t, root, "api/proto/acme/v1/service.proto", "syntax = \"proto3\";\npackage acme.v1;\n") + writeFixture(t, root, "tools/buf/grpc.gen.yaml", "version: v2\nplugins: []\n") + var temporary string + runner := &recordingRunner{run: func(command toolrun.Command) error { + temporary = command.Args[len(command.Args)-1] + writeFixture(t, temporary, "acme/v1/service.pb.go", "generated") + return nil + }} + target := projectdomain.Target{ + ID: "grpc-server", Family: "grpc", Input: "api/proto", + Paths: []string{"acme/v1"}, Config: "tools/buf/grpc.gen.yaml", + } + + output, err := bufgen.New(runner).Generate(context.Background(), projectdomain.Project{Root: root}, target) + + require.NoError(t, err) + require.Equal(t, []toolrun.Command{{ + Name: "go", + Args: []string{"tool", "buf", "generate", "api/proto", "--template", "tools/buf/grpc.gen.yaml", "--path", "acme/v1", "--output", temporary}, + Dir: root, + }}, runner.commands) + require.Equal(t, artifact.Tree{Files: []artifact.File{{Path: "acme/v1/service.pb.go", Content: []byte("generated"), Mode: 0o644}}}, output.Directory) + require.NoDirExists(t, temporary) +} + +func TestClientDelegatesBufLintToRunnerWithoutTemporaryOutput(t *testing.T) { + t.Parallel() + + root := t.TempDir() + writeFixture(t, root, "go.mod", "module example.test/service\n\ngo 1.25\n\ntool github.com/bufbuild/buf/cmd/buf\n") + writeFixture(t, root, "api/proto/acme/v1/service.proto", "syntax = \"proto3\";\npackage acme.v1;\n") + runner := &recordingRunner{} + target := projectdomain.Target{ + ID: "grpc-server", Family: "grpc", Input: "api/proto", Paths: []string{"acme/v1"}, + } + + err := bufgen.New(runner).Lint(context.Background(), projectdomain.Project{Root: root}, target) + + require.NoError(t, err) + require.Equal(t, []toolrun.Command{{ + Name: "go", Args: []string{"tool", "buf", "lint", "api/proto", "--path", "acme/v1"}, Dir: root, + }}, runner.commands) +} + +type recordingRunner struct { + commands []toolrun.Command + run func(toolrun.Command) error +} + +func (r *recordingRunner) Run(_ context.Context, command toolrun.Command) error { + command.Args = append([]string(nil), command.Args...) + r.commands = append(r.commands, command) + if r.run != nil { + return r.run(command) + } + return nil +} diff --git a/internal/client/generator/client.go b/internal/client/generator/client.go new file mode 100644 index 0000000..4ce2389 --- /dev/null +++ b/internal/client/generator/client.go @@ -0,0 +1,65 @@ +// Package generator routes generation Targets to their concrete tool adapters. +package generator + +import ( + "context" + "fmt" + + generatedomain "github.com/devctllabs/devctl/internal/domain/generate" + projectdomain "github.com/devctllabs/devctl/internal/domain/project" +) + +//go:generate go tool mockgen -destination mocks/client.go -package mocks -typed . Adapter + +// Adapter generates unpublished Managed Output for one supported Target. +type Adapter interface { + // Generate returns unpublished Managed Output without modifying the Project workspace. + Generate(ctx context.Context, project projectdomain.Project, target projectdomain.Target) (generatedomain.Output, error) +} + +// Adapters contains the concrete tool capabilities selected by Client. +type Adapters struct { + OpenAPI Adapter + Proto Adapter + JSONSchema Adapter +} + +// Client routes each supported Target to its concrete generator adapter. +type Client struct { + adapters Adapters +} + +// New returns a Target-aware generator using adapters. +func New(adapters Adapters) *Client { + return &Client{adapters: adapters} +} + +// Generate returns unpublished Managed Output for target. +func (c *Client) Generate( + ctx context.Context, + project projectdomain.Project, + target projectdomain.Target, +) (generatedomain.Output, error) { + adapter, name := c.adapter(target) + if adapter == nil { + return generatedomain.Output{}, fmt.Errorf("unsupported generation target %q", target.ID) + } + output, err := adapter.Generate(ctx, project, target) + if err != nil { + return generatedomain.Output{}, fmt.Errorf("%s.Generate: %w", name, err) + } + return output, nil +} + +func (c *Client) adapter(target projectdomain.Target) (Adapter, string) { + switch { + case target.Family == "http": + return c.adapters.OpenAPI, "openAPI" + case target.Family == "grpc", target.Family == "kafka" && target.Format == "proto": + return c.adapters.Proto, "proto" + case target.Family == "kafka" && target.Format == "json": + return c.adapters.JSONSchema, "jsonSchema" + default: + return nil, "" + } +} diff --git a/internal/client/generator/client_test.go b/internal/client/generator/client_test.go new file mode 100644 index 0000000..64d7216 --- /dev/null +++ b/internal/client/generator/client_test.go @@ -0,0 +1,96 @@ +package generator_test + +import ( + "context" + "errors" + "testing" + + generatorclient "github.com/devctllabs/devctl/internal/client/generator" + "github.com/devctllabs/devctl/internal/client/generator/mocks" + "github.com/devctllabs/devctl/internal/domain/artifact" + generatedomain "github.com/devctllabs/devctl/internal/domain/generate" + projectdomain "github.com/devctllabs/devctl/internal/domain/project" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" +) + +func TestClientRoutesSupportedTargets(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + target projectdomain.Target + selected func(*mocks.MockAdapter, *mocks.MockAdapter, *mocks.MockAdapter) *mocks.MockAdapter + }{ + { + name: "http to OpenAPI", + target: projectdomain.Target{ID: "http-client:billing", Family: "http", Format: "openapi"}, + selected: func(openAPI, _, _ *mocks.MockAdapter) *mocks.MockAdapter { return openAPI }, + }, + { + name: "gRPC to Proto", + target: projectdomain.Target{ID: "grpc-server", Family: "grpc", Format: "proto"}, + selected: func(_, proto, _ *mocks.MockAdapter) *mocks.MockAdapter { return proto }, + }, + { + name: "Kafka Proto to Proto", + target: projectdomain.Target{ID: "kafka-producer:audit", Family: "kafka", Format: "proto"}, + selected: func(_, proto, _ *mocks.MockAdapter) *mocks.MockAdapter { return proto }, + }, + { + name: "Kafka JSON to JSON Schema", + target: projectdomain.Target{ID: "kafka-consumer:audit", Family: "kafka", Format: "json"}, + selected: func(_, _, jsonSchema *mocks.MockAdapter) *mocks.MockAdapter { return jsonSchema }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + openAPI := mocks.NewMockAdapter(ctrl) + proto := mocks.NewMockAdapter(ctrl) + jsonSchema := mocks.NewMockAdapter(ctrl) + project := projectdomain.Project{Root: "/project"} + expected := generatedomain.Output{Directory: artifact.Tree{Files: []artifact.File{{Path: "generated.go"}}}} + test.selected(openAPI, proto, jsonSchema).EXPECT().Generate(gomock.Any(), project, test.target).Return(expected, nil) + client := generatorclient.New(generatorclient.Adapters{ + OpenAPI: openAPI, Proto: proto, JSONSchema: jsonSchema, + }) + + actual, err := client.Generate(context.Background(), project, test.target) + + require.NoError(t, err) + require.Equal(t, expected, actual) + }) + } +} + +func TestClientRejectsUnsupportedTarget(t *testing.T) { + t.Parallel() + + client := generatorclient.New(generatorclient.Adapters{}) + + _, err := client.Generate(context.Background(), projectdomain.Project{}, projectdomain.Target{ + ID: "kafka-consumer:audit", Family: "kafka", Format: "raw", + }) + + require.EqualError(t, err, `unsupported generation target "kafka-consumer:audit"`) +} + +func TestClientPreservesAdapterFailure(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + openAPI := mocks.NewMockAdapter(ctrl) + cause := errors.New("tool failed") + project := projectdomain.Project{Root: "/project"} + target := projectdomain.Target{ID: "http-server", Family: "http", Format: "openapi"} + openAPI.EXPECT().Generate(gomock.Any(), project, target).Return(generatedomain.Output{}, cause) + client := generatorclient.New(generatorclient.Adapters{OpenAPI: openAPI}) + + _, err := client.Generate(context.Background(), project, target) + + require.ErrorContains(t, err, "openAPI.Generate") + require.ErrorIs(t, err, cause) +} diff --git a/internal/client/generator/mocks/client.go b/internal/client/generator/mocks/client.go new file mode 100644 index 0000000..7456007 --- /dev/null +++ b/internal/client/generator/mocks/client.go @@ -0,0 +1,82 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/devctllabs/devctl/internal/client/generator (interfaces: Adapter) +// +// Generated by this command: +// +// mockgen -destination mocks/client.go -package mocks -typed . Adapter +// + +// Package mocks is a generated GoMock package. +package mocks + +import ( + context "context" + reflect "reflect" + + generate "github.com/devctllabs/devctl/internal/domain/generate" + project "github.com/devctllabs/devctl/internal/domain/project" + gomock "go.uber.org/mock/gomock" +) + +// MockAdapter is a mock of Adapter interface. +type MockAdapter struct { + ctrl *gomock.Controller + recorder *MockAdapterMockRecorder + isgomock struct{} +} + +// MockAdapterMockRecorder is the mock recorder for MockAdapter. +type MockAdapterMockRecorder struct { + mock *MockAdapter +} + +// NewMockAdapter creates a new mock instance. +func NewMockAdapter(ctrl *gomock.Controller) *MockAdapter { + mock := &MockAdapter{ctrl: ctrl} + mock.recorder = &MockAdapterMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockAdapter) EXPECT() *MockAdapterMockRecorder { + return m.recorder +} + +// Generate mocks base method. +func (m *MockAdapter) Generate(ctx context.Context, arg1 project.Project, target project.Target) (generate.Output, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Generate", ctx, arg1, target) + ret0, _ := ret[0].(generate.Output) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Generate indicates an expected call of Generate. +func (mr *MockAdapterMockRecorder) Generate(ctx, arg1, target any) *MockAdapterGenerateCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Generate", reflect.TypeOf((*MockAdapter)(nil).Generate), ctx, arg1, target) + return &MockAdapterGenerateCall{Call: call} +} + +// MockAdapterGenerateCall wrap *gomock.Call +type MockAdapterGenerateCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockAdapterGenerateCall) Return(arg0 generate.Output, arg1 error) *MockAdapterGenerateCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockAdapterGenerateCall) Do(f func(context.Context, project.Project, project.Target) (generate.Output, error)) *MockAdapterGenerateCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockAdapterGenerateCall) DoAndReturn(f func(context.Context, project.Project, project.Target) (generate.Output, error)) *MockAdapterGenerateCall { + c.Call = c.Call.DoAndReturn(f) + return c +} diff --git a/internal/client/git/client.go b/internal/client/git/client.go new file mode 100644 index 0000000..8a04e02 --- /dev/null +++ b/internal/client/git/client.go @@ -0,0 +1,73 @@ +package git + +import ( + "context" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" +) + +// Client provides detached temporary Git checkouts without source-selection policy. +type Client struct{} + +func New() *Client { return &Client{} } + +// WithCheckout invokes use with a detached checkout and always attempts cleanup before returning. +// Callback and cleanup failures are both preserved. +func (c *Client) WithCheckout(ctx context.Context, repository, ref string, use func(root string) error) error { + workspace, err := os.MkdirTemp("", "devctl-source-") + if err != nil { + return fmt.Errorf("os.MkdirTemp: %w", err) + } + worktree := &Worktree{root: filepath.Join(workspace, "repository"), workspace: workspace} + if err := run(ctx, "git", "clone", "--quiet", "--no-checkout", "--", repository, worktree.root); err != nil { + return errors.Join(err, worktree.Close()) + } + if err := run(ctx, "git", "-C", worktree.root, "checkout", "--quiet", "--detach", ref); err != nil { + return errors.Join(err, worktree.Close()) + } + return errors.Join(use(worktree.root), worktree.Close()) +} + +// Worktree owns one temporary checkout and its surrounding temporary directory. +type Worktree struct { + root string + workspace string + closed bool +} + +// Root returns the checkout directory, which remains valid until Close. +func (w *Worktree) Root() string { return w.root } + +// Close attempts to remove the complete temporary workspace at most once. +func (w *Worktree) Close() error { + if w.closed { + return nil + } + w.closed = true + if err := os.RemoveAll(w.workspace); err != nil { + return fmt.Errorf("os.RemoveAll: %w", err) + } + return nil +} + +func run(ctx context.Context, executable string, arguments ...string) error { + command := exec.CommandContext(ctx, executable, arguments...) + output, err := command.CombinedOutput() + if err != nil { + return fmt.Errorf("command.CombinedOutput: %s: %w", bounded(output), err) + } + return nil +} + +func bounded(data []byte) string { + const limit = 1024 + value := strings.TrimSpace(string(data)) + if len(value) > limit { + return value[:limit] + "…" + } + return value +} diff --git a/internal/client/git/client_test.go b/internal/client/git/client_test.go new file mode 100644 index 0000000..a20d53b --- /dev/null +++ b/internal/client/git/client_test.go @@ -0,0 +1,52 @@ +package git_test + +import ( + "bytes" + "context" + "os" + "os/exec" + "path/filepath" + "testing" + + gitclient "github.com/devctllabs/devctl/internal/client/git" + "github.com/stretchr/testify/require" +) + +func TestClientChecksOutDetachedWorktreeAndCleansItUp(t *testing.T) { + t.Parallel() + + repository := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(repository, "openapi.yaml"), []byte("openapi: 3.1.0\n"), 0o644)) + commit := commitFixture(t, repository) + + var root string + err := gitclient.New().WithCheckout(context.Background(), repository, commit, func(checkoutRoot string) error { + root = checkoutRoot + require.FileExists(t, filepath.Join(root, "openapi.yaml")) + return nil + }) + + require.NoError(t, err) + require.NoDirExists(t, root) +} + +func commitFixture(t *testing.T, repository string) string { + t.Helper() + for _, arguments := range [][]string{ + {"init", "--quiet"}, + {"config", "user.email", "devctl@example.test"}, + {"config", "user.name", "Devctl Test"}, + {"add", "."}, + {"commit", "--quiet", "-m", "fixture"}, + } { + command := exec.CommandContext(context.Background(), "git", arguments...) + command.Dir = repository + output, err := command.CombinedOutput() + require.NoError(t, err, "%s", output) + } + command := exec.CommandContext(context.Background(), "git", "rev-parse", "HEAD") + command.Dir = repository + output, err := command.Output() + require.NoError(t, err) + return string(bytes.TrimSpace(output)) +} diff --git a/internal/client/http/client.go b/internal/client/http/client.go new file mode 100644 index 0000000..e5443dc --- /dev/null +++ b/internal/client/http/client.go @@ -0,0 +1,151 @@ +package http + +import ( + "context" + "fmt" + "io" + stdhttp "net/http" + "net/url" + "strings" + "time" + + "github.com/devctllabs/devctl/internal/domain/failure" + materializedomain "github.com/devctllabs/devctl/internal/domain/materialize" +) + +const maxResponseSize = 32 << 20 + +// Client retrieves bounded contract documents with a fixed request timeout. +type Client struct { + client *stdhttp.Client +} + +func New() *Client { + return &Client{client: &stdhttp.Client{Timeout: 30 * time.Second}} +} + +// Fetch accepts only 2xx responses, limits bodies to 32 MiB, and returns the effective response URL. +func (c *Client) Fetch(ctx context.Context, fetch materializedomain.HTTPFetchRequest) (materializedomain.HTTPDocument, error) { + request, err := stdhttp.NewRequestWithContext(ctx, stdhttp.MethodGet, fetch.URL, nil) + if err != nil { + return materializedomain.HTTPDocument{}, fmt.Errorf("stdhttp.NewRequestWithContext: %w", err) + } + origin, err := url.Parse(fetch.OriginURL) + if err != nil || origin.Host == "" { + return materializedomain.HTTPDocument{}, &PolicyError{Reason: "invalid origin URL", Cause: err} + } + if err := validateURLPolicy(origin, request.URL, fetch.AllowInsecureHTTP); err != nil { + return materializedomain.HTTPDocument{}, err + } + client := *c.client + client.CheckRedirect = redirectPolicy(origin, fetch.AllowInsecureHTTP) + response, err := client.Do(request) + if err != nil { + category := failure.CategoryOf(err) + if category == failure.Internal { + category = failure.Unavailable + } + return materializedomain.HTTPDocument{}, &FetchError{Kind: category, Cause: err} + } + defer func() { _ = response.Body.Close() }() + if response.StatusCode < 200 || response.StatusCode > 299 { + return materializedomain.HTTPDocument{}, &StatusError{StatusCode: response.StatusCode} + } + data, err := io.ReadAll(io.LimitReader(response.Body, maxResponseSize+1)) + if err != nil { + return materializedomain.HTTPDocument{}, fmt.Errorf("io.ReadAll: %w", err) + } + if len(data) > maxResponseSize { + return materializedomain.HTTPDocument{}, &BodyTooLargeError{Limit: maxResponseSize} + } + return materializedomain.HTTPDocument{URL: response.Request.URL.String(), Content: data}, nil +} + +// redirectPolicy rejects origin changes, credentials, disallowed schemes, and excessive redirect chains. +func redirectPolicy(origin *url.URL, allowInsecureHTTP bool) func(*stdhttp.Request, []*stdhttp.Request) error { + return func(request *stdhttp.Request, via []*stdhttp.Request) error { + if len(via) >= 10 { + return &PolicyError{Reason: "too many redirects"} + } + return validateURLPolicy(origin, request.URL, allowInsecureHTTP) + } +} + +func validateURLPolicy(origin, target *url.URL, allowInsecureHTTP bool) error { + if origin.User != nil || target.User != nil { + return &PolicyError{Reason: "URL contains credentials"} + } + targetScheme := strings.ToLower(target.Scheme) + if targetScheme != "https" && (targetScheme != "http" || !allowInsecureHTTP) { + return &PolicyError{Reason: "URL uses a disallowed scheme"} + } + if !sameOrigin(origin, target) { + return &PolicyError{Reason: "URL changes source origin"} + } + return nil +} + +func sameOrigin(left, right *url.URL) bool { + return strings.EqualFold(left.Scheme, right.Scheme) && + strings.EqualFold(left.Hostname(), right.Hostname()) && + effectivePort(left) == effectivePort(right) +} + +func effectivePort(value *url.URL) string { + if value.Port() != "" { + return value.Port() + } + switch strings.ToLower(value.Scheme) { + case "http": + return "80" + case "https": + return "443" + default: + return "" + } +} + +// PolicyError reports a URL request that violates Source fetch policy. +type PolicyError struct { + Reason string + Cause error +} + +func (e *PolicyError) Error() string { return e.Reason } +func (e *PolicyError) Unwrap() error { return e.Cause } +func (e *PolicyError) Category() failure.Category { + return failure.InvalidInput +} + +// FetchError retains the raw transport cause behind a query-safe public message. +type FetchError struct { + Kind failure.Category + Cause error +} + +func (e *FetchError) Error() string { return "HTTP fetch failed" } +func (e *FetchError) Unwrap() error { return e.Cause } +func (e *FetchError) Category() failure.Category { return e.Kind } + +// StatusError retains a non-success HTTP response status without interpreting application policy. +type StatusError struct { + StatusCode int +} + +func (e *StatusError) Error() string { return fmt.Sprintf("HTTP status %d", e.StatusCode) } + +func (e *StatusError) Category() failure.Category { + if e.StatusCode == stdhttp.StatusNotFound || e.StatusCode == stdhttp.StatusGone { + return failure.NotFound + } + return failure.Unavailable +} + +// BodyTooLargeError reports the byte limit exceeded by a response body. +type BodyTooLargeError struct { + Limit int +} + +func (e *BodyTooLargeError) Error() string { return fmt.Sprintf("response exceeds %d bytes", e.Limit) } + +func (e *BodyTooLargeError) Category() failure.Category { return failure.InvalidInput } diff --git a/internal/client/http/client_test.go b/internal/client/http/client_test.go new file mode 100644 index 0000000..1e23e0b --- /dev/null +++ b/internal/client/http/client_test.go @@ -0,0 +1,148 @@ +package http_test + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + httpclient "github.com/devctllabs/devctl/internal/client/http" + "github.com/devctllabs/devctl/internal/domain/failure" + materializedomain "github.com/devctllabs/devctl/internal/domain/materialize" + "github.com/stretchr/testify/require" +) + +func TestClientGetsResponseBody(t *testing.T) { + t.Parallel() + + methods := make(chan string, 1) + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + methods <- request.Method + _, _ = writer.Write([]byte("openapi: 3.1.0\n")) + })) + t.Cleanup(server.Close) + + document, err := httpclient.New().Fetch(context.Background(), materializedomain.HTTPFetchRequest{ + URL: server.URL, OriginURL: server.URL, AllowInsecureHTTP: true, + }) + + require.NoError(t, err) + require.Equal(t, http.MethodGet, <-methods) + require.Equal(t, server.URL, document.URL) + require.Equal(t, []byte("openapi: 3.1.0\n"), document.Content) +} + +func TestClientRejectsCrossOriginRedirect(t *testing.T) { + t.Parallel() + + destination := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { + _, _ = writer.Write([]byte("should not be fetched")) + })) + t.Cleanup(destination.Close) + origin := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { + http.Redirect(writer, &http.Request{}, destination.URL+"/contract.yaml", http.StatusFound) + })) + t.Cleanup(origin.Close) + + _, err := httpclient.New().Fetch(context.Background(), materializedomain.HTTPFetchRequest{ + URL: origin.URL, OriginURL: origin.URL, AllowInsecureHTTP: true, + }) + + require.Equal(t, failure.InvalidInput, failure.CategoryOf(err)) +} + +func TestClientReturnsEffectiveURLAfterSameOriginRedirect(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if request.URL.Path == "/start" { + http.Redirect(writer, request, "/nested/contract.yaml", http.StatusFound) + return + } + _, _ = writer.Write([]byte("openapi: 3.1.0\n")) + })) + t.Cleanup(server.Close) + + document, err := httpclient.New().Fetch(context.Background(), materializedomain.HTTPFetchRequest{ + URL: server.URL + "/start", OriginURL: server.URL + "/start", AllowInsecureHTTP: true, + }) + + require.NoError(t, err) + require.Equal(t, server.URL+"/nested/contract.yaml", document.URL) +} + +func TestClientClassifiesHTTPStatus(t *testing.T) { + t.Parallel() + + tests := []struct { + status int + expected failure.Category + }{ + {status: http.StatusNotFound, expected: failure.NotFound}, + {status: http.StatusGone, expected: failure.NotFound}, + {status: http.StatusInternalServerError, expected: failure.Unavailable}, + } + for _, test := range tests { + t.Run(http.StatusText(test.status), func(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { + writer.WriteHeader(test.status) + })) + t.Cleanup(server.Close) + + _, err := httpclient.New().Fetch(context.Background(), materializedomain.HTTPFetchRequest{ + URL: server.URL, OriginURL: server.URL, AllowInsecureHTTP: true, + }) + + require.Equal(t, test.expected, failure.CategoryOf(err)) + }) + } +} + +func TestClientClassifiesOversizedResponseAsInvalidInput(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { + _, _ = writer.Write(make([]byte, (32<<20)+1)) + })) + t.Cleanup(server.Close) + + _, err := httpclient.New().Fetch(context.Background(), materializedomain.HTTPFetchRequest{ + URL: server.URL, OriginURL: server.URL, AllowInsecureHTTP: true, + }) + + require.Equal(t, failure.InvalidInput, failure.CategoryOf(err)) + var sizeErr *httpclient.BodyTooLargeError + require.ErrorAs(t, err, &sizeErr) +} + +func TestClientClassifiesTransportFailureAsUnavailable(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) + serverURL := server.URL + server.Close() + + _, err := httpclient.New().Fetch(context.Background(), materializedomain.HTTPFetchRequest{ + URL: serverURL, OriginURL: serverURL, AllowInsecureHTTP: true, + }) + + require.Equal(t, failure.Unavailable, failure.CategoryOf(err)) +} + +func TestClientReturnsTypedStatusError(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { + writer.WriteHeader(http.StatusBadGateway) + })) + t.Cleanup(server.Close) + + _, err := httpclient.New().Fetch(context.Background(), materializedomain.HTTPFetchRequest{ + URL: server.URL, OriginURL: server.URL, AllowInsecureHTTP: true, + }) + + var statusError *httpclient.StatusError + require.ErrorAs(t, err, &statusError) + require.Equal(t, http.StatusBadGateway, statusError.StatusCode) +} diff --git a/internal/client/http/policy_test.go b/internal/client/http/policy_test.go new file mode 100644 index 0000000..a43ea96 --- /dev/null +++ b/internal/client/http/policy_test.go @@ -0,0 +1,66 @@ +package http + +import ( + "net/url" + "testing" + + "github.com/devctllabs/devctl/internal/domain/failure" + "github.com/stretchr/testify/require" +) + +func TestSameOriginUsesSchemeHostAndEffectivePort(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + left string + right string + expected bool + }{ + {name: "https default port", left: "https://example.test/a", right: "https://EXAMPLE.test:443/b", expected: true}, + {name: "http default port", left: "http://example.test/a", right: "http://example.test:80/b", expected: true}, + {name: "different explicit port", left: "https://example.test/a", right: "https://example.test:8443/b", expected: false}, + {name: "different scheme", left: "http://example.test/a", right: "https://example.test/b", expected: false}, + {name: "different host", left: "https://example.test/a", right: "https://other.test/b", expected: false}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + left, err := url.Parse(test.left) + require.NoError(t, err) + right, err := url.Parse(test.right) + require.NoError(t, err) + + require.Equal(t, test.expected, sameOrigin(left, right)) + }) + } +} + +func TestURLPolicyRejectsCredentialsAndInsecureScheme(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + origin string + target string + allowInsecure bool + }{ + {name: "origin credentials", origin: "https://user:secret@example.test/a", target: "https://example.test/b"}, + {name: "target credentials", origin: "https://example.test/a", target: "https://user:secret@example.test/b"}, + {name: "insecure scheme", origin: "http://example.test/a", target: "http://example.test/b"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + origin, err := url.Parse(test.origin) + require.NoError(t, err) + target, err := url.Parse(test.target) + require.NoError(t, err) + + err = validateURLPolicy(origin, target, test.allowInsecure) + + require.Equal(t, failure.InvalidInput, failure.CategoryOf(err)) + require.NotContains(t, err.Error(), "secret") + }) + } +} diff --git a/internal/client/jsonschema/client.go b/internal/client/jsonschema/client.go new file mode 100644 index 0000000..5728ff7 --- /dev/null +++ b/internal/client/jsonschema/client.go @@ -0,0 +1,102 @@ +package jsonschema + +import ( + "context" + "fmt" + "go/parser" + "go/token" + "os/exec" + "path/filepath" + "strings" + + "github.com/devctllabs/devctl/internal/client/toolrun" + "github.com/devctllabs/devctl/internal/client/toolsafety" + "github.com/devctllabs/devctl/internal/domain/artifact" + generatedomain "github.com/devctllabs/devctl/internal/domain/generate" + projectdomain "github.com/devctllabs/devctl/internal/domain/project" + platformjsonschema "github.com/devctllabs/devctl/internal/platform/jsonschema" +) + +// Client runs project-pinned quicktype in a temporary output directory. +type Client struct { + runner toolrun.Runner +} + +func New(runner toolrun.Runner) *Client { return &Client{runner: runner} } + +// Generate returns one parsed Go file without writing managed project output. +func (c *Client) Generate(ctx context.Context, project projectdomain.Project, target projectdomain.Target) (generatedomain.Output, error) { + if err := ctx.Err(); err != nil { + return generatedomain.Output{}, fmt.Errorf("ctx.Err: %w", err) + } + if target.Family != "kafka" || target.Format != "json" { + return generatedomain.Output{}, fmt.Errorf("unsupported generation target %q", target.ID) + } + input, err := toolsafety.ReadRegularFile(project.Root, target.Input) + if err != nil { + return generatedomain.Output{}, fmt.Errorf("toolsafety.ReadRegularFile input: %w", err) + } + title, err := platformjsonschema.RootTitle(input.Content) + if err != nil { + return generatedomain.Output{}, fmt.Errorf("jsonschema.RootTitle: %w", err) + } + outputName := target.OutputFile + if outputName == "" { + outputName = "schema.gen.go" + } + if filepath.IsAbs(outputName) || filepath.Base(outputName) != outputName || outputName == "." { + return generatedomain.Output{}, fmt.Errorf("invalid generated output filename %q", outputName) + } + quicktype, err := exec.LookPath("quicktype") + if err != nil { + return generatedomain.Output{}, fmt.Errorf("exec.LookPath: quicktype is unavailable; run mise install and execute generation through mise: %w", err) + } + output, err := toolrun.WithTemporaryOutput(ctx, "devctl-jsonschema-generate-", func(temporary string) (generatedomain.Output, error) { + outputPath := filepath.Join(temporary, outputName) + generatedPackage := packageName(target.ID) + command := toolrun.Command{ + Name: quicktype, + Args: []string{ + "--src", target.Input, + "--src-lang", "schema", + "--lang", "go", + "--package", generatedPackage, + "--top-level", title, + "--out", outputPath, + "--omit-empty", + }, + Dir: project.Root, + } + if err := c.runner.Run(ctx, command); err != nil { + return generatedomain.Output{}, fmt.Errorf("runner.Run: %w", err) + } + generated, err := toolsafety.ReadRegularFile(temporary, outputName) + if err != nil { + return generatedomain.Output{}, fmt.Errorf("toolsafety.ReadRegularFile output: %w", err) + } + parsed, err := parser.ParseFile(token.NewFileSet(), outputName, generated.Content, parser.AllErrors) + if err != nil { + return generatedomain.Output{}, fmt.Errorf("parser.ParseFile: %w", err) + } + if parsed.Name.Name != generatedPackage { + return generatedomain.Output{}, fmt.Errorf("generated package %q does not match %q", parsed.Name.Name, generatedPackage) + } + mode := uint32(generated.Mode.Perm()) + if mode == 0 { + mode = 0o644 + } + return generatedomain.Output{Directory: artifact.Tree{Files: []artifact.File{{Path: outputName, Content: generated.Content, Mode: mode}}}}, nil + }) + if err != nil { + return output, fmt.Errorf("toolrun.WithTemporaryOutput: %w", err) + } + return output, nil +} + +func packageName(targetID string) string { + name := targetID + if _, selected, found := strings.Cut(targetID, ":"); found { + name = selected + } + return strings.ReplaceAll(name, "-", "_") +} diff --git a/internal/client/jsonschema/client_test.go b/internal/client/jsonschema/client_test.go new file mode 100644 index 0000000..6805540 --- /dev/null +++ b/internal/client/jsonschema/client_test.go @@ -0,0 +1,127 @@ +package jsonschema_test + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "testing" + + jsonschema "github.com/devctllabs/devctl/internal/client/jsonschema" + "github.com/devctllabs/devctl/internal/client/toolrun" + projectdomain "github.com/devctllabs/devctl/internal/domain/project" + "github.com/stretchr/testify/require" +) + +func TestClientRejectsSchemaWithoutTitleBeforeRunningQuicktype(t *testing.T) { + root := t.TempDir() + writeFixture(t, root, "api/contracts/audit.json", `{"type":"object"}`) + t.Setenv("PATH", t.TempDir()) + runner := &recordingRunner{} + + _, err := jsonschema.New(runner).Generate(context.Background(), projectdomain.Project{Root: root}, jsonTarget()) + + require.ErrorContains(t, err, "root title is required") + require.Empty(t, runner.commands) +} + +func TestClientReportsMissingQuicktypeWithMiseGuidance(t *testing.T) { + root := t.TempDir() + writeFixture(t, root, "api/contracts/audit.json", `{"title":"AuditEvent","type":"object"}`) + t.Setenv("PATH", t.TempDir()) + + _, err := jsonschema.New(&recordingRunner{}).Generate(context.Background(), projectdomain.Project{Root: root}, jsonTarget()) + + require.ErrorContains(t, err, "quicktype is unavailable") + require.ErrorContains(t, err, "run mise install") +} + +func TestClientPreservesQuicktypeRunnerFailure(t *testing.T) { //nolint:paralleltest // PATH is part of the LookPath contract. + root := t.TempDir() + writeFixture(t, root, "api/contracts/audit.json", `{"title":"AuditEvent","type":"object"}`) + installQuicktypePlaceholder(t) + primary := errors.New("quicktype process failed") + runner := &recordingRunner{run: func(toolrun.Command) error { return primary }} + + _, err := jsonschema.New(runner).Generate(context.Background(), projectdomain.Project{Root: root}, jsonTarget()) + + require.ErrorIs(t, err, primary) + require.ErrorContains(t, err, "runner.Run") +} + +func TestClientRejectsMalformedGeneratedGo(t *testing.T) { //nolint:paralleltest // The helper sets PATH for LookPath. + err := generateWithFakeOutput(t, "not Go") + + require.ErrorContains(t, err, "parser.ParseFile") +} + +func TestClientRejectsUnexpectedGeneratedPackage(t *testing.T) { //nolint:paralleltest // The helper sets PATH for LookPath. + err := generateWithFakeOutput(t, "package wrong\n") + + require.ErrorContains(t, err, `generated package "wrong" does not match "audit_events"`) +} + +func TestClientReportsMissingGeneratedOutput(t *testing.T) { //nolint:paralleltest // PATH is part of the LookPath contract. + root := t.TempDir() + writeFixture(t, root, "api/contracts/audit.json", `{"title":"AuditEvent","type":"object"}`) + installQuicktypePlaceholder(t) + + _, err := jsonschema.New(&recordingRunner{}).Generate(context.Background(), projectdomain.Project{Root: root}, jsonTarget()) + + require.ErrorContains(t, err, "toolsafety.ReadRegularFile output") +} + +func TestClientRejectsUnsafeOutputNameBeforeResolvingQuicktype(t *testing.T) { + root := t.TempDir() + writeFixture(t, root, "api/contracts/audit.json", `{"title":"AuditEvent","type":"object"}`) + t.Setenv("PATH", t.TempDir()) + target := jsonTarget() + target.OutputFile = "../outside.go" + + _, err := jsonschema.New(&recordingRunner{}).Generate(context.Background(), projectdomain.Project{Root: root}, target) + + require.EqualError(t, err, `invalid generated output filename "../outside.go"`) +} + +func TestClientHonorsCancellationBeforeGeneration(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + _, err := jsonschema.New(&recordingRunner{}).Generate(ctx, projectdomain.Project{}, jsonTarget()) + + require.ErrorIs(t, err, context.Canceled) +} + +func generateWithFakeOutput(t *testing.T, content string) error { + t.Helper() + root := t.TempDir() + writeFixture(t, root, "api/contracts/audit.json", `{"title":"AuditEvent","type":"object"}`) + installQuicktypePlaceholder(t) + runner := &recordingRunner{run: func(command toolrun.Command) error { + outputPath := command.Args[11] + writeFixture(t, filepath.Dir(outputPath), filepath.Base(outputPath), content) + return nil + }} + _, err := jsonschema.New(runner).Generate(context.Background(), projectdomain.Project{Root: root}, jsonTarget()) + if err != nil { + return fmt.Errorf("client.Generate: %w", err) + } + return nil +} + +func jsonTarget() projectdomain.Target { + return projectdomain.Target{ + ID: "kafka-consumer:audit-events", Family: "kafka", Format: "json", + Input: "api/contracts/audit.json", OutputDir: "gen/kafka/consumer/audit-events", OutputFile: "schema.gen.go", + } +} + +func writeFixture(t *testing.T, root, relative, content string) { + t.Helper() + filename := filepath.Join(root, filepath.FromSlash(relative)) + require.NoError(t, os.MkdirAll(filepath.Dir(filename), 0o755)) + require.NoError(t, os.WriteFile(filename, []byte(content), 0o644)) +} diff --git a/internal/client/jsonschema/runner_test.go b/internal/client/jsonschema/runner_test.go new file mode 100644 index 0000000..b04f1df --- /dev/null +++ b/internal/client/jsonschema/runner_test.go @@ -0,0 +1,67 @@ +package jsonschema_test + +import ( + "context" + "os" + "path/filepath" + "testing" + + jsonschema "github.com/devctllabs/devctl/internal/client/jsonschema" + "github.com/devctllabs/devctl/internal/client/toolrun" + "github.com/devctllabs/devctl/internal/domain/artifact" + projectdomain "github.com/devctllabs/devctl/internal/domain/project" + "github.com/stretchr/testify/require" +) + +func TestClientDelegatesQuicktypeGenerationToRunner(t *testing.T) { //nolint:paralleltest // PATH is part of the LookPath contract. + root := t.TempDir() + writeFixture(t, root, "api/contracts/audit.json", `{"title":"AuditEvent","type":"object"}`) + quicktype := installQuicktypePlaceholder(t) + var temporary string + runner := &recordingRunner{run: func(command toolrun.Command) error { + outputPath := command.Args[11] + temporary = filepath.Dir(outputPath) + writeFixture(t, temporary, filepath.Base(outputPath), "package audit_events\n\ntype AuditEvent struct{}\n") + return nil + }} + + output, err := jsonschema.New(runner).Generate(context.Background(), projectdomain.Project{Root: root}, jsonTarget()) + + require.NoError(t, err) + require.Equal(t, []toolrun.Command{{ + Name: quicktype, + Args: []string{ + "--src", "api/contracts/audit.json", "--src-lang", "schema", "--lang", "go", + "--package", "audit_events", "--top-level", "AuditEvent", + "--out", filepath.Join(temporary, "schema.gen.go"), "--omit-empty", + }, + Dir: root, + }}, runner.commands) + require.Equal(t, artifact.Tree{Files: []artifact.File{{ + Path: "schema.gen.go", Content: []byte("package audit_events\n\ntype AuditEvent struct{}\n"), Mode: 0o644, + }}}, output.Directory) + require.NoDirExists(t, temporary) +} + +type recordingRunner struct { + commands []toolrun.Command + run func(toolrun.Command) error +} + +func (r *recordingRunner) Run(_ context.Context, command toolrun.Command) error { + command.Args = append([]string(nil), command.Args...) + r.commands = append(r.commands, command) + if r.run != nil { + return r.run(command) + } + return nil +} + +func installQuicktypePlaceholder(t *testing.T) string { + t.Helper() + directory := t.TempDir() + executable := filepath.Join(directory, "quicktype") + require.NoError(t, os.WriteFile(executable, nil, 0o755)) + t.Setenv("PATH", directory) + return executable +} diff --git a/internal/client/oapicodegen/client.go b/internal/client/oapicodegen/client.go new file mode 100644 index 0000000..d42920a --- /dev/null +++ b/internal/client/oapicodegen/client.go @@ -0,0 +1,77 @@ +package oapicodegen + +import ( + "bytes" + "context" + "fmt" + "path/filepath" + + "github.com/devctllabs/devctl/internal/client/toolrun" + "github.com/devctllabs/devctl/internal/client/toolsafety" + "github.com/devctllabs/devctl/internal/domain/artifact" + generatedomain "github.com/devctllabs/devctl/internal/domain/generate" + projectdomain "github.com/devctllabs/devctl/internal/domain/project" +) + +// Client runs the project-declared oapi-codegen tool in a temporary output workspace. +type Client struct { + runner toolrun.Runner +} + +func New(runner toolrun.Runner) *Client { return &Client{runner: runner} } + +// Generate validates contained inputs and returns unpublished generated output. +func (c *Client) Generate( + ctx context.Context, + project projectdomain.Project, + target projectdomain.Target, +) (generatedomain.Output, error) { + if err := ctx.Err(); err != nil { + return generatedomain.Output{}, fmt.Errorf("ctx.Err: %w", err) + } + if target.Family != "http" { + return generatedomain.Output{}, fmt.Errorf("unsupported generation target kind %q", target.Family) + } + return c.generateHTTP(ctx, project.Root, target) +} + +func (c *Client) generateHTTP(ctx context.Context, root string, target projectdomain.Target) (generatedomain.Output, error) { + if target.OutputFile == "" || filepath.Base(target.OutputFile) != target.OutputFile { + return generatedomain.Output{}, fmt.Errorf("invalid generated output filename %q", target.OutputFile) + } + if _, err := toolsafety.ReadRegularFile(root, target.Input); err != nil { + return generatedomain.Output{}, fmt.Errorf("toolsafety.ReadRegularFile input: %w", err) + } + if _, err := toolsafety.ReadRegularFile(root, target.Config); err != nil { + return generatedomain.Output{}, fmt.Errorf("toolsafety.ReadRegularFile config: %w", err) + } + goMod, err := toolsafety.ReadRegularFile(root, "go.mod") + if err != nil { + return generatedomain.Output{}, fmt.Errorf("toolsafety.ReadRegularFile go.mod: %w", err) + } + if !bytes.Contains(goMod.Content, []byte("tool github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen")) { + return generatedomain.Output{}, fmt.Errorf("go.mod does not declare the oapi-codegen tool") + } + + output, err := toolrun.WithTemporaryOutput(ctx, "devctl-generate-", func(temporary string) (generatedomain.Output, error) { + outputPath := filepath.Join(temporary, target.OutputFile) + command := toolrun.Command{ + Name: "go", Args: []string{"tool", "oapi-codegen", "--config", target.Config, "-o", outputPath, target.Input}, Dir: root, + } + if err := c.runner.Run(ctx, command); err != nil { + return generatedomain.Output{}, fmt.Errorf("runner.Run: %w", err) + } + generated, err := toolsafety.ReadRegularFile(temporary, target.OutputFile) + if err != nil { + return generatedomain.Output{}, fmt.Errorf("toolsafety.ReadRegularFile output: %w", err) + } + if !bytes.Contains(generated.Content, []byte("Code generated")) { + return generatedomain.Output{}, fmt.Errorf("oapi-codegen did not create a canonical generated Go file") + } + return generatedomain.Output{Directory: artifact.Tree{Files: []artifact.File{{Path: target.OutputFile, Content: generated.Content, Mode: 0o644}}}}, nil + }) + if err != nil { + return output, fmt.Errorf("toolrun.WithTemporaryOutput: %w", err) + } + return output, nil +} diff --git a/internal/client/oapicodegen/client_test.go b/internal/client/oapicodegen/client_test.go new file mode 100644 index 0000000..8cbf23c --- /dev/null +++ b/internal/client/oapicodegen/client_test.go @@ -0,0 +1,41 @@ +package oapicodegen_test + +import ( + "context" + "errors" + "os" + "path/filepath" + "testing" + + oapicodegen "github.com/devctllabs/devctl/internal/client/oapicodegen" + "github.com/devctllabs/devctl/internal/client/toolrun" + projectdomain "github.com/devctllabs/devctl/internal/domain/project" + "github.com/stretchr/testify/require" +) + +func TestClientPreservesOAPIRunnerFailure(t *testing.T) { + t.Parallel() + + root := t.TempDir() + writeFixture(t, root, "go.mod", "module example.test/service\n\ngo 1.26\n\ntool github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen\n") + writeFixture(t, root, "api/openapi.yaml", "openapi: 3.1.0\ninfo:\n title: service\n version: 1.0.0\npaths: {}\n") + writeFixture(t, root, "tools/oapi/client.yaml", "package: clienthttp\ngenerate:\n client: true\n") + primary := errors.New("oapi process failed") + runner := &recordingRunner{run: func(toolrun.Command) error { return primary }} + target := projectdomain.Target{ + ID: "http-client:payments", Family: "http", Input: "api/openapi.yaml", + Config: "tools/oapi/client.yaml", OutputFile: "client.gen.go", + } + + _, err := oapicodegen.New(runner).Generate(context.Background(), projectdomain.Project{Root: root}, target) + + require.ErrorIs(t, err, primary) + require.ErrorContains(t, err, "runner.Run") +} + +func writeFixture(t *testing.T, root, relative, content string) { + t.Helper() + filename := filepath.Join(root, filepath.FromSlash(relative)) + require.NoError(t, os.MkdirAll(filepath.Dir(filename), 0o755)) + require.NoError(t, os.WriteFile(filename, []byte(content), 0o644)) +} diff --git a/internal/client/oapicodegen/runner_test.go b/internal/client/oapicodegen/runner_test.go new file mode 100644 index 0000000..5d08aa2 --- /dev/null +++ b/internal/client/oapicodegen/runner_test.go @@ -0,0 +1,57 @@ +package oapicodegen_test + +import ( + "context" + "path/filepath" + "testing" + + oapicodegen "github.com/devctllabs/devctl/internal/client/oapicodegen" + "github.com/devctllabs/devctl/internal/client/toolrun" + projectdomain "github.com/devctllabs/devctl/internal/domain/project" + "github.com/stretchr/testify/require" +) + +func TestClientDelegatesOAPIGenerationToRunner(t *testing.T) { + t.Parallel() + + root := t.TempDir() + writeFixture(t, root, "go.mod", "module example.test/service\n\ngo 1.26\n\ntool github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen\n") + writeFixture(t, root, "api/openapi.yaml", "openapi: 3.1.0\ninfo:\n title: service\n version: 1.0.0\npaths: {}\n") + writeFixture(t, root, "tools/oapi/client.yaml", "package: clienthttp\ngenerate:\n client: true\n") + var temporary string + runner := &recordingRunner{run: func(command toolrun.Command) error { + outputPath := command.Args[5] + temporary = filepath.Dir(outputPath) + writeFixture(t, temporary, filepath.Base(outputPath), "// Code generated by oapi-codegen. DO NOT EDIT.\npackage clienthttp\n") + return nil + }} + target := projectdomain.Target{ + ID: "http-client:payments", Family: "http", Input: "api/openapi.yaml", + Config: "tools/oapi/client.yaml", OutputFile: "client.gen.go", + } + + output, err := oapicodegen.New(runner).Generate(context.Background(), projectdomain.Project{Root: root}, target) + + require.NoError(t, err) + require.Equal(t, []toolrun.Command{{ + Name: "go", + Args: []string{"tool", "oapi-codegen", "--config", "tools/oapi/client.yaml", "-o", filepath.Join(temporary, "client.gen.go"), "api/openapi.yaml"}, + Dir: root, + }}, runner.commands) + require.Equal(t, []byte("// Code generated by oapi-codegen. DO NOT EDIT.\npackage clienthttp\n"), output.Directory.Files[0].Content) + require.NoDirExists(t, temporary) +} + +type recordingRunner struct { + commands []toolrun.Command + run func(toolrun.Command) error +} + +func (r *recordingRunner) Run(_ context.Context, command toolrun.Command) error { + command.Args = append([]string(nil), command.Args...) + r.commands = append(r.commands, command) + if r.run != nil { + return r.run(command) + } + return nil +} diff --git a/internal/client/toolrun/runner.go b/internal/client/toolrun/runner.go new file mode 100644 index 0000000..7bf1929 --- /dev/null +++ b/internal/client/toolrun/runner.go @@ -0,0 +1,42 @@ +// Package toolrun executes project-owned tools without assigning tool-specific policy. +package toolrun + +import ( + "context" + "fmt" + "os/exec" + + "github.com/devctllabs/devctl/internal/client/toolsafety" +) + +// Command describes one local process invocation. +type Command struct { + Name string + Args []string + Dir string +} + +// Runner executes local tool commands. +type Runner interface { + Run(ctx context.Context, command Command) error +} + +// OSRunner executes commands through the operating system. +type OSRunner struct{} + +// New returns an operating-system-backed Runner. +func New() *OSRunner { return &OSRunner{} } + +// Run executes command and includes bounded combined output in failures. +func (*OSRunner) Run(ctx context.Context, command Command) error { + process := exec.CommandContext(ctx, command.Name, command.Args...) + process.Dir = command.Dir + output, err := process.CombinedOutput() + if err == nil { + return nil + } + if cancellation := ctx.Err(); cancellation != nil { + err = cancellation + } + return fmt.Errorf("command.CombinedOutput: %s: %w", toolsafety.BoundedOutput(output), err) +} diff --git a/internal/client/toolrun/runner_test.go b/internal/client/toolrun/runner_test.go new file mode 100644 index 0000000..4212eba --- /dev/null +++ b/internal/client/toolrun/runner_test.go @@ -0,0 +1,85 @@ +package toolrun_test + +import ( + "context" + "fmt" + "os" + "os/exec" + "strings" + "testing" + "time" + + "github.com/devctllabs/devctl/internal/client/toolrun" + "github.com/stretchr/testify/require" +) + +func TestOSRunnerPreservesCommandArgumentsAndWorkingDirectory(t *testing.T) { + workingDirectory := t.TempDir() + t.Setenv("DEVCTL_TOOLRUN_HELPER", "command") + t.Setenv("DEVCTL_TOOLRUN_EXPECTED_DIR", workingDirectory) + + err := toolrun.New().Run(context.Background(), toolrun.Command{ + Name: os.Args[0], + Args: []string{"-test.run=TestToolrunHelperProcess", "--", "first", "second value"}, + Dir: workingDirectory, + }) + + require.NoError(t, err) +} + +func TestOSRunnerPreservesExitErrorAndBoundsDiagnostics(t *testing.T) { + t.Setenv("DEVCTL_TOOLRUN_HELPER", "failure") + + err := toolrun.New().Run(context.Background(), toolrun.Command{ + Name: os.Args[0], Args: []string{"-test.run=TestToolrunHelperProcess"}, + }) + + require.Error(t, err) + var exitError *exec.ExitError + require.ErrorAs(t, err, &exitError) + require.Equal(t, 7, exitError.ExitCode()) + require.Contains(t, err.Error(), strings.Repeat("x", 64)) + require.Less(t, len(err.Error()), 4300) +} + +func TestOSRunnerPreservesContextCancellation(t *testing.T) { + t.Setenv("DEVCTL_TOOLRUN_HELPER", "wait") + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + + err := toolrun.New().Run(ctx, toolrun.Command{ + Name: os.Args[0], Args: []string{"-test.run=TestToolrunHelperProcess"}, + }) + + require.ErrorIs(t, err, context.DeadlineExceeded) +} + +func TestToolrunHelperProcess(t *testing.T) { //nolint:paralleltest // The parent tests control this subprocess through inherited environment. + switch os.Getenv("DEVCTL_TOOLRUN_HELPER") { + case "": + return + case "command": + workingDirectory, err := os.Getwd() + if err != nil { + os.Exit(2) + } + separator := -1 + for index, argument := range os.Args { + if argument == "--" { + separator = index + break + } + } + if workingDirectory != os.Getenv("DEVCTL_TOOLRUN_EXPECTED_DIR") || + separator == -1 || fmt.Sprint(os.Args[separator+1:]) != "[first second value]" { + os.Exit(3) + } + case "failure": + _, _ = fmt.Fprint(os.Stderr, strings.Repeat("x", 5000)) + os.Exit(7) + case "wait": + time.Sleep(time.Minute) + default: + os.Exit(4) + } +} diff --git a/internal/client/toolrun/temporary.go b/internal/client/toolrun/temporary.go new file mode 100644 index 0000000..7dcdc01 --- /dev/null +++ b/internal/client/toolrun/temporary.go @@ -0,0 +1,25 @@ +package toolrun + +import ( + "context" + "fmt" + "os" +) + +// WithTemporaryOutput runs use in a temporary directory and always attempts cleanup. +func WithTemporaryOutput[T any]( + ctx context.Context, + prefix string, + use func(string) (T, error), +) (T, error) { + var zero T + if err := ctx.Err(); err != nil { + return zero, fmt.Errorf("ctx.Err: %w", err) + } + temporary, err := os.MkdirTemp("", prefix) + if err != nil { + return zero, fmt.Errorf("os.MkdirTemp: %w", err) + } + defer func() { _ = os.RemoveAll(temporary) }() + return use(temporary) +} diff --git a/internal/client/toolrun/temporary_test.go b/internal/client/toolrun/temporary_test.go new file mode 100644 index 0000000..498bf58 --- /dev/null +++ b/internal/client/toolrun/temporary_test.go @@ -0,0 +1,78 @@ +package toolrun_test + +import ( + "context" + "errors" + "path/filepath" + "strings" + "testing" + + "github.com/devctllabs/devctl/internal/client/toolrun" + "github.com/stretchr/testify/require" +) + +func TestWithTemporaryOutputReturnsValueAndCleansAfterSuccess(t *testing.T) { + t.Parallel() + + var temporary string + + value, err := toolrun.WithTemporaryOutput(context.Background(), "devctl-toolrun-success-", func(directory string) (string, error) { + temporary = directory + require.DirExists(t, directory) + require.True(t, strings.HasPrefix(filepath.Base(directory), "devctl-toolrun-success-")) + return "generated", nil + }) + + require.NoError(t, err) + require.Equal(t, "generated", value) + require.NoDirExists(t, temporary) +} + +func TestWithTemporaryOutputPreservesValueAndErrorAndCleansAfterFailure(t *testing.T) { + t.Parallel() + + primary := errors.New("generation failed") + var temporary string + + value, err := toolrun.WithTemporaryOutput(context.Background(), "devctl-toolrun-failure-", func(directory string) (int, error) { + temporary = directory + return 42, primary + }) + + require.Equal(t, 42, value) + require.ErrorIs(t, err, primary) + require.NoDirExists(t, temporary) +} + +func TestWithTemporaryOutputCleansAfterCancellation(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithCancel(context.Background()) + var temporary string + + value, err := toolrun.WithTemporaryOutput(ctx, "devctl-toolrun-cancel-", func(directory string) (int, error) { + temporary = directory + cancel() + return 7, ctx.Err() + }) + + require.Equal(t, 7, value) + require.ErrorIs(t, err, context.Canceled) + require.NoDirExists(t, temporary) +} + +func TestWithTemporaryOutputDoesNotStartAfterCancellation(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + called := false + + _, err := toolrun.WithTemporaryOutput(ctx, "devctl-toolrun-cancelled-", func(string) (int, error) { + called = true + return 0, nil + }) + + require.ErrorIs(t, err, context.Canceled) + require.False(t, called) +} diff --git a/internal/client/toolsafety/safety.go b/internal/client/toolsafety/safety.go new file mode 100644 index 0000000..90b45ba --- /dev/null +++ b/internal/client/toolsafety/safety.go @@ -0,0 +1,157 @@ +// Package toolsafety contains shared filesystem and diagnostic safety rules for generator tools. +package toolsafety + +import ( + "bytes" + "fmt" + "io/fs" + "path/filepath" + "sort" + "strings" + + "github.com/devctllabs/devctl/internal/domain/artifact" + devfs "github.com/devctllabs/go-libs/filesystem" +) + +// File is a contained regular file read from a tool workspace. +type File struct { + Content []byte + Mode fs.FileMode +} + +// ReadRegularFile reads name below root without accepting symlinks or non-regular files. +func ReadRegularFile(root, name string) (File, error) { + contained, err := containedName(root, name) + if err != nil { + return File{}, err + } + disk, err := devfs.Open(root) + if err != nil { + return File{}, fmt.Errorf("filesystem.Open: %w", err) + } + defer func() { _ = disk.Close() }() + info, err := disk.Lstat(contained) + if err != nil { + return File{}, fmt.Errorf("filesystem.Lstat: %w", err) + } + if info.Mode()&fs.ModeSymlink != 0 || !info.Mode().IsRegular() { + return File{}, fmt.Errorf("%w: path is not a regular non-symlink file: %s", fs.ErrInvalid, name) + } + content, err := fs.ReadFile(disk, contained) + if err != nil { + return File{}, fmt.Errorf("filesystem.ReadFile: %w", err) + } + return File{Content: content, Mode: info.Mode().Perm()}, nil +} + +// RequireDirectory requires name to be a real directory contained below root. +func RequireDirectory(root, name string) error { + contained, err := containedName(root, name) + if err != nil { + return err + } + disk, err := devfs.Open(root) + if err != nil { + return fmt.Errorf("filesystem.Open: %w", err) + } + defer func() { _ = disk.Close() }() + info, err := disk.Lstat(contained) + if err != nil { + return fmt.Errorf("filesystem.Lstat: %w", err) + } + if info.Mode()&fs.ModeSymlink != 0 || !info.IsDir() { + return fmt.Errorf("%w: path is not a real directory: %s", fs.ErrInvalid, name) + } + return nil +} + +// ReadRegularTree reads a contained tree of regular files using slash-separated relative paths. +func ReadRegularTree(root, directory string) (artifact.Tree, error) { + contained, err := containedName(root, directory) + if err != nil { + return artifact.Tree{}, err + } + disk, err := devfs.Open(root) + if err != nil { + return artifact.Tree{}, fmt.Errorf("filesystem.Open: %w", err) + } + defer func() { _ = disk.Close() }() + info, err := disk.Lstat(contained) + if err != nil { + return artifact.Tree{}, fmt.Errorf("filesystem.Lstat: %w", err) + } + if info.Mode()&fs.ModeSymlink != 0 || !info.IsDir() { + return artifact.Tree{}, fmt.Errorf("%w: path is not a real directory: %s", fs.ErrInvalid, directory) + } + + var tree artifact.Tree + err = fs.WalkDir(disk, contained, func(name string, entry fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if name == contained { + return nil + } + if entry.Type()&fs.ModeSymlink != 0 { + return fmt.Errorf("%w: generated output is a symlink: %s", fs.ErrInvalid, name) + } + if entry.IsDir() { + return nil + } + info, err := entry.Info() + if err != nil { + return fmt.Errorf("entry.Info: %w", err) + } + if !info.Mode().IsRegular() { + return fmt.Errorf("%w: generated output is not a regular file: %s", fs.ErrInvalid, name) + } + content, err := fs.ReadFile(disk, name) + if err != nil { + return fmt.Errorf("filesystem.ReadFile: %w", err) + } + relative := strings.TrimPrefix(name, contained+"/") + tree.Files = append(tree.Files, artifact.File{ + Path: relative, Content: content, Mode: uint32(info.Mode().Perm()), + }) + return nil + }) + if err != nil { + return artifact.Tree{}, fmt.Errorf("filesystem.WalkDir: %w", err) + } + sort.Slice(tree.Files, func(i, j int) bool { return tree.Files[i].Path < tree.Files[j].Path }) + return tree, nil +} + +// BoundedOutput trims tool output and limits it to 4096 bytes. +func BoundedOutput(output []byte) string { + const limit = 4 << 10 + output = bytes.TrimSpace(output) + if len(output) > limit { + output = output[:limit] + } + return string(output) +} + +func containedName(root, name string) (string, error) { + absoluteRoot, err := filepath.Abs(root) + if err != nil { + return "", fmt.Errorf("filepath.Abs root: %w", err) + } + absoluteName := name + if !filepath.IsAbs(absoluteName) { + absoluteName = filepath.Join(absoluteRoot, filepath.FromSlash(name)) + } + absoluteName, err = filepath.Abs(absoluteName) + if err != nil { + return "", fmt.Errorf("filepath.Abs name: %w", err) + } + relative, err := filepath.Rel(absoluteRoot, absoluteName) + if err != nil { + return "", fmt.Errorf("filepath.Rel: %w", err) + } + relative = filepath.ToSlash(relative) + if !fs.ValidPath(relative) { + return "", fmt.Errorf("%w: path %q is outside root", fs.ErrInvalid, name) + } + return relative, nil +} diff --git a/internal/client/toolsafety/safety_test.go b/internal/client/toolsafety/safety_test.go new file mode 100644 index 0000000..5c1d384 --- /dev/null +++ b/internal/client/toolsafety/safety_test.go @@ -0,0 +1,165 @@ +package toolsafety_test + +import ( + "context" + "net" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/devctllabs/devctl/internal/client/toolsafety" + "github.com/devctllabs/devctl/internal/domain/artifact" + "github.com/stretchr/testify/require" +) + +func TestReadRegularFileAcceptsContainedRelativeAndAbsolutePaths(t *testing.T) { + t.Parallel() + + root := t.TempDir() + filename := filepath.Join(root, "nested", "input.json") + require.NoError(t, os.MkdirAll(filepath.Dir(filename), 0o755)) + require.NoError(t, os.WriteFile(filename, []byte("schema"), 0o600)) + + for _, name := range []string{"nested/input.json", filename} { + file, err := toolsafety.ReadRegularFile(root, name) + + require.NoError(t, err) + require.Equal(t, []byte("schema"), file.Content) + require.Equal(t, os.FileMode(0o600), file.Mode.Perm()) + } +} + +func TestReadRegularFileRejectsUnsafeEntries(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + setup func(*testing.T, string) string + }{ + {name: "relative traversal", setup: func(t *testing.T, _ string) string { + t.Helper() + outside := filepath.Join(t.TempDir(), "outside.txt") + require.NoError(t, os.WriteFile(outside, []byte("outside"), 0o644)) + return "../" + filepath.Base(filepath.Dir(outside)) + "/outside.txt" + }}, + {name: "absolute outside", setup: func(t *testing.T, _ string) string { + t.Helper() + outside := filepath.Join(t.TempDir(), "outside.txt") + require.NoError(t, os.WriteFile(outside, []byte("outside"), 0o644)) + return outside + }}, + {name: "leaf symlink", setup: func(t *testing.T, root string) string { + t.Helper() + outside := filepath.Join(t.TempDir(), "outside.txt") + require.NoError(t, os.WriteFile(outside, []byte("outside"), 0o644)) + require.NoError(t, os.Symlink(outside, filepath.Join(root, "input.json"))) + return "input.json" + }}, + {name: "ancestor symlink", setup: func(t *testing.T, root string) string { + t.Helper() + outside := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(outside, "input.json"), []byte("outside"), 0o644)) + require.NoError(t, os.Symlink(outside, filepath.Join(root, "linked"))) + return "linked/input.json" + }}, + {name: "directory", setup: func(t *testing.T, root string) string { + t.Helper() + require.NoError(t, os.Mkdir(filepath.Join(root, "input.json"), 0o755)) + return "input.json" + }}, + {name: "socket", setup: func(t *testing.T, root string) string { + t.Helper() + return listenUnixSocket(t, root, "input.sock") + }}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + root := t.TempDir() + if test.name == "socket" { + root = shortTempDir(t) + } + + _, err := toolsafety.ReadRegularFile(root, test.setup(t, root)) + + require.Error(t, err) + }) + } +} + +func TestRequireDirectoryRejectsFilesAndSymlinks(t *testing.T) { + t.Parallel() + + root := t.TempDir() + require.NoError(t, os.Mkdir(filepath.Join(root, "contracts"), 0o755)) + require.NoError(t, toolsafety.RequireDirectory(root, "contracts")) + require.NoError(t, os.WriteFile(filepath.Join(root, "file"), []byte("data"), 0o644)) + require.Error(t, toolsafety.RequireDirectory(root, "file")) + require.NoError(t, os.Symlink(filepath.Join(root, "contracts"), filepath.Join(root, "linked"))) + require.Error(t, toolsafety.RequireDirectory(root, "linked")) +} + +func TestReadRegularTreeReturnsStableFilesAndRejectsUnsafeEntries(t *testing.T) { + t.Parallel() + + root := t.TempDir() + writeFile(t, root, artifact.File{Path: "generated/zeta.go", Content: []byte("zeta"), Mode: 0o644}) + writeFile(t, root, artifact.File{Path: "generated/alpha/nested.go", Content: []byte("alpha"), Mode: 0o600}) + + tree, err := toolsafety.ReadRegularTree(root, "generated") + + require.NoError(t, err) + require.Equal(t, artifact.Tree{Files: []artifact.File{ + {Path: "alpha/nested.go", Content: []byte("alpha"), Mode: 0o600}, + {Path: "zeta.go", Content: []byte("zeta"), Mode: 0o644}, + }}, tree) + + require.NoError(t, os.Symlink(filepath.Join(root, "generated", "zeta.go"), filepath.Join(root, "generated", "linked.go"))) + _, err = toolsafety.ReadRegularTree(root, "generated") + require.Error(t, err) + + socketRoot := shortTempDir(t) + require.NoError(t, os.Mkdir(filepath.Join(socketRoot, "generated"), 0o755)) + listenUnixSocket(t, filepath.Join(socketRoot, "generated"), "output.sock") + _, err = toolsafety.ReadRegularTree(socketRoot, "generated") + require.Error(t, err) +} + +func TestBoundedOutputTrimsAndLimitsDiagnostics(t *testing.T) { + t.Parallel() + + require.Equal(t, "message", toolsafety.BoundedOutput([]byte(" message\n"))) + exact := strings.Repeat("x", 4096) + require.Equal(t, exact, toolsafety.BoundedOutput([]byte(exact))) + require.Equal(t, exact, toolsafety.BoundedOutput([]byte(exact+"overflow"))) +} + +func writeFile(t *testing.T, root string, file artifact.File) { + t.Helper() + filename := filepath.Join(root, filepath.FromSlash(file.Path)) + require.NoError(t, os.MkdirAll(filepath.Dir(filename), 0o755)) + require.NoError(t, os.WriteFile(filename, file.Content, os.FileMode(file.Mode))) +} + +func listenUnixSocket(t *testing.T, root, name string) string { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("Unix socket file-kind check is not available on Windows") + } + listener, err := (&net.ListenConfig{}).Listen(context.Background(), "unix", filepath.Join(root, name)) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, listener.Close()) }) + return name +} + +func shortTempDir(t *testing.T) string { + t.Helper() + //nolint:usetesting // A regular t.TempDir path exceeds the Unix socket path limit on macOS. + root, err := os.MkdirTemp("/tmp", "devctl-toolsafety-") + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, os.RemoveAll(root)) }) + return root +} diff --git a/internal/deps/clients.go b/internal/deps/clients.go new file mode 100644 index 0000000..688d909 --- /dev/null +++ b/internal/deps/clients.go @@ -0,0 +1,124 @@ +package deps + +import ( + "fmt" + + bufgenclient "github.com/devctllabs/devctl/internal/client/bufgen" + generatorclient "github.com/devctllabs/devctl/internal/client/generator" + gitclient "github.com/devctllabs/devctl/internal/client/git" + httpclient "github.com/devctllabs/devctl/internal/client/http" + jsonschemaclient "github.com/devctllabs/devctl/internal/client/jsonschema" + oapicodegenclient "github.com/devctllabs/devctl/internal/client/oapicodegen" + "github.com/devctllabs/devctl/internal/client/toolrun" + generateservice "github.com/devctllabs/devctl/internal/service/generate" + lintservice "github.com/devctllabs/devctl/internal/service/lint" + "github.com/devctllabs/devctl/internal/service/materialize" + "github.com/devctllabs/go-libs/di" +) + +func (c *Container) provideClients() error { + providers := []func() error{ + c.provideToolRunner, + c.provideMaterializeClients, + c.provideGeneratorClients, + } + for _, provide := range providers { + if err := provide(); err != nil { + return err + } + } + return nil +} + +func (c *Container) provideToolRunner() error { + if err := di.Provide(c.di, func(di.Resolver) (*toolrun.OSRunner, error) { + return toolrun.New(), nil + }); err != nil { + return fmt.Errorf("di.Provide: %w", err) + } + if err := di.Provide(c.di, func(resolver di.Resolver) (toolrun.Runner, error) { + return resolve[*toolrun.OSRunner](resolver) + }); err != nil { + return fmt.Errorf("di.Provide: %w", err) + } + return nil +} + +func (c *Container) provideMaterializeClients() error { + if err := di.Provide(c.di, func(di.Resolver) (*httpclient.Client, error) { + return httpclient.New(), nil + }); err != nil { + return fmt.Errorf("di.Provide: %w", err) + } + if err := di.Provide(c.di, func(resolver di.Resolver) (materialize.HTTPClient, error) { + return resolve[*httpclient.Client](resolver) + }); err != nil { + return fmt.Errorf("di.Provide: %w", err) + } + if err := di.Provide(c.di, func(di.Resolver) (*gitclient.Client, error) { + return gitclient.New(), nil + }); err != nil { + return fmt.Errorf("di.Provide: %w", err) + } + if err := di.Provide(c.di, func(resolver di.Resolver) (materialize.GitClient, error) { + return resolve[*gitclient.Client](resolver) + }); err != nil { + return fmt.Errorf("di.Provide: %w", err) + } + return nil +} + +func (c *Container) provideGeneratorClients() error { + if err := di.Provide(c.di, func(resolver di.Resolver) (*oapicodegenclient.Client, error) { + runner, err := resolve[toolrun.Runner](resolver) + if err != nil { + return nil, err + } + return oapicodegenclient.New(runner), nil + }); err != nil { + return fmt.Errorf("di.Provide: %w", err) + } + if err := di.Provide(c.di, func(resolver di.Resolver) (*bufgenclient.Client, error) { + runner, err := resolve[toolrun.Runner](resolver) + if err != nil { + return nil, err + } + return bufgenclient.New(runner), nil + }); err != nil { + return fmt.Errorf("di.Provide: %w", err) + } + if err := di.Provide(c.di, func(resolver di.Resolver) (*jsonschemaclient.Client, error) { + runner, err := resolve[toolrun.Runner](resolver) + if err != nil { + return nil, err + } + return jsonschemaclient.New(runner), nil + }); err != nil { + return fmt.Errorf("di.Provide: %w", err) + } + if err := di.Provide(c.di, func(resolver di.Resolver) (generateservice.GeneratorClient, error) { + openAPI, err := resolve[*oapicodegenclient.Client](resolver) + if err != nil { + return nil, err + } + proto, err := resolve[*bufgenclient.Client](resolver) + if err != nil { + return nil, err + } + jsonSchema, err := resolve[*jsonschemaclient.Client](resolver) + if err != nil { + return nil, err + } + return generatorclient.New(generatorclient.Adapters{ + OpenAPI: openAPI, Proto: proto, JSONSchema: jsonSchema, + }), nil + }); err != nil { + return fmt.Errorf("di.Provide: %w", err) + } + if err := di.Provide(c.di, func(resolver di.Resolver) (lintservice.ProtoLinter, error) { + return resolve[*bufgenclient.Client](resolver) + }); err != nil { + return fmt.Errorf("di.Provide: %w", err) + } + return nil +} diff --git a/internal/deps/container.go b/internal/deps/container.go new file mode 100644 index 0000000..64bd991 --- /dev/null +++ b/internal/deps/container.go @@ -0,0 +1,93 @@ +package deps + +import ( + "context" + "fmt" + + generateservice "github.com/devctllabs/devctl/internal/service/generate" + lintservice "github.com/devctllabs/devctl/internal/service/lint" + "github.com/devctllabs/devctl/internal/service/project" + "github.com/devctllabs/devctl/internal/service/scaffold" + syncservice "github.com/devctllabs/devctl/internal/service/sync" + "github.com/devctllabs/go-libs/di" + "go.uber.org/zap" +) + +// Container owns the lazy dependency graph and the resources resolved from it. +type Container struct { + di *di.Container +} + +// New registers the complete lazy CLI dependency graph. Providers construct +// dependencies only when a typed getter resolves the selected command root. +func New(logger *zap.Logger) (*Container, error) { + container := &Container{di: di.New()} + providers := []func() error{ + func() error { return container.provideLogger(logger) }, + container.provideRepositories, + container.provideTargetInput, + container.provideScaffoldRepositories, + container.provideClients, + container.provideServices, + container.provideScaffoldServices, + } + for _, provide := range providers { + if err := provide(); err != nil { + return nil, err + } + } + return container, nil +} + +// ProjectService resolves the project query and mutation root on demand. +func (c *Container) ProjectService() (*project.Service, error) { + service, err := resolve[*project.Service](c.di) + if err != nil { + return nil, fmt.Errorf("di.Resolve: %w", err) + } + return service, nil +} + +// ScaffoldService resolves the project scaffolding root on demand. +func (c *Container) ScaffoldService() (*scaffold.Service, error) { + service, err := resolve[*scaffold.Service](c.di) + if err != nil { + return nil, fmt.Errorf("di.Resolve: %w", err) + } + return service, nil +} + +// SyncService resolves the contract synchronization root on demand. +func (c *Container) SyncService() (*syncservice.Service, error) { + service, err := resolve[*syncservice.Service](c.di) + if err != nil { + return nil, fmt.Errorf("di.Resolve: %w", err) + } + return service, nil +} + +// LintService resolves the contract lint root on demand. +func (c *Container) LintService() (*lintservice.Service, error) { + service, err := resolve[*lintservice.Service](c.di) + if err != nil { + return nil, fmt.Errorf("di.Resolve: %w", err) + } + return service, nil +} + +// GenService resolves the managed-output generation root on demand. +func (c *Container) GenService() (*generateservice.Service, error) { + service, err := resolve[*generateservice.Service](c.di) + if err != nil { + return nil, fmt.Errorf("di.Resolve: %w", err) + } + return service, nil +} + +// Shutdown closes every constructed resource in dependency order. +func (c *Container) Shutdown(ctx context.Context) error { + if err := c.di.Shutdown(ctx); err != nil { + return fmt.Errorf("di.Shutdown: %w", err) + } + return nil +} diff --git a/internal/deps/init_scaffold.go b/internal/deps/init_scaffold.go new file mode 100644 index 0000000..3072c42 --- /dev/null +++ b/internal/deps/init_scaffold.go @@ -0,0 +1 @@ +package deps diff --git a/internal/deps/logger.go b/internal/deps/logger.go new file mode 100644 index 0000000..192bb3b --- /dev/null +++ b/internal/deps/logger.go @@ -0,0 +1,15 @@ +package deps + +import ( + "fmt" + + "github.com/devctllabs/go-libs/di" + "go.uber.org/zap" +) + +func (c *Container) provideLogger(logger *zap.Logger) error { + if err := di.ProvideValue(c.di, logger); err != nil { + return fmt.Errorf("di.ProvideValue: %w", err) + } + return nil +} diff --git a/internal/deps/repositories.go b/internal/deps/repositories.go new file mode 100644 index 0000000..732c948 --- /dev/null +++ b/internal/deps/repositories.go @@ -0,0 +1,84 @@ +package deps + +import ( + "fmt" + + manifestrepo "github.com/devctllabs/devctl/internal/repository/manifest" + workspacerepo "github.com/devctllabs/devctl/internal/repository/workspace" + generateservice "github.com/devctllabs/devctl/internal/service/generate" + lintservice "github.com/devctllabs/devctl/internal/service/lint" + "github.com/devctllabs/devctl/internal/service/materialize" + projectservice "github.com/devctllabs/devctl/internal/service/project" + "github.com/devctllabs/devctl/internal/service/projectreadiness" + syncservice "github.com/devctllabs/devctl/internal/service/sync" + "github.com/devctllabs/go-libs/di" +) + +func (c *Container) provideRepositories() error { + if err := di.Provide(c.di, func(di.Resolver) (*manifestrepo.FilesystemRepo, error) { + return manifestrepo.NewFilesystemRepo(), nil + }); err != nil { + return fmt.Errorf("di.Provide: %w", err) + } + if err := di.Provide(c.di, func(resolver di.Resolver) (projectservice.ManifestRepository, error) { + return resolve[*manifestrepo.FilesystemRepo](resolver) + }); err != nil { + return fmt.Errorf("di.Provide: %w", err) + } + if err := di.Provide(c.di, func(di.Resolver) (*workspacerepo.FilesystemRepo, error) { + return workspacerepo.NewFilesystemRepo(), nil + }); err != nil { + return fmt.Errorf("di.Provide: %w", err) + } + if err := di.Provide(c.di, func(resolver di.Resolver) (projectservice.ManifestLocator, error) { + return resolve[*workspacerepo.FilesystemRepo](resolver) + }); err != nil { + return fmt.Errorf("di.Provide: %w", err) + } + if err := di.Provide(c.di, func(resolver di.Resolver) (projectreadiness.Workspace, error) { + return resolve[*workspacerepo.FilesystemRepo](resolver) + }); err != nil { + return fmt.Errorf("di.Provide: %w", err) + } + if err := di.Provide(c.di, func(resolver di.Resolver) (syncservice.ProjectRepository, error) { + return resolve[*projectservice.Service](resolver) + }); err != nil { + return fmt.Errorf("di.Provide: %w", err) + } + if err := di.Provide(c.di, func(resolver di.Resolver) (syncservice.WorkspaceRepository, error) { + return resolve[*workspacerepo.FilesystemRepo](resolver) + }); err != nil { + return fmt.Errorf("di.Provide: %w", err) + } + if err := di.Provide(c.di, func(resolver di.Resolver) (materialize.FileReader, error) { + return resolve[*workspacerepo.FilesystemRepo](resolver) + }); err != nil { + return fmt.Errorf("di.Provide: %w", err) + } + if err := di.Provide(c.di, func(resolver di.Resolver) (materialize.ManifestRepository, error) { + return resolve[*manifestrepo.FilesystemRepo](resolver) + }); err != nil { + return fmt.Errorf("di.Provide: %w", err) + } + if err := di.Provide(c.di, func(resolver di.Resolver) (generateservice.ProjectRepository, error) { + return resolve[*projectservice.Service](resolver) + }); err != nil { + return fmt.Errorf("di.Provide: %w", err) + } + if err := di.Provide(c.di, func(resolver di.Resolver) (generateservice.WorkspaceRepository, error) { + return resolve[*workspacerepo.FilesystemRepo](resolver) + }); err != nil { + return fmt.Errorf("di.Provide: %w", err) + } + if err := di.Provide(c.di, func(resolver di.Resolver) (lintservice.ProjectRepository, error) { + return resolve[*projectservice.Service](resolver) + }); err != nil { + return fmt.Errorf("di.Provide: %w", err) + } + if err := di.Provide(c.di, func(resolver di.Resolver) (lintservice.ContractLocator, error) { + return resolve[*workspacerepo.FilesystemRepo](resolver) + }); err != nil { + return fmt.Errorf("di.Provide: %w", err) + } + return nil +} diff --git a/internal/deps/resolve.go b/internal/deps/resolve.go new file mode 100644 index 0000000..b3eb91d --- /dev/null +++ b/internal/deps/resolve.go @@ -0,0 +1,16 @@ +package deps + +import ( + "fmt" + + "github.com/devctllabs/go-libs/di" +) + +func resolve[T any](resolver di.Resolver) (T, error) { + value, err := di.Resolve[T](resolver) + if err != nil { + var zero T + return zero, fmt.Errorf("di.Resolve: %w", err) + } + return value, nil +} diff --git a/internal/deps/scaffold_repositories.go b/internal/deps/scaffold_repositories.go new file mode 100644 index 0000000..696402d --- /dev/null +++ b/internal/deps/scaffold_repositories.go @@ -0,0 +1,24 @@ +package deps + +import ( + "fmt" + + workspacerepo "github.com/devctllabs/devctl/internal/repository/workspace" + projectservice "github.com/devctllabs/devctl/internal/service/project" + scaffoldservice "github.com/devctllabs/devctl/internal/service/scaffold" + "github.com/devctllabs/go-libs/di" +) + +func (c *Container) provideScaffoldRepositories() error { + if err := di.Provide(c.di, func(resolver di.Resolver) (scaffoldservice.ProjectRepository, error) { + return resolve[*projectservice.Service](resolver) + }); err != nil { + return fmt.Errorf("di.Provide: %w", err) + } + if err := di.Provide(c.di, func(resolver di.Resolver) (scaffoldservice.WorkspaceRepository, error) { + return resolve[*workspacerepo.FilesystemRepo](resolver) + }); err != nil { + return fmt.Errorf("di.Provide: %w", err) + } + return nil +} diff --git a/internal/deps/scaffold_services.go b/internal/deps/scaffold_services.go new file mode 100644 index 0000000..4e3d3c8 --- /dev/null +++ b/internal/deps/scaffold_services.go @@ -0,0 +1,30 @@ +package deps + +import ( + "fmt" + + scaffoldservice "github.com/devctllabs/devctl/internal/service/scaffold" + "github.com/devctllabs/go-libs/di" + "go.uber.org/zap" +) + +func (c *Container) provideScaffoldServices() error { + if err := di.Provide(c.di, func(resolver di.Resolver) (*scaffoldservice.Service, error) { + logger, err := resolve[*zap.Logger](resolver) + if err != nil { + return nil, err + } + projects, err := resolve[scaffoldservice.ProjectRepository](resolver) + if err != nil { + return nil, err + } + workspace, err := resolve[scaffoldservice.WorkspaceRepository](resolver) + if err != nil { + return nil, err + } + return scaffoldservice.New(logger.Named("service.scaffold"), projects, workspace), nil + }); err != nil { + return fmt.Errorf("di.Provide: %w", err) + } + return nil +} diff --git a/internal/deps/services.go b/internal/deps/services.go new file mode 100644 index 0000000..3910e71 --- /dev/null +++ b/internal/deps/services.go @@ -0,0 +1,192 @@ +package deps + +import ( + "fmt" + + generateservice "github.com/devctllabs/devctl/internal/service/generate" + lintservice "github.com/devctllabs/devctl/internal/service/lint" + "github.com/devctllabs/devctl/internal/service/materialize" + projectservice "github.com/devctllabs/devctl/internal/service/project" + "github.com/devctllabs/devctl/internal/service/projectreadiness" + syncservice "github.com/devctllabs/devctl/internal/service/sync" + "github.com/devctllabs/go-libs/di" + "go.uber.org/zap" +) + +func (c *Container) provideServices() error { + providers := []func() error{ + c.provideProjectReadiness, + c.provideProjectService, + c.provideMaterializeService, + c.provideSyncService, + c.provideLintService, + c.provideGenService, + } + for _, provide := range providers { + if err := provide(); err != nil { + return err + } + } + return nil +} + +func (c *Container) provideProjectReadiness() error { + if err := di.Provide(c.di, func(resolver di.Resolver) (*projectreadiness.Checker, error) { + workspace, err := resolve[projectreadiness.Workspace](resolver) + if err != nil { + return nil, err + } + return projectreadiness.New(workspace), nil + }); err != nil { + return fmt.Errorf("di.Provide: %w", err) + } + return nil +} + +func (c *Container) provideMaterializeService() error { + if err := di.Provide(c.di, func(resolver di.Resolver) (*materialize.Service, error) { + reader, err := resolve[materialize.FileReader](resolver) + if err != nil { + return nil, err + } + httpClient, err := resolve[materialize.HTTPClient](resolver) + if err != nil { + return nil, err + } + gitClient, err := resolve[materialize.GitClient](resolver) + if err != nil { + return nil, err + } + manifests, err := resolve[materialize.ManifestRepository](resolver) + if err != nil { + return nil, err + } + return materialize.New( + materialize.NewLocal(reader), + materialize.NewURL(httpClient), + materialize.NewGit(gitClient, reader), + materialize.NewDevctl(gitClient, manifests, reader), + ) + }); err != nil { + return fmt.Errorf("di.Provide: %w", err) + } + return nil +} + +func (c *Container) provideProjectService() error { + if err := di.Provide(c.di, func(resolver di.Resolver) (*projectservice.Service, error) { + logger, err := resolve[*zap.Logger](resolver) + if err != nil { + return nil, err + } + manifests, err := resolve[projectservice.ManifestRepository](resolver) + if err != nil { + return nil, err + } + locator, err := resolve[projectservice.ManifestLocator](resolver) + if err != nil { + return nil, err + } + inputs, err := resolve[projectservice.TargetResolver](resolver) + if err != nil { + return nil, err + } + readiness, err := resolve[*projectreadiness.Checker](resolver) + if err != nil { + return nil, err + } + return projectservice.New(logger.Named("service.project"), projectservice.Dependencies{ + Manifests: manifests, Locator: locator, Inputs: inputs, Readiness: readiness, + }), nil + }); err != nil { + return fmt.Errorf("di.Provide: %w", err) + } + return nil +} + +func (c *Container) provideSyncService() error { + if err := di.Provide(c.di, func(resolver di.Resolver) (*syncservice.Service, error) { + logger, err := resolve[*zap.Logger](resolver) + if err != nil { + return nil, err + } + projects, err := resolve[syncservice.ProjectRepository](resolver) + if err != nil { + return nil, err + } + sources, err := resolve[*materialize.Service](resolver) + if err != nil { + return nil, err + } + workspace, err := resolve[syncservice.WorkspaceRepository](resolver) + if err != nil { + return nil, err + } + return syncservice.New(logger.Named("service.sync"), projects, sources, workspace), nil + }); err != nil { + return fmt.Errorf("di.Provide: %w", err) + } + return nil +} + +func (c *Container) provideLintService() error { + if err := di.Provide(c.di, func(resolver di.Resolver) (*lintservice.Service, error) { + logger, err := resolve[*zap.Logger](resolver) + if err != nil { + return nil, err + } + projects, err := resolve[lintservice.ProjectRepository](resolver) + if err != nil { + return nil, err + } + contracts, err := resolve[lintservice.ContractLocator](resolver) + if err != nil { + return nil, err + } + proto, err := resolve[lintservice.ProtoLinter](resolver) + if err != nil { + return nil, err + } + inputs, err := resolve[lintservice.TargetResolver](resolver) + if err != nil { + return nil, err + } + return lintservice.New(logger.Named("service.lint"), lintservice.Dependencies{ + Projects: projects, Contracts: contracts, Inputs: inputs, Proto: proto, + }), nil + }); err != nil { + return fmt.Errorf("di.Provide: %w", err) + } + return nil +} + +func (c *Container) provideGenService() error { + if err := di.Provide(c.di, func(resolver di.Resolver) (*generateservice.Service, error) { + logger, err := resolve[*zap.Logger](resolver) + if err != nil { + return nil, err + } + projects, err := resolve[generateservice.ProjectRepository](resolver) + if err != nil { + return nil, err + } + generator, err := resolve[generateservice.GeneratorClient](resolver) + if err != nil { + return nil, err + } + inputs, err := resolve[generateservice.TargetResolver](resolver) + if err != nil { + return nil, err + } + workspace, err := resolve[generateservice.WorkspaceRepository](resolver) + if err != nil { + return nil, err + } + return generateservice.New(logger.Named("service.gen"), generateservice.Dependencies{ + Projects: projects, Inputs: inputs, Generator: generator, Workspace: workspace, + }), nil + }); err != nil { + return fmt.Errorf("di.Provide: %w", err) + } + return nil +} diff --git a/internal/deps/target_input.go b/internal/deps/target_input.go new file mode 100644 index 0000000..387b469 --- /dev/null +++ b/internal/deps/target_input.go @@ -0,0 +1,54 @@ +package deps + +import ( + "fmt" + + workspacerepo "github.com/devctllabs/devctl/internal/repository/workspace" + "github.com/devctllabs/devctl/internal/service/contractsnapshot" + generateservice "github.com/devctllabs/devctl/internal/service/generate" + lintservice "github.com/devctllabs/devctl/internal/service/lint" + projectservice "github.com/devctllabs/devctl/internal/service/project" + "github.com/devctllabs/devctl/internal/service/targetinput" + "github.com/devctllabs/go-libs/di" +) + +func (c *Container) provideTargetInput() error { + if err := di.Provide(c.di, func(resolver di.Resolver) (*contractsnapshot.Loader, error) { + workspace, err := resolve[*workspacerepo.FilesystemRepo](resolver) + if err != nil { + return nil, err + } + return contractsnapshot.New(workspace), nil + }); err != nil { + return fmt.Errorf("di.Provide: %w", err) + } + if err := di.Provide(c.di, func(resolver di.Resolver) (*targetinput.Resolver, error) { + workspace, err := resolve[*workspacerepo.FilesystemRepo](resolver) + if err != nil { + return nil, err + } + snapshots, err := resolve[*contractsnapshot.Loader](resolver) + if err != nil { + return nil, err + } + return targetinput.New(workspace, snapshots), nil + }); err != nil { + return fmt.Errorf("di.Provide: %w", err) + } + if err := di.Provide(c.di, func(resolver di.Resolver) (generateservice.TargetResolver, error) { + return resolve[*targetinput.Resolver](resolver) + }); err != nil { + return fmt.Errorf("di.Provide: %w", err) + } + if err := di.Provide(c.di, func(resolver di.Resolver) (lintservice.TargetResolver, error) { + return resolve[*targetinput.Resolver](resolver) + }); err != nil { + return fmt.Errorf("di.Provide: %w", err) + } + if err := di.Provide(c.di, func(resolver di.Resolver) (projectservice.TargetResolver, error) { + return resolve[*targetinput.Resolver](resolver) + }); err != nil { + return fmt.Errorf("di.Provide: %w", err) + } + return nil +} diff --git a/internal/deps/validate.go b/internal/deps/validate.go new file mode 100644 index 0000000..3072c42 --- /dev/null +++ b/internal/deps/validate.go @@ -0,0 +1 @@ +package deps diff --git a/internal/deps/validate_test.go b/internal/deps/validate_test.go new file mode 100644 index 0000000..e8c1954 --- /dev/null +++ b/internal/deps/validate_test.go @@ -0,0 +1,118 @@ +package deps + +import ( + "context" + "testing" + + bufgenclient "github.com/devctllabs/devctl/internal/client/bufgen" + generatorclient "github.com/devctllabs/devctl/internal/client/generator" + "github.com/devctllabs/devctl/internal/client/toolrun" + "github.com/devctllabs/devctl/internal/service/contractsnapshot" + generateservice "github.com/devctllabs/devctl/internal/service/generate" + lintservice "github.com/devctllabs/devctl/internal/service/lint" + projectservice "github.com/devctllabs/devctl/internal/service/project" + "github.com/devctllabs/devctl/internal/service/projectreadiness" + "github.com/devctllabs/devctl/internal/service/targetinput" + "github.com/devctllabs/go-libs/di" + "github.com/stretchr/testify/require" + "go.uber.org/zap" +) + +func TestNewRegistersOneLazyGraphWithTypedRoots(t *testing.T) { + t.Parallel() + + container, err := New(zap.NewNop()) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, container.Shutdown(context.Background())) }) + + projectService, err := container.ProjectService() + require.NoError(t, err) + require.NotNil(t, projectService) + scaffoldService, err := container.ScaffoldService() + require.NoError(t, err) + require.NotNil(t, scaffoldService) + syncService, err := container.SyncService() + require.NoError(t, err) + require.NotNil(t, syncService) + lintService, err := container.LintService() + require.NoError(t, err) + require.NotNil(t, lintService) + genService, err := container.GenService() + require.NoError(t, err) + require.NotNil(t, genService) +} + +func TestNewRegistersTheCommandDiagnosticLogger(t *testing.T) { + t.Parallel() + + logger := zap.NewExample() + container, err := New(logger) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, container.Shutdown(context.Background())) }) + resolved, err := di.Resolve[*zap.Logger](container.di) + require.NoError(t, err) + require.Same(t, logger, resolved) +} + +func TestNewRegistersCompositeGeneratorAndSharesBufWithLint(t *testing.T) { + t.Parallel() + + container, err := New(zap.NewNop()) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, container.Shutdown(context.Background())) }) + + generator, err := di.Resolve[generateservice.GeneratorClient](container.di) + require.NoError(t, err) + require.IsType(t, &generatorclient.Client{}, generator) + buf, err := di.Resolve[*bufgenclient.Client](container.di) + require.NoError(t, err) + linter, err := di.Resolve[lintservice.ProtoLinter](container.di) + require.NoError(t, err) + require.Same(t, buf, linter) +} + +func TestNewRegistersOneSharedToolRunner(t *testing.T) { + t.Parallel() + + container, err := New(zap.NewNop()) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, container.Shutdown(context.Background())) }) + + osRunner, err := di.Resolve[*toolrun.OSRunner](container.di) + require.NoError(t, err) + runner, err := di.Resolve[toolrun.Runner](container.di) + require.NoError(t, err) + require.Same(t, osRunner, runner) +} + +func TestNewRegistersSharedTargetInputServices(t *testing.T) { + t.Parallel() + + container, err := New(zap.NewNop()) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, container.Shutdown(context.Background())) }) + + snapshots, err := di.Resolve[*contractsnapshot.Loader](container.di) + require.NoError(t, err) + require.NotNil(t, snapshots) + inputs, err := di.Resolve[*targetinput.Resolver](container.di) + require.NoError(t, err) + require.NotNil(t, inputs) +} + +func TestNewRegistersSplitProjectLocationAndReadinessServices(t *testing.T) { + t.Parallel() + + container, err := New(zap.NewNop()) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, container.Shutdown(context.Background())) }) + + locator, err := di.Resolve[projectservice.ManifestLocator](container.di) + require.NoError(t, err) + readinessWorkspace, err := di.Resolve[projectreadiness.Workspace](container.di) + require.NoError(t, err) + require.Same(t, locator, readinessWorkspace) + checker, err := di.Resolve[*projectreadiness.Checker](container.di) + require.NoError(t, err) + require.NotNil(t, checker) +} diff --git a/internal/domain/artifact/tree.go b/internal/domain/artifact/tree.go new file mode 100644 index 0000000..f479660 --- /dev/null +++ b/internal/domain/artifact/tree.go @@ -0,0 +1,36 @@ +package artifact + +// File is one managed output file relative to its containing Tree. +type File struct { + Path string + Content []byte + // Mode contains Unix permission bits; publishers decide which other mode bits they support. + Mode uint32 +} + +// Tree is the complete desired snapshot of one managed output directory. +type Tree struct { + Files []File +} + +// PublishAction classifies one atomically observed publication effect. +type PublishAction string + +const ( + PublishCreated PublishAction = "created" + PublishUpdated PublishAction = "updated" + PublishUnchanged PublishAction = "unchanged" + PublishRemoved PublishAction = "removed" +) + +// PublishChange describes one file inside a completely published Tree. +type PublishChange struct { + Path string + Action PublishAction +} + +// PublishResult reports the target effect and precise file effects from one publication call. +type PublishResult struct { + Action PublishAction + Changes []PublishChange +} diff --git a/internal/domain/contract/metadata.go b/internal/domain/contract/metadata.go new file mode 100644 index 0000000..c2fac23 --- /dev/null +++ b/internal/domain/contract/metadata.go @@ -0,0 +1,271 @@ +package contract + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "path" + "strings" + + "github.com/devctllabs/devctl/internal/domain/failure" +) + +// MetadataInvalidReason identifies why committed Snapshot Metadata is stale. +type MetadataInvalidReason string + +const ( + MetadataRequired MetadataInvalidReason = "required" + MetadataInvalidType MetadataInvalidReason = "invalid_type" + MetadataInvalidPath MetadataInvalidReason = "invalid_path" + MetadataMismatch MetadataInvalidReason = "mismatch" + MetadataNotFound MetadataInvalidReason = "not_found" + MetadataNotRegular MetadataInvalidReason = "not_regular" + MetadataUnexpected MetadataInvalidReason = "unexpected" +) + +// SnapshotMetadataError reports stale committed metadata and tells callers how to refresh it. +type SnapshotMetadataError struct { + Field string + Reason MetadataInvalidReason + Hint string + Cause error +} + +func (e *SnapshotMetadataError) Error() string { + return fmt.Sprintf("snapshot metadata field %q is invalid: %s", e.Field, e.Reason) +} + +func (e *SnapshotMetadataError) Unwrap() error { return e.Cause } + +func (e *SnapshotMetadataError) Category() failure.Category { return failure.InvalidInput } + +// MetadataExpectation binds a committed Snapshot to its consuming Target. +type MetadataExpectation struct { + Kind string + Topic string + Format string +} + +// DecodeMetadata decodes one strict sidecar document. +func DecodeMetadata(data []byte) (Metadata, error) { + var metadata Metadata + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&metadata); err != nil { + return Metadata{}, invalidMetadata(jsonErrorField(err), MetadataInvalidType, err) + } + if err := requireJSONEnd(decoder); err != nil { + return Metadata{}, invalidMetadata(".devctl-contract.json", MetadataInvalidType, err) + } + return metadata, nil +} + +func requireJSONEnd(decoder *json.Decoder) error { + var extra any + err := decoder.Decode(&extra) + if errors.Is(err, io.EOF) { + return nil + } + if err == nil { + return errors.New("multiple JSON values") + } + return fmt.Errorf("decoder.Decode: %w", err) +} + +func jsonErrorField(err error) string { + var typeErr *json.UnmarshalTypeError + if errors.As(err, &typeErr) && typeErr.Field != "" { + parts := strings.Split(typeErr.Field, ".") + return jsonFieldName(parts[len(parts)-1]) + } + const unknownPrefix = "json: unknown field \"" + if message := err.Error(); strings.HasPrefix(message, unknownPrefix) && strings.HasSuffix(message, "\"") { + return strings.TrimSuffix(strings.TrimPrefix(message, unknownPrefix), "\"") + } + return ".devctl-contract.json" +} + +func jsonFieldName(name string) string { + switch name { + case "Kind": + return "kind" + case "Topic": + return "topic" + case "Format": + return "format" + case "Entrypoint": + return "entrypoint" + case "ModuleRoot": + return "module_root" + case "BufConfig": + return "buf_config" + default: + return name + } +} + +// ValidateSnapshot checks metadata shape, Target identity, paths, and referenced files. +func ValidateSnapshot(snapshot Snapshot, expected MetadataExpectation) error { + if snapshot.Metadata == nil { + return invalidMetadata(".devctl-contract.json", MetadataRequired, nil) + } + metadata := *snapshot.Metadata + if err := ValidateMetadata(metadata, expected); err != nil { + return err + } + files := make(map[string]struct{}, len(snapshot.Files)) + for _, file := range snapshot.Files { + files[path.Clean(file.Path)] = struct{}{} + } + if metadata.Entrypoint != "" { + if _, exists := files[metadata.Entrypoint]; !exists { + return invalidMetadata("entrypoint", MetadataNotFound, nil) + } + } + if metadata.BufConfig != "" { + if _, exists := files[metadata.BufConfig]; !exists { + return invalidMetadata("buf_config", MetadataNotFound, nil) + } + } + if metadata.ModuleRoot != "" && !containsModuleFile(files, metadata.ModuleRoot) { + return invalidMetadata("module_root", MetadataNotFound, nil) + } + if metadata.Format == "raw" && len(snapshot.Files) != 0 { + return invalidMetadata("files", MetadataUnexpected, nil) + } + return nil +} + +// ValidateMetadata checks one sidecar's shape and consuming Target identity. +func ValidateMetadata(metadata Metadata, expected MetadataExpectation) error { + if err := validateIdentity(metadata, expected); err != nil { + return err + } + return validateMetadataShape(metadata) +} + +func validateIdentity(metadata Metadata, expected MetadataExpectation) error { + for _, value := range []struct { + field, actual, expected string + }{ + {"kind", metadata.Kind, expected.Kind}, + {"topic", metadata.Topic, expected.Topic}, + {"format", metadata.Format, expected.Format}, + } { + if value.expected != "" && value.actual != value.expected { + return invalidMetadata(value.field, MetadataMismatch, nil) + } + } + return nil +} + +func validateMetadataShape(metadata Metadata) error { + if metadata.Kind == "" { + return invalidMetadata("kind", MetadataRequired, nil) + } + switch metadata.Kind { + case "grpc": + if metadata.Format != "proto" { + return invalidMetadata("format", MetadataMismatch, nil) + } + if metadata.Topic != "" { + return invalidMetadata("topic", MetadataUnexpected, nil) + } + if metadata.Entrypoint != "" { + return invalidMetadata("entrypoint", MetadataUnexpected, nil) + } + return validateProtoMetadata(metadata, false) + case "kafka": + return validateKafkaMetadata(metadata) + default: + return invalidMetadata("kind", MetadataMismatch, nil) + } +} + +func validateKafkaMetadata(metadata Metadata) error { + if metadata.Topic == "" { + return invalidMetadata("topic", MetadataRequired, nil) + } + switch metadata.Format { + case "raw": + for _, value := range []struct{ field, value string }{ + {"entrypoint", metadata.Entrypoint}, {"module_root", metadata.ModuleRoot}, {"buf_config", metadata.BufConfig}, + } { + if value.value != "" { + return invalidMetadata(value.field, MetadataUnexpected, nil) + } + } + return nil + case "json": + if err := requireMetadataPath("entrypoint", metadata.Entrypoint, false); err != nil { + return err + } + if metadata.ModuleRoot != "" { + return invalidMetadata("module_root", MetadataUnexpected, nil) + } + if metadata.BufConfig != "" { + return invalidMetadata("buf_config", MetadataUnexpected, nil) + } + return nil + case "proto": + return validateProtoMetadata(metadata, true) + default: + return invalidMetadata("format", MetadataMismatch, nil) + } +} + +func validateProtoMetadata(metadata Metadata, requireEntrypoint bool) error { + if err := requireMetadataPath("module_root", metadata.ModuleRoot, true); err != nil { + return err + } + if err := requireMetadataPath("buf_config", metadata.BufConfig, false); err != nil { + return err + } + if requireEntrypoint { + if err := requireMetadataPath("entrypoint", metadata.Entrypoint, false); err != nil { + return err + } + if !pathWithin(metadata.ModuleRoot, metadata.Entrypoint) { + return invalidMetadata("entrypoint", MetadataInvalidPath, nil) + } + } + return nil +} + +func requireMetadataPath(field, value string, allowCurrent bool) error { + if value == "" { + return invalidMetadata(field, MetadataRequired, nil) + } + if allowCurrent && value == "." { + return nil + } + if !safeMetadataPath(value) { + return invalidMetadata(field, MetadataInvalidPath, nil) + } + return nil +} + +func safeMetadataPath(value string) bool { + return value != "" && value != "." && !path.IsAbs(value) && + path.Clean(value) == value && value != ".." && !strings.HasPrefix(value, "../") && + !strings.Contains(value, "\\") +} + +func pathWithin(root, name string) bool { + return root == "." || name == root || strings.HasPrefix(name, root+"/") +} + +func containsModuleFile(files map[string]struct{}, moduleRoot string) bool { + for name := range files { + if pathWithin(moduleRoot, name) { + return true + } + } + return false +} + +func invalidMetadata(field string, reason MetadataInvalidReason, cause error) error { + return &SnapshotMetadataError{Field: field, Reason: reason, Hint: "devctl sync", Cause: cause} +} diff --git a/internal/domain/contract/snapshot.go b/internal/domain/contract/snapshot.go new file mode 100644 index 0000000..1291aa9 --- /dev/null +++ b/internal/domain/contract/snapshot.go @@ -0,0 +1,46 @@ +package contract + +// Reference selects a contract entrypoint directly or through a named upstream export. +type Reference struct { + Entrypoint string + Export string + Format string + ProtoRoot string + Topic string +} + +// Location describes where a materialized contract can be resolved inside a project. +type Location struct { + // Root is the containment boundary for RelativePath and Entrypoint. + Root string + RelativePath string + Entrypoint string + // Local distinguishes project-owned inputs from previously materialized managed output. + Local bool +} + +// File is one contract-closure file relative to its Snapshot root. +type File struct { + Path string + Content []byte + // Mode contains the source file's Unix permission bits. + Mode uint32 +} + +// Snapshot is an exact local contract closure with Entrypoint naming one of Files. +type Snapshot struct { + ModuleRoot string + Entrypoint string + Files []File + Metadata *Metadata +} + +// Metadata records upstream facts needed to detect stale materialized contracts. +type Metadata struct { + Kind string `json:"kind"` + Topic string `json:"topic,omitempty"` + Format string `json:"format,omitempty"` + Entrypoint string `json:"entrypoint,omitempty"` + ModuleRoot string `json:"module_root,omitempty"` + BufConfig string `json:"buf_config,omitempty"` +} diff --git a/internal/domain/failure/category.go b/internal/domain/failure/category.go new file mode 100644 index 0000000..95667b6 --- /dev/null +++ b/internal/domain/failure/category.go @@ -0,0 +1,41 @@ +package failure + +import ( + "context" + "errors" +) + +// Category is the transport-neutral class of an application failure. +type Category string + +const ( + InvalidInput Category = "invalid_input" + NotFound Category = "not_found" + Conflict Category = "conflict" + Unavailable Category = "unavailable" + Unsupported Category = "unsupported" + Cancelled Category = "cancelled" + Internal Category = "internal" +) + +// Categorized exposes a stable failure class without prescribing one error type. +type Categorized interface { + error + // Category returns the stable transport-neutral class of the failure. + Category() Category +} + +// CategoryOf prioritizes cancellation semantics, preserves typed categories, and classifies unknown errors as Internal. +func CategoryOf(err error) Category { + switch { + case errors.Is(err, context.Canceled): + return Cancelled + case errors.Is(err, context.DeadlineExceeded): + return Unavailable + } + var categorized Categorized + if errors.As(err, &categorized) { + return categorized.Category() + } + return Internal +} diff --git a/internal/domain/failure/category_test.go b/internal/domain/failure/category_test.go new file mode 100644 index 0000000..6243b8d --- /dev/null +++ b/internal/domain/failure/category_test.go @@ -0,0 +1,41 @@ +package failure_test + +import ( + "context" + "errors" + "fmt" + "testing" + + "github.com/devctllabs/devctl/internal/domain/failure" + "github.com/stretchr/testify/require" +) + +type categorizedError struct { + category failure.Category +} + +func (e categorizedError) Error() string { return "categorized" } +func (e categorizedError) Category() failure.Category { return e.category } + +func TestCategoryOf(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + err error + expected failure.Category + }{ + {name: "typed category", err: fmt.Errorf("repository.Load: %w", categorizedError{category: failure.NotFound}), expected: failure.NotFound}, + {name: "wrapped cancellation", err: fmt.Errorf("client.Do: %w", context.Canceled), expected: failure.Cancelled}, + {name: "deadline", err: context.DeadlineExceeded, expected: failure.Unavailable}, + {name: "unknown", err: errors.New("boom"), expected: failure.Internal}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + require.Equal(t, test.expected, failure.CategoryOf(test.err)) + }) + } +} diff --git a/internal/domain/generate/command.go b/internal/domain/generate/command.go new file mode 100644 index 0000000..1e9190f --- /dev/null +++ b/internal/domain/generate/command.go @@ -0,0 +1,44 @@ +package generate + +import "github.com/devctllabs/devctl/internal/domain/artifact" + +// Command selects generation targets and whether execution is a side-effect-free preview. +type Command struct { + ManifestPath string + Family string + Target string + DryRun bool +} + +// ChangeAction classifies an observed or planned managed-output change. +type ChangeAction string + +const ( + ChangeCreated ChangeAction = "created" + ChangeUpdated ChangeAction = "updated" + ChangeUnchanged ChangeAction = "unchanged" + ChangeRemoved ChangeAction = "removed" + ChangePlannedPublish ChangeAction = "planned_publish" + ChangePlannedRemove ChangeAction = "planned_remove" +) + +// Change records one generated managed-output decision. +type Change struct { + Target string + // Path is relative to the project root. + Path string + Action ChangeAction +} + +// Result contains targets completed before success or the first execution error. +type Result struct { + Targets []string + Changes []Change + DryRun bool +} + +// Output separates an atomically published target directory from auxiliary project files. +type Output struct { + Directory artifact.Tree + Files artifact.Tree +} diff --git a/internal/domain/generate/errors.go b/internal/domain/generate/errors.go new file mode 100644 index 0000000..54ada43 --- /dev/null +++ b/internal/domain/generate/errors.go @@ -0,0 +1,47 @@ +package generate + +import "github.com/devctllabs/devctl/internal/domain/failure" + +// Operation identifies the generation stage that failed. +type Operation string + +const ( + OperationSelectTarget Operation = "select_target" + OperationLocateContract Operation = "locate_contract" + OperationRunGenerator Operation = "run_generator" + OperationPublishOutput Operation = "publish_output" +) + +// FailureKind maps generation facts to a transport-neutral failure category. +type FailureKind uint8 + +const ( + FailureNotFound FailureKind = iota + 1 + FailureUnavailable +) + +// OperationError retains generation facts and the underlying execution cause. +type OperationError struct { + Operation Operation + Target string + Path string + Kind FailureKind + Cause error +} + +func (e *OperationError) Error() string { + message := string(e.Operation) + " failed" + if e.Cause != nil { + return message + ": " + e.Cause.Error() + } + return message +} + +func (e *OperationError) Unwrap() error { return e.Cause } + +func (e *OperationError) Category() failure.Category { + if e.Kind == FailureNotFound { + return failure.NotFound + } + return failure.Unavailable +} diff --git a/internal/domain/generate/errors_test.go b/internal/domain/generate/errors_test.go new file mode 100644 index 0000000..d4190c2 --- /dev/null +++ b/internal/domain/generate/errors_test.go @@ -0,0 +1,20 @@ +package generate_test + +import ( + "errors" + "testing" + + "github.com/devctllabs/devctl/internal/domain/failure" + "github.com/devctllabs/devctl/internal/domain/generate" + "github.com/stretchr/testify/require" +) + +func TestOperationErrorPreservesCategoryAndCause(t *testing.T) { + t.Parallel() + + cause := errors.New("tool failed") + err := &generate.OperationError{Operation: generate.OperationRunGenerator, Kind: generate.FailureUnavailable, Cause: cause} + + require.Equal(t, failure.Unavailable, failure.CategoryOf(err)) + require.ErrorIs(t, err, cause) +} diff --git a/internal/domain/lint/command.go b/internal/domain/lint/command.go new file mode 100644 index 0000000..d2841f4 --- /dev/null +++ b/internal/domain/lint/command.go @@ -0,0 +1,36 @@ +package lint + +// Command selects the configured contracts to lint. +type Command struct { + ManifestPath string + Family string +} + +// Result reports findings collected before success or the first execution error. +// Findings are normal results: Valid is false when Issues is non-empty. +type Result struct { + Valid bool + Contracts []string + Issues []Issue +} + +// Issue is one stable, presentation-neutral devctl lint finding. +type Issue struct { + Code string + Target string + Path string + // Line and Column are one-based; zero means the location is unavailable. + Line int + Column int + Field string + Parameters *Parameters +} + +// Parameters carries code-specific facts used by delivery renderers. +type Parameters struct { + OperationID string + Location string + Type string + Subtype string + SpecPath string +} diff --git a/internal/domain/lint/errors.go b/internal/domain/lint/errors.go new file mode 100644 index 0000000..fd219fa --- /dev/null +++ b/internal/domain/lint/errors.go @@ -0,0 +1,46 @@ +package lint + +import "github.com/devctllabs/devctl/internal/domain/failure" + +// Operation identifies the lint execution stage that failed. +type Operation string + +const ( + OperationSelectContracts Operation = "select_contracts" + OperationLocateContract Operation = "locate_contract" + OperationReadContract Operation = "read_contract" +) + +// FailureKind maps lint execution facts to a transport-neutral failure category. +type FailureKind uint8 + +const ( + FailureInvalid FailureKind = iota + 1 + FailureUnavailable +) + +// OperationError represents an execution failure, never a lint finding. +type OperationError struct { + Operation Operation + Target string + Path string + Kind FailureKind + Cause error +} + +func (e *OperationError) Error() string { + message := string(e.Operation) + " failed" + if e.Cause != nil { + return message + ": " + e.Cause.Error() + } + return message +} + +func (e *OperationError) Unwrap() error { return e.Cause } + +func (e *OperationError) Category() failure.Category { + if e.Kind == FailureInvalid { + return failure.InvalidInput + } + return failure.Unavailable +} diff --git a/internal/domain/lint/errors_test.go b/internal/domain/lint/errors_test.go new file mode 100644 index 0000000..ae6bfea --- /dev/null +++ b/internal/domain/lint/errors_test.go @@ -0,0 +1,20 @@ +package lint_test + +import ( + "errors" + "testing" + + "github.com/devctllabs/devctl/internal/domain/failure" + "github.com/devctllabs/devctl/internal/domain/lint" + "github.com/stretchr/testify/require" +) + +func TestOperationErrorPreservesCategoryAndCause(t *testing.T) { + t.Parallel() + + cause := errors.New("read failed") + err := &lint.OperationError{Operation: lint.OperationReadContract, Kind: lint.FailureUnavailable, Cause: cause} + + require.Equal(t, failure.Unavailable, failure.CategoryOf(err)) + require.ErrorIs(t, err, cause) +} diff --git a/internal/domain/materialize/contract.go b/internal/domain/materialize/contract.go new file mode 100644 index 0000000..f28fd12 --- /dev/null +++ b/internal/domain/materialize/contract.go @@ -0,0 +1,27 @@ +package materialize + +import ( + "github.com/devctllabs/devctl/internal/domain/contract" + "github.com/devctllabs/devctl/internal/domain/project" +) + +// Request selects a contract closure from one source within a project root. +type Request struct { + // Root is the containment boundary for project-relative source paths. + Root string + Source project.Source + Reference contract.Reference +} + +// HTTPFetchRequest describes one bounded fetch within a URL Source origin. +type HTTPFetchRequest struct { + URL string + OriginURL string + AllowInsecureHTTP bool +} + +// HTTPDocument is one fetched Contract document and its effective URL. +type HTTPDocument struct { + URL string + Content []byte +} diff --git a/internal/domain/materialize/errors.go b/internal/domain/materialize/errors.go new file mode 100644 index 0000000..f2975f3 --- /dev/null +++ b/internal/domain/materialize/errors.go @@ -0,0 +1,143 @@ +package materialize + +import ( + "fmt" + + "github.com/devctllabs/devctl/internal/domain/failure" + "github.com/devctllabs/devctl/internal/domain/project" +) + +// Operation identifies the materialization stage that failed. +type Operation string + +const ( + OperationConfigureRouter Operation = "configure_router" + OperationValidateSource Operation = "validate_source" + OperationReadFile Operation = "read_file" + OperationDownload Operation = "download" + OperationCheckout Operation = "checkout" + OperationBuildSnapshot Operation = "build_snapshot" +) + +// FailureKind maps materialization facts to a transport-neutral failure category. +type FailureKind uint8 + +const ( + FailureInvalid FailureKind = iota + 1 + FailureNotFound + FailureUnavailable + FailureUnsupported +) + +// OperationError retains source facts and the underlying I/O or protocol cause. +type OperationError struct { + Operation Operation + SourceType project.SourceType + Path string + Kind FailureKind + Cause error +} + +func (e *OperationError) Error() string { + message := string(e.Operation) + " failed" + if e.Cause != nil { + return message + ": " + e.Cause.Error() + } + return message +} + +func (e *OperationError) Unwrap() error { return e.Cause } + +func (e *OperationError) Category() failure.Category { + switch e.Kind { + case FailureInvalid: + return failure.InvalidInput + case FailureNotFound: + return failure.NotFound + case FailureUnavailable: + return failure.Unavailable + case FailureUnsupported: + return failure.Unsupported + default: + return failure.Internal + } +} + +// UnsupportedSourceError reports a source type for which no strategy was configured. +type UnsupportedSourceError struct { + SourceType project.SourceType +} + +func (e *UnsupportedSourceError) Error() string { + return fmt.Sprintf("source type %q is unsupported", e.SourceType) +} + +func (e *UnsupportedSourceError) Category() failure.Category { return failure.Unsupported } + +// UpstreamManifestError identifies an invalid or inaccessible upstream Devctl project. +type UpstreamManifestError struct { + Repository string + Ref string + Cause error +} + +func (e *UpstreamManifestError) Error() string { + return "upstream Devctl manifest is invalid or inaccessible" +} + +func (e *UpstreamManifestError) Category() failure.Category { return failure.InvalidInput } +func (e *UpstreamManifestError) Unwrap() error { return e.Cause } + +// ExportNotFoundError reports a requested export absent from an upstream manifest. +type ExportNotFoundError struct{ Name string } + +func (e *ExportNotFoundError) Error() string { + return fmt.Sprintf("upstream export %q was not found", e.Name) +} + +func (e *ExportNotFoundError) Category() failure.Category { return failure.NotFound } + +// InvalidExportError reports a selected Export that does not match an effective upstream surface. +type InvalidExportError struct{ Name string } + +func (e *InvalidExportError) Error() string { + return fmt.Sprintf("upstream export %q is invalid", e.Name) +} + +func (e *InvalidExportError) Category() failure.Category { return failure.InvalidInput } + +// UnsupportedExportError reports an export whose contract kind cannot be materialized. +type UnsupportedExportError struct { + Name string + Kind string +} + +func (e *UnsupportedExportError) Error() string { + return fmt.Sprintf("upstream export %q has unsupported kind %q", e.Name, e.Kind) +} + +func (e *UnsupportedExportError) Category() failure.Category { return failure.Unsupported } + +// KafkaTopicMismatchError reports a downstream topic that disagrees with its exported producer. +type KafkaTopicMismatchError struct { + Expected string + Actual string +} + +func (e *KafkaTopicMismatchError) Error() string { + return fmt.Sprintf("Kafka export topic mismatch: expected %q, got %q", e.Expected, e.Actual) +} + +func (e *KafkaTopicMismatchError) Category() failure.Category { return failure.InvalidInput } + +// KafkaFormatMismatchError reports a downstream format that disagrees with its exported producer. +type KafkaFormatMismatchError struct { + Expected string + Actual string +} + +func (e *KafkaFormatMismatchError) Error() string { + return fmt.Sprintf("Kafka export format mismatch: expected %q, got %q", e.Expected, e.Actual) +} + +func (e *KafkaFormatMismatchError) Category() failure.Category { return failure.InvalidInput } diff --git a/internal/domain/materialize/errors_test.go b/internal/domain/materialize/errors_test.go new file mode 100644 index 0000000..cc9bea9 --- /dev/null +++ b/internal/domain/materialize/errors_test.go @@ -0,0 +1,39 @@ +package materialize_test + +import ( + "errors" + "testing" + + "github.com/devctllabs/devctl/internal/domain/failure" + "github.com/devctllabs/devctl/internal/domain/materialize" + "github.com/stretchr/testify/require" +) + +func TestOperationErrorPreservesCategoryAndCause(t *testing.T) { + t.Parallel() + + cause := errors.New("download failed") + err := &materialize.OperationError{Operation: materialize.OperationDownload, Kind: materialize.FailureUnavailable, Cause: cause} + + require.Equal(t, failure.Unavailable, failure.CategoryOf(err)) + require.ErrorIs(t, err, cause) +} + +func TestInvalidExportErrorIsInvalidInput(t *testing.T) { + t.Parallel() + + err := &materialize.InvalidExportError{Name: "public-api"} + + require.Equal(t, failure.InvalidInput, failure.CategoryOf(err)) + require.Contains(t, err.Error(), "public-api") +} + +func TestKafkaFormatMismatchErrorIsInvalidInput(t *testing.T) { + t.Parallel() + + err := &materialize.KafkaFormatMismatchError{Expected: "raw", Actual: "json"} + + require.Equal(t, failure.InvalidInput, failure.CategoryOf(err)) + require.Contains(t, err.Error(), "raw") + require.Contains(t, err.Error(), "json") +} diff --git a/internal/domain/project/command.go b/internal/domain/project/command.go new file mode 100644 index 0000000..4672801 --- /dev/null +++ b/internal/domain/project/command.go @@ -0,0 +1,117 @@ +package project + +// ChangeAction classifies the persisted effect of a manifest operation. +type ChangeAction string + +const ( + ChangeCreated ChangeAction = "created" + ChangeUpdated ChangeAction = "updated" + ChangeUnchanged ChangeAction = "unchanged" +) + +// ManifestResult identifies the selected manifest and its persisted change. +type ManifestResult struct { + Manifest string + Change ChangeAction +} + +// InitManifestCommand describes the canonical manifest to create or replace. +type InitManifestCommand struct { + Destination string + Language string + Preset string + Name string + Module string + Force bool +} + +// EnableCommand enables one project capability in an existing valid manifest. +type EnableCommand struct { + ManifestPath string + Capability string + Always bool + Force bool +} + +// AddDBCommand adds or replaces one database connection declaration. +type AddDBCommand struct { + ManifestPath string + Name string + Kind string + Default bool + NoMigrations bool + MigrationsPath string + Force bool +} + +// AddSourceCommand adds or replaces one local or external contract source. +type AddSourceCommand struct { + ManifestPath string + Name string + Type string + Path string + URL string + Filename string + AllowInsecureHTTP bool + Repo string + Ref string + BufConfig string + Force bool +} + +// AddHTTPClientCommand adds or replaces one generated HTTP client declaration. +type AddHTTPClientCommand struct { + ManifestPath string + Name string + Source string + Export string + Path string + BaseURLEnv string + Force bool +} + +type AddGRPCClientCommand struct { + ManifestPath string + Name string + Source string + Export string + Path string + ProtoRoot string + BufGenConfig string + AddrEnv string + Force bool +} + +type AddKafkaConsumerCommand struct { + ManifestPath string + Name, Topic, Source, Export, Path, Format, ProtoRoot, Message, Encoding, GroupEnv string + Always, Force bool +} + +type AddKafkaProducerCommand struct { + ManifestPath string + Name, Topic, Source, Export, Path, Format, ProtoRoot, Message, Encoding, TopicEnv string + Force bool +} + +type AddRedisCommand struct { + ManifestPath string + Name string + AddrEnv string + AddrDefault string + Force bool +} + +type AddS3ConnectionCommand struct { + ManifestPath string + Name string + Credentials string + Force bool +} + +type AddS3Command struct { + ManifestPath string + Name string + Connection string + Force bool +} diff --git a/internal/domain/project/errors.go b/internal/domain/project/errors.go new file mode 100644 index 0000000..5e2e9a8 --- /dev/null +++ b/internal/domain/project/errors.go @@ -0,0 +1,104 @@ +package project + +import "github.com/devctllabs/devctl/internal/domain/failure" + +// Operation identifies the project or manifest stage that failed. +type Operation string + +const ( + OperationLoadManifest Operation = "load_manifest" + OperationSaveManifest Operation = "save_manifest" + OperationInspectFile Operation = "inspect_file" + OperationReadFile Operation = "read_file" + OperationInitManifest Operation = "init_manifest" +) + +// FailureKind maps project facts to a transport-neutral failure category. +type FailureKind uint8 + +const ( + FailureInvalid FailureKind = iota + 1 + FailureNotFound + FailureConflict + FailureUnavailable + FailureInternal +) + +// OperationError retains project path facts and the underlying execution cause. +type OperationError struct { + Operation Operation + Path string + Kind FailureKind + Cause error +} + +// MutationReason identifies the stable policy fact that rejected a manifest mutation. +type MutationReason string + +const ( + MutationUnsupportedOption MutationReason = "unsupported_option" + MutationUnsupportedValue MutationReason = "unsupported_value" + MutationInvalidName MutationReason = "invalid_name" + MutationInvalidOptions MutationReason = "invalid_options" + MutationInvalidURL MutationReason = "invalid_url" + MutationInsecureURL MutationReason = "insecure_url" + MutationNotFound MutationReason = "not_found" + MutationExistingConflict MutationReason = "existing_conflict" +) + +// MutationError contains presentation-neutral facts about a rejected manifest mutation. +type MutationError struct { + Reason MutationReason + Field string + Value string + Conflict bool +} + +func (e *MutationError) Error() string { return "manifest mutation failed" } + +func (e *MutationError) Category() failure.Category { + if e.Conflict { + return failure.Conflict + } + return failure.InvalidInput +} + +func (e *OperationError) Error() string { + message := string(e.Operation) + " failed" + if e.Cause != nil { + return message + ": " + e.Cause.Error() + } + return message +} + +func (e *OperationError) Unwrap() error { return e.Cause } + +func (e *OperationError) Category() failure.Category { + switch e.Kind { + case FailureInvalid: + return failure.InvalidInput + case FailureNotFound: + return failure.NotFound + case FailureConflict: + return failure.Conflict + case FailureUnavailable: + return failure.Unavailable + case FailureInternal: + return failure.Internal + default: + return failure.Internal + } +} + +// InvalidManifestError reports structural or semantic issues that block a project operation. +type InvalidManifestError struct { + Path string + Issues []Issue + Cause error +} + +func (e *InvalidManifestError) Error() string { return "manifest is invalid" } + +func (e *InvalidManifestError) Category() failure.Category { return failure.InvalidInput } + +func (e *InvalidManifestError) Unwrap() error { return e.Cause } diff --git a/internal/domain/project/errors_test.go b/internal/domain/project/errors_test.go new file mode 100644 index 0000000..038ce70 --- /dev/null +++ b/internal/domain/project/errors_test.go @@ -0,0 +1,20 @@ +package project_test + +import ( + "errors" + "testing" + + "github.com/devctllabs/devctl/internal/domain/failure" + "github.com/devctllabs/devctl/internal/domain/project" + "github.com/stretchr/testify/require" +) + +func TestOperationErrorPreservesCategoryAndCause(t *testing.T) { + t.Parallel() + + cause := errors.New("storage failed") + err := &project.OperationError{Operation: project.OperationLoadManifest, Kind: project.FailureNotFound, Cause: cause} + + require.Equal(t, failure.NotFound, failure.CategoryOf(err)) + require.ErrorIs(t, err, cause) +} diff --git a/internal/domain/project/export.go b/internal/domain/project/export.go new file mode 100644 index 0000000..13ccf14 --- /dev/null +++ b/internal/domain/project/export.go @@ -0,0 +1,40 @@ +package project + +import ( + "path" + "strings" +) + +// ExportMatchesSurface reports whether exported is an exact alias of one effective Project surface. +func (m Manifest) ExportMatchesSurface(exported Export) bool { + switch exported.Kind { + case "openapi": + target, exists := m.target("http-server") + return exists && exported.Producer == "" && validExportPath(exported.Path, false) && exported.Path == target.Reference.Entrypoint + case "grpc": + target, exists := m.target("grpc-server") + return exists && exported.Producer == "" && validExportPath(exported.Path, true) && exported.Path == target.Reference.ProtoRoot + case "kafka": + _, exists := m.target("kafka-producer:" + exported.Producer) + return exists && exported.Producer != "" && exported.Path == "" + default: + return false + } +} + +func (m Manifest) target(id string) (Target, bool) { + for _, target := range NewTargetCatalog(m).All() { + if target.ID == id { + return target, true + } + } + return Target{}, false +} + +func validExportPath(name string, allowRoot bool) bool { + clean := path.Clean(strings.ReplaceAll(name, "\\", "/")) + if allowRoot && clean == "." && name != "" { + return true + } + return name != "" && clean != "." && clean != ".." && !strings.HasPrefix(clean, "/") && !strings.HasPrefix(clean, "../") +} diff --git a/internal/domain/project/export_test.go b/internal/domain/project/export_test.go new file mode 100644 index 0000000..e5cfc9e --- /dev/null +++ b/internal/domain/project/export_test.go @@ -0,0 +1,39 @@ +package project_test + +import ( + "testing" + + "github.com/devctllabs/devctl/internal/domain/project" + "github.com/stretchr/testify/require" +) + +func TestManifestExportMatchesEffectiveSurface(t *testing.T) { + t.Parallel() + + manifest := project.Manifest{Components: project.Components{ + HTTP: &project.HTTP{Server: &project.HTTPServer{OpenAPI: "api/openapi.yaml"}}, + GRPC: &project.GRPC{Server: &project.GRPCServer{ProtoRoot: "api/proto/grpc"}}, + Kafka: &project.Kafka{Producers: []project.KafkaProducer{{ + Name: "audit", Topic: "audit.events", Contract: project.KafkaContract{Format: "json"}, + }}}, + }} + tests := []struct { + name string + exported project.Export + expected bool + }{ + {name: "OpenAPI", exported: project.Export{Kind: "openapi", Path: "api/openapi.yaml"}, expected: true}, + {name: "OpenAPI mismatch", exported: project.Export{Kind: "openapi", Path: "api/other.yaml"}}, + {name: "gRPC", exported: project.Export{Kind: "grpc", Path: "api/proto/grpc"}, expected: true}, + {name: "gRPC mismatch", exported: project.Export{Kind: "grpc", Path: "api/proto/other"}}, + {name: "Kafka", exported: project.Export{Kind: "kafka", Producer: "audit"}, expected: true}, + {name: "Kafka missing producer", exported: project.Export{Kind: "kafka", Producer: "missing"}}, + {name: "unknown kind", exported: project.Export{Kind: "graphql", Path: "api/schema.graphql"}}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + require.Equal(t, test.expected, manifest.ExportMatchesSurface(test.exported)) + }) + } +} diff --git a/internal/domain/project/manifest.go b/internal/domain/project/manifest.go new file mode 100644 index 0000000..487816c --- /dev/null +++ b/internal/domain/project/manifest.go @@ -0,0 +1,277 @@ +package project + +// Manifest is the canonical desired project configuration before effective defaults are applied. +type Manifest struct { + Version int + Project Identity + Env Env + Paths ManifestPaths + Sources map[string]Source + Exports map[string]Export + Components Components + Languages Languages +} + +// Project binds one decoded Manifest to its selected filesystem root and manifest path. +type Project struct { + Root string + ManifestPath string + Manifest Manifest +} + +type Identity struct { + Name string + Language string +} + +// Export publishes a named contract path from an upstream Devctl project. +type Export struct { + Kind string + Path string + Producer string +} + +type Env struct { + Prefix string + Custom []EnvGroup +} + +type EnvGroup struct { + Group string + Vars []EnvVar +} + +// EnvVar describes one generated environment variable declaration. +type EnvVar struct { + Key string + Type string + // Default retains the scalar type declared by the manifest. + Default any + Secret bool +} + +type ManifestPaths struct { + ExternalContracts string +} + +type Components struct { + HTTP *HTTP + GRPC *GRPC + Kafka *Kafka + Logging *Logging + Health *Health + Telemetry *Telemetry + DB *DB + Redis *Redis + S3 *S3 +} + +type Redis struct { + Connections []RedisConnection + Env ComponentEnv +} + +type RedisConnection struct { + Name string + AddrEnv string + AddrDefault string +} + +type S3 struct { + Connections []S3Connection + Buckets []S3Bucket + Env ComponentEnv +} + +type S3Connection struct { + Name string + Credentials string + Endpoint string + Region string + PathStyle bool + AccessKeyEnv string + SecretKeyEnv string +} + +type S3Bucket struct { + Name string + Connection string + Bucket string +} + +type Kafka struct { + Consumers []KafkaConsumer + Producers []KafkaProducer + Env ComponentEnv +} + +type KafkaContract struct { + Source string + Export string + Path string + Format string + ProtoRoot string + Message string + Encoding string +} + +type KafkaConsumer struct { + Name string + Topic string + GroupEnv string + Start *Start + Contract KafkaContract +} + +type KafkaProducer struct { + Name string + Topic string + TopicEnv string + Contract KafkaContract +} + +type GRPC struct { + Server *GRPCServer + Clients []GRPCClient + Env ComponentEnv +} + +type GRPCClient struct { + Name string + Source string + Export string + Path string + ProtoRoot string + BufGenConfig string + AddrEnv string +} + +type GRPCServer struct { + ProtoRoot string + BufConfig string + Start *Start +} + +type ComponentEnv struct { + System []EnvVar + Custom []EnvVar +} + +// Start controls whether a runtime component starts by default. +type Start struct { + Env string + // Default is nil when the manifest leaves the start policy unspecified. + Default *bool +} + +type HTTP struct { + Server *HTTPServer + Clients []HTTPClient + Env ComponentEnv +} + +type HTTPServer struct { + OpenAPI string + Start *Start +} + +// HTTPClient describes one generated client and its contract selection. +type HTTPClient struct { + Name string + Source string + Export string + // Path is the contract entrypoint within Source when Export is not used. + Path string + BaseURLEnv string + // OAPIConfig is a project-relative oapi-codegen configuration path. + OAPIConfig string +} + +type Logging struct{ Env ComponentEnv } + +type Health struct { + Server *HealthServer + Env ComponentEnv +} + +type HealthServer struct{ Start *Start } + +type Telemetry struct { + Start *Start + Env ComponentEnv +} + +type DB struct { + Connections []DBConnection + Env ComponentEnv +} + +// DBConnection groups selectable variants of one logical database connection. +type DBConnection struct { + Name string + // Default names the variant selected when KindEnv is unset. + Default string + KindEnv string + Variants []DBVariant +} + +// DBVariant describes one concrete database driver and DSN source. +type DBVariant struct { + Name string + Kind string + DSNEnv string + DSNDefault string + Secret bool + Migrations *DBMigrations +} + +// DBMigrations describes a golang-migrate target owned by one database variant. +type DBMigrations struct { + Path string + DatabaseEnv string + DatabaseDefault string +} + +type Languages struct{ Go GoLanguage } + +type GoLanguage struct { + Module string + Generators GoGenerators + Components GoComponents +} + +type GoGenerators struct { + Config *ConfigGenerator + HTTP *HTTPGenerator + GRPC *GRPCGenerator + Kafka *KafkaGenerator +} + +type KafkaGenerator struct { + Out string + BufGenConfig string +} + +type GRPCGenerator struct { + Out string + BufGenConfig string +} + +// ConfigGenerator configures the project-relative managed config output directory. +type ConfigGenerator struct{ Out string } + +// HTTPGenerator configures oapi-codegen inputs and managed output directories. +type HTTPGenerator struct { + OAPIConfig string + ServerOut string + ClientOut string +} + +type GoComponents struct{ Pprof *Pprof } + +type Pprof struct { + Server *PprofServer + Env ComponentEnv +} + +type PprofServer struct{ Start *Start } diff --git a/internal/domain/project/query.go b/internal/domain/project/query.go new file mode 100644 index 0000000..7668c8e --- /dev/null +++ b/internal/domain/project/query.go @@ -0,0 +1,171 @@ +package project + +// InspectQuery selects the manifest whose effective project view is requested. +type InspectQuery struct { + ManifestPath string +} + +// InspectResult contains the effective view derived from one valid manifest. +type InspectResult struct { + Project Inspection +} + +// Inspection is the effective view of one selected manifest-managed directory. +type Inspection struct { + Root string + ManifestPath string + Name string + Language string + Module string + EnvPrefix string + Paths Paths + Targets []InspectionTarget + Env []EffectiveEnv + Resources InspectionResources +} + +// InspectionTarget describes one effective contract or config generation target. +type InspectionTarget struct { + ID string + Family string + Format string + Input string + ResolvedInput string + Config string + Output string +} + +// EffectiveEnv describes one fully-prefixed environment variable. +type EffectiveEnv struct { + Key string + Type string + Default any + Secret bool +} + +// InspectionResources inventories named runtime resources. +type InspectionResources struct { + DBConnections []string + RedisConnections []string + S3Connections []string + S3Buckets []string + Migrations []InspectionMigration +} + +// InspectionMigration is one effective golang-migrate target. +type InspectionMigration struct { + Connection string + Variant string + Kind string + Path string + DatabaseEnv string +} + +// Paths contains effective project-relative managed-output locations. +type Paths struct { + ExternalContracts string + ConfigOut string + ServerOut string + ClientOut string +} + +// ValidateQuery selects the manifest whose project readiness should be checked. +type ValidateQuery struct { + ManifestPath string +} + +// ValidationResult reports all project readiness issues that could be collected. +// Invalid project data is a normal result rather than an execution error. +type ValidationResult struct { + Issues []Issue +} + +// IsValid reports whether validation found no issues. +func (r ValidationResult) IsValid() bool { + return len(r.Issues) == 0 +} + +// IssueCode identifies one stable project validation rule. +type IssueCode string + +const ( + IssueYAMLInvalid IssueCode = "yaml_invalid" + IssueSchemaInvalid IssueCode = "schema_invalid" + IssueYAMLDuplicateKey IssueCode = "yaml_duplicate_key" + IssueSchemaUnknownField IssueCode = "schema_unknown_field" + IssueVersionUnsupported IssueCode = "version_unsupported" + IssueNameInvalid IssueCode = "name_invalid" + IssueLanguageUnsupported IssueCode = "language_unsupported" + IssueGoModuleRequired IssueCode = "go_module_required" + IssuePathInvalid IssueCode = "path_invalid" + IssuePathOverlap IssueCode = "path_overlap" + IssueSourceNameInvalid IssueCode = "source_name_invalid" + IssueSourceInvalid IssueCode = "source_invalid" + IssueSourceInsecure IssueCode = "source_insecure" + IssueSourceTypeUnsupported IssueCode = "source_type_unsupported" + IssueExportInvalid IssueCode = "export_invalid" + IssueHTTPClientInvalid IssueCode = "http_client_invalid" + IssueGRPCClientInvalid IssueCode = "grpc_client_invalid" + IssueKafkaContractInvalid IssueCode = "kafka_contract_invalid" + IssueSourceNotFound IssueCode = "source_not_found" + IssueDBConnectionInvalid IssueCode = "db_connection_invalid" + IssueDBVariantInvalid IssueCode = "db_variant_invalid" + IssueDBDefaultInvalid IssueCode = "db_default_invalid" + IssueDBMigrationsInvalid IssueCode = "db_migrations_invalid" + IssueMigrationPathMissing IssueCode = "migration_path_missing" + IssueRedisConnectionInvalid IssueCode = "redis_connection_invalid" + IssueRedisAddressInvalid IssueCode = "redis_address_invalid" + IssueS3ConnectionNotFound IssueCode = "s3_connection_not_found" + IssueGoModMissing IssueCode = "go_mod_missing" + IssueGoModInvalid IssueCode = "go_mod_invalid" + IssueSourceMissing IssueCode = "source_missing" + IssueOpenAPIMissing IssueCode = "openapi_missing" + IssueHTTPGeneratorMissing IssueCode = "http_generator_missing" + IssueToolConfigMissing IssueCode = "tool_config_missing" + IssueToolConfigInvalid IssueCode = "tool_config_invalid" + IssueToolMissing IssueCode = "tool_missing" + IssueRuntimeConfigConflict IssueCode = "runtime_config_conflict" +) + +// DecodeIssueKind identifies a structural manifest decoding failure. +type DecodeIssueKind uint8 + +const ( + DecodeYAMLInvalid DecodeIssueKind = iota + 1 + DecodeSchemaInvalid + DecodeDuplicateKey + DecodeUnknownField +) + +// DecodeIssue is a structured manifest persistence fact. +type DecodeIssue struct { + Kind DecodeIssueKind + Field string + // Line and Column are one-based; zero means the location is unavailable. + Line int + Column int +} + +// LoadManifestResult contains the selected project and structural manifest issues. +type LoadManifestResult struct { + Project Project + Issues []DecodeIssue +} + +// Issue is one stable, presentation-neutral project validation fact. +type Issue struct { + Code IssueCode + Path string + // Line and Column are one-based; zero means the location is unavailable. + Line int + Column int + Field string + Parameters *Parameters +} + +// Parameters carries code-specific validation facts used by delivery renderers. +type Parameters struct { + Expected string + Actual string + Value string +} diff --git a/internal/domain/project/query_test.go b/internal/domain/project/query_test.go new file mode 100644 index 0000000..eaddebf --- /dev/null +++ b/internal/domain/project/query_test.go @@ -0,0 +1,14 @@ +package project + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestValidationResultIsValid(t *testing.T) { + t.Parallel() + + require.True(t, ValidationResult{}.IsValid()) + require.False(t, ValidationResult{Issues: []Issue{{}}}.IsValid()) +} diff --git a/internal/domain/project/runtime_config.go b/internal/domain/project/runtime_config.go new file mode 100644 index 0000000..3a65d3a --- /dev/null +++ b/internal/domain/project/runtime_config.go @@ -0,0 +1,497 @@ +package project + +import ( + "reflect" + "sort" + "strings" + "unicode" +) + +// RuntimeConfigType identifies the typed value loaded from one environment key. +type RuntimeConfigType string + +const ( + RuntimeConfigString RuntimeConfigType = "string" + RuntimeConfigBool RuntimeConfigType = "bool" + RuntimeConfigInt RuntimeConfigType = "int" + RuntimeConfigDuration RuntimeConfigType = "duration" + RuntimeConfigStringList RuntimeConfigType = "string_list" +) + +// RuntimeConfigScope selects one projection of the canonical runtime configuration. +type RuntimeConfigScope uint8 + +const ( + RuntimeConfigRuntime RuntimeConfigScope = 1 << iota + RuntimeConfigExample + RuntimeConfigInspect +) + +// RuntimeConfigField is one effective environment-backed configuration fact. +type RuntimeConfigField struct { + Group string + Name string + Key string + Type RuntimeConfigType + Default any + HasDefault bool + Secret bool +} + +// RuntimeConfigCatalog is an immutable effective Runtime Config projection of one Manifest. +type RuntimeConfigCatalog struct { + prefix string + entries []runtimeConfigEntry +} + +type runtimeConfigEntry struct { + field RuntimeConfigField + scopes RuntimeConfigScope +} + +// RuntimeConfigConflictError reports declarations that cannot own the same effective key. +type RuntimeConfigConflictError struct { + Key string + Field string +} + +func (e *RuntimeConfigConflictError) Error() string { + if e.Field != "" { + return "conflicting runtime config field " + e.Field + } + return "conflicting runtime config declaration for " + e.Key +} + +// NewRuntimeConfigCatalog resolves effective Runtime Config policy for manifest. +func NewRuntimeConfigCatalog(manifest Manifest) (RuntimeConfigCatalog, error) { + builder := runtimeConfigBuilder{ + prefix: runtimeConfigPrefix(manifest), + byKey: make(map[string]*runtimeConfigCandidate), + } + builder.addDerived(manifest) + if err := builder.addExplicit(manifest); err != nil { + return RuntimeConfigCatalog{}, err + } + entries, err := builder.sortedEntries() + if err != nil { + return RuntimeConfigCatalog{}, err + } + return RuntimeConfigCatalog{prefix: builder.prefix, entries: entries}, nil +} + +// Prefix returns the effective project environment prefix. +func (c RuntimeConfigCatalog) Prefix() string { + return c.prefix +} + +// Entries returns a defensive key-sorted projection for scope. +func (c RuntimeConfigCatalog) Entries(scope RuntimeConfigScope) []RuntimeConfigField { + fields := make([]RuntimeConfigField, 0, len(c.entries)) + for _, entry := range c.entries { + if entry.scopes&scope != 0 { + fields = append(fields, entry.field) + } + } + return fields +} + +type runtimeConfigCandidate struct { + entry runtimeConfigEntry + explicit *runtimeConfigDeclaration +} + +type runtimeConfigDeclaration struct { + group string + name string + typeName RuntimeConfigType + defaultVal any + hasDefault bool + secret bool +} + +type runtimeConfigBuilder struct { + prefix string + byKey map[string]*runtimeConfigCandidate +} + +type derivedRuntimeConfig struct { + typeName RuntimeConfigType + defaultVal any + hasDefault bool + secret bool + scopes RuntimeConfigScope +} + +func runtimeDerived(typeName RuntimeConfigType, defaultValue any, hasDefault bool) derivedRuntimeConfig { + return derivedRuntimeConfig{typeName: typeName, defaultVal: defaultValue, hasDefault: hasDefault, scopes: runtimeScopes()} +} + +func runtimeDerivedSecret(typeName RuntimeConfigType, defaultValue any, hasDefault, secret bool) derivedRuntimeConfig { + return derivedRuntimeConfig{typeName: typeName, defaultVal: defaultValue, hasDefault: hasDefault, secret: secret, scopes: runtimeScopes()} +} + +func (b *runtimeConfigBuilder) addDerived(manifest Manifest) { + b.addCapabilities(manifest) + b.addDatabase(manifest.Components.DB) + b.addRedis(manifest.Components.Redis) + b.addS3(manifest.Components.S3) + b.addClients(manifest.Components.HTTP, manifest.Components.GRPC) + b.addMigrations(manifest.Components.DB) +} + +func (b *runtimeConfigBuilder) addCapabilities(manifest Manifest) { + components := manifest.Components + if components.Logging != nil { + b.derived("Logging", "Level", "LOG_LEVEL", runtimeDerived(RuntimeConfigString, "info", true)) + } + if components.HTTP != nil && components.HTTP.Server != nil { + b.derived("HTTP", "Address", "HTTP_ADDR", runtimeDerived(RuntimeConfigString, ":8080", true)) + b.start("HTTP", "Enabled", "HTTP_SERVER_ENABLED", components.HTTP.Server.Start) + } + if components.GRPC != nil && components.GRPC.Server != nil { + b.derived("GRPC", "Address", "GRPC_ADDR", runtimeDerived(RuntimeConfigString, ":9090", true)) + b.start("GRPC", "Enabled", "GRPC_SERVER_ENABLED", components.GRPC.Server.Start) + } + if components.Kafka != nil { + b.derived("Kafka", "Brokers", "KAFKA_BROKERS", runtimeDerived(RuntimeConfigStringList, "localhost:29092", true)) + for _, consumer := range components.Kafka.Consumers { + name := runtimeConfigExportedName(consumer.Name) + keyPart := runtimeConfigEnvName(consumer.Name) + groupKey := valueOrDefault(consumer.GroupEnv, "KAFKA_"+keyPart+"_GROUP") + b.derived("Kafka", name+"Group", groupKey, runtimeDerived(RuntimeConfigString, manifest.Project.Name+"-"+consumer.Name+"-group", true)) + b.derived("Kafka", name+"Topic", "KAFKA_"+keyPart+"_TOPIC", runtimeDerived(RuntimeConfigString, consumer.Topic, true)) + b.derived("Kafka", name+"BatchMaxSize", "KAFKA_"+keyPart+"_BATCH_MAX_SIZE", runtimeDerived(RuntimeConfigInt, 1, true)) + b.derived("Kafka", name+"BatchFlushInterval", "KAFKA_"+keyPart+"_BATCH_FLUSH_INTERVAL", runtimeDerived(RuntimeConfigDuration, "1s", true)) + b.derived("Kafka", name+"RetryMaxAttempts", "KAFKA_"+keyPart+"_RETRY_MAX_ATTEMPTS", runtimeDerived(RuntimeConfigInt, 3, true)) + b.derived("Kafka", name+"RetryMaxElapsedTime", "KAFKA_"+keyPart+"_RETRY_MAX_ELAPSED_TIME", runtimeDerived(RuntimeConfigDuration, "0s", true)) + b.derived("Kafka", name+"RetryInitialDelay", "KAFKA_"+keyPart+"_RETRY_INITIAL_DELAY", runtimeDerived(RuntimeConfigDuration, "1s", true)) + b.derived("Kafka", name+"RetryMaxDelay", "KAFKA_"+keyPart+"_RETRY_MAX_DELAY", runtimeDerived(RuntimeConfigDuration, "30s", true)) + b.derived("Kafka", name+"RebalanceTimeout", "KAFKA_"+keyPart+"_REBALANCE_TIMEOUT", runtimeDerived(RuntimeConfigDuration, "30s", true)) + b.derived("Kafka", name+"RebalanceDrainTimeout", "KAFKA_"+keyPart+"_REBALANCE_DRAIN_TIMEOUT", runtimeDerived(RuntimeConfigDuration, "20s", true)) + b.derived("Kafka", name+"ShutdownTimeout", "KAFKA_"+keyPart+"_SHUTDOWN_TIMEOUT", runtimeDerived(RuntimeConfigDuration, "30s", true)) + b.start("Kafka", name+"Enabled", "KAFKA_"+keyPart+"_CONSUMER_ENABLED", consumer.Start) + } + for _, producer := range components.Kafka.Producers { + name := runtimeConfigExportedName(producer.Name) + key := valueOrDefault(producer.TopicEnv, "KAFKA_"+runtimeConfigEnvName(producer.Name)+"_TOPIC") + b.derived("Kafka", name+"Topic", key, runtimeDerived(RuntimeConfigString, producer.Topic, producer.Topic != "")) + } + } + if components.Health != nil { + b.derived("Health", "Address", "HEALTH_ADDR", runtimeDerived(RuntimeConfigString, ":8081", true)) + if components.Health.Server != nil { + b.start("Health", "Enabled", "HEALTH_SERVER_ENABLED", components.Health.Server.Start) + } + } + if components.Telemetry != nil { + b.start("Telemetry", "Enabled", "TELEMETRY_ENABLED", components.Telemetry.Start) + b.derived("Telemetry", "ServiceVersion", "SERVICE_VERSION", runtimeDerived(RuntimeConfigString, "dev", true)) + b.derived("Telemetry", "DeploymentEnvironment", "DEPLOYMENT_ENVIRONMENT", runtimeDerived(RuntimeConfigString, "development", true)) + } + if pprof := manifest.Languages.Go.Components.Pprof; pprof != nil { + b.derived("Pprof", "Address", "PPROF_ADDR", runtimeDerived(RuntimeConfigString, "127.0.0.1:6060", true)) + if pprof.Server != nil { + b.start("Pprof", "Enabled", "PPROF_ENABLED", pprof.Server.Start) + } + } +} + +func (b *runtimeConfigBuilder) start(group, name, fallbackKey string, start *Start) { + if start == nil { + return + } + defaultValue := false + if start.Default != nil { + defaultValue = *start.Default + } + b.derived(group, name, valueOrDefault(start.Env, fallbackKey), runtimeDerived(RuntimeConfigBool, defaultValue, true)) +} + +func (b *runtimeConfigBuilder) addDatabase(database *DB) { + if database == nil { + return + } + for _, connection := range database.Connections { + connectionName := runtimeConfigExportedName(connection.Name) + group := "DB" + connectionName + defaultVariant := connection.Default + if defaultVariant == "" && len(connection.Variants) == 1 { + defaultVariant = connection.Variants[0].Name + } + kindKey := valueOrDefault(connection.KindEnv, "DB_"+runtimeConfigEnvName(connection.Name)+"_KIND") + b.derived(group, "Kind", kindKey, runtimeDerived(RuntimeConfigString, defaultVariant, defaultVariant != "")) + for _, variant := range connection.Variants { + key := valueOrDefault(variant.DSNEnv, "DB_"+runtimeConfigEnvName(connection.Name)+"_"+runtimeConfigEnvName(variant.Name)+"_DSN") + b.derived(group, runtimeConfigExportedName(variant.Name)+"DSN", key, runtimeDerivedSecret(RuntimeConfigString, variant.DSNDefault, variant.DSNDefault != "", variant.Secret)) + } + } +} + +func (b *runtimeConfigBuilder) addRedis(redis *Redis) { + if redis == nil { + return + } + for _, connection := range redis.Connections { + key := valueOrDefault(connection.AddrEnv, "REDIS_"+runtimeConfigEnvName(connection.Name)+"_ADDR") + b.derived("Redis", runtimeConfigExportedName(connection.Name)+"Address", key, runtimeDerived(RuntimeConfigString, connection.AddrDefault, connection.AddrDefault != "")) + } +} + +func (b *runtimeConfigBuilder) addS3(storage *S3) { + if storage == nil { + return + } + for _, connection := range storage.Connections { + keyPrefix := "S3" + fieldPrefix := "" + if connection.Name != "" && connection.Name != "default" { + keyPrefix += "_" + runtimeConfigEnvName(connection.Name) + fieldPrefix = runtimeConfigExportedName(connection.Name) + } + b.derived("S3", fieldPrefix+"Endpoint", keyPrefix+"_ENDPOINT", runtimeDerived(RuntimeConfigString, connection.Endpoint, connection.Endpoint != "")) + b.derived("S3", fieldPrefix+"Region", keyPrefix+"_REGION", runtimeDerived(RuntimeConfigString, connection.Region, connection.Region != "")) + b.derived("S3", fieldPrefix+"ForcePathStyle", keyPrefix+"_FORCE_PATH_STYLE", runtimeDerived(RuntimeConfigBool, connection.PathStyle, true)) + if connection.Credentials == "static" { + accessKey := valueOrDefault(connection.AccessKeyEnv, keyPrefix+"_ACCESS_KEY_ID") + secretKey := valueOrDefault(connection.SecretKeyEnv, keyPrefix+"_SECRET_ACCESS_KEY") + b.derived("S3", fieldPrefix+"AccessKeyID", accessKey, runtimeDerivedSecret(RuntimeConfigString, nil, false, true)) + b.derived("S3", fieldPrefix+"SecretAccessKey", secretKey, runtimeDerivedSecret(RuntimeConfigString, nil, false, true)) + } + } + for _, bucket := range storage.Buckets { + key := "S3_" + runtimeConfigEnvName(bucket.Name) + "_BUCKET" + b.derived("S3", runtimeConfigExportedName(bucket.Name)+"Bucket", key, runtimeDerived(RuntimeConfigString, bucket.Bucket, bucket.Bucket != "")) + } +} + +func (b *runtimeConfigBuilder) addClients(http *HTTP, grpc *GRPC) { + if http != nil { + for _, client := range http.Clients { + key := valueOrDefault(client.BaseURLEnv, "HTTP_"+runtimeConfigEnvName(client.Name)+"_BASE_URL") + b.derived("HTTPClients", runtimeConfigExportedName(client.Name)+"BaseURL", key, runtimeDerived(RuntimeConfigString, nil, false)) + } + } + if grpc != nil { + for _, client := range grpc.Clients { + key := valueOrDefault(client.AddrEnv, "GRPC_"+runtimeConfigEnvName(client.Name)+"_ADDR") + b.derived("GRPCClients", runtimeConfigExportedName(client.Name)+"Address", key, runtimeDerived(RuntimeConfigString, nil, false)) + } + } +} + +func (b *runtimeConfigBuilder) addMigrations(database *DB) { + if database == nil { + return + } + for _, connection := range database.Connections { + for _, variant := range connection.Variants { + if variant.Migrations == nil || variant.Migrations.DatabaseEnv == "" { + continue + } + name := "DB" + runtimeConfigExportedName(connection.Name) + runtimeConfigExportedName(variant.Name) + "MigrationsURL" + b.derived("Migrations", name, variant.Migrations.DatabaseEnv, derivedRuntimeConfig{ + typeName: RuntimeConfigString, defaultVal: variant.Migrations.DatabaseDefault, + hasDefault: variant.Migrations.DatabaseDefault != "", secret: variant.Kind == "postgres" || variant.Kind == "clickhouse", + scopes: RuntimeConfigExample | RuntimeConfigInspect, + }) + } + } +} + +func (b *runtimeConfigBuilder) addExplicit(manifest Manifest) error { + for _, group := range manifest.Env.Custom { + if err := b.explicitVars(runtimeConfigExportedName(group.Group), group.Vars); err != nil { + return err + } + } + for _, environment := range explicitComponentEnvironments(manifest) { + if err := b.explicitEnv(environment.group, environment.environment); err != nil { + return err + } + } + return nil +} + +type namedComponentEnvironment struct { + group string + environment ComponentEnv +} + +func explicitComponentEnvironments(manifest Manifest) []namedComponentEnvironment { + components := manifest.Components + result := make([]namedComponentEnvironment, 0, 10) + if components.HTTP != nil { + result = append(result, namedComponentEnvironment{"HTTP", components.HTTP.Env}) + } + if components.GRPC != nil { + result = append(result, namedComponentEnvironment{"GRPC", components.GRPC.Env}) + } + if components.Kafka != nil { + result = append(result, namedComponentEnvironment{"Kafka", components.Kafka.Env}) + } + if components.Logging != nil { + result = append(result, namedComponentEnvironment{"Logging", components.Logging.Env}) + } + if components.Health != nil { + result = append(result, namedComponentEnvironment{"Health", components.Health.Env}) + } + if components.Telemetry != nil { + result = append(result, namedComponentEnvironment{"Telemetry", components.Telemetry.Env}) + } + if components.DB != nil { + result = append(result, namedComponentEnvironment{"Database", components.DB.Env}) + } + if components.Redis != nil { + result = append(result, namedComponentEnvironment{"Redis", components.Redis.Env}) + } + if components.S3 != nil { + result = append(result, namedComponentEnvironment{"S3", components.S3.Env}) + } + if pprof := manifest.Languages.Go.Components.Pprof; pprof != nil { + result = append(result, namedComponentEnvironment{"Pprof", pprof.Env}) + } + return result +} + +func (b *runtimeConfigBuilder) explicitEnv(group string, environment ComponentEnv) error { + variables := append(append([]EnvVar(nil), environment.System...), environment.Custom...) + return b.explicitVars(group, variables) +} + +func (b *runtimeConfigBuilder) explicitVars(group string, variables []EnvVar) error { + for _, variable := range variables { + if variable.Key == "" { + continue + } + name := runtimeConfigCustomName(group, variable.Key) + declaration := runtimeConfigDeclaration{ + group: group, name: name, typeName: runtimeConfigType(variable.Type), + defaultVal: variable.Default, hasDefault: variable.Default != nil, secret: variable.Secret, + } + if declaration.secret { + declaration.defaultVal = nil + declaration.hasDefault = false + } + key := b.key(variable.Key) + existing := b.byKey[key] + if existing == nil { + b.byKey[key] = &runtimeConfigCandidate{ + entry: runtimeConfigEntry{field: RuntimeConfigField{ + Group: group, Name: name, Key: key, Type: declaration.typeName, + Default: declaration.defaultVal, HasDefault: declaration.hasDefault, Secret: declaration.secret, + }, scopes: runtimeScopes()}, + explicit: &declaration, + } + continue + } + if existing.explicit != nil && !reflect.DeepEqual(*existing.explicit, declaration) { + return &RuntimeConfigConflictError{Key: key} + } + if existing.explicit == nil { + existing.explicit = &declaration + existing.entry.field.Type = declaration.typeName + existing.entry.field.Default = declaration.defaultVal + existing.entry.field.HasDefault = declaration.hasDefault + existing.entry.field.Secret = declaration.secret + } + } + return nil +} + +func (b *runtimeConfigBuilder) derived(group, name, key string, config derivedRuntimeConfig) { + if key == "" { + return + } + if config.secret { + config.defaultVal = nil + config.hasDefault = false + } + finalKey := b.key(key) + if _, exists := b.byKey[finalKey]; exists { + return + } + b.byKey[finalKey] = &runtimeConfigCandidate{entry: runtimeConfigEntry{field: RuntimeConfigField{ + Group: group, Name: name, Key: finalKey, Type: config.typeName, + Default: config.defaultVal, HasDefault: config.hasDefault, Secret: config.secret, + }, scopes: config.scopes}} +} + +func (b *runtimeConfigBuilder) key(key string) string { + if strings.HasPrefix(key, "OTEL_") { + return key + } + return b.prefix + key +} + +func (b *runtimeConfigBuilder) sortedEntries() ([]runtimeConfigEntry, error) { + entries := make([]runtimeConfigEntry, 0, len(b.byKey)) + paths := make(map[string]string, len(b.byKey)) + for key, candidate := range b.byKey { + fieldPath := candidate.entry.field.Group + "." + candidate.entry.field.Name + if otherKey, exists := paths[fieldPath]; exists && otherKey != key { + return nil, &RuntimeConfigConflictError{Field: fieldPath} + } + paths[fieldPath] = key + entries = append(entries, candidate.entry) + } + sort.Slice(entries, func(i, j int) bool { return entries[i].field.Key < entries[j].field.Key }) + return entries, nil +} + +func runtimeConfigPrefix(manifest Manifest) string { + if manifest.Env.Prefix != "" { + return manifest.Env.Prefix + } + return runtimeConfigEnvName(manifest.Project.Name) + "_" +} + +func runtimeConfigType(value string) RuntimeConfigType { + switch value { + case "bool": + return RuntimeConfigBool + case "int": + return RuntimeConfigInt + case "duration": + return RuntimeConfigDuration + default: + return RuntimeConfigString + } +} + +func runtimeConfigCustomName(group, key string) string { + if group == "Telemetry" && strings.HasPrefix(key, "OTEL_") { + key = strings.TrimPrefix(key, "OTEL_") + } + return runtimeConfigExportedName(key) +} + +func runtimeConfigEnvName(value string) string { + return strings.ToUpper(strings.ReplaceAll(value, "-", "_")) +} + +func runtimeConfigExportedName(value string) string { + parts := strings.FieldsFunc(value, func(char rune) bool { + return char == '_' || char == '-' || !unicode.IsLetter(char) && !unicode.IsDigit(char) + }) + var builder strings.Builder + for _, part := range parts { + if part == "" { + continue + } + runes := []rune(strings.ToLower(part)) + runes[0] = unicode.ToUpper(runes[0]) + builder.WriteString(string(runes)) + } + if builder.Len() == 0 { + return "Value" + } + name := builder.String() + if unicode.IsDigit([]rune(name)[0]) { + return "Value" + name + } + return name +} + +func runtimeScopes() RuntimeConfigScope { + return RuntimeConfigRuntime | RuntimeConfigExample | RuntimeConfigInspect +} diff --git a/internal/domain/project/runtime_config_test.go b/internal/domain/project/runtime_config_test.go new file mode 100644 index 0000000..8ec5fa6 --- /dev/null +++ b/internal/domain/project/runtime_config_test.go @@ -0,0 +1,112 @@ +package project_test + +import ( + "testing" + + "github.com/devctllabs/devctl/internal/domain/project" + "github.com/stretchr/testify/require" +) + +func TestRuntimeConfigCatalogBuildsCanonicalScopedFields(t *testing.T) { + t.Parallel() + + manifest := project.Manifest{ + Project: project.Identity{Name: "sample-api", Language: "go"}, + Components: project.Components{ + HTTP: &project.HTTP{ + Server: &project.HTTPServer{Start: &project.Start{}}, + Env: project.ComponentEnv{System: []project.EnvVar{{Key: "HTTP_ADDR", Type: "string", Default: ":8088"}}}, + }, + Telemetry: &project.Telemetry{Env: project.ComponentEnv{System: []project.EnvVar{{ + Key: "OTEL_SERVICE_NAME", Type: "string", Default: "sample-api", + }}}}, + DB: &project.DB{Connections: []project.DBConnection{{ + Name: "primary", + Variants: []project.DBVariant{{ + Name: "postgres", Kind: "postgres", DSNDefault: "postgres://secret", Secret: true, + Migrations: &project.DBMigrations{DatabaseEnv: "DB_PRIMARY_MIGRATIONS_URL", DatabaseDefault: "postgres://migration-secret"}, + }}, + }}}, + }, + } + + catalog, err := project.NewRuntimeConfigCatalog(manifest) + require.NoError(t, err) + require.Equal(t, "SAMPLE_API_", catalog.Prefix()) + require.Equal(t, []project.RuntimeConfigField{ + {Group: "Telemetry", Name: "ServiceName", Key: "OTEL_SERVICE_NAME", Type: project.RuntimeConfigString, Default: "sample-api", HasDefault: true}, + {Group: "DBPrimary", Name: "Kind", Key: "SAMPLE_API_DB_PRIMARY_KIND", Type: project.RuntimeConfigString, Default: "postgres", HasDefault: true}, + {Group: "DBPrimary", Name: "PostgresDSN", Key: "SAMPLE_API_DB_PRIMARY_POSTGRES_DSN", Type: project.RuntimeConfigString, Secret: true}, + {Group: "Telemetry", Name: "DeploymentEnvironment", Key: "SAMPLE_API_DEPLOYMENT_ENVIRONMENT", Type: project.RuntimeConfigString, Default: "development", HasDefault: true}, + {Group: "HTTP", Name: "Address", Key: "SAMPLE_API_HTTP_ADDR", Type: project.RuntimeConfigString, Default: ":8088", HasDefault: true}, + {Group: "HTTP", Name: "Enabled", Key: "SAMPLE_API_HTTP_SERVER_ENABLED", Type: project.RuntimeConfigBool, Default: false, HasDefault: true}, + {Group: "Telemetry", Name: "ServiceVersion", Key: "SAMPLE_API_SERVICE_VERSION", Type: project.RuntimeConfigString, Default: "dev", HasDefault: true}, + }, catalog.Entries(project.RuntimeConfigRuntime)) + require.Equal(t, []project.RuntimeConfigField{ + {Group: "Migrations", Name: "DBPrimaryPostgresMigrationsURL", Key: "SAMPLE_API_DB_PRIMARY_MIGRATIONS_URL", Type: project.RuntimeConfigString, Secret: true}, + }, onlyMigrationFields(catalog.Entries(project.RuntimeConfigExample))) + + fields := catalog.Entries(project.RuntimeConfigRuntime) + originalName := fields[0].Name + fields[0].Name = "Changed" + require.Equal(t, originalName, catalog.Entries(project.RuntimeConfigRuntime)[0].Name) +} + +func TestRuntimeConfigCatalogRejectsConflictingExplicitDeclarations(t *testing.T) { + t.Parallel() + + manifest := project.Manifest{ + Project: project.Identity{Name: "sample", Language: "go"}, + Env: project.Env{Custom: []project.EnvGroup{ + {Group: "First", Vars: []project.EnvVar{{Key: "SHARED", Type: "string", Default: "one"}}}, + {Group: "Second", Vars: []project.EnvVar{{Key: "SHARED", Type: "string", Default: "two"}}}, + }}, + } + + _, err := project.NewRuntimeConfigCatalog(manifest) + + var conflict *project.RuntimeConfigConflictError + require.ErrorAs(t, err, &conflict) + require.Equal(t, "SAMPLE_SHARED", conflict.Key) + require.NotContains(t, err.Error(), "one") + require.NotContains(t, err.Error(), "two") +} + +func TestRuntimeConfigCatalogSeparatesClickHouseRuntimeAndMigrationURLs(t *testing.T) { + t.Parallel() + + manifest := project.Manifest{ + Project: project.Identity{Name: "sample", Language: "go"}, + Components: project.Components{DB: &project.DB{Connections: []project.DBConnection{{ + Name: "analytics", Default: "clickhouse", Variants: []project.DBVariant{{ + Name: "clickhouse", Kind: "clickhouse", DSNDefault: "clickhouse://localhost:9000/default", Secret: true, + Migrations: &project.DBMigrations{Path: "migrations/analytics/clickhouse", DatabaseEnv: "DB_ANALYTICS_CLICKHOUSE_MIGRATIONS_URL"}, + }}, + }}}}, + } + + catalog, err := project.NewRuntimeConfigCatalog(manifest) + require.NoError(t, err) + require.Contains(t, catalog.Entries(project.RuntimeConfigRuntime), project.RuntimeConfigField{ + Group: "DBAnalytics", Name: "ClickhouseDSN", Key: "SAMPLE_DB_ANALYTICS_CLICKHOUSE_DSN", + Type: project.RuntimeConfigString, Secret: true, + }) + require.NotContains(t, catalog.Entries(project.RuntimeConfigRuntime), project.RuntimeConfigField{ + Group: "Migrations", Name: "DBAnalyticsClickhouseMigrationsURL", Key: "SAMPLE_DB_ANALYTICS_CLICKHOUSE_MIGRATIONS_URL", + Type: project.RuntimeConfigString, Secret: true, + }) + require.Contains(t, catalog.Entries(project.RuntimeConfigExample), project.RuntimeConfigField{ + Group: "Migrations", Name: "DBAnalyticsClickhouseMigrationsURL", Key: "SAMPLE_DB_ANALYTICS_CLICKHOUSE_MIGRATIONS_URL", + Type: project.RuntimeConfigString, Secret: true, + }) +} + +func onlyMigrationFields(fields []project.RuntimeConfigField) []project.RuntimeConfigField { + result := make([]project.RuntimeConfigField, 0, len(fields)) + for _, field := range fields { + if field.Group == "Migrations" { + result = append(result, field) + } + } + return result +} diff --git a/internal/domain/project/source.go b/internal/domain/project/source.go new file mode 100644 index 0000000..3d20e01 --- /dev/null +++ b/internal/domain/project/source.go @@ -0,0 +1,29 @@ +package project + +// SourceType identifies the mechanism used to obtain a contract. +type SourceType string + +const ( + SourceLocal SourceType = "local" + SourceURL SourceType = "url" + SourceGit SourceType = "git" + SourceDevctl SourceType = "devctl" +) + +// Source describes a named local or external origin of contracts. +type Source struct { + Name string + Type SourceType + Path string + URL string + Filename string + AllowInsecureHTTP bool + Repo string + Ref string + Proto SourceProto +} + +// SourceProto contains protobuf tooling metadata relative to the source root. +type SourceProto struct { + BufConfig string +} diff --git a/internal/domain/project/target.go b/internal/domain/project/target.go new file mode 100644 index 0000000..5ece0ca --- /dev/null +++ b/internal/domain/project/target.go @@ -0,0 +1,403 @@ +package project + +import ( + "path" + "sort" + "strings" + + "github.com/devctllabs/devctl/internal/domain/contract" +) + +// TargetOperation identifies a workflow that can address a Target. +type TargetOperation uint8 + +const ( + TargetOperationSync TargetOperation = iota + 1 + TargetOperationLint + TargetOperationGenerate +) + +// Target contains the stable effective facts shared by target workflows. +type Target struct { + ID string + Family string + Role string + Name string + Format string + + SourceName string + Source Source + SourceFound bool + Reference contract.Reference + Location contract.Location + + Input string + Paths []string + Config string + OutputDir string + OutputFile string +} + +// TargetCatalog is an immutable effective Target projection of one Manifest. +type TargetCatalog struct { + entries []targetCatalogEntry +} + +type targetCatalogEntry struct { + target Target + operations targetOperations +} + +type targetOperations uint8 + +const ( + targetSync targetOperations = 1 << iota + targetLint + targetGenerate +) + +// NewTargetCatalog applies effective defaults and returns Targets sorted by ID. +// It is total: malformed references remain visible for validation instead of returning an error. +func NewTargetCatalog(manifest Manifest) TargetCatalog { + entries := targetEntries(manifest) + sort.Slice(entries, func(i, j int) bool { return entries[i].target.ID < entries[j].target.ID }) + return TargetCatalog{entries: entries} +} + +// All returns every configured Target as a defensive copy in stable ID order. +func (c TargetCatalog) All() []Target { + return copyCatalogTargets(c.entries) +} + +// Select returns CLI-addressable Targets for operation, family, and id in stable ID order. +// Empty family or id values do not constrain that dimension; an unsupported selection is empty. +func (c TargetCatalog) Select(operation TargetOperation, family, id string) []Target { + mask := targetOperationMask(operation) + if mask == 0 { + return nil + } + entries := make([]targetCatalogEntry, 0, len(c.entries)) + for _, entry := range c.entries { + if entry.operations&mask == 0 || family != "" && entry.target.Family != family || id != "" && entry.target.ID != id { + continue + } + entries = append(entries, entry) + } + return copyCatalogTargets(entries) +} + +func targetEntries(manifest Manifest) []targetCatalogEntry { + var entries []targetCatalogEntry + if generator := manifest.Languages.Go.Generators.Config; generator != nil || manifest.Project.Language == "go" { + outputDir := "gen/config" + if generator != nil { + outputDir = valueOrDefault(generator.Out, outputDir) + } + entries = append(entries, targetCatalogEntry{ + target: Target{ + ID: "config", Family: "config", Format: "go", + OutputDir: outputDir, OutputFile: "config.gen.go", + }, + operations: targetGenerate, + }) + } + entries = append(entries, httpTargetEntries(manifest)...) + entries = append(entries, grpcTargetEntries(manifest)...) + entries = append(entries, kafkaTargetEntries(manifest)...) + return entries +} + +func httpTargetEntries(manifest Manifest) []targetCatalogEntry { + http := manifest.Components.HTTP + if http == nil { + return nil + } + generator := manifest.Languages.Go.Generators.HTTP + var entries []targetCatalogEntry + if server := http.Server; server != nil { + entrypoint := valueOrDefault(server.OpenAPI, "api/openapi/swagger.yaml") + entries = append(entries, targetCatalogEntry{ + target: Target{ + ID: "http-server", Family: "http", Role: "server", Format: "openapi", + Reference: contract.Reference{Entrypoint: entrypoint}, + Location: contract.Location{RelativePath: entrypoint, Entrypoint: entrypoint, Local: true}, + Input: entrypoint, Config: httpServerConfig(generator), + OutputDir: httpServerOutput(generator), OutputFile: "server.gen.go", + }, + operations: targetLint | targetGenerate, + }) + } + for _, client := range http.Clients { + source, found := manifest.Sources[client.Source] + location := targetContractLocation(manifest, targetLocationRequest{ + source: source, sourceFound: found, entrypoint: client.Path, + externalSuffix: path.Join("http", "client", client.Name), + }) + input := location.RelativePath + if location.Local { + input = path.Join(input, client.Path) + } + operations := targetLint | targetGenerate + if found { + operations |= targetSync + } + entries = append(entries, targetCatalogEntry{ + target: Target{ + ID: "http-client:" + client.Name, Family: "http", Role: "client", Name: client.Name, Format: "openapi", + SourceName: client.Source, Source: source, SourceFound: found, + Reference: contract.Reference{Entrypoint: client.Path, Export: client.Export}, Location: location, + Input: input, Config: valueOrDefault(client.OAPIConfig, "tools/oapi/clients."+client.Name+".yaml"), + OutputDir: path.Join(httpClientOutput(generator), client.Name), OutputFile: "client.gen.go", + }, + operations: operations, + }) + } + return entries +} + +func grpcTargetEntries(manifest Manifest) []targetCatalogEntry { + grpc := manifest.Components.GRPC + if grpc == nil { + return nil + } + generator := manifest.Languages.Go.Generators.GRPC + root := grpcOutput(generator) + var entries []targetCatalogEntry + if server := grpc.Server; server != nil { + entries = append(entries, targetCatalogEntry{ + target: Target{ + ID: "grpc-server", Family: "grpc", Role: "server", Format: "proto", + Reference: contract.Reference{Format: "proto", ProtoRoot: valueOrDefault(server.ProtoRoot, "api/proto/grpc")}, + Input: valueOrDefault(server.ProtoRoot, "api/proto/grpc"), Config: grpcConfig(generator, ""), + OutputDir: path.Join(root, "server"), + }, + operations: targetLint | targetGenerate, + }) + } + for _, client := range grpc.Clients { + source, found := manifest.Sources[client.Source] + protoRoot := valueOrDefault(client.ProtoRoot, client.Path) + location := targetContractLocation(manifest, targetLocationRequest{ + source: source, sourceFound: found, entrypoint: client.Path, + externalSuffix: path.Join("grpc", "client", client.Name), + }) + inputRoot := location.RelativePath + if protoRoot != "." { + inputRoot = path.Join(inputRoot, protoRoot) + } + operations := targetLint | targetGenerate + if found { + operations |= targetSync + } + entries = append(entries, targetCatalogEntry{ + target: Target{ + ID: "grpc-client:" + client.Name, Family: "grpc", Role: "client", Name: client.Name, Format: "proto", + SourceName: client.Source, Source: source, SourceFound: found, + Reference: contract.Reference{Entrypoint: client.Path, Export: client.Export, Format: "proto", ProtoRoot: client.ProtoRoot}, + Location: location, Input: inputRoot, Paths: selectedProtoPaths(client.Path, protoRoot), + Config: grpcConfig(generator, client.BufGenConfig), OutputDir: path.Join(root, "client", client.Name), + }, + operations: operations, + }) + } + return entries +} + +func kafkaTargetEntries(manifest Manifest) []targetCatalogEntry { + kafka := manifest.Components.Kafka + if kafka == nil { + return nil + } + entries := make([]targetCatalogEntry, 0, len(kafka.Consumers)+len(kafka.Producers)) + for _, consumer := range kafka.Consumers { + entries = append(entries, kafkaTargetEntry(manifest, kafkaTargetSelection{ + role: "consumer", name: consumer.Name, topic: consumer.Topic, contract: consumer.Contract, + })) + } + for _, producer := range kafka.Producers { + entries = append(entries, kafkaTargetEntry(manifest, kafkaTargetSelection{ + role: "producer", name: producer.Name, topic: producer.Topic, contract: producer.Contract, + })) + } + return entries +} + +type kafkaTargetSelection struct { + role string + name string + topic string + contract KafkaContract +} + +func kafkaTargetEntry(manifest Manifest, selection kafkaTargetSelection) targetCatalogEntry { + role, name, topic, selected := selection.role, selection.name, selection.topic, selection.contract + format := valueOrDefault(selected.Format, "raw") + target := Target{ + ID: "kafka-" + role + ":" + name, Family: "kafka", Role: role, Name: name, Format: format, + Reference: contract.Reference{ + Entrypoint: selected.Path, Export: selected.Export, Format: format, + ProtoRoot: selected.ProtoRoot, Topic: topic, + }, + } + operations := targetLint | targetGenerate + if format == "raw" { + return targetCatalogEntry{target: target, operations: operations} + } + source, found := manifest.Sources[selected.Source] + target.SourceName, target.Source, target.SourceFound = selected.Source, source, found + target.Location = targetContractLocation(manifest, targetLocationRequest{ + source: source, sourceFound: found, entrypoint: selected.Path, + externalSuffix: path.Join("kafka", role, name), + }) + target.OutputDir = path.Join(kafkaOutput(manifest.Languages.Go.Generators.Kafka), role, name) + if format == "json" { + target.Input = path.Join(target.Location.RelativePath, selected.Path) + target.OutputFile = "schema.gen.go" + } else { + protoRoot := valueOrDefault(selected.ProtoRoot, path.Dir(selected.Path)) + target.Input = path.Join(target.Location.RelativePath, protoRoot) + target.Paths = selectedProtoPaths(selected.Path, protoRoot) + target.Config = kafkaConfig(manifest.Languages.Go.Generators.Kafka) + } + if found { + operations |= targetSync + } + return targetCatalogEntry{target: target, operations: operations} +} + +type targetLocationRequest struct { + source Source + sourceFound bool + entrypoint string + externalSuffix string +} + +func targetContractLocation(manifest Manifest, request targetLocationRequest) contract.Location { + if request.sourceFound && request.source.Type == SourceLocal { + return contract.Location{RelativePath: request.source.Path, Entrypoint: request.entrypoint, Local: true} + } + return contract.Location{ + RelativePath: path.Join(externalContractsRoot(manifest), request.externalSuffix), + Entrypoint: request.entrypoint, + } +} + +func selectedProtoPaths(entrypoint, protoRoot string) []string { + selected := strings.TrimPrefix(path.Clean(entrypoint), path.Clean(protoRoot)+"/") + if selected == "." { + return nil + } + return []string{selected} +} + +func targetOperationMask(operation TargetOperation) targetOperations { + switch operation { + case TargetOperationSync: + return targetSync + case TargetOperationLint: + return targetLint + case TargetOperationGenerate: + return targetGenerate + default: + return 0 + } +} + +func copyCatalogTargets(entries []targetCatalogEntry) []Target { + targets := make([]Target, len(entries)) + for index, entry := range entries { + targets[index] = entry.target + targets[index].Paths = append([]string(nil), entry.target.Paths...) + } + return targets +} + +// SnapshotExpectation binds a Devctl-sourced Target to committed Snapshot Metadata. +func (target Target) SnapshotExpectation() contract.MetadataExpectation { + expected := contract.MetadataExpectation{Kind: target.Family, Format: target.Format} + if target.Family == "kafka" { + expected.Topic = target.Reference.Topic + } + return expected +} + +// WithSnapshot resolves a Target's concrete input from validated committed metadata. +func (target Target) WithSnapshot(snapshot contract.Snapshot) Target { + root := target.Location.RelativePath + switch { + case target.Family == "grpc": + target.Input = path.Join(root, snapshot.ModuleRoot) + target.Paths = nil + case target.Family == "kafka" && target.Format == "proto": + target.Input = path.Join(root, snapshot.ModuleRoot) + target.Paths = selectedProtoPaths(snapshot.Entrypoint, snapshot.ModuleRoot) + target.Location.Entrypoint = snapshot.Entrypoint + case target.Family == "kafka" && target.Format == "json": + target.Input = path.Join(root, snapshot.Entrypoint) + target.Location.Entrypoint = snapshot.Entrypoint + } + return target +} + +func valueOrDefault(value, fallback string) string { + if value != "" { + return value + } + return fallback +} + +func externalContractsRoot(manifest Manifest) string { + return valueOrDefault(manifest.Paths.ExternalContracts, "api/external") +} + +func httpServerConfig(generator *HTTPGenerator) string { + if generator == nil { + return "tools/oapi/server.yaml" + } + return valueOrDefault(generator.OAPIConfig, "tools/oapi/server.yaml") +} + +func httpServerOutput(generator *HTTPGenerator) string { + if generator == nil { + return "gen/serverhttp" + } + return valueOrDefault(generator.ServerOut, "gen/serverhttp") +} + +func httpClientOutput(generator *HTTPGenerator) string { + if generator == nil { + return "gen/clienthttp" + } + return valueOrDefault(generator.ClientOut, "gen/clienthttp") +} + +func grpcConfig(generator *GRPCGenerator, selected string) string { + if selected != "" { + return selected + } + if generator != nil && generator.BufGenConfig != "" { + return generator.BufGenConfig + } + return "tools/buf/grpc.gen.yaml" +} + +func grpcOutput(generator *GRPCGenerator) string { + if generator == nil { + return "gen/grpc" + } + return valueOrDefault(generator.Out, "gen/grpc") +} + +func kafkaConfig(generator *KafkaGenerator) string { + if generator != nil && generator.BufGenConfig != "" { + return generator.BufGenConfig + } + return "tools/buf/kafka.gen.yaml" +} + +func kafkaOutput(generator *KafkaGenerator) string { + if generator == nil { + return "gen/kafka" + } + return valueOrDefault(generator.Out, "gen/kafka") +} diff --git a/internal/domain/project/target_selection.go b/internal/domain/project/target_selection.go new file mode 100644 index 0000000..1ab2084 --- /dev/null +++ b/internal/domain/project/target_selection.go @@ -0,0 +1,80 @@ +package project + +import "github.com/devctllabs/devctl/internal/domain/failure" + +// TargetSelectionReason identifies why a workflow target selection failed. +type TargetSelectionReason string + +const ( + TargetSelectionUnknownFamily TargetSelectionReason = "unknown_family" + TargetSelectionTargetNotFound TargetSelectionReason = "target_not_found" + TargetSelectionOperationUnsupported TargetSelectionReason = "operation_unsupported" +) + +// TargetSelectionError reports one presentation-neutral catalog selection fact. +type TargetSelectionError struct { + Operation TargetOperation + Family string + Target string + Reason TargetSelectionReason +} + +func (e *TargetSelectionError) Error() string { return "target selection failed" } + +// Category maps selection policy to the stable transport-neutral failure contract. +func (e *TargetSelectionError) Category() failure.Category { + switch e.Reason { + case TargetSelectionUnknownFamily: + return failure.InvalidInput + case TargetSelectionTargetNotFound: + return failure.NotFound + case TargetSelectionOperationUnsupported: + return failure.Unsupported + default: + return failure.Internal + } +} + +// Resolve applies the workflow selection contract and returns targets in catalog ID order. +func (c TargetCatalog) Resolve(operation TargetOperation, family, id string) ([]Target, error) { + if family != "" && !knownTargetFamily(family) { + return nil, &TargetSelectionError{ + Operation: operation, Family: family, Target: id, Reason: TargetSelectionUnknownFamily, + } + } + if id == "" { + return c.Select(operation, family, ""), nil + } + + entry, exists := c.entry(id) + if !exists || family != "" && entry.target.Family != family { + return nil, &TargetSelectionError{ + Operation: operation, Family: family, Target: id, Reason: TargetSelectionTargetNotFound, + } + } + mask := targetOperationMask(operation) + if mask == 0 || entry.operations&mask == 0 { + return nil, &TargetSelectionError{ + Operation: operation, Family: family, Target: id, Reason: TargetSelectionOperationUnsupported, + } + } + return copyCatalogTargets([]targetCatalogEntry{entry}), nil +} + +func (c TargetCatalog) entry(id string) (targetCatalogEntry, bool) { + for _, entry := range c.entries { + if entry.target.ID == id { + return entry, true + } + } + return targetCatalogEntry{}, false +} + +func knownTargetFamily(family string) bool { + switch family { + case "config", "http", "grpc", "kafka": + return true + default: + return false + } +} diff --git a/internal/domain/project/target_test.go b/internal/domain/project/target_test.go new file mode 100644 index 0000000..a9a8ef1 --- /dev/null +++ b/internal/domain/project/target_test.go @@ -0,0 +1,242 @@ +package project_test + +import ( + "testing" + + "github.com/devctllabs/devctl/internal/domain/contract" + "github.com/devctllabs/devctl/internal/domain/failure" + "github.com/devctllabs/devctl/internal/domain/project" + "github.com/stretchr/testify/require" +) + +func TestTargetCatalogBuildsStableFacts(t *testing.T) { + t.Parallel() + + localSource := project.Source{Type: project.SourceLocal, Path: "api/contracts"} + remoteSource := project.Source{Type: project.SourceGit, Repo: "acme/contracts", Ref: "v1"} + manifest := project.Manifest{ + Paths: project.ManifestPaths{ExternalContracts: "contracts/external"}, + Sources: map[string]project.Source{ + "local": localSource, + "remote": remoteSource, + }, + Components: project.Components{ + HTTP: &project.HTTP{ + Server: &project.HTTPServer{}, + Clients: []project.HTTPClient{{ + Name: "catalog", Source: "remote", Path: "openapi/catalog.yaml", + }}, + }, + GRPC: &project.GRPC{Clients: []project.GRPCClient{{ + Name: "billing", Source: "local", Path: "proto/acme/billing/v1/service.proto", + ProtoRoot: "proto", BufGenConfig: "tools/buf/billing.gen.yaml", + }}}, + Kafka: &project.Kafka{ + Consumers: []project.KafkaConsumer{{ + Name: "audit", Topic: "audit.events.v1", Contract: project.KafkaContract{Format: "raw"}, + }}, + Producers: []project.KafkaProducer{{ + Name: "invoice", Topic: "invoice.events.v1", Contract: project.KafkaContract{ + Source: "remote", Path: "proto/acme/invoice/v1/event.proto", Format: "proto", + ProtoRoot: "proto", Message: "acme.invoice.v1.Event", Encoding: "binary", + }, + }}, + }, + }, + Languages: project.Languages{Go: project.GoLanguage{Generators: project.GoGenerators{ + Config: &project.ConfigGenerator{Out: "generated/config"}, + HTTP: &project.HTTPGenerator{ServerOut: "generated/http/server", ClientOut: "generated/http/client"}, + GRPC: &project.GRPCGenerator{Out: "generated/grpc"}, + Kafka: &project.KafkaGenerator{Out: "generated/kafka", BufGenConfig: "tools/buf/kafka.custom.yaml"}, + }}}, + } + + targets := project.NewTargetCatalog(manifest).All() + + require.Equal(t, []project.Target{ + { + ID: "config", Family: "config", Format: "go", + OutputDir: "generated/config", OutputFile: "config.gen.go", + }, + { + ID: "grpc-client:billing", Family: "grpc", Role: "client", Name: "billing", Format: "proto", + SourceName: "local", Source: localSource, SourceFound: true, + Reference: contract.Reference{Entrypoint: "proto/acme/billing/v1/service.proto", Format: "proto", ProtoRoot: "proto"}, + Location: contract.Location{RelativePath: "api/contracts", Entrypoint: "proto/acme/billing/v1/service.proto", Local: true}, + Input: "api/contracts/proto", Paths: []string{"acme/billing/v1/service.proto"}, + Config: "tools/buf/billing.gen.yaml", OutputDir: "generated/grpc/client/billing", + }, + { + ID: "http-client:catalog", Family: "http", Role: "client", Name: "catalog", Format: "openapi", + SourceName: "remote", Source: remoteSource, SourceFound: true, + Reference: contract.Reference{Entrypoint: "openapi/catalog.yaml"}, + Location: contract.Location{RelativePath: "contracts/external/http/client/catalog", Entrypoint: "openapi/catalog.yaml"}, + Input: "contracts/external/http/client/catalog", Config: "tools/oapi/clients.catalog.yaml", + OutputDir: "generated/http/client/catalog", OutputFile: "client.gen.go", + }, + { + ID: "http-server", Family: "http", Role: "server", Format: "openapi", + Reference: contract.Reference{Entrypoint: "api/openapi/swagger.yaml"}, + Location: contract.Location{RelativePath: "api/openapi/swagger.yaml", Entrypoint: "api/openapi/swagger.yaml", Local: true}, + Input: "api/openapi/swagger.yaml", Config: "tools/oapi/server.yaml", + OutputDir: "generated/http/server", OutputFile: "server.gen.go", + }, + { + ID: "kafka-consumer:audit", Family: "kafka", Role: "consumer", Name: "audit", Format: "raw", + Reference: contract.Reference{Format: "raw", Topic: "audit.events.v1"}, + }, + { + ID: "kafka-producer:invoice", Family: "kafka", Role: "producer", Name: "invoice", Format: "proto", + SourceName: "remote", Source: remoteSource, SourceFound: true, + Reference: contract.Reference{ + Entrypoint: "proto/acme/invoice/v1/event.proto", Format: "proto", ProtoRoot: "proto", Topic: "invoice.events.v1", + }, + Location: contract.Location{RelativePath: "contracts/external/kafka/producer/invoice", Entrypoint: "proto/acme/invoice/v1/event.proto"}, + Input: "contracts/external/kafka/producer/invoice/proto", Paths: []string{"acme/invoice/v1/event.proto"}, + Config: "tools/buf/kafka.custom.yaml", OutputDir: "generated/kafka/producer/invoice", + }, + }, targets) +} + +func TestTargetCatalogSelectsCLIAddressableTargets(t *testing.T) { + t.Parallel() + + manifest := project.Manifest{ + Sources: map[string]project.Source{ + "local": {Type: project.SourceLocal, Path: "api/contracts"}, + "remote": {Type: project.SourceURL, URL: "https://example.test/openapi.yaml"}, + }, + Components: project.Components{ + HTTP: &project.HTTP{ + Server: &project.HTTPServer{}, + Clients: []project.HTTPClient{ + {Name: "local", Source: "local", Path: "openapi.yaml"}, + {Name: "remote", Source: "remote", Path: "openapi.yaml"}, + }, + }, + Kafka: &project.Kafka{ + Consumers: []project.KafkaConsumer{{Name: "raw", Contract: project.KafkaContract{Format: "raw"}}}, + Producers: []project.KafkaProducer{{ + Name: "schema", Contract: project.KafkaContract{Source: "local", Path: "schema.json", Format: "json"}, + }}, + }, + }, + Languages: project.Languages{Go: project.GoLanguage{Generators: project.GoGenerators{Config: &project.ConfigGenerator{}}}}, + } + catalog := project.NewTargetCatalog(manifest) + + require.Equal(t, + []string{"http-client:local", "http-client:remote", "kafka-producer:schema"}, + targetIDs(catalog.Select(project.TargetOperationSync, "", "")), + ) + require.Equal(t, + []string{"http-client:local", "http-client:remote", "http-server", "kafka-consumer:raw", "kafka-producer:schema"}, + targetIDs(catalog.Select(project.TargetOperationLint, "", "")), + ) + require.Equal(t, + []string{"config", "http-client:local", "http-client:remote", "http-server", "kafka-consumer:raw", "kafka-producer:schema"}, + targetIDs(catalog.Select(project.TargetOperationGenerate, "", "")), + ) + require.Equal(t, + []string{"http-client:local"}, + targetIDs(catalog.Select(project.TargetOperationSync, "http", "http-client:local")), + ) + require.Empty(t, catalog.Select(project.TargetOperationSync, "kafka", "kafka-consumer:raw")) + require.Empty(t, catalog.Select(project.TargetOperationGenerate, "grpc", "http-server")) +} + +func TestTargetCatalogDefaultsGoConfigTarget(t *testing.T) { + t.Parallel() + + manifest := project.Manifest{ + Project: project.Identity{Language: "go"}, + Languages: project.Languages{Go: project.GoLanguage{Module: "example.test/sample"}}, + } + + targets := project.NewTargetCatalog(manifest).Select(project.TargetOperationGenerate, "config", "") + + require.Equal(t, []project.Target{{ + ID: "config", Family: "config", Format: "go", + OutputDir: "gen/config", OutputFile: "config.gen.go", + }}, targets) +} + +func TestTargetCatalogIsTotalAndReturnsDefensiveCopies(t *testing.T) { + t.Parallel() + + manifest := project.Manifest{Components: project.Components{GRPC: &project.GRPC{Clients: []project.GRPCClient{{ + Name: "missing", Source: "unknown", Path: "proto/missing.proto", ProtoRoot: "proto", + }}}}} + catalog := project.NewTargetCatalog(manifest) + + targets := catalog.All() + require.Len(t, targets, 1) + require.Equal(t, "unknown", targets[0].SourceName) + require.False(t, targets[0].SourceFound) + require.Equal(t, "api/external/grpc/client/missing/proto", targets[0].Input) + + targets[0].ID = "changed" + targets[0].Paths[0] = "changed.proto" + require.Equal(t, "grpc-client:missing", catalog.All()[0].ID) + require.Equal(t, []string{"missing.proto"}, catalog.All()[0].Paths) +} + +func TestTargetCatalogAppliesOneWorkflowSelectionContract(t *testing.T) { + t.Parallel() + + manifest := project.Manifest{ + Project: project.Identity{Language: "go"}, + Sources: map[string]project.Source{ + "local": {Type: project.SourceLocal, Path: "api/contracts"}, + }, + Components: project.Components{ + HTTP: &project.HTTP{Clients: []project.HTTPClient{{Name: "local", Source: "local", Path: "openapi.yaml"}}}, + Kafka: &project.Kafka{Consumers: []project.KafkaConsumer{{ + Name: "raw", Topic: "sample.events.raw.v1", Contract: project.KafkaContract{Format: "raw"}, + }}}, + }, + } + catalog := project.NewTargetCatalog(manifest) + + tests := []struct { + name string + operation project.TargetOperation + family string + id string + ids []string + category failure.Category + }{ + {name: "known empty family", operation: project.TargetOperationSync, family: "grpc", ids: []string{}}, + {name: "known empty lint family", operation: project.TargetOperationLint, family: "config", ids: []string{}}, + {name: "local sync no-op is supported", operation: project.TargetOperationSync, family: "http", id: "http-client:local", ids: []string{"http-client:local"}}, + {name: "unknown family", operation: project.TargetOperationSync, family: "other", category: failure.InvalidInput}, + {name: "unknown lint family", operation: project.TargetOperationLint, family: "other", category: failure.InvalidInput}, + {name: "unknown target", operation: project.TargetOperationGenerate, id: "grpc-client:missing", category: failure.NotFound}, + {name: "existing target unsupported by operation", operation: project.TargetOperationSync, id: "config", category: failure.Unsupported}, + {name: "existing target unsupported by lint", operation: project.TargetOperationLint, id: "config", category: failure.Unsupported}, + {name: "raw Kafka does not support sync", operation: project.TargetOperationSync, id: "kafka-consumer:raw", category: failure.Unsupported}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + targets, err := catalog.Resolve(test.operation, test.family, test.id) + + if test.category != "" { + require.Equal(t, test.category, failure.CategoryOf(err)) + return + } + require.NoError(t, err) + require.Equal(t, test.ids, targetIDs(targets)) + }) + } +} + +func targetIDs(targets []project.Target) []string { + ids := make([]string, len(targets)) + for index, target := range targets { + ids[index] = target.ID + } + return ids +} diff --git a/internal/domain/project/validation.go b/internal/domain/project/validation.go new file mode 100644 index 0000000..4b6f910 --- /dev/null +++ b/internal/domain/project/validation.go @@ -0,0 +1,512 @@ +package project + +import ( + "errors" + "net" + "net/url" + "path" + "path/filepath" + "regexp" + "sort" + "strconv" + "strings" +) + +var validationName = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`) +var validationEnvironmentKey = regexp.MustCompile(`^[A-Z][A-Z0-9_]*$`) + +type semanticValidation struct { + project Project + issues []Issue +} + +// Validate returns every context-free semantic issue in stable order. +func Validate(project Project) []Issue { + validation := &semanticValidation{project: project} + validation.validateIdentityAndPaths() + validation.validateSources() + validation.validateExports() + validation.validateHTTP() + validation.validateGRPC() + validation.validateKafka() + validation.validateComponentPolicies() + validation.validateRuntimeConfig() + return validation.issues +} + +func (v *semanticValidation) add(code IssueCode, field string) { + v.issues = append(v.issues, Issue{Code: code, Path: v.project.ManifestPath, Field: field}) +} + +func (v *semanticValidation) validateIdentityAndPaths() { + manifest := v.project.Manifest + if manifest.Version != 1 { + v.add(IssueVersionUnsupported, "version") + } + if !validationName.MatchString(manifest.Project.Name) { + v.add(IssueNameInvalid, "project.name") + } + if manifest.Project.Language != "go" { + v.add(IssueLanguageUnsupported, "project.language") + } + if manifest.Languages.Go.Module == "" { + v.add(IssueGoModuleRequired, "languages.go.module") + } + if manifest.Paths.ExternalContracts != "" && !validationSafeRelative(manifest.Paths.ExternalContracts) { + v.add(IssuePathInvalid, "paths.external_contracts") + } + v.validateOutputPaths() +} + +func (v *semanticValidation) validateSources() { + for _, name := range validationSortedKeys(v.project.Manifest.Sources) { + v.validateSource(name, v.project.Manifest.Sources[name]) + } +} + +func (v *semanticValidation) validateSource(name string, source Source) { + base := "sources." + name + if !validationName.MatchString(name) { + v.add(IssueSourceNameInvalid, base) + } + unsafeBufConfig := source.Proto.BufConfig != "" && !validationSafeRelative(source.Proto.BufConfig) + validatedSource := source + if unsafeBufConfig { + validatedSource.Proto.BufConfig = "" + v.add(IssuePathInvalid, base+".proto.buf_config") + } + if !sourceShapeValid(validatedSource) { + v.add(IssueSourceInvalid, base) + } + switch source.Type { + case SourceLocal: + if source.Path == "" || !validationSafeRelative(source.Path) { + v.add(IssueSourceInvalid, base) + } + case SourceURL: + if source.URL == "" { + v.add(IssueSourceInvalid, base) + } + if strings.HasPrefix(strings.ToLower(source.URL), "http://") && !source.AllowInsecureHTTP { + v.add(IssueSourceInsecure, base) + } + case SourceGit, SourceDevctl: + if source.Repo == "" || source.Ref == "" { + v.add(IssueSourceInvalid, base) + } + default: + v.add(IssueSourceTypeUnsupported, base) + } +} + +func sourceShapeValid(source Source) bool { + if source.Proto.BufConfig != "" && !validationSafeRelative(source.Proto.BufConfig) { + return false + } + switch source.Type { + case SourceLocal: + return validationSafeRelative(source.Path) && source.URL == "" && source.Repo == "" && + source.Ref == "" && source.Filename == "" && !source.AllowInsecureHTTP + case SourceURL: + parsed, err := url.Parse(source.URL) + validURL := err == nil && parsed.Host != "" && parsed.User == nil && + (parsed.Scheme == "https" || parsed.Scheme == "http" && source.AllowInsecureHTTP) + return validURL && source.Path == "" && source.Repo == "" && source.Ref == "" && + (source.Filename == "" || !strings.Contains(source.Filename, "/")) + case SourceGit: + return source.Repo != "" && source.Ref != "" && + (source.Path == "" || validationSafeRelative(source.Path)) && source.URL == "" && + source.Filename == "" && !source.AllowInsecureHTTP + case SourceDevctl: + return source.Repo != "" && source.Ref != "" && source.Path == "" && source.URL == "" && + source.Filename == "" && !source.AllowInsecureHTTP + default: + return false + } +} + +func (v *semanticValidation) validateExports() { + for _, name := range validationSortedKeys(v.project.Manifest.Exports) { + if !v.project.Manifest.ExportMatchesSurface(v.project.Manifest.Exports[name]) { + v.add(IssueExportInvalid, "exports."+name) + } + } +} + +func (v *semanticValidation) validateHTTP() { + httpComponent := v.project.Manifest.Components.HTTP + if httpComponent == nil { + return + } + if server := httpComponent.Server; server != nil { + entrypoint := valueOrDefault(server.OpenAPI, "api/openapi/swagger.yaml") + if !validationSafeRelative(entrypoint) { + v.add(IssuePathInvalid, "components.http.server.openapi") + } + } + seen := make(map[string]bool, len(httpComponent.Clients)) + for _, client := range httpComponent.Clients { + base := "components.http.clients." + client.Name + if !validationName.MatchString(client.Name) || seen[client.Name] { + v.add(IssueHTTPClientInvalid, base) + } + source, exists := v.project.Manifest.Sources[client.Source] + if !exists { + v.add(IssueSourceNotFound, base+".source") + } else if clientContractSelectionInvalid(source, client.Export, client.Path) { + v.add(IssueHTTPClientInvalid, base) + } + seen[client.Name] = true + } +} + +func (v *semanticValidation) validateGRPC() { + grpc := v.project.Manifest.Components.GRPC + if grpc == nil { + return + } + if server := grpc.Server; server != nil { + if server.ProtoRoot != "" && !validationSafeProtoRoot(server.ProtoRoot) { + v.add(IssuePathInvalid, "components.grpc.server.proto_root") + } + if server.BufConfig != "" && !validationSafeRelative(server.BufConfig) { + v.add(IssuePathInvalid, "components.grpc.server.buf_config") + } + } + if generator := v.project.Manifest.Languages.Go.Generators.GRPC; generator != nil && + generator.BufGenConfig != "" && !validationSafeRelative(generator.BufGenConfig) { + v.add(IssuePathInvalid, "languages.go.generators.grpc.buf_gen_config") + } + seen := make(map[string]bool, len(grpc.Clients)) + for _, client := range grpc.Clients { + v.validateGRPCClient(client, seen[client.Name]) + seen[client.Name] = true + } +} + +func (v *semanticValidation) validateGRPCClient(client GRPCClient, duplicate bool) { + base := "components.grpc.clients." + client.Name + if !validationName.MatchString(client.Name) || duplicate { + v.add(IssueGRPCClientInvalid, base) + } + source, exists := v.project.Manifest.Sources[client.Source] + if !exists { + v.add(IssueSourceNotFound, base+".source") + return + } + if clientContractSelectionInvalid(source, client.Export, client.Path) { + v.add(IssueGRPCClientInvalid, base) + } + if client.Path != "" && !validationSafeRelative(client.Path) { + v.add(IssuePathInvalid, base+".path") + } + if client.ProtoRoot != "" && !validationSafeProtoRoot(client.ProtoRoot) { + v.add(IssuePathInvalid, base+".proto_root") + } + if client.BufGenConfig != "" && !validationSafeRelative(client.BufGenConfig) { + v.add(IssuePathInvalid, base+".buf_gen_config") + } +} + +func clientContractSelectionInvalid(source Source, exported, selectedPath string) bool { + if source.Type == SourceDevctl { + return exported == "" || selectedPath != "" + } + return selectedPath == "" || exported != "" +} + +func (v *semanticValidation) validateKafka() { + kafka := v.project.Manifest.Components.Kafka + if kafka == nil { + return + } + for _, consumer := range kafka.Consumers { + base := "components.kafka.consumers." + consumer.Name + ".contract" + v.validateKafkaContract(base, consumer.Contract) + v.validateKafkaSource(base+".source", consumer.Contract) + } + for _, producer := range kafka.Producers { + base := "components.kafka.producers." + producer.Name + ".contract" + v.validateKafkaContract(base, producer.Contract) + v.validateKafkaSource(base+".source", producer.Contract) + } +} + +func (v *semanticValidation) validateKafkaContract(field string, selected KafkaContract) { + format := valueOrDefault(selected.Format, "raw") + if format == "raw" { + if selected.Source != "" || selected.Export != "" || selected.Path != "" || + selected.ProtoRoot != "" || selected.Message != "" || selected.Encoding != "" { + v.add(IssueKafkaContractInvalid, field) + } + return + } + if format != "json" && format != "proto" || selected.Source == "" { + v.add(IssueKafkaContractInvalid, field) + return + } + source, exists := v.project.Manifest.Sources[selected.Source] + if !exists { + return + } + if kafkaSourceSelectionInvalid(source, selected) { + v.add(IssueKafkaContractInvalid, field) + return + } + if selected.Path != "" && !validationSafeRelative(selected.Path) { + v.add(IssueKafkaContractInvalid, field) + return + } + if format == "json" { + if selected.ProtoRoot != "" || selected.Message != "" || selected.Encoding != "" { + v.add(IssueKafkaContractInvalid, field) + } + return + } + protoRoot := valueOrDefault(selected.ProtoRoot, filepath.ToSlash(filepath.Dir(selected.Path))) + if !validationSafeProtoRoot(protoRoot) || selected.Path != "" && !validationPathWithin(protoRoot, selected.Path) || + selected.Encoding != "" && selected.Encoding != "binary" && selected.Encoding != "json" { + v.add(IssueKafkaContractInvalid, field) + } +} + +func kafkaSourceSelectionInvalid(source Source, selected KafkaContract) bool { + if source.Type == SourceDevctl { + return selected.Export == "" || selected.Path != "" + } + return selected.Path == "" || selected.Export != "" +} + +func (v *semanticValidation) validateKafkaSource(field string, selected KafkaContract) { + if selected.Source == "" { + return + } + if _, exists := v.project.Manifest.Sources[selected.Source]; !exists { + v.add(IssueSourceNotFound, field) + } +} + +func (v *semanticValidation) validateComponentPolicies() { + manifest := v.project.Manifest + if manifest.Components.DB != nil { + v.validateDB(manifest.Components.DB) + } + if manifest.Components.S3 != nil { + v.validateS3(manifest.Components.S3) + } + if manifest.Components.Redis != nil { + v.validateRedis(manifest.Components.Redis) + } +} + +func (v *semanticValidation) validateDB(database *DB) { + if len(database.Connections) == 0 { + v.add(IssueDBConnectionInvalid, "components.db.connections") + return + } + seen := make(map[string]bool, len(database.Connections)) + for _, connection := range database.Connections { + v.validateDBConnection(connection, seen[connection.Name]) + seen[connection.Name] = true + } +} + +func (v *semanticValidation) validateDBConnection(connection DBConnection, duplicate bool) { + base := "components.db.connections." + connection.Name + if !validationName.MatchString(connection.Name) || duplicate { + v.add(IssueDBConnectionInvalid, base) + } + seen := make(map[string]bool, len(connection.Variants)) + for _, variant := range connection.Variants { + validKind := variant.Kind == "sqlite" || variant.Kind == "postgres" || variant.Kind == "clickhouse" + if !validationName.MatchString(variant.Name) || seen[variant.Name] || !validKind { + v.add(IssueDBVariantInvalid, base) + } + seen[variant.Name] = true + if variant.Migrations != nil && !validDBMigrations(variant.Kind, variant.Migrations) { + v.add(IssueDBMigrationsInvalid, base+".variants."+variant.Name+".migrations") + } + } + if connection.Default == "" && len(connection.Variants) != 1 || + connection.Default != "" && !seen[connection.Default] { + v.add(IssueDBDefaultInvalid, base+".default") + } +} + +func validDBMigrations(kind string, migrations *DBMigrations) bool { + if migrations == nil { + return true + } + if kind != "sqlite" && kind != "postgres" && kind != "clickhouse" || + !validationSafeRelative(migrations.Path) || !validationEnvironmentKey.MatchString(migrations.DatabaseEnv) { + return false + } + if migrations.DatabaseDefault == "" { + return true + } + parsed, err := url.Parse(migrations.DatabaseDefault) + if err != nil { + return false + } + switch kind { + case "sqlite": + return parsed.Scheme == "sqlite" + case "clickhouse": + return parsed.Scheme == "clickhouse" + default: + return parsed.Scheme == "postgres" || parsed.Scheme == "postgresql" + } +} + +func (v *semanticValidation) validateRedis(redis *Redis) { + seen := make(map[string]bool, len(redis.Connections)) + for _, connection := range redis.Connections { + base := "components.redis.connections." + connection.Name + if !validationName.MatchString(connection.Name) || seen[connection.Name] || + !validationEnvironmentKey.MatchString(connection.AddrEnv) { + v.add(IssueRedisConnectionInvalid, base) + } + if connection.AddrDefault != "" && !validRedisAddress(connection.AddrDefault) { + v.add(IssueRedisAddressInvalid, base+".addr_default") + } + seen[connection.Name] = true + } +} + +func validRedisAddress(value string) bool { + if strings.Contains(value, "://") { + parsed, err := url.Parse(value) + return err == nil && (parsed.Scheme == "redis" || parsed.Scheme == "rediss") && + parsed.Hostname() != "" && parsed.User == nil + } + host, port, err := net.SplitHostPort(value) + if err != nil || host == "" { + return false + } + number, err := strconv.Atoi(port) + return err == nil && number > 0 && number <= 65535 +} + +func (v *semanticValidation) validateS3(storage *S3) { + connections := make(map[string]struct{}, len(storage.Connections)) + for _, connection := range storage.Connections { + connections[connection.Name] = struct{}{} + } + for _, bucket := range storage.Buckets { + if _, exists := connections[bucket.Connection]; !exists { + v.add(IssueS3ConnectionNotFound, "components.s3.buckets."+bucket.Name+".connection") + } + } +} + +func (v *semanticValidation) validateRuntimeConfig() { + _, err := NewRuntimeConfigCatalog(v.project.Manifest) + var conflict *RuntimeConfigConflictError + if errors.As(err, &conflict) { + v.add(IssueRuntimeConfigConflict, "env") + } +} + +func (v *semanticValidation) validateOutputPaths() { + paths := managedOutputPaths(v.project.Manifest) + for _, candidate := range paths { + if candidate.value != "" && !validationSafeRelative(candidate.value) { + v.add(IssuePathInvalid, candidate.field) + } + } + for index := range paths { + v.validateOutputOverlaps(paths, index) + } +} + +type managedPath struct { + field string + value string +} + +func managedOutputPaths(manifest Manifest) []managedPath { + paths := []managedPath{{field: "paths.external_contracts", value: valueOrDefault(manifest.Paths.ExternalContracts, "api/external")}} + seen := make(map[managedPath]struct{}) + for _, target := range NewTargetCatalog(manifest).All() { + candidate := managedPath{field: targetOutputField(target), value: target.OutputDir} + if candidate.field == "" || candidate.value == "" { + continue + } + if _, exists := seen[candidate]; exists { + continue + } + seen[candidate] = struct{}{} + paths = append(paths, candidate) + } + return paths +} + +func (v *semanticValidation) validateOutputOverlaps(paths []managedPath, leftIndex int) { + leftCandidate := paths[leftIndex] + if leftCandidate.value == "" { + return + } + left := filepath.Clean(filepath.FromSlash(leftCandidate.value)) + for _, rightCandidate := range paths[leftIndex+1:] { + if rightCandidate.value == "" { + continue + } + right := filepath.Clean(filepath.FromSlash(rightCandidate.value)) + if validationPathsOverlap(left, right) { + v.add(IssuePathOverlap, leftCandidate.field) + } + } +} + +func targetOutputField(target Target) string { + switch target.Family { + case "config": + return "languages.go.generators.config.out" + case "http": + if target.Role == "server" { + return "languages.go.generators.http.server_out" + } + return "languages.go.generators.http.client_out" + case "grpc": + return "languages.go.generators.grpc.out" + case "kafka": + return "languages.go.generators.kafka.out" + default: + return "" + } +} + +func validationSafeRelative(name string) bool { + if name == "" || strings.HasPrefix(name, "/") { + return false + } + clean := path.Clean(strings.ReplaceAll(name, "\\", "/")) + return clean != "." && clean != ".." && !strings.HasPrefix(clean, "../") +} + +func validationSafeProtoRoot(value string) bool { + return value == "." || validationSafeRelative(value) +} + +func validationPathWithin(root, selected string) bool { + root = filepath.ToSlash(filepath.Clean(root)) + selected = filepath.ToSlash(filepath.Clean(selected)) + if root == "." { + return validationSafeRelative(selected) + } + return selected == root || strings.HasPrefix(selected, root+"/") +} + +func validationPathsOverlap(left, right string) bool { + separator := string(filepath.Separator) + return left == right || strings.HasPrefix(left, right+separator) || strings.HasPrefix(right, left+separator) +} + +func validationSortedKeys[Value any](values map[string]Value) []string { + keys := make([]string, 0, len(values)) + for key := range values { + keys = append(keys, key) + } + sort.Strings(keys) + return keys +} diff --git a/internal/domain/project/validation_policy_test.go b/internal/domain/project/validation_policy_test.go new file mode 100644 index 0000000..6720130 --- /dev/null +++ b/internal/domain/project/validation_policy_test.go @@ -0,0 +1,174 @@ +package project_test + +import ( + "testing" + + "github.com/devctllabs/devctl/internal/domain/project" + "github.com/stretchr/testify/require" +) + +func TestValidateContextFreePolicyGroups(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + mutate func(*project.Manifest) + expected []project.Issue + }{ + { + name: "sources", + mutate: func(manifest *project.Manifest) { + manifest.Sources = map[string]project.Source{ + "Bad": {Type: project.SourceLocal, Path: "contracts"}, + "devctl": {Type: project.SourceDevctl, Repo: "example/contracts", Ref: "v1", Path: "unexpected"}, + "insecure": {Type: project.SourceURL, URL: "http://example.test/openapi.yaml"}, + "unknown": {Type: project.SourceType("ftp")}, + } + }, + expected: []project.Issue{ + {Code: project.IssueSourceNameInvalid, Path: projectManifestPath, Field: "sources.Bad"}, + {Code: project.IssueSourceInvalid, Path: projectManifestPath, Field: "sources.devctl"}, + {Code: project.IssueSourceInvalid, Path: projectManifestPath, Field: "sources.insecure"}, + {Code: project.IssueSourceInsecure, Path: projectManifestPath, Field: "sources.insecure"}, + {Code: project.IssueSourceInvalid, Path: projectManifestPath, Field: "sources.unknown"}, + {Code: project.IssueSourceTypeUnsupported, Path: projectManifestPath, Field: "sources.unknown"}, + }, + }, + { + name: "exports are checked in name order", + mutate: func(manifest *project.Manifest) { + manifest.Exports = map[string]project.Export{ + "zeta": {Kind: "openapi", Path: "api/zeta.yaml"}, + "alpha": {Kind: "proto", Path: "api/alpha.proto"}, + } + }, + expected: []project.Issue{ + {Code: project.IssueExportInvalid, Path: projectManifestPath, Field: "exports.alpha"}, + {Code: project.IssueExportInvalid, Path: projectManifestPath, Field: "exports.zeta"}, + }, + }, + { + name: "http", + mutate: func(manifest *project.Manifest) { + manifest.Sources = map[string]project.Source{ + "local": {Type: project.SourceLocal, Path: "contracts"}, + } + manifest.Components.HTTP = &project.HTTP{ + Server: &project.HTTPServer{OpenAPI: "../openapi.yaml"}, + Clients: []project.HTTPClient{ + {Name: "Bad", Source: "missing"}, + {Name: "local", Source: "local"}, + {Name: "local", Source: "local", Path: "openapi.yaml"}, + }, + } + }, + expected: []project.Issue{ + {Code: project.IssuePathInvalid, Path: projectManifestPath, Field: "components.http.server.openapi"}, + {Code: project.IssueHTTPClientInvalid, Path: projectManifestPath, Field: "components.http.clients.Bad"}, + {Code: project.IssueSourceNotFound, Path: projectManifestPath, Field: "components.http.clients.Bad.source"}, + {Code: project.IssueHTTPClientInvalid, Path: projectManifestPath, Field: "components.http.clients.local"}, + {Code: project.IssueHTTPClientInvalid, Path: projectManifestPath, Field: "components.http.clients.local"}, + }, + }, + { + name: "grpc", + mutate: func(manifest *project.Manifest) { + manifest.Sources = map[string]project.Source{ + "contracts": {Type: project.SourceLocal, Path: "contracts"}, + } + manifest.Components.GRPC = &project.GRPC{ + Server: &project.GRPCServer{ProtoRoot: "../proto", BufConfig: "/buf.yaml"}, + Clients: []project.GRPCClient{{ + Name: "client", Source: "contracts", Path: "../client.proto", ProtoRoot: "../proto", BufGenConfig: "/gen.yaml", + }}, + } + manifest.Languages.Go.Generators.GRPC = &project.GRPCGenerator{BufGenConfig: "../grpc.gen.yaml"} + }, + expected: []project.Issue{ + {Code: project.IssuePathInvalid, Path: projectManifestPath, Field: "components.grpc.server.proto_root"}, + {Code: project.IssuePathInvalid, Path: projectManifestPath, Field: "components.grpc.server.buf_config"}, + {Code: project.IssuePathInvalid, Path: projectManifestPath, Field: "languages.go.generators.grpc.buf_gen_config"}, + {Code: project.IssuePathInvalid, Path: projectManifestPath, Field: "components.grpc.clients.client.path"}, + {Code: project.IssuePathInvalid, Path: projectManifestPath, Field: "components.grpc.clients.client.proto_root"}, + {Code: project.IssuePathInvalid, Path: projectManifestPath, Field: "components.grpc.clients.client.buf_gen_config"}, + }, + }, + { + name: "kafka", + mutate: func(manifest *project.Manifest) { + manifest.Sources = map[string]project.Source{ + "contracts": {Type: project.SourceLocal, Path: "contracts"}, + } + manifest.Components.Kafka = &project.Kafka{ + Consumers: []project.KafkaConsumer{{Name: "raw", Contract: project.KafkaContract{Source: "contracts"}}}, + Producers: []project.KafkaProducer{{Name: "proto", Contract: project.KafkaContract{ + Source: "missing", Path: "event.proto", Format: "proto", + }}}, + } + }, + expected: []project.Issue{ + {Code: project.IssueKafkaContractInvalid, Path: projectManifestPath, Field: "components.kafka.consumers.raw.contract"}, + {Code: project.IssueSourceNotFound, Path: projectManifestPath, Field: "components.kafka.producers.proto.contract.source"}, + }, + }, + { + name: "database redis and s3", + mutate: func(manifest *project.Manifest) { + manifest.Components.DB = &project.DB{Connections: []project.DBConnection{{ + Name: "Bad", Variants: []project.DBVariant{{Name: "bad", Kind: "oracle"}}, + }}} + manifest.Components.S3 = &project.S3{Buckets: []project.S3Bucket{{Name: "media", Connection: "missing"}}} + manifest.Components.Redis = &project.Redis{Connections: []project.RedisConnection{{ + Name: "cache", AddrEnv: "bad-env", AddrDefault: "localhost", + }}} + }, + expected: []project.Issue{ + {Code: project.IssueDBConnectionInvalid, Path: projectManifestPath, Field: "components.db.connections.Bad"}, + {Code: project.IssueDBVariantInvalid, Path: projectManifestPath, Field: "components.db.connections.Bad"}, + {Code: project.IssueS3ConnectionNotFound, Path: projectManifestPath, Field: "components.s3.buckets.media.connection"}, + {Code: project.IssueRedisConnectionInvalid, Path: projectManifestPath, Field: "components.redis.connections.cache"}, + {Code: project.IssueRedisAddressInvalid, Path: projectManifestPath, Field: "components.redis.connections.cache.addr_default"}, + }, + }, + { + name: "runtime config conflict", + mutate: func(manifest *project.Manifest) { + manifest.Env.Custom = []project.EnvGroup{ + {Group: "service", Vars: []project.EnvVar{{Key: "MODE", Type: "string"}}}, + {Group: "worker", Vars: []project.EnvVar{{Key: "MODE", Type: "bool"}}}, + } + }, + expected: []project.Issue{{Code: project.IssueRuntimeConfigConflict, Path: projectManifestPath, Field: "env"}}, + }, + { + name: "managed output overlap", + mutate: func(manifest *project.Manifest) { + manifest.Paths.ExternalContracts = "gen" + manifest.Languages.Go.Generators.Config = &project.ConfigGenerator{Out: "gen/config"} + }, + expected: []project.Issue{{Code: project.IssuePathOverlap, Path: projectManifestPath, Field: "paths.external_contracts"}}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + manifest := validSemanticManifest() + test.mutate(&manifest) + + issues := project.Validate(project.Project{ManifestPath: projectManifestPath, Manifest: manifest}) + + require.Equal(t, test.expected, issues) + }) + } +} + +const projectManifestPath = "/project/devctl.yaml" + +func validSemanticManifest() project.Manifest { + return project.Manifest{ + Version: 1, + Project: project.Identity{Name: "example", Language: "go"}, + Languages: project.Languages{Go: project.GoLanguage{Module: "example.test/example"}}, + } +} diff --git a/internal/domain/project/validation_test.go b/internal/domain/project/validation_test.go new file mode 100644 index 0000000..bcad505 --- /dev/null +++ b/internal/domain/project/validation_test.go @@ -0,0 +1,36 @@ +package project_test + +import ( + "testing" + + "github.com/devctllabs/devctl/internal/domain/project" + "github.com/stretchr/testify/require" +) + +func TestValidateReportsIdentityAndPathIssuesInStableOrder(t *testing.T) { + t.Parallel() + + selected := project.Project{ + ManifestPath: "/project/devctl.yaml", + Manifest: project.Manifest{ + Version: 2, + Project: project.Identity{Name: "Invalid Name", Language: "python"}, + Paths: project.ManifestPaths{ExternalContracts: "../external"}, + Languages: project.Languages{Go: project.GoLanguage{Generators: project.GoGenerators{ + Config: &project.ConfigGenerator{Out: "/generated/config"}, + }}}, + }, + } + + issues := project.Validate(selected) + + require.Equal(t, []project.Issue{ + {Code: project.IssueVersionUnsupported, Path: selected.ManifestPath, Field: "version"}, + {Code: project.IssueNameInvalid, Path: selected.ManifestPath, Field: "project.name"}, + {Code: project.IssueLanguageUnsupported, Path: selected.ManifestPath, Field: "project.language"}, + {Code: project.IssueGoModuleRequired, Path: selected.ManifestPath, Field: "languages.go.module"}, + {Code: project.IssuePathInvalid, Path: selected.ManifestPath, Field: "paths.external_contracts"}, + {Code: project.IssuePathInvalid, Path: selected.ManifestPath, Field: "paths.external_contracts"}, + {Code: project.IssuePathInvalid, Path: selected.ManifestPath, Field: "languages.go.generators.config.out"}, + }, issues) +} diff --git a/internal/domain/scaffold/command.go b/internal/domain/scaffold/command.go new file mode 100644 index 0000000..abc254a --- /dev/null +++ b/internal/domain/scaffold/command.go @@ -0,0 +1,27 @@ +package scaffold + +// Command selects a project scaffold refresh. +type Command struct { + ManifestPath string +} + +// FileAction classifies the persisted effect on one scaffold file. +type FileAction string + +const ( + FileCreated FileAction = "created" + FileUpdated FileAction = "updated" + FileUnchanged FileAction = "unchanged" +) + +// FileChange records one scaffold file processed before success or failure. +type FileChange struct { + // Path is relative to the project root. + Path string + Action FileAction +} + +// Result contains files completed before success or the first publication error. +type Result struct { + Files []FileChange +} diff --git a/internal/domain/scaffold/errors.go b/internal/domain/scaffold/errors.go new file mode 100644 index 0000000..6914700 --- /dev/null +++ b/internal/domain/scaffold/errors.go @@ -0,0 +1,52 @@ +package scaffold + +import "github.com/devctllabs/devctl/internal/domain/failure" + +// Operation identifies the scaffold stage that failed. +type Operation string + +const ( + OperationPlan Operation = "plan" + OperationPreflight Operation = "preflight" + OperationPublish Operation = "publish" +) + +// FailureKind maps scaffold facts to a transport-neutral failure category. +type FailureKind uint8 + +const ( + FailureConflict FailureKind = iota + 1 + FailureUnavailable + FailureInternal +) + +// OperationError retains scaffold path facts and the underlying execution cause. +type OperationError struct { + Operation Operation + Path string + Kind FailureKind + Cause error +} + +func (e *OperationError) Error() string { + message := string(e.Operation) + " failed" + if e.Cause != nil { + return message + ": " + e.Cause.Error() + } + return message +} + +func (e *OperationError) Unwrap() error { return e.Cause } + +func (e *OperationError) Category() failure.Category { + switch e.Kind { + case FailureConflict: + return failure.Conflict + case FailureUnavailable: + return failure.Unavailable + case FailureInternal: + return failure.Internal + default: + return failure.Internal + } +} diff --git a/internal/domain/scaffold/errors_test.go b/internal/domain/scaffold/errors_test.go new file mode 100644 index 0000000..99fe2a0 --- /dev/null +++ b/internal/domain/scaffold/errors_test.go @@ -0,0 +1,20 @@ +package scaffold_test + +import ( + "errors" + "testing" + + "github.com/devctllabs/devctl/internal/domain/failure" + "github.com/devctllabs/devctl/internal/domain/scaffold" + "github.com/stretchr/testify/require" +) + +func TestOperationErrorPreservesCategoryAndCause(t *testing.T) { + t.Parallel() + + cause := errors.New("conflict") + err := &scaffold.OperationError{Operation: scaffold.OperationPreflight, Kind: scaffold.FailureConflict, Cause: cause} + + require.Equal(t, failure.Conflict, failure.CategoryOf(err)) + require.ErrorIs(t, err, cause) +} diff --git a/internal/domain/sync/command.go b/internal/domain/sync/command.go new file mode 100644 index 0000000..fe87968 --- /dev/null +++ b/internal/domain/sync/command.go @@ -0,0 +1,36 @@ +package sync + +// Command selects synchronization targets and whether execution is a side-effect-free preview. +type Command struct { + ManifestPath string + Family string + Target string + DryRun bool +} + +// ChangeAction classifies an observed or planned managed-contract change. +type ChangeAction string + +const ( + ChangeCreated ChangeAction = "created" + ChangeUpdated ChangeAction = "updated" + ChangeUnchanged ChangeAction = "unchanged" + ChangeRemoved ChangeAction = "removed" + ChangePlannedPublish ChangeAction = "planned_publish" + ChangePlannedRemove ChangeAction = "planned_remove" +) + +// Change records one managed-contract decision. +type Change struct { + Target string + // Path is relative to the project root. + Path string + Action ChangeAction +} + +// Result contains targets completed before success or the first execution error. +type Result struct { + Targets []string + Changes []Change + DryRun bool +} diff --git a/internal/domain/sync/errors.go b/internal/domain/sync/errors.go new file mode 100644 index 0000000..a8ed468 --- /dev/null +++ b/internal/domain/sync/errors.go @@ -0,0 +1,54 @@ +package sync + +import "github.com/devctllabs/devctl/internal/domain/failure" + +// Operation identifies the synchronization stage that failed. +type Operation string + +const ( + OperationSelectTarget Operation = "select_target" + OperationMaterialize Operation = "materialize" + OperationPublish Operation = "publish" + OperationPrune Operation = "prune" +) + +// FailureKind maps synchronization facts to a transport-neutral failure category. +type FailureKind uint8 + +const ( + FailureNotFound FailureKind = iota + 1 + FailureUnavailable +) + +// OperationError retains target facts and the underlying execution cause. +type OperationError struct { + Operation Operation + Target string + Source string + Path string + Kind FailureKind + Cause error +} + +func (e *OperationError) Error() string { + message := string(e.Operation) + " failed" + if e.Cause != nil { + return message + ": " + e.Cause.Error() + } + return message +} + +func (e *OperationError) Unwrap() error { return e.Cause } + +func (e *OperationError) Category() failure.Category { + if e.Operation == OperationMaterialize && e.Cause != nil { + category := failure.CategoryOf(e.Cause) + if category != failure.Internal { + return category + } + } + if e.Kind == FailureNotFound { + return failure.NotFound + } + return failure.Unavailable +} diff --git a/internal/domain/sync/errors_test.go b/internal/domain/sync/errors_test.go new file mode 100644 index 0000000..17388f8 --- /dev/null +++ b/internal/domain/sync/errors_test.go @@ -0,0 +1,20 @@ +package sync_test + +import ( + "errors" + "testing" + + "github.com/devctllabs/devctl/internal/domain/failure" + syncdomain "github.com/devctllabs/devctl/internal/domain/sync" + "github.com/stretchr/testify/require" +) + +func TestOperationErrorPreservesCategoryAndCause(t *testing.T) { + t.Parallel() + + cause := errors.New("target missing") + err := &syncdomain.OperationError{Operation: syncdomain.OperationSelectTarget, Kind: syncdomain.FailureNotFound, Cause: cause} + + require.Equal(t, failure.NotFound, failure.CategoryOf(err)) + require.ErrorIs(t, err, cause) +} diff --git a/internal/platform/jsonschema/jsonschema.go b/internal/platform/jsonschema/jsonschema.go new file mode 100644 index 0000000..c58f65f --- /dev/null +++ b/internal/platform/jsonschema/jsonschema.go @@ -0,0 +1,22 @@ +package jsonschema + +import ( + "encoding/json" + "fmt" + "strings" +) + +// RootTitle returns the non-empty root title that owns generated top-level type naming. +func RootTitle(data []byte) (string, error) { + var document struct { + Title string `json:"title"` + } + if err := json.Unmarshal(data, &document); err != nil { + return "", fmt.Errorf("json.Unmarshal: %w", err) + } + title := strings.TrimSpace(document.Title) + if title == "" { + return "", fmt.Errorf("root title is required") + } + return title, nil +} diff --git a/internal/platform/jsonschema/jsonschema_test.go b/internal/platform/jsonschema/jsonschema_test.go new file mode 100644 index 0000000..66a3ed0 --- /dev/null +++ b/internal/platform/jsonschema/jsonschema_test.go @@ -0,0 +1,27 @@ +package jsonschema_test + +import ( + "testing" + + platformjsonschema "github.com/devctllabs/devctl/internal/platform/jsonschema" + "github.com/stretchr/testify/require" +) + +func TestRootTitle(t *testing.T) { + t.Parallel() + + title, err := platformjsonschema.RootTitle([]byte(`{"title":" AuditEvent ","type":"object"}`)) + + require.NoError(t, err) + require.Equal(t, "AuditEvent", title) +} + +func TestRootTitleRejectsMissingOrInvalidTitle(t *testing.T) { + t.Parallel() + + for _, schema := range []string{`{"type":"object"}`, `{"title":" "}`, `{`} { + _, err := platformjsonschema.RootTitle([]byte(schema)) + + require.Error(t, err) + } +} diff --git a/internal/platform/openapi/openapi.go b/internal/platform/openapi/openapi.go new file mode 100644 index 0000000..50f9c49 --- /dev/null +++ b/internal/platform/openapi/openapi.go @@ -0,0 +1,152 @@ +package openapi + +import ( + "sort" + "strings" + + "github.com/pb33f/libopenapi" + validator "github.com/pb33f/libopenapi-validator" + "gopkg.in/yaml.v3" +) + +// FindingKind classifies protocol-level parse, reference, and standard-validation facts. +type FindingKind string + +const ( + DocumentInvalid FindingKind = "document_invalid" + ReferenceInvalid FindingKind = "reference_invalid" + StandardInvalid FindingKind = "openapi_standard" +) + +// Finding is one presentation-neutral OpenAPI analysis fact. +type Finding struct { + Kind FindingKind + Type string + Subtype string + SpecPath string + Field string + // Line and Column are one-based; zero means the location is unavailable. + Line int + Column int +} + +// Operation contains the fields needed by devctl rules for one OpenAPI operation. +type Operation struct { + Method string + Path string + OperationID string + Responses []string + // Line and Column are one-based source coordinates. + Line int + Column int +} + +// Report contains deterministic facts extracted without performing I/O. +type Report struct { + Version string + Operations []Operation + Findings []Finding +} + +// Analyze parses and validates one OpenAPI document without applying devctl-specific lint policy. +func Analyze(data []byte) Report { + var document yaml.Node + if err := yaml.Unmarshal(data, &document); err != nil { + return Report{Operations: []Operation{}, Findings: []Finding{{Kind: DocumentInvalid}}} + } + root := documentRoot(&document) + report := Report{ + Version: mappingScalar(root, "openapi"), Operations: operations(root), Findings: []Finding{}, + } + openAPIDocument, err := libopenapi.NewDocument(data) + if err != nil { + report.Findings = append(report.Findings, Finding{Kind: DocumentInvalid}) + return report + } + defer openAPIDocument.Release() + if _, err := openAPIDocument.BuildV3Model(); err != nil { + report.Findings = append(report.Findings, Finding{Kind: ReferenceInvalid}) + return report + } + openAPIValidator, setupErrors := validator.NewValidator(openAPIDocument) + if len(setupErrors) > 0 { + report.Findings = append(report.Findings, Finding{Kind: StandardInvalid}) + return report + } + defer openAPIValidator.Release() + valid, validationErrors := openAPIValidator.ValidateDocument() + if !valid { + for range validationErrors { + report.Findings = append(report.Findings, Finding{Kind: StandardInvalid}) + } + } + return report +} + +func operations(root *yaml.Node) []Operation { + paths := mappingValue(root, "paths") + if paths == nil || paths.Kind != yaml.MappingNode { + return []Operation{} + } + methods := map[string]bool{"get": true, "put": true, "post": true, "delete": true, "options": true, "head": true, "patch": true, "trace": true} + result := []Operation{} + for index := 0; index+1 < len(paths.Content); index += 2 { + pathName, item := paths.Content[index], paths.Content[index+1] + if item.Kind != yaml.MappingNode { + continue + } + for operationIndex := 0; operationIndex+1 < len(item.Content); operationIndex += 2 { + methodNode, operationNode := item.Content[operationIndex], item.Content[operationIndex+1] + method := strings.ToLower(methodNode.Value) + if !methods[method] || operationNode.Kind != yaml.MappingNode { + continue + } + responses := mappingKeys(mappingValue(operationNode, "responses")) + result = append(result, Operation{ + Method: strings.ToUpper(method), Path: pathName.Value, + OperationID: mappingScalar(operationNode, "operationId"), Responses: responses, + Line: methodNode.Line, Column: methodNode.Column, + }) + } + } + return result +} + +func mappingKeys(node *yaml.Node) []string { + if node == nil || node.Kind != yaml.MappingNode { + return []string{} + } + keys := make([]string, 0, len(node.Content)/2) + for index := 0; index+1 < len(node.Content); index += 2 { + keys = append(keys, node.Content[index].Value) + } + sort.Strings(keys) + return keys +} + +func documentRoot(node *yaml.Node) *yaml.Node { + if node != nil && node.Kind == yaml.DocumentNode && len(node.Content) > 0 { + return node.Content[0] + } + return node +} + +func mappingValue(node *yaml.Node, key string) *yaml.Node { + if node == nil || node.Kind != yaml.MappingNode { + return nil + } + for index := 0; index+1 < len(node.Content); index += 2 { + if node.Content[index].Value == key { + return node.Content[index+1] + } + } + return nil +} + +func mappingScalar(node *yaml.Node, key string) string { + value := mappingValue(node, key) + if value == nil { + return "" + } + return value.Value +} diff --git a/internal/platform/openapi/openapi_test.go b/internal/platform/openapi/openapi_test.go new file mode 100644 index 0000000..0a46544 --- /dev/null +++ b/internal/platform/openapi/openapi_test.go @@ -0,0 +1,35 @@ +package openapi_test + +import ( + "testing" + + "github.com/devctllabs/devctl/internal/platform/openapi" + "github.com/stretchr/testify/require" +) + +func TestAnalyzeReturnsMalformedDocumentFact(t *testing.T) { + t.Parallel() + + report := openapi.Analyze([]byte("openapi: [\n")) + + require.Equal(t, []openapi.Finding{{Kind: openapi.DocumentInvalid}}, report.Findings) +} + +func TestAnalyzeExtractsOperationFacts(t *testing.T) { + t.Parallel() + + report := openapi.Analyze([]byte(`openapi: 3.1.0 +info: {title: Fixture, version: 1.0.0} +paths: + /widgets: + get: + operationId: listWidgets + responses: + 2XX: {description: success} +`)) + + require.Equal(t, "3.1.0", report.Version) + require.Equal(t, []openapi.Operation{{ + Method: "GET", Path: "/widgets", OperationID: "listWidgets", Responses: []string{"2XX"}, Line: 5, Column: 5, + }}, report.Operations) +} diff --git a/internal/repository/manifest/decode.go b/internal/repository/manifest/decode.go new file mode 100644 index 0000000..738710f --- /dev/null +++ b/internal/repository/manifest/decode.go @@ -0,0 +1,162 @@ +package manifest + +import ( + "errors" + "fmt" + "reflect" + "strings" + + projectdomain "github.com/devctllabs/devctl/internal/domain/project" + "gopkg.in/yaml.v3" +) + +func parse(data []byte) (document, []projectdomain.DecodeIssue, error) { + var node yaml.Node + if err := yaml.Unmarshal(data, &node); err != nil { + return document{}, []projectdomain.DecodeIssue{{Kind: projectdomain.DecodeYAMLInvalid}}, fmt.Errorf("yaml.Unmarshal: %w", err) + } + if len(node.Content) != 1 || node.Content[0].Kind != yaml.MappingNode { + err := errors.New("manifest root must be a mapping") + return document{}, []projectdomain.DecodeIssue{{Kind: projectdomain.DecodeSchemaInvalid}}, err + } + issues := validateKnownFields(node.Content[0], "") + if len(issues) > 0 { + return document{}, issues, errors.New("manifest schema validation failed") + } + var manifest document + if err := node.Content[0].Decode(&manifest); err != nil { + return document{}, []projectdomain.DecodeIssue{{Kind: projectdomain.DecodeSchemaInvalid}}, fmt.Errorf("node.Decode: %w", err) + } + return manifest, issues, nil +} + +func validateKnownFields(root *yaml.Node, path string) []projectdomain.DecodeIssue { + issues := make([]projectdomain.DecodeIssue, 0) + validateNode(root, reflect.TypeFor[document](), path, &issues) + return issues +} + +func validateNode(node *yaml.Node, target reflect.Type, path string, issues *[]projectdomain.DecodeIssue) { + if node.Tag == "!!null" { + return + } + for target.Kind() == reflect.Pointer { + target = target.Elem() + } + kind := target.Kind() + if kind == reflect.Struct { + validateStructNode(node, target, path, issues) + return + } + if kind == reflect.Map { + validateMapNode(node, target.Elem(), path, issues) + return + } + if kind == reflect.Slice { + validateSliceNode(node, target.Elem(), path, issues) + return + } + if kind == reflect.Interface { + return + } + validateScalarNode(node, target, path, issues) +} + +func validateStructNode(node *yaml.Node, target reflect.Type, path string, issues *[]projectdomain.DecodeIssue) { + if node.Kind != yaml.MappingNode { + appendSchemaIssue(issues, path, node) + return + } + fields := yamlFields(target) + visitMapping(node, path, issues, func(value *yaml.Node, fieldPath, name string) { + fieldType, exists := fields[name] + if !exists { + key := mappingKey(node, name) + *issues = append(*issues, projectdomain.DecodeIssue{Kind: projectdomain.DecodeUnknownField, Field: fieldPath, Line: key.Line, Column: key.Column}) + return + } + validateNode(value, fieldType, fieldPath, issues) + }) +} + +func validateMapNode(node *yaml.Node, element reflect.Type, path string, issues *[]projectdomain.DecodeIssue) { + if node.Kind != yaml.MappingNode { + appendSchemaIssue(issues, path, node) + return + } + visitMapping(node, path, issues, func(value *yaml.Node, fieldPath, _ string) { + validateNode(value, element, fieldPath, issues) + }) +} + +func validateSliceNode(node *yaml.Node, element reflect.Type, path string, issues *[]projectdomain.DecodeIssue) { + if node.Kind != yaml.SequenceNode { + appendSchemaIssue(issues, path, node) + return + } + for index, item := range node.Content { + validateNode(item, element, fmt.Sprintf("%s[%d]", path, index), issues) + } +} + +func validateScalarNode(node *yaml.Node, target reflect.Type, path string, issues *[]projectdomain.DecodeIssue) { + if node.Kind != yaml.ScalarNode { + appendSchemaIssue(issues, path, node) + return + } + value := reflect.New(target).Interface() + if err := node.Decode(value); err != nil { + appendSchemaIssue(issues, path, node) + } +} + +func visitMapping( + node *yaml.Node, + path string, + issues *[]projectdomain.DecodeIssue, + visit func(value *yaml.Node, fieldPath, name string), +) { + seen := make(map[string]struct{}, len(node.Content)/2) + for index := 0; index+1 < len(node.Content); index += 2 { + key, value := node.Content[index], node.Content[index+1] + fieldPath := joinFieldPath(path, key.Value) + if _, exists := seen[key.Value]; exists { + *issues = append(*issues, projectdomain.DecodeIssue{Kind: projectdomain.DecodeDuplicateKey, Field: fieldPath, Line: key.Line, Column: key.Column}) + continue + } + seen[key.Value] = struct{}{} + visit(value, fieldPath, key.Value) + } +} + +func yamlFields(target reflect.Type) map[string]reflect.Type { + fields := make(map[string]reflect.Type, target.NumField()) + for index := 0; index < target.NumField(); index++ { + field := target.Field(index) + name := strings.SplitN(field.Tag.Get("yaml"), ",", 2)[0] + if name != "" && name != "-" { + fields[name] = field.Type + } + } + return fields +} + +func mappingKey(node *yaml.Node, name string) *yaml.Node { + for index := 0; index+1 < len(node.Content); index += 2 { + if node.Content[index].Value == name { + return node.Content[index] + } + } + return node +} + +func appendSchemaIssue(issues *[]projectdomain.DecodeIssue, path string, node *yaml.Node) { + *issues = append(*issues, projectdomain.DecodeIssue{Kind: projectdomain.DecodeSchemaInvalid, Field: path, Line: node.Line, Column: node.Column}) +} + +func joinFieldPath(parent, field string) string { + if parent == "" { + return field + } + return parent + "." + field +} diff --git a/internal/repository/manifest/document.go b/internal/repository/manifest/document.go new file mode 100644 index 0000000..38299dd --- /dev/null +++ b/internal/repository/manifest/document.go @@ -0,0 +1,280 @@ +package manifest + +type document struct { + Version int `yaml:"version"` + Project projectDocument `yaml:"project"` + Env envDocument `yaml:"env"` + Paths pathsDocument `yaml:"paths"` + Sources map[string]sourceDocument `yaml:"sources"` + Exports map[string]exportDocument `yaml:"exports"` + Components componentsDocument `yaml:"components"` + Languages languagesDocument `yaml:"languages"` +} + +type projectDocument struct { + Name string `yaml:"name"` + Language string `yaml:"language"` +} + +type envDocument struct { + Prefix string `yaml:"prefix,omitempty"` + Custom []envGroupDocument `yaml:"custom,omitempty"` +} + +type envGroupDocument struct { + Group string `yaml:"group"` + Vars []envVarDocument `yaml:"vars"` +} + +type envVarDocument struct { + Key string `yaml:"key"` + Type string `yaml:"type,omitempty"` + Default any `yaml:"default,omitempty"` + Secret bool `yaml:"secret,omitempty"` +} + +type pathsDocument struct { + ExternalContracts string `yaml:"external_contracts,omitempty"` +} + +type sourceDocument struct { + Type string `yaml:"type"` + Path string `yaml:"path,omitempty"` + URL string `yaml:"url,omitempty"` + Filename string `yaml:"filename,omitempty"` + AllowInsecureHTTP bool `yaml:"allow_insecure_http,omitempty"` + Repo string `yaml:"repo,omitempty"` + Ref string `yaml:"ref,omitempty"` + Proto sourceProtoDocument `yaml:"proto,omitempty"` +} + +type sourceProtoDocument struct { + BufConfig string `yaml:"buf_config,omitempty"` +} + +type exportDocument struct { + Kind string `yaml:"kind"` + Path string `yaml:"path,omitempty"` + Producer string `yaml:"producer,omitempty"` +} + +type componentsDocument struct { + HTTP *httpDocument `yaml:"http,omitempty"` + GRPC *grpcDocument `yaml:"grpc,omitempty"` + Kafka *kafkaDocument `yaml:"kafka,omitempty"` + Logging *loggingDocument `yaml:"logging,omitempty"` + Health *healthDocument `yaml:"health,omitempty"` + Telemetry *telemetryDocument `yaml:"telemetry,omitempty"` + DB *dbDocument `yaml:"db,omitempty"` + Redis *redisDocument `yaml:"redis,omitempty"` + S3 *s3Document `yaml:"s3,omitempty"` +} + +type redisDocument struct { + Connections []redisConnectionDocument `yaml:"connections,omitempty"` + Env componentEnvDocument `yaml:"env,omitempty"` +} + +type redisConnectionDocument struct { + Name string `yaml:"name"` + AddrEnv string `yaml:"addr_env,omitempty"` + AddrDefault string `yaml:"addr_default,omitempty"` +} + +type s3Document struct { + Connections []s3ConnectionDocument `yaml:"connections,omitempty"` + Buckets []s3BucketDocument `yaml:"buckets,omitempty"` + Env componentEnvDocument `yaml:"env,omitempty"` +} + +type s3ConnectionDocument struct { + Name string `yaml:"name"` + Credentials string `yaml:"credentials,omitempty"` + Endpoint string `yaml:"endpoint,omitempty"` + Region string `yaml:"region,omitempty"` + PathStyle bool `yaml:"path_style,omitempty"` + AccessKeyEnv string `yaml:"access_key_env,omitempty"` + SecretKeyEnv string `yaml:"secret_key_env,omitempty"` +} + +type s3BucketDocument struct { + Name string `yaml:"name"` + Connection string `yaml:"connection"` + Bucket string `yaml:"bucket,omitempty"` +} + +type kafkaDocument struct { + Consumers []kafkaConsumerDocument `yaml:"consumers,omitempty"` + Producers []kafkaProducerDocument `yaml:"producers,omitempty"` + Env componentEnvDocument `yaml:"env,omitempty"` +} + +type kafkaContractDocument struct { + Source string `yaml:"source,omitempty"` + Export string `yaml:"export,omitempty"` + Path string `yaml:"path,omitempty"` + Format string `yaml:"format,omitempty"` + ProtoRoot string `yaml:"proto_root,omitempty"` + Message string `yaml:"message,omitempty"` + Encoding string `yaml:"encoding,omitempty"` +} + +type kafkaConsumerDocument struct { + Name string `yaml:"name"` + Topic string `yaml:"topic"` + GroupEnv string `yaml:"group_env,omitempty"` + Start *startDocument `yaml:"start,omitempty"` + Contract kafkaContractDocument `yaml:"contract,omitempty"` +} +type kafkaProducerDocument struct { + Name string `yaml:"name"` + Topic string `yaml:"topic"` + TopicEnv string `yaml:"topic_env,omitempty"` + Contract kafkaContractDocument `yaml:"contract,omitempty"` +} + +type grpcDocument struct { + Server *grpcServerDocument `yaml:"server,omitempty"` + Clients []grpcClientDocument `yaml:"clients,omitempty"` + Env componentEnvDocument `yaml:"env,omitempty"` +} + +type grpcClientDocument struct { + Name string `yaml:"name"` + Source string `yaml:"source"` + Export string `yaml:"export,omitempty"` + Path string `yaml:"path,omitempty"` + ProtoRoot string `yaml:"proto_root,omitempty"` + BufGenConfig string `yaml:"buf_gen_config,omitempty"` + AddrEnv string `yaml:"addr_env,omitempty"` +} + +type grpcServerDocument struct { + ProtoRoot string `yaml:"proto_root,omitempty"` + BufConfig string `yaml:"buf_config,omitempty"` + Start *startDocument `yaml:"start,omitempty"` +} + +type componentEnvDocument struct { + System []envVarDocument `yaml:"system,omitempty"` + Custom []envVarDocument `yaml:"custom,omitempty"` +} + +type startDocument struct { + Env string `yaml:"env"` + Default *bool `yaml:"default,omitempty"` +} + +type httpDocument struct { + Server *httpServerDocument `yaml:"server,omitempty"` + Clients []httpClientDocument `yaml:"clients,omitempty"` + Env componentEnvDocument `yaml:"env,omitempty"` +} + +type httpServerDocument struct { + OpenAPI string `yaml:"openapi,omitempty"` + Start *startDocument `yaml:"start,omitempty"` +} + +type httpClientDocument struct { + Name string `yaml:"name"` + Source string `yaml:"source"` + Export string `yaml:"export,omitempty"` + Path string `yaml:"path,omitempty"` + BaseURLEnv string `yaml:"base_url_env,omitempty"` + OAPIConfig string `yaml:"oapi_config,omitempty"` +} + +type loggingDocument struct { + Env componentEnvDocument `yaml:"env,omitempty"` +} + +type healthDocument struct { + Server *healthServerDocument `yaml:"server,omitempty"` + Env componentEnvDocument `yaml:"env,omitempty"` +} + +type healthServerDocument struct { + Start *startDocument `yaml:"start,omitempty"` +} + +type telemetryDocument struct { + Start *startDocument `yaml:"start,omitempty"` + Env componentEnvDocument `yaml:"env,omitempty"` +} + +type dbDocument struct { + Connections []dbConnectionDocument `yaml:"connections"` + Env componentEnvDocument `yaml:"env,omitempty"` +} + +type dbConnectionDocument struct { + Name string `yaml:"name"` + Default string `yaml:"default,omitempty"` + KindEnv string `yaml:"kind_env,omitempty"` + Variants []dbVariantDocument `yaml:"variants"` +} + +type dbVariantDocument struct { + Name string `yaml:"name"` + Kind string `yaml:"kind"` + DSNEnv string `yaml:"dsn_env,omitempty"` + DSNDefault string `yaml:"dsn_default,omitempty"` + Secret bool `yaml:"secret,omitempty"` + Migrations *dbMigrationsDocument `yaml:"migrations,omitempty"` +} + +type dbMigrationsDocument struct { + Path string `yaml:"path"` + DatabaseEnv string `yaml:"database_env"` + DatabaseDefault string `yaml:"database_default,omitempty"` +} + +type languagesDocument struct { + Go goLanguageDocument `yaml:"go"` +} + +type goLanguageDocument struct { + Module string `yaml:"module"` + Generators goGeneratorsDocument `yaml:"generators,omitempty"` + Components goComponentsDocument `yaml:"components,omitempty"` +} + +type goGeneratorsDocument struct { + Config *configGeneratorDocument `yaml:"config,omitempty"` + HTTP *httpGeneratorDocument `yaml:"http,omitempty"` + GRPC *grpcGeneratorDocument `yaml:"grpc,omitempty"` + Kafka *kafkaGeneratorDocument `yaml:"kafka,omitempty"` +} + +type grpcGeneratorDocument struct { + Out string `yaml:"out,omitempty"` + BufGenConfig string `yaml:"buf_gen_config,omitempty"` +} +type kafkaGeneratorDocument struct { + Out string `yaml:"out,omitempty"` + BufGenConfig string `yaml:"buf_gen_config,omitempty"` +} + +type configGeneratorDocument struct { + Out string `yaml:"out,omitempty"` +} + +type httpGeneratorDocument struct { + OAPIConfig string `yaml:"oapi_config,omitempty"` + ServerOut string `yaml:"server_out,omitempty"` + ClientOut string `yaml:"client_out,omitempty"` +} + +type goComponentsDocument struct { + Pprof *pprofDocument `yaml:"pprof,omitempty"` +} + +type pprofDocument struct { + Server *pprofServerDocument `yaml:"server,omitempty"` + Env componentEnvDocument `yaml:"env,omitempty"` +} + +type pprofServerDocument struct { + Start *startDocument `yaml:"start,omitempty"` +} diff --git a/internal/repository/manifest/mapper.go b/internal/repository/manifest/mapper.go new file mode 100644 index 0000000..a74f074 --- /dev/null +++ b/internal/repository/manifest/mapper.go @@ -0,0 +1,407 @@ +package manifest + +import projectdomain "github.com/devctllabs/devctl/internal/domain/project" + +func toProjectSpec(document document) projectdomain.Manifest { + sources := make(map[string]projectdomain.Source, len(document.Sources)) + for name, source := range document.Sources { + sources[name] = projectdomain.Source{Type: projectdomain.SourceType(source.Type), Path: source.Path, URL: source.URL, Filename: source.Filename, AllowInsecureHTTP: source.AllowInsecureHTTP, Repo: source.Repo, Ref: source.Ref, Proto: projectdomain.SourceProto{BufConfig: source.Proto.BufConfig}} + } + exports := make(map[string]projectdomain.Export, len(document.Exports)) + for name, export := range document.Exports { + exports[name] = projectdomain.Export(export) + } + + return projectdomain.Manifest{ + Version: document.Version, + Project: projectdomain.Identity(document.Project), + Env: mapEnv(document.Env), + Paths: projectdomain.ManifestPaths(document.Paths), + Sources: sources, + Exports: exports, + Components: mapComponents(document.Components), + Languages: mapLanguages(document.Languages), + } +} + +func mapEnv(value envDocument) projectdomain.Env { + groups := make([]projectdomain.EnvGroup, len(value.Custom)) + for index, group := range value.Custom { + groups[index] = projectdomain.EnvGroup{Group: group.Group, Vars: mapEnvVars(group.Vars)} + } + return projectdomain.Env{Prefix: value.Prefix, Custom: groups} +} + +func mapEnvVars(values []envVarDocument) []projectdomain.EnvVar { + result := make([]projectdomain.EnvVar, len(values)) + for index, value := range values { + result[index] = projectdomain.EnvVar(value) + } + return result +} + +func mapComponentEnv(value componentEnvDocument) projectdomain.ComponentEnv { + return projectdomain.ComponentEnv{System: mapEnvVars(value.System), Custom: mapEnvVars(value.Custom)} +} + +func mapStart(value *startDocument) *projectdomain.Start { + if value == nil { + return nil + } + return &projectdomain.Start{Env: value.Env, Default: value.Default} +} + +func mapComponents(value componentsDocument) projectdomain.Components { + return projectdomain.Components{ + HTTP: mapHTTP(value.HTTP), GRPC: mapGRPC(value.GRPC), Kafka: mapKafka(value.Kafka), + Logging: mapLogging(value.Logging), Health: mapHealth(value.Health), Telemetry: mapTelemetry(value.Telemetry), + DB: mapDB(value.DB), Redis: mapRedis(value.Redis), S3: mapS3(value.S3), + } +} + +func mapHTTP(value *httpDocument) *projectdomain.HTTP { + if value == nil { + return nil + } + clients := make([]projectdomain.HTTPClient, len(value.Clients)) + for index, client := range value.Clients { + clients[index] = projectdomain.HTTPClient(client) + } + var server *projectdomain.HTTPServer + if value.Server != nil { + server = &projectdomain.HTTPServer{OpenAPI: value.Server.OpenAPI, Start: mapStart(value.Server.Start)} + } + return &projectdomain.HTTP{Server: server, Clients: clients, Env: mapComponentEnv(value.Env)} +} + +func mapGRPC(value *grpcDocument) *projectdomain.GRPC { + if value == nil { + return nil + } + clients := make([]projectdomain.GRPCClient, len(value.Clients)) + for index, client := range value.Clients { + clients[index] = projectdomain.GRPCClient(client) + } + var server *projectdomain.GRPCServer + if value.Server != nil { + server = &projectdomain.GRPCServer{ProtoRoot: value.Server.ProtoRoot, BufConfig: value.Server.BufConfig, Start: mapStart(value.Server.Start)} + } + return &projectdomain.GRPC{Server: server, Clients: clients, Env: mapComponentEnv(value.Env)} +} + +func mapKafka(value *kafkaDocument) *projectdomain.Kafka { + if value == nil { + return nil + } + consumers := make([]projectdomain.KafkaConsumer, len(value.Consumers)) + for index, consumer := range value.Consumers { + consumers[index] = projectdomain.KafkaConsumer{Name: consumer.Name, Topic: consumer.Topic, GroupEnv: consumer.GroupEnv, Start: mapStart(consumer.Start), Contract: projectdomain.KafkaContract(consumer.Contract)} + } + producers := make([]projectdomain.KafkaProducer, len(value.Producers)) + for index, producer := range value.Producers { + producers[index] = projectdomain.KafkaProducer{Name: producer.Name, Topic: producer.Topic, TopicEnv: producer.TopicEnv, Contract: projectdomain.KafkaContract(producer.Contract)} + } + return &projectdomain.Kafka{Consumers: consumers, Producers: producers, Env: mapComponentEnv(value.Env)} +} + +func mapLogging(value *loggingDocument) *projectdomain.Logging { + if value == nil { + return nil + } + return &projectdomain.Logging{Env: mapComponentEnv(value.Env)} +} + +func mapHealth(value *healthDocument) *projectdomain.Health { + if value == nil { + return nil + } + var server *projectdomain.HealthServer + if value.Server != nil { + server = &projectdomain.HealthServer{Start: mapStart(value.Server.Start)} + } + return &projectdomain.Health{Server: server, Env: mapComponentEnv(value.Env)} +} + +func mapTelemetry(value *telemetryDocument) *projectdomain.Telemetry { + if value == nil { + return nil + } + return &projectdomain.Telemetry{Start: mapStart(value.Start), Env: mapComponentEnv(value.Env)} +} + +func mapDB(value *dbDocument) *projectdomain.DB { + if value == nil { + return nil + } + connections := make([]projectdomain.DBConnection, len(value.Connections)) + for index, connection := range value.Connections { + variants := make([]projectdomain.DBVariant, len(connection.Variants)) + for variantIndex, variant := range connection.Variants { + variants[variantIndex] = mapDBVariant(variant) + } + connections[index] = projectdomain.DBConnection{Name: connection.Name, Default: connection.Default, KindEnv: connection.KindEnv, Variants: variants} + } + return &projectdomain.DB{Connections: connections, Env: mapComponentEnv(value.Env)} +} + +func mapDBVariant(value dbVariantDocument) projectdomain.DBVariant { + variant := projectdomain.DBVariant{Name: value.Name, Kind: value.Kind, DSNEnv: value.DSNEnv, DSNDefault: value.DSNDefault, Secret: value.Secret} + if value.Migrations != nil { + variant.Migrations = &projectdomain.DBMigrations{Path: value.Migrations.Path, DatabaseEnv: value.Migrations.DatabaseEnv, DatabaseDefault: value.Migrations.DatabaseDefault} + } + return variant +} + +func mapRedis(value *redisDocument) *projectdomain.Redis { + if value == nil { + return nil + } + connections := make([]projectdomain.RedisConnection, len(value.Connections)) + for index, connection := range value.Connections { + connections[index] = projectdomain.RedisConnection(connection) + } + return &projectdomain.Redis{Connections: connections, Env: mapComponentEnv(value.Env)} +} + +func mapS3(value *s3Document) *projectdomain.S3 { + if value == nil { + return nil + } + connections := make([]projectdomain.S3Connection, len(value.Connections)) + for index, connection := range value.Connections { + connections[index] = projectdomain.S3Connection(connection) + } + buckets := make([]projectdomain.S3Bucket, len(value.Buckets)) + for index, bucket := range value.Buckets { + buckets[index] = projectdomain.S3Bucket(bucket) + } + return &projectdomain.S3{Connections: connections, Buckets: buckets, Env: mapComponentEnv(value.Env)} +} + +func mapLanguages(value languagesDocument) projectdomain.Languages { + generators := projectdomain.GoGenerators{} + if value.Go.Generators.Config != nil { + config := projectdomain.ConfigGenerator(*value.Go.Generators.Config) + generators.Config = &config + } + if value.Go.Generators.HTTP != nil { + http := projectdomain.HTTPGenerator(*value.Go.Generators.HTTP) + generators.HTTP = &http + } + if value.Go.Generators.GRPC != nil { + grpc := projectdomain.GRPCGenerator(*value.Go.Generators.GRPC) + generators.GRPC = &grpc + } + if value.Go.Generators.Kafka != nil { + kafka := projectdomain.KafkaGenerator(*value.Go.Generators.Kafka) + generators.Kafka = &kafka + } + components := projectdomain.GoComponents{} + if value.Go.Components.Pprof != nil { + var server *projectdomain.PprofServer + if value.Go.Components.Pprof.Server != nil { + server = &projectdomain.PprofServer{Start: mapStart(value.Go.Components.Pprof.Server.Start)} + } + components.Pprof = &projectdomain.Pprof{Server: server, Env: mapComponentEnv(value.Go.Components.Pprof.Env)} + } + return projectdomain.Languages{Go: projectdomain.GoLanguage{Module: value.Go.Module, Generators: generators, Components: components}} +} + +func fromProjectManifest(value projectdomain.Manifest) document { + sources := make(map[string]sourceDocument, len(value.Sources)) + for name, source := range value.Sources { + sources[name] = sourceDocument{Type: string(source.Type), Path: source.Path, URL: source.URL, Filename: source.Filename, AllowInsecureHTTP: source.AllowInsecureHTTP, Repo: source.Repo, Ref: source.Ref, Proto: sourceProtoDocument{BufConfig: source.Proto.BufConfig}} + } + exports := make(map[string]exportDocument, len(value.Exports)) + for name, exported := range value.Exports { + exports[name] = exportDocument(exported) + } + return document{ + Version: value.Version, Project: projectDocument(value.Project), Env: fromProjectEnv(value.Env), + Paths: pathsDocument(value.Paths), Sources: sources, Exports: exports, + Components: fromProjectComponents(value.Components), Languages: fromProjectLanguages(value.Languages), + } +} + +func fromProjectEnv(value projectdomain.Env) envDocument { + groups := make([]envGroupDocument, len(value.Custom)) + for index, group := range value.Custom { + groups[index] = envGroupDocument{Group: group.Group, Vars: fromProjectEnvVars(group.Vars)} + } + return envDocument{Prefix: value.Prefix, Custom: groups} +} + +func fromProjectEnvVars(values []projectdomain.EnvVar) []envVarDocument { + result := make([]envVarDocument, len(values)) + for index, value := range values { + result[index] = envVarDocument(value) + } + return result +} + +func fromProjectComponentEnv(value projectdomain.ComponentEnv) componentEnvDocument { + return componentEnvDocument{System: fromProjectEnvVars(value.System), Custom: fromProjectEnvVars(value.Custom)} +} + +func fromProjectStart(value *projectdomain.Start) *startDocument { + if value == nil { + return nil + } + return &startDocument{Env: value.Env, Default: value.Default} +} + +func fromProjectComponents(value projectdomain.Components) componentsDocument { + return componentsDocument{ + HTTP: fromProjectHTTP(value.HTTP), GRPC: fromProjectGRPC(value.GRPC), Kafka: fromProjectKafka(value.Kafka), + Logging: fromProjectLogging(value.Logging), Health: fromProjectHealth(value.Health), Telemetry: fromProjectTelemetry(value.Telemetry), + DB: fromProjectDB(value.DB), Redis: fromProjectRedis(value.Redis), S3: fromProjectS3(value.S3), + } +} + +func fromProjectHTTP(value *projectdomain.HTTP) *httpDocument { + if value == nil { + return nil + } + clients := make([]httpClientDocument, len(value.Clients)) + for index, client := range value.Clients { + clients[index] = httpClientDocument(client) + } + var server *httpServerDocument + if value.Server != nil { + server = &httpServerDocument{OpenAPI: value.Server.OpenAPI, Start: fromProjectStart(value.Server.Start)} + } + return &httpDocument{Server: server, Clients: clients, Env: fromProjectComponentEnv(value.Env)} +} + +func fromProjectGRPC(value *projectdomain.GRPC) *grpcDocument { + if value == nil { + return nil + } + clients := make([]grpcClientDocument, len(value.Clients)) + for index, client := range value.Clients { + clients[index] = grpcClientDocument(client) + } + var server *grpcServerDocument + if value.Server != nil { + server = &grpcServerDocument{ProtoRoot: value.Server.ProtoRoot, BufConfig: value.Server.BufConfig, Start: fromProjectStart(value.Server.Start)} + } + return &grpcDocument{Server: server, Clients: clients, Env: fromProjectComponentEnv(value.Env)} +} + +func fromProjectKafka(value *projectdomain.Kafka) *kafkaDocument { + if value == nil { + return nil + } + consumers := make([]kafkaConsumerDocument, len(value.Consumers)) + for index, consumer := range value.Consumers { + consumers[index] = kafkaConsumerDocument{Name: consumer.Name, Topic: consumer.Topic, GroupEnv: consumer.GroupEnv, Start: fromProjectStart(consumer.Start), Contract: kafkaContractDocument(consumer.Contract)} + } + producers := make([]kafkaProducerDocument, len(value.Producers)) + for index, producer := range value.Producers { + producers[index] = kafkaProducerDocument{Name: producer.Name, Topic: producer.Topic, TopicEnv: producer.TopicEnv, Contract: kafkaContractDocument(producer.Contract)} + } + return &kafkaDocument{Consumers: consumers, Producers: producers, Env: fromProjectComponentEnv(value.Env)} +} + +func fromProjectLogging(value *projectdomain.Logging) *loggingDocument { + if value == nil { + return nil + } + return &loggingDocument{Env: fromProjectComponentEnv(value.Env)} +} + +func fromProjectHealth(value *projectdomain.Health) *healthDocument { + if value == nil { + return nil + } + var server *healthServerDocument + if value.Server != nil { + server = &healthServerDocument{Start: fromProjectStart(value.Server.Start)} + } + return &healthDocument{Server: server, Env: fromProjectComponentEnv(value.Env)} +} + +func fromProjectTelemetry(value *projectdomain.Telemetry) *telemetryDocument { + if value == nil { + return nil + } + return &telemetryDocument{Start: fromProjectStart(value.Start), Env: fromProjectComponentEnv(value.Env)} +} + +func fromProjectDB(value *projectdomain.DB) *dbDocument { + if value == nil { + return nil + } + connections := make([]dbConnectionDocument, len(value.Connections)) + for index, connection := range value.Connections { + variants := make([]dbVariantDocument, len(connection.Variants)) + for variantIndex, variant := range connection.Variants { + variants[variantIndex] = fromProjectDBVariant(variant) + } + connections[index] = dbConnectionDocument{Name: connection.Name, Default: connection.Default, KindEnv: connection.KindEnv, Variants: variants} + } + return &dbDocument{Connections: connections, Env: fromProjectComponentEnv(value.Env)} +} + +func fromProjectDBVariant(value projectdomain.DBVariant) dbVariantDocument { + variant := dbVariantDocument{Name: value.Name, Kind: value.Kind, DSNEnv: value.DSNEnv, DSNDefault: value.DSNDefault, Secret: value.Secret} + if value.Migrations != nil { + variant.Migrations = &dbMigrationsDocument{Path: value.Migrations.Path, DatabaseEnv: value.Migrations.DatabaseEnv, DatabaseDefault: value.Migrations.DatabaseDefault} + } + return variant +} + +func fromProjectRedis(value *projectdomain.Redis) *redisDocument { + if value == nil { + return nil + } + connections := make([]redisConnectionDocument, len(value.Connections)) + for index, connection := range value.Connections { + connections[index] = redisConnectionDocument(connection) + } + return &redisDocument{Connections: connections, Env: fromProjectComponentEnv(value.Env)} +} + +func fromProjectS3(value *projectdomain.S3) *s3Document { + if value == nil { + return nil + } + connections := make([]s3ConnectionDocument, len(value.Connections)) + for index, connection := range value.Connections { + connections[index] = s3ConnectionDocument(connection) + } + buckets := make([]s3BucketDocument, len(value.Buckets)) + for index, bucket := range value.Buckets { + buckets[index] = s3BucketDocument(bucket) + } + return &s3Document{Connections: connections, Buckets: buckets, Env: fromProjectComponentEnv(value.Env)} +} + +func fromProjectLanguages(value projectdomain.Languages) languagesDocument { + generators := goGeneratorsDocument{} + if value.Go.Generators.Config != nil { + config := configGeneratorDocument(*value.Go.Generators.Config) + generators.Config = &config + } + if value.Go.Generators.HTTP != nil { + http := httpGeneratorDocument(*value.Go.Generators.HTTP) + generators.HTTP = &http + } + if value.Go.Generators.GRPC != nil { + grpc := grpcGeneratorDocument(*value.Go.Generators.GRPC) + generators.GRPC = &grpc + } + if value.Go.Generators.Kafka != nil { + kafka := kafkaGeneratorDocument(*value.Go.Generators.Kafka) + generators.Kafka = &kafka + } + components := goComponentsDocument{} + if value.Go.Components.Pprof != nil { + var server *pprofServerDocument + if value.Go.Components.Pprof.Server != nil { + server = &pprofServerDocument{Start: fromProjectStart(value.Go.Components.Pprof.Server.Start)} + } + components.Pprof = &pprofDocument{Server: server, Env: fromProjectComponentEnv(value.Go.Components.Pprof.Env)} + } + return languagesDocument{Go: goLanguageDocument{Module: value.Go.Module, Generators: generators, Components: components}} +} diff --git a/internal/repository/manifest/repository.go b/internal/repository/manifest/repository.go new file mode 100644 index 0000000..71e8c80 --- /dev/null +++ b/internal/repository/manifest/repository.go @@ -0,0 +1,109 @@ +package manifest + +import ( + "context" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" + + projectdomain "github.com/devctllabs/devctl/internal/domain/project" + devfs "github.com/devctllabs/go-libs/filesystem" + "gopkg.in/yaml.v3" +) + +// FilesystemRepo maps canonical manifest YAML to and from project domain data. +type FilesystemRepo struct{} + +func NewFilesystemRepo() *FilesystemRepo { return &FilesystemRepo{} } + +// Load decodes selectedPath, returning syntax and type issues as data rather than execution errors. +func (r *FilesystemRepo) Load(ctx context.Context, selectedPath string) (projectdomain.LoadManifestResult, error) { + if err := ctx.Err(); err != nil { + return projectdomain.LoadManifestResult{}, fmt.Errorf("ctx.Err: %w", err) + } + manifestPath := filepath.Clean(selectedPath) + data, err := readManifestFile(manifestPath) + if err != nil { + return projectdomain.LoadManifestResult{}, fmt.Errorf("readManifestFile: %w", err) + } + document, issues, parseErr := parse(data) + project := projectdomain.Project{Root: filepath.Dir(manifestPath), ManifestPath: manifestPath} + if len(issues) > 0 { + return projectdomain.LoadManifestResult{Project: project, Issues: issues}, nil + } + if parseErr != nil { + return projectdomain.LoadManifestResult{}, fmt.Errorf("parse: %w", parseErr) + } + project.Manifest = toProjectSpec(document) + return projectdomain.LoadManifestResult{Project: project}, nil +} + +// Save canonically encodes and atomically publishes project.Manifest at project.ManifestPath. +func (r *FilesystemRepo) Save(ctx context.Context, project projectdomain.Project) (bool, error) { + data, err := encode(project.Manifest) + if err != nil { + return false, fmt.Errorf("encode: %w", err) + } + changed, err := publish(ctx, project.ManifestPath, data) + if err != nil { + return false, fmt.Errorf("publish: %w", err) + } + return changed, nil +} + +func encode(manifest projectdomain.Manifest) ([]byte, error) { + var buffer strings.Builder + encoder := yaml.NewEncoder(&buffer) + encoder.SetIndent(2) + if err := encoder.Encode(fromProjectManifest(manifest)); err != nil { + return nil, fmt.Errorf("encoder.Encode: %w", err) + } + if err := encoder.Close(); err != nil { + return nil, fmt.Errorf("encoder.Close: %w", err) + } + return []byte(buffer.String()), nil +} + +func publish(ctx context.Context, path string, data []byte) (bool, error) { + directory := filepath.Dir(path) + if err := os.MkdirAll(directory, 0o755); err != nil { + return false, fmt.Errorf("os.MkdirAll: %w", err) + } + disk, err := devfs.Open(directory) + if err != nil { + return false, fmt.Errorf("filesystem.Open: %w", err) + } + changed, publishErr := disk.PublishFile(ctx, filepath.Base(path), devfs.File{Content: data, Mode: 0o644}) + if publishErr != nil { + publishErr = fmt.Errorf("filesystem.PublishFile: %w", publishErr) + } + closeErr := disk.Close() + if closeErr != nil { + closeErr = fmt.Errorf("filesystem.Close: %w", closeErr) + } + return changed, errors.Join(publishErr, closeErr) +} + +func readManifestFile(path string) ([]byte, error) { + rootFS, err := devfs.Open(filepath.Dir(path)) + if err != nil { + return nil, fmt.Errorf("filesystem.Open: %w", err) + } + defer func() { _ = rootFS.Close() }() + name := filepath.Base(path) + info, err := rootFS.Lstat(name) + if err != nil { + return nil, fmt.Errorf("filesystem.Lstat: %w", err) + } + if info.Mode()&fs.ModeSymlink != 0 || !info.Mode().IsRegular() { + return nil, fmt.Errorf("%s must be a regular non-symlink file", name) + } + data, err := fs.ReadFile(rootFS, name) + if err != nil { + return nil, fmt.Errorf("filesystem.ReadFile: %w", err) + } + return data, nil +} diff --git a/internal/repository/manifest/repository_test.go b/internal/repository/manifest/repository_test.go new file mode 100644 index 0000000..6fcd3df --- /dev/null +++ b/internal/repository/manifest/repository_test.go @@ -0,0 +1,268 @@ +package manifest_test + +import ( + "context" + "os" + "path/filepath" + "testing" + + projectdomain "github.com/devctllabs/devctl/internal/domain/project" + "github.com/devctllabs/devctl/internal/repository/manifest" + "github.com/stretchr/testify/require" +) + +func TestFilesystemRepoLoadMinimalManifest(t *testing.T) { + t.Parallel() + + root := t.TempDir() + manifestPath := filepath.Join(root, "devctl.yaml") + err := os.WriteFile(manifestPath, []byte("version: 1\nproject:\n name: example\n language: go\nenv: {}\npaths:\n external_contracts: api/external\nsources: {}\nexports: {}\ncomponents: {}\nlanguages:\n go:\n module: github.com/acme/example\n"), 0o644) + require.NoError(t, err) + + loaded, err := manifest.NewFilesystemRepo().Load(context.Background(), manifestPath) + + require.NoError(t, err) + require.Empty(t, loaded.Issues) + require.Equal(t, "example", loaded.Project.Manifest.Project.Name) + require.Empty(t, loaded.Project.Manifest.Env.Prefix) + require.Equal(t, root, loaded.Project.Root) +} + +func TestFilesystemRepoSavesCanonicalManifestIdempotently(t *testing.T) { + t.Parallel() + + root := t.TempDir() + manifestPath := filepath.Join(root, "devctl.yaml") + project := projectdomain.Project{ + Root: root, ManifestPath: manifestPath, + Manifest: projectdomain.Manifest{ + Version: 1, + Project: projectdomain.Identity{Name: "example", Language: "go"}, + Sources: map[string]projectdomain.Source{}, + Exports: map[string]projectdomain.Export{}, + Languages: projectdomain.Languages{Go: projectdomain.GoLanguage{Module: "example.test/example"}}, + }, + } + repository := manifest.NewFilesystemRepo() + + changed, err := repository.Save(context.Background(), project) + require.NoError(t, err) + require.True(t, changed) + + changed, err = repository.Save(context.Background(), project) + require.NoError(t, err) + require.False(t, changed) + + loaded, err := repository.Load(context.Background(), manifestPath) + require.NoError(t, err) + require.Equal(t, project.Manifest.Project, loaded.Project.Manifest.Project) + require.Equal(t, project.Manifest.Languages.Go.Module, loaded.Project.Manifest.Languages.Go.Module) +} + +func TestFilesystemRepoClassifiesMissingManifest(t *testing.T) { + t.Parallel() + + missing := filepath.Join(t.TempDir(), "missing.yaml") + + loaded, err := manifest.NewFilesystemRepo().Load(context.Background(), missing) + + require.ErrorIs(t, err, os.ErrNotExist) + require.Empty(t, loaded.Issues) +} + +func TestFilesystemRepoReturnsMalformedYAMLAsDocumentIssue(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "devctl.yaml") + require.NoError(t, os.WriteFile(path, []byte("version: [\n"), 0o644)) + + loaded, err := manifest.NewFilesystemRepo().Load(context.Background(), path) + + require.NoError(t, err) + require.Equal(t, []projectdomain.DecodeIssue{{Kind: projectdomain.DecodeYAMLInvalid}}, loaded.Issues) +} + +func TestFilesystemRepoReturnsTypeMismatchPosition(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "devctl.yaml") + require.NoError(t, os.WriteFile(path, []byte("version: nope\n"), 0o644)) + + loaded, err := manifest.NewFilesystemRepo().Load(context.Background(), path) + + require.NoError(t, err) + require.Equal(t, []projectdomain.DecodeIssue{{ + Kind: projectdomain.DecodeSchemaInvalid, Field: "version", Line: 1, Column: 10, + }}, loaded.Issues) +} + +func TestFilesystemRepoReturnsDuplicateKeyPosition(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "devctl.yaml") + require.NoError(t, os.WriteFile(path, []byte("project:\n name: first\n name: second\n"), 0o644)) + + loaded, err := manifest.NewFilesystemRepo().Load(context.Background(), path) + + require.NoError(t, err) + require.Equal(t, []projectdomain.DecodeIssue{{ + Kind: projectdomain.DecodeDuplicateKey, Field: "project.name", Line: 3, Column: 3, + }}, loaded.Issues) +} + +func TestFilesystemRepoAggregatesUnknownFields(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "devctl.yaml") + require.NoError(t, os.WriteFile(path, []byte("unknown_root: true\nproject:\n unknown_project: true\n"), 0o644)) + + loaded, err := manifest.NewFilesystemRepo().Load(context.Background(), path) + + require.NoError(t, err) + require.Equal(t, []projectdomain.DecodeIssue{ + {Kind: projectdomain.DecodeUnknownField, Field: "unknown_root", Line: 1, Column: 1}, + {Kind: projectdomain.DecodeUnknownField, Field: "project.unknown_project", Line: 3, Column: 3}, + }, loaded.Issues) +} + +func TestLoadManifestMapsYAMLDocumentToProjectSpec(t *testing.T) { + t.Parallel() + + root := t.TempDir() + manifestPath := filepath.Join(root, "devctl.yaml") + require.NoError(t, os.WriteFile(manifestPath, []byte(`version: 1 +project: {name: example, language: go} +env: {prefix: EXAMPLE_} +paths: {external_contracts: contracts/external} +sources: + remote: + type: url + url: http://example.test/openapi.yaml + filename: remote.yaml + allow_insecure_http: true +exports: {} +components: + http: + clients: + - name: remote + source: remote + path: openapi.yaml + base_url_env: REMOTE_BASE_URL + oapi_config: tools/oapi/remote.yaml + db: + connections: + - name: primary + default: sqlite + kind_env: DB_PRIMARY_KIND + variants: + - name: sqlite + kind: sqlite + dsn_env: DB_PRIMARY_SQLITE_DSN + dsn_default: file:./data/primary.db + migrations: + path: migrations/primary/sqlite + database_env: DB_PRIMARY_SQLITE_MIGRATIONS_URL + database_default: sqlite://./data/primary.db + redis: + connections: + - name: cache + addr_env: REDIS_CACHE_ADDR + addr_default: redis://localhost:6379/1 +languages: + go: + module: github.com/acme/example + generators: + http: + oapi_config: tools/oapi/server.yaml + server_out: gen/server + client_out: gen/client +`), 0o644)) + + loaded, err := manifest.NewFilesystemRepo().Load(context.Background(), manifestPath) + + require.NoError(t, err) + require.Empty(t, loaded.Issues) + require.Equal(t, root, loaded.Project.Root) + require.Equal(t, manifestPath, loaded.Project.ManifestPath) + spec := loaded.Project.Manifest + require.Equal(t, "contracts/external", spec.Paths.ExternalContracts) + require.True(t, spec.Sources["remote"].AllowInsecureHTTP) + require.Equal(t, "REMOTE_BASE_URL", spec.Components.HTTP.Clients[0].BaseURLEnv) + require.Equal(t, "tools/oapi/remote.yaml", spec.Components.HTTP.Clients[0].OAPIConfig) + require.Equal(t, "DB_PRIMARY_KIND", spec.Components.DB.Connections[0].KindEnv) + require.Equal(t, "DB_PRIMARY_SQLITE_DSN", spec.Components.DB.Connections[0].Variants[0].DSNEnv) + require.Equal(t, &projectdomain.DBMigrations{ + Path: "migrations/primary/sqlite", DatabaseEnv: "DB_PRIMARY_SQLITE_MIGRATIONS_URL", + DatabaseDefault: "sqlite://./data/primary.db", + }, spec.Components.DB.Connections[0].Variants[0].Migrations) + require.Equal(t, projectdomain.RedisConnection{ + Name: "cache", AddrEnv: "REDIS_CACHE_ADDR", AddrDefault: "redis://localhost:6379/1", + }, spec.Components.Redis.Connections[0]) + require.Equal(t, "gen/server", spec.Languages.Go.Generators.HTTP.ServerOut) + require.Equal(t, "gen/client", spec.Languages.Go.Generators.HTTP.ClientOut) +} + +func TestFilesystemRepoRejectsRemovedRedisFields(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "devctl.yaml") + require.NoError(t, os.WriteFile(path, []byte(`components: + redis: + instances: [] + connections: + - name: cache + addr_env: REDIS_CACHE_ADDR + default: true +`), 0o644)) + + loaded, err := manifest.NewFilesystemRepo().Load(context.Background(), path) + + require.NoError(t, err) + fields := make([]string, len(loaded.Issues)) + for index, issue := range loaded.Issues { + require.Equal(t, projectdomain.DecodeUnknownField, issue.Kind) + fields[index] = issue.Field + } + require.Equal(t, []string{ + "components.redis.instances", + "components.redis.connections[0].default", + }, fields) +} + +func TestMutationRejectsUnknownExtensionFields(t *testing.T) { + t.Parallel() + + root := t.TempDir() + manifestPath := filepath.Join(root, "devctl.yaml") + original := `version: 1 +project: {name: example, language: go} +env: {} +paths: {external_contracts: api/external} +sources: + users: + # transport note + type: git + repo: old + ref: main + x-owner: contracts +exports: {} +components: + x-component: {keep: true} +languages: + go: {module: github.com/acme/example} +` + require.NoError(t, os.WriteFile(manifestPath, []byte(original), 0o644)) + + loaded, err := manifest.NewFilesystemRepo().Load(context.Background(), manifestPath) + require.NoError(t, err) + require.Equal(t, []projectdomain.DecodeIssue{ + {Kind: projectdomain.DecodeUnknownField, Field: "sources.users.x-owner", Line: 11, Column: 5}, + {Kind: projectdomain.DecodeUnknownField, Field: "components.x-component", Line: 14, Column: 3}, + }, loaded.Issues) + updated, err := os.ReadFile(manifestPath) + require.NoError(t, err) + require.Contains(t, string(updated), "# transport note") + require.Contains(t, string(updated), "x-owner: contracts") + require.Contains(t, string(updated), "x-component: {keep: true}") + require.Contains(t, string(updated), "repo: old") +} diff --git a/internal/repository/workspace/contracts.go b/internal/repository/workspace/contracts.go new file mode 100644 index 0000000..efa51a5 --- /dev/null +++ b/internal/repository/workspace/contracts.go @@ -0,0 +1,160 @@ +package workspace + +import ( + "context" + "fmt" + "io/fs" + "os" + "path" + "path/filepath" + "sort" + "strings" + + "github.com/devctllabs/devctl/internal/domain/contract" + devfs "github.com/devctllabs/go-libs/filesystem" + "gopkg.in/yaml.v3" +) + +// ReadContract reads exact bytes from a resolved regular non-symlink contract path. +func (r *FilesystemRepo) ReadContract(ctx context.Context, contractPath string) ([]byte, error) { + if err := ctx.Err(); err != nil { + return nil, fmt.Errorf("ctx.Err: %w", err) + } + info, err := os.Lstat(contractPath) + if err != nil { + return nil, fmt.Errorf("os.Lstat: %w", err) + } + if info.Mode()&fs.ModeSymlink != 0 || !info.Mode().IsRegular() { + return nil, fmt.Errorf("contract is not a regular non-symlink file: %s", contractPath) + } + data, err := os.ReadFile(contractPath) + if err != nil { + return nil, fmt.Errorf("os.ReadFile: %w", err) + } + return data, nil +} + +// ListProtoFiles returns sorted project-relative Proto files below relativeRoot. +func (r *FilesystemRepo) ListProtoFiles(ctx context.Context, root, relativeRoot string) ([]string, error) { + if err := ctx.Err(); err != nil { + return nil, fmt.Errorf("ctx.Err: %w", err) + } + disk, err := devfs.Open(root) + if err != nil { + return nil, fmt.Errorf("filesystem.Open: %w", err) + } + defer func() { _ = disk.Close() }() + var files []string + err = fs.WalkDir(disk, relativeRoot, func(name string, entry fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if err := ctx.Err(); err != nil { + return fmt.Errorf("ctx.Err: %w", err) + } + if entry.Type()&fs.ModeSymlink != 0 { + return fmt.Errorf("symlink in Proto tree: %s", name) + } + if !entry.IsDir() && path.Ext(name) == ".proto" { + files = append(files, path.Clean(name)) + } + return nil + }) + if err != nil { + return nil, fmt.Errorf("filesystem.WalkDir: %w", err) + } + sort.Strings(files) + return files, nil +} + +// ResolveContract selects one regular non-symlink entrypoint within location.Root. +// When Entrypoint is empty, resolution succeeds only if exactly one OpenAPI 3.x document is found. +func (r *FilesystemRepo) ResolveContract(ctx context.Context, location contract.Location) (string, error) { + disk, err := devfs.Open(location.Root) + if err != nil { + return "", fmt.Errorf("filesystem.Open: %w", err) + } + defer func() { _ = disk.Close() }() + + relative := location.RelativePath + if location.Local { + if location.Entrypoint != location.RelativePath { + info, statErr := disk.Lstat(relative) + if statErr != nil { + return "", fmt.Errorf("filesystem.Lstat: %w", statErr) + } + if info.IsDir() { + relative = path.Join(relative, location.Entrypoint) + } + } + } else if location.Entrypoint != "" { + relative = path.Join(relative, location.Entrypoint) + } + if location.Entrypoint == "" { + relative, err = findOpenAPI(ctx, disk, relative) + if err != nil { + return "", fmt.Errorf("findOpenAPI: %w", err) + } + } + info, err := disk.Lstat(relative) + if err != nil { + return "", fmt.Errorf("filesystem.Lstat: %w", err) + } + if info.Mode()&fs.ModeSymlink != 0 || !info.Mode().IsRegular() { + return "", fmt.Errorf("contract is not a regular non-symlink file: %s", relative) + } + return filepath.Join(location.Root, filepath.FromSlash(relative)), nil +} + +func findOpenAPI(ctx context.Context, disk *devfs.OS, root string) (string, error) { + var candidates []string + err := fs.WalkDir(disk, root, func(name string, entry fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if err := ctx.Err(); err != nil { + return fmt.Errorf("ctx.Err: %w", err) + } + if entry.Type()&fs.ModeSymlink != 0 { + return fmt.Errorf("symlink in materialized target: %s", name) + } + if entry.IsDir() { + return nil + } + data, err := fs.ReadFile(disk, name) + if err != nil { + return fmt.Errorf("filesystem.ReadFile: %w", err) + } + var document yaml.Node + if yaml.Unmarshal(data, &document) == nil && strings.HasPrefix(mappingScalar(documentRoot(&document), "openapi"), "3.") { + candidates = append(candidates, name) + } + return nil + }) + if err != nil { + return "", fmt.Errorf("filesystem.WalkDir: %w", err) + } + if len(candidates) != 1 { + return "", fmt.Errorf("expected exactly one OpenAPI entrypoint in %s, found %d", root, len(candidates)) + } + return candidates[0], nil +} + +func documentRoot(node *yaml.Node) *yaml.Node { + if node != nil && node.Kind == yaml.DocumentNode && len(node.Content) > 0 { + return node.Content[0] + } + return node +} + +func mappingScalar(node *yaml.Node, key string) string { + if node == nil || node.Kind != yaml.MappingNode { + return "" + } + for index := 0; index+1 < len(node.Content); index += 2 { + if node.Content[index].Value == key { + return node.Content[index+1].Value + } + } + return "" +} diff --git a/internal/repository/workspace/repository.go b/internal/repository/workspace/repository.go new file mode 100644 index 0000000..f959f25 --- /dev/null +++ b/internal/repository/workspace/repository.go @@ -0,0 +1,441 @@ +package workspace + +import ( + "bytes" + "context" + "errors" + "fmt" + "io/fs" + "os" + "path" + "sort" + "strings" + "sync" + + "github.com/devctllabs/devctl/internal/domain/artifact" + "github.com/devctllabs/devctl/internal/domain/contract" + devfs "github.com/devctllabs/go-libs/filesystem" +) + +// FilesystemRepo implements contained project workspace mechanics over the operating-system filesystem. +type FilesystemRepo struct { + publication sync.Mutex +} + +func NewFilesystemRepo() *FilesystemRepo { return &FilesystemRepo{} } + +// WorkingDirectory returns the process working directory unless ctx is already cancelled. +func (r *FilesystemRepo) WorkingDirectory(ctx context.Context) (string, error) { + if err := ctx.Err(); err != nil { + return "", fmt.Errorf("ctx.Err: %w", err) + } + directory, err := os.Getwd() + if err != nil { + return "", fmt.Errorf("os.Getwd: %w", err) + } + return directory, nil +} + +// Walk visits entries below root, propagates cancellation, and closes the rooted filesystem before returning. +func (r *FilesystemRepo) Walk(ctx context.Context, root string, visit fs.WalkDirFunc) error { + if err := ctx.Err(); err != nil { + return fmt.Errorf("ctx.Err: %w", err) + } + disk, err := devfs.Open(root) + if err != nil { + return fmt.Errorf("filesystem.Open: %w", err) + } + walkErr := fs.WalkDir(disk, ".", func(name string, entry fs.DirEntry, err error) error { + if err := ctx.Err(); err != nil { + return fmt.Errorf("ctx.Err: %w", err) + } + if err := visit(name, entry, err); err != nil { + return fmt.Errorf("visit: %w", err) + } + return nil + }) + if walkErr != nil { + walkErr = fmt.Errorf("filesystem.WalkDir: %w", walkErr) + } + closeErr := disk.Close() + if closeErr != nil { + closeErr = fmt.Errorf("filesystem.Close: %w", closeErr) + } + return errors.Join(walkErr, closeErr) +} + +// Lstat reports contained path metadata without following the final symlink. +func (r *FilesystemRepo) Lstat(ctx context.Context, root, name string) (fs.FileInfo, error) { + if err := ctx.Err(); err != nil { + return nil, fmt.Errorf("ctx.Err: %w", err) + } + disk, err := devfs.Open(root) + if err != nil { + return nil, fmt.Errorf("filesystem.Open: %w", err) + } + info, statErr := disk.Lstat(name) + if statErr != nil { + statErr = fmt.Errorf("filesystem.Lstat: %w", statErr) + } + closeErr := disk.Close() + if closeErr != nil { + closeErr = fmt.Errorf("filesystem.Close: %w", closeErr) + } + return info, errors.Join(statErr, closeErr) +} + +// RegularFile reports false for missing paths, symlinks, directories, and other non-regular entries. +func (r *FilesystemRepo) RegularFile(ctx context.Context, root, name string) (bool, error) { + info, err := r.Lstat(ctx, root, name) + if errors.Is(err, fs.ErrNotExist) { + return false, nil + } + if err != nil { + return false, fmt.Errorf("r.Lstat: %w", err) + } + return info.Mode().IsRegular() && info.Mode()&fs.ModeSymlink == 0, nil +} + +// Directory reports false for missing paths, symlinks, and non-directory entries. +func (r *FilesystemRepo) Directory(ctx context.Context, root, name string) (bool, error) { + info, err := r.Lstat(ctx, root, name) + if errors.Is(err, fs.ErrNotExist) { + return false, nil + } + if err != nil { + return false, fmt.Errorf("r.Lstat: %w", err) + } + return info.IsDir() && info.Mode()&fs.ModeSymlink == 0, nil +} + +// ReadBytes reads a path through a filesystem rooted at root and closes that filesystem before returning. +func (r *FilesystemRepo) ReadBytes(ctx context.Context, root, name string) ([]byte, error) { + if err := ctx.Err(); err != nil { + return nil, fmt.Errorf("ctx.Err: %w", err) + } + disk, err := devfs.Open(root) + if err != nil { + return nil, fmt.Errorf("filesystem.Open: %w", err) + } + data, readErr := fs.ReadFile(disk, name) + if readErr != nil { + readErr = fmt.Errorf("filesystem.ReadFile: %w", readErr) + } + closeErr := disk.Close() + if closeErr != nil { + closeErr = fmt.Errorf("filesystem.Close: %w", closeErr) + } + return data, errors.Join(readErr, closeErr) +} + +// ReadFile returns a regular non-symlink file below root with its relative path and permission bits. +func (r *FilesystemRepo) ReadFile(ctx context.Context, root, name string) (contract.File, error) { + if err := ctx.Err(); err != nil { + return contract.File{}, fmt.Errorf("ctx.Err: %w", err) + } + disk, err := devfs.Open(root) + if err != nil { + return contract.File{}, fmt.Errorf("filesystem.Open: %w", err) + } + defer func() { _ = disk.Close() }() + info, err := disk.Lstat(name) + if err != nil { + return contract.File{}, fmt.Errorf("filesystem.Lstat: %w", err) + } + if info.Mode()&fs.ModeSymlink != 0 || !info.Mode().IsRegular() { + return contract.File{}, fmt.Errorf("path is not a regular non-symlink file: %s", name) + } + content, err := fs.ReadFile(disk, name) + if err != nil { + return contract.File{}, fmt.Errorf("filesystem.ReadFile: %w", err) + } + return contract.File{Path: name, Content: content, Mode: uint32(info.Mode().Perm())}, nil +} + +// ReadTree returns all regular non-symlink files below a contained directory. +func (r *FilesystemRepo) ReadTree(ctx context.Context, root, directory string) ([]contract.File, error) { + if err := ctx.Err(); err != nil { + return nil, fmt.Errorf("ctx.Err: %w", err) + } + disk, err := devfs.Open(root) + if err != nil { + return nil, fmt.Errorf("filesystem.Open: %w", err) + } + defer func() { _ = disk.Close() }() + + var files []contract.File + err = fs.WalkDir(disk, directory, func(name string, entry fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if err := ctx.Err(); err != nil { + return fmt.Errorf("ctx.Err: %w", err) + } + if entry.Type()&fs.ModeSymlink != 0 { + return fmt.Errorf("symlink in contract tree: %s", name) + } + if entry.IsDir() { + return nil + } + info, err := entry.Info() + if err != nil { + return fmt.Errorf("entry.Info: %w", err) + } + if !info.Mode().IsRegular() { + return fmt.Errorf("path is not a regular file: %s", name) + } + content, err := fs.ReadFile(disk, name) + if err != nil { + return fmt.Errorf("filesystem.ReadFile: %w", err) + } + files = append(files, contract.File{Path: path.Clean(name), Content: content, Mode: uint32(info.Mode().Perm())}) + return nil + }) + if err != nil { + return nil, fmt.Errorf("filesystem.WalkDir: %w", err) + } + return files, nil +} + +// PublishFile atomically publishes content below root and reports creation, replacement, or equality. +func (r *FilesystemRepo) PublishFile( + ctx context.Context, + root, target string, + content []byte, +) (artifact.PublishResult, error) { + r.publication.Lock() + defer r.publication.Unlock() + + disk, err := devfs.Open(root) + if err != nil { + return artifact.PublishResult{}, fmt.Errorf("filesystem.Open: %w", err) + } + _, statErr := disk.Lstat(target) + exists := statErr == nil + if statErr != nil && !errors.Is(statErr, fs.ErrNotExist) { + _ = disk.Close() + return artifact.PublishResult{}, fmt.Errorf("filesystem.Lstat: %w", statErr) + } + changed, publishErr := disk.PublishFile(ctx, target, devfs.File{Content: content, Mode: 0o644}) + if publishErr != nil { + publishErr = fmt.Errorf("filesystem.PublishFile: %w", publishErr) + } + closeErr := disk.Close() + if closeErr != nil { + closeErr = fmt.Errorf("filesystem.Close: %w", closeErr) + } + result := artifact.PublishResult{Action: publishAction(exists, changed)} + return result, errors.Join(publishErr, closeErr) +} + +// PublishDirectory atomically replaces target with the complete tree and reports precise file effects. +func (r *FilesystemRepo) PublishDirectory( + ctx context.Context, + root, target string, + tree artifact.Tree, +) (artifact.PublishResult, error) { + r.publication.Lock() + defer r.publication.Unlock() + + disk, err := devfs.Open(root) + if err != nil { + return artifact.PublishResult{}, fmt.Errorf("filesystem.Open: %w", err) + } + snapshot := make(devfs.Snapshot, len(tree.Files)) + for _, file := range tree.Files { + mode := fs.FileMode(file.Mode) + if mode == 0 { + mode = 0o644 + } + snapshot[file.Path] = devfs.File{Content: file.Content, Mode: mode} + } + previous, exists, inspectErr := inspectPublishedTree(ctx, disk, target) + if inspectErr != nil { + _ = disk.Close() + return artifact.PublishResult{}, fmt.Errorf("inspectPublishedTree: %w", inspectErr) + } + changed, publishErr := disk.PublishDirectory(ctx, target, snapshot) + if publishErr != nil { + publishErr = fmt.Errorf("filesystem.PublishDirectory: %w", publishErr) + } + closeErr := disk.Close() + if closeErr != nil { + closeErr = fmt.Errorf("filesystem.Close: %w", closeErr) + } + result := artifact.PublishResult{ + Action: publishAction(exists, changed), + Changes: publicationChanges(previous, snapshot), + } + return result, errors.Join(publishErr, closeErr) +} + +func publishAction(existed, changed bool) artifact.PublishAction { + if !changed { + return artifact.PublishUnchanged + } + if existed { + return artifact.PublishUpdated + } + return artifact.PublishCreated +} + +func inspectPublishedTree( + ctx context.Context, + disk *devfs.OS, + target string, +) (devfs.Snapshot, bool, error) { + info, err := disk.Lstat(target) + if errors.Is(err, fs.ErrNotExist) { + return devfs.Snapshot{}, false, nil + } + if err != nil { + return nil, false, fmt.Errorf("filesystem.Lstat: %w", err) + } + if info.Mode()&fs.ModeSymlink != 0 || !info.IsDir() { + return nil, true, &fs.PathError{Op: "publishdirectory", Path: target, Err: fs.ErrInvalid} + } + result := devfs.Snapshot{} + err = fs.WalkDir(disk, target, func(name string, entry fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if err := ctx.Err(); err != nil { + return fmt.Errorf("ctx.Err: %w", err) + } + if name == target || entry.IsDir() { + return nil + } + if entry.Type()&fs.ModeSymlink != 0 { + return &fs.PathError{Op: "publishdirectory", Path: name, Err: fs.ErrInvalid} + } + info, err := entry.Info() + if err != nil { + return fmt.Errorf("entry.Info: %w", err) + } + if !info.Mode().IsRegular() { + return &fs.PathError{Op: "publishdirectory", Path: name, Err: fs.ErrInvalid} + } + content, err := fs.ReadFile(disk, name) + if err != nil { + return fmt.Errorf("filesystem.ReadFile: %w", err) + } + relative := strings.TrimPrefix(name, strings.TrimSuffix(target, "/")+"/") + result[relative] = devfs.File{Content: content, Mode: info.Mode().Perm()} + return nil + }) + if err != nil { + return nil, true, fmt.Errorf("filesystem.WalkDir: %w", err) + } + return result, true, nil +} + +func publicationChanges(previous, next devfs.Snapshot) []artifact.PublishChange { + changes := make([]artifact.PublishChange, 0, len(previous)+len(next)) + for name, expected := range next { + action := artifact.PublishCreated + if current, exists := previous[name]; exists { + action = artifact.PublishUpdated + if current.Mode.Perm() == expected.Mode.Perm() && bytes.Equal(current.Content, expected.Content) { + action = artifact.PublishUnchanged + } + } + changes = append(changes, artifact.PublishChange{Path: name, Action: action}) + } + for name := range previous { + if _, exists := next[name]; !exists { + changes = append(changes, artifact.PublishChange{Path: name, Action: artifact.PublishRemoved}) + } + } + sort.Slice(changes, func(i, j int) bool { return changes[i].Path < changes[j].Path }) + return changes +} + +// PruneDirectories removes regular child directories absent from keep and returns their names in lexical order. +func (r *FilesystemRepo) PruneDirectories( + ctx context.Context, + root, parent string, + keep []string, +) ([]string, error) { + r.publication.Lock() + defer r.publication.Unlock() + + disk, err := devfs.Open(root) + if err != nil { + return nil, fmt.Errorf("filesystem.Open: %w", err) + } + removed, pruneErr := prunableDirectories(ctx, disk, parent, keep) + if pruneErr == nil { + for _, name := range removed { + if err := disk.RemoveAll(ctx, path.Join(parent, name)); err != nil { + pruneErr = fmt.Errorf("filesystem.RemoveAll: %w", err) + break + } + } + } + closeErr := disk.Close() + if closeErr != nil { + closeErr = fmt.Errorf("filesystem.Close: %w", closeErr) + } + return removed, errors.Join(pruneErr, closeErr) +} + +// PreviewPruneDirectories returns the validated stale child directories without removing them. +func (r *FilesystemRepo) PreviewPruneDirectories( + ctx context.Context, + root, parent string, + keep []string, +) ([]string, error) { + r.publication.Lock() + defer r.publication.Unlock() + + disk, err := devfs.Open(root) + if err != nil { + return nil, fmt.Errorf("filesystem.Open: %w", err) + } + removed, previewErr := prunableDirectories(ctx, disk, parent, keep) + closeErr := disk.Close() + if closeErr != nil { + closeErr = fmt.Errorf("filesystem.Close: %w", closeErr) + } + return removed, errors.Join(previewErr, closeErr) +} + +func prunableDirectories(ctx context.Context, disk *devfs.OS, parent string, keep []string) ([]string, error) { + info, err := disk.Lstat(parent) + if errors.Is(err, fs.ErrNotExist) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("filesystem.Lstat: %w", err) + } + if info.Mode()&fs.ModeSymlink != 0 || !info.IsDir() { + return nil, &fs.PathError{Op: "prune", Path: parent, Err: fs.ErrInvalid} + } + entries, err := fs.ReadDir(disk, parent) + if err != nil { + return nil, fmt.Errorf("filesystem.ReadDir: %w", err) + } + kept := make(map[string]struct{}, len(keep)) + for _, name := range keep { + kept[name] = struct{}{} + } + removed := make([]string, 0, len(entries)) + for _, entry := range entries { + if err := ctx.Err(); err != nil { + return nil, fmt.Errorf("ctx.Err: %w", err) + } + if _, ok := kept[entry.Name()]; ok { + continue + } + target := path.Join(parent, entry.Name()) + info, err := disk.Lstat(target) + if err != nil { + return nil, fmt.Errorf("filesystem.Lstat: %w", err) + } + if info.Mode()&fs.ModeSymlink != 0 || !info.IsDir() { + return nil, &fs.PathError{Op: "prune", Path: target, Err: fs.ErrInvalid} + } + removed = append(removed, entry.Name()) + } + return removed, nil +} diff --git a/internal/repository/workspace/repository_test.go b/internal/repository/workspace/repository_test.go new file mode 100644 index 0000000..239f167 --- /dev/null +++ b/internal/repository/workspace/repository_test.go @@ -0,0 +1,201 @@ +package workspace_test + +import ( + "context" + "io/fs" + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/devctllabs/devctl/internal/domain/artifact" + "github.com/devctllabs/devctl/internal/domain/contract" + workspacerepo "github.com/devctllabs/devctl/internal/repository/workspace" + "github.com/stretchr/testify/require" +) + +func TestFilesystemRepoPublishesAndPrunesManagedDirectories(t *testing.T) { + t.Parallel() + + root := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(root, "api/external/clienthttp/stale"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(root, "api/external/clienthttp/stale/openapi.yaml"), []byte("stale"), 0o644)) + repository := workspacerepo.NewFilesystemRepo() + + published, err := repository.PublishDirectory(context.Background(), root, "api/external/clienthttp/active", artifact.Tree{Files: []artifact.File{ + {Path: "openapi.yaml", Content: []byte("active")}, + }}) + require.NoError(t, err) + require.Equal(t, artifact.PublishCreated, published.Action) + require.Equal(t, []artifact.PublishChange{{Path: "openapi.yaml", Action: artifact.PublishCreated}}, published.Changes) + + preview, err := repository.PreviewPruneDirectories(context.Background(), root, "api/external/clienthttp", []string{"active"}) + require.NoError(t, err) + require.Equal(t, []string{"stale"}, preview) + require.DirExists(t, filepath.Join(root, "api/external/clienthttp/stale")) + + removed, err := repository.PruneDirectories(context.Background(), root, "api/external/clienthttp", []string{"active"}) + require.NoError(t, err) + require.Equal(t, []string{"stale"}, removed) + require.NoDirExists(t, filepath.Join(root, "api/external/clienthttp/stale")) + require.FileExists(t, filepath.Join(root, "api/external/clienthttp/active/openapi.yaml")) +} + +func TestFilesystemRepoPreviewAndPruneRejectTheSameUnsafeEntries(t *testing.T) { + t.Parallel() + if runtime.GOOS == "windows" { + t.Skip("symlink policy requires symlink support") + } + + tests := []struct { + name string + parent string + setup func(*testing.T, string) + }{ + {name: "unsafe parent", parent: "../outside", setup: func(*testing.T, string) {}}, + {name: "parent file", parent: "managed", setup: func(t *testing.T, root string) { + t.Helper() + require.NoError(t, os.WriteFile(filepath.Join(root, "managed"), []byte("file"), 0o644)) + }}, + {name: "parent symlink", parent: "managed", setup: func(t *testing.T, root string) { + t.Helper() + outside := t.TempDir() + require.NoError(t, os.Symlink(outside, filepath.Join(root, "managed"))) + }}, + {name: "child file", parent: "managed", setup: func(t *testing.T, root string) { + t.Helper() + require.NoError(t, os.MkdirAll(filepath.Join(root, "managed"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(root, "managed", "stale"), []byte("file"), 0o644)) + }}, + {name: "child symlink", parent: "managed", setup: func(t *testing.T, root string) { + t.Helper() + require.NoError(t, os.MkdirAll(filepath.Join(root, "managed"), 0o755)) + require.NoError(t, os.Symlink(t.TempDir(), filepath.Join(root, "managed", "stale"))) + }}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + root := t.TempDir() + test.setup(t, root) + repository := workspacerepo.NewFilesystemRepo() + + _, previewErr := repository.PreviewPruneDirectories(context.Background(), root, test.parent, nil) + _, pruneErr := repository.PruneDirectories(context.Background(), root, test.parent, nil) + + require.ErrorIs(t, previewErr, fs.ErrInvalid) + require.ErrorIs(t, pruneErr, fs.ErrInvalid) + }) + } +} + +func TestFilesystemRepoPublishesManagedFileIdempotently(t *testing.T) { + t.Parallel() + + root := t.TempDir() + repository := workspacerepo.NewFilesystemRepo() + + published, err := repository.PublishFile(context.Background(), root, ".env.example", []byte("LOG_LEVEL=info\n")) + require.NoError(t, err) + require.Equal(t, artifact.PublishResult{Action: artifact.PublishCreated}, published) + require.FileExists(t, filepath.Join(root, ".env.example")) + + published, err = repository.PublishFile(context.Background(), root, ".env.example", []byte("LOG_LEVEL=info\n")) + require.NoError(t, err) + require.Equal(t, artifact.PublishResult{Action: artifact.PublishUnchanged}, published) + + published, err = repository.PublishFile(context.Background(), root, ".env.example", []byte("LOG_LEVEL=debug\n")) + require.NoError(t, err) + require.Equal(t, artifact.PublishResult{Action: artifact.PublishUpdated}, published) + content, err := os.ReadFile(filepath.Join(root, ".env.example")) + require.NoError(t, err) + require.Equal(t, []byte("LOG_LEVEL=debug\n"), content) +} + +func TestFilesystemRepoReportsPreciseDirectoryPublicationChanges(t *testing.T) { + t.Parallel() + + root := t.TempDir() + target := filepath.Join(root, "generated") + require.NoError(t, os.MkdirAll(target, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(target, "equal.go"), []byte("equal"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(target, "changed.go"), []byte("before"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(target, "stale.go"), []byte("stale"), 0o644)) + + repository := workspacerepo.NewFilesystemRepo() + desired := artifact.Tree{Files: []artifact.File{ + {Path: "equal.go", Content: []byte("equal"), Mode: 0o644}, + {Path: "changed.go", Content: []byte("after"), Mode: 0o644}, + {Path: "created.go", Content: []byte("created"), Mode: 0o644}, + }} + published, err := repository.PublishDirectory(context.Background(), root, "generated", desired) + + require.NoError(t, err) + require.Equal(t, artifact.PublishUpdated, published.Action) + require.Equal(t, []artifact.PublishChange{ + {Path: "changed.go", Action: artifact.PublishUpdated}, + {Path: "created.go", Action: artifact.PublishCreated}, + {Path: "equal.go", Action: artifact.PublishUnchanged}, + {Path: "stale.go", Action: artifact.PublishRemoved}, + }, published.Changes) + require.NoFileExists(t, filepath.Join(target, "stale.go")) + + published, err = repository.PublishDirectory(context.Background(), root, "generated", desired) + require.NoError(t, err) + require.Equal(t, artifact.PublishUnchanged, published.Action) + require.Equal(t, []artifact.PublishChange{ + {Path: "changed.go", Action: artifact.PublishUnchanged}, + {Path: "created.go", Action: artifact.PublishUnchanged}, + {Path: "equal.go", Action: artifact.PublishUnchanged}, + }, published.Changes) +} + +func TestFilesystemRepoLocatesOneOpenAPIEntrypoint(t *testing.T) { + t.Parallel() + + root := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(root, "api/external/clienthttp/remote/spec"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(root, "api/external/clienthttp/remote/spec/openapi.yaml"), []byte("openapi: 3.1.0\n"), 0o644)) + contractPath, err := workspacerepo.NewFilesystemRepo().ResolveContract(context.Background(), contract.Location{Root: root, RelativePath: "api/external/clienthttp/remote"}) + + require.NoError(t, err) + require.Equal(t, filepath.Join(root, "api/external/clienthttp/remote/spec/openapi.yaml"), contractPath) +} + +func TestFilesystemRepoResolvesLocalEntrypointWithoutDuplicatingItsPath(t *testing.T) { + t.Parallel() + + root := t.TempDir() + relative := "api/openapi/swagger.yaml" + require.NoError(t, os.MkdirAll(filepath.Join(root, "api/openapi"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(root, filepath.FromSlash(relative)), []byte("openapi: 3.1.0\n"), 0o644)) + + contractPath, err := workspacerepo.NewFilesystemRepo().ResolveContract(context.Background(), contract.Location{Root: root, RelativePath: relative, Entrypoint: relative, Local: true}) + + require.NoError(t, err) + require.Equal(t, filepath.Join(root, filepath.FromSlash(relative)), contractPath) +} + +func TestFilesystemRepoListsProtoFilesInProjectOrder(t *testing.T) { + t.Parallel() + + root := t.TempDir() + for _, name := range []string{ + "api/proto/zeta/zeta.service.proto", + "api/proto/alpha/alpha.common_types.proto", + "api/proto/README.md", + } { + filename := filepath.Join(root, filepath.FromSlash(name)) + require.NoError(t, os.MkdirAll(filepath.Dir(filename), 0o755)) + require.NoError(t, os.WriteFile(filename, []byte(name), 0o644)) + } + + files, err := workspacerepo.NewFilesystemRepo().ListProtoFiles(context.Background(), root, "api/proto") + + require.NoError(t, err) + require.Equal(t, []string{ + "api/proto/alpha/alpha.common_types.proto", + "api/proto/zeta/zeta.service.proto", + }, files) +} diff --git a/internal/service/contractsnapshot/filesystem_integration_test.go b/internal/service/contractsnapshot/filesystem_integration_test.go new file mode 100644 index 0000000..95fafcd --- /dev/null +++ b/internal/service/contractsnapshot/filesystem_integration_test.go @@ -0,0 +1,160 @@ +package contractsnapshot_test + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/devctllabs/devctl/internal/domain/contract" + "github.com/devctllabs/devctl/internal/domain/failure" + workspacerepo "github.com/devctllabs/devctl/internal/repository/workspace" + "github.com/devctllabs/devctl/internal/service/contractsnapshot" + "github.com/stretchr/testify/require" +) + +func TestLoaderUsesFilesystemReaderForCommittedProtoSnapshot(t *testing.T) { + t.Parallel() + + root := t.TempDir() + snapshotRoot := "api/external/grpc/client/billing" + writeSnapshotFile(t, root, snapshotRoot+"/.devctl-contract.json", `{ + "kind": "grpc", + "format": "proto", + "module_root": "api/proto/grpc", + "buf_config": "buf.yaml" +}`) + writeSnapshotFile(t, root, snapshotRoot+"/api/proto/grpc/billing/v1/service.proto", "syntax = \"proto3\";\n") + writeSnapshotFile(t, root, snapshotRoot+"/buf.yaml", "version: v2\n") + writeSnapshotFile(t, root, snapshotRoot+"/buf.lock", "deps: []\n") + + snapshot, err := contractsnapshot.New(workspacerepo.NewFilesystemRepo()).Load( + context.Background(), root, snapshotRoot, + contract.MetadataExpectation{Kind: "grpc", Format: "proto"}, + ) + + require.NoError(t, err) + require.Equal(t, "api/proto/grpc", snapshot.ModuleRoot) + require.Empty(t, snapshot.Entrypoint) + require.Equal(t, []string{ + "api/proto/grpc/billing/v1/service.proto", "buf.lock", "buf.yaml", + }, snapshotFilePaths(snapshot)) + require.Equal(t, "buf.yaml", snapshot.Metadata.BufConfig) +} + +func TestLoaderUsesFilesystemReaderForTypedInvalidMetadata(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + metadata string + prepare func(*testing.T, string, string) + field string + reason contract.MetadataInvalidReason + expectation contract.MetadataExpectation + }{ + { + name: "missing sidecar", field: ".devctl-contract.json", reason: contract.MetadataRequired, + expectation: contract.MetadataExpectation{Kind: "kafka", Format: "json"}, + }, + { + name: "wrong JSON type", metadata: `{"kind":"kafka","topic":42,"format":"json","entrypoint":"schema.json"}`, + field: "topic", reason: contract.MetadataInvalidType, + expectation: contract.MetadataExpectation{Kind: "kafka", Format: "json"}, + }, + { + name: "missing JSON entrypoint", metadata: `{"kind":"kafka","topic":"sample.events.created.v1","format":"json"}`, + field: "entrypoint", reason: contract.MetadataRequired, + expectation: contract.MetadataExpectation{Kind: "kafka", Format: "json"}, + }, + { + name: "absolute entrypoint", metadata: `{"kind":"kafka","topic":"sample.events.created.v1","format":"json","entrypoint":"/schema.json"}`, + field: "entrypoint", reason: contract.MetadataInvalidPath, + expectation: contract.MetadataExpectation{Kind: "kafka", Format: "json"}, + }, + { + name: "traversing module root", metadata: `{"kind":"grpc","format":"proto","module_root":"../proto","buf_config":"buf.yaml"}`, + field: "module_root", reason: contract.MetadataInvalidPath, + expectation: contract.MetadataExpectation{Kind: "grpc", Format: "proto"}, + }, + { + name: "missing referenced file", metadata: `{"kind":"kafka","topic":"sample.events.created.v1","format":"json","entrypoint":"schema.json"}`, + field: "entrypoint", reason: contract.MetadataNotFound, + expectation: contract.MetadataExpectation{Kind: "kafka", Format: "json"}, + }, + { + name: "symlink entrypoint", metadata: `{"kind":"kafka","topic":"sample.events.created.v1","format":"json","entrypoint":"schema.json"}`, + prepare: func(t *testing.T, root, snapshotRoot string) { + t.Helper() + writeSnapshotFile(t, root, snapshotRoot+"/real.json", `{"title":"Event"}`) + link := filepath.Join(root, filepath.FromSlash(snapshotRoot), "schema.json") + require.NoError(t, os.Symlink("real.json", link)) + }, + field: "entrypoint", reason: contract.MetadataNotRegular, + expectation: contract.MetadataExpectation{Kind: "kafka", Format: "json"}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + root := t.TempDir() + const snapshotRoot = "api/external/kafka/consumer/events" + if test.metadata != "" { + writeSnapshotFile(t, root, snapshotRoot+"/.devctl-contract.json", test.metadata) + } + if test.prepare != nil { + test.prepare(t, root, snapshotRoot) + } + + _, err := contractsnapshot.New(workspacerepo.NewFilesystemRepo()).Load( + context.Background(), root, snapshotRoot, test.expectation, + ) + + require.Equal(t, failure.InvalidInput, failure.CategoryOf(err)) + var metadataErr *contract.SnapshotMetadataError + require.ErrorAs(t, err, &metadataErr) + require.Equal(t, test.field, metadataErr.Field) + require.Equal(t, test.reason, metadataErr.Reason) + require.Equal(t, "devctl sync", metadataErr.Hint) + }) + } +} + +func TestLoaderUsesFilesystemReaderForRawKafkaWithoutFiles(t *testing.T) { + t.Parallel() + + root := t.TempDir() + const snapshotRoot = "api/external/kafka/consumer/events" + writeSnapshotFile(t, root, snapshotRoot+"/.devctl-contract.json", `{ + "kind":"kafka", + "topic":"sample.events.created.v1", + "format":"raw" +}`) + + snapshot, err := contractsnapshot.New(workspacerepo.NewFilesystemRepo()).Load( + context.Background(), root, snapshotRoot, + contract.MetadataExpectation{Kind: "kafka", Topic: "sample.events.created.v1", Format: "raw"}, + ) + + require.NoError(t, err) + require.Empty(t, snapshot.Files) + require.Empty(t, snapshot.Entrypoint) + require.Empty(t, snapshot.ModuleRoot) +} + +func writeSnapshotFile(t *testing.T, root, name, content string) { + t.Helper() + filename := filepath.Join(root, filepath.FromSlash(name)) + require.NoError(t, os.MkdirAll(filepath.Dir(filename), 0o755)) + require.NoError(t, os.WriteFile(filename, []byte(content), 0o644)) +} + +func snapshotFilePaths(snapshot contract.Snapshot) []string { + paths := make([]string, len(snapshot.Files)) + for index, file := range snapshot.Files { + paths[index] = file.Path + } + return paths +} diff --git a/internal/service/contractsnapshot/loader.go b/internal/service/contractsnapshot/loader.go new file mode 100644 index 0000000..9e782ba --- /dev/null +++ b/internal/service/contractsnapshot/loader.go @@ -0,0 +1,126 @@ +package contractsnapshot + +import ( + "context" + "errors" + "fmt" + "io/fs" + "path" + "sort" + "strings" + + "github.com/devctllabs/devctl/internal/domain/contract" +) + +const metadataFilename = ".devctl-contract.json" + +//go:generate go tool mockgen -destination mocks/loader.go -package mocks -typed . Reader + +// Reader provides the contained committed files needed to reconstruct a Snapshot. +type Reader interface { + // ReadFile returns a regular non-symlink file below root with its relative path and permission bits. + ReadFile(ctx context.Context, root, name string) (contract.File, error) + // ReadTree returns every regular non-symlink file below a contained directory in deterministic order. + ReadTree(ctx context.Context, root, directory string) ([]contract.File, error) +} + +// Loader reconstructs and validates committed Contract Snapshots. +type Loader struct { + reader Reader +} + +func New(reader Reader) *Loader { + return &Loader{reader: reader} +} + +// Load reconstructs the committed Snapshot rooted at treeRoot and validates it against expected. +func (l *Loader) Load( + ctx context.Context, + root string, + treeRoot string, + expected contract.MetadataExpectation, +) (contract.Snapshot, error) { + metadataFile, err := l.reader.ReadFile(ctx, root, path.Join(treeRoot, metadataFilename)) + if err != nil { + reason := contract.MetadataNotRegular + if errors.Is(err, fs.ErrNotExist) { + reason = contract.MetadataRequired + } + return contract.Snapshot{}, metadataError(metadataFilename, reason, err) + } + metadata, err := contract.DecodeMetadata(metadataFile.Content) + if err != nil { + return contract.Snapshot{}, fmt.Errorf("contract.DecodeMetadata: %w", err) + } + if err := contract.ValidateMetadata(metadata, expected); err != nil { + return contract.Snapshot{}, fmt.Errorf("contract.ValidateMetadata: %w", err) + } + if err := l.validateReferences(ctx, root, treeRoot, metadata); err != nil { + return contract.Snapshot{}, err + } + files, err := l.reader.ReadTree(ctx, root, treeRoot) + if err != nil { + return contract.Snapshot{}, metadataError("files", contract.MetadataNotRegular, err) + } + snapshot := contract.Snapshot{ + ModuleRoot: metadata.ModuleRoot, + Entrypoint: metadata.Entrypoint, + Files: rebaseFiles(files, treeRoot), + Metadata: &metadata, + } + if err := contract.ValidateSnapshot(snapshot, expected); err != nil { + return contract.Snapshot{}, fmt.Errorf("contract.ValidateSnapshot: %w", err) + } + return snapshot, nil +} + +func (l *Loader) validateReferences( + ctx context.Context, + root string, + treeRoot string, + metadata contract.Metadata, +) error { + for _, reference := range []struct{ field, name string }{ + {field: "entrypoint", name: metadata.Entrypoint}, + {field: "buf_config", name: metadata.BufConfig}, + } { + if reference.name == "" { + continue + } + if _, err := l.reader.ReadFile(ctx, root, path.Join(treeRoot, reference.name)); err != nil { + return referenceError(reference.field, err) + } + } + if metadata.ModuleRoot == "" { + return nil + } + if _, err := l.reader.ReadTree(ctx, root, path.Join(treeRoot, metadata.ModuleRoot)); err != nil { + return referenceError("module_root", err) + } + return nil +} + +func referenceError(field string, err error) error { + reason := contract.MetadataNotRegular + if errors.Is(err, fs.ErrNotExist) { + reason = contract.MetadataNotFound + } + return metadataError(field, reason, err) +} + +func rebaseFiles(files []contract.File, treeRoot string) []contract.File { + prefix := strings.TrimSuffix(path.Clean(treeRoot), "/") + "/" + rebased := make([]contract.File, 0, len(files)) + for _, file := range files { + file.Path = strings.TrimPrefix(file.Path, prefix) + if file.Path != metadataFilename { + rebased = append(rebased, file) + } + } + sort.Slice(rebased, func(i, j int) bool { return rebased[i].Path < rebased[j].Path }) + return rebased +} + +func metadataError(field string, reason contract.MetadataInvalidReason, cause error) error { + return &contract.SnapshotMetadataError{Field: field, Reason: reason, Hint: "devctl sync", Cause: cause} +} diff --git a/internal/service/contractsnapshot/loader_test.go b/internal/service/contractsnapshot/loader_test.go new file mode 100644 index 0000000..643c8c5 --- /dev/null +++ b/internal/service/contractsnapshot/loader_test.go @@ -0,0 +1,187 @@ +package contractsnapshot_test + +import ( + "context" + "errors" + "io/fs" + "testing" + + "github.com/devctllabs/devctl/internal/domain/contract" + "github.com/devctllabs/devctl/internal/service/contractsnapshot" + "github.com/devctllabs/devctl/internal/service/contractsnapshot/mocks" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" +) + +func TestLoaderLoadsCommittedProtoSnapshot(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + reader := mocks.NewMockReader(ctrl) + loader := contractsnapshot.New(reader) + const root = "/project" + const treeRoot = "api/external/grpc/client/billing" + metadata := []byte(`{"kind":"grpc","format":"proto","module_root":"api/proto/grpc","buf_config":"buf.yaml"}`) + + reader.EXPECT().ReadFile(gomock.Any(), root, treeRoot+"/.devctl-contract.json").Return( + contract.File{Path: treeRoot + "/.devctl-contract.json", Content: metadata, Mode: 0o644}, nil, + ) + reader.EXPECT().ReadFile(gomock.Any(), root, treeRoot+"/buf.yaml").Return( + contract.File{Path: treeRoot + "/buf.yaml", Content: []byte("version: v2\n"), Mode: 0o644}, nil, + ) + reader.EXPECT().ReadTree(gomock.Any(), root, treeRoot+"/api/proto/grpc").Return([]contract.File{{ + Path: treeRoot + "/api/proto/grpc/billing/v1/service.proto", + }}, nil) + reader.EXPECT().ReadTree(gomock.Any(), root, treeRoot).Return([]contract.File{ + {Path: treeRoot + "/buf.yaml", Content: []byte("version: v2\n"), Mode: 0o644}, + {Path: treeRoot + "/.devctl-contract.json", Content: metadata, Mode: 0o644}, + {Path: treeRoot + "/api/proto/grpc/billing/v1/service.proto", Content: []byte("syntax = \"proto3\";\n"), Mode: 0o600}, + }, nil) + + snapshot, err := loader.Load( + context.Background(), root, treeRoot, + contract.MetadataExpectation{Kind: "grpc", Format: "proto"}, + ) + + require.NoError(t, err) + require.Equal(t, "api/proto/grpc", snapshot.ModuleRoot) + require.Empty(t, snapshot.Entrypoint) + require.Equal(t, []contract.File{ + {Path: "api/proto/grpc/billing/v1/service.proto", Content: []byte("syntax = \"proto3\";\n"), Mode: 0o600}, + {Path: "buf.yaml", Content: []byte("version: v2\n"), Mode: 0o644}, + }, snapshot.Files) + require.Equal(t, &contract.Metadata{ + Kind: "grpc", Format: "proto", ModuleRoot: "api/proto/grpc", BufConfig: "buf.yaml", + }, snapshot.Metadata) +} + +func TestLoaderAcceptsRawKafkaMetadataWithoutFiles(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + reader := mocks.NewMockReader(ctrl) + loader := contractsnapshot.New(reader) + const root = "/project" + const treeRoot = "api/external/kafka/consumer/events" + metadata := []byte(`{"kind":"kafka","topic":"sample.events.created.v1","format":"raw"}`) + reader.EXPECT().ReadFile(gomock.Any(), root, treeRoot+"/.devctl-contract.json").Return( + contract.File{Content: metadata}, nil, + ) + reader.EXPECT().ReadTree(gomock.Any(), root, treeRoot).Return([]contract.File{{ + Path: treeRoot + "/.devctl-contract.json", Content: metadata, Mode: 0o644, + }}, nil) + + snapshot, err := loader.Load(context.Background(), root, treeRoot, contract.MetadataExpectation{ + Kind: "kafka", Topic: "sample.events.created.v1", Format: "raw", + }) + + require.NoError(t, err) + require.Empty(t, snapshot.Files) + require.Empty(t, snapshot.Entrypoint) + require.Empty(t, snapshot.ModuleRoot) +} + +func TestLoaderReportsPreciseMetadataErrors(t *testing.T) { + t.Parallel() + + errNotRegular := errors.New("not a regular file") + tests := []struct { + name string + load func(*mocks.MockReader) + expected contract.MetadataExpectation + field string + reason contract.MetadataInvalidReason + cause error + }{ + { + name: "missing sidecar", + load: func(reader *mocks.MockReader) { + reader.EXPECT().ReadFile(gomock.Any(), "/project", "contracts/.devctl-contract.json").Return( + contract.File{}, fs.ErrNotExist, + ) + }, + expected: contract.MetadataExpectation{Kind: "kafka", Format: "json"}, + field: ".devctl-contract.json", reason: contract.MetadataRequired, cause: fs.ErrNotExist, + }, + { + name: "invalid metadata type", + load: func(reader *mocks.MockReader) { + reader.EXPECT().ReadFile(gomock.Any(), "/project", "contracts/.devctl-contract.json").Return( + contract.File{Content: []byte(`{"kind":"kafka","topic":42,"format":"json","entrypoint":"schema.json"}`)}, nil, + ) + }, + expected: contract.MetadataExpectation{Kind: "kafka", Format: "json"}, + field: "topic", reason: contract.MetadataInvalidType, + }, + { + name: "missing entrypoint", + load: func(reader *mocks.MockReader) { + reader.EXPECT().ReadFile(gomock.Any(), "/project", "contracts/.devctl-contract.json").Return( + contract.File{Content: []byte(`{"kind":"kafka","topic":"sample.events.created.v1","format":"json","entrypoint":"schema.json"}`)}, nil, + ) + reader.EXPECT().ReadFile(gomock.Any(), "/project", "contracts/schema.json").Return( + contract.File{}, fs.ErrNotExist, + ) + }, + expected: contract.MetadataExpectation{Kind: "kafka", Topic: "sample.events.created.v1", Format: "json"}, + field: "entrypoint", reason: contract.MetadataNotFound, cause: fs.ErrNotExist, + }, + { + name: "non regular buf config", + load: func(reader *mocks.MockReader) { + reader.EXPECT().ReadFile(gomock.Any(), "/project", "contracts/.devctl-contract.json").Return( + contract.File{Content: []byte(`{"kind":"grpc","format":"proto","module_root":"proto","buf_config":"buf.yaml"}`)}, nil, + ) + reader.EXPECT().ReadFile(gomock.Any(), "/project", "contracts/buf.yaml").Return( + contract.File{}, errNotRegular, + ) + }, + expected: contract.MetadataExpectation{Kind: "grpc", Format: "proto"}, + field: "buf_config", reason: contract.MetadataNotRegular, cause: errNotRegular, + }, + { + name: "missing module root", + load: func(reader *mocks.MockReader) { + reader.EXPECT().ReadFile(gomock.Any(), "/project", "contracts/.devctl-contract.json").Return( + contract.File{Content: []byte(`{"kind":"grpc","format":"proto","module_root":"proto","buf_config":"buf.yaml"}`)}, nil, + ) + reader.EXPECT().ReadFile(gomock.Any(), "/project", "contracts/buf.yaml").Return(contract.File{}, nil) + reader.EXPECT().ReadTree(gomock.Any(), "/project", "contracts/proto").Return(nil, fs.ErrNotExist) + }, + expected: contract.MetadataExpectation{Kind: "grpc", Format: "proto"}, + field: "module_root", reason: contract.MetadataNotFound, cause: fs.ErrNotExist, + }, + { + name: "cancelled tree read", + load: func(reader *mocks.MockReader) { + reader.EXPECT().ReadFile(gomock.Any(), "/project", "contracts/.devctl-contract.json").Return( + contract.File{Content: []byte(`{"kind":"kafka","topic":"sample.events.created.v1","format":"raw"}`)}, nil, + ) + reader.EXPECT().ReadTree(gomock.Any(), "/project", "contracts").Return(nil, context.Canceled) + }, + expected: contract.MetadataExpectation{Kind: "kafka", Topic: "sample.events.created.v1", Format: "raw"}, + field: "files", reason: contract.MetadataNotRegular, cause: context.Canceled, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + reader := mocks.NewMockReader(ctrl) + test.load(reader) + + _, err := contractsnapshot.New(reader).Load(context.Background(), "/project", "contracts", test.expected) + + var metadataErr *contract.SnapshotMetadataError + require.ErrorAs(t, err, &metadataErr) + require.Equal(t, test.field, metadataErr.Field) + require.Equal(t, test.reason, metadataErr.Reason) + require.Equal(t, "devctl sync", metadataErr.Hint) + if test.cause != nil { + require.ErrorIs(t, err, test.cause) + } + }) + } +} diff --git a/internal/service/contractsnapshot/mocks/loader.go b/internal/service/contractsnapshot/mocks/loader.go new file mode 100644 index 0000000..176a0f2 --- /dev/null +++ b/internal/service/contractsnapshot/mocks/loader.go @@ -0,0 +1,120 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/devctllabs/devctl/internal/service/contractsnapshot (interfaces: Reader) +// +// Generated by this command: +// +// mockgen -destination mocks/loader.go -package mocks -typed . Reader +// + +// Package mocks is a generated GoMock package. +package mocks + +import ( + context "context" + reflect "reflect" + + contract "github.com/devctllabs/devctl/internal/domain/contract" + gomock "go.uber.org/mock/gomock" +) + +// MockReader is a mock of Reader interface. +type MockReader struct { + ctrl *gomock.Controller + recorder *MockReaderMockRecorder + isgomock struct{} +} + +// MockReaderMockRecorder is the mock recorder for MockReader. +type MockReaderMockRecorder struct { + mock *MockReader +} + +// NewMockReader creates a new mock instance. +func NewMockReader(ctrl *gomock.Controller) *MockReader { + mock := &MockReader{ctrl: ctrl} + mock.recorder = &MockReaderMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockReader) EXPECT() *MockReaderMockRecorder { + return m.recorder +} + +// ReadFile mocks base method. +func (m *MockReader) ReadFile(ctx context.Context, root, name string) (contract.File, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ReadFile", ctx, root, name) + ret0, _ := ret[0].(contract.File) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ReadFile indicates an expected call of ReadFile. +func (mr *MockReaderMockRecorder) ReadFile(ctx, root, name any) *MockReaderReadFileCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ReadFile", reflect.TypeOf((*MockReader)(nil).ReadFile), ctx, root, name) + return &MockReaderReadFileCall{Call: call} +} + +// MockReaderReadFileCall wrap *gomock.Call +type MockReaderReadFileCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockReaderReadFileCall) Return(arg0 contract.File, arg1 error) *MockReaderReadFileCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockReaderReadFileCall) Do(f func(context.Context, string, string) (contract.File, error)) *MockReaderReadFileCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockReaderReadFileCall) DoAndReturn(f func(context.Context, string, string) (contract.File, error)) *MockReaderReadFileCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// ReadTree mocks base method. +func (m *MockReader) ReadTree(ctx context.Context, root, directory string) ([]contract.File, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ReadTree", ctx, root, directory) + ret0, _ := ret[0].([]contract.File) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ReadTree indicates an expected call of ReadTree. +func (mr *MockReaderMockRecorder) ReadTree(ctx, root, directory any) *MockReaderReadTreeCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ReadTree", reflect.TypeOf((*MockReader)(nil).ReadTree), ctx, root, directory) + return &MockReaderReadTreeCall{Call: call} +} + +// MockReaderReadTreeCall wrap *gomock.Call +type MockReaderReadTreeCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockReaderReadTreeCall) Return(arg0 []contract.File, arg1 error) *MockReaderReadTreeCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockReaderReadTreeCall) Do(f func(context.Context, string, string) ([]contract.File, error)) *MockReaderReadTreeCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockReaderReadTreeCall) DoAndReturn(f func(context.Context, string, string) ([]contract.File, error)) *MockReaderReadTreeCall { + c.Call = c.Call.DoAndReturn(f) + return c +} diff --git a/internal/service/generate/config.go b/internal/service/generate/config.go new file mode 100644 index 0000000..1c42665 --- /dev/null +++ b/internal/service/generate/config.go @@ -0,0 +1,21 @@ +package generate + +import ( + "fmt" + + generatedomain "github.com/devctllabs/devctl/internal/domain/generate" + projectdomain "github.com/devctllabs/devctl/internal/domain/project" + "github.com/devctllabs/devctl/internal/service/runtimeconfig" +) + +func generateConfig(manifest projectdomain.Manifest) (generatedomain.Output, error) { + catalog, err := projectdomain.NewRuntimeConfigCatalog(manifest) + if err != nil { + return generatedomain.Output{}, fmt.Errorf("project.NewRuntimeConfigCatalog: %w", err) + } + output, err := runtimeconfig.Render(catalog) + if err != nil { + return generatedomain.Output{}, fmt.Errorf("runtimeconfig.Render: %w", err) + } + return generatedomain.Output{Directory: output.Directory, Files: output.Files}, nil +} diff --git a/internal/service/generate/mocks/service.go b/internal/service/generate/mocks/service.go new file mode 100644 index 0000000..28063d3 --- /dev/null +++ b/internal/service/generate/mocks/service.go @@ -0,0 +1,311 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/devctllabs/devctl/internal/service/generate (interfaces: ProjectRepository,TargetResolver,GeneratorClient,WorkspaceRepository) +// +// Generated by this command: +// +// mockgen -destination mocks/service.go -package mocks -typed . ProjectRepository,TargetResolver,GeneratorClient,WorkspaceRepository +// + +// Package mocks is a generated GoMock package. +package mocks + +import ( + context "context" + reflect "reflect" + + artifact "github.com/devctllabs/devctl/internal/domain/artifact" + generate "github.com/devctllabs/devctl/internal/domain/generate" + project "github.com/devctllabs/devctl/internal/domain/project" + gomock "go.uber.org/mock/gomock" +) + +// MockProjectRepository is a mock of ProjectRepository interface. +type MockProjectRepository struct { + ctrl *gomock.Controller + recorder *MockProjectRepositoryMockRecorder + isgomock struct{} +} + +// MockProjectRepositoryMockRecorder is the mock recorder for MockProjectRepository. +type MockProjectRepositoryMockRecorder struct { + mock *MockProjectRepository +} + +// NewMockProjectRepository creates a new mock instance. +func NewMockProjectRepository(ctrl *gomock.Controller) *MockProjectRepository { + mock := &MockProjectRepository{ctrl: ctrl} + mock.recorder = &MockProjectRepositoryMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockProjectRepository) EXPECT() *MockProjectRepositoryMockRecorder { + return m.recorder +} + +// LoadProject mocks base method. +func (m *MockProjectRepository) LoadProject(ctx context.Context, manifestPath string) (project.Project, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "LoadProject", ctx, manifestPath) + ret0, _ := ret[0].(project.Project) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// LoadProject indicates an expected call of LoadProject. +func (mr *MockProjectRepositoryMockRecorder) LoadProject(ctx, manifestPath any) *MockProjectRepositoryLoadProjectCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "LoadProject", reflect.TypeOf((*MockProjectRepository)(nil).LoadProject), ctx, manifestPath) + return &MockProjectRepositoryLoadProjectCall{Call: call} +} + +// MockProjectRepositoryLoadProjectCall wrap *gomock.Call +type MockProjectRepositoryLoadProjectCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockProjectRepositoryLoadProjectCall) Return(arg0 project.Project, arg1 error) *MockProjectRepositoryLoadProjectCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockProjectRepositoryLoadProjectCall) Do(f func(context.Context, string) (project.Project, error)) *MockProjectRepositoryLoadProjectCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockProjectRepositoryLoadProjectCall) DoAndReturn(f func(context.Context, string) (project.Project, error)) *MockProjectRepositoryLoadProjectCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// MockTargetResolver is a mock of TargetResolver interface. +type MockTargetResolver struct { + ctrl *gomock.Controller + recorder *MockTargetResolverMockRecorder + isgomock struct{} +} + +// MockTargetResolverMockRecorder is the mock recorder for MockTargetResolver. +type MockTargetResolverMockRecorder struct { + mock *MockTargetResolver +} + +// NewMockTargetResolver creates a new mock instance. +func NewMockTargetResolver(ctrl *gomock.Controller) *MockTargetResolver { + mock := &MockTargetResolver{ctrl: ctrl} + mock.recorder = &MockTargetResolverMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockTargetResolver) EXPECT() *MockTargetResolverMockRecorder { + return m.recorder +} + +// Resolve mocks base method. +func (m *MockTargetResolver) Resolve(ctx context.Context, selected project.Project, target project.Target) (project.Target, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Resolve", ctx, selected, target) + ret0, _ := ret[0].(project.Target) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Resolve indicates an expected call of Resolve. +func (mr *MockTargetResolverMockRecorder) Resolve(ctx, selected, target any) *MockTargetResolverResolveCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Resolve", reflect.TypeOf((*MockTargetResolver)(nil).Resolve), ctx, selected, target) + return &MockTargetResolverResolveCall{Call: call} +} + +// MockTargetResolverResolveCall wrap *gomock.Call +type MockTargetResolverResolveCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockTargetResolverResolveCall) Return(arg0 project.Target, arg1 error) *MockTargetResolverResolveCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockTargetResolverResolveCall) Do(f func(context.Context, project.Project, project.Target) (project.Target, error)) *MockTargetResolverResolveCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockTargetResolverResolveCall) DoAndReturn(f func(context.Context, project.Project, project.Target) (project.Target, error)) *MockTargetResolverResolveCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// MockGeneratorClient is a mock of GeneratorClient interface. +type MockGeneratorClient struct { + ctrl *gomock.Controller + recorder *MockGeneratorClientMockRecorder + isgomock struct{} +} + +// MockGeneratorClientMockRecorder is the mock recorder for MockGeneratorClient. +type MockGeneratorClientMockRecorder struct { + mock *MockGeneratorClient +} + +// NewMockGeneratorClient creates a new mock instance. +func NewMockGeneratorClient(ctrl *gomock.Controller) *MockGeneratorClient { + mock := &MockGeneratorClient{ctrl: ctrl} + mock.recorder = &MockGeneratorClientMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockGeneratorClient) EXPECT() *MockGeneratorClientMockRecorder { + return m.recorder +} + +// Generate mocks base method. +func (m *MockGeneratorClient) Generate(ctx context.Context, arg1 project.Project, target project.Target) (generate.Output, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Generate", ctx, arg1, target) + ret0, _ := ret[0].(generate.Output) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Generate indicates an expected call of Generate. +func (mr *MockGeneratorClientMockRecorder) Generate(ctx, arg1, target any) *MockGeneratorClientGenerateCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Generate", reflect.TypeOf((*MockGeneratorClient)(nil).Generate), ctx, arg1, target) + return &MockGeneratorClientGenerateCall{Call: call} +} + +// MockGeneratorClientGenerateCall wrap *gomock.Call +type MockGeneratorClientGenerateCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockGeneratorClientGenerateCall) Return(arg0 generate.Output, arg1 error) *MockGeneratorClientGenerateCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockGeneratorClientGenerateCall) Do(f func(context.Context, project.Project, project.Target) (generate.Output, error)) *MockGeneratorClientGenerateCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockGeneratorClientGenerateCall) DoAndReturn(f func(context.Context, project.Project, project.Target) (generate.Output, error)) *MockGeneratorClientGenerateCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// MockWorkspaceRepository is a mock of WorkspaceRepository interface. +type MockWorkspaceRepository struct { + ctrl *gomock.Controller + recorder *MockWorkspaceRepositoryMockRecorder + isgomock struct{} +} + +// MockWorkspaceRepositoryMockRecorder is the mock recorder for MockWorkspaceRepository. +type MockWorkspaceRepositoryMockRecorder struct { + mock *MockWorkspaceRepository +} + +// NewMockWorkspaceRepository creates a new mock instance. +func NewMockWorkspaceRepository(ctrl *gomock.Controller) *MockWorkspaceRepository { + mock := &MockWorkspaceRepository{ctrl: ctrl} + mock.recorder = &MockWorkspaceRepositoryMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockWorkspaceRepository) EXPECT() *MockWorkspaceRepositoryMockRecorder { + return m.recorder +} + +// PublishDirectory mocks base method. +func (m *MockWorkspaceRepository) PublishDirectory(ctx context.Context, root, target string, tree artifact.Tree) (artifact.PublishResult, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PublishDirectory", ctx, root, target, tree) + ret0, _ := ret[0].(artifact.PublishResult) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// PublishDirectory indicates an expected call of PublishDirectory. +func (mr *MockWorkspaceRepositoryMockRecorder) PublishDirectory(ctx, root, target, tree any) *MockWorkspaceRepositoryPublishDirectoryCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PublishDirectory", reflect.TypeOf((*MockWorkspaceRepository)(nil).PublishDirectory), ctx, root, target, tree) + return &MockWorkspaceRepositoryPublishDirectoryCall{Call: call} +} + +// MockWorkspaceRepositoryPublishDirectoryCall wrap *gomock.Call +type MockWorkspaceRepositoryPublishDirectoryCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockWorkspaceRepositoryPublishDirectoryCall) Return(arg0 artifact.PublishResult, arg1 error) *MockWorkspaceRepositoryPublishDirectoryCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockWorkspaceRepositoryPublishDirectoryCall) Do(f func(context.Context, string, string, artifact.Tree) (artifact.PublishResult, error)) *MockWorkspaceRepositoryPublishDirectoryCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockWorkspaceRepositoryPublishDirectoryCall) DoAndReturn(f func(context.Context, string, string, artifact.Tree) (artifact.PublishResult, error)) *MockWorkspaceRepositoryPublishDirectoryCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// PublishFile mocks base method. +func (m *MockWorkspaceRepository) PublishFile(ctx context.Context, root, target string, content []byte) (artifact.PublishResult, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PublishFile", ctx, root, target, content) + ret0, _ := ret[0].(artifact.PublishResult) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// PublishFile indicates an expected call of PublishFile. +func (mr *MockWorkspaceRepositoryMockRecorder) PublishFile(ctx, root, target, content any) *MockWorkspaceRepositoryPublishFileCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PublishFile", reflect.TypeOf((*MockWorkspaceRepository)(nil).PublishFile), ctx, root, target, content) + return &MockWorkspaceRepositoryPublishFileCall{Call: call} +} + +// MockWorkspaceRepositoryPublishFileCall wrap *gomock.Call +type MockWorkspaceRepositoryPublishFileCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockWorkspaceRepositoryPublishFileCall) Return(arg0 artifact.PublishResult, arg1 error) *MockWorkspaceRepositoryPublishFileCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockWorkspaceRepositoryPublishFileCall) Do(f func(context.Context, string, string, []byte) (artifact.PublishResult, error)) *MockWorkspaceRepositoryPublishFileCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockWorkspaceRepositoryPublishFileCall) DoAndReturn(f func(context.Context, string, string, []byte) (artifact.PublishResult, error)) *MockWorkspaceRepositoryPublishFileCall { + c.Call = c.Call.DoAndReturn(f) + return c +} diff --git a/internal/service/generate/publish.go b/internal/service/generate/publish.go new file mode 100644 index 0000000..e6519ec --- /dev/null +++ b/internal/service/generate/publish.go @@ -0,0 +1,42 @@ +package generate + +import ( + "context" + "fmt" + "path" + "sort" + + "github.com/devctllabs/devctl/internal/domain/artifact" + generatedomain "github.com/devctllabs/devctl/internal/domain/generate" + projectdomain "github.com/devctllabs/devctl/internal/domain/project" +) + +func (s *Service) publish(ctx context.Context, project projectdomain.Project, target projectdomain.Target, output generatedomain.Output) ([]generatedomain.Change, error) { + var changes []generatedomain.Change + published, err := s.workspace.PublishDirectory(ctx, project.Root, target.OutputDir, output.Directory) + if err != nil { + return changes, fmt.Errorf("workspace.PublishDirectory: %w", err) + } + for _, change := range published.Changes { + changes = append(changes, generatedomain.Change{ + Target: target.ID, Path: path.Join(target.OutputDir, change.Path), + Action: generatedomain.ChangeAction(change.Action), + }) + } + for _, file := range sortedArtifacts(output.Files) { + published, err = s.workspace.PublishFile(ctx, project.Root, file.Path, file.Content) + if err != nil { + return changes, fmt.Errorf("workspace.PublishFile: %w", err) + } + changes = append(changes, generatedomain.Change{ + Target: target.ID, Path: file.Path, Action: generatedomain.ChangeAction(published.Action), + }) + } + return changes, nil +} + +func sortedArtifacts(tree artifact.Tree) []artifact.File { + files := append([]artifact.File(nil), tree.Files...) + sort.Slice(files, func(i, j int) bool { return files[i].Path < files[j].Path }) + return files +} diff --git a/internal/service/generate/service.go b/internal/service/generate/service.go new file mode 100644 index 0000000..422d829 --- /dev/null +++ b/internal/service/generate/service.go @@ -0,0 +1,137 @@ +package generate + +import ( + "context" + "fmt" + + "github.com/devctllabs/devctl/internal/domain/artifact" + generatedomain "github.com/devctllabs/devctl/internal/domain/generate" + projectdomain "github.com/devctllabs/devctl/internal/domain/project" + "go.uber.org/zap" +) + +//go:generate go tool mockgen -destination mocks/service.go -package mocks -typed . ProjectRepository,TargetResolver,GeneratorClient,WorkspaceRepository + +// ProjectRepository resolves the valid project selected for generation. +type ProjectRepository interface { + // LoadProject returns a structurally and semantically valid project or an execution error. + LoadProject(ctx context.Context, manifestPath string) (projectdomain.Project, error) +} + +// TargetResolver attaches the concrete input required to execute one Target. +type TargetResolver interface { + // Resolve attaches the concrete input required to execute target in selected Project. + Resolve(ctx context.Context, selected projectdomain.Project, target projectdomain.Target) (projectdomain.Target, error) +} + +// GeneratorClient generates unpublished Managed Output for one supported Target. +type GeneratorClient interface { + // Generate returns unpublished managed output; it must not write into the project workspace. + Generate(ctx context.Context, project projectdomain.Project, target projectdomain.Target) (generatedomain.Output, error) +} + +// WorkspaceRepository atomically publishes managed generation output. +type WorkspaceRepository interface { + // PublishFile atomically publishes one contained auxiliary file and reports whether bytes changed. + PublishFile(ctx context.Context, root, target string, content []byte) (artifact.PublishResult, error) + // PublishDirectory atomically replaces one contained target with the complete tree and reports whether content changed. + PublishDirectory(ctx context.Context, root, target string, tree artifact.Tree) (artifact.PublishResult, error) +} + +type Service struct { + logger *zap.Logger + projects ProjectRepository + inputs TargetResolver + generator GeneratorClient + workspace WorkspaceRepository +} + +// Dependencies names the required generation capabilities passed to New. +type Dependencies struct { + Projects ProjectRepository + Inputs TargetResolver + Generator GeneratorClient + Workspace WorkspaceRepository +} + +func New(logger *zap.Logger, dependencies Dependencies) *Service { + return &Service{ + logger: logger, projects: dependencies.Projects, + inputs: dependencies.Inputs, generator: dependencies.Generator, workspace: dependencies.Workspace, + } +} + +// Generate executes selected targets sequentially in deterministic order and publishes each target atomically. +// Dry-run invokes neither generator nor publisher; a failure returns completed changes without rollback. +func (s *Service) Generate(ctx context.Context, command generatedomain.Command) (generatedomain.Result, error) { + result := generatedomain.Result{Targets: []string{}, Changes: []generatedomain.Change{}, DryRun: command.DryRun} + project, err := s.projects.LoadProject(ctx, command.ManifestPath) + if err != nil { + return result, fmt.Errorf("projects.LoadProject: %w", err) + } + targets, err := generationTargets(project.Manifest, command.Family, command.Target) + if err != nil { + return result, fmt.Errorf("generationTargets: %w", err) + } + for _, target := range targets { + changes, targetErr := s.generateOne(ctx, project, target, command.DryRun) + result.Changes = append(result.Changes, changes...) + if targetErr != nil { + return result, targetErr + } + result.Targets = append(result.Targets, target.ID) + } + s.logger.Debug("generation completed", zap.Int("targets", len(result.Targets))) + return result, nil +} + +func (s *Service) generateOne( + ctx context.Context, + project projectdomain.Project, + target projectdomain.Target, + dryRun bool, +) ([]generatedomain.Change, error) { + if err := ctx.Err(); err != nil { + return nil, fmt.Errorf("ctx.Err: %w", err) + } + if dryRun { + return plannedGeneration(target), nil + } + resolved := target + if target.Family != "config" { + var err error + resolved, err = s.inputs.Resolve(ctx, project, target) + if err != nil { + if target.Family == "http" { + operationErr := &generatedomain.OperationError{Operation: generatedomain.OperationLocateContract, Target: target.ID, Path: target.Location.Entrypoint, Kind: generatedomain.FailureUnavailable, Cause: err} + return nil, fmt.Errorf("inputs.Resolve: %w", operationErr) + } + return nil, fmt.Errorf("inputs.Resolve: %w", err) + } + } + if resolved.Family == "kafka" && resolved.Format == "raw" { + return nil, nil + } + output, err := s.generateTarget(ctx, project, resolved) + if err != nil { + operationErr := &generatedomain.OperationError{Operation: generatedomain.OperationRunGenerator, Target: resolved.ID, Path: resolved.OutputDir, Kind: generatedomain.FailureUnavailable, Cause: err} + return nil, fmt.Errorf("s.generateTarget: %w", operationErr) + } + changes, err := s.publish(ctx, project, resolved, output) + if err != nil { + operationErr := &generatedomain.OperationError{Operation: generatedomain.OperationPublishOutput, Target: resolved.ID, Path: resolved.OutputDir, Kind: generatedomain.FailureUnavailable, Cause: err} + return changes, fmt.Errorf("s.publish: %w", operationErr) + } + return changes, nil +} + +func (s *Service) generateTarget(ctx context.Context, project projectdomain.Project, target projectdomain.Target) (generatedomain.Output, error) { + if target.Family == "config" { + return generateConfig(project.Manifest) + } + output, err := s.generator.Generate(ctx, project, target) + if err != nil { + return generatedomain.Output{}, fmt.Errorf("generator.Generate: %w", err) + } + return output, nil +} diff --git a/internal/service/generate/service_test.go b/internal/service/generate/service_test.go new file mode 100644 index 0000000..770cebe --- /dev/null +++ b/internal/service/generate/service_test.go @@ -0,0 +1,943 @@ +package generate_test + +import ( + "context" + "errors" + "go/ast" + "go/parser" + "go/token" + "os" + "strconv" + "testing" + + "github.com/devctllabs/devctl/internal/domain/artifact" + "github.com/devctllabs/devctl/internal/domain/contract" + "github.com/devctllabs/devctl/internal/domain/failure" + generatedomain "github.com/devctllabs/devctl/internal/domain/generate" + projectdomain "github.com/devctllabs/devctl/internal/domain/project" + generateservice "github.com/devctllabs/devctl/internal/service/generate" + "github.com/devctllabs/devctl/internal/service/generate/mocks" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + "go.uber.org/zap" +) + +func TestGenServiceOwnsGenerationAndPublicationFlow(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + projects := mocks.NewMockProjectRepository(ctrl) + generator := mocks.NewMockGeneratorClient(ctrl) + workspace := mocks.NewMockWorkspaceRepository(ctrl) + project := projectdomain.Project{Root: "/project", Manifest: projectdomain.Manifest{ + Project: projectdomain.Identity{Name: "checkout", Language: "go"}, + Components: projectdomain.Components{Logging: &projectdomain.Logging{}}, + Languages: projectdomain.Languages{Go: projectdomain.GoLanguage{Module: "example.test/checkout"}}, + }} + target := generationTarget(t, project.Manifest, "config") + projects.EXPECT().LoadProject(gomock.Any(), "custom.yaml").Return(project, nil) + gomock.InOrder( + workspace.EXPECT().PublishDirectory(gomock.Any(), project.Root, target.OutputDir, gomock.Any()).DoAndReturn( + func(_ context.Context, _, _ string, tree artifact.Tree) (artifact.PublishResult, error) { + require.Len(t, tree.Files, 1) + require.Equal(t, "config.gen.go", tree.Files[0].Path) + requireStructFieldTag(t, tree.Files[0].Content, fieldTagExpectation{ + Struct: "LoggingConfig", Field: "Level", Tag: `env:"CHECKOUT_LOG_LEVEL" default:"info"`, + }) + golden, err := os.ReadFile("testdata/config_logging.golden") + require.NoError(t, err) + require.Equal(t, golden, tree.Files[0].Content) + return publishedDirectory(tree, artifact.PublishUpdated), nil + }, + ), + workspace.EXPECT().PublishFile(gomock.Any(), project.Root, ".env.example", []byte("CHECKOUT_LOG_LEVEL=info\n")).Return(artifact.PublishResult{Action: artifact.PublishUnchanged}, nil), + ) + service := generateservice.New(zap.NewNop(), generateservice.Dependencies{ + Projects: projects, Generator: generator, Workspace: workspace, + }) + + result, err := service.Generate(context.Background(), generatedomain.Command{ManifestPath: "custom.yaml", Family: "config"}) + + require.NoError(t, err) + require.Equal(t, []string{"config"}, result.Targets) + require.Equal(t, []generatedomain.Change{ + {Target: "config", Path: "gen/config/config.gen.go", Action: generatedomain.ChangeUpdated}, + {Target: "config", Path: ".env.example", Action: generatedomain.ChangeUnchanged}, + }, result.Changes) +} + +func TestGenHTTPClientReadsCanonicalExternalLayout(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + projects := mocks.NewMockProjectRepository(ctrl) + inputs := mocks.NewMockTargetResolver(ctrl) + generator := mocks.NewMockGeneratorClient(ctrl) + workspace := mocks.NewMockWorkspaceRepository(ctrl) + project := projectdomain.Project{Root: "/project", Manifest: projectdomain.Manifest{ + Paths: projectdomain.ManifestPaths{ExternalContracts: "api/external"}, + Sources: map[string]projectdomain.Source{"remote": {Type: projectdomain.SourceURL}}, + Components: projectdomain.Components{HTTP: &projectdomain.HTTP{Clients: []projectdomain.HTTPClient{{ + Name: "catalog", Source: "remote", Path: "openapi.yaml", + }}}}, + Languages: projectdomain.Languages{Go: projectdomain.GoLanguage{Generators: projectdomain.GoGenerators{HTTP: &projectdomain.HTTPGenerator{ + OAPIConfig: "tools/oapi/client.yaml", ClientOut: "gen/http/client", + }}}}, + }} + logical := generationTarget(t, project.Manifest, "http-client:catalog") + target := logical + target.Input = "/project/api/external/http/client/catalog/openapi.yaml" + projects.EXPECT().LoadProject(gomock.Any(), "devctl.yaml").Return(project, nil) + inputs.EXPECT().Resolve(gomock.Any(), project, logical).Return(target, nil) + generator.EXPECT().Generate(gomock.Any(), project, target).Return(generatedomain.Output{Directory: artifact.Tree{Files: []artifact.File{{Path: "client.gen.go", Content: []byte("package client")}}}}, nil) + workspace.EXPECT().PublishDirectory(gomock.Any(), project.Root, target.OutputDir, gomock.Any()).Return(artifact.PublishResult{ + Action: artifact.PublishUpdated, + Changes: []artifact.PublishChange{{Path: "client.gen.go", Action: artifact.PublishUpdated}}, + }, nil) + service := generateservice.New(zap.NewNop(), generateservice.Dependencies{ + Projects: projects, Inputs: inputs, Generator: generator, Workspace: workspace, + }) + + result, err := service.Generate(context.Background(), generatedomain.Command{ManifestPath: "devctl.yaml", Target: target.ID}) + + require.NoError(t, err) + require.Equal(t, []string{target.ID}, result.Targets) +} + +func TestGenConfigHonorsGRPCStartPolicy(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + start *projectdomain.Start + expectedFields map[string]string + expectedEnv []byte + }{ + { + name: "runtime toggle", + start: &projectdomain.Start{Env: "GRPC_SERVER_ENABLED", Default: boolPointer(false)}, + expectedFields: map[string]string{ + "Address": `env:"SAMPLE_GRPC_ADDR" default:":9090"`, + "Enabled": `env:"SAMPLE_GRPC_SERVER_ENABLED" default:"false"`, + }, + expectedEnv: []byte("SAMPLE_GRPC_ADDR=:9090\nSAMPLE_GRPC_SERVER_ENABLED=false\n"), + }, + { + name: "always active", + start: nil, + expectedFields: map[string]string{ + "Address": `env:"SAMPLE_GRPC_ADDR" default:":9090"`, + }, + expectedEnv: []byte("SAMPLE_GRPC_ADDR=:9090\n"), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + projects := mocks.NewMockProjectRepository(ctrl) + workspace := mocks.NewMockWorkspaceRepository(ctrl) + project := projectdomain.Project{Root: "/project", Manifest: projectdomain.Manifest{ + Project: projectdomain.Identity{Name: "sample"}, + Components: projectdomain.Components{GRPC: &projectdomain.GRPC{ + Server: &projectdomain.GRPCServer{Start: test.start}, + }}, + Languages: projectdomain.Languages{Go: projectdomain.GoLanguage{Generators: projectdomain.GoGenerators{ + Config: &projectdomain.ConfigGenerator{Out: "gen/config"}, + }}}, + }} + projects.EXPECT().LoadProject(gomock.Any(), "devctl.yaml").Return(project, nil) + workspace.EXPECT().PublishDirectory(gomock.Any(), project.Root, "gen/config", gomock.Any()).DoAndReturn( + func(_ context.Context, _, _ string, tree artifact.Tree) (artifact.PublishResult, error) { + require.Len(t, tree.Files, 1) + require.Equal(t, test.expectedFields, structFieldTags(t, tree.Files[0].Content, "GRPCConfig")) + return publishedDirectory(tree, artifact.PublishUpdated), nil + }, + ) + workspace.EXPECT().PublishFile(gomock.Any(), project.Root, ".env.example", test.expectedEnv).Return(artifact.PublishResult{Action: artifact.PublishUpdated}, nil) + service := generateservice.New(zap.NewNop(), generateservice.Dependencies{ + Projects: projects, Workspace: workspace, + }) + + _, err := service.Generate(context.Background(), generatedomain.Command{ManifestPath: "devctl.yaml", Family: "config"}) + + require.NoError(t, err) + }) + } +} + +func TestGenConfigBuildsKafkaRuntimeAndProducerEnv(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + projects := mocks.NewMockProjectRepository(ctrl) + workspace := mocks.NewMockWorkspaceRepository(ctrl) + project := projectdomain.Project{Root: "/project", Manifest: projectdomain.Manifest{ + Project: projectdomain.Identity{Name: "sample"}, + Components: projectdomain.Components{Kafka: &projectdomain.Kafka{ + Consumers: []projectdomain.KafkaConsumer{ + {Name: "billing", GroupEnv: "BILLING_GROUP", Start: &projectdomain.Start{Env: "BILLING_KAFKA_ENABLED", Default: boolPointer(false)}}, + {Name: "replay", GroupEnv: "REPLAY_GROUP"}, + }, + Producers: []projectdomain.KafkaProducer{{ + Name: "audit", Topic: "audit_service.audit.events.v1", TopicEnv: "AUDIT_TOPIC", + }}, + }}, + Languages: projectdomain.Languages{Go: projectdomain.GoLanguage{Generators: projectdomain.GoGenerators{ + Config: &projectdomain.ConfigGenerator{Out: "gen/config"}, + }}}, + }} + projects.EXPECT().LoadProject(gomock.Any(), "devctl.yaml").Return(project, nil) + workspace.EXPECT().PublishDirectory(gomock.Any(), project.Root, "gen/config", gomock.Any()).DoAndReturn( + func(_ context.Context, _, _ string, tree artifact.Tree) (artifact.PublishResult, error) { + tags := structFieldTags(t, tree.Files[0].Content, "KafkaConfig") + require.Subset(t, tags, map[string]string{ + "AuditTopic": `env:"SAMPLE_AUDIT_TOPIC" default:"audit_service.audit.events.v1"`, + "BillingEnabled": `env:"SAMPLE_BILLING_KAFKA_ENABLED" default:"false"`, + "BillingGroup": `env:"SAMPLE_BILLING_GROUP" default:"sample-billing-group"`, + "Brokers": `env:"SAMPLE_KAFKA_BROKERS" default:"localhost:29092"`, + "ReplayGroup": `env:"SAMPLE_REPLAY_GROUP" default:"sample-replay-group"`, + }) + require.Equal(t, `env:"SAMPLE_KAFKA_BILLING_BATCH_MAX_SIZE" default:"1"`, tags["BillingBatchMaxSize"]) + require.Equal(t, `env:"SAMPLE_KAFKA_BILLING_RETRY_MAX_ATTEMPTS" default:"3"`, tags["BillingRetryMaxAttempts"]) + require.Equal(t, `env:"SAMPLE_KAFKA_BILLING_REBALANCE_TIMEOUT" default:"30s"`, tags["BillingRebalanceTimeout"]) + return publishedDirectory(tree, artifact.PublishUpdated), nil + }, + ) + workspace.EXPECT().PublishFile(gomock.Any(), project.Root, ".env.example", gomock.Any()).DoAndReturn( + func(_ context.Context, _, _ string, content []byte) (artifact.PublishResult, error) { + for _, line := range []string{ + "SAMPLE_AUDIT_TOPIC=audit_service.audit.events.v1\n", + "SAMPLE_BILLING_GROUP=sample-billing-group\n", + "SAMPLE_BILLING_KAFKA_ENABLED=false\n", + "SAMPLE_KAFKA_BILLING_BATCH_MAX_SIZE=1\n", + "SAMPLE_KAFKA_BROKERS=localhost:29092\n", + "SAMPLE_REPLAY_GROUP=sample-replay-group\n", + } { + require.Contains(t, string(content), line) + } + return artifact.PublishResult{Action: artifact.PublishUpdated}, nil + }, + ) + service := generateservice.New(zap.NewNop(), generateservice.Dependencies{ + Projects: projects, Workspace: workspace, + }) + + _, err := service.Generate(context.Background(), generatedomain.Command{ManifestPath: "devctl.yaml", Family: "config"}) + + require.NoError(t, err) +} + +func TestGenConfigBuildsRedisAndS3EnvWithoutSecretDefaults(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + projects := mocks.NewMockProjectRepository(ctrl) + workspace := mocks.NewMockWorkspaceRepository(ctrl) + project := projectdomain.Project{Root: "/project", Manifest: projectdomain.Manifest{ + Project: projectdomain.Identity{Name: "sample"}, + Components: projectdomain.Components{ + Redis: &projectdomain.Redis{Connections: []projectdomain.RedisConnection{ + {Name: "cache", AddrEnv: "REDIS_CACHE_ADDR", AddrDefault: "redis://localhost:6379/1"}, + {Name: "ephemeral", AddrEnv: "REDIS_EPHEMERAL_ADDR"}, + }}, + S3: &projectdomain.S3{ + Connections: []projectdomain.S3Connection{{ + Name: "default", Credentials: "static", Endpoint: "http://localhost:9000", + Region: "us-east-1", PathStyle: true, AccessKeyEnv: "S3_ACCESS_KEY_ID", SecretKeyEnv: "S3_SECRET_ACCESS_KEY", + }}, + Buckets: []projectdomain.S3Bucket{{Name: "media", Connection: "default", Bucket: "media-local"}}, + }, + }, + Languages: projectdomain.Languages{Go: projectdomain.GoLanguage{Generators: projectdomain.GoGenerators{ + Config: &projectdomain.ConfigGenerator{Out: "gen/config"}, + }}}, + }} + projects.EXPECT().LoadProject(gomock.Any(), "devctl.yaml").Return(project, nil) + workspace.EXPECT().PublishDirectory(gomock.Any(), project.Root, "gen/config", gomock.Any()).DoAndReturn( + func(_ context.Context, _, _ string, tree artifact.Tree) (artifact.PublishResult, error) { + require.Equal(t, map[string]string{ + "CacheAddress": `env:"SAMPLE_REDIS_CACHE_ADDR" default:"redis://localhost:6379/1"`, + "EphemeralAddress": `env:"SAMPLE_REDIS_EPHEMERAL_ADDR"`, + }, structFieldTags(t, tree.Files[0].Content, "RedisConfig")) + require.Equal(t, map[string]string{ + "AccessKeyID": `env:"SAMPLE_S3_ACCESS_KEY_ID"`, + "Endpoint": `env:"SAMPLE_S3_ENDPOINT" default:"http://localhost:9000"`, + "ForcePathStyle": `env:"SAMPLE_S3_FORCE_PATH_STYLE" default:"true"`, + "MediaBucket": `env:"SAMPLE_S3_MEDIA_BUCKET" default:"media-local"`, + "Region": `env:"SAMPLE_S3_REGION" default:"us-east-1"`, + "SecretAccessKey": `env:"SAMPLE_S3_SECRET_ACCESS_KEY"`, + }, structFieldTags(t, tree.Files[0].Content, "S3Config")) + return publishedDirectory(tree, artifact.PublishUpdated), nil + }, + ) + workspace.EXPECT().PublishFile(gomock.Any(), project.Root, ".env.example", []byte( + "SAMPLE_REDIS_CACHE_ADDR=redis://localhost:6379/1\n"+ + "SAMPLE_REDIS_EPHEMERAL_ADDR=\n"+ + "SAMPLE_S3_ACCESS_KEY_ID=\n"+ + "SAMPLE_S3_ENDPOINT=http://localhost:9000\n"+ + "SAMPLE_S3_FORCE_PATH_STYLE=true\n"+ + "SAMPLE_S3_MEDIA_BUCKET=media-local\n"+ + "SAMPLE_S3_REGION=us-east-1\n"+ + "SAMPLE_S3_SECRET_ACCESS_KEY=\n", + )).Return(artifact.PublishResult{Action: artifact.PublishUpdated}, nil) + service := generateservice.New(zap.NewNop(), generateservice.Dependencies{ + Projects: projects, Workspace: workspace, + }) + + _, err := service.Generate(context.Background(), generatedomain.Command{ManifestPath: "devctl.yaml", Family: "config"}) + + require.NoError(t, err) +} + +func TestGenConfigKeepsMigrationURLsOutOfRuntimeConfig(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + projects := mocks.NewMockProjectRepository(ctrl) + workspace := mocks.NewMockWorkspaceRepository(ctrl) + project := projectdomain.Project{Root: "/project", Manifest: projectdomain.Manifest{ + Project: projectdomain.Identity{Name: "sample"}, + Components: projectdomain.Components{DB: &projectdomain.DB{Connections: []projectdomain.DBConnection{{ + Name: "primary", Default: "sqlite", Variants: []projectdomain.DBVariant{ + {Name: "sqlite", Kind: "sqlite", DSNEnv: "DB_PRIMARY_SQLITE_DSN", DSNDefault: "file:./data/primary.db?_foreign_keys=on", Migrations: &projectdomain.DBMigrations{ + Path: "migrations/primary/sqlite", DatabaseEnv: "DB_PRIMARY_SQLITE_MIGRATIONS_URL", DatabaseDefault: "sqlite://./data/primary.db?_pragma=foreign_keys%281%29", + }}, + {Name: "postgres", Kind: "postgres", DSNEnv: "DB_PRIMARY_POSTGRES_DSN", Secret: true, Migrations: &projectdomain.DBMigrations{ + Path: "migrations/primary/postgres", DatabaseEnv: "DB_PRIMARY_POSTGRES_MIGRATIONS_URL", + }}, + }, + }}}}, + Languages: projectdomain.Languages{Go: projectdomain.GoLanguage{Generators: projectdomain.GoGenerators{ + Config: &projectdomain.ConfigGenerator{Out: "gen/config"}, + }}}, + }} + projects.EXPECT().LoadProject(gomock.Any(), "devctl.yaml").Return(project, nil) + workspace.EXPECT().PublishDirectory(gomock.Any(), project.Root, "gen/config", gomock.Any()).DoAndReturn( + func(_ context.Context, _, _ string, tree artifact.Tree) (artifact.PublishResult, error) { + require.Equal(t, map[string]string{ + "Kind": `env:"SAMPLE_DB_PRIMARY_KIND" default:"sqlite"`, + "PostgresDSN": `env:"SAMPLE_DB_PRIMARY_POSTGRES_DSN"`, + "SqliteDSN": `env:"SAMPLE_DB_PRIMARY_SQLITE_DSN" default:"file:./data/primary.db?_foreign_keys=on"`, + }, structFieldTags(t, tree.Files[0].Content, "DBPrimaryConfig")) + return publishedDirectory(tree, artifact.PublishUpdated), nil + }, + ) + workspace.EXPECT().PublishFile(gomock.Any(), project.Root, ".env.example", []byte( + "SAMPLE_DB_PRIMARY_KIND=sqlite\n"+ + "SAMPLE_DB_PRIMARY_POSTGRES_DSN=\n"+ + "SAMPLE_DB_PRIMARY_POSTGRES_MIGRATIONS_URL=\n"+ + "SAMPLE_DB_PRIMARY_SQLITE_DSN=file:./data/primary.db?_foreign_keys=on\n"+ + "SAMPLE_DB_PRIMARY_SQLITE_MIGRATIONS_URL=sqlite://./data/primary.db?_pragma=foreign_keys%281%29\n", + )).Return(artifact.PublishResult{Action: artifact.PublishUpdated}, nil) + service := generateservice.New(zap.NewNop(), generateservice.Dependencies{ + Projects: projects, Workspace: workspace, + }) + + _, err := service.Generate(context.Background(), generatedomain.Command{ManifestPath: "devctl.yaml", Family: "config"}) + + require.NoError(t, err) +} + +func TestGenServiceRoutesGRPCTargetToProtoGeneratorAndPublishesOutput(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + projects := mocks.NewMockProjectRepository(ctrl) + generator := mocks.NewMockGeneratorClient(ctrl) + workspace := mocks.NewMockWorkspaceRepository(ctrl) + project := projectdomain.Project{Root: "/project", Manifest: projectdomain.Manifest{ + Components: projectdomain.Components{GRPC: &projectdomain.GRPC{ + Server: &projectdomain.GRPCServer{ProtoRoot: "api/proto", BufConfig: "buf.yaml"}, + }}, + Languages: projectdomain.Languages{Go: projectdomain.GoLanguage{Generators: projectdomain.GoGenerators{ + GRPC: &projectdomain.GRPCGenerator{Out: "gen/grpc", BufGenConfig: "tools/buf/grpc.gen.yaml"}, + }}}, + }} + target := generationTarget(t, project.Manifest, "grpc-server") + generated := generatedomain.Output{Directory: artifact.Tree{Files: []artifact.File{{ + Path: "acme/v1/service.pb.go", Content: []byte("generated"), Mode: 0o644, + }}}} + projects.EXPECT().LoadProject(gomock.Any(), "devctl.yaml").Return(project, nil) + generator.EXPECT().Generate(gomock.Any(), project, target).Return(generated, nil) + workspace.EXPECT().PublishDirectory(gomock.Any(), project.Root, target.OutputDir, generated.Directory).Return(publishedDirectory(generated.Directory, artifact.PublishUpdated), nil) + service := generateservice.New(zap.NewNop(), generateservice.Dependencies{ + Projects: projects, Inputs: passthroughTargetResolver(ctrl), Generator: generator, Workspace: workspace, + }) + + result, err := service.Generate(context.Background(), generatedomain.Command{ + ManifestPath: "devctl.yaml", Family: "grpc", Target: "grpc-server", + }) + + require.NoError(t, err) + require.Equal(t, []string{"grpc-server"}, result.Targets) + require.Equal(t, []generatedomain.Change{{ + Target: "grpc-server", Path: "gen/grpc/server/acme/v1/service.pb.go", Action: generatedomain.ChangeUpdated, + }}, result.Changes) +} + +func TestGenServiceReportsPreciseChangesInsideOnlyTheSelectedTarget(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + projects := mocks.NewMockProjectRepository(ctrl) + protoGenerator := mocks.NewMockGeneratorClient(ctrl) + workspace := mocks.NewMockWorkspaceRepository(ctrl) + project := projectdomain.Project{Root: "/project", Manifest: projectdomain.Manifest{ + Sources: map[string]projectdomain.Source{"local": {Type: projectdomain.SourceLocal, Path: "api/contracts"}}, + Components: projectdomain.Components{GRPC: &projectdomain.GRPC{ + Server: &projectdomain.GRPCServer{}, + Clients: []projectdomain.GRPCClient{{ + Name: "unrelated", Source: "local", Path: "proto/unrelated.proto", ProtoRoot: "proto", + }}, + }}, + }} + target := generationTarget(t, project.Manifest, "grpc-server") + generated := generatedomain.Output{Directory: artifact.Tree{Files: []artifact.File{ + {Path: "changed.pb.go", Content: []byte("changed")}, + {Path: "created.pb.go", Content: []byte("created")}, + {Path: "equal.pb.go", Content: []byte("equal")}, + }}} + projects.EXPECT().LoadProject(gomock.Any(), "devctl.yaml").Return(project, nil) + protoGenerator.EXPECT().Generate(gomock.Any(), project, target).Return(generated, nil) + workspace.EXPECT().PublishDirectory( + gomock.Any(), project.Root, target.OutputDir, generated.Directory, + ).Return(artifact.PublishResult{Action: artifact.PublishUpdated, Changes: []artifact.PublishChange{ + {Path: "changed.pb.go", Action: artifact.PublishUpdated}, + {Path: "created.pb.go", Action: artifact.PublishCreated}, + {Path: "equal.pb.go", Action: artifact.PublishUnchanged}, + {Path: "stale.pb.go", Action: artifact.PublishRemoved}, + }}, nil) + service := generateservice.New(zap.NewNop(), generateservice.Dependencies{ + Projects: projects, Inputs: passthroughTargetResolver(ctrl), Generator: protoGenerator, Workspace: workspace, + }) + + result, err := service.Generate(context.Background(), generatedomain.Command{ + ManifestPath: "devctl.yaml", Target: target.ID, + }) + + require.NoError(t, err) + require.Equal(t, []string{target.ID}, result.Targets) + require.Equal(t, []generatedomain.Change{ + {Target: target.ID, Path: "gen/grpc/server/changed.pb.go", Action: generatedomain.ChangeUpdated}, + {Target: target.ID, Path: "gen/grpc/server/created.pb.go", Action: generatedomain.ChangeCreated}, + {Target: target.ID, Path: "gen/grpc/server/equal.pb.go", Action: generatedomain.ChangeUnchanged}, + {Target: target.ID, Path: "gen/grpc/server/stale.pb.go", Action: generatedomain.ChangeRemoved}, + }, result.Changes) +} + +func TestGenServiceDefaultsGRPCServerProtoRoot(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + projects := mocks.NewMockProjectRepository(ctrl) + protoGenerator := mocks.NewMockGeneratorClient(ctrl) + workspace := mocks.NewMockWorkspaceRepository(ctrl) + project := projectdomain.Project{Root: "/project", Manifest: projectdomain.Manifest{ + Components: projectdomain.Components{GRPC: &projectdomain.GRPC{ + Server: &projectdomain.GRPCServer{}, + }}, + Languages: projectdomain.Languages{Go: projectdomain.GoLanguage{Generators: projectdomain.GoGenerators{ + GRPC: &projectdomain.GRPCGenerator{}, + }}}, + }} + target := generationTarget(t, project.Manifest, "grpc-server") + projects.EXPECT().LoadProject(gomock.Any(), "devctl.yaml").Return(project, nil) + protoGenerator.EXPECT().Generate(gomock.Any(), project, target).Return(generatedomain.Output{}, nil) + workspace.EXPECT().PublishDirectory(gomock.Any(), project.Root, target.OutputDir, artifact.Tree{}).Return(artifact.PublishResult{Action: artifact.PublishUnchanged}, nil) + service := generateservice.New(zap.NewNop(), generateservice.Dependencies{ + Projects: projects, Inputs: passthroughTargetResolver(ctrl), Generator: protoGenerator, Workspace: workspace, + }) + + result, err := service.Generate(context.Background(), generatedomain.Command{ + ManifestPath: "devctl.yaml", Family: "grpc", Target: "grpc-server", + }) + + require.NoError(t, err) + require.Equal(t, []string{"grpc-server"}, result.Targets) +} + +func TestGenServiceResolvesLocalGRPCClientProtoSelection(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + projects := mocks.NewMockProjectRepository(ctrl) + generator := mocks.NewMockGeneratorClient(ctrl) + workspace := mocks.NewMockWorkspaceRepository(ctrl) + project := projectdomain.Project{Root: "/project", Manifest: projectdomain.Manifest{ + Sources: map[string]projectdomain.Source{ + "contracts": {Type: "local", Path: "api/contracts"}, + }, + Components: projectdomain.Components{GRPC: &projectdomain.GRPC{Clients: []projectdomain.GRPCClient{{ + Name: "billing", Source: "contracts", Path: "proto/acme/billing/v1", + ProtoRoot: "proto", BufGenConfig: "tools/buf/billing.gen.yaml", + }}}}, + Languages: projectdomain.Languages{Go: projectdomain.GoLanguage{Generators: projectdomain.GoGenerators{ + GRPC: &projectdomain.GRPCGenerator{Out: "gen/grpc", BufGenConfig: "tools/buf/grpc.gen.yaml"}, + }}}, + }} + target := generationTarget(t, project.Manifest, "grpc-client:billing") + generated := generatedomain.Output{Directory: artifact.Tree{Files: []artifact.File{{ + Path: "acme/billing/v1/billing.pb.go", Content: []byte("generated"), Mode: 0o644, + }}}} + projects.EXPECT().LoadProject(gomock.Any(), "devctl.yaml").Return(project, nil) + generator.EXPECT().Generate(gomock.Any(), project, target).Return(generated, nil) + workspace.EXPECT().PublishDirectory(gomock.Any(), project.Root, target.OutputDir, generated.Directory).Return(publishedDirectory(generated.Directory, artifact.PublishUpdated), nil) + service := generateservice.New(zap.NewNop(), generateservice.Dependencies{ + Projects: projects, Inputs: passthroughTargetResolver(ctrl), Generator: generator, Workspace: workspace, + }) + + result, err := service.Generate(context.Background(), generatedomain.Command{ + ManifestPath: "devctl.yaml", Family: "grpc", Target: "grpc-client:billing", + }) + + require.NoError(t, err) + require.Equal(t, []string{"grpc-client:billing"}, result.Targets) +} + +func TestGenServiceUsesEveryGRPCClientsExplicitConfig(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + projects := mocks.NewMockProjectRepository(ctrl) + protoGenerator := mocks.NewMockGeneratorClient(ctrl) + workspace := mocks.NewMockWorkspaceRepository(ctrl) + project := projectdomain.Project{Root: "/project", Manifest: projectdomain.Manifest{ + Sources: map[string]projectdomain.Source{ + "contracts": {Type: projectdomain.SourceLocal, Path: "api/contracts"}, + }, + Components: projectdomain.Components{GRPC: &projectdomain.GRPC{Clients: []projectdomain.GRPCClient{ + {Name: "billing", Source: "contracts", Path: "proto/billing.proto", BufGenConfig: "tools/buf/billing.gen.yaml"}, + {Name: "orders", Source: "contracts", Path: "proto/orders.proto", BufGenConfig: "tools/buf/orders.gen.yaml"}, + }}}, + Languages: projectdomain.Languages{Go: projectdomain.GoLanguage{Generators: projectdomain.GoGenerators{ + GRPC: &projectdomain.GRPCGenerator{Out: "gen/grpc"}, + }}}, + }} + billing := generationTarget(t, project.Manifest, "grpc-client:billing") + orders := generationTarget(t, project.Manifest, "grpc-client:orders") + projects.EXPECT().LoadProject(gomock.Any(), "devctl.yaml").Return(project, nil) + protoGenerator.EXPECT().Generate(gomock.Any(), project, billing).Return(generatedomain.Output{}, nil) + workspace.EXPECT().PublishDirectory(gomock.Any(), project.Root, billing.OutputDir, artifact.Tree{}).Return(artifact.PublishResult{Action: artifact.PublishUnchanged}, nil) + protoGenerator.EXPECT().Generate(gomock.Any(), project, orders).Return(generatedomain.Output{}, nil) + workspace.EXPECT().PublishDirectory(gomock.Any(), project.Root, orders.OutputDir, artifact.Tree{}).Return(artifact.PublishResult{Action: artifact.PublishUnchanged}, nil) + service := generateservice.New(zap.NewNop(), generateservice.Dependencies{ + Projects: projects, Inputs: passthroughTargetResolver(ctrl), Generator: protoGenerator, Workspace: workspace, + }) + + result, err := service.Generate(context.Background(), generatedomain.Command{ + ManifestPath: "devctl.yaml", Family: "grpc", + }) + + require.NoError(t, err) + require.Equal(t, []string{"grpc-client:billing", "grpc-client:orders"}, result.Targets) + require.Equal(t, "tools/buf/billing.gen.yaml", billing.Config) + require.Equal(t, "tools/buf/orders.gen.yaml", orders.Config) +} + +func TestGenServiceUsesCommittedModuleRootForDevctlGRPCClient(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + projects := mocks.NewMockProjectRepository(ctrl) + inputs := mocks.NewMockTargetResolver(ctrl) + protoGenerator := mocks.NewMockGeneratorClient(ctrl) + workspace := mocks.NewMockWorkspaceRepository(ctrl) + project := projectdomain.Project{Root: "/project", Manifest: projectdomain.Manifest{ + Paths: projectdomain.ManifestPaths{ExternalContracts: "api/external"}, + Sources: map[string]projectdomain.Source{"contracts": {Type: projectdomain.SourceDevctl}}, + Components: projectdomain.Components{GRPC: &projectdomain.GRPC{Clients: []projectdomain.GRPCClient{{ + Name: "billing", Source: "contracts", Export: "billing", + }}}}, + Languages: projectdomain.Languages{Go: projectdomain.GoLanguage{Generators: projectdomain.GoGenerators{ + GRPC: &projectdomain.GRPCGenerator{Out: "gen/grpc", BufGenConfig: "tools/buf/grpc.gen.yaml"}, + }}}, + }} + logicalTarget := generationTarget(t, project.Manifest, "grpc-client:billing") + snapshot := contract.Snapshot{ + ModuleRoot: "api/proto/grpc", + Metadata: &contract.Metadata{ + Kind: "grpc", Format: "proto", ModuleRoot: "api/proto/grpc", BufConfig: "buf.yaml", + }, + } + resolvedTarget := logicalTarget.WithSnapshot(snapshot) + projects.EXPECT().LoadProject(gomock.Any(), "devctl.yaml").Return(project, nil) + inputs.EXPECT().Resolve(gomock.Any(), project, logicalTarget).Return(resolvedTarget, nil) + protoGenerator.EXPECT().Generate(gomock.Any(), project, resolvedTarget).Return(generatedomain.Output{}, nil) + workspace.EXPECT().PublishDirectory(gomock.Any(), project.Root, resolvedTarget.OutputDir, artifact.Tree{}).Return(artifact.PublishResult{Action: artifact.PublishUnchanged}, nil) + service := generateservice.New(zap.NewNop(), generateservice.Dependencies{ + Projects: projects, Inputs: inputs, Generator: protoGenerator, Workspace: workspace, + }) + + _, err := service.Generate(context.Background(), generatedomain.Command{ManifestPath: "devctl.yaml", Target: logicalTarget.ID}) + + require.NoError(t, err) + require.Equal(t, "api/external/grpc/client/billing/api/proto/grpc", resolvedTarget.Input) +} + +func TestGenServiceResolvesKafkaProducerProtoSelection(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + projects := mocks.NewMockProjectRepository(ctrl) + generator := mocks.NewMockGeneratorClient(ctrl) + workspace := mocks.NewMockWorkspaceRepository(ctrl) + project := projectdomain.Project{Root: "/project", Manifest: projectdomain.Manifest{ + Sources: map[string]projectdomain.Source{ + "events": {Type: "local", Path: "api/events"}, + }, + Components: projectdomain.Components{Kafka: &projectdomain.Kafka{Producers: []projectdomain.KafkaProducer{{ + Name: "invoice", Topic: "invoice_service.invoice.events.v1", + Contract: projectdomain.KafkaContract{ + Source: "events", Path: "proto/invoice_service.invoice.events.v1.proto", + Format: "proto", ProtoRoot: "proto", Message: "acme.invoice.v1.Invoice", Encoding: "binary", + }, + }}}}, + Languages: projectdomain.Languages{Go: projectdomain.GoLanguage{Generators: projectdomain.GoGenerators{ + Kafka: &projectdomain.KafkaGenerator{Out: "gen/kafka", BufGenConfig: "tools/buf/kafka.gen.yaml"}, + }}}, + }} + target := generationTarget(t, project.Manifest, "kafka-producer:invoice") + generated := generatedomain.Output{Directory: artifact.Tree{Files: []artifact.File{{ + Path: "acme/invoice/v1/invoice.pb.go", Content: []byte("generated"), Mode: 0o644, + }}}} + projects.EXPECT().LoadProject(gomock.Any(), "devctl.yaml").Return(project, nil) + generator.EXPECT().Generate(gomock.Any(), project, target).Return(generated, nil) + workspace.EXPECT().PublishDirectory(gomock.Any(), project.Root, target.OutputDir, generated.Directory).Return(publishedDirectory(generated.Directory, artifact.PublishUpdated), nil) + service := generateservice.New(zap.NewNop(), generateservice.Dependencies{ + Projects: projects, Inputs: passthroughTargetResolver(ctrl), Generator: generator, Workspace: workspace, + }) + + result, err := service.Generate(context.Background(), generatedomain.Command{ + ManifestPath: "devctl.yaml", Family: "kafka", Target: "kafka-producer:invoice", + }) + + require.NoError(t, err) + require.Equal(t, []string{"kafka-producer:invoice"}, result.Targets) +} + +func TestGenServiceResolvesDevctlKafkaProtoFromCommittedMetadata(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + projects := mocks.NewMockProjectRepository(ctrl) + inputs := mocks.NewMockTargetResolver(ctrl) + protoGenerator := mocks.NewMockGeneratorClient(ctrl) + workspace := mocks.NewMockWorkspaceRepository(ctrl) + const topic = "invoice_service.invoice.events.v1" + project := projectdomain.Project{Root: "/project", Manifest: projectdomain.Manifest{ + Sources: map[string]projectdomain.Source{"events": {Type: projectdomain.SourceDevctl}}, + Components: projectdomain.Components{Kafka: &projectdomain.Kafka{Producers: []projectdomain.KafkaProducer{{ + Name: "invoice", Topic: topic, + Contract: projectdomain.KafkaContract{Source: "events", Export: "invoice", Format: "proto"}, + }}}}, + Languages: projectdomain.Languages{Go: projectdomain.GoLanguage{Generators: projectdomain.GoGenerators{ + Kafka: &projectdomain.KafkaGenerator{Out: "gen/kafka", BufGenConfig: "tools/buf/kafka.gen.yaml"}, + }}}, + }} + logical := generationTarget(t, project.Manifest, "kafka-producer:invoice") + snapshot := contract.Snapshot{ + ModuleRoot: "proto", Entrypoint: "proto/event.proto", + Metadata: &contract.Metadata{ + Kind: "kafka", Topic: topic, Format: "proto", + Entrypoint: "proto/event.proto", ModuleRoot: "proto", BufConfig: "buf.yaml", + }, + } + resolved := logical.WithSnapshot(snapshot) + projects.EXPECT().LoadProject(gomock.Any(), "devctl.yaml").Return(project, nil) + inputs.EXPECT().Resolve(gomock.Any(), project, logical).Return(resolved, nil) + protoGenerator.EXPECT().Generate(gomock.Any(), project, resolved).Return(generatedomain.Output{}, nil) + workspace.EXPECT().PublishDirectory(gomock.Any(), project.Root, resolved.OutputDir, artifact.Tree{}).Return(artifact.PublishResult{Action: artifact.PublishUnchanged}, nil) + service := generateservice.New(zap.NewNop(), generateservice.Dependencies{ + Projects: projects, Inputs: inputs, Generator: protoGenerator, Workspace: workspace, + }) + + _, err := service.Generate(context.Background(), generatedomain.Command{ + ManifestPath: "devctl.yaml", Target: logical.ID, + }) + + require.NoError(t, err) + require.Equal(t, "api/external/kafka/producer/invoice/proto", resolved.Input) + require.Equal(t, []string{"event.proto"}, resolved.Paths) +} + +func TestGenKafkaJSONUsesJSONSchemaGenerator(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + projects := mocks.NewMockProjectRepository(ctrl) + jsonGenerator := mocks.NewMockGeneratorClient(ctrl) + workspace := mocks.NewMockWorkspaceRepository(ctrl) + project := projectdomain.Project{Root: "/project", Manifest: projectdomain.Manifest{ + Sources: map[string]projectdomain.Source{"events": {Type: "local", Path: "api/contracts"}}, + Components: projectdomain.Components{Kafka: &projectdomain.Kafka{Consumers: []projectdomain.KafkaConsumer{{ + Name: "audit", Topic: "audit_service.audit.created.v1", + Contract: projectdomain.KafkaContract{Source: "events", Format: "json", Path: "schemas/audit_service.audit.created.v1.json"}, + }}}}, + Languages: projectdomain.Languages{Go: projectdomain.GoLanguage{Generators: projectdomain.GoGenerators{ + Kafka: &projectdomain.KafkaGenerator{Out: "gen/kafka", BufGenConfig: "tools/buf/kafka.gen.yaml"}, + }}}, + }} + target := generationTarget(t, project.Manifest, "kafka-consumer:audit") + projects.EXPECT().LoadProject(gomock.Any(), "devctl.yaml").Return(project, nil) + jsonGenerator.EXPECT().Generate(gomock.Any(), project, target).Return(generatedomain.Output{Directory: artifact.Tree{Files: []artifact.File{{ + Path: "schema.gen.go", Content: []byte("package audit\n"), + }}}}, nil) + workspace.EXPECT().PublishDirectory(gomock.Any(), project.Root, target.OutputDir, gomock.Any()).Return(artifact.PublishResult{ + Action: artifact.PublishUpdated, + Changes: []artifact.PublishChange{{Path: "schema.gen.go", Action: artifact.PublishUpdated}}, + }, nil) + service := generateservice.New(zap.NewNop(), generateservice.Dependencies{ + Projects: projects, Inputs: passthroughTargetResolver(ctrl), Generator: jsonGenerator, Workspace: workspace, + }) + + result, err := service.Generate(context.Background(), generatedomain.Command{ManifestPath: "devctl.yaml", Family: "kafka"}) + + require.NoError(t, err) + require.Equal(t, []string{"kafka-consumer:audit"}, result.Targets) + require.Equal(t, []generatedomain.Change{{ + Target: "kafka-consumer:audit", Path: "gen/kafka/consumer/audit/schema.gen.go", Action: generatedomain.ChangeUpdated, + }}, result.Changes) +} + +func TestGenServicePreservesTargetInputFailureSemantics(t *testing.T) { + t.Parallel() + + t.Run("HTTP locate operation", func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + projects := mocks.NewMockProjectRepository(ctrl) + inputs := mocks.NewMockTargetResolver(ctrl) + cause := errors.New("entrypoint unavailable") + selected := projectdomain.Project{Root: "/project", Manifest: projectdomain.Manifest{ + Components: projectdomain.Components{HTTP: &projectdomain.HTTP{Server: &projectdomain.HTTPServer{ + OpenAPI: "api/openapi/swagger.yaml", + }}}, + }} + target := generationTarget(t, selected.Manifest, "http-server") + projects.EXPECT().LoadProject(gomock.Any(), "devctl.yaml").Return(selected, nil) + inputs.EXPECT().Resolve(gomock.Any(), selected, target).Return(target, cause) + service := generateservice.New(zap.NewNop(), generateservice.Dependencies{ + Projects: projects, Inputs: inputs, + }) + + _, err := service.Generate(context.Background(), generatedomain.Command{ + ManifestPath: "devctl.yaml", Target: target.ID, + }) + + var operationErr *generatedomain.OperationError + require.ErrorAs(t, err, &operationErr) + require.Equal(t, generatedomain.OperationLocateContract, operationErr.Operation) + require.Equal(t, target.ID, operationErr.Target) + require.Equal(t, target.Location.Entrypoint, operationErr.Path) + require.Equal(t, generatedomain.FailureUnavailable, operationErr.Kind) + require.ErrorIs(t, err, cause) + }) + + t.Run("committed metadata category", func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + projects := mocks.NewMockProjectRepository(ctrl) + inputs := mocks.NewMockTargetResolver(ctrl) + metadataErr := &contract.SnapshotMetadataError{ + Field: "topic", Reason: contract.MetadataMismatch, Hint: "devctl sync", + } + selected := projectdomain.Project{Root: "/project", Manifest: projectdomain.Manifest{ + Sources: map[string]projectdomain.Source{"upstream": {Type: projectdomain.SourceDevctl}}, + Components: projectdomain.Components{GRPC: &projectdomain.GRPC{Clients: []projectdomain.GRPCClient{{ + Name: "billing", Source: "upstream", Export: "billing", + }}}}, + }} + target := generationTarget(t, selected.Manifest, "grpc-client:billing") + projects.EXPECT().LoadProject(gomock.Any(), "devctl.yaml").Return(selected, nil) + inputs.EXPECT().Resolve(gomock.Any(), selected, target).Return(target, metadataErr) + service := generateservice.New(zap.NewNop(), generateservice.Dependencies{ + Projects: projects, Inputs: inputs, + }) + + _, err := service.Generate(context.Background(), generatedomain.Command{ + ManifestPath: "devctl.yaml", Target: target.ID, + }) + + require.ErrorIs(t, err, metadataErr) + require.Equal(t, failure.InvalidInput, failure.CategoryOf(err)) + }) +} + +func TestGenServiceAppliesCatalogSelectionContract(t *testing.T) { + t.Parallel() + + manifest := projectdomain.Manifest{Project: projectdomain.Identity{Language: "go"}} + tests := []struct { + name string + command generatedomain.Command + targets []string + category failure.Category + }{ + {name: "known empty family", command: generatedomain.Command{Family: "grpc", DryRun: true}, targets: []string{}}, + {name: "known configured family", command: generatedomain.Command{Family: "config", DryRun: true}, targets: []string{"config"}}, + {name: "unknown family", command: generatedomain.Command{Family: "other", DryRun: true}, category: failure.InvalidInput}, + {name: "unknown target", command: generatedomain.Command{Target: "grpc-client:missing", DryRun: true}, category: failure.NotFound}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + projects := mocks.NewMockProjectRepository(ctrl) + projects.EXPECT().LoadProject(gomock.Any(), "devctl.yaml").Return(projectdomain.Project{Root: "/project", Manifest: manifest}, nil) + service := generateservice.New(zap.NewNop(), generateservice.Dependencies{Projects: projects}) + command := test.command + command.ManifestPath = "devctl.yaml" + + result, err := service.Generate(context.Background(), command) + + if test.category != "" { + require.Equal(t, test.category, failure.CategoryOf(err)) + return + } + require.NoError(t, err) + require.Equal(t, test.targets, result.Targets) + }) + } +} + +func TestGenExecutionOrderIsIndependentFromCatalogIDOrder(t *testing.T) { + t.Parallel() + + manifest := projectdomain.Manifest{ + Project: projectdomain.Identity{Language: "go"}, + Sources: map[string]projectdomain.Source{ + "local": {Type: projectdomain.SourceLocal, Path: "api/contracts"}, + }, + Components: projectdomain.Components{ + HTTP: &projectdomain.HTTP{ + Server: &projectdomain.HTTPServer{}, + Clients: []projectdomain.HTTPClient{{Name: "billing", Source: "local", Path: "openapi.yaml"}}, + }, + GRPC: &projectdomain.GRPC{ + Server: &projectdomain.GRPCServer{}, + Clients: []projectdomain.GRPCClient{{Name: "billing", Source: "local", Path: "proto/billing.proto", ProtoRoot: "proto"}}, + }, + Kafka: &projectdomain.Kafka{ + Consumers: []projectdomain.KafkaConsumer{{Name: "audit", Topic: "sample.audit.events.v1", Contract: projectdomain.KafkaContract{Format: "raw"}}}, + Producers: []projectdomain.KafkaProducer{{Name: "audit", Topic: "sample.audit.events.v1", Contract: projectdomain.KafkaContract{Format: "raw"}}}, + }, + }, + } + ctrl := gomock.NewController(t) + projects := mocks.NewMockProjectRepository(ctrl) + projects.EXPECT().LoadProject(gomock.Any(), "devctl.yaml").Return(projectdomain.Project{Root: "/project", Manifest: manifest}, nil) + service := generateservice.New(zap.NewNop(), generateservice.Dependencies{Projects: projects}) + + result, err := service.Generate(context.Background(), generatedomain.Command{ManifestPath: "devctl.yaml", DryRun: true}) + + require.NoError(t, err) + require.Equal(t, []string{ + "config", + "http-server", "http-client:billing", + "grpc-server", "grpc-client:billing", + "kafka-consumer:audit", "kafka-producer:audit", + }, result.Targets) + require.Equal(t, []string{ + "config", + "grpc-client:billing", "grpc-server", + "http-client:billing", "http-server", + "kafka-consumer:audit", "kafka-producer:audit", + }, targetIDs(projectdomain.NewTargetCatalog(manifest).All())) +} + +func targetIDs(targets []projectdomain.Target) []string { + ids := make([]string, len(targets)) + for index, target := range targets { + ids[index] = target.ID + } + return ids +} + +func passthroughTargetResolver(ctrl *gomock.Controller) *mocks.MockTargetResolver { + resolver := mocks.NewMockTargetResolver(ctrl) + resolver.EXPECT().Resolve(gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, _ projectdomain.Project, target projectdomain.Target) (projectdomain.Target, error) { + return target, nil + }, + ).AnyTimes() + return resolver +} + +func publishedDirectory(tree artifact.Tree, action artifact.PublishAction) artifact.PublishResult { + changes := make([]artifact.PublishChange, len(tree.Files)) + for index, file := range tree.Files { + changes[index] = artifact.PublishChange{Path: file.Path, Action: action} + } + return artifact.PublishResult{Action: action, Changes: changes} +} + +type fieldTagExpectation struct { + Struct string + Field string + Tag string +} + +func generationTarget(t *testing.T, manifest projectdomain.Manifest, id string) projectdomain.Target { + t.Helper() + targets := projectdomain.NewTargetCatalog(manifest).Select(projectdomain.TargetOperationGenerate, "", id) + require.Len(t, targets, 1) + return targets[0] +} + +func requireStructFieldTag(t *testing.T, source []byte, expected fieldTagExpectation) { + t.Helper() + file, err := parser.ParseFile(token.NewFileSet(), "config.gen.go", source, parser.AllErrors) + require.NoError(t, err) + found := false + ast.Inspect(file, func(node ast.Node) bool { + declaration, ok := node.(*ast.TypeSpec) + if !ok || declaration.Name.Name != expected.Struct { + return true + } + structure, ok := declaration.Type.(*ast.StructType) + require.True(t, ok) + for _, field := range structure.Fields.List { + if len(field.Names) == 1 && field.Names[0].Name == expected.Field { + require.NotNil(t, field.Tag) + tag, unquoteErr := strconv.Unquote(field.Tag.Value) + require.NoError(t, unquoteErr) + require.Equal(t, expected.Tag, tag) + found = true + } + } + return false + }) + require.True(t, found, "%s.%s was not generated", expected.Struct, expected.Field) +} + +func structFieldTags(t *testing.T, source []byte, structName string) map[string]string { + t.Helper() + file, err := parser.ParseFile(token.NewFileSet(), "config.gen.go", source, parser.AllErrors) + require.NoError(t, err) + fields := map[string]string{} + ast.Inspect(file, func(node ast.Node) bool { + declaration, ok := node.(*ast.TypeSpec) + if !ok || declaration.Name.Name != structName { + return true + } + structure, ok := declaration.Type.(*ast.StructType) + require.True(t, ok) + for _, field := range structure.Fields.List { + if len(field.Names) != 1 || field.Tag == nil { + continue + } + tag, unquoteErr := strconv.Unquote(field.Tag.Value) + require.NoError(t, unquoteErr) + fields[field.Names[0].Name] = tag + } + return false + }) + return fields +} + +func boolPointer(value bool) *bool { return &value } diff --git a/internal/service/generate/targets.go b/internal/service/generate/targets.go new file mode 100644 index 0000000..a02b315 --- /dev/null +++ b/internal/service/generate/targets.go @@ -0,0 +1,60 @@ +package generate + +import ( + "fmt" + "path" + "sort" + + generatedomain "github.com/devctllabs/devctl/internal/domain/generate" + projectdomain "github.com/devctllabs/devctl/internal/domain/project" +) + +func generationTargets(spec projectdomain.Manifest, family, selected string) ([]projectdomain.Target, error) { + targets, err := projectdomain.NewTargetCatalog(spec).Resolve(projectdomain.TargetOperationGenerate, family, selected) + if err != nil { + return nil, fmt.Errorf("catalog.Resolve: %w", err) + } + sort.SliceStable(targets, func(i, j int) bool { + left, right := generationOrder(targets[i]), generationOrder(targets[j]) + if left != right { + return left < right + } + return targets[i].ID < targets[j].ID + }) + return targets, nil +} + +func generationOrder(target projectdomain.Target) int { + switch target.Family { + case "config": + return 0 + case "http": + if target.Role == "server" { + return 10 + } + return 11 + case "grpc": + if target.Role == "server" { + return 20 + } + return 21 + case "kafka": + if target.Role == "consumer" { + return 30 + } + return 31 + default: + return 100 + } +} + +func plannedGeneration(target projectdomain.Target) []generatedomain.Change { + if target.Family == "kafka" && target.Format == "raw" { + return nil + } + changes := []generatedomain.Change{{Target: target.ID, Path: path.Join(target.OutputDir, target.OutputFile), Action: generatedomain.ChangePlannedPublish}} + if target.Family == "config" { + changes = append(changes, generatedomain.Change{Target: target.ID, Path: ".env.example", Action: generatedomain.ChangePlannedPublish}) + } + return changes +} diff --git a/internal/service/generate/testdata/config_logging.golden b/internal/service/generate/testdata/config_logging.golden new file mode 100644 index 0000000..7e7b43f --- /dev/null +++ b/internal/service/generate/testdata/config_logging.golden @@ -0,0 +1,25 @@ +// Code generated by devctl. DO NOT EDIT. + +package config + +import ( + "fmt" + "time" +) + +type Config struct { + Logging LoggingConfig +} + +type LoggingConfig struct { + Level string `env:"CHECKOUT_LOG_LEVEL" default:"info"` +} + +func (c *Config) Validate() error { + if c == nil { + return fmt.Errorf("config is nil") + } + return nil +} + +var _ time.Duration diff --git a/internal/service/lint/mocks/service.go b/internal/service/lint/mocks/service.go new file mode 100644 index 0000000..3098512 --- /dev/null +++ b/internal/service/lint/mocks/service.go @@ -0,0 +1,348 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/devctllabs/devctl/internal/service/lint (interfaces: ProjectRepository,ContractLocator,TargetResolver,ProtoLinter) +// +// Generated by this command: +// +// mockgen -destination mocks/service.go -package mocks -typed . ProjectRepository,ContractLocator,TargetResolver,ProtoLinter +// + +// Package mocks is a generated GoMock package. +package mocks + +import ( + context "context" + reflect "reflect" + + contract "github.com/devctllabs/devctl/internal/domain/contract" + project "github.com/devctllabs/devctl/internal/domain/project" + gomock "go.uber.org/mock/gomock" +) + +// MockProjectRepository is a mock of ProjectRepository interface. +type MockProjectRepository struct { + ctrl *gomock.Controller + recorder *MockProjectRepositoryMockRecorder + isgomock struct{} +} + +// MockProjectRepositoryMockRecorder is the mock recorder for MockProjectRepository. +type MockProjectRepositoryMockRecorder struct { + mock *MockProjectRepository +} + +// NewMockProjectRepository creates a new mock instance. +func NewMockProjectRepository(ctrl *gomock.Controller) *MockProjectRepository { + mock := &MockProjectRepository{ctrl: ctrl} + mock.recorder = &MockProjectRepositoryMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockProjectRepository) EXPECT() *MockProjectRepositoryMockRecorder { + return m.recorder +} + +// LoadProject mocks base method. +func (m *MockProjectRepository) LoadProject(ctx context.Context, manifestPath string) (project.Project, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "LoadProject", ctx, manifestPath) + ret0, _ := ret[0].(project.Project) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// LoadProject indicates an expected call of LoadProject. +func (mr *MockProjectRepositoryMockRecorder) LoadProject(ctx, manifestPath any) *MockProjectRepositoryLoadProjectCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "LoadProject", reflect.TypeOf((*MockProjectRepository)(nil).LoadProject), ctx, manifestPath) + return &MockProjectRepositoryLoadProjectCall{Call: call} +} + +// MockProjectRepositoryLoadProjectCall wrap *gomock.Call +type MockProjectRepositoryLoadProjectCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockProjectRepositoryLoadProjectCall) Return(arg0 project.Project, arg1 error) *MockProjectRepositoryLoadProjectCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockProjectRepositoryLoadProjectCall) Do(f func(context.Context, string) (project.Project, error)) *MockProjectRepositoryLoadProjectCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockProjectRepositoryLoadProjectCall) DoAndReturn(f func(context.Context, string) (project.Project, error)) *MockProjectRepositoryLoadProjectCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// MockContractLocator is a mock of ContractLocator interface. +type MockContractLocator struct { + ctrl *gomock.Controller + recorder *MockContractLocatorMockRecorder + isgomock struct{} +} + +// MockContractLocatorMockRecorder is the mock recorder for MockContractLocator. +type MockContractLocatorMockRecorder struct { + mock *MockContractLocator +} + +// NewMockContractLocator creates a new mock instance. +func NewMockContractLocator(ctrl *gomock.Controller) *MockContractLocator { + mock := &MockContractLocator{ctrl: ctrl} + mock.recorder = &MockContractLocatorMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockContractLocator) EXPECT() *MockContractLocatorMockRecorder { + return m.recorder +} + +// ListProtoFiles mocks base method. +func (m *MockContractLocator) ListProtoFiles(ctx context.Context, root, relativeRoot string) ([]string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListProtoFiles", ctx, root, relativeRoot) + ret0, _ := ret[0].([]string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ListProtoFiles indicates an expected call of ListProtoFiles. +func (mr *MockContractLocatorMockRecorder) ListProtoFiles(ctx, root, relativeRoot any) *MockContractLocatorListProtoFilesCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListProtoFiles", reflect.TypeOf((*MockContractLocator)(nil).ListProtoFiles), ctx, root, relativeRoot) + return &MockContractLocatorListProtoFilesCall{Call: call} +} + +// MockContractLocatorListProtoFilesCall wrap *gomock.Call +type MockContractLocatorListProtoFilesCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockContractLocatorListProtoFilesCall) Return(arg0 []string, arg1 error) *MockContractLocatorListProtoFilesCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockContractLocatorListProtoFilesCall) Do(f func(context.Context, string, string) ([]string, error)) *MockContractLocatorListProtoFilesCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockContractLocatorListProtoFilesCall) DoAndReturn(f func(context.Context, string, string) ([]string, error)) *MockContractLocatorListProtoFilesCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// ReadContract mocks base method. +func (m *MockContractLocator) ReadContract(ctx context.Context, path string) ([]byte, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ReadContract", ctx, path) + ret0, _ := ret[0].([]byte) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ReadContract indicates an expected call of ReadContract. +func (mr *MockContractLocatorMockRecorder) ReadContract(ctx, path any) *MockContractLocatorReadContractCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ReadContract", reflect.TypeOf((*MockContractLocator)(nil).ReadContract), ctx, path) + return &MockContractLocatorReadContractCall{Call: call} +} + +// MockContractLocatorReadContractCall wrap *gomock.Call +type MockContractLocatorReadContractCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockContractLocatorReadContractCall) Return(arg0 []byte, arg1 error) *MockContractLocatorReadContractCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockContractLocatorReadContractCall) Do(f func(context.Context, string) ([]byte, error)) *MockContractLocatorReadContractCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockContractLocatorReadContractCall) DoAndReturn(f func(context.Context, string) ([]byte, error)) *MockContractLocatorReadContractCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// ResolveContract mocks base method. +func (m *MockContractLocator) ResolveContract(ctx context.Context, location contract.Location) (string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ResolveContract", ctx, location) + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ResolveContract indicates an expected call of ResolveContract. +func (mr *MockContractLocatorMockRecorder) ResolveContract(ctx, location any) *MockContractLocatorResolveContractCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ResolveContract", reflect.TypeOf((*MockContractLocator)(nil).ResolveContract), ctx, location) + return &MockContractLocatorResolveContractCall{Call: call} +} + +// MockContractLocatorResolveContractCall wrap *gomock.Call +type MockContractLocatorResolveContractCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockContractLocatorResolveContractCall) Return(arg0 string, arg1 error) *MockContractLocatorResolveContractCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockContractLocatorResolveContractCall) Do(f func(context.Context, contract.Location) (string, error)) *MockContractLocatorResolveContractCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockContractLocatorResolveContractCall) DoAndReturn(f func(context.Context, contract.Location) (string, error)) *MockContractLocatorResolveContractCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// MockTargetResolver is a mock of TargetResolver interface. +type MockTargetResolver struct { + ctrl *gomock.Controller + recorder *MockTargetResolverMockRecorder + isgomock struct{} +} + +// MockTargetResolverMockRecorder is the mock recorder for MockTargetResolver. +type MockTargetResolverMockRecorder struct { + mock *MockTargetResolver +} + +// NewMockTargetResolver creates a new mock instance. +func NewMockTargetResolver(ctrl *gomock.Controller) *MockTargetResolver { + mock := &MockTargetResolver{ctrl: ctrl} + mock.recorder = &MockTargetResolverMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockTargetResolver) EXPECT() *MockTargetResolverMockRecorder { + return m.recorder +} + +// Resolve mocks base method. +func (m *MockTargetResolver) Resolve(ctx context.Context, selected project.Project, target project.Target) (project.Target, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Resolve", ctx, selected, target) + ret0, _ := ret[0].(project.Target) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Resolve indicates an expected call of Resolve. +func (mr *MockTargetResolverMockRecorder) Resolve(ctx, selected, target any) *MockTargetResolverResolveCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Resolve", reflect.TypeOf((*MockTargetResolver)(nil).Resolve), ctx, selected, target) + return &MockTargetResolverResolveCall{Call: call} +} + +// MockTargetResolverResolveCall wrap *gomock.Call +type MockTargetResolverResolveCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockTargetResolverResolveCall) Return(arg0 project.Target, arg1 error) *MockTargetResolverResolveCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockTargetResolverResolveCall) Do(f func(context.Context, project.Project, project.Target) (project.Target, error)) *MockTargetResolverResolveCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockTargetResolverResolveCall) DoAndReturn(f func(context.Context, project.Project, project.Target) (project.Target, error)) *MockTargetResolverResolveCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// MockProtoLinter is a mock of ProtoLinter interface. +type MockProtoLinter struct { + ctrl *gomock.Controller + recorder *MockProtoLinterMockRecorder + isgomock struct{} +} + +// MockProtoLinterMockRecorder is the mock recorder for MockProtoLinter. +type MockProtoLinterMockRecorder struct { + mock *MockProtoLinter +} + +// NewMockProtoLinter creates a new mock instance. +func NewMockProtoLinter(ctrl *gomock.Controller) *MockProtoLinter { + mock := &MockProtoLinter{ctrl: ctrl} + mock.recorder = &MockProtoLinterMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockProtoLinter) EXPECT() *MockProtoLinterMockRecorder { + return m.recorder +} + +// Lint mocks base method. +func (m *MockProtoLinter) Lint(ctx context.Context, arg1 project.Project, target project.Target) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Lint", ctx, arg1, target) + ret0, _ := ret[0].(error) + return ret0 +} + +// Lint indicates an expected call of Lint. +func (mr *MockProtoLinterMockRecorder) Lint(ctx, arg1, target any) *MockProtoLinterLintCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Lint", reflect.TypeOf((*MockProtoLinter)(nil).Lint), ctx, arg1, target) + return &MockProtoLinterLintCall{Call: call} +} + +// MockProtoLinterLintCall wrap *gomock.Call +type MockProtoLinterLintCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockProtoLinterLintCall) Return(arg0 error) *MockProtoLinterLintCall { + c.Call = c.Call.Return(arg0) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockProtoLinterLintCall) Do(f func(context.Context, project.Project, project.Target) error) *MockProtoLinterLintCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockProtoLinterLintCall) DoAndReturn(f func(context.Context, project.Project, project.Target) error) *MockProtoLinterLintCall { + c.Call = c.Call.DoAndReturn(f) + return c +} diff --git a/internal/service/lint/service.go b/internal/service/lint/service.go new file mode 100644 index 0000000..a62623b --- /dev/null +++ b/internal/service/lint/service.go @@ -0,0 +1,306 @@ +package lint + +import ( + "bytes" + "context" + "fmt" + "path" + "regexp" + "strings" + + "github.com/devctllabs/devctl/internal/domain/contract" + lintdomain "github.com/devctllabs/devctl/internal/domain/lint" + projectdomain "github.com/devctllabs/devctl/internal/domain/project" + platformjsonschema "github.com/devctllabs/devctl/internal/platform/jsonschema" + platformopenapi "github.com/devctllabs/devctl/internal/platform/openapi" + "github.com/santhosh-tekuri/jsonschema/v6" + "go.uber.org/zap" +) + +//go:generate go tool mockgen -destination mocks/service.go -package mocks -typed . ProjectRepository,ContractLocator,TargetResolver,ProtoLinter + +// ProjectRepository resolves the valid project selected for linting. +type ProjectRepository interface { + // LoadProject returns a structurally and semantically valid project or an execution error. + LoadProject(ctx context.Context, manifestPath string) (projectdomain.Project, error) +} + +// ContractLocator resolves and reads contained local or materialized contracts. +type ContractLocator interface { + // ResolveContract returns the contained entrypoint selected by location. + ResolveContract(ctx context.Context, location contract.Location) (string, error) + // ReadContract returns the exact bytes at path without interpreting OpenAPI semantics. + ReadContract(ctx context.Context, path string) ([]byte, error) + // ListProtoFiles returns sorted project-relative Proto files below relativeRoot. + ListProtoFiles(ctx context.Context, root, relativeRoot string) ([]string, error) +} + +// TargetResolver attaches the concrete input required to lint one Target. +type TargetResolver interface { + // Resolve attaches the concrete input required to execute target in selected Project. + Resolve(ctx context.Context, selected projectdomain.Project, target projectdomain.Target) (projectdomain.Target, error) +} + +// ProtoLinter checks one contained Proto target with project-pinned Buf. +type ProtoLinter interface { + // Lint checks target content without modifying project files. + Lint(ctx context.Context, project projectdomain.Project, target projectdomain.Target) error +} + +type Service struct { + logger *zap.Logger + projects ProjectRepository + contracts ContractLocator + inputs TargetResolver + proto ProtoLinter +} + +// Dependencies names the required lint capabilities passed to New. +type Dependencies struct { + Projects ProjectRepository + Contracts ContractLocator + Inputs TargetResolver + Proto ProtoLinter +} + +var protoFilenamePattern = regexp.MustCompile(`^[a-z][a-z0-9]*(?:_[a-z][a-z0-9]*)*\.[a-z][a-z0-9]*(?:_[a-z][a-z0-9]*)*\.proto$`) +var kafkaTopicPattern = regexp.MustCompile(`^[a-z][a-z0-9]*(?:_[a-z][a-z0-9]*)*\.[a-z][a-z0-9]*(?:_[a-z][a-z0-9]*)*\.[a-z][a-z0-9]*(?:_[a-z][a-z0-9]*)*\.v[1-9][0-9]*$`) + +func New(logger *zap.Logger, dependencies Dependencies) *Service { + return &Service{ + logger: logger, projects: dependencies.Projects, contracts: dependencies.Contracts, + inputs: dependencies.Inputs, proto: dependencies.Proto, + } +} + +// Lint aggregates findings across configured contracts in deterministic order. +// Findings are normal results; an execution error returns findings collected from earlier contracts. +func (s *Service) Lint(ctx context.Context, command lintdomain.Command) (lintdomain.Result, error) { + result := lintdomain.Result{Valid: true, Contracts: []string{}, Issues: []lintdomain.Issue{}} + project, err := s.projects.LoadProject(ctx, command.ManifestPath) + if err != nil { + return result, fmt.Errorf("projects.LoadProject: %w", err) + } + targets, err := projectdomain.NewTargetCatalog(project.Manifest).Resolve( + projectdomain.TargetOperationLint, command.Family, "", + ) + if err != nil { + return result, fmt.Errorf("catalog.Resolve: %w", err) + } + for _, target := range targets { + result, err = s.lintTarget(ctx, project, result, target) + if err != nil { + return result, err + } + } + result.Valid = len(result.Issues) == 0 + s.logger.Debug("contract lint completed", zap.Bool("valid", result.Valid), zap.Int("contracts", len(result.Contracts))) + return result, nil +} + +func (s *Service) lintTarget( + ctx context.Context, + project projectdomain.Project, + result lintdomain.Result, + target projectdomain.Target, +) (lintdomain.Result, error) { + if err := ctx.Err(); err != nil { + return result, fmt.Errorf("ctx.Err: %w", err) + } + resolved, err := s.inputs.Resolve(ctx, project, target) + if err != nil { + if target.Family == "http" { + operationErr := &lintdomain.OperationError{Operation: lintdomain.OperationLocateContract, Target: target.ID, Path: target.Reference.Entrypoint, Kind: lintdomain.FailureUnavailable, Cause: err} + return result, fmt.Errorf("inputs.Resolve: %w", operationErr) + } + return result, fmt.Errorf("inputs.Resolve: %w", err) + } + target = resolved + switch target.Family { + case "http": + return s.lintHTTPTarget(ctx, project, result, target) + case "grpc": + return s.lintGRPCTarget(ctx, project, result, target) + case "kafka": + return s.appendKafkaResult(ctx, project, result, target) + default: + return result, nil + } +} + +func (s *Service) lintHTTPTarget( + ctx context.Context, + project projectdomain.Project, + result lintdomain.Result, + target projectdomain.Target, +) (lintdomain.Result, error) { + contractPath := target.Input + result.Contracts = append(result.Contracts, target.ID) + data, err := s.contracts.ReadContract(ctx, contractPath) + if err != nil { + operationErr := &lintdomain.OperationError{Operation: lintdomain.OperationReadContract, Target: target.ID, Path: contractPath, Kind: lintdomain.FailureUnavailable, Cause: err} + return result, fmt.Errorf("contracts.ReadContract: %w", operationErr) + } + result.Issues = append(result.Issues, lintIssues(target, contractPath, platformopenapi.Analyze(data))...) + return result, nil +} + +func (s *Service) appendKafkaResult(ctx context.Context, project projectdomain.Project, result lintdomain.Result, target projectdomain.Target) (lintdomain.Result, error) { + targetID := target.ID + result.Contracts = append(result.Contracts, targetID) + if !kafkaTopicPattern.MatchString(target.Reference.Topic) { + result.Issues = append(result.Issues, lintdomain.Issue{Code: "kafka_topic", Target: targetID}) + } + if localKafkaSchemaMismatch(target) { + result.Issues = append(result.Issues, lintdomain.Issue{Code: "kafka_schema_filename", Target: targetID, Path: target.Reference.Entrypoint}) + } + switch target.Format { + case "json": + return s.lintKafkaJSON(ctx, project, result, target) + case "proto": + return s.lintKafkaProto(ctx, project, result, target) + default: + return result, nil + } +} + +func localKafkaSchemaMismatch(target projectdomain.Target) bool { + if target.Source.Type != projectdomain.SourceLocal || target.Reference.Entrypoint == "" || target.Format != "proto" && target.Format != "json" { + return false + } + return path.Base(target.Reference.Entrypoint) != target.Reference.Topic+"."+target.Format +} + +func (s *Service) lintKafkaJSON(ctx context.Context, project projectdomain.Project, result lintdomain.Result, target projectdomain.Target) (lintdomain.Result, error) { + targetID := target.ID + location := target.Location + location.Root = project.Root + contractPath, err := s.contracts.ResolveContract(ctx, location) + if err != nil { + operationErr := &lintdomain.OperationError{Operation: lintdomain.OperationLocateContract, Target: targetID, Path: target.Reference.Entrypoint, Kind: lintdomain.FailureUnavailable, Cause: err} + return result, fmt.Errorf("contracts.ResolveContract: %w", operationErr) + } + data, err := s.contracts.ReadContract(ctx, contractPath) + if err != nil { + operationErr := &lintdomain.OperationError{Operation: lintdomain.OperationReadContract, Target: targetID, Path: contractPath, Kind: lintdomain.FailureUnavailable, Cause: err} + return result, fmt.Errorf("contracts.ReadContract: %w", operationErr) + } + if jsonSchemaInvalid(data) { + result.Issues = append(result.Issues, lintdomain.Issue{Code: "json_schema", Target: targetID, Path: contractPath}) + return result, nil + } + if jsonSchemaTitleMissing(data) { + result.Issues = append(result.Issues, lintdomain.Issue{ + Code: "json_schema_title", Target: targetID, Path: contractPath, Field: "title", + }) + } + return result, nil +} + +func jsonSchemaInvalid(data []byte) bool { + return compileJSONSchema(data) != nil +} + +func jsonSchemaTitleMissing(data []byte) bool { + _, err := platformjsonschema.RootTitle(data) + return err != nil +} + +func compileJSONSchema(data []byte) error { + document, err := jsonschema.UnmarshalJSON(bytes.NewReader(data)) + if err != nil { + return fmt.Errorf("jsonschema.UnmarshalJSON: %w", err) + } + compiler := jsonschema.NewCompiler() + if err := compiler.AddResource("schema.json", document); err != nil { + return fmt.Errorf("compiler.AddResource: %w", err) + } + _, err = compiler.Compile("schema.json") + if err != nil { + return fmt.Errorf("compiler.Compile: %w", err) + } + return nil +} + +func (s *Service) lintKafkaProto(ctx context.Context, project projectdomain.Project, result lintdomain.Result, target projectdomain.Target) (lintdomain.Result, error) { + if s.proto == nil { + return result, nil + } + if err := s.proto.Lint(ctx, project, target); err != nil { + operationErr := &lintdomain.OperationError{Operation: lintdomain.OperationReadContract, Target: target.ID, Path: target.Input, Kind: lintdomain.FailureUnavailable, Cause: err} + return result, fmt.Errorf("proto.Lint: %w", operationErr) + } + return result, nil +} + +func (s *Service) lintGRPCTarget(ctx context.Context, project projectdomain.Project, result lintdomain.Result, target projectdomain.Target) (lintdomain.Result, error) { + result.Contracts = append(result.Contracts, target.ID) + if target.Role == "server" || target.Location.Local { + files, err := s.contracts.ListProtoFiles(ctx, project.Root, target.Input) + if err != nil { + operationErr := &lintdomain.OperationError{Operation: lintdomain.OperationReadContract, Target: target.ID, Path: target.Input, Kind: lintdomain.FailureUnavailable, Cause: err} + return result, fmt.Errorf("contracts.ListProtoFiles: %w", operationErr) + } + for _, filename := range files { + if !protoFilenamePattern.MatchString(path.Base(filename)) { + result.Issues = append(result.Issues, lintdomain.Issue{Code: "proto_filename", Target: target.ID, Path: filename}) + } + } + } + result.Valid = len(result.Issues) == 0 + if s.proto != nil { + if err := s.proto.Lint(ctx, project, target); err != nil { + operationErr := &lintdomain.OperationError{Operation: lintdomain.OperationReadContract, Target: target.ID, Path: target.Input, Kind: lintdomain.FailureUnavailable, Cause: err} + return result, fmt.Errorf("proto.Lint: %w", operationErr) + } + } + return result, nil +} + +func lintIssues(target projectdomain.Target, contractPath string, report platformopenapi.Report) []lintdomain.Issue { + issues := make([]lintdomain.Issue, 0, len(report.Findings)+len(report.Operations)) + for _, finding := range report.Findings { + issues = append(issues, lintdomain.Issue{ + Code: string(finding.Kind), Target: target.ID, Path: contractPath, + Line: finding.Line, Column: finding.Column, Field: finding.Field, + Parameters: &lintdomain.Parameters{ + Type: finding.Type, Subtype: finding.Subtype, SpecPath: finding.SpecPath, + }, + }) + } + if !strings.HasPrefix(report.Version, "3.1.") { + issues = append(issues, lintdomain.Issue{Code: "openapi_version", Target: target.ID, Path: contractPath}) + } + seen := map[string]struct{}{} + for _, operation := range report.Operations { + parameters := &lintdomain.Parameters{OperationID: operation.OperationID, Location: operation.Method + " " + operation.Path} + switch { + case operation.OperationID == "": + issues = append(issues, lintdomain.Issue{Code: "operation_id_missing", Target: target.ID, Path: contractPath, Line: operation.Line, Column: operation.Column, Parameters: parameters}) + case hasOperationID(seen, operation.OperationID): + issues = append(issues, lintdomain.Issue{Code: "operation_id_duplicate", Target: target.ID, Path: contractPath, Line: operation.Line, Column: operation.Column, Parameters: parameters}) + } + if !hasSuccessfulResponse(operation.Responses) { + issues = append(issues, lintdomain.Issue{Code: "response_2xx_missing", Target: target.ID, Path: contractPath, Line: operation.Line, Column: operation.Column, Parameters: parameters}) + } + } + return issues +} + +func hasOperationID(seen map[string]struct{}, operationID string) bool { + _, exists := seen[operationID] + seen[operationID] = struct{}{} + return exists +} + +func hasSuccessfulResponse(responses []string) bool { + for _, response := range responses { + if response == "2XX" { + return true + } + if len(response) == 3 && response[0] == '2' && response[1] >= '0' && response[1] <= '9' && response[2] >= '0' && response[2] <= '9' { + return true + } + } + return false +} diff --git a/internal/service/lint/service_test.go b/internal/service/lint/service_test.go new file mode 100644 index 0000000..90ef002 --- /dev/null +++ b/internal/service/lint/service_test.go @@ -0,0 +1,432 @@ +package lint_test + +import ( + "context" + "errors" + "testing" + + "github.com/devctllabs/devctl/internal/domain/contract" + "github.com/devctllabs/devctl/internal/domain/failure" + lintdomain "github.com/devctllabs/devctl/internal/domain/lint" + projectdomain "github.com/devctllabs/devctl/internal/domain/project" + lintservice "github.com/devctllabs/devctl/internal/service/lint" + "github.com/devctllabs/devctl/internal/service/lint/mocks" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + "go.uber.org/zap" +) + +func TestLintServiceOwnsContractCatalogAndOutcome(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + projects := mocks.NewMockProjectRepository(ctrl) + contracts := mocks.NewMockContractLocator(ctrl) + inputs := mocks.NewMockTargetResolver(ctrl) + project := projectdomain.Project{Root: "/project", Manifest: projectdomain.Manifest{ + Paths: projectdomain.ManifestPaths{ExternalContracts: "api/external"}, + Sources: map[string]projectdomain.Source{"remote": {Type: "url"}}, + Components: projectdomain.Components{HTTP: &projectdomain.HTTP{ + Server: &projectdomain.HTTPServer{OpenAPI: "api/openapi/swagger.yaml"}, + Clients: []projectdomain.HTTPClient{ + {Name: "remote", Source: "remote", Path: "openapi.yaml"}, + }, + }}, + }} + projects.EXPECT().LoadProject(gomock.Any(), "custom.yaml").Return(project, nil) + serverContract := "/project/api/openapi/swagger.yaml" + remoteContract := "/project/api/external/http/client/remote/openapi.yaml" + targets := projectdomain.NewTargetCatalog(project.Manifest).Select(projectdomain.TargetOperationLint, "http", "") + remoteTarget, serverTarget := targets[0], targets[1] + resolvedRemote, resolvedServer := remoteTarget, serverTarget + resolvedRemote.Input, resolvedServer.Input = remoteContract, serverContract + gomock.InOrder( + inputs.EXPECT().Resolve(gomock.Any(), project, remoteTarget).Return(resolvedRemote, nil), + contracts.EXPECT().ReadContract(gomock.Any(), remoteContract).Return([]byte(`openapi: 3.1.0 +info: {title: Remote, version: 1.0.0} +paths: + /first: + get: {operationId: duplicate, responses: {"200": {description: ok}}} + /second: + get: {operationId: duplicate, responses: {"200": {description: ok}}} +`), nil), + inputs.EXPECT().Resolve(gomock.Any(), project, serverTarget).Return(resolvedServer, nil), + contracts.EXPECT().ReadContract(gomock.Any(), serverContract).Return([]byte(`openapi: 3.1.0 +info: {title: Server, version: 1.0.0} +paths: + /health: + get: {operationId: health, responses: {"200": {description: ok}}} +`), nil), + ) + service := lintservice.New(zap.NewNop(), lintservice.Dependencies{ + Projects: projects, Contracts: contracts, Inputs: inputs, + }) + + result, err := service.Lint(context.Background(), lintdomain.Command{ManifestPath: "custom.yaml", Family: "http"}) + + require.NoError(t, err) + require.False(t, result.Valid) + require.Equal(t, []string{"http-client:remote", "http-server"}, result.Contracts) + require.Len(t, result.Issues, 1) + require.Equal(t, "operation_id_duplicate", result.Issues[0].Code) + require.Equal(t, "http-client:remote", result.Issues[0].Target) +} + +func TestLintServicePreservesHTTPInputFailureSemantics(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + projects := mocks.NewMockProjectRepository(ctrl) + contracts := mocks.NewMockContractLocator(ctrl) + inputs := mocks.NewMockTargetResolver(ctrl) + cause := errors.New("entrypoint unavailable") + selected := projectdomain.Project{Root: "/project", Manifest: projectdomain.Manifest{ + Components: projectdomain.Components{HTTP: &projectdomain.HTTP{Server: &projectdomain.HTTPServer{ + OpenAPI: "api/openapi/swagger.yaml", + }}}, + }} + target := projectdomain.NewTargetCatalog(selected.Manifest).Select(projectdomain.TargetOperationLint, "http", "")[0] + projects.EXPECT().LoadProject(gomock.Any(), "devctl.yaml").Return(selected, nil) + inputs.EXPECT().Resolve(gomock.Any(), selected, target).Return(target, cause) + service := lintservice.New(zap.NewNop(), lintservice.Dependencies{ + Projects: projects, Contracts: contracts, Inputs: inputs, + }) + + _, err := service.Lint(context.Background(), lintdomain.Command{ManifestPath: "devctl.yaml", Family: "http"}) + + var operationErr *lintdomain.OperationError + require.ErrorAs(t, err, &operationErr) + require.Equal(t, lintdomain.OperationLocateContract, operationErr.Operation) + require.Equal(t, target.ID, operationErr.Target) + require.Equal(t, target.Reference.Entrypoint, operationErr.Path) + require.Equal(t, lintdomain.FailureUnavailable, operationErr.Kind) + require.ErrorIs(t, err, cause) +} + +func TestLintServiceReportsInvalidGRPCProtoFilename(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + projects := mocks.NewMockProjectRepository(ctrl) + contracts := mocks.NewMockContractLocator(ctrl) + proto := mocks.NewMockProtoLinter(ctrl) + project := projectdomain.Project{Root: "/project", Manifest: projectdomain.Manifest{ + Components: projectdomain.Components{GRPC: &projectdomain.GRPC{ + Server: &projectdomain.GRPCServer{ProtoRoot: "api/proto", BufConfig: "buf.yaml"}, + }}, + }} + projects.EXPECT().LoadProject(gomock.Any(), "devctl.yaml").Return(project, nil) + contracts.EXPECT().ListProtoFiles(gomock.Any(), project.Root, "api/proto").Return([]string{ + "api/proto/acme/v1/sample.common_types.proto", + "api/proto/acme/v1/service.proto", + }, nil) + target := projectdomain.NewTargetCatalog(project.Manifest).Select(projectdomain.TargetOperationLint, "grpc", "")[0] + proto.EXPECT().Lint(gomock.Any(), project, target).Return(nil) + service := lintservice.New(zap.NewNop(), lintservice.Dependencies{ + Projects: projects, Contracts: contracts, Inputs: passthroughTargetResolver(ctrl), Proto: proto, + }) + + result, err := service.Lint(context.Background(), lintdomain.Command{ManifestPath: "devctl.yaml", Family: "grpc"}) + + require.NoError(t, err) + require.False(t, result.Valid) + require.Equal(t, []string{"grpc-server"}, result.Contracts) + require.Equal(t, []lintdomain.Issue{{ + Code: "proto_filename", Target: "grpc-server", Path: "api/proto/acme/v1/service.proto", + }}, result.Issues) +} + +func TestLintServiceChecksGRPCClientsWithoutServer(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + projects := mocks.NewMockProjectRepository(ctrl) + contracts := mocks.NewMockContractLocator(ctrl) + proto := mocks.NewMockProtoLinter(ctrl) + project := projectdomain.Project{Root: "/project", Manifest: projectdomain.Manifest{ + Sources: map[string]projectdomain.Source{"contracts": {Type: "local", Path: "api/contracts"}}, + Components: projectdomain.Components{GRPC: &projectdomain.GRPC{Clients: []projectdomain.GRPCClient{{ + Name: "billing", Source: "contracts", Path: "proto/acme/billing/v1", ProtoRoot: "proto", + }}}}, + }} + target := projectdomain.NewTargetCatalog(project.Manifest).Select(projectdomain.TargetOperationLint, "grpc", "")[0] + projects.EXPECT().LoadProject(gomock.Any(), "devctl.yaml").Return(project, nil) + contracts.EXPECT().ListProtoFiles(gomock.Any(), project.Root, target.Input).Return([]string{ + "api/contracts/proto/acme/billing/v1/billing_service.invoice_service.proto", + }, nil) + proto.EXPECT().Lint(gomock.Any(), project, target).Return(nil) + service := lintservice.New(zap.NewNop(), lintservice.Dependencies{ + Projects: projects, Contracts: contracts, Inputs: passthroughTargetResolver(ctrl), Proto: proto, + }) + + result, err := service.Lint(context.Background(), lintdomain.Command{ManifestPath: "devctl.yaml", Family: "grpc"}) + + require.NoError(t, err) + require.True(t, result.Valid) + require.Equal(t, []string{"grpc-client:billing"}, result.Contracts) + require.Empty(t, result.Issues) +} + +func TestLintServiceReportsInvalidKafkaTopic(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + projects := mocks.NewMockProjectRepository(ctrl) + contracts := mocks.NewMockContractLocator(ctrl) + project := projectdomain.Project{Root: "/project", Manifest: projectdomain.Manifest{ + Components: projectdomain.Components{Kafka: &projectdomain.Kafka{Producers: []projectdomain.KafkaProducer{{ + Name: "audit", Topic: "audit.events", Contract: projectdomain.KafkaContract{Format: "raw"}, + }}}}, + }} + projects.EXPECT().LoadProject(gomock.Any(), "devctl.yaml").Return(project, nil) + service := lintservice.New(zap.NewNop(), lintservice.Dependencies{ + Projects: projects, Contracts: contracts, Inputs: passthroughTargetResolver(ctrl), + }) + + result, err := service.Lint(context.Background(), lintdomain.Command{ManifestPath: "devctl.yaml", Family: "kafka"}) + + require.NoError(t, err) + require.False(t, result.Valid) + require.Equal(t, []string{"kafka-producer:audit"}, result.Contracts) + require.Equal(t, []lintdomain.Issue{{ + Code: "kafka_topic", Target: "kafka-producer:audit", + }}, result.Issues) +} + +func TestLintServiceRejectsCommittedKafkaMetadataThatDoesNotMatchTarget(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + projects := mocks.NewMockProjectRepository(ctrl) + contracts := mocks.NewMockContractLocator(ctrl) + inputs := mocks.NewMockTargetResolver(ctrl) + project := projectdomain.Project{Root: "/project", Manifest: projectdomain.Manifest{ + Paths: projectdomain.ManifestPaths{ExternalContracts: "api/external"}, + Sources: map[string]projectdomain.Source{"upstream": {Type: projectdomain.SourceDevctl}}, + Components: projectdomain.Components{Kafka: &projectdomain.Kafka{Consumers: []projectdomain.KafkaConsumer{{ + Name: "events", Topic: "downstream_service.domain.events.v1", Contract: projectdomain.KafkaContract{ + Format: "proto", Source: "upstream", Export: "events", + }, + }}}}, + }} + projects.EXPECT().LoadProject(gomock.Any(), "devctl.yaml").Return(project, nil) + metadataErr := &contract.SnapshotMetadataError{ + Field: "topic", Reason: contract.MetadataMismatch, Hint: "devctl sync", + } + target := projectdomain.NewTargetCatalog(project.Manifest).Select(projectdomain.TargetOperationLint, "kafka", "")[0] + inputs.EXPECT().Resolve(gomock.Any(), project, target).Return(target, metadataErr) + service := lintservice.New(zap.NewNop(), lintservice.Dependencies{ + Projects: projects, Contracts: contracts, Inputs: inputs, + }) + + _, err := service.Lint(context.Background(), lintdomain.Command{ManifestPath: "devctl.yaml", Family: "kafka"}) + + require.ErrorIs(t, err, metadataErr) + require.Equal(t, failure.InvalidInput, failure.CategoryOf(err)) +} + +func TestLintServiceReportsMismatchedLocalKafkaSchemaFilename(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + projects := mocks.NewMockProjectRepository(ctrl) + contracts := mocks.NewMockContractLocator(ctrl) + project := projectdomain.Project{Root: "/project", Manifest: projectdomain.Manifest{ + Sources: map[string]projectdomain.Source{"events": {Type: "local", Path: "api/events"}}, + Components: projectdomain.Components{Kafka: &projectdomain.Kafka{Producers: []projectdomain.KafkaProducer{{ + Name: "audit", Topic: "audit_service.audit.created.v1", + Contract: projectdomain.KafkaContract{Source: "events", Format: "json", Path: "wrong_name.json"}, + }}}}, + }} + projects.EXPECT().LoadProject(gomock.Any(), "devctl.yaml").Return(project, nil) + contracts.EXPECT().ResolveContract(gomock.Any(), contract.Location{Root: "/project", RelativePath: "api/events", Entrypoint: "wrong_name.json", Local: true}).Return("/project/api/events/wrong_name.json", nil) + contracts.EXPECT().ReadContract(gomock.Any(), "/project/api/events/wrong_name.json").Return([]byte(`{"title":"AuditEvent","type":"object"}`), nil) + service := lintservice.New(zap.NewNop(), lintservice.Dependencies{ + Projects: projects, Contracts: contracts, Inputs: passthroughTargetResolver(ctrl), + }) + + result, err := service.Lint(context.Background(), lintdomain.Command{ManifestPath: "devctl.yaml", Family: "kafka"}) + + require.NoError(t, err) + require.False(t, result.Valid) + require.Equal(t, []string{"kafka-producer:audit"}, result.Contracts) + require.Equal(t, []lintdomain.Issue{{ + Code: "kafka_schema_filename", Target: "kafka-producer:audit", Path: "wrong_name.json", + }}, result.Issues) +} + +func TestLintServiceCompilesKafkaJSONSchema(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + projects := mocks.NewMockProjectRepository(ctrl) + contracts := mocks.NewMockContractLocator(ctrl) + project := projectdomain.Project{Root: "/project", Manifest: projectdomain.Manifest{ + Sources: map[string]projectdomain.Source{"events": {Type: "local", Path: "api/contracts"}}, + Components: projectdomain.Components{Kafka: &projectdomain.Kafka{Consumers: []projectdomain.KafkaConsumer{{ + Name: "audit", Topic: "audit_service.audit.created.v1", + Contract: projectdomain.KafkaContract{Format: "json", Source: "events", Path: "schemas/audit_service.audit.created.v1.json"}, + }}}}, + }} + projects.EXPECT().LoadProject(gomock.Any(), "devctl.yaml").Return(project, nil) + location := contract.Location{Root: project.Root, RelativePath: "api/contracts", Entrypoint: "schemas/audit_service.audit.created.v1.json", Local: true} + contracts.EXPECT().ResolveContract(gomock.Any(), location).Return("/project/api/contracts/schemas/audit_service.audit.created.v1.json", nil) + contracts.EXPECT().ReadContract(gomock.Any(), "/project/api/contracts/schemas/audit_service.audit.created.v1.json").Return([]byte(`{"$schema":"https://json-schema.org/draft/2020-12/schema","type":42}`), nil) + service := lintservice.New(zap.NewNop(), lintservice.Dependencies{ + Projects: projects, Contracts: contracts, Inputs: passthroughTargetResolver(ctrl), + }) + + result, err := service.Lint(context.Background(), lintdomain.Command{ManifestPath: "devctl.yaml", Family: "kafka"}) + + require.NoError(t, err) + require.False(t, result.Valid) + require.Equal(t, []lintdomain.Issue{{ + Code: "json_schema", Target: "kafka-consumer:audit", Path: "/project/api/contracts/schemas/audit_service.audit.created.v1.json", + }}, result.Issues) +} + +func TestLintServiceResolvesDevctlKafkaJSONFromCommittedMetadata(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + projects := mocks.NewMockProjectRepository(ctrl) + contracts := mocks.NewMockContractLocator(ctrl) + inputs := mocks.NewMockTargetResolver(ctrl) + const topic = "audit_service.audit.created.v1" + project := projectdomain.Project{Root: "/project", Manifest: projectdomain.Manifest{ + Sources: map[string]projectdomain.Source{"events": {Type: projectdomain.SourceDevctl}}, + Components: projectdomain.Components{Kafka: &projectdomain.Kafka{Consumers: []projectdomain.KafkaConsumer{{ + Name: "audit", Topic: topic, + Contract: projectdomain.KafkaContract{Format: "json", Source: "events", Export: "audit"}, + }}}}, + }} + target := projectdomain.NewTargetCatalog(project.Manifest).Select(projectdomain.TargetOperationLint, "kafka", "")[0] + snapshot := contract.Snapshot{ + Entrypoint: "schemas/event.json", + Metadata: &contract.Metadata{ + Kind: "kafka", Topic: topic, Format: "json", Entrypoint: "schemas/event.json", + }, + } + resolvedInput := target.WithSnapshot(snapshot) + resolved := resolvedInput + resolved.Location.Root = project.Root + projects.EXPECT().LoadProject(gomock.Any(), "devctl.yaml").Return(project, nil) + inputs.EXPECT().Resolve(gomock.Any(), project, target).Return(resolvedInput, nil) + contracts.EXPECT().ResolveContract(gomock.Any(), resolved.Location).Return( + "/project/api/external/kafka/consumer/audit/schemas/event.json", nil, + ) + contracts.EXPECT().ReadContract( + gomock.Any(), "/project/api/external/kafka/consumer/audit/schemas/event.json", + ).Return([]byte(`{"title":"Event","type":"object"}`), nil) + service := lintservice.New(zap.NewNop(), lintservice.Dependencies{ + Projects: projects, Contracts: contracts, Inputs: inputs, + }) + + result, err := service.Lint(context.Background(), lintdomain.Command{ManifestPath: "devctl.yaml", Family: "kafka"}) + + require.NoError(t, err) + require.True(t, result.Valid) +} + +func TestLintServiceRequiresKafkaJSONSchemaTitle(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + projects := mocks.NewMockProjectRepository(ctrl) + contracts := mocks.NewMockContractLocator(ctrl) + project := projectdomain.Project{Root: "/project", Manifest: projectdomain.Manifest{ + Sources: map[string]projectdomain.Source{"events": {Type: "local", Path: "api/contracts"}}, + Components: projectdomain.Components{Kafka: &projectdomain.Kafka{Consumers: []projectdomain.KafkaConsumer{{ + Name: "audit", Topic: "audit_service.audit.created.v1", + Contract: projectdomain.KafkaContract{Format: "json", Source: "events", Path: "schemas/audit_service.audit.created.v1.json"}, + }}}}, + }} + projects.EXPECT().LoadProject(gomock.Any(), "devctl.yaml").Return(project, nil) + location := contract.Location{Root: project.Root, RelativePath: "api/contracts", Entrypoint: "schemas/audit_service.audit.created.v1.json", Local: true} + contracts.EXPECT().ResolveContract(gomock.Any(), location).Return("/project/api/contracts/schemas/audit_service.audit.created.v1.json", nil) + contracts.EXPECT().ReadContract(gomock.Any(), "/project/api/contracts/schemas/audit_service.audit.created.v1.json").Return([]byte(`{"type":"object"}`), nil) + service := lintservice.New(zap.NewNop(), lintservice.Dependencies{ + Projects: projects, Contracts: contracts, Inputs: passthroughTargetResolver(ctrl), + }) + + result, err := service.Lint(context.Background(), lintdomain.Command{ManifestPath: "devctl.yaml", Family: "kafka"}) + + require.NoError(t, err) + require.False(t, result.Valid) + require.Equal(t, []lintdomain.Issue{{ + Code: "json_schema_title", Target: "kafka-consumer:audit", + Path: "/project/api/contracts/schemas/audit_service.audit.created.v1.json", Field: "title", + }}, result.Issues) +} + +func TestLintServiceWithoutFamilyIncludesKafkaContracts(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + projects := mocks.NewMockProjectRepository(ctrl) + contracts := mocks.NewMockContractLocator(ctrl) + project := projectdomain.Project{Root: "/project", Manifest: projectdomain.Manifest{ + Components: projectdomain.Components{Kafka: &projectdomain.Kafka{Consumers: []projectdomain.KafkaConsumer{{ + Name: "audit", Topic: "audit.events", Contract: projectdomain.KafkaContract{Format: "raw"}, + }}}}, + }} + projects.EXPECT().LoadProject(gomock.Any(), "devctl.yaml").Return(project, nil) + service := lintservice.New(zap.NewNop(), lintservice.Dependencies{ + Projects: projects, Contracts: contracts, Inputs: passthroughTargetResolver(ctrl), + }) + + result, err := service.Lint(context.Background(), lintdomain.Command{ManifestPath: "devctl.yaml"}) + + require.NoError(t, err) + require.False(t, result.Valid) + require.Equal(t, []string{"kafka-consumer:audit"}, result.Contracts) + require.Equal(t, []lintdomain.Issue{{Code: "kafka_topic", Target: "kafka-consumer:audit"}}, result.Issues) +} + +func TestLintServiceAppliesCatalogFamilySelectionContract(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + family string + category failure.Category + }{ + {name: "known empty family", family: "grpc"}, + {name: "unknown family", family: "other", category: failure.InvalidInput}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + projects := mocks.NewMockProjectRepository(ctrl) + contracts := mocks.NewMockContractLocator(ctrl) + projects.EXPECT().LoadProject(gomock.Any(), "devctl.yaml").Return(projectdomain.Project{ + Root: "/project", Manifest: projectdomain.Manifest{Project: projectdomain.Identity{Language: "go"}}, + }, nil) + + result, err := lintservice.New(zap.NewNop(), lintservice.Dependencies{ + Projects: projects, Contracts: contracts, Inputs: passthroughTargetResolver(ctrl), + }).Lint( + context.Background(), lintdomain.Command{ManifestPath: "devctl.yaml", Family: test.family}, + ) + + if test.category != "" { + require.Equal(t, test.category, failure.CategoryOf(err)) + return + } + require.NoError(t, err) + require.True(t, result.Valid) + require.Empty(t, result.Contracts) + }) + } +} + +func passthroughTargetResolver(ctrl *gomock.Controller) *mocks.MockTargetResolver { + resolver := mocks.NewMockTargetResolver(ctrl) + resolver.EXPECT().Resolve(gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, _ projectdomain.Project, target projectdomain.Target) (projectdomain.Target, error) { + return target, nil + }, + ).AnyTimes() + return resolver +} diff --git a/internal/service/materialize/closure.go b/internal/service/materialize/closure.go new file mode 100644 index 0000000..df33cc5 --- /dev/null +++ b/internal/service/materialize/closure.go @@ -0,0 +1,96 @@ +package materialize + +import ( + "context" + "fmt" + "path" + "strings" + + "github.com/devctllabs/devctl/internal/domain/contract" + materializedomain "github.com/devctllabs/devctl/internal/domain/materialize" + "gopkg.in/yaml.v3" +) + +// referenceClosure follows contained local $ref values from entrypoint and ignores document fragments and remote references. +func referenceClosure(ctx context.Context, reader FileReader, root, entrypoint string) (contract.Snapshot, error) { + if !safeRelative(entrypoint) { + return contract.Snapshot{}, &materializedomain.OperationError{Operation: materializedomain.OperationValidateSource, Path: entrypoint, Kind: materializedomain.FailureInvalid} + } + entrypoint = path.Clean(entrypoint) + queue := []string{entrypoint} + files := make([]contract.File, 0) + seen := make(map[string]struct{}) + for len(queue) > 0 { + current := queue[0] + queue = queue[1:] + if _, exists := seen[current]; exists { + continue + } + if err := ctx.Err(); err != nil { + return contract.Snapshot{}, fmt.Errorf("ctx.Err: %w", err) + } + file, err := reader.ReadFile(ctx, root, current) + if err != nil { + operationErr := &materializedomain.OperationError{Operation: materializedomain.OperationReadFile, Path: current, Kind: materializedomain.FailureUnavailable, Cause: err} + return contract.Snapshot{}, fmt.Errorf("reader.ReadFile: %w", operationErr) + } + file.Path = current + files = append(files, file) + seen[current] = struct{}{} + references, err := localReferences(current, file.Content) + if err != nil { + return contract.Snapshot{}, err + } + queue = append(queue, references...) + } + return newSnapshot(entrypoint, files) +} + +func localReferences(current string, data []byte) ([]string, error) { + resolved := make([]string, 0) + for _, reference := range collectReferences(data) { + relative := strings.SplitN(reference, "#", 2)[0] + if relative == "" || strings.Contains(relative, "://") { + continue + } + name := path.Clean(path.Join(path.Dir(current), relative)) + if !safeRelative(name) { + return nil, &materializedomain.OperationError{Operation: materializedomain.OperationValidateSource, Path: reference, Kind: materializedomain.FailureInvalid} + } + resolved = append(resolved, name) + } + return resolved, nil +} + +func collectReferences(data []byte) []string { + var document yaml.Node + if yaml.Unmarshal(data, &document) != nil { + return nil + } + var references []string + visitYAML(&document, func(key, value *yaml.Node) { + if key.Value == "$ref" && value.Kind == yaml.ScalarNode { + references = append(references, value.Value) + } + }) + return references +} + +func visitYAML(node *yaml.Node, visit func(key, value *yaml.Node)) { + if node.Kind == yaml.MappingNode { + for index := 0; index+1 < len(node.Content); index += 2 { + visit(node.Content[index], node.Content[index+1]) + } + } + for _, child := range node.Content { + visitYAML(child, visit) + } +} + +func safeRelative(name string) bool { + if name == "" || strings.HasPrefix(name, "/") { + return false + } + clean := path.Clean(strings.ReplaceAll(name, "\\", "/")) + return clean != "." && clean != ".." && !strings.HasPrefix(clean, "../") +} diff --git a/internal/service/materialize/devctl.go b/internal/service/materialize/devctl.go new file mode 100644 index 0000000..532db96 --- /dev/null +++ b/internal/service/materialize/devctl.go @@ -0,0 +1,215 @@ +package materialize + +import ( + "context" + "errors" + "fmt" + "path" + "path/filepath" + + "github.com/devctllabs/devctl/internal/domain/contract" + materializedomain "github.com/devctllabs/devctl/internal/domain/materialize" + "github.com/devctllabs/devctl/internal/domain/project" + "github.com/devctllabs/devctl/internal/service/contractsnapshot" +) + +const contractMetadataFile = ".devctl-contract.json" + +// DevctlService resolves a named export from a temporary upstream Devctl project checkout. +type DevctlService struct { + client GitClient + manifests ManifestRepository + reader FileReader + snapshots *contractsnapshot.Loader +} + +func NewDevctl(client GitClient, manifests ManifestRepository, reader FileReader) *DevctlService { + return &DevctlService{ + client: client, manifests: manifests, reader: reader, + snapshots: contractsnapshot.New(reader), + } +} + +func (s *DevctlService) SourceType() project.SourceType { return project.SourceDevctl } + +func (s *DevctlService) Materialize(ctx context.Context, request materializedomain.Request) (contract.Snapshot, error) { + var snapshot contract.Snapshot + err := s.client.WithCheckout(ctx, request.Source.Repo, request.Source.Ref, func(root string) error { + var materializeErr error + snapshot, materializeErr = s.materializeCheckout(ctx, root, request) + return materializeErr + }) + if err != nil { + return snapshot, fmt.Errorf("client.WithCheckout: %w", err) + } + return snapshot, nil +} + +func (s *DevctlService) materializeCheckout(ctx context.Context, root string, request materializedomain.Request) (contract.Snapshot, error) { + loaded, err := s.manifests.Load(ctx, filepath.Join(root, "devctl.yaml")) + if err != nil { + return contract.Snapshot{}, &materializedomain.UpstreamManifestError{Repository: request.Source.Repo, Ref: request.Source.Ref, Cause: err} + } + if len(loaded.Issues) > 0 { + return contract.Snapshot{}, &materializedomain.UpstreamManifestError{Repository: request.Source.Repo, Ref: request.Source.Ref} + } + manifest := loaded.Project.Manifest + exported, exists := manifest.Exports[request.Reference.Export] + if !exists { + return contract.Snapshot{}, &materializedomain.ExportNotFoundError{Name: request.Reference.Export} + } + if !manifest.ExportMatchesSurface(exported) { + return contract.Snapshot{}, &materializedomain.InvalidExportError{Name: request.Reference.Export} + } + switch exported.Kind { + case "openapi": + return referenceClosure(ctx, s.reader, root, exported.Path) + case "grpc": + return s.materializeGRPCExport(ctx, root, manifest, exported) + case "kafka": + producer, exists := kafkaProducer(manifest, exported.Producer) + if !exists { + return contract.Snapshot{}, &materializedomain.ExportNotFoundError{Name: exported.Producer} + } + if request.Reference.Topic != "" && request.Reference.Topic != producer.Topic { + return contract.Snapshot{}, &materializedomain.KafkaTopicMismatchError{Expected: producer.Topic, Actual: request.Reference.Topic} + } + format := kafkaFormat(producer.Contract) + if request.Reference.Format != "" && request.Reference.Format != format { + return contract.Snapshot{}, &materializedomain.KafkaFormatMismatchError{Expected: format, Actual: request.Reference.Format} + } + return s.materializeKafkaProducer(ctx, root, manifest, producer) + default: + return contract.Snapshot{}, &materializedomain.UnsupportedExportError{Name: request.Reference.Export, Kind: exported.Kind} + } +} + +func (s *DevctlService) materializeGRPCExport( + ctx context.Context, + root string, + manifest project.Manifest, + exported project.Export, +) (contract.Snapshot, error) { + snapshot, err := s.snapshots.Load( + ctx, root, exported.Path, contract.MetadataExpectation{Kind: "grpc", Format: "proto"}, + ) + if err == nil { + return snapshot, nil + } + if !committedMetadataRequired(err) { + return contract.Snapshot{}, fmt.Errorf("snapshots.Load: %w", err) + } + files, err := s.reader.ReadTree(ctx, root, exported.Path) + if err != nil { + return contract.Snapshot{}, &materializedomain.OperationError{Operation: materializedomain.OperationReadFile, Path: exported.Path, Kind: materializedomain.FailureUnavailable, Cause: err} + } + if len(files) == 0 { + return contract.Snapshot{}, &materializedomain.OperationError{Operation: materializedomain.OperationBuildSnapshot, Path: exported.Path, Kind: materializedomain.FailureNotFound} + } + bufConfig := "buf.yaml" + if manifest.Components.GRPC.Server.BufConfig != "" { + bufConfig = manifest.Components.GRPC.Server.BufConfig + } + files, err = includeBufFiles(ctx, s.reader, bufFilesRequest{ + root: root, files: files, configPath: bufConfig, + }) + if err != nil { + return contract.Snapshot{}, err + } + snapshot, err = newProtoSnapshot(exported.Path, "", files) + if err != nil { + return contract.Snapshot{}, err + } + snapshot.Metadata = &contract.Metadata{ + Kind: "grpc", Format: "proto", ModuleRoot: exported.Path, BufConfig: bufConfig, + } + return snapshot, nil +} + +func (s *DevctlService) materializeKafkaProducer(ctx context.Context, root string, manifest project.Manifest, producer project.KafkaProducer) (contract.Snapshot, error) { + format := kafkaFormat(producer.Contract) + metadata := &contract.Metadata{Kind: "kafka", Topic: producer.Topic, Format: format} + if format == "raw" { + return contract.Snapshot{Metadata: metadata}, nil + } + source, exists := manifest.Sources[producer.Contract.Source] + if !exists { + return contract.Snapshot{}, &materializedomain.ExportNotFoundError{Name: producer.Contract.Source} + } + if source.Type != project.SourceLocal { + return s.materializeSyncedKafkaProducer(ctx, root, manifest, producer) + } + sourceRoot := filepath.Join(root, filepath.FromSlash(source.Path)) + reference := contract.Reference{ + Entrypoint: producer.Contract.Path, + Format: format, + ProtoRoot: producer.Contract.ProtoRoot, + } + var snapshot contract.Snapshot + var err error + if format == "proto" { + snapshot, err = protoTree(ctx, s.reader, protoTreeRequest{ + root: sourceRoot, reference: reference, bufConfig: source.Proto.BufConfig, + }) + } else { + snapshot, err = referenceClosure(ctx, s.reader, sourceRoot, reference.Entrypoint) + } + if err != nil { + return contract.Snapshot{}, err + } + metadata.Entrypoint = snapshot.Entrypoint + if format == "proto" { + metadata.ModuleRoot = snapshot.ModuleRoot + metadata.BufConfig = source.Proto.BufConfig + } + snapshot.Metadata = metadata + if err := contract.ValidateSnapshot(snapshot, contract.MetadataExpectation{ + Kind: "kafka", Topic: producer.Topic, Format: format, + }); err != nil { + return contract.Snapshot{}, fmt.Errorf("contract.ValidateSnapshot: %w", err) + } + return snapshot, nil +} + +func (s *DevctlService) materializeSyncedKafkaProducer(ctx context.Context, root string, manifest project.Manifest, producer project.KafkaProducer) (contract.Snapshot, error) { + externalRoot := manifest.Paths.ExternalContracts + if externalRoot == "" { + externalRoot = "api/external" + } + treeRoot := path.Join(externalRoot, "kafka", "producer", producer.Name) + snapshot, err := s.snapshots.Load( + ctx, root, treeRoot, + contract.MetadataExpectation{ + Kind: "kafka", Topic: producer.Topic, Format: kafkaFormat(producer.Contract), + }, + ) + if err != nil { + return contract.Snapshot{}, fmt.Errorf("snapshots.Load: %w", err) + } + return snapshot, nil +} + +func committedMetadataRequired(err error) bool { + var metadataErr *contract.SnapshotMetadataError + return errors.As(err, &metadataErr) && + metadataErr.Field == contractMetadataFile && metadataErr.Reason == contract.MetadataRequired +} + +func kafkaFormat(selected project.KafkaContract) string { + if selected.Format == "" { + return "raw" + } + return selected.Format +} + +func kafkaProducer(manifest project.Manifest, name string) (project.KafkaProducer, bool) { + if manifest.Components.Kafka == nil { + return project.KafkaProducer{}, false + } + for _, producer := range manifest.Components.Kafka.Producers { + if producer.Name == name { + return producer, true + } + } + return project.KafkaProducer{}, false +} diff --git a/internal/service/materialize/git.go b/internal/service/materialize/git.go new file mode 100644 index 0000000..1cc5262 --- /dev/null +++ b/internal/service/materialize/git.go @@ -0,0 +1,49 @@ +package materialize + +import ( + "context" + "fmt" + "path/filepath" + + "github.com/devctllabs/devctl/internal/domain/contract" + materializedomain "github.com/devctllabs/devctl/internal/domain/materialize" + "github.com/devctllabs/devctl/internal/domain/project" +) + +// GitService reads a contract closure from a temporary repository checkout. +type GitService struct { + client GitClient + reader FileReader +} + +func NewGit(client GitClient, reader FileReader) *GitService { + return &GitService{client: client, reader: reader} +} + +func (s *GitService) SourceType() project.SourceType { return project.SourceGit } + +func (s *GitService) Materialize(ctx context.Context, request materializedomain.Request) (contract.Snapshot, error) { + var snapshot contract.Snapshot + err := s.client.WithCheckout(ctx, request.Source.Repo, request.Source.Ref, func(root string) error { + if request.Source.Path != "" { + if !safeRelative(request.Source.Path) { + return &materializedomain.OperationError{Operation: materializedomain.OperationValidateSource, Path: request.Source.Path, Kind: materializedomain.FailureInvalid} + } + root = filepath.Join(root, filepath.FromSlash(request.Source.Path)) + } + var materializeErr error + if request.Reference.Format == "proto" { + snapshot, materializeErr = protoTree(ctx, s.reader, protoTreeRequest{ + root: root, reference: request.Reference, bufConfig: request.Source.Proto.BufConfig, + }) + } else { + snapshot, materializeErr = referenceClosure(ctx, s.reader, root, request.Reference.Entrypoint) + } + return materializeErr + }) + if err != nil { + operationErr := &materializedomain.OperationError{Operation: materializedomain.OperationCheckout, SourceType: project.SourceGit, Kind: materializedomain.FailureUnavailable, Cause: err} + return snapshot, fmt.Errorf("client.WithCheckout: %w", operationErr) + } + return snapshot, nil +} diff --git a/internal/service/materialize/local.go b/internal/service/materialize/local.go new file mode 100644 index 0000000..938bac8 --- /dev/null +++ b/internal/service/materialize/local.go @@ -0,0 +1,36 @@ +package materialize + +import ( + "context" + "path/filepath" + + "github.com/devctllabs/devctl/internal/domain/contract" + materializedomain "github.com/devctllabs/devctl/internal/domain/materialize" + "github.com/devctllabs/devctl/internal/domain/project" +) + +// LocalService reads a contract closure contained by the current project. +type LocalService struct { + reader FileReader +} + +func NewLocal(reader FileReader) *LocalService { return &LocalService{reader: reader} } + +func (s *LocalService) SourceType() project.SourceType { return project.SourceLocal } + +func (s *LocalService) Materialize(ctx context.Context, request materializedomain.Request) (contract.Snapshot, error) { + if !safeRelative(request.Source.Path) { + return contract.Snapshot{}, &materializedomain.OperationError{ + Operation: materializedomain.OperationValidateSource, + Path: request.Source.Path, + Kind: materializedomain.FailureInvalid, + } + } + sourceRoot := filepath.Join(request.Root, filepath.FromSlash(request.Source.Path)) + if request.Reference.Format == "proto" { + return protoTree(ctx, s.reader, protoTreeRequest{ + root: sourceRoot, reference: request.Reference, bufConfig: request.Source.Proto.BufConfig, + }) + } + return referenceClosure(ctx, s.reader, sourceRoot, request.Reference.Entrypoint) +} diff --git a/internal/service/materialize/mocks/ports.go b/internal/service/materialize/mocks/ports.go new file mode 100644 index 0000000..81f70ea --- /dev/null +++ b/internal/service/materialize/mocks/ports.go @@ -0,0 +1,310 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/devctllabs/devctl/internal/service/materialize (interfaces: FileReader,HTTPClient,GitClient,ManifestRepository) +// +// Generated by this command: +// +// mockgen -destination mocks/ports.go -package mocks -typed . FileReader,HTTPClient,GitClient,ManifestRepository +// + +// Package mocks is a generated GoMock package. +package mocks + +import ( + context "context" + reflect "reflect" + + contract "github.com/devctllabs/devctl/internal/domain/contract" + materialize "github.com/devctllabs/devctl/internal/domain/materialize" + project "github.com/devctllabs/devctl/internal/domain/project" + gomock "go.uber.org/mock/gomock" +) + +// MockFileReader is a mock of FileReader interface. +type MockFileReader struct { + ctrl *gomock.Controller + recorder *MockFileReaderMockRecorder + isgomock struct{} +} + +// MockFileReaderMockRecorder is the mock recorder for MockFileReader. +type MockFileReaderMockRecorder struct { + mock *MockFileReader +} + +// NewMockFileReader creates a new mock instance. +func NewMockFileReader(ctrl *gomock.Controller) *MockFileReader { + mock := &MockFileReader{ctrl: ctrl} + mock.recorder = &MockFileReaderMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockFileReader) EXPECT() *MockFileReaderMockRecorder { + return m.recorder +} + +// ReadFile mocks base method. +func (m *MockFileReader) ReadFile(ctx context.Context, root, name string) (contract.File, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ReadFile", ctx, root, name) + ret0, _ := ret[0].(contract.File) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ReadFile indicates an expected call of ReadFile. +func (mr *MockFileReaderMockRecorder) ReadFile(ctx, root, name any) *MockFileReaderReadFileCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ReadFile", reflect.TypeOf((*MockFileReader)(nil).ReadFile), ctx, root, name) + return &MockFileReaderReadFileCall{Call: call} +} + +// MockFileReaderReadFileCall wrap *gomock.Call +type MockFileReaderReadFileCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockFileReaderReadFileCall) Return(arg0 contract.File, arg1 error) *MockFileReaderReadFileCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockFileReaderReadFileCall) Do(f func(context.Context, string, string) (contract.File, error)) *MockFileReaderReadFileCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockFileReaderReadFileCall) DoAndReturn(f func(context.Context, string, string) (contract.File, error)) *MockFileReaderReadFileCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// ReadTree mocks base method. +func (m *MockFileReader) ReadTree(ctx context.Context, root, directory string) ([]contract.File, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ReadTree", ctx, root, directory) + ret0, _ := ret[0].([]contract.File) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ReadTree indicates an expected call of ReadTree. +func (mr *MockFileReaderMockRecorder) ReadTree(ctx, root, directory any) *MockFileReaderReadTreeCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ReadTree", reflect.TypeOf((*MockFileReader)(nil).ReadTree), ctx, root, directory) + return &MockFileReaderReadTreeCall{Call: call} +} + +// MockFileReaderReadTreeCall wrap *gomock.Call +type MockFileReaderReadTreeCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockFileReaderReadTreeCall) Return(arg0 []contract.File, arg1 error) *MockFileReaderReadTreeCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockFileReaderReadTreeCall) Do(f func(context.Context, string, string) ([]contract.File, error)) *MockFileReaderReadTreeCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockFileReaderReadTreeCall) DoAndReturn(f func(context.Context, string, string) ([]contract.File, error)) *MockFileReaderReadTreeCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// MockHTTPClient is a mock of HTTPClient interface. +type MockHTTPClient struct { + ctrl *gomock.Controller + recorder *MockHTTPClientMockRecorder + isgomock struct{} +} + +// MockHTTPClientMockRecorder is the mock recorder for MockHTTPClient. +type MockHTTPClientMockRecorder struct { + mock *MockHTTPClient +} + +// NewMockHTTPClient creates a new mock instance. +func NewMockHTTPClient(ctrl *gomock.Controller) *MockHTTPClient { + mock := &MockHTTPClient{ctrl: ctrl} + mock.recorder = &MockHTTPClientMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockHTTPClient) EXPECT() *MockHTTPClientMockRecorder { + return m.recorder +} + +// Fetch mocks base method. +func (m *MockHTTPClient) Fetch(ctx context.Context, request materialize.HTTPFetchRequest) (materialize.HTTPDocument, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Fetch", ctx, request) + ret0, _ := ret[0].(materialize.HTTPDocument) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Fetch indicates an expected call of Fetch. +func (mr *MockHTTPClientMockRecorder) Fetch(ctx, request any) *MockHTTPClientFetchCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Fetch", reflect.TypeOf((*MockHTTPClient)(nil).Fetch), ctx, request) + return &MockHTTPClientFetchCall{Call: call} +} + +// MockHTTPClientFetchCall wrap *gomock.Call +type MockHTTPClientFetchCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockHTTPClientFetchCall) Return(arg0 materialize.HTTPDocument, arg1 error) *MockHTTPClientFetchCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockHTTPClientFetchCall) Do(f func(context.Context, materialize.HTTPFetchRequest) (materialize.HTTPDocument, error)) *MockHTTPClientFetchCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockHTTPClientFetchCall) DoAndReturn(f func(context.Context, materialize.HTTPFetchRequest) (materialize.HTTPDocument, error)) *MockHTTPClientFetchCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// MockGitClient is a mock of GitClient interface. +type MockGitClient struct { + ctrl *gomock.Controller + recorder *MockGitClientMockRecorder + isgomock struct{} +} + +// MockGitClientMockRecorder is the mock recorder for MockGitClient. +type MockGitClientMockRecorder struct { + mock *MockGitClient +} + +// NewMockGitClient creates a new mock instance. +func NewMockGitClient(ctrl *gomock.Controller) *MockGitClient { + mock := &MockGitClient{ctrl: ctrl} + mock.recorder = &MockGitClientMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockGitClient) EXPECT() *MockGitClientMockRecorder { + return m.recorder +} + +// WithCheckout mocks base method. +func (m *MockGitClient) WithCheckout(ctx context.Context, repository, ref string, use func(string) error) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "WithCheckout", ctx, repository, ref, use) + ret0, _ := ret[0].(error) + return ret0 +} + +// WithCheckout indicates an expected call of WithCheckout. +func (mr *MockGitClientMockRecorder) WithCheckout(ctx, repository, ref, use any) *MockGitClientWithCheckoutCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "WithCheckout", reflect.TypeOf((*MockGitClient)(nil).WithCheckout), ctx, repository, ref, use) + return &MockGitClientWithCheckoutCall{Call: call} +} + +// MockGitClientWithCheckoutCall wrap *gomock.Call +type MockGitClientWithCheckoutCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockGitClientWithCheckoutCall) Return(arg0 error) *MockGitClientWithCheckoutCall { + c.Call = c.Call.Return(arg0) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockGitClientWithCheckoutCall) Do(f func(context.Context, string, string, func(string) error) error) *MockGitClientWithCheckoutCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockGitClientWithCheckoutCall) DoAndReturn(f func(context.Context, string, string, func(string) error) error) *MockGitClientWithCheckoutCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// MockManifestRepository is a mock of ManifestRepository interface. +type MockManifestRepository struct { + ctrl *gomock.Controller + recorder *MockManifestRepositoryMockRecorder + isgomock struct{} +} + +// MockManifestRepositoryMockRecorder is the mock recorder for MockManifestRepository. +type MockManifestRepositoryMockRecorder struct { + mock *MockManifestRepository +} + +// NewMockManifestRepository creates a new mock instance. +func NewMockManifestRepository(ctrl *gomock.Controller) *MockManifestRepository { + mock := &MockManifestRepository{ctrl: ctrl} + mock.recorder = &MockManifestRepositoryMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockManifestRepository) EXPECT() *MockManifestRepositoryMockRecorder { + return m.recorder +} + +// Load mocks base method. +func (m *MockManifestRepository) Load(ctx context.Context, manifestPath string) (project.LoadManifestResult, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Load", ctx, manifestPath) + ret0, _ := ret[0].(project.LoadManifestResult) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Load indicates an expected call of Load. +func (mr *MockManifestRepositoryMockRecorder) Load(ctx, manifestPath any) *MockManifestRepositoryLoadCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Load", reflect.TypeOf((*MockManifestRepository)(nil).Load), ctx, manifestPath) + return &MockManifestRepositoryLoadCall{Call: call} +} + +// MockManifestRepositoryLoadCall wrap *gomock.Call +type MockManifestRepositoryLoadCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockManifestRepositoryLoadCall) Return(arg0 project.LoadManifestResult, arg1 error) *MockManifestRepositoryLoadCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockManifestRepositoryLoadCall) Do(f func(context.Context, string) (project.LoadManifestResult, error)) *MockManifestRepositoryLoadCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockManifestRepositoryLoadCall) DoAndReturn(f func(context.Context, string) (project.LoadManifestResult, error)) *MockManifestRepositoryLoadCall { + c.Call = c.Call.DoAndReturn(f) + return c +} diff --git a/internal/service/materialize/ports.go b/internal/service/materialize/ports.go new file mode 100644 index 0000000..271593c --- /dev/null +++ b/internal/service/materialize/ports.go @@ -0,0 +1,38 @@ +package materialize + +import ( + "context" + + "github.com/devctllabs/devctl/internal/domain/contract" + materializedomain "github.com/devctllabs/devctl/internal/domain/materialize" + "github.com/devctllabs/devctl/internal/domain/project" +) + +//go:generate go tool mockgen -destination mocks/ports.go -package mocks -typed . FileReader,HTTPClient,GitClient,ManifestRepository + +// FileReader reads contained files for snapshot construction. +type FileReader interface { + // ReadFile returns a regular non-symlink file below root with its relative path and permission bits. + ReadFile(ctx context.Context, root, name string) (contract.File, error) + // ReadTree returns every regular non-symlink file below a contained directory in deterministic order. + ReadTree(ctx context.Context, root, directory string) ([]contract.File, error) +} + +// HTTPClient retrieves remote contract bytes. +type HTTPClient interface { + // Fetch retrieves request.URL and returns its effective URL after redirects. + // Request.OriginURL defines the scheme, host, and effective port that every request must retain. + Fetch(ctx context.Context, request materializedomain.HTTPFetchRequest) (materializedomain.HTTPDocument, error) +} + +// GitClient provides a temporary checkout for the lifetime of one callback. +type GitClient interface { + // WithCheckout invokes use with repository at ref and cleans the worktree after use succeeds or fails. + WithCheckout(ctx context.Context, repository, ref string, use func(root string) error) error +} + +// ManifestRepository reads upstream Devctl manifests by exact path. +type ManifestRepository interface { + // Load returns structural manifest issues as data and access failures as errors. + Load(ctx context.Context, manifestPath string) (project.LoadManifestResult, error) +} diff --git a/internal/service/materialize/proto.go b/internal/service/materialize/proto.go new file mode 100644 index 0000000..8be058e --- /dev/null +++ b/internal/service/materialize/proto.go @@ -0,0 +1,123 @@ +package materialize + +import ( + "context" + "errors" + "fmt" + "io/fs" + "path" + "strings" + + "github.com/devctllabs/devctl/internal/domain/contract" + materializedomain "github.com/devctllabs/devctl/internal/domain/materialize" +) + +type protoTreeRequest struct { + root string + reference contract.Reference + bufConfig string +} + +type bufFilesRequest struct { + root string + files []contract.File + configPath string +} + +func protoTree(ctx context.Context, reader FileReader, request protoTreeRequest) (contract.Snapshot, error) { + root := request.root + reference := request.reference + protoRoot := reference.ProtoRoot + if protoRoot == "" { + protoRoot = path.Dir(reference.Entrypoint) + } + if !safeProtoSelection(protoRoot, reference.Entrypoint) { + return contract.Snapshot{}, &materializedomain.OperationError{ + Operation: materializedomain.OperationValidateSource, + Path: reference.Entrypoint, + Kind: materializedomain.FailureInvalid, + } + } + files, err := reader.ReadTree(ctx, root, protoRoot) + if err != nil { + operationErr := &materializedomain.OperationError{Operation: materializedomain.OperationReadFile, Path: protoRoot, Kind: materializedomain.FailureUnavailable, Cause: err} + return contract.Snapshot{}, fmt.Errorf("reader.ReadTree: %w", operationErr) + } + if request.bufConfig != "" { + files, err = includeBufFiles(ctx, reader, bufFilesRequest{ + root: root, files: files, configPath: request.bufConfig, + }) + if err != nil { + return contract.Snapshot{}, err + } + } + return newProtoSnapshot(protoRoot, reference.Entrypoint, files) +} + +func includeBufFiles( + ctx context.Context, + reader FileReader, + request bufFilesRequest, +) ([]contract.File, error) { + root, files, configPath := request.root, request.files, request.configPath + if !safeRelative(configPath) { + return nil, &materializedomain.OperationError{ + Operation: materializedomain.OperationValidateSource, + Path: configPath, + Kind: materializedomain.FailureInvalid, + } + } + config, err := reader.ReadFile(ctx, root, configPath) + if err != nil { + return nil, bufReadError(configPath, err) + } + config.Path = configPath + files = replaceFile(files, config) + + lockPath := path.Join(path.Dir(configPath), "buf.lock") + lock, err := reader.ReadFile(ctx, root, lockPath) + if errors.Is(err, fs.ErrNotExist) { + return files, nil + } + if err != nil { + return nil, bufReadError(lockPath, err) + } + lock.Path = lockPath + return replaceFile(files, lock), nil +} + +func replaceFile(files []contract.File, replacement contract.File) []contract.File { + result := make([]contract.File, 0, len(files)+1) + for _, file := range files { + if cleanSnapshotPath(file.Path) != replacement.Path { + result = append(result, file) + } + } + return append(result, replacement) +} + +func bufReadError(name string, cause error) error { + kind := materializedomain.FailureUnavailable + if errors.Is(cause, fs.ErrNotExist) { + kind = materializedomain.FailureNotFound + } + operationErr := &materializedomain.OperationError{ + Operation: materializedomain.OperationReadFile, + Path: name, + Kind: kind, + Cause: cause, + } + return fmt.Errorf("reader.ReadFile: %w", operationErr) +} + +func safeProtoSelection(root, entrypoint string) bool { + if root != "." && !safeRelative(root) { + return false + } + if !safeRelative(entrypoint) { + return false + } + root = path.Clean(root) + entrypoint = path.Clean(entrypoint) + return root == "." || entrypoint == root || strings.HasPrefix(entrypoint, root+"/") +} diff --git a/internal/service/materialize/service.go b/internal/service/materialize/service.go new file mode 100644 index 0000000..ce22c43 --- /dev/null +++ b/internal/service/materialize/service.go @@ -0,0 +1,54 @@ +package materialize + +import ( + "context" + "fmt" + + "github.com/devctllabs/devctl/internal/domain/contract" + materializedomain "github.com/devctllabs/devctl/internal/domain/materialize" + "github.com/devctllabs/devctl/internal/domain/project" +) + +// Strategy materializes exactly one project source type. +type Strategy interface { + // SourceType returns the single source type owned by the strategy. + SourceType() project.SourceType + // Materialize returns the selected exact contract closure without publishing it. + Materialize(ctx context.Context, request materializedomain.Request) (contract.Snapshot, error) +} + +type Service struct { + strategies map[project.SourceType]Strategy +} + +// New builds an immutable strategy router from any subset and rejects duplicate source types. +func New(strategies ...Strategy) (*Service, error) { + byType := make(map[project.SourceType]Strategy, len(strategies)) + for _, strategy := range strategies { + sourceType := strategy.SourceType() + if _, exists := byType[sourceType]; exists { + return nil, &materializedomain.OperationError{Operation: materializedomain.OperationConfigureRouter, SourceType: sourceType, Kind: materializedomain.FailureInvalid} + } + byType[sourceType] = strategy + } + return &Service{strategies: byType}, nil +} + +// Materialize routes request by source type and reports an unsupported source when no strategy was configured. +func (s *Service) Materialize( + ctx context.Context, + root string, + source project.Source, + reference contract.Reference, +) (contract.Snapshot, error) { + request := materializedomain.Request{Root: root, Source: source, Reference: reference} + strategy, exists := s.strategies[source.Type] + if !exists { + return contract.Snapshot{}, &materializedomain.UnsupportedSourceError{SourceType: source.Type} + } + snapshot, err := strategy.Materialize(ctx, request) + if err != nil { + return snapshot, fmt.Errorf("strategy.Materialize: %w", err) + } + return snapshot, nil +} diff --git a/internal/service/materialize/service_test.go b/internal/service/materialize/service_test.go new file mode 100644 index 0000000..ee381f5 --- /dev/null +++ b/internal/service/materialize/service_test.go @@ -0,0 +1,66 @@ +package materialize_test + +import ( + "context" + "testing" + + "github.com/devctllabs/devctl/internal/domain/contract" + "github.com/devctllabs/devctl/internal/domain/failure" + materializedomain "github.com/devctllabs/devctl/internal/domain/materialize" + "github.com/devctllabs/devctl/internal/domain/project" + "github.com/devctllabs/devctl/internal/service/materialize" + "github.com/stretchr/testify/require" +) + +type strategy struct { + typeName project.SourceType + called bool +} + +func (s *strategy) SourceType() project.SourceType { return s.typeName } + +func (s *strategy) Materialize(context.Context, materializedomain.Request) (contract.Snapshot, error) { + s.called = true + return contract.Snapshot{Entrypoint: "openapi.yaml", Files: []contract.File{{Path: "openapi.yaml", Content: []byte("openapi: 3.1.0")}}}, nil +} + +func TestRouterAcceptsStrategySubsetAndRoutesBySourceType(t *testing.T) { + t.Parallel() + + local := &strategy{typeName: project.SourceLocal} + service, err := materialize.New(local) + require.NoError(t, err) + + snapshot, err := service.Materialize( + context.Background(), + "", + project.Source{Type: project.SourceLocal}, + contract.Reference{Entrypoint: "openapi.yaml"}, + ) + + require.NoError(t, err) + require.True(t, local.called) + require.Equal(t, "openapi.yaml", snapshot.Entrypoint) +} + +func TestRouterRejectsDuplicateSourceType(t *testing.T) { + t.Parallel() + + _, err := materialize.New(&strategy{typeName: project.SourceURL}, &strategy{typeName: project.SourceURL}) + + require.Equal(t, failure.InvalidInput, failure.CategoryOf(err)) +} + +func TestRouterReportsMissingStrategyAtCallTime(t *testing.T) { + t.Parallel() + + service, err := materialize.New() + require.NoError(t, err) + + _, err = service.Materialize(context.Background(), "", project.Source{Type: project.SourceGit}, contract.Reference{}) + + require.Equal(t, failure.Unsupported, failure.CategoryOf(err)) + var unsupported *materializedomain.UnsupportedSourceError + require.ErrorAs(t, err, &unsupported) + require.Equal(t, project.SourceGit, unsupported.SourceType) +} diff --git a/internal/service/materialize/snapshot.go b/internal/service/materialize/snapshot.go new file mode 100644 index 0000000..325a5ec --- /dev/null +++ b/internal/service/materialize/snapshot.go @@ -0,0 +1,75 @@ +package materialize + +import ( + "path" + "sort" + "strings" + + "github.com/devctllabs/devctl/internal/domain/contract" + materializedomain "github.com/devctllabs/devctl/internal/domain/materialize" +) + +// newSnapshot copies, normalizes, and sorts files while requiring unique contained paths and a present entrypoint. +func newSnapshot(entrypoint string, files []contract.File) (contract.Snapshot, error) { + normalized, seen, err := normalizeSnapshotFiles(files) + if err != nil { + return contract.Snapshot{}, err + } + entrypoint = cleanSnapshotPath(entrypoint) + if _, exists := seen[entrypoint]; !exists { + return contract.Snapshot{}, invalidSnapshotPath(entrypoint) + } + return contract.Snapshot{Entrypoint: entrypoint, Files: normalized}, nil +} + +func newProtoSnapshot(moduleRoot, entrypoint string, files []contract.File) (contract.Snapshot, error) { + normalized, seen, err := normalizeSnapshotFiles(files) + if err != nil { + return contract.Snapshot{}, err + } + moduleRoot = cleanSnapshotPath(moduleRoot) + if !safeRelativeOrCurrent(moduleRoot) { + return contract.Snapshot{}, invalidSnapshotPath(moduleRoot) + } + if entrypoint != "" { + entrypoint = cleanSnapshotPath(entrypoint) + if _, exists := seen[entrypoint]; !exists { + return contract.Snapshot{}, invalidSnapshotPath(entrypoint) + } + } + return contract.Snapshot{ModuleRoot: moduleRoot, Entrypoint: entrypoint, Files: normalized}, nil +} + +func normalizeSnapshotFiles(files []contract.File) ([]contract.File, map[string]struct{}, error) { + normalized := make([]contract.File, 0, len(files)) + seen := make(map[string]struct{}, len(files)) + for _, file := range files { + name := cleanSnapshotPath(file.Path) + if !safeRelative(name) { + return nil, nil, invalidSnapshotPath(name) + } + if _, exists := seen[name]; exists { + return nil, nil, invalidSnapshotPath(name) + } + seen[name] = struct{}{} + normalized = append(normalized, contract.File{Path: name, Content: append([]byte(nil), file.Content...), Mode: file.Mode}) + } + sort.Slice(normalized, func(i, j int) bool { return normalized[i].Path < normalized[j].Path }) + return normalized, seen, nil +} + +func cleanSnapshotPath(name string) string { + return path.Clean(strings.ReplaceAll(name, "\\", "/")) +} + +func safeRelativeOrCurrent(name string) bool { + return name == "." || safeRelative(name) +} + +func invalidSnapshotPath(name string) error { + return &materializedomain.OperationError{ + Operation: materializedomain.OperationBuildSnapshot, + Path: name, + Kind: materializedomain.FailureInvalid, + } +} diff --git a/internal/service/materialize/strategy_test.go b/internal/service/materialize/strategy_test.go new file mode 100644 index 0000000..5c9e5a3 --- /dev/null +++ b/internal/service/materialize/strategy_test.go @@ -0,0 +1,1019 @@ +package materialize_test + +import ( + "context" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/devctllabs/devctl/internal/domain/contract" + "github.com/devctllabs/devctl/internal/domain/failure" + materializedomain "github.com/devctllabs/devctl/internal/domain/materialize" + "github.com/devctllabs/devctl/internal/domain/project" + workspacerepo "github.com/devctllabs/devctl/internal/repository/workspace" + "github.com/devctllabs/devctl/internal/service/materialize" + "github.com/devctllabs/devctl/internal/service/materialize/mocks" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" +) + +func TestLocalMaterializesReferenceClosure(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + reader := mocks.NewMockFileReader(ctrl) + reader.EXPECT().ReadFile(gomock.Any(), "/project/api/contracts", "openapi.yaml").Return(contract.File{ + Content: []byte("openapi: 3.1.0\ncomponents:\n schemas:\n Item:\n $ref: './components.yaml#/Item'\n"), Mode: 0o644, + }, nil) + reader.EXPECT().ReadFile(gomock.Any(), "/project/api/contracts", "components.yaml").Return(contract.File{ + Content: []byte("Item:\n type: object\n"), Mode: 0o644, + }, nil) + + snapshot, err := materialize.NewLocal(reader).Materialize(context.Background(), materializedomain.Request{ + Root: "/project", + Source: project.Source{Type: project.SourceLocal, Path: "api/contracts"}, + Reference: contract.Reference{Entrypoint: "openapi.yaml"}, + }) + + require.NoError(t, err) + require.Equal(t, "openapi.yaml", snapshot.Entrypoint) + require.Len(t, snapshot.Files, 2) +} + +func TestLocalMaterializesProtoRootTreeAndBufMetadata(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + reader := mocks.NewMockFileReader(ctrl) + reader.EXPECT().ReadTree(gomock.Any(), "/project/api/contracts", "proto").Return([]contract.File{ + {Path: "proto/acme/v1/common.proto", Content: []byte("syntax = \"proto3\";\n"), Mode: 0o644}, + {Path: "proto/acme/v1/service.proto", Content: []byte("syntax = \"proto3\";\n"), Mode: 0o644}, + }, nil) + reader.EXPECT().ReadFile(gomock.Any(), "/project/api/contracts", "buf.yaml").Return(contract.File{ + Path: "buf.yaml", Content: []byte("version: v2\n"), Mode: 0o644, + }, nil) + reader.EXPECT().ReadFile(gomock.Any(), "/project/api/contracts", "buf.lock").Return(contract.File{}, fs.ErrNotExist) + + snapshot, err := materialize.NewLocal(reader).Materialize(context.Background(), materializedomain.Request{ + Root: "/project", + Source: project.Source{Type: project.SourceLocal, Path: "api/contracts", Proto: project.SourceProto{ + BufConfig: "buf.yaml", + }}, + Reference: contract.Reference{Entrypoint: "proto/acme/v1/service.proto", Format: "proto", ProtoRoot: "proto"}, + }) + + require.NoError(t, err) + require.Equal(t, "proto/acme/v1/service.proto", snapshot.Entrypoint) + require.Equal(t, []string{"buf.yaml", "proto/acme/v1/common.proto", "proto/acme/v1/service.proto"}, snapshotPaths(snapshot)) +} + +func TestLocalIncludesBufConfigAndAdjacentLockOnlyOnceWhenTheyAreInsideProtoRoot(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + reader := mocks.NewMockFileReader(ctrl) + reader.EXPECT().ReadTree(gomock.Any(), "/project/api/contracts", "proto").Return([]contract.File{ + {Path: "proto/acme/v1/service.proto", Content: []byte("syntax = \"proto3\";\n"), Mode: 0o644}, + {Path: "proto/buf.lock", Content: []byte("deps: []\n"), Mode: 0o644}, + {Path: "proto/buf.yaml", Content: []byte("version: v2\n"), Mode: 0o644}, + }, nil) + reader.EXPECT().ReadFile(gomock.Any(), "/project/api/contracts", "proto/buf.yaml").Return(contract.File{ + Content: []byte("version: v2\n"), Mode: 0o644, + }, nil) + reader.EXPECT().ReadFile(gomock.Any(), "/project/api/contracts", "proto/buf.lock").Return(contract.File{ + Content: []byte("deps: []\n"), Mode: 0o644, + }, nil) + + snapshot, err := materialize.NewLocal(reader).Materialize(context.Background(), materializedomain.Request{ + Root: "/project", + Source: project.Source{Type: project.SourceLocal, Path: "api/contracts", Proto: project.SourceProto{ + BufConfig: "proto/buf.yaml", + }}, + Reference: contract.Reference{Entrypoint: "proto/acme/v1/service.proto", Format: "proto", ProtoRoot: "proto"}, + }) + + require.NoError(t, err) + require.Equal(t, []string{"proto/acme/v1/service.proto", "proto/buf.lock", "proto/buf.yaml"}, snapshotPaths(snapshot)) +} + +func TestLocalReportsInvalidBufConfigFileAtTheSelectedPath(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + config string + prepare func(*testing.T, string) + category failure.Category + }{ + { + name: "missing", config: "buf/missing.yaml", category: failure.NotFound, + prepare: func(*testing.T, string) {}, + }, + { + name: "directory", config: "buf/config", category: failure.Unavailable, + prepare: func(t *testing.T, root string) { + t.Helper() + require.NoError(t, os.MkdirAll(filepath.Join(root, "contracts/buf/config"), 0o755)) + }, + }, + { + name: "symlink", config: "buf/link.yaml", category: failure.Unavailable, + prepare: func(t *testing.T, root string) { + t.Helper() + target := filepath.Join(root, "contracts/buf/target.yaml") + require.NoError(t, os.MkdirAll(filepath.Dir(target), 0o755)) + require.NoError(t, os.WriteFile(target, []byte("version: v2\n"), 0o644)) + require.NoError(t, os.Symlink("target.yaml", filepath.Join(root, "contracts/buf/link.yaml"))) + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + root := t.TempDir() + proto := filepath.Join(root, "contracts/proto/service.proto") + require.NoError(t, os.MkdirAll(filepath.Dir(proto), 0o755)) + require.NoError(t, os.WriteFile(proto, []byte("syntax = \"proto3\";\n"), 0o644)) + test.prepare(t, root) + + _, err := materialize.NewLocal(workspacerepo.NewFilesystemRepo()).Materialize( + context.Background(), + materializedomain.Request{ + Root: root, + Source: project.Source{Type: project.SourceLocal, Path: "contracts", Proto: project.SourceProto{ + BufConfig: test.config, + }}, + Reference: contract.Reference{Entrypoint: "proto/service.proto", Format: "proto", ProtoRoot: "proto"}, + }, + ) + + require.Equal(t, test.category, failure.CategoryOf(err)) + var operationErr *materializedomain.OperationError + require.ErrorAs(t, err, &operationErr) + require.Equal(t, materializedomain.OperationReadFile, operationErr.Operation) + require.Equal(t, test.config, operationErr.Path) + }) + } +} + +func snapshotPaths(snapshot contract.Snapshot) []string { + paths := make([]string, 0, len(snapshot.Files)) + for _, file := range snapshot.Files { + paths = append(paths, file.Path) + } + return paths +} + +func TestURLRejectsInsecureSourceBeforeClientCall(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + client := mocks.NewMockHTTPClient(ctrl) + + _, err := materialize.NewURL(client).Materialize(context.Background(), materializedomain.Request{ + Source: project.Source{Type: project.SourceURL, URL: "http://example.test/openapi.yaml"}, + }) + + require.Error(t, err) +} + +func TestURLRejectsCredentialedSourceWithoutLeakingCredentialsOrQuery(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + client := mocks.NewMockHTTPClient(ctrl) + + _, err := materialize.NewURL(client).Materialize(context.Background(), materializedomain.Request{ + Source: project.Source{ + Type: project.SourceURL, + URL: "https://user:password@example.test/openapi.yaml?token=secret", + }, + }) + + require.Equal(t, failure.InvalidInput, failure.CategoryOf(err)) + var operationErr *materializedomain.OperationError + require.ErrorAs(t, err, &operationErr) + require.Equal(t, "https://example.test/openapi.yaml", operationErr.Path) + require.NotContains(t, err.Error(), "password") + require.NotContains(t, err.Error(), "token=") +} + +func TestURLBuildsSnapshotFromDownloadedDocument(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + client := mocks.NewMockHTTPClient(ctrl) + client.EXPECT().Fetch(gomock.Any(), materializedomain.HTTPFetchRequest{ + URL: "https://example.test/openapi.yaml", OriginURL: "https://example.test/openapi.yaml", + }).Return(materializedomain.HTTPDocument{ + URL: "https://example.test/openapi.yaml", Content: []byte("openapi: 3.1.0\n"), + }, nil) + + snapshot, err := materialize.NewURL(client).Materialize(context.Background(), materializedomain.Request{ + Source: project.Source{Type: project.SourceURL, URL: "https://example.test/openapi.yaml"}, + Reference: contract.Reference{Entrypoint: "spec/openapi.yaml"}, + }) + + require.NoError(t, err) + require.Equal(t, "spec/openapi.yaml", snapshot.Entrypoint) + require.Equal(t, []byte("openapi: 3.1.0\n"), snapshot.Files[0].Content) +} + +func TestURLMaterializesRelativeReferenceClosureInFetchAndVirtualPaths(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + client := mocks.NewMockHTTPClient(ctrl) + const sourceURL = "https://example.test/contracts/openapi.yaml" + client.EXPECT().Fetch(gomock.Any(), materializedomain.HTTPFetchRequest{ + URL: sourceURL, OriginURL: sourceURL, + }).Return(materializedomain.HTTPDocument{ + URL: sourceURL, + Content: []byte("openapi: 3.1.0\ncomponents:\n schemas:\n Item:\n" + + " $ref: './schemas/common.yaml#/Item'\n"), + }, nil) + client.EXPECT().Fetch(gomock.Any(), materializedomain.HTTPFetchRequest{ + URL: "https://example.test/contracts/schemas/common.yaml", OriginURL: sourceURL, + }).Return(materializedomain.HTTPDocument{ + URL: "https://example.test/contracts/schemas/common.yaml", + Content: []byte("Item:\n type: object\n"), + }, nil) + + snapshot, err := materialize.NewURL(client).Materialize(context.Background(), materializedomain.Request{ + Source: project.Source{Type: project.SourceURL, URL: sourceURL}, + Reference: contract.Reference{Entrypoint: "spec/openapi.yaml"}, + }) + + require.NoError(t, err) + require.Equal(t, "spec/openapi.yaml", snapshot.Entrypoint) + require.Equal(t, []string{"spec/openapi.yaml", "spec/schemas/common.yaml"}, snapshotPaths(snapshot)) +} + +func TestURLMaterializesJSONSchemaReferenceClosure(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + client := mocks.NewMockHTTPClient(ctrl) + const sourceURL = "https://example.test/contracts/event.json" + client.EXPECT().Fetch(gomock.Any(), materializedomain.HTTPFetchRequest{ + URL: sourceURL, OriginURL: sourceURL, + }).Return(materializedomain.HTTPDocument{ + URL: sourceURL, Content: []byte(`{"$ref":"./schemas/payload.json#/$defs/Payload"}`), + }, nil) + client.EXPECT().Fetch(gomock.Any(), materializedomain.HTTPFetchRequest{ + URL: "https://example.test/contracts/schemas/payload.json", OriginURL: sourceURL, + }).Return(materializedomain.HTTPDocument{ + URL: "https://example.test/contracts/schemas/payload.json", + Content: []byte(`{"$defs":{"Payload":{"type":"object"}}}`), + }, nil) + + snapshot, err := materialize.NewURL(client).Materialize(context.Background(), materializedomain.Request{ + Source: project.Source{Type: project.SourceURL, URL: sourceURL}, + Reference: contract.Reference{Entrypoint: "schemas/event.json", Format: "json"}, + }) + + require.NoError(t, err) + require.Equal(t, []string{"schemas/event.json", "schemas/schemas/payload.json"}, snapshotPaths(snapshot)) +} + +func TestURLResolvesReferenceFromEffectiveURLAfterRedirect(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + client := mocks.NewMockHTTPClient(ctrl) + const sourceURL = "https://example.test/contracts/start" + client.EXPECT().Fetch(gomock.Any(), materializedomain.HTTPFetchRequest{ + URL: sourceURL, OriginURL: sourceURL, + }).Return(materializedomain.HTTPDocument{ + URL: "https://example.test/redirected/openapi.yaml", + Content: []byte("$ref: './schema.yaml'\n"), + }, nil) + client.EXPECT().Fetch(gomock.Any(), materializedomain.HTTPFetchRequest{ + URL: "https://example.test/redirected/schema.yaml", OriginURL: sourceURL, + }).Return(materializedomain.HTTPDocument{ + URL: "https://example.test/redirected/schema.yaml", Content: []byte("type: object\n"), + }, nil) + + snapshot, err := materialize.NewURL(client).Materialize(context.Background(), materializedomain.Request{ + Source: project.Source{Type: project.SourceURL, URL: sourceURL}, + Reference: contract.Reference{Entrypoint: "spec/openapi.yaml"}, + }) + + require.NoError(t, err) + require.Equal(t, []string{"spec/openapi.yaml", "spec/schema.yaml"}, snapshotPaths(snapshot)) +} + +func TestURLRejectsReferenceEscapingVirtualSourceRoot(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + client := mocks.NewMockHTTPClient(ctrl) + const sourceURL = "https://example.test/contracts/openapi.yaml" + client.EXPECT().Fetch(gomock.Any(), materializedomain.HTTPFetchRequest{ + URL: sourceURL, OriginURL: sourceURL, + }).Return(materializedomain.HTTPDocument{ + URL: sourceURL, + Content: []byte("openapi: 3.1.0\ncomponents:\n schemas:\n Item:\n $ref: '../shared.yaml#/Item'\n"), + }, nil) + + _, err := materialize.NewURL(client).Materialize(context.Background(), materializedomain.Request{ + Source: project.Source{Type: project.SourceURL, URL: sourceURL}, + Reference: contract.Reference{Entrypoint: "spec/openapi.yaml"}, + }) + + require.Equal(t, failure.InvalidInput, failure.CategoryOf(err)) +} + +func TestURLTerminatesCyclesAndIgnoresAbsoluteReferences(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + client := mocks.NewMockHTTPClient(ctrl) + const sourceURL = "https://example.test/contracts/openapi.yaml" + gomock.InOrder( + client.EXPECT().Fetch(gomock.Any(), materializedomain.HTTPFetchRequest{ + URL: sourceURL, OriginURL: sourceURL, + }).Return(materializedomain.HTTPDocument{ + URL: sourceURL, + Content: []byte("openapi: 3.1.0\nrefs:\n" + + " - {$ref: './schemas/common.yaml#/Item'}\n" + + " - {$ref: '#/components/schemas/Local'}\n" + + " - {$ref: 'https://example.test/contracts/ignored.yaml'}\n" + + " - {$ref: '/contracts/ignored.yaml'}\n" + + " - {$ref: '//example.test/contracts/ignored.yaml'}\n"), + }, nil), + client.EXPECT().Fetch(gomock.Any(), materializedomain.HTTPFetchRequest{ + URL: "https://example.test/contracts/schemas/common.yaml", OriginURL: sourceURL, + }).Return(materializedomain.HTTPDocument{ + URL: "https://example.test/contracts/schemas/common.yaml", + Content: []byte("Item:\n $ref: '../openapi.yaml#/components/schemas/Local'\n"), + }, nil), + ) + + snapshot, err := materialize.NewURL(client).Materialize(context.Background(), materializedomain.Request{ + Source: project.Source{Type: project.SourceURL, URL: sourceURL}, + Reference: contract.Reference{Entrypoint: "spec/openapi.yaml"}, + }) + + require.NoError(t, err) + require.Equal(t, []string{"spec/openapi.yaml", "spec/schemas/common.yaml"}, snapshotPaths(snapshot)) +} + +func TestURLRejectsQueryIdentitiesCollidingOnVirtualPathWithoutLeakingQuery(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + client := mocks.NewMockHTTPClient(ctrl) + const sourceURL = "https://example.test/contracts/openapi.yaml" + client.EXPECT().Fetch(gomock.Any(), materializedomain.HTTPFetchRequest{ + URL: sourceURL, OriginURL: sourceURL, + }).Return(materializedomain.HTTPDocument{ + URL: sourceURL, + Content: []byte("refs:\n" + + " - {$ref: './schema.yaml?token=alpha#/Item'}\n" + + " - {$ref: './schema.yaml?token=bravo#/Item'}\n"), + }, nil) + + _, err := materialize.NewURL(client).Materialize(context.Background(), materializedomain.Request{ + Source: project.Source{Type: project.SourceURL, URL: sourceURL}, + Reference: contract.Reference{Entrypoint: "spec/openapi.yaml"}, + }) + + require.Equal(t, failure.InvalidInput, failure.CategoryOf(err)) + var operationErr *materializedomain.OperationError + require.ErrorAs(t, err, &operationErr) + require.Equal(t, "spec/schema.yaml", operationErr.Path) + require.NotContains(t, err.Error(), "token=") +} + +func TestURLPreservesDownloadCategoryAndRedactsQueryFromFailureContext(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + client := mocks.NewMockHTTPClient(ctrl) + const sourceURL = "https://example.test/contracts/openapi.yaml" + client.EXPECT().Fetch(gomock.Any(), materializedomain.HTTPFetchRequest{ + URL: sourceURL, OriginURL: sourceURL, + }).Return(materializedomain.HTTPDocument{ + URL: sourceURL, Content: []byte("$ref: './missing.yaml?token=secret'\n"), + }, nil) + client.EXPECT().Fetch(gomock.Any(), materializedomain.HTTPFetchRequest{ + URL: "https://example.test/contracts/missing.yaml?token=secret", OriginURL: sourceURL, + }).Return(materializedomain.HTTPDocument{}, &materializedomain.OperationError{ + Operation: materializedomain.OperationDownload, + Kind: materializedomain.FailureNotFound, + Cause: errors.New("HTTP status 404"), + }) + + _, err := materialize.NewURL(client).Materialize(context.Background(), materializedomain.Request{ + Source: project.Source{Type: project.SourceURL, URL: sourceURL}, + Reference: contract.Reference{Entrypoint: "spec/openapi.yaml"}, + }) + + require.Equal(t, failure.NotFound, failure.CategoryOf(err)) + var operationErr *materializedomain.OperationError + require.ErrorAs(t, err, &operationErr) + require.Equal(t, "https://example.test/contracts/missing.yaml", operationErr.Path) + require.NotContains(t, err.Error(), "token=") +} + +func TestURLRejectsClosureExceedingDocumentLimit(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + client := mocks.NewMockHTTPClient(ctrl) + const sourceURL = "https://example.test/contracts/openapi.yaml" + var refs strings.Builder + refs.WriteString("refs:\n") + for index := 0; index < 64; index++ { + _, _ = fmt.Fprintf(&refs, " - {$ref: './schemas/%02d.yaml'}\n", index) + } + fetches := 0 + client.EXPECT().Fetch(gomock.Any(), gomock.Any()).AnyTimes().DoAndReturn( + func(_ context.Context, request materializedomain.HTTPFetchRequest) (materializedomain.HTTPDocument, error) { + fetches++ + if request.URL == sourceURL { + return materializedomain.HTTPDocument{URL: sourceURL, Content: []byte(refs.String())}, nil + } + return materializedomain.HTTPDocument{URL: request.URL, Content: []byte("type: object\n")}, nil + }, + ) + + _, err := materialize.NewURL(client).Materialize(context.Background(), materializedomain.Request{ + Source: project.Source{Type: project.SourceURL, URL: sourceURL}, + Reference: contract.Reference{Entrypoint: "spec/openapi.yaml"}, + }) + + require.Equal(t, failure.InvalidInput, failure.CategoryOf(err)) + require.LessOrEqual(t, fetches, 64) +} + +func TestURLAllowsClosureAtDocumentLimit(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + client := mocks.NewMockHTTPClient(ctrl) + const sourceURL = "https://example.test/contracts/openapi.yaml" + var refs strings.Builder + refs.WriteString("refs:\n") + for index := 0; index < 63; index++ { + _, _ = fmt.Fprintf(&refs, " - {$ref: './schemas/%02d.yaml'}\n", index) + } + fetches := 0 + client.EXPECT().Fetch(gomock.Any(), gomock.Any()).AnyTimes().DoAndReturn( + func(_ context.Context, request materializedomain.HTTPFetchRequest) (materializedomain.HTTPDocument, error) { + fetches++ + if request.URL == sourceURL { + return materializedomain.HTTPDocument{URL: sourceURL, Content: []byte(refs.String())}, nil + } + return materializedomain.HTTPDocument{URL: request.URL, Content: []byte("type: object\n")}, nil + }, + ) + + snapshot, err := materialize.NewURL(client).Materialize(context.Background(), materializedomain.Request{ + Source: project.Source{Type: project.SourceURL, URL: sourceURL}, + Reference: contract.Reference{Entrypoint: "spec/openapi.yaml"}, + }) + + require.NoError(t, err) + require.Len(t, snapshot.Files, 64) + require.Equal(t, 64, fetches) +} + +func TestURLRejectsDocumentExceedingResponseLimit(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + client := mocks.NewMockHTTPClient(ctrl) + const sourceURL = "https://example.test/contracts/openapi.yaml" + client.EXPECT().Fetch(gomock.Any(), materializedomain.HTTPFetchRequest{ + URL: sourceURL, OriginURL: sourceURL, + }).Return(materializedomain.HTTPDocument{ + URL: sourceURL, Content: make([]byte, (32<<20)+1), + }, nil) + + _, err := materialize.NewURL(client).Materialize(context.Background(), materializedomain.Request{ + Source: project.Source{Type: project.SourceURL, URL: sourceURL}, + Reference: contract.Reference{Entrypoint: "spec/openapi.yaml"}, + }) + + require.Equal(t, failure.InvalidInput, failure.CategoryOf(err)) +} + +func TestURLRejectsClosureExceedingAggregateSizeLimit(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + client := mocks.NewMockHTTPClient(ctrl) + const sourceURL = "https://example.test/contracts/openapi.yaml" + client.EXPECT().Fetch(gomock.Any(), materializedomain.HTTPFetchRequest{ + URL: sourceURL, OriginURL: sourceURL, + }).Return(materializedomain.HTTPDocument{ + URL: sourceURL, + Content: []byte("refs:\n - {$ref: './one.yaml'}\n - {$ref: './two.yaml'}\n"), + }, nil) + largeDocument := make([]byte, 32<<20) + client.EXPECT().Fetch(gomock.Any(), materializedomain.HTTPFetchRequest{ + URL: "https://example.test/contracts/one.yaml", OriginURL: sourceURL, + }).Return(materializedomain.HTTPDocument{ + URL: "https://example.test/contracts/one.yaml", Content: largeDocument, + }, nil) + client.EXPECT().Fetch(gomock.Any(), materializedomain.HTTPFetchRequest{ + URL: "https://example.test/contracts/two.yaml", OriginURL: sourceURL, + }).Return(materializedomain.HTTPDocument{ + URL: "https://example.test/contracts/two.yaml", Content: largeDocument, + }, nil) + + _, err := materialize.NewURL(client).Materialize(context.Background(), materializedomain.Request{ + Source: project.Source{Type: project.SourceURL, URL: sourceURL}, + Reference: contract.Reference{Entrypoint: "spec/openapi.yaml"}, + }) + + require.Equal(t, failure.InvalidInput, failure.CategoryOf(err)) +} + +func TestGitMaterializesCheckedOutReferenceClosure(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + client := mocks.NewMockGitClient(ctrl) + reader := mocks.NewMockFileReader(ctrl) + client.EXPECT().WithCheckout(gomock.Any(), "repo", "main", gomock.Any()).DoAndReturn( + func(_ context.Context, _, _ string, use func(string) error) error { return use("/checkout") }, + ) + reader.EXPECT().ReadFile(gomock.Any(), "/checkout/contracts", "openapi.yaml").Return(contract.File{Content: []byte("openapi: 3.1.0\n")}, nil) + + snapshot, err := materialize.NewGit(client, reader).Materialize(context.Background(), materializedomain.Request{ + Source: project.Source{Type: project.SourceGit, Path: "contracts", Repo: "repo", Ref: "main"}, + Reference: contract.Reference{Entrypoint: "openapi.yaml"}, + }) + + require.NoError(t, err) + require.Equal(t, "openapi.yaml", snapshot.Entrypoint) +} + +func TestDevctlMaterializesSelectedExportWithoutUpstreamReadiness(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + client := mocks.NewMockGitClient(ctrl) + manifests := mocks.NewMockManifestRepository(ctrl) + reader := mocks.NewMockFileReader(ctrl) + client.EXPECT().WithCheckout(gomock.Any(), "repo", "v1", gomock.Any()).DoAndReturn( + func(_ context.Context, _, _ string, use func(string) error) error { return use("/checkout") }, + ) + manifests.EXPECT().Load(gomock.Any(), "/checkout/devctl.yaml").Return(project.LoadManifestResult{Project: project.Project{Manifest: project.Manifest{ + Exports: map[string]project.Export{ + "public-api": {Kind: "openapi", Path: "api/openapi.yaml"}, + }, + Components: project.Components{HTTP: &project.HTTP{Server: &project.HTTPServer{OpenAPI: "api/openapi.yaml"}}}, + }}}, nil) + reader.EXPECT().ReadFile(gomock.Any(), "/checkout", "api/openapi.yaml").Return(contract.File{Content: []byte("openapi: 3.1.0\n")}, nil) + + snapshot, err := materialize.NewDevctl(client, manifests, reader).Materialize(context.Background(), materializedomain.Request{ + Source: project.Source{Type: project.SourceDevctl, Repo: "repo", Ref: "v1"}, + Reference: contract.Reference{Export: "public-api"}, + }) + + require.NoError(t, err) + require.Equal(t, "api/openapi.yaml", snapshot.Entrypoint) +} + +func TestDevctlRejectsSelectedExportThatDoesNotMatchEffectiveSurface(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + client := mocks.NewMockGitClient(ctrl) + manifests := mocks.NewMockManifestRepository(ctrl) + reader := mocks.NewMockFileReader(ctrl) + client.EXPECT().WithCheckout(gomock.Any(), "repo", "v1", gomock.Any()).DoAndReturn( + func(_ context.Context, _, _ string, use func(string) error) error { return use("/checkout") }, + ) + manifests.EXPECT().Load(gomock.Any(), "/checkout/devctl.yaml").Return(project.LoadManifestResult{Project: project.Project{Manifest: project.Manifest{ + Exports: map[string]project.Export{ + "public-api": {Kind: "openapi", Path: "api/exported.yaml"}, + }, + Components: project.Components{HTTP: &project.HTTP{Server: &project.HTTPServer{OpenAPI: "api/canonical.yaml"}}}, + }}}, nil) + + _, err := materialize.NewDevctl(client, manifests, reader).Materialize(context.Background(), materializedomain.Request{ + Source: project.Source{Type: project.SourceDevctl, Repo: "repo", Ref: "v1"}, + Reference: contract.Reference{Export: "public-api"}, + }) + + require.Equal(t, failure.InvalidInput, failure.CategoryOf(err)) +} + +func TestDevctlMaterializesNamedGRPCExportTree(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + client := mocks.NewMockGitClient(ctrl) + manifests := mocks.NewMockManifestRepository(ctrl) + reader := mocks.NewMockFileReader(ctrl) + client.EXPECT().WithCheckout(gomock.Any(), "repo", "v1", gomock.Any()).DoAndReturn( + func(_ context.Context, _, _ string, use func(string) error) error { return use("/checkout") }, + ) + manifests.EXPECT().Load(gomock.Any(), "/checkout/devctl.yaml").Return(project.LoadManifestResult{Project: project.Project{Manifest: project.Manifest{ + Exports: map[string]project.Export{ + "billing": {Kind: "grpc", Path: "api/proto/grpc"}, + }, + Components: project.Components{GRPC: &project.GRPC{Server: &project.GRPCServer{ProtoRoot: "api/proto/grpc"}}}, + }}}, nil) + reader.EXPECT().ReadFile(gomock.Any(), "/checkout", "api/proto/grpc/.devctl-contract.json").Return( + contract.File{}, fs.ErrNotExist, + ) + reader.EXPECT().ReadTree(gomock.Any(), "/checkout", "api/proto/grpc").Return([]contract.File{{ + Path: "api/proto/grpc/acme/billing/v1/service.proto", Content: []byte("syntax = \"proto3\";\n"), Mode: 0o644, + }}, nil) + reader.EXPECT().ReadFile(gomock.Any(), "/checkout", "buf.yaml").Return(contract.File{ + Content: []byte("version: v2\n"), Mode: 0o644, + }, nil) + reader.EXPECT().ReadFile(gomock.Any(), "/checkout", "buf.lock").Return(contract.File{ + Content: []byte("deps: []\n"), Mode: 0o644, + }, nil) + + snapshot, err := materialize.NewDevctl(client, manifests, reader).Materialize(context.Background(), materializedomain.Request{ + Source: project.Source{Type: project.SourceDevctl, Repo: "repo", Ref: "v1"}, + Reference: contract.Reference{Export: "billing", Format: "proto"}, + }) + + require.NoError(t, err) + require.Empty(t, snapshot.Entrypoint) + require.Equal(t, []string{"api/proto/grpc/acme/billing/v1/service.proto", "buf.lock", "buf.yaml"}, snapshotPaths(snapshot)) + require.Equal(t, &contract.Metadata{ + Kind: "grpc", Format: "proto", ModuleRoot: "api/proto/grpc", BufConfig: "buf.yaml", + }, snapshot.Metadata) +} + +func TestDevctlMaterializesExplicitGRPCBufConfigAndAdjacentLock(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + client := mocks.NewMockGitClient(ctrl) + manifests := mocks.NewMockManifestRepository(ctrl) + reader := mocks.NewMockFileReader(ctrl) + client.EXPECT().WithCheckout(gomock.Any(), "repo", "v1", gomock.Any()).DoAndReturn( + func(_ context.Context, _, _ string, use func(string) error) error { return use("/checkout") }, + ) + manifests.EXPECT().Load(gomock.Any(), "/checkout/devctl.yaml").Return(project.LoadManifestResult{Project: project.Project{Manifest: project.Manifest{ + Exports: map[string]project.Export{"billing": {Kind: "grpc", Path: "api/proto/grpc"}}, + Components: project.Components{GRPC: &project.GRPC{Server: &project.GRPCServer{ + ProtoRoot: "api/proto/grpc", BufConfig: "tools/buf/upstream.yaml", + }}}, + }}}, nil) + reader.EXPECT().ReadFile(gomock.Any(), "/checkout", "api/proto/grpc/.devctl-contract.json").Return( + contract.File{}, fs.ErrNotExist, + ) + reader.EXPECT().ReadTree(gomock.Any(), "/checkout", "api/proto/grpc").Return([]contract.File{{ + Path: "api/proto/grpc/service.proto", Content: []byte("syntax = \"proto3\";\n"), Mode: 0o644, + }}, nil) + reader.EXPECT().ReadFile(gomock.Any(), "/checkout", "tools/buf/upstream.yaml").Return(contract.File{ + Content: []byte("version: v2\n"), Mode: 0o644, + }, nil) + reader.EXPECT().ReadFile(gomock.Any(), "/checkout", "tools/buf/buf.lock").Return(contract.File{ + Content: []byte("deps: []\n"), Mode: 0o644, + }, nil) + + snapshot, err := materialize.NewDevctl(client, manifests, reader).Materialize(context.Background(), materializedomain.Request{ + Source: project.Source{Type: project.SourceDevctl, Repo: "repo", Ref: "v1"}, + Reference: contract.Reference{Export: "billing", Format: "proto"}, + }) + + require.NoError(t, err) + require.Equal(t, []string{ + "api/proto/grpc/service.proto", "tools/buf/buf.lock", "tools/buf/upstream.yaml", + }, snapshotPaths(snapshot)) + require.Equal(t, "tools/buf/upstream.yaml", snapshot.Metadata.BufConfig) +} + +func TestDevctlReexportsCommittedGRPCSnapshotWithoutInventingEntrypoint(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + client := mocks.NewMockGitClient(ctrl) + manifests := mocks.NewMockManifestRepository(ctrl) + reader := mocks.NewMockFileReader(ctrl) + client.EXPECT().WithCheckout(gomock.Any(), "repo", "v1", gomock.Any()).DoAndReturn( + func(_ context.Context, _, _ string, use func(string) error) error { return use("/checkout") }, + ) + const treeRoot = "api/external/grpc/client/billing" + manifests.EXPECT().Load(gomock.Any(), "/checkout/devctl.yaml").Return(project.LoadManifestResult{Project: project.Project{Manifest: project.Manifest{ + Exports: map[string]project.Export{"billing": {Kind: "grpc", Path: treeRoot}}, + Components: project.Components{GRPC: &project.GRPC{Server: &project.GRPCServer{ProtoRoot: treeRoot}}}, + }}}, nil) + reader.EXPECT().ReadFile(gomock.Any(), "/checkout", treeRoot+"/.devctl-contract.json").Return(contract.File{ + Content: []byte(`{"kind":"grpc","format":"proto","module_root":"api/proto/grpc","buf_config":"buf.yaml"}`), + }, nil) + reader.EXPECT().ReadFile(gomock.Any(), "/checkout", treeRoot+"/buf.yaml").Return(contract.File{}, nil) + reader.EXPECT().ReadTree(gomock.Any(), "/checkout", treeRoot+"/api/proto/grpc").Return([]contract.File{{ + Path: treeRoot + "/api/proto/grpc/service.proto", + }}, nil) + reader.EXPECT().ReadTree(gomock.Any(), "/checkout", treeRoot).Return([]contract.File{ + {Path: treeRoot + "/.devctl-contract.json", Content: []byte(`{}`), Mode: 0o644}, + {Path: treeRoot + "/api/proto/grpc/service.proto", Content: []byte("syntax = \"proto3\";\n"), Mode: 0o644}, + {Path: treeRoot + "/buf.yaml", Content: []byte("version: v2\n"), Mode: 0o644}, + }, nil) + + snapshot, err := materialize.NewDevctl(client, manifests, reader).Materialize(context.Background(), materializedomain.Request{ + Source: project.Source{Type: project.SourceDevctl, Repo: "repo", Ref: "v1"}, + Reference: contract.Reference{Export: "billing", Format: "proto"}, + }) + + require.NoError(t, err) + require.Equal(t, "api/proto/grpc", snapshot.ModuleRoot) + require.Empty(t, snapshot.Entrypoint) + require.Equal(t, []string{"api/proto/grpc/service.proto", "buf.yaml"}, snapshotPaths(snapshot)) +} + +func TestDevctlDoesNotFallbackWhenCommittedGRPCMetadataIsInvalid(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + client := mocks.NewMockGitClient(ctrl) + manifests := mocks.NewMockManifestRepository(ctrl) + reader := mocks.NewMockFileReader(ctrl) + client.EXPECT().WithCheckout(gomock.Any(), "repo", "v1", gomock.Any()).DoAndReturn( + func(_ context.Context, _, _ string, use func(string) error) error { return use("/checkout") }, + ) + manifests.EXPECT().Load(gomock.Any(), "/checkout/devctl.yaml").Return(project.LoadManifestResult{Project: project.Project{Manifest: project.Manifest{ + Exports: map[string]project.Export{"billing": {Kind: "grpc", Path: "api/proto/grpc"}}, + Components: project.Components{GRPC: &project.GRPC{Server: &project.GRPCServer{ + ProtoRoot: "api/proto/grpc", + }}}, + }}}, nil) + reader.EXPECT().ReadFile(gomock.Any(), "/checkout", "api/proto/grpc/.devctl-contract.json").Return(contract.File{ + Content: []byte(`{"kind":"grpc","format":"proto","buf_config":"buf.yaml"}`), + }, nil) + + _, err := materialize.NewDevctl(client, manifests, reader).Materialize(context.Background(), materializedomain.Request{ + Source: project.Source{Type: project.SourceDevctl, Repo: "repo", Ref: "v1"}, + Reference: contract.Reference{Export: "billing", Format: "proto"}, + }) + + var metadataErr *contract.SnapshotMetadataError + require.ErrorAs(t, err, &metadataErr) + require.Equal(t, "module_root", metadataErr.Field) + require.Equal(t, contract.MetadataRequired, metadataErr.Reason) +} + +func TestDevctlReportsMissingNamedExport(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + client := mocks.NewMockGitClient(ctrl) + manifests := mocks.NewMockManifestRepository(ctrl) + reader := mocks.NewMockFileReader(ctrl) + client.EXPECT().WithCheckout(gomock.Any(), "repo", "v1", gomock.Any()).DoAndReturn( + func(_ context.Context, _, _ string, use func(string) error) error { return use("/checkout") }, + ) + manifests.EXPECT().Load(gomock.Any(), "/checkout/devctl.yaml").Return(project.LoadManifestResult{Project: project.Project{Manifest: project.Manifest{Exports: map[string]project.Export{}}}}, nil) + + _, err := materialize.NewDevctl(client, manifests, reader).Materialize(context.Background(), materializedomain.Request{ + Source: project.Source{Type: project.SourceDevctl, Repo: "repo", Ref: "v1"}, + Reference: contract.Reference{Export: "missing"}, + }) + + require.Equal(t, failure.NotFound, failure.CategoryOf(err)) +} + +func TestDevctlRejectsKafkaExportTopicMismatch(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + client := mocks.NewMockGitClient(ctrl) + manifests := mocks.NewMockManifestRepository(ctrl) + reader := mocks.NewMockFileReader(ctrl) + client.EXPECT().WithCheckout(gomock.Any(), "repo", "v1", gomock.Any()).DoAndReturn( + func(_ context.Context, _, _ string, use func(string) error) error { return use("/checkout") }, + ) + manifests.EXPECT().Load(gomock.Any(), "/checkout/devctl.yaml").Return(project.LoadManifestResult{Project: project.Project{Manifest: project.Manifest{ + Exports: map[string]project.Export{"events": {Kind: "kafka", Producer: "events"}}, + Components: project.Components{Kafka: &project.Kafka{Producers: []project.KafkaProducer{{ + Name: "events", Topic: "upstream_service.domain.events.v1", Contract: project.KafkaContract{Format: "raw"}, + }}}}, + }}}, nil) + + _, err := materialize.NewDevctl(client, manifests, reader).Materialize(context.Background(), materializedomain.Request{ + Source: project.Source{Type: project.SourceDevctl, Repo: "repo", Ref: "v1"}, + Reference: contract.Reference{Export: "events", Topic: "downstream_service.domain.events.v1"}, + }) + + require.Equal(t, failure.InvalidInput, failure.CategoryOf(err)) +} + +func TestDevctlRejectsKafkaExportFormatMismatch(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + client := mocks.NewMockGitClient(ctrl) + manifests := mocks.NewMockManifestRepository(ctrl) + reader := mocks.NewMockFileReader(ctrl) + client.EXPECT().WithCheckout(gomock.Any(), "repo", "v1", gomock.Any()).DoAndReturn( + func(_ context.Context, _, _ string, use func(string) error) error { return use("/checkout") }, + ) + manifests.EXPECT().Load(gomock.Any(), "/checkout/devctl.yaml").Return(project.LoadManifestResult{Project: project.Project{Manifest: project.Manifest{ + Exports: map[string]project.Export{"events": {Kind: "kafka", Producer: "events"}}, + Components: project.Components{Kafka: &project.Kafka{Producers: []project.KafkaProducer{{ + Name: "events", Topic: "upstream_service.domain.events.v1", Contract: project.KafkaContract{Format: "raw"}, + }}}}, + }}}, nil) + + _, err := materialize.NewDevctl(client, manifests, reader).Materialize(context.Background(), materializedomain.Request{ + Source: project.Source{Type: project.SourceDevctl, Repo: "repo", Ref: "v1"}, + Reference: contract.Reference{ + Export: "events", Topic: "upstream_service.domain.events.v1", Format: "json", + }, + }) + + require.Equal(t, failure.InvalidInput, failure.CategoryOf(err)) +} + +func TestDevctlMaterializesKafkaExportContractAndMetadata(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + client := mocks.NewMockGitClient(ctrl) + manifests := mocks.NewMockManifestRepository(ctrl) + reader := mocks.NewMockFileReader(ctrl) + client.EXPECT().WithCheckout(gomock.Any(), "repo", "v1", gomock.Any()).DoAndReturn( + func(_ context.Context, _, _ string, use func(string) error) error { return use("/checkout") }, + ) + manifests.EXPECT().Load(gomock.Any(), "/checkout/devctl.yaml").Return(project.LoadManifestResult{Project: project.Project{Manifest: project.Manifest{ + Sources: map[string]project.Source{"contracts": {Type: project.SourceLocal, Path: "api/contracts"}}, + Exports: map[string]project.Export{"events": {Kind: "kafka", Producer: "events"}}, + Components: project.Components{Kafka: &project.Kafka{Producers: []project.KafkaProducer{{ + Name: "events", Topic: "upstream_service.domain.events.v1", Contract: project.KafkaContract{ + Format: "json", Source: "contracts", Path: "schemas/events.json", + }, + }}}}, + }}}, nil) + reader.EXPECT().ReadFile(gomock.Any(), "/checkout/api/contracts", "schemas/events.json").Return(contract.File{ + Content: []byte(`{"type":"object"}`), Mode: 0o644, + }, nil) + + snapshot, err := materialize.NewDevctl(client, manifests, reader).Materialize(context.Background(), materializedomain.Request{ + Source: project.Source{Type: project.SourceDevctl, Repo: "repo", Ref: "v1"}, + Reference: contract.Reference{ + Export: "events", Topic: "upstream_service.domain.events.v1", Format: "json", + }, + }) + + require.NoError(t, err) + require.Equal(t, "schemas/events.json", snapshot.Entrypoint) + require.Equal(t, &contract.Metadata{ + Kind: "kafka", Topic: "upstream_service.domain.events.v1", Format: "json", Entrypoint: "schemas/events.json", + }, snapshot.Metadata) + require.Equal(t, []string{"schemas/events.json"}, snapshotPaths(snapshot)) +} + +func TestDevctlMaterializesProtoKafkaExportMetadataAndBufFiles(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + client := mocks.NewMockGitClient(ctrl) + manifests := mocks.NewMockManifestRepository(ctrl) + reader := mocks.NewMockFileReader(ctrl) + client.EXPECT().WithCheckout(gomock.Any(), "repo", "v1", gomock.Any()).DoAndReturn( + func(_ context.Context, _, _ string, use func(string) error) error { return use("/checkout") }, + ) + manifests.EXPECT().Load(gomock.Any(), "/checkout/devctl.yaml").Return(project.LoadManifestResult{Project: project.Project{Manifest: project.Manifest{ + Sources: map[string]project.Source{"contracts": { + Type: project.SourceLocal, Path: "api/contracts", Proto: project.SourceProto{BufConfig: "buf.yaml"}, + }}, + Exports: map[string]project.Export{"events": {Kind: "kafka", Producer: "events"}}, + Components: project.Components{Kafka: &project.Kafka{Producers: []project.KafkaProducer{{ + Name: "events", Topic: "upstream_service.domain.events.v1", Contract: project.KafkaContract{ + Format: "proto", Source: "contracts", Path: "proto/events.proto", ProtoRoot: "proto", + }, + }}}}, + }}}, nil) + reader.EXPECT().ReadTree(gomock.Any(), "/checkout/api/contracts", "proto").Return([]contract.File{{ + Path: "proto/events.proto", Content: []byte("syntax = \"proto3\";\n"), Mode: 0o644, + }}, nil) + reader.EXPECT().ReadFile(gomock.Any(), "/checkout/api/contracts", "buf.yaml").Return(contract.File{ + Content: []byte("version: v2\n"), Mode: 0o644, + }, nil) + reader.EXPECT().ReadFile(gomock.Any(), "/checkout/api/contracts", "buf.lock").Return(contract.File{ + Content: []byte("deps: []\n"), Mode: 0o644, + }, nil) + + snapshot, err := materialize.NewDevctl(client, manifests, reader).Materialize(context.Background(), materializedomain.Request{ + Source: project.Source{Type: project.SourceDevctl, Repo: "repo", Ref: "v1"}, + Reference: contract.Reference{ + Export: "events", Topic: "upstream_service.domain.events.v1", Format: "proto", + }, + }) + + require.NoError(t, err) + require.Equal(t, "proto", snapshot.ModuleRoot) + require.Equal(t, "proto/events.proto", snapshot.Entrypoint) + require.Equal(t, &contract.Metadata{ + Kind: "kafka", Topic: "upstream_service.domain.events.v1", Format: "proto", + Entrypoint: "proto/events.proto", ModuleRoot: "proto", BufConfig: "buf.yaml", + }, snapshot.Metadata) + require.Equal(t, []string{"buf.lock", "buf.yaml", "proto/events.proto"}, snapshotPaths(snapshot)) +} + +func TestDevctlMaterializesKafkaExportFromUpstreamSyncedTree(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + client := mocks.NewMockGitClient(ctrl) + manifests := mocks.NewMockManifestRepository(ctrl) + reader := mocks.NewMockFileReader(ctrl) + client.EXPECT().WithCheckout(gomock.Any(), "repo", "v1", gomock.Any()).DoAndReturn( + func(_ context.Context, _, _ string, use func(string) error) error { return use("/checkout") }, + ) + manifests.EXPECT().Load(gomock.Any(), "/checkout/devctl.yaml").Return(project.LoadManifestResult{Project: project.Project{Manifest: project.Manifest{ + Paths: project.ManifestPaths{ExternalContracts: "api/external"}, + Sources: map[string]project.Source{"contracts": {Type: project.SourceDevctl, Repo: "contracts", Ref: "v1"}}, + Exports: map[string]project.Export{"events": {Kind: "kafka", Producer: "events"}}, + Components: project.Components{Kafka: &project.Kafka{Producers: []project.KafkaProducer{{ + Name: "events", Topic: "upstream_service.domain.events.v1", Contract: project.KafkaContract{ + Format: "json", Source: "contracts", Export: "upstream-events", + }, + }}}}, + }}}, nil) + reader.EXPECT().ReadFile(gomock.Any(), "/checkout", "api/external/kafka/producer/events/.devctl-contract.json").Return(contract.File{ + Content: []byte(`{"kind":"kafka","topic":"upstream_service.domain.events.v1","format":"json","entrypoint":"schemas/events.json"}`), + }, nil) + reader.EXPECT().ReadFile(gomock.Any(), "/checkout", "api/external/kafka/producer/events/schemas/events.json").Return( + contract.File{}, nil, + ) + reader.EXPECT().ReadTree(gomock.Any(), "/checkout", "api/external/kafka/producer/events").Return([]contract.File{ + {Path: "api/external/kafka/producer/events/.devctl-contract.json", Content: []byte(`{}`), Mode: 0o644}, + {Path: "api/external/kafka/producer/events/schemas/events.json", Content: []byte(`{"type":"object"}`), Mode: 0o644}, + }, nil) + + snapshot, err := materialize.NewDevctl(client, manifests, reader).Materialize(context.Background(), materializedomain.Request{ + Source: project.Source{Type: project.SourceDevctl, Repo: "repo", Ref: "v1"}, + Reference: contract.Reference{ + Export: "events", Topic: "upstream_service.domain.events.v1", Format: "json", + }, + }) + + require.NoError(t, err) + require.Equal(t, "schemas/events.json", snapshot.Entrypoint) + require.Equal(t, &contract.Metadata{ + Kind: "kafka", Topic: "upstream_service.domain.events.v1", Format: "json", Entrypoint: "schemas/events.json", + }, snapshot.Metadata) + require.Equal(t, []string{"schemas/events.json"}, snapshotPaths(snapshot)) +} + +func TestDevctlReexportsCommittedProtoKafkaSnapshotOffline(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + client := mocks.NewMockGitClient(ctrl) + manifests := mocks.NewMockManifestRepository(ctrl) + reader := mocks.NewMockFileReader(ctrl) + client.EXPECT().WithCheckout(gomock.Any(), "repo", "v1", gomock.Any()).DoAndReturn( + func(_ context.Context, _, _ string, use func(string) error) error { return use("/checkout") }, + ) + const topic = "upstream_service.domain.events.v1" + manifests.EXPECT().Load(gomock.Any(), "/checkout/devctl.yaml").Return(project.LoadManifestResult{Project: project.Project{Manifest: project.Manifest{ + Sources: map[string]project.Source{"contracts": {Type: project.SourceDevctl, Repo: "unavailable", Ref: "v1"}}, + Exports: map[string]project.Export{"events": {Kind: "kafka", Producer: "events"}}, + Components: project.Components{Kafka: &project.Kafka{Producers: []project.KafkaProducer{{ + Name: "events", Topic: topic, Contract: project.KafkaContract{ + Format: "proto", Source: "contracts", Export: "upstream-events", + }, + }}}}, + }}}, nil) + const treeRoot = "api/external/kafka/producer/events" + metadata := `{"kind":"kafka","topic":"upstream_service.domain.events.v1","format":"proto","entrypoint":"proto/events.proto","module_root":"proto","buf_config":"buf.yaml"}` + reader.EXPECT().ReadFile(gomock.Any(), "/checkout", treeRoot+"/.devctl-contract.json").Return(contract.File{ + Content: []byte(metadata), + }, nil) + reader.EXPECT().ReadFile(gomock.Any(), "/checkout", treeRoot+"/proto/events.proto").Return(contract.File{}, nil) + reader.EXPECT().ReadFile(gomock.Any(), "/checkout", treeRoot+"/buf.yaml").Return(contract.File{}, nil) + reader.EXPECT().ReadTree(gomock.Any(), "/checkout", treeRoot+"/proto").Return([]contract.File{{ + Path: treeRoot + "/proto/events.proto", + }}, nil) + reader.EXPECT().ReadTree(gomock.Any(), "/checkout", treeRoot).Return([]contract.File{ + {Path: treeRoot + "/.devctl-contract.json", Content: []byte(metadata), Mode: 0o644}, + {Path: treeRoot + "/proto/events.proto", Content: []byte("syntax = \"proto3\";\n"), Mode: 0o644}, + {Path: treeRoot + "/buf.yaml", Content: []byte("version: v2\n"), Mode: 0o644}, + {Path: treeRoot + "/buf.lock", Content: []byte("deps: []\n"), Mode: 0o644}, + }, nil) + + snapshot, err := materialize.NewDevctl(client, manifests, reader).Materialize(context.Background(), materializedomain.Request{ + Source: project.Source{Type: project.SourceDevctl, Repo: "repo", Ref: "v1"}, + Reference: contract.Reference{ + Export: "events", Topic: topic, Format: "proto", + }, + }) + + require.NoError(t, err) + require.Equal(t, "proto", snapshot.ModuleRoot) + require.Equal(t, "proto/events.proto", snapshot.Entrypoint) + require.Equal(t, []string{"buf.lock", "buf.yaml", "proto/events.proto"}, snapshotPaths(snapshot)) + require.Equal(t, "buf.yaml", snapshot.Metadata.BufConfig) +} diff --git a/internal/service/materialize/url.go b/internal/service/materialize/url.go new file mode 100644 index 0000000..c384498 --- /dev/null +++ b/internal/service/materialize/url.go @@ -0,0 +1,237 @@ +package materialize + +import ( + "context" + "fmt" + "net/url" + "path" + "strings" + + "github.com/devctllabs/devctl/internal/domain/contract" + "github.com/devctllabs/devctl/internal/domain/failure" + materializedomain "github.com/devctllabs/devctl/internal/domain/materialize" + "github.com/devctllabs/devctl/internal/domain/project" +) + +const ( + maxURLDocuments = 64 + maxURLResponseSize = 32 << 20 + maxURLSnapshotSize = 64 << 20 +) + +// URLService materializes a bounded relative-reference closure from a URL Source. +type URLService struct { + client HTTPClient +} + +func NewURL(client HTTPClient) *URLService { return &URLService{client: client} } + +func (s *URLService) SourceType() project.SourceType { return project.SourceURL } + +func (s *URLService) Materialize(ctx context.Context, request materializedomain.Request) (contract.Snapshot, error) { + closure, err := newURLClosure(s.client, request) + if err != nil { + return contract.Snapshot{}, err + } + return closure.materialize(ctx) +} + +type pendingURLDocument struct { + fetchURL string + virtualPath string +} + +type urlClosure struct { + client HTTPClient + source project.Source + entrypoint string + virtualRoot string + queue []pendingURLDocument + seen map[string]struct{} + virtualOwners map[string]string + files []contract.File + aggregateSize int +} + +func newURLClosure(client HTTPClient, request materializedomain.Request) (*urlClosure, error) { + if err := validateSourceURL(request.Source); err != nil { + return nil, err + } + entrypoint := request.Reference.Entrypoint + if entrypoint == "" { + entrypoint = request.Source.Filename + } + if entrypoint == "" { + entrypoint = "openapi.yaml" + } + if !safeRelative(entrypoint) { + return nil, &materializedomain.OperationError{Operation: materializedomain.OperationValidateSource, SourceType: project.SourceURL, Path: entrypoint, Kind: materializedomain.FailureInvalid} + } + entrypoint = path.Clean(entrypoint) + initial := pendingURLDocument{fetchURL: request.Source.URL, virtualPath: entrypoint} + return &urlClosure{ + client: client, source: request.Source, entrypoint: entrypoint, + virtualRoot: path.Dir(entrypoint), + queue: []pendingURLDocument{initial}, + seen: make(map[string]struct{}), + virtualOwners: map[string]string{ + entrypoint: fetchIdentity(initial.fetchURL), + }, + files: make([]contract.File, 0, 1), + }, nil +} + +func (c *urlClosure) materialize(ctx context.Context) (contract.Snapshot, error) { + for len(c.queue) > 0 { + current := c.pop() + identity := fetchIdentity(current.fetchURL) + if _, exists := c.seen[identity]; exists { + continue + } + if err := c.fetch(ctx, current, identity); err != nil { + return contract.Snapshot{}, err + } + } + return newSnapshot(c.entrypoint, c.files) +} + +func (c *urlClosure) pop() pendingURLDocument { + current := c.queue[0] + c.queue = c.queue[1:] + return current +} + +func (c *urlClosure) fetch(ctx context.Context, current pendingURLDocument, identity string) error { + if len(c.seen) >= maxURLDocuments { + return &materializedomain.OperationError{Operation: materializedomain.OperationBuildSnapshot, SourceType: project.SourceURL, Path: current.virtualPath, Kind: materializedomain.FailureInvalid} + } + document, err := c.client.Fetch(ctx, materializedomain.HTTPFetchRequest{ + URL: current.fetchURL, OriginURL: c.source.URL, + AllowInsecureHTTP: c.source.AllowInsecureHTTP, + }) + if err != nil { + operationErr := &materializedomain.OperationError{Operation: materializedomain.OperationDownload, SourceType: project.SourceURL, Path: redactedURL(current.fetchURL), Kind: downloadFailureKind(err), Cause: err} + return fmt.Errorf("client.Fetch: %w", operationErr) + } + if err := c.add(current, document); err != nil { + return err + } + c.seen[identity] = struct{}{} + return c.enqueueReferences(current, document) +} + +func (c *urlClosure) add(current pendingURLDocument, document materializedomain.HTTPDocument) error { + if len(document.Content) > maxURLResponseSize || c.aggregateSize+len(document.Content) > maxURLSnapshotSize { + return &materializedomain.OperationError{Operation: materializedomain.OperationBuildSnapshot, SourceType: project.SourceURL, Path: current.virtualPath, Kind: materializedomain.FailureInvalid} + } + c.aggregateSize += len(document.Content) + c.files = append(c.files, contract.File{Path: current.virtualPath, Content: document.Content, Mode: 0o644}) + return nil +} + +func (c *urlClosure) enqueueReferences(current pendingURLDocument, document materializedomain.HTTPDocument) error { + base, err := url.Parse(document.URL) + if err != nil { + return &materializedomain.OperationError{Operation: materializedomain.OperationDownload, SourceType: project.SourceURL, Path: redactedURL(current.fetchURL), Kind: materializedomain.FailureUnavailable, Cause: err} + } + for _, rawReference := range collectReferences(document.Content) { + resolved, include, resolveErr := c.resolveReference(current, base, rawReference) + if resolveErr != nil { + return resolveErr + } + if include { + if enqueueErr := c.enqueue(resolved); enqueueErr != nil { + return enqueueErr + } + } + } + return nil +} + +func (c *urlClosure) enqueue(document pendingURLDocument) error { + identity := fetchIdentity(document.fetchURL) + if owner, exists := c.virtualOwners[document.virtualPath]; exists && owner != identity { + return &materializedomain.OperationError{Operation: materializedomain.OperationBuildSnapshot, SourceType: project.SourceURL, Path: document.virtualPath, Kind: materializedomain.FailureInvalid} + } + c.virtualOwners[document.virtualPath] = identity + c.queue = append(c.queue, document) + return nil +} + +func (c *urlClosure) resolveReference(current pendingURLDocument, base *url.URL, rawReference string) (pendingURLDocument, bool, error) { + reference, err := url.Parse(rawReference) + if err != nil { + return pendingURLDocument{}, false, &materializedomain.OperationError{Operation: materializedomain.OperationValidateSource, SourceType: project.SourceURL, Path: redactedURL(rawReference), Kind: materializedomain.FailureInvalid, Cause: err} + } + if reference.IsAbs() || reference.Host != "" || strings.HasPrefix(reference.Path, "/") { + return pendingURLDocument{}, false, nil + } + reference.Fragment = "" + if reference.Path == "" && reference.RawQuery == "" { + return pendingURLDocument{}, false, nil + } + virtualPath := current.virtualPath + if reference.Path != "" { + virtualPath = path.Clean(path.Join(path.Dir(current.virtualPath), reference.Path)) + } + if !withinVirtualRoot(c.virtualRoot, virtualPath) { + return pendingURLDocument{}, false, &materializedomain.OperationError{Operation: materializedomain.OperationValidateSource, SourceType: project.SourceURL, Path: redactedURL(rawReference), Kind: materializedomain.FailureInvalid} + } + return pendingURLDocument{fetchURL: base.ResolveReference(reference).String(), virtualPath: virtualPath}, true, nil +} + +func downloadFailureKind(err error) materializedomain.FailureKind { + switch failure.CategoryOf(err) { + case failure.InvalidInput: + return materializedomain.FailureInvalid + case failure.NotFound: + return materializedomain.FailureNotFound + case failure.Unsupported: + return materializedomain.FailureUnsupported + case failure.Conflict, failure.Unavailable, failure.Cancelled, failure.Internal: + return materializedomain.FailureUnavailable + } + return materializedomain.FailureUnavailable +} + +func redactedURL(rawURL string) string { + parsed, err := url.Parse(rawURL) + if err != nil { + return "" + } + parsed.User = nil + parsed.RawQuery = "" + parsed.ForceQuery = false + parsed.Fragment = "" + return parsed.String() +} + +func withinVirtualRoot(root, name string) bool { + if !safeRelative(name) { + return false + } + return root == "." || strings.HasPrefix(name, root+"/") +} + +func fetchIdentity(rawURL string) string { + parsed, err := url.Parse(rawURL) + if err != nil { + return rawURL + } + parsed.Fragment = "" + return parsed.String() +} + +func validateSourceURL(source project.Source) error { + parsed, err := url.Parse(source.URL) + if err != nil || parsed.Host == "" { + return &materializedomain.OperationError{Operation: materializedomain.OperationValidateSource, SourceType: project.SourceURL, Path: redactedURL(source.URL), Kind: materializedomain.FailureInvalid} + } + if parsed.User != nil { + return &materializedomain.OperationError{Operation: materializedomain.OperationValidateSource, SourceType: project.SourceURL, Path: redactedURL(source.URL), Kind: materializedomain.FailureInvalid} + } + if parsed.Scheme != "https" && (parsed.Scheme != "http" || !source.AllowInsecureHTTP) { + return &materializedomain.OperationError{Operation: materializedomain.OperationValidateSource, SourceType: project.SourceURL, Path: redactedURL(source.URL), Kind: materializedomain.FailureInvalid} + } + return nil +} diff --git a/internal/service/project/errors.go b/internal/service/project/errors.go new file mode 100644 index 0000000..a8ae659 --- /dev/null +++ b/internal/service/project/errors.go @@ -0,0 +1,24 @@ +package project + +import ( + "context" + "errors" + "io/fs" + + projectdomain "github.com/devctllabs/devctl/internal/domain/project" +) + +func projectOperationError(operation projectdomain.Operation, path string, kind projectdomain.FailureKind, cause error) error { + return &projectdomain.OperationError{Operation: operation, Path: path, Kind: kind, Cause: cause} +} + +func manifestAccessFailure(err error) projectdomain.FailureKind { + switch { + case errors.Is(err, fs.ErrNotExist): + return projectdomain.FailureNotFound + case errors.Is(err, context.Canceled), errors.Is(err, context.DeadlineExceeded): + return projectdomain.FailureUnavailable + default: + return projectdomain.FailureUnavailable + } +} diff --git a/internal/service/project/init.go b/internal/service/project/init.go new file mode 100644 index 0000000..6441fdc --- /dev/null +++ b/internal/service/project/init.go @@ -0,0 +1,89 @@ +package project + +import ( + "context" + "errors" + "fmt" + "io/fs" + "path/filepath" + "reflect" + + projectdomain "github.com/devctllabs/devctl/internal/domain/project" +) + +// InitManifest creates a canonical manifest, preserves identical content, and requires Force to replace a conflict. +func (s *Service) InitManifest(ctx context.Context, command projectdomain.InitManifestCommand) (projectdomain.ManifestResult, error) { + if err := validateInitManifest(command); err != nil { + return projectdomain.ManifestResult{}, err + } + destination, err := s.manifestDestination(ctx, command.Destination) + if err != nil { + operationErr := projectOperationError(projectdomain.OperationInitManifest, command.Destination, projectdomain.FailureUnavailable, err) + return projectdomain.ManifestResult{}, fmt.Errorf("s.manifestDestination: %w", operationErr) + } + desired := projectdomain.Project{ + Root: filepath.Dir(destination), ManifestPath: destination, Manifest: initialManifest(command), + } + existing, loadErr := s.manifests.Load(ctx, destination) + existed := loadErr == nil + switch { + case loadErr == nil && len(existing.Issues) == 0 && reflect.DeepEqual(existing.Project.Manifest, desired.Manifest): + case loadErr == nil && !command.Force: + return projectdomain.ManifestResult{Manifest: destination}, projectOperationError(projectdomain.OperationInitManifest, destination, projectdomain.FailureConflict, errors.New("different content")) + case loadErr != nil && !errors.Is(loadErr, fs.ErrNotExist): + operationErr := projectOperationError(projectdomain.OperationLoadManifest, destination, projectdomain.FailureUnavailable, loadErr) + return projectdomain.ManifestResult{Manifest: destination}, fmt.Errorf("manifests.Load: %w", operationErr) + } + changed, err := s.manifests.Save(ctx, desired) + if err != nil { + operationErr := projectOperationError(projectdomain.OperationSaveManifest, destination, projectdomain.FailureUnavailable, err) + return projectdomain.ManifestResult{Manifest: destination}, fmt.Errorf("manifests.Save: %w", operationErr) + } + action := projectdomain.ChangeUnchanged + if !existed { + action = projectdomain.ChangeCreated + } else if changed { + action = projectdomain.ChangeUpdated + } + return projectdomain.ManifestResult{Manifest: destination, Change: action}, nil +} + +func validateInitManifest(command projectdomain.InitManifestCommand) error { + if command.Language != "go" || (command.Preset != "cli" && command.Preset != "http-service") || !kebabCase.MatchString(command.Name) || command.Module == "" { + return projectOperationError(projectdomain.OperationInitManifest, command.Destination, projectdomain.FailureInvalid, errors.New("invalid command")) + } + return nil +} + +func (s *Service) manifestDestination(ctx context.Context, selected string) (string, error) { + if selected != "" && filepath.IsAbs(selected) { + return filepath.Clean(selected), nil + } + directory, err := s.locator.WorkingDirectory(ctx) + if err != nil { + return "", fmt.Errorf("workspace.WorkingDirectory: %w", err) + } + if selected == "" { + selected = manifestFilename + } + return filepath.Join(directory, selected), nil +} + +func initialManifest(command projectdomain.InitManifestCommand) projectdomain.Manifest { + manifest := projectdomain.Manifest{ + Version: 1, Project: projectdomain.Identity{Name: command.Name, Language: "go"}, + Env: projectdomain.Env{}, Paths: projectdomain.ManifestPaths{ExternalContracts: "api/external"}, + Sources: map[string]projectdomain.Source{}, Exports: map[string]projectdomain.Export{}, + Components: projectdomain.Components{Logging: &projectdomain.Logging{Env: projectdomain.ComponentEnv{System: []projectdomain.EnvVar{{Key: "LOG_LEVEL", Type: "string", Default: "info"}}}}}, + Languages: projectdomain.Languages{Go: projectdomain.GoLanguage{Module: command.Module}}, + } + if command.Preset == "http-service" { + trueValue, falseValue := true, false + manifest.Components.HTTP = &projectdomain.HTTP{Server: &projectdomain.HTTPServer{OpenAPI: "api/openapi/swagger.yaml", Start: &projectdomain.Start{Env: "HTTP_SERVER_ENABLED", Default: &trueValue}}, Env: projectdomain.ComponentEnv{System: []projectdomain.EnvVar{{Key: "HTTP_ADDR", Type: "string", Default: ":8080"}}}} + manifest.Components.Health = &projectdomain.Health{Server: &projectdomain.HealthServer{Start: &projectdomain.Start{Env: "HEALTH_SERVER_ENABLED", Default: &trueValue}}, Env: projectdomain.ComponentEnv{System: []projectdomain.EnvVar{{Key: "HEALTH_ADDR", Type: "string", Default: ":8081"}}}} + manifest.Components.Telemetry = &projectdomain.Telemetry{Start: &projectdomain.Start{Env: "TELEMETRY_ENABLED", Default: &falseValue}} + manifest.Languages.Go.Components.Pprof = &projectdomain.Pprof{Server: &projectdomain.PprofServer{Start: &projectdomain.Start{Env: "PPROF_ENABLED", Default: &falseValue}}, Env: projectdomain.ComponentEnv{System: []projectdomain.EnvVar{{Key: "PPROF_ADDR", Type: "string", Default: "127.0.0.1:6060"}}}} + manifest.Languages.Go.Generators.HTTP = &projectdomain.HTTPGenerator{OAPIConfig: "tools/oapi/server.yaml", ServerOut: "gen/serverhttp", ClientOut: "gen/clienthttp"} + } + return manifest +} diff --git a/internal/service/project/inspect.go b/internal/service/project/inspect.go new file mode 100644 index 0000000..91ff79c --- /dev/null +++ b/internal/service/project/inspect.go @@ -0,0 +1,140 @@ +package project + +import ( + "context" + "fmt" + "path" + "sort" + + projectdomain "github.com/devctllabs/devctl/internal/domain/project" +) + +// Inspect returns an effective project view with defaults applied to a valid selected manifest. +func (s *Service) Inspect(ctx context.Context, query projectdomain.InspectQuery) (projectdomain.InspectResult, error) { + selected, err := s.loadValidProject(ctx, query.ManifestPath) + if err != nil { + return projectdomain.InspectResult{}, err + } + targets := projectdomain.NewTargetCatalog(selected.Manifest).All() + inspection, err := inspectProject(selected, targets) + if err != nil { + return projectdomain.InspectResult{}, err + } + inspection.Targets = s.inspectionTargets(ctx, selected, targets) + return projectdomain.InspectResult{Project: inspection}, nil +} + +func inspectProject(selected projectdomain.Project, targets []projectdomain.Target) (projectdomain.Inspection, error) { + manifest := selected.Manifest + catalog, err := projectdomain.NewRuntimeConfigCatalog(manifest) + if err != nil { + return projectdomain.Inspection{}, fmt.Errorf("project.NewRuntimeConfigCatalog: %w", err) + } + return projectdomain.Inspection{ + Root: selected.Root, ManifestPath: selected.ManifestPath, + Name: manifest.Project.Name, Language: manifest.Project.Language, + Module: manifest.Languages.Go.Module, EnvPrefix: catalog.Prefix(), + Paths: effectivePaths(manifest), Targets: inspectionTargets(targets), + Env: effectiveEnv(catalog), Resources: effectiveResources(manifest, catalog.Prefix()), + }, nil +} + +func effectivePaths(manifest projectdomain.Manifest) projectdomain.Paths { + paths := projectdomain.Paths{ExternalContracts: "api/external", ConfigOut: "gen/config", ServerOut: "gen/serverhttp", ClientOut: "gen/clienthttp"} + if manifest.Paths.ExternalContracts != "" { + paths.ExternalContracts = manifest.Paths.ExternalContracts + } + for _, target := range projectdomain.NewTargetCatalog(manifest).All() { + switch { + case target.ID == "config": + paths.ConfigOut = target.OutputDir + case target.ID == "http-server": + paths.ServerOut = target.OutputDir + case target.Family == "http" && target.Role == "client": + paths.ClientOut = path.Dir(target.OutputDir) + } + } + return paths +} + +func inspectionTargets(targets []projectdomain.Target) []projectdomain.InspectionTarget { + result := make([]projectdomain.InspectionTarget, len(targets)) + for index, target := range targets { + result[index] = projectdomain.InspectionTarget{ + ID: target.ID, Family: target.Family, Format: target.Format, + Input: target.Input, Config: target.Config, Output: target.OutputDir, + } + } + return result +} + +func (s *Service) inspectionTargets( + ctx context.Context, + selected projectdomain.Project, + targets []projectdomain.Target, +) []projectdomain.InspectionTarget { + result := inspectionTargets(targets) + for index, target := range targets { + if target.Source.Type != projectdomain.SourceDevctl || target.Family != "grpc" && target.Family != "kafka" { + continue + } + resolved, err := s.inputs.Resolve(ctx, selected, target) + if err == nil { + result[index].ResolvedInput = resolved.Input + } + } + return result +} + +func effectiveResources(manifest projectdomain.Manifest, envPrefix string) projectdomain.InspectionResources { + resources := projectdomain.InspectionResources{} + appendDBResources(&resources, manifest.Components.DB, envPrefix) + if manifest.Components.Redis != nil { + for _, value := range manifest.Components.Redis.Connections { + resources.RedisConnections = append(resources.RedisConnections, value.Name) + } + } + if manifest.Components.S3 != nil { + for _, value := range manifest.Components.S3.Connections { + resources.S3Connections = append(resources.S3Connections, value.Name) + } + for _, value := range manifest.Components.S3.Buckets { + resources.S3Buckets = append(resources.S3Buckets, value.Name) + } + } + sort.Strings(resources.DBConnections) + sort.Strings(resources.RedisConnections) + sort.Strings(resources.S3Connections) + sort.Strings(resources.S3Buckets) + sort.Slice(resources.Migrations, func(i, j int) bool { + left, right := resources.Migrations[i], resources.Migrations[j] + if left.Connection != right.Connection { + return left.Connection < right.Connection + } + return left.Variant < right.Variant + }) + return resources +} + +func appendDBResources(resources *projectdomain.InspectionResources, database *projectdomain.DB, envPrefix string) { + if database == nil { + return + } + for _, connection := range database.Connections { + resources.DBConnections = append(resources.DBConnections, connection.Name) + appendMigrations(resources, connection, envPrefix) + } +} + +func appendMigrations(resources *projectdomain.InspectionResources, connection projectdomain.DBConnection, envPrefix string) { + for _, variant := range connection.Variants { + migrations := variant.Migrations + if migrations == nil { + continue + } + resources.Migrations = append(resources.Migrations, projectdomain.InspectionMigration{ + Connection: connection.Name, Variant: variant.Name, Kind: variant.Kind, + Path: migrations.Path, DatabaseEnv: envPrefix + migrations.DatabaseEnv, + }) + } +} diff --git a/internal/service/project/inspect_env.go b/internal/service/project/inspect_env.go new file mode 100644 index 0000000..5f243d0 --- /dev/null +++ b/internal/service/project/inspect_env.go @@ -0,0 +1,18 @@ +package project + +import projectdomain "github.com/devctllabs/devctl/internal/domain/project" + +func effectiveEnv(catalog projectdomain.RuntimeConfigCatalog) []projectdomain.EffectiveEnv { + fields := catalog.Entries(projectdomain.RuntimeConfigInspect) + result := make([]projectdomain.EffectiveEnv, len(fields)) + for index, field := range fields { + var defaultValue any + if field.HasDefault && !field.Secret { + defaultValue = field.Default + } + result[index] = projectdomain.EffectiveEnv{ + Key: field.Key, Type: string(field.Type), Default: defaultValue, Secret: field.Secret, + } + } + return result +} diff --git a/internal/service/project/load.go b/internal/service/project/load.go new file mode 100644 index 0000000..94a75f9 --- /dev/null +++ b/internal/service/project/load.go @@ -0,0 +1,101 @@ +package project + +import ( + "context" + "fmt" + "io/fs" + "path/filepath" + + projectdomain "github.com/devctllabs/devctl/internal/domain/project" +) + +func (s *Service) loadValidProject(ctx context.Context, manifestPath string) (projectdomain.Project, error) { + selectedPath, err := s.resolveManifestPath(ctx, manifestPath) + if err != nil { + operationErr := projectOperationError(projectdomain.OperationLoadManifest, manifestPath, manifestAccessFailure(err), err) + return projectdomain.Project{}, fmt.Errorf("s.resolveManifestPath: %w", operationErr) + } + loaded, err := s.manifests.Load(ctx, selectedPath) + if err != nil { + operationErr := projectOperationError(projectdomain.OperationLoadManifest, selectedPath, manifestAccessFailure(err), err) + return projectdomain.Project{}, fmt.Errorf("manifests.Load: %w", operationErr) + } + if len(loaded.Issues) > 0 { + path := selectedManifestPath(loaded.Project.ManifestPath, selectedPath) + return projectdomain.Project{}, &projectdomain.InvalidManifestError{ + Path: path, + Issues: validationIssues(path, loaded.Issues), + } + } + issues := projectdomain.Validate(loaded.Project) + if len(issues) > 0 { + return projectdomain.Project{}, &projectdomain.InvalidManifestError{Path: loaded.Project.ManifestPath, Issues: issues} + } + return loaded.Project, nil +} + +func (s *Service) resolveManifestPath(ctx context.Context, selected string) (string, error) { + if filepath.IsAbs(selected) { + return filepath.Clean(selected), nil + } + directory, err := s.locator.WorkingDirectory(ctx) + if err != nil { + return "", fmt.Errorf("workspace.WorkingDirectory: %w", err) + } + if selected != "" { + return filepath.Join(directory, selected), nil + } + for { + exists, err := s.locator.RegularFile(ctx, directory, manifestFilename) + if err != nil { + return "", fmt.Errorf("workspace.RegularFile: %w", err) + } + if exists { + return filepath.Join(directory, manifestFilename), nil + } + parent := filepath.Dir(directory) + if parent == directory { + return "", fmt.Errorf("%s not found: %w", manifestFilename, fs.ErrNotExist) + } + directory = parent + } +} + +// LoadProject resolves a structurally and semantically valid project for other +// capability services. Readiness checks remain exclusive to Validate. +func (s *Service) LoadProject(ctx context.Context, manifestPath string) (projectdomain.Project, error) { + return s.loadValidProject(ctx, manifestPath) +} + +func validationIssues(path string, documentIssues []projectdomain.DecodeIssue) []projectdomain.Issue { + issues := make([]projectdomain.Issue, 0, len(documentIssues)) + for _, documentIssue := range documentIssues { + issues = append(issues, projectdomain.Issue{ + Code: documentIssueCode(documentIssue.Kind), Path: path, Field: documentIssue.Field, + Line: documentIssue.Line, Column: documentIssue.Column, + }) + } + return issues +} + +func documentIssueCode(kind projectdomain.DecodeIssueKind) projectdomain.IssueCode { + switch kind { + case projectdomain.DecodeYAMLInvalid: + return projectdomain.IssueYAMLInvalid + case projectdomain.DecodeSchemaInvalid: + return projectdomain.IssueSchemaInvalid + case projectdomain.DecodeDuplicateKey: + return projectdomain.IssueYAMLDuplicateKey + case projectdomain.DecodeUnknownField: + return projectdomain.IssueSchemaUnknownField + default: + return projectdomain.IssueSchemaInvalid + } +} + +func selectedManifestPath(selectedPath, requestedPath string) string { + if selectedPath != "" { + return selectedPath + } + return requestedPath +} diff --git a/internal/service/project/mocks/service.go b/internal/service/project/mocks/service.go new file mode 100644 index 0000000..7d986b7 --- /dev/null +++ b/internal/service/project/mocks/service.go @@ -0,0 +1,348 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/devctllabs/devctl/internal/service/project (interfaces: ManifestRepository,ManifestLocator,TargetResolver,ReadinessChecker) +// +// Generated by this command: +// +// mockgen -destination mocks/service.go -package mocks -typed . ManifestRepository,ManifestLocator,TargetResolver,ReadinessChecker +// + +// Package mocks is a generated GoMock package. +package mocks + +import ( + context "context" + reflect "reflect" + + project "github.com/devctllabs/devctl/internal/domain/project" + gomock "go.uber.org/mock/gomock" +) + +// MockManifestRepository is a mock of ManifestRepository interface. +type MockManifestRepository struct { + ctrl *gomock.Controller + recorder *MockManifestRepositoryMockRecorder + isgomock struct{} +} + +// MockManifestRepositoryMockRecorder is the mock recorder for MockManifestRepository. +type MockManifestRepositoryMockRecorder struct { + mock *MockManifestRepository +} + +// NewMockManifestRepository creates a new mock instance. +func NewMockManifestRepository(ctrl *gomock.Controller) *MockManifestRepository { + mock := &MockManifestRepository{ctrl: ctrl} + mock.recorder = &MockManifestRepositoryMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockManifestRepository) EXPECT() *MockManifestRepositoryMockRecorder { + return m.recorder +} + +// Load mocks base method. +func (m *MockManifestRepository) Load(ctx context.Context, manifestPath string) (project.LoadManifestResult, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Load", ctx, manifestPath) + ret0, _ := ret[0].(project.LoadManifestResult) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Load indicates an expected call of Load. +func (mr *MockManifestRepositoryMockRecorder) Load(ctx, manifestPath any) *MockManifestRepositoryLoadCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Load", reflect.TypeOf((*MockManifestRepository)(nil).Load), ctx, manifestPath) + return &MockManifestRepositoryLoadCall{Call: call} +} + +// MockManifestRepositoryLoadCall wrap *gomock.Call +type MockManifestRepositoryLoadCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockManifestRepositoryLoadCall) Return(arg0 project.LoadManifestResult, arg1 error) *MockManifestRepositoryLoadCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockManifestRepositoryLoadCall) Do(f func(context.Context, string) (project.LoadManifestResult, error)) *MockManifestRepositoryLoadCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockManifestRepositoryLoadCall) DoAndReturn(f func(context.Context, string) (project.LoadManifestResult, error)) *MockManifestRepositoryLoadCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// Save mocks base method. +func (m *MockManifestRepository) Save(ctx context.Context, arg1 project.Project) (bool, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Save", ctx, arg1) + ret0, _ := ret[0].(bool) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Save indicates an expected call of Save. +func (mr *MockManifestRepositoryMockRecorder) Save(ctx, arg1 any) *MockManifestRepositorySaveCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Save", reflect.TypeOf((*MockManifestRepository)(nil).Save), ctx, arg1) + return &MockManifestRepositorySaveCall{Call: call} +} + +// MockManifestRepositorySaveCall wrap *gomock.Call +type MockManifestRepositorySaveCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockManifestRepositorySaveCall) Return(arg0 bool, arg1 error) *MockManifestRepositorySaveCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockManifestRepositorySaveCall) Do(f func(context.Context, project.Project) (bool, error)) *MockManifestRepositorySaveCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockManifestRepositorySaveCall) DoAndReturn(f func(context.Context, project.Project) (bool, error)) *MockManifestRepositorySaveCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// MockManifestLocator is a mock of ManifestLocator interface. +type MockManifestLocator struct { + ctrl *gomock.Controller + recorder *MockManifestLocatorMockRecorder + isgomock struct{} +} + +// MockManifestLocatorMockRecorder is the mock recorder for MockManifestLocator. +type MockManifestLocatorMockRecorder struct { + mock *MockManifestLocator +} + +// NewMockManifestLocator creates a new mock instance. +func NewMockManifestLocator(ctrl *gomock.Controller) *MockManifestLocator { + mock := &MockManifestLocator{ctrl: ctrl} + mock.recorder = &MockManifestLocatorMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockManifestLocator) EXPECT() *MockManifestLocatorMockRecorder { + return m.recorder +} + +// RegularFile mocks base method. +func (m *MockManifestLocator) RegularFile(ctx context.Context, root, relativePath string) (bool, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "RegularFile", ctx, root, relativePath) + ret0, _ := ret[0].(bool) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// RegularFile indicates an expected call of RegularFile. +func (mr *MockManifestLocatorMockRecorder) RegularFile(ctx, root, relativePath any) *MockManifestLocatorRegularFileCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RegularFile", reflect.TypeOf((*MockManifestLocator)(nil).RegularFile), ctx, root, relativePath) + return &MockManifestLocatorRegularFileCall{Call: call} +} + +// MockManifestLocatorRegularFileCall wrap *gomock.Call +type MockManifestLocatorRegularFileCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockManifestLocatorRegularFileCall) Return(arg0 bool, arg1 error) *MockManifestLocatorRegularFileCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockManifestLocatorRegularFileCall) Do(f func(context.Context, string, string) (bool, error)) *MockManifestLocatorRegularFileCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockManifestLocatorRegularFileCall) DoAndReturn(f func(context.Context, string, string) (bool, error)) *MockManifestLocatorRegularFileCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// WorkingDirectory mocks base method. +func (m *MockManifestLocator) WorkingDirectory(ctx context.Context) (string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "WorkingDirectory", ctx) + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// WorkingDirectory indicates an expected call of WorkingDirectory. +func (mr *MockManifestLocatorMockRecorder) WorkingDirectory(ctx any) *MockManifestLocatorWorkingDirectoryCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "WorkingDirectory", reflect.TypeOf((*MockManifestLocator)(nil).WorkingDirectory), ctx) + return &MockManifestLocatorWorkingDirectoryCall{Call: call} +} + +// MockManifestLocatorWorkingDirectoryCall wrap *gomock.Call +type MockManifestLocatorWorkingDirectoryCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockManifestLocatorWorkingDirectoryCall) Return(arg0 string, arg1 error) *MockManifestLocatorWorkingDirectoryCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockManifestLocatorWorkingDirectoryCall) Do(f func(context.Context) (string, error)) *MockManifestLocatorWorkingDirectoryCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockManifestLocatorWorkingDirectoryCall) DoAndReturn(f func(context.Context) (string, error)) *MockManifestLocatorWorkingDirectoryCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// MockTargetResolver is a mock of TargetResolver interface. +type MockTargetResolver struct { + ctrl *gomock.Controller + recorder *MockTargetResolverMockRecorder + isgomock struct{} +} + +// MockTargetResolverMockRecorder is the mock recorder for MockTargetResolver. +type MockTargetResolverMockRecorder struct { + mock *MockTargetResolver +} + +// NewMockTargetResolver creates a new mock instance. +func NewMockTargetResolver(ctrl *gomock.Controller) *MockTargetResolver { + mock := &MockTargetResolver{ctrl: ctrl} + mock.recorder = &MockTargetResolverMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockTargetResolver) EXPECT() *MockTargetResolverMockRecorder { + return m.recorder +} + +// Resolve mocks base method. +func (m *MockTargetResolver) Resolve(ctx context.Context, selected project.Project, target project.Target) (project.Target, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Resolve", ctx, selected, target) + ret0, _ := ret[0].(project.Target) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Resolve indicates an expected call of Resolve. +func (mr *MockTargetResolverMockRecorder) Resolve(ctx, selected, target any) *MockTargetResolverResolveCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Resolve", reflect.TypeOf((*MockTargetResolver)(nil).Resolve), ctx, selected, target) + return &MockTargetResolverResolveCall{Call: call} +} + +// MockTargetResolverResolveCall wrap *gomock.Call +type MockTargetResolverResolveCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockTargetResolverResolveCall) Return(arg0 project.Target, arg1 error) *MockTargetResolverResolveCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockTargetResolverResolveCall) Do(f func(context.Context, project.Project, project.Target) (project.Target, error)) *MockTargetResolverResolveCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockTargetResolverResolveCall) DoAndReturn(f func(context.Context, project.Project, project.Target) (project.Target, error)) *MockTargetResolverResolveCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// MockReadinessChecker is a mock of ReadinessChecker interface. +type MockReadinessChecker struct { + ctrl *gomock.Controller + recorder *MockReadinessCheckerMockRecorder + isgomock struct{} +} + +// MockReadinessCheckerMockRecorder is the mock recorder for MockReadinessChecker. +type MockReadinessCheckerMockRecorder struct { + mock *MockReadinessChecker +} + +// NewMockReadinessChecker creates a new mock instance. +func NewMockReadinessChecker(ctrl *gomock.Controller) *MockReadinessChecker { + mock := &MockReadinessChecker{ctrl: ctrl} + mock.recorder = &MockReadinessCheckerMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockReadinessChecker) EXPECT() *MockReadinessCheckerMockRecorder { + return m.recorder +} + +// Check mocks base method. +func (m *MockReadinessChecker) Check(ctx context.Context, selected project.Project) ([]project.Issue, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Check", ctx, selected) + ret0, _ := ret[0].([]project.Issue) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Check indicates an expected call of Check. +func (mr *MockReadinessCheckerMockRecorder) Check(ctx, selected any) *MockReadinessCheckerCheckCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Check", reflect.TypeOf((*MockReadinessChecker)(nil).Check), ctx, selected) + return &MockReadinessCheckerCheckCall{Call: call} +} + +// MockReadinessCheckerCheckCall wrap *gomock.Call +type MockReadinessCheckerCheckCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockReadinessCheckerCheckCall) Return(arg0 []project.Issue, arg1 error) *MockReadinessCheckerCheckCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockReadinessCheckerCheckCall) Do(f func(context.Context, project.Project) ([]project.Issue, error)) *MockReadinessCheckerCheckCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockReadinessCheckerCheckCall) DoAndReturn(f func(context.Context, project.Project) ([]project.Issue, error)) *MockReadinessCheckerCheckCall { + c.Call = c.Call.DoAndReturn(f) + return c +} diff --git a/internal/service/project/mutation.go b/internal/service/project/mutation.go new file mode 100644 index 0000000..6530f27 --- /dev/null +++ b/internal/service/project/mutation.go @@ -0,0 +1,848 @@ +package project + +import ( + "context" + "fmt" + "net" + "net/url" + "path" + "reflect" + "regexp" + "strconv" + "strings" + + projectdomain "github.com/devctllabs/devctl/internal/domain/project" +) + +var kebabCase = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`) +var environmentKey = regexp.MustCompile(`^[A-Z][A-Z0-9_]*$`) + +type mutation func(manifest *projectdomain.Manifest) error + +// Enable applies one capability mutation to a structurally and semantically valid manifest. +func (s *Service) Enable(ctx context.Context, command projectdomain.EnableCommand) (projectdomain.ManifestResult, error) { + return s.mutate(ctx, command.ManifestPath, func(manifest *projectdomain.Manifest) error { + return enableCapability(manifest, command) + }) +} + +// AddDB adds one database connection using the mutation command's conflict policy. +func (s *Service) AddDB(ctx context.Context, command projectdomain.AddDBCommand) (projectdomain.ManifestResult, error) { + return s.mutate(ctx, command.ManifestPath, func(manifest *projectdomain.Manifest) error { + return addDatabase(manifest, command) + }) +} + +// AddSource adds one contract source using the mutation command's conflict policy. +func (s *Service) AddSource(ctx context.Context, command projectdomain.AddSourceCommand) (projectdomain.ManifestResult, error) { + return s.mutate(ctx, command.ManifestPath, func(manifest *projectdomain.Manifest) error { + return addSource(manifest, command) + }) +} + +// AddHTTPClient adds one generated HTTP client using the mutation command's conflict policy. +func (s *Service) AddHTTPClient(ctx context.Context, command projectdomain.AddHTTPClientCommand) (projectdomain.ManifestResult, error) { + return s.mutate(ctx, command.ManifestPath, func(manifest *projectdomain.Manifest) error { + return addHTTPClient(manifest, command) + }) +} + +func (s *Service) AddGRPCClient(ctx context.Context, command projectdomain.AddGRPCClientCommand) (projectdomain.ManifestResult, error) { + return s.mutate(ctx, command.ManifestPath, func(manifest *projectdomain.Manifest) error { return addGRPCClient(manifest, command) }) +} + +func (s *Service) AddKafkaConsumer(ctx context.Context, command projectdomain.AddKafkaConsumerCommand) (projectdomain.ManifestResult, error) { + return s.mutate(ctx, command.ManifestPath, func(manifest *projectdomain.Manifest) error { return addKafkaConsumer(manifest, command) }) +} + +func (s *Service) AddKafkaProducer(ctx context.Context, command projectdomain.AddKafkaProducerCommand) (projectdomain.ManifestResult, error) { + return s.mutate(ctx, command.ManifestPath, func(manifest *projectdomain.Manifest) error { return addKafkaProducer(manifest, command) }) +} + +func (s *Service) AddRedis(ctx context.Context, command projectdomain.AddRedisCommand) (projectdomain.ManifestResult, error) { + return s.mutate(ctx, command.ManifestPath, func(manifest *projectdomain.Manifest) error { return addRedis(manifest, command) }) +} + +func (s *Service) AddS3Connection(ctx context.Context, command projectdomain.AddS3ConnectionCommand) (projectdomain.ManifestResult, error) { + return s.mutate(ctx, command.ManifestPath, func(manifest *projectdomain.Manifest) error { return addS3Connection(manifest, command) }) +} + +func (s *Service) AddS3(ctx context.Context, command projectdomain.AddS3Command) (projectdomain.ManifestResult, error) { + return s.mutate(ctx, command.ManifestPath, func(manifest *projectdomain.Manifest) error { return addS3(manifest, command) }) +} + +func (s *Service) mutate(ctx context.Context, manifestPath string, apply mutation) (projectdomain.ManifestResult, error) { + selected, err := s.loadValidProject(ctx, manifestPath) + if err != nil { + return projectdomain.ManifestResult{}, fmt.Errorf("s.loadValidProject: %w", err) + } + before := cloneManifest(selected.Manifest) + if err := apply(&selected.Manifest); err != nil { + return projectdomain.ManifestResult{Manifest: selected.ManifestPath}, err + } + if reflect.DeepEqual(before, selected.Manifest) { + return projectdomain.ManifestResult{Manifest: selected.ManifestPath, Change: projectdomain.ChangeUnchanged}, nil + } + if _, err := s.manifests.Save(ctx, selected); err != nil { + operationErr := projectOperationError(projectdomain.OperationSaveManifest, selected.ManifestPath, projectdomain.FailureUnavailable, err) + return projectdomain.ManifestResult{Manifest: selected.ManifestPath}, fmt.Errorf("manifests.Save: %w", operationErr) + } + return projectdomain.ManifestResult{Manifest: selected.ManifestPath, Change: projectdomain.ChangeUpdated}, nil +} + +func cloneManifest(source projectdomain.Manifest) projectdomain.Manifest { + clone := source + clone.Sources = cloneMap(source.Sources) + clone.Exports = cloneMap(source.Exports) + clone.Env.Custom = append([]projectdomain.EnvGroup(nil), source.Env.Custom...) + clone.Components = cloneComponents(source.Components) + clone.Languages = cloneLanguages(source.Languages) + return clone +} + +func cloneMap[K comparable, V any](source map[K]V) map[K]V { + if source == nil { + return nil + } + clone := make(map[K]V, len(source)) + for key, value := range source { + clone[key] = value + } + return clone +} + +func cloneComponents(source projectdomain.Components) projectdomain.Components { + clone := source + clone.HTTP = cloneHTTP(source.HTTP) + clone.GRPC = cloneGRPC(source.GRPC) + clone.Kafka = cloneKafka(source.Kafka) + if source.Logging != nil { + value := *source.Logging + clone.Logging = &value + } + clone.Health = cloneHealth(source.Health) + if source.Telemetry != nil { + value := *source.Telemetry + value.Start = cloneStart(source.Telemetry.Start) + clone.Telemetry = &value + } + clone.DB = cloneDB(source.DB) + if source.Redis != nil { + redis := *source.Redis + redis.Connections = append([]projectdomain.RedisConnection(nil), source.Redis.Connections...) + clone.Redis = &redis + } + clone.S3 = cloneS3(source.S3) + return clone +} + +func cloneHTTP(source *projectdomain.HTTP) *projectdomain.HTTP { + if source == nil { + return nil + } + clone := *source + clone.Clients = append([]projectdomain.HTTPClient(nil), source.Clients...) + if source.Server != nil { + server := *source.Server + server.Start = cloneStart(source.Server.Start) + clone.Server = &server + } + return &clone +} + +func cloneGRPC(source *projectdomain.GRPC) *projectdomain.GRPC { + if source == nil { + return nil + } + clone := *source + clone.Clients = append([]projectdomain.GRPCClient(nil), source.Clients...) + if source.Server != nil { + server := *source.Server + server.Start = cloneStart(source.Server.Start) + clone.Server = &server + } + return &clone +} + +func cloneKafka(source *projectdomain.Kafka) *projectdomain.Kafka { + if source == nil { + return nil + } + clone := *source + clone.Consumers = append([]projectdomain.KafkaConsumer(nil), source.Consumers...) + for index := range clone.Consumers { + clone.Consumers[index].Start = cloneStart(clone.Consumers[index].Start) + } + clone.Producers = append([]projectdomain.KafkaProducer(nil), source.Producers...) + return &clone +} + +func cloneHealth(source *projectdomain.Health) *projectdomain.Health { + if source == nil { + return nil + } + clone := *source + if source.Server != nil { + server := *source.Server + server.Start = cloneStart(source.Server.Start) + clone.Server = &server + } + return &clone +} + +func cloneDB(source *projectdomain.DB) *projectdomain.DB { + if source == nil { + return nil + } + clone := *source + clone.Connections = append([]projectdomain.DBConnection(nil), source.Connections...) + for index := range clone.Connections { + clone.Connections[index].Variants = append([]projectdomain.DBVariant(nil), source.Connections[index].Variants...) + for variantIndex := range clone.Connections[index].Variants { + migrations := clone.Connections[index].Variants[variantIndex].Migrations + if migrations != nil { + migrationClone := *migrations + clone.Connections[index].Variants[variantIndex].Migrations = &migrationClone + } + } + } + return &clone +} + +func cloneS3(source *projectdomain.S3) *projectdomain.S3 { + if source == nil { + return nil + } + clone := *source + clone.Connections = append([]projectdomain.S3Connection(nil), source.Connections...) + clone.Buckets = append([]projectdomain.S3Bucket(nil), source.Buckets...) + return &clone +} + +func cloneLanguages(source projectdomain.Languages) projectdomain.Languages { + clone := source + if source.Go.Components.Pprof != nil { + pprof := *source.Go.Components.Pprof + if source.Go.Components.Pprof.Server != nil { + server := *source.Go.Components.Pprof.Server + server.Start = cloneStart(source.Go.Components.Pprof.Server.Start) + pprof.Server = &server + } + clone.Go.Components.Pprof = &pprof + } + return clone +} + +func cloneStart(source *projectdomain.Start) *projectdomain.Start { + if source == nil { + return nil + } + clone := *source + if source.Default != nil { + value := *source.Default + clone.Default = &value + } + return &clone +} + +func enableCapability(manifest *projectdomain.Manifest, command projectdomain.EnableCommand) error { + switch command.Capability { + case "http": + return enableHTTP(manifest, command) + case "grpc": + return enableGRPC(manifest, command) + case "logging": + if command.Always { + return invalidMutation(projectdomain.MutationUnsupportedOption, "always", "true") + } + if manifest.Components.Logging == nil { + manifest.Components.Logging = &projectdomain.Logging{} + } + return nil + case "health": + return enableHealth(manifest, command) + case "telemetry": + return enableTelemetry(manifest, command) + case "pprof": + return enablePprof(manifest, command) + default: + return invalidMutation(projectdomain.MutationUnsupportedValue, "capability", command.Capability) + } +} + +func enableGRPC(manifest *projectdomain.Manifest, command projectdomain.EnableCommand) error { + desired := desiredStart(command.Always, "GRPC_SERVER_ENABLED", true) + if manifest.Components.GRPC == nil { + manifest.Components.GRPC = &projectdomain.GRPC{} + } + if manifest.Components.GRPC.Server == nil { + manifest.Components.GRPC.Server = &projectdomain.GRPCServer{ + ProtoRoot: "api/proto/grpc", + BufConfig: "buf.yaml", + Start: desired, + } + } + if !reflect.DeepEqual(manifest.Components.GRPC.Server.Start, desired) && !command.Force { + return mutationConflict("components.grpc.server.start", "") + } + manifest.Components.GRPC.Server.Start = desired + if manifest.Languages.Go.Generators.GRPC == nil { + manifest.Languages.Go.Generators.GRPC = &projectdomain.GRPCGenerator{Out: "gen/grpc", BufGenConfig: "tools/buf/grpc.gen.yaml"} + } + return nil +} + +func desiredStart(always bool, environment string, defaultValue bool) *projectdomain.Start { + if always { + return nil + } + return &projectdomain.Start{Env: environment, Default: &defaultValue} +} + +func enableHTTP(manifest *projectdomain.Manifest, command projectdomain.EnableCommand) error { + desired := desiredStart(command.Always, "HTTP_SERVER_ENABLED", true) + if manifest.Components.HTTP == nil { + manifest.Components.HTTP = &projectdomain.HTTP{} + } + if manifest.Components.HTTP.Server == nil { + manifest.Components.HTTP.Server = &projectdomain.HTTPServer{OpenAPI: "api/openapi/swagger.yaml", Start: desired} + } + if !reflect.DeepEqual(manifest.Components.HTTP.Server.Start, desired) && !command.Force { + return mutationConflict("components.http.server.start", "") + } + manifest.Components.HTTP.Server.Start = desired + return nil +} + +func enableHealth(manifest *projectdomain.Manifest, command projectdomain.EnableCommand) error { + desired := desiredStart(command.Always, "HEALTH_SERVER_ENABLED", true) + if manifest.Components.Health == nil { + manifest.Components.Health = &projectdomain.Health{} + } + if manifest.Components.Health.Server == nil { + manifest.Components.Health.Server = &projectdomain.HealthServer{Start: desired} + } + if !reflect.DeepEqual(manifest.Components.Health.Server.Start, desired) && !command.Force { + return mutationConflict("components.health.server.start", "") + } + manifest.Components.Health.Server.Start = desired + return nil +} + +func enableTelemetry(manifest *projectdomain.Manifest, command projectdomain.EnableCommand) error { + desired := desiredStart(command.Always, "TELEMETRY_ENABLED", false) + if manifest.Components.Telemetry == nil { + manifest.Components.Telemetry = &projectdomain.Telemetry{Start: desired} + } + if !reflect.DeepEqual(manifest.Components.Telemetry.Start, desired) && !command.Force { + return mutationConflict("components.telemetry.start", "") + } + manifest.Components.Telemetry.Start = desired + return nil +} + +func enablePprof(manifest *projectdomain.Manifest, command projectdomain.EnableCommand) error { + desired := desiredStart(command.Always, "PPROF_ENABLED", false) + if manifest.Languages.Go.Components.Pprof == nil { + manifest.Languages.Go.Components.Pprof = &projectdomain.Pprof{} + } + if manifest.Languages.Go.Components.Pprof.Server == nil { + manifest.Languages.Go.Components.Pprof.Server = &projectdomain.PprofServer{Start: desired} + } + if !reflect.DeepEqual(manifest.Languages.Go.Components.Pprof.Server.Start, desired) && !command.Force { + return mutationConflict("languages.go.components.pprof.server.start", "") + } + manifest.Languages.Go.Components.Pprof.Server.Start = desired + return nil +} + +func addSource(manifest *projectdomain.Manifest, command projectdomain.AddSourceCommand) error { + if !kebabCase.MatchString(command.Name) { + return invalidMutation(projectdomain.MutationInvalidName, "source.name", command.Name) + } + desired := projectdomain.Source{Type: projectdomain.SourceType(command.Type), Path: command.Path, URL: command.URL, Filename: command.Filename, AllowInsecureHTTP: command.AllowInsecureHTTP, Repo: command.Repo, Ref: command.Ref, Proto: projectdomain.SourceProto{BufConfig: command.BufConfig}} + if err := validateSource(desired); err != nil { + return err + } + if current, exists := manifest.Sources[command.Name]; exists && !reflect.DeepEqual(current, desired) && !command.Force { + return mutationConflict("sources."+command.Name, command.Name) + } + if manifest.Sources == nil { + manifest.Sources = make(map[string]projectdomain.Source) + } + manifest.Sources[command.Name] = desired + return nil +} + +func validateSource(source projectdomain.Source) error { + if source.Proto.BufConfig != "" && !safeRelative(source.Proto.BufConfig) { + return invalidMutation(projectdomain.MutationInvalidOptions, "source.proto.buf_config", source.Proto.BufConfig) + } + switch source.Type { + case projectdomain.SourceLocal: + if !safeRelative(source.Path) || source.URL != "" || source.Repo != "" || source.Ref != "" || source.Filename != "" || source.AllowInsecureHTTP { + return invalidMutation(projectdomain.MutationInvalidOptions, "source", string(source.Type)) + } + case projectdomain.SourceURL: + return validateURLSource(source) + case projectdomain.SourceGit: + if source.Repo == "" || source.Ref == "" || (source.Path != "" && !safeRelative(source.Path)) || source.URL != "" || source.Filename != "" || source.AllowInsecureHTTP { + return invalidMutation(projectdomain.MutationInvalidOptions, "source", string(source.Type)) + } + case projectdomain.SourceDevctl: + if source.Repo == "" || source.Ref == "" || source.Path != "" || source.URL != "" || source.Filename != "" || source.AllowInsecureHTTP { + return invalidMutation(projectdomain.MutationInvalidOptions, "source", string(source.Type)) + } + default: + return invalidMutation(projectdomain.MutationUnsupportedValue, "source.type", string(source.Type)) + } + return nil +} + +func validateURLSource(source projectdomain.Source) error { + parsed, err := url.Parse(source.URL) + if err != nil || parsed.Host == "" || parsed.User != nil { + return invalidMutation(projectdomain.MutationInvalidURL, "source.url", source.URL) + } + if parsed.Scheme != "https" && (parsed.Scheme != "http" || !source.AllowInsecureHTTP) { + return invalidMutation(projectdomain.MutationInsecureURL, "source.url", source.URL) + } + if source.Path != "" || source.Repo != "" || source.Ref != "" { + return invalidMutation(projectdomain.MutationInvalidOptions, "source", string(source.Type)) + } + if source.Filename != "" && strings.Contains(source.Filename, "/") { + return invalidMutation(projectdomain.MutationInvalidOptions, "source.filename", source.Filename) + } + return nil +} + +func addDatabase(manifest *projectdomain.Manifest, command projectdomain.AddDBCommand) error { + if !kebabCase.MatchString(command.Name) { + return invalidMutation(projectdomain.MutationInvalidName, "database.name", command.Name) + } + if command.Kind != "sqlite" && command.Kind != "postgres" && command.Kind != "clickhouse" { + return invalidMutation(projectdomain.MutationUnsupportedValue, "database.kind", command.Kind) + } + if !validMigrationOptions(command) { + return invalidMutation(projectdomain.MutationInvalidOptions, "database.migrations", command.MigrationsPath) + } + if manifest.Components.DB == nil { + manifest.Components.DB = &projectdomain.DB{} + } + connection := findConnection(manifest.Components.DB, command.Name) + variant := defaultDBVariant(command) + if connection == nil { + manifest.Components.DB.Connections = append(manifest.Components.DB.Connections, projectdomain.DBConnection{Name: command.Name, Default: command.Kind, Variants: []projectdomain.DBVariant{variant}}) + return nil + } + addingClickHouseToExisting := command.Kind == "clickhouse" && len(connection.Variants) > 0 + addingTransactionalToClickHouse := command.Kind != "clickhouse" && connectionHasKind(connection, "clickhouse") + if addingClickHouseToExisting || addingTransactionalToClickHouse { + return invalidMutation(projectdomain.MutationInvalidOptions, "database.kind", command.Kind) + } + if err := upsertVariant(connection, variant, command.Force); err != nil { + return err + } + if command.Default || connection.Default == "" { + connection.Default = command.Kind + } + return nil +} + +func validMigrationOptions(command projectdomain.AddDBCommand) bool { + if command.NoMigrations && command.MigrationsPath != "" { + return false + } + return command.MigrationsPath == "" || safeRelative(command.MigrationsPath) +} + +func connectionHasKind(connection *projectdomain.DBConnection, kind string) bool { + for _, variant := range connection.Variants { + if variant.Kind == kind { + return true + } + } + return false +} + +func findConnection(database *projectdomain.DB, name string) *projectdomain.DBConnection { + for index := range database.Connections { + if database.Connections[index].Name == name { + return &database.Connections[index] + } + } + return nil +} + +func upsertVariant(connection *projectdomain.DBConnection, desired projectdomain.DBVariant, force bool) error { + for index, current := range connection.Variants { + if current.Name != desired.Name { + continue + } + if !reflect.DeepEqual(current, desired) && !force { + return mutationConflict("database.variant", desired.Name) + } + connection.Variants[index] = desired + return nil + } + connection.Variants = append(connection.Variants, desired) + return nil +} + +func defaultDBVariant(command projectdomain.AddDBCommand) projectdomain.DBVariant { + upper := strings.ToUpper(strings.ReplaceAll(command.Name, "-", "_")) + kindUpper := strings.ToUpper(command.Kind) + variant := projectdomain.DBVariant{Name: command.Kind, Kind: command.Kind, DSNEnv: "DB_" + upper + "_" + kindUpper + "_DSN", Secret: command.Kind == "postgres" || command.Kind == "clickhouse"} + if command.Kind == "sqlite" { + variant.DSNDefault = "file:./data/" + command.Name + ".db?_foreign_keys=on" + } + if command.Kind == "clickhouse" { + variant.DSNDefault = "clickhouse://localhost:9000/default" + } + if !command.NoMigrations { + path := command.MigrationsPath + if path == "" { + path = "migrations/" + command.Name + "/" + command.Kind + } + variant.Migrations = &projectdomain.DBMigrations{Path: path, DatabaseEnv: "DB_" + upper + "_" + kindUpper + "_MIGRATIONS_URL"} + if command.Kind == "sqlite" { + variant.Migrations.DatabaseDefault = "sqlite://./data/" + command.Name + ".db?_pragma=foreign_keys%281%29" + } + } + return variant +} + +func addHTTPClient(manifest *projectdomain.Manifest, command projectdomain.AddHTTPClientCommand) error { + if !kebabCase.MatchString(command.Name) { + return invalidMutation(projectdomain.MutationInvalidName, "http_client.name", command.Name) + } + source, exists := manifest.Sources[command.Source] + if !exists { + return invalidMutation(projectdomain.MutationNotFound, "http_client.source", command.Source) + } + if source.Type == projectdomain.SourceDevctl && (command.Export == "" || command.Path != "") { + return invalidMutation(projectdomain.MutationInvalidOptions, "http_client", command.Name) + } + if source.Type != projectdomain.SourceDevctl && (command.Path == "" || command.Export != "") { + return invalidMutation(projectdomain.MutationInvalidOptions, "http_client", command.Name) + } + if manifest.Components.HTTP == nil { + manifest.Components.HTTP = &projectdomain.HTTP{} + } + desired := projectdomain.HTTPClient{Name: command.Name, Source: command.Source, Export: command.Export, Path: command.Path, BaseURLEnv: command.BaseURLEnv} + for index, current := range manifest.Components.HTTP.Clients { + if current.Name != desired.Name { + continue + } + if !reflect.DeepEqual(current, desired) && !command.Force { + return mutationConflict("http_client", desired.Name) + } + manifest.Components.HTTP.Clients[index] = desired + return nil + } + manifest.Components.HTTP.Clients = append(manifest.Components.HTTP.Clients, desired) + return nil +} + +func addGRPCClient(manifest *projectdomain.Manifest, command projectdomain.AddGRPCClientCommand) error { + if !kebabCase.MatchString(command.Name) { + return invalidMutation(projectdomain.MutationInvalidName, "grpc_client.name", command.Name) + } + source, exists := manifest.Sources[command.Source] + if !exists { + return invalidMutation(projectdomain.MutationNotFound, "grpc_client.source", command.Source) + } + if source.Type == projectdomain.SourceDevctl && (command.Export == "" || command.Path != "") { + return invalidMutation(projectdomain.MutationInvalidOptions, "grpc_client", command.Name) + } + if source.Type != projectdomain.SourceDevctl && (command.Path == "" || command.Export != "") { + return invalidMutation(projectdomain.MutationInvalidOptions, "grpc_client", command.Name) + } + desired := projectdomain.GRPCClient{ + Name: command.Name, + Source: command.Source, + Export: command.Export, + Path: command.Path, + ProtoRoot: command.ProtoRoot, + BufGenConfig: command.BufGenConfig, + AddrEnv: command.AddrEnv, + } + if manifest.Components.GRPC == nil { + manifest.Components.GRPC = &projectdomain.GRPC{} + } + for index, current := range manifest.Components.GRPC.Clients { + if current.Name != desired.Name { + continue + } + if !reflect.DeepEqual(current, desired) && !command.Force { + return mutationConflict("grpc_client", desired.Name) + } + manifest.Components.GRPC.Clients[index] = desired + return nil + } + manifest.Components.GRPC.Clients = append(manifest.Components.GRPC.Clients, desired) + if manifest.Languages.Go.Generators.GRPC == nil { + manifest.Languages.Go.Generators.GRPC = &projectdomain.GRPCGenerator{Out: "gen/grpc", BufGenConfig: "tools/buf/grpc.gen.yaml"} + } + return nil +} + +func kafkaContract(contract projectdomain.KafkaContract) projectdomain.KafkaContract { + if contract.Format == "" { + contract.Format = "raw" + } + if contract.Format == "proto" { + if contract.ProtoRoot == "" { + contract.ProtoRoot = path.Dir(contract.Path) + } + if contract.Encoding == "" { + contract.Encoding = "binary" + } + } + return contract +} + +func validateKafkaContract(manifest *projectdomain.Manifest, contract projectdomain.KafkaContract) error { + if contract.Format != "raw" && contract.Format != "json" && contract.Format != "proto" { + return invalidMutation(projectdomain.MutationUnsupportedValue, "kafka.format", contract.Format) + } + if contract.Format == "raw" && (contract.Source != "" || contract.Export != "" || contract.Path != "" || contract.ProtoRoot != "" || contract.Message != "" || contract.Encoding != "") { + return invalidMutation(projectdomain.MutationInvalidOptions, "kafka.contract", contract.Format) + } + if contract.Format == "raw" { + return nil + } + source, exists := manifest.Sources[contract.Source] + if !exists { + return invalidMutation(projectdomain.MutationNotFound, "kafka.source", contract.Source) + } + if source.Type == projectdomain.SourceDevctl && (contract.Export == "" || contract.Path != "") { + return invalidMutation(projectdomain.MutationInvalidOptions, "kafka.contract", contract.Format) + } + if source.Type != projectdomain.SourceDevctl && (contract.Path == "" || contract.Export != "") { + return invalidMutation(projectdomain.MutationInvalidOptions, "kafka.contract", contract.Format) + } + if contract.Format == "json" && (contract.ProtoRoot != "" || contract.Message != "" || contract.Encoding != "") { + return invalidMutation(projectdomain.MutationInvalidOptions, "kafka.contract", contract.Format) + } + if contract.Format == "proto" && (contract.Encoding != "binary" && contract.Encoding != "json" || !pathWithin(contract.ProtoRoot, contract.Path)) { + return invalidMutation(projectdomain.MutationInvalidOptions, "kafka.contract", contract.Format) + } + return nil +} + +func addKafkaConsumer(manifest *projectdomain.Manifest, command projectdomain.AddKafkaConsumerCommand) error { + if !kebabCase.MatchString(command.Name) || command.Topic == "" { + return invalidMutation(projectdomain.MutationInvalidOptions, "kafka_consumer", command.Name) + } + contract := kafkaContract(projectdomain.KafkaContract{ + Source: command.Source, Export: command.Export, Path: command.Path, Format: command.Format, + ProtoRoot: command.ProtoRoot, Message: command.Message, Encoding: command.Encoding, + }) + if err := validateKafkaContract(manifest, contract); err != nil { + return err + } + desired := projectdomain.KafkaConsumer{ + Name: command.Name, Topic: command.Topic, GroupEnv: command.GroupEnv, + Start: desiredStart(command.Always, "KAFKA_"+strings.ToUpper(strings.ReplaceAll(command.Name, "-", "_"))+"_CONSUMER_ENABLED", false), + Contract: contract, + } + if manifest.Components.Kafka == nil { + manifest.Components.Kafka = &projectdomain.Kafka{} + } + for index, current := range manifest.Components.Kafka.Consumers { + if current.Name == desired.Name { + if !reflect.DeepEqual(current, desired) && !command.Force { + return mutationConflict("kafka_consumer", desired.Name) + } + manifest.Components.Kafka.Consumers[index] = desired + return nil + } + } + manifest.Components.Kafka.Consumers = append(manifest.Components.Kafka.Consumers, desired) + if contract.Format == "proto" && manifest.Languages.Go.Generators.Kafka == nil { + manifest.Languages.Go.Generators.Kafka = &projectdomain.KafkaGenerator{Out: "gen/kafka", BufGenConfig: "tools/buf/kafka.gen.yaml"} + } + return nil +} + +func addKafkaProducer(manifest *projectdomain.Manifest, command projectdomain.AddKafkaProducerCommand) error { + if !kebabCase.MatchString(command.Name) || command.Topic == "" { + return invalidMutation(projectdomain.MutationInvalidOptions, "kafka_producer", command.Name) + } + contract := kafkaContract(projectdomain.KafkaContract{ + Source: command.Source, Export: command.Export, Path: command.Path, Format: command.Format, + ProtoRoot: command.ProtoRoot, Message: command.Message, Encoding: command.Encoding, + }) + if err := validateKafkaContract(manifest, contract); err != nil { + return err + } + desired := projectdomain.KafkaProducer{ + Name: command.Name, Topic: command.Topic, TopicEnv: command.TopicEnv, Contract: contract, + } + if manifest.Components.Kafka == nil { + manifest.Components.Kafka = &projectdomain.Kafka{} + } + for index, current := range manifest.Components.Kafka.Producers { + if current.Name == desired.Name { + if !reflect.DeepEqual(current, desired) && !command.Force { + return mutationConflict("kafka_producer", desired.Name) + } + manifest.Components.Kafka.Producers[index] = desired + return nil + } + } + manifest.Components.Kafka.Producers = append(manifest.Components.Kafka.Producers, desired) + if contract.Format == "proto" && manifest.Languages.Go.Generators.Kafka == nil { + manifest.Languages.Go.Generators.Kafka = &projectdomain.KafkaGenerator{Out: "gen/kafka", BufGenConfig: "tools/buf/kafka.gen.yaml"} + } + return nil +} + +func addRedis(manifest *projectdomain.Manifest, command projectdomain.AddRedisCommand) error { + if !kebabCase.MatchString(command.Name) { + return invalidMutation(projectdomain.MutationInvalidName, "redis.name", command.Name) + } + addrEnv := command.AddrEnv + if addrEnv == "" { + addrEnv = "REDIS_" + strings.ToUpper(strings.ReplaceAll(command.Name, "-", "_")) + "_ADDR" + } + addrDefault := command.AddrDefault + if addrDefault == "" { + addrDefault = "localhost:6379" + } + if !environmentKey.MatchString(addrEnv) { + return invalidMutation(projectdomain.MutationInvalidOptions, "redis.addr_env", addrEnv) + } + if !validRedisAddress(addrDefault) { + return invalidMutation(projectdomain.MutationInvalidURL, "redis.addr_default", addrDefault) + } + desired := projectdomain.RedisConnection{Name: command.Name, AddrEnv: addrEnv, AddrDefault: addrDefault} + if manifest.Components.Redis == nil { + manifest.Components.Redis = &projectdomain.Redis{} + } + for index, current := range manifest.Components.Redis.Connections { + if current.Name != desired.Name { + continue + } + if !reflect.DeepEqual(current, desired) && !command.Force { + return mutationConflict("redis", desired.Name) + } + manifest.Components.Redis.Connections[index] = desired + return nil + } + manifest.Components.Redis.Connections = append(manifest.Components.Redis.Connections, desired) + return nil +} + +func addS3Connection(manifest *projectdomain.Manifest, command projectdomain.AddS3ConnectionCommand) error { + if !kebabCase.MatchString(command.Name) { + return invalidMutation(projectdomain.MutationInvalidName, "s3_connection.name", command.Name) + } + credentials := command.Credentials + if credentials == "" { + credentials = "ambient" + } + if credentials != "ambient" && credentials != "static" { + return invalidMutation(projectdomain.MutationUnsupportedValue, "s3_connection.credentials", credentials) + } + desired := projectdomain.S3Connection{Name: command.Name, Credentials: credentials} + if manifest.Components.S3 == nil { + manifest.Components.S3 = &projectdomain.S3{} + } + for index, current := range manifest.Components.S3.Connections { + if current.Name != desired.Name { + continue + } + if !reflect.DeepEqual(current, desired) && !command.Force { + return mutationConflict("s3_connection", desired.Name) + } + manifest.Components.S3.Connections[index] = desired + return nil + } + manifest.Components.S3.Connections = append(manifest.Components.S3.Connections, desired) + return nil +} + +func addS3(manifest *projectdomain.Manifest, command projectdomain.AddS3Command) error { + if !kebabCase.MatchString(command.Name) { + return invalidMutation(projectdomain.MutationInvalidName, "s3.name", command.Name) + } + if manifest.Components.S3 == nil { + manifest.Components.S3 = &projectdomain.S3{} + } + connection := command.Connection + if connection == "" { + connection = "default" + if !hasS3Connection(manifest.Components.S3, connection) { + manifest.Components.S3.Connections = append(manifest.Components.S3.Connections, projectdomain.S3Connection{ + Name: connection, Credentials: "static", Endpoint: "http://localhost:9000", + Region: "us-east-1", PathStyle: true, + }) + } + } + if !hasS3Connection(manifest.Components.S3, connection) { + return invalidMutation(projectdomain.MutationNotFound, "s3.connection", connection) + } + desired := projectdomain.S3Bucket{Name: command.Name, Connection: connection, Bucket: command.Name + "-local"} + for index, current := range manifest.Components.S3.Buckets { + if current.Name != desired.Name { + continue + } + if !reflect.DeepEqual(current, desired) && !command.Force { + return mutationConflict("s3", desired.Name) + } + manifest.Components.S3.Buckets[index] = desired + return nil + } + manifest.Components.S3.Buckets = append(manifest.Components.S3.Buckets, desired) + return nil +} + +func hasS3Connection(storage *projectdomain.S3, name string) bool { + for _, connection := range storage.Connections { + if connection.Name == name { + return true + } + } + return false +} + +func safeRelative(name string) bool { + if name == "" || strings.HasPrefix(name, "/") { + return false + } + clean := path.Clean(strings.ReplaceAll(name, "\\", "/")) + return clean != "." && clean != ".." && !strings.HasPrefix(clean, "../") +} + +func pathWithin(root, selected string) bool { + root = path.Clean(strings.ReplaceAll(root, "\\", "/")) + selected = path.Clean(strings.ReplaceAll(selected, "\\", "/")) + if root == "." { + return safeRelative(selected) + } + return selected == root || strings.HasPrefix(selected, root+"/") +} + +func validRedisAddress(value string) bool { + if strings.Contains(value, "://") { + parsed, err := url.Parse(value) + return err == nil && (parsed.Scheme == "redis" || parsed.Scheme == "rediss") && + parsed.Hostname() != "" && parsed.User == nil + } + host, port, err := net.SplitHostPort(value) + if err != nil || host == "" { + return false + } + number, err := strconv.Atoi(port) + return err == nil && number > 0 && number <= 65535 +} + +func invalidMutation(reason projectdomain.MutationReason, field, value string) error { + return &projectdomain.MutationError{Reason: reason, Field: field, Value: value} +} + +func mutationConflict(field, value string) error { + return &projectdomain.MutationError{Reason: projectdomain.MutationExistingConflict, Field: field, Value: value, Conflict: true} +} diff --git a/internal/service/project/mutation_db_test.go b/internal/service/project/mutation_db_test.go new file mode 100644 index 0000000..dabfde7 --- /dev/null +++ b/internal/service/project/mutation_db_test.go @@ -0,0 +1,48 @@ +package project + +import ( + "testing" + + projectdomain "github.com/devctllabs/devctl/internal/domain/project" + "github.com/stretchr/testify/require" +) + +func TestDefaultClickHouseVariantPlansIndependentMigrations(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + command projectdomain.AddDBCommand + wantMigrations *projectdomain.DBMigrations + }{ + { + name: "default path", + command: projectdomain.AddDBCommand{Name: "analytics", Kind: "clickhouse"}, + wantMigrations: &projectdomain.DBMigrations{ + Path: "migrations/analytics/clickhouse", DatabaseEnv: "DB_ANALYTICS_CLICKHOUSE_MIGRATIONS_URL", + }, + }, + { + name: "explicit path", + command: projectdomain.AddDBCommand{Name: "analytics", Kind: "clickhouse", MigrationsPath: "db/analytics"}, + wantMigrations: &projectdomain.DBMigrations{ + Path: "db/analytics", DatabaseEnv: "DB_ANALYTICS_CLICKHOUSE_MIGRATIONS_URL", + }, + }, + { + name: "opt out", + command: projectdomain.AddDBCommand{Name: "analytics", Kind: "clickhouse", NoMigrations: true}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + variant := defaultDBVariant(test.command) + require.Equal(t, "clickhouse://localhost:9000/default", variant.DSNDefault) + require.Equal(t, "DB_ANALYTICS_CLICKHOUSE_DSN", variant.DSNEnv) + require.True(t, variant.Secret) + require.Equal(t, test.wantMigrations, variant.Migrations) + }) + } +} diff --git a/internal/service/project/service.go b/internal/service/project/service.go new file mode 100644 index 0000000..ccb3213 --- /dev/null +++ b/internal/service/project/service.go @@ -0,0 +1,63 @@ +package project + +import ( + "context" + + projectdomain "github.com/devctllabs/devctl/internal/domain/project" + "go.uber.org/zap" +) + +const manifestFilename = "devctl.yaml" + +//go:generate go tool mockgen -destination mocks/service.go -package mocks -typed . ManifestRepository,ManifestLocator,TargetResolver,ReadinessChecker + +// ManifestRepository persists the canonical manifest selected by the project service. +type ManifestRepository interface { + // Load decodes manifestPath, returning structural issues as data and access failures as errors. + Load(ctx context.Context, manifestPath string) (projectdomain.LoadManifestResult, error) + // Save atomically publishes project.Manifest at project.ManifestPath in canonical form and reports whether bytes changed. + Save(ctx context.Context, project projectdomain.Project) (bool, error) +} + +// ManifestLocator exposes only the filesystem facts needed to locate a Project manifest. +type ManifestLocator interface { + // WorkingDirectory returns the process directory used as the project search origin. + WorkingDirectory(ctx context.Context) (string, error) + // RegularFile reports whether relativePath is a regular non-symlink file below root. + RegularFile(ctx context.Context, root, relativePath string) (bool, error) +} + +// TargetResolver attaches the concrete input used to inspect one Target. +type TargetResolver interface { + // Resolve attaches the concrete input required to execute target in selected Project. + Resolve(ctx context.Context, selected projectdomain.Project, target projectdomain.Target) (projectdomain.Target, error) +} + +// ReadinessChecker evaluates environment-dependent project readiness policy. +type ReadinessChecker interface { + // Check returns every applicable readiness issue in stable order. + Check(ctx context.Context, selected projectdomain.Project) ([]projectdomain.Issue, error) +} + +type Service struct { + logger *zap.Logger + manifests ManifestRepository + locator ManifestLocator + inputs TargetResolver + readiness ReadinessChecker +} + +// Dependencies names the required Project service capabilities passed to New. +type Dependencies struct { + Manifests ManifestRepository + Locator ManifestLocator + Inputs TargetResolver + Readiness ReadinessChecker +} + +func New(logger *zap.Logger, dependencies Dependencies) *Service { + return &Service{ + logger: logger, manifests: dependencies.Manifests, + locator: dependencies.Locator, inputs: dependencies.Inputs, readiness: dependencies.Readiness, + } +} diff --git a/internal/service/project/service_test.go b/internal/service/project/service_test.go new file mode 100644 index 0000000..11a31ac --- /dev/null +++ b/internal/service/project/service_test.go @@ -0,0 +1,702 @@ +package project_test + +import ( + "context" + "errors" + "io/fs" + "testing" + + "github.com/devctllabs/devctl/internal/domain/contract" + domainfailure "github.com/devctllabs/devctl/internal/domain/failure" + projectdomain "github.com/devctllabs/devctl/internal/domain/project" + "github.com/devctllabs/devctl/internal/service/project" + "github.com/devctllabs/devctl/internal/service/project/mocks" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + "go.uber.org/zap" +) + +func TestServiceValidateSuccess(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + manifests := mocks.NewMockManifestRepository(ctrl) + workspace := mocks.NewMockManifestLocator(ctrl) + selected := projectdomain.Project{ + Root: "/project", ManifestPath: "/project/devctl.yaml", + Manifest: projectdomain.Manifest{ + Version: 1, + Project: projectdomain.Identity{Name: "example", Language: "go"}, + Languages: projectdomain.Languages{Go: projectdomain.GoLanguage{Module: "example.test/example"}}, + }, + } + gomock.InOrder( + workspace.EXPECT().WorkingDirectory(gomock.Any()).Return("/project", nil), + manifests.EXPECT().Load(gomock.Any(), "/project/devctl.yaml").Return(projectdomain.LoadManifestResult{Project: selected}, nil), + ) + + result, err := newValidationService(ctrl, manifests, workspace, selected).Validate(context.Background(), projectdomain.ValidateQuery{ManifestPath: "devctl.yaml"}) + + require.NoError(t, err) + require.True(t, result.IsValid()) + require.Empty(t, result.Issues) +} + +func TestServiceValidateRejectsRuntimeConfigConflicts(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + manifests := mocks.NewMockManifestRepository(ctrl) + workspace := mocks.NewMockManifestLocator(ctrl) + selected := projectdomain.Project{ + Root: "/project", ManifestPath: "/project/devctl.yaml", + Manifest: projectdomain.Manifest{ + Version: 1, + Project: projectdomain.Identity{Name: "example", Language: "go"}, + Env: projectdomain.Env{Custom: []projectdomain.EnvGroup{ + {Group: "service", Vars: []projectdomain.EnvVar{{Key: "MODE", Type: "string"}}}, + {Group: "worker", Vars: []projectdomain.EnvVar{{Key: "MODE", Type: "bool"}}}, + }}, + Languages: projectdomain.Languages{Go: projectdomain.GoLanguage{Module: "example.test/example"}}, + }, + } + gomock.InOrder( + workspace.EXPECT().WorkingDirectory(gomock.Any()).Return("/project", nil), + manifests.EXPECT().Load(gomock.Any(), "/project/devctl.yaml").Return(projectdomain.LoadManifestResult{Project: selected}, nil), + ) + + result, err := newValidationService(ctrl, manifests, workspace, selected).Validate( + context.Background(), projectdomain.ValidateQuery{ManifestPath: "devctl.yaml"}, + ) + + require.NoError(t, err) + require.Equal(t, projectdomain.ValidationResult{Issues: []projectdomain.Issue{{ + Code: projectdomain.IssueRuntimeConfigConflict, Path: "/project/devctl.yaml", Field: "env", + }}}, result) +} + +func TestServiceValidateAcceptsGitSourceRoot(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + manifests := mocks.NewMockManifestRepository(ctrl) + workspace := mocks.NewMockManifestLocator(ctrl) + selected := projectdomain.Project{ + Root: "/project", ManifestPath: "/project/devctl.yaml", + Manifest: projectdomain.Manifest{ + Version: 1, Project: projectdomain.Identity{Name: "example", Language: "go"}, + Sources: map[string]projectdomain.Source{"contracts": { + Type: projectdomain.SourceGit, Path: "contracts", Repo: "example/contracts", Ref: "v1", + }}, + Languages: projectdomain.Languages{Go: projectdomain.GoLanguage{Module: "example.test/example"}}, + }, + } + gomock.InOrder( + workspace.EXPECT().WorkingDirectory(gomock.Any()).Return("/project", nil), + manifests.EXPECT().Load(gomock.Any(), "/project/devctl.yaml").Return(projectdomain.LoadManifestResult{Project: selected}, nil), + ) + + result, err := newValidationService(ctrl, manifests, workspace, selected).Validate(context.Background(), projectdomain.ValidateQuery{ManifestPath: "devctl.yaml"}) + + require.NoError(t, err) + require.True(t, result.IsValid()) +} + +func TestServiceValidateDiscoversManifestFromWorkingDirectory(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + manifests := mocks.NewMockManifestRepository(ctrl) + workspace := mocks.NewMockManifestLocator(ctrl) + selected := projectdomain.Project{ + Root: "/project", ManifestPath: "/project/devctl.yaml", + Manifest: projectdomain.Manifest{ + Version: 1, + Project: projectdomain.Identity{Name: "example", Language: "go"}, + Languages: projectdomain.Languages{Go: projectdomain.GoLanguage{Module: "example.test/example"}}, + }, + } + gomock.InOrder( + workspace.EXPECT().WorkingDirectory(gomock.Any()).Return("/project/nested", nil), + workspace.EXPECT().RegularFile(gomock.Any(), "/project/nested", "devctl.yaml").Return(false, nil), + workspace.EXPECT().RegularFile(gomock.Any(), "/project", "devctl.yaml").Return(true, nil), + manifests.EXPECT().Load(gomock.Any(), "/project/devctl.yaml").Return(projectdomain.LoadManifestResult{Project: selected}, nil), + ) + + result, err := newValidationService(ctrl, manifests, workspace, selected).Validate(context.Background(), projectdomain.ValidateQuery{}) + + require.NoError(t, err) + require.True(t, result.IsValid()) +} + +func TestServiceValidateReturnsAllManifestIssues(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + manifests := mocks.NewMockManifestRepository(ctrl) + workspace := mocks.NewMockManifestLocator(ctrl) + selected := projectdomain.Project{ + Root: "/project", ManifestPath: "/project/devctl.yaml", + Manifest: projectdomain.Manifest{Version: 1, Project: projectdomain.Identity{Language: "go"}}, + } + gomock.InOrder( + workspace.EXPECT().WorkingDirectory(gomock.Any()).Return("/project", nil), + manifests.EXPECT().Load(gomock.Any(), "/project/devctl.yaml").Return(projectdomain.LoadManifestResult{Project: selected}, nil), + ) + + readiness := mocks.NewMockReadinessChecker(ctrl) + readiness.EXPECT().Check(gomock.Any(), selected).Return([]projectdomain.Issue{{ + Code: projectdomain.IssueGoModMissing, Path: selected.ManifestPath, Field: "go.mod", + }}, nil) + result, err := project.New(zap.NewNop(), project.Dependencies{ + Manifests: manifests, Locator: workspace, Readiness: readiness, + }).Validate(context.Background(), projectdomain.ValidateQuery{ManifestPath: "devctl.yaml"}) + + require.NoError(t, err) + require.Equal(t, projectdomain.ValidationResult{Issues: []projectdomain.Issue{ + {Code: "name_invalid", Path: "/project/devctl.yaml", Field: "project.name"}, + {Code: "go_module_required", Path: "/project/devctl.yaml", Field: "languages.go.module"}, + {Code: "go_mod_missing", Path: "/project/devctl.yaml", Field: "go.mod"}, + }}, result) +} + +func TestServiceValidatePreservesReadinessErrorAfterSemanticIssues(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + manifests := mocks.NewMockManifestRepository(ctrl) + workspace := mocks.NewMockManifestLocator(ctrl) + readiness := mocks.NewMockReadinessChecker(ctrl) + cause := errors.New("workspace unavailable") + selected := projectdomain.Project{ + Root: "/project", ManifestPath: "/project/devctl.yaml", + Manifest: projectdomain.Manifest{ + Version: 1, + Project: projectdomain.Identity{Language: "go"}, + Languages: projectdomain.Languages{Go: projectdomain.GoLanguage{Module: "example.test/example"}}, + }, + } + gomock.InOrder( + workspace.EXPECT().WorkingDirectory(gomock.Any()).Return("/project", nil), + manifests.EXPECT().Load(gomock.Any(), "/project/devctl.yaml").Return(projectdomain.LoadManifestResult{Project: selected}, nil), + readiness.EXPECT().Check(gomock.Any(), selected).Return(nil, &projectdomain.OperationError{ + Operation: projectdomain.OperationInspectFile, + Path: "go.mod", + Kind: projectdomain.FailureUnavailable, + Cause: cause, + }), + ) + + result, err := project.New(zap.NewNop(), project.Dependencies{ + Manifests: manifests, Locator: workspace, Readiness: readiness, + }).Validate(context.Background(), projectdomain.ValidateQuery{ManifestPath: "devctl.yaml"}) + + var operationErr *projectdomain.OperationError + require.ErrorAs(t, err, &operationErr) + require.Equal(t, projectdomain.OperationInspectFile, operationErr.Operation) + require.Equal(t, "go.mod", operationErr.Path) + require.Equal(t, projectdomain.FailureUnavailable, operationErr.Kind) + require.ErrorIs(t, err, cause) + require.Equal(t, projectdomain.ValidationResult{}, result) +} + +func TestServiceValidateAppendsInjectedReadinessIssuesAfterSemanticIssues(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + manifests := mocks.NewMockManifestRepository(ctrl) + workspace := mocks.NewMockManifestLocator(ctrl) + readiness := mocks.NewMockReadinessChecker(ctrl) + selected := projectdomain.Project{ + Root: "/project", ManifestPath: "/project/devctl.yaml", + Manifest: projectdomain.Manifest{ + Version: 1, Project: projectdomain.Identity{Language: "go"}, + Languages: projectdomain.Languages{Go: projectdomain.GoLanguage{Module: "example.test/example"}}, + }, + } + readinessIssue := projectdomain.Issue{ + Code: projectdomain.IssueGoModMissing, Path: selected.ManifestPath, Field: "go.mod", + } + gomock.InOrder( + workspace.EXPECT().WorkingDirectory(gomock.Any()).Return("/project", nil), + manifests.EXPECT().Load(gomock.Any(), "/project/devctl.yaml").Return(projectdomain.LoadManifestResult{Project: selected}, nil), + readiness.EXPECT().Check(gomock.Any(), selected).Return([]projectdomain.Issue{readinessIssue}, nil), + ) + + result, err := project.New(zap.NewNop(), project.Dependencies{ + Manifests: manifests, Locator: workspace, Readiness: readiness, + }).Validate(context.Background(), projectdomain.ValidateQuery{ManifestPath: "devctl.yaml"}) + + require.NoError(t, err) + require.Equal(t, []projectdomain.Issue{ + {Code: projectdomain.IssueNameInvalid, Path: selected.ManifestPath, Field: "project.name"}, + readinessIssue, + }, result.Issues) +} + +func TestServiceValidateRejectsGRPCClientWithUnknownSource(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + manifests := mocks.NewMockManifestRepository(ctrl) + workspace := mocks.NewMockManifestLocator(ctrl) + selected := projectdomain.Project{ + Root: "/project", ManifestPath: "/project/devctl.yaml", + Manifest: projectdomain.Manifest{ + Version: 1, Project: projectdomain.Identity{Name: "example", Language: "go"}, + Components: projectdomain.Components{GRPC: &projectdomain.GRPC{Clients: []projectdomain.GRPCClient{{ + Name: "billing", Source: "missing", Path: "proto/billing", ProtoRoot: "proto", + }}}}, + Languages: projectdomain.Languages{Go: projectdomain.GoLanguage{Module: "example.test/example"}}, + }, + } + gomock.InOrder( + workspace.EXPECT().WorkingDirectory(gomock.Any()).Return("/project", nil), + manifests.EXPECT().Load(gomock.Any(), "/project/devctl.yaml").Return(projectdomain.LoadManifestResult{Project: selected}, nil), + ) + + result, err := newValidationService(ctrl, manifests, workspace, selected).Validate( + context.Background(), projectdomain.ValidateQuery{ManifestPath: "devctl.yaml"}, + ) + + require.NoError(t, err) + require.Equal(t, projectdomain.ValidationResult{Issues: []projectdomain.Issue{{ + Code: projectdomain.IssueSourceNotFound, Path: "/project/devctl.yaml", + Field: "components.grpc.clients.billing.source", + }}}, result) +} + +func TestServiceInspectReportsGRPCAndKafkaTargets(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + manifests := mocks.NewMockManifestRepository(ctrl) + workspace := mocks.NewMockManifestLocator(ctrl) + selected := projectdomain.Project{ + Root: "/project", ManifestPath: "/project/devctl.yaml", + Manifest: projectdomain.Manifest{ + Version: 1, Project: projectdomain.Identity{Name: "example", Language: "go"}, + Sources: map[string]projectdomain.Source{"contracts": {Type: "local", Path: "api/contracts"}}, + Components: projectdomain.Components{ + GRPC: &projectdomain.GRPC{ + Server: &projectdomain.GRPCServer{ProtoRoot: "api/proto"}, + Clients: []projectdomain.GRPCClient{{ + Name: "billing", Source: "contracts", Path: "proto/billing", ProtoRoot: "proto", + BufGenConfig: "tools/buf/billing.gen.yaml", + }}, + }, + Kafka: &projectdomain.Kafka{Producers: []projectdomain.KafkaProducer{{ + Name: "audit", Topic: "audit_service.audit.events.v1", Contract: projectdomain.KafkaContract{Format: "raw"}, + }}}, + }, + Languages: projectdomain.Languages{Go: projectdomain.GoLanguage{ + Module: "example.test/example", + Generators: projectdomain.GoGenerators{GRPC: &projectdomain.GRPCGenerator{ + Out: "generated/grpc", BufGenConfig: "tools/buf/shared.gen.yaml", + }}, + }}, + }, + } + gomock.InOrder( + workspace.EXPECT().WorkingDirectory(gomock.Any()).Return("/project", nil), + manifests.EXPECT().Load(gomock.Any(), "/project/devctl.yaml").Return(projectdomain.LoadManifestResult{Project: selected}, nil), + ) + service := project.New(zap.NewNop(), project.Dependencies{Manifests: manifests, Locator: workspace}) + + result, err := service.Inspect(context.Background(), projectdomain.InspectQuery{ManifestPath: "devctl.yaml"}) + + require.NoError(t, err) + require.Equal(t, []projectdomain.InspectionTarget{ + {ID: "config", Family: "config", Format: "go", Output: "gen/config"}, + {ID: "grpc-client:billing", Family: "grpc", Format: "proto", Input: "api/contracts/proto", Config: "tools/buf/billing.gen.yaml", Output: "generated/grpc/client/billing"}, + {ID: "grpc-server", Family: "grpc", Format: "proto", Input: "api/proto", Config: "tools/buf/shared.gen.yaml", Output: "generated/grpc/server"}, + {ID: "kafka-producer:audit", Family: "kafka", Format: "raw"}, + }, result.Project.Targets) +} + +func TestServiceInspectAddsResolvedInputOnlyFromValidCommittedMetadata(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + manifests := mocks.NewMockManifestRepository(ctrl) + workspace := mocks.NewMockManifestLocator(ctrl) + inputs := mocks.NewMockTargetResolver(ctrl) + selected := projectdomain.Project{ + Root: "/project", ManifestPath: "/project/devctl.yaml", + Manifest: projectdomain.Manifest{ + Version: 1, Project: projectdomain.Identity{Name: "example", Language: "go"}, + Sources: map[string]projectdomain.Source{ + "upstream": {Type: projectdomain.SourceDevctl, Repo: "example/contracts", Ref: "v1"}, + }, + Components: projectdomain.Components{ + GRPC: &projectdomain.GRPC{Clients: []projectdomain.GRPCClient{{ + Name: "billing", Source: "upstream", Export: "billing", + }}}, + Kafka: &projectdomain.Kafka{Consumers: []projectdomain.KafkaConsumer{{ + Name: "audit", Topic: "audit_service.audit.events.v1", + Contract: projectdomain.KafkaContract{Format: "json", Source: "upstream", Export: "audit"}, + }}}, + }, + Languages: projectdomain.Languages{Go: projectdomain.GoLanguage{Module: "example.test/example"}}, + }, + } + grpcSnapshot := contract.Snapshot{ + ModuleRoot: "api/proto/grpc", + Metadata: &contract.Metadata{ + Kind: "grpc", Format: "proto", ModuleRoot: "api/proto/grpc", BufConfig: "buf.yaml", + }, + } + staleMetadata := &contract.SnapshotMetadataError{ + Field: "entrypoint", Reason: contract.MetadataMismatch, Hint: "devctl sync", + } + targets := projectdomain.NewTargetCatalog(selected.Manifest).All() + grpcTarget, kafkaTarget := targets[1], targets[2] + gomock.InOrder( + workspace.EXPECT().WorkingDirectory(gomock.Any()).Return("/project", nil), + manifests.EXPECT().Load(gomock.Any(), "/project/devctl.yaml").Return(projectdomain.LoadManifestResult{Project: selected}, nil), + ) + inputs.EXPECT().Resolve(gomock.Any(), selected, grpcTarget).Return(grpcTarget.WithSnapshot(grpcSnapshot), nil) + inputs.EXPECT().Resolve(gomock.Any(), selected, kafkaTarget).Return(kafkaTarget, staleMetadata) + + result, err := project.New(zap.NewNop(), project.Dependencies{ + Manifests: manifests, Locator: workspace, Inputs: inputs, + }).Inspect( + context.Background(), projectdomain.InspectQuery{ManifestPath: "devctl.yaml"}, + ) + + require.NoError(t, err) + require.Equal(t, []projectdomain.InspectionTarget{ + {ID: "config", Family: "config", Format: "go", Output: "gen/config"}, + { + ID: "grpc-client:billing", Family: "grpc", Format: "proto", + Input: "api/external/grpc/client/billing", + ResolvedInput: "api/external/grpc/client/billing/api/proto/grpc", + Config: "tools/buf/grpc.gen.yaml", Output: "gen/grpc/client/billing", + }, + { + ID: "kafka-consumer:audit", Family: "kafka", Format: "json", + Input: "api/external/kafka/consumer/audit", Output: "gen/kafka/consumer/audit", + }, + }, result.Project.Targets) +} + +func TestServiceInspectRejectsUnsafeCatalogOutputPathsForEveryGeneratorFamily(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + manifests := mocks.NewMockManifestRepository(ctrl) + workspace := mocks.NewMockManifestLocator(ctrl) + selected := projectdomain.Project{ + Root: "/project", ManifestPath: "/project/devctl.yaml", + Manifest: projectdomain.Manifest{ + Version: 1, Project: projectdomain.Identity{Name: "example", Language: "go"}, + Sources: map[string]projectdomain.Source{ + "contracts": {Type: projectdomain.SourceLocal, Path: "api/contracts"}, + }, + Components: projectdomain.Components{ + GRPC: &projectdomain.GRPC{Server: &projectdomain.GRPCServer{}}, + Kafka: &projectdomain.Kafka{Producers: []projectdomain.KafkaProducer{{ + Name: "audit", Topic: "audit_service.audit.events.v1", + Contract: projectdomain.KafkaContract{ + Format: "proto", Source: "contracts", Path: "proto/audit.proto", ProtoRoot: "proto", + }, + }}}, + }, + Languages: projectdomain.Languages{Go: projectdomain.GoLanguage{ + Module: "example.test/example", + Generators: projectdomain.GoGenerators{ + GRPC: &projectdomain.GRPCGenerator{Out: "/tmp/grpc"}, + Kafka: &projectdomain.KafkaGenerator{Out: "../kafka"}, + }, + }}, + }, + } + gomock.InOrder( + workspace.EXPECT().WorkingDirectory(gomock.Any()).Return("/project", nil), + manifests.EXPECT().Load(gomock.Any(), "/project/devctl.yaml").Return(projectdomain.LoadManifestResult{Project: selected}, nil), + ) + + _, err := project.New(zap.NewNop(), project.Dependencies{Manifests: manifests, Locator: workspace}).Inspect( + context.Background(), projectdomain.InspectQuery{ManifestPath: "devctl.yaml"}, + ) + + var invalid *projectdomain.InvalidManifestError + require.ErrorAs(t, err, &invalid) + require.Equal(t, []projectdomain.Issue{ + {Code: projectdomain.IssuePathInvalid, Path: selected.ManifestPath, Field: "languages.go.generators.grpc.out"}, + {Code: projectdomain.IssuePathInvalid, Path: selected.ManifestPath, Field: "languages.go.generators.kafka.out"}, + }, invalid.Issues) +} + +func TestServiceInspectReportsMigrationResourcesAndEnvironment(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + manifests := mocks.NewMockManifestRepository(ctrl) + workspace := mocks.NewMockManifestLocator(ctrl) + selected := projectdomain.Project{ + Root: "/project", ManifestPath: "/project/devctl.yaml", + Manifest: projectdomain.Manifest{ + Version: 1, Project: projectdomain.Identity{Name: "example", Language: "go"}, + Components: projectdomain.Components{DB: &projectdomain.DB{Connections: []projectdomain.DBConnection{{ + Name: "primary", Default: "sqlite", Variants: []projectdomain.DBVariant{ + {Name: "sqlite", Kind: "sqlite", Migrations: &projectdomain.DBMigrations{Path: "migrations/primary/sqlite", DatabaseEnv: "DB_PRIMARY_SQLITE_MIGRATIONS_URL", DatabaseDefault: "sqlite://./data/primary.db"}}, + {Name: "memory", Kind: "sqlite"}, + {Name: "postgres", Kind: "postgres", Migrations: &projectdomain.DBMigrations{Path: "migrations/primary/postgres", DatabaseEnv: "DB_PRIMARY_POSTGRES_MIGRATIONS_URL"}}, + }, + }}}}, + Languages: projectdomain.Languages{Go: projectdomain.GoLanguage{Module: "example.test/example"}}, + }, + } + gomock.InOrder( + workspace.EXPECT().WorkingDirectory(gomock.Any()).Return("/project", nil), + manifests.EXPECT().Load(gomock.Any(), "/project/devctl.yaml").Return(projectdomain.LoadManifestResult{Project: selected}, nil), + ) + + result, err := project.New(zap.NewNop(), project.Dependencies{Manifests: manifests, Locator: workspace}).Inspect( + context.Background(), projectdomain.InspectQuery{ManifestPath: "devctl.yaml"}, + ) + + require.NoError(t, err) + require.Equal(t, []string{"primary"}, result.Project.Resources.DBConnections) + require.Equal(t, []projectdomain.InspectionMigration{ + {Connection: "primary", Variant: "postgres", Kind: "postgres", Path: "migrations/primary/postgres", DatabaseEnv: "EXAMPLE_DB_PRIMARY_POSTGRES_MIGRATIONS_URL"}, + {Connection: "primary", Variant: "sqlite", Kind: "sqlite", Path: "migrations/primary/sqlite", DatabaseEnv: "EXAMPLE_DB_PRIMARY_SQLITE_MIGRATIONS_URL"}, + }, result.Project.Resources.Migrations) + require.Equal(t, []projectdomain.EffectiveEnv{ + {Key: "EXAMPLE_DB_PRIMARY_KIND", Type: "string", Default: "sqlite"}, + {Key: "EXAMPLE_DB_PRIMARY_MEMORY_DSN", Type: "string"}, + {Key: "EXAMPLE_DB_PRIMARY_POSTGRES_DSN", Type: "string"}, + {Key: "EXAMPLE_DB_PRIMARY_POSTGRES_MIGRATIONS_URL", Type: "string", Secret: true}, + {Key: "EXAMPLE_DB_PRIMARY_SQLITE_DSN", Type: "string"}, + {Key: "EXAMPLE_DB_PRIMARY_SQLITE_MIGRATIONS_URL", Type: "string", Default: "sqlite://./data/primary.db"}, + }, result.Project.Env) +} + +func TestServiceInspectUsesCanonicalRuntimeConfigPolicy(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + manifests := mocks.NewMockManifestRepository(ctrl) + workspace := mocks.NewMockManifestLocator(ctrl) + selected := projectdomain.Project{ + Root: "/project", ManifestPath: "/project/devctl.yaml", + Manifest: projectdomain.Manifest{ + Version: 1, Project: projectdomain.Identity{Name: "sample-api", Language: "go"}, + Components: projectdomain.Components{ + Logging: &projectdomain.Logging{}, + HTTP: &projectdomain.HTTP{Server: &projectdomain.HTTPServer{Start: &projectdomain.Start{ + Env: "SERVE_HTTP", + }}}, + }, + Languages: projectdomain.Languages{Go: projectdomain.GoLanguage{Module: "example.test/sample-api"}}, + }, + } + gomock.InOrder( + workspace.EXPECT().WorkingDirectory(gomock.Any()).Return("/project", nil), + manifests.EXPECT().Load(gomock.Any(), "/project/devctl.yaml").Return(projectdomain.LoadManifestResult{Project: selected}, nil), + ) + + result, err := project.New(zap.NewNop(), project.Dependencies{Manifests: manifests, Locator: workspace}).Inspect( + context.Background(), projectdomain.InspectQuery{ManifestPath: "devctl.yaml"}, + ) + + require.NoError(t, err) + require.Equal(t, []projectdomain.EffectiveEnv{ + {Key: "SAMPLE_API_HTTP_ADDR", Type: "string", Default: ":8080"}, + {Key: "SAMPLE_API_LOG_LEVEL", Type: "string", Default: "info"}, + {Key: "SAMPLE_API_SERVE_HTTP", Type: "bool", Default: false}, + }, result.Project.Env) +} + +func TestServiceValidateReturnsMissingManifestAsExecutionError(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + manifests := mocks.NewMockManifestRepository(ctrl) + workspace := mocks.NewMockManifestLocator(ctrl) + gomock.InOrder( + workspace.EXPECT().WorkingDirectory(gomock.Any()).Return("/project", nil), + manifests.EXPECT().Load(gomock.Any(), "/project/missing.yaml").Return(projectdomain.LoadManifestResult{}, fs.ErrNotExist), + ) + + result, err := project.New(zap.NewNop(), project.Dependencies{Manifests: manifests, Locator: workspace}).Validate(context.Background(), projectdomain.ValidateQuery{ManifestPath: "missing.yaml"}) + + require.Equal(t, domainfailure.NotFound, domainfailure.CategoryOf(err)) + var operationErr *projectdomain.OperationError + require.ErrorAs(t, err, &operationErr) + require.Equal(t, projectdomain.OperationLoadManifest, operationErr.Operation) + require.Equal(t, "/project/missing.yaml", operationErr.Path) + require.ErrorIs(t, err, fs.ErrNotExist) + require.EqualError(t, err, "manifests.Load: load_manifest failed: file does not exist") + require.Equal(t, projectdomain.ValidationResult{}, result) +} + +func TestServiceValidatePreservesLoadErrorWhenResultAlsoContainsIssues(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + manifests := mocks.NewMockManifestRepository(ctrl) + workspace := mocks.NewMockManifestLocator(ctrl) + loadErr := errors.New("storage unavailable") + workspace.EXPECT().WorkingDirectory(gomock.Any()).Return("/project", nil) + manifests.EXPECT().Load(gomock.Any(), "/project/devctl.yaml").Return(projectdomain.LoadManifestResult{ + Project: projectdomain.Project{ManifestPath: "/project/devctl.yaml"}, + Issues: []projectdomain.DecodeIssue{{Kind: projectdomain.DecodeYAMLInvalid}}, + }, loadErr) + + result, err := project.New(zap.NewNop(), project.Dependencies{Manifests: manifests, Locator: workspace}).Validate( + context.Background(), projectdomain.ValidateQuery{ManifestPath: "devctl.yaml"}, + ) + + require.ErrorIs(t, err, loadErr) + require.Equal(t, domainfailure.Unavailable, domainfailure.CategoryOf(err)) + require.Equal(t, projectdomain.ValidationResult{}, result) +} + +func TestServiceLoadProjectPreservesLoadErrorWhenResultAlsoContainsIssues(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + manifests := mocks.NewMockManifestRepository(ctrl) + workspace := mocks.NewMockManifestLocator(ctrl) + loadErr := errors.New("storage unavailable") + workspace.EXPECT().WorkingDirectory(gomock.Any()).Return("/project", nil) + manifests.EXPECT().Load(gomock.Any(), "/project/devctl.yaml").Return(projectdomain.LoadManifestResult{ + Project: projectdomain.Project{ManifestPath: "/project/devctl.yaml"}, + Issues: []projectdomain.DecodeIssue{{Kind: projectdomain.DecodeYAMLInvalid}}, + }, loadErr) + + result, err := project.New(zap.NewNop(), project.Dependencies{Manifests: manifests, Locator: workspace}).LoadProject(context.Background(), "devctl.yaml") + + require.ErrorIs(t, err, loadErr) + require.Equal(t, domainfailure.Unavailable, domainfailure.CategoryOf(err)) + require.Equal(t, projectdomain.Project{}, result) +} + +func TestServiceLoadProjectUsesAbsoluteManifestPathWithoutWorkingDirectory(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + manifests := mocks.NewMockManifestRepository(ctrl) + workspace := mocks.NewMockManifestLocator(ctrl) + selected := projectdomain.Project{ + Root: "/project", ManifestPath: "/project/devctl.yaml", + Manifest: projectdomain.Manifest{ + Version: 1, + Project: projectdomain.Identity{Name: "example", Language: "go"}, + Languages: projectdomain.Languages{Go: projectdomain.GoLanguage{Module: "example.test/example"}}, + }, + } + manifests.EXPECT().Load(gomock.Any(), "/project/devctl.yaml").Return(projectdomain.LoadManifestResult{Project: selected}, nil) + + result, err := project.New(zap.NewNop(), project.Dependencies{Manifests: manifests, Locator: workspace}).LoadProject(context.Background(), "/project/devctl.yaml") + + require.NoError(t, err) + require.Equal(t, selected, result) +} + +func TestServiceOwnsEnableCommandOutcome(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + manifests := mocks.NewMockManifestRepository(ctrl) + workspace := mocks.NewMockManifestLocator(ctrl) + selected := projectdomain.Project{ManifestPath: "custom.yaml", Manifest: projectdomain.Manifest{ + Version: 1, + Project: projectdomain.Identity{Name: "example", Language: "go"}, + Components: projectdomain.Components{}, + Languages: projectdomain.Languages{Go: projectdomain.GoLanguage{Module: "example.test/example"}}, + }} + updated := selected + updated.Manifest.Components.Logging = &projectdomain.Logging{} + gomock.InOrder( + workspace.EXPECT().WorkingDirectory(gomock.Any()).Return("/project", nil), + manifests.EXPECT().Load(gomock.Any(), "/project/custom.yaml").Return(projectdomain.LoadManifestResult{Project: selected}, nil), + manifests.EXPECT().Save(gomock.Any(), updated).Return(true, nil), + ) + + result, err := project.New(zap.NewNop(), project.Dependencies{Manifests: manifests, Locator: workspace}).Enable(context.Background(), projectdomain.EnableCommand{ + ManifestPath: "custom.yaml", Capability: "logging", + }) + + require.NoError(t, err) + require.Equal(t, projectdomain.ManifestResult{Manifest: "custom.yaml", Change: projectdomain.ChangeUpdated}, result) +} + +func TestServiceReturnsTypedMutationFailure(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + manifests := mocks.NewMockManifestRepository(ctrl) + workspace := mocks.NewMockManifestLocator(ctrl) + selected := projectdomain.Project{ManifestPath: "custom.yaml", Manifest: projectdomain.Manifest{ + Version: 1, + Project: projectdomain.Identity{Name: "example", Language: "go"}, + Languages: projectdomain.Languages{Go: projectdomain.GoLanguage{Module: "example.test/example"}}, + }} + gomock.InOrder( + workspace.EXPECT().WorkingDirectory(gomock.Any()).Return("/project", nil), + manifests.EXPECT().Load(gomock.Any(), "/project/custom.yaml").Return(projectdomain.LoadManifestResult{Project: selected}, nil), + ) + + result, err := project.New(zap.NewNop(), project.Dependencies{Manifests: manifests, Locator: workspace}).Enable(context.Background(), projectdomain.EnableCommand{ + ManifestPath: "custom.yaml", Capability: "logging", Always: true, + }) + + var mutationErr *projectdomain.MutationError + require.ErrorAs(t, err, &mutationErr) + require.Equal(t, projectdomain.MutationUnsupportedOption, mutationErr.Reason) + require.Equal(t, "always", mutationErr.Field) + require.Equal(t, domainfailure.InvalidInput, domainfailure.CategoryOf(err)) + require.Equal(t, projectdomain.ManifestResult{Manifest: "custom.yaml"}, result) +} + +func TestServiceAddSourceRejectsPathForDevctlSource(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + manifests := mocks.NewMockManifestRepository(ctrl) + workspace := mocks.NewMockManifestLocator(ctrl) + selected := projectdomain.Project{ManifestPath: "custom.yaml", Manifest: projectdomain.Manifest{ + Version: 1, + Project: projectdomain.Identity{Name: "example", Language: "go"}, + Languages: projectdomain.Languages{Go: projectdomain.GoLanguage{Module: "example.test/example"}}, + }} + gomock.InOrder( + workspace.EXPECT().WorkingDirectory(gomock.Any()).Return("/project", nil), + manifests.EXPECT().Load(gomock.Any(), "/project/custom.yaml").Return(projectdomain.LoadManifestResult{Project: selected}, nil), + ) + + result, err := project.New(zap.NewNop(), project.Dependencies{Manifests: manifests, Locator: workspace}).AddSource(context.Background(), projectdomain.AddSourceCommand{ + ManifestPath: "custom.yaml", Name: "contracts", Type: "devctl", Path: "contracts", + Repo: "example/contracts", Ref: "v1", + }) + + var mutationErr *projectdomain.MutationError + require.ErrorAs(t, err, &mutationErr) + require.Equal(t, projectdomain.MutationInvalidOptions, mutationErr.Reason) + require.Equal(t, "source", mutationErr.Field) + require.Equal(t, domainfailure.InvalidInput, domainfailure.CategoryOf(err)) + require.Equal(t, projectdomain.ManifestResult{Manifest: "custom.yaml"}, result) +} + +func TestServiceOwnsInitManifestOutcome(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + manifests := mocks.NewMockManifestRepository(ctrl) + command := projectdomain.InitManifestCommand{Destination: "/project/custom.yaml", Language: "go", Preset: "cli", Name: "sample", Module: "example.test/sample"} + gomock.InOrder( + manifests.EXPECT().Load(gomock.Any(), "/project/custom.yaml").Return(projectdomain.LoadManifestResult{}, fs.ErrNotExist), + manifests.EXPECT().Save(gomock.Any(), gomock.Any()).DoAndReturn(func(_ context.Context, saved projectdomain.Project) (bool, error) { + require.Nil(t, saved.Manifest.Languages.Go.Generators.Config) + return true, nil + }), + ) + + result, err := project.New(zap.NewNop(), project.Dependencies{Manifests: manifests}).InitManifest(context.Background(), command) + + require.NoError(t, err) + require.Equal(t, projectdomain.ManifestResult{Manifest: "/project/custom.yaml", Change: projectdomain.ChangeCreated}, result) +} diff --git a/internal/service/project/validation.go b/internal/service/project/validation.go new file mode 100644 index 0000000..af0cec4 --- /dev/null +++ b/internal/service/project/validation.go @@ -0,0 +1,35 @@ +package project + +import ( + "context" + "fmt" + + projectdomain "github.com/devctllabs/devctl/internal/domain/project" +) + +// Validate collects all available structural, semantic, and project-readiness issues. +// Invalid project data is returned as issues; access and persistence failures are errors. +func (s *Service) Validate(ctx context.Context, query projectdomain.ValidateQuery) (projectdomain.ValidationResult, error) { + manifestPath, err := s.resolveManifestPath(ctx, query.ManifestPath) + if err != nil { + operationErr := projectOperationError(projectdomain.OperationLoadManifest, query.ManifestPath, manifestAccessFailure(err), err) + return projectdomain.ValidationResult{}, fmt.Errorf("s.resolveManifestPath: %w", operationErr) + } + loaded, err := s.manifests.Load(ctx, manifestPath) + if err != nil { + operationErr := projectOperationError(projectdomain.OperationLoadManifest, manifestPath, manifestAccessFailure(err), err) + return projectdomain.ValidationResult{}, fmt.Errorf("manifests.Load: %w", operationErr) + } + if len(loaded.Issues) > 0 { + issues := validationIssues(selectedManifestPath(loaded.Project.ManifestPath, manifestPath), loaded.Issues) + return projectdomain.ValidationResult{Issues: issues}, nil + } + + issues := projectdomain.Validate(loaded.Project) + readinessIssues, err := s.readiness.Check(ctx, loaded.Project) + if err != nil { + return projectdomain.ValidationResult{}, fmt.Errorf("readiness.Check: %w", err) + } + issues = append(issues, readinessIssues...) + return projectdomain.ValidationResult{Issues: issues}, nil +} diff --git a/internal/service/project/validation_db_test.go b/internal/service/project/validation_db_test.go new file mode 100644 index 0000000..6bbb2af --- /dev/null +++ b/internal/service/project/validation_db_test.go @@ -0,0 +1,129 @@ +package project_test + +import ( + "context" + "testing" + + projectdomain "github.com/devctllabs/devctl/internal/domain/project" + "github.com/devctllabs/devctl/internal/service/project/mocks" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" +) + +func TestServiceValidateDBShape(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + database projectdomain.DB + issues []projectdomain.Issue + }{ + { + name: "empty connection list", + database: projectdomain.DB{}, + issues: []projectdomain.Issue{{ + Code: projectdomain.IssueDBConnectionInvalid, Path: "/project/devctl.yaml", Field: "components.db.connections", + }}, + }, + { + name: "single variant with mismatched explicit default", + database: projectdomain.DB{Connections: []projectdomain.DBConnection{{ + Name: "primary", Default: "postgres", Variants: []projectdomain.DBVariant{{Name: "sqlite", Kind: "sqlite"}}, + }}}, + issues: []projectdomain.Issue{{ + Code: projectdomain.IssueDBDefaultInvalid, Path: "/project/devctl.yaml", Field: "components.db.connections.primary.default", + }}, + }, + { + name: "single variant with inferred default", + database: projectdomain.DB{Connections: []projectdomain.DBConnection{{ + Name: "primary", Variants: []projectdomain.DBVariant{{Name: "sqlite", Kind: "sqlite"}}, + }}}, + }, + { + name: "single variant with matching explicit default", + database: projectdomain.DB{Connections: []projectdomain.DBConnection{{ + Name: "primary", Default: "sqlite", Variants: []projectdomain.DBVariant{{Name: "sqlite", Kind: "sqlite"}}, + }}}, + }, + { + name: "single clickhouse variant", + database: projectdomain.DB{Connections: []projectdomain.DBConnection{{ + Name: "analytics", Default: "clickhouse", Variants: []projectdomain.DBVariant{{Name: "clickhouse", Kind: "clickhouse"}}, + }}}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + manifests := mocks.NewMockManifestRepository(ctrl) + workspace := mocks.NewMockManifestLocator(ctrl) + selected := projectdomain.Project{ + Root: "/project", ManifestPath: "/project/devctl.yaml", + Manifest: projectdomain.Manifest{ + Version: 1, + Project: projectdomain.Identity{Name: "example", Language: "go"}, + Components: projectdomain.Components{DB: &test.database}, + Languages: projectdomain.Languages{Go: projectdomain.GoLanguage{Module: "example.test/example"}}, + }, + } + gomock.InOrder( + workspace.EXPECT().WorkingDirectory(gomock.Any()).Return("/project", nil), + manifests.EXPECT().Load(gomock.Any(), "/project/devctl.yaml").Return(projectdomain.LoadManifestResult{Project: selected}, nil), + ) + + result, err := newValidationService(ctrl, manifests, workspace, selected).Validate( + context.Background(), + projectdomain.ValidateQuery{ManifestPath: "devctl.yaml"}, + ) + + require.NoError(t, err) + require.Equal(t, len(test.issues) == 0, result.IsValid()) + require.Equal(t, test.issues, result.Issues) + }) + } +} + +func TestServiceValidateRejectsInvalidMigrations(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + manifests := mocks.NewMockManifestRepository(ctrl) + workspace := mocks.NewMockManifestLocator(ctrl) + selected := projectdomain.Project{ + Root: "/project", ManifestPath: "/project/devctl.yaml", + Manifest: projectdomain.Manifest{ + Version: 1, Project: projectdomain.Identity{Name: "example", Language: "go"}, + Components: projectdomain.Components{DB: &projectdomain.DB{Connections: []projectdomain.DBConnection{ + {Name: "primary", Default: "sqlite", Variants: []projectdomain.DBVariant{{ + Name: "sqlite", Kind: "sqlite", Migrations: &projectdomain.DBMigrations{ + Path: "../outside", DatabaseEnv: "bad-env", DatabaseDefault: "postgres://localhost/app", + }, + }}}, + {Name: "analytics", Default: "clickhouse", Variants: []projectdomain.DBVariant{{ + Name: "clickhouse", Kind: "clickhouse", Migrations: &projectdomain.DBMigrations{ + Path: "migrations/analytics/clickhouse", DatabaseEnv: "DB_ANALYTICS_CLICKHOUSE_MIGRATIONS_URL", DatabaseDefault: "postgres://localhost/wrong", + }, + }}}, + }}}, + Languages: projectdomain.Languages{Go: projectdomain.GoLanguage{Module: "example.test/example"}}, + }, + } + gomock.InOrder( + workspace.EXPECT().WorkingDirectory(gomock.Any()).Return("/project", nil), + manifests.EXPECT().Load(gomock.Any(), "/project/devctl.yaml").Return(projectdomain.LoadManifestResult{Project: selected}, nil), + ) + + result, err := newValidationService(ctrl, manifests, workspace, selected).Validate( + context.Background(), projectdomain.ValidateQuery{ManifestPath: "devctl.yaml"}, + ) + + require.NoError(t, err) + require.Equal(t, []projectdomain.Issue{ + {Code: "db_migrations_invalid", Path: "/project/devctl.yaml", Field: "components.db.connections.primary.variants.sqlite.migrations"}, + {Code: "db_migrations_invalid", Path: "/project/devctl.yaml", Field: "components.db.connections.analytics.variants.clickhouse.migrations"}, + }, result.Issues) +} diff --git a/internal/service/project/validation_grpc_test.go b/internal/service/project/validation_grpc_test.go new file mode 100644 index 0000000..a36e73a --- /dev/null +++ b/internal/service/project/validation_grpc_test.go @@ -0,0 +1,226 @@ +package project_test + +import ( + "context" + "testing" + + projectdomain "github.com/devctllabs/devctl/internal/domain/project" + "github.com/devctllabs/devctl/internal/service/project" + "github.com/devctllabs/devctl/internal/service/project/mocks" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + "go.uber.org/zap" +) + +func TestServiceLoadProjectRejectsInvalidGRPCClients(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + sources map[string]projectdomain.Source + clients []projectdomain.GRPCClient + field string + }{ + { + name: "invalid name", + sources: map[string]projectdomain.Source{"contracts": {Type: projectdomain.SourceGit, Repo: "example/contracts", Ref: "v1"}}, + clients: []projectdomain.GRPCClient{{Name: "Billing", Source: "contracts", Path: "proto/billing.proto"}}, + field: "components.grpc.clients.Billing", + }, + { + name: "duplicate name", + sources: map[string]projectdomain.Source{"contracts": {Type: projectdomain.SourceGit, Repo: "example/contracts", Ref: "v1"}}, + clients: []projectdomain.GRPCClient{ + {Name: "billing", Source: "contracts", Path: "proto/billing.proto"}, + {Name: "billing", Source: "contracts", Path: "proto/billing-v2.proto"}, + }, + field: "components.grpc.clients.billing", + }, + { + name: "devctl source without export", + sources: map[string]projectdomain.Source{"contracts": {Type: projectdomain.SourceDevctl, Repo: "example/contracts", Ref: "v1"}}, + clients: []projectdomain.GRPCClient{{Name: "billing", Source: "contracts"}}, + field: "components.grpc.clients.billing", + }, + { + name: "devctl source with path", + sources: map[string]projectdomain.Source{"contracts": {Type: projectdomain.SourceDevctl, Repo: "example/contracts", Ref: "v1"}}, + clients: []projectdomain.GRPCClient{{Name: "billing", Source: "contracts", Export: "billing", Path: "proto/billing.proto"}}, + field: "components.grpc.clients.billing", + }, + { + name: "git source without path", + sources: map[string]projectdomain.Source{"contracts": {Type: projectdomain.SourceGit, Repo: "example/contracts", Ref: "v1"}}, + clients: []projectdomain.GRPCClient{{Name: "billing", Source: "contracts"}}, + field: "components.grpc.clients.billing", + }, + { + name: "git source with export", + sources: map[string]projectdomain.Source{"contracts": {Type: projectdomain.SourceGit, Repo: "example/contracts", Ref: "v1"}}, + clients: []projectdomain.GRPCClient{{Name: "billing", Source: "contracts", Export: "billing", Path: "proto/billing.proto"}}, + field: "components.grpc.clients.billing", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + manifests := mocks.NewMockManifestRepository(ctrl) + workspace := mocks.NewMockManifestLocator(ctrl) + selected := projectdomain.Project{ + Root: "/project", ManifestPath: "/project/devctl.yaml", + Manifest: projectdomain.Manifest{ + Version: 1, Project: projectdomain.Identity{Name: "example", Language: "go"}, + Sources: test.sources, + Components: projectdomain.Components{GRPC: &projectdomain.GRPC{Clients: test.clients}}, + Languages: projectdomain.Languages{Go: projectdomain.GoLanguage{Module: "example.test/example"}}, + }, + } + gomock.InOrder( + workspace.EXPECT().WorkingDirectory(gomock.Any()).Return("/project", nil), + manifests.EXPECT().Load(gomock.Any(), "/project/devctl.yaml").Return(projectdomain.LoadManifestResult{Project: selected}, nil), + ) + + _, err := project.New(zap.NewNop(), project.Dependencies{Manifests: manifests, Locator: workspace}).LoadProject(context.Background(), "devctl.yaml") + + var invalidManifest *projectdomain.InvalidManifestError + require.ErrorAs(t, err, &invalidManifest) + require.Equal(t, []projectdomain.Issue{{ + Code: projectdomain.IssueGRPCClientInvalid, Path: "/project/devctl.yaml", Field: test.field, + }}, invalidManifest.Issues) + }) + } +} + +func TestServiceLoadProjectAcceptsValidGRPCClientSelections(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + source projectdomain.Source + client projectdomain.GRPCClient + }{ + { + name: "git source path", + source: projectdomain.Source{Type: projectdomain.SourceGit, Repo: "example/contracts", Ref: "v1"}, + client: projectdomain.GRPCClient{Name: "billing", Source: "contracts", Path: "proto/billing.proto"}, + }, + { + name: "devctl source export", + source: projectdomain.Source{Type: projectdomain.SourceDevctl, Repo: "example/contracts", Ref: "v1"}, + client: projectdomain.GRPCClient{Name: "billing", Source: "contracts", Export: "billing"}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + manifests := mocks.NewMockManifestRepository(ctrl) + workspace := mocks.NewMockManifestLocator(ctrl) + selected := projectdomain.Project{ + Root: "/project", ManifestPath: "/project/devctl.yaml", + Manifest: projectdomain.Manifest{ + Version: 1, Project: projectdomain.Identity{Name: "example", Language: "go"}, + Sources: map[string]projectdomain.Source{"contracts": test.source}, + Components: projectdomain.Components{GRPC: &projectdomain.GRPC{Clients: []projectdomain.GRPCClient{test.client}}}, + Languages: projectdomain.Languages{Go: projectdomain.GoLanguage{Module: "example.test/example"}}, + }, + } + gomock.InOrder( + workspace.EXPECT().WorkingDirectory(gomock.Any()).Return("/project", nil), + manifests.EXPECT().Load(gomock.Any(), "/project/devctl.yaml").Return(projectdomain.LoadManifestResult{Project: selected}, nil), + ) + + loaded, err := project.New(zap.NewNop(), project.Dependencies{Manifests: manifests, Locator: workspace}).LoadProject(context.Background(), "devctl.yaml") + + require.NoError(t, err) + require.Equal(t, selected, loaded) + }) + } +} + +func TestServiceLoadProjectRejectsUnsafeBufPathsAtTheirManifestFields(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + configure func(*projectdomain.Manifest) + field string + }{ + { + name: "traversing server config", + configure: func(manifest *projectdomain.Manifest) { + manifest.Components.GRPC.Server.BufConfig = "../buf.yaml" + }, + field: "components.grpc.server.buf_config", + }, + { + name: "absolute client config", + configure: func(manifest *projectdomain.Manifest) { + manifest.Components.GRPC.Clients[0].BufGenConfig = "/tmp/client.gen.yaml" + }, + field: "components.grpc.clients.billing.buf_gen_config", + }, + { + name: "traversing shared generator config", + configure: func(manifest *projectdomain.Manifest) { + manifest.Languages.Go.Generators.GRPC.BufGenConfig = "../grpc.gen.yaml" + }, + field: "languages.go.generators.grpc.buf_gen_config", + }, + { + name: "absolute source config", + configure: func(manifest *projectdomain.Manifest) { + source := manifest.Sources["contracts"] + source.Proto.BufConfig = "/tmp/buf.yaml" + manifest.Sources["contracts"] = source + }, + field: "sources.contracts.proto.buf_config", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + manifests := mocks.NewMockManifestRepository(ctrl) + workspace := mocks.NewMockManifestLocator(ctrl) + manifest := projectdomain.Manifest{ + Version: 1, Project: projectdomain.Identity{Name: "example", Language: "go"}, + Sources: map[string]projectdomain.Source{ + "contracts": {Type: projectdomain.SourceGit, Repo: "example/contracts", Ref: "v1"}, + }, + Components: projectdomain.Components{GRPC: &projectdomain.GRPC{ + Server: &projectdomain.GRPCServer{BufConfig: "buf.yaml"}, + Clients: []projectdomain.GRPCClient{{ + Name: "billing", Source: "contracts", Path: "proto/billing.proto", + }}, + }}, + Languages: projectdomain.Languages{Go: projectdomain.GoLanguage{ + Module: "example.test/example", Generators: projectdomain.GoGenerators{ + GRPC: &projectdomain.GRPCGenerator{BufGenConfig: "tools/buf/grpc.gen.yaml"}, + }, + }}, + } + test.configure(&manifest) + selected := projectdomain.Project{Root: "/project", ManifestPath: "/project/devctl.yaml", Manifest: manifest} + gomock.InOrder( + workspace.EXPECT().WorkingDirectory(gomock.Any()).Return("/project", nil), + manifests.EXPECT().Load(gomock.Any(), "/project/devctl.yaml").Return(projectdomain.LoadManifestResult{Project: selected}, nil), + ) + + _, err := project.New(zap.NewNop(), project.Dependencies{Manifests: manifests, Locator: workspace}).LoadProject(context.Background(), "devctl.yaml") + + var invalidManifest *projectdomain.InvalidManifestError + require.ErrorAs(t, err, &invalidManifest) + require.Contains(t, invalidManifest.Issues, projectdomain.Issue{ + Code: projectdomain.IssuePathInvalid, Path: "/project/devctl.yaml", Field: test.field, + }) + }) + } +} diff --git a/internal/service/project/validation_http_test.go b/internal/service/project/validation_http_test.go new file mode 100644 index 0000000..f1cb7dc --- /dev/null +++ b/internal/service/project/validation_http_test.go @@ -0,0 +1,43 @@ +package project_test + +import ( + "context" + "testing" + + projectdomain "github.com/devctllabs/devctl/internal/domain/project" + "github.com/devctllabs/devctl/internal/service/project" + "github.com/devctllabs/devctl/internal/service/project/mocks" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + "go.uber.org/zap" +) + +func TestServiceLoadProjectDoesNotResolveDevctlHTTPExportLocally(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + manifests := mocks.NewMockManifestRepository(ctrl) + workspace := mocks.NewMockManifestLocator(ctrl) + selected := projectdomain.Project{ + Root: "/project", ManifestPath: "/project/devctl.yaml", + Manifest: projectdomain.Manifest{ + Version: 1, Project: projectdomain.Identity{Name: "example", Language: "go"}, + Sources: map[string]projectdomain.Source{ + "contracts": {Type: projectdomain.SourceDevctl, Repo: "example/contracts", Ref: "v1"}, + }, + Components: projectdomain.Components{HTTP: &projectdomain.HTTP{Clients: []projectdomain.HTTPClient{{ + Name: "billing", Source: "contracts", Export: "billing", + }}}}, + Languages: projectdomain.Languages{Go: projectdomain.GoLanguage{Module: "example.test/example"}}, + }, + } + gomock.InOrder( + workspace.EXPECT().WorkingDirectory(gomock.Any()).Return("/project", nil), + manifests.EXPECT().Load(gomock.Any(), "/project/devctl.yaml").Return(projectdomain.LoadManifestResult{Project: selected}, nil), + ) + + loaded, err := project.New(zap.NewNop(), project.Dependencies{Manifests: manifests, Locator: workspace}).LoadProject(context.Background(), "devctl.yaml") + + require.NoError(t, err) + require.Equal(t, selected, loaded) +} diff --git a/internal/service/project/validation_kafka_test.go b/internal/service/project/validation_kafka_test.go new file mode 100644 index 0000000..89fc274 --- /dev/null +++ b/internal/service/project/validation_kafka_test.go @@ -0,0 +1,77 @@ +package project_test + +import ( + "context" + "testing" + + projectdomain "github.com/devctllabs/devctl/internal/domain/project" + "github.com/devctllabs/devctl/internal/service/project/mocks" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" +) + +func TestServiceValidateRejectsKafkaContractWithUnknownSource(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + manifests := mocks.NewMockManifestRepository(ctrl) + workspace := mocks.NewMockManifestLocator(ctrl) + selected := projectdomain.Project{ + Root: "/project", ManifestPath: "/project/devctl.yaml", + Manifest: projectdomain.Manifest{ + Version: 1, Project: projectdomain.Identity{Name: "example", Language: "go"}, + Components: projectdomain.Components{Kafka: &projectdomain.Kafka{Consumers: []projectdomain.KafkaConsumer{{ + Name: "billing", Topic: "billing_service.invoice.events.v1", + Contract: projectdomain.KafkaContract{Source: "missing", Path: "invoice.proto", Format: "proto"}, + }}}}, + Languages: projectdomain.Languages{Go: projectdomain.GoLanguage{Module: "example.test/example"}}, + }, + } + gomock.InOrder( + workspace.EXPECT().WorkingDirectory(gomock.Any()).Return("/project", nil), + manifests.EXPECT().Load(gomock.Any(), "/project/devctl.yaml").Return(projectdomain.LoadManifestResult{Project: selected}, nil), + ) + + result, err := newValidationService(ctrl, manifests, workspace, selected).Validate( + context.Background(), projectdomain.ValidateQuery{ManifestPath: "devctl.yaml"}, + ) + + require.NoError(t, err) + require.Equal(t, projectdomain.ValidationResult{Issues: []projectdomain.Issue{{ + Code: projectdomain.IssueSourceNotFound, Path: "/project/devctl.yaml", + Field: "components.kafka.consumers.billing.contract.source", + }}}, result) +} + +func TestServiceValidateRejectsNonRawKafkaContractWithoutSource(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + manifests := mocks.NewMockManifestRepository(ctrl) + workspace := mocks.NewMockManifestLocator(ctrl) + selected := projectdomain.Project{ + Root: "/project", ManifestPath: "/project/devctl.yaml", + Manifest: projectdomain.Manifest{ + Version: 1, Project: projectdomain.Identity{Name: "example", Language: "go"}, + Components: projectdomain.Components{Kafka: &projectdomain.Kafka{Consumers: []projectdomain.KafkaConsumer{{ + Name: "billing", Topic: "billing_service.invoice.events.v1", + Contract: projectdomain.KafkaContract{Path: "invoice.json", Format: "json"}, + }}}}, + Languages: projectdomain.Languages{Go: projectdomain.GoLanguage{Module: "example.test/example"}}, + }, + } + gomock.InOrder( + workspace.EXPECT().WorkingDirectory(gomock.Any()).Return("/project", nil), + manifests.EXPECT().Load(gomock.Any(), "/project/devctl.yaml").Return(projectdomain.LoadManifestResult{Project: selected}, nil), + ) + + result, err := newValidationService(ctrl, manifests, workspace, selected).Validate( + context.Background(), projectdomain.ValidateQuery{ManifestPath: "devctl.yaml"}, + ) + + require.NoError(t, err) + require.Equal(t, projectdomain.ValidationResult{Issues: []projectdomain.Issue{{ + Code: projectdomain.IssueKafkaContractInvalid, Path: "/project/devctl.yaml", + Field: "components.kafka.consumers.billing.contract", + }}}, result) +} diff --git a/internal/service/project/validation_source_export_test.go b/internal/service/project/validation_source_export_test.go new file mode 100644 index 0000000..21bb41a --- /dev/null +++ b/internal/service/project/validation_source_export_test.go @@ -0,0 +1,164 @@ +package project_test + +import ( + "context" + "fmt" + "testing" + + projectdomain "github.com/devctllabs/devctl/internal/domain/project" + "github.com/devctllabs/devctl/internal/service/project" + "github.com/devctllabs/devctl/internal/service/project/mocks" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + "go.uber.org/zap" +) + +func TestServiceLoadProjectRejectsPathOnDevctlSource(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + manifests := mocks.NewMockManifestRepository(ctrl) + workspace := mocks.NewMockManifestLocator(ctrl) + selected := projectdomain.Project{ + Root: "/project", ManifestPath: "/project/devctl.yaml", + Manifest: projectdomain.Manifest{ + Version: 1, Project: projectdomain.Identity{Name: "example", Language: "go"}, + Sources: map[string]projectdomain.Source{ + "contracts": {Type: projectdomain.SourceDevctl, Path: "contracts", Repo: "example/contracts", Ref: "v1"}, + }, + Languages: projectdomain.Languages{Go: projectdomain.GoLanguage{Module: "example.test/example"}}, + }, + } + gomock.InOrder( + workspace.EXPECT().WorkingDirectory(gomock.Any()).Return("/project", nil), + manifests.EXPECT().Load(gomock.Any(), "/project/devctl.yaml").Return(projectdomain.LoadManifestResult{Project: selected}, nil), + ) + + _, err := project.New(zap.NewNop(), project.Dependencies{Manifests: manifests, Locator: workspace}).LoadProject(context.Background(), "devctl.yaml") + + var invalidManifest *projectdomain.InvalidManifestError + require.ErrorAs(t, err, &invalidManifest) + require.Equal(t, []projectdomain.Issue{{ + Code: projectdomain.IssueSourceInvalid, Path: "/project/devctl.yaml", Field: "sources.contracts", + }}, invalidManifest.Issues) +} + +func TestServiceLoadProjectRejectsOpenAPIExportPathMismatch(t *testing.T) { + t.Parallel() + + manifest := validManifest() + manifest.Exports = map[string]projectdomain.Export{ + "public-api": {Kind: "openapi", Path: "api/other.yaml"}, + } + manifest.Components.HTTP = &projectdomain.HTTP{Server: &projectdomain.HTTPServer{OpenAPI: "api/openapi.yaml"}} + + err := loadProjectWithManifest(t, manifest) + + var invalidManifest *projectdomain.InvalidManifestError + require.ErrorAs(t, err, &invalidManifest) + require.Equal(t, []projectdomain.Issue{{ + Code: projectdomain.IssueExportInvalid, Path: "/project/devctl.yaml", Field: "exports.public-api", + }}, invalidManifest.Issues) +} + +func TestServiceLoadProjectValidatesExportsAgainstEffectiveSurfaces(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + exported projectdomain.Export + components projectdomain.Components + valid bool + }{ + { + name: "OpenAPI exact path", exported: projectdomain.Export{Kind: "openapi", Path: "api/openapi.yaml"}, valid: true, + components: projectdomain.Components{HTTP: &projectdomain.HTTP{Server: &projectdomain.HTTPServer{OpenAPI: "api/openapi.yaml"}}}, + }, + { + name: "OpenAPI default path", exported: projectdomain.Export{Kind: "openapi", Path: "api/openapi/swagger.yaml"}, valid: true, + components: projectdomain.Components{HTTP: &projectdomain.HTTP{Server: &projectdomain.HTTPServer{}}}, + }, + {name: "OpenAPI missing server", exported: projectdomain.Export{Kind: "openapi", Path: "api/openapi.yaml"}}, + { + name: "gRPC exact root", exported: projectdomain.Export{Kind: "grpc", Path: "api/proto/grpc"}, valid: true, + components: projectdomain.Components{GRPC: &projectdomain.GRPC{Server: &projectdomain.GRPCServer{ProtoRoot: "api/proto/grpc"}}}, + }, + { + name: "gRPC mismatched root", exported: projectdomain.Export{Kind: "grpc", Path: "api/proto/other"}, + components: projectdomain.Components{GRPC: &projectdomain.GRPC{Server: &projectdomain.GRPCServer{ProtoRoot: "api/proto/grpc"}}}, + }, + {name: "gRPC missing server", exported: projectdomain.Export{Kind: "grpc", Path: "api/proto/grpc"}}, + { + name: "Kafka existing producer", exported: projectdomain.Export{Kind: "kafka", Producer: "audit"}, valid: true, + components: projectdomain.Components{Kafka: &projectdomain.Kafka{Producers: []projectdomain.KafkaProducer{{ + Name: "audit", Topic: "audit.events", Contract: projectdomain.KafkaContract{Format: "raw"}, + }}}}, + }, + {name: "Kafka missing producer", exported: projectdomain.Export{Kind: "kafka", Producer: "audit"}}, + {name: "unknown kind", exported: projectdomain.Export{Kind: "graphql", Path: "api/schema.graphql"}}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + manifest := validManifest() + manifest.Exports = map[string]projectdomain.Export{"public": test.exported} + manifest.Components = test.components + + err := loadProjectWithManifest(t, manifest) + if test.valid { + require.NoError(t, err) + return + } + var invalidManifest *projectdomain.InvalidManifestError + require.ErrorAs(t, err, &invalidManifest) + require.Equal(t, []projectdomain.Issue{{ + Code: projectdomain.IssueExportInvalid, Path: "/project/devctl.yaml", Field: "exports.public", + }}, invalidManifest.Issues) + }) + } +} + +func TestServiceLoadProjectOrdersExportIssuesByName(t *testing.T) { + t.Parallel() + + manifest := validManifest() + manifest.Exports = map[string]projectdomain.Export{ + "zulu": {Kind: "openapi", Path: "api/zulu.yaml"}, + "alpha": {Kind: "grpc", Path: "api/proto/alpha"}, + } + + err := loadProjectWithManifest(t, manifest) + + var invalidManifest *projectdomain.InvalidManifestError + require.ErrorAs(t, err, &invalidManifest) + require.Equal(t, []projectdomain.Issue{ + {Code: projectdomain.IssueExportInvalid, Path: "/project/devctl.yaml", Field: "exports.alpha"}, + {Code: projectdomain.IssueExportInvalid, Path: "/project/devctl.yaml", Field: "exports.zulu"}, + }, invalidManifest.Issues) +} + +func loadProjectWithManifest(t *testing.T, manifest projectdomain.Manifest) error { + t.Helper() + ctrl := gomock.NewController(t) + manifests := mocks.NewMockManifestRepository(ctrl) + workspace := mocks.NewMockManifestLocator(ctrl) + selected := projectdomain.Project{Root: "/project", ManifestPath: "/project/devctl.yaml", Manifest: manifest} + gomock.InOrder( + workspace.EXPECT().WorkingDirectory(gomock.Any()).Return("/project", nil), + manifests.EXPECT().Load(gomock.Any(), "/project/devctl.yaml").Return(projectdomain.LoadManifestResult{Project: selected}, nil), + ) + + _, err := project.New(zap.NewNop(), project.Dependencies{Manifests: manifests, Locator: workspace}).LoadProject(context.Background(), "devctl.yaml") + if err != nil { + return fmt.Errorf("service.LoadProject: %w", err) + } + return nil +} + +func validManifest() projectdomain.Manifest { + return projectdomain.Manifest{ + Version: 1, + Project: projectdomain.Identity{Name: "example", Language: "go"}, + Languages: projectdomain.Languages{Go: projectdomain.GoLanguage{Module: "example.test/example"}}, + } +} diff --git a/internal/service/project/validation_storage_test.go b/internal/service/project/validation_storage_test.go new file mode 100644 index 0000000..e83c46e --- /dev/null +++ b/internal/service/project/validation_storage_test.go @@ -0,0 +1,77 @@ +package project_test + +import ( + "context" + "testing" + + projectdomain "github.com/devctllabs/devctl/internal/domain/project" + "github.com/devctllabs/devctl/internal/service/project/mocks" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" +) + +func TestServiceValidateRejectsS3BucketWithUnknownConnection(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + manifests := mocks.NewMockManifestRepository(ctrl) + workspace := mocks.NewMockManifestLocator(ctrl) + selected := projectdomain.Project{ + Root: "/project", ManifestPath: "/project/devctl.yaml", + Manifest: projectdomain.Manifest{ + Version: 1, Project: projectdomain.Identity{Name: "example", Language: "go"}, + Components: projectdomain.Components{S3: &projectdomain.S3{ + Connections: []projectdomain.S3Connection{{Name: "default", Credentials: "static"}}, + Buckets: []projectdomain.S3Bucket{{Name: "media", Connection: "archive", Bucket: "media-local"}}, + }}, + Languages: projectdomain.Languages{Go: projectdomain.GoLanguage{Module: "example.test/example"}}, + }, + } + gomock.InOrder( + workspace.EXPECT().WorkingDirectory(gomock.Any()).Return("/project", nil), + manifests.EXPECT().Load(gomock.Any(), "/project/devctl.yaml").Return(projectdomain.LoadManifestResult{Project: selected}, nil), + ) + + result, err := newValidationService(ctrl, manifests, workspace, selected).Validate( + context.Background(), projectdomain.ValidateQuery{ManifestPath: "devctl.yaml"}, + ) + + require.NoError(t, err) + require.Equal(t, projectdomain.ValidationResult{Issues: []projectdomain.Issue{{ + Code: "s3_connection_not_found", Path: "/project/devctl.yaml", + Field: "components.s3.buckets.media.connection", + }}}, result) +} + +func TestServiceValidateRejectsInvalidRedisAddresses(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + manifests := mocks.NewMockManifestRepository(ctrl) + workspace := mocks.NewMockManifestLocator(ctrl) + selected := projectdomain.Project{ + Root: "/project", ManifestPath: "/project/devctl.yaml", + Manifest: projectdomain.Manifest{ + Version: 1, Project: projectdomain.Identity{Name: "example", Language: "go"}, + Components: projectdomain.Components{Redis: &projectdomain.Redis{Connections: []projectdomain.RedisConnection{ + {Name: "cache", AddrEnv: "REDIS_CACHE_ADDR", AddrDefault: "localhost"}, + {Name: "sessions", AddrEnv: "REDIS_SESSIONS_ADDR", AddrDefault: "redis://user:secret@localhost:6379/0"}, + }}}, + Languages: projectdomain.Languages{Go: projectdomain.GoLanguage{Module: "example.test/example"}}, + }, + } + gomock.InOrder( + workspace.EXPECT().WorkingDirectory(gomock.Any()).Return("/project", nil), + manifests.EXPECT().Load(gomock.Any(), "/project/devctl.yaml").Return(projectdomain.LoadManifestResult{Project: selected}, nil), + ) + + result, err := newValidationService(ctrl, manifests, workspace, selected).Validate( + context.Background(), projectdomain.ValidateQuery{ManifestPath: "devctl.yaml"}, + ) + + require.NoError(t, err) + require.Equal(t, []projectdomain.Issue{ + {Code: "redis_address_invalid", Path: "/project/devctl.yaml", Field: "components.redis.connections.cache.addr_default"}, + {Code: "redis_address_invalid", Path: "/project/devctl.yaml", Field: "components.redis.connections.sessions.addr_default"}, + }, result.Issues) +} diff --git a/internal/service/project/validation_test_helpers_test.go b/internal/service/project/validation_test_helpers_test.go new file mode 100644 index 0000000..7d57715 --- /dev/null +++ b/internal/service/project/validation_test_helpers_test.go @@ -0,0 +1,24 @@ +package project_test + +import ( + projectdomain "github.com/devctllabs/devctl/internal/domain/project" + projectservice "github.com/devctllabs/devctl/internal/service/project" + "github.com/devctllabs/devctl/internal/service/project/mocks" + "go.uber.org/mock/gomock" + "go.uber.org/zap" +) + +func newValidationService( + ctrl *gomock.Controller, + manifests projectservice.ManifestRepository, + workspace projectservice.ManifestLocator, + selected projectdomain.Project, +) *projectservice.Service { + readiness := mocks.NewMockReadinessChecker(ctrl) + readiness.EXPECT().Check(gomock.Any(), selected).Return(nil, nil) + return projectservice.New(zap.NewNop(), projectservice.Dependencies{ + Manifests: manifests, + Locator: workspace, + Readiness: readiness, + }) +} diff --git a/internal/service/projectreadiness/checker.go b/internal/service/projectreadiness/checker.go new file mode 100644 index 0000000..a1586b6 --- /dev/null +++ b/internal/service/projectreadiness/checker.go @@ -0,0 +1,396 @@ +package projectreadiness + +import ( + "context" + "fmt" + "net/url" + "path" + "regexp" + "sort" + "strings" + + "github.com/BurntSushi/toml" + "github.com/devctllabs/devctl/internal/domain/project" + "golang.org/x/mod/modfile" +) + +var environmentKey = regexp.MustCompile(`^[A-Z][A-Z0-9_]*$`) + +const ( + oapiCodegenToolPath = "github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen" + bufToolPath = "github.com/bufbuild/buf/cmd/buf" +) + +//go:generate go tool mockgen -destination mocks/checker.go -package mocks -typed . Workspace + +// Workspace exposes only filesystem facts used by Project Readiness policy. +type Workspace interface { + // RegularFile reports whether relativePath is a regular non-symlink file below root. + RegularFile(ctx context.Context, root, relativePath string) (bool, error) + // Directory reports whether relativePath is a real directory below root. + Directory(ctx context.Context, root, relativePath string) (bool, error) + // ReadBytes reads the regular project file at relativePath below root. + ReadBytes(ctx context.Context, root, relativePath string) ([]byte, error) +} + +// Checker evaluates filesystem and tool readiness for a semantically inspected Project. +type Checker struct { + workspace Workspace +} + +func New(workspace Workspace) *Checker { + return &Checker{workspace: workspace} +} + +// Check returns every applicable readiness issue in stable order. +func (c *Checker) Check(ctx context.Context, selected project.Project) ([]project.Issue, error) { + check := readiness{workspace: c.workspace, project: selected} + goModExists, err := check.requireRegular(ctx, "go.mod", project.IssueGoModMissing) + if err != nil { + return nil, err + } + if err := check.sources(ctx); err != nil { + return check.issues, err + } + if err := check.components(ctx, goModExists); err != nil { + return check.issues, err + } + if err := check.migrations(ctx); err != nil { + return check.issues, err + } + return check.issues, nil +} + +func (r *readiness) components(ctx context.Context, goModExists bool) error { + if r.project.Manifest.Components.HTTP != nil { + if err := r.http(ctx, goModExists); err != nil { + return err + } + } + if r.project.Manifest.Components.GRPC != nil { + if err := r.grpc(ctx, goModExists); err != nil { + return err + } + } + if r.project.Manifest.Components.Kafka != nil { + if err := r.kafka(ctx, goModExists); err != nil { + return err + } + } + return nil +} + +func (r *readiness) kafka(ctx context.Context, goModExists bool) error { + manifest := r.project.Manifest + if hasReadyKafkaContractFormat(manifest, "proto") { + configs := make(map[string]struct{}) + for _, target := range project.NewTargetCatalog(manifest).Select(project.TargetOperationGenerate, "kafka", "") { + if target.Format == "proto" { + configs[target.Config] = struct{}{} + } + } + for _, config := range sortedKeys(configs) { + if _, err := r.requireRegular(ctx, config, project.IssueToolConfigMissing); err != nil { + return err + } + } + if goModExists { + if err := r.goTool(ctx, bufToolPath); err != nil { + return err + } + } + } + if hasReadyKafkaContractFormat(manifest, "json") { + return r.miseTools(ctx, "node", "npm:quicktype") + } + return nil +} + +func (r *readiness) miseTools(ctx context.Context, tools ...string) error { + exists, err := r.requireRegular(ctx, ".mise.toml", project.IssueToolConfigMissing) + if err != nil || !exists { + return err + } + content, err := r.workspace.ReadBytes(ctx, r.project.Root, ".mise.toml") + if err != nil { + return fmt.Errorf("workspace.ReadBytes: %w", operationError(project.OperationReadFile, ".mise.toml", err)) + } + configured, valid := decodeMiseTools(content) + if !valid { + r.add(project.IssueToolConfigInvalid, ".mise.toml") + return nil + } + for _, tool := range tools { + if _, exists := configured[tool]; !exists { + r.issues = append(r.issues, project.Issue{ + Code: project.IssueToolMissing, Path: r.project.ManifestPath, Field: ".mise.toml", + Parameters: &project.Parameters{Value: tool}, + }) + } + } + return nil +} + +func decodeMiseTools(content []byte) (map[string]toml.Primitive, bool) { + var config struct { + Tools map[string]toml.Primitive `toml:"tools"` + } + _, err := toml.Decode(string(content), &config) + return config.Tools, err == nil +} + +func hasReadyKafkaContractFormat(manifest project.Manifest, format string) bool { + kafka := manifest.Components.Kafka + if kafka == nil { + return false + } + for _, consumer := range kafka.Consumers { + if kafkaContractReady(manifest, consumer.Contract, format) { + return true + } + } + for _, producer := range kafka.Producers { + if kafkaContractReady(manifest, producer.Contract, format) { + return true + } + } + return false +} + +func kafkaContractReady(manifest project.Manifest, selected project.KafkaContract, format string) bool { + if selected.Format != format || selected.Source == "" { + return false + } + _, exists := manifest.Sources[selected.Source] + return exists +} + +func (r *readiness) grpc(ctx context.Context, goModExists bool) error { + manifest := r.project.Manifest + grpc := manifest.Components.GRPC + if grpc.Server == nil && !hasReadyGRPCClient(manifest, grpc.Clients) { + return nil + } + if grpc.Server != nil { + config := grpc.Server.BufConfig + if config == "" { + config = "buf.yaml" + } + if _, err := r.requireRegular(ctx, config, project.IssueToolConfigMissing); err != nil { + return err + } + } + configs := make(map[string]struct{}) + for _, target := range project.NewTargetCatalog(manifest).Select(project.TargetOperationGenerate, "grpc", "") { + if safeRelative(target.Config) { + configs[target.Config] = struct{}{} + } + } + for _, config := range sortedKeys(configs) { + if _, err := r.requireRegular(ctx, config, project.IssueToolConfigMissing); err != nil { + return err + } + } + if goModExists { + return r.goTool(ctx, bufToolPath) + } + return nil +} + +func hasReadyGRPCClient(manifest project.Manifest, clients []project.GRPCClient) bool { + for _, client := range clients { + if _, exists := manifest.Sources[client.Source]; exists { + return true + } + } + return false +} + +func (r *readiness) http(ctx context.Context, goModExists bool) error { + manifest := r.project.Manifest + targets := project.NewTargetCatalog(manifest).Select(project.TargetOperationGenerate, "http", "") + for _, target := range targets { + if target.Role == "server" && safeRelative(target.Reference.Entrypoint) { + if _, err := r.requireRegular(ctx, target.Reference.Entrypoint, project.IssueOpenAPIMissing); err != nil { + return err + } + } + } + if manifest.Languages.Go.Generators.HTTP == nil { + r.add(project.IssueHTTPGeneratorMissing, "languages.go.generators.http") + } else { + configs := make(map[string]struct{}, len(targets)) + for _, target := range targets { + configs[target.Config] = struct{}{} + } + for _, config := range sortedKeys(configs) { + if _, err := r.requireRegular(ctx, config, project.IssueToolConfigMissing); err != nil { + return err + } + } + } + if goModExists { + return r.goTool(ctx, oapiCodegenToolPath) + } + return nil +} + +func (r *readiness) goTool(ctx context.Context, toolPath string) error { + if _, checked := r.tools[toolPath]; checked { + return nil + } + if r.tools == nil { + r.tools = make(map[string]struct{}) + } + r.tools[toolPath] = struct{}{} + content, err := r.workspace.ReadBytes(ctx, r.project.Root, "go.mod") + if err != nil { + return fmt.Errorf("workspace.ReadBytes: %w", operationError(project.OperationReadFile, "go.mod", err)) + } + parsed, valid := parseGoMod(content) + if !valid { + r.add(project.IssueGoModInvalid, "go.mod") + return nil + } + for _, tool := range parsed.Tool { + if tool.Path == toolPath { + return nil + } + } + r.add(project.IssueToolMissing, "go.mod") + return nil +} + +func parseGoMod(content []byte) (*modfile.File, bool) { + parsed, err := modfile.Parse("go.mod", content, nil) + return parsed, err == nil +} + +type readiness struct { + workspace Workspace + project project.Project + issues []project.Issue + tools map[string]struct{} +} + +func (r *readiness) sources(ctx context.Context) error { + for _, name := range sortedKeys(r.project.Manifest.Sources) { + source := r.project.Manifest.Sources[name] + if source.Type != project.SourceLocal || !safeRelative(source.Path) { + continue + } + exists, err := r.directory(ctx, source.Path) + if err != nil { + return err + } + if !exists { + r.add(project.IssueSourceMissing, source.Path) + continue + } + if source.Proto.BufConfig != "" && safeRelative(source.Proto.BufConfig) { + if _, err := r.requireRegular(ctx, path.Join(source.Path, source.Proto.BufConfig), project.IssueToolConfigMissing); err != nil { + return err + } + } + } + return nil +} + +func (r *readiness) migrations(ctx context.Context) error { + if r.project.Manifest.Components.DB == nil { + return nil + } + var paths []string + for _, connection := range r.project.Manifest.Components.DB.Connections { + for _, variant := range connection.Variants { + if migrationsReady(variant.Kind, variant.Migrations) { + paths = append(paths, variant.Migrations.Path) + } + } + } + sort.Strings(paths) + for _, migrationPath := range paths { + exists, err := r.directory(ctx, migrationPath) + if err != nil { + return err + } + if !exists { + r.add(project.IssueMigrationPathMissing, migrationPath) + } + } + return nil +} + +func (r *readiness) directory(ctx context.Context, relativePath string) (bool, error) { + exists, err := r.workspace.Directory(ctx, r.project.Root, relativePath) + if err != nil { + return false, fmt.Errorf("workspace.Directory: %w", operationError(project.OperationInspectFile, relativePath, err)) + } + return exists, nil +} + +func migrationsReady(kind string, migrations *project.DBMigrations) bool { + if migrations == nil { + return false + } + validKind := kind == "sqlite" || kind == "postgres" || kind == "clickhouse" + if !validKind || !safeRelative(migrations.Path) || !environmentKey.MatchString(migrations.DatabaseEnv) { + return false + } + if migrations.DatabaseDefault == "" { + return true + } + parsed, err := url.Parse(migrations.DatabaseDefault) + if err != nil { + return false + } + switch kind { + case "sqlite": + return parsed.Scheme == "sqlite" + case "clickhouse": + return parsed.Scheme == "clickhouse" + default: + return parsed.Scheme == "postgres" || parsed.Scheme == "postgresql" + } +} + +func (r *readiness) requireRegular(ctx context.Context, relativePath string, code project.IssueCode) (bool, error) { + exists, err := r.workspace.RegularFile(ctx, r.project.Root, relativePath) + if err != nil { + return false, fmt.Errorf("workspace.RegularFile: %w", operationError(project.OperationInspectFile, relativePath, err)) + } + if !exists { + r.add(code, relativePath) + } + return exists, nil +} + +func operationError(operation project.Operation, selectedPath string, cause error) error { + return &project.OperationError{ + Operation: operation, + Path: selectedPath, + Kind: project.FailureUnavailable, + Cause: cause, + } +} + +func (r *readiness) add(code project.IssueCode, field string) { + r.issues = append(r.issues, project.Issue{Code: code, Path: r.project.ManifestPath, Field: field}) +} + +func safeRelative(name string) bool { + if name == "" || strings.HasPrefix(name, "/") { + return false + } + clean := path.Clean(strings.ReplaceAll(name, "\\", "/")) + return clean != "." && clean != ".." && !strings.HasPrefix(clean, "../") +} + +func sortedKeys[Value any](values map[string]Value) []string { + keys := make([]string, 0, len(values)) + for key := range values { + keys = append(keys, key) + } + sort.Strings(keys) + return keys +} diff --git a/internal/service/projectreadiness/checker_test.go b/internal/service/projectreadiness/checker_test.go new file mode 100644 index 0000000..53833c3 --- /dev/null +++ b/internal/service/projectreadiness/checker_test.go @@ -0,0 +1,617 @@ +package projectreadiness_test + +import ( + "context" + "errors" + "testing" + + "github.com/devctllabs/devctl/internal/domain/project" + "github.com/devctllabs/devctl/internal/service/projectreadiness" + "github.com/devctllabs/devctl/internal/service/projectreadiness/mocks" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" +) + +func TestCheckerReportsMissingGoModule(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + workspace := mocks.NewMockWorkspace(ctrl) + selected := project.Project{Root: "/project", ManifestPath: "/project/devctl.yaml"} + workspace.EXPECT().RegularFile(gomock.Any(), selected.Root, "go.mod").Return(false, nil) + + issues, err := projectreadiness.New(workspace).Check(context.Background(), selected) + + require.NoError(t, err) + require.Equal(t, []project.Issue{{ + Code: project.IssueGoModMissing, Path: selected.ManifestPath, Field: "go.mod", + }}, issues) +} + +func TestCheckerPreservesGoModuleInspectionError(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + workspace := mocks.NewMockWorkspace(ctrl) + selected := project.Project{Root: "/project", ManifestPath: "/project/devctl.yaml"} + cause := errors.New("permission denied") + workspace.EXPECT().RegularFile(gomock.Any(), selected.Root, "go.mod").Return(false, cause) + + issues, err := projectreadiness.New(workspace).Check(context.Background(), selected) + + require.Empty(t, issues) + require.ErrorIs(t, err, cause) + var operationErr *project.OperationError + require.ErrorAs(t, err, &operationErr) + require.Equal(t, project.OperationInspectFile, operationErr.Operation) + require.Equal(t, "go.mod", operationErr.Path) + require.Equal(t, project.FailureUnavailable, operationErr.Kind) +} + +func TestCheckerChecksLocalSourcesInNameOrder(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + workspace := mocks.NewMockWorkspace(ctrl) + selected := project.Project{ + Root: "/project", ManifestPath: "/project/devctl.yaml", + Manifest: project.Manifest{Sources: map[string]project.Source{ + "bravo": {Type: project.SourceLocal, Path: "contracts/bravo"}, + "alpha": { + Type: project.SourceLocal, Path: "contracts/alpha", + Proto: project.SourceProto{BufConfig: "buf.yaml"}, + }, + }}, + } + gomock.InOrder( + workspace.EXPECT().RegularFile(gomock.Any(), selected.Root, "go.mod").Return(true, nil), + workspace.EXPECT().Directory(gomock.Any(), selected.Root, "contracts/alpha").Return(true, nil), + workspace.EXPECT().RegularFile(gomock.Any(), selected.Root, "contracts/alpha/buf.yaml").Return(false, nil), + workspace.EXPECT().Directory(gomock.Any(), selected.Root, "contracts/bravo").Return(false, nil), + ) + + issues, err := projectreadiness.New(workspace).Check(context.Background(), selected) + + require.NoError(t, err) + require.Equal(t, []project.Issue{ + {Code: project.IssueToolConfigMissing, Path: selected.ManifestPath, Field: "contracts/alpha/buf.yaml"}, + {Code: project.IssueSourceMissing, Path: selected.ManifestPath, Field: "contracts/bravo"}, + }, issues) +} + +func TestCheckerPreservesLocalSourceInspectionError(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + workspace := mocks.NewMockWorkspace(ctrl) + selected := project.Project{ + Root: "/project", ManifestPath: "/project/devctl.yaml", + Manifest: project.Manifest{Sources: map[string]project.Source{ + "contracts": {Type: project.SourceLocal, Path: "api/contracts"}, + }}, + } + cause := errors.New("stat failed") + workspace.EXPECT().RegularFile(gomock.Any(), selected.Root, "go.mod").Return(true, nil) + workspace.EXPECT().Directory(gomock.Any(), selected.Root, "api/contracts").Return(false, cause) + + issues, err := projectreadiness.New(workspace).Check(context.Background(), selected) + + require.Empty(t, issues) + require.ErrorIs(t, err, cause) + var operationErr *project.OperationError + require.ErrorAs(t, err, &operationErr) + require.Equal(t, project.OperationInspectFile, operationErr.Operation) + require.Equal(t, "api/contracts", operationErr.Path) + require.Equal(t, project.FailureUnavailable, operationErr.Kind) +} + +func TestCheckerChecksValidMigrationDirectoriesInPathOrder(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + workspace := mocks.NewMockWorkspace(ctrl) + selected := project.Project{ + Root: "/project", ManifestPath: "/project/devctl.yaml", + Manifest: project.Manifest{Components: project.Components{DB: &project.DB{ + Connections: []project.DBConnection{{Variants: []project.DBVariant{ + {Kind: "postgres", Migrations: &project.DBMigrations{Path: "migrations/zeta", DatabaseEnv: "DATABASE_URL"}}, + {Kind: "sqlite", Migrations: &project.DBMigrations{Path: "../unsafe", DatabaseEnv: "DATABASE_URL"}}, + {Kind: "clickhouse", Migrations: &project.DBMigrations{Path: "migrations/alpha", DatabaseEnv: "DATABASE_URL"}}, + }}}, + }}}, + } + gomock.InOrder( + workspace.EXPECT().RegularFile(gomock.Any(), selected.Root, "go.mod").Return(true, nil), + workspace.EXPECT().Directory(gomock.Any(), selected.Root, "migrations/alpha").Return(false, nil), + workspace.EXPECT().Directory(gomock.Any(), selected.Root, "migrations/zeta").Return(false, nil), + ) + + issues, err := projectreadiness.New(workspace).Check(context.Background(), selected) + + require.NoError(t, err) + require.Equal(t, []project.Issue{ + {Code: project.IssueMigrationPathMissing, Path: selected.ManifestPath, Field: "migrations/alpha"}, + {Code: project.IssueMigrationPathMissing, Path: selected.ManifestPath, Field: "migrations/zeta"}, + }, issues) +} + +func TestCheckerChecksMigrationDirectoriesWithMatchingDatabaseDefaults(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + workspace := mocks.NewMockWorkspace(ctrl) + selected := project.Project{ + Root: "/project", ManifestPath: "/project/devctl.yaml", + Manifest: project.Manifest{Components: project.Components{DB: &project.DB{ + Connections: []project.DBConnection{{Variants: []project.DBVariant{ + {Kind: "sqlite", Migrations: &project.DBMigrations{Path: "migrations/sqlite", DatabaseEnv: "SQLITE_URL", DatabaseDefault: "sqlite://data/app.db"}}, + {Kind: "postgres", Migrations: &project.DBMigrations{Path: "migrations/postgres", DatabaseEnv: "POSTGRES_URL", DatabaseDefault: "postgresql://localhost/app"}}, + {Kind: "clickhouse", Migrations: &project.DBMigrations{Path: "migrations/clickhouse", DatabaseEnv: "CLICKHOUSE_URL", DatabaseDefault: "clickhouse://localhost/app"}}, + {Kind: "postgres", Migrations: &project.DBMigrations{Path: "migrations/mismatch", DatabaseEnv: "MISMATCH_URL", DatabaseDefault: "sqlite://data/app.db"}}, + }}}, + }}}, + } + gomock.InOrder( + workspace.EXPECT().RegularFile(gomock.Any(), selected.Root, "go.mod").Return(true, nil), + workspace.EXPECT().Directory(gomock.Any(), selected.Root, "migrations/clickhouse").Return(true, nil), + workspace.EXPECT().Directory(gomock.Any(), selected.Root, "migrations/postgres").Return(true, nil), + workspace.EXPECT().Directory(gomock.Any(), selected.Root, "migrations/sqlite").Return(true, nil), + ) + + issues, err := projectreadiness.New(workspace).Check(context.Background(), selected) + + require.NoError(t, err) + require.Empty(t, issues) +} + +func TestCheckerPreservesMigrationDirectoryInspectionError(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + workspace := mocks.NewMockWorkspace(ctrl) + selected := project.Project{ + Root: "/project", ManifestPath: "/project/devctl.yaml", + Manifest: project.Manifest{Components: project.Components{DB: &project.DB{ + Connections: []project.DBConnection{{Variants: []project.DBVariant{{ + Kind: "postgres", Migrations: &project.DBMigrations{Path: "migrations/app", DatabaseEnv: "DATABASE_URL"}, + }}}}, + }}}, + } + cause := errors.New("migration stat failed") + workspace.EXPECT().RegularFile(gomock.Any(), selected.Root, "go.mod").Return(true, nil) + workspace.EXPECT().Directory(gomock.Any(), selected.Root, "migrations/app").Return(false, cause) + + issues, err := projectreadiness.New(workspace).Check(context.Background(), selected) + + require.Empty(t, issues) + require.ErrorIs(t, err, cause) + var operationErr *project.OperationError + require.ErrorAs(t, err, &operationErr) + require.Equal(t, project.OperationInspectFile, operationErr.Operation) + require.Equal(t, "migrations/app", operationErr.Path) +} + +func TestCheckerReportsHTTPFilesAndGeneratorAfterGoModule(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + workspace := mocks.NewMockWorkspace(ctrl) + selected := project.Project{ + Root: "/project", ManifestPath: "/project/devctl.yaml", + Manifest: project.Manifest{Components: project.Components{HTTP: &project.HTTP{ + Server: &project.HTTPServer{}, + }}}, + } + gomock.InOrder( + workspace.EXPECT().RegularFile(gomock.Any(), selected.Root, "go.mod").Return(false, nil), + workspace.EXPECT().RegularFile(gomock.Any(), selected.Root, "api/openapi/swagger.yaml").Return(false, nil), + ) + + issues, err := projectreadiness.New(workspace).Check(context.Background(), selected) + + require.NoError(t, err) + require.Equal(t, []project.Issue{ + {Code: project.IssueGoModMissing, Path: selected.ManifestPath, Field: "go.mod"}, + {Code: project.IssueOpenAPIMissing, Path: selected.ManifestPath, Field: "api/openapi/swagger.yaml"}, + {Code: project.IssueHTTPGeneratorMissing, Path: selected.ManifestPath, Field: "languages.go.generators.http"}, + }, issues) +} + +func TestCheckerChecksHTTPConfigsAndOAPICodegenTool(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + workspace := mocks.NewMockWorkspace(ctrl) + selected := project.Project{ + Root: "/project", ManifestPath: "/project/devctl.yaml", + Manifest: project.Manifest{ + Sources: map[string]project.Source{ + "contracts": {Type: project.SourceGit, Repo: "example/contracts", Ref: "v1"}, + }, + Components: project.Components{HTTP: &project.HTTP{ + Server: &project.HTTPServer{OpenAPI: "api/service.yaml"}, + Clients: []project.HTTPClient{ + {Name: "zeta", Source: "contracts", Path: "zeta.yaml", OAPIConfig: "tools/oapi/zeta.yaml"}, + {Name: "alpha", Source: "contracts", Path: "alpha.yaml", OAPIConfig: "tools/oapi/alpha.yaml"}, + }, + }}, + Languages: project.Languages{Go: project.GoLanguage{Generators: project.GoGenerators{ + HTTP: &project.HTTPGenerator{OAPIConfig: "tools/oapi/server.yaml"}, + }}}, + }, + } + gomock.InOrder( + workspace.EXPECT().RegularFile(gomock.Any(), selected.Root, "go.mod").Return(true, nil), + workspace.EXPECT().RegularFile(gomock.Any(), selected.Root, "api/service.yaml").Return(true, nil), + workspace.EXPECT().RegularFile(gomock.Any(), selected.Root, "tools/oapi/alpha.yaml").Return(false, nil), + workspace.EXPECT().RegularFile(gomock.Any(), selected.Root, "tools/oapi/server.yaml").Return(true, nil), + workspace.EXPECT().RegularFile(gomock.Any(), selected.Root, "tools/oapi/zeta.yaml").Return(false, nil), + workspace.EXPECT().ReadBytes(gomock.Any(), selected.Root, "go.mod").Return([]byte("module example.test/example\n"), nil), + ) + + issues, err := projectreadiness.New(workspace).Check(context.Background(), selected) + + require.NoError(t, err) + require.Equal(t, []project.Issue{ + {Code: project.IssueToolConfigMissing, Path: selected.ManifestPath, Field: "tools/oapi/alpha.yaml"}, + {Code: project.IssueToolConfigMissing, Path: selected.ManifestPath, Field: "tools/oapi/zeta.yaml"}, + {Code: project.IssueToolMissing, Path: selected.ManifestPath, Field: "go.mod"}, + }, issues) +} + +func TestCheckerReportsInvalidGoModuleWhenHTTPNeedsTool(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + workspace := mocks.NewMockWorkspace(ctrl) + selected := project.Project{ + Root: "/project", ManifestPath: "/project/devctl.yaml", + Manifest: project.Manifest{ + Components: project.Components{HTTP: &project.HTTP{}}, + Languages: project.Languages{Go: project.GoLanguage{Generators: project.GoGenerators{ + HTTP: &project.HTTPGenerator{}, + }}}, + }, + } + workspace.EXPECT().RegularFile(gomock.Any(), selected.Root, "go.mod").Return(true, nil) + workspace.EXPECT().ReadBytes(gomock.Any(), selected.Root, "go.mod").Return([]byte("module [invalid"), nil) + + issues, err := projectreadiness.New(workspace).Check(context.Background(), selected) + + require.NoError(t, err) + require.Equal(t, []project.Issue{{ + Code: project.IssueGoModInvalid, Path: selected.ManifestPath, Field: "go.mod", + }}, issues) +} + +func TestCheckerPreservesGoModuleReadErrorWhenHTTPNeedsTool(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + workspace := mocks.NewMockWorkspace(ctrl) + selected := project.Project{ + Root: "/project", ManifestPath: "/project/devctl.yaml", + Manifest: project.Manifest{ + Components: project.Components{HTTP: &project.HTTP{}}, + Languages: project.Languages{Go: project.GoLanguage{Generators: project.GoGenerators{ + HTTP: &project.HTTPGenerator{}, + }}}, + }, + } + cause := errors.New("read failed") + workspace.EXPECT().RegularFile(gomock.Any(), selected.Root, "go.mod").Return(true, nil) + workspace.EXPECT().ReadBytes(gomock.Any(), selected.Root, "go.mod").Return(nil, cause) + + issues, err := projectreadiness.New(workspace).Check(context.Background(), selected) + + require.Empty(t, issues) + require.ErrorIs(t, err, cause) + var operationErr *project.OperationError + require.ErrorAs(t, err, &operationErr) + require.Equal(t, project.OperationReadFile, operationErr.Operation) + require.Equal(t, "go.mod", operationErr.Path) +} + +func TestCheckerChecksGRPCModuleAndGeneratorConfigsWithoutGoModule(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + workspace := mocks.NewMockWorkspace(ctrl) + selected := project.Project{ + Root: "/project", ManifestPath: "/project/devctl.yaml", + Manifest: project.Manifest{ + Components: project.Components{GRPC: &project.GRPC{ + Server: &project.GRPCServer{BufConfig: "buf.yaml"}, + }}, + Languages: project.Languages{Go: project.GoLanguage{Generators: project.GoGenerators{ + GRPC: &project.GRPCGenerator{BufGenConfig: "tools/buf/grpc.gen.yaml"}, + }}}, + }, + } + gomock.InOrder( + workspace.EXPECT().RegularFile(gomock.Any(), selected.Root, "go.mod").Return(false, nil), + workspace.EXPECT().RegularFile(gomock.Any(), selected.Root, "buf.yaml").Return(true, nil), + workspace.EXPECT().RegularFile(gomock.Any(), selected.Root, "tools/buf/grpc.gen.yaml").Return(false, nil), + ) + + issues, err := projectreadiness.New(workspace).Check(context.Background(), selected) + + require.NoError(t, err) + require.Equal(t, []project.Issue{ + {Code: project.IssueGoModMissing, Path: selected.ManifestPath, Field: "go.mod"}, + {Code: project.IssueToolConfigMissing, Path: selected.ManifestPath, Field: "tools/buf/grpc.gen.yaml"}, + }, issues) +} + +func TestCheckerChecksGRPCClientOnlyGeneratorAndBufTool(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + workspace := mocks.NewMockWorkspace(ctrl) + selected := project.Project{ + Root: "/project", ManifestPath: "/project/devctl.yaml", + Manifest: project.Manifest{ + Sources: map[string]project.Source{ + "contracts": {Type: project.SourceGit, Repo: "example/contracts", Ref: "v1"}, + }, + Components: project.Components{GRPC: &project.GRPC{Clients: []project.GRPCClient{{ + Name: "billing", Source: "contracts", Path: "proto/billing.proto", ProtoRoot: "proto", + }}}}, + Languages: project.Languages{Go: project.GoLanguage{Generators: project.GoGenerators{ + GRPC: &project.GRPCGenerator{BufGenConfig: "tools/buf/clients.gen.yaml"}, + }}}, + }, + } + gomock.InOrder( + workspace.EXPECT().RegularFile(gomock.Any(), selected.Root, "go.mod").Return(true, nil), + workspace.EXPECT().RegularFile(gomock.Any(), selected.Root, "tools/buf/clients.gen.yaml").Return(true, nil), + workspace.EXPECT().ReadBytes(gomock.Any(), selected.Root, "go.mod").Return([]byte( + "module example.test/example\n\ntool github.com/bufbuild/buf/cmd/buf\n", + ), nil), + ) + + issues, err := projectreadiness.New(workspace).Check(context.Background(), selected) + + require.NoError(t, err) + require.Empty(t, issues) +} + +func TestCheckerChecksEveryDistinctGRPCConfigInSortedOrder(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + workspace := mocks.NewMockWorkspace(ctrl) + selected := project.Project{ + Root: "/project", ManifestPath: "/project/devctl.yaml", + Manifest: project.Manifest{ + Sources: map[string]project.Source{ + "contracts": {Type: project.SourceGit, Repo: "example/contracts", Ref: "v1"}, + }, + Components: project.Components{GRPC: &project.GRPC{Clients: []project.GRPCClient{ + {Name: "zeta", Source: "contracts", Path: "proto/zeta.proto", BufGenConfig: "tools/buf/zeta.gen.yaml"}, + {Name: "alpha", Source: "contracts", Path: "proto/alpha.proto", BufGenConfig: "tools/buf/alpha.gen.yaml"}, + {Name: "alpha-v2", Source: "contracts", Path: "proto/alpha-v2.proto", BufGenConfig: "tools/buf/alpha.gen.yaml"}, + }}}, + Languages: project.Languages{Go: project.GoLanguage{Generators: project.GoGenerators{ + GRPC: &project.GRPCGenerator{BufGenConfig: "tools/buf/default.gen.yaml"}, + }}}, + }, + } + gomock.InOrder( + workspace.EXPECT().RegularFile(gomock.Any(), selected.Root, "go.mod").Return(false, nil), + workspace.EXPECT().RegularFile(gomock.Any(), selected.Root, "tools/buf/alpha.gen.yaml").Return(true, nil), + workspace.EXPECT().RegularFile(gomock.Any(), selected.Root, "tools/buf/zeta.gen.yaml").Return(false, nil), + ) + + issues, err := projectreadiness.New(workspace).Check(context.Background(), selected) + + require.NoError(t, err) + require.Equal(t, []project.Issue{ + {Code: project.IssueGoModMissing, Path: selected.ManifestPath, Field: "go.mod"}, + {Code: project.IssueToolConfigMissing, Path: selected.ManifestPath, Field: "tools/buf/zeta.gen.yaml"}, + }, issues) +} + +func TestCheckerDoesNotTreatGoModuleCommentAsToolDirective(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + workspace := mocks.NewMockWorkspace(ctrl) + selected := project.Project{ + Root: "/project", ManifestPath: "/project/devctl.yaml", + Manifest: project.Manifest{ + Components: project.Components{GRPC: &project.GRPC{Server: &project.GRPCServer{}}}, + Languages: project.Languages{Go: project.GoLanguage{Generators: project.GoGenerators{ + GRPC: &project.GRPCGenerator{BufGenConfig: "tools/buf/grpc.gen.yaml"}, + }}}, + }, + } + gomock.InOrder( + workspace.EXPECT().RegularFile(gomock.Any(), selected.Root, "go.mod").Return(true, nil), + workspace.EXPECT().RegularFile(gomock.Any(), selected.Root, "buf.yaml").Return(true, nil), + workspace.EXPECT().RegularFile(gomock.Any(), selected.Root, "tools/buf/grpc.gen.yaml").Return(true, nil), + workspace.EXPECT().ReadBytes(gomock.Any(), selected.Root, "go.mod").Return([]byte( + "module example.test/example\n\n// tool github.com/bufbuild/buf/cmd/buf\n", + ), nil), + ) + + issues, err := projectreadiness.New(workspace).Check(context.Background(), selected) + + require.NoError(t, err) + require.Equal(t, []project.Issue{{ + Code: project.IssueToolMissing, Path: selected.ManifestPath, Field: "go.mod", + }}, issues) +} + +func TestCheckerChecksKafkaProtoConfigAndBufTool(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + workspace := mocks.NewMockWorkspace(ctrl) + selected := project.Project{ + Root: "/project", ManifestPath: "/project/devctl.yaml", + Manifest: project.Manifest{ + Sources: map[string]project.Source{ + "contracts": {Type: project.SourceGit, Repo: "example/contracts", Ref: "v1"}, + }, + Components: project.Components{Kafka: &project.Kafka{Consumers: []project.KafkaConsumer{{ + Name: "billing", Contract: project.KafkaContract{ + Source: "contracts", Path: "proto/billing.proto", Format: "proto", ProtoRoot: "proto", + }, + }}}}, + Languages: project.Languages{Go: project.GoLanguage{Generators: project.GoGenerators{ + Kafka: &project.KafkaGenerator{BufGenConfig: "tools/buf/kafka.gen.yaml"}, + }}}, + }, + } + gomock.InOrder( + workspace.EXPECT().RegularFile(gomock.Any(), selected.Root, "go.mod").Return(true, nil), + workspace.EXPECT().RegularFile(gomock.Any(), selected.Root, "tools/buf/kafka.gen.yaml").Return(false, nil), + workspace.EXPECT().ReadBytes(gomock.Any(), selected.Root, "go.mod").Return([]byte( + "module example.test/example\n\ntool github.com/bufbuild/buf/cmd/buf\n", + ), nil), + ) + + issues, err := projectreadiness.New(workspace).Check(context.Background(), selected) + + require.NoError(t, err) + require.Equal(t, []project.Issue{{ + Code: project.IssueToolConfigMissing, Path: selected.ManifestPath, Field: "tools/buf/kafka.gen.yaml", + }}, issues) +} + +func TestCheckerChecksSharedBufToolOnceForGRPCAndKafka(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + workspace := mocks.NewMockWorkspace(ctrl) + selected := project.Project{ + Root: "/project", ManifestPath: "/project/devctl.yaml", + Manifest: project.Manifest{ + Sources: map[string]project.Source{ + "contracts": {Type: project.SourceGit, Repo: "example/contracts", Ref: "v1"}, + }, + Components: project.Components{ + GRPC: &project.GRPC{Server: &project.GRPCServer{BufConfig: "buf.yaml"}}, + Kafka: &project.Kafka{Producers: []project.KafkaProducer{{Contract: project.KafkaContract{ + Source: "contracts", Path: "proto/event.proto", Format: "proto", ProtoRoot: "proto", + }}}}, + }, + Languages: project.Languages{Go: project.GoLanguage{Generators: project.GoGenerators{ + GRPC: &project.GRPCGenerator{BufGenConfig: "tools/buf/grpc.gen.yaml"}, + Kafka: &project.KafkaGenerator{BufGenConfig: "tools/buf/kafka.gen.yaml"}, + }}}, + }, + } + gomock.InOrder( + workspace.EXPECT().RegularFile(gomock.Any(), selected.Root, "go.mod").Return(true, nil), + workspace.EXPECT().RegularFile(gomock.Any(), selected.Root, "buf.yaml").Return(true, nil), + workspace.EXPECT().RegularFile(gomock.Any(), selected.Root, "tools/buf/grpc.gen.yaml").Return(true, nil), + workspace.EXPECT().ReadBytes(gomock.Any(), selected.Root, "go.mod").Return([]byte( + "module example.test/example\n\ntool github.com/bufbuild/buf/cmd/buf\n", + ), nil), + workspace.EXPECT().RegularFile(gomock.Any(), selected.Root, "tools/buf/kafka.gen.yaml").Return(true, nil), + ) + + issues, err := projectreadiness.New(workspace).Check(context.Background(), selected) + + require.NoError(t, err) + require.Empty(t, issues) +} + +func TestCheckerRequiresMiseConfigForKafkaJSON(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + workspace := mocks.NewMockWorkspace(ctrl) + selected := kafkaJSONProject() + gomock.InOrder( + workspace.EXPECT().RegularFile(gomock.Any(), selected.Root, "go.mod").Return(true, nil), + workspace.EXPECT().RegularFile(gomock.Any(), selected.Root, ".mise.toml").Return(false, nil), + ) + + issues, err := projectreadiness.New(workspace).Check(context.Background(), selected) + + require.NoError(t, err) + require.Equal(t, []project.Issue{{ + Code: project.IssueToolConfigMissing, Path: selected.ManifestPath, Field: ".mise.toml", + }}, issues) +} + +func TestCheckerValidatesKafkaJSONMiseToolDeclarations(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + content string + expected []project.Issue + }{ + {name: "valid", content: "[tools]\nnode = \"24\"\n\"npm:quicktype\" = \"26.0.0\"\n"}, + {name: "missing", content: "[tools]\ngo = \"1.26\"\n", expected: []project.Issue{ + {Code: project.IssueToolMissing, Path: "/project/devctl.yaml", Field: ".mise.toml", Parameters: &project.Parameters{Value: "node"}}, + {Code: project.IssueToolMissing, Path: "/project/devctl.yaml", Field: ".mise.toml", Parameters: &project.Parameters{Value: "npm:quicktype"}}, + }}, + {name: "invalid", content: "[tools\n", expected: []project.Issue{{ + Code: project.IssueToolConfigInvalid, Path: "/project/devctl.yaml", Field: ".mise.toml", + }}}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + workspace := mocks.NewMockWorkspace(ctrl) + selected := kafkaJSONProject() + gomock.InOrder( + workspace.EXPECT().RegularFile(gomock.Any(), selected.Root, "go.mod").Return(true, nil), + workspace.EXPECT().RegularFile(gomock.Any(), selected.Root, ".mise.toml").Return(true, nil), + workspace.EXPECT().ReadBytes(gomock.Any(), selected.Root, ".mise.toml").Return([]byte(test.content), nil), + ) + + issues, err := projectreadiness.New(workspace).Check(context.Background(), selected) + + require.NoError(t, err) + require.Equal(t, test.expected, issues) + }) + } +} + +func TestCheckerPreservesMiseConfigReadError(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + workspace := mocks.NewMockWorkspace(ctrl) + selected := kafkaJSONProject() + cause := errors.New("mise read failed") + gomock.InOrder( + workspace.EXPECT().RegularFile(gomock.Any(), selected.Root, "go.mod").Return(true, nil), + workspace.EXPECT().RegularFile(gomock.Any(), selected.Root, ".mise.toml").Return(true, nil), + workspace.EXPECT().ReadBytes(gomock.Any(), selected.Root, ".mise.toml").Return(nil, cause), + ) + + issues, err := projectreadiness.New(workspace).Check(context.Background(), selected) + + require.Empty(t, issues) + require.ErrorIs(t, err, cause) + var operationErr *project.OperationError + require.ErrorAs(t, err, &operationErr) + require.Equal(t, project.OperationReadFile, operationErr.Operation) + require.Equal(t, ".mise.toml", operationErr.Path) +} + +func kafkaJSONProject() project.Project { + return project.Project{ + Root: "/project", ManifestPath: "/project/devctl.yaml", + Manifest: project.Manifest{ + Sources: map[string]project.Source{ + "contracts": {Type: project.SourceGit, Repo: "example/contracts", Ref: "v1"}, + }, + Components: project.Components{Kafka: &project.Kafka{Consumers: []project.KafkaConsumer{{ + Name: "billing", Contract: project.KafkaContract{ + Source: "contracts", Path: "json/billing.json", Format: "json", + }, + }}}}, + }, + } +} diff --git a/internal/service/projectreadiness/mocks/checker.go b/internal/service/projectreadiness/mocks/checker.go new file mode 100644 index 0000000..3106e93 --- /dev/null +++ b/internal/service/projectreadiness/mocks/checker.go @@ -0,0 +1,158 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/devctllabs/devctl/internal/service/projectreadiness (interfaces: Workspace) +// +// Generated by this command: +// +// mockgen -destination mocks/checker.go -package mocks -typed . Workspace +// + +// Package mocks is a generated GoMock package. +package mocks + +import ( + context "context" + reflect "reflect" + + gomock "go.uber.org/mock/gomock" +) + +// MockWorkspace is a mock of Workspace interface. +type MockWorkspace struct { + ctrl *gomock.Controller + recorder *MockWorkspaceMockRecorder + isgomock struct{} +} + +// MockWorkspaceMockRecorder is the mock recorder for MockWorkspace. +type MockWorkspaceMockRecorder struct { + mock *MockWorkspace +} + +// NewMockWorkspace creates a new mock instance. +func NewMockWorkspace(ctrl *gomock.Controller) *MockWorkspace { + mock := &MockWorkspace{ctrl: ctrl} + mock.recorder = &MockWorkspaceMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockWorkspace) EXPECT() *MockWorkspaceMockRecorder { + return m.recorder +} + +// Directory mocks base method. +func (m *MockWorkspace) Directory(ctx context.Context, root, relativePath string) (bool, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Directory", ctx, root, relativePath) + ret0, _ := ret[0].(bool) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Directory indicates an expected call of Directory. +func (mr *MockWorkspaceMockRecorder) Directory(ctx, root, relativePath any) *MockWorkspaceDirectoryCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Directory", reflect.TypeOf((*MockWorkspace)(nil).Directory), ctx, root, relativePath) + return &MockWorkspaceDirectoryCall{Call: call} +} + +// MockWorkspaceDirectoryCall wrap *gomock.Call +type MockWorkspaceDirectoryCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockWorkspaceDirectoryCall) Return(arg0 bool, arg1 error) *MockWorkspaceDirectoryCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockWorkspaceDirectoryCall) Do(f func(context.Context, string, string) (bool, error)) *MockWorkspaceDirectoryCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockWorkspaceDirectoryCall) DoAndReturn(f func(context.Context, string, string) (bool, error)) *MockWorkspaceDirectoryCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// ReadBytes mocks base method. +func (m *MockWorkspace) ReadBytes(ctx context.Context, root, relativePath string) ([]byte, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ReadBytes", ctx, root, relativePath) + ret0, _ := ret[0].([]byte) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ReadBytes indicates an expected call of ReadBytes. +func (mr *MockWorkspaceMockRecorder) ReadBytes(ctx, root, relativePath any) *MockWorkspaceReadBytesCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ReadBytes", reflect.TypeOf((*MockWorkspace)(nil).ReadBytes), ctx, root, relativePath) + return &MockWorkspaceReadBytesCall{Call: call} +} + +// MockWorkspaceReadBytesCall wrap *gomock.Call +type MockWorkspaceReadBytesCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockWorkspaceReadBytesCall) Return(arg0 []byte, arg1 error) *MockWorkspaceReadBytesCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockWorkspaceReadBytesCall) Do(f func(context.Context, string, string) ([]byte, error)) *MockWorkspaceReadBytesCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockWorkspaceReadBytesCall) DoAndReturn(f func(context.Context, string, string) ([]byte, error)) *MockWorkspaceReadBytesCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// RegularFile mocks base method. +func (m *MockWorkspace) RegularFile(ctx context.Context, root, relativePath string) (bool, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "RegularFile", ctx, root, relativePath) + ret0, _ := ret[0].(bool) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// RegularFile indicates an expected call of RegularFile. +func (mr *MockWorkspaceMockRecorder) RegularFile(ctx, root, relativePath any) *MockWorkspaceRegularFileCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RegularFile", reflect.TypeOf((*MockWorkspace)(nil).RegularFile), ctx, root, relativePath) + return &MockWorkspaceRegularFileCall{Call: call} +} + +// MockWorkspaceRegularFileCall wrap *gomock.Call +type MockWorkspaceRegularFileCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockWorkspaceRegularFileCall) Return(arg0 bool, arg1 error) *MockWorkspaceRegularFileCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockWorkspaceRegularFileCall) Do(f func(context.Context, string, string) (bool, error)) *MockWorkspaceRegularFileCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockWorkspaceRegularFileCall) DoAndReturn(f func(context.Context, string, string) (bool, error)) *MockWorkspaceRegularFileCall { + c.Call = c.Call.DoAndReturn(f) + return c +} diff --git a/internal/service/runtimeconfig/render.go b/internal/service/runtimeconfig/render.go new file mode 100644 index 0000000..6cd768a --- /dev/null +++ b/internal/service/runtimeconfig/render.go @@ -0,0 +1,122 @@ +package runtimeconfig + +import ( + "bytes" + _ "embed" + "fmt" + "go/format" + "sort" + "strconv" + "strings" + "text/template" + + "github.com/devctllabs/devctl/internal/domain/artifact" + projectdomain "github.com/devctllabs/devctl/internal/domain/project" +) + +//go:embed templates/config.go.gotmpl +var configTemplate string + +// Output contains the config package and project-root files owned by Runtime Config. +type Output struct { + Directory artifact.Tree + Files artifact.Tree +} + +// Render converts a canonical catalog into deterministic Managed Output. +func Render(catalog projectdomain.RuntimeConfigCatalog) (Output, error) { + configFile, err := renderConfig(catalog.Entries(projectdomain.RuntimeConfigRuntime)) + if err != nil { + return Output{}, err + } + envFile := renderEnv(catalog.Entries(projectdomain.RuntimeConfigExample)) + return Output{ + Directory: artifact.Tree{Files: []artifact.File{{Path: "config.gen.go", Content: configFile, Mode: 0o644}}}, + Files: artifact.Tree{Files: []artifact.File{{Path: ".env.example", Content: envFile, Mode: 0o644}}}, + }, nil +} + +type templateGroup struct { + Name string + Fields []templateField +} + +type templateField struct { + Name string + Type string + Tag string +} + +func renderConfig(fields []projectdomain.RuntimeConfigField) ([]byte, error) { + grouped := make(map[string][]projectdomain.RuntimeConfigField) + for _, field := range fields { + grouped[field.Group] = append(grouped[field.Group], field) + } + groupNames := make([]string, 0, len(grouped)) + for name := range grouped { + groupNames = append(groupNames, name) + } + sort.Strings(groupNames) + + groups := make([]templateGroup, 0, len(groupNames)) + for _, groupName := range groupNames { + groupFields := grouped[groupName] + sort.Slice(groupFields, func(i, j int) bool { return groupFields[i].Name < groupFields[j].Name }) + group := templateGroup{Name: groupName, Fields: make([]templateField, 0, len(groupFields))} + for _, field := range groupFields { + group.Fields = append(group.Fields, templateField{Name: field.Name, Type: goType(field.Type), Tag: fieldTag(field)}) + } + groups = append(groups, group) + } + + parsed, err := template.New("config.go").Parse(configTemplate) + if err != nil { + return nil, fmt.Errorf("template.Parse: %w", err) + } + var rendered bytes.Buffer + if err := parsed.Execute(&rendered, groups); err != nil { + return nil, fmt.Errorf("parsed.Execute: %w", err) + } + formatted, err := format.Source(rendered.Bytes()) + if err != nil { + return nil, fmt.Errorf("format.Source: %w", err) + } + return formatted, nil +} + +func fieldTag(field projectdomain.RuntimeConfigField) string { + tag := "`env:" + strconv.Quote(field.Key) + if field.HasDefault && !field.Secret { + tag += " default:" + strconv.Quote(fmt.Sprint(field.Default)) + } + return tag + "`" +} + +func goType(value projectdomain.RuntimeConfigType) string { + switch value { + case projectdomain.RuntimeConfigBool: + return "bool" + case projectdomain.RuntimeConfigInt: + return "int" + case projectdomain.RuntimeConfigDuration: + return "time.Duration" + case projectdomain.RuntimeConfigStringList: + return "[]string" + case projectdomain.RuntimeConfigString: + return "string" + default: + return "string" + } +} + +func renderEnv(fields []projectdomain.RuntimeConfigField) []byte { + var builder strings.Builder + for _, field := range fields { + value := "" + if field.HasDefault && !field.Secret { + value = fmt.Sprint(field.Default) + } + fmt.Fprintf(&builder, "%s=%s\n", field.Key, value) + } + return []byte(builder.String()) +} diff --git a/internal/service/runtimeconfig/render_test.go b/internal/service/runtimeconfig/render_test.go new file mode 100644 index 0000000..902cc84 --- /dev/null +++ b/internal/service/runtimeconfig/render_test.go @@ -0,0 +1,46 @@ +package runtimeconfig_test + +import ( + "testing" + + projectdomain "github.com/devctllabs/devctl/internal/domain/project" + "github.com/devctllabs/devctl/internal/service/runtimeconfig" + "github.com/stretchr/testify/require" +) + +func TestRenderProducesCanonicalManagedOutput(t *testing.T) { + t.Parallel() + + manifest := projectdomain.Manifest{ + Project: projectdomain.Identity{Name: "sample", Language: "go"}, + Components: projectdomain.Components{ + HTTP: &projectdomain.HTTP{Server: &projectdomain.HTTPServer{Start: &projectdomain.Start{}}}, + Kafka: &projectdomain.Kafka{Consumers: []projectdomain.KafkaConsumer{{Name: "billing"}}}, + }, + } + catalog, err := projectdomain.NewRuntimeConfigCatalog(manifest) + require.NoError(t, err) + + output, err := runtimeconfig.Render(catalog) + + require.NoError(t, err) + require.Len(t, output.Directory.Files, 1) + require.Equal(t, "config.gen.go", output.Directory.Files[0].Path) + source := string(output.Directory.Files[0].Content) + require.Regexp(t, `HTTP\s+HTTPConfig`, source) + require.Regexp(t, `Address\s+string\s+`+"`"+`env:"SAMPLE_HTTP_ADDR" default:":8080"`+"`", source) + require.Regexp(t, `Enabled\s+bool\s+`+"`"+`env:"SAMPLE_HTTP_SERVER_ENABLED" default:"false"`+"`", source) + require.Regexp(t, `Brokers\s+\[\]string\s+`+"`"+`env:"SAMPLE_KAFKA_BROKERS" default:"localhost:29092"`+"`", source) + require.Len(t, output.Files.Files, 1) + require.Equal(t, ".env.example", output.Files.Files[0].Path) + environment := string(output.Files.Files[0].Content) + for _, line := range []string{ + "SAMPLE_HTTP_ADDR=:8080\n", "SAMPLE_HTTP_SERVER_ENABLED=false\n", + "SAMPLE_KAFKA_BILLING_GROUP=sample-billing-group\n", "SAMPLE_KAFKA_BILLING_TOPIC=\n", + "SAMPLE_KAFKA_BILLING_BATCH_MAX_SIZE=1\n", "SAMPLE_KAFKA_BILLING_RETRY_MAX_ATTEMPTS=3\n", + "SAMPLE_KAFKA_BILLING_REBALANCE_TIMEOUT=30s\n", "SAMPLE_KAFKA_BILLING_SHUTDOWN_TIMEOUT=30s\n", + "SAMPLE_KAFKA_BROKERS=localhost:29092\n", + } { + require.Contains(t, environment, line) + } +} diff --git a/internal/service/runtimeconfig/templates/config.go.gotmpl b/internal/service/runtimeconfig/templates/config.go.gotmpl new file mode 100644 index 0000000..e9d2cae --- /dev/null +++ b/internal/service/runtimeconfig/templates/config.go.gotmpl @@ -0,0 +1,29 @@ +// Code generated by devctl. DO NOT EDIT. + +package config + +import ( + "fmt" + "time" +) + +type Config struct { +{{- range . }} + {{ .Name }} {{ .Name }}Config +{{- end }} +} +{{ range . }} +type {{ .Name }}Config struct { +{{- range .Fields }} + {{ .Name }} {{ .Type }} {{ .Tag }} +{{- end }} +} +{{ end }} +func (c *Config) Validate() error { + if c == nil { + return fmt.Errorf("config is nil") + } + return nil +} + +var _ time.Duration diff --git a/internal/service/scaffold/integration_test.go b/internal/service/scaffold/integration_test.go new file mode 100644 index 0000000..d4ae4ac --- /dev/null +++ b/internal/service/scaffold/integration_test.go @@ -0,0 +1,216 @@ +package scaffold_test + +import ( + "context" + "fmt" + "go/ast" + "go/parser" + "go/token" + "os" + "path/filepath" + "testing" + + projectdomain "github.com/devctllabs/devctl/internal/domain/project" + scaffolddomain "github.com/devctllabs/devctl/internal/domain/scaffold" + manifestrepo "github.com/devctllabs/devctl/internal/repository/manifest" + workspacerepo "github.com/devctllabs/devctl/internal/repository/workspace" + projectservice "github.com/devctllabs/devctl/internal/service/project" + "github.com/devctllabs/devctl/internal/service/scaffold" + "github.com/stretchr/testify/require" + "go.uber.org/zap" +) + +func TestFilesystemRepoPreflightsAndPublishesRenderedArtifacts(t *testing.T) { + t.Parallel() + + root := t.TempDir() + repository := workspacerepo.NewFilesystemRepo() + service := scaffold.New(zap.NewNop(), fixedProjectRepository{project: projectdomain.Project{Root: root, Manifest: projectdomain.Manifest{ + Project: projectdomain.Identity{Name: "sample", Language: "go"}, Languages: projectdomain.Languages{Go: projectdomain.GoLanguage{Module: "example.test/sample"}}, + }}}, repository) + changes, err := service.Scaffold(context.Background(), scaffolddomain.Command{}) + require.NoError(t, err) + require.NotEmpty(t, changes.Files) + requireGoldenFile(t, filepath.Join(root, "cmd/sample/main.go"), "testdata/minimal/cmd/sample/main.go") + requireGoldenFile(t, filepath.Join(root, "go.mod"), "testdata/minimal/go.mod") + changes, err = service.Scaffold(context.Background(), scaffolddomain.Command{}) + require.NoError(t, err) + for _, change := range changes.Files { + require.Equal(t, scaffolddomain.FileUnchanged, change.Action) + } +} + +func requireGoldenFile(t *testing.T, actualPath, goldenPath string) { + t.Helper() + actual, err := os.ReadFile(actualPath) + require.NoError(t, err) + golden, err := os.ReadFile(goldenPath) + require.NoError(t, err) + require.Equal(t, golden, actual) +} + +func TestFilesystemRepoCreatesCLIFoundation(t *testing.T) { + t.Parallel() + + root := t.TempDir() + manifestPath := filepath.Join(root, "devctl.yaml") + require.NoError(t, os.WriteFile(manifestPath, []byte("version: 1\nproject: {name: sample-cli, language: go}\nenv: {}\npaths: {external_contracts: api/external}\nsources: {}\nexports: {}\ncomponents:\n logging: {}\nlanguages:\n go:\n module: github.com/acme/sample-cli\n generators:\n config: {out: gen/config}\n"), 0o644)) + + result, err := scaffoldProject(context.Background(), manifestPath) + + require.NoError(t, err) + require.NotEmpty(t, result.Files) + for _, path := range []string{"go.mod", ".mise.toml", ".golangci.yml", "cmd/sample-cli/main.go", "internal/deps/container.gen.go"} { + _, err := os.Stat(filepath.Join(root, path)) + require.NoError(t, err, path) + } +} + +func TestFilesystemRepoRefreshPreservesApplicationAndAddsConsumerSeeds(t *testing.T) { + t.Parallel() + + root := t.TempDir() + manifestPath := filepath.Join(root, "devctl.yaml") + initialManifest := []byte(`version: 1 +project: {name: sample, language: go} +env: {} +paths: {external_contracts: api/external} +sources: {} +exports: {} +components: {} +languages: + go: {module: example.test/sample} +`) + require.NoError(t, os.WriteFile(manifestPath, initialManifest, 0o644)) + _, err := scaffoldProject(context.Background(), manifestPath) + require.NoError(t, err) + applicationPath := filepath.Join(root, "internal/deps/application.go") + require.NoError(t, os.WriteFile(applicationPath, []byte("package deps\n\n// user composition\n"), 0o644)) + + withConsumer := []byte(`version: 1 +project: {name: sample, language: go} +env: {} +paths: {external_contracts: api/external} +sources: {} +exports: {} +components: + kafka: + consumers: + - name: audit + topic: sample.audit.events.v1 + contract: {format: raw} +languages: + go: {module: example.test/sample} +`) + require.NoError(t, os.WriteFile(manifestPath, withConsumer, 0o644)) + result, err := scaffoldProject(context.Background(), manifestPath) + require.NoError(t, err) + + application, err := os.ReadFile(applicationPath) + require.NoError(t, err) + require.Equal(t, "package deps\n\n// user composition\n", string(application)) + requireFileChange(t, result, "internal/deps/application.go", scaffolddomain.FileUnchanged) + requireFileChange(t, result, "internal/deps/consumer_audit.go", scaffolddomain.FileCreated) + _, err = os.Stat(filepath.Join(root, "internal/transport/consumerkafka/audit/handler.go")) + require.NoError(t, err) +} + +func requireFileChange(t *testing.T, result scaffolddomain.Result, path string, action scaffolddomain.FileAction) { + t.Helper() + for _, change := range result.Files { + if change.Path == path { + require.Equal(t, action, change.Action) + return + } + } + require.Fail(t, "scaffold change missing", path) +} + +func TestFilesystemRepoCreatesGoLibsDBProviders(t *testing.T) { + t.Parallel() + + root := t.TempDir() + manifestPath := filepath.Join(root, "devctl.yaml") + require.NoError(t, os.WriteFile(manifestPath, []byte(`version: 1 +project: {name: sample-api, language: go} +env: {prefix: SAMPLE_} +paths: {external_contracts: api/external} +sources: {} +exports: {} +components: + health: {server: {start: {env: HEALTH_ENABLED, default: true}}} + telemetry: {start: {env: TELEMETRY_ENABLED, default: false}} + db: + connections: + - name: primary + default: sqlite + variants: + - {name: sqlite, kind: sqlite, dsn_default: 'file:./data/app.db?_foreign_keys=on'} + - {name: postgres, kind: postgres, secret: true} +languages: + go: {module: github.com/acme/sample-api} +`), 0o644)) + + result, err := scaffoldProject(context.Background(), manifestPath) + require.NoError(t, err) + require.NotEmpty(t, result.Files) + apiSource, err := os.ReadFile(filepath.Join(root, "cmd/sample-api/internal/api.go")) + require.NoError(t, err) + for _, declaration := range []string{ + "func NewCmdAPI() *cli.Command", "deps.NewAPI(ctx)", "scenario.Run(ctx)", + } { + require.Contains(t, string(apiSource), declaration) + } + + storage, err := os.ReadFile(filepath.Join(root, "internal/deps/storage_primary.gen.go")) + require.NoError(t, err) + selectors := selectorNames(t, storage) + for _, expected := range []string{ + "sqlitedb.Open", "postgresdb.Open", "di.ProvideNamedResource", "di.ProvideNamed[txmanager.Managers]", + } { + if expected == "di.ProvideNamed[txmanager.Managers]" { + expected = "di.ProvideNamed" + } + require.Contains(t, selectors, expected) + } + _, err = os.Stat(filepath.Join(root, "data/.gitkeep")) + require.NoError(t, err) +} + +func selectorNames(t *testing.T, source []byte) map[string]struct{} { + t.Helper() + file, err := parser.ParseFile(token.NewFileSet(), "generated.go", source, parser.AllErrors) + require.NoError(t, err) + selectors := make(map[string]struct{}) + ast.Inspect(file, func(node ast.Node) bool { + selector, ok := node.(*ast.SelectorExpr) + if !ok { + return true + } + identifier, ok := selector.X.(*ast.Ident) + if ok { + selectors[identifier.Name+"."+selector.Sel.Name] = struct{}{} + } + return true + }) + return selectors +} + +func scaffoldProject(ctx context.Context, manifestPath string) (scaffolddomain.Result, error) { + adapter := workspacerepo.NewFilesystemRepo() + projects := projectservice.New(zap.NewNop(), projectservice.Dependencies{ + Manifests: manifestrepo.NewFilesystemRepo(), Locator: adapter, + }) + service := scaffold.New(zap.NewNop(), projects, adapter) + result, err := service.Scaffold(ctx, scaffolddomain.Command{ManifestPath: manifestPath}) + if err != nil { + return result, fmt.Errorf("service.Scaffold: %w", err) + } + return result, nil +} + +type fixedProjectRepository struct{ project projectdomain.Project } + +func (r fixedProjectRepository) LoadProject(context.Context, string) (projectdomain.Project, error) { + return r.project, nil +} diff --git a/internal/service/scaffold/mocks/ports.go b/internal/service/scaffold/mocks/ports.go new file mode 100644 index 0000000..d853583 --- /dev/null +++ b/internal/service/scaffold/mocks/ports.go @@ -0,0 +1,262 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/devctllabs/devctl/internal/service/scaffold (interfaces: ProjectRepository,WorkspaceRepository) +// +// Generated by this command: +// +// mockgen -destination mocks/ports.go -package mocks -typed . ProjectRepository,WorkspaceRepository +// + +// Package mocks is a generated GoMock package. +package mocks + +import ( + context "context" + fs "io/fs" + reflect "reflect" + + artifact "github.com/devctllabs/devctl/internal/domain/artifact" + project "github.com/devctllabs/devctl/internal/domain/project" + gomock "go.uber.org/mock/gomock" +) + +// MockProjectRepository is a mock of ProjectRepository interface. +type MockProjectRepository struct { + ctrl *gomock.Controller + recorder *MockProjectRepositoryMockRecorder + isgomock struct{} +} + +// MockProjectRepositoryMockRecorder is the mock recorder for MockProjectRepository. +type MockProjectRepositoryMockRecorder struct { + mock *MockProjectRepository +} + +// NewMockProjectRepository creates a new mock instance. +func NewMockProjectRepository(ctrl *gomock.Controller) *MockProjectRepository { + mock := &MockProjectRepository{ctrl: ctrl} + mock.recorder = &MockProjectRepositoryMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockProjectRepository) EXPECT() *MockProjectRepositoryMockRecorder { + return m.recorder +} + +// LoadProject mocks base method. +func (m *MockProjectRepository) LoadProject(ctx context.Context, manifestPath string) (project.Project, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "LoadProject", ctx, manifestPath) + ret0, _ := ret[0].(project.Project) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// LoadProject indicates an expected call of LoadProject. +func (mr *MockProjectRepositoryMockRecorder) LoadProject(ctx, manifestPath any) *MockProjectRepositoryLoadProjectCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "LoadProject", reflect.TypeOf((*MockProjectRepository)(nil).LoadProject), ctx, manifestPath) + return &MockProjectRepositoryLoadProjectCall{Call: call} +} + +// MockProjectRepositoryLoadProjectCall wrap *gomock.Call +type MockProjectRepositoryLoadProjectCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockProjectRepositoryLoadProjectCall) Return(arg0 project.Project, arg1 error) *MockProjectRepositoryLoadProjectCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockProjectRepositoryLoadProjectCall) Do(f func(context.Context, string) (project.Project, error)) *MockProjectRepositoryLoadProjectCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockProjectRepositoryLoadProjectCall) DoAndReturn(f func(context.Context, string) (project.Project, error)) *MockProjectRepositoryLoadProjectCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// MockWorkspaceRepository is a mock of WorkspaceRepository interface. +type MockWorkspaceRepository struct { + ctrl *gomock.Controller + recorder *MockWorkspaceRepositoryMockRecorder + isgomock struct{} +} + +// MockWorkspaceRepositoryMockRecorder is the mock recorder for MockWorkspaceRepository. +type MockWorkspaceRepositoryMockRecorder struct { + mock *MockWorkspaceRepository +} + +// NewMockWorkspaceRepository creates a new mock instance. +func NewMockWorkspaceRepository(ctrl *gomock.Controller) *MockWorkspaceRepository { + mock := &MockWorkspaceRepository{ctrl: ctrl} + mock.recorder = &MockWorkspaceRepositoryMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockWorkspaceRepository) EXPECT() *MockWorkspaceRepositoryMockRecorder { + return m.recorder +} + +// Lstat mocks base method. +func (m *MockWorkspaceRepository) Lstat(ctx context.Context, root, name string) (fs.FileInfo, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Lstat", ctx, root, name) + ret0, _ := ret[0].(fs.FileInfo) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Lstat indicates an expected call of Lstat. +func (mr *MockWorkspaceRepositoryMockRecorder) Lstat(ctx, root, name any) *MockWorkspaceRepositoryLstatCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Lstat", reflect.TypeOf((*MockWorkspaceRepository)(nil).Lstat), ctx, root, name) + return &MockWorkspaceRepositoryLstatCall{Call: call} +} + +// MockWorkspaceRepositoryLstatCall wrap *gomock.Call +type MockWorkspaceRepositoryLstatCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockWorkspaceRepositoryLstatCall) Return(arg0 fs.FileInfo, arg1 error) *MockWorkspaceRepositoryLstatCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockWorkspaceRepositoryLstatCall) Do(f func(context.Context, string, string) (fs.FileInfo, error)) *MockWorkspaceRepositoryLstatCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockWorkspaceRepositoryLstatCall) DoAndReturn(f func(context.Context, string, string) (fs.FileInfo, error)) *MockWorkspaceRepositoryLstatCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// PublishFile mocks base method. +func (m *MockWorkspaceRepository) PublishFile(ctx context.Context, root, target string, content []byte) (artifact.PublishResult, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PublishFile", ctx, root, target, content) + ret0, _ := ret[0].(artifact.PublishResult) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// PublishFile indicates an expected call of PublishFile. +func (mr *MockWorkspaceRepositoryMockRecorder) PublishFile(ctx, root, target, content any) *MockWorkspaceRepositoryPublishFileCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PublishFile", reflect.TypeOf((*MockWorkspaceRepository)(nil).PublishFile), ctx, root, target, content) + return &MockWorkspaceRepositoryPublishFileCall{Call: call} +} + +// MockWorkspaceRepositoryPublishFileCall wrap *gomock.Call +type MockWorkspaceRepositoryPublishFileCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockWorkspaceRepositoryPublishFileCall) Return(arg0 artifact.PublishResult, arg1 error) *MockWorkspaceRepositoryPublishFileCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockWorkspaceRepositoryPublishFileCall) Do(f func(context.Context, string, string, []byte) (artifact.PublishResult, error)) *MockWorkspaceRepositoryPublishFileCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockWorkspaceRepositoryPublishFileCall) DoAndReturn(f func(context.Context, string, string, []byte) (artifact.PublishResult, error)) *MockWorkspaceRepositoryPublishFileCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// ReadBytes mocks base method. +func (m *MockWorkspaceRepository) ReadBytes(ctx context.Context, root, name string) ([]byte, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ReadBytes", ctx, root, name) + ret0, _ := ret[0].([]byte) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ReadBytes indicates an expected call of ReadBytes. +func (mr *MockWorkspaceRepositoryMockRecorder) ReadBytes(ctx, root, name any) *MockWorkspaceRepositoryReadBytesCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ReadBytes", reflect.TypeOf((*MockWorkspaceRepository)(nil).ReadBytes), ctx, root, name) + return &MockWorkspaceRepositoryReadBytesCall{Call: call} +} + +// MockWorkspaceRepositoryReadBytesCall wrap *gomock.Call +type MockWorkspaceRepositoryReadBytesCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockWorkspaceRepositoryReadBytesCall) Return(arg0 []byte, arg1 error) *MockWorkspaceRepositoryReadBytesCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockWorkspaceRepositoryReadBytesCall) Do(f func(context.Context, string, string) ([]byte, error)) *MockWorkspaceRepositoryReadBytesCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockWorkspaceRepositoryReadBytesCall) DoAndReturn(f func(context.Context, string, string) ([]byte, error)) *MockWorkspaceRepositoryReadBytesCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// Walk mocks base method. +func (m *MockWorkspaceRepository) Walk(ctx context.Context, root string, visit fs.WalkDirFunc) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Walk", ctx, root, visit) + ret0, _ := ret[0].(error) + return ret0 +} + +// Walk indicates an expected call of Walk. +func (mr *MockWorkspaceRepositoryMockRecorder) Walk(ctx, root, visit any) *MockWorkspaceRepositoryWalkCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Walk", reflect.TypeOf((*MockWorkspaceRepository)(nil).Walk), ctx, root, visit) + return &MockWorkspaceRepositoryWalkCall{Call: call} +} + +// MockWorkspaceRepositoryWalkCall wrap *gomock.Call +type MockWorkspaceRepositoryWalkCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockWorkspaceRepositoryWalkCall) Return(arg0 error) *MockWorkspaceRepositoryWalkCall { + c.Call = c.Call.Return(arg0) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockWorkspaceRepositoryWalkCall) Do(f func(context.Context, string, fs.WalkDirFunc) error) *MockWorkspaceRepositoryWalkCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockWorkspaceRepositoryWalkCall) DoAndReturn(f func(context.Context, string, fs.WalkDirFunc) error) *MockWorkspaceRepositoryWalkCall { + c.Call = c.Call.DoAndReturn(f) + return c +} diff --git a/internal/service/scaffold/planner.go b/internal/service/scaffold/planner.go new file mode 100644 index 0000000..8744683 --- /dev/null +++ b/internal/service/scaffold/planner.go @@ -0,0 +1,471 @@ +package scaffold + +import ( + "bytes" + "context" + "fmt" + "go/format" + "io/fs" + "path/filepath" + "sort" + "strings" + + projectdomain "github.com/devctllabs/devctl/internal/domain/project" +) + +// Artifact is one rendered scaffold file and its replacement policy. +type Artifact struct { + Path string + Mode fs.FileMode + Content []byte + // CreateOnly preserves different existing content on every refresh. + CreateOnly bool +} + +type conflict struct { + code string + path string +} + +type preflightInspector struct { + workspace WorkspaceRepository + root string + planned map[string]Artifact + conflicts []conflict +} + +type preflightRequest struct { + root string + artifacts []Artifact +} + +// preflight collects all detectable workspace conflicts before the first scaffold publication. +func preflight( + ctx context.Context, + workspace WorkspaceRepository, + request preflightRequest, +) ([]conflict, error) { + if err := ctx.Err(); err != nil { + return nil, fmt.Errorf("ctx.Err: %w", err) + } + planned := make(map[string]Artifact, len(request.artifacts)) + for _, artifact := range request.artifacts { + planned[canonicalArtifactPath(artifact.Path)] = artifact + } + inspector := preflightInspector{workspace: workspace, root: request.root, planned: planned} + err := workspace.Walk(ctx, request.root, func(path string, entry fs.DirEntry, walkErr error) error { + return inspector.inspectPath(ctx, path, entry, walkErr) + }) + if err != nil { + return nil, fmt.Errorf("workspace.Walk: %w", err) + } + return inspector.conflicts, nil +} + +// plan returns the complete rendered artifact set in deterministic path order with formatted Go sources. +func plan(m projectdomain.Manifest) ([]Artifact, error) { + projection := compileScaffoldProjection(m) + artifacts, err := baseArtifacts(projection) + if err != nil { + return nil, fmt.Errorf("baseArtifacts: %w", err) + } + server, err := serverArtifacts(projection) + if err != nil { + return nil, fmt.Errorf("serverArtifacts: %w", err) + } + artifacts = append(artifacts, server...) + http, err := httpArtifacts(projection.http) + if err != nil { + return nil, fmt.Errorf("httpArtifacts: %w", err) + } + artifacts = append(artifacts, http...) + proto, err := protoArtifacts(projection.proto) + if err != nil { + return nil, fmt.Errorf("protoArtifacts: %w", err) + } + artifacts = append(artifacts, proto...) + database, err := dbArtifacts(projection) + if err != nil { + return nil, fmt.Errorf("dbArtifacts: %w", err) + } + artifacts = append(artifacts, database...) + components, err := componentArtifacts(projection.components) + if err != nil { + return nil, fmt.Errorf("componentArtifacts: %w", err) + } + artifacts = append(artifacts, components...) + applyArtifactOwnership(projection, artifacts) + if err := ensureUniqueArtifactPaths(artifacts); err != nil { + return nil, fmt.Errorf("ensureUniqueArtifactPaths: %w", err) + } + sort.SliceStable(artifacts, func(i, j int) bool { return artifacts[i].Path < artifacts[j].Path }) + if err := formatGoArtifacts(artifacts); err != nil { + return nil, fmt.Errorf("formatGoArtifacts: %w", err) + } + return artifacts, nil +} + +func applyArtifactOwnership(projection scaffoldProjection, artifacts []Artifact) { + for index := range artifacts { + artifacts[index].CreateOnly = projection.scaffoldSeed(artifacts[index].Path) + } +} + +func componentArtifacts(projection componentProjection) ([]Artifact, error) { + builders := []func(componentProjection) ([]Artifact, error){ + grpcComponentArtifacts, + httpClientArtifacts, + resourceComponentArtifacts, + kafkaComponentArtifacts, + } + var artifacts []Artifact + for _, build := range builders { + group, err := build(projection) + if err != nil { + return nil, err + } + artifacts = append(artifacts, group...) + } + return artifacts, nil +} + +func grpcComponentArtifacts(projection componentProjection) ([]Artifact, error) { + if !projection.grpcEnabled { + return nil, nil + } + grpc, err := renderedTemplateArtifact("internal/deps/grpc.gen.go", "grpc.go.gotmpl", nil) + if err != nil { + return nil, err + } + artifacts := []Artifact{grpc} + if len(projection.grpcClients) == 0 { + return artifacts, nil + } + clients, err := renderedTemplateArtifact("internal/deps/grpc_clients.gen.go", "grpc_clients.go.gotmpl", projection.grpcClients) + if err != nil { + return nil, err + } + return append(artifacts, clients), nil +} + +func httpClientArtifacts(projection componentProjection) ([]Artifact, error) { + if len(projection.httpClients) == 0 { + return nil, nil + } + artifact, err := renderedTemplateArtifact("internal/deps/http_clients.gen.go", "http_clients.go.gotmpl", projection.httpClients) + return artifactSlice(artifact, err) +} + +func resourceComponentArtifacts(projection componentProjection) ([]Artifact, error) { + var artifacts []Artifact + if projection.redisEnabled { + artifact, err := renderedTemplateArtifact("internal/deps/redis.gen.go", "redis.go.gotmpl", projection.redisConnections) + if err != nil { + return nil, err + } + artifacts = append(artifacts, artifact) + } + if projection.s3 != nil { + artifact, err := renderedTemplateArtifact("internal/deps/s3.gen.go", "s3.go.gotmpl", projection.s3) + if err != nil { + return nil, err + } + artifacts = append(artifacts, artifact) + } + return artifacts, nil +} + +func kafkaComponentArtifacts(projection componentProjection) ([]Artifact, error) { + if projection.kafka == nil { + return nil, nil + } + kafka := projection.kafka + definitions := []struct { + path string + template string + data any + }{ + {"internal/deps/kafka_broker.gen.go", "kafka_broker.go.gotmpl", nil}, + {"internal/deps/kafka_consumers.gen.go", "kafka_consumers.go.gotmpl", struct { + Module string + Consumers []kafkaConsumerTemplateData + }{kafka.module, kafka.consumers}}, + {"internal/deps/kafka_producers.gen.go", "kafka_producers.go.gotmpl", kafka.producers}, + {filepath.ToSlash(filepath.Join("cmd", kafka.projectName, "internal", "consumer.go")), "consumer.go.gotmpl", struct{ Module string }{kafka.module}}, + } + artifacts := make([]Artifact, 0, len(definitions)+2*len(kafka.consumers)) + for _, definition := range definitions { + artifact, err := renderedTemplateArtifact(definition.path, definition.template, definition.data) + if err != nil { + return nil, err + } + artifacts = append(artifacts, artifact) + } + consumerSeeds, err := kafkaConsumerSeedArtifacts(kafka.consumerSeedFacts) + if err != nil { + return nil, err + } + return append(artifacts, consumerSeeds...), nil +} + +func kafkaConsumerSeedArtifacts(facts []kafkaConsumerSeedFact) ([]Artifact, error) { + artifacts := make([]Artifact, 0, 2*len(facts)) + for _, fact := range facts { + binding, err := renderedTemplateArtifact(filepath.ToSlash(filepath.Join("internal", "deps", "consumer_"+fact.packageName+".go")), "kafka_consumer_binding.go.gotmpl", fact.data) + if err != nil { + return nil, err + } + handler, err := renderedTemplateArtifact(filepath.ToSlash(filepath.Join("internal", "transport", "consumerkafka", fact.packageName, "handler.go")), "kafka_handler.go.gotmpl", struct{ Package string }{fact.packageName}) + if err != nil { + return nil, err + } + artifacts = append(artifacts, binding, handler) + } + return artifacts, nil +} + +func renderedTemplateArtifact(output, templateName string, data any) (Artifact, error) { + content, err := executeTemplate(templateName, data) + if err != nil { + return Artifact{}, err + } + return Artifact{Path: output, Mode: 0o644, Content: []byte(content)}, nil +} + +func artifactSlice(artifact Artifact, err error) ([]Artifact, error) { + if err != nil { + return nil, err + } + return []Artifact{artifact}, nil +} + +type kafkaConsumerTemplateData struct { + Name string + Topic string + Toggle bool + Format string + Module string +} + +func protoArtifacts(projection protoProjection) ([]Artifact, error) { + if !projection.enabled { + return nil, nil + } + generated, err := readTemplateAsset("buf-go.gen.yaml") + if err != nil { + return nil, fmt.Errorf("readTemplateAsset: %w", err) + } + artifacts := make([]Artifact, 0, len(projection.configPaths)+1) + for _, configPath := range projection.configPaths { + artifacts = append(artifacts, Artifact{configPath, 0o644, generated, false}) + } + moduleArtifact, err := grpcModuleArtifact(projection.grpcModule) + if err != nil { + return nil, err + } + if moduleArtifact != nil { + artifacts = append(artifacts, *moduleArtifact) + } + return artifacts, nil +} + +func grpcModuleArtifact(module *grpcModuleProjection) (*Artifact, error) { + if module == nil { + return nil, nil + } + moduleConfig, err := executeTemplate("buf.yaml.gotmpl", struct{ ProtoRoot string }{ProtoRoot: module.protoRoot}) + if err != nil { + return nil, fmt.Errorf("executeTemplate: %w", err) + } + artifact := Artifact{module.path, 0o644, []byte(moduleConfig), false} + return &artifact, nil +} + +func valueOrDefault(value, fallback string) string { + if value == "" { + return fallback + } + return value +} + +func ensureUniqueArtifactPaths(artifacts []Artifact) error { + seen := make(map[string]struct{}, len(artifacts)) + for _, artifact := range artifacts { + path := canonicalArtifactPath(artifact.Path) + if _, exists := seen[path]; exists { + return fmt.Errorf("duplicate artifact path %q", path) + } + seen[path] = struct{}{} + } + return nil +} + +func canonicalArtifactPath(path string) string { + return filepath.ToSlash(filepath.Clean(filepath.FromSlash(path))) +} + +func baseArtifacts(projection scaffoldProjection) ([]Artifact, error) { + goMod, err := renderGoMod(projection.goMod) + if err != nil { + return nil, fmt.Errorf("renderGoMod: %w", err) + } + mainFile, err := renderMain(projection.main) + if err != nil { + return nil, fmt.Errorf("renderMain: %w", err) + } + configArtifacts, err := runtimeConfigArtifacts(projection.config) + if err != nil { + return nil, fmt.Errorf("runtimeConfigArtifacts: %w", err) + } + containerFile, err := renderContainer(projection.container) + if err != nil { + return nil, fmt.Errorf("renderContainer: %w", err) + } + applicationFile, err := renderApplication(projection.application) + if err != nil { + return nil, fmt.Errorf("renderApplication: %w", err) + } + projectReadme, err := executeTemplate("project-readme.md.gotmpl", struct{ Project string }{Project: projection.projectName}) + if err != nil { + return nil, fmt.Errorf("executeTemplate project README: %w", err) + } + mise, err := renderMise(projection.mise) + if err != nil { + return nil, fmt.Errorf("renderMise: %w", err) + } + golangci, err := readTemplateAsset("golangci.yml") + if err != nil { + return nil, fmt.Errorf("readTemplateAsset: %w", err) + } + artifacts := []Artifact{ + {"go.mod", 0o644, []byte(goMod), false}, + {"README.md", 0o644, []byte(projectReadme), false}, + {".mise.toml", 0o644, []byte(mise), false}, + {".golangci.yml", 0o644, golangci, false}, + {filepath.ToSlash(filepath.Join("cmd", projection.projectName, "main.go")), 0o644, []byte(mainFile), false}, + {"internal/deps/container.gen.go", 0o644, []byte(containerFile), false}, + {"internal/deps/application.go", 0o644, []byte(applicationFile), false}, + } + artifacts = append(artifacts, configArtifacts...) + return artifacts, nil +} + +func serverArtifacts(projection scaffoldProjection) ([]Artifact, error) { + if !projection.hasServer { + return nil, nil + } + runtimeFile, err := renderRuntime(projection.runtime) + if err != nil { + return nil, fmt.Errorf("renderRuntime: %w", err) + } + apiFile, err := renderAPI(projection.module) + if err != nil { + return nil, fmt.Errorf("renderAPI: %w", err) + } + return []Artifact{ + {"internal/deps/runtime.gen.go", 0o644, []byte(runtimeFile), false}, + {filepath.ToSlash(filepath.Join("cmd", projection.projectName, "internal", "api.go")), 0o644, []byte(apiFile), false}, + }, nil +} + +func httpArtifacts(projection httpProjection) ([]Artifact, error) { + var artifacts []Artifact + if !projection.enabled { + return artifacts, nil + } + for _, target := range projection.targets { + templateName := "oapi-client.yaml" + if target.Role == "server" { + seed, err := readTemplateAsset("openapi.yaml") + if err != nil { + return nil, fmt.Errorf("readTemplateAsset: %w", err) + } + artifacts = append(artifacts, Artifact{target.Reference.Entrypoint, 0o644, seed, false}) + templateName = "oapi-server.yaml" + } + config, err := readTemplateAsset(templateName) + if err != nil { + return nil, fmt.Errorf("readTemplateAsset: %w", err) + } + artifacts = append(artifacts, Artifact{target.Config, 0o644, config, false}) + } + return artifacts, nil +} + +func formatGoArtifacts(artifacts []Artifact) error { + for index := range artifacts { + artifact := &artifacts[index] + if strings.HasSuffix(artifact.Path, ".gen.go") && !bytes.HasPrefix(artifact.Content, []byte("// Code generated by devctl. DO NOT EDIT.")) { + artifact.Content = append([]byte("// Code generated by devctl. DO NOT EDIT.\n\n"), artifact.Content...) + } + if strings.HasSuffix(artifact.Path, ".go") { + formatted, formatErr := format.Source(artifact.Content) + if formatErr != nil { + return fmt.Errorf("format.Source: %s: %w", artifact.Path, formatErr) + } + artifact.Content = formatted + } + } + return nil +} + +func dbArtifacts(projection scaffoldProjection) ([]Artifact, error) { + if len(projection.storages) == 0 { + return nil, nil + } + artifacts := make([]Artifact, 0, len(projection.storages)+1) + for _, storage := range projection.storages { + storageFile, err := renderStorage(storage.template) + if err != nil { + return nil, fmt.Errorf("renderStorage: %w", err) + } + artifacts = append(artifacts, Artifact{storage.path, 0o644, []byte(storageFile), false}) + for _, migrationPath := range storage.migrationPaths { + artifacts = append(artifacts, Artifact{ + Path: filepath.ToSlash(filepath.Join(migrationPath, ".gitkeep")), + Mode: 0o644, Content: []byte{}, CreateOnly: false, + }) + } + } + if projection.hasSQLite { + artifacts = append(artifacts, Artifact{"data/.gitkeep", 0o644, []byte{}, false}) + } + return artifacts, nil +} + +func (i *preflightInspector) inspectPath(ctx context.Context, path string, entry fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + relative := filepath.ToSlash(path) + if relative == "." { + return nil + } + if strings.HasPrefix(relative, ".git/") { + if entry.IsDir() { + return filepath.SkipDir + } + return nil + } + clean := filepath.ToSlash(filepath.Clean(relative)) + if artifact, exists := i.planned[clean]; exists { + return i.inspectArtifact(ctx, artifact, clean, relative) + } + return nil +} + +func (i *preflightInspector) inspectArtifact(ctx context.Context, artifact Artifact, clean, relative string) error { + info, err := i.workspace.Lstat(ctx, i.root, clean) + if err != nil { + return fmt.Errorf("workspace.Lstat: %w", err) + } + if info.Mode()&fs.ModeSymlink != 0 || !info.Mode().IsRegular() { + i.conflicts = append(i.conflicts, conflict{code: "wrong_file_kind", path: relative}) + return nil + } + _, err = i.workspace.ReadBytes(ctx, i.root, clean) + if err != nil { + return fmt.Errorf("workspace.ReadBytes: %w", err) + } + return nil +} diff --git a/internal/service/scaffold/planner_characterization_test.go b/internal/service/scaffold/planner_characterization_test.go new file mode 100644 index 0000000..c88f5e6 --- /dev/null +++ b/internal/service/scaffold/planner_characterization_test.go @@ -0,0 +1,177 @@ +package scaffold + +import ( + "fmt" + "io/fs" + "os" + "path/filepath" + "sort" + "testing" + + projectdomain "github.com/devctllabs/devctl/internal/domain/project" + "github.com/stretchr/testify/require" +) + +func TestPlanMatchesGoldenTrees(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + manifest projectdomain.Manifest + seeds []string + }{ + {name: "minimal", manifest: minimalScaffoldManifest(), seeds: []string{ + "README.md", "cmd/sample/main.go", "internal/deps/application.go", + }}, + {name: "full", manifest: fullCharacterizationManifest(), seeds: []string{ + "README.md", + "api/openapi/swagger.yaml", + "cmd/sample-api/internal/api.go", + "cmd/sample-api/internal/consumer.go", + "cmd/sample-api/main.go", + "data/.gitkeep", + "internal/deps/application.go", + "internal/deps/consumer_audit.go", + "internal/deps/consumer_invoice.go", + "internal/transport/consumerkafka/audit/handler.go", + "internal/transport/consumerkafka/invoice/handler.go", + "migrations/analytics/clickhouse/.gitkeep", + }}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + artifacts, err := plan(test.manifest) + require.NoError(t, err) + goldenRoot := filepath.Join("testdata", test.name) + if os.Getenv("UPDATE_GOLDEN") == "1" { + writeGoldenTree(t, goldenRoot, artifacts) + } + expected := readGoldenTree(t, goldenRoot) + + actual := make(map[string]string, len(artifacts)) + actualPaths := make([]string, 0, len(artifacts)) + actualSeeds := make([]string, 0, len(test.seeds)) + for _, artifact := range artifacts { + require.NotContains(t, actual, artifact.Path, "duplicate artifact path") + require.Equal(t, fs.FileMode(0o644), artifact.Mode, artifact.Path) + actual[artifact.Path] = string(artifact.Content) + actualPaths = append(actualPaths, artifact.Path) + if artifact.CreateOnly { + actualSeeds = append(actualSeeds, artifact.Path) + } + } + + expectedPaths := sortedPaths(expected) + require.Equal(t, expectedPaths, actualPaths, "artifact paths and deterministic position") + require.Equal(t, test.seeds, actualSeeds, "artifact ownership") + for _, path := range expectedPaths { + require.Equal(t, expected[path], actual[path], path) + } + }) + } +} + +func writeGoldenTree(t *testing.T, root string, artifacts []Artifact) { + t.Helper() + require.NoError(t, os.RemoveAll(root)) + for _, artifact := range artifacts { + path := filepath.Join(root, filepath.FromSlash(artifact.Path)) + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) + require.NoError(t, os.WriteFile(path, artifact.Content, artifact.Mode)) + } +} + +func readGoldenTree(t *testing.T, root string) map[string]string { + t.Helper() + + contents := make(map[string]string) + err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if entry.IsDir() { + return nil + } + + relativePath, err := filepath.Rel(root, path) + if err != nil { + return fmt.Errorf("filepath.Rel: %w", err) + } + content, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("os.ReadFile: %w", err) + } + contents[filepath.ToSlash(relativePath)] = string(content) + return nil + }) + require.NoError(t, err) + return contents +} + +func sortedPaths(contents map[string]string) []string { + paths := make([]string, 0, len(contents)) + for path := range contents { + paths = append(paths, path) + } + sort.Strings(paths) + return paths +} + +func fullCharacterizationManifest() projectdomain.Manifest { + return projectdomain.Manifest{ + Version: 1, + Project: projectdomain.Identity{Name: "sample-api", Language: "go"}, + Env: projectdomain.Env{Prefix: "SAMPLE_"}, + Sources: map[string]projectdomain.Source{ + "contracts": {Type: projectdomain.SourceLocal, Path: "api/contracts"}, + }, + Components: projectdomain.Components{ + Logging: &projectdomain.Logging{}, + Health: &projectdomain.Health{}, + Telemetry: &projectdomain.Telemetry{}, + HTTP: &projectdomain.HTTP{ + Server: &projectdomain.HTTPServer{OpenAPI: "api/openapi/swagger.yaml"}, + Clients: []projectdomain.HTTPClient{{Name: "catalog", Source: "contracts", Path: "catalog.yaml", BaseURLEnv: "CATALOG_BASE_URL"}}, + }, + GRPC: &projectdomain.GRPC{ + Server: &projectdomain.GRPCServer{ProtoRoot: "api/proto", BufConfig: "buf.yaml"}, + Clients: []projectdomain.GRPCClient{{Name: "billing", Source: "contracts", Path: "billing.proto", AddrEnv: "BILLING_GRPC_ADDR"}}, + }, + Kafka: &projectdomain.Kafka{ + Consumers: []projectdomain.KafkaConsumer{ + {Name: "audit", Topic: "sample.audit.events.v1", Contract: projectdomain.KafkaContract{Format: "raw"}}, + {Name: "invoice", Topic: "sample.invoice.events.v1", Contract: projectdomain.KafkaContract{Format: "proto", Source: "contracts", Path: "invoice.proto"}}, + }, + Producers: []projectdomain.KafkaProducer{{Name: "events", Topic: "sample.events.v1", Contract: projectdomain.KafkaContract{Format: "json", Source: "contracts", Path: "events.json"}}}, + }, + Redis: &projectdomain.Redis{Connections: []projectdomain.RedisConnection{{Name: "cache", AddrDefault: "localhost:6379"}}}, + S3: &projectdomain.S3{ + Connections: []projectdomain.S3Connection{{Name: "default", Region: "us-east-1"}}, + Buckets: []projectdomain.S3Bucket{{Name: "media", Connection: "default", Bucket: "media-local"}}, + }, + DB: &projectdomain.DB{Connections: []projectdomain.DBConnection{{ + Name: "primary", Default: "sqlite", + Variants: []projectdomain.DBVariant{ + {Name: "sqlite", Kind: "sqlite", DSNDefault: "file:./data/app.db?_foreign_keys=on"}, + {Name: "postgres", Kind: "postgres", Secret: true}, + }, + }, { + Name: "analytics", Default: "clickhouse", + Variants: []projectdomain.DBVariant{{Name: "clickhouse", Kind: "clickhouse", DSNDefault: "clickhouse://localhost:9000/default", Secret: true, Migrations: &projectdomain.DBMigrations{ + Path: "migrations/analytics/clickhouse", DatabaseEnv: "DB_ANALYTICS_CLICKHOUSE_MIGRATIONS_URL", + }}}, + }}}, + }, + Languages: projectdomain.Languages{Go: projectdomain.GoLanguage{ + Module: "example.test/sample-api", + Generators: projectdomain.GoGenerators{ + GRPC: &projectdomain.GRPCGenerator{Out: "gen/grpc", BufGenConfig: "tools/buf/grpc.gen.yaml"}, + Kafka: &projectdomain.KafkaGenerator{Out: "gen/kafka", BufGenConfig: "tools/buf/kafka.gen.yaml"}, + }, + Components: projectdomain.GoComponents{Pprof: &projectdomain.Pprof{}}, + }}, + } +} diff --git a/internal/service/scaffold/planner_test.go b/internal/service/scaffold/planner_test.go new file mode 100644 index 0000000..b3ce2c5 --- /dev/null +++ b/internal/service/scaffold/planner_test.go @@ -0,0 +1,746 @@ +package scaffold + +import ( + "go/parser" + "go/token" + "io/fs" + "strings" + "testing" + + "github.com/BurntSushi/toml" + projectdomain "github.com/devctllabs/devctl/internal/domain/project" + "github.com/stretchr/testify/require" + "golang.org/x/mod/modfile" + "gopkg.in/yaml.v3" +) + +func TestPlanAddsPinnedBufToolingForGRPC(t *testing.T) { + t.Parallel() + + manifest := minimalScaffoldManifest() + manifest.Components.GRPC = &projectdomain.GRPC{Server: &projectdomain.GRPCServer{ + ProtoRoot: "api/proto", BufConfig: "buf.yaml", + }} + manifest.Languages.Go.Generators.GRPC = &projectdomain.GRPCGenerator{Out: "gen/grpc", BufGenConfig: "tools/buf/grpc.gen.yaml"} + artifacts := plannedArtifacts(t, manifest) + + goMod, err := modfile.Parse("go.mod", artifacts["go.mod"].Content, nil) + require.NoError(t, err) + tools := make([]string, 0, len(goMod.Tool)) + for _, tool := range goMod.Tool { + tools = append(tools, tool.Path) + } + require.ElementsMatch(t, []string{ + "github.com/bufbuild/buf/cmd/buf", + "google.golang.org/protobuf/cmd/protoc-gen-go", + "google.golang.org/grpc/cmd/protoc-gen-go-grpc", + }, tools) + + var moduleConfig struct { + Modules []struct { + Path string `yaml:"path"` + } `yaml:"modules"` + Lint struct { + Use []string `yaml:"use"` + Except []string `yaml:"except"` + } `yaml:"lint"` + } + require.NoError(t, yaml.Unmarshal(artifacts["buf.yaml"].Content, &moduleConfig)) + require.Equal(t, "api/proto", moduleConfig.Modules[0].Path) + require.Equal(t, []string{"STANDARD"}, moduleConfig.Lint.Use) + require.Equal(t, []string{"FILE_LOWER_SNAKE_CASE"}, moduleConfig.Lint.Except) + + var generationConfig struct { + Plugins []struct { + Local []string `yaml:"local"` + } `yaml:"plugins"` + } + require.NoError(t, yaml.Unmarshal(artifacts["tools/buf/grpc.gen.yaml"].Content, &generationConfig)) + require.Equal(t, [][]string{ + {"go", "tool", "protoc-gen-go"}, + {"go", "tool", "protoc-gen-go-grpc"}, + }, [][]string{generationConfig.Plugins[0].Local, generationConfig.Plugins[1].Local}) +} + +func TestPlanManagesOnlyCanonicalBufGenerationConfigs(t *testing.T) { + t.Parallel() + + manifest := minimalScaffoldManifest() + manifest.Sources = map[string]projectdomain.Source{ + "contracts": {Type: projectdomain.SourceGit, Repo: "example/contracts", Ref: "v1"}, + } + manifest.Components.GRPC = &projectdomain.GRPC{Clients: []projectdomain.GRPCClient{ + {Name: "billing", Source: "contracts", Path: "proto/billing.proto", BufGenConfig: "tools/buf/billing.gen.yaml"}, + {Name: "catalog", Source: "contracts", Path: "proto/catalog.proto", BufGenConfig: "tools/buf/grpc.gen.yaml"}, + {Name: "orders", Source: "contracts", Path: "proto/orders.proto", BufGenConfig: "tools/buf/orders.gen.yaml"}, + }} + + artifacts := plannedArtifacts(t, manifest) + + require.Contains(t, artifacts, "tools/buf/grpc.gen.yaml") + require.NotContains(t, artifacts, "tools/buf/billing.gen.yaml") + require.NotContains(t, artifacts, "tools/buf/orders.gen.yaml") +} + +func TestPlanDoesNotCreateUnusedCanonicalBufConfigForCustomOnlyGRPCTargets(t *testing.T) { + t.Parallel() + + manifest := minimalScaffoldManifest() + manifest.Sources = map[string]projectdomain.Source{ + "contracts": {Type: projectdomain.SourceGit, Repo: "example/contracts", Ref: "v1"}, + } + manifest.Components.GRPC = &projectdomain.GRPC{Clients: []projectdomain.GRPCClient{ + {Name: "billing", Source: "contracts", Path: "proto/billing.proto", BufGenConfig: "tools/buf/billing.gen.yaml"}, + {Name: "orders", Source: "contracts", Path: "proto/orders.proto", BufGenConfig: "tools/buf/orders.gen.yaml"}, + }} + + artifacts := plannedArtifacts(t, manifest) + + require.NotContains(t, artifacts, "tools/buf/grpc.gen.yaml") + require.NotContains(t, artifacts, "tools/buf/billing.gen.yaml") + require.NotContains(t, artifacts, "tools/buf/orders.gen.yaml") +} + +func TestPlanAddsPinnedBufToolingForKafkaProto(t *testing.T) { + t.Parallel() + + manifest := minimalScaffoldManifest() + manifest.Components.Kafka = &projectdomain.Kafka{Producers: []projectdomain.KafkaProducer{{ + Name: "events", Topic: "sample.event.events.v1", + Contract: projectdomain.KafkaContract{Format: "proto", Source: "contracts", Path: "proto/sample.event.events.v1.proto", ProtoRoot: "proto"}, + }}} + manifest.Languages.Go.Generators.Kafka = &projectdomain.KafkaGenerator{Out: "gen/kafka", BufGenConfig: "tools/buf/kafka.gen.yaml"} + artifacts := plannedArtifacts(t, manifest) + + require.Contains(t, artifacts, "tools/buf/kafka.gen.yaml") + goMod, err := modfile.Parse("go.mod", artifacts["go.mod"].Content, nil) + require.NoError(t, err) + tools := make([]string, 0, len(goMod.Tool)) + for _, tool := range goMod.Tool { + tools = append(tools, tool.Path) + } + require.Contains(t, tools, "github.com/bufbuild/buf/cmd/buf") + require.NotContains(t, string(artifacts["go.mod"].Content), "github.com/devctllabs/go-libs/kafkaproto") +} + +func TestPlanAddsKafkaProtoDecoderOnlyForProtoConsumer(t *testing.T) { + t.Parallel() + + manifest := minimalScaffoldManifest() + manifest.Components.Kafka = &projectdomain.Kafka{Consumers: []projectdomain.KafkaConsumer{{ + Name: "events", Topic: "sample.event.events.v1", + Contract: projectdomain.KafkaContract{Format: "proto", Source: "contracts", Path: "proto/sample.event.events.v1.proto"}, + }}} + artifacts := plannedArtifacts(t, manifest) + + requireArtifactContains(t, artifacts, "go.mod", "github.com/devctllabs/go-libs/kafkaproto v0.1.0") + requireArtifactContains(t, artifacts, "internal/deps/kafka_consumers.gen.go", "kafkaproto.NewDecoder", "protoKafkaDecoder") +} + +func TestPlanAddsPinnedQuicktypeToolingForKafkaJSON(t *testing.T) { + t.Parallel() + + manifest := minimalScaffoldManifest() + manifest.Components.Kafka = &projectdomain.Kafka{Producers: []projectdomain.KafkaProducer{{ + Name: "events", Topic: "sample.event.events.v1", + Contract: projectdomain.KafkaContract{Format: "json", Source: "contracts", Path: "json/sample.event.events.v1.json"}, + }}} + artifacts := plannedArtifacts(t, manifest) + + require.NotContains(t, string(artifacts["go.mod"].Content), "go-jsonschema") + var mise struct { + Tools map[string]any `toml:"tools"` + } + _, err := toml.Decode(string(artifacts[".mise.toml"].Content), &mise) + require.NoError(t, err) + require.Equal(t, "24", mise.Tools["node"]) + require.Equal(t, "26.0.0", mise.Tools["npm:quicktype"]) +} + +func TestPlanPinsPublishedGoLibVersionsByModule(t *testing.T) { + t.Parallel() + + artifacts := plannedArtifacts(t, fullCharacterizationManifest()) + goMod := string(artifacts["go.mod"].Content) + + for _, dependency := range []string{ + "github.com/devctllabs/go-libs/lifecycle v0.2.0", + "github.com/devctllabs/go-libs/log v0.2.0", + "github.com/devctllabs/go-libs/oapivalidator v0.2.0", + "github.com/devctllabs/go-libs/postgresdb v0.2.0", + "github.com/devctllabs/go-libs/config v0.1.0", + "github.com/devctllabs/go-libs/di v0.1.0", + "github.com/devctllabs/go-libs/kafka v0.1.0", + "github.com/devctllabs/go-libs/kafkaproto v0.1.0", + "github.com/devctllabs/go-libs/retry v0.1.0", + "github.com/devctllabs/go-libs/sqlitedb v0.1.0", + "github.com/devctllabs/go-libs/txmanager v0.1.0", + "github.com/devctllabs/go-libs/telemetry v0.1.0", + "github.com/devctllabs/go-libs/health v0.1.0", + "github.com/devctllabs/go-libs/healthserver v0.1.0", + "github.com/devctllabs/go-libs/debugserver v0.1.0", + } { + require.Contains(t, goMod, dependency) + } + require.NotContains(t, goMod, "replace ") +} + +func TestPlanIncludesEveryV1ComponentFoundation(t *testing.T) { + t.Parallel() + + manifest := minimalScaffoldManifest() + manifest.Components.GRPC = &projectdomain.GRPC{Server: &projectdomain.GRPCServer{ProtoRoot: "api/proto/grpc", BufConfig: "buf.yaml"}} + manifest.Components.Kafka = &projectdomain.Kafka{ + Consumers: []projectdomain.KafkaConsumer{{Name: "audit", Topic: "sample.audit.events.v1", Contract: projectdomain.KafkaContract{Format: "raw"}}}, + Producers: []projectdomain.KafkaProducer{{Name: "events", Topic: "sample.event.events.v1", Contract: projectdomain.KafkaContract{Format: "raw"}}}, + } + manifest.Components.Redis = &projectdomain.Redis{Connections: []projectdomain.RedisConnection{{Name: "cache"}}} + manifest.Components.S3 = &projectdomain.S3{Connections: []projectdomain.S3Connection{{Name: "default"}}, Buckets: []projectdomain.S3Bucket{{Name: "uploads", Connection: "default"}}} + artifacts := plannedArtifacts(t, manifest) + + for _, filename := range []string{ + "internal/deps/grpc.gen.go", + "internal/deps/kafka_broker.gen.go", + "internal/deps/kafka_consumers.gen.go", + "internal/deps/kafka_producers.gen.go", + "internal/deps/redis.gen.go", + "internal/deps/s3.gen.go", + "cmd/sample/internal/consumer.go", + "internal/transport/consumerkafka/audit/handler.go", + } { + artifact, exists := artifacts[filename] + require.True(t, exists, filename) + _, err := parser.ParseFile(token.NewFileSet(), filename, artifact.Content, parser.AllErrors) + require.NoError(t, err, filename) + } + require.True(t, artifacts["cmd/sample/internal/consumer.go"].CreateOnly) + require.True(t, artifacts["internal/transport/consumerkafka/audit/handler.go"].CreateOnly) +} + +func TestPlanAppliesS3ConnectionRuntimeConfig(t *testing.T) { + t.Parallel() + + manifest := minimalScaffoldManifest() + manifest.Components.S3 = &projectdomain.S3{ + Connections: []projectdomain.S3Connection{{ + Name: "archive", Credentials: "static", Endpoint: "http://localhost:9000", + Region: "us-east-1", PathStyle: true, + }}, + Buckets: []projectdomain.S3Bucket{{Name: "media", Connection: "archive", Bucket: "media-local"}}, + } + artifacts := plannedArtifacts(t, manifest) + + requireArtifactContains(t, artifacts, "internal/deps/s3.gen.go", + `s3ArchiveKey = "s3-connection:archive"`, + "credentials.NewStaticCredentialsProvider", + "cfg.S3.ArchiveAccessKeyID", "cfg.S3.ArchiveSecretAccessKey", + "cfg.S3.ArchiveEndpoint", "options.UsePathStyle = cfg.S3.ArchiveForcePathStyle", + `s3MediaBucketKey = "s3-bucket:media"`, `s3ArchiveKey`, "cfg.S3.MediaBucket", + ) +} + +func TestPlanMakesGeneratedGoOwnershipExplicit(t *testing.T) { + t.Parallel() + + manifest := fullCharacterizationManifest() + manifest.Components.GRPC = &projectdomain.GRPC{Server: &projectdomain.GRPCServer{}} + manifest.Components.Kafka = &projectdomain.Kafka{Consumers: []projectdomain.KafkaConsumer{{ + Name: "audit", Topic: "sample.audit.events.v1", Contract: projectdomain.KafkaContract{Format: "raw"}, + }}} + manifest.Components.Redis = &projectdomain.Redis{Connections: []projectdomain.RedisConnection{{Name: "cache"}}} + manifest.Components.S3 = &projectdomain.S3{Connections: []projectdomain.S3Connection{{Name: "default"}}} + + for path, artifact := range plannedArtifacts(t, manifest) { + if !strings.HasSuffix(path, ".go") { + continue + } + if artifact.CreateOnly { + require.NotContains(t, path, ".gen.go", path) + require.NotContains(t, string(artifact.Content), "Code generated by devctl", path) + continue + } + require.True(t, strings.HasSuffix(path, ".gen.go"), path) + require.True(t, strings.HasPrefix(string(artifact.Content), "// Code generated by devctl. DO NOT EDIT.\n"), path) + } +} + +func TestPlanCreatesProjectReadmeWithRefreshChecklist(t *testing.T) { + t.Parallel() + + artifact := plannedArtifacts(t, fullCharacterizationManifest())["README.md"] + + require.True(t, artifact.CreateOnly) + require.Contains(t, string(artifact.Content), "go run ./cmd/sample-api --help") + require.Contains(t, string(artifact.Content), "devctl sync") + require.Contains(t, string(artifact.Content), "devctl init scaffold") + require.Contains(t, string(artifact.Content), "devctl gen") + require.Contains(t, string(artifact.Content), "*.gen.go") + require.Contains(t, string(artifact.Content), "internal/deps/application.go") +} + +func TestPlanSelectsRuntimeRootComponents(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + configure func(*projectdomain.Manifest) + dependency string + configField string + runtimeField string + unexpectedFields []string + artifactCount int + }{ + { + name: "HTTP server", + configure: func(manifest *projectdomain.Manifest) { + manifest.Components.HTTP = &projectdomain.HTTP{Server: &projectdomain.HTTPServer{}} + }, + dependency: "github.com/labstack/echo/v5 v5.3.1", configField: "HTTPConfig", runtimeField: "http *http.Server", + unexpectedFields: []string{"httpEnabled bool", "healthEnabled bool", "pprofEnabled bool"}, artifactCount: 14, + }, + { + name: "health server", + configure: func(manifest *projectdomain.Manifest) { + manifest.Components.Health = &projectdomain.Health{} + }, + dependency: "github.com/devctllabs/go-libs/healthserver v0.1.0", configField: "HealthConfig", runtimeField: "health *healthserverlib.Server", + unexpectedFields: []string{"httpEnabled bool", "healthEnabled bool", "pprofEnabled bool"}, artifactCount: 12, + }, + { + name: "pprof server", + configure: func(manifest *projectdomain.Manifest) { + manifest.Languages.Go.Components.Pprof = &projectdomain.Pprof{} + }, + dependency: "github.com/devctllabs/go-libs/debugserver v0.1.0", configField: "PprofConfig", runtimeField: "pprof *debugserverlib.Server", + unexpectedFields: []string{"httpEnabled bool", "healthEnabled bool", "pprofEnabled bool"}, artifactCount: 12, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + manifest := minimalScaffoldManifest() + test.configure(&manifest) + artifacts := plannedArtifacts(t, manifest) + + require.Len(t, artifacts, test.artifactCount) + require.Contains(t, artifacts, "cmd/sample/internal/api.go") + requireArtifactContains(t, artifacts, "go.mod", "github.com/devctllabs/go-libs/lifecycle v0.2.0", test.dependency) + requireArtifactContains(t, artifacts, "gen/config/config.gen.go", test.configField) + requireArtifactContains(t, artifacts, "internal/deps/runtime.gen.go", test.runtimeField) + for _, field := range test.unexpectedFields { + require.NotContains(t, string(artifacts["internal/deps/runtime.gen.go"].Content), field) + } + }) + } +} + +func TestPlanRendersLazyRuntimeScenariosAndFailClosedConsumers(t *testing.T) { + t.Parallel() + + disabled := false + enabled := true + manifest := minimalScaffoldManifest() + manifest.Components.HTTP = &projectdomain.HTTP{Server: &projectdomain.HTTPServer{}} + manifest.Components.GRPC = &projectdomain.GRPC{Server: &projectdomain.GRPCServer{Start: &projectdomain.Start{}}} + manifest.Components.Health = &projectdomain.Health{Server: &projectdomain.HealthServer{Start: &projectdomain.Start{Default: &enabled}}} + manifest.Components.Kafka = &projectdomain.Kafka{Consumers: []projectdomain.KafkaConsumer{ + {Name: "audit", Topic: "sample.audit.events.v1", Contract: projectdomain.KafkaContract{Format: "raw"}}, + {Name: "invoice", Topic: "sample.invoice.events.v1", Start: &projectdomain.Start{Default: &disabled}, Contract: projectdomain.KafkaContract{Format: "raw"}}, + }} + artifacts := plannedArtifacts(t, manifest) + + container := string(artifacts["internal/deps/container.gen.go"].Content) + require.Contains(t, container, "func NewAPI(ctx context.Context) (*Scenario, error)") + require.Contains(t, container, "func NewConsumer(ctx context.Context, name string) (*Scenario, error)") + require.Contains(t, container, `case "audit":`) + require.Contains(t, container, `case "invoice":`) + require.Contains(t, container, `if !(cfg.Kafka.InvoiceEnabled)`) + require.Contains(t, container, `di.ResolveNamed[scenarioRunner](graph, kafkaConsumerKey(name))`) + consumerSource := container[strings.Index(container, "func NewConsumer"):] + require.Less(t, strings.Index(consumerSource, "switch name"), strings.Index(consumerSource, "graph := di.New()")) + require.Contains(t, container, "graph.Shutdown(context.WithoutCancel(ctx))") + + runtime := string(artifacts["internal/deps/runtime.gen.go"].Content) + require.Contains(t, runtime, `lifecycle.Task{Name: "http"`) + require.Contains(t, runtime, "if r.grpcEnabled") + require.Contains(t, runtime, "if r.healthEnabled") + require.Contains(t, runtime, "r.grpc.Stop()") + require.Contains(t, runtime, "<-ctx.Done()") + + kafka := string(artifacts["internal/deps/kafka_consumers.gen.go"].Content) + for _, expected := range []string{ + "config.CommitRetry = &config.Retry", "kafka.RejectStop", + "RebalanceTimeout:", "RebalanceDrainTimeout:", "ShutdownTimeout:", + } { + require.Contains(t, kafka, expected) + } + requireArtifactContains(t, artifacts, "gen/config/config.gen.go", + `env:"SAMPLE_KAFKA_AUDIT_TOPIC" default:"sample.audit.events.v1"`, + `env:"SAMPLE_KAFKA_AUDIT_RETRY_MAX_ATTEMPTS" default:"3"`, + `env:"SAMPLE_KAFKA_INVOICE_CONSUMER_ENABLED" default:"false"`, + ) + requireArtifactContains(t, artifacts, "internal/transport/consumerkafka/audit/handler.go", + "retry.Permanent(ErrNotImplemented)", "must not retain batch data", + ) +} + +func TestPlanKeepsTelemetryOutOfServerRoot(t *testing.T) { + t.Parallel() + + manifest := minimalScaffoldManifest() + manifest.Components.Telemetry = &projectdomain.Telemetry{} + artifacts := plannedArtifacts(t, manifest) + + require.Len(t, artifacts, 10) + require.NotContains(t, artifacts, "internal/deps/runtime.gen.go") + require.NotContains(t, artifacts, "cmd/sample/internal/api.go") + requireArtifactContains(t, artifacts, "go.mod", "github.com/devctllabs/go-libs/telemetry v0.1.0") + requireArtifactContains(t, artifacts, "gen/config/config.gen.go", "TelemetryConfig") + requireArtifactContains(t, artifacts, "internal/deps/container.gen.go", "telemetrylib.Open", "cfg.Telemetry.ServiceVersion") + require.NotContains(t, string(artifacts["go.mod"].Content), "github.com/devctllabs/go-libs/lifecycle") +} + +func TestPlanAppliesHTTPArtifactPolicies(t *testing.T) { + t.Parallel() + + t.Run("server and clients", func(t *testing.T) { + t.Parallel() + + manifest := minimalScaffoldManifest() + manifest.Sources = map[string]projectdomain.Source{ + "contracts": {Type: projectdomain.SourceLocal, Path: "api/contracts"}, + } + manifest.Components.HTTP = &projectdomain.HTTP{ + Server: &projectdomain.HTTPServer{OpenAPI: "contracts/service.yaml"}, + Clients: []projectdomain.HTTPClient{ + {Name: "orders", Source: "contracts", Path: "orders.yaml", OAPIConfig: "tools/oapi/orders.yaml"}, + {Name: "catalog", Source: "contracts", Path: "catalog.yaml"}, + }, + } + manifest.Languages.Go.Generators.HTTP = &projectdomain.HTTPGenerator{OAPIConfig: "tools/oapi/custom-server.yaml"} + artifacts := plannedArtifacts(t, manifest) + + require.True(t, artifacts["contracts/service.yaml"].CreateOnly) + require.False(t, artifacts["tools/oapi/custom-server.yaml"].CreateOnly) + require.False(t, artifacts["tools/oapi/clients.catalog.yaml"].CreateOnly) + require.False(t, artifacts["tools/oapi/orders.yaml"].CreateOnly) + require.NotContains(t, artifacts, "tools/oapi/server.yaml") + require.NotContains(t, artifacts, "api/openapi/swagger.yaml") + requireArtifactContains(t, artifacts, "go.mod", "tool github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen") + }) + + t.Run("client only", func(t *testing.T) { + t.Parallel() + + manifest := minimalScaffoldManifest() + manifest.Sources = map[string]projectdomain.Source{ + "contracts": {Type: projectdomain.SourceLocal, Path: "api/contracts"}, + } + manifest.Components.HTTP = &projectdomain.HTTP{Clients: []projectdomain.HTTPClient{{ + Name: "catalog", Source: "contracts", Path: "catalog.yaml", + }}} + artifacts := plannedArtifacts(t, manifest) + + require.Contains(t, artifacts, "tools/oapi/clients.catalog.yaml") + require.NotContains(t, artifacts, "tools/oapi/server.yaml") + require.NotContains(t, artifacts, "api/openapi/swagger.yaml") + require.NotContains(t, artifacts, "internal/deps/runtime.gen.go") + require.NotContains(t, artifacts, "cmd/sample/internal/api.go") + requireArtifactContains(t, artifacts, "go.mod", "tool github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen") + }) +} + +func TestPlanExposesRegistrarsAndRawOutboundClients(t *testing.T) { + t.Parallel() + + manifest := minimalScaffoldManifest() + manifest.Sources = map[string]projectdomain.Source{"contracts": {Type: projectdomain.SourceLocal, Path: "api/contracts"}} + manifest.Components.HTTP = &projectdomain.HTTP{ + Server: &projectdomain.HTTPServer{}, + Clients: []projectdomain.HTTPClient{{Name: "billing", Source: "contracts", Path: "billing.yaml"}}, + } + manifest.Components.GRPC = &projectdomain.GRPC{ + Server: &projectdomain.GRPCServer{}, + Clients: []projectdomain.GRPCClient{{Name: "billing", Source: "contracts", Path: "billing.proto"}}, + } + artifacts := plannedArtifacts(t, manifest) + + requireArtifactContains(t, artifacts, "internal/deps/runtime.gen.go", + "type HTTPRegistrar interface", "RegisterHTTP(*echo.Echo)", + "type GRPCRegistrar interface", "RegisterGRPC(*grpc.Server)", + "httpRegistrar.RegisterHTTP", "grpcRegistrar.RegisterGRPC", + ) + requireArtifactContains(t, artifacts, "internal/deps/http_clients.gen.go", + `httpClientBillingKey = "http-client:billing"`, + "func BillingHTTPTransport(resolver di.Resolver) (*http.Client, error)", + "func BillingHTTPBaseURL(resolver di.Resolver) (string, error)", + ) + requireArtifactContains(t, artifacts, "internal/deps/grpc_clients.gen.go", + `grpcClientBillingKey = "grpc-client:billing"`, + "func BillingGRPCConn(resolver di.Resolver) (*grpc.ClientConn, error)", + ) + require.NotContains(t, string(artifacts["internal/deps/http_clients.gen.go"].Content), "ClientWithResponses") + require.NotContains(t, string(artifacts["internal/deps/grpc_clients.gen.go"].Content), "NewBillingClient") +} + +func TestPlanRendersDatabaseDecisions(t *testing.T) { + t.Parallel() + + t.Run("multiple kinds with telemetry", func(t *testing.T) { + t.Parallel() + + manifest := minimalScaffoldManifest() + manifest.Env.Prefix = "APP_" + manifest.Components.Telemetry = &projectdomain.Telemetry{} + manifest.Components.DB = &projectdomain.DB{Connections: []projectdomain.DBConnection{ + { + Name: "read-model", KindEnv: "DATABASE_DRIVER", + Variants: []projectdomain.DBVariant{{Name: "postgres", Kind: "postgres", DSNEnv: "READ_DATABASE_URL", Secret: true}}, + }, + { + Name: "primary", Default: "local", + Variants: []projectdomain.DBVariant{ + {Name: "local", Kind: "sqlite", DSNDefault: "file:./data/app.db"}, + {Name: "remote", Kind: "postgres", Secret: true}, + }, + }, + }} + artifacts := plannedArtifacts(t, manifest) + + require.Contains(t, artifacts, "internal/deps/storage_primary.gen.go") + require.Contains(t, artifacts, "internal/deps/storage_read_model.gen.go") + require.True(t, artifacts["data/.gitkeep"].CreateOnly) + goMod := string(artifacts["go.mod"].Content) + require.Equal(t, 1, strings.Count(goMod, "github.com/devctllabs/go-libs/sqlitedb v0.1.0")) + require.Equal(t, 1, strings.Count(goMod, "github.com/devctllabs/go-libs/postgresdb v0.2.0")) + require.Equal(t, 1, strings.Count(goMod, "github.com/devctllabs/go-libs/txmanager v0.1.0")) + requireArtifactContains(t, artifacts, "gen/config/config.gen.go", + `env:"APP_DATABASE_DRIVER" default:"postgres"`, + `env:"APP_READ_DATABASE_URL"`, + "DBPrimary DBPrimaryConfig", + `env:"APP_DB_PRIMARY_KIND" default:"local"`, + ) + requireArtifactContains(t, artifacts, "internal/deps/storage_primary.gen.go", `case "local":`, `case "remote":`, "cfg.DBPrimary.Kind", "telemetryRuntime.TracerProvider()") + requireArtifactContains(t, artifacts, "internal/deps/application.go", "provideStorageReadModel", "provideStoragePrimary") + }) + + t.Run("postgres does not create sqlite data directory", func(t *testing.T) { + t.Parallel() + + manifest := minimalScaffoldManifest() + manifest.Components.DB = &projectdomain.DB{Connections: []projectdomain.DBConnection{{ + Name: "primary", Variants: []projectdomain.DBVariant{{Name: "postgres", Kind: "postgres", Secret: true}}, + }}} + artifacts := plannedArtifacts(t, manifest) + + require.NotContains(t, artifacts, "data/.gitkeep") + requireArtifactContains(t, artifacts, "gen/config/config.gen.go", `default:"postgres"`, `env:"SAMPLE_DB_PRIMARY_POSTGRES_DSN"`) + require.NotContains(t, string(artifacts["internal/deps/storage_primary.gen.go"].Content), "sqlitedb") + }) + + t.Run("native clickhouse keeps telemetry out and uses namespaced health key", func(t *testing.T) { + t.Parallel() + + manifest := minimalScaffoldManifest() + manifest.Components.Health = &projectdomain.Health{} + manifest.Components.Telemetry = &projectdomain.Telemetry{} + manifest.Components.DB = &projectdomain.DB{Connections: []projectdomain.DBConnection{{ + Name: "analytics", Default: "clickhouse", + Variants: []projectdomain.DBVariant{{Name: "clickhouse", Kind: "clickhouse", DSNDefault: "clickhouse://localhost:9000/default"}}, + }}} + artifacts := plannedArtifacts(t, manifest) + + storage := string(artifacts["internal/deps/storage_analytics.gen.go"].Content) + require.Contains(t, storage, `storageAnalyticsConnectionName = "db-connection:analytics"`) + for _, operation := range []string{"clickhouse.ParseDSN", "clickhouse.Open", "connection.Ping", "connection.Close"} { + require.Contains(t, storage, operation) + } + require.NotContains(t, storage, "telemetrylib") + require.NotContains(t, storage, "txmanager") + requireArtifactContains(t, artifacts, "internal/deps/runtime.gen.go", + `di.ResolveNamed[dbChecker](resolver, "db-connection:analytics")`, + ) + }) +} + +func TestPlanScaffoldsMigrationTargetsAndMiseTasks(t *testing.T) { + t.Parallel() + + manifest := minimalScaffoldManifest() + manifest.Env.Prefix = "APP_" + manifest.Components.DB = &projectdomain.DB{Connections: []projectdomain.DBConnection{ + {Name: "archive", Variants: []projectdomain.DBVariant{{ + Name: "postgres", Kind: "postgres", Migrations: &projectdomain.DBMigrations{ + Path: "db/archive", DatabaseEnv: "ARCHIVE_MIGRATIONS_URL", + }, + }}}, + {Name: "primary", Variants: []projectdomain.DBVariant{{ + Name: "sqlite", Kind: "sqlite", Migrations: &projectdomain.DBMigrations{ + Path: "migrations/primary/sqlite", DatabaseEnv: "DB_PRIMARY_SQLITE_MIGRATIONS_URL", + DatabaseDefault: "sqlite://./data/primary.db?_pragma=foreign_keys%281%29", + }, + }}}, + }} + artifacts := plannedArtifacts(t, manifest) + + require.True(t, artifacts["db/archive/.gitkeep"].CreateOnly) + require.True(t, artifacts["migrations/primary/sqlite/.gitkeep"].CreateOnly) + var mise struct { + Tools map[string]toml.Primitive `toml:"tools"` + Tasks map[string]struct { + Run string `toml:"run"` + Usage string `toml:"usage"` + Confirm any `toml:"confirm"` + } `toml:"tasks"` + } + metadata, err := toml.Decode(string(artifacts[".mise.toml"].Content), &mise) + require.NoError(t, err) + var migrateTool struct { + Version string `toml:"version"` + Tags []string `toml:"tags"` + } + require.NoError(t, metadata.PrimitiveDecode(mise.Tools["go:github.com/golang-migrate/migrate/v4/cmd/migrate"], &migrateTool)) + require.Equal(t, "v4.19.1", migrateTool.Version) + require.Equal(t, []string{"postgres", "sqlite"}, migrateTool.Tags) + require.Equal(t, `arg "" help="Migration name"`, mise.Tasks["migrate:primary:sqlite:create"].Usage) + require.Contains(t, mise.Tasks["migrate:primary:sqlite:create"].Run, `-format "20060102150405" "${usage_name?}"`) + require.Contains(t, mise.Tasks["migrate:primary:sqlite:up"].Run, `${APP_DB_PRIMARY_SQLITE_MIGRATIONS_URL:-sqlite://./data/primary.db?_pragma=foreign_keys%281%29}`) + require.Equal(t, `arg "[steps]" default="1" help="Number of migrations"`, mise.Tasks["migrate:primary:sqlite:down"].Usage) + require.NotNil(t, mise.Tasks["migrate:primary:sqlite:down"].Confirm) + require.Contains(t, mise.Tasks["migrate:archive:postgres:up"].Run, `${APP_ARCHIVE_MIGRATIONS_URL:?set APP_ARCHIVE_MIGRATIONS_URL}`) + require.NotContains(t, mise.Tasks["migrate:archive:postgres:down"].Run, "-all") +} + +func TestPlanUsesFallbackEnvironmentPrefix(t *testing.T) { + t.Parallel() + + manifest := minimalScaffoldManifest() + manifest.Project.Name = "sample-api" + manifest.Components.Logging = &projectdomain.Logging{} + artifacts := plannedArtifacts(t, manifest) + + requireArtifactContains(t, artifacts, "gen/config/config.gen.go", `env:"SAMPLE_API_LOG_LEVEL"`) +} + +func TestPlanUsesCanonicalGeneratedRuntimeConfig(t *testing.T) { + t.Parallel() + + manifest := minimalScaffoldManifest() + manifest.Components.HTTP = &projectdomain.HTTP{ + Server: &projectdomain.HTTPServer{Start: &projectdomain.Start{}}, + Env: projectdomain.ComponentEnv{System: []projectdomain.EnvVar{{ + Key: "HTTP_ADDR", Type: "string", Default: ":8080", + }}}, + } + artifacts := plannedArtifacts(t, manifest) + + requireArtifactContains(t, artifacts, "gen/config/config.gen.go", + "type Config struct", "HTTP HTTPConfig", "Enabled bool", "Address string", + `env:"SAMPLE_HTTP_SERVER_ENABLED" default:"false"`, + `env:"SAMPLE_HTTP_ADDR" default:":8080"`, + ) + requireArtifactContains(t, artifacts, ".env.example", + "SAMPLE_HTTP_ADDR=:8080", "SAMPLE_HTTP_SERVER_ENABLED=false", + ) + requireArtifactContains(t, artifacts, "internal/deps/config.gen.go", + `generatedconfig "example.test/sample/gen/config"`, + "type Config = generatedconfig.Config", + ) + requireArtifactContains(t, artifacts, "internal/deps/runtime.gen.go", + "cfg.HTTP.Address", "cfg.HTTP.Enabled", + ) +} + +func TestPlanRejectsDuplicateArtifactPaths(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + configure func(*projectdomain.Manifest) + path string + }{ + { + name: "OpenAPI seed collides with base artifact", + configure: func(manifest *projectdomain.Manifest) { + manifest.Components.HTTP = &projectdomain.HTTP{Server: &projectdomain.HTTPServer{OpenAPI: "api/../go.mod"}} + }, + path: "go.mod", + }, + { + name: "client generator configs collide", + configure: func(manifest *projectdomain.Manifest) { + manifest.Sources = map[string]projectdomain.Source{ + "contracts": {Type: projectdomain.SourceLocal, Path: "api/contracts"}, + } + manifest.Components.HTTP = &projectdomain.HTTP{Clients: []projectdomain.HTTPClient{ + {Name: "catalog", Source: "contracts", Path: "catalog.yaml", OAPIConfig: "tools/oapi/shared.yaml"}, + {Name: "orders", Source: "contracts", Path: "orders.yaml", OAPIConfig: "tools/oapi/shared.yaml"}, + }} + }, + path: "tools/oapi/shared.yaml", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + manifest := minimalScaffoldManifest() + test.configure(&manifest) + + artifacts, err := plan(manifest) + + require.ErrorContains(t, err, "duplicate artifact path") + require.ErrorContains(t, err, test.path) + require.Nil(t, artifacts) + }) + } +} + +func minimalScaffoldManifest() projectdomain.Manifest { + return projectdomain.Manifest{ + Version: 1, + Project: projectdomain.Identity{Name: "sample", Language: "go"}, + Languages: projectdomain.Languages{Go: projectdomain.GoLanguage{Module: "example.test/sample"}}, + } +} + +func TestRenderPolicies(t *testing.T) { + t.Parallel() + + t.Run("Go names", func(t *testing.T) { + t.Parallel() + require.Equal(t, "Primary", goName("primary")) + require.Equal(t, "ReadModel", goName("read-model")) + }) + +} + +func plannedArtifacts(t *testing.T, manifest projectdomain.Manifest) map[string]Artifact { + t.Helper() + + planned, err := plan(manifest) + require.NoError(t, err) + artifacts := make(map[string]Artifact, len(planned)) + for index, artifact := range planned { + if index > 0 { + require.Less(t, planned[index-1].Path, artifact.Path, "artifact paths must be sorted") + } + require.NotContains(t, artifacts, artifact.Path, "duplicate artifact path") + require.Equal(t, fs.FileMode(0o644), artifact.Mode, artifact.Path) + artifacts[artifact.Path] = artifact + } + return artifacts +} + +func requireArtifactContains(t *testing.T, artifacts map[string]Artifact, path string, values ...string) { + t.Helper() + + artifact, exists := artifacts[path] + require.True(t, exists, path) + for _, value := range values { + require.Contains(t, string(artifact.Content), value, path) + } +} diff --git a/internal/service/scaffold/ports.go b/internal/service/scaffold/ports.go new file mode 100644 index 0000000..7206eae --- /dev/null +++ b/internal/service/scaffold/ports.go @@ -0,0 +1,29 @@ +package scaffold + +import ( + "context" + "io/fs" + + "github.com/devctllabs/devctl/internal/domain/artifact" + projectdomain "github.com/devctllabs/devctl/internal/domain/project" +) + +//go:generate go tool mockgen -destination mocks/ports.go -package mocks -typed . ProjectRepository,WorkspaceRepository + +// ProjectRepository resolves the valid project selected for scaffolding. +type ProjectRepository interface { + // LoadProject returns a structurally and semantically valid project or an execution error. + LoadProject(ctx context.Context, manifestPath string) (projectdomain.Project, error) +} + +// WorkspaceRepository exposes contained file facts and per-file atomic publication. +type WorkspaceRepository interface { + // Walk visits project entries below root without escaping its containment boundary. + Walk(ctx context.Context, root string, visit fs.WalkDirFunc) error + // Lstat reports file metadata without following the final symlink named by name. + Lstat(ctx context.Context, root, name string) (fs.FileInfo, error) + // ReadBytes reads a contained project file below root. + ReadBytes(ctx context.Context, root, name string) ([]byte, error) + // PublishFile atomically publishes content at target below root and reports whether bytes changed. + PublishFile(ctx context.Context, root, target string, content []byte) (artifact.PublishResult, error) +} diff --git a/internal/service/scaffold/projection.go b/internal/service/scaffold/projection.go new file mode 100644 index 0000000..2936501 --- /dev/null +++ b/internal/service/scaffold/projection.go @@ -0,0 +1,627 @@ +package scaffold + +import ( + "path" + "sort" + "strings" + + projectdomain "github.com/devctllabs/devctl/internal/domain/project" +) + +type scaffoldProjection struct { + projectName string + module string + hasServer bool + container containerTemplateData + application applicationTemplateData + runtime runtimeTemplateData + config configProjection + http httpProjection + proto protoProjection + components componentProjection + storages []storageArtifactProjection + hasSQLite bool + goMod goModTemplateData + mise miseTemplateData + main mainTemplateData + seedPaths map[string]struct{} +} + +func compileScaffoldProjection(manifest projectdomain.Manifest) scaffoldProjection { + hasServer := manifestHasServer(manifest) + kafkaProto := manifestHasKafkaFormat(manifest, "proto") + kafkaJSON := manifestHasKafkaFormat(manifest, "json") + targets := projectdomain.NewTargetCatalog(manifest) + runtimeCatalog, runtimeErr := projectdomain.NewRuntimeConfigCatalog(manifest) + configTargets := targets.Select(projectdomain.TargetOperationGenerate, "config", "") + config := configProjection{catalog: runtimeCatalog, catalogErr: runtimeErr} + if len(configTargets) == 1 { + config.targetAvailable = true + config.target = configTargets[0] + config.importPath = path.Join(manifest.Languages.Go.Module, configTargets[0].OutputDir) + } + httpTargets := targets.Select(projectdomain.TargetOperationGenerate, "http", "") + proto := compileProtoProjection(manifest, targets, kafkaProto) + components := compileComponentProjection(manifest) + storages, hasSQLite := compileStorageProjections(manifest) + projection := scaffoldProjection{ + projectName: manifest.Project.Name, + module: manifest.Languages.Go.Module, + hasServer: hasServer, + container: compileContainerTemplate(manifest, hasServer), + application: compileApplicationTemplate(manifest, hasServer), + runtime: compileRuntimeTemplate(manifest), + config: config, + http: httpProjection{enabled: manifest.Components.HTTP != nil, targets: httpTargets}, + proto: proto, + components: components, + storages: storages, + hasSQLite: hasSQLite, + goMod: compileGoModTemplate(manifest, hasServer, kafkaProto), + mise: compileMiseTemplate(manifest, kafkaJSON), + main: mainTemplateData{ + Project: manifest.Project.Name, Module: manifest.Languages.Go.Module, + Server: hasServer, Kafka: manifest.Components.Kafka != nil, + }, + } + projection.seedPaths = compileSeedPaths(projection) + return projection +} + +func compileSeedPaths(projection scaffoldProjection) map[string]struct{} { + paths := map[string]struct{}{ + "README.md": {}, + "internal/deps/application.go": {}, + path.Join("cmd", projection.projectName, "main.go"): {}, + } + if projection.hasServer { + paths[path.Join("cmd", projection.projectName, "internal", "api.go")] = struct{}{} + } + for _, target := range projection.http.targets { + if target.Role == "server" { + paths[target.Reference.Entrypoint] = struct{}{} + } + } + if kafka := projection.components.kafka; kafka != nil { + paths[path.Join("cmd", kafka.projectName, "internal", "consumer.go")] = struct{}{} + for _, fact := range kafka.consumerSeedFacts { + paths[path.Join("internal", "deps", "consumer_"+fact.packageName+".go")] = struct{}{} + paths[path.Join("internal", "transport", "consumerkafka", fact.packageName, "handler.go")] = struct{}{} + } + } + for _, storage := range projection.storages { + for _, migrationPath := range storage.migrationPaths { + paths[path.Join(migrationPath, ".gitkeep")] = struct{}{} + } + } + if projection.hasSQLite { + paths["data/.gitkeep"] = struct{}{} + } + return paths +} + +func (p scaffoldProjection) scaffoldSeed(artifactPath string) bool { + _, exists := p.seedPaths[canonicalArtifactPath(artifactPath)] + return exists +} + +type componentProjection struct { + grpcEnabled bool + grpcClients []projectdomain.GRPCClient + httpClients []projectdomain.HTTPClient + redisEnabled bool + redisConnections []projectdomain.RedisConnection + s3 *projectdomain.S3 + kafka *kafkaProjection +} + +type kafkaProjection struct { + module string + projectName string + consumers []kafkaConsumerTemplateData + producers []projectdomain.KafkaProducer + consumerSeedFacts []kafkaConsumerSeedFact +} + +type kafkaConsumerSeedFact struct { + packageName string + data kafkaConsumerTemplateData +} + +func compileComponentProjection(manifest projectdomain.Manifest) componentProjection { + projection := componentProjection{ + grpcEnabled: manifest.Components.GRPC != nil, + redisEnabled: manifest.Components.Redis != nil, + } + if manifest.Components.GRPC != nil { + projection.grpcClients = append([]projectdomain.GRPCClient(nil), manifest.Components.GRPC.Clients...) + } + if manifest.Components.HTTP != nil { + projection.httpClients = append([]projectdomain.HTTPClient(nil), manifest.Components.HTTP.Clients...) + } + if manifest.Components.Redis != nil { + projection.redisConnections = append([]projectdomain.RedisConnection(nil), manifest.Components.Redis.Connections...) + } + if manifest.Components.S3 != nil { + storage := *manifest.Components.S3 + storage.Connections = append([]projectdomain.S3Connection(nil), storage.Connections...) + storage.Buckets = append([]projectdomain.S3Bucket(nil), storage.Buckets...) + projection.s3 = &storage + } + if manifest.Components.Kafka != nil { + kafka := &kafkaProjection{ + module: manifest.Languages.Go.Module, + projectName: manifest.Project.Name, + producers: append([]projectdomain.KafkaProducer(nil), manifest.Components.Kafka.Producers...), + } + kafka.consumers = make([]kafkaConsumerTemplateData, len(manifest.Components.Kafka.Consumers)) + kafka.consumerSeedFacts = make([]kafkaConsumerSeedFact, len(manifest.Components.Kafka.Consumers)) + for index, consumer := range manifest.Components.Kafka.Consumers { + data := kafkaConsumerTemplateData{ + Name: consumer.Name, Topic: consumer.Topic, Toggle: consumer.Start != nil, + Format: consumer.Contract.Format, Module: manifest.Languages.Go.Module, + } + kafka.consumers[index] = data + kafka.consumerSeedFacts[index] = kafkaConsumerSeedFact{ + packageName: strings.ReplaceAll(consumer.Name, "-", "_"), data: data, + } + } + projection.kafka = kafka + } + return projection +} + +type storageArtifactProjection struct { + path string + template storageTemplateData + migrationPaths []string +} + +func compileStorageProjections(manifest projectdomain.Manifest) ([]storageArtifactProjection, bool) { + if manifest.Components.DB == nil { + return nil, false + } + connections := append([]projectdomain.DBConnection(nil), manifest.Components.DB.Connections...) + sort.Slice(connections, func(i, j int) bool { return connections[i].Name < connections[j].Name }) + projections := make([]storageArtifactProjection, 0, len(connections)) + hasSQLite := false + for _, connection := range connections { + projection := storageArtifactProjection{ + path: "internal/deps/storage_" + strings.ReplaceAll(connection.Name, "-", "_") + ".gen.go", + template: compileStorageTemplate(connection, manifest.Components.Telemetry != nil), + } + for _, variant := range connection.Variants { + hasSQLite = hasSQLite || variant.Kind == "sqlite" + if variant.Migrations != nil { + projection.migrationPaths = append(projection.migrationPaths, variant.Migrations.Path) + } + } + projections = append(projections, projection) + } + return projections, hasSQLite +} + +func compileStorageTemplate(connection projectdomain.DBConnection, telemetry bool) storageTemplateData { + data := storageTemplateData{Name: goName(connection.Name), Connection: connection.Name, Telemetry: telemetry} + seenKinds := make(map[string]bool, len(connection.Variants)) + data.Variants = make([]storageVariant, 0, len(connection.Variants)) + for _, variant := range connection.Variants { + if variant.Kind == "clickhouse" { + data.ClickHouse = true + data.ClickHouseConfig = goName(variant.Name) + "DSN" + data.Variants = append(data.Variants, storageVariant{ + Name: variant.Name, Kind: variant.Kind, + ConfigField: goName(variant.Name) + "DSN", ClickHouse: true, + }) + continue + } + field := strings.ToLower(variant.Kind[:1]) + variant.Kind[1:] + if !seenKinds[variant.Kind] { + data.Kinds = append(data.Kinds, storageKind{Name: variant.Kind, Field: field}) + seenKinds[variant.Kind] = true + } + data.Variants = append(data.Variants, storageVariant{ + Name: variant.Name, Kind: variant.Kind, Field: field, + ConfigField: goName(variant.Name) + "DSN", + SQLite: variant.Kind == "sqlite", + }) + } + return data +} + +type httpProjection struct { + enabled bool + targets []projectdomain.Target +} + +type protoProjection struct { + enabled bool + configPaths []string + grpcModule *grpcModuleProjection +} + +type grpcModuleProjection struct { + path string + protoRoot string +} + +func compileProtoProjection( + manifest projectdomain.Manifest, + targets projectdomain.TargetCatalog, + kafkaProto bool, +) protoProjection { + projection := protoProjection{enabled: manifest.Components.GRPC != nil || kafkaProto} + seen := make(map[string]struct{}, 2) + for _, target := range targets.Select(projectdomain.TargetOperationGenerate, "grpc", "") { + if target.Config == "tools/buf/grpc.gen.yaml" { + seen[target.Config] = struct{}{} + } + } + for _, target := range targets.Select(projectdomain.TargetOperationGenerate, "kafka", "") { + if target.Format == "proto" && target.Config == "tools/buf/kafka.gen.yaml" { + seen[target.Config] = struct{}{} + } + } + projection.configPaths = make([]string, 0, len(seen)) + for configPath := range seen { + projection.configPaths = append(projection.configPaths, configPath) + } + sort.Strings(projection.configPaths) + if manifest.Components.GRPC != nil && manifest.Components.GRPC.Server != nil { + server := manifest.Components.GRPC.Server + projection.grpcModule = &grpcModuleProjection{ + path: valueOrDefault(server.BufConfig, "buf.yaml"), + protoRoot: valueOrDefault(server.ProtoRoot, "api/proto/grpc"), + } + } + return projection +} + +func compileGoModTemplate( + manifest projectdomain.Manifest, + hasServer bool, + kafkaProto bool, +) goModTemplateData { + requires := []string{ + "github.com/devctllabs/go-libs/config v0.1.0", + "github.com/devctllabs/go-libs/di v0.1.0", + "github.com/urfave/cli/v3 v3.10.1", + } + if manifest.Components.Logging != nil { + requires = append(requires, "github.com/devctllabs/go-libs/log v0.2.0", "go.uber.org/zap v1.28.0") + } + if hasServer { + requires = append(requires, "github.com/devctllabs/go-libs/lifecycle v0.2.0") + } + if manifest.Components.HTTP != nil && manifest.Components.HTTP.Server != nil { + requires = append(requires, "github.com/devctllabs/go-libs/oapivalidator v0.2.0", "github.com/labstack/echo/v5 v5.3.1") + } + if manifest.Components.Health != nil { + requires = append(requires, "github.com/devctllabs/go-libs/health v0.1.0", "github.com/devctllabs/go-libs/healthserver v0.1.0") + } + if manifest.Components.Telemetry != nil { + requires = append(requires, "github.com/devctllabs/go-libs/telemetry v0.1.0") + } + if manifest.Languages.Go.Components.Pprof != nil { + requires = append(requires, "github.com/devctllabs/go-libs/debugserver v0.1.0") + } + if manifest.Components.GRPC != nil { + requires = append(requires, + "github.com/bufbuild/buf v1.72.0", + "google.golang.org/grpc v1.83.2", + "google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.6.2", + "google.golang.org/protobuf v1.36.12", + ) + } + if manifest.Components.Kafka != nil { + requires = append(requires, + "github.com/devctllabs/go-libs/kafka v0.1.0", + "github.com/devctllabs/go-libs/retry v0.1.0", + "github.com/twmb/franz-go v1.21.6", + ) + if manifestHasKafkaConsumerFormat(manifest, "proto") { + requires = append(requires, "github.com/devctllabs/go-libs/kafkaproto v0.1.0") + } + } + if manifest.Components.Redis != nil { + requires = append(requires, "github.com/redis/go-redis/v9 v9.22.0") + } + if manifest.Components.S3 != nil { + requires = append(requires, + "github.com/aws/aws-sdk-go-v2 v1.45.1", + "github.com/aws/aws-sdk-go-v2/config v1.33.1", + "github.com/aws/aws-sdk-go-v2/credentials v1.20.1", + "github.com/aws/aws-sdk-go-v2/service/s3 v1.109.1", + ) + } + if manifest.Components.DB != nil { + for _, connection := range manifest.Components.DB.Connections { + for _, variant := range connection.Variants { + requires = append(requires, databaseVariantRequirements(variant.Kind)...) + } + } + } + sort.Strings(requires) + return goModTemplateData{ + Module: manifest.Languages.Go.Module, Requires: unique(requires), + HTTP: manifest.Components.HTTP != nil, Proto: manifest.Components.GRPC != nil || kafkaProto, + } +} + +func databaseVariantRequirements(kind string) []string { + if kind == "clickhouse" { + return []string{"github.com/ClickHouse/clickhouse-go/v2 v2.48.0"} + } + version := "v0.1.0" + if kind == "postgres" { + version = "v0.2.0" + } + return []string{ + "github.com/devctllabs/go-libs/txmanager v0.1.0", + "github.com/devctllabs/go-libs/" + kind + "db " + version, + } +} + +func compileMiseTemplate(manifest projectdomain.Manifest, kafkaJSON bool) miseTemplateData { + prefix := manifest.Env.Prefix + if prefix == "" { + prefix = strings.ToUpper(strings.ReplaceAll(manifest.Project.Name, "-", "_")) + "_" + } + migrations, tags := compileMigrationTasks(manifest.Components.DB, prefix) + return miseTemplateData{JSON: kafkaJSON, Migrations: migrations, MigrationTags: tags} +} + +func compileMigrationTasks(database *projectdomain.DB, prefix string) ([]migrationTask, []string) { + if database == nil { + return nil, nil + } + var migrations []migrationTask + migrationKinds := make(map[string]struct{}) + for _, connection := range database.Connections { + connectionTasks := compileConnectionMigrationTasks(connection, prefix) + migrations = append(migrations, connectionTasks...) + for _, task := range connectionTasks { + migrationKinds[task.Kind] = struct{}{} + } + } + sort.Slice(migrations, func(i, j int) bool { return migrations[i].TaskPrefix < migrations[j].TaskPrefix }) + tags := sortedKeys(migrationKinds) + return migrations, tags +} + +func compileConnectionMigrationTasks(connection projectdomain.DBConnection, prefix string) []migrationTask { + var tasks []migrationTask + for _, variant := range connection.Variants { + if task, exists := compileMigrationTask(connection.Name, variant, prefix); exists { + tasks = append(tasks, task) + } + } + return tasks +} + +func compileMigrationTask( + connectionName string, + variant projectdomain.DBVariant, + prefix string, +) (migrationTask, bool) { + if variant.Migrations == nil { + return migrationTask{}, false + } + environment := prefix + variant.Migrations.DatabaseEnv + expression := "${" + environment + ":?set " + environment + "}" + if variant.Migrations.DatabaseDefault != "" { + expression = "${" + environment + ":-" + variant.Migrations.DatabaseDefault + "}" + } + return migrationTask{ + TaskPrefix: "migrate:" + connectionName + ":" + variant.Name, + Path: variant.Migrations.Path, DatabaseExpression: expression, Kind: variant.Kind, + }, true +} + +func sortedKeys[Value any](values map[string]Value) []string { + keys := make([]string, 0, len(values)) + for key := range values { + keys = append(keys, key) + } + sort.Strings(keys) + return keys +} + +func manifestHasKafkaFormat(manifest projectdomain.Manifest, format string) bool { + if manifest.Components.Kafka == nil { + return false + } + for _, consumer := range manifest.Components.Kafka.Consumers { + if consumer.Contract.Format == format { + return true + } + } + for _, producer := range manifest.Components.Kafka.Producers { + if producer.Contract.Format == format { + return true + } + } + return false +} + +func manifestHasKafkaConsumerFormat(manifest projectdomain.Manifest, format string) bool { + if manifest.Components.Kafka == nil { + return false + } + for _, consumer := range manifest.Components.Kafka.Consumers { + if consumer.Contract.Format == format { + return true + } + } + return false +} + +func compileContainerTemplate(manifest projectdomain.Manifest, hasServer bool) containerTemplateData { + data := containerTemplateData{ + Project: manifest.Project.Name, + Logging: manifest.Components.Logging != nil, + Telemetry: manifest.Components.Telemetry != nil, + Database: manifest.Components.DB != nil, + Server: hasServer, + Kafka: manifest.Components.Kafka != nil, + } + if manifest.Components.Telemetry != nil { + data.TelemetryToggle = manifest.Components.Telemetry.Start != nil + } + if manifest.Components.DB != nil { + data.RuntimeUsesResolver = manifest.Components.Health != nil && len(manifest.Components.DB.Connections) > 0 + data.Connections = make([]containerConnection, 0, len(manifest.Components.DB.Connections)) + for _, connection := range manifest.Components.DB.Connections { + data.Connections = append(data.Connections, containerConnection{ + Name: goName(connection.Name), Connection: connection.Name, + }) + } + } + if manifest.Components.Kafka != nil { + for _, consumer := range manifest.Components.Kafka.Consumers { + enabled := "true" + if consumer.Start != nil { + enabled = "cfg.Kafka." + goName(consumer.Name) + "Enabled" + } + data.Consumers = append(data.Consumers, containerConsumer{Name: consumer.Name, Enabled: enabled}) + } + } + return data +} + +func compileApplicationTemplate(manifest projectdomain.Manifest, hasServer bool) applicationTemplateData { + return applicationTemplateData{ + HTTP: manifest.Components.HTTP != nil && manifest.Components.HTTP.Server != nil, + GRPC: manifest.Components.GRPC != nil && manifest.Components.GRPC.Server != nil, + Calls: applicationProviderCalls(manifest, hasServer), + } +} + +func applicationProviderCalls(manifest projectdomain.Manifest, hasServer bool) []string { + var calls []string + if manifest.Components.Logging != nil { + calls = append(calls, "provideLogging") + } + if manifest.Components.Telemetry != nil { + calls = append(calls, "provideTelemetry") + } + calls = append(calls, databaseProviderCalls(manifest.Components.DB)...) + calls = append(calls, kafkaProviderCalls(manifest.Components.Kafka)...) + calls = append(calls, httpClientProviderCalls(manifest.Components.HTTP)...) + calls = append(calls, grpcClientProviderCalls(manifest.Components.GRPC)...) + calls = append(calls, redisProviderCalls(manifest.Components.Redis)...) + calls = append(calls, s3ProviderCalls(manifest.Components.S3)...) + if hasServer { + calls = append(calls, "provideRuntime") + } + return calls +} + +func databaseProviderCalls(database *projectdomain.DB) []string { + if database == nil { + return nil + } + calls := make([]string, 0, len(database.Connections)) + for _, connection := range database.Connections { + calls = append(calls, "provideStorage"+goName(connection.Name)) + } + return calls +} + +func kafkaProviderCalls(broker *projectdomain.Kafka) []string { + if broker == nil { + return nil + } + calls := make([]string, 0, len(broker.Consumers)+len(broker.Producers)) + for _, consumer := range broker.Consumers { + calls = append(calls, "provide"+goName(consumer.Name)+"Consumer") + } + for _, producer := range broker.Producers { + calls = append(calls, "provide"+goName(producer.Name)+"KafkaProducer") + } + return calls +} + +func httpClientProviderCalls(http *projectdomain.HTTP) []string { + if http == nil { + return nil + } + calls := make([]string, 0, len(http.Clients)) + for _, client := range http.Clients { + calls = append(calls, "provide"+goName(client.Name)+"HTTPClient") + } + return calls +} + +func grpcClientProviderCalls(grpc *projectdomain.GRPC) []string { + if grpc == nil { + return nil + } + calls := make([]string, 0, len(grpc.Clients)) + for _, client := range grpc.Clients { + calls = append(calls, "provide"+goName(client.Name)+"GRPCClient") + } + return calls +} + +func redisProviderCalls(redis *projectdomain.Redis) []string { + if redis == nil { + return nil + } + calls := make([]string, 0, len(redis.Connections)) + for _, connection := range redis.Connections { + calls = append(calls, "provideRedis"+goName(connection.Name)) + } + return calls +} + +func s3ProviderCalls(storage *projectdomain.S3) []string { + if storage == nil { + return nil + } + calls := make([]string, 0, len(storage.Connections)+len(storage.Buckets)) + for _, connection := range storage.Connections { + calls = append(calls, "provideS3"+goName(connection.Name)) + } + for _, bucket := range storage.Buckets { + calls = append(calls, "provideS3"+goName(bucket.Name)+"Bucket") + } + return calls +} + +func compileRuntimeTemplate(manifest projectdomain.Manifest) runtimeTemplateData { + data := runtimeTemplateData{ + HTTP: manifest.Components.HTTP != nil && manifest.Components.HTTP.Server != nil, + GRPC: manifest.Components.GRPC != nil && manifest.Components.GRPC.Server != nil, + Health: manifest.Components.Health != nil, + Pprof: manifest.Languages.Go.Components.Pprof != nil, + } + if data.HTTP { + data.HTTPToggle = manifest.Components.HTTP.Server.Start != nil + } + if data.GRPC { + data.GRPCToggle = manifest.Components.GRPC.Server.Start != nil + } + if data.Health && manifest.Components.Health.Server != nil { + data.HealthToggle = manifest.Components.Health.Server.Start != nil + } + if data.Pprof && manifest.Languages.Go.Components.Pprof.Server != nil { + data.PprofToggle = manifest.Languages.Go.Components.Pprof.Server.Start != nil + } + if data.Health && manifest.Components.DB != nil { + data.HealthConnections = make([]runtimeHealthConnection, 0, len(manifest.Components.DB.Connections)) + for _, connection := range manifest.Components.DB.Connections { + data.HealthConnections = append(data.HealthConnections, runtimeHealthConnection{ + Connection: "db-connection:" + connection.Name, + Probe: "db." + connection.Name, + }) + } + } + return data +} + +func manifestHasServer(manifest projectdomain.Manifest) bool { + return manifest.Components.HTTP != nil && manifest.Components.HTTP.Server != nil || + manifest.Components.GRPC != nil && manifest.Components.GRPC.Server != nil || + manifest.Components.Health != nil || manifest.Languages.Go.Components.Pprof != nil +} diff --git a/internal/service/scaffold/render.go b/internal/service/scaffold/render.go new file mode 100644 index 0000000..e75fbac --- /dev/null +++ b/internal/service/scaffold/render.go @@ -0,0 +1,118 @@ +package scaffold + +import ( + "bytes" + "embed" + "fmt" + "strings" + "text/template" + "unicode" +) + +//go:embed templates/* +var scaffoldTemplates embed.FS + +func readTemplateAsset(name string) ([]byte, error) { + content, err := scaffoldTemplates.ReadFile("templates/" + name) + if err != nil { + return nil, fmt.Errorf("scaffoldTemplates.ReadFile: %w", err) + } + return content, nil +} + +func executeTemplate(name string, data any) (string, error) { + parsed, err := template.New(name).Funcs(template.FuncMap{ + "goName": goName, + "replace": func(value string) string { return strings.ReplaceAll(value, "-", "_") }, + "hasProto": func(consumers []kafkaConsumerTemplateData) bool { + for _, consumer := range consumers { + if consumer.Format == "proto" { + return true + } + } + return false + }, + }).ParseFS(scaffoldTemplates, "templates/"+name) + if err != nil { + return "", fmt.Errorf("template.ParseFS: %w", err) + } + var output bytes.Buffer + if err := parsed.Execute(&output, data); err != nil { + return "", fmt.Errorf("parsed.Execute: %w", err) + } + return output.String(), nil +} + +type goModTemplateData struct { + Module string + Requires []string + HTTP bool + Proto bool +} + +func renderGoMod(data goModTemplateData) (string, error) { + return executeTemplate("go.mod.gotmpl", data) +} + +type migrationTask struct { + TaskPrefix string + Path string + DatabaseExpression string + Kind string +} + +type miseTemplateData struct { + JSON bool + Migrations []migrationTask + MigrationTags []string +} + +func renderMise(data miseTemplateData) (string, error) { + rendered, err := executeTemplate("mise.toml", data) + if err != nil { + return "", err + } + return strings.TrimRight(rendered, "\n") + "\n", nil +} + +type mainTemplateData struct { + Project string + Module string + Server bool + Kafka bool +} + +func renderMain(data mainTemplateData) (string, error) { + return executeTemplate("main.go.gotmpl", data) +} + +func renderAPI(module string) (string, error) { + return executeTemplate("api.go.gotmpl", struct{ Module string }{Module: module}) +} + +func unique(values []string) []string { + result := values[:0] + for _, value := range values { + if len(result) == 0 || result[len(result)-1] != value { + result = append(result, value) + } + } + return result +} + +func goName(value string) string { + var builder strings.Builder + upper := true + for _, char := range value { + if char == '-' || char == '_' { + upper = true + continue + } + if upper { + char = unicode.ToUpper(char) + upper = false + } + builder.WriteRune(char) + } + return builder.String() +} diff --git a/internal/service/scaffold/render_config.go b/internal/service/scaffold/render_config.go new file mode 100644 index 0000000..17f375b --- /dev/null +++ b/internal/service/scaffold/render_config.go @@ -0,0 +1,49 @@ +package scaffold + +import ( + "fmt" + "io/fs" + "path" + + projectdomain "github.com/devctllabs/devctl/internal/domain/project" + "github.com/devctllabs/devctl/internal/service/runtimeconfig" +) + +type configLoaderTemplateData struct { + ImportPath string +} + +type configProjection struct { + catalog projectdomain.RuntimeConfigCatalog + catalogErr error + target projectdomain.Target + targetAvailable bool + importPath string +} + +func runtimeConfigArtifacts(projection configProjection) ([]Artifact, error) { + if projection.catalogErr != nil { + return nil, fmt.Errorf("project.NewRuntimeConfigCatalog: %w", projection.catalogErr) + } + output, err := runtimeconfig.Render(projection.catalog) + if err != nil { + return nil, fmt.Errorf("runtimeconfig.Render: %w", err) + } + if !projection.targetAvailable { + return nil, fmt.Errorf("effective config target is unavailable") + } + loader, err := executeTemplate("config.go.gotmpl", configLoaderTemplateData{ + ImportPath: projection.importPath, + }) + if err != nil { + return nil, fmt.Errorf("executeTemplate: %w", err) + } + artifacts := []Artifact{{Path: "internal/deps/config.gen.go", Mode: 0o644, Content: []byte(loader)}} + for _, file := range output.Directory.Files { + artifacts = append(artifacts, Artifact{Path: path.Join(projection.target.OutputDir, file.Path), Mode: fs.FileMode(file.Mode), Content: file.Content}) + } + for _, file := range output.Files.Files { + artifacts = append(artifacts, Artifact{Path: file.Path, Mode: fs.FileMode(file.Mode), Content: file.Content}) + } + return artifacts, nil +} diff --git a/internal/service/scaffold/render_container.go b/internal/service/scaffold/render_container.go new file mode 100644 index 0000000..2c20bf7 --- /dev/null +++ b/internal/service/scaffold/render_container.go @@ -0,0 +1,38 @@ +package scaffold + +type containerTemplateData struct { + Project string + Logging bool + Telemetry bool + TelemetryToggle bool + Database bool + Server bool + Kafka bool + RuntimeUsesResolver bool + Connections []containerConnection + Consumers []containerConsumer +} + +type containerConnection struct { + Name string + Connection string +} + +type containerConsumer struct { + Name string + Enabled string +} + +func renderContainer(data containerTemplateData) (string, error) { + return executeTemplate("container.go.gotmpl", data) +} + +type applicationTemplateData struct { + HTTP bool + GRPC bool + Calls []string +} + +func renderApplication(data applicationTemplateData) (string, error) { + return executeTemplate("application.go.gotmpl", data) +} diff --git a/internal/service/scaffold/render_runtime.go b/internal/service/scaffold/render_runtime.go new file mode 100644 index 0000000..be8c21b --- /dev/null +++ b/internal/service/scaffold/render_runtime.go @@ -0,0 +1,22 @@ +package scaffold + +type runtimeTemplateData struct { + HTTP bool + HTTPToggle bool + GRPC bool + GRPCToggle bool + Health bool + HealthToggle bool + Pprof bool + PprofToggle bool + HealthConnections []runtimeHealthConnection +} + +type runtimeHealthConnection struct { + Connection string + Probe string +} + +func renderRuntime(data runtimeTemplateData) (string, error) { + return executeTemplate("runtime.go.gotmpl", data) +} diff --git a/internal/service/scaffold/render_storage.go b/internal/service/scaffold/render_storage.go new file mode 100644 index 0000000..db346e4 --- /dev/null +++ b/internal/service/scaffold/render_storage.go @@ -0,0 +1,29 @@ +package scaffold + +type storageTemplateData struct { + Name string + Connection string + Telemetry bool + ClickHouse bool + ClickHouseConfig string + Kinds []storageKind + Variants []storageVariant +} + +type storageKind struct { + Name string + Field string +} + +type storageVariant struct { + Name string + Kind string + Field string + ConfigField string + SQLite bool + ClickHouse bool +} + +func renderStorage(data storageTemplateData) (string, error) { + return executeTemplate("storage.go.gotmpl", data) +} diff --git a/internal/service/scaffold/service.go b/internal/service/scaffold/service.go new file mode 100644 index 0000000..c9cf46d --- /dev/null +++ b/internal/service/scaffold/service.go @@ -0,0 +1,94 @@ +package scaffold + +import ( + "bytes" + "context" + "errors" + "fmt" + "io/fs" + + scaffolddomain "github.com/devctllabs/devctl/internal/domain/scaffold" + "go.uber.org/zap" +) + +type Service struct { + logger *zap.Logger + projects ProjectRepository + workspace WorkspaceRepository +} + +func New(logger *zap.Logger, projects ProjectRepository, workspace WorkspaceRepository) *Service { + return &Service{logger: logger, projects: projects, workspace: workspace} +} + +// Scaffold preflights the complete artifact plan, then publishes files in deterministic order. +// A publication error returns changes for earlier files without rolling them back. +func (s *Service) Scaffold(ctx context.Context, command scaffolddomain.Command) (scaffolddomain.Result, error) { + result := scaffolddomain.Result{Files: []scaffolddomain.FileChange{}} + project, err := s.projects.LoadProject(ctx, command.ManifestPath) + if err != nil { + return result, fmt.Errorf("projects.LoadProject: %w", err) + } + artifacts, err := plan(project.Manifest) + if err != nil { + return result, &scaffolddomain.OperationError{Operation: scaffolddomain.OperationPlan, Kind: scaffolddomain.FailureInternal, Cause: err} + } + conflicts, err := preflight(ctx, s.workspace, preflightRequest{root: project.Root, artifacts: artifacts}) + if err != nil { + operationErr := &scaffolddomain.OperationError{Operation: scaffolddomain.OperationPreflight, Kind: scaffolddomain.FailureUnavailable, Cause: err} + return result, fmt.Errorf("preflight: %w", operationErr) + } + if len(conflicts) > 0 { + return result, &scaffolddomain.OperationError{Operation: scaffolddomain.OperationPreflight, Path: conflicts[0].path, Kind: scaffolddomain.FailureConflict} + } + result.Files, err = s.publish(ctx, project.Root, artifacts) + if err != nil { + if ctx.Err() != nil { + return result, errors.Join(fmt.Errorf("ctx.Err: %w", ctx.Err()), err) + } + return result, err + } + s.logger.Debug("scaffold completed", zap.Int("files", len(result.Files))) + return result, nil +} + +func (s *Service) publish(ctx context.Context, root string, artifacts []Artifact) ([]scaffolddomain.FileChange, error) { + changes := make([]scaffolddomain.FileChange, 0, len(artifacts)) + for _, artifact := range artifacts { + change, err := s.publishArtifact(ctx, root, artifact) + if err != nil { + return changes, err + } + changes = append(changes, change) + } + return changes, nil +} + +func (s *Service) publishArtifact(ctx context.Context, root string, artifact Artifact) (scaffolddomain.FileChange, error) { + if err := ctx.Err(); err != nil { + return scaffolddomain.FileChange{}, fmt.Errorf("ctx.Err: %w", err) + } + info, statErr := s.workspace.Lstat(ctx, root, artifact.Path) + exists := statErr == nil + if statErr != nil && !errors.Is(statErr, fs.ErrNotExist) { + operationErr := &scaffolddomain.OperationError{Operation: scaffolddomain.OperationPreflight, Path: artifact.Path, Kind: scaffolddomain.FailureUnavailable, Cause: statErr} + return scaffolddomain.FileChange{}, fmt.Errorf("workspace.Lstat: %w", operationErr) + } + if exists && info.Mode().IsRegular() { + existing, readErr := s.workspace.ReadBytes(ctx, root, artifact.Path) + if readErr != nil { + operationErr := &scaffolddomain.OperationError{Operation: scaffolddomain.OperationPreflight, Path: artifact.Path, Kind: scaffolddomain.FailureUnavailable, Cause: readErr} + return scaffolddomain.FileChange{}, fmt.Errorf("workspace.ReadBytes: %w", operationErr) + } + if bytes.Equal(existing, artifact.Content) || artifact.CreateOnly { + return scaffolddomain.FileChange{Path: artifact.Path, Action: scaffolddomain.FileUnchanged}, nil + } + } + published, err := s.workspace.PublishFile(ctx, root, artifact.Path, artifact.Content) + if err != nil { + operationErr := &scaffolddomain.OperationError{Operation: scaffolddomain.OperationPublish, Path: artifact.Path, Kind: scaffolddomain.FailureUnavailable, Cause: err} + return scaffolddomain.FileChange{}, fmt.Errorf("workspace.PublishFile: %w", operationErr) + } + action := scaffolddomain.FileAction(published.Action) + return scaffolddomain.FileChange{Path: artifact.Path, Action: action}, nil +} diff --git a/internal/service/scaffold/service_preflight_test.go b/internal/service/scaffold/service_preflight_test.go new file mode 100644 index 0000000..5eb6e03 --- /dev/null +++ b/internal/service/scaffold/service_preflight_test.go @@ -0,0 +1,312 @@ +package scaffold_test + +import ( + "context" + "errors" + "io/fs" + "testing" + "testing/fstest" + + "github.com/devctllabs/devctl/internal/domain/artifact" + projectdomain "github.com/devctllabs/devctl/internal/domain/project" + scaffolddomain "github.com/devctllabs/devctl/internal/domain/scaffold" + "github.com/devctllabs/devctl/internal/service/scaffold" + "github.com/devctllabs/devctl/internal/service/scaffold/mocks" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + "go.uber.org/zap" +) + +func TestServiceRejectsPreflightConflictsBeforePublishing(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + path string + entryKind string + different bool + }{ + {name: "planned symlink", path: "go.mod", entryKind: "symlink"}, + {name: "planned directory", path: "go.mod", entryKind: "directory"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + fixture := newPreflightFixture(t, minimalServiceManifest()) + info := scaffoldFileInfo(t, test.path, test.entryKind) + fixture.expectWalkEntry(test.path, info) + if test.path == "go.mod" { + fixture.workspace.EXPECT().Lstat(gomock.Any(), fixture.root, test.path).Return(info, nil) + } + if test.different { + fixture.workspace.EXPECT().ReadBytes(gomock.Any(), fixture.root, test.path).Return([]byte("different"), nil) + } + + result, err := fixture.service.Scaffold(context.Background(), scaffolddomain.Command{}) + + var operationErr *scaffolddomain.OperationError + require.ErrorAs(t, err, &operationErr) + require.Equal(t, scaffolddomain.OperationPreflight, operationErr.Operation) + require.Equal(t, scaffolddomain.FailureConflict, operationErr.Kind) + require.Equal(t, test.path, operationErr.Path) + require.Empty(t, result.Files) + }) + } +} + +func TestServiceReportsPreflightFailuresAsUnavailable(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + stage string + }{ + {name: "cancelled context", stage: "context"}, + {name: "walk failure", stage: "walk"}, + {name: "lstat failure", stage: "lstat"}, + {name: "read failure", stage: "read"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + failure := errors.New(test.stage + " failed") + fixture := newPreflightFixture(t, minimalServiceManifest()) + ctx := context.Background() + switch test.stage { + case "context": + var cancel context.CancelFunc + ctx, cancel = context.WithCancel(ctx) + cancel() + failure = context.Canceled + case "walk": + fixture.workspace.EXPECT().Walk(gomock.Any(), fixture.root, gomock.Any()).Return(failure) + case "lstat", "read": + info := scaffoldFileInfo(t, "go.mod", "regular") + fixture.expectWalkEntry("go.mod", info) + if test.stage == "lstat" { + fixture.workspace.EXPECT().Lstat(gomock.Any(), fixture.root, "go.mod").Return(nil, failure) + } else { + fixture.workspace.EXPECT().Lstat(gomock.Any(), fixture.root, "go.mod").Return(info, nil) + fixture.workspace.EXPECT().ReadBytes(gomock.Any(), fixture.root, "go.mod").Return(nil, failure) + } + } + + result, err := fixture.service.Scaffold(ctx, scaffolddomain.Command{}) + + var operationErr *scaffolddomain.OperationError + require.ErrorAs(t, err, &operationErr) + require.Equal(t, scaffolddomain.OperationPreflight, operationErr.Operation) + require.Equal(t, scaffolddomain.FailureUnavailable, operationErr.Kind) + require.ErrorIs(t, err, failure) + require.Empty(t, result.Files) + }) + } +} + +func TestServiceRefreshesManagedFilesAndPreservesSeeds(t *testing.T) { + t.Parallel() + + t.Run("refresh updates a different managed file", func(t *testing.T) { + t.Parallel() + + fixture := newPreflightFixture(t, minimalServiceManifest()) + info := scaffoldFileInfo(t, "go.mod", "regular") + fixture.expectWalkEntry("go.mod", info) + fixture.allowPublication("go.mod", info, []byte("different"), "") + + result, err := fixture.service.Scaffold(context.Background(), scaffolddomain.Command{}) + + require.NoError(t, err) + requireFileAction(t, result, "go.mod", scaffolddomain.FileUpdated) + }) + + t.Run("refresh preserves a different user-owned seed", func(t *testing.T) { + t.Parallel() + + manifest := minimalServiceManifest() + manifest.Components.HTTP = &projectdomain.HTTP{Server: &projectdomain.HTTPServer{}} + fixture := newPreflightFixture(t, manifest) + path := "api/openapi/swagger.yaml" + info := scaffoldFileInfo(t, path, "regular") + fixture.expectWalkEntry(path, info) + fixture.allowPublication(path, info, []byte("user-owned OpenAPI"), "") + + result, err := fixture.service.Scaffold(context.Background(), scaffolddomain.Command{}) + + require.NoError(t, err) + requireFileAction(t, result, path, scaffolddomain.FileUnchanged) + }) +} + +func TestServiceRefreshesManagedFilesAndPreservesUserOwnedEntrypoints(t *testing.T) { + t.Parallel() + + manifest := minimalServiceManifest() + fixture := newPreflightFixture(t, manifest) + mainPath := "cmd/sample/main.go" + mainInfo := scaffoldFileInfo(t, mainPath, "regular") + fixture.expectWalkEntry(mainPath, mainInfo) + fixture.allowPublication(mainPath, mainInfo, []byte("package main\n// user changes\n"), mainPath) + + result, err := fixture.service.Scaffold(context.Background(), scaffolddomain.Command{}) + + require.NoError(t, err) + requireFileAction(t, result, mainPath, scaffolddomain.FileUnchanged) +} + +func TestServiceRefreshNeverReadsOrPublishesCustomBufGenerationConfig(t *testing.T) { + t.Parallel() + + manifest := minimalServiceManifest() + manifest.Sources = map[string]projectdomain.Source{ + "contracts": {Type: projectdomain.SourceGit, Repo: "example/contracts", Ref: "v1"}, + } + const customConfig = "tools/buf/billing.gen.yaml" + manifest.Components.GRPC = &projectdomain.GRPC{Clients: []projectdomain.GRPCClient{{ + Name: "billing", Source: "contracts", Path: "proto/billing.proto", BufGenConfig: customConfig, + }}} + fixture := newPreflightFixture(t, manifest) + fixture.expectWalkEntry(customConfig, scaffoldFileInfo(t, customConfig, "regular")) + fixture.workspace.EXPECT().Lstat(gomock.Any(), fixture.root, gomock.Any()).DoAndReturn( + func(_ context.Context, _ string, target string) (fs.FileInfo, error) { + require.NotEqual(t, customConfig, target) + return nil, fs.ErrNotExist + }, + ).AnyTimes() + fixture.workspace.EXPECT().PublishFile(gomock.Any(), fixture.root, gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, _ string, target string, _ []byte) (artifact.PublishResult, error) { + require.NotEqual(t, customConfig, target) + return artifact.PublishResult{Action: artifact.PublishCreated}, nil + }, + ).AnyTimes() + + result, err := fixture.service.Scaffold(context.Background(), scaffolddomain.Command{}) + + require.NoError(t, err) + for _, change := range result.Files { + require.NotEqual(t, customConfig, change.Path) + } +} + +func TestServiceIgnoresGitMetadataDuringPreflight(t *testing.T) { + t.Parallel() + + fixture := newPreflightFixture(t, minimalServiceManifest()) + path := ".git/hooks/generated.go" + fixture.expectWalkEntry(path, scaffoldFileInfo(t, path, "regular")) + fixture.workspace.EXPECT().Lstat(gomock.Any(), fixture.root, gomock.Any()).DoAndReturn( + func(_ context.Context, _ string, name string) (fs.FileInfo, error) { + require.NotEqual(t, path, name) + return nil, fs.ErrNotExist + }, + ).AnyTimes() + fixture.workspace.EXPECT().PublishFile(gomock.Any(), fixture.root, gomock.Any(), gomock.Any()).Return(artifact.PublishResult{Action: artifact.PublishCreated}, nil).AnyTimes() + + result, err := fixture.service.Scaffold(context.Background(), scaffolddomain.Command{}) + + require.NoError(t, err) + require.NotEmpty(t, result.Files) +} + +type preflightFixture struct { + t *testing.T + root string + workspace *mocks.MockWorkspaceRepository + service *scaffold.Service +} + +func newPreflightFixture(t *testing.T, manifest projectdomain.Manifest) *preflightFixture { + t.Helper() + + ctrl := gomock.NewController(t) + projects := mocks.NewMockProjectRepository(ctrl) + workspace := mocks.NewMockWorkspaceRepository(ctrl) + project := projectdomain.Project{Root: "/project", Manifest: manifest} + projects.EXPECT().LoadProject(gomock.Any(), "").Return(project, nil) + return &preflightFixture{ + t: t, + root: project.Root, + workspace: workspace, + service: scaffold.New(zap.NewNop(), projects, workspace), + } +} + +func (f *preflightFixture) expectWalkEntry(path string, info fs.FileInfo) { + f.workspace.EXPECT().Walk(gomock.Any(), f.root, gomock.Any()).DoAndReturn( + func(_ context.Context, _ string, visit fs.WalkDirFunc) error { + return visit(path, fs.FileInfoToDirEntry(info), nil) + }, + ) +} + +func (f *preflightFixture) allowPublication( + existingPath string, + info fs.FileInfo, + existing []byte, + forbiddenPublishPath string, +) { + f.t.Helper() + + f.workspace.EXPECT().Lstat(gomock.Any(), f.root, gomock.Any()).DoAndReturn( + func(_ context.Context, _ string, name string) (fs.FileInfo, error) { + if name == existingPath { + return info, nil + } + return nil, fs.ErrNotExist + }, + ).AnyTimes() + f.workspace.EXPECT().ReadBytes(gomock.Any(), f.root, existingPath).Return(existing, nil).AnyTimes() + f.workspace.EXPECT().PublishFile(gomock.Any(), f.root, gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, _ string, target string, _ []byte) (artifact.PublishResult, error) { + require.NotEqual(f.t, forbiddenPublishPath, target) + action := artifact.PublishCreated + if target == existingPath { + action = artifact.PublishUpdated + } + return artifact.PublishResult{Action: action}, nil + }, + ).AnyTimes() +} + +func scaffoldFileInfo(t *testing.T, name, kind string) fs.FileInfo { + t.Helper() + + files := fstest.MapFS{} + switch kind { + case "regular": + files[name] = &fstest.MapFile{Data: []byte("existing"), Mode: 0o644} + case "symlink": + files[name] = &fstest.MapFile{Mode: fs.ModeSymlink | 0o777} + case "directory": + files[name+"/child"] = &fstest.MapFile{Data: []byte("child"), Mode: 0o644} + default: + require.FailNow(t, "unknown file kind", kind) + } + info, err := fs.Stat(files, name) + require.NoError(t, err) + return info +} + +func minimalServiceManifest() projectdomain.Manifest { + return projectdomain.Manifest{ + Version: 1, + Project: projectdomain.Identity{Name: "sample", Language: "go"}, + Languages: projectdomain.Languages{Go: projectdomain.GoLanguage{Module: "example.test/sample"}}, + } +} + +func requireFileAction(t *testing.T, result scaffolddomain.Result, path string, action scaffolddomain.FileAction) { + t.Helper() + + for _, change := range result.Files { + if change.Path == path { + require.Equal(t, action, change.Action) + return + } + } + require.Fail(t, "file change not found", path) +} diff --git a/internal/service/scaffold/service_test.go b/internal/service/scaffold/service_test.go new file mode 100644 index 0000000..f9f91c0 --- /dev/null +++ b/internal/service/scaffold/service_test.go @@ -0,0 +1,41 @@ +package scaffold_test + +import ( + "context" + "io/fs" + "testing" + + "github.com/devctllabs/devctl/internal/domain/artifact" + projectdomain "github.com/devctllabs/devctl/internal/domain/project" + scaffolddomain "github.com/devctllabs/devctl/internal/domain/scaffold" + "github.com/devctllabs/devctl/internal/service/scaffold" + "github.com/devctllabs/devctl/internal/service/scaffold/mocks" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + "go.uber.org/zap" +) + +func TestServiceOwnsScaffoldWorkflowAndOutcome(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + projects := mocks.NewMockProjectRepository(ctrl) + workspace := mocks.NewMockWorkspaceRepository(ctrl) + project := projectdomain.Project{Root: "/project", Manifest: projectdomain.Manifest{ + Project: projectdomain.Identity{Name: "sample", Language: "go"}, + Languages: projectdomain.Languages{Go: projectdomain.GoLanguage{Module: "example.test/sample"}}, + }} + gomock.InOrder( + projects.EXPECT().LoadProject(gomock.Any(), "custom.yaml").Return(project, nil), + workspace.EXPECT().Walk(gomock.Any(), project.Root, gomock.Any()).Return(nil), + ) + workspace.EXPECT().Lstat(gomock.Any(), project.Root, gomock.Any()).Return(nil, fs.ErrNotExist).AnyTimes() + workspace.EXPECT().PublishFile(gomock.Any(), project.Root, gomock.Any(), gomock.Any()).Return(artifact.PublishResult{Action: artifact.PublishCreated}, nil).AnyTimes() + service := scaffold.New(zap.NewNop(), projects, workspace) + + result, err := service.Scaffold(context.Background(), scaffolddomain.Command{ManifestPath: "custom.yaml"}) + + require.NoError(t, err) + require.NotEmpty(t, result.Files) + require.Equal(t, scaffolddomain.FileChange{Path: ".env.example", Action: scaffolddomain.FileCreated}, result.Files[0]) +} diff --git a/internal/service/scaffold/templates/api.go.gotmpl b/internal/service/scaffold/templates/api.go.gotmpl new file mode 100644 index 0000000..5240850 --- /dev/null +++ b/internal/service/scaffold/templates/api.go.gotmpl @@ -0,0 +1,23 @@ +package internal + +import ( + "context" + "fmt" + + "{{ .Module }}/internal/deps" + "github.com/urfave/cli/v3" +) + +// NewCmdAPI constructs the API server command. +func NewCmdAPI() *cli.Command { + return &cli.Command{ + Name: "api", + Usage: "Run API servers", + Action: func(ctx context.Context, _ *cli.Command) error { + scenario, err := deps.NewAPI(ctx) + if err != nil { return fmt.Errorf("deps.NewAPI: %w", err) } + if err := scenario.Run(ctx); err != nil { return fmt.Errorf("scenario.Run: %w", err) } + return nil + }, + } +} diff --git a/internal/service/scaffold/templates/application.go.gotmpl b/internal/service/scaffold/templates/application.go.gotmpl new file mode 100644 index 0000000..4f6d5f1 --- /dev/null +++ b/internal/service/scaffold/templates/application.go.gotmpl @@ -0,0 +1,46 @@ +package deps + +import ( + "context" +{{- if or .HTTP .GRPC .Calls }} + "fmt" +{{- end }} + + "github.com/devctllabs/go-libs/di" +{{- if .HTTP }} + "github.com/labstack/echo/v5" +{{- end }} +{{- if .GRPC }} + "google.golang.org/grpc" +{{- end }} +) + +// application is the user-owned composition root. Add application dependencies here. +type application struct{} + +{{- if .HTTP }} +func (*application) RegisterHTTP(*echo.Echo) {} +{{- end }} +{{- if .GRPC }} +func (*application) RegisterGRPC(*grpc.Server) {} +{{- end }} + +// provideApplication is created once. Add newly scaffolded provider calls manually. +func provideApplication(ctx context.Context, graph *di.Container, cfg *Config) error { +{{- if .HTTP }} + if err := di.Provide[HTTPRegistrar](graph, func(di.Resolver) (HTTPRegistrar, error) { return &application{}, nil }); err != nil { + return fmt.Errorf("di.Provide HTTPRegistrar: %w", err) + } +{{- end }} +{{- if .GRPC }} + if err := di.Provide[GRPCRegistrar](graph, func(di.Resolver) (GRPCRegistrar, error) { return &application{}, nil }); err != nil { + return fmt.Errorf("di.Provide GRPCRegistrar: %w", err) + } +{{- end }} +{{- range .Calls }} + if err := {{ . }}(ctx, graph, cfg); err != nil { + return fmt.Errorf("{{ . }}: %w", err) + } +{{- end }} + return nil +} diff --git a/internal/service/scaffold/templates/buf-go.gen.yaml b/internal/service/scaffold/templates/buf-go.gen.yaml new file mode 100644 index 0000000..9483aef --- /dev/null +++ b/internal/service/scaffold/templates/buf-go.gen.yaml @@ -0,0 +1,10 @@ +version: v2 +plugins: + - local: [go, tool, protoc-gen-go] + out: . + opt: + - paths=source_relative + - local: [go, tool, protoc-gen-go-grpc] + out: . + opt: + - paths=source_relative diff --git a/internal/service/scaffold/templates/buf.yaml.gotmpl b/internal/service/scaffold/templates/buf.yaml.gotmpl new file mode 100644 index 0000000..42a0973 --- /dev/null +++ b/internal/service/scaffold/templates/buf.yaml.gotmpl @@ -0,0 +1,11 @@ +version: v2 +modules: + - path: {{ .ProtoRoot }} +lint: + use: + - STANDARD + except: + - FILE_LOWER_SNAKE_CASE +breaking: + use: + - FILE diff --git a/internal/service/scaffold/templates/config.go.gotmpl b/internal/service/scaffold/templates/config.go.gotmpl new file mode 100644 index 0000000..b168f09 --- /dev/null +++ b/internal/service/scaffold/templates/config.go.gotmpl @@ -0,0 +1,21 @@ +package deps + +import ( + "context" + "fmt" + + configlib "github.com/devctllabs/go-libs/config" + generatedconfig "{{ .ImportPath }}" +) + +// Config is the canonical generated runtime configuration. +type Config = generatedconfig.Config + +func loadConfig(ctx context.Context) (*Config, error) { + var cfg Config + loader := configlib.Chain(configlib.Defaults(), configlib.OSEnv()) + if err := loader.Load(ctx, &cfg); err != nil { + return nil, fmt.Errorf("loader.Load: %w", err) + } + return &cfg, nil +} diff --git a/internal/service/scaffold/templates/consumer.go.gotmpl b/internal/service/scaffold/templates/consumer.go.gotmpl new file mode 100644 index 0000000..f78d60a --- /dev/null +++ b/internal/service/scaffold/templates/consumer.go.gotmpl @@ -0,0 +1,23 @@ +package internal + +import ( + "context" + "fmt" + + "{{ .Module }}/internal/deps" + "github.com/urfave/cli/v3" +) + +// NewCmdConsumer constructs the named Kafka consumer command. +func NewCmdConsumer() *cli.Command { + return &cli.Command{ + Name: "consumer", + Arguments: []cli.Argument{&cli.StringArg{Name: "consumer-name"}}, + Action: func(ctx context.Context, command *cli.Command) error { + scenario, err := deps.NewConsumer(ctx, command.StringArg("consumer-name")) + if err != nil { return fmt.Errorf("deps.NewConsumer: %w", err) } + if err := scenario.Run(ctx); err != nil { return fmt.Errorf("scenario.Run: %w", err) } + return nil + }, + } +} diff --git a/internal/service/scaffold/templates/container.go.gotmpl b/internal/service/scaffold/templates/container.go.gotmpl new file mode 100644 index 0000000..825c30c --- /dev/null +++ b/internal/service/scaffold/templates/container.go.gotmpl @@ -0,0 +1,144 @@ +package deps + +{{- if or .Database .Logging .Telemetry .Server .Kafka }} +import ( + "context" +{{- if or .Server .Kafka }} + "errors" + "fmt" + "time" +{{- else if or .Logging .Telemetry }} + "fmt" +{{- end }} + +{{- if or .Logging .Telemetry .Server .Kafka }} + "github.com/devctllabs/go-libs/di" +{{- end }} +{{- if or .Server .Kafka }} + "github.com/devctllabs/go-libs/lifecycle" +{{- end }} +{{- if .Logging }} + loglib "github.com/devctllabs/go-libs/log" + "go.uber.org/zap" + "go.uber.org/zap/zapcore" +{{- end }} +{{- if .Telemetry }} + telemetrylib "github.com/devctllabs/go-libs/telemetry" +{{- end }} +) +{{- end }} + +{{- if .Database }} +type dbChecker interface { Check(context.Context) error } +{{- end }} + +{{- if or .Server .Kafka }} +type scenarioRunner interface { Run(context.Context) error } + +// Scenario owns one lazily resolved runnable branch and its dependency graph. +type Scenario struct { + graph *di.Container + tasks []lifecycle.Task +} + +// Run coordinates the selected branch until cancellation or failure. +func (s *Scenario) Run(ctx context.Context) error { + return lifecycle.Run(ctx, lifecycle.Config{ + ShutdownTimeout: 30 * time.Second, + Shutdown: s.Shutdown, + Tasks: s.tasks, + }) +} + +// Shutdown closes only resources constructed by this Scenario. +func (s *Scenario) Shutdown(ctx context.Context) error { + if s == nil || s.graph == nil { return nil } + if err := s.graph.Shutdown(ctx); err != nil { return fmt.Errorf("graph.Shutdown: %w", err) } + return nil +} + +func newScenarioGraph(ctx context.Context) (*di.Container, *Config, error) { + cfg, err := loadConfig(ctx) + if err != nil { return nil, nil, fmt.Errorf("loadConfig: %w", err) } + graph := di.New() + if err := di.ProvideValue(graph, cfg); err != nil { return nil, nil, fmt.Errorf("di.ProvideValue: %w", err) } + if err := provideApplication(ctx, graph, cfg); err != nil { + shutdownErr := graph.Shutdown(context.WithoutCancel(ctx)) + return nil, nil, errors.Join(fmt.Errorf("provideApplication: %w", err), shutdownErr) + } + return graph, cfg, nil +} +{{- end }} + +{{- if .Logging }} +func provideLogging(_ context.Context, graph *di.Container, cfg *Config) error { + return di.ProvideResource(graph, func(di.Resolver) (*zap.Logger, error) { + level := zapcore.InfoLevel + if err := level.Set(cfg.Logging.Level); err != nil { return nil, fmt.Errorf("logging level: %w", err) } + return loglib.New(level, false).Named({{ printf "%q" .Project }}), nil + }, func(_ context.Context, value *zap.Logger) error { _ = value.Sync(); return nil }) +} +{{- end }} + +{{- if .Telemetry }} +func provideTelemetry(ctx context.Context, graph *di.Container, cfg *Config) error { + return di.ProvideResource(graph, func(di.Resolver) (*telemetrylib.Runtime, error) { + value, err := telemetrylib.Open(ctx, telemetrylib.Config{ + Enabled: {{ if .TelemetryToggle }}cfg.Telemetry.Enabled{{ else }}true{{ end }}, + ServiceName: {{ printf "%q" .Project }}, + ServiceVersion: cfg.Telemetry.ServiceVersion, + DeploymentEnvironment: cfg.Telemetry.DeploymentEnvironment, + }) + if err != nil { return nil, fmt.Errorf("telemetrylib.Open: %w", err) } + return value, nil + }, func(ctx context.Context, value *telemetrylib.Runtime) error { return value.Shutdown(ctx) }) +} +{{- end }} + +{{- if .Server }} +// NewAPI resolves only the API Runtime branch. +func NewAPI(ctx context.Context) (*Scenario, error) { + graph, _, err := newScenarioGraph(ctx) + if err != nil { return nil, err } + runtime, err := di.Resolve[*Runtime](graph) + if err != nil { + return nil, errors.Join(fmt.Errorf("di.Resolve Runtime: %w", err), graph.Shutdown(context.WithoutCancel(ctx))) + } + return &Scenario{graph: graph, tasks: runtime.Tasks()}, nil +} +{{- end }} + +{{- if .Kafka }} +// ConsumerSelectionError reports an unknown or disabled selected consumer. +type ConsumerSelectionError struct { Name string; Reason string } + +func (e *ConsumerSelectionError) Error() string { + return fmt.Sprintf("Kafka consumer %q is %s", e.Name, e.Reason) +} + +// NewConsumer validates selection before resolving the selected consumer branch. +func NewConsumer(ctx context.Context, name string) (*Scenario, error) { + cfg, err := loadConfig(ctx) + if err != nil { return nil, fmt.Errorf("loadConfig: %w", err) } + switch name { +{{- range .Consumers }} + case {{ printf "%q" .Name }}: + if !({{ .Enabled }}) { return nil, &ConsumerSelectionError{Name: name, Reason: "disabled"} } +{{- end }} + default: + return nil, &ConsumerSelectionError{Name: name, Reason: "unknown"} + } + graph := di.New() + if err := di.ProvideValue(graph, cfg); err != nil { return nil, fmt.Errorf("di.ProvideValue: %w", err) } + if err := provideApplication(ctx, graph, cfg); err != nil { + return nil, errors.Join(fmt.Errorf("provideApplication: %w", err), graph.Shutdown(context.WithoutCancel(ctx))) + } + runner, err := di.ResolveNamed[scenarioRunner](graph, kafkaConsumerKey(name)) + if err != nil { + return nil, errors.Join(fmt.Errorf("di.ResolveNamed consumer: %w", err), graph.Shutdown(context.WithoutCancel(ctx))) + } + return &Scenario{graph: graph, tasks: []lifecycle.Task{ + {Name: "kafka-consumer:" + name, Run: runner.Run}, + }}, nil +} +{{- end }} diff --git a/internal/service/scaffold/templates/go.mod.gotmpl b/internal/service/scaffold/templates/go.mod.gotmpl new file mode 100644 index 0000000..bbbc305 --- /dev/null +++ b/internal/service/scaffold/templates/go.mod.gotmpl @@ -0,0 +1,21 @@ +module {{ .Module }} + +go 1.26.0 + +require ( +{{- range .Requires }} + {{ . }} +{{- end }} +) +{{ if .HTTP }} +tool github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen + +require github.com/oapi-codegen/oapi-codegen/v2 v2.8.0 +{{ end -}} +{{ if .Proto }} +tool ( + github.com/bufbuild/buf/cmd/buf + google.golang.org/grpc/cmd/protoc-gen-go-grpc + google.golang.org/protobuf/cmd/protoc-gen-go +) +{{ end -}} diff --git a/internal/service/scaffold/templates/golangci.yml b/internal/service/scaffold/templates/golangci.yml new file mode 100644 index 0000000..a01c843 --- /dev/null +++ b/internal/service/scaffold/templates/golangci.yml @@ -0,0 +1,76 @@ +version: "2" +run: + relative-path-mode: gomod + tests: true + modules-download-mode: readonly +linters: + default: none + enable: + - asasalint + - bidichk + - bodyclose + - containedctx + - contextcheck + - durationcheck + - errcheck + - errchkjson + - errname + - errorlint + - exhaustive + - fatcontext + - forcetypeassert + - gocheckcompilerdirectives + - gochecknoinits + - gocognit + - govet + - inamedparam + - ineffassign + - loggercheck + - makezero + - musttag + - nilerr + - nilnesserr + - noctx + - nolintlint + - nosprintfhostport + - paralleltest + - predeclared + - reassign + - recvcheck + - revive + - rowserrcheck + - sqlclosecheck + - staticcheck + - testifylint + - thelper + - tparallel + - unconvert + - unused + - usetesting + - wastedassign + - wrapcheck + settings: + gocognit: + min-complexity: 20 + nolintlint: + allow-unused: false + require-explanation: true + require-specific: true + paralleltest: + ignore-missing: false + ignore-missing-subtests: false + check-cleanup: true + revive: + rules: + - name: argument-limit + arguments: [4] + - name: function-result-limit + arguments: [3] + exclusions: + generated: strict + paths: ["^gen/"] +formatters: + enable: [gofmt] + exclusions: + generated: strict + paths: ["^gen/"] diff --git a/internal/service/scaffold/templates/grpc.go.gotmpl b/internal/service/scaffold/templates/grpc.go.gotmpl new file mode 100644 index 0000000..902de1d --- /dev/null +++ b/internal/service/scaffold/templates/grpc.go.gotmpl @@ -0,0 +1,6 @@ +package deps + +import "google.golang.org/grpc" + +// newGRPCServer constructs the handwritten gRPC runtime boundary. +func newGRPCServer() *grpc.Server { return grpc.NewServer() } diff --git a/internal/service/scaffold/templates/grpc_clients.go.gotmpl b/internal/service/scaffold/templates/grpc_clients.go.gotmpl new file mode 100644 index 0000000..c420adc --- /dev/null +++ b/internal/service/scaffold/templates/grpc_clients.go.gotmpl @@ -0,0 +1,23 @@ +package deps + +import ( + "context" + + "github.com/devctllabs/go-libs/di" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" +) + +{{- range . }} +const grpcClient{{ goName .Name }}Key = "grpc-client:{{ .Name }}" + +func provide{{ goName .Name }}GRPCClient(_ context.Context, graph *di.Container, cfg *Config) error { + return di.ProvideNamedResource(graph, grpcClient{{ goName .Name }}Key, func(di.Resolver) (*grpc.ClientConn, error) { + return grpc.NewClient(cfg.GRPCClients.{{ goName .Name }}Address, grpc.WithTransportCredentials(insecure.NewCredentials())) + }, func(_ context.Context, connection *grpc.ClientConn) error { return connection.Close() }) +} + +func {{ goName .Name }}GRPCConn(resolver di.Resolver) (*grpc.ClientConn, error) { + return di.ResolveNamed[*grpc.ClientConn](resolver, grpcClient{{ goName .Name }}Key) +} +{{- end }} diff --git a/internal/service/scaffold/templates/http_clients.go.gotmpl b/internal/service/scaffold/templates/http_clients.go.gotmpl new file mode 100644 index 0000000..d6bc01f --- /dev/null +++ b/internal/service/scaffold/templates/http_clients.go.gotmpl @@ -0,0 +1,28 @@ +package deps + +import ( + "context" + "net/http" + + "github.com/devctllabs/go-libs/di" +) + +type httpBaseURL string + +{{- range . }} +const httpClient{{ goName .Name }}Key = "http-client:{{ .Name }}" + +func provide{{ goName .Name }}HTTPClient(_ context.Context, graph *di.Container, cfg *Config) error { + if err := di.ProvideNamedValue(graph, httpClient{{ goName .Name }}Key, &http.Client{}); err != nil { return err } + return di.ProvideNamedValue(graph, httpClient{{ goName .Name }}Key, httpBaseURL(cfg.HTTPClients.{{ goName .Name }}BaseURL)) +} + +func {{ goName .Name }}HTTPTransport(resolver di.Resolver) (*http.Client, error) { + return di.ResolveNamed[*http.Client](resolver, httpClient{{ goName .Name }}Key) +} + +func {{ goName .Name }}HTTPBaseURL(resolver di.Resolver) (string, error) { + value, err := di.ResolveNamed[httpBaseURL](resolver, httpClient{{ goName .Name }}Key) + return string(value), err +} +{{- end }} diff --git a/internal/service/scaffold/templates/kafka_broker.go.gotmpl b/internal/service/scaffold/templates/kafka_broker.go.gotmpl new file mode 100644 index 0000000..fb59562 --- /dev/null +++ b/internal/service/scaffold/templates/kafka_broker.go.gotmpl @@ -0,0 +1,5 @@ +package deps + +func kafkaConsumerKey(name string) string { return "kafka-consumer:" + name } + +func kafkaProducerKey(name string) string { return "kafka-producer:" + name } diff --git a/internal/service/scaffold/templates/kafka_consumer_binding.go.gotmpl b/internal/service/scaffold/templates/kafka_consumer_binding.go.gotmpl new file mode 100644 index 0000000..f798763 --- /dev/null +++ b/internal/service/scaffold/templates/kafka_consumer_binding.go.gotmpl @@ -0,0 +1,35 @@ +package deps + +import ( + "context" + "fmt" + + {{ replace .Name }}consumer "{{ .Module }}/internal/transport/consumerkafka/{{ replace .Name }}" + "github.com/devctllabs/go-libs/di" + kafka "github.com/devctllabs/go-libs/kafka" + retry "github.com/devctllabs/go-libs/retry" +) + +// provide{{ goName .Name }}Consumer is user-owned: change []byte and the decoder for generated schema types. +func provide{{ goName .Name }}Consumer(ctx context.Context, graph *di.Container, cfg *Config) error { + key := kafkaConsumerKey({{ printf "%q" .Name }}) + if err := provide{{ goName .Name }}ConsumerConfig(ctx, graph, cfg); err != nil { + return fmt.Errorf("provide consumer config: %w", err) + } + if err := di.ProvideNamedValue[kafka.Decoder[[]byte]](graph, key, rawKafkaDecoder()); err != nil { + return fmt.Errorf("provide consumer decoder: %w", err) + } + if err := di.ProvideNamedValue[kafka.BatchHandler[[]byte]](graph, key, {{ replace .Name }}consumer.NewHandler()); err != nil { + return fmt.Errorf("provide consumer handler: %w", err) + } + if err := di.ProvideNamed[retry.Policy](graph, key, func(di.Resolver) (retry.Policy, error) { + return retry.NewExponential(retry.ExponentialConfig{ + InitialDelay: cfg.Kafka.{{ goName .Name }}RetryInitialDelay, + MaxDelay: cfg.Kafka.{{ goName .Name }}RetryMaxDelay, + Multiplier: 2, + }) + }); err != nil { + return fmt.Errorf("provide consumer retry policy: %w", err) + } + return provideConsumer[[]byte](graph, key) +} diff --git a/internal/service/scaffold/templates/kafka_consumers.go.gotmpl b/internal/service/scaffold/templates/kafka_consumers.go.gotmpl new file mode 100644 index 0000000..e4c5c70 --- /dev/null +++ b/internal/service/scaffold/templates/kafka_consumers.go.gotmpl @@ -0,0 +1,61 @@ +package deps + +import ( + "context" + "fmt" + + "github.com/devctllabs/go-libs/di" + kafka "github.com/devctllabs/go-libs/kafka" +{{- if hasProto .Consumers }} + kafkaproto "github.com/devctllabs/go-libs/kafkaproto" +{{- end }} + retry "github.com/devctllabs/go-libs/retry" +) + +func rawKafkaDecoder() kafka.Decoder[[]byte] { + // The consumer owns the record bytes until Handle returns; handlers must not retain a batch. + return kafka.DecoderFunc[[]byte](func(_ context.Context, value []byte) ([]byte, error) { return value, nil }) +} + +func jsonKafkaDecoder[T any]() kafka.Decoder[T] { return kafka.NewJSONDecoder[T]() } + +{{- if hasProto .Consumers }} +func protoKafkaDecoder[T any, PT kafkaproto.ProtoPtr[T]]() kafka.Decoder[PT] { + return kafkaproto.NewDecoder[T, PT]() +} +{{- end }} + +func provideConsumer[T any](graph *di.Container, key string) error { + return di.ProvideNamed[scenarioRunner](graph, key, func(resolver di.Resolver) (scenarioRunner, error) { + config, err := di.ResolveNamed[kafka.ConsumerConfig](resolver, key) + if err != nil { return nil, fmt.Errorf("resolve consumer config: %w", err) } + decoder, err := di.ResolveNamed[kafka.Decoder[T]](resolver, key) + if err != nil { return nil, fmt.Errorf("resolve consumer decoder: %w", err) } + handler, err := di.ResolveNamed[kafka.BatchHandler[T]](resolver, key) + if err != nil { return nil, fmt.Errorf("resolve consumer handler: %w", err) } + policy, err := di.ResolveNamed[retry.Policy](resolver, key) + if err != nil { return nil, fmt.Errorf("resolve consumer retry policy: %w", err) } + config.Retry.Policy = policy + config.CommitRetry = &config.Retry + consumer, err := kafka.NewConsumer(config, decoder, handler) + if err != nil { return nil, fmt.Errorf("kafka.NewConsumer: %w", err) } + return consumer, nil + }) +} + +{{- range .Consumers }} +func provide{{ goName .Name }}ConsumerConfig(_ context.Context, graph *di.Container, cfg *Config) error { + key := kafkaConsumerKey({{ printf "%q" .Name }}) + return di.ProvideNamedValue(graph, key, kafka.ConsumerConfig{ + Brokers: cfg.Kafka.Brokers, + Group: cfg.Kafka.{{ goName .Name }}Group, + Topics: []string{cfg.Kafka.{{ goName .Name }}Topic}, + Batch: kafka.BatchConfig{MaxSize: cfg.Kafka.{{ goName .Name }}BatchMaxSize, FlushInterval: cfg.Kafka.{{ goName .Name }}BatchFlushInterval}, + Retry: kafka.RetryConfig{MaxAttempts: uint(cfg.Kafka.{{ goName .Name }}RetryMaxAttempts), MaxElapsedTime: cfg.Kafka.{{ goName .Name }}RetryMaxElapsedTime}, + OnReject: kafka.RejectStop, + RebalanceTimeout: cfg.Kafka.{{ goName .Name }}RebalanceTimeout, + RebalanceDrainTimeout: cfg.Kafka.{{ goName .Name }}RebalanceDrainTimeout, + ShutdownTimeout: cfg.Kafka.{{ goName .Name }}ShutdownTimeout, + }) +} +{{- end }} diff --git a/internal/service/scaffold/templates/kafka_handler.go.gotmpl b/internal/service/scaffold/templates/kafka_handler.go.gotmpl new file mode 100644 index 0000000..f4ce9e3 --- /dev/null +++ b/internal/service/scaffold/templates/kafka_handler.go.gotmpl @@ -0,0 +1,20 @@ +package {{ .Package }} + +import ( + "context" + "errors" + + kafka "github.com/devctllabs/go-libs/kafka" + retry "github.com/devctllabs/go-libs/retry" +) + +var ErrNotImplemented = errors.New("Kafka consumer handler is not implemented") + +type Handler struct{} + +func NewHandler() *Handler { return &Handler{} } + +// Handle processes the batch synchronously. It must not retain batch data after returning. +func (h *Handler) Handle(context.Context, *kafka.Batch[[]byte]) error { + return retry.Permanent(ErrNotImplemented) +} diff --git a/internal/service/scaffold/templates/kafka_producers.go.gotmpl b/internal/service/scaffold/templates/kafka_producers.go.gotmpl new file mode 100644 index 0000000..23f337d --- /dev/null +++ b/internal/service/scaffold/templates/kafka_producers.go.gotmpl @@ -0,0 +1,19 @@ +package deps + +import ( + "context" + "fmt" + + "github.com/devctllabs/go-libs/di" + kafka "github.com/devctllabs/go-libs/kafka" +) + +{{- range . }} +func provide{{ goName .Name }}KafkaProducer(_ context.Context, graph *di.Container, cfg *Config) error { + return di.ProvideNamedResource(graph, kafkaProducerKey({{ printf "%q" .Name }}), func(di.Resolver) (*kafka.Producer[[]byte], error) { + producer, err := kafka.NewProducer(kafka.ProducerConfig{Brokers: cfg.Kafka.Brokers}, kafka.NewBytesEncoder()) + if err != nil { return nil, fmt.Errorf("kafka.NewProducer: %w", err) } + return producer, nil + }, func(ctx context.Context, producer *kafka.Producer[[]byte]) error { return producer.Close(ctx) }) +} +{{- end }} diff --git a/internal/service/scaffold/templates/main.go.gotmpl b/internal/service/scaffold/templates/main.go.gotmpl new file mode 100644 index 0000000..5936364 --- /dev/null +++ b/internal/service/scaffold/templates/main.go.gotmpl @@ -0,0 +1,48 @@ +package main + +import ( + "context" + "fmt" + "os" + "os/signal" + "syscall" + + "github.com/urfave/cli/v3" +{{- if or .Server .Kafka }} + + appcmd "{{ .Module }}/cmd/{{ .Project }}/internal" +{{- end }} +) + +func main() { + signals := make(chan os.Signal, 1) + signal.Notify(signals, syscall.SIGINT, syscall.SIGTERM) + defer signal.Stop(signals) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go func() { + select { + case <-signals: + cancel() + case <-ctx.Done(): + } + }() + root := &cli.Command{ + Name: "{{ .Project }}", + Usage: "Run {{ .Project }}", +{{- if or .Server .Kafka }} + Commands: []*cli.Command{ +{{- if .Server }} + appcmd.NewCmdAPI(), +{{- end }} +{{- if .Kafka }} + appcmd.NewCmdConsumer(), +{{- end }} + }, +{{- end }} + } + if err := root.Run(ctx, os.Args); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} diff --git a/internal/service/scaffold/templates/mise.toml b/internal/service/scaffold/templates/mise.toml new file mode 100644 index 0000000..b8d73de --- /dev/null +++ b/internal/service/scaffold/templates/mise.toml @@ -0,0 +1,57 @@ +[tools] +go = "1.26.0" +golangci-lint = "2.12.2" +{{- if .JSON }} +node = "24" +"npm:quicktype" = "26.0.0" +{{- end }} +{{- if .Migrations }} +"go:github.com/golang-migrate/migrate/v4/cmd/migrate" = { version = "v4.19.1", tags = [{{- range $index, $tag := .MigrationTags }}{{ if $index }}, {{ end }}{{ printf "%q" $tag }}{{- end }}] } +{{- end }} + +[tasks.fmt] +run = "golangci-lint fmt" +[tasks."fmt:check"] +run = "golangci-lint fmt --diff" +[tasks."lint:contracts"] +run = "devctl lint" +[tasks."lint:go"] +run = "golangci-lint run" +[tasks.lint] +depends = ["lint:contracts", "lint:go"] +[tasks.test] +run = "go test ./..." +[tasks.gen] +run = "devctl gen" +[tasks."gen:http"] +run = "devctl gen http" +[tasks."gen:grpc"] +run = "devctl gen grpc" +[tasks."gen:kafka"] +run = "devctl gen kafka" +[tasks.check] +depends = ["fmt:check", "lint", "test"] +{{ range .Migrations }} +[tasks."{{ .TaskPrefix }}:create"] +description = "Create timestamped migration files in {{ .Path }}" +usage = 'arg "" help="Migration name"' +run = ''' +migrate create -ext sql -dir "{{ .Path }}" -format "20060102150405" "${usage_name?}" +''' + +[tasks."{{ .TaskPrefix }}:up"] +description = "Apply migrations from {{ .Path }}" +run = ''' +database_url="{{ .DatabaseExpression }}" +migrate -path "{{ .Path }}" -database "$database_url" up +''' + +[tasks."{{ .TaskPrefix }}:down"] +description = "Roll back migrations from {{ .Path }}" +usage = 'arg "[steps]" default="1" help="Number of migrations"' +confirm = "Roll back {{ .TaskPrefix }} migrations?" +run = ''' +database_url="{{ .DatabaseExpression }}" +migrate -path "{{ .Path }}" -database "$database_url" down "${usage_steps?}" +''' +{{ end }} diff --git a/internal/service/scaffold/templates/oapi-client.yaml b/internal/service/scaffold/templates/oapi-client.yaml new file mode 100644 index 0000000..3334bfb --- /dev/null +++ b/internal/service/scaffold/templates/oapi-client.yaml @@ -0,0 +1,4 @@ +package: clienthttp +generate: + models: true + client: true diff --git a/internal/service/scaffold/templates/oapi-server.yaml b/internal/service/scaffold/templates/oapi-server.yaml new file mode 100644 index 0000000..45c9a22 --- /dev/null +++ b/internal/service/scaffold/templates/oapi-server.yaml @@ -0,0 +1,6 @@ +package: serverhttp +generate: + models: true + echo5-server: true + strict-server: true + embedded-spec: true diff --git a/internal/service/scaffold/templates/openapi.yaml b/internal/service/scaffold/templates/openapi.yaml new file mode 100644 index 0000000..aa356d4 --- /dev/null +++ b/internal/service/scaffold/templates/openapi.yaml @@ -0,0 +1,5 @@ +openapi: 3.1.0 +info: + title: API + version: 0.0.0 +paths: {} diff --git a/internal/service/scaffold/templates/project-readme.md.gotmpl b/internal/service/scaffold/templates/project-readme.md.gotmpl new file mode 100644 index 0000000..7b9def9 --- /dev/null +++ b/internal/service/scaffold/templates/project-readme.md.gotmpl @@ -0,0 +1,30 @@ +# {{ .Project }} + +This project foundation is scaffolded by Devctl. + +## Bootstrap + +```sh +mise install +go mod download all +go mod tidy +devctl lint +devctl gen +go mod tidy +mise run check +``` + +Inspect the application commands with `go run ./cmd/{{ .Project }} --help`. + +## Updating the foundation + +- Run `devctl sync` after changing remote sources. +- Run `devctl init scaffold` after changing components in `devctl.yaml`. +- Run `devctl gen` after changing API or schema contracts. + +Devctl replaces files ending in `*.gen.go`. Ordinary `.go` files and this +README are created once, so application code and local notes are preserved. + +When a component adds a provider seed, review it and call the provider from +`internal/deps/application.go`. That file is the user-owned composition root; +Devctl does not rewrite its provider list. diff --git a/internal/service/scaffold/templates/redis.go.gotmpl b/internal/service/scaffold/templates/redis.go.gotmpl new file mode 100644 index 0000000..6648814 --- /dev/null +++ b/internal/service/scaffold/templates/redis.go.gotmpl @@ -0,0 +1,18 @@ +package deps + +import ( + "context" + + "github.com/devctllabs/go-libs/di" + redis "github.com/redis/go-redis/v9" +) + +{{- range . }} +const redis{{ goName .Name }}Key = "redis-connection:{{ .Name }}" + +func provideRedis{{ goName .Name }}(_ context.Context, graph *di.Container, cfg *Config) error { + return di.ProvideNamedResource(graph, redis{{ goName .Name }}Key, func(di.Resolver) (*redis.Client, error) { + return redis.NewClient(&redis.Options{Addr: cfg.Redis.{{ goName .Name }}Address}), nil + }, func(ctx context.Context, client *redis.Client) error { return client.Close() }) +} +{{- end }} diff --git a/internal/service/scaffold/templates/runtime.go.gotmpl b/internal/service/scaffold/templates/runtime.go.gotmpl new file mode 100644 index 0000000..35f1fe7 --- /dev/null +++ b/internal/service/scaffold/templates/runtime.go.gotmpl @@ -0,0 +1,243 @@ +package deps + +import ( + "context" + "errors" + "fmt" + + "github.com/devctllabs/go-libs/di" + "github.com/devctllabs/go-libs/lifecycle" +{{- if .HTTP }} + "net/http" + "time" + "github.com/labstack/echo/v5" +{{- end }} +{{- if .GRPC }} + "net" + "google.golang.org/grpc" +{{- end }} +{{- if .Health }} + healthlib "github.com/devctllabs/go-libs/health" + healthserverlib "github.com/devctllabs/go-libs/healthserver" +{{- end }} +{{- if .Pprof }} + debugserverlib "github.com/devctllabs/go-libs/debugserver" +{{- end }} +) + +{{- if .HTTP }} +// HTTPRegistrar is implemented by user-owned application composition. +type HTTPRegistrar interface { RegisterHTTP(*echo.Echo) } +{{- end }} +{{- if .GRPC }} +// GRPCRegistrar is implemented by user-owned application composition. +type GRPCRegistrar interface { RegisterGRPC(*grpc.Server) } +{{- end }} + +// Runtime owns the optional long-lived components selected by configuration. +type Runtime struct { +{{- if .HTTP }} + http *http.Server +{{- if .HTTPToggle }} + httpEnabled bool +{{- end }} +{{- end }} +{{- if .GRPC }} + grpc *grpc.Server + grpcAddress string +{{- if .GRPCToggle }} + grpcEnabled bool +{{- end }} +{{- end }} +{{- if .Health }} + health *healthserverlib.Server +{{- if .HealthToggle }} + healthEnabled bool +{{- end }} +{{- end }} +{{- if .Pprof }} + pprof *debugserverlib.Server +{{- if .PprofToggle }} + pprofEnabled bool +{{- end }} +{{- end }} +} + +func newRuntime(resolver di.Resolver, cfg *Config) (*Runtime, error) { +{{- if .HTTP }} + httpRegistrar, err := di.Resolve[HTTPRegistrar](resolver) + if err != nil { return nil, fmt.Errorf("di.Resolve HTTPRegistrar: %w", err) } +{{- end }} +{{- if .GRPC }} + grpcRegistrar, err := di.Resolve[GRPCRegistrar](resolver) + if err != nil { return nil, fmt.Errorf("di.Resolve GRPCRegistrar: %w", err) } +{{- end }} +{{- if .HealthConnections }} + options := make([]healthlib.Option, 0, {{ len .HealthConnections }}) +{{- range .HealthConnections }} + { + checker, err := di.ResolveNamed[dbChecker](resolver, {{ printf "%q" .Connection }}) + if err != nil { + return nil, fmt.Errorf("di.ResolveNamed: %w", err) + } + options = append(options, healthlib.NonCritical({{ printf "%q" .Probe }}, checker)) + } +{{- end }} +{{- end }} + return NewRuntime(cfg{{ if .HTTP }}, httpRegistrar{{ end }}{{ if .GRPC }}, grpcRegistrar{{ end }}{{ if .HealthConnections }}, options...{{ end }}) +} + +func NewRuntime(cfg *Config{{ if .HTTP }}, httpRegistrar HTTPRegistrar{{ end }}{{ if .GRPC }}, grpcRegistrar GRPCRegistrar{{ end }}{{ if .HealthConnections }}, options ...healthlib.Option{{ end }}) (*Runtime, error) { + if cfg == nil { + return nil, errors.New("config is nil") + } + runtime := &Runtime{} +{{- if .HTTP }} + httpRouter := echo.New() + httpRegistrar.RegisterHTTP(httpRouter) + runtime.http = &http.Server{Addr: cfg.HTTP.Address, Handler: httpRouter, ReadHeaderTimeout: 2 * time.Second, IdleTimeout: 30 * time.Second} +{{- if .HTTPToggle }} + runtime.httpEnabled = cfg.HTTP.Enabled +{{- end }} +{{- end }} +{{- if .GRPC }} + runtime.grpc = newGRPCServer() + grpcRegistrar.RegisterGRPC(runtime.grpc) + runtime.grpcAddress = cfg.GRPC.Address +{{- if .GRPCToggle }} + runtime.grpcEnabled = cfg.GRPC.Enabled +{{- end }} +{{- end }} +{{- if .Health }} + probes, err := healthlib.New({{ if .HealthConnections }}options...{{ end }}) + if err != nil { + return nil, fmt.Errorf("healthlib.New: %w", err) + } + runtime.health, err = healthserverlib.NewServer(probes, healthserverlib.WithAddress(cfg.Health.Address)) + if err != nil { + return nil, fmt.Errorf("healthserverlib.NewServer: %w", err) + } +{{- if .HealthToggle }} + runtime.healthEnabled = cfg.Health.Enabled +{{- end }} +{{- end }} +{{- if .Pprof }} + pprofServer, err := debugserverlib.NewServer(debugserverlib.WithAddress(cfg.Pprof.Address)) + if err != nil { + return nil, fmt.Errorf("debugserverlib.NewServer: %w", err) + } + runtime.pprof = pprofServer +{{- if .PprofToggle }} + runtime.pprofEnabled = cfg.Pprof.Enabled +{{- end }} +{{- end }} + return runtime, nil +} + +func provideRuntime(_ context.Context, graph *di.Container, cfg *Config) error { + return di.ProvideResource(graph, func(resolver di.Resolver) (*Runtime, error) { + return newRuntime(resolver, cfg) + }, func(ctx context.Context, value *Runtime) error { return value.Shutdown(ctx) }) +} + +// Tasks returns enabled runtime roots in deterministic startup order. +func (r *Runtime) Tasks() []lifecycle.Task { + tasks := make([]lifecycle.Task, 0, 3) +{{- if .HTTP }} +{{- if .HTTPToggle }} + if r.httpEnabled { +{{- end }} + tasks = append(tasks, lifecycle.Task{Name: "http", Run: func(context.Context) error { + if err := r.http.ListenAndServe(); err != nil { + return fmt.Errorf("r.http.ListenAndServe: %w", err) + } + return nil + }}) +{{- if .HTTPToggle }} + } +{{- end }} +{{- end }} +{{- if .GRPC }} +{{- if .GRPCToggle }} + if r.grpcEnabled { +{{- end }} + tasks = append(tasks, lifecycle.Task{Name: "grpc", Run: func(context.Context) error { + listener, err := net.Listen("tcp", r.grpcAddress) + if err != nil { return fmt.Errorf("net.Listen: %w", err) } + if err := r.grpc.Serve(listener); err != nil { return fmt.Errorf("r.grpc.Serve: %w", err) } + return nil + }}) +{{- if .GRPCToggle }} + } +{{- end }} +{{- end }} +{{- if .Health }} +{{- if .HealthToggle }} + if r.healthEnabled { +{{- end }} + tasks = append(tasks, lifecycle.Task{Name: "health", Run: func(context.Context) error { + if err := r.health.ListenAndServe(); err != nil { + return fmt.Errorf("r.health.ListenAndServe: %w", err) + } + return nil + }}) +{{- if .HealthToggle }} + } +{{- end }} +{{- end }} +{{- if .Pprof }} +{{- if .PprofToggle }} + if r.pprofEnabled { +{{- end }} + tasks = append(tasks, lifecycle.Task{Name: "pprof", Run: func(context.Context) error { + if err := r.pprof.ListenAndServe(); err != nil { + return fmt.Errorf("r.pprof.ListenAndServe: %w", err) + } + return nil + }}) +{{- if .PprofToggle }} + } +{{- end }} +{{- end }} + return tasks +} + +// Shutdown stops every constructed runtime component and joins cleanup failures. +func (r *Runtime) Shutdown(ctx context.Context) error { + var shutdownErrors []error +{{- if .HTTP }} + if r.http != nil { + if err := r.http.Shutdown(ctx); err != nil { + shutdownErrors = append(shutdownErrors, fmt.Errorf("r.http.Shutdown: %w", err)) + } + } +{{- end }} +{{- if .GRPC }} + if r.grpc != nil { + stopped := make(chan struct{}) + go func() { r.grpc.GracefulStop(); close(stopped) }() + select { + case <-stopped: + case <-ctx.Done(): + r.grpc.Stop() + <-stopped + shutdownErrors = append(shutdownErrors, fmt.Errorf("grpc shutdown: %w", ctx.Err())) + } + } +{{- end }} +{{- if .Health }} + if r.health != nil { + if err := r.health.Shutdown(ctx); err != nil { + shutdownErrors = append(shutdownErrors, fmt.Errorf("r.health.Shutdown: %w", err)) + } + } +{{- end }} +{{- if .Pprof }} + if r.pprof != nil { + if err := r.pprof.Shutdown(ctx); err != nil { + shutdownErrors = append(shutdownErrors, fmt.Errorf("r.pprof.Shutdown: %w", err)) + } + } +{{- end }} + return errors.Join(shutdownErrors...) +} diff --git a/internal/service/scaffold/templates/s3.go.gotmpl b/internal/service/scaffold/templates/s3.go.gotmpl new file mode 100644 index 0000000..88a7d11 --- /dev/null +++ b/internal/service/scaffold/templates/s3.go.gotmpl @@ -0,0 +1,65 @@ +package deps + +import ( + "context" +{{- if .Buckets }} + "fmt" +{{- end }} + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/devctllabs/go-libs/di" + awsconfig "github.com/aws/aws-sdk-go-v2/config" +{{- $static := false }} +{{- range .Connections }}{{ if eq .Credentials "static" }}{{ $static = true }}{{ end }}{{ end }} +{{- if $static }} + "github.com/aws/aws-sdk-go-v2/credentials" +{{- end }} + "github.com/aws/aws-sdk-go-v2/service/s3" +) + +{{- if .Buckets }} +// S3Bucket binds one configured bucket name to its connection. +type S3Bucket struct { + Client *s3.Client + Name string +} +{{- end }} + +{{- range .Connections }} +const s3{{ goName .Name }}Key = "s3-connection:{{ .Name }}" + +func provideS3{{ goName .Name }}(ctx context.Context, graph *di.Container, cfg *Config) error { + return di.ProvideNamedResource(graph, s3{{ goName .Name }}Key, func(di.Resolver) (*s3.Client, error) { + loadOptions := []func(*awsconfig.LoadOptions) error{ + awsconfig.WithRegion(cfg.S3.{{ if and .Name (ne .Name "default") }}{{ goName .Name }}{{ end }}Region), + } +{{- if eq .Credentials "static" }} + loadOptions = append(loadOptions, awsconfig.WithCredentialsProvider(credentials.NewStaticCredentialsProvider( + cfg.S3.{{ if and .Name (ne .Name "default") }}{{ goName .Name }}{{ end }}AccessKeyID, + cfg.S3.{{ if and .Name (ne .Name "default") }}{{ goName .Name }}{{ end }}SecretAccessKey, + "", + ))) +{{- end }} + awsCfg, err := awsconfig.LoadDefaultConfig(ctx, loadOptions...) + if err != nil { return nil, err } + return s3.NewFromConfig(awsCfg, func(options *s3.Options) { + if endpoint := cfg.S3.{{ if and .Name (ne .Name "default") }}{{ goName .Name }}{{ end }}Endpoint; endpoint != "" { + options.BaseEndpoint = aws.String(endpoint) + } + options.UsePathStyle = cfg.S3.{{ if and .Name (ne .Name "default") }}{{ goName .Name }}{{ end }}ForcePathStyle + }), nil + }, func(context.Context, *s3.Client) error { return nil }) +} +{{- end }} + +{{- range .Buckets }} +const s3{{ goName .Name }}BucketKey = "s3-bucket:{{ .Name }}" + +func provideS3{{ goName .Name }}Bucket(_ context.Context, graph *di.Container, cfg *Config) error { + return di.ProvideNamed[S3Bucket](graph, s3{{ goName .Name }}BucketKey, func(resolver di.Resolver) (S3Bucket, error) { + client, err := di.ResolveNamed[*s3.Client](resolver, s3{{ goName .Connection }}Key) + if err != nil { return S3Bucket{}, fmt.Errorf("resolve S3 connection {{ .Connection }}: %w", err) } + return S3Bucket{Client: client, Name: cfg.S3.{{ goName .Name }}Bucket}, nil + }) +} +{{- end }} diff --git a/internal/service/scaffold/templates/storage.go.gotmpl b/internal/service/scaffold/templates/storage.go.gotmpl new file mode 100644 index 0000000..6512437 --- /dev/null +++ b/internal/service/scaffold/templates/storage.go.gotmpl @@ -0,0 +1,133 @@ +package deps + +import ( + "context" + "errors" + "fmt" + + "github.com/devctllabs/go-libs/di" +{{- if not .ClickHouse }} + "github.com/devctllabs/go-libs/txmanager" +{{- end }} +{{- if and .Telemetry (not .ClickHouse) }} + telemetrylib "github.com/devctllabs/go-libs/telemetry" +{{- end }} +{{- if .ClickHouse }} + "github.com/ClickHouse/clickhouse-go/v2" + clickhousedriver "github.com/ClickHouse/clickhouse-go/v2/lib/driver" +{{- end }} +{{- range .Kinds }} + {{ .Name }}db "github.com/devctllabs/go-libs/{{ .Name }}db" +{{- end }} +) + +const storage{{ .Name }}ConnectionName = "db-connection:{{ .Connection }}" + +{{- if .ClickHouse }} +type clickHouse{{ .Name }}Checker struct { connection clickhousedriver.Conn } + +func (c clickHouse{{ .Name }}Checker) Check(ctx context.Context) error { return c.connection.Ping(ctx) } + +func provideStorage{{ .Name }}(ctx context.Context, graph *di.Container, cfg *Config) error { + if err := di.ProvideNamedResource[clickhousedriver.Conn](graph, storage{{ .Name }}ConnectionName, func(di.Resolver) (clickhousedriver.Conn, error) { + options, err := clickhouse.ParseDSN(cfg.DB{{ .Name }}.{{ .ClickHouseConfig }}) + if err != nil { return nil, fmt.Errorf("clickhouse.ParseDSN: %w", err) } + connection, err := clickhouse.Open(options) + if err != nil { return nil, fmt.Errorf("clickhouse.Open: %w", err) } + if err := connection.Ping(ctx); err != nil { + return nil, errors.Join(fmt.Errorf("connection.Ping: %w", err), connection.Close()) + } + return connection, nil + }, func(_ context.Context, connection clickhousedriver.Conn) error { return connection.Close() }); err != nil { + return fmt.Errorf("di.ProvideNamedResource: %w", err) + } + return di.ProvideNamed[dbChecker](graph, storage{{ .Name }}ConnectionName, func(resolver di.Resolver) (dbChecker, error) { + connection, err := di.ResolveNamed[clickhousedriver.Conn](resolver, storage{{ .Name }}ConnectionName) + if err != nil { return nil, fmt.Errorf("di.ResolveNamed: %w", err) } + return clickHouse{{ .Name }}Checker{connection: connection}, nil + }) +} +{{- else }} +type storage{{ .Name }} struct { +{{- range .Kinds }} + {{ .Field }} *{{ .Name }}db.DB +{{- end }} +} + +func provideStorage{{ .Name }}(ctx context.Context, graph *di.Container, cfg *Config) error { + if err := di.ProvideNamedResource[*storage{{ .Name }}](graph, storage{{ .Name }}ConnectionName, func(resolver di.Resolver) (*storage{{ .Name }}, error) { + return openStorage{{ .Name }}(ctx, resolver, cfg) + }, func(_ context.Context, value *storage{{ .Name }}) error { return value.close() }); err != nil { + return fmt.Errorf("di.ProvideNamedResource: %w", err) + } +{{- range .Kinds }} + if err := di.ProvideNamed[*{{ .Name }}db.Endpoint](graph, storage{{ $.Name }}ConnectionName+".reader", func(resolver di.Resolver) (*{{ .Name }}db.Endpoint, error) { + storage, err := di.ResolveNamed[*storage{{ $.Name }}](resolver, storage{{ $.Name }}ConnectionName) + if err != nil { return nil, fmt.Errorf("di.ResolveNamed: %w", err) } + if storage.{{ .Field }} == nil { return nil, fmt.Errorf("storage {{ $.Connection }} does not use {{ .Name }}") } + return storage.{{ .Field }}.Reader(), nil + }); err != nil { return fmt.Errorf("di.ProvideNamed reader: %w", err) } + if err := di.ProvideNamed[*{{ .Name }}db.Endpoint](graph, storage{{ $.Name }}ConnectionName+".writer", func(resolver di.Resolver) (*{{ .Name }}db.Endpoint, error) { + storage, err := di.ResolveNamed[*storage{{ $.Name }}](resolver, storage{{ $.Name }}ConnectionName) + if err != nil { return nil, fmt.Errorf("di.ResolveNamed: %w", err) } + if storage.{{ .Field }} == nil { return nil, fmt.Errorf("storage {{ $.Connection }} does not use {{ .Name }}") } + return storage.{{ .Field }}.Writer(), nil + }); err != nil { return fmt.Errorf("di.ProvideNamed writer: %w", err) } +{{- end }} + if err := di.ProvideNamed[txmanager.Managers](graph, storage{{ .Name }}ConnectionName, func(resolver di.Resolver) (txmanager.Managers, error) { + storage, err := di.ResolveNamed[*storage{{ .Name }}](resolver, storage{{ .Name }}ConnectionName) + if err != nil { return nil, fmt.Errorf("di.ResolveNamed: %w", err) } + return storage.managers(), nil + }); err != nil { return fmt.Errorf("di.ProvideNamed tx managers: %w", err) } + return di.ProvideNamed[dbChecker](graph, storage{{ .Name }}ConnectionName, func(resolver di.Resolver) (dbChecker, error) { + storage, err := di.ResolveNamed[*storage{{ .Name }}](resolver, storage{{ .Name }}ConnectionName) + if err != nil { return nil, fmt.Errorf("di.ResolveNamed: %w", err) } + return storage.checker(), nil + }) +} + +func openStorage{{ .Name }}(ctx context.Context, resolver di.Resolver, cfg *Config) (*storage{{ .Name }}, error) { +{{- if .Telemetry }} + telemetryRuntime, err := di.Resolve[*telemetrylib.Runtime](resolver) + if err != nil { return nil, fmt.Errorf("di.Resolve: %w", err) } +{{- end }} + switch cfg.DB{{ .Name }}.Kind { +{{- range .Variants }} + case {{ printf "%q" .Name }}: +{{- if .SQLite }} + db, err := {{ .Kind }}db.Open(ctx, {{ .Kind }}db.Config{DSN: cfg.DB{{ $.Name }}.{{ .ConfigField }}{{ if $.Telemetry }}, Telemetry: {{ .Kind }}db.Telemetry{TracerProvider: telemetryRuntime.TracerProvider(), MeterProvider: telemetryRuntime.MeterProvider()}{{ end }}}) +{{- else }} + db, err := {{ .Kind }}db.Open(ctx, {{ .Kind }}db.Config{Writer: {{ .Kind }}db.EndpointConfig{DSN: cfg.DB{{ $.Name }}.{{ .ConfigField }}}{{ if $.Telemetry }}, Telemetry: {{ .Kind }}db.Telemetry{TracerProvider: telemetryRuntime.TracerProvider(), MeterProvider: telemetryRuntime.MeterProvider()}{{ end }}}) +{{- end }} + if err != nil { return nil, fmt.Errorf("{{ .Kind }}db.Open: %w", err) } + return &storage{{ $.Name }}{ {{- .Field }}: db}, nil +{{- end }} + default: + return nil, fmt.Errorf("unsupported {{ .Connection }} database kind %q", cfg.DB{{ .Name }}.Kind) + } +} + +func (s *storage{{ .Name }}) managers() txmanager.Managers { +{{- range .Kinds }} + if s.{{ .Field }} != nil { return s.{{ .Field }}.TxManagers() } +{{- end }} + return nil +} + +func (s *storage{{ .Name }}) checker() dbChecker { +{{- range .Kinds }} + if s.{{ .Field }} != nil { return s.{{ .Field }}.Writer() } +{{- end }} + return nil +} + +func (s *storage{{ .Name }}) close() error { + var closeErrors []error +{{- range .Kinds }} + if s.{{ .Field }} != nil { + if err := s.{{ .Field }}.Close(); err != nil { closeErrors = append(closeErrors, fmt.Errorf("close {{ .Name }}: %w", err)) } + } +{{- end }} + return errors.Join(closeErrors...) +} +{{- end }} diff --git a/internal/service/scaffold/testdata/full/.env.example b/internal/service/scaffold/testdata/full/.env.example new file mode 100644 index 0000000..54a143b --- /dev/null +++ b/internal/service/scaffold/testdata/full/.env.example @@ -0,0 +1,44 @@ +SAMPLE_BILLING_GRPC_ADDR= +SAMPLE_CATALOG_BASE_URL= +SAMPLE_DB_ANALYTICS_CLICKHOUSE_DSN= +SAMPLE_DB_ANALYTICS_CLICKHOUSE_MIGRATIONS_URL= +SAMPLE_DB_ANALYTICS_KIND=clickhouse +SAMPLE_DB_PRIMARY_KIND=sqlite +SAMPLE_DB_PRIMARY_POSTGRES_DSN= +SAMPLE_DB_PRIMARY_SQLITE_DSN=file:./data/app.db?_foreign_keys=on +SAMPLE_DEPLOYMENT_ENVIRONMENT=development +SAMPLE_GRPC_ADDR=:9090 +SAMPLE_HEALTH_ADDR=:8081 +SAMPLE_HTTP_ADDR=:8080 +SAMPLE_KAFKA_AUDIT_BATCH_FLUSH_INTERVAL=1s +SAMPLE_KAFKA_AUDIT_BATCH_MAX_SIZE=1 +SAMPLE_KAFKA_AUDIT_GROUP=sample-api-audit-group +SAMPLE_KAFKA_AUDIT_REBALANCE_DRAIN_TIMEOUT=20s +SAMPLE_KAFKA_AUDIT_REBALANCE_TIMEOUT=30s +SAMPLE_KAFKA_AUDIT_RETRY_INITIAL_DELAY=1s +SAMPLE_KAFKA_AUDIT_RETRY_MAX_ATTEMPTS=3 +SAMPLE_KAFKA_AUDIT_RETRY_MAX_DELAY=30s +SAMPLE_KAFKA_AUDIT_RETRY_MAX_ELAPSED_TIME=0s +SAMPLE_KAFKA_AUDIT_SHUTDOWN_TIMEOUT=30s +SAMPLE_KAFKA_AUDIT_TOPIC=sample.audit.events.v1 +SAMPLE_KAFKA_BROKERS=localhost:29092 +SAMPLE_KAFKA_EVENTS_TOPIC=sample.events.v1 +SAMPLE_KAFKA_INVOICE_BATCH_FLUSH_INTERVAL=1s +SAMPLE_KAFKA_INVOICE_BATCH_MAX_SIZE=1 +SAMPLE_KAFKA_INVOICE_GROUP=sample-api-invoice-group +SAMPLE_KAFKA_INVOICE_REBALANCE_DRAIN_TIMEOUT=20s +SAMPLE_KAFKA_INVOICE_REBALANCE_TIMEOUT=30s +SAMPLE_KAFKA_INVOICE_RETRY_INITIAL_DELAY=1s +SAMPLE_KAFKA_INVOICE_RETRY_MAX_ATTEMPTS=3 +SAMPLE_KAFKA_INVOICE_RETRY_MAX_DELAY=30s +SAMPLE_KAFKA_INVOICE_RETRY_MAX_ELAPSED_TIME=0s +SAMPLE_KAFKA_INVOICE_SHUTDOWN_TIMEOUT=30s +SAMPLE_KAFKA_INVOICE_TOPIC=sample.invoice.events.v1 +SAMPLE_LOG_LEVEL=info +SAMPLE_PPROF_ADDR=127.0.0.1:6060 +SAMPLE_REDIS_CACHE_ADDR=localhost:6379 +SAMPLE_S3_ENDPOINT= +SAMPLE_S3_FORCE_PATH_STYLE=false +SAMPLE_S3_MEDIA_BUCKET=media-local +SAMPLE_S3_REGION=us-east-1 +SAMPLE_SERVICE_VERSION=dev diff --git a/internal/service/scaffold/testdata/full/.golangci.yml b/internal/service/scaffold/testdata/full/.golangci.yml new file mode 100644 index 0000000..a01c843 --- /dev/null +++ b/internal/service/scaffold/testdata/full/.golangci.yml @@ -0,0 +1,76 @@ +version: "2" +run: + relative-path-mode: gomod + tests: true + modules-download-mode: readonly +linters: + default: none + enable: + - asasalint + - bidichk + - bodyclose + - containedctx + - contextcheck + - durationcheck + - errcheck + - errchkjson + - errname + - errorlint + - exhaustive + - fatcontext + - forcetypeassert + - gocheckcompilerdirectives + - gochecknoinits + - gocognit + - govet + - inamedparam + - ineffassign + - loggercheck + - makezero + - musttag + - nilerr + - nilnesserr + - noctx + - nolintlint + - nosprintfhostport + - paralleltest + - predeclared + - reassign + - recvcheck + - revive + - rowserrcheck + - sqlclosecheck + - staticcheck + - testifylint + - thelper + - tparallel + - unconvert + - unused + - usetesting + - wastedassign + - wrapcheck + settings: + gocognit: + min-complexity: 20 + nolintlint: + allow-unused: false + require-explanation: true + require-specific: true + paralleltest: + ignore-missing: false + ignore-missing-subtests: false + check-cleanup: true + revive: + rules: + - name: argument-limit + arguments: [4] + - name: function-result-limit + arguments: [3] + exclusions: + generated: strict + paths: ["^gen/"] +formatters: + enable: [gofmt] + exclusions: + generated: strict + paths: ["^gen/"] diff --git a/internal/service/scaffold/testdata/full/.mise.toml b/internal/service/scaffold/testdata/full/.mise.toml new file mode 100644 index 0000000..30c9c17 --- /dev/null +++ b/internal/service/scaffold/testdata/full/.mise.toml @@ -0,0 +1,52 @@ +[tools] +go = "1.26.0" +golangci-lint = "2.12.2" +node = "24" +"npm:quicktype" = "26.0.0" +"go:github.com/golang-migrate/migrate/v4/cmd/migrate" = { version = "v4.19.1", tags = ["clickhouse"] } + +[tasks.fmt] +run = "golangci-lint fmt" +[tasks."fmt:check"] +run = "golangci-lint fmt --diff" +[tasks."lint:contracts"] +run = "devctl lint" +[tasks."lint:go"] +run = "golangci-lint run" +[tasks.lint] +depends = ["lint:contracts", "lint:go"] +[tasks.test] +run = "go test ./..." +[tasks.gen] +run = "devctl gen" +[tasks."gen:http"] +run = "devctl gen http" +[tasks."gen:grpc"] +run = "devctl gen grpc" +[tasks."gen:kafka"] +run = "devctl gen kafka" +[tasks.check] +depends = ["fmt:check", "lint", "test"] + +[tasks."migrate:analytics:clickhouse:create"] +description = "Create timestamped migration files in migrations/analytics/clickhouse" +usage = 'arg "" help="Migration name"' +run = ''' +migrate create -ext sql -dir "migrations/analytics/clickhouse" -format "20060102150405" "${usage_name?}" +''' + +[tasks."migrate:analytics:clickhouse:up"] +description = "Apply migrations from migrations/analytics/clickhouse" +run = ''' +database_url="${SAMPLE_DB_ANALYTICS_CLICKHOUSE_MIGRATIONS_URL:?set SAMPLE_DB_ANALYTICS_CLICKHOUSE_MIGRATIONS_URL}" +migrate -path "migrations/analytics/clickhouse" -database "$database_url" up +''' + +[tasks."migrate:analytics:clickhouse:down"] +description = "Roll back migrations from migrations/analytics/clickhouse" +usage = 'arg "[steps]" default="1" help="Number of migrations"' +confirm = "Roll back migrate:analytics:clickhouse migrations?" +run = ''' +database_url="${SAMPLE_DB_ANALYTICS_CLICKHOUSE_MIGRATIONS_URL:?set SAMPLE_DB_ANALYTICS_CLICKHOUSE_MIGRATIONS_URL}" +migrate -path "migrations/analytics/clickhouse" -database "$database_url" down "${usage_steps?}" +''' diff --git a/internal/service/scaffold/testdata/full/README.md b/internal/service/scaffold/testdata/full/README.md new file mode 100644 index 0000000..af2c059 --- /dev/null +++ b/internal/service/scaffold/testdata/full/README.md @@ -0,0 +1,30 @@ +# sample-api + +This project foundation is scaffolded by Devctl. + +## Bootstrap + +```sh +mise install +go mod download all +go mod tidy +devctl lint +devctl gen +go mod tidy +mise run check +``` + +Inspect the application commands with `go run ./cmd/sample-api --help`. + +## Updating the foundation + +- Run `devctl sync` after changing remote sources. +- Run `devctl init scaffold` after changing components in `devctl.yaml`. +- Run `devctl gen` after changing API or schema contracts. + +Devctl replaces files ending in `*.gen.go`. Ordinary `.go` files and this +README are created once, so application code and local notes are preserved. + +When a component adds a provider seed, review it and call the provider from +`internal/deps/application.go`. That file is the user-owned composition root; +Devctl does not rewrite its provider list. diff --git a/internal/service/scaffold/testdata/full/api/openapi/swagger.yaml b/internal/service/scaffold/testdata/full/api/openapi/swagger.yaml new file mode 100644 index 0000000..aa356d4 --- /dev/null +++ b/internal/service/scaffold/testdata/full/api/openapi/swagger.yaml @@ -0,0 +1,5 @@ +openapi: 3.1.0 +info: + title: API + version: 0.0.0 +paths: {} diff --git a/internal/service/scaffold/testdata/full/buf.yaml b/internal/service/scaffold/testdata/full/buf.yaml new file mode 100644 index 0000000..30947eb --- /dev/null +++ b/internal/service/scaffold/testdata/full/buf.yaml @@ -0,0 +1,11 @@ +version: v2 +modules: + - path: api/proto +lint: + use: + - STANDARD + except: + - FILE_LOWER_SNAKE_CASE +breaking: + use: + - FILE diff --git a/internal/service/scaffold/testdata/full/cmd/sample-api/internal/api.go b/internal/service/scaffold/testdata/full/cmd/sample-api/internal/api.go new file mode 100644 index 0000000..d12ec7a --- /dev/null +++ b/internal/service/scaffold/testdata/full/cmd/sample-api/internal/api.go @@ -0,0 +1,27 @@ +package internal + +import ( + "context" + "fmt" + + "example.test/sample-api/internal/deps" + "github.com/urfave/cli/v3" +) + +// NewCmdAPI constructs the API server command. +func NewCmdAPI() *cli.Command { + return &cli.Command{ + Name: "api", + Usage: "Run API servers", + Action: func(ctx context.Context, _ *cli.Command) error { + scenario, err := deps.NewAPI(ctx) + if err != nil { + return fmt.Errorf("deps.NewAPI: %w", err) + } + if err := scenario.Run(ctx); err != nil { + return fmt.Errorf("scenario.Run: %w", err) + } + return nil + }, + } +} diff --git a/internal/service/scaffold/testdata/full/cmd/sample-api/internal/consumer.go b/internal/service/scaffold/testdata/full/cmd/sample-api/internal/consumer.go new file mode 100644 index 0000000..5cfbc41 --- /dev/null +++ b/internal/service/scaffold/testdata/full/cmd/sample-api/internal/consumer.go @@ -0,0 +1,27 @@ +package internal + +import ( + "context" + "fmt" + + "example.test/sample-api/internal/deps" + "github.com/urfave/cli/v3" +) + +// NewCmdConsumer constructs the named Kafka consumer command. +func NewCmdConsumer() *cli.Command { + return &cli.Command{ + Name: "consumer", + Arguments: []cli.Argument{&cli.StringArg{Name: "consumer-name"}}, + Action: func(ctx context.Context, command *cli.Command) error { + scenario, err := deps.NewConsumer(ctx, command.StringArg("consumer-name")) + if err != nil { + return fmt.Errorf("deps.NewConsumer: %w", err) + } + if err := scenario.Run(ctx); err != nil { + return fmt.Errorf("scenario.Run: %w", err) + } + return nil + }, + } +} diff --git a/internal/service/scaffold/testdata/full/cmd/sample-api/main.go b/internal/service/scaffold/testdata/full/cmd/sample-api/main.go new file mode 100644 index 0000000..2b3f732 --- /dev/null +++ b/internal/service/scaffold/testdata/full/cmd/sample-api/main.go @@ -0,0 +1,40 @@ +package main + +import ( + "context" + "fmt" + "os" + "os/signal" + "syscall" + + "github.com/urfave/cli/v3" + + appcmd "example.test/sample-api/cmd/sample-api/internal" +) + +func main() { + signals := make(chan os.Signal, 1) + signal.Notify(signals, syscall.SIGINT, syscall.SIGTERM) + defer signal.Stop(signals) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go func() { + select { + case <-signals: + cancel() + case <-ctx.Done(): + } + }() + root := &cli.Command{ + Name: "sample-api", + Usage: "Run sample-api", + Commands: []*cli.Command{ + appcmd.NewCmdAPI(), + appcmd.NewCmdConsumer(), + }, + } + if err := root.Run(ctx, os.Args); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} diff --git a/internal/service/scaffold/testdata/full/data/.gitkeep b/internal/service/scaffold/testdata/full/data/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/internal/service/scaffold/testdata/full/gen/config/config.gen.go b/internal/service/scaffold/testdata/full/gen/config/config.gen.go new file mode 100644 index 0000000..1021cfd --- /dev/null +++ b/internal/service/scaffold/testdata/full/gen/config/config.gen.go @@ -0,0 +1,115 @@ +// Code generated by devctl. DO NOT EDIT. + +package config + +import ( + "fmt" + "time" +) + +type Config struct { + DBAnalytics DBAnalyticsConfig + DBPrimary DBPrimaryConfig + GRPC GRPCConfig + GRPCClients GRPCClientsConfig + HTTP HTTPConfig + HTTPClients HTTPClientsConfig + Health HealthConfig + Kafka KafkaConfig + Logging LoggingConfig + Pprof PprofConfig + Redis RedisConfig + S3 S3Config + Telemetry TelemetryConfig +} + +type DBAnalyticsConfig struct { + ClickhouseDSN string `env:"SAMPLE_DB_ANALYTICS_CLICKHOUSE_DSN"` + Kind string `env:"SAMPLE_DB_ANALYTICS_KIND" default:"clickhouse"` +} + +type DBPrimaryConfig struct { + Kind string `env:"SAMPLE_DB_PRIMARY_KIND" default:"sqlite"` + PostgresDSN string `env:"SAMPLE_DB_PRIMARY_POSTGRES_DSN"` + SqliteDSN string `env:"SAMPLE_DB_PRIMARY_SQLITE_DSN" default:"file:./data/app.db?_foreign_keys=on"` +} + +type GRPCConfig struct { + Address string `env:"SAMPLE_GRPC_ADDR" default:":9090"` +} + +type GRPCClientsConfig struct { + BillingAddress string `env:"SAMPLE_BILLING_GRPC_ADDR"` +} + +type HTTPConfig struct { + Address string `env:"SAMPLE_HTTP_ADDR" default:":8080"` +} + +type HTTPClientsConfig struct { + CatalogBaseURL string `env:"SAMPLE_CATALOG_BASE_URL"` +} + +type HealthConfig struct { + Address string `env:"SAMPLE_HEALTH_ADDR" default:":8081"` +} + +type KafkaConfig struct { + AuditBatchFlushInterval time.Duration `env:"SAMPLE_KAFKA_AUDIT_BATCH_FLUSH_INTERVAL" default:"1s"` + AuditBatchMaxSize int `env:"SAMPLE_KAFKA_AUDIT_BATCH_MAX_SIZE" default:"1"` + AuditGroup string `env:"SAMPLE_KAFKA_AUDIT_GROUP" default:"sample-api-audit-group"` + AuditRebalanceDrainTimeout time.Duration `env:"SAMPLE_KAFKA_AUDIT_REBALANCE_DRAIN_TIMEOUT" default:"20s"` + AuditRebalanceTimeout time.Duration `env:"SAMPLE_KAFKA_AUDIT_REBALANCE_TIMEOUT" default:"30s"` + AuditRetryInitialDelay time.Duration `env:"SAMPLE_KAFKA_AUDIT_RETRY_INITIAL_DELAY" default:"1s"` + AuditRetryMaxAttempts int `env:"SAMPLE_KAFKA_AUDIT_RETRY_MAX_ATTEMPTS" default:"3"` + AuditRetryMaxDelay time.Duration `env:"SAMPLE_KAFKA_AUDIT_RETRY_MAX_DELAY" default:"30s"` + AuditRetryMaxElapsedTime time.Duration `env:"SAMPLE_KAFKA_AUDIT_RETRY_MAX_ELAPSED_TIME" default:"0s"` + AuditShutdownTimeout time.Duration `env:"SAMPLE_KAFKA_AUDIT_SHUTDOWN_TIMEOUT" default:"30s"` + AuditTopic string `env:"SAMPLE_KAFKA_AUDIT_TOPIC" default:"sample.audit.events.v1"` + Brokers []string `env:"SAMPLE_KAFKA_BROKERS" default:"localhost:29092"` + EventsTopic string `env:"SAMPLE_KAFKA_EVENTS_TOPIC" default:"sample.events.v1"` + InvoiceBatchFlushInterval time.Duration `env:"SAMPLE_KAFKA_INVOICE_BATCH_FLUSH_INTERVAL" default:"1s"` + InvoiceBatchMaxSize int `env:"SAMPLE_KAFKA_INVOICE_BATCH_MAX_SIZE" default:"1"` + InvoiceGroup string `env:"SAMPLE_KAFKA_INVOICE_GROUP" default:"sample-api-invoice-group"` + InvoiceRebalanceDrainTimeout time.Duration `env:"SAMPLE_KAFKA_INVOICE_REBALANCE_DRAIN_TIMEOUT" default:"20s"` + InvoiceRebalanceTimeout time.Duration `env:"SAMPLE_KAFKA_INVOICE_REBALANCE_TIMEOUT" default:"30s"` + InvoiceRetryInitialDelay time.Duration `env:"SAMPLE_KAFKA_INVOICE_RETRY_INITIAL_DELAY" default:"1s"` + InvoiceRetryMaxAttempts int `env:"SAMPLE_KAFKA_INVOICE_RETRY_MAX_ATTEMPTS" default:"3"` + InvoiceRetryMaxDelay time.Duration `env:"SAMPLE_KAFKA_INVOICE_RETRY_MAX_DELAY" default:"30s"` + InvoiceRetryMaxElapsedTime time.Duration `env:"SAMPLE_KAFKA_INVOICE_RETRY_MAX_ELAPSED_TIME" default:"0s"` + InvoiceShutdownTimeout time.Duration `env:"SAMPLE_KAFKA_INVOICE_SHUTDOWN_TIMEOUT" default:"30s"` + InvoiceTopic string `env:"SAMPLE_KAFKA_INVOICE_TOPIC" default:"sample.invoice.events.v1"` +} + +type LoggingConfig struct { + Level string `env:"SAMPLE_LOG_LEVEL" default:"info"` +} + +type PprofConfig struct { + Address string `env:"SAMPLE_PPROF_ADDR" default:"127.0.0.1:6060"` +} + +type RedisConfig struct { + CacheAddress string `env:"SAMPLE_REDIS_CACHE_ADDR" default:"localhost:6379"` +} + +type S3Config struct { + Endpoint string `env:"SAMPLE_S3_ENDPOINT"` + ForcePathStyle bool `env:"SAMPLE_S3_FORCE_PATH_STYLE" default:"false"` + MediaBucket string `env:"SAMPLE_S3_MEDIA_BUCKET" default:"media-local"` + Region string `env:"SAMPLE_S3_REGION" default:"us-east-1"` +} + +type TelemetryConfig struct { + DeploymentEnvironment string `env:"SAMPLE_DEPLOYMENT_ENVIRONMENT" default:"development"` + ServiceVersion string `env:"SAMPLE_SERVICE_VERSION" default:"dev"` +} + +func (c *Config) Validate() error { + if c == nil { + return fmt.Errorf("config is nil") + } + return nil +} + +var _ time.Duration diff --git a/internal/service/scaffold/testdata/full/go.mod b/internal/service/scaffold/testdata/full/go.mod new file mode 100644 index 0000000..3fd9045 --- /dev/null +++ b/internal/service/scaffold/testdata/full/go.mod @@ -0,0 +1,45 @@ +module example.test/sample-api + +go 1.26.0 + +require ( + github.com/ClickHouse/clickhouse-go/v2 v2.48.0 + github.com/aws/aws-sdk-go-v2 v1.45.1 + github.com/aws/aws-sdk-go-v2/config v1.33.1 + github.com/aws/aws-sdk-go-v2/credentials v1.20.1 + github.com/aws/aws-sdk-go-v2/service/s3 v1.109.1 + github.com/bufbuild/buf v1.72.0 + github.com/devctllabs/go-libs/config v0.1.0 + github.com/devctllabs/go-libs/debugserver v0.1.0 + github.com/devctllabs/go-libs/di v0.1.0 + github.com/devctllabs/go-libs/health v0.1.0 + github.com/devctllabs/go-libs/healthserver v0.1.0 + github.com/devctllabs/go-libs/kafka v0.1.0 + github.com/devctllabs/go-libs/kafkaproto v0.1.0 + github.com/devctllabs/go-libs/lifecycle v0.2.0 + github.com/devctllabs/go-libs/log v0.2.0 + github.com/devctllabs/go-libs/oapivalidator v0.2.0 + github.com/devctllabs/go-libs/postgresdb v0.2.0 + github.com/devctllabs/go-libs/retry v0.1.0 + github.com/devctllabs/go-libs/sqlitedb v0.1.0 + github.com/devctllabs/go-libs/telemetry v0.1.0 + github.com/devctllabs/go-libs/txmanager v0.1.0 + github.com/labstack/echo/v5 v5.3.1 + github.com/redis/go-redis/v9 v9.22.0 + github.com/twmb/franz-go v1.21.6 + github.com/urfave/cli/v3 v3.10.1 + go.uber.org/zap v1.28.0 + google.golang.org/grpc v1.83.2 + google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.6.2 + google.golang.org/protobuf v1.36.12 +) + +tool github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen + +require github.com/oapi-codegen/oapi-codegen/v2 v2.8.0 + +tool ( + github.com/bufbuild/buf/cmd/buf + google.golang.org/grpc/cmd/protoc-gen-go-grpc + google.golang.org/protobuf/cmd/protoc-gen-go +) diff --git a/internal/service/scaffold/testdata/full/internal/deps/application.go b/internal/service/scaffold/testdata/full/internal/deps/application.go new file mode 100644 index 0000000..e6a88bb --- /dev/null +++ b/internal/service/scaffold/testdata/full/internal/deps/application.go @@ -0,0 +1,66 @@ +package deps + +import ( + "context" + "fmt" + + "github.com/devctllabs/go-libs/di" + "github.com/labstack/echo/v5" + "google.golang.org/grpc" +) + +// application is the user-owned composition root. Add application dependencies here. +type application struct{} + +func (*application) RegisterHTTP(*echo.Echo) {} +func (*application) RegisterGRPC(*grpc.Server) {} + +// provideApplication is created once. Add newly scaffolded provider calls manually. +func provideApplication(ctx context.Context, graph *di.Container, cfg *Config) error { + if err := di.Provide[HTTPRegistrar](graph, func(di.Resolver) (HTTPRegistrar, error) { return &application{}, nil }); err != nil { + return fmt.Errorf("di.Provide HTTPRegistrar: %w", err) + } + if err := di.Provide[GRPCRegistrar](graph, func(di.Resolver) (GRPCRegistrar, error) { return &application{}, nil }); err != nil { + return fmt.Errorf("di.Provide GRPCRegistrar: %w", err) + } + if err := provideLogging(ctx, graph, cfg); err != nil { + return fmt.Errorf("provideLogging: %w", err) + } + if err := provideTelemetry(ctx, graph, cfg); err != nil { + return fmt.Errorf("provideTelemetry: %w", err) + } + if err := provideStoragePrimary(ctx, graph, cfg); err != nil { + return fmt.Errorf("provideStoragePrimary: %w", err) + } + if err := provideStorageAnalytics(ctx, graph, cfg); err != nil { + return fmt.Errorf("provideStorageAnalytics: %w", err) + } + if err := provideAuditConsumer(ctx, graph, cfg); err != nil { + return fmt.Errorf("provideAuditConsumer: %w", err) + } + if err := provideInvoiceConsumer(ctx, graph, cfg); err != nil { + return fmt.Errorf("provideInvoiceConsumer: %w", err) + } + if err := provideEventsKafkaProducer(ctx, graph, cfg); err != nil { + return fmt.Errorf("provideEventsKafkaProducer: %w", err) + } + if err := provideCatalogHTTPClient(ctx, graph, cfg); err != nil { + return fmt.Errorf("provideCatalogHTTPClient: %w", err) + } + if err := provideBillingGRPCClient(ctx, graph, cfg); err != nil { + return fmt.Errorf("provideBillingGRPCClient: %w", err) + } + if err := provideRedisCache(ctx, graph, cfg); err != nil { + return fmt.Errorf("provideRedisCache: %w", err) + } + if err := provideS3Default(ctx, graph, cfg); err != nil { + return fmt.Errorf("provideS3Default: %w", err) + } + if err := provideS3MediaBucket(ctx, graph, cfg); err != nil { + return fmt.Errorf("provideS3MediaBucket: %w", err) + } + if err := provideRuntime(ctx, graph, cfg); err != nil { + return fmt.Errorf("provideRuntime: %w", err) + } + return nil +} diff --git a/internal/service/scaffold/testdata/full/internal/deps/config.gen.go b/internal/service/scaffold/testdata/full/internal/deps/config.gen.go new file mode 100644 index 0000000..99f5dc3 --- /dev/null +++ b/internal/service/scaffold/testdata/full/internal/deps/config.gen.go @@ -0,0 +1,23 @@ +// Code generated by devctl. DO NOT EDIT. + +package deps + +import ( + "context" + "fmt" + + generatedconfig "example.test/sample-api/gen/config" + configlib "github.com/devctllabs/go-libs/config" +) + +// Config is the canonical generated runtime configuration. +type Config = generatedconfig.Config + +func loadConfig(ctx context.Context) (*Config, error) { + var cfg Config + loader := configlib.Chain(configlib.Defaults(), configlib.OSEnv()) + if err := loader.Load(ctx, &cfg); err != nil { + return nil, fmt.Errorf("loader.Load: %w", err) + } + return &cfg, nil +} diff --git a/internal/service/scaffold/testdata/full/internal/deps/consumer_audit.go b/internal/service/scaffold/testdata/full/internal/deps/consumer_audit.go new file mode 100644 index 0000000..99135ca --- /dev/null +++ b/internal/service/scaffold/testdata/full/internal/deps/consumer_audit.go @@ -0,0 +1,35 @@ +package deps + +import ( + "context" + "fmt" + + auditconsumer "example.test/sample-api/internal/transport/consumerkafka/audit" + "github.com/devctllabs/go-libs/di" + kafka "github.com/devctllabs/go-libs/kafka" + retry "github.com/devctllabs/go-libs/retry" +) + +// provideAuditConsumer is user-owned: change []byte and the decoder for generated schema types. +func provideAuditConsumer(ctx context.Context, graph *di.Container, cfg *Config) error { + key := kafkaConsumerKey("audit") + if err := provideAuditConsumerConfig(ctx, graph, cfg); err != nil { + return fmt.Errorf("provide consumer config: %w", err) + } + if err := di.ProvideNamedValue[kafka.Decoder[[]byte]](graph, key, rawKafkaDecoder()); err != nil { + return fmt.Errorf("provide consumer decoder: %w", err) + } + if err := di.ProvideNamedValue[kafka.BatchHandler[[]byte]](graph, key, auditconsumer.NewHandler()); err != nil { + return fmt.Errorf("provide consumer handler: %w", err) + } + if err := di.ProvideNamed[retry.Policy](graph, key, func(di.Resolver) (retry.Policy, error) { + return retry.NewExponential(retry.ExponentialConfig{ + InitialDelay: cfg.Kafka.AuditRetryInitialDelay, + MaxDelay: cfg.Kafka.AuditRetryMaxDelay, + Multiplier: 2, + }) + }); err != nil { + return fmt.Errorf("provide consumer retry policy: %w", err) + } + return provideConsumer[[]byte](graph, key) +} diff --git a/internal/service/scaffold/testdata/full/internal/deps/consumer_invoice.go b/internal/service/scaffold/testdata/full/internal/deps/consumer_invoice.go new file mode 100644 index 0000000..202e6d7 --- /dev/null +++ b/internal/service/scaffold/testdata/full/internal/deps/consumer_invoice.go @@ -0,0 +1,35 @@ +package deps + +import ( + "context" + "fmt" + + invoiceconsumer "example.test/sample-api/internal/transport/consumerkafka/invoice" + "github.com/devctllabs/go-libs/di" + kafka "github.com/devctllabs/go-libs/kafka" + retry "github.com/devctllabs/go-libs/retry" +) + +// provideInvoiceConsumer is user-owned: change []byte and the decoder for generated schema types. +func provideInvoiceConsumer(ctx context.Context, graph *di.Container, cfg *Config) error { + key := kafkaConsumerKey("invoice") + if err := provideInvoiceConsumerConfig(ctx, graph, cfg); err != nil { + return fmt.Errorf("provide consumer config: %w", err) + } + if err := di.ProvideNamedValue[kafka.Decoder[[]byte]](graph, key, rawKafkaDecoder()); err != nil { + return fmt.Errorf("provide consumer decoder: %w", err) + } + if err := di.ProvideNamedValue[kafka.BatchHandler[[]byte]](graph, key, invoiceconsumer.NewHandler()); err != nil { + return fmt.Errorf("provide consumer handler: %w", err) + } + if err := di.ProvideNamed[retry.Policy](graph, key, func(di.Resolver) (retry.Policy, error) { + return retry.NewExponential(retry.ExponentialConfig{ + InitialDelay: cfg.Kafka.InvoiceRetryInitialDelay, + MaxDelay: cfg.Kafka.InvoiceRetryMaxDelay, + Multiplier: 2, + }) + }); err != nil { + return fmt.Errorf("provide consumer retry policy: %w", err) + } + return provideConsumer[[]byte](graph, key) +} diff --git a/internal/service/scaffold/testdata/full/internal/deps/container.gen.go b/internal/service/scaffold/testdata/full/internal/deps/container.gen.go new file mode 100644 index 0000000..e6bd630 --- /dev/null +++ b/internal/service/scaffold/testdata/full/internal/deps/container.gen.go @@ -0,0 +1,141 @@ +// Code generated by devctl. DO NOT EDIT. + +package deps + +import ( + "context" + "errors" + "fmt" + "github.com/devctllabs/go-libs/di" + "github.com/devctllabs/go-libs/lifecycle" + loglib "github.com/devctllabs/go-libs/log" + telemetrylib "github.com/devctllabs/go-libs/telemetry" + "go.uber.org/zap" + "go.uber.org/zap/zapcore" + "time" +) + +type dbChecker interface{ Check(context.Context) error } +type scenarioRunner interface{ Run(context.Context) error } + +// Scenario owns one lazily resolved runnable branch and its dependency graph. +type Scenario struct { + graph *di.Container + tasks []lifecycle.Task +} + +// Run coordinates the selected branch until cancellation or failure. +func (s *Scenario) Run(ctx context.Context) error { + return lifecycle.Run(ctx, lifecycle.Config{ + ShutdownTimeout: 30 * time.Second, + Shutdown: s.Shutdown, + Tasks: s.tasks, + }) +} + +// Shutdown closes only resources constructed by this Scenario. +func (s *Scenario) Shutdown(ctx context.Context) error { + if s == nil || s.graph == nil { + return nil + } + if err := s.graph.Shutdown(ctx); err != nil { + return fmt.Errorf("graph.Shutdown: %w", err) + } + return nil +} + +func newScenarioGraph(ctx context.Context) (*di.Container, *Config, error) { + cfg, err := loadConfig(ctx) + if err != nil { + return nil, nil, fmt.Errorf("loadConfig: %w", err) + } + graph := di.New() + if err := di.ProvideValue(graph, cfg); err != nil { + return nil, nil, fmt.Errorf("di.ProvideValue: %w", err) + } + if err := provideApplication(ctx, graph, cfg); err != nil { + shutdownErr := graph.Shutdown(context.WithoutCancel(ctx)) + return nil, nil, errors.Join(fmt.Errorf("provideApplication: %w", err), shutdownErr) + } + return graph, cfg, nil +} +func provideLogging(_ context.Context, graph *di.Container, cfg *Config) error { + return di.ProvideResource(graph, func(di.Resolver) (*zap.Logger, error) { + level := zapcore.InfoLevel + if err := level.Set(cfg.Logging.Level); err != nil { + return nil, fmt.Errorf("logging level: %w", err) + } + return loglib.New(level, false).Named("sample-api"), nil + }, func(_ context.Context, value *zap.Logger) error { _ = value.Sync(); return nil }) +} +func provideTelemetry(ctx context.Context, graph *di.Container, cfg *Config) error { + return di.ProvideResource(graph, func(di.Resolver) (*telemetrylib.Runtime, error) { + value, err := telemetrylib.Open(ctx, telemetrylib.Config{ + Enabled: true, + ServiceName: "sample-api", + ServiceVersion: cfg.Telemetry.ServiceVersion, + DeploymentEnvironment: cfg.Telemetry.DeploymentEnvironment, + }) + if err != nil { + return nil, fmt.Errorf("telemetrylib.Open: %w", err) + } + return value, nil + }, func(ctx context.Context, value *telemetrylib.Runtime) error { return value.Shutdown(ctx) }) +} + +// NewAPI resolves only the API Runtime branch. +func NewAPI(ctx context.Context) (*Scenario, error) { + graph, _, err := newScenarioGraph(ctx) + if err != nil { + return nil, err + } + runtime, err := di.Resolve[*Runtime](graph) + if err != nil { + return nil, errors.Join(fmt.Errorf("di.Resolve Runtime: %w", err), graph.Shutdown(context.WithoutCancel(ctx))) + } + return &Scenario{graph: graph, tasks: runtime.Tasks()}, nil +} + +// ConsumerSelectionError reports an unknown or disabled selected consumer. +type ConsumerSelectionError struct { + Name string + Reason string +} + +func (e *ConsumerSelectionError) Error() string { + return fmt.Sprintf("Kafka consumer %q is %s", e.Name, e.Reason) +} + +// NewConsumer validates selection before resolving the selected consumer branch. +func NewConsumer(ctx context.Context, name string) (*Scenario, error) { + cfg, err := loadConfig(ctx) + if err != nil { + return nil, fmt.Errorf("loadConfig: %w", err) + } + switch name { + case "audit": + if !(true) { + return nil, &ConsumerSelectionError{Name: name, Reason: "disabled"} + } + case "invoice": + if !(true) { + return nil, &ConsumerSelectionError{Name: name, Reason: "disabled"} + } + default: + return nil, &ConsumerSelectionError{Name: name, Reason: "unknown"} + } + graph := di.New() + if err := di.ProvideValue(graph, cfg); err != nil { + return nil, fmt.Errorf("di.ProvideValue: %w", err) + } + if err := provideApplication(ctx, graph, cfg); err != nil { + return nil, errors.Join(fmt.Errorf("provideApplication: %w", err), graph.Shutdown(context.WithoutCancel(ctx))) + } + runner, err := di.ResolveNamed[scenarioRunner](graph, kafkaConsumerKey(name)) + if err != nil { + return nil, errors.Join(fmt.Errorf("di.ResolveNamed consumer: %w", err), graph.Shutdown(context.WithoutCancel(ctx))) + } + return &Scenario{graph: graph, tasks: []lifecycle.Task{ + {Name: "kafka-consumer:" + name, Run: runner.Run}, + }}, nil +} diff --git a/internal/service/scaffold/testdata/full/internal/deps/grpc.gen.go b/internal/service/scaffold/testdata/full/internal/deps/grpc.gen.go new file mode 100644 index 0000000..385bcdb --- /dev/null +++ b/internal/service/scaffold/testdata/full/internal/deps/grpc.gen.go @@ -0,0 +1,8 @@ +// Code generated by devctl. DO NOT EDIT. + +package deps + +import "google.golang.org/grpc" + +// newGRPCServer constructs the handwritten gRPC runtime boundary. +func newGRPCServer() *grpc.Server { return grpc.NewServer() } diff --git a/internal/service/scaffold/testdata/full/internal/deps/grpc_clients.gen.go b/internal/service/scaffold/testdata/full/internal/deps/grpc_clients.gen.go new file mode 100644 index 0000000..23d2621 --- /dev/null +++ b/internal/service/scaffold/testdata/full/internal/deps/grpc_clients.gen.go @@ -0,0 +1,23 @@ +// Code generated by devctl. DO NOT EDIT. + +package deps + +import ( + "context" + + "github.com/devctllabs/go-libs/di" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" +) + +const grpcClientBillingKey = "grpc-client:billing" + +func provideBillingGRPCClient(_ context.Context, graph *di.Container, cfg *Config) error { + return di.ProvideNamedResource(graph, grpcClientBillingKey, func(di.Resolver) (*grpc.ClientConn, error) { + return grpc.NewClient(cfg.GRPCClients.BillingAddress, grpc.WithTransportCredentials(insecure.NewCredentials())) + }, func(_ context.Context, connection *grpc.ClientConn) error { return connection.Close() }) +} + +func BillingGRPCConn(resolver di.Resolver) (*grpc.ClientConn, error) { + return di.ResolveNamed[*grpc.ClientConn](resolver, grpcClientBillingKey) +} diff --git a/internal/service/scaffold/testdata/full/internal/deps/http_clients.gen.go b/internal/service/scaffold/testdata/full/internal/deps/http_clients.gen.go new file mode 100644 index 0000000..952cc4a --- /dev/null +++ b/internal/service/scaffold/testdata/full/internal/deps/http_clients.gen.go @@ -0,0 +1,30 @@ +// Code generated by devctl. DO NOT EDIT. + +package deps + +import ( + "context" + "net/http" + + "github.com/devctllabs/go-libs/di" +) + +type httpBaseURL string + +const httpClientCatalogKey = "http-client:catalog" + +func provideCatalogHTTPClient(_ context.Context, graph *di.Container, cfg *Config) error { + if err := di.ProvideNamedValue(graph, httpClientCatalogKey, &http.Client{}); err != nil { + return err + } + return di.ProvideNamedValue(graph, httpClientCatalogKey, httpBaseURL(cfg.HTTPClients.CatalogBaseURL)) +} + +func CatalogHTTPTransport(resolver di.Resolver) (*http.Client, error) { + return di.ResolveNamed[*http.Client](resolver, httpClientCatalogKey) +} + +func CatalogHTTPBaseURL(resolver di.Resolver) (string, error) { + value, err := di.ResolveNamed[httpBaseURL](resolver, httpClientCatalogKey) + return string(value), err +} diff --git a/internal/service/scaffold/testdata/full/internal/deps/kafka_broker.gen.go b/internal/service/scaffold/testdata/full/internal/deps/kafka_broker.gen.go new file mode 100644 index 0000000..acbe825 --- /dev/null +++ b/internal/service/scaffold/testdata/full/internal/deps/kafka_broker.gen.go @@ -0,0 +1,7 @@ +// Code generated by devctl. DO NOT EDIT. + +package deps + +func kafkaConsumerKey(name string) string { return "kafka-consumer:" + name } + +func kafkaProducerKey(name string) string { return "kafka-producer:" + name } diff --git a/internal/service/scaffold/testdata/full/internal/deps/kafka_consumers.gen.go b/internal/service/scaffold/testdata/full/internal/deps/kafka_consumers.gen.go new file mode 100644 index 0000000..1b2c754 --- /dev/null +++ b/internal/service/scaffold/testdata/full/internal/deps/kafka_consumers.gen.go @@ -0,0 +1,79 @@ +// Code generated by devctl. DO NOT EDIT. + +package deps + +import ( + "context" + "fmt" + + "github.com/devctllabs/go-libs/di" + kafka "github.com/devctllabs/go-libs/kafka" + kafkaproto "github.com/devctllabs/go-libs/kafkaproto" + retry "github.com/devctllabs/go-libs/retry" +) + +func rawKafkaDecoder() kafka.Decoder[[]byte] { + // The consumer owns the record bytes until Handle returns; handlers must not retain a batch. + return kafka.DecoderFunc[[]byte](func(_ context.Context, value []byte) ([]byte, error) { return value, nil }) +} + +func jsonKafkaDecoder[T any]() kafka.Decoder[T] { return kafka.NewJSONDecoder[T]() } +func protoKafkaDecoder[T any, PT kafkaproto.ProtoPtr[T]]() kafka.Decoder[PT] { + return kafkaproto.NewDecoder[T, PT]() +} + +func provideConsumer[T any](graph *di.Container, key string) error { + return di.ProvideNamed[scenarioRunner](graph, key, func(resolver di.Resolver) (scenarioRunner, error) { + config, err := di.ResolveNamed[kafka.ConsumerConfig](resolver, key) + if err != nil { + return nil, fmt.Errorf("resolve consumer config: %w", err) + } + decoder, err := di.ResolveNamed[kafka.Decoder[T]](resolver, key) + if err != nil { + return nil, fmt.Errorf("resolve consumer decoder: %w", err) + } + handler, err := di.ResolveNamed[kafka.BatchHandler[T]](resolver, key) + if err != nil { + return nil, fmt.Errorf("resolve consumer handler: %w", err) + } + policy, err := di.ResolveNamed[retry.Policy](resolver, key) + if err != nil { + return nil, fmt.Errorf("resolve consumer retry policy: %w", err) + } + config.Retry.Policy = policy + config.CommitRetry = &config.Retry + consumer, err := kafka.NewConsumer(config, decoder, handler) + if err != nil { + return nil, fmt.Errorf("kafka.NewConsumer: %w", err) + } + return consumer, nil + }) +} +func provideAuditConsumerConfig(_ context.Context, graph *di.Container, cfg *Config) error { + key := kafkaConsumerKey("audit") + return di.ProvideNamedValue(graph, key, kafka.ConsumerConfig{ + Brokers: cfg.Kafka.Brokers, + Group: cfg.Kafka.AuditGroup, + Topics: []string{cfg.Kafka.AuditTopic}, + Batch: kafka.BatchConfig{MaxSize: cfg.Kafka.AuditBatchMaxSize, FlushInterval: cfg.Kafka.AuditBatchFlushInterval}, + Retry: kafka.RetryConfig{MaxAttempts: uint(cfg.Kafka.AuditRetryMaxAttempts), MaxElapsedTime: cfg.Kafka.AuditRetryMaxElapsedTime}, + OnReject: kafka.RejectStop, + RebalanceTimeout: cfg.Kafka.AuditRebalanceTimeout, + RebalanceDrainTimeout: cfg.Kafka.AuditRebalanceDrainTimeout, + ShutdownTimeout: cfg.Kafka.AuditShutdownTimeout, + }) +} +func provideInvoiceConsumerConfig(_ context.Context, graph *di.Container, cfg *Config) error { + key := kafkaConsumerKey("invoice") + return di.ProvideNamedValue(graph, key, kafka.ConsumerConfig{ + Brokers: cfg.Kafka.Brokers, + Group: cfg.Kafka.InvoiceGroup, + Topics: []string{cfg.Kafka.InvoiceTopic}, + Batch: kafka.BatchConfig{MaxSize: cfg.Kafka.InvoiceBatchMaxSize, FlushInterval: cfg.Kafka.InvoiceBatchFlushInterval}, + Retry: kafka.RetryConfig{MaxAttempts: uint(cfg.Kafka.InvoiceRetryMaxAttempts), MaxElapsedTime: cfg.Kafka.InvoiceRetryMaxElapsedTime}, + OnReject: kafka.RejectStop, + RebalanceTimeout: cfg.Kafka.InvoiceRebalanceTimeout, + RebalanceDrainTimeout: cfg.Kafka.InvoiceRebalanceDrainTimeout, + ShutdownTimeout: cfg.Kafka.InvoiceShutdownTimeout, + }) +} diff --git a/internal/service/scaffold/testdata/full/internal/deps/kafka_producers.gen.go b/internal/service/scaffold/testdata/full/internal/deps/kafka_producers.gen.go new file mode 100644 index 0000000..01febc8 --- /dev/null +++ b/internal/service/scaffold/testdata/full/internal/deps/kafka_producers.gen.go @@ -0,0 +1,21 @@ +// Code generated by devctl. DO NOT EDIT. + +package deps + +import ( + "context" + "fmt" + + "github.com/devctllabs/go-libs/di" + kafka "github.com/devctllabs/go-libs/kafka" +) + +func provideEventsKafkaProducer(_ context.Context, graph *di.Container, cfg *Config) error { + return di.ProvideNamedResource(graph, kafkaProducerKey("events"), func(di.Resolver) (*kafka.Producer[[]byte], error) { + producer, err := kafka.NewProducer(kafka.ProducerConfig{Brokers: cfg.Kafka.Brokers}, kafka.NewBytesEncoder()) + if err != nil { + return nil, fmt.Errorf("kafka.NewProducer: %w", err) + } + return producer, nil + }, func(ctx context.Context, producer *kafka.Producer[[]byte]) error { return producer.Close(ctx) }) +} diff --git a/internal/service/scaffold/testdata/full/internal/deps/redis.gen.go b/internal/service/scaffold/testdata/full/internal/deps/redis.gen.go new file mode 100644 index 0000000..9edb194 --- /dev/null +++ b/internal/service/scaffold/testdata/full/internal/deps/redis.gen.go @@ -0,0 +1,18 @@ +// Code generated by devctl. DO NOT EDIT. + +package deps + +import ( + "context" + + "github.com/devctllabs/go-libs/di" + redis "github.com/redis/go-redis/v9" +) + +const redisCacheKey = "redis-connection:cache" + +func provideRedisCache(_ context.Context, graph *di.Container, cfg *Config) error { + return di.ProvideNamedResource(graph, redisCacheKey, func(di.Resolver) (*redis.Client, error) { + return redis.NewClient(&redis.Options{Addr: cfg.Redis.CacheAddress}), nil + }, func(ctx context.Context, client *redis.Client) error { return client.Close() }) +} diff --git a/internal/service/scaffold/testdata/full/internal/deps/runtime.gen.go b/internal/service/scaffold/testdata/full/internal/deps/runtime.gen.go new file mode 100644 index 0000000..bd752cc --- /dev/null +++ b/internal/service/scaffold/testdata/full/internal/deps/runtime.gen.go @@ -0,0 +1,161 @@ +// Code generated by devctl. DO NOT EDIT. + +package deps + +import ( + "context" + "errors" + "fmt" + + debugserverlib "github.com/devctllabs/go-libs/debugserver" + "github.com/devctllabs/go-libs/di" + healthlib "github.com/devctllabs/go-libs/health" + healthserverlib "github.com/devctllabs/go-libs/healthserver" + "github.com/devctllabs/go-libs/lifecycle" + "github.com/labstack/echo/v5" + "google.golang.org/grpc" + "net" + "net/http" + "time" +) + +// HTTPRegistrar is implemented by user-owned application composition. +type HTTPRegistrar interface{ RegisterHTTP(*echo.Echo) } + +// GRPCRegistrar is implemented by user-owned application composition. +type GRPCRegistrar interface{ RegisterGRPC(*grpc.Server) } + +// Runtime owns the optional long-lived components selected by configuration. +type Runtime struct { + http *http.Server + grpc *grpc.Server + grpcAddress string + health *healthserverlib.Server + pprof *debugserverlib.Server +} + +func newRuntime(resolver di.Resolver, cfg *Config) (*Runtime, error) { + httpRegistrar, err := di.Resolve[HTTPRegistrar](resolver) + if err != nil { + return nil, fmt.Errorf("di.Resolve HTTPRegistrar: %w", err) + } + grpcRegistrar, err := di.Resolve[GRPCRegistrar](resolver) + if err != nil { + return nil, fmt.Errorf("di.Resolve GRPCRegistrar: %w", err) + } + options := make([]healthlib.Option, 0, 2) + { + checker, err := di.ResolveNamed[dbChecker](resolver, "db-connection:primary") + if err != nil { + return nil, fmt.Errorf("di.ResolveNamed: %w", err) + } + options = append(options, healthlib.NonCritical("db.primary", checker)) + } + { + checker, err := di.ResolveNamed[dbChecker](resolver, "db-connection:analytics") + if err != nil { + return nil, fmt.Errorf("di.ResolveNamed: %w", err) + } + options = append(options, healthlib.NonCritical("db.analytics", checker)) + } + return NewRuntime(cfg, httpRegistrar, grpcRegistrar, options...) +} + +func NewRuntime(cfg *Config, httpRegistrar HTTPRegistrar, grpcRegistrar GRPCRegistrar, options ...healthlib.Option) (*Runtime, error) { + if cfg == nil { + return nil, errors.New("config is nil") + } + runtime := &Runtime{} + httpRouter := echo.New() + httpRegistrar.RegisterHTTP(httpRouter) + runtime.http = &http.Server{Addr: cfg.HTTP.Address, Handler: httpRouter, ReadHeaderTimeout: 2 * time.Second, IdleTimeout: 30 * time.Second} + runtime.grpc = newGRPCServer() + grpcRegistrar.RegisterGRPC(runtime.grpc) + runtime.grpcAddress = cfg.GRPC.Address + probes, err := healthlib.New(options...) + if err != nil { + return nil, fmt.Errorf("healthlib.New: %w", err) + } + runtime.health, err = healthserverlib.NewServer(probes, healthserverlib.WithAddress(cfg.Health.Address)) + if err != nil { + return nil, fmt.Errorf("healthserverlib.NewServer: %w", err) + } + pprofServer, err := debugserverlib.NewServer(debugserverlib.WithAddress(cfg.Pprof.Address)) + if err != nil { + return nil, fmt.Errorf("debugserverlib.NewServer: %w", err) + } + runtime.pprof = pprofServer + return runtime, nil +} + +func provideRuntime(_ context.Context, graph *di.Container, cfg *Config) error { + return di.ProvideResource(graph, func(resolver di.Resolver) (*Runtime, error) { + return newRuntime(resolver, cfg) + }, func(ctx context.Context, value *Runtime) error { return value.Shutdown(ctx) }) +} + +// Tasks returns enabled runtime roots in deterministic startup order. +func (r *Runtime) Tasks() []lifecycle.Task { + tasks := make([]lifecycle.Task, 0, 3) + tasks = append(tasks, lifecycle.Task{Name: "http", Run: func(context.Context) error { + if err := r.http.ListenAndServe(); err != nil { + return fmt.Errorf("r.http.ListenAndServe: %w", err) + } + return nil + }}) + tasks = append(tasks, lifecycle.Task{Name: "grpc", Run: func(context.Context) error { + listener, err := net.Listen("tcp", r.grpcAddress) + if err != nil { + return fmt.Errorf("net.Listen: %w", err) + } + if err := r.grpc.Serve(listener); err != nil { + return fmt.Errorf("r.grpc.Serve: %w", err) + } + return nil + }}) + tasks = append(tasks, lifecycle.Task{Name: "health", Run: func(context.Context) error { + if err := r.health.ListenAndServe(); err != nil { + return fmt.Errorf("r.health.ListenAndServe: %w", err) + } + return nil + }}) + tasks = append(tasks, lifecycle.Task{Name: "pprof", Run: func(context.Context) error { + if err := r.pprof.ListenAndServe(); err != nil { + return fmt.Errorf("r.pprof.ListenAndServe: %w", err) + } + return nil + }}) + return tasks +} + +// Shutdown stops every constructed runtime component and joins cleanup failures. +func (r *Runtime) Shutdown(ctx context.Context) error { + var shutdownErrors []error + if r.http != nil { + if err := r.http.Shutdown(ctx); err != nil { + shutdownErrors = append(shutdownErrors, fmt.Errorf("r.http.Shutdown: %w", err)) + } + } + if r.grpc != nil { + stopped := make(chan struct{}) + go func() { r.grpc.GracefulStop(); close(stopped) }() + select { + case <-stopped: + case <-ctx.Done(): + r.grpc.Stop() + <-stopped + shutdownErrors = append(shutdownErrors, fmt.Errorf("grpc shutdown: %w", ctx.Err())) + } + } + if r.health != nil { + if err := r.health.Shutdown(ctx); err != nil { + shutdownErrors = append(shutdownErrors, fmt.Errorf("r.health.Shutdown: %w", err)) + } + } + if r.pprof != nil { + if err := r.pprof.Shutdown(ctx); err != nil { + shutdownErrors = append(shutdownErrors, fmt.Errorf("r.pprof.Shutdown: %w", err)) + } + } + return errors.Join(shutdownErrors...) +} diff --git a/internal/service/scaffold/testdata/full/internal/deps/s3.gen.go b/internal/service/scaffold/testdata/full/internal/deps/s3.gen.go new file mode 100644 index 0000000..7481224 --- /dev/null +++ b/internal/service/scaffold/testdata/full/internal/deps/s3.gen.go @@ -0,0 +1,51 @@ +// Code generated by devctl. DO NOT EDIT. + +package deps + +import ( + "context" + "fmt" + + "github.com/aws/aws-sdk-go-v2/aws" + awsconfig "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/service/s3" + "github.com/devctllabs/go-libs/di" +) + +// S3Bucket binds one configured bucket name to its connection. +type S3Bucket struct { + Client *s3.Client + Name string +} + +const s3DefaultKey = "s3-connection:default" + +func provideS3Default(ctx context.Context, graph *di.Container, cfg *Config) error { + return di.ProvideNamedResource(graph, s3DefaultKey, func(di.Resolver) (*s3.Client, error) { + loadOptions := []func(*awsconfig.LoadOptions) error{ + awsconfig.WithRegion(cfg.S3.Region), + } + awsCfg, err := awsconfig.LoadDefaultConfig(ctx, loadOptions...) + if err != nil { + return nil, err + } + return s3.NewFromConfig(awsCfg, func(options *s3.Options) { + if endpoint := cfg.S3.Endpoint; endpoint != "" { + options.BaseEndpoint = aws.String(endpoint) + } + options.UsePathStyle = cfg.S3.ForcePathStyle + }), nil + }, func(context.Context, *s3.Client) error { return nil }) +} + +const s3MediaBucketKey = "s3-bucket:media" + +func provideS3MediaBucket(_ context.Context, graph *di.Container, cfg *Config) error { + return di.ProvideNamed[S3Bucket](graph, s3MediaBucketKey, func(resolver di.Resolver) (S3Bucket, error) { + client, err := di.ResolveNamed[*s3.Client](resolver, s3DefaultKey) + if err != nil { + return S3Bucket{}, fmt.Errorf("resolve S3 connection default: %w", err) + } + return S3Bucket{Client: client, Name: cfg.S3.MediaBucket}, nil + }) +} diff --git a/internal/service/scaffold/testdata/full/internal/deps/storage_analytics.gen.go b/internal/service/scaffold/testdata/full/internal/deps/storage_analytics.gen.go new file mode 100644 index 0000000..ba97eef --- /dev/null +++ b/internal/service/scaffold/testdata/full/internal/deps/storage_analytics.gen.go @@ -0,0 +1,45 @@ +// Code generated by devctl. DO NOT EDIT. + +package deps + +import ( + "context" + "errors" + "fmt" + + "github.com/ClickHouse/clickhouse-go/v2" + clickhousedriver "github.com/ClickHouse/clickhouse-go/v2/lib/driver" + "github.com/devctllabs/go-libs/di" +) + +const storageAnalyticsConnectionName = "db-connection:analytics" + +type clickHouseAnalyticsChecker struct{ connection clickhousedriver.Conn } + +func (c clickHouseAnalyticsChecker) Check(ctx context.Context) error { return c.connection.Ping(ctx) } + +func provideStorageAnalytics(ctx context.Context, graph *di.Container, cfg *Config) error { + if err := di.ProvideNamedResource[clickhousedriver.Conn](graph, storageAnalyticsConnectionName, func(di.Resolver) (clickhousedriver.Conn, error) { + options, err := clickhouse.ParseDSN(cfg.DBAnalytics.ClickhouseDSN) + if err != nil { + return nil, fmt.Errorf("clickhouse.ParseDSN: %w", err) + } + connection, err := clickhouse.Open(options) + if err != nil { + return nil, fmt.Errorf("clickhouse.Open: %w", err) + } + if err := connection.Ping(ctx); err != nil { + return nil, errors.Join(fmt.Errorf("connection.Ping: %w", err), connection.Close()) + } + return connection, nil + }, func(_ context.Context, connection clickhousedriver.Conn) error { return connection.Close() }); err != nil { + return fmt.Errorf("di.ProvideNamedResource: %w", err) + } + return di.ProvideNamed[dbChecker](graph, storageAnalyticsConnectionName, func(resolver di.Resolver) (dbChecker, error) { + connection, err := di.ResolveNamed[clickhousedriver.Conn](resolver, storageAnalyticsConnectionName) + if err != nil { + return nil, fmt.Errorf("di.ResolveNamed: %w", err) + } + return clickHouseAnalyticsChecker{connection: connection}, nil + }) +} diff --git a/internal/service/scaffold/testdata/full/internal/deps/storage_primary.gen.go b/internal/service/scaffold/testdata/full/internal/deps/storage_primary.gen.go new file mode 100644 index 0000000..0d23bf8 --- /dev/null +++ b/internal/service/scaffold/testdata/full/internal/deps/storage_primary.gen.go @@ -0,0 +1,152 @@ +// Code generated by devctl. DO NOT EDIT. + +package deps + +import ( + "context" + "errors" + "fmt" + + "github.com/devctllabs/go-libs/di" + postgresdb "github.com/devctllabs/go-libs/postgresdb" + sqlitedb "github.com/devctllabs/go-libs/sqlitedb" + telemetrylib "github.com/devctllabs/go-libs/telemetry" + "github.com/devctllabs/go-libs/txmanager" +) + +const storagePrimaryConnectionName = "db-connection:primary" + +type storagePrimary struct { + sqlite *sqlitedb.DB + postgres *postgresdb.DB +} + +func provideStoragePrimary(ctx context.Context, graph *di.Container, cfg *Config) error { + if err := di.ProvideNamedResource[*storagePrimary](graph, storagePrimaryConnectionName, func(resolver di.Resolver) (*storagePrimary, error) { + return openStoragePrimary(ctx, resolver, cfg) + }, func(_ context.Context, value *storagePrimary) error { return value.close() }); err != nil { + return fmt.Errorf("di.ProvideNamedResource: %w", err) + } + if err := di.ProvideNamed[*sqlitedb.Endpoint](graph, storagePrimaryConnectionName+".reader", func(resolver di.Resolver) (*sqlitedb.Endpoint, error) { + storage, err := di.ResolveNamed[*storagePrimary](resolver, storagePrimaryConnectionName) + if err != nil { + return nil, fmt.Errorf("di.ResolveNamed: %w", err) + } + if storage.sqlite == nil { + return nil, fmt.Errorf("storage primary does not use sqlite") + } + return storage.sqlite.Reader(), nil + }); err != nil { + return fmt.Errorf("di.ProvideNamed reader: %w", err) + } + if err := di.ProvideNamed[*sqlitedb.Endpoint](graph, storagePrimaryConnectionName+".writer", func(resolver di.Resolver) (*sqlitedb.Endpoint, error) { + storage, err := di.ResolveNamed[*storagePrimary](resolver, storagePrimaryConnectionName) + if err != nil { + return nil, fmt.Errorf("di.ResolveNamed: %w", err) + } + if storage.sqlite == nil { + return nil, fmt.Errorf("storage primary does not use sqlite") + } + return storage.sqlite.Writer(), nil + }); err != nil { + return fmt.Errorf("di.ProvideNamed writer: %w", err) + } + if err := di.ProvideNamed[*postgresdb.Endpoint](graph, storagePrimaryConnectionName+".reader", func(resolver di.Resolver) (*postgresdb.Endpoint, error) { + storage, err := di.ResolveNamed[*storagePrimary](resolver, storagePrimaryConnectionName) + if err != nil { + return nil, fmt.Errorf("di.ResolveNamed: %w", err) + } + if storage.postgres == nil { + return nil, fmt.Errorf("storage primary does not use postgres") + } + return storage.postgres.Reader(), nil + }); err != nil { + return fmt.Errorf("di.ProvideNamed reader: %w", err) + } + if err := di.ProvideNamed[*postgresdb.Endpoint](graph, storagePrimaryConnectionName+".writer", func(resolver di.Resolver) (*postgresdb.Endpoint, error) { + storage, err := di.ResolveNamed[*storagePrimary](resolver, storagePrimaryConnectionName) + if err != nil { + return nil, fmt.Errorf("di.ResolveNamed: %w", err) + } + if storage.postgres == nil { + return nil, fmt.Errorf("storage primary does not use postgres") + } + return storage.postgres.Writer(), nil + }); err != nil { + return fmt.Errorf("di.ProvideNamed writer: %w", err) + } + if err := di.ProvideNamed[txmanager.Managers](graph, storagePrimaryConnectionName, func(resolver di.Resolver) (txmanager.Managers, error) { + storage, err := di.ResolveNamed[*storagePrimary](resolver, storagePrimaryConnectionName) + if err != nil { + return nil, fmt.Errorf("di.ResolveNamed: %w", err) + } + return storage.managers(), nil + }); err != nil { + return fmt.Errorf("di.ProvideNamed tx managers: %w", err) + } + return di.ProvideNamed[dbChecker](graph, storagePrimaryConnectionName, func(resolver di.Resolver) (dbChecker, error) { + storage, err := di.ResolveNamed[*storagePrimary](resolver, storagePrimaryConnectionName) + if err != nil { + return nil, fmt.Errorf("di.ResolveNamed: %w", err) + } + return storage.checker(), nil + }) +} + +func openStoragePrimary(ctx context.Context, resolver di.Resolver, cfg *Config) (*storagePrimary, error) { + telemetryRuntime, err := di.Resolve[*telemetrylib.Runtime](resolver) + if err != nil { + return nil, fmt.Errorf("di.Resolve: %w", err) + } + switch cfg.DBPrimary.Kind { + case "sqlite": + db, err := sqlitedb.Open(ctx, sqlitedb.Config{DSN: cfg.DBPrimary.SqliteDSN, Telemetry: sqlitedb.Telemetry{TracerProvider: telemetryRuntime.TracerProvider(), MeterProvider: telemetryRuntime.MeterProvider()}}) + if err != nil { + return nil, fmt.Errorf("sqlitedb.Open: %w", err) + } + return &storagePrimary{sqlite: db}, nil + case "postgres": + db, err := postgresdb.Open(ctx, postgresdb.Config{Writer: postgresdb.EndpointConfig{DSN: cfg.DBPrimary.PostgresDSN}, Telemetry: postgresdb.Telemetry{TracerProvider: telemetryRuntime.TracerProvider(), MeterProvider: telemetryRuntime.MeterProvider()}}) + if err != nil { + return nil, fmt.Errorf("postgresdb.Open: %w", err) + } + return &storagePrimary{postgres: db}, nil + default: + return nil, fmt.Errorf("unsupported primary database kind %q", cfg.DBPrimary.Kind) + } +} + +func (s *storagePrimary) managers() txmanager.Managers { + if s.sqlite != nil { + return s.sqlite.TxManagers() + } + if s.postgres != nil { + return s.postgres.TxManagers() + } + return nil +} + +func (s *storagePrimary) checker() dbChecker { + if s.sqlite != nil { + return s.sqlite.Writer() + } + if s.postgres != nil { + return s.postgres.Writer() + } + return nil +} + +func (s *storagePrimary) close() error { + var closeErrors []error + if s.sqlite != nil { + if err := s.sqlite.Close(); err != nil { + closeErrors = append(closeErrors, fmt.Errorf("close sqlite: %w", err)) + } + } + if s.postgres != nil { + if err := s.postgres.Close(); err != nil { + closeErrors = append(closeErrors, fmt.Errorf("close postgres: %w", err)) + } + } + return errors.Join(closeErrors...) +} diff --git a/internal/service/scaffold/testdata/full/internal/transport/consumerkafka/audit/handler.go b/internal/service/scaffold/testdata/full/internal/transport/consumerkafka/audit/handler.go new file mode 100644 index 0000000..e994717 --- /dev/null +++ b/internal/service/scaffold/testdata/full/internal/transport/consumerkafka/audit/handler.go @@ -0,0 +1,20 @@ +package audit + +import ( + "context" + "errors" + + kafka "github.com/devctllabs/go-libs/kafka" + retry "github.com/devctllabs/go-libs/retry" +) + +var ErrNotImplemented = errors.New("Kafka consumer handler is not implemented") + +type Handler struct{} + +func NewHandler() *Handler { return &Handler{} } + +// Handle processes the batch synchronously. It must not retain batch data after returning. +func (h *Handler) Handle(context.Context, *kafka.Batch[[]byte]) error { + return retry.Permanent(ErrNotImplemented) +} diff --git a/internal/service/scaffold/testdata/full/internal/transport/consumerkafka/invoice/handler.go b/internal/service/scaffold/testdata/full/internal/transport/consumerkafka/invoice/handler.go new file mode 100644 index 0000000..5465c9b --- /dev/null +++ b/internal/service/scaffold/testdata/full/internal/transport/consumerkafka/invoice/handler.go @@ -0,0 +1,20 @@ +package invoice + +import ( + "context" + "errors" + + kafka "github.com/devctllabs/go-libs/kafka" + retry "github.com/devctllabs/go-libs/retry" +) + +var ErrNotImplemented = errors.New("Kafka consumer handler is not implemented") + +type Handler struct{} + +func NewHandler() *Handler { return &Handler{} } + +// Handle processes the batch synchronously. It must not retain batch data after returning. +func (h *Handler) Handle(context.Context, *kafka.Batch[[]byte]) error { + return retry.Permanent(ErrNotImplemented) +} diff --git a/internal/service/scaffold/testdata/full/migrations/analytics/clickhouse/.gitkeep b/internal/service/scaffold/testdata/full/migrations/analytics/clickhouse/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/internal/service/scaffold/testdata/full/tools/buf/grpc.gen.yaml b/internal/service/scaffold/testdata/full/tools/buf/grpc.gen.yaml new file mode 100644 index 0000000..9483aef --- /dev/null +++ b/internal/service/scaffold/testdata/full/tools/buf/grpc.gen.yaml @@ -0,0 +1,10 @@ +version: v2 +plugins: + - local: [go, tool, protoc-gen-go] + out: . + opt: + - paths=source_relative + - local: [go, tool, protoc-gen-go-grpc] + out: . + opt: + - paths=source_relative diff --git a/internal/service/scaffold/testdata/full/tools/buf/kafka.gen.yaml b/internal/service/scaffold/testdata/full/tools/buf/kafka.gen.yaml new file mode 100644 index 0000000..9483aef --- /dev/null +++ b/internal/service/scaffold/testdata/full/tools/buf/kafka.gen.yaml @@ -0,0 +1,10 @@ +version: v2 +plugins: + - local: [go, tool, protoc-gen-go] + out: . + opt: + - paths=source_relative + - local: [go, tool, protoc-gen-go-grpc] + out: . + opt: + - paths=source_relative diff --git a/internal/service/scaffold/testdata/full/tools/oapi/clients.catalog.yaml b/internal/service/scaffold/testdata/full/tools/oapi/clients.catalog.yaml new file mode 100644 index 0000000..3334bfb --- /dev/null +++ b/internal/service/scaffold/testdata/full/tools/oapi/clients.catalog.yaml @@ -0,0 +1,4 @@ +package: clienthttp +generate: + models: true + client: true diff --git a/internal/service/scaffold/testdata/full/tools/oapi/server.yaml b/internal/service/scaffold/testdata/full/tools/oapi/server.yaml new file mode 100644 index 0000000..45c9a22 --- /dev/null +++ b/internal/service/scaffold/testdata/full/tools/oapi/server.yaml @@ -0,0 +1,6 @@ +package: serverhttp +generate: + models: true + echo5-server: true + strict-server: true + embedded-spec: true diff --git a/internal/service/scaffold/testdata/minimal/.env.example b/internal/service/scaffold/testdata/minimal/.env.example new file mode 100644 index 0000000..e69de29 diff --git a/internal/service/scaffold/testdata/minimal/.golangci.yml b/internal/service/scaffold/testdata/minimal/.golangci.yml new file mode 100644 index 0000000..a01c843 --- /dev/null +++ b/internal/service/scaffold/testdata/minimal/.golangci.yml @@ -0,0 +1,76 @@ +version: "2" +run: + relative-path-mode: gomod + tests: true + modules-download-mode: readonly +linters: + default: none + enable: + - asasalint + - bidichk + - bodyclose + - containedctx + - contextcheck + - durationcheck + - errcheck + - errchkjson + - errname + - errorlint + - exhaustive + - fatcontext + - forcetypeassert + - gocheckcompilerdirectives + - gochecknoinits + - gocognit + - govet + - inamedparam + - ineffassign + - loggercheck + - makezero + - musttag + - nilerr + - nilnesserr + - noctx + - nolintlint + - nosprintfhostport + - paralleltest + - predeclared + - reassign + - recvcheck + - revive + - rowserrcheck + - sqlclosecheck + - staticcheck + - testifylint + - thelper + - tparallel + - unconvert + - unused + - usetesting + - wastedassign + - wrapcheck + settings: + gocognit: + min-complexity: 20 + nolintlint: + allow-unused: false + require-explanation: true + require-specific: true + paralleltest: + ignore-missing: false + ignore-missing-subtests: false + check-cleanup: true + revive: + rules: + - name: argument-limit + arguments: [4] + - name: function-result-limit + arguments: [3] + exclusions: + generated: strict + paths: ["^gen/"] +formatters: + enable: [gofmt] + exclusions: + generated: strict + paths: ["^gen/"] diff --git a/internal/service/scaffold/testdata/minimal/.mise.toml b/internal/service/scaffold/testdata/minimal/.mise.toml new file mode 100644 index 0000000..1d2631c --- /dev/null +++ b/internal/service/scaffold/testdata/minimal/.mise.toml @@ -0,0 +1,26 @@ +[tools] +go = "1.26.0" +golangci-lint = "2.12.2" + +[tasks.fmt] +run = "golangci-lint fmt" +[tasks."fmt:check"] +run = "golangci-lint fmt --diff" +[tasks."lint:contracts"] +run = "devctl lint" +[tasks."lint:go"] +run = "golangci-lint run" +[tasks.lint] +depends = ["lint:contracts", "lint:go"] +[tasks.test] +run = "go test ./..." +[tasks.gen] +run = "devctl gen" +[tasks."gen:http"] +run = "devctl gen http" +[tasks."gen:grpc"] +run = "devctl gen grpc" +[tasks."gen:kafka"] +run = "devctl gen kafka" +[tasks.check] +depends = ["fmt:check", "lint", "test"] diff --git a/internal/service/scaffold/testdata/minimal/README.md b/internal/service/scaffold/testdata/minimal/README.md new file mode 100644 index 0000000..bdd81bf --- /dev/null +++ b/internal/service/scaffold/testdata/minimal/README.md @@ -0,0 +1,30 @@ +# sample + +This project foundation is scaffolded by Devctl. + +## Bootstrap + +```sh +mise install +go mod download all +go mod tidy +devctl lint +devctl gen +go mod tidy +mise run check +``` + +Inspect the application commands with `go run ./cmd/sample --help`. + +## Updating the foundation + +- Run `devctl sync` after changing remote sources. +- Run `devctl init scaffold` after changing components in `devctl.yaml`. +- Run `devctl gen` after changing API or schema contracts. + +Devctl replaces files ending in `*.gen.go`. Ordinary `.go` files and this +README are created once, so application code and local notes are preserved. + +When a component adds a provider seed, review it and call the provider from +`internal/deps/application.go`. That file is the user-owned composition root; +Devctl does not rewrite its provider list. diff --git a/internal/service/scaffold/testdata/minimal/cmd/sample/main.go b/internal/service/scaffold/testdata/minimal/cmd/sample/main.go new file mode 100644 index 0000000..bbeec23 --- /dev/null +++ b/internal/service/scaffold/testdata/minimal/cmd/sample/main.go @@ -0,0 +1,34 @@ +package main + +import ( + "context" + "fmt" + "os" + "os/signal" + "syscall" + + "github.com/urfave/cli/v3" +) + +func main() { + signals := make(chan os.Signal, 1) + signal.Notify(signals, syscall.SIGINT, syscall.SIGTERM) + defer signal.Stop(signals) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go func() { + select { + case <-signals: + cancel() + case <-ctx.Done(): + } + }() + root := &cli.Command{ + Name: "sample", + Usage: "Run sample", + } + if err := root.Run(ctx, os.Args); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} diff --git a/internal/service/scaffold/testdata/minimal/gen/config/config.gen.go b/internal/service/scaffold/testdata/minimal/gen/config/config.gen.go new file mode 100644 index 0000000..d35b347 --- /dev/null +++ b/internal/service/scaffold/testdata/minimal/gen/config/config.gen.go @@ -0,0 +1,20 @@ +// Code generated by devctl. DO NOT EDIT. + +package config + +import ( + "fmt" + "time" +) + +type Config struct { +} + +func (c *Config) Validate() error { + if c == nil { + return fmt.Errorf("config is nil") + } + return nil +} + +var _ time.Duration diff --git a/internal/service/scaffold/testdata/minimal/go.mod b/internal/service/scaffold/testdata/minimal/go.mod new file mode 100644 index 0000000..06feaf3 --- /dev/null +++ b/internal/service/scaffold/testdata/minimal/go.mod @@ -0,0 +1,9 @@ +module example.test/sample + +go 1.26.0 + +require ( + github.com/devctllabs/go-libs/config v0.1.0 + github.com/devctllabs/go-libs/di v0.1.0 + github.com/urfave/cli/v3 v3.10.1 +) diff --git a/internal/service/scaffold/testdata/minimal/internal/deps/application.go b/internal/service/scaffold/testdata/minimal/internal/deps/application.go new file mode 100644 index 0000000..f16db85 --- /dev/null +++ b/internal/service/scaffold/testdata/minimal/internal/deps/application.go @@ -0,0 +1,15 @@ +package deps + +import ( + "context" + + "github.com/devctllabs/go-libs/di" +) + +// application is the user-owned composition root. Add application dependencies here. +type application struct{} + +// provideApplication is created once. Add newly scaffolded provider calls manually. +func provideApplication(ctx context.Context, graph *di.Container, cfg *Config) error { + return nil +} diff --git a/internal/service/scaffold/testdata/minimal/internal/deps/config.gen.go b/internal/service/scaffold/testdata/minimal/internal/deps/config.gen.go new file mode 100644 index 0000000..c4b921f --- /dev/null +++ b/internal/service/scaffold/testdata/minimal/internal/deps/config.gen.go @@ -0,0 +1,23 @@ +// Code generated by devctl. DO NOT EDIT. + +package deps + +import ( + "context" + "fmt" + + generatedconfig "example.test/sample/gen/config" + configlib "github.com/devctllabs/go-libs/config" +) + +// Config is the canonical generated runtime configuration. +type Config = generatedconfig.Config + +func loadConfig(ctx context.Context) (*Config, error) { + var cfg Config + loader := configlib.Chain(configlib.Defaults(), configlib.OSEnv()) + if err := loader.Load(ctx, &cfg); err != nil { + return nil, fmt.Errorf("loader.Load: %w", err) + } + return &cfg, nil +} diff --git a/internal/service/scaffold/testdata/minimal/internal/deps/container.gen.go b/internal/service/scaffold/testdata/minimal/internal/deps/container.gen.go new file mode 100644 index 0000000..a3d09f6 --- /dev/null +++ b/internal/service/scaffold/testdata/minimal/internal/deps/container.gen.go @@ -0,0 +1,3 @@ +// Code generated by devctl. DO NOT EDIT. + +package deps diff --git a/internal/service/sync/mocks/sync_service.go b/internal/service/sync/mocks/sync_service.go new file mode 100644 index 0000000..0cd13b5 --- /dev/null +++ b/internal/service/sync/mocks/sync_service.go @@ -0,0 +1,326 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/devctllabs/devctl/internal/service/sync (interfaces: ProjectRepository,Materializer,WorkspaceRepository) +// +// Generated by this command: +// +// mockgen -destination mocks/sync_service.go -package mocks -typed . ProjectRepository,Materializer,WorkspaceRepository +// + +// Package mocks is a generated GoMock package. +package mocks + +import ( + context "context" + reflect "reflect" + + artifact "github.com/devctllabs/devctl/internal/domain/artifact" + contract "github.com/devctllabs/devctl/internal/domain/contract" + project "github.com/devctllabs/devctl/internal/domain/project" + gomock "go.uber.org/mock/gomock" +) + +// MockProjectRepository is a mock of ProjectRepository interface. +type MockProjectRepository struct { + ctrl *gomock.Controller + recorder *MockProjectRepositoryMockRecorder + isgomock struct{} +} + +// MockProjectRepositoryMockRecorder is the mock recorder for MockProjectRepository. +type MockProjectRepositoryMockRecorder struct { + mock *MockProjectRepository +} + +// NewMockProjectRepository creates a new mock instance. +func NewMockProjectRepository(ctrl *gomock.Controller) *MockProjectRepository { + mock := &MockProjectRepository{ctrl: ctrl} + mock.recorder = &MockProjectRepositoryMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockProjectRepository) EXPECT() *MockProjectRepositoryMockRecorder { + return m.recorder +} + +// LoadProject mocks base method. +func (m *MockProjectRepository) LoadProject(ctx context.Context, manifestPath string) (project.Project, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "LoadProject", ctx, manifestPath) + ret0, _ := ret[0].(project.Project) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// LoadProject indicates an expected call of LoadProject. +func (mr *MockProjectRepositoryMockRecorder) LoadProject(ctx, manifestPath any) *MockProjectRepositoryLoadProjectCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "LoadProject", reflect.TypeOf((*MockProjectRepository)(nil).LoadProject), ctx, manifestPath) + return &MockProjectRepositoryLoadProjectCall{Call: call} +} + +// MockProjectRepositoryLoadProjectCall wrap *gomock.Call +type MockProjectRepositoryLoadProjectCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockProjectRepositoryLoadProjectCall) Return(arg0 project.Project, arg1 error) *MockProjectRepositoryLoadProjectCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockProjectRepositoryLoadProjectCall) Do(f func(context.Context, string) (project.Project, error)) *MockProjectRepositoryLoadProjectCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockProjectRepositoryLoadProjectCall) DoAndReturn(f func(context.Context, string) (project.Project, error)) *MockProjectRepositoryLoadProjectCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// MockMaterializer is a mock of Materializer interface. +type MockMaterializer struct { + ctrl *gomock.Controller + recorder *MockMaterializerMockRecorder + isgomock struct{} +} + +// MockMaterializerMockRecorder is the mock recorder for MockMaterializer. +type MockMaterializerMockRecorder struct { + mock *MockMaterializer +} + +// NewMockMaterializer creates a new mock instance. +func NewMockMaterializer(ctrl *gomock.Controller) *MockMaterializer { + mock := &MockMaterializer{ctrl: ctrl} + mock.recorder = &MockMaterializerMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockMaterializer) EXPECT() *MockMaterializerMockRecorder { + return m.recorder +} + +// Materialize mocks base method. +func (m *MockMaterializer) Materialize(ctx context.Context, root string, source project.Source, reference contract.Reference) (contract.Snapshot, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Materialize", ctx, root, source, reference) + ret0, _ := ret[0].(contract.Snapshot) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Materialize indicates an expected call of Materialize. +func (mr *MockMaterializerMockRecorder) Materialize(ctx, root, source, reference any) *MockMaterializerMaterializeCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Materialize", reflect.TypeOf((*MockMaterializer)(nil).Materialize), ctx, root, source, reference) + return &MockMaterializerMaterializeCall{Call: call} +} + +// MockMaterializerMaterializeCall wrap *gomock.Call +type MockMaterializerMaterializeCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockMaterializerMaterializeCall) Return(arg0 contract.Snapshot, arg1 error) *MockMaterializerMaterializeCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockMaterializerMaterializeCall) Do(f func(context.Context, string, project.Source, contract.Reference) (contract.Snapshot, error)) *MockMaterializerMaterializeCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockMaterializerMaterializeCall) DoAndReturn(f func(context.Context, string, project.Source, contract.Reference) (contract.Snapshot, error)) *MockMaterializerMaterializeCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// MockWorkspaceRepository is a mock of WorkspaceRepository interface. +type MockWorkspaceRepository struct { + ctrl *gomock.Controller + recorder *MockWorkspaceRepositoryMockRecorder + isgomock struct{} +} + +// MockWorkspaceRepositoryMockRecorder is the mock recorder for MockWorkspaceRepository. +type MockWorkspaceRepositoryMockRecorder struct { + mock *MockWorkspaceRepository +} + +// NewMockWorkspaceRepository creates a new mock instance. +func NewMockWorkspaceRepository(ctrl *gomock.Controller) *MockWorkspaceRepository { + mock := &MockWorkspaceRepository{ctrl: ctrl} + mock.recorder = &MockWorkspaceRepositoryMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockWorkspaceRepository) EXPECT() *MockWorkspaceRepositoryMockRecorder { + return m.recorder +} + +// PreviewPruneDirectories mocks base method. +func (m *MockWorkspaceRepository) PreviewPruneDirectories(ctx context.Context, root, parent string, keep []string) ([]string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PreviewPruneDirectories", ctx, root, parent, keep) + ret0, _ := ret[0].([]string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// PreviewPruneDirectories indicates an expected call of PreviewPruneDirectories. +func (mr *MockWorkspaceRepositoryMockRecorder) PreviewPruneDirectories(ctx, root, parent, keep any) *MockWorkspaceRepositoryPreviewPruneDirectoriesCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PreviewPruneDirectories", reflect.TypeOf((*MockWorkspaceRepository)(nil).PreviewPruneDirectories), ctx, root, parent, keep) + return &MockWorkspaceRepositoryPreviewPruneDirectoriesCall{Call: call} +} + +// MockWorkspaceRepositoryPreviewPruneDirectoriesCall wrap *gomock.Call +type MockWorkspaceRepositoryPreviewPruneDirectoriesCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockWorkspaceRepositoryPreviewPruneDirectoriesCall) Return(arg0 []string, arg1 error) *MockWorkspaceRepositoryPreviewPruneDirectoriesCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockWorkspaceRepositoryPreviewPruneDirectoriesCall) Do(f func(context.Context, string, string, []string) ([]string, error)) *MockWorkspaceRepositoryPreviewPruneDirectoriesCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockWorkspaceRepositoryPreviewPruneDirectoriesCall) DoAndReturn(f func(context.Context, string, string, []string) ([]string, error)) *MockWorkspaceRepositoryPreviewPruneDirectoriesCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// PruneDirectories mocks base method. +func (m *MockWorkspaceRepository) PruneDirectories(ctx context.Context, root, parent string, keep []string) ([]string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PruneDirectories", ctx, root, parent, keep) + ret0, _ := ret[0].([]string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// PruneDirectories indicates an expected call of PruneDirectories. +func (mr *MockWorkspaceRepositoryMockRecorder) PruneDirectories(ctx, root, parent, keep any) *MockWorkspaceRepositoryPruneDirectoriesCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PruneDirectories", reflect.TypeOf((*MockWorkspaceRepository)(nil).PruneDirectories), ctx, root, parent, keep) + return &MockWorkspaceRepositoryPruneDirectoriesCall{Call: call} +} + +// MockWorkspaceRepositoryPruneDirectoriesCall wrap *gomock.Call +type MockWorkspaceRepositoryPruneDirectoriesCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockWorkspaceRepositoryPruneDirectoriesCall) Return(arg0 []string, arg1 error) *MockWorkspaceRepositoryPruneDirectoriesCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockWorkspaceRepositoryPruneDirectoriesCall) Do(f func(context.Context, string, string, []string) ([]string, error)) *MockWorkspaceRepositoryPruneDirectoriesCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockWorkspaceRepositoryPruneDirectoriesCall) DoAndReturn(f func(context.Context, string, string, []string) ([]string, error)) *MockWorkspaceRepositoryPruneDirectoriesCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// PublishDirectory mocks base method. +func (m *MockWorkspaceRepository) PublishDirectory(ctx context.Context, root, target string, tree artifact.Tree) (artifact.PublishResult, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PublishDirectory", ctx, root, target, tree) + ret0, _ := ret[0].(artifact.PublishResult) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// PublishDirectory indicates an expected call of PublishDirectory. +func (mr *MockWorkspaceRepositoryMockRecorder) PublishDirectory(ctx, root, target, tree any) *MockWorkspaceRepositoryPublishDirectoryCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PublishDirectory", reflect.TypeOf((*MockWorkspaceRepository)(nil).PublishDirectory), ctx, root, target, tree) + return &MockWorkspaceRepositoryPublishDirectoryCall{Call: call} +} + +// MockWorkspaceRepositoryPublishDirectoryCall wrap *gomock.Call +type MockWorkspaceRepositoryPublishDirectoryCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockWorkspaceRepositoryPublishDirectoryCall) Return(arg0 artifact.PublishResult, arg1 error) *MockWorkspaceRepositoryPublishDirectoryCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockWorkspaceRepositoryPublishDirectoryCall) Do(f func(context.Context, string, string, artifact.Tree) (artifact.PublishResult, error)) *MockWorkspaceRepositoryPublishDirectoryCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockWorkspaceRepositoryPublishDirectoryCall) DoAndReturn(f func(context.Context, string, string, artifact.Tree) (artifact.PublishResult, error)) *MockWorkspaceRepositoryPublishDirectoryCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// PublishFile mocks base method. +func (m *MockWorkspaceRepository) PublishFile(ctx context.Context, root, target string, content []byte) (artifact.PublishResult, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PublishFile", ctx, root, target, content) + ret0, _ := ret[0].(artifact.PublishResult) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// PublishFile indicates an expected call of PublishFile. +func (mr *MockWorkspaceRepositoryMockRecorder) PublishFile(ctx, root, target, content any) *MockWorkspaceRepositoryPublishFileCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PublishFile", reflect.TypeOf((*MockWorkspaceRepository)(nil).PublishFile), ctx, root, target, content) + return &MockWorkspaceRepositoryPublishFileCall{Call: call} +} + +// MockWorkspaceRepositoryPublishFileCall wrap *gomock.Call +type MockWorkspaceRepositoryPublishFileCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockWorkspaceRepositoryPublishFileCall) Return(arg0 artifact.PublishResult, arg1 error) *MockWorkspaceRepositoryPublishFileCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockWorkspaceRepositoryPublishFileCall) Do(f func(context.Context, string, string, []byte) (artifact.PublishResult, error)) *MockWorkspaceRepositoryPublishFileCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockWorkspaceRepositoryPublishFileCall) DoAndReturn(f func(context.Context, string, string, []byte) (artifact.PublishResult, error)) *MockWorkspaceRepositoryPublishFileCall { + c.Call = c.Call.DoAndReturn(f) + return c +} diff --git a/internal/service/sync/prune.go b/internal/service/sync/prune.go new file mode 100644 index 0000000..6281c2a --- /dev/null +++ b/internal/service/sync/prune.go @@ -0,0 +1,79 @@ +package sync + +import ( + "context" + "fmt" + "path" + "sort" + + projectdomain "github.com/devctllabs/devctl/internal/domain/project" + syncdomain "github.com/devctllabs/devctl/internal/domain/sync" +) + +func (s *Service) pruneStaleTargets( + ctx context.Context, + project projectdomain.Project, + family string, + preview bool, +) ([]syncdomain.Change, error) { + type subtree struct{ family, role, targetPrefix string } + subtrees := []subtree{ + {family: "http", role: "client", targetPrefix: "http-client:"}, + {family: "grpc", role: "client", targetPrefix: "grpc-client:"}, + {family: "kafka", role: "consumer", targetPrefix: "kafka-consumer:"}, + {family: "kafka", role: "producer", targetPrefix: "kafka-producer:"}, + } + catalog := projectdomain.NewTargetCatalog(project.Manifest).Select(projectdomain.TargetOperationSync, "", "") + changes := make([]syncdomain.Change, 0) + for _, subtree := range subtrees { + if family != "" && subtree.family != family { + continue + } + keep := make([]string, 0) + for _, target := range catalog { + if target.Family == subtree.family && target.Role == subtree.role && target.Source.Type != projectdomain.SourceLocal { + keep = append(keep, target.Name) + } + } + sort.Strings(keep) + parent := path.Join(externalContractsRoot(project.Manifest), subtree.family, subtree.role) + removed, err := s.staleDirectories(ctx, staleDirectoryRequest{ + root: project.Root, parent: parent, keep: keep, preview: preview, + }) + if err != nil { + operationErr := &syncdomain.OperationError{Operation: syncdomain.OperationPrune, Path: parent, Kind: syncdomain.FailureUnavailable, Cause: err} + return changes, fmt.Errorf("stale target selection: %w", operationErr) + } + sort.Strings(removed) + action := syncdomain.ChangeRemoved + if preview { + action = syncdomain.ChangePlannedRemove + } + for _, name := range removed { + changes = append(changes, syncdomain.Change{Target: subtree.targetPrefix + name, Path: path.Join(parent, name), Action: action}) + } + } + return changes, nil +} + +type staleDirectoryRequest struct { + root string + parent string + keep []string + preview bool +} + +func (s *Service) staleDirectories(ctx context.Context, request staleDirectoryRequest) ([]string, error) { + if request.preview { + removed, err := s.workspace.PreviewPruneDirectories(ctx, request.root, request.parent, request.keep) + if err != nil { + return removed, fmt.Errorf("workspace.PreviewPruneDirectories: %w", err) + } + return removed, nil + } + removed, err := s.workspace.PruneDirectories(ctx, request.root, request.parent, request.keep) + if err != nil { + return removed, fmt.Errorf("workspace.PruneDirectories: %w", err) + } + return removed, nil +} diff --git a/internal/service/sync/service.go b/internal/service/sync/service.go new file mode 100644 index 0000000..e0dd734 --- /dev/null +++ b/internal/service/sync/service.go @@ -0,0 +1,168 @@ +package sync + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/devctllabs/devctl/internal/domain/artifact" + "github.com/devctllabs/devctl/internal/domain/contract" + projectdomain "github.com/devctllabs/devctl/internal/domain/project" + syncdomain "github.com/devctllabs/devctl/internal/domain/sync" + "go.uber.org/zap" +) + +//go:generate go tool mockgen -destination mocks/sync_service.go -package mocks -typed . ProjectRepository,Materializer,WorkspaceRepository + +// ProjectRepository resolves the valid project selected for synchronization. +type ProjectRepository interface { + // LoadProject returns a structurally and semantically valid project or an execution error. + LoadProject(ctx context.Context, manifestPath string) (projectdomain.Project, error) +} + +// Materializer obtains exact contract closures from configured sources. +type Materializer interface { + // Materialize resolves reference from source without publishing managed output. + Materialize(ctx context.Context, root string, source projectdomain.Source, reference contract.Reference) (contract.Snapshot, error) +} + +// WorkspaceRepository atomically publishes target snapshots and removes stale target directories. +type WorkspaceRepository interface { + // PublishFile atomically publishes one contained file and reports whether bytes changed. + PublishFile(ctx context.Context, root, target string, content []byte) (artifact.PublishResult, error) + // PublishDirectory atomically replaces one contained target with the complete tree and reports whether content changed. + PublishDirectory(ctx context.Context, root, target string, tree artifact.Tree) (artifact.PublishResult, error) + // PruneDirectories removes child directories below parent except names in keep and returns removed child names. + PruneDirectories(ctx context.Context, root, parent string, keep []string) ([]string, error) + // PreviewPruneDirectories returns the same validated stale child set without mutating the workspace. + PreviewPruneDirectories(ctx context.Context, root, parent string, keep []string) ([]string, error) +} + +type Service struct { + logger *zap.Logger + projects ProjectRepository + sources Materializer + workspace WorkspaceRepository +} + +func New(logger *zap.Logger, projects ProjectRepository, sources Materializer, workspace WorkspaceRepository) *Service { + return &Service{logger: logger, projects: projects, sources: sources, workspace: workspace} +} + +type materializationKey struct { + source projectdomain.Source + reference contract.Reference +} + +type syncExecution struct { + project projectdomain.Project + dryRun bool + resolved map[materializationKey]artifact.Tree +} + +// Sync materializes and publishes selected targets sequentially in deterministic order. +// Dry-run performs no source materialization or filesystem mutation; a failure returns completed changes without rollback. +func (s *Service) Sync(ctx context.Context, command syncdomain.Command) (syncdomain.Result, error) { + result := syncdomain.Result{Targets: []string{}, Changes: []syncdomain.Change{}, DryRun: command.DryRun} + project, err := s.projects.LoadProject(ctx, command.ManifestPath) + if err != nil { + return result, fmt.Errorf("projects.LoadProject: %w", err) + } + targets, err := syncTargets(project.Manifest, command.Family, command.Target) + if err != nil { + return result, fmt.Errorf("syncTargets: %w", err) + } + execution := syncExecution{ + project: project, + dryRun: command.DryRun, + resolved: make(map[materializationKey]artifact.Tree), + } + for _, target := range targets { + id, change, err := s.syncTarget(ctx, execution, target) + if err != nil { + return result, err + } + result.Targets = append(result.Targets, id) + if change != nil { + result.Changes = append(result.Changes, *change) + } + } + if command.Target == "" { + changes, err := s.pruneStaleTargets(ctx, project, command.Family, command.DryRun) + if err != nil { + return result, err + } + result.Changes = append(result.Changes, changes...) + } + s.logger.Debug("source synchronization completed", zap.Int("targets", len(result.Targets))) + return result, nil +} + +func (s *Service) syncTarget( + ctx context.Context, + execution syncExecution, + target projectdomain.Target, +) (string, *syncdomain.Change, error) { + id := target.ID + if err := ctx.Err(); err != nil { + return "", nil, fmt.Errorf("ctx.Err: %w", err) + } + if target.Source.Type == projectdomain.SourceLocal { + return id, nil, nil + } + destination := target.Location.RelativePath + if execution.dryRun { + return id, &syncdomain.Change{Target: id, Path: destination, Action: syncdomain.ChangePlannedPublish}, nil + } + files, err := s.materializedFiles(ctx, execution.project.Root, target, execution.resolved) + if err != nil { + return "", nil, err + } + published, err := s.workspace.PublishDirectory(ctx, execution.project.Root, destination, files) + if err != nil { + operationErr := &syncdomain.OperationError{Operation: syncdomain.OperationPublish, Target: id, Path: destination, Kind: syncdomain.FailureUnavailable, Cause: err} + return "", nil, fmt.Errorf("workspace.PublishDirectory: %w", operationErr) + } + action := syncdomain.ChangeAction(published.Action) + return id, &syncdomain.Change{Target: id, Path: destination, Action: action}, nil +} + +func (s *Service) materializedFiles( + ctx context.Context, + projectRoot string, + target projectdomain.Target, + resolved map[materializationKey]artifact.Tree, +) (artifact.Tree, error) { + reference := target.Reference + key := materializationKey{source: target.Source, reference: reference} + if files, cached := resolved[key]; cached { + return files, nil + } + snapshot, err := s.sources.Materialize(ctx, projectRoot, target.Source, reference) + if err != nil { + operationErr := &syncdomain.OperationError{ + Operation: syncdomain.OperationMaterialize, + Target: target.ID, Source: target.SourceName, Path: target.Reference.Entrypoint, + Kind: syncdomain.FailureUnavailable, Cause: err, + } + return artifact.Tree{}, fmt.Errorf("sources.Materialize: %w", operationErr) + } + files := managedTree(snapshot) + resolved[key] = files + return files, nil +} + +func managedTree(snapshot contract.Snapshot) artifact.Tree { + files := make([]artifact.File, 0, len(snapshot.Files)+1) + for _, file := range snapshot.Files { + files = append(files, artifact.File{Path: file.Path, Content: append([]byte(nil), file.Content...), Mode: file.Mode}) + } + if snapshot.Metadata != nil { + content, err := json.Marshal(snapshot.Metadata) + if err == nil { + content = append(content, '\n') + files = append(files, artifact.File{Path: ".devctl-contract.json", Content: content, Mode: 0o644}) + } + } + return artifact.Tree{Files: files} +} diff --git a/internal/service/sync/service_test.go b/internal/service/sync/service_test.go new file mode 100644 index 0000000..7760fbe --- /dev/null +++ b/internal/service/sync/service_test.go @@ -0,0 +1,425 @@ +package sync_test + +import ( + "context" + "errors" + "testing" + + "github.com/devctllabs/devctl/internal/domain/artifact" + "github.com/devctllabs/devctl/internal/domain/contract" + "github.com/devctllabs/devctl/internal/domain/failure" + materializedomain "github.com/devctllabs/devctl/internal/domain/materialize" + projectdomain "github.com/devctllabs/devctl/internal/domain/project" + syncdomain "github.com/devctllabs/devctl/internal/domain/sync" + syncservice "github.com/devctllabs/devctl/internal/service/sync" + "github.com/devctllabs/devctl/internal/service/sync/mocks" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + "go.uber.org/zap" +) + +func TestServiceSyncOwnsTargetSelectionAndOutcome(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + projects := mocks.NewMockProjectRepository(ctrl) + sources := mocks.NewMockMaterializer(ctrl) + workspace := mocks.NewMockWorkspaceRepository(ctrl) + project := projectdomain.Project{ + Root: "/project", + ManifestPath: "/project/devctl.yaml", + Manifest: projectdomain.Manifest{ + Paths: projectdomain.ManifestPaths{ExternalContracts: "api/external"}, + Sources: map[string]projectdomain.Source{ + "alpha": {Type: "url", URL: "https://example.test/alpha.yaml"}, + "bravo": {Type: "git", Repo: "example/repo", Ref: "main"}, + "charlie": {Type: "url", URL: "https://example.test/charlie.yaml"}, + "local": {Type: "local", Path: "api/local.yaml"}, + }, + Components: projectdomain.Components{HTTP: &projectdomain.HTTP{Clients: []projectdomain.HTTPClient{ + {Name: "local", Source: "local", Path: "openapi.yaml"}, + {Name: "bravo", Source: "bravo", Path: "openapi.yaml"}, + {Name: "alpha", Source: "alpha", Path: "openapi.yaml"}, + {Name: "charlie", Source: "charlie", Path: "openapi.yaml"}, + }}}, + }, + } + projects.EXPECT().LoadProject(gomock.Any(), "custom.yaml").Return(project, nil) + gomock.InOrder( + sources.EXPECT().Materialize(gomock.Any(), project.Root, projectdomain.Source{Type: projectdomain.SourceURL, URL: "https://example.test/alpha.yaml"}, contract.Reference{Entrypoint: "openapi.yaml"}).Return(snapshot(t, "alpha"), nil), + workspace.EXPECT().PublishDirectory(gomock.Any(), project.Root, "api/external/http/client/alpha", artifact.Tree{Files: []artifact.File{{Path: "openapi.yaml", Content: []byte("alpha")}}}).Return(artifact.PublishResult{Action: artifact.PublishCreated}, nil), + sources.EXPECT().Materialize(gomock.Any(), project.Root, projectdomain.Source{Type: projectdomain.SourceGit, Repo: "example/repo", Ref: "main"}, contract.Reference{Entrypoint: "openapi.yaml"}).Return(snapshot(t, "bravo"), nil), + workspace.EXPECT().PublishDirectory(gomock.Any(), project.Root, "api/external/http/client/bravo", artifact.Tree{Files: []artifact.File{{Path: "openapi.yaml", Content: []byte("bravo")}}}).Return(artifact.PublishResult{Action: artifact.PublishUnchanged}, nil), + sources.EXPECT().Materialize(gomock.Any(), project.Root, projectdomain.Source{Type: projectdomain.SourceURL, URL: "https://example.test/charlie.yaml"}, contract.Reference{Entrypoint: "openapi.yaml"}).Return(snapshot(t, "charlie"), nil), + workspace.EXPECT().PublishDirectory(gomock.Any(), project.Root, "api/external/http/client/charlie", artifact.Tree{Files: []artifact.File{{Path: "openapi.yaml", Content: []byte("charlie")}}}).Return(artifact.PublishResult{Action: artifact.PublishUpdated}, nil), + ) + gomock.InOrder( + workspace.EXPECT().PruneDirectories(gomock.Any(), project.Root, "api/external/http/client", []string{"alpha", "bravo", "charlie"}).Return([]string{"stale"}, nil), + workspace.EXPECT().PruneDirectories(gomock.Any(), project.Root, "api/external/grpc/client", []string{}).Return(nil, nil), + workspace.EXPECT().PruneDirectories(gomock.Any(), project.Root, "api/external/kafka/consumer", []string{}).Return(nil, nil), + workspace.EXPECT().PruneDirectories(gomock.Any(), project.Root, "api/external/kafka/producer", []string{}).Return(nil, nil), + ) + service := syncservice.New(zap.NewNop(), projects, sources, workspace) + + result, err := service.Sync(context.Background(), syncdomain.Command{ManifestPath: "custom.yaml"}) + + require.NoError(t, err) + require.Equal(t, []string{"http-client:alpha", "http-client:bravo", "http-client:charlie", "http-client:local"}, result.Targets) + require.Equal(t, []syncdomain.Change{ + {Target: "http-client:alpha", Path: "api/external/http/client/alpha", Action: syncdomain.ChangeCreated}, + {Target: "http-client:bravo", Path: "api/external/http/client/bravo", Action: syncdomain.ChangeUnchanged}, + {Target: "http-client:charlie", Path: "api/external/http/client/charlie", Action: syncdomain.ChangeUpdated}, + {Target: "http-client:stale", Path: "api/external/http/client/stale", Action: syncdomain.ChangeRemoved}, + }, result.Changes) +} + +func TestServiceSyncReturnsAppliedChangesWithLateFailure(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + projects := mocks.NewMockProjectRepository(ctrl) + sources := mocks.NewMockMaterializer(ctrl) + workspace := mocks.NewMockWorkspaceRepository(ctrl) + project := projectdomain.Project{Root: "/project", Manifest: projectdomain.Manifest{ + Sources: map[string]projectdomain.Source{ + "alpha": {Type: "url", URL: "https://example.test/alpha.yaml"}, + "bravo": {Type: "url", URL: "https://example.test/bravo.yaml"}, + }, + Components: projectdomain.Components{HTTP: &projectdomain.HTTP{Clients: []projectdomain.HTTPClient{ + {Name: "bravo", Source: "bravo", Path: "openapi.yaml"}, + {Name: "alpha", Source: "alpha", Path: "openapi.yaml"}, + }}}, + }} + projects.EXPECT().LoadProject(gomock.Any(), "devctl.yaml").Return(project, nil) + gomock.InOrder( + sources.EXPECT().Materialize(gomock.Any(), project.Root, projectdomain.Source{Type: projectdomain.SourceURL, URL: "https://example.test/alpha.yaml"}, contract.Reference{Entrypoint: "openapi.yaml"}).Return(snapshot(t, "alpha"), nil), + workspace.EXPECT().PublishDirectory(gomock.Any(), "/project", "api/external/http/client/alpha", gomock.Any()).Return(artifact.PublishResult{Action: artifact.PublishCreated}, nil), + sources.EXPECT().Materialize(gomock.Any(), project.Root, projectdomain.Source{Type: projectdomain.SourceURL, URL: "https://example.test/bravo.yaml"}, contract.Reference{Entrypoint: "openapi.yaml"}).Return(contract.Snapshot{}, errors.New("upstream failed")), + ) + + result, err := syncservice.New(zap.NewNop(), projects, sources, workspace).Sync(context.Background(), syncdomain.Command{ManifestPath: "devctl.yaml"}) + + require.Equal(t, failure.Unavailable, failure.CategoryOf(err)) + require.Equal(t, []string{"http-client:alpha"}, result.Targets) + require.Equal(t, []syncdomain.Change{{ + Target: "http-client:alpha", Path: "api/external/http/client/alpha", Action: syncdomain.ChangeCreated, + }}, result.Changes) +} + +func TestServiceSyncPreservesStaleSnapshotDiagnosticDuringReexport(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + projects := mocks.NewMockProjectRepository(ctrl) + sources := mocks.NewMockMaterializer(ctrl) + workspace := mocks.NewMockWorkspaceRepository(ctrl) + source := projectdomain.Source{Type: projectdomain.SourceDevctl, Repo: "example/contracts", Ref: "v1"} + project := projectdomain.Project{Root: "/project", Manifest: projectdomain.Manifest{ + Sources: map[string]projectdomain.Source{"upstream": source}, + Components: projectdomain.Components{Kafka: &projectdomain.Kafka{Consumers: []projectdomain.KafkaConsumer{{ + Name: "events", Topic: "sample.events.created.v1", Contract: projectdomain.KafkaContract{ + Source: "upstream", Export: "events", Format: "json", + }, + }}}}, + }} + reference := contract.Reference{Export: "events", Topic: "sample.events.created.v1", Format: "json"} + metadataErr := &contract.SnapshotMetadataError{ + Field: "entrypoint", Reason: contract.MetadataRequired, Hint: "devctl sync", + } + projects.EXPECT().LoadProject(gomock.Any(), "devctl.yaml").Return(project, nil) + sources.EXPECT().Materialize(gomock.Any(), project.Root, source, reference).Return(contract.Snapshot{}, metadataErr) + service := syncservice.New(zap.NewNop(), projects, sources, workspace) + + _, err := service.Sync(context.Background(), syncdomain.Command{ + ManifestPath: "devctl.yaml", Target: "kafka-consumer:events", + }) + + require.ErrorIs(t, err, metadataErr) + require.Equal(t, failure.InvalidInput, failure.CategoryOf(err)) +} + +func TestServiceSyncPreservesMaterializationFailureCategories(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + cause error + category failure.Category + }{ + {name: "invalid input", cause: &materializedomain.OperationError{Kind: materializedomain.FailureInvalid}, category: failure.InvalidInput}, + {name: "not found", cause: &materializedomain.OperationError{Kind: materializedomain.FailureNotFound}, category: failure.NotFound}, + {name: "unsupported", cause: &materializedomain.OperationError{Kind: materializedomain.FailureUnsupported}, category: failure.Unsupported}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + projects := mocks.NewMockProjectRepository(ctrl) + sources := mocks.NewMockMaterializer(ctrl) + workspace := mocks.NewMockWorkspaceRepository(ctrl) + source := projectdomain.Source{Type: projectdomain.SourceURL, URL: "https://example.test/openapi.yaml"} + project := projectdomain.Project{Root: "/project", Manifest: projectdomain.Manifest{ + Sources: map[string]projectdomain.Source{"upstream": source}, + Components: projectdomain.Components{HTTP: &projectdomain.HTTP{Clients: []projectdomain.HTTPClient{{ + Name: "upstream", Source: "upstream", Path: "openapi.yaml", + }}}}, + }} + projects.EXPECT().LoadProject(gomock.Any(), "devctl.yaml").Return(project, nil) + sources.EXPECT().Materialize( + gomock.Any(), project.Root, source, contract.Reference{Entrypoint: "openapi.yaml"}, + ).Return(contract.Snapshot{}, test.cause) + service := syncservice.New(zap.NewNop(), projects, sources, workspace) + + _, err := service.Sync(context.Background(), syncdomain.Command{ + ManifestPath: "devctl.yaml", Target: "http-client:upstream", + }) + + require.Equal(t, test.category, failure.CategoryOf(err)) + var operationErr *syncdomain.OperationError + require.ErrorAs(t, err, &operationErr) + require.Equal(t, "http-client:upstream", operationErr.Target) + require.Equal(t, "upstream", operationErr.Source) + require.Equal(t, "openapi.yaml", operationErr.Path) + }) + } +} + +func TestServiceSyncCarriesProtoRootIntoMaterialization(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + projects := mocks.NewMockProjectRepository(ctrl) + sources := mocks.NewMockMaterializer(ctrl) + workspace := mocks.NewMockWorkspaceRepository(ctrl) + source := projectdomain.Source{Type: projectdomain.SourceGit, Repo: "example/contracts", Ref: "v1", Proto: projectdomain.SourceProto{BufConfig: "buf.yaml"}} + project := projectdomain.Project{Root: "/project", Manifest: projectdomain.Manifest{ + Paths: projectdomain.ManifestPaths{ExternalContracts: "api/external"}, + Sources: map[string]projectdomain.Source{"contracts": source}, + Components: projectdomain.Components{GRPC: &projectdomain.GRPC{Clients: []projectdomain.GRPCClient{{ + Name: "billing", Source: "contracts", Path: "proto/acme/billing/v1/service.proto", ProtoRoot: "proto", + }}}}, + }} + projects.EXPECT().LoadProject(gomock.Any(), "devctl.yaml").Return(project, nil) + sources.EXPECT().Materialize(gomock.Any(), project.Root, source, contract.Reference{ + Entrypoint: "proto/acme/billing/v1/service.proto", Format: "proto", ProtoRoot: "proto", + }).Return(contract.Snapshot{Entrypoint: "proto/acme/billing/v1/service.proto", Files: []contract.File{{ + Path: "proto/acme/billing/v1/service.proto", Content: []byte("syntax = \"proto3\";\n"), + }}}, nil) + workspace.EXPECT().PublishDirectory(gomock.Any(), project.Root, "api/external/grpc/client/billing", gomock.Any()).Return(artifact.PublishResult{Action: artifact.PublishCreated}, nil) + workspace.EXPECT().PruneDirectories(gomock.Any(), project.Root, "api/external/grpc/client", []string{"billing"}).Return(nil, nil) + service := syncservice.New(zap.NewNop(), projects, sources, workspace) + + result, err := service.Sync(context.Background(), syncdomain.Command{ManifestPath: "devctl.yaml", Family: "grpc"}) + + require.NoError(t, err) + require.Equal(t, []string{"grpc-client:billing"}, result.Targets) +} + +func TestServiceSyncPublishesDevctlGRPCMetadataAtTargetRoot(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + projects := mocks.NewMockProjectRepository(ctrl) + sources := mocks.NewMockMaterializer(ctrl) + workspace := mocks.NewMockWorkspaceRepository(ctrl) + source := projectdomain.Source{Type: projectdomain.SourceDevctl, Repo: "example/contracts", Ref: "v1"} + project := projectdomain.Project{Root: "/project", Manifest: projectdomain.Manifest{ + Sources: map[string]projectdomain.Source{"contracts": source}, + Components: projectdomain.Components{GRPC: &projectdomain.GRPC{Clients: []projectdomain.GRPCClient{{ + Name: "billing", Source: "contracts", Export: "billing", + }}}}, + }} + reference := contract.Reference{Export: "billing", Format: "proto"} + snapshot := contract.Snapshot{ + ModuleRoot: "api/proto/grpc", + Files: []contract.File{ + {Path: "api/proto/grpc/service.proto", Content: []byte("syntax = \"proto3\";\n")}, + {Path: "buf.yaml", Content: []byte("version: v2\n")}, + }, + Metadata: &contract.Metadata{ + Kind: "grpc", Format: "proto", ModuleRoot: "api/proto/grpc", BufConfig: "buf.yaml", + }, + } + projects.EXPECT().LoadProject(gomock.Any(), "devctl.yaml").Return(project, nil) + sources.EXPECT().Materialize(gomock.Any(), project.Root, source, reference).Return(snapshot, nil) + workspace.EXPECT().PublishDirectory( + gomock.Any(), project.Root, "api/external/grpc/client/billing", artifact.Tree{Files: []artifact.File{ + {Path: "api/proto/grpc/service.proto", Content: []byte("syntax = \"proto3\";\n")}, + {Path: "buf.yaml", Content: []byte("version: v2\n")}, + {Path: ".devctl-contract.json", Content: []byte("{\"kind\":\"grpc\",\"format\":\"proto\",\"module_root\":\"api/proto/grpc\",\"buf_config\":\"buf.yaml\"}\n"), Mode: 0o644}, + }}, + ).Return(artifact.PublishResult{Action: artifact.PublishCreated}, nil) + service := syncservice.New(zap.NewNop(), projects, sources, workspace) + + _, err := service.Sync(context.Background(), syncdomain.Command{ + ManifestPath: "devctl.yaml", Target: "grpc-client:billing", + }) + + require.NoError(t, err) +} + +func TestServiceSyncPublishesKafkaSidecarAtConsumerAndProducerRoots(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + projects := mocks.NewMockProjectRepository(ctrl) + sources := mocks.NewMockMaterializer(ctrl) + workspace := mocks.NewMockWorkspaceRepository(ctrl) + source := projectdomain.Source{Type: projectdomain.SourceDevctl, Repo: "example/contracts", Ref: "v1"} + selected := projectdomain.KafkaContract{Source: "contracts", Export: "events", Format: "json"} + project := projectdomain.Project{Root: "/project", Manifest: projectdomain.Manifest{ + Sources: map[string]projectdomain.Source{"contracts": source}, + Components: projectdomain.Components{Kafka: &projectdomain.Kafka{ + Consumers: []projectdomain.KafkaConsumer{{Name: "audit", Topic: "sample.audit.events.v1", Contract: selected}}, + Producers: []projectdomain.KafkaProducer{{Name: "audit", Topic: "sample.audit.events.v1", Contract: selected}}, + }}, + }} + reference := contract.Reference{Export: "events", Format: "json", Topic: "sample.audit.events.v1"} + snapshot := contract.Snapshot{ + Entrypoint: "schemas/event.json", + Files: []contract.File{{Path: "schemas/event.json", Content: []byte(`{"title":"Event"}`)}}, + Metadata: &contract.Metadata{ + Kind: "kafka", Topic: "sample.audit.events.v1", Format: "json", Entrypoint: "schemas/event.json", + }, + } + expectedTree := artifact.Tree{Files: []artifact.File{ + {Path: "schemas/event.json", Content: []byte(`{"title":"Event"}`)}, + {Path: ".devctl-contract.json", Content: []byte("{\"kind\":\"kafka\",\"topic\":\"sample.audit.events.v1\",\"format\":\"json\",\"entrypoint\":\"schemas/event.json\"}\n"), Mode: 0o644}, + }} + projects.EXPECT().LoadProject(gomock.Any(), "devctl.yaml").Return(project, nil) + sources.EXPECT().Materialize(gomock.Any(), project.Root, source, reference).Return(snapshot, nil) + workspace.EXPECT().PublishDirectory(gomock.Any(), project.Root, "api/external/kafka/consumer/audit", expectedTree).Return(artifact.PublishResult{Action: artifact.PublishCreated}, nil) + workspace.EXPECT().PublishDirectory(gomock.Any(), project.Root, "api/external/kafka/producer/audit", expectedTree).Return(artifact.PublishResult{Action: artifact.PublishCreated}, nil) + workspace.EXPECT().PruneDirectories(gomock.Any(), project.Root, "api/external/kafka/consumer", []string{"audit"}).Return(nil, nil) + workspace.EXPECT().PruneDirectories(gomock.Any(), project.Root, "api/external/kafka/producer", []string{"audit"}).Return(nil, nil) + service := syncservice.New(zap.NewNop(), projects, sources, workspace) + + _, err := service.Sync(context.Background(), syncdomain.Command{ManifestPath: "devctl.yaml", Family: "kafka"}) + + require.NoError(t, err) +} + +func TestServiceSyncDryRunDoesNotMaterializeURLSource(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + projects := mocks.NewMockProjectRepository(ctrl) + sources := mocks.NewMockMaterializer(ctrl) + workspace := mocks.NewMockWorkspaceRepository(ctrl) + project := projectdomain.Project{Root: "/project", Manifest: projectdomain.Manifest{ + Paths: projectdomain.ManifestPaths{ExternalContracts: "api/external"}, + Sources: map[string]projectdomain.Source{ + "billing": {Type: projectdomain.SourceURL, URL: "https://example.test/billing/openapi.yaml"}, + }, + Components: projectdomain.Components{HTTP: &projectdomain.HTTP{Clients: []projectdomain.HTTPClient{{ + Name: "billing", Source: "billing", Path: "spec/openapi.yaml", + }}}}, + }} + projects.EXPECT().LoadProject(gomock.Any(), "devctl.yaml").Return(project, nil) + workspace.EXPECT().PreviewPruneDirectories( + gomock.Any(), project.Root, "api/external/http/client", []string{"billing"}, + ).Return(nil, nil) + service := syncservice.New(zap.NewNop(), projects, sources, workspace) + + result, err := service.Sync(context.Background(), syncdomain.Command{ + ManifestPath: "devctl.yaml", Family: "http", DryRun: true, + }) + + require.NoError(t, err) + require.Equal(t, []string{"http-client:billing"}, result.Targets) + require.Equal(t, []syncdomain.Change{{ + Target: "http-client:billing", Path: "api/external/http/client/billing", Action: syncdomain.ChangePlannedPublish, + }}, result.Changes) +} + +func TestServiceSyncAppliesCatalogSelectionContract(t *testing.T) { + t.Parallel() + + manifest := projectdomain.Manifest{ + Project: projectdomain.Identity{Language: "go"}, + Sources: map[string]projectdomain.Source{ + "local": {Type: projectdomain.SourceLocal, Path: "api/contracts"}, + }, + Components: projectdomain.Components{ + HTTP: &projectdomain.HTTP{Clients: []projectdomain.HTTPClient{{ + Name: "local", Source: "local", Path: "openapi.yaml", + }}}, + Kafka: &projectdomain.Kafka{Consumers: []projectdomain.KafkaConsumer{{ + Name: "raw", Topic: "sample.events.raw.v1", Contract: projectdomain.KafkaContract{Format: "raw"}, + }}}, + }, + } + tests := []struct { + name string + command syncdomain.Command + targets []string + category failure.Category + }{ + {name: "known empty family", command: syncdomain.Command{Family: "grpc", DryRun: true}, targets: []string{}}, + {name: "local sync no-op", command: syncdomain.Command{Target: "http-client:local", DryRun: true}, targets: []string{"http-client:local"}}, + {name: "unknown family", command: syncdomain.Command{Family: "other", DryRun: true}, category: failure.InvalidInput}, + {name: "unknown target", command: syncdomain.Command{Target: "grpc-client:missing", DryRun: true}, category: failure.NotFound}, + {name: "config does not sync", command: syncdomain.Command{Target: "config", DryRun: true}, category: failure.Unsupported}, + {name: "raw Kafka does not sync", command: syncdomain.Command{Target: "kafka-consumer:raw", DryRun: true}, category: failure.Unsupported}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + projects := mocks.NewMockProjectRepository(ctrl) + workspace := mocks.NewMockWorkspaceRepository(ctrl) + projects.EXPECT().LoadProject(gomock.Any(), "devctl.yaml").Return(projectdomain.Project{Root: "/project", Manifest: manifest}, nil) + if test.name == "known empty family" { + workspace.EXPECT().PreviewPruneDirectories( + gomock.Any(), "/project", "api/external/grpc/client", []string{}, + ).Return(nil, nil) + } + service := syncservice.New( + zap.NewNop(), projects, mocks.NewMockMaterializer(ctrl), workspace, + ) + command := test.command + command.ManifestPath = "devctl.yaml" + + result, err := service.Sync(context.Background(), command) + + if test.category != "" { + require.Equal(t, test.category, failure.CategoryOf(err)) + return + } + require.NoError(t, err) + require.Equal(t, test.targets, result.Targets) + require.Empty(t, result.Changes) + }) + } +} + +func TestServiceSyncDryRunReportsPlannedStaleRemovalWithoutMutation(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + projects := mocks.NewMockProjectRepository(ctrl) + sources := mocks.NewMockMaterializer(ctrl) + workspace := mocks.NewMockWorkspaceRepository(ctrl) + project := projectdomain.Project{Root: "/project", Manifest: projectdomain.Manifest{}} + projects.EXPECT().LoadProject(gomock.Any(), "devctl.yaml").Return(project, nil) + workspace.EXPECT().PreviewPruneDirectories( + gomock.Any(), project.Root, "api/external/http/client", []string{}, + ).Return([]string{"stale"}, nil) + service := syncservice.New(zap.NewNop(), projects, sources, workspace) + + result, err := service.Sync(context.Background(), syncdomain.Command{ + ManifestPath: "devctl.yaml", Family: "http", DryRun: true, + }) + + require.NoError(t, err) + require.Empty(t, result.Targets) + require.Equal(t, []syncdomain.Change{{ + Target: "http-client:stale", Path: "api/external/http/client/stale", Action: syncdomain.ChangePlannedRemove, + }}, result.Changes) +} + +func snapshot(t *testing.T, content string) contract.Snapshot { + t.Helper() + return contract.Snapshot{Entrypoint: "openapi.yaml", Files: []contract.File{{Path: "openapi.yaml", Content: []byte(content)}}} +} diff --git a/internal/service/sync/targets.go b/internal/service/sync/targets.go new file mode 100644 index 0000000..75d6762 --- /dev/null +++ b/internal/service/sync/targets.go @@ -0,0 +1,22 @@ +package sync + +import ( + "fmt" + + projectdomain "github.com/devctllabs/devctl/internal/domain/project" +) + +func syncTargets(spec projectdomain.Manifest, family, selected string) ([]projectdomain.Target, error) { + targets, err := projectdomain.NewTargetCatalog(spec).Resolve(projectdomain.TargetOperationSync, family, selected) + if err != nil { + return nil, fmt.Errorf("catalog.Resolve: %w", err) + } + return targets, nil +} + +func externalContractsRoot(spec projectdomain.Manifest) string { + if spec.Paths.ExternalContracts != "" { + return spec.Paths.ExternalContracts + } + return "api/external" +} diff --git a/internal/service/targetinput/mocks/resolver.go b/internal/service/targetinput/mocks/resolver.go new file mode 100644 index 0000000..f8b1328 --- /dev/null +++ b/internal/service/targetinput/mocks/resolver.go @@ -0,0 +1,144 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/devctllabs/devctl/internal/service/targetinput (interfaces: EntrypointResolver,SnapshotLoader) +// +// Generated by this command: +// +// mockgen -destination mocks/resolver.go -package mocks -typed . EntrypointResolver,SnapshotLoader +// + +// Package mocks is a generated GoMock package. +package mocks + +import ( + context "context" + reflect "reflect" + + contract "github.com/devctllabs/devctl/internal/domain/contract" + gomock "go.uber.org/mock/gomock" +) + +// MockEntrypointResolver is a mock of EntrypointResolver interface. +type MockEntrypointResolver struct { + ctrl *gomock.Controller + recorder *MockEntrypointResolverMockRecorder + isgomock struct{} +} + +// MockEntrypointResolverMockRecorder is the mock recorder for MockEntrypointResolver. +type MockEntrypointResolverMockRecorder struct { + mock *MockEntrypointResolver +} + +// NewMockEntrypointResolver creates a new mock instance. +func NewMockEntrypointResolver(ctrl *gomock.Controller) *MockEntrypointResolver { + mock := &MockEntrypointResolver{ctrl: ctrl} + mock.recorder = &MockEntrypointResolverMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockEntrypointResolver) EXPECT() *MockEntrypointResolverMockRecorder { + return m.recorder +} + +// ResolveContract mocks base method. +func (m *MockEntrypointResolver) ResolveContract(ctx context.Context, location contract.Location) (string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ResolveContract", ctx, location) + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ResolveContract indicates an expected call of ResolveContract. +func (mr *MockEntrypointResolverMockRecorder) ResolveContract(ctx, location any) *MockEntrypointResolverResolveContractCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ResolveContract", reflect.TypeOf((*MockEntrypointResolver)(nil).ResolveContract), ctx, location) + return &MockEntrypointResolverResolveContractCall{Call: call} +} + +// MockEntrypointResolverResolveContractCall wrap *gomock.Call +type MockEntrypointResolverResolveContractCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockEntrypointResolverResolveContractCall) Return(arg0 string, arg1 error) *MockEntrypointResolverResolveContractCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockEntrypointResolverResolveContractCall) Do(f func(context.Context, contract.Location) (string, error)) *MockEntrypointResolverResolveContractCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockEntrypointResolverResolveContractCall) DoAndReturn(f func(context.Context, contract.Location) (string, error)) *MockEntrypointResolverResolveContractCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// MockSnapshotLoader is a mock of SnapshotLoader interface. +type MockSnapshotLoader struct { + ctrl *gomock.Controller + recorder *MockSnapshotLoaderMockRecorder + isgomock struct{} +} + +// MockSnapshotLoaderMockRecorder is the mock recorder for MockSnapshotLoader. +type MockSnapshotLoaderMockRecorder struct { + mock *MockSnapshotLoader +} + +// NewMockSnapshotLoader creates a new mock instance. +func NewMockSnapshotLoader(ctrl *gomock.Controller) *MockSnapshotLoader { + mock := &MockSnapshotLoader{ctrl: ctrl} + mock.recorder = &MockSnapshotLoaderMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockSnapshotLoader) EXPECT() *MockSnapshotLoaderMockRecorder { + return m.recorder +} + +// Load mocks base method. +func (m *MockSnapshotLoader) Load(ctx context.Context, root, treeRoot string, expected contract.MetadataExpectation) (contract.Snapshot, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Load", ctx, root, treeRoot, expected) + ret0, _ := ret[0].(contract.Snapshot) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Load indicates an expected call of Load. +func (mr *MockSnapshotLoaderMockRecorder) Load(ctx, root, treeRoot, expected any) *MockSnapshotLoaderLoadCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Load", reflect.TypeOf((*MockSnapshotLoader)(nil).Load), ctx, root, treeRoot, expected) + return &MockSnapshotLoaderLoadCall{Call: call} +} + +// MockSnapshotLoaderLoadCall wrap *gomock.Call +type MockSnapshotLoaderLoadCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockSnapshotLoaderLoadCall) Return(arg0 contract.Snapshot, arg1 error) *MockSnapshotLoaderLoadCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockSnapshotLoaderLoadCall) Do(f func(context.Context, string, string, contract.MetadataExpectation) (contract.Snapshot, error)) *MockSnapshotLoaderLoadCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockSnapshotLoaderLoadCall) DoAndReturn(f func(context.Context, string, string, contract.MetadataExpectation) (contract.Snapshot, error)) *MockSnapshotLoaderLoadCall { + c.Call = c.Call.DoAndReturn(f) + return c +} diff --git a/internal/service/targetinput/resolver.go b/internal/service/targetinput/resolver.go new file mode 100644 index 0000000..86639a4 --- /dev/null +++ b/internal/service/targetinput/resolver.go @@ -0,0 +1,61 @@ +package targetinput + +import ( + "context" + "fmt" + + "github.com/devctllabs/devctl/internal/domain/contract" + "github.com/devctllabs/devctl/internal/domain/project" +) + +//go:generate go tool mockgen -destination mocks/resolver.go -package mocks -typed . EntrypointResolver,SnapshotLoader + +// EntrypointResolver resolves a concrete contract entrypoint within a contained location. +type EntrypointResolver interface { + // ResolveContract returns the contained entrypoint selected by location. + ResolveContract(ctx context.Context, location contract.Location) (string, error) +} + +// SnapshotLoader reconstructs one committed Contract Snapshot without contacting its supplier. +type SnapshotLoader interface { + // Load reconstructs the Snapshot rooted at treeRoot and validates it against expected. + Load(ctx context.Context, root, treeRoot string, expected contract.MetadataExpectation) (contract.Snapshot, error) +} + +// Resolver attaches concrete local input to a logical Target. +type Resolver struct { + entrypoints EntrypointResolver + snapshots SnapshotLoader +} + +func New(entrypoints EntrypointResolver, snapshots SnapshotLoader) *Resolver { + return &Resolver{entrypoints: entrypoints, snapshots: snapshots} +} + +// Resolve attaches the concrete input required to execute target in selected Project. +func (r *Resolver) Resolve( + ctx context.Context, + selected project.Project, + target project.Target, +) (project.Target, error) { + if target.Source.Type == project.SourceDevctl && (target.Family == "grpc" || target.Family == "kafka") { + snapshot, err := r.snapshots.Load( + ctx, selected.Root, target.Location.RelativePath, target.SnapshotExpectation(), + ) + if err != nil { + return target, fmt.Errorf("snapshots.Load: %w", err) + } + return target.WithSnapshot(snapshot), nil + } + if target.Family != "http" { + return target, nil + } + location := target.Location + location.Root = selected.Root + input, err := r.entrypoints.ResolveContract(ctx, location) + if err != nil { + return target, fmt.Errorf("entrypoints.ResolveContract: %w", err) + } + target.Input = input + return target, nil +} diff --git a/internal/service/targetinput/resolver_test.go b/internal/service/targetinput/resolver_test.go new file mode 100644 index 0000000..6350816 --- /dev/null +++ b/internal/service/targetinput/resolver_test.go @@ -0,0 +1,225 @@ +package targetinput_test + +import ( + "context" + "errors" + "testing" + + "github.com/devctllabs/devctl/internal/domain/contract" + "github.com/devctllabs/devctl/internal/domain/project" + "github.com/devctllabs/devctl/internal/service/targetinput" + "github.com/devctllabs/devctl/internal/service/targetinput/mocks" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" +) + +func TestResolverResolvesHTTPEntrypointInsideProject(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + entrypoints := mocks.NewMockEntrypointResolver(ctrl) + snapshots := mocks.NewMockSnapshotLoader(ctrl) + resolver := targetinput.New(entrypoints, snapshots) + selected := project.Project{Root: "/project"} + target := project.Target{ + ID: "http-server", Family: "http", + Location: contract.Location{ + RelativePath: "api/openapi/swagger.yaml", + Entrypoint: "api/openapi/swagger.yaml", + Local: true, + }, + } + entrypoints.EXPECT().ResolveContract(gomock.Any(), contract.Location{ + Root: selected.Root, + RelativePath: target.Location.RelativePath, + Entrypoint: target.Location.Entrypoint, + Local: true, + }).Return("/project/api/openapi/swagger.yaml", nil) + + resolved, err := resolver.Resolve(context.Background(), selected, target) + + require.NoError(t, err) + require.Equal(t, "/project/api/openapi/swagger.yaml", resolved.Input) + require.Equal(t, target.Location, resolved.Location) +} + +func TestResolverResolvesExternalHTTPEntrypointInsideMaterializedTree(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + entrypoints := mocks.NewMockEntrypointResolver(ctrl) + snapshots := mocks.NewMockSnapshotLoader(ctrl) + target := project.Target{ + ID: "http-client:billing", Family: "http", + Source: project.Source{Type: project.SourceURL}, + Location: contract.Location{ + RelativePath: "api/external/http/client/billing", + Entrypoint: "openapi.yaml", + }, + } + entrypoints.EXPECT().ResolveContract(gomock.Any(), contract.Location{ + Root: "/project", + RelativePath: target.Location.RelativePath, + Entrypoint: target.Location.Entrypoint, + }).Return("/project/api/external/http/client/billing/openapi.yaml", nil) + + resolved, err := targetinput.New(entrypoints, snapshots).Resolve( + context.Background(), project.Project{Root: "/project"}, target, + ) + + require.NoError(t, err) + require.Equal(t, "/project/api/external/http/client/billing/openapi.yaml", resolved.Input) + require.Equal(t, target.Location, resolved.Location) +} + +func TestResolverResolvesCommittedSnapshotTargets(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + target project.Target + snapshot contract.Snapshot + input string + paths []string + }{ + { + name: "grpc", + target: project.Target{ + ID: "grpc-client:billing", Family: "grpc", Format: "proto", + Source: project.Source{Type: project.SourceDevctl}, + Location: contract.Location{RelativePath: "api/external/grpc/client/billing"}, + }, + snapshot: contract.Snapshot{ModuleRoot: "api/proto/grpc"}, + input: "api/external/grpc/client/billing/api/proto/grpc", + }, + { + name: "kafka proto", + target: project.Target{ + ID: "kafka-consumer:events", Family: "kafka", Format: "proto", + Source: project.Source{Type: project.SourceDevctl}, + Reference: contract.Reference{Topic: "sample.events.created.v1"}, + Location: contract.Location{RelativePath: "api/external/kafka/consumer/events"}, + }, + snapshot: contract.Snapshot{ModuleRoot: "proto", Entrypoint: "proto/events.proto"}, + input: "api/external/kafka/consumer/events/proto", + paths: []string{"events.proto"}, + }, + { + name: "kafka json", + target: project.Target{ + ID: "kafka-consumer:events", Family: "kafka", Format: "json", + Source: project.Source{Type: project.SourceDevctl}, + Reference: contract.Reference{Topic: "sample.events.created.v1"}, + Location: contract.Location{RelativePath: "api/external/kafka/consumer/events"}, + }, + snapshot: contract.Snapshot{Entrypoint: "schemas/events.json"}, + input: "api/external/kafka/consumer/events/schemas/events.json", + }, + { + name: "kafka raw", + target: project.Target{ + ID: "kafka-consumer:events", Family: "kafka", Format: "raw", + Source: project.Source{Type: project.SourceDevctl}, + Reference: contract.Reference{Topic: "sample.events.created.v1"}, + Location: contract.Location{RelativePath: "api/external/kafka/consumer/events"}, + }, + snapshot: contract.Snapshot{}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + entrypoints := mocks.NewMockEntrypointResolver(ctrl) + snapshots := mocks.NewMockSnapshotLoader(ctrl) + selected := project.Project{Root: "/project"} + snapshots.EXPECT().Load( + gomock.Any(), selected.Root, test.target.Location.RelativePath, test.target.SnapshotExpectation(), + ).Return(test.snapshot, nil) + + resolved, err := targetinput.New(entrypoints, snapshots).Resolve( + context.Background(), selected, test.target, + ) + + require.NoError(t, err) + require.Equal(t, test.input, resolved.Input) + require.Equal(t, test.paths, resolved.Paths) + if test.snapshot.Entrypoint != "" { + require.Equal(t, test.snapshot.Entrypoint, resolved.Location.Entrypoint) + } + }) + } +} + +func TestResolverPassesThroughTargetsWithoutResolvableInput(t *testing.T) { + t.Parallel() + + tests := []project.Target{ + {ID: "config", Family: "config", Format: "go"}, + {ID: "grpc-server", Family: "grpc", Format: "proto", Source: project.Source{Type: project.SourceLocal}}, + {ID: "kafka-producer:events", Family: "kafka", Format: "json", Source: project.Source{Type: project.SourceLocal}}, + } + for _, target := range tests { + target := target + t.Run(target.ID, func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + resolved, err := targetinput.New( + mocks.NewMockEntrypointResolver(ctrl), mocks.NewMockSnapshotLoader(ctrl), + ).Resolve(context.Background(), project.Project{Root: "/project"}, target) + + require.NoError(t, err) + require.Equal(t, target, resolved) + }) + } +} + +func TestResolverPreservesDependencyErrors(t *testing.T) { + t.Parallel() + + t.Run("entrypoint", func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + entrypoints := mocks.NewMockEntrypointResolver(ctrl) + snapshots := mocks.NewMockSnapshotLoader(ctrl) + cause := errors.New("entrypoint unavailable") + target := project.Target{Family: "http", Location: contract.Location{RelativePath: "api", Entrypoint: "openapi.yaml"}} + entrypoints.EXPECT().ResolveContract(gomock.Any(), contract.Location{ + Root: "/project", RelativePath: "api", Entrypoint: "openapi.yaml", + }).Return("", cause) + + resolved, err := targetinput.New(entrypoints, snapshots).Resolve( + context.Background(), project.Project{Root: "/project"}, target, + ) + + require.Equal(t, target, resolved) + require.ErrorIs(t, err, cause) + }) + + t.Run("snapshot", func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + entrypoints := mocks.NewMockEntrypointResolver(ctrl) + snapshots := mocks.NewMockSnapshotLoader(ctrl) + cause := errors.New("snapshot unavailable") + target := project.Target{ + Family: "grpc", Format: "proto", Source: project.Source{Type: project.SourceDevctl}, + Location: contract.Location{RelativePath: "contracts"}, + } + snapshots.EXPECT().Load( + gomock.Any(), "/project", "contracts", target.SnapshotExpectation(), + ).Return(contract.Snapshot{}, cause) + + resolved, err := targetinput.New(entrypoints, snapshots).Resolve( + context.Background(), project.Project{Root: "/project"}, target, + ) + + require.Equal(t, target, resolved) + require.ErrorIs(t, err, cause) + }) +} diff --git a/internal/testutil/testexec/testexec.go b/internal/testutil/testexec/testexec.go new file mode 100644 index 0000000..be3e66d --- /dev/null +++ b/internal/testutil/testexec/testexec.go @@ -0,0 +1,18 @@ +// Package testexec provides fixtures for testing subprocess clients. +package testexec + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +// StubPathCommand installs script as name and returns a PATH that resolves it first. +func StubPathCommand(t *testing.T, name, script string) string { + t.Helper() + bin := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(bin, name), []byte(script), 0o755)) + return bin + string(os.PathListSeparator) + os.Getenv("PATH") +}