@shortlink-org/portolan 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (188) hide show
  1. package/README.md +151 -6
  2. package/catalog/model.go +147 -0
  3. package/catalog/roundtrip_test.go +7 -0
  4. package/cli/portolan.mjs +6 -1
  5. package/go.mod +7 -0
  6. package/go.sum +20 -6
  7. package/package.json +10 -9
  8. package/plugin/describe.go +38 -0
  9. package/plugins/README.md +296 -5
  10. package/plugins/extract-argocd/options.schema.json +44 -0
  11. package/plugins/extract-celery/main.py +1 -0
  12. package/plugins/extract-csharp-ddd/README.md +213 -0
  13. package/plugins/extract-csharp-ddd/options.schema.json +55 -0
  14. package/plugins/extract-django/README.md +13 -2
  15. package/plugins/extract-django/choices.py +47 -0
  16. package/plugins/extract-django/domain.py +2 -1
  17. package/plugins/extract-django/extract_test.py +13 -0
  18. package/plugins/extract-django/lifecycle.py +2 -28
  19. package/plugins/extract-django/main.py +1 -0
  20. package/plugins/extract-django/rules.py +154 -0
  21. package/plugins/extract-django/rules_test.py +158 -0
  22. package/plugins/extract-go-sqs/options.schema.json +21 -0
  23. package/plugins/extract-http-clients/describe.go +5 -4
  24. package/plugins/extract-java/build/org/portolan/extract/Main.class +0 -0
  25. package/plugins/extract-k8s/options.schema.json +32 -0
  26. package/plugins/extract-laravel/Cargo.lock +962 -0
  27. package/plugins/extract-laravel/Cargo.toml +20 -0
  28. package/plugins/extract-laravel/README.md +200 -0
  29. package/plugins/extract-laravel/options.schema.json +68 -0
  30. package/plugins/extract-laravel/rustfmt.toml +1 -0
  31. package/plugins/extract-php-ddd/Cargo.lock +972 -0
  32. package/plugins/extract-php-ddd/Cargo.toml +22 -0
  33. package/plugins/extract-php-ddd/README.md +141 -0
  34. package/plugins/extract-php-ddd/options.schema.json +50 -0
  35. package/plugins/extract-php-ddd/rustfmt.toml +1 -0
  36. package/plugins/extract-python-kafka/main.py +1 -0
  37. package/plugins/extract-terraform/options.schema.json +26 -0
  38. package/plugins/extract-ts/main.ts +1 -0
  39. package/plugins/phpscan/Cargo.lock +953 -0
  40. package/plugins/phpscan/Cargo.toml +19 -0
  41. package/plugins/phpscan/rustfmt.toml +1 -0
  42. package/plugins/portolan-go.wasm +0 -0
  43. package/plugins/pyplugin/catalog.py +24 -1
  44. package/plugins/verify-otel/options.schema.json +12 -0
  45. package/portolan.json +130 -3
  46. package/rules/builtin.json +218 -0
  47. package/schema/portolan.schema.json +880 -13
  48. package/scripts/README.md +3 -1
  49. package/scripts/delivery-presets.mjs +36 -3
  50. package/scripts/gen-likec4.mjs +149 -16
  51. package/scripts/gen-likec4.test.mjs +96 -0
  52. package/scripts/gen.mjs +30 -3
  53. package/scripts/gitops-example.test.mjs +108 -0
  54. package/scripts/host-plugins/fetch-argocd.mjs +338 -0
  55. package/scripts/host-plugins/fetch-argocd.options.json +49 -0
  56. package/scripts/host-plugins/fetch-argocd.test.mjs +274 -0
  57. package/scripts/host-plugins/fetch-bsr.mjs +1 -0
  58. package/scripts/host-plugins/fetch-csr.mjs +1 -0
  59. package/scripts/host-plugins/fetch-git.mjs +1 -0
  60. package/scripts/host-plugins/fetch-k8s.mjs +263 -0
  61. package/scripts/host-plugins/fetch-k8s.options.json +50 -0
  62. package/scripts/host-plugins/fetch-k8s.test.mjs +259 -0
  63. package/scripts/host-plugins/k8s-topology.mjs +183 -0
  64. package/scripts/json-format.mjs +192 -0
  65. package/scripts/json-format.test.mjs +97 -0
  66. package/scripts/local-api.mjs +216 -8
  67. package/scripts/local-api.test.mjs +88 -2
  68. package/scripts/local-discovery.mjs +12 -0
  69. package/scripts/manifest.mjs +10 -1
  70. package/scripts/plugin-host.mjs +23 -1
  71. package/scripts/plugins-fresh.mjs +97 -0
  72. package/scripts/plugins-fresh.test.mjs +64 -0
  73. package/scripts/run-builtin.mjs +26 -5
  74. package/scripts/schema.mjs +138 -19
  75. package/scripts/trace-trials.mjs +176 -0
  76. package/scripts/trace-trials.test.mjs +142 -0
  77. package/scripts/warning-policy.mjs +12 -6
  78. package/scripts/warning-policy.test.mjs +40 -3
  79. package/src/app/Breadcrumbs.test.ts +3 -0
  80. package/src/app/Breadcrumbs.tsx +3 -0
  81. package/src/app/CatalogApp.tsx +2 -0
  82. package/src/app/SidebarFooter.tsx +20 -4
  83. package/src/catalog-index.ts +25 -0
  84. package/src/catalog-model.ts +246 -1
  85. package/src/catalog-validation.ts +104 -2
  86. package/src/catalog.test.ts +40 -1
  87. package/src/components/ChannelRows.test.tsx +76 -1
  88. package/src/components/ChannelRows.tsx +223 -60
  89. package/src/components/DeploymentRows.tsx +131 -0
  90. package/src/components/DocsLinks.test.tsx +27 -0
  91. package/src/components/DocsLinks.tsx +56 -0
  92. package/src/components/FieldTree.tsx +5 -2
  93. package/src/components/HTTPDestinationEvidence.tsx +1 -1
  94. package/src/components/PluginIcon.tsx +77 -0
  95. package/src/components/ProblemRow.tsx +173 -153
  96. package/src/components/RuleMarks.tsx +22 -0
  97. package/src/components/ShapeRows.tsx +24 -20
  98. package/src/data.ts +21 -0
  99. package/src/enrich.test.ts +127 -3
  100. package/src/enrich.ts +104 -6
  101. package/src/er/ErCanvas.tsx +218 -12
  102. package/src/er/GroupNode.tsx +57 -0
  103. package/src/er/StoreHeader.tsx +1 -0
  104. package/src/er/layout.test.ts +85 -2
  105. package/src/er/layout.ts +140 -5
  106. package/src/er/spec.test.ts +17 -0
  107. package/src/er/spec.ts +23 -10
  108. package/src/flow/Recordings.test.tsx +52 -0
  109. package/src/flow/Recordings.tsx +236 -0
  110. package/src/flow/StepDetail.tsx +53 -0
  111. package/src/flow/TraceTrial.tsx +419 -0
  112. package/src/flow/examples.test.ts +33 -0
  113. package/src/flow/examples.ts +37 -0
  114. package/src/flow/outline.test.ts +28 -0
  115. package/src/flow/outline.ts +7 -2
  116. package/src/flow/trace-trial-resume.test.ts +42 -0
  117. package/src/flow/trace-trial-resume.ts +74 -0
  118. package/src/graph/elk.ts +78 -0
  119. package/src/index.css +24 -0
  120. package/src/landing/LandingPage.tsx +15 -11
  121. package/src/landing/ProductTour.tsx +6 -6
  122. package/src/lib/all-problems.ts +27 -17
  123. package/src/lib/centrality.test.ts +251 -0
  124. package/src/lib/centrality.ts +232 -0
  125. package/src/lib/confluence.test.ts +37 -0
  126. package/src/lib/confluence.ts +41 -0
  127. package/src/lib/context-color.ts +1 -1
  128. package/src/lib/deployment-drift.ts +22 -0
  129. package/src/lib/derive.ts +43 -69
  130. package/src/lib/environments.test.ts +83 -0
  131. package/src/lib/environments.ts +57 -0
  132. package/src/lib/integration-url.test.ts +30 -0
  133. package/src/lib/integration-url.ts +63 -0
  134. package/src/lib/kafka-ui.ts +3 -45
  135. package/src/lib/local-api.ts +96 -2
  136. package/src/lib/notion.ts +13 -0
  137. package/src/lib/plugin-index.json +2765 -0
  138. package/src/lib/plugins.test.ts +68 -0
  139. package/src/lib/plugins.ts +264 -0
  140. package/src/lib/problem-flows.test.ts +61 -0
  141. package/src/lib/problem-flows.ts +78 -0
  142. package/src/lib/problem-rules-cel.d.mts +43 -0
  143. package/src/lib/problem-rules-cel.mjs +407 -0
  144. package/src/lib/problem-rules.test.ts +287 -0
  145. package/src/lib/problem-rules.ts +271 -0
  146. package/src/lib/problem-subjects.ts +737 -0
  147. package/src/lib/rule-entries.ts +39 -0
  148. package/src/lib/{data-problems.test.ts → rules-data.test.ts} +18 -16
  149. package/src/lib/rules-deploy.test.ts +133 -0
  150. package/src/lib/{problems.test.ts → rules-edges.test.ts} +8 -3
  151. package/src/lib/{proto-problems.test.ts → rules-proto.test.ts} +9 -25
  152. package/src/lib/{wire-problems.test.ts → rules-wire.test.ts} +13 -11
  153. package/src/lib/rules.test.ts +51 -0
  154. package/src/lib/rules.ts +86 -0
  155. package/src/lib/shape.test.ts +32 -0
  156. package/src/lib/shape.ts +30 -6
  157. package/src/lib/tech.ts +16 -0
  158. package/src/lib/trace-project.test.ts +34 -0
  159. package/src/lib/trace-project.ts +50 -0
  160. package/src/lib/use-problems.ts +23 -0
  161. package/src/lib/warnings.test.ts +9 -0
  162. package/src/lib/warnings.ts +10 -10
  163. package/src/likec4/ids.test.ts +6 -2
  164. package/src/likec4/ids.ts +43 -0
  165. package/src/main.tsx +23 -0
  166. package/src/merge-deployments.test.ts +127 -0
  167. package/src/merge.test.ts +82 -0
  168. package/src/merge.ts +173 -9
  169. package/src/pages/AggregatePage.tsx +57 -7
  170. package/src/pages/ContextMap.tsx +45 -3
  171. package/src/pages/ContextPage.tsx +2 -0
  172. package/src/pages/EventPage.tsx +15 -5
  173. package/src/pages/FlowDetail.tsx +23 -2
  174. package/src/pages/GraphPage.tsx +40 -3
  175. package/src/pages/Overview.tsx +152 -12
  176. package/src/pages/PluginIndex.tsx +190 -0
  177. package/src/pages/Problems.tsx +396 -128
  178. package/src/pages/ServicePage.tsx +58 -2
  179. package/src/pages/Settings.tsx +28 -2
  180. package/src/pages/settings/AboutSettings.tsx +8 -1
  181. package/src/pages/settings/IntegrationsSettings.tsx +63 -17
  182. package/src/pages/settings/RecordingSettings.tsx +138 -0
  183. package/src/pages/settings/RulesSettings.tsx +825 -0
  184. package/src/routes.test.ts +9 -0
  185. package/src/routes.ts +22 -1
  186. package/src/lib/data-problems.ts +0 -314
  187. package/src/lib/proto-problems.ts +0 -237
  188. package/src/lib/wire-problems.ts +0 -342
package/plugins/README.md CHANGED
@@ -18,7 +18,7 @@ the plugin itself rather than its work:
18
18
  → { "portolanVersion": "0.1.0", "kind": "describe" }
19
19
  ← { "files": [],
20
20
  "describe": { "name": "extract-go", "summary": "...",
21
- "phases": ["extract"],
21
+ "category": "code", "phases": ["extract"],
22
22
  "options": { "type": "object", "additionalProperties": false,
23
23
  "properties": { "context": { ... } } } } }
24
24
  ```
@@ -31,6 +31,16 @@ is what makes that worth having: `encoding/json` drops a field it does not
31
31
  recognise, so before this a misspelled option was no option at all and nothing
32
32
  said so.
33
33
 
34
+ `category` is what the plugin reads or makes, one of `code`, `contracts`,
35
+ `messaging`, `data`, `infrastructure`, `repository`, `documents`, `evidence`,
36
+ `sources` and `exports` (the constants in `plugin/describe.go`). A phase says
37
+ where in a run a plugin goes; the category says what kind of fact it is after,
38
+ which is how the site's `/plugins` page groups it. `npm run schema` also writes
39
+ `src/lib/plugin-index.json` - every declared plugin as it describes itself,
40
+ with how the manifest runs it - and that page and the landing page's list of
41
+ inputs are rendered from it, so a plugin added to `portolan.json` appears on
42
+ both without being written down twice.
43
+
34
44
  A descriptor may also carry `needs`: what the host must put in the request
35
45
  beyond the tree, because a sandboxed module cannot reach it. The one need so
36
46
  far is `history` (portolan.0007) - when each file under the root was first
@@ -154,7 +164,14 @@ a value's name is the constant's literal, because that is what the wire
154
164
  carries, and the constant's own name only for an iota. A `Deprecated:`
155
165
  paragraph in the doc marks the value. In Rust it is a `pub enum` whose every
156
166
  variant is a bare name, the literal an `as_str` answers standing in for the
157
- variant. In Java it is a top-level enum in the aggregate's package. In proto,
167
+ variant. In Java it is a top-level enum in the aggregate's package. In PHP it
168
+ is a backed `enum` anywhere in the module, and on an Eloquent model the
169
+ constants that share a prefix - `STATUS_PENDING = 'pending'` beside
170
+ `STATUS_CLOSED = 'closed'` - with `@deprecated` in a constant's docblock
171
+ marking the value; see `extract-laravel/README.md`. In a PHP tree laid out
172
+ by bounded context (`extract-php-ddd`) it is a backed `enum` under a module's
173
+ `Domain/`. In a .NET modular monolith (`extract-csharp-ddd`) it is a C# `enum`
174
+ in the aggregate's directory, `[Obsolete]` marking a value. In proto,
158
175
  the enums the messages reach through their fields sit on the interface as
159
176
  `enums`, with the numbers the wire uses.
160
177
 
@@ -248,6 +265,61 @@ channel's doc. A subject read off a database row is a warning at the call,
248
265
  not a channel. Consumer configs give the filter subject and the durable name;
249
266
  streams, wildcard subjects and work-queue retention are not read yet.
250
267
 
268
+ `extract-go-sqs` is its twin for Amazon SQS through aws-sdk-go-v2. A call is
269
+ known by being made on `*sqs.Client` - `SendMessage`, `SendMessageBatch`,
270
+ `ReceiveMessage` - and the queue is the `QueueUrl` of the input struct,
271
+ written inline or built into a local first. `aws.String` and `aws.ToString`
272
+ are taken off, and the `QueueUrl` of a `GetQueueUrl` result is read as the
273
+ `QueueName` that was asked for. The queue is then followed like a subject: to
274
+ a literal, a constant, a config default, or a parameter up to two hops through
275
+ the callers, and when it sits in a field of the receiver, to the constructor
276
+ that filled the field and on to that constructor's callers - the worker's
277
+ shape, where the queue comes in at `New` and is read at `Run`. A URL becomes
278
+ the queue's name, its last path segment. Channels are `message` streams, not
279
+ domain events: a queue is point to point, and the event model is not asked to
280
+ match it. A port that takes exactly one other string beside the queue names
281
+ the message; a direct call names none. `DeleteMessage`, visibility changes and
282
+ `CreateQueue` say nothing about direction and are not read; SNS is not read.
283
+
284
+ `extract-terraform` reads the other half: the infrastructure the code runs
285
+ on, from the `*.tf` files of the directory given as `dir` - or the root, or
286
+ the one directory under it that holds any. Nothing is evaluated; the files
287
+ are read as syntax with `hashicorp/hcl`, and a name is followed the way a
288
+ subject is - to a literal, a variable's default, a local, the argument a
289
+ calling `module` block passed in, or a child module's output - and left out
290
+ with a warning when none of those reaches it, never taken from the label.
291
+ Local modules (`source = "./..."`) are read where they are called. A registry
292
+ module the reader knows - `terraform-aws-modules/lambda`, `sqs`, `sns`,
293
+ `s3-bucket` with its `notification` submodule, `dynamodb-table`, whether
294
+ named through the registry or as the git repository behind it with a ref -
295
+ is read as the resource it wraps: its inputs are that resource's attributes
296
+ under the module's names (`environment_variables`, `attributes = [...]`,
297
+ `event_source_mapping = {...}`, `subscriptions = {...}`, `create_dlq`), and
298
+ its outputs (`lambda_function_arn`, `queue_arn` or `sqs_queue_arn`,
299
+ `dead_letter_queue_arn`, `dynamodb_table_stream_arn`) are references to it,
300
+ so `module.queue` reads as `aws_sqs_queue` would; the dead-letter queue the
301
+ queue module makes beside itself is `module.queue.dlq`, and a layer the
302
+ lambda module makes is not a function. Any other registry module is noted
303
+ and skipped. A reference from one resource to another
304
+ (`aws_sqs_queue.orders.arn`) is the edge itself and needs no value. What
305
+ comes out: an `aws_sqs_queue` is a `message` channel and an `aws_sns_topic`
306
+ an `event` channel, each with `messages: []` and the facts in the doc - FIFO,
307
+ the dead-letter queue and its receive count from `redrive_policy`, the SNS
308
+ subscription that fills a queue, the S3 notification that fills either; an
309
+ `aws_lambda_function` is a component of kind `function` in the same context,
310
+ its runtime among its technologies - or the service itself when its name
311
+ slugs to the `service` option; an `aws_lambda_event_source_mapping` puts the
312
+ queue on the function as received, and a topic reaching the function through
313
+ a subscribed queue is listed on it too; a value in the function's
314
+ `environment` that reaches a queue, topic, table or bucket lists that on the
315
+ function with the variable's name, and without a direction, because
316
+ Terraform does not know one. `aws_s3_bucket`, `aws_dynamodb_table` (keys and
317
+ secondary indexes as the table), `aws_db_instance` and `aws_rds_cluster`
318
+ (kind from `engine`) are stores at the root, owned by the `service` option;
319
+ a queue or topic nothing in the module touches is listed on that service as
320
+ well, and says so. Kinesis, EventBridge, API Gateway, Step Functions,
321
+ ElastiCache, MSK and ECS resources are named once as not read yet.
322
+
251
323
  `extract-python-kafka` is the framework-independent Kafka enrichment for
252
324
  Python. It recognizes confluent-kafka, kafka-python and aiokafka by their
253
325
  imported client types, follows literal topics through constants, environment
@@ -654,6 +726,178 @@ line with its description, and as entity links of type `command`, each
654
726
  leading to the line of the runner file it was read from, when `sourceBaseUrl`
655
727
  says where the repository is.
656
728
 
729
+ ## Where it answers, what it dials: the manifests, and the cluster
730
+
731
+ A catalog that knows what a service provides and what it consumes still has
732
+ holes where the two do not meet by route alone: two services answer
733
+ `POST /foo`, and the caller's code says only that it posts to `$LEDGER_URL`.
734
+ What that variable holds is not in the code. It is in the Deployment, the
735
+ ConfigMap it reads, and the Service and Ingress objects that give the far end
736
+ a name. Two plugins read those - `extract-k8s` from the manifests in a
737
+ repository, `fetch-k8s` from a live cluster - and put two lists on the
738
+ service:
739
+
740
+ - **Hosts** - the names it answers on: a Kubernetes Service's name in its
741
+ short, namespaced, `svc` and fully qualified forms, and the hosts of the
742
+ Ingress or Gateway API HTTPRoute in front of it, from a rule whose backend
743
+ is that Service.
744
+ - **Dials** - the in-cluster names its workload is configured to reach, cut
745
+ out of its containers' `env` and the ConfigMaps they read.
746
+
747
+ The merge then has both facts in one place. A call that names a host, or a
748
+ caller whose manifests dial one, resolves against the providers of its route
749
+ when exactly one of them answers on that name, and the evidence says
750
+ `kubernetes-host` as the basis. A host only ever decides between providers of
751
+ the route; it never conjures a provider that does not have it.
752
+
753
+ ### What is never written
754
+
755
+ A manifest holds configuration, and configuration is where the passwords are.
756
+ The rule that keeps them out is a rule of shape, not a list of words:
757
+
758
+ - A `Secret`, a `SealedSecret`, an `ExternalSecret`, or any document with
759
+ `stringData` is passed over at its `kind`, before its body is decoded. A
760
+ `secretKeyRef` or `secretRef` is not followed.
761
+ - A value from `env` or a ConfigMap is reduced to the host it names, or to
762
+ nothing. A URL gives up its hostname and keeps nothing else - not the
763
+ scheme, the port, the path, the query, nor the user and password in front
764
+ of the host. A bare `host[:port]` gives up the port. `info`, `true`, a
765
+ token, a DSN written as `key=value` pairs: none of these is a host, and
766
+ none passes.
767
+ - What is left qualifies only when the cluster resolves it: a form the tree's
768
+ own Services answer to, or `<name>.<namespace>.svc[.cluster.local]`, the
769
+ one shape no name outside a cluster has. A bare `cart` from another
770
+ repository's manifests is not taken, because nothing distinguishes it from
771
+ a word.
772
+ - Warnings name files and directories, never values, because the build
773
+ report is committed.
774
+
775
+ The fragment carries the variable's name no more than its value: the
776
+ `environmentVariable` the HTTP client extractor records stays what it was, and
777
+ no value is attached to it.
778
+
779
+ ### The manifests: extract-k8s
780
+
781
+ Every `*.yaml` and `*.yml` under the root, or under the `paths` named in the
782
+ options; anything without `apiVersion` and `kind` - a compose file, a
783
+ workflow - is passed over silently. Multi-document files are split. A file
784
+ holding `{{` is a template and not YAML until rendered, so a directory of
785
+ Helm templates is passed over with one warning for the directory; render the
786
+ chart into the tree, or point `paths` at plain manifests, to have it read.
787
+ Kustomize is read without running it: the base and the overlays are all under
788
+ the root, and two documents of one kind and name - a Deployment and the patch
789
+ laid over it - fold into one, their env and labels unioned. `namespace`
790
+ narrows the read to one namespace; an object naming none is read either way.
791
+
792
+ The workload the step is about is the Deployment, StatefulSet, DaemonSet,
793
+ ReplicaSet, Job or CronJob named like the service, or labelled so with
794
+ `app.kubernetes.io/name` or `app`, or the only one in the tree. A Job or
795
+ CronJob makes the service's kind `job`; a Deployment says nothing about kind,
796
+ because a webapp and a worker both deploy that way. Several workloads and
797
+ none named is a warning, not a guess.
798
+
799
+ ### The cluster: fetch-k8s
800
+
801
+ The manifests say what should run; the cluster says what does, and for an
802
+ estate deployed by Helm or Argo the rendered objects are in no repository at
803
+ all. `fetch-k8s` asks `kubectl` for the workloads, Services, Ingresses,
804
+ Gateway API routes and ConfigMaps of the namespaces named - never for a
805
+ Secret - and answers with one fragment for every workload it saw, placed
806
+ into contexts and services by label. It runs in the host (portolan.0008)
807
+ because it needs the binary, the socket and kubectl's own credential, which
808
+ comes from the kubeconfig and is never in the manifest.
809
+
810
+ ```json
811
+ { "name": "k8s-cluster", "host": "fetch-k8s" }
812
+
813
+ {
814
+ "plugin": "k8s-cluster",
815
+ "in": ".",
816
+ "out": "data",
817
+ "options": {
818
+ "kubeContext": "prod-eu",
819
+ "namespaces": ["shop", "auth", "payments"],
820
+ "labels": { "context": "app.kubernetes.io/part-of", "service": "app.kubernetes.io/name" },
821
+ "namespaceContexts": { "shop-jobs": "shop" },
822
+ "cache": "data",
823
+ "out": "k8s-cluster.json"
824
+ }
825
+ }
826
+ ```
827
+
828
+ The context is the value of the `app.kubernetes.io/part-of` label and the
829
+ service the value of `app.kubernetes.io/name`; an estate that labels
830
+ differently names its own keys under `labels`. A workload without the
831
+ context label falls back to `namespaceContexts`; one with neither is counted
832
+ in a warning for its namespace and left out, not guessed at. A workload
833
+ without the service label is named after itself. Several workloads landing
834
+ on one service - a Deployment and the CronJob beside it - fold into one; a
835
+ service that is only jobs is a `job`, one with a Deployment beside them is
836
+ whatever its code says.
837
+
838
+ The same offline rule as fetch-git: with `PORTOLAN_OFFLINE` set, or in CI,
839
+ the fragment committed at `cache` is used unchanged; when the cluster cannot
840
+ be reached it is used with a warning that names the failed command and its
841
+ exit status, not the server; with no committed copy, that is an error and
842
+ not a short answer. What kubectl itself said on stderr goes to the terminal,
843
+ where the person at the keyboard can read the connection error, and not into
844
+ the build report, which is committed. Nothing but the fragment is written to
845
+ disk: the objects live in the process for the length of one call.
846
+
847
+ portolan.0011 records why both readers keep names and nothing else.
848
+
849
+ ## Where it runs: the GitOps tree, and the deployer
850
+
851
+ A service's manifests say what it is; what deploys them says where it
852
+ stands - which environment, cluster and namespace, from which directory of
853
+ which repository, tracking which revision. Two plugins read that, and the
854
+ merge lays one under the other (portolan.0012, portolan.0013):
855
+
856
+ - **extract-argocd** reads the Argo CD `Application` and `ApplicationSet`
857
+ objects in a GitOps tree, as wasm over the workspace, and answers with
858
+ what *should* run: one deployment per Application, basis `manifest`. An
859
+ ApplicationSet is expanded as far as a tree allows - `list` generators
860
+ whole, `git` directories and files by walking this repository, `matrix`
861
+ over those, the template rendered as Go `text/template` or fasttemplate
862
+ as it declared. A `clusters`, `pullRequest`, `scmProvider`, `merge` or
863
+ `plugin` generator, or a `git` one pointing at another repository, is a
864
+ warning naming the ApplicationSet and no rows. When the source path holds
865
+ a `kustomization.yaml`, the tool is kustomize and the images the overlay
866
+ pins are the declared images.
867
+ - **fetch-argocd** asks the Argo CD API for what *does* run - the revision
868
+ synced, the images running, the link - and commits it as a snapshot
869
+ beside a lock, basis `api` (see the fetchers above).
870
+
871
+ ```json
872
+ { "name": "argocd-gitops", "wasm": { "url": "file://plugins/portolan-go.wasm" } }
873
+
874
+ {
875
+ "plugin": "argocd-gitops",
876
+ "in": "examples/gitops",
877
+ "out": "examples/gitops/portolan",
878
+ "options": {
879
+ "repo": "github.com/shortlink-org/portolan",
880
+ "paths": ["bootstrap", "appsets"],
881
+ "labels": { "context": "app.kubernetes.io/part-of", "service": "app.kubernetes.io/name" },
882
+ "out": "argocd.json"
883
+ }
884
+ }
885
+ ```
886
+
887
+ An Application is placed on a service by the labels it carries -
888
+ `app.kubernetes.io/part-of` and `app.kubernetes.io/name`, the same pair
889
+ fetch-k8s reads off a workload, or whichever the manifest names under
890
+ `labels` - and, without them, by deploying from the service's repository
891
+ inside the service's directory. One that matches neither way is listed on
892
+ the Problems page as unclaimed rather than guessed at.
893
+
894
+ Where both plugins spoke for one Application the merge folds the two into
895
+ one row, basis `both`: the deployer's shape, the tree filling what the
896
+ deployer did not say, and the fields the two disagree on kept as the tree's
897
+ word under `drift` - a `deployment-drift` problem, and a chip on the
898
+ service page that says the difference in words. A row only the tree spoke
899
+ for wears `declared`.
900
+
657
901
  ## Outside the estate: an external with a contract
658
902
 
659
903
  A service calls things nobody here builds - a card network, a tax API, a
@@ -777,13 +1021,60 @@ called" - and `unresolved` is never raised, because a trace does not put the
777
1021
  far end in the catalog. A consumer span inside a trace opens a flow of its own
778
1022
  and is matched the same way, so one password change verifies both the
779
1023
  request's flow and the policy's. A root no flow opens is written down as
780
- `observed-<service>-<route>`, once per shape, with a summary saying how many
1024
+ `observed-<service>-<route>`, once per opening, with a summary saying how many
781
1025
  traces showed it.
782
1026
 
1027
+ Every recording that opened a flow is laid over it. A declared step carries
1028
+ `seen: { traces }`, how many recordings showed it. An rpc or an event a
1029
+ recording showed that the code does not declare is put into the flow after
1030
+ the declared step it followed, as a step with an id the code never gave
1031
+ (`seen1`, `seen2`) and a note saying in how many recordings; a lane such a
1032
+ hop needed is added after the declared ones. A store call is never added -
1033
+ the code's word on which repository method ran is the better one - and a
1034
+ consumer met on the way belongs to the flow it opens.
1035
+
1036
+ Where recordings part, the flow says so. What every recording showed after
1037
+ a step is a run of plain steps; where they went different ways is an `alt`
1038
+ (`seen-alt1`, or `alt1` in an observed flow) with a branch for each way,
1039
+ titled by the hop it starts with and counting the recordings that went it,
1040
+ each branch laid over the same way inside; what they all showed again
1041
+ after parting comes after the frame, once. A recording that went no
1042
+ further where others went on is the branch titled `otherwise`, with no
1043
+ steps and its count, so that a reader sees the hop is not always taken.
1044
+ Two observed recordings that open the same way are one flow built this
1045
+ way, so a happy path and a refusal read as one sequence that parts. The
1046
+ merge takes all of this from a second declaration of a flow because it
1047
+ says where it came from: `seen` on a step the first declaration does not
1048
+ have, or a frame whose every step and branch carries it.
1049
+
1050
+ A recording is also kept as an example of each flow it showed, under
1051
+ `examples` on the flow: the recording and trace it came from, when it ran,
1052
+ how long it took, and for each step the span's name, its length and an
1053
+ allowlist of its attributes - `http.route`, `http.request.method`,
1054
+ `http.response.status_code`, `rpc.service`, `rpc.method`, `db.operation.name`,
1055
+ `db.collection.name`, `messaging.destination.name`, `event.name`,
1056
+ `server.address` and a few more of that kind. A query text, a header, a full
1057
+ URL or a path with an id in it is somebody's data and is never carried; the
1058
+ list is closed in `examples.go`, and a new attribute is added there with the
1059
+ argument for it. `examples` in the options says how many recordings a flow
1060
+ keeps, the ones that show the most of it first: five when unset, zero for
1061
+ none.
1062
+
1063
+ A recording added from the page lands under the project's
1064
+ `telemetry/recordings/` and widens (or adds) the project's `otel` verify step
1065
+ to read that directory, so every run after reads it too; the page runs the
1066
+ generator over a copy of the workspace first and shows which flows the
1067
+ recording showed before anything is written (portolan.0014).
1068
+
783
1069
  `service.name` is matched to the one service whose slug it is, `event.name` to
784
1070
  the one event whose `wire.name` it is, or failing that to the one event of the
785
1071
  publisher's with that last segment; `services` and `events` in the options say
786
- otherwise where an estate's names differ. A publish span whose
1072
+ otherwise where an estate's names differ. A server span's route is matched to
1073
+ the operation whose `http` verb and path template it fits; `routes` in the
1074
+ options names an operation for a route the estate spells another way - a
1075
+ prefix a gateway adds, a path the document writes differently - as
1076
+ `{ "POST /api/v1/sessions": "login" }`, and the name has to be one the
1077
+ service's interface declares. A publish span whose
787
1078
  `messaging.destination.name` is not the event's `wire.channel` is a warning:
788
1079
  the event went out, but not where the code says it does.
789
1080
 
@@ -863,7 +1154,7 @@ trusted not to write the tree it reads, the same trust a process plugin has
863
1154
  today; a `sha256` pins that trust to a build.
864
1155
 
865
1156
  `process` is the escape hatch for a plugin that needs a toolchain: the typed Go
866
- HTTP client analyzer and the Rust, Java, Python and TypeScript extractors run in
1157
+ HTTP client analyzer and the Rust, Java, Python, PHP and TypeScript extractors run in
867
1158
  their own runtimes. It gets the same protocol
868
1159
  and none of the sandbox, which is the trade being made and the reason it is
869
1160
  not the default. It declares `command` and an `args` array; the host never
@@ -0,0 +1,44 @@
1
+ {
2
+ "type": "object",
3
+ "additionalProperties": false,
4
+ "properties": {
5
+ "repo": {
6
+ "type": "string",
7
+ "description": "The repository this tree is, spelled the way `Service.repo` spells it. A git generator pointing at another repository cannot be walked and is passed over with a warning; left out, every git generator is taken to mean this tree.",
8
+ "examples": ["github.com/acme/gitops"]
9
+ },
10
+ "paths": {
11
+ "type": "array",
12
+ "items": { "type": "string" },
13
+ "description": "Directories under the input root that hold the Applications and ApplicationSets, so overlays and charts beside them are not read for Argo CD objects. Left out, the whole root is walked.",
14
+ "examples": [["bootstrap", "appsets"]]
15
+ },
16
+ "environmentLabel": {
17
+ "type": "string",
18
+ "default": "env",
19
+ "description": "The Application label that names the environment. An Application without it is placed by the cluster it deploys to."
20
+ },
21
+ "labels": {
22
+ "type": "object",
23
+ "additionalProperties": false,
24
+ "description": "Which Application labels name the service it deploys. The defaults are the Kubernetes recommended labels, the same ones fetch-argocd and fetch-k8s read.",
25
+ "properties": {
26
+ "context": {
27
+ "type": "string",
28
+ "description": "The label whose value is the bounded context's slug.",
29
+ "default": "app.kubernetes.io/part-of"
30
+ },
31
+ "service": {
32
+ "type": "string",
33
+ "description": "The label whose value is the service's slug.",
34
+ "default": "app.kubernetes.io/name"
35
+ }
36
+ }
37
+ },
38
+ "out": {
39
+ "type": "string",
40
+ "description": "Name of the fragment file, inside the step's output directory.",
41
+ "default": "argocd.json"
42
+ }
43
+ }
44
+ }
@@ -25,6 +25,7 @@ HERE = os.path.dirname(os.path.abspath(__file__))
25
25
  DESCRIPTOR = {
26
26
  "name": "extract-celery",
27
27
  "summary": "Reads Celery task declarations, the calls that enqueue them and the routes that place them into work queues and source-backed job flows.",
28
+ "category": "messaging",
28
29
  "phases": ["extract"],
29
30
  }
30
31
 
@@ -0,0 +1,213 @@
1
+ # extract-csharp-ddd
2
+
3
+ A C# tree laid out by module and layer in, a catalog fragment out - three
4
+ kinds, when the API host proves an HTTP contract and the database project a
5
+ schema. The .NET twin of `extract-php-ddd`: like it, this reads a layout
6
+ rather than a framework, and the layout is the claim. Nothing is annotated
7
+ for the catalog; the directory a class sits in and the base it extends say
8
+ what it is.
9
+
10
+ The layout is the one [kgrzybek's modular-monolith-with-ddd](https://github.com/kgrzybek/modular-monolith-with-ddd)
11
+ made common in .NET - `src/Modules/<Module>/{Domain,Application,Infrastructure,IntegrationEvents}`
12
+ with one API host under `src/API` and one SQL Server database project under
13
+ `src/Database` - and it is that project the site ships as a profile, read by
14
+ this extractor alone.
15
+
16
+ Written in C# and run as a process plugin, `dotnet plugins/extract-csharp-ddd/bin/portolan-extract-csharp-ddd.dll`,
17
+ built by `npm run plugins:build` with the .NET SDK (8 or later). The parser
18
+ is Roslyn, the compiler's own, and the one dependency; every `.cs` under the
19
+ root goes into one `Compilation`, so a partial class is one type, a base
20
+ chain is followed across files and modules, and the type of `new X(...)` or
21
+ of a variable is a fact rather than a guess. **The application is never
22
+ built or run, and none of its packages is restored.** What comes from NuGet
23
+ - MediatR's `INotificationHandler`, EF's `IEntityTypeConfiguration`,
24
+ ASP.NET's `ControllerBase` - is an error type with a name, and every rule
25
+ that touches one goes by the name as written on the base list. Only the
26
+ runtime's own assemblies are referenced, so that `Guid`, `string` and
27
+ `List<T>` read as themselves.
28
+
29
+ ## The layout it reads
30
+
31
+ ```
32
+ src/Modules/<Module>/Domain/<Aggregate>/ the model: root, entities, value objects, events, ports
33
+ src/Modules/<Module>/Application/<Group>/ the use cases: commands, queries, their handlers, notification handlers
34
+ src/Modules/<Module>/Infrastructure/ the adapters: repositories, EF configurations, the bus subscriptions
35
+ src/Modules/<Module>/IntegrationEvents/ what the module tells the other modules
36
+ src/API/**/Modules/<Module>/*Controller.cs the HTTP edge of that module
37
+ src/Database/**/Structure/<schema>/Tables/ the tables, one file each, T-SQL
38
+ src/Database/**/Structure/<schema>/Views/ the views over them
39
+ src/BuildingBlocks/ the bases: Entity, ValueObject, DomainEventBase, IntegrationEvent
40
+ ```
41
+
42
+ `modules`, `api` and `database` in the options rename the three roots. A
43
+ directory named `Tests`, or ending in `Tests`, is not read; neither is `bin/`
44
+ or `obj/`.
45
+
46
+ ## What becomes what
47
+
48
+ **Context.** A directory under `src/Modules` with `Domain` or `Application`
49
+ under it, with its slug as id - `user-access` for `UserAccess`.
50
+ `classification` in the options applies to every module the tree declares;
51
+ nothing in the tree says which is core.
52
+
53
+ **Service.** One per module, `<module>.module` - `meetings.module` - because
54
+ a module here has its own schema, its own outbox, its own composition root
55
+ and its own slice of the API. There is one deployable, and the catalog says
56
+ so where it matters: a call from one module to another is drawn as a `call`
57
+ step with a note that it is in process. The controllers filed under the
58
+ module's directory of the API host are its HTTP interfaces, one per
59
+ controller, `meetings.module.meetings`.
60
+
61
+ **Aggregate.** A directory under `Domain/` whose own files declare a root: a
62
+ class implementing `IAggregateRoot` or extending a class named
63
+ `AggregateRoot` (the event-sourced base Payments keeps in its `SeedWork`).
64
+ Subdirectories with no root of their own fold in - `Events/`, `Rules/` - and
65
+ one with a root is an aggregate in its own right, `Members/MemberSubscriptions`.
66
+ `SharedKernel`, `SeedWork`, `Shared`, `Rules`, `Events` and `Exceptions` at
67
+ the top of `Domain/` are not aggregates. A directory with types and no root
68
+ is a group the application layer may claim as a `model-group`; unclaimed, it
69
+ is reported.
70
+
71
+ The root and every class extending `Entity` are the entities, the root first.
72
+ Their fields are the public properties inherited from a base that is not the
73
+ framework's, then their own members in source order, a private `_title`
74
+ read as `title`, the `_domainEvents` list left out. A value object is a
75
+ class extending `ValueObject`, `TypedIdValueBase` or `AggregateId<T>`, with
76
+ its public properties read up the base chain, so `MeetingId` has the base's
77
+ `value: Guid`; one with no fields is reported and not written. A C# `enum` in
78
+ the directory is a closed set with its members, `[Obsolete]` carried as
79
+ deprecated. Rules, exceptions, domain services and `I*Context` interfaces are
80
+ not shape and are skipped.
81
+
82
+ **Domain event.** A class extending `DomainEventBase` or implementing
83
+ `IDomainEvent`, in the aggregate's directory. Its fields are its own public
84
+ properties - `Id` and `OccurredOn` are the envelope. It has no wire: a
85
+ domain event here is a MediatR notification in the same transaction, and a
86
+ notification replayed from the outbox after it. Its consumers are the
87
+ module's `INotificationHandler<TheEvent>` classes, and its
88
+ `INotificationHandler<TheNotification>` classes where the notification
89
+ extends `DomainNotificationBase<TheEvent>`, each `declared` with the
90
+ handler's name. The id keeps the class name whole,
91
+ `meetings.module.meetings.MeetingCreatedDomainEvent`, because the
92
+ integration event that follows it is a different fact with the same stem.
93
+
94
+ **Integration event.** A class extending `IntegrationEvent` under the
95
+ module's `IntegrationEvents/`. Its publisher is the module whose application
96
+ layer calls `IEventsBus.Publish(new X(...))`, and its aggregate is the one
97
+ whose domain event the publishing handler's notification wraps; an event
98
+ nobody publishes is reported and filed under the owning module's
99
+ `integration-events` group. Its wire is the class name on its own channel,
100
+ `<bus>.<Event>` with `bus` from the options, `integration-events` by
101
+ default, because the code names the class `InMemoryEventBus` and nothing
102
+ else. Its consumers are the modules whose
103
+ infrastructure says `SubscribeToIntegrationEvent<X>` or
104
+ `new IntegrationEventGenericHandler<X>()`, each with the
105
+ `INotificationHandler<X>` that hears it - `declared` when both are there,
106
+ `unresolved` and reported when a module subscribes without a handler or
107
+ handles without subscribing.
108
+
109
+ **Channel.** The bus has no named channels - `InMemoryEventBus` routes by
110
+ type and a module subscribes to a type - so each integration event is a
111
+ channel of its own, `<bus>.<Event>`, of kind `event`: the module that
112
+ publishes it sends on it, the modules that subscribe receive. Every module
113
+ whose application layer calls `ICommandsScheduler.EnqueueAsync(new C(...))`
114
+ has a `job` channel, `<module>.internal-commands`: the command written to
115
+ its `InternalCommands` table in the same transaction, read by the
116
+ ProcessInternalCommands job and handed to its handler. A command enqueued
117
+ that no handler in the module takes is reported.
118
+
119
+ **Operation.** A class under `Application/` whose base list says
120
+ `ICommandHandler<C>` or `ICommandHandler<C, R>` or `IQueryHandler<Q, R>`; the
121
+ message names the operation, `create-meeting` for `CreateMeetingCommand`.
122
+ Its fields are the message's widest constructor's parameters, or its
123
+ settable properties when it has none; its doc the handler's `<summary>`, or
124
+ the message's; its source the handler's `Handle`. The aggregate is the
125
+ domain directory named like the first directory under `Application/`
126
+ (`Application/Meetings/CreateMeeting/` is `Domain/Meetings`), and a group
127
+ with no such directory - `Countries`, `Authentication` - is a model-group
128
+ named after it. A message extending `InternalCommandBase` is reached from
129
+ the job channel; one implementing `IRecurringCommand` runs on a Quartz
130
+ schedule. A handler under `Infrastructure/` is plumbing - `ProcessOutboxCommandHandler`
131
+ - and is not read. `exposedBy` names the actions of the module's own
132
+ controllers that new the message up; an action in another module's
133
+ controller is a call across contexts and lives in the flow.
134
+
135
+ **Store.** One per schema of the database project, owned by the module
136
+ whose EF configurations `ToTable("X", "schema")` into it, else the module
137
+ named like it, else the module that writes it; a read owns nothing, and a
138
+ schema with no owner - `app`, written by the building blocks - is reported
139
+ and not read. The module's home schema is its `db`, `meetings.module.db`;
140
+ a second schema keeps its name. `storeKind` in the options says what the
141
+ schemas live in, `other` for SQL Server. A table is read from its
142
+ `CREATE TABLE`: columns with type and nullability, the primary key from the
143
+ constraint or the column, foreign keys, and the indexes created beside it.
144
+ A view is read from its `CREATE VIEW`: the select list's names, each typed
145
+ from the column it selects when the alias resolves, the tables it reads,
146
+ and the SELECT itself as the definition.
147
+
148
+ An `IEntityTypeConfiguration<T>` in the module's infrastructure says which
149
+ table holds T: the `ToTable` in `Configure` is the table, `Property<X>("_f").HasColumnName("C")`
150
+ and `Property(x => x.P)` map its columns to T's fields, an `OwnsOne` maps
151
+ the value object's parts to the owner's field, and an `OwnsMany` with a
152
+ `ToTable` of its own is the child's table. The table `persists` the block
153
+ and takes the role `aggregate-root` or `child`; a table nobody maps is
154
+ `outbox` when it is the outbox, `projection` when the application
155
+ layer writes it with SQL, `lookup` when it is only ever read, `other`
156
+ otherwise. A `Messages` or `Streams` table is where an event-sourced
157
+ module's `IAggregateStore` keeps its streams.
158
+
159
+ Accesses come from two places. A class in the infrastructure implementing an
160
+ `I*Repository` port from the domain, or `IAggregateStore`, reads and writes
161
+ the table that holds the port's root - `write MeetingRepository.AddAsync` -
162
+ by the method's name: `Add`, `Save`, `Update`, `Append` write, `Remove` and
163
+ `Delete` delete, the rest read. Every string in a method of the application
164
+ or infrastructure layer is read as SQL: `FROM`, `JOIN`, `INSERT INTO`,
165
+ `UPDATE` and `DELETE FROM` against a schema-qualified name are accesses by
166
+ that method; a view read is an access on the tables the view reads; a name
167
+ the database project does not declare is reported - which is how the
168
+ project's `v_Countriess` was found.
169
+
170
+ **Flow.** One per controller action: the request in, the command or query
171
+ the action news up, then what the handler does, followed through the
172
+ classes it holds and the domain methods it calls, by symbol. A call on a
173
+ port is a step to the store; a Dapper query a step to the tables its SQL
174
+ names; `AddDomainEvent(...)` an event raised in process, drawn as a
175
+ self-message with the event's id; `IEventsBus.Publish(...)` an event on the
176
+ bus with a `message` handoff; `ICommandsScheduler.EnqueueAsync(...)` a `job`
177
+ handoff to the module's internal queue; a command handed to another module's
178
+ facade a `call` across the boundary, followed there. One flow per
179
+ notification handler of a domain event, from the event in; one per
180
+ subscription a module handles, from the bus in; one per internal command,
181
+ from the queue in; one per recurring command, on its schedule. Each flow
182
+ carries the trigger it was read from: `http`, `event`, `message`, `job` or
183
+ `scheduled`.
184
+
185
+ **HTTP.** One OpenAPI 3.1 document per module with controllers,
186
+ `openapi.<module>.yaml` (`openapiOut` in the options), one operation per
187
+ action: the path from `[Route]` and `[HttpGet("...")]` with `[controller]`
188
+ filled in, the `{parameters}` typed from the action's, the `[FromBody]`
189
+ class as the request schema one level deep, `[ProducesResponseType]` as the
190
+ responses, `[HasPermission]` as `x-portolan-permission`, the action's
191
+ `<summary>` or its name as the summary.
192
+
193
+ ## Options
194
+
195
+ See `options.schema.json`. `modules`, `api` and `database` default to
196
+ `src/Modules`, `src/API` and `src/Database`; `bus` to `integration-events`;
197
+ `storeKind` to `other`; `repo` and `classification` to nothing.
198
+
199
+ ## Tests
200
+
201
+ ```bash
202
+ dotnet run --project plugins/extract-csharp-ddd/test
203
+ ```
204
+
205
+ `testdata/mymeetings` is a small tree in the layout: three modules, one of
206
+ them event-sourced, a module publishing an integration event two others
207
+ hear, an internal command enqueued from a subscription, a recurring command,
208
+ an EF configuration with owned types and a child table, views, and a
209
+ controller filed under a module the tree does not have. `expected.json`,
210
+ `expected-stores.json` and the `openapi.*.yaml` beside it are what the
211
+ extractor writes for it, and `expected-warnings.txt` what it says on the
212
+ way; `UPDATE_GOLDEN=1` writes them again after a deliberate change, and the
213
+ diff is the review.